@ondevplann/remotetype 0.2.1 → 0.3.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/bin/index.js +73 -2
- package/lib/agent.js +10 -8
- package/lib/rt-align.js +48 -1
- package/package.json +5 -3
package/bin/index.js
CHANGED
|
@@ -230,6 +230,59 @@ async function cmdServe(cfg) {
|
|
|
230
230
|
// 本批按键计划中注入失败过的 seq:commit 到来时走 commitFailed
|
|
231
231
|
// (mirror 不推进、重试),而不是 commitApplied(误以为已注入 → 永久错位)
|
|
232
232
|
const failedAlignSeqs = new Set();
|
|
233
|
+
// 焦点绑定:首个成功注入的窗口标识记为对齐目标,之后每条 op 携带
|
|
234
|
+
// expect_focus 校验;rust 拒注时挂起 align 会话+通知手机,避免
|
|
235
|
+
// 电脑切焦点后误删误打。新会话(对齐模式)建立时重置。
|
|
236
|
+
let focusTarget = null;
|
|
237
|
+
let focusPaused = false;
|
|
238
|
+
let focusPollTimer = null;
|
|
239
|
+
|
|
240
|
+
/** 焦点匹配成功:首次绑定,后续直接用。每次顺手更新到目标(target 句柄可能跨进程稳定)。 */
|
|
241
|
+
function bindFocus(current) {
|
|
242
|
+
if (current && !focusTarget) {
|
|
243
|
+
focusTarget = current;
|
|
244
|
+
logger.info('焦点已绑定(首个注入目标)', { target: focusTarget });
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** 焦点漂移:挂起 align 会话 + 通知手机 + 启动轮询。同一 seq 走 commitFailed 不推进 mirror。 */
|
|
249
|
+
function handleFocusDrift(seq, currentFocus) {
|
|
250
|
+
if (focusPaused) return; // 已经在挂起,重复漂移不重复发 ctrl
|
|
251
|
+
focusPaused = true;
|
|
252
|
+
logger.warn('焦点漂移,注入已暂停', { target: focusTarget, current: currentFocus, seq });
|
|
253
|
+
if (seq !== undefined) failedAlignSeqs.add(seq);
|
|
254
|
+
if (align) align.pause();
|
|
255
|
+
// 通知手机:把 ctrl 信封通过 sendAck 同一个 transport 推上去(共用 c2p 方向)
|
|
256
|
+
if (align?.sendCtrl) {
|
|
257
|
+
align.sendCtrl('focus-paused').catch((e) => logger.warn('ctrl 通知失败', { error: e.message }));
|
|
258
|
+
}
|
|
259
|
+
// 启动轮询:每 2s 询问 rust 当前焦点,回到目标则恢复
|
|
260
|
+
focusPollTimer = setInterval(() => {
|
|
261
|
+
agent.send({ action: 'focus' }, cfg.requestTimeoutMs)
|
|
262
|
+
.then((res) => {
|
|
263
|
+
if (res?.focus && res.focus === focusTarget) {
|
|
264
|
+
resumeFromFocus();
|
|
265
|
+
}
|
|
266
|
+
})
|
|
267
|
+
.catch((e) => logger.debug('焦点轮询失败', { error: e.message }));
|
|
268
|
+
}, 2000);
|
|
269
|
+
focusPollTimer.unref?.();
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** 焦点回归:取消轮询、通知手机、恢复 align 会话(会重新 dispatch 当前 desired)。 */
|
|
273
|
+
function resumeFromFocus() {
|
|
274
|
+
if (!focusPaused) return;
|
|
275
|
+
focusPaused = false;
|
|
276
|
+
if (focusPollTimer) {
|
|
277
|
+
clearInterval(focusPollTimer);
|
|
278
|
+
focusPollTimer = null;
|
|
279
|
+
}
|
|
280
|
+
logger.info('焦点已恢复,注入恢复', { target: focusTarget });
|
|
281
|
+
if (align?.sendCtrl) {
|
|
282
|
+
align.sendCtrl('focus-resumed').catch((e) => logger.warn('ctrl 通知失败', { error: e.message }));
|
|
283
|
+
}
|
|
284
|
+
if (align) align.resume();
|
|
285
|
+
}
|
|
233
286
|
|
|
234
287
|
// ---- 消息队列:串行消费,逐条注入 ----
|
|
235
288
|
// serve 明文模式 item = {text, clientId};对齐模式 item =
|
|
@@ -255,11 +308,16 @@ async function cmdServe(cfg) {
|
|
|
255
308
|
|
|
256
309
|
// backspace:删除原语(relay 对齐模式)
|
|
257
310
|
if (item.op === 'backspace') {
|
|
311
|
+
const expectFocus = meta.source === 'align' ? focusTarget : null;
|
|
258
312
|
try {
|
|
259
|
-
const res = await agent.backspace(item.count);
|
|
313
|
+
const res = await agent.backspace(item.count, cfg.requestTimeoutMs, expectFocus);
|
|
260
314
|
if (res?.ok) {
|
|
261
315
|
logger.debug('退格完成', { count: item.count, queueWaitMs });
|
|
262
316
|
store.append({ status: 'backspaced', count: item.count, source: meta.source, seq: item.seq });
|
|
317
|
+
if (meta.source === 'align') bindFocus(res.focus);
|
|
318
|
+
} else if (res?.msg === 'focus_drift') {
|
|
319
|
+
handleFocusDrift(item.seq, res.focus);
|
|
320
|
+
store.append({ status: 'backspace_focus_drift', count: item.count, source: meta.source, seq: item.seq, currentFocus: res.focus });
|
|
263
321
|
} else {
|
|
264
322
|
logger.error('退格失败', { count: item.count, msg: res?.msg });
|
|
265
323
|
if (item.seq !== undefined) failedAlignSeqs.add(item.seq);
|
|
@@ -316,10 +374,12 @@ async function cmdServe(cfg) {
|
|
|
316
374
|
});
|
|
317
375
|
|
|
318
376
|
try {
|
|
319
|
-
const
|
|
377
|
+
const expectFocus = isAlignSource ? focusTarget : null;
|
|
378
|
+
const res = await agent.typeText(cleaned.text, cfg.requestTimeoutMs, expectFocus);
|
|
320
379
|
const durationMs = Date.now() - startedAt;
|
|
321
380
|
if (res?.ok) {
|
|
322
381
|
logger.info('注入完成', { durationMs, textLen: cleaned.text.length });
|
|
382
|
+
if (isAlignSource) bindFocus(res.focus);
|
|
323
383
|
store.append({
|
|
324
384
|
text: cleaned.text,
|
|
325
385
|
status: 'injected',
|
|
@@ -329,6 +389,17 @@ async function cmdServe(cfg) {
|
|
|
329
389
|
durationMs,
|
|
330
390
|
truncated: Boolean(cleaned.truncated),
|
|
331
391
|
});
|
|
392
|
+
} else if (res?.msg === 'focus_drift' && isAlignSource) {
|
|
393
|
+
handleFocusDrift(item.seq, res.focus);
|
|
394
|
+
store.append({
|
|
395
|
+
text: cleaned.text,
|
|
396
|
+
status: 'inject_focus_drift',
|
|
397
|
+
clientId: item.clientId,
|
|
398
|
+
source: meta.source,
|
|
399
|
+
queueWaitMs,
|
|
400
|
+
durationMs,
|
|
401
|
+
currentFocus: res.focus,
|
|
402
|
+
});
|
|
332
403
|
} else {
|
|
333
404
|
logger.error('注入失败', { msg: res?.msg, durationMs });
|
|
334
405
|
if (meta.source === 'align' && item.seq !== undefined) failedAlignSeqs.add(item.seq);
|
package/lib/agent.js
CHANGED
|
@@ -326,16 +326,18 @@ class AgentManager {
|
|
|
326
326
|
});
|
|
327
327
|
}
|
|
328
328
|
|
|
329
|
-
/**
|
|
330
|
-
async typeText(text, timeoutMs) {
|
|
331
|
-
const
|
|
332
|
-
|
|
329
|
+
/** 便捷方法:注入文本。`expectFocus` 对齐模式校验:与当前焦点不符则 rust 拒注并回 focus_drift。 */
|
|
330
|
+
async typeText(text, timeoutMs, expectFocus = null) {
|
|
331
|
+
const payload = { action: 'type_text', text };
|
|
332
|
+
if (expectFocus) payload.expect_focus = expectFocus;
|
|
333
|
+
return this.send(payload, timeoutMs);
|
|
333
334
|
}
|
|
334
335
|
|
|
335
|
-
/** 便捷方法:向焦点输入框发送 N
|
|
336
|
-
async backspace(count, timeoutMs) {
|
|
337
|
-
const
|
|
338
|
-
|
|
336
|
+
/** 便捷方法:向焦点输入框发送 N 次退格。`expectFocus` 同上:漂移时拒退格。 */
|
|
337
|
+
async backspace(count, timeoutMs, expectFocus = null) {
|
|
338
|
+
const payload = { action: 'backspace', count };
|
|
339
|
+
if (expectFocus) payload.expect_focus = expectFocus;
|
|
340
|
+
return this.send(payload, timeoutMs);
|
|
339
341
|
}
|
|
340
342
|
|
|
341
343
|
/** 优雅关闭:结束 stdin -> 等待退出 -> 超时强杀。 */
|
package/lib/rt-align.js
CHANGED
|
@@ -78,6 +78,7 @@ class RtAlignSession extends EventEmitter {
|
|
|
78
78
|
this.pendingFails = 0; // 当前 desired 连续注入失败次数(成功 commit 或新快照归零)
|
|
79
79
|
this.ackSeq = 0;
|
|
80
80
|
this.sid = crypto.randomBytes(4).toString('hex');
|
|
81
|
+
this.paused = false; // 焦点漂移期间挂起(focus-paused),挂起期间 _dispatch 直接丢弃
|
|
81
82
|
|
|
82
83
|
this.stats = {
|
|
83
84
|
envelopesOk: 0, envelopesBad: 0, syncsApplied: 0, acksSent: 0, plaintextRejected: 0,
|
|
@@ -157,7 +158,7 @@ class RtAlignSession extends EventEmitter {
|
|
|
157
158
|
* busy 期间只更新 desiredText,commit 后自动续跑(全文快照语义下永远收敛)。
|
|
158
159
|
*/
|
|
159
160
|
_dispatch() {
|
|
160
|
-
if (this.busy || this.desiredText === null) return;
|
|
161
|
+
if (this.busy || this.paused || this.desiredText === null) return;
|
|
161
162
|
const plan = planTransition(this.mirror, this.desiredText);
|
|
162
163
|
if (plan.backspaces === 0 && plan.insert === '') {
|
|
163
164
|
// 目标已达成(重复快照),仍需补 ACK 推进手机端确认
|
|
@@ -248,6 +249,52 @@ class RtAlignSession extends EventEmitter {
|
|
|
248
249
|
this.busySince = 0;
|
|
249
250
|
this._dispatch();
|
|
250
251
|
}
|
|
252
|
+
|
|
253
|
+
// ================= 挂起/恢复(焦点漂移) =================
|
|
254
|
+
|
|
255
|
+
/** 挂起对齐会话:焦点漂移期间停止派发按键计划,避免误删误打。 */
|
|
256
|
+
pause() {
|
|
257
|
+
if (this.paused) return;
|
|
258
|
+
this.paused = true;
|
|
259
|
+
this.logger?.info('align session paused (focus drift)');
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* 恢复对齐会话:重新派发当前 desired,焦点已回归到目标输入框。
|
|
264
|
+
* 关键:desiredText / desiredSeq 都保留着(之前的快照),所以新一次
|
|
265
|
+
* dispatch 从当前 mirror 重新 diff,自然处理「挂起期间手机又改了几个字」。
|
|
266
|
+
*/
|
|
267
|
+
resume() {
|
|
268
|
+
if (!this.paused) return;
|
|
269
|
+
this.paused = false;
|
|
270
|
+
this.logger?.info('align session resumed (focus restored)');
|
|
271
|
+
if (this.desiredText !== null) this._dispatch();
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* 发送 ctrl 信封到手机(共用 sendAck 同一传输层 + keyPcToPhone 方向,
|
|
276
|
+
* 走 c2p 单调 seq 域:手机端会判 ackSeq 顺序与去重)。
|
|
277
|
+
* @param {string} event 事件名,如 'focus-paused' / 'focus-resumed'
|
|
278
|
+
*/
|
|
279
|
+
sendCtrl(event) {
|
|
280
|
+
if (typeof this._sendAckTransport !== 'function') return Promise.resolve();
|
|
281
|
+
this.ackSeq += 1;
|
|
282
|
+
const envelope = seal(
|
|
283
|
+
this.keyPcToPhone,
|
|
284
|
+
buildAad(this.room, DIR_C2P, this.ackSeq),
|
|
285
|
+
{ event, ts: Date.now() },
|
|
286
|
+
{ sid: this.sid, seq: this.ackSeq },
|
|
287
|
+
);
|
|
288
|
+
return new Promise((resolve) => {
|
|
289
|
+
try {
|
|
290
|
+
this._sendAckTransport(envelope);
|
|
291
|
+
this.logger?.info('ctrl sent', { event, seq: this.ackSeq });
|
|
292
|
+
} catch (e) {
|
|
293
|
+
this.logger?.warn('ctrl send failed', { event, error: e.message });
|
|
294
|
+
}
|
|
295
|
+
resolve();
|
|
296
|
+
});
|
|
297
|
+
}
|
|
251
298
|
}
|
|
252
299
|
|
|
253
300
|
module.exports = { RtAlignSession, looksLikeEnvelope };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ondevplann/remotetype",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "RemoteType PC 客户端:npx 一键启动,接收手机端语音识别文本并注入到当前焦点输入框",
|
|
5
5
|
"bin": {
|
|
6
6
|
"remotetype": "./bin/index.js"
|
|
@@ -22,8 +22,10 @@
|
|
|
22
22
|
"ws": "^8.18.0"
|
|
23
23
|
},
|
|
24
24
|
"optionalDependencies": {
|
|
25
|
-
"@ondevplann/input-agent-win32-x64": "0.
|
|
26
|
-
"@ondevplann/input-agent-
|
|
25
|
+
"@ondevplann/input-agent-win32-x64": "0.2.0",
|
|
26
|
+
"@ondevplann/input-agent-darwin-x64": "0.2.0",
|
|
27
|
+
"@ondevplann/input-agent-darwin-arm64": "0.2.0",
|
|
28
|
+
"@ondevplann/input-agent-linux-x64": "0.2.0"
|
|
27
29
|
},
|
|
28
30
|
"engines": {
|
|
29
31
|
"node": ">=18.0.0"
|