Google Meet で画面を共有するとき、相手にはスライドだけを見せ、自分の窓にだけ台本(発表者ノート)を出したい。そのためのMacアプリを、Xcodeプロジェクトを作らずに swiftc で直接ビルドして作りました。
Keynote と PowerPoint の無いMacで使うのが前提です。入力には、Chrome headless でPDFにしている section.slide 構成のHTMLをそのまま使います。
Swift 4ファイル・約1,200行で、半日で完了条件まで通りました。完了条件は、2ウィンドウ表示・発表者側だけにノート・数字キーで非表示スライドへジャンプ・./build.sh 一発でビルドの4つです。
以下の順で書きます。
- 元のHTMLをパースし直さず、CSSとJSを差し込むだけでスライドを描かせる設計
- 2つの WKWebView の同期とキー入力の取り方
- 途中で詰まった4か所(WKWebView のサイズ・
NSScreen.main・UserDefaults のドメイン・信号機ボタン)
前提
- macOS
- Swift(
swiftcで直接ビルド。Xcodeプロジェクトは作らない) - AppKit + WKWebView
- 入力:
section.slideを並べたHTML(PDF化にも使っているもの) - ビルドは
./build.sh1本。検品用に./build.sh --snapshot <slides.html>でアプリ自身にPNGを書き出させる - アプリ名は「みせる」。先に作った姉妹アプリ「よむ」と同じ作り方
構成は2ウィンドウです。
| ウィンドウ | 置き場所 | 表示するもの |
|---|---|---|
| 観客側 | 外部ディスプレイ(Meet で共有する窓) | 今のスライド1枚だけ |
| 発表者側 | 内蔵ディスプレイ(カメラのある側) | 今のスライド・次のスライド・ノート |
手順1: HTMLに注入してWebKitに描かせる
設計の中心は「元のHTMLを解析し直さない」ことです。手を入れるのは2か所だけです。
-
</head>の直前にCSSを差し込む -
</body>の直前にJSを差し込む
元のCSSがそのまま効くので、PDFと同じ見た目のまま表示できます。レイアウトエンジンは1行も書いていません。
観客側
.slide を絶対配置で重ね、今の1枚だけ visibility: visible にします。
ここで display を切り替える方法は使えませんでした。デッキ側に .cover{display:flex} のような指定があると、display を上書きした時点で表紙のレイアウトが壊れます。visibility なら元の display に触れずに済みます。
/* 観客側: 今の1枚以外は visibility で隠す(display は触らない) */
.slide { visibility: hidden; }
今の1枚を visibility: visible に戻す処理は、注入したJSが担当します。
発表者側
.slide をいったん退避し、今と次のスライドを cloneNode で複製して並べます。複製なので元のCSSが当たり、観客側と同じ見た目になります。
発表者側のUI(ノート欄など)は div と span だけで組みました。それでも、デッキ側のCSSにある p{font-size:17px} や h1{…} がノート本文に当たってしまいます。発表者UIの中では inherit !important で打ち消しました。
/* 発表者UIの中ではデッキ側の文字指定を打ち消す */
font-size: inherit !important;
手順2: 構造はJSが読み、進行はSwiftが持つ
役割を次のように分けました。
- JS → Swift: 読み込み後、デッキの構造を1回だけ送る
{"type": "deck", "slides": [{"n": 1, "title": "...", "hidden": false}]}
- Swift → JS: ページ送りのたびに、2つの WKWebView 両方へ同じ関数を呼ぶ
miseru.go(i)
2つの窓は同じプロセスの中にあります。そのため同期は「同じ関数を2回呼ぶ」だけで済み、通信や状態の突き合わせは要りません。
hidden は非表示スライドの印です。通常のページ送りでは飛ばし、数字キーで番号を指定したときだけ表示します。質疑応答用の補足スライドを、聞かれたときだけ出す使い方を想定しています。
手順3: キー入力はウィンドウでなくアプリで受ける
どちらの窓が前面にあっても、また WKWebView がフォーカスを持っていても、同じキーで操作できるようにします。そのため、キーの受け口をウィンドウからアプリ全体に移しました。
NSEvent.addLocalMonitorForEvents(matching: .keyDown) { event in
// ⌘⌃⌥ が付いたキーはメニューへ流し、それ以外はスライド操作として消費する
...
}
修飾キー(⌘⌃⌥)付きのキーだけは素通しにしてメニューへ流します。全部を握りつぶすと ⌘Q などが効かなくなるためです。
起きたこと/落とし穴
1. WKWebView が窓より 160×90 小さいまま
スクリーンショットで気づきました。表示領域が窓より 160×90 小さく、右下に隙間ができていました。
原因は次の順番にあります。
-
window.contentView = vの時点で、vは窓の大きさに揃えられる - その後に
addSubview(web)したwebは、初期フレームのまま残る -
autoresizingMaskは「以後の差分」を追うだけで、最初のズレは直さない
「よむ」では web を最初から窓と同じ寸法で作っていたので、同じ問題は表に出ていませんでした。
2. NSScreen.main が起動直後に外部ディスプレイを返す
発表者の窓を「主画面」に置いたつもりが外部ディスプレイに出て、観客の窓が内蔵ディスプレイに来ました。手元の環境では、起動直後のキーウィンドウが無いタイミングで NSScreen.main が外部ディスプレイを返しました。
3. swiftc 直ビルドの生バイナリは UserDefaults のドメインが別になる
検証中に、古いウィンドウ座標が復元されて「置き場所がおかしい」と30分迷いました。
swiftc で作った生バイナリを直接実行すると、UserDefaults のドメインが実行ファイル名(miseru-bin)になります。.app として起動したときのドメインは com.webharu.miseru です。2つは別物なので、生バイナリで試したときの座標が .app 側からは見えず、その逆も起きます。
4. Meet の窓リストに信号機ボタンが写る
Meet で共有する窓を選ぶ一覧に、観客側の窓の信号機ボタン(閉じる・最小化・最大化)が写り込みました。
対処
WKWebView のサイズ
窓に載せる直前に、フレームを contentView に合わせます。
web.frame = contentView.bounds
内蔵ディスプレイを発表者用に固定
NSScreen.main に頼らず、CGDisplayIsBuiltin で内蔵ディスプレイを判定して発表者の窓を置きます。内蔵ディスプレイはカメラのある側なので、台本を見ながらでも目線がカメラから大きく外れません。
// 各 NSScreen のディスプレイIDに対して CGDisplayIsBuiltin で内蔵かどうかを判定する
CGDisplayIsBuiltin(displayID)
UserDefaults のドメイン
検証の前に、両方のドメインを消してから起動します。
defaults delete miseru-bin
defaults delete com.webharu.miseru
信号機ボタン
タイトルバーを内容に重ねて隠し、マウスが乗ったときだけボタンを出します。
// styleMask に .fullSizeContentView を足す
window.titleVisibility = .hidden
// tracking area でマウスの出入りを拾い、乗っている間だけ表示する
window.standardWindowButton(.closeButton)?.isHidden = false
検品
検品は目視ではなく、アプリ自身にPNGを書き出させて行いました。
./build.sh --snapshot <slides.html>
撮るのは2窓ぶんです。通常のページ送りに加え、数字キーで非表示スライドへ飛んだ後の状態も撮ります。上記のWKWebView のサイズずれも、このPNGで見つけました。
まとめ
- 「HTMLに注入してWebKitに描かせる」方式にすると、レイアウトを自前で組まずにプレゼン機能を作れる。元のCSSが効くので、PDFと見た目が揃う
- 構造の読み取りはJS、進行管理はSwiftに分ける。同一プロセスの2窓なら、同期は同じ関数を2回呼ぶだけで済む
- キーは
NSEvent.addLocalMonitorForEventsでアプリ全体から拾う。修飾キー付きだけメニューへ流す -
addSubviewした WKWebView はautoresizingMask任せにせず、載せる直前にframeを合わせる -
swiftc直ビルドで検証するときは、生バイナリと.appで UserDefaults のドメインが別になる点に注意する