Building a WebView screensaver on macOS 26 without Xcode
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 installsswiftc/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)NSPrincipalClasstakes 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
legacyScreenSaverprocess. When you want to clear them, delete~/Library/WebKit/com.apple.ScreenSaver.Engine.legacyScreenSaver(or if you useWKWebViewConfiguration.websiteDataStore = .nonPersistent(), they are not persisted in the first place) - With multiple monitors, a
ScreenSaverViewis created for each screen, and each WebView loads independently. Watch out for bandwidth / CPU
Related posts
Build, sign, and notarize a Tauri app on GitHub Actions for distribution
When CSS opacity transition silently fails inside WKWebView
Struggling to Understand the Relationship Between Frame Size and Viewport Size in UIWebView
Turning a Raspberry Pi into digital signage — maximizing Chromium with Playwright automation
We look forward to discussing your development needs.