asterism-games 0.18.0 → 0.19.1
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 +4 -4
- data/CHANGELOG.md +72 -0
- data/README.md +321 -3
- data/VERSION +1 -1
- data/bin/asterism-games +8 -0
- data/lib/asterism_games/ai.rb +64 -0
- data/lib/asterism_games/commentate/ai_comment.rb +633 -0
- data/lib/asterism_games/commentate/assets/TILES_LICENSE.txt +42 -0
- data/lib/asterism_games/commentate/assets/commentate.css +100 -0
- data/lib/asterism_games/commentate/assets/commentate.js +735 -0
- data/lib/asterism_games/commentate/assets/index.html +26 -0
- data/lib/asterism_games/commentate/assets/tiles_hand.bmp +0 -0
- data/lib/asterism_games/commentate/assets/tiles_river.bmp +0 -0
- data/lib/asterism_games/commentate/mahjong.rb +400 -0
- data/lib/asterism_games/commentate/mahjong_events.rb +320 -0
- data/lib/asterism_games/commentate/mahjong_phrases.rb +535 -0
- data/lib/asterism_games/commentate/mahjong_voice.yml +93 -0
- data/lib/asterism_games/commentate/server.rb +204 -0
- data/lib/asterism_games/commentate/voice.rb +394 -0
- data/lib/asterism_games/commentate/voicevox.rb +110 -0
- data/lib/asterism_games/commentate.rb +223 -0
- metadata +18 -2
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="ja">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>麻雀の実況</title>
|
|
7
|
+
<link rel="stylesheet" href="/commentate.css">
|
|
8
|
+
</head>
|
|
9
|
+
<body>
|
|
10
|
+
<header id="bar">
|
|
11
|
+
<span id="tname" class="item strong">卓を待っています</span>
|
|
12
|
+
<select id="tables" class="item" title="卓" hidden></select>
|
|
13
|
+
<span id="voice" class="item" title="M で切り替え">声: 切</span>
|
|
14
|
+
<span id="ai" class="item"></span>
|
|
15
|
+
<span id="delay" class="item"></span>
|
|
16
|
+
<span id="mode" class="item"></span>
|
|
17
|
+
<span id="conn" class="item"></span>
|
|
18
|
+
</header>
|
|
19
|
+
<div id="hint" role="button" tabindex="0">画面をクリックすると実況の声が出ます (ブラウザの自動再生の決まり)</div>
|
|
20
|
+
<main id="stage"><canvas id="table" aria-label="卓"></canvas></main>
|
|
21
|
+
<section id="captions" aria-live="polite"></section>
|
|
22
|
+
<footer id="keys">Space 一時停止 / M 声 / H 手牌を隠す</footer>
|
|
23
|
+
<div id="credit" hidden></div>
|
|
24
|
+
<script src="/commentate.js"></script>
|
|
25
|
+
</body>
|
|
26
|
+
</html>
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,400 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "monitor"
|
|
5
|
+
require_relative "mahjong_events"
|
|
6
|
+
require_relative "mahjong_phrases"
|
|
7
|
+
require_relative "server"
|
|
8
|
+
require_relative "voice"
|
|
9
|
+
require_relative "ai_comment"
|
|
10
|
+
|
|
11
|
+
module AsterismGames
|
|
12
|
+
module Commentate
|
|
13
|
+
# What the pages are told (README "Commentary"): each table's last
|
|
14
|
+
# spectate view and its commentary, kept here and pushed to the open
|
|
15
|
+
# pages (Server). Fed by the runner (update, host_left, host_back);
|
|
16
|
+
# nothing in it waits on the network, so the tests feed it directly.
|
|
17
|
+
class MahjongHub
|
|
18
|
+
KEEP_LINES = 100
|
|
19
|
+
|
|
20
|
+
attr_reader :tables
|
|
21
|
+
|
|
22
|
+
# public: hide the hands from everything that leaves this process (the
|
|
23
|
+
# views lose hand and drawn, the lines are the public ones). json: an
|
|
24
|
+
# IO for a JSON line per commentary line (--json), or nil. voice: a
|
|
25
|
+
# Voice (VOICEVOX: the lines in its character's tone, each with the
|
|
26
|
+
# URLs of its sound), or nil (the browser's voice; voice_note says
|
|
27
|
+
# why, for the page). ai: an AIComment (the AI's one-liners), or nil
|
|
28
|
+
# (ai_status says why, for the page).
|
|
29
|
+
def initialize(public: false, table: nil, json: nil, rng: Random.new, clock: -> { Time.now }, version: nil,
|
|
30
|
+
voice: nil, voice_note: nil, ai: nil, ai_status: nil)
|
|
31
|
+
@public = public
|
|
32
|
+
@want = table
|
|
33
|
+
@json = json
|
|
34
|
+
@rng = rng
|
|
35
|
+
@clock = clock
|
|
36
|
+
@version = version
|
|
37
|
+
@voice = voice
|
|
38
|
+
@voice_note = voice_note
|
|
39
|
+
@tone = voice ? voice.tone : "plain"
|
|
40
|
+
@lock = Monitor.new
|
|
41
|
+
@tables = {} # id => {"host", "name", "view", "memo", "lines", "at", "gone"}
|
|
42
|
+
@clients = []
|
|
43
|
+
@seq = 0
|
|
44
|
+
@ai = ai
|
|
45
|
+
@ai_status = ai_status || { "state" => "off", "note" => "off" }
|
|
46
|
+
@ai&.attach(deliver: ->(id, text, job) { ai_line(id, text, hands: job["hands"]) },
|
|
47
|
+
changed: -> { @lock.synchronize { broadcast_status } },
|
|
48
|
+
targets: -> { ai_targets }, hands: -> { hands_for_ai? }, tone: @tone)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
attr_reader :ai
|
|
52
|
+
|
|
53
|
+
def public? = @public
|
|
54
|
+
|
|
55
|
+
# A table's id on the page: <host>/<table>.
|
|
56
|
+
def self.id(host, name) = "#{host}/#{name}"
|
|
57
|
+
|
|
58
|
+
# A spectate view arrived. Returns the lines it made.
|
|
59
|
+
def update(host, name, view)
|
|
60
|
+
return [] unless view.is_a?(Hash)
|
|
61
|
+
id = MahjongHub.id(host, name)
|
|
62
|
+
frames = []
|
|
63
|
+
lines = []
|
|
64
|
+
@lock.synchronize do
|
|
65
|
+
t = (@tables[id] ||= { "host" => host, "name" => name, "view" => nil, "memo" => {}, "lines" => [] })
|
|
66
|
+
fresh = t["view"].nil?
|
|
67
|
+
was_gone = t["gone"]
|
|
68
|
+
t["gone"] = false
|
|
69
|
+
t["at"] = @clock.call
|
|
70
|
+
evs, t["memo"] = MahjongEvents.diff(t["view"], view, t["memo"])
|
|
71
|
+
evs.each do |ev|
|
|
72
|
+
l = MahjongPhrases.line(ev, view, @rng, tone: @tone)
|
|
73
|
+
l = public_line(l) if @public
|
|
74
|
+
next unless l
|
|
75
|
+
l["voice"] = @voice.plan(l, @rng) if @voice
|
|
76
|
+
@seq += 1
|
|
77
|
+
l["id"] = @seq
|
|
78
|
+
l["at"] = @clock.call.strftime("%H:%M:%S")
|
|
79
|
+
l["table"] = id
|
|
80
|
+
t["lines"] << l
|
|
81
|
+
lines << l
|
|
82
|
+
end
|
|
83
|
+
t["lines"].shift while t["lines"].size > KEEP_LINES
|
|
84
|
+
t["view"] = view
|
|
85
|
+
out = shown(view)
|
|
86
|
+
frames << [id, Server.frame("state", { "table" => id, "view" => out })]
|
|
87
|
+
lines.each { |l| frames << [id, Server.frame("line", l)] }
|
|
88
|
+
broadcast_status if fresh || was_gone
|
|
89
|
+
frames.each { |tid, f| each_client(tid) { |c| c.push(f) } }
|
|
90
|
+
end
|
|
91
|
+
lines.each { |l| @json.puts(JSON.generate(json_line(l))) } if @json
|
|
92
|
+
@json&.flush
|
|
93
|
+
@ai&.observe(id, view, lines)
|
|
94
|
+
lines
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# An AI's one-liner for a table (AIComment, after its fixed lines):
|
|
98
|
+
# the caption as it came, the speech read as the fixed lines are
|
|
99
|
+
# (SPEECH_READ, the tiles' names), marked "ai" for the page (said
|
|
100
|
+
# after the fixed lines, dropped first). hands: whether the AI saw
|
|
101
|
+
# the hands (then a page that hides them does not show it).
|
|
102
|
+
def ai_line(id, text, hands: false)
|
|
103
|
+
speech = MahjongPhrases.reading(text.delete("!").gsub(/\s+/, " ").strip)
|
|
104
|
+
l = { "type" => "ai", "prio" => 0, "ai" => true, "text" => text, "speech" => speech,
|
|
105
|
+
"ptext" => hands ? nil : text, "pspeech" => hands ? nil : speech, "clips" => [] }
|
|
106
|
+
frames = []
|
|
107
|
+
@lock.synchronize do
|
|
108
|
+
t = @tables[id] or return nil
|
|
109
|
+
l["voice"] = @voice.plan(l) if @voice
|
|
110
|
+
@seq += 1
|
|
111
|
+
l["id"] = @seq
|
|
112
|
+
l["at"] = @clock.call.strftime("%H:%M:%S")
|
|
113
|
+
l["table"] = id
|
|
114
|
+
t["lines"] << l
|
|
115
|
+
t["lines"].shift while t["lines"].size > KEEP_LINES
|
|
116
|
+
frames << Server.frame("line", l)
|
|
117
|
+
frames.each { |f| each_client(id) { |c| c.push(f) } }
|
|
118
|
+
end
|
|
119
|
+
if @json
|
|
120
|
+
@json.puts(JSON.generate(json_line(l)))
|
|
121
|
+
@json.flush
|
|
122
|
+
end
|
|
123
|
+
l
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Every few seconds (the runner): a quiet table may get a one-liner.
|
|
127
|
+
def tick
|
|
128
|
+
@ai&.tick
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# The tables the AI comments on: those a page watches (and with
|
|
132
|
+
# --json the first one): nobody watching, nothing is spent.
|
|
133
|
+
def ai_targets
|
|
134
|
+
@lock.synchronize do
|
|
135
|
+
ids = @clients.map(&:table).compact
|
|
136
|
+
ids << default_table if @json && default_table
|
|
137
|
+
ids.uniq
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Whether the AI may see the hands: not with --public, nor while a
|
|
142
|
+
# page hides them (H).
|
|
143
|
+
def hands_for_ai?
|
|
144
|
+
return false if @public
|
|
145
|
+
@lock.synchronize { @clients.none? { |c| c.respond_to?(:hide) && c.hide } }
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# The host of a table left the network (Asterism's on_leave): the pages
|
|
149
|
+
# say so; nothing stops.
|
|
150
|
+
def host_left(host) = mark(host, true)
|
|
151
|
+
def host_back(host) = mark(host, false)
|
|
152
|
+
|
|
153
|
+
# Tables not heard from for stale_s (the host puts every 5 s): gone.
|
|
154
|
+
def check_stale(stale_s = 15)
|
|
155
|
+
@lock.synchronize do
|
|
156
|
+
now = @clock.call
|
|
157
|
+
changed = false
|
|
158
|
+
@tables.each_value do |t|
|
|
159
|
+
next if t["gone"] || t["at"].nil? || now - t["at"] < stale_s
|
|
160
|
+
t["gone"] = true
|
|
161
|
+
changed = true
|
|
162
|
+
end
|
|
163
|
+
broadcast_status if changed
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# ------------------------------------------------------------ the pages
|
|
168
|
+
|
|
169
|
+
def subscribe(client)
|
|
170
|
+
@lock.synchronize { @clients << client }
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def unsubscribe(client)
|
|
174
|
+
@lock.synchronize { @clients.delete(client) }
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# The first frames of a page: who we are, its table and the lines so far.
|
|
178
|
+
def hello(client)
|
|
179
|
+
@lock.synchronize do
|
|
180
|
+
client.table = nil if client.table && !@tables.key?(client.table) && !@tables.empty? && !want?(client.table)
|
|
181
|
+
client.table ||= default_table
|
|
182
|
+
out = [["status", status]]
|
|
183
|
+
t = @tables[client.table]
|
|
184
|
+
if t
|
|
185
|
+
out << ["state", { "table" => client.table, "view" => shown(t["view"]) }]
|
|
186
|
+
t["lines"].each { |l| out << ["line", l.merge("old" => true)] }
|
|
187
|
+
end
|
|
188
|
+
out
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def status
|
|
193
|
+
{ "public" => @public, "version" => @version, "want" => @want, "voice" => voice_status,
|
|
194
|
+
"ai" => @ai ? @ai.status : @ai_status,
|
|
195
|
+
"tables" => @tables.sort.map { |id, t| { "id" => id, "name" => t["name"], "host" => t["host"], "gone" => t["gone"] ? true : false } } }
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
attr_reader :voice
|
|
199
|
+
|
|
200
|
+
private
|
|
201
|
+
|
|
202
|
+
def voice_status
|
|
203
|
+
return @voice.status if @voice
|
|
204
|
+
{ "engine" => "browser", "note" => @voice_note }
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def want?(id) = @want && id.end_with?(@want)
|
|
208
|
+
|
|
209
|
+
def default_table
|
|
210
|
+
ids = @tables.keys.sort
|
|
211
|
+
ids.find { |id| want?(id) } || ids.first
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def mark(host, gone)
|
|
215
|
+
@lock.synchronize do
|
|
216
|
+
hit = @tables.values.select { |t| t["host"] == host && t["gone"] != gone }
|
|
217
|
+
hit.each { |t| t["gone"] = gone }
|
|
218
|
+
broadcast_status unless hit.empty?
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def broadcast_status
|
|
223
|
+
f = Server.frame("status", status)
|
|
224
|
+
# A page that had no table yet takes the first one now.
|
|
225
|
+
@clients.each do |c|
|
|
226
|
+
if c.table.nil? && (d = default_table)
|
|
227
|
+
c.table = d
|
|
228
|
+
t = @tables[d]
|
|
229
|
+
c.push(Server.frame("state", { "table" => d, "view" => shown(t["view"]) })) if t["view"]
|
|
230
|
+
end
|
|
231
|
+
c.push(f)
|
|
232
|
+
end
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def each_client(id, &blk)
|
|
236
|
+
@clients.each do |c|
|
|
237
|
+
c.table ||= default_table
|
|
238
|
+
blk.call(c) if c.table == id
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
# The view as it leaves: without the hands with --public.
|
|
243
|
+
def shown(view)
|
|
244
|
+
return view unless @public && view.is_a?(Hash)
|
|
245
|
+
v = view.dup
|
|
246
|
+
v["seats"] = Array(view["seats"]).map { |s| s.reject { |k, _| %w[hand drawn].include?(k) } }
|
|
247
|
+
v
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def public_line(l)
|
|
251
|
+
return nil if l["ptext"].nil?
|
|
252
|
+
l.merge("text" => l["ptext"], "speech" => l["pspeech"]).reject { |k, _| %w[ptext pspeech].include?(k) }
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
def json_line(l)
|
|
256
|
+
out = { "at" => l["at"], "table" => l["table"], "type" => l["type"], "prio" => l["prio"], "text" => l["text"] }
|
|
257
|
+
out["ai"] = true if l["ai"]
|
|
258
|
+
out
|
|
259
|
+
end
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
# `asterism-games commentate mahjong`: the connection, the spectate
|
|
263
|
+
# keys, the web server and the browser.
|
|
264
|
+
module MahjongRunner
|
|
265
|
+
SPECTATE = "asterism/*/spectate/*/state"
|
|
266
|
+
STALE_S = 15
|
|
267
|
+
|
|
268
|
+
module_function
|
|
269
|
+
|
|
270
|
+
def run(opts, out: $stdout, err: $stderr)
|
|
271
|
+
require "asterism"
|
|
272
|
+
# A pipe or a file (--json to another tool) gets each line at once.
|
|
273
|
+
out.sync = true if out.respond_to?(:sync=)
|
|
274
|
+
info = opts[:json] ? err : out
|
|
275
|
+
voice, note = start_voice(opts, info)
|
|
276
|
+
ai, ai_status = AIComment.from_options(opts, log: ->(m) { info.puts "commentate: #{m}" })
|
|
277
|
+
hub = MahjongHub.new(public: opts[:public], table: table_filter(opts), json: opts[:json] ? out : nil,
|
|
278
|
+
version: AsterismGames::VERSION, voice: voice, voice_note: note, ai: ai,
|
|
279
|
+
ai_status: ai_status)
|
|
280
|
+
server = Server.new(hub, port: opts[:port], log: ->(m) { err.puts("commentate: #{m}") }).start
|
|
281
|
+
info.puts "commentate: #{server.url} (127.0.0.1 only; Ctrl-C quits)"
|
|
282
|
+
info.puts "commentate: hands hidden (--public)" if opts[:public]
|
|
283
|
+
open_browser(server.url, err) if opts[:open]
|
|
284
|
+
prefix = room_prefix(opts[:namespace])
|
|
285
|
+
connect(opts, err) do |net|
|
|
286
|
+
net.on_leave { |n| hub.host_left(n) }
|
|
287
|
+
net.on_join { |n| hub.host_back(n) }
|
|
288
|
+
net.start
|
|
289
|
+
net.session.subscribe(SPECTATE) do |sample|
|
|
290
|
+
next if sample.respond_to?(:kind) && sample.kind == :delete
|
|
291
|
+
_, host, _, name, = sample.key.to_s.split("/")
|
|
292
|
+
next unless wanted?(host, name, opts[:table], prefix)
|
|
293
|
+
v = Asterism::Codec.unpack(sample.payload)
|
|
294
|
+
hub.update(host, name, v)
|
|
295
|
+
rescue StandardError => e
|
|
296
|
+
err.puts "commentate: #{sample.key}: #{e.class}: #{e.message}"
|
|
297
|
+
end
|
|
298
|
+
stop = Thread::Queue.new
|
|
299
|
+
%w[INT TERM].each { |s| trap(s) { stop << true } }
|
|
300
|
+
until stop.pop(timeout: 2)
|
|
301
|
+
hub.check_stale(STALE_S)
|
|
302
|
+
hub.tick
|
|
303
|
+
end
|
|
304
|
+
end
|
|
305
|
+
ensure
|
|
306
|
+
server&.stop
|
|
307
|
+
voice&.stop
|
|
308
|
+
ai&.stop
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
# The VOICEVOX voice ([voice, nil]), or [nil, note] when the engine is
|
|
312
|
+
# not there (the browser's voice reads). Voicevox::NotFound (no such
|
|
313
|
+
# character or style) goes up: the command says what there is.
|
|
314
|
+
def start_voice(opts, info, root: ClipBank.cache_root)
|
|
315
|
+
url = opts[:voicevox] || Voicevox::DEFAULT_URL
|
|
316
|
+
log = ->(m) { info.puts "commentate: #{m}" }
|
|
317
|
+
voice = Voice.connect(url: url, speaker: opts[:speaker] || DEFAULT_SPEAKER, style: opts[:style] || DEFAULT_STYLE,
|
|
318
|
+
root: root, log: log)
|
|
319
|
+
unless voice
|
|
320
|
+
info.puts "commentate: no VOICEVOX at #{url}: the browser's voice reads (README \"Commentary\", \"The voice\")"
|
|
321
|
+
info.puts "commentate: VOICEVOX is a free app you install yourself (it is not in this gem): " \
|
|
322
|
+
"#{Voicevox::HOME}; start it and run this again for #{opts[:speaker] || DEFAULT_SPEAKER}'s voice"
|
|
323
|
+
return [nil, "missing"]
|
|
324
|
+
end
|
|
325
|
+
info.puts "commentate: VOICEVOX #{voice.engine_version} at #{url}: #{voice.speaker} (#{voice.style}, #{voice.id}), " \
|
|
326
|
+
"fixed phrases in #{voice.bank.dir}; credit: #{voice.credit}"
|
|
327
|
+
voice.make_missing_async
|
|
328
|
+
[voice, nil]
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
def connect(opts, err, &blk)
|
|
332
|
+
require "socket"
|
|
333
|
+
node = opts[:node] || (opts[:credentials] ? nil : "commentate-#{Socket.gethostname[/\A[A-Za-z0-9_-]+/]}-#{Process.pid}")
|
|
334
|
+
kw = { app: "commentate" }
|
|
335
|
+
kw[:node] = node if node
|
|
336
|
+
kw[:credentials] = opts[:credentials] if opts[:credentials]
|
|
337
|
+
# No namespace: the spectate keys are outside every room (README
|
|
338
|
+
# "Spectate"); --namespace room/01 picks that room's tables.
|
|
339
|
+
Asterism.connect(opts[:locator], **kw) do |net|
|
|
340
|
+
net.on_error { |e, where| err.puts "commentate: #{where}: #{e.class}: #{e.message}" }
|
|
341
|
+
blk.call(net)
|
|
342
|
+
end
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
# --namespace room/01: the room's tables are room01-<table> outside it.
|
|
346
|
+
def room_prefix(ns)
|
|
347
|
+
return nil if ns.to_s.empty?
|
|
348
|
+
"#{ns.to_s.delete('/')}-"
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
# What --table / --namespace name, for the page's first choice.
|
|
352
|
+
def table_filter(opts)
|
|
353
|
+
t = opts[:table].to_s
|
|
354
|
+
return nil if t.empty?
|
|
355
|
+
parts = t.split("/")
|
|
356
|
+
parts.size >= 3 ? "#{parts[0]}/#{parts[-1]}" : t
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
# --table: <table>, <host>/<table>, <host>/spectate/<table> or the
|
|
360
|
+
# play's <host>/games/<table>.
|
|
361
|
+
def wanted?(host, name, table, prefix)
|
|
362
|
+
return false if host.nil? || name.nil?
|
|
363
|
+
return false if prefix && !name.start_with?(prefix)
|
|
364
|
+
return true if table.to_s.empty?
|
|
365
|
+
parts = table.to_s.split("/")
|
|
366
|
+
want = parts.last
|
|
367
|
+
want = "#{prefix}#{want}" if prefix && !want.start_with?(prefix)
|
|
368
|
+
return false unless name == want
|
|
369
|
+
parts.size == 1 || parts.first == host
|
|
370
|
+
end
|
|
371
|
+
|
|
372
|
+
# The page in the browser: macOS open, WSL wslview or Windows' start,
|
|
373
|
+
# elsewhere xdg-open. A failure only says the URL again.
|
|
374
|
+
def open_browser(url, err)
|
|
375
|
+
cmd = if RUBY_PLATFORM.include?("darwin") then ["open", url]
|
|
376
|
+
elsif wsl?
|
|
377
|
+
which("wslview") ? ["wslview", url] : ["cmd.exe", "/c", "start", "", url]
|
|
378
|
+
elsif which("xdg-open") then ["xdg-open", url]
|
|
379
|
+
end
|
|
380
|
+
return err.puts("commentate: open #{url} in a browser") unless cmd
|
|
381
|
+
# cmd.exe complains in a directory of Linux's: start it in C:.
|
|
382
|
+
dir = cmd[0] == "cmd.exe" && File.directory?("/mnt/c") ? "/mnt/c" : Dir.pwd
|
|
383
|
+
pid = Process.spawn(*cmd, %i[out err] => File::NULL, chdir: dir)
|
|
384
|
+
Process.detach(pid)
|
|
385
|
+
rescue SystemCallError
|
|
386
|
+
err.puts "commentate: open #{url} in a browser"
|
|
387
|
+
end
|
|
388
|
+
|
|
389
|
+
def wsl?
|
|
390
|
+
File.read("/proc/version").match?(/microsoft/i)
|
|
391
|
+
rescue SystemCallError
|
|
392
|
+
false
|
|
393
|
+
end
|
|
394
|
+
|
|
395
|
+
def which(cmd)
|
|
396
|
+
ENV["PATH"].to_s.split(File::PATH_SEPARATOR).any? { |d| File.executable?(File.join(d, cmd)) }
|
|
397
|
+
end
|
|
398
|
+
end
|
|
399
|
+
end
|
|
400
|
+
end
|