Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 5 additions & 8 deletions core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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", {})
Expand Down
141 changes: 141 additions & 0 deletions core/startup.py
Original file line number Diff line number Diff line change
@@ -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)

83 changes: 82 additions & 1 deletion main_qml.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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", False))
if hid_backend:
try:
set_hid_backend_preference(hid_backend)
Expand Down Expand Up @@ -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()
Expand All @@ -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("\\", "/"))

Expand All @@ -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")
Expand Down Expand Up @@ -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())
Expand Down
4 changes: 3 additions & 1 deletion tests/test_autostart.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"])

Expand Down
Loading
Loading