@ondevplann/remotetype 0.1.2 → 0.1.3
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/bin/index.js +732 -732
- package/binaries/linux-x64/input-agent +0 -0
- package/lib/agent.js +382 -382
- package/lib/config.js +148 -148
- package/lib/index.js +27 -27
- package/lib/logger.js +95 -95
- package/lib/queue.js +141 -141
- package/lib/sanitize.js +71 -71
- package/lib/store.js +75 -75
- package/lib/ws-client.js +217 -217
- package/package.json +1 -1
package/bin/index.js
CHANGED
|
@@ -1,732 +1,732 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
'use strict';
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* RemoteType PC 客户端 CLI 入口。
|
|
6
|
-
*
|
|
7
|
-
* 子命令:
|
|
8
|
-
* remotetype serve 启动守护进程,连接公网 Serve,等待远程文本注入
|
|
9
|
-
* remotetype serve --align --key K 对齐模式:端到端加密 + 输入框实时对齐(含删除)
|
|
10
|
-
* remotetype test "文本" 本地直接测试 Rust 注入,不走公网服务
|
|
11
|
-
* remotetype doctor 环境自检(平台/二进制/权限/网络)
|
|
12
|
-
* remotetype binary 打印解析到的 Rust 二进制路径
|
|
13
|
-
*
|
|
14
|
-
* 全局参数:
|
|
15
|
-
* --url <ws://...> 公网服务地址
|
|
16
|
-
* --client-id <id> 本机标识(服务端据此路由)
|
|
17
|
-
* --log-level <level> debug|info|warn|error
|
|
18
|
-
* --help / --version
|
|
19
|
-
*/
|
|
20
|
-
|
|
21
|
-
const fs = require('fs');
|
|
22
|
-
const os = require('os');
|
|
23
|
-
const path = require('path');
|
|
24
|
-
|
|
25
|
-
const { AgentManager } = require('../lib/agent');
|
|
26
|
-
const { MessageQueue } = require('../lib/queue');
|
|
27
|
-
const { ServeClient } = require('../lib/ws-client');
|
|
28
|
-
const { RtAlignSession, looksLikeEnvelope } = require('../lib/rt-align');
|
|
29
|
-
const rtCrypto = require('../lib/rt-crypto');
|
|
30
|
-
const { LocalStore } = require('../lib/store');
|
|
31
|
-
const { Logger } = require('../lib/logger');
|
|
32
|
-
const { sanitize } = require('../lib/sanitize');
|
|
33
|
-
const { findBinary, isSupported, resolveTarget, binaryUrl, cacheDir } = require('../lib/platform');
|
|
34
|
-
const { loadConfig, parseArgs } = require('../lib/config');
|
|
35
|
-
|
|
36
|
-
const pkg = require('../package.json');
|
|
37
|
-
|
|
38
|
-
const C = {
|
|
39
|
-
reset: '\x1b[0m',
|
|
40
|
-
bold: '\x1b[1m',
|
|
41
|
-
dim: '\x1b[90m',
|
|
42
|
-
red: '\x1b[31m',
|
|
43
|
-
green: '\x1b[32m',
|
|
44
|
-
yellow: '\x1b[33m',
|
|
45
|
-
cyan: '\x1b[36m',
|
|
46
|
-
};
|
|
47
|
-
|
|
48
|
-
/** 打印带颜色的提示行(不走日志器,用于面向用户的交互输出)。 */
|
|
49
|
-
function say(msg = '') {
|
|
50
|
-
process.stdout.write(`${msg}\n`);
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
function printHelp() {
|
|
54
|
-
say(`
|
|
55
|
-
${C.bold}remotetype${C.reset} v${pkg.version} — 远程语音文字输入客户端
|
|
56
|
-
|
|
57
|
-
${C.bold}用法${C.reset}
|
|
58
|
-
npx remotetype <command> [options]
|
|
59
|
-
|
|
60
|
-
${C.bold}命令${C.reset}
|
|
61
|
-
${C.cyan}serve${C.reset} 启动守护进程:连接公网服务,等待远程文本注入
|
|
62
|
-
${C.cyan}serve --align${C.reset} 对齐模式:端到端加密 + 输入框实时对齐(含删除)
|
|
63
|
-
${C.cyan}test${C.reset} "<文本>" 本地测试:直接调用注入引擎,不连公网
|
|
64
|
-
${C.cyan}doctor${C.reset} 环境自检:平台支持 / 注入二进制 / 权限 / 网络
|
|
65
|
-
${C.cyan}binary${C.reset} 打印解析到的注入引擎路径
|
|
66
|
-
|
|
67
|
-
${C.bold}选项${C.reset}
|
|
68
|
-
--url <ws://...> 公网服务地址(默认 ${require('../lib/config').DEFAULT_SERVE_URL})
|
|
69
|
-
--client-id <id> 本机唯一标识,服务端按此路由(默认自动生成并持久化)
|
|
70
|
-
--align 对齐模式:只接受 RT1 加密信封,手机输入与电脑实时对齐
|
|
71
|
-
--key <KEY> 对齐模式配对 key(不传则自动生成强随机并打印;手机端输入同一 key)
|
|
72
|
-
--token <TOKEN> 注册门禁 token(服务器设 RT_TOKEN 后两端都必须携带同一值)
|
|
73
|
-
--log-level <level> debug | info | warn | error(默认 info)
|
|
74
|
-
--max-queue <n> 本地消息队列上限(默认 100)
|
|
75
|
-
--max-text <n> 单条文本最大长度,超出截断(默认 5000)
|
|
76
|
-
--agent <path> 手动指定注入引擎二进制路径
|
|
77
|
-
--help 显示帮助
|
|
78
|
-
--version 显示版本
|
|
79
|
-
|
|
80
|
-
${C.bold}示例${C.reset}
|
|
81
|
-
npx remotetype serve --url ws://1.2.3.4:8790/ws
|
|
82
|
-
npx remotetype serve --align # 自动生成 key,复制到手机
|
|
83
|
-
npx remotetype serve --align --key MYKEY123 # 两端用同一个 key
|
|
84
|
-
npx remotetype test "你好,这是一条本地注入测试"
|
|
85
|
-
npx remotetype doctor
|
|
86
|
-
`);
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
function printVersion() {
|
|
90
|
-
say(`remotetype v${pkg.version} (${process.platform}-${process.arch}, node ${process.version})`);
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
/**
|
|
94
|
-
* 解析注入引擎二进制路径;找不到时给出可操作的错误提示。
|
|
95
|
-
* @returns {string} 二进制绝对路径
|
|
96
|
-
* @throws {Error} 找不到时抛出(带友好信息)
|
|
97
|
-
*/
|
|
98
|
-
function resolveAgentPath(cfg) {
|
|
99
|
-
if (cfg.agentPath) {
|
|
100
|
-
if (!fs.existsSync(cfg.agentPath)) {
|
|
101
|
-
throw new Error(`指定的注入引擎不存在:${cfg.agentPath}`);
|
|
102
|
-
}
|
|
103
|
-
return cfg.agentPath;
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
const found = findBinary({ extraDirs: [] });
|
|
107
|
-
if (found) return found;
|
|
108
|
-
|
|
109
|
-
const target = resolveTarget();
|
|
110
|
-
const error = new Error(
|
|
111
|
-
[
|
|
112
|
-
`未找到适用于 ${target.key} 的注入引擎(input-agent)。`,
|
|
113
|
-
'',
|
|
114
|
-
'可能原因:',
|
|
115
|
-
' 1) 平台分包未安装 —— 重新执行 npx 会自动拉取;',
|
|
116
|
-
' 2) 处于离线环境 —— 主包只含 JS,二进制需单独获取。',
|
|
117
|
-
'',
|
|
118
|
-
'手动指定:remotetype serve --agent /path/to/input-agent',
|
|
119
|
-
`手动放置:${path.join(cacheDir(), target.key, target.platform === 'win32' ? 'input-agent.exe' : 'input-agent')}`,
|
|
120
|
-
`下载地址:${binaryUrl(target)}`,
|
|
121
|
-
].join('\n'),
|
|
122
|
-
);
|
|
123
|
-
error.code = 'AGENT_NOT_FOUND';
|
|
124
|
-
throw error;
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
/** 检测 Linux 会话类型,返回 'x11' | 'wayland' | 'unknown'。 */
|
|
128
|
-
function detectLinuxSession() {
|
|
129
|
-
if (process.platform !== 'linux') return null;
|
|
130
|
-
const hasDisplay = Boolean(process.env.DISPLAY);
|
|
131
|
-
const hasWayland = Boolean(process.env.WAYLAND_DISPLAY);
|
|
132
|
-
if (hasDisplay) return 'x11'; // 含 XWayland 场景
|
|
133
|
-
if (hasWayland) return 'wayland';
|
|
134
|
-
return 'unknown';
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
/**
|
|
138
|
-
* 平台相关的启动前提示(需求文档 6.1 / 风险清单)。
|
|
139
|
-
* 不阻塞启动,只做醒目提醒。
|
|
140
|
-
*/
|
|
141
|
-
function printPlatformNotes(logger) {
|
|
142
|
-
const target = resolveTarget();
|
|
143
|
-
|
|
144
|
-
if (target.platform === 'darwin') {
|
|
145
|
-
logger.warn(
|
|
146
|
-
'macOS 需要手动开启辅助功能权限,否则注入会静默失败:' +
|
|
147
|
-
'系统设置 → 隐私与安全性 → 辅助功能 → 勾选你的终端 / Node。',
|
|
148
|
-
);
|
|
149
|
-
logger.warn(
|
|
150
|
-
'若二进制来自浏览器下载,可能被系统隔离。如遇「无法打开」,执行:' +
|
|
151
|
-
`xattr -dr com.apple.quarantine "${path.dirname(findBinary() || '')}"`,
|
|
152
|
-
);
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
if (target.platform === 'linux') {
|
|
156
|
-
const session = detectLinuxSession();
|
|
157
|
-
if (session === 'wayland') {
|
|
158
|
-
logger.error(
|
|
159
|
-
'检测到当前为纯 Wayland 会话(仅有 WAYLAND_DISPLAY)。' +
|
|
160
|
-
'本版本注入引擎在 Wayland 下无法工作(详见文档 ENIGO_FINDINGS.md)。',
|
|
161
|
-
);
|
|
162
|
-
logger.error(
|
|
163
|
-
'请在登录界面选择「Ubuntu on Xorg」/「GNOME on Xorg」会话后重试;' +
|
|
164
|
-
'若已启用 XWayland,请确认 DISPLAY 环境变量存在。',
|
|
165
|
-
);
|
|
166
|
-
} else if (session === 'unknown') {
|
|
167
|
-
logger.warn('未检测到 DISPLAY 环境变量;若在无图形界面环境运行,注入将失败。');
|
|
168
|
-
}
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
if (target.platform === 'win32') {
|
|
172
|
-
logger.debug(
|
|
173
|
-
'Windows 下键鼠模拟可能被杀毒软件拦截;若注入失效,请将本程序加入白名单。',
|
|
174
|
-
);
|
|
175
|
-
}
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
// ============================ serve ============================
|
|
179
|
-
|
|
180
|
-
/**
|
|
181
|
-
* serve 子命令:启动守护进程。
|
|
182
|
-
* 链路:Serve(WS) → 队列(串行) → AgentManager(stdio) → Rust → 系统注入
|
|
183
|
-
*/
|
|
184
|
-
async function cmdServe(cfg) {
|
|
185
|
-
const logger = new Logger({
|
|
186
|
-
level: cfg.logLevel,
|
|
187
|
-
logDir: cfg.logDir,
|
|
188
|
-
name: 'client',
|
|
189
|
-
});
|
|
190
|
-
|
|
191
|
-
logger.info(`${C.bold}RemoteType 客户端启动${C.reset}`, {
|
|
192
|
-
version: pkg.version,
|
|
193
|
-
target: cfg.target.key,
|
|
194
|
-
clientId: cfg.clientId,
|
|
195
|
-
url: cfg.url,
|
|
196
|
-
});
|
|
197
|
-
logger.info('配置', {
|
|
198
|
-
logDir: cfg.logDir,
|
|
199
|
-
recordFile: cfg.recordFile,
|
|
200
|
-
maxQueueSize: cfg.maxQueueSize,
|
|
201
|
-
maxTextLength: cfg.maxTextLength,
|
|
202
|
-
});
|
|
203
|
-
|
|
204
|
-
printPlatformNotes(logger);
|
|
205
|
-
|
|
206
|
-
// ---- 解析并校验注入引擎 ----
|
|
207
|
-
let agentPath;
|
|
208
|
-
try {
|
|
209
|
-
agentPath = resolveAgentPath(cfg);
|
|
210
|
-
} catch (e) {
|
|
211
|
-
logger.error(e.message);
|
|
212
|
-
process.exitCode = 2;
|
|
213
|
-
return;
|
|
214
|
-
}
|
|
215
|
-
logger.info('注入引擎已就绪', { agentPath });
|
|
216
|
-
|
|
217
|
-
const store = new LocalStore({ file: cfg.recordFile, logger });
|
|
218
|
-
const agent = new AgentManager({
|
|
219
|
-
binaryPath: agentPath,
|
|
220
|
-
logger,
|
|
221
|
-
restartDelayMs: cfg.restartDelayMs,
|
|
222
|
-
heartbeatMs: cfg.heartbeatMs,
|
|
223
|
-
requestTimeoutMs: cfg.requestTimeoutMs,
|
|
224
|
-
});
|
|
225
|
-
|
|
226
|
-
// 对齐模式会话(可选);serve 在其后创建,sendAck 闭包引用
|
|
227
|
-
let align = null;
|
|
228
|
-
let serve;
|
|
229
|
-
// 本批按键计划中注入失败过的 seq:commit 到来时走 commitFailed
|
|
230
|
-
// (mirror 不推进、重试),而不是 commitApplied(误以为已注入 → 永久错位)
|
|
231
|
-
const failedAlignSeqs = new Set();
|
|
232
|
-
|
|
233
|
-
// ---- 消息队列:串行消费,逐条注入 ----
|
|
234
|
-
// serve 明文模式 item = {text, clientId};对齐模式 item =
|
|
235
|
-
// {op:'type', text, seq} | {op:'backspace', count, seq} | {op:'commit', seq, text}
|
|
236
|
-
const queue = new MessageQueue({
|
|
237
|
-
maxSize: cfg.maxQueueSize,
|
|
238
|
-
logger,
|
|
239
|
-
handler: async (item, meta) => {
|
|
240
|
-
const enqueuedAt = meta.enqueuedAt ?? Date.now();
|
|
241
|
-
const queueWaitMs = Date.now() - enqueuedAt;
|
|
242
|
-
const startedAt = Date.now();
|
|
243
|
-
|
|
244
|
-
// commit:本批按键计划完成,推进 mirror 并回 ACK(无注入动作)
|
|
245
|
-
if (item.op === 'commit') {
|
|
246
|
-
if (failedAlignSeqs.has(item.seq)) {
|
|
247
|
-
failedAlignSeqs.delete(item.seq);
|
|
248
|
-
align?.commitFailed(item.seq);
|
|
249
|
-
} else {
|
|
250
|
-
align?.commitApplied(item.seq, item.text);
|
|
251
|
-
}
|
|
252
|
-
return;
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
// backspace:删除原语(relay 对齐模式)
|
|
256
|
-
if (item.op === 'backspace') {
|
|
257
|
-
try {
|
|
258
|
-
const res = await agent.backspace(item.count);
|
|
259
|
-
if (res?.ok) {
|
|
260
|
-
logger.debug('退格完成', { count: item.count, queueWaitMs });
|
|
261
|
-
store.append({ status: 'backspaced', count: item.count, source: meta.source, seq: item.seq });
|
|
262
|
-
} else {
|
|
263
|
-
logger.error('退格失败', { count: item.count, msg: res?.msg });
|
|
264
|
-
if (item.seq !== undefined) failedAlignSeqs.add(item.seq);
|
|
265
|
-
store.append({ status: 'backspace_failed', count: item.count, errorMsg: res?.msg ?? 'unknown', source: meta.source, seq: item.seq });
|
|
266
|
-
}
|
|
267
|
-
} catch (e) {
|
|
268
|
-
logger.error('退格异常', { count: item.count, error: e.message });
|
|
269
|
-
if (item.seq !== undefined) failedAlignSeqs.add(item.seq);
|
|
270
|
-
store.append({ status: 'backspace_failed', count: item.count, errorMsg: e.message, source: meta.source, seq: item.seq });
|
|
271
|
-
}
|
|
272
|
-
return;
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
// type:常规文本注入(serve 与 relay 共用,relay 的 text 是 diff 出的增量)
|
|
276
|
-
// 二次预处理:即便上游已过滤,这里仍是最后一道防线
|
|
277
|
-
const cleaned = sanitize(item.text, { maxLength: cfg.maxTextLength });
|
|
278
|
-
if (!cleaned.ok) {
|
|
279
|
-
logger.warn('文本预处理后丢弃', { reason: cleaned.reason, clientId: item.clientId });
|
|
280
|
-
if (meta.source === 'align' && item.seq !== undefined) failedAlignSeqs.add(item.seq);
|
|
281
|
-
store.append({
|
|
282
|
-
text: item.text,
|
|
283
|
-
status: 'dropped',
|
|
284
|
-
errorMsg: cleaned.reason,
|
|
285
|
-
clientId: item.clientId,
|
|
286
|
-
source: meta.source,
|
|
287
|
-
queueWaitMs,
|
|
288
|
-
});
|
|
289
|
-
return;
|
|
290
|
-
}
|
|
291
|
-
if (cleaned.truncated) {
|
|
292
|
-
logger.warn('文本超长已截断', {
|
|
293
|
-
originalLength: cleaned.originalLength,
|
|
294
|
-
maxLength: cfg.maxTextLength,
|
|
295
|
-
});
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
logger.info('开始注入', {
|
|
299
|
-
textLen: cleaned.text.length,
|
|
300
|
-
queueWaitMs,
|
|
301
|
-
clientId: item.clientId,
|
|
302
|
-
source: meta.source,
|
|
303
|
-
preview: cleaned.text.slice(0, 40),
|
|
304
|
-
});
|
|
305
|
-
|
|
306
|
-
try {
|
|
307
|
-
const res = await agent.typeText(cleaned.text);
|
|
308
|
-
const durationMs = Date.now() - startedAt;
|
|
309
|
-
if (res?.ok) {
|
|
310
|
-
logger.info('注入完成', { durationMs, textLen: cleaned.text.length });
|
|
311
|
-
store.append({
|
|
312
|
-
text: cleaned.text,
|
|
313
|
-
status: 'injected',
|
|
314
|
-
clientId: item.clientId,
|
|
315
|
-
source: meta.source,
|
|
316
|
-
queueWaitMs,
|
|
317
|
-
durationMs,
|
|
318
|
-
truncated: Boolean(cleaned.truncated),
|
|
319
|
-
});
|
|
320
|
-
} else {
|
|
321
|
-
logger.error('注入失败', { msg: res?.msg, durationMs });
|
|
322
|
-
if (meta.source === 'align' && item.seq !== undefined) failedAlignSeqs.add(item.seq);
|
|
323
|
-
store.append({
|
|
324
|
-
text: cleaned.text,
|
|
325
|
-
status: 'inject_failed',
|
|
326
|
-
errorMsg: res?.msg ?? 'unknown',
|
|
327
|
-
clientId: item.clientId,
|
|
328
|
-
source: meta.source,
|
|
329
|
-
queueWaitMs,
|
|
330
|
-
durationMs,
|
|
331
|
-
});
|
|
332
|
-
}
|
|
333
|
-
} catch (e) {
|
|
334
|
-
const durationMs = Date.now() - startedAt;
|
|
335
|
-
logger.error('注入异常', { error: e.message, durationMs });
|
|
336
|
-
if (meta.source === 'align' && item.seq !== undefined) failedAlignSeqs.add(item.seq);
|
|
337
|
-
store.append({
|
|
338
|
-
text: cleaned.text,
|
|
339
|
-
status: 'inject_failed',
|
|
340
|
-
errorMsg: e.message,
|
|
341
|
-
clientId: item.clientId,
|
|
342
|
-
source: meta.source,
|
|
343
|
-
queueWaitMs,
|
|
344
|
-
durationMs,
|
|
345
|
-
});
|
|
346
|
-
}
|
|
347
|
-
},
|
|
348
|
-
});
|
|
349
|
-
|
|
350
|
-
// ---- 拉起子进程 ----
|
|
351
|
-
try {
|
|
352
|
-
agent.start();
|
|
353
|
-
} catch (e) {
|
|
354
|
-
logger.error('注入引擎启动失败', { error: e.message });
|
|
355
|
-
process.exitCode = 3;
|
|
356
|
-
return;
|
|
357
|
-
}
|
|
358
|
-
|
|
359
|
-
// ---- 对齐模式(可选):密文信封 → 按键计划 → ACK ----
|
|
360
|
-
if (cfg.align) {
|
|
361
|
-
const key = cfg.alignKey || rtCrypto.generateKey();
|
|
362
|
-
align = new RtAlignSession({
|
|
363
|
-
key,
|
|
364
|
-
logger,
|
|
365
|
-
sendAck: (envelope) => serve.send({ type: 'ack', phoneId: align.phoneId, ack: envelope }),
|
|
366
|
-
});
|
|
367
|
-
|
|
368
|
-
if (!cfg.alignKey) {
|
|
369
|
-
say('');
|
|
370
|
-
say(`${C.bold}${C.green} ┌──────────────────────────────────────────┐${C.reset}`);
|
|
371
|
-
say(`${C.bold}${C.green} │ 配对 key(输入到 fr「远程输入」demo): │${C.reset}`);
|
|
372
|
-
say(`${C.bold}${C.green} │ │${C.reset}`);
|
|
373
|
-
say(`${C.bold}${C.green} │ ${key} │${C.reset}`);
|
|
374
|
-
say(`${C.bold}${C.green} └──────────────────────────────────────────┘${C.reset}`);
|
|
375
|
-
say(`${C.dim} key 即凭据:泄漏 = 任何人可向本机注入文本。勿外传。${C.reset}`);
|
|
376
|
-
say('');
|
|
377
|
-
}
|
|
378
|
-
|
|
379
|
-
align.on('decrypt-error', ({ seq }) => {
|
|
380
|
-
logger.error('解密失败:两端 key 不一致?', { seq });
|
|
381
|
-
});
|
|
382
|
-
align.on('op', (op) => {
|
|
383
|
-
const accepted = queue.push(op, { source: 'align', enqueuedAt: Date.now() });
|
|
384
|
-
if (!accepted) {
|
|
385
|
-
logger.warn('队列溢出,丢弃计划', { op: op.op, seq: op.seq });
|
|
386
|
-
store.append({ status: 'dropped', errorMsg: 'queue_overflow', source: 'align', seq: op.seq });
|
|
387
|
-
}
|
|
388
|
-
});
|
|
389
|
-
}
|
|
390
|
-
|
|
391
|
-
// ---- 连接公网服务(明文 / 对齐两种模式共用 ServeClient)----
|
|
392
|
-
serve = new ServeClient({
|
|
393
|
-
url: cfg.url,
|
|
394
|
-
clientId: cfg.clientId,
|
|
395
|
-
token: cfg.token,
|
|
396
|
-
logger,
|
|
397
|
-
});
|
|
398
|
-
|
|
399
|
-
serve.on('connected', () => {
|
|
400
|
-
logger.info(
|
|
401
|
-
align
|
|
402
|
-
? `${C.green}已连接公网服务(对齐模式),等待手机配对…${C.reset}`
|
|
403
|
-
: `${C.green}已连接公网服务,等待远程文本…${C.reset}`,
|
|
404
|
-
{ clientId: cfg.clientId },
|
|
405
|
-
);
|
|
406
|
-
});
|
|
407
|
-
serve.on('reconnecting', ({ delayMs, attempt }) => {
|
|
408
|
-
logger.warn(`连接断开,${delayMs}ms 后重连`, { attempt });
|
|
409
|
-
});
|
|
410
|
-
serve.on('disconnected', ({ code, reason }) => {
|
|
411
|
-
logger.warn('与服务端连接已断开', { code, reason });
|
|
412
|
-
});
|
|
413
|
-
serve.on('registered', (msg) => {
|
|
414
|
-
logger.info('服务端已登记本机', { clientId: msg.clientId, role: msg.role });
|
|
415
|
-
});
|
|
416
|
-
|
|
417
|
-
serve.on('text', (msg) => {
|
|
418
|
-
if (align) {
|
|
419
|
-
// 对齐模式:只接受 RT1 加密信封;明文一律拒绝(防旁路注入)
|
|
420
|
-
const outcome = align.handleIncoming(msg.text);
|
|
421
|
-
if (outcome === 'plaintext') {
|
|
422
|
-
store.append({
|
|
423
|
-
text: String(msg.text ?? '').slice(0, 200),
|
|
424
|
-
status: 'plaintext_rejected',
|
|
425
|
-
clientId: msg.clientId,
|
|
426
|
-
source: 'align',
|
|
427
|
-
});
|
|
428
|
-
}
|
|
429
|
-
return;
|
|
430
|
-
}
|
|
431
|
-
logger.info('收到远程文本', { textLen: msg.text.length, clientId: msg.clientId });
|
|
432
|
-
const accepted = queue.push(
|
|
433
|
-
{ text: msg.text, clientId: msg.clientId },
|
|
434
|
-
{ source: 'serve', enqueuedAt: Date.now() },
|
|
435
|
-
);
|
|
436
|
-
if (!accepted) {
|
|
437
|
-
store.append({
|
|
438
|
-
text: msg.text,
|
|
439
|
-
status: 'dropped',
|
|
440
|
-
errorMsg: 'queue_overflow',
|
|
441
|
-
clientId: msg.clientId,
|
|
442
|
-
source: 'serve',
|
|
443
|
-
});
|
|
444
|
-
}
|
|
445
|
-
});
|
|
446
|
-
|
|
447
|
-
// 注册门禁拒绝(4403):token 配置错误需要人工介入,重连无意义,直接退出
|
|
448
|
-
serve.on('auth-rejected', () => {
|
|
449
|
-
logger.error('token 无效或缺失:请核对 --token / REMOTETYPE_TOKEN 与服务器 RT_TOKEN 是否一致');
|
|
450
|
-
shutdown('auth-rejected', 1);
|
|
451
|
-
});
|
|
452
|
-
|
|
453
|
-
serve.connect();
|
|
454
|
-
|
|
455
|
-
// ---- 状态摘要(每 60s 打印一次,便于长期运行观测) ----
|
|
456
|
-
const summary = setInterval(() => {
|
|
457
|
-
logger.info('运行状态', {
|
|
458
|
-
queueDepth: queue.depth,
|
|
459
|
-
queue: queue.stats,
|
|
460
|
-
agent: agent.stats,
|
|
461
|
-
serve: serve ? serve.stats : null,
|
|
462
|
-
align: align ? align.stats : null,
|
|
463
|
-
store: store.stats(),
|
|
464
|
-
});
|
|
465
|
-
}, 60000);
|
|
466
|
-
summary.unref?.();
|
|
467
|
-
|
|
468
|
-
// ---- 优雅退出 ----
|
|
469
|
-
let shuttingDown = false;
|
|
470
|
-
const shutdown = async (signal, code = 0) => {
|
|
471
|
-
if (shuttingDown) return;
|
|
472
|
-
shuttingDown = true;
|
|
473
|
-
clearInterval(summary);
|
|
474
|
-
logger.info(`收到 ${signal},开始优雅退出`);
|
|
475
|
-
queue.pause();
|
|
476
|
-
align?.dispose();
|
|
477
|
-
serve?.close();
|
|
478
|
-
// 给队列里剩余消息一点时间跑完
|
|
479
|
-
const drained = await queue.waitIdle(8000);
|
|
480
|
-
if (!drained) logger.warn('队列未在超时内消费完毕,剩余消息将被丢弃', { depth: queue.depth });
|
|
481
|
-
await agent.stop();
|
|
482
|
-
logger.info('已退出', { queue: queue.stats, agent: agent.stats, store: store.stats() });
|
|
483
|
-
logger.close();
|
|
484
|
-
process.exit(code);
|
|
485
|
-
};
|
|
486
|
-
|
|
487
|
-
process.on('SIGINT', () => shutdown('SIGINT'));
|
|
488
|
-
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
|
489
|
-
|
|
490
|
-
// 保持进程存活
|
|
491
|
-
await new Promise(() => {});
|
|
492
|
-
}
|
|
493
|
-
|
|
494
|
-
// ============================ test ============================
|
|
495
|
-
|
|
496
|
-
/**
|
|
497
|
-
* test 子命令:不连公网,直接本地注入,用于快速验证环境。
|
|
498
|
-
* 退出码:0 成功;4 注入失败;2 找不到二进制。
|
|
499
|
-
*/
|
|
500
|
-
async function cmdTest(cfg, positional) {
|
|
501
|
-
const logger = new Logger({ level: cfg.logLevel, logDir: null, name: 'client' });
|
|
502
|
-
|
|
503
|
-
const raw = positional.join(' ');
|
|
504
|
-
if (!raw) {
|
|
505
|
-
say(`${C.red}错误:请提供要注入的文本${C.reset}`);
|
|
506
|
-
say(`示例:npx remotetype test "你好,这是一条本地注入测试"`);
|
|
507
|
-
process.exitCode = 1;
|
|
508
|
-
return;
|
|
509
|
-
}
|
|
510
|
-
|
|
511
|
-
const cleaned = sanitize(raw, { maxLength: cfg.maxTextLength });
|
|
512
|
-
if (!cleaned.ok) {
|
|
513
|
-
say(`${C.red}文本预处理后为空(${cleaned.reason}),已丢弃${C.reset}`);
|
|
514
|
-
process.exitCode = 1;
|
|
515
|
-
return;
|
|
516
|
-
}
|
|
517
|
-
if (cleaned.truncated) {
|
|
518
|
-
logger.warn('文本超长已截断', { originalLength: cleaned.originalLength, maxLength: cfg.maxTextLength });
|
|
519
|
-
}
|
|
520
|
-
|
|
521
|
-
let agentPath;
|
|
522
|
-
try {
|
|
523
|
-
agentPath = resolveAgentPath(cfg);
|
|
524
|
-
} catch (e) {
|
|
525
|
-
// 复用统一提示(--agent 指定错误时同样走这里)
|
|
526
|
-
throw e;
|
|
527
|
-
}
|
|
528
|
-
|
|
529
|
-
say(`${C.dim}注入引擎:${agentPath}${C.reset}`);
|
|
530
|
-
say(`${C.dim}将注入 ${Array.from(cleaned.text).length} 个字符到当前焦点窗口…${C.reset}`);
|
|
531
|
-
|
|
532
|
-
printPlatformNotes(logger);
|
|
533
|
-
|
|
534
|
-
const agent = new AgentManager({
|
|
535
|
-
binaryPath: agentPath,
|
|
536
|
-
logger,
|
|
537
|
-
restartDelayMs: cfg.restartDelayMs,
|
|
538
|
-
heartbeatMs: 0, // 单次测试不需要心跳
|
|
539
|
-
requestTimeoutMs: cfg.requestTimeoutMs,
|
|
540
|
-
});
|
|
541
|
-
|
|
542
|
-
try {
|
|
543
|
-
agent.start();
|
|
544
|
-
const startedAt = Date.now();
|
|
545
|
-
const res = await agent.typeText(cleaned.text);
|
|
546
|
-
const durationMs = Date.now() - startedAt;
|
|
547
|
-
|
|
548
|
-
if (res?.ok) {
|
|
549
|
-
say(`${C.green}✓ 注入成功${C.reset}(${durationMs}ms)`);
|
|
550
|
-
say(`${C.dim}请确认当前焦点输入框中已出现文字;若没有,请检查辅助功能权限或会话类型。${C.reset}`);
|
|
551
|
-
} else {
|
|
552
|
-
say(`${C.red}✗ 注入失败${C.reset}:${res?.msg ?? 'unknown'}(${durationMs}ms)`);
|
|
553
|
-
process.exitCode = 4;
|
|
554
|
-
}
|
|
555
|
-
} catch (e) {
|
|
556
|
-
say(`${C.red}✗ 注入异常${C.reset}:${e.message}`);
|
|
557
|
-
process.exitCode = 4;
|
|
558
|
-
} finally {
|
|
559
|
-
await agent.stop();
|
|
560
|
-
}
|
|
561
|
-
}
|
|
562
|
-
|
|
563
|
-
// =========================== doctor ===========================
|
|
564
|
-
|
|
565
|
-
/** doctor 子命令:环境自检,逐项给出结论与建议。 */
|
|
566
|
-
async function cmdDoctor(cfg) {
|
|
567
|
-
const lines = [];
|
|
568
|
-
const ok = (m) => lines.push(`${C.green}✓${C.reset} ${m}`);
|
|
569
|
-
const bad = (m) => lines.push(`${C.red}✗${C.reset} ${m}`);
|
|
570
|
-
const warn = (m) => lines.push(`${C.yellow}!${C.reset} ${m}`);
|
|
571
|
-
const info = (m) => lines.push(` ${C.dim}${m}${C.reset}`);
|
|
572
|
-
|
|
573
|
-
say(`\n${C.bold}RemoteType 环境自检${C.reset}\n`);
|
|
574
|
-
|
|
575
|
-
// 1) 运行时
|
|
576
|
-
ok(`操作系统:${process.platform} ${os.release()} (${process.arch})`);
|
|
577
|
-
ok(`Node.js:${process.version}`);
|
|
578
|
-
if (Number.parseInt(process.versions.node, 10) >= 18) {
|
|
579
|
-
ok('Node 版本满足要求(>= 18)');
|
|
580
|
-
} else {
|
|
581
|
-
bad('Node 版本过低,需要 >= 18');
|
|
582
|
-
}
|
|
583
|
-
|
|
584
|
-
// 2) 平台支持
|
|
585
|
-
const target = resolveTarget();
|
|
586
|
-
if (isSupported()) {
|
|
587
|
-
ok(`目标平台 ${target.key} 受支持`);
|
|
588
|
-
} else {
|
|
589
|
-
bad(`目标平台 ${target.key} 不在支持列表内`);
|
|
590
|
-
}
|
|
591
|
-
|
|
592
|
-
// 3) Linux 会话类型
|
|
593
|
-
if (process.platform === 'linux') {
|
|
594
|
-
const session = detectLinuxSession();
|
|
595
|
-
if (session === 'x11') {
|
|
596
|
-
ok(`图形会话:X11(DISPLAY=${process.env.DISPLAY})`);
|
|
597
|
-
} else if (session === 'wayland') {
|
|
598
|
-
bad('图形会话:纯 Wayland —— 注入引擎不支持(请改用 Xorg 会话)');
|
|
599
|
-
info('详见项目文档 ENIGO_FINDINGS.md');
|
|
600
|
-
} else {
|
|
601
|
-
warn('未检测到 DISPLAY / WAYLAND_DISPLAY,可能无图形环境');
|
|
602
|
-
}
|
|
603
|
-
}
|
|
604
|
-
|
|
605
|
-
// 4) 注入引擎
|
|
606
|
-
try {
|
|
607
|
-
const agentPath = resolveAgentPath(cfg);
|
|
608
|
-
const st = fs.statSync(agentPath);
|
|
609
|
-
ok(`注入引擎:${agentPath}`);
|
|
610
|
-
info(`大小 ${(st.size / 1024 / 1024).toFixed(2)} MB`);
|
|
611
|
-
if (process.platform !== 'win32' && !(st.mode & 0o111)) {
|
|
612
|
-
warn('二进制缺少可执行权限,启动时会自动 chmod 修复');
|
|
613
|
-
}
|
|
614
|
-
|
|
615
|
-
// 5) 实机注入测试(ping + 可选的空跑)
|
|
616
|
-
const logger = new Logger({ level: 'error', logDir: null, name: 'doctor' });
|
|
617
|
-
const agent = new AgentManager({
|
|
618
|
-
binaryPath: agentPath,
|
|
619
|
-
logger,
|
|
620
|
-
heartbeatMs: 0,
|
|
621
|
-
requestTimeoutMs: 8000,
|
|
622
|
-
maxRestarts: 1,
|
|
623
|
-
});
|
|
624
|
-
try {
|
|
625
|
-
agent.start();
|
|
626
|
-
const res = await agent.send({ action: 'ping' }, 8000);
|
|
627
|
-
if (res?.ok) {
|
|
628
|
-
ok('注入引擎通信正常(stdio IPC 应答 pong)');
|
|
629
|
-
} else {
|
|
630
|
-
bad(`注入引擎应答异常:${JSON.stringify(res)}`);
|
|
631
|
-
}
|
|
632
|
-
} catch (e) {
|
|
633
|
-
bad(`注入引擎无法通信:${e.message}`);
|
|
634
|
-
if (process.platform === 'darwin') {
|
|
635
|
-
info('macOS 请检查:系统设置 → 隐私与安全性 → 辅助功能');
|
|
636
|
-
}
|
|
637
|
-
if (process.platform === 'linux') {
|
|
638
|
-
info('Linux 请确认在 X11 会话下运行,且 DISPLAY 正确');
|
|
639
|
-
}
|
|
640
|
-
} finally {
|
|
641
|
-
await agent.stop();
|
|
642
|
-
}
|
|
643
|
-
} catch (e) {
|
|
644
|
-
bad(e.message);
|
|
645
|
-
}
|
|
646
|
-
|
|
647
|
-
// 6) 网络连通性(仅解析地址,不强制连接)
|
|
648
|
-
try {
|
|
649
|
-
const u = new URL(cfg.url);
|
|
650
|
-
ok(`服务地址:${cfg.url}`);
|
|
651
|
-
info(`协议 ${u.protocol.replace(':', '')} / 主机 ${u.host}`);
|
|
652
|
-
if (u.protocol === 'wss:') {
|
|
653
|
-
info('使用 TLS 加密传输');
|
|
654
|
-
} else {
|
|
655
|
-
warn('使用明文 ws:// 传输,公网环境建议改用 wss://');
|
|
656
|
-
}
|
|
657
|
-
} catch {
|
|
658
|
-
bad(`服务地址格式不合法:${cfg.url}`);
|
|
659
|
-
}
|
|
660
|
-
|
|
661
|
-
// 7) 数据目录可写性
|
|
662
|
-
for (const [label, p] of [['日志目录', cfg.logDir], ['记录文件', cfg.recordFile]]) {
|
|
663
|
-
try {
|
|
664
|
-
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
665
|
-
fs.accessSync(path.dirname(p), fs.constants.W_OK);
|
|
666
|
-
ok(`${label}可写:${p}`);
|
|
667
|
-
} catch (e) {
|
|
668
|
-
bad(`${label}不可写:${p}(${e.message})`);
|
|
669
|
-
}
|
|
670
|
-
}
|
|
671
|
-
|
|
672
|
-
ok(`本机标识 clientId:${cfg.clientId}`);
|
|
673
|
-
|
|
674
|
-
say(lines.join('\n'));
|
|
675
|
-
say(`\n${C.dim}提示:doctor 只做自检,不会真正向焦点窗口输入文字。${C.reset}`);
|
|
676
|
-
say(`${C.dim}要实测注入,请运行:npx remotetype test "测试文本"${C.reset}\n`);
|
|
677
|
-
}
|
|
678
|
-
|
|
679
|
-
// =========================== binary ===========================
|
|
680
|
-
|
|
681
|
-
function cmdBinary(cfg) {
|
|
682
|
-
try {
|
|
683
|
-
const agentPath = resolveAgentPath(cfg);
|
|
684
|
-
say(agentPath);
|
|
685
|
-
} catch (e) {
|
|
686
|
-
say(`${C.red}${e.message}${C.reset}`);
|
|
687
|
-
process.exitCode = 2;
|
|
688
|
-
}
|
|
689
|
-
}
|
|
690
|
-
|
|
691
|
-
// ============================ main ============================
|
|
692
|
-
|
|
693
|
-
async function main() {
|
|
694
|
-
const argv = process.argv.slice(2);
|
|
695
|
-
const { flags, positional } = parseArgs(argv);
|
|
696
|
-
const command = positional[0];
|
|
697
|
-
const rest = positional.slice(1);
|
|
698
|
-
|
|
699
|
-
if (flags.version || command === 'version') return printVersion();
|
|
700
|
-
if (flags.help && !command) return printHelp();
|
|
701
|
-
if (!command) {
|
|
702
|
-
printHelp();
|
|
703
|
-
process.exitCode = 1;
|
|
704
|
-
return;
|
|
705
|
-
}
|
|
706
|
-
|
|
707
|
-
const cfg = loadConfig(flags);
|
|
708
|
-
|
|
709
|
-
switch (command) {
|
|
710
|
-
case 'serve':
|
|
711
|
-
return cmdServe(cfg);
|
|
712
|
-
case 'test':
|
|
713
|
-
return cmdTest(cfg, rest);
|
|
714
|
-
case 'doctor':
|
|
715
|
-
return cmdDoctor(cfg);
|
|
716
|
-
case 'binary':
|
|
717
|
-
return cmdBinary(cfg);
|
|
718
|
-
case 'help':
|
|
719
|
-
return printHelp();
|
|
720
|
-
default:
|
|
721
|
-
say(`${C.red}未知命令:${command}${C.reset}\n`);
|
|
722
|
-
printHelp();
|
|
723
|
-
process.exitCode = 1;
|
|
724
|
-
return undefined;
|
|
725
|
-
}
|
|
726
|
-
}
|
|
727
|
-
|
|
728
|
-
main().catch((e) => {
|
|
729
|
-
process.stderr.write(`${C.red}致命错误:${e.message}${C.reset}\n`);
|
|
730
|
-
if (process.env.REMOTETYPE_DEBUG) process.stderr.write(`${e.stack}\n`);
|
|
731
|
-
process.exit(1);
|
|
732
|
-
});
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* RemoteType PC 客户端 CLI 入口。
|
|
6
|
+
*
|
|
7
|
+
* 子命令:
|
|
8
|
+
* remotetype serve 启动守护进程,连接公网 Serve,等待远程文本注入
|
|
9
|
+
* remotetype serve --align --key K 对齐模式:端到端加密 + 输入框实时对齐(含删除)
|
|
10
|
+
* remotetype test "文本" 本地直接测试 Rust 注入,不走公网服务
|
|
11
|
+
* remotetype doctor 环境自检(平台/二进制/权限/网络)
|
|
12
|
+
* remotetype binary 打印解析到的 Rust 二进制路径
|
|
13
|
+
*
|
|
14
|
+
* 全局参数:
|
|
15
|
+
* --url <ws://...> 公网服务地址
|
|
16
|
+
* --client-id <id> 本机标识(服务端据此路由)
|
|
17
|
+
* --log-level <level> debug|info|warn|error
|
|
18
|
+
* --help / --version
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
const fs = require('fs');
|
|
22
|
+
const os = require('os');
|
|
23
|
+
const path = require('path');
|
|
24
|
+
|
|
25
|
+
const { AgentManager } = require('../lib/agent');
|
|
26
|
+
const { MessageQueue } = require('../lib/queue');
|
|
27
|
+
const { ServeClient } = require('../lib/ws-client');
|
|
28
|
+
const { RtAlignSession, looksLikeEnvelope } = require('../lib/rt-align');
|
|
29
|
+
const rtCrypto = require('../lib/rt-crypto');
|
|
30
|
+
const { LocalStore } = require('../lib/store');
|
|
31
|
+
const { Logger } = require('../lib/logger');
|
|
32
|
+
const { sanitize } = require('../lib/sanitize');
|
|
33
|
+
const { findBinary, isSupported, resolveTarget, binaryUrl, cacheDir } = require('../lib/platform');
|
|
34
|
+
const { loadConfig, parseArgs } = require('../lib/config');
|
|
35
|
+
|
|
36
|
+
const pkg = require('../package.json');
|
|
37
|
+
|
|
38
|
+
const C = {
|
|
39
|
+
reset: '\x1b[0m',
|
|
40
|
+
bold: '\x1b[1m',
|
|
41
|
+
dim: '\x1b[90m',
|
|
42
|
+
red: '\x1b[31m',
|
|
43
|
+
green: '\x1b[32m',
|
|
44
|
+
yellow: '\x1b[33m',
|
|
45
|
+
cyan: '\x1b[36m',
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/** 打印带颜色的提示行(不走日志器,用于面向用户的交互输出)。 */
|
|
49
|
+
function say(msg = '') {
|
|
50
|
+
process.stdout.write(`${msg}\n`);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function printHelp() {
|
|
54
|
+
say(`
|
|
55
|
+
${C.bold}remotetype${C.reset} v${pkg.version} — 远程语音文字输入客户端
|
|
56
|
+
|
|
57
|
+
${C.bold}用法${C.reset}
|
|
58
|
+
npx remotetype <command> [options]
|
|
59
|
+
|
|
60
|
+
${C.bold}命令${C.reset}
|
|
61
|
+
${C.cyan}serve${C.reset} 启动守护进程:连接公网服务,等待远程文本注入
|
|
62
|
+
${C.cyan}serve --align${C.reset} 对齐模式:端到端加密 + 输入框实时对齐(含删除)
|
|
63
|
+
${C.cyan}test${C.reset} "<文本>" 本地测试:直接调用注入引擎,不连公网
|
|
64
|
+
${C.cyan}doctor${C.reset} 环境自检:平台支持 / 注入二进制 / 权限 / 网络
|
|
65
|
+
${C.cyan}binary${C.reset} 打印解析到的注入引擎路径
|
|
66
|
+
|
|
67
|
+
${C.bold}选项${C.reset}
|
|
68
|
+
--url <ws://...> 公网服务地址(默认 ${require('../lib/config').DEFAULT_SERVE_URL})
|
|
69
|
+
--client-id <id> 本机唯一标识,服务端按此路由(默认自动生成并持久化)
|
|
70
|
+
--align 对齐模式:只接受 RT1 加密信封,手机输入与电脑实时对齐
|
|
71
|
+
--key <KEY> 对齐模式配对 key(不传则自动生成强随机并打印;手机端输入同一 key)
|
|
72
|
+
--token <TOKEN> 注册门禁 token(服务器设 RT_TOKEN 后两端都必须携带同一值)
|
|
73
|
+
--log-level <level> debug | info | warn | error(默认 info)
|
|
74
|
+
--max-queue <n> 本地消息队列上限(默认 100)
|
|
75
|
+
--max-text <n> 单条文本最大长度,超出截断(默认 5000)
|
|
76
|
+
--agent <path> 手动指定注入引擎二进制路径
|
|
77
|
+
--help 显示帮助
|
|
78
|
+
--version 显示版本
|
|
79
|
+
|
|
80
|
+
${C.bold}示例${C.reset}
|
|
81
|
+
npx remotetype serve --url ws://1.2.3.4:8790/ws
|
|
82
|
+
npx remotetype serve --align # 自动生成 key,复制到手机
|
|
83
|
+
npx remotetype serve --align --key MYKEY123 # 两端用同一个 key
|
|
84
|
+
npx remotetype test "你好,这是一条本地注入测试"
|
|
85
|
+
npx remotetype doctor
|
|
86
|
+
`);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function printVersion() {
|
|
90
|
+
say(`remotetype v${pkg.version} (${process.platform}-${process.arch}, node ${process.version})`);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* 解析注入引擎二进制路径;找不到时给出可操作的错误提示。
|
|
95
|
+
* @returns {string} 二进制绝对路径
|
|
96
|
+
* @throws {Error} 找不到时抛出(带友好信息)
|
|
97
|
+
*/
|
|
98
|
+
function resolveAgentPath(cfg) {
|
|
99
|
+
if (cfg.agentPath) {
|
|
100
|
+
if (!fs.existsSync(cfg.agentPath)) {
|
|
101
|
+
throw new Error(`指定的注入引擎不存在:${cfg.agentPath}`);
|
|
102
|
+
}
|
|
103
|
+
return cfg.agentPath;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const found = findBinary({ extraDirs: [] });
|
|
107
|
+
if (found) return found;
|
|
108
|
+
|
|
109
|
+
const target = resolveTarget();
|
|
110
|
+
const error = new Error(
|
|
111
|
+
[
|
|
112
|
+
`未找到适用于 ${target.key} 的注入引擎(input-agent)。`,
|
|
113
|
+
'',
|
|
114
|
+
'可能原因:',
|
|
115
|
+
' 1) 平台分包未安装 —— 重新执行 npx 会自动拉取;',
|
|
116
|
+
' 2) 处于离线环境 —— 主包只含 JS,二进制需单独获取。',
|
|
117
|
+
'',
|
|
118
|
+
'手动指定:remotetype serve --agent /path/to/input-agent',
|
|
119
|
+
`手动放置:${path.join(cacheDir(), target.key, target.platform === 'win32' ? 'input-agent.exe' : 'input-agent')}`,
|
|
120
|
+
`下载地址:${binaryUrl(target)}`,
|
|
121
|
+
].join('\n'),
|
|
122
|
+
);
|
|
123
|
+
error.code = 'AGENT_NOT_FOUND';
|
|
124
|
+
throw error;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** 检测 Linux 会话类型,返回 'x11' | 'wayland' | 'unknown'。 */
|
|
128
|
+
function detectLinuxSession() {
|
|
129
|
+
if (process.platform !== 'linux') return null;
|
|
130
|
+
const hasDisplay = Boolean(process.env.DISPLAY);
|
|
131
|
+
const hasWayland = Boolean(process.env.WAYLAND_DISPLAY);
|
|
132
|
+
if (hasDisplay) return 'x11'; // 含 XWayland 场景
|
|
133
|
+
if (hasWayland) return 'wayland';
|
|
134
|
+
return 'unknown';
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* 平台相关的启动前提示(需求文档 6.1 / 风险清单)。
|
|
139
|
+
* 不阻塞启动,只做醒目提醒。
|
|
140
|
+
*/
|
|
141
|
+
function printPlatformNotes(logger) {
|
|
142
|
+
const target = resolveTarget();
|
|
143
|
+
|
|
144
|
+
if (target.platform === 'darwin') {
|
|
145
|
+
logger.warn(
|
|
146
|
+
'macOS 需要手动开启辅助功能权限,否则注入会静默失败:' +
|
|
147
|
+
'系统设置 → 隐私与安全性 → 辅助功能 → 勾选你的终端 / Node。',
|
|
148
|
+
);
|
|
149
|
+
logger.warn(
|
|
150
|
+
'若二进制来自浏览器下载,可能被系统隔离。如遇「无法打开」,执行:' +
|
|
151
|
+
`xattr -dr com.apple.quarantine "${path.dirname(findBinary() || '')}"`,
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (target.platform === 'linux') {
|
|
156
|
+
const session = detectLinuxSession();
|
|
157
|
+
if (session === 'wayland') {
|
|
158
|
+
logger.error(
|
|
159
|
+
'检测到当前为纯 Wayland 会话(仅有 WAYLAND_DISPLAY)。' +
|
|
160
|
+
'本版本注入引擎在 Wayland 下无法工作(详见文档 ENIGO_FINDINGS.md)。',
|
|
161
|
+
);
|
|
162
|
+
logger.error(
|
|
163
|
+
'请在登录界面选择「Ubuntu on Xorg」/「GNOME on Xorg」会话后重试;' +
|
|
164
|
+
'若已启用 XWayland,请确认 DISPLAY 环境变量存在。',
|
|
165
|
+
);
|
|
166
|
+
} else if (session === 'unknown') {
|
|
167
|
+
logger.warn('未检测到 DISPLAY 环境变量;若在无图形界面环境运行,注入将失败。');
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (target.platform === 'win32') {
|
|
172
|
+
logger.debug(
|
|
173
|
+
'Windows 下键鼠模拟可能被杀毒软件拦截;若注入失效,请将本程序加入白名单。',
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// ============================ serve ============================
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* serve 子命令:启动守护进程。
|
|
182
|
+
* 链路:Serve(WS) → 队列(串行) → AgentManager(stdio) → Rust → 系统注入
|
|
183
|
+
*/
|
|
184
|
+
async function cmdServe(cfg) {
|
|
185
|
+
const logger = new Logger({
|
|
186
|
+
level: cfg.logLevel,
|
|
187
|
+
logDir: cfg.logDir,
|
|
188
|
+
name: 'client',
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
logger.info(`${C.bold}RemoteType 客户端启动${C.reset}`, {
|
|
192
|
+
version: pkg.version,
|
|
193
|
+
target: cfg.target.key,
|
|
194
|
+
clientId: cfg.clientId,
|
|
195
|
+
url: cfg.url,
|
|
196
|
+
});
|
|
197
|
+
logger.info('配置', {
|
|
198
|
+
logDir: cfg.logDir,
|
|
199
|
+
recordFile: cfg.recordFile,
|
|
200
|
+
maxQueueSize: cfg.maxQueueSize,
|
|
201
|
+
maxTextLength: cfg.maxTextLength,
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
printPlatformNotes(logger);
|
|
205
|
+
|
|
206
|
+
// ---- 解析并校验注入引擎 ----
|
|
207
|
+
let agentPath;
|
|
208
|
+
try {
|
|
209
|
+
agentPath = resolveAgentPath(cfg);
|
|
210
|
+
} catch (e) {
|
|
211
|
+
logger.error(e.message);
|
|
212
|
+
process.exitCode = 2;
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
logger.info('注入引擎已就绪', { agentPath });
|
|
216
|
+
|
|
217
|
+
const store = new LocalStore({ file: cfg.recordFile, logger });
|
|
218
|
+
const agent = new AgentManager({
|
|
219
|
+
binaryPath: agentPath,
|
|
220
|
+
logger,
|
|
221
|
+
restartDelayMs: cfg.restartDelayMs,
|
|
222
|
+
heartbeatMs: cfg.heartbeatMs,
|
|
223
|
+
requestTimeoutMs: cfg.requestTimeoutMs,
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
// 对齐模式会话(可选);serve 在其后创建,sendAck 闭包引用
|
|
227
|
+
let align = null;
|
|
228
|
+
let serve;
|
|
229
|
+
// 本批按键计划中注入失败过的 seq:commit 到来时走 commitFailed
|
|
230
|
+
// (mirror 不推进、重试),而不是 commitApplied(误以为已注入 → 永久错位)
|
|
231
|
+
const failedAlignSeqs = new Set();
|
|
232
|
+
|
|
233
|
+
// ---- 消息队列:串行消费,逐条注入 ----
|
|
234
|
+
// serve 明文模式 item = {text, clientId};对齐模式 item =
|
|
235
|
+
// {op:'type', text, seq} | {op:'backspace', count, seq} | {op:'commit', seq, text}
|
|
236
|
+
const queue = new MessageQueue({
|
|
237
|
+
maxSize: cfg.maxQueueSize,
|
|
238
|
+
logger,
|
|
239
|
+
handler: async (item, meta) => {
|
|
240
|
+
const enqueuedAt = meta.enqueuedAt ?? Date.now();
|
|
241
|
+
const queueWaitMs = Date.now() - enqueuedAt;
|
|
242
|
+
const startedAt = Date.now();
|
|
243
|
+
|
|
244
|
+
// commit:本批按键计划完成,推进 mirror 并回 ACK(无注入动作)
|
|
245
|
+
if (item.op === 'commit') {
|
|
246
|
+
if (failedAlignSeqs.has(item.seq)) {
|
|
247
|
+
failedAlignSeqs.delete(item.seq);
|
|
248
|
+
align?.commitFailed(item.seq);
|
|
249
|
+
} else {
|
|
250
|
+
align?.commitApplied(item.seq, item.text);
|
|
251
|
+
}
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// backspace:删除原语(relay 对齐模式)
|
|
256
|
+
if (item.op === 'backspace') {
|
|
257
|
+
try {
|
|
258
|
+
const res = await agent.backspace(item.count);
|
|
259
|
+
if (res?.ok) {
|
|
260
|
+
logger.debug('退格完成', { count: item.count, queueWaitMs });
|
|
261
|
+
store.append({ status: 'backspaced', count: item.count, source: meta.source, seq: item.seq });
|
|
262
|
+
} else {
|
|
263
|
+
logger.error('退格失败', { count: item.count, msg: res?.msg });
|
|
264
|
+
if (item.seq !== undefined) failedAlignSeqs.add(item.seq);
|
|
265
|
+
store.append({ status: 'backspace_failed', count: item.count, errorMsg: res?.msg ?? 'unknown', source: meta.source, seq: item.seq });
|
|
266
|
+
}
|
|
267
|
+
} catch (e) {
|
|
268
|
+
logger.error('退格异常', { count: item.count, error: e.message });
|
|
269
|
+
if (item.seq !== undefined) failedAlignSeqs.add(item.seq);
|
|
270
|
+
store.append({ status: 'backspace_failed', count: item.count, errorMsg: e.message, source: meta.source, seq: item.seq });
|
|
271
|
+
}
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// type:常规文本注入(serve 与 relay 共用,relay 的 text 是 diff 出的增量)
|
|
276
|
+
// 二次预处理:即便上游已过滤,这里仍是最后一道防线
|
|
277
|
+
const cleaned = sanitize(item.text, { maxLength: cfg.maxTextLength });
|
|
278
|
+
if (!cleaned.ok) {
|
|
279
|
+
logger.warn('文本预处理后丢弃', { reason: cleaned.reason, clientId: item.clientId });
|
|
280
|
+
if (meta.source === 'align' && item.seq !== undefined) failedAlignSeqs.add(item.seq);
|
|
281
|
+
store.append({
|
|
282
|
+
text: item.text,
|
|
283
|
+
status: 'dropped',
|
|
284
|
+
errorMsg: cleaned.reason,
|
|
285
|
+
clientId: item.clientId,
|
|
286
|
+
source: meta.source,
|
|
287
|
+
queueWaitMs,
|
|
288
|
+
});
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
if (cleaned.truncated) {
|
|
292
|
+
logger.warn('文本超长已截断', {
|
|
293
|
+
originalLength: cleaned.originalLength,
|
|
294
|
+
maxLength: cfg.maxTextLength,
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
logger.info('开始注入', {
|
|
299
|
+
textLen: cleaned.text.length,
|
|
300
|
+
queueWaitMs,
|
|
301
|
+
clientId: item.clientId,
|
|
302
|
+
source: meta.source,
|
|
303
|
+
preview: cleaned.text.slice(0, 40),
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
try {
|
|
307
|
+
const res = await agent.typeText(cleaned.text);
|
|
308
|
+
const durationMs = Date.now() - startedAt;
|
|
309
|
+
if (res?.ok) {
|
|
310
|
+
logger.info('注入完成', { durationMs, textLen: cleaned.text.length });
|
|
311
|
+
store.append({
|
|
312
|
+
text: cleaned.text,
|
|
313
|
+
status: 'injected',
|
|
314
|
+
clientId: item.clientId,
|
|
315
|
+
source: meta.source,
|
|
316
|
+
queueWaitMs,
|
|
317
|
+
durationMs,
|
|
318
|
+
truncated: Boolean(cleaned.truncated),
|
|
319
|
+
});
|
|
320
|
+
} else {
|
|
321
|
+
logger.error('注入失败', { msg: res?.msg, durationMs });
|
|
322
|
+
if (meta.source === 'align' && item.seq !== undefined) failedAlignSeqs.add(item.seq);
|
|
323
|
+
store.append({
|
|
324
|
+
text: cleaned.text,
|
|
325
|
+
status: 'inject_failed',
|
|
326
|
+
errorMsg: res?.msg ?? 'unknown',
|
|
327
|
+
clientId: item.clientId,
|
|
328
|
+
source: meta.source,
|
|
329
|
+
queueWaitMs,
|
|
330
|
+
durationMs,
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
} catch (e) {
|
|
334
|
+
const durationMs = Date.now() - startedAt;
|
|
335
|
+
logger.error('注入异常', { error: e.message, durationMs });
|
|
336
|
+
if (meta.source === 'align' && item.seq !== undefined) failedAlignSeqs.add(item.seq);
|
|
337
|
+
store.append({
|
|
338
|
+
text: cleaned.text,
|
|
339
|
+
status: 'inject_failed',
|
|
340
|
+
errorMsg: e.message,
|
|
341
|
+
clientId: item.clientId,
|
|
342
|
+
source: meta.source,
|
|
343
|
+
queueWaitMs,
|
|
344
|
+
durationMs,
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
},
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
// ---- 拉起子进程 ----
|
|
351
|
+
try {
|
|
352
|
+
agent.start();
|
|
353
|
+
} catch (e) {
|
|
354
|
+
logger.error('注入引擎启动失败', { error: e.message });
|
|
355
|
+
process.exitCode = 3;
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// ---- 对齐模式(可选):密文信封 → 按键计划 → ACK ----
|
|
360
|
+
if (cfg.align) {
|
|
361
|
+
const key = cfg.alignKey || rtCrypto.generateKey();
|
|
362
|
+
align = new RtAlignSession({
|
|
363
|
+
key,
|
|
364
|
+
logger,
|
|
365
|
+
sendAck: (envelope) => serve.send({ type: 'ack', phoneId: align.phoneId, ack: envelope }),
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
if (!cfg.alignKey) {
|
|
369
|
+
say('');
|
|
370
|
+
say(`${C.bold}${C.green} ┌──────────────────────────────────────────┐${C.reset}`);
|
|
371
|
+
say(`${C.bold}${C.green} │ 配对 key(输入到 fr「远程输入」demo): │${C.reset}`);
|
|
372
|
+
say(`${C.bold}${C.green} │ │${C.reset}`);
|
|
373
|
+
say(`${C.bold}${C.green} │ ${key} │${C.reset}`);
|
|
374
|
+
say(`${C.bold}${C.green} └──────────────────────────────────────────┘${C.reset}`);
|
|
375
|
+
say(`${C.dim} key 即凭据:泄漏 = 任何人可向本机注入文本。勿外传。${C.reset}`);
|
|
376
|
+
say('');
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
align.on('decrypt-error', ({ seq }) => {
|
|
380
|
+
logger.error('解密失败:两端 key 不一致?', { seq });
|
|
381
|
+
});
|
|
382
|
+
align.on('op', (op) => {
|
|
383
|
+
const accepted = queue.push(op, { source: 'align', enqueuedAt: Date.now() });
|
|
384
|
+
if (!accepted) {
|
|
385
|
+
logger.warn('队列溢出,丢弃计划', { op: op.op, seq: op.seq });
|
|
386
|
+
store.append({ status: 'dropped', errorMsg: 'queue_overflow', source: 'align', seq: op.seq });
|
|
387
|
+
}
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// ---- 连接公网服务(明文 / 对齐两种模式共用 ServeClient)----
|
|
392
|
+
serve = new ServeClient({
|
|
393
|
+
url: cfg.url,
|
|
394
|
+
clientId: cfg.clientId,
|
|
395
|
+
token: cfg.token,
|
|
396
|
+
logger,
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
serve.on('connected', () => {
|
|
400
|
+
logger.info(
|
|
401
|
+
align
|
|
402
|
+
? `${C.green}已连接公网服务(对齐模式),等待手机配对…${C.reset}`
|
|
403
|
+
: `${C.green}已连接公网服务,等待远程文本…${C.reset}`,
|
|
404
|
+
{ clientId: cfg.clientId },
|
|
405
|
+
);
|
|
406
|
+
});
|
|
407
|
+
serve.on('reconnecting', ({ delayMs, attempt }) => {
|
|
408
|
+
logger.warn(`连接断开,${delayMs}ms 后重连`, { attempt });
|
|
409
|
+
});
|
|
410
|
+
serve.on('disconnected', ({ code, reason }) => {
|
|
411
|
+
logger.warn('与服务端连接已断开', { code, reason });
|
|
412
|
+
});
|
|
413
|
+
serve.on('registered', (msg) => {
|
|
414
|
+
logger.info('服务端已登记本机', { clientId: msg.clientId, role: msg.role });
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
serve.on('text', (msg) => {
|
|
418
|
+
if (align) {
|
|
419
|
+
// 对齐模式:只接受 RT1 加密信封;明文一律拒绝(防旁路注入)
|
|
420
|
+
const outcome = align.handleIncoming(msg.text);
|
|
421
|
+
if (outcome === 'plaintext') {
|
|
422
|
+
store.append({
|
|
423
|
+
text: String(msg.text ?? '').slice(0, 200),
|
|
424
|
+
status: 'plaintext_rejected',
|
|
425
|
+
clientId: msg.clientId,
|
|
426
|
+
source: 'align',
|
|
427
|
+
});
|
|
428
|
+
}
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
431
|
+
logger.info('收到远程文本', { textLen: msg.text.length, clientId: msg.clientId });
|
|
432
|
+
const accepted = queue.push(
|
|
433
|
+
{ text: msg.text, clientId: msg.clientId },
|
|
434
|
+
{ source: 'serve', enqueuedAt: Date.now() },
|
|
435
|
+
);
|
|
436
|
+
if (!accepted) {
|
|
437
|
+
store.append({
|
|
438
|
+
text: msg.text,
|
|
439
|
+
status: 'dropped',
|
|
440
|
+
errorMsg: 'queue_overflow',
|
|
441
|
+
clientId: msg.clientId,
|
|
442
|
+
source: 'serve',
|
|
443
|
+
});
|
|
444
|
+
}
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
// 注册门禁拒绝(4403):token 配置错误需要人工介入,重连无意义,直接退出
|
|
448
|
+
serve.on('auth-rejected', () => {
|
|
449
|
+
logger.error('token 无效或缺失:请核对 --token / REMOTETYPE_TOKEN 与服务器 RT_TOKEN 是否一致');
|
|
450
|
+
shutdown('auth-rejected', 1);
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
serve.connect();
|
|
454
|
+
|
|
455
|
+
// ---- 状态摘要(每 60s 打印一次,便于长期运行观测) ----
|
|
456
|
+
const summary = setInterval(() => {
|
|
457
|
+
logger.info('运行状态', {
|
|
458
|
+
queueDepth: queue.depth,
|
|
459
|
+
queue: queue.stats,
|
|
460
|
+
agent: agent.stats,
|
|
461
|
+
serve: serve ? serve.stats : null,
|
|
462
|
+
align: align ? align.stats : null,
|
|
463
|
+
store: store.stats(),
|
|
464
|
+
});
|
|
465
|
+
}, 60000);
|
|
466
|
+
summary.unref?.();
|
|
467
|
+
|
|
468
|
+
// ---- 优雅退出 ----
|
|
469
|
+
let shuttingDown = false;
|
|
470
|
+
const shutdown = async (signal, code = 0) => {
|
|
471
|
+
if (shuttingDown) return;
|
|
472
|
+
shuttingDown = true;
|
|
473
|
+
clearInterval(summary);
|
|
474
|
+
logger.info(`收到 ${signal},开始优雅退出`);
|
|
475
|
+
queue.pause();
|
|
476
|
+
align?.dispose();
|
|
477
|
+
serve?.close();
|
|
478
|
+
// 给队列里剩余消息一点时间跑完
|
|
479
|
+
const drained = await queue.waitIdle(8000);
|
|
480
|
+
if (!drained) logger.warn('队列未在超时内消费完毕,剩余消息将被丢弃', { depth: queue.depth });
|
|
481
|
+
await agent.stop();
|
|
482
|
+
logger.info('已退出', { queue: queue.stats, agent: agent.stats, store: store.stats() });
|
|
483
|
+
logger.close();
|
|
484
|
+
process.exit(code);
|
|
485
|
+
};
|
|
486
|
+
|
|
487
|
+
process.on('SIGINT', () => shutdown('SIGINT'));
|
|
488
|
+
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
|
489
|
+
|
|
490
|
+
// 保持进程存活
|
|
491
|
+
await new Promise(() => {});
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
// ============================ test ============================
|
|
495
|
+
|
|
496
|
+
/**
|
|
497
|
+
* test 子命令:不连公网,直接本地注入,用于快速验证环境。
|
|
498
|
+
* 退出码:0 成功;4 注入失败;2 找不到二进制。
|
|
499
|
+
*/
|
|
500
|
+
async function cmdTest(cfg, positional) {
|
|
501
|
+
const logger = new Logger({ level: cfg.logLevel, logDir: null, name: 'client' });
|
|
502
|
+
|
|
503
|
+
const raw = positional.join(' ');
|
|
504
|
+
if (!raw) {
|
|
505
|
+
say(`${C.red}错误:请提供要注入的文本${C.reset}`);
|
|
506
|
+
say(`示例:npx remotetype test "你好,这是一条本地注入测试"`);
|
|
507
|
+
process.exitCode = 1;
|
|
508
|
+
return;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
const cleaned = sanitize(raw, { maxLength: cfg.maxTextLength });
|
|
512
|
+
if (!cleaned.ok) {
|
|
513
|
+
say(`${C.red}文本预处理后为空(${cleaned.reason}),已丢弃${C.reset}`);
|
|
514
|
+
process.exitCode = 1;
|
|
515
|
+
return;
|
|
516
|
+
}
|
|
517
|
+
if (cleaned.truncated) {
|
|
518
|
+
logger.warn('文本超长已截断', { originalLength: cleaned.originalLength, maxLength: cfg.maxTextLength });
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
let agentPath;
|
|
522
|
+
try {
|
|
523
|
+
agentPath = resolveAgentPath(cfg);
|
|
524
|
+
} catch (e) {
|
|
525
|
+
// 复用统一提示(--agent 指定错误时同样走这里)
|
|
526
|
+
throw e;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
say(`${C.dim}注入引擎:${agentPath}${C.reset}`);
|
|
530
|
+
say(`${C.dim}将注入 ${Array.from(cleaned.text).length} 个字符到当前焦点窗口…${C.reset}`);
|
|
531
|
+
|
|
532
|
+
printPlatformNotes(logger);
|
|
533
|
+
|
|
534
|
+
const agent = new AgentManager({
|
|
535
|
+
binaryPath: agentPath,
|
|
536
|
+
logger,
|
|
537
|
+
restartDelayMs: cfg.restartDelayMs,
|
|
538
|
+
heartbeatMs: 0, // 单次测试不需要心跳
|
|
539
|
+
requestTimeoutMs: cfg.requestTimeoutMs,
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
try {
|
|
543
|
+
agent.start();
|
|
544
|
+
const startedAt = Date.now();
|
|
545
|
+
const res = await agent.typeText(cleaned.text);
|
|
546
|
+
const durationMs = Date.now() - startedAt;
|
|
547
|
+
|
|
548
|
+
if (res?.ok) {
|
|
549
|
+
say(`${C.green}✓ 注入成功${C.reset}(${durationMs}ms)`);
|
|
550
|
+
say(`${C.dim}请确认当前焦点输入框中已出现文字;若没有,请检查辅助功能权限或会话类型。${C.reset}`);
|
|
551
|
+
} else {
|
|
552
|
+
say(`${C.red}✗ 注入失败${C.reset}:${res?.msg ?? 'unknown'}(${durationMs}ms)`);
|
|
553
|
+
process.exitCode = 4;
|
|
554
|
+
}
|
|
555
|
+
} catch (e) {
|
|
556
|
+
say(`${C.red}✗ 注入异常${C.reset}:${e.message}`);
|
|
557
|
+
process.exitCode = 4;
|
|
558
|
+
} finally {
|
|
559
|
+
await agent.stop();
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
// =========================== doctor ===========================
|
|
564
|
+
|
|
565
|
+
/** doctor 子命令:环境自检,逐项给出结论与建议。 */
|
|
566
|
+
async function cmdDoctor(cfg) {
|
|
567
|
+
const lines = [];
|
|
568
|
+
const ok = (m) => lines.push(`${C.green}✓${C.reset} ${m}`);
|
|
569
|
+
const bad = (m) => lines.push(`${C.red}✗${C.reset} ${m}`);
|
|
570
|
+
const warn = (m) => lines.push(`${C.yellow}!${C.reset} ${m}`);
|
|
571
|
+
const info = (m) => lines.push(` ${C.dim}${m}${C.reset}`);
|
|
572
|
+
|
|
573
|
+
say(`\n${C.bold}RemoteType 环境自检${C.reset}\n`);
|
|
574
|
+
|
|
575
|
+
// 1) 运行时
|
|
576
|
+
ok(`操作系统:${process.platform} ${os.release()} (${process.arch})`);
|
|
577
|
+
ok(`Node.js:${process.version}`);
|
|
578
|
+
if (Number.parseInt(process.versions.node, 10) >= 18) {
|
|
579
|
+
ok('Node 版本满足要求(>= 18)');
|
|
580
|
+
} else {
|
|
581
|
+
bad('Node 版本过低,需要 >= 18');
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
// 2) 平台支持
|
|
585
|
+
const target = resolveTarget();
|
|
586
|
+
if (isSupported()) {
|
|
587
|
+
ok(`目标平台 ${target.key} 受支持`);
|
|
588
|
+
} else {
|
|
589
|
+
bad(`目标平台 ${target.key} 不在支持列表内`);
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
// 3) Linux 会话类型
|
|
593
|
+
if (process.platform === 'linux') {
|
|
594
|
+
const session = detectLinuxSession();
|
|
595
|
+
if (session === 'x11') {
|
|
596
|
+
ok(`图形会话:X11(DISPLAY=${process.env.DISPLAY})`);
|
|
597
|
+
} else if (session === 'wayland') {
|
|
598
|
+
bad('图形会话:纯 Wayland —— 注入引擎不支持(请改用 Xorg 会话)');
|
|
599
|
+
info('详见项目文档 ENIGO_FINDINGS.md');
|
|
600
|
+
} else {
|
|
601
|
+
warn('未检测到 DISPLAY / WAYLAND_DISPLAY,可能无图形环境');
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
// 4) 注入引擎
|
|
606
|
+
try {
|
|
607
|
+
const agentPath = resolveAgentPath(cfg);
|
|
608
|
+
const st = fs.statSync(agentPath);
|
|
609
|
+
ok(`注入引擎:${agentPath}`);
|
|
610
|
+
info(`大小 ${(st.size / 1024 / 1024).toFixed(2)} MB`);
|
|
611
|
+
if (process.platform !== 'win32' && !(st.mode & 0o111)) {
|
|
612
|
+
warn('二进制缺少可执行权限,启动时会自动 chmod 修复');
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
// 5) 实机注入测试(ping + 可选的空跑)
|
|
616
|
+
const logger = new Logger({ level: 'error', logDir: null, name: 'doctor' });
|
|
617
|
+
const agent = new AgentManager({
|
|
618
|
+
binaryPath: agentPath,
|
|
619
|
+
logger,
|
|
620
|
+
heartbeatMs: 0,
|
|
621
|
+
requestTimeoutMs: 8000,
|
|
622
|
+
maxRestarts: 1,
|
|
623
|
+
});
|
|
624
|
+
try {
|
|
625
|
+
agent.start();
|
|
626
|
+
const res = await agent.send({ action: 'ping' }, 8000);
|
|
627
|
+
if (res?.ok) {
|
|
628
|
+
ok('注入引擎通信正常(stdio IPC 应答 pong)');
|
|
629
|
+
} else {
|
|
630
|
+
bad(`注入引擎应答异常:${JSON.stringify(res)}`);
|
|
631
|
+
}
|
|
632
|
+
} catch (e) {
|
|
633
|
+
bad(`注入引擎无法通信:${e.message}`);
|
|
634
|
+
if (process.platform === 'darwin') {
|
|
635
|
+
info('macOS 请检查:系统设置 → 隐私与安全性 → 辅助功能');
|
|
636
|
+
}
|
|
637
|
+
if (process.platform === 'linux') {
|
|
638
|
+
info('Linux 请确认在 X11 会话下运行,且 DISPLAY 正确');
|
|
639
|
+
}
|
|
640
|
+
} finally {
|
|
641
|
+
await agent.stop();
|
|
642
|
+
}
|
|
643
|
+
} catch (e) {
|
|
644
|
+
bad(e.message);
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
// 6) 网络连通性(仅解析地址,不强制连接)
|
|
648
|
+
try {
|
|
649
|
+
const u = new URL(cfg.url);
|
|
650
|
+
ok(`服务地址:${cfg.url}`);
|
|
651
|
+
info(`协议 ${u.protocol.replace(':', '')} / 主机 ${u.host}`);
|
|
652
|
+
if (u.protocol === 'wss:') {
|
|
653
|
+
info('使用 TLS 加密传输');
|
|
654
|
+
} else {
|
|
655
|
+
warn('使用明文 ws:// 传输,公网环境建议改用 wss://');
|
|
656
|
+
}
|
|
657
|
+
} catch {
|
|
658
|
+
bad(`服务地址格式不合法:${cfg.url}`);
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
// 7) 数据目录可写性
|
|
662
|
+
for (const [label, p] of [['日志目录', cfg.logDir], ['记录文件', cfg.recordFile]]) {
|
|
663
|
+
try {
|
|
664
|
+
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
665
|
+
fs.accessSync(path.dirname(p), fs.constants.W_OK);
|
|
666
|
+
ok(`${label}可写:${p}`);
|
|
667
|
+
} catch (e) {
|
|
668
|
+
bad(`${label}不可写:${p}(${e.message})`);
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
ok(`本机标识 clientId:${cfg.clientId}`);
|
|
673
|
+
|
|
674
|
+
say(lines.join('\n'));
|
|
675
|
+
say(`\n${C.dim}提示:doctor 只做自检,不会真正向焦点窗口输入文字。${C.reset}`);
|
|
676
|
+
say(`${C.dim}要实测注入,请运行:npx remotetype test "测试文本"${C.reset}\n`);
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
// =========================== binary ===========================
|
|
680
|
+
|
|
681
|
+
function cmdBinary(cfg) {
|
|
682
|
+
try {
|
|
683
|
+
const agentPath = resolveAgentPath(cfg);
|
|
684
|
+
say(agentPath);
|
|
685
|
+
} catch (e) {
|
|
686
|
+
say(`${C.red}${e.message}${C.reset}`);
|
|
687
|
+
process.exitCode = 2;
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
// ============================ main ============================
|
|
692
|
+
|
|
693
|
+
async function main() {
|
|
694
|
+
const argv = process.argv.slice(2);
|
|
695
|
+
const { flags, positional } = parseArgs(argv);
|
|
696
|
+
const command = positional[0];
|
|
697
|
+
const rest = positional.slice(1);
|
|
698
|
+
|
|
699
|
+
if (flags.version || command === 'version') return printVersion();
|
|
700
|
+
if (flags.help && !command) return printHelp();
|
|
701
|
+
if (!command) {
|
|
702
|
+
printHelp();
|
|
703
|
+
process.exitCode = 1;
|
|
704
|
+
return;
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
const cfg = loadConfig(flags);
|
|
708
|
+
|
|
709
|
+
switch (command) {
|
|
710
|
+
case 'serve':
|
|
711
|
+
return cmdServe(cfg);
|
|
712
|
+
case 'test':
|
|
713
|
+
return cmdTest(cfg, rest);
|
|
714
|
+
case 'doctor':
|
|
715
|
+
return cmdDoctor(cfg);
|
|
716
|
+
case 'binary':
|
|
717
|
+
return cmdBinary(cfg);
|
|
718
|
+
case 'help':
|
|
719
|
+
return printHelp();
|
|
720
|
+
default:
|
|
721
|
+
say(`${C.red}未知命令:${command}${C.reset}\n`);
|
|
722
|
+
printHelp();
|
|
723
|
+
process.exitCode = 1;
|
|
724
|
+
return undefined;
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
main().catch((e) => {
|
|
729
|
+
process.stderr.write(`${C.red}致命错误:${e.message}${C.reset}\n`);
|
|
730
|
+
if (process.env.REMOTETYPE_DEBUG) process.stderr.write(`${e.stack}\n`);
|
|
731
|
+
process.exit(1);
|
|
732
|
+
});
|