macOS 26 で Xcode を使わずに WebView スクリーンセーバーを作る

2026-05-06 11:33 (54 minutes ago)

なぜ書いたか

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.plistNSPrincipalClass と一致する必要がある。

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.screennil

対策: bounds が 0x0 なら NSScreen.main?.frame.sizesetFrameSize を強制。bounds 確定後 (1px 以上) は触らないこと。0x0 のままだと WKWebView の viewport が 0 で固定されて CSS の 100vh / 100vw が機能しない。

罠 2: Retina で bounds が 3840x2160 で来る

物理 1920x1080 の Retina ディスプレイで bounds3840x2160 (= 2x backing) で渡されることがある。AppKit の frame.size は通常は logical point だが、screensaver の remote view path では backing pixel 単位で来る場合がある。

対策: 触らないのが正解。CSS の 100vw / 100vhwindow.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.prototypevisibilityState'visible' に偽装 + CSS の transitionnone に上書きする。

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 エントリが増え続ける。

対策: stopAnimationwebView.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 つだったので、上記の対策を入れておけばすんなり動く。

評価をお願いします
まだ評価がありません
著者は、アプリケーション開発会社 Cyberneura を運営しています。
開発相談をお待ちしています。

アーカイブ