Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion packages/computer-helper/Sources/ComputerHelper/Events.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand All @@ -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") {
Expand Down
32 changes: 32 additions & 0 deletions src/commands/computer-actions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
Expand Down
17 changes: 16 additions & 1 deletion src/commands/computer-actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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 <ms>', '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<string, unknown> = { 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)`);
Expand Down