Files
Move/README.md
T
nokeo08 db3310c247 Add pluggable movement strategies (v1.3.0)
Turn the hardcoded straight-line sweep into a strategy system behind three
seams so new patterns are easy to add and, for the first time, testable
without nut.js or a real screen:

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

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

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

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

Tests: new suites for strategies, the executor (all bounds policies,
rounding, interrupt, tolerance, pacing), and the keeper loop; config and
configFile suites extended for pattern/stepSize. editor.test.ts moved to
tests/ for consistency. 64 pass.
2026-08-13 15:36:22 -05:00

368 lines
16 KiB
Markdown

# 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](https://bun.sh) >= 1.0.0
- macOS or Linux (relies on
[`@nut-tree-fork/nut-js`](https://github.com/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
```sh
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.
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.
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. |
| `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. |
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
```sh
move
move --help
```
Stop with `Ctrl+C`. If `SIGINT` arrives mid-sweep, the cursor stays at
whichever step was last commanded — by design.
## Uninstall
```sh
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:
```sh
rm -rf "${XDG_CONFIG_HOME:-$HOME/.config}/move"
```
## Usage
```text
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.
-n, --step-count <count> Steps per sweep. Default: 250.
-s, --step-size <pixels> Pixels moved per step. Default: 1.
-p, --pattern <name> Movement strategy. Default: line.
One of: line, diagonal, jitter, walk, arc,
figureEight.
-V, --verbose Log every sweep, interrupt, and bounds event
(default prints only the startup banner).
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, user-interrupt, and
out-of-bounds 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**. 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`).
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
```jsonc
{
"moveInterval": 240,
"checkInterval": 10,
"stepDelay": 50,
"stepCount": 250,
"stepSize": 1,
"pattern": "line",
"verbose": 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, `stepCount` is
a step count, `stepSize` is pixels-per-step, `pattern` is a movement
strategy name, `verbose` is a boolean.
### Editing
```sh
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.
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
`--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`.
## How it works
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,
applies the strategy's bounds policy, 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. |
| `stepCount` | `250` | `-n`, `--step-count` | Number of steps per sweep. |
| `stepSize` | `1` | `-s`, `--step-size` | Pixels moved per step. |
| `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 apply the strategy's bounds policy
(`abort` / `clamp` / `reflect`) to keep it on-screen.
- 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.
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.
### Movement strategies
`config.pattern` selects one of the generators in `src/strategies.ts`:
| Name | Motion | Bounds |
| ------------- | ------------------------------------------------------------- | --------- |
| `line` | Straight horizontal sweep (the original behavior). | `abort` |
| `diagonal` | Straight line on both axes toward the roomiest corner. | `clamp` |
| `jitter` | Small random hops within a local radius that scales with reach. | `clamp` |
| `walk` | Cumulative random walk; bounces off the screen edges. | `reflect` |
| `arc` | Smooth quadratic-Bézier curve to a random on-screen point. | `clamp` |
| `figureEight` | Traces a figure-eight (lemniscate) and returns to the start. | `clamp` |
`stepCount` is the number of steps; `stepSize` is how many pixels each step
travels (so total reach is `stepCount * stepSize`). With the default
`stepSize` of 1, `line` produces the identical 1px-per-step path it always
has. To add a pattern, write one pure generator and register it — the
executor supplies bounds, 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:
```sh
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:
```sh
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:
```sh
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: bounds policy, pacing, interrupt detection, restore. |
| `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`.