dsh-mobile-plugin 0.5.19 → 0.5.20
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/lib/canvas-app.html +441 -0
- package/lib/client.js +40 -1
- package/lib/mobile/attachment-store.js +111 -15
- package/lib/mobile/attachments.js +6 -2
- package/lib/mobile/host-bridge/endpoints.js +7 -1
- package/lib/mobile/local-images.js +1 -1
- package/lib/mobile/magpie-web.js +425 -0
- package/lib/mobile/session-send.js +13 -13
- package/lib/mobile/stats-ledger.cjs +21 -6
- package/lib/mobile/stats.js +7 -3
- package/lib/panel/index.html +8 -8
- package/lib/routes/canvas-desktop.js +196 -0
- package/lib/routes/control-plane.js +40 -0
- package/lib/routes/mobile-api.js +6 -0
- package/package.json +1 -1
|
@@ -0,0 +1,425 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* magpie 网页版:把这台电脑上 magpie 自带的那个网页界面端给手机,需要时替它起来。
|
|
5
|
+
*
|
|
6
|
+
* **为什么走网页版而不是自己画一页**:magpie 的网页版就是它自己的界面(Agents / Providers /
|
|
7
|
+
* Routing / Usage / Settings 全在里面),而且它本来就是给手机做的 —— 页面源码里写着
|
|
8
|
+
* 「加到手机主屏后,作为独立 App 打开」,还有 manifest 与 apple-touch-icon。照着它的
|
|
9
|
+
* `/api/state` 再画一套,等于把人家已经做好的东西重做一遍,而且每加一个设置项我们就得跟一次。
|
|
10
|
+
* 所以手机那边直接嵌这个页面:一份实现、永远跟 magpie 同步。
|
|
11
|
+
*
|
|
12
|
+
* **为什么要在这里起进程**:网关那个端口(3425)只答 API,不端页面 —— 页面是 `magpie web`
|
|
13
|
+
* 这个独立进程端出来的,默认 `127.0.0.1:3430`,而且它起来之后要**换一个 key**(每次运行
|
|
14
|
+
* 随机,除非设了 `MAGPIE_WEB_KEY`)。用户点开那一页时网页版没在跑,这里就替他把进程拉起来,
|
|
15
|
+
* 拿到地址与 key 再交出去;已经跑着就直接用现成的。
|
|
16
|
+
*
|
|
17
|
+
* **key 是凭据**:它只出现在这一条端点与那一次 URL 里,不进日志、不回给别的端点。设了
|
|
18
|
+
* `MAGPIE_WEB_KEY` 就用它(每次运行同一个,手机那边刷新不用重新要地址);没有就给这个进程
|
|
19
|
+
* 临时定一个,随进程走。
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
const { spawn } = require('node:child_process');
|
|
23
|
+
const fs = require('node:fs');
|
|
24
|
+
const http = require('node:http');
|
|
25
|
+
const net = require('node:net');
|
|
26
|
+
const os = require('node:os');
|
|
27
|
+
const path = require('node:path');
|
|
28
|
+
|
|
29
|
+
const { sendJson } = require('../shared/http-util');
|
|
30
|
+
|
|
31
|
+
/** 网页版默认端口(magpie 自己的缺省)。 */
|
|
32
|
+
const DEFAULT_WEB_PORT = 3430;
|
|
33
|
+
/** 起来之后最多等这么久让它把页面端出来。冷启动实测 3~6 秒(要读各家配置与订阅)。 */
|
|
34
|
+
const START_TIMEOUT_MS = 25000;
|
|
35
|
+
/** 探测间隔。 */
|
|
36
|
+
const POLL_MS = 250;
|
|
37
|
+
/** 上面等不到时的退避:同一个端口短时间内不反复起进程。 */
|
|
38
|
+
const START_COOLDOWN_MS = 5000;
|
|
39
|
+
|
|
40
|
+
/** key 只要够长、能进查询串;magpie 自己要求 16 位以上字母数字与 `-._~`。 */
|
|
41
|
+
const KEY_RE = /^[A-Za-z0-9._~-]{16,}$/;
|
|
42
|
+
|
|
43
|
+
/** 正在起来的那个:同一时刻只许一个(连点两下不该起两个进程)。 */
|
|
44
|
+
let starting = null;
|
|
45
|
+
/**
|
|
46
|
+
* 我们自己起的那个进程用的 key(端口 → key)。
|
|
47
|
+
*
|
|
48
|
+
* **必须记住**:没设 `MAGPIE_WEB_KEY` 时 key 是这里现造的,而那一次运行之内它一直有效 ——
|
|
49
|
+
* 第二次点开时进程已经在跑,如果不记得当初用了哪把,就只能回「拿不到凭据」,用户看到的是
|
|
50
|
+
* 「第一次能开、第二次打不开」。进程随插件重载消失,这份记录跟着进程走,所以重启后重新起
|
|
51
|
+
* 一个新进程、配一把新 key,也是一致的。
|
|
52
|
+
*/
|
|
53
|
+
const startedKeys = new Map();
|
|
54
|
+
/**
|
|
55
|
+
* 上次起失败的时刻与原因(按端口记):失败之后短时间内不再对**同一个端口**反复试。
|
|
56
|
+
*
|
|
57
|
+
* 按端口而不是全局:一个端口被别的东西占着起不来,不该把另一个能用的端口也一起封住。
|
|
58
|
+
*/
|
|
59
|
+
const failures = new Map();
|
|
60
|
+
|
|
61
|
+
function text(value) {
|
|
62
|
+
return typeof value === 'string' ? value.trim() : '';
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** magpie 的配置目录(与 `magpie.js` 同一口径)。 */
|
|
66
|
+
function configDir(env = process.env) {
|
|
67
|
+
const xdg = text(env.XDG_CONFIG_HOME);
|
|
68
|
+
const base = xdg || path.join(os.homedir(), '.config');
|
|
69
|
+
return path.join(base, 'magpie');
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* 网页版该占哪个端口。
|
|
74
|
+
*
|
|
75
|
+
* magpie 自己的默认是 3430;`settings.json` 里没有单独的「网页版端口」这一项(那是命令行
|
|
76
|
+
* 参数),所以这里只认 `$MAGPIE_WEB_PORT` 与默认值。端口被别的东西占着时往上找几个。
|
|
77
|
+
*/
|
|
78
|
+
function preferredPort(env = process.env) {
|
|
79
|
+
const raw = Number(text(env.MAGPIE_WEB_PORT));
|
|
80
|
+
return Number.isInteger(raw) && raw > 0 && raw < 65536 ? raw : DEFAULT_WEB_PORT;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* 这个端口上是不是已经有一个 magpie 网页版在答。
|
|
85
|
+
*
|
|
86
|
+
* 判据是它回 303/302(`/?k=` 换 cookie 那一跳)或 401(认得出是它、只是没带 key)——
|
|
87
|
+
* 别的程序占着这个端口时不会这么答。不带 key 请求,所以这条探测不会泄漏任何东西。
|
|
88
|
+
*/
|
|
89
|
+
function probeWeb(port, timeoutMs = 1200) {
|
|
90
|
+
return new Promise((resolve) => {
|
|
91
|
+
const socket = net.connect({ host: '127.0.0.1', port });
|
|
92
|
+
let done = false;
|
|
93
|
+
const finish = (ok) => {
|
|
94
|
+
if (done) return;
|
|
95
|
+
done = true;
|
|
96
|
+
try { socket.destroy(); } catch { /* 已经断了 */ }
|
|
97
|
+
resolve(ok);
|
|
98
|
+
};
|
|
99
|
+
socket.setTimeout(timeoutMs, () => finish(false));
|
|
100
|
+
socket.on('error', () => finish(false));
|
|
101
|
+
socket.on('connect', () => {
|
|
102
|
+
socket.write(
|
|
103
|
+
'GET / HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n'
|
|
104
|
+
+ 'User-Agent: dsh-mobile-plugin\r\n\r\n',
|
|
105
|
+
);
|
|
106
|
+
});
|
|
107
|
+
let raw = '';
|
|
108
|
+
socket.on('data', (chunk) => {
|
|
109
|
+
raw += chunk.toString('latin1');
|
|
110
|
+
if (raw.length > 2048) raw = raw.slice(0, 2048);
|
|
111
|
+
const status = raw.match(/^HTTP\/1\.[01] (\d{3})/);
|
|
112
|
+
if (!status) return;
|
|
113
|
+
const code = Number(status[1]);
|
|
114
|
+
// 303/302 = 有 key 就换 cookie 的那一跳;401 = 是它、但这次没给 key。
|
|
115
|
+
finish(code === 303 || code === 302 || code === 401);
|
|
116
|
+
});
|
|
117
|
+
socket.on('close', () => finish(false));
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** 端口空着没有(起进程前先看一眼,别去撞一个不认识的程序)。 */
|
|
122
|
+
function portFree(port) {
|
|
123
|
+
return new Promise((resolve) => {
|
|
124
|
+
const server = net.createServer();
|
|
125
|
+
let done = false;
|
|
126
|
+
const finish = (free) => {
|
|
127
|
+
if (done) return;
|
|
128
|
+
done = true;
|
|
129
|
+
try { server.close(); } catch { /* 没起来 */ }
|
|
130
|
+
resolve(free);
|
|
131
|
+
};
|
|
132
|
+
server.once('error', () => finish(false));
|
|
133
|
+
server.listen(port, '127.0.0.1', () => finish(true));
|
|
134
|
+
server.unref();
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** 找一个能用的端口:优先默认,被占了就往上找几个。 */
|
|
139
|
+
async function pickPort(env = process.env) {
|
|
140
|
+
const base = preferredPort(env);
|
|
141
|
+
for (let offset = 0; offset < 10; offset += 1) {
|
|
142
|
+
const port = base + offset;
|
|
143
|
+
if (await probeWeb(port, 600)) return { port, running: true };
|
|
144
|
+
if (await portFree(port)) return { port, running: false };
|
|
145
|
+
}
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* 找 magpie 可执行文件。
|
|
151
|
+
*
|
|
152
|
+
* magpie 三个桌面系统都有(菜单栏 / 窗口 / TUI / 网页 / CLI 是同一个程序的不同入口),
|
|
153
|
+
* 装法也各不相同,所以按「先 PATH、再各系统的常规落点」找,别只认 macOS 那一个 .app 包:
|
|
154
|
+
* - PATH:`install.sh` 与 `go install` 都往某个 bin 目录里放一个 `magpie`(Windows 是
|
|
155
|
+
* `magpie.exe`),只要那个目录在 PATH 上就能直接命中。
|
|
156
|
+
* - macOS:官方 .app 包里的可执行文件,外加 Homebrew 的两个前缀。
|
|
157
|
+
* - Windows:安装器放在 `%LOCALAPPDATA%\Programs\magpie`、`%ProgramFiles%\magpie`;
|
|
158
|
+
* `go install` 落在 `%USERPROFILE%\go\bin`。
|
|
159
|
+
* - Linux:`~/.local/bin`、`/usr/local/bin`、`/usr/bin`、`/opt/magpie`,以及 `go install`
|
|
160
|
+
* 的 `~/go/bin`。
|
|
161
|
+
*
|
|
162
|
+
* `MAGPIE_BIN` 给了就只认它:用户明确指了一条路径,指错时如实回「找不到」,而不是悄悄去
|
|
163
|
+
* PATH 里找另一个 —— 那样排查起来是「我明明改了变量,它却还在跑旧的那个」。
|
|
164
|
+
*/
|
|
165
|
+
function findBinary(env = process.env) {
|
|
166
|
+
const explicit = text(env.MAGPIE_BIN);
|
|
167
|
+
if (explicit) return isExecutable(explicit) ? explicit : null;
|
|
168
|
+
const home = os.homedir();
|
|
169
|
+
const names = process.platform === 'win32' ? ['magpie.exe', 'magpie'] : ['magpie'];
|
|
170
|
+
const candidates = [];
|
|
171
|
+
for (const dir of (env.PATH || '').split(path.delimiter)) {
|
|
172
|
+
if (!dir) continue;
|
|
173
|
+
for (const name of names) candidates.push(path.join(dir, name));
|
|
174
|
+
}
|
|
175
|
+
if (process.platform === 'darwin') {
|
|
176
|
+
candidates.push('/Applications/Magpie.app/Contents/MacOS/magpie');
|
|
177
|
+
candidates.push(path.join(home, 'Applications', 'Magpie.app', 'Contents', 'MacOS', 'magpie'));
|
|
178
|
+
candidates.push('/opt/homebrew/bin/magpie');
|
|
179
|
+
candidates.push('/usr/local/bin/magpie');
|
|
180
|
+
} else if (process.platform === 'win32') {
|
|
181
|
+
const local = text(env.LOCALAPPDATA);
|
|
182
|
+
if (local) candidates.push(path.join(local, 'Programs', 'magpie', 'magpie.exe'));
|
|
183
|
+
for (const key of ['ProgramFiles', 'ProgramW6432', 'ProgramFiles(x86)']) {
|
|
184
|
+
const base = text(env[key]);
|
|
185
|
+
if (base) candidates.push(path.join(base, 'magpie', 'magpie.exe'));
|
|
186
|
+
}
|
|
187
|
+
candidates.push(path.join(home, 'go', 'bin', 'magpie.exe'));
|
|
188
|
+
} else {
|
|
189
|
+
candidates.push(path.join(home, '.local', 'bin', 'magpie'));
|
|
190
|
+
candidates.push('/usr/local/bin/magpie');
|
|
191
|
+
candidates.push('/usr/bin/magpie');
|
|
192
|
+
candidates.push('/opt/magpie/magpie');
|
|
193
|
+
candidates.push(path.join(home, 'go', 'bin', 'magpie'));
|
|
194
|
+
}
|
|
195
|
+
for (const file of candidates) {
|
|
196
|
+
if (isExecutable(file)) return file;
|
|
197
|
+
}
|
|
198
|
+
return null;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** 这个路径上是不是一个能执行的文件。 */
|
|
202
|
+
function isExecutable(file) {
|
|
203
|
+
try {
|
|
204
|
+
const stat = fs.statSync(file);
|
|
205
|
+
if (!stat.isFile()) return false;
|
|
206
|
+
if (process.platform !== 'win32') fs.accessSync(file, fs.constants.X_OK);
|
|
207
|
+
return true;
|
|
208
|
+
} catch {
|
|
209
|
+
return false;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* 网页版这次用的 key。
|
|
215
|
+
*
|
|
216
|
+
* 干净的「只按环境变量或现造」判定(纯函数,方便单测)。`ensureWeb` 实际用的是
|
|
217
|
+
* `resolveKey`:多一层「上次留下的那份」。
|
|
218
|
+
*/
|
|
219
|
+
function webKey(env = process.env) {
|
|
220
|
+
const given = text(env.MAGPIE_WEB_KEY);
|
|
221
|
+
if (KEY_RE.test(given)) return given;
|
|
222
|
+
// 32 位十六进制,够长且只含字母数字。
|
|
223
|
+
return require('node:crypto').randomBytes(16).toString('hex');
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** 本地存 key 的文件:默认 `~/.dsh/magpie-web-key`,`MAGPIE_WEB_KEY_FILE` 可改(测试用)。 */
|
|
227
|
+
function keyFilePath(env = process.env) {
|
|
228
|
+
const override = text(env.MAGPIE_WEB_KEY_FILE);
|
|
229
|
+
if (override) return override;
|
|
230
|
+
return path.join(os.homedir(), '.dsh', 'magpie-web-key');
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** 读上次留下的 key;没有/不合规就回空串。 */
|
|
234
|
+
function readStoredKey(env = process.env) {
|
|
235
|
+
try {
|
|
236
|
+
const value = fs.readFileSync(keyFilePath(env), 'utf8').trim();
|
|
237
|
+
return KEY_RE.test(value) ? value : '';
|
|
238
|
+
} catch {
|
|
239
|
+
return '';
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** 留一份 key 给下次用(0600,只有当前用户能读)。写不进去就算了,退回「本次进程记住」,不阻断打开。 */
|
|
244
|
+
function writeStoredKey(env, key) {
|
|
245
|
+
try {
|
|
246
|
+
const file = keyFilePath(env);
|
|
247
|
+
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
248
|
+
fs.writeFileSync(file, key, { mode: 0o600 });
|
|
249
|
+
fs.chmodSync(file, 0o600);
|
|
250
|
+
} catch { /* 落盘失败不致命:startedKeys 在本次进程内仍然记得 */ }
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* 这次网页版用的 key,按优先级:用户设的 `MAGPIE_WEB_KEY` → 上次留下那份 → 现造并存下。
|
|
255
|
+
*
|
|
256
|
+
* **为什么要「上次留下那份」**:插件热重载 / 宿主重启后,内存里那份 `startedKeys` 就没了,
|
|
257
|
+
* 而网页版进程是 detached 的、还活着。若这时现造一把新 key,就跟那个已经在跑的进程对不上,
|
|
258
|
+
* 只能回「拿不到登录凭据」——用户被要求自己去设环境变量,这不是我们要的。留一份稳定的 key,
|
|
259
|
+
* 同一个进程永远对得上,用户什么都不用配(与 magpie 自己把 key 放进 service file 一个路数)。
|
|
260
|
+
*/
|
|
261
|
+
function resolveKey(env = process.env) {
|
|
262
|
+
const given = text(env.MAGPIE_WEB_KEY);
|
|
263
|
+
if (KEY_RE.test(given)) return given;
|
|
264
|
+
const stored = readStoredKey(env);
|
|
265
|
+
if (stored) return stored;
|
|
266
|
+
const key = webKey(env);
|
|
267
|
+
writeStoredKey(env, key);
|
|
268
|
+
return key;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* 带 key 请求一下,看这个端口上跑着的 magpie 认不认这把 key。
|
|
273
|
+
*
|
|
274
|
+
* 认(登录那一跳 3xx)就说明正是我们自己起的那一个;401 / 别的都算不认。用来在起进程之前
|
|
275
|
+
* 先「认领」一个还活着的旧进程,而不是又起一个新的。
|
|
276
|
+
*/
|
|
277
|
+
function probeKey(port, key, timeoutMs = 1200) {
|
|
278
|
+
return new Promise((resolve) => {
|
|
279
|
+
let done = false;
|
|
280
|
+
const finish = (ok) => {
|
|
281
|
+
if (done) return;
|
|
282
|
+
done = true;
|
|
283
|
+
try { req.destroy(); } catch { /* 已经断了 */ }
|
|
284
|
+
resolve(ok);
|
|
285
|
+
};
|
|
286
|
+
const req = http.request({
|
|
287
|
+
host: '127.0.0.1',
|
|
288
|
+
port,
|
|
289
|
+
path: `/?k=${encodeURIComponent(key)}`,
|
|
290
|
+
method: 'GET',
|
|
291
|
+
timeout: timeoutMs,
|
|
292
|
+
}, (res) => {
|
|
293
|
+
const code = res.statusCode;
|
|
294
|
+
res.resume();
|
|
295
|
+
// 302/303 是换 cookie 的那一跳;3xx 都算「key 被接受」。
|
|
296
|
+
finish(typeof code === 'number' && code >= 300 && code < 400);
|
|
297
|
+
});
|
|
298
|
+
req.on('timeout', () => finish(false));
|
|
299
|
+
req.on('error', () => finish(false));
|
|
300
|
+
req.end();
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/** 等网页版在这个端口上答起来。 */
|
|
305
|
+
async function waitForWeb(port, deadline, probe = probeWeb) {
|
|
306
|
+
for (;;) {
|
|
307
|
+
if (await probe(port, 800)) return true;
|
|
308
|
+
if (Date.now() >= deadline) return false;
|
|
309
|
+
await new Promise((resolve) => setTimeout(resolve, POLL_MS));
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* 把这台电脑上的 magpie 网页版拿给手机。
|
|
315
|
+
*
|
|
316
|
+
* 三种结局:已经有一个我们对得上 key 的在跑(认领它)、没有就起一个新的、起不来(如实说为什么)。
|
|
317
|
+
* **失败不抛**:这是一次能力探测,抛出去只会变成一条看不出哪一格缺了的 500。
|
|
318
|
+
*
|
|
319
|
+
* 认领优先于新起:网页版进程是 detached 的,插件热重载 / 宿主重启之后它常常还活着,而内存里
|
|
320
|
+
* 的 `startedKeys` 已经没了 —— 这时用稳定 key 探一下就能把它认回来,不必再起第二个,也不必
|
|
321
|
+
* 让用户去设环境变量。
|
|
322
|
+
*/
|
|
323
|
+
async function ensureWeb({
|
|
324
|
+
env = process.env,
|
|
325
|
+
spawnImpl = spawn,
|
|
326
|
+
probe = probeWeb,
|
|
327
|
+
probeKeyFn = probeKey,
|
|
328
|
+
portFreeFn = portFree,
|
|
329
|
+
startTimeoutMs = START_TIMEOUT_MS,
|
|
330
|
+
} = {}) {
|
|
331
|
+
const base = preferredPort(env);
|
|
332
|
+
const key = resolveKey(env);
|
|
333
|
+
|
|
334
|
+
// 一、先看有没有「认得出来是我们的」在跑:本进程记得的、或稳定 key 探得通的,都认领。
|
|
335
|
+
for (let offset = 0; offset < 10; offset += 1) {
|
|
336
|
+
const port = base + offset;
|
|
337
|
+
if (startedKeys.get(port) === key) {
|
|
338
|
+
return { available: true, port, key };
|
|
339
|
+
}
|
|
340
|
+
if (!(await probe(port, 500))) continue;
|
|
341
|
+
if (await probeKeyFn(port, key, 800)) {
|
|
342
|
+
startedKeys.set(port, key);
|
|
343
|
+
return { available: true, port, key };
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// 正在起的那一个:搭它的车,别再起第二个。
|
|
348
|
+
if (starting) {
|
|
349
|
+
const shared = await starting;
|
|
350
|
+
return shared;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// 二、挑一个空端口起我们自己的。
|
|
354
|
+
let port = null;
|
|
355
|
+
for (let offset = 0; offset < 10; offset += 1) {
|
|
356
|
+
const candidate = base + offset;
|
|
357
|
+
if (await portFreeFn(candidate)) { port = candidate; break; }
|
|
358
|
+
}
|
|
359
|
+
if (port == null) return { available: false, reason: 'no-free-port' };
|
|
360
|
+
|
|
361
|
+
const lastFailure = failures.get(port);
|
|
362
|
+
if (lastFailure && Date.now() - lastFailure.at < START_COOLDOWN_MS) {
|
|
363
|
+
return { available: false, reason: lastFailure.reason, port };
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
const bin = findBinary(env);
|
|
367
|
+
if (!bin) return { available: false, reason: 'no-magpie' };
|
|
368
|
+
|
|
369
|
+
starting = (async () => {
|
|
370
|
+
let child;
|
|
371
|
+
try {
|
|
372
|
+
child = spawnImpl(bin, ['web', '--addr', `127.0.0.1:${port}`, '--no-open'], {
|
|
373
|
+
env: { ...env, MAGPIE_WEB_KEY: key },
|
|
374
|
+
// 脱离父进程:插件重载 / 宿主重启不该把它带走,用户下次点开还能直接连上。
|
|
375
|
+
// stdout/stderr 丢掉:那是 magpie 自己的日志,插件不替它收(收着不读会堵住管道)。
|
|
376
|
+
detached: true,
|
|
377
|
+
stdio: 'ignore',
|
|
378
|
+
});
|
|
379
|
+
child.unref();
|
|
380
|
+
} catch (error) {
|
|
381
|
+
failures.set(port, { at: Date.now(), reason: 'spawn-failed' });
|
|
382
|
+
return { available: false, reason: 'spawn-failed', port };
|
|
383
|
+
}
|
|
384
|
+
const ok = await waitForWeb(port, Date.now() + startTimeoutMs, probe);
|
|
385
|
+
if (!ok) {
|
|
386
|
+
failures.set(port, { at: Date.now(), reason: 'start-timeout' });
|
|
387
|
+
return { available: false, reason: 'start-timeout', port };
|
|
388
|
+
}
|
|
389
|
+
failures.delete(port);
|
|
390
|
+
startedKeys.set(port, key);
|
|
391
|
+
return { available: true, port, key };
|
|
392
|
+
})().finally(() => { starting = null; });
|
|
393
|
+
return starting;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* GET /api/mobile/v1/magpie/web — 手机要打开 magpie 网页版时问这一条。
|
|
398
|
+
*
|
|
399
|
+
* 回 `{ ok: true, available, port, key, reason }`。**key 只在这里出现一次**,App 拿它拼
|
|
400
|
+
* `http://127.0.0.1:3430/?k=…` 交给预览管道 —— 之后那一跳 magpie 会换成 cookie,
|
|
401
|
+
* 地址栏里的 key 不会跟着页面里的链接到处跑。
|
|
402
|
+
*/
|
|
403
|
+
async function handleMagpieWeb(ctx, _req, res) {
|
|
404
|
+
try {
|
|
405
|
+
const entry = await ensureWeb({});
|
|
406
|
+
sendJson(res, 200, { ok: true, ...entry });
|
|
407
|
+
} catch {
|
|
408
|
+
sendJson(res, 200, { ok: true, available: false, reason: 'unexpected' });
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
module.exports = {
|
|
413
|
+
handleMagpieWeb,
|
|
414
|
+
ensureWeb,
|
|
415
|
+
probeWeb,
|
|
416
|
+
probeKey,
|
|
417
|
+
portFree,
|
|
418
|
+
pickPort,
|
|
419
|
+
findBinary,
|
|
420
|
+
webKey,
|
|
421
|
+
resolveKey,
|
|
422
|
+
readStoredKey,
|
|
423
|
+
keyFilePath,
|
|
424
|
+
preferredPort,
|
|
425
|
+
};
|
|
@@ -283,25 +283,25 @@ async function handleSessionAttachment(ctx, req, res) {
|
|
|
283
283
|
|| typeof body.attachmentId !== 'string' || !body.attachmentId) {
|
|
284
284
|
return sendJson(res, 400, { ok: false, error: { code: 'bad-request', message: '需要 { sessionId, attachmentId }' } });
|
|
285
285
|
}
|
|
286
|
-
// 直读宿主附件库(2026-10-31):登记过的 id
|
|
287
|
-
//
|
|
288
|
-
// `attachment-store.js` 的 `streamIfRemembered`:不满足就往下走原来的宿主接口。
|
|
286
|
+
// 直读宿主附件库(2026-10-31):登记过的 id 直接从本地盘流给手机,省掉「等宿主把整张读成
|
|
287
|
+
// base64 再返回」那段(宿主主线程忙时手机一个字节都收不到)。回退见 `streamIfRemembered`。
|
|
289
288
|
if (body.raw === true && await attachmentStore.streamIfRemembered(req, res, body.sessionId, body.attachmentId)) return;
|
|
290
|
-
|
|
289
|
+
// 要递一份**和盘上字节对得上**的引用:宿主会逐字段比对类型 / 字节数 / 宽高,占位值会被
|
|
290
|
+
// 挡回 `Stored attachment metadata does not match its reference.`。拼不出来(对象不在)回 404。
|
|
291
|
+
const ref = attachmentStore.refFor(body.sessionId, body.attachmentId);
|
|
292
|
+
if (!ref) return sendJson(res, 404, { ok: false, error: { code: 'not-found', message: '附件已不可读' } });
|
|
293
|
+
const { value, error } = unwrap(await callSessions(ctx, 'attachment', { sessionId: body.sessionId, attachment: ref }));
|
|
291
294
|
if (error) return sendJson(res, 400, { ok: false, error });
|
|
292
295
|
const data = value && typeof value.data === 'string' ? value.data : '';
|
|
293
|
-
//
|
|
294
|
-
// 让 App 那边把重试按钮的失败说清楚,而不是转圈到超时。
|
|
296
|
+
// 宿主回空字节=附件已被会话清理掉:如实回 404,别让 App 那边的重试按钮转圈到超时。
|
|
295
297
|
if (!data) return sendJson(res, 404, { ok: false, error: { code: 'not-found', message: '附件已不可读' } });
|
|
296
298
|
const attachment = (value && value.attachment) || {};
|
|
297
299
|
const mediaType = typeof attachment.mediaType === 'string' && attachment.mediaType ? attachment.mediaType : 'image/png';
|
|
298
|
-
// `dims: true` =
|
|
299
|
-
//
|
|
300
|
-
//
|
|
301
|
-
//
|
|
302
|
-
//
|
|
303
|
-
// base64 不进 JS 堆 —— 一张两兆的截图在手机上原本要摊着两兆七的字符串加一份解析副本。
|
|
304
|
-
// **不封包不许走这一档**:那就成了明文传输,正好把这条路本来的意义抹掉,如实回 400。
|
|
300
|
+
// `dims: true` = 只要原始宽高,不要字节:老会话里宿主没存宽高,手机得先按兜底盒占位、等字节
|
|
301
|
+
// 到了再变高(那一行一跳,上面整截就被推走)。回给它的只有两格数字,base64 不出这台电脑。
|
|
302
|
+
// `raw: true` = 要原始字节(2026-10-30):宿主给的仍是 base64,这里解成 Buffer 后按分块信封
|
|
303
|
+
// 流出去,手机那边靠原生解盘口边解边写,整张图的 base64 不进 JS 堆。
|
|
304
|
+
// **不封包不许走这一档**:那就成了明文传输,如实回 400。
|
|
305
305
|
if (body.raw === true) {
|
|
306
306
|
const opened = req && req.__dshE2ee;
|
|
307
307
|
if (!opened || !opened.keys || !opened.req) {
|
|
@@ -170,11 +170,25 @@ function addDays(day, delta) {
|
|
|
170
170
|
/** 范围起点(含);`all` 返回 null,表示不设下限。 */
|
|
171
171
|
function startDayOf(range, now) {
|
|
172
172
|
const today = dayKeyOf(now);
|
|
173
|
+
if (range === 'today') return today;
|
|
174
|
+
if (range === 'yesterday') return addDays(today, -1);
|
|
175
|
+
if (range === '3d') return addDays(today, -2);
|
|
173
176
|
if (range === '7d') return addDays(today, -6);
|
|
174
177
|
if (range === '30d') return addDays(today, -29);
|
|
175
178
|
return null;
|
|
176
179
|
}
|
|
177
180
|
|
|
181
|
+
/**
|
|
182
|
+
* 范围终点(含):只有「昨天」不看今天。
|
|
183
|
+
*
|
|
184
|
+
* 逐日补零、合计、`usageInRange` 都走它。单看昨天却把今天的量算进去,是这一档最容易错的
|
|
185
|
+
* 地方 —— 用户多半正是用着这台机器的时候打开统计页。
|
|
186
|
+
*/
|
|
187
|
+
function endDayOf(range, now) {
|
|
188
|
+
const today = dayKeyOf(now);
|
|
189
|
+
return range === 'yesterday' ? addDays(today, -1) : today;
|
|
190
|
+
}
|
|
191
|
+
|
|
178
192
|
/**
|
|
179
193
|
* `YYYY-MM-DD` → 那天本地零点的毫秒数。
|
|
180
194
|
*
|
|
@@ -501,18 +515,18 @@ function moneyOf(value) {
|
|
|
501
515
|
}
|
|
502
516
|
|
|
503
517
|
/**
|
|
504
|
-
* 一条会话在 [startDay,
|
|
518
|
+
* 一条会话在 [startDay, endDay] 里的用量。
|
|
505
519
|
*
|
|
506
520
|
* `startDay` 为 null(`all`)时直接用记录里的累计值 —— 累计值是权威,而它那份按天用量
|
|
507
521
|
* 只保留最近 `MAX_DAILY_DAYS` 天。
|
|
508
522
|
*/
|
|
509
|
-
function usageInRange(record, startDay,
|
|
523
|
+
function usageInRange(record, startDay, endDay) {
|
|
510
524
|
const billed = record.billed || emptyUsage();
|
|
511
525
|
if (!startDay) return sanitizeUsage(billed);
|
|
512
526
|
const usage = emptyUsage();
|
|
513
527
|
const daily = record.daily || {};
|
|
514
528
|
for (const [day, value] of Object.entries(daily)) {
|
|
515
|
-
if (day < startDay || day >
|
|
529
|
+
if (day < startDay || day > endDay) continue;
|
|
516
530
|
for (const key of USAGE_KEYS) usage[key] += num(value[key]);
|
|
517
531
|
}
|
|
518
532
|
return usage;
|
|
@@ -529,6 +543,7 @@ function usageInRange(record, startDay, today) {
|
|
|
529
543
|
function summarize(ledger, range, now) {
|
|
530
544
|
const today = dayKeyOf(now);
|
|
531
545
|
const startDay = startDayOf(range, now);
|
|
546
|
+
const endDay = endDayOf(range, now);
|
|
532
547
|
const allDays = Object.keys(ledger.days).sort();
|
|
533
548
|
const firstDay = startDay || allDays[0] || today;
|
|
534
549
|
|
|
@@ -544,7 +559,7 @@ function summarize(ledger, range, now) {
|
|
|
544
559
|
requests: num(source.requests),
|
|
545
560
|
turns: num(source.turns),
|
|
546
561
|
});
|
|
547
|
-
if (cursor >=
|
|
562
|
+
if (cursor >= endDay) break;
|
|
548
563
|
}
|
|
549
564
|
|
|
550
565
|
const kpi = {
|
|
@@ -589,7 +604,7 @@ function summarize(ledger, range, now) {
|
|
|
589
604
|
let composition = null;
|
|
590
605
|
let compositionSession = null;
|
|
591
606
|
for (const [sessionId, record] of Object.entries(ledger.sessions)) {
|
|
592
|
-
const usage = usageInRange(record, startDay,
|
|
607
|
+
const usage = usageInRange(record, startDay, endDay);
|
|
593
608
|
if (record.composition && num(record.lastAt) > num(compositionSession && compositionSession.lastAt)) {
|
|
594
609
|
composition = {
|
|
595
610
|
systemTokens: num(record.composition.systemTokens),
|
|
@@ -643,7 +658,7 @@ function summarize(ledger, range, now) {
|
|
|
643
658
|
let costPricedRows = 0;
|
|
644
659
|
for (const bucket of Object.values(ledger.models || {})) {
|
|
645
660
|
if (!bucket || typeof bucket !== 'object') continue;
|
|
646
|
-
const usage = usageInRange(bucket, startDay,
|
|
661
|
+
const usage = usageInRange(bucket, startDay, endDay);
|
|
647
662
|
const billedTokens = billedOf(usage);
|
|
648
663
|
const turns = num(usage.turns);
|
|
649
664
|
// 与卡片同一条规矩:范围内一个 token、一轮都没有的组合不上卡。
|
package/lib/mobile/stats.js
CHANGED
|
@@ -1,21 +1,25 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* GET /api/mobile/v1/stats/overview?range=7d|30d|all[&fresh=1](token)
|
|
4
|
+
* GET /api/mobile/v1/stats/overview?range=today|yesterday|3d|7d|30d|all[&fresh=1](token)
|
|
5
5
|
*
|
|
6
6
|
* 统计页的唯一接口:先采一轮(水位差记账),再把账本按范围折算成页面要的那份。
|
|
7
7
|
*
|
|
8
8
|
* 采集失败**不**让整页报错:账本里已有的数字照样是真的,宁可让用户看到一份偏旧的统计,
|
|
9
9
|
* 也不要为了「这次没采上」把整页变成错误页。要重新采就用 `fresh=1`(下拉刷新那条)。
|
|
10
10
|
*
|
|
11
|
-
*
|
|
11
|
+
* 范围词是 App 与这里共用的一份词汇表(`app/src/types/stats.ts` 的 `StatsRange`):
|
|
12
|
+
* 「今天 / 昨天」是单天窗口,`3d` / `7d` / `30d` 是含今天往前数的天窗,`all` 不设下限。
|
|
13
|
+
* 日历口径只有 `stats-ledger.cjs` 的 `startDayOf` / `endDayOf` 一份,App 不自己算。
|
|
14
|
+
* 认不出来的词一律按 `7d`(老 App 照旧发那三档);**新范围词遇到老插件就会落到这一条**,
|
|
15
|
+
* 所以 App 拿回包里的 `range` 与请求的对不对得上当作「插件较旧」的判据(见 A2-60)。
|
|
12
16
|
*/
|
|
13
17
|
|
|
14
18
|
const { sendJson } = require('../http/routing.js');
|
|
15
19
|
const { collectStats, DEFAULT_BUDGET_MS } = require('./stats-collect.cjs');
|
|
16
20
|
const ledger = require('./stats-ledger.cjs');
|
|
17
21
|
|
|
18
|
-
const RANGES = new Set(['7d', '30d', 'all']);
|
|
22
|
+
const RANGES = new Set(['today', 'yesterday', '3d', '7d', '30d', 'all']);
|
|
19
23
|
|
|
20
24
|
function normalizeRange(value) {
|
|
21
25
|
return typeof value === 'string' && RANGES.has(value) ? value : '7d';
|