@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 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 res = await agent.typeText(cleaned.text);
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 res = await this.send({ action: 'type_text', text }, timeoutMs);
332
- return res;
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 次退格(relay 对齐模式的删除原语)。 */
336
- async backspace(count, timeoutMs) {
337
- const res = await this.send({ action: 'backspace', count }, timeoutMs);
338
- return res;
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.2.1",
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.1.0",
26
- "@ondevplann/input-agent-linux-x64": "0.1.0"
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"