Add pluggable movement strategies (v1.3.0)

Turn the hardcoded straight-line sweep into a strategy system behind three
seams so new patterns are easy to add and, for the first time, testable
without nut.js or a real screen:

- src/device.ts:     injectable Device seam over nut.js (autoDelayMs lives
                     here now); the only module that touches the native lib.
- src/strategies.ts: pure per-pattern path generators + registry + lenient
                     name resolution. Ships line, diagonal, jitter, walk,
                     arc, figureEight.
- src/executor.ts:   single executePath driver owning bounds policy
                     (abort/clamp/reflect), pacing, interrupt detection, and
                     restore-on-clean.

keeper.ts's simulateActivity now selects a strategy and delegates to the
executor; the default `line` pattern is byte-for-byte the previous behavior.

New config surface, layered CLI > file > default with strict validation:
- -p/--pattern <name>   movement strategy (names matched case/-/_-insensitive)
- -s/--step-size <px>   pixels per step; stepCount is now a step *count*

Robustness for the new edge-seeking patterns: interrupt detection compares
against the last commanded (rounded) point with a 2px tolerance, and
clamp/reflect stay a couple pixels off the screen edge, so sub-pixel cursor
placement on scaled/multi-monitor displays isn't misread as user activity.
jitter's radius scales with sweep length so it moves at the default stepSize.

