---
slug: "macos-26-screensaver-webview-without-xcode"
title: "macOS 26 で Xcode を使わずに WebView スクリーンセーバーを作る"
description: "macOS の screensaver (.saver バンドル) を Xcode 無し / swiftc 一発でビルドして WebView を表示するスクリーンセーバを作る方法。macOS 26 (Tahoe) で実機検証。bounds=0x0 / Retina backing pixel / visibility=hidden で transition 停止 / WebView インスタンス蓄積、4 つの罠と対処も収録。"
url: "https://www.ytyng.com/blog/macos-26-screensaver-webview-without-xcode"
publish_date: "2026-05-06T11:33:13.576Z"
created: "2026-05-06T11:33:13.577Z"
updated: "2026-05-06T12:29:53.277Z"
categories: []
keywords: ""
featured_image_url: "https://media.ytyng.com/resize/20260506/5599e97347f24838b39f5877756f54e0.png.webp?width=768"
has_video: false
has_music: false
video_urls: []
music_urls: []
lang: "ja"
---

# 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
<?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`:

```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

```bash
#!/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 が動かないとき疑うこと](https://www.ytyng.com/blog/wkwebview-opacity-transition-not-working)。screensaver の WKWebView は OS から hidden 扱いされ、`document.visibilityState === 'hidden'` で transition / requestAnimationFrame が停止する。

対策: WKUserScript で `Document.prototype` の `visibilityState` を `'visible'` に偽装 + CSS の `transition` を `none` に上書きする。

```swift
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 に依存) が、メモリ蓄積は抑えられる。

## インストールと再読み込み

```bash
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` で拾える。

```swift
import os.log
let saverLog = OSLog(subsystem: "com.example.screensaver", category: "main")
os_log("%{public}@", log: saverLog, type: .default, "message")
```

```bash
log stream --info | grep 'com.example.screensaver'
```

WKWebView 内側のデバッグは `webView.isInspectable = true` (macOS 13.3+) で Safari の Develop メニューから接続可能。System Settings の preview を出した状態で接続できる。

WKWebView 内に値を取り出したいときは Swift から `evaluateJavaScript` でポーリングするのが手軽。

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