diff --git a/packages/computer-helper/Sources/ComputerHelper/Events.swift b/packages/computer-helper/Sources/ComputerHelper/Events.swift index 872f7e35..24c2168e 100644 --- a/packages/computer-helper/Sources/ComputerHelper/Events.swift +++ b/packages/computer-helper/Sources/ComputerHelper/Events.swift @@ -48,6 +48,12 @@ enum Events { try ensurePidAllowed(pid) let front = try checkFrontmost(pid: pid, params: params) + // Inter-character delay. Default 4ms (backward compatible); lossy + // keyboard relays (Parallels/VM guests) can drop chars mid-stream at + // that rate, so callers may raise it. Clamp to [1, 250]ms. + let charDelayMs = min(250, max(1, Params.intOpt(params, "char_delay_ms") ?? 4)) + let charDelay = Double(charDelayMs) / 1000.0 + for scalar in text.unicodeScalars { var utf16 = Array(String(scalar).utf16) guard let down = CGEvent(keyboardEventSource: EventSynth.source, virtualKey: 0, keyDown: true), @@ -58,7 +64,7 @@ enum Events { up.keyboardSetUnicodeString(stringLength: utf16.count, unicodeString: &utf16) down.postToPid(pid_t(pid)) up.postToPid(pid_t(pid)) - Thread.sleep(forTimeInterval: 0.004) + Thread.sleep(forTimeInterval: charDelay) } if Params.bool(params, "commit") { diff --git a/src/commands/computer-actions.test.ts b/src/commands/computer-actions.test.ts index 0508d755..f8728ae1 100644 --- a/src/commands/computer-actions.test.ts +++ b/src/commands/computer-actions.test.ts @@ -5,6 +5,9 @@ import { buildElementOrCoords, buildRaiseParams, buildWaitParams, + clampCharDelay, + CHAR_DELAY_MIN_MS, + CHAR_DELAY_MAX_MS, type AppInfo, } from './computer-actions.js'; import { resolveRpcTimeoutMs, RPC_TIMEOUT_MS } from '../lib/computer-rpc.js'; @@ -162,6 +165,35 @@ describe('buildWaitParams', () => { }); }); +describe('clampCharDelay', () => { + it('returns undefined when unset so the daemon applies its 4ms default', () => { + expect(clampCharDelay(undefined)).toBeUndefined(); + }); + + it('passes a typical VM-guest value through unchanged', () => { + expect(clampCharDelay(25)).toBe(25); + }); + + it('clamps below the floor up to the minimum', () => { + expect(clampCharDelay(0)).toBe(CHAR_DELAY_MIN_MS); + expect(clampCharDelay(-100)).toBe(CHAR_DELAY_MIN_MS); + }); + + it('clamps above the ceiling down to the maximum', () => { + expect(clampCharDelay(1000)).toBe(CHAR_DELAY_MAX_MS); + }); + + it('keeps the boundary values', () => { + expect(clampCharDelay(CHAR_DELAY_MIN_MS)).toBe(1); + expect(clampCharDelay(CHAR_DELAY_MAX_MS)).toBe(250); + }); + + it('truncates fractional input and rejects NaN', () => { + expect(clampCharDelay(25.9)).toBe(25); + expect(clampCharDelay(NaN)).toBeUndefined(); + }); +}); + describe('resolveRpcTimeoutMs', () => { it('defaults to RPC_TIMEOUT_MS when the env var is unset', () => { expect(resolveRpcTimeoutMs(undefined)).toBe(RPC_TIMEOUT_MS); diff --git a/src/commands/computer-actions.ts b/src/commands/computer-actions.ts index 3da9d83c..a8f60390 100644 --- a/src/commands/computer-actions.ts +++ b/src/commands/computer-actions.ts @@ -95,6 +95,18 @@ export function buildRaiseParams(opts: { return params; } +// Inter-character typing delay for type-text. Default 4ms matches the daemon's +// historical fixed rate; lossy keyboard relays (Parallels/VM guests) drop chars +// at that rate, so callers can raise it. Clamp to [1, 250]ms CLI-side (the +// daemon clamps too — defense in depth). Returns undefined when unset so the +// daemon applies its own default. Pure + tested. +export const CHAR_DELAY_MIN_MS = 1; +export const CHAR_DELAY_MAX_MS = 250; +export function clampCharDelay(ms: number | undefined): number | undefined { + if (ms === undefined || !Number.isFinite(ms)) return undefined; + return Math.min(CHAR_DELAY_MAX_MS, Math.max(CHAR_DELAY_MIN_MS, Math.trunc(ms))); +} + // Build the wait RPC params. Pure + tested. Three modes, mirroring the // daemon's Wait.run: --duration (unconditional sleep), --id + --until // (cached-element poll), or --role/--label/--identifier (live locator poll). @@ -331,14 +343,17 @@ export function registerActionCommands(program: Command): void { .option('--commit', 'Press Return after typing') .option('--raise', 'Bring the target app to the front first') .option('--require-frontmost', 'Fail (not warn) if the target is not the frontmost app') + .option('--char-delay ', 'Inter-character delay in ms (default 4; raise for lossy keyboard relays like VM guests, e.g. 25). Clamped to [1, 250].', (v) => parseInt(v, 10)) .option('--json', 'Emit JSON'), - ).action(async (opts: TargetOpts & { text: string; commit?: boolean; raise?: boolean; requireFrontmost?: boolean }) => { + ).action(async (opts: TargetOpts & { text: string; commit?: boolean; raise?: boolean; requireFrontmost?: boolean; charDelay?: number }) => { await withClient(async (client) => { const pid = await resolveTargetPid(client, opts); await raiseIfRequested(client, pid, opts.raise); const params: Record = { pid, text: opts.text }; if (opts.commit) params.commit = true; if (opts.requireFrontmost) params.require_frontmost = true; + const charDelay = clampCharDelay(opts.charDelay); + if (charDelay !== undefined) params.char_delay_ms = charDelay; const res = unwrap(await client.call('type_text', params)); warnIfNotFrontmost(res); emit(res, Boolean(opts.json), () => `typed ${res.chars ?? opts.text.length} char(s)`);