Building a WebView screensaver on macOS 26 without Xcode

2026-05-06 11:33 (11 days ago)
Bundle Houdini
Play a song themed on this article

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 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:

import ScreenSaver
import WebKit

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

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

    public override func startAnimation() {
        super.startAnimation()
        ensureFullSize()
        installWebView()
        webView?.frame = webViewTargetFrame()
    }

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

    // Defends against bounds being delivered in backing pixels — see Trap 2.
    private func webViewTargetFrame() -> NSRect {
        if isPreviewMode, bounds.width > 1, bounds.height > 1 {
            return bounds
        }
        let screenSize = window?.screen?.frame.size
            ?? NSScreen.main?.frame.size
            ?? .zero
        if screenSize.width > 1, screenSize.height > 1 {
            if bounds.width > 1, bounds.height > 1 {
                return NSRect(
                    x: 0, y: 0,
                    width: min(bounds.width, screenSize.width),
                    height: min(bounds.height, screenSize.height)
                )
            }
            return NSRect(origin: .zero, size: screenSize)
        }
        return bounds
    }

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

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

        let wv = WKWebView(frame: webViewTargetFrame(), configuration: config)
        // No autoresizingMask (see Trap 2). The frame is set explicitly in callbacks.
        if #available(macOS 13.0, *) { wv.underPageBackgroundColor = .black }
        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 viewDidMoveToWindow() {
        super.viewDidMoveToWindow()
        webView?.frame = webViewTargetFrame()
    }
    public override func layout() {
        super.layout()
        webView?.frame = webViewTargetFrame()
    }
    public override func setFrameSize(_ newSize: NSSize) {
        super.setFrameSize(newSize)
        webView?.frame = webViewTargetFrame()
    }
    public override func resizeSubviews(withOldSize oldSize: NSSize) {
        super.resizeSubviews(withOldSize: oldSize)
        webView?.frame = webViewTargetFrame()
    }
}

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

build.sh

#!/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 (= 2x viewport on external non-Retina displays)

The legacyScreenSaver host can deliver the ScreenSaverView's bounds in backing pixels, not in logical points. Specifically, you may see bounds = 3840x2160. Normally AppKit frame.size is in logical points, but the screensaver's remote view path can hand you backing pixels.

The nasty part is that this accidentally works on a Retina builtin display (scale=2). Logical 1920x1080 and backing 3840x2160 happen to coincide, so passing bounds straight into the WKWebView frame still looks right, and you never notice the bug.

But it falls apart on an external FullHD over HDMI (scale=1). With backing pixel = logical point = 1920x1080, the bounds = 3840x2160 is now treated as 3840x2160 logical points. The WKWebView lays out at 2x screen size and only the bottom-left quarter is visible.

(An earlier version of this post said "don't touch it, leave bounds alone." That conclusion came from testing on a Retina builtin only and is wrong on non-Retina externals.)

Fix: clamp the WKWebView frame against window?.screen?.frame.size, which is always in logical points. In preview (the System Settings thumbnail), bounds is a small embed area and should be respected as-is.

private func webViewTargetFrame() -> NSRect {
    if isPreviewMode, bounds.width > 1, bounds.height > 1 {
        return bounds
    }
    let screenSize = window?.screen?.frame.size
        ?? NSScreen.main?.frame.size
        ?? .zero
    if screenSize.width > 1, screenSize.height > 1 {
        if bounds.width > 1, bounds.height > 1 {
            return NSRect(
                x: 0, y: 0,
                width: min(bounds.width, screenSize.width),
                height: min(bounds.height, screenSize.height)
            )
        }
        return NSRect(origin: .zero, size: screenSize)
    }
    return bounds
}

Don't set autoresizingMask = [.width, .height] on the WKWebView. It will follow the parent's bounds and get dragged back into the pixel-scale value. Instead, set the frame explicitly in layout / setFrameSize / resizeSubviews / viewDidMoveToWindow / startAnimation.

Stash isPreview (from init?(frame:isPreview:)) into a property so webViewTargetFrame() can branch on it.

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. 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.

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

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:

import os.log
let saverLog = OSLog(subsystem: "com.example.screensaver", category: "main")
os_log("%{public}@", log: saverLog, type: .default, "message")
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:

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. To wipe them, delete ~/Library/WebKit/com.apple.ScreenSaver.Engine.legacyScreenSaver. Or just use WKWebViewConfiguration.websiteDataStore = .nonPersistent() to skip persistence entirely.
  • 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. About 90% of the surprise comes from three things: "initial bounds is 0x0", "bounds in backing pixels", and "transitions stop because visibility is hidden". Handle those three and the rest falls into place.

In particular, Trap 2 (backing pixels) is invisible if you only test on a Retina builtin display. Always verify on an external non-Retina display (e.g. FullHD over HDMI) before calling it done.

Please rate this article (No signup or login required)
Currently unrated
The author runs the application development company Cyberneura.
We look forward to discussing your development needs.

Categories

Archive