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.
- checksums.yaml +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +206 -0
- data/exe/mp +6 -0
- data/lib/mp/app.rb +158 -0
- data/lib/mp/browser.rb +119 -0
- data/lib/mp/cdp.rb +119 -0
- data/lib/mp/cli.rb +36 -0
- data/lib/mp/deck.rb +268 -0
- data/lib/mp/frame_unblocker.rb +77 -0
- data/lib/mp/server.rb +224 -0
- data/lib/mp/slide_renderer.rb +184 -0
- data/lib/mp/terminal.rb +41 -0
- data/lib/mp/version.rb +5 -0
- data/lib/mp/web_socket.rb +49 -0
- data/lib/mp.rb +12 -0
- data/public/app.js +306 -0
- data/public/index.html +37 -0
- data/public/presenter.css +33 -0
- data/public/presenter.html +41 -0
- data/public/presenter.js +168 -0
- data/public/slides.js +35 -0
- data/public/style.css +93 -0
- data/themes/dark.css +26 -0
- data/themes/default.css +27 -0
- data/vendor/assets/katex/LICENSE +21 -0
- data/vendor/assets/katex/auto-render.min.js +1 -0
- data/vendor/assets/katex/fonts/KaTeX_AMS-Regular.ttf +0 -0
- data/vendor/assets/katex/fonts/KaTeX_AMS-Regular.woff +0 -0
- data/vendor/assets/katex/fonts/KaTeX_AMS-Regular.woff2 +0 -0
- data/vendor/assets/katex/fonts/KaTeX_Caligraphic-Bold.ttf +0 -0
- data/vendor/assets/katex/fonts/KaTeX_Caligraphic-Bold.woff +0 -0
- data/vendor/assets/katex/fonts/KaTeX_Caligraphic-Bold.woff2 +0 -0
- data/vendor/assets/katex/fonts/KaTeX_Caligraphic-Regular.ttf +0 -0
- data/vendor/assets/katex/fonts/KaTeX_Caligraphic-Regular.woff +0 -0
- data/vendor/assets/katex/fonts/KaTeX_Caligraphic-Regular.woff2 +0 -0
- data/vendor/assets/katex/fonts/KaTeX_Fraktur-Bold.ttf +0 -0
- data/vendor/assets/katex/fonts/KaTeX_Fraktur-Bold.woff +0 -0
- data/vendor/assets/katex/fonts/KaTeX_Fraktur-Bold.woff2 +0 -0
- data/vendor/assets/katex/fonts/KaTeX_Fraktur-Regular.ttf +0 -0
- data/vendor/assets/katex/fonts/KaTeX_Fraktur-Regular.woff +0 -0
- data/vendor/assets/katex/fonts/KaTeX_Fraktur-Regular.woff2 +0 -0
- data/vendor/assets/katex/fonts/KaTeX_Main-Bold.ttf +0 -0
- data/vendor/assets/katex/fonts/KaTeX_Main-Bold.woff +0 -0
- data/vendor/assets/katex/fonts/KaTeX_Main-Bold.woff2 +0 -0
- data/vendor/assets/katex/fonts/KaTeX_Main-BoldItalic.ttf +0 -0
- data/vendor/assets/katex/fonts/KaTeX_Main-BoldItalic.woff +0 -0
- data/vendor/assets/katex/fonts/KaTeX_Main-BoldItalic.woff2 +0 -0
- data/vendor/assets/katex/fonts/KaTeX_Main-Italic.ttf +0 -0
- data/vendor/assets/katex/fonts/KaTeX_Main-Italic.woff +0 -0
- data/vendor/assets/katex/fonts/KaTeX_Main-Italic.woff2 +0 -0
- data/vendor/assets/katex/fonts/KaTeX_Main-Regular.ttf +0 -0
- data/vendor/assets/katex/fonts/KaTeX_Main-Regular.woff +0 -0
- data/vendor/assets/katex/fonts/KaTeX_Main-Regular.woff2 +0 -0
- data/vendor/assets/katex/fonts/KaTeX_Math-BoldItalic.ttf +0 -0
- data/vendor/assets/katex/fonts/KaTeX_Math-BoldItalic.woff +0 -0
- data/vendor/assets/katex/fonts/KaTeX_Math-BoldItalic.woff2 +0 -0
- data/vendor/assets/katex/fonts/KaTeX_Math-Italic.ttf +0 -0
- data/vendor/assets/katex/fonts/KaTeX_Math-Italic.woff +0 -0
- data/vendor/assets/katex/fonts/KaTeX_Math-Italic.woff2 +0 -0
- data/vendor/assets/katex/fonts/KaTeX_SansSerif-Bold.ttf +0 -0
- data/vendor/assets/katex/fonts/KaTeX_SansSerif-Bold.woff +0 -0
- data/vendor/assets/katex/fonts/KaTeX_SansSerif-Bold.woff2 +0 -0
- data/vendor/assets/katex/fonts/KaTeX_SansSerif-Italic.ttf +0 -0
- data/vendor/assets/katex/fonts/KaTeX_SansSerif-Italic.woff +0 -0
- data/vendor/assets/katex/fonts/KaTeX_SansSerif-Italic.woff2 +0 -0
- data/vendor/assets/katex/fonts/KaTeX_SansSerif-Regular.ttf +0 -0
- data/vendor/assets/katex/fonts/KaTeX_SansSerif-Regular.woff +0 -0
- data/vendor/assets/katex/fonts/KaTeX_SansSerif-Regular.woff2 +0 -0
- data/vendor/assets/katex/fonts/KaTeX_Script-Regular.ttf +0 -0
- data/vendor/assets/katex/fonts/KaTeX_Script-Regular.woff +0 -0
- data/vendor/assets/katex/fonts/KaTeX_Script-Regular.woff2 +0 -0
- data/vendor/assets/katex/fonts/KaTeX_Size1-Regular.ttf +0 -0
- data/vendor/assets/katex/fonts/KaTeX_Size1-Regular.woff +0 -0
- data/vendor/assets/katex/fonts/KaTeX_Size1-Regular.woff2 +0 -0
- data/vendor/assets/katex/fonts/KaTeX_Size2-Regular.ttf +0 -0
- data/vendor/assets/katex/fonts/KaTeX_Size2-Regular.woff +0 -0
- data/vendor/assets/katex/fonts/KaTeX_Size2-Regular.woff2 +0 -0
- data/vendor/assets/katex/fonts/KaTeX_Size3-Regular.ttf +0 -0
- data/vendor/assets/katex/fonts/KaTeX_Size3-Regular.woff +0 -0
- data/vendor/assets/katex/fonts/KaTeX_Size3-Regular.woff2 +0 -0
- data/vendor/assets/katex/fonts/KaTeX_Size4-Regular.ttf +0 -0
- data/vendor/assets/katex/fonts/KaTeX_Size4-Regular.woff +0 -0
- data/vendor/assets/katex/fonts/KaTeX_Size4-Regular.woff2 +0 -0
- data/vendor/assets/katex/fonts/KaTeX_Typewriter-Regular.ttf +0 -0
- data/vendor/assets/katex/fonts/KaTeX_Typewriter-Regular.woff +0 -0
- data/vendor/assets/katex/fonts/KaTeX_Typewriter-Regular.woff2 +0 -0
- data/vendor/assets/katex/katex.min.css +1 -0
- data/vendor/assets/katex/katex.min.js +1 -0
- data/vendor/assets/xterm/LICENSE +21 -0
- data/vendor/assets/xterm/addon-fit.js +2 -0
- data/vendor/assets/xterm/xterm.css +285 -0
- data/vendor/assets/xterm/xterm.js +2 -0
- 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
|
+
 サイズ指定
|
|
97
|
+
 フィルタ
|
|
98
|
+
 背景(複数並べると分割)
|
|
99
|
+
 cover / contain / fit / auto / 50%
|
|
100
|
+
 画面を分割して片側に配置
|
|
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
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
|