Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
90f846c70b | ||
|
|
d9ef771db8 | ||
|
|
f16a67e042 | ||
|
|
fb6220155c | ||
|
|
c4533d12c0 |
No files matched your search
@@ -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
|
||||
@@ -4,3 +4,4 @@ __pycache__/
|
||||
.pytest_cache/
|
||||
build/
|
||||
dist/
|
||||
.venv-build/
|
||||
@@ -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.
|
||||
|
||||

|
||||
|
||||
**A past game** opened from History, with "Back to live" to return.
|
||||
|
||||

|
||||
|
||||
**History**: stats and every finished game; sort by any column.
|
||||
|
||||

|
||||
|
||||
**Filtered history**: right-click any value to filter; the stats follow the filter.
|
||||
|
||||

|
||||
|
||||
**Customization**: reorder and show/hide deck sections and turn stats.
|
||||
|
||||

|
||||
|
||||
## 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.
|
||||
|
Before Width: | Height: | Size: 27 KiB |
@@ -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 |
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 9.2 KiB |
|
After Width: | Height: | Size: 673 KiB |
|
After Width: | Height: | Size: 690 KiB |
|
After Width: | Height: | Size: 818 KiB |
|
After Width: | Height: | Size: 711 KiB |
|
After Width: | Height: | Size: 643 KiB |
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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__":
|
||||
|
||||
@@ -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,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 = [
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 9.2 KiB |
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -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"'
|
||||
@@ -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
|
||||