From f16a67e042c979f444081e3ac29b7ffe3b2b238a Mon Sep 17 00:00:00 2001 From: Aeth3r Date: Mon, 19 Oct 2026 09:54:08 -0400 Subject: [PATCH] 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 --- .gitattributes | 6 + .gitignore | 1 + install.bat | 6 + install.sh | 223 ++++++++++++++++++++++++++++++++++ packaging/install_windows.ps1 | 205 +++++++++++++++++++++++++++++++ 5 files changed, 441 insertions(+) create mode 100644 .gitattributes create mode 100644 install.bat create mode 100755 install.sh create mode 100644 packaging/install_windows.ps1 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..8136a70 --- /dev/null +++ b/.gitattributes @@ -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 diff --git a/.gitignore b/.gitignore index 4d53613..385dda8 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ __pycache__/ .pytest_cache/ build/ dist/ +.venv-build/ diff --git a/install.bat b/install.bat new file mode 100644 index 0000000..bf4f07a --- /dev/null +++ b/install.bat @@ -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 diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..ae23c32 --- /dev/null +++ b/install.sh @@ -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" </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//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 diff --git a/packaging/install_windows.ps1 b/packaging/install_windows.ps1 new file mode 100644 index 0000000..2a0fc2f --- /dev/null +++ b/packaging/install_windows.ps1 @@ -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 +}