/** * 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 (bounds, 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 / bounds 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 } from "./executor.ts"; import { DEFAULT_PATTERN, STRATEGIES, type MoveContext } 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 a single synthetic mouse-activity sweep. * * Snapshots the cursor and screen (re-read every call so monitor changes * are handled), selects the configured strategy from the registry, and * hands the resulting path to `executePath`, which owns bounds, pacing, * interrupt detection, and restore-on-clean. An unknown `config.pattern` * falls back to the default strategy defensively; validation at the CLI / * config-file boundary should prevent that from ever happening. */ async function simulateActivity(config: Config, log: Logger, device: Device): Promise { const start: Point = await device.getPosition(); const width: number = await device.width(); const height: number = await device.height(); const strategy = STRATEGIES[config.pattern] ?? STRATEGIES[DEFAULT_PATTERN]!; const ctx: MoveContext = { start, width, height, rng: Math.random }; await executePath(strategy, ctx, device, log, config); } /** * 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. */ export async function runKeeper(config: Config, device?: Device): 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); // 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(); } } }