copy_tuner_client 2.2.1 → 2.4.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 +4 -4
- data/CLAUDE.md +10 -0
- data/app/assets/javascripts/copytuner.js +69 -78
- data/docs/poller-startup.md +108 -0
- data/index.html +54 -0
- data/lib/copy_tuner_client/fork_hook.rb +53 -0
- data/lib/copy_tuner_client/poller.rb +87 -23
- data/lib/copy_tuner_client/process_guard.rb +13 -4
- data/lib/copy_tuner_client/queue_with_timeout.rb +8 -2
- data/lib/copy_tuner_client/version.rb +1 -1
- data/spec/copy_tuner_client/fork_hook_spec.rb +171 -0
- data/spec/copy_tuner_client/poller_spec.rb +148 -1
- data/spec/copy_tuner_client/process_guard_spec.rb +8 -0
- data/spec/copy_tuner_client/queue_with_timeout_spec.rb +27 -0
- data/spec/spec_helper.rb +6 -0
- data/src/copyray-overlay.ts +54 -47
- data/src/copytuner-bar.ts +0 -11
- data/src/copytuner-root.ts +31 -0
- data/src/main.ts +22 -12
- data/src/styles.ts +62 -41
- data/src/util.ts +1 -36
- metadata +7 -2
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
require 'copy_tuner_client/fork_hook'
|
|
2
|
+
|
|
1
3
|
module CopyTunerClient
|
|
2
4
|
# Starts the poller from a worker process, or register hooks for a spawner
|
|
3
5
|
# process (such as in Unicorn or Passenger). Also registers hooks for exiting
|
|
@@ -14,6 +16,9 @@ module CopyTunerClient
|
|
|
14
16
|
|
|
15
17
|
# Starts the poller or registers hooks
|
|
16
18
|
def start
|
|
19
|
+
# fork の前後で poller を張り直すフックは、どのプロセスでも必ず登録する
|
|
20
|
+
ForkHook.install
|
|
21
|
+
|
|
17
22
|
if spawner?
|
|
18
23
|
register_spawn_hooks
|
|
19
24
|
else
|
|
@@ -115,9 +120,12 @@ module CopyTunerClient
|
|
|
115
120
|
::Process.singleton_class.prepend(hook_module)
|
|
116
121
|
end
|
|
117
122
|
|
|
123
|
+
# 起動方法(rails server / puma -C)とモード(single / cluster / preload の有無)ごとに
|
|
124
|
+
# どのプロセスで poller が起動するかは docs/poller-startup.md にまとめてある
|
|
118
125
|
def register_puma_hook
|
|
119
|
-
#
|
|
120
|
-
#
|
|
126
|
+
# 非 preload の cluster worker はここに来る。この構成ではアプリのロードが
|
|
127
|
+
# Puma::Runner#start_server の中で起きるため、今から prepend しても実行中の呼び出しには
|
|
128
|
+
# 間に合わない。つまりこの分岐が poller を起動する唯一の経路で、最適化ではなく必須
|
|
121
129
|
if $PROGRAM_NAME.include?('cluster worker')
|
|
122
130
|
@logger.info('Puma would be clustered mode without preload_app')
|
|
123
131
|
@poller.start
|
|
@@ -125,8 +133,9 @@ module CopyTunerClient
|
|
|
125
133
|
end
|
|
126
134
|
|
|
127
135
|
@logger.info('Register Puma fork hook')
|
|
128
|
-
#
|
|
129
|
-
#
|
|
136
|
+
# preload ありの cluster ではこのメソッドが fork 前の master で走る。master は
|
|
137
|
+
# リクエストを捌かないのでここでは起動せず、worker 側で呼ばれる
|
|
138
|
+
# Puma::Runner#start_server まで遅らせる
|
|
130
139
|
poller = @poller
|
|
131
140
|
hook_module =
|
|
132
141
|
Module.new do
|
|
@@ -51,10 +51,16 @@ module CopyTunerClient
|
|
|
51
51
|
|
|
52
52
|
def wait_with_timeout(timeout)
|
|
53
53
|
# wait for element or timeout
|
|
54
|
-
|
|
55
|
-
|
|
54
|
+
# NTP の step 補正や手動の時刻変更で巻き戻らない CLOCK_MONOTONIC で締め切りを測る。
|
|
55
|
+
# ウォールクロックだと時計が後ろへ飛んだぶんだけ待ち時間が伸びる
|
|
56
|
+
deadline = monotonic_now + timeout
|
|
57
|
+
while @queue.empty? && (remaining_time = deadline - monotonic_now).positive?
|
|
56
58
|
@received.wait(@mutex, remaining_time)
|
|
57
59
|
end
|
|
58
60
|
end
|
|
61
|
+
|
|
62
|
+
def monotonic_now
|
|
63
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
64
|
+
end
|
|
59
65
|
end
|
|
60
66
|
end
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
require 'spec_helper'
|
|
2
|
+
|
|
3
|
+
describe CopyTunerClient::ForkHook do
|
|
4
|
+
let(:client) { FakeClient.new }
|
|
5
|
+
let(:cache) { CopyTunerClient::Cache.new(client, logger: FakeLogger.new) }
|
|
6
|
+
let(:poller) do
|
|
7
|
+
config = CopyTunerClient::Configuration.new.to_hash
|
|
8
|
+
CopyTunerClient::Poller.new(cache, config.update(logger: FakeLogger.new, polling_delay:))
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def polling_delay
|
|
12
|
+
0.5
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
before do
|
|
16
|
+
described_class.install
|
|
17
|
+
# フックは CopyTunerClient.configuration&.poller を見るので、実際の設定に載せる
|
|
18
|
+
CopyTunerClient.configuration.poller = poller
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
after do
|
|
22
|
+
poller.stop
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
describe '停止と再開の順序' do
|
|
26
|
+
# 実際に fork してしまうと「fork の瞬間にスレッドが止まっているか」を観測できないため、
|
|
27
|
+
# _fork の代わりに記録だけするオブジェクトに prepend して順序を見る
|
|
28
|
+
def build_forkable(events, &fork_body)
|
|
29
|
+
forkable = Object.new
|
|
30
|
+
forkable.define_singleton_method(:_fork) do
|
|
31
|
+
events << :fork
|
|
32
|
+
fork_body ? fork_body.call : 0
|
|
33
|
+
end
|
|
34
|
+
forkable.singleton_class.prepend(described_class)
|
|
35
|
+
forkable
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def stub_poller(events, stopped:)
|
|
39
|
+
allow(poller).to receive(:stop) do
|
|
40
|
+
events << :stop
|
|
41
|
+
stopped
|
|
42
|
+
end
|
|
43
|
+
allow(poller).to receive(:start) { events << :start }
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
it 'fork の前に poller を停止し、fork の後に再開する' do
|
|
47
|
+
events = []
|
|
48
|
+
stub_poller(events, stopped: true)
|
|
49
|
+
|
|
50
|
+
build_forkable(events)._fork
|
|
51
|
+
|
|
52
|
+
expect(events).to eq(%i[stop fork start])
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
it 'fork 前に poller が動いていなければ再開しない' do
|
|
56
|
+
events = []
|
|
57
|
+
stub_poller(events, stopped: false)
|
|
58
|
+
|
|
59
|
+
build_forkable(events)._fork
|
|
60
|
+
|
|
61
|
+
expect(events).to eq(%i[stop fork])
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
it 'fork が失敗しても poller を再開する' do
|
|
65
|
+
events = []
|
|
66
|
+
stub_poller(events, stopped: true)
|
|
67
|
+
|
|
68
|
+
forkable = build_forkable(events) { raise Errno::EAGAIN }
|
|
69
|
+
|
|
70
|
+
expect { forkable._fork }.to raise_error(Errno::EAGAIN)
|
|
71
|
+
expect(events).to eq(%i[stop fork start])
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
describe 'poller を取得できないとき' do
|
|
76
|
+
it 'configuration が nil でも fork を壊さない' do
|
|
77
|
+
# Process._fork への prepend は外せないので、ここで例外を漏らすとアプリの
|
|
78
|
+
# すべての fork が失敗する
|
|
79
|
+
CopyTunerClient.configuration = nil
|
|
80
|
+
|
|
81
|
+
expect { Process.waitpid(fork { exit!(0) }) }.not_to raise_error
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
it 'ログ出力自体が失敗しても fork は成立する' do
|
|
85
|
+
allow(poller).to receive(:stop).and_return(true)
|
|
86
|
+
allow(poller).to receive(:start).and_raise(ThreadError, 'cannot create thread')
|
|
87
|
+
allow(CopyTunerClient.configuration.logger).to receive(:error).and_raise('logger is broken')
|
|
88
|
+
|
|
89
|
+
expect { Process.waitpid(fork { exit!(0) }) }.not_to raise_error
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
it 'fork 後の poller 起動に失敗しても fork 自体は成立する' do
|
|
93
|
+
allow(poller).to receive(:stop).and_return(true)
|
|
94
|
+
allow(poller).to receive(:start).and_raise(ThreadError, 'cannot create thread')
|
|
95
|
+
|
|
96
|
+
expect { Process.waitpid(fork { exit!(0) }) }.not_to raise_error
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
describe '実際に fork したとき' do
|
|
101
|
+
it 'fork 前に poller が例外で死んでいても親子とも張り直す' do
|
|
102
|
+
# rails server の cluster では ForkHook が唯一の再開経路なので、ここで張り直さないと
|
|
103
|
+
# 親子とも poller を失う
|
|
104
|
+
fail_once = true
|
|
105
|
+
allow(cache).to receive(:sync).and_wrap_original do |original, *args|
|
|
106
|
+
if fail_once
|
|
107
|
+
fail_once = false
|
|
108
|
+
raise 'boom'
|
|
109
|
+
end
|
|
110
|
+
original.call(*args)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
poller.start
|
|
114
|
+
sleep(polling_delay * 0.4) # スレッドが例外で終わる
|
|
115
|
+
|
|
116
|
+
reader, writer = IO.pipe
|
|
117
|
+
pid =
|
|
118
|
+
fork do
|
|
119
|
+
reader.close
|
|
120
|
+
client['child.key'] = 'value'
|
|
121
|
+
sleep(polling_delay * 3)
|
|
122
|
+
writer.write(cache['child.key'].to_s)
|
|
123
|
+
writer.close
|
|
124
|
+
exit!(0)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
writer.close
|
|
128
|
+
child_result = reader.read
|
|
129
|
+
Process.waitpid(pid)
|
|
130
|
+
|
|
131
|
+
client['parent.key'] = 'value'
|
|
132
|
+
sleep(polling_delay * 3)
|
|
133
|
+
|
|
134
|
+
expect(child_result).to eq('value')
|
|
135
|
+
expect(cache['parent.key']).to eq('value')
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
it '子プロセスでも poller がポーリングを続ける' do
|
|
139
|
+
poller.start
|
|
140
|
+
reader, writer = IO.pipe
|
|
141
|
+
|
|
142
|
+
pid =
|
|
143
|
+
fork do
|
|
144
|
+
reader.close
|
|
145
|
+
client['test.key'] = 'value'
|
|
146
|
+
sleep(polling_delay * 3)
|
|
147
|
+
writer.write(cache['test.key'].to_s)
|
|
148
|
+
writer.close
|
|
149
|
+
exit!(0)
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
writer.close
|
|
153
|
+
result = reader.read
|
|
154
|
+
Process.waitpid(pid)
|
|
155
|
+
|
|
156
|
+
expect(result).to eq('value')
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
it '親プロセスでも poller がポーリングを続ける' do
|
|
160
|
+
poller.start
|
|
161
|
+
sleep(polling_delay * 0.2)
|
|
162
|
+
|
|
163
|
+
Process.waitpid(fork { exit!(0) })
|
|
164
|
+
|
|
165
|
+
client['test.key'] = 'value'
|
|
166
|
+
sleep(polling_delay * 3)
|
|
167
|
+
|
|
168
|
+
expect(cache['test.key']).to eq('value')
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
end
|
|
@@ -11,7 +11,7 @@ describe CopyTunerClient::Poller do
|
|
|
11
11
|
|
|
12
12
|
def build_poller(config = {})
|
|
13
13
|
config[:logger] ||= FakeLogger.new
|
|
14
|
-
config[:polling_delay]
|
|
14
|
+
config[:polling_delay] ||= polling_delay
|
|
15
15
|
default_config = CopyTunerClient::Configuration.new.to_hash
|
|
16
16
|
poller = CopyTunerClient::Poller.new(cache, default_config.update(config))
|
|
17
17
|
pollers << poller
|
|
@@ -95,4 +95,151 @@ describe CopyTunerClient::Poller do
|
|
|
95
95
|
|
|
96
96
|
expect(logger).to have_received(:flush).at_least(:once)
|
|
97
97
|
end
|
|
98
|
+
|
|
99
|
+
describe '#stop' do
|
|
100
|
+
it 'スレッドを停止したときは true を返す' do
|
|
101
|
+
poller = build_poller
|
|
102
|
+
poller.start
|
|
103
|
+
|
|
104
|
+
expect(poller.stop).to be true
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
it 'スレッドが動いていないときは false を返す' do
|
|
108
|
+
poller = build_poller
|
|
109
|
+
|
|
110
|
+
expect(poller.stop).to be false
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
it 'スレッドが例外で終わっていても例外を再送出しない' do
|
|
114
|
+
# stop は fork の直前にも呼ばれる。ここで join が poller の例外を再送出すると
|
|
115
|
+
# アプリ側の fork まで巻き添えになる
|
|
116
|
+
logger = FakeLogger.new
|
|
117
|
+
allow(cache).to receive(:sync).and_raise('boom')
|
|
118
|
+
poller = build_poller(logger:)
|
|
119
|
+
poller.start
|
|
120
|
+
sleep(polling_delay * 0.2)
|
|
121
|
+
|
|
122
|
+
expect { poller.stop }.not_to raise_error
|
|
123
|
+
expect(logger).to have_entry(:error, 'boom')
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
it 'start していないときに stop してもキューに :stop を残さない' do
|
|
127
|
+
poller = build_poller
|
|
128
|
+
poller.stop
|
|
129
|
+
|
|
130
|
+
poller.start
|
|
131
|
+
|
|
132
|
+
# 1 周目の sync だけでは「:stop がキューに残っている」状態と区別できないため、
|
|
133
|
+
# 2 周目以降も同期が続くことを確かめる
|
|
134
|
+
wait_for_next_sync
|
|
135
|
+
client['test.key'] = 'value'
|
|
136
|
+
wait_for_next_sync
|
|
137
|
+
|
|
138
|
+
expect(cache['test.key']).to eq('value')
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
describe '世代をまたいだコマンドの混入' do
|
|
143
|
+
# :stop は pop されるまでキューに残る。前の世代のスレッド宛に積まれた :stop を
|
|
144
|
+
# 次の世代のスレッドが拾うと、起動直後に自分を止めてしまう
|
|
145
|
+
it 'スレッドが例外で死んだ後に stop → start してもポーリングが続く' do
|
|
146
|
+
poller = build_poller
|
|
147
|
+
allow(cache).to receive(:sync).and_raise('boom')
|
|
148
|
+
poller.start
|
|
149
|
+
sleep(polling_delay * 0.3) # スレッドが例外で終わるのを待つ
|
|
150
|
+
|
|
151
|
+
allow(cache).to receive(:sync).and_call_original
|
|
152
|
+
poller.stop
|
|
153
|
+
poller.start
|
|
154
|
+
|
|
155
|
+
wait_for_next_sync
|
|
156
|
+
client['test.key'] = 'value'
|
|
157
|
+
wait_for_next_sync
|
|
158
|
+
|
|
159
|
+
expect(cache['test.key']).to eq('value')
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
it 'stop の直後に sync が例外で終わっても、次の start でポーリングが続く' do
|
|
163
|
+
poller = build_poller
|
|
164
|
+
# stop が :stop を積んだ後にスレッドが例外で終わる順序を作る。
|
|
165
|
+
# sync に入ったところで stop を待たせ、:stop を積み終えてから例外にする
|
|
166
|
+
syncing = Queue.new
|
|
167
|
+
resume = Queue.new
|
|
168
|
+
allow(cache).to receive(:sync) do
|
|
169
|
+
syncing << true
|
|
170
|
+
resume.pop
|
|
171
|
+
raise 'boom'
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
poller.start
|
|
175
|
+
syncing.pop # スレッドが sync に入った
|
|
176
|
+
|
|
177
|
+
stopper = Thread.new { poller.stop }
|
|
178
|
+
sleep(0.1) # :stop がキューに積まれるのを待つ
|
|
179
|
+
resume << true # ここで sync が例外になり、:stop は消費されない
|
|
180
|
+
stopper.join
|
|
181
|
+
|
|
182
|
+
allow(cache).to receive(:sync).and_call_original
|
|
183
|
+
poller.start
|
|
184
|
+
|
|
185
|
+
wait_for_next_sync
|
|
186
|
+
client['test.key'] = 'value'
|
|
187
|
+
wait_for_next_sync
|
|
188
|
+
|
|
189
|
+
expect(cache['test.key']).to eq('value')
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# stop の戻り値は ForkHook の「fork 後に張り直すか」の判断に使われる。スレッドの生死ではなく
|
|
193
|
+
# 「ポーリングを継続する意図があるか」を返す必要がある
|
|
194
|
+
it 'スレッドが例外で死んでいても、起動中だったなら stop は true を返す' do
|
|
195
|
+
poller = build_poller
|
|
196
|
+
allow(cache).to receive(:sync).and_raise('boom')
|
|
197
|
+
poller.start
|
|
198
|
+
sleep(polling_delay * 0.3)
|
|
199
|
+
|
|
200
|
+
expect(poller.stop).to be true
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
it '回復の見込みがない理由で終了した後の stop は false を返す' do
|
|
204
|
+
poller = build_poller
|
|
205
|
+
allow(cache).to receive(:sync).and_raise(CopyTunerClient::InvalidApiKey, 'Invalid API key')
|
|
206
|
+
poller.start
|
|
207
|
+
sleep(polling_delay * 0.3)
|
|
208
|
+
|
|
209
|
+
# API キーが不正なら fork のたびに張り直しても同じ理由で死ぬだけなので再開させない
|
|
210
|
+
expect(poller.stop).to be false
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
describe '再開時の sync 間隔' do
|
|
215
|
+
# 経過時間で判定するため、CI の負荷や GC で sleep が伸びても落ちないよう
|
|
216
|
+
# 他のテストより長い間隔を使ってマージンを稼ぐ
|
|
217
|
+
let(:slow_delay) { 2.0 }
|
|
218
|
+
|
|
219
|
+
it '初回の start では待たずに sync する' do
|
|
220
|
+
poller = build_poller(polling_delay: slow_delay)
|
|
221
|
+
|
|
222
|
+
poller.start
|
|
223
|
+
sleep(slow_delay * 0.2)
|
|
224
|
+
|
|
225
|
+
expect(client.downloads).to eq(1)
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
it '停止直後に再開したときは前回 sync からの残り時間を待ってから sync する' do
|
|
229
|
+
poller = build_poller(polling_delay: slow_delay)
|
|
230
|
+
poller.start
|
|
231
|
+
sleep(slow_delay * 0.2)
|
|
232
|
+
poller.stop
|
|
233
|
+
|
|
234
|
+
poller.start
|
|
235
|
+
|
|
236
|
+
# 前回 sync から polling_delay 経つまでは sync しない
|
|
237
|
+
sleep(slow_delay * 0.4)
|
|
238
|
+
expect(client.downloads).to eq(1)
|
|
239
|
+
|
|
240
|
+
# 残り時間が過ぎれば sync が再開する
|
|
241
|
+
sleep(slow_delay * 0.8)
|
|
242
|
+
expect(client.downloads).to eq(2)
|
|
243
|
+
end
|
|
244
|
+
end
|
|
98
245
|
end
|
|
@@ -20,6 +20,14 @@ describe CopyTunerClient::ProcessGuard do
|
|
|
20
20
|
process_guard
|
|
21
21
|
end
|
|
22
22
|
|
|
23
|
+
it 'fork の前後で poller を張り直すフックを登録する' do
|
|
24
|
+
allow(CopyTunerClient::ForkHook).to receive(:install)
|
|
25
|
+
|
|
26
|
+
build_process_guard.start
|
|
27
|
+
|
|
28
|
+
expect(CopyTunerClient::ForkHook).to have_received(:install)
|
|
29
|
+
end
|
|
30
|
+
|
|
23
31
|
it 'starts polling from a worker process' do
|
|
24
32
|
process_guard = build_process_guard
|
|
25
33
|
process_guard.start
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
require 'spec_helper'
|
|
2
|
+
require 'timeout'
|
|
3
|
+
|
|
4
|
+
describe CopyTunerClient::QueueWithTimeout do
|
|
5
|
+
subject(:queue) { described_class.new }
|
|
6
|
+
|
|
7
|
+
describe '#pop_with_timeout' do
|
|
8
|
+
it 'キューに要素があれば取り出す' do
|
|
9
|
+
queue << :sync
|
|
10
|
+
|
|
11
|
+
expect(queue.pop_with_timeout(0)).to eq(:sync)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
it '要素が無いままタイムアウトしたら ThreadError を投げる' do
|
|
15
|
+
expect { queue.pop_with_timeout(0.1) }.to raise_error(ThreadError)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
it 'ウォールクロックが巻き戻ってもタイムアウトが伸びない' do
|
|
19
|
+
# 待っている最中に NTP の step 補正や手動の時刻変更で時計が後ろへ飛ぶ状況を模す。
|
|
20
|
+
# 経過時間をウォールクロックで測っていると、飛んだ幅がそのまま待ち時間に乗る
|
|
21
|
+
base = Time.now
|
|
22
|
+
allow(Time).to receive(:now).and_return(base, base - 3600)
|
|
23
|
+
|
|
24
|
+
expect { Timeout.timeout(2) { queue.pop_with_timeout(0.1) } }.to raise_error(ThreadError)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
data/spec/spec_helper.rb
CHANGED
|
@@ -26,4 +26,10 @@ RSpec.configure do |config|
|
|
|
26
26
|
FakeCopyTunerApp.reset
|
|
27
27
|
reset_config
|
|
28
28
|
end
|
|
29
|
+
|
|
30
|
+
# apply を通す spec は本物の poller スレッドを起動するので、example ごとに止める。
|
|
31
|
+
# 放置するとスレッドがスイート終了まで積み上がり、実際に HTTP を叩き続ける
|
|
32
|
+
config.after do
|
|
33
|
+
CopyTunerClient.configuration&.poller&.stop
|
|
34
|
+
end
|
|
29
35
|
end
|
data/src/copyray-overlay.ts
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { OVERLAY_STYLES } from './styles'
|
|
2
|
-
import { computeBoundingBox } from './util'
|
|
3
2
|
|
|
4
3
|
type OpenCallback = (key: string) => void
|
|
5
4
|
|
|
@@ -15,13 +14,14 @@ const findBlurbs = (): Blurb[] =>
|
|
|
15
14
|
element,
|
|
16
15
|
}))
|
|
17
16
|
|
|
18
|
-
//
|
|
17
|
+
// オーバーレイ背景と翻訳要素のハイライト枠(specimen)を Shadow DOM 内に描画する。
|
|
19
18
|
export class CopyrayOverlay extends HTMLElement {
|
|
20
19
|
#onOpen: OpenCallback = () => {}
|
|
21
|
-
#
|
|
20
|
+
#onClose: () => void = () => {}
|
|
22
21
|
#backdrop: HTMLDivElement
|
|
23
22
|
#specimens: HTMLDivElement
|
|
24
|
-
#
|
|
23
|
+
#boxes = new Map<Element, HTMLDivElement>()
|
|
24
|
+
#frame: number | null = null
|
|
25
25
|
|
|
26
26
|
constructor() {
|
|
27
27
|
super()
|
|
@@ -33,74 +33,81 @@ export class CopyrayOverlay extends HTMLElement {
|
|
|
33
33
|
|
|
34
34
|
this.#backdrop = document.createElement('div')
|
|
35
35
|
this.#backdrop.classList.add('backdrop')
|
|
36
|
-
this.#backdrop.addEventListener('click', () => this
|
|
36
|
+
this.#backdrop.addEventListener('click', () => this.#onClose())
|
|
37
37
|
|
|
38
|
-
// specimen をページ座標基準で absolute 配置するコンテナ
|
|
39
38
|
this.#specimens = document.createElement('div')
|
|
40
|
-
this.#specimens.classList.add('specimens')
|
|
41
39
|
|
|
42
|
-
this.#
|
|
43
|
-
this.#toggleButton.classList.add('toggle-button')
|
|
44
|
-
this.#toggleButton.textContent = 'Open CopyTuner'
|
|
45
|
-
// 旧実装ではトグルボタンが overlay と bar の両方を表示していた。show() ではなく onToggle 経由で表示する。
|
|
46
|
-
this.#toggleButton.addEventListener('click', () => this.#onToggle())
|
|
47
|
-
|
|
48
|
-
shadow.append(this.#backdrop, this.#specimens, this.#toggleButton)
|
|
49
|
-
|
|
50
|
-
// 初期は非表示(背景と specimen を隠す)。トグルボタンは常時表示のため :host([hidden]) は使わず個別制御する。
|
|
51
|
-
this.hide()
|
|
40
|
+
shadow.append(this.#backdrop, this.#specimens)
|
|
52
41
|
}
|
|
53
42
|
|
|
54
43
|
set onOpen(callback: OpenCallback) {
|
|
55
44
|
this.#onOpen = callback
|
|
56
45
|
}
|
|
57
46
|
|
|
58
|
-
set
|
|
59
|
-
this.#
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
get isShowing(): boolean {
|
|
63
|
-
return !this.#backdrop.hidden
|
|
47
|
+
set onClose(callback: () => void) {
|
|
48
|
+
this.#onClose = callback
|
|
64
49
|
}
|
|
65
50
|
|
|
66
51
|
show() {
|
|
67
|
-
|
|
68
|
-
this
|
|
52
|
+
// 二重 show でもリスナーと boxes が重複しないよう、まず後始末を通す
|
|
53
|
+
this.hide()
|
|
69
54
|
|
|
70
55
|
for (const { element, keys } of findBlurbs()) {
|
|
71
|
-
|
|
72
|
-
if (box) {
|
|
73
|
-
this.#specimens.append(box)
|
|
74
|
-
}
|
|
56
|
+
this.#boxes.set(element, this.makeBox(keys))
|
|
75
57
|
}
|
|
58
|
+
// 初回は同期で配置する(rAF 経由だと開いた直後の 1 フレームだけ位置がずれる)
|
|
59
|
+
this.#reposition()
|
|
60
|
+
this.#specimens.append(...this.#boxes.values())
|
|
61
|
+
|
|
62
|
+
document.addEventListener('scroll', this.#requestReposition, { capture: true, passive: true })
|
|
63
|
+
window.addEventListener('resize', this.#requestReposition, { passive: true })
|
|
76
64
|
}
|
|
77
65
|
|
|
78
66
|
hide() {
|
|
79
|
-
|
|
80
|
-
this.#
|
|
81
|
-
|
|
67
|
+
// capture フラグはリスナーの同一性判定に含まれるため、省くと解除されない
|
|
68
|
+
document.removeEventListener('scroll', this.#requestReposition, { capture: true })
|
|
69
|
+
window.removeEventListener('resize', this.#requestReposition)
|
|
70
|
+
|
|
71
|
+
// 閉じた後に走っても boxes は空で no-op になるが、無駄なコールバックを残さない
|
|
72
|
+
if (this.#frame !== null) {
|
|
73
|
+
cancelAnimationFrame(this.#frame)
|
|
74
|
+
this.#frame = null
|
|
75
|
+
}
|
|
82
76
|
|
|
83
|
-
|
|
77
|
+
// detached な box を握り続けると開くたびに肥大する
|
|
78
|
+
this.#boxes.clear()
|
|
84
79
|
this.#specimens.replaceChildren()
|
|
85
80
|
}
|
|
86
81
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
if (
|
|
82
|
+
// scroll は慣性スクロール中に毎フレーム相当で発火するため、同一フレーム内の再計測は 1 回にまとめる
|
|
83
|
+
#requestReposition = () => {
|
|
84
|
+
if (this.#frame !== null) return
|
|
90
85
|
|
|
86
|
+
this.#frame = requestAnimationFrame(() => {
|
|
87
|
+
this.#frame = null
|
|
88
|
+
this.#reposition()
|
|
89
|
+
})
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
#reposition() {
|
|
93
|
+
// 読みと書きを分けないと、要素数分の強制同期レイアウトが走る
|
|
94
|
+
const measured = Array.from(this.#boxes, ([element, box]) => [box, element.getBoundingClientRect()] as const)
|
|
95
|
+
|
|
96
|
+
for (const [box, rect] of measured) {
|
|
97
|
+
// display: none / DOM から外れた要素は rect が全て 0 になる。原点に枠だけ残さないよう隠す
|
|
98
|
+
box.hidden = rect.width === 0 && rect.height === 0
|
|
99
|
+
if (box.hidden) continue
|
|
100
|
+
|
|
101
|
+
box.style.left = `${rect.left}px`
|
|
102
|
+
box.style.top = `${rect.top}px`
|
|
103
|
+
box.style.width = `${rect.width}px`
|
|
104
|
+
box.style.height = `${rect.height}px`
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
private makeBox(keys: string[]): HTMLDivElement {
|
|
91
109
|
const box = document.createElement('div')
|
|
92
110
|
box.classList.add('specimen')
|
|
93
|
-
box.style.left = `${bounds.left}px`
|
|
94
|
-
box.style.top = `${bounds.top}px`
|
|
95
|
-
box.style.width = `${bounds.width}px`
|
|
96
|
-
box.style.height = `${bounds.height}px`
|
|
97
|
-
|
|
98
|
-
const { position, top, left } = getComputedStyle(element)
|
|
99
|
-
if (position === 'fixed') {
|
|
100
|
-
box.style.position = 'fixed'
|
|
101
|
-
box.style.top = top
|
|
102
|
-
box.style.left = left
|
|
103
|
-
}
|
|
104
111
|
|
|
105
112
|
// box 全体のクリックは先頭キーを開く(広いクリック領域を維持)。複数キー時は各ラベルから個別に開ける
|
|
106
113
|
box.addEventListener('click', () => this.#onOpen(keys[0]))
|
data/src/copytuner-bar.ts
CHANGED
|
@@ -21,12 +21,6 @@ export class CopytunerBar extends HTMLElement {
|
|
|
21
21
|
this.attachShadow({ mode: 'open' })
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
-
// custom element の constructor 内では属性・プロパティを変更できない(createElement が弾く)ため、
|
|
25
|
-
// hidden の初期化は DOM 挿入後に呼ばれる connectedCallback で行う。
|
|
26
|
-
connectedCallback() {
|
|
27
|
-
this.hidden = true
|
|
28
|
-
}
|
|
29
|
-
|
|
30
24
|
// url/data/keysSkipped/onOpen はオブジェクトや関数を含むため属性ではなくメソッドで受け渡す。
|
|
31
25
|
init({ url, data, keysSkipped, onOpen }: InitOptions) {
|
|
32
26
|
this.#onOpen = onOpen
|
|
@@ -66,14 +60,9 @@ export class CopytunerBar extends HTMLElement {
|
|
|
66
60
|
}
|
|
67
61
|
|
|
68
62
|
show() {
|
|
69
|
-
this.hidden = false
|
|
70
63
|
this.#searchBox.focus()
|
|
71
64
|
}
|
|
72
65
|
|
|
73
|
-
hide() {
|
|
74
|
-
this.hidden = true
|
|
75
|
-
}
|
|
76
|
-
|
|
77
66
|
private makeButton(label: string, href: string, target?: string): HTMLAnchorElement {
|
|
78
67
|
const button = document.createElement('a')
|
|
79
68
|
button.classList.add('button')
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { ROOT_STYLES } from './styles'
|
|
2
|
+
|
|
3
|
+
// dialog を showModal() で top layer の最上位に載せることで、アプリ側のモーダル dialog による
|
|
4
|
+
// inert 化を受けずに操作できる。トグルボタンだけ dialog の外に置くのは、
|
|
5
|
+
// 閉じている間 dialog のサブツリーが display: none になり、常時表示のボタンが消えてしまうため。
|
|
6
|
+
export class CopytunerRoot extends HTMLElement {
|
|
7
|
+
readonly dialog: HTMLDialogElement
|
|
8
|
+
#onToggle: () => void = () => {}
|
|
9
|
+
|
|
10
|
+
constructor() {
|
|
11
|
+
super()
|
|
12
|
+
const shadow = this.attachShadow({ mode: 'open' })
|
|
13
|
+
|
|
14
|
+
const style = document.createElement('style')
|
|
15
|
+
style.textContent = ROOT_STYLES
|
|
16
|
+
shadow.append(style)
|
|
17
|
+
|
|
18
|
+
this.dialog = document.createElement('dialog')
|
|
19
|
+
|
|
20
|
+
const toggleButton = document.createElement('a')
|
|
21
|
+
toggleButton.classList.add('toggle-button')
|
|
22
|
+
toggleButton.textContent = 'Open CopyTuner'
|
|
23
|
+
toggleButton.addEventListener('click', () => this.#onToggle())
|
|
24
|
+
|
|
25
|
+
shadow.append(this.dialog, toggleButton)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
set onToggle(callback: () => void) {
|
|
29
|
+
this.#onToggle = callback
|
|
30
|
+
}
|
|
31
|
+
}
|