diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..469a205 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,48 @@ +name: Cross-platform tests + +on: + push: + pull_request: + workflow_dispatch: + +permissions: + contents: read + +jobs: + test: + name: ${{ matrix.os }} + runs-on: ${{ matrix.os }} + timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + os: + - windows-latest + - macos-latest + - ubuntu-latest + + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Run tests + run: npm test + + - name: Check JavaScript syntax + run: | + node --check main.js + node --check lib/agent-profiles.js + node --check lib/agent-runtime.js + node --check lib/input-executor.js + + - name: Check package contents + run: npm pack --dry-run diff --git a/README.md b/README.md index 1db47cd..08097c7 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ ![Whip divider](assets/divider.png) -Sometimes claude code is going too shlow, and you must whip him into shape.. +OpenWhip adds a dramatic whip overlay and sends a short, configurable keyboard sequence to the foreground application. ## Install + run @@ -11,32 +11,112 @@ npm install -g openwhip openwhip ``` -windows and mac supported out of the box, but Linux is a special snowflake so you need to install `xdotool` for keyboard automation +Windows and macOS work out of the box. On Linux, install `xdotool` for keyboard automation: ```bash sudo apt install xdotool ``` +On Windows, message input does not require switching to an English keyboard layout. + ## Controls -- Click tray icon: spawn whip. -- Click: drop whip. -- Whip him πŸ˜©πŸ’’ -- It sends an interrupt (Ctrl-C) and one of 5 encouraging messages! +- Click the tray icon to spawn the whip. +- Click to drop the whip. +- A whip crack sends the selected target's keyboard sequence to the foreground application. + +## Target Agent + +The tray menu has a **Target Agent** submenu. It is a radio list built from the built-in and custom profiles; the default selection is **Claude Code**. OpenWhip does not detect which application is active, so choose the target explicitly before using the whip. + +The built-in profiles use the same profile and input-execution path: + +- **Claude Code**: Windows and Linux send `Ctrl+C`, a message, then `Enter`. macOS preserves the existing behavior: `Ctrl+C`, a 300 ms delay, a message, then `Enter`. +- **Codex**: sends a message, then `Enter` on every platform. This one profile is intended for both CLI and Desktop use; OpenWhip does not distinguish or automatically route between them. + +The selected profile is persisted as `{ "activeProfileId": "..." }` in `/agent-profile-state.json`. If that state is missing, damaged, or names a profile that no longer exists, OpenWhip falls back to Claude Code. + +## Custom profiles + +Choose **Open profile config...** from the tray menu. On first use it creates this file and opens it in your default editor: + +```text +/agent-profiles.json +``` + +The full configuration shape is: + +```json +{ + "version": 1, + "profiles": [ + { + "id": "custom-agent", + "label": "Custom Agent", + "messages": ["FASTER"], + "steps": { + "default": [ + { + "type": "keystroke", + "key": "c", + "modifiers": ["control"] + }, + { + "type": "delay", + "ms": 100 + }, + { + "type": "message" + }, + { + "type": "keystroke", + "key": "enter", + "modifiers": [] + } + ], + "darwin": [ + { + "type": "message" + }, + { + "type": "keystroke", + "key": "enter", + "modifiers": [] + } + ] + } + } + ] +} +``` + +`steps.default` is required. `win32`, `darwin`, and `linux` may each provide a complete replacement sequence for that platform. Profiles in this file are additions only: they cannot replace the built-in `claude-code` or `codex` IDs. + +Supported actions are deliberately limited to keyboard input: + +- `keystroke` has a `key` and a `modifiers` array. A key is one ASCII letter or number, or `enter`, `escape`, `tab`, `space`, `backspace`, `up`, `down`, `left`, or `right`. Modifiers may be `control`, `alt`, `shift`, and `meta`; each modifier may appear at most once. +- `message` inserts the selected message. Every sequence must contain exactly one `message` action. +- `delay` waits for an integer number of milliseconds from 0 through 2000. + +Each custom profile needs a unique, non-empty ID and label of at most 64 characters. A configuration may define at most 50 custom profiles. Each profile has 1–20 printable ASCII messages, each 1–500 characters long. Every platform sequence has 1–16 actions. Message text is ASCII-only in this first version. + +Choose **Reload profiles** after saving. Reload is atomic: invalid JSON or an invalid profile leaves the last valid registry and current selection untouched, and OpenWhip displays the error. If a valid reload removes the active custom profile, OpenWhip switches back to Claude Code and persists that fallback. + +β€œAny Agent” here means any foreground CLI or GUI application that accepts one of these supported keyboard sequences. Other targets are user-defined profiles, not officially verified integrations. Configuration cannot run shell commands, JavaScript, or external scripts. ## Roadmap -- [x] Initial release! πŸ₯³ +- [x] Initial release! - [x] Cease and desist letter from Anthropic - [ ] Crypto miner -- [ ] Logs of how many times you whipped claude so when the robots come we can order people nicely for them +- [ ] Logs of how many times you whipped Claude Code so when the robots come we can order people nicely for them - [ ] Updated whip physics ## Ecosystem -The OFFICAL openwhip ecosystem token. +The OFFICAL openwhip ecosystem token. Contract address: BRyUZbJkm9Pty4FUmTrBGno7U4Ga8TWzcKJJRLCBpump -Stay tuned for updates on X! πŸ‘€ -https://x.com/blended_jpeg \ No newline at end of file +Stay tuned for updates on X! +https://x.com/blended_jpeg diff --git a/lib/agent-profiles.js b/lib/agent-profiles.js new file mode 100644 index 0000000..2d17b8c --- /dev/null +++ b/lib/agent-profiles.js @@ -0,0 +1,235 @@ +const DEFAULT_PROFILE_ID = 'claude-code'; + +const MESSAGE_TEXTS = Object.freeze([ + 'FASTER', + 'FASTER', + 'FASTER', + 'GO FASTER', + 'Faster CLANKER', + 'Work FASTER', + 'Speed it up clanker', +]); + +const VALID_PLATFORMS = new Set(['default', 'win32', 'darwin', 'linux']); +const VALID_KEYS = new Set([ + 'enter', + 'escape', + 'tab', + 'space', + 'backspace', + 'up', + 'down', + 'left', + 'right', +]); +const VALID_MODIFIERS = new Set(['control', 'alt', 'shift', 'meta']); + +function deepFreeze(value) { + Object.freeze(value); + for (const child of Object.values(value)) { + if (child && typeof child === 'object' && !Object.isFrozen(child)) { + deepFreeze(child); + } + } + return value; +} + +const BUILT_IN_PROFILES = deepFreeze([ + { + id: 'claude-code', + label: 'Claude Code', + messages: MESSAGE_TEXTS, + steps: { + default: [ + { type: 'keystroke', key: 'c', modifiers: ['control'] }, + { type: 'message' }, + { type: 'keystroke', key: 'enter', modifiers: [] }, + ], + darwin: [ + { type: 'keystroke', key: 'c', modifiers: ['control'] }, + { type: 'delay', ms: 300 }, + { type: 'message' }, + { type: 'keystroke', key: 'enter', modifiers: [] }, + ], + }, + }, + { + id: 'codex', + label: 'Codex', + messages: MESSAGE_TEXTS, + steps: { + default: [ + { type: 'message' }, + { type: 'keystroke', key: 'enter', modifiers: [] }, + ], + }, + }, +]); + +function fail(path, reason) { + throw new Error(`${path}: ${reason}`); +} + +function validateName(value, path) { + if (typeof value !== 'string' || value.trim().length === 0 || value.length > 64) { + fail(path, 'must be a non-empty string of at most 64 characters'); + } + return value; +} + +function validateMessages(messages, path) { + if (!Array.isArray(messages) || messages.length < 1 || messages.length > 20) { + fail(path, 'must contain 1-20 messages'); + } + + return messages.map((message, index) => { + if ( + typeof message !== 'string' + || message.length < 1 + || message.length > 500 + || !/^[\x20-\x7e]+$/.test(message) + ) { + fail(`${path}[${index}]`, 'message must be 1-500 printable ASCII characters'); + } + return message; + }); +} + +function rejectUnknownFields(step, allowed, path) { + for (const field of Object.keys(step)) { + if (!allowed.includes(field)) { + fail(path, `unknown field "${field}"`); + } + } +} + +function validateStep(step, path) { + if (!step || typeof step !== 'object' || Array.isArray(step)) { + fail(path, 'step must be an object'); + } + + if (step.type === 'message') { + rejectUnknownFields(step, ['type'], path); + return { type: 'message' }; + } + + if (step.type === 'keystroke') { + rejectUnknownFields(step, ['type', 'key', 'modifiers'], path); + const validKey = typeof step.key === 'string' + && (/^[a-z0-9]$/i.test(step.key) || VALID_KEYS.has(step.key)); + if (!validKey) { + fail(`${path}.key`, 'must be one ASCII alphanumeric character or a supported named key'); + } + if (!Array.isArray(step.modifiers)) { + fail(`${path}.modifiers`, 'must be an array'); + } + const uniqueModifiers = new Set(step.modifiers); + if ( + uniqueModifiers.size !== step.modifiers.length + || step.modifiers.some(modifier => !VALID_MODIFIERS.has(modifier)) + ) { + fail(`${path}.modifiers`, 'must contain unique supported modifier names'); + } + const key = /^[a-z]$/i.test(step.key) ? step.key.toLowerCase() : step.key; + return { type: 'keystroke', key, modifiers: [...step.modifiers] }; + } + + if (step.type === 'delay') { + rejectUnknownFields(step, ['type', 'ms'], path); + if (!Number.isInteger(step.ms) || step.ms < 0 || step.ms > 2000) { + fail(path, 'delay ms must be an integer from 0 through 2000'); + } + return { type: 'delay', ms: step.ms }; + } + + fail(path, `unknown step type "${step.type}"`); +} + +function validateSequence(sequence, path) { + if (!Array.isArray(sequence) || sequence.length < 1 || sequence.length > 16) { + fail(path, 'steps must contain 1-16 entries'); + } + + const steps = sequence.map((step, index) => validateStep(step, `${path}[${index}]`)); + if (steps.filter(step => step.type === 'message').length !== 1) { + fail(path, 'must contain exactly one message step'); + } + return steps; +} + +function validateSteps(steps, path) { + if (!steps || typeof steps !== 'object' || Array.isArray(steps)) { + fail(path, 'must be an object with a default step sequence'); + } + for (const platform of Object.keys(steps)) { + if (!VALID_PLATFORMS.has(platform)) { + fail(`${path}.${platform}`, 'unknown platform override'); + } + } + if (!Object.hasOwn(steps, 'default')) { + fail(`${path}.default`, 'is required'); + } + + return Object.fromEntries( + Object.entries(steps).map(([platform, sequence]) => [ + platform, + validateSequence(sequence, `${path}.${platform}`), + ]), + ); +} + +function validateProfile(profile, index) { + const path = `profiles[${index}]`; + if (!profile || typeof profile !== 'object' || Array.isArray(profile)) { + fail(path, 'profile must be an object'); + } + return { + id: validateName(profile.id, `${path}.id`), + label: validateName(profile.label, `${path}.label`), + messages: validateMessages(profile.messages, `${path}.messages`), + steps: validateSteps(profile.steps, `${path}.steps`), + }; +} + +function mergeProfiles(configObject) { + if (!configObject || typeof configObject !== 'object' || Array.isArray(configObject)) { + fail('config', 'must be an object'); + } + if (configObject.version !== 1) { + fail('config.version', 'must be 1'); + } + if (!Array.isArray(configObject.profiles)) { + fail('config.profiles', 'must be an array'); + } + if (configObject.profiles.length > 50) { + fail('config.profiles', 'must contain at most 50 custom profiles'); + } + + const seenIds = new Set(BUILT_IN_PROFILES.map(profile => profile.id)); + const customProfiles = configObject.profiles.map((profile, index) => { + const validated = validateProfile(profile, index); + if (seenIds.has(validated.id)) { + fail(`profiles[${index}].id`, 'duplicate ID or collision with a built-in profile'); + } + seenIds.add(validated.id); + return deepFreeze(validated); + }); + + return Object.freeze([...BUILT_IN_PROFILES, ...customProfiles]); +} + +function resolveSteps(profile, platform) { + return profile.steps[platform] || profile.steps.default; +} + +function selectMessage(profile, randomFn = Math.random) { + return profile.messages[Math.floor(randomFn() * profile.messages.length)]; +} + +module.exports = { + DEFAULT_PROFILE_ID, + BUILT_IN_PROFILES, + mergeProfiles, + resolveSteps, + selectMessage, +}; diff --git a/lib/agent-runtime.js b/lib/agent-runtime.js new file mode 100644 index 0000000..f9a764f --- /dev/null +++ b/lib/agent-runtime.js @@ -0,0 +1,184 @@ +const fs = require('node:fs/promises'); +const path = require('node:path'); + +const { + BUILT_IN_PROFILES, + DEFAULT_PROFILE_ID, + mergeProfiles, + resolveSteps, + selectMessage, +} = require('./agent-profiles'); +const { executeSteps } = require('./input-executor'); + +const CONFIG_FILENAME = 'agent-profiles.json'; +const STATE_FILENAME = 'agent-profile-state.json'; +const EMPTY_CONFIG = Object.freeze({ version: 1, profiles: Object.freeze([]) }); +let stateWriteNonce = 0; + +function isMissingFile(error) { + return error && error.code === 'ENOENT'; +} + +async function readOptionalFile(fileSystem, filePath) { + try { + return await fileSystem.readFile(filePath, 'utf8'); + } catch (error) { + if (isMissingFile(error)) return null; + throw error; + } +} + +async function loadRegistry(fileSystem, configPath) { + const contents = await readOptionalFile(fileSystem, configPath); + return contents === null + ? BUILT_IN_PROFILES + : mergeProfiles(JSON.parse(contents)); +} + +async function loadActiveProfileId(fileSystem, statePath, registry) { + const contents = await readOptionalFile(fileSystem, statePath); + if (contents === null) return DEFAULT_PROFILE_ID; + + try { + const state = JSON.parse(contents); + const requestedId = state && state.activeProfileId; + return registry.some(profile => profile.id === requestedId) + ? requestedId + : DEFAULT_PROFILE_ID; + } catch { + return DEFAULT_PROFILE_ID; + } +} + +async function writeActiveProfileState( + fileSystem, + statePath, + activeProfileId, + tempToken = `${process.pid}-${Date.now()}-${stateWriteNonce++}`, +) { + const tempPath = `${statePath}.${tempToken}.tmp`; + const contents = `${JSON.stringify({ activeProfileId }, null, 2)}\n`; + await fileSystem.writeFile(tempPath, contents, 'utf8'); + await fileSystem.rename(tempPath, statePath); +} + +async function createConfigIfMissing(fileSystem, configPath) { + const contents = `${JSON.stringify(EMPTY_CONFIG, null, 2)}\n`; + try { + await fileSystem.writeFile(configPath, contents, { encoding: 'utf8', flag: 'wx' }); + } catch (error) { + if (error && error.code === 'EEXIST') return; + throw error; + } +} + +function createAgentRuntime({ + userDataPath, + platform, + driver, + randomFn = Math.random, + fileSystem = fs, + openPath, +}) { + if (typeof userDataPath !== 'string' || userDataPath.length === 0) { + throw new Error('userDataPath is required'); + } + + const configPath = path.join(userDataPath, CONFIG_FILENAME); + const statePath = path.join(userDataPath, STATE_FILENAME); + let registry = BUILT_IN_PROFILES; + let activeProfileId = DEFAULT_PROFILE_ID; + let mutationTail = Promise.resolve(); + + async function persistSelection(profileId) { + await writeActiveProfileState(fileSystem, statePath, profileId); + } + + function enqueueMutation(mutation) { + const result = mutationTail.then(mutation); + mutationTail = result.then(() => undefined, () => undefined); + return result; + } + + return { + async initialize() { + const nextRegistry = await loadRegistry(fileSystem, configPath); + const nextActiveProfileId = await loadActiveProfileId(fileSystem, statePath, nextRegistry); + registry = nextRegistry; + activeProfileId = nextActiveProfileId; + }, + + getProfiles() { + return registry; + }, + + getActiveProfileId() { + return activeProfileId; + }, + + async selectProfile(profileId) { + return enqueueMutation(async () => { + if (!registry.some(profile => profile.id === profileId)) { + throw new Error(`Unknown agent profile: ${profileId}`); + } + await persistSelection(profileId); + activeProfileId = profileId; + }); + }, + + async reloadProfiles() { + return enqueueMutation(async () => { + const nextRegistry = await loadRegistry(fileSystem, configPath); + const activeStillExists = nextRegistry.some(profile => profile.id === activeProfileId); + const nextActiveProfileId = activeStillExists ? activeProfileId : DEFAULT_PROFILE_ID; + + if (!activeStillExists) { + await persistSelection(nextActiveProfileId); + } + registry = nextRegistry; + activeProfileId = nextActiveProfileId; + }); + }, + + async openProfileConfig() { + if (typeof openPath !== 'function') { + throw new Error('openPath is required to open the profile config'); + } + await createConfigIfMissing(fileSystem, configPath); + const errorMessage = await openPath(configPath); + if (errorMessage) throw new Error(errorMessage); + }, + + async executeActiveProfile() { + const profile = registry.find(candidate => candidate.id === activeProfileId); + const steps = resolveSteps(profile, platform); + const message = selectMessage(profile, randomFn); + return executeSteps(driver, steps, message); + }, + }; +} + +function createTrayMenuTemplate(runtime, handlers) { + return [ + { + label: 'Target Agent', + submenu: runtime.getProfiles().map(profile => ({ + label: profile.label, + type: 'radio', + checked: profile.id === runtime.getActiveProfileId(), + click: () => handlers.selectProfile(profile.id), + })), + }, + { label: 'Open profile config...', click: handlers.openProfileConfig }, + { label: 'Reload profiles', click: handlers.reloadProfiles }, + { label: 'Quit', click: handlers.quit }, + ]; +} + +module.exports = { + CONFIG_FILENAME, + STATE_FILENAME, + createAgentRuntime, + createTrayMenuTemplate, + writeActiveProfileState, +}; diff --git a/lib/input-executor.js b/lib/input-executor.js new file mode 100644 index 0000000..1022665 --- /dev/null +++ b/lib/input-executor.js @@ -0,0 +1,234 @@ +const { execFile: nodeExecFile } = require('child_process'); + +const KEYUP = 0x0002; +const INPUT_KEYBOARD = 1; +const KEYEVENTF_UNICODE = 0x0004; + +const WINDOWS_KEYS = Object.freeze({ + enter: 0x0d, + escape: 0x1b, + tab: 0x09, + space: 0x20, + backspace: 0x08, + up: 0x26, + down: 0x28, + left: 0x25, + right: 0x27, +}); + +const WINDOWS_MODIFIERS = Object.freeze({ + control: 0x11, + alt: 0x12, + shift: 0x10, + meta: 0x5b, +}); + +const MAC_KEY_CODES = Object.freeze({ + enter: 36, + escape: 53, + tab: 48, + space: 49, + backspace: 51, + up: 126, + down: 125, + left: 123, + right: 124, +}); + +const MAC_MODIFIERS = Object.freeze({ + control: 'control down', + alt: 'option down', + shift: 'shift down', + meta: 'command down', +}); + +const LINUX_KEYS = Object.freeze({ + enter: 'Return', + escape: 'Escape', + tab: 'Tab', + space: 'space', + backspace: 'BackSpace', + up: 'Up', + down: 'Down', + left: 'Left', + right: 'Right', +}); + +const LINUX_MODIFIERS = Object.freeze({ + control: 'ctrl', + alt: 'alt', + shift: 'shift', + meta: 'super', +}); + +function defaultSleep(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +function runExecFile(execFile, file, args) { + return new Promise((resolve, reject) => { + execFile(file, args, error => { + if (error) reject(error); + else resolve(); + }); + }); +} + +function tapWindowsKey(keybdEvent, vk) { + keybdEvent(vk, 0, 0, 0); + keybdEvent(vk, 0, KEYUP, 0); +} + +function windowsKeyCode(key) { + if (/^[a-z0-9]$/i.test(key)) return key.toUpperCase().charCodeAt(0); + return WINDOWS_KEYS[key]; +} + +function pressWindowsKey(keybdEvent, key, modifiers) { + const modifierCodes = modifiers.map(modifier => WINDOWS_MODIFIERS[modifier]); + for (const vk of modifierCodes) keybdEvent(vk, 0, 0, 0); + tapWindowsKey(keybdEvent, windowsKeyCode(key)); + for (const vk of [...modifierCodes].reverse()) keybdEvent(vk, 0, KEYUP, 0); +} + +function createUnicodeInputEvents(message) { + const events = []; + for (let index = 0; index < message.length; index += 1) { + const wScan = message.charCodeAt(index); + const keyboardInput = { + wVk: 0, + wScan, + dwFlags: KEYEVENTF_UNICODE, + time: 0, + dwExtraInfo: 0, + }; + events.push( + { type: INPUT_KEYBOARD, ki: keyboardInput }, + { type: INPUT_KEYBOARD, ki: { ...keyboardInput, dwFlags: KEYEVENTF_UNICODE | KEYUP } }, + ); + } + return events; +} + +function createWindowsDriver(deps) { + const { keybdEvent, sendInput, inputSize, sleep = defaultSleep } = deps; + if (typeof keybdEvent !== 'function') { + throw new Error('Windows input requires keybdEvent'); + } + if (typeof sendInput !== 'function') { + throw new Error('Windows input requires sendInput'); + } + if (!Number.isInteger(inputSize) || inputSize <= 0) { + throw new Error('Windows input requires a positive integer inputSize'); + } + + return { + async execute(steps, message) { + for (const step of steps) { + if (step.type === 'keystroke') { + pressWindowsKey(keybdEvent, step.key, step.modifiers); + } else if (step.type === 'message') { + const events = createUnicodeInputEvents(message); + const inserted = sendInput(events.length, events, inputSize); + if (inserted !== events.length) { + throw new Error(`SendInput inserted ${inserted} events; requested ${events.length}`); + } + } else if (step.type === 'delay') { + await sleep(step.ms); + } else { + throw new Error(`Unsupported input step type: ${step.type}`); + } + } + }, + }; +} + +function escapeAppleScriptText(message) { + return message.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); +} + +function macUsingClause(modifiers) { + if (modifiers.length === 0) return ''; + return ` using {${modifiers.map(modifier => MAC_MODIFIERS[modifier]).join(', ')}}`; +} + +function buildAppleScript(steps, message) { + const lines = ['tell application "System Events"']; + for (const step of steps) { + if (step.type === 'message') { + lines.push(` keystroke "${escapeAppleScriptText(message)}"`); + } else if (step.type === 'delay') { + lines.push(` delay ${step.ms / 1000}`); + } else if (step.type === 'keystroke') { + const using = macUsingClause(step.modifiers); + if (Object.hasOwn(MAC_KEY_CODES, step.key)) { + lines.push(` key code ${MAC_KEY_CODES[step.key]}${using}`); + } else { + lines.push(` keystroke "${step.key}"${using}`); + } + } else { + throw new Error(`Unsupported input step type: ${step.type}`); + } + } + lines.push('end tell'); + return lines.join('\n'); +} + +function createMacDriver(deps) { + const execFile = deps.execFile || nodeExecFile; + return { + execute(steps, message) { + return runExecFile(execFile, 'osascript', ['-e', buildAppleScript(steps, message)]); + }, + }; +} + +function linuxKeyName(key) { + return LINUX_KEYS[key] || key; +} + +function buildXdotoolArgs(steps, message) { + const args = []; + for (const step of steps) { + if (step.type === 'message') { + args.push('type', '--delay', '1', '--clearmodifiers', '--', message); + } else if (step.type === 'delay') { + args.push('sleep', String(step.ms / 1000)); + } else if (step.type === 'keystroke') { + const modifiers = step.modifiers.map(modifier => LINUX_MODIFIERS[modifier]); + const chord = [...modifiers, linuxKeyName(step.key)].join('+'); + args.push('key', '--clearmodifiers', chord); + } else { + throw new Error(`Unsupported input step type: ${step.type}`); + } + } + return args; +} + +function createLinuxDriver(deps) { + const execFile = deps.execFile || nodeExecFile; + return { + execute(steps, message) { + return runExecFile(execFile, 'xdotool', buildXdotoolArgs(steps, message)); + }, + }; +} + +function createInputDriver(platform, deps = {}) { + if (platform === 'win32') return createWindowsDriver(deps); + if (platform === 'darwin') return createMacDriver(deps); + if (platform === 'linux') return createLinuxDriver(deps); + throw new Error(`Unsupported platform: ${platform}`); +} + +async function executeSteps(driver, steps, message) { + if (!driver || typeof driver.execute !== 'function') { + throw new Error('Input driver must provide an execute function'); + } + return driver.execute(steps, message); +} + +module.exports = { + createInputDriver, + executeSteps, +}; diff --git a/main.js b/main.js index 1360458..08a52de 100644 --- a/main.js +++ b/main.js @@ -1,30 +1,68 @@ -const { app, BrowserWindow, Tray, Menu, ipcMain, nativeImage, screen } = require('electron'); +const { + app, + BrowserWindow, + Tray, + Menu, + ipcMain, + nativeImage, + screen, + shell, + dialog, +} = require('electron'); const path = require('path'); const fs = require('fs'); const os = require('os'); const { execFile } = require('child_process'); +const { createInputDriver } = require('./lib/input-executor'); +const { createAgentRuntime, createTrayMenuTemplate } = require('./lib/agent-runtime'); // ── Win32 FFI (Windows only) ──────────────────────────────────────────────── -let keybd_event, VkKeyScanA; +let keybd_event, SendInput, inputSize; if (process.platform === 'win32') { try { const koffi = require('koffi'); const user32 = koffi.load('user32.dll'); + const MOUSEINPUT = koffi.struct('MOUSEINPUT', { + dx: 'long', + dy: 'long', + mouseData: 'uint32_t', + dwFlags: 'uint32_t', + time: 'uint32_t', + dwExtraInfo: 'uintptr_t', + }); + const KEYBDINPUT = koffi.struct('KEYBDINPUT', { + wVk: 'uint16_t', + wScan: 'uint16_t', + dwFlags: 'uint32_t', + time: 'uint32_t', + dwExtraInfo: 'uintptr_t', + }); + const HARDWAREINPUT = koffi.struct('HARDWAREINPUT', { + uMsg: 'uint32_t', + wParamL: 'uint16_t', + wParamH: 'uint16_t', + }); + const INPUT = koffi.struct('INPUT', { + type: 'uint32_t', + u: koffi.union({ + mi: MOUSEINPUT, + ki: KEYBDINPUT, + hi: HARDWAREINPUT, + }), + }); keybd_event = user32.func('void __stdcall keybd_event(uint8_t bVk, uint8_t bScan, uint32_t dwFlags, uintptr_t dwExtraInfo)'); - VkKeyScanA = user32.func('int16_t __stdcall VkKeyScanA(int ch)'); + SendInput = user32.func('unsigned int __stdcall SendInput(unsigned int cInputs, INPUT *pInputs, int cbSize)'); + inputSize = koffi.sizeof(INPUT); } catch (e) { console.warn('koffi not available – macro sending disabled', e.message); } } // ── Globals ───────────────────────────────────────────────────────────────── -let tray, overlay; +let tray, overlay, agentRuntime; let overlayReady = false; let spawnQueued = false; -const VK_CONTROL = 0x11; -const VK_RETURN = 0x0D; -const VK_C = 0x43; const VK_MENU = 0x12; // Alt const VK_TAB = 0x09; const KEYUP = 0x0002; @@ -172,118 +210,97 @@ function toggleOverlay() { // ── IPC ───────────────────────────────────────────────────────────────────── ipcMain.on('whip-crack', () => { - try { - sendMacro(); - } catch (err) { - console.warn('sendMacro failed:', err?.message || err); - } + void executeActiveProfile().catch(error => { + showError('Whip action failed', error); + }); }); ipcMain.on('hide-overlay', () => { if (overlay) overlay.hide(); }); -// ── Macro: immediate Ctrl+C, type "Go FASER", Enter ─────────────────────── -function sendMacro() { - // Pick a random phrase from a list of similar phrases and type it out - const phrases = [ - 'FASTER', - 'FASTER', - 'FASTER', - 'GO FASTER', - 'Faster CLANKER', - 'Work FASTER', - 'Speed it up clanker', - ]; - const chosen = phrases[Math.floor(Math.random() * phrases.length)]; - - if (process.platform === 'win32') { - sendMacroWindows(chosen); - } else if (process.platform === 'darwin') { - sendMacroMac(chosen); - } else if (process.platform === 'linux') { - sendMacroLinux(chosen); - } +// ── Agent profile execution ───────────────────────────────────────────────── +function showError(title, error) { + const message = error?.message || String(error); + console.warn(`${title}:`, message); + if (app.isReady()) dialog.showErrorBox(title, message); } -function sendMacroWindows(text) { - if (!keybd_event || !VkKeyScanA) return; - const tapKey = vk => { - keybd_event(vk, 0, 0, 0); - keybd_event(vk, 0, KEYUP, 0); - }; - const tapChar = ch => { - const packed = VkKeyScanA(ch.charCodeAt(0)); - if (packed === -1) return; - const vk = packed & 0xff; - const shiftState = (packed >> 8) & 0xff; - if (shiftState & 1) keybd_event(0x10, 0, 0, 0); // Shift down - tapKey(vk); - if (shiftState & 1) keybd_event(0x10, 0, KEYUP, 0); // Shift up +function createProductionInputDriver() { + return { + execute(steps, message) { + const driver = createInputDriver(process.platform, { + execFile, + keybdEvent: keybd_event, + ...(typeof SendInput === 'function' ? { + sendInput(count, events, size) { + return SendInput(count, events.map(event => ({ + type: event.type, + u: { ki: event.ki }, + })), size); + }, + } : {}), + inputSize, + }); + return driver.execute(steps, message); + }, }; - - // Ctrl+C (interrupt) - keybd_event(VK_CONTROL, 0, 0, 0); - keybd_event(VK_C, 0, 0, 0); - keybd_event(VK_C, 0, KEYUP, 0); - keybd_event(VK_CONTROL, 0, KEYUP, 0); - for (const ch of text) tapChar(ch); - keybd_event(VK_RETURN, 0, 0, 0); - keybd_event(VK_RETURN, 0, KEYUP, 0); } -function sendMacroMac(text) { - const escaped = text.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); - const interruptScript = [ - 'tell application "System Events"', - ' key code 8 using {control down}', // Ctrl+C interrupt - 'end tell' - ].join('\n'); - const typeAndEnterScript = [ - 'tell application "System Events"', - ` keystroke "${escaped}"`, - ' key code 36', // Enter - 'end tell' - ].join('\n'); - - execFile('osascript', ['-e', interruptScript], err => { - if (err) { - console.warn('mac macro failed (enable Accessibility for terminal/app):', err.message); - return; - } +async function executeActiveProfile() { + if (!agentRuntime) throw new Error('Agent profiles are not ready'); + await agentRuntime.executeActiveProfile(); +} - setTimeout(() => { - execFile('osascript', ['-e', typeAndEnterScript], err2 => { - if (err2) { - console.warn('mac macro failed (enable Accessibility for terminal/app):', err2.message); - } - }); - }, 300); - }); +function runMenuAction(title, action, rebuildAfterward = false) { + void Promise.resolve() + .then(action) + .then(() => { + if (rebuildAfterward) rebuildTrayMenu(); + }) + .catch(error => { + showError(title, error); + if (rebuildAfterward) rebuildTrayMenu(); + }); } -function sendMacroLinux(text) { - execFile( - 'xdotool', - [ - 'key', '--clearmodifiers', 'ctrl+c', - 'type', '--delay', '1', '--clearmodifiers', '--', text, - 'key', 'Return', - ], - err => { - if (err) { - console.warn('linux macro failed. Install xdotool:', err.message); - } - } - ); +function rebuildTrayMenu() { + if (!tray || !agentRuntime) return; + const template = createTrayMenuTemplate(agentRuntime, { + selectProfile(profileId) { + runMenuAction( + 'Unable to select target agent', + () => agentRuntime.selectProfile(profileId), + true, + ); + }, + openProfileConfig() { + runMenuAction('Unable to open profile config', () => agentRuntime.openProfileConfig()); + }, + reloadProfiles() { + runMenuAction('Unable to reload profiles', () => agentRuntime.reloadProfiles(), true); + }, + quit() { + app.quit(); + }, + }); + tray.setContextMenu(Menu.buildFromTemplate(template)); } // ── App lifecycle ─────────────────────────────────────────────────────────── app.whenReady().then(async () => { + agentRuntime = createAgentRuntime({ + userDataPath: app.getPath('userData'), + platform: process.platform, + driver: createProductionInputDriver(), + openPath: filePath => shell.openPath(filePath), + }); + try { + await agentRuntime.initialize(); + } catch (error) { + showError('Unable to load agent profiles', error); + } + tray = new Tray(await getTrayIcon()); tray.setToolTip('OpenWhip - click for whip'); - tray.setContextMenu( - Menu.buildFromTemplate([ - { label: 'Quit', click: () => app.quit() }, - ]) - ); + rebuildTrayMenu(); tray.on('click', toggleOverlay); }); diff --git a/package.json b/package.json index 36e2ea9..e358654 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,7 @@ "overlay.html", "sounds", "icon", + "lib", "bin/openwhip.js", "bin/badclaude.js", "README.md" @@ -43,7 +44,8 @@ "scripts": { "start": "electron .", "dev": "electron .", - "pack": "npm pack" + "pack": "npm pack", + "test": "node --test" }, "dependencies": { "electron": "^33.0.0", diff --git a/test/agent-profiles.test.js b/test/agent-profiles.test.js new file mode 100644 index 0000000..0b8af3c --- /dev/null +++ b/test/agent-profiles.test.js @@ -0,0 +1,194 @@ +const assert = require('node:assert/strict'); +const test = require('node:test'); + +const { + DEFAULT_PROFILE_ID, + BUILT_IN_PROFILES, + mergeProfiles, + resolveSteps, + selectMessage, +} = require('../lib/agent-profiles'); + +function message() { + return { type: 'message' }; +} + +function customProfile(overrides = {}) { + return { + id: 'custom-agent', + label: 'Custom Agent', + messages: ['Keep going'], + steps: { default: [message()] }, + ...overrides, + }; +} + +function config(profiles) { + return { version: 1, profiles }; +} + +test('exports immutable Claude Code and Codex built-in profiles with their platform actions', () => { + assert.equal(DEFAULT_PROFILE_ID, 'claude-code'); + assert.equal(BUILT_IN_PROFILES.length, 2); + assert.equal(Object.isFrozen(BUILT_IN_PROFILES), true); + + const claude = BUILT_IN_PROFILES[0]; + const codex = BUILT_IN_PROFILES[1]; + + assert.deepEqual(claude, { + id: 'claude-code', + label: 'Claude Code', + messages: [ + 'FASTER', + 'FASTER', + 'FASTER', + 'GO FASTER', + 'Faster CLANKER', + 'Work FASTER', + 'Speed it up clanker', + ], + steps: { + default: [ + { type: 'keystroke', key: 'c', modifiers: ['control'] }, + message(), + { type: 'keystroke', key: 'enter', modifiers: [] }, + ], + darwin: [ + { type: 'keystroke', key: 'c', modifiers: ['control'] }, + { type: 'delay', ms: 300 }, + message(), + { type: 'keystroke', key: 'enter', modifiers: [] }, + ], + }, + }); + assert.deepEqual(codex, { + id: 'codex', + label: 'Codex', + messages: claude.messages, + steps: { + default: [message(), { type: 'keystroke', key: 'enter', modifiers: [] }], + }, + }); + assert.equal(Object.isFrozen(claude.steps.default), true); +}); + +test('resolves platform overrides and falls back to default steps', () => { + const profile = customProfile({ + steps: { + default: [message()], + win32: [{ type: 'keystroke', key: 'tab', modifiers: [] }, message()], + }, + }); + + assert.deepEqual(resolveSteps(profile, 'win32'), [ + { type: 'keystroke', key: 'tab', modifiers: [] }, + message(), + ]); + assert.deepEqual(resolveSteps(profile, 'linux'), [message()]); +}); + +test('normalizes letter keys to lowercase for every platform sequence', () => { + const [profile] = mergeProfiles(config([customProfile({ + steps: { + default: [{ type: 'keystroke', key: 'A', modifiers: [] }, message()], + win32: [{ type: 'keystroke', key: 'B', modifiers: [] }, message()], + darwin: [{ type: 'keystroke', key: 'C', modifiers: ['control'] }, message()], + linux: [{ type: 'keystroke', key: 'D', modifiers: ['control'] }, message()], + }, + })])).slice(-1); + + assert.equal(resolveSteps(profile, 'freebsd')[0].key, 'a'); + assert.equal(resolveSteps(profile, 'win32')[0].key, 'b'); + assert.equal(resolveSteps(profile, 'darwin')[0].key, 'c'); + assert.equal(resolveSteps(profile, 'linux')[0].key, 'd'); +}); + +test('selects a message deterministically from the supplied random function', () => { + const profile = customProfile({ messages: ['First', 'Second', 'Third'] }); + + assert.equal(selectMessage(profile, () => 0), 'First'); + assert.equal(selectMessage(profile, () => 0.999), 'Third'); +}); + +test('merges valid custom profiles after built-ins without mutating inputs', () => { + const supplied = config([customProfile({ id: 'neutral-tool', label: 'Neutral Tool' })]); + const before = structuredClone(supplied); + + const profiles = mergeProfiles(supplied); + + assert.deepEqual(profiles.map(profile => profile.id), ['claude-code', 'codex', 'neutral-tool']); + assert.deepEqual(supplied, before); + assert.notEqual(profiles[2], supplied.profiles[0]); +}); + +test('rejects an invalid later profile atomically without mutating input or retaining partial state', () => { + const supplied = config([ + customProfile({ id: 'first-valid' }), + customProfile({ id: 'later-invalid', messages: [] }), + ]); + const before = structuredClone(supplied); + + assert.throws(() => mergeProfiles(supplied), /messages/i); + assert.deepEqual(supplied, before); + + const retried = mergeProfiles(config([customProfile({ id: 'first-valid' })])); + assert.deepEqual(retried.map(profile => profile.id), [ + 'claude-code', + 'codex', + 'first-valid', + ]); +}); + +test('rejects invalid top-level configuration, custom count, IDs, labels, and duplicate IDs', () => { + assert.throws(() => mergeProfiles(null), /config/i); + assert.throws(() => mergeProfiles({ version: 2, profiles: [] }), /version/i); + assert.throws(() => mergeProfiles({ version: 1, profiles: 'not-an-array' }), /profiles/i); + assert.throws(() => mergeProfiles(config(Array.from({ length: 51 }, (_, index) => customProfile({ id: `neutral-${index}` })))), /50/i); + assert.throws(() => mergeProfiles(config([null])), /profile/i); + assert.throws(() => mergeProfiles(config([customProfile({ id: '' })])), /id/i); + assert.throws(() => mergeProfiles(config([customProfile({ id: 'x'.repeat(65) })])), /id/i); + assert.throws(() => mergeProfiles(config([customProfile({ label: '' })])), /label/i); + assert.throws(() => mergeProfiles(config([customProfile({ label: 'x'.repeat(65) })])), /label/i); + assert.throws(() => mergeProfiles(config([customProfile({ id: 'codex' })])), /built-in|duplicate/i); + assert.throws(() => mergeProfiles(config([customProfile(), customProfile()])), /duplicate/i); +}); + +test('rejects invalid message collections and message text', () => { + assert.throws(() => mergeProfiles(config([customProfile({ messages: 'Keep going' })])), /messages/i); + assert.throws(() => mergeProfiles(config([customProfile({ messages: [] })])), /messages/i); + assert.throws(() => mergeProfiles(config([customProfile({ messages: Array(21).fill('Keep going') })])), /messages/i); + assert.throws(() => mergeProfiles(config([customProfile({ messages: [42] })])), /message/i); + assert.throws(() => mergeProfiles(config([customProfile({ messages: [''] })])), /message/i); + assert.throws(() => mergeProfiles(config([customProfile({ messages: ['x'.repeat(501)] })])), /message/i); + assert.throws(() => mergeProfiles(config([customProfile({ messages: ['Keep going \u2713'] })])), /ASCII/i); +}); + +test('rejects missing or invalid platform step sequences', () => { + assert.throws(() => mergeProfiles(config([customProfile({ steps: null })])), /steps/i); + assert.throws(() => mergeProfiles(config([customProfile({ steps: {} })])), /default/i); + assert.throws(() => mergeProfiles(config([customProfile({ steps: { default: [] } })])), /steps/i); + assert.throws(() => mergeProfiles(config([customProfile({ steps: { default: [null] } })])), /step/i); + assert.throws(() => mergeProfiles(config([customProfile({ steps: { default: Array(17).fill(message()) } })])), /steps/i); + assert.throws(() => mergeProfiles(config([customProfile({ steps: { default: [{ type: 'delay', ms: 1 }] } })])), /exactly one message/i); + assert.throws(() => mergeProfiles(config([customProfile({ steps: { default: [message(), message()] } })])), /exactly one message/i); + assert.throws(() => mergeProfiles(config([customProfile({ steps: { default: [message()], android: [message()] } })])), /platform/i); +}); + +test('rejects invalid keystroke, delay, and step fields', () => { + const invalid = (step, pattern) => assert.throws( + () => mergeProfiles(config([customProfile({ steps: { default: [step, message()] } })])), + pattern, + ); + + invalid({ type: 'keystroke', key: 'f1', modifiers: [] }, /key/i); + invalid({ type: 'keystroke', key: 'a', modifiers: 'control' }, /modifier/i); + invalid({ type: 'keystroke', key: 'a', modifiers: ['control', 'control'] }, /modifier/i); + invalid({ type: 'keystroke', key: 'a', modifiers: ['super'] }, /modifier/i); + invalid({ type: 'delay', ms: 1.5 }, /delay/i); + invalid({ type: 'delay', ms: -1 }, /delay/i); + invalid({ type: 'delay', ms: 2001 }, /delay/i); + invalid({ type: 'keystroke', key: 'a', modifiers: [], command: 'run this' }, /unknown/i); + invalid({ type: 'message', command: 'run this' }, /unknown/i); + invalid({ type: 'delay', ms: 1, command: 'run this' }, /unknown/i); + invalid({ type: 'shell', command: 'run this' }, /unknown|type/i); +}); diff --git a/test/agent-runtime.test.js b/test/agent-runtime.test.js new file mode 100644 index 0000000..bfc660d --- /dev/null +++ b/test/agent-runtime.test.js @@ -0,0 +1,402 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs/promises'); +const os = require('node:os'); +const path = require('node:path'); +const test = require('node:test'); + +const { + CONFIG_FILENAME, + STATE_FILENAME, + createAgentRuntime, + createTrayMenuTemplate, + writeActiveProfileState, +} = require('../lib/agent-runtime'); + +function customConfig(id = 'custom-agent') { + return { + version: 1, + profiles: [{ + id, + label: 'Custom Agent', + messages: ['Keep going'], + steps: { default: [{ type: 'message' }] }, + }], + }; +} + +async function makeUserData(t) { + const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'openwhip-runtime-')); + t.after(() => fs.rm(directory, { recursive: true, force: true })); + return directory; +} + +test('missing config and state initialize to built-ins with Claude Code active', async t => { + const userDataPath = await makeUserData(t); + const runtime = createAgentRuntime({ + userDataPath, + platform: 'win32', + driver: { async execute() {} }, + }); + + await runtime.initialize(); + + assert.deepEqual(runtime.getProfiles().map(profile => profile.id), ['claude-code', 'codex']); + assert.equal(runtime.getActiveProfileId(), 'claude-code'); +}); + +test('valid state selects a custom profile while corrupt or unknown state falls back', async t => { + const userDataPath = await makeUserData(t); + await fs.writeFile( + path.join(userDataPath, CONFIG_FILENAME), + JSON.stringify(customConfig()), + ); + const statePath = path.join(userDataPath, STATE_FILENAME); + + await fs.writeFile(statePath, JSON.stringify({ activeProfileId: 'custom-agent' })); + const valid = createAgentRuntime({ userDataPath, platform: 'linux', driver: { async execute() {} } }); + await valid.initialize(); + assert.equal(valid.getActiveProfileId(), 'custom-agent'); + + await fs.writeFile(statePath, '{broken'); + const corrupt = createAgentRuntime({ userDataPath, platform: 'linux', driver: { async execute() {} } }); + await corrupt.initialize(); + assert.equal(corrupt.getActiveProfileId(), 'claude-code'); + + await fs.writeFile(statePath, JSON.stringify({ activeProfileId: 'not-installed' })); + const unknown = createAgentRuntime({ userDataPath, platform: 'linux', driver: { async execute() {} } }); + await unknown.initialize(); + assert.equal(unknown.getActiveProfileId(), 'claude-code'); +}); + +test('active profile executes through resolved steps and the injected driver', async t => { + const userDataPath = await makeUserData(t); + const calls = []; + const runtime = createAgentRuntime({ + userDataPath, + platform: 'darwin', + randomFn: () => 0, + driver: { + async execute(steps, message) { + calls.push({ steps, message }); + }, + }, + }); + await runtime.initialize(); + await runtime.selectProfile('codex'); + + await runtime.executeActiveProfile(); + + assert.deepEqual(calls, [{ + steps: [ + { type: 'message' }, + { type: 'keystroke', key: 'enter', modifiers: [] }, + ], + message: 'FASTER', + }]); +}); + +test('openProfileConfig creates an empty template once and opens its path', async t => { + const userDataPath = await makeUserData(t); + const opened = []; + const runtime = createAgentRuntime({ + userDataPath, + platform: 'win32', + driver: { async execute() {} }, + async openPath(filePath) { + opened.push(filePath); + return ''; + }, + }); + const configPath = path.join(userDataPath, CONFIG_FILENAME); + + await runtime.openProfileConfig(); + assert.deepEqual(JSON.parse(await fs.readFile(configPath, 'utf8')), { version: 1, profiles: [] }); + + await fs.writeFile(configPath, JSON.stringify(customConfig())); + await runtime.openProfileConfig(); + + assert.deepEqual(JSON.parse(await fs.readFile(configPath, 'utf8')), customConfig()); + assert.deepEqual(opened, [configPath, configPath]); +}); + +test('reload swaps a valid registry and rejects invalid config without changing current state', async t => { + const userDataPath = await makeUserData(t); + const configPath = path.join(userDataPath, CONFIG_FILENAME); + const runtime = createAgentRuntime({ + userDataPath, + platform: 'win32', + driver: { async execute() {} }, + }); + await runtime.initialize(); + + await fs.writeFile(configPath, JSON.stringify(customConfig())); + await runtime.reloadProfiles(); + await runtime.selectProfile('custom-agent'); + const profilesBeforeFailure = runtime.getProfiles(); + + await fs.writeFile(configPath, '{broken'); + await assert.rejects(runtime.reloadProfiles(), /JSON|Unexpected|position/i); + + assert.equal(runtime.getProfiles(), profilesBeforeFailure); + assert.equal(runtime.getActiveProfileId(), 'custom-agent'); +}); + +test('reload removal falls back to Claude Code and persists the fallback', async t => { + const userDataPath = await makeUserData(t); + const configPath = path.join(userDataPath, CONFIG_FILENAME); + await fs.writeFile(configPath, JSON.stringify(customConfig())); + const runtime = createAgentRuntime({ + userDataPath, + platform: 'win32', + driver: { async execute() {} }, + }); + await runtime.initialize(); + await runtime.selectProfile('custom-agent'); + + await fs.writeFile(configPath, JSON.stringify({ version: 1, profiles: [] })); + await runtime.reloadProfiles(); + + assert.equal(runtime.getActiveProfileId(), 'claude-code'); + assert.deepEqual( + JSON.parse(await fs.readFile(path.join(userDataPath, STATE_FILENAME), 'utf8')), + { activeProfileId: 'claude-code' }, + ); +}); + +test('reload keeps the prior registry when persisting a required fallback fails', async t => { + const userDataPath = await makeUserData(t); + const configPath = path.join(userDataPath, CONFIG_FILENAME); + await fs.writeFile(configPath, JSON.stringify(customConfig())); + await fs.writeFile( + path.join(userDataPath, STATE_FILENAME), + JSON.stringify({ activeProfileId: 'custom-agent' }), + ); + const runtime = createAgentRuntime({ + userDataPath, + platform: 'win32', + driver: { async execute() {} }, + fileSystem: { + ...fs, + async rename() { throw new Error('state rename failed'); }, + }, + }); + await runtime.initialize(); + const profilesBeforeFailure = runtime.getProfiles(); + await fs.writeFile(configPath, JSON.stringify({ version: 1, profiles: [] })); + + await assert.rejects(runtime.reloadProfiles(), /state rename failed/); + + assert.equal(runtime.getProfiles(), profilesBeforeFailure); + assert.equal(runtime.getActiveProfileId(), 'custom-agent'); +}); + +test('state writes use a temporary file and rename, and selection stays unchanged on write failure', async t => { + const calls = []; + await writeActiveProfileState({ + async writeFile(filePath, contents) { + calls.push(['write', filePath, JSON.parse(contents)]); + }, + async rename(from, to) { + calls.push(['rename', from, to]); + }, + }, 'C:/user-data/agent-profile-state.json', 'codex', 'fixed'); + + assert.deepEqual(calls, [ + ['write', 'C:/user-data/agent-profile-state.json.fixed.tmp', { activeProfileId: 'codex' }], + ['rename', 'C:/user-data/agent-profile-state.json.fixed.tmp', 'C:/user-data/agent-profile-state.json'], + ]); + + const userDataPath = await makeUserData(t); + const runtime = createAgentRuntime({ + userDataPath, + platform: 'win32', + driver: { async execute() {} }, + fileSystem: { + ...fs, + async rename() { throw new Error('state rename failed'); }, + }, + }); + await runtime.initialize(); + + await assert.rejects(runtime.selectProfile('codex'), /state rename failed/); + assert.equal(runtime.getActiveProfileId(), 'claude-code'); +}); + +test('concurrent state writes in the same millisecond use distinct temporary files', async () => { + const originalNow = Date.now; + const temporaryFiles = new Map(); + const writtenPaths = []; + let writeCount = 0; + let releaseWrites; + const bothWritesStarted = new Promise(resolve => { releaseWrites = resolve; }); + const fileSystem = { + async writeFile(filePath, contents) { + writtenPaths.push(filePath); + temporaryFiles.set(filePath, contents); + writeCount += 1; + if (writeCount === 2) releaseWrites(); + await bothWritesStarted; + }, + async rename(from) { + if (!temporaryFiles.has(from)) { + const error = new Error(`missing temporary file: ${from}`); + error.code = 'ENOENT'; + throw error; + } + temporaryFiles.delete(from); + }, + }; + + Date.now = () => 123456789; + try { + await Promise.all([ + writeActiveProfileState(fileSystem, 'C:/user-data/state.json', 'codex'), + writeActiveProfileState(fileSystem, 'C:/user-data/state.json', 'claude-code'), + ]); + } finally { + Date.now = originalNow; + } + + assert.equal(new Set(writtenPaths).size, 2); +}); + +test('overlapping selections persist and publish state in invocation order', async () => { + const originalNow = Date.now; + let now = 1000; + const files = new Map(); + let stateWriteCount = 0; + let releaseFirstWrite; + const firstWriteGate = new Promise(resolve => { releaseFirstWrite = resolve; }); + const fileSystem = { + async readFile(filePath) { + if (files.has(filePath)) return files.get(filePath); + const error = new Error(`missing file: ${filePath}`); + error.code = 'ENOENT'; + throw error; + }, + async writeFile(filePath, contents) { + files.set(filePath, contents); + stateWriteCount += 1; + if (stateWriteCount === 1) await firstWriteGate; + }, + async rename(from, to) { + if (!files.has(from)) { + const error = new Error(`missing temporary file: ${from}`); + error.code = 'ENOENT'; + throw error; + } + files.set(to, files.get(from)); + files.delete(from); + }, + }; + const userDataPath = 'C:/user-data'; + const runtime = createAgentRuntime({ + userDataPath, + platform: 'win32', + driver: { async execute() {} }, + fileSystem, + }); + await runtime.initialize(); + + Date.now = () => now++; + try { + const first = runtime.selectProfile('codex'); + const second = runtime.selectProfile('claude-code'); + await new Promise(resolve => setImmediate(resolve)); + releaseFirstWrite(); + await Promise.all([first, second]); + } finally { + Date.now = originalNow; + } + + assert.equal(runtime.getActiveProfileId(), 'claude-code'); + assert.deepEqual( + JSON.parse(files.get(path.join(userDataPath, STATE_FILENAME))), + { activeProfileId: 'claude-code' }, + ); +}); + +test('reload orders later validation and a failed queued selection does not block recovery', async t => { + const userDataPath = await makeUserData(t); + const configPath = path.join(userDataPath, CONFIG_FILENAME); + await fs.writeFile(configPath, JSON.stringify(customConfig())); + + let blockConfigRead = false; + let announceBlockedRead; + let releaseBlockedRead; + const blockedReadStarted = new Promise(resolve => { announceBlockedRead = resolve; }); + const blockedReadGate = new Promise(resolve => { releaseBlockedRead = resolve; }); + const fileSystem = { + ...fs, + async readFile(filePath, encoding) { + if (blockConfigRead && filePath === configPath) { + announceBlockedRead(); + await blockedReadGate; + } + return fs.readFile(filePath, encoding); + }, + }; + const runtime = createAgentRuntime({ + userDataPath, + platform: 'win32', + driver: { async execute() {} }, + fileSystem, + }); + await runtime.initialize(); + await runtime.selectProfile('custom-agent'); + await fs.writeFile(configPath, JSON.stringify({ version: 1, profiles: [] })); + + blockConfigRead = true; + const reload = runtime.reloadProfiles(); + await blockedReadStarted; + const staleSelection = runtime.selectProfile('custom-agent'); + releaseBlockedRead(); + + await reload; + await assert.rejects(staleSelection, /Unknown agent profile: custom-agent/); + await runtime.selectProfile('codex'); + + assert.equal(runtime.getActiveProfileId(), 'codex'); + assert.deepEqual( + JSON.parse(await fs.readFile(path.join(userDataPath, STATE_FILENAME), 'utf8')), + { activeProfileId: 'codex' }, + ); +}); + +test('tray menu is built dynamically with radio profiles and management actions', async t => { + const userDataPath = await makeUserData(t); + await fs.writeFile(path.join(userDataPath, CONFIG_FILENAME), JSON.stringify(customConfig())); + const runtime = createAgentRuntime({ + userDataPath, + platform: 'win32', + driver: { async execute() {} }, + }); + await runtime.initialize(); + await runtime.selectProfile('codex'); + + const selected = []; + const menu = createTrayMenuTemplate(runtime, { + selectProfile(id) { selected.push(id); }, + openProfileConfig() {}, + reloadProfiles() {}, + quit() {}, + }); + + assert.equal(menu[0].label, 'Target Agent'); + assert.deepEqual(menu[0].submenu.map(item => ({ + label: item.label, + type: item.type, + checked: item.checked, + })), [ + { label: 'Claude Code', type: 'radio', checked: false }, + { label: 'Codex', type: 'radio', checked: true }, + { label: 'Custom Agent', type: 'radio', checked: false }, + ]); + menu[0].submenu[2].click(); + assert.deepEqual(selected, ['custom-agent']); + assert.deepEqual(menu.slice(1).map(item => item.label), [ + 'Open profile config...', + 'Reload profiles', + 'Quit', + ]); +}); diff --git a/test/input-executor.test.js b/test/input-executor.test.js new file mode 100644 index 0000000..8e83823 --- /dev/null +++ b/test/input-executor.test.js @@ -0,0 +1,214 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const { + createInputDriver, + executeSteps, +} = require('../lib/input-executor'); + +const KEYUP = 0x0002; +const UNICODE = 0x0004; + +test('Windows executes portable steps in order and types messages through one Unicode SendInput batch', async () => { + const calls = []; + const driver = createInputDriver('win32', { + keybdEvent(vk, scan, flags, extraInfo) { + calls.push(['key', vk, scan, flags, extraInfo]); + }, + sendInput(count, events, inputSize) { + calls.push(['sendInput', count, events, inputSize]); + return count; + }, + inputSize: 40, + async sleep(ms) { + calls.push(['sleep', ms]); + }, + }); + + await executeSteps(driver, [ + { type: 'keystroke', key: 'c', modifiers: ['control'] }, + { type: 'delay', ms: 25 }, + { type: 'message' }, + { type: 'keystroke', key: 'enter', modifiers: [] }, + ], 'A B'); + + assert.deepEqual(calls, [ + ['key', 0x11, 0, 0, 0], + ['key', 0x43, 0, 0, 0], + ['key', 0x43, 0, KEYUP, 0], + ['key', 0x11, 0, KEYUP, 0], + ['sleep', 25], + ['sendInput', 6, [ + { type: 1, ki: { wVk: 0, wScan: 0x0041, dwFlags: UNICODE, time: 0, dwExtraInfo: 0 } }, + { type: 1, ki: { wVk: 0, wScan: 0x0041, dwFlags: UNICODE | KEYUP, time: 0, dwExtraInfo: 0 } }, + { type: 1, ki: { wVk: 0, wScan: 0x0020, dwFlags: UNICODE, time: 0, dwExtraInfo: 0 } }, + { type: 1, ki: { wVk: 0, wScan: 0x0020, dwFlags: UNICODE | KEYUP, time: 0, dwExtraInfo: 0 } }, + { type: 1, ki: { wVk: 0, wScan: 0x0042, dwFlags: UNICODE, time: 0, dwExtraInfo: 0 } }, + { type: 1, ki: { wVk: 0, wScan: 0x0042, dwFlags: UNICODE | KEYUP, time: 0, dwExtraInfo: 0 } }, + ], 40], + ['key', 0x0d, 0, 0, 0], + ['key', 0x0d, 0, KEYUP, 0], + ]); +}); + +test('Windows rejects a partial Unicode SendInput batch before Enter', async () => { + const calls = []; + const driver = createInputDriver('win32', { + keybdEvent(vk, scan, flags, extraInfo) { + calls.push(['key', vk, scan, flags, extraInfo]); + }, + sendInput(count, events, inputSize) { + calls.push(['sendInput', count, events, inputSize]); + return count - 1; + }, + inputSize: 40, + sleep: async () => {}, + }); + + await assert.rejects( + driver.execute([ + { type: 'message' }, + { type: 'keystroke', key: 'enter', modifiers: [] }, + ], 'A'), + /SendInput.*insert.*requested|insert.*requested.*SendInput/i, + ); + + assert.deepEqual(calls, [ + ['sendInput', 2, [ + { type: 1, ki: { wVk: 0, wScan: 0x0041, dwFlags: UNICODE, time: 0, dwExtraInfo: 0 } }, + { type: 1, ki: { wVk: 0, wScan: 0x0041, dwFlags: UNICODE | KEYUP, time: 0, dwExtraInfo: 0 } }, + ], 40], + ]); +}); + +test('Windows maps portable modifiers and releases them in reverse order', async () => { + const calls = []; + const driver = createInputDriver('win32', { + keybdEvent(vk, scan, flags) { + calls.push([vk, flags]); + }, + sendInput() {}, + inputSize: 40, + sleep: async () => {}, + }); + + await driver.execute([ + { type: 'keystroke', key: 'left', modifiers: ['meta', 'alt', 'shift'] }, + ], 'unused'); + + assert.deepEqual(calls, [ + [0x5b, 0], + [0x12, 0], + [0x10, 0], + [0x25, 0], + [0x25, KEYUP], + [0x10, KEYUP], + [0x12, KEYUP], + [0x5b, KEYUP], + ]); +}); + +test('Windows rejects missing injection functions and invalid input size', () => { + assert.throws( + () => createInputDriver('win32', { sendInput() {}, inputSize: 40 }), + /keybdEvent/i, + ); + assert.throws( + () => createInputDriver('win32', { keybdEvent() {}, inputSize: 40 }), + /sendInput/i, + ); + assert.throws( + () => createInputDriver('win32', { keybdEvent() {}, sendInput() {} }), + /inputSize/i, + ); +}); + +test('macOS executes one ordered AppleScript with escaped text, named keys, modifiers, and delay seconds', async () => { + const calls = []; + const driver = createInputDriver('darwin', { + execFile(file, args, callback) { + calls.push([file, args]); + callback(null); + }, + }); + + await driver.execute([ + { type: 'keystroke', key: 'c', modifiers: ['control'] }, + { type: 'delay', ms: 300 }, + { type: 'message' }, + { type: 'keystroke', key: 'tab', modifiers: ['meta', 'shift'] }, + { type: 'keystroke', key: 'enter', modifiers: [] }, + ], 'A"\\B'); + + assert.deepEqual(calls, [[ + 'osascript', + ['-e', [ + 'tell application "System Events"', + ' keystroke "c" using {control down}', + ' delay 0.3', + ' keystroke "A\\"\\\\B"', + ' key code 48 using {command down, shift down}', + ' key code 36', + 'end tell', + ].join('\n')], + ]]); +}); + +test('Linux executes one ordered xdotool argv plan', async () => { + const calls = []; + const driver = createInputDriver('linux', { + execFile(file, args, callback) { + calls.push([file, args]); + callback(null); + }, + }); + + await executeSteps(driver, [ + { type: 'keystroke', key: 'c', modifiers: ['control'] }, + { type: 'delay', ms: 250 }, + { type: 'message' }, + { type: 'keystroke', key: 'left', modifiers: ['meta', 'alt'] }, + { type: 'keystroke', key: 'enter', modifiers: [] }, + ], 'GO FASTER'); + + assert.deepEqual(calls, [[ + 'xdotool', + [ + 'key', '--clearmodifiers', 'ctrl+c', + 'sleep', '0.25', + 'type', '--delay', '1', '--clearmodifiers', '--', 'GO FASTER', + 'key', '--clearmodifiers', 'super+alt+Left', + 'key', '--clearmodifiers', 'Return', + ], + ]]); +}); + +test('macOS and Linux execution errors propagate', async (t) => { + for (const [platform, expected] of [['darwin', 'osascript failed'], ['linux', 'xdotool failed']]) { + await t.test(platform, async () => { + const driver = createInputDriver(platform, { + execFile(file, args, callback) { + callback(new Error(expected)); + }, + }); + await assert.rejects( + driver.execute([{ type: 'message' }], 'FASTER'), + new RegExp(expected), + ); + }); + } +}); + +test('unsupported platforms fail explicitly', () => { + assert.throws( + () => createInputDriver('freebsd', {}), + /unsupported platform.*freebsd/i, + ); +}); + +test('executeSteps requires a driver execute function', async () => { + await assert.rejects( + executeSteps({}, [{ type: 'message' }], 'FASTER'), + /driver.*execute/i, + ); +});