magpie-page-token-widget 0.0.0-stage → 0.1.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.
- package/LICENSE +21 -0
- package/NOTICE +22 -0
- package/README.md +172 -2
- package/index.mjs +38 -0
- package/lib/collector.mjs +123 -0
- package/lib/forward.mjs +67 -0
- package/lib/normalize.mjs +526 -0
- package/lib/qr.mjs +22 -0
- package/lib/server.mjs +584 -0
- package/lib/state.mjs +57 -0
- package/lib/tailnet.mjs +164 -0
- package/lib/vendor/qrcode-generator.README.md +101 -0
- package/lib/vendor/qrcode-generator.cjs +2297 -0
- package/package.json +64 -4
- package/page/boot.css +59 -0
- package/page/boot.js +41 -0
- package/page/icon.svg +6 -0
- package/page/index.html +18 -0
- package/scriptable/SetupUsageWidget.js +58 -0
- package/scriptable/UsageWidget.js +343 -0
- package/ui/app.js +157 -0
- package/ui/index.html +58 -0
- package/ui/style.css +157 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 liuzq
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/NOTICE
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# Third-party notices
|
|
2
|
+
|
|
3
|
+
## Magpie
|
|
4
|
+
|
|
5
|
+
[Magpie](https://github.com/yetone/magpie), MIT, supplies the local observation APIs used at runtime. No Magpie source, account configuration or provider credentials are bundled. The product is independently implemented and is not an official Magpie client.
|
|
6
|
+
|
|
7
|
+
## ScriptableTokenWidgets
|
|
8
|
+
|
|
9
|
+
[liuzq1103/ScriptableTokenWidgets](https://github.com/liuzq1103/ScriptableTokenWidgets), MIT, copyright 2026 liuzq1103. The collector, the projection of the snapshot and the widget script of the phone are taken from that project and are changed for this plugin. The same licence and the same author apply.
|
|
10
|
+
|
|
11
|
+
## Reference projects
|
|
12
|
+
|
|
13
|
+
- [DaleXiao/ScriptableTokenWidgets](https://github.com/DaleXiao/ScriptableTokenWidgets): inspiration for the iPhone widget concept. No source files copied.
|
|
14
|
+
- [Hj714110-oss/ScriptableTokenTrackerV2](https://github.com/Hj714110-oss/ScriptableTokenTrackerV2): MIT, copyright 2026 Hj714110-oss. Reviewed for feature organization and Scriptable caching approaches. No source files copied.
|
|
15
|
+
|
|
16
|
+
## Bundled libraries
|
|
17
|
+
|
|
18
|
+
- [qrcode-generator](https://github.com/kazuhikoarase/qrcode-generator), MIT, copyright 2009 Kazuhiko Arase. The library is vendored at `lib/vendor/qrcode-generator.cjs` so that the plugin carries no dependency that has to be installed.
|
|
19
|
+
|
|
20
|
+
## Runtime applications
|
|
21
|
+
|
|
22
|
+
Scriptable, Tailscale and Magpie are separately installed applications with their own licences and terms. OpenAI, MiniMax and GLM names identify the subscriptions observed through Magpie. No proprietary logo assets or model lists are bundled.
|
package/README.md
CHANGED
|
@@ -1,3 +1,173 @@
|
|
|
1
|
-
#
|
|
1
|
+
# magpie-page-token-widget
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
把 iPhone 桌面的额度组件接进 Magpie:装上这个插件,扫一次码,手机上就能显示 Codex、MiniMax、GLM 各家套餐的剩余额度与真实 Token 用量。
|
|
4
|
+
|
|
5
|
+
装这个插件不需要再跑安装脚本、装 Node.js、建计划任务或开 Tailscale 端口转发。插件自己把服务跑起来,自己找到尾网地址,手机扫一次码就配对好了。
|
|
6
|
+
|
|
7
|
+
> 数据来自你自己的 Magpie,脚本里不含任何密钥。组件显示的仍是 Magpie 的口径。
|
|
8
|
+
|
|
9
|
+

|
|
10
|
+
|
|
11
|
+
## 它做什么
|
|
12
|
+
|
|
13
|
+
- 插件页给出一个二维码。用 iPhone 的相机扫一下,Safari 打开配对页,下载组件脚本,用 Scriptable 打开它,长按桌面加组件就行了。
|
|
14
|
+
- 组件里显示每家订阅的剩余额度、5 小时与周额度窗口的百分比、重置倒计时、今日 Token、七日趋势和各家排行。参数可以选订阅(`codex`、`minimax`、`glm`,或 `codex@账号名`),也可以把百分比换成已用(`|used`)。
|
|
15
|
+
- 插件页还能改中号和大号组件里显示哪些订阅,能看到 Tailscale 的状态和同一尾网里的 iPhone。
|
|
16
|
+
- 勾一个厂商=这个厂商的所有账号都显示;只勾一个账号=只有这个账号。勾选框显示的就是手机上会出现的卡片。
|
|
17
|
+
- 服务只读 Magpie 的本地接口,只在本机打开,手机经 Tailscale 的端口转发进来,令牌存放在 Magpie 自己的目录里。
|
|
18
|
+
|
|
19
|
+
## 需要什么
|
|
20
|
+
|
|
21
|
+
- Magpie(桌面版,插件宿主是 Bun)。
|
|
22
|
+
- Tailscale:电脑和 iPhone 都在同一个尾网里,插件靠它找到地址。
|
|
23
|
+
- iPhone 上的 [Scriptable](https://scriptable.app/)。
|
|
24
|
+
|
|
25
|
+
## 安装
|
|
26
|
+
|
|
27
|
+
在 Magpie 里打开插件页,或者用命令行:
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
magpie plugin add magpie-page-token-widget
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
从源码装:
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
magpie plugin add ./magpie-page-token-widget
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## 配对
|
|
40
|
+
|
|
41
|
+
1. 在 Magpie 里打开「插件 → iPhone 额度组件」。
|
|
42
|
+
2. 插件页会显示 Tailscale 的状态。电脑和 iPhone 都在同一个尾网里时,这里会显示尾网地址和一张二维码。
|
|
43
|
+
3. iPhone 用相机扫码,在 Safari 里打开配对页,点「下载组件脚本」。
|
|
44
|
+
4. 在「文件」App 里打开刚下载的 `UsageWidget.js`,选「共享 → 用 Scriptable 打开」。
|
|
45
|
+
5. 长按 iPhone 桌面空白处,添加 Scriptable 小组件,选择 `UsageWidget`。
|
|
46
|
+
|
|
47
|
+
配对码只能用一次。手机装好组件后码就失效了;再生成一个就能装第二台。脚本里只有一个一次性配对码,第一次运行时换成自己的令牌,存进 Scriptable 的钥匙串。
|
|
48
|
+
|
|
49
|
+
不想扫码也行:插件页上有「只读令牌」,把它和「服务地址」填进 `scriptable/SetupUsageWidget.js`(在 Scriptable 里新建一个脚本粘贴运行)也行。
|
|
50
|
+
|
|
51
|
+
## 组件参数
|
|
52
|
+
|
|
53
|
+
| 尺寸 | 内容 | Widget Parameter |
|
|
54
|
+
|---|---|---|
|
|
55
|
+
| 小号 | 单家额度、重置倒计时、今日 Token | `codex`(默认)、`minimax`、`glm`;某账号写作 `codex@账号名`;加 `|used` 改显已用 |
|
|
56
|
+
| 中号 | 每页最多 6 个;1–4 个并排,5–6 个两行三列;今日 Token | 留空;`page=2` 查看第二页 |
|
|
57
|
+
| 大号 | 每页最多 6 个;两列卡片,显示主额度、重置时间及第二窗口百分比;今日与近七日 Token | 留空;`page=2` 查看第二页 |
|
|
58
|
+
|
|
59
|
+
## 生命周期
|
|
60
|
+
|
|
61
|
+
组件随 Magpie 运行。关掉 Magpie 之后,手机上显示的是最后一次拿到的结果,重新打开 Magpie 就会更新。这和原来那个用计划任务常驻的服务不同,是"插件"这个形态的取舍。
|
|
62
|
+
|
|
63
|
+
## 端口
|
|
64
|
+
|
|
65
|
+
服务默认听 8790。端口上冲突时换一个:
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
magpie plugin options magpie-page-token-widget '{"port":8890}'
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
改完要重启 Magpie。插件会在尾网里给这个端口建一条转发(`tailscale serve --tcp`),手机从 100.x 地址连进来;端口上已经有别人的转发时,插件不会动它。
|
|
72
|
+
|
|
73
|
+
转发指向的是手机那一侧的端口(默认比服务端口大 1,即 8791),控制台留在 127.0.0.1:8790。尾网里的请求落在这条转发上,不会被当成本机的请求,所以手机看不到控制台,也拿不到令牌。
|
|
74
|
+
|
|
75
|
+
`tailscale serve --bg --tcp=8790` 是 Tailscale 里的长期设置:它留在那儿,插件被移除或崩溃都不会自己消失,只有插件正常卸载时才会撤掉。想知道现在有哪些转发,看 `tailscale serve status`。
|
|
76
|
+
|
|
77
|
+
## 卸载
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
magpie plugin rm magpie-page-token-widget
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
插件被移除时,它建的转发会一并撤掉。
|
|
84
|
+
|
|
85
|
+
## 隐私
|
|
86
|
+
|
|
87
|
+
- 服务只读 Magpie 的本地接口(`127.0.0.1:3425`),不写 Magpie 的配置,不碰登录信息。
|
|
88
|
+
- 令牌是本机随机生成的,只读,存在 Magpie 的配置目录里;只有本机的插件页和拿着配对码的手机能拿到。
|
|
89
|
+
- 服务只在本机(127.0.0.1)上监听,手机是经 Tailscale 的转发进来的,没有公网入口,也不需要防火墙规则。
|
|
90
|
+
|
|
91
|
+
## 开发
|
|
92
|
+
|
|
93
|
+
```bash
|
|
94
|
+
node --test test/service.test.mjs
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
`lib/collector.mjs` 和 `lib/normalize.mjs` 来自 [ScriptableTokenWidgets](https://github.com/liuzq1103/ScriptableTokenWidgets),`lib/vendor/qrcode-generator.cjs` 是 [qrcode-generator](https://github.com/kazuhikoarase/qrcode-generator)。详见 [NOTICE](NOTICE)。
|
|
98
|
+
|
|
99
|
+
---
|
|
100
|
+
|
|
101
|
+
# magpie-page-token-widget (English)
|
|
102
|
+
|
|
103
|
+
An iPhone home screen widget for Magpie, brought in as a plugin: install the plugin, scan a code, and the widget shows the remaining quota and the real token usage of Codex, MiniMax and GLM.
|
|
104
|
+
|
|
105
|
+
Installing it needs no install script, no Node.js, no scheduled task and no Tailscale port forwarding. The plugin runs the service itself, finds the tailnet address itself, and the phone pairs by scanning a code once.
|
|
106
|
+
|
|
107
|
+
## Requirements
|
|
108
|
+
|
|
109
|
+
- Magpie on the desktop (the plugin host is Bun).
|
|
110
|
+
- Tailscale, with the computer and the iPhone in the same tailnet.
|
|
111
|
+
- [Scriptable](https://scriptable.app/) on the iPhone.
|
|
112
|
+
|
|
113
|
+
## Install
|
|
114
|
+
|
|
115
|
+
```bash
|
|
116
|
+
magpie plugin add magpie-page-token-widget
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
From a checkout: `magpie plugin add ./magpie-page-token-widget`.
|
|
120
|
+
|
|
121
|
+
## Pair
|
|
122
|
+
|
|
123
|
+
1. Open the plugin page in Magpie.
|
|
124
|
+
2. The page shows the state of Tailscale. When the computer and the iPhone share a tailnet it shows the tailnet address and a code to scan.
|
|
125
|
+
3. Scan the code with the camera of the iPhone, open the page in Safari and download the widget script.
|
|
126
|
+
4. Open the downloaded `UsageWidget.js` in the Files app and choose "Share → Open with Scriptable".
|
|
127
|
+
5. Long press the home screen, add the Scriptable widget and pick `UsageWidget`.
|
|
128
|
+
|
|
129
|
+
The pairing code works once. The script carries only that one-time code; on its first run it exchanges the code for a token of its own and keeps it in the keychain of Scriptable.
|
|
130
|
+
|
|
131
|
+
## Widget parameter
|
|
132
|
+
|
|
133
|
+
| Size | Shows | Widget Parameter |
|
|
134
|
+
|---|---|---|
|
|
135
|
+
| Small | one quota, the reset countdown, today's tokens | `codex` (default), `minimax`, `glm`; one account is written `codex@<account>`; `\|used` shows what is used |
|
|
136
|
+
| Medium | up to 6 per page; today's tokens | empty; `page=2` for the second page |
|
|
137
|
+
| Large | up to 6 per page; two columns with the quota, the reset and the second window; today's and the last seven days | empty; `page=2` for the second page |
|
|
138
|
+
|
|
139
|
+
## Lifetime
|
|
140
|
+
|
|
141
|
+
The service runs as long as Magpie runs. With Magpie closed, the phone keeps the last result it received.
|
|
142
|
+
|
|
143
|
+
## Port
|
|
144
|
+
|
|
145
|
+
The service listens on 8790 by default. Pick another one when the port is taken:
|
|
146
|
+
|
|
147
|
+
```bash
|
|
148
|
+
magpie plugin options magpie-page-token-widget '{"port":8890}'
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
Restart Magpie afterwards. The plugin publishes the port in the tailnet with a forwarding of Tailscale (`tailscale serve --tcp`), so the phone reaches it on the 100.x address. A forwarding of another service on that port is never touched.
|
|
152
|
+
|
|
153
|
+
The forwarding points at the phone side of the service (by default one port above the service port, so 8791), while the console stays on 127.0.0.1:8790. A request that comes in through the tailnet lands on that phone port and is never taken for a request of this computer, so the phone cannot read the console or the token.
|
|
154
|
+
|
|
155
|
+
`tailscale serve --bg --tcp=8790` is a standing setting of Tailscale: it stays there when the plugin is removed or crashes, and only a clean uninstall takes it away. `tailscale serve status` shows what is published right now.
|
|
156
|
+
|
|
157
|
+
## Uninstall
|
|
158
|
+
|
|
159
|
+
```bash
|
|
160
|
+
magpie plugin rm magpie-page-token-widget
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
The forwarding of the plugin is taken away with it.
|
|
164
|
+
|
|
165
|
+
## Privacy
|
|
166
|
+
|
|
167
|
+
The service only reads the local API of Magpie (`127.0.0.1:3425`). The token is random, read only and stays in the directory of Magpie. The service listens on this computer only (127.0.0.1); the phone comes in through the forwarding of Tailscale. There is no public entrance and no firewall rule is needed.
|
|
168
|
+
|
|
169
|
+
## Development
|
|
170
|
+
|
|
171
|
+
```bash
|
|
172
|
+
node --test test/service.test.mjs
|
|
173
|
+
```
|
package/index.mjs
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { fileURLToPath } from 'node:url';
|
|
3
|
+
import { startServer } from './lib/server.mjs';
|
|
4
|
+
|
|
5
|
+
const root = path.dirname(fileURLToPath(import.meta.url));
|
|
6
|
+
|
|
7
|
+
let running;
|
|
8
|
+
let current;
|
|
9
|
+
|
|
10
|
+
// The host calls this again when the options change, and calls it once when
|
|
11
|
+
// the plugin is loaded. The service is restarted whenever the hand-over
|
|
12
|
+
// changes, so a new port or a new gateway takes effect.
|
|
13
|
+
export default {
|
|
14
|
+
id: 'token-widget',
|
|
15
|
+
async server(input, options) {
|
|
16
|
+
if (!input?.directory) throw new Error('The Magpie plugin host is required.');
|
|
17
|
+
options ||= {};
|
|
18
|
+
const wanted = JSON.stringify([input.directory, options]);
|
|
19
|
+
if (wanted !== current) {
|
|
20
|
+
const previous = running;
|
|
21
|
+
current = wanted;
|
|
22
|
+
running = (async () => {
|
|
23
|
+
await (await previous?.catch(() => null))?.close();
|
|
24
|
+
return startServer({
|
|
25
|
+
root,
|
|
26
|
+
dataDir: path.join(input.directory, 'token-widget'),
|
|
27
|
+
magpieDir: input.directory,
|
|
28
|
+
options,
|
|
29
|
+
});
|
|
30
|
+
})();
|
|
31
|
+
running.catch(() => {
|
|
32
|
+
if (current === wanted) current = undefined;
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
await running;
|
|
36
|
+
return {};
|
|
37
|
+
},
|
|
38
|
+
};
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { readFile, writeFile, mkdir, rename } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { resolveQuotas, normalizeUsage, project, shanghaiDay, sanitizeCachedLabels, providerFamily, accountKey, number } from './normalize.mjs';
|
|
4
|
+
|
|
5
|
+
export class Collector {
|
|
6
|
+
constructor({ upstream, magpieDir, snapshotFile, fetcher = fetch, clock = Date.now, display = {} }) {
|
|
7
|
+
Object.assign(this, { upstream, magpieDir, snapshotFile, fetcher, clock });
|
|
8
|
+
this.display = display;
|
|
9
|
+
this.snapshot = { version: 1, parts: {}, failures: {} };
|
|
10
|
+
this.pending = null;
|
|
11
|
+
// The address of every account, kept in memory only: it is never written
|
|
12
|
+
// to the snapshot and never leaves this machine.
|
|
13
|
+
this.addresses = new Map();
|
|
14
|
+
// The last reading of every account, kept in memory only as well.
|
|
15
|
+
this.usage = new Map();
|
|
16
|
+
}
|
|
17
|
+
async load() {
|
|
18
|
+
try {
|
|
19
|
+
const saved = JSON.parse(await readFile(this.snapshotFile, 'utf8'));
|
|
20
|
+
if (saved.version === 1 && saved.parts && saved.failures) {
|
|
21
|
+
for (const key of Object.keys(saved.parts)) {
|
|
22
|
+
saved.parts[key].data = sanitizeCachedLabels(saved.parts[key].data, key);
|
|
23
|
+
saved.failures[key] = true; // Await live revalidation after every process restart.
|
|
24
|
+
}
|
|
25
|
+
this.snapshot = saved;
|
|
26
|
+
}
|
|
27
|
+
} catch { /* A new or damaged cache must not prevent startup. */ }
|
|
28
|
+
}
|
|
29
|
+
async request(endpoint, timeout = 15_000) {
|
|
30
|
+
const result = await this.fetcher(new URL(endpoint, this.upstream), {
|
|
31
|
+
signal: AbortSignal.timeout(timeout), headers: { Accept: 'application/json' }, redirect: 'error',
|
|
32
|
+
});
|
|
33
|
+
if (!result.ok) throw new Error('Magpie unavailable');
|
|
34
|
+
return result.json();
|
|
35
|
+
}
|
|
36
|
+
async quotaCache() {
|
|
37
|
+
try { return JSON.parse(await readFile(path.join(this.magpieDir, 'quotas.json'), 'utf8')); }
|
|
38
|
+
catch { return {}; }
|
|
39
|
+
}
|
|
40
|
+
async quotas() {
|
|
41
|
+
const [response, cache] = await Promise.allSettled([
|
|
42
|
+
this.request('/v1/magpie/quotas?kept=1'), this.quotaCache(),
|
|
43
|
+
]);
|
|
44
|
+
const raw = response.status === 'fulfilled' ? response.value : null;
|
|
45
|
+
const cards = resolveQuotas(raw, cache.status === 'fulfilled' ? cache.value : {}, this.snapshot.parts.quotas?.data || []);
|
|
46
|
+
await this.accountUsage(raw, cards);
|
|
47
|
+
return cards;
|
|
48
|
+
}
|
|
49
|
+
// An account's own usage is asked for by the address Magpie reports for it.
|
|
50
|
+
// The address goes into the query of the local gateway and only the totals
|
|
51
|
+
// come back, so it never leaves this machine and is never stored.
|
|
52
|
+
async accountUsage(raw, cards) {
|
|
53
|
+
const fresh = new Map();
|
|
54
|
+
for (const card of raw?.data || []) {
|
|
55
|
+
const family = providerFamily(card?.provider);
|
|
56
|
+
const address = typeof (card?.user ?? card?.account) === 'string' ? (card.user ?? card.account).trim() : '';
|
|
57
|
+
if (!family || !address.includes('@')) continue;
|
|
58
|
+
// The key of the account is the one of its card, so a query can never
|
|
59
|
+
// reach the reading of another account.
|
|
60
|
+
const key = accountKey(family, address);
|
|
61
|
+
if (!key) continue;
|
|
62
|
+
const known = fresh.get(key);
|
|
63
|
+
// Two accounts of one vendor that share the same name cannot be told
|
|
64
|
+
// apart: their usage stays unknown instead of being taken from the wrong
|
|
65
|
+
// one.
|
|
66
|
+
if (known === null) continue;
|
|
67
|
+
if (known && known !== address) { fresh.set(key, null); continue; }
|
|
68
|
+
fresh.set(key, address);
|
|
69
|
+
}
|
|
70
|
+
// The addresses of the last successful reading are kept in memory only, so
|
|
71
|
+
// the usage of an account survives a quota outage. They are never written
|
|
72
|
+
// to the snapshot.
|
|
73
|
+
if (fresh.size) this.addresses = new Map([...this.addresses, ...fresh]);
|
|
74
|
+
await Promise.all(cards.filter(card => card.account).map(async card => {
|
|
75
|
+
const address = this.addresses.get(card.id);
|
|
76
|
+
if (!address) return;
|
|
77
|
+
try {
|
|
78
|
+
// A short timeout: the reading of the quotas must not wait long for a
|
|
79
|
+
// usage query that only fills in one number.
|
|
80
|
+
const data = await this.request('/v1/magpie/usage/requests?period=today&limit=1&account=' + encodeURIComponent(address), 5000);
|
|
81
|
+
if (!['input', 'output', 'cache_read', 'cache_write', 'calls'].every(key =>
|
|
82
|
+
typeof data[key] === 'number' && Number.isFinite(data[key]))) throw new Error('Not a usage answer');
|
|
83
|
+
const total = number(data.input) + number(data.output) + number(data.cache_read) + number(data.cache_write);
|
|
84
|
+
const usage = { today: { totalTokens: total, requests: number(data.calls) } };
|
|
85
|
+
card.accountUsage = usage;
|
|
86
|
+
this.usage.set(card.id, usage);
|
|
87
|
+
} catch {
|
|
88
|
+
// The last reading of this account is kept: a number that was read a
|
|
89
|
+
// minute ago is better than no number at all.
|
|
90
|
+
card.accountUsage = card.accountUsage || this.usage.get(card.id) || null;
|
|
91
|
+
}
|
|
92
|
+
}));
|
|
93
|
+
}
|
|
94
|
+
refresh() {
|
|
95
|
+
if (this.pending) return this.pending;
|
|
96
|
+
this.pending = this.collect().finally(() => { this.pending = null; });
|
|
97
|
+
return this.pending;
|
|
98
|
+
}
|
|
99
|
+
async collect() {
|
|
100
|
+
const jobs = {
|
|
101
|
+
quotas: () => this.quotas(),
|
|
102
|
+
today: async () => normalizeUsage(await this.request('/v1/magpie/usage/requests?period=today&limit=1')),
|
|
103
|
+
week: async () => normalizeUsage(await this.request('/v1/magpie/usage/requests?period=7d&limit=1')),
|
|
104
|
+
};
|
|
105
|
+
await Promise.all(Object.entries(jobs).map(async ([key, job]) => {
|
|
106
|
+
try {
|
|
107
|
+
const periodDay = shanghaiDay(this.clock());
|
|
108
|
+
const data = await job();
|
|
109
|
+
this.snapshot.parts[key] = { observedAt: new Date(this.clock()).toISOString(), periodDay, data };
|
|
110
|
+
if (key === 'quotas' && data.every(card => card.stale)) this.snapshot.failures[key] = true;
|
|
111
|
+
else delete this.snapshot.failures[key];
|
|
112
|
+
} catch { this.snapshot.failures[key] = true; }
|
|
113
|
+
}));
|
|
114
|
+
try {
|
|
115
|
+
await mkdir(path.dirname(this.snapshotFile), { recursive: true });
|
|
116
|
+
await writeFile(this.snapshotFile + '.tmp', JSON.stringify(this.snapshot), { mode: 0o600 });
|
|
117
|
+
await rename(this.snapshotFile + '.tmp', this.snapshotFile);
|
|
118
|
+
} catch { console.warn('Snapshot could not be saved; serving the in-memory snapshot.'); }
|
|
119
|
+
}
|
|
120
|
+
overview() { return project(this.snapshot, this.clock(), this.display); }
|
|
121
|
+
// The console shows every account, not only the ones on the home screen.
|
|
122
|
+
overviewWith(display) { return project(this.snapshot, this.clock(), display); }
|
|
123
|
+
}
|
package/lib/forward.mjs
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { call } from './tailnet.mjs';
|
|
2
|
+
|
|
3
|
+
// The phone reaches the service through a forwarding of Tailscale: the port
|
|
4
|
+
// of the service is published inside the tailnet and points at this computer.
|
|
5
|
+
// The forwarding of another service on the same port is never touched.
|
|
6
|
+
//
|
|
7
|
+
// The published port and the port it points at are not the same one. The
|
|
8
|
+
// console of this computer keeps the published port on the loopback address,
|
|
9
|
+
// and the phone side gets a port of its own, so a request that arrives
|
|
10
|
+
// through the tailnet can never be mistaken for one from this computer.
|
|
11
|
+
export const targetFor = port => '127.0.0.1:' + port;
|
|
12
|
+
|
|
13
|
+
export async function serveState() {
|
|
14
|
+
const result = await call(['serve', 'status', '--json']);
|
|
15
|
+
if (!result.ok) return { ok: false, error: result.error };
|
|
16
|
+
try {
|
|
17
|
+
return { ok: true, data: JSON.parse(result.stdout) };
|
|
18
|
+
} catch {
|
|
19
|
+
return { ok: false, error: 'tailscale serve status did not answer with JSON.' };
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// The status of Tailscale lists the published ports under "TCP".
|
|
24
|
+
export const publishedFor = (status, port) => status?.data?.TCP?.[String(port)] || null;
|
|
25
|
+
|
|
26
|
+
export async function planForward(publishedPort, phonePort) {
|
|
27
|
+
const status = await serveState();
|
|
28
|
+
if (!status.ok) return { ok: false, error: status.error };
|
|
29
|
+
const existing = publishedFor(status, publishedPort);
|
|
30
|
+
const target = targetFor(phonePort);
|
|
31
|
+
// An older version of this plugin pointed the published port at the console
|
|
32
|
+
// of this computer. That forwarding is ours as well, and it is moved to the
|
|
33
|
+
// phone side.
|
|
34
|
+
const legacy = targetFor(publishedPort);
|
|
35
|
+
if (existing && existing.TCPForward !== target && existing.TCPForward !== legacy) {
|
|
36
|
+
return { ok: false, error: `端口 ${publishedPort} 上已经有别的转发,没有动它。` };
|
|
37
|
+
}
|
|
38
|
+
const commands = existing && existing.TCPForward === target
|
|
39
|
+
? []
|
|
40
|
+
: [['serve', '--bg', '--tcp=' + publishedPort, 'tcp://' + target]];
|
|
41
|
+
return { ok: true, commands, ready: true };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export async function applyForward(publishedPort, phonePort) {
|
|
45
|
+
const plan = await planForward(publishedPort, phonePort);
|
|
46
|
+
if (!plan.ok) return plan;
|
|
47
|
+
for (const command of plan.commands) {
|
|
48
|
+
const result = await call(command);
|
|
49
|
+
if (!result.ok) return { ok: false, error: result.error };
|
|
50
|
+
}
|
|
51
|
+
return { ok: true };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Only a forwarding that points at this service is removed.
|
|
55
|
+
export async function removeForward(publishedPort, phonePort) {
|
|
56
|
+
const status = await serveState();
|
|
57
|
+
const existing = status.ok ? publishedFor(status, publishedPort) : null;
|
|
58
|
+
const ours = existing && (
|
|
59
|
+
existing.TCPForward === targetFor(phonePort) ||
|
|
60
|
+
existing.TCPForward === targetFor(publishedPort)
|
|
61
|
+
);
|
|
62
|
+
if (!ours) {
|
|
63
|
+
return { ok: false, error: '没有找到属于这个插件的转发。' };
|
|
64
|
+
}
|
|
65
|
+
const result = await call(['serve', '--tcp=' + publishedPort, 'off']);
|
|
66
|
+
return result.ok ? { ok: true } : { ok: false, error: result.error };
|
|
67
|
+
}
|