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/README.md CHANGED
@@ -1,8 +1,10 @@
1
1
  # magpie-page-token-widget
2
2
 
3
+ > [English](README.en.md) · 中文 · 当前版本 0.3.3(变更见 [CHANGELOG.md](CHANGELOG.md))
4
+
3
5
  把 iPhone 桌面的额度组件接进 Magpie:装上这个插件,扫一次码,手机上就能显示 Codex、MiniMax、GLM 各家套餐的剩余额度与真实 Token 用量。
4
6
 
5
- 装这个插件不需要再跑安装脚本、装 Node.js、建计划任务或开 Tailscale 端口转发。插件自己把服务跑起来,自己找到尾网地址,手机扫一次码就配对好了。
7
+ 装这个插件不需要再跑安装脚本、装 Node.js、建计划任务,也不需要你自己开端口转发:默认是 Tailscale 模式,插件自己把服务跑起来、自己建好尾网里的转发,手机扫一次码就配对好了。
6
8
 
7
9
  > 数据来自你自己的 Magpie,脚本里不含任何密钥。组件显示的仍是 Magpie 的口径。
8
10
 
@@ -12,15 +14,17 @@
12
14
 
13
15
  - 插件页给出一个二维码。用 iPhone 的相机扫一下,Safari 打开配对页,下载组件脚本,用 Scriptable 打开它,长按桌面加组件就行了。
14
16
  - 组件里显示每家订阅的剩余额度、5 小时与周额度窗口的百分比、重置倒计时、今日 Token、七日趋势和各家排行。参数可以选订阅(`codex`、`minimax`、`glm`,或 `codex@账号名`),也可以把百分比换成已用(`|used`)。
15
- - 插件页是一个配置中心,分五块:**订阅展示**(勾选手机上要出现的卡片)、**数据更新**(多久读一次)、**网络连接**(尾网状态、连接路径、本机网卡)、**设备管理**(已配对的手机,可单独撤销)、**高级设置**(端口、网关等少改的参数)。
17
+ - 插件页是一个配置中心,分五块:**订阅展示**(勾选手机上要出现的卡片)、**数据更新**(多久读一次)、**网络连接**(尾网状态、连接路径、本机网卡)、**设备管理**(已配对的手机,可单独撤销)、**高级设置**(端口和网关这些看一眼就行的参数)。
16
18
  - 点一下手机上的组件,Safari 会打开手机版用量页:每家订阅的剩余额度、今日与近七日的 Token、模型/厂商/客户端排行,和电脑上看到的是同一份数据。
17
- - 服务只读 Magpie 的本地接口,只在本机打开,手机经 Tailscale 的端口转发进来,令牌存放在 Magpie 自己的目录里。
19
+ - 服务只读 Magpie 的本地接口。默认(本机监听 + Tailscale 转发)时,服务只在本机打开,手机经尾网的转发进来,令牌存放在 Magpie 自己的目录里。
18
20
 
19
21
  ## 需要什么
20
22
 
21
23
  - Magpie(桌面版,插件宿主是 Bun)。
