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
2026-06-15 14:31:31 -05:00
2026-08-17 10:58:26 -05:00

Teams Status Keeper

Keeps Microsoft Teams (or any presence-tracking app) from marking you as "Away" by nudging the mouse cursor when the machine has been idle long enough to trigger an idle timeout.

Real user movement always wins: the script never fires while the user is actively using the mouse, and any synthetic sweep aborts the moment the cursor leaves the position the script just commanded.

Requirements

  • Bun >= 1.0.0
  • macOS or Linux (relies on @nut-tree-fork/nut-js for cross-platform mouse + screen control)
  • On macOS: Accessibility permission for the terminal running Bun (System Settings > Privacy & Security > Accessibility)

Install

curl -fsSL https://gitea.cahlen.com/nokeo08/Move/raw/branch/master/scripts/install.sh | sh

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:

  • Source lives at $XDG_DATA_HOME/move (default ~/.local/share/move).
  • Wrapper goes to $XDG_BIN_HOME/move (default ~/.local/bin/move). 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 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.

If your bin dir isn't on PATH, the installer prints the line to add to your shell rc. It will not modify rc files for you.

Run

move
move --help

Stop with Ctrl+C. If SIGINT arrives mid-sweep, the cursor stays at whichever step was last commanded — by design.

Uninstall

curl -fsSL https://gitea.cahlen.com/nokeo08/Move/raw/branch/master/scripts/uninstall.sh | sh

Removes the wrapper at $XDG_BIN_HOME/move and the install tree at $XDG_DATA_HOME/move. Bun stays — it's your runtime, not ours.

Your config file at $XDG_CONFIG_HOME/move/config.json is intentionally left behind, whether you customized it or never touched the seeded defaults. The uninstaller prints a one-line notice pointing at the path so you can remove it manually if you want:

rm -rf "${XDG_CONFIG_HOME:-$HOME/.config}/move"

Usage

Usage: move [options]

Options:
  -h, --help                      Show this help and exit.
  -v, --version                   Print version and exit.
  -e, --edit                      Open the config file in $EDITOR and exit.
  -C, --config <path>             Load defaults from a JSON config file.
                                  Default path: see the Configuration section.
  -m, --move-interval <seconds>   Idle time before a sweep fires. Default: 240.
  -c, --check-interval <seconds>  Cursor poll cadence. Default: 10.
  -d, --step-delay <ms>           Pause between synthetic steps. Default: 50.
  -p, --pattern <name>            Movement strategy. Default: line.
                                  One of: line, diagonal, jitter, walk, arc,
                                  figureEight. Each pattern defines its own
                                  size and speed.
  -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.

Run move --help for the resolved default config-file path on your system. Numeric overrides are layered onto the defaults via resolveConfig in src/config.ts; time-valued inputs (-m, -c) are expressed in seconds at the CLI boundary and converted to milliseconds 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 and user-interrupt events.

Invalid input (unknown flag, missing value, non-positive number) prints an error to stderr and exits with code 2.

Configuration

move reads an optional JSON config file at:

