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

2026-05-06 11:33 (11 days ago)
Bundle Houdini
この記事をテーマにした曲を再生

なぜ書いたか

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?
    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)
    }

    // bounds が backing pixel で渡されるケース対策。詳細は罠 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 偽装 + transition 無効化を仕掛ける (後述)

        let wv = WKWebView(frame: webViewTargetFrame(), configuration: config)
        // autoresizingMask は使わない (罠 2 参照)。frame は callback で明示的に更新する。
        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) で 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: bounds が backing pixel 単位で渡される (= 外部 non-Retina で viewport が 2x になる)

legacyScreenSaver host が ScreenSaverView の boundsbacking pixel で渡してくる。具体的には bounds = 3840x2160 で渡されるケース。AppKit の frame.size は通常 logical point だが、screensaver の remote view path では backing pixel 単位で来ることがある。

これが厄介なのは、 Retina 内蔵 (scale=2) では結果的に動いて見える こと。 logical point 1920x1080 と backing pixel 3840x2160 が偶然一致するため、 bounds をそのまま WKWebView の frame に渡しても見た目が合ってしまい、罠に気付かない。

しかし、 HDMI 接続の外部 FullHD (scale=1) では破綻する。 backing pixel = logical point = 1920x1080 のディスプレイに、 bounds=3840x2160 がそのまま points として扱われ、 WKWebView が画面の 2x のサイズで描画される。 結果として bottom-left 1/4 しか可視領域に収まらない。

(以前のバージョンの本記事では「触らないのが正解」と書いていたが、これは Retina 内蔵だけでテストした結論で誤り。 外部 non-Retina ディスプレイで再現する。)

対策: window?.screen?.frame.size を真として WKWebView frame を clamp する。これは常に logical point で取れる。 Preview (System Settings のサムネイル) では bounds が小さい埋め込み領域として渡されるので尊重する。

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
}

WKWebView の autoresizingMask = [.width, .height] は使わない。 親 view の bounds 変更に追従して再び pixel スケールに引きずられる。 layout / setFrameSize / resizeSubviews / viewDidMoveToWindow / startAnimation の各 callback で明示的に webView.frame = webViewTargetFrame() を呼ぶ。

isPreviewModeinit?(frame:isPreview:)isPreview を保持しておく。

罠 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 を削除 (or WKWebViewConfiguration.websiteDataStore = .nonPersistent() を使えばそもそも永続化されない)
  • マルチモニター時は画面ごとに ScreenSaverView が生成され、各 WebView が独立に load する。帯域 / CPU 注意

おわりに

Apple が積極推奨してない領域だが、自分用 Mac で WebView スクリーンセーバを作る分には十分実用的。Xcode を開かずに 1 つの shell script でビルドできるのは気持ちいい。罠の 9 割は「初期 bounds 0x0」「bounds が backing pixel で来る」「visibility=hidden で transition が止まる」の 3 つだったので、上記の対策を入れておけばすんなり動く。

特に 罠 2 (backing pixel) は Retina 内蔵だけでテストすると気付けない。外部 non-Retina ディスプレイ (HDMI 接続の FullHD など) で必ず動作確認すること。

評価をお願いします (会員登録・ログイン不要)
まだ評価がありません
著者は、アプリケーション開発会社 Cyberneura を運営しています。
開発相談をお待ちしています。

アーカイブ