Add loop mode (--loop): repeat movement until user activity
Introduce a continuous "loop" setting so a triggered sweep keeps the
cursor moving until the user moves the mouse (or Ctrl+C), instead of
firing a single sweep.
- strategies.ts: add optional `loopPath` to MovementStrategy; give `line`
and `diagonal` infinite loop generators that pick a direction once and
ramp forever (4px/step). Their finite `path` and declared `bounds` are
unchanged, so single-sweep behavior is identical.
- executor.ts: add ExecuteOptions { restore?, bounds?, loop? }. Omitting
options reproduces the original single-sweep contract exactly.
- keeper.ts: in loop mode, run an infinite loopPath once (stopped only by
interruption) or chain a finite path cycle after cycle; force `reflect`
bounds for every pattern and suppress the between-cycle restore, so
line/diagonal bounce edge-to-edge instead of stopping at the first edge.
- config plumbing: new boolean `loop` through config.default.json,
config.ts, configFile.ts, cli.ts (-l/--loop), and move.ts, mirroring
the existing `verbose` precedence.
- docs: README loop-mode section + usage/validation updates; CHANGELOG
Unreleased entry.
- tests: loopPath generators, executor options (bounds override, loop
selection, restore suppression), config/configFile loop plumbing, and
keeper-level loop behavior (ramps far vs. bounded single-sweep, chained
cycles). 79 pass.
This commit is contained in:
+13
@@ -19,6 +19,8 @@
|
||||
* -p, --pattern Movement strategy name (see strategies.ts).
|
||||
* -V, --verbose Enable per-sweep / interrupt / bounds logging.
|
||||
* (`-V` capital because `-v` is `--version`.)
|
||||
* -l, --loop Loop mode: once triggered, keep moving
|
||||
* until the user moves the mouse (or Ctrl+C).
|
||||
*
|
||||
* Numeric overrides are layered (CLI > file > DEFAULT_CONFIG) by
|
||||
* `resolveConfig` in `config.ts`; this module only parses and validates.
|
||||
@@ -55,6 +57,11 @@ export interface ParsedCliArgs {
|
||||
* even though the CLI has no off-switch today.
|
||||
*/
|
||||
verbose: boolean | undefined;
|
||||
/**
|
||||
* `true` when `-l`/`--loop` was passed; `undefined` when it was not.
|
||||
* Same `undefined`-not-`false` rationale as `verbose`.
|
||||
*/
|
||||
loop: boolean | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -105,6 +112,7 @@ export function parseCliArgs(): ParsedCliArgs {
|
||||
"step-delay": { type: "string", short: "d" },
|
||||
pattern: { type: "string", short: "p" },
|
||||
verbose: { type: "boolean", short: "V" },
|
||||
loop: { type: "boolean", short: "l" },
|
||||
},
|
||||
strict: true,
|
||||
allowPositionals: false,
|
||||
@@ -127,6 +135,7 @@ export function parseCliArgs(): ParsedCliArgs {
|
||||
stepDelay: parsePositiveNumber("step-delay", values["step-delay"] as string | undefined),
|
||||
pattern: parsePatternName(values.pattern as string | undefined),
|
||||
verbose: values.verbose === true ? true : undefined,
|
||||
loop: values.loop === true ? true : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -173,6 +182,9 @@ Options:
|
||||
Each pattern defines its own size and speed.
|
||||
-V, --verbose Log every sweep, interrupt, and bounds event
|
||||
(default prints only the startup banner).
|
||||
-l, --loop Loop mode: once a sweep is triggered,
|
||||
keep moving until you move the mouse (or
|
||||
Ctrl+C), instead of firing a single sweep.
|
||||
|
||||
Precedence (highest wins): CLI flags > config file > built-in defaults.
|
||||
|
||||
@@ -181,6 +193,7 @@ Examples:
|
||||
move --move-interval 180 --check-interval 5
|
||||
move -m 300 -V
|
||||
move --pattern arc
|
||||
move --pattern diagonal --loop
|
||||
move --config ~/myprofile.json
|
||||
`);
|
||||
}
|
||||
|
||||
+14
-1
@@ -47,6 +47,10 @@ import seedRaw from "../scripts/config.default.json" with { type: "json" };
|
||||
* pattern owns its own size and step count.
|
||||
* - `verbose` — whether per-sweep / interrupt / bounds events are
|
||||
* logged. The startup banner is always printed.
|
||||
* - `loop` — loop mode: once a sweep is triggered, keep
|
||||
* repeating the movement until the user moves the mouse
|
||||
* (or Ctrl+C), rather than firing a single sweep. See
|
||||
* `keeper.ts` for how the pattern is repeated.
|
||||
*/
|
||||
export interface Config {
|
||||
readonly moveInterval: number;
|
||||
@@ -54,6 +58,7 @@ export interface Config {
|
||||
readonly stepDelay: number;
|
||||
readonly pattern: PatternName;
|
||||
readonly verbose: boolean;
|
||||
readonly loop: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -68,6 +73,7 @@ interface SeedShape {
|
||||
stepDelay: number; // milliseconds
|
||||
pattern: string; // strategy name
|
||||
verbose: boolean;
|
||||
loop: boolean;
|
||||
}
|
||||
|
||||
function assertSeedShape(raw: unknown): asserts raw is SeedShape {
|
||||
@@ -87,6 +93,9 @@ function assertSeedShape(raw: unknown): asserts raw is SeedShape {
|
||||
if (typeof r.verbose !== "boolean") {
|
||||
throw new Error(`scripts/config.default.json: 'verbose' must be a boolean (got ${JSON.stringify(r.verbose)})`);
|
||||
}
|
||||
if (typeof r.loop !== "boolean") {
|
||||
throw new Error(`scripts/config.default.json: 'loop' must be a boolean (got ${JSON.stringify(r.loop)})`);
|
||||
}
|
||||
}
|
||||
|
||||
assertSeedShape(seedRaw);
|
||||
@@ -105,6 +114,7 @@ export const DEFAULT_CONFIG: Config = {
|
||||
stepDelay: seed.stepDelay,
|
||||
pattern: seed.pattern,
|
||||
verbose: seed.verbose,
|
||||
loop: seed.loop,
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -125,7 +135,8 @@ export const DEFAULT_CONFIG: Config = {
|
||||
* was not passed and `true` when it was. There is no CLI off-switch
|
||||
* today, so CLI `false` doesn't occur — a file-set `verbose: true` cannot
|
||||
* be overridden back to false from the command line (see the Configuration
|
||||
* section of the README).
|
||||
* section of the README). `loop` behaves identically: `-l/--loop` sets it
|
||||
* `true`, and a file-set `loop: true` can't be switched off from the CLI.
|
||||
*/
|
||||
export interface ConfigOverrides {
|
||||
readonly moveInterval: number | undefined;
|
||||
@@ -133,6 +144,7 @@ export interface ConfigOverrides {
|
||||
readonly stepDelay: number | undefined;
|
||||
readonly pattern: string | undefined;
|
||||
readonly verbose: boolean | undefined;
|
||||
readonly loop: boolean | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -199,5 +211,6 @@ export function resolveConfig(file: ConfigOverrides | null, cli: ConfigOverrides
|
||||
stepDelay: pickRaw(cli.stepDelay, file?.stepDelay, DEFAULT_CONFIG.stepDelay),
|
||||
pattern: pickRaw(cli.pattern, file?.pattern, DEFAULT_CONFIG.pattern),
|
||||
verbose: pickRaw(cli.verbose, file?.verbose, DEFAULT_CONFIG.verbose),
|
||||
loop: pickRaw(cli.loop, file?.loop, DEFAULT_CONFIG.loop),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
* stepDelay number milliseconds, positive
|
||||
* pattern string a registered strategy name
|
||||
* verbose boolean
|
||||
* loop boolean
|
||||
*
|
||||
* Unknown keys, wrong types, and non-positive numerics are rejected with a
|
||||
* `CliError` so the entry point can exit 2 (user error) with a clear
|
||||
@@ -39,6 +40,7 @@ const ALLOWED_KEYS: ReadonlySet<string> = new Set<string>([
|
||||
"stepDelay",
|
||||
"pattern",
|
||||
"verbose",
|
||||
"loop",
|
||||
]);
|
||||
|
||||
/**
|
||||
@@ -173,5 +175,9 @@ export function loadConfigFile(explicitPath: string | undefined): ConfigOverride
|
||||
"verbose" in parsed
|
||||
? requireBoolean("verbose", parsed.verbose, path)
|
||||
: undefined,
|
||||
loop:
|
||||
"loop" in parsed
|
||||
? requireBoolean("loop", parsed.loop, path)
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
+44
-5
@@ -47,6 +47,31 @@ export interface Logger {
|
||||
*/
|
||||
export type SweepOutcome = "completed" | "interrupted" | "aborted";
|
||||
|
||||
/**
|
||||
* Per-call knobs for `executePath`. All optional; the defaults reproduce the
|
||||
* original single-sweep behavior exactly, so every existing caller and test
|
||||
* is unaffected.
|
||||
*
|
||||
* - `restore` — restore the cursor to `ctx.start` after a clean sweep.
|
||||
* Default `true`. Loop (`--loop`) mode passes `false`:
|
||||
* chained cycles must not snap back between iterations, and an
|
||||
* infinite `loopPath` never reaches the restore anyway.
|
||||
* - `bounds` — override the strategy's declared `BoundsPolicy`. Loop mode
|
||||
* forces `"reflect"` for every pattern so edge-seeking paths
|
||||
* bounce off the screen instead of aborting (`line`) or
|
||||
* sticking in a corner (`clamp`). Absent, the strategy's own
|
||||
* `bounds` is used, so single-sweep behavior is unchanged.
|
||||
* - `loop` — prefer the strategy's infinite `loopPath` when it defines
|
||||
* one. Falls back to `path` when the strategy has no
|
||||
* `loopPath`, so a plain chained-repeat caller can pass this
|
||||
* unconditionally.
|
||||
*/
|
||||
export interface ExecuteOptions {
|
||||
readonly restore?: boolean;
|
||||
readonly bounds?: BoundsPolicy;
|
||||
readonly loop?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Slack, in pixels, allowed between the coordinate we commanded and the one
|
||||
* we read back before calling it real-user activity. Absorbs the sub-pixel
|
||||
@@ -145,7 +170,15 @@ function timestamp(): string {
|
||||
* user moved it: return `interrupted` without restoring.
|
||||
*
|
||||
* On a clean run the cursor is restored to `ctx.start` so the next
|
||||
* idle-check sees no net movement, and `completed` is returned.
|
||||
* idle-check sees no net movement, and `completed` is returned — unless
|
||||
* `options.restore === false` (loop mode), in which case the cursor is
|
||||
* left where the last step put it.
|
||||
*
|
||||
* `options` (all optional, see `ExecuteOptions`) let loop mode reuse
|
||||
* this same driver: `bounds` overrides the strategy's policy (loop mode
|
||||
* forces `reflect`), `loop` selects the strategy's infinite `loopPath`, and
|
||||
* `restore` suppresses the snap-back. Omitting `options` reproduces the
|
||||
* original single-sweep contract exactly.
|
||||
*
|
||||
* `config` supplies only the pacing (`stepDelay`); a strategy's geometry is
|
||||
* entirely self-contained, so the path itself needs nothing from it.
|
||||
@@ -156,13 +189,17 @@ export async function executePath(
|
||||
device: Device,
|
||||
log: Logger,
|
||||
config: Config,
|
||||
options?: ExecuteOptions,
|
||||
): Promise<SweepOutcome> {
|
||||
const { start, width, height } = ctx;
|
||||
const policy: BoundsPolicy = options?.bounds ?? strategy.bounds;
|
||||
const path: Iterable<Point> =
|
||||
options?.loop && strategy.loopPath ? strategy.loopPath(ctx) : strategy.path(ctx);
|
||||
|
||||
log.event(`Simulating activity (${strategy.name}) at ${timestamp()}...`);
|
||||
|
||||
for (const target of strategy.path(ctx)) {
|
||||
const point: Point | null = resolveTarget(strategy.bounds, target, width, height);
|
||||
for (const target of path) {
|
||||
const point: Point | null = resolveTarget(policy, target, width, height);
|
||||
if (point === null) {
|
||||
log.event(`Out of bounds at ${timestamp()}; aborting simulation.`);
|
||||
return "aborted";
|
||||
@@ -191,7 +228,9 @@ export async function executePath(
|
||||
}
|
||||
}
|
||||
|
||||
await device.setPosition({ x: Math.round(start.x), y: Math.round(start.y) });
|
||||
log.event("Mouse moved.");
|
||||
if (options?.restore !== false) {
|
||||
await device.setPosition({ x: Math.round(start.x), y: Math.round(start.y) });
|
||||
log.event("Mouse moved.");
|
||||
}
|
||||
return "completed";
|
||||
}
|
||||
|
||||
+48
-12
@@ -24,7 +24,7 @@
|
||||
*/
|
||||
|
||||
import { createNutDevice, type Device, type Point } from "./device.ts";
|
||||
import { executePath, type Logger } from "./executor.ts";
|
||||
import { executePath, type Logger, type SweepOutcome } from "./executor.ts";
|
||||
import { DEFAULT_PATTERN, STRATEGIES, type MoveContext } from "./strategies.ts";
|
||||
|
||||
import type { Config } from "./config.ts";
|
||||
@@ -46,24 +46,60 @@ function makeLogger(verbose: boolean): Logger {
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform a single synthetic mouse-activity sweep.
|
||||
* Perform synthetic mouse activity once the keeper decides the cursor is
|
||||
* idle.
|
||||
*
|
||||
* 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.
|
||||
* 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.
|
||||
*
|
||||
* Single-sweep mode (`config.loop === false`) runs exactly one sweep via
|
||||
* `executePath`, which owns bounds, 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). Two things change for every pattern:
|
||||
* the cursor is never restored between iterations (`restore: false`), and the
|
||||
* bounds policy is forced to `reflect` so edge-seeking paths bounce off the
|
||||
* screen instead of aborting (`line`) or sticking in a corner (`clamp`).
|
||||
* 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): 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);
|
||||
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, bounds: "reflect" as const, 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}.`);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -121,6 +121,7 @@ const cliOverrides: ConfigOverrides = {
|
||||
stepDelay: cliArgs.stepDelay,
|
||||
pattern: cliArgs.pattern,
|
||||
verbose: cliArgs.verbose,
|
||||
loop: cliArgs.loop,
|
||||
};
|
||||
|
||||
const config = resolveConfig(fileOverrides, cliOverrides);
|
||||
|
||||
+51
-5
@@ -57,16 +57,31 @@ export interface MoveContext {
|
||||
/**
|
||||
* A named movement pattern.
|
||||
*
|
||||
* - `name` — registry key, also the value accepted by `--pattern` / the
|
||||
* `pattern` config key.
|
||||
* - `bounds` — how the executor confines this pattern to the screen.
|
||||
* - `path` — pure generator of ideal (possibly fractional) targets,
|
||||
* emitted in visiting order. Should not re-emit `start`.
|
||||
* - `name` — registry key, also the value accepted by `--pattern` / the
|
||||
* `pattern` config key.
|
||||
* - `bounds` — how the executor confines this pattern to the screen.
|
||||
* - `path` — pure generator of ideal (possibly fractional) targets,
|
||||
* emitted in visiting order. Should not re-emit `start`.
|
||||
* - `loopPath` — optional infinite variant for loop mode (`--loop`).
|
||||
* A pattern defines it when its finite `path` doesn't chain
|
||||
* cleanly under repetition: `line`/`diagonal` re-derive their
|
||||
* direction from the cursor's position every cycle, so chained
|
||||
* repetition oscillates in a band near an edge instead of
|
||||
* crossing the screen. An infinite generator picks its
|
||||
* direction once and ramps forever; the executor's `reflect`
|
||||
* policy (forced on in loop mode) folds the monotonic ramp
|
||||
* into an edge-to-edge bounce. Absent this, loop mode simply
|
||||
* chains `path` — correct for patterns whose finite path is a
|
||||
* self-contained cyclic unit (`jitter`, `walk`, `arc`,
|
||||
* `figureEight`). The executor stops either kind on real user
|
||||
* activity; an infinite `loopPath` therefore only ever ends
|
||||
* by interruption.
|
||||
*/
|
||||
export interface MovementStrategy {
|
||||
readonly name: string;
|
||||
readonly bounds: BoundsPolicy;
|
||||
path(ctx: MoveContext): Iterable<Point>;
|
||||
loopPath?(ctx: MoveContext): Iterable<Point>;
|
||||
}
|
||||
|
||||
/** Clamp `v` into the inclusive pixel range `[0, max - 1]`. */
|
||||
@@ -84,8 +99,14 @@ function clamp(v: number, max: number): number {
|
||||
* vertical movement. 250 one-pixel steps is byte-for-byte the sweep the
|
||||
* keeper produced before movement patterns existed, which is why its bounds
|
||||
* policy is `abort` (the direction choice guarantees it never triggers).
|
||||
*
|
||||
* In loop mode `loopPath` ramps x in one direction forever (loop mode
|
||||
* forces `reflect`, so the direction never matters and the ramp bounces edge
|
||||
* to edge). `LINE_LOOP_STEP` is several pixels per step rather than one so a
|
||||
* screen crossing takes seconds, not minutes, at the default cadence.
|
||||
*/
|
||||
const LINE_STEPS = 250;
|
||||
const LINE_LOOP_STEP = 4;
|
||||
|
||||
export const line: MovementStrategy = {
|
||||
name: "line",
|
||||
@@ -97,6 +118,14 @@ export const line: MovementStrategy = {
|
||||
yield { x: start.x + i * dx, y: start.y };
|
||||
}
|
||||
},
|
||||
*loopPath(ctx: MoveContext): Generator<Point> {
|
||||
const { start } = ctx;
|
||||
let x: number = start.x;
|
||||
for (;;) {
|
||||
x += LINE_LOOP_STEP;
|
||||
yield { x, y: start.y };
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -104,8 +133,15 @@ export const line: MovementStrategy = {
|
||||
* chosen independently by available room, so the sweep heads toward the
|
||||
* roomiest corner and stays on-screen. 250 single-pixel steps per axis
|
||||
* (≈250px reach), matching `line`'s magnitude.
|
||||
*
|
||||
* In loop mode `loopPath` ramps both axes forever under the forced
|
||||
* `reflect` policy. Because the x and y travel ranges have different spans,
|
||||
* their triangle waves have different periods, so the path precesses across
|
||||
* the whole screen — the roaming-DVD bounce — rather than retracing one 45°
|
||||
* line.
|
||||
*/
|
||||
const DIAGONAL_STEPS = 250;
|
||||
const DIAGONAL_LOOP_STEP = 4;
|
||||
|
||||
export const diagonal: MovementStrategy = {
|
||||
name: "diagonal",
|
||||
@@ -118,6 +154,16 @@ export const diagonal: MovementStrategy = {
|
||||
yield { x: start.x + i * dx, y: start.y + i * dy };
|
||||
}
|
||||
},
|
||||
*loopPath(ctx: MoveContext): Generator<Point> {
|
||||
const { start } = ctx;
|
||||
let x: number = start.x;
|
||||
let y: number = start.y;
|
||||
for (;;) {
|
||||
x += DIAGONAL_LOOP_STEP;
|
||||
y += DIAGONAL_LOOP_STEP;
|
||||
yield { x, y };
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user