@dshfly/remote-connector 0.2.1
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/LICENSE +21 -0
- package/README.md +44 -0
- package/config.js +69 -0
- package/cordis.patch.yml +6 -0
- package/core/cloud.js +55 -0
- package/core/connector-core.js +910 -0
- package/core/dsh-web.js +556 -0
- package/core/keys.js +68 -0
- package/core/path-guard.js +60 -0
- package/core/pending-approvals.js +80 -0
- package/core/push-notifier.js +79 -0
- package/core/session-titles.js +85 -0
- package/core/tunnel.js +607 -0
- package/dist/client.js +556 -0
- package/dist/client.js.map +7 -0
- package/http-api.js +531 -0
- package/index.js +173 -0
- package/mobile-bridge/README.md +33 -0
- package/mobile-bridge/core/bridge-core.js +367 -0
- package/mobile-bridge/core/enumerate.js +44 -0
- package/mobile-bridge/core/files.js +431 -0
- package/mobile-bridge/core/roots.js +50 -0
- package/mobile-bridge/index.js +59 -0
- package/package.json +87 -0
|
@@ -0,0 +1,910 @@
|
|
|
1
|
+
// packages/remote-connector/core/connector-core.js
|
|
2
|
+
// ConnectorCore:把 connector 的核心逻辑(连接/E2EE/隧道/事件转发/配对/解除)抽成
|
|
3
|
+
// 一个 GUI 无耦合、Node 可单测的类。自 services/connector/core.js 迁入(单一代码源,
|
|
4
|
+
// 保留 M5.5 全部扩展)。Electron 主进程 / CLI / 插件 apply 都是它的消费者。
|
|
5
|
+
|
|
6
|
+
import fs from 'node:fs';
|
|
7
|
+
import os from 'node:os';
|
|
8
|
+
import { randomBytes } from 'node:crypto';
|
|
9
|
+
import { loadOrCreateKeys, saveKeys } from './keys.js';
|
|
10
|
+
import { createCloud } from './cloud.js';
|
|
11
|
+
import { connectConnector } from './tunnel.js';
|
|
12
|
+
import { PendingApprovalCache } from './pending-approvals.js';
|
|
13
|
+
import { createSessionTitleCache } from './session-titles.js';
|
|
14
|
+
import { createPushNotifier } from './push-notifier.js';
|
|
15
|
+
import { guardPath } from './path-guard.js';
|
|
16
|
+
import { deriveSessionKey, pairFingerprint, pairShortCode, signPairingConfirm, signDeviceLogin } from '@dshfly/crypto';
|
|
17
|
+
|
|
18
|
+
// M4.x-c:mobile.* RPC 的本地适配器(spec §5.1 信封语义)。
|
|
19
|
+
// 手机端把 mobile.* 当作普通 /api 方法调用(client-request 信封 + base64 body),
|
|
20
|
+
// 本适配器在 connector 侧截获(tunnel 的 localHandler 只认 url 前缀),
|
|
21
|
+
// 解析信封 → bridge.handleRpc → 组 server-response 回包。bridge 未接入时返回 null(回落反代)。
|
|
22
|
+
const MOBILE_PREFIX = '/api/mobile.';
|
|
23
|
+
|
|
24
|
+
/** PC 计算机名(去 .local 后缀;2026-08:手机端"我"页显示可读 PC 名)。 */
|
|
25
|
+
function hostnameClean() {
|
|
26
|
+
return String(os.hostname() || '').replace(/\.local$/, '') || null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* 解析本地 JWT 的 exp(毫秒)。HS256 只需 base64url 解码 payload(不验签——本地 token,
|
|
31
|
+
* 签名是 relay 的,本地只看过期)。解析失败返回 null。
|
|
32
|
+
*/
|
|
33
|
+
function tokenExpiryMs(token) {
|
|
34
|
+
try {
|
|
35
|
+
const part = String(token || '').split('.')[1];
|
|
36
|
+
if (!part) return null;
|
|
37
|
+
const body = JSON.parse(Buffer.from(part.replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString('utf8'));
|
|
38
|
+
return body?.exp ? body.exp * 1000 : null;
|
|
39
|
+
} catch {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** cloud.call 抛的"token 失效/设备不存在"错误——需触发签名刷新重试。 */
|
|
45
|
+
function isAuthError(e) {
|
|
46
|
+
const msg = String(e?.message || e);
|
|
47
|
+
// 401 = token 失效;404 'device not found' = 设备不在中继库(库被清/换库后旧 deviceId 失效)
|
|
48
|
+
// → 同样触发刷新/重登记,否则 connector 一直拿着"未过期但已失效"的 1 年 TTL token 卡死在 404。
|
|
49
|
+
return /-> 401\b|\b401:|\bHTTP 401\b/i.test(msg) || /device not found/i.test(msg);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** 续期提前量:token 剩余寿命少于该值时刷新(24h TTL 的一半 = 12h)。 */
|
|
53
|
+
const TOKEN_REFRESH_LEAD_MS = 12 * 3600 * 1000;
|
|
54
|
+
|
|
55
|
+
/** 组 HTTP 响应(与 replayInner 返回同构:status/headers/body(base64)/enc)。 */
|
|
56
|
+
function httpJson(obj, status = 200) {
|
|
57
|
+
const body = Buffer.from(JSON.stringify(obj)).toString('base64');
|
|
58
|
+
return { status, headers: { 'content-type': 'application/json' }, body, enc: null };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* 供 tunnel localHandler 使用;返回响应对象或 null(不接管)。
|
|
63
|
+
* opts.fullDisk:该手机的完整磁盘权限(缺省 true = 兼容旧版/未受限);
|
|
64
|
+
* opts.resolveRoots:允许根提供者(已登记工作区 ∪ 主机 cwd)——受限手机的
|
|
65
|
+
* browse/mkdir 目标必须位于允许根内(2026-08 完整磁盘权限门禁)。
|
|
66
|
+
*/
|
|
67
|
+
export async function handleMobileLocal(bridge, inner, { fullDisk = true, resolveRoots = null } = {}) {
|
|
68
|
+
if (!inner?.url?.startsWith(MOBILE_PREFIX)) return null;
|
|
69
|
+
if (inner.method !== 'POST') {
|
|
70
|
+
return httpJson({ type: 'server-response', result: { ok: false, error: { code: 'INTERNAL', message: 'mobile.* 仅支持 POST' } } }, 405);
|
|
71
|
+
}
|
|
72
|
+
let req = null;
|
|
73
|
+
try {
|
|
74
|
+
req = JSON.parse(Buffer.from(inner.body || '', 'base64').toString('utf8'));
|
|
75
|
+
} catch {
|
|
76
|
+
return httpJson({ type: 'server-response', result: { ok: false, error: { code: 'INTERNAL', message: '请求信封解析失败' } } }, 400);
|
|
77
|
+
}
|
|
78
|
+
const method = req?.method;
|
|
79
|
+
// 完整磁盘权限门禁(P4/B2):受限手机的 browse/mkdir/read/info/download 的**最终位置**必须在允许根内
|
|
80
|
+
// (即使手输路径)——browse/mkdir 判 path,read/info/download 判 root(它们的读取范围 = root 内)。
|
|
81
|
+
// fullDisk=true 时 guardPath 直接放行(null)。
|
|
82
|
+
const guardedMethod =
|
|
83
|
+
method === 'mobile.files.browse' || method === 'mobile.files.mkdir'
|
|
84
|
+
? { path: req?.payload?.path }
|
|
85
|
+
: method === 'mobile.files.read' || method === 'mobile.files.info' || method === 'mobile.files.download'
|
|
86
|
+
? { path: req?.payload?.root }
|
|
87
|
+
: null;
|
|
88
|
+
if (!fullDisk && guardedMethod && resolveRoots) {
|
|
89
|
+
let roots = [];
|
|
90
|
+
try {
|
|
91
|
+
roots = await resolveRoots();
|
|
92
|
+
} catch {
|
|
93
|
+
roots = [];
|
|
94
|
+
}
|
|
95
|
+
const code = await guardPath({ path: guardedMethod.path, fullDisk: false, roots });
|
|
96
|
+
if (code) {
|
|
97
|
+
// 业务错误用 200 + result.ok=false(与 bridge 其他错误一致):手机端 call() 才能
|
|
98
|
+
// 解析出 error.code 显示友好文案;HTTP 非 2xx 会被传输层当"传输失败 HTTP xxx"吞掉错误码
|
|
99
|
+
return httpJson({
|
|
100
|
+
type: 'server-response',
|
|
101
|
+
rpcId: req?.rpcId ?? null,
|
|
102
|
+
result: { ok: false, error: { code, message: '目录在允许范围之外(PC 端可开启完整磁盘权限)' } },
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
try {
|
|
107
|
+
const value = await bridge.handleRpc(method, req?.payload ?? {});
|
|
108
|
+
// raw-bytes 响应(mobile.files.download,方案 file-image-download-plan.md §4.2):
|
|
109
|
+
// bridge 以 {__raw: Uint8Array} 标记——此处直接回传该标记(不包 server-response JSON,
|
|
110
|
+
// 避免 JSON.stringify 把字节序列化成数字键对象),由 tunnel 检测后以原始字节作信封明文。
|
|
111
|
+
// 错误仍走下面 catch(files-too-large / files-not-found 等经 {__error} 回传)。
|
|
112
|
+
if (value && typeof value === 'object' && value.__raw instanceof Uint8Array) {
|
|
113
|
+
return { __raw: value.__raw };
|
|
114
|
+
}
|
|
115
|
+
return httpJson({ type: 'server-response', rpcId: req?.rpcId ?? null, result: { ok: true, value } });
|
|
116
|
+
} catch (e) {
|
|
117
|
+
// spec §5.3:错误码透传;INTERNAL 细节只进 PC 日志,不回传 App
|
|
118
|
+
const code = e?.code || 'INTERNAL';
|
|
119
|
+
if (code === 'INTERNAL') console.error(`[connector] mobile.* ${method} failed:`, e?.message);
|
|
120
|
+
return httpJson({
|
|
121
|
+
type: 'server-response',
|
|
122
|
+
rpcId: req?.rpcId ?? null,
|
|
123
|
+
result: { ok: false, error: { code, message: e?.message || code } },
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export class ConnectorCore {
|
|
129
|
+
constructor({ relayUrl, deviceName, keysFile, target, controlPath = '/control', dshAdapter = null }) {
|
|
130
|
+
this.keys = loadOrCreateKeys(keysFile);
|
|
131
|
+
// 2026-09:DSH web 0.1.2 起 /api 需要 browser-session cookie 且协议结构性改动(斜杠端点、
|
|
132
|
+
// {args} 包装、事件走 remote.mux、/api/respond 移除)。connector 同进程 —— 改为进程内
|
|
133
|
+
// 直接调 DSH 的 typertGateway(免认证 + 免线协议)。dshAdapter 提供 invoke/事件订阅/审批结算。
|
|
134
|
+
this.dsh = dshAdapter;
|
|
135
|
+
this.cfg = {
|
|
136
|
+
// 中继地址:运行时修改(设置页"编辑")持久化到 keys.relayUrl,重启后优先于插件配置
|
|
137
|
+
relayUrl: String(this.keys.relayUrl || relayUrl || '').replace(/\/$/, ''),
|
|
138
|
+
deviceName,
|
|
139
|
+
// D1(方案 B):无账号层——loginName 字段彻底移除(keys.accountName 仅兼容旧 keys 文件)
|
|
140
|
+
keysFile,
|
|
141
|
+
target,
|
|
142
|
+
controlPath,
|
|
143
|
+
};
|
|
144
|
+
this.cloud = createCloud(this.cfg.relayUrl);
|
|
145
|
+
this.keys.lastUsed = this.keys.lastUsed || {};
|
|
146
|
+
this.sessionKeys = new Map(Object.entries(this.keys.sessionKeys || {}));
|
|
147
|
+
this.state = { connection: 'disconnected', deviceId: this.keys.deviceId || null };
|
|
148
|
+
/** 手机 /events 在线状态镜像(M5.12 方案C PHONE_STATE 回调驱动;
|
|
149
|
+
* 缺省=全在线——与 connector 门禁"默认全在线"语义一致,relay 快照到达后校正)。 */
|
|
150
|
+
this.phoneOnline = new Map(); // phonePk -> boolean
|
|
151
|
+
this.listeners = new Set();
|
|
152
|
+
this._saveTimer = null;
|
|
153
|
+
this._emitTimer = null;
|
|
154
|
+
this.mobileBridge = null; // M4.x-c:MobileBridge 服务(可选;由 setMobileBridge 接入)
|
|
155
|
+
this._tunnel = null;
|
|
156
|
+
this._bridgeUnsub = null;
|
|
157
|
+
/** 方案 A:pending 审批/提问镜像缓存(手机离线期间的审批经 mobile.approvals.list 补拉)。 */
|
|
158
|
+
this.pendingApprovals = new PendingApprovalCache();
|
|
159
|
+
/** 推送:sessionId→title 缓存 + 触发检测(只对离线手机)。 */
|
|
160
|
+
this.sessionTitles = createSessionTitleCache();
|
|
161
|
+
this.pushNotifier = createPushNotifier({
|
|
162
|
+
onNotify: (phonePk, kind, sessionId) => this._notifyPush(phonePk, kind, sessionId),
|
|
163
|
+
});
|
|
164
|
+
/**
|
|
165
|
+
* A0(S0):配对中手机公钥缓存 pairingId -> {phoneIdPk, phoneTrPk, name}。
|
|
166
|
+
* 展示(短码)与派生(会话密钥)必须原子绑定同一份公钥——PC 确认页 GET pairing
|
|
167
|
+
* 时缓存,confirm 时用缓存值派生,绝不从 confirm 响应重新拉取(杜绝中继偷换)。
|
|
168
|
+
*/
|
|
169
|
+
this._pendingPairKeys = new Map();
|
|
170
|
+
/** 完整磁盘权限门禁的允许根提供者(已登记工作区 ∪ 主机 cwd,loopback 实时取)。 */
|
|
171
|
+
// 完整磁盘权限门禁的允许根提供者(已登记工作区)。2026-09:一律走进程内 DSH 适配层取工作区
|
|
172
|
+
// (移除旧 loopback HTTP 兜底——升级后 workspace.list 端点已删除 + 需 cookie,会 401);
|
|
173
|
+
// adapter 缺失/失败时 fail-closed 返回空根,使受限手机所有 files.* 被拒(安全方向)。
|
|
174
|
+
this._roots = async () => {
|
|
175
|
+
try {
|
|
176
|
+
if (!this.dsh) return [];
|
|
177
|
+
const r = await this.dsh.workspaceList();
|
|
178
|
+
return (r?.ok ? r.value?.items || [] : []).map((i) => i?.path).filter((p) => typeof p === 'string' && p);
|
|
179
|
+
} catch {
|
|
180
|
+
return [];
|
|
181
|
+
}
|
|
182
|
+
};
|
|
183
|
+
/** device token 刷新单飞(任意时刻最多一个刷新在跑,防并发竞态)。 */
|
|
184
|
+
this._tokenRefreshing = null;
|
|
185
|
+
/** 设备被中继主动撤销(不再自动重新登记;设置页醒目标识)。 */
|
|
186
|
+
this._deviceBlocked = null;
|
|
187
|
+
this._tokenTimer = null; // 续期定时器(按剩余寿命调度,替换旧的固定 12h setInterval)
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
onStateChange(cb) {
|
|
191
|
+
this.listeners.add(cb);
|
|
192
|
+
return () => this.listeners.delete(cb);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
_emit() {
|
|
196
|
+
const s = this.getState();
|
|
197
|
+
for (const cb of this.listeners) {
|
|
198
|
+
try {
|
|
199
|
+
cb(s);
|
|
200
|
+
} catch {}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** device token 健康状态(供 PC 设置页可见,提前预警而非 401 才去猜)。 */
|
|
205
|
+
getTokenState() {
|
|
206
|
+
if (this._deviceBlocked === 'revoked') return { state: 'revoked', expiringAt: null };
|
|
207
|
+
const t = this.keys.deviceToken;
|
|
208
|
+
if (!t || !this.keys.deviceId) return { state: 'unregistered', expiringAt: null };
|
|
209
|
+
const exp = tokenExpiryMs(t);
|
|
210
|
+
if (exp == null) return { state: 'unknown', expiringAt: null };
|
|
211
|
+
const left = exp - Date.now();
|
|
212
|
+
let state = 'ok';
|
|
213
|
+
if (left <= 0) state = 'expired';
|
|
214
|
+
else if (left < TOKEN_REFRESH_LEAD_MS) state = 'expiring';
|
|
215
|
+
return { state, expiringAt: exp };
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
getState() {
|
|
219
|
+
const devices = Object.entries(this.keys.sessionKeys || {}).map(([phonePk]) => ({
|
|
220
|
+
phonePk,
|
|
221
|
+
shortId: String(phonePk).slice(0, 8),
|
|
222
|
+
name: this.keys.deviceNames?.[phonePk] || null, // 手机上报的设备名(2026-08;缺省 null)
|
|
223
|
+
lastUsedAt: this.keys.lastUsed?.[phonePk] || null,
|
|
224
|
+
permission: this.getPermission(phonePk), // 完整磁盘权限(2026-08)
|
|
225
|
+
online: this.phoneOnline.get(phonePk) ?? true, // 手机 /events 在线状态(2026-08 M5.12)
|
|
226
|
+
}));
|
|
227
|
+
const tok = this.getTokenState();
|
|
228
|
+
return {
|
|
229
|
+
connection: this.state.connection,
|
|
230
|
+
deviceId: this.keys.deviceId || null,
|
|
231
|
+
hostname: hostnameClean(), // PC 计算机名(去 .local,2026-08:手机端显示可读 PC 名)
|
|
232
|
+
devices,
|
|
233
|
+
tokenState: tok.state, // device token 健康(2026-08 自愈)
|
|
234
|
+
deviceTokenExpiringAt: tok.expiringAt,
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** 手机权限(缺省最小权限:不允许完整磁盘)。 */
|
|
239
|
+
getPermission(phonePk) {
|
|
240
|
+
const p = this.keys.permissions?.[phonePk];
|
|
241
|
+
return { fullDisk: !!(p && p.fullDisk) };
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** 修改手机权限(PC 设置页设备列表;配对确认时写入)。 */
|
|
245
|
+
setPermission(phonePk, { fullDisk } = {}) {
|
|
246
|
+
if (!phonePk) return;
|
|
247
|
+
this.keys.permissions = this.keys.permissions || {};
|
|
248
|
+
this.keys.permissions[phonePk] = { fullDisk: !!fullDisk };
|
|
249
|
+
saveKeys(this.keys, this.cfg.keysFile);
|
|
250
|
+
this._emit();
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** 记录手机上报的设备名(配对后首次连接时上报;PC 设置页设备列表显示)。 */
|
|
254
|
+
setDeviceName(phonePk, name) {
|
|
255
|
+
if (!phonePk || typeof name !== 'string' || !name.trim()) return;
|
|
256
|
+
const clean = name.trim().slice(0, 64);
|
|
257
|
+
this.keys.deviceNames = this.keys.deviceNames || {};
|
|
258
|
+
if (this.keys.deviceNames[phonePk] === clean) return;
|
|
259
|
+
this.keys.deviceNames[phonePk] = clean;
|
|
260
|
+
saveKeys(this.keys, this.cfg.keysFile);
|
|
261
|
+
this._emit();
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** M5.12 方案C:relay PHONE_STATE 回调——手机 /events 订阅上下线,镜像进 getState().devices[].online。 */
|
|
265
|
+
_setPhoneOnline(phonePk, online) {
|
|
266
|
+
if (!phonePk) return;
|
|
267
|
+
this.phoneOnline.set(phonePk, !!online);
|
|
268
|
+
this._emit();
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** 手机焦点上报(2026-09 Phase 3):开/关 session/follow 流重构消息级实时。 */
|
|
272
|
+
/** 当前离线手机集合(已配对且 phoneOnline 为 false)。供 pushNotifier 判定推送目标(只推离线手机)。 */
|
|
273
|
+
_offlinePhones() {
|
|
274
|
+
const set = new Set();
|
|
275
|
+
for (const pk of this.sessionKeys.keys()) {
|
|
276
|
+
if (this.phoneOnline.get(pk) === false) set.add(pk);
|
|
277
|
+
}
|
|
278
|
+
return set;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
_setFocusSession(sessionId) {
|
|
282
|
+
this._followStop?.();
|
|
283
|
+
this._followStop = null;
|
|
284
|
+
this._focusSessionId = sessionId || null;
|
|
285
|
+
if (!sessionId || !this.dsh?.startSessionFollow || !this._tunnel) return;
|
|
286
|
+
this._followStop = this.dsh.startSessionFollow(sessionId, {
|
|
287
|
+
onFrames: (frames) => {
|
|
288
|
+
for (const f of frames) {
|
|
289
|
+
try {
|
|
290
|
+
this.sessionTitles?.ingest?.(JSON.stringify(f)); // 喂标题缓存(推送标题源;session/projection·event 帧)
|
|
291
|
+
this._tunnel.injectEvent(JSON.stringify(f));
|
|
292
|
+
} catch {}
|
|
293
|
+
}
|
|
294
|
+
},
|
|
295
|
+
onError: (e) => console.warn(`[dshfly-remote-connector] session/follow ${sessionId} error:`, e?.message),
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** 校验中继地址可达(GET /healthz)。返回错误文案或 null(可达)。
|
|
300
|
+
/** 校验中继地址可达(D1:GET /healthz——无账号层后无"登录"可探测;healthz 足够判可达)。 */
|
|
301
|
+
async checkRelay(url) {
|
|
302
|
+
const clean = String(url || '').trim();
|
|
303
|
+
if (!/^https?:\/\//.test(clean)) return '中继地址需以 http(s):// 开头';
|
|
304
|
+
try {
|
|
305
|
+
const res = await fetch(`${clean.replace(/\/$/, '')}/healthz`, { signal: AbortSignal.timeout(5000) });
|
|
306
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
307
|
+
return null;
|
|
308
|
+
} catch (e) {
|
|
309
|
+
return `中继不可达或校验失败:${e?.message || '网络错误'}`;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** 更换中继(2026-08,设置页"编辑"):停旧连接 → 持久化 keys.relayUrl → 新中继
|
|
314
|
+
* 重新登记设备 → 重启隧道。⚠️ 中继间配对数据不共享:更换后已配对手机需在新中继
|
|
315
|
+
* 重新扫码配对。 */
|
|
316
|
+
async setRelayUrl(url) {
|
|
317
|
+
const clean = String(url || '').trim().replace(/\/$/, '');
|
|
318
|
+
if (!/^https?:\/\//.test(clean)) throw new Error('中继地址需以 http(s):// 开头');
|
|
319
|
+
if (clean === this.cfg.relayUrl) return this.getState();
|
|
320
|
+
this.stop();
|
|
321
|
+
this.cfg.relayUrl = clean;
|
|
322
|
+
this.keys.relayUrl = clean;
|
|
323
|
+
// 新中继上旧 deviceToken/deviceId 无效:强制重新登记(旧 relay 残留记录无害)
|
|
324
|
+
delete this.keys.deviceId;
|
|
325
|
+
delete this.keys.deviceToken;
|
|
326
|
+
this.state.deviceId = null;
|
|
327
|
+
saveKeys(this.keys, this.cfg.keysFile);
|
|
328
|
+
await this.start();
|
|
329
|
+
return this.getState();
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
async enroll() {
|
|
333
|
+
// D1(方案 B):纯公钥登记(无账号层)——登记响应即 deviceToken
|
|
334
|
+
const reg = await this.cloud.registerDevice({
|
|
335
|
+
name: this.cfg.deviceName,
|
|
336
|
+
pk_identity: this.keys.identityPublicKey,
|
|
337
|
+
pk_transport: this.keys.transportPublicKey,
|
|
338
|
+
});
|
|
339
|
+
this.keys.deviceId = reg.deviceId;
|
|
340
|
+
this.keys.deviceToken = reg.deviceToken;
|
|
341
|
+
saveKeys(this.keys, this.cfg.keysFile);
|
|
342
|
+
this.state.deviceId = reg.deviceId;
|
|
343
|
+
this._emit();
|
|
344
|
+
return reg;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
async ensureEnrolled() {
|
|
348
|
+
await this._ensureDeviceSession();
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/** 保证本地有一份有效 deviceToken + deviceId(缺失/已过期/临近过期都会自愈)。 */
|
|
352
|
+
async _ensureDeviceSession(minSafeMs = TOKEN_REFRESH_LEAD_MS) {
|
|
353
|
+
const hasToken = !!this.keys.deviceToken;
|
|
354
|
+
const hasId = !!this.keys.deviceId;
|
|
355
|
+
if (!hasToken || !hasId) {
|
|
356
|
+
// D1(方案 B):纯公钥登记(无账号层)——登记响应即 deviceToken
|
|
357
|
+
await this.enroll();
|
|
358
|
+
return { token: this.keys.deviceToken, deviceId: this.keys.deviceId, via: 'register' };
|
|
359
|
+
}
|
|
360
|
+
const exp = tokenExpiryMs(this.keys.deviceToken);
|
|
361
|
+
if (exp != null && exp - Date.now() > minSafeMs) {
|
|
362
|
+
// 有效且剩余寿命充足 → 直接复用
|
|
363
|
+
return { token: this.keys.deviceToken, deviceId: this.keys.deviceId, via: 'existing' };
|
|
364
|
+
}
|
|
365
|
+
return this._refreshDeviceToken();
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/** device token 刷新(单飞):优先 rotate(保 deviceId),失效/异常落 device-login,兜底 register。 */
|
|
369
|
+
_refreshDeviceToken() {
|
|
370
|
+
if (this._tokenRefreshing) return this._tokenRefreshing;
|
|
371
|
+
this._tokenRefreshing = this._doRefreshDeviceToken().finally(() => { this._tokenRefreshing = null; });
|
|
372
|
+
return this._tokenRefreshing;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
async _doRefreshDeviceToken() {
|
|
376
|
+
const exp = tokenExpiryMs(this.keys.deviceToken);
|
|
377
|
+
// 1) 本地 token 尚有效(未过期)→ 先续期(rotate,最省、天然保 deviceId)
|
|
378
|
+
if (exp != null && exp > Date.now() && this.keys.deviceId) {
|
|
379
|
+
try {
|
|
380
|
+
const r = await this.cloud.rotateDeviceToken(this.keys.deviceToken, this.keys.deviceId);
|
|
381
|
+
this.keys.deviceToken = r.deviceToken;
|
|
382
|
+
saveKeys(this.keys, this.cfg.keysFile);
|
|
383
|
+
console.log(`[dshfly-remote-connector] device token rotated (deviceId=${this.keys.deviceId})`);
|
|
384
|
+
return { token: this.keys.deviceToken, deviceId: this.keys.deviceId, via: 'rotate' };
|
|
385
|
+
} catch (e) {
|
|
386
|
+
console.warn('[dshfly-remote-connector] token rotate failed, fallback device-login:', e?.message);
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
// 2) 过期或 rotate 失败 → device-login(身份签名;按身份公钥匹配回同一 deviceId)
|
|
390
|
+
const ts = Date.now();
|
|
391
|
+
const nonce = 'rt-' + randomBytes(6).toString('hex');
|
|
392
|
+
const sig = signDeviceLogin(this.keys.identitySecretKey, { publicKey: this.keys.identityPublicKey, ts, nonce });
|
|
393
|
+
try {
|
|
394
|
+
const r = await this.cloud.deviceLogin(this.keys.identityPublicKey, ts, nonce, sig);
|
|
395
|
+
this.keys.deviceToken = r.token;
|
|
396
|
+
if (r.deviceId) this.keys.deviceId = r.deviceId;
|
|
397
|
+
saveKeys(this.keys, this.cfg.keysFile);
|
|
398
|
+
console.log(`[dshfly-remote-connector] device token refreshed via device-login (deviceId=${this.keys.deviceId})`);
|
|
399
|
+
return { token: this.keys.deviceToken, deviceId: this.keys.deviceId, via: 'device-login' };
|
|
400
|
+
} catch (e) {
|
|
401
|
+
return this._fallbackEnroll(e);
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/** device-login 失败降级:仅"身份无设备行"才重新登记(新 deviceId);撤销态明确报错不自动登记。 */
|
|
406
|
+
async _fallbackEnroll(e) {
|
|
407
|
+
const msg = String(e?.message || e);
|
|
408
|
+
if (/device revoked/i.test(msg)) {
|
|
409
|
+
this._deviceBlocked = 'revoked';
|
|
410
|
+
console.error('[dshfly-remote-connector] PC 设备已被中继撤销,需重新初始化(不会自动重新登记)');
|
|
411
|
+
this._emit();
|
|
412
|
+
throw new Error('device revoked');
|
|
413
|
+
}
|
|
414
|
+
if (/identity not registered/i.test(msg)) {
|
|
415
|
+
console.warn('[dshfly-remote-connector] 设备行不存在(中继库被清/换中继)→ 重新登记(手机需重新扫码)');
|
|
416
|
+
await this.enroll(); // registerDevice -> 新 deviceId
|
|
417
|
+
return { token: this.keys.deviceToken, deviceId: this.keys.deviceId, via: 'register' };
|
|
418
|
+
}
|
|
419
|
+
throw e; // 网络等其余错误原样抛回,不误触发登记
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/** 带 token 自愈的云端调用:token 失效(401)→ 刷新一次 → 重试一次。 */
|
|
423
|
+
async _callWithDeviceToken(fn) {
|
|
424
|
+
await this.ensureEnrolled();
|
|
425
|
+
try {
|
|
426
|
+
return await fn();
|
|
427
|
+
} catch (e) {
|
|
428
|
+
if (isAuthError(e)) {
|
|
429
|
+
console.warn('[dshfly-remote-connector] 云端返回 401(token 失效),刷新后重试');
|
|
430
|
+
await this._refreshDeviceToken();
|
|
431
|
+
return await fn();
|
|
432
|
+
}
|
|
433
|
+
throw e;
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/** 接入 MobileBridge(M4.x-c,可选):mobile.* RPC 本地处理 + 插件事件注入隧道。 */
|
|
438
|
+
setMobileBridge(bridge) {
|
|
439
|
+
this.mobileBridge = bridge;
|
|
440
|
+
this._wireMobileBridgeProviders();
|
|
441
|
+
if (this._tunnel && bridge?.onEvent) {
|
|
442
|
+
this._bridgeUnsub = bridge.onEvent((frame) => {
|
|
443
|
+
try {
|
|
444
|
+
this._tunnel.injectEvent(JSON.stringify(frame));
|
|
445
|
+
} catch {}
|
|
446
|
+
});
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/** 方案 A + 2026-08:把 pending 审批缓存 / 中继用量查询接为 bridge 的数据源(幂等)。 */
|
|
451
|
+
_wireMobileBridgeProviders() {
|
|
452
|
+
if (this.mobileBridge?.setPendingApprovalProvider) {
|
|
453
|
+
this.mobileBridge.setPendingApprovalProvider(() => this.pendingApprovals.list());
|
|
454
|
+
}
|
|
455
|
+
if (this.mobileBridge?.setRelayUsageProvider) {
|
|
456
|
+
this.mobileBridge.setRelayUsageProvider(() => this._relayUsage());
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/** 中继用量/配额(2026-08,quota-ux.md §5 定稿链路):connector 持 deviceToken 查
|
|
461
|
+
* relay GET /api/v1/usage(非计费只读——查询不落 /tunnel 计次,额度用尽后仍可查)。
|
|
462
|
+
* 返回 { plan, quota, used, resetAt, quotaDisabled };quota=null 表示不限量。 */
|
|
463
|
+
async _relayUsage() {
|
|
464
|
+
const doFetch = async () => {
|
|
465
|
+
const res = await fetch(`${this.cfg.relayUrl}/api/v1/usage`, {
|
|
466
|
+
headers: { authorization: `Bearer ${this.keys.deviceToken}` },
|
|
467
|
+
signal: AbortSignal.timeout(5000),
|
|
468
|
+
});
|
|
469
|
+
if (!res.ok) throw new Error(`relay usage: HTTP ${res.status}`);
|
|
470
|
+
return res.json();
|
|
471
|
+
};
|
|
472
|
+
if (!this.keys.deviceToken || !this.cfg.relayUrl) {
|
|
473
|
+
throw new Error('relay usage: 未登记或未配置中继');
|
|
474
|
+
}
|
|
475
|
+
return this._callWithDeviceToken(doFetch);
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/** 推送触发:connector → relay /push/notify(fire-and-forget,绝不阻塞事件流)。 */
|
|
479
|
+
_notifyPush(phonePk, kind, sessionId) {
|
|
480
|
+
const title = this.sessionTitles.titleOf(sessionId) ?? null;
|
|
481
|
+
// 2026-08 角标:携带当前待确认数(pending 审批/提问),relay 透传到推送 payload。
|
|
482
|
+
// ⚠️ 必须 Math.max(...,1):**任何一次推送都代表"有事件要通知"**(尤其"会话完成"这类
|
|
483
|
+
// 通常没有 pending 审批 → 原逻辑算出 0),角标为 0 则通知/桌面角标不显示。取 max 保证
|
|
484
|
+
// 有推送就有角标;App 存活时仍以自己的 badgeCount 为准(更准)。
|
|
485
|
+
const pendingCount = this.pendingApprovals ? this.pendingApprovals.list().frames.length : 0;
|
|
486
|
+
const badge = Math.max(pendingCount, 1);
|
|
487
|
+
// 推送类日志不显示(避免刷屏);失败仍静默——需要时可临时加回或经日志分级
|
|
488
|
+
this._callWithDeviceToken(() =>
|
|
489
|
+
this.cloud.notifyPush(this.keys.deviceToken, { phonePk, kind, title, sessionId, badge }),
|
|
490
|
+
).then(() => {}).catch(() => {});
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/**
|
|
494
|
+
* 隧道 localHandler:mobile.* 本地 RPC(含完整磁盘权限门禁)+ workspace.create 反代拦截。
|
|
495
|
+
* 返回响应对象或 null(不接管 → 回落通用反代)。
|
|
496
|
+
*/
|
|
497
|
+
async _mobileLocal(inner, phonePk) {
|
|
498
|
+
if (inner?.url?.startsWith('/api/mobile.')) {
|
|
499
|
+
// PC 信息(2026-08):手机端补拉计算机名(覆盖已配对手机,免重新配对)
|
|
500
|
+
if (inner?.url === '/api/mobile.getPcInfo') {
|
|
501
|
+
let req = null;
|
|
502
|
+
try {
|
|
503
|
+
req = JSON.parse(Buffer.from(inner.body || '', 'base64').toString('utf8'));
|
|
504
|
+
} catch {
|
|
505
|
+
return null;
|
|
506
|
+
}
|
|
507
|
+
return httpJson({
|
|
508
|
+
type: 'server-response',
|
|
509
|
+
rpcId: req?.rpcId ?? null,
|
|
510
|
+
result: { ok: true, value: { hostname: hostnameClean() } },
|
|
511
|
+
});
|
|
512
|
+
}
|
|
513
|
+
// 设备名上报(2026-08):手机配对后首次连接时上报系统设备名,PC 设置页设备列表显示
|
|
514
|
+
if (inner?.url === '/api/mobile.setDeviceName') {
|
|
515
|
+
let req = null;
|
|
516
|
+
try {
|
|
517
|
+
req = JSON.parse(Buffer.from(inner.body || '', 'base64').toString('utf8'));
|
|
518
|
+
} catch {
|
|
519
|
+
return null;
|
|
520
|
+
}
|
|
521
|
+
this.setDeviceName(phonePk, req?.payload?.name);
|
|
522
|
+
return httpJson({
|
|
523
|
+
type: 'server-response',
|
|
524
|
+
rpcId: req?.rpcId ?? null,
|
|
525
|
+
result: { ok: true, value: { name: this.keys.deviceNames?.[phonePk] || null } },
|
|
526
|
+
});
|
|
527
|
+
}
|
|
528
|
+
if (!this.mobileBridge) return null;
|
|
529
|
+
return handleMobileLocal(this.mobileBridge, inner, {
|
|
530
|
+
fullDisk: this.getPermission(phonePk).fullDisk,
|
|
531
|
+
resolveRoots: this._roots,
|
|
532
|
+
});
|
|
533
|
+
}
|
|
534
|
+
// workspace.create 反代拦截(A6/P0-5):受限手机的目标路径必须在允许根内(即使手输也受限)。
|
|
535
|
+
// 门禁判定改用【信封 body 的 method】(先解析),不再用 URL 字符串精确匹配——
|
|
536
|
+
// `/api/workspace.create?x=1`、`%2e` 编码、重复斜杠等 URL 拼写变体无法绕过门禁
|
|
537
|
+
// (HTTP 路由通常按 pathname 匹配并忽略 query,字符串匹配依赖下游路由行为,不安全)。
|
|
538
|
+
if (!this.getPermission(phonePk).fullDisk && inner?.url?.startsWith('/api/workspace.create')) {
|
|
539
|
+
let req = null;
|
|
540
|
+
try {
|
|
541
|
+
req = JSON.parse(Buffer.from(inner.body || '', 'base64').toString('utf8'));
|
|
542
|
+
} catch {
|
|
543
|
+
// 信封解析失败:明确业务错误(不再回落旧 HTTP 反代 → 401/404)
|
|
544
|
+
return httpJson({ type: 'server-response', rpcId: null, result: { ok: false, error: { code: 'bad-envelope', message: 'dshfly: request envelope parse failed', details: {} } } });
|
|
545
|
+
}
|
|
546
|
+
if (req?.method === 'workspace.create') {
|
|
547
|
+
return this._guardWorkspaceCreate(inner);
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
// 2026-09:其余 /api 方法 → 进程内 DSH 适配层(斜杠端点 + {args} 包装 + 免 HTTP 认证)。
|
|
551
|
+
// 不再回落 HTTP 反代(升级后那需要 browser-session cookie 且端点已改名)。
|
|
552
|
+
if (this.dsh && inner?.url?.startsWith('/api/')) {
|
|
553
|
+
let req = null;
|
|
554
|
+
try {
|
|
555
|
+
req = JSON.parse(Buffer.from(inner.body || '', 'base64').toString('utf8'));
|
|
556
|
+
} catch {
|
|
557
|
+
return httpJson({ type: 'server-response', rpcId: null, result: { ok: false, error: { code: 'bad-envelope', message: 'dshfly: request envelope parse failed', details: {} } } });
|
|
558
|
+
}
|
|
559
|
+
const method = req?.method;
|
|
560
|
+
// /api/respond:手机端应答是 {type:'client-response', rpcId, result},**没有 method 字段**,
|
|
561
|
+
// 必须按 URL 判定(req.method==='respond' 恒为 false 会漏掉 → 落回已废的 HTTP 反代)。
|
|
562
|
+
if (inner?.url === '/api/respond' || method === 'respond') {
|
|
563
|
+
// 2026-09:/api/respond 已被 DSH 移除(审批/提问走 waterfall)。connector 以 $events
|
|
564
|
+
// 客户端身份经 $events/result 结算手机端的应答(resolveRespond)。
|
|
565
|
+
// ⚠️ 回执形状对齐旧 /api/respond:手机端 respondReceiptAction 判 receipt.accepted===true
|
|
566
|
+
// (→close)或 accepted===false && reason==='not-pending'(→close)才关卡片。
|
|
567
|
+
try {
|
|
568
|
+
const r = await this.dsh.resolveRespond(req);
|
|
569
|
+
if (r.ok && r.resolvedFrame) {
|
|
570
|
+
try {
|
|
571
|
+
const text = JSON.stringify(r.resolvedFrame);
|
|
572
|
+
// 剪枝缓存:手机端自己批时,网关不会向"此客户端"广播 cancel → 残留陈旧条目
|
|
573
|
+
// 会让后续 mobile.approvals.list(syncApprovals on open)重拉重弹卡片。
|
|
574
|
+
this.pendingApprovals?.ingest?.(text);
|
|
575
|
+
this._tunnel?.injectEvent(text);
|
|
576
|
+
} catch {}
|
|
577
|
+
}
|
|
578
|
+
if (r.ok) return httpJson({ accepted: true });
|
|
579
|
+
if (r.error?.code === 'approval-not-found') return httpJson({ accepted: false, reason: 'not-pending' });
|
|
580
|
+
console.warn('[dshfly-remote-connector] respond not settled:', r.error?.code, r.error?.message);
|
|
581
|
+
return httpJson({ accepted: false, reason: 'bad-response' });
|
|
582
|
+
} catch (e) {
|
|
583
|
+
console.warn('[dshfly-remote-connector] respond resolve failed:', e?.message);
|
|
584
|
+
return httpJson({ accepted: false, reason: 'internal' });
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
if (method && !method.startsWith('mobile.')) {
|
|
588
|
+
try {
|
|
589
|
+
const r = await this.dsh.invoke(method, req?.payload ?? {});
|
|
590
|
+
// 2026-09:session.list 不再走 HTTP 反代(replayInner),推送标题源改由此处喂
|
|
591
|
+
if (method === 'session.list' && r?.ok) {
|
|
592
|
+
try { this.sessionTitles?.ingestSessionList?.(JSON.stringify({ result: { ok: true, value: r.value } })); } catch {}
|
|
593
|
+
}
|
|
594
|
+
return httpJson({
|
|
595
|
+
type: 'server-response',
|
|
596
|
+
rpcId: req?.rpcId ?? null,
|
|
597
|
+
result: r.ok ? { ok: true, value: r.value } : { ok: false, error: r.error },
|
|
598
|
+
});
|
|
599
|
+
} catch (e) {
|
|
600
|
+
return httpJson({
|
|
601
|
+
type: 'server-response',
|
|
602
|
+
rpcId: req?.rpcId ?? null,
|
|
603
|
+
result: { ok: false, error: { code: 'INTERNAL', message: e?.message || String(e) } },
|
|
604
|
+
});
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
// 2026-09:DSH /api 已全部由适配层接管。此兜底仅当 this.dsh 缺失(测试/降级)时回 null(旧行为);
|
|
609
|
+
// 否则对未接管请求返回明确业务错误,绝不回落旧 HTTP 反代(会 401/404)。
|
|
610
|
+
if (this.dsh) {
|
|
611
|
+
return httpJson({
|
|
612
|
+
type: 'server-response',
|
|
613
|
+
rpcId: null,
|
|
614
|
+
result: { ok: false, error: { code: 'unhandled', message: 'dshfly: unhandled DSH request', details: {} } },
|
|
615
|
+
});
|
|
616
|
+
}
|
|
617
|
+
return null;
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
async _guardWorkspaceCreate(inner) {
|
|
621
|
+
let req = null;
|
|
622
|
+
try {
|
|
623
|
+
req = JSON.parse(Buffer.from(inner.body || '', 'base64').toString('utf8'));
|
|
624
|
+
} catch {
|
|
625
|
+
// 信封解析失败:明确业务错误(不再回落旧 HTTP 反代 → 401/404)
|
|
626
|
+
return httpJson({ type: 'server-response', rpcId: null, result: { ok: false, error: { code: 'bad-envelope', message: 'dshfly: request envelope parse failed', details: {} } } });
|
|
627
|
+
}
|
|
628
|
+
let roots = [];
|
|
629
|
+
try {
|
|
630
|
+
roots = await this._roots();
|
|
631
|
+
} catch {
|
|
632
|
+
roots = [];
|
|
633
|
+
}
|
|
634
|
+
const code = await guardPath({ path: req?.payload?.path, fullDisk: false, roots });
|
|
635
|
+
// 在允许根内:放行 —— 进程内 DSH 适配层执行(不再回落 HTTP 反代,见 _mobileLocal 注释)
|
|
636
|
+
if (!code && this.dsh) {
|
|
637
|
+
try {
|
|
638
|
+
const r = await this.dsh.invoke('workspace.create', req?.payload ?? {});
|
|
639
|
+
return httpJson({
|
|
640
|
+
type: 'server-response',
|
|
641
|
+
rpcId: req?.rpcId ?? null,
|
|
642
|
+
result: r.ok ? { ok: true, value: r.value } : { ok: false, error: r.error },
|
|
643
|
+
});
|
|
644
|
+
} catch (e) {
|
|
645
|
+
return httpJson({
|
|
646
|
+
type: 'server-response',
|
|
647
|
+
rpcId: req?.rpcId ?? null,
|
|
648
|
+
result: { ok: false, error: { code: 'INTERNAL', message: e?.message || String(e) } },
|
|
649
|
+
});
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
if (!code) return null; // 兼容:无 adapter 时回落反代(旧行为)
|
|
653
|
+
// 业务错误用 200 + result.ok=false(同 handleMobileLocal 门禁):错误码可被手机端解析
|
|
654
|
+
return httpJson({
|
|
655
|
+
type: 'server-response',
|
|
656
|
+
rpcId: req?.rpcId ?? null,
|
|
657
|
+
result: { ok: false, error: { code: 'workspace-permission-denied', message: '此目录在允许范围之外(PC 端可开启完整磁盘权限)' } },
|
|
658
|
+
});
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
/** 连接中继并维持;连接状态 / 设备活动 / 会话密钥热加载都从这里驱动。 */
|
|
662
|
+
async start() {
|
|
663
|
+
await this.ensureEnrolled();
|
|
664
|
+
// ALB 多实例(2026-09):/control 也带 ?deviceId= 作一致性哈希路由键(metering §6.6-6.7)。
|
|
665
|
+
// relay 侧 hint 校验是"带了必须与 deviceToken.sub 一致",keys.deviceId 与 deviceToken 同源,天然通过。
|
|
666
|
+
const wsUrlBase = this.cfg.relayUrl.replace(/^http/, 'ws') + this.cfg.controlPath;
|
|
667
|
+
const wsUrl = `${wsUrlBase}${wsUrlBase.includes('?') ? '&' : '?'}deviceId=${encodeURIComponent(this.keys.deviceId ?? '')}`;
|
|
668
|
+
this._tunnel = connectConnector({
|
|
669
|
+
relayWsUrl: wsUrl,
|
|
670
|
+
deviceToken: this.keys.deviceToken,
|
|
671
|
+
keys: this.keys,
|
|
672
|
+
keysFile: this.cfg.keysFile,
|
|
673
|
+
sessionKeys: this.sessionKeys,
|
|
674
|
+
target: this.cfg.target,
|
|
675
|
+
// M4.x-c:mobile.* 本地截获(bridge 未接入时 handleMobileLocal 恒返回 null,行为同旧版);
|
|
676
|
+
// 2026-08:完整磁盘权限门禁——受限手机的 browse/mkdir/workspace.create 目标必须在
|
|
677
|
+
// 允许根内(phonePk 由 tunnel 随请求传入)
|
|
678
|
+
localHandler: (inner, phonePk) => this._mobileLocal(inner, phonePk),
|
|
679
|
+
onRevoke: (phonePk) => this._removeSessionKey(phonePk),
|
|
680
|
+
onConnection: (status) => {
|
|
681
|
+
this.state.connection = status;
|
|
682
|
+
this._emit();
|
|
683
|
+
},
|
|
684
|
+
onActivity: (phonePk) => this._markUsed(phonePk),
|
|
685
|
+
pendingApprovals: this.pendingApprovals, // 方案 A:事件流上镜像 pending 审批/提问
|
|
686
|
+
pushNotifier: this.pushNotifier, // 推送:审批/完成触发检测(对离线手机)
|
|
687
|
+
sessionTitles: this.sessionTitles, // 推送:sessionId→title 缓存(喂 session.list + 事件帧)
|
|
688
|
+
// M5.12 方案C:手机 /events 在线状态回调(设置页设备列表"连接状态"提示的数据源)
|
|
689
|
+
onPhoneOnlineChange: (phonePk, online) => this._setPhoneOnline(phonePk, online),
|
|
690
|
+
// 2026-08 token 自愈:控制 WS HELLO 认证被拒(token 过期/失效)时触发签名刷新,
|
|
691
|
+
// 让下一次重连带着有效 token——否则会用过期 token 反复重连死循环
|
|
692
|
+
onAuthFailure: () => this._refreshDeviceToken(),
|
|
693
|
+
// 2026-09:DSH 事件不再走 /api/events.mux|.host(已移除)。有 dshAdapter 时事件由
|
|
694
|
+
// 进程内订阅注入隧道,tunnel 不再连已死端点。
|
|
695
|
+
eventsDisabled: !!this.dsh,
|
|
696
|
+
// 2026-09 Phase 3:手机端焦点上报 → 开/关 session/follow 流重构消息级实时
|
|
697
|
+
onFocus: (phonePk, sessionId) => this._setFocusSession(sessionId),
|
|
698
|
+
});
|
|
699
|
+
this._stopConnector = this._tunnel;
|
|
700
|
+
// bridge 在 start 之后才接入(插件 apply 时序):补挂事件注入 + 审批缓存数据源
|
|
701
|
+
if (this.mobileBridge?.onEvent) {
|
|
702
|
+
this._bridgeUnsub = this.mobileBridge.onEvent((frame) => {
|
|
703
|
+
try {
|
|
704
|
+
this._tunnel.injectEvent(JSON.stringify(frame));
|
|
705
|
+
} catch {}
|
|
706
|
+
});
|
|
707
|
+
}
|
|
708
|
+
this._wireMobileBridgeProviders();
|
|
709
|
+
|
|
710
|
+
// 2026-09:DSH 0.1.2 起 /api/events.mux|.host 已移除(事件改 remote.mux)。connector 以
|
|
711
|
+
// DSH $events 远程流客户端身份运行(进程内 openRemoteEvents):接收 api-session/* emit +
|
|
712
|
+
// approval/request + user-questions/request 瀑布,转成手机端帧经合批通道注入隧道。
|
|
713
|
+
// 同时让 tunnel 不再连已死的 events 端点(tunnel.js 的 eventsDisabled)。
|
|
714
|
+
if (this.dsh?.startEventPump) {
|
|
715
|
+
this._eventsStop = this.dsh.startEventPump({
|
|
716
|
+
onFrames: (frames) => {
|
|
717
|
+
for (const f of frames) {
|
|
718
|
+
try {
|
|
719
|
+
const text = JSON.stringify(f);
|
|
720
|
+
this.pendingApprovals?.ingest?.(text); // 镜像进 cache(syncApprovals 离线/重连补拉)
|
|
721
|
+
// 2026-09:事件已不走 events.mux(eventsDisabled)→ 推送触发器改由进程内事件泵喂:
|
|
722
|
+
// approval/requested·question/requested(审批)与 host/session-status(完成)都在 pump 帧上。
|
|
723
|
+
this.pushNotifier?.ingest?.(text, this._offlinePhones());
|
|
724
|
+
this.sessionTitles?.ingest?.(text);
|
|
725
|
+
this._tunnel?.injectEvent(text);
|
|
726
|
+
} catch {}
|
|
727
|
+
}
|
|
728
|
+
},
|
|
729
|
+
onResolved: (frame) => {
|
|
730
|
+
try {
|
|
731
|
+
const text = JSON.stringify(frame);
|
|
732
|
+
this.pendingApprovals?.ingest?.(text); // resolved 帧按 approvalId/questionRpcId 剪枝
|
|
733
|
+
this._tunnel?.injectEvent(text);
|
|
734
|
+
} catch {}
|
|
735
|
+
},
|
|
736
|
+
onError: (e) => console.warn('[dshfly-remote-connector] events pump error:', e?.message),
|
|
737
|
+
});
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
// B3/D2:deviceToken 短 TTL(24h)在线续期。改为**按剩余寿命调度**(替换固定 12h
|
|
741
|
+
// setInterval):进程重启/冷启动不再"从此刻再数 12h"(会复用过期的旧 token),
|
|
742
|
+
// 而是到期前 LEAD 提前量主动续期;refresh 走 _refreshDeviceToken(rotate→device-login
|
|
743
|
+
// →register 兜底),不再 catch{} 静默吞错。
|
|
744
|
+
this._scheduleTokenRefresh();
|
|
745
|
+
|
|
746
|
+
// pair 完成后写回 keys 文件,常驻进程热加载新会话密钥
|
|
747
|
+
fs.watchFile(this.cfg.keysFile, { interval: 1000 }, () => {
|
|
748
|
+
try {
|
|
749
|
+
const fresh = JSON.parse(fs.readFileSync(this.cfg.keysFile, 'utf8'));
|
|
750
|
+
this.sessionKeys.clear();
|
|
751
|
+
for (const [k, v] of Object.entries(fresh.sessionKeys || {})) this.sessionKeys.set(k, v);
|
|
752
|
+
this.keys.sessionKeys = fresh.sessionKeys || {};
|
|
753
|
+
this.keys.lastUsed = fresh.lastUsed || {};
|
|
754
|
+
} catch {}
|
|
755
|
+
this._emit();
|
|
756
|
+
});
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
/** 按 device token 剩余寿命调度下一次续期(失败时短间隔重试)。 */
|
|
760
|
+
_scheduleTokenRefresh(retryDelayMs = null) {
|
|
761
|
+
if (this._tokenTimer) clearTimeout(this._tokenTimer);
|
|
762
|
+
this._tokenTimer = null;
|
|
763
|
+
const exp = tokenExpiryMs(this.keys.deviceToken);
|
|
764
|
+
let delay = retryDelayMs;
|
|
765
|
+
if (delay == null) {
|
|
766
|
+
if (exp != null) {
|
|
767
|
+
// 剩余寿命 - LEAD 即为续期时刻;至少留 60s 兜底(token 已过期会 60s 后再试)
|
|
768
|
+
delay = Math.max(60 * 1000, exp - Date.now() - TOKEN_REFRESH_LEAD_MS);
|
|
769
|
+
} else {
|
|
770
|
+
delay = 12 * 3600 * 1000; // 无 exp(异常)退回固定周期
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
this._tokenTimer = setTimeout(async () => {
|
|
774
|
+
let ok = false;
|
|
775
|
+
try {
|
|
776
|
+
if (!this.keys.deviceToken || !this.keys.deviceId) {
|
|
777
|
+
this._scheduleTokenRefresh(12 * 3600 * 1000);
|
|
778
|
+
return;
|
|
779
|
+
}
|
|
780
|
+
await this._refreshDeviceToken();
|
|
781
|
+
ok = true;
|
|
782
|
+
} catch (e) {
|
|
783
|
+
console.warn('[dshfly-remote-connector] token refresh failed:', e?.message);
|
|
784
|
+
} finally {
|
|
785
|
+
// 成功按新 exp 重新调度;失败短间隔重试(默认 5min)
|
|
786
|
+
this._scheduleTokenRefresh(ok ? null : 5 * 60 * 1000);
|
|
787
|
+
}
|
|
788
|
+
}, delay);
|
|
789
|
+
this._tokenTimer.unref?.();
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
_markUsed(phonePk) {
|
|
793
|
+
this.keys.lastUsed[phonePk] = Date.now();
|
|
794
|
+
clearTimeout(this._saveTimer);
|
|
795
|
+
this._saveTimer = setTimeout(() => saveKeys(this.keys, this.cfg.keysFile), 500);
|
|
796
|
+
// 状态通知防抖:_markUsed 随每条事件调用(合批前 54-150/s),直接 _emit 会打满 Electron tray
|
|
797
|
+
clearTimeout(this._emitTimer);
|
|
798
|
+
this._emitTimer = setTimeout(() => this._emit(), 1000);
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
_removeSessionKey(phonePk) {
|
|
802
|
+
delete this.keys.sessionKeys[phonePk];
|
|
803
|
+
this.sessionKeys.delete(phonePk);
|
|
804
|
+
this.phoneOnline.delete(phonePk); // 已解除配对的设备不再有连接状态
|
|
805
|
+
if (this.keys.lastUsed) delete this.keys.lastUsed[phonePk];
|
|
806
|
+
saveKeys(this.keys, this.cfg.keysFile);
|
|
807
|
+
this._emit();
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
/** 新建配对:返回二维码内容(deep link + 二维码图片 data URL),手机扫码后可调用 confirmPairing 完成。 */
|
|
811
|
+
async createPairing() {
|
|
812
|
+
const p = await this._callWithDeviceToken(() => this.cloud.createPairing(this.keys.deviceToken));
|
|
813
|
+
const fp = pairFingerprint(this.keys.identityPublicKey, this.keys.transportPublicKey);
|
|
814
|
+
// D1(方案 B):deep link 不再携带账号名(无账号层;手机配对前无凭证,扫码即授权)
|
|
815
|
+
const deepLink = `dshpair://${new URL(this.cfg.relayUrl).host}/p/${p.pairingId}?fp=${fp}`;
|
|
816
|
+
let qrDataUrl = null;
|
|
817
|
+
try {
|
|
818
|
+
const { default: QRCode } = await import('qrcode');
|
|
819
|
+
qrDataUrl = await QRCode.toDataURL(deepLink, { margin: 1, width: 240 });
|
|
820
|
+
} catch {}
|
|
821
|
+
return { pairingId: p.pairingId, fp, deepLink, qrDataUrl };
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
/**
|
|
825
|
+
* 配对状态(http-api 用)。A0:completed 时缓存手机公钥并计算短码——
|
|
826
|
+
* 未 pin 过的设备返回 shortCode(用户与手机屏幕比对);已 pin 返回 known:true(免码)。
|
|
827
|
+
* 返回 st 附带 shortCode/phoneName/known 字段。
|
|
828
|
+
*/
|
|
829
|
+
async getPairingStatus(pairingId) {
|
|
830
|
+
const st = await this.cloud.getPairing(pairingId);
|
|
831
|
+
if (st.status === 'completed' && st.phone?.pk_identity && st.phone?.pk_transport) {
|
|
832
|
+
// A0:拉取即缓存(展示的与派生的必须是同一份公钥)
|
|
833
|
+
this._pendingPairKeys.set(pairingId, {
|
|
834
|
+
phoneIdPk: st.phone.pk_identity,
|
|
835
|
+
phoneTrPk: st.phone.pk_transport,
|
|
836
|
+
name: st.phone.name || null,
|
|
837
|
+
});
|
|
838
|
+
const pinned = !!this.keys.pinnedPhones?.[st.phone.pk_identity];
|
|
839
|
+
st.shortCode = pinned ? null : pairShortCode(st.phone.pk_identity, st.phone.pk_transport);
|
|
840
|
+
st.phoneName = st.phone.name || null;
|
|
841
|
+
st.known = pinned;
|
|
842
|
+
}
|
|
843
|
+
return st;
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
/** 完成配对(用户在 GUI 点「确认」后调用):确认 + 派生会话密钥 + 持久化。
|
|
847
|
+
* opts.fullDisk:完整磁盘权限(2026-08,缺省 false=不允许;配对后可在设置页修改)。
|
|
848
|
+
* A0/A2:PC 身份签名 confirm;用【已缓存/已展示】的手机公钥派生;首次 TOFU pin。 */
|
|
849
|
+
async confirmPairing(pairingId, { fullDisk = false } = {}) {
|
|
850
|
+
let keys = this._pendingPairKeys.get(pairingId);
|
|
851
|
+
if (!keys) {
|
|
852
|
+
// 兜底(autoConfirm/未先轮询):拉取并缓存
|
|
853
|
+
const st = await this.cloud.getPairing(pairingId);
|
|
854
|
+
if (st.status !== 'completed' || !st.phone?.pk_identity || !st.phone?.pk_transport) {
|
|
855
|
+
throw new Error(`pairing not confirmable (${st.status})`);
|
|
856
|
+
}
|
|
857
|
+
keys = { phoneIdPk: st.phone.pk_identity, phoneTrPk: st.phone.pk_transport, name: st.phone.name || null };
|
|
858
|
+
this._pendingPairKeys.set(pairingId, keys);
|
|
859
|
+
}
|
|
860
|
+
// A2:PC 身份私钥签名(relay 用设备登记的 PC 公钥验签,中继不能冒充确认)
|
|
861
|
+
const sig = signPairingConfirm(this.keys.identitySecretKey, { pairingId, deviceId: this.keys.deviceId });
|
|
862
|
+
await this._callWithDeviceToken(() => this.cloud.confirmPairing(this.keys.deviceToken, pairingId, { sig }));
|
|
863
|
+
// A0:用已缓存公钥派生(绝不重新拉取)
|
|
864
|
+
const sk = deriveSessionKey(this.keys.transportSecretKey, keys.phoneTrPk);
|
|
865
|
+
this.keys.sessionKeys = this.keys.sessionKeys || {};
|
|
866
|
+
this.keys.sessionKeys[keys.phoneIdPk] = sk;
|
|
867
|
+
this.sessionKeys.set(keys.phoneIdPk, sk);
|
|
868
|
+
// TOFU pin(首次)
|
|
869
|
+
this.keys.pinnedPhones = this.keys.pinnedPhones || {};
|
|
870
|
+
this.keys.pinnedPhones[keys.phoneIdPk] = Date.now();
|
|
871
|
+
this.setPermission(keys.phoneIdPk, { fullDisk });
|
|
872
|
+
saveKeys(this.keys, this.cfg.keysFile);
|
|
873
|
+
this._emit();
|
|
874
|
+
this._pendingPairKeys.delete(pairingId);
|
|
875
|
+
return keys.phoneIdPk;
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
/** 拒绝配对:PC 身份签名(relay 验签,中继不能伪造拒绝)。 */
|
|
879
|
+
async rejectPairing(pairingId) {
|
|
880
|
+
const sig = signPairingConfirm(this.keys.identitySecretKey, { pairingId, deviceId: this.keys.deviceId });
|
|
881
|
+
return this._callWithDeviceToken(() => this.cloud.rejectPairing(this.keys.deviceToken, pairingId, { sig }));
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
/** 解除某台手机的配对(本地删除会话密钥;手机下次请求会收到 no session key 并提示重配)。 */
|
|
885
|
+
async unpair(phonePk) {
|
|
886
|
+
this._removeSessionKey(phonePk);
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
/** 停止:关闭连接、停掉热加载监听(Electron 退出 / 测试收尾时调用)。 */
|
|
890
|
+
stop() {
|
|
891
|
+
this._followStop?.();
|
|
892
|
+
this._followStop = null;
|
|
893
|
+
this._eventsStop?.();
|
|
894
|
+
this._eventsStop = null;
|
|
895
|
+
this._eventsUnsub?.();
|
|
896
|
+
this._eventsUnsub = null;
|
|
897
|
+
this._bridgeUnsub?.();
|
|
898
|
+
this._bridgeUnsub = null;
|
|
899
|
+
this._stopConnector?.();
|
|
900
|
+
if (this._rotateTimer) clearInterval(this._rotateTimer);
|
|
901
|
+
this._rotateTimer = null;
|
|
902
|
+
if (this._tokenTimer) clearTimeout(this._tokenTimer);
|
|
903
|
+
this._tokenTimer = null;
|
|
904
|
+
if (this._saveTimer) clearTimeout(this._saveTimer);
|
|
905
|
+
if (this._emitTimer) clearTimeout(this._emitTimer);
|
|
906
|
+
try {
|
|
907
|
+
fs.unwatchFile(this.cfg.keysFile);
|
|
908
|
+
} catch {}
|
|
909
|
+
}
|
|
910
|
+
}
|