macOS 26 で Xcode を使わずに WebView スクリーンセーバーを作る
なぜ書いたか
macOS の screensaver (.saver バンドル) を Xcode プロジェクト無しで作る 情報がほぼゼロ。Apple の公式ドキュメントは Objective-C 時代の遺産で、Swift / 現代の AppKit / WKWebView との組み合わせは公式に書かれていない。macOS 26 (Tahoe) では screensaver を host するプロセスの名前が legacyScreenSaver になっており、Apple が積極的に新規開発を推奨していない領域であることが伺える。
それでも、
- 自分の Mac で動かしたいだけ
- WebView でちょっとした Web ページを表示するスクリーンセーバが欲しい
- Xcode を立ち上げるほどでもない
という用途には、swiftc 一発でビルドできる方が遥かに気持ちいい。実際に macOS 26 で動かしてハマった点も含めてまとめる。
必要なもの
- macOS 14 (Sonoma) 以降。検証は macOS 26 (Tahoe) Apple Silicon
- Xcode Command Line Tools (
xcode-select --install) —swiftc/lipo/codesignが入る - Xcode 本体は不要
バンドル構造
MyScreenSaver.saver/
└── Contents/
├── Info.plist
└── MacOS/
└── MyScreenSaver ← Mach-O bundle (universal)
.saver は実体としてはディレクトリ (Bundle)。中の Mach-O は executable ではなく bundle 形式 (mh_bundle)。swiftc -emit-library -Xlinker -bundle で生成する。
Info.plist
最低限これだけあれば動く:
<?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>
最重要:
CFBundlePackageType=BNDL(executable ではない)NSPrincipalClassには Swift class の@objc(...)で指定した名前を入れる。Swift のフルネーム (ModuleName.ClassName) ではなく、ObjC ランタイム名にする
Swift ソース
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 偽装 + transition 無効化を仕掛ける (後述)
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) で ObjC ランタイム名を固定する。これが Info.plist の NSPrincipalClass と一致する必要がある。
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 一発で build/MyScreenSaver.saver が生成される。Universal binary (arm64 + x86_64) で ad-hoc 署名済み。
macOS 26 で踏んだ罠 4 つ
罠 1: 初期 bounds が 0x0
init?(frame: NSRect, isPreview: Bool) で渡される frame が (0, 0, 0, 0) で来ることがある。viewDidMoveToWindow の時点でも 0x0、window.screen も nil。
対策: bounds が 0x0 なら NSScreen.main?.frame.size で setFrameSize を強制。bounds 確定後 (1px 以上) は触らないこと。0x0 のままだと WKWebView の viewport が 0 で固定されて CSS の 100vh / 100vw が機能しない。
罠 2: Retina で bounds が 3840x2160 で来る
物理 1920x1080 の Retina ディスプレイで bounds が 3840x2160 (= 2x backing) で渡されることがある。AppKit の frame.size は通常は logical point だが、screensaver の remote view path では backing pixel 単位で来る場合がある。
対策: 触らないのが正解。CSS の 100vw / 100vh は window.innerWidth/Height (= logical viewport) ベースで動くので、bounds が backing pixel でも問題ない。bounds=3840x2160 を「正規化」して 1920x1080 に縮小すると、本物の Retina backing 値が消えて画面がギザギザになる。
罠 3: visibility=hidden で CSS transition が止まる
これは別記事に書いた WKWebView 全般のハマり: WKWebView で CSS の opacity transition が動かないとき疑うこと。screensaver の WKWebView は OS から hidden 扱いされ、document.visibilityState === 'hidden' で transition / requestAnimationFrame が停止する。
対策: WKUserScript で Document.prototype の visibilityState を 'visible' に偽装 + CSS の transition を 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)
)
罠 4: WKWebView インスタンスが残る
System Settings の preview を出すたびに ScreenSaverView が生成され、preview 解除しても WKWebView が dealloc されない。Safari の Develop メニューに WebView エントリが増え続ける。
対策: stopAnimation で webView.removeFromSuperview() + navigationDelegate = nil + stopLoading() を呼ぶ。完全には防ぎ切れない (macOS 側の lifecycle に依存) が、メモリ蓄積は抑えられる。
インストールと再読み込み
cp -R build/MyScreenSaver.saver "$HOME/Library/Screen Savers/"
killall legacyScreenSaver 2>/dev/null
killall ScreenSaverEngine 2>/dev/null
legacyScreenSaver プロセスは bundle を mmap で保持するので、コードを変えて再ビルドした後は 必ずプロセスを kill しないと反映されない。System Settings → スクリーンセーバーで一度別のものに切り替えて戻すのも有効。
デバッグ
os_log を独自 subsystem で発行すれば 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'
WKWebView 内側のデバッグは webView.isInspectable = true (macOS 13.3+) で Safari の Develop メニューから接続可能。System Settings の preview を出した状態で接続できる。
WKWebView 内に値を取り出したいときは Swift から 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) }
}
これで WebView 内側の状態を log stream 一本で追える。
制限事項
- ad-hoc 署名のままでは他 Mac に配布不可。Developer ID + notarization が必要
- ServiceWorker / localStorage はそのまま動くが、
legacyScreenSaverプロセスのサンドボックス内で永続化される。クリアしたいときは~/Library/WebKit/com.apple.ScreenSaver.Engine.legacyScreenSaverを削除 - マルチモニター時は画面ごとに
ScreenSaverViewが生成され、各 WebView が独立に load する。帯域 / CPU 注意
おわりに
Apple が積極推奨してない領域だが、自分用 Mac で WebView スクリーンセーバを作る分には十分実用的。Xcode を開かずに 1 つの shell script でビルドできるのは気持ちいい。罠の 9 割は「初期 bounds 0x0」と「visibility=hidden で transition が止まる」の 2 つだったので、上記の対策を入れておけばすんなり動く。
開発相談をお待ちしています。