/** * keeper.ts * --------- * The "Teams Status Keeper" behavior: the idle-watch loop plus the * per-sweep glue that ties a movement strategy to the execution driver. * * The mechanics are split across three seams so this file stays small and * the interesting parts stay testable: * - `device.ts` — the nut.js I/O boundary (injected here). * - `strategies.ts` — pure "where to move" pattern generators. * - `executor.ts` — the "how to move" driver (on-screen reflection, * timing, interrupt detection, restore). * * `runKeeper` takes an optional `Device` so tests can drive the loop with a * fake; production supplies the nut.js device. Importing this module is * side-effect-free: nut.js isn't touched until `createNutDevice()` runs. * * Logging policy: * - The startup banner in `runKeeper` is unconditional so the user always * sees the process is alive. * - Per-sweep / interrupt lines are gated by `config.verbose` (see * `makeLogger`). Errors stay on `console.error`, raised by the entry * point on unhandled rejection. */ import { createNutDevice, type Device, type Point } from "./device.ts"; import { executePath, type Logger, type SweepOutcome } from "./executor.ts"; import { createRandomPicker, DEFAULT_PATTERN, RANDOM_PATTERN, STRATEGIES, type MoveContext, type MovementStrategy, } from "./strategies.ts"; import type { Config } from "./config.ts"; /** * Build a verbose-gated `Logger`. `info` is unconditional; `event` only * fires when the caller asked for verbose output. Returning a small object * keeps call sites free of `if (verbose)` noise at every log line. */ function makeLogger(verbose: boolean): Logger { return { info: (msg: string): void => { console.log(msg); }, event: (msg: string): void => { if (verbose) console.log(msg); }, }; } /** * Perform synthetic mouse activity once the keeper decides the cursor is * idle. * * Snapshots the screen (re-read every call so monitor changes are handled) * and selects the configured strategy from the registry. An unknown * `config.pattern` falls back to the default strategy defensively; validation * at the CLI / config-file boundary should prevent that from ever happening. * * `pattern: "random"` isn't a registry key — it asks for a fresh pattern per * sweep, so `pickRandom` supplies one here. The pick happens once, before the * loop-mode branch below, which is what makes a random selection hold for an * entire loop run rather than changing under the user mid-run; the picker's * own no-repeat memory then spans sweeps, since the keeper holds one picker * for the life of the process. Because the pick is a real strategy, the log * lines below and in `executePath` name the concrete pattern, not "random". * * Single-sweep mode (`config.loop === false`) runs exactly one sweep via * `executePath`, which owns on-screen reflection, pacing, interrupt * detection, and restore-on-clean — unchanged from before loop mode existed. * * Loop mode (`config.loop === true`) keeps the cursor moving until the * user moves the mouse (or Ctrl+C). The cursor is never restored between * iterations (`restore: false`). Patterns that define an infinite `loopPath` * (`line`, `diagonal`) run it once and are stopped only by interruption; the * rest have their finite `path` chained, re-read from the cursor's current * position each cycle. Per-cycle event logs are suppressed to avoid unbounded * output — one line brackets the run at each end. */ async function simulateActivity( config: Config, log: Logger, device: Device, pickRandom: () => MovementStrategy, ): Promise { const width: number = await device.width(); const height: number = await device.height(); const strategy: MovementStrategy = config.pattern === RANDOM_PATTERN ? pickRandom() : (STRATEGIES[config.pattern] ?? STRATEGIES[DEFAULT_PATTERN]!); if (!config.loop) { const start: Point = await device.getPosition(); const ctx: MoveContext = { start, width, height, rng: Math.random }; await executePath(strategy, ctx, device, log, config); return; } log.event(`Loop mode (${strategy.name}); repeating until you move the mouse.`); const cycleLog: Logger = { info: log.info, event: (): void => {} }; const loopOpts = { restore: false, loop: true }; let cycles = 0; let outcome: SweepOutcome; do { const start: Point = await device.getPosition(); const ctx: MoveContext = { start, width, height, rng: Math.random }; outcome = await executePath(strategy, ctx, device, cycleLog, config, loopOpts); cycles++; // Spin guard for the chained-repeat path: a finite strategy that // yielded nothing would otherwise return "completed" instantly in a // tight loop. Sleeping one stepDelay makes that harmless. An infinite // loopPath never returns "completed", so this branch is skipped there. if (outcome === "completed") await device.sleep(config.stepDelay); } while (outcome === "completed"); log.event(`Loop run ended after ${cycles} cycle(s): ${outcome}.`); } /** * Main idle-watch loop. Runs forever; exits only on `Ctrl+C` (SIGINT) or * an unhandled rejection caught by the entry point. * * Algorithm: * - Track the last known cursor position (`lastPos`) and the timestamp of * the last observed real user movement (`lastActivity`). * - Every `config.checkInterval`: * * If the cursor moved since the last check, that's real user * activity: reset `lastActivity` and `lastPos`, skip the rest. * * Otherwise, if it's been at least `config.moveInterval` since the * last real activity, fire a synthetic sweep, then reset the * idleness clock so we wait another full `moveInterval` before * firing again. * * `simulateActivity` (via `executePath`) is designed so its own synthetic * movement never counts as real activity: on a clean sweep it restores the * cursor, and on a user-interrupted sweep the next iteration sees the * user's new position and correctly resets the clock. * * @param config - Resolved runtime config. * @param device - I/O device; defaults to the production nut.js device. * @param pickRandom - Supplies a strategy when `config.pattern` is `random`. * Created once here (not per sweep) so its no-repeat * memory spans the whole run; injectable so tests can * drive a deterministic sequence. */ export async function runKeeper( config: Config, device?: Device, pickRandom: () => MovementStrategy = createRandomPicker(), ): Promise { const dev: Device = device ?? (await createNutDevice()); const log = makeLogger(config.verbose); log.info("Teams Status Keeper started. Press Ctrl+C to stop."); let lastPos: Point = await dev.getPosition(); let lastActivity: number = Date.now(); while (true) { await dev.sleep(config.checkInterval); const pos: Point = await dev.getPosition(); const now: number = Date.now(); if (pos.x !== lastPos.x || pos.y !== lastPos.y) { // Real user activity since the last check; reset the idleness clock. lastActivity = now; lastPos = pos; continue; } if (now - lastActivity >= config.moveInterval) { await simulateActivity(config, log, dev, pickRandom); // The sweep either restored the cursor to its start (clean) or // left it where the user moved it (interrupt). Either way, reset // the clock and require another full moveInterval of inactivity // before firing again. lastActivity = Date.now(); // Re-sync lastPos to where the cursor actually ended. After a // clean sweep this is a no-op (it was restored to start). After // an interrupt it snaps lastPos to the user's position, so the // next poll doesn't re-read that same displacement and count it a // second time as fresh activity. lastPos = await dev.getPosition(); } } }