diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d6b6402..622a223 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -48,6 +48,15 @@ jobs: - name: Run legacy integration tests run: python integration_tests.py + - name: Validate Linux shell scripts + if: runner.os == 'Linux' + run: | + sh -n setup.sh + sh -n run-unityscraper.sh + sh -n build_linux.sh + sh -n packaging/linux/install.sh + sh -n packaging/linux/uninstall.sh + windows-build: name: Windows executable runs-on: windows-latest @@ -80,3 +89,50 @@ jobs: dist/UnityScraper.exe dist/UnityScraper.exe.sha256 if-no-files-found: error + + linux-build: + name: Linux executable + runs-on: ubuntu-22.04 + needs: test + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + cache: pip + + - name: Install desktop build dependencies + run: | + sudo apt-get update + sudo apt-get install -y appstream desktop-file-utils python3-tk xvfb + python -m pip install -r requirements.txt pyinstaller + + - name: Validate desktop metadata + run: | + sed -e 's|@EXEC@|/usr/bin/unityscraper|g' \ + -e 's|@ICON@|io.github.trapemall.UnityScraper|g' \ + packaging/linux/io.github.trapemall.UnityScraper.desktop \ + > /tmp/io.github.trapemall.UnityScraper.desktop + desktop-file-validate /tmp/io.github.trapemall.UnityScraper.desktop + appstreamcli validate --no-net packaging/linux/io.github.trapemall.UnityScraper.metainfo.xml + + - name: Build Linux bundle + run: ./build_linux.sh + + - name: Smoke test graphical executable + run: | + set +e + xvfb-run -a timeout 8s dist/UnityScraper + status=$? + set -e + test "$status" -eq 124 + + - uses: actions/upload-artifact@v4 + with: + name: UnityScraper-Linux-x86_64 + path: | + dist/UnityScraper-Linux-x86_64.tar.gz + dist/UnityScraper-Linux-x86_64.tar.gz.sha256 + if-no-files-found: error diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 99df1cb..20cb9d5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -9,8 +9,8 @@ permissions: contents: write jobs: - release: - runs-on: windows-latest + validate: + runs-on: ubuntu-22.04 steps: - uses: actions/checkout@v4 @@ -20,43 +20,111 @@ jobs: python-version: "3.12" cache: pip - - name: Install build dependencies - run: python -m pip install -r requirements.txt pyinstaller + - name: Install dependencies + run: python -m pip install -r requirements.txt -r requirements-dev.txt - - name: Test - shell: pwsh + - name: Validate release run: | python scripts/check_version.py --tag "${{ github.ref_name }}" + python -m ruff check . python tests.py python integration_tests.py + windows: + runs-on: windows-latest + needs: validate + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + cache: pip + + - name: Install build dependencies + run: python -m pip install -r requirements.txt pyinstaller + - name: Build executable run: python -m PyInstaller --clean --noconfirm UnityScraper.spec - - name: Package release + - name: Package Windows release shell: pwsh run: | - Copy-Item README.md, CHANGELOG.md, LICENSE dist\ - Compress-Archive ` - -Path dist\UnityScraper.exe, dist\README.md, dist\CHANGELOG.md, dist\LICENSE ` - -DestinationPath UnityScraper-Windows-x64.zip + New-Item -ItemType Directory -Path package | Out-Null + Copy-Item dist\UnityScraper.exe, README.md, CHANGELOG.md, LICENSE package\ + Compress-Archive -Path package\* -DestinationPath UnityScraper-Windows-x64.zip $hash = (Get-FileHash UnityScraper-Windows-x64.zip -Algorithm SHA256).Hash.ToLower() "$hash *UnityScraper-Windows-x64.zip" | Set-Content UnityScraper-Windows-x64.zip.sha256 + - uses: actions/upload-artifact@v4 + with: + name: release-windows + path: | + UnityScraper-Windows-x64.zip + UnityScraper-Windows-x64.zip.sha256 + + linux: + runs-on: ubuntu-22.04 + needs: validate + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + cache: pip + + - name: Install desktop build dependencies + run: | + sudo apt-get update + sudo apt-get install -y appstream desktop-file-utils python3-tk + python -m pip install -r requirements.txt pyinstaller + + - name: Validate desktop metadata + run: | + sed -e 's|@EXEC@|/usr/bin/unityscraper|g' \ + -e 's|@ICON@|io.github.trapemall.UnityScraper|g' \ + packaging/linux/io.github.trapemall.UnityScraper.desktop \ + > /tmp/io.github.trapemall.UnityScraper.desktop + desktop-file-validate /tmp/io.github.trapemall.UnityScraper.desktop + appstreamcli validate --no-net packaging/linux/io.github.trapemall.UnityScraper.metainfo.xml + + - name: Build Linux release + run: ./build_linux.sh + + - uses: actions/upload-artifact@v4 + with: + name: release-linux + path: | + dist/UnityScraper-Linux-x86_64.tar.gz + dist/UnityScraper-Linux-x86_64.tar.gz.sha256 + + publish: + runs-on: ubuntu-latest + needs: [windows, linux] + + steps: + - uses: actions/download-artifact@v4 + with: + pattern: release-* + path: release + merge-multiple: true + - name: Publish GitHub release - shell: pwsh env: GH_TOKEN: ${{ github.token }} run: | - $arguments = @( - "release", "create", "${{ github.ref_name }}", - "UnityScraper-Windows-x64.zip", - "UnityScraper-Windows-x64.zip.sha256", - "--title", "UnityScraper ${{ github.ref_name }}", - "--generate-notes", - "--verify-tag" + args=( + release create "${{ github.ref_name }}" + release/* + --repo "${{ github.repository }}" + --title "UnityScraper ${{ github.ref_name }}" + --generate-notes + --verify-tag ) - if ("${{ github.ref_name }}" -match "-") { - $arguments += "--prerelease" - } - & gh @arguments + if [[ "${{ github.ref_name }}" == *-* ]]; then + args+=(--prerelease) + fi + gh "${args[@]}" diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 3b33c0e..b54d111 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -109,6 +109,10 @@ Normal Windows data: Portable mode uses `UnityScraperData` beside the application when a `portable.mode` marker is present. +Linux follows the XDG Base Directory specification and separates data, +configuration, cache, and logs under `XDG_DATA_HOME`, `XDG_CONFIG_HOME`, +`XDG_CACHE_HOME`, and `XDG_STATE_HOME`. + Bundled read-only assets are resolved through `app_paths.resource_path`, which works in source and PyInstaller one-file builds. @@ -127,7 +131,7 @@ See [SECURITY.md](SECURITY.md) for reporting and operational guidance. ## Packaging -`UnityScraper.spec` is the canonical PyInstaller definition. Assets and modules -loaded indirectly by the GUI are listed explicitly. GitHub Actions validates a -Windows one-file build on pull requests and publishes ZIP/checksum artifacts for -version tags. +`UnityScraper.spec` is the canonical cross-platform PyInstaller definition. +Assets and modules loaded indirectly by the GUI are listed explicitly. GitHub +Actions validates Windows and Linux one-file builds on pull requests. Version +tags publish a Windows ZIP and Linux tarball with separate SHA-256 files. diff --git a/CHANGELOG.md b/CHANGELOG.md index 88d7b5e..1aa8ffb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,13 @@ Notable changes to UnityScraper are documented here. The project follows ### Added +- Native Linux desktop support with XDG data, configuration, cache, and state + directories. +- Linux x86_64 release bundle with user-level installation, application-menu + integration, AppStream metadata, and safe uninstallation. +- Linux source setup, launcher, packaging script, platform tests, and dedicated + support documentation. +- Parallel Windows and Linux CI artifacts and tag-driven release publishing. - Authenticated remote REST API mode and validated configuration updates. - Cross-platform CI with Windows executable smoke builds. - Tagged-release packaging with SHA-256 checksums. @@ -14,6 +21,9 @@ Notable changes to UnityScraper are documented here. The project follows ### Changed +- PyInstaller configuration and desktop entry point now support Windows and + Linux from the same source tree. +- Knowledge source snapshots use the platform cache directory. - Repository-generated executables are published through releases and CI artifacts instead of being committed to source control. - Source checkouts use normal application storage unless the user creates a diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ec20fc5..391370d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -20,12 +20,25 @@ python -m venv .venv .\.venv\Scripts\python.exe -m pip install -r requirements.txt -r requirements-dev.txt ``` +Linux: + +```bash +python3 -m venv .venv +.venv/bin/python -m pip install -r requirements.txt -r requirements-dev.txt +``` + Run the desktop application: ```powershell .\.venv\Scripts\python.exe desktop_app.py ``` +On Linux: + +```bash +.venv/bin/python desktop_app.py +``` + Run validation: ```powershell @@ -34,6 +47,9 @@ Run validation: .\.venv\Scripts\python.exe tests.py ``` +Replace `.\.venv\Scripts\python.exe` with `.venv/bin/python` on Linux. Validate +changed shell scripts with `sh -n`. + ## Pull Requests - Branch from the current `main`. diff --git a/DOCS_INDEX.md b/DOCS_INDEX.md index d032aed..789a2f8 100644 --- a/DOCS_INDEX.md +++ b/DOCS_INDEX.md @@ -10,6 +10,8 @@ - [Advanced Features](ADVANCED_FEATURES.md) - rate limits, resume, diagnostics, portable mode, API, and conversion - [REST API](API.md) - authentication, endpoints, configuration, and safety +- [Linux Support](LINUX.md) - installation, XDG storage, desktop integration, + uninstallation, and troubleshooting - [Project Status](PROJECT_STATUS.md) - completed work, boundaries, and roadmap - [Changelog](CHANGELOG.md) - release history @@ -36,3 +38,9 @@ Windows packaging: ```powershell powershell -ExecutionPolicy Bypass -File .\build_windows.ps1 ``` + +Linux packaging: + +```bash +./build_linux.sh +``` diff --git a/LINUX.md b/LINUX.md new file mode 100644 index 0000000..040cbcf --- /dev/null +++ b/LINUX.md @@ -0,0 +1,140 @@ +# Linux Support + +UnityScraper supports 64-bit Linux desktops through a packaged PyInstaller +bundle or Python 3.10 and newer. The primary interface, command-line tools, +knowledge imports, backup manager, FTP transfer, REST API, and portable mode +use the same code and database format as Windows. + +## Supported Environment + +The release bundle is built on Ubuntu 22.04 for x86_64 systems. It should run +on current glibc-based distributions with an X11 desktop or XWayland available +under Wayland, including: + +- Ubuntu 22.04 and newer +- Debian 12 and newer +- Fedora Workstation +- current Arch Linux +- openSUSE Leap and Tumbleweed + +Native ARM64 packages and musl-only distributions are not currently produced. +Running from source remains possible when a compatible Python and Tk are +available. + +## Install the Release Bundle + +Download the Linux tarball and checksum from GitHub Releases: + +```text +UnityScraper-Linux-x86_64.tar.gz +UnityScraper-Linux-x86_64.tar.gz.sha256 +``` + +Verify, extract, and install for the current user: + +```bash +sha256sum --check UnityScraper-Linux-x86_64.tar.gz.sha256 +tar -xzf UnityScraper-Linux-x86_64.tar.gz +cd UnityScraper-Linux-x86_64 +./install.sh +``` + +The installer places the application under `~/.local/lib/unityscraper`, adds a +`~/.local/bin/unityscraper` command, and installs an application-menu entry and +icon. It does not require root access. + +## Run From Source + +Install Python, virtual-environment support, and Tk: + +```bash +# Debian or Ubuntu +sudo apt install python3 python3-venv python3-tk + +# Fedora +sudo dnf install python3 python3-tkinter + +# Arch Linux +sudo pacman -S python tk + +# openSUSE +sudo zypper install python311 python311-tk +``` + +Then run: + +```bash +./setup.sh +./run-unityscraper.sh +``` + +The command-line interface does not require a graphical session: + +```bash +.venv/bin/python main.py --help +``` + +## Linux Storage + +Installed mode follows the XDG Base Directory specification: + +| Purpose | Default location | Override | +| --- | --- | --- | +| Database, downloads, exports | `~/.local/share/unityscraper` | `XDG_DATA_HOME` | +| Configuration | `~/.config/unityscraper` | `XDG_CONFIG_HOME` | +| Source cache | `~/.cache/unityscraper` | `XDG_CACHE_HOME` | +| Logs | `~/.local/state/unityscraper/logs` | `XDG_STATE_HOME` | + +Set `UNITYSCRAPER_PORTABLE=1` or create `portable.mode` beside the executable +to place all writable files under `UnityScraperData` beside the application. +The marker must be created before UnityScraper starts. + +## Storage Devices + +Select mounted USB drives and archive folders from the Backup Manager. Common +desktop mount locations include `/run/media/$USER` and `/media/$USER`. +UnityScraper operates on normal mounted paths and does not mount filesystems or +request elevated privileges itself. + +FATX filesystems require a compatible external driver or mount tool. +UnityScraper does not bundle kernel modules, filesystem drivers, or privilege +helpers. + +## Uninstall + +From the installed application: + +```bash +~/.local/lib/unityscraper/uninstall.sh +``` + +The uninstaller removes the application but deliberately retains user data. +Remove retained data manually only after confirming it is no longer needed: + +```bash +rm -rf "${XDG_DATA_HOME:-$HOME/.local/share}/unityscraper" +rm -rf "${XDG_CONFIG_HOME:-$HOME/.config}/unityscraper" +rm -rf "${XDG_CACHE_HOME:-$HOME/.cache}/unityscraper" +rm -rf "${XDG_STATE_HOME:-$HOME/.local/state}/unityscraper" +``` + +## Troubleshooting + +`Tkinter is required` + +: Install the distribution's Tk package listed above. Recreate `.venv` if + Python was upgraded after the environment was created. + +`could not connect to a graphical desktop` + +: Launch UnityScraper from an active desktop session. For remote systems, use + the CLI or configure trusted X11 forwarding. + +Folders do not open from the Help page + +: Install `xdg-utils` or GLib's `gio` command. UnityScraper supports either. + +The packaged binary does not start + +: Run `./unityscraper` from a terminal to view the startup message, then include + the output and a diagnostics bundle in a bug report. diff --git a/PROJECT_STATUS.md b/PROJECT_STATUS.md index efe49d2..beb0bb8 100644 --- a/PROJECT_STATUS.md +++ b/PROJECT_STATUS.md @@ -20,8 +20,8 @@ backup-management, and source-attributed knowledge application. size, status, and checksum metadata. - Desktop knowledge browser with source status, licenses, citations, import actions, and conflict review. -- Windows packaging rules that include the full visual asset set and dynamically - loaded knowledge adapters. +- Cross-platform packaging rules that include the full visual asset set and + dynamically loaded knowledge adapters. - Local inventory for Xbox content roots, USB drives, archive folders, Games on Demand, XBLA, DLC, title updates, and extracted Xbox/Xbox 360 games. - Public STFS and XBE header inspection for TitleID, MediaID, content type, disc, @@ -36,6 +36,8 @@ backup-management, and source-attributed knowledge application. browser origins, validated settings, and current version reporting. - Cross-platform CI, Windows packaging checks, tagged release archives, and SHA-256 release checksums. +- Linux x86_64 packaging, XDG storage, application-menu integration, source + launch scripts, and release artifacts. - Repository contribution, security, architecture, API, and release documentation. @@ -47,8 +49,8 @@ backup-management, and source-attributed knowledge application. end-to-end local workflow. - Network-backed source syncs remain dependent on each source's availability and access policy. Cached copies are used when available. -- Windows executable artifacts are generated by CI and releases rather than - committed to the source tree. +- Windows and Linux executable artifacts are generated by CI and releases + rather than committed to the source tree. ## Deliberate Boundaries diff --git a/README.md b/README.md index 8a1cc90..743948c 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ [![Latest release](https://img.shields.io/github/v/release/TrapEmAll/UnityScraper?include_prereleases)](https://github.com/TrapEmAll/UnityScraper/releases) [![License: GPL-3.0](https://img.shields.io/badge/license-GPL--3.0--only-blue.svg)](LICENSE) -UnityScraper is a Windows-focused Xbox 360 library, knowledge, title-update, +UnityScraper is a cross-platform Xbox 360 library, knowledge, title-update, artwork, verification, and backup manager. It combines a local SQLite library with source-attributed community knowledge and tools for content you already own. @@ -64,12 +64,28 @@ the SHA-256 file, extract the ZIP, and run `UnityScraper.exe`. Release executables are generated by GitHub Actions. Built binaries are not stored in the source tree. +### Linux Release + +Download the Linux x86_64 tarball and checksum from GitHub Releases: + +```bash +sha256sum --check UnityScraper-Linux-x86_64.tar.gz.sha256 +tar -xzf UnityScraper-Linux-x86_64.tar.gz +cd UnityScraper-Linux-x86_64 +./install.sh +``` + +The user-level installer adds UnityScraper to the desktop application menu and +creates `~/.local/bin/unityscraper`. It does not require root access. See +[LINUX.md](LINUX.md) for supported distributions, XDG paths, source setup, +uninstallation, and troubleshooting. + ### Run From Source Requirements: - Python 3.10 or newer -- Tkinter, normally included with the Windows Python installer +- Tkinter Clone the repository and run: @@ -89,6 +105,13 @@ python -m venv .venv The primary interface is `desktop_app.py`. `GUI.py` remains available from **Advanced Tools** for older scraper controls. +Linux source setup: + +```bash +./setup.sh +./run-unityscraper.sh +``` + ## Application Workspaces | Workspace | Purpose | @@ -113,6 +136,15 @@ Normal Windows installations store writable data under: This includes the database, configuration, logs, downloads, exports, source cache, and diagnostics. +Linux follows the XDG Base Directory specification: + +```text +Data: ${XDG_DATA_HOME:-~/.local/share}/unityscraper +Config: ${XDG_CONFIG_HOME:-~/.config}/unityscraper +Cache: ${XDG_CACHE_HOME:-~/.cache}/unityscraper +Logs: ${XDG_STATE_HOME:-~/.local/state}/unityscraper/logs +``` + To enable portable mode, create an empty file named `portable.mode` beside the source entry point or packaged executable before launch. Portable data is written to `UnityScraperData` beside the application. The marker and runtime @@ -223,10 +255,20 @@ powershell -ExecutionPolicy Bypass -File .\build_windows.ps1 The output appears under `dist\` and is ignored by Git. +Build the Linux release bundle on Linux: + +```bash +./build_linux.sh +``` + +The output is `dist/UnityScraper-Linux-.tar.gz` with a matching +SHA-256 file. + ## Documentation - [Documentation index](DOCS_INDEX.md) - [Architecture](ARCHITECTURE.md) +- [Linux support](LINUX.md) - [Knowledge sources and licensing](KNOWLEDGE_SOURCES.md) - [Backup manager](BACKUP_MANAGER.md) - [REST API](API.md) diff --git a/UnityScraper.spec b/UnityScraper.spec index 8b10e5d..2863a23 100644 --- a/UnityScraper.spec +++ b/UnityScraper.spec @@ -1,5 +1,9 @@ # -*- mode: python ; coding: utf-8 -*- +import sys + + +icon = 'assets/UnityScraper.ico' if sys.platform == 'win32' else None a = Analysis( ['desktop_app.py'], @@ -45,5 +49,5 @@ exe = EXE( target_arch=None, codesign_identity=None, entitlements_file=None, - icon=['assets\\UnityScraper.ico'], + icon=icon, ) diff --git a/app_paths.py b/app_paths.py index a102949..e79ccab 100644 --- a/app_paths.py +++ b/app_paths.py @@ -1,21 +1,31 @@ -""" -Application storage paths with optional portable-mode support. - -Portable mode is enabled when either: -1. A file named ``portable.mode`` exists beside the executable/source files, or -2. The environment variable ``UNITYSCRAPER_PORTABLE`` is set to ``1``. - -Normal installations continue to use %LOCALAPPDATA%\\UnityScraper. -""" +"""Cross-platform application storage and bundled-resource paths.""" from __future__ import annotations import os +import posixpath import shutil import sys +from dataclasses import dataclass from pathlib import Path +from typing import Mapping APP_NAME = "UnityScraper" +APP_SLUG = "unityscraper" + + +@dataclass(frozen=True) +class StoragePaths: + """Resolved writable directories for one UnityScraper installation.""" + + base: Path + downloads: Path + logs: Path + config: Path + data: Path + cache: Path + exports: Path + diagnostics: Path def app_root() -> Path: @@ -28,7 +38,7 @@ def app_root() -> Path: def executable_root() -> Path: - """Return the writable directory beside the executable or source checkout.""" + """Return the directory beside the executable or source checkout.""" if getattr(sys, "frozen", False): return Path(sys.executable).resolve().parent return Path(__file__).resolve().parent @@ -41,26 +51,100 @@ def portable_mode_enabled() -> bool: return env_enabled or marker_enabled -def _base_dir() -> Path: - """Resolve the root directory used for mutable application data.""" - if portable_mode_enabled(): - return executable_root() / "UnityScraperData" - - if os.name == "nt": - root = os.environ.get("LOCALAPPDATA") or os.environ.get("APPDATA") - if root: - return Path(root) / APP_NAME +def resolve_storage_paths( + *, + os_name: str | None = None, + platform_name: str | None = None, + environ: Mapping[str, str] | None = None, + home: Path | None = None, + portable_root: Path | None = None, +) -> StoragePaths: + """Resolve platform-native paths without creating them. + + Explicit parameters keep path behavior straightforward to test on any host. + """ + current_os = os_name or os.name + current_platform = platform_name or sys.platform + env = environ if environ is not None else os.environ + user_home = Path(home) if home is not None else Path.home() + + def xdg_path(variable: str, fallback: Path) -> Path: + value = env.get(variable) + if value: + candidate = Path(value).expanduser() + if posixpath.isabs(value): + return candidate + return fallback + + if portable_root is not None: + base = Path(portable_root) / "UnityScraperData" + return StoragePaths( + base=base, + downloads=base / "downloads", + logs=base / "logs", + config=base / "config", + data=base / "data", + cache=base / "cache", + exports=base / "exports", + diagnostics=base / "diagnostics", + ) + + if current_os == "nt": + root = env.get("LOCALAPPDATA") or env.get("APPDATA") + base = Path(root) / APP_NAME if root else user_home / APP_NAME + return StoragePaths( + base=base, + downloads=base / "downloads", + logs=base / "logs", + config=base / "config", + data=base / "data", + cache=base / "cache", + exports=base / "exports", + diagnostics=base / "diagnostics", + ) + + if current_platform == "darwin": + base = user_home / "Library" / "Application Support" / APP_NAME + return StoragePaths( + base=base, + downloads=base / "downloads", + logs=user_home / "Library" / "Logs" / APP_NAME, + config=base / "config", + data=base / "data", + cache=user_home / "Library" / "Caches" / APP_NAME, + exports=base / "exports", + diagnostics=base / "diagnostics", + ) + + data_home = xdg_path("XDG_DATA_HOME", user_home / ".local" / "share") + config_home = xdg_path("XDG_CONFIG_HOME", user_home / ".config") + cache_home = xdg_path("XDG_CACHE_HOME", user_home / ".cache") + state_home = xdg_path("XDG_STATE_HOME", user_home / ".local" / "state") + base = data_home / APP_SLUG + return StoragePaths( + base=base, + downloads=base / "downloads", + logs=state_home / APP_SLUG / "logs", + config=config_home / APP_SLUG, + data=base / "data", + cache=cache_home / APP_SLUG, + exports=base / "exports", + diagnostics=base / "diagnostics", + ) - return Path.home() / ".unityscraper" +_PATHS = resolve_storage_paths( + portable_root=executable_root() if portable_mode_enabled() else None +) -BASE_DIR = _base_dir() -DOWNLOADS_DIR = BASE_DIR / "downloads" -LOG_DIR = BASE_DIR / "logs" -CONFIG_DIR = BASE_DIR / "config" -DATA_DIR = BASE_DIR / "data" -EXPORTS_DIR = BASE_DIR / "exports" -DIAGNOSTICS_DIR = BASE_DIR / "diagnostics" +BASE_DIR = _PATHS.base +DOWNLOADS_DIR = _PATHS.downloads +LOG_DIR = _PATHS.logs +CONFIG_DIR = _PATHS.config +DATA_DIR = _PATHS.data +CACHE_DIR = _PATHS.cache +EXPORTS_DIR = _PATHS.exports +DIAGNOSTICS_DIR = _PATHS.diagnostics DATABASE_PATH = DATA_DIR / "unityscraper.db" CONFIG_PATH = CONFIG_DIR / "config.json" @@ -83,6 +167,7 @@ def ensure_app_dirs() -> None: LOG_DIR, CONFIG_DIR, DATA_DIR, + CACHE_DIR, EXPORTS_DIR, DIAGNOSTICS_DIR, ): @@ -112,5 +197,6 @@ def describe_storage() -> str: f"Downloads: {DOWNLOADS_DIR}\n" f"Exports: {EXPORTS_DIR}\n" f"Config: {CONFIG_DIR}\n" + f"Cache: {CACHE_DIR}\n" f"Logs: {LOG_DIR}" ) diff --git a/backup_service.py b/backup_service.py index 700e1b5..2578e1d 100644 --- a/backup_service.py +++ b/backup_service.py @@ -97,8 +97,11 @@ class BackupRepository: """Additive SQLite storage for targets, scans, inventory, and operations.""" def __init__(self, db_path: str | Path = DATABASE_PATH): - ensure_app_dirs() self.db_path = Path(db_path) + if self.db_path == DATABASE_PATH: + ensure_app_dirs() + else: + self.db_path.parent.mkdir(parents=True, exist_ok=True) self.ensure_schema() @contextmanager diff --git a/build_linux.sh b/build_linux.sh new file mode 100755 index 0000000..02c0e28 --- /dev/null +++ b/build_linux.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env sh +set -eu + +cd "$(dirname "$0")" + +PYTHON="${PYTHON:-python3}" +if [ -x ".venv/bin/python" ]; then + PYTHON=".venv/bin/python" +fi + +"$PYTHON" -m pip install -r requirements.txt pyinstaller +"$PYTHON" -m PyInstaller --clean --noconfirm UnityScraper.spec + +ARCH="$(uname -m)" +STAGE="dist/UnityScraper-Linux-${ARCH}" +ARCHIVE="dist/UnityScraper-Linux-${ARCH}.tar.gz" + +rm -rf "$STAGE" +mkdir -p "$STAGE" +install -m 0755 dist/UnityScraper "$STAGE/unityscraper" +install -m 0755 packaging/linux/install.sh "$STAGE/install.sh" +install -m 0755 packaging/linux/uninstall.sh "$STAGE/uninstall.sh" +install -m 0644 packaging/linux/io.github.trapemall.UnityScraper.desktop \ + "$STAGE/io.github.trapemall.UnityScraper.desktop" +install -m 0644 packaging/linux/io.github.trapemall.UnityScraper.metainfo.xml \ + "$STAGE/io.github.trapemall.UnityScraper.metainfo.xml" +install -m 0644 assets/UnityScraper.png "$STAGE/unityscraper.png" +install -m 0644 README.md CHANGELOG.md LICENSE "$STAGE/" + +tar -C dist -czf "$ARCHIVE" "UnityScraper-Linux-${ARCH}" +sha256sum "$ARCHIVE" > "$ARCHIVE.sha256" + +printf '\nBuild complete: %s\nChecksum: %s.sha256\n' "$ARCHIVE" "$ARCHIVE" diff --git a/desktop_app.py b/desktop_app.py index 11ee253..dc80777 100644 --- a/desktop_app.py +++ b/desktop_app.py @@ -1,11 +1,41 @@ -"""Windows desktop entry point for the library-first UnityScraper interface.""" +"""Cross-platform desktop entry point for UnityScraper.""" + +from __future__ import annotations + +import sys + from app_paths import ensure_app_dirs, ensure_user_titleids_file -from modern_gui import main as gui_main -def main() -> None: + +def main() -> int: + """Initialize writable storage and launch the desktop application.""" ensure_app_dirs() ensure_user_titleids_file() - gui_main() + + try: + import tkinter as tk + from modern_gui import main as gui_main + except ImportError as exc: + print( + "UnityScraper requires Tkinter. On Debian/Ubuntu install python3-tk; " + "on Fedora install python3-tkinter.", + file=sys.stderr, + ) + print(f"Details: {exc}", file=sys.stderr) + return 1 + + try: + gui_main() + except tk.TclError as exc: + print( + "UnityScraper could not connect to a graphical desktop. " + "Start it from an X11 or Wayland session.", + file=sys.stderr, + ) + print(f"Details: {exc}", file=sys.stderr) + return 1 + return 0 + if __name__ == "__main__": - main() + raise SystemExit(main()) diff --git a/knowledge_sources.py b/knowledge_sources.py index 4eddf47..e646ad0 100644 --- a/knowledge_sources.py +++ b/knowledge_sources.py @@ -12,7 +12,7 @@ import requests -from app_paths import DATA_DIR, ensure_app_dirs +from app_paths import CACHE_DIR, ensure_app_dirs from knowledge_base import EntityRecord, KnowledgeRepository, utc_now logger = logging.getLogger(__name__) @@ -75,8 +75,11 @@ def __init__( timeout: int = 30, session: requests.Session | None = None, ) -> None: - ensure_app_dirs() - self.cache_dir = Path(cache_dir) if cache_dir else DATA_DIR / "source_cache" + if cache_dir is None: + ensure_app_dirs() + self.cache_dir = CACHE_DIR / "source_documents" + else: + self.cache_dir = Path(cache_dir) self.cache_dir.mkdir(parents=True, exist_ok=True) self.rate_limit_seconds = rate_limit_seconds self.timeout = timeout diff --git a/modern_gui.py b/modern_gui.py index a256b98..1252b70 100644 --- a/modern_gui.py +++ b/modern_gui.py @@ -8,9 +8,6 @@ from __future__ import annotations import json -import os -import subprocess -import sys import tkinter as tk import webbrowser from pathlib import Path @@ -40,6 +37,7 @@ from knowledge_service import KnowledgeService from knowledge_gui import KnowledgePage from library_service import GameSummary, LibraryService +from platform_support import desktop_font_family, open_path from setup_wizard import run_first_run_wizard @@ -55,18 +53,15 @@ MUTED = "#a5b2a8" DANGER = "#ff5d68" WARNING = "#ffc857" +UI_FONT = desktop_font_family() def _open_path(path: Path) -> None: """Open a file or folder using the current operating system.""" - path = path.resolve() - - if os.name == "nt": - os.startfile(str(path)) # type: ignore[attr-defined] - elif sys.platform == "darwin": - subprocess.Popen(["open", str(path)]) - else: - subprocess.Popen(["xdg-open", str(path)]) + try: + open_path(path) + except (OSError, RuntimeError) as exc: + messagebox.showerror("Unable to open", str(exc)) @@ -136,7 +131,7 @@ def _redraw(self) -> None: text=f"Background not loaded: {self.image_path}", anchor=tk.CENTER, fill="#d6e6d3", - font=("Segoe UI", 10), + font=(UI_FONT, 10), ) self.canvas.create_rectangle( @@ -148,7 +143,7 @@ def _redraw(self) -> None: text=self.title, anchor=tk.W, fill="#ffffff", - font=("Segoe UI", 23, "bold"), + font=(UI_FONT, 23, "bold"), ) self.canvas.create_text( 30, @@ -157,7 +152,7 @@ def _redraw(self) -> None: anchor=tk.W, fill="#d6e6d3", width=max(width - 60, 300), - font=("Segoe UI", 11), + font=(UI_FONT, 11), ) @staticmethod @@ -222,23 +217,23 @@ def _configure_style(self) -> None: style.configure(".", background=PANEL, foreground=TEXT, fieldbackground=PANEL_ALT, bordercolor=BORDER, darkcolor=PANEL, lightcolor=PANEL, troughcolor=BG, selectbackground="#315f12", selectforeground=TEXT, - font=("Segoe UI", 10)) + font=(UI_FONT, 10)) style.configure("TFrame", background=PANEL) style.configure("Sidebar.TFrame", background="#060a07") style.configure("Content.TFrame", background=PANEL) style.configure("TLabel", background=PANEL, foreground=TEXT) style.configure("Brand.TLabel", background="#060a07", foreground=TEXT, - font=("Segoe UI", 17, "bold")) + font=(UI_FONT, 17, "bold")) style.configure("AccentBrand.TLabel", background="#060a07", foreground=ACCENT, - font=("Segoe UI", 10)) + font=(UI_FONT, 10)) style.configure("Header.TLabel", background=PANEL, foreground=TEXT, - font=("Segoe UI", 24, "bold")) + font=(UI_FONT, 24, "bold")) style.configure("Subheader.TLabel", background=PANEL, foreground=MUTED, - font=("Segoe UI", 11)) + font=(UI_FONT, 11)) style.configure("Metric.TLabel", background=PANEL_ALT, foreground=ACCENT, - font=("Segoe UI", 20, "bold")) + font=(UI_FONT, 20, "bold")) style.configure("CardTitle.TLabel", background=PANEL, foreground=ACCENT, - font=("Segoe UI", 11, "bold")) + font=(UI_FONT, 11, "bold")) style.configure("StatusDownloaded.TLabel", background=PANEL, foreground=ACCENT) style.configure("StatusFailed.TLabel", background=PANEL, foreground=DANGER) style.configure("StatusPending.TLabel", background=PANEL, foreground=WARNING) @@ -259,7 +254,7 @@ def _configure_style(self) -> None: style.configure("TLabelframe", background=PANEL, foreground=ACCENT, bordercolor=BORDER, relief="solid", borderwidth=1) style.configure("TLabelframe.Label", background=PANEL, foreground=ACCENT, - font=("Segoe UI", 10, "bold")) + font=(UI_FONT, 10, "bold")) style.configure("TEntry", fieldbackground="#070b08", foreground=TEXT, insertcolor=ACCENT, bordercolor=BORDER, padding=7) style.configure("TSpinbox", fieldbackground="#070b08", foreground=TEXT, @@ -269,7 +264,7 @@ def _configure_style(self) -> None: style.map("Treeview", background=[("selected", "#23480f")], foreground=[("selected", TEXT)]) style.configure("Treeview.Heading", background="#101912", foreground=TEXT, - bordercolor=BORDER, relief="flat", font=("Segoe UI", 9, "bold")) + bordercolor=BORDER, relief="flat", font=(UI_FONT, 9, "bold")) style.map("Treeview.Heading", background=[("active", "#18271b")], foreground=[("active", ACCENT)]) style.configure("TNotebook", background=PANEL, bordercolor=BORDER) @@ -1000,7 +995,7 @@ def _open_legacy_gui(self) -> None: def main() -> None: - root = tk.Tk() + root = tk.Tk(className="UnityScraper") UnityScraperDesktop(root) root.mainloop() diff --git a/packaging/linux/install.sh b/packaging/linux/install.sh new file mode 100755 index 0000000..78acc47 --- /dev/null +++ b/packaging/linux/install.sh @@ -0,0 +1,45 @@ +#!/usr/bin/env sh +set -eu + +PACKAGE_DIR="$(CDPATH= cd -- "$(dirname "$0")" && pwd)" +APP_DIR="${HOME}/.local/lib/unityscraper" +BIN_DIR="${HOME}/.local/bin" +DATA_HOME="${XDG_DATA_HOME:-${HOME}/.local/share}" +APPLICATIONS_DIR="${DATA_HOME}/applications" +ICON_DIR="${DATA_HOME}/icons/hicolor/1024x1024/apps" +METAINFO_DIR="${DATA_HOME}/metainfo" + +mkdir -p "$APP_DIR" "$BIN_DIR" "$APPLICATIONS_DIR" "$ICON_DIR" "$METAINFO_DIR" + +install -m 0755 "$PACKAGE_DIR/unityscraper" "$APP_DIR/unityscraper" +install -m 0755 "$PACKAGE_DIR/uninstall.sh" "$APP_DIR/uninstall.sh" +install -m 0644 "$PACKAGE_DIR/README.md" "$PACKAGE_DIR/CHANGELOG.md" \ + "$PACKAGE_DIR/LICENSE" "$APP_DIR/" +install -m 0644 "$PACKAGE_DIR/unityscraper.png" \ + "$ICON_DIR/io.github.trapemall.UnityScraper.png" +install -m 0644 "$PACKAGE_DIR/io.github.trapemall.UnityScraper.metainfo.xml" \ + "$METAINFO_DIR/io.github.trapemall.UnityScraper.metainfo.xml" +ln -sfn "$APP_DIR/unityscraper" "$BIN_DIR/unityscraper" + +sed \ + -e "s|@EXEC@|$APP_DIR/unityscraper|g" \ + -e "s|@ICON@|$ICON_DIR/io.github.trapemall.UnityScraper.png|g" \ + "$PACKAGE_DIR/io.github.trapemall.UnityScraper.desktop" \ + > "$APPLICATIONS_DIR/io.github.trapemall.UnityScraper.desktop" +chmod 0644 "$APPLICATIONS_DIR/io.github.trapemall.UnityScraper.desktop" + +if command -v update-desktop-database >/dev/null 2>&1; then + update-desktop-database "$APPLICATIONS_DIR" >/dev/null 2>&1 || true +fi + +cat < + + io.github.trapemall.UnityScraper + UnityScraper + Manage Xbox 360 libraries, metadata, backups, and preservation records + UnityScraper contributors + CC0-1.0 + GPL-3.0-only + +

+ UnityScraper is an Xbox 360 library, knowledge, title-update, artwork, + verification, and backup manager. +

+
+ io.github.trapemall.UnityScraper + + Utility + Game + + io.github.trapemall.UnityScraper.desktop + https://github.com/TrapEmAll/UnityScraper + https://github.com/TrapEmAll/UnityScraper/issues + + + + +
diff --git a/packaging/linux/uninstall.sh b/packaging/linux/uninstall.sh new file mode 100755 index 0000000..3982979 --- /dev/null +++ b/packaging/linux/uninstall.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env sh +set -eu + +APP_DIR="${HOME}/.local/lib/unityscraper" +BIN_DIR="${HOME}/.local/bin" +DATA_HOME="${XDG_DATA_HOME:-${HOME}/.local/share}" + +rm -f "$BIN_DIR/unityscraper" +rm -f "$DATA_HOME/applications/io.github.trapemall.UnityScraper.desktop" +rm -f "$DATA_HOME/icons/hicolor/1024x1024/apps/io.github.trapemall.UnityScraper.png" +rm -f "$DATA_HOME/metainfo/io.github.trapemall.UnityScraper.metainfo.xml" +rm -rf "$APP_DIR" + +if command -v update-desktop-database >/dev/null 2>&1; then + update-desktop-database "$DATA_HOME/applications" >/dev/null 2>&1 || true +fi + +cat <<'EOF' +UnityScraper was uninstalled. +Your library, configuration, downloads, and logs were left intact. +See LINUX.md for their XDG locations and optional manual cleanup. +EOF diff --git a/platform_support.py b/platform_support.py new file mode 100644 index 0000000..88de9b1 --- /dev/null +++ b/platform_support.py @@ -0,0 +1,56 @@ +"""Small operating-system integration helpers.""" + +from __future__ import annotations + +import os +import shutil +import subprocess +import sys +from pathlib import Path + + +def desktop_font_family() -> str: + """Return a sensible UI font family for the current desktop.""" + if os.name == "nt": + return "Segoe UI" + if sys.platform == "darwin": + return "Helvetica Neue" + return "DejaVu Sans" + + +def path_opener_command( + *, + os_name: str | None = None, + platform_name: str | None = None, +) -> list[str] | None: + """Return the available command used to open files on this platform.""" + current_os = os_name or os.name + current_platform = platform_name or sys.platform + if current_os == "nt": + return None + if current_platform == "darwin": + return ["open"] + for candidate in ("xdg-open", "gio"): + if shutil.which(candidate): + return [candidate, "open"] if candidate == "gio" else [candidate] + return None + + +def open_path(path: Path | str) -> None: + """Open a file or directory with the desktop's default application.""" + target = Path(path).expanduser().resolve() + if not target.exists(): + raise FileNotFoundError(target) + + if os.name == "nt": + os.startfile(str(target)) # type: ignore[attr-defined] + return + + command = path_opener_command() + if command is None: + raise RuntimeError("No desktop file opener was found (install xdg-utils or GLib).") + subprocess.Popen( + [*command, str(target)], + close_fds=True, + start_new_session=True, + ) diff --git a/run-unityscraper.sh b/run-unityscraper.sh new file mode 100755 index 0000000..9c90d38 --- /dev/null +++ b/run-unityscraper.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env sh +set -eu + +cd "$(dirname "$0")" + +if [ -x ".venv/bin/python" ]; then + exec .venv/bin/python desktop_app.py "$@" +fi + +exec python3 desktop_app.py "$@" diff --git a/scripts/check_version.py b/scripts/check_version.py index 5c9dacc..16f44b1 100644 --- a/scripts/check_version.py +++ b/scripts/check_version.py @@ -5,6 +5,7 @@ import argparse import json import re +import xml.etree.ElementTree as element_tree from pathlib import Path from packaging.version import Version @@ -28,10 +29,18 @@ def read_versions() -> dict[str, str]: if not project_match: raise RuntimeError("project.version was not found in pyproject.toml") + metainfo = element_tree.parse( + ROOT / "packaging/linux/io.github.trapemall.UnityScraper.metainfo.xml" + ).getroot() + release = metainfo.find("./releases/release") + if release is None or not release.get("version"): + raise RuntimeError("A release version was not found in Linux AppStream metadata") + return { "app_version.py": app_match.group(1), "VERSION": str(version_data["version"]), "pyproject.toml": project_match.group(1), + "Linux AppStream metadata": str(release.get("version")), } diff --git a/setup.sh b/setup.sh new file mode 100755 index 0000000..553ca4a --- /dev/null +++ b/setup.sh @@ -0,0 +1,42 @@ +#!/usr/bin/env sh +set -eu + +cd "$(dirname "$0")" + +PYTHON="${PYTHON:-python3}" + +if ! command -v "$PYTHON" >/dev/null 2>&1; then + echo "Python 3.10 or newer is required." >&2 + exit 1 +fi + +if ! "$PYTHON" -c 'import sys; raise SystemExit(sys.version_info < (3, 10))'; then + echo "Python 3.10 or newer is required." >&2 + exit 1 +fi + +if ! "$PYTHON" -c 'import tkinter' >/dev/null 2>&1; then + cat >&2 <<'EOF' +Tkinter is required for the desktop interface. + +Debian/Ubuntu: sudo apt install python3-tk +Fedora: sudo dnf install python3-tkinter +Arch Linux: sudo pacman -S tk +openSUSE: sudo zypper install python311-tk +EOF + exit 1 +fi + +if [ ! -x ".venv/bin/python" ]; then + "$PYTHON" -m venv .venv +fi + +.venv/bin/python -m pip install --upgrade pip +.venv/bin/python -m pip install -r requirements.txt + +cat <<'EOF' + +UnityScraper is ready. +Launch it with: + ./run-unityscraper.sh +EOF diff --git a/setup_wizard.py b/setup_wizard.py index c898ac7..2d5f690 100644 --- a/setup_wizard.py +++ b/setup_wizard.py @@ -15,6 +15,10 @@ ensure_app_dirs, ensure_user_titleids_file, ) +from platform_support import desktop_font_family + + +UI_FONT = desktop_font_family() class SetupWizard(tk.Toplevel): @@ -45,7 +49,7 @@ def _build(self) -> None: ttk.Label( container, text="Set up your Xbox 360 archive", - font=("Segoe UI", 18, "bold"), + font=(UI_FONT, 18, "bold"), ).pack(anchor=tk.W) ttk.Label( diff --git a/tests.py b/tests.py index 869b16b..bec300a 100644 --- a/tests.py +++ b/tests.py @@ -43,6 +43,94 @@ from backup_service import BackupRepository from api import UnityScraperAPI from app_version import DISPLAY_VERSION +from app_paths import resolve_storage_paths +from platform_support import desktop_font_family, path_opener_command + + +class TestPlatformSupport(unittest.TestCase): + """Test cross-platform storage and desktop integration.""" + + def test_linux_uses_xdg_directories(self): + home = Path("/home/tester") + paths = resolve_storage_paths( + os_name="posix", + platform_name="linux", + environ={ + "XDG_DATA_HOME": "/xdg/data", + "XDG_CONFIG_HOME": "/xdg/config", + "XDG_CACHE_HOME": "/xdg/cache", + "XDG_STATE_HOME": "/xdg/state", + }, + home=home, + ) + + self.assertEqual(paths.base, Path("/xdg/data/unityscraper")) + self.assertEqual(paths.config, Path("/xdg/config/unityscraper")) + self.assertEqual(paths.cache, Path("/xdg/cache/unityscraper")) + self.assertEqual(paths.logs, Path("/xdg/state/unityscraper/logs")) + + def test_linux_xdg_defaults_follow_home(self): + home = Path("/home/tester") + paths = resolve_storage_paths( + os_name="posix", + platform_name="linux", + environ={}, + home=home, + ) + + self.assertEqual(paths.base, home / ".local/share/unityscraper") + self.assertEqual(paths.config, home / ".config/unityscraper") + self.assertEqual(paths.cache, home / ".cache/unityscraper") + self.assertEqual(paths.logs, home / ".local/state/unityscraper/logs") + + def test_linux_ignores_relative_xdg_values(self): + home = Path("/home/tester") + paths = resolve_storage_paths( + os_name="posix", + platform_name="linux", + environ={"XDG_CONFIG_HOME": "relative/config"}, + home=home, + ) + + self.assertEqual(paths.config, home / ".config/unityscraper") + + def test_portable_mode_keeps_everything_together(self): + paths = resolve_storage_paths(portable_root=Path("/opt/unityscraper")) + + self.assertEqual(paths.base, Path("/opt/unityscraper/UnityScraperData")) + self.assertEqual(paths.config, paths.base / "config") + self.assertEqual(paths.cache, paths.base / "cache") + + def test_platform_openers(self): + self.assertIsNone(path_opener_command(os_name="nt", platform_name="win32")) + self.assertEqual( + path_opener_command(os_name="posix", platform_name="darwin"), + ["open"], + ) + with patch("platform_support.shutil.which") as which: + which.side_effect = lambda command: "/usr/bin/gio" if command == "gio" else None + self.assertEqual( + path_opener_command(os_name="posix", platform_name="linux"), + ["gio", "open"], + ) + + def test_desktop_font_is_defined(self): + self.assertTrue(desktop_font_family()) + + def test_linux_desktop_metadata_is_complete(self): + desktop = Path("packaging/linux/io.github.trapemall.UnityScraper.desktop") + metadata = Path("packaging/linux/io.github.trapemall.UnityScraper.metainfo.xml") + self.assertIn("Type=Application", desktop.read_text(encoding="utf-8")) + self.assertIn("@EXEC@", desktop.read_text(encoding="utf-8")) + + import xml.etree.ElementTree as element_tree + + root = element_tree.parse(metadata).getroot() + self.assertEqual(root.attrib["type"], "desktop-application") + self.assertEqual( + root.findtext("id"), + "io.github.trapemall.UnityScraper", + ) class TestConfig(unittest.TestCase): @@ -924,11 +1012,12 @@ def test_config_validates_and_applies_allowlisted_values(self): def run_tests(): """Run all tests""" # Create test suite - loader = unittest.TestLoader() - suite = unittest.TestSuite() - - # Add all test classes - suite.addTests(loader.loadTestsFromTestCase(TestConfig)) + loader = unittest.TestLoader() + suite = unittest.TestSuite() + + # Add all test classes + suite.addTests(loader.loadTestsFromTestCase(TestPlatformSupport)) + suite.addTests(loader.loadTestsFromTestCase(TestConfig)) suite.addTests(loader.loadTestsFromTestCase(TestRateLimiter)) suite.addTests(loader.loadTestsFromTestCase(TestUnityScraper)) suite.addTests(loader.loadTestsFromTestCase(TestDatabaseManager))