Pick a different movement pattern every time a sweep is triggered, so the motion varies across the day instead of repeating one shape. - strategies.ts: add the `random` sentinel, `SELECTABLE_PATTERN_NAMES`, `isSelectablePattern`, and `createRandomPicker`. `random` is deliberately NOT a registry entry: it has no path of its own, so `STRATEGIES` stays a total lookup and `PATTERN_NAMES` keeps listing only real generators. The picker is a closure over `last`, giving a uniform draw that never returns the same pattern twice in a row. Building CANONICAL_PATTERNS from the selectable list makes both validation boundaries accept `random` (and loose spellings) for free, and extends the normalization-collision assertion to cover the sentinel. - cli.ts: add `-r`/`--random` plus an exported `selectPattern` holding the conflict rule. `-r` is sugar for `--pattern random`, so the two agreeing is a no-op while `-r -p arc` is rejected as contradictory. The flag folds into `pattern`, so ConfigOverrides, resolveConfig, and move.ts are untouched. `parseCliArgs` now takes its argv as an optional parameter so the flag surface is testable without process.argv. - keeper.ts: resolve `random` via the picker once per trigger, before the loop-mode branch, so a pick holds for a whole loop run rather than changing mid-run. runKeeper builds one picker for the process, so the no-repeat memory spans sweeps minutes apart. Because the pick is a real strategy, --verbose logs the concrete pattern name and a pick with an infinite loopPath still bounces edge-to-edge under --loop. - config.ts / configFile.ts: accept the sentinel where a pattern is valid, and quote the selectable list in errors. No `random` boolean config key — the file spells it "pattern": "random". executor.ts and move.ts needed no changes. Tests: new tests/cli.test.ts (the file had no coverage before) covering the flag surface and the conflict rule; picker tests pinning the no-repeat and full-registry-coverage properties; keeper tests pinning once-per-trigger and once-per-loop-run.
193 lines
8.2 KiB
TypeScript
193 lines
8.2 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 (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<void> {
|
|
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<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, 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();
|
|
}
|
|
}
|
|
}
|