From 1747f796a8115d6ff3284f1bcbbf9fe4e3615b7a Mon Sep 17 00:00:00 2001 From: awkure Date: Sun, 22 Mar 2026 10:44:41 +0300 Subject: [PATCH 1/4] feat(core): cross-platform login startup and config migration Add core/startup.py for Windows registry Run and macOS LaunchAgent. Adjust v5 migration: map start_with_windows only when present, drop legacy key. Tests for startup helpers, config migration, and autostart plist path. --- core/config.py | 13 +-- core/startup.py | 141 +++++++++++++++++++++++++++ tests/test_autostart.py | 4 +- tests/test_config.py | 28 +++++- tests/test_startup.py | 204 ++++++++++++++++++++++++++++++++++++++++ 5 files changed, 376 insertions(+), 14 deletions(-) create mode 100644 core/startup.py create mode 100644 tests/test_startup.py diff --git a/core/config.py b/core/config.py index 3fae1a8..637ec88 100644 --- a/core/config.py +++ b/core/config.py @@ -282,10 +282,11 @@ def _migrate(cfg): if version < 5: settings = cfg.setdefault("settings", {}) - if "start_at_login" not in settings: - settings["start_at_login"] = bool( - settings.get("start_with_windows", False) - ) + if "start_at_login" not in settings and "start_with_windows" in settings: + settings["start_at_login"] = bool(settings["start_with_windows"]) + else: + settings.setdefault("start_at_login", False) + settings.pop("start_with_windows", None) cfg["version"] = 5 if version < 6: @@ -295,10 +296,6 @@ def _migrate(cfg): cfg["version"] = 6 cfg.setdefault("settings", {}) - if "start_at_login" not in cfg["settings"]: - cfg["settings"]["start_at_login"] = bool( - cfg["settings"].get("start_with_windows", False) - ) cfg["settings"].setdefault("appearance_mode", "system") cfg["settings"].setdefault("debug_mode", False) cfg["settings"].setdefault("device_layout_overrides", {}) diff --git a/core/startup.py b/core/startup.py new file mode 100644 index 0000000..56e8dff --- /dev/null +++ b/core/startup.py @@ -0,0 +1,141 @@ +"""Cross-platform login startup: Windows HKCU Run and macOS LaunchAgent.""" + +import os +import plistlib +import subprocess +import sys + +# Windows +RUN_KEY = r"Software\Microsoft\Windows\CurrentVersion\Run" +RUN_VALUE_NAME = "Mouser" + +# macOS +MACOS_LAUNCH_AGENT_LABEL = "com.mouser.startup" +MACOS_PLIST_NAME = "com.mouser.startup.plist" + + +def supports_login_startup(): + return sys.platform in ("win32", "darwin") + + +def _quote_arg(s: str) -> str: + if not s: + return '""' + if " " in s or "\t" in s: + return '"' + s.replace('"', '\\"') + '"' + return s + + +def build_run_command() -> str: + """Windows: command line stored in the HKCU Run value.""" + exe = os.path.abspath(sys.executable) + exe_q = _quote_arg(exe) + if getattr(sys, "frozen", False): + return exe_q + script = os.path.abspath(sys.argv[0]) + return f"{exe_q} {_quote_arg(script)}" + + +def _program_arguments(): + """Argv list for macOS LaunchAgent ProgramArguments.""" + exe = os.path.abspath(sys.executable) + if getattr(sys, "frozen", False): + return [exe] + return [exe, os.path.abspath(sys.argv[0])] + + +def _get_winreg(): + import winreg + + return winreg + + +def _apply_windows(enabled: bool) -> None: + if sys.platform != "win32": + return + winreg = _get_winreg() + key = winreg.OpenKey( + winreg.HKEY_CURRENT_USER, + RUN_KEY, + 0, + winreg.KEY_SET_VALUE, + ) + try: + if enabled: + winreg.SetValueEx( + key, RUN_VALUE_NAME, 0, winreg.REG_SZ, build_run_command() + ) + else: + try: + winreg.DeleteValue(key, RUN_VALUE_NAME) + except FileNotFoundError: + pass + finally: + winreg.CloseKey(key) + + +def _macos_plist_path() -> str: + return os.path.expanduser( + os.path.join("~/Library/LaunchAgents", MACOS_PLIST_NAME) + ) + + +def _launchctl_run(args: list) -> subprocess.CompletedProcess: + return subprocess.run( + args, + capture_output=True, + text=True, + ) + + +def _apply_macos(enabled: bool) -> None: + if sys.platform != "darwin": + return + plist_path = _macos_plist_path() + launch_agents_dir = os.path.dirname(plist_path) + uid = os.getuid() + domain = f"gui/{uid}" + + if enabled: + os.makedirs(launch_agents_dir, exist_ok=True) + if os.path.isfile(plist_path): + _launchctl_run(["launchctl", "bootout", domain, plist_path]) + payload = { + "Label": MACOS_LAUNCH_AGENT_LABEL, + "ProgramArguments": _program_arguments(), + "RunAtLoad": True, + } + with open(plist_path, "wb") as f: + plistlib.dump(payload, f, fmt=plistlib.FMT_XML) + result = _launchctl_run(["launchctl", "bootstrap", domain, plist_path]) + if result.returncode != 0: + print( + f"[startup] launchctl bootstrap failed: {result.stderr.strip()}", + file=sys.stderr, + ) + else: + if os.path.isfile(plist_path): + _launchctl_run(["launchctl", "bootout", domain, plist_path]) + try: + os.remove(plist_path) + except OSError: + pass + else: + _launchctl_run( + ["launchctl", "bootout", domain, MACOS_LAUNCH_AGENT_LABEL] + ) + + +def apply_login_startup(enabled: bool) -> None: + if not supports_login_startup(): + return + if sys.platform == "win32": + _apply_windows(enabled) + elif sys.platform == "darwin": + _apply_macos(enabled) + + +def sync_from_config(enabled: bool) -> None: + """Ensure OS login startup matches config.""" + apply_login_startup(enabled) + diff --git a/tests/test_autostart.py b/tests/test_autostart.py index 49c591d..f05af5b 100644 --- a/tests/test_autostart.py +++ b/tests/test_autostart.py @@ -51,7 +51,9 @@ def test_enable_launch_at_login_writes_plist(self): with plist_path.open("rb") as handle: payload = plistlib.load(handle) - self.assertEqual(payload["ProgramArguments"][0], fake_executable) + self.assertEqual( + payload["ProgramArguments"][0], str(Path(fake_executable)) + ) self.assertEqual(payload["ProgramArguments"][-1], "--start-hidden") self.assertTrue(payload["RunAtLoad"]) diff --git a/tests/test_config.py b/tests/test_config.py index d0b7b7e..1bbb2f6 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -33,7 +33,6 @@ def test_migrate_v1_config_adds_profile_apps_and_gesture_defaults(self): self.assertEqual(migrated["version"], 6) self.assertEqual(migrated["profiles"]["default"]["apps"], []) - self.assertFalse(migrated["settings"]["start_at_login"]) self.assertFalse(migrated["settings"]["invert_hscroll"]) self.assertFalse(migrated["settings"]["invert_vscroll"]) self.assertEqual(migrated["settings"]["dpi"], 1000) @@ -44,6 +43,8 @@ def test_migrate_v1_config_adds_profile_apps_and_gesture_defaults(self): self.assertEqual(migrated["settings"]["appearance_mode"], "system") self.assertFalse(migrated["settings"]["debug_mode"]) self.assertEqual(migrated["settings"]["device_layout_overrides"], {}) + self.assertFalse(migrated["settings"]["start_at_login"]) + self.assertNotIn("start_with_windows", migrated["settings"]) self.assertEqual( migrated["profiles"]["default"]["mappings"]["gesture"], "none" ) @@ -69,14 +70,16 @@ def test_migrate_updates_media_player_profile_apps(self): migrated = config._migrate(cfg) + self.assertEqual(migrated["version"], 6) self.assertEqual( migrated["profiles"]["media"]["apps"], ["Microsoft.Media.Player.exe", "VLC.exe"], ) - self.assertFalse(migrated["settings"]["start_at_login"]) self.assertEqual(migrated["settings"]["appearance_mode"], "system") self.assertFalse(migrated["settings"]["debug_mode"]) self.assertEqual(migrated["settings"]["device_layout_overrides"], {}) + self.assertFalse(migrated["settings"]["start_at_login"]) + self.assertNotIn("start_with_windows", migrated["settings"]) def test_load_config_merges_missing_defaults_from_disk(self): partial = { @@ -106,6 +109,7 @@ def test_load_config_merges_missing_defaults_from_disk(self): ): loaded = config.load_config() + self.assertEqual(loaded["version"], 6) self.assertEqual(loaded["settings"]["dpi"], 800) self.assertFalse(loaded["settings"]["start_at_login"]) self.assertEqual(loaded["settings"]["gesture_threshold"], 50) @@ -245,11 +249,15 @@ def test_resolve_app_spec_for_mac_app_path_prefers_bundle_identifier(self): self.assertEqual(resolved["id"], "com.google.Chrome") self.assertEqual(resolved["label"], "Google Chrome") - self.assertEqual(resolved["path"], app_path) + self.assertTrue( + resolved["path"].replace("/", os.sep).endswith( + os.path.join("Applications", "Google Chrome.app") + ) + ) self.assertIn("Google Chrome", resolved["aliases"]) def test_resolve_app_spec_for_windows_exe_path_uses_curated_label(self): - app_path = "/Program Files/Google/Chrome/Application/chrome.exe" + app_path = r"C:\Program Files\Google\Chrome\Application\chrome.exe" with ( patch.object(app_catalog.sys, "platform", "win32"), @@ -259,7 +267,17 @@ def test_resolve_app_spec_for_windows_exe_path_uses_curated_label(self): self.assertEqual(resolved["id"], "chrome.exe") self.assertEqual(resolved["label"], "Google Chrome") - self.assertEqual(resolved["path"], os.path.abspath(app_path)) + self.assertTrue( + resolved["path"].replace("/", os.sep).endswith( + os.path.join( + "Program Files", + "Google", + "Chrome", + "Application", + "chrome.exe", + ) + ) + ) self.assertIn("chrome.exe", resolved["aliases"]) def test_resolve_app_spec_for_windows_terminal_alias(self): diff --git a/tests/test_startup.py b/tests/test_startup.py new file mode 100644 index 0000000..741d165 --- /dev/null +++ b/tests/test_startup.py @@ -0,0 +1,204 @@ +import sys +import unittest +from unittest.mock import MagicMock, mock_open, patch + +from core import startup as st + + +class BuildRunCommandTests(unittest.TestCase): + def test_frozen_uses_executable_only(self): + with ( + patch.object(sys, "executable", r"C:\Apps\Mouser App\Mouser.exe"), + patch.object(sys, "frozen", True, create=True), + ): + cmd = st.build_run_command() + self.assertEqual(cmd, r'"C:\Apps\Mouser App\Mouser.exe"') + + def test_script_appends_quoted_argv0(self): + with ( + patch.object(sys, "executable", r"C:\Python\python.exe"), + patch.object(sys, "frozen", False, create=True), + patch.object(sys, "argv", ["main_qml.py", "extra"]), + patch( + "os.path.abspath", + side_effect=lambda p: { + r"C:\Python\python.exe": r"C:\Python\python.exe", + "main_qml.py": r"C:\proj\main_qml.py", + }.get(p, p), + ), + ): + cmd = st.build_run_command() + self.assertEqual(cmd, r"C:\Python\python.exe C:\proj\main_qml.py") + + def test_script_quotes_paths_with_spaces(self): + with ( + patch.object(sys, "executable", r"C:\Program Files\Python\python.exe"), + patch.object(sys, "frozen", False, create=True), + patch.object(sys, "argv", [r"C:\My Project\main_qml.py"]), + patch("os.path.abspath", side_effect=lambda p: p), + ): + cmd = st.build_run_command() + self.assertEqual( + cmd, + r'"C:\Program Files\Python\python.exe" "C:\My Project\main_qml.py"', + ) + + def test_path_without_spaces_unquoted(self): + with ( + patch.object(sys, "executable", r"C:\Python\python.exe"), + patch.object(sys, "frozen", True, create=True), + ): + cmd = st.build_run_command() + self.assertEqual(cmd, r"C:\Python\python.exe") + + +class ApplyLoginStartupWindowsTests(unittest.TestCase): + def test_noop_when_unsupported(self): + with ( + patch.object(st, "supports_login_startup", return_value=False), + patch.object(st, "_get_winreg") as mock_get, + ): + st.apply_login_startup(True) + mock_get.assert_not_called() + + def test_enabled_sets_registry_value(self): + mock_wr = MagicMock() + mock_key = MagicMock() + mock_wr.HKEY_CURRENT_USER = 1 + mock_wr.KEY_SET_VALUE = 2 + mock_wr.REG_SZ = 1 + mock_wr.OpenKey.return_value = mock_key + + with ( + patch.object(sys, "platform", "win32"), + patch.object(st, "supports_login_startup", return_value=True), + patch.object(st, "_get_winreg", return_value=mock_wr), + patch.object(st, "build_run_command", return_value="THE_CMD"), + ): + st.apply_login_startup(True) + + mock_wr.OpenKey.assert_called_once() + mock_wr.SetValueEx.assert_called_once_with( + mock_key, st.RUN_VALUE_NAME, 0, mock_wr.REG_SZ, "THE_CMD" + ) + mock_wr.DeleteValue.assert_not_called() + mock_wr.CloseKey.assert_called_once_with(mock_key) + + def test_disabled_deletes_registry_value(self): + mock_wr = MagicMock() + mock_key = MagicMock() + mock_wr.HKEY_CURRENT_USER = 1 + mock_wr.KEY_SET_VALUE = 2 + mock_wr.REG_SZ = 1 + mock_wr.OpenKey.return_value = mock_key + + with ( + patch.object(sys, "platform", "win32"), + patch.object(st, "supports_login_startup", return_value=True), + patch.object(st, "_get_winreg", return_value=mock_wr), + ): + st.apply_login_startup(False) + + mock_wr.SetValueEx.assert_not_called() + mock_wr.DeleteValue.assert_called_once_with(mock_key, st.RUN_VALUE_NAME) + mock_wr.CloseKey.assert_called_once_with(mock_key) + + def test_disabled_ignores_missing_value(self): + mock_wr = MagicMock() + mock_key = MagicMock() + mock_wr.OpenKey.return_value = mock_key + mock_wr.DeleteValue.side_effect = FileNotFoundError() + + with ( + patch.object(sys, "platform", "win32"), + patch.object(st, "supports_login_startup", return_value=True), + patch.object(st, "_get_winreg", return_value=mock_wr), + ): + st.apply_login_startup(False) + + mock_wr.CloseKey.assert_called_once_with(mock_key) + + +class ApplyLoginStartupMacTests(unittest.TestCase): + def test_macos_enable_writes_plist_and_bootstraps(self): + plist = "/tmp/com.mouser.startup.plist" + domain = "gui/501" + + with ( + patch.object(sys, "platform", "darwin"), + patch("core.startup.os.getuid", return_value=501, create=True), + patch.object(st, "supports_login_startup", return_value=True), + patch.object(st, "_macos_plist_path", return_value=plist), + patch.object(st, "_program_arguments", return_value=["/X/Mouser"]), + patch.object(st, "_launchctl_run") as m_lc, + patch("os.makedirs") as m_makedirs, + patch("os.path.isfile", return_value=False), + patch("builtins.open", mock_open()) as m_open, + patch("core.startup.plistlib.dump"), + ): + m_lc.return_value = MagicMock(returncode=0) + st.apply_login_startup(True) + + m_makedirs.assert_called_once() + m_open.assert_called_once_with(plist, "wb") + self.assertEqual(m_lc.call_count, 1) + m_lc.assert_called_with( + ["launchctl", "bootstrap", domain, plist] + ) + + def test_macos_disable_bootout_and_remove_when_plist_exists(self): + plist = "/tmp/com.mouser.startup.plist" + domain = "gui/501" + + with ( + patch.object(sys, "platform", "darwin"), + patch("core.startup.os.getuid", return_value=501, create=True), + patch.object(st, "supports_login_startup", return_value=True), + patch.object(st, "_macos_plist_path", return_value=plist), + patch.object(st, "_launchctl_run") as m_lc, + patch("os.path.isfile", return_value=True), + patch("os.remove") as m_remove, + ): + m_lc.return_value = MagicMock(returncode=0) + st.apply_login_startup(False) + + self.assertEqual(m_lc.call_count, 1) + m_lc.assert_called_with( + ["launchctl", "bootout", domain, plist] + ) + m_remove.assert_called_once_with(plist) + + def test_macos_disable_uses_label_bootout_when_no_plist(self): + plist = "/tmp/com.mouser.startup.plist" + domain = "gui/501" + + with ( + patch.object(sys, "platform", "darwin"), + patch("core.startup.os.getuid", return_value=501, create=True), + patch.object(st, "supports_login_startup", return_value=True), + patch.object(st, "_macos_plist_path", return_value=plist), + patch.object(st, "_launchctl_run") as m_lc, + patch("os.path.isfile", return_value=False), + ): + m_lc.return_value = MagicMock(returncode=0) + st.apply_login_startup(False) + + m_lc.assert_called_once_with( + [ + "launchctl", + "bootout", + domain, + st.MACOS_LAUNCH_AGENT_LABEL, + ] + ) + + +class SyncFromConfigTests(unittest.TestCase): + def test_delegates_to_apply(self): + with patch.object(st, "apply_login_startup") as mock_apply: + st.sync_from_config(True) + mock_apply.assert_called_once_with(True) + + +if __name__ == "__main__": + unittest.main() From 806637ef62949d7a7315ef908903bf5094ffeb1e Mon Sep 17 00:00:00 2001 From: awkure Date: Sun, 22 Mar 2026 10:44:45 +0300 Subject: [PATCH 2/4] feat(ui): start-at-login via core.startup Backend syncs OS login items and applies user toggles. ScrollPage: gate login row on supportsStartAtLogin; start minimized independent. Update backend unit tests. --- tests/test_backend.py | 82 +++++++++++++++++++------------------------ ui/backend.py | 78 +++++++++++----------------------------- ui/qml/ScrollPage.qml | 12 +++---- 3 files changed, 64 insertions(+), 108 deletions(-) diff --git a/tests/test_backend.py b/tests/test_backend.py index 4118276..3f6df06 100644 --- a/tests/test_backend.py +++ b/tests/test_backend.py @@ -146,69 +146,61 @@ def resolve_app(spec): @unittest.skipIf(Backend is None, "PySide6 not installed in test environment") -class BackendAutostartTests(unittest.TestCase): - def _make_backend( - self, - *, - config=None, - autostart_supported=True, - launch_enabled=False, - ): - effective_config = copy.deepcopy(config or DEFAULT_CONFIG) +class BackendLoginStartupTests(unittest.TestCase): + def test_init_calls_sync_from_config_when_supported(self): + cfg = copy.deepcopy(DEFAULT_CONFIG) + cfg["settings"]["start_at_login"] = True with ( - patch("ui.backend.load_config", return_value=effective_config), - patch("ui.backend.save_config") as save_config_mock, - patch("ui.backend.autostart.is_supported", return_value=autostart_supported), - patch( - "ui.backend.autostart.is_launch_at_login_enabled", - return_value=launch_enabled, - ), + patch("ui.backend.load_config", return_value=cfg), + patch("ui.backend.save_config"), + patch("ui.backend.supports_login_startup", return_value=True), + patch("ui.backend.sync_login_startup_from_config") as sync_mock, ): - backend = Backend(engine=None) - return backend, save_config_mock - - def test_syncs_start_at_login_from_existing_launch_agent(self): - backend, save_config_mock = self._make_backend( - autostart_supported=True, - launch_enabled=True, - ) - - self.assertTrue(backend.startAtLogin) - save_config_mock.assert_called_once() + Backend(engine=None) + sync_mock.assert_called_once_with(True) - def test_set_start_at_login_uses_current_hidden_start_preference(self): - config = copy.deepcopy(DEFAULT_CONFIG) - config["settings"]["start_minimized"] = False + def test_init_clears_start_at_login_when_unsupported(self): + cfg = copy.deepcopy(DEFAULT_CONFIG) + cfg["settings"]["start_at_login"] = True + with ( + patch("ui.backend.load_config", return_value=cfg), + patch("ui.backend.save_config"), + patch("ui.backend.supports_login_startup", return_value=False), + patch("ui.backend.sync_login_startup_from_config") as sync_mock, + ): + backend = Backend(engine=None) + sync_mock.assert_not_called() + self.assertFalse(backend.startAtLogin) + def test_set_start_at_login_calls_apply(self): with ( - patch("ui.backend.load_config", return_value=config), + patch("ui.backend.load_config", return_value=copy.deepcopy(DEFAULT_CONFIG)), patch("ui.backend.save_config"), - patch("ui.backend.autostart.is_supported", return_value=True), - patch("ui.backend.autostart.is_launch_at_login_enabled", return_value=False), - patch("ui.backend.autostart.enable_launch_at_login") as enable_mock, + patch("ui.backend.supports_login_startup", return_value=True), + patch("ui.backend.sync_login_startup_from_config"), + patch("ui.backend.apply_login_startup") as apply_mock, ): backend = Backend(engine=None) backend.setStartAtLogin(True) - enable_mock.assert_called_once_with(start_hidden=False) + apply_mock.assert_called_once_with(True) self.assertTrue(backend.startAtLogin) - def test_set_start_minimized_refreshes_existing_login_item(self): - config = copy.deepcopy(DEFAULT_CONFIG) - config["settings"]["start_at_login"] = True - config["settings"]["start_minimized"] = True - + def test_set_start_minimized_does_not_call_apply_login_startup(self): + cfg = copy.deepcopy(DEFAULT_CONFIG) + cfg["settings"]["start_at_login"] = True with ( - patch("ui.backend.load_config", return_value=config), + patch("ui.backend.load_config", return_value=cfg), patch("ui.backend.save_config"), - patch("ui.backend.autostart.is_supported", return_value=True), - patch("ui.backend.autostart.is_launch_at_login_enabled", return_value=True), - patch("ui.backend.autostart.enable_launch_at_login") as enable_mock, + patch("ui.backend.supports_login_startup", return_value=True), + patch("ui.backend.sync_login_startup_from_config"), + patch("ui.backend.apply_login_startup") as apply_mock, ): backend = Backend(engine=None) + apply_mock.reset_mock() backend.setStartMinimized(False) - enable_mock.assert_called_once_with(start_hidden=False) + apply_mock.assert_not_called() self.assertFalse(backend.startMinimized) diff --git a/ui/backend.py b/ui/backend.py index 9cf7fd2..a38a8fb 100644 --- a/ui/backend.py +++ b/ui/backend.py @@ -11,7 +11,6 @@ from PySide6.QtCore import QObject, Property, Signal, Slot, Qt from core.accessibility import is_process_trusted -from core import autostart from core.config import ( BUTTON_NAMES, load_config, save_config, get_active_mappings, PROFILE_BUTTON_NAMES, set_mapping, create_profile, delete_profile, @@ -21,6 +20,11 @@ from core.device_layouts import get_device_layout, get_manual_layout_choices from core.logi_devices import DEFAULT_DPI_MAX, DEFAULT_DPI_MIN, clamp_dpi from core.key_simulator import ACTIONS, custom_action_label, valid_custom_key_names +from core.startup import ( + apply_login_startup, + supports_login_startup, + sync_from_config as sync_login_startup_from_config, +) def _action_label(action_id): @@ -62,7 +66,6 @@ def __init__(self, engine=None, parent=None): super().__init__(parent) self._engine = engine self._cfg = load_config() - self._autostart_supported = autostart.is_supported() self._mouse_connected = False self._device_display_name = "Logitech mouse" self._connected_device_key = "" @@ -113,37 +116,15 @@ def __init__(self, engine=None, parent=None): if hasattr(engine, "set_debug_enabled"): engine.set_debug_enabled(self.debugMode) self._mouse_connected = bool(getattr(engine, "device_connected", False)) - self._sync_autostart_state() + if supports_login_startup(): + sync_login_startup_from_config(self.startAtLogin) + else: + self._cfg.setdefault("settings", {})["start_at_login"] = False self._apply_device_layout( getattr(engine, "connected_device", None) if engine and self._mouse_connected else None ) - def _sync_autostart_state(self): - settings = self._settings() - if not self._autostart_supported: - settings["start_at_login"] = False - return - - enabled = autostart.is_launch_at_login_enabled() - if settings.get("start_at_login") != enabled: - settings["start_at_login"] = enabled - save_config(self._cfg) - - def _settings(self): - return self._cfg.setdefault("settings", {}) - - def _write_launch_at_login(self, enabled, *, start_hidden=None): - if enabled: - launch_hidden = ( - self.startMinimized if start_hidden is None else bool(start_hidden) - ) - autostart.enable_launch_at_login( - start_hidden=launch_hidden - ) - return - autostart.disable_launch_at_login() - # ── Properties ───────────────────────────────────────────── @Property(list, notify=mappingsChanged) @@ -231,7 +212,7 @@ def startAtLogin(self): @Property(bool, constant=True) def supportsStartAtLogin(self): - return self._autostart_supported + return supports_login_startup() @Property(bool, notify=settingsChanged) def invertVScroll(self): @@ -471,44 +452,27 @@ def setProfileMapping(self, profileName, button, actionId): @Slot(bool) def setStartMinimized(self, value): - enabled = bool(value) - settings = self._settings() - if settings.get("start_minimized", True) == enabled: + hidden = bool(value) + if self.startMinimized == hidden: return - - settings["start_minimized"] = enabled - status_message = ( - "Launch hidden after login enabled" if enabled - else "Launch hidden after login disabled" - ) - - if self._autostart_supported and self.startAtLogin: - try: - self._write_launch_at_login(True, start_hidden=enabled) - except Exception as exc: - status_message = f"Updated setting, but login item refresh failed: {exc}" - + self._cfg.setdefault("settings", {})["start_minimized"] = hidden save_config(self._cfg) self.settingsChanged.emit() - self.statusMessage.emit(status_message) + self.statusMessage.emit("Saved") @Slot(bool) def setStartAtLogin(self, value): enabled = bool(value) - if not self._autostart_supported: - self.statusMessage.emit("Start at login is only available on macOS") + if not supports_login_startup(): + self.statusMessage.emit( + "Start at login is only available on Windows and macOS" + ) return - - try: - self._write_launch_at_login(enabled) - except Exception as exc: - self._sync_autostart_state() - self.settingsChanged.emit() - self.statusMessage.emit(f"Failed to update login item: {exc}") + if self.startAtLogin == enabled: return - - self._settings()["start_at_login"] = enabled + self._cfg.setdefault("settings", {})["start_at_login"] = enabled save_config(self._cfg) + apply_login_startup(enabled) self.settingsChanged.emit() self.statusMessage.emit( "Start at login enabled" if enabled else "Start at login disabled" diff --git a/ui/qml/ScrollPage.qml b/ui/qml/ScrollPage.qml index 4727267..06f893c 100644 --- a/ui/qml/ScrollPage.qml +++ b/ui/qml/ScrollPage.qml @@ -461,13 +461,14 @@ Item { } Text { - text: "Start Mouser automatically after login, and optionally keep the settings window hidden." + text: "Start Mouser at login on Windows and macOS, and choose whether the settings window opens on launch or Mouser stays in the system tray." font { family: uiState.fontFamily pixelSize: 12 } color: scrollPage.theme.textSecondary wrapMode: Text.WordWrap + width: parent.width } Rectangle { @@ -475,6 +476,7 @@ Item { height: 52 radius: 10 color: scrollPage.theme.bgSubtle + visible: backend.supportsStartAtLogin RowLayout { anchors { @@ -497,7 +499,7 @@ Item { id: startAtLoginSwitch checked: backend.startAtLogin Material.accent: scrollPage.theme.accent - Accessible.name: "Start Mouser at login" + Accessible.name: "Start at login" onToggled: backend.setStartAtLogin(checked) } } @@ -508,7 +510,6 @@ Item { height: 52 radius: 10 color: scrollPage.theme.bgSubtle - opacity: startAtLoginSwitch.checked ? 1 : 0.55 RowLayout { anchors { @@ -518,7 +519,7 @@ Item { } Text { - text: "Launch hidden after login" + text: "Start minimized" font { family: uiState.fontFamily pixelSize: 13 @@ -530,9 +531,8 @@ Item { Switch { id: startMinimizedSwitch checked: backend.startMinimized - enabled: startAtLoginSwitch.checked Material.accent: scrollPage.theme.accent - Accessible.name: "Launch hidden after login" + Accessible.name: "Start minimized" onToggled: backend.setStartMinimized(checked) } } From 4654a43f7d5cb22635e59711212bba888a8a6104 Mon Sep 17 00:00:00 2001 From: awkure Date: Sun, 22 Mar 2026 10:44:46 +0300 Subject: [PATCH 3/4] feat(app): single instance and tray-first launch QLocalServer/socket second-instance handoff shows main window. launchHidden from start_minimized and --start-hidden; tray notice when minimized. Add single-instance unit tests. --- main_qml.py | 83 +++++++++++++++++++++++++++- tests/test_single_instance.py | 101 ++++++++++++++++++++++++++++++++++ 2 files changed, 183 insertions(+), 1 deletion(-) create mode 100644 tests/test_single_instance.py diff --git a/main_qml.py b/main_qml.py index 4cfbb17..31c7b93 100644 --- a/main_qml.py +++ b/main_qml.py @@ -12,6 +12,9 @@ import sys import os import signal +import hashlib +import getpass +import time from urllib.parse import parse_qs, unquote # Ensure project root on path — works for both normal Python and PyInstaller @@ -32,6 +35,7 @@ from PySide6.QtQml import QQmlApplicationEngine from PySide6.QtQuick import QQuickImageProvider from PySide6.QtSvg import QSvgRenderer +from PySide6.QtNetwork import QLocalServer, QLocalSocket, QAbstractSocket _t2 = _time.perf_counter() # Ensure PySide6 QML plugins are found @@ -41,6 +45,7 @@ os.environ.setdefault("QT_PLUGIN_PATH", os.path.join(_pyside_dir, "plugins")) _t3 = _time.perf_counter() +from core.config import load_config from core.engine import Engine from core.hid_gesture import set_backend_preference as set_hid_backend_preference from core.accessibility import is_process_trusted @@ -80,6 +85,57 @@ def _parse_cli_args(argv): return qt_argv, hid_backend, start_hidden +_SINGLE_INSTANCE_ACTIVATE_MSG = b"show" + + +def _single_instance_server_name() -> str: + raw = f"{getpass.getuser()}\0{sys.platform}" + digest = hashlib.sha256(raw.encode("utf-8", errors="replace")).hexdigest()[:16] + return f"mouser_instance_{digest}" + + +def _try_activate_existing_instance(server_name: str, timeout_ms: int = 500) -> bool: + sock = QLocalSocket() + sock.connectToServer(server_name) + if not sock.waitForConnected(timeout_ms): + return False + sock.write(_SINGLE_INSTANCE_ACTIVATE_MSG) + sock.waitForBytesWritten(timeout_ms) + sock.disconnectFromServer() + return True + + +def _drain_local_activate_socket(sock: QLocalSocket | None) -> None: + if not sock: + return + sock.waitForReadyRead(300) + sock.readAll() + sock.deleteLater() + + +def _single_instance_acquire(app: QApplication, server_name: str): + """Return (QLocalServer, None) if this process owns the instance, or (None, exit_code).""" + if _try_activate_existing_instance(server_name): + return None, 0 + server = QLocalServer(app) + QLocalServer.removeServer(server_name) + if server.listen(server_name): + return server, None + if server.serverError() != QAbstractSocket.SocketError.AddressInUseError: + print(f"[Mouser] single-instance server: {server.errorString()}") + return None, 1 + for _ in range(3): + time.sleep(0.05) + if _try_activate_existing_instance(server_name): + return None, 0 + QLocalServer.removeServer(server_name) + server.close() + if server.listen(server_name): + return server, None + print("[Mouser] Could not claim single-instance lock or reach running instance.") + return None, 1 + + def _app_icon() -> QIcon: if sys.platform == "darwin": icon = QIcon() @@ -314,6 +370,8 @@ def main(): _print_startup_times() _t5 = _time.perf_counter() argv, hid_backend, start_hidden = _parse_cli_args(sys.argv) + cfg_settings = load_config().get("settings", {}) + launch_hidden = start_hidden or bool(cfg_settings.get("start_minimized", True)) if hid_backend: try: set_hid_backend_preference(hid_backend) @@ -343,6 +401,11 @@ def _dump_threads(sig, frame): traceback.print_stack(sys._current_frames().get(t.ident)) signal.signal(signal.SIGUSR1, _dump_threads) + server_name = _single_instance_server_name() + single_server, single_exit = _single_instance_acquire(app, server_name) + if single_exit is not None: + sys.exit(single_exit) + _t6 = _time.perf_counter() # ── Engine (created but started AFTER UI is visible) ─────── engine = Engine() @@ -361,7 +424,7 @@ def _dump_threads(sig, frame): qml_engine.addImageProvider("systemicons", SystemIconProvider()) qml_engine.rootContext().setContextProperty("backend", backend) qml_engine.rootContext().setContextProperty("uiState", ui_state) - qml_engine.rootContext().setContextProperty("launchHidden", start_hidden) + qml_engine.rootContext().setContextProperty("launchHidden", launch_hidden) qml_engine.rootContext().setContextProperty( "applicationDirPath", ROOT.replace("\\", "/")) @@ -381,6 +444,12 @@ def show_main_window(): root_window.requestActivate() _activate_macos_window() + def _on_second_instance_activate(): + _drain_local_activate_socket(single_server.nextPendingConnection()) + show_main_window() + + single_server.newConnection.connect(_on_second_instance_activate) + print(f"[Startup] QApp create: {(_t6-_t5)*1000:7.1f} ms") print(f"[Startup] Engine create: {(_t7-_t6)*1000:7.1f} ms") print(f"[Startup] QML load: {(_t8-_t7)*1000:7.1f} ms") @@ -457,6 +526,18 @@ def quit_app(): ) else None) tray.show() + if launch_hidden and QSystemTrayIcon.isSystemTrayAvailable(): + + def _tray_minimized_notice(): + tray.showMessage( + "Mouser", + "Mouser is running in the system tray. Click the icon to open settings.", + QSystemTrayIcon.MessageIcon.Information, + 5000, + ) + + QTimer.singleShot(400, _tray_minimized_notice) + # ── Run ──────────────────────────────────────────────────── try: sys.exit(app.exec()) diff --git a/tests/test_single_instance.py b/tests/test_single_instance.py new file mode 100644 index 0000000..e8a8d90 --- /dev/null +++ b/tests/test_single_instance.py @@ -0,0 +1,101 @@ +import sys +import unittest +import uuid +from unittest.mock import MagicMock, patch + +try: + import main_qml + from PySide6.QtWidgets import QApplication + from PySide6.QtNetwork import QLocalServer +except Exception: # pragma: no cover - env without PySide6 / project deps + main_qml = None + QApplication = None + QLocalServer = None + + +def _ensure_qapp(): + app = QApplication.instance() + if app is None: + return QApplication(sys.argv) + return app + + +@unittest.skipIf(main_qml is None, "main_qml / PySide6 not available") +class SingleInstanceServerNameTests(unittest.TestCase): + def test_server_name_format_and_stability(self): + with patch.object(main_qml.getpass, "getuser", return_value="testuser"): + a = main_qml._single_instance_server_name() + b = main_qml._single_instance_server_name() + self.assertEqual(a, b) + self.assertTrue(a.startswith("mouser_instance_")) + self.assertEqual(len(a), len("mouser_instance_") + 16) + + +@unittest.skipIf(main_qml is None, "main_qml / PySide6 not available") +class TryActivateExistingTests(unittest.TestCase): + @patch("main_qml.QLocalSocket") + def test_returns_false_when_not_connected(self, mock_sock_cls): + sock = MagicMock() + sock.waitForConnected.return_value = False + mock_sock_cls.return_value = sock + self.assertFalse(main_qml._try_activate_existing_instance("pipe_name")) + sock.write.assert_not_called() + + @patch("main_qml.QLocalSocket") + def test_returns_true_and_sends_payload_when_connected(self, mock_sock_cls): + sock = MagicMock() + sock.waitForConnected.return_value = True + sock.waitForBytesWritten.return_value = True + mock_sock_cls.return_value = sock + self.assertTrue(main_qml._try_activate_existing_instance("pipe_name")) + sock.connectToServer.assert_called_once_with("pipe_name") + sock.write.assert_called_once_with(main_qml._SINGLE_INSTANCE_ACTIVATE_MSG) + sock.disconnectFromServer.assert_called_once() + + +@unittest.skipIf(main_qml is None, "main_qml / PySide6 not available") +class SingleInstanceAcquireTests(unittest.TestCase): + @patch("main_qml._try_activate_existing_instance", return_value=True) + def test_secondary_instance_returns_exit_zero(self, _): + app = _ensure_qapp() + server, code = main_qml._single_instance_acquire(app, "any_name") + self.assertIsNone(server) + self.assertEqual(code, 0) + + @patch("main_qml._try_activate_existing_instance", return_value=False) + @patch.object(main_qml.QLocalServer, "removeServer") + def test_primary_gets_server_when_listen_succeeds(self, _remove, _try_act): + mock_server = MagicMock() + mock_server.listen.return_value = True + with patch("main_qml.QLocalServer", return_value=mock_server): + app = _ensure_qapp() + server, code = main_qml._single_instance_acquire(app, "unique_name") + self.assertIsNone(code) + self.assertIs(server, mock_server) + mock_server.listen.assert_called_once_with("unique_name") + + def test_primary_integration_unique_pipe(self): + app = _ensure_qapp() + name = f"mouser_unittest_{uuid.uuid4().hex}" + server, code = main_qml._single_instance_acquire(app, name) + self.addCleanup(lambda: (server.close(), QLocalServer.removeServer(name))) + self.assertIsNone(code) + self.assertIsNotNone(server) + self.assertTrue(server.isListening()) + + server2, code2 = main_qml._single_instance_acquire(app, name) + self.assertEqual(code2, 0) + self.assertIsNone(server2) + + +@unittest.skipIf(main_qml is None, "main_qml / PySide6 not available") +class DrainActivateSocketTests(unittest.TestCase): + def test_noop_when_sock_is_none(self): + main_qml._drain_local_activate_socket(None) + + def test_drains_when_sock_present(self): + mock_sock = MagicMock() + main_qml._drain_local_activate_socket(mock_sock) + mock_sock.waitForReadyRead.assert_called_once_with(300) + mock_sock.readAll.assert_called_once() + mock_sock.deleteLater.assert_called_once() From 4b4036f88cf46e33fa7cd4415f8da1d8056dec5c Mon Sep 17 00:00:00 2001 From: user Date: Wed, 25 Mar 2026 11:56:35 +0200 Subject: [PATCH 4/4] fix: correct start_minimized default and add error handling - Change start_minimized fallback from True to False so the app doesn't start hidden on first launch when the config key is missing. - Wrap apply_login_startup in try/except so registry or launchctl errors emit a status message instead of crashing. --- main_qml.py | 2 +- ui/backend.py | 7 ++++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/main_qml.py b/main_qml.py index 31c7b93..e7301ef 100644 --- a/main_qml.py +++ b/main_qml.py @@ -371,7 +371,7 @@ def main(): _t5 = _time.perf_counter() argv, hid_backend, start_hidden = _parse_cli_args(sys.argv) cfg_settings = load_config().get("settings", {}) - launch_hidden = start_hidden or bool(cfg_settings.get("start_minimized", True)) + launch_hidden = start_hidden or bool(cfg_settings.get("start_minimized", False)) if hid_backend: try: set_hid_backend_preference(hid_backend) diff --git a/ui/backend.py b/ui/backend.py index a38a8fb..c8f04a6 100644 --- a/ui/backend.py +++ b/ui/backend.py @@ -472,7 +472,12 @@ def setStartAtLogin(self, value): return self._cfg.setdefault("settings", {})["start_at_login"] = enabled save_config(self._cfg) - apply_login_startup(enabled) + try: + apply_login_startup(enabled) + except Exception as exc: + self.settingsChanged.emit() + self.statusMessage.emit(f"Failed to update login item: {exc}") + return self.settingsChanged.emit() self.statusMessage.emit( "Start at login enabled" if enabled else "Start at login disabled"