Tests: new suites for strategies, the executor (all bounds policies,
rounding, interrupt, tolerance, pacing), and the keeper loop; config and
configFile suites extended for pattern/stepSize. editor.test.ts moved to
tests/ for consistency. 64 pass.
This commit is contained in:
2026-08-13 15:36:22 -05:00
parent 7777b16540
commit db3310c247
18 changed files with 1319 additions and 153 deletions
+56 -120
View File
@@ -1,63 +1,38 @@
/**
* keeper.ts
* ---------
* The actual "Teams Status Keeper" behavior: synthetic mouse activity with
* real-user-wins semantics, plus the idle-watch loop that drives it.
* The "Teams Status Keeper" behavior: the idle-watch loop plus the
* per-sweep glue that ties a movement strategy to the execution driver.
*
* Runtime: Bun (uses `@nut-tree-fork/nut-js` for cross-platform mouse +
* screen). The nut.js auto-delay is disabled inside `runKeeper`, not at
* module load, so importing this module is side-effect-free.
* 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 confirmation that the process is alive.
* - Every per-sweep / interrupt / bounds log is gated by `config.verbose`
* so the default is quiet. Errors stay on `console.error` (unconditional,
* raised by the entry point on unhandled rejection).
* 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 { mouse, Point, screen } from "@nut-tree-fork/nut-js";
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";
/**
* Promise-based `setTimeout` wrapper. Allows `await sleep(ms)` ergonomics.
*
* @param ms - Duration to wait, in milliseconds.
*/
const sleep = (ms: number): Promise<void> =>
new Promise<void>((resolve: () => void): void => {
setTimeout(resolve, ms);
});
/**
* Format the current local time as `HH:MM:SS` (24-hour, zero-padded).
* Used for human-readable log lines. Date is intentionally omitted.
*/
const timestamp = (): string => {
const d: Date = new Date();
const pad = (n: number): string => String(n).padStart(2, "0");
return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
};
/**
* Minimal log surface used by `simulateActivity` and `runKeeper`. Named so
* it can appear directly in function signatures (clearer than
* `ReturnType<typeof makeLogger>`) and so a test could substitute a fake
* implementation if needed.
*
* - `info(msg)` prints unconditionally.
* - `event(msg)` prints only when `--verbose` / `verbose: true` is set.
*/
interface Logger {
info(msg: string): void;
event(msg: string): void;
}
/**
* Build a verbose-gated `Logger`. `info` is unconditional; `event` only
* fires when the caller asked for verbose output. Returning a small object
* keeps `simulateActivity` free of `if (verbose)` noise at every log site.
* keeps call sites free of `if (verbose)` noise at every log line.
*/
function makeLogger(verbose: boolean): Logger {
return {
@@ -73,61 +48,22 @@ function makeLogger(verbose: boolean): Logger {
/**
* Perform a single synthetic mouse-activity sweep.
*
* Behavior:
* 1. Snapshot the starting cursor position.
* 2. Read current screen dimensions (re-read every call so monitor changes
* are handled correctly).
* 3. Pick a horizontal direction (`dx`) that keeps the sweep on-screen:
* move right if there's room, otherwise move left. Vertical movement is
* currently disabled (`dy = 0`) but the framework is in place for
* richer patterns later.
* 4. For each of `config.stepCount` steps:
* - Compute the next target position.
* - Defensive bounds check (belt-and-braces given the `dx` choice).
* - Command nut.js to move the cursor there.
* - Sleep `config.stepDelay` — also the user's interrupt window.
* - Re-read the cursor. If it isn't where we put it, the user
* touched the mouse: log (verbose) and return early, leaving the
* cursor wherever the user moved it.
* 5. On a clean full sweep, restore the cursor to the starting position
* so the next idle-check sees "no movement" and doesn't misread the
* synthetic activity as the user returning.
* 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): Promise<void> {
const start: Point = await mouse.getPosition();
const screenWidth: number = await screen.width();
const screenHeight: number = await screen.height();
const dx: number = start.x + config.stepCount < screenWidth ? 1 : -1;
const dy: number = 0;
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();
log.event(`Simulating activity at ${timestamp()}...`);
const strategy = STRATEGIES[config.pattern] ?? STRATEGIES[DEFAULT_PATTERN]!;
const ctx: MoveContext = { start, width, height, config, rng: Math.random };
for (let i: number = 1; i <= config.stepCount; i++) {
const expected: Point = new Point(start.x + i * dx, start.y + i * dy);
if (expected.x < 0 || expected.x >= screenWidth || expected.y < 0 || expected.y >= screenHeight) {
// Safety net for future non-linear movement patterns. With the
// current straight-line sweep + `dx` selection above, this branch
// should never fire.
log.event(`Out of bounds at ${timestamp()}; aborting simulation.`);
return;
}
await mouse.setPosition(expected);
await sleep(config.stepDelay);
const current: Point = await mouse.getPosition();
if (current.x !== expected.x || current.y !== expected.y) {
// Cursor isn't where we put it -> real user activity. Abort
// without snapping back, so we don't yank the cursor out from
// under the user.
log.event(`User activity detected at ${timestamp()}; aborting simulation.`);
return;
}
}
await mouse.setPosition(start);
log.event("Mouse moved.");
await executePath(strategy, ctx, device, log);
}
/**
@@ -145,32 +81,26 @@ async function simulateActivity(config: Config, log: Logger): Promise<void> {
* idleness clock so we wait another full `moveInterval` before
* firing again.
*
* `simulateActivity` is designed so that its own synthetic movement never
* counts as real activity: on a clean sweep it restores the cursor (so the
* next position check matches), and on a user-interrupted sweep the next
* iteration sees the user's new position and correctly resets the clock.
* `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): Promise<void> {
// nut.js inserts a configurable delay after every action (default 100ms).
// That default would silently more-than-double the duration of every
// setPosition and getPosition call. We drive cadence ourselves via
// config.stepDelay, so disable nut.js's implicit delay entirely.
//
// Setting this here (rather than at module load) keeps `keeper.ts` free
// of import-time side effects on the shared nut.js singleton — useful
// for tests and any future code path that imports this module without
// actually running the loop.
mouse.config.autoDelayMs = 0;
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 mouse.getPosition();
let lastPos: Point = await dev.getPosition();
let lastActivity: number = Date.now();
while (true) {
await sleep(config.checkInterval);
const pos: Point = await mouse.getPosition();
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) {
@@ -181,12 +111,18 @@ export async function runKeeper(config: Config): Promise<void> {
}
if (now - lastActivity >= config.moveInterval) {
await simulateActivity(config, log);
// `simulateActivity` either returns the cursor to its start
// (clean sweep) or leaves it where the user moved it (interrupt).
// Either way we reset the clock and require another full
// moveInterval of inactivity before firing again.
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();
}
}
}