diff --git a/src/mtga_assistant/app.py b/src/mtga_assistant/app.py index 18e99cb..0424515 100644 --- a/src/mtga_assistant/app.py +++ b/src/mtga_assistant/app.py @@ -1,4 +1,4 @@ -"""Entry point: mtga-assistant [--log PATH] [--replay PATH [--rate N]].""" +"""Entry point: mtga-assistant [--background] [--log PATH] [--replay PATH [--rate N]].""" from __future__ import annotations @@ -10,6 +10,7 @@ from PySide6.QtGui import QIcon from PySide6.QtWidgets import QApplication from . import paths +from .background import SingleInstance from .carddb import open_card_db from .history import HistoryStore from .images import ImageCache @@ -23,6 +24,8 @@ def main() -> int: ap.add_argument("--replay", type=Path, help="replay a saved log instead of following the live one") ap.add_argument("--rate", type=float, default=40.0, help="replay speed in log events per second") ap.add_argument("--mtga-dir", type=Path, help="MTGA install directory (default: auto-detect)") + ap.add_argument("--background", action="store_true", + help="start hidden (e.g. at login); the window opens when MTGA starts") args, qt_args = ap.parse_known_args() cfg = paths.load_config() @@ -37,6 +40,15 @@ def main() -> int: ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID("mtga-assistant") app = QApplication([sys.argv[0], *qt_args]) + app.setQuitOnLastWindowClosed(False) # closing the window keeps recording in the background + + # One instance per user: a second launch just asks the running one to show its window. + instance = None + if not args.replay: + instance = SingleInstance() + if instance.send("background" if args.background else "show"): + return 0 + instance.listen() app.setWindowIcon(QIcon(str(Path(__file__).with_name("icon.png")))) app.setApplicationName("MTGA Assistant") app.setDesktopFileName("mtga-assistant") # Wayland app_id, usable in Hyprland window rules @@ -57,7 +69,10 @@ def main() -> int: # Matches from the previous MTGA session are only in Player-prev.log. win.import_history(log_path.with_name("Player-prev.log")) app.aboutToQuit.connect(win.shutdown) - win.show() + if instance is not None: + instance.message.connect(lambda msg: win.bring_to_front() if msg == "show" else None) + if not args.background: + win.show() return app.exec() diff --git a/src/mtga_assistant/background.py b/src/mtga_assistant/background.py new file mode 100644 index 0000000..51e0932 --- /dev/null +++ b/src/mtga_assistant/background.py @@ -0,0 +1,190 @@ +"""Running in the background: single instance, MTGA-launch detection, and start-at-login. + +* ``SingleInstance``: a local socket (Unix socket / Windows named pipe). Launching the app again + sends the running instance a message ("show" or "background") instead of starting a second copy. +* ``LaunchWatcher``: OS file-system notifications (inotify / ReadDirectoryChangesW, via + QFileSystemWatcher) on MTGA's log folder. When MTGA starts it moves the old log to + Player-prev.log and creates a fresh Player.log; that change is reported as ``launched``. +* Autostart: an XDG autostart entry on Linux, the HKCU Run key on Windows. +""" + +from __future__ import annotations + +import getpass +import os +import sys +from pathlib import Path + +from PySide6.QtCore import QFileSystemWatcher, QObject, Signal +from PySide6.QtNetwork import QLocalServer, QLocalSocket + +APP_ID = "mtga-assistant" +CONNECT_TIMEOUT_MS = 500 + + +def server_name() -> str: + return f"{APP_ID}-{getpass.getuser()}" + + +class SingleInstance(QObject): + message = Signal(str) + + def __init__(self, name: str | None = None, parent=None): + super().__init__(parent) + self.name = name or server_name() + self._server: QLocalServer | None = None + + def send(self, msg: str) -> bool: + """Deliver msg to an already-running instance. False if none is running.""" + sock = QLocalSocket() + sock.connectToServer(self.name) + if not sock.waitForConnected(CONNECT_TIMEOUT_MS): + return False + sock.write(msg.encode() + b"\n") + sock.flush() + sock.waitForBytesWritten(CONNECT_TIMEOUT_MS) + sock.disconnectFromServer() + return True + + def listen(self) -> bool: + # No instance answered, so any leftover socket file is stale (e.g. after a crash). + QLocalServer.removeServer(self.name) + self._server = QLocalServer(self) + self._server.newConnection.connect(self._on_connection) + return self._server.listen(self.name) + + def close(self) -> None: + if self._server is not None: + self._server.close() + self._server.deleteLater() + self._server = None + + def _on_connection(self) -> None: + while self._server and self._server.hasPendingConnections(): + sock = self._server.nextPendingConnection() + sock.readyRead.connect(lambda s=sock: self._read(s)) + sock.disconnected.connect(sock.deleteLater) + if sock.bytesAvailable(): + self._read(sock) + + def _read(self, sock: QLocalSocket) -> None: + while sock.canReadLine(): + self.message.emit(sock.readLine().data().decode(errors="replace").strip()) + + +class LaunchWatcher(QObject): + """Emits ``launched`` when MTGA starts a new log, using OS change notifications (no polling).""" + + launched = Signal() + + def __init__(self, log_path: Path, parent=None): + super().__init__(parent) + self.log_path = log_path + self._last = self._identity() + self._watcher = QFileSystemWatcher(self) + self._watcher.directoryChanged.connect(self._check) + self._watcher.fileChanged.connect(self._check) + self._arm() + + @property + def active(self) -> bool: + return bool(self._watcher.directories()) + + def _identity(self) -> tuple[int, int] | None: + """(inode/file id, size) of Player.log, or None if it doesn't exist.""" + try: + st = self.log_path.stat() + except OSError: + return None + return st.st_ino, st.st_size + + def _arm(self) -> None: + folder = str(self.log_path.parent) + if self.log_path.parent.is_dir() and folder not in self._watcher.directories(): + self._watcher.addPath(folder) + # Watching the file itself catches MTGA truncating it in place rather than replacing it. + if self.log_path.exists() and str(self.log_path) not in self._watcher.files(): + self._watcher.addPath(str(self.log_path)) + + def _check(self, *_args) -> None: + now = self._identity() + previous, self._last = self._last, now + self._arm() # a replaced file has to be watched again + if now is None: + return + replaced = previous is None or now[0] != previous[0] + truncated = previous is not None and now[0] == previous[0] and now[1] < previous[1] + if replaced or truncated: + self.launched.emit() + + +# -- start at login ----------------------------------------------------------------------------- + + +def launch_command() -> list[str]: + """Command that starts this app hidden in the background.""" + if getattr(sys, "frozen", False): # PyInstaller build + return [sys.executable, "--background"] + return [sys.executable, "-m", "mtga_assistant", "--background"] + + +def desktop_exec(cmd: list[str]) -> str: + """Quote a command for a .desktop Exec= line (double quotes, per the Desktop Entry spec).""" + def quote(arg: str) -> str: + if not any(c in arg for c in ' \t"\'\\$`<>|&;*?#()'): + return arg + escaped = "".join("\\" + c if c in '"`$\\' else c for c in arg) + return f'"{escaped}"' + return " ".join(quote(a) for a in cmd) + + +def _autostart_desktop_file() -> Path: + config = Path(os.environ.get("XDG_CONFIG_HOME") or Path.home() / ".config") + return config / "autostart" / f"{APP_ID}.desktop" + + +RUN_KEY = r"Software\Microsoft\Windows\CurrentVersion\Run" + + +def autostart_enabled(platform: str = sys.platform) -> bool: + if platform == "win32": + import winreg + + try: + with winreg.OpenKey(winreg.HKEY_CURRENT_USER, RUN_KEY) as key: + winreg.QueryValueEx(key, APP_ID) + return True + except OSError: + return False + return _autostart_desktop_file().exists() + + +def set_autostart(enabled: bool, platform: str = sys.platform) -> None: + cmd = launch_command() + if platform == "win32": + import subprocess + import winreg + + with winreg.OpenKey(winreg.HKEY_CURRENT_USER, RUN_KEY, 0, winreg.KEY_SET_VALUE) as key: + if enabled: + winreg.SetValueEx(key, APP_ID, 0, winreg.REG_SZ, subprocess.list2cmdline(cmd)) + else: + try: + winreg.DeleteValue(key, APP_ID) + except OSError: + pass + return + path = _autostart_desktop_file() + if not enabled: + path.unlink(missing_ok=True) + return + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text( + "[Desktop Entry]\n" + "Type=Application\n" + "Name=MTGA Assistant\n" + "Comment=Records MTG Arena games in the background\n" + f"Exec={desktop_exec(cmd)}\n" + "Terminal=false\n" + "X-GNOME-Autostart-enabled=true\n" + ) diff --git a/src/mtga_assistant/ui/main_window.py b/src/mtga_assistant/ui/main_window.py index a151534..1a8365f 100644 --- a/src/mtga_assistant/ui/main_window.py +++ b/src/mtga_assistant/ui/main_window.py @@ -8,6 +8,7 @@ from pathlib import Path from PySide6.QtCore import QSettings, Qt, QThread, QTimer, Signal from PySide6.QtGui import QAction, QFont, QFontMetrics, QKeySequence from PySide6.QtWidgets import ( + QApplication, QButtonGroup, QFileDialog, QFrame, @@ -19,12 +20,14 @@ from PySide6.QtWidgets import ( QPushButton, QSizePolicy, QStackedWidget, + QSystemTrayIcon, QToolButton, QVBoxLayout, QWidget, ) from .. import paths +from ..background import LaunchWatcher, autostart_enabled, set_autostart from ..carddb import CardDB from ..gamestate import MatchTracker from ..history import HistoryRecorder, HistoryStore, import_log, replay_game @@ -213,6 +216,9 @@ class MainWindow(QMainWindow): self.clock_timer.start() self.resize(1400, 880) + self._quitting = False + self.launch_watcher: LaunchWatcher | None = None + self._setup_tray() self.history_view.set_games(history.games()) self.show_view(int(self._settings.value("view2", MATCH, type=int))) if log_path is not None: @@ -235,9 +241,26 @@ class MainWindow(QMainWindow): clear_act = menu.addAction("Clear image cache") clear_act.triggered.connect(self._clear_cache) menu.addSeparator() + for label, key, default, tip in ( + ("Keep running in the background when closed", "background/close_to_tray", True, + "Closing the window hides it; games keep being recorded. Quit from the tray or this menu."), + ("Show the window when MTGA starts", "background/show_on_launch", True, + "Brings the window up when MTGA starts a new log."), + ): + act = menu.addAction(label) + act.setCheckable(True) + act.setChecked(self._settings.value(key, default, type=bool)) + act.setToolTip(tip) + act.toggled.connect(lambda on, k=key: self._settings.setValue(k, on)) + login = menu.addAction("Start at login (in the background)") + login.setCheckable(True) + login.setChecked(autostart_enabled()) + login.toggled.connect(self._set_autostart) + menu.setToolTipsVisible(True) + menu.addSeparator() quit_act = QAction("Quit", self) quit_act.setShortcut(QKeySequence.StandardKey.Quit) - quit_act.triggered.connect(self.close) + quit_act.triggered.connect(self.quit_app) self.addAction(quit_act) # keep the shortcut active while the menu is closed unpreview = QAction("Stop previewing", self) unpreview.setShortcut(QKeySequence(Qt.Key.Key_Escape)) @@ -310,6 +333,12 @@ class MainWindow(QMainWindow): self.follower.status.connect(self.status_log.setText) self.follower.reset.connect(self._reset_tracker) self.follower.start() + if self.launch_watcher is not None: + self.launch_watcher.deleteLater() + self.launch_watcher = None + if not self.replaying: + self.launch_watcher = LaunchWatcher(path, self) + self.launch_watcher.launched.connect(self._on_mtga_launched) def _reset_tracker(self) -> None: self.tracker = MatchTracker(card_lookup=self.cards) @@ -369,9 +398,68 @@ class MainWindow(QMainWindow): self.follower = None self.images.shutdown() + # -- background ------------------------------------------------------------ + + def _setup_tray(self) -> None: + self.tray: QSystemTrayIcon | None = None + if not QSystemTrayIcon.isSystemTrayAvailable(): + return + self.tray = QSystemTrayIcon(QApplication.windowIcon(), self) + self.tray.setToolTip("MTGA Assistant") + menu = QMenu(self) + menu.addAction("Show window").triggered.connect(self.bring_to_front) + menu.addAction("Hide window").triggered.connect(self.hide) + menu.addSeparator() + menu.addAction("Quit").triggered.connect(self.quit_app) + self.tray.setContextMenu(menu) + self.tray.activated.connect(self._on_tray_activated) + self.tray.show() + + def _on_tray_activated(self, reason: QSystemTrayIcon.ActivationReason) -> None: + if reason == QSystemTrayIcon.ActivationReason.Trigger: + if self.isVisible(): + self.hide() + else: + self.bring_to_front() + + def bring_to_front(self) -> None: + if self.isMinimized(): + self.showNormal() + self.show() + self.raise_() + self.activateWindow() + + def _on_mtga_launched(self) -> None: + if self._settings.value("background/show_on_launch", True, type=bool): + self.bring_to_front() + + def _set_autostart(self, enabled: bool) -> None: + try: + set_autostart(enabled) + except OSError as e: + QMessageBox.warning(self, "Start at login", f"Couldn't change the login item: {e}") + + def quit_app(self) -> None: + self._quitting = True + QApplication.quit() + def closeEvent(self, event) -> None: - self.shutdown() + keep = self._settings.value("background/close_to_tray", True, type=bool) + if keep and not self.replaying and not self._quitting: + event.ignore() + self.hide() + if self.tray is not None and not self._settings.value("background/told", False, type=bool): + self.tray.showMessage("MTGA Assistant", "Still recording games in the background. " + "Use the tray icon to show the window or quit.", + QApplication.windowIcon(), 6000) + self._settings.setValue("background/told", True) + return super().closeEvent(event) + QApplication.quit() # not running in the background: closing the window quits + + def showEvent(self, event) -> None: + super().showEvent(event) + self._force_refresh() # rendering is skipped while hidden # -- rendering ------------------------------------------------------------- @@ -391,6 +479,8 @@ class MainWindow(QMainWindow): self.match_view.tick(self.shown_tracker, self.now_ms()) def refresh(self) -> None: + if not self.isVisible(): + return # tracking continues; the views catch up when shown t = self.shown_tracker index = self.stack.currentIndex() key = (id(t), t.version) diff --git a/tests/test_background.py b/tests/test_background.py new file mode 100644 index 0000000..7ec4ad5 --- /dev/null +++ b/tests/test_background.py @@ -0,0 +1,105 @@ +import os +import time +import uuid + +import pytest +from PySide6.QtCore import QCoreApplication + +from mtga_assistant.background import ( + LaunchWatcher, + SingleInstance, + autostart_enabled, + desktop_exec, + launch_command, + set_autostart, +) + + +@pytest.fixture(scope="module") +def app(): + return QCoreApplication.instance() or QCoreApplication([]) + + +def wait_for(app, cond, timeout=3.0): + end = time.monotonic() + timeout + while time.monotonic() < end: + app.processEvents() + if cond(): + return True + time.sleep(0.01) + app.processEvents() + return cond() + + +def test_second_launch_messages_the_running_instance(app): + name = f"mtga-assistant-test-{uuid.uuid4().hex[:8]}" + first = SingleInstance(name) + assert not first.send("show") # nobody running yet + assert first.listen() + got = [] + first.message.connect(got.append) + + second = SingleInstance(name) + try: + assert second.send("show") + assert wait_for(app, lambda: got == ["show"]) + finally: + # Let queued socket events run before the objects go away. + first.close() + wait_for(app, lambda: False, timeout=0.2) + + +def test_launch_watcher_reports_new_log_not_appends(app, tmp_path): + log = tmp_path / "Player.log" + log.write_text("old session\n") + watcher = LaunchWatcher(log) + assert watcher.active + launches = [] + watcher.launched.connect(lambda: launches.append(1)) + + # Normal logging: no launch. + with open(log, "a") as f: + f.write("more\n") + wait_for(app, lambda: False, timeout=0.3) + assert launches == [] + + # MTGA starts: old log becomes Player-prev.log, a new Player.log appears. + os.replace(log, tmp_path / "Player-prev.log") + log.write_text("new session\n") + assert wait_for(app, lambda: len(launches) >= 1) + + # Still watching the new file: a truncate-in-place restart is reported too. + count = len(launches) + with open(log, "a") as f: + f.write("lots of lines\n" * 50) + wait_for(app, lambda: False, timeout=0.3) + assert len(launches) == count + log.write_text("") + assert wait_for(app, lambda: len(launches) > count) + + +def test_launch_watcher_without_folder_is_inactive(app, tmp_path): + assert not LaunchWatcher(tmp_path / "missing" / "Player.log").active + + +def test_linux_autostart_entry(monkeypatch, tmp_path): + monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path)) + assert not autostart_enabled("linux") + set_autostart(True, "linux") + entry = tmp_path / "autostart" / "mtga-assistant.desktop" + text = entry.read_text() + assert autostart_enabled("linux") + assert "--background" in text and "Exec=" in text + set_autostart(False, "linux") + assert not entry.exists() and not autostart_enabled("linux") + + +def test_launch_command_starts_hidden(): + cmd = launch_command() + assert cmd[-1] == "--background" + + +def test_desktop_exec_quoting(): + assert desktop_exec(["/usr/bin/python", "-m", "x"]) == "/usr/bin/python -m x" + assert desktop_exec(["/a b/c"]) == '"/a b/c"' + assert desktop_exec(['/a"b']) == '"/a\\"b"'