@ondevplann/remotetype 0.2.0 → 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);
@@ -274,8 +332,14 @@ async function cmdServe(cfg) {
274
332
  }
275
333
 
276
334
  // type:常规文本注入(serve 与 relay 共用,relay 的 text 是 diff 出的增量)
277
- // 二次预处理:即便上游已过滤,这里仍是最后一道防线
278
- const cleaned = sanitize(item.text, { maxLength: cfg.maxTextLength });
335
+ // 二次预处理:即便上游已过滤,这里仍是最后一道防线。
336
+ // 对齐模式保留纯空白:换行/空格是手机输入框的真实内容(IME 自动插入的
337
+ // 换行不该被判失败挂起整批计划),见 sanitize 的 keepWhitespace。
338
+ const isAlignSource = meta.source === 'align';
339
+ const cleaned = sanitize(item.text, {
340
+ maxLength: cfg.maxTextLength,
341
+ keepWhitespace: isAlignSource,
342
+ });
279
343
  if (!cleaned.ok) {
280
344
  logger.warn('文本预处理后丢弃', { reason: cleaned.reason, clientId: item.clientId });
281
345
  if (meta.source === 'align' && item.seq !== undefined) failedAlignSeqs.add(item.seq);
@@ -289,6 +353,11 @@ async function cmdServe(cfg) {
289
353
  });
290
354
  return;
291
355
  }
356
+ if (isAlignSource && cleaned.text.length === 0) {
357
+ // 整条只含不可见字符:无可注入内容,也不算失败——commit 仍会推进 mirror
358
+ logger.debug('对齐空操作(仅不可见字符),跳过注入', { seq: item.seq });
359
+ return;
360
+ }
292
361
  if (cleaned.truncated) {
293
362
  logger.warn('文本超长已截断', {
294
363
  originalLength: cleaned.originalLength,
@@ -305,10 +374,12 @@ async function cmdServe(cfg) {
305
374
  });
306
375
 
307
376
  try {
308
- const res = await agent.typeText(cleaned.text);
377
+ const expectFocus = isAlignSource ? focusTarget : null;
378
+ const res = await agent.typeText(cleaned.text, cfg.requestTimeoutMs, expectFocus);
309
379
  const durationMs = Date.now() - startedAt;
310
380
  if (res?.ok) {
311
381
  logger.info('注入完成', { durationMs, textLen: cleaned.text.length });
382
+ if (isAlignSource) bindFocus(res.focus);
312
383
  store.append({
313
384
  text: cleaned.text,
314
385
  status: 'injected',
@@ -318,6 +389,17 @@ async function cmdServe(cfg) {
318
389
  durationMs,
319
390
  truncated: Boolean(cleaned.truncated),
320
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
+ });
321
403
  } else {
322
404
  logger.error('注入失败', { msg: res?.msg, durationMs });
323
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/lib/sanitize.js CHANGED
@@ -32,10 +32,14 @@ function isInvisible(c) {
32
32
  * @param {string} raw
33
33
  * @param {object} [opts]
34
34
  * @param {number} [opts.maxLength]
35
+ * @param {boolean} [opts.keepWhitespace] 保留纯空白文本(对齐模式用:
36
+ * 换行/空格是手机输入框的真实内容,整条空白不算「空文本」;
37
+ * 返回 ok:true 且 text 可能为空串,由调用方决定是否为无操作)
35
38
  * @returns {{ok:boolean, text:string, reason?:string, truncated?:boolean, originalLength?:number}}
36
39
  */
37
40
  function sanitize(raw, opts = {}) {
38
41
  const maxLength = opts.maxLength ?? DEFAULT_MAX_LENGTH;
42
+ const keepWhitespace = opts.keepWhitespace === true;
39
43
 
40
44
  if (typeof raw !== 'string') {
41
45
  return { ok: false, text: '', reason: 'not_a_string' };
@@ -52,8 +56,8 @@ function sanitize(raw, opts = {}) {
52
56
  // 2) 统一换行,避免 \r\n 造成目标程序出现多余字符
53
57
  text = text.replace(/\r\n?/g, '\n');
54
58
 
55
- // 3) 空文本丢弃
56
- if (text.trim().length === 0) {
59
+ // 3) 空文本丢弃(keepWhitespace 时跳过:空白是对齐语义下的有效内容)
60
+ if (!keepWhitespace && text.trim().length === 0) {
57
61
  return { ok: false, text: '', reason: 'empty_after_clean', originalLength };
58
62
  }
59
63
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ondevplann/remotetype",
3
- "version": "0.2.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.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"