Files
Move/README.md
T
nokeo08 df9305c6ac Add JSON config file support (XDG-respecting)
End users can now set defaults in a config file at:

  ${XDG_CONFIG_HOME:-$HOME/.config}/move/config.json

CLI flags continue to win when both are set:

  CLI flags  >  config file  >  built-in defaults

Schema mirrors the CLI flag names and units. Loader is strict: unknown
keys, wrong types, and non-positive numerics are rejected with a clear
message naming the file and key, and the process exits 2.

Changes:

- New src/configFile.ts: existence-aware loader + strict schema
  validation. Throws CliError; the entry point converts those to exit 2.
- src/config.ts: ConfigOverrides gains 'verbose'; resolveConfig takes
  both file and CLI override layers; new defaultConfigPath() honors
  XDG_CONFIG_HOME; new resolveVerbose() layers verbose with the
  presence-only-CLI semantics documented.
- src/cli.ts: -C/--config <path> flag. printHelp prints the default
  config path and the precedence rule.
- src/move.ts: loads the config file (default XDG path or --config),
  passes both override layers into resolveConfig, resolves verbose
  separately, exits 2 on any validation failure.
- README: new Configuration section with path, precedence, example,
  validation rules, and the verbose CLI-can't-turn-off limitation.
  Files table gains src/configFile.ts.
2026-06-17 12:27:50 -05:00

10 KiB

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 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

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.

Usage

Usage: move [options]

Options:
  -h, --help                      Show this help and exit.
  -v, --version                   Print version and exit.
  -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 <pixels>       Steps per sweep. Default: 250.
  -V, --verbose                   Log every sweep, interrupt, and bounds event
                                  (default prints only the startup banner).

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

If the file is missing, defaults are used (no config file is required). 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,
  "stepCount":     250,
  "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 pixels, verbose is a boolean.

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.
  • 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 four 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, verbose).
  • 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, DEFAULT_CONFIG, defaultConfigPath, and the layered resolveConfig / resolveVerbose overlay functions.
  • src/keeper.ts owns the synthetic-activity sweep and the idle-watch loop.

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 Pixel-steps per sweep.

-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)

  1. Read the starting position and current screen dimensions.
  2. Pick a horizontal direction (dx = +1 if there's room to the right, else -1) so the sweep stays on-screen. Vertical is dy = 0 for now.
  3. For each of config.stepCount steps:
    • Compute and bounds-check the next target.
    • Move the cursor there, sleep config.stepDelay.
    • Re-read the cursor. If it isn't where we put it, the user moved it — log (when --verbose) and return early without snapping back.
  4. 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.

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 script controls cadence itself via config.stepDelay, so the implicit delay is disabled at module load (a side effect of importing keeper.ts).

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).
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, DEFAULT_CONFIG, defaultConfigPath, and the layered resolveConfig / resolveVerbose overlay.
src/configFile.ts Optional JSON config-file loader with strict schema validation.
src/keeper.ts Synthetic-activity sweep and idle-watch loop.
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.