Make the app cross-platform and add a Windows build

- paths: per-platform detection behind an injectable Env. Windows finds
  Player.log in the user profile and the install via Steam (registry),
  the standalone installer, Epic, or the uninstall registry. App dirs
  come from platformdirs (unchanged XDG locations on Linux).
- logtail: open Player.log only while reading, so Windows can still
  rotate it when MTGA restarts.
- carddb: on Windows read a private copy of the card database so MTGA's
  updater can replace the original.
- App icon (SVG source + generated ICO/PNG), Windows taskbar identity,
  and a Windows-safe timer glyph.
- PyInstaller spec, launcher and PowerShell build script producing a
  one-folder windowed build and zip.
- Tests for path detection, the log follower and the card-DB copy;
  README covers Windows usage, building and per-OS locations.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Aeth3randClaude Opus 5.5 committed 2026-10-16 13:06:19 -04:00
1 parent 171fc64ca9
commit e82f8d1858
19 files changed
+685 -54

No files matched your search

+2
View File
@@ -2,3 +2,5 @@
__pycache__/
*.pyc
.pytest_cache/
build/
dist/
+38 -6
View File
@@ -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
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

+11
View File
@@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256">
<rect x="8" y="8" width="240" height="240" rx="56" fill="#14171a"/>
<rect x="8" y="8" width="240" height="240" rx="56" fill="none" stroke="#2f353b" stroke-width="6"/>
<g transform="rotate(-10 128 128)">
<rect x="70" y="44" width="116" height="168" rx="14" fill="#1d2125" stroke="#3eb489" stroke-width="10"/>
<rect x="86" y="62" width="84" height="58" rx="6" fill="#24443a"/>
</g>
<circle cx="98" cy="166" r="13" fill="#c1d7e9"/>
<circle cx="130" cy="160" r="13" fill="#bab1ab"/>
<circle cx="162" cy="154" r="13" fill="#e49977"/>
</svg>

After

Width:  |  Height:  |  Size: 621 B

+13
View File
@@ -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"
+7
View File
@@ -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())
+51
View File
@@ -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("<HHH", 0, 1, len(pngs))
offset = 6 + 16 * len(pngs)
for size, png in pngs:
dim = 0 if size >= 256 else size # 0 means 256
out += struct.pack("<BBBBHHII", dim, dim, 0, 0, 1, 32, len(png), offset)
offset += len(png)
out += b"".join(png for _, png in pngs)
(ROOT / "assets/icon.ico").write_bytes(out)
# The app loads this at runtime (window/taskbar icon), so it lives inside the package.
(ROOT / "src/mtga_assistant/icon.png").write_bytes(dict(pngs)[256])
print(f"wrote assets/icon.ico ({len(out)} bytes) and src/mtga_assistant/icon.png")
if __name__ == "__main__":
main()
+42
View File
@@ -0,0 +1,42 @@
# PyInstaller spec for MTGA Assistant: a one-folder, windowed build.
# Build on the target OS (PyInstaller can't cross-compile), e.g. on Windows:
# powershell -ExecutionPolicy Bypass -File packaging\build_windows.ps1
import sys
from pathlib import Path
ROOT = Path(SPECPATH).parent
SRC = ROOT / "src"
a = Analysis(
[str(ROOT / "packaging" / "launch.py")],
pathex=[str(SRC)],
datas=[(str(SRC / "mtga_assistant" / "icon.png"), "mtga_assistant")],
# Qt modules the app doesn't use; keeps the bundle smaller.
excludes=[
"tkinter",
"PySide6.QtNetwork",
"PySide6.QtQml",
"PySide6.QtQuick",
"PySide6.QtSql",
"PySide6.QtTest",
"PySide6.QtOpenGL",
"PySide6.QtPdf",
],
noarchive=False,
)
# Not needed by a Qt Widgets app: the software OpenGL fallback (~20 MB) and Qt's translations.
a.binaries = [b for b in a.binaries if Path(b[0]).name.lower() != "opengl32sw.dll"]
a.datas = [d for d in a.datas if "translations" not in Path(d[0]).parts]
pyz = PYZ(a.pure)
exe = EXE(
pyz,
a.scripts,
[],
exclude_binaries=True,
name="MTGA Assistant",
console=False,
icon=str(ROOT / "assets" / "icon.ico") if sys.platform == "win32" else None,
)
coll = COLLECT(exe, a.binaries, a.datas, name="MTGA Assistant")
+2
View File
@@ -6,6 +6,7 @@ readme = "README.md"
requires-python = ">=3.12"
dependencies = [
"PySide6-Essentials>=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"]
+9 -2
View File
@@ -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)
+33
View File
@@ -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}")
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

+16 -11
View File
@@ -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()
+142 -32
View File
@@ -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
+2 -1
View File
@@ -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
+1 -1
View File
@@ -180,7 +180,7 @@ class PlayerCard(QFrame):
else:
color = theme.DANGER if remaining <= warn else theme.MINT_TEXT
self.timer.setText(f"<span style='font-size:{theme.FONT_PT * 2}pt; color:{color}; font-weight:bold'>"
f"⏱ {fmt_seconds(remaining)}</span>")
f"◷ {fmt_seconds(remaining)}</span>")
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)
+75
View File
@@ -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)
+135
View File
@@ -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
Generated
+106 -1
View File
@@ -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"