22
- - Tailscale:电脑和 iPhone 都在同一个尾网里,插件靠它找到地址。
23
24
  - iPhone 上的 [Scriptable](https://scriptable.app/)。
25
+ - Tailscale:**只有默认模式需要**。电脑和 iPhone 都在同一个尾网里,插件靠它找到地址、自己建转发。改用自己的服务器或可信内网地址时,Tailscale 不再是必需品。
26
+
27
+ 三种连接方式(默认 Tailscale、自定义外部地址、局域网直连)见下面的「三种连接方式」。
24
28
 
25
29
  ## 安装
26
30
 
@@ -44,7 +48,9 @@ magpie plugin add ./magpie-page-token-widget
44
48
  4. 在「文件」App 里打开刚下载的 `UsageWidget.js`,选「共享 → 用 Scriptable 打开」。
45
49
  5. 长按 iPhone 桌面空白处,添加 Scriptable 小组件,选择 `UsageWidget`。
46
50
 
47
- 配对码只能用一次,而且只活十分钟:十分钟之后它自己就废了,重新生成一个就行;用掉之后旧码立刻作废。每配一台手机,它都会拿到属于自己的那枚只读令牌,服务端只留这枚令牌的哈希,明文只在配对的那一次出现,之后再也拿不出来。
51
+ 配对码只能用一次,而且只活十分钟:十分钟之后它就废了,插件页会自己换一个新的出来(不用手点),所以第一次配对不用先去点「重新生成配对码」;用掉之后旧码立刻作废。**每配一台手机,它都会拿到属于自己的那枚只读令牌**,服务端只留这枚令牌的哈希,明文只在配对的那一次出现,之后再也拿不出来。一台手机作废,只停这一台。
52
+
53
+ 0.1 时代那枚给所有手机共用的共享令牌已经没有了:升级之后它立刻作废,手里还拿着旧令牌的手机要重新扫一次码配对,重新拿到自己的令牌。
48
54
 
49
55
  猜配对码是没有便宜的:同一个配对码连续猜错十次就锁五分钟,锁的是这个码本身,不是那台机器。原因是转发过来的手机在服务器看来都是本机的回环地址,按地址锁的话,尾网上任何一个人猜错十次就能把所有人的配对都关上。请求头里的 `X-Forwarded-For` 谁都能写,服务器不看它。
50
56
 
@@ -52,11 +58,7 @@ magpie plugin add ./magpie-page-token-widget
52
58
 
53
59
  手机脚本把令牌存在 Scriptable 的钥匙串里,同一台 iPhone 上的三个组件共用这一枚令牌。
54
60
 
55
- 不想扫码也行:插件页的「设备管理」里可以给某台手机单独发一枚令牌,把它和「服务地址」填进 `scriptable/SetupUsageWidget.js`(在 Scriptable 里新建一个脚本粘贴运行)也行。
56
-
57
- ### 旧手机怎么办
58
-
59
- 升级到 0.2 之后,0.1 时代配好的手机**不会失效**:老版本那枚共享令牌会被迁成一个名叫「旧版共享令牌」的设备授权,手机照常用它读数。只有在「设备管理」里把这一条撤销,0.1 时代的手机才会一起失效。新配的手机拿到的都是自己的令牌,和别人无关,撤销哪一台就只停哪一台。
61
+ 不想扫码也行:插件页的「设备管理」里可以给某台手机单独发一枚令牌,把它和「服务地址」填进 `scriptable/SetupUsageWidget.js`(在 Scriptable 里新建一个脚本粘贴运行)也行。服务地址可以填 Tailscale 地址(`http://100.x.x.x:8791`)、自己内网里的地址(`http://192.168.1.50:8791`),也可以填你自己配好的 HTTPS 域名(`https://my-server`)。**公网上的明文 HTTP 地址会被拒绝**,理由和规则见下面「三种连接方式」。
60
62
 
61
63
  ## 设备管理
62
64
 
@@ -93,217 +95,149 @@ magpie plugin add ./magpie-page-token-widget
93
95
 
94
96
  本机的网卡也在这一块里列着:网卡名、IPv4、IPv6、子网前缀、是 Tailscale 网卡还是 Wi-Fi/有线/虚拟网卡。**这些地址只给电脑上的你看,手机接口拿不到。** 列出它们是为了排障:手机连不上的时候,先看看这台电脑在局域网里的地址,再看尾网地址是不是变了。
95
97
 
96
- **默认仍然走 Tailscale**,不会因为这台电脑有个 192.168.x.x 就让手机改走局域网直连。原因是:局域网直连要处理明文 HTTP(或另配 HTTPS)、DHCP 换地址、iOS 的本地网络权限等等一串事情,做不好就是新的安全口子;而且尾网地址在手机和电脑上都是稳的。局域网直连这次没有实现,只做了地址发现。
98
+ **默认仍然走 Tailscale**,不会因为这台电脑有个 192.168.x.x 就让手机改走局域网直连。原因是:局域网直连要处理明文 HTTP(或另配 HTTPS)、DHCP 换地址、iOS 的本地网络权限等等一串事情,做不好就是新的安全口子;而且尾网地址在手机和电脑上都是稳的。
97
99
 
98
- ## 手机上的用量页
100
+ 想走自己的路,请看下面那一节:那是自己选、自己填、自己负责的,不是自动发生的。
99
101
 
100
- 点一下桌面上的组件,Safari 会打开手机版的用量页:每家订阅的剩余额度、今日与近七日的 Token、模型/厂商/客户端排行,和电脑上看到的是同一份数据。
102
+ ## 三种连接方式
101
103
 
102
- 页面用组件里的那个只读令牌打开,令牌只走地址栏的 `#token=` 一段,页面打开后立刻把它抹掉,之后由 Scriptable 自己记着。手机版页面只在尾网这一侧提供,电脑上的控制台仍然只在本机。
104
+ 这是三件事,看的时候别混。控制台「网络连接」里随时能看出当前用的是哪一种。
103
105
 
104
- ## 组件参数
106
+ ### 一、默认:Tailscale
105
107
 
106
- | 尺寸 | 内容 | Widget Parameter |
107
- |---|---|---|
108
- | 小号 | 单家额度、重置倒计时、今日 Token | `codex`(默认)、`minimax`、`glm`;某账号写作 `codex@账号名`;加 `|used` 改显已用 |
109
- | 中号 | 每页最多 6 个;1–4 个并排,5–6 个两行三列;今日 Token | 留空;`page=2` 查看第二页 |
110
- | 大号 | 每页最多 6 个;两列卡片,显示主额度、重置时间及第二窗口百分比;今日与近七日 Token | 留空;`page=2` 查看第二页 |
108
+ - 装好就是这样,什么都不用配。插件找到本机的尾网地址,自己在尾网里建一条转发(`tailscale serve --tcp`),手机从 100.x 的地址连进来。
109
+ - 控制台留在 127.0.0.1,只有手机那一侧在尾网上。尾网里的请求不会被当成本机的请求,所以手机看不到控制台,也拿不到控制台的令牌。
110
+ - 端口上已经有别人的转发时,插件不会动它,控制台会说明转发没建起来。
111
+ - 手机连不上时先看三件事:iPhone 在不在这个尾网里、Tailscale 是不是登录着、两端之间有没有直连路径。
112
+ - 走哪条路看「本机连接路径」:**直连(Direct)**是两端直接加密通信,通常是同一个局域网里自动找到对方地址,最快;**经由尾网中继设备(Peer Relay)**是借了尾网里第三台设备转发;**经由 Tailscale 中继服务器(DERP)**是既连不上也没有中继设备时的兜底,最慢但最稳。这三条路都不保证一定通——DERP 是最后的退路,不是「一定能连上」的保证。
111
113
 
112
- ## 生命周期
114
+ ### 二、自定义外部地址:自己的服务器、反向代理或自建 Headscale
113
115
 
114
- 组件随 Magpie 运行。关掉 Magpie 之后,手机上显示的是最后一次拿到的结果,重新打开 Magpie 就会更新。这和原来那个用计划任务常驻的服务不同,是"插件"这个形态的取舍。
116
+ 在「网络连接」的输入框里填一个地址,手机配对时拿到的就是这个地址,组件和手机上的用量页都用它。填了以后插件不再建 Tailscale 转发,这件事上不再需要 Tailscale 的任何东西。
115
117
 
116
- ## 端口
118
+ 能填什么:
117
119
 
118
- 服务默认听 8790,手机那一侧是 8791。端口上冲突时换一个:
120
+ | 地址 | 能不能用 | 说明 |
121
+ |---|---|---|
122
+ | `https://my-server` / `https://my-server.example.com` | 能 | HTTPS 地址可以写任何域名。令牌和用量在传输里是加密的。 |
123
+ | `http://192.168.1.50:8791`、`http://10.0.0.7:8791`、`http://172.16.3.9:8791` | 能 | RFC1918 的私网地址。 |
124
+ | `http://100.64.0.5:8791` | 能 | Tailscale(和自建 Headscale)的地址段。 |
125
+ | `http://[fd00::1]:8791`、`http://[::1]:8791` | 能 | IPv6 的 ULA 和本机回环。 |
126
+ | `http://box.local:8791`、`http://myserver:8791` | 能 | 保留给内网的域名,或本机解析器里的单段名字。 |
127
+ | `http://example.com`、`http://93.184.216.34:8791` | **不能** | 公网上的明文 HTTP:令牌会在路上被看见。 |
119
128
 
120
- ```bash
121
- magpie plugin options magpie-page-token-widget '{"port":8890}'
122
- ```
129
+ 服务器上的检查和手机上的安装器用的是同一套规则,所以两边不会给出相反的结论。
123
130
 
124
- 改完要重启 Magpie。插件会在尾网里给这个端口建一条转发(`tailscale serve --tcp`),手机从 100.x 地址连进来;端口上已经有别人的转发时,插件不会动它。
131
+ 升级到 0.3.3 之后,配置里存着的地址会重新按这套规则过一遍:以前填过的**公网上的明文 HTTP 地址不再被接受**,插件会把它从配置里去掉,并在控制台上写明原因,然后回到 Tailscale 的转发。内网地址、Tailscale 地址和 HTTPS 地址都不受影响,手机上已经配好的令牌也不用重新配对。
125
132
 
126
- 转发指向的是手机那一侧的端口(默认比服务端口大 1,即 8791),控制台留在 127.0.0.1:8790。尾网里的请求落在这条转发上,不会被当成本机的请求,所以手机看不到控制台,也拿不到令牌。
133
+ 反向代理的要求:
127
134
 
128
- 插件会把自己建的转发记在配置文件里(端口和指向哪里)。重启之后它认得自己的转发,不会重复建;老版本留下的转发会被搬到手机那一侧;别人的转发它既不碰也不删。只有插件正常卸载、并且那条转发仍然指向它记下的地方时,它才会把转发撤掉。
135
+ - **这一版不支持带路径的地址。** 请用一个独立域名,把它的**根路径**转到插件服务,然后填 `https://my-server`。填 `https://my-server/widget` 会被明确拒绝,而不是半通不通。
136
+ - 代理要转到**手机那一侧的端口**(默认 8791),不是控制台的端口(默认 8790):控制台永远只留在本机,代理只应该把手机那一侧放出去。
137
+ - 代理必须把原来的 `Host` 原样传过去(`proxy_set_header Host $host;`)。插件靠它认出「这个请求是冲着我来的」,伪造的 `Host` 会被拒绝。
138
+ - 插件**不信任** `X-Forwarded-Proto`、`X-Forwarded-Host` 这些请求头:谁都能写它们。走 HTTPS 还是 HTTP,只看你在控制台里填的那个地址。
139
+ - 代理自己终结 HTTPS、再用 HTTP 回源到插件,是可以的:插件只按你填的地址判断对错,不看中间那一段。
129
140
 
130
- `tailscale serve --bg --tcp=8790` 是 Tailscale 里的长期设置:它留在那儿,插件被移除或崩溃都不会自己消失。想知道现在有哪些转发,看 `tailscale serve status`。
141
+ 要当心的事:
131
142
 
132
- ## 兼容性
143
+ - 走明文 HTTP 时,令牌就在里面走。除非地址是上面表里那些可信内网,否则只在自己信得过的网里用。
144
+ - 手机直连局域网地址时,iPhone 会问「本地网络」的权限,要允许。
145
+ - 地址会动:路由器换了地址,手机就找不到了,那时候来控制台改一下。
146
+ - 防火墙:手机要从局域网进来,Windows 的防火墙可能会挡住这个口,第一次要放行一次(只放行「专用网络」就够了)。
133
147
 
134
- - 需要 Magpie `0.1.3681` 以上(`package.json` 里写着)。
135
- - 额度读的是 Magpie 已经缓存好的那份(`/v1/magpie/quotas?kept=1`),不额外去问供应商。
136
- - 今天的 Token 读 `/v1/magpie/usage?period=today`,一次请求就拿到总数、分时、各厂商和各账号。**老版本 Magpie 没有这个接口时会自动退回老办法**(`/v1/magpie/usage/requests?period=today` 加逐账号的请求),组件上的数字照常有。
137
- - 七日趋势和模型/厂商排行读 `/v1/magpie/usage/requests?period=7d&limit=1`:这个接口才带 `by.model`、`by.agent` 这些分组,换成别的接口会把排行和趋势丢掉,所以保留。
138
- - 周期参数 `week` 在 Magpie 里不认(会被当成 30 天),七日要用 `7d`。
148
+ ### 三、LAN 直连:手机不进尾网
139
149
 
140
- ## 卸载
150
+ 不想用 Tailscale,只在同一个局域网里用:让手机那一侧直接在这台电脑的局域网地址上开一个口,手机走局域网地址连进来。
141
151
 
142
- ```bash
143
- magpie plugin rm magpie-page-token-widget
144
- ```
152
+ - 开的只有**手机那一侧**这个口,控制台永远关在本机(127.0.0.1),手机接口里没有控制台。
153
+ - 在「网络连接」里点某个网卡后面的「手机从这里连」,或者自己填地址(`http://192.168.1.50:8791`)并在「这台电脑的网卡」里选一个地址,插件就在那个地址上打开手机那一侧的端口。
154
+ - iPhone 会问「本地网络」的权限,要允许;Windows 防火墙第一次要放行一次。
155
+ - 电脑的局域网地址会变(DHCP),手机就找不到了,这时候回控制台重新选一次。
156
+ - 这条路是明文 HTTP,令牌在里面走。
145
157
 
146
- 插件被移除时,它建的转发会一并撤掉。
158
+ **怎么彻底关掉它**:点「网络连接」里的**「恢复 Tailscale 默认连接」**。这个动作会清掉自己填的地址、清掉文件里记着的网络设置、**把局域网那个口真的关掉**(不是只改配置),再把 Tailscale 的转发建回来。关完之后控制台会写明现在生效的是哪一种连接方式。
147
159
 
148
- ## 隐私
160
+ ## 手机上的用量页
149
161
 
150
- - 服务只读 Magpie 的本地接口(`127.0.0.1:3425`),不写 Magpie 的配置,不碰登录信息。
151
- - 令牌是本机随机生成的,只读,存在 Magpie 的配置目录里;服务端只留哈希。只有本机的插件页和拿着配对码的手机能拿到。
152
- - 局域网地址只出现在电脑上的控制台里,手机接口不提供。
153
- - 服务只在本机(127.0.0.1)上监听,手机是经 Tailscale 的转发进来的,没有公网入口,也不需要防火墙规则。
162
+ 点一下桌面上的组件,Safari 会打开手机版的用量页:每家订阅的剩余额度、今日与近七日的 Token、模型/厂商/客户端排行,和电脑上看到的是同一份数据。
154
163
 
155
- ## 开发
164
+ 页面用组件里的那个只读令牌打开,令牌只走地址栏的 `#token=` 一段,页面打开后立刻把它抹掉,之后由 Scriptable 自己记着。手机版页面只在尾网这一侧提供(填了自己的地址以后,就是在你自己的网上),电脑上的控制台仍然只在本机。
156
165
 
157
- ```bash
158
- npm test
159
- ```
160
-
161
- 测试里不会碰真的 Tailscale:所有和 Tailscale 命令打交道的测试都用一个自己的假命令(`test/helpers.mjs` 里的 `mockTailscale`),也不会去改真的 `tailscale serve` 设置。Magpie 网关同样是测试自己起的一个。
166
+ ## 组件参数
162
167
 
163
- `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)。
168
+ | 尺寸 | 内容 | Widget Parameter |
169
+ |---|---|---|
170
+ | 小号 | 单家额度、重置倒计时、今日 Token | `codex`(默认)、`minimax`、`glm`;某账号写作 `codex@账号名`;加 `\|used` 改显已用 |
171
+ | 中号 | 每页最多 6 个;1–4 个并排,5–6 个两行三列;今日 Token;有周限额时和重置时间写在同一行(5–6 个订阅挤成两行时只留 5 小时额度) | 留空;`page=2` 查看第二页 |
172
+ | 大号 | 每页最多 6 个;两列卡片,显示主额度、重置时间及第二窗口百分比;今日与近七日 Token | 留空;`page=2` 查看第二页 |
164
173
 
165
- ---
174
+ 参数里的账号名是现在的账号名。控制台「显示哪些订阅」里,每一行都写着组件认得的参数,照抄过去就行。账号改了名字以后参数会找不到,小号组件不会再空着:它会先给你看第一张卡,旁边写一句「没有找到 xxx」。
166
175
 
167
- # magpie-page-token-widget (English)
176
+ 数字前面永远写着方向:「剩余」还是「已用」。进度条在亮色和暗色里都是同一种墨(只是深浅不同),夜里不会发亮成警报;只有数字在快用完的时候才会变成橙色或红色。
168
177
 
169
- 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.
178
+ ## 生命周期
170
179
 
171
- 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.
180
+ 组件随 Magpie 运行。关掉 Magpie 之后,手机上显示的是最后一次拿到的结果,重新打开 Magpie 就会更新。这和原来那个用计划任务常驻的服务不同,是"插件"这个形态的取舍。
172
181
 
173
- ## Requirements
182
+ Magpie 停止或重载这个插件时,插件会走一遍自己的清理:关掉自己开的监听、撤掉自己建的转发。进程被强杀时做不了这一步,见下面「卸载」。
174
183
 
175
- - Magpie on the desktop (the plugin host is Bun).
176
- - Tailscale, with the computer and the iPhone in the same tailnet.
177
- - [Scriptable](https://scriptable.app/) on the iPhone.
184
+ ## 端口
178
185
 
179
- ## Install
186
+ 服务默认听 8790,手机那一侧是 8791。端口上冲突时换一个:
180
187
 
181
188
  ```bash
182
- magpie plugin add magpie-page-token-widget
189
+ magpie plugin options magpie-page-token-widget '{"port":8890}'
183
190
  ```
184
191
 
185
- From a checkout: `magpie plugin add ./magpie-page-token-widget`.
186
-
187
- ## Pair
188
-
189
- 1. Open the plugin page in Magpie.
190
- 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.
191
- 3. Scan the code with the camera of the iPhone, open the page in Safari and download the widget script.
192
- 4. Open the downloaded `UsageWidget.js` in the Files app and choose "Share → Open with Scriptable".
193
- 5. Long press the home screen, add the Scriptable widget and pick `UsageWidget`.
194
-
195
- The pairing code works once and lives ten minutes at most: after that it is dead, and a new one replaces it at once. Every phone that pairs gets a read-only token of its own. The service keeps only the hash of that token; the plain text is handed over exactly once, at the moment of pairing, and never again.
196
-
197
- Guessing a code is not cheap: ten wrong tries at one code lock that code for five minutes. The lock follows the code, not the machine — behind the Tailscale forward every phone arrives as the loopback of this computer, so a lock per address would let anybody on the tailnet shut the pairing of everybody else. An `X-Forwarded-For` header can be written by anybody, so it is not believed.
198
-
199
- The service keeps one budget for the whole of it: a hundred wrong tries in five minutes, whichever code they were aimed at, and the pairing page, the widget script and the pairing door all close until the five minutes are up. Asking for a new pairing code at the computer empties that budget at once, so a person can open the door again without waiting. What really makes a code hard to guess is neither of those two doors: it has ninety-six bits of randomness, it lives ten minutes and it opens the door once.
200
-
201
- The script keeps the token in the keychain of Scriptable, and the three widgets of one iPhone share it.
202
-
203
- Without the code you can also use "Device management": give a phone a token of its own and put it, with the service address, into `scriptable/SetupUsageWidget.js`.
204
-
205
- ### The phones of the older version
206
-
207
- Updating to 0.2 does not lock out the phones that were paired with 0.1: the shared token of the old version is migrated into a device named "旧版共享令牌" and those phones keep reading with it. Only revoking that entry stops them. Every new phone gets a token of its own, and revoking one phone stops that phone alone.
208
-
209
- ## Device management
192
+ 改完要重启 Magpie。默认(Tailscale)模式下,插件会在尾网里给这个端口建一条转发(`tailscale serve --tcp`),手机从 100.x 地址连进来;端口上已经有别人的转发时,插件不会动它。
210
193
 
211
- The list holds every device that was paired: its name, when it was paired, when it was last seen and when it was revoked. Every entry can be renamed and revoked on its own. To add a phone, press "give this phone a token" and put the token that appears once into the phone.
194
+ 转发指向的是手机那一侧的端口(默认比服务端口大 1,即 8791),控制台留在 127.0.0.1:8790。尾网里的请求落在这条转发上,不会被当成本机的请求,所以手机看不到控制台,也拿不到令牌。改用自己的外部地址以后,就不再建这条转发。
212
195
 
213
- ## Data update
196
+ 插件会把自己建的转发记在配置文件里(端口和指向哪里)。重启之后它认得自己的转发,不会重复建;老版本留下的转发会被搬到手机那一侧;别人的转发它既不碰也不删——只凭端口相同不算「是我的」。插件正常停止或卸载、并且那条转发仍然指向它记下的地方时,它才会把转发撤掉。
214
197
 
215
- The service does not read Magpie for every request of the phone. It keeps a snapshot that is good for a minute: a fresh snapshot is given right away, an old one triggers a reading, and all the requests that come at that moment share that one reading.
198
+ `tailscale serve --bg --tcp=8790` 是 Tailscale 里的长期设置:进程被强杀(崩溃、断电)时它留在那儿,不会自己消失。这种情况下插件下次启动会发现它还在、而且指向自己,会接着用;要自己收掉就 `tailscale serve --tcp=8790 off`,然后 `tailscale serve status` 看看还剩什么。
216
199
 
217
- A reader waits at most a few seconds: it gets the new numbers when they arrive, and the old ones marked as old when they do not, while the reading continues in the background.
200
+ 端口和网关都是 Magpie 启动这个插件时给的参数:端口是插件自己听在哪儿的地址,网关是插件去读的那个 Magpie。插件自己搬不了家,所以它们留在 Magpie 的启动参数里。想换端口或网关,在 Magpie 的插件设置里改:
218
201
 
219
- How often the service reads on its own:
202
+ `magpie plugin options magpie-page-token-widget '{"port":8890,"gatewayURL":"http://127.0.0.1:3500"}'`
220
203
 
221
- | Strategy | What it does |
222
- |---|---|
223
- | Smart (the default) | every minute while somebody is looking, every five minutes after ten quiet minutes, and almost never after half an hour of quiet |
224
- | Every minute | a fixed rhythm |
225
- | Every two minutes | a fixed rhythm |
226
- | Every five minutes | a fixed rhythm |
227
-
228
- Changing it in the plugin page takes effect at once; Magpie does not have to be restarted.
229
-
230
- ## Network
231
-
232
- The page shows whether Tailscale is installed and connected, the address of this computer in the tailnet, the MagicDNS name, the iPhones of the tailnet and the way the connection is made. Three ways are told apart:
233
-
234
- - **Direct**: Tailscale talks to the phone itself, encrypted end to end; on one LAN it usually finds the other side on its own, and that is the fastest way.
235
- - **Peer relay**: there is no direct path, so another device of the tailnet carries the traffic.
236
- - **DERP**: Tailscale's own relay server carries the traffic; the slowest way, but it always works.
237
-
238
- When the way cannot be read, the page says so instead of guessing. The button "diagnose" pings the phone once and writes down the answer; the service never pings on its own.
204
+ 改完重启 Magpie;手机那边的地址带着端口,换了端口手机要重新配对一次。
239
205
 
240
- The network cards of this computer are listed as well: name, IPv4, IPv6, the length of the network part, and whether the card is Tailscale, Wi-Fi, wired or virtual. **These addresses are for the computer only; the phone API never gets them.** They are there to help: when the phone cannot be reached, look at the address of this computer in the LAN and at the address of the tailnet.
206
+ 插件读的是 Magpie 自己的本地接口,默认是 `http://127.0.0.1:3425`。**这个网关地址必须指着本机**(`127.0.0.1`、`::1` 或 `localhost`):插件只去读这台电脑上的 Magpie,填成别的地址会被明确拒绝(控制台会写出原因),而不是把你的请求发到那台机器上去。控制台的**高级设置**里能看见现在读的是哪里(只是看一眼),但改它要去 Magpie 的插件设置里:这个地址换了要重启 Magpie 才算数,在插件页里改一半反而容易糊涂。
241
207
 
242
- **Tailscale stays the default way in.** The phone does not go around the tailnet just because this computer has a 192.168.x.x: a direct way over the LAN would have to deal with plain HTTP (or with HTTPS of its own), with addresses that move when the router hands out new ones, and with the local network permission of iOS. That is a new hole to look after, and the tailnet address is stable on both sides. The direct way over the LAN is not implemented in this release; only the addresses are discovered.
243
-
244
- ## The page on the phone
245
-
246
- Tapping the widget opens the usage page on the phone: the remaining quota of every subscription, today's tokens, the last seven days and the rankings by model, vendor and client, the same numbers the computer shows.
247
-
248
- The page opens with the read-only token of the widget. The token travels in the `#token=` part of the address and is wiped from there as soon as the page is open; Scriptable keeps it from then on. The page is served on the tailnet side only, the console of the computer stays on this computer.
249
-
250
- ## Widget parameter
251
-
252
- | Size | Shows | Widget Parameter |
253
- |---|---|---|
254
- | Small | one quota, the reset countdown, today's tokens | `codex` (default), `minimax`, `glm`; one account is written `codex@<account>`; `\|used` shows what is used |
255
- | Medium | up to 6 per page; today's tokens | empty; `page=2` for the second page |
256
- | 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 |
257
-
258
- ## Lifetime
259
-
260
- The service runs as long as Magpie runs. With Magpie closed, the phone keeps the last result it received.
208
+ ## 兼容性
261
209
 
262
- ## Port
210
+ - 需要 Magpie `0.1.3681` 以上(`package.json` 里写着)。
211
+ - 额度读的是 Magpie 已经缓存好的那份(`/v1/magpie/quotas?kept=1`),不额外去问供应商。
212
+ - 今天的 Token 读 `/v1/magpie/usage?period=today`,一次请求就拿到总数、分时、各厂商和各账号。**老版本 Magpie 没有这个接口时会自动退回老办法**(`/v1/magpie/usage/requests?period=today` 加逐账号的请求),组件上的数字照常有。退回只在网关明确回答「这个接口没有」(404、405、501)时才发生;网关临时出错(500、超时、断网)不会去逐账号重问,而是保留上一次读到的数字并标成「旧数据」。「这个接口没有」这件事会被记住一阵子,不会每次刷新都去问一遍。
213
+ - 七日趋势和模型/厂商排行读 `/v1/magpie/usage/requests?period=7d&limit=1`:这个接口才带 `by.model`、`by.agent` 这些分组,换成别的接口会把排行和趋势丢掉,所以保留。
214
+ - 周期参数 `week` 在 Magpie 里不认(会被当成 30 天),七日要用 `7d`。
263
215
 
264
- The service listens on 8790 by default, the phone side on 8791. Pick another one when the port is taken:
216
+ ## 卸载
265
217
 
266
218
  ```bash
267
- magpie plugin options magpie-page-token-widget '{"port":8890}'
219
+ magpie plugin rm magpie-page-token-widget
268
220
  ```
269
221
 
270
- 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.
271
-
272
- 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.
273
-
274
- The plugin writes down the forwarding it made (the port and where it points) in its own file. After a restart it recognizes it and does not make it twice; a forwarding of an older version is moved to the phone side; the forwarding of somebody else is neither touched nor removed. It is taken away only when the plugin is uninstalled properly and the forwarding still points where it was written down.
275
-
276
- `tailscale serve --bg --tcp=8790` is a standing setting of Tailscale: it stays there when the plugin is removed or crashes. `tailscale serve status` shows what is published right now.
277
-
278
- ## Compatibility
279
-
280
- - Magpie `0.1.3681` or later is needed (`package.json` says so).
281
- - The quota is read from the report Magpie has already cached (`/v1/magpie/quotas?kept=1`); the vendors are not asked again.
282
- - Today's tokens come from one request to `/v1/magpie/usage?period=today`, which holds the totals, the hours, the vendors and the accounts. **When an older Magpie does not know that endpoint, the old way is used instead** (`/v1/magpie/usage/requests?period=today` plus one request per account) and the widget keeps its numbers.
283
- - The trend of the last seven days and the rankings by model and vendor come from `/v1/magpie/usage/requests?period=7d&limit=1`: this is the only endpoint that carries the groups `by.model` and `by.agent`, and another endpoint would lose the rankings and the trend, so it stays.
284
- - Magpie does not know the period `week` (it is taken for 30 days); seven days is `7d`.
285
-
286
- ## Uninstall
222
+ 插件被移除时,插件会关掉自己开的监听、撤掉自己建的转发(只撤它自己记着的那条)。但有一件事做不到:进程被强杀(崩溃、断电、被强退)时来不及做这些,Tailscale 里的转发会留着,需要自己收一次:
287
223
 
288
224
  ```bash
289
- magpie plugin rm magpie-page-token-widget
225
+ tailscale serve --tcp=8790 off
290
226
  ```
291
227
 
292
- The forwarding of the plugin is taken away with it.
293
-
294
- ## Privacy
228
+ ## 隐私
295
229
 
296
- - The service only reads the local API of Magpie (`127.0.0.1:3425`). It does not write the configuration of Magpie and does not touch the logins.
297
- - The tokens are random, read only and stay in the directory of Magpie; the service keeps only their hashes. Only the plugin page of this computer and the phone that carries a pairing code can get one.
298
- - The addresses of the LAN are only shown in the console of the computer; the phone API does not have them.
299
- - 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.
230
+ - 服务只读 Magpie 的本地接口(默认 `127.0.0.1:3425`,只能是本机),不写 Magpie 的配置,不碰登录信息。
231
+ - 令牌是本机随机生成的,只读,存在 Magpie 的配置目录里;服务端只留哈希。没有共享令牌:一台手机一枚,撤销哪一枚就只停哪一台。只有这台电脑上的插件页,和带着配对码的手机,才能拿到令牌。
232
+ - 局域网地址只出现在电脑上的控制台里,手机接口不提供。
233
+ - 默认(Tailscale)模式下,服务只在本机(127.0.0.1)上监听,手机是经尾网的转发进来的,没有公网入口,也不需要防火墙规则。改用自己的地址以后,手机那一侧会在你选的地址上开一个口(**只有手机那一侧**,控制台永远关在本机),这时防火墙要放行一次,而且那条路是明文 HTTP。
300
234
 
301
- ## Development
235
+ ## 开发
302
236
 
303
237
  ```bash
304
238
  npm test
305
239
  ```
306
240
 
307
- The tests never touch the real Tailscale: everything that speaks with the command of Tailscale talks to a fake one instead (`mockTailscale` in `test/helpers.mjs`), and the real `tailscale serve` settings are never changed. The gateway of Magpie is one the tests start themselves.
241
+ 测试里不会碰真的 Tailscale:所有和 Tailscale 命令打交道的测试都用一个自己的假命令(`test/helpers.mjs` 里的 `mockTailscale`),也不会去改真的 `tailscale serve` 设置。Magpie 网关同样是测试自己起的一个。
308
242
 
309
- `lib/collector.mjs` and `lib/normalize.mjs` come from [ScriptableTokenWidgets](https://github.com/liuzq1103/ScriptableTokenWidgets), `lib/vendor/qrcode-generator.cjs` is [qrcode-generator](https://github.com/kazuhikoarase/qrcode-generator). See [NOTICE](NOTICE).
243
+ `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)。
package/index.mjs CHANGED
@@ -1,11 +1,49 @@
1
1
  import path from 'node:path';
2
2
  import { fileURLToPath } from 'node:url';
3
+ import { readFileSync } from 'node:fs';
3
4
  import { startServer } from './lib/server.mjs';
5
+ import { removeForwardSync } from './lib/forward.mjs';
4
6
 
5
7
  const root = path.dirname(fileURLToPath(import.meta.url));
6
8
 
7
9
  let running;
8
10
  let current;
11
+ let directory;
12
+
13
+ // The service is shut down and the forwarding of the tailnet is taken away.
14
+ // This is what the host asks for when the plugin is unloaded, and what the
15
+ // host asks for when the settings change.
16
+ async function shutdown() {
17
+ const previous = running;
18
+ running = undefined;
19
+ current = undefined;
20
+ if (!previous) return { ok: true, stopped: false };
21
+ const service = await previous.catch(() => null);
22
+ await service?.close?.();
23
+ return { ok: true, stopped: true };
24
+ }
25
+
26
+ // The last way out. When the program ends, the sockets go with it, but the
27
+ // forwarding of Tailscale is a setting of Tailscale and would stay behind.
28
+ // It is taken away here, and only when it still points where this plugin
29
+ // left it: a forwarding of somebody else is never touched.
30
+ export function cleanupForward(directory, runner) {
31
+ try {
32
+ if (!directory) return { ok: false, error: '没有数据目录。' };
33
+ const file = path.join(directory, 'token-widget', 'state.json');
34
+ const record = JSON.parse(readFileSync(file, 'utf8'))?.forward;
35
+ if (!record) return { ok: true, skipped: true };
36
+ return removeForwardSync(record.publishedPort, record, runner);
37
+ } catch (error) {
38
+ return { ok: false, error: String(error?.message || error) };
39
+ }
40
+ }
41
+
42
+ function cleanupOnExit() {
43
+ cleanupForward(directory);
44
+ }
45
+
46
+ process.once('exit', cleanupOnExit);
9
47
 
10
48
  // The host calls this again when the options change, and calls it once when
11
49
  // the plugin is loaded. The service is restarted whenever the hand-over
@@ -15,6 +53,7 @@ export default {
15
53
  async server(input, options) {
16
54
  if (!input?.directory) throw new Error('The Magpie plugin host is required.');
17
55
  options ||= {};
56
+ directory = input.directory;
18
57
  const wanted = JSON.stringify([input.directory, options]);
19
58
  if (wanted !== current) {
20
59
  const previous = running;
@@ -35,4 +74,16 @@ export default {
35
74
  await running;
36
75
  return {};
37
76
  },
77
+ // A host that asks the plugin to stop gets the service closed and the
78
+ // forwarding of the tailnet taken away. A host that does not ask still
79
+ // ends up in the same state when the program ends.
80
+ async stop() {
81
+ return shutdown();
82
+ },
83
+ async uninstall() {
84
+ return shutdown();
85
+ },
86
+ async close() {
87
+ return shutdown();
88
+ },
38
89
  };
package/lib/collector.mjs CHANGED
@@ -36,6 +36,34 @@ export const refreshStrategy = value => (REFRESH_STRATEGIES[value] ? value : 'sm
36
36
 
37
37
  const sleep = ms => new Promise(resolve => { setTimeout(resolve, ms).unref?.(); });
38
38
 
39
+ // The answer of the gateway, when it did not come.
40
+ //
41
+ // Two kinds of "no" are told apart. "unsupported" means that this gateway
42
+ // does not have that answer at all: an older Magpie that knows nothing of
43
+ // /v1/magpie/usage. "transient" means that the answer should have been there
44
+ // and was not: the gateway was busy, it answered with an error, or the
45
+ // network was gone for a moment.
46
+ //
47
+ // The difference matters. The old way of reading the day asks one answer per
48
+ // account; doing that after a hiccup would make the trouble worse, so it is
49
+ // only done when the gateway says that the newer answer does not exist.
50
+ export class MagpieError extends Error {
51
+ constructor(message, { status = 0, kind = 'transient' } = {}) {
52
+ super(message);
53
+ this.name = 'MagpieError';
54
+ this.status = status;
55
+ this.kind = kind;
56
+ }
57
+ }
58
+
59
+ // The statuses that mean "this answer is not there": the endpoint was never
60
+ // built, the method is not allowed on it, or the gateway does not implement
61
+ // it.
62
+ const UNSUPPORTED_STATUS = new Set([404, 405, 501]);
63
+ // How long the answer of an older Magpie is taken for granted. After that
64
+ // the newer answer is asked once more, in case the gateway was changed.
65
+ const LEGACY_RECHECK_MS = 10 * 60_000;
66
+
39
67
  // On the disk the accounts are written as the name of the account alone: the
40
68
  // whole address stays in the memory of the service and never reaches the
41
69
  // phone.
@@ -58,7 +86,7 @@ const snapshotForDisk = (snapshot) => {
58
86
  };
59
87
 
60
88
  export class Collector {
61
- constructor({ upstream, magpieDir, snapshotFile, fetcher = fetch, clock = Date.now, display = {}, strategy = 'smart', ttlMs = SNAPSHOT_TTL_MS, maxWaitMs = MAX_WAIT_MS, requestTimeoutMs = 15_000 }) {
89
+ constructor({ upstream, magpieDir, snapshotFile, fetcher = fetch, clock = Date.now, display = {}, strategy = 'smart', ttlMs = SNAPSHOT_TTL_MS, maxWaitMs = MAX_WAIT_MS, requestTimeoutMs = 15_000, enabled = true }) {
62
90
  this.upstream = upstream;
63
91
  this.magpieDir = magpieDir;
64
92
  this.snapshotFile = snapshotFile;
@@ -69,6 +97,9 @@ export class Collector {
69
97
  this.ttlMs = ttlMs;
70
98
  this.maxWaitMs = maxWaitMs;
71
99
  this.requestTimeoutMs = requestTimeoutMs;
100
+ // A gateway that was refused is never asked. The service keeps the last
101
+ // reading and waits for the setting to be fixed.
102
+ this.enabled = enabled !== false;
72
103
  this.snapshot = { version: 1, parts: {}, failures: {} };
73
104
  // The whole address of an account is kept in the memory of the service
74
105
  // only. On the disk it is written as the name of the account alone,
@@ -84,6 +115,11 @@ export class Collector {
84
115
  this.expired = false;
85
116
  this.timer = null;
86
117
  this.stopped = true;
118
+ // What this gateway can answer: 'unknown' at first, then 'new' or
119
+ // 'legacy'. It is remembered so that the missing answer of an older
120
+ // Magpie is not looked for again and again.
121
+ this.todayMode = 'unknown';
122
+ this.todayModeAt = 0;
87
123
  }
88
124
 
89
125
  // A snapshot that was written by an earlier run is read back, but it is
@@ -103,13 +139,27 @@ export class Collector {
103
139
  }
104
140
 
105
141
  async request(endpoint, timeout = this.requestTimeoutMs) {
106
- const response = await this.fetcher(new URL(endpoint, this.upstream), {
107
- signal: AbortSignal.timeout(timeout),
108
- headers: { Accept: 'application/json' },
109
- redirect: 'error',
110
- });
111
- if (!response.ok) throw new Error('Magpie unavailable');
112
- return response.json();
142
+ let response;
143
+ try {
144
+ response = await this.fetcher(new URL(endpoint, this.upstream), {
145
+ signal: AbortSignal.timeout(timeout),
146
+ headers: { Accept: 'application/json' },
147
+ redirect: 'error',
148
+ });
149
+ } catch (error) {
150
+ // The gateway did not answer at all. That is a moment of trouble, not
151
+ // a sign that the answer does not exist.
152
+ throw new MagpieError(`Magpie did not answer: ${error?.message || error}`, { kind: 'transient' });
153
+ }
154
+ if (!response.ok) {
155
+ const kind = UNSUPPORTED_STATUS.has(response.status) ? 'unsupported' : 'transient';
156
+ throw new MagpieError(`Magpie answered ${response.status} for ${endpoint}`, { status: response.status, kind });
157
+ }
158
+ try {
159
+ return await response.json();
160
+ } catch (error) {
161
+ throw new MagpieError(`Magpie answered something that is not JSON: ${error?.message || error}`, { kind: 'transient' });
162
+ }
113
163
  }
114
164
 
115
165
  // The quota readings Magpie has already written down. They are the same
@@ -187,14 +237,41 @@ export class Collector {
187
237
 
188
238
  // The reading of today: the totals, the hours, the vendors and the accounts
189
239
  // all come with one answer of the gateway.
240
+ //
241
+ // The old way is only used when the gateway says that the newer answer does
242
+ // not exist (404, 405, 501). A gateway that is busy, that answers with an
243
+ // error, or that cannot be reached is a moment of trouble: the old way
244
+ // asks one answer per account, and doing that after a hiccup would only
245
+ // make the trouble worse. The last good reading is kept in that case, and
246
+ // it is marked as old.
190
247
  async todayUsage(cards = []) {
248
+ // The newer answer is asked again from time to time, so a gateway that
249
+ // was changed under the feet of the service is noticed.
250
+ const known = this.todayMode === 'legacy' && this.clock() - this.todayModeAt < LEGACY_RECHECK_MS;
251
+ if (known) {
252
+ try {
253
+ return await this.legacyToday(cards);
254
+ } catch (error) {
255
+ // The old way is gone as well. The newer one is asked again the next
256
+ // time, in case the gateway was changed under the feet of the service.
257
+ if (error instanceof MagpieError && error.kind === 'unsupported') this.todayMode = 'unknown';
258
+ throw error;
259
+ }
260
+ }
261
+ let answer;
191
262
  try {
192
- return normalizeUsageSummary(await this.request('/v1/magpie/usage?period=today'));
263
+ answer = await this.request('/v1/magpie/usage?period=today');
193
264
  } catch (error) {
194
- // An older Magpie does not know this answer. The old way is used then:
195
- // the totals of the day, and one answer per account.
196
- return this.legacyToday(cards);
265
+ if (error instanceof MagpieError && error.kind === 'unsupported') {
266
+ this.todayMode = 'legacy';
267
+ this.todayModeAt = this.clock();
268
+ return this.legacyToday(cards);
269
+ }
270
+ throw error;
197
271
  }
272
+ this.todayMode = 'new';
273
+ this.todayModeAt = this.clock();
274
+ return normalizeUsageSummary(answer);
198
275
  }
199
276
 
200
277
  // The way of the older Magpie: the totals of the day come from the requests
@@ -316,6 +393,7 @@ export class Collector {
316
393
 
317
394
  // Only one reading runs at a time. Every request that asks for a refresh
318
395
  // joins the one that is running.
396
+
319
397
  refresh() {
320
398
  if (this.pending) return this.pending;
321
399
  const job = this.collect()
@@ -371,6 +449,7 @@ export class Collector {
371
449
  }));
372
450
  }
373
451
 
452
+
374
453
  setDisplay(display) {
375
454
  this.display = display;
376
455
  }
@@ -401,7 +480,7 @@ export class Collector {
401
480
 
402
481
  schedule() {
403
482
  clearTimeout(this.timer);
404
- if (this.stopped) return;
483
+ if (this.stopped || !this.enabled) return;
405
484
  this.timer = setTimeout(() => {
406
485
  this.timer = null;
407
486
  this.tick();