Building a WebView screensaver on macOS 26 without Xcode

2026-05-06 11:33 (58 minutes ago)
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 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?

    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

#!/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. 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. 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.

Please rate this article
Currently unrated
The author runs the application development company Cyberneura.
We look forward to discussing your development needs.

Categories

Archive