dsh-mobile-plugin 0.5.16 → 0.5.18

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.
@@ -0,0 +1,63 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * 「让 AI 操作手机」这一整块的装配入口。
5
+ *
6
+ * 一条调用做三件事:建常驻连接、把模型那组动作工具注册上、在插件卸载时收掉连接。
7
+ *
8
+ * **路由不在这里挂**:三条普通路由与那条升级口写进了 `routes/mobile-api.js` 那张表 ——
9
+ * 那张表的唯一用途就是「一眼确认 App 打的地址到底注册没有」,`test/mobile-routes.test.cjs`
10
+ * 拿 App 的 `apiPaths.ts` 反向核对它。挂在这边等于绕开那道对账,App 侧一改名就静默 404。
11
+ *
12
+ * 服务必须**每个插件实例一份**:连接跟着插件走,热替换之后旧实例没人关就会漏一条手机连接。
13
+ * 所以建与关成对出现,关挂在 `ctx.effect` 上。
14
+ */
15
+
16
+ const { createPhoneService } = require('./service.js');
17
+ const { installPhoneTools } = require('./tools.js');
18
+ const { listDevices, describeDevice } = require('./routes.js');
19
+ const binding = require('./binding.js');
20
+
21
+ /**
22
+ * @param {object} ctx 插件上下文
23
+ * @param {object} deps
24
+ * @param {(req: object) => boolean} deps.authorized 现有那条鉴权判定
25
+ * @param {object} [deps.credentials] 凭据库(报设备名要用它的 `listDevices`)
26
+ * @param {string} [deps.instanceId]
27
+ * @param {string} [deps.name] 插件名(日志前缀)
28
+ * @returns {object} 这一实例的手机操作服务(装配层转手交给路由表)
29
+ */
30
+ function mountPhoneControl(ctx, deps) {
31
+ const log = (message) => ctx.logger?.info?.(`${deps.name || 'dsh-mobile-plugin'}: %s`, message);
32
+ const service = createPhoneService({ authorized: deps.authorized, onLog: log });
33
+ const routeDeps = {
34
+ getService: () => service,
35
+ credentials: deps.credentials,
36
+ instanceId: deps.instanceId,
37
+ };
38
+
39
+ // 工具:缺 tools 注入的 profile 只少这一组,别把整块装配掀掉(与 extensions / cleanup 同口径)。
40
+ try {
41
+ installPhoneTools(ctx, {
42
+ getService: () => service,
43
+ listDevices: () => listDevices(routeDeps).map((row) => ({
44
+ deviceId: row.deviceId,
45
+ name: row.name,
46
+ online: row.online,
47
+ })),
48
+ describeDevice: (deviceId) => describeDevice(routeDeps, deviceId),
49
+ });
50
+ } catch (error) {
51
+ const message = error && error.message ? error.message : String(error);
52
+ if (!message.includes('without inject')) throw error;
53
+ }
54
+
55
+ ctx.effect(() => () => service.close(), `${deps.name || 'dsh-mobile-plugin'}: phone control`);
56
+ // 路由表那边要凭据库与实例号来报设备名,而它只拿得到这个服务(装配层只转交一个对象)。
57
+ // 挂在服务上而不是再走一遍依赖注入:多一处注入就多一处能不同步的地方。
58
+ service.credentials = deps.credentials;
59
+ service.instanceId = deps.instanceId;
60
+ return service;
61
+ }
62
+
63
+ module.exports = { mountPhoneControl, binding };
@@ -0,0 +1,168 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * 「让 AI 操作手机」的三条普通状态路由(动作字节流在 `phone/service.js` 的升级口上)。
5
+ *
6
+ * - `GET /api/mobile/v1/phone/status`:这台电脑上「操作手机」这一侧的状态 —— 服务在不在、
7
+ * 哪几台手机连着、这条会话绑的是哪台。App 的配对向导与设置页读它。
8
+ * - `GET /api/mobile/v1/phone/devices`:此刻认识哪几台手机。模型改绑、用户看「另一台」时
9
+ * 都读这份清单。
10
+ * - `POST /api/mobile/v1/phone/unbind`:解除。带 `sessionId` 只清这条会话的绑定;不带就断开
11
+ * 所有连接并清掉所有指向它们的绑定。
12
+ *
13
+ * 三条都只读状态或做收尾,**没有一条能往手机上发动作**:发动作只有 `phone/tools.js` 那条路,
14
+ * 且只由模型在会话里触发。这个分界是刻意的 —— 路由服务的是「正在看设置页的人」,工具服务的
15
+ * 是「正在替用户干活的模型」。
16
+ *
17
+ * 装配方式:路由表(`routes/mobile-api.js`)只认「路径 → handler」,所以这里给的是
18
+ * `createPhoneRoutes(deps)` 造出来的一组 handler,`deps` 由装配层从 `phone/index.js` 转来。
19
+ */
20
+
21
+ const { sendJson, readJsonBody } = require('../../shared/http-util');
22
+ const binding = require('./binding.js');
23
+
24
+ /** 设备标题:机型优先,没有机型才用 deviceId 的短尾巴。与凭据面板同一口径。 */
25
+ function deviceLabel(row) {
26
+ const model = row && typeof row.model === 'string' ? row.model.trim() : '';
27
+ if (model) return row.suffix ? `${model} · ${row.suffix}` : model;
28
+ const id = row && typeof row.deviceId === 'string' ? row.deviceId : '';
29
+ return id ? `手机 ${id.slice(0, 8)}` : '一台没认出身份的手机';
30
+ }
31
+
32
+ /**
33
+ * 这台电脑认识哪几台手机、哪几台此刻连着。
34
+ *
35
+ * 名字从凭据库拿(那里才是「这台电脑认识哪些手机」的真值),拿不到就退回 id 的短尾巴;
36
+ * 已经连上来但凭据库里没有的(老记录、刚重置过的库)也照样报出来 —— 否则模型看不见一台
37
+ * 明明能用的手机。
38
+ */
39
+ function listDevices(deps) {
40
+ const known = [];
41
+ try {
42
+ if (deps.credentials && deps.instanceId) {
43
+ for (const row of deps.credentials.listDevices(deps.instanceId)) known.push(row);
44
+ }
45
+ } catch {
46
+ // 凭据库读不出来不影响「谁在线」这件事:退化成只报 id。
47
+ }
48
+ const service = deps.getService ? deps.getService() : null;
49
+ const online = new Map();
50
+ if (service && service.devices) for (const [deviceId, link] of service.devices) online.set(deviceId, link);
51
+
52
+ const rows = new Map();
53
+ for (const row of known) {
54
+ const live = online.get(row.deviceId);
55
+ rows.set(row.deviceId, {
56
+ deviceId: row.deviceId,
57
+ name: deviceLabel(row),
58
+ model: row.model || '',
59
+ platform: row.platform || '',
60
+ osVersion: row.osVersion || '',
61
+ appVersion: row.appVersion || '',
62
+ online: Boolean(live),
63
+ lastSeenAt: row.lastSeenAt || 0,
64
+ });
65
+ if (live) online.delete(row.deviceId);
66
+ }
67
+ for (const [deviceId, link] of online) {
68
+ const device = link.device || {};
69
+ rows.set(deviceId, {
70
+ deviceId,
71
+ name: deviceLabel({ deviceId, model: device.model }),
72
+ model: device.model || '',
73
+ platform: device.platform || '',
74
+ osVersion: device.osVersion || '',
75
+ appVersion: device.appVersion || '',
76
+ online: true,
77
+ lastSeenAt: Date.now(),
78
+ });
79
+ }
80
+ return [...rows.values()].sort((a, b) => (Number(b.online) - Number(a.online)) || (b.lastSeenAt - a.lastSeenAt));
81
+ }
82
+
83
+ function describeDevice(deps, deviceId) {
84
+ const row = listDevices(deps).find((item) => item.deviceId === deviceId);
85
+ return row ? row.name : deviceId;
86
+ }
87
+
88
+ /** GET /phone/status?sessionId=… */
89
+ async function handlePhoneStatus(deps, _ctx, req, res) {
90
+ const url = new URL(req.url || '/', 'http://x');
91
+ const sessionId = (url.searchParams.get('sessionId') || '').trim();
92
+ const service = deps.getService ? deps.getService() : null;
93
+ const devices = listDevices(deps);
94
+ const payload = {
95
+ ok: true,
96
+ running: Boolean(service),
97
+ online: devices.filter((row) => row.online).map((row) => row.deviceId),
98
+ devices,
99
+ };
100
+ if (sessionId) {
101
+ const bound = binding.boundDeviceOf(sessionId);
102
+ payload.binding = {
103
+ deviceId: bound || '',
104
+ name: bound ? describeDevice(deps, bound) : '',
105
+ lastSender: binding.lastSenderOf(sessionId) || '',
106
+ };
107
+ }
108
+ sendJson(res, 200, payload);
109
+ }
110
+
111
+ /** GET /phone/devices */
112
+ async function handlePhoneDevices(deps, _ctx, _req, res) {
113
+ sendJson(res, 200, { ok: true, devices: listDevices(deps) });
114
+ }
115
+
116
+ /**
117
+ * POST /phone/unbind —— `{ sessionId?, deviceId? }`
118
+ *
119
+ * 不带参数是「这台电脑上操作手机这件事整个解除」:断开所有连接、清掉所有指向它们的绑定。
120
+ * 带 `sessionId` 只清这一条会话的绑定(用户只是想让这条对话别再用那台手机)。
121
+ *
122
+ * 卸掉手机上那枚 adb 钥匙这一步只能由手机侧自己做(壳在手机上,宿主碰不到它),所以回包里
123
+ * 如实写 `localReset: 'requested'`,界面据此提示「回手机上确认一下」。
124
+ */
125
+ async function handlePhoneUnbind(deps, _ctx, req, res) {
126
+ const body = await readJsonBody(req);
127
+ const sessionId = body && typeof body.sessionId === 'string' ? body.sessionId.trim() : '';
128
+ const deviceId = body && typeof body.deviceId === 'string' ? body.deviceId.trim() : '';
129
+ if (sessionId) {
130
+ return sendJson(res, 200, {
131
+ ok: true,
132
+ clearedSession: binding.clearBinding(sessionId),
133
+ disconnected: [],
134
+ localReset: '',
135
+ });
136
+ }
137
+ const service = deps.getService ? deps.getService() : null;
138
+ const targets = deviceId ? [deviceId] : listDevices(deps).map((row) => row.deviceId);
139
+ for (const id of targets) service?.kick?.(id);
140
+ const cleared = targets.reduce((sum, id) => sum + binding.clearBindingForDevice(id), 0);
141
+ sendJson(res, 200, { ok: true, clearedSession: false, clearedBindings: cleared, disconnected: targets, localReset: 'requested' });
142
+ }
143
+
144
+ /**
145
+ * 造出路由表要的那组 handler。
146
+ *
147
+ * @param {object} deps
148
+ * @param {() => object|null} deps.getService 取值器(服务随插件重载整体替换)
149
+ * @param {object} [deps.credentials] 凭据库
150
+ * @param {string} [deps.instanceId]
151
+ */
152
+ function createPhoneRoutes(deps) {
153
+ return {
154
+ status: (ctx, req, res) => handlePhoneStatus(deps, ctx, req, res),
155
+ devices: (ctx, req, res) => handlePhoneDevices(deps, ctx, req, res),
156
+ unbind: (ctx, req, res) => handlePhoneUnbind(deps, ctx, req, res),
157
+ };
158
+ }
159
+
160
+ module.exports = {
161
+ createPhoneRoutes,
162
+ handlePhoneStatus,
163
+ handlePhoneDevices,
164
+ handlePhoneUnbind,
165
+ listDevices,
166
+ describeDevice,
167
+ deviceLabel,
168
+ };
@@ -0,0 +1,327 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * 手机操作通道的宿主端:一台手机一条常驻连接,宿主在这条连接上发动作、收结果。
5
+ *
6
+ * ## 为什么是「一条连接对应一台手机」而不是一条会话
7
+ *
8
+ * 一块虚拟屏一个任务,而任务属于会话;但连接属于手机 —— 同一台手机上可能同时跑两个会话的
9
+ * 任务,而断线重连应当一次接回全部。所以键是 deviceId,帧里带 sessionId 让手机自己分派。
10
+ *
11
+ * ## 谁发起、往哪走
12
+ *
13
+ * 手机在户外、电脑在任何地方,两者只能互连在**已有的加密通道**上(局域网直连 / 公网中转
14
+ * 都落地到同一个 webServer 升级口)。所以这条和预览、终端同一条机制:手机建 WebSocket,
15
+ * 宿主完成鉴权后接管。手机上不额外开监听端口。
16
+ *
17
+ * ## 帧
18
+ *
19
+ * 文本帧,JSON。手机 → 宿主:
20
+ * - `{t:'hello', device:{model, platform, osVersion, appVersion}, capabilities:[…]}`
21
+ * - `{t:'result', id, ok:true, data}` / `{t:'result', id, ok:false, error:{code,message}}`
22
+ * - `{t:'event', kind:'needs-human'|'progress', sessionId, …}` —— 暂只记账,不转发
23
+ *
24
+ * 宿主 → 手机:
25
+ * - `{t:'op', id, sessionId, action, args}`
26
+ * - `{t:'cancel', id}`
27
+ *
28
+ * 动作名是封闭的一小张表(见 `ACTIONS`);手机端认不出的动作回 `unsupported`,宿主不会因为
29
+ * 版本错位把整条连接打断。
30
+ *
31
+ * ## 超时与断开
32
+ *
33
+ * 每个 op 带自己的预算(看屏宽松、点击紧凑)。超时按 `timeout` 失败返回,并把 `cancel` 发
34
+ * 过去 —— 手机那边可能正卡在一个慢动作里,不撤销就会在下一个 op 上错位。连接断开时,所有
35
+ * 在途 op 立刻以 `offline` 结束,调用方(工具的 execute)据此给模型一句说清楚的话。
36
+ */
37
+
38
+ const { randomUUID } = require('node:crypto');
39
+ const { WebSocketServer } = require('ws');
40
+
41
+ /** 一条帧的上限:截图是 base64 塞在 JSON 里的,留足余量;更大的东西手机不会发。 */
42
+ const MAX_PAYLOAD = 12 * 1024 * 1024;
43
+ /** 心跳:手机每 20 秒至少要有一次 ping/pong 往返,否则按掉线处理(等它自己重连)。 */
44
+ const HEARTBEAT_MS = 20_000;
45
+ /** 单次 op 的默认预算;每个动作另有自己的一档(见 ACTIONS)。 */
46
+ const DEFAULT_OP_TIMEOUT_MS = 30_000;
47
+
48
+ /**
49
+ * 动作表:`action → 预算(ms)`。
50
+ *
51
+ * 它不是「白名单」这种可有可无的礼貌:手机端按这张表实现、宿主按这张表校验,多一个动作
52
+ * 就是扩一次能力面,两边必须同时改。**这里永远不会有通用执行** —— 没有 `shell`、没有
53
+ * `exec`、没有可以拼参数的入口。
54
+ */
55
+ const ACTIONS = new Map([
56
+ ['screen.read', 15_000],
57
+ ['screen.screenshot', 20_000],
58
+ ['tap', 15_000],
59
+ ['type', 20_000],
60
+ ['swipe', 20_000],
61
+ ['key', 15_000],
62
+ ['app.open', 30_000],
63
+ ['app.close', 15_000],
64
+ ['app.list', 20_000],
65
+ ['wait', 30_000],
66
+ ['task.release', 20_000],
67
+ ]);
68
+
69
+ function text(value) {
70
+ return typeof value === 'string' ? value.trim() : '';
71
+ }
72
+
73
+ function statusOf(socket) {
74
+ return socket && socket.readyState === 1;
75
+ }
76
+
77
+ /**
78
+ * @param {object} deps
79
+ * @param {(req: object) => boolean} deps.authorized 现有那条鉴权判定(`mobile/auth.js`)
80
+ * @param {(message: string) => void} [deps.onLog]
81
+ */
82
+ function createPhoneService({ authorized, onLog } = {}) {
83
+ /** deviceId → 连接。同一台手机重连时新的顶掉旧的(旧的那条先被关掉)。 */
84
+ const devices = new Map();
85
+ let closed = false;
86
+ let seq = 0;
87
+
88
+ const log = (message) => { try { onLog?.(message); } catch { /* 日志失败不影响搬运 */ } };
89
+
90
+ const wss = new WebSocketServer({ noServer: true, perMessageDeflate: false, maxPayload: MAX_PAYLOAD });
91
+
92
+ function nextId() {
93
+ seq += 1;
94
+ return `p${seq}`;
95
+ }
96
+
97
+ /** 关掉一条连接并结算它的在途 op。 */
98
+ function drop(deviceId, reason) {
99
+ const link = devices.get(deviceId);
100
+ if (!link) return;
101
+ devices.delete(deviceId);
102
+ for (const [, pending] of link.pending) {
103
+ pending.settleErr({
104
+ code: reason,
105
+ message: reason === 'offline'
106
+ ? '手机已经断开连接'
107
+ : '这台手机重新连上了,上一条连接上的这一步作废',
108
+ });
109
+ }
110
+ link.pending.clear();
111
+ try { link.socket.close(); } catch { /* 已经断了 */ }
112
+ }
113
+
114
+ function attach(socket, deviceId, hello) {
115
+ const previous = devices.get(deviceId);
116
+ // 同一台手机重连:旧的先结算掉,否则那几条在途 op 会一直等到各自超时。
117
+ if (previous && previous.socket !== socket) drop(deviceId, 'replace');
118
+ const link = {
119
+ socket,
120
+ deviceId,
121
+ device: hello && hello.device && typeof hello.device === 'object' ? hello.device : {},
122
+ capabilities: hello && Array.isArray(hello.capabilities) ? hello.capabilities.filter((c) => typeof c === 'string') : [],
123
+ connectedAt: Date.now(),
124
+ alive: true,
125
+ pending: new Map(),
126
+ };
127
+ devices.set(deviceId, link);
128
+ log(`phone ${deviceId}: 接上了`);
129
+
130
+ socket.on('message', (raw) => {
131
+ let frame;
132
+ try {
133
+ frame = JSON.parse(typeof raw === 'string' ? raw : raw.toString('utf8'));
134
+ } catch {
135
+ return; // 畸形帧丢掉就好,不该让一条坏帧打断整条连接
136
+ }
137
+ if (!frame || typeof frame !== 'object') return;
138
+ if (frame.t === 'pong') {
139
+ link.alive = true;
140
+ return;
141
+ }
142
+ if (frame.t === 'hello') {
143
+ // 身份在连上之后补上来(不必挤进升级头里):机型 / 系统版本只用于显示与自检。
144
+ if (frame.device && typeof frame.device === 'object') link.device = frame.device;
145
+ if (Array.isArray(frame.capabilities)) {
146
+ link.capabilities = frame.capabilities.filter((c) => typeof c === 'string');
147
+ }
148
+ return;
149
+ }
150
+ if (frame.t === 'result') {
151
+ const pending = link.pending.get(text(frame.id));
152
+ if (!pending) return;
153
+ link.pending.delete(text(frame.id));
154
+ if (frame.ok) pending.settleOk(frame.data === undefined ? null : frame.data);
155
+ else {
156
+ const error = frame.error && typeof frame.error === 'object' ? frame.error : {};
157
+ pending.settleErr({
158
+ code: text(error.code) || 'failed',
159
+ message: text(error.message) || '手机那边没能完成这一步',
160
+ });
161
+ }
162
+ return;
163
+ }
164
+ if (frame.t === 'event') {
165
+ // 目前只用来更新「这台上有没有等人的事」,没有订阅方就不传播。
166
+ if (frame.kind === 'needs-human' && typeof frame.sessionId === 'string') {
167
+ link.needsHuman = { sessionId: frame.sessionId, reason: text(frame.reason), at: Date.now() };
168
+ }
169
+ }
170
+ });
171
+
172
+ socket.on('close', () => {
173
+ if (devices.get(deviceId) === link) drop(deviceId, 'offline');
174
+ log(`phone ${deviceId}: 断开了`);
175
+ });
176
+ socket.on('error', () => { /* close 会跟着来,账在那边结 */ });
177
+
178
+ // 心跳:手机端不实现自动 pong 的话(RN 的 WebSocket 有),这里照样按超时判掉线。
179
+ link.heartbeat = setInterval(() => {
180
+ if (!statusOf(socket)) return;
181
+ if (!link.alive) {
182
+ log(`phone ${deviceId}: 心跳丢了,按断线处理`);
183
+ drop(deviceId, 'offline');
184
+ return;
185
+ }
186
+ link.alive = false;
187
+ try { socket.send(JSON.stringify({ t: 'ping', at: Date.now() })); } catch { /* 下面 close 会来 */ }
188
+ }, HEARTBEAT_MS);
189
+ link.heartbeat.unref?.();
190
+
191
+ try {
192
+ socket.send(JSON.stringify({
193
+ t: 'welcome',
194
+ actions: [...ACTIONS.keys()],
195
+ serverTime: Date.now(),
196
+ }));
197
+ } catch { /* 刚连上就断的,等 close */ }
198
+ }
199
+
200
+ /**
201
+ * 在一台手机上执行一个动作。
202
+ *
203
+ * @returns {Promise<{ok: true, data: any}>} 失败一律 reject 成 `{code, message}`
204
+ */
205
+ function call(deviceId, sessionId, action, args, { signal, timeoutMs } = {}) {
206
+ const id = text(deviceId);
207
+ const link = devices.get(id);
208
+ if (closed) return Promise.reject({ code: 'unavailable', message: '手机操作服务没在运行' });
209
+ if (!link || !statusOf(link.socket)) return Promise.reject({ code: 'offline', message: '那台手机没连上' });
210
+ if (!ACTIONS.has(action)) return Promise.reject({ code: 'unknown-action', message: `不支持的动作 ${action}` });
211
+
212
+ const opId = nextId();
213
+ const budget = Number.isFinite(timeoutMs) ? timeoutMs : (ACTIONS.get(action) || DEFAULT_OP_TIMEOUT_MS);
214
+ return new Promise((resolve, reject) => {
215
+ let settled = false;
216
+ const settle = (fn, value) => {
217
+ if (settled) return;
218
+ settled = true;
219
+ link.pending.delete(opId);
220
+ clearTimeout(timer);
221
+ if (signal) signal.removeEventListener?.('abort', onAbort);
222
+ fn(value);
223
+ };
224
+ const pending = {
225
+ settleOk: (data) => settle(resolve, { ok: true, data }),
226
+ settleErr: (error) => settle(reject, error),
227
+ };
228
+ const timer = setTimeout(() => {
229
+ settle(reject, { code: 'timeout', message: '迟迟没有回应这一步' });
230
+ try { link.socket.send(JSON.stringify({ t: 'cancel', id: opId })); } catch { /* 已断 */ }
231
+ }, budget);
232
+ timer.unref?.();
233
+ const onAbort = () => {
234
+ settle(reject, { code: 'aborted', message: '这一步被取消了' });
235
+ try { link.socket.send(JSON.stringify({ t: 'cancel', id: opId })); } catch { /* 已断 */ }
236
+ };
237
+ if (signal) {
238
+ if (signal.aborted) return onAbort();
239
+ signal.addEventListener?.('abort', onAbort, { once: true });
240
+ }
241
+ link.pending.set(opId, pending);
242
+ try {
243
+ link.socket.send(JSON.stringify({ t: 'op', id: opId, sessionId: text(sessionId), action, args: args || {} }));
244
+ } catch {
245
+ settle(reject, { code: 'offline', message: '发不出去,那台手机断开了' });
246
+ }
247
+ });
248
+ }
249
+
250
+ /** 这台手机此刻在不在线。 */
251
+ function isOnline(deviceId) {
252
+ const link = devices.get(text(deviceId));
253
+ return Boolean(link && statusOf(link.socket));
254
+ }
255
+
256
+ /** 一台手机的连接信息(给 App 的状态页用)。 */
257
+ function statusOfDevice(deviceId) {
258
+ const link = devices.get(text(deviceId));
259
+ if (!link || !statusOf(link.socket)) return { online: false };
260
+ return {
261
+ online: true,
262
+ connectedAt: link.connectedAt,
263
+ device: link.device,
264
+ capabilities: link.capabilities,
265
+ };
266
+ }
267
+
268
+ /** 让这台手机重连(用户点了「重新连接」):断开后它自己会回来。 */
269
+ function kick(deviceId) {
270
+ drop(text(deviceId), 'offline');
271
+ }
272
+
273
+ /** 升级处理:`/api/mobile/v1/phone/control`。 */
274
+ function handleUpgrade(req, socket, head) {
275
+ if (closed) return refuse(socket, 503, 'Service Unavailable', '手机操作服务已关闭');
276
+ let allowed = false;
277
+ try {
278
+ allowed = Boolean(authorized && authorized(req));
279
+ } catch {
280
+ allowed = false;
281
+ }
282
+ if (!allowed) return refuse(socket, 401, 'Unauthorized', '移动端凭据无效');
283
+
284
+ // 认这台手机靠它自带的 deviceId(与 `x-dsh-device` 同一个头)。缺了就没法把动作路由到
285
+ // 具体某一台,拒绝比猜一台好。
286
+ const header = req.headers && req.headers['x-dsh-device'];
287
+ const deviceId = text(Array.isArray(header) ? header[0] : header);
288
+ if (!deviceId) return refuse(socket, 400, 'Bad Request', '缺少设备标识');
289
+
290
+ wss.handleUpgrade(req, socket, head, (ws) => attach(ws, deviceId, null));
291
+ }
292
+
293
+ function close() {
294
+ closed = true;
295
+ for (const [deviceId, link] of devices) {
296
+ clearInterval(link.heartbeat);
297
+ drop(deviceId, 'offline');
298
+ }
299
+ for (const client of wss.clients) {
300
+ try { client.close(); } catch { /* 已关 */ }
301
+ }
302
+ wss.close?.();
303
+ }
304
+
305
+ return { handleUpgrade, call, isOnline, statusOfDevice, kick, close, ACTIONS, devices };
306
+ }
307
+
308
+ function refuse(socket, status, reason, bodyText) {
309
+ if (!socket || socket.destroyed) return;
310
+ const body = Buffer.from(bodyText, 'utf8');
311
+ socket.write(
312
+ `HTTP/1.1 ${status} ${reason}\r\n`
313
+ + 'Connection: close\r\n'
314
+ + 'Content-Type: text/plain; charset=utf-8\r\n'
315
+ + `Content-Length: ${body.length}\r\n\r\n`,
316
+ );
317
+ socket.write(body);
318
+ socket.end();
319
+ }
320
+
321
+ /** 从请求头里认这台手机(与 `shared/device-headers.js` 同一口径:缺了就是空串)。 */
322
+ function deviceIdOf(req) {
323
+ const raw = req && req.headers ? req.headers['x-dsh-device'] : undefined;
324
+ return text(Array.isArray(raw) ? raw[0] : raw);
325
+ }
326
+
327
+ module.exports = { createPhoneService, deviceIdOf, ACTIONS, DEFAULT_OP_TIMEOUT_MS };