mp 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +21 -0
  3. data/README.md +206 -0
  4. data/exe/mp +6 -0
  5. data/lib/mp/app.rb +158 -0
  6. data/lib/mp/browser.rb +119 -0
  7. data/lib/mp/cdp.rb +119 -0
  8. data/lib/mp/cli.rb +36 -0
  9. data/lib/mp/deck.rb +268 -0
  10. data/lib/mp/frame_unblocker.rb +77 -0
  11. data/lib/mp/server.rb +224 -0
  12. data/lib/mp/slide_renderer.rb +184 -0
  13. data/lib/mp/terminal.rb +41 -0
  14. data/lib/mp/version.rb +5 -0
  15. data/lib/mp/web_socket.rb +49 -0
  16. data/lib/mp.rb +12 -0
  17. data/public/app.js +306 -0
  18. data/public/index.html +37 -0
  19. data/public/presenter.css +33 -0
  20. data/public/presenter.html +41 -0
  21. data/public/presenter.js +168 -0
  22. data/public/slides.js +35 -0
  23. data/public/style.css +93 -0
  24. data/themes/dark.css +26 -0
  25. data/themes/default.css +27 -0
  26. data/vendor/assets/katex/LICENSE +21 -0
  27. data/vendor/assets/katex/auto-render.min.js +1 -0
  28. data/vendor/assets/katex/fonts/KaTeX_AMS-Regular.ttf +0 -0
  29. data/vendor/assets/katex/fonts/KaTeX_AMS-Regular.woff +0 -0
  30. data/vendor/assets/katex/fonts/KaTeX_AMS-Regular.woff2 +0 -0
  31. data/vendor/assets/katex/fonts/KaTeX_Caligraphic-Bold.ttf +0 -0
  32. data/vendor/assets/katex/fonts/KaTeX_Caligraphic-Bold.woff +0 -0
  33. data/vendor/assets/katex/fonts/KaTeX_Caligraphic-Bold.woff2 +0 -0
  34. data/vendor/assets/katex/fonts/KaTeX_Caligraphic-Regular.ttf +0 -0
  35. data/vendor/assets/katex/fonts/KaTeX_Caligraphic-Regular.woff +0 -0
  36. data/vendor/assets/katex/fonts/KaTeX_Caligraphic-Regular.woff2 +0 -0
  37. data/vendor/assets/katex/fonts/KaTeX_Fraktur-Bold.ttf +0 -0
  38. data/vendor/assets/katex/fonts/KaTeX_Fraktur-Bold.woff +0 -0
  39. data/vendor/assets/katex/fonts/KaTeX_Fraktur-Bold.woff2 +0 -0
  40. data/vendor/assets/katex/fonts/KaTeX_Fraktur-Regular.ttf +0 -0
  41. data/vendor/assets/katex/fonts/KaTeX_Fraktur-Regular.woff +0 -0
  42. data/vendor/assets/katex/fonts/KaTeX_Fraktur-Regular.woff2 +0 -0
  43. data/vendor/assets/katex/fonts/KaTeX_Main-Bold.ttf +0 -0
  44. data/vendor/assets/katex/fonts/KaTeX_Main-Bold.woff +0 -0
  45. data/vendor/assets/katex/fonts/KaTeX_Main-Bold.woff2 +0 -0
  46. data/vendor/assets/katex/fonts/KaTeX_Main-BoldItalic.ttf +0 -0
  47. data/vendor/assets/katex/fonts/KaTeX_Main-BoldItalic.woff +0 -0
  48. data/vendor/assets/katex/fonts/KaTeX_Main-BoldItalic.woff2 +0 -0
  49. data/vendor/assets/katex/fonts/KaTeX_Main-Italic.ttf +0 -0
  50. data/vendor/assets/katex/fonts/KaTeX_Main-Italic.woff +0 -0
  51. data/vendor/assets/katex/fonts/KaTeX_Main-Italic.woff2 +0 -0
  52. data/vendor/assets/katex/fonts/KaTeX_Main-Regular.ttf +0 -0
  53. data/vendor/assets/katex/fonts/KaTeX_Main-Regular.woff +0 -0
  54. data/vendor/assets/katex/fonts/KaTeX_Main-Regular.woff2 +0 -0
  55. data/vendor/assets/katex/fonts/KaTeX_Math-BoldItalic.ttf +0 -0
  56. data/vendor/assets/katex/fonts/KaTeX_Math-BoldItalic.woff +0 -0
  57. data/vendor/assets/katex/fonts/KaTeX_Math-BoldItalic.woff2 +0 -0
  58. data/vendor/assets/katex/fonts/KaTeX_Math-Italic.ttf +0 -0
  59. data/vendor/assets/katex/fonts/KaTeX_Math-Italic.woff +0 -0
  60. data/vendor/assets/katex/fonts/KaTeX_Math-Italic.woff2 +0 -0
  61. data/vendor/assets/katex/fonts/KaTeX_SansSerif-Bold.ttf +0 -0
  62. data/vendor/assets/katex/fonts/KaTeX_SansSerif-Bold.woff +0 -0
  63. data/vendor/assets/katex/fonts/KaTeX_SansSerif-Bold.woff2 +0 -0
  64. data/vendor/assets/katex/fonts/KaTeX_SansSerif-Italic.ttf +0 -0
  65. data/vendor/assets/katex/fonts/KaTeX_SansSerif-Italic.woff +0 -0
  66. data/vendor/assets/katex/fonts/KaTeX_SansSerif-Italic.woff2 +0 -0
  67. data/vendor/assets/katex/fonts/KaTeX_SansSerif-Regular.ttf +0 -0
  68. data/vendor/assets/katex/fonts/KaTeX_SansSerif-Regular.woff +0 -0
  69. data/vendor/assets/katex/fonts/KaTeX_SansSerif-Regular.woff2 +0 -0
  70. data/vendor/assets/katex/fonts/KaTeX_Script-Regular.ttf +0 -0
  71. data/vendor/assets/katex/fonts/KaTeX_Script-Regular.woff +0 -0
  72. data/vendor/assets/katex/fonts/KaTeX_Script-Regular.woff2 +0 -0
  73. data/vendor/assets/katex/fonts/KaTeX_Size1-Regular.ttf +0 -0
  74. data/vendor/assets/katex/fonts/KaTeX_Size1-Regular.woff +0 -0
  75. data/vendor/assets/katex/fonts/KaTeX_Size1-Regular.woff2 +0 -0
  76. data/vendor/assets/katex/fonts/KaTeX_Size2-Regular.ttf +0 -0
  77. data/vendor/assets/katex/fonts/KaTeX_Size2-Regular.woff +0 -0
  78. data/vendor/assets/katex/fonts/KaTeX_Size2-Regular.woff2 +0 -0
  79. data/vendor/assets/katex/fonts/KaTeX_Size3-Regular.ttf +0 -0
  80. data/vendor/assets/katex/fonts/KaTeX_Size3-Regular.woff +0 -0
  81. data/vendor/assets/katex/fonts/KaTeX_Size3-Regular.woff2 +0 -0
  82. data/vendor/assets/katex/fonts/KaTeX_Size4-Regular.ttf +0 -0
  83. data/vendor/assets/katex/fonts/KaTeX_Size4-Regular.woff +0 -0
  84. data/vendor/assets/katex/fonts/KaTeX_Size4-Regular.woff2 +0 -0
  85. data/vendor/assets/katex/fonts/KaTeX_Typewriter-Regular.ttf +0 -0
  86. data/vendor/assets/katex/fonts/KaTeX_Typewriter-Regular.woff +0 -0
  87. data/vendor/assets/katex/fonts/KaTeX_Typewriter-Regular.woff2 +0 -0
  88. data/vendor/assets/katex/katex.min.css +1 -0
  89. data/vendor/assets/katex/katex.min.js +1 -0
  90. data/vendor/assets/xterm/LICENSE +21 -0
  91. data/vendor/assets/xterm/addon-fit.js +2 -0
  92. data/vendor/assets/xterm/xterm.css +285 -0
  93. data/vendor/assets/xterm/xterm.js +2 -0
  94. metadata +230 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: ff079cf7563dddd6684f0a4b88d59adba0e0a7290c505f45687566c5bc13af9a
