9 Commits
Author SHA1 Message Date
nokeo08 b019f25a42 Release 1.4.0 2026-08-17 16:15:44 -05:00
nokeo08 c8942bb380 Collapse bounds policies to reflect-only; drop abort and clamp
The executor kept every commanded point on-screen via a per-strategy
BoundsPolicy of abort / clamp / reflect. Measured against the real
strategies, the other two earned nothing: abort truncated a sweep at the
first edge (line on a narrow screen ran only 90 of 250 steps), and clamp
could park the cursor against an edge (a monotonic ramp stalled 162 steps
in a row) -- both counter to the program's whole purpose of keeping the
cursor moving. reflect bounces off the edge and keeps going, and is
already what line/diagonal need in loop mode. arc's declared clamp was
provably dead code (it clamps its own endpoint, so no sample ever leaves
the screen).

Collapse to reflect-only:
- strategies.ts: remove the BoundsPolicy type and the `bounds` field from
  the interface and all six strategies. Keep the local clamp() helper --
  it's arc's endpoint geometry, not an on-screen policy; docstring says so.
- executor.ts: resolveTarget loses its policy parameter and its null
  return and just reflects both axes; delete clampInt; SweepOutcome drops
  "aborted"; ExecuteOptions drops `bounds`; remove the Out of bounds log.
- keeper.ts: loopOpts is now { restore: false, loop: true } -- the
  reflect override added with loop mode is redundant.
- tests: drop the abort-outcome, clamp, and bounds-override tests; simplify
  fixed() to take no policy; add a regression test that a monotonic ramp
  past an edge never yields two identical points in a row (the guarantee
  that motivated removing clamp).

Behavior is unchanged for every pattern at normal cursor positions
(verified: line's normal sweep is byte-identical). The only differences
are at a screen edge, where motion now bounces instead of stopping. No
config keys, flags, or pattern names changed.

Docs updated to match, including in-code comments, the README strategies
table (Bounds column removed) and verbose description, the sequence
diagram (resolveTarget signature + getPosition/width ordering + a loop-mode
note), and a CHANGELOG Changed entry.
2026-08-17 15:53:49 -05:00
nokeo08 7e632b3e9d 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.
2026-08-17 14:36:18 -05:00
nokeo08 1ad724cd33 Release 1.3.3
install.sh now installs the newest published tag by default instead of
tracking the master branch, so 'curl ... | sh' installs a real release and
reports its version. The tag is resolved from the Gitea tags API, falling
back to master if the lookup fails. MOVE_VERSION still pins an explicit ref.
2026-08-17 10:58:26 -05:00
nokeo08 b9669269bc Release 1.3.2
Interactive installer: prompt before replacing an existing install or
overwriting an existing config, reading answers from /dev/tty so it works
under 'curl ... | sh'. Falls back to the prior non-interactive contract
when no terminal is available. MOVE_FORCE=1 skips all prompts; new
MOVE_RESEED_CONFIG=1 reseeds the config unattended (old file kept as .bak).

Fix: install.sh no longer wipes a working install before downloading. The
tree is built in a staging dir and swapped into place only once complete,
so a failed download/extract/build leaves the existing install intact.
2026-08-17 09:28:13 -05:00
nokeo08 68e64eeaef Release 1.3.1 2026-08-14 15:09:59 -05:00
nokeo08 d0528b4a92 docs: fix mermaid parse error in happy-path diagram
Mermaid treats ';' as a statement separator, so the label
'lastPos (== start; re-sync)' terminated mid-line and the parser then
expected an arrow (the 'Parse error on line 59' the preview showed).
Removed the semicolon and, defensively, replaced 'Iterable<Point>' with
plain text so the angle brackets aren't interpreted as an HTML tag in the
rendered label.
2026-08-14 13:50:21 -05:00
nokeo08 c002a6d902 Fix stale comments to match current code
Audited comments project-wide against the current implementation:

- scripts/install.sh, scripts/uninstall.sh: header curl URLs pointed at a
  root-level install.sh/uninstall.sh, but the files live under scripts/ —
  the documented command 404'd. Corrected to the scripts/ path (matching
  the README and the actual file location).
- src/move.ts: header said it ties "four logic modules" and omitted
  editor.ts; the --edit terminal action was also missing from the order of
  operations. Both corrected.
- src/config.ts: numeric Config fields are all milliseconds now; dropped the
  stale "pixels" unit left over from stepCount/stepSize.

