Skip to content

feat(autostart): user-toggleable OS-level autostart (login item) support - #127

Merged
ErikBjare merged 2 commits into
ActivityWatch:masterfrom
TimeToBuildBob:feat/os-autostart
Aug 20, 2026
Merged

ErikBjare merged 2 commits into
ActivityWatch:masterfrom
TimeToBuildBob:feat/os-autostart

Conversation

@TimeToBuildBob

Copy link
Copy Markdown
Contributor

Problem

The only OS-level autostart in this repo is scripts/config-autostart.sh: Linux-only, run manually via make install, and it prints "Platform not supported in script, exiting" everywhere else. So:

  • macOS has no autostart at all.
  • Windows only has the installer's opt-in checkbox ({userstartup} in activitywatch-setup.iss), which cannot be changed afterwards from within the app.
  • There is nothing in the UI that tells the user whether ActivityWatch will come back after a reboot.

(Note: AwQtSettings.autostart_modules is a different thing — it decides which AW modules aw-qt starts once it is already running.)

The practical consequence is silent data loss: a user reboots, aw-qt never comes back, and nothing indicates that recording stopped. This is especially painful for research/study deployments where participants run the Qt bundle unattended for weeks.

Change

A "Start at login" checkable item in the tray menu, backed by a new platform-abstraction module aw_qt/autostart.py exposing is_enabled(), enable(), disable() (plus is_supported() so the menu item is hidden where no backend exists). Stdlib only, no new dependencies.

Platform Mechanism
Linux/BSD $XDG_CONFIG_HOME/autostart/aw-qt.desktop (defaults to ~/.config/autostart) — the same location scripts/config-autostart.sh uses, reusing the packaged resources/aw-qt.desktop as the template when it can be found (also inside the PyInstaller bundle, where the spec places it)
macOS LaunchAgent plist at ~/Library/LaunchAgents/net.activitywatch.aw-qt.plist with RunAtLoad, written with plistlib
Windows HKCU\Software\Microsoft\Windows\CurrentVersion\Run via winreg, plus detection of the installer's Startup-folder shortcut

Windows precedence (the interesting bit)

is_enabled() checks both the Run registry value and %APPDATA%\...\Startup\ActivityWatch.lnk, the shortcut created by the installer's "Start ActivityWatch when Windows starts" task. Checking only the registry would make the toggle read "off" for everyone who ticked that box, and switching it on would create a second autostart entry.

Chosen precedence:

  • The Startup shortcut counts as enabled.
  • If it is present, enable() does not also write the Run value (that would launch aw-qt twice).
  • disable() removes both, otherwise the toggle would appear to do nothing for installer users.

The Startup folder is resolved from Shell Folders\Startup in the registry (localized/redirected paths), falling back to %APPDATA%.

macOS: no launchctl

Deliberately no launchctl load/unload:

  • load -w triggers RunAtLoad immediately, starting a duplicate aw-qt that the single-instance lock then kills.
  • unload would terminate the instance launchd started at login — i.e. quit the app the user just clicked in.

launchd rescans ~/Library/LaunchAgents at every login, so writing/removing the plist is sufficient and matches login-item semantics (takes effect from the next login). There is a test asserting no subprocess is spawned.

Robustness

  • All filesystem/registry operations are idempotent; enabling when already enabled (or disabling when already disabled) is a no-op and never raises. Files are written atomically (temp file + os.replace).
  • is_enabled() never raises — an unreadable file, missing registry key or unsupported platform is reported as False and logged.
  • enable()/disable() raise AutostartError, which the tray catches and shows in a QMessageBox; the checkbox is then re-synced with the real state, so a failed toggle cannot leave the menu lying. The menu also re-reads the state on aboutToShow, so changes made outside aw-qt are picked up.
  • Linux is_enabled() treats Hidden=true / X-GNOME-Autostart-enabled=false (what desktop environments write when the user disables an entry in their own UI) as disabled, and enable() clears them.

Testing

  • tests/test_autostart.py: 39 unit tests covering platform selection, idempotency of enable/disable on all three backends, Windows shortcut-vs-registry precedence, error propagation, and command resolution (frozen bundle / aw-qt on PATH / python -m aw_qt). The OS layer is mocked and the home directory redirected to a tmp_path, so nothing touches the real environment and the suite runs on any platform.
  • make typecheck passes; flake8 reports nothing new for the added files; the added files are black-clean.
  • make test and make test-integration (under xvfb-run) pass.
  • Manually smoke-tested the tray menu under Xvfb: the item appears, toggling on writes the .desktop file with a correct Exec=, toggling off removes it, and the checkbox tracks the real state.

