---
slug: "macos-26-screensaver-webview-without-xcode"
title: "macOS 26 で Xcode を使わずに WebView スクリーンセーバーを作る"
description: "macOS 26 のスクリーンセーバー (.saver バンドル) を Xcode を使わず `swiftc` 一発でビルドして、WebView を表示するスクリーンセーバーを作る方法。"
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-11T13:07:21.377Z"
categories: []
keywords: ""
featured_image_url: "https://media.ytyng.com/resize/20260510/c1be2abf31cf434da8aa5ea58e460d06.png.webp?width=768"
has_video: true
has_music: true
video_urls: ["https://media.ytyng.net/ytyng-blog/348/featured-video-1.mp4", "https://media.ytyng.net/ytyng-blog/348/featured-video-2.mp4", "https://media.ytyng.net/ytyng-blog/348/featured-video-3.mp4"]
music_urls: ["https://media.ytyng.net/ytyng-blog/348/featured-music-348-1.mp3", "https://media.ytyng.net/ytyng-blog/348/featured-music-348-2.mp3"]
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?
    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.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: bounds が backing pixel 単位で渡される (= 外部 non-Retina で viewport が 2x になる)

`legacyScreenSaver` host が ScreenSaverView の `bounds` を **backing 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 が小さい埋め込み領域として渡されるので尊重する。

```swift
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()` を呼ぶ。

`isPreviewMode` は `init?(frame:isPreview:)` の `isPreview` を保持しておく。

### 罠 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` を削除 (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 など) で必ず動作確認すること。