Comments-only (plus two script header lines); tsc clean, 64 tests pass.
2026-08-14 13:39:15 -05:00
nokeo08 41903ebaf1 docs: add execution happy-path sequence diagram 2026-08-14 13:28:43 -05:00
19 changed files with 956 additions and 248 deletions
+78
View File
@@ -5,6 +5,80 @@ All notable changes to `move` are documented here.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [1.4.0] - 2026-08-17
### Added
- Loop mode: `-l` / `--loop` (and the `loop` config key) keep the mouse
moving after a sweep is triggered until real user activity is detected,
instead of firing a single sweep. In loop mode the cursor is never restored
between iterations, so `line` and `diagonal` bounce edge-to-edge across the
screen (a roaming-DVD effect) rather than stopping at the first edge.
Patterns with a finite path (`jitter`, `walk`, `arc`, `figureEight`) chain
that path cycle after cycle. Interruption remains mouse-movement only.
### Changed
- Simplified on-screen confinement to a single policy: the executor now
reflects every pattern's out-of-range coordinates back inside the screen.
The `abort` and `clamp` bounds policies (and the per-strategy `bounds`
field) were removed. `abort` truncated a sweep at the first edge and `clamp`
could park the cursor against an edge — both counter to keeping the cursor
moving — while `reflect` bounces and keeps going. Behavior is unchanged for
every pattern at normal cursor positions; the only differences are at a
screen edge, where motion now bounces instead of stopping. No config keys,
flags, or pattern names changed.
## [1.3.3] - 2026-08-17
### Changed
- `install.sh` now installs the newest published tag by default instead of
tracking the `master` branch, so the plain `curl ... | sh` one-liner
installs a real release and reports its version (e.g. `v1.3.2`). The tag is
resolved from the Gitea tags API; if that lookup fails (offline, API
unreachable, or no tags yet) it falls back to `master`, preserving the old
behavior. Set `MOVE_VERSION` to pin an explicit branch or tag as before.
## [1.3.2] - 2026-08-17
### Added
- `install.sh` is now interactive. When it finds an existing install and a
controlling terminal is available, it reports what's there and asks before
replacing it, instead of leaving `MOVE_FORCE` as the only control. If a
config file already exists it asks separately whether to reseed it from the
shipped defaults. Both questions are asked before anything is downloaded or
deleted, so declining changes nothing.
- `MOVE_RESEED_CONFIG=1` overwrites the user config with the shipped defaults
without prompting, for unattended use. The previous file is kept as
`config.json.bak`; the same backup is written when reseeding is confirmed
at the prompt.
### Changed
- `MOVE_FORCE=1` now means "skip every prompt and reinstall unconditionally".
It deliberately does not touch the user config, so automation that
reinstalls the CLI can't take customizations down with it.
- Existing-install detection looks for the install tree and the wrapper, not
just the `.installed-version` marker, so a half-finished or hand-moved
install is caught rather than silently overwritten.
### Fixed
- Installing a *different* version over an existing one used to wipe and
replace it with no warning; only an exact version match was ever reported.
That case now prompts. With no terminal (CI, cron, container builds) the
previous non-interactive behavior is preserved exactly: an identical
version is a no-op, a different version is replaced.
## [1.3.1] - 2026-08-14
### Added
- `docs/execution-happy-path.md`: a sequence diagram (plus invariants) tracing
a clean idle-triggered sweep end to end, linked from the README.
### Fixed
- Stale comments corrected to match the current code: the `install.sh` /
`uninstall.sh` header curl URLs pointed at a nonexistent repo-root path
(they live under `scripts/`), so the documented command 404'd; `move.ts`'s
module list omitted `editor.ts` and the `--edit` step; and `config.ts` still
described a removed pixel unit.
## [1.3.0] - 2026-08-14
### Added
@@ -113,6 +187,10 @@ Initial release.
- Source split into `src/{move,cli,config,keeper}.ts`.
- `bin` entry + shebang so `bun link` registers `move` globally.
[1.4.0]: https://gitea.cahlen.com/nokeo08/Move/compare/v1.3.3...v1.4.0
[1.3.3]: https://gitea.cahlen.com/nokeo08/Move/compare/v1.3.2...v1.3.3
[1.3.2]: https://gitea.cahlen.com/nokeo08/Move/compare/v1.3.1...v1.3.2
[1.3.1]: https://gitea.cahlen.com/nokeo08/Move/compare/v1.3.0...v1.3.1
[1.3.0]: https://gitea.cahlen.com/nokeo08/Move/compare/v1.2.0...v1.3.0
[1.2.0]: https://gitea.cahlen.com/nokeo08/Move/compare/v1.1.1...v1.2.0
[1.1.1]: https://gitea.cahlen.com/nokeo08/Move/compare/v1.1.0...v1.1.1
+106 -38
View File
@@ -23,8 +23,11 @@ cursor leaves the position the script just commanded.
curl -fsSL https://gitea.cahlen.com/nokeo08/Move/raw/branch/master/scripts/install.sh | sh
```
This fetches the latest `master` from Gitea, runs `bun install --production`
under the install dir, and drops a `move` wrapper on your bin dir.
This installs the latest tagged release from Gitea (the installer resolves
it automatically), runs `bun install --production` under the install dir,
and drops a `move` wrapper on your bin dir. If the latest tag can't be
determined — offline, or the API is unreachable — it falls back to the
`master` branch. Pin an exact ref with `MOVE_VERSION` (see below).
The installer respects the XDG Base Directory Specification:
@@ -33,14 +36,38 @@ The installer respects the XDG Base Directory Specification:
`XDG_BIN_HOME` is the widely-recognized de facto convention; XDG itself
doesn't standardize a user bin dir.
### Reinstalling over an existing install
The installer never replaces an existing install silently. When it finds
one and it can reach a terminal, it tells you what's there and asks:
```
==> Found an existing move install (v1.2.0) at /home/you/.local/share/move
Replace it with v1.3.2? [Y/n]
```
If a config file already exists, it asks separately whether to overwrite
it with the shipped defaults (default: no). Both questions come *before*
anything is downloaded or deleted, so declining costs you nothing.
This works under `curl ... | sh` too: the prompts read from `/dev/tty`
rather than stdin, which the piped script itself occupies.
With no terminal available — CI, cron, a container build — there's nobody
to ask, so the installer falls back to its long-standing behavior: an
identical version is a no-op, a different version is replaced, and your
config is left alone. Use the env vars below to drive it explicitly.
Env vars (all optional):
| Var | Default | Purpose |
| --- | ------- | ------- |
| `MOVE_VERSION` | `master` | Branch or tag to install. Pin with e.g. `v1.0.0`. |
| `MOVE_FORCE` | unset | Set to `1` to reinstall when the same version is already present. |
| `MOVE_VERSION` | latest tag | Branch or tag to install; auto-resolves to the newest tag, falling back to `master`. Pin with e.g. `v1.0.0`. |
| `MOVE_FORCE` | unset | Set to `1` to skip every prompt and reinstall unconditionally. Never touches your config. |
| `MOVE_RESEED_CONFIG` | unset | Set to `1` to overwrite your config with the shipped defaults without asking. The old file is kept as `config.json.bak`. |
| `XDG_DATA_HOME` | `$HOME/.local/share` | Where the source tree is installed (under `move/`). |
| `XDG_BIN_HOME` | `$HOME/.local/bin` | Where the `move` wrapper is placed. |
| `XDG_CONFIG_HOME` | `$HOME/.config` | Where the config file lives (under `move/`). |
Bun must already be installed; the installer fails with a clear pointer
to <https://bun.sh> if it isn't.
@@ -94,8 +121,11 @@ Options:
One of: line, diagonal, jitter, walk, arc,
figureEight. Each pattern defines its own
size and speed.
-V, --verbose Log every sweep, interrupt, and bounds event
-V, --verbose Log every sweep and interrupt
(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.
```
@@ -108,8 +138,8 @@ internally.
Logging is **quiet by default**: only the startup banner ("Teams Status
Keeper started…") and any error from an unhandled rejection print on a
default run. `-V` / `--verbose` opens up per-sweep, user-interrupt, and
out-of-bounds events.
default run. `-V` / `--verbose` opens up per-sweep and user-interrupt
events.
Invalid input (unknown flag, missing value, non-positive number) prints an
error to `stderr` and exits with code `2`.
@@ -123,11 +153,14 @@ ${XDG_CONFIG_HOME:-$HOME/.config}/move/config.json
```
The installer seeds this file with the default values on a fresh install,
**only if no file already exists at that path**. Existing configs yours
or from a previous install — are never overwritten. If you remove the
file later, `move` still works: missing defaults fall back to the values
baked into the binary (which match what was seeded, since both come from
`scripts/config.default.json`).
**only if no file already exists at that path**. An existing config —
yours or from a previous install — is never overwritten silently: the
installer asks first, and replaces it only if you say yes (or if you set
`MOVE_RESEED_CONFIG=1`), keeping the old file as `config.json.bak` either
way. `MOVE_FORCE=1` reinstalls the software but leaves your config alone.
If you remove the file later, `move` still works: missing defaults fall
back to the values baked into the binary (which match what was seeded,
since both come from `scripts/config.default.json`).
Pass `-C` / `--config <path>` to point at a different file; in that mode
the file must exist.
@@ -150,14 +183,15 @@ doesn't set.
"checkInterval": 10,
"stepDelay": 50,
"pattern": "line",
"verbose": false
"verbose": false,
"loop": false
}
```
All keys are optional; supply only the ones you want to override. Keys
and units mirror the CLI flags exactly: `moveInterval` and
`checkInterval` are seconds, `stepDelay` is milliseconds, `pattern` is a
movement strategy name, `verbose` is a boolean.
movement strategy name, `verbose` and `loop` are booleans.
> The obsolete `stepCount` / `stepSize` keys (removed in 1.3.0) are
> tolerated for backward compatibility: they're ignored with a one-line
@@ -193,19 +227,42 @@ The loader is strict:
case and separators (`-`, `_`, spaces), so `figure-eight` and `figureEight`
are equivalent.
- `verbose` must be a boolean.
- `loop` must be a boolean.
Any validation failure prints a message naming the file and the offending
key to `stderr` and exits `2`.
### Known limitation: `verbose` can be turned on but not off from the CLI
### Loop mode (`--loop`)
`--verbose` is a presence-only flag (there is no `--no-verbose`). If the
config file sets `"verbose": true`, the CLI cannot force quiet mode in
that invocation. Workarounds: edit the file, or point at a different
file with `--config`.
By default a triggered sweep runs once and stops. With `-l` / `--loop` (or
`"loop": true` in the config file) the movement instead repeats until you
move the mouse (or press `Ctrl+C`) — a "keep moving until I'm back" mode.
It pairs naturally with the roaming patterns:
```sh
move --pattern diagonal --loop # roaming-DVD bounce around the screen
move --pattern figureEight --loop # traces the eight over and over
```
In loop mode the cursor is never restored between iterations, so `line` and
`diagonal` bounce edge-to-edge across the whole screen (the executor keeps
every pattern on-screen by reflecting off the edges) instead of ending at
the first edge. Interruption is detected via mouse movement only — there is
no keyboard hook — so if you resume by typing without touching the mouse,
the cursor keeps cycling until you nudge it or stop the process.
### Known limitation: `verbose` and `loop` can be turned on but not off from the CLI
`--verbose` and `--loop` are presence-only flags (there is no
`--no-verbose` / `--no-loop`). If the config file sets `"verbose": true` or
`"loop": true`, the CLI cannot force it back off in that invocation.
Workarounds: edit the file, or point at a different file with `--config`.
## How it works
For a step-by-step trace of a clean sweep, see the
[execution happy-path sequence diagram](docs/execution-happy-path.md).
The source lives under `src/`, split into an entry point plus logic
modules:
@@ -232,8 +289,8 @@ and everything but the raw nut.js call is unit-testable:
of target points given a start, screen size, config, and RNG — plus the
registry and name validation. Adding a pattern is one pure function.
- `src/executor.ts` is the single `executePath` driver: it rounds targets,
applies the strategy's bounds policy, paces steps, detects real-user
interruption, and restores the cursor on a clean sweep.
reflects any off-screen coordinate back inside, paces steps, detects
real-user interruption, and restores the cursor on a clean sweep.
Defaults live in `src/config.ts` as `DEFAULT_CONFIG`:
@@ -264,8 +321,8 @@ to milliseconds before handing the resolved `Config` to `runKeeper`.
up `config.pattern` in the strategy registry, and builds a `MoveContext`.
2. It hands the strategy and context to `executePath`, which drives the
sweep. For each target the strategy yields:
- Round to whole pixels and apply the strategy's bounds policy
(`abort` / `clamp` / `reflect`) to keep it on-screen.
- Round to whole pixels and reflect any off-screen coordinate back inside
the travel range, so the cursor bounces off the edges and keeps moving.
- Move the cursor there, sleep `config.stepDelay`.
- Re-read the cursor. If it isn't at the point we *just commanded*, the
user moved it — log (when `--verbose`) and return early without
@@ -274,34 +331,44 @@ to milliseconds before handing the resolved `Config` to `runKeeper`.
the next idle-check sees "no movement" and doesn't misread the synthetic
activity as real user input.
In loop mode (`--loop`) step 2 repeats until the user interrupts: a
pattern with an infinite `loopPath` (`line`, `diagonal`) runs that single
never-ending path, while the others chain their finite path cycle after
cycle. The restore in step 3 is skipped so successive cycles flow from where
the last left off.
Comparing against the last commanded (rounded) point — not the strategy's
ideal, possibly fractional target — is what lets curved and stochastic
patterns run without every rounded step looking like user activity. The
comparison also allows a small (2px) tolerance, and the `clamp`/`reflect`
patterns stay a couple of pixels off the screen edge, so sub-pixel cursor
placement on scaled or multi-monitor displays isn't misread as the user
grabbing the mouse. `line` uses the `abort` policy and is unaffected.
comparison also allows a small (2px) tolerance, and the travel range stays a
couple of pixels off the screen edge, so sub-pixel cursor placement on scaled
or multi-monitor displays isn't misread as the user grabbing the mouse.
### Movement strategies
`config.pattern` selects one of the generators in `src/strategies.ts`:
| Name | Motion | Steps | Size | Bounds |
| ------------- | ------------------------------------------------------------- | ----- | -------- | --------- |
| `line` | Straight horizontal sweep (the original behavior). | 250 | 250px | `abort` |
| `diagonal` | Straight line on both axes toward the roomiest corner. | 250 | 250px/axis | `clamp` |
| `jitter` | Small random hops within a tight radius of the start. | 80 | 30px radius | `clamp` |
| `walk` | Cumulative random walk; bounces off the screen edges. | 200 | ±4px/step | `reflect` |
| `arc` | Smooth quadratic-Bézier curve to a random on-screen point. | 120 | ~300px | `clamp` |
| `figureEight` | Traces a figure-eight (lemniscate) and returns to the start. | 90 | ~250px wide | `clamp` |
| Name | Motion | Steps | Size |
| ------------- | ------------------------------------------------------------- | ----- | ----------- |
| `line` | Straight horizontal sweep (the original behavior). | 250 | 250px |
| `diagonal` | Straight line on both axes toward the roomiest corner. | 250 | 250px/axis |
| `jitter` | Small random hops within a tight radius of the start. | 80 | 30px radius |
| `walk` | Cumulative random walk; bounces off the screen edges. | 200 | ±4px/step |
| `arc` | Smooth quadratic-Bézier curve to a random on-screen point. | 120 | ~300px |
| `figureEight` | Traces a figure-eight (lemniscate) and returns to the start. | 90 | ~250px wide |
Every pattern is kept on-screen the same way: the executor reflects any
coordinate that would fall past a screen edge back inside, so motion bounces
instead of stopping. Strategies therefore never bound their own output —
they emit ideal geometry and let the executor confine it.
Each pattern owns its geometry — how many steps it takes and how far it
reaches — as constants in `src/strategies.ts`. Those are properties of the
pattern, not user preferences, so there is no knob for sweep size or step
count; `stepDelay` (the per-step pause) is the only pacing lever, and it
scales every pattern's total duration. To add a pattern, write one pure
generator and register it — the executor supplies bounds, pacing, interrupt,
and restore for free.
generator and register it — the executor supplies on-screen reflection,
pacing, interrupt, and restore for free.
### Why `mouse.config.autoDelayMs = 0`
@@ -357,7 +424,8 @@ move --help
| `src/keeper.ts` | Idle-watch loop + per-sweep glue (selects a strategy, calls the executor). |
| `src/device.ts` | `Device` I/O seam over nut.js (`Point`, `createNutDevice`); the only nut.js importer. |
| `src/strategies.ts` | Pure movement-pattern generators, the strategy registry, and name validation. |
| `src/executor.ts` | `executePath` driver: bounds policy, pacing, interrupt detection, restore. |
| `src/executor.ts` | `executePath` driver: on-screen reflection, pacing, interrupt detection, restore. |
| `docs/execution-happy-path.md` | Sequence diagram + invariants for a clean sweep. |
| `package.json` | Bun project manifest. Single runtime dep: `@nut-tree-fork/nut-js`. |
| `tsconfig.json` | Strict TypeScript config tuned for Bun (ESNext, bundler resolution). |
| `bun.lock` | Bun's lockfile. Commit this. |
+103
View File
@@ -0,0 +1,103 @@
# Execution: the happy path
This traces one full idle-triggered sweep that completes cleanly — the
"happy path" where the machine is idle long enough to fire, the configured
pattern runs to exhaustion, and no real user activity interrupts it.
For the module breakdown and the three seams (`device` / `strategies` /
`executor`), see the "How it works" section of the [README](../README.md).
```mermaid
sequenceDiagram
autonumber
participant Entry as move.ts
participant Keeper as runKeeper
participant Sim as simulateActivity
participant Strat as Strategy<br/>(e.g. line)
participant Exec as executePath
participant Dev as Device<br/>(nut.js)
Note over Entry: startup (args → config)
Entry->>Entry: parseCliArgs()
Entry->>Entry: loadConfigFile()
Entry->>Entry: resolveConfig(file, cli)
Entry->>Keeper: runKeeper(config)
Keeper->>Dev: createNutDevice()
Note right of Dev: sets mouse.config.autoDelayMs = 0
Keeper->>Keeper: log.info(banner)
Keeper->>Dev: getPosition()
Dev-->>Keeper: lastPos
Note over Keeper: lastActivity = now
loop every checkInterval (until idle long enough)
Keeper->>Dev: sleep(checkInterval)
Keeper->>Dev: getPosition()
Dev-->>Keeper: pos
Note over Keeper: pos == lastPos (no user movement)<br/>now - lastActivity ≥ moveInterval → fire
end
Keeper->>Sim: simulateActivity(config, log, dev)
Sim->>Dev: width()
Dev-->>Sim: width
Sim->>Dev: height()
Dev-->>Sim: height
Sim->>Dev: getPosition()
Dev-->>Sim: start
Note over Sim: strategy = STRATEGIES[config.pattern]<br/>ctx = { start, width, height, rng }
Sim->>Exec: executePath(strategy, ctx, dev, log, config)
Exec->>Strat: path(ctx)
Strat-->>Exec: iterable of Points
loop for each target point (clean run)
Exec->>Exec: resolveTarget(target) → point (reflected on-screen)
Exec->>Dev: setPosition(point)
Exec->>Dev: sleep(stepDelay)
Exec->>Dev: getPosition()
Dev-->>Exec: current
Note over Exec: |current - point| ≤ 2px → not the user, continue
end
Note over Exec: path exhausted, no interruption
Exec->>Dev: setPosition(round(start))
Note right of Exec: restore cursor to origin
Exec-->>Sim: "completed"
Sim-->>Keeper: (done)
Keeper->>Dev: getPosition()
Dev-->>Keeper: lastPos (equals start, re-synced)
Note over Keeper: lastActivity = now<br/>loop continues
```
## Invariants this path relies on
- **`createNutDevice()` is the only nut.js touchpoint.** It disables nut.js's
100ms auto-delay so `executePath` owns cadence via `stepDelay`.
- **The strategy is pure.** `path(ctx)` yields ideal points from geometry
alone (`start` / `width` / `height` / `rng`); it never touches the device,
which is what makes every pattern unit-testable without a screen.
- **Every step re-reads the cursor** and compares it against the *commanded*
point (not the strategy's ideal, possibly fractional target) within a 2px
tolerance. On the happy path each check passes, so the loop runs to
exhaustion. A mismatch beyond tolerance is real user activity and returns
`"interrupted"` without restoring — the branch this diagram omits.
- **Clean completion restores the cursor to `round(start)`.** That is why the
follow-up `getPosition()` in `runKeeper` re-syncs `lastPos` to the origin as
a no-op, and the next idle check sees no net movement (so the synthetic
sweep is never mistaken for the user returning).
- **On-screen confinement is uniform.** `resolveTarget` reflects any
coordinate past a screen edge back inside the travel range — the sole,
per-pattern-independent policy. A strategy emits ideal geometry and never
bounds its own output.
## Loop mode (`--loop`)
This diagram is the single-sweep path (`config.loop === false`). Under
`--loop`, `simulateActivity` instead repeats the step loop until the user
interrupts: a pattern with an infinite `loopPath` (`line`, `diagonal`) runs
that one never-ending path, while the others chain their finite `path` cycle
after cycle, re-reading the cursor as the next `start` each time. The restore
in the final step is skipped (`restore: false`), so successive cycles flow
from where the last left off. Everything else — reflection, pacing, and the
per-step interrupt check — is identical to the sweep traced above.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "move",
"version": "1.3.0",
"version": "1.4.0",
"private": true,
"license": "GPL-3.0-only",
"type": "module",
+2 -1
View File
@@ -3,5 +3,6 @@
"checkInterval": 10,
"stepDelay": 50,
"pattern": "line",
"verbose": false
"verbose": false,
"loop": false
}
+232 -36
View File
@@ -4,31 +4,49 @@
#
# Curl-pipe ready:
#
# curl -fsSL https://gitea.cahlen.com/nokeo08/Move/raw/branch/master/install.sh | sh
# curl -fsSL https://gitea.cahlen.com/nokeo08/Move/raw/branch/master/scripts/install.sh | sh
#
# What it does:
# 1. Detect platform; bail on anything @nut-tree-fork/nut-js doesn't ship.
# 2. Require Bun; fail with a clear hint if missing (no auto-install).
# 3. Resolve XDG-compliant install paths.
# 4. Idempotence check via a version marker file.
# 5. Download the source tarball from Gitea, extract under the install dir.
# 4. Detect an existing install and, on a terminal, ask before replacing it.
# 5. Download and build in a temp staging dir; swap it over the install
# dir only once it's complete, so a failed run can't destroy a working
# install.
# 6. `bun install --production` (skips devDependencies).
# 7. Drop a small wrapper script as `move` on the user's bin dir.
# 8. Seed the user's config file with defaults, only if one doesn't already
# exist at $XDG_CONFIG_HOME/move/config.json.
# 8. Seed the user's config file with defaults if one doesn't already exist
# at $XDG_CONFIG_HOME/move/config.json; if one does, offer to reseed it.
# 9. Verify PATH, surface macOS Accessibility hint, print final status.
#
# Interactivity:
# When a controlling terminal is available, an existing install is never
# replaced without asking, and an existing config is never overwritten
# without asking. Both questions are put up front, before anything is
# downloaded or deleted, so declining costs nothing. With no terminal
# (CI, cron, container build) the script falls back to its historical
# non-interactive contract: an identical version is a no-op, a different
# version is replaced, and the config is left alone.
#
# Env vars (all optional):
# MOVE_VERSION Branch or tag to install. Default: master.
# MOVE_FORCE Set to 1 to reinstall even if the version marker matches.
# MOVE_VERSION Branch or tag to install. Default: the newest tag
# published to the repo, falling back to the master
# branch if that can't be determined.
# MOVE_FORCE Set to 1 to skip every prompt and reinstall
# unconditionally. Does not touch the user config.
# MOVE_RESEED_CONFIG Set to 1 to overwrite the user config with the
# shipped defaults without asking. The previous file
# is saved alongside it as config.json.bak.
# XDG_DATA_HOME Source install root (default $HOME/.local/share).
# Final source location is $XDG_DATA_HOME/move.
# XDG_BIN_HOME Wrapper install root (default $HOME/.local/bin).
# Final binary location is $XDG_BIN_HOME/move.
# XDG_CONFIG_HOME User config root (default $HOME/.config).
# Default config file path is $XDG_CONFIG_HOME/move/config.json.
# Default config file is $XDG_CONFIG_HOME/move/config.json.
#
# POSIX sh; no bashisms.
# POSIX sh; no bashisms. Note the absence of `local`: helper functions use
# `_`-prefixed globals, which POSIX sh leaves us with.
set -eu
@@ -36,13 +54,17 @@ REPO_OWNER="nokeo08"
REPO_NAME="Move"
GITEA_HOST="gitea.cahlen.com"
MOVE_VERSION="${MOVE_VERSION:-master}"
# Left empty when unset so it can be resolved to the latest tag once the
# prerequisite tools are confirmed present (see "Resolve version" below).
MOVE_VERSION="${MOVE_VERSION:-}"
MOVE_FORCE="${MOVE_FORCE:-0}"
MOVE_RESEED_CONFIG="${MOVE_RESEED_CONFIG:-0}"
INSTALL_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/move"
BIN_DIR="${XDG_BIN_HOME:-$HOME/.local/bin}"
CONFIG_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/move"
CONFIG_FILE="$CONFIG_DIR/config.json"
WRAPPER="$BIN_DIR/move"
die() {
printf 'Error: %s\n' "$1" >&2
@@ -62,6 +84,70 @@ assert_safe_dir() {
esac
}
# Print the newest tag name published to the repo (e.g. v1.3.2), or return
# non-zero if it can't be determined. The Gitea tags API lists newest first,
# so with ?limit=1 the sole entry is the latest tag; grep+sed pull its "name"
# without a jq dependency. Any failure -- offline, API error, no tags, a
# missing grep/sed -- collapses to a non-zero return and lets the caller fall
# back to the master branch.
resolve_latest_tag() {
_tags_url="https://$GITEA_HOST/api/v1/repos/$REPO_OWNER/$REPO_NAME/tags?limit=1"
_json=$(curl -fsSL --max-time 10 "$_tags_url" 2>/dev/null) || return 1
_tag=$(printf '%s' "$_json" | grep -o '"name":"[^"]*"' | head -n 1 | sed 's/.*:"//; s/"$//')
[ -n "$_tag" ] || return 1
printf '%s\n' "$_tag"
}
# --- Interactive prompt support ----------------------------------------------
#
# The documented entry point is `curl -fsSL ... | sh`, which means stdin is
# the *script source itself*. Reading a prompt answer from stdin would
# consume the rest of the program and truncate execution mid-run, so every
# prompt reads from /dev/tty directly.
#
# Detecting whether that's possible needs a real open(2) attempt. A `[ -r
# /dev/tty ]` test is not enough: the device node exists and is mode 0666
# even in contexts with no controlling terminal (cron, CI, container
# builds), where opening it fails with ENXIO. The probe runs in a subshell
# because a redirection failure on `exec` -- a special built-in -- exits a
# non-interactive shell outright under POSIX.
if (: >/dev/tty) 2>/dev/null; then
INTERACTIVE=1
else
INTERACTIVE=0
fi
# confirm PROMPT DEFAULT -> 0 for yes, 1 for no.
#
# DEFAULT is 'y' or 'n' and is taken on a bare Enter or on EOF (^D), so the
# loop can't spin forever against a closed terminal. Prompts are written to
# /dev/tty rather than stdout so they stay visible when the caller redirects
# our output.
confirm() {
_prompt="$1"
_default="$2"
case "$_default" in
y) _hint='[Y/n]' ;;
*) _hint='[y/N]' ;;
esac
while :; do
printf '%s %s ' "$_prompt" "$_hint" > /dev/tty
if ! IFS= read -r _reply < /dev/tty; then
printf '\n' > /dev/tty
_reply=''
fi
if [ -z "$_reply" ]; then
_reply="$_default"
fi
case "$_reply" in
[yY] | [yY][eE][sS]) return 0 ;;
[nN] | [nN][oO]) return 1 ;;
*) printf "Please answer 'y' or 'n'.\n" > /dev/tty ;;
esac
done
}
# --- Prerequisite tools ------------------------------------------------------
for tool in curl tar mktemp; do
@@ -100,53 +186,160 @@ fi
BUN_VERSION=$(bun --version)
printf '==> Using bun %s\n' "$BUN_VERSION"
# --- Idempotence check -------------------------------------------------------
# --- Resolve version to install ----------------------------------------------
#
# With no explicit MOVE_VERSION, default to the newest published tag so the
# plain one-liner installs a real release and reports its version (e.g.
# v1.3.2) rather than tracking the moving `master` branch. If the lookup
# fails -- offline, API unreachable, or no tags yet -- fall back to master,
# preserving the historical behavior instead of aborting.
if [ -z "$MOVE_VERSION" ]; then
if MOVE_VERSION=$(resolve_latest_tag); then
printf '==> Latest release is %s\n' "$MOVE_VERSION"
else
MOVE_VERSION=master
printf '==> Could not determine latest release; installing from master\n' >&2
fi
fi
# --- Existing install check --------------------------------------------------
#
# Both questions this script can ask are asked here, before anything is
# downloaded, deleted, or written. Declining therefore costs the user
# nothing, and no prompt appears minutes into a `bun install`.
assert_safe_dir "$INSTALL_DIR"
assert_safe_dir "$CONFIG_DIR"
if [ "$MOVE_FORCE" != "1" ] && [ -f "$INSTALL_DIR/.installed-version" ]; then
CURRENT=$(cat "$INSTALL_DIR/.installed-version" 2>/dev/null || printf '')
if [ "$CURRENT" = "$MOVE_VERSION" ]; then
printf 'move %s is already installed at %s/move.\n' "$MOVE_VERSION" "$BIN_DIR"
INSTALLED_VERSION=''
if [ -f "$INSTALL_DIR/.installed-version" ]; then
INSTALLED_VERSION=$(cat "$INSTALL_DIR/.installed-version" 2>/dev/null || printf '')
fi
# Look wider than the version marker: a half-finished or hand-edited install
# can leave a tree or a wrapper behind without one, and steamrolling that
# silently is precisely what this check exists to prevent.
FOUND_EXISTING=0
if [ -d "$INSTALL_DIR" ] || [ -e "$WRAPPER" ] || [ -L "$WRAPPER" ]; then
FOUND_EXISTING=1
fi
# Decided here, applied at the end -- the seed file it copies from only
# exists once the tarball has been extracted.
RESEED_CONFIG="$MOVE_RESEED_CONFIG"
if [ "$FOUND_EXISTING" = "1" ] && [ "$MOVE_FORCE" != "1" ]; then
if [ -n "$INSTALLED_VERSION" ]; then
printf '==> Found an existing move install (%s) at %s\n' \
"$INSTALLED_VERSION" "$INSTALL_DIR"
else
printf '==> Found an existing move install at %s (version unknown)\n' \
"$INSTALL_DIR"
fi
if [ "$INTERACTIVE" = "1" ]; then
# The defaults below are chosen so that a bare Enter reproduces
# exactly what this script did before it learned to ask: skip when
# the version is identical, replace when it differs.
if [ "$INSTALLED_VERSION" = "$MOVE_VERSION" ]; then
REPLACE_PROMPT="Reinstall move $MOVE_VERSION over it?"
REPLACE_DEFAULT=n
else
REPLACE_PROMPT="Replace it with $MOVE_VERSION?"
REPLACE_DEFAULT=y
fi
if ! confirm "$REPLACE_PROMPT" "$REPLACE_DEFAULT"; then
printf 'Leaving the existing install alone. Nothing was changed.\n'
exit 0
fi
else
# Nowhere to ask, so fall back to the historical contract.
if [ "$INSTALLED_VERSION" = "$MOVE_VERSION" ]; then
printf 'move %s is already installed at %s.\n' "$MOVE_VERSION" "$WRAPPER"
printf 'Set MOVE_FORCE=1 to reinstall, or set MOVE_VERSION to a different ref.\n'
exit 0
fi
printf '==> No terminal available; replacing %s with %s\n' \
"${INSTALLED_VERSION:-unknown}" "$MOVE_VERSION"
fi
fi
# --- Clean install dir -------------------------------------------------------
if [ -e "$CONFIG_FILE" ] && [ "$RESEED_CONFIG" != "1" ] &&
[ "$INTERACTIVE" = "1" ] && [ "$MOVE_FORCE" != "1" ]; then
printf '==> A config file already exists at %s\n' "$CONFIG_FILE"
if confirm 'Overwrite it with the shipped defaults?' n; then
RESEED_CONFIG=1
fi
fi
# --- Stage, download, build --------------------------------------------------
#
# Everything is assembled in a temp staging dir first; the existing install
# is removed only once the staged tree is fully built and ready to swap in.
# A failed download, extract, or `bun install` therefore leaves a working
# install untouched -- unlike the old flow, which wiped INSTALL_DIR before
# the download even started and left nothing behind on any failure.
mkdir -p "$BIN_DIR"
rm -rf "$INSTALL_DIR"
mkdir -p "$INSTALL_DIR"
# --- Download source ---------------------------------------------------------
DATA_ROOT=$(dirname "$INSTALL_DIR")
mkdir -p "$DATA_ROOT"
TARBALL_URL="https://$GITEA_HOST/$REPO_OWNER/$REPO_NAME/archive/$MOVE_VERSION.tar.gz"
TARBALL_TMP=$(mktemp) || die "could not create temp file"
# Stage on the same filesystem as INSTALL_DIR so the final swap is a rename,
# not a cross-device copy.
STAGE_DIR=$(mktemp -d "$DATA_ROOT/.move-stage.XXXXXX") ||
{ rm -f "$TARBALL_TMP"; die "could not create staging dir under $DATA_ROOT"; }
# On any exit, clean up the tarball and any leftover staging dir. After a
# successful swap STAGE_DIR has been renamed away, so the rm -rf is a no-op.
cleanup() {
rm -f "$TARBALL_TMP"
rm -rf "$STAGE_DIR"
}
trap cleanup EXIT INT TERM
TARBALL_URL="https://$GITEA_HOST/$REPO_OWNER/$REPO_NAME/archive/$MOVE_VERSION.tar.gz"
printf '==> Downloading %s\n' "$TARBALL_URL"
if ! curl -fsSL "$TARBALL_URL" -o "$TARBALL_TMP"; then
die "could not download $TARBALL_URL (check MOVE_VERSION='$MOVE_VERSION' and network)"
fi
printf '==> Extracting source to %s\n' "$INSTALL_DIR"
if ! tar -xzf "$TARBALL_TMP" -C "$INSTALL_DIR" --strip-components=1; then
printf '==> Extracting source\n'
if ! tar -xzf "$TARBALL_TMP" -C "$STAGE_DIR" --strip-components=1; then
die "could not extract tarball from $TARBALL_URL"
fi
# --- Install runtime deps ----------------------------------------------------
printf '==> Installing runtime dependencies (bun install --production)\n'
(cd "$INSTALL_DIR" && bun install --production)
(cd "$STAGE_DIR" && bun install --production)
# Sanity-check the staged tree before we disturb the existing install: a
# truncated or wrong tarball that's missing the config seed should fail here,
# while the old install is still intact and swappable-out.
if [ ! -f "$STAGE_DIR/scripts/config.default.json" ]; then
die "downloaded tree is missing scripts/config.default.json (bad MOVE_VERSION='$MOVE_VERSION'?)"
fi
# Record the version inside the staged tree so the install is self-consistent
# the instant it lands.
printf '%s\n' "$MOVE_VERSION" > "$STAGE_DIR/.installed-version"
# --- Swap staged tree into place ---------------------------------------------
#
# The only destructive step, kept as late as possible: the window where
# INSTALL_DIR is absent is just this rm + rename, not the whole build.
assert_safe_dir "$INSTALL_DIR"
printf '==> Installing to %s\n' "$INSTALL_DIR"
rm -rf "$INSTALL_DIR"
if ! mv "$STAGE_DIR" "$INSTALL_DIR"; then
die "could not move staged install into place at $INSTALL_DIR"
fi
# --- Drop the wrapper --------------------------------------------------------
WRAPPER="$BIN_DIR/move"
printf '==> Writing wrapper to %s\n' "$WRAPPER"
cat > "$WRAPPER" <<EOF
#!/usr/bin/env sh
@@ -154,23 +347,21 @@ exec bun "$INSTALL_DIR/src/move.ts" "\$@"
EOF
chmod +x "$WRAPPER"
# --- Write version marker ----------------------------------------------------
printf '%s\n' "$MOVE_VERSION" > "$INSTALL_DIR/.installed-version"
# --- Seed user config file (only if absent) ----------------------------------
# --- Seed user config file ---------------------------------------------------
#
# The defaults file shipped with the source tree (scripts/config.default.json)
# is also the single source of truth for the runtime defaults loaded by
# src/config.ts, so seeding a fresh user file from the same place keeps the
# CLI behavior and the user-visible config in sync.
#
# Strict policy: never overwrite an existing user config. The uninstaller
# follows the matching policy of never removing it; together that
# preserves user customizations unconditionally across (re)installs and
# uninstalls.
# Policy: an existing user config is never overwritten *silently*. It is
# replaced only on an explicit answer to the prompt above or an explicit
# MOVE_RESEED_CONFIG=1, and even then the previous file is kept as a .bak
# rather than destroyed. Everything else -- MOVE_FORCE=1, a non-interactive
# run -- leaves it untouched, so automation that reinstalls the software
# can't take a user's customizations down with it. The uninstaller follows
# the matching policy of never removing the config at all.
assert_safe_dir "$CONFIG_DIR"
SEED_SRC="$INSTALL_DIR/scripts/config.default.json"
if [ ! -f "$SEED_SRC" ]; then
@@ -181,6 +372,11 @@ mkdir -p "$CONFIG_DIR"
if [ ! -e "$CONFIG_FILE" ]; then
cp "$SEED_SRC" "$CONFIG_FILE"
printf '==> Wrote default config to %s\n' "$CONFIG_FILE"
elif [ "$RESEED_CONFIG" = "1" ]; then
cp "$CONFIG_FILE" "$CONFIG_FILE.bak"
cp "$SEED_SRC" "$CONFIG_FILE"
printf '==> Reseeded %s (previous file saved as %s)\n' \
"$CONFIG_FILE" "$CONFIG_FILE.bak"
else
printf '==> Config already exists at %s; leaving it alone\n' "$CONFIG_FILE"
fi
+1 -1
View File
@@ -4,7 +4,7 @@
#
# Curl-pipe ready:
#
# curl -fsSL https://gitea.cahlen.com/nokeo08/Move/raw/branch/master/uninstall.sh | sh
# curl -fsSL https://gitea.cahlen.com/nokeo08/Move/raw/branch/master/scripts/uninstall.sh | sh
#
# Removes the `move` wrapper from $XDG_BIN_HOME and the install tree from
# $XDG_DATA_HOME/move. Does NOT remove Bun — that's your runtime, not ours.
+15 -2
View File
@@ -17,8 +17,10 @@
* -c, --check-interval Cursor poll cadence (seconds).
* -d, --step-delay Pause between synthetic steps (ms).
* -p, --pattern Movement strategy name (see strategies.ts).
* -V, --verbose Enable per-sweep / interrupt / bounds logging.
* -V, --verbose Enable per-sweep / interrupt 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,
};
}
@@ -171,8 +180,11 @@ Options:
-p, --pattern <name> Movement strategy. Default: ${DEFAULT_CONFIG.pattern}.
One of: ${PATTERN_NAMES.join(", ")}.
Each pattern defines its own size and speed.
-V, --verbose Log every sweep, interrupt, and bounds event
-V, --verbose Log every sweep and interrupt
(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
`);
}
+17 -4
View File
@@ -10,7 +10,7 @@
* `resolveConfig` rather than mutating the defaults, so the defaults stay
* genuinely constant and the resolved config stays structurally typed.
*
* All numeric `Config` fields are in their internal units (ms, pixels).
* All numeric `Config` fields are in their internal units (milliseconds).
* The CLI and config file expose the time-valued fields in seconds for
* ergonomics; `resolveConfig` performs the seconds->ms conversion at the
* boundary so downstream code never has to think about it.
@@ -45,8 +45,12 @@ import seedRaw from "../scripts/config.default.json" with { type: "json" };
* - `pattern` — name of the movement strategy to use (see
* `strategies.ts`; e.g. `line`, `walk`, `arc`). Each
* pattern owns its own size and step count.
* - `verbose` — whether per-sweep / interrupt / bounds events are
* logged. The startup banner is always printed.
* - `verbose` — whether per-sweep / interrupt 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),
};
}
+6
View File
@@ -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,
};
}
+61 -61
View File
@@ -7,13 +7,13 @@
* *everything else* about carrying a sweep out against a `Device`:
*
* - round each ideal target to whole pixels,
* - keep it on-screen per the strategy's `BoundsPolicy`,
* - keep it on-screen by reflecting coordinates that fall past an edge,
* - command the cursor and pace it with `stepDelay`,
* - detect real-user interruption after each step,
* - restore the cursor to the origin on a clean run.
*
* Writing this once means new patterns inherit correct real-user-wins,
* bounds, and restore semantics for free. It's pure with respect to I/O —
* on-screen, and restore semantics for free. It's pure with respect to I/O —
* all side effects go through the injected `Device`, so it's unit-testable
* with a fake.
*
@@ -25,7 +25,7 @@
import type { Config } from "./config.ts";
import type { Device, Point } from "./device.ts";
import type { BoundsPolicy, MoveContext, MovementStrategy } from "./strategies.ts";
import type { MoveContext, MovementStrategy } from "./strategies.ts";
/**
* Minimal log surface used by the executor and the keeper loop.
@@ -41,11 +41,29 @@ export interface Logger {
/**
* How a sweep ended:
* - `completed` — full path ran and the cursor was restored to start.
* - `interrupted` — real user activity detected mid-sweep; aborted without
* snapping back.
* - `aborted` — an `abort`-policy target went out of bounds.
* - `interrupted` — real user activity detected mid-sweep; the sweep stopped
* without snapping back.
*/
export type SweepOutcome = "completed" | "interrupted" | "aborted";
export type SweepOutcome = "completed" | "interrupted";
/**
* 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.
* - `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 loop?: boolean;
}
/**
* Slack, in pixels, allowed between the coordinate we commanded and the one
@@ -56,20 +74,17 @@ export type SweepOutcome = "completed" | "interrupted" | "aborted";
const READBACK_TOLERANCE: number = 2;
/**
* Pixels to inset the `clamp` / `reflect` travel range from each screen edge.
* Keeps edge-seeking patterns off the literal first/last pixel, where DPI
* scaling and multi-monitor boundaries most often make the OS place the
* cursor a hair off what we commanded (which the readback check would then
* misread as the user). `abort` (used by `line`) is deliberately left on the
* full `[0, max - 1]` range, so its behavior is unchanged.
* Pixels to inset the travel range from each screen edge. Keeps edge-seeking
* patterns off the literal first/last pixel, where DPI scaling and
* multi-monitor boundaries most often make the OS place the cursor a hair off
* what we commanded (which the readback check would then misread as the user).
*/
const EDGE_MARGIN: number = 2;
/**
* The inclusive `[lo, hi]` integer range an axis of length `max` may travel
* under the `clamp` / `reflect` policies: `[0, max - 1]` inset by
* `EDGE_MARGIN` on each side. Screens too small to inset fall back to the
* full range so the math never inverts.
* The inclusive `[lo, hi]` integer range an axis of length `max` may travel:
* `[0, max - 1]` inset by `EDGE_MARGIN` on each side. Screens too small to
* inset fall back to the full range so the math never inverts.
*/
function travelRange(max: number): { lo: number; hi: number } {
const hiEdge: number = max - 1;
@@ -77,18 +92,11 @@ function travelRange(max: number): { lo: number; hi: number } {
return { lo: EDGE_MARGIN, hi: hiEdge - EDGE_MARGIN };
}
/** Round to whole pixels and clamp into the inset travel range for `max`. */
function clampInt(v: number, max: number): number {
const { lo, hi } = travelRange(max);
const r: number = Math.round(v);
if (r < lo) return lo;
if (r > hi) return hi;
return r;
}
/**
* Mirror `v` into the inset travel range for `max` as a triangle wave, so
* values past an edge bounce back inside instead of clamping flat against it.
* values past an edge bounce back inside instead of running off it. This is
* the sole on-screen policy: a coordinate that overshoots an edge reflects
* back in, so a pattern keeps moving instead of parking against the boundary.
*/
function reflectInt(v: number, max: number): number {
const { lo, hi } = travelRange(max);
@@ -100,28 +108,12 @@ function reflectInt(v: number, max: number): number {
}
/**
* Resolve a strategy's ideal target to an on-screen integer pixel under the
* given policy. Returns `null` when policy is `abort` and the (rounded)
* target lies outside the screen — the signal to stop the sweep.
* Resolve a strategy's ideal (possibly fractional, possibly off-screen) target
* to an on-screen integer pixel by reflecting each axis into its travel range.
*/
function resolveTarget(
policy: BoundsPolicy,
p: Point,
width: number,
height: number,
): Point | null {
if (policy === "reflect") {
function resolveTarget(p: Point, width: number, height: number): Point {
return { x: reflectInt(p.x, width), y: reflectInt(p.y, height) };
}
if (policy === "clamp") {
return { x: clampInt(p.x, width), y: clampInt(p.y, height) };
}
// abort: round, then reject anything off-screen.
const x: number = Math.round(p.x);
const y: number = Math.round(p.y);
if (x < 0 || x >= width || y < 0 || y >= height) return null;
return { x, y };
}
/**
* Format the current local time as `HH:MM:SS` for log lines.
@@ -137,15 +129,22 @@ function timestamp(): string {
* against `device`.
*
* Contract, per step:
* 1. Resolve the ideal target to an on-screen integer (bounds policy).
* An `abort`-policy out-of-bounds target ends the sweep (`aborted`).
* 1. Resolve the ideal target to an on-screen integer by reflecting it
* into the travel range.
* 2. Command the cursor there and sleep `config.stepDelay` — also the
* user's interrupt window.
* 3. Re-read the cursor. If it isn't at the point we just commanded, the
* 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: `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,17 +155,16 @@ export async function executePath(
device: Device,
log: Logger,
config: Config,
options?: ExecuteOptions,
): Promise<SweepOutcome> {
const { start, width, height } = ctx;
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);
if (point === null) {
log.event(`Out of bounds at ${timestamp()}; aborting simulation.`);
return "aborted";
}
for (const target of path) {
const point: Point = resolveTarget(target, width, height);
await device.setPosition(point);
await device.sleep(config.stepDelay);
@@ -176,22 +174,24 @@ export async function executePath(
Math.abs(current.x - point.x) > READBACK_TOLERANCE ||
Math.abs(current.y - point.y) > READBACK_TOLERANCE
) {
// Cursor isn't where we last put it -> real user activity. Abort
// Cursor isn't where we last put it -> real user activity. Stop
// without snapping back, so we don't yank it from under the user.
//
// The comparison allows a small tolerance rather than demanding an
// exact match: on scaled (fractional-DPI) or multi-monitor setups
// the OS can place the cursor a pixel off the coordinate we
// commanded, and the edge-seeking patterns (clamp/reflect/arc)
// reach exactly the coordinates where that's most likely. A real
// user moves far more than a couple of pixels, so this doesn't
// meaningfully weaken real-user-wins.
log.event(`User activity detected at ${timestamp()}; aborting simulation.`);
// commanded, and edge-seeking patterns reach exactly the
// coordinates where that's most likely. A real user moves far more
// than a couple of pixels, so this doesn't meaningfully weaken
// real-user-wins.
log.event(`User activity detected at ${timestamp()}; stopping simulation.`);
return "interrupted";
}
}
if (options?.restore !== false) {
await device.setPosition({ x: Math.round(start.x), y: Math.round(start.y) });
log.event("Mouse moved.");
}
return "completed";
}
+49 -16
View File
@@ -8,8 +8,8 @@
* 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).
* - `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
@@ -18,13 +18,13 @@
* 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.
* - 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 } 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,57 @@ 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 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): 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 };
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}.`);
}
/**
+10 -5
View File
@@ -4,23 +4,27 @@
* -------
* Entry point for the `move` CLI.
*
* Thin shim that ties the four logic modules together:
* Thin shim that ties the logic modules together:
* - `cli.ts` parses and validates `process.argv`.
* - `configFile.ts` loads and validates the JSON config file.
* - `config.ts` holds defaults and the layered `resolveConfig` overlay.
* - `keeper.ts` owns the synthetic-activity sweep and idle-watch loop.
* - `editor.ts` backs `--edit` (open the config file in `$EDITOR`).
* - `keeper.ts` owns the idle-watch loop and drives the movement
* machinery (device / strategy / executor).
*
* Order of operations:
* 1. Parse CLI args. Bad input -> stderr + usage hint, exit 2.
* 2. `--help` / `--version` short-circuit before any I/O, config load, or
* mouse work. `keeper.ts` is also lazy-imported (see below) so these
* flags don't pay the cost of loading the nut.js native binary.
* 3. Load + validate the config file (default XDG path, or `--config
* 3. `--edit` opens the resolved config file in `$EDITOR` and is a
* terminal action (propagates the editor's exit code).
* 4. Load + validate the config file (default XDG path, or `--config
* <path>` if supplied). Validation failures share the exit-2 path.
* 4. Resolve the full `Config` (CLI > file > DEFAULT_CONFIG) — verbose
* 5. Resolve the full `Config` (CLI > file > DEFAULT_CONFIG) — verbose
* lives inside `Config` and is layered with the same precedence as
* the numeric fields.
* 5. Lazy-import `keeper.ts` (dynamic import keeps nut.js out of the
* 6. Lazy-import `keeper.ts` (dynamic import keeps nut.js out of the
* `--help` / `--version` startup path) and run it. Any unhandled
* rejection — from the import itself or from the loop — exits 1.
*
@@ -117,6 +121,7 @@ const cliOverrides: ConfigOverrides = {
stepDelay: cliArgs.stepDelay,
pattern: cliArgs.pattern,
verbose: cliArgs.verbose,
loop: cliArgs.loop,
};
const config = resolveConfig(fileOverrides, cliOverrides);
+59 -29
View File
@@ -10,9 +10,10 @@
* add (write one pure generator) and trivial to test (feed a deterministic
* `rng`, assert the emitted points).
*
* Coordinates emitted here may be fractional; the executor rounds to whole
* pixels before commanding the cursor and applies the strategy's declared
* `BoundsPolicy` to keep everything on-screen.
* Coordinates emitted here may be fractional and may fall past a screen
* edge; the executor rounds to whole pixels and reflects any out-of-range
* coordinate back inside, so a pattern bounces off the edges and keeps
* moving. Strategies never need to bound their own output.
*
* Each pattern owns its own geometry — how many steps it takes, how far it
* reaches, how tight its radius is — as module-private constants below. Those
@@ -25,19 +26,6 @@
import type { Point } from "./device.ts";
/**
* How the executor keeps a strategy's targets on-screen:
*
* - `abort` — stop the sweep the moment a target falls out of bounds.
* Used by `line`, whose direction is chosen so this never
* actually fires; preserves the original straight-line
* semantics exactly.
* - `clamp` — pin each out-of-bounds coordinate to the nearest edge.
* - `reflect` — mirror out-of-bounds coordinates back inside, so a roaming
* pattern bounces off the screen edges instead of sticking.
*/
export type BoundsPolicy = "abort" | "clamp" | "reflect";
/**
* Everything a strategy needs to generate a path. Screen dimensions and the
* start point are snapshotted per sweep by the caller; `rng` is injected so
@@ -59,17 +47,34 @@ export interface MoveContext {
*
* - `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 reflects 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]`. */
/**
* Clamp `v` into the inclusive pixel range `[0, max - 1]`. This is a geometry
* helper for `arc` (choosing a well-formed on-screen endpoint and control
* point), NOT an on-screen bounds policy — the executor keeps every commanded
* point on-screen by reflecting, uniformly for all patterns.
*/
function clamp(v: number, max: number): number {
if (v < 0) return 0;
if (v > max - 1) return max - 1;
@@ -82,14 +87,20 @@ function clamp(v: number, max: number): number {
* Pick a horizontal direction that keeps the sweep on-screen (right if
* there's room, else left) and walk `LINE_STEPS` single-pixel steps with no
* 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).
* keeper produced before movement patterns existed. The direction choice
* keeps the finite sweep on-screen, so the executor's reflection never
* actually engages for it.
*
* In loop mode `loopPath` ramps x in one direction forever; the direction
* never matters because the executor reflects the ramp 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",
bounds: "abort",
*path(ctx: MoveContext): Generator<Point> {
const { start, width } = ctx;
const dx: number = start.x + LINE_STEPS < width ? 1 : -1;
@@ -97,6 +108,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,12 +123,17 @@ 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, and the executor reflects
* them. 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",
bounds: "clamp",
*path(ctx: MoveContext): Generator<Point> {
const { start, width, height } = ctx;
const dx: number = start.x + DIAGONAL_STEPS < width ? 1 : -1;
@@ -118,6 +142,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 };
}
},
};
/**
@@ -132,7 +166,6 @@ const JITTER_RADIUS = 30;
export const jitter: MovementStrategy = {
name: "jitter",
bounds: "clamp",
*path(ctx: MoveContext): Generator<Point> {
const { start, rng } = ctx;
for (let i = 1; i <= JITTER_STEPS; i++) {
@@ -148,15 +181,14 @@ export const jitter: MovementStrategy = {
* per-axis delta in `[-WALK_STEP, +WALK_STEP]`. The per-step magnitude is
* deliberately several pixels so the walk actually roams — a ±1px walk over
* this many steps would drift only ~√N pixels net. The generator lets the
* position drift freely; the executor's `reflect` policy mirrors it back
* on-screen, so the cursor bounces off the edges instead of escaping.
* position drift freely; the executor mirrors it back on-screen, so the
* cursor bounces off the edges instead of escaping.
*/
const WALK_STEPS = 200;
const WALK_STEP = 4;
export const walk: MovementStrategy = {
name: "walk",
bounds: "reflect",
*path(ctx: MoveContext): Generator<Point> {
const { start, rng } = ctx;
let x: number = start.x;
@@ -180,7 +212,6 @@ const ARC_REACH = 300;
export const arc: MovementStrategy = {
name: "arc",
bounds: "clamp",
*path(ctx: MoveContext): Generator<Point> {
const { start, width, height, rng } = ctx;
@@ -222,7 +253,6 @@ const FIG8_AMP = 125;
export const figureEight: MovementStrategy = {
name: "figureEight",
bounds: "clamp",
*path(ctx: MoveContext): Generator<Point> {
const { start } = ctx;
for (let i = 1; i <= FIG8_STEPS; i++) {
+16
View File
@@ -17,6 +17,7 @@ const NONE: ConfigOverrides = {
stepDelay: undefined,
pattern: undefined,
verbose: undefined,
loop: undefined,
};
describe("resolveConfig", () => {
@@ -78,6 +79,21 @@ describe("resolveConfig", () => {
const cfg = resolveConfig(null, NONE);
expect(cfg.verbose).toBe(DEFAULT_CONFIG.verbose);
});
test("loop: CLI true wins over file false", () => {
const cfg = resolveConfig({ ...NONE, loop: false }, { ...NONE, loop: true });
expect(cfg.loop).toBe(true);
});
test("loop: file true wins over default (no CLI)", () => {
const cfg = resolveConfig({ ...NONE, loop: true }, NONE);
expect(cfg.loop).toBe(true);
});
test("loop: falls back to DEFAULT_CONFIG.loop when neither set", () => {
const cfg = resolveConfig(null, NONE);
expect(cfg.loop).toBe(DEFAULT_CONFIG.loop);
});
});
describe("defaultConfigPath", () => {
+11
View File
@@ -98,6 +98,17 @@ describe("loadConfigFile (explicit path)", () => {
expect(() => loadConfigFile(path)).toThrow(/'verbose'.*boolean/);
});
test("accepts a boolean loop", () => {
const path = writeFixture("loop.json", JSON.stringify({ loop: true }));
const result = loadConfigFile(path);
expect(result!.loop).toBe(true);
});
test("throws when loop is the wrong type", () => {
const path = writeFixture("loop-bad.json", JSON.stringify({ loop: "yes" }));
expect(() => loadConfigFile(path)).toThrow(/'loop'.*boolean/);
});
test("accepts a known pattern", () => {
const path = writeFixture("pattern.json", JSON.stringify({ pattern: "arc" }));
const result = loadConfigFile(path);
+109 -36
View File
@@ -2,9 +2,9 @@
* executor.test.ts
* ----------------
* Unit tests for the execution driver against a fake `Device`. Covers the
* three sweep outcomes, all three bounds policies, the rounding/interrupt
* contract, and step pacing — none of which was testable before the device
* seam existed.
* two sweep outcomes, on-screen reflection, the rounding/interrupt contract,
* step pacing, and the loop/restore options — none of which was testable
* before the device seam existed.
*/
import { describe, expect, test } from "bun:test";
@@ -13,7 +13,7 @@ import { DEFAULT_CONFIG } from "../src/config.ts";
import type { Config } from "../src/config.ts";
import type { Device, Point } from "../src/device.ts";
import { executePath, type Logger } from "../src/executor.ts";
import type { BoundsPolicy, MoveContext, MovementStrategy } from "../src/strategies.ts";
import type { MoveContext, MovementStrategy } from "../src/strategies.ts";
const noopLog: Logger = { info: (): void => {}, event: (): void => {} };
@@ -50,11 +50,10 @@ class FakeDevice implements Device {
}
}
/** A strategy that emits a fixed list of points under a chosen bounds policy. */
function fixed(points: Point[], bounds: BoundsPolicy): MovementStrategy {
/** A strategy that emits a fixed list of points. */
function fixed(points: Point[]): MovementStrategy {
return {
name: "fixed",
bounds,
*path(): Generator<Point> {
yield* points;
},
@@ -79,7 +78,7 @@ describe("executePath — outcomes", () => {
{ x: 502, y: 500 },
{ x: 503, y: 500 },
];
const outcome = await executePath(fixed(pts, "clamp"), ctxOf(start, dev.w, dev.h), dev, noopLog, cfgOf());
const outcome = await executePath(fixed(pts), ctxOf(start, dev.w, dev.h), dev, noopLog, cfgOf());
expect(outcome).toBe("completed");
// 3 steps + 1 restore.
expect(dev.commanded).toEqual([...pts, start]);
@@ -95,42 +94,116 @@ describe("executePath — outcomes", () => {
];
// 2nd getPosition call reports the user elsewhere.
dev.overrides.set(2, { x: 9, y: 9 });
const outcome = await executePath(fixed(pts, "clamp"), ctxOf(start, dev.w, dev.h), dev, noopLog, cfgOf());
const outcome = await executePath(fixed(pts), ctxOf(start, dev.w, dev.h), dev, noopLog, cfgOf());
expect(outcome).toBe("interrupted");
// Commanded points 1 and 2 only; never restored to start.
expect(dev.commanded).toEqual([pts[0]!, pts[1]!]);
expect(dev.commanded.at(-1)).not.toEqual(start);
});
test("abort policy stops before commanding an out-of-bounds point", async () => {
const dev = new FakeDevice(100, 100);
const pts = [{ x: 150, y: 10 }]; // x >= width
const outcome = await executePath(fixed(pts, "abort"), ctxOf({ x: 10, y: 10 }, 100, 100), dev, noopLog, cfgOf());
expect(outcome).toBe("aborted");
expect(dev.commanded).toEqual([]);
});
});
describe("executePath — bounds policies", () => {
test("clamp pins out-of-bounds coordinates to the inset edges", async () => {
const dev = new FakeDevice(100, 100);
const pts = [
{ x: -5, y: 50 },
{ x: 9999, y: 50 },
];
// travelRange(100) is inset by EDGE_MARGIN (2) to [2, 97].
await executePath(fixed(pts, "clamp"), ctxOf({ x: 50, y: 50 }, 100, 100), dev, noopLog, cfgOf());
expect(dev.commanded[0]).toEqual({ x: 2, y: 50 });
expect(dev.commanded[1]).toEqual({ x: 97, y: 50 });
});
test("reflect mirrors out-of-bounds coordinates back inside the inset range", async () => {
describe("executePath — on-screen reflection", () => {
test("mirrors an out-of-range coordinate back inside the inset range", async () => {
const dev = new FakeDevice(100, 100);
// Inset range [2, 97], span = 95; x=120 -> (120-2)=118, 190-118=72, +2 = 74.
const pts = [{ x: 120, y: 50 }];
await executePath(fixed(pts, "reflect"), ctxOf({ x: 50, y: 50 }, 100, 100), dev, noopLog, cfgOf());
await executePath(fixed(pts), ctxOf({ x: 50, y: 50 }, 100, 100), dev, noopLog, cfgOf());
expect(dev.commanded[0]).toEqual({ x: 74, y: 50 });
});
test("negative and far-past-edge coordinates both fold inside", async () => {
const dev = new FakeDevice(100, 100);
// Inset [2, 97]. x=-5 -> reflects to 9; x=99 -> 95 (period 190).
const pts = [
{ x: -5, y: 50 },
{ x: 99, y: 50 },
];
await executePath(fixed(pts), ctxOf({ x: 50, y: 50 }, 100, 100), dev, noopLog, cfgOf());
for (const p of dev.commanded.slice(0, 2)) {
expect(p.x).toBeGreaterThanOrEqual(2);
expect(p.x).toBeLessThanOrEqual(97);
}
});
test("a monotonic ramp past an edge keeps moving — never two identical points in a row", async () => {
// This is the guarantee that motivated removing `clamp`: a clamp would
// pin every over-the-edge point to the same edge pixel, stalling the
// cursor. Reflection folds the ramp into a triangle wave, so the cursor
// both rises and falls and never repeats a pixel step to step.
const dev = new FakeDevice(40, 40);
// Ramp x well past the right edge and back's worth of travel.
const pts = Array.from({ length: 60 }, (_, i) => ({ x: 10 + i, y: 20 }));
await executePath(fixed(pts), ctxOf({ x: 10, y: 20 }, 40, 40), dev, noopLog, cfgOf({ stepDelay: 0 }));
const xs = dev.commanded.slice(0, 60).map((p) => p.x);
// No stall: consecutive commanded points always differ.
for (let i = 1; i < xs.length; i++) {
expect(xs[i]).not.toBe(xs[i - 1]);
}
// It bounced: the ramp both increased and decreased at some point.
const rose = xs.some((x, i) => i > 0 && x > xs[i - 1]!);
const fell = xs.some((x, i) => i > 0 && x < xs[i - 1]!);
expect(rose && fell).toBe(true);
});
});
describe("executePath — options", () => {
test("restore:false leaves the cursor at the last step, no snap-back", async () => {
const dev = new FakeDevice();
const start = { x: 500, y: 500 };
const pts = [
{ x: 501, y: 500 },
{ x: 502, y: 500 },
];
const outcome = await executePath(
fixed(pts),
ctxOf(start, dev.w, dev.h),
dev,
noopLog,
cfgOf(),
{ restore: false },
);
expect(outcome).toBe("completed");
// No trailing restore-to-start command.
expect(dev.commanded).toEqual(pts);
});
test("the default (no options) still restores to start", async () => {
const dev = new FakeDevice();
const start = { x: 500, y: 500 };
const pts = [{ x: 501, y: 500 }];
await executePath(fixed(pts), ctxOf(start, dev.w, dev.h), dev, noopLog, cfgOf());
expect(dev.commanded).toEqual([...pts, start]);
});
test("loop:true runs loopPath when present, path otherwise", async () => {
const dev = new FakeDevice();
// A strategy whose loopPath differs from its path, both finite here.
const strat: MovementStrategy = {
name: "dual",
*path(): Generator<Point> {
yield { x: 1, y: 1 };
},
*loopPath(): Generator<Point> {
yield { x: 10, y: 10 };
yield { x: 20, y: 20 };
},
};
await executePath(strat, ctxOf({ x: 0, y: 0 }, dev.w, dev.h), dev, noopLog, cfgOf(), {
loop: true,
restore: false,
});
expect(dev.commanded).toEqual([{ x: 10, y: 10 }, { x: 20, y: 20 }]);
});
test("loop:true falls back to path when the strategy has no loopPath", async () => {
const dev = new FakeDevice();
const strat = fixed([{ x: 3, y: 3 }]);
await executePath(strat, ctxOf({ x: 0, y: 0 }, dev.w, dev.h), dev, noopLog, cfgOf(), {
loop: true,
restore: false,
});
expect(dev.commanded).toEqual([{ x: 3, y: 3 }]);
});
});
describe("executePath — readback tolerance", () => {
@@ -145,7 +218,7 @@ describe("executePath — readback tolerance", () => {
// not the user). 2px is within READBACK_TOLERANCE, so the sweep runs on.
dev.overrides.set(1, { x: 512, y: 501 });
dev.overrides.set(2, { x: 518, y: 499 });
const outcome = await executePath(fixed(pts, "clamp"), ctxOf(start, dev.w, dev.h), dev, noopLog, cfgOf());
const outcome = await executePath(fixed(pts), ctxOf(start, dev.w, dev.h), dev, noopLog, cfgOf());
expect(outcome).toBe("completed");
expect(dev.commanded).toEqual([...pts, start]);
});
@@ -159,7 +232,7 @@ describe("executePath — readback tolerance", () => {
];
// First readback is 3px off -> exceeds the 2px tolerance -> real user.
dev.overrides.set(1, { x: 513, y: 500 });
const outcome = await executePath(fixed(pts, "clamp"), ctxOf(start, dev.w, dev.h), dev, noopLog, cfgOf());
const outcome = await executePath(fixed(pts), ctxOf(start, dev.w, dev.h), dev, noopLog, cfgOf());
expect(outcome).toBe("interrupted");
expect(dev.commanded).toEqual([pts[0]!]);
});
@@ -170,7 +243,7 @@ describe("executePath — rounding & pacing", () => {
const dev = new FakeDevice();
const start = { x: 500, y: 500 };
const pts = [{ x: 10.4, y: 20.6 }]; // -> (10, 21)
const outcome = await executePath(fixed(pts, "clamp"), ctxOf(start, dev.w, dev.h), dev, noopLog, cfgOf());
const outcome = await executePath(fixed(pts), ctxOf(start, dev.w, dev.h), dev, noopLog, cfgOf());
expect(outcome).toBe("completed");
expect(dev.commanded[0]).toEqual({ x: 10, y: 21 });
});
@@ -181,7 +254,7 @@ describe("executePath — rounding & pacing", () => {
{ x: 501, y: 500 },
{ x: 502, y: 500 },
];
await executePath(fixed(pts, "clamp"), ctxOf({ x: 500, y: 500 }, dev.w, dev.h), dev, noopLog, cfgOf({ stepDelay: 7 }));
await executePath(fixed(pts), ctxOf({ x: 500, y: 500 }, dev.w, dev.h), dev, noopLog, cfgOf({ stepDelay: 7 }));
expect(dev.sleeps).toEqual([7, 7]);
});
});
+34
View File
@@ -89,3 +89,37 @@ describe("runKeeper", () => {
expect(dev.commanded.length).toBe(0);
});
});
describe("runKeeper — loop mode", () => {
const maxX = (pts: Point[]): number => pts.reduce((m, p) => Math.max(m, p.x), -Infinity);
test("loop mode ramps far from the start via the infinite loopPath", async () => {
// `line`'s loopPath ramps x by 4px/step from the start and never
// restores, reflecting off the screen edge. From x=100 it climbs well
// past a single finite sweep's reach before the budget stops it.
const dev = new LoopDevice(400, { x: 100, y: 100 });
await runUntilStop(quietConfig({ moveInterval: 0, pattern: "line", loop: true }), dev);
expect(maxX(dev.commanded)).toBeGreaterThan(1000);
});
test("single-sweep mode restores each sweep, so x never ramps away", async () => {
// Same setup without loop: `line` runs 250 one-pixel steps then snaps
// back to the start, so x is bounded by start + 250 no matter how many
// sweeps fire within the budget.
const dev = new LoopDevice(400, { x: 100, y: 100 });
await runUntilStop(quietConfig({ moveInterval: 0, pattern: "line", loop: false }), dev);
expect(maxX(dev.commanded)).toBeLessThanOrEqual(350);
});
test("loop mode chains a finite pattern across multiple cycles per trigger", async () => {
// `figureEight` has no loopPath, so loop mode chains its 90-step path.
// A single trigger keeps chaining cycles until the budget stops it,
// yielding far more than the 90 commands one cycle would.
const dev = new LoopDevice(400, { x: 800, y: 500 });
await runUntilStop(
quietConfig({ moveInterval: 0, pattern: "figureEight", loop: true }),
dev,
);
expect(dev.commanded.length).toBeGreaterThan(180);
});
});
+28
View File
@@ -36,6 +36,16 @@ function mulberry32(seed: number): () => number {
};
}
/** Pull the first `n` points from a (possibly infinite) point iterable. */
function take(iter: Iterable<Point>, n: number): Point[] {
const out: Point[] = [];
for (const p of iter) {
out.push(p);
if (out.length >= n) break;
}
return out;
}
function ctxOf(overrides: {
start?: Point;
width?: number;
@@ -67,6 +77,14 @@ describe("line", () => {
expect(pts[1]!.x).toBe(88);
expect(pts.at(-1)!.x).toBe(90 - 250);
});
test("loopPath ramps x forever at a fixed step, y held constant", () => {
const start = { x: 500, y: 300 };
const pts = take(line.loopPath!(ctxOf({ start })), 5);
// Monotonic +4 per step (LINE_LOOP_STEP), no vertical drift.
expect(pts.map((p) => p.x)).toEqual([504, 508, 512, 516, 520]);
expect(pts.every((p) => p.y === 300)).toBe(true);
});
});
describe("diagonal", () => {
@@ -76,6 +94,16 @@ describe("diagonal", () => {
expect(pts[0]!).toEqual({ x: 501, y: 501 });
expect(pts.at(-1)!).toEqual({ x: 750, y: 750 });
});
test("loopPath ramps both axes forever at a fixed step", () => {
const pts = take(diagonal.loopPath!(ctxOf({ start: { x: 100, y: 200 } })), 3);
// Both axes advance by DIAGONAL_LOOP_STEP (4) each step.
expect(pts).toEqual([
{ x: 104, y: 204 },
{ x: 108, y: 208 },
{ x: 112, y: 212 },
]);
});
});
describe("jitter", () => {