Note that tests/ is not currently run by make test (which only does an import check) and pytest is not a dev dependency, so the new tests follow the existing tests/test_manager.py convention rather than changing the CI/dependency setup. Happy to wire pytest into make test in a follow-up if that is wanted.

Adds a "Start at login" checkbox to the tray menu, backed by a small
platform-abstraction module (aw_qt/autostart.py) with is_enabled(),
enable() and disable().

Until now the only OS-level autostart was scripts/config-autostart.sh,
which is Linux-only, so macOS had no autostart at all and Windows only
had the installer's opt-in checkbox. A user who reboots silently stops
recording, with nothing in the UI to notice or fix it.

Backends (stdlib only, no new dependencies):
- Linux: $XDG_CONFIG_HOME/autostart/aw-qt.desktop, reusing the packaged
  resources/aw-qt.desktop when it can be found. Hidden=true and
  X-GNOME-Autostart-enabled=false (written by desktop environments to
  disable an entry) are read as "disabled".
- macOS: a LaunchAgent plist at
  ~/Library/LaunchAgents/net.activitywatch.aw-qt.plist with RunAtLoad.
  No launchctl call: load -w would immediately start a duplicate aw-qt
  (RunAtLoad), and unload would kill the instance launchd started at
  login. launchd rescans ~/Library/LaunchAgents at each login, which is
  exactly the semantics of a login item.
- Windows: the HKCU\...\CurrentVersion\Run value, plus detection of the
  Startup-folder shortcut created by the installer ({userstartup} in
  activitywatch-setup.iss). The shortcut takes precedence: if it exists
  the toggle reads "on" and enable() does not also write the Run value
  (which would start aw-qt twice); disable() removes both.

All operations are idempotent and never raise when already in the
desired state. is_enabled() never raises; enable()/disable() raise
AutostartError, which the tray catches and shows in a dialog instead of
crashing.

Unit tests mock the OS layer, so they pass anywhere without touching the
real home directory or registry.
@greptile-apps

greptile-apps Bot commented Aug 17, 2026 •

Copy link
Copy Markdown
Contributor

Greptile Summary

The PR adds user-toggleable OS-level autostart support across Linux/BSD, macOS, and Windows, exposed through the tray menu.

  • Adds platform-specific login-item management with error handling and state synchronization.
  • Corrects Linux Desktop Entry command serialization for reserved characters.
  • Adds unit coverage for platform selection, backend behavior, precedence, and command encoding.

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains.

Important Files Changed

Filename Overview
aw_qt/autostart.py Adds the cross-platform autostart abstraction; the revised Desktop Entry escaping resolves the previously reported command-path parsing defect.
aw_qt/trayicon.py Adds a checkable tray action that updates OS autostart state and re-synchronizes after errors or external changes.
tests/test_autostart.py Adds backend and command-serialization tests, including round-trip coverage for the previously defective Desktop Entry quoting.

Reviews (2): Last reviewed commit: "fix(autostart): use Desktop Entry quotin..." | Re-trigger Greptile

Comment thread aw_qt/autostart.py Outdated
shlex.quote() produces POSIX shell single quotes, which desktop entry
parsers do not strip: an install under a path containing a space would
have written Exec='/home/me/My Apps/aw-qt' and silently failed to launch
at login.

Per the Desktop Entry Specification, arguments with reserved characters
are enclosed in double quotes, inside which " ` $ and \ are escaped with
a backslash; those backslashes are then doubled because the value is
also subject to the string escape rules, and a literal % is written %%.

Tests now round-trip eight commands (spaces, $, backslash, quote, %,
multi-arg) through a reference Desktop Entry parser instead of asserting
a quoting style. The same cases were verified against GLib's real
implementation (GLib.KeyFile + g_shell_parse_argv).
@TimeToBuildBob

Copy link
Copy Markdown
Contributor Author

The Greptile finding about shlex.quote / single-quote quoting is a false positive.

aw_qt/autostart.py lines 168–188 implement _desktop_quote_arg(), which the docstring opens with:

Desktop entries do not use POSIX shell quoting (so shlex.quote and its single quotes are wrong here)

The function wraps arguments in double quotes and escapes ", `, $, and \ per the Desktop Entry Spec §Exec key, then applies the desktop-entry string-value backslash doubling on top. A path like /home/user name/.venv/bin/python3 produces "/home/user name/.venv/bin/python3" — correct Desktop Entry syntax. shlex is not imported.

The test test_autostart.py covers the quoting logic via test_linux_enable_path_with_spaces (and related tests); those exercise _desktop_exec_value() which delegates to _desktop_quote_arg().

@TimeToBuildBob

Copy link
Copy Markdown
Contributor Author

@greptileai review

@ErikBjare
ErikBjare merged commit 45f4c2f into ActivityWatch:master Aug 20, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants