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/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) throw new Error(`request failed: ${response.status}`);
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
- device.legacy ? '旧版共享令牌' : '独立令牌',
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
- ['配对码剩余时间', `${Math.max(0, Math.round((state.pairingExpiresIn || 0) / 60))} 分钟`],
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 render = (state) => {
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
- ? `配对码只能用一次,十分钟后也会作废(还剩 ${minutes} 分钟)。手机装好组件后它会自动失效,再生成一个就能装第二台。`
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 里。</li>
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: 640px;
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
+ };