Files
Move/src/keeper.ts
T
nokeo08 ec33648e74 Remove stepCount/stepSize; patterns own their geometry
The stepCount and stepSize knobs were two controls for one quantity users
actually care about (reach), and the number of steps is an implementation
detail nobody meaningfully tunes. Each pattern has a natural size and
resolution — a jitter is inherently small, an arc a broad curve — so those
now live as constants in each strategy rather than as global config.

- strategies.ts: each pattern defines its own step count and size; MoveContext
  drops `config` down to pure geometry (start/width/height/rng), and the
  module no longer imports Config at all (dissolving the type-only-import
  cycle workaround). line stays byte-for-byte: 250 one-pixel steps.
- executor.ts: executePath takes `config` for pacing (stepDelay); the path
  itself needs nothing from it.
- config.ts / cli.ts / move.ts / config.default.json: drop stepCount and
  stepSize from the type, seed, validation, resolver, CLI flags (-n, -s),
  and help. stepDelay stays as the one pacing lever.
- configFile.ts: tolerate the removed keys instead of rejecting them — every
  pre-1.3.0 install seeded stepCount, so a hard "unknown key" failure on
  upgrade is avoided. They're ignored with a one-line stderr notice; genuine
  unknown keys still error.

The -n/--step-count CLI flag (shipped since 1.0.0) is now an unknown option;
config files degrade gracefully, command lines don't. Stays in the unpushed
1.3.0 release. 64 tests pass.
2026-08-14 12:56:22 -05:00

129 lines
5.3 KiB
TypeScript

/**
* 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<void> {
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<void> {
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();
}
}
}