Run in the background and open when MTGA starts

- Closing the window hides it to the system tray; tracking and history
  recording continue. Quit from the tray, the menu or Ctrl+Q.
- Show the window when MTGA starts a new Player.log, detected with OS
  file notifications on the log folder (QFileSystemWatcher), not polling.
- Single instance over a local socket: launching again shows the
  running window (or stays hidden with --background).
- Menu options for background mode, show-on-launch and start at login
  (XDG autostart on Linux, HKCU Run key on Windows); --background flag.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Aeth3randClaude Opus 5.5 committed 2026-10-19 09:54:08 -04:00
1 parent 5f3c7c13d0
commit c4533d12c0
4 files changed
+403 -3

No files matched your search

+16 -1
View File
@@ -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 from __future__ import annotations
@@ -10,6 +10,7 @@ from PySide6.QtGui import QIcon
from PySide6.QtWidgets import QApplication from PySide6.QtWidgets import QApplication
from . import paths from . import paths
from .background import SingleInstance
from .carddb import open_card_db from .carddb import open_card_db
from .history import HistoryStore from .history import HistoryStore
from .images import ImageCache 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("--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("--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("--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() args, qt_args = ap.parse_known_args()
cfg = paths.load_config() cfg = paths.load_config()
@@ -37,6 +40,15 @@ def main() -> int:
ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID("mtga-assistant") ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID("mtga-assistant")
app = QApplication([sys.argv[0], *qt_args]) 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.setWindowIcon(QIcon(str(Path(__file__).with_name("icon.png"))))
app.setApplicationName("MTGA Assistant") app.setApplicationName("MTGA Assistant")
app.setDesktopFileName("mtga-assistant") # Wayland app_id, usable in Hyprland window rules app.setDesktopFileName("mtga-assistant") # Wayland app_id, usable in Hyprland window rules
@@ -57,6 +69,9 @@ def main() -> int:
# Matches from the previous MTGA session are only in Player-prev.log. # Matches from the previous MTGA session are only in Player-prev.log.
win.import_history(log_path.with_name("Player-prev.log")) win.import_history(log_path.with_name("Player-prev.log"))
app.aboutToQuit.connect(win.shutdown) app.aboutToQuit.connect(win.shutdown)
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() win.show()
return app.exec() return app.exec()
+190
View File
@@ -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"
)
+92 -2
View File
@@ -8,6 +8,7 @@ from pathlib import Path
from PySide6.QtCore import QSettings, Qt, QThread, QTimer, Signal from PySide6.QtCore import QSettings, Qt, QThread, QTimer, Signal
from PySide6.QtGui import QAction, QFont, QFontMetrics, QKeySequence from PySide6.QtGui import QAction, QFont, QFontMetrics, QKeySequence
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QApplication,
QButtonGroup, QButtonGroup,
QFileDialog, QFileDialog,
QFrame, QFrame,
@@ -19,12 +20,14 @@ from PySide6.QtWidgets import (
QPushButton, QPushButton,
QSizePolicy, QSizePolicy,
QStackedWidget, QStackedWidget,
QSystemTrayIcon,
QToolButton, QToolButton,
QVBoxLayout, QVBoxLayout,
QWidget, QWidget,
) )
from .. import paths from .. import paths
from ..background import LaunchWatcher, autostart_enabled, set_autostart
from ..carddb import CardDB from ..carddb import CardDB
from ..gamestate import MatchTracker from ..gamestate import MatchTracker
from ..history import HistoryRecorder, HistoryStore, import_log, replay_game from ..history import HistoryRecorder, HistoryStore, import_log, replay_game
@@ -213,6 +216,9 @@ class MainWindow(QMainWindow):
self.clock_timer.start() self.clock_timer.start()
self.resize(1400, 880) self.resize(1400, 880)
self._quitting = False
self.launch_watcher: LaunchWatcher | None = None
self._setup_tray()
self.history_view.set_games(history.games()) self.history_view.set_games(history.games())
self.show_view(int(self._settings.value("view2", MATCH, type=int))) self.show_view(int(self._settings.value("view2", MATCH, type=int)))
if log_path is not None: if log_path is not None:
@@ -235,9 +241,26 @@ class MainWindow(QMainWindow):
clear_act = menu.addAction("Clear image cache") clear_act = menu.addAction("Clear image cache")
clear_act.triggered.connect(self._clear_cache) clear_act.triggered.connect(self._clear_cache)
menu.addSeparator() 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 = QAction("Quit", self)
quit_act.setShortcut(QKeySequence.StandardKey.Quit) 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 self.addAction(quit_act) # keep the shortcut active while the menu is closed
unpreview = QAction("Stop previewing", self) unpreview = QAction("Stop previewing", self)
unpreview.setShortcut(QKeySequence(Qt.Key.Key_Escape)) unpreview.setShortcut(QKeySequence(Qt.Key.Key_Escape))
@@ -310,6 +333,12 @@ class MainWindow(QMainWindow):
self.follower.status.connect(self.status_log.setText) self.follower.status.connect(self.status_log.setText)
self.follower.reset.connect(self._reset_tracker) self.follower.reset.connect(self._reset_tracker)
self.follower.start() 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: def _reset_tracker(self) -> None:
self.tracker = MatchTracker(card_lookup=self.cards) self.tracker = MatchTracker(card_lookup=self.cards)
@@ -369,9 +398,68 @@ class MainWindow(QMainWindow):
self.follower = None self.follower = None
self.images.shutdown() 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: 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) 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 ------------------------------------------------------------- # -- rendering -------------------------------------------------------------
@@ -391,6 +479,8 @@ class MainWindow(QMainWindow):
self.match_view.tick(self.shown_tracker, self.now_ms()) self.match_view.tick(self.shown_tracker, self.now_ms())
def refresh(self) -> None: def refresh(self) -> None:
if not self.isVisible():
return # tracking continues; the views catch up when shown
t = self.shown_tracker t = self.shown_tracker
index = self.stack.currentIndex() index = self.stack.currentIndex()
key = (id(t), t.version) key = (id(t), t.version)
+105
View File
@@ -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"'