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
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
|