${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. 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.

Precedence

CLI flags  >  config file  >  built-in defaults

CLI flags always win. The config file fills in any flag the user didn't pass on the command line. Built-in defaults fill in anything the file doesn't set.

Example

{
  "moveInterval":  240,
  "checkInterval": 10,
  "stepDelay":     50,
  "pattern":       "line",
  "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 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 notice rather than rejected, so a config seeded by an older install keeps working. Sweep size and step count are now properties of each pattern.

Editing

move -e         # or --edit
move --edit --config /path/to/another.json

Opens the active config file in $EDITOR (honors flags in the value, so EDITOR="code --wait" and EDITOR=vim both work). Refuses with exit 2 if:

  • $EDITOR is unset or empty.
  • The target file doesn't exist. (Run move once or reinstall to re-seed the default file.)

The editor's own exit code is propagated, so you can chain move -e && move to validate-by-running after every edit.

Validation

The loader is strict:

  • Root must be a JSON object.
  • Unknown keys are rejected (catches typos like "movInterval").
  • Numeric values must be finite and strictly positive.
  • pattern must resolve to a registered strategy name. Matching ignores 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.

Loop mode (--loop)

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:

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.

The source lives under src/, split into an entry point plus logic modules:

  • src/move.ts is a thin entry point: parses args, dispatches --help / --version, loads the config file, resolves the layered runtime config, and calls runKeeper(config).
  • src/cli.ts owns argument parsing, validation, and help/version output.
  • src/configFile.ts owns optional JSON config-file loading + strict schema validation.
  • src/config.ts exports the Config type (which carries every tunable including verbose), DEFAULT_CONFIG, defaultConfigPath, and the layered resolveConfig overlay function.
  • src/keeper.ts owns the idle-watch loop and the per-sweep glue that wires a strategy to the executor.

Movement itself is split across three seams so patterns are easy to add and everything but the raw nut.js call is unit-testable:

  • src/device.ts is the I/O boundary: a Device interface (getPosition/setPosition/width/height/sleep) plus the nut.js implementation. It's the only module that imports nut.js, and it's injectable, so tests drive the loop and executor with a fake.
  • src/strategies.ts holds the pure movement patterns — each a generator 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, 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:

Field Default CLI flag Purpose
moveInterval 4 * 60_000 -m, --move-interval Idle time (ms) required before a synthetic sweep fires.
checkInterval 10_000 -c, --check-interval How often (ms) the main loop polls the cursor for real activity.
stepDelay 50 -d, --step-delay Pause (ms) between individual synthetic steps in a sweep.
pattern "line" -p, --pattern Movement strategy name (see Movement strategies below).

-m and -c are accepted in seconds at the CLI; resolveConfig converts to milliseconds before handing the resolved Config to runKeeper.

Main loop (runKeeper)

  1. Print the startup banner (unconditional).
  2. Snapshot lastPos and lastActivity = now.
  3. Every config.checkInterval:
    • If the cursor moved since the last check, the user is active — reset lastActivity and lastPos, continue.
    • Otherwise, if now - lastActivity >= config.moveInterval, call simulateActivity and reset the idleness clock.

Synthetic sweep (simulateActivity + executePath)

  1. simulateActivity snapshots the starting position and current screen dimensions (re-read every sweep so monitor changes are handled), looks 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 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 snapping back.
  3. On a clean full sweep, restore the cursor to its starting position so 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 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
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 on-screen reflection, pacing, interrupt, and restore for free.

Why mouse.config.autoDelayMs = 0

nut.js inserts a 100ms delay after every action by default. With two mouse calls per step that would silently more-than-double the duration of a sweep. The code controls cadence itself via config.stepDelay, so the implicit delay is disabled in createNutDevice — the single place nut.js is wired up. Importing the movement modules stays side-effect-free.

For contributors

Clone the repo and bootstrap a dev environment:

git clone https://gitea.cahlen.com/nokeo08/Move.git
cd Move
./scripts/dev-setup.sh

scripts/dev-setup.sh verifies Bun is installed and runs bun install (with devDependencies, unlike the end-user scripts/install.sh). It operates at the repo root regardless of the CWD you invoke it from.

Run from the source tree:

bun run start                  # via the package.json script
bun run src/move.ts            # direct
bun run src/move.ts --help

Or install a global move pointed at your checkout:

bun link
move --help

Files

File Purpose
scripts/install.sh End-user installer; curl-pipeable from Gitea.
scripts/uninstall.sh End-user uninstaller; curl-pipeable from Gitea.
scripts/dev-setup.sh Contributor bootstrap (verify Bun + bun install).
scripts/config.default.json Single source of truth for default values: imported by src/config.ts and copied to $XDG_CONFIG_HOME/move/config.json on a fresh install.
src/move.ts CLI entry point: parses args, dispatches help/version, starts the loop.
src/cli.ts Argument parsing, validation, and help/version output.
src/config.ts Config type (carries every tunable, including verbose), DEFAULT_CONFIG (derived from scripts/config.default.json), defaultConfigPath, and the layered resolveConfig overlay.
src/configFile.ts Optional JSON config-file loader with strict schema validation.
src/editor.ts move --edit: opens the active config file in $EDITOR.
src/errors.ts Shared error types (CliError).
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: 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.
LICENSE GPLv3 license text.

License

GPL-3.0-only. See LICENSE.

S
Description
No description provided
Readme GPL-3.0
497 KiB
Languages
TypeScript 86.2%
Shell 13.8%