Compare commits

...
5 Commits
Author SHA1 Message Date
Aeth3randClaude Opus 5.5 90f846c70b Rewrite README with logo and screenshots
Add the logo and screenshots, restructure into features, installation,
usage, configuration and development, and bring everything up to date
(background mode, installers, menus, limitations). The project
description now covers Linux and Windows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 09:54:08 -04:00
Aeth3randClaude Opus 5.5 d9ef771db8 Name emblems and boons instead of showing "Card #N"
Command-zone objects such as boons have small grpIds that aren't cards.
Name them from the log's name id (e.g. "The Ring") or the card that
created them ("Swiftspear's Teachings (boon)"), and preview the source
card on hover.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 09:54:08 -04:00
Aeth3randClaude Opus 5.5 f16a67e042 Add Linux and Windows installers
- install.sh: checks Python 3.12+, installs the app into its own
  environment, checks Qt's system libraries, installs icons and a
  --background .desktop launcher, offers start at login; --uninstall.
- install.bat / install_windows.ps1: checks Python (offering winget),
  builds with PyInstaller, installs to LOCALAPPDATA with Start Menu and
  Desktop shortcuts, offers start at login; -Uninstall.
- .gitattributes keeps .bat/.ps1 as CRLF and .sh as LF.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 09:54:08 -04:00
Aeth3randClaude Opus 5.5 fb6220155c Use the new app icon
The icon now comes from images/mtga-assistant-icon.png: make_icon.py
copies it into the package (window, taskbar and tray icon) and builds
the Windows .ico used for the .exe. The old generated SVG icon is gone.
The PyInstaller spec also keeps QtNetwork, needed for single-instance.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 09:54:08 -04:00
Aeth3randClaude Opus 5.5 c4533d12c0 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>
2026-10-19 09:54:08 -04:00
27 changed files with 1118 additions and 129 deletions

No files matched your search

