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?
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 useWKWebViewConfiguration.websiteDataStore = .nonPersistent()to skip persistence entirely. - 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. 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.
We look forward to discussing your development needs.