---
slug: "macos-26-screensaver-webview-without-xcode"
title: "Building a WebView screensaver on macOS 26 without Xcode"
description: "How to build a macOS screensaver (.saver bundle) with swiftc and a shell script — no Xcode project needed — that displays a webapp in a WKWebView. Verified on macOS 26 (Tahoe). Covers four traps: bounds=0x0, Retina backing pixels, visibility=hidden pausing CSS transitions, and accumulating WebView instances."
url: "https://www.ytyng.com/en/blog/macos-26-screensaver-webview-without-xcode"
publish_date: "2026-05-06T11:33:13.576Z"
created: "2026-05-06T11:33:13.577Z"
updated: "2026-05-06T12:29:53.277Z"
categories: []
keywords: ""
featured_image_url: "https://media.ytyng.com/resize/20260506/5599e97347f24838b39f5877756f54e0.png.webp?width=768"
has_video: false
has_music: false
video_urls: []
music_urls: []
lang: "en"
---

# Building a WebView screensaver on macOS 26 without Xcode

## Why this post exists

There is almost no documentation on building a macOS screensaver (`.saver` bundle) **without an Xcode project**. Apple's official material is an Objective-C era leftover, and the modern Swift / AppKit / WKWebView combo isn't covered. On macOS 26 (Tahoe), the host process for screensavers is even named `legacyScreenSaver` — a clear signal that this isn't a corner of the system Apple is investing in for new development.

Still, if you just want to:

- run a screensaver on your own Mac,
- show a small web page through a WebView,
- and not bother launching Xcode,

then a one-shot `swiftc` build is much nicer than a full project. This post documents what I ended up with on macOS 26, including the four traps that wasted a few hours.

## What you need

- macOS 14 (Sonoma) or later. Tested on macOS 26 (Tahoe), Apple Silicon.
- Xcode Command Line Tools (`xcode-select --install`) for `swiftc` / `lipo` / `codesign`.
- Xcode itself: not required.

## Bundle layout

```
MyScreenSaver.saver/
└── Contents/
    ├── Info.plist
    └── MacOS/
        └── MyScreenSaver   ← Mach-O bundle (universal)
```

A `.saver` is just a directory (a Bundle). The Mach-O inside is a **bundle** (`mh_bundle`), not an executable. You produce one with `swiftc -emit-library -Xlinker -bundle`.

## Info.plist

The minimum that makes macOS happy:

```xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <key>CFBundleExecutable</key>
    <string>MyScreenSaver</string>
    <key>CFBundleIdentifier</key>
    <string>com.example.screensaver</string>
    <key>CFBundleName</key>
    <string>MyScreenSaver</string>
    <key>CFBundlePackageType</key>
    <string>BNDL</string>
    <key>CFBundleShortVersionString</key>
    <string>0.1.0</string>
    <key>LSMinimumSystemVersion</key>
    <string>14.0</string>
    <key>NSPrincipalClass</key>
    <string>MyScreenSaverView</string>
</dict>
</plist>
```

What matters:

- `CFBundlePackageType` must be `BNDL`, not `APPL`.
- `NSPrincipalClass` is the **Objective-C runtime name**, not the Swift fully-qualified name. Pin it with `@objc(...)` on the Swift class.

## Swift source

`Sources/MyScreenSaverView.swift`:

```swift
import ScreenSaver
import WebKit

@objc(MyScreenSaverView)
public final class MyScreenSaverView: ScreenSaverView {
    private var webView: WKWebView?

    public override init?(frame: NSRect, isPreview: Bool) {
        super.init(frame: frame, isPreview: isPreview)
        wantsLayer = true
        layer?.backgroundColor = NSColor.black.cgColor
        animationTimeInterval = 1.0 / 30.0
        autoresizingMask = [.width, .height]
    }
    public required init?(coder: NSCoder) { super.init(coder: coder) }

    public override func startAnimation() {
        super.startAnimation()
        ensureFullSize()
        installWebView()
    }

    private func ensureFullSize() {
        if bounds.width > 1, bounds.height > 1 { return }
        let target = NSScreen.main?.frame.size ?? NSSize(width: 1920, height: 1080)
        setFrameSize(target)
    }

    private func installWebView() {
        guard webView == nil else { return }

        let config = WKWebViewConfiguration()
        // ↓ visibility spoof + transition override go here (see below)

        let wv = WKWebView(frame: bounds, configuration: config)
        wv.autoresizingMask = [.width, .height]
        wv.setValue(false, forKey: "drawsBackground")
        if #available(macOS 13.3, *) { wv.isInspectable = true }
        addSubview(wv)
        webView = wv

        if let url = URL(string: "https://example.com/") {
            wv.load(URLRequest(url: url))
        }
    }

    public override func layout() {
        super.layout()
        webView?.frame = bounds
    }
    public override func setFrameSize(_ newSize: NSSize) {
        super.setFrameSize(newSize)
        webView?.frame = bounds
    }
}
```

`@objc(MyScreenSaverView)` pins the Objective-C runtime name so the value lines up with `NSPrincipalClass` in `Info.plist`.

## build.sh