+6
View File
@@ -0,0 +1,6 @@
# Windows scripts need CRLF line endings.
*.bat text eol=crlf
*.ps1 text eol=crlf
*.ico binary
*.png binary
*.sh text eol=lf
+1
View File
@@ -4,3 +4,4 @@ __pycache__/
.pytest_cache/
build/
dist/
.venv-build/
+187 -81
View File
@@ -1,120 +1,226 @@
# mtga-assistant
<p align="center">
<img src="images/mtga-assistant-icon.png" alt="MTGA Assistant logo" width="128">
</p>
A native Linux deck and game tracker for **Magic: The Gathering Arena** running under Steam/Proton.
It follows MTGA's `Player.log`, much like Untapped.gg and other trackers do, and shows:
<h1 align="center">MTGA Assistant</h1>
Two views, switched from the top bar (Ctrl+1 / Ctrl+2):
<p align="center">
A deck and game tracker for <b>Magic: The Gathering Arena</b> on Linux and Windows.
</p>
- **Match**, from top to bottom (drag the dividers to resize; sizes are remembered):
- *You · Match · Opponent*: life totals, poison/energy and other player counters, commanders,
emblems, hand/library/graveyard counts, turn timer and timeouts, land drops used/allowed, who
was on the play, plus the match panel (turn, phase, game number, Bo3 score and draw count,
game time).
- *You · Turn · Opponent*: each player's stats for the selected turn and the whole game
(cards drawn, spells cast, lands played, creatures died, life gained/lost, damage, discards,
mills, exiles, counters, tokens, land drops). The two sides mirror each other, with the
whole-game column next to the middle panel, which picks the turn (◀ ▶, or *Live* to follow
the current one).
- *My deck · Opponent · Preview*: remaining library with draw odds, your zones; the opponent's
board, graveyard, exile, cards known to be in their hand or library, and every card seen this
match; hover a card for its [Scryfall](https://scryfall.com) image.
- **History**: stats (win rate overall, last 10/100 games, last 7 days, on the play/draw, average
length and turns) and a sortable list of every finished game: date, event, deck, commanders,
opponent, both decks' colours, who was on the play, result, turns and length. Click a header to
sort, right-click a cell to show only (or hide) games with that value; the stats follow the
filters. Click a game to open it in the Match view ("Back to live" returns). Games from the
previous MTGA session (`Player-prev.log`) are imported on startup.
MTGA Assistant follows MTGA's `Player.log`, as Untapped.gg and other trackers do. It shows your
library with draw odds, everything revealed about your opponent, per-turn stats, and a filterable
history of every game you play. It runs natively on Linux (MTGA through Steam/Proton) and on
Windows (Steam, Epic or the standalone installer).
*⚙ Customize* shows or hides each panel of the Match page (You, Match, Opponent, Turn stats,
My deck, Preview, Opponent's cards) and the details in the player panels. *Customize → Arrange…*
reorders (drag, or ↑/↓) and shows/hides the My deck and Opponent sections and the turn stats.
Your choices are remembered.
## Screenshots
**Match**: both players, the match, per-turn stats, your deck, a card preview and everything known
about the opponent.
![Match view during a Brawl game](images/screenshots/1-matchScreen.jpg)
**A past game** opened from History, with "Back to live" to return.
![A past game opened from History](images/screenshots/2-previousMatchScreen.jpg)
**History**: stats and every finished game; sort by any column.
![History view](images/screenshots/3-historyScreen.jpg)
**Filtered history**: right-click any value to filter; the stats follow the filter.
![History filtered to one commander](images/screenshots/4-historyScreenFiltered.jpg)
**Customization**: reorder and show/hide deck sections and turn stats.
![Arrange dialog](images/screenshots/5-customization.jpg)
## Features
### Match
Three rows; drag the dividers to resize them (sizes are remembered).
- **You · Match · Opponent**: life totals, poison/energy and other player counters, commanders
(with location and commander tax), emblems, hand/library/graveyard/exile counts, turn timer and
timeouts used, land drops used/allowed (e.g. `0/2` with extra land effects), who was on the play,
plus the match panel: turn, phase, game number, Bo3 score, who has been on the draw more, and
game time.
- **You · Turn · Opponent**: each player's stats for the selected turn and the whole game: cards
drawn, spells cast, lands played, creatures died, life gained/lost, damage, discards, mills,
exiles, spells countered, tokens, counters and land drops. The middle panel picks the turn
(◀ ▶, or *Live* to follow the current one).
- **My deck · Preview · Opponent**:
- *My deck*: the remaining library with draw odds, and your hand, stack, battlefield, graveyard,
exile and sideboard. Counts show copies in that zone against copies in the deck (`2/5`).
- *Opponent*: their battlefield, stack, graveyard and exile, cards known to be in their hand or
library (seen going back there, or revealed), and every card seen this match.
- *Preview*: hover a card to see its [Scryfall](https://scryfall.com) image. Clear it with ✕,
a click on the image, or Esc.
Card names, mana costs and types come from MTGA's own local card database, so they work offline.
### History
Every finished game is recorded with its date, event, deck, commanders, opponent, both decks'
colours, who was on the play, the result, turns and length. Above the list are your win rate
overall, for the last 10 and 100 games, for the last 7 days, and on the play and on the draw,
plus average game length and turns.
- **Sort**: click a column header; click again to reverse.
- **Filter**: right-click a cell to show only (or hide) games with that value, such as a deck,
commander, event, opponent's colours or play/draw. Filters combine, appear as removable chips,
and the stats follow them.
- **Open a game**: click it to view it in the Match page as it ended, including turn-by-turn
stats. *Back to live* returns to the current game.
- Columns can be resized; games from the previous MTGA session (`Player-prev.log`) are imported
on startup.
### Customization
- **⚙ Customize**: show or hide each panel of the Match page (You, Match, Opponent, Turn stats,
My deck, Preview, Opponent's cards) and the details in the player panels. The menu stays open
while you tick and untick.
- **Customize → Arrange deck sections and turn stats…**: reorder (drag, or ↑/↓) and show/hide the
My deck and Opponent sections and the turn stats.
All choices are remembered.
### Runs in the background
So that no game is missed, closing the window keeps the app running in the system tray (on
Hyprland, Waybar's tray) and recording history. Quit from the tray menu, *☰ → Quit*, or Ctrl+Q.
- **Opens when MTGA starts**: the app watches MTGA's log folder using OS file notifications
(inotify on Linux, ReadDirectoryChangesW on Windows) rather than polling. When MTGA launches it
starts a fresh `Player.log`, and the window pops up. This works for Steam, Epic and standalone
installs.
- **One instance**: starting the app again shows the running copy instead of opening a second
one.
- **Start at login**: *☰ → Start at login (in the background)*. The installers can also set this
up.
Both behaviours can be switched off in the ☰ menu.
## Requirements
Runs on **Linux** and **Windows**.
- **Detailed logs enabled** in MTGA: *Options → Account → Detailed Logs (Plugin Support)*.
- 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
- **Detailed logs enabled in MTGA**: *Options → Account → Detailed Logs (Plugin Support)*.
- **Python 3.12 or newer.** The installers check for it and offer to install it.
- MTGA's log, install folder and card database are found automatically:
- **Linux**: MTGA through Steam (Proton), found via your Steam library folders.
- **Windows**: Steam, Epic Games or the standalone 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
## Installation
From source (any OS):
### Linux
```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
./install.sh
```
On Windows you can instead use the packaged build: unzip `MTGA-Assistant-windows.zip` and run
`MTGA Assistant.exe`.
The installer:
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.
1. Checks for Python 3.12+, offering your distro's install command (pacman, apt, dnf or zypper)
if it's missing.
2. Installs the app and its dependencies into `~/.local/share/mtga-assistant/app`. No root is
needed.
3. Checks that Qt can start, and suggests the missing system packages if it can't.
4. Installs the icon and a launcher, `~/.local/share/applications/mtga-assistant.desktop`, which
starts the app with `--background`.
5. Asks whether to start the app at login, then launches it.
### Linux desktop integration
Run `./install.sh` again after pulling to update. `./install.sh --uninstall` removes the app and
keeps your history and settings, and `--yes` answers every question with yes.
On Wayland the window's app id is `mtga-assistant`. For example, to keep it floating on another
workspace in Hyprland:
### Windows
1. Download this project (on the repository page: *Download ZIP*) and extract it.
2. Double-click **`install.bat`**.
The installer checks for Python 3.12+ (offering to install it with winget), installs the
dependencies into a private build folder, builds the app with PyInstaller, and installs it to
`%LOCALAPPDATA%\Programs\MTGA Assistant` with Start Menu and Desktop shortcuts. No admin rights are
needed. It asks whether to start the app at login, then launches it.
Run `install.bat` again to update after downloading a newer version. `install.bat -Uninstall`
removes the app and keeps your history and settings.
## Usage
Start the app 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, the tracker resets for the new session.
| Shortcut | Action |
|---|---|
| Ctrl+1 / Ctrl+2 | Match / History |
| Esc | Clear the card preview |
| Ctrl+Q | Quit (closing the window only hides it) |
Command-line options: `--background` (start hidden), `--log PATH` and `--mtga-dir PATH` (override
auto-detection), `--replay PATH [--rate N]` (replay a saved log, for testing).
**Hyprland**: the window's app id is `mtga-assistant`. For example, to keep it floating on another
workspace:
```
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.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\` |
Settings made in the app are saved automatically. An optional `config.toml` overrides
auto-detection and limits:
```toml
log_path = "/path/to/Player.log" # override auto-detection
mtga_dir = "/path/to/MTGA" # the folder containing MTGA_Data
cache_max_mb = 500 # image cache cap
cache_max_mb = 500 # card image cache cap
history_max_mb = 300 # cap for stored game details (summaries are always kept)
```
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.
| | Config file | History | Card image cache |
|---|---|---|---|
| Linux | `~/.config/mtga-assistant/config.toml` | `~/.local/share/mtga-assistant/history.db` | `~/.cache/mtga-assistant/images/` |
| Windows | `%APPDATA%\mtga-assistant\config.toml` | `%LOCALAPPDATA%\mtga-assistant\history.db` | `%LOCALAPPDATA%\mtga-assistant\Cache\images\` |
## Image cache
- **Card images** are downloaded from Scryfall only for cards that actually appear (about
60–100 KB each). When the cache passes `cache_max_mb`, the least recently viewed images are
deleted. *☰ → Clear image cache* empties it.
- **History** takes roughly 20–100 KB per game. Past `history_max_mb`, the oldest games keep
their row in the list but can no longer be opened.
- **On Windows** the app reads a private copy of MTGA's card database (about 260 MB, in the cache
folder), so MTGA's updater is never blocked by the app holding the file open.
Images are downloaded only for cards that actually appear, at Scryfall's `normal` size (about
60–100 KB each), and stored in `~/.cache/mtga-assistant/images`. When the cache passes
`cache_max_mb`, the least recently viewed images are deleted. *Cache → Clear image cache* empties it.
## Development
```sh
uv run mtga-assistant # run from the source tree
uv run mtga-assistant --replay path/to/Player-prev.log --rate 40
uv run pytest # tests
```
- **Windows build without installing**: on Windows with [uv](https://docs.astral.sh/uv/),
`powershell -ExecutionPolicy Bypass -File packaging\build_windows.ps1` produces
`dist\MTGA Assistant\MTGA Assistant.exe` and `dist\MTGA-Assistant-windows.zip`. PyInstaller
can't cross-compile, so this must run on Windows. On Linux, the same spec
(`uv run --group build pyinstaller packaging/mtga-assistant.spec`) builds a Linux bundle.
- **Icon**: the app icon comes from `images/mtga-assistant-icon.png`. After changing it, run
`uv run python packaging/make_icon.py` to regenerate the Windows `.ico` and the copy bundled
with the app.
## Known limitations
- MTGA leaves oversized game-state messages out of the log ("Message summarized because one or
more GameStateMessages exceeded the 50 GameObject or 50 Annotation limit"), typically during board
wipes. Some zones can then be stale until they next change. The app detects this and shows a
warning.
- After a library shuffle, a known card's position in the opponent's library is lost. It stays listed
under "Library … known" until it shows up again. If it is drawn without being revealed, it may
stay listed.
- **Gaps in the log**: MTGA leaves oversized game-state messages out of the log ("Message
summarized because one or more GameStateMessages exceeded the 50 GameObject or 50 Annotation
limit"), typically during board wipes. Later messages usually fill the gap in. If some of your
cards stay unaccounted for, the status bar shows "⚠ N of your cards not tracked" and library
odds may be slightly off.
- **Shuffled cards**: after a library shuffle, a known card's position in the opponent's library
is lost. It stays listed under "Library … known" until it shows up again, so if it's drawn
without being revealed it may stay listed.
- **Inferred land drops**: the log doesn't say how many land plays are allowed. They're inferred
from "additional land" effects in play, so conditional effects (e.g. "as long as you control an
Elf") are always counted.
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

-11
View File
@@ -1,11 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 621 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 673 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 690 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 818 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 711 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 643 KiB

+6
View File
@@ -0,0 +1,6 @@
@echo off
rem Double-click to build and install MTGA Assistant on Windows.
rem Uninstall with: install.bat -Uninstall
powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0packaging\install_windows.ps1" %*
echo.
pause
Executable
+223
View File
@@ -0,0 +1,223 @@
#!/usr/bin/env bash
# MTGA Assistant installer for Linux.
#
# ./install.sh install or update (run it again after pulling a newer version)
# ./install.sh --yes same, answering "yes" to every question
# ./install.sh --uninstall remove the app, launcher and icons (history and settings are kept)
#
# It installs the app into its own Python environment (no root needed), adds a launcher
# (.desktop file) and icons, optionally starts it at login, and launches it.
set -euo pipefail
APP_ID="mtga-assistant"
APP_NAME="MTGA Assistant"
MIN_PY_MINOR=12 # Python 3.12+
PROJECT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
DATA_HOME="${XDG_DATA_HOME:-$HOME/.local/share}"
CONFIG_HOME="${XDG_CONFIG_HOME:-$HOME/.config}"
APP_DIR="$DATA_HOME/$APP_ID/app" # the app's Python environment
BIN="$APP_DIR/bin/$APP_ID"
DESKTOP_FILE="$DATA_HOME/applications/$APP_ID.desktop"
ICON_THEME_DIR="$DATA_HOME/icons/hicolor"
ICON_SIZES=(32 48 64 128 256)
AUTOSTART_FILE="$CONFIG_HOME/autostart/$APP_ID.desktop"
ASSUME_YES=0
UNINSTALL=0
for arg in "$@"; do
case "$arg" in
-y|--yes) ASSUME_YES=1 ;;
--uninstall) UNINSTALL=1 ;;
-h|--help) sed -n '2,9p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
*) echo "Unknown option: $arg (see --help)" >&2; exit 2 ;;
esac
done
if [[ -t 1 ]]; then BOLD=$'\e[1m'; CYAN=$'\e[36m'; GREEN=$'\e[32m'; RED=$'\e[31m'; RESET=$'\e[0m'
else BOLD=""; CYAN=""; GREEN=""; RED=""; RESET=""; fi
step() { printf '\n%s==> %s%s\n' "$CYAN$BOLD" "$1" "$RESET"; }
info() { printf ' %s\n' "$1"; }
fail() { printf '\n%sERROR:%s %s\n' "$RED$BOLD" "$RESET" "$1" >&2; exit 1; }
ask() { # ask "Question?" [default y|n] -> exit status 0 for yes
local default="${2:-y}" hint answer
[[ $default == y ]] && hint="[Y/n]" || hint="[y/N]"
if (( ASSUME_YES )); then return 0; fi
if [[ ! -t 0 ]]; then [[ $default == y ]]; return; fi
read -r -p " $1 $hint " answer || true
answer="${answer:-$default}"
[[ ${answer,,} == y* ]]
}
app_running() { pgrep -u "$(id -u)" -f "(bin/$APP_ID|-m mtga_assistant)( |$)" >/dev/null 2>&1; }
refresh_desktop_caches() {
command -v update-desktop-database >/dev/null && update-desktop-database -q "$DATA_HOME/applications" || true
command -v gtk-update-icon-cache >/dev/null && gtk-update-icon-cache -q -t "$ICON_THEME_DIR" 2>/dev/null || true
}
# --- uninstall ------------------------------------------------------------------------------------
if (( UNINSTALL )); then
step "Uninstalling $APP_NAME"
if app_running && ask "$APP_NAME is running. Quit it?"; then
pkill -u "$(id -u)" -f "(bin/$APP_ID|-m mtga_assistant)( |$)" || true
fi
rm -rf "$APP_DIR"
rm -f "$DESKTOP_FILE" "$AUTOSTART_FILE"
for size in "${ICON_SIZES[@]}"; do rm -f "$ICON_THEME_DIR/${size}x${size}/apps/$APP_ID.png"; done
refresh_desktop_caches
info "Removed the app, its launcher, icons and login item."
info "Your game history and settings are still in $DATA_HOME/$APP_ID and $CONFIG_HOME/$APP_ID;"
info "delete those folders too to remove everything."
exit 0
fi
printf '%s%s installer%s\n' "$GREEN$BOLD" "$APP_NAME" "$RESET"
info "Project folder: $PROJECT_DIR"
# --- 1. Python --------------------------------------------------------------------------------------
python_ok() { # python_ok CMD -> prints version and succeeds if CMD is Python >= 3.MIN_PY_MINOR
"$1" -c "import sys; assert sys.version_info >= (3, $MIN_PY_MINOR); print('%d.%d' % sys.version_info[:2])" 2>/dev/null
}
find_python() {
local cmd
for cmd in python3 python3.14 python3.13 python3.12 python; do
if command -v "$cmd" >/dev/null && python_ok "$cmd" >/dev/null; then
command -v "$cmd"; return 0
fi
done
return 1
}
distro_install_hint() { # prints a package-manager command for $1 (a space-separated package list per family)
local id_like=""
if [[ -r /etc/os-release ]]; then
# shellcheck disable=SC1091
id_like="$(. /etc/os-release; echo "${ID:-} ${ID_LIKE:-}")"
fi
case " $id_like " in
*" arch "*) echo "sudo pacman -S --needed $1" ;;
*" debian "*|*" ubuntu "*) echo "sudo apt install $2" ;;
*" fedora "*|*" rhel "*) echo "sudo dnf install $3" ;;
*" suse "*|*" opensuse "*) echo "sudo zypper install $4" ;;
*) echo "" ;;
esac
}
step "Checking for Python 3.$MIN_PY_MINOR or newer"
if ! PYTHON="$(find_python)"; then
cmd="$(distro_install_hint "python" "python3 python3-venv" "python3" "python3")"
info "Python 3.$MIN_PY_MINOR+ was not found."
if [[ -n $cmd ]] && ask "Install it now with: $cmd ?"; then
$cmd
PYTHON="$(find_python)" || fail "Python still isn't available after installing."
else
fail "Install Python 3.$MIN_PY_MINOR or newer${cmd:+ (e.g. $cmd)}, then run this script again."
fi
fi
info "Using Python $(python_ok "$PYTHON") ($PYTHON)"
# --- 2. App environment and dependencies -------------------------------------------------------------
step "Installing $APP_NAME and its dependencies (first run downloads ~100 MB)"
if [[ ! -x $APP_DIR/bin/python ]]; then
mkdir -p "$(dirname "$APP_DIR")"
if ! "$PYTHON" -m venv "$APP_DIR" 2>/dev/null; then
rm -rf "$APP_DIR"
cmd="$(distro_install_hint "python" "python3-venv" "python3" "python3")"
fail "Couldn't create a Python environment (the 'venv' module is missing).${cmd:+ Install it with: $cmd}"
fi
fi
"$APP_DIR/bin/python" -m pip install --disable-pip-version-check --quiet --upgrade pip
"$APP_DIR/bin/python" -m pip install --disable-pip-version-check --quiet --upgrade "$PROJECT_DIR" \
|| fail "Installing the app failed (see the messages above)."
# The version number doesn't change between updates, so make sure the new code replaces the old.
"$APP_DIR/bin/python" -m pip install --disable-pip-version-check --quiet --force-reinstall --no-deps "$PROJECT_DIR" \
|| fail "Updating the app failed (see the messages above)."
info "Installed into $APP_DIR"
# --- 3. Qt system libraries ---------------------------------------------------------------------------
step "Checking that Qt can start"
if ! qt_error="$(QT_QPA_PLATFORM=offscreen "$APP_DIR/bin/python" -c \
'from PySide6.QtWidgets import QApplication; QApplication([])' 2>&1)"; then
info "Qt failed to load:"
printf '%s\n' "$qt_error" | tail -n 3 | sed 's/^/ /'
cmd="$(distro_install_hint "libxkbcommon libglvnd fontconfig wayland xcb-util-cursor" \
"libegl1 libgl1 libxkbcommon0 libfontconfig1 libwayland-client0 libxcb-cursor0" \
"libglvnd-egl libxkbcommon fontconfig libwayland-client xcb-util-cursor" \
"libEGL1 libxkbcommon0 fontconfig libwayland-client0 libxcb-cursor0")"
fail "Some system libraries Qt needs are missing.${cmd:+ Try: $cmd}"
fi
info "Qt loads fine."
# --- 4. Icons and launcher ------------------------------------------------------------------------------
step "Installing icons and the launcher"
QT_QPA_PLATFORM=offscreen "$APP_DIR/bin/python" - "$PROJECT_DIR/images/mtga-assistant-icon.png" \
"$ICON_THEME_DIR" "$APP_ID" "${ICON_SIZES[@]}" <<'PY'
import sys
from pathlib import Path
from PySide6.QtCore import Qt
from PySide6.QtGui import QGuiApplication, QImage
src, theme_dir, app_id, *sizes = sys.argv[1:]
app = QGuiApplication([])
img = QImage(src)
if img.isNull():
sys.exit(f"can't read {src}")
for size in map(int, sizes):
out = Path(theme_dir) / f"{size}x{size}" / "apps" / f"{app_id}.png"
out.parent.mkdir(parents=True, exist_ok=True)
img.scaled(size, size, Qt.AspectRatioMode.KeepAspectRatio,
Qt.TransformationMode.SmoothTransformation).save(str(out))
PY
mkdir -p "$(dirname "$DESKTOP_FILE")"
cat > "$DESKTOP_FILE" <<EOF
[Desktop Entry]
Type=Application
Name=$APP_NAME
GenericName=MTG Arena Tracker
Comment=Deck and game tracker for Magic: The Gathering Arena
Exec="$BIN" --background
Icon=$APP_ID
Terminal=false
Categories=Game;
Keywords=magic;mtg;arena;mtga;tracker;deck;
StartupWMClass=$APP_ID
EOF
if command -v desktop-file-validate >/dev/null && ! desktop-file-validate "$DESKTOP_FILE"; then
fail "The generated launcher failed validation: $DESKTOP_FILE"
fi
refresh_desktop_caches
info "Launcher: $DESKTOP_FILE"
info "Icons: $ICON_THEME_DIR/<size>/apps/$APP_ID.png"
# --- 5. Start at login, launch ---------------------------------------------------------------------------
step "Start at login"
info "Running in the background lets it record every game and pop up when MTGA starts."
if ask "Start $APP_NAME hidden when you log in?"; then
"$APP_DIR/bin/python" -c "from mtga_assistant.background import set_autostart; set_autostart(True)"
info "Added $AUTOSTART_FILE (change it later in the app: menu -> Start at login)."
else
"$APP_DIR/bin/python" -c "from mtga_assistant.background import set_autostart; set_autostart(False)"
info "Skipped."
fi
printf '\n%s%s is installed.%s\n' "$GREEN$BOLD" "$APP_NAME" "$RESET"
info "Remember to enable MTGA's detailed logs: Options -> Account -> Detailed Logs (Plugin Support)."
info "To update, run ./install.sh again; to remove, ./install.sh --uninstall"
if app_running; then
info "$APP_NAME is already running; restart it (tray icon -> Quit, then start it again) to use this version."
elif ask "Start $APP_NAME now?"; then
nohup "$BIN" >/dev/null 2>&1 &
disown || true
fi
+205
View File
@@ -0,0 +1,205 @@
# MTGA Assistant installer for Windows.
#
# Normally started by double-clicking install.bat in the project folder. It:
# 1. finds Python 3.12+ (offering to install it with winget if missing),
# 2. installs the app's dependencies and PyInstaller into a private build environment,
# 3. builds the app with PyInstaller,
# 4. installs it to %LOCALAPPDATA%\Programs\MTGA Assistant (no admin rights needed),
# 5. adds Start Menu and Desktop shortcuts, optionally starts it at login, and launches it.
#
# Uninstall: install.bat -Uninstall
param(
[switch]$Uninstall,
[switch]$NoLaunch
)
$ErrorActionPreference = "Stop"
$ProgressPreference = "SilentlyContinue"
$AppName = "MTGA Assistant"
$ExeName = "MTGA Assistant.exe"
$RunValue = "mtga-assistant" # same value the app's own "Start at login" option uses
$RunKey = "HKCU:\Software\Microsoft\Windows\CurrentVersion\Run"
$MinPython = [version]"3.12"
$WingetId = "Python.Python.3.12"
$ProjectDir = Split-Path -Parent $PSScriptRoot
$BuildVenv = Join-Path $ProjectDir ".venv-build"
$DistDir = Join-Path $ProjectDir "dist\$AppName"
$InstallDir = Join-Path $env:LOCALAPPDATA "Programs\$AppName"
$InstalledExe = Join-Path $InstallDir $ExeName
$StartMenuLnk = Join-Path ([Environment]::GetFolderPath("Programs")) "$AppName.lnk"
$DesktopLnk = Join-Path ([Environment]::GetFolderPath("Desktop")) "$AppName.lnk"
function Step($text) { Write-Host ""; Write-Host "==> $text" -ForegroundColor Cyan }
function Info($text) { Write-Host " $text" }
function Fail($text) { Write-Host ""; Write-Host "ERROR: $text" -ForegroundColor Red; exit 1 }
function Ask-YesNo($question, [bool]$default = $true) {
$hint = if ($default) { "[Y/n]" } else { "[y/N]" }
$answer = Read-Host " $question $hint"
if ([string]::IsNullOrWhiteSpace($answer)) { return $default }
return $answer.Trim().ToLower().StartsWith("y")
}
function Stop-RunningApp {
$running = Get-Process -Name $AppName -ErrorAction SilentlyContinue
if ($running) {
Info "Closing the running $AppName..."
$running | Stop-Process -Force
Start-Sleep -Seconds 1
}
}
function Remove-Shortcuts {
foreach ($lnk in @($StartMenuLnk, $DesktopLnk)) {
if (Test-Path $lnk) { Remove-Item $lnk -Force }
}
}
# --- uninstall -------------------------------------------------------------------------------
if ($Uninstall) {
Step "Uninstalling $AppName"
Stop-RunningApp
Remove-Shortcuts
Remove-ItemProperty -Path $RunKey -Name $RunValue -ErrorAction SilentlyContinue
if (Test-Path $InstallDir) { Remove-Item $InstallDir -Recurse -Force }
Info "Removed the app, its shortcuts and its login item."
Info "Your game history and settings were kept in $env:LOCALAPPDATA\mtga-assistant and $env:APPDATA\mtga-assistant."
Info "Delete those folders too if you want to remove everything."
exit 0
}
Write-Host "$AppName installer" -ForegroundColor Green
Info "Project folder: $ProjectDir"
# --- 1. Python --------------------------------------------------------------------------------
function Refresh-Path {
# winget installs update PATH in the registry, not in this already-running session.
$machine = [Environment]::GetEnvironmentVariable("Path", "Machine")
$user = [Environment]::GetEnvironmentVariable("Path", "User")
$env:Path = "$machine;$user"
}
function Test-Python([string[]]$command) {
# Returns the version if this command runs a real Python >= $MinPython, else $null.
# (The Microsoft Store "python" alias exists but only opens the Store, so it fails here.)
try {
$exe = $command[0]
$prefix = @($command | Select-Object -Skip 1)
$out = & $exe @prefix -c "import sys; print('%d.%d.%d' % sys.version_info[:3])" 2>$null
if ($LASTEXITCODE -ne 0 -or -not $out) { return $null }
$version = [version]($out | Select-Object -First 1).Trim()
if ($version -ge $MinPython) { return $version }
} catch { }
return $null
}
function Find-Python {
$candidates = @(
@("py", "-3.13"), @("py", "-3.12"), @("py", "-3"),
@("python"), @("python3"),
@((Join-Path $env:LOCALAPPDATA "Programs\Python\Python313\python.exe")),
@((Join-Path $env:LOCALAPPDATA "Programs\Python\Python312\python.exe"))
)
foreach ($cmd in $candidates) {
$cmd = [string[]]@($cmd) # keep one-word commands as arrays, not strings
$version = Test-Python $cmd
if ($version) { return @{ Command = $cmd; Version = $version } }
}
return $null
}
Step "Checking for Python $MinPython or newer"
$python = Find-Python
if (-not $python) {
Info "Python $MinPython+ was not found."
$winget = Get-Command winget -ErrorAction SilentlyContinue
if ($winget -and (Ask-YesNo "Install Python 3.12 now with winget (for your user only)?")) {
& winget install --exact --id $WingetId --scope user --silent `
--accept-package-agreements --accept-source-agreements
if ($LASTEXITCODE -ne 0) { Fail "winget couldn't install Python (exit code $LASTEXITCODE)." }
Refresh-Path
$python = Find-Python
}
if (-not $python) {
Info "Install Python from https://www.python.org/downloads/ (tick 'Add python.exe to PATH'),"
Info "then run install.bat again."
Start-Process "https://www.python.org/downloads/windows/"
Fail "Python $MinPython or newer is required."
}
}
$pyExe = $python.Command[0]
$pyArgs = @($python.Command | Select-Object -Skip 1)
Info "Using Python $($python.Version) ($($python.Command -join ' '))"
# --- 2. Dependencies --------------------------------------------------------------------------
Step "Installing dependencies (first run downloads ~100 MB; this can take a few minutes)"
$venvPython = Join-Path $BuildVenv "Scripts\python.exe"
if (-not (Test-Path $venvPython)) {
& $pyExe @pyArgs -m venv $BuildVenv
if ($LASTEXITCODE -ne 0) { Fail "Couldn't create the build environment in $BuildVenv." }
}
& $venvPython -m pip install --disable-pip-version-check --quiet --upgrade pip
& $venvPython -m pip install --disable-pip-version-check --quiet $ProjectDir "pyinstaller>=6"
if ($LASTEXITCODE -ne 0) { Fail "Installing dependencies failed (see the messages above)." }
Info "Dependencies installed."
# --- 3. Build ------------------------------------------------------------------------------------
Step "Building $AppName"
Push-Location $ProjectDir
try {
& $venvPython -m PyInstaller --noconfirm --clean --log-level WARN "packaging\mtga-assistant.spec"
if ($LASTEXITCODE -ne 0) { Fail "The PyInstaller build failed (see the messages above)." }
} finally {
Pop-Location
}
if (-not (Test-Path (Join-Path $DistDir $ExeName))) { Fail "The build finished but $ExeName is missing." }
Info "Built $DistDir"
# --- 4. Install ----------------------------------------------------------------------------------
Step "Installing to $InstallDir"
Stop-RunningApp
if (Test-Path $InstallDir) { Remove-Item $InstallDir -Recurse -Force }
New-Item -ItemType Directory -Path (Split-Path $InstallDir) -Force | Out-Null
Copy-Item -Path $DistDir -Destination $InstallDir -Recurse
Info "Installed."
# --- 5. Shortcuts, login item, launch -------------------------------------------------------------
Step "Creating shortcuts"
$shell = New-Object -ComObject WScript.Shell
foreach ($lnkPath in @($StartMenuLnk, $DesktopLnk)) {
$lnk = $shell.CreateShortcut($lnkPath)
$lnk.TargetPath = $InstalledExe
$lnk.WorkingDirectory = $InstallDir
$lnk.IconLocation = "$InstalledExe,0"
$lnk.Description = "Deck and game tracker for Magic: The Gathering Arena"
$lnk.Save()
}
Info "Start Menu: $StartMenuLnk"
Info "Desktop: $DesktopLnk"
Step "Start at login"
Info "Running in the background lets it record every game and pop up when MTGA starts."
if (Ask-YesNo "Start $AppName hidden when you log in?") {
Set-ItemProperty -Path $RunKey -Name $RunValue -Value "`"$InstalledExe`" --background"
Info "Added. (You can change this later in the app: menu -> Start at login.)"
} else {
Remove-ItemProperty -Path $RunKey -Name $RunValue -ErrorAction SilentlyContinue
Info "Skipped."
}
Write-Host ""
Write-Host "$AppName is installed." -ForegroundColor Green
Info "Remember to enable MTGA's detailed logs: Options -> Account -> Detailed Logs (Plugin Support)."
Info "To uninstall later, run: install.bat -Uninstall"
if (-not $NoLaunch) {
Start-Process -FilePath $InstalledExe -WorkingDirectory $InstallDir
}
+22 -20
View File
@@ -1,39 +1,41 @@
"""Render assets/icon.svg to assets/icon.ico (PNG-compressed entries at the usual Windows sizes).
"""Build the app icons from images/mtga-assistant-icon.png (the single source image).
Run with: uv run python packaging/make_icon.py
Writes:
* src/mtga_assistant/icon.png: loaded at runtime for the window, taskbar and tray icon.
* images/mtga-assistant-icon.ico: the Windows .exe icon (PNG-compressed entries at the usual sizes).
Run after changing the source image: uv run python packaging/make_icon.py
"""
import shutil
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
from PySide6.QtGui import QGuiApplication, QImage
SIZES = [16, 24, 32, 48, 64, 128, 256]
ROOT = Path(__file__).resolve().parent.parent
SOURCE = ROOT / "images" / "mtga-assistant-icon.png"
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()
def png_at(img: QImage, size: int) -> bytes:
scaled = img.scaled(size, size, Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation)
data = QByteArray()
buf = QBuffer(data)
buf.open(QIODevice.OpenModeFlag.WriteOnly)
img.save(buf, "PNG")
return bytes(data)
scaled.save(buf, "PNG") # type: ignore[arg-type] # PySide accepts str; its stubs say bytes
return data.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.
img = QImage(str(SOURCE))
if img.isNull():
sys.exit(f"can't read {SOURCE}")
pngs = [(size, png_at(img, size)) for size in SIZES]
# ICONDIR header, 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:
@@ -41,10 +43,10 @@ def main() -> None:
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")
ico = SOURCE.with_suffix(".ico")
ico.write_bytes(out)
shutil.copyfile(SOURCE, ROOT / "src" / "mtga_assistant" / "icon.png")
print(f"wrote {ico.relative_to(ROOT)} and src/mtga_assistant/icon.png from {SOURCE.relative_to(ROOT)}")
if __name__ == "__main__":
+1 -2
View File
@@ -14,7 +14,6 @@ a = Analysis(
# Qt modules the app doesn't use; keeps the bundle smaller.
excludes=[
"tkinter",
"PySide6.QtNetwork",
"PySide6.QtQml",
"PySide6.QtQuick",
"PySide6.QtSql",
@@ -37,6 +36,6 @@ exe = EXE(
exclude_binaries=True,
name="MTGA Assistant",
console=False,
icon=str(ROOT / "assets" / "icon.ico") if sys.platform == "win32" else None,
icon=str(ROOT / "images" / "mtga-assistant-icon.ico") if sys.platform == "win32" else None,
)
coll = COLLECT(exe, a.binaries, a.datas, name="MTGA Assistant")
+1 -1
View File
@@ -1,7 +1,7 @@
[project]
name = "mtga-assistant"
version = "0.1.0"
description = "Native Linux deck and game tracker for MTG Arena, driven by Player.log"
description = "Deck and game tracker for MTG Arena on Linux and Windows, driven by Player.log"
readme = "README.md"
requires-python = ">=3.12"
dependencies = [
+17 -2
View File
@@ -1,4 +1,4 @@
"""Entry point: mtga-assistant [--log PATH] [--replay PATH [--rate N]]."""
"""Entry point: mtga-assistant [--background] [--log PATH] [--replay PATH [--rate N]]."""
from __future__ import annotations
@@ -10,6 +10,7 @@ from PySide6.QtGui import QIcon
from PySide6.QtWidgets import QApplication
from . import paths
from .background import SingleInstance
from .carddb import open_card_db
from .history import HistoryStore
from .images import ImageCache
@@ -23,6 +24,8 @@ def main() -> int:
ap.add_argument("--replay", type=Path, help="replay a saved log instead of following the live one")
ap.add_argument("--rate", type=float, default=40.0, help="replay speed in log events per second")
ap.add_argument("--mtga-dir", type=Path, help="MTGA install directory (default: auto-detect)")
ap.add_argument("--background", action="store_true",
help="start hidden (e.g. at login); the window opens when MTGA starts")
args, qt_args = ap.parse_known_args()
cfg = paths.load_config()
@@ -37,6 +40,15 @@ def main() -> int:
ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID("mtga-assistant")
app = QApplication([sys.argv[0], *qt_args])
app.setQuitOnLastWindowClosed(False) # closing the window keeps recording in the background
# One instance per user: a second launch just asks the running one to show its window.
instance = None
if not args.replay:
instance = SingleInstance()
if instance.send("background" if args.background else "show"):
return 0
instance.listen()
app.setWindowIcon(QIcon(str(Path(__file__).with_name("icon.png"))))
app.setApplicationName("MTGA Assistant")
app.setDesktopFileName("mtga-assistant") # Wayland app_id, usable in Hyprland window rules
@@ -57,7 +69,10 @@ def main() -> int:
# Matches from the previous MTGA session are only in Player-prev.log.
win.import_history(log_path.with_name("Player-prev.log"))
app.aboutToQuit.connect(win.shutdown)
win.show()
if instance is not None:
instance.message.connect(lambda msg: win.bring_to_front() if msg == "show" else None)
if not args.background:
win.show()
return app.exec()
+190
View File
@@ -0,0 +1,190 @@
"""Running in the background: single instance, MTGA-launch detection, and start-at-login.
* ``SingleInstance``: a local socket (Unix socket / Windows named pipe). Launching the app again
sends the running instance a message ("show" or "background") instead of starting a second copy.
* ``LaunchWatcher``: OS file-system notifications (inotify / ReadDirectoryChangesW, via
QFileSystemWatcher) on MTGA's log folder. When MTGA starts it moves the old log to
Player-prev.log and creates a fresh Player.log; that change is reported as ``launched``.
* Autostart: an XDG autostart entry on Linux, the HKCU Run key on Windows.
"""
from __future__ import annotations
import getpass
import os
import sys
from pathlib import Path
from PySide6.QtCore import QFileSystemWatcher, QObject, Signal
from PySide6.QtNetwork import QLocalServer, QLocalSocket
APP_ID = "mtga-assistant"
CONNECT_TIMEOUT_MS = 500
def server_name() -> str:
return f"{APP_ID}-{getpass.getuser()}"
class SingleInstance(QObject):
message = Signal(str)
def __init__(self, name: str | None = None, parent=None):
super().__init__(parent)
self.name = name or server_name()
self._server: QLocalServer | None = None
def send(self, msg: str) -> bool:
"""Deliver msg to an already-running instance. False if none is running."""
sock = QLocalSocket()
sock.connectToServer(self.name)
if not sock.waitForConnected(CONNECT_TIMEOUT_MS):
return False
sock.write(msg.encode() + b"\n")
sock.flush()
sock.waitForBytesWritten(CONNECT_TIMEOUT_MS)
sock.disconnectFromServer()
return True
def listen(self) -> bool:
# No instance answered, so any leftover socket file is stale (e.g. after a crash).
QLocalServer.removeServer(self.name)
self._server = QLocalServer(self)
self._server.newConnection.connect(self._on_connection)
return self._server.listen(self.name)
def close(self) -> None:
if self._server is not None:
self._server.close()
self._server.deleteLater()
self._server = None
def _on_connection(self) -> None:
while self._server and self._server.hasPendingConnections():
sock = self._server.nextPendingConnection()
sock.readyRead.connect(lambda s=sock: self._read(s))
sock.disconnected.connect(sock.deleteLater)
if sock.bytesAvailable():
self._read(sock)
def _read(self, sock: QLocalSocket) -> None:
while sock.canReadLine():
self.message.emit(sock.readLine().data().decode(errors="replace").strip())
class LaunchWatcher(QObject):
"""Emits ``launched`` when MTGA starts a new log, using OS change notifications (no polling)."""
launched = Signal()
def __init__(self, log_path: Path, parent=None):
super().__init__(parent)
self.log_path = log_path
self._last = self._identity()
self._watcher = QFileSystemWatcher(self)
self._watcher.directoryChanged.connect(self._check)
self._watcher.fileChanged.connect(self._check)
self._arm()
@property
def active(self) -> bool:
return bool(self._watcher.directories())
def _identity(self) -> tuple[int, int] | None:
"""(inode/file id, size) of Player.log, or None if it doesn't exist."""
try:
st = self.log_path.stat()
except OSError:
return None
return st.st_ino, st.st_size
def _arm(self) -> None:
folder = str(self.log_path.parent)
if self.log_path.parent.is_dir() and folder not in self._watcher.directories():
self._watcher.addPath(folder)
# Watching the file itself catches MTGA truncating it in place rather than replacing it.
if self.log_path.exists() and str(self.log_path) not in self._watcher.files():
self._watcher.addPath(str(self.log_path))
def _check(self, *_args) -> None:
now = self._identity()
previous, self._last = self._last, now
self._arm() # a replaced file has to be watched again
if now is None:
return
replaced = previous is None or now[0] != previous[0]
truncated = previous is not None and now[0] == previous[0] and now[1] < previous[1]
if replaced or truncated:
self.launched.emit()
# -- start at login -----------------------------------------------------------------------------
def launch_command() -> list[str]:
"""Command that starts this app hidden in the background."""
if getattr(sys, "frozen", False): # PyInstaller build
return [sys.executable, "--background"]
return [sys.executable, "-m", "mtga_assistant", "--background"]
def desktop_exec(cmd: list[str]) -> str:
"""Quote a command for a .desktop Exec= line (double quotes, per the Desktop Entry spec)."""
def quote(arg: str) -> str:
if not any(c in arg for c in ' \t"\'\\$`<>|&;*?#()'):
return arg
escaped = "".join("\\" + c if c in '"`$\\' else c for c in arg)
return f'"{escaped}"'
return " ".join(quote(a) for a in cmd)
def _autostart_desktop_file() -> Path:
config = Path(os.environ.get("XDG_CONFIG_HOME") or Path.home() / ".config")
return config / "autostart" / f"{APP_ID}.desktop"
RUN_KEY = r"Software\Microsoft\Windows\CurrentVersion\Run"
def autostart_enabled(platform: str = sys.platform) -> bool:
if platform == "win32":
import winreg
try:
with winreg.OpenKey(winreg.HKEY_CURRENT_USER, RUN_KEY) as key:
winreg.QueryValueEx(key, APP_ID)
return True
except OSError:
return False
return _autostart_desktop_file().exists()
def set_autostart(enabled: bool, platform: str = sys.platform) -> None:
cmd = launch_command()
if platform == "win32":
import subprocess
import winreg
with winreg.OpenKey(winreg.HKEY_CURRENT_USER, RUN_KEY, 0, winreg.KEY_SET_VALUE) as key:
if enabled:
winreg.SetValueEx(key, APP_ID, 0, winreg.REG_SZ, subprocess.list2cmdline(cmd))
else:
try:
winreg.DeleteValue(key, APP_ID)
except OSError:
pass
return
path = _autostart_desktop_file()
if not enabled:
path.unlink(missing_ok=True)
return
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(
"[Desktop Entry]\n"
"Type=Application\n"
"Name=MTGA Assistant\n"
"Comment=Records MTG Arena games in the background\n"
f"Exec={desktop_exec(cmd)}\n"
"Terminal=false\n"
"X-GNOME-Autostart-enabled=true\n"
)
+30 -1
View File
@@ -6,7 +6,7 @@ import re
import shutil
import sqlite3
import sys
from dataclasses import dataclass, field
from dataclasses import dataclass, field, replace
from functools import lru_cache
from pathlib import Path
@@ -117,6 +117,24 @@ def open_card_db(db: Path | None, copy_dir: Path, platform: str = sys.platform)
return CardDB(db)
def command_object_info(cards: "CardDB", obj: dict) -> CardInfo:
"""Display info for a non-card command-zone object (an emblem, boon, ...).
These have their own small grpIds that aren't cards (e.g. 6 for a boon), so naming them by
grpId gives "Card #6". Use the object's own name if the log gives one, otherwise the card that
created it. The returned grp_id is the source card's, so hovering previews that card.
"""
kind = (obj.get("type") or "").removeprefix("GameObjectType_") or "Emblem"
source = obj.get("objectSourceGrpId")
source_info = cards.get(source) if source else None
name = cards.loc(obj["name"]) if obj.get("name") else None
if not name:
name = f"{source_info.name} ({kind.lower()})" if source_info else kind
# Without a source card there's nothing sensible to preview (grp_id 0 = no hover preview).
base = source_info or CardInfo(grp_id=0, name=name)
return replace(base, name=name, mana=(), types=(kind,), is_token=False)
def unknown_card(grp_id: int) -> CardInfo:
return CardInfo(grp_id=grp_id, name=f"Unknown card #{grp_id}")
@@ -169,6 +187,17 @@ class CardDB:
colors=tuple(c for c in WUBRG if c in color_set),
)
@lru_cache(maxsize=None)
def loc(self, loc_id: int) -> str | None:
"""A localized (English) string by LocId, e.g. an emblem's name."""
if self._conn is None:
return None
row = self._conn.execute(
"SELECT Loc FROM Localizations_enUS WHERE LocId = ? ORDER BY Formatted = 1 DESC LIMIT 1",
(loc_id,),
).fetchone()
return _strip_tags(row[0]) if row and row[0] else None
@lru_cache(maxsize=None)
def counter_name(self, counter_type: int) -> str:
if self._conn is not None:
+5 -5
View File
@@ -295,17 +295,17 @@ class GameState:
if seat := obj.get("ownerSeatId"):
self.commander_casts[seat] += 1
def emblems(self, seat: int) -> Counter[int]:
"""Emblems and other non-card objects in the seat's command zone (commanders excluded)."""
out: Counter[int] = Counter()
def emblems(self, seat: int) -> list[dict[str, Any]]:
"""Emblems, boons and other non-card objects in the seat's command zone (commanders excluded)."""
out = []
for z in self.zones.values():
if z.type != "ZoneType_Command":
continue
for iid in z.ids:
obj = self.objects.get(iid)
if obj and obj.get("grpId") and obj.get("type") != "GameObjectType_Card" \
if obj and obj.get("type") != "GameObjectType_Card" \
and obj.get("ownerSeatId", obj.get("controllerSeatId")) == seat:
out[obj["grpId"]] += 1
out.append(obj)
return out
def _land_rules(self) -> dict:
Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 9.2 KiB

+92 -2
View File
@@ -8,6 +8,7 @@ from pathlib import Path
from PySide6.QtCore import QSettings, Qt, QThread, QTimer, Signal
from PySide6.QtGui import QAction, QFont, QFontMetrics, QKeySequence
from PySide6.QtWidgets import (
QApplication,
QButtonGroup,
QFileDialog,
QFrame,
@@ -19,12 +20,14 @@ from PySide6.QtWidgets import (
QPushButton,
QSizePolicy,
QStackedWidget,
QSystemTrayIcon,
QToolButton,
QVBoxLayout,
QWidget,
)
from .. import paths
from ..background import LaunchWatcher, autostart_enabled, set_autostart
from ..carddb import CardDB
from ..gamestate import MatchTracker
from ..history import HistoryRecorder, HistoryStore, import_log, replay_game
@@ -213,6 +216,9 @@ class MainWindow(QMainWindow):
self.clock_timer.start()
self.resize(1400, 880)
self._quitting = False
self.launch_watcher: LaunchWatcher | None = None
self._setup_tray()
self.history_view.set_games(history.games())
self.show_view(int(self._settings.value("view2", MATCH, type=int)))
if log_path is not None:
@@ -235,9 +241,26 @@ class MainWindow(QMainWindow):
clear_act = menu.addAction("Clear image cache")
clear_act.triggered.connect(self._clear_cache)
menu.addSeparator()
for label, key, default, tip in (
("Keep running in the background when closed", "background/close_to_tray", True,
"Closing the window hides it; games keep being recorded. Quit from the tray or this menu."),
("Show the window when MTGA starts", "background/show_on_launch", True,
"Brings the window up when MTGA starts a new log."),
):
act = menu.addAction(label)
act.setCheckable(True)
act.setChecked(self._settings.value(key, default, type=bool))
act.setToolTip(tip)
act.toggled.connect(lambda on, k=key: self._settings.setValue(k, on))
login = menu.addAction("Start at login (in the background)")
login.setCheckable(True)
login.setChecked(autostart_enabled())
login.toggled.connect(self._set_autostart)
menu.setToolTipsVisible(True)
menu.addSeparator()
quit_act = QAction("Quit", self)
quit_act.setShortcut(QKeySequence.StandardKey.Quit)
quit_act.triggered.connect(self.close)
quit_act.triggered.connect(self.quit_app)
self.addAction(quit_act) # keep the shortcut active while the menu is closed
unpreview = QAction("Stop previewing", self)
unpreview.setShortcut(QKeySequence(Qt.Key.Key_Escape))
@@ -310,6 +333,12 @@ class MainWindow(QMainWindow):
self.follower.status.connect(self.status_log.setText)
self.follower.reset.connect(self._reset_tracker)
self.follower.start()
if self.launch_watcher is not None:
self.launch_watcher.deleteLater()
self.launch_watcher = None
if not self.replaying:
self.launch_watcher = LaunchWatcher(path, self)
self.launch_watcher.launched.connect(self._on_mtga_launched)
def _reset_tracker(self) -> None:
self.tracker = MatchTracker(card_lookup=self.cards)
@@ -369,9 +398,68 @@ class MainWindow(QMainWindow):
self.follower = None
self.images.shutdown()
# -- background ------------------------------------------------------------
def _setup_tray(self) -> None:
self.tray: QSystemTrayIcon | None = None
if not QSystemTrayIcon.isSystemTrayAvailable():
return
self.tray = QSystemTrayIcon(QApplication.windowIcon(), self)
self.tray.setToolTip("MTGA Assistant")
menu = QMenu(self)
menu.addAction("Show window").triggered.connect(self.bring_to_front)
menu.addAction("Hide window").triggered.connect(self.hide)
menu.addSeparator()
menu.addAction("Quit").triggered.connect(self.quit_app)
self.tray.setContextMenu(menu)
self.tray.activated.connect(self._on_tray_activated)
self.tray.show()
def _on_tray_activated(self, reason: QSystemTrayIcon.ActivationReason) -> None:
if reason == QSystemTrayIcon.ActivationReason.Trigger:
if self.isVisible():
self.hide()
else:
self.bring_to_front()
def bring_to_front(self) -> None:
if self.isMinimized():
self.showNormal()
self.show()
self.raise_()
self.activateWindow()
def _on_mtga_launched(self) -> None:
if self._settings.value("background/show_on_launch", True, type=bool):
self.bring_to_front()
def _set_autostart(self, enabled: bool) -> None:
try:
set_autostart(enabled)
except OSError as e:
QMessageBox.warning(self, "Start at login", f"Couldn't change the login item: {e}")
def quit_app(self) -> None:
self._quitting = True
QApplication.quit()
def closeEvent(self, event) -> None:
self.shutdown()
keep = self._settings.value("background/close_to_tray", True, type=bool)
if keep and not self.replaying and not self._quitting:
event.ignore()
self.hide()
if self.tray is not None and not self._settings.value("background/told", False, type=bool):
self.tray.showMessage("MTGA Assistant", "Still recording games in the background. "
"Use the tray icon to show the window or quit.",
QApplication.windowIcon(), 6000)
self._settings.setValue("background/told", True)
return
super().closeEvent(event)
QApplication.quit() # not running in the background: closing the window quits
def showEvent(self, event) -> None:
super().showEvent(event)
self._force_refresh() # rendering is skipped while hidden
# -- rendering -------------------------------------------------------------
@@ -391,6 +479,8 @@ class MainWindow(QMainWindow):
self.match_view.tick(self.shown_tracker, self.now_ms())
def refresh(self) -> None:
if not self.isVisible():
return # tracking continues; the views catch up when shown
t = self.shown_tracker
index = self.stack.currentIndex()
key = (id(t), t.version)
+6 -2
View File
@@ -15,7 +15,7 @@ from PySide6.QtWidgets import (
QWidget,
)
from ..carddb import CardDB
from ..carddb import CardDB, command_object_info
from ..gamestate import GameState, MatchTracker
from . import theme
from .card_list import CardList, Row, Section
@@ -164,8 +164,12 @@ class PlayerCard(QFrame):
self.commander.setVisible(t.on("commander") and bool(status))
emblems = g.emblems(seat)
counts: dict[str, list] = {} # name -> [info, count]
for obj in emblems:
info = command_object_info(self.cards, obj)
counts.setdefault(info.name, [info, 0])[1] += 1
self.emblems.set_sections([Section("emblems", "Emblems", [
Row(self.cards.get(grp), n) for grp, n in emblems.items()
Row(info, n) for info, n in counts.values()
])] if emblems else [])
self.emblems.setVisible(t.on("emblems") and bool(emblems))
+105
View File
@@ -0,0 +1,105 @@
import os
import time
import uuid
import pytest
from PySide6.QtCore import QCoreApplication
from mtga_assistant.background import (
LaunchWatcher,
SingleInstance,
autostart_enabled,
desktop_exec,
launch_command,
set_autostart,
)
@pytest.fixture(scope="module")
def app():
return QCoreApplication.instance() or QCoreApplication([])
def wait_for(app, cond, timeout=3.0):
end = time.monotonic() + timeout
while time.monotonic() < end:
app.processEvents()
if cond():
return True
time.sleep(0.01)
app.processEvents()
return cond()
def test_second_launch_messages_the_running_instance(app):
name = f"mtga-assistant-test-{uuid.uuid4().hex[:8]}"
first = SingleInstance(name)
assert not first.send("show") # nobody running yet
assert first.listen()
got = []
first.message.connect(got.append)
second = SingleInstance(name)
try:
assert second.send("show")
assert wait_for(app, lambda: got == ["show"])
finally:
# Let queued socket events run before the objects go away.
first.close()
wait_for(app, lambda: False, timeout=0.2)
def test_launch_watcher_reports_new_log_not_appends(app, tmp_path):
log = tmp_path / "Player.log"
log.write_text("old session\n")
watcher = LaunchWatcher(log)
assert watcher.active
launches = []
watcher.launched.connect(lambda: launches.append(1))
# Normal logging: no launch.
with open(log, "a") as f:
f.write("more\n")
wait_for(app, lambda: False, timeout=0.3)
assert launches == []
# MTGA starts: old log becomes Player-prev.log, a new Player.log appears.
os.replace(log, tmp_path / "Player-prev.log")
log.write_text("new session\n")
assert wait_for(app, lambda: len(launches) >= 1)
# Still watching the new file: a truncate-in-place restart is reported too.
count = len(launches)
with open(log, "a") as f:
f.write("lots of lines\n" * 50)
wait_for(app, lambda: False, timeout=0.3)
assert len(launches) == count
log.write_text("")
assert wait_for(app, lambda: len(launches) > count)
def test_launch_watcher_without_folder_is_inactive(app, tmp_path):
assert not LaunchWatcher(tmp_path / "missing" / "Player.log").active
def test_linux_autostart_entry(monkeypatch, tmp_path):
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path))
assert not autostart_enabled("linux")
set_autostart(True, "linux")
entry = tmp_path / "autostart" / "mtga-assistant.desktop"
text = entry.read_text()
assert autostart_enabled("linux")
assert "--background" in text and "Exec=" in text
set_autostart(False, "linux")
assert not entry.exists() and not autostart_enabled("linux")
def test_launch_command_starts_hidden():
cmd = launch_command()
assert cmd[-1] == "--background"
def test_desktop_exec_quoting():
assert desktop_exec(["/usr/bin/python", "-m", "x"]) == "/usr/bin/python -m x"
assert desktop_exec(["/a b/c"]) == '"/a b/c"'
assert desktop_exec(['/a"b']) == '"/a\\"b"'
+21 -2
View File
@@ -211,8 +211,8 @@ def test_commanders_are_not_emblems():
g = t.game
assert g.commander_status(ME) == [(8000, "command zone")]
assert g.commander_status(OPP) == [(9000, "command zone")]
assert g.emblems(OPP) == {7777: 1}
assert g.emblems(ME) == {}
assert [o["grpId"] for o in g.emblems(OPP)] == [7777]
assert g.emblems(ME) == []
assert sum(t.my_library_remaining().values()) == 3 # the commander isn't part of the library
# Opponent casts their commander from the command zone: it's on the stack, tax goes up.
@@ -247,3 +247,22 @@ def test_known_cards_never_exceed_zone_size():
t.handle(GreMessages([gsm(2, zones=[zone(OPP_HAND, "Hand", [201], OPP)], objects=[
card(400, 7007, OPP_HAND, otype="RevealedCard"), card(401, 7008, OPP_HAND, otype="RevealedCard")])]))
assert sum(t.game.opp_known_in("ZoneType_Hand").values()) == 1
def test_command_zone_objects_are_named_from_log_or_source_card():
from mtga_assistant.carddb import command_object_info
class Cards:
def get(self, grp):
return CardInfo(grp_id=grp, name={97038: "Swiftspear's Teachings", 87496: "The Ring"}.get(grp, f"Card #{grp}"))
def loc(self, loc_id):
return {732510: "The Ring"}.get(loc_id)
boon = {"grpId": 6, "type": "GameObjectType_Boon", "objectSourceGrpId": 97038}
emblem = {"grpId": 2, "type": "GameObjectType_Emblem", "objectSourceGrpId": 87496, "name": 732510}
bare = {"grpId": 3, "type": "GameObjectType_Emblem"}
b, e, x = (command_object_info(Cards(), o) for o in (boon, emblem, bare))
assert (b.name, b.types, b.grp_id) == ("Swiftspear's Teachings (boon)", ("Boon",), 97038) # hover shows the source card
assert (e.name, e.types, e.grp_id) == ("The Ring", ("Emblem",), 87496)
assert x.name == "Emblem" and x.grp_id == 0 # no source card: nothing to preview