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/lib/tailnet.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { execFile } from 'node:child_process';
|
|
1
|
+
import { execFile, spawnSync } from 'node:child_process';
|
|
2
2
|
import { existsSync } from 'node:fs';
|
|
3
3
|
import os from 'node:os';
|
|
4
4
|
import path from 'node:path';
|
|
@@ -89,6 +89,38 @@ export async function status(runner) {
|
|
|
89
89
|
}
|
|
90
90
|
}
|
|
91
91
|
|
|
92
|
+
// The same command, but without waiting: it is used at the end of a run,
|
|
93
|
+
// where there is no time left for a promise. The runner may be given as well,
|
|
94
|
+
// but then it has to answer at once.
|
|
95
|
+
export function callSync(args, timeout = 15000, runner) {
|
|
96
|
+
if (runner) {
|
|
97
|
+
const result = runner(args);
|
|
98
|
+
if (result && typeof result.then === 'function') {
|
|
99
|
+
return { ok: false, error: 'The runner has to answer at once.' };
|
|
100
|
+
}
|
|
101
|
+
return result;
|
|
102
|
+
}
|
|
103
|
+
const binary = find();
|
|
104
|
+
if (!binary) return { ok: false, error: 'Tailscale is not installed on this computer.' };
|
|
105
|
+
try {
|
|
106
|
+
const result = IS_WINDOWS
|
|
107
|
+
? spawnSync('cmd.exe', ['/d', '/s', '/c', windowsCommand(binary, args)], {
|
|
108
|
+
timeout,
|
|
109
|
+
windowsHide: true,
|
|
110
|
+
windowsVerbatimArguments: true,
|
|
111
|
+
encoding: 'utf8',
|
|
112
|
+
})
|
|
113
|
+
: spawnSync(binary, args, { timeout, windowsHide: true, encoding: 'utf8' });
|
|
114
|
+
if (result.error) return { ok: false, error: String(result.error.message || result.error) };
|
|
115
|
+
if (result.status !== 0) {
|
|
116
|
+
return { ok: false, error: String(result.stderr || '').trim() || 'tailscale failed' };
|
|
117
|
+
}
|
|
118
|
+
return { ok: true, stdout: String(result.stdout || '') };
|
|
119
|
+
} catch (error) {
|
|
120
|
+
return { ok: false, error: String(error?.message || error) };
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
92
124
|
// The address of this computer inside the tailnet: 100.64.0.0/10.
|
|
93
125
|
export function pickIP(data) {
|
|
94
126
|
const self = data?.Self || {};
|
|
@@ -261,6 +293,37 @@ export function dnsName(data) {
|
|
|
261
293
|
return name.endsWith('.ts.net') ? name : null;
|
|
262
294
|
}
|
|
263
295
|
|
|
296
|
+
// The addresses of this computer that a phone of the own network of the user
|
|
297
|
+
// could use: the cards of a cable, of a wi-fi, and the tunnels of a network
|
|
298
|
+
// of one's own. The loopback is never a way in from outside. The 100.64/10
|
|
299
|
+
// range IS in: a Headscale of the user's own hands out the same range
|
|
300
|
+
// Tailscale uses, on an adapter that is not Tailscale's.
|
|
301
|
+
export const isCgnatIPv4 = (ip) => /^100\.(6[4-9]|[789]\d|1[01]\d|12[0-7])\./.test(ip);
|
|
302
|
+
|
|
303
|
+
export function bindCandidates(list = os.networkInterfaces()) {
|
|
304
|
+
const found = [];
|
|
305
|
+
for (const [name, addresses] of Object.entries(list || {})) {
|
|
306
|
+
if (!Array.isArray(addresses) || !addresses.length) continue;
|
|
307
|
+
const kind = interfaceKind(name, addresses);
|
|
308
|
+
// The loopback is never a way in from outside. The adapter of a tailnet
|
|
309
|
+
// is: it may be a Tailscale of this computer or a Headscale of the
|
|
310
|
+
// user's own, and the phone reaches the service on that address.
|
|
311
|
+
if (kind === 'loopback') continue;
|
|
312
|
+
for (const entry of addresses) {
|
|
313
|
+
if (!entry || entry.internal) continue;
|
|
314
|
+
const family = entry.family === 'IPv4' || entry.family === 4 ? 'IPv4' : 'IPv6';
|
|
315
|
+
if (family === 'IPv4') {
|
|
316
|
+
// The private addresses of a house or an office, and the range a
|
|
317
|
+
// Headscale of the user's own hands out (the same range Tailscale
|
|
318
|
+
// uses, on an adapter that is not Tailscale's).
|
|
319
|
+
if (!isPrivateIPv4(entry.address) && !isCgnatIPv4(entry.address)) continue;
|
|
320
|
+
} else if (!isUlaIPv6(entry.address)) continue;
|
|
321
|
+
found.push({ name, kind, address: entry.address });
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
return found;
|
|
325
|
+
}
|
|
326
|
+
|
|
264
327
|
export async function detect(runner) {
|
|
265
328
|
const result = await status(runner);
|
|
266
329
|
if (!result.ok) {
|
package/mobile/app.js
CHANGED
|
@@ -177,7 +177,7 @@ function speedText(metrics) {
|
|
|
177
177
|
}
|
|
178
178
|
function render(data, offline = false) {
|
|
179
179
|
const stale = offline || Object.values(data.sources).some(source => source.state !== 'ok');
|
|
180
|
-
$('connection').textContent = offline ? '离线· 显示缓存' : stale ? '部分数据待更新' : '
|
|
180
|
+
$('connection').textContent = offline ? '离线· 显示缓存' : stale ? '部分数据待更新' : '已连接';
|
|
181
181
|
$('status-dot').className = 'status-dot ' + (stale ? 'warn' : 'ok');
|
|
182
182
|
const notices = [];
|
|
183
183
|
if (data.demo) notices.push('演示模式· 所有数字、模型和设备用量均为虚构数据。');
|
|
@@ -228,7 +228,7 @@ async function load() {
|
|
|
228
228
|
$('auth').close(); render(data);
|
|
229
229
|
} catch {
|
|
230
230
|
if (last) render(last, true);
|
|
231
|
-
else { $('connection').textContent = '连接失败'; $('notice').hidden = false; $('notice').textContent = '无法连接服务。请检查 PC 服务和 iPhone
|
|
231
|
+
else { $('connection').textContent = '连接失败'; $('notice').hidden = false; $('notice').textContent = '无法连接服务。请检查 PC 服务和 iPhone 的网络(尾网或内网)。'; }
|
|
232
232
|
} finally { $('refresh').disabled = false; }
|
|
233
233
|
}
|
|
234
234
|
$('auth-form').addEventListener('submit', event => {
|
package/mobile/index.html
CHANGED
|
@@ -48,7 +48,7 @@
|
|
|
48
48
|
</section>
|
|
49
49
|
<details class="install">
|
|
50
50
|
<summary>添加到 iPhone 桌面 <span aria-hidden="true">↗</span></summary>
|
|
51
|
-
<ol><li>在 iPhone 安装 Scriptable
|
|
51
|
+
<ol><li>在 iPhone 安装 Scriptable。手机要能访问这个服务地址:默认是 Tailscale 地址,也可以是你自己配好的内网地址或 HTTPS 地址。</li><li>新建脚本,粘贴 <a href="/scriptable/SetupUsageWidget.js">SetupUsageWidget.js</a> 的完整内容并运行。</li><li>输入服务地址与只读令牌(控制台「扫码配对」里有)。安装器会自动下载桌面组件。</li><li>长按桌面添加 Scriptable,选择 UsageWidget;推荐中号。</li></ol>
|
|
52
52
|
<p>小号组件参数:<code>codex</code>、<code>minimax</code> 或 <code>glm</code>;追加 <code>|used</code> 改显已用(如 <code>glm|used</code>),中号组件填 <code>used</code> 可整体切换。令牌只保存在本机 Keychain。</p>
|
|
53
53
|
</details>
|
|
54
54
|
<footer><span>OBSERVED BY MAGPIE <span class="footer-separator">/</span> MADE FOR YOUR IPHONE</span><span id="updated">尚未更新</span></footer>
|
package/mobile/style.css
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
@font-face{font-family:UsageSystem;src:local("SF Pro Display"),local("Segoe UI")}
|
|
2
2
|
:root{color-scheme:light dark;--bg:#f7f7f5;--panel:#fff;--text:#232523;--muted:#888c86;--line:#e9ebe5;--track:#eef0eb;--fill:#4c544a;--accent:#6d8067;--warn:#b77c36;--bad:#b35447;--soft:#f2f4ef;--shadow:0 3px 18px #22270503}
|
|
3
3
|
*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--text);font-family:UsageSystem,-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Microsoft YaHei",sans-serif;-webkit-font-smoothing:antialiased}button,input{font:inherit}a{color:inherit;text-underline-offset:4px}.shell{max-width:1120px;margin:auto;padding:0 44px}.topbar{height:100px;display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid var(--line)}.brand{display:flex;align-items:center;gap:10px;font-size:27px;font-weight:650;letter-spacing:-1.4px;text-decoration:none}.brand-mark{display:inline-block;font-size:32px;line-height:1;transform:rotate(-35deg)}.brand-dot{margin-left:-9px;color:var(--accent)}.top-actions{display:flex;align-items:center;gap:13px}.theme-toggle{border:0;background:transparent;color:var(--muted);font-size:20px;padding:5px;line-height:1}.connection{display:flex;align-items:center;gap:8px;font-size:12px;color:var(--muted)}.status-dot{height:6px;width:6px;background:var(--muted);border-radius:50%}.status-dot.ok{background:var(--accent)}.status-dot.warn{background:var(--warn)}main{padding:46px 0 28px}.page-heading{display:flex;align-items:center;justify-content:space-between;margin-bottom:40px}.eyebrow{font-size:9px;font-weight:600;letter-spacing:2.8px;color:var(--muted);margin:0 0 13px}h1{font-size:32px;letter-spacing:-1px;font-weight:600;margin:0 0 12px}.intro{margin:0;font-size:13px;color:var(--muted);letter-spacing:.3px}button{cursor:pointer}.refresh{display:flex;align-items:center;gap:8px;border:1px solid var(--line);background:var(--panel);border-radius:24px;padding:9px 15px;color:var(--text);font-size:12px}.refresh>span:first-child{font-size:20px;line-height:1}.refresh:disabled{opacity:.45;cursor:wait}.section-heading{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:20px}.section-heading h2{font-size:13px;font-weight:550;letter-spacing:.3px;margin:0}.muted{font-size:10px;color:var(--muted)}.quota-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:16px;margin-bottom:26px}.panel{border:1px solid var(--line);background:var(--panel);border-radius:16px;padding:25px;box-shadow:var(--shadow);min-width:0}.quota-card{padding:22px 24px}.provider-heading{display:flex;align-items:center;gap:10px;margin-bottom:25px}.provider-monogram{border:1px solid var(--line);border-radius:9px;width:31px;height:31px;display:grid;place-items:center;font-size:14px;font-weight:650}.provider-name{font-size:15px;font-weight:600;letter-spacing:-.4px}.tag{padding:4px 7px;background:var(--soft);color:var(--muted);font-size:9px;border-radius:4px;text-transform:uppercase;letter-spacing:.7px}.provider-heading .tag{margin-left:auto;max-width:110px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.window{margin:17px 0 0}.window.primary{margin-top:0}.window-heading{display:flex;align-items:baseline;justify-content:space-between;gap:8px;margin-bottom:8px}.window-name{font-size:11px;color:var(--muted)}.window-value{font-size:13px;font-weight:600;font-variant-numeric:tabular-nums}.primary .window-value{font-size:36px;font-weight:500;letter-spacing:-1.6px}.primary .window-value small{font-size:17px;letter-spacing:0;margin-left:3px;color:var(--muted)}progress{width:100%;height:4px;display:block;border:0;border-radius:3px;overflow:hidden;background:var(--track);color:var(--fill);accent-color:var(--fill)}progress::-webkit-progress-bar{background:var(--track);border-radius:3px}progress::-webkit-progress-value{background:var(--fill);border-radius:3px}progress::-moz-progress-bar{background:var(--fill);border-radius:3px}.low{--fill:var(--warn)}.critical{--fill:var(--bad)}.window-reset{font-size:10px;color:var(--muted);margin:8px 0 0}.stale-label{font-size:10px;color:var(--warn);margin:14px 0 0}.activity-grid{display:grid;grid-template-columns:1fr 1.15fr;gap:20px;margin:0 0 20px}.daily-number{font-size:57px;line-height:1.1;letter-spacing:-3px;font-weight:450;font-variant-numeric:tabular-nums;margin:28px 0 9px}.metric-caption{font-size:10px;color:var(--muted);margin:0}.breakdown{display:grid;grid-template-columns:repeat(2,1fr);gap:18px 16px;margin:27px 0 24px}.breakdown-item{display:flex;align-items:baseline;justify-content:space-between;font-size:10px;gap:6px;color:var(--muted)}.breakdown-item strong{font-size:12px;font-weight:500;color:var(--text);font-variant-numeric:tabular-nums}.reasoning{font-size:9px;color:var(--muted);margin:-8px 0 18px}.card-foot{border-top:1px solid var(--line);padding-top:15px;display:flex;justify-content:space-between;gap:8px;font-size:10px;color:var(--muted)}.week-heading{display:flex;align-items:baseline;gap:10px;margin:27px 0 0}.week-heading strong{font-size:29px;letter-spacing:-1px;font-weight:500}.chart{height:175px;margin:6px 0 12px}.chart svg{width:100%;height:100%;overflow:visible}.chart rect{fill:#cfd5ca}.chart rect.last{fill:var(--fill)}.chart text{fill:var(--muted);font-size:9px;font-family:inherit}.chart .gridline{stroke:var(--line);stroke-width:1}.ranking-row{padding:13px 0;border-bottom:1px solid var(--line)}.ranking-row:last-child{border-bottom:0}.ranking-heading{display:flex;justify-content:space-between;align-items:center;gap:12px;font-size:12px;margin-bottom:9px}.ranking-heading span:first-child{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.ranking-heading strong{font-size:11px;font-weight:500;font-variant-numeric:tabular-nums;white-space:nowrap}.ranking-row progress{height:3px;--fill:#c7cec2}.fine-print{font-size:10px;line-height:1.9;color:var(--muted);margin:22px 0 0}.empty{color:var(--muted);font-size:12px;padding:24px 0}.quota-grid>.empty{padding:30px;grid-column:1/-1}.install{border-top:1px solid var(--line);margin-top:35px;padding-top:22px;font-size:11px;color:var(--muted);line-height:2}.install summary{cursor:pointer;list-style:none;color:var(--text);display:flex;justify-content:space-between;font-size:12px}.install li{padding:3px 0}.install code{color:var(--text)}footer{display:flex;justify-content:space-between;align-items:center;gap:20px;margin:30px 0 12px;font-size:8px;color:var(--muted);letter-spacing:1px}.footer-separator{margin:0 8px;color:#c5c9bf}.method{font-size:9px;color:var(--muted);line-height:1.9;margin:0}.notice{border:1px solid var(--line);background:var(--soft);font-size:11px;padding:12px 16px;border-radius:9px;margin-bottom:24px;line-height:1.7}.notice[hidden]{display:none}dialog{border:1px solid var(--line);border-radius:18px;padding:30px;background:var(--panel);color:var(--text);width:min(400px,calc(100% - 32px));box-shadow:0 20px 100px #0002}dialog::backdrop{background:#272b2777;backdrop-filter:blur(5px)}dialog h2{font-size:22px;font-weight:550;margin:22px 0 12px}dialog p{font-size:12px;color:var(--muted);line-height:1.8}dialog label{display:block;font-size:11px;margin:22px 0 9px}dialog input{width:100%;padding:12px;border:1px solid var(--line);background:var(--bg);color:var(--text);border-radius:8px;font-size:13px}dialog button{width:100%;margin-top:13px;padding:12px;border:0;border-radius:8px;background:var(--text);color:var(--panel);font-size:13px}.auth-error{color:var(--bad);min-height:18px;margin-bottom:0}:focus-visible{outline:2px solid var(--accent);outline-offset:4px}
|
|
4
|
-
@media(prefers-color-scheme:dark){:root:not([data-theme="light"]){--bg:#171916;--panel:#1f221e;--text:#ebeee7;--muted:#92998d;--line:#31362e;--track:#30362d;--fill:#
|
|
4
|
+
@media(prefers-color-scheme:dark){:root:not([data-theme="light"]){--bg:#171916;--panel:#1f221e;--text:#ebeee7;--muted:#92998d;--line:#31362e;--track:#30362d;--fill:#8b9384;--accent:#a6bb9a;--warn:#d2a061;--bad:#e09c88;--soft:#292e26}:root:not([data-theme="light"]) .chart rect{fill:#525f4b}:root:not([data-theme="light"]) .ranking-row progress{--fill:#66765c}}
|
|
5
5
|
@media(max-width:700px){.shell{padding:0 22px}.topbar{height:79px}main{padding-top:30px}.page-heading{margin-bottom:30px}h1{font-size:28px}.eyebrow{font-size:8px;letter-spacing:2px}.intro{font-size:11px}.quota-grid{grid-template-columns:1fr;gap:12px;margin-bottom:18px}.quota-card{padding:19px 21px}.provider-heading{margin-bottom:15px}.quota-card .windows{display:grid;grid-template-columns:1fr 1fr;gap:25px}.quota-card .window{margin:0}.quota-card .window-value,.quota-card .primary .window-value{font-size:27px;letter-spacing:-1px}.quota-card .primary .window-value small{font-size:14px}.quota-card .window-heading{flex-wrap:wrap;gap:4px}.quota-card .window-name{order:2;width:100%;font-size:10px}.quota-card .window-reset{font-size:9px}.activity-grid{grid-template-columns:1fr;gap:16px}.panel{border-radius:13px}.today-card,.trend-card,.detail-panel{padding:23px}.daily-number{font-size:51px;margin-top:24px}.breakdown{margin-top:25px}.chart{height:165px}.section-heading{margin-bottom:14px}.section-heading>.muted{font-size:9px}.detail-grid .ranking-row{padding:13px 0}footer{align-items:flex-start;line-height:1.8;flex-direction:column;gap:8px}.connection{font-size:10px}.brand{font-size:25px}.refresh{padding:9px 12px}.refresh>span:last-child{display:none}.method{font-size:8px}.install{margin-top:26px}.week-heading{margin-top:22px}}
|
|
6
|
-
:root[data-theme="dark"]{--bg:#171916;--panel:#1f221e;--text:#ebeee7;--muted:#92998d;--line:#31362e;--track:#30362d;--fill:#
|
|
6
|
+
:root[data-theme="dark"]{--bg:#171916;--panel:#1f221e;--text:#ebeee7;--muted:#92998d;--line:#31362e;--track:#30362d;--fill:#8b9384;--accent:#a6bb9a;--warn:#d2a061;--bad:#e09c88;--soft:#292e26}:root[data-theme="dark"] .chart rect{fill:#525f4b}:root[data-theme="dark"] .ranking-row progress{--fill:#66765c}
|
|
7
7
|
:root[data-theme="dark"] .chart rect.last{fill:var(--fill)}@media(prefers-color-scheme:dark){:root:not([data-theme="light"]) .chart rect.last{fill:var(--fill)}}
|
|
8
|
-
|
|
8
|
+
|
|
9
9
|
.segmented{display:inline-flex;border:1px solid var(--line);border-radius:8px;overflow:hidden;background:var(--panel)}.segmented button{border:0;background:transparent;color:var(--muted);font-size:11px;padding:6px 12px;letter-spacing:.3px;cursor:pointer}.segmented button+button{border-left:1px solid var(--line)}.segmented button.active{background:var(--soft);color:var(--text);font-weight:550}.window-amount{font-size:10px;color:var(--muted);margin:6px 0 0;font-variant-numeric:tabular-nums}.extras{font-size:9px;color:var(--muted);margin:-8px 0 18px;font-variant-numeric:tabular-nums}.rank-note{margin:16px 0 0;font-size:10px}.ranking-value{display:inline-flex;align-items:baseline;gap:8px}.ranking-cost{font-size:10px;color:var(--muted);font-variant-numeric:tabular-nums}@media(max-width:700px){.segmented button{padding:5px 9px;font-size:10px}.rank-note{font-size:9px}}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "magpie-page-token-widget",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.3",
|
|
4
4
|
"description": "An iPhone home screen widget for Magpie subscription quotas: install the plugin, scan a code, and the widget shows each subscription's remaining quota.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./index.mjs",
|
|
@@ -10,13 +10,17 @@
|
|
|
10
10
|
"mobile",
|
|
11
11
|
"page",
|
|
12
12
|
"scriptable",
|
|
13
|
+
"scripts",
|
|
13
14
|
"ui",
|
|
14
15
|
"README.md",
|
|
16
|
+
"README.en.md",
|
|
17
|
+
"CHANGELOG.md",
|
|
15
18
|
"LICENSE",
|
|
16
19
|
"NOTICE"
|
|
17
20
|
],
|
|
18
21
|
"scripts": {
|
|
19
|
-
"test": "node
|
|
22
|
+
"test": "node scripts/run-tests.mjs",
|
|
23
|
+
"check:syntax": "node scripts/check-syntax.mjs"
|
|
20
24
|
},
|
|
21
25
|
"engines": {
|
|
22
26
|
"magpie": ">=0.1.3681",
|
|
@@ -7,7 +7,7 @@ let saved = {};
|
|
|
7
7
|
try { saved = JSON.parse(Keychain.get(SETTINGS_KEY)) || {}; } catch {}
|
|
8
8
|
const alert = new Alert();
|
|
9
9
|
alert.title = "连接你的用量";
|
|
10
|
-
alert.message = "
|
|
10
|
+
alert.message = "确保 iPhone 能访问你填写的服务地址。该地址可以是 Tailscale 地址,也可以是你自己配好的可信内网地址,或你自己的 HTTPS 域名。输入 PC 上生成的服务地址与只读令牌。无需填写模型 API Key。";
|
|
11
11
|
alert.addTextField("http://你的 Tailscale IP:" + DEFAULT_PORT, typeof saved.baseURL === "string" ? saved.baseURL : "");
|
|
12
12
|
alert.addSecureTextField("只读访问令牌", typeof saved.token === "string" ? saved.token : "");
|
|
13
13
|
alert.addAction("验证并安装"); alert.addCancelAction("取消");
|
|
@@ -15,13 +15,67 @@ if (await alert.presentAlert() !== -1) {
|
|
|
15
15
|
const baseURL = alert.textFieldValue(0).trim().replace(/\/+$/, "");
|
|
16
16
|
const token = alert.textFieldValue(1).trim();
|
|
17
17
|
try {
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
18
|
+
// The same rules as the service itself (lib/network.mjs): HTTPS may be
|
|
19
|
+
// any name, plain HTTP only a name or an address of a network that is
|
|
20
|
+
// trusted. The service refuses the same addresses, so the phone and the
|
|
21
|
+
// service never disagree about what may be used.
|
|
22
|
+
const PLAIN_TEXT_REFUSAL = "公网的明文 HTTP 地址不接受:令牌会在路上被看见。请用 HTTPS,或者换成内网地址(Tailscale、RFC1918、ULA)。";
|
|
23
|
+
const isTrustedHost = (name) => {
|
|
24
|
+
if (/^\d{1,3}(\.\d{1,3}){3}$/.test(name)) {
|
|
25
|
+
const p = name.split(".").map(Number);
|
|
26
|
+
if (p.some(o => !Number.isInteger(o) || o < 0 || o > 255)) return false;
|
|
27
|
+
if (p[0] === 127) return true;
|
|
28
|
+
if (p[0] === 10 || (p[0] === 192 && p[1] === 168) || (p[0] === 172 && p[1] >= 16 && p[1] <= 31)) return true;
|
|
29
|
+
if (p[0] === 100 && p[1] >= 64 && p[1] <= 127) return true;
|
|
30
|
+
if (p[0] === 169 && p[1] === 254) return true;
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
if (name.includes(":")) {
|
|
34
|
+
if (name === "::1") return true;
|
|
35
|
+
if (/^f[cd][0-9a-f]{2}:/.test(name)) return true;
|
|
36
|
+
if (/^fe[89ab][0-9a-f]:/.test(name)) return true;
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
const bare = name.replace(/\.$/, "");
|
|
40
|
+
if (!bare.includes(".")) return true;
|
|
41
|
+
return [".localhost", ".local", ".lan", ".home.arpa", ".internal", ".ts.net"].some(s => bare.endsWith(s));
|
|
42
|
+
};
|
|
43
|
+
const checkServiceAddress = (value) => {
|
|
44
|
+
const text = String(value || "").trim();
|
|
45
|
+
if (!text) return { ok: true, empty: true };
|
|
46
|
+
if (text.includes("?") || text.includes("#")) return { ok: false, error: "地址里不能带问号后面的参数。" };
|
|
47
|
+
const match = /^(https?):\/\/([^/]+)(\/.*)?$/i.exec(text);
|
|
48
|
+
if (!match) return { ok: false, error: "请输入完整的服务地址,例如 https://example.com 或 http://192.168.1.50:8791。" };
|
|
49
|
+
const scheme = match[1].toLowerCase();
|
|
50
|
+
const authority = match[2];
|
|
51
|
+
if (authority.includes("@")) return { ok: false, error: "地址里不能带用户名或密码。" };
|
|
52
|
+
const path = match[3] || "";
|
|
53
|
+
if (path && path !== "/") return { ok: false, error: "这个版本不支持带路径的地址。反向代理请用一个独立域名,把它的根路径转到插件服务。" };
|
|
54
|
+
let host = authority, portText = "";
|
|
55
|
+
if (authority.startsWith("[")) {
|
|
56
|
+
const end = authority.indexOf("]");
|
|
57
|
+
if (end < 0) return { ok: false, error: "IPv6 的地址要写成 [::1] 这样的方括号。" };
|
|
58
|
+
host = authority.slice(1, end);
|
|
59
|
+
const tail = authority.slice(end + 1);
|
|
60
|
+
if (tail && !tail.startsWith(":")) return { ok: false, error: "端口不对。" };
|
|
61
|
+
portText = tail.slice(1);
|
|
62
|
+
} else {
|
|
63
|
+
const colon = authority.lastIndexOf(":");
|
|
64
|
+
if (colon >= 0) { host = authority.slice(0, colon); portText = authority.slice(colon + 1); }
|
|
65
|
+
}
|
|
66
|
+
if (!host) return { ok: false, error: "地址里没有主机名。" };
|
|
67
|
+
if (portText) {
|
|
68
|
+
if (!/^\d+$/.test(portText)) return { ok: false, error: "端口不对。" };
|
|
69
|
+
const port = Number(portText);
|
|
70
|
+
if (port < 1 || port > 65535) return { ok: false, error: "端口不对。" };
|
|
71
|
+
}
|
|
72
|
+
const name = host.toLowerCase();
|
|
73
|
+
if (scheme === "http" && !isTrustedHost(name)) return { ok: false, error: PLAIN_TEXT_REFUSAL };
|
|
74
|
+
return { ok: true, secure: scheme === "https" };
|
|
75
|
+
};
|
|
76
|
+
const checked = checkServiceAddress(baseURL);
|
|
77
|
+
if (!checked.ok) throw new Error(checked.error);
|
|
78
|
+
if (checked.empty) throw new Error("请填写服务地址。");
|
|
25
79
|
if (token.length < 32) throw new Error("令牌不完整,请复制 PC 上生成的完整只读令牌。");
|
|
26
80
|
const probe = new Request(baseURL + "/api/v1/overview");
|
|
27
81
|
probe.headers = { Authorization: "Bearer " + token }; probe.timeoutInterval = 12;
|
|
@@ -51,7 +105,7 @@ if (await alert.presentAlert() !== -1) {
|
|
|
51
105
|
} catch (error) {
|
|
52
106
|
const failed = new Alert();
|
|
53
107
|
failed.title = "连接未完成";
|
|
54
|
-
failed.message = String(error.message || "请检查 PC
|
|
108
|
+
failed.message = String(error.message || "请检查 PC 服务,以及 iPhone 能不能访问你填的那个地址,再重试。");
|
|
55
109
|
failed.addAction("知道了"); await failed.presentAlert();
|
|
56
110
|
}
|
|
57
111
|
}
|
|
@@ -58,7 +58,10 @@ const colors = {
|
|
|
58
58
|
text: Color.dynamic(new Color("#232523"), new Color("#ebeee7")),
|
|
59
59
|
muted: Color.dynamic(new Color("#8a8f85"), new Color("#92998d")),
|
|
60
60
|
track: dark ? new Color("#30362d") : new Color("#eef0eb"),
|
|
61
|
-
|
|
61
|
+
// Ink in both appearances. A bright bar in the night mode was read as a
|
|
62
|
+
// warning, and a warning that really means "plenty left" turns the reading
|
|
63
|
+
// of the whole card upside down.
|
|
64
|
+
fill: dark ? new Color("#8b9384") : new Color("#6b7268"),
|
|
62
65
|
warning: new Color(dark ? "#d2a061" : "#b77c36"),
|
|
63
66
|
critical: new Color(dark ? "#e09c88" : "#b35447")
|
|
64
67
|
};
|
|
@@ -139,6 +142,15 @@ function shortenName(label, size, width) {
|
|
|
139
142
|
while (keep > 1 && nameWidth(label.slice(0, keep) + "…") * size * minimumScale > width) keep--;
|
|
140
143
|
return label.slice(0, keep) + "…";
|
|
141
144
|
}
|
|
145
|
+
// The word in front of every number. Without it the bar and the number can
|
|
146
|
+
// be read the wrong way round, and the night mode makes that worse.
|
|
147
|
+
function direction() { return showUsed ? "已用" : "剩余"; }
|
|
148
|
+
// The name of a card is the name of the vendor and, when it has one, the
|
|
149
|
+
// account of it.
|
|
150
|
+
function splitLabel(label) {
|
|
151
|
+
const parts = String(label).split("·").map(part => part.trim()).filter(Boolean);
|
|
152
|
+
return { vendor: parts[0] || String(label), account: parts.slice(1).join(" · ") };
|
|
153
|
+
}
|
|
142
154
|
function expired(window) {
|
|
143
155
|
return window.expired || !!window.resetsAt && Date.parse(window.resetsAt) <= Date.now();
|
|
144
156
|
}
|
|
@@ -203,7 +215,7 @@ function bar(parent, window, width) {
|
|
|
203
215
|
function header(status) {
|
|
204
216
|
const row = widget.addStack(); row.centerAlignContent();
|
|
205
217
|
if (family !== "small") row.size = new Size(layout.width, layout.header);
|
|
206
|
-
text(row, "usage.",
|
|
218
|
+
text(row, "usage.", 12, colors.muted, "medium"); row.addSpacer();
|
|
207
219
|
text(row, status, 9, cached ? colors.warning : colors.muted);
|
|
208
220
|
}
|
|
209
221
|
// The parameter is read once: a subscription to show, a page and a direction.
|
|
@@ -225,17 +237,22 @@ function subscriptionGrid(parent, cards, sharedWindow) {
|
|
|
225
237
|
const tight = large ? dimensions.height < 345 : dimensions.height < 155;
|
|
226
238
|
// A cell that has several accounts of one vendor carries a longer name, so a
|
|
227
239
|
// narrow cell takes a smaller title font to keep the account readable.
|
|
240
|
+
// The account of a card needs a line of its own; the weekly limit needs
|
|
241
|
+
// one too. Where there is no room for them, they are left out.
|
|
228
242
|
const style = large
|
|
229
243
|
? dense
|
|
230
|
-
? { name: tight ?
|
|
231
|
-
: { name:
|
|
244
|
+
? { name: tight ? 11 : 12, value: tight ? 20 : 24, reset: 8, secondary: 8, account: 9, titleGap: 2, barGap: 3, resetGap: 3, rowGap: 8 }
|
|
245
|
+
: { name: 13, value: 34, reset: 10, secondary: 10, account: 9, titleGap: 4, barGap: 6, resetGap: 5, rowGap: 16 }
|
|
232
246
|
: dense
|
|
233
|
-
? { name: tight ?
|
|
234
|
-
: { name:
|
|
247
|
+
? { name: tight ? 10 : 11, value: tight ? 18 : 20, titleGap: 1, barGap: 1, resetGap: 3, rowGap: tight ? 4 : 6, account: 0, secondary: 0, reset: 9 }
|
|
248
|
+
: { name: 12, value: cellWidth < 67 ? 22 : 25, reset: cellWidth < 64 ? 9 : 10, titleGap: 3, barGap: 5, resetGap: 5, rowGap: 0, account: 9, secondary: 9 };
|
|
235
249
|
const grid = parent.addStack(); grid.layoutVertically(); grid.size = new Size(layout.width, 0);
|
|
236
250
|
// Every name of the page takes the size that fits the narrowest of them, so
|
|
237
251
|
// the cards keep one title size.
|
|
238
|
-
|
|
252
|
+
// The dense large family has no line of its own for the account, so it
|
|
253
|
+
// goes with the name of the vendor.
|
|
254
|
+
const titleText = card => large && dense ? card.label : splitLabel(card.label).vendor;
|
|
255
|
+
const names = cards.map(card => fitName(titleText(card), style.name, cellWidth));
|
|
239
256
|
const nameSize = names.length ? Math.min(...names.map(name => name.size)) : style.name;
|
|
240
257
|
for (let start = 0; start < cards.length; start += columns) {
|
|
241
258
|
if (start) grid.addSpacer(style.rowGap);
|
|
@@ -246,25 +263,54 @@ function subscriptionGrid(parent, cards, sharedWindow) {
|
|
|
246
263
|
const card = cards[start + index];
|
|
247
264
|
if (!card) continue; // Keep the final card aligned with the previous rows.
|
|
248
265
|
const primary = card.windows[0] || emptyWindow;
|
|
249
|
-
|
|
266
|
+
// The name of the vendor is the name of the card; the account of it goes
|
|
267
|
+
// under it when there is a line for it.
|
|
268
|
+
const parts = splitLabel(card.label);
|
|
269
|
+
const inline = large && dense && parts.account;
|
|
270
|
+
const accountSize = Math.max(7, Math.min(9, nameSize - 3));
|
|
271
|
+
const vendor = inline ? parts.vendor + " · " + parts.account : parts.vendor;
|
|
272
|
+
text(column, shortenName(vendor, inline ? accountSize : nameSize, cellWidth), inline ? accountSize : nameSize, colors.text, "medium");
|
|
273
|
+
if (parts.account && style.account && !inline) {
|
|
274
|
+
column.addSpacer(1);
|
|
275
|
+
text(column, shortenName(parts.account, accountSize, cellWidth), accountSize, colors.muted);
|
|
276
|
+
}
|
|
250
277
|
column.addSpacer(style.titleGap);
|
|
251
278
|
const valueRow = column.addStack(); valueRow.centerAlignContent();
|
|
279
|
+
// A narrow cell has no room for the whole word, and the footer of the
|
|
280
|
+
// widget says which way the numbers are read.
|
|
281
|
+
const word = large || cellWidth >= 78 ? direction() : (showUsed ? "用" : "剩");
|
|
282
|
+
text(valueRow, word, large ? 12 : 9, colors.muted);
|
|
283
|
+
valueRow.addSpacer(2);
|
|
252
284
|
text(valueRow, percent(primary), style.value, accent(primary), "medium");
|
|
253
285
|
if (!large && dense && !sharedWindow) {
|
|
254
286
|
valueRow.addSpacer(); text(valueRow, windowLabel(primary), 8, colors.muted);
|
|
255
287
|
}
|
|
256
288
|
column.addSpacer(style.barGap);
|
|
257
289
|
bar(column, primary, cellWidth);
|
|
258
|
-
|
|
290
|
+
const secondary = card.windows[1];
|
|
291
|
+
if (large) {
|
|
292
|
+
if (!dense) {
|
|
293
|
+
column.addSpacer(style.resetGap);
|
|
294
|
+
const label = (sharedWindow ? "" : windowLabel(primary) + " · ") + shortReset(primary);
|
|
295
|
+
const size = sharedWindow ? style.reset : Math.min(style.reset, cellWidth < 64 ? 8 : 9);
|
|
296
|
+
text(column, label, size, colors.muted);
|
|
297
|
+
}
|
|
298
|
+
// The second window is the weekly limit. The large family has a line
|
|
299
|
+
// of its own for it.
|
|
300
|
+
if (secondary) {
|
|
301
|
+
column.addSpacer(dense ? 2 : 3);
|
|
302
|
+
text(column, windowLabel(secondary) + (showUsed ? " 已用 " : " 剩余 ") + percent(secondary), style.secondary, accent(secondary));
|
|
303
|
+
}
|
|
304
|
+
} else if (!dense) {
|
|
305
|
+
// The medium family has one line for the reset and for the weekly
|
|
306
|
+
// limit; two lines do not fit above the bar of a cell.
|
|
259
307
|
column.addSpacer(style.resetGap);
|
|
260
|
-
const
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
column.addSpacer(dense ? 2 : 4);
|
|
267
|
-
text(column, windowLabel(secondary) + (showUsed ? " 已用 " : " 剩余 ") + percent(secondary), style.secondary, accent(secondary));
|
|
308
|
+
const parts = [];
|
|
309
|
+
if (!sharedWindow) parts.push(windowLabel(primary) + " · ");
|
|
310
|
+
parts.push(shortReset(primary));
|
|
311
|
+
if (secondary && style.secondary) parts.push(" · " + windowLabel(secondary) + " " + percent(secondary));
|
|
312
|
+
const size = Math.min(style.reset, cellWidth < 64 ? 8 : 9);
|
|
313
|
+
text(column, shortenName(parts.join(""), size, cellWidth), size, colors.muted);
|
|
268
314
|
}
|
|
269
315
|
}
|
|
270
316
|
}
|
|
@@ -288,15 +334,32 @@ if (!settings || !data || data.schemaVersion !== 1) {
|
|
|
288
334
|
// it is written: two accounts of one vendor may differ in their case only.
|
|
289
335
|
const exact = data.subscriptions.find(c => c.id === requested);
|
|
290
336
|
const same = data.subscriptions.filter(c => String(c.id).toLowerCase() === requested.toLowerCase());
|
|
291
|
-
|
|
292
|
-
|
|
337
|
+
// A vendor is matched before an account: an account of another vendor
|
|
338
|
+
// may carry the name of this one.
|
|
339
|
+
const byProvider = data.subscriptions.find(c => c.provider === requested.toLowerCase());
|
|
340
|
+
const byAccount = data.subscriptions.find(c => String(c.account || "").toLowerCase() === requested.toLowerCase());
|
|
341
|
+
const found = exact || (same.length === 1 ? same[0] : null) || byProvider || byAccount;
|
|
342
|
+
// The account of a card can be renamed; an old parameter then finds
|
|
343
|
+
// nothing. The first card is shown instead, and the note says so.
|
|
344
|
+
const card = found || data.subscriptions[0];
|
|
345
|
+
const missing = !found && Boolean(card);
|
|
293
346
|
widget.addSpacer(12);
|
|
294
347
|
const primary = card && card.windows[0] || emptyWindow;
|
|
295
348
|
const name = widget.addStack();
|
|
296
349
|
const title = fitName(card ? card.label : "暂无该套餐", 12, layout.width - 60);
|
|
297
|
-
text(name, title.label, title.size, colors.
|
|
298
|
-
name.addSpacer(4);
|
|
299
|
-
|
|
350
|
+
text(name, title.label, title.size, colors.text, "medium");
|
|
351
|
+
name.addSpacer(4);
|
|
352
|
+
if (missing) {
|
|
353
|
+
// The note is shortened to the room that is left, so the name of the
|
|
354
|
+
// card and the note never fight for the same line.
|
|
355
|
+
text(name, shortenName("没有找到 " + requested, 8, Math.max(60, layout.width * 0.42)), 8, colors.warning);
|
|
356
|
+
}
|
|
357
|
+
else text(name, windowLabel(primary), 9, colors.muted);
|
|
358
|
+
widget.addSpacer(3);
|
|
359
|
+
const value = widget.addStack(); value.centerAlignContent();
|
|
360
|
+
text(value, direction(), 13, colors.muted);
|
|
361
|
+
value.addSpacer(4);
|
|
362
|
+
text(value, percent(primary), 34, accent(primary), "medium");
|
|
300
363
|
widget.addSpacer(7); bar(widget, primary, 120); widget.addSpacer(7);
|
|
301
364
|
text(widget, reset(primary), 9, colors.muted);
|
|
302
365
|
if (card && card.windows[1]) {
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// Checks that every source file of the plugin parses.
|
|
2
|
+
// The browser and Scriptable files are ES modules (they use import or a top level await),
|
|
3
|
+
// so each one is checked through a copy with the .mjs extension: node --check then reads
|
|
4
|
+
// them as modules, on every platform the same way.
|
|
5
|
+
import { copyFileSync, mkdtempSync, readdirSync, statSync } from 'node:fs';
|
|
6
|
+
import { tmpdir } from 'node:os';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import { spawnSync } from 'node:child_process';
|
|
9
|
+
|
|
10
|
+
const roots = ['index.mjs', 'lib', 'test', 'ui', 'mobile', 'page', 'scriptable', 'scripts'];
|
|
11
|
+
const files = [];
|
|
12
|
+
|
|
13
|
+
const walk = (target) => {
|
|
14
|
+
const state = statSync(target);
|
|
15
|
+
if (state.isDirectory()) {
|
|
16
|
+
for (const entry of readdirSync(target)) walk(path.join(target, entry));
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
if (target.endsWith('.mjs') || target.endsWith('.js')) files.push(target);
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
for (const root of roots) walk(root);
|
|
23
|
+
|
|
24
|
+
const scratch = mkdtempSync(path.join(tmpdir(), 'magpie-syntax-'));
|
|
25
|
+
let broken = 0;
|
|
26
|
+
|
|
27
|
+
files.forEach((file, index) => {
|
|
28
|
+
const copy = path.join(scratch, `file-${index}.mjs`);
|
|
29
|
+
copyFileSync(file, copy);
|
|
30
|
+
const result = spawnSync(process.execPath, ['--check', copy], { encoding: 'utf8' });
|
|
31
|
+
if (result.status !== 0) {
|
|
32
|
+
broken += 1;
|
|
33
|
+
console.error(`syntax error in ${file}`);
|
|
34
|
+
console.error(result.stderr || result.stdout);
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
if (broken > 0) {
|
|
39
|
+
console.error(`${broken} file(s) did not parse`);
|
|
40
|
+
process.exit(1);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
console.log(`${files.length} files parsed`);
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// The tests are run by a small script of our own, so that the same command
|
|
2
|
+
// works the same way on every platform and on every version of Node.
|
|
3
|
+
//
|
|
4
|
+
// The shell of Windows does not expand the names of the files, and Node 20
|
|
5
|
+
// does not expand them either, so "node --test test/*.test.mjs" works on a
|
|
6
|
+
// Linux machine and fails on a Windows one. Here the names are read and
|
|
7
|
+
// handed over one by one, which every version understands.
|
|
8
|
+
import { readdir } from 'node:fs/promises';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
import { fileURLToPath } from 'node:url';
|
|
11
|
+
import { spawn } from 'node:child_process';
|
|
12
|
+
|
|
13
|
+
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
14
|
+
const folder = path.join(root, 'test');
|
|
15
|
+
|
|
16
|
+
const names = (await readdir(folder)).filter((name) => name.endsWith('.test.mjs')).sort();
|
|
17
|
+
if (!names.length) {
|
|
18
|
+
console.error('There is no test file in ' + folder);
|
|
19
|
+
process.exit(1);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const child = spawn(process.execPath, ['--test', ...names.map((name) => path.join('test', name))], {
|
|
23
|
+
cwd: root,
|
|
24
|
+
stdio: 'inherit',
|
|
25
|
+
});
|
|
26
|
+
child.on('exit', (code, signal) => {
|
|
27
|
+
process.exit(signal ? 1 : (code ?? 1));
|
|
28
|
+
});
|