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) forswiftc/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:
CFBundlePackageTypemust beBNDL, notAPPL.NSPrincipalClassis 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
ScreenSaverViewis 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.
We look forward to discussing your development needs.