Building a WebView screensaver on macOS 26 without Xcode

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

Why this post exists

There is almost zero information on building a macOS screensaver (.saver bundle) without an Xcode project. Apple's official documentation is a legacy of the Objective-C era, and the combination with Swift / modern AppKit / WKWebView is not officially documented. On macOS 26 (Tahoe), the process that hosts screensavers is named legacyScreenSaver, which suggests that this is an area where Apple does not actively recommend new development.

Even so, for uses like these:

  • I only want to run it on my own Mac
  • I want a screensaver that shows a small web page in a WebView
  • It is not worth launching Xcode for

being able to build with a single swiftc command feels far better. I'll write it up here, including the points where I got stuck when actually running it on macOS 26.

What you need

  • macOS 14 (Sonoma) or later. Verified on macOS 26 (Tahoe), Apple Silicon
  • Xcode Command Line Tools (xcode-select --install). This installs swiftc / lipo / codesign
  • Xcode itself is not needed

Bundle layout

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

A .saver is in fact a directory (a Bundle). The Mach-O inside is not an executable but is in bundle format (mh_bundle). You generate it with swiftc -emit-library -Xlinker -bundle.

Info.plist

It works with just this minimum.

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

The most important points are these.

  • CFBundlePackageType = BNDL (not an executable)
  • NSPrincipalClass takes the name you specified with @objc(...) on the Swift class. Use the ObjC runtime name, not the full Swift name (ModuleName.ClassName)

Swift source

Sources/MyScreenSaverView.swift is as follows.

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)
    }

    // Handles the case where bounds is passed in backing pixels. See Trap 2 for details.
    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()
        // ↓ set up the visibility spoof + transition disabling here (described below)

        let wv = WKWebView(frame: webViewTargetFrame(), configuration: config)
        // Do not use autoresizingMask (see Trap 2). The frame is updated 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) fixes the ObjC runtime name. It has to match 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"

A single ./build.sh generates build/MyScreenSaver.saver. It is a Universal binary (arm64 + x86_64), already ad-hoc signed.

Four traps on macOS 26

Trap 1: initial bounds is 0x0

The frame passed to init?(frame: NSRect, isPreview: Bool) sometimes comes as (0, 0, 0, 0). It is still 0x0 at the time of viewDidMoveToWindow, and window.screen is nil too.

Fix: if bounds is 0x0, force setFrameSize with NSScreen.main?.frame.size. Do not touch it after bounds is settled (1px or more). If it stays 0x0, the WKWebView's viewport is fixed at 0 and CSS 100vh / 100vw does not work.

Trap 2: bounds may arrive in backing pixels (= 2x viewport on external non-Retina displays)

The legacyScreenSaver host passes the ScreenSaverView's bounds in backing pixels. Specifically, there are cases where bounds is passed as 3840x2160. AppKit's frame.size is normally in logical points, but in the screensaver's remote view path it sometimes comes in backing pixels.

What makes this nasty is that on a built-in Retina display (scale=2) it ends up looking like it works. Logical points 1920x1080 and backing pixels 3840x2160 happen to coincide, so even if you pass bounds as it is to the WKWebView's frame, the appearance matches and you don't notice the trap.

However, on an external FullHD display connected over HDMI (scale=1), the display breaks. On a display where backing pixel = logical point = 1920x1080, bounds=3840x2160 is treated as points as it is, and the WKWebView is drawn at 2x the size of the screen. As a result, only the bottom-left 1/4 fits in the visible area.

(An earlier version of this article said "the right answer is not to touch it", but that was a conclusion from testing only on a built-in Retina display and it is wrong. It reproduces on an external non-Retina display.)

Fix: treat window?.screen?.frame.size as the truth and clamp the WKWebView frame to it. This is always available in logical points. In Preview (the thumbnail in System Settings), bounds is passed as a small embedded area, so respect it.

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
}

Do not use autoresizingMask = [.width, .height] on the WKWebView. It follows changes to the parent view's bounds and gets dragged back to the pixel scale. Call webView.frame = webViewTargetFrame() explicitly in each of the layout / setFrameSize / resizeSubviews / viewDidMoveToWindow / startAnimation callbacks.

For isPreviewMode, keep the isPreview from init?(frame:isPreview:).

You can't notice this trap if you test only on a built-in Retina display, so always check the behavior on an external non-Retina display (such as a FullHD display connected over HDMI).

Trap 3: CSS transitions stop because visibility is hidden

This is a problem with WKWebView in general that I wrote about in another article: What to suspect when a CSS opacity transition doesn't work in WKWebView. The screensaver's WKWebView is treated as hidden by the OS, and with document.visibilityState === 'hidden', transitions / requestAnimationFrame stop.

Fix: with a WKUserScript, spoof visibilityState on Document.prototype as 'visible' + override the CSS transition with none.

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

A ScreenSaverView is created every time you show the preview in System Settings, and the WKWebView is not deallocated even after the preview is dismissed. WebView entries keep increasing in Safari's Develop menu.

Fix: in stopAnimation, call webView.removeFromSuperview() + navigationDelegate = nil + stopLoading(). It cannot be prevented completely (it depends on the lifecycle on the macOS side), but the memory buildup can be held down.

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 with mmap, so after you change the code and rebuild, the change is not reflected unless you always kill the process. Switching to a different one once in System Settings → Screen Saver and switching back also works.

Debugging

If you emit os_log with your own subsystem, you can pick it up with log stream.

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'

To debug the inside of the WKWebView, with webView.isInspectable = true (macOS 13.3+) you can connect from Safari's Develop menu. You can connect while the System Settings preview is shown.

When you want to pull values out of the WKWebView, polling with evaluateJavaScript from Swift is the easy way.

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) }
}

With this, you can follow the state inside the WebView with log stream alone.

Limitations

  • With only ad-hoc signing, it cannot be distributed to other Macs. Developer ID + notarization is required
  • ServiceWorker / localStorage work as they are, but they are persisted inside the sandbox of the legacyScreenSaver process. When you want to clear them, delete ~/Library/WebKit/com.apple.ScreenSaver.Engine.legacyScreenSaver (or if you use WKWebViewConfiguration.websiteDataStore = .nonPersistent(), they are not persisted in the first place)
  • With multiple monitors, a ScreenSaverView is created for each screen, and each WebView loads independently. Watch out for bandwidth / CPU

Categories

Archive