The executor kept every commanded point on-screen via a per-strategy
BoundsPolicy of abort / clamp / reflect. Measured against the real
strategies, the other two earned nothing: abort truncated a sweep at the
first edge (line on a narrow screen ran only 90 of 250 steps), and clamp
could park the cursor against an edge (a monotonic ramp stalled 162 steps
in a row) -- both counter to the program's whole purpose of keeping the
cursor moving. reflect bounces off the edge and keeps going, and is
already what line/diagonal need in loop mode. arc's declared clamp was
provably dead code (it clamps its own endpoint, so no sample ever leaves
the screen).
Collapse to reflect-only:
- strategies.ts: remove the BoundsPolicy type and the `bounds` field from
the interface and all six strategies. Keep the local clamp() helper --
it's arc's endpoint geometry, not an on-screen policy; docstring says so.
- executor.ts: resolveTarget loses its policy parameter and its null
return and just reflects both axes; delete clampInt; SweepOutcome drops
"aborted"; ExecuteOptions drops `bounds`; remove the Out of bounds log.
- keeper.ts: loopOpts is now { restore: false, loop: true } -- the
reflect override added with loop mode is redundant.
- tests: drop the abort-outcome, clamp, and bounds-override tests; simplify
fixed() to take no policy; add a regression test that a monotonic ramp
past an edge never yields two identical points in a row (the guarantee
that motivated removing clamp).
Behavior is unchanged for every pattern at normal cursor positions
(verified: line's normal sweep is byte-identical). The only differences
are at a screen edge, where motion now bounces instead of stopping. No
config keys, flags, or pattern names changed.
Docs updated to match, including in-code comments, the README strategies
table (Bounds column removed) and verbose description, the sequence
diagram (resolveTarget signature + getPosition/width ordering + a loop-mode
note), and a CHANGELOG Changed entry.
437 lines
20 KiB
Markdown
437 lines
20 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 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 <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 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 <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,
|
|
"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, `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. Matching ignores
|
|
case and separators (`-`, `_`, spaces), so `figure-eight` and `figureEight`
|
|
are equivalent.
|
|
- `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 and name validation. 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` | 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 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`:
|
|
|
|
| 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 |
|
|
|
|
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, and name validation. |
|
|
| `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`.
|