Files
Move/README.md
T
nokeo08 ec33648e74 Remove stepCount/stepSize; patterns own their geometry
The stepCount and stepSize knobs were two controls for one quantity users
actually care about (reach), and the number of steps is an implementation
detail nobody meaningfully tunes. Each pattern has a natural size and
resolution — a jitter is inherently small, an arc a broad curve — so those
now live as constants in each strategy rather than as global config.

- strategies.ts: each pattern defines its own step count and size; MoveContext
  drops `config` down to pure geometry (start/width/height/rng), and the
  module no longer imports Config at all (dissolving the type-only-import
  cycle workaround). line stays byte-for-byte: 250 one-pixel steps.
- executor.ts: executePath takes `config` for pacing (stepDelay); the path
  itself needs nothing from it.
- config.ts / cli.ts / move.ts / config.default.json: drop stepCount and
  stepSize from the type, seed, validation, resolver, CLI flags (-n, -s),
  and help. stepDelay stays as the one pacing lever.
- configFile.ts: tolerate the removed keys instead of rejecting them — every
  pre-1.3.0 install seeded stepCount, so a hard "unknown key" failure on
  upgrade is avoided. They're ignored with a one-line stderr notice; genuine
  unknown keys still error.

The -n/--step-count CLI flag (shipped since 1.0.0) is now an unknown option;
config files degrade gracefully, command lines don't. Stays in the unpushed
1.3.0 release. 64 tests pass.
2026-08-14 12:56:22 -05:00

369 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.
-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, 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,
"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, `pattern` is a
movement strategy name, `verbose` is a boolean.
> 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
```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. |
| `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 | 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` |
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.
### 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`.