dsh-remote-control 1.0.0
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/cordis.patch.yml +21 -0
- package/dist/bundle/index.js +33956 -0
- package/dist/src/core/clock.d.ts +45 -0
- package/dist/src/core/clock.js +83 -0
- package/dist/src/core/clock.js.map +1 -0
- package/dist/src/core/keys.d.ts +98 -0
- package/dist/src/core/keys.js +176 -0
- package/dist/src/core/keys.js.map +1 -0
- package/dist/src/core/pairing-window.d.ts +95 -0
- package/dist/src/core/pairing-window.js +92 -0
- package/dist/src/core/pairing-window.js.map +1 -0
- package/dist/src/core/runtime.d.ts +104 -0
- package/dist/src/core/runtime.js +491 -0
- package/dist/src/core/runtime.js.map +1 -0
- package/dist/src/core/sleep-policy.d.ts +81 -0
- package/dist/src/core/sleep-policy.js +134 -0
- package/dist/src/core/sleep-policy.js.map +1 -0
- package/dist/src/core/window.d.ts +88 -0
- package/dist/src/core/window.js +185 -0
- package/dist/src/core/window.js.map +1 -0
- package/dist/src/index.d.ts +70 -0
- package/dist/src/index.js +882 -0
- package/dist/src/index.js.map +1 -0
- package/dist/src/platform/carrier-services.d.ts +96 -0
- package/dist/src/platform/carrier-services.js +982 -0
- package/dist/src/platform/carrier-services.js.map +1 -0
- package/dist/src/platform/guard.d.ts +60 -0
- package/dist/src/platform/guard.js +124 -0
- package/dist/src/platform/guard.js.map +1 -0
- package/dist/src/platform/mock-kernel.d.ts +95 -0
- package/dist/src/platform/mock-kernel.js +180 -0
- package/dist/src/platform/mock-kernel.js.map +1 -0
- package/dist/src/platform/qr.d.ts +28 -0
- package/dist/src/platform/qr.js +80 -0
- package/dist/src/platform/qr.js.map +1 -0
- package/dist/src/platform/sleep-posix.d.ts +17 -0
- package/dist/src/platform/sleep-posix.js +91 -0
- package/dist/src/platform/sleep-posix.js.map +1 -0
- package/dist/src/ports/index.d.ts +246 -0
- package/dist/src/ports/index.js +2 -0
- package/dist/src/ports/index.js.map +1 -0
- package/dist/src/shell/config.d.ts +56 -0
- package/dist/src/shell/config.js +212 -0
- package/dist/src/shell/config.js.map +1 -0
- package/dist/src/shell/pairing-text.d.ts +38 -0
- package/dist/src/shell/pairing-text.js +22 -0
- package/dist/src/shell/pairing-text.js.map +1 -0
- package/dist/src/shell/status.d.ts +39 -0
- package/dist/src/shell/status.js +119 -0
- package/dist/src/shell/status.js.map +1 -0
- package/dist/src/transport/relay.d.ts +107 -0
- package/dist/src/transport/relay.js +359 -0
- package/dist/src/transport/relay.js.map +1 -0
- package/dist/tests/carrier-events.test.d.ts +1 -0
- package/dist/tests/carrier-events.test.js +88 -0
- package/dist/tests/carrier-events.test.js.map +1 -0
- package/dist/tests/carrier-services.test.d.ts +1 -0
- package/dist/tests/carrier-services.test.js +562 -0
- package/dist/tests/carrier-services.test.js.map +1 -0
- package/dist/tests/carrier-vocabulary.test.d.ts +1 -0
- package/dist/tests/carrier-vocabulary.test.js +149 -0
- package/dist/tests/carrier-vocabulary.test.js.map +1 -0
- package/dist/tests/clock-probe-timers.test.d.ts +1 -0
- package/dist/tests/clock-probe-timers.test.js +73 -0
- package/dist/tests/clock-probe-timers.test.js.map +1 -0
- package/dist/tests/command-result.test.d.ts +1 -0
- package/dist/tests/command-result.test.js +185 -0
- package/dist/tests/command-result.test.js.map +1 -0
- package/dist/tests/config.test.d.ts +1 -0
- package/dist/tests/config.test.js +223 -0
- package/dist/tests/config.test.js.map +1 -0
- package/dist/tests/guard.test.d.ts +1 -0
- package/dist/tests/guard.test.js +175 -0
- package/dist/tests/guard.test.js.map +1 -0
- package/dist/tests/ids-and-keys.test.d.ts +1 -0
- package/dist/tests/ids-and-keys.test.js +211 -0
- package/dist/tests/ids-and-keys.test.js.map +1 -0
- package/dist/tests/pairing-text.test.d.ts +1 -0
- package/dist/tests/pairing-text.test.js +62 -0
- package/dist/tests/pairing-text.test.js.map +1 -0
- package/dist/tests/pairing-window.test.d.ts +1 -0
- package/dist/tests/pairing-window.test.js +165 -0
- package/dist/tests/pairing-window.test.js.map +1 -0
- package/dist/tests/relay-client.test.d.ts +1 -0
- package/dist/tests/relay-client.test.js +557 -0
- package/dist/tests/relay-client.test.js.map +1 -0
- package/dist/tests/runtime.test.d.ts +1 -0
- package/dist/tests/runtime.test.js +963 -0
- package/dist/tests/runtime.test.js.map +1 -0
- package/dist/tests/sleep.test.d.ts +1 -0
- package/dist/tests/sleep.test.js +264 -0
- package/dist/tests/sleep.test.js.map +1 -0
- package/dist/tests/status.test.d.ts +1 -0
- package/dist/tests/status.test.js +238 -0
- package/dist/tests/status.test.js.map +1 -0
- package/dist/tests/window.test.d.ts +1 -0
- package/dist/tests/window.test.js +210 -0
- package/dist/tests/window.test.js.map +1 -0
- package/package.json +70 -0
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* config — 插件配置:合成、校验、夹取。
|
|
3
|
+
*
|
|
4
|
+
* 优先级与旧实现一致:**环境变量 > patch 注入的 config > 默认值**
|
|
5
|
+
* (取证 docs/legacy-spec/host-plugin-cordis.md §5.1)。
|
|
6
|
+
* **键名一律继承旧名**:这些键写在用户的 `~/.dsh/profiles/desktop/cordis.patch.yml` 里,
|
|
7
|
+
* 改名等于让线上配置静默失效——那是"语义继承"允许、而"结构照抄"禁止的分界线上
|
|
8
|
+
* 唯一必须站在继承一侧的维度(复核 §5.4)。
|
|
9
|
+
*
|
|
10
|
+
* 校验只拦"真的不能工作"的两件事(serverUrl 与 hostToken),其余一律 warn + 夹回默认值:
|
|
11
|
+
* 一个 GUI 宿主里,插件因为某个可选参数写错而不加载,比带着次优参数跑起来更难排查。
|
|
12
|
+
* error 时也不抛,只写一次 status.json 留下面包屑(§5.2 的原话动机:
|
|
13
|
+
* 「otherwise "why is nothing happening?" has no answer」)。
|
|
14
|
+
*/
|
|
15
|
+
import { homedir } from 'node:os';
|
|
16
|
+
import path from 'node:path';
|
|
17
|
+
import { z } from 'zod';
|
|
18
|
+
export const DEFAULT_CONFIG = {
|
|
19
|
+
enabled: true,
|
|
20
|
+
serverUrl: 'ws://127.0.0.1:8787',
|
|
21
|
+
hostToken: '',
|
|
22
|
+
hostTokenEnv: 'DRC_HOST_TOKEN',
|
|
23
|
+
hostId: '',
|
|
24
|
+
hostLabel: 'dsh-host',
|
|
25
|
+
keepAwake: { enabled: true, idleReleaseSec: 300, keepDisplay: false },
|
|
26
|
+
mockBridge: false,
|
|
27
|
+
statusFile: path.join(homedir(), '.dsh', 'dsh-remote-control', 'status.json'),
|
|
28
|
+
unarchiveOnPrompt: true,
|
|
29
|
+
pairOnStartSec: 0,
|
|
30
|
+
pairTtlMs: 120_000,
|
|
31
|
+
// **默认走图片**。2026-10-02 取证推翻了原判断(原判断:文本二维码在任何等宽渲染器下
|
|
32
|
+
// 都能扫,图片是可有可无的旁路):
|
|
33
|
+
// · DSH 命令卡的等宽输出块是 `white-space: pre` + `line-height: 1.6`(app bundle 里
|
|
34
|
+
// 的那条规则,实测行间留白约占行高 37%),每两行模块之间被塞进一条整行宽的缝隙,
|
|
35
|
+
// 半块二维码被横切成条。
|
|
36
|
+
// · 用用户真机贴出来的那段输出做受控实验:把字号行距调到 1.0 时 zxing 解得出来,
|
|
37
|
+
// 调到 ≥1.15 就扫不出来;而 DSH 固定 1.6。也就是说"文本码能扫"在**唯一宿主**上不成立。
|
|
38
|
+
// · 而 ANSI 反色救不了:TerminalBlock 把每行渲染成内联 span,背景只覆盖字形盒,
|
|
39
|
+
// 不覆盖行间留白(见 dsh-client-ui-primitives 的 parseAnsiLines/renderLine)。
|
|
40
|
+
// 图片是自包含位图矩阵,跟宿主的行高、字体、配色都无关——这才是"扫得出来"的那条路。
|
|
41
|
+
qrImage: true,
|
|
42
|
+
// 写图片 ≠ 替用户打开查看器:仍然默认关(原话「不要用打开一个图片的方式」)。
|
|
43
|
+
qrOpen: false,
|
|
44
|
+
qrAnsi: false,
|
|
45
|
+
qrStyle: 'half',
|
|
46
|
+
carrierGraceMs: 5000,
|
|
47
|
+
takeOverQuestions: false,
|
|
48
|
+
approvalTimeoutSec: 180,
|
|
49
|
+
listingRefreshSec: 15,
|
|
50
|
+
conversationIdleTtlSec: 86_400,
|
|
51
|
+
};
|
|
52
|
+
const loopback = /^wss?:\/\/(localhost|127\.0\.0\.1|\[::1\])(:|\/|$)/;
|
|
53
|
+
const wsUrl = /^wss?:\/\/.+/;
|
|
54
|
+
/**
|
|
55
|
+
* 环境变量里的数字:只收「有限的非负数」,其余折成 0(= 关)。
|
|
56
|
+
*
|
|
57
|
+
* 为什么不能写成 `Number(v) || 0`:`Number('Infinity')` 是 Infinity,而它是**真值**,
|
|
58
|
+
* `|| 0` 放过去了——`pairOnStartSec=Infinity` 的语义是"这张仍然带着 PSK 的配对码
|
|
59
|
+
* 永久有效",正好是这条配置想避免的事。NaN 侥幸会被 `|| 0` 折掉,但那是巧合不是规则。
|
|
60
|
+
*/
|
|
61
|
+
function envSeconds(value) {
|
|
62
|
+
const parsed = Number(value);
|
|
63
|
+
return Number.isFinite(parsed) && parsed >= 0 ? parsed : 0;
|
|
64
|
+
}
|
|
65
|
+
/** 布尔开关的环境变量写法:`1`/`0`(`true`/`false` 也认)是这里的运维习惯,其余值不猜、退回上层值。 */
|
|
66
|
+
function envFlag(value, fallback) {
|
|
67
|
+
if (value === '1' || value === 'true')
|
|
68
|
+
return true;
|
|
69
|
+
if (value === '0' || value === 'false')
|
|
70
|
+
return false;
|
|
71
|
+
return fallback;
|
|
72
|
+
}
|
|
73
|
+
/** 合成三级配置。`injected` 来自 cordis 的 patch 层。 */
|
|
74
|
+
export function readConfig(injected, env = process.env) {
|
|
75
|
+
const merged = {
|
|
76
|
+
...DEFAULT_CONFIG,
|
|
77
|
+
...(injected ?? {}),
|
|
78
|
+
keepAwake: { ...DEFAULT_CONFIG.keepAwake, ...(injected?.keepAwake ?? {}) },
|
|
79
|
+
};
|
|
80
|
+
const fromEnv = (name) => env[name];
|
|
81
|
+
if (fromEnv('DRC_SERVER_URL'))
|
|
82
|
+
merged.serverUrl = env.DRC_SERVER_URL;
|
|
83
|
+
if (fromEnv('DRC_HOST_LABEL'))
|
|
84
|
+
merged.hostLabel = env.DRC_HOST_LABEL;
|
|
85
|
+
if (fromEnv('DRC_HOST_ID'))
|
|
86
|
+
merged.hostId = env.DRC_HOST_ID;
|
|
87
|
+
if (fromEnv('DRC_PAIR_ON_START_SEC'))
|
|
88
|
+
merged.pairOnStartSec = envSeconds(env.DRC_PAIR_ON_START_SEC);
|
|
89
|
+
// 这两个开关默认就是关的,所以环境变量只用于**打开**(`=1`);
|
|
90
|
+
// 仍然接受 `=0` 显式关闭,覆盖 patch 里开的情况。
|
|
91
|
+
if (fromEnv('DRC_QR_IMAGE'))
|
|
92
|
+
merged.qrImage = envFlag(env.DRC_QR_IMAGE, merged.qrImage);
|
|
93
|
+
if (fromEnv('DRC_QR_OPEN'))
|
|
94
|
+
merged.qrOpen = envFlag(env.DRC_QR_OPEN, merged.qrOpen);
|
|
95
|
+
if (fromEnv('DRC_QR_ANSI'))
|
|
96
|
+
merged.qrAnsi = envFlag(env.DRC_QR_ANSI, merged.qrAnsi);
|
|
97
|
+
if (fromEnv('DRC_QR_STYLE')) {
|
|
98
|
+
const style = env.DRC_QR_STYLE;
|
|
99
|
+
// 不猜:值不认识就保留上层(patch 或默认)的值,并由 validateConfig 负责 warn。
|
|
100
|
+
if (['ascii', 'block', 'half'].includes(style))
|
|
101
|
+
merged.qrStyle = style;
|
|
102
|
+
}
|
|
103
|
+
if (fromEnv('DRC_MOCK_BRIDGE') === '1')
|
|
104
|
+
merged.mockBridge = true;
|
|
105
|
+
if (fromEnv('DRC_TAKE_OVER_QUESTIONS') === '1')
|
|
106
|
+
merged.takeOverQuestions = true;
|
|
107
|
+
// token 只从环境变量取:patch 文件是 600 权限的 yaml,但把凭据写在配置文件里
|
|
108
|
+
// 比留在环境变量里更容易被顺手提交或贴进工单。
|
|
109
|
+
if (merged.hostTokenEnv)
|
|
110
|
+
merged.hostToken = env[merged.hostTokenEnv] ?? merged.hostToken;
|
|
111
|
+
return merged;
|
|
112
|
+
}
|
|
113
|
+
const secondsSchema = z.number().finite();
|
|
114
|
+
/** 校验 + 就地夹取。error 级问题会让上层拒绝启动 runtime(但仍留 status 面包屑)。 */
|
|
115
|
+
export function validateConfig(config) {
|
|
116
|
+
const problems = [];
|
|
117
|
+
if (!wsUrl.test(config.serverUrl)) {
|
|
118
|
+
problems.push({
|
|
119
|
+
level: 'error',
|
|
120
|
+
field: 'serverUrl',
|
|
121
|
+
message: `serverUrl 必须是 ws:// 或 wss:// 开头的地址,收到 ${JSON.stringify(config.serverUrl)}`,
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
else if (config.serverUrl.startsWith('ws://') && !loopback.test(config.serverUrl)) {
|
|
125
|
+
problems.push({
|
|
126
|
+
level: 'warn',
|
|
127
|
+
field: 'serverUrl',
|
|
128
|
+
message: '向非回环地址走明文 ws://,配对码与密文在链路上是可见的(生产请用 wss://)',
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
if (!config.hostToken) {
|
|
132
|
+
problems.push({
|
|
133
|
+
level: 'error',
|
|
134
|
+
field: 'hostToken',
|
|
135
|
+
message: `缺少 host token:请把中继的 DRC_HOST_TOKEN 写进环境变量 ${config.hostTokenEnv || '(hostTokenEnv 为空)'},或在 patch 里直接给 hostToken`,
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
else if (config.hostToken.length < 24) {
|
|
139
|
+
problems.push({
|
|
140
|
+
level: 'warn',
|
|
141
|
+
field: 'hostToken',
|
|
142
|
+
message: 'host token 短于 24 字符,建议 `openssl rand -hex 24`',
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
if (!secondsSchema.safeParse(config.keepAwake.idleReleaseSec).success || config.keepAwake.idleReleaseSec < 0) {
|
|
146
|
+
problems.push({
|
|
147
|
+
level: 'warn',
|
|
148
|
+
field: 'keepAwake.idleReleaseSec',
|
|
149
|
+
message: '不是有效的非负秒数,已夹回 300(KeepAwake 绝不能拿到 NaN)',
|
|
150
|
+
});
|
|
151
|
+
config.keepAwake.idleReleaseSec = 300;
|
|
152
|
+
}
|
|
153
|
+
if (config.pairTtlMs <= 0) {
|
|
154
|
+
problems.push({ level: 'warn', field: 'pairTtlMs', message: '必须为正数,已夹回 120000' });
|
|
155
|
+
config.pairTtlMs = 120_000;
|
|
156
|
+
}
|
|
157
|
+
if (!secondsSchema.safeParse(config.pairOnStartSec).success || config.pairOnStartSec < 0) {
|
|
158
|
+
// patch 文件里写 `pairOnStartSec: .inf` 也会走到这里:env 侧已经折掉了,注入侧不能漏。
|
|
159
|
+
problems.push({ level: 'warn', field: 'pairOnStartSec', message: '必须是非负的有限秒数,已折回 0(关闭自动发码)' });
|
|
160
|
+
config.pairOnStartSec = 0;
|
|
161
|
+
}
|
|
162
|
+
if (config.qrOpen && !config.qrImage) {
|
|
163
|
+
problems.push({
|
|
164
|
+
level: 'warn',
|
|
165
|
+
field: 'qrOpen',
|
|
166
|
+
message: 'qrOpen 需要 qrImage=true 才会写图片文件;现在只出文本二维码(文本码在 DSH 命令卡里扫不出来,见 config.ts 默认值注释)',
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
if (!['ascii', 'block', 'half'].includes(config.qrStyle)) {
|
|
170
|
+
problems.push({
|
|
171
|
+
level: 'warn',
|
|
172
|
+
field: 'qrStyle',
|
|
173
|
+
message: `qrStyle 只认 ascii/block/half,收到 ${JSON.stringify(config.qrStyle)},已退回 half`,
|
|
174
|
+
});
|
|
175
|
+
config.qrStyle = 'half';
|
|
176
|
+
}
|
|
177
|
+
if (!secondsSchema.safeParse(config.conversationIdleTtlSec).success || config.conversationIdleTtlSec <= 0) {
|
|
178
|
+
// 0 或非法值**不表示"永不剪枝"**:永不剪枝就是那条无界的密钥簿。夹回默认值并说明。
|
|
179
|
+
problems.push({
|
|
180
|
+
level: 'warn',
|
|
181
|
+
field: 'conversationIdleTtlSec',
|
|
182
|
+
message: '必须是正数秒;这里不接受"永不剪枝"(无界的 PSK 簿是复核 🟡8 的原始缺陷),已夹回 86400',
|
|
183
|
+
});
|
|
184
|
+
config.conversationIdleTtlSec = 86_400;
|
|
185
|
+
}
|
|
186
|
+
if (config.pairOnStartSec > 0) {
|
|
187
|
+
problems.push({
|
|
188
|
+
level: 'warn',
|
|
189
|
+
field: 'pairOnStartSec',
|
|
190
|
+
message: '开启后会把"仍然有效的配对码(含 PSK)"发布进 0600 的 status.json;这是验证辅助,用完请关掉',
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
if (!config.statusFile) {
|
|
194
|
+
problems.push({ level: 'warn', field: 'statusFile', message: '为空将关闭状态快照;GUI 宿主里这是唯一的排错入口' });
|
|
195
|
+
}
|
|
196
|
+
if (config.takeOverQuestions) {
|
|
197
|
+
problems.push({
|
|
198
|
+
level: 'warn',
|
|
199
|
+
field: 'takeOverQuestions',
|
|
200
|
+
message: '提问接管会**取代桌面 UI 的提问能力**(ctx.userQuestions 只允许一个活跃 provider);手机端不在线时提问会直接失败',
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
return problems;
|
|
204
|
+
}
|
|
205
|
+
/** 令牌脱敏:日志与 status.json 里都只允许出现这个形态。 */
|
|
206
|
+
export function redactSecret(value) {
|
|
207
|
+
if (value.length <= 8)
|
|
208
|
+
return '****';
|
|
209
|
+
return `${value.slice(0, 4)}…${value.slice(-2)}(${value.length})`;
|
|
210
|
+
}
|
|
211
|
+
export { redactSecret as redact };
|
|
212
|
+
//# sourceMappingURL=config.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"config.js","sourceRoot":"","sources":["../../../src/shell/config.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pairing-text — `/drc pair` 的文本输出(纯函数,所以这条拍板能被单测锁住)。
|
|
3
|
+
*
|
|
4
|
+
* **2026-10-02 翻转**:原拍板是「出文本二维码,图片版是显式旁路」。真机取证推翻了它——
|
|
5
|
+
* DSH 命令卡把等宽输出按 `line-height: 1.6` 渲染,行间留白把半块二维码横切成条,
|
|
6
|
+
* zxing 在 ≥1.15 行距就解不出来(实测,见 `shell/config.ts` 的 `qrImage` 默认值注释)。
|
|
7
|
+
* 所以现在**默认输出图片路径**,文本二维码降级为 `qrImage:false` 时的退路。
|
|
8
|
+
*
|
|
9
|
+
* 无论走哪条路,输出本身必须自足:6 位配对码 + 可粘贴的 URI + TTL + 一次性提示。
|
|
10
|
+
* 这条如果没有测试,将来很容易又"顺手加一句 open 更好用"。
|
|
11
|
+
*
|
|
12
|
+
* 为什么最终仍可能落到文本:宿主的命令 API 只接受纯文本结果(没有图片/附件返回类型),
|
|
13
|
+
* 所以我们能把 PNG 写到磁盘、把**路径**写进文本,但不能把图直接塞进卡片。
|
|
14
|
+
*/
|
|
15
|
+
export interface PairingTextView {
|
|
16
|
+
/** 6 位配对码。 */
|
|
17
|
+
token: string;
|
|
18
|
+
/** 完整的 `dshr:/p?...` 载荷:既是二维码的内容,也是可粘贴的那一行。 */
|
|
19
|
+
qr: string;
|
|
20
|
+
/** 本地记录的过期时刻(毫秒);服务端权威 TTL 到达后已改写。 */
|
|
21
|
+
expiresAt: number;
|
|
22
|
+
/** 当前时刻(注入,便于测试)。 */
|
|
23
|
+
now: number;
|
|
24
|
+
qrImage: boolean;
|
|
25
|
+
/**
|
|
26
|
+
* 二维码 PNG 的**绝对路径**,由调用方按当前会话的工作区解析好后传进来
|
|
27
|
+
* (`<workspace>/.dsh/pairing-qr.png`,解析不出工作区时是 `~/.dsh/pairing-qr.png`)。
|
|
28
|
+
* 这里是纯函数,不认识"工作区"这个概念,也就不该自己去拼路径——拼法只有 `shell/status.ts`
|
|
29
|
+
* 一处,卡片上印的路径与真正落盘的路径因此不可能分叉。
|
|
30
|
+
* 空串 = 这一版没有图(图片模式关掉,或 statusFile 没配),正文不许报路径。
|
|
31
|
+
*/
|
|
32
|
+
imageFile: string;
|
|
33
|
+
/** 已经渲染好的终端二维码;`qrImage` 模式下不需要它,所以可选。 */
|
|
34
|
+
terminalQr?: string;
|
|
35
|
+
}
|
|
36
|
+
/** 中继没连上时的说法:必须指出去哪看原因,而不是"失败了"。 */
|
|
37
|
+
export declare const PAIR_UNAVAILABLE_TEXT = "\u4E2D\u7EE7\u8FD8\u6CA1\u8FDE\u4E0A\uFF0C\u6682\u65F6\u65E0\u6CD5\u914D\u5BF9\u3002\u770B status.json \u7684 relayProblem\u3002";
|
|
38
|
+
export declare function pairingPairText(view: PairingTextView): string;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/** 中继没连上时的说法:必须指出去哪看原因,而不是"失败了"。 */
|
|
2
|
+
export const PAIR_UNAVAILABLE_TEXT = '中继还没连上,暂时无法配对。看 status.json 的 relayProblem。';
|
|
3
|
+
export function pairingPairText(view) {
|
|
4
|
+
const ttlSec = Math.max(0, Math.round((view.expiresAt - view.now) / 1000));
|
|
5
|
+
const lines = [`配对码 ${view.token} · ${ttlSec} 秒内有效 · 一次性`, '小程序里扫码,或手输这 6 位数字。'];
|
|
6
|
+
if (view.qrImage && view.imageFile) {
|
|
7
|
+
// 图片是默认路径:正文只说"扫这张图",不再塞那段在 DSH 里扫不出来的文本码。
|
|
8
|
+
// "打开这张 PNG"只能当兜底:`/drc pair` 之后右栏会**自己弹出**二维码
|
|
9
|
+
// (`packages/presentation`,装了那一行才成立),把开图写成主路径等于让用户
|
|
10
|
+
// 白跑一趟去找文件——而这正是本命令当初被要求别做的事。
|
|
11
|
+
lines.push('', `二维码会自动弹出到右栏;没弹出就打开这张 PNG 再扫:${view.imageFile}`);
|
|
12
|
+
}
|
|
13
|
+
else if (view.terminalQr) {
|
|
14
|
+
lines.push('', view.terminalQr);
|
|
15
|
+
}
|
|
16
|
+
// 扫不出来时(比例字体、吞字形的渲染器、或行高把码切开了)这条路仍然成立:配对页可以直接粘这一行。
|
|
17
|
+
lines.push('', view.qr);
|
|
18
|
+
if (!view.qrImage)
|
|
19
|
+
lines.push('', '(如需图片版:把 patch 里的 qrImage 设为 true)');
|
|
20
|
+
return lines.join('\n');
|
|
21
|
+
}
|
|
22
|
+
//# sourceMappingURL=pairing-text.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"pairing-text.js","sourceRoot":"","sources":["../../../src/shell/pairing-text.ts"],"names":[],"mappings":"AAoCA,oCAAoC;AACpC,MAAM,CAAC,MAAM,qBAAqB,GAAG,6CAA6C,CAAA;AAElF,MAAM,UAAU,eAAe,CAAC,IAAqB;IACnD,MAAM,MAAM,GAAG,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,IAAI,CAAC,KAAK,CAAC,CAAC,IAAI,CAAC,SAAS,GAAG,IAAI,CAAC,GAAG,CAAC,GAAG,IAAI,CAAC,CAAC,CAAA;IAC1E,MAAM,KAAK,GAAG,CAAC,OAAO,IAAI,CAAC,KAAK,MAAM,MAAM,aAAa,EAAE,oBAAoB,CAAC,CAAA;IAChF,IAAI,IAAI,CAAC,OAAO,IAAI,IAAI,CAAC,SAAS,EAAE,CAAC;QACnC,2CAA2C;QAC3C,+CAA+C;QAC/C,mDAAmD;QACnD,8BAA8B;QAC9B,KAAK,CAAC,IAAI,CAAC,EAAE,EAAE,+BAA+B,IAAI,CAAC,SAAS,EAAE,CAAC,CAAA;IACjE,CAAC;SAAM,IAAI,IAAI,CAAC,UAAU,EAAE,CAAC;QAC3B,KAAK,CAAC,IAAI,CAAC,EAAE,EAAE,IAAI,CAAC,UAAU,CAAC,CAAA;IACjC,CAAC;IACD,mDAAmD;IACnD,KAAK,CAAC,IAAI,CAAC,EAAE,EAAE,IAAI,CAAC,EAAE,CAAC,CAAA;IACvB,IAAI,CAAC,IAAI,CAAC,OAAO;QAAE,KAAK,CAAC,IAAI,CAAC,EAAE,EAAE,oCAAoC,CAAC,CAAA;IACvE,OAAO,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,CAAA;AACzB,CAAC"}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { Clock, StatusSink } from '../ports/index.js';
|
|
2
|
+
export declare class StatusFile implements StatusSink {
|
|
3
|
+
private readonly file;
|
|
4
|
+
private readonly clock;
|
|
5
|
+
private readonly refreshMs;
|
|
6
|
+
private timer;
|
|
7
|
+
private lastWritten;
|
|
8
|
+
constructor(file: string, clock: Clock, refreshMs?: number);
|
|
9
|
+
write(snapshot: Record<string, unknown>): void;
|
|
10
|
+
/**
|
|
11
|
+
* 打开定时器刷新(调用方提供的是**纯构造器**:只负责返回当下最新的状态)。
|
|
12
|
+
*
|
|
13
|
+
* 写盘这件事归本类,不归调用方:第一版这里只 `refresh()` 把返回值丢掉了,
|
|
14
|
+
* 于是 status.json 永远停在启动那一版——而 live-e2e 与外部脚本取配对码读的就是
|
|
15
|
+
* 这个文件(取证 docs/legacy-spec/host-plugin-cordis.md §5.3 的"年龄 < 25s"判据)。
|
|
16
|
+
* 更糟的是 `carrier`/`relay` 再也不更新,GUI 宿主里唯一的排错入口变成了死数据。
|
|
17
|
+
*/
|
|
18
|
+
start(refresh: () => Record<string, unknown>): void;
|
|
19
|
+
stop(): void;
|
|
20
|
+
/** 此刻是否在按节拍刷新(测试与 status 自身的 `refreshing` 字段读它)。 */
|
|
21
|
+
get refreshing(): boolean;
|
|
22
|
+
get path(): string;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* 配对二维码 PNG 的落点(0600):**当前会话的工作区**下的 `.dsh/pairing-qr.png`。
|
|
26
|
+
*
|
|
27
|
+
* 2026-10-02 用户拍板改的落点(原话「要保存到工作区的 .dsh 不是 home 下的 .dsh」):
|
|
28
|
+
* 二维码属于"这次配对属于哪个项目"这件事,跟着会话的工作区走比跟着 home 走更符合直觉,
|
|
29
|
+
* 也不会在用户机器上多出一个与项目无关的固定文件。
|
|
30
|
+
*
|
|
31
|
+
* `workspaceDir` 解析不出来时退回 `~/.dsh/pairing-qr.png`(配置目录根部)——**不是**退回
|
|
32
|
+
* 与 status.json 同目录:PNG 是给用户/扫码器看的东西,不该埋进 `~/.dsh/dsh-remote-control/`
|
|
33
|
+
* 这个实现目录里。
|
|
34
|
+
*
|
|
35
|
+
* statusFile 只当开关用——空串表示这一行没配过,此时返回空串,调用方据此退回文本码
|
|
36
|
+
* (空串路径绝不能去 `path.join`,否则 PNG 会写到进程 CWD)。
|
|
37
|
+
*/
|
|
38
|
+
export declare function pairingImagePath(statusFile: string, workspaceDir?: string): string;
|
|
39
|
+
export declare function writePrivateFile(file: string, data: Buffer | string): boolean;
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* status — 运行状态快照(`~/.dsh/dsh-remote-control/status.json`)。
|
|
3
|
+
*
|
|
4
|
+
* 为什么非有不可:宿主是 GUI 应用,stdout 没人看,插件"没反应"时用户与开发者
|
|
5
|
+
* 都没有入口。旧实现把它做成排错第一入口,靠两个字段区分了两类完全不同的故障——
|
|
6
|
+
* `carrier`(`services` 真内核 / `mock` 内存替身 / `none` 配置不合法)
|
|
7
|
+
* 与 `relay` + `relayProblem`(连不上中继还是内核没接上)。
|
|
8
|
+
* 取证 docs/legacy-spec/host-plugin-cordis.md §5.3。
|
|
9
|
+
*
|
|
10
|
+
* 三条实现要求:
|
|
11
|
+
* 1. **0600 + 临时文件改名**。文件里可能出现"仍然有效的配对码 + PSK"(pairOnStartSec),
|
|
12
|
+
* 半写状态被读到也会让人误判。
|
|
13
|
+
* 2. **绝不含 token**(测试断言 `hostToken` 这个键不存在,只能出现脱敏形态)。
|
|
14
|
+
* 3. **按定时器刷新,不按消费刷新**。这条不是偷懒:它决定了外部脚本只能接受
|
|
15
|
+
* "年龄 < 25s 且本轮未用过"的配对码(取证 HANDOFF.md §4.5 第 3 条)。
|
|
16
|
+
*/
|
|
17
|
+
import { mkdirSync, renameSync, writeFileSync } from 'node:fs';
|
|
18
|
+
import { homedir } from 'node:os';
|
|
19
|
+
import path from 'node:path';
|
|
20
|
+
export class StatusFile {
|
|
21
|
+
file;
|
|
22
|
+
clock;
|
|
23
|
+
refreshMs;
|
|
24
|
+
timer;
|
|
25
|
+
lastWritten = 0;
|
|
26
|
+
constructor(file, clock, refreshMs = 3000) {
|
|
27
|
+
this.file = file;
|
|
28
|
+
this.clock = clock;
|
|
29
|
+
this.refreshMs = refreshMs;
|
|
30
|
+
}
|
|
31
|
+
write(snapshot) {
|
|
32
|
+
if (!this.file)
|
|
33
|
+
return;
|
|
34
|
+
try {
|
|
35
|
+
mkdirSync(path.dirname(this.file), { recursive: true });
|
|
36
|
+
const tmp = `${this.file}.tmp-${process.pid}`;
|
|
37
|
+
// mode 在文件已存在时不生效,所以再 chmod 一次。
|
|
38
|
+
writeFileSync(tmp, JSON.stringify({ ...snapshot, updatedAt: new Date().toISOString(), pid: process.pid }, null, 2) + '\n', {
|
|
39
|
+
mode: 0o600,
|
|
40
|
+
});
|
|
41
|
+
renameSync(tmp, this.file);
|
|
42
|
+
this.lastWritten = this.clock.now();
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
// 快照写失败绝不能影响主流程;排错入口没了是遗憾,插件崩了是事故。
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* 打开定时器刷新(调用方提供的是**纯构造器**:只负责返回当下最新的状态)。
|
|
50
|
+
*
|
|
51
|
+
* 写盘这件事归本类,不归调用方:第一版这里只 `refresh()` 把返回值丢掉了,
|
|
52
|
+
* 于是 status.json 永远停在启动那一版——而 live-e2e 与外部脚本取配对码读的就是
|
|
53
|
+
* 这个文件(取证 docs/legacy-spec/host-plugin-cordis.md §5.3 的"年龄 < 25s"判据)。
|
|
54
|
+
* 更糟的是 `carrier`/`relay` 再也不更新,GUI 宿主里唯一的排错入口变成了死数据。
|
|
55
|
+
*/
|
|
56
|
+
start(refresh) {
|
|
57
|
+
if (!this.file || this.timer !== undefined)
|
|
58
|
+
return;
|
|
59
|
+
const tick = () => {
|
|
60
|
+
// 先续期再写:写盘内部已经吞异常,但构造器是调用方的代码,
|
|
61
|
+
// 它抛了也不该让刷新节拍断掉——节拍一断就等于回到"停在启动那一版"。
|
|
62
|
+
this.timer = this.clock.setTimeout(tick, this.refreshMs);
|
|
63
|
+
try {
|
|
64
|
+
this.write(refresh());
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
/* 同上:状态入口不许成为崩溃源 */
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
this.timer = this.clock.setTimeout(tick, this.refreshMs);
|
|
71
|
+
}
|
|
72
|
+
stop() {
|
|
73
|
+
if (this.timer !== undefined) {
|
|
74
|
+
this.clock.clearTimeout(this.timer);
|
|
75
|
+
this.timer = undefined;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
/** 此刻是否在按节拍刷新(测试与 status 自身的 `refreshing` 字段读它)。 */
|
|
79
|
+
get refreshing() {
|
|
80
|
+
return this.timer !== undefined;
|
|
81
|
+
}
|
|
82
|
+
get path() {
|
|
83
|
+
return this.file;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* 配对二维码 PNG 的落点(0600):**当前会话的工作区**下的 `.dsh/pairing-qr.png`。
|
|
88
|
+
*
|
|
89
|
+
* 2026-10-02 用户拍板改的落点(原话「要保存到工作区的 .dsh 不是 home 下的 .dsh」):
|
|
90
|
+
* 二维码属于"这次配对属于哪个项目"这件事,跟着会话的工作区走比跟着 home 走更符合直觉,
|
|
91
|
+
* 也不会在用户机器上多出一个与项目无关的固定文件。
|
|
92
|
+
*
|
|
93
|
+
* `workspaceDir` 解析不出来时退回 `~/.dsh/pairing-qr.png`(配置目录根部)——**不是**退回
|
|
94
|
+
* 与 status.json 同目录:PNG 是给用户/扫码器看的东西,不该埋进 `~/.dsh/dsh-remote-control/`
|
|
95
|
+
* 这个实现目录里。
|
|
96
|
+
*
|
|
97
|
+
* statusFile 只当开关用——空串表示这一行没配过,此时返回空串,调用方据此退回文本码
|
|
98
|
+
* (空串路径绝不能去 `path.join`,否则 PNG 会写到进程 CWD)。
|
|
99
|
+
*/
|
|
100
|
+
export function pairingImagePath(statusFile, workspaceDir) {
|
|
101
|
+
if (!statusFile)
|
|
102
|
+
return '';
|
|
103
|
+
if (workspaceDir)
|
|
104
|
+
return path.join(workspaceDir, '.dsh', 'pairing-qr.png');
|
|
105
|
+
return path.join(homedir(), '.dsh', 'pairing-qr.png');
|
|
106
|
+
}
|
|
107
|
+
export function writePrivateFile(file, data) {
|
|
108
|
+
if (!file)
|
|
109
|
+
return false;
|
|
110
|
+
try {
|
|
111
|
+
mkdirSync(path.dirname(file), { recursive: true });
|
|
112
|
+
writeFileSync(file, data, { mode: 0o600 });
|
|
113
|
+
return true;
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
//# sourceMappingURL=status.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"status.js","sourceRoot":"","sources":["../../../src/shell/status.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AACH,OAAO,EAAE,SAAS,EAAE,UAAU,EAAE,aAAa,EAAE,MAAM,SAAS,CAAA;AAC9D,OAAO,EAAE,OAAO,EAAE,MAAM,SAAS,CAAA;AACjC,OAAO,IAAI,MAAM,WAAW,CAAA;AAK5B,MAAM,OAAO,UAAU;IAKF;IACA;IACA;IANX,KAAK,CAAS;IACd,WAAW,GAAG,CAAC,CAAA;IAEvB,YACmB,IAAY,EACZ,KAAY,EACZ,YAAY,IAAI;QAFhB,SAAI,GAAJ,IAAI,CAAQ;QACZ,UAAK,GAAL,KAAK,CAAO;QACZ,cAAS,GAAT,SAAS,CAAO;IAChC,CAAC;IAEJ,KAAK,CAAC,QAAiC;QACrC,IAAI,CAAC,IAAI,CAAC,IAAI;YAAE,OAAM;QACtB,IAAI,CAAC;YACH,SAAS,CAAC,IAAI,CAAC,OAAO,CAAC,IAAI,CAAC,IAAI,CAAC,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAA;YACvD,MAAM,GAAG,GAAG,GAAG,IAAI,CAAC,IAAI,QAAQ,OAAO,CAAC,GAAG,EAAE,CAAA;YAC7C,gCAAgC;YAChC,aAAa,CACX,GAAG,EACH,IAAI,CAAC,SAAS,CAAC,EAAE,GAAG,QAAQ,EAAE,SAAS,EAAE,IAAI,IAAI,EAAE,CAAC,WAAW,EAAE,EAAE,GAAG,EAAE,OAAO,CAAC,GAAG,EAAE,EAAE,IAAI,EAAE,CAAC,CAAC,GAAG,IAAI,EACtG;gBACE,IAAI,EAAE,KAAK;aACZ,CACF,CAAA;YACD,UAAU,CAAC,GAAG,EAAE,IAAI,CAAC,IAAI,CAAC,CAAA;YAC1B,IAAI,CAAC,WAAW,GAAG,IAAI,CAAC,KAAK,CAAC,GAAG,EAAE,CAAA;QACrC,CAAC;QAAC,MAAM,CAAC;YACP,mCAAmC;QACrC,CAAC;IACH,CAAC;IAED;;;;;;;OAOG;IACH,KAAK,CAAC,OAAsC;QAC1C,IAAI,CAAC,IAAI,CAAC,IAAI,IAAI,IAAI,CAAC,KAAK,KAAK,SAAS;YAAE,OAAM;QAClD,MAAM,IAAI,GAAG,GAAS,EAAE;YACtB,+BAA+B;YAC/B,qCAAqC;YACrC,IAAI,CAAC,KAAK,GAAG,IAAI,CAAC,KAAK,CAAC,UAAU,CAAC,IAAI,EAAE,IAAI,CAAC,SAAS,CAAC,CAAA;YACxD,IAAI,CAAC;gBACH,IAAI,CAAC,KAAK,CAAC,OAAO,EAAE,CAAC,CAAA;YACvB,CAAC;YAAC,MAAM,CAAC;gBACP,oBAAoB;YACtB,CAAC;QACH,CAAC,CAAA;QACD,IAAI,CAAC,KAAK,GAAG,IAAI,CAAC,KAAK,CAAC,UAAU,CAAC,IAAI,EAAE,IAAI,CAAC,SAAS,CAAC,CAAA;IAC1D,CAAC;IAED,IAAI;QACF,IAAI,IAAI,CAAC,KAAK,KAAK,SAAS,EAAE,CAAC;YAC7B,IAAI,CAAC,KAAK,CAAC,YAAY,CAAC,IAAI,CAAC,KAAK,CAAC,CAAA;YACnC,IAAI,CAAC,KAAK,GAAG,SAAS,CAAA;QACxB,CAAC;IACH,CAAC;IAED,oDAAoD;IACpD,IAAI,UAAU;QACZ,OAAO,IAAI,CAAC,KAAK,KAAK,SAAS,CAAA;IACjC,CAAC;IAED,IAAI,IAAI;QACN,OAAO,IAAI,CAAC,IAAI,CAAA;IAClB,CAAC;CACF;AAED;;;;;;;;;;;;;GAaG;AACH,MAAM,UAAU,gBAAgB,CAAC,UAAkB,EAAE,YAAqB;IACxE,IAAI,CAAC,UAAU;QAAE,OAAO,EAAE,CAAA;IAC1B,IAAI,YAAY;QAAE,OAAO,IAAI,CAAC,IAAI,CAAC,YAAY,EAAE,MAAM,EAAE,gBAAgB,CAAC,CAAA;IAC1E,OAAO,IAAI,CAAC,IAAI,CAAC,OAAO,EAAE,EAAE,MAAM,EAAE,gBAAgB,CAAC,CAAA;AACvD,CAAC;AAED,MAAM,UAAU,gBAAgB,CAAC,IAAY,EAAE,IAAqB;IAClE,IAAI,CAAC,IAAI;QAAE,OAAO,KAAK,CAAA;IACvB,IAAI,CAAC;QACH,SAAS,CAAC,IAAI,CAAC,OAAO,CAAC,IAAI,CAAC,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAA;QAClD,aAAa,CAAC,IAAI,EAAE,IAAI,EAAE,EAAE,IAAI,EAAE,KAAK,EAAE,CAAC,CAAA;QAC1C,OAAO,IAAI,CAAA;IACb,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,KAAK,CAAA;IACd,CAAC;AACH,CAAC"}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { type CmdPayload, type EvPayload } from 'dsh-remote-wire';
|
|
2
|
+
import { ConversationBook, type PairSlot, type PrunePolicy } from '../core/keys.js';
|
|
3
|
+
import type { Clock } from '../ports/index.js';
|
|
4
|
+
export interface RelayClientOptions {
|
|
5
|
+
url: string;
|
|
6
|
+
hostId: string;
|
|
7
|
+
label: string;
|
|
8
|
+
/** 中继侧 `DRC_HOST_TOKEN` 的同值副本;只在这一条出站帧上出现。 */
|
|
9
|
+
token: string;
|
|
10
|
+
clock: Clock;
|
|
11
|
+
log: (message: string, fields?: Record<string, string | number | boolean | undefined>) => void;
|
|
12
|
+
/** 解出一条命令(已经过 MAC 校验)。 */
|
|
13
|
+
onCommand: (conversationId: string, cmd: CmdPayload, clientId?: string) => void;
|
|
14
|
+
/** 一条新配对建立:调用方要立刻把会话与防休眠状态推过去。 */
|
|
15
|
+
onPeerJoined: (conversationId: string, pairingToken: string | undefined) => void;
|
|
16
|
+
/** 一条配对通道被彻底作废(本端解不开、或本端主动声明它没人接了)。 */
|
|
17
|
+
onConversationGone: (conversationId: string) => void;
|
|
18
|
+
/** 某个客户端离开,但**会话保留**(D3):它下次用同一个 convId 回来时还能接上。 */
|
|
19
|
+
onClientLeft: (conversationId: string, clientId: string) => void;
|
|
20
|
+
/**
|
|
21
|
+
* 按 `peer-joined` 回传的 pairingToken 取本地那张码。
|
|
22
|
+
* 取不到必须返回 null 并由调用方拒绝这条配对——**不允许**退化成"取最新的一张",
|
|
23
|
+
* 那正是多码并存事故的形状。
|
|
24
|
+
*/
|
|
25
|
+
lookupPairingSlot: (token: string) => PairSlot | null;
|
|
26
|
+
/** `pair-ready` 到达:调用方据此改写本地 PSK 过期时间。 */
|
|
27
|
+
onPairReady: (pairingToken: string, ttlMs: number) => void;
|
|
28
|
+
/**
|
|
29
|
+
* 中继宣告"这张配对码没配上"(`expired` / `already_used` / `rate_limited` /
|
|
30
|
+
* `invalid_or_expired`)。主机侧唯一的用处是**退避自动补发**:
|
|
31
|
+
* 被限速时还按原节拍发码,只会让自己继续被拒。
|
|
32
|
+
*/
|
|
33
|
+
onPairFail?: (reason: string) => void;
|
|
34
|
+
/** 连接状态变化,直接进 status.json。 */
|
|
35
|
+
onState: (state: RelayStateInfo) => void;
|
|
36
|
+
/** 重连后的第一批帧之前要不要等一会(手机端可能还在旧 convId 上发)。 */
|
|
37
|
+
handshakeTimeoutMs?: number;
|
|
38
|
+
/** 配对通道的剪枝策略(不传用默认:空闲 24h 或超过 64 条)。 */
|
|
39
|
+
prunePolicy?: PrunePolicy;
|
|
40
|
+
}
|
|
41
|
+
export interface RelayStateInfo {
|
|
42
|
+
relay: 'online' | 'connecting' | 'offline';
|
|
43
|
+
problem?: string;
|
|
44
|
+
clients: number;
|
|
45
|
+
conversations: number;
|
|
46
|
+
generation: number;
|
|
47
|
+
}
|
|
48
|
+
export declare class RelayClient {
|
|
49
|
+
private readonly options;
|
|
50
|
+
readonly conversations: ConversationBook;
|
|
51
|
+
private socket;
|
|
52
|
+
private generation;
|
|
53
|
+
private attempts;
|
|
54
|
+
private stopped;
|
|
55
|
+
private reconnectTimer;
|
|
56
|
+
private handshakeTimer;
|
|
57
|
+
/** 连续解不开的会话:解不开说明这端已经没有对应密钥,留着它只会让手机永远转圈。 */
|
|
58
|
+
private readonly undecryptable;
|
|
59
|
+
/** 已经向中继声明过的 convId(防重复 `session-leave`);有上界,见 {@link VOIDED_MAX}。 */
|
|
60
|
+
private readonly voided;
|
|
61
|
+
constructor(options: RelayClientOptions);
|
|
62
|
+
/** 已配对的会话数(`status.json` 与"有没有人能收"的判据都读它)。 */
|
|
63
|
+
get conversationCount(): number;
|
|
64
|
+
connect(): void;
|
|
65
|
+
stop(): void;
|
|
66
|
+
/** 发布一张新的配对码(PSK 留在本地,中继只拿到 6 位码)。
|
|
67
|
+
*
|
|
68
|
+
* 返回 false = socket 不在 OPEN 状态、这条 `pair-begin` **根本没上过网**。
|
|
69
|
+
* 调用方不能把这张码记成"有效",否则 status.json 会展示一张中继没见过的码,
|
|
70
|
+
* 而手机扫它必然失败(取证 docs/legacy-spec/host-plugin-runtime.md §6.2)。
|
|
71
|
+
*/
|
|
72
|
+
publishPairing(slot: PairSlot): boolean;
|
|
73
|
+
/**
|
|
74
|
+
* 按策略剪掉"再也不会被用"的配对通道(一天没收发,或超出条数上界)。
|
|
75
|
+
*
|
|
76
|
+
* 必须**同时**告诉中继与上层:只删本地内存的话,中继仍会把手机的密文转过来,
|
|
77
|
+
* 而手机看到的是一个不再有人应答的对端——正是复核 R1 那个静默黑洞的另一个入口。
|
|
78
|
+
* 所以这里走 `voidConversation`(发 `session-leave` + 回调 `onConversationGone`)。
|
|
79
|
+
*/
|
|
80
|
+
pruneConversations(now?: number): string[];
|
|
81
|
+
/** 向一条配对会话发一条载荷;返回 false = 这条会话已经没了或没人接。 */
|
|
82
|
+
send(conversationId: string, payload: EvPayload): boolean;
|
|
83
|
+
broadcast(payload: EvPayload): number;
|
|
84
|
+
hasPeer(conversationId: string): boolean;
|
|
85
|
+
conversationIds(): string[];
|
|
86
|
+
/**
|
|
87
|
+
* 作废一条配对通道:本地删密钥 + 告诉中继"这条别再转给我了" + 通知上层。
|
|
88
|
+
*
|
|
89
|
+
* **本端没有记录时同样要发 `session-leave`**(复核 R1①)。这是"主机重启之后"的出口:
|
|
90
|
+
* PSK 随进程没了,中继却还留着会话并继续把手机的密文转过来。若这里因为
|
|
91
|
+
* `!has(id)` 直接 return(第一版就是这么写的),中继永远不知道该删这条会话,
|
|
92
|
+
* 手机也就永远撞不上 `unknown_session` → 表现是"发什么都没反应、也不提示重新配对",
|
|
93
|
+
* 而这是整条链路上唯一能让手机脱身的机制。
|
|
94
|
+
*
|
|
95
|
+
* 幂等:同一条通道只声明一次(`voided` 有上界,避免长会话里堆内存)。
|
|
96
|
+
* 重新配对(`peer-joined`)会清掉这条记录,所以下次作废仍能声明。
|
|
97
|
+
*/
|
|
98
|
+
voidConversation(conversationId: string): void;
|
|
99
|
+
private raw;
|
|
100
|
+
private onFrame;
|
|
101
|
+
private onEncrypted;
|
|
102
|
+
/** 两次解不开就作废(与手机端"两帧解不开就丢配对"对称,避免一端永久静默)。 */
|
|
103
|
+
private noteUndecryptable;
|
|
104
|
+
private scheduleReconnect;
|
|
105
|
+
private closeSocket;
|
|
106
|
+
private emitState;
|
|
107
|
+
}
|