4
+ data.tar.gz: 8178770f3b19a573736ffc24e20534221170dfe7d7429c65edd5bc619bac3b1d
5
+ SHA512:
6
+ metadata.gz: 50c91001bcbd108799a7e752910e5b7bab6badcd530a8357b07860bf22f5484a190c509cafc05ebc2b243a0755a9a329e8bf9f1d7d4986902cbfe8269d59448c
7
+ data.tar.gz: e252cc2177716ade059b52fd4846228c41c1f3b1598db1683005a6d3f580946012514deac78d3dbdfd3bbcc2890d14638949475b1a95f881bdcae59ce5f705e9
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 takkanm
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,206 @@
1
+ # mp
2
+
3
+ Markdown を全画面プレゼンテーションにする Ruby 製ツールです。スライドに本物のターミナルと Web ページを埋め込めます。
4
+
5
+ ## 必要なもの
6
+
7
+ - Ruby 3.3 以上
8
+ - Chrome 系ブラウザ(Chrome / Chromium / Edge / Brave。別の場所にある場合は `MP_BROWSER` でパスを指定)
9
+
10
+ ## 使い方
11
+
12
+ ```bash
13
+ bundle install
14
+ bundle exec exe/mp examples/demo.md
15
+ ```
16
+
17
+ | オプション | 説明 |
18
+ | --- | --- |
19
+ | `-w`, `--window` | 全画面にせずウィンドウで開く |
20
+ | `-P`, `--presenter` | 発表者ビューも開く |
21
+ | `-p`, `--port PORT` | ローカルサーバのポート(既定は空きポート) |
22
+ | `--profile DIR` | Chrome のプロファイルを保存する(埋め込みページのログインを保持) |
23
+ | `--no-watch` | ファイル変更時に再読み込みしない |
24
+
25
+ Markdown や CSS を保存すると自動で再読み込みします。このときターミナルのセッションはリセットされます。
26
+
27
+ ## スライドの書き方
28
+
29
+ | 記法 | 結果 |
30
+ | --- | --- |
31
+ | `# 見出し` | 表紙スライド。次の H2 までの本文も表紙に入る |
32
+ | `## 見出し` | 通常のスライド 1 枚。見出しがページタイトルになる |
33
+ | `---` | タイトルなしの続きスライド |
34
+
35
+ ## ターミナルの埋め込み
36
+
37
+ ````markdown
38
+ ```terminal cwd=./demo height=60% font=20
39
+ npm test
40
+ git status
41
+ ```
42
+ ````
43
+
44
+ 本文に書いたコマンドは 1 行ずつ入力欄に打ち込まれますが、**Enter を押すまで実行されません**。実行すると次の行が打ち込まれます。本文は空でも構いません。
45
+
46
+ | 属性 | 説明 |
47
+ | --- | --- |
48
+ | `cwd` | 作業ディレクトリ(デッキからの相対パス) |
49
+ | `height` / `width` | 大きさ(`60%`、`400px`)。省略すると残りの領域を埋める |
50
+ | `font` | 文字サイズ(既定 18) |
51
+
52
+ ## Web ページの埋め込み
53
+
54
+ ````markdown
55
+ ```web zoom=0.8 height=70%
56
+ https://example.com
57
+ ```
58
+ ````
59
+
60
+ 本文には `http(s)` の URL か、デッキからの相対パス(`./page.html`)を書きます。`X-Frame-Options` などで埋め込みを拒否するサイトも表示できます。
61
+
62
+ | 属性 | 説明 |
63
+ | --- | --- |
64
+ | `zoom` | 表示倍率 |
65
+ | `height` / `width` | ターミナルと同じ |
66
+
67
+ クリックするとページを操作でき、マウスが外に出るとスライドのキー操作に戻ります。
68
+
69
+ ターミナルとページを横に並べるには `<!-- _class: columns -->` を使います。
70
+
71
+ ## Marp 互換の記法
72
+
73
+ **フロントマター / ディレクティブ**
74
+
75
+ ```markdown
76
+ ---
77
+ theme: default # default / dark / ./my-theme.css
78
+ size: 16:9 # 16:9 / 4:3
79
+ title: タイトル
80
+ paginate: true
81
+ header: ヘッダー
82
+ footer: フッター
83
+ style: |
84
+ section { font-size: 28px; }
85
+ ---
86
+
87
+ <!-- class: lead --> 以降のスライドに適用
88
+ <!-- _backgroundColor: black --> このスライドだけに適用(先頭に _)
89
+ ```
90
+
91
+ 使えるディレクティブは `paginate` `header` `footer` `class` `color` `backgroundColor` `backgroundImage` `backgroundPosition` `backgroundRepeat` `backgroundSize` です。クラスには `lead`(中央寄せ)、`invert`(配色反転)、`columns`(2 列)があります。
92
+
93
+ **画像**
94
+
95
+ ```markdown
96
+ ![w:300 h:200](a.png) サイズ指定
97
+ ![blur:5px grayscale](a.png) フィルタ
98
+ ![bg](a.png) 背景(複数並べると分割)
99
+ ![bg contain](a.png) cover / contain / fit / auto / 50%
100
+ ![bg right:40%](a.png) 画面を分割して片側に配置
101
+ ```
102
+
103
+ **その他**
104
+
105
+ - 上記以外の `<!-- コメント -->` は発表者ノートになる(`n` で表示)
106
+ - `*` のリストは 1 項目ずつ表示し、`-` のリストは一度に表示する
107
+ - `# <!-- fit --> 見出し` は見出しを横幅いっぱいに拡大する
108
+ - `<style scoped>` はそのスライドだけに効く
109
+ - 数式は `$...$` と `$$...$$`(KaTeX)
110
+ - コードはシンタックスハイライトされる(Rouge)
111
+
112
+ ## 発表者ビュー
113
+
114
+ ```bash
115
+ bundle exec exe/mp --presenter examples/demo.md
116
+ ```
117
+
118
+ 発表中に `p` を押しても開けます。現在のスライド、次のスライド、発表者ノート、経過時間、現在時刻を表示します。
119
+
120
+ - **操作:** 発表者ビューのキー操作は聴衆画面に反映されます。聴衆画面を操作した場合も発表者ビューが追従します
121
+ - **次の枠:** `*` リストの未表示項目が残っていれば、次に表示される項目を先に見せます
122
+ - **ターミナルと Web:** 現在の枠には聴衆画面をそのまま映します(1 秒に数回更新)。次の枠にはコマンドや URL を表示します
123
+ - **台本の実行:** ターミナルのスライドでは次のコマンドを表示し、発表者ビューの `Enter` で聴衆画面のターミナルを実行します
124
+ - **ディスプレイ:** 外部ディスプレイがあれば、聴衆画面をそちらの全画面に移し、発表者ビューを手元の画面に置きます
125
+ - **閉じる:** 発表者ビューだけを閉じても発表は続きます。`q` を押すと全体が終了します
126
+
127
+ | キー | 動作 |
128
+ | --- | --- |
129
+ | `→` `↓` `Space` `PageDown` | 次へ |
130
+ | `←` `↑` `PageUp` | 前へ |
131
+ | `Home` / `End` | 最初 / 最後 |
132
+ | 数字 + `Enter` | そのページへ |
133
+ | `Enter` | 台本のコマンドを実行 |
134
+ | `r` | 経過時間をリセット |
135
+ | `+` / `-` | ノートの文字サイズ |
136
+ | `q` | 終了 |
137
+
138
+ 1 画面で使うと、発表者ビューが聴衆画面の全画面の裏に隠れます。macOS ではスペースを切り替えて表示してください。
139
+
140
+ ## キー操作
141
+
142
+ | キー | 動作 |
143
+ | --- | --- |
144
+ | `→` `↓` `Space` `PageDown` | 次へ |
145
+ | `←` `↑` `PageUp` | 前へ |
146
+ | `Home` / `End` | 最初 / 最後 |
147
+ | 数字 + `Enter` | そのページへ |
148
+ | `t` またはクリック | ターミナルを操作 |
149
+ | `⌘.` | ターミナルから抜ける |
150
+ | `⌘←` / `⌘→` | ターミナル操作中でもページ移動 |
151
+ | `f` | 全画面の切り替え |
152
+ | `n` | 発表者ノート |
153
+ | `p` | 発表者ビュー |
154
+ | `?` | ヘルプ |
155
+ | `q` | 終了 |
156
+
157
+ ## しくみと安全性
158
+
159
+ Ruby のローカルサーバ(puma)がスライドを配信し、Chrome をアプリモードで開きます。ターミナルは Ruby の `PTY` と WebSocket で xterm.js につなぎます。Web 埋め込みでは、Chrome DevTools Protocol でフレームのレスポンスから `X-Frame-Options` と CSP の `frame-ancestors` を取り除きます。
160
+
161
+ ターミナルはコマンドを実行できるので、次のように保護しています。
162
+
163
+ - サーバは `127.0.0.1` のみで待ち受け、全パスにランダムトークンを要求する
164
+ - WebSocket は Origin を検証する
165
+ - CSP により、デッキ内の HTML に書いたスクリプトは実行されない
166
+ - デッキ配下の HTML は sandbox で配信し、ターミナルへ接続できない
167
+ - コマンドは Enter を押すまで実行されない
168
+
169
+ それでも、Enter を押せば書かれたコマンドは実行されます。**他人が作ったデッキは内容を確認してから発表してください。**
170
+
171
+ 埋め込みを拒否するヘッダを外すのは、mp が起動する Chrome の中だけです。この Chrome は既定で一時プロファイルを使い、普段のプロファイルには影響しません。
172
+
173
+ ## 制限
174
+
175
+ - 埋め込みページは iframe なので、サードパーティ Cookie の制限によりログイン状態を維持できないサイトがあります
176
+ - `:smile:` 形式の絵文字ショートコードは未対応です(Unicode の絵文字は使えます)
177
+ - 発表者ビューから入力できるのは `Enter` だけです。台本にないコマンドを打つときは、聴衆画面のターミナルをクリックしてください
178
+
179
+ ## 開発
180
+
181
+ ```bash
182
+ bundle exec rake test
183
+ bundle exec ruby test/e2e.rb
184
+ bundle exec ruby test/presenter_e2e.rb
185
+ ```
186
+
187
+ `test/e2e.rb` と `test/presenter_e2e.rb` はヘッドレス Chrome で実際に動かして確認します。Web 埋め込みの確認にはネットワーク接続が必要です。
188
+
189
+ `vendor/assets` には xterm.js と KaTeX を同梱しています。実行に Node.js は不要です。
190
+
191
+ ## リリース
192
+
193
+ | コマンド | 説明 |
194
+ | --- | --- |
195
+ | `bundle exec rake build` | テストを実行し、`pkg/` に gem を作る |
196
+ | `bundle exec rake install` | gem を作ってローカルにインストールする |
197
+ | `bundle exec rake release` | テスト、ビルド、タグ付け、push、RubyGems への公開を行う |
198
+
199
+ 手順は次のとおりです。
200
+
201
+ 1. `lib/mp/version.rb` のバージョンを上げる
202
+ 2. `bundle install` で `Gemfile.lock` を更新する
203
+ 3. 変更をコミットする
204
+ 4. `bundle exec rake release` を実行する
205
+
206
+ `rake release` にはコミット済みの git リポジトリと `origin` リモート、RubyGems の認証情報が必要です。未コミットの変更があると公開前に中断します。
data/exe/mp ADDED
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "../lib/mp/cli"
5
+
6
+ exit Mp::Cli.run(ARGV)
data/lib/mp/app.rb ADDED
@@ -0,0 +1,158 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "listen"
5
+ require_relative "browser"
6
+ require_relative "frame_unblocker"
7
+ require_relative "server"
8
+
9
+ module Mp
10
+ # サーバとブラウザを起動し、ウィンドウが閉じるまで待つ。
11
+ class App
12
+ attr_reader :server, :browser, :page_session
13
+
14
+ MIRROR_WIDTH = 1280
15
+
16
+ def initialize(path, fullscreen: true, port: 0, profile: nil, watch: true, headless: false, size: [1280, 720],
17
+ presenter: false)
18
+ @path = path
19
+ @fullscreen = fullscreen
20
+ @port = port
21
+ @profile = profile
22
+ @watch = watch
23
+ @headless = headless
24
+ @size = size
25
+ @presenter = presenter
26
+ @done = Queue.new
27
+ end
28
+
29
+ def start
30
+ @server = Server.new(@path, port: @port, on_quit: -> { @done << :quit }).start
31
+ @server.on_open_presenter = -> { open_presenter }
32
+ @server.on_frame_request = -> { send_frame }
33
+ @browser = Browser.new(@server.boot_url, fullscreen: @fullscreen, profile: @profile,
34
+ headless: @headless, size: @size).launch
35
+ @page_session = @browser.attach_page
36
+ cdp = @browser.cdp
37
+ @target_id = cdp.call("Target.getTargetInfo", {}, session_id: @page_session)["targetInfo"]["targetId"]
38
+ cdp.on("Target.targetDestroyed") { |params, _| @done << :closed if params["targetId"] == @target_id }
39
+ cdp.on_close { @done << :closed }
40
+ cdp.call("Target.setDiscoverTargets", { discover: true })
41
+
42
+ # 埋め込み用の設定が終わってから本番ページへ遷移する
43
+ FrameUnblocker.new(cdp).attach(@page_session)
44
+ cdp.call("Page.navigate", { url: @server.app_url }, session_id: @page_session)
45
+ start_watcher if @watch
46
+ open_presenter if @presenter
47
+ self
48
+ rescue StandardError
49
+ stop # 起動途中で失敗しても Chrome を残さない
50
+ raise
51
+ end
52
+
53
+ def wait
54
+ @done.pop
55
+ end
56
+
57
+ # シグナルハンドラからも呼べるよう、Queue への追加だけにしている
58
+ def request_stop
59
+ @done << :signal
60
+ end
61
+
62
+ def stop
63
+ @listener&.stop
64
+ @browser&.close
65
+ @server&.stop
66
+ end
67
+
68
+ private
69
+
70
+ def open_presenter
71
+ now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
72
+ return if @server.presenter_connected? || (@opened_at && now - @opened_at < 5) # キーの連打対策
73
+
74
+ @opened_at = now
75
+ @browser.open_window(@server.presenter_url)
76
+ Thread.new { arrange_displays } unless @headless
77
+ end
78
+
79
+ # 聴衆画面の見た目を発表者ビューへ送る。screencast と違い、ウィンドウが
80
+ # 他のウィンドウの裏にあっても最新の画面を取れる
81
+ def send_frame
82
+ cdp = @browser.cdp
83
+ view = cdp.call("Page.getLayoutMetrics", {}, session_id: @page_session)["cssLayoutViewport"]
84
+ clip = { x: 0, y: 0, width: view["clientWidth"], height: view["clientHeight"],
85
+ scale: [MIRROR_WIDTH.to_f / view["clientWidth"], 1].min }
86
+ frame = cdp.call("Page.captureScreenshot", { format: "jpeg", quality: 75, clip: clip, optimizeForSpeed: true },
87
+ session_id: @page_session)
88
+ @server.send_frame(frame["data"])
89
+ rescue Cdp::Error => e
90
+ warn "[mp] #{e.message}" if ENV["MP_DEBUG"]
91
+ end
92
+
93
+ # 外部ディスプレイがあれば聴衆画面をそちらの全画面に、発表者ビューを手元に置く
94
+ def arrange_displays
95
+ screens = detect_screens
96
+ external = screens.find { !_1["isPrimary"] }
97
+ return warn("[mp] 外部ディスプレイが見つかりません。発表者ビューが全画面の裏に隠れたら、スペースを切り替えてください") unless external
98
+
99
+ primary = screens.find { _1["isPrimary"] } || screens.first
100
+ cdp = @browser.cdp
101
+ presenter = wait_for_presenter_target
102
+ audience = cdp.call("Browser.getWindowForTarget", { targetId: @target_id })
103
+ unless inside_screen?(audience["bounds"], external)
104
+ set_bounds(audience["windowId"], windowState: "normal")
105
+ sleep 1 # macOS の全画面解除アニメーションの間は移動できない
106
+ set_bounds(audience["windowId"], **screen_bounds(external))
107
+ sleep 0.3
108
+ set_bounds(audience["windowId"], windowState: "fullscreen") if @fullscreen
109
+ end
110
+ return unless presenter
111
+
112
+ window = cdp.call("Browser.getWindowForTarget", { targetId: presenter["targetId"] })["windowId"]
113
+ set_bounds(window, **screen_bounds(primary))
114
+ rescue Cdp::Error => e
115
+ warn "[mp] ウィンドウを配置できませんでした: #{e.message}"
116
+ end
117
+
118
+ def detect_screens
119
+ cdp = @browser.cdp
120
+ cdp.call("Browser.grantPermissions", { permissions: ["windowManagement"], origin: @server.origin })
121
+ expression = <<~JS
122
+ getScreenDetails().then((details) => JSON.stringify(details.screens.map((s) => ({
123
+ left: s.availLeft, top: s.availTop, width: s.availWidth, height: s.availHeight, isPrimary: s.isPrimary,
124
+ }))))
125
+ JS
126
+ reply = cdp.call("Runtime.evaluate", { expression: expression, awaitPromise: true, returnByValue: true, userGesture: true },
127
+ session_id: @page_session)
128
+ JSON.parse(reply.dig("result", "value") || "[]")
129
+ end
130
+
131
+ def wait_for_presenter_target
132
+ 50.times do
133
+ target = @browser.cdp.call("Target.getTargets")["targetInfos"].find { _1["url"] == @server.presenter_url }
134
+ return target if target
135
+
136
+ sleep 0.1
137
+ end
138
+ nil
139
+ end
140
+
141
+ def inside_screen?(bounds, screen)
142
+ bounds["left"].between?(screen["left"], screen["left"] + screen["width"] - 1) &&
143
+ bounds["top"].between?(screen["top"], screen["top"] + screen["height"] - 1)
144
+ end
145
+
146
+ def screen_bounds(screen) = screen.slice("left", "top", "width", "height").transform_keys(&:to_sym)
147
+
148
+ def set_bounds(window_id, **bounds)
149
+ @browser.cdp.call("Browser.setWindowBounds", { windowId: window_id, bounds: bounds })
150
+ end
151
+
152
+ def start_watcher
153
+ dir = File.dirname(File.expand_path(@path))
154
+ @listener = Listen.to(dir, only: /\.(md|markdown|css)\z/) { @server.reload }
155
+ @listener.start
156
+ end
157
+ end
158
+ end
data/lib/mp/browser.rb ADDED
@@ -0,0 +1,119 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "json"
5
+ require "net/http"
6
+ require "tmpdir"
7
+ require_relative "cdp"
8
+
9
+ module Mp
10
+ # Chrome をアプリモードで起動し、CDP で接続する。
11
+ class Browser
12
+ class NotFound < StandardError; end
13
+
14
+ CANDIDATES = [
15
+ "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
16
+ "/Applications/Chromium.app/Contents/MacOS/Chromium",
17
+ "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
18
+ "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser"
19
+ ].freeze
20
+ COMMANDS = %w[google-chrome google-chrome-stable chromium chromium-browser microsoft-edge].freeze
21
+
22
+ attr_reader :cdp, :pid
23
+
24
+ def self.find_executable
25
+ return ENV["MP_BROWSER"] if ENV["MP_BROWSER"]
26
+
27
+ found = CANDIDATES.find { |path| File.executable?(path) }
28
+ found ||= COMMANDS.filter_map { |cmd|
29
+ ENV.fetch("PATH", "").split(File::PATH_SEPARATOR).map { |dir| File.join(dir, cmd) }
30
+ .find { |path| File.executable?(path) }
31
+ }.first
32
+ found or raise NotFound, "Chrome 系ブラウザが見つかりません。MP_BROWSER でパスを指定してください"
33
+ end
34
+
35
+ def initialize(url, fullscreen: true, profile: nil, headless: false, size: [1280, 720])
36
+ @url = url
37
+ @fullscreen = fullscreen
38
+ @headless = headless
39
+ @size = size
40
+ @temp_profile = profile.nil?
41
+ @profile = profile || Dir.mktmpdir("mp-chrome-")
42
+ FileUtils.mkdir_p(@profile)
43
+ end
44
+
45
+ def launch
46
+ args = [
47
+ "--user-data-dir=#{@profile}",
48
+ "--remote-debugging-port=0",
49
+ "--no-first-run",
50
+ "--no-default-browser-check",
51
+ "--disable-features=Translate",
52
+ "--window-size=#{@size.join(",")}"
53
+ ]
54
+ args << "--start-fullscreen" if @fullscreen && !@headless
55
+ args << "--headless=new" if @headless
56
+ args << (@headless ? @url : "--app=#{@url}") # ヘッドレスは --app を無視する
57
+
58
+ @pid = Process.spawn(self.class.find_executable, *args, out: File::NULL, err: File::NULL)
59
+ @cdp = Cdp.new(wait_for_debugger_url)
60
+ self
61
+ end
62
+
63
+ # 表示用ページに接続して session id を返す。起動直後はターゲットが無いことがあるので待つ
64
+ def attach_page
65
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + 15
66
+ loop do
67
+ target = @cdp.call("Target.getTargets")["targetInfos"].find do |info|
68
+ %w[page app].include?(info["type"]) && info["url"].start_with?(@url)
69
+ end
70
+ return @cdp.call("Target.attachToTarget", { targetId: target["targetId"], flatten: true })["sessionId"] if target
71
+ raise Cdp::Error, "ページを取得できません" if Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline
72
+
73
+ sleep 0.05
74
+ end
75
+ end
76
+
77
+ # 同じプロファイルで --app を起動すると、動いている Chrome にアプリウィンドウが増える
78
+ def open_window(url)
79
+ return @cdp.call("Target.createTarget", { url: url }) if @headless
80
+
81
+ Process.detach(Process.spawn(self.class.find_executable, "--user-data-dir=#{@profile}", "--app=#{url}",
82
+ out: File::NULL, err: File::NULL))
83
+ end
84
+
85
+ def wait
86
+ Process.wait(@pid)
87
+ @pid = nil
88
+ rescue Errno::ECHILD
89
+ @pid = nil
90
+ end
91
+
92
+ def close
93
+ @cdp&.close
94
+ if @pid
95
+ Process.kill("TERM", @pid)
96
+ Process.wait(@pid)
97
+ end
98
+ rescue Errno::ESRCH, Errno::ECHILD
99
+ nil
100
+ ensure
101
+ @pid = nil
102
+ FileUtils.rm_rf(@profile) if @temp_profile
103
+ end
104
+
105
+ private
106
+
107
+ def wait_for_debugger_url
108
+ port_file = File.join(@profile, "DevToolsActivePort")
109
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + 15
110
+ until File.size?(port_file)
111
+ raise Cdp::Error, "ブラウザの起動を確認できません" if Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline
112
+
113
+ sleep 0.05
114
+ end
115
+ port, path = File.read(port_file).lines.map(&:strip)
116
+ "ws://127.0.0.1:#{port}#{path}"
117
+ end
118
+ end
119
+ end
data/lib/mp/cdp.rb ADDED
@@ -0,0 +1,119 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "monitor"
5
+ require "socket"
6
+ require "uri"
7
+ require "websocket/driver"
8
+
9
+ module Mp
10
+ # Chrome DevTools Protocol の最小クライアント(flatten セッション前提)。
11
+ class Cdp
12
+ class Error < StandardError; end
13
+
14
+ attr_reader :url
15
+
16
+ def initialize(ws_url, timeout: 10)
17
+ @url = ws_url
18
+ @timeout = timeout
19
+ uri = URI(ws_url)
20
+ @socket = TCPSocket.new(uri.host, uri.port)
21
+ @write_lock = Monitor.new
22
+ @pending = {}
23
+ @pending_lock = Mutex.new
24
+ @next_id = 0
25
+ @handlers = Hash.new { |h, k| h[k] = [] }
26
+ @events = Queue.new
27
+
28
+ @driver = WebSocket::Driver.client(self, max_length: 256 * 1024 * 1024)
29
+ opened = Queue.new
30
+ @driver.on(:open) { opened << true }
31
+ @driver.on(:message) { |e| dispatch(JSON.parse(e.data)) }
32
+ @driver.on(:close) { @events.close }
33
+ @driver.start
34
+
35
+ @reader = Thread.new { read_loop }
36
+ # ハンドラ内から call しても reader を塞がないよう、イベントは別スレッドで処理する
37
+ @worker = Thread.new { event_loop }
38
+ opened.pop(timeout: @timeout) or raise Error, "CDP に接続できません: #{ws_url}"
39
+ end
40
+
41
+ # websocket-driver が呼ぶ
42
+ def write(data)
43
+ @write_lock.synchronize { @socket.write(data) }
44
+ end
45
+
46
+ def on(method, &block)
47
+ @handlers[method] << block
48
+ end
49
+
50
+ def on_close(&block)
51
+ @on_close = block
52
+ end
53
+
54
+ def call(method, params = {}, session_id: nil)
55
+ reply = Queue.new
56
+ id = @pending_lock.synchronize do
57
+ @next_id += 1
58
+ @pending[@next_id] = reply
59
+ @next_id
60
+ end
61
+ message = { id: id, method: method, params: params }
62
+ message[:sessionId] = session_id if session_id
63
+ @write_lock.synchronize { @driver.text(JSON.generate(message)) }
64
+
65
+ response = reply.pop(timeout: @timeout)
66
+ raise Error, "CDP タイムアウト: #{method}" unless response
67
+ raise Error, "#{method}: #{response["error"]["message"]}" if response["error"]
68
+
69
+ response["result"]
70
+ ensure
71
+ @pending_lock.synchronize { @pending.delete(id) }
72
+ end
73
+
74
+ # 応答を待たない呼び出し。ターゲットが消えた後の失敗は無視してよい場面で使う
75
+ def cast(method, params = {}, session_id: nil)
76
+ message = { id: 0, method: method, params: params }
77
+ message[:sessionId] = session_id if session_id
78
+ @write_lock.synchronize { @driver.text(JSON.generate(message)) }
79
+ rescue IOError, SystemCallError
80
+ nil
81
+ end
82
+
83
+ def close
84
+ @socket.close
85
+ rescue IOError, SystemCallError
86
+ nil
87
+ end
88
+
89
+ private
90
+
91
+ def read_loop
92
+ loop { @driver.parse(@socket.readpartial(65_536)) }
93
+ rescue IOError, SystemCallError
94
+ @events.close
95
+ end
96
+
97
+ def dispatch(message)
98
+ if message.key?("id")
99
+ reply = @pending_lock.synchronize { @pending[message["id"]] }
100
+ reply&.push(message)
101
+ else
102
+ @events << message
103
+ end
104
+ rescue ClosedQueueError
105
+ nil
106
+ end
107
+
108
+ def event_loop
109
+ while (event = @events.pop)
110
+ @handlers[event["method"]].each do |handler|
111
+ handler.call(event["params"], event["sessionId"])
112
+ rescue Error, IOError, SystemCallError => e
113
+ warn "[mp] CDP: #{e.message}" if ENV["MP_DEBUG"]
114
+ end
115
+ end
116
+ @on_close&.call
117
+ end
118
+ end
119
+ end
data/lib/mp/cli.rb ADDED
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+ require_relative "../mp"
5
+
6
+ module Mp
7
+ module Cli
8
+ def self.run(argv)
9
+ options = { fullscreen: true, watch: true }
10
+ parser = OptionParser.new do |o|
11
+ o.banner = "使い方: mp [オプション] deck.md"
12
+ o.on("-w", "--window", "全画面にせずウィンドウで開く") { options[:fullscreen] = false }
13
+ o.on("-P", "--presenter", "発表者ビューも開く(外部ディスプレイがあれば聴衆画面をそちらへ移す)") { options[:presenter] = true }
14
+ o.on("-p", "--port PORT", Integer, "ローカルサーバのポート(既定: 空きポート)") { options[:port] = _1 }
15
+ o.on("--profile DIR", "Chrome のプロファイルを保存する(埋め込みページのログインを保持)") { options[:profile] = _1 }
16
+ o.on("--no-watch", "ファイル変更時に再読み込みしない") { options[:watch] = false }
17
+ o.on("-v", "--version") { puts VERSION; return 0 }
18
+ end
19
+ path = parser.parse(argv).first
20
+ unless path && File.file?(path)
21
+ warn(path ? "ファイルが見つかりません: #{path}" : parser.help)
22
+ return 1
23
+ end
24
+
25
+ app = App.new(path, **options)
26
+ %w[INT TERM].each { |signal| trap(signal) { app.request_stop } }
27
+ app.start
28
+ app.wait
29
+ app.stop
30
+ 0
31
+ rescue Browser::NotFound, OptionParser::ParseError, Cdp::Error => e
32
+ warn e.message
33
+ 1
34
+ end
35
+ end
36
+ end