Source code for exlab_wizard.tray.autostart
"""Per-platform autostart registration. Backend Spec §4.3.2 + §15.7.
Registers ``ExLab-Wizard-Tray`` to launch at user login. Reversible from
``Settings -> Application`` (Frontend §7).
Per-platform mechanism:
* **macOS** -- ``LaunchAgent`` plist at
``~/Library/LaunchAgents/com.exlab-wizard.tray.plist`` with
``RunAtLoad: true``.
* **Windows** -- ``HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run``
registry entry. Per-user only (no ``HKLM`` writes).
* **Linux** -- ``~/.config/systemd/user/exlab-wizard-tray.service`` plus
the XDG ``~/.config/autostart/exlab-wizard-tray.desktop`` fallback for
non-systemd setups (§15.7.1).
Tests inject a ``filesystem_root`` (or set the
``EXLAB_AUTOSTART_ROOT`` env var) so the manager writes to ``tmp_path``
instead of a real per-user location. Real per-OS registration is left
to the launcher in production.
"""
from __future__ import annotations
import contextlib
import os
import sys
from pathlib import Path
from typing import Any
from exlab_wizard.constants import Platform
from exlab_wizard.io import atomic_write_bytes
from exlab_wizard.logging import get_logger
__all__ = [
"AUTOSTART_DESKTOP_NAME",
"AUTOSTART_PLIST_NAME",
"AUTOSTART_REG_VALUE",
"AUTOSTART_SERVICE_NAME",
"AutostartManager",
]
_log = get_logger(__name__)
# Public constants -- referenced in tests and in §15.7's table.
AUTOSTART_PLIST_NAME = "com.exlab-wizard.tray.plist"
AUTOSTART_SERVICE_NAME = "exlab-wizard-tray.service"
AUTOSTART_DESKTOP_NAME = "exlab-wizard-tray.desktop"
AUTOSTART_REG_VALUE = "ExLabWizard"
_REG_RUN_KEY = r"Software\Microsoft\Windows\CurrentVersion\Run"
[docs]
class AutostartManager:
"""Per-platform autostart register / unregister / is_registered.
Construction-time parameters:
* ``executable_path`` -- absolute path to ``ExLab-Wizard-Tray``. The
production launcher resolves ``sys.executable`` (PyInstaller
bundles set ``sys.executable`` to the launcher binary).
* ``filesystem_root`` -- root directory for per-user files.
Defaults to ``Path.home()`` in production; tests inject
``tmp_path``. The env-var override ``EXLAB_AUTOSTART_ROOT`` wins
over the constructor default.
* ``platform`` -- override the OS dispatch, used by tests to
exercise every branch on a single host.
"""
def __init__(
self,
*,
executable_path: str | Path | None = None,
filesystem_root: Path | None = None,
platform: Platform | str | None = None,
) -> None:
self._executable = str(
Path(executable_path) if executable_path is not None else Path(sys.executable)
)
env_root = os.environ.get("EXLAB_AUTOSTART_ROOT")
if env_root:
self._fs_root = Path(env_root)
elif filesystem_root is not None:
self._fs_root = Path(filesystem_root)
else:
self._fs_root = Path.home()
self._platform = Platform(platform) if platform is not None else _detect_platform()
# ------------------------------------------------------------------
# Public API
# ------------------------------------------------------------------
[docs]
def is_registered(self) -> bool:
"""Return True iff a per-platform autostart record exists."""
match self._platform:
case Platform.MACOS:
return self._plist_path().exists()
case Platform.WINDOWS:
return self._win_reg_value() is not None
case Platform.LINUX:
return self._systemd_path().exists() or self._desktop_path().exists()
[docs]
def register(self) -> None:
"""Create the per-platform autostart record. Idempotent."""
match self._platform:
case Platform.MACOS:
self._write_plist()
case Platform.WINDOWS:
self._set_win_reg_value()
case Platform.LINUX:
self._register_linux()
[docs]
def unregister(self) -> None:
"""Remove the per-platform autostart record. Idempotent."""
match self._platform:
case Platform.MACOS:
_silently_unlink(self._plist_path())
case Platform.WINDOWS:
self._delete_win_reg_value()
case Platform.LINUX:
_silently_unlink(self._systemd_path())
_silently_unlink(self._desktop_path())
@property
def executable_path(self) -> str:
"""Return the absolute path the autostart record points at."""
return self._executable
@property
def platform(self) -> Platform:
"""Return the platform dispatch this manager is configured for."""
return self._platform
# ------------------------------------------------------------------
# macOS LaunchAgent
# ------------------------------------------------------------------
def _plist_path(self) -> Path:
return self._fs_root / "Library" / "LaunchAgents" / AUTOSTART_PLIST_NAME
def _write_plist(self) -> None:
path = self._plist_path()
path.parent.mkdir(parents=True, exist_ok=True)
atomic_write_bytes(path, _render_plist(self._executable).encode("utf-8"))
_log.info("registered macOS LaunchAgent at %s", path)
# ------------------------------------------------------------------
# Linux: systemd + .desktop fallback
# ------------------------------------------------------------------
def _systemd_path(self) -> Path:
return self._fs_root / ".config" / "systemd" / "user" / AUTOSTART_SERVICE_NAME
def _desktop_path(self) -> Path:
return self._fs_root / ".config" / "autostart" / AUTOSTART_DESKTOP_NAME
def _register_linux(self) -> None:
# Spec §15.7.1: prefer systemd-user; fall back to XDG .desktop.
self._write_systemd_unit()
self._write_desktop_file()
_log.info("registered Linux autostart (systemd + .desktop fallback)")
def _write_systemd_unit(self) -> None:
path = self._systemd_path()
path.parent.mkdir(parents=True, exist_ok=True)
atomic_write_bytes(path, _render_systemd_unit(self._executable).encode("utf-8"))
def _write_desktop_file(self) -> None:
path = self._desktop_path()
path.parent.mkdir(parents=True, exist_ok=True)
atomic_write_bytes(path, _render_desktop_file(self._executable).encode("utf-8"))
# ------------------------------------------------------------------
# Windows registry
# ------------------------------------------------------------------
def _set_win_reg_value(self) -> None:
winreg = _import_winreg()
with winreg.OpenKey(winreg.HKEY_CURRENT_USER, _REG_RUN_KEY, 0, winreg.KEY_SET_VALUE) as key:
winreg.SetValueEx(key, AUTOSTART_REG_VALUE, 0, winreg.REG_SZ, self._executable)
_log.info("registered Windows Run value %s", AUTOSTART_REG_VALUE)
def _delete_win_reg_value(self) -> None:
winreg = _import_winreg()
try:
with winreg.OpenKey(
winreg.HKEY_CURRENT_USER, _REG_RUN_KEY, 0, winreg.KEY_SET_VALUE
) as key:
winreg.DeleteValue(key, AUTOSTART_REG_VALUE)
except FileNotFoundError:
pass
def _win_reg_value(self) -> str | None:
winreg = _import_winreg()
try:
with winreg.OpenKey(
winreg.HKEY_CURRENT_USER, _REG_RUN_KEY, 0, winreg.KEY_QUERY_VALUE
) as key:
value, _kind = winreg.QueryValueEx(key, AUTOSTART_REG_VALUE)
return str(value)
except FileNotFoundError:
return None
# ---------------------------------------------------------------------------
# Renderers
# ---------------------------------------------------------------------------
def _render_plist(executable: str) -> str:
"""Return the macOS LaunchAgent plist body. Backend Spec §15.7.1."""
return (
'<?xml version="1.0" encoding="UTF-8"?>\n'
'<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" '
'"http://www.apple.com/DTDs/PropertyList-1.0.dtd">\n'
'<plist version="1.0">\n'
"<dict>\n"
" <key>Label</key>\n"
" <string>com.exlab-wizard.tray</string>\n"
" <key>ProgramArguments</key>\n"
" <array>\n"
f" <string>{executable}</string>\n"
" </array>\n"
" <key>RunAtLoad</key>\n"
" <true/>\n"
" <key>KeepAlive</key>\n"
" <dict>\n"
" <key>SuccessfulExit</key>\n"
" <false/>\n"
" </dict>\n"
"</dict>\n"
"</plist>\n"
)
def _render_systemd_unit(executable: str) -> str:
"""Return the Linux systemd-user unit body. Backend Spec §15.7.1."""
return (
"[Unit]\n"
"Description=ExLab-Wizard tray\n"
"\n"
"[Service]\n"
f"ExecStart={executable}\n"
"Restart=on-failure\n"
"\n"
"[Install]\n"
"WantedBy=default.target\n"
)
def _render_desktop_file(executable: str) -> str:
"""Return the XDG ``.desktop`` autostart body. Backend Spec §15.7.1."""
return (
"[Desktop Entry]\n"
"Type=Application\n"
"Name=ExLab-Wizard\n"
f"Exec={executable}\n"
"X-GNOME-Autostart-enabled=true\n"
"NoDisplay=false\n"
)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _detect_platform() -> Platform:
if sys.platform == "darwin":
return Platform.MACOS
if sys.platform == "win32":
return Platform.WINDOWS
return Platform.LINUX
def _silently_unlink(path: Path) -> None:
with contextlib.suppress(FileNotFoundError):
path.unlink()
def _import_winreg() -> Any:
"""Import ``winreg`` lazily so the module loads on macOS / Linux too.
Returns the imported module (typed as ``Any`` since the stdlib's
``winreg`` constants and functions are only typed on Windows; using
``Any`` here keeps the registry-specific call sites readable without
per-attribute ``# type: ignore`` markers). Raises ``RuntimeError``
when called on a non-Windows host without a winreg shim mounted
(tests inject a fake under ``sys.modules['winreg']`` to exercise the
registry path).
"""
try:
import winreg # type: ignore[import-not-found]
return winreg
except ImportError as exc: # pragma: no cover -- only on non-Windows
msg = "winreg unavailable on this platform"
raise RuntimeError(msg) from exc