magpie-page-token-widget 0.2.0 → 0.3.3
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.
- package/CHANGELOG.md +45 -0
- package/NOTICE +1 -1
- package/README.en.md +235 -0
- package/README.md +97 -163
- package/index.mjs +51 -0
- package/lib/collector.mjs +92 -13
- package/lib/forward.mjs +23 -1
- package/lib/network.mjs +179 -0
- package/lib/server.mjs +325 -28
- package/lib/state.mjs +33 -34
- package/lib/tailnet.mjs +64 -1
- package/mobile/app.js +2 -2
- package/mobile/index.html +1 -1
- package/mobile/style.css +3 -3
- package/package.json +6 -2
- package/scriptable/SetupUsageWidget.js +63 -9
- package/scriptable/UsageWidget.js +85 -22
- package/scripts/check-syntax.mjs +43 -0
- package/scripts/run-tests.mjs +28 -0
- package/ui/app.js +200 -10
- package/ui/index.html +15 -6
- package/ui/style.css +23 -1
- package/ui/timing.mjs +16 -0
package/ui/app.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { pairingLeft, remainingText } from './timing.mjs';
|
|
2
|
+
|
|
1
3
|
(() => {
|
|
2
4
|
const token = document.querySelector('meta[name="service-token"]')?.content || '';
|
|
3
5
|
let current = null;
|
|
@@ -28,7 +30,16 @@
|
|
|
28
30
|
...(options.headers || {}),
|
|
29
31
|
},
|
|
30
32
|
});
|
|
31
|
-
if (!response.ok)
|
|
33
|
+
if (!response.ok) {
|
|
34
|
+
let message = `request failed: ${response.status}`;
|
|
35
|
+
try {
|
|
36
|
+
const body = await response.json();
|
|
37
|
+
if (body && body.error) message = body.error;
|
|
38
|
+
} catch {
|
|
39
|
+
// The answer is not json; the number is all there is.
|
|
40
|
+
}
|
|
41
|
+
throw new Error(message);
|
|
42
|
+
}
|
|
32
43
|
return response.json();
|
|
33
44
|
};
|
|
34
45
|
|
|
@@ -71,9 +82,10 @@
|
|
|
71
82
|
const list = element(target);
|
|
72
83
|
if (!list) return;
|
|
73
84
|
list.replaceChildren();
|
|
74
|
-
for (const [name, value] of rows) {
|
|
85
|
+
for (const [name, value, cellId] of rows) {
|
|
75
86
|
list.append(node('dt', null, name));
|
|
76
87
|
const cell = node('dd');
|
|
88
|
+
if (cellId) cell.id = cellId;
|
|
77
89
|
if (value instanceof Node) cell.append(value);
|
|
78
90
|
else cell.textContent = value === undefined || value === null || value === '' ? '—' : String(value);
|
|
79
91
|
list.append(cell);
|
|
@@ -105,10 +117,101 @@
|
|
|
105
117
|
tailnet.ios?.length ? tailnet.ios.map((phone) => phone.name).join('、') : '没有看到',
|
|
106
118
|
],
|
|
107
119
|
['Magpie 网关', mono(state.upstream || '—')],
|
|
108
|
-
['旧版共享令牌', state.token ? '••••••••••••••••' : '已经撤掉'],
|
|
109
120
|
]);
|
|
110
121
|
};
|
|
111
122
|
|
|
123
|
+
// The user may have a network of his own: a Headscale of his own, a
|
|
124
|
+
// server of his own, a tunnel of his own. The address the phone is told to
|
|
125
|
+
// use is written down here, and the phone side may listen on an address of
|
|
126
|
+
// this computer.
|
|
127
|
+
const renderOwnNetwork = (state) => {
|
|
128
|
+
const external = state.external || {};
|
|
129
|
+
const field = element('external');
|
|
130
|
+
if (field && document.activeElement !== field) field.value = external.url || '';
|
|
131
|
+
const note = element('externalNote');
|
|
132
|
+
if (note) {
|
|
133
|
+
const parts = [];
|
|
134
|
+
const mode = external.mode || 'tailscale';
|
|
135
|
+
if (mode === 'tailscale') {
|
|
136
|
+
parts.push('现在的连法:Tailscale。手机从尾网地址连进来。');
|
|
137
|
+
} else if (mode === 'https') {
|
|
138
|
+
parts.push(`现在的连法:自己的 HTTPS 地址 ${external.url}。数据是加密的,令牌也走在加密里。`);
|
|
139
|
+
} else if (mode === 'lan') {
|
|
140
|
+
parts.push(`现在的连法:自己的内网 ${external.url}。`);
|
|
141
|
+
} else {
|
|
142
|
+
parts.push(`现在的连法:自己的地址 ${external.url}。`);
|
|
143
|
+
}
|
|
144
|
+
if (external.url && mode !== 'tailscale') {
|
|
145
|
+
parts.push('留空就用 Tailscale 的地址。');
|
|
146
|
+
}
|
|
147
|
+
if (external.listenAddress) {
|
|
148
|
+
parts.push(`手机那一侧的端口已经在 ${external.listenAddress} 上打开。`);
|
|
149
|
+
}
|
|
150
|
+
if (external.error) parts.push(external.error);
|
|
151
|
+
if (external.listenError) parts.push(external.listenError);
|
|
152
|
+
if (mode !== 'https') {
|
|
153
|
+
parts.push('这条路上的数据是明文的 HTTP,令牌就走在里面;iPhone 会问你要「本地网络」的权限。');
|
|
154
|
+
}
|
|
155
|
+
parts.push('只有 HTTPS 地址可以随便写域名;明文 HTTP 只认内网地址、Tailscale 地址和本机回环地址。');
|
|
156
|
+
note.textContent = parts.join(' ');
|
|
157
|
+
}
|
|
158
|
+
const list = element('bindList');
|
|
159
|
+
if (!list) return;
|
|
160
|
+
list.replaceChildren();
|
|
161
|
+
const candidates = external.candidates || [];
|
|
162
|
+
if (!candidates.length) {
|
|
163
|
+
list.append(node('p', 'note', '没有找到这台电脑上可以给手机用的地址。'));
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
for (const item of candidates) {
|
|
167
|
+
const row = node('div', 'row');
|
|
168
|
+
row.append(node('strong', null, item.name));
|
|
169
|
+
row.append(node('span', 'tag', KIND_LABEL[item.kind] || KIND_LABEL.other));
|
|
170
|
+
row.append(node('span', 'mono', item.address));
|
|
171
|
+
const use = node('button', null, '手机从这里连');
|
|
172
|
+
use.type = 'button';
|
|
173
|
+
use.addEventListener('click', () => { void useOwnAddress(item.address); });
|
|
174
|
+
row.append(use);
|
|
175
|
+
list.append(row);
|
|
176
|
+
}
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
// The phone is told to go to an address of this computer, and the phone
|
|
180
|
+
// side starts listening there.
|
|
181
|
+
const useOwnAddress = async (address) => {
|
|
182
|
+
try {
|
|
183
|
+
const port = current?.phonePort;
|
|
184
|
+
const host = address.includes(':') ? `[${address}]` : address;
|
|
185
|
+
const url = port ? `http://${host}:${port}` : '';
|
|
186
|
+
const before = current?.external?.url || '';
|
|
187
|
+
if (before && before !== url && !window.confirm(`把手机换成 ${url}?原来填的地址会换掉。`)) return;
|
|
188
|
+
await api('api/v1/console/options', {
|
|
189
|
+
method: 'POST',
|
|
190
|
+
body: JSON.stringify({
|
|
191
|
+
listenAddress: address,
|
|
192
|
+
externalURL: url,
|
|
193
|
+
}),
|
|
194
|
+
});
|
|
195
|
+
await load();
|
|
196
|
+
} catch (error) {
|
|
197
|
+
text('externalNote', error.message || '这个地址没有用。');
|
|
198
|
+
}
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
const saveOwnAddress = async () => {
|
|
202
|
+
const field = element('external');
|
|
203
|
+
if (!field) return;
|
|
204
|
+
try {
|
|
205
|
+
await api('api/v1/console/options', {
|
|
206
|
+
method: 'POST',
|
|
207
|
+
body: JSON.stringify({ externalURL: field.value.trim() }),
|
|
208
|
+
});
|
|
209
|
+
await load();
|
|
210
|
+
} catch (error) {
|
|
211
|
+
text('externalNote', error.message || '这个地址写不了。');
|
|
212
|
+
}
|
|
213
|
+
};
|
|
214
|
+
|
|
112
215
|
const renderNetwork = (state) => {
|
|
113
216
|
const tailnet = state.tailnet || {};
|
|
114
217
|
fillFacts('netFacts', [
|
|
@@ -187,7 +290,7 @@
|
|
|
187
290
|
meta.textContent = [
|
|
188
291
|
`配对于 ${timeOf(device.createdAt)}`,
|
|
189
292
|
`最后访问 ${device.lastAccessAt ? timeOf(device.lastAccessAt) : '还没有'}`,
|
|
190
|
-
|
|
293
|
+
'独立令牌',
|
|
191
294
|
device.revokedAt ? `已撤销(${timeOf(device.revokedAt)})` : '',
|
|
192
295
|
]
|
|
193
296
|
.filter(Boolean)
|
|
@@ -275,10 +378,17 @@
|
|
|
275
378
|
['服务端口', mono(String(state.port ?? '—'))],
|
|
276
379
|
['手机端口', mono(String(state.phonePort ?? '—'))],
|
|
277
380
|
['Magpie 网关', mono(state.upstream || '—')],
|
|
278
|
-
['配对码剩余时间',
|
|
381
|
+
['配对码剩余时间', remainingText(pairingLeft(state)), 'pairRemaining'],
|
|
279
382
|
['本插件的转发', forward.owned ? mono(`${forward.owned.publishedPort} → ${forward.owned.target}`) : '还没有记录'],
|
|
280
383
|
['配对记录', `${(state.paired || []).length} 条`],
|
|
281
384
|
]);
|
|
385
|
+
text(
|
|
386
|
+
'portNote',
|
|
387
|
+
'端口和网关都是 Magpie 启动这个插件时给的参数:插件改不了自己听的端口,也改不了它去读的 Magpie,' +
|
|
388
|
+
'所以它们在这里只看不改。要换,在 Magpie 的插件设置里改' +
|
|
389
|
+
'(magpie plugin options magpie-page-token-widget \'{"port":8890,"gatewayURL":"http://127.0.0.1:3500"}\'),' +
|
|
390
|
+
'改完重启 Magpie;手机那边的地址带着端口,换了端口手机要重新配对一次。'
|
|
391
|
+
);
|
|
282
392
|
};
|
|
283
393
|
|
|
284
394
|
const renderSubscriptions = (state) => {
|
|
@@ -296,6 +406,15 @@
|
|
|
296
406
|
input.value = subscription.id;
|
|
297
407
|
input.checked = all || chosen.has(family) || chosen.has(subscription.id);
|
|
298
408
|
label.append(input, document.createTextNode(subscription.label || subscription.id));
|
|
409
|
+
// The small widget is told which card to show with a parameter. The
|
|
410
|
+
// names it understands are written here, so an old one (an account that
|
|
411
|
+
// was renamed) can be seen and replaced.
|
|
412
|
+
const names = [subscription.provider || subscription.id];
|
|
413
|
+
if (subscription.account) names.push(subscription.id);
|
|
414
|
+
const hint = document.createElement('span');
|
|
415
|
+
hint.className = 'tag';
|
|
416
|
+
hint.textContent = '参数 ' + names.join(' / ');
|
|
417
|
+
label.append(document.createTextNode(' · '), hint);
|
|
299
418
|
list.append(label);
|
|
300
419
|
}
|
|
301
420
|
list.onchange = async () => {
|
|
@@ -337,44 +456,94 @@
|
|
|
337
456
|
};
|
|
338
457
|
};
|
|
339
458
|
|
|
340
|
-
const
|
|
341
|
-
current = state;
|
|
459
|
+
const renderPairing = (state) => {
|
|
342
460
|
const qr = element('qr');
|
|
343
461
|
if (qr && state.qr) qr.src = state.qr;
|
|
344
462
|
if (qr && !state.qr) qr.closest('.qr')?.classList.add('empty');
|
|
345
463
|
text('pairingURL', state.pairingURL || '还没有找到 Tailscale 地址');
|
|
346
|
-
const minutes = Math.max(0, Math.round((state.pairingExpiresIn || 0) / 60));
|
|
347
464
|
const blocked = Math.max(0, Math.round((state.pairingBlocked || 0) / 60));
|
|
348
465
|
text(
|
|
349
466
|
'pairNote',
|
|
350
467
|
state.pairingBlocked
|
|
351
468
|
? `猜错的次数太多,配对先关上 ${blocked} 分钟。点「重新生成配对码」可以马上再开。`
|
|
352
469
|
: state.pairingURL
|
|
353
|
-
?
|
|
470
|
+
? '配对码只能用一次,十分钟后也会作废。手机装好组件后它会自动失效,再生成一个就能装第二台。'
|
|
354
471
|
: 'Tailscale 没有连上,或者这台电脑没有尾网地址。手机和电脑要在同一个 Tailscale 里。'
|
|
355
472
|
);
|
|
473
|
+
// The time left is written in its own place. The note above is shared by
|
|
474
|
+
// the copy button and by the errors, and it must not be overwritten every
|
|
475
|
+
// second.
|
|
476
|
+
text('pairCountdown', state.pairingBlocked ? '' : remainingText(pairingLeft(state)));
|
|
477
|
+
};
|
|
478
|
+
|
|
479
|
+
const render = (state) => {
|
|
480
|
+
current = state;
|
|
481
|
+
renderPairing(state);
|
|
356
482
|
text(
|
|
357
483
|
'lifetime',
|
|
358
484
|
'组件随 Magpie 运行:关掉 Magpie 之后,手机上显示的是上一次的结果,重新打开 Magpie 就会更新。'
|
|
359
485
|
);
|
|
360
486
|
renderFacts(state);
|
|
361
487
|
renderNetwork(state);
|
|
488
|
+
renderOwnNetwork(state);
|
|
362
489
|
renderDevices(state);
|
|
363
490
|
renderRefresh(state);
|
|
364
491
|
renderAdvanced(state);
|
|
365
492
|
renderSubscriptions(state);
|
|
366
493
|
};
|
|
367
494
|
|
|
495
|
+
// A pairing code that has run out is of no use to anybody. A new one is
|
|
496
|
+
// made at once, so the first pairing after the install needs no extra click.
|
|
497
|
+
const renewPairing = async () => {
|
|
498
|
+
if (!current) return false;
|
|
499
|
+
if (pairingLeft(current) > 0) {
|
|
500
|
+
// The note counts down on the page itself.
|
|
501
|
+
renderPairing(current);
|
|
502
|
+
renderAdvanced(current);
|
|
503
|
+
return false;
|
|
504
|
+
}
|
|
505
|
+
try {
|
|
506
|
+
const result = await api('api/v1/console/pair', { method: 'POST', body: '{}' });
|
|
507
|
+
current = result;
|
|
508
|
+
renderPairing(result);
|
|
509
|
+
renderAdvanced(result);
|
|
510
|
+
return true;
|
|
511
|
+
} catch {
|
|
512
|
+
text('pairNote', '没能换一个新的配对码,过一会儿再试一次。');
|
|
513
|
+
return false;
|
|
514
|
+
}
|
|
515
|
+
};
|
|
516
|
+
|
|
368
517
|
const load = async () => {
|
|
369
518
|
try {
|
|
370
519
|
render(await api('api/v1/console/state'));
|
|
520
|
+
await renewPairing();
|
|
371
521
|
} catch {
|
|
372
522
|
text('pairNote', '这个页面没有连上本机的额度服务,关掉插件页面再打开一次。');
|
|
373
523
|
}
|
|
374
524
|
};
|
|
375
525
|
|
|
526
|
+
// The page stays open, so the code is looked at now and then. When it has
|
|
527
|
+
// run out, a new one is made without being asked for.
|
|
528
|
+
setInterval(() => { void renewPairing(); }, 30000);
|
|
529
|
+
|
|
530
|
+
// The time left on the code is counted here, on the page, so the numbers
|
|
531
|
+
// move while the page is open. Nothing is asked of the service, and the
|
|
532
|
+
// note above is left alone.
|
|
533
|
+
const tickCountdown = () => {
|
|
534
|
+
if (document.hidden || !current) return;
|
|
535
|
+
const left = pairingLeft(current);
|
|
536
|
+
// While the door is shut the line stays empty; the note next to it says why.
|
|
537
|
+
if (!current.pairingBlocked) text('pairCountdown', remainingText(left));
|
|
538
|
+
const cell = element('pairRemaining');
|
|
539
|
+
if (cell) cell.textContent = remainingText(left);
|
|
540
|
+
};
|
|
541
|
+
setInterval(tickCountdown, 1000);
|
|
542
|
+
document.addEventListener('visibilitychange', () => {
|
|
543
|
+
if (!document.hidden) tickCountdown();
|
|
544
|
+
});
|
|
545
|
+
|
|
376
546
|
element('copyLink')?.addEventListener('click', () => copy(current?.pairingURL, '配对链接'));
|
|
377
|
-
element('copyToken')?.addEventListener('click', () => copy(current?.token, '旧版共享令牌'));
|
|
378
547
|
element('manualToken')?.addEventListener('click', async () => {
|
|
379
548
|
const name = element('manualName')?.value?.trim() || '';
|
|
380
549
|
try {
|
|
@@ -399,6 +568,27 @@
|
|
|
399
568
|
text('pairNote', '没有换成新的配对码,请再试一次。');
|
|
400
569
|
}
|
|
401
570
|
});
|
|
571
|
+
element('saveExternal')?.addEventListener('click', () => { void saveOwnAddress(); });
|
|
572
|
+
element('restoreTailscale')?.addEventListener('click', async () => {
|
|
573
|
+
const button = element('restoreTailscale');
|
|
574
|
+
if (button) button.disabled = true;
|
|
575
|
+
text('restoreNote', '正在关掉自己开的端口,让 Tailscale 回来…');
|
|
576
|
+
try {
|
|
577
|
+
const result = await api('api/v1/console/network/reset', { method: 'POST', body: '{}' });
|
|
578
|
+
await load();
|
|
579
|
+
const mode = result?.network?.mode || 'tailscale';
|
|
580
|
+
text(
|
|
581
|
+
'restoreNote',
|
|
582
|
+
mode === 'tailscale'
|
|
583
|
+
? '已经回到 Tailscale:自己填的地址和在内网上开的端口都关掉了,手机从尾网连进来。'
|
|
584
|
+
: '已经恢复了一部分,请看上面的连接方式。',
|
|
585
|
+
);
|
|
586
|
+
} catch (error) {
|
|
587
|
+
text('restoreNote', error.message || '没有恢复成功,请再试一次。');
|
|
588
|
+
} finally {
|
|
589
|
+
if (button) button.disabled = false;
|
|
590
|
+
}
|
|
591
|
+
});
|
|
402
592
|
element('diagnose')?.addEventListener('click', async () => {
|
|
403
593
|
text('diagnoseNote', '正在问 Tailscale…');
|
|
404
594
|
try {
|
package/ui/index.html
CHANGED
|
@@ -16,15 +16,16 @@
|
|
|
16
16
|
</p>
|
|
17
17
|
</header>
|
|
18
18
|
|
|
19
|
+
<div class="cards">
|
|
19
20
|
<section class="card">
|
|
20
21
|
<h2>扫码配对</h2>
|
|
21
22
|
<div class="qr"><img id="qr" alt="配对二维码" /></div>
|
|
22
23
|
<p class="center mono" id="pairingURL">—</p>
|
|
23
24
|
<div class="actions">
|
|
24
25
|
<button id="copyLink" type="button">复制配对链接</button>
|
|
25
|
-
<button id="copyToken" type="button">复制旧版共享令牌</button>
|
|
26
26
|
<button id="rotate" type="button" class="quiet">重新生成配对码</button>
|
|
27
27
|
</div>
|
|
28
|
+
<p class="countdown" id="pairCountdown"></p>
|
|
28
29
|
<p class="note" id="pairNote"></p>
|
|
29
30
|
</section>
|
|
30
31
|
|
|
@@ -47,10 +48,19 @@
|
|
|
47
48
|
<h3>这台电脑的网卡</h3>
|
|
48
49
|
<div id="interfaces" class="list"></div>
|
|
49
50
|
<p class="note" id="lanNote"></p>
|
|
51
|
+
<h3>自己的内网(不想用 Tailscale 的时候)</h3>
|
|
52
|
+
<div class="row">
|
|
53
|
+
<input id="external" type="text" placeholder="http://192.168.1.50:8791" />
|
|
54
|
+
<button id="saveExternal" type="button">保存地址</button>
|
|
55
|
+
</div>
|
|
56
|
+
<p class="note" id="externalNote"></p>
|
|
57
|
+
<div id="bindList" class="list"></div>
|
|
50
58
|
<div class="actions">
|
|
51
59
|
<button id="diagnose" type="button">诊断连接</button>
|
|
60
|
+
<button id="restoreTailscale" type="button" class="quiet">恢复 Tailscale 默认连接</button>
|
|
52
61
|
</div>
|
|
53
62
|
<p class="note" id="diagnoseNote"></p>
|
|
63
|
+
<p class="note" id="restoreNote"></p>
|
|
54
64
|
</section>
|
|
55
65
|
|
|
56
66
|
<section class="card">
|
|
@@ -77,23 +87,22 @@
|
|
|
77
87
|
<details>
|
|
78
88
|
<summary>高级设置</summary>
|
|
79
89
|
<dl id="advanced"></dl>
|
|
80
|
-
<p class="note">
|
|
81
|
-
服务端口和网关地址是 Magpie 插件的启动参数,要改请到 Magpie 的插件设置里改;这里只是显示,不改动。
|
|
82
|
-
</p>
|
|
90
|
+
<p class="note" id="portNote"></p>
|
|
83
91
|
</details>
|
|
84
92
|
</section>
|
|
85
93
|
|
|
86
94
|
<section class="card">
|
|
87
95
|
<h2>在 iPhone 上</h2>
|
|
88
96
|
<ol>
|
|
89
|
-
<li>手机和电脑连在同一个 Tailscale
|
|
97
|
+
<li>手机和电脑连在同一个 Tailscale 里(用自定义地址时,让 iPhone 能访问那个地址就行)。</li>
|
|
90
98
|
<li>用相机扫上面的二维码,打开配对页。</li>
|
|
91
99
|
<li>在下载列表里用 Scriptable 打开下载的脚本。</li>
|
|
92
100
|
<li>长按 iPhone 桌面,添加 Scriptable 小组件,选 UsageWidget。</li>
|
|
93
101
|
</ol>
|
|
94
102
|
<p class="note" id="lifetime"></p>
|
|
95
103
|
</section>
|
|
104
|
+
</div>
|
|
96
105
|
</main>
|
|
97
|
-
<script src="app.js"></script>
|
|
106
|
+
<script type="module" src="app.js"></script>
|
|
98
107
|
</body>
|
|
99
108
|
</html>
|
package/ui/style.css
CHANGED
|
@@ -39,7 +39,7 @@ body {
|
|
|
39
39
|
"Microsoft YaHei", sans-serif;
|
|
40
40
|
}
|
|
41
41
|
main {
|
|
42
|
-
max-width:
|
|
42
|
+
max-width: 1120px;
|
|
43
43
|
margin: 0 auto;
|
|
44
44
|
padding: 28px 20px 56px;
|
|
45
45
|
}
|
|
@@ -60,6 +60,15 @@ h2 {
|
|
|
60
60
|
margin: 0;
|
|
61
61
|
color: var(--muted);
|
|
62
62
|
}
|
|
63
|
+
.cards {
|
|
64
|
+
display: grid;
|
|
65
|
+
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
|
66
|
+
gap: 14px;
|
|
67
|
+
align-items: start;
|
|
68
|
+
}
|
|
69
|
+
.cards .card {
|
|
70
|
+
margin-bottom: 0;
|
|
71
|
+
}
|
|
63
72
|
.card {
|
|
64
73
|
background: var(--card);
|
|
65
74
|
border: 1px solid var(--line);
|
|
@@ -67,6 +76,11 @@ h2 {
|
|
|
67
76
|
padding: 18px;
|
|
68
77
|
margin-bottom: 14px;
|
|
69
78
|
}
|
|
79
|
+
.countdown {
|
|
80
|
+
margin: 0 0 6px;
|
|
81
|
+
font-size: 14px;
|
|
82
|
+
font-variant-numeric: tabular-nums;
|
|
83
|
+
}
|
|
70
84
|
.qr {
|
|
71
85
|
display: grid;
|
|
72
86
|
place-items: center;
|
|
@@ -110,6 +124,14 @@ button {
|
|
|
110
124
|
font-size: 13px;
|
|
111
125
|
cursor: pointer;
|
|
112
126
|
}
|
|
127
|
+
/* The buttons that sit in a row next to an input are smaller. Only the
|
|
128
|
+
ones that are a direct child of the row; the ones inside .actions keep the
|
|
129
|
+
size of a real button. */
|
|
130
|
+
.row > button {
|
|
131
|
+
padding: 4px 9px;
|
|
132
|
+
font-size: 12px;
|
|
133
|
+
border-radius: 7px;
|
|
134
|
+
}
|
|
113
135
|
button.quiet {
|
|
114
136
|
background: transparent;
|
|
115
137
|
color: var(--fg);
|
package/ui/timing.mjs
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// The time left on a pairing code, counted on the page itself. These two
|
|
2
|
+
// functions are plain, so they can be tested without a browser.
|
|
3
|
+
export const pairingLeft = (state, now = Date.now()) => {
|
|
4
|
+
const until = Date.parse(state?.pairingExpiresAt || '');
|
|
5
|
+
if (!Number.isFinite(until)) return 0;
|
|
6
|
+
return Math.max(0, Math.round((until - now) / 1000));
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
// Under a minute the seconds are shown, so a code that is about to die does
|
|
10
|
+
// not look like a dead one.
|
|
11
|
+
export const remainingText = (seconds) => {
|
|
12
|
+
const left = Math.max(0, Math.round(seconds || 0));
|
|
13
|
+
if (!left) return '已经作废';
|
|
14
|
+
if (left < 60) return `还剩 ${left} 秒`;
|
|
15
|
+
return `还剩 ${Math.floor(left / 60)} 分 ${left % 60} 秒`;
|
|
16
|
+
};
|