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>
This commit is contained in:
Aeth3randClaude Opus 5.5 committed 2026-10-19 09:54:08 -04:00
1 parent fb6220155c
commit f16a67e042
5 files changed
+441

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/
+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
}