```bash
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "${BASH_SOURCE[0]}")"

NAME=MyScreenSaver
BUNDLE=$NAME.saver
BUILD=build/$BUNDLE
SDK=$(xcrun --sdk macosx --show-sdk-path)
DEPLOY=14.0

rm -rf "$BUILD"
mkdir -p "$BUILD/Contents/MacOS"
cp Info.plist "$BUILD/Contents/Info.plist"

COMMON=(
  -sdk "$SDK"
  -framework ScreenSaver -framework WebKit -framework AppKit
  -emit-library -Xlinker -bundle
  -module-name "$NAME" -O
)

swiftc -target arm64-apple-macos$DEPLOY  "${COMMON[@]}" \
       -o "$BUILD/Contents/MacOS/arm64.bin"  Sources/*.swift
swiftc -target x86_64-apple-macos$DEPLOY "${COMMON[@]}" \
       -o "$BUILD/Contents/MacOS/x86_64.bin" Sources/*.swift

lipo -create "$BUILD/Contents/MacOS/arm64.bin" "$BUILD/Contents/MacOS/x86_64.bin" \
     -output "$BUILD/Contents/MacOS/$NAME"
rm "$BUILD/Contents/MacOS/arm64.bin" "$BUILD/Contents/MacOS/x86_64.bin"

codesign --force --sign - --timestamp=none "$BUILD"
echo "Built: $BUILD"
```

`./build.sh` produces `build/MyScreenSaver.saver` — a universal binary (arm64 + x86_64), ad-hoc signed.

## Four traps on macOS 26

### Trap 1: initial bounds is 0x0

`init?(frame: NSRect, isPreview: Bool)` sometimes hands you `(0, 0, 0, 0)`. `viewDidMoveToWindow` may also see `bounds = .zero` and `window?.screen == nil`.

Fix: when bounds is empty, force `setFrameSize` with `NSScreen.main?.frame.size`. Once bounds becomes non-empty, leave it alone. If you let bounds stay at 0x0, the WKWebView locks in a 0x0 viewport and CSS units like `100vh` / `100vw` collapse.

### Trap 2: bounds may arrive in backing pixels (3840x2160)

On a Retina 1920x1080 logical display, `bounds` may come in as `3840x2160` — the 2x backing buffer. Normally AppKit `frame.size` is in logical points, but the screensaver's remote view path can deliver backing pixels.

Fix: don't try to "normalize" it. CSS `100vw` / `100vh` is driven by `window.innerWidth` / `Height` (logical viewport), so the WebView lays out correctly even when the host bounds is in backing pixels. Forcing `3840x2160` back to `1920x1080` destroys the real Retina backing value and your output gets jagged.

### Trap 3: CSS transitions stop because visibility is `hidden`

This is the WKWebView trap I wrote up separately: [When CSS opacity transition silently fails inside WKWebView](https://www.ytyng.com/blog/wkwebview-opacity-transition-not-working). Inside the screensaver host process, `document.visibilityState === 'hidden'`, which makes WebKit pause CSS transitions and `requestAnimationFrame`.

Fix: inject a `WKUserScript` at document-start that overrides `Document.prototype.visibilityState` to `'visible'`, and another that injects CSS to set `transition: none` and `opacity: 1` on the relevant classes.

```swift
let visibilityOverride = """
try {
  Object.defineProperty(Document.prototype, 'hidden', {
    configurable: true, get: function() { return false; }
  });
  Object.defineProperty(Document.prototype, 'visibilityState', {
    configurable: true, get: function() { return 'visible'; }
  });
} catch (e) {}
"""

config.userContentController.addUserScript(
  WKUserScript(source: visibilityOverride,
               injectionTime: .atDocumentStart,
               forMainFrameOnly: false)
)
```

### Trap 4: WKWebView instances accumulate

Every time the System Settings preview is shown, a new `ScreenSaverView` (and a new `WKWebView`) is created. They are not reliably released when the preview goes away. The Develop menu in Safari starts piling up entries.

Fix: in `stopAnimation`, call `webView.removeFromSuperview()`, set `navigationDelegate = nil`, and call `stopLoading()`. You can't fully prevent the leak (the lifecycle is owned by the screensaver host), but the accumulation slows down meaningfully.

## Install and reload

```bash
cp -R build/MyScreenSaver.saver "$HOME/Library/Screen Savers/"
killall legacyScreenSaver 2>/dev/null
killall ScreenSaverEngine 2>/dev/null
```

The `legacyScreenSaver` process holds the bundle through `mmap`, so after a rebuild **you must kill the process** for the new binary to load. Toggling System Settings → Screen Saver to a different saver and back also works.

## Debugging

`NSLog` / `os_log` from a saver bundle is hard to find with the default predicate. Tag your own subsystem and stream from it:

```swift
import os.log
let saverLog = OSLog(subsystem: "com.example.screensaver", category: "main")
os_log("%{public}@", log: saverLog, type: .default, "message")
```

```bash
log stream --info | grep 'com.example.screensaver'
```

For the inside of the WebView, set `webView.isInspectable = true` (macOS 13.3+). Safari → Develop menu → your Mac shows the WebView and you can attach. To pull values out programmatically, poll from Swift via `evaluateJavaScript`:

```swift
webView.evaluateJavaScript("JSON.stringify({inner: [innerWidth, innerHeight], vis: document.visibilityState})") {
  result, _ in
  if let s = result as? String { os_log("%{public}@", log: saverLog, type: .default, s) }
}
```

That gives you a single `log stream` to follow both native and web state.

## Limitations

- Ad-hoc signed bundles can't be distributed to other Macs. You need a Developer ID + notarization for that.
- ServiceWorker and localStorage work, but they persist inside the screensaver host's sandbox. If you need to wipe them, delete `~/Library/WebKit/com.apple.ScreenSaver.Engine.legacyScreenSaver`.
- On a multi-monitor setup, one `ScreenSaverView` is created per display, and each one runs its own WebView. Watch for bandwidth / CPU cost.

## Closing

Apple isn't pushing this surface forward, but for personal-use WebView screensavers on your own Mac, it's perfectly serviceable. Being able to build with one shell script feels good. Roughly 90% of the surprise comes from "initial bounds is 0x0" and "transitions stop because visibility is hidden" — handle those two and the rest falls into place.
