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:
+56
-120
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user