dsh-mobile-plugin 0.5.19 → 0.5.21
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 +358 -28
- 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/monitor.js +5 -2
- package/lib/mobile/session-flags.js +61 -8
- 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 +12 -11
- package/lib/routes/canvas-desktop.js +196 -0
- package/lib/routes/control-plane.js +49 -0
- package/lib/routes/mobile-api.js +6 -0
- package/lib/session-cleanup.cjs +22 -7
- package/package.json +1 -1
|
@@ -25,6 +25,10 @@ const path = require('node:path');
|
|
|
25
25
|
|
|
26
26
|
const { dshHome } = require('../shared/paths.js');
|
|
27
27
|
const { chunksHeaders, pipeEncrypted } = require('../shared/e2ee-chunks.js');
|
|
28
|
+
const { imageSizeOf, sniffMediaType } = require('./local-images.js');
|
|
29
|
+
|
|
30
|
+
/** 量宽高只需要文件头;JPEG 的 SOF 段可能在几百字节之后,64KB 覆盖绝大多数实拍图。 */
|
|
31
|
+
const HEAD_BYTES = 64 * 1024;
|
|
28
32
|
|
|
29
33
|
/** 一个会话最多记多少个附件 id(会话里几千张图也不该无限涨)。 */
|
|
30
34
|
const MAX_IDS_PER_SESSION = 4096;
|
|
@@ -36,29 +40,52 @@ const TTL_MS = 30 * 60 * 1000;
|
|
|
36
40
|
/** sessionId -> { at, entries: Map<attachmentId, { mediaType, at }> } */
|
|
37
41
|
const SESSIONS = new Map();
|
|
38
42
|
|
|
39
|
-
/**
|
|
40
|
-
|
|
43
|
+
/**
|
|
44
|
+
* 宿主附件编号的形状:`sha256:<64 位小写十六进制>`(见宿主 `attachment-local` 的 `ID_PATTERN`)。
|
|
45
|
+
*
|
|
46
|
+
* **前缀不能省**:宿主发的编号带 `sha256:`,磁盘上的文件名却是去掉前缀的那段哈希。以前这里
|
|
47
|
+
* 只认裸哈希,于是每个真实编号都被当成「不是我们发过的」,直读这条路一次都没生效过。
|
|
48
|
+
*/
|
|
49
|
+
const OBJECT_ID = /^sha256:([0-9a-f]{64})$/;
|
|
41
50
|
|
|
42
51
|
function nowMs() {
|
|
43
52
|
return Date.now();
|
|
44
53
|
}
|
|
45
54
|
|
|
55
|
+
/** 从附件编号里取出裸哈希(磁盘文件名那段);形状不对回 null。 */
|
|
56
|
+
function hexOf(attachmentId) {
|
|
57
|
+
if (typeof attachmentId !== 'string') return null;
|
|
58
|
+
const match = OBJECT_ID.exec(attachmentId);
|
|
59
|
+
return match ? match[1] : null;
|
|
60
|
+
}
|
|
61
|
+
|
|
46
62
|
/**
|
|
47
63
|
* 附件 id → 宿主附件库里的绝对路径。
|
|
48
64
|
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
65
|
+
* 宿主 `normalizedImagePath` 的落盘形状是 `objects/<前两位>/<完整 64 位哈希>` —— 子目录取前两位、
|
|
66
|
+
* **文件名仍是完整哈希**(不是去掉前两位的那段)。曾经拼成 `objects/<前两位>/<其余>`,于是每一份
|
|
67
|
+
* 真实附件都找不到、直读与现量元数据全部落空(取图恒 404)。
|
|
68
|
+
*
|
|
69
|
+
* 形状不对(不是 `sha256:<64 位小写十六进制>`)直接回 null:这条路径要拼进
|
|
70
|
+
* `createReadStream`,校验放在这里、而不是靠调用方记得先校验。
|
|
51
71
|
*/
|
|
52
72
|
function objectPathOf(attachmentId) {
|
|
53
|
-
|
|
73
|
+
const hex = hexOf(attachmentId);
|
|
74
|
+
if (!hex) return null;
|
|
54
75
|
const root = path.join(dshHome(), 'attachments', 'v1', 'objects');
|
|
55
|
-
return path.join(root,
|
|
76
|
+
return path.join(root, hex.slice(0, 2), hex);
|
|
56
77
|
}
|
|
57
78
|
|
|
58
|
-
/**
|
|
59
|
-
|
|
79
|
+
/**
|
|
80
|
+
* 记账:这个附件我们刚在 `sessionId` 的消息里下发过(手机接下来就可能来取字节)。
|
|
81
|
+
*
|
|
82
|
+
* 顺手把**完整引用**(媒体类型 / 字节数 / 宽高 / 名字)也记下来。回退去问宿主时,宿主新版
|
|
83
|
+
* 的取附件接口要的是整份 `ImageAttachmentRef`,只有编号它不收。取字节本身宿主只用到编号,
|
|
84
|
+
* 但请求形状这五个字段都必填。
|
|
85
|
+
*/
|
|
86
|
+
function remember(sessionId, attachmentId, ref) {
|
|
60
87
|
if (typeof sessionId !== 'string' || !sessionId) return;
|
|
61
|
-
if (
|
|
88
|
+
if (!hexOf(attachmentId)) return;
|
|
62
89
|
const at = nowMs();
|
|
63
90
|
let slot = SESSIONS.get(sessionId);
|
|
64
91
|
if (!slot) {
|
|
@@ -72,11 +99,16 @@ function remember(sessionId, attachmentId, mediaType) {
|
|
|
72
99
|
}
|
|
73
100
|
}
|
|
74
101
|
slot.at = at;
|
|
75
|
-
|
|
76
|
-
slot.entries.set(attachmentId, {
|
|
102
|
+
const entry = {
|
|
77
103
|
at,
|
|
78
|
-
mediaType: typeof mediaType === 'string' && mediaType ? mediaType : 'application/octet-stream',
|
|
79
|
-
}
|
|
104
|
+
mediaType: ref && typeof ref.mediaType === 'string' && ref.mediaType ? ref.mediaType : 'application/octet-stream',
|
|
105
|
+
};
|
|
106
|
+
if (ref && Number.isFinite(ref.bytes)) entry.bytes = ref.bytes;
|
|
107
|
+
if (ref && Number.isFinite(ref.width)) entry.width = ref.width;
|
|
108
|
+
if (ref && Number.isFinite(ref.height)) entry.height = ref.height;
|
|
109
|
+
if (ref && typeof ref.name === 'string' && ref.name) entry.name = ref.name;
|
|
110
|
+
slot.entries.delete(attachmentId);
|
|
111
|
+
slot.entries.set(attachmentId, entry);
|
|
80
112
|
while (slot.entries.size > MAX_IDS_PER_SESSION) {
|
|
81
113
|
const oldest = slot.entries.keys().next().value;
|
|
82
114
|
if (oldest === undefined) break;
|
|
@@ -84,7 +116,9 @@ function remember(sessionId, attachmentId, mediaType) {
|
|
|
84
116
|
}
|
|
85
117
|
}
|
|
86
118
|
|
|
87
|
-
/**
|
|
119
|
+
/**
|
|
120
|
+
* 这个 id 是不是我们为这条会话下发过的;是就带上当时的完整引用(少 `attachmentId` 与时间戳)。
|
|
121
|
+
*/
|
|
88
122
|
function lookup(sessionId, attachmentId, now = nowMs()) {
|
|
89
123
|
if (typeof sessionId !== 'string' || !sessionId) return null;
|
|
90
124
|
if (typeof attachmentId !== 'string' || !attachmentId) return null;
|
|
@@ -100,7 +134,8 @@ function lookup(sessionId, attachmentId, now = nowMs()) {
|
|
|
100
134
|
slot.entries.delete(attachmentId);
|
|
101
135
|
return null;
|
|
102
136
|
}
|
|
103
|
-
|
|
137
|
+
const { at: _at, ...ref } = hit;
|
|
138
|
+
return ref;
|
|
104
139
|
}
|
|
105
140
|
|
|
106
141
|
/** 打开一条只读流(调用方自己负责在连接关掉时 destroy)。 */
|
|
@@ -139,6 +174,66 @@ async function streamIfRemembered(req, res, sessionId, attachmentId) {
|
|
|
139
174
|
return true;
|
|
140
175
|
}
|
|
141
176
|
|
|
177
|
+
/**
|
|
178
|
+
* 从盘上这份附件对象量出它的真实元数据(类型 / 字节数 / 宽高)。
|
|
179
|
+
*
|
|
180
|
+
* 宿主取附件时会读同一份对象文件、用 sharp 探一遍,再跟我们递过去的引用**逐字段比对**:
|
|
181
|
+
* 类型、字节数、宽高任一对不上就回 `Stored attachment metadata does not match its reference.`
|
|
182
|
+
* (400)。所以引用不能瞎填占位值,必须和盘上那份字节对上。
|
|
183
|
+
*
|
|
184
|
+
* 附件存的是**归一化后**的字节(携带 EXIF 的图会被重编码、朝向烘进像素),因此文件头读出来的
|
|
185
|
+
* 就是宿主 `probeImage` 会量到的同一组数;`imageSizeOf` 本就按 EXIF 5–8 对调,口径一致。
|
|
186
|
+
* 读不出(不是位图 / 文件不在)回 null,由调用方决定怎么回。
|
|
187
|
+
*/
|
|
188
|
+
function probeObject(attachmentId) {
|
|
189
|
+
const target = objectPathOf(attachmentId);
|
|
190
|
+
if (!target) return null;
|
|
191
|
+
let stat;
|
|
192
|
+
try {
|
|
193
|
+
stat = fs.statSync(target);
|
|
194
|
+
} catch {
|
|
195
|
+
return null;
|
|
196
|
+
}
|
|
197
|
+
if (!stat.isFile() || stat.size <= 0) return null;
|
|
198
|
+
let fd = null;
|
|
199
|
+
let head;
|
|
200
|
+
try {
|
|
201
|
+
fd = fs.openSync(target, 'r');
|
|
202
|
+
const length = Math.min(stat.size, HEAD_BYTES);
|
|
203
|
+
head = Buffer.alloc(length);
|
|
204
|
+
fs.readSync(fd, head, 0, length, 0);
|
|
205
|
+
} catch {
|
|
206
|
+
return null;
|
|
207
|
+
} finally {
|
|
208
|
+
if (fd !== null) {
|
|
209
|
+
try { fs.closeSync(fd); } catch { /* 关不上就算了,元数据已经读到 */ }
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
const mediaType = sniffMediaType(head);
|
|
213
|
+
const size = imageSizeOf(head);
|
|
214
|
+
if (!mediaType || !size || !(size.width > 0) || !(size.height > 0)) return null;
|
|
215
|
+
return { mediaType, bytes: stat.size, width: size.width, height: size.height };
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* 给宿主取附件接口拼一份完整 `ImageAttachmentRef`。
|
|
220
|
+
*
|
|
221
|
+
* 记过账的用记下的整份引用;没记到的(过期 / 超上限 / 插件重启后没重新登记 / 排队条里透传的图)
|
|
222
|
+
* 就从盘上那份对象现量一遍 —— 宿主校验要的就是盘上这份字节的真实元数据,占位值一定对不上。
|
|
223
|
+
* 对象文件不在或量不出时回 null(调用方回 404),不再拿假引用去碰宿主的那条校验。
|
|
224
|
+
*/
|
|
225
|
+
function refFor(sessionId, attachmentId) {
|
|
226
|
+
const known = lookup(sessionId, attachmentId);
|
|
227
|
+
if (known && Number.isFinite(known.bytes) && Number.isFinite(known.width) && Number.isFinite(known.height)) {
|
|
228
|
+
return { attachmentId, ...known };
|
|
229
|
+
}
|
|
230
|
+
const probed = probeObject(attachmentId);
|
|
231
|
+
if (probed) {
|
|
232
|
+
return { attachmentId, ...probed, ...(known && typeof known.name === 'string' ? { name: known.name } : {}) };
|
|
233
|
+
}
|
|
234
|
+
return known ? { attachmentId, ...known } : null;
|
|
235
|
+
}
|
|
236
|
+
|
|
142
237
|
/** 测试用:清空记账。 */
|
|
143
238
|
function reset() {
|
|
144
239
|
SESSIONS.clear();
|
|
@@ -151,6 +246,7 @@ module.exports = {
|
|
|
151
246
|
objectPathOf,
|
|
152
247
|
remember,
|
|
153
248
|
lookup,
|
|
249
|
+
refFor,
|
|
154
250
|
openObject,
|
|
155
251
|
streamIfRemembered,
|
|
156
252
|
reset,
|
|
@@ -58,12 +58,16 @@ async function resolveEventAttachments(ctx, sessionId, event, opts = {}) {
|
|
|
58
58
|
if (referencesOnly) {
|
|
59
59
|
// 顺便记一笔:手机接下来就可能拿着这个 id 来取字节。记下来之后那条路可以直接从
|
|
60
60
|
// 宿主附件库流给它,不必再等宿主把整张图读成 base64(见 attachment-store.js)。
|
|
61
|
-
|
|
61
|
+
// 记的是**整份引用**:回退问宿主时要的正是这个形状,光有 id 不够。
|
|
62
|
+
attachmentStore.remember(sessionId, attachmentId, {
|
|
63
|
+
...block.attachment,
|
|
64
|
+
mediaType: block.attachment.mediaType || block.mediaType,
|
|
65
|
+
});
|
|
62
66
|
return block;
|
|
63
67
|
}
|
|
64
68
|
let result;
|
|
65
69
|
try {
|
|
66
|
-
result = unwrap(await callSessions(ctx, 'attachment', { sessionId,
|
|
70
|
+
result = unwrap(await callSessions(ctx, 'attachment', { sessionId, attachment: block.attachment }));
|
|
67
71
|
} catch {
|
|
68
72
|
return dataUnavailableBlock(block);
|
|
69
73
|
}
|
|
@@ -57,7 +57,13 @@ const ENDPOINTS = {
|
|
|
57
57
|
'sessions.create': inv('session', 'create'),
|
|
58
58
|
'sessions.search': inv('session', 'search'),
|
|
59
59
|
'sessions.selectModel': inv('session', 'selectModel'),
|
|
60
|
-
|
|
60
|
+
// 宿主 `SessionAttachmentRequest` 是 `{ sessionId, attachment: ImageAttachmentRef }`,
|
|
61
|
+
// `attachment` 必填且内含 attachmentId / mediaType / bytes / width / height。只传
|
|
62
|
+
// `{ sessionId, attachmentId }` 会在 strict codec 的 parse 那一步被挡回
|
|
63
|
+
// `wire field "request" failed boundary validation`(400),App 把 400 catch 掉后图片就没了。
|
|
64
|
+
'sessions.attachment': inv('session', 'attachment', (p) => ({
|
|
65
|
+
request: { sessionId: p && p.sessionId, attachment: p && p.attachment },
|
|
66
|
+
})),
|
|
61
67
|
'sessions.updateQueue': inv('session', 'updateQueue'),
|
|
62
68
|
// 宿主的 fork 请求体只有 sessionId / atSeq,increaseTitle 在 0.2.0 已删除。
|
|
63
69
|
'sessions.fork': inv('session', 'fork', (p) => ({ request: pick(p, ['sessionId', 'atSeq']) })),
|
|
@@ -467,4 +467,4 @@ function inRanges(index, ranges) {
|
|
|
467
467
|
return false;
|
|
468
468
|
}
|
|
469
469
|
|
|
470
|
-
module.exports = { inlineTextImages, createTextImageBudget, localImagePathOf, hasRelativeImage, localImageOf, registerLocalImage, imageSizeOf, clearLocalImages };
|
|
470
|
+
module.exports = { inlineTextImages, createTextImageBudget, localImagePathOf, hasRelativeImage, localImageOf, registerLocalImage, imageSizeOf, sniffMediaType, clearLocalImages };
|
|
@@ -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
|
+
};
|
package/lib/mobile/monitor.js
CHANGED
|
@@ -26,7 +26,7 @@ const {
|
|
|
26
26
|
const { updateCompletionStates, confirmCompletionFromHistory } = require('./completion.js');
|
|
27
27
|
const { lastTurnOf } = require('./session-last-turn.js');
|
|
28
28
|
const { sessionPreviewFor } = require('./session-preview.js');
|
|
29
|
-
const {
|
|
29
|
+
const { archivedSessionIdsForMonitor, pendingInteractionOf, pendingCardOf } = require('./session-flags.js');
|
|
30
30
|
|
|
31
31
|
/**
|
|
32
32
|
* 一条会话在监控视图里的那几格。
|
|
@@ -114,7 +114,10 @@ async function buildMonitorState(ctx, deviceKey, extraItems = null) {
|
|
|
114
114
|
|
|
115
115
|
const generation = completionGenerationOf(ctx, deviceKey);
|
|
116
116
|
const deleted = deletedSessionIdsOf(ctx);
|
|
117
|
-
|
|
117
|
+
// 归档集合用「记住上一次真读到的那份」那个口径,不用列表那条 fail-open:
|
|
118
|
+
// 这一条决定要不要在后台叫醒人,一次读不到就按「没归档」画会让已归档的会话重新进清单、
|
|
119
|
+
// 被当成刚跑完再响一遍(见 session-flags.js 的 archivedSessionIdsForMonitor)。
|
|
120
|
+
const archived = await archivedSessionIdsForMonitor(ctx);
|
|
118
121
|
// 子代理会话不参与后台提醒:它们是主轮内部的执行单元,App 侧栏也不列;把它们算进来
|
|
119
122
|
// 会在后台弹一堆「子代理跑完了」。
|
|
120
123
|
const live = summaries.filter((s) => s && typeof s.sessionId === 'string' && s.sessionId
|