nokeo08 d5656efe5b Move 'mouse.config.autoDelayMs = 0' from module-load to runKeeper
The assignment used to run at import time, mutating the shared nut.js
singleton the second anything imported keeper.ts. Moved into the top
of runKeeper(), where the only caller actually needs it.

Same observable behavior — runKeeper is called exactly once per process
— but keeper.ts is now import-side-effect-free, which makes it
straightforward to import for tests or future tooling without touching
global mouse state.
2026-06-17 16:24:24 -05:00
2026-06-15 14:31:31 -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 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.

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.
  -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 <pixels>       Steps per sweep. Default: 250.
  -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

{
  "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).
  • 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 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).
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/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.

S
Description
No description provided
Readme GPL-3.0 191 KiB
Languages
TypeScript 81.4%
Shell 18.6%