# 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 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 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 Load defaults from a JSON config file. Default path: see the Configuration section. -m, --move-interval Idle time before a sweep fires. Default: 240. -c, --check-interval Cursor poll cadence. Default: 10. -d, --step-delay Pause between synthetic steps. Default: 50. -p, --pattern Movement strategy. Default: line. One of: line, diagonal, jitter, walk, arc, figureEight, random. Each pattern defines its own size and speed. -r, --random Shorthand for --pattern random. Picks a different pattern for each sweep, never the same one twice in a row. In loop mode the pick holds for the whole loop run. -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 ` 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, "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 (or `"random"`), `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 ```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, or to `random`. Matching ignores case and separators (`-`, `_`, spaces), so `figure-eight` and `figureEight` are equivalent. There is no `random` boolean key — the CLI's `-r` is sugar for `--pattern random`, and the file spells it the same way. - `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: ```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: - `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, name validation, and the `random` picker. 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`, `-r` | Movement strategy name, or `random` (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`. When `config.pattern` is `random` — the one name the registry doesn't contain — the strategy comes from the picker instead, once per trigger. 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` (or `random`, which picks one for you): | 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 | | `random` | Meta-selection: a different one of the above per sweep. | — | — | ### Random (`-r` / `--pattern random`) `random` isn't a movement pattern of its own — it's a selection that resolves to one of the real patterns above each time a sweep fires: ```sh move -r # a different pattern every sweep move --pattern random -V # verbose names the pattern each sweep picked move -r --loop # one random pick, looped until you move the mouse ``` Two rules make it predictable: - **Never twice in a row.** Consecutive sweeps always use different patterns, so the motion visibly varies instead of occasionally repeating itself. - **One pick per trigger.** In loop mode a single trigger runs many cycles; the pattern is chosen once and holds for that whole run rather than changing mid-run. Because the pick is a real strategy, it behaves exactly as if you'd named it: `--verbose` logs the concrete pattern (`Simulating activity (arc)...`), and a pick with an infinite loop path (`line`, `diagonal`) bounces edge-to-edge under `--loop` just as selecting it directly would. `-r` and `--pattern` state the same setting two ways, so passing both is rejected (exit `2`) unless they agree — `move -r -p arc` is an error, while `move -r -p random` is a harmless no-op. 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: ```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, name validation, and the `random` picker. | | `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`.