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
data/lib/mp/deck.rb ADDED
@@ -0,0 +1,268 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "cgi"
4
+ require "shellwords"
5
+ require "yaml"
6
+ require_relative "slide_renderer"
7
+
8
+ module Mp
9
+ # Markdown をスライドへ分割する。
10
+ #
11
+ # # H1 → 表紙スライド
12
+ # ## H2 → 通常スライド(H2 がページタイトル)
13
+ # --- → タイトルなしの続きスライド
14
+ #
15
+ # kramdown に渡す前に行単位で走査する。kramdown は info 文字列に属性を持つ
16
+ # フェンスを認識せず、`$x$` も扱えないため、独自記法はここでトークンに置換する。
17
+ class Deck
18
+ GLOBAL_DIRECTIVES = %w[theme size style title].freeze
19
+ LOCAL_DIRECTIVES = %w[
20
+ paginate header footer class color
21
+ backgroundColor backgroundImage backgroundPosition backgroundRepeat backgroundSize
22
+ ].freeze
23
+ SIZES = { "16:9" => [1280, 720], "4:3" => [960, 720] }.freeze
24
+ FENCE = /\A {0,3}(`{3,}|~{3,})\s*(.*?)\s*\z/
25
+ INLINE = /(?<code>(?<ticks>`+).+?\k<ticks>)|<!--(?<comment>.*?)-->|\$\$(?<display>.+?)\$\$|(?<![\\\w$])\$(?=\S)(?<math>[^$\n]+?)(?<=\S)\$(?![\d$])/
26
+ CSS_LENGTH = /\A\d+(\.\d+)?(%|px)?\z/
27
+
28
+ Chunk = Struct.new(:kind, :title, :lines, :comments, :fit, keyword_init: true)
29
+
30
+ attr_reader :slides, :globals, :embeds, :base_dir
31
+
32
+ def self.load(path)
33
+ new(File.read(path, encoding: "UTF-8"), base_dir: File.dirname(File.expand_path(path)))
34
+ end
35
+
36
+ def initialize(source, base_dir: Dir.pwd)
37
+ @base_dir = base_dir
38
+ @globals = {}
39
+ @embeds = {}
40
+ @tokens = {}
41
+ body, front = split_front_matter(source.gsub("\r\n", "\n"))
42
+ @state = {}
43
+ apply_directives(front, {})
44
+ @slides = build_slides(scan(body))
45
+ end
46
+
47
+ def size
48
+ SIZES.fetch(@globals["size"].to_s, SIZES["16:9"])
49
+ end
50
+
51
+ def title
52
+ @globals["title"] || @slides.find { _1[:kind] == :cover }&.fetch(:title) || "mp"
53
+ end
54
+
55
+ # ブラウザへ渡す情報。cwd はサーバ側だけで使うので含めない
56
+ def to_h
57
+ {
58
+ title: title,
59
+ size: size,
60
+ style: @globals["style"].to_s,
61
+ slides: @slides.map { _1.slice(:html, :notes) },
62
+ embeds: @embeds.transform_values { _1.except(:cwd) }
63
+ }
64
+ end
65
+
66
+ private
67
+
68
+ def split_front_matter(source)
69
+ match = source.match(/\A---[ \t]*\n(.*?)^---[ \t]*(\n|\z)/m)
70
+ return [source, {}] unless match
71
+
72
+ front = load_yaml(match[1]) || {}
73
+ [match.post_match, front.is_a?(Hash) ? front : {}]
74
+ end
75
+
76
+ # YAML 1.1 では `4:3` が 60 進数の 243 になるので、文字列のまま読ませる
77
+ def load_yaml(text)
78
+ YAML.safe_load(text.gsub(/^(\s*_?size:\s*)(\d+:\d+)\s*$/, '\1"\2"'))
79
+ end
80
+
81
+ def scan(body)
82
+ chunks = [Chunk.new(kind: :preamble, lines: [], comments: [])]
83
+ lines = body.split("\n", -1)
84
+ index = 0
85
+ while index < lines.length
86
+ line = lines[index]
87
+ if (fence = line.match(FENCE))
88
+ index = read_fence(lines, index, fence, chunks.last)
89
+ elsif line.match?(/\A\s*\$\$\s*\z/)
90
+ index = read_math_block(lines, index, chunks.last)
91
+ else
92
+ index = read_line(lines, index, chunks)
93
+ end
94
+ end
95
+ chunks
96
+ end
97
+
98
+ def read_fence(lines, index, fence, chunk)
99
+ marker = fence[1]
100
+ close = /\A {0,3}#{Regexp.escape(marker[0])}{#{marker.length},}\s*\z/
101
+ stop = (index + 1...lines.length).find { lines[_1].match?(close) } || lines.length
102
+ inner = lines[index + 1...stop]
103
+ name, *attrs = Shellwords.split(fence[2]) rescue [fence[2].split.first]
104
+ if %w[terminal web].include?(name)
105
+ chunk.lines.push("", embed_token(name, parse_attrs(attrs), inner), "")
106
+ else
107
+ # kramdown は言語名だけを受け付ける
108
+ chunk.lines.push("#{marker}#{name}", *inner, marker)
109
+ end
110
+ stop + 1
111
+ end
112
+
113
+ def read_math_block(lines, index, chunk)
114
+ stop = (index + 1...lines.length).find { lines[_1].match?(/\A\s*\$\$\s*\z/) } || lines.length
115
+ chunk.lines.push("", math_token(lines[index + 1...stop].join("\n"), true), "")
116
+ stop + 1
117
+ end
118
+
119
+ def read_line(lines, index, chunks)
120
+ line, index = strip_inline(lines, index, chunks.last)
121
+ following = lines[index]
122
+ if (heading = line.match(/\A {0,3}(#{"#"}{1,2})\s+(.*?)\s*#*\s*\z/))
123
+ start_chunk(chunks, heading[1].length, heading[2], line)
124
+ elsif following&.match?(/\A {0,3}(=+|-+)\s*\z/) && !line.strip.empty? && !line.match?(/\A\s*[-*+>|#]/)
125
+ start_chunk(chunks, following.lstrip.start_with?("=") ? 1 : 2, line.strip, "#{line}\n#{following}")
126
+ return index + 1
127
+ elsif line.match?(/\A {0,3}(-{3,}|\*{3,}|_{3,})\s*\z/)
128
+ chunks << Chunk.new(kind: :continuation, lines: [], comments: [])
129
+ else
130
+ chunks.last.lines << line
131
+ end
132
+ index
133
+ end
134
+
135
+ def start_chunk(chunks, level, title, source)
136
+ fit = @pending_fit
137
+ @pending_fit = false
138
+ chunks << Chunk.new(kind: level == 1 ? :cover : :slide, title: title, lines: [source], comments: [], fit: fit)
139
+ # 見出し行より前に読んだコメントは前のチャンクに残るので、見出し行の分だけ移す
140
+ chunks.last.comments.concat(@heading_comments)
141
+ end
142
+
143
+ # コメントと数式を行から抜き出す。次に読むべき行番号も返す
144
+ def strip_inline(lines, index, chunk)
145
+ @heading_comments = []
146
+ heading = lines[index].match?(/\A {0,3}\#{1,2}\s/)
147
+ line = lines[index].gsub(INLINE) do
148
+ match = Regexp.last_match
149
+ if match[:code] then match[:code]
150
+ elsif match[:comment]
151
+ text = match[:comment].strip
152
+ if heading && text == "fit" then @pending_fit = true
153
+ elsif heading then @heading_comments << text
154
+ else chunk.comments << text
155
+ end
156
+ ""
157
+ elsif match[:display] then math_token(match[:display], true)
158
+ else math_token(match[:math], false)
159
+ end
160
+ end
161
+ index += 1
162
+
163
+ # 複数行コメント
164
+ start = line.gsub(/(`+).+?\1/, "").index("<!--") && line.index("<!--")
165
+ return [line, index] unless start
166
+
167
+ text = [line[(start + 4)..]]
168
+ line = line[0...start]
169
+ until index >= lines.length || lines[index].include?("-->")
170
+ text << lines[index]
171
+ index += 1
172
+ end
173
+ if index < lines.length
174
+ head, tail = lines[index].split("-->", 2)
175
+ text << head
176
+ line += tail.to_s
177
+ index += 1
178
+ end
179
+ chunk.comments << text.join("\n").strip
180
+ [line, index]
181
+ end
182
+
183
+ def parse_attrs(attrs)
184
+ attrs.to_h { |attr| attr.include?("=") ? attr.split("=", 2) : [attr, "true"] }
185
+ end
186
+
187
+ def embed_token(type, attrs, inner)
188
+ id = "e#{@embeds.length + 1}"
189
+ style = %w[width height].filter_map { |key|
190
+ value = attrs[key]
191
+ "#{key}:#{value}#{"px" if value.match?(/\A[\d.]+\z/)};flex:none" if value&.match?(CSS_LENGTH)
192
+ }.join(";")
193
+ @embeds[id] = type == "web" ? web_embed(attrs, inner) : terminal_embed(attrs, inner)
194
+ token(%(<div class="embed embed-#{type}" data-embed="#{id}" style="#{CGI.escapeHTML(style)}"></div>))
195
+ end
196
+
197
+ def terminal_embed(attrs, inner)
198
+ {
199
+ type: "terminal",
200
+ cwd: resolve_cwd(attrs["cwd"]),
201
+ commands: inner.map(&:strip).reject(&:empty?),
202
+ font: attrs["font"].to_i.positive? ? attrs["font"].to_i : 18
203
+ }
204
+ end
205
+
206
+ def web_embed(attrs, inner)
207
+ src = inner.map(&:strip).reject(&:empty?).first.to_s
208
+ src = "" if src.match?(/\A[a-z][a-z0-9+.-]*:/i) && !src.match?(%r{\Ahttps?://}i)
209
+ { type: "web", src: src, zoom: attrs["zoom"].to_f.positive? ? attrs["zoom"].to_f : 1.0 }
210
+ end
211
+
212
+ def resolve_cwd(cwd)
213
+ path = File.expand_path(cwd.to_s, @base_dir)
214
+ File.directory?(path) ? path : @base_dir
215
+ end
216
+
217
+ def math_token(tex, display)
218
+ token(%(<span class="math" data-tex="#{CGI.escapeHTML(tex)}" data-display="#{display}"></span>))
219
+ end
220
+
221
+ def token(html)
222
+ key = "MPTOKEN#{@tokens.length}X"
223
+ @tokens[key] = html
224
+ key
225
+ end
226
+
227
+ def parse_directive(comment)
228
+ data = load_yaml(comment)
229
+ return unless data.is_a?(Hash) && !data.empty?
230
+
231
+ known = GLOBAL_DIRECTIVES + LOCAL_DIRECTIVES
232
+ data if data.keys.all? { known.include?(_1.to_s.delete_prefix("_")) }
233
+ rescue Psych::Exception
234
+ nil
235
+ end
236
+
237
+ def apply_directives(data, spot)
238
+ data.each do |key, value|
239
+ key = key.to_s
240
+ if GLOBAL_DIRECTIVES.include?(key) then @globals[key] = value
241
+ elsif key.start_with?("_") then spot[key.delete_prefix("_")] = value
242
+ else @state[key] = value
243
+ end
244
+ end
245
+ end
246
+
247
+ def build_slides(chunks)
248
+ chunks = chunks.reject { |chunk|
249
+ # `---` の直後に見出しが来る書き方でも空のスライドを作らない
250
+ next false unless %i[preamble continuation].include?(chunk.kind) && chunk.lines.all? { _1.strip.empty? }
251
+
252
+ chunk.comments.each { |comment| parse_directive(comment)&.then { apply_directives(_1, {}) } }
253
+ true
254
+ }
255
+ total = chunks.length
256
+ chunks.each_with_index.map do |chunk, index|
257
+ spot = {}
258
+ notes = chunk.comments.reject do |comment|
259
+ directive = parse_directive(comment)
260
+ apply_directives(directive, spot) if directive
261
+ directive
262
+ end
263
+ SlideRenderer.new(chunk, index:, total:, directives: @state.merge(spot), tokens: @tokens)
264
+ .render.merge(kind: chunk.kind, title: chunk.title, notes: notes.join("\n\n"))
265
+ end
266
+ end
267
+ end
268
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mp
4
+ # 埋め込みを拒否するレスポンスヘッダだけを取り除く。
5
+ # 本文には触れないので、プロキシと違いページ自体は壊れない。
6
+ class FrameUnblocker
7
+ REQUEST_PATTERNS = [{ urlPattern: "*", resourceType: "Document", requestStage: "Response" }].freeze
8
+
9
+ def self.strip_headers(headers)
10
+ changed = false
11
+ result = headers.filter_map do |header|
12
+ case header["name"].downcase
13
+ when "x-frame-options"
14
+ changed = true
15
+ nil
16
+ when "content-security-policy"
17
+ policy = strip_frame_ancestors(header["value"])
18
+ changed ||= policy != header["value"]
19
+ policy.empty? ? nil : header.merge("value" => policy)
20
+ else
21
+ header
22
+ end
23
+ end
24
+ changed ? result : nil
25
+ end
26
+
27
+ def self.strip_frame_ancestors(policy)
28
+ # 複数ポリシーがカンマで連結されている場合があるので、ポリシーごとに処理する
29
+ policy.split(",").map { |one|
30
+ one.split(";").reject { |d| d.strip.downcase.start_with?("frame-ancestors") }.join(";").strip
31
+ }.reject(&:empty?).join(", ")
32
+ end
33
+
34
+ def initialize(cdp)
35
+ @cdp = cdp
36
+ end
37
+
38
+ # ページを開く前に呼ぶこと。遷移後だと最初の iframe のレスポンスに間に合わない
39
+ def attach(page_session)
40
+ @cdp.on("Target.attachedToTarget") { |params, _| prepare_child(params) }
41
+ @cdp.on("Fetch.requestPaused") { |params, session| rewrite(params, session) }
42
+ watch(page_session)
43
+ end
44
+
45
+ private
46
+
47
+ # サイトをまたぐ iframe は別プロセスの子ターゲットになるので、そちらにも同じ設定をする
48
+ def prepare_child(params)
49
+ session = params["sessionId"]
50
+ watch(session)
51
+ @cdp.call("Runtime.runIfWaitingForDebugger", {}, session_id: session)
52
+ end
53
+
54
+ def watch(session)
55
+ @cdp.call("Fetch.enable", { patterns: REQUEST_PATTERNS }, session_id: session)
56
+ @cdp.call("Target.setAutoAttach", { autoAttach: true, waitForDebuggerOnStart: true, flatten: true },
57
+ session_id: session)
58
+ end
59
+
60
+ def rewrite(params, session)
61
+ id = params["requestId"]
62
+ headers = params["responseHeaders"] && self.class.strip_headers(params["responseHeaders"])
63
+ unless headers
64
+ @cdp.call("Fetch.continueResponse", { requestId: id }, session_id: session)
65
+ return
66
+ end
67
+
68
+ # continueResponse で渡したヘッダは表示上は変わるが、Chrome はパース済みの
69
+ # ポリシーで判定を続ける。fulfillRequest ならレスポンスごと作り直される。
70
+ reply = @cdp.call("Fetch.getResponseBody", { requestId: id }, session_id: session)
71
+ body = reply["base64Encoded"] ? reply["body"] : [reply["body"]].pack("m0")
72
+ @cdp.call("Fetch.fulfillRequest",
73
+ { requestId: id, responseCode: params["responseStatusCode"], responseHeaders: headers, body: body },
74
+ session_id: session)
75
+ end
76
+ end
77
+ end
data/lib/mp/server.rb ADDED
@@ -0,0 +1,224 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "puma"
5
+ require "puma/server"
6
+ require "rack"
7
+ require "rouge"
8
+ require "securerandom"
9
+ require "websocket/driver"
10
+ require_relative "deck"
11
+ require_relative "terminal"
12
+ require_relative "web_socket"
13
+
14
+ module Mp
15
+ # デッキと埋め込みターミナルを配信するローカルサーバ。
16
+ #
17
+ # ターミナルは任意コマンドを実行できるので、127.0.0.1 のみで待ち受け、全パスに
18
+ # ランダムトークンを要求し、WebSocket は Origin も検証する。
19
+ class Server
20
+ HOST = "127.0.0.1"
21
+ PUBLIC_DIR = File.expand_path("../../public", __dir__)
22
+ VENDOR_DIR = File.expand_path("../../vendor/assets", __dir__)
23
+ THEME_DIR = File.expand_path("../../themes", __dir__)
24
+ SANDBOXED_TYPES = %w[text/html image/svg+xml application/xhtml+xml].freeze
25
+
26
+ attr_reader :token, :port, :deck
27
+ attr_accessor :on_open_presenter, :on_frame_request
28
+
29
+ def initialize(path, port: 0, on_quit: nil)
30
+ @path = File.expand_path(path)
31
+ @requested_port = port
32
+ @on_quit = on_quit
33
+ @token = SecureRandom.hex(16)
34
+ @clients = {} # socket => :audience / :presenter
35
+ @terminals = []
36
+ @lock = Mutex.new
37
+ reload
38
+ end
39
+
40
+ def start
41
+ log_writer = ENV["MP_DEBUG"] ? Puma::LogWriter.stdio : Puma::LogWriter.null
42
+ @puma = Puma::Server.new(self, nil, min_threads: 0, max_threads: 16, log_writer: log_writer)
43
+ @port = @puma.add_tcp_listener(HOST, @requested_port).addr[1]
44
+ @puma.run
45
+ self
46
+ end
47
+
48
+ def stop
49
+ @lock.synchronize { @terminals.dup }.each(&:close)
50
+ @puma&.stop(true)
51
+ end
52
+
53
+ def origin = "http://#{HOST}:#{@port}"
54
+ def boot_url = "#{origin}/#{@token}/_mp/boot"
55
+ def app_url = "#{origin}/#{@token}/"
56
+ def presenter_url = "#{origin}/#{@token}/presenter"
57
+
58
+ def presenter_connected?
59
+ @lock.synchronize { @clients.value?(:presenter) }
60
+ end
61
+
62
+ def send_frame(data)
63
+ broadcast(JSON.generate(type: "frame", data: data), role: :presenter)
64
+ end
65
+
66
+ # デッキを読み直して表示中のページへ通知する。失敗したら前の内容のまま続ける
67
+ def reload
68
+ @deck = Deck.load(@path)
69
+ broadcast(JSON.generate(type: "reload"))
70
+ rescue Psych::Exception, SystemCallError => e
71
+ raise unless @deck
72
+
73
+ warn "[mp] 読み込みに失敗しました: #{e.message}"
74
+ end
75
+
76
+ def call(env)
77
+ path = env["PATH_INFO"]
78
+ prefix = "/#{@token}/"
79
+ return not_found unless path.start_with?(prefix)
80
+
81
+ route(path.delete_prefix(prefix), env)
82
+ end
83
+
84
+ private
85
+
86
+ def route(path, env)
87
+ case path
88
+ when "" then file(File.join(PUBLIC_DIR, "index.html"), app_headers)
89
+ when "presenter" then file(File.join(PUBLIC_DIR, "presenter.html"), app_headers)
90
+ when "_mp/boot" then [200, { "content-type" => "text/plain" }, [""]]
91
+ when "_mp/deck.json" then [200, { "content-type" => "application/json" }, [JSON.generate(deck_json)]]
92
+ when "_mp/events" then websocket(env) { |socket| serve_events(socket, env) }
93
+ when "_mp/pty" then websocket(env) { |socket| serve_terminal(socket, env) }
94
+ when %r{\A_mp/vendor/(.+)\z} then static(VENDOR_DIR, Regexp.last_match(1))
95
+ when %r{\A_mp/(.+)\z} then static(PUBLIC_DIR, Regexp.last_match(1))
96
+ else deck_file(path)
97
+ end
98
+ end
99
+
100
+ def app_headers
101
+ {
102
+ "content-security-policy" => [
103
+ "default-src 'self'", "script-src 'self'", "style-src 'self' 'unsafe-inline' https:",
104
+ "img-src * data: blob:", "media-src *", "font-src 'self' data: https:", "frame-src *",
105
+ "connect-src 'self' ws://#{HOST}:#{@port}", "object-src 'none'", "base-uri 'none'"
106
+ ].join("; ")
107
+ }
108
+ end
109
+
110
+ def deck_json
111
+ @deck.to_h.merge(themeCss: theme_css)
112
+ end
113
+
114
+ def theme_css
115
+ theme = @deck.globals["theme"].to_s
116
+ theme = "default" if theme.empty?
117
+ custom = File.expand_path(theme, @deck.base_dir)
118
+ css = if theme.end_with?(".css") && inside?(@deck.base_dir, custom) && File.file?(custom)
119
+ File.read(custom)
120
+ else
121
+ builtin = File.join(THEME_DIR, "#{File.basename(theme, ".css")}.css")
122
+ File.read(File.file?(builtin) ? builtin : File.join(THEME_DIR, "default.css"))
123
+ end
124
+ mode = css.include?("mp-dark") ? :dark : :light
125
+ "#{Rouge::Themes::Github.mode(mode).render(scope: ".highlight")}\n#{css}"
126
+ end
127
+
128
+ def inside?(dir, path)
129
+ path.start_with?("#{File.expand_path(dir)}/")
130
+ end
131
+
132
+ def static(dir, relative)
133
+ full = File.expand_path(relative, dir)
134
+ inside?(dir, full) ? file(full) : not_found
135
+ end
136
+
137
+ # デッキのあるディレクトリのファイル(画像など)
138
+ def deck_file(relative)
139
+ relative = Rack::Utils.unescape_path(relative)
140
+ return not_found if relative.split("/").any? { _1.start_with?(".") }
141
+
142
+ base = File.realpath(@deck.base_dir)
143
+ full = File.expand_path(relative, base)
144
+ return not_found unless File.file?(full)
145
+ return not_found unless inside?(base, File.realpath(full))
146
+
147
+ file(full, sandbox_headers(full))
148
+ end
149
+
150
+ # デッキ配下の HTML はアプリと同一オリジンにせず、PTY の WebSocket へ触れないようにする
151
+ def sandbox_headers(path)
152
+ return {} unless SANDBOXED_TYPES.include?(mime(path))
153
+
154
+ { "content-security-policy" => "sandbox allow-scripts allow-forms allow-popups" }
155
+ end
156
+
157
+ def mime(path) = Rack::Mime.mime_type(File.extname(path), "application/octet-stream")
158
+
159
+ def file(path, headers = {})
160
+ return not_found unless File.file?(path)
161
+
162
+ [200, { "content-type" => mime(path), "x-content-type-options" => "nosniff", "cache-control" => "no-store" }
163
+ .merge(headers), [File.binread(path)]]
164
+ end
165
+
166
+ def not_found = [404, { "content-type" => "text/plain" }, ["not found"]]
167
+
168
+ def websocket(env)
169
+ return [403, {}, ["forbidden"]] unless env["HTTP_ORIGIN"] == origin
170
+ return [400, {}, ["websocket only"]] unless WebSocket::Driver.websocket?(env)
171
+
172
+ socket = WebSocketConnection.new(env)
173
+ yield socket
174
+ socket.run
175
+ [-1, {}, []] # hijack 済み
176
+ end
177
+
178
+ def serve_events(socket, env)
179
+ presenter = Rack::Utils.parse_query(env["QUERY_STRING"])["role"] == "presenter"
180
+ @lock.synchronize { @clients[socket] = presenter ? :presenter : :audience }
181
+ socket.on_message { |message| handle_event(socket, message) }
182
+ socket.on_close { @lock.synchronize { @clients.delete(socket) } }
183
+ end
184
+
185
+ # スライドの状態は聴衆画面だけが持つ。発表者のコマンドと聴衆の状態はここで中継する
186
+ def handle_event(socket, message)
187
+ case JSON.parse(message)["type"]
188
+ when "quit" then @on_quit&.call
189
+ when "open-presenter" then @on_open_presenter&.call
190
+ when "frame" then @on_frame_request&.call
191
+ else broadcast(message, except: socket)
192
+ end
193
+ rescue JSON::ParserError
194
+ nil
195
+ end
196
+
197
+ def broadcast(message, role: nil, except: nil)
198
+ targets = @lock.synchronize { @clients.select { |socket, r| socket != except && (role.nil? || r == role) }.keys }
199
+ targets.each { _1.send_text(message) }
200
+ end
201
+
202
+ def serve_terminal(socket, env)
203
+ params = Rack::Utils.parse_query(env["QUERY_STRING"])
204
+ embed = @deck.embeds[params["id"]]
205
+ return socket.close unless embed && embed[:type] == "terminal"
206
+
207
+ terminal = Terminal.new(cwd: embed[:cwd], cols: params["cols"].to_i, rows: params["rows"].to_i)
208
+ @lock.synchronize { @terminals << terminal }
209
+ reader = Thread.new do
210
+ terminal.read_loop { |data| socket.send_binary(data) }
211
+ socket.close
212
+ end
213
+ socket.on_message do |message|
214
+ data = JSON.parse(message)
215
+ data["resize"] ? terminal.resize(*data["resize"].map(&:to_i)) : terminal.write(data["input"].to_s)
216
+ end
217
+ socket.on_close do
218
+ @lock.synchronize { @terminals.delete(terminal) }
219
+ terminal.close
220
+ reader.kill
221
+ end
222
+ end
223
+ end
224
+ end