0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【Swift】Xcodeなしで発表者ノート付きプレゼンアプリを作る(WKWebView2窓同期)

0
Last updated at Posted at 2026-10-13

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.sh 1本。検品用に ./build.sh --snapshot <slides.html> でアプリ自身にPNGを書き出させる
  • アプリ名は「みせる」。先に作った姉妹アプリ「よむ」と同じ作り方

構成は2ウィンドウです。

ウィンドウ 置き場所 表示するもの
観客側 外部ディスプレイ(Meet で共有する窓) 今のスライド1枚だけ
発表者側 内蔵ディスプレイ(カメラのある側) 今のスライド・次のスライド・ノート

手順1: HTMLに注入してWebKitに描かせる

設計の中心は「元のHTMLを解析し直さない」ことです。手を入れるのは2か所だけです。

  1. </head> の直前にCSSを差し込む
  2. </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 小さく、右下に隙間ができていました。

原因は次の順番にあります。

  1. window.contentView = v の時点で、v は窓の大きさに揃えられる
  2. その後に addSubview(web) した web は、初期フレームのまま残る
  3. 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 のドメインが別になる点に注意する
0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?