diff --git a/.gitignore b/.gitignore
index 17cd2fc..4d53613 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,3 +2,5 @@
__pycache__/
*.pyc
.pytest_cache/
+build/
+dist/
diff --git a/README.md b/README.md
index e3e1c64..86316fe 100644
--- a/README.md
+++ b/README.md
@@ -33,22 +33,34 @@ Card names, mana costs and types come from MTGA's own local card database, so th
## Requirements
-- MTGA installed through Steam (Proton). The log, install and card DB are auto-detected from your
- Steam library folders.
+Runs on **Linux** and **Windows**.
+
- **Detailed logs enabled** in MTGA: *Options → Account → Detailed Logs (Plugin Support)*.
-- [uv](https://docs.astral.sh/uv/) (`pacman -S uv`).
+- MTGA's log, install folder and card database are auto-detected:
+ - Linux: MTGA through Steam (Proton), found via your Steam library folders.
+ - Windows: Steam, Epic Games or the standalone Wizards installer. The log is always in
+ `%USERPROFILE%\AppData\LocalLow\Wizards Of The Coast\MTGA\Player.log`.
+- To run from source: [uv](https://docs.astral.sh/uv/) (`pacman -S uv` on Arch;
+ `winget install astral-sh.uv` on Windows).
## Running
+From source (any OS):
+
```sh
uv run mtga-assistant # follow the live Player.log
uv run mtga-assistant --replay path/to/Player-prev.log --rate 40 # replay a saved log
uv run pytest # tests
```
+On Windows you can instead use the packaged build: unzip `MTGA-Assistant-windows.zip` and run
+`MTGA Assistant.exe`.
+
Start it before or after MTGA: on startup it reads the existing log to catch up on a match in
progress, then follows new lines. When MTGA restarts and replaces the log, the tracker resets.
+### Linux desktop integration
+
On Wayland the window's app id is `mtga-assistant`. For example, to keep it floating on another
workspace in Hyprland:
@@ -57,18 +69,38 @@ windowrule = float, class:^(mtga-assistant)$
windowrule = workspace 2, class:^(mtga-assistant)$
```
+### Building the Windows app
+
+PyInstaller can't cross-compile, so build on Windows (with uv installed):
+
+```powershell
+powershell -ExecutionPolicy Bypass -File packaging\build_windows.ps1
+```
+
+This produces `dist\MTGA Assistant\MTGA Assistant.exe` and `dist\MTGA-Assistant-windows.zip`.
+The same spec builds a Linux bundle when run on Linux
+(`uv run --group build pyinstaller packaging/mtga-assistant.spec`). After editing
+`assets/icon.svg`, regenerate the icons with `uv run python packaging/make_icon.py`.
+
## Configuration
-Optional: `~/.config/mtga-assistant/config.toml`
+Optional `config.toml`:
+
+| | Config file | History | Image cache |
+|---|---|---|---|
+| Linux | `~/.config/mtga-assistant/config.toml` | `~/.local/share/mtga-assistant/` | `~/.cache/mtga-assistant/` |
+| Windows | `%APPDATA%\mtga-assistant\config.toml` | `%LOCALAPPDATA%\mtga-assistant\` | `%LOCALAPPDATA%\mtga-assistant\Cache\` |
```toml
log_path = "/path/to/Player.log" # override auto-detection
-mtga_dir = "/path/to/steamapps/common/MTGA"
+mtga_dir = "/path/to/MTGA" # the folder containing MTGA_Data
cache_max_mb = 500 # image cache cap
history_max_mb = 300 # cap for stored game details (summaries are always kept)
```
-Game history lives in `~/.local/share/mtga-assistant/history.db` (roughly 20–100 KB per game).
+Game history is stored in `history.db` (roughly 20–100 KB per game). On Windows the app reads a
+private copy of MTGA's card database (kept in the cache folder, about 260 MB) so that MTGA's
+updater is never blocked by the app holding the file open.
## Image cache
diff --git a/assets/icon.ico b/assets/icon.ico
new file mode 100644
index 0000000..e3a8ef3
Binary files /dev/null and b/assets/icon.ico differ
diff --git a/assets/icon.svg b/assets/icon.svg
new file mode 100644
index 0000000..3a7106a
--- /dev/null
+++ b/assets/icon.svg
@@ -0,0 +1,11 @@
+
diff --git a/packaging/build_windows.ps1 b/packaging/build_windows.ps1
new file mode 100644
index 0000000..689baac
--- /dev/null
+++ b/packaging/build_windows.ps1
@@ -0,0 +1,13 @@
+# Build the Windows app folder and a zip of it: dist\MTGA Assistant\ and dist\MTGA-Assistant-windows.zip
+# Requires uv (https://docs.astral.sh/uv/). Run from anywhere:
+# powershell -ExecutionPolicy Bypass -File packaging\build_windows.ps1
+$ErrorActionPreference = "Stop"
+Set-Location (Split-Path $PSScriptRoot -Parent)
+
+uv sync --group build
+uv run pyinstaller --noconfirm --clean packaging/mtga-assistant.spec
+
+$zip = "dist/MTGA-Assistant-windows.zip"
+if (Test-Path $zip) { Remove-Item $zip }
+Compress-Archive -Path "dist/MTGA Assistant" -DestinationPath $zip
+Write-Host "Built dist\MTGA Assistant\MTGA Assistant.exe and $zip"
diff --git a/packaging/launch.py b/packaging/launch.py
new file mode 100644
index 0000000..aad29ed
--- /dev/null
+++ b/packaging/launch.py
@@ -0,0 +1,7 @@
+"""Entry point for the PyInstaller build (PyInstaller needs a script, not a package)."""
+
+import sys
+
+from mtga_assistant.app import main
+
+sys.exit(main())
diff --git a/packaging/make_icon.py b/packaging/make_icon.py
new file mode 100644
index 0000000..b16aa83
--- /dev/null
+++ b/packaging/make_icon.py
@@ -0,0 +1,51 @@
+"""Render assets/icon.svg to assets/icon.ico (PNG-compressed entries at the usual Windows sizes).
+
+Run with: uv run python packaging/make_icon.py
+"""
+
+import struct
+import sys
+from pathlib import Path
+
+from PySide6.QtCore import QBuffer, QByteArray, QIODevice, Qt
+from PySide6.QtGui import QGuiApplication, QImage, QPainter
+from PySide6.QtSvg import QSvgRenderer
+
+SIZES = [16, 24, 32, 48, 64, 128, 256]
+ROOT = Path(__file__).resolve().parent.parent
+
+
+def render_png(renderer: QSvgRenderer, size: int) -> bytes:
+ img = QImage(size, size, QImage.Format.Format_ARGB32)
+ img.fill(Qt.GlobalColor.transparent)
+ painter = QPainter(img)
+ painter.setRenderHint(QPainter.RenderHint.Antialiasing)
+ renderer.render(painter)
+ painter.end()
+ data = QByteArray()
+ buf = QBuffer(data)
+ buf.open(QIODevice.OpenModeFlag.WriteOnly)
+ img.save(buf, "PNG")
+ return bytes(data)
+
+
+def main() -> None:
+ QGuiApplication(sys.argv[:1])
+ renderer = QSvgRenderer(str(ROOT / "assets/icon.svg"))
+ pngs = [(size, render_png(renderer, size)) for size in SIZES]
+ # ICONDIR header, then one ICONDIRENTRY per image, then the PNG data.
+ out = struct.pack("= 256 else size # 0 means 256
+ out += struct.pack("=6.8",
+ "platformdirs>=4",
]
[project.scripts]
@@ -13,6 +14,7 @@ mtga-assistant = "mtga_assistant.app:main"
[dependency-groups]
dev = ["pytest>=8"]
+build = ["pyinstaller>=6"]
[build-system]
requires = ["hatchling"]
diff --git a/src/mtga_assistant/app.py b/src/mtga_assistant/app.py
index 9fcbca1..18e99cb 100644
--- a/src/mtga_assistant/app.py
+++ b/src/mtga_assistant/app.py
@@ -6,10 +6,11 @@ import argparse
import sys
from pathlib import Path
+from PySide6.QtGui import QIcon
from PySide6.QtWidgets import QApplication
from . import paths
-from .carddb import CardDB
+from .carddb import open_card_db
from .history import HistoryStore
from .images import ImageCache
from .ui import theme
@@ -30,14 +31,20 @@ def main() -> int:
if args.mtga_dir:
cfg.mtga_dir = args.mtga_dir
+ if sys.platform == "win32":
+ # Own taskbar identity (icon and grouping) instead of python.exe's.
+ import ctypes
+
+ ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID("mtga-assistant")
app = QApplication([sys.argv[0], *qt_args])
+ 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
theme.apply(app)
mtga_dir = paths.find_mtga_dir(cfg)
db_path = paths.find_card_db(mtga_dir) if mtga_dir else None
- cards = CardDB(db_path)
+ cards = open_card_db(db_path, paths.CACHE_DIR / "carddb")
images = ImageCache(paths.CACHE_DIR, cards, cfg.cache_max_mb * 1024 * 1024)
history = HistoryStore(paths.DATA_DIR / "history.db", cfg.history_max_mb)
diff --git a/src/mtga_assistant/carddb.py b/src/mtga_assistant/carddb.py
index a4999d9..42c8779 100644
--- a/src/mtga_assistant/carddb.py
+++ b/src/mtga_assistant/carddb.py
@@ -3,7 +3,9 @@
from __future__ import annotations
import re
+import shutil
import sqlite3
+import sys
from dataclasses import dataclass, field
from functools import lru_cache
from pathlib import Path
@@ -84,6 +86,37 @@ class CardInfo:
return " ".join(self.types)
+def local_copy(db: Path, copy_dir: Path) -> Path:
+ """Copy MTGA's card DB into our cache (once per DB version) and return the copy's path.
+
+ Used on Windows, where keeping MTGA's own file open would block its updater from replacing it.
+ The DB file name contains a content hash, so a copy with the same name and size is current.
+ """
+ copy_dir.mkdir(parents=True, exist_ok=True)
+ target = copy_dir / db.name
+ if not target.exists() or target.stat().st_size != db.stat().st_size:
+ tmp = target.with_suffix(".part")
+ shutil.copyfile(db, tmp)
+ tmp.replace(target)
+ for old in copy_dir.glob("Raw_CardDatabase_*.mtga"):
+ if old != target:
+ try:
+ old.unlink()
+ except OSError:
+ pass
+ return target
+
+
+def open_card_db(db: Path | None, copy_dir: Path, platform: str = sys.platform) -> "CardDB":
+ """Open the card DB; on Windows, via a private copy (see local_copy)."""
+ if db is not None and platform == "win32":
+ try:
+ db = local_copy(db, copy_dir)
+ except OSError:
+ pass # fall back to reading MTGA's file directly
+ return CardDB(db)
+
+
def unknown_card(grp_id: int) -> CardInfo:
return CardInfo(grp_id=grp_id, name=f"Unknown card #{grp_id}")
diff --git a/src/mtga_assistant/icon.png b/src/mtga_assistant/icon.png
new file mode 100644
index 0000000..b7f47e3
Binary files /dev/null and b/src/mtga_assistant/icon.png differ
diff --git a/src/mtga_assistant/logtail.py b/src/mtga_assistant/logtail.py
index 1c5e743..e7adf30 100644
--- a/src/mtga_assistant/logtail.py
+++ b/src/mtga_assistant/logtail.py
@@ -12,6 +12,7 @@ from .logparse import LogParser
POLL_INTERVAL = 0.25
BATCH_MAX = 200
+READ_MAX = 8 * 1024 * 1024 # per poll, so catching up on a big log stays responsive
class LogFollower(QThread):
@@ -50,9 +51,10 @@ class LogFollower(QThread):
self.status.emit(f"Replay of {self.path.name} finished")
def _follow(self) -> None:
+ # The file is only open while reading: on Windows an open handle would stop MTGA from
+ # renaming Player.log to Player-prev.log when it restarts.
parser = LogParser()
- f = None
- inode = None
+ inode: int | None = None # None until the first read
pos = 0
buf = b""
waiting_reported = False
@@ -68,25 +70,30 @@ class LogFollower(QThread):
continue
waiting_reported = False
- if f is None or st.st_ino != inode or st.st_size < pos:
- # First open, or MTGA restarted and replaced/truncated the log.
- if f is not None:
- f.close()
+ if inode is None or st.st_ino != inode or st.st_size < pos:
+ # First read, or MTGA restarted and replaced/truncated the log.
+ if inode is not None:
self.reset.emit()
- f = open(self.path, "rb")
inode, pos, buf = st.st_ino, 0, b""
parser.reset()
self.status.emit(f"Reading {self.path.name}")
following = False
- chunk = f.read()
+ chunk = b""
+ if st.st_size > pos:
+ try:
+ with open(self.path, "rb") as f:
+ f.seek(pos)
+ chunk = f.read(READ_MAX)
+ except OSError:
+ chunk = b"" # e.g. the file is being replaced right now; retry next poll
if not chunk:
if not following:
self.status.emit(f"Following {self.path.name}")
following = True
time.sleep(POLL_INTERVAL)
continue
- pos = f.tell()
+ pos += len(chunk)
buf += chunk
lines = buf.split(b"\n")
buf = lines.pop() # keep a trailing partial line for the next read
@@ -99,5 +106,3 @@ class LogFollower(QThread):
batch = []
if batch:
self.events.emit(batch)
- if f is not None:
- f.close()
diff --git a/src/mtga_assistant/paths.py b/src/mtga_assistant/paths.py
index db28b12..24d4288 100644
--- a/src/mtga_assistant/paths.py
+++ b/src/mtga_assistant/paths.py
@@ -1,29 +1,57 @@
-"""Locate MTGA's Player.log and install directory inside a Steam/Proton setup."""
+"""Locate MTGA's Player.log, install directory and card database, on Linux (Steam/Proton) or Windows.
+
+Detection functions take an ``Env`` (platform, home, environment variables, registry access) so the
+Windows logic can be tested anywhere; ``current_env()`` describes the machine we're running on.
+"""
from __future__ import annotations
import os
import re
+import sys
import tomllib
-from dataclasses import dataclass
+from collections.abc import Callable, Mapping
+from dataclasses import dataclass, field
from pathlib import Path
-MTGA_APP_ID = "2141910"
-LOG_SUBPATH = Path(
- "pfx/drive_c/users/steamuser/AppData/LocalLow/Wizards Of The Coast/MTGA"
-)
+import platformdirs
-CONFIG_DIR = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) / "mtga-assistant"
-CACHE_DIR = Path(os.environ.get("XDG_CACHE_HOME", Path.home() / ".cache")) / "mtga-assistant"
-DATA_DIR = Path(os.environ.get("XDG_DATA_HOME", Path.home() / ".local/share")) / "mtga-assistant"
+APP_NAME = "mtga-assistant"
+MTGA_APP_ID = "2141910" # Steam app id
+# Where MTGA writes Player.log, relative to the Windows user profile (also inside a Proton prefix).
+LOG_DIR_IN_PROFILE = Path("AppData/LocalLow/Wizards Of The Coast/MTGA")
+PROTON_PROFILE = Path("pfx/drive_c/users/steamuser")
+
+# On Linux these resolve to the XDG locations (~/.config, ~/.cache, ~/.local/share).
+CONFIG_DIR = Path(platformdirs.user_config_dir(APP_NAME, appauthor=False, roaming=True))
+CACHE_DIR = Path(platformdirs.user_cache_dir(APP_NAME, appauthor=False))
+DATA_DIR = Path(platformdirs.user_data_dir(APP_NAME, appauthor=False))
CONFIG_FILE = CONFIG_DIR / "config.toml"
-STEAM_ROOTS = [
- Path.home() / ".local/share/Steam",
- Path.home() / ".steam/steam",
- Path.home() / ".steam/root",
- Path.home() / ".var/app/com.valvesoftware.Steam/.local/share/Steam",
-]
+# (hive, key, value) -> registry string or None
+RegistryReader = Callable[[str, str, str], "str | None"]
+
+
+@dataclass
+class Env:
+ platform: str
+ home: Path
+ environ: Mapping[str, str] = field(default_factory=dict)
+ read_registry: RegistryReader | None = None
+ # (DisplayName, InstallLocation) pairs from the Windows uninstall registry keys.
+ uninstall_entries: Callable[[], list[tuple[str, str]]] | None = None
+
+ @property
+ def windows(self) -> bool:
+ return self.platform == "win32"
+
+
+def current_env() -> Env:
+ env = Env(platform=sys.platform, home=Path.home(), environ=os.environ)
+ if env.windows:
+ env.read_registry = _winreg_read
+ env.uninstall_entries = _winreg_uninstall_entries
+ return env
@dataclass
@@ -51,10 +79,31 @@ def load_config() -> Config:
return cfg
-def steam_libraries() -> list[Path]:
+# -- Steam -------------------------------------------------------------------------------
+
+
+def steam_roots(env: Env) -> list[Path]:
+ if env.windows:
+ roots = []
+ if env.read_registry and (p := env.read_registry("HKCU", r"Software\Valve\Steam", "SteamPath")):
+ roots.append(Path(p))
+ x86 = env.environ.get("ProgramFiles(x86)", r"C:\Program Files (x86)")
+ roots.append(Path(x86) / "Steam")
+ return roots
+ home = env.home
+ return [
+ home / ".local/share/Steam",
+ home / ".steam/steam",
+ home / ".steam/root",
+ home / ".var/app/com.valvesoftware.Steam/.local/share/Steam",
+ ]
+
+
+def steam_libraries(env: Env | None = None) -> list[Path]:
"""All Steam library folders listed in any libraryfolders.vdf we can find."""
+ env = env or current_env()
libs: list[Path] = []
- for root in STEAM_ROOTS:
+ for root in steam_roots(env):
vdf = root / "steamapps/libraryfolders.vdf"
try:
text = vdf.read_text(errors="replace")
@@ -69,40 +118,101 @@ def steam_libraries() -> list[Path]:
return libs
-def find_mtga_library() -> Path | None:
- for lib in steam_libraries():
+def find_mtga_library(env: Env | None = None) -> Path | None:
+ for lib in steam_libraries(env):
if (lib / f"steamapps/appmanifest_{MTGA_APP_ID}.acf").exists():
return lib
return None
-def find_log_path(cfg: Config) -> Path | None:
+# -- log and install -------------------------------------------------------------------------
+
+
+def find_log_path(cfg: Config, env: Env | None = None) -> Path | None:
if cfg.log_path:
return cfg.log_path
- libs = steam_libraries()
- mtga_lib = find_mtga_library()
- # The prefix normally lives in the same library as the game, but check all of them.
+ env = env or current_env()
+ if env.windows:
+ # Same place for Steam, Epic and the standalone installer.
+ profile = Path(env.environ.get("USERPROFILE") or env.home)
+ d = profile / LOG_DIR_IN_PROFILE
+ return d / "Player.log" if d.is_dir() else None
+ libs = steam_libraries(env)
+ mtga_lib = find_mtga_library(env)
+ # The Proton prefix normally lives in the same library as the game, but check all of them.
if mtga_lib:
- libs = [mtga_lib] + [l for l in libs if l != mtga_lib]
+ libs = [mtga_lib] + [lib for lib in libs if lib != mtga_lib]
for lib in libs:
- d = lib / "steamapps/compatdata" / MTGA_APP_ID / LOG_SUBPATH
+ d = lib / "steamapps/compatdata" / MTGA_APP_ID / PROTON_PROFILE / LOG_DIR_IN_PROFILE
if d.is_dir():
return d / "Player.log"
return None
-def find_mtga_dir(cfg: Config) -> Path | None:
+def _is_mtga_install(d: Path) -> bool:
+ return (d / "MTGA_Data").is_dir()
+
+
+def find_mtga_dir(cfg: Config, env: Env | None = None) -> Path | None:
if cfg.mtga_dir:
return cfg.mtga_dir
- lib = find_mtga_library()
- if lib:
- d = lib / "steamapps/common/MTGA"
- if d.is_dir():
- return d
- return None
+ env = env or current_env()
+ candidates: list[Path] = []
+ if lib := find_mtga_library(env):
+ candidates.append(lib / "steamapps/common/MTGA")
+ if env.windows:
+ program_files = Path(env.environ.get("ProgramFiles", r"C:\Program Files"))
+ candidates += [
+ program_files / "Wizards of the Coast/MTGA", # standalone installer
+ program_files / "Epic Games/MagicTheGathering", # Epic Games Store
+ ]
+ if env.uninstall_entries:
+ candidates += [Path(loc) for name, loc in env.uninstall_entries()
+ if loc and "magic" in name.lower() and "arena" in name.lower()]
+ return next((d for d in candidates if _is_mtga_install(d)), None)
def find_card_db(mtga_dir: Path) -> Path | None:
raw = mtga_dir / "MTGA_Data/Downloads/Raw"
dbs = sorted(raw.glob("Raw_CardDatabase_*.mtga"), key=lambda p: p.stat().st_mtime)
return dbs[-1] if dbs else None
+
+
+# -- Windows registry -----------------------------------------------------------------------------
+
+
+def _winreg_read(hive: str, key: str, value: str) -> str | None:
+ import winreg
+
+ root = {"HKCU": winreg.HKEY_CURRENT_USER, "HKLM": winreg.HKEY_LOCAL_MACHINE}[hive]
+ try:
+ with winreg.OpenKey(root, key) as k:
+ v, _type = winreg.QueryValueEx(k, value)
+ return str(v)
+ except OSError:
+ return None
+
+
+def _winreg_uninstall_entries() -> list[tuple[str, str]]:
+ import winreg
+
+ out = []
+ roots = [
+ (winreg.HKEY_LOCAL_MACHINE, r"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall"),
+ (winreg.HKEY_LOCAL_MACHINE, r"SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall"),
+ (winreg.HKEY_CURRENT_USER, r"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall"),
+ ]
+ for root, path in roots:
+ try:
+ with winreg.OpenKey(root, path) as parent:
+ for i in range(winreg.QueryInfoKey(parent)[0]):
+ try:
+ with winreg.OpenKey(parent, winreg.EnumKey(parent, i)) as k:
+ name = winreg.QueryValueEx(k, "DisplayName")[0]
+ loc = winreg.QueryValueEx(k, "InstallLocation")[0]
+ out.append((str(name), str(loc)))
+ except OSError:
+ continue
+ except OSError:
+ continue
+ return out
diff --git a/src/mtga_assistant/ui/main_window.py b/src/mtga_assistant/ui/main_window.py
index 032d6f4..ead0134 100644
--- a/src/mtga_assistant/ui/main_window.py
+++ b/src/mtga_assistant/ui/main_window.py
@@ -24,6 +24,7 @@ from PySide6.QtWidgets import (
QWidget,
)
+from .. import paths
from ..carddb import CardDB
from ..gamestate import MatchTracker
from ..history import HistoryRecorder, HistoryStore, import_log, replay_game
@@ -405,7 +406,7 @@ class MainWindow(QMainWindow):
messages = []
if not self.cards.available:
messages.append("MTGA card database not found: card names are unavailable. "
- "Set mtga_dir in ~/.config/mtga-assistant/config.toml.")
+ f"Set mtga_dir in {paths.CONFIG_FILE}.")
# MTGA sometimes leaves oversized messages out of the log. Usually later messages fill the
# gap in; only flag it (quietly) while some of my cards are still not accounted for.
unaccounted = 0
diff --git a/src/mtga_assistant/ui/match_view.py b/src/mtga_assistant/ui/match_view.py
index 268d0e8..bb0e4d8 100644
--- a/src/mtga_assistant/ui/match_view.py
+++ b/src/mtga_assistant/ui/match_view.py
@@ -180,7 +180,7 @@ class PlayerCard(QFrame):
else:
color = theme.DANGER if remaining <= warn else theme.MINT_TEXT
self.timer.setText(f""
- f"⏱ {fmt_seconds(remaining)}")
+ f"◷ {fmt_seconds(remaining)}")
self.timer.setToolTip("Turn timer running" if running else
"Time available before the turn timer runs out (starts when the rope appears)")
self.timer.setVisible(self.toggles.on("timer") and timer is not None)
diff --git a/tests/test_logtail.py b/tests/test_logtail.py
new file mode 100644
index 0000000..1c4a8f9
--- /dev/null
+++ b/tests/test_logtail.py
@@ -0,0 +1,75 @@
+"""The log follower: chunked appends, partial lines, MTGA replacing the log, and no lingering handle."""
+
+import os
+import time
+from pathlib import Path
+
+import pytest
+from PySide6.QtCore import QCoreApplication
+
+from mtga_assistant.logtail import LogFollower
+
+FIXTURE = Path(__file__).parent / "fixtures" / "bo3_match.log"
+
+
+@pytest.fixture(scope="module")
+def app():
+ return QCoreApplication.instance() or QCoreApplication([])
+
+
+def wait_for(app, cond, timeout=5.0):
+ end = time.monotonic() + timeout
+ while time.monotonic() < end:
+ app.processEvents()
+ if cond():
+ return True
+ time.sleep(0.02)
+ return False
+
+
+def open_handles_to(path: Path) -> int:
+ """Number of this process's file descriptors pointing at path (Linux /proc)."""
+ fd_dir = Path("/proc/self/fd")
+ if not fd_dir.exists():
+ pytest.skip("needs /proc")
+ count = 0
+ for fd in fd_dir.iterdir():
+ try:
+ if Path(os.readlink(fd)) == path:
+ count += 1
+ except OSError:
+ pass
+ return count
+
+
+def test_follow_append_replace_and_no_open_handle(app, tmp_path):
+ src = FIXTURE.read_bytes()
+ log = tmp_path / "Player.log"
+ log.write_bytes(b"")
+ got, resets = [], []
+ f = LogFollower(log)
+ f.events.connect(lambda evs: got.extend(evs))
+ f.reset.connect(lambda: resets.append(1))
+ f.start()
+ try:
+ cut = len(src) * 3 // 4 # mid-line
+ with open(log, "ab") as fh:
+ fh.write(src[:cut])
+ assert wait_for(app, lambda: len(got) >= 11)
+ with open(log, "ab") as fh:
+ fh.write(src[cut:])
+ assert wait_for(app, lambda: len(got) == 22)
+
+ # Between polls the follower must not keep Player.log open (Windows would then block
+ # MTGA from renaming it).
+ time.sleep(0.3)
+ assert open_handles_to(log.resolve()) == 0
+
+ # MTGA restarts: Player.log is replaced by a new file.
+ new = tmp_path / "Player.new"
+ new.write_bytes(src[:cut])
+ os.replace(new, log)
+ assert wait_for(app, lambda: resets and len(got) >= 33)
+ finally:
+ f.stop()
+ f.wait(3000)
diff --git a/tests/test_paths.py b/tests/test_paths.py
new file mode 100644
index 0000000..85d6a50
--- /dev/null
+++ b/tests/test_paths.py
@@ -0,0 +1,135 @@
+"""Path detection for Linux (Steam/Proton) and Windows, against fake directory trees."""
+
+from pathlib import Path
+
+from mtga_assistant.carddb import local_copy, open_card_db
+from mtga_assistant.paths import MTGA_APP_ID, Config, Env, find_card_db, find_log_path, find_mtga_dir
+
+
+def make_install(d: Path) -> Path:
+ raw = d / "MTGA_Data/Downloads/Raw"
+ raw.mkdir(parents=True)
+ (raw / "Raw_CardDatabase_abc.mtga").write_bytes(b"db")
+ return d
+
+
+def make_log_dir(profile: Path) -> Path:
+ d = profile / "AppData/LocalLow/Wizards Of The Coast/MTGA"
+ d.mkdir(parents=True)
+ return d
+
+
+def steam_library(lib: Path, vdf_root: Path, *, with_mtga: bool = True) -> None:
+ (vdf_root / "steamapps").mkdir(parents=True, exist_ok=True)
+ escaped = str(lib).replace("\\", "\\\\")
+ (vdf_root / "steamapps/libraryfolders.vdf").write_text(
+ f'"libraryfolders"\n{{\n "0"\n {{\n "path" "{escaped}"\n }}\n}}\n')
+ if with_mtga:
+ (lib / "steamapps").mkdir(parents=True, exist_ok=True)
+ (lib / f"steamapps/appmanifest_{MTGA_APP_ID}.acf").write_text("")
+ make_install(lib / "steamapps/common/MTGA")
+
+
+# -- Linux ---------------------------------------------------------------------------------------
+
+
+def test_linux_proton(tmp_path):
+ home, lib = tmp_path / "home", tmp_path / "games"
+ steam_library(lib, home / ".local/share/Steam")
+ make_log_dir(lib / f"steamapps/compatdata/{MTGA_APP_ID}/pfx/drive_c/users/steamuser")
+ env = Env(platform="linux", home=home)
+ log = find_log_path(Config(), env)
+ assert log is not None and log.name == "Player.log" and "compatdata" in log.parts
+ assert find_mtga_dir(Config(), env) == lib / "steamapps/common/MTGA"
+
+
+def test_linux_nothing_installed(tmp_path):
+ env = Env(platform="linux", home=tmp_path)
+ assert find_log_path(Config(), env) is None
+ assert find_mtga_dir(Config(), env) is None
+
+
+# -- Windows ---------------------------------------------------------------------------------------
+
+
+def windows_env(tmp_path, registry=None, uninstall=None) -> Env:
+ home = tmp_path / "Users/me"
+ home.mkdir(parents=True, exist_ok=True)
+ return Env(
+ platform="win32", home=home,
+ environ={"USERPROFILE": str(home), "ProgramFiles": str(tmp_path / "Program Files"),
+ "ProgramFiles(x86)": str(tmp_path / "Program Files (x86)")},
+ read_registry=lambda hive, key, value: (registry or {}).get((hive, key, value)),
+ uninstall_entries=lambda: uninstall or [],
+ )
+
+
+def test_windows_log_is_in_user_profile(tmp_path):
+ env = windows_env(tmp_path)
+ assert find_log_path(Config(), env) is None # MTGA never ran
+ d = make_log_dir(Path(env.environ["USERPROFILE"]))
+ assert find_log_path(Config(), env) == d / "Player.log"
+
+
+def test_windows_steam_found_via_registry(tmp_path):
+ steam, lib = tmp_path / "Steam", tmp_path / "D/SteamLibrary"
+ steam_library(lib, steam)
+ env = windows_env(tmp_path, registry={("HKCU", r"Software\Valve\Steam", "SteamPath"): str(steam)})
+ assert find_mtga_dir(Config(), env) == lib / "steamapps/common/MTGA"
+
+
+def test_windows_steam_default_location(tmp_path):
+ steam, lib = tmp_path / "Program Files (x86)/Steam", tmp_path / "E/Games"
+ steam_library(lib, steam)
+ assert find_mtga_dir(Config(), windows_env(tmp_path)) == lib / "steamapps/common/MTGA"
+
+
+def test_windows_standalone_and_epic(tmp_path):
+ env = windows_env(tmp_path)
+ epic = make_install(tmp_path / "Program Files/Epic Games/MagicTheGathering")
+ assert find_mtga_dir(Config(), env) == epic
+ standalone = make_install(tmp_path / "Program Files/Wizards of the Coast/MTGA")
+ assert find_mtga_dir(Config(), env) == standalone # preferred over Epic
+
+
+def test_windows_uninstall_registry_fallback(tmp_path):
+ custom = make_install(tmp_path / "Games/MTGA")
+ env = windows_env(tmp_path, uninstall=[("Some Other App", str(tmp_path)),
+ ("Magic: The Gathering Arena", str(custom))])
+ assert find_mtga_dir(Config(), env) == custom
+ assert find_card_db(custom).name == "Raw_CardDatabase_abc.mtga"
+
+
+def test_config_overrides_detection(tmp_path):
+ cfg = Config(log_path=tmp_path / "x.log", mtga_dir=tmp_path / "mtga")
+ env = windows_env(tmp_path)
+ assert find_log_path(cfg, env) == tmp_path / "x.log"
+ assert find_mtga_dir(cfg, env) == tmp_path / "mtga"
+
+
+# -- card DB copy (Windows) -------------------------------------------------------------------------
+
+
+def test_card_db_copy_reused_and_old_versions_removed(tmp_path):
+ src_dir, copies = tmp_path / "Raw", tmp_path / "cache/carddb"
+ src_dir.mkdir()
+ v1 = src_dir / "Raw_CardDatabase_v1.mtga"
+ v1.write_bytes(b"one")
+ c1 = local_copy(v1, copies)
+ assert c1.read_bytes() == b"one"
+ mtime = c1.stat().st_mtime_ns
+ assert local_copy(v1, copies).stat().st_mtime_ns == mtime # not copied again
+
+ v2 = src_dir / "Raw_CardDatabase_v2.mtga"
+ v2.write_bytes(b"two!")
+ c2 = local_copy(v2, copies)
+ assert c2.read_bytes() == b"two!" and not c1.exists()
+
+
+def test_open_card_db_copies_only_on_windows(tmp_path):
+ import sqlite3
+ db = tmp_path / "Raw_CardDatabase_x.mtga"
+ sqlite3.connect(db).close()
+ assert open_card_db(db, tmp_path / "copies", platform="linux").path == db
+ win = open_card_db(db, tmp_path / "copies", platform="win32")
+ assert win.path == tmp_path / "copies" / db.name
diff --git a/uv.lock b/uv.lock
index 656cb75..4e12c10 100644
--- a/uv.lock
+++ b/uv.lock
@@ -2,6 +2,15 @@ version = 1
revision = 5
requires-python = ">=3.12"
+[[package]]
+name = "altgraph"
+version = "0.17.5"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/7e/f8/97fdf103f38fed6792a1601dbc16cc8aac56e7459a9fff08c812d8ae177a/altgraph-0.17.5.tar.gz", hash = "sha256:c87b395dd12fabde9c99573a9749d67da8d29ef9de0125c7f536699b4a9bc9e7", size = 48428, upload-time = "2025-11-21T20:35:50.583Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/a9/ba/000a1996d4308bc65120167c21241a3b205464a2e0b58deda26ae8ac21d1/altgraph-0.17.5-py2.py3-none-any.whl", hash = "sha256:f3a22400bce1b0c701683820ac4f3b159cd301acab067c51c653e06961600597", size = 21228, upload-time = "2025-11-21T20:35:49.444Z" },
+]
+
[[package]]
name = "colorama"
version = "0.4.6"
@@ -20,23 +29,43 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/56/43/4ca9e49d27a1fcf6bece6f6aec0ea46bb9112489b93d4b688fb415457bdb/iniconfig-2.3.1-py3-none-any.whl", hash = "sha256:9121e2c1fdb355232495be3194c8dfe87ccc2d5dee45947b78e68f499790d7a7", size = 7552, upload-time = "2026-10-06T22:48:36.959Z" },
]
+[[package]]
+name = "macholib"
+version = "1.16.4"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "altgraph" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/10/2f/97589876ea967487978071c9042518d28b958d87b17dceb7cdc1d881f963/macholib-1.16.4.tar.gz", hash = "sha256:f408c93ab2e995cd2c46e34fe328b130404be143469e41bc366c807448979362", size = 59427, upload-time = "2025-11-22T08:28:38.373Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/c7/d1/a9f36f8ecdf0fb7c9b1e78c8d7af12b8c8754e74851ac7b94a8305540fc7/macholib-1.16.4-py2.py3-none-any.whl", hash = "sha256:da1a3fa8266e30f0ce7e97c6a54eefaae8edd1e5f86f3eb8b95457cae90265ea", size = 38117, upload-time = "2025-11-22T08:28:36.939Z" },
+]
+
[[package]]
name = "mtga-assistant"
version = "0.1.0"
source = { editable = "." }
dependencies = [
+ { name = "platformdirs" },
{ name = "pyside6-essentials" },
]
[package.dev-dependencies]
+build = [
+ { name = "pyinstaller" },
+]
dev = [
{ name = "pytest" },
]
[package.metadata]
-requires-dist = [{ name = "pyside6-essentials", specifier = ">=6.8" }]
+requires-dist = [
+ { name = "platformdirs", specifier = ">=4" },
+ { name = "pyside6-essentials", specifier = ">=6.8" },
+]
[package.metadata.requires-dev]
+build = [{ name = "pyinstaller", specifier = ">=6" }]
dev = [{ name = "pytest", specifier = ">=8" }]
[[package]]
@@ -48,6 +77,24 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" },
]
+[[package]]
+name = "pefile"
+version = "2024.8.26"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/03/4f/2750f7f6f025a1507cd3b7218691671eecfd0bbebebe8b39aa0fe1d360b8/pefile-2024.8.26.tar.gz", hash = "sha256:3ff6c5d8b43e8c37bb6e6dd5085658d658a7a0bdcd20b6a07b1fcfc1c4e9d632", size = 76008, upload-time = "2024-08-26T20:58:38.155Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/54/16/12b82f791c7f50ddec566873d5bdd245baa1491bac11d15ffb98aecc8f8b/pefile-2024.8.26-py3-none-any.whl", hash = "sha256:76f8b485dcd3b1bb8166f1128d395fa3d87af26360c2358fb75b80019b957c6f", size = 74766, upload-time = "2024-08-26T21:01:02.632Z" },
+]
+
+[[package]]
+name = "platformdirs"
+version = "4.13.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/da/91/4a03cfdb03314cfca262921797fff04ff4054bd86a160aed76eddf8aa12b/platformdirs-4.13.3.tar.gz", hash = "sha256:5e567f664eb087ab8521c0179cd8d1bd60857d271136567a39719e28e2d383ce", size = 61559, upload-time = "2026-10-16T01:16:16.573Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/45/b8/fd1af06b079af236f5423f7c1821264419cc8f6b4803f79353acbb8bfa53/platformdirs-4.13.3-py3-none-any.whl", hash = "sha256:f6ad7f447f24f8a3b82cce5976387428bff894a0eca6c3488f4a17f153c130c4", size = 32762, upload-time = "2026-10-16T01:16:15.051Z" },
+]
+
[[package]]
name = "pluggy"
version = "1.7.0"
@@ -66,6 +113,46 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" },
]
+[[package]]
+name = "pyinstaller"
+version = "6.22.3"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "altgraph" },
+ { name = "macholib", marker = "sys_platform == 'darwin'" },
+ { name = "packaging" },
+ { name = "pefile", marker = "sys_platform == 'win32'" },
+ { name = "pyinstaller-hooks-contrib" },
+ { name = "pywin32-ctypes", marker = "sys_platform == 'win32'" },
+ { name = "setuptools" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/63/41/f90302845945abd4ed647933ff5ee7c6ac93983187be67f897b6cb613331/pyinstaller-6.22.3.tar.gz", hash = "sha256:05eb2f5615503e72939a7224d68b4aff572c6b0438ee4a17d0a4b481f399362d", size = 4437180, upload-time = "2026-09-12T21:54:09.669Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/ec/af/898f70947e27f8e65d55183c780e77112e023c0b4a6fba6255d4d563a27d/pyinstaller-6.22.3-py3-none-macosx_10_13_universal2.whl", hash = "sha256:052f4a1cd4f81092ccb7a18fe8ebc9ecf832a5917317e39fb3775af4e959b253", size = 1071847, upload-time = "2026-09-12T21:53:01.711Z" },
+ { url = "https://files.pythonhosted.org/packages/c4/96/99f5184985141c2b024bfa61335e9142ae444555224f303186e59b7bb438/pyinstaller-6.22.3-py3-none-manylinux2014_aarch64.whl", hash = "sha256:312da84b4b31ab9750066a823734c11b7c68757fdd67c9f038bf19c330a954e4", size = 758172, upload-time = "2026-09-12T21:53:06.171Z" },
+ { url = "https://files.pythonhosted.org/packages/69/9f/9ccb12cc1e6617042cb028fe79f0a756f5f03305bd14b584a4e043fe3844/pyinstaller-6.22.3-py3-none-manylinux2014_i686.whl", hash = "sha256:020d4fe0627f5c73dfbcfad1a6578ef354d5fe4bdccd9d8e5a615528f5a45d2b", size = 772135, upload-time = "2026-09-12T21:53:10.291Z" },
+ { url = "https://files.pythonhosted.org/packages/c1/43/4b1d5193a88628c052db89f064433681076959a9f41d0d9f660001b49079/pyinstaller-6.22.3-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:9e4f7bd64b2103f14b4a6240fb9155e9fe7bc61391b4043bbddb7d3ebc8ab0fa", size = 770461, upload-time = "2026-09-12T21:53:14.592Z" },
+ { url = "https://files.pythonhosted.org/packages/cb/83/f173f8757c618d83e116fa2b8e57c5cd796e5ad09792e664eabd6ac5edae/pyinstaller-6.22.3-py3-none-manylinux2014_s390x.whl", hash = "sha256:f82d0fb89f21c3ed6f482ee16f341bc59e8a31dbabb9bae9ef04e53ade4dc0d9", size = 764997, upload-time = "2026-09-12T21:53:19.079Z" },
+ { url = "https://files.pythonhosted.org/packages/7b/b6/dd21d61fc5e25377be24eabf13e99e4e46548a5abe2bf7b15d8b3d9cc74a/pyinstaller-6.22.3-py3-none-manylinux2014_x86_64.whl", hash = "sha256:451a4ae14b719365bf1a2f0a99dae7b3463060061c3a394c70d5264cfb439528", size = 764974, upload-time = "2026-09-12T21:53:23.176Z" },
+ { url = "https://files.pythonhosted.org/packages/6c/fd/d9590942972b1fc205519f7ccffb0d5cd8eb1d6fa61edad319c71d25baed/pyinstaller-6.22.3-py3-none-musllinux_1_1_aarch64.whl", hash = "sha256:ea240b5dfa831ff673c97505eee0ec24670bccf71396c83109440348944c83d0", size = 764450, upload-time = "2026-09-12T21:53:27.419Z" },
+ { url = "https://files.pythonhosted.org/packages/72/d6/f22c55d1768433b65c460b19a3136dd9769b6c74b786149b829344654c7d/pyinstaller-6.22.3-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:a2b6fc26601e8c2596c3d54981566934a3382e85d05d0fe91b55b6a8dcc6b282", size = 763863, upload-time = "2026-09-12T21:53:31.609Z" },
+ { url = "https://files.pythonhosted.org/packages/c5/87/cf28cfd42ceca889171e6dd5e7f1553d4372db83d047049ffb7cf95fcbd4/pyinstaller-6.22.3-py3-none-win32.whl", hash = "sha256:46a3e30a118341a1ec20667a5a95aa7e0aa8c4fa129893c0151b3eb3f801a722", size = 1390905, upload-time = "2026-09-12T21:53:38.133Z" },
+ { url = "https://files.pythonhosted.org/packages/24/83/c5422204772a27aef643280fdc887b7ef55679989096b21dceb1b9625fc4/pyinstaller-6.22.3-py3-none-win_amd64.whl", hash = "sha256:500bd58c7bf7e584a8435adccbd763a0b918d5c12b08d74ff50fd79b2915458b", size = 1454907, upload-time = "2026-09-12T21:53:44.812Z" },
+ { url = "https://files.pythonhosted.org/packages/67/2e/ebc93968bf63f81b122e205da116b16036b653b7ce2cc8ad86e3ddd19586/pyinstaller-6.22.3-py3-none-win_arm64.whl", hash = "sha256:31df3cc4261e804a53d358b7897c293ec8b88b167a7cca06c78018085c855f97", size = 1402956, upload-time = "2026-09-12T21:53:51.369Z" },
+]
+
+[[package]]
+name = "pyinstaller-hooks-contrib"
+version = "2026.8"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "packaging" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/c9/3b/fab1a12a21bf9223af012e5dd002b7d4265683d21d9fabbd0ccb347316e4/pyinstaller_hooks_contrib-2026.8.tar.gz", hash = "sha256:4d825786ad7a9b7dbcc52d612748a34562fe273461bcfd88f4d549c594bbf8f9", size = 176955, upload-time = "2026-09-30T07:53:52.56Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/8f/f1/3eee0263b487e571aed9204257b45bc2a0b6fec34d0a1adade4d8d8a968c/pyinstaller_hooks_contrib-2026.8-py3-none-any.whl", hash = "sha256:6d21d65323ea4467753afc21f52d7d0e69416ae8101dd88269bf273ad48db084", size = 462145, upload-time = "2026-09-30T07:53:51.167Z" },
+]
+
[[package]]
name = "pyside6-essentials"
version = "6.12.0"
@@ -97,6 +184,24 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" },
]
+[[package]]
+name = "pywin32-ctypes"
+version = "0.2.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/85/9f/01a1a99704853cb63f253eea009390c88e7131c67e66a0a02099a8c917cb/pywin32-ctypes-0.2.3.tar.gz", hash = "sha256:d162dc04946d704503b2edc4d55f3dba5c1d539ead017afa00142c38b9885755", size = 29471, upload-time = "2024-08-14T10:15:34.626Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/de/3d/8161f7711c017e01ac9f008dfddd9410dff3674334c233bde66e7ba65bbf/pywin32_ctypes-0.2.3-py3-none-any.whl", hash = "sha256:8a1513379d709975552d202d942d9837758905c8d01eb82b8bcc30918929e7b8", size = 30756, upload-time = "2024-08-14T10:15:33.187Z" },
+]
+
+[[package]]
+name = "setuptools"
+version = "84.0.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/6d/44/f5da03a8ef95d369145c5bb53050e7877c9f3d312e128605fd9504829143/setuptools-84.0.0.tar.gz", hash = "sha256:f4695c21257f0d9b537ec2692c941d02ee143b7cc1276941349a546573b2ef73", size = 1168449, upload-time = "2026-08-08T18:27:58.365Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/95/9c/c510029fc6ef33a6275cd2c5d3cecd6613dfd6aa401d57c54f1c18852ccf/setuptools-84.0.0-py3-none-any.whl", hash = "sha256:51a52592b3b99e102b609654876bd65f19f999935166d1352678931132b0c670", size = 818216, upload-time = "2026-08-08T18:27:56.719Z" },
+]
+
[[package]]
name = "shiboken6"
version = "6.12.0"