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/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
|
|
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
|
|
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
|
-
|
|
102
|
+
## 三种连接方式
|
|
101
103
|
|
|
102
|
-
|
|
104
|
+
这是三件事,看的时候别混。控制台「网络连接」里随时能看出当前用的是哪一种。
|
|
103
105
|
|
|
104
|
-
|
|
106
|
+
### 一、默认:Tailscale
|
|
105
107
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
-
|
|
116
|
+
在「网络连接」的输入框里填一个地址,手机配对时拿到的就是这个地址,组件和手机上的用量页都用它。填了以后插件不再建 Tailscale 转发,这件事上不再需要 Tailscale 的任何东西。
|
|
115
117
|
|
|
116
|
-
|
|
118
|
+
能填什么:
|
|
117
119
|
|
|
118
|
-
|
|
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
|
-
|
|
121
|
-
magpie plugin options magpie-page-token-widget '{"port":8890}'
|
|
122
|
-
```
|
|
129
|
+
服务器上的检查和手机上的安装器用的是同一套规则,所以两边不会给出相反的结论。
|
|
123
130
|
|
|
124
|
-
|
|
131
|
+
升级到 0.3.3 之后,配置里存着的地址会重新按这套规则过一遍:以前填过的**公网上的明文 HTTP 地址不再被接受**,插件会把它从配置里去掉,并在控制台上写明原因,然后回到 Tailscale 的转发。内网地址、Tailscale 地址和 HTTPS 地址都不受影响,手机上已经配好的令牌也不用重新配对。
|
|
125
132
|
|
|
126
|
-
|
|
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
|
-
|
|
141
|
+
要当心的事:
|
|
131
142
|
|
|
132
|
-
|
|
143
|
+
- 走明文 HTTP 时,令牌就在里面走。除非地址是上面表里那些可信内网,否则只在自己信得过的网里用。
|
|
144
|
+
- 手机直连局域网地址时,iPhone 会问「本地网络」的权限,要允许。
|
|
145
|
+
- 地址会动:路由器换了地址,手机就找不到了,那时候来控制台改一下。
|
|
146
|
+
- 防火墙:手机要从局域网进来,Windows 的防火墙可能会挡住这个口,第一次要放行一次(只放行「专用网络」就够了)。
|
|
133
147
|
|
|
134
|
-
|
|
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
|
-
|
|
143
|
-
|
|
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
|
-
|
|
151
|
-
- 令牌是本机随机生成的,只读,存在 Magpie 的配置目录里;服务端只留哈希。只有本机的插件页和拿着配对码的手机能拿到。
|
|
152
|
-
- 局域网地址只出现在电脑上的控制台里,手机接口不提供。
|
|
153
|
-
- 服务只在本机(127.0.0.1)上监听,手机是经 Tailscale 的转发进来的,没有公网入口,也不需要防火墙规则。
|
|
162
|
+
点一下桌面上的组件,Safari 会打开手机版的用量页:每家订阅的剩余额度、今日与近七日的 Token、模型/厂商/客户端排行,和电脑上看到的是同一份数据。
|
|
154
163
|
|
|
155
|
-
|
|
164
|
+
页面用组件里的那个只读令牌打开,令牌只走地址栏的 `#token=` 一段,页面打开后立刻把它抹掉,之后由 Scriptable 自己记着。手机版页面只在尾网这一侧提供(填了自己的地址以后,就是在你自己的网上),电脑上的控制台仍然只在本机。
|
|
156
165
|
|
|
157
|
-
|
|
158
|
-
npm test
|
|
159
|
-
```
|
|
160
|
-
|
|
161
|
-
测试里不会碰真的 Tailscale:所有和 Tailscale 命令打交道的测试都用一个自己的假命令(`test/helpers.mjs` 里的 `mockTailscale`),也不会去改真的 `tailscale serve` 设置。Magpie 网关同样是测试自己起的一个。
|
|
166
|
+
## 组件参数
|
|
162
167
|
|
|
163
|
-
|
|
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
|
-
|
|
176
|
+
数字前面永远写着方向:「剩余」还是「已用」。进度条在亮色和暗色里都是同一种墨(只是深浅不同),夜里不会发亮成警报;只有数字在快用完的时候才会变成橙色或红色。
|
|
168
177
|
|
|
169
|
-
|
|
178
|
+
## 生命周期
|
|
170
179
|
|
|
171
|
-
|
|
180
|
+
组件随 Magpie 运行。关掉 Magpie 之后,手机上显示的是最后一次拿到的结果,重新打开 Magpie 就会更新。这和原来那个用计划任务常驻的服务不同,是"插件"这个形态的取舍。
|
|
172
181
|
|
|
173
|
-
|
|
182
|
+
Magpie 停止或重载这个插件时,插件会走一遍自己的清理:关掉自己开的监听、撤掉自己建的转发。进程被强杀时做不了这一步,见下面「卸载」。
|
|
174
183
|
|
|
175
|
-
|
|
176
|
-
- Tailscale, with the computer and the iPhone in the same tailnet.
|
|
177
|
-
- [Scriptable](https://scriptable.app/) on the iPhone.
|
|
184
|
+
## 端口
|
|
178
185
|
|
|
179
|
-
|
|
186
|
+
服务默认听 8790,手机那一侧是 8791。端口上冲突时换一个:
|
|
180
187
|
|
|
181
188
|
```bash
|
|
182
|
-
magpie plugin
|
|
189
|
+
magpie plugin options magpie-page-token-widget '{"port":8890}'
|
|
183
190
|
```
|
|
184
191
|
|
|
185
|
-
|
|
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
|
-
|
|
194
|
+
转发指向的是手机那一侧的端口(默认比服务端口大 1,即 8791),控制台留在 127.0.0.1:8790。尾网里的请求落在这条转发上,不会被当成本机的请求,所以手机看不到控制台,也拿不到令牌。改用自己的外部地址以后,就不再建这条转发。
|
|
212
195
|
|
|
213
|
-
|
|
196
|
+
插件会把自己建的转发记在配置文件里(端口和指向哪里)。重启之后它认得自己的转发,不会重复建;老版本留下的转发会被搬到手机那一侧;别人的转发它既不碰也不删——只凭端口相同不算「是我的」。插件正常停止或卸载、并且那条转发仍然指向它记下的地方时,它才会把转发撤掉。
|
|
214
197
|
|
|
215
|
-
|
|
198
|
+
`tailscale serve --bg --tcp=8790` 是 Tailscale 里的长期设置:进程被强杀(崩溃、断电)时它留在那儿,不会自己消失。这种情况下插件下次启动会发现它还在、而且指向自己,会接着用;要自己收掉就 `tailscale serve --tcp=8790 off`,然后 `tailscale serve status` 看看还剩什么。
|
|
216
199
|
|
|
217
|
-
|
|
200
|
+
端口和网关都是 Magpie 启动这个插件时给的参数:端口是插件自己听在哪儿的地址,网关是插件去读的那个 Magpie。插件自己搬不了家,所以它们留在 Magpie 的启动参数里。想换端口或网关,在 Magpie 的插件设置里改:
|
|
218
201
|
|
|
219
|
-
|
|
202
|
+
`magpie plugin options magpie-page-token-widget '{"port":8890,"gatewayURL":"http://127.0.0.1:3500"}'`
|
|
220
203
|
|
|
221
|
-
|
|
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
|
-
|
|
206
|
+
插件读的是 Magpie 自己的本地接口,默认是 `http://127.0.0.1:3425`。**这个网关地址必须指着本机**(`127.0.0.1`、`::1` 或 `localhost`):插件只去读这台电脑上的 Magpie,填成别的地址会被明确拒绝(控制台会写出原因),而不是把你的请求发到那台机器上去。控制台的**高级设置**里能看见现在读的是哪里(只是看一眼),但改它要去 Magpie 的插件设置里:这个地址换了要重启 Magpie 才算数,在插件页里改一半反而容易糊涂。
|
|
241
207
|
|
|
242
|
-
|
|
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
|
-
|
|
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
|
-
|
|
216
|
+
## 卸载
|
|
265
217
|
|
|
266
218
|
```bash
|
|
267
|
-
magpie plugin
|
|
219
|
+
magpie plugin rm magpie-page-token-widget
|
|
268
220
|
```
|
|
269
221
|
|
|
270
|
-
|
|
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
|
-
|
|
225
|
+
tailscale serve --tcp=8790 off
|
|
290
226
|
```
|
|
291
227
|
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
## Privacy
|
|
228
|
+
## 隐私
|
|
295
229
|
|
|
296
|
-
-
|
|
297
|
-
-
|
|
298
|
-
-
|
|
299
|
-
-
|
|
230
|
+
- 服务只读 Magpie 的本地接口(默认 `127.0.0.1:3425`,只能是本机),不写 Magpie 的配置,不碰登录信息。
|
|
231
|
+
- 令牌是本机随机生成的,只读,存在 Magpie 的配置目录里;服务端只留哈希。没有共享令牌:一台手机一枚,撤销哪一枚就只停哪一台。只有这台电脑上的插件页,和带着配对码的手机,才能拿到令牌。
|
|
232
|
+
- 局域网地址只出现在电脑上的控制台里,手机接口不提供。
|
|
233
|
+
- 默认(Tailscale)模式下,服务只在本机(127.0.0.1)上监听,手机是经尾网的转发进来的,没有公网入口,也不需要防火墙规则。改用自己的地址以后,手机那一侧会在你选的地址上开一个口(**只有手机那一侧**,控制台永远关在本机),这时防火墙要放行一次,而且那条路是明文 HTTP。
|
|
300
234
|
|
|
301
|
-
##
|
|
235
|
+
## 开发
|
|
302
236
|
|
|
303
237
|
```bash
|
|
304
238
|
npm test
|
|
305
239
|
```
|
|
306
240
|
|
|
307
|
-
|
|
241
|
+
测试里不会碰真的 Tailscale:所有和 Tailscale 命令打交道的测试都用一个自己的假命令(`test/helpers.mjs` 里的 `mockTailscale`),也不会去改真的 `tailscale serve` 设置。Magpie 网关同样是测试自己起的一个。
|
|
308
242
|
|
|
309
|
-
`lib/collector.mjs`
|
|
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
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
-
|
|
263
|
+
answer = await this.request('/v1/magpie/usage?period=today');
|
|
193
264
|
} catch (error) {
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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();
|