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:
1 parent
5f3c7c13d0
commit
c4533d12c0
4 files changed
+404
-4
No files matched your search
@@ -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,7 +69,10 @@ 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)
|
||||||
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()
|
return app.exec()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
)
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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"'
|
||||||
Reference in new issue
Block a user