diff --git a/.env.example b/.env.example index 2fd456d6..731a9ffd 100644 --- a/.env.example +++ b/.env.example @@ -1,8 +1,43 @@ # Put real secrets in ~/.env only (NOT in this repo). # Copy nothing from here into tracked files. +# +# load_dotenv() drops any key that is not in env_config.ENV_KEYS, so a key set +# here but absent from that allowlist is silently ignored. Every credential key +# in this file is in that allowlist and is genuinely loaded. The process-only +# variables are in their own marked section: they are NOT in the allowlist, so +# they are read straight from the environment and must be exported instead. # GitHub personal access token (optional, used for release API rate limits) # GITHUB_TOKEN= +# Aliases accepted: GH_TOKEN, OPENBOX_GITHUB_TOKEN +# GH_TOKEN= +# OPENBOX_GITHUB_TOKEN= + +# --------------------------------------------------------------------- +# Process-only variables: read from the environment, NOT from a .env file. +# load_dotenv() drops them, so set them by exporting before launch: +# export OPENBOX_SAFE_MODE=1 +# --------------------------------------------------------------------- + +# Data directory override (read at import time, so export before launch) +# OPENBOX_DATA_DIR= + +# Explicit env file path, checked before the data-directory roots +# OPENBOX_ENV_FILE= + +# SQLite read model (optional; self-enables at 5000+ games, explicit 0 opts out) +# OPENBOX_ENABLE_SQLITE_READ= + +# Unsandboxed plugins (disables the bubblewrap sandbox entirely, even when available) +# OPENBOX_ALLOW_UNSANDBOXED_PLUGINS= + +# Safe mode: disables plugin execution process-wide (library and +# library_source hooks, before_launch/after_session, events) and the webhook +# dispatcher, and reports safe_mode in the capabilities response +# OPENBOX_SAFE_MODE= + +# Steam shortcut launcher override (executable used for Steam shortcut plans) +# OPENBOX_EXECUTABLE= # Stripe secret key (optional, maintainers only; for scripts/create_stripe_donate_link.sh) # Never commit real values. Create payment links locally; only the public donate URL goes in README. @@ -15,11 +50,41 @@ # RetroAchievements (optional; enables RA matching and progress without the UI form) # RETROACHIEVEMENTS_USERNAME= # RETROACHIEVEMENTS_API_KEY= +# Aliases accepted: RA_USERNAME, OPENBOX_RA_USERNAME, RA_API_KEY, OPENBOX_RA_API_KEY, RETROACHIEVEMENTS_KEY +# RA_USERNAME= +# OPENBOX_RA_USERNAME= +# RA_API_KEY= +# OPENBOX_RA_API_KEY= +# RETROACHIEVEMENTS_KEY= + +# SteamGridDB artwork provider (optional; enables artwork search, apply, and bulk matching) +# STEAMGRIDDB_API_KEY= +# Alias accepted: OPENBOX_STEAMGRIDDB_API_KEY +# OPENBOX_STEAMGRIDDB_API_KEY= # EmuMovies (optional; licensed account required for media downloads) # EMUMOVIES_USERNAME= # EMUMOVIES_PASSWORD= +# Aliases accepted: OPENBOX_EMUMOVIES_USERNAME, OPENBOX_EMUMOVIES_PASSWORD +# OPENBOX_EMUMOVIES_USERNAME= +# OPENBOX_EMUMOVIES_PASSWORD= +# ScreenScraper: your account login plus a developer ID and password. The API refuses +# lookups without the developer pair, so both pairs are required (request a developer ID +# from ScreenScraper). +# SCREENSCRAPER_USER= +# SCREENSCRAPER_PASSWORD= +# SCREENSCRAPER_DEV_ID= +# SCREENSCRAPER_DEV_PASSWORD= + +# Aliases accepted: OPENBOX_SCREENSCRAPER_USER, OPENBOX_SCREENSCRAPER_PASSWORD, OPENBOX_SCREENSCRAPER_DEV_ID, OPENBOX_SCREENSCRAPER_DEV_PASSWORD +# OPENBOX_SCREENSCRAPER_USER= +# OPENBOX_SCREENSCRAPER_PASSWORD= +# OPENBOX_SCREENSCRAPER_DEV_ID= +# OPENBOX_SCREENSCRAPER_DEV_PASSWORD= # Webhooks reject plain HTTP by default. Set this only for trusted local test targets. -# OPENBOX_ALLOW_HTTP_WEBHOOKS=1 \ No newline at end of file +# OPENBOX_ALLOW_HTTP_WEBHOOKS=1 + +# Gameyfin plain HTTP is blocked by default. Set this only for trusted local servers. +# OPENBOX_ALLOW_HTTP_GAMEYFIN=1 diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 00000000..f6a51423 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1 @@ +buy_me_a_coffee: haydenopenbox diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 96ccf140..eed41cb8 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -7,17 +7,26 @@ body: - type: markdown attributes: value: | - Thanks for taking the time to report an issue. Please search [existing issues](https://github.com/vindeckyy/OpenBoxGL/issues) first. The current release is [v1.2.0](https://github.com/vindeckyy/OpenBoxGL/releases/latest). + Thanks for taking the time to report an issue. Please search [existing issues](https://github.com/vindeckyy/OpenBoxGL/issues) first. The current release is [v2.0.0](https://github.com/vindeckyy/OpenBoxGL/releases/latest). - type: input id: version attributes: label: OpenBox version description: Release tag, AppImage version, or git commit hash - placeholder: v1.2.0 + placeholder: v2.0.0 validations: required: true + - type: input + id: library_format + attributes: + label: Library format + description: The library format and path mode shown in Settings > About, for example "7, portable". Leave it blank if the library does not open at all. + placeholder: "7, portable" + validations: + required: false + - type: dropdown id: interface attributes: @@ -25,9 +34,19 @@ body: options: - Web UI - Native window + validations: + required: true + + - type: dropdown + id: install + attributes: + label: Install method + options: - AppImage - Flatpak - Source install + - Windows portable install + - macOS app bundle validations: required: true @@ -35,8 +54,8 @@ body: id: environment attributes: label: Environment - description: Distro, desktop environment, and Python version if running from source - placeholder: Fedora 42, KDE Plasma, Python 3.12 + description: Operating system and version (Linux distro and desktop, Windows build, or macOS version), and Python version if running from source + placeholder: Fedora 41, KDE Plasma, Python 3.12; or macOS 15 on Apple Silicon; or Windows 11 x86_64 validations: required: true @@ -70,7 +89,7 @@ body: id: diagnostic attributes: label: Diagnostic report - description: Paste the output of Settings -> Library Audit -> the diagnostic log, or the "Copy details" output from an error banner. It is redacted automatically. + description: Paste the output of Settings -> Advanced -> Copy diagnostic log, or the "Copy details" output from an error banner. It is redacted automatically. validations: required: false diff --git a/.github/dependabot.yml b/.github/dependabot.yml index c6a22bbd..5b8b064b 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,17 +1,27 @@ version: 2 updates: - - package-ecosystem: "github-actions" - directory: "/" + - package-ecosystem: pip + directory: / schedule: - interval: "weekly" - open-pull-requests-limit: 5 - - package-ecosystem: "npm" - directory: "/scripts" + interval: weekly + target-branch: master + labels: + - dependencies + - package-ecosystem: npm + directory: /scripts schedule: - interval: "weekly" - open-pull-requests-limit: 5 - - package-ecosystem: "pip" - directory: "/" + interval: weekly + target-branch: master + labels: + - dependencies + - package-ecosystem: github-actions + directory: / schedule: - interval: "weekly" - open-pull-requests-limit: 5 + interval: weekly + target-branch: master + labels: + - dependencies + groups: + github-actions: + patterns: + - "*" diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index b858dea1..8d44d55e 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -22,7 +22,7 @@ Describe how you verified the change. ## Checklist -- [ ] I read [CONTRIBUTING.md](../CONTRIBUTING.md) +- [ ] I read [CONTRIBUTING.md](../docs/CONTRIBUTING.md) - [ ] I updated documentation when user-facing behavior changed - [ ] I added or updated tests when behavior changed - [ ] I did not commit secrets, tokens, ROMs, or personal library data diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 06581ffc..d7da13d8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,37 +26,233 @@ jobs: steps: - name: Checkout - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # actions/checkout@v4 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # actions/checkout@v7.0.1 with: persist-credentials: false - name: Set up Python - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # actions/setup-python@v5 + uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # actions/setup-python@v7.1.0 with: python-version: ${{ matrix.python-version }} - name: Run test suite run: ./run_all_tests.sh + windows: + runs-on: windows-latest + timeout-minutes: 40 + + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # actions/checkout@v7.0.1 + with: + persist-credentials: false + # The feature-regression ratchets compare each contract against a + # reference commit that predates the change. A depth-1 checkout has + # no such commit, so layer 2 would silently degrade to layer 1. + fetch-depth: 0 + + - name: Set up Python + uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # actions/setup-python@v7.1.0 + with: + python-version: "3.12" + + # The suite is standalone scripts; the runner reports per-file + # pass/fail/timeout and exits non-zero on any failure (ADR 0025). + - name: Run test suite + run: python -B scripts/run_windows_tests.py + + # native_host_win.c is C and Windows-only, so this is the only automated + # proof that it still compiles against the WebView2 SDK (ADR 0048). + - name: Build the WebView2 native host + shell: pwsh + run: | + scripts/build_native_host_windows.ps1 + # Surface the exact host/SDK versions in the job log so a red build + # is triageable without downloading artifacts. Single source of + # truth: the #defines at the top of native_host_win.c. + $hostVersion = (Select-String -Path native_host_win.c -Pattern '^#define NATIVE_HOST_VERSION "([^"]+)"').Matches[0].Groups[1].Value + $sdkVersion = (Select-String -Path native_host_win.c -Pattern '^#define WEBVIEW2_SDK_VERSION "([^"]+)"').Matches[0].Groups[1].Value + echo "native_host version: $hostVersion" + echo "WebView2 SDK cache key: microsoft.web.webview2/$sdkVersion" + + - name: Runtime module manifest + run: python -B scripts/check_runtime_modules.py + + - name: v1 route contract + run: python -B scripts/check_v1_contract.py + + - name: Version sync + run: python -B scripts/check_version_sync.py + + - name: i18n keys + run: python -B scripts/check_i18n.py + + - name: CSP framing contract + run: python -B scripts/check_csp.py + + - name: Design tokens + run: python -B scripts/check_tokens.py + + # The AppImage, Flatpak and Windows zip all promise no install step. A + # guarded third-party import makes the host -- not the release -- decide + # behaviour, and a `try: import X except ImportError` makes that branch + # untestable in CI. Runs on both runners: it is a source invariant. + - name: Runtime dependencies are stdlib-only + run: python -B scripts/check_dependencies.py + + # Feature-regression ratchets (ADR 0060). Pure-Python and platform + # independent, so the Windows job is the proof they behave off Linux too. + - name: HTTP route surface ratchet + run: python -B scripts/check_routes_contract.py + + - name: Settings schema ratchet + run: python -B scripts/check_settings_contract.py + + - name: Emulator definition contract + run: python -B scripts/check_emulator_defs.py + + - name: Frontend module graph + run: python -B scripts/check_frontend_modules.py + + - name: Reliability catalog + run: python -B scripts/check_reliability_catalog.py + + - name: Clock-coupled tests + run: python -B scripts/check_clock_coupling.py + + # ruff EXE001 cannot run on Windows, so this is where a lost exec bit + # gets caught before the Linux Lint step turns red. Reads the git index, + # which records the mode on every platform. + - name: Exec bit on shebang files + run: python -B scripts/check_exec_modes.py + + macos: + name: macOS (${{ matrix.runner }}, Python ${{ matrix.python-version }}) + runs-on: ${{ matrix.runner }} + timeout-minutes: 45 + strategy: + fail-fast: false + matrix: + runner: [macos-15, macos-15-intel] + python-version: ["3.10", "3.12"] + + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # actions/checkout@v7.0.1 + with: + persist-credentials: false + # The feature-regression ratchets compare each contract against a + # reference commit that predates the change. A depth-1 checkout has + # no such commit, so layer 2 would silently degrade to layer 1. + fetch-depth: 0 + + - name: Set up Python + uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # actions/setup-python@v7.1.0 + with: + python-version: ${{ matrix.python-version }} + + # The suite is standalone scripts. The runner's system python3 is + # older than the 3.10 floor, so the setup-python interpreter is put + # first on the PATH. + - name: Run test suite + run: | + mkdir -p "$RUNNER_TEMP/bin" + ln -sf "$(command -v python)" "$RUNNER_TEMP/bin/python3" + PATH="$RUNNER_TEMP/bin:$PATH" ./run_all_tests.sh + + - name: Runtime module manifest + run: python -B scripts/check_runtime_modules.py + + - name: v1 route contract + run: python -B scripts/check_v1_contract.py + + - name: Version sync + run: python -B scripts/check_version_sync.py + + - name: i18n keys + run: python -B scripts/check_i18n.py + + - name: CSP framing contract + run: python -B scripts/check_csp.py + + - name: Design tokens + run: python -B scripts/check_tokens.py + + - name: Runtime dependencies are stdlib-only + run: python -B scripts/check_dependencies.py + + - name: HTTP route surface ratchet + run: python -B scripts/check_routes_contract.py + + - name: Settings schema ratchet + run: python -B scripts/check_settings_contract.py + + - name: Emulator definition contract + run: python -B scripts/check_emulator_defs.py + + - name: Frontend module graph + run: python -B scripts/check_frontend_modules.py + + - name: Reliability catalog + run: python -B scripts/check_reliability_catalog.py + + - name: Clock-coupled tests + run: python -B scripts/check_clock_coupling.py + + - name: Exec bit on shebang files + run: python -B scripts/check_exec_modes.py + + # native_host_mac.m is Objective-C and macOS-only, so this is the only + # automated proof that it still compiles (ADR 0085). + - name: Build the macOS native host + run: scripts/build_native_host_macos.sh + + - name: Native host self test + run: OPENBOX_PYTHON="$(command -v python)" ./native_host --selftest + + # The bundle build merges the two interpreter builds, signs the + # result, and runs the smoke test. It is the slow job, so it runs + # once. + - name: Build the app bundle + if: matrix.runner == 'macos-15' && matrix.python-version == '3.12' + run: scripts/build_macos_app.sh "$RUNNER_TEMP/OpenBox-macos.zip" + gate: - runs-on: ubuntu-latest - timeout-minutes: 25 + strategy: + fail-fast: false + matrix: + include: + # x86_64 on the standard runner; aarch64 on GitHub's arm image + # (ubuntu-22.04-arm deprecates 2026-09-17, so use 24.04-arm). ADR 0024. + - arch: x86_64 + runner: ubuntu-latest + - arch: aarch64 + runner: ubuntu-24.04-arm + runs-on: ${{ matrix.runner }} + # The coverage pass over the suite alone needs ~26 minutes on a shared + # runner; the 25-minute budget was timing the job out, not the code. + timeout-minutes: 45 steps: - name: Checkout - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # actions/checkout@v4 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # actions/checkout@v7.0.1 with: persist-credentials: false + # The feature-regression ratchets compare each contract against a + # reference commit that predates the change. A depth-1 checkout has + # no such commit, so layer 2 would silently degrade to layer 1. + fetch-depth: 0 - name: Set up Python - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # actions/setup-python@v5 + uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # actions/setup-python@v7.1.0 with: python-version: "3.12" - name: Set up Node.js - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # actions/setup-node@v4.4.0 + uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # actions/setup-node@v7.1.0 with: - node-version: "22.12.0" + node-version: "24" cache: npm cache-dependency-path: scripts/package-lock.json @@ -68,24 +264,53 @@ jobs: - name: Lint run: .venv-dev/bin/ruff check . - - name: JS lint + - name: JS lint and typecheck env: PUPPETEER_SKIP_DOWNLOAD: "true" run: | - # ESLint only, no Chromium is needed for this step. The lockfile - # install skips lifecycle scripts, and this variable documents that - # no browser download is expected in the gate job. npm ci --ignore-scripts --prefix scripts - # Flat config's base path is the cwd, so lint static/ from the root - # (npx eslint ../static/ from scripts/ would silently ignore files). - ./scripts/node_modules/.bin/eslint --config scripts/eslint.config.mjs static/ + python3 -B scripts/check_frontend.py + # `npm audit` reports a registry outage and a real advisory with the same + # non-zero exit code. Only the endpoint error is retried - a maintenance + # window on registry.npmjs.org must not turn into a red gate that hides + # whether the rest of the gate passed - and the loop still fails closed + # when the service never answers. - name: Audit JavaScript dependencies - run: npm audit --prefix scripts --audit-level=high + run: | + for attempt in 1 2 3 4 5; do + if output=$(npm audit --prefix scripts --audit-level=high 2>&1); then + printf '%s\n' "$output" + exit 0 + fi + printf '%s\n' "$output" + case "$output" in + *"audit endpoint returned an error"*|*"503 Service Unavailable"*) ;; + *) exit 1 ;; + esac + if [ "$attempt" = 5 ]; then break; fi + echo "npm audit endpoint unavailable (attempt $attempt); retrying" + sleep $((attempt * 30)) + done + echo "npm audit endpoint still unavailable" >&2 + exit 1 - name: Version sync run: python3 scripts/check_version_sync.py + # Feature-regression ratchets (ADR 0060). Also covered by the full gate + # below; run standalone here so a regression names itself in the log + # instead of only appearing as a non-zero exit from check_tests.py. + - name: Feature-regression ratchets + run: | + for gate in check_routes_contract check_settings_contract \ + check_emulator_defs check_frontend_modules \ + check_reliability_catalog check_clock_coupling; do + echo "::group::$gate" + python3 -B "scripts/$gate.py" || exit 1 + echo "::endgroup::" + done + - name: Compile runtime modules run: | while IFS= read -r module; do @@ -94,17 +319,22 @@ jobs: *.py) python3 -m py_compile "$module" ;; esac done < runtime_modules.txt - python3 -m py_compile test_*.py scripts/*.py + python3 -m py_compile scripts/*.py + if compgen -G "test_*.py" > /dev/null; then python3 -m py_compile test_*.py; fi + if compgen -G "tests/test_*.py" > /dev/null; then python3 -m py_compile tests/test_*.py; fi - name: Compile native host run: | sudo apt-get update - sudo apt-get install -y --no-install-recommends libwebkit2gtk-4.1-dev + sudo apt-get install -y --no-install-recommends libwebkit2gtk-4.1-dev xdg-utils gcc -O2 native_host.c -o /tmp/native_host_check $(pkg-config --cflags --libs webkit2gtk-4.1) - name: Full gate (coverage) run: python3 scripts/check_tests.py + - name: Changed-line coverage (95%) + run: python3 -B scripts/check_changed_coverage.py --fail-under=95 + - name: Whitespace hygiene run: | git diff --check @@ -113,25 +343,116 @@ jobs: exit 1 fi + shellcheck: + runs-on: ubuntu-latest + timeout-minutes: 10 + + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # actions/checkout@v7.0.1 + with: + persist-credentials: false + + - name: Run shellcheck + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends shellcheck + shellcheck -S error -x run_all_tests.sh build_appimage.sh openbox.sh openbox-native.sh scripts/*.sh + + desktop-appstream: + runs-on: ubuntu-latest + timeout-minutes: 10 + + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # actions/checkout@v7.0.1 + with: + persist-credentials: false + + - name: Validate desktop and AppStream metadata + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends desktop-file-utils appstream + desktop-file-validate openbox.desktop + appstreamcli validate --no-net openbox.metainfo.xml + + flatpak-validate: + runs-on: ubuntu-latest + timeout-minutes: 15 + + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # actions/checkout@v7.0.1 + with: + persist-credentials: false + + - name: Dry-run Flatpak build + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends flatpak-builder + if flatpak-builder --help 2>&1 | grep -q -- '--dry-run'; then + flatpak-builder --dry-run --force-clean /tmp/ob-flatpak-dry io.openbox.GameLauncher.yml + else + python3 -B scripts/validate_flatpak_manifest.py + fi + + perf-20k: + runs-on: ubuntu-latest + timeout-minutes: 45 + + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # actions/checkout@v7.0.1 + with: + persist-credentials: false + + - name: Set up Python + uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # actions/setup-python@v7.1.0 + with: + python-version: "3.12" + + - name: Run 10k/20k performance gates + env: + OPENBOX_PERF_CI_MULT: "2.5" + run: python3 -B scripts/perf_bench.py --sizes 10000,20000 --runs 5 + + # The no-regression gate compares against the 1.19.1 baseline measured in this + # same job, so the ratio is measured on one machine (plan W7-04). + - name: Checkout the 1.19.1 baseline + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # actions/checkout@v7.0.1 + with: + repository: vindeckyy/OpenBoxGL + ref: release/1.19 + path: .baseline + persist-credentials: false + + - name: Compare against the 1.19.1 baseline + env: + OPENBOX_PERF_CI_MULT: "2.5" + run: | + python3 -B .baseline/scripts/perf_bench.py --base-dir "$RUNNER_TEMP/perf-base" --sizes 10000,20000 --runs 5 --no-gate --out "$RUNNER_TEMP/perf-baseline.json" + python3 -B scripts/perf_bench.py --base-dir "$RUNNER_TEMP/perf-cur" --sizes 10000,20000 --runs 5 --no-gate --out "$RUNNER_TEMP/perf-current.json" + python3 -B scripts/check_perf_regression.py --baseline "$RUNNER_TEMP/perf-baseline.json" --current "$RUNNER_TEMP/perf-current.json" + ui-smoke: runs-on: ubuntu-latest timeout-minutes: 30 steps: - name: Checkout - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # actions/checkout@v4 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # actions/checkout@v7.0.1 with: persist-credentials: false - name: Set up Python - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # actions/setup-python@v5 + uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # actions/setup-python@v7.1.0 with: python-version: "3.12" - name: Set up Node.js - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # actions/setup-node@v4.4.0 + uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # actions/setup-node@v7.1.0 with: - node-version: "22.12.0" + node-version: "24" cache: npm cache-dependency-path: scripts/package-lock.json @@ -142,3 +463,31 @@ jobs: - name: Frontend smoke test run: ./scripts/ui_smoke.sh + + # Conditional ARM64 job (ADR 0095). The artifact ships only after this job has built the + # native host for ARM64 and the suite has passed on windows-11-arm, so the job is + # informational until then: it reports, it does not block. + windows-arm64: + runs-on: windows-11-arm + timeout-minutes: 60 + continue-on-error: true + + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # actions/checkout@v7.0.1 + with: + persist-credentials: false + fetch-depth: 0 + + - name: Set up Python + uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # actions/setup-python@v7.1.0 + with: + python-version: "3.12" + + - name: Build the ARM64 WebView2 native host + shell: pwsh + run: | + scripts/build_native_host_windows.ps1 -Architecture arm64 + + - name: Run test suite + run: python -B scripts/run_windows_tests.py diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index d0a80852..6e1b00de 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -30,24 +30,24 @@ jobs: steps: - name: Checkout - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # actions/checkout@v4 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # actions/checkout@v7.0.1 with: persist-credentials: false - name: Set up Python - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # actions/setup-python@v5 + uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # actions/setup-python@v7.1.0 with: python-version: "3.12" - name: Initialize CodeQL - uses: github/codeql-action/init@f3712979fa5f215279b101dd0a2e3bdfb4353324 # github/codeql-action@v3 (v3.37.7) + uses: github/codeql-action/init@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # github/codeql-action@v4 (v4.37.7) with: languages: ${{ matrix.language }} - name: Autobuild - uses: github/codeql-action/autobuild@f3712979fa5f215279b101dd0a2e3bdfb4353324 # github/codeql-action@v3 (v3.37.7) + uses: github/codeql-action/autobuild@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # github/codeql-action@v4 (v4.37.7) - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@f3712979fa5f215279b101dd0a2e3bdfb4353324 # github/codeql-action@v3 (v3.37.7) + uses: github/codeql-action/analyze@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # github/codeql-action@v4 (v4.37.7) with: category: "/language:${{ matrix.language }}" diff --git a/.github/workflows/release-appimage.yml b/.github/workflows/release-appimage.yml deleted file mode 100644 index 9b317bac..00000000 --- a/.github/workflows/release-appimage.yml +++ /dev/null @@ -1,196 +0,0 @@ -name: Release AppImage - -on: - push: - tags: - - "v*" - -permissions: - contents: read - -concurrency: - group: release - cancel-in-progress: false - -jobs: - build: - runs-on: ubuntu-latest - timeout-minutes: 35 - permissions: - contents: read - - steps: - - name: Checkout - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # actions/checkout@v4 - with: - persist-credentials: false - - - name: Validate release tag - run: | - [[ "$GITHUB_REF_NAME" =~ ^v[0-9]+\.[0-9]+\.[0-9]+([-.][0-9A-Za-z.-]+)?$ ]] || { - echo "invalid release tag: $GITHUB_REF_NAME" >&2 - exit 1 - } - expected_version="$(python3 -c 'import updates; print(updates.VERSION)')" - test "$GITHUB_REF_NAME" = "v$expected_version" || { - echo "tag $GITHUB_REF_NAME does not match updates.py version $expected_version" >&2 - exit 1 - } - - - name: Install build dependencies - run: | - sudo apt-get update - sudo apt-get install -y --no-install-recommends \ - python3 \ - libwebkit2gtk-4.1-dev \ - libgtk-3-dev \ - libsoup-3.0-dev \ - libjavascriptcoregtk-4.1-dev \ - curl \ - file \ - binutils - - - name: Compile native host - run: gcc -O2 native_host.c -o /tmp/native_host_check $(pkg-config --cflags --libs webkit2gtk-4.1) - - - name: Run test suite - run: ./run_all_tests.sh - - - name: Build AppImage - env: - OPENBOX_APPDIR: ${{ runner.temp }}/OpenBox.AppDir - run: ./build_appimage.sh "$PWD/OpenBox-x86_64.AppImage" - - - name: Write SHA-256 checksum - run: sha256sum OpenBox-x86_64.AppImage | tee OpenBox-x86_64.AppImage.sha256 - - - name: Generate SBOM - run: | - VERSION="${GITHUB_REF_NAME#v}" - python3 scripts/gen_sbom.py \ - --version "$VERSION" \ - --appdir "${RUNNER_TEMP}/OpenBox.AppDir" \ - --out "OpenBox-$VERSION-sbom.json" - - - name: Verify zsync metadata - run: test -f OpenBox-x86_64.AppImage.zsync - - - name: Upload unsigned build outputs - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # actions/upload-artifact@v4.6.2 - with: - name: openbox-release-assets - if-no-files-found: error - retention-days: 7 - path: | - OpenBox-x86_64.AppImage - OpenBox-x86_64.AppImage.zsync - OpenBox-x86_64.AppImage.sha256 - OpenBox-*-sbom.json - - attest: - needs: build - runs-on: ubuntu-latest - timeout-minutes: 10 - permissions: - actions: read - contents: read - id-token: write - attestations: write - - steps: - - name: Download build output - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # actions/download-artifact@v4.3.0 - with: - name: openbox-release-assets - - - name: Attest build provenance - uses: actions/attest-build-provenance@e8998f949152b193b063cb0ec769d69d929409be # actions/attest-build-provenance@v2 - with: - subject-name: OpenBox-x86_64.AppImage - subject-path: OpenBox-x86_64.AppImage - - publish: - needs: [build, attest] - runs-on: ubuntu-latest - timeout-minutes: 15 - environment: release - permissions: - actions: read - contents: write - - steps: - - name: Checkout exact tagged commit - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # actions/checkout@v4 - with: - ref: ${{ github.sha }} - fetch-depth: 0 - persist-credentials: false - - - name: Download build outputs - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # actions/download-artifact@v4.3.0 - with: - name: openbox-release-assets - - - name: Validate annotated tag points at the build commit - run: | - test "$(git cat-file -t "$GITHUB_REF_NAME")" = tag - test "$(git rev-parse "$GITHUB_REF_NAME^{}")" = "$GITHUB_SHA" - - - name: Set up Python - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # actions/setup-python@v5 - with: - python-version: "3.12" - - - name: Install signing dependency - run: python3 -m pip install --disable-pip-version-check cryptography==50.0.0 - - - name: Sign and verify release - env: - OPENBOX_SIGNING_KEY: ${{ secrets.OPENBOX_SIGNING_KEY }} - run: | - set -euo pipefail - test -n "${OPENBOX_SIGNING_KEY:-}" || { - echo "OPENBOX_SIGNING_KEY is required; refusing to publish an unsigned release" >&2 - exit 1 - } - umask 077 - key_file="$(mktemp)" - public_key_file="$(mktemp)" - trap 'rm -f -- "$key_file" "$public_key_file"' EXIT - printf '%s' "$OPENBOX_SIGNING_KEY" > "$key_file" - unset OPENBOX_SIGNING_KEY - python3 scripts/sign_release.py \ - --key "$key_file" \ - --public-key-out "$public_key_file" \ - --out OpenBox-x86_64.AppImage.sig \ - OpenBox-x86_64.AppImage - cmp -s "$public_key_file" openbox-release.pub || { - echo "OPENBOX_SIGNING_KEY does not match the committed release public key" >&2 - exit 1 - } - sha256sum --check --strict OpenBox-x86_64.AppImage.sha256 - python3 scripts/verify_release.py \ - --key openbox-release.pub \ - OpenBox-x86_64.AppImage \ - OpenBox-x86_64.AppImage.sig - cp scripts/install.sh install.sh - - - name: Create release and upload signed assets - uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # softprops/action-gh-release@v2 - with: - tag_name: ${{ github.ref_name }} - target_commitish: ${{ github.sha }} - name: OpenBox ${{ github.ref_name }} - body_path: RELEASE_NOTES.md - generate_release_notes: false - prerelease: ${{ contains(github.ref_name, '-') }} - fail_on_unmatched_files: true - overwrite_files: false - files: | - OpenBox-x86_64.AppImage - OpenBox-x86_64.AppImage.zsync - OpenBox-x86_64.AppImage.sha256 - OpenBox-x86_64.AppImage.sig - openbox-release.pub - install.sh - OpenBox-*-sbom.json diff --git a/.github/workflows/release-flatpak.yml b/.github/workflows/release-flatpak.yml new file mode 100644 index 00000000..d7a5bf51 --- /dev/null +++ b/.github/workflows/release-flatpak.yml @@ -0,0 +1,136 @@ +name: Release Flatpak + +on: + push: + tags: + - "v*" + +permissions: + contents: read + +concurrency: + group: release-flatpak + cancel-in-progress: false + +jobs: + build: + runs-on: ubuntu-24.04 + timeout-minutes: 45 + permissions: + contents: read + + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # actions/checkout@v7.0.1 + with: + persist-credentials: false + + - name: Validate release tag + run: | + [[ "$GITHUB_REF_NAME" =~ ^v[0-9]+\.[0-9]+\.[0-9]+([-.][0-9A-Za-z.-]+)?$ ]] || { + echo "invalid release tag: $GITHUB_REF_NAME" >&2 + exit 1 + } + expected_version="$(python3 -c 'import updates; print(updates.VERSION)')" + test "$GITHUB_REF_NAME" = "v$expected_version" || { + echo "tag $GITHUB_REF_NAME does not match updates.py version $expected_version" >&2 + exit 1 + } + + - name: Install build dependencies + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + flatpak-builder \ + appstream \ + desktop-file-utils \ + elfutils \ + librsvg2-common \ + libwebkit2gtk-4.1-dev \ + libgtk-3-dev \ + libsoup-3.0-dev \ + libjavascriptcoregtk-4.1-dev \ + gcc \ + pkg-config + + - name: Validate packaging metadata + run: | + desktop-file-validate openbox.desktop + if ! appstreamcli validate --no-net openbox.metainfo.xml; then + python3 -B scripts/validate_flatpak_manifest.py + grep -q 'type="homepage"' openbox.metainfo.xml + grep -q 'type="bugtracker"' openbox.metainfo.xml + fi + if flatpak-builder --help 2>&1 | grep -q -- '--dry-run'; then + flatpak-builder --dry-run --force-clean /tmp/ob-flatpak-dry io.openbox.GameLauncher.yml + else + python3 -B scripts/validate_flatpak_manifest.py + fi + + - name: Compile native host + run: gcc -O2 native_host.c -o /tmp/native_host_check $(pkg-config --cflags --libs webkit2gtk-4.1) + + - name: Run packaging tests + run: python3 -B tests/test_packaging.py + + - name: Install Flatpak runtime and SDK + run: | + flatpak remote-add --if-not-exists --user flathub https://dl.flathub.org/repo/flathub.flatpakrepo + flatpak install -y --user flathub org.gnome.Platform//49 org.gnome.Sdk//49 + + - name: Build Flatpak bundle + run: | + flatpak-builder --force-clean --repo=repo build-dir io.openbox.GameLauncher.yml + flatpak build-bundle repo "OpenBox-x86_64.flatpak" io.openbox.GameLauncher --arch=x86_64 + + - name: Upload Flatpak artifact + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # actions/upload-artifact@v7.0.2 + with: + name: openbox-flatpak + if-no-files-found: error + retention-days: 7 + path: OpenBox-x86_64.flatpak + + publish: + needs: build + runs-on: ubuntu-24.04 + timeout-minutes: 15 + # AppImage and Flatpak publish jobs share one GitHub Release. Keep the + # uploads serialized while allowing their build jobs to run in parallel. + concurrency: + group: release-publish-${{ github.ref_name }} + cancel-in-progress: false + environment: release + permissions: + contents: write + + steps: + - name: Checkout exact tagged commit + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # actions/checkout@v7.0.1 + with: + ref: ${{ github.sha }} + fetch-depth: 0 + persist-credentials: false + + - name: Download Flatpak artifact + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # actions/download-artifact@v8.0.2 + with: + name: openbox-flatpak + + - name: Compute release name + id: relname + run: echo "name=OpenBox ${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT" + + - name: Create release and upload Flatpak + uses: softprops/action-gh-release@efb35369e0ad2afab669f228072c1b0d510eae64 # softprops/action-gh-release@v3.0.3 + with: + tag_name: ${{ github.ref_name }} + target_commitish: ${{ github.sha }} + name: ${{ steps.relname.outputs.name }} + body_path: docs/RELEASE_NOTES.md + generate_release_notes: false + prerelease: ${{ contains(github.ref_name, '-') }} + fail_on_unmatched_files: true + overwrite_files: false + files: | + OpenBox-x86_64.flatpak diff --git a/.github/workflows/release-smoke.yml b/.github/workflows/release-smoke.yml new file mode 100644 index 00000000..47aabe16 --- /dev/null +++ b/.github/workflows/release-smoke.yml @@ -0,0 +1,336 @@ +name: Release smoke + +# Manual only. Downloads a published release's assets, verifies them against the +# release key, and runs them on each platform: the web server, then the native +# window on a virtual display (Linux) or the desktop session (Windows). Screenshots +# are uploaded as artifacts. Nothing here publishes anything. +on: + workflow_dispatch: + inputs: + tag: + description: Release tag to smoke-test + required: true + default: v1.19.1 + +permissions: + contents: read + +jobs: + linux: + strategy: + fail-fast: false + matrix: + include: + - arch: x86_64 + runner: ubuntu-24.04 + - arch: aarch64 + runner: ubuntu-24.04-arm + runs-on: ${{ matrix.runner }} + timeout-minutes: 30 + steps: + - name: Checkout the release tag + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # actions/checkout@v7.0.1 + with: + ref: ${{ inputs.tag }} + persist-credentials: false + + - name: Set up Python + uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # actions/setup-python@v7.1.0 + with: + python-version: "3.12" + + - name: Install runtime tools + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends xvfb imagemagick xauth xdotool libwebkit2gtk-4.1-0 libgtk-3-0 + python3 -m pip install --disable-pip-version-check cryptography==50.0.0 + + - name: Download and verify the published AppImage + env: + GH_TOKEN: ${{ github.token }} + run: | + mkdir -p smoke && cd smoke + name="OpenBox-${{ matrix.arch }}.AppImage" + gh release download "${{ inputs.tag }}" -R "$GITHUB_REPOSITORY" -p "$name" -p "$name.sha256" -p "$name.sig" + sha256sum --check --strict "$name.sha256" + python3 -B ../scripts/verify_release.py --key ../openbox-release.pub "$name" "$name.sig" + chmod +x "$name" + ./"$name" --appimage-extract >/dev/null + test -x squashfs-root/AppRun + + - name: Serve the web app headless + env: + OPENBOX_DATA_DIR: ${{ runner.temp }}/data-web + run: | + set -euo pipefail + mkdir -p "$OPENBOX_DATA_DIR" + smoke/squashfs-root/AppRun --web --no-browser > "$RUNNER_TEMP/web.out" 2> "$RUNNER_TEMP/web.err" & + pid=$! + url="" + for i in $(seq 1 90); do + url="$(grep -m1 -oE 'http://127\.0\.0\.1:[0-9]+/' "$RUNNER_TEMP/web.out" || true)" + [ -n "$url" ] && break + sleep 1 + done + test -n "$url" + code="$(curl -s -o "$RUNNER_TEMP/index.html" -w '%{http_code}' "$url")" + echo "GET / -> $code, $(wc -c < "$RUNNER_TEMP/index.html") bytes" + test "$code" = 200 + kill "$pid" || true + + - name: Open the native window on a virtual display + env: + SMOKE_ARCH: ${{ matrix.arch }} + OPENBOX_DATA_DIR: ${{ runner.temp }}/data-native + run: | + set -euo pipefail + mkdir -p "$OPENBOX_DATA_DIR" + cat > "$RUNNER_TEMP/window.sh" <<'EOS' + #!/bin/bash + set -u + "$GITHUB_WORKSPACE/smoke/squashfs-root/AppRun" > "$RUNNER_TEMP/native.out" 2> "$RUNNER_TEMP/native.err" & + pid=$! + sleep 25 + import -window root "$RUNNER_TEMP/linux-window-$SMOKE_ARCH.png" + # Click "Continue" on the Library Setup Center (position from the first screenshot) and check the next step. + xdotool mousemove 1020 611 click 1 || true + sleep 4 + import -window root "$RUNNER_TEMP/linux-window-step2-$SMOKE_ARCH.png" + if kill -0 "$pid" 2>/dev/null; then echo "native launcher still running after 25s"; else echo "native launcher exited early"; fi + pkill -P "$pid" 2>/dev/null || true + kill "$pid" 2>/dev/null || true + EOS + xvfb-run -a -s "-screen 0 1280x800x24" bash "$RUNNER_TEMP/window.sh" + test -s "$RUNNER_TEMP/linux-window-$SMOKE_ARCH.png" + + - name: Upload the screenshot and logs + if: always() + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # actions/upload-artifact@v7.0.2 + with: + name: smoke-linux-${{ matrix.arch }} + if-no-files-found: warn + retention-days: 7 + path: | + ${{ runner.temp }}/linux-window-*.png + ${{ runner.temp }}/web.out + ${{ runner.temp }}/web.err + ${{ runner.temp }}/native.out + ${{ runner.temp }}/native.err + ${{ runner.temp }}/data-web/openbox-launch.log + ${{ runner.temp }}/data-native/openbox-launch.log + + windows: + runs-on: windows-latest + timeout-minutes: 30 + steps: + - name: Checkout the release tag + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # actions/checkout@v7.0.1 + with: + ref: ${{ inputs.tag }} + persist-credentials: false + + - name: Set up Python + uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # actions/setup-python@v7.1.0 + with: + python-version: "3.12" + + - name: Download and verify the published Windows zip + shell: pwsh + env: + GH_TOKEN: ${{ github.token }} + run: | + New-Item -ItemType Directory -Force -Path smoke | Out-Null + Set-Location smoke + $name = 'OpenBox-x86_64-windows.zip' + gh release download "${{ inputs.tag }}" -R $env:GITHUB_REPOSITORY -p $name -p "$name.sha256" -p "$name.sig" + $expected = (Get-Content "$name.sha256" -Raw).Split(' ')[0].Trim().ToLower() + $actual = (Get-FileHash -Algorithm SHA256 -LiteralPath $name).Hash.ToLower() + if ($actual -ne $expected) { throw "sha256 mismatch: $actual vs $expected" } + python -m pip install --disable-pip-version-check cryptography==50.0.0 + python -B ..\scripts\verify_release.py --key ..\openbox-release.pub $name "$name.sig" + Expand-Archive -LiteralPath $name -DestinationPath extracted + if (-not (Test-Path 'extracted\OpenBox\native_host.exe')) { throw 'native_host.exe missing from the zip' } + + - name: Serve the web app headless + shell: pwsh + env: + OPENBOX_DATA_DIR: ${{ runner.temp }}\data-web + run: | + New-Item -ItemType Directory -Force -Path $env:OPENBOX_DATA_DIR | Out-Null + $web = Start-Process -FilePath python -ArgumentList '-B','smoke\extracted\OpenBox\web_app.py','--no-browser' -PassThru -RedirectStandardOutput "$env:RUNNER_TEMP\web.out" -RedirectStandardError "$env:RUNNER_TEMP\web.err" + $url = $null + for ($i = 0; $i -lt 90 -and -not $url; $i++) { + Start-Sleep -Seconds 1 + if (Test-Path "$env:RUNNER_TEMP\web.out") { + $m = Select-String -Path "$env:RUNNER_TEMP\web.out" -Pattern 'http://127\.0\.0\.1:\d+/' | Select-Object -First 1 + if ($m) { $url = $m.Matches[0].Value } + } + } + if (-not $url) { throw 'the web server printed no URL' } + $code = (Invoke-WebRequest -UseBasicParsing -Uri $url).StatusCode + "GET / -> $code" + Stop-Process -Id $web.Id -Force -ErrorAction SilentlyContinue + if ($code -ne 200) { throw "unexpected status $code" } + + - name: Open the native window and capture the desktop + shell: pwsh + env: + OPENBOX_DATA_DIR: ${{ runner.temp }}\data-native + run: | + New-Item -ItemType Directory -Force -Path $env:OPENBOX_DATA_DIR | Out-Null + # The launcher sets these before it starts the host (openbox-native.ps1); the host needs them. + $env:OPENBOX_WEB_APP = (Resolve-Path 'smoke\extracted\OpenBox\web_app.py').Path + $env:OPENBOX_PYTHON = (Get-Command python).Source + $host_proc = Start-Process -FilePath "$PWD\smoke\extracted\OpenBox\native_host.exe" -PassThru -RedirectStandardOutput "$env:RUNNER_TEMP\host.out" -RedirectStandardError "$env:RUNNER_TEMP\host.err" + Start-Sleep -Seconds 25 + Add-Type -AssemblyName System.Windows.Forms, System.Drawing + $bounds = [System.Windows.Forms.Screen]::PrimaryScreen.Bounds + $bitmap = New-Object System.Drawing.Bitmap $bounds.Width, $bounds.Height + $graphics = [System.Drawing.Graphics]::FromImage($bitmap) + $graphics.CopyFromScreen($bounds.Location, [System.Drawing.Point]::Empty, $bounds.Size) + $bitmap.Save("$env:RUNNER_TEMP\windows-window.png", [System.Drawing.Imaging.ImageFormat]::Png) + # Click "Continue" on the Library Setup Center (position from the first screenshot), then capture the next step. + Add-Type -Namespace Native -Name Mouse -MemberDefinition @' + [DllImport("user32.dll")] public static extern bool SetCursorPos(int X, int Y); + [DllImport("user32.dll")] public static extern void mouse_event(uint dwFlags, uint dx, uint dy, uint dwData, System.IntPtr dwExtraInfo); + '@ + [Native.Mouse]::SetCursorPos(891, 618) | Out-Null + [Native.Mouse]::mouse_event(0x0002, 0, 0, 0, [IntPtr]::Zero) + [Native.Mouse]::mouse_event(0x0004, 0, 0, 0, [IntPtr]::Zero) + Start-Sleep -Seconds 4 + $bitmap2 = New-Object System.Drawing.Bitmap $bounds.Width, $bounds.Height + $graphics2 = [System.Drawing.Graphics]::FromImage($bitmap2) + $graphics2.CopyFromScreen($bounds.Location, [System.Drawing.Point]::Empty, $bounds.Size) + $bitmap2.Save("$env:RUNNER_TEMP\windows-window-step2.png", [System.Drawing.Imaging.ImageFormat]::Png) + $alive = -not $host_proc.HasExited + "native_host still running after 25s: $alive" + if ($alive) { Stop-Process -Id $host_proc.Id -Force -ErrorAction SilentlyContinue } + else { throw "native_host exited within 25s (exit code $($host_proc.ExitCode))" } + + - name: Collect the Windows host's own data and logs + if: always() + shell: pwsh + run: | + # The native host keeps its data in %LOCALAPPDATA%\openbox-game-launcher (DEFAULT_DATA_DIR_NAME), + # not in OPENBOX_DATA_DIR. + $dest = Join-Path $env:RUNNER_TEMP 'host-data' + New-Item -ItemType Directory -Force -Path $dest | Out-Null + $source = Join-Path $env:LOCALAPPDATA 'openbox-game-launcher' + if (Test-Path $source) { + Copy-Item -Recurse -Force -Path $source -Destination (Join-Path $dest 'openbox-game-launcher') + } else { + "no host data folder at $source" + } + Get-ChildItem -Recurse -File $dest | ForEach-Object { "{0} {1} bytes" -f $_.FullName, $_.Length } + + - name: Upload the screenshot and logs + if: always() + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # actions/upload-artifact@v7.0.2 + with: + name: smoke-windows + if-no-files-found: warn + retention-days: 7 + path: | + ${{ runner.temp }}\windows-window.png + ${{ runner.temp }}\windows-window-step2.png + ${{ runner.temp }}\web.out + ${{ runner.temp }}\web.err + ${{ runner.temp }}\host.out + ${{ runner.temp }}\host.err + ${{ runner.temp }}\host-data\** + ${{ runner.temp }}\data-web\** + ${{ runner.temp }}\data-native\** + + macos: + runs-on: macos-15 + timeout-minutes: 30 + steps: + - name: Checkout the release tag + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # actions/checkout@v7.0.1 + with: + ref: ${{ inputs.tag }} + persist-credentials: false + + - name: Set up Python + uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # actions/setup-python@v7.1.0 + with: + python-version: "3.12" + + - name: Download and verify the published macOS bundle + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + mkdir -p smoke + cd smoke + name="OpenBox-macos.zip" + gh release download "${{ inputs.tag }}" -R "$GITHUB_REPOSITORY" -p "$name" -p "$name.sha256" -p "$name.sig" + shasum -a 256 -c "$name.sha256" + python3 -m pip install --disable-pip-version-check cryptography==50.0.0 + python3 -B ../scripts/verify_release.py --key ../openbox-release.pub "$name" "$name.sig" + ditto -x -k "$name" extracted + test -x "extracted/OpenBox.app/Contents/MacOS/OpenBox" || { + echo "the bundle has no native host" >&2 + exit 1 + } + + - name: Serve the web app headless + env: + OPENBOX_DATA_DIR: ${{ runner.temp }}/data-web + run: | + set -euo pipefail + mkdir -p "$OPENBOX_DATA_DIR" + python3 -B "smoke/extracted/OpenBox.app/Contents/Resources/openbox/web_app.py" --no-browser \ + > "$RUNNER_TEMP/web.out" 2> "$RUNNER_TEMP/web.err" & + web_pid=$! + url="" + for _ in $(seq 1 90); do + sleep 1 + if [ -f "$RUNNER_TEMP/web.out" ]; then + url="$(grep -o 'http://127\.0\.0\.1:[0-9]\+/' "$RUNNER_TEMP/web.out" | head -1 || true)" + fi + [ -n "$url" ] && break + done + if [ -z "$url" ]; then + echo "the web server printed no URL" >&2 + cat "$RUNNER_TEMP/web.err" >&2 || true + exit 1 + fi + code="$(curl -s -o /dev/null -w '%{http_code}' "$url")" + kill "$web_pid" 2>/dev/null || true + echo "GET / -> $code" + [ "$code" = "200" ] || exit 1 + + - name: Open the native window and capture the desktop + env: + OPENBOX_DATA_DIR: ${{ runner.temp }}/data-native + run: | + set -euo pipefail + mkdir -p "$OPENBOX_DATA_DIR" + "smoke/extracted/OpenBox.app/Contents/MacOS/OpenBox" \ + > "$RUNNER_TEMP/host.out" 2> "$RUNNER_TEMP/host.err" & + host_pid=$! + sleep 25 + screencapture -x "$RUNNER_TEMP/macos-window.png" || true + if ! kill -0 "$host_pid" 2>/dev/null; then + echo "the native host exited within 25s" >&2 + cat "$RUNNER_TEMP/host.err" >&2 || true + exit 1 + fi + kill "$host_pid" 2>/dev/null || true + + - name: Upload the screenshot and logs + if: always() + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # actions/upload-artifact@v7.0.2 + with: + name: smoke-macos + if-no-files-found: warn + retention-days: 7 + path: | + ${{ runner.temp }}/macos-window.png + ${{ runner.temp }}/web.out + ${{ runner.temp }}/web.err + ${{ runner.temp }}/host.out + ${{ runner.temp }}/host.err + ${{ runner.temp }}/data-web/** + ${{ runner.temp }}/data-native/** diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..3679a73b --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,826 @@ +name: Release + +on: + push: + tags: + - "v*" + # A manual run builds and checks the release on a branch. Every publish step is + # gated on a tag, so nothing is published (ADR 0065 dry run). + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: release + cancel-in-progress: false + +jobs: + # The test suite and the native-host compile run on the host, as they always + # have. The container only builds, so the checks do not depend on its userland. + test: + runs-on: ubuntu-24.04 + timeout-minutes: 35 + permissions: + contents: read + + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # actions/checkout@v7.0.1 + with: + persist-credentials: false + + - name: Install build dependencies + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + python3 \ + libwebkit2gtk-4.1-dev \ + libgtk-3-dev \ + libsoup-3.0-dev \ + libjavascriptcoregtk-4.1-dev \ + curl \ + file \ + binutils \ + xdg-utils + + - name: Compile native host + run: gcc -O2 native_host.c -o /tmp/native_host_check $(pkg-config --cflags --libs webkit2gtk-4.1) + + - name: Run test suite + run: ./run_all_tests.sh + + build: + needs: test + strategy: + fail-fast: false + matrix: + include: + # x86_64 builds in an ubuntu:22.04 container on a supported 24.04 runner, + # so the AppImage keeps the glibc 2.35 floor (ADR 0065). aarch64 uses + # ubuntu-24.04-arm: ubuntu-22.04-arm deprecates 2026-09-17 (ADR 0024). + # Each AppImage bundles its host interpreter, so the artifact arch + # follows the runner arch. + - arch: x86_64 + runner: ubuntu-24.04 + - arch: aarch64 + runner: ubuntu-24.04-arm + runs-on: ${{ matrix.runner }} + container: ubuntu:22.04 + # The container's /bin/sh is dash; the steps below use bash features. + defaults: + run: + shell: bash + timeout-minutes: 35 + permissions: + contents: read + + steps: + # The container ships none of these, and has no sudo. The build needs the + # WebKit headers, as it did on the host. + - name: Install container tools + run: | + apt-get update + apt-get install -y --no-install-recommends \ + ca-certificates git python3 curl file binutils xdg-utils \ + build-essential pkg-config libwebkit2gtk-4.1-dev libgtk-3-dev \ + libsoup-3.0-dev libjavascriptcoregtk-4.1-dev + # The checkout belongs to the runner user; git refuses it as root without this. + git config --global --add safe.directory "$GITHUB_WORKSPACE" + + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # actions/checkout@v7.0.1 + with: + persist-credentials: false + + - name: Validate release tag + if: startsWith(github.ref, 'refs/tags/') + run: | + [[ "$GITHUB_REF_NAME" =~ ^v[0-9]+\.[0-9]+\.[0-9]+([-.][0-9A-Za-z.-]+)?$ ]] || { + echo "invalid release tag: $GITHUB_REF_NAME" >&2 + exit 1 + } + expected_version="$(python3 -c 'import updates; print(updates.VERSION)')" + test "$GITHUB_REF_NAME" = "v$expected_version" || { + echo "tag $GITHUB_REF_NAME does not match updates.py version $expected_version" >&2 + exit 1 + } + + - name: Build AppImage + env: + OPENBOX_APPDIR: ${{ runner.temp }}/OpenBox.AppDir + OPENBOX_ARCH: ${{ matrix.arch }} + run: ./build_appimage.sh "$PWD/OpenBox-${{ matrix.arch }}.AppImage" + + # The AppImage must not need a glibc newer than the 2.35 floor (ADR 0065). + # Extract it and read the GLIBC_ symbol versions of every ELF file in it. + - name: Check the glibc floor + run: | + set -euo pipefail + ldd --version | sed -n '1p' + rm -rf squashfs-root + "./OpenBox-${{ matrix.arch }}.AppImage" --appimage-extract >/dev/null + highest="" + while IFS= read -r file; do + if file -b "$file" | grep -q '^ELF'; then + versions="$(objdump -T "$file" 2>/dev/null | grep -oE 'GLIBC_[0-9.]+' || true)" + if [ -n "$versions" ]; then + highest="$(printf '%s\n%s\n' "$highest" "$versions" | sort -uV | tail -1)" + fi + fi + done < <(find squashfs-root -type f) + echo "highest GLIBC symbol version in the AppImage: $highest" + test -n "$highest" + test "$(printf '%s\n%s\n' "$highest" GLIBC_2.35 | sort -V | tail -1)" = GLIBC_2.35 + + - name: Write SHA-256 checksum + run: sha256sum "OpenBox-${{ matrix.arch }}.AppImage" | tee "OpenBox-${{ matrix.arch }}.AppImage.sha256" + + - name: Generate SBOM + run: | + VERSION="${GITHUB_REF_NAME#v}" + # x86_64 keeps the legacy unprefixed SBOM name (OpenBox--sbom.json) + # matching all prior releases; aarch64 gets an arch-suffixed name so the + # two SBOMs in a multi-arch release are distinct (ADR 0024). + if [ "${{ matrix.arch }}" = "x86_64" ]; then + sbom_name="OpenBox-$VERSION-sbom.json" + else + sbom_name="OpenBox-$VERSION-${{ matrix.arch }}-sbom.json" + fi + python3 scripts/gen_sbom.py \ + --version "$VERSION" \ + --appdir "${RUNNER_TEMP}/OpenBox.AppDir" \ + --out "$sbom_name" + + - name: Verify zsync metadata + run: test -f "OpenBox-${{ matrix.arch }}.AppImage.zsync" + + - name: Upload unsigned build outputs + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # actions/upload-artifact@v7.0.2 + with: + name: openbox-release-assets-${{ matrix.arch }} + if-no-files-found: error + retention-days: 7 + path: | + OpenBox-${{ matrix.arch }}.AppImage + OpenBox-${{ matrix.arch }}.AppImage.zsync + OpenBox-${{ matrix.arch }}.AppImage.sha256 + OpenBox-*-sbom.json + + attest: + if: startsWith(github.ref, 'refs/tags/') + needs: build + strategy: + fail-fast: false + matrix: + include: + - arch: x86_64 + - arch: aarch64 + runs-on: ubuntu-24.04 + timeout-minutes: 10 + permissions: + actions: read + contents: read + id-token: write + attestations: write + + steps: + - name: Download build output + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # actions/download-artifact@v8.0.2 + with: + name: openbox-release-assets-${{ matrix.arch }} + + - name: Attest build provenance + uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # actions/attest-build-provenance@v4.2.2 + with: + subject-name: OpenBox-${{ matrix.arch }}.AppImage + subject-path: OpenBox-${{ matrix.arch }}.AppImage + + build-windows: + runs-on: windows-latest + timeout-minutes: 30 + permissions: + contents: read + + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # actions/checkout@v7.0.1 + with: + persist-credentials: false + + - name: Set up Python + uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # actions/setup-python@v7.1.0 + with: + python-version: "3.12" + + - name: Validate release tag + if: startsWith(github.ref, 'refs/tags/') + shell: pwsh + run: | + $tag = "${{ github.ref_name }}" + if ($tag -notmatch '^v[0-9]+\.[0-9]+\.[0-9]+([-.][0-9A-Za-z.-]+)?$') { + throw "invalid release tag: $tag" + } + $expected = (python -B -c "import updates; print(updates.VERSION)").Trim() + if ($tag -ne "v$expected") { + throw "tag $tag does not match updates.py version $expected" + } + + - name: Run test suite + run: python -B scripts/run_windows_tests.py + + # The released zip carries the WebView2 host, so users get the native + # window without a toolchain. Building it here also means a broken + # native_host_win.c fails the release instead of shipping silently; + # the windows CI job proves the same build on every push. + - name: Build the WebView2 native host + shell: pwsh + run: | + scripts/build_native_host_windows.ps1 + if (-not (Test-Path -LiteralPath native_host.exe)) { + throw "native_host.exe was not produced; refusing to package a release without the Windows host" + } + + - name: Package the portable release + shell: pwsh + run: | + # One top-level folder so scripts/install.ps1 can lift the whole tree + # into %LOCALAPPDATA%\OpenBox\share\openbox in a single move. + $root = Join-Path $env:RUNNER_TEMP 'OpenBox-windows/OpenBox' + New-Item -ItemType Directory -Path $root -Force | Out-Null + $skip = @('.git', '.github', '.venv-dev', 'build', 'node_modules', '__pycache__', '.coverage') + Get-ChildItem -Force -LiteralPath . | + Where-Object { $skip -notcontains $_.Name } | + ForEach-Object { Copy-Item -Recurse -Force -LiteralPath $_.FullName -Destination $root } + Get-ChildItem -Recurse -Force -Directory -LiteralPath $root | + Where-Object { $_.Name -eq '__pycache__' } | + Remove-Item -Recurse -Force + $asset = 'OpenBox-x86_64-windows.zip' + Compress-Archive -Path $root -DestinationPath $asset -CompressionLevel Optimal + # The native window is the point of the Windows build: fail the + # release if the packaged tree does not carry the host binary. + # Entry separators differ between Compress-Archive on PowerShell 5.1 + # ("\") and 7 ("/"), so normalize before comparing. + Add-Type -AssemblyName System.IO.Compression.FileSystem + $zip = [System.IO.Compression.ZipFile]::OpenRead((Resolve-Path -LiteralPath $asset)) + try { + $hasHost = @($zip.Entries | Where-Object { ($_.FullName -replace '\\', '/') -eq 'OpenBox/native_host.exe' }).Count -eq 1 + } finally { + $zip.Dispose() + } + if (-not $hasHost) { + throw "the release archive is missing OpenBox/native_host.exe" + } + $digest = (Get-FileHash -Algorithm SHA256 -LiteralPath $asset).Hash.ToLower() + # -NoNewline keeps this byte-identical to sha256sum output, which is + # what the publish job validates it with. + "$digest $asset" | Set-Content -LiteralPath "$asset.sha256" -Encoding ascii -NoNewline + # The same host binary also ships as its own asset for source + # checkouts, which otherwise need MSVC plus the WebView2 SDK to get a + # native window. Copied after the archive so the zip is unchanged. + $hostAsset = 'OpenBox-x86_64-windows-native-host.exe' + Copy-Item -LiteralPath 'native_host.exe' -Destination $hostAsset -Force + $hostDigest = (Get-FileHash -Algorithm SHA256 -LiteralPath $hostAsset).Hash.ToLower() + "$hostDigest $hostAsset" | Set-Content -LiteralPath "$hostAsset.sha256" -Encoding ascii -NoNewline + + - name: Upload unsigned build outputs + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # actions/upload-artifact@v7.0.2 + with: + name: openbox-release-assets-windows + if-no-files-found: error + retention-days: 7 + path: | + OpenBox-x86_64-windows.zip + OpenBox-x86_64-windows.zip.sha256 + OpenBox-x86_64-windows-native-host.exe + OpenBox-x86_64-windows-native-host.exe.sha256 + + # The ARM64 lane (ADR 0095, W5-03/W5-07). It runs only on windows-11-arm + # runners, which the repository gets on tagged releases; on other runners + # the artifacts it would have built simply do not exist. The lane mirrors + # the x86_64 lane exactly: build the host for the architecture, package, + # name, hash, upload, attest, sign, verify, and publish under OpenBox-*-arm64 + # file names. + build-windows-arm64: + runs-on: windows-11-arm + timeout-minutes: 30 + permissions: + contents: read + + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # actions/checkout@v7.0.1 + with: + persist-credentials: false + + - name: Set up Python + uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # actions/setup-python@v7.1.0 + with: + python-version: "3.12" + + - name: Validate release tag + if: startsWith(github.ref, 'refs/tags/') + shell: pwsh + run: | + $tag = "${{ github.ref_name }}" + if ($tag -notmatch '^v[0-9]+\.[0-9]+\.[0-9]+([-.][0-9A-Za-z.-]+)?$') { + throw "invalid release tag: $tag" + } + $expected = (python -B -c "import updates; print(updates.VERSION)").Trim() + if ($tag -ne "v$expected") { + throw "tag $tag does not match updates.py version $expected" + } + + - name: Run test suite + run: python -B scripts/run_windows_tests.py + + - name: Build the ARM64 WebView2 native host + shell: pwsh + run: | + scripts/build_native_host_windows.ps1 -Architecture arm64 + if (-not (Test-Path -LiteralPath native_host.exe)) { + throw "native_host.exe was not produced; refusing to package a release without the ARM64 Windows host" + } + + - name: Generate the ARM64 SBOM + shell: pwsh + run: | + $version = "${{ github.ref_name }}".TrimStart("v") + python -B scripts/gen_sbom.py --version $version --out "OpenBox-$version-arm64-windows-sbom.json" + + - name: Package the portable release + shell: pwsh + run: | + $root = Join-Path $env:RUNNER_TEMP 'OpenBox-windows/OpenBox' + New-Item -ItemType Directory -Path $root -Force | Out-Null + $skip = @('.git', '.github', '.venv-dev', 'build', 'node_modules', '__pycache__', '.coverage') + Get-ChildItem -Force -LiteralPath . | + Where-Object { $skip -notcontains $_.Name } | + ForEach-Object { Copy-Item -Recurse -Force -LiteralPath $_.FullName -Destination $root } + Get-ChildItem -Recurse -Force -Directory -LiteralPath $root | + Where-Object { $_.Name -eq '__pycache__' } | + Remove-Item -Recurse -Force + $asset = 'OpenBox-arm64-windows.zip' + Compress-Archive -Path $root -DestinationPath $asset -CompressionLevel Optimal + Add-Type -AssemblyName System.IO.Compression.FileSystem + $zip = [System.IO.Compression.ZipFile]::OpenRead((Resolve-Path -LiteralPath $asset)) + try { + $hasHost = @($zip.Entries | Where-Object { ($_.FullName -replace '\\', '/') -eq 'OpenBox/native_host.exe' }).Count -eq 1 + } finally { + $zip.Dispose() + } + if (-not $hasHost) { + throw "the release archive is missing OpenBox/native_host.exe" + } + $digest = (Get-FileHash -Algorithm SHA256 -LiteralPath $asset).Hash.ToLower() + "$digest $asset" | Set-Content -LiteralPath "$asset.sha256" -Encoding ascii -NoNewline + $hostAsset = 'OpenBox-arm64-windows-native-host.exe' + Copy-Item -LiteralPath 'native_host.exe' -Destination $hostAsset -Force + $hostDigest = (Get-FileHash -Algorithm SHA256 -LiteralPath $hostAsset).Hash.ToLower() + "$hostDigest $hostAsset" | Set-Content -LiteralPath "$hostAsset.sha256" -Encoding ascii -NoNewline + + - name: Upload unsigned build outputs + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # actions/upload-artifact@v7.0.2 + with: + name: openbox-release-assets-windows-arm64 + if-no-files-found: error + retention-days: 7 + path: | + OpenBox-arm64-windows.zip + OpenBox-arm64-windows.zip.sha256 + OpenBox-arm64-windows-native-host.exe + OpenBox-arm64-windows-native-host.exe.sha256 + OpenBox-*-arm64-windows-sbom.json + build-macos: + runs-on: macos-15 + timeout-minutes: 45 + permissions: + contents: read + # The secrets context is not available in a step condition, so the + # Developer ID and the notary profile arrive as job env vars and the + # notarization steps gate on them. + env: + MACOS_CODESIGN_IDENTITY: ${{ secrets.MACOS_CODESIGN_IDENTITY }} + MACOS_NOTARY_PROFILE: ${{ secrets.MACOS_NOTARY_PROFILE }} + + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # actions/checkout@v7.0.1 + with: + persist-credentials: false + + - name: Set up Python + uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # actions/setup-python@v7.1.0 + with: + python-version: "3.12" + + - name: Validate release tag + if: startsWith(github.ref, 'refs/tags/') + run: | + set -euo pipefail + if [[ ! "$GITHUB_REF_NAME" =~ ^v[0-9]+\.[0-9]+\.[0-9]+([-.][0-9A-Za-z.-]+)?$ ]]; then + echo "invalid release tag: $GITHUB_REF_NAME" >&2 + exit 1 + fi + expected="$(python3 -c 'import updates; print(updates.VERSION)')" + if [ "$GITHUB_REF_NAME" != "v$expected" ]; then + echo "tag $GITHUB_REF_NAME does not match updates.py version $expected" >&2 + exit 1 + fi + + - name: Run test suite + run: | + mkdir -p "$RUNNER_TEMP/bin" + ln -sf "$(command -v python)" "$RUNNER_TEMP/bin/python3" + PATH="$RUNNER_TEMP/bin:$PATH" ./run_all_tests.sh + + # The bundle carries the universal native host and the bundled + # interpreter, and the build signs it and runs the smoke test. + # With the Developer ID secrets set, the build signs with that identity under the + # hardened runtime and notarizes and staples the bundle (ADR 0094). Without them it + # stays ad-hoc, and the release notes say the bundle is not notarized. + - name: Build the macOS app bundle + env: + OPENBOX_DEVELOPER_ID: ${{ env.MACOS_CODESIGN_IDENTITY }} + OPENBOX_NOTARY_PROFILE: ${{ env.MACOS_NOTARY_PROFILE }} + run: scripts/build_macos_app.sh "$PWD/OpenBox-macos.zip" + + # A notarized bundle is only claimed with Gatekeeper's own verdict (ADR 0094). + - name: Verify the notarized bundle + if: ${{ env.MACOS_CODESIGN_IDENTITY != '' }} + run: | + set -euo pipefail + xcrun stapler validate build/macos/OpenBox.app + spctl --assess --type execute --verbose=2 build/macos/OpenBox.app + + # The installer prints the "not notarized" caveat unless this marker says otherwise. + - name: Mark the installer as notarized + if: ${{ env.MACOS_CODESIGN_IDENTITY != '' }} + run: | + set -euo pipefail + sed -i.bak 's/^NOTARIZED_BUILD=0$/NOTARIZED_BUILD=1/' scripts/install-macos.sh + rm -f scripts/install-macos.sh.bak + grep -q '^NOTARIZED_BUILD=1$' scripts/install-macos.sh + + - name: Upload build outputs + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # actions/upload-artifact@v7.0.2 + with: + name: openbox-release-assets-macos + if-no-files-found: error + retention-days: 7 + path: | + OpenBox-macos.zip + OpenBox-macos.zip.sha256 + OpenBox-*-macos-sbom.json + + attest-macos: + if: startsWith(github.ref, 'refs/tags/') + needs: build-macos + runs-on: ubuntu-24.04 + timeout-minutes: 10 + permissions: + actions: read + contents: read + id-token: write + attestations: write + + steps: + - name: Download build output + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # actions/download-artifact@v8.0.2 + with: + name: openbox-release-assets-macos + + - name: Attest build provenance + uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # actions/attest-build-provenance@v4.2.2 + with: + subject-name: OpenBox-macos.zip + subject-path: OpenBox-macos.zip + + attest-windows: + if: startsWith(github.ref, 'refs/tags/') + needs: build-windows + runs-on: ubuntu-24.04 + timeout-minutes: 10 + permissions: + actions: read + contents: read + id-token: write + attestations: write + + steps: + - name: Download build output + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # actions/download-artifact@v8.0.2 + with: + name: openbox-release-assets-windows + + - name: Attest build provenance + uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # actions/attest-build-provenance@v4.2.2 + with: + subject-name: OpenBox-x86_64-windows.zip + subject-path: OpenBox-x86_64-windows.zip + + - name: Attest the native host provenance + uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # actions/attest-build-provenance@v4.2.2 + with: + subject-name: OpenBox-x86_64-windows-native-host.exe + subject-path: OpenBox-x86_64-windows-native-host.exe + attest-windows-arm64: + if: startsWith(github.ref, 'refs/tags/') + needs: build-windows-arm64 + runs-on: ubuntu-24.04 + timeout-minutes: 10 + permissions: + actions: read + contents: read + id-token: write + attestations: write + + steps: + - name: Download build output + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # actions/download-artifact@v8.0.2 + with: + name: openbox-release-assets-windows-arm64 + + - name: Attest build provenance + uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # actions/attest-build-provenance@v4.2.2 + with: + subject-name: OpenBox-arm64-windows.zip + subject-path: OpenBox-arm64-windows.zip + + - name: Attest the native host provenance + uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # actions/attest-build-provenance@v4.2.2 + with: + subject-name: OpenBox-arm64-windows-native-host.exe + subject-path: OpenBox-arm64-windows-native-host.exe + + publish: + if: startsWith(github.ref, 'refs/tags/') + needs: [build, attest, build-windows, attest-windows, build-windows-arm64, attest-windows-arm64, build-macos, attest-macos] + runs-on: ubuntu-24.04 + timeout-minutes: 15 + # AppImage, Windows and Flatpak publish jobs share one GitHub Release. Keep + # the uploads serialized while allowing their build matrices to run in + # parallel. + concurrency: + group: release-publish-${{ github.ref_name }} + cancel-in-progress: false + environment: release + permissions: + actions: read + contents: write + + steps: + - name: Checkout exact tagged commit + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # actions/checkout@v7.0.1 + with: + ref: ${{ github.sha }} + fetch-depth: 0 + persist-credentials: false + + - name: Download build outputs + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # actions/download-artifact@v8.0.2 + with: + pattern: openbox-release-assets-* + merge-multiple: true + + - name: Validate annotated tag points at the build commit + run: | + test "$(git cat-file -t "$GITHUB_REF_NAME")" = tag + test "$(git rev-parse "$GITHUB_REF_NAME^{}")" = "$GITHUB_SHA" + + - name: Set up Python + uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # actions/setup-python@v7.1.0 + with: + python-version: "3.12" + + - name: Install signing dependency + run: python3 -m pip install --disable-pip-version-check cryptography==50.0.0 + + - name: Sign and verify release artifacts + env: + OPENBOX_SIGNING_KEY: ${{ secrets.OPENBOX_SIGNING_KEY }} + run: | + set -euo pipefail + test -n "${OPENBOX_SIGNING_KEY:-}" || { + echo "OPENBOX_SIGNING_KEY is required; refusing to publish an unsigned release" >&2 + exit 1 + } + umask 077 + key_file="$(mktemp)" + public_key_file="$(mktemp)" + trap 'rm -f -- "$key_file" "$public_key_file"' EXIT + printf '%s' "$OPENBOX_SIGNING_KEY" > "$key_file" + unset OPENBOX_SIGNING_KEY + for arch in x86_64 aarch64; do + appimage="OpenBox-$arch.AppImage" + [ -f "$appimage" ] || { + echo "missing build artifact: $appimage" >&2 + exit 1 + } + python3 scripts/sign_release.py \ + --key "$key_file" \ + --public-key-out "$public_key_file" \ + --out "$appimage.sig" \ + "$appimage" + cmp -s "$public_key_file" openbox-release.pub || { + echo "OPENBOX_SIGNING_KEY does not match the committed release public key" >&2 + exit 1 + } + sha256sum --check --strict "$appimage.sha256" + python3 scripts/verify_release.py \ + --key openbox-release.pub \ + "$appimage" \ + "$appimage.sig" + done + # The Windows portable zip follows the same ladder: sign, confirm the + # signing key is the committed release key, then verify. + windows_asset="OpenBox-x86_64-windows.zip" + [ -f "$windows_asset" ] || { + echo "missing build artifact: $windows_asset" >&2 + exit 1 + } + python3 scripts/sign_release.py \ + --key "$key_file" \ + --public-key-out "$public_key_file" \ + --out "$windows_asset.sig" \ + "$windows_asset" + cmp -s "$public_key_file" openbox-release.pub || { + echo "OPENBOX_SIGNING_KEY does not match the committed release public key" >&2 + exit 1 + } + sha256sum --check --strict "$windows_asset.sha256" + python3 scripts/verify_release.py \ + --key openbox-release.pub \ + "$windows_asset" \ + "$windows_asset.sig" + # The standalone WebView2 host is the same binary the zip carries, so + # it follows the same ladder rather than shipping unsigned. + host_asset="OpenBox-x86_64-windows-native-host.exe" + [ -f "$host_asset" ] || { + echo "missing build artifact: $host_asset" >&2 + exit 1 + } + python3 scripts/sign_release.py \ + --key "$key_file" \ + --public-key-out "$public_key_file" \ + --out "$host_asset.sig" \ + "$host_asset" + cmp -s "$public_key_file" openbox-release.pub || { + echo "OPENBOX_SIGNING_KEY does not match the committed release public key" >&2 + exit 1 + } + sha256sum --check --strict "$host_asset.sha256" + python3 scripts/verify_release.py \ + --key openbox-release.pub \ + "$host_asset" \ + "$host_asset.sig" + # The ARM64 lane adds the second architecture without touching the + # x86_64 ladder above. It signs, confirms the key, and verifies the + # same way, and it refuses to ship half a channel: the archive, the + # standalone host, and the SBOM travel together. + windows_arm64_asset="OpenBox-arm64-windows.zip" + windows_arm64_host="OpenBox-arm64-windows-native-host.exe" + windows_arm64_sbom="OpenBox-${GITHUB_REF_NAME#v}-arm64-windows-sbom.json" + [ -f "$windows_arm64_asset" ] || { + echo "missing build artifact: $windows_arm64_asset" >&2 + exit 1 + } + [ -f "$windows_arm64_host" ] || { + echo "missing build artifact: $windows_arm64_host" >&2 + exit 1 + } + [ -f "$windows_arm64_sbom" ] || { + echo "missing build artifact: $windows_arm64_sbom" >&2 + exit 1 + } + python3 scripts/sign_release.py \ + --key "$key_file" \ + --public-key-out "$public_key_file" \ + --out "$windows_arm64_asset.sig" \ + "$windows_arm64_asset" + cmp -s "$public_key_file" openbox-release.pub || { + echo "OPENBOX_SIGNING_KEY does not match the committed release public key" >&2 + exit 1 + } + sha256sum --check --strict "$windows_arm64_asset.sha256" + python3 scripts/verify_release.py \ + --key openbox-release.pub \ + "$windows_arm64_asset" \ + "$windows_arm64_asset.sig" + python3 scripts/sign_release.py \ + --key "$key_file" \ + --public-key-out "$public_key_file" \ + --out "$windows_arm64_host.sig" \ + "$windows_arm64_host" + cmp -s "$public_key_file" openbox-release.pub || { + echo "OPENBOX_SIGNING_KEY does not match the committed release public key" >&2 + exit 1 + } + sha256sum --check --strict "$windows_arm64_host.sha256" + python3 scripts/verify_release.py \ + --key openbox-release.pub \ + "$windows_arm64_host" \ + "$windows_arm64_host.sig" + # The macOS bundle follows the same ladder: sign, confirm the + # signing key is the committed release key, then verify. + macos_asset="OpenBox-macos.zip" + [ -f "$macos_asset" ] || { + echo "missing build artifact: $macos_asset" >&2 + exit 1 + } + python3 scripts/sign_release.py \ + --key "$key_file" \ + --public-key-out "$public_key_file" \ + --out "$macos_asset.sig" \ + "$macos_asset" + cmp -s "$public_key_file" openbox-release.pub || { + echo "OPENBOX_SIGNING_KEY does not match the committed release public key" >&2 + exit 1 + } + sha256sum --check --strict "$macos_asset.sha256" + python3 scripts/verify_release.py \ + --key openbox-release.pub \ + "$macos_asset" \ + "$macos_asset.sig" + cp scripts/install.sh install.sh + cp scripts/install.ps1 install.ps1 + cp scripts/install-macos.sh install-macos.sh + cp scripts/verify_release.py verify_release.py + + - name: Build, sign, and verify the emulator definition pack + env: + GH_TOKEN: ${{ github.token }} + OPENBOX_SIGNING_KEY: ${{ secrets.OPENBOX_SIGNING_KEY }} + run: | + set -euo pipefail + test -n "${OPENBOX_SIGNING_KEY:-}" || { + echo "OPENBOX_SIGNING_KEY is required; the definition pack must be signed" >&2 + exit 1 + } + umask 077 + key_file="$(mktemp)" + pack_dir="$(mktemp -d)" + trap 'rm -f -- "$key_file"; rm -rf -- "$pack_dir"' EXIT + printf '%s' "$OPENBOX_SIGNING_KEY" > "$key_file" + unset OPENBOX_SIGNING_KEY + version="${GITHUB_REF_NAME#v}" + python3 scripts/build_defs_pack.py --version "$version" \ + --notes "Emulator definitions for OpenBox $version" --out "$pack_dir" + python3 scripts/sign_release.py --key "$key_file" \ + --out "$pack_dir/community-defs.tar.gz.sig" "$pack_dir/community-defs.tar.gz" + python3 scripts/verify_release.py --key openbox-release.pub \ + "$pack_dir/community-defs.tar.gz" "$pack_dir/community-defs.tar.gz.sig" + # The update channel reads the rolling emulator-defs release (PACK_BASE), not the version release. + gh release view emulator-defs >/dev/null 2>&1 || gh release create emulator-defs \ + --title "Emulator definitions" \ + --notes "Signed emulator definitions. OpenBox checks this release for updates." \ + --latest=false + gh release upload emulator-defs \ + "$pack_dir/community-defs.tar.gz" \ + "$pack_dir/community-defs.tar.gz.sig" \ + "$pack_dir/index.json" --clobber + + - name: Compute release name + id: relname + run: echo "name=OpenBox ${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT" + + - name: Create release and upload signed assets + uses: softprops/action-gh-release@efb35369e0ad2afab669f228072c1b0d510eae64 # softprops/action-gh-release@v3.0.3 + with: + tag_name: ${{ github.ref_name }} + target_commitish: ${{ github.sha }} + name: ${{ steps.relname.outputs.name }} + body_path: docs/RELEASE_NOTES.md + generate_release_notes: false + prerelease: ${{ contains(github.ref_name, '-') }} + fail_on_unmatched_files: true + overwrite_files: false + files: | + OpenBox-x86_64.AppImage + OpenBox-x86_64.AppImage.zsync + OpenBox-x86_64.AppImage.sha256 + OpenBox-x86_64.AppImage.sig + OpenBox-aarch64.AppImage + OpenBox-aarch64.AppImage.zsync + OpenBox-aarch64.AppImage.sha256 + OpenBox-aarch64.AppImage.sig + OpenBox-x86_64-windows.zip + OpenBox-x86_64-windows.zip.sha256 + OpenBox-x86_64-windows.zip.sig + OpenBox-x86_64-windows-native-host.exe + OpenBox-x86_64-windows-native-host.exe.sig + OpenBox-arm64-windows.zip + OpenBox-arm64-windows.zip.sha256 + OpenBox-arm64-windows.zip.sig + OpenBox-arm64-windows-native-host.exe + OpenBox-arm64-windows-native-host.exe.sha256 + OpenBox-arm64-windows-native-host.exe.sig + OpenBox-macos.zip + OpenBox-macos.zip.sha256 + OpenBox-macos.zip.sig + openbox-release.pub + install.sh + install.ps1 + install-macos.sh + verify_release.py + OpenBox-*-sbom.json diff --git a/.gitignore b/.gitignore index 278eb90f..c0f25b94 100644 --- a/.gitignore +++ b/.gitignore @@ -27,20 +27,45 @@ scripts/node_modules/ .coverage .coverage.* htmlcov/ +perf-results.json # Compiled native host binary (built from native_host.c) native_host +native_host.exe # Harness working directory .omo/ +# Generated image/scratch output +output/ + # Maintainer-only signing material (never commit private keys) -# Local-only planning and design docs (kept out of the repo) -PLAN.md -POLISH_PLAN.md -ARCHITECTURE.md -DESIGN.md -PRODUCT.md -COVERAGE.md -PERF.md +# Local-only planning and design docs (kept out of the repo root; +# tracked files with these names under docs/ are unaffected) +/PLAN.md +/POLISH_PLAN.md +/ARCHITECTURE.md +/DESIGN.md +/PRODUCT.md +/COVERAGE.md +/PERF.md +docs/*.local.md +docs/NEXT_UPDATE_PLAN-*.md +.agents/ +.mimocode/ +local/ +# Scratch output from one-off API/CI probes. Ad-hoc curl calls write these +# into the repo root; they must never reach a commit. +/st.json +/r.json +/j.json +/c.json +/run.json +/gl.zip +/ui.log +/w.log + + +# Per-user Claude Code settings (written by the harness, not by the project) +.claude/settings.local.json diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 00000000..e7a77828 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,23 @@ +repos: + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.16.3 + hooks: + - id: ruff + args: [--fix] + - id: ruff-format + - repo: local + hooks: + - id: py-compile + name: py-compile + # ``python`` (not ``python3``): the dev venv provides it on every + # platform, while Windows only ships a broken ``python3`` store alias. + entry: python -B -m py_compile + language: system + types: [python] + pass_filenames: true + - id: trailing-whitespace + name: trailing-whitespace + entry: trailing-whitespace-fixer + language: system + types: [text] + args: [--markdown-linebreak-ext=md] diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..910dfdcb --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,55 @@ +# OpenBox - Agent Conventions + +Read this before writing code. A convention that lives only in a human's head is one prompt away from being ignored. + +## Where things go +- `openbox.py` is the shared core library for data paths, state, and launch helpers, and stays at root. Launch paths are `openbox.sh`, `openbox-native.sh`, and `web_app.py` on Linux; `openbox.cmd`, `openbox.ps1`, and `openbox-native.ps1` on Windows; `OpenBox.app` (built by `scripts/build_macos_app.sh`), `openbox.sh`, and `openbox-native.sh` on macOS, with the native window in `native_host_mac.m`. Platform seams live in `pkg/platform_compat.py` (ADR 0048, ADR 0085). Feature gates that hide platform-specific controls come from its `platform_features()` contract (ADR 0086). +- `handlers/` - HTTP handlers, one module per domain or feature surface (library, imports, media, party, picker, etc.). Shared helpers live in `_shared.py`. +- `pkg/parity/` - parity_* modules (clear names, cohesive cluster) plus the shared `launch_tokens.py` helper. New parity code goes there. Flat `import parity_x` still resolves via the MetaPathFinder in `pkg/parity/__init__.py`; entry points (`openbox.py`, `web_app.py`) must `import pkg.parity` before any flat `parity_*` import. +- `static/` - Frontend JavaScript modules, the search worker, and app.css. +- `themes/` - theme CSS files, each overrides `:root` tokens only. +- `emulator_defs/` - YAML definitions. +- `tests/` - standalone `test_*.py` scripts run via `./run_all_tests.sh` (`python3 -B file.py` style). Tests are feature/module-scoped, not a strict 1:1 mirror; every new runtime module still needs a covering test file. +- `scripts/` - check_tests.py, check_tokens.py, check_v1_contract.py, etc. +- `docs/` - all markdown except README.md and LICENSE. ADR in `docs/adr/`. Superseded plans and old release notes go in `docs/archive/`, never deleted. +- `build/`, `native_host`, `.venv-dev/`, `.coverage` - generated, never commit. + +New integration goes in `pkg/parity/` or `handlers/` as appropriate, never at root. New runtime module must be added to `runtime_modules.txt`. + +## Design system +- Every color, spacing, and typography value comes from a token in `static/app.css` `:root` (`--brand`, `--focus`, `--surface-card`, `--font-heading`, etc.). +- No raw hex in component rules outside `:root`. A new visual value means a new token plus its entry in each theme file. +- Token names are the theme contract. Themes override `:root` and mostly nothing else. +- `scripts/check_tokens.py` enforces this with a ratcheting baseline (ratcheted to 0, originally 625). + +## Non-negotiables +- Runtime stays dependency-free. `pyproject.toml` dev deps are not installed in AppImage. +- v1 route surface is frozen (`scripts/check_v1_contract.py` + `v1_contracts.json`). Drift fails the gate. +- **The whole feature surface is ratcheted** (ADR 0060, `scripts/contracts/`). + Routes, `KNOWN_SETTINGS`, emulator definitions, frontend modules, and the + reliability catalog may **grow freely** and may only **shrink** by moving + the entry to a `retired` ledger with a reason. Each baseline is itself + ratcheted against git, so deleting an entry to hide a removal also fails. + Run `make contracts` after an intentional change; `make ratchets` runs the + gates alone. +- Coverage floors go up, never down. `COVERAGE_FLOOR` and `WEB_APP_FLOOR` in `scripts/check_tests.py` are ratcheted. +- New runtime module must be added to `runtime_modules.txt` (enforced for root `*.py`, `handlers/`, `pkg/state/`, `pkg/parity/`, `routes/`) and have a `test_*.py` (convention, not gate-enforced). +- Every emulator definition must carry `native_exe_windows` and the other keys + in `scripts/check_emulator_defs.py::REQUIRED_KEYS`. +- A test that pins a calendar date while exercising a rolling `*_DAYS` window + needs a `# clock-coupled: ` marker plus an entry in + `scripts/contracts/clock_coupling.json`. + +## Tests +- One `test_*.py` per module, standalone-script style. +- Run all with `./run_all_tests.sh` (walks `tests/` preferentially; root `test_*.py` only run when `tests/` has none). +- Full gate with `make check` → `scripts/check_tests.py`: ruff, runtime_modules, v1_contract, version_sync, frontend, i18n, py_compile, tests under coverage, coverage floors (total + web_app), changed-line, new-module, tokens. + +## Before opening a PR +- `make check` must pass locally. +- Add changelog entry if user-visible. +- Add ADR in `docs/adr/` when changing layout, contracts, or gates. + +## Conventions +- Commit format: conventional, scope is module. +- When adding a UI component, add a token if needed and update all themes. diff --git a/CHANGELOG.md b/CHANGELOG.md deleted file mode 100644 index 55c724db..00000000 --- a/CHANGELOG.md +++ /dev/null @@ -1,527 +0,0 @@ -# Changelog - -All notable changes to OpenBox are documented in this file. - -The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - -## [Unreleased] - -### Hardened - -- Plugin execution is now sandboxed with bubblewrap when `bwrap` is available, running plugins with no home directory or network access. When the sandbox cannot start, enabled plugins are skipped with a warning rather than running unsandboxed; set `OPENBOX_ALLOW_UNSANDBOXED_PLUGINS=1` to allow trusted plugins to run unsandboxed. - -## [1.2.0] - 2026-08-15 - -### Fixed - -- SteamOS AppImages no longer export bundled libraries into the host shell, fixing startup failures where `/bin/bash` could not resolve `rl_print_keybinding`. -- Webhook delivery rejects non-public destinations, pins validated DNS results, disables proxies and redirects, and bounds response reads. -- Library and save backups use private atomic files, reject unsafe archive entries, and protect restore paths against symlinks and archive replacement races. -- Native bridge authorization now requires the exact OpenBox origin, including its dynamic port. -- Gameyfin validates IDs before path construction, contains filesystem operations under the install root, requires HTTPS, and verifies supplied checksums. -- 7z extraction rejects links, operates on a bounded snapshot, and validates the staging tree before promotion. -- Media and document reads enforce approved roots, including symlinked parent checks. -- Environment loading accepts only owner-controlled files and supported keys, and no longer searches the current directory. -- Job and SSE queues now have explicit capacity, expiry, cleanup, and slow-client behavior. - -### Changed - -- Release artifacts require Ed25519 signatures against the pinned production public key. -- Release jobs separate build, provenance attestation, and publication permissions and refuse asset overwrites. -- Build and CI inputs are pinned, the SBOM is generated from the completed AppImage, and Puppeteer 25.7.0 resolves the audited npm dependency issues. -- Plugin catalogs require a pinned digest, HTTPS package URLs, and package checksums. - -## [1.1.0] - 2026-08-15 - -### Fixed - -- Cloud sync: the local-wins merge branch contained a dead condition (`remote_played > local_played` can never be true there), so newer per-field remote values were silently dropped. -- State backup now mirrors the latest committed primary, staged atomically, instead of aliasing the brand-new write. -- Plugin environment filtering fixed a typo (`GAMEFYIN_` -> `GAMEYFIN_`), so correctly spelled Gameyfin variables are stripped from plugin subprocesses. -- Play queue: skip flags recorded while advancing are now written back to state before a valid item is returned, so they can no longer silently disappear. -- Queue `path_exists` now checks the filesystem instead of reporting any nonempty path string as existing. -- OBS status: `recording` now requires a recording file produced within the last two minutes instead of reporting any running OBS process as actively recording. -- Steam and Lutris imports now verify the Flatpak app is actually installed (`flatpak info`) before building a `flatpak run` command. -- IGDB: time-to-beat came from a nonexistent `time_to_beat` field on the games endpoint. It now queries the separate `game_time_to_beats` endpoint and converts its seconds value to hours. -- Gameyfin: catalog requests the provider list once; raw responses are returned open and closed by the caller; the tautological `str(folder) if installed else str(folder)` is gone. -- Job manager: completed futures are now released via a done callback so job bookkeeping cannot accumulate indefinitely. -- Webhook retry: the injected clock now measures the sleep duration and warns when the wall-clock sleep overshoots. -- The emulator-defs YAML fallback parser no longer decides a key is a list based on whether its name ends in "s"; indented values build sequences from the actual shape. -- Native dialog bridge: selected paths are now JSON-quoted strings, so paths with spaces or quotes produce valid JSON and no longer leak the `g_strescape` allocation. -- Ed25519 point decoding now rejects out-of-range coordinates, small-order points, and off-curve values in both `updates.py` and `scripts/verify_release.py`, and checks the canonical scalar before point arithmetic. -- `remove_exclusion` returns the number of removed entries; the API route reports `removed` truthfully. -- `sanitize_settings` no longer iterates a non-dict input into garbage dropped-key lists. -- The 7z archive validator counts a final member even when the listing omits the trailing blank separator. -- Screenshot capture: the previously ignored `window_hint` now selects active-window flags for gnome-screenshot, spectacle, and scrot. - -### Changed - -- `_tdp_args` returns only the argument list; the unused milliwatt value is gone. - -## [1.0.1] - 2026-08-15 - -### Fixed - -- Big Box hybrid mode: platform buttons were emitting a broken `data-bigbox-AppState.platform` attribute that the click handler never matched, so switching platforms did nothing. The attribute now matches the selector. -- IGDB search sent a malformed `&AppState.platform =` query parameter instead of `platform=`, dropping the platform hint from searches. -- Custom-field keys in the details pane were rendered without escaping; a crafted key could inject HTML. Keys and values are now both escaped. -- The session token stayed in the browser address bar after load. It is now scrubbed from history immediately, with deeplink parameters preserved. - -### Changed - -- CSP tightened: `script-src 'self'` without `'unsafe-inline'`, plus `object-src 'none'` and `base-uri 'none'`. -- Requests without a Host header are now rejected instead of bypassing the loopback check. -- The SSE stream now carries the same security headers as every other response. -- The startup URL printed to stdout no longer contains the session token. -- The updater verifies an Ed25519 signature when a release publishes one, and skips with a loud warning while the public key is still the placeholder. -- WebKit rendering defaults changed: dmabuf renderer disabled unless `OPENBOX_ENABLE_DMABUF` is set (fixes silent window failures on AMD GPUs, including Steam Deck), and hardware acceleration switched to on-demand. - -### Hardened - -- The native host now validates full URIs (scheme, host, no userinfo, no control characters) before handing anything to the default handler. -- Reveal-in-folder is restricted to paths under the data directory or home directory. -- The native bridge rejects suspicious payloads instead of evaluating them. -- Plugin catalog downloads now require a valid sha256 checksum; entries without one are refused. -- Plugin subprocess environments are scrubbed of token, password, secret, and API-key variables. -- `before_launch` plugin hooks can no longer swap the launch binary or move the working directory outside the game or data directories; tampered results fall back to the original command. - -### Fixed (launch reliability) - -- AppImage launches now route through the fallback ladder instead of exec-ing the native host directly, and all launch failures are written to `~/.local/share/openbox-game-launcher/openbox-launch.log` instead of vanishing on a double-click. -- The native host writes its own log and the single-instance message is no longer invisible. -- Game Mode with no kiosk browser installed now prints the server URL instead of failing silently. - -### Verification - -- `./run_all_tests.sh`: 47 test files, 0 failures. -- `make check`: lint, compile checks, coverage floors green. -- CI smoke test now covers Big Box platform switching and IGDB search parameters; JS linting runs in CI; Dependabot watches GitHub Actions and npm. - -## [1.0.0] - 2026-08-14 - -### Added - -#### Native window (native-first) - -- `native_host.c` (C, WebKitGTK) renders the same `index.html`, `app.js`, and `app.css` as the web UI in a native window, with smooth scrolling, hardware acceleration, and the app's exact background color. The native host owns server lifecycle, single-instance locking, minimize-to-tray, window geometry persistence, and a fallback ladder (system-browser chrome-less app window, then the default browser) when WebKitGTK is missing or the host fails to start. -- `openbox` and `openbox-native` launchers resolve the share directory across repo, Makefile/Flatpak, and AppImage layouts; `openbox --web` remains the development opt-out to the loopback web UI in a browser. -- Native IPC: `/api/native/*` routes with dynamic capability reporting, plus a JS↔C bridge (`window.openboxNative`) for native dialogs, external opens, reveal-in-file-manager, and window chrome including Big Box fullscreen enter/exit. -- Server-Sent Events: `/api/events` streams session and job events; the job manager emits observer events and polling remains the fallback. SIGTERM/SIGINT teardown stops running sessions and drains work before exit. - -#### State and API contract - -- Schema v5 adds a host-owned `ui_state` block; existing games, settings, playlists, and history migrate untouched. -- The v1 API contract is frozen: `contracts.py` + `v1_contracts.json` (46 routes) with `scripts/check_v1_contract.py` wired into the coverage gate and CI, and `test_v1_aliases.py` pinning legacy aliases. - -#### Batch metadata auto-match - -- One "Auto-match library" action binds every unmatched game whose title exactly matches the LaunchBox Games Database, replacing the one-game-at-a-time dialog flow. Only exact normalized-title hits qualify, so ambiguous titles are left unmatched for manual confirmation. - -#### Frontend - -- Grid cover-grouping by aspect ratio (default), persisted as `cover_grouping` and toggleable from the library header. -- The topbar regroups into Library, Actions, and Tools zones; the empty-state import surface covers every storefront path. -- The dialog manager traps focus and closes on Escape. -- Scroll-lag fixes: rAF-coalesced grid rendering, backdrop blur removed from the base scroll path, hover-gated cover transitions, and a constrained workspace grid row. - -#### Tests and documentation - -- `test_native_ipc.py` and `test_sse.py` cover the native bridge and the event stream. -- The screenshot pipeline now waits for Big Box and game-detail views to settle and enriches fixture metadata (ESRB, max players, play time, region, play mode, wiki links). -- `docs/adr/0001-native-host.md` and `docs/native-host-contract.md` document the host/server split and the IPC contract. - -### Changed - -- The 260-method `Handler` class is split into capability mixins under `handlers/` (data, library, settings, media, metadata, imports, sessions, extensions, health, emulators, native). `web_app.py` drops from 3,755 to 1,628 lines and keeps server plumbing, shell/SSE serving, bootstrap, and lifecycle; each mixin's methods resolve the live `web_app` namespace at call time, so response bytes and route wiring are unchanged. Authentication is centralized in dispatch and route tables resolve module-qualified method names. -- The removed Tk interface no longer ships: AppImage dependencies drop python3-tk/tcl/tk in favor of WebKitGTK 4.1 and GTK dev headers, and the AppImage installs the `handlers/` package. CI compiles the native host (libwebkit2gtk-4.1-dev) on pull requests so C build breaks fail early, and release CI ships the compiled host and the logo asset. -- Packaging installs the runtime modules and `handlers/` (tracked in `runtime_modules.txt`), with SHARE resolution for Makefile/Flatpak layouts. - -### Fixed - -- The AppImage build now creates the `handlers/` directory so the handler split installs correctly. -- The `gen_sbom` usage example version, stale 0.9.0 release-date and localization claims, and SECURITY.md support rows (the 1.0.x row is pinned in tests). - -### Verification - -- Ran `./run_all_tests.sh`: 47 test files, 0 failures. -- Ran `make check`: lint, compile, tests, coverage floors (59.0% total vs 55.0% floor; 65.0% `web_app.py` vs 44.0% floor), and the v1 contract check all pass. -- UI smoke test (`scripts/ui_smoke.sh`) boots a real server, drives the grid, and asserts the Tools menu still opens under every stock theme with no page errors. - -## [0.9.0] - 2026-08-12 - -### Added - -#### LaunchBox media catalog and archive manuals - -- Full LaunchBox Games Database media downloads beyond covers, backgrounds, and screenshots: box backs, box spines, 3D boxes, clear logos, fanart, banners, title screens, cart fronts, cart backs, discs, and advertisement flyers. The metadata dialog, bulk download dialog, media audit, artwork gallery, image groups, and the auto-import setting all accept the expanded type set. -- Manuals are not in the LaunchBox feed, so the manual option now pulls a PDF or text manual (`MANUAL_SUFFIXES = (".pdf", ".txt")`) out of the game's own archive. The finder ranks `manual.pdf` first, then shortest and most recent candidates, caps the candidate list at 8 to stay safe on pathological archives, copies the winner next to the game's media, and never blocks a sync on a bad archive. When nothing is found, the game records a `manual: no manual in this archive` note that the UI surfaces. -- Metadata search maps the app's own platform names to their LaunchBox spellings: `Game Boy` to `Nintendo Game Boy`, `PlayStation` to `Sony Playstation`, `GameCube` to `Nintendo GameCube`, `Xbox` to `Microsoft Xbox`, and 22 more (26 aliases total, including `PC` to `Windows` and `DOS` to `MS-DOS`). Exact-name searches now rank platform-correct results first. -- The LaunchBox Games Database dialog shows library coverage facts (matched games, match ratio, and per-field media counts) once the local database is ready. -- Batch auto-match: one "Auto-match library" action binds every unmatched game whose title exactly matches the database, instead of matching one game at a time. Only exact normalized-title hits qualify, so ambiguous or partial titles are left unmatched for manual confirmation rather than guessed onto the wrong record. - -#### Desktop window - -- The desktop UI opens in a chrome-less app window by default instead of a browser tab: a Chromium-family browser opens with `--app=`, Firefox falls back to a separate window, and with no compatible browser the default browser is used. The Settings panel controls this with an "Open the UI in" option, and `--app-window` / `--no-app-window` override it at launch. - -#### Engineering gates - -- `make check` runs ruff lint, compile checks over every runtime module and test, the full test suite under coverage, and coverage floors in one command. CI enforces it on push, pull requests, and a weekly schedule. Baselines are recorded in [docs/development/COVERAGE.md](docs/development/COVERAGE.md): 55% total and 44% for `web_app.py`, and the floors fail the build when coverage regresses. -- A version-sync check (`scripts/check_version_sync.py`) fails when `updates.py` disagrees with the README badge, metainfo, PARITY.md, the bug report template, or CHANGELOG, so a release can no longer ship with a stale version claim. -- Dev-only tooling (ruff, coverage) is pinned in `pyproject.toml` and lives in `.venv-dev`; the runtime app still has zero third-party dependencies. - -#### HTTP layer - -- The two monolithic dispatch chains (a 613-line GET and a 195-line POST if-chain) became a route registry in `routes.py`: 103 GET and 124 POST entries (including v1 aliases; the pre-alias base was 79 GET + 95 POST, plus 24 GET and 29 POST alias entries), each a named `Handler` method (5 entries use dotted `handlers.native.*` specs), with zero behavior change during the mechanical extraction. The contract-frozen v1 surface is 46 routes (`v1_contracts.json`). -- Structured errors in `api_errors.py`: every failure carries a stable machine code (`BAD_REQUEST`, `GAME_NOT_FOUND`, `MEDIA_NOT_FOUND`, `ROUTE_NOT_FOUND`, `MEDIA_JOB_RUNNING`, `STATE_UNAVAILABLE`, ...) and a per-request id that appears in the UI and the diagnostic log. POST handlers re-raise `ApiError` unchanged and convert legacy `ValueError`s into `400 BAD_REQUEST` instead of leaking them to the generic 500 path. -- A versioned `/api/v1` surface aliases the stable routes; legacy paths keep working for older clients. -- The library payload is gzip-compressed once per state change and served with a `private, no-cache` ETag, so polls get 304s when nothing changed: 5,000 games serve in about 2 ms at 638 KB instead of 13.8 MB (measured in [docs/development/PERF.md](docs/development/PERF.md)). -- Settings saves now drop unknown keys against the settings key registry (`settings_schema.py`) with a diagnostic warning, instead of persisting junk from a stale client. - -#### Reliability - -- Rolling state snapshots: the state store keeps the last 5 committed states as timestamped JSON copies under `library.json.snapshots/`, rotated on every commit. `/api/state/recover` gained `dry_run` (preview what recovery would do) and `snapshot` (restore a named point in time) modes; snapshot names are strictly validated. -- Background jobs keep a bounded history of the last 50 finished jobs, exposed at `/api/jobs` and rendered in the Library Audit dialog as a jobs panel. -- Graceful shutdown: Ctrl-C / SIGTERM now stops running sessions and drains webhooks in the `finally` block instead of stranding game processes. -- The browser's session poll runs every 1s while a game is running and every 10s when idle, cutting idle wakeups on handhelds. -- Log redaction now covers RetroAchievements key and `client_secret` shapes in addition to tokens, passwords, and authorization headers. -- A local-only diagnostic report packager (`/api/diagnostic`) bundles redacted logs, system facts, and the version for pasting into issues. - -#### Frontend - -- `index.html` shrank from 3,117 lines to a 485-line shell; the JS and CSS live in `static/app.js` and `static/app.css`, served over `/static/*` with ETag caching and a filename whitelist. The server injects the token into the asset URLs so script and stylesheet loads authenticate. -- All 30 browser state globals moved into one `AppState` object; browser-only preferences (library view, image group, badge visibility, sidebar sections) persist through `localStorage` with a versioned key. -- Server errors surface in a persistent, dismissible banner with a "Copy details" action that includes the request id, and `window.onunhandledrejection` routes internal errors there. -- The `api()` helper maps 45 stable routes to `/api/v1` through a single `API_V1` map, so a future v2 rename is a one-object change. -- Accessibility: every dialog is `aria-modal`, toasts and lifecycle messages are live regions, the label floor rose from 11px to 12px, and the library heading leads the workspace. -- The interface language selector now ships English-only with an honest note; the five partial translations were removed until real localization lands. -- `scripts/ui_smoke.sh` boots a real server and drives the grid with puppeteer, asserting the grid renders, selection works, and no page errors fire. - -#### Release and supply chain - -- `scripts/gen_sbom.py` produces a CycloneDX 1.4 SBOM of the runtime modules, bundled data files, and the bundled Python stdlib. -- `scripts/sign_release.py` (Ed25519, maintainer-only) and `scripts/verify_release.py` (pure-stdlib RFC 8032 verification) sign and check release artifacts; `test_release_signing.py` proves the round trip and tamper detection. -- `scripts/release.sh` chains version sync, the make check gate, AppImage build, SBOM, checksum, optional signing, and a release notes draft, stopping before the human publish step. -- `scripts/gen_api_docs.py` generates the API v1 contract page from the live route tables. - -#### Documentation - -- `docs/reliability.md`: a 23-scenario edge case catalog (truncated state, full disk, orphaned sessions, wrong credentials, huge archives, broken symlinks, 20k-game libraries) with expected behavior and verification status per row. -- `SUPPORT.md`: the platform and runtime matrix plus reporting guidance. -- `TRIAGE.md`: response targets and the triage flow. - -### Fixed - -- Duplicate media cleanup now scans every media field, not just covers and backgrounds. -- Latent undefined names in `web_app.py` that would have crashed their code paths on first use: `automation.DEFAULT_ATTEMPTS`/`DEFAULT_TIMEOUT` (module referenced instead of the imported names) and `contained_path`/`read_limited` (used but not imported from `backend_io`). -- A stale `Request` import in the media catalog downloader. -- Lint debt across the gate rule set: default-argument calls (`home=Path.home()`), loop-variable closures, unused variables, lambda assignments, missing `check=` on `subprocess.run`, shebangs, and import placement. - -### Verification - -- Ran `./run_all_tests.sh`: 45 test files, 0 failures. -- Ran `make check`: lint, compile, tests, and coverage gates all pass (56% total, 44% `web_app.py`, floors recorded in [docs/development/COVERAGE.md](docs/development/COVERAGE.md)). -- UI smoke test (`scripts/ui_smoke.sh`) boots a real server and drives the grid with no page errors. -- `python3 scripts/check_version_sync.py` passes at 0.9.0. -- Perf bench at 5,000 games: gzip library 1.9ms / 638KB vs 13.7ms / 13.8MB plain (see [docs/development/PERF.md](docs/development/PERF.md)). - -## [0.8.2] - 2026-08-12 - -### Added - -- Added Web UI surfaces for the persistent play queue, normalized game tags, Notification Center, and signed webhook settings. -- Added `/api/queue`, `/api/tags`, `/api/notifications`, and `/api/webhooks` contracts with bounded state, secret redaction, and destination validation. - -### Fixed - -- Box art now keeps its natural aspect ratio in the library grid, Big Box Stage, and CoverFlow views instead of being force-cropped into a single ratio, so games with non-standard artwork (for example SNES titles) display uncropped. Title-only covers keep the standard portrait box. - -### Verification - -- Ran `./run_all_tests.sh`: 39 test files, 0 failures. - -## [0.8.1] - 2026-08-09 - -### Fixed - -- Launching a game with no launch command and no matching platform profile now fails with a clear error before anything runs, instead of silently spawning a process that exits on the spot and reporting a normal session end. Games whose file is not executable get the same message. -- The web UI now shows the real outcome of a failed session. An immediate exit with a non-zero code reports "Session failed" with the exit code and a hint to check the launch command and emulator install, instead of the generic "Play time and history were saved" toast. -- Emulator installs no longer re-add the Flathub remote when it already exists, which previously made `flatpak remote-add --user` exit non-zero and abort the install. When a remote add or install does fail, the error now includes flatpak's own message instead of a bare non-zero status. - -### Verification - -- Added regression coverage for the emulator install path and ran the full suite. -- Ran `./run_all_tests.sh`: 36 test files, 0 failures. - -## [0.8.0] - 2026-08-07 - -### Added - -- Handheld performance profiles: per launch-profile TDP limits applied via `ryzenadj` at launch, with an optional restore limit when the session ends, gated by an `Apply handheld performance limits` setting (auto / always / off). `auto` applies only on Steam Deck / Bazzite game mode and battery-powered handhelds; a missing `ryzenadj` or permission error logs a warning and never blocks a launch. - -## [0.7.0] - 2026-08-02 - -### Fixed - -- Backup restore now merges archived settings back into `library.json` instead of a sidecar file the app never reads, so settings survive a restore. -- Restoring an older backup over a newer library is refused unless explicitly forced, and restore re-validates symlink parents after `mkdir` to close a zip-slip window. -- Save backups resolve relative `save_paths` against the game instead of the process working directory, and reject symlinked backup directories. -- Cloud sync propagates local deletions and resolves per-game conflicts by `last_played` instead of a stale global timestamp, so newer progress, ratings, and favorites win. -- Big Box exit no longer runs the app-exit `shutdown_commands`; only entering Big Box runs its configured commands. -- The media audit endpoint tolerates null media fields instead of returning a 500. -- Plugin updates are atomic with rollback on failure, and reinstalling a removed plugin no longer comes back disabled. -- The plugin `library` hook is TTL-cached so `/api/library` no longer blocks up to 5 seconds per plugin per request. -- Storefront catalog launch commands substitute their real identifiers (`{lutris_id}`, Steam, and Heroic). -- EmuMovies media searches URL-encode their query parameters. -- Bezel downloads extract into staging and swap atomically, so a corrupt archive no longer destroys the working bezel set. -- Emulator scan folders with `auto_update` enabled are re-scanned by the auto-import worker. -- `read_limited` rejects negative or huge `Content-Length` headers up front. -- Replaced backend jobs clean up their futures so the job manager does not grow unbounded. -- The state-store backup is written from the temp file before `os.replace`, so a crash cannot pair a fresh primary with a stale backup. -- Pre-release and build-suffixed release tags parse cleanly and are never treated as available updates. -- `.env` files strip inline comments while preserving `#` inside unquoted values. -- `openbox://` deeplinks reject foreign hosts and fail clearly when no server port is known instead of silently hitting a dead port. -- Folder-based session tracking matches path boundaries so `MyGame2` no longer matches `MyGame`. -- The native UI launches games detached from the launcher's process session. -- `rom_quality_score` no longer crashes on a missing or unreadable ROM. -- Duplicate-media cleanup prefers keeping the copy inside the allowed media roots. -- The metadata database temp zip is cleaned up when the download fails. -- User edits to stock themes are preserved instead of being reverted on every Themes dialog open. -- Gamescope guest detection also recognizes `STEAM_GAMESCOPE_RESTRICTED` sessions. -- IGDB token failures and Gameyfin provider fallbacks now surface readable errors. -- Flatpak packages include the diagnostic logging module. - -### Changed - -- The test runner now continues past failures and reports a per-file pass/fail summary instead of stopping at the first error. -- Tests no longer leak environment variables (`GITHUB_TOKEN`, `RA_*`, `OPENBOX_DATA_DIR`) into the shell or a real home directory. -- Documented `OPENBOX_DATA_DIR` and the RetroAchievements and EmuMovies environment variables in the README and `.env.example`. -- Refreshed user-facing documentation, package metadata, and installation guidance. - -### Verification - -- Added regression tests for every fix in this release. -- Ran `./run_all_tests.sh`: 32 test files, 0 failures. - -## [0.6.0] - 2026-07-30 - -### Added - -- LaunchBox-style advanced search in the Web UI, including field terms, quoted values, status filters, and negative terms. -- Ordered manual playlists with parent grouping, notes, membership editing, and keyboard-friendly reorder controls, alongside existing filter playlists. -- Game context actions, Ctrl/Shift multi-selection, configurable status badges, richer platform/category/playlist detail panes, related-game reasons, artwork galleries, and per-game launch profile overrides. -- Backup archive listing, manifest summaries, restore actions, expanded artwork groups, and metadata fields for controller support, disc count, portable games, and broken entries. - -### Changed - -- State persistence now uses schema migrations, stable game identities with legacy aliases, last-known-good recovery, process-safe transactions, atomic writes, and corruption preservation. -- Long-running backend jobs now expose bounded state, retries, cancellation, durations, and replacement protection. Request bodies, downloads, archives, media responses, save restores, backup restores, and plugin execution use bounded and validated paths. -- Packaging uses the runtime module manifest and stricter import and artifact checks. Flatpak support documentation and release validation were refreshed. - -### Verification - -- Added focused API and launch-profile regression tests. -- Ran `./run_all_tests.sh`, Python compilation, JavaScript syntax checks, diff validation, and a Chromium smoke test covering the new search, selection, context, playlist, backup, settings, media, and detail workflows. - -## [0.5.0] - 2026-07-30 - -### Added - -- Steam Game Mode guest support for Steam Deck, Bazzite, and similar gamescope sessions. Big Box opens fullscreen while Steam retains Input, Quick Access Menu, and TDP controls. -- Settings can remove all Steam-imported library entries at once. This keeps game files and media on disk. -- Local rotating diagnostic logs with a Settings copy button. Request and auto-import failures include timestamps, uncaught crashes include stack traces, and tokens and passwords are redacted. - -### Fixed - -- Session playtime and Gameyfin installs now use stable library identities after deletes or reorders. Gameyfin downloads stage safely and stream to disk. -- Storefront and update failures now return readable errors instead of dropped connections or uncaught errors. -- AppImage desktop launches now work through Gear Lever and desktop menus, and the AppImage instructions show the correct `--native` command for the Tk interface. -- Artwork downloads now use the active LaunchBox CDN instead of the retired image URL that returns a redirect loop. - -## [0.4.10] - 2026-07-30 - -### Fixed - -- AppImage desktop launches via Gear Lever and similar integrators: stop leaking bundled `LD_LIBRARY_PATH` into host `xdg-open`/browsers, open the UI with a clean-env `xdg-open`, use a unique `io.openbox.GameLauncher` desktop/icon id, and let `openbox://` start fall through to a normal server boot when no instance is running - -### In case you missed it - -If you jumped from an older build and skipped the last two releases: - -- **0.4.8 — Steam Game Mode guest:** `--game-mode` opens Big Box fullscreen under gamescope on Steam Deck, Bazzite, and similar handheld images. Guest sessions are detected automatically, Big Box opens without a manual deeplink, and Steam keeps Input, QAM, and TDP while OpenBox tags its UI and non-Steam launches for Steam's overlay path. -- **0.4.9 — library reliability:** session playtime and Gameyfin installs update the correct library entry after deletes or reorders, Gameyfin downloads stage then replace and stream to disk, Lutris CLI failures return JSON errors, and empty release checksum files fail cleanly. - -## [0.4.9] - 2026-07-30 - -### Fixed - -- Session completion now credits playtime and history by stable game identity, so deleting another library entry while a game is running no longer updates the wrong title -- Gameyfin installs resolve library updates by Gameyfin ID instead of a stale array index, keep existing installs until a download succeeds, and stream downloads to disk instead of buffering whole files in memory -- Gameyfin provider lists ignore malformed non-object entries; empty passwords on connection tests no longer overwrite a stored credential for the probe -- Lutris import and storefront catalog routes return JSON errors when the Lutris CLI fails or times out, instead of dropping the HTTP connection -- Empty release checksum files raise a clear update error instead of an IndexError - -## [0.4.8] - 2026-07-30 - -### Added - -- Steam Game Mode guest support: `--game-mode` opens Big Box fullscreen under gamescope on Steam Deck, Bazzite, and similar handheld images, detects guest sessions, and best-effort sets `STEAM_GAME` window props on the OpenBox UI and non-Steam launches while leaving Steam Input, QAM, and TDP controls with Steam -- `parity_gamescope.py` module with portable gamescope detection, kiosk browser launching, and window tagging helpers -- Deck/Bazzite nested gamescope emulation harness (`scripts/emulate_deck_gamemode.sh`, `test_gamescope_deck_emu.py`) for verifying guest behavior on desktop hosts - -### Changed - -- Big Box mode now opens automatically when running under a gamescope session, no manual deeplink needed - -## [0.4.7] - 2026-07-28 - -### Fixed - -- AppImage desktop integration now starts the bundled application through `AppRun` -- GitHub update checks, plugin catalog downloads, AppImage zsync metadata, and repository links now use the canonical `vindeckyy/OpenBoxGL` location after the repository rename - -## [0.4.6] - 2026-07-26 - -### Fixed - -- Authenticated POST API requests now reject non-object JSON and request-shape type errors with JSON `400` responses instead of dropping the connection - -### Changed - -- Public identity, support, trademark, and notice documentation consistently distinguishes OpenBox Game Launcher from the Openbox window manager -- The repository default branch, CI triggers, issue links, and contributor documentation now use `master` - -### Tests - -- Added real-HTTP API boundary regressions for authorization, validation, exception mapping, partial settings updates, secret sanitization, and lifecycle errors - -## [0.4.5] - 2026-07-24 - -### Added - -- Filter presets, explorer facets, Big Box quick-switch presets, and import exclusions -- `openbox://` deep links, keyboard launcher support, granular library backups, and restore rotation -- Process tracking modes, optional IGDB metadata search, and YAML emulator definition packs - -### Fixed - -- Session restart handling, launcher menu formatting, ROM paths containing spaces, malformed tracking values, and unsafe backup archive paths -- Responsive and keyboard-accessible library controls, lazy media loading, and reduced-motion behavior - -## [0.4.4] - 2026-07-24 - -### Fixed - -- Settings persistence on partial saves, storefront-only POSTs, JSON error responses on catalog routes, Gameyfin install recovery, premium route auth, and session polling guards - -## [0.4.3] - 2026-07-24 - -### Fixed - -- Settings save merges omitted keys from existing state. Partial storefront POSTs no longer wipe watch folders or storefront auto-import flags. -- Storefront save posts only storefront fields instead of spreading empty Settings form values -- GET `/api/storefront/catalog` and `/api/gameyfin/providers` return JSON errors instead of dropping the connection -- Gameyfin install worker reports error state on invalid `library_id`; UI times out after ~60s instead of polling forever -- Premium GET routes require authorization; `/api/update` catches malformed release JSON -- Session poll no longer overlaps `refresh()`; `openReader` guards missing documents - -## [0.4.2] - 2026-07-24 - -### Fixed - -- Gameyfin installs run in a background worker; the UI polls `/api/gameyfin/install/status` instead of blocking the server -- Storefront Gameyfin settings use dedicated `storefront*` form fields; saving Settings no longer touches Gameyfin credentials - -## [0.4.1] - 2026-07-24 - -### Fixed - -- Update check no longer fails when a release omits a separate `.sha256` file; GitHub asset digests are used instead -- Settings update check returns readable errors instead of a browser network failure -- Top bar and library header scale cleanly at any browser zoom level - -## [0.4.0] - 2026-07-24 - -### Added - -- Gameyfin storefront integration: browse owned library, import, install/uninstall on demand in desktop and Big Box -- Library and Big Box filters for installed-only vs all owned games -- Ludusavi and Hoard save-tool hooks from the game detail pane (when installed on PATH) - -## [0.3.0] - 2026-07-24 - -### Added - -- LaunchBox Premium-equivalent features without a subscription: custom fields, ESRB metadata and filters, list view, platform categories, and bulk edit wizard -- Drag-and-drop import zone with multi-emulator install chooser and ROM version ranking -- Steam trailer and GOG media auto-download, RetroAchievements 7z scanning, and richer achievement stats -- Big Box hybrid scoped search, attract mode, startup video, bundled media packs, and controller prompt packs -- Localization for English, Spanish, German, French, and Portuguese -- Xbox 360, loose arcade, and Vita3K title resolution import helpers -- `parity_premium.py` module and premium API routes - -### Changed - -- Expanded PARITY.md to mark premium workflows as done and free -- AppImage, Makefile, Flatpak manifest, and packaging tests include `parity_premium.py` - -## [0.2.0] - 2026-07-24 - -### Added - -- Storefront Manager with catalog browse, uninstalled import, and startup auto-import -- Game Discovery Center with curated local discovery lists -- Big Box Stage, Hybrid, and CoverFlow layouts with gamepad mapping, pause overlay, and screensaver -- Parity modules for import, media hygiene, saves, integrations, and storefront workflows -- ScummVM, RPCS3, and Vita3K dedicated import endpoints -- RetroAchievements Big Box filters, pause access, and emulator launch injection -- EmuMovies and Bezel Project integration hooks -- OBS recording auto-attach on session close -- MAME community high score export and import -- Platform documents pane and sidebar section management -- Searchable settings, welcome wizard, and library health audit -- Expanded API and parity integration test coverage - -### Changed - -- Expanded LaunchBox parity matrix with Linux equivalents for premium workflows -- AppImage and Makefile packaging updated for new modules - -## [0.1.0] - 2026-07-23 - -### Added - -- Initial public release -- Web UI and native Tk UI -- Steam, Heroic, and Lutris import -- LaunchBox Games Database sync and media scraping -- Emulator profiles with Flathub auto-install -- RetroAchievements integration -- Save discovery and versioned backups -- Session tracking and plugin hooks -- AppImage, Flatpak manifest, and Makefile install targets - -[0.8.2]: https://github.com/vindeckyy/OpenBoxGL/releases/tag/v0.8.2 -[1.2.0]: https://github.com/vindeckyy/OpenBoxGL/compare/v1.1.0...v1.2.0 -[1.1.0]: https://github.com/vindeckyy/OpenBoxGL/compare/v1.0.1...v1.1.0 -[1.0.1]: https://github.com/vindeckyy/OpenBoxGL/compare/v1.0.0...v1.0.1 -[1.0.0]: https://github.com/vindeckyy/OpenBoxGL/releases/tag/v1.0.0 -[0.9.0]: https://github.com/vindeckyy/OpenBoxGL/releases/tag/v0.9.0 -[0.8.1]: https://github.com/vindeckyy/OpenBoxGL/releases/tag/v0.8.1 -[0.8.0]: https://github.com/vindeckyy/OpenBoxGL/releases/tag/v0.8.0 -[0.7.0]: https://github.com/vindeckyy/OpenBoxGL/releases/tag/v0.7.0 -[0.6.0]: https://github.com/vindeckyy/OpenBoxGL/releases/tag/v0.6.0 -[0.5.0]: https://github.com/vindeckyy/OpenBoxGL/releases/tag/v0.5.0 -[0.4.10]: https://github.com/vindeckyy/OpenBoxGL/releases/tag/v0.4.10 -[0.4.9]: https://github.com/vindeckyy/OpenBoxGL/releases/tag/v0.4.9 -[0.4.8]: https://github.com/vindeckyy/OpenBoxGL/releases/tag/v0.4.8 -[0.4.7]: https://github.com/vindeckyy/OpenBoxGL/releases/tag/v0.4.7 -[0.4.6]: https://github.com/vindeckyy/OpenBoxGL/releases/tag/v0.4.6 -[0.4.5]: https://github.com/vindeckyy/OpenBoxGL/releases/tag/v0.4.5 -[0.4.4]: https://github.com/vindeckyy/OpenBoxGL/releases/tag/v0.4.4 -[0.4.3]: https://github.com/vindeckyy/OpenBoxGL/releases/tag/v0.4.3 -[0.4.2]: https://github.com/vindeckyy/OpenBoxGL/releases/tag/v0.4.2 -[0.4.1]: https://github.com/vindeckyy/OpenBoxGL/releases/tag/v0.4.1 -[0.4.0]: https://github.com/vindeckyy/OpenBoxGL/releases/tag/v0.4.0 -[0.3.0]: https://github.com/vindeckyy/OpenBoxGL/releases/tag/v0.3.0 -[0.2.0]: https://github.com/vindeckyy/OpenBoxGL/releases/tag/v0.2.0 -[0.1.0]: https://github.com/vindeckyy/OpenBoxGL/releases/tag/v0.1.0 diff --git a/CLAUDE.md b/CLAUDE.md new file mode 120000 index 00000000..47dc3e3d --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md deleted file mode 100644 index 693b49bf..00000000 --- a/CONTRIBUTING.md +++ /dev/null @@ -1,169 +0,0 @@ -# Contributing to OpenBox - -Thank you for your interest in contributing to OpenBox. This document describes the workflow we expect for issues, pull requests, and local development. - -## Code of conduct - -Participation in this project is governed by [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md). By contributing, you agree to uphold those standards. - -## Before you start - -1. Search [existing issues](https://github.com/vindeckyy/OpenBoxGL/issues) to avoid duplicate work. -2. For substantial changes, open an issue first so maintainers can confirm direction. -3. Keep pull requests focused. One logical change per PR is easier to review. - -## Development setup - -### Requirements - -- Linux environment -- Python 3.10 or newer -- Git - -### Clone and run - -```bash -git clone https://github.com/vindeckyy/OpenBoxGL.git -cd OpenBoxGL -python3 web_app.py -``` - -Optional local configuration can be loaded from `~/.env` or a project `.env` file. See `.env.example`. Never commit secrets, tokens, or personal credentials. - -### Native window - -```bash -python3 web_app.py --no-browser # loopback server (native host spawns this) -``` - -Build the native host with `make native-host`, then run `./openbox-native.sh`. - -## Testing - -Run the full verification gate before submitting a pull request. This covers lint, compile checks, the full test suite, and coverage floors: - -```bash -make check -``` - -The gate needs dev-only tooling (ruff and coverage) in `.venv-dev`, which `make check` creates on first run. The runtime app itself stays dependency-free. - -Run the plain test suite without coverage when iterating: - -```bash -./run_all_tests.sh -``` - -Run an individual module when iterating: - -```bash -make test-one TEST=test_catalog.py -``` - -Check that the version in `updates.py` matches every published spot: - -```bash -make version-check -``` - -Packaging checks: - -```bash -./build_appimage.sh -python3 test_packaging.py -``` - -All tests must pass on CI before a PR can be merged. - -## Coding guidelines - -- Match the style of surrounding code in each module. -- Prefer focused changes over broad refactors unless the refactor is the purpose of the PR. -- Keep user-facing strings clear and neutral. -- Do not commit ROMs, BIOS images, API tokens, or personal library data. -- Document non-obvious behavior with brief comments only where needed. - -### Python - -- Target Python 3.10+ syntax and standard library usage already present in the codebase. -- Avoid adding dependencies unless they are required and approved in the PR discussion. -- Use explicit error messages for user-facing validation failures. -- Keep the lint gate green: `.venv-dev/bin/ruff check .` must pass. The gate rule set lives in `pyproject.toml`. -- Any PR that changes `web_app.py` must add or update a test that exercises the touched route. -- Do not lower the coverage floors in `scripts/check_tests.py`; raise them when coverage improves. - -### Web UI (`index.html`) - -- Preserve existing UI patterns and accessibility conventions. -- Test dialog flows, Big Box navigation, and settings persistence manually when touching frontend logic. - -## Pull request process - -1. Fork the repository and create a feature branch from `master`. -2. Make your changes with clear commits. -3. Run `make check` (or `./run_all_tests.sh` when coverage is not available locally). -4. Open a pull request using the provided template. -5. Describe the problem, the solution, and the test plan. -6. Address review feedback promptly. - -### Commit messages - -Use concise, descriptive commit messages in the imperative mood: - -``` -Add storefront startup auto-import setting -Fix emulator dependency check for missing flatpak -docs: update parity matrix for OBS attach workflow -``` - -## Plugins - -Plugins are optional Python packages installed in the user plugins directory. Each plugin requires: - -- `plugin.json` with `id`, `name`, `version`, `entry`, and `hooks` -- An entry Python module that reads JSON from stdin and writes JSON to stdout - -Supported hooks: - -| Hook | Purpose | -| --- | --- | -| `library` | Observe or transform library state | -| `before_launch` | Adjust launch payload before execution | -| `after_session` | React to completed or stopped sessions | - -Example manifest: - -```json -{ - "id": "example-plugin", - "name": "Example Plugin", - "version": "1.0.0", - "entry": "main.py", - "hooks": ["library"] -} -``` - -Bundled catalog entries live in `plugins/catalog.json` and `plugin_catalog.py`. - -## Documentation - -Update relevant docs when behavior changes: - -- [README.md](README.md) for user-facing overview changes -- [PARITY.md](PARITY.md) for capability status changes -- [CHANGELOG.md](CHANGELOG.md) for release-visible changes -- [openbox.metainfo.xml](openbox.metainfo.xml) and [SECURITY.md](SECURITY.md) when releases or support policy change - -Parity-related modules include `parity_import.py`, `parity_premium.py`, `parity_storefront.py`, `parity_discovery.py`, `parity_media.py`, `parity_saves.py`, `parity_integrations.py`, `parity_gamescope.py`, and the other `parity_*.py` helpers. - -## Documentation site - -The public documentation is published at [openboxgl.github.io](https://openboxgl.github.io/). Site source and content changes belong in the [OpenBoxGL Pages repository](https://github.com/OpenBoxGL/openboxgl.github.io); run `bun install --frozen-lockfile` and `bun run test` there before pushing. - -## Security issues - -Do not open public issues for security vulnerabilities. Follow [SECURITY.md](SECURITY.md). - -## Licensing - -By contributing, you agree that your contributions will be licensed under the [GNU Affero General Public License v3.0](LICENSE). diff --git a/Makefile b/Makefile index 8dd352f6..8b85f904 100644 --- a/Makefile +++ b/Makefile @@ -8,25 +8,66 @@ LICENSEDIR = $(PREFIX)/share/licenses/openbox PYTHON_SOURCES = $(shell sed '/^[[:space:]]*#/d;/^[[:space:]]*$$/d' runtime_modules.txt) -DATA_FILES = index.html openbox.svg openbox.metainfo.xml LICENSE assets/openbox-logo.png -STATIC_FILES = static/app.js static/app.css +# Interpreter for the dev-only contract/ratchet targets. Overridable so the +# same Makefile works on Windows, where `python3` is a Store alias (ADR 0048). +PYTHON ?= python3 + + +DATA_FILES = index.html openbox.svg openbox.metainfo.xml LICENSE assets/openbox-logo.png assets/placeholder-cover.svg assets/mame/scanlines.png assets/shaders/scanlines.slangp +STATIC_FILES = $(wildcard static/*.js) $(wildcard static/*.css) $(wildcard static/*.svg) +LOCALE_FILES = $(wildcard locales/*.json) NATIVE_HOST = native_host -.PHONY: install uninstall appimage check version-check dev-venv test-one native-host +.PHONY: install uninstall appimage check version-check dev-venv test-one native-host native-host-macos macos-app contracts ratchets + +# Feature-regression ratchets (ADR 0060). Regenerates the frozen contracts in +# scripts/contracts/ after an intentional surface change. Run this only when a +# removal is deliberate; the gate then diffs the result against git. +contracts: + $(PYTHON) -B scripts/check_routes_contract.py --update + $(PYTHON) -B scripts/check_settings_contract.py --update + $(PYTHON) -B scripts/check_emulator_defs.py --update + $(PYTHON) -B scripts/check_frontend_modules.py --update + $(PYTHON) -B scripts/check_reliability_catalog.py --update + $(PYTHON) -B scripts/check_state_schema.py --update + $(PYTHON) -B scripts/check_api_contract.py --update + @echo "Contracts regenerated. Review the diff, then commit it with the change." + +# Run only the feature-regression ratchets, without the coverage stage. +ratchets: + $(PYTHON) -B scripts/check_routes_contract.py + $(PYTHON) -B scripts/check_settings_contract.py + $(PYTHON) -B scripts/check_emulator_defs.py + $(PYTHON) -B scripts/check_frontend_modules.py + $(PYTHON) -B scripts/check_reliability_catalog.py + $(PYTHON) -B scripts/check_clock_coupling.py + $(PYTHON) -B scripts/check_exec_modes.py + $(PYTHON) -B scripts/check_state_schema.py + $(PYTHON) -B scripts/check_api_contract.py + native-host: gcc -O2 native_host.c -o $(NATIVE_HOST) $$(pkg-config --cflags --libs webkit2gtk-4.1) +# The macOS native host (WKWebView) and the app bundle that ships it. +native-host-macos: + bash scripts/build_native_host_macos.sh + +macos-app: + bash scripts/build_macos_app.sh + dev-venv: python3 -m venv .venv-dev .venv-dev/bin/pip install --disable-pip-version-check -r requirements-dev.txt -# Full verification gate: lint, compile, tests, coverage floors. +# Full verification gate: ruff, runtime_modules, v1_contract, version_sync, +# frontend, i18n, py_compile, tests under coverage, coverage floors +# (total + web_app + changed-line + new-module), tokens. # Dev-only dependencies live in .venv-dev; the runtime app stays dep-free. check: dev-venv python3 scripts/check_tests.py -# A single test file, e.g. `make test-one TEST=test_saves.py`. +# A single test file, e.g. `make test-one TEST=tests/test_saves.py`. test-one: python3 -B $(TEST) @@ -56,6 +97,7 @@ install: native-host install -Dm644 plugins/catalog.json $(DESTDIR)$(SHAREDIR)/plugins/catalog.json for f in $(DATA_FILES); do install -Dm644 "$$f" "$(DESTDIR)$(SHAREDIR)/$$f"; done for f in $(STATIC_FILES); do install -Dm644 "$$f" "$(DESTDIR)$(SHAREDIR)/$$f"; done + for f in $(LOCALE_FILES); do install -Dm644 "$$f" "$(DESTDIR)$(SHAREDIR)/$$f"; done for f in themes/*.css; do install -Dm644 "$$f" "$(DESTDIR)$(SHAREDIR)/$$f"; done install -Dm644 openbox.desktop $(DESTDIR)$(DESKTOPDIR)/io.openbox.GameLauncher.desktop install -Dm644 openbox.svg $(DESTDIR)$(ICONDIR)/io.openbox.GameLauncher.svg diff --git a/PARITY.md b/PARITY.md deleted file mode 100644 index 5842f14d..00000000 --- a/PARITY.md +++ /dev/null @@ -1,106 +0,0 @@ -# OpenBox Parity Matrix - -OpenBox tracks LaunchBox feature parity for Linux environments. For contribution and release workflow, see [CONTRIBUTING.md](CONTRIBUTING.md) and [CHANGELOG.md](CHANGELOG.md). The latest release is **v1.2.0**, the security and SteamOS compatibility release described in the CHANGELOG. - -> **Legal disclaimer:** OpenBox is an independent open-source project and is NOT affiliated, associated, authorized, endorsed by, or in any way officially connected with LaunchBox or Unbroken Software, LLC. Reference to LaunchBox features is solely for software compatibility tracking and open-source parity comparison. - -Acceptance source: [LaunchBox product overview](https://www.launchbox-app.com/about), [Windows changelog](https://www.launchbox-app.com/about/changelog), and [plugin overview](https://feedback.launchbox-app.com/help/articles/1605395-plugins-overview). - -`done` means the workflow is usable end to end on Linux. `partial` means a deliberate Linux equivalent exists but LaunchBox’s Windows/premium surface is broader or unavailable. `missing` means no usable equivalent yet. - -## Capability matrix - -| Capability | Status | Acceptance check | -|---|---|---| -| Local library, metadata editing, search, filters, favorites | done | Changes persist and remain searchable | -| Platform and collection navigation | done | Counts and filters update from real games | -| Local executable and ROM import | done | Multi-disc M3U generation, multi-platform folder import, and emulator recommendations on import work | -| Steam installed-game import | done | Manifests are discovered across Steam libraries | -| Steam metadata and artwork | done | Selected game downloads real store data and media | -| Steam launching | done | Imported App ID launches through Steam or its URI | -| Epic, GOG, and Amazon imports through Heroic | done | Installed manifests import and launch through Heroic | -| EA, Ubisoft, and Xbox imports | done | Lutris/Heroic catalog import with EA, Ubisoft, and Xbox/Game Pass tagging; Xbox native PC packages remain unavailable on Linux | -| MAME and FinalBurn full-set imports | done | DAT/XML metadata classifies and imports merged, split, and non-merged sets | -| LaunchBox Games Database matching | done | Official daily database sync, local matching, and selected metadata/media downloads work | -| Full LaunchBox media catalog | done | Box backs, spines, 3D boxes, clear logos, fanart, banners, title screens, carts, discs, and advertisement flyers download from the database alongside covers, backgrounds, and screenshots; manuals are user-supplied paths because the LaunchBox metadata feed ships no manual images | -| Media manager and image groups | done | Grid image groups, extended artwork groups, audits, bulk downloads, duplicate cleanup, region priority, and download limits work | -| Emulator profiles and per-game commands | done | Commands launch without a shell | -| Emulator installation and automatic configuration | done | Install, Update All, open emulator, recommend-on-import, and dependency checks work | -| Archive extraction before launch | done | ZIP uses safe built-in extraction; 7z/RAR use installed 7z | -| Startup and shutdown screens | done | Screens follow the launched process from start through its actual exit | -| Play counts, time, and session history | done | Sessions persist with duration and exit status | -| Additional apps, versions, and documents | done | Extras launch from each game's detail pane | -| Manuals and reader | done | Reader toolbar with page navigation, spread layout, and light/dark themes for PDFs and documents | -| Save management | done | Discover, scan, retention limits, versioned backups, backup-on-close, and restore work | -| RetroAchievements | done | Account, matching, progress, badges, Big Box filters, pause access, and emulator injection work | -| Video, music, and screenshot playback | done | Multi-category videos, library BGM, video/BGM mix, capture, and gallery work | -| Playlists, auto-filters, and saved filters | done | Platform, view, search rules, ordered manual members, parent playlists, and notes save, apply, update, and delete | -| Big Box controller-first navigation | done | Stage, hybrid, and CoverFlow layouts; filter/sort/RA filters; pause overlay; screensaver launch | -| Steam Game Mode / gamescope guest | done | `--game-mode` opens Big Box; guest detection; Steam launches keep Input; non-Steam windows get best-effort STEAM_GAME props | -| Themes and per-platform themes | done | Five stock CSS themes ship with the Web UI; import, persist, apply live, and open-folder access work | -| Plugin manager and extension API | done | Local packages install and run hooks; curated community catalog is bundled | -| Backups and restore | done | The web UI lists archives, shows manifests, restores selected archives, and creates a pre-restore safety copy | -| Library audit and missing-file checks | done | Files, provider-aware duplicates, media, extras, saves, and emulator configuration are audited | -| Linux packaging and updates | done | AppImage build, Flatpak manifest, Makefile install/uninstall, desktop entry, and verified update mechanism | -| Welcome wizard and first-run setup | done | Staged setup wizard with media limits and persistent import queues | -| Searchable settings pages | done | Settings dialog filters fields by name and related terms | -| Session history toggle and viewer | done | Play sessions can be disabled and are browsable from the History menu | -| Arrange-by scrollbar | done | Large sorted views expose a jump bar with group markers | -| Delete media on game removal | done | Removing a game can optionally delete its associated media files | -| Save backup on session close | done | Automatic save backups can run when a game session ends | -| Game progress automation | done | Playing and paused progress values can be updated from play time and idle days | -| Open emulator and install all | done | Installed emulators can be launched directly and bulk-installed from Flathub | -| Shutdown progress and force close | done | Exiting with running games shows shutdown status and supports force close | -| Ctrl+, settings shortcut | done | Opens Settings from the keyboard | -| Platform documents | done | Platform detail pane and document storage API work per platform | -| Sidebar filter section management | done | Hidden sidebar sections apply live from Settings | -| CoverFlow / 3D box models | done | CoverFlow layout with CSS jewel-case depth styling | -| Game Discovery Center | done | Curated local discovery lists launch from the Discovery menu | -| EmuMovies / Bezel Project | done | Bezel downloads and EmuMovies credential/download hooks work with licensed accounts | -| Storefront Manager / uninstalled auto-import | done | Unified storefront dialog, catalog browse, uninstalled import, and startup auto-import work | -| Gameyfin self-hosted library | done | Catalog import, background install with status polling, install/uninstall on demand, desktop + Big Box owned/installed filters | -| Ludusavi / Hoard save tools | done | Optional CLI hooks from game detail when binaries are on PATH | -| ScummVM / RPCS3 / Vita3K library import | done | Dedicated import endpoints scan common emulator libraries | -| MAME community high scores | done | Local high-score discovery plus export/import bundles for sharing | -| OBS recording attach | done | Latest OBS recording auto-attaches on session close; manual attach remains available | -| Premium cloud sync | done | Mounted-folder statistics sync is the Linux equivalent to LaunchBox Premium cloud stats | -| Custom fields and bulk metadata wizard | done | Define custom fields in Settings, edit per game, and bulk update selected games | -| ESRB ratings filter and metadata | done | ESRB from LaunchBox database imports, sidebar filter, list view column, and bulk edit | -| List view and library columns | done | Grid/list toggle with sortable list columns including ESRB and progress | -| Drag-and-drop import wizard | done | Drop zone prompts for folder path with multi-emulator install chooser | -| Platform categories | done | Sidebar category navigation groups platforms by family | -| ROM version ranking on import | done | Duplicate ROM groups rank USA/world releases and skip betas on import | -| Steam trailer and GOG media download | done | Detail pane downloads Steam trailers and Heroic GOG artwork automatically | -| RetroAchievements 7z scanning and rich profile | done | ZIP and 7z ROM hashing plus beaten/mastered stats in achievement pane | -| Big Box hybrid scoped search | done | Hybrid mode exposes platform-scoped search while browsing | -| Attract mode and startup video | done | Separate attract delay, optional Big Box startup video, and screensaver launch | -| Bundled media packs (free) | done | Platform logos, controller prompts, and badge packs apply without a subscription | -| Localization | partial | Interface is English-only; the five partial translations were removed until real localization lands | -| Big Box shutdown apps on mode switch | done | Configurable commands run when entering Big Box (not when leaving) | -| Xbox 360 and loose arcade import | done | default.xex folder scan and Hypseus/Singe loose file import | -| Vita3K title resolution | done | Title IDs resolve to readable game names on import | -| Advanced search syntax | done | Field terms such as `platform:PC`, quoted values, status terms, and negative terms filter the live grid | -| Context actions and multi-selection | done | Right-click actions plus Ctrl/Shift range selection expose launch, favorite, progress, playlist, edit, and remove actions | -| Configurable status badges | done | Settings controls favorite, install, media, save, progress, storefront, achievement, rating, and hardware badges | -| Collection and related details | done | Platform, category, and playlist detail panes show stats, quick actions, rich related-game reasons, and artwork galleries | -| Per-game launch profile overrides | done | A game can select an installed emulator profile without changing the platform default | -| Persistent play queue | done | Queue controls add, remove, advance, reorder, and resolve missing games | -| Game tags and tag filtering | done | Tags normalize, count, filter, and bulk-edit through the Web UI | -| Notification Center | done | Persistent deduplicated notifications expose unread state and read/clear controls | -| Signed webhook automation | done | Event subscriptions, HTTPS validation, bounded retries, test delivery, and secret redaction work locally | - -All LaunchBox Premium-equivalent workflows above are included in OpenBox without a subscription. OpenBox sets `premium_features_free: true` in settings and ships bundled media packs without a license gate. - -## Intentionally not replicated on Linux - -These LaunchBox features have no practical Linux equivalent and are documented rather than emulated: - -| LaunchBox feature | OpenBox decision | -|---|---| -| Windows shell replacement | Not applicable on Linux desktop environments | -| LEDBlinky / cabinet LED control | Use external Linux arcade I/O tools instead | -| Teknoparrot arcade launcher | Use Lutris/Wine launch profiles for supported titles | -| Native Xbox PC package scanning | Use Heroic/Lutris/Xbox Cloud entries instead | -| Bundled proprietary media packs | Replaced by free bundled media packs in OpenBox | -| LaunchBox Premium account cloud library | Replaced by mounted-folder sync plus local backups | -| LaunchBox online theme storefront | Replaced by local CSS theme import and open-folder workflow | diff --git a/README.md b/README.md index 896e0b1a..63c584cb 100644 --- a/README.md +++ b/README.md @@ -11,15 +11,17 @@

- Local-first game library and launcher for Linux + Local-first game library and launcher for Linux, Windows, and macOS

- License: AGPL-3.0 - Python 3.10+ - Release v1.2.0 - LaunchBox parity matrix - CI + License: AGPL-3.0 + Python 3.10+ + Release v1.19.1 + CI + Linux, Windows, and macOS +
+ Latest stable: v2.0.0

@@ -59,9 +61,9 @@ ## Quick Start -1. **Install.** Grab the [latest AppImage](https://github.com/vindeckyy/OpenBoxGL/releases/latest), or run from source with `python3 web_app.py` (Python 3.10+). -2. **Open the UI.** `openbox` opens a native WebKitGTK window by default, and falls back to a chrome-less app window (then your default browser) when WebKitGTK is missing. `openbox --web` skips the native window and opens the loopback web UI in a browser; from source, `python3 web_app.py` also opens the browser automatically with the token in the URL. To open the UI manually, append the token from the data directory, e.g. open `http://127.0.0.1:PORT/?token=$(cat ~/.local/share/openbox-game-launcher/server.token)`. -3. **Import games.** Click **Import Folder** and point at a directory of `.sh` files, or **Import Steam** to scan your installed games. +1. **Install.** On Linux grab the [latest AppImage](https://github.com/vindeckyy/OpenBoxGL/releases/latest); on Windows download `OpenBox-x86_64-windows.zip` from the same release, extract it, and run `OpenBox\scripts\install.ps1` (the installer verifies the signed release before installing; requires Python 3.10+); on macOS download `install-macos.sh` from the same release and run it (it verifies the signed `OpenBox-macos.zip` and installs `OpenBox.app` into `~/Applications`). The macOS bundle carries its own Python, and the first launch asks you to allow it in System Settings > Privacy & Security > Open Anyway. You can also run from source with `python3 web_app.py` on Linux/macOS (`python web_app.py` on Windows, Python 3.10+). +2. **Open the UI.** `openbox` opens a native window (WebKitGTK on Linux, WebView2 on Windows, WKWebView on macOS) and falls back to a chrome-less app window, then your default browser, when the host runtime is missing. `openbox --web` skips the native host and opens the loopback UI in a browser app window (plain tab fallback); from source, `python3 web_app.py` also opens the browser automatically with the token in the URL. Steam Deck / gamescope kiosk: `python3 web_app.py --game-mode` or `openbox --web --game-mode` (Linux only). To open the UI manually, append the token from the data directory, e.g. open `http://127.0.0.1:PORT/?token=$(cat ~/.local/share/openbox-game-launcher/server.token)`. Treat token-bearing URLs as secrets; prefer the `X-OpenBox-Token` header for scripts and never paste or share the URL. +3. **Import games.** Click **Import folder** and point at a directory of `.sh` files, or **Import Steam** to scan your installed games. 4. **Press PLAY.** Sessions, play time, and history are tracked automatically. For ROMs, emulators, Big Box, RetroAchievements, and everything else, see [Getting started](https://openboxgl.github.io/getting-started/) and [Installation](https://openboxgl.github.io/install/). @@ -70,20 +72,20 @@ For ROMs, emulators, Big Box, RetroAchievements, and everything else, see [Getti ## Overview -OpenBox Game Launcher is an open-source game library manager and launcher built for Linux. It puts PC games, storefront libraries, ROM collections, arcade sets, and emulator workflows in one searchable catalog with advanced search, ordered playlists, artwork galleries, session tracking, save and library backups, and launch profiles. +OpenBox Game Launcher is an open-source game library manager and launcher for Linux, Windows, and macOS. It puts Steam, Heroic (Epic/GOG/Amazon), Lutris, Faugus, Gameyfin, ROM folders, ScummVM, RPCS3, Vita3K, and Eden Switch collections, and local executables in one searchable catalog with advanced search, ordered playlists, artwork galleries, session tracking, save and library backups, launch profiles, and controller-ready Big Box mode. On Windows it also imports installed GOG, EA app, and Ubisoft Connect games natively. No account, no vendor cloud lock-in, and no telemetry; optional mounted-folder sync stays under your control. OpenBox Game Launcher is unrelated to [Openbox](https://openbox.org/), the open-source Linux window manager. The projects have different maintainers, codebases, and purposes. -OpenBox provides one UI over two hosts: +OpenBox provides one UI over two use-case entry points (the native host renders the same `web_app.py` loopback UI): -| Host | Entry point | Best for | -| --- | --- | --- | -| Native window | `openbox` or `openbox-native` | Default desktop use; one WebKitGTK window renders the full UI | -| Web UI | `openbox --web` or `python3 web_app.py` | Development and debugging; full feature set, REST API, Big Box mode | +| Entry point | Use when | +| --- | --- | +| `openbox` or `openbox-native` | Default desktop use; one native window (WebKitGTK on Linux, WebView2 on Windows) renders the full UI | +| `openbox --web` or `python3 web_app.py` | Development and debugging; full feature set, REST API, Big Box mode | -Library data is stored locally at `~/.local/share/openbox-game-launcher/library.json`. Set the `OPENBOX_DATA_DIR` environment variable to use a different data directory. +Library data is stored locally at `~/.local/share/openbox-game-launcher/library.json` (`%LOCALAPPDATA%\openbox-game-launcher\library.json` on Windows, `~/Library/Application Support/openbox-game-launcher/library.json` on macOS). Set the `OPENBOX_DATA_DIR` environment variable to use a different data directory. -> **Independence notice:** OpenBox Game Launcher is an independent open-source project. It is not affiliated with LaunchBox, Unbroken Software, LLC, or the Openbox window manager project. LaunchBox and Big Box are trademarks of Unbroken Software, LLC. See [DISCLAIMER.md](DISCLAIMER.md). +> **Independence notice:** OpenBox Game Launcher is an independent open-source project. It is not affiliated with LaunchBox, Unbroken Software, LLC, or the Openbox window manager project. LaunchBox and Big Box are trademarks of Unbroken Software, LLC. See [DISCLAIMER.md](docs/DISCLAIMER.md). --- @@ -108,43 +110,190 @@ Consider OpenBox if you: - Need Flathub-aware emulator install/update flows - Want RetroAchievements, save backups, session history, and Big Box in one app -The full capability matrix with acceptance checks lives in [PARITY.md](PARITY.md). +OpenBox also runs on Windows, installed from a signed portable zip and rendered in a WebView2 window, and on macOS 11 or newer, installed from a signed universal app bundle and rendered in a WKWebView window. The Linux-specific machinery — AppImage and Flatpak packaging, gamescope/Game Mode, XDG desktop entries, and Flatpak-aware emulator installs — stays Linux-only; the macOS bundle hides those controls and reports emulators without a verified macOS build as not installed. + +The full capability matrix with acceptance checks lives in [PARITY.md](docs/PARITY.md). --- ## Features +### Now on macOS (1.19.0) + +**OpenBox runs natively on Apple Silicon and Intel Macs.** `OpenBox-macos.zip` is +one universal app bundle with its own Python, a WKWebView window, the tray, +native dialogs, `openbox://` deeplinks, and the same verified updater as Linux +and Windows. Your library lives in +`~/Library/Application Support/openbox-game-launcher`, process tracking comes +from libproc, and emulator definitions now say which of them actually publish a +macOS build. The bundle is ad-hoc signed rather than notarized, so the first +launch asks you to allow it in **System Settings > Privacy & Security**. Linux +and Windows behavior is unchanged. + +### A Clearer, Deeper Library (1.18.0) + +**A calmer first run.** One Add games menu replaces the four top-bar import +buttons; Setup Center leads with a six-step quick path; **F1** opens Help & +shortcuts; the empty library is one illustrated card; the details pane adds a +three-step onboarding checklist and a **Jump back in** hero. **The library as a +whole** can rename or delete a platform and move its media, queue media +downloads durably, and verify and selectively restore save backups. +**Sources and plugins go further** with PICO-8, MSU-1, WHDLoad, and RPCS3 +imports, plugin capability services, ordered metadata providers, and Steam +achievements plus MAME high scores. **Deeper controls** cover per-platform +emulator overrides, Big Box video and audio, 3D cassette and jewel-case models, +and a remappable reader. + +### Every Launcher (1.17.0) + +**Your other Windows launchers.** Import installed GOG, EA app, and Ubisoft +Connect games from the Tools menu or the Storefront Manager, then launch them +from OpenBox; re-importing matches each store id instead of adding a duplicate. +**Game Night decks** save players, time, and a filter as a named setup, and +every draw returns a seed you can replay. **What your library cost** records a +price paid per game and shows totals by currency plus cost per hour for the +games you actually played. **Server notices** for cloud sync, launch checks, +definition updates, and webhooks are now translated in all five locales. +ScreenScraper lookups are fixed. + +### Ready to Launch (1.16.1) + +**Launch readiness everywhere.** Games that will not launch are marked on the +grid, groups of games that share one fix get one button, a missing-file group +relinks from one place, and each game can pick its own RetroArch core — the +check says when its results change. **Every system out of the box**: launch +definitions for 18 more systems, each RetroArch definition naming its core +once, shipped as a signed definition pack with every tag; shared disc images +ask which system they belong to. **Live and fast**: one live connection per tab +keeps busy tabs from losing updates, and a search no longer pushes the panels +below it. The release also stops a stale window overwriting a neighboring +game, makes Steam removals undoable, and adds Grant access for Flatpak +emulators without folder access. + +### Make It True (1.16.0) + +**Launch Readiness** runs the Launch Doctor over the whole library in a +background job and groups the results by cause: "RetroArch is not installed — +412 games", with one Install action for the group and a link to each game. It +reuses the single-game checks unchanged and caches the `flatpak` probes per +emulator, so a 20,000-game library is checked in seconds. **Restore Preview** +shows what restoring a backup would remove, bring back and overwrite, and +whether your settings would change, before the Restore button exists. The +release also closes a path-traversal read, a high-score write traversal and a +plugin-sandbox gap, stops playtime landing on the wrong game, makes a failed +launch say so, and corrects four claims from the 1.15.0 notes (ADR 0064). + +### Signed Packs & Live Library (1.15.0) + +The **emulator-definition update channel** installs a signed community +definition pack into the per-user data directory, shadowing the bundled set +without ever overwriting it: the fetch is verified with the release Ed25519 +key, a signature failure raises a security notification instead of a silent +skip, and a definition you have edited locally is never clobbered. The +**Time Machine compare** tab diffs the library between two journal points +read-only — added, removed, and per-field changed rows with true totals even +when the page truncates — and every game **Story** card gains a PNG export +rendered client-side, with no new endpoint. **Settings > About** reports the +version, platform, data folder, and whether OpenBox is in its native window or +a browser tab. A sixth stock **High Contrast** theme redeclares every token +against a maximum-contrast palette, one **reduced-motion switch** whose gate is an +AND over a reviewed allow-list, with `motionMs()` the only motion read (corrected +in 1.16.0), and **Windows install and removal are symmetric**: +`scripts/uninstall.ps1` removes exactly what `install.ps1` creates. + +### Library Intelligence (1.14.0) + +**Plugins 2.0** brings a catalog browser with one-click install/update, +per-plugin checksum-bound trust, Android-style permission prompts, settings +forms generated from the plugin manifest, a `library_source` importer hook, an +`events` lifecycle hook, and palette commands. **Effortless metadata** matches +and downloads artwork automatically after import (providers opt-in), with a +thumbnail chooser on search results and a "Time to beat" fact card. +**Backlog management** adds a personal layer to every game: Unplayed/Playing +statuses, 0–5 star ratings, manual playtime logging, dated notes, and a +one-time "Mark as Playing?" prompt. **Couch-ready Big Box** boots straight +into Big Box (`--bigbox` flag or Settings checkbox), adds a d-pad on-screen +keyboard, and copies Steam grid artwork for bridged games. The **library +health score** grades your collection 0–100 across five dimensions with a +fix-all queue, undo, Artwork Doctor, missing-file repair, and duplicate +merging. **Game DNA** is fully offline smart search: a Title | Smart toggle +powered by BM25 and a 151-concept lexicon in five languages, with +explanation chips and "More like this". + +### Living Library (1.12.0) + +Saved Backlog Radio searches pin as named **smart collections** that +re-evaluate live — the shelf stores the question, not the answer. Every game +gains a **Story** tab narrating its journey: added, first played, longest +session, milestones, progress, and captured Moments. The launch sheet gains +per-game `KEY=value` environment overrides and an optional confirm-before- +launch step, and an opt-in **weekly automatic backup** keeps a bounded archive +history with a last-run line in Settings. The SQLite read model is +opt-in via `OPENBOX_ENABLE_SQLITE_READ=1`, auto-enabled at 5,000+ games +unless opted out (`=0`); JSON stays the source of truth, and the command palette ranks recently +used games and actions first. + +### Every Second Counts (1.11.0) + +Quick Resume and Moments keep save-state-aware progress, captures, and session +recaps close to the game you were playing. Record That adds replay-buffer clips +and deterministic highlight reels. The Time Machine provides journal-backed +history and safe as-of inspection, while Backlog Radio, the explainable natural +query bar, and the command palette make a large backlog easier to navigate. + +The Arcade Room and Museum mode turn the library into a controller-friendly +showcase, with an optional local kiosk PIN convenience boundary. Household +records provide opt-in, server-free challenges and leaderboards. Steam Bridge +can preview and manage non-Steam shortcuts, and ES-DE `gamelist.xml` imports are +reviewable before they change the library. + ### Library & Discovery -One catalog for Steam, Heroic, Lutris, Gameyfin, ROM folders, ScummVM, RPCS3, Vita3K, and local executables. Advanced search, collections, playlists, tags, bulk edits, custom fields, ESRB filtering, list view, and Surprise Me random selection. +One catalog for Steam, Heroic, Lutris, Faugus, Gameyfin, ROM folders, ScummVM, RPCS3, Vita3K, Eden, arcade (MAME), and local executables. Advanced search, collections, playlists, tags, bulk edits, custom fields, ESRB filtering, list view, **"What should I play?" smart picker** (time, mood, familiarity, players) plus Surprise Me random selection, and a pan/zoomable **Library Constellation** relationship graph. **Keyboard and gamepad navigation** across the grid and list (arrows/Home/End/Page, `f` favorite, Escape clear, configurable controller map), **hash routing** so refresh and shared links restore platform/playlist/preset/query/selection/sort, sortable list-view columns with persisted direction, screenshot lightbox with prev/next/zoom, cover skeleton loading, and **Mood Match adaptive cover theming** that tints accents from the selected game. ### Metadata & Media -LaunchBox Games Database sync (covers, backgrounds, screenshots, box backs, spines, 3D boxes, clear logos, fanart, banners, title screens, carts, discs, and advertisement flyers), IGDB search, Steam/GOG media, EmuMovies, Bezel Project, bundled media packs (platform logos, controller prompts, badges), duplicate cleanup, region priority, download limits. +LaunchBox Games Database sync (covers, backgrounds, screenshots, box backs, spines, 3D boxes, clear logos, fanart, banners, title screens, carts, discs, and advertisement flyers), IGDB search, Steam/GOG media, EmuMovies, Bezel Project, **ScreenScraper** per-ROM-hash scraping (credentials in `~/.env`, 1 req/s throttle, 30-day cache), optional **SteamGridDB** artwork search/apply and bulk matching for covers, backgrounds, clear logos, icons, and banners (credentials in `~/.env`, local cache), bundled media packs (platform logos, controller prompts, badges), duplicate cleanup, region priority, download limits. ### Emulators & Launching -Auto-detect emulators on `$PATH`, Flathub install/update, YAML definition packs, archive extraction (ZIP/7z/RAR), safe tokenized commands, per-game launch overrides. Example emulator profile: +Auto-detect emulators on `$PATH`, Flathub install/update, YAML definition packs, archive extraction (ZIP/7z/RAR), safe tokenized commands, per-game launch overrides, BIOS SHA1 drift detection in Launch Doctor. Example emulator profile: ``` SNES = retroarch -L /usr/lib/libretro/snes9x_libretro.so "{path}" ``` -Tokens: `{path}`, `{name}`, `{rom_name}`, `{app_id}`, `{heroic_app_id}`, `{lutris_id}`. +Tokens: `{path}`, `{name}`, `{rom_name}`, `{app_id}`, `{heroic_app_id}`, `{lutris_id}` — see the [command-tokens reference](https://openboxgl.github.io/reference/command-tokens/) for the full placeholder list. ### Sessions & Saves -Play time tracking, session history, save discovery (Steam Cloud, RetroArch, PCSX2, PPSSPP, RPCS3, Dolphin, Cemu), versioned backups with retention limits, Ludusavi/Hoard CLI hooks, RetroAchievements (hardcore, beaten, mastered, badge injection). +Play time tracking, session history, save discovery (Steam Cloud, RetroArch, PCSX2, PPSSPP, RPCS3, Dolphin, Cemu), versioned backups with retention limits, Ludusavi/Hoard CLI hooks, RetroAchievements (hardcore, beaten, mastered, badge injection). OpenBox launcher trophies are a separate local, deterministic trophy case evaluated from library and history. + +### Play Insights + +Local-first playtime analytics with a 366-day activity heatmap (levels 0–4), current and longest play streaks, 30-day play momentum, and top platforms/genres. **OpenBox Wrapped** prints your year in games (playtime, streaks, progress, busiest month), the **History Timeline** tab groups sessions by day, and the **Mastery Map** dashboard breaks the library into per-platform and per-decade progress bars with RetroAchievements columns. The **launcher trophy case** evaluates deterministic OpenBox milestones from local library/history data; these awards are separate from RetroAchievements. Computed entirely locally with zero telemetry. ### Big Box & Handhelds -Fullscreen Stage/Hybrid/CoverFlow layouts, gamepad navigation, screensaver/attract mode, optional startup video, library BGM, Steam Game Mode guest (`--game-mode`), localization (English; more languages planned). +Fullscreen Stage/Hybrid/CoverFlow layouts, gamepad navigation, screensaver/attract mode, optional startup video, library BGM, **video snaps** (looping gameplay videos in Stage mode with debounce and BGM ducking), **Game Night party mode** (couch-multiplayer queue, spinning wheel, up-next strip, persistent rounds), Steam Game Mode guest (`--game-mode`, Linux only), gamescope presets (Steam Deck, Steam Deck (HD), 1080p, 1440p, 4K, integer scale, stretch, borderless) plus **custom user-defined presets with per-game override** (Linux only), MangoHud performance overlay toggle (Linux only), controller bench with live gamepad SVG visualization, localization (English, Spanish, German, French, Portuguese). + +### Scale & Backups + +Optional SQLite read model (opt-in via `OPENBOX_ENABLE_SQLITE_READ=1`, auto-enabled at 5,000+ games unless opted out (`=0`); JSON stays the source of truth) with canonical name-substring search and JSON-equivalent facets for large libraries, wired into `/api/v2/library/search`. Backup diff API (`GET /api/v2/backup/diff`) to compare current library against archives. Visual chip builder for smart collection filter presets. **Library export** to JSON or CSV with platform/playlist scopes, shareable-by-construction field projection, and automatic newest-10 rotation. Statistics sync remains available through the mounted-folder workflow; the opt-in causal transport provides validated catalog sync with conflict review and tombstones, while the legacy whole-library publish/pull routes return `LIBRARY_SYNC_UNAVAILABLE` before mutation. **LaunchBox XML migration** import (`POST /api/v2/import/launchbox/preview` and `/apply`). **Manual/shelf entries** for games without local files. + +For the review-first workflows, use the UI rather than copying paths or records by hand: + +- **LaunchBox XML:** open the LaunchBox migration panel, choose one bounded XML export, review the parsed entries, path mappings, exclusions, and emulator mappings, then apply the preview. A changed file or library invalidates the preview and requires a new review; XML never supplies executable commands. +- **Catalog sync:** configure a mounted folder in Settings, enable **library catalog sync**, preview incoming changes, choose alternatives for conflicts, apply the reviewed plan, and publish local changes explicitly. Paths, launch commands, credentials, media, and play statistics remain local; apply requires the reviewed plan and publish is an explicit separate step. +- **Shelf entries:** use **Add shelf entry** for a title without a local file. Shelf records can be edited, filtered, and exported, then converted to a playable entry only after selecting an existing absolute file. + +The SQLite read model is opt-in via `OPENBOX_ENABLE_SQLITE_READ=1`, auto-enabled at 5,000+ games unless opted out (`=0`); JSON stays the source of truth. The release gate covers 10,000 and 20,000-game libraries; behavior and performance beyond 20,000 games are exploratory. ### Extensibility -REST API with token auth, Python plugins (`library`, `before_launch`, `after_session` hooks), local CSS themes with live reload, HMAC-signed webhooks, mounted-folder statistics sync, `openbox://` deep links. +REST API with token auth, Python plugins (`library`, `before_launch`, `after_session`, `command`, `library_source`, `events` hooks), local CSS themes with instant apply, HMAC-signed webhooks, mounted-folder statistics sync, `openbox://` deep links. -[Full feature list → Documentation](https://openboxgl.github.io/) +[Full feature list in the documentation](https://openboxgl.github.io/) --- @@ -195,6 +344,21 @@ REST API with token auth, Python plugins (`library`, `before_launch`, `after_ses

+

+ Constellation: library relationship graph with series, developer, and genre edges +

+ +

+ + OpenBox Library Constellation graph showing connected game nodes + +

+

Screenshots use real LaunchBox metadata and cover art. Regenerate with python3 scripts/capture_readme_screenshots.py (requires Node.js 22.12+; run cd scripts && npm ci first for Puppeteer).

@@ -203,12 +367,22 @@ REST API with token auth, Python plugins (`library`, `before_launch`, `after_ses ## Installation +| Method | Best for | Notes | +| --- | --- | --- | +| AppImage (installer) | Linux desktop, Steam Deck, handhelds, immutable systems | Architecture-matched installer that verifies the signed release and built-in verified updater; installs to `~/.local/bin` | +| Windows (installer) | Windows 10/11 (x86_64) | Signed portable zip, verified updater, WebView2 window; installs to `%LOCALAPPDATA%\OpenBox` | +| macOS (installer) | macOS 11+ (Apple Silicon and Intel) | Signed universal app bundle with its own Python, verified updater, WKWebView window; installs to `~/Applications` | +| AppImage (manual) | Offline or custom path | `chmod +x` and run, no install step | +| Flatpak | Sandboxed installs | `flatpak-builder` from manifest | +| Source | Development, patching | `git clone` and `python3 web_app.py` | +| System | Install to `/usr/local` | `sudo make install` | + ### Versioned release installer -Download the installer from a specific signed release, inspect it, then run it. The installer verifies the release public-key pin, SHA-256 checksum, and Ed25519 signature before installing to `~/.local/bin`: +Download the installer from a specific signed release, inspect it, then run it. The installer detects `uname -m` (override with `OPENBOX_ARCH=x86_64` or `OPENBOX_ARCH=aarch64`), selects the matching AppImage, and verifies the release public-key pin, SHA-256 checksum, and Ed25519 signature before installing to `~/.local/bin`: ```bash -VERSION=1.2.0 +VERSION=2.0.0 curl --proto '=https' --tlsv1.2 --fail --location \ --output install.sh \ "https://github.com/vindeckyy/OpenBoxGL/releases/download/v${VERSION}/install.sh" @@ -224,16 +398,113 @@ OPENBOX_RELEASE_TAG="v${VERSION}" bash install.sh --run Omit `OPENBOX_RELEASE_TAG` only when you intentionally want the latest stable release. Install to a different directory with `OPENBOX_INSTALL_DIR` (for example, `OPENBOX_INSTALL_DIR="$HOME/Applications"`). +### Windows (installer) + +Windows 10/11 on x86_64 installs from the signed portable zip. Download +`OpenBox-x86_64-windows.zip` from a specific release, extract it, then run the +bundled installer from Windows PowerShell 5.1. Requires Python 3.10+ on `PATH` +(or set `OPENBOX_PYTHON`); verification needs no `curl` and no OpenSSL — it +uses the same `updates.py` Ed25519 code path as the in-app updater: + +```powershell +$Version = '2.0.0' +Invoke-WebRequest -UseBasicParsing -OutFile OpenBox-x86_64-windows.zip ` + "https://github.com/vindeckyy/OpenBoxGL/releases/download/v$Version/OpenBox-x86_64-windows.zip" +Expand-Archive OpenBox-x86_64-windows.zip -DestinationPath . +notepad .\OpenBox\scripts\install.ps1 # read it before running it +powershell -ExecutionPolicy Bypass -File .\OpenBox\scripts\install.ps1 -Tag "v$Version" +``` + +(The `-ExecutionPolicy Bypass` flag scopes the bypass to that one invocation, +in case your execution policy blocks downloaded scripts.) + +The installer checks your CPU architecture (Windows builds are x86_64-only; on +ARM64 it exits with a clear message instead of downloading), then verifies the +release public key against the pinned trust anchor, the archive's SHA-256 +sidecar, and the Ed25519 signature — nothing is extracted until all three +pass. It installs to `%LOCALAPPDATA%\OpenBox\share\openbox`, keeps the previous +tree at `share\openbox.previous`, adds +`%LOCALAPPDATA%\OpenBox\share\openbox` to your user `PATH`, registers a +Start Menu shortcut, and registers the `openbox://` protocol handler. If Start +Menu or protocol registration fails, the previous installation tree is restored +instead of leaving a half-registered install. + +| Option | Effect | +| --- | --- | +| `-Run` | Launch OpenBox when the install finishes | +| `-InstallDir ` | Install somewhere else (or set `OPENBOX_INSTALL_DIR`) | +| `-NoPathUpdate` | Leave the user `PATH` alone | +| `-Tag vX.Y.Z` | Install a specific release instead of the latest | +| `-Arch x86_64` | Override the detected architecture | +| `-ReleaseBase ` / `-Repo ` | Use a mirror (or set `OPENBOX_RELEASE_BASE`) | +| `-PublicKeyPath ` | Trust a supplied release key (mirror/offline escape hatch) | + +`openbox` then opens the UI in a WebView2 window and falls back to the +browser-based app window when the WebView2 runtime is absent; `openbox --web` +forces the browser. The in-app updater verifies and swaps the installed tree the +same way, and refuses to overwrite anything that is not an installed copy — a +`git clone` updates with `git pull`. + +Each release also carries `OpenBox-x86_64-windows-native-host.exe` (with +`.sha256` and `.sig`): the compiled WebView2 host the zip ships, published on +its own so a source checkout gets a native window without the MSVC toolchain. +Save it beside `web_app.py` as `native_host.exe`, or point `OPENBOX_NATIVE_HOST` +at it. + +To remove the install, run the uninstaller that ships inside it. It removes +exactly what the installer creates — the install tree and its +`openbox.previous` rollback copy, the user `PATH` entry, the Start Menu +shortcut, and the `openbox://` registration — and never touches your library +or settings, so a reinstall picks them straight back up. The installed copy +lives at `%LOCALAPPDATA%\OpenBox\share\openbox\scripts\uninstall.ps1`; add +`-WhatIf` to preview the removals: + +```powershell +# Default install (%LOCALAPPDATA%\OpenBox) +powershell -ExecutionPolicy Bypass ` + -File "$env:LOCALAPPDATA\OpenBox\share\openbox\scripts\uninstall.ps1" -WhatIf +powershell -ExecutionPolicy Bypass ` + -File "$env:LOCALAPPDATA\OpenBox\share\openbox\scripts\uninstall.ps1" + +# Pass the same -InstallDir you installed with +powershell -ExecutionPolicy Bypass ` + -File "D:\Apps\OpenBox\share\openbox\scripts\uninstall.ps1" -InstallDir "D:\Apps\OpenBox" +``` + +### macOS (installer) + +macOS 11 or newer on Apple Silicon or Intel installs from the signed universal +app bundle. The installer checks the release public-key pin, the bundle's +SHA-256 sidecar, and its Ed25519 signature, then installs `OpenBox.app` into +`~/Applications` and links `openbox` into `~/.local/bin`: + +```bash +VERSION=2.0.0 +curl --proto '=https' --tlsv1.2 --fail --location \ + --output install-macos.sh \ + "https://github.com/vindeckyy/OpenBoxGL/releases/download/v${VERSION}/install-macos.sh" +less install-macos.sh +OPENBOX_RELEASE_TAG="v${VERSION}" bash install-macos.sh +``` + +Re-run with `--replace` to update an existing install, or set +`OPENBOX_INSTALL_DIR` to install somewhere else. The bundle carries its own +universal CPython, so nothing else has to be installed. Notarization depends on the release: its release notes say whether the bundle is notarized. An ad-hoc bundle asks you to allow it in **System Settings > +Privacy & Security > Open Anyway** on first launch, and a newly installed build may ask again +for the folders and permissions you granted before. The in-app updater verifies +and swaps the bundle the same way and keeps the previous one as +`OpenBox.app.previous`. + ### AppImage (manual) -Download the latest release from [GitHub Releases](https://github.com/vindeckyy/OpenBoxGL/releases/latest). +Download the latest release from [GitHub Releases](https://github.com/vindeckyy/OpenBoxGL/releases/latest). Release artifacts are built for both **x86_64** and **aarch64**; pick the one matching your CPU (`uname -m`). ```bash -chmod +x OpenBox-x86_64.AppImage -./OpenBox-x86_64.AppImage +chmod +x OpenBox-$(uname -m).AppImage +./OpenBox-$(uname -m).AppImage ``` -The AppImage opens the native window by default. To use the loopback web UI instead, pass `--web`: +The AppImage opens the native window by default. To skip the WebKitGTK host and open the loopback UI in a browser app window (plain tab fallback), pass `--web`: ```bash ./OpenBox-x86_64.AppImage --web @@ -243,10 +514,12 @@ Desktop integrators such as Gear Lever work with the AppImage. If an older build ### System install +`make install` builds `native_host` first (needs `gcc` + `libwebkit2gtk-4.1-dev`): + ```bash sudo make install openbox # Native window (default) -openbox --web # Web UI (development) +openbox --web # Skips WebKitGTK host, loopback UI in browser app window (plain tab fallback) ``` ### Flatpak @@ -264,7 +537,11 @@ cd OpenBoxGL python3 web_app.py ``` -Requirements: Python 3.10 or newer on a Linux system with standard desktop tooling. The native window additionally needs WebKitGTK 4.1 (`make native-host` builds `native_host`); `python3 web_app.py` runs without it. +On Windows use `python web_app.py` (`python3` is a Microsoft Store alias that does not run scripts), or `.\openbox.cmd` with the WebView2 host: either build it with `scripts\build_native_host_windows.ps1` or download `OpenBox-x86_64-windows-native-host.exe` from a release and save it beside `web_app.py` as `native_host.exe`. + +On macOS, install a current Python (for example `brew install python@3.12`) and run `python3 web_app.py`, or build the native window with `scripts/build_native_host_macos.sh`; `scripts/build_macos_app.sh` builds the whole universal bundle. The system `python3` is older than the 3.10 floor. + +Requirements: Python 3.10 or newer. The native window additionally needs WebKitGTK 4.1 on Linux (`make native-host`), the WebView2 runtime on Windows (present on Windows 11 and most Windows 10 systems), or WKWebView on macOS (present on every supported release); `python3 web_app.py` runs without any of them. Optional local configuration can be loaded from an explicit `OPENBOX_ENV_FILE`, the data directory (or its parent), `~/.env`, or `~/.config/openbox-game-launcher/.env`. See `.env.example`. Never commit secrets. @@ -279,41 +556,49 @@ The full user documentation is at [openboxgl.github.io](https://openboxgl.github | [Getting started](https://openboxgl.github.io/getting-started/) | First-run walkthrough with disposable folder | | [Installation](https://openboxgl.github.io/install/) | AppImage, Flatpak, source, troubleshooting | | [Library overview](https://openboxgl.github.io/guides/library/) | Browse, search, filters, health audit | -| [Importing](https://openboxgl.github.io/guides/library/importing/) | Steam, Heroic, Lutris, ROM folders, arcade, Gameyfin | +| [Importing](https://openboxgl.github.io/guides/library/importing/) | Steam, Heroic, Lutris, Faugus, ROM folders, arcade, Gameyfin | +| [Proton & Wine](https://openboxgl.github.io/guides/wine-and-proton/) | Windows game runners, Wine prefixes, and Proton runtime environments | | [Emulators and launching](https://openboxgl.github.io/guides/emulators-and-launching/) | Profiles, tokens, archives, dependency checks | | [Big Box and handhelds](https://openboxgl.github.io/guides/big-box-and-handhelds/) | Layouts, gamepad, gamescope, TDP profiles | | [Sessions, saves, and backups](https://openboxgl.github.io/guides/sessions-saves-and-backups/) | History, save discovery, versioned backups | | [RetroAchievements](https://openboxgl.github.io/guides/retroachievements/) | Matching, hardcore, badge injection | | [Plugins](https://openboxgl.github.io/guides/plugins/) | Install, hooks, safe mode | | [REST API](https://openboxgl.github.io/reference/api/) | Full endpoint documentation | -| [PARITY.md](PARITY.md) | LaunchBox capability matrix | -| [SUPPORT.md](SUPPORT.md) | Supported platforms, runtimes, and reporting guidance | +| [PARITY.md](docs/PARITY.md) | LaunchBox capability matrix | +| [docs/SUPPORT.md](docs/SUPPORT.md) | Supported platforms, runtimes, and reporting guidance | | [docs/reliability.md](docs/reliability.md) | Edge case catalog and expected behavior | -| [CHANGELOG.md](CHANGELOG.md) | Release history | -| [CONTRIBUTING.md](CONTRIBUTING.md) | Development workflow and contribution guidelines | -| [SECURITY.md](SECURITY.md) | Security reporting process | +| [CHANGELOG.md](docs/CHANGELOG.md) | Release history | +| [CONTRIBUTING.md](docs/CONTRIBUTING.md) | Development workflow and contribution guidelines | +| [SECURITY.md](docs/SECURITY.md) | Security reporting process | --- +## Backups and portable libraries + +OpenBox writes backups as ZIP archives with a SHA-256 manifest (format 2). A restore checks every file against that manifest before anything is written. Choose **1.19-compatible** in **Settings > Sessions > Backup format** when an older install must read a backup. [docs/backups.md](docs/backups.md) lists what restores where and what stays local. + +A portable library keeps its game paths relative to one folder, so the folder can move to another drive or machine. **Settings > Library > Move library** previews the move before it changes anything. [docs/portable-library.md](docs/portable-library.md) explains what moves. + ## REST API The Web UI exposes a local REST API for automation. Authenticate with `X-OpenBox-Token`: ```bash # Find your token and port (only while the Web UI is running) +# On macOS the data directory is ~/Library/Application Support/openbox-game-launcher; on Windows it is %LOCALAPPDATA%\openbox-game-launcher. cat ~/.local/share/openbox-game-launcher/server.token cat ~/.local/share/openbox-game-launcher/server.port # List your library TOKEN=$(cat ~/.local/share/openbox-game-launcher/server.token) PORT=$(cat ~/.local/share/openbox-game-launcher/server.port) -curl -H "X-OpenBox-Token: $TOKEN" http://127.0.0.1:$PORT/api/library | jq '.games | length' +curl -H "X-OpenBox-Token: $TOKEN" http://127.0.0.1:$PORT/api/v2/library | jq '.games | length' # Launch a game by stable ID curl -X POST -H "X-OpenBox-Token: $TOKEN" \ -H "Content-Type: application/json" \ -d '{"game_id": "GAME_ID"}' \ - http://127.0.0.1:$PORT/api/launch + http://127.0.0.1:$PORT/api/v2/launch ``` Full endpoint documentation: [REST API](https://openboxgl.github.io/reference/api/). @@ -332,7 +617,11 @@ No OpenBox account is required. Optional integrations (RetroAchievements, EmuMov ### Is Windows supported? -OpenBox targets Linux (desktop, Steam Deck, handhelds). Windows-only features are documented as boundaries in [PARITY.md](PARITY.md). If you need Windows, LaunchBox remains a strong option. +Yes. OpenBox runs on Windows 10/11 (x86_64) from a signed portable install: the same UI in a WebView2 window, the same library, imports, sessions, saves, Big Box mode, and REST API, with Windows binary names for every bundled emulator definition. Extract `OpenBox-x86_64-windows.zip` from a release and run `OpenBox\scripts\install.ps1` (requires Python 3.10+), or `python web_app.py` from a checkout. Linux remains the primary target for the distro-integration surfaces — AppImage, Flatpak, gamescope/Game Mode, XDG desktop entries, and Flathub-aware emulator management are Linux-only, and the capability matrix in [PARITY.md](docs/PARITY.md) is written from the Linux side. See [ADR 0048](docs/adr/0048-windows-port.md) for the port's boundaries. + +### Is macOS supported? + +Yes, since 1.19.0. OpenBox runs on macOS 11 or newer, on Apple Silicon and Intel, from a signed universal app bundle that carries its own Python. Install it with the published `install-macos.sh`, which verifies the release key, the bundle's SHA-256 checksum, and its Ed25519 signature before it installs `OpenBox.app` into `~/Applications`. The bundle is not notarized, so the first launch needs **System Settings > Privacy & Security > Open Anyway**. Linux-only features (gamescope and Game Mode, MangoHud, Flatpak, Wine, Lutris, Heroic, and Epic) are hidden there, and the plugin sandbox is unavailable. Seven bundled emulator definitions have no verified macOS build and are reported as not installed. See the [macOS guide](https://openboxgl.github.io/macos/). --- @@ -343,17 +632,32 @@ OpenBox targets Linux (desktop, Steam Deck, handhelds). Windows-only features ar ``` OpenBox/ ├── native_host.c Native WebKitGTK host (spawns web_app.py) +├── native_host_win.c Native WebView2 host (spawns web_app.py) +├── pkg/platform_compat.py The only platform seam (locking, processes, quoting, paths) ├── handlers/ Route handler mixins (library, media, imports, settings, ...) ├── web_app.py Loopback server + REST API (shared core) -├── routes.py GET/POST route tables (79 GET + 95 POST entries; 103/124 incl. v1 aliases; v1_contracts.json freezes 46 paths) -├── contracts.py Frozen v1 API contract + legacy aliases +├── webapp_state.py SSE/event bus facade over the canonical state owner +├── routes.py GET/POST route tables (frozen v1 surface + additive v2 routes) +├── routes/ `@route` decorator registry only (handlers are imported by web_app.py) +├── contracts.py Frozen v1 API contract (v1_contracts.json); legacy `/api/library` + `/api/launch` aliases live in handlers/library.py, handlers/sessions.py via routes.py ├── openbox.py Shared core helpers (data paths, launch, profiles) +├── static/ Frontend JavaScript modules, search worker, and app.css ├── state_store.py Schema-versioned state, atomic writes, snapshots ├── settings_schema.py Settings key whitelist ├── api_errors.py Structured API error codes -├── job_manager.py Background job lifecycle +├── job_manager.py Background job lifecycle adapter ├── importers.py Steam, Heroic, Lutris, ROM imports -├── parity_*.py Parity modules (storefront, saves, media, discovery, integrations, etc.) +├── automation.py HMAC-signed webhook delivery + event allowlists +├── backend_io.py Bounded network and filesystem helpers +├── cloud_sync.py Mounted-folder statistics sync +├── crash_report.py Redacted diagnostic report generator +├── notifications.py Persistent notification feed helpers +├── openbox_logging.py Diagnostic logging with secret redaction +├── play_queue.py Persistent play queue helpers +├── archives.py Safe, cached archive extraction +├── arcade.py MAME and FinalBurn full-set import +├── pkg/state/ Modularized state, caches, launch, and operations +├── pkg/parity/ Parity modules (flat `import parity_*` via MetaPathFinder bridge) ├── emulators.py Emulator profiles + Flathub management ├── metadata.py LaunchBox DB sync + media scraping ├── retroachievements.py RA matching + badge injection @@ -361,13 +665,14 @@ OpenBox/ ├── updates.py Verified GitHub updater ├── env_config.py .env loading + credential aliases ├── plugins.py Plugin lifecycle + hooks +├── plugin_runner.py Sandboxed plugin subprocess runner ├── plugin_catalog.py Bundled community catalog ├── catalog.py Search, filters, bulk edits ├── stock_themes.py Bundled CSS theme installer -├── themes/ Stock themes (5 CSS files) +├── themes/ Stock themes (6 CSS files) ├── emulator_defs/ YAML definition packs ├── scripts/ Build, test, screenshot capture -└── test_*.py Test suite +├── tests/test_*.py Standalone test files run by ./run_all_tests.sh ``` ### Run tests @@ -376,14 +681,25 @@ OpenBox/ ./run_all_tests.sh ``` -Build the native window host and the AppImage: +On Windows (no bash required): + +```powershell +python scripts\run_windows_tests.py # every test file, per-file pass/fail/timeout +python scripts\run_windows_tests.py test_platform_compat.py # a subset +``` + +Build the native window hosts and the AppImage: ```bash -make native-host # WebKitGTK window host (needs libwebkit2gtk-4.1) +make native-host # WebKitGTK window host (needs libwebkit2gtk-4.1) ./build_appimage.sh ``` -Pull requests should pass the full test suite. See [CONTRIBUTING.md](CONTRIBUTING.md). +```powershell +scripts\build_native_host_windows.ps1 # WebView2 host (needs the MSVC build tools) +``` + +Pull requests should pass the full test suite. See [CONTRIBUTING.md](docs/CONTRIBUTING.md). --- @@ -393,7 +709,7 @@ OpenBox Game Launcher is released under the [GNU Affero General Public License v Trademark references to LaunchBox, Steam, Heroic, Lutris, RetroArch, and other third-party products are used for compatibility description only. OpenBox does not distribute ROMs, BIOS files, firmware, or DRM circumvention tools. -For the full legal policy, see [DISCLAIMER.md](DISCLAIMER.md) and [TRADEMARKS.md](TRADEMARKS.md). +For the full legal policy, see [DISCLAIMER.md](docs/DISCLAIMER.md) and [TRADEMARKS.md](docs/TRADEMARKS.md). --- @@ -411,4 +727,4 @@ OpenBox is free and open source (AGPL-3.0). If it saves you time, a coffee helps - [Request a feature](https://github.com/vindeckyy/OpenBoxGL/issues/new?template=feature_request.yml) - [Review open issues](https://github.com/vindeckyy/OpenBoxGL/issues) -Contributions are welcome. Please read [CONTRIBUTING.md](CONTRIBUTING.md) and [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md) before opening a pull request. +Contributions are welcome. Please read [CONTRIBUTING.md](docs/CONTRIBUTING.md) and [CODE_OF_CONDUCT.md](docs/CODE_OF_CONDUCT.md) before opening a pull request. diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md deleted file mode 100644 index 1f7fb320..00000000 --- a/RELEASE_NOTES.md +++ /dev/null @@ -1,21 +0,0 @@ -### Fixed - -- SteamOS AppImages no longer export bundled libraries into the host shell, fixing startup failures where `/bin/bash` could not resolve `rl_print_keybinding`. -- Webhook delivery rejects non-public destinations, pins validated DNS results, disables proxies and redirects, and bounds response reads. -- Library and save backups use private atomic files, reject unsafe archive entries, and protect restore paths against symlinks and archive replacement races. -- The native bridge now accepts messages only from the exact OpenBox scheme, host, and port. -- Gameyfin validates IDs before path construction, keeps installation and removal inside the configured root, requires HTTPS, and verifies supplied checksums. -- 7z extraction rejects link entries, snapshots the archive before validation, and validates the staged tree before promotion. -- Media and document endpoints enforce approved roots, including symlinked parent checks. -- Environment loading no longer searches the current directory and accepts only owner-controlled files and supported keys. -- Job bookkeeping, executor queues, SSE subscribers, and per-client event queues now have explicit limits and cleanup behavior. - -### Changed - -- Release publication now requires an Ed25519 signature and a pinned public key. Missing signing material fails the release instead of publishing checksum-only artifacts. -- The release workflow separates build, provenance attestation, and publication permissions, validates the tag against the application version, and refuses to overwrite existing assets. -- AppImageTool, Python development tools, browser tooling, and CI dependencies are pinned; the generated SBOM inventories the completed AppImage. -- Remote plugin catalogs require a pinned catalog digest, HTTPS package URLs, and package checksums. -- Puppeteer was upgraded to 25.7.0; `npm audit` reports zero vulnerabilities. - -The full changelog is available at https://github.com/vindeckyy/OpenBoxGL/compare/v1.1.0...v1.2.0. diff --git a/SECURITY.md b/SECURITY.md deleted file mode 100644 index 1bb687b4..00000000 --- a/SECURITY.md +++ /dev/null @@ -1,76 +0,0 @@ -# Security Policy - -## Supported versions - -| Version | Supported | -| --- | --- | -| 1.2.x | Yes | -| 1.1.x | Yes | -| 1.0.x | Yes | -| 0.9.x | Yes | -| 0.8.x | Yes | -| 0.7.x | Best effort | -| 0.6.x | Best effort | -| 0.5.x | Best effort | -| 0.4.x | Best effort | -| < 0.4.0 | No | - -Security fixes are provided for the latest release on the `master` branch. - -## Reporting a vulnerability - -Please do **not** report security vulnerabilities through public GitHub issues. - -Instead, report them privately using one of the following methods: - -1. Open a **private security advisory** on GitHub: [Create a security advisory](https://github.com/vindeckyy/OpenBoxGL/security/advisories/new) -2. If GitHub advisories are unavailable, open a minimal public issue asking for a private contact channel without disclosing exploit details - -Include as much of the following as possible: - -- Description of the issue and potential impact -- Steps to reproduce -- Affected version(s) -- Proof of concept, if available -- Suggested remediation, if known - -## Response expectations - -Maintainers aim to acknowledge valid reports within **5 business days** and provide a remediation plan or status update within **14 business days**, depending on severity and complexity. - -## Scope - -The following are in scope: - -- Remote code execution or privilege escalation in OpenBox server components -- Authentication or authorization bypass in the local web API -- Unsafe command execution introduced by OpenBox launch logic -- Credential leakage through logs, backups, or repository defaults - -The following are generally out of scope: - -- Vulnerabilities in third-party games, emulators, storefront clients, or operating system components launched by OpenBox -- Issues requiring physical access to an unlocked machine with an already running OpenBox instance -- Social engineering or phishing unrelated to OpenBox itself - -## Safe usage guidance - -- Run OpenBox on trusted local networks. The web UI uses a session token; do not expose it directly to the public internet without a reverse proxy and additional hardening. -- Do not commit `.env`, API tokens, RetroAchievements credentials, or EmuMovies credentials to the repository. -- Keep OpenBox updated to the latest release. -- Plugins run with an OS sandbox when bubblewrap is available. If the sandbox cannot start, OpenBox skips enabled plugins unless `OPENBOX_ALLOW_UNSANDBOXED_PLUGINS=1` is set explicitly. Only use that override for plugins you trust. - -## Release signing - -Release publication is fail-closed. The workflow requires `OPENBOX_SIGNING_KEY`, derives an Ed25519 public key in a mode-`0600` temporary file, compares it with the committed `openbox-release.pub`, signs the AppImage, and verifies the signature before upload. The updater and `scripts/install.sh` reject checksum-only releases. - -The committed `openbox-release.pub` pins the production release key. Maintainers must preserve these external release controls: - -- Generate a new Ed25519 key pair outside the repository and store only the private key as the `OPENBOX_SIGNING_KEY` repository secret. -- When rotating the signing key, replace `openbox-release.pub` and update `RELEASE_KEY_SHA256` in `scripts/install.sh` in the same reviewed change. -- Require approval for the `release` environment and protect `v*` tags against unreviewed pushes; use annotated, signed tags where the GitHub organization supports the signing-key policy. -- Publish `openbox-release.pub`, the `.sig`, the checksum, and `install.sh` together. Rotate the key and repeat the pin update if it is compromised. - -## Disclosure - -We prefer coordinated disclosure. Reporters will be credited in release notes when fixes ship, unless they request anonymity. diff --git a/SUPPORT.md b/SUPPORT.md deleted file mode 100644 index 6b491fa1..00000000 --- a/SUPPORT.md +++ /dev/null @@ -1,32 +0,0 @@ -# Support - -## Supported platforms - -OpenBox targets Linux. The maintainers test these environments per release: - -| Environment | Status | -|---|---| -| Ubuntu LTS (two most recent) | Tested | -| Fedora (latest stable) | Tested | -| Arch Linux | Tested | -| SteamOS / Steam Deck | Tested (gamescope guest mode) | -| Other glibc distributions | Best effort | - -## Supported runtimes - -- Python 3.10 or newer (CI runs 3.10 and 3.12) -- Chromium-family browsers get the chrome-less app window; Firefox opens a separate window; no compatible browser falls back to the default browser - -## Reporting problems - -Open **Settings -> Library Audit** and use the diagnostic log, or paste the error banner's "Copy details" output (it includes a request id maintainers can correlate with the log). - -Bug reports should include: OpenBox version, distro and desktop, Python version when running from source, the diagnostic report from `/api/diagnostic` (Settings), and steps to reproduce. - -## Known behavior - -See `docs/reliability.md` for the full edge case catalog. Highlights: - -- Games you launch keep running after OpenBox exits unless you close them from the Running panel. -- OpenBox data lives in `~/.local/share/openbox-game-launcher/`; deleting `library.json` resets the library while media files stay. -- The web UI is local-only. Sharing the token in the URL with another machine is equivalent to handing over control of the instance. diff --git a/TRADEMARKS.md b/TRADEMARKS.md deleted file mode 100644 index 90c9d06e..00000000 --- a/TRADEMARKS.md +++ /dev/null @@ -1,27 +0,0 @@ -# Trademark and branding policy - -OpenBox Game Launcher is an independent open-source Linux game library manager and launcher. - -## Project identity - -Use the full name **OpenBox Game Launcher** in public listings, package descriptions, screenshots, and release announcements when space allows. The project is unrelated to [Openbox](https://openbox.org/), the Linux window manager, and is not affiliated with its maintainers. - -The project is also independent from LaunchBox, Big Box, Unbroken Software, LLC, and every other third-party product named in the repository. Do not describe OpenBox Game Launcher as official, endorsed, sponsored, or produced by another project or company. - -The `io.openbox.GameLauncher` application ID and `openbox://` URI scheme are technical identifiers. They do not imply affiliation with the Openbox window manager. - -## Third-party names and logos - -Third-party names, logos, and marks belong to their respective owners. OpenBox uses product names in documentation and interface text only when needed to describe: - -- compatibility or interoperability; -- supported import, launch, or metadata workflows; or -- a factual feature comparison. - -Do not copy third-party logos, artwork, screenshots, or other branded assets into the repository or release artifacts without a documented license or permission. New contributions that include third-party assets must state the source and applicable license. - -## Formal notices - -Do not post private contact details or formal legal notices in public issues. Use GitHub's [copyright claims form](https://github.com/contact/dmca) for copyright complaints and the [GitHub Trademark Policy](https://docs.github.com/en/site-policy/content-removal-policies/github-trademark-policy) for trademark complaints. - -This policy is a project guideline, not legal advice. It does not determine whether a particular use is lawful in any jurisdiction. diff --git a/api_contracts.json b/api_contracts.json new file mode 100644 index 00000000..72b66523 --- /dev/null +++ b/api_contracts.json @@ -0,0 +1,1952 @@ +{ + "format": 1, + "app_version": "2.0.0", + "public": [ + { + "method": "GET", + "path": "/api/v2/arcade/kiosk/status" + }, + { + "method": "GET", + "path": "/api/v2/backup" + }, + { + "method": "GET", + "path": "/api/v2/backup/diff" + }, + { + "method": "GET", + "path": "/api/v2/backup/manifest" + }, + { + "method": "GET", + "path": "/api/v2/backups" + }, + { + "method": "GET", + "path": "/api/v2/bigbox/startup-video" + }, + { + "method": "GET", + "path": "/api/v2/clips" + }, + { + "method": "GET", + "path": "/api/v2/collections" + }, + { + "method": "GET", + "path": "/api/v2/compat/report" + }, + { + "method": "GET", + "path": "/api/v2/diagnostic" + }, + { + "method": "GET", + "path": "/api/v2/discovery" + }, + { + "method": "GET", + "path": "/api/v2/document" + }, + { + "method": "GET", + "path": "/api/v2/emulators" + }, + { + "method": "GET", + "path": "/api/v2/emulators/definitions" + }, + { + "method": "GET", + "path": "/api/v2/emulators/defs/status" + }, + { + "method": "GET", + "path": "/api/v2/emulators/defs/update" + }, + { + "method": "GET", + "path": "/api/v2/emulators/dependencies" + }, + { + "method": "GET", + "path": "/api/v2/emulators/recommend" + }, + { + "method": "GET", + "path": "/api/v2/emulators/registry" + }, + { + "method": "GET", + "path": "/api/v2/emulators/scan-configs" + }, + { + "method": "GET", + "path": "/api/v2/emulators/settings" + }, + { + "method": "GET", + "path": "/api/v2/events" + }, + { + "method": "GET", + "path": "/api/v2/explorer/facets" + }, + { + "method": "GET", + "path": "/api/v2/faugus/scan" + }, + { + "method": "GET", + "path": "/api/v2/faugus/status" + }, + { + "method": "GET", + "path": "/api/v2/filter-presets" + }, + { + "method": "GET", + "path": "/api/v2/gameyfin/install/status" + }, + { + "method": "GET", + "path": "/api/v2/gameyfin/providers" + }, + { + "method": "GET", + "path": "/api/v2/highscores" + }, + { + "method": "GET", + "path": "/api/v2/highscores/table" + }, + { + "method": "GET", + "path": "/api/v2/history" + }, + { + "method": "GET", + "path": "/api/v2/history/timeline" + }, + { + "method": "GET", + "path": "/api/v2/household" + }, + { + "method": "GET", + "path": "/api/v2/household/leaderboard" + }, + { + "method": "GET", + "path": "/api/v2/import/exclusions" + }, + { + "method": "GET", + "path": "/api/v2/insights/heatmap" + }, + { + "method": "GET", + "path": "/api/v2/insights/mastery" + }, + { + "method": "GET", + "path": "/api/v2/insights/radar" + }, + { + "method": "GET", + "path": "/api/v2/insights/radio" + }, + { + "method": "GET", + "path": "/api/v2/insights/summary" + }, + { + "method": "GET", + "path": "/api/v2/insights/trophies" + }, + { + "method": "GET", + "path": "/api/v2/insights/wrapped" + }, + { + "method": "GET", + "path": "/api/v2/integration-tokens" + }, + { + "method": "GET", + "path": "/api/v2/integrations/steam" + }, + { + "method": "GET", + "path": "/api/v2/jobs" + }, + { + "method": "GET", + "path": "/api/v2/jobs/items" + }, + { + "method": "GET", + "path": "/api/v2/launch/audit" + }, + { + "method": "GET", + "path": "/api/v2/launch/audit/status" + }, + { + "method": "GET", + "path": "/api/v2/launch/cores" + }, + { + "method": "GET", + "path": "/api/v2/launcher/menu" + }, + { + "method": "GET", + "path": "/api/v2/library" + }, + { + "method": "GET", + "path": "/api/v2/library/constellation" + }, + { + "method": "GET", + "path": "/api/v2/library/delta" + }, + { + "method": "GET", + "path": "/api/v2/library/dna/status" + }, + { + "method": "GET", + "path": "/api/v2/library/duplicates" + }, + { + "method": "GET", + "path": "/api/v2/library/export/download" + }, + { + "method": "GET", + "path": "/api/v2/library/export/exports" + }, + { + "method": "GET", + "path": "/api/v2/library/health" + }, + { + "method": "GET", + "path": "/api/v2/library/health/issues" + }, + { + "method": "GET", + "path": "/api/v2/library/integrity" + }, + { + "method": "GET", + "path": "/api/v2/library/repair" + }, + { + "method": "GET", + "path": "/api/v2/library/search" + }, + { + "method": "GET", + "path": "/api/v2/library/time-machine/as-of" + }, + { + "method": "GET", + "path": "/api/v2/library/time-machine/compare" + }, + { + "method": "GET", + "path": "/api/v2/library/time-machine/events" + }, + { + "method": "GET", + "path": "/api/v2/library/trash" + }, + { + "method": "GET", + "path": "/api/v2/log" + }, + { + "method": "GET", + "path": "/api/v2/media" + }, + { + "method": "GET", + "path": "/api/v2/media/audit" + }, + { + "method": "GET", + "path": "/api/v2/media/bulk/status" + }, + { + "method": "GET", + "path": "/api/v2/media/duplicates" + }, + { + "method": "GET", + "path": "/api/v2/media/queue" + }, + { + "method": "GET", + "path": "/api/v2/memories" + }, + { + "method": "GET", + "path": "/api/v2/memories/media" + }, + { + "method": "GET", + "path": "/api/v2/memories/status" + }, + { + "method": "GET", + "path": "/api/v2/metadata/igdb/search" + }, + { + "method": "GET", + "path": "/api/v2/metadata/matches/items" + }, + { + "method": "GET", + "path": "/api/v2/metadata/matches/preview" + }, + { + "method": "GET", + "path": "/api/v2/metadata/media-candidates" + }, + { + "method": "GET", + "path": "/api/v2/metadata/scrape-settings" + }, + { + "method": "GET", + "path": "/api/v2/metadata/search" + }, + { + "method": "GET", + "path": "/api/v2/metadata/status" + }, + { + "method": "GET", + "path": "/api/v2/moments" + }, + { + "method": "GET", + "path": "/api/v2/native/battery" + }, + { + "method": "GET", + "path": "/api/v2/native/capabilities" + }, + { + "method": "GET", + "path": "/api/v2/netplay/lobby" + }, + { + "method": "GET", + "path": "/api/v2/notifications" + }, + { + "method": "GET", + "path": "/api/v2/obs/status" + }, + { + "method": "GET", + "path": "/api/v2/party/decks" + }, + { + "method": "GET", + "path": "/api/v2/party/queue" + }, + { + "method": "GET", + "path": "/api/v2/perf_profiles" + }, + { + "method": "GET", + "path": "/api/v2/platform/document" + }, + { + "method": "GET", + "path": "/api/v2/platform/documents" + }, + { + "method": "GET", + "path": "/api/v2/platform/video" + }, + { + "method": "GET", + "path": "/api/v2/platform/videos" + }, + { + "method": "GET", + "path": "/api/v2/plugin-stats" + }, + { + "method": "GET", + "path": "/api/v2/plugins" + }, + { + "method": "GET", + "path": "/api/v2/plugins/catalog" + }, + { + "method": "GET", + "path": "/api/v2/plugins/commands" + }, + { + "method": "GET", + "path": "/api/v2/plugins/credentials" + }, + { + "method": "GET", + "path": "/api/v2/plugins/data" + }, + { + "method": "GET", + "path": "/api/v2/plugins/settings" + }, + { + "method": "GET", + "path": "/api/v2/plugins/trust" + }, + { + "method": "GET", + "path": "/api/v2/premium/media-packs" + }, + { + "method": "GET", + "path": "/api/v2/premium/platform-categories" + }, + { + "method": "GET", + "path": "/api/v2/premium/strings" + }, + { + "method": "GET", + "path": "/api/v2/profiles" + }, + { + "method": "GET", + "path": "/api/v2/queue" + }, + { + "method": "GET", + "path": "/api/v2/ra/badge" + }, + { + "method": "GET", + "path": "/api/v2/ra/settings" + }, + { + "method": "GET", + "path": "/api/v2/reels" + }, + { + "method": "GET", + "path": "/api/v2/related" + }, + { + "method": "GET", + "path": "/api/v2/related/rich" + }, + { + "method": "GET", + "path": "/api/v2/resume/status" + }, + { + "method": "GET", + "path": "/api/v2/running" + }, + { + "method": "GET", + "path": "/api/v2/save-tools/status" + }, + { + "method": "GET", + "path": "/api/v2/saves" + }, + { + "method": "GET", + "path": "/api/v2/saves/discover" + }, + { + "method": "GET", + "path": "/api/v2/saves/manifest" + }, + { + "method": "GET", + "path": "/api/v2/saves/scan" + }, + { + "method": "GET", + "path": "/api/v2/screenscraper/search" + }, + { + "method": "GET", + "path": "/api/v2/screenscraper/status" + }, + { + "method": "GET", + "path": "/api/v2/sessions/recap" + }, + { + "method": "GET", + "path": "/api/v2/settings" + }, + { + "method": "GET", + "path": "/api/v2/setup/migration/status" + }, + { + "method": "GET", + "path": "/api/v2/setup/preview" + }, + { + "method": "GET", + "path": "/api/v2/setup/preview/items" + }, + { + "method": "GET", + "path": "/api/v2/setup/summary" + }, + { + "method": "GET", + "path": "/api/v2/steambridge/status" + }, + { + "method": "GET", + "path": "/api/v2/steamgrid/hygiene/report" + }, + { + "method": "GET", + "path": "/api/v2/steamgrid/search" + }, + { + "method": "GET", + "path": "/api/v2/steamgrid/status" + }, + { + "method": "GET", + "path": "/api/v2/storefront/catalog" + }, + { + "method": "GET", + "path": "/api/v2/storefront/status" + }, + { + "method": "GET", + "path": "/api/v2/storefronts" + }, + { + "method": "GET", + "path": "/api/v2/story" + }, + { + "method": "GET", + "path": "/api/v2/tags" + }, + { + "method": "GET", + "path": "/api/v2/theme-asset" + }, + { + "method": "GET", + "path": "/api/v2/theme-sounds" + }, + { + "method": "GET", + "path": "/api/v2/theme.css" + }, + { + "method": "GET", + "path": "/api/v2/themes" + }, + { + "method": "GET", + "path": "/api/v2/update" + }, + { + "method": "GET", + "path": "/api/v2/version" + }, + { + "method": "GET", + "path": "/api/v2/webhooks" + }, + { + "method": "GET", + "path": "/api/v2/wine/prefix-for-game" + }, + { + "method": "GET", + "path": "/api/v2/wine/prefixes" + }, + { + "method": "GET", + "path": "/api/v2/wine/protons" + }, + { + "method": "POST", + "path": "/api/v2/achievements/override" + }, + { + "method": "POST", + "path": "/api/v2/achievements/refresh" + }, + { + "method": "POST", + "path": "/api/v2/arcade/kiosk/pin" + }, + { + "method": "POST", + "path": "/api/v2/arcade/kiosk/verify" + }, + { + "method": "POST", + "path": "/api/v2/backup/create" + }, + { + "method": "POST", + "path": "/api/v2/backup/restore" + }, + { + "method": "POST", + "path": "/api/v2/bezels/download" + }, + { + "method": "POST", + "path": "/api/v2/bigbox/mode" + }, + { + "method": "POST", + "path": "/api/v2/bulk/find-replace" + }, + { + "method": "POST", + "path": "/api/v2/clips/capture" + }, + { + "method": "POST", + "path": "/api/v2/cloud/sync" + }, + { + "method": "POST", + "path": "/api/v2/collections" + }, + { + "method": "POST", + "path": "/api/v2/collections/delete" + }, + { + "method": "POST", + "path": "/api/v2/desktop/install" + }, + { + "method": "POST", + "path": "/api/v2/emulators/defs/rollback" + }, + { + "method": "POST", + "path": "/api/v2/emulators/defs/update" + }, + { + "method": "POST", + "path": "/api/v2/emulators/install" + }, + { + "method": "POST", + "path": "/api/v2/emulators/install-all" + }, + { + "method": "POST", + "path": "/api/v2/emulators/open" + }, + { + "method": "POST", + "path": "/api/v2/emulators/scan" + }, + { + "method": "POST", + "path": "/api/v2/emulators/scan-configs" + }, + { + "method": "POST", + "path": "/api/v2/emulators/settings" + }, + { + "method": "POST", + "path": "/api/v2/emulators/update" + }, + { + "method": "POST", + "path": "/api/v2/emulators/update-all" + }, + { + "method": "POST", + "path": "/api/v2/emumovies/download" + }, + { + "method": "POST", + "path": "/api/v2/emumovies/settings" + }, + { + "method": "POST", + "path": "/api/v2/extra/launch" + }, + { + "method": "POST", + "path": "/api/v2/faugus/import" + }, + { + "method": "POST", + "path": "/api/v2/favorite" + }, + { + "method": "POST", + "path": "/api/v2/filter-presets" + }, + { + "method": "POST", + "path": "/api/v2/filter-presets/delete" + }, + { + "method": "POST", + "path": "/api/v2/game" + }, + { + "method": "POST", + "path": "/api/v2/game/delete" + }, + { + "method": "POST", + "path": "/api/v2/game/store-action" + }, + { + "method": "POST", + "path": "/api/v2/games/bulk" + }, + { + "method": "POST", + "path": "/api/v2/games/bulk-wizard" + }, + { + "method": "POST", + "path": "/api/v2/games/delete-steam" + }, + { + "method": "POST", + "path": "/api/v2/gameyfin/install" + }, + { + "method": "POST", + "path": "/api/v2/gameyfin/test" + }, + { + "method": "POST", + "path": "/api/v2/gameyfin/uninstall" + }, + { + "method": "POST", + "path": "/api/v2/health" + }, + { + "method": "POST", + "path": "/api/v2/health/dedupe" + }, + { + "method": "POST", + "path": "/api/v2/highscores/export" + }, + { + "method": "POST", + "path": "/api/v2/highscores/import" + }, + { + "method": "POST", + "path": "/api/v2/household/challenge" + }, + { + "method": "POST", + "path": "/api/v2/household/challenge/result" + }, + { + "method": "POST", + "path": "/api/v2/household/member" + }, + { + "method": "POST", + "path": "/api/v2/household/merge" + }, + { + "method": "POST", + "path": "/api/v2/household/record" + }, + { + "method": "POST", + "path": "/api/v2/household/share" + }, + { + "method": "POST", + "path": "/api/v2/household/sync/publish" + }, + { + "method": "POST", + "path": "/api/v2/household/sync/pull" + }, + { + "method": "POST", + "path": "/api/v2/image-group" + }, + { + "method": "POST", + "path": "/api/v2/import" + }, + { + "method": "POST", + "path": "/api/v2/import/arcade" + }, + { + "method": "POST", + "path": "/api/v2/import/ea" + }, + { + "method": "POST", + "path": "/api/v2/import/epic" + }, + { + "method": "POST", + "path": "/api/v2/import/esde/apply" + }, + { + "method": "POST", + "path": "/api/v2/import/esde/preview" + }, + { + "method": "POST", + "path": "/api/v2/import/exclusions" + }, + { + "method": "POST", + "path": "/api/v2/import/exclusions/delete" + }, + { + "method": "POST", + "path": "/api/v2/import/gog" + }, + { + "method": "POST", + "path": "/api/v2/import/heroic" + }, + { + "method": "POST", + "path": "/api/v2/import/launchbox/apply" + }, + { + "method": "POST", + "path": "/api/v2/import/launchbox/preview" + }, + { + "method": "POST", + "path": "/api/v2/import/loose-arcade" + }, + { + "method": "POST", + "path": "/api/v2/import/lutris" + }, + { + "method": "POST", + "path": "/api/v2/import/msu1" + }, + { + "method": "POST", + "path": "/api/v2/import/pico8" + }, + { + "method": "POST", + "path": "/api/v2/import/rpcs3" + }, + { + "method": "POST", + "path": "/api/v2/import/scummvm" + }, + { + "method": "POST", + "path": "/api/v2/import/steam" + }, + { + "method": "POST", + "path": "/api/v2/import/ubisoft" + }, + { + "method": "POST", + "path": "/api/v2/import/vita3k" + }, + { + "method": "POST", + "path": "/api/v2/import/watch" + }, + { + "method": "POST", + "path": "/api/v2/import/whdload" + }, + { + "method": "POST", + "path": "/api/v2/import/wizard" + }, + { + "method": "POST", + "path": "/api/v2/import/xbox360" + }, + { + "method": "POST", + "path": "/api/v2/insights/radar/park" + }, + { + "method": "POST", + "path": "/api/v2/insights/radio/refresh" + }, + { + "method": "POST", + "path": "/api/v2/insights/trophies/evaluate" + }, + { + "method": "POST", + "path": "/api/v2/integration-tokens" + }, + { + "method": "POST", + "path": "/api/v2/integration-tokens/remove" + }, + { + "method": "POST", + "path": "/api/v2/integrations/steam" + }, + { + "method": "POST", + "path": "/api/v2/integrations/steam/refresh" + }, + { + "method": "POST", + "path": "/api/v2/integrations/steam/test" + }, + { + "method": "POST", + "path": "/api/v2/jobs/cancel" + }, + { + "method": "POST", + "path": "/api/v2/jobs/resume" + }, + { + "method": "POST", + "path": "/api/v2/jobs/retry" + }, + { + "method": "POST", + "path": "/api/v2/launch" + }, + { + "method": "POST", + "path": "/api/v2/launch/audit/refresh" + }, + { + "method": "POST", + "path": "/api/v2/launch/audit/scan" + }, + { + "method": "POST", + "path": "/api/v2/launch/core" + }, + { + "method": "POST", + "path": "/api/v2/launch/grant" + }, + { + "method": "POST", + "path": "/api/v2/launch/grant/undo" + }, + { + "method": "POST", + "path": "/api/v2/launch/preflight" + }, + { + "method": "POST", + "path": "/api/v2/launch/preflight/batch" + }, + { + "method": "POST", + "path": "/api/v2/library/dna/index/rebuild" + }, + { + "method": "POST", + "path": "/api/v2/library/dna/search" + }, + { + "method": "POST", + "path": "/api/v2/library/duplicates/merge" + }, + { + "method": "POST", + "path": "/api/v2/library/duplicates/preview" + }, + { + "method": "POST", + "path": "/api/v2/library/export" + }, + { + "method": "POST", + "path": "/api/v2/library/health/fix" + }, + { + "method": "POST", + "path": "/api/v2/library/health/scan" + }, + { + "method": "POST", + "path": "/api/v2/library/health/undo" + }, + { + "method": "POST", + "path": "/api/v2/library/manual-entry" + }, + { + "method": "POST", + "path": "/api/v2/library/manual-entry/convert" + }, + { + "method": "POST", + "path": "/api/v2/library/manual-entry/update" + }, + { + "method": "POST", + "path": "/api/v2/library/move/apply" + }, + { + "method": "POST", + "path": "/api/v2/library/move/preview" + }, + { + "method": "POST", + "path": "/api/v2/library/notes/add" + }, + { + "method": "POST", + "path": "/api/v2/library/notes/delete" + }, + { + "method": "POST", + "path": "/api/v2/library/notes/update" + }, + { + "method": "POST", + "path": "/api/v2/library/pick" + }, + { + "method": "POST", + "path": "/api/v2/library/playtime/delete" + }, + { + "method": "POST", + "path": "/api/v2/library/playtime/log" + }, + { + "method": "POST", + "path": "/api/v2/library/playtime/update" + }, + { + "method": "POST", + "path": "/api/v2/library/progress/set" + }, + { + "method": "POST", + "path": "/api/v2/library/query/parse" + }, + { + "method": "POST", + "path": "/api/v2/library/rating/set" + }, + { + "method": "POST", + "path": "/api/v2/library/repair/apply" + }, + { + "method": "POST", + "path": "/api/v2/library/repair/preview" + }, + { + "method": "POST", + "path": "/api/v2/library/sync/apply" + }, + { + "method": "POST", + "path": "/api/v2/library/sync/preview" + }, + { + "method": "POST", + "path": "/api/v2/library/sync/publish" + }, + { + "method": "POST", + "path": "/api/v2/library/sync/pull" + }, + { + "method": "POST", + "path": "/api/v2/library/time-machine/revert" + }, + { + "method": "POST", + "path": "/api/v2/library/trash" + }, + { + "method": "POST", + "path": "/api/v2/library/trash/purge" + }, + { + "method": "POST", + "path": "/api/v2/library/trash/restore" + }, + { + "method": "POST", + "path": "/api/v2/media/bulk" + }, + { + "method": "POST", + "path": "/api/v2/media/cache/cleanup" + }, + { + "method": "POST", + "path": "/api/v2/media/cleanup" + }, + { + "method": "POST", + "path": "/api/v2/media/queue/cancel" + }, + { + "method": "POST", + "path": "/api/v2/media/queue/pause" + }, + { + "method": "POST", + "path": "/api/v2/media/queue/resume" + }, + { + "method": "POST", + "path": "/api/v2/media/queue/retry" + }, + { + "method": "POST", + "path": "/api/v2/memories/import" + }, + { + "method": "POST", + "path": "/api/v2/metadata/apply" + }, + { + "method": "POST", + "path": "/api/v2/metadata/auto-scrape" + }, + { + "method": "POST", + "path": "/api/v2/metadata/candidates/apply" + }, + { + "method": "POST", + "path": "/api/v2/metadata/gog" + }, + { + "method": "POST", + "path": "/api/v2/metadata/igdb/apply" + }, + { + "method": "POST", + "path": "/api/v2/metadata/match" + }, + { + "method": "POST", + "path": "/api/v2/metadata/matches/apply" + }, + { + "method": "POST", + "path": "/api/v2/metadata/matches/decisions" + }, + { + "method": "POST", + "path": "/api/v2/metadata/matches/preview" + }, + { + "method": "POST", + "path": "/api/v2/metadata/scrape-settings" + }, + { + "method": "POST", + "path": "/api/v2/metadata/steam" + }, + { + "method": "POST", + "path": "/api/v2/metadata/sync" + }, + { + "method": "POST", + "path": "/api/v2/metadata/trailer" + }, + { + "method": "POST", + "path": "/api/v2/moments" + }, + { + "method": "POST", + "path": "/api/v2/moments/delete" + }, + { + "method": "POST", + "path": "/api/v2/moments/resume" + }, + { + "method": "POST", + "path": "/api/v2/moments/update" + }, + { + "method": "POST", + "path": "/api/v2/native/dialog" + }, + { + "method": "POST", + "path": "/api/v2/native/open-external" + }, + { + "method": "POST", + "path": "/api/v2/native/reveal" + }, + { + "method": "POST", + "path": "/api/v2/native/window" + }, + { + "method": "POST", + "path": "/api/v2/notifications" + }, + { + "method": "POST", + "path": "/api/v2/obs/attach" + }, + { + "method": "POST", + "path": "/api/v2/party/decks" + }, + { + "method": "POST", + "path": "/api/v2/party/decks/delete" + }, + { + "method": "POST", + "path": "/api/v2/party/next" + }, + { + "method": "POST", + "path": "/api/v2/party/queue" + }, + { + "method": "POST", + "path": "/api/v2/perf_profiles" + }, + { + "method": "POST", + "path": "/api/v2/platform/delete" + }, + { + "method": "POST", + "path": "/api/v2/platform/documents" + }, + { + "method": "POST", + "path": "/api/v2/platform/rename" + }, + { + "method": "POST", + "path": "/api/v2/platform/videos" + }, + { + "method": "POST", + "path": "/api/v2/playlists" + }, + { + "method": "POST", + "path": "/api/v2/playlists/delete" + }, + { + "method": "POST", + "path": "/api/v2/plugin-stats/refresh" + }, + { + "method": "POST", + "path": "/api/v2/plugins/catalog/install" + }, + { + "method": "POST", + "path": "/api/v2/plugins/command" + }, + { + "method": "POST", + "path": "/api/v2/plugins/credentials" + }, + { + "method": "POST", + "path": "/api/v2/plugins/data" + }, + { + "method": "POST", + "path": "/api/v2/plugins/install" + }, + { + "method": "POST", + "path": "/api/v2/plugins/permissions" + }, + { + "method": "POST", + "path": "/api/v2/plugins/remove" + }, + { + "method": "POST", + "path": "/api/v2/plugins/repair" + }, + { + "method": "POST", + "path": "/api/v2/plugins/settings" + }, + { + "method": "POST", + "path": "/api/v2/plugins/toggle" + }, + { + "method": "POST", + "path": "/api/v2/plugins/trust" + }, + { + "method": "POST", + "path": "/api/v2/premium/media-packs/apply" + }, + { + "method": "POST", + "path": "/api/v2/profiles" + }, + { + "method": "POST", + "path": "/api/v2/queue" + }, + { + "method": "POST", + "path": "/api/v2/ra/game" + }, + { + "method": "POST", + "path": "/api/v2/ra/inject" + }, + { + "method": "POST", + "path": "/api/v2/ra/settings" + }, + { + "method": "POST", + "path": "/api/v2/reels/create" + }, + { + "method": "POST", + "path": "/api/v2/resume" + }, + { + "method": "POST", + "path": "/api/v2/resume/discard" + }, + { + "method": "POST", + "path": "/api/v2/resume/slot" + }, + { + "method": "POST", + "path": "/api/v2/save-tools/hoard" + }, + { + "method": "POST", + "path": "/api/v2/save-tools/ludusavi" + }, + { + "method": "POST", + "path": "/api/v2/saves/add" + }, + { + "method": "POST", + "path": "/api/v2/saves/backup" + }, + { + "method": "POST", + "path": "/api/v2/saves/restore" + }, + { + "method": "POST", + "path": "/api/v2/saves/restore/preview" + }, + { + "method": "POST", + "path": "/api/v2/saves/scan/apply" + }, + { + "method": "POST", + "path": "/api/v2/screenscraper/apply" + }, + { + "method": "POST", + "path": "/api/v2/screenscraper/info" + }, + { + "method": "POST", + "path": "/api/v2/screenscraper/match" + }, + { + "method": "POST", + "path": "/api/v2/screenscraper/test" + }, + { + "method": "POST", + "path": "/api/v2/screenshot" + }, + { + "method": "POST", + "path": "/api/v2/session/cleanup" + }, + { + "method": "POST", + "path": "/api/v2/session/control" + }, + { + "method": "POST", + "path": "/api/v2/settings" + }, + { + "method": "POST", + "path": "/api/v2/setup/commit" + }, + { + "method": "POST", + "path": "/api/v2/setup/migration/acknowledge" + }, + { + "method": "POST", + "path": "/api/v2/setup/migration/backup" + }, + { + "method": "POST", + "path": "/api/v2/setup/migration/rollback" + }, + { + "method": "POST", + "path": "/api/v2/setup/preview" + }, + { + "method": "POST", + "path": "/api/v2/setup/preview/decisions" + }, + { + "method": "POST", + "path": "/api/v2/setup/preview/revalidate" + }, + { + "method": "POST", + "path": "/api/v2/shutdown" + }, + { + "method": "POST", + "path": "/api/v2/state/recover" + }, + { + "method": "POST", + "path": "/api/v2/steam/action" + }, + { + "method": "POST", + "path": "/api/v2/steambridge/apply" + }, + { + "method": "POST", + "path": "/api/v2/steambridge/preview" + }, + { + "method": "POST", + "path": "/api/v2/steambridge/remove" + }, + { + "method": "POST", + "path": "/api/v2/steambridge/remove/preview" + }, + { + "method": "POST", + "path": "/api/v2/steamgrid/apply" + }, + { + "method": "POST", + "path": "/api/v2/steamgrid/hygiene/fix" + }, + { + "method": "POST", + "path": "/api/v2/steamgrid/hygiene/undo" + }, + { + "method": "POST", + "path": "/api/v2/steamgrid/info" + }, + { + "method": "POST", + "path": "/api/v2/steamgrid/match" + }, + { + "method": "POST", + "path": "/api/v2/steamgrid/test" + }, + { + "method": "POST", + "path": "/api/v2/storefront/import" + }, + { + "method": "POST", + "path": "/api/v2/storefront/scan" + }, + { + "method": "POST", + "path": "/api/v2/storefronts/plugin/catalog" + }, + { + "method": "POST", + "path": "/api/v2/storefronts/plugin/import" + }, + { + "method": "POST", + "path": "/api/v2/tags" + }, + { + "method": "POST", + "path": "/api/v2/themes/import" + }, + { + "method": "POST", + "path": "/api/v2/themes/open-folder" + }, + { + "method": "POST", + "path": "/api/v2/themes/select" + }, + { + "method": "POST", + "path": "/api/v2/update/install" + }, + { + "method": "POST", + "path": "/api/v2/webhooks" + }, + { + "method": "POST", + "path": "/api/v2/webhooks/test" + } + ], + "host": [ + { + "method": "GET", + "path": "/api/host/capabilities" + }, + { + "method": "POST", + "path": "/api/host/launch" + }, + { + "method": "POST", + "path": "/api/host/resume" + }, + { + "method": "POST", + "path": "/api/host/shutdown" + } + ], + "v1_bridge": { + "enabled_by_default": false, + "env": "OPENBOX_ENABLE_V1_API", + "removal": "2.1.0", + "routes": [ + { + "path": "/api/v1/backup", + "methods": [ + "GET" + ] + }, + { + "path": "/api/v1/backup/create", + "methods": [ + "POST" + ] + }, + { + "path": "/api/v1/backup/restore", + "methods": [ + "POST" + ] + }, + { + "path": "/api/v1/backups", + "methods": [ + "GET" + ] + }, + { + "path": "/api/v1/bigbox/mode", + "methods": [ + "POST" + ] + }, + { + "path": "/api/v1/diagnostic", + "methods": [ + "GET" + ] + }, + { + "path": "/api/v1/emulators", + "methods": [ + "GET" + ] + }, + { + "path": "/api/v1/emulators/install", + "methods": [ + "POST" + ] + }, + { + "path": "/api/v1/extra/launch", + "methods": [ + "POST" + ] + }, + { + "path": "/api/v1/favorite", + "methods": [ + "POST" + ] + }, + { + "path": "/api/v1/filter-presets", + "methods": [ + "GET", + "POST" + ] + }, + { + "path": "/api/v1/game", + "methods": [ + "POST" + ] + }, + { + "path": "/api/v1/game/delete", + "methods": [ + "POST" + ] + }, + { + "path": "/api/v1/games/bulk", + "methods": [ + "POST" + ] + }, + { + "path": "/api/v1/games/bulk-wizard", + "methods": [ + "POST" + ] + }, + { + "path": "/api/v1/gameyfin/test", + "methods": [ + "POST" + ] + }, + { + "path": "/api/v1/health", + "methods": [ + "POST" + ] + }, + { + "path": "/api/v1/health/dedupe", + "methods": [ + "POST" + ] + }, + { + "path": "/api/v1/history", + "methods": [ + "GET" + ] + }, + { + "path": "/api/v1/import", + "methods": [ + "POST" + ] + }, + { + "path": "/api/v1/import/arcade", + "methods": [ + "POST" + ] + }, + { + "path": "/api/v1/import/epic", + "methods": [ + "POST" + ] + }, + { + "path": "/api/v1/import/heroic", + "methods": [ + "POST" + ] + }, + { + "path": "/api/v1/import/lutris", + "methods": [ + "POST" + ] + }, + { + "path": "/api/v1/import/rpcs3", + "methods": [ + "POST" + ] + }, + { + "path": "/api/v1/import/scummvm", + "methods": [ + "POST" + ] + }, + { + "path": "/api/v1/import/steam", + "methods": [ + "POST" + ] + }, + { + "path": "/api/v1/import/vita3k", + "methods": [ + "POST" + ] + }, + { + "path": "/api/v1/jobs", + "methods": [ + "GET" + ] + }, + { + "path": "/api/v1/launch", + "methods": [ + "POST" + ] + }, + { + "path": "/api/v1/library", + "methods": [ + "GET" + ] + }, + { + "path": "/api/v1/log", + "methods": [ + "GET" + ] + }, + { + "path": "/api/v1/media", + "methods": [ + "GET" + ] + }, + { + "path": "/api/v1/media/audit", + "methods": [ + "GET" + ] + }, + { + "path": "/api/v1/media/bulk", + "methods": [ + "POST" + ] + }, + { + "path": "/api/v1/media/cleanup", + "methods": [ + "POST" + ] + }, + { + "path": "/api/v1/metadata/apply", + "methods": [ + "POST" + ] + }, + { + "path": "/api/v1/metadata/match", + "methods": [ + "POST" + ] + }, + { + "path": "/api/v1/metadata/search", + "methods": [ + "GET" + ] + }, + { + "path": "/api/v1/metadata/status", + "methods": [ + "GET" + ] + }, + { + "path": "/api/v1/notifications", + "methods": [ + "GET", + "POST" + ] + }, + { + "path": "/api/v1/playlists", + "methods": [ + "POST" + ] + }, + { + "path": "/api/v1/plugins", + "methods": [ + "GET" + ] + }, + { + "path": "/api/v1/premium/media-packs", + "methods": [ + "GET" + ] + }, + { + "path": "/api/v1/premium/media-packs/apply", + "methods": [ + "POST" + ] + }, + { + "path": "/api/v1/profiles", + "methods": [ + "GET", + "POST" + ] + }, + { + "path": "/api/v1/queue", + "methods": [ + "GET", + "POST" + ] + }, + { + "path": "/api/v1/ra/inject", + "methods": [ + "POST" + ] + }, + { + "path": "/api/v1/running", + "methods": [ + "GET" + ] + }, + { + "path": "/api/v1/saves", + "methods": [ + "GET" + ] + }, + { + "path": "/api/v1/saves/scan/apply", + "methods": [ + "POST" + ] + }, + { + "path": "/api/v1/settings", + "methods": [ + "GET", + "POST" + ] + }, + { + "path": "/api/v1/shutdown", + "methods": [ + "POST" + ] + }, + { + "path": "/api/v1/state/recover", + "methods": [ + "POST" + ] + }, + { + "path": "/api/v1/storefront/import", + "methods": [ + "POST" + ] + }, + { + "path": "/api/v1/tags", + "methods": [ + "GET", + "POST" + ] + }, + { + "path": "/api/v1/themes", + "methods": [ + "GET" + ] + }, + { + "path": "/api/v1/themes/open-folder", + "methods": [ + "POST" + ] + }, + { + "path": "/api/v1/update", + "methods": [ + "GET" + ] + }, + { + "path": "/api/v1/update/install", + "methods": [ + "POST" + ] + }, + { + "path": "/api/v1/webhooks", + "methods": [ + "GET", + "POST" + ] + } + ] + } +} diff --git a/api_errors.py b/api_errors.py index 6309d6ca..37098a39 100644 --- a/api_errors.py +++ b/api_errors.py @@ -30,6 +30,13 @@ class BadRequest(ApiError): code = "BAD_REQUEST" +class V1ApiDisabled(ApiError): + """The /api/v1 surface is off unless OPENBOX_ENABLE_V1_API=1 (ADR 0091).""" + + status = 410 + code = "V1_API_DISABLED" + + class NotFound(ApiError): status = 404 code = "NOT_FOUND" @@ -57,3 +64,120 @@ class PlatformDocumentNotFound(NotFound): class RouteNotFound(NotFound): code = "ROUTE_NOT_FOUND" + + +class Conflict(ApiError): + status = 409 + code = "CONFLICT" + + +class JobStateConflict(Conflict): + code = "JOB_STATE_CONFLICT" + + +class JobNotCancellable(Conflict): + code = "JOB_NOT_CANCELLABLE" + + +class JobNotResumable(Conflict): + code = "JOB_NOT_RESUMABLE" + + +class PreviewNotFound(NotFound): + code = "PREVIEW_NOT_FOUND" + + +class PreviewExpired(Conflict): + code = "PREVIEW_EXPIRED" + + +class PreviewStale(Conflict): + code = "PREVIEW_STALE" + + +class PreviewLibraryChanged(Conflict): + code = "PREVIEW_LIBRARY_CHANGED" + + +class UnresolvedCandidates(Conflict): + code = "UNRESOLVED_CANDIDATES" + + +class PreviewLimitExceeded(BadRequest): + code = "PREVIEW_LIMIT_EXCEEDED" + + +class PreviewEntryLimitExceeded(BadRequest): + code = "PREVIEW_ENTRY_LIMIT_EXCEEDED" + + +class RangeParseError(ValueError): + """``send_file`` could not parse the request's ``Range`` header. + + A ``ValueError`` subclass so legacy ``except ValueError`` handlers keep + working; byte-serving routes catch this exact type to answer 416 without + also swallowing unrelated validation errors. + """ + + +PROBLEM_TYPE_BASE = "https://openbox.local/problems/" +_RETRYABLE_STATUSES = frozenset({408, 425, 429, 500, 502, 503, 504}) +_STATUS_TITLES = { + 400: "Bad request", + 401: "Unauthorized", + 403: "Forbidden", + 404: "Not found", + 405: "Method not allowed", + 409: "Conflict", + 410: "Gone", + 413: "Payload too large", + 415: "Unsupported media type", + 422: "Unprocessable content", + 429: "Too many requests", + 500: "Internal error", + 503: "Service unavailable", +} + + +def problem_payload(status, payload, *, request_id="", instance=""): + """Return an RFC 9457-style problem document built from a legacy error payload. + + ``code`` is the stable machine-readable key and ``type`` is derived from it, + so both survive rewording. ``error`` duplicates ``detail`` for one major cycle. + """ + title = _STATUS_TITLES.get(status, "Request failed") + message = str(payload.get("error") or payload.get("detail") or title) + code = str(payload.get("code") or f"HTTP_{status}") + body = dict(payload) + body.update({ + "type": PROBLEM_TYPE_BASE + code.lower().replace("_", "-"), + "title": title, + "status": status, + "detail": message, + "code": code, + "error": message, + "retryable": status in _RETRYABLE_STATUSES or "retry_after" in payload, + }) + if request_id: + body["request_id"] = request_id + if instance: + body["instance"] = instance + return body + + +class HostApiUnsupported(Conflict): + """A native host sent a major API version this server does not speak.""" + + code = "HOST_API_UNSUPPORTED" + + +class BackupFormatUnsupported(BadRequest): + """A backup manifest declares a format this build cannot read or write (ADR 0093).""" + + code = "BACKUP_FORMAT_UNSUPPORTED" + + +class MovePlanStale(Conflict): + """The library changed after a move preview; the preview must be run again (ADR 0090).""" + + code = "MOVE_PLAN_STALE" diff --git a/arcade.py b/arcade.py index 9a867148..c56599e6 100644 --- a/arcade.py +++ b/arcade.py @@ -1,13 +1,15 @@ """MAME and FinalBurn full-set import.""" import io -import shlex +import os import shutil import subprocess import zipfile from pathlib import Path, PurePosixPath from xml.etree import ElementTree +from pkg.platform_compat import join_command, split_command + def parse_catalog(source): catalog = {} @@ -20,10 +22,36 @@ def parse_catalog(source): child.text.strip() for child in element if child.tag.rsplit("}", 1)[-1] == "description" and child.text ), name) + display = next(( + child for child in element + if child.tag.rsplit("}", 1)[-1] == "display" + ), None) + input_node = next(( + child for child in element + if child.tag.rsplit("}", 1)[-1] == "input" + ), None) + controls = { + key: (input_node.get(key, "") if input_node is not None else "") + for key in ("trackball", "lightgun", "paddle", "spinner", "keyboard", "mouse") + } + players = element.get("players", "") + if not players and input_node is not None: + players = input_node.get("players", "") catalog[name.casefold()] = { "name": name, "description": description, "cloneof": element.get("cloneof", "").strip(), + "players": str(players or "").strip(), + "vertical": bool( + display is not None and ( + str(display.get("rotate", "0")).strip() not in {"", "0"} + or str(display.get("orientation", "")).strip().casefold() == "vertical" + ) + ), + "requires_chd": any( + child.tag.rsplit("}", 1)[-1] == "disk" for child in element + ), + "controls": controls, "merged_roms": { child.get("name", "") for child in element @@ -59,32 +87,60 @@ def load_catalog(dat_path="", source="MAME"): def zip_members(path): - if path.suffix.casefold() != ".zip": + p = path if isinstance(path, Path) else Path(path) + if p.suffix.casefold() != ".zip": return set() try: - with zipfile.ZipFile(path) as archive: + with zipfile.ZipFile(p) as archive: return {PurePosixPath(name).name for name in archive.namelist()} - except zipfile.BadZipFile: + except (zipfile.BadZipFile, OSError): return set() -def import_arcade(folder, dat_path="", command="", source="MAME", catalog=None): +def import_arcade( + folder, + dat_path="", + command="", + source="MAME", + catalog=None, + players="any", + skip_chd=False, + skip_inputs=(), +): root = Path(folder).expanduser() if not root.is_dir(): raise FileNotFoundError("The arcade ROM folder does not exist.") source = "FinalBurn Neo" if source == "FinalBurn Neo" else "MAME" - archives = { - path.stem.casefold(): path - for path in root.iterdir() - if path.is_file() and path.suffix.casefold() in {".zip", ".7z"} - } + players = str(players or "any").strip().lower() + if players not in {"any", "2", "4"}: + raise ValueError("Player filter must be any, 2, or 4.") + skip_inputs = {str(value).strip().lower() for value in (skip_inputs or []) if str(value).strip()} + allowed_inputs = {"trackball", "lightgun", "paddle", "spinner", "keyboard", "mouse"} + unknown_inputs = skip_inputs - allowed_inputs + if unknown_inputs: + raise ValueError(f"Unknown input filter: {sorted(unknown_inputs)[0]}") + archives = {} + try: + with os.scandir(root) as it: + for entry in it: + try: + if entry.is_file(follow_symlinks=True): + name = entry.name + idx = name.rfind(".") + if idx != -1 and name[idx:].casefold() in {".zip", ".7z"}: + stem = name[:idx].casefold() + archives[stem] = Path(entry.path) + except OSError: + continue + except OSError: + pass catalog = catalog if catalog is not None else load_catalog(dat_path, source) if not command: binary = shutil.which("mame" if source == "MAME" else "fbneo") if not binary: raise FileNotFoundError(f"{source} is not installed. Enter a launch command for the configured emulator.") - command = shlex.join([binary, "-rompath", str(root), "{rom_name}"]) if source == "MAME" else shlex.join([binary, "{path}"]) - if not shlex.split(command): + command = join_command([binary, "-rompath", str(root), "{rom_name}"]) if source == "MAME" else join_command([binary, "{path}"]) + if not split_command(command): raise ValueError("The arcade launch command is empty.") games = [] for key, record in catalog.items(): @@ -93,6 +149,18 @@ def import_arcade(folder, dat_path="", command="", source="MAME", catalog=None): archive = own or parent if not archive: continue + if players != "any": + try: + supported = int(record.get("players", "0") or 0) + except (TypeError, ValueError): + supported = 0 + if supported < int(players): + continue + if skip_chd and record.get("requires_chd"): + continue + controls = record.get("controls") or {} + if skip_inputs and any(controls.get(name) for name in skip_inputs): + continue if record["cloneof"] and not own: set_type = "merged" elif record["cloneof"] and record["merged_roms"] - zip_members(own): @@ -111,5 +179,9 @@ def import_arcade(folder, dat_path="", command="", source="MAME", catalog=None): "rom_name": record["name"], "clone_of": record["cloneof"], "set_type": set_type, + "players": record.get("players", ""), + "vertical": bool(record.get("vertical")), + "requires_chd": bool(record.get("requires_chd")), }) return games + diff --git a/archives.py b/archives.py index 7dab19d6..48ac02b4 100644 --- a/archives.py +++ b/archives.py @@ -2,15 +2,17 @@ import hashlib import os -import selectors import stat import shutil import subprocess import tempfile +import threading import time import zipfile from pathlib import Path +from pkg.platform_compat import user_symlink_component + MAX_ARCHIVE_MEMBERS = 25_000 MAX_ARCHIVE_MEMBER_BYTES = 2 * 1024 * 1024 * 1024 @@ -103,7 +105,7 @@ def safe_zip_extract( max_total_bytes=MAX_ARCHIVE_TOTAL_BYTES, ): destination = Path(destination) - if destination.is_symlink() or any(parent.is_symlink() for parent in destination.parents): + if user_symlink_component(destination) is not None: raise ValueError("Archive destination contains a symlink.") root = destination.resolve() with zipfile.ZipFile(archive) as package: @@ -139,7 +141,7 @@ def safe_zip_extract( if info.is_dir(): target.mkdir(parents=True, exist_ok=True) continue - if any(parent.is_symlink() for parent in [target, *target.parents] if parent != root and parent.exists()): + if user_symlink_component(target) is not None: raise ValueError(f"Archive destination contains a symlink: {info.filename}") target.parent.mkdir(parents=True, exist_ok=True) with package.open(info) as source, target.open("xb") as output: @@ -151,38 +153,55 @@ def safe_zip_extract( def _read_7z_listing(extractor, archive): - """Run 7z -slt and stream its listing back under strict bounds.""" + """Run 7z -slt and stream its listing back under strict bounds. + + A reader thread plus a deadline replaces ``selectors``: Windows only + supports select() on sockets, never on pipes. + """ process = subprocess.Popen( [extractor, "l", "-slt", str(archive)], stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, ) output = bytearray() - selector = selectors.DefaultSelector() + failure: list[BaseException] = [] + finished = threading.Event() + + def reader(): + try: + while True: + chunk = process.stdout.read1(64 * 1024) + if not chunk: + break + output.extend(chunk) + if len(output) > MAX_ARCHIVE_LISTING_BYTES: + raise ValueError("Archive listing is too large.") + except BaseException as error: # noqa: BLE001 - re-raised on the main thread + failure.append(error) + finally: + finished.set() + + thread = threading.Thread(target=reader, name="openbox-7z-listing", daemon=True) + thread.start() try: - selector.register(process.stdout, selectors.EVENT_READ) deadline = time.monotonic() + 60 - while True: - remaining = deadline - time.monotonic() - if remaining <= 0: - raise subprocess.TimeoutExpired(process.args, 60) - events = selector.select(remaining) - if not events: - raise subprocess.TimeoutExpired(process.args, 60) - chunk = process.stdout.read1(64 * 1024) - if not chunk: - break - output.extend(chunk) - if len(output) > MAX_ARCHIVE_LISTING_BYTES: - raise ValueError("Archive listing is too large.") - return_code = process.wait(timeout=60) + if not finished.wait(timeout=60): + raise subprocess.TimeoutExpired(process.args, 60) + if failure: + raise failure[0] + return_code = process.wait(timeout=max(0.1, deadline - time.monotonic())) except Exception: if process.poll() is None: process.kill() process.wait() raise finally: - selector.close() + thread.join(timeout=5) + if process.stdout is not None: + try: + process.stdout.close() + except OSError: + pass if return_code != 0: raise ValueError("7z could not inspect the archive.") return bytes(output).decode("utf-8", errors="replace") @@ -276,10 +295,23 @@ def extract_game(archive_path, cache_root, member=""): staging = Path(tempfile.mkdtemp(prefix=f".{destination.name}.extracting-", dir=destination.parent)) snapshot = None try: - snapshot = _snapshot_archive(archive, destination.parent) if archive.suffix.lower() == ".zip": - safe_zip_extract(snapshot, staging) + flags = os.O_RDONLY | getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0) + source_fd = os.open(archive, flags) + try: + source_info = os.fstat(source_fd) + if not stat.S_ISREG(source_info.st_mode): + raise ValueError("Archive source must be a regular file.") + if source_info.st_size > MAX_ARCHIVE_TOTAL_BYTES: + raise ValueError("Archive source is too large.") + with os.fdopen(source_fd, "rb") as source_file: + source_fd = -1 + safe_zip_extract(source_file, staging) + finally: + if source_fd >= 0: + os.close(source_fd) else: + snapshot = _snapshot_archive(archive, destination.parent) extractor = shutil.which("7z") or shutil.which("7zz") if not extractor: raise FileNotFoundError("7z or 7zz is required to extract this archive.") diff --git a/assets/OpenBoxGL.png b/assets/OpenBoxGL.png index d4ca1616..83354105 100644 Binary files a/assets/OpenBoxGL.png and b/assets/OpenBoxGL.png differ diff --git a/assets/OpenBoxLogo.svg b/assets/OpenBoxLogo.svg new file mode 100644 index 00000000..c2d4b5b0 --- /dev/null +++ b/assets/OpenBoxLogo.svg @@ -0,0 +1,22 @@ + + + OpenBox GL logo + Geometric OpenBox GL monogram, traced as editable vector paths. White O, G and L on a black cube silhouette, with a black B on an orange upper-right panel. Transparent background. + + + + + + + diff --git a/assets/mame/scanlines.png b/assets/mame/scanlines.png new file mode 100644 index 00000000..ac8dc5b0 Binary files /dev/null and b/assets/mame/scanlines.png differ diff --git a/assets/openbox-bigbox.png b/assets/openbox-bigbox.png index 23724f74..03b03bef 100644 Binary files a/assets/openbox-bigbox.png and b/assets/openbox-bigbox.png differ diff --git a/assets/openbox-constellation.png b/assets/openbox-constellation.png new file mode 100644 index 00000000..8e8836a8 Binary files /dev/null and b/assets/openbox-constellation.png differ diff --git a/assets/openbox-game-detail.png b/assets/openbox-game-detail.png index ff061932..2245cbd4 100644 Binary files a/assets/openbox-game-detail.png and b/assets/openbox-game-detail.png differ diff --git a/assets/openbox-game-night.png b/assets/openbox-game-night.png new file mode 100644 index 00000000..79e3d154 Binary files /dev/null and b/assets/openbox-game-night.png differ diff --git a/assets/openbox-icon-1024.png b/assets/openbox-icon-1024.png new file mode 100644 index 00000000..9c9ff6eb Binary files /dev/null and b/assets/openbox-icon-1024.png differ diff --git a/assets/openbox-logo.png b/assets/openbox-logo.png index 215eb8ce..ee940842 100644 Binary files a/assets/openbox-logo.png and b/assets/openbox-logo.png differ diff --git a/assets/openbox-mastery.png b/assets/openbox-mastery.png new file mode 100644 index 00000000..26d3da5a Binary files /dev/null and b/assets/openbox-mastery.png differ diff --git a/assets/openbox-picker.png b/assets/openbox-picker.png new file mode 100644 index 00000000..3d1fbf07 Binary files /dev/null and b/assets/openbox-picker.png differ diff --git a/assets/openbox-screenshot.png b/assets/openbox-screenshot.png index 5d84a32f..8a49efb1 100644 Binary files a/assets/openbox-screenshot.png and b/assets/openbox-screenshot.png differ diff --git a/assets/openbox-wrapped.png b/assets/openbox-wrapped.png new file mode 100644 index 00000000..25bf027d Binary files /dev/null and b/assets/openbox-wrapped.png differ diff --git a/assets/placeholder-cover.svg b/assets/placeholder-cover.svg new file mode 100644 index 00000000..db456e19 --- /dev/null +++ b/assets/placeholder-cover.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/assets/shaders/scanlines.slangp b/assets/shaders/scanlines.slangp new file mode 100644 index 00000000..f47ca48d --- /dev/null +++ b/assets/shaders/scanlines.slangp @@ -0,0 +1,10 @@ +# OpenBox bundled scanline preset. +# The preset is intentionally minimal so the file stays valid, readable, and +# dependency-free. RetroArch loads the referenced shader pass when the setting +# is enabled in Edit Emulator. +shaders = 1 +shader0 = scanlines.slang +filter_linear0 = false +wrap_mode0 = clamp_to_border +scale_type0 = source +scale0 = 1.0 diff --git a/automation.py b/automation.py index f41b84bc..b8d775af 100644 --- a/automation.py +++ b/automation.py @@ -58,7 +58,7 @@ EVENT_DATA_ALLOWLISTS: dict[str, tuple[str, ...]] = { "session.started": ("launch_id", "game_id", "name", "platform", "started_at"), "session.stopped": ( - "launch_id", "game_id", "name", "seconds", "exit_code", + "launch_id", "game_id", "name", "seconds", "exit_code", "timed_out", "started_at", "stopped_at", ), "queue.advanced": ("from_game_id", "from_entry_id", "to_game_id", "to_entry_id"), @@ -134,7 +134,8 @@ def _attempt_delay(attempt_index: int, retry_after_value: str | None = None) -> except (TypeError, ValueError): retry_after = 0.0 if retry_after > 0: - delay = min(delay, max(0.0, min(retry_after, _MAX_RETRY_AFTER))) + # Retry-After is a floor ("wait at least"), clamped to 30s. + delay = max(delay, min(retry_after, _MAX_RETRY_AFTER)) return max(0.0, delay) @@ -569,8 +570,6 @@ def _validate_webhook_host(parsed, host: str, scheme: str, *, openbox_port=None, port = 0 if openbox_port and int(openbox_port) == port: raise ValueError("Webhook URL may not point at the running OpenBox server.") - if _port_in_use(host, port): - raise ValueError("Webhook URL may not point at the running OpenBox server.") else: addresses = _resolve_addresses(host, resolver=resolver) if not addresses: diff --git a/backend_io.py b/backend_io.py index e3a02a02..67184b98 100644 --- a/backend_io.py +++ b/backend_io.py @@ -49,11 +49,14 @@ def read_limited(response, max_bytes=MAX_RESPONSE_BYTES) -> bytes: def fsync_directory(path: Path) -> None: - directory_fd = os.open(Path(path), os.O_RDONLY) try: - os.fsync(directory_fd) - finally: - os.close(directory_fd) + directory_fd = os.open(Path(path), os.O_RDONLY) + try: + os.fsync(directory_fd) + finally: + os.close(directory_fd) + except OSError: + pass def atomic_write_bytes(path: Path, data: bytes, *, mode=0o600) -> Path: @@ -126,6 +129,7 @@ def download_file( opener=urlopen, headers=None, sha256="", + mode=0o644, ) -> Path: url = validate_http_url(url) request = Request(url, headers={"User-Agent": "OpenBox/1", **(headers or {})}) @@ -167,6 +171,7 @@ def download_file( os.fsync(output.fileno()) if sha256 and digest.hexdigest().casefold() != str(sha256).casefold(): raise ValueError("The downloaded file failed checksum verification.") + os.chmod(temporary_name, mode) os.replace(temporary_name, destination) fsync_directory(destination.parent) temporary_name = None diff --git a/build_appimage.sh b/build_appimage.sh index ec48cce2..5da65781 100755 --- a/build_appimage.sh +++ b/build_appimage.sh @@ -2,7 +2,15 @@ set -euo pipefail source_root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" -output="${1:-$source_root/OpenBox-x86_64.AppImage}" +# Bundle the host interpreter and libraries, so the artifact arch follows the +# build host. OPENBOX_ARCH overrides detection (CI cross-builds set it). +arch="${OPENBOX_ARCH:-$(uname -m)}" +case "$arch" in + x86_64|amd64) arch="x86_64" ;; + aarch64|arm64) arch="aarch64" ;; + *) echo "build_appimage.sh: unsupported architecture: $arch" >&2; exit 1 ;; +esac +output="${1:-$source_root/OpenBox-$arch.AppImage}" build_root="$source_root/build" mkdir -p "$build_root/tools" temporary="$(mktemp -d "$build_root/appimage.XXXXXX")" @@ -13,19 +21,35 @@ mkdir -p "$appdir/usr/bin" "$appdir/usr/lib" "$appdir/usr/share/openbox" "$appdi python_binary="$(readlink -f "$(command -v python3)")" stdlib="$(python3 -c 'import sysconfig; print(sysconfig.get_path("stdlib"))')" python_version="$(python3 -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")')" +stdlib_platlib="$(python3 -c 'import sys; print(getattr(sys, "platlibdir", "lib"))')" +python_stdlib_dir="$appdir/usr/$stdlib_platlib/python$python_version" +mkdir -p "$(dirname "$python_stdlib_dir")" cp "$python_binary" "$appdir/usr/bin/python3" -cp -a "$stdlib" "$appdir/usr/lib/python$python_version" -find "$appdir/usr/lib/python$python_version" -type d -name __pycache__ -prune -exec rm -rf -- {} + +cp -a "$stdlib" "$python_stdlib_dir" +find "$python_stdlib_dir" -type d -name __pycache__ -prune -exec rm -rf -- {} + while IFS= read -r file; do + file="$(echo "$file" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')" [ -n "$file" ] || continue - cp "$source_root/$file" "$appdir/usr/share/openbox/$file" + [[ "$file" =~ ^# ]] && continue + if [ ! -f "$source_root/$file" ]; then + echo "build_appimage.sh: missing runtime module: $file" >&2 + exit 1 + fi + install -Dm644 "$source_root/$file" "$appdir/usr/share/openbox/$file" done < "$source_root/runtime_modules.txt" cp "$source_root/index.html" "$appdir/usr/share/openbox/index.html" +cp "$source_root/openbox.svg" "$appdir/usr/share/openbox/openbox.svg" mkdir -p "$appdir/usr/share/openbox/static" -cp "$source_root"/static/app.js "$source_root"/static/app.css "$appdir/usr/share/openbox/static/" +cp "$source_root"/static/*.js "$source_root"/static/*.css "$appdir/usr/share/openbox/static/" +find "$source_root/static" -maxdepth 1 -type f -name '*.svg' -exec cp {} "$appdir/usr/share/openbox/static/" \; +mkdir -p "$appdir/usr/share/openbox/locales" +cp "$source_root"/locales/*.json "$appdir/usr/share/openbox/locales/" mkdir -p "$appdir/usr/share/openbox/assets" cp "$source_root"/assets/openbox-logo.png "$appdir/usr/share/openbox/assets/" +install -Dm644 "$source_root/assets/placeholder-cover.svg" "$appdir/usr/share/openbox/assets/placeholder-cover.svg" +install -Dm644 "$source_root/assets/mame/scanlines.png" "$appdir/usr/share/openbox/assets/mame/scanlines.png" +install -Dm644 "$source_root/assets/shaders/scanlines.slangp" "$appdir/usr/share/openbox/assets/shaders/scanlines.slangp" mkdir -p "$appdir/usr/share/openbox/emulator_defs" cp "$source_root"/emulator_defs/*.yaml "$appdir/usr/share/openbox/emulator_defs/" install -Dm755 "$source_root/scripts/openbox-launcher.sh" "$appdir/usr/share/openbox/openbox-launcher.sh" @@ -38,7 +62,7 @@ gcc -O2 "$source_root/native_host.c" -o "$appdir/usr/share/openbox/native_host" while IFS= read -r library; do cp -L "$library" "$appdir/usr/lib/$(basename "$library")" done < <( - find "$appdir/usr/bin/python3" "$appdir/usr/lib/python$python_version/lib-dynload" -type f -print0 | + find "$appdir/usr/bin/python3" "$python_stdlib_dir/lib-dynload" -type f -print0 | xargs -0 -n1 ldd 2>/dev/null | awk '/=> \// {print $3} /^\// {print $1}' | grep -vE '/(libc|libm|libpthread|libdl|librt|ld-linux)[^/]*\.so' | @@ -60,7 +84,7 @@ export APPDIR="$app_root" export PATH="$app_root/usr/bin:${PATH:-/usr/bin:/bin}" export PYTHONHOME="$app_root/usr" export PYTHONPATH="$app_root/usr/share/openbox${PYTHONPATH:+:$PYTHONPATH}" -lib_path="$app_root/usr/lib" +lib_path="$app_root/usr/lib:$app_root/usr/lib64" python="$app_root/usr/bin/python3" data_dir="${OPENBOX_DATA_DIR:-$HOME/.local/share/openbox-game-launcher}" mkdir -p "$data_dir" @@ -103,17 +127,52 @@ ln -s usr/share/applications/io.openbox.GameLauncher.desktop "$appdir/io.openbox ln -s usr/share/icons/hicolor/scalable/apps/io.openbox.GameLauncher.svg "$appdir/io.openbox.GameLauncher.svg" ln -s io.openbox.GameLauncher.svg "$appdir/.DirIcon" +# Record manifest of bundled files and generate CycloneDX SBOM +python3 - "$build_root/sbom-manifest.json" "$appdir" <<'PY' +import hashlib, json, pathlib, sys +out_path = pathlib.Path(sys.argv[1]) +appdir = pathlib.Path(sys.argv[2]) +records = [] +for p in sorted(appdir.rglob("*")): + if p.is_file() and not p.is_symlink(): + rel = p.relative_to(appdir).as_posix() + digest = hashlib.sha256(p.read_bytes()).hexdigest() + records.append({ + "path": rel, + "sha256": digest, + "size": p.stat().st_size + }) +manifest = { + "appdir": str(appdir.name), + "file_count": len(records), + "files": sorted(records, key=lambda x: x["path"]) +} +out_path.write_text(json.dumps(manifest, indent=2, sort_keys=True) + "\n", encoding="utf-8") +PY +python3 "$source_root/scripts/gen_sbom.py" --appdir "$appdir" --out "$build_root/sbom.json" if [ -n "${OPENBOX_APPDIR:-}" ]; then preserved_appdir="$OPENBOX_APPDIR" mkdir -p "$preserved_appdir" cp -a "$appdir"/. "$preserved_appdir"/ fi -tool="$build_root/tools/appimagetool-x86_64.AppImage" -tool_url="https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-x86_64.AppImage" -tool_sha256="a6d71e2b6cd66f8e8d16c37ad164658985e0cf5fcaa950c90a482890cb9d13e0" +# Pinned to the tagged 1.9.1 release, not `continuous`: that tag is rebuilt in +# place, so a pin on it breaks the release build whenever upstream pushes (it +# did on 2026-10-04). Both hashes match the digests GitHub publishes for the +# 1.9.1 assets. To bump, pick a new tag and recompute both. +appimagetool_version="1.9.1" +tool="$build_root/tools/appimagetool-$appimagetool_version-$arch.AppImage" +tool_url="https://github.com/AppImage/appimagetool/releases/download/$appimagetool_version/appimagetool-$arch.AppImage" +case "$arch" in + x86_64) + tool_sha256="ed4ce84f0d9caff66f50bcca6ff6f35aae54ce8135408b3fa33abfc3cb384eb0" + ;; + aarch64) + tool_sha256="f0837e7448a0c1e4e650a93bb3e85802546e60654ef287576f46c71c126a9158" + ;; +esac if [ ! -x "$tool" ]; then - downloaded_tool="$temporary/appimagetool-x86_64.AppImage" + downloaded_tool="$temporary/appimagetool-$arch.AppImage" curl --proto '=https' --tlsv1.2 --location --fail --silent --show-error --output "$downloaded_tool" "$tool_url" downloaded_hash="$(sha256sum "$downloaded_tool" | awk '{print $1}')" [ "$downloaded_hash" = "$tool_sha256" ] || { @@ -128,7 +187,7 @@ tool_hash="$(sha256sum "$tool" | awk '{print $1}')" echo "build_appimage.sh: cached appimagetool checksum mismatch" >&2 exit 1 } -update_information="${OPENBOX_UPDATE_INFORMATION:-gh-releases-zsync|vindeckyy|OpenBoxGL|latest|OpenBox-x86_64.AppImage.zsync}" +update_information="${OPENBOX_UPDATE_INFORMATION:-gh-releases-zsync|vindeckyy|OpenBoxGL|latest|OpenBox-$arch.AppImage.zsync}" arguments=(-n -u "$update_information" "$appdir" "$output") -ARCH=x86_64 APPIMAGE_EXTRACT_AND_RUN=1 "$tool" "${arguments[@]}" +ARCH=$arch APPIMAGE_EXTRACT_AND_RUN=1 "$tool" "${arguments[@]}" chmod +x "$output" diff --git a/catalog.py b/catalog.py index 62742ad3..09a441aa 100644 --- a/catalog.py +++ b/catalog.py @@ -3,10 +3,159 @@ import re from datetime import datetime -PROGRESS = {"", "Playing", "Paused", "Beaten", "Completed", "Mastered", "Abandoned"} -MEDIA_FIELDS = ("cover", "background", "clear_logo", "fanart", "banner", "icon", "box_back", "box_spine", "box_3d", "title_screen", "cart_front", "cart_back", "disc", "advertisement", "manual", "video", "music") +from pkg.platform_compat import parse_timestamp + +PROGRESS = {"", "Unplayed", "Playing", "Paused", "Beaten", "Completed", "Mastered", "Abandoned"} +# Coordinator decision (ADR-0051): do NOT create a parallel completion_status +# field. "" stays the stored "unset" value; UI and queries display it as +# "Unplayed". "Abandoned" covers the plan's "Dropped" wording. +MEDIA_FIELDS = ("cover", "background", "clear_logo", "fanart", "banner", "icon", "poster", "square", "box_back", "box_spine", "box_3d", "title_screen", "cart_front", "cart_back", "disc", "advertisement", "manual", "video", "music") MAX_TAGS = 50 MAX_TAG_LENGTH = 64 +MAX_PRICE_CENTS = 1_000_000_000 +_CURRENCY_RE = re.compile(r"^[A-Z]{3}$") +_WORD_RE = re.compile(r"\w+") + + +def canon_progress(value): + """Canonical stored progress: "Unplayed" (any case) maps to "" (unset).""" + text = str(value or "").strip() + if text.casefold() == "unplayed": + return "" + return text + + +def display_progress(value): + """UI label for progress: unset "" renders as "Unplayed".""" + text = str(value or "").strip() + return "Unplayed" if not text else text + + +def clean_user_rating(value): + """Personal 0-5 star rating, distinct from metadata ``rating`` float. + + Stored as int; 0 means unrated. Raises ValueError outside 0-5. + """ + try: + rating = int(value) + except (TypeError, ValueError): + raise ValueError("User rating must be an integer from 0 to 5.") from None + if not 0 <= rating <= 5: + raise ValueError("User rating must be an integer from 0 to 5.") + return rating + + +def clean_price_cents(value): + """Price paid in integer cents; None clears it. Booleans and floats are refused.""" + if value is None or value == "": + return None + if isinstance(value, bool) or not isinstance(value, int): + raise ValueError("Price paid must be a whole number of cents.") + if not 0 <= value <= MAX_PRICE_CENTS: + raise ValueError("Price paid is out of range.") + return value + + +def clean_currency(value): + """ISO-4217-shaped three-letter currency code, uppercase.""" + text = str(value or "").strip().upper() + if not _CURRENCY_RE.fullmatch(text): + raise ValueError("Currency must be a three-letter code such as USD.") + return text + + +def normalize_notes(value): + """Normalize notes to a list of {ts, text} entries (F4d). + + Backward compatible: a legacy single string becomes one entry with an + empty timestamp; None/missing becomes []. Entries with empty text after + stripping are dropped. + """ + if value is None: + return [] + if isinstance(value, str): + text = value.strip() + return [{"ts": "", "text": text}] if text else [] + if not isinstance(value, list): + return [] + entries = [] + for item in value: + if not isinstance(item, dict): + text = str(item or "").strip() + if text: + entries.append({"ts": "", "text": text}) + continue + text = str(item.get("text") or "").strip() + if not text: + continue + entries.append({"ts": str(item.get("ts") or "").strip(), "text": text}) + return entries + + +def normalize_manual_sessions(value): + """Normalize manual playtime sessions to [{date, seconds, note}] (F4c). + + Entries are bounded: non-positive seconds are dropped. + """ + if not isinstance(value, list): + return [] + sessions = [] + for item in value: + if not isinstance(item, dict): + continue + try: + seconds = int(item.get("seconds") or 0) + except (TypeError, ValueError): + continue + if seconds <= 0: + continue + sessions.append({ + "date": str(item.get("date") or "").strip(), + "seconds": seconds, + "note": str(item.get("note") or "").strip(), + }) + return sessions + + +def total_playtime_seconds(game): + """Observed playtime plus manual playtime, seconds (F4c).""" + if not isinstance(game, dict): + return 0 + try: + observed = int(game.get("playtime_seconds") or 0) + except (TypeError, ValueError): + observed = 0 + try: + manual = int(game.get("manual_playtime_seconds") or 0) + except (TypeError, ValueError): + manual = 0 + return max(0, observed) + max(0, manual) + + +def progress_suggest_due(game, settings): + """True when the one-time "Playing?" auto-suggest may fire (F4a). + + Fires only when the progress automation would not have set a status + already (``progress_on_first_play`` is explicitly empty/None; the + launch default "Playing" counts as active automation), the kill-switch + setting ``backlog_progress_suggest`` is not False, and the per-game + ``progress_suggested`` flag is not set. + """ + if not isinstance(game, dict): + return False + settings = settings if isinstance(settings, dict) else {} + if settings.get("backlog_progress_suggest") is False: + return False + # The launch automation defaults to "Playing" when the setting is + # absent, matching _make_start_mutator; only an explicit empty/None + # value leaves room for the suggest. + if settings.get("progress_on_first_play", "Playing"): + return False # automation already handles the status; stay quiet + if str(game.get("progress") or "").strip(): + return False + if game.get("progress_suggested"): + return False + return True def normalize_tags(value): @@ -77,12 +226,12 @@ def tag_counts(games): def related_game_ids(games, selected, limit=8): """Return related game IDs scored from local metadata only.""" base = games[selected] - base_genres = set(re.findall(r"\w+", str(base.get("genre", "")).lower())) + base_genres = set(_WORD_RE.findall(str(base.get("genre", "")).lower())) ranked = [] for index, game in enumerate(games): if index == selected or game.get("hidden"): continue - genres = set(re.findall(r"\w+", str(game.get("genre", "")).lower())) + genres = set(_WORD_RE.findall(str(game.get("genre", "")).lower())) score = 2 * len(base_genres & genres) score += 8 * bool(base.get("series") and base.get("series") == game.get("series")) score += 5 * bool(base.get("collection") and base.get("collection") == game.get("collection")) @@ -94,7 +243,11 @@ def related_game_ids(games, selected, limit=8): return [index for _, _, index in sorted(ranked)[:limit]] -_BULK_ALLOWED = {"platform", "genre", "progress", "rating", "favorite", "hidden", "esrb", "custom_fields", "tags", "tags_add", "tags_remove"} +_BULK_ALLOWED = { + "platform", "genre", "progress", "rating", "user_rating", "favorite", "hidden", "esrb", + "custom_fields", "tags", "tags_add", "tags_remove", "reset_stats", + "play_count", "playtime_seconds", "last_played", "price_paid_cents", "price_currency", +} def _validate_bulk_changes(ids, changes): @@ -120,15 +273,41 @@ def _clean_bulk_fields(changes): if not isinstance(value, bool): raise ValueError(f"{field.title()} must be true or false.") clean[field] = value + elif field == "reset_stats": + if not isinstance(value, bool): + raise ValueError("Reset stats must be true or false.") + clean[field] = value + elif field in ("play_count", "playtime_seconds"): + try: + num = int(value) + if num < 0: + raise ValueError(f"{field} must be non-negative.") + clean[field] = num + except (ValueError, TypeError): + raise ValueError(f"{field} must be a valid integer.") from None + elif field == "last_played": + text = str(value).strip() + # Every date rule (played:, idle, picker recency) skips a value it + # cannot parse, so a typo like "yesterday" would hide the game silently. + if text and parse_timestamp(text) is None: + raise ValueError("last_played must be a date or date and time.") + clean[field] = text elif field == "progress": - if str(value) not in PROGRESS: + value = canon_progress(value) + if value not in PROGRESS: raise ValueError("Unknown progress value.") - clean[field] = str(value) + clean[field] = value elif field == "rating": rating = float(value) if not 0 <= rating <= 5: raise ValueError("Rating must be between 0 and 5.") clean[field] = rating + elif field == "price_paid_cents": + clean[field] = clean_price_cents(value) + elif field == "price_currency": + clean[field] = clean_currency(value) + elif field == "user_rating": + clean[field] = clean_user_rating(value) elif field == "esrb": clean[field] = str(value).strip() elif field == "custom_fields": @@ -141,6 +320,13 @@ def _clean_bulk_fields(changes): clean[field] = normalize_tags(value) else: clean[field] = str(value).strip() + if "price_paid_cents" in clean: + if clean["price_paid_cents"] is None: + clean.pop("price_currency", None) + elif "price_currency" not in clean: + raise ValueError("Currency is required when a price is set.") + elif "price_currency" in clean: + raise ValueError("Currency cannot be set without a price.") return clean @@ -165,7 +351,16 @@ def _resolve_bulk_indexes(games, ids): def _apply_bulk_patch(game, patch): - """Apply one cleaned patch dict to a game: custom_fields merge, tag changes, then the remaining fields.""" + """Apply one cleaned patch dict to a game: custom_fields merge, tag changes, reset_stats, then the remaining fields.""" + if patch.get("price_paid_cents", "") is None: + patch.pop("price_paid_cents", None) + patch.pop("price_currency", None) + game.pop("price_paid_cents", None) + game.pop("price_currency", None) + if patch.pop("reset_stats", False): + game["play_count"] = 0 + game["playtime_seconds"] = 0 + game["last_played"] = "" if "custom_fields" in patch: merged = game.get("custom_fields", {}) if not isinstance(merged, dict): @@ -195,16 +390,24 @@ def apply_progress_automation(game, settings, now=None): """Apply LaunchBox-style progress automation after a session ends.""" if not settings.get("progress_automation_enabled"): return + allowed = settings.get("progress_values") + allowed = set(allowed) if isinstance(allowed, list) else PROGRESS now = now or datetime.now() play_minutes = int(settings.get("progress_automation_play_minutes", 0) or 0) idle_days = int(settings.get("progress_automation_idle_days", 0) or 0) - if play_minutes and game.get("playtime_seconds", 0) >= play_minutes * 60: + if play_minutes and "Playing" in allowed and game.get("playtime_seconds", 0) >= play_minutes * 60: if not game.get("progress") or game["progress"] == "Paused": game["progress"] = "Playing" - if idle_days and game.get("last_played"): + if idle_days and "Paused" in allowed and game.get("last_played"): try: last = datetime.fromisoformat(str(game["last_played"])) - if (now - last).days >= idle_days and game.get("progress") == "Playing": + current = now + if (last.tzinfo is None) != (current.tzinfo is None): + # Compare an offset-carrying timestamp (bulk edit, import) in + # local wall-clock time instead of raising TypeError. + last = last.astimezone().replace(tzinfo=None) + current = current.astimezone().replace(tzinfo=None) + if (current - last).days >= idle_days and game.get("progress") == "Playing": game["progress"] = "Paused" except ValueError: pass diff --git a/cloud_sync.py b/cloud_sync.py index d096df81..baf28356 100644 --- a/cloud_sync.py +++ b/cloud_sync.py @@ -1,15 +1,41 @@ """Service-agnostic game-stat syncing through a mounted cloud folder.""" import json -import fcntl from datetime import datetime from contextlib import contextmanager from pathlib import Path from backend_io import atomic_write_text +from notifications import record_cloud_sync_outcome +from pkg.platform_compat import file_lock +from saves import SAVE_SYNC_LOCK STAT_FIELDS = ("play_count", "playtime_seconds", "last_played", "progress", "rating", "favorite") -PROGRESS = {"", "Playing", "Paused", "Beaten", "Completed", "Mastered", "Abandoned"} +PROGRESS = {"", "Unplayed", "Playing", "Paused", "Beaten", "Completed", "Mastered", "Abandoned"} + + +class CloudSyncError(Exception): + """Base error for cloud statistics sync.""" + + code = "CLOUD_SYNC_ERROR" + + def __init__(self, message, *, code=None): + super().__init__(message) + self.message = str(message) + self.code = code or self.__class__.code + + +class CloudRemoteInvalid(CloudSyncError): + code = "CLOUD_REMOTE_INVALID" + + +class LibrarySyncUnavailable(CloudSyncError): + """Full-library synchronization is disabled until a safe transport exists.""" + + code = "LIBRARY_SYNC_UNAVAILABLE" + + def __init__(self, message="Full library synchronization is unavailable pending a safe replacement."): + super().__init__(message) def nonnegative_int(value): @@ -48,21 +74,22 @@ def _timestamp(value): @contextmanager def _sync_lock(target): lock_path = Path(target).with_name(f".{Path(target).name}.lock") - lock_path.parent.mkdir(parents=True, exist_ok=True) - with lock_path.open("a+", encoding="utf-8") as lock_file: - fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX) - try: - yield - finally: - fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN) + with file_lock(lock_path): + yield def _load_remote_state(target): + if not target.is_file(): + return {}, {} try: - remote = json.loads(target.read_text()) if target.is_file() else {} - except (OSError, json.JSONDecodeError): - remote = {} - remote_games = remote.get("games", {}) if isinstance(remote, dict) and isinstance(remote.get("games", {}), dict) else {} + remote = json.loads(target.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as error: + raise CloudRemoteInvalid("Remote cloud statistics file is invalid or unreadable.") from error + if not isinstance(remote, dict): + raise CloudRemoteInvalid("Remote cloud statistics file is invalid or unreadable.") + remote_games = remote.get("games", {}) + if not isinstance(remote_games, dict): + raise CloudRemoteInvalid("Remote cloud statistics file is invalid or unreadable.") return remote, remote_games @@ -81,35 +108,90 @@ def _resolve_saved_record(remote_games, game, key): return saved, remote_key -def _merge_game_stats(game, saved, remote_newer_overall): - local_played = _timestamp(game.get("last_played")) - remote_played = _timestamp(saved.get("last_played")) - if local_played or remote_played: - # The side with the newer last_played is authoritative. - remote_wins = remote_played > local_played - else: - # No play timestamps on either side: defer to file freshness. - remote_wins = remote_newer_overall +def _local_progress_missing(game): + progress = str(game.get("progress", "")) + return progress == "" or progress not in PROGRESS + + +def _local_rating_missing(game): + try: + return float(game.get("rating", 0)) == 0 + except (TypeError, ValueError): + return True + + +def _local_favorite_missing(game): + return "favorite" not in game + + +def _pick_last_played(local_value, remote_value): + local_text = str(local_value or "") + remote_text = str(remote_value or "") + local_ts = _timestamp(local_text) + remote_ts = _timestamp(remote_text) + if local_ts > remote_ts: + return local_text + if remote_ts > local_ts: + return remote_text + return remote_text or local_text + + +def _fill_progress(game, saved): + if not _local_progress_missing(game): + return + progress = str(saved.get("progress", "")) + if progress in PROGRESS and progress: + game["progress"] = progress + + +def _fill_rating(game, saved): + if not _local_rating_missing(game): + return + try: + rating = float(saved.get("rating", 0)) + except (TypeError, ValueError): + return + if 0 <= rating <= 5: + game["rating"] = rating + + +def _fill_favorite(game, saved): + if not _local_favorite_missing(game): + return + if isinstance(saved.get("favorite"), bool): + game["favorite"] = saved["favorite"] + + +def apply_synced_stats(game, synced): + """Merge an already-merged cloud record into a game's *current* stats. + + The public half of `_merge_game_stats`, for callers that fetched the record + outside a state mutator and must apply it inside one. + + The rule is monotonic and therefore idempotent: counters take the larger of + the two, `last_played` takes the newer, and progress/rating/favorite are + filled only when the local value is missing. Applying it twice changes + nothing the second time, and applying it to a game that has been *played* + since the record was fetched cannot move that game backwards -- which is the + whole reason it exists. A blind `game[key] = record[key]` against a record + built from a pre-fetch snapshot does exactly that. + """ + _merge_game_stats(game, synced) + return game + + +def _merge_game_stats(game, saved): before = {field: game.get(field) for field in STAT_FIELDS} game["play_count"] = max(nonnegative_int(game.get("play_count")), nonnegative_int(saved.get("play_count"))) - game["playtime_seconds"] = max(nonnegative_int(game.get("playtime_seconds")), nonnegative_int(saved.get("playtime_seconds"))) - game["last_played"] = max(str(game.get("last_played", "")), str(saved.get("last_played", ""))) - if remote_wins: - if str(saved.get("progress", "")) in PROGRESS: - game["progress"] = str(saved.get("progress", "")) - try: - rating = float(saved.get("rating", game.get("rating", 0))) - if 0 <= rating <= 5: - game["rating"] = rating - except (TypeError, ValueError): - pass - if isinstance(saved.get("favorite"), bool): - game["favorite"] = saved["favorite"] - elif saved: - # Local changes win; keep newer per-field values from the remote. - for field in ("progress", "rating", "favorite"): - if saved.get(field) is not None and str(saved.get(field)) != "": - game[field] = saved[field] + game["playtime_seconds"] = max( + nonnegative_int(game.get("playtime_seconds")), + nonnegative_int(saved.get("playtime_seconds")), + ) + game["last_played"] = _pick_last_played(game.get("last_played", ""), saved.get("last_played", "")) + if saved: + _fill_progress(game, saved) + _fill_rating(game, saved) + _fill_favorite(game, saved) changed = before != {field: game.get(field) for field in STAT_FIELDS} merged = { field: game.get(field, 0 if field in {"play_count", "playtime_seconds", "rating"} else "") @@ -120,10 +202,8 @@ def _merge_game_stats(game, saved, remote_newer_overall): def _resolve_generated_at(remote, remote_generated, last_sync, timestamp): if remote_generated and last_sync >= remote_generated: - # Local state is newer; bump the remote timestamp to now. generated_at = timestamp else: - # Keep the remote timestamp when newer or first sync, so the next sync can still compare. generated_at = remote.get("generated_at", timestamp) return generated_at @@ -133,28 +213,62 @@ def sync_statistics(state, folder, now=None): if not folder.is_dir(): raise ValueError(f"Cloud sync folder does not exist: {folder}") target = folder / "openbox-statistics.json" - with _sync_lock(target): - remote, remote_games = _load_remote_state(target) - remote_generated = _timestamp(remote.get("generated_at", "")) - last_sync = _timestamp(state.get("settings", {}).get("last_cloud_sync", "")) - remote_newer_overall = remote_generated > last_sync - merged = {} - changed = 0 - for game in state["games"]: - key = game_key(game) - saved, remote_key = _resolve_saved_record(remote_games, game, key) - merged_record, record_changed = _merge_game_stats(game, saved, remote_newer_overall) - changed += record_changed - merged[key] = merged_record - if remote_key != key: - merged.pop(remote_key, None) - timestamp = now or datetime.now().astimezone().isoformat(timespec="seconds") - generated_at = _resolve_generated_at(remote, remote_generated, last_sync, timestamp) - payload = { - "format": 1, - "generated_at": generated_at, - "games": merged, - } - atomic_write_text(target, json.dumps(payload, indent=2), mode=0o600) + timestamp = now or datetime.now().astimezone().isoformat(timespec="seconds") + try: + # Serialize the remote read->merge->upload cycle against save-archive + # extraction/backups so a sync never observes a half-restored tree. + with SAVE_SYNC_LOCK: + with _sync_lock(target): + remote, remote_games = _load_remote_state(target) + remote_generated = _timestamp(remote.get("generated_at", "")) + last_sync = _timestamp(state.get("settings", {}).get("last_cloud_sync", "")) + merged = {} + changed = 0 + for game in state["games"]: + key = game_key(game) + saved, remote_key = _resolve_saved_record(remote_games, game, key) + merged_record, record_changed = _merge_game_stats(game, saved) + changed += record_changed + merged[key] = merged_record + if remote_key != key: + merged.pop(remote_key, None) + generated_at = _resolve_generated_at(remote, remote_generated, last_sync, timestamp) + payload = { + "format": 1, + "generated_at": generated_at, + "games": merged, + } + atomic_write_text(target, json.dumps(payload, indent=2), mode=0o600) + except CloudSyncError as error: + record_cloud_sync_outcome( + state, success=False, body=error.message, now=timestamp, + params={"detail": error.message}, + ) + raise state.setdefault("settings", {})["last_cloud_sync"] = timestamp + game_count = len(state.get("games", [])) + record_cloud_sync_outcome( + state, + success=True, + body=f"Synced {game_count} game(s).", + now=timestamp, + params={"count": game_count}, + ) return {"path": str(target), "games": len(payload["games"]), "merged": changed, "synced_at": timestamp} + + +# Legacy full-library protocol is disabled; preserve its filename for recovery. +# See ADR 0038. Statistics sync above remains independently supported. + +LIBRARY_SYNC_FILE = "openbox-library.json" +TOMBSTONE_FIELDS = frozenset({"deleted_at", "device_id"}) + + +def publish_library(state, folder, device_id="local", now=None): + """Reject the unsafe legacy whole-library publication protocol.""" + raise LibrarySyncUnavailable() + + +def pull_library(state, folder, device_id="local", now=None): + """Reject the unsafe legacy whole-library pull protocol.""" + raise LibrarySyncUnavailable() diff --git a/crash_report.py b/crash_report.py index 46efa108..73a8657c 100644 --- a/crash_report.py +++ b/crash_report.py @@ -6,11 +6,38 @@ from __future__ import annotations import json +import os import platform +import re +import shutil import sys from pathlib import Path from openbox_logging import read_diagnostic_log +from pkg.platform_compat import IS_MACOS + +_HOME_PATH_RE = re.compile(r"/(?:home|Users)/[^/\s'\"]+") +# Windows profiles appear as C:\Users\x, C:/Users/x, and repr/JSON-escaped +# C:\\Users\\x in log text; all three must hide the user name. +_WINDOWS_HOME_PATH_RE = re.compile(r"[A-Za-z]:(?:\\\\|\\|/)Users(?:\\\\|\\|/)[^\\/\s'\"]+", re.IGNORECASE) +_REQUEST_ID_RE = re.compile(r"\[([0-9a-f]{8})\]") + + +def tokenize_home_paths(value): + """Replace home directories and /home/ or C:\\Users\\ segments with ~.""" + home = os.path.expanduser("~") + if isinstance(value, str): + # Skip a root or unresolved home ("/" would turn every slash into ~), + # and only match whole path segments so /home/al never eats /home/alice. + if home and home.rstrip("/\\") and home != "~": + value = re.sub(re.escape(home) + r"(?![\w.-])", "~", value) + value = _HOME_PATH_RE.sub("~", value) + return _WINDOWS_HOME_PATH_RE.sub("~", value) + if isinstance(value, dict): + return {key: tokenize_home_paths(item) for key, item in value.items()} + if isinstance(value, list): + return [tokenize_home_paths(item) for item in value] + return value def system_facts() -> dict: @@ -32,25 +59,128 @@ def system_facts() -> dict: return facts -def build_report(data_dir, *, include_log=True, log_limit=250_000): - """Return the diagnostic report text; the log is redacted on read.""" +def install_channel() -> str: + if os.environ.get("FLATPAK_ID"): + return "flatpak" + if os.environ.get("APPIMAGE"): + return "appimage" + if os.name == "nt": # pragma: no cover - Windows branch exercised on windows CI + return "windows" + if IS_MACOS: # pragma: no cover - macOS branch exercised on the macOS CI job + return "macos" + return "source" + + +def desktop_session() -> dict: + return { + "type": os.environ.get("XDG_SESSION_TYPE", ""), + "desktop": os.environ.get("XDG_CURRENT_DESKTOP", "") or os.environ.get("DESKTOP_SESSION", ""), + } + + +def native_host_facts() -> dict: + attached = bool(os.environ.get("OPENBOX_NATIVE_HOST")) + return { + "attached": attached, + "webkit": attached, + } + + +def renderer_flags() -> dict: + attached = bool(os.environ.get("OPENBOX_NATIVE_HOST")) + return { + "native_host": attached, + "browser_fallback": not attached, + } + + +def disk_space_facts(data_dir) -> dict: + try: + usage = shutil.disk_usage(Path(data_dir)) + except OSError: + return {"total_bytes": 0, "free_bytes": 0} + return {"total_bytes": usage.total, "free_bytes": usage.free} + + +def package_integrity_facts() -> dict: + appimage = os.environ.get("APPIMAGE", "").strip() + if not appimage: + return {"status": "skipped", "reason": "source"} + manifest = Path(appimage).resolve().parent / "sbom-manifest.json" + if not manifest.is_file(): + return {"status": "skipped", "reason": "manifest_missing"} + try: + payload = json.loads(manifest.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return {"status": "failed", "reason": "manifest_unreadable"} + return { + "status": "ok", + "file_count": payload.get("file_count", 0), + } + + +def _library_facts(root: Path) -> tuple[int, int, int]: + library_path = root / "library.json" + try: + with library_path.open(encoding="utf-8") as source: + raw = source.read() + payload = json.loads(raw) + games = payload.get("games", []) + library_count = len(games) if isinstance(games, list) else 0 + schema_version = int(payload.get("schema_version", 0)) + library_bytes = len(raw.encode("utf-8", errors="replace")) + return library_count, schema_version, library_bytes + except (OSError, TypeError, ValueError, json.JSONDecodeError): + return 0, 0, 0 + + +def recent_request_ids_from_log(data_dir, *, limit=10) -> list[str]: + log = read_diagnostic_log(data_dir, limit=250_000) + seen = [] + for match in reversed(list(_REQUEST_ID_RE.finditer(log))): + request_id = match.group(1) + if request_id not in seen: + seen.append(request_id) + if len(seen) >= limit: + break + return list(reversed(seen)) + + +def build_preview(data_dir, *, recent_job_ids=None, recent_request_ids=None): + """Return a tokenized diagnostic preview payload without the full log.""" root = Path(data_dir) facts = system_facts() facts["data_dir"] = str(root) - try: - with (root / "library.json").open(encoding="utf-8") as source: - library_size = len(source.read().encode("utf-8", errors="replace")) - facts["library_bytes"] = library_size - except OSError: - facts["library_bytes"] = 0 - log = read_diagnostic_log(root) if include_log else "" - return json.dumps( - { - "report": "openbox-diagnostic", - "version": facts.pop("version"), - "system": facts, - "diagnostic_log": log[-log_limit:] if include_log else "", - "python_executable": sys.executable, - }, - indent=2, - ) + library_count, schema_version, library_bytes = _library_facts(root) + facts["library_bytes"] = library_bytes + request_ids = list(recent_request_ids or recent_request_ids_from_log(root)) + preview = { + "report": "openbox-diagnostic", + "version": facts.pop("version"), + "install_channel": install_channel(), + "architecture": platform.machine(), + "distro": platform.platform(), + "desktop_session": desktop_session(), + "native_host": native_host_facts(), + "renderer_flags": renderer_flags(), + "library_count": library_count, + "schema_version": schema_version, + "disk_space": disk_space_facts(root), + "package_integrity": package_integrity_facts(), + "recent_job_ids": list(recent_job_ids or []), + "recent_request_ids": request_ids, + "system": facts, + "python_executable": sys.executable, + "diagnostic_log": "", + } + return tokenize_home_paths(preview) + + +def build_report(data_dir, *, include_log=True, log_limit=250_000): + """Return the diagnostic report text; the log is redacted on read.""" + preview = build_preview(data_dir) + if include_log: + log = read_diagnostic_log(data_dir, limit=log_limit) + preview["diagnostic_log"] = log[-log_limit:] + preview = tokenize_home_paths(preview) + return json.dumps(preview, indent=2) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md new file mode 100644 index 00000000..c6d45f81 --- /dev/null +++ b/docs/CHANGELOG.md @@ -0,0 +1,2239 @@ +# Changelog + +All notable changes to OpenBox are documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## Unreleased + +## [2.0.0] - unreleased + +### Added +- Settings > About has a short support line and a Buy me a coffee button. The repository shows a Sponsor button, and the AppStream metadata includes a donation URL. +- **Portable library (ADR 0090).** Game paths can live under one library folder and be stored as `@library/...` tokens. Settings > Library > Move library previews a move, rebases the paths after you move the folder, and refuses a stale preview (`MOVE_PLAN_STALE`). Changing the folder or the path mode outside that flow is refused. +- **Library maintenance commands.** `openbox --check-library`, `--repair-library`, `--migrate-library`, and `--rollback-library` run without starting a window. `GET /api/v2/library/integrity` serves the same report. +- **Version endpoint.** `GET /api/v2/version` reports the app, API, state schema, plugin API, backup format, and sync format versions. +- **Backup format 2 (ADR 0093).** New backups record a SHA-256 for every file, and restore refuses a corrupt or unlisted file before writing anything. A 1.19-compatible export is available. +- **Problem-details errors.** API errors are `application/problem+json` with `type`, `title`, `status`, `detail`, `code`, `retryable`, and `request_id`. The `error` field is kept. +- **Library integrity check.** Settings > Library > Check integrity reports missing, unreadable, and outside-folder game paths, plus media files that were changed, deleted, or added behind the app's back. Nothing is changed by a check; the repair command rebuilds the media manifest. +- **Media manifest.** Downloads and captures are recorded in `media/manifest.json` with their size, mtime, and SHA-256, so the integrity check can tell a changed file from an untouched one. +- **Compatibility report.** Settings > About > Show compatibility report lists the library format, each theme's token fallbacks, which backups restore on which version, unknown settings keys, the public API and host channel, and the sync folder. The report is the same on every run. +- **Restore results.** A restore reports every archive member: whether its hash was checked and whether this restore wrote it. +- **Extra-app paths move with the library.** Extra apps, versions, and documents under the library folder are stored as tokens and are rebased by a move, like the game path. +- **Plugin API v2 (ADR 0092).** A plugin may declare `"api_version": 2`, the oldest OpenBox that serves it (`min_openbox`), and the events it subscribes to. A v2 plugin is served by a JSON Lines runner with a 4 MiB output cap, and the host services it can ask for are versioned and read-only. A v1 plugin keeps the v1 runner and the v1 payloads unchanged; the v1 runner goes in 3.0. `docs/plugin-api-v2.md` is the reference, and `plugins/openbox.sample-v2/` is a working sample. + +### Changed +- **2.0 readiness on the first launch.** The migration gate lists what needs a look before the upgraded library is used: a theme with a token the app does not know, a plugin with an API version this build does not serve, a missing sync folder, an archive with no format, a library over the formal scale, or a custom data directory. A backup can be written from the gate before the library is opened. The 1.19.2 preflight release was dropped, so these checks run here instead. +- **Search projection rebuilds in the background (ADR 0101).** A write queues a debounced rebuild of the SQLite read model instead of rebuilding it on the request. A search that arrives before the rebuild lands answers from the canonical library and reports `source: "json"`; the next one is served from the projection. A corrupt projection is deleted and rebuilt, and a projection that disagrees with the library drops back to the JSON path. +- **The library filter chips count off the UI thread.** The platform and platform-category counts come from the search worker, so a large library no longer spends the UI thread counting chips on every render. A reply for an older request is ignored, and without a worker the counts are computed on the UI thread as before. +- **Library format 7 (ADR 0089).** Libraries upgrade on first start. A copy of the old file is kept as `library.v6.backup.json`, and `openbox --rollback-library --yes` restores it. Changes made after the upgrade are listed before a rollback discards them. 1.19.x cannot read a format 7 library. +- **Public API is `/api/v2` (ADR 0091).** The frontend and the native windows use `/api/v2` and `/api/host`. `/api/v1` is off by default; `OPENBOX_ENABLE_V1_API=1` restores it through 2.0.x and returns 410 otherwise. Native windows from a different major API version are refused with `HOST_API_UNSUPPORTED`. +- **Flat `parity_*` imports are gone.** Parity modules are imported as `pkg.parity.*`; `scripts/check_flat_imports.py` keeps it that way. +- **Sessions are written to `sessions.json`** for crash recovery and are no longer stored in `library.json`. + +### Fixed +- **The library commands work on Windows.** `openbox --rollback-library` refuses cleanly instead of failing when the terminal cannot answer the confirmation, and a corrupt search projection is released before it is deleted, so it is rebuilt rather than left broken. `pkg/state/library_cli.py`, `pkg/state/sqlite_readmodel.py`. +- **A restored trash entry expires on every platform.** A `trashed_at` stamp written in UTC (`Z`) is read like any other, so an entry from a backup is pruned once it is old instead of being kept for ever. `state_store.py`. + +## [1.19.1] - 2026-10-28 + +A cross-platform bug sweep. Every platform-specific control is now decided by one server-computed +capability contract, so a Linux-only control is hidden on Windows and macOS instead of failing at +click time, and the controls those platforms do have use their own tools. Linux behavior is the +reference and is unchanged. Upgrade from 1.19.0. + +### Fixed +- **A stored gamescope preset no longer breaks Windows and macOS launches.** The preset is applied + only on Linux, so it can no longer be prepended to a Windows or macOS command line; the preset, + MangoHud, and handheld-performance controls are hidden on those hosts, and `web_app.py --game-mode` + refuses to start there with the Linux-only message instead of entering a broken guest mode. + `pkg/state/launch.py`, `web_app.py`, `pkg/parity/parity_gamescope.py`, + `tests/test_launch_gamescope_preset.py`, `tests/test_macos_platform_fallbacks.py`. +- **Windows reports its real battery.** `/api/native/battery` reads `GetSystemPowerStatus` instead of + answering "no battery" everywhere: a desktop reports absent, a laptop reports its percentage and + charging state, and the reading is cached for 30 seconds like the macOS one. `pkg/platform_compat.py`, + `handlers/native.py`, `tests/test_platform_compat.py`, `tests/test_native_handlers.py`. +- **Wine, Faugus, and Flatpak management answer only on Linux.** The Wine/Proton manager reports + `available: false` and never scans the disk on Windows and macOS, the Faugus scanner returns + nothing, the Flatpak folder grant refuses the request before any subprocess runs, and the Launch + Doctor no longer offers a Flatpak install on a platform without Flatpak. `pkg/parity/parity_wine.py`, + `pkg/parity/parity_faugus.py`, `handlers/wine.py`, `pkg/parity/parity_flatpak_grant.py`, + `pkg/parity/parity_launch_doctor.py`, `pkg/parity/parity_emulator_defs.py`, + `tests/test_wine_faugus.py`, `tests/test_parity_flatpak_grant.py`, `tests/test_launch_doctor.py`, + `tests/test_emulators.py`, `tests/test_platform_route_guards.py`. +- **Storefront browsing uses the platform URL opener.** The Steam and Heroic catalogs no longer assume + `xdg-open`; they use the same opener as the rest of the app, say clearly when a host has none, and a + Git/MSYS `xdg-open` no longer wins over Explorer on Windows. `pkg/parity/parity_storefront.py`, + `importers.py`, `pkg/platform_compat.py`, `tests/test_parity_storefront.py`, + `tests/test_platform_compat.py`, `tests/test_macos_platform_fallbacks.py`. +- **The storefront list and its import routes match the host.** `/api/v2/storefronts` lists Heroic on + Linux and Windows, Lutris only on Linux, and GOG/EA/Ubisoft only on Windows; the native Windows + import POSTs refuse other hosts with a clear 400 and never run their discoverer. + `handlers/imports.py`, `tests/test_windows_storefront_http.py`. + +### Changed +- **One capability contract drives every platform-specific control.** `GET /api/native/capabilities` + now carries a `features` object (gamescope, mangohud, handheld_perf, flatpak, wine, faugus, lutris, + heroic, epic, native_storefronts, desktop_entry), and the UI gates every one of those controls from + `data-feature` instead of per-OS markup markers. A missing object means every feature is false, so + the failure mode is a hidden control, never a broken one. `pkg/platform_compat.py`, + `handlers/native.py`, `static/state.js`, `static/app.js`, `static/setup.js`, `static/settings.js`, + `index.html`, `docs/adr/0086-platform-capability-contract.md`, `docs/native-host-contract.md`, + `tests/test_capability_contract.py`. +- **PARITY and the reliability catalog state the gated behavior.** The Windows and macOS channels now + name the battery, Wine, Flatpak, Lutris/Faugus, and gamescope rows as capability-gated, and six new + reliability rows cover the mismatches. `docs/PARITY.md`, `docs/reliability.md`, + `scripts/contracts/reliability.json`. + +## [1.19.0] - 2026-10-27 + +OpenBox now runs natively on macOS. The release adds a universal app bundle for Apple Silicon and Intel, +a WKWebView host with the same window, tray, dialogs, deeplinks, and verified updater as the other +platforms, macOS path and process handling, and macOS values for every bundled emulator definition. +Linux-only features are hidden there rather than broken. The Linux and Windows behavior is unchanged. +Upgrade from any 1.18.x release. + +### Added +- **OpenBox runs on macOS 11 or newer.** `OpenBox-macos.zip` is one universal app bundle carrying arm64 + and x86_64 builds of the WKWebView host and one merged CPython 3.12, so a Mac needs nothing installed. + It has the same native bridge, `NSStatusItem` tray, native dialogs, `openbox://` deeplinks, window + geometry, and verified in-app updater; the updater replaces the bundle after the server exits and keeps + the previous one as `OpenBox.app.previous`. `native_host_mac.m`, `scripts/build_macos_app.sh`, + `scripts/install-macos.sh`, `docs/adr/0085-macos-support.md`. +- **macOS uses native paths and process identity.** Library data lives in + `~/Library/Application Support/openbox-game-launcher`, process identity comes from libproc, path + comparison folds Unicode (NFC) and case, symlinked save targets are refused, and app bundles resolve + through `Contents/Info.plist`. `pkg/platform_compat.py`, `tests/test_platform_compat_darwin.py`, + `tests/test_symlink_refusals.py`. +- **Every emulator definition says whether it ships a macOS build.** `native_exe_macos` accepts an app + bundle (`RetroArch.app`), a bare program (`mame`), or an explicit empty value when no macOS build is + verified; an empty value is reported as not installed instead of launching a Linux binary, and the + Flatpak install controls are hidden. `emulator_defs/*.yaml`, + `pkg/parity/parity_emulator_defs.py`, `pkg/parity/parity_launch_doctor.py`, `static/settings.js`. +- **macOS saves and desktop features.** Save discovery covers Steam Cloud plus the documented macOS + folders for PPSSPP, RPCS3, Dolphin, and RetroArch; the Big Box battery indicator reads `pmset`; + screenshots use `screencapture`; the tray and recent-games menu behave as on the other hosts. + `saves.py`, `pkg/platform_compat.py`, `handlers/native.py`. +- **macOS is a CI and release target.** CI runs the suite and the portable gates on `macos-15` and + `macos-15-intel`, and the release workflow builds, smoke-tests, signs, and attaches the bundle next to + the Linux and Windows artifacts. `.github/workflows/ci.yml`, `.github/workflows/release.yml`, + `.github/workflows/release-smoke.yml`. + +### Changed +- **Linux-only features are hidden on macOS instead of failing.** Heroic, Lutris, Epic, Faugus, Proton + scanning, Flatpak emulator installs, and the plugin sandbox are not offered there, and Game Mode exits + with "Game Mode needs gamescope and is only available on Linux." The Linux and Windows paths are + unchanged. `static/settings.js`, `web_app.py`, `handlers/native.py`. +- The version gate now reads the release version the bug-report template advertises instead + of looking for the version string anywhere in the file, so a stale "current release is + v1.16.0" line fails `make version-check` again. `scripts/check_version_sync.py`. + +### Fixed +- **Backups no longer carry provider tokens or the OBS WebSocket password.** The redaction + list covered the Gameyfin password and webhook secrets but not the per-provider + integration tokens (IGDB, ScreenScraper, SteamGridDB, Gameyfin, RetroAchievements) or + `obs_websocket_password`, so a default backup wrote both into `settings.json` and + `library.json` in cleartext. Both are now redacted like every other credential, and a + restore still preserves the local copy. `pkg/parity/parity_redact.py`, + `tests/test_backup_redaction.py`. +- **The Windows installer explains itself when the release verifier is missing.** The + standalone `install.ps1` asset is shipped next to the verifier it trusts, so running it + on its own now fails with the supported flow (extract the release archive and run + `OpenBox\scripts\install.ps1`) instead of "run this script from a checkout". + `scripts/install.ps1`, `tests/test_install_scripts.py`. +- **A native `openbox://resume/` deeplink is no longer ignored.** The host resumes the + session server-side and then loaded the window at `?deeplink=resume&id=`, which the page + did not handle; the page now selects the game and scrolls it into view, and + `?deeplink=showgame&id=` scrolls as the contract describes. `static/app.js`, + `tests/test_frontend_contract.py`. + +## [1.18.0] - 2026-10-19 + +A release that makes OpenBox a clearer and deeper library manager. The first-run pass consolidates actions, adds +guided setup and help, and surfaces the most recently played game; the rest of the release adds platform +administration, durable media automation, plugin capability services, save manifests with selective restore, richer +emulator and Big Box controls, and a broad LaunchBox parity pass. No v1 route is removed or changed. +Upgrade from any 1.17.x release. + +### Added +- **Big Box input parity.** Pointer drag, mouse wheel, trackball, and spinner input navigate Big Box with configurable + sensitivity and wheel speed; holding Back opens the menu, and a battery indicator reads the Linux sysfs battery + through the new `GET /api/native/battery` route. The game editor's path pickers open at the current file's folder or + the first game on the same platform, and Big Box can hide platforms, categories, and playlists. + `static/pointer_nav.js`, `static/battery.js`, `handlers/native.py`, `tests/test_native_handlers.py`. +- **Keyboard and controller remapping.** Settings > Controller records keyboard shortcuts for play, back, menu, pause, + paging, search, settings, and the reader; controller buttons can be cleared, and duplicate assignments are swapped + only after confirmation. `static/settings.js`, `handlers/settings.py`, `tests/test_settings_schema.py`. +- **Filter exclusions and richer rules.** Platform and category filters cycle include → exclude → clear on repeated + clicks, and the Source filter has a matching **Exclude** toggle; presets and playlists accept source, + achievement-source, region, text operators, and + exclusions. Exclusions survive a refresh through the hash route. `pkg/parity/parity_filter_presets.py`, + `static/state.js`, `static/library.js`, `static/router.js`, `tests/test_parity_features.py`. +- **Configurable progress values.** Progress automation and the game editor only offer the labels enabled in Settings; + ineligible labels are rejected at the API boundary. `catalog.py`, `handlers/library.py`, `tests/test_catalog.py`. +- **Sidebar ordering.** Settings can reorder the sidebar and hide the Explorer section. +- **Discovery achievements list.** The Discovery Center gains a "Games with achievements" shelf that also recognizes + per-provider achievement totals. `pkg/parity/parity_discovery.py`, `static/storefront.js`. +- **Platform administration.** A platform view can rename a platform across games, launch profiles, filter playlists, platform settings, and platform media, or delete one by typing its name; deletion moves games to Trash. `pkg/parity/parity_platform_admin.py`, `handlers/data.py`, `static/library.js`. +- **Steam missing and repair states.** Imported Steam games now record whether their install folder still exists. Missing games show a red Missing badge and an Install handoff; installed games offer Verify files through `steam://validate/`. `importers.py`, `pkg/parity/parity_storefront.py`, `pkg/state/cache.py`. +- **Per-provider integration tokens.** Optional IGDB, ScreenScraper, SteamGridDB, Gameyfin, and RetroAchievements tokens are stored outside the public settings projection and shown only masked. `pkg/parity/parity_integration_tokens.py`. +- **Save manifests and selective restore.** Backups record each file's size, mode, mtime, and SHA-256. A version-history dialog lists every older backup with add/overwrite/unchanged counts; the restore dialog lists archive contents, supports a dry run, and restores only checked files after a safety backup. `saves.py`, `static/sessions.js`. +- **Bulk find and replace.** Bulk Edit can preview and apply a literal replacement in one metadata field across the selected games. `pkg/parity/parity_bulk_edit.py`, `static/app.js`. +- **Controller types and per-platform emulator overrides.** Settings define controller types shown as chips in the game editor and as one badge per type on library cards; a platform can override its emulator adapter, RetroArch core, and extra launch arguments. `settings_schema.py`, `handlers/settings.py`, `pkg/state/launch.py`, `static/state.js`. +- **Tray recent games.** OpenBox writes a private recent-game list at boot; the Linux and Windows tray menus launch the eight most recent games directly. `pkg/parity/parity_tray_recent.py`, `native_host.c`, `native_host_win.c`. +- **Launch deeplink.** `?deeplink=launch&id=` starts a game, used by the tray menu. +- **Media automation.** Automatic imports queue poster, square, video, music, and manual downloads with global and + per-platform limits; the durable queue survives restarts and supports pause, resume, cancel, retry, and per-item + errors. Folder and storefront imports enqueue their matched LaunchBox or metadata-provider games, so the queue is + fed by real imports rather than only bulk actions. EmuMovies joins LaunchBox, SteamGridDB, IGDB, ScreenScraper, and + plugin media providers, now covering covers, box backs/spines/3D boxes, screenshots, title screens, marquees, + cart fronts and backs, discs, clear logos, theme and snap videos, manuals, and music. The queue response includes + `items`, `queue`, and per-state `counts`, and existing files are preserved unless overwrite is requested. + `pkg/parity/parity_media.py`, `pkg/parity/parity_emumovies.py`, `handlers/media.py`, + `tests/test_parity_media_queue.py`. +- **Plugin platform expansion.** Manifests declare storefront, metadata, media, stats, emulator-settings, and discovery + capabilities; capability hooks get a bounded data bag, settings, and credential store and may request read-only + library, achievement, profile, activity, leaderboard, catalog, metadata, and media services. Plugin storefronts use + the shared catalog/import UI, plugin stats merge monotonically into library projections, plugin discovery lists + join the Discovery Center, and plugin notifications resolve localized messages. Disabling or removing a plugin + deletes its imported games and stops its cached stats from merging; removal also clears its settings, trust, + permissions, data, credentials, and stats cache. Metadata results persist their provider so bulk media can + prioritize it, and the catalog browser shows each entry's author and homepage. Capability hooks opt into the + one-round host-service contract; there is no standalone `services` hook. Backups exclude the capability credential + store and blank password-format plugin settings. `plugins.py`, `pkg/parity/parity_plugin_*.py`, `handlers/extensions.py`, + `docs/plugin-api.md`, `tests/test_plugin_*.py`. +- **Metadata provider priority and local verification.** Metadata and media searches can mix built-in and plugin + providers in a configured order, candidates carry their source, and bulk downloads can prioritize plugin results. + LaunchBox media downloads verify HTTP status, content type, and payload before writing; local database imports keep + retry/progress reporting. Partial-word metadata search prefers exact matches and filters by platform. + `pkg/parity/parity_metadata_providers.py`, `metadata.py`, `tests/test_metadata_providers.py`. +- **Emulator management.** Manage Emulators gains an Edit Emulator dialog with a managed flag, per-platform overrides, + and install/version status; MAME and RetroArch settings cover display, fullscreen, effects, run-ahead, rewind, and + upscaling and render into launch arguments or `--appendconfig`. Update-all probes installed versions and honours a + per-emulator opt-out. MAME imports can filter by player count, skip CHD-dependent sets, and generate vertical + playlists. `pkg/parity/parity_emulator_settings.py`, `handlers/emulators.py`, `emulators.py`, `arcade.py`. +- **Storefront scanning and imports.** Startup scans report per-source progress, skip sources with no library games, + stop after the 30-second budget, and preserve partial state. Per-source auto-import stores the target platform and + can include owned-but-uninstalled games; imports can restrict to new games only. PICO-8, MSU-1, and WHDLoad join + the source list, and RPCS3 imports external folders and ISOs from `games.yml`. `pkg/parity/parity_storefront.py`, + `pkg/parity/parity_legacy_import.py`, `pkg/state/imports.py`. +- **Big Box presentation.** Reader controls are remappable and persist theme/spread; image cells and Ctrl+I open the + fullscreen lightbox with labels and integer scaling; 3D cassette and jewel-case models render box art; Big Box + videos gain controls and audio mix, startup video plays once, themes may add background music and a sound pack, and + the details window remembers its position and size. `static/reader.js`, `static/library.js`, `static/model3d.js`, + `static/bigbox.js`, `static/sfx.js`, `native_host.c`. +- **3D model controls.** Cassette models offer straight or angled backs and clear or solid cases, the rotation lock can + be free, horizontal, or vertical, and each can be overridden per platform; the selected CoverFlow card now uses the + same 3D model instead of a flat image. `static/model3d.js`, `static/library.js`, `static/bigbox.js`, + `handlers/settings.py`, `tests/test_parity_platform_admin.py`. +- **Achievements, high scores, and netplay.** Steam achievements and playtime import through the Steam Web API with + monotonic playtime/last-played merges; achievement tabs show provider totals and source filters, and manual + overrides persist. MAME high scores parse hi2txt's JSON or pipe-delimited score table, import/export, and render in + a searchable table. + Automatic matching also reads WiiWare WADs with the rcheevos TMD/content layout. RetroArch's public HTTPS netplay lobby is available from Tools > Online play and the Big Box menu. + `pkg/parity/parity_steam_achievements.py`, `pkg/parity/parity_highscores.py`, `pkg/parity/parity_netplay.py`. +- **Resume slots, disc hashing, and plugin settings.** A game can store a RetroArch save-state slot from its Save + management card, and automatic RetroAchievements matching expands to M3U playlists, PlayStation CHD, and + GameCube/Wii RVZ through `chdman` or `dolphin-tool`. Notifications fetch their unread count on boot (without opening + the center), and enabled plugin settings render under Settings > Plugins with the shared schema form; palette + commands refresh per open and never run for a disabled plugin. `handlers/resume.py`, `retroachievements.py`, + `static/plugin_form.js`, `static/palette.js`, `tests/test_resume_http.py`, `tests/test_retroachievements.py`. +- **Plugin repair and achievement refresh.** Installed plugins can be reinstalled from their catalog entry from the + Plugins browser without losing settings, stats, or trust, and achievement tabs refresh Steam and plugin sources + independently of RetroAchievements. The launch audit also appends a compact rolling history to + `cache/launch_audit.jsonl` for trend checks. `handlers/extensions.py`, `handlers/metadata.py`, + `pkg/parity/parity_launch_audit.py`, `tests/test_parity_admin_http.py`, `tests/test_launch_audit.py`. +- **Audit source filtering and bulk media moves.** Library checkup now shows a Source column, per-source counts, and a + source filter; plugin sources use their display names. Bulk platform edits can move selected games' media with the + field update committed even when a move fails, reporting `media_errors` for retry. `static/settings.js`, + `handlers/library.py`, `pkg/parity/parity_platform_admin.py`. +- **Big Box pause high scores.** The running-game panel now shows, exports, and imports MAME high scores for arcade + entries, using the same endpoints as the library details pane. `static/bigbox.js`, `index.html`. +- **Settings, lifecycle, and media caches.** Options search scans every category and focuses the field; a font-family + picker, custom-field dropdown options, platform-document ordering, report-issue link, save-management toggle, + startup error surface, and Big Box shutdown-on-exit complete the settings surface. A shared + placeholder cover replaces repeated missing-art text, and the media cache cleanup route removes stale generated + files. `static/settings.js`, `handlers/settings.py`, `assets/placeholder-cover.svg`. +- **Newcomer GUI pass.** The top bar collapses four add actions into one Add games menu; Setup Center opens on a + six-step quick path with advanced steps behind a toggle; Help & shortcuts (`F1` / `?`) adds a glossary and the real + shortcut table; the empty library shows one illustrated card; the details pane adds a dismissible three-step + onboarding checklist; a Jump back in hero surfaces the most recently played game with Play and View details; the + Themes dialog gains a swatch gallery; and the default palette uses a brand-orange action ramp with teal focus. + Play insights now follows the grid so games are visible on the first screen. Tool actions are grouped Start here / + Library / Imports & extras / More, the details-pane Launch Doctor card reads Launch check, the Health tool and its + dialog read Library checkup (previously Health and Library Audit), Online play is a new Tools entry for the netplay + lobby, and the arcade kiosk overlay is titled Kiosk mode (previously Museum). `index.html`, `static/help.js`, + `static/setup.js`, `static/library.js`, `static/palette.js`, `static/app.js`, `static/app.css`, `static/settings.js`, `themes/*.css`, `locales/*.json`. +- **Localization note.** In `es`, `de`, `fr`, and `pt`, the renamed and newly added Tools and top-bar entries (including the + four group headings and all 17 hints), the Add games menu, and the new setup, help, onboarding, and Jump back in copy are + English: 52 of the 55 renamed or new `tools.*` and `nav.*` keys. The PICO-8, MSU-1, and WHDLoad import labels are + translated. Untouched entries such as Massenbearbeitung and Verlauf keep their existing translations. The i18n gate verifies + key and placeholder parity, not translation quality. Native copy for these blocks will follow in a translation pass. + +### Fixed +- Cross-device platform media moves flush the copied file through a writable handle and tolerate platforms that + cannot `fsync` it, so Windows migrations no longer fail with a bad file descriptor. + `pkg/parity/parity_platform_admin.py`, `tests/test_parity_platform_admin.py`. +- Manage Emulators shows the probed emulator version string and an explicit update-checks-off note instead of + rendering nothing for an installed emulator. `handlers/emulators.py`, `static/settings.js`. +- Saving settings no longer rejects an older sidebar order that still contains section ids from an earlier build. +- Gameyfin auto-import now honors `auto_import_excluded_platforms` and its include-owned-but-uninstalled option, like the other storefronts. +- Legacy boolean `storefront_auto_import` entries are migrated to the per-source options shape on the next settings save, + so enabled stores keep importing after the upgrade. + +### Changed +- `storefront_auto_import` values are now `{enabled, platform, include_uninstalled}` objects; readers accept the old + boolean shape until it is migrated. + +### Documentation and gates +- ADRs 0077-0084: plugin capability services, the emulator-settings contract, the GUI clarity and colour system, + the durable media queue, save manifests with selective restore, platform rename/delete with scoped media + migration, native tray recents with the launch deeplink and battery route, and per-source storefront auto-import. +- `docs/reliability.md` rows 98-122, each naming a real test or a written manual procedure. +- The route and settings contracts are regenerated; no v1 route is removed or changed. + +## [1.17.0] - 2026-10-18 + +A release that brings the other Windows launchers into the library, remembers Game Night decks, records what each game +cost, and translates server notices. No v1 route is removed or changed. Six routes are added: +`GET`/`POST /api/v2/party/decks`, `POST /api/v2/party/decks/delete`, and +`POST /api/v2/import/{gog,ea,ubisoft}`. + +### Added +- **Native Windows storefront imports.** GOG Galaxy, EA app, and Ubisoft Connect games installed on Windows are found + from the registry and the launchers' own manifests, then merged by a stable provider id. GOG launch tasks are kept + inside the install dir and run their executable directly; EA and Ubisoft launch through their registered + `origin2://` and `uplay://` handlers. Every id is digit-validated, every path is checked, and a malformed entry is + skipped with an error rather than crashing the import. `pkg/parity/parity_storefront_windows.py`, + `tests/test_parity_storefront_windows.py`, `tests/test_windows_storefront_http.py`. +- **Game Night decks.** A deck stores players, a minute budget, and a filter query; saving is bounded to 20 decks. + Building a queue returns an 8-character seed, and the same seed and library reproduce the same draw on another + device. The deck dialog can save, load, and delete decks. `pkg/parity/parity_party.py`, + `tests/test_parity_party.py`, `tests/test_handlers_party.py`. +- **What the library cost.** A game can record a price paid and its currency. Play Insights shows totals grouped by + currency plus cost per hour for games with at least a minute played, and the detail pane shows the price and rate. + Bulk edit can set or clear a price. No exchange-rate source is involved and no conversion is performed. + `catalog.py`, `pkg/parity/parity_insights.py`, `tests/test_catalog.py`, `tests/test_insights.py`, + `tests/js/pure.test.mjs`. +- **Translated server notices.** Cloud sync, launch-check changes, rejected definition packs, and webhook failures now + carry `title_key`/`body_key` plus parameters, and the notification center translates them in all five locales. Older + stored notifications keep their English text and still render. `notifications.py`, `pkg/state/sse.py`, + `tests/test_notifications.py`. +- **A currency setting.** Settings > Library selects the default currency for new prices. `tests/test_settings_schema.py`. + +### Fixed +- **ScreenScraper lookups failed with HTTP 400 (#52).** Requests used parameter names the API does not read: the login + went out as `user=`, the ROM hashes as `rommd5`/`romsha1`/`romcrc`, and archives as `romtype=zip`, a value the API does + not document. Requests now send `ssid`/`sspassword` and `softname`, the hashes as `md5`/`sha1`/`crc` with `romtaille`, + and `romtype` as `rom` or `iso`. The Test button called `userInfos.php`, which does not exist; it now calls + `ssuserInfos.php`. Errors carry the server's own reason, and refusals the server sends as plain text are no longer + retried. A ScreenScraper developer ID and password are now required (`SCREENSCRAPER_DEV_ID` and + `SCREENSCRAPER_DEV_PASSWORD`), because the API refuses lookups without them; they were documented as optional. + `tests/test_screenscraper.py`, `tests/test_gapfill_f2.py`. + +### Documentation and gates +- ADRs 0073-0076: Windows native storefront imports, Game Night decks, price paid/cost per hour, and translated + server notices. +- `docs/reliability.md` rows 87-97, each naming a real test or a written manual procedure. +- The route, settings, and reliability contracts are regenerated; `v1_contracts.json` is unchanged. +- The 1.16.1 entry's “folded into 1.17.0” note is corrected to “shipped in 1.16.1”, and its duplicated + `perf_bench.py --browser` known-limit bullet is removed. + +### Known limits in 1.17.0 +- Xbox PC package scanning and owned-but-uninstalled GOG/EA/Ubisoft catalogs are not imported. In 1.17.0 native + storefront imports are manual; 1.18.0 adds opt-in per-source startup auto-import for them. +- Provider ids, price paid, and decks stay local. They do not cross the library-sync protocol, and Time Machine does + not journal price changes; adding them would make a mixed-version peer reject the whole event. +- Cost totals are grouped by currency and are not converted. A rate is shown only for a priced game with at least + one minute of playtime. +- GOG games launch their executable directly, so Galaxy overlay and cloud-save features are not started. EA and + Ubisoft games launch through their launcher URI; uninstalling the launcher breaks those launches until it returns. +- Notification titles and bodies are translated. Other server responses and some dialog labels remain English. +- Windows ARM64 and macOS remain out of scope. The Flatpak RetroArch check still covers one Flathub build and one + core, as recorded in the 1.16.1 limits. + + +## [1.16.1] - 2026-10-08 + +A release that finishes launch readiness and includes the fixes from the 1.16.1 cycle. It finishes launch readiness, adds launch definitions +for 18 more systems, and keeps the library live and responsive. No route is removed or changed. Five routes are +added: `GET /api/v2/launch/audit/status`, `POST /api/v2/launch/grant`, `POST /api/v2/launch/grant/undo`, +`GET /api/v2/launch/cores` and `POST /api/v2/launch/core`. + +### Headlines +- **Launch readiness everywhere.** Games that will not launch are marked on the grid. Groups of games that share one + fix get one button for the group, a missing-file group relinks from one place, and a game can choose its RetroArch + core. The check says when its results change. +- **Every system out of the box.** Launch definitions for 18 more systems, each RetroArch definition names its core + once, and the definition pack is signed and released with each tag. Disc images that several systems share ask which + system they belong to. +- **Always live and fast.** One live connection per tab, a search no longer moves the panels below it, and the browser + benchmark measures first render, long tasks and layout shift. + +### Security and data safety +- **A stale window could edit a different game (B1).** When the library changed after an edit + dialog opened (a delete, trash or sync in another window), the save fell back to the game's + position and could overwrite a neighbouring game. A request that names a stable game id which no + longer exists is now refused. Edit, favorite, trash, screenshot, metadata, RetroAchievements, + save and extra-launch requests send the stable id as well as the position. + `tests/test_launch_phases.py`, `tests/test_game_addressing_ratchet.py`. +- **Removing imported Steam games can be undone.** Remove Steam games used to delete them outright. It now + moves each one to the Trash with its position and manual-playlist memberships, so Restore brings it back. + The Trash keeps 200 entries, and a removal moves only as many games as it has room for, so entries you already + trashed are never evicted. The rest stay in your library and the notice says how many. `tests/test_parity_api.py`. +- **Plugins trusted before this release need one re-approval.** The trust check now covers file boundaries, which the + old checksum did not. Accepting the old checksum would let a package with the same bytes moved between files pass + as trusted, so the old record is not honoured. Approve each affected plugin again under Plugins in Settings. +- **Coalesced writes could roll back a newer commit (sweep).** A snapshot queued before a full + commit was flushed after it. The full commit now discards it; a mutation that raises no longer + leaves its half-applied change in memory; and a file changed by another process is no longer + treated as the last commit. `tests/test_perf_writes.py` (`CoalesceCommitOrderingTests`). +- **Snapshot rotation could delete the newest recovery point (sweep).** Rotation now prunes in the + same order the snapshots are listed, not by modification time, which ties. `tests/test_perf_writes.py`. +- **Unescaped bridge ids (Linux host).** The page's id is written as one escaped JavaScript literal, so + an id with an apostrophe, backslash or line break no longer breaks the call and leaves the page's + promise pending. The Windows host received the same line-break escape; that change was checked by + reading only, as the Windows toolchain is not available to this release. + +### Fixed +- **Flatpak probes could hang a request (B4).** The status, open, setup and install probes give up after 5 s and + count the emulator as not installed. The two Windows `taskkill` calls are bounded too. A gate keeps new + unbounded subprocess calls out of the runtime. `tests/test_subprocess_timeouts.py`. +- **Per-game RetroArch core on Windows.** A game's chosen core now applies on Windows. Its file name replaces the + core in the definition's `{EmulatorDir}\cores\` path, as a `.dll`, and resume states record the chosen core there + too. Before, the choice was ignored on Windows. `tests/test_emulators.py` (`CoreChoiceTests`), + `tests/test_parity_resume.py`. +- **"Played recently" missed offset-stamped dates (B5).** A last-played value written with `Z` or an offset + was ignored by the query engine, so `played recently` never matched it. All date rules now compare in local + time through one parser. Bulk edit rejects a last-played value it cannot read, instead of storing it and + silently hiding the game from every date rule. A restore whose backup carries an offset no longer fails + with an internal error. `tests/test_parity_query.py`, `tests/test_platform_compat.py`, `tests/test_catalog.py`. +- **A Flatpak emulator without folder access was told to reinstall (B6).** The fix for `FLATPAK_FS_DENIED` + was a reinstall, which cannot grant access. The Launch Doctor now offers **Grant access**. After you + confirm, it runs `flatpak override --user --filesystem=:ro `, and **Remove access** reverses + it until OpenBox restarts. The grant is read-only, and the home folder itself cannot be granted. The exact + command is still shown with a Copy button. `tests/test_parity_flatpak_grant.py`, `tests/test_launch_doctor.py`. +- **Each tab could open three live connections (ADR 0068).** The server accepts 16 at a time, so a busy tab + could be refused and lose updates. Each tab now holds one connection that every live feature shares, and a + gate fails a second one. `tests/test_frontend_contract.py`. +- **A search no longer moves the panels below it.** Typing a search added filter chips to a row that grew with them, + and the row pushed the drop zone and Play Insights down. The chip row is now its own line under the library title, + and its items keep their natural width; long rows scroll sideways (ADR 0070). The sidebar's Game DNA + Title and Smart toggle wraps under the search box instead of squeezing it, and its labels are translated. The browser measure reads 0 on every run at 1,000 and 10,000 games; it was 0.099 on the cold run. + `scripts/perf_browser.cjs`; docs/development/PERF.md. +- **Live session updates stopped after the window was hidden (B3).** The session event stream stayed closed + for the rest of the session, so the UI fell back to polling. It reopens when the page is shown again and + catches up with one poll. The scheduled-rescan stream retries with backoff instead of closing for good. +- **"Retry failed" had no button (B7).** The media manager's retry, which re-runs only the failed games, is + now a button. It is enabled only when a download has failed games. +- **A failed setting save looked applied (B8).** The cover grouping and list/grid view roll back on a failed + save and say why; a failed list sort is reported. Startup storefront imports name the sources that failed, + instead of failing silently. +- **Emulator Health showed buttons that did nothing (B9).** The BIOS button reveals the folder where the host + can; the missing core and firmware are stated rather than offered as dead buttons. +- **Preflight endpoints validated nothing (B10).** Two blocks that claimed to validate launch tokens were + no-ops and are removed. Both endpoints reject a body that is not a JSON object. +- **Sweep fixes.** Across the Python core and handlers: a timestamp check that raised on naive and aware + values, a Retry-After that could shorten a wait, a `.env` parser that dropped quotes in secrets, a Steam + root lookup with no home directory, plugin checksums that could be made to match, save-root matching, + an undo batch id that could escape its folder, and a web server that left a missing icon request hanging. + Across the frontend: Big Box platform tabs and empty results, the constellation tooltip, import wizard + prompts that could cancel themselves, a duplicate session poll timer, the moments capture for newly added + games, insights' negative momentum, and Party Mode's Enter key on buttons. Across the native hosts: dialog + paths with `;` or accents, SIGTERM and SIGHUP shutdown, and a launch request on the first Windows + instance. This list covers the main areas, not every change. + +### Added +- **Launch readiness on the grid (H1, part).** After a Launch Audit, a game it found blocked shows a + "Won't launch" badge and one it found with warnings shows "Needs attention". The badges come from the + cached audit and disappear when the library has changed since the audit ran, so a stale report is never + shown. Route: `GET /api/v2/launch/audit/status`. `tests/test_launch_audit.py`. +- **The Launch Audit runs on the library health schedule.** The audit behind the badges runs with the + scheduled library check (daily or weekly), so the badges follow the library without a setting of their own. + `tests/test_gapfill_rest.py`. +- **Launch definitions for 18 more systems.** RetroArch cores for Genesis, Master System, Game Gear, Sega CD, + PC Engine, Neo Geo Pocket, Atari 2600, 7800, Lynx and Jaguar, WonderSwan, Virtual Boy, C64, MSX and Amiga, + and standalone definitions for Dreamcast (Flycast), 3DS (Azahar) and MS-DOS (DOSBox Staging). Core names + follow the libretro buildbot and Flathub ids were checked with `flatpak remote-info`. The RetroArch core + path is verified on one machine only (ADR 0067). `tests/test_emulators.py` (SystemCoverageDefinitionTests). + +- **Group fixes and a scoped relink.** A group of games that share one Flatpak grant or one emulator install has one + button for all of them. After a grant, only that group's games are checked again, and the merged result equals a full + audit (`POST /api/v2/launch/audit/refresh`). A missing-file group has a "Find moved files" button that opens the repair + wizard limited to its games. ADR 0072; `tests/test_launch_audit.py` (RefreshReportTests), `tests/test_parity_repair.py`. +- **Choose the RetroArch core for one game.** The Launch Doctor's "Choose core" lists the installed cores. The game + launches with the one you pick, and "Use the default core" restores the definition's. ADR 0072 and the ADR 0061 + addendum; `tests/test_emulators.py` (CoreChoiceTests). +- **The Launch check says when it changes.** When the blocked or warning totals change, one notice reaches the + notification feed. `tests/test_launch_audit.py` (TotalsChangeNoticeTests). +- **Turn the launch badges off.** Settings > Appearance has a switch for the grid's "Won't launch" and "Needs attention" + badges. `tests/test_settings_schema.py`. +- **Disc images ask which system they belong to.** `.bin`, `.cue` and `.iso` files that several systems share wait for + a platform choice in the import wizard instead of being guessed. A file cannot import until it has one. ADR 0072; + `tests/test_parity_setup_preview.py` (AmbiguousDiscChooserTests). +- **Definitions use schema 2.** RetroArch definitions name their core once. A definition pack written for a newer + schema is refused, with the version it needs. ADR 0061 addendum; `tests/test_parity_emulator_defs_update.py` + (SchemaGateTests). +- **The native window starts in the theme's colour.** Both native hosts paint the active theme's background before the + page loads, instead of the dark default. ADR 0071; `tests/test_native_background.py`. +- **The definition pack is released with each tag.** The release workflow builds, signs and verifies + `community-defs.tar.gz` and uploads it to the `emulator-defs` release, with its signature and index. The first + signed pack needs a tagged CI run with the release key. +- **Emulator Health styling uses named classes.** Its inline styles now use classes built from the design tokens, so + the look is unchanged. The Media Manager had no inline styles to move. + +### Corrections to 1.16.0 +- **"Both flatpak probes have a timeout" (S6)** covered the two Launch Doctor probes. Four other + `flatpak info` calls and the Windows `taskkill` calls had no timeout. All are bounded now (B4). +- **"A failed launch says it failed" (S26)** is true of the event that is sent. The stream that carries + it closed when the window was hidden, and nothing reopened it, so a session that ended while the window + was hidden was not shown until a reload. The stream reopens on return (B3). + +### Documentation and gates +- New ratchets, each with a measured allowance: unbounded subprocess calls (`tests/test_subprocess_timeouts.py`); + game requests that send only a position, and bare `datetime.fromisoformat` calls + (`tests/test_game_addressing_ratchet.py`); literal English `notify()` calls (`tests/test_notify_literals.py`). + ADR 0066 records them. +- The full gate now fails when a test run leaves a new untracked entry in the repository root + (`scripts/check_tests.py`). +- ADR 0065 proposes the release runner change (keep the Ubuntu 22.04 glibc floor on a supported runner). It + is not yet in effect. +- ADR 0007 gained an addendum for the coalesced-write lock. +- `docs/reliability.md` rows 55-67 were added in 1.16.1. Rows 60 and 61 are manual checks, with their procedures written there. + +### Known limits in 1.16.1 +- RetroArch under Flatpak: checked on one machine with the Flathub build (RetroArch 1.22.2) and the SNES core, + where a core in the sandbox's config folder loads (ADR 0067). Other cores and other Flatpak builds are not + checked on a real install. +- Notification text is fully translated, and the gate holds it at zero. Some dialog and button labels are still + English. Server notices (the Launch check change, cloud sync) are English too; translating them is a separate change. +- The search does not move the facet count to a worker. A profile of broad searches at 20,000 games found no facet + time among the top functions; the long tasks come from Play Insights and rendering (docs/development/PERF.md). +- The Linux native host builds here against WebKitGTK 2.52 and its 13 tests pass. The Windows CI job builds the Windows + native host with MSVC against the WebView2 SDK and runs the Windows test suite; both pass on this release. Nobody has + run the Windows app by hand on a Windows desktop yet (ADR 0071). +- The Linux AppImages build in an ubuntu:22.04 container on a 24.04 runner, so the glibc floor stays at 2.35 (ADR 0065). + A manual dry run built both arches, and their highest `GLIBC_` symbol is 2.35. The attestation and publish jobs run on + ubuntu-24.04, and the first tag run verifies them. The first signed definition pack needs a tagged CI run with the + release key (ADR 0061 addendum). +- The integrated `perf_bench.py --browser` step measured an empty directory (the 20,000-game write-path folder) + and failed with `Waiting for selector .cover`. It now measures the largest library the run generated for the + read benchmark. `tests/test_perf_bench.py`. + +## [1.16.0] - 2026-10-05 + +### Security +- **`preview_id` path traversal (S2).** `parity_setup_preview` and the Launch Doctor's + `validate_preview` joined a request's `preview_id` into a path unchecked, so a + `../`-laden id returned any `*.json` on disk, `settings.json` included. One validator + (`safe_identifier`) and a containment check (`contained_path`) in `pkg/platform_compat.py` + now guard both paths. `tests/test_path_traversal.py`. +- **`rom_name` write traversal (S3).** The high-score restore built its destination name from + `rom_name`; it is now reduced by `safe_filename_component` and the destination is checked to + stay inside the high-score folder. +- **Plugin sandbox data-dir mask (S4).** The bubblewrap command masked five fixed paths, so a + data directory moved with `OPENBOX_DATA_DIR` was readable by sandboxed plugins. The resolved + data directory is now masked wherever it is. `tests/test_plugin_sandbox.py`. +- **Wrapped report escaping (S1b, S41).** All four interpolations and the error message are + escaped, and `test_unescaped_untrusted_data_never_reaches_an_html_sink` fails if a bare + `game.*`/`top.*`/`data.*` property reaches `innerHTML` again. +- **`orjson` removed (S1).** State writes used `orjson` when it happened to be installed, with + output that could diverge from the standard library. The runtime is standard-library only; + `scripts/check_dependencies.py` enforces it, with `yaml` and `py7zr` as the two reviewed, + parity-tested exceptions. +- RetroAchievements cache names are built from `int(console["ID"])` (S5), and both + `flatpak info` probes have a 5 s timeout (S6) that is now caught, so a hung Flatpak reports + "not installed" instead of failing the request. + +### Added +- **Launch Readiness (F1).** `POST /api/v2/launch/audit/scan` queues a cancellable + `launch-audit` job that runs `run_preflight_checks` over every game, and + `GET /api/v2/launch/audit` serves the cached report: ready/warning/blocked totals, one row + per root cause (code plus subject, such as the Flatpak app id or file extension), and + paginated member lists (`?group=`, at most 500 per page). The Doctor is reused unchanged, so + the audit's codes are the Doctor's. `ProbeCache` (`pkg/parity/parity_launch_audit.py`) + memoizes the `flatpak info` result per argv and `which` per name, so a 2,000-game audit makes + at most two spawns per distinct app id (28 with the shipped definitions) instead of 4,000. + The report lives in `cache/launch_audit.json`, never in `library.json`. Deep mode (archive + listing and BIOS hashing) is opt-in; `run_preflight_checks` gained `deep=True` so the + single-game path is unchanged. The Library health dialog shows the report, with an + *Install* action for Flatpak causes and "show me" links to each game. +- **Restore Preview (F2).** Selecting a backup fetches `GET /api/v2/backup/diff` and renders + what the restore would remove, bring back and overwrite (per-field from/to), with true + totals and explicit truncation, plus a settings line when `settings.json` would change. The + restore button is built only from a successful diff; a failed diff offers a retry and no + restore. `parity_backup.diff_manifests` was rewritten around `restore.will_remove`, + `will_add` and `will_change`. +- Singular forms for counted strings: `t()` prefers `_one` when `count` is 1 (S19), and + every counted string has one in all five locales. + +### Fixed +- **A failed launch was reported as a success (S26).** The `stopped` event carried the + `WaitResult` namedtuple, which JSON serializes as `[1,false]`, so the client's exit-code + check was always false. The exit code is now an int with a separate `timed_out` bool on both + the session event and the `session.stopped` webhook (whose allowlist gained `timed_out`). +- **The import wizard imported candidates it said needed review (S27).** `review` was posted as + `import`; it is now left undecided, so the server's unresolved-candidate guard applies, and + the Confirm step counts the candidates still waiting. +- **Deleted game's playtime credited to the last game (S7).** The "not found" index was `-1`, + which indexes the last element; it is now `None`. `tests/test_playtime_attribution.py`. +- **A user's `.m3u` overwritten on import (S8)**, and its sheet's platform read from a stale + parse. `tests/test_import_playlist_safety.py`. +- Data integrity and concurrency: cloud sync re-merges inside the write (S9); definition + installs are two-phase with full rollback (S10); a replaced job is cancelled, archived and + notified, and a finished job's id stops resolving to its name before anything is told it + finished (S11); SQLite read-model readers take the lock and a superseded rebuild does nothing + (S12, S13); the platform-category cache keys on a content hash (S14); a failed Windows update + restores the install (S15); unchanged state is not rewritten (S16); the definition registry + is published complete under a lock (S17). Snapshot names now order themselves, fixing a + flaky snapshot test. +- Frontend: request sequencing and `AbortSignal` in the details pane (F5); a 60 s `api()` + timeout with caller signals (F9); the 20k list view stays windowed (F3); quoted query + phrases, empty tokens and unknown keys (F7, F11, F12); a truncated query match count is shown + (F10); and F1, F2, F4, F6, F8, F13, F14. +- Trust bugs: the session card's game lookup (S28), the palette launching the shown game + (S29), Big Box video snaps on close (S30), gamepad input after blur (S31), a zero-length + guard and a session-control failure in Big Box (S32, S33), the constellation's reduced-motion + path freezing the page and its stuck spinner (S34, S35), the mood pipeline hanging after one + bad image (S36), silent metadata bulk-accept failures (S37), setup decision and preflight + failures (S38), the Arcade Room closing on a cancelled launch (S39), forked session polls + (S40), timeline covers requesting `id=undefined` (S42), overlapping match reviews sharing a + revision (S43), Time Machine pagination dying on a double click (S44), a false "timed out" + for a pruned job (S45), stale emulator choices after a failed setup (S46), double-escaped + definition errors (S47), a silently reverted DNA search mode (S48), duplicate insights + navigation after a language switch (S49), confirmation and sample notes on the LaunchBox and + ES-DE apply paths (S50, S51), dead mastery clicks (S52), an uncancellable Gameyfin install + watch (S53), and the museum PIN prompt with no backoff (S54). +- Artwork chooser candidates without an `http(s)` URL are no longer offered. +- Fixed the **ScreenScraper credential aliases being accepted but ignored**: the four + `OPENBOX_SCREENSCRAPER_*` names are in `ENV_KEYS`, so `load_dotenv()` kept them, but + `credentials()` read only the bare names — a user who set an alias got a provider that + reported itself unconfigured. They now resolve through `env_value()`, bare name first, + like every other credential provider. + +### Corrections to 1.15.0 +The 1.15.0 entry below described four behaviours that had shipped as documentation only. +They are corrected here rather than by editing that entry (ADR 0064, rule 4). +- **"Every dialog now plays an exit"** held only for JS `close()`. Escape and backdrop clicks + bypass it, so 21 of 29 dialogs snapped shut and lost focus. One delegated, bubble-phase + `cancel` listener that honours `defaultPrevented`, plus a backdrop handler, now route every + close through `closeDialog` (B1). +- **The toast queue** was one element with several writers. `state.js` now owns a toast + manager (`TOAST_LIMIT = 3`, per-toast timers that pause on hover and focus) and the + `#toasts` container is a `popover="manual"` that is raised above open dialogs (B2–B5). +- **Definition-channel atomicity** did not hold on failure; see S10 above. +- **The reduced-motion gate** accepted either of two reads (an OR), so a module using neither + passed. It is an AND over a reviewed allow-list, and `motionMs()` is the only motion read (S22). + +### Documentation & Gates +- **ADR 0064, the changelog-truth contract.** A claim is written only after the gate that + proves it; gates test behaviour, not presence; a gate that cannot fail is fixed or removed; + a shipped claim is re-checked at the next release. +- New gates: `check_dependencies.py`; i18n placeholder parity in `check_i18n.py` (S20); the + `--dur-loop` completeness check (S21); the counted-string singular gate with a self-policing + exemption list (S19); `tests/test_launch_audit.py` asserting the Flatpak spawn count, not the + wall clock; and `ui_smoke` cases for the restore preview and Launch Readiness that assert + non-empty lists. The vacuous `if errors:` guard is gone (S25). +- `docs/reliability.md` rows 50–54 (Restore Preview, Launch Readiness ×2, S7, S8), each naming + a test that exists. `docs/development/PERF.md` records `launch_audit_ms_p95` (3.3 s at 20k, + documented, not gated). `docs/SECURITY.md` leads with the security class fixed in this + release; 1.15.x is no longer supported. + +## [1.15.0] - 2026-09-29 + +### Added +- **Signed emulator-definition update channel (ADR 0061).** A verified + community definition pack installs into the per-user data directory and + shadows the bundled set without ever overwriting it. Fetch is verified with + the release Ed25519 key via `updates.verify_artifact`; a signature failure + raises a security notification instead of a silent skip. Local definitions + win: a file already in the data directory is never clobbered. New v2 + routes: `GET /api/v2/emulators/defs/status`, `GET`/`POST + `/api/v2/emulators/defs/update`, and `POST /api/v2/emulators/defs/rollback`. +- **High Contrast theme.** A sixth stock theme redeclaring every `:root` + token with a maximum-contrast palette (21:1 body text, AAA foregrounds) — + and the proof that the token contract holds end to end. +- **Time Machine compare.** `GET /api/v2/library/time-machine/compare?after=` + (&optional `before=`, `fields=`, `limit=`) diffs the library between two + journal points read-only: added, removed, and per-field changed rows with + true totals even when the page truncates. +- **Story PNG export.** The per-game Story card gains a PNG export rendered + entirely client-side on a canvas — no new endpoint and no dependency. + +- **Emulator definitions panel (ADR 0061).** Settings > Emulators can check for, install and + roll back the signed community pack, shows which definitions you edited (never overwritten), and + says plainly when no pack has been published yet. `scripts/build_defs_pack.py` builds the + byte-deterministic archive and index; signing stays a maintainer step. `PACK_BASE` moved from a + pinned commit that never held an index to a rolling `emulator-defs` release. +- **Time Machine Compare tab.** Pick one date, or two, and see games added, removed and changed + with true totals ("showing 200 of 1,340") and a warning when the start date predates the kept + history. Read-only; revert is exactly as bounded as before. +- **Settings > About.** Version, platform, data folder, and whether OpenBox is in its native + window or a browser tab, from the host report. The capabilities response gained `version`, + `platform` and `data_dir`. +- **`scripts/uninstall.ps1` (ADR 0062).** Removes exactly what `install.ps1` creates: the install + tree and its rollback copy, the user PATH entry, the Start Menu shortcut and the `openbox://` + registration. The library and settings are never touched. `-WhatIf` previews it. +- **Motion tokens and one reduced-motion switch (ADR 0063).** Every duration and easing is a + token; a single block zeroes them, so reduced motion cannot regress by omission. Every dialog + now plays an exit, the toast is one queue-safe surface in the top layer, and a live theme + switch cross-fades. +- **Semantic ink tokens and a theme contrast matrix.** `--ink-strong`, `--on-active`, + `--on-danger`, `--border-input`, `--state-hover`, `--state-press`, checked against every theme + by a contrast matrix in `tests/test_stock_themes.py`. +- **Grid list semantics.** The card grid is a list with the true `aria-setsize` and + `aria-posinset`; Big Box, its menu, pause panel and the screensaver announce as dialogs; job + rows expose a progressbar; the result count is a live region; forced-colors mode gets outlines. +- A **Clear filters** button on the "No games match this view" state. + +### Documentation & Gates +- **Feature-regression ratchets (ADR 0060).** The gate suite proved a great + deal about code and almost nothing about features. Six new gates now pin + every surface a feature can disappear from, so a regression is a failing + check instead of a 404 in a user's browser: + - `check_routes_contract.py` freezes the **full** HTTP route surface. + The v1 contract only covered 61 routes, so every `/api/v2/*` route and + every unversioned route could be deleted unnoticed. + - `check_settings_contract.py` protects the `KNOWN_SETTINGS` allowlist, which + is *destructive*: removing one key erases that setting from every existing + library on the next save. Retiring a key that holds user data now requires + a reason naming the migration that preserves it. + - `check_emulator_defs.py` requires `native_exe_windows` and the other + required keys on every definition, and ratchets the set. ADR 0048 added + that key to all definitions but nothing enforced it, so a new emulator + could ship Linux-only and silently fail on Windows. + - `check_frontend_modules.py` resolves every ES module import against the + filesystem: no dangling imports (which break the whole page at runtime) + and no orphaned modules (features unreachable while still shipping). + Dynamic `await import('./x.js')` and subdirectory specifiers count too — a + dangling lazy import broke its feature at runtime with the gate green. + - `check_reliability_catalog.py` requires every `Tested` row in + `docs/reliability.md` to name a test that actually exists. + - `check_clock_coupling.py` fails when a test pins a calendar date against a + rolling `*_DAYS` window, and a `# clock-coupled` marker alone does not + exempt a test: the exemption has to be recorded in the contract to be + reviewed. + - `check_exec_modes.py` requires the exec bit on every tracked file that + carries a shebang. ruff's `EXE001` reports a missing exec bit only on + Linux, so the failure was **structurally invisible from a Windows + checkout**: every local gate and the `windows-latest` job stayed green + while the Linux `gate` job was red. The gate reads the git index, which + records the mode on every platform, so the defect is now caught pre-push. +- **Every gate is a two-layer ratchet.** A single-layer ratchet is defeated by + editing its own baseline, so each contract is also ratcheted against a + **reference commit that predates the change** — the merge-base with the + default branch, or the parent commit on a direct push. The reference is + deliberately *not* `git HEAD`: in CI that is the file under test, so + comparing against it compares the contract with itself and the layer is + inert. Each gate reports the reference it used, and the two CI jobs that run + the ratchets check out with `fetch-depth: 0` so the layer is real rather than + skipped. Deleting an entry to hide a removal is itself a failure: surfaces + may only shrink through an append-only `retired` ledger carrying a reason, so + **removal stays possible but never silent** and always leaves a reviewable + record. Growth is free and expected. The rule lives once, in + `scripts/contract_ratchet.py`, and every gate calls it. +- `make ratchets` runs the six gates alone; `make contracts` regenerates the + frozen baselines after an intentional change. Both run in `scripts/check_tests.py` + and in the Linux `gate` and `windows-latest` CI jobs. + `tests/test_feature_contracts.py` covers them, including that each gate stays + wired into the orchestrator and CI so it cannot be quietly unhooked. +- `check_version_sync.py` now reads the locale files. + +### Fixed +- Fixed **Harbor Light (the light theme) being unreadable**: `--white` was used as a text colour in + 15 places and is literally white there, so inputs, card titles and the active nav item were + white on white, insight cards were dark on dark, and toast and health text sat between 2.3:1 + and 3.7:1. Text now uses semantic ink tokens, Harbor Light's leaked dark-theme colours were + re-derived, and the accent and danger text that failed in five of six themes now passes. +- Fixed the **grid replaying its entrance animation on every search keystroke**, favourite, bulk + toggle and cover-ratio regroup. It now plays for a view change only. +- Fixed **toasts hiding behind modal backdrops and Big Box**, and an Undo toast being overwritten + by any message sent while it was showing. +- Fixed the **party wheel showing "Spinning" for 2.4 s under reduced motion**, the + **constellation layout ignoring reduced motion and never stopping when the dialog closed** + (about 259 frames of O(n^2) layout), and `.library-head` ignoring reduced motion. +- Fixed **covers collapsing to zero height and jumping on load** (the shimmer was on an element + that had no height); the box is reserved, the cover fades in, and a failed cover falls back to + the title tile. +- Fixed the lightbox arrows **jumping about 17 px on mouse-down**: the global press style + outranked their positioning transform. +- Fixed the **busy state blanking the label** on dark buttons; it now keeps the label and trails a + spinner (an ellipsis under reduced motion). +- Fixed the workspace **clipping between 1101 and 1119 px**: the breakpoint was 1100 but the + three columns need 1120. +- Fixed a **light theme opening dark**: the last theme is remembered and applied before the API + answers, and the theme list is no longer re-fetched on every sidebar click. +- Fixed lazily built dialogs missing the shared focus wiring and focus restore. +- The def-update channel's User-Agent now reads the running version instead of a hardcoded string. +- `ui_smoke.cjs` now runs on Windows (`scripts/run_ui_smoke.py`) and waits for the add-game + dialog instead of a fixed 300 ms. + +- Fixed the CI **Lint** step failing on both Linux architectures, and red on + every commit since `f605e27`. Eight files that carry a shebang had lost the + exec bit (`100644`), which ruff's `EXE001` rejects on Linux. Because Windows + has no exec bit, no local gate and not the `windows-latest` job could see it. +- Fixed the UI smoke test failing `activityUi.partitionRecent.includes('job-recent')` + on every run since 2026-09-23. The activity fixture pinned absolute job + timestamps while the recent partition is a rolling 30-day window, so the + fixture slowly aged out of its own window and began failing for a reason + unrelated to the code under test. Timestamps are now offsets from the + fixture's own `now`. +- Fixed a class of flake that could only ever appear in CI. `run_windows_tests.py` + — the runner behind the `windows-latest` job — ran each suite exactly once, + while the local gate allows three attempts. A wall-clock assertion such as the + 10k-ROM import budget therefore failed on a loaded shared runner and passed on + every developer machine. The runner now retries with the same budget, marks a + retried suite as passed, and prints the first attempt's failure so a genuine + intermittent bug still surfaces. +- Fixed the 10k-ROM import budget measuring itself out of contention. The + benchmark took a **single cold** wall-clock reading, so it charged the + algorithm for lazily-compiled regexes, first-touch page faults, and + `dedupe_ranked_imports`' own lazy `parity_premium` import — and on a loaded + shared runner it measured the machine's scheduling rather than + `group_multi_disc` + `dedupe_ranked_imports`. Measured properly the real + median was 231ms against a 250ms budget — about 8% headroom, which is why it + passed on a developer machine and failed on CI. The test now warms up both + functions before timing and asserts the **best of three** samples, reporting + the median alongside so the load the runner carried stays visible. The 250ms + budget is unchanged: the contract did not move, only the measurement stopped + measuring the harness. Proven by extracting the pre-change file and running + both forms side by side under escalating CPU load — the old form fails at 8 + and 10 competing threads, the new form passes at 8, 10, 12, 14 and 16. +- Fixed the web UI failing to boot (`Cannot access 'AppState' before + initialization`): the Game DNA search initializer ran at module load time, + before the app state module finished evaluating. It now runs after module + evaluation completes. +- Fixed `POST /api/v2/metadata/scrape-settings` (and its `GET` counterpart) + returning 400: the routes were registered with bare handler names that the + dispatcher could not resolve to the module-level functions in + `handlers/metadata.py`. Both now use dotted specs. +- Restored the `window.AppState` / `window.filteredGames` page globals used by + the UI smoke harness. + +- **A definition update did not take effect until restart.** Installing a pack + cleared the memoized registry but not the import-time snapshots that + `emulators.py`, `parity_import.py` and `openbox` share by value, so + `find_adapter` saw the new definition while every emulator list — and folder + import, which could not recognize a new extension — kept answering from the + pre-install data. Those snapshots are now refreshed in place through a + registry of them, so a module that derives data at import time can register + rather than drift silently. +- **A retracted definition kept shadowing the bundled set.** A pack that no + longer shipped a definition the channel had installed left the old file in + place, and only a full rollback — which removes everything the pack + installed — could clear it. An update now honors the retraction it declares. +- **Folder import silently ignored ten file extensions the bundled set + already supports.** The scan whitelist was a hand-written literal that no + definition ever fed, so `.nca`, `.xiso`, `.img`, `.bin`, `.scummvm`, `.cso`, + `.nro`, `.rpx`, `.wud` and `.wux` were filtered out before the platform map + was consulted — a folder of Switch, Xbox, ScummVM or PlayStation discs + imported nothing at all. The whitelist is now the static floor (launchers, + scripts and archives, which have no definition) unioned with what the + registry declares, and it refreshes on install like the platform map does. +- **A cue sheet and the disc image it names imported as two games.** Only + files named by an `.m3u` were excluded from the scan, never the ones a + `.cue` points at, so `Game.cue` and `Game.bin` each became a row. They land + in different platform buckets, which the ranked-import dedupe cannot merge + on, so this is two entries for one game — and a folder of disc images grew + noisier the more definitions the scan learned about. Cue-referenced files + are now excluded the same way m3u-referenced ones are. Two gaps in that first + pass: the sheet matcher only recognized `BINARY`, so the `MOTOROLA`, `WAVE` + and `AIFF` variants of mixed-mode and legacy tooling still imported twice; + and the surviving row was left on "Disc image", which recommends no emulator + at all, so a PlayStation disc offered no DuckStation suggestion. The row now + inherits the platform of the file the sheet names, whether it is a `.cue` or + an `.m3u`. + A sheet may also name its target with an absolute path carrying `..` segments, + which the parsers hand back verbatim and which therefore never matched the + resolved scan paths; both sides of that comparison are now normalized. +- **A disc sheet's own row recommended no emulator.** A `.cue` or `.m3u` row + kept the platform the map gives the sheet, "Disc image", which + `recommend_emulators` returns nothing for — so a PlayStation disc imported + with no suggestion at all. The row now follows the sheet to a file it names + and takes that platform, walking playlist-to-cue chains the way Redump-style + sets arrange them. A sheet whose targets map to nothing keeps its own + platform rather than becoming blank. +- **A concurrent search could fail with a 400.** The DNA index was written + through one shared temp file, so the background rebuild job and an + in-flight search could each rename the other's file out from under it — + surfacing as `[WinError 32]` or `[WinError 2]` from an unrelated search. The + temp file is now per-write, the rename rides out a reader holding the old + index open, and the synchronous build no longer turns a lost write race + into a failed request. + +- **What's New advertised the wrong version for two releases.** All five + locales hardcoded `"What's new in OpenBox 1.11"` in the dialog title while + 1.12 and 1.13 shipped, and `check_version_sync.py` never read the locale + files, so the drift was invisible to the gate by construction. The title is + now a `{version}` template filled from the live settings payload, and any + `OpenBox 1.x` literal in a locale now fails the version gate. +- `tests/test_parity_radio.py` and `tests/test_parity_query.py` carry an + explicit `# clock-coupled:` marker recording that every call injects + `now=NOW`, which is why their pinned dates are safe. The query marker was + added after the gate was fixed and immediately found that coupling. +- **A local emulator definition saved under a new filename was silently + ignored.** Precedence was decided by filename while the `adapter_id` map was + last-writer-wins, so `pcsx2-custom.yaml` loaded but `find_adapter` returned + the bundled definition, while the platform and emulator lists ranked the + local one first — which definition won depended on the lookup path. The + higher-precedence folder now wins by `adapter_id` on every path. +- A corrupt definition file in the data directory crashed definition loading + outright instead of falling back to the bundled file of the same name. + +### Changed +- In-app What's New dialog now highlights the 1.14.0 flagships instead of + the 1.11 highlights. + +## [1.14.0] - 2026-09-22 + +### Added +- Plugins 2.0 (ADR 0056, ADR 0057): per-plugin checksum-bound trust for + sandbox-unavailable hosts (no global trust toggle; updates re-prompt); + Android-style permission prompts at install/enable time (`network` + permission grants `--share-net`); per-plugin settings forms generated + from a manifest JSON Schema subset (text, number, boolean, enum, + password) and delivered to hooks as `payload["settings"]`; a + `library_source` importer hook that merges plugin games into the library + with a source badge (disable/removal drops them on rebuild); a single + `events` lifecycle hook (`app_startup`, `app_shutdown`, `scan_finished`, + `playtime_milestone`, `game_added`, `game_removed`, `game_updated`); + a catalog browser tab with per-entry Install/Update and installed/update + state; and palette integration listing plugin commands under the `>` + prefix with notification support. New `GET/POST /api/v2/plugins/trust`, + `POST /api/v2/plugins/permissions`, `GET/POST /api/v2/plugins/settings`, + and `GET /api/v2/plugins/catalog` routes. +- Backlog management (ADRs 0054, 0058). Every game now has a personal + backlog layer on top of the existing progress field: + - Status stays on the existing `progress` enum; unset renders as + "Unplayed" everywhere (cards, details, badges, explorer facets, filters). + "Abandoned" covers the plan's "Dropped" wording; only "Unplayed" + canonicalizes (to unset). New smart-query grammar: `progress:unplayed`, + `my 4 stars`, `unrated by me`, `myrating:4`, plus `user_rating_min` / + `user_rating_unrated` clause kinds distinct from the metadata `rating` + predicates. + - Personal star rating (`user_rating` 0-5, distinct from metadata `rating`): + hover star widgets on cards and in details, card badges, a "My rating" + sort option, "My rating" bulk field, and picker weighting that favors + highly-rated unplayed games with a composite reason + ("rated {stars} stars, never started it, ~{ttb}h to beat"). + - Manual playtime: log sessions (date, seconds, note) from the details pane + with edit/delete; stats and playtime totals include manual time while + streaks still count observed sessions only; exports carry the new fields. + - Dated notes: legacy single-string notes migrate to `{ts, text}` entries + on read (no bulk rewrite), with add/edit/delete in the details pane. + - Optional one-time "Mark as Playing?" prompt on first launch for unplayed + games, with a settings kill switch and per-game prompted-once persistence. + Never sets progress silently. New v2 routes under + `/api/v2/library/playtime/*`, `/api/v2/library/notes/*`, + `/api/v2/library/progress/set`, and `/api/v2/library/rating/set`; v1 is + frozen. +- Effortless metadata (Flagship 2): new `POST /api/v2/metadata/auto-scrape` + queues exactly two jobs per import batch — `metadata-match:{batch}` (offline + LaunchBox match preview plus opted-in ScreenScraper dual-hash and IGDB + exact-title passes) and `metadata-media:{batch}` (LaunchBox media for + confidently matched games, then SteamGridDB fill for anything still + missing). All providers stay off by default; each runs only when its opt-in + setting is enabled and the provider is configured, with per-run budgets, and + a single game's failure never aborts a pass. The setup wizard's Options step + gains an "Automatically match metadata and download media after import" + toggle (default on) plus opt-in checkboxes for ScreenScraper, IGDB, and + SteamGridDB (all default off), persisted through the new + `GET`/`POST /api/v2/metadata/scrape-settings` (the master toggle, provider + opt-ins, and empty media-type lists are honored: unchecking every media type + runs match-only). The wizard drives one auto-scrape call and leaves the + match-review preview available for anything the automatic pass could not + resolve. +- ScreenScraper ROM-hash confidence (ADR 0059): independent MD5-only and + CRC32-only lookups must agree on the same ScreenScraper game id (`dual` + confidence) before anything auto-applies; weaker evidence stays review-only. + `/api/v2/screenscraper/match` accepts `apply_confident` for bulk runs. +- Thumbnail chooser: every LaunchBox, SteamGridDB, and ScreenScraper search + result now offers a Thumbnails button that opens a chooser dialog with all + image candidates grouped by media kind; picking one applies exactly that + image (`media_urls` on the three apply routes) instead of the top pick. New + `GET /api/v2/metadata/media-candidates` lists LaunchBox image candidates. + Chosen URLs are downloaded only when they match a candidate the + provider/database returned for that record, so a client cannot make the + server fetch an arbitrary URL. +- Information card: "Time to beat" fact (from IGDB, projected into the game + list), validated `http:`/`https:` fact values now render as safe links, and + a Links row derives Steam store and custom website URLs. +- Game DNA search (ADR 0052): offline smart search with a Title | Smart toggle + on the sidebar search box (default Title, persisted). Smart mode runs + classical IR only — BM25 + a curated 151-concept lexicon (en/de/es/fr/pt) + + 1024-dimensional feature hashing — with a 250 ms debounce, "why" explanation + chips, and honest building (cancelable) / degraded / no-description states. + New `/api/v2` routes in `handlers/discovery.py` (`library/dna/search`, + `library/dna/status`, `library/dna/index/rebuild`); the atomic sidecar index + lives at `/dna_index.json` and is excluded from cloud sync. Taste + boosting uses the F4 `progress` / `user_rating` fields; library mutations + invalidate the index best-effort, and the picker gains additive + `seed_query` / `dna_boost` parameters. "More like this" is available in the + details pane, the card context menu, and Big Box (gamepad-operable toggle + feeding `#bigBoxHybridSearch`, why-chips as subtitle lines). No AI cloud, no + downloads — everything runs locally with the standard library. +- Plugin API v1 is frozen (ADR 0050, `docs/plugin-api.md`): manifests declare + `api_version` (default 1; newer versions are refused and surfaced with an + error), a `command` hook, and up to 32 palette `commands` (`{id, label, + description?}`). New `GET /api/v2/plugins/commands` and + `POST /api/v2/plugins/command` run palette commands through the sandboxed + runner with a bounded read-only library projection (500 entries) and support + a `notification` post; `GET /api/plugins` now also reports `api_version` and + `sandbox` status. Malformed packages and unavailable sandboxes are surfaced + in the Plugins manager instead of being hidden. +- Artwork Doctor: scan the library for missing covers, missing files, + low-resolution and wrong-aspect artwork, and duplicate images, then fix all + with SteamGridDB in one cancelable batch job. Every replacement is + attributable to its provider and undoable per batch. +- Duplicate games: detection now groups identity, path, and title collisions, + with a merge preview that picks the record with the most play history, unions + media and list fields, and moves absorbed entries to the Trash so merges are + reversible. The health dialog's dedupe button opens the merge dialog. +- Repair missing files: a wizard that scans for missing game and media paths, + matches them against a folder you pick, and relinks only the matches that are + still missing, skipping anything that changed since the scan. +- Bulk media downloads now commit one transaction for large batches and keep + per-game commits for small ones; malformed `Range` requests map to 416 + without masking missing files as range errors. +- Big Box boot: a new "Start in Big Box mode" checkbox in Settings → Big Box + (`bigbox_start_at_launch`) and a `--bigbox` CLI flag that opens the UI with + `?deeplink=bigbox`, so a Deck/HTPC can boot straight into Big Box like + Steam Big Picture. +- Big Box on-screen keyboard: a d-pad-navigable QWERTY + symbols grid that + opens when the hybrid search takes focus, types into it via input events + (reusing the existing filter), and sits on the gamepad loop just below the + pause overlay. Big Box-native for v1. +- Steam Bridge now copies cached SteamGridDB artwork (cover → capsule, + background → hero, clear logo → logo) into the account's Steam grid + directory after shortcuts are applied, so bridged games are no longer grey + tiles in Game Mode. Original bytes are copied as-is (no resize); skips are + logged and reported, and nothing is ever written outside a detected Steam + account directory. +- Library health score: a 0–100 score with five weighted dimensions (file + integrity 35, duplicates 20, artwork 20, metadata 15, launch readiness 10), + where every deduction names its games. The audit dialog gained a health card + and a breakdown dialog with per-dimension issue lists, a "Fix all in this + dimension" queue (dry-run preview first, every fix undoable), a Big Box + health tile with re-scan, and a scheduled rescan (daily/weekly/on + startup/off, default weekly) that skips active game sessions. Weights and + the never-auto-fix principle are recorded in ADR 0053. + +### Fixed +- Big Box pause overlay trapped gamepad users: with the panel open, the pad + kept driving the grid behind it, A launched the highlighted game, and B + killed the whole Big Box mode. The overlay now owns the pad while open — + d-pad moves focus between its buttons, A activates the focused button, and + B (or Escape, or the new Close button) dismisses only the overlay. +- Attract mode could start over the Big Box pause panel and the Game Night + party overlay; the overlay guard dropped by a461edd is restored. +- Opening the Big Box pause overlay for a game without attached documents + crashed on a missing documents list; it now treats a missing list as empty. + +### Changed +- The three per-surface gamepad poll loops (Big Box, library view, arcade + room) are unified into a single rAF loop in `static/gamepad.js` that + dispatches each frame to the highest-priority active surface. No input + behavior changes. + +## [1.13.1] - 2026-09-22 + +### Fixed +- Utility dialogs (prompt, choice, confirm, trophy case) were rendered inside a + `hidden` wrapper, so they opened invisibly — every path prompt and + confirmation was a silent dead end, including the unsaved-changes guard on + the game editor. +- Clicking outside a nested dialog closed every open dialog instead of only the + topmost one, which could dismiss the whole setup wizard mid-flow. +- `input { width: 100% }` also applied to checkboxes and radios, detaching them + from their labels app-wide; they are now excluded from the full-width rule. +- Setup wizard: source tiles used emoji icons that render as tofu boxes without + an emoji font (now monograms), selected sources never highlighted (the + selection key was never written), duplicate sources piled up, and there was + no way to remove a selected source. Completed steps are clickable, "More + sources" stays expanded across re-renders, the scan-empty message no longer + tells you to start a scan that already ran, the raw preview hash is no longer + shown on Confirm, and Finish hides actions that do nothing on an empty + import. +- The setup wizard re-opened on every library refresh while the library was + empty, even after closing it; dismissing it now holds for the session. +- The empty library hid the "Start your library" call-to-action below a full + screen of empty insights widgets; the insights panel is now hidden until the + library has games. +- Big Box on an empty view showed only a fleeting toast; it now explains the + prerequisite and opens the setup wizard. +- Saving the game editor with a required field on a hidden tab failed silently; + the editor now jumps to the section containing the invalid field. +- Reloading the page dropped the session token (it is stripped from the URL + after first load), forcing a fresh sign-in; the token is now kept in + sessionStorage for the tab's lifetime. +- "View imported games" after a scan landed on an empty library because + `import_batch_id` was stored but never projected to the library payload. +- Setup wizard Readiness rows and Activity Center job titles showed raw + candidate/job hashes; they now show the detected title and a cleaned-up + fallback name. +- "Scan all save paths" reported a bogus updated count; it now points to the + Activity Center where the queued scan reports progress. +- The Play Insights panel pushed the game grid below the fold at short + viewports; it is now collapsible and the choice persists. +- "Hidden sidebar sections" was a comma-separated free-text field; it is now + a checkbox group. +- The game editor showed Prev/Next navigation while adding a new game. +- The Activity badge rendered "0" before the jobs stream connected and was then + destroyed by the i18n pass (`data-i18n` on the button overwrote its child + spans); the label moved onto its own span and the badge starts hidden. +- The game editor's Media tab showed ~22 path rows at once; the 16 rarely used + media types are now behind a "Show more media types" toggle that + auto-expands when the game already has values there. +- Static JS/CSS was served `immutable` for a year, so app updates could run + stale modules until the cache expired; app statics now send `no-cache` and + rely on the ETag for cheap revalidation. +- Hiding a sidebar section in settings only hid its header — the `.platforms` + grid overrode the `hidden` attribute. +- Switching tabs inside the game editor with a dirty form prompted "Discard + unsaved changes?" even though tab switches discard nothing. +- On Windows, "Reveal in Explorer" from the native host never opened anything; + the command line passed to `CreateProcessW` started with `/select,""` + with no `explorer.exe` first token (a dead `wchar_t explorer[]` variable sat + unused); the folder path is now passed to `explorer.exe` in the executable + position. +- Rejected or malformed messages on the native WebView2 bridge used to leave + the pending frontend promise hanging forever; they now reject it so the UI + can surface the error. +- When every SSE subscriber slot was taken, the server opened a stream that + only ever sent heartbeats and no events; it now answers `503 SSE_BUSY` with + `Retry-After` so clients retry once another tab closes. +- Reconnecting the session event stream leaked the previous `EventSource`; it + is closed before a new one opens, and streams close when the tab hides. +- A hidden tab could send state-changing requests with stale `AppState` and + overwrite newer state written by the visible tab; mutations from hidden tabs + are now blocked (except shutdown) and close on hide. +- The Windows installer left a half-registered installation behind when Start + Menu or protocol registration failed; it now restores the previous + installation tree. +- Late-night moments were filed under the wrong day in the story view and the + history timeline: timezone-aware timestamps are now converted to the + viewer's local day before grouping, so a moment captured at 23:30 local + sits with its session instead of the next day. +- Utility dialogs created lazily (prompt, choice, confirm, Trophy Case) missed + the focus-restoration wiring of dialogs in the page markup; they now share + the same wiring, so opener focus is restored consistently. +- Clicking outside a dialog bypassed the shared close path; dismissal now + routes through `closeDialog()` so focus restoration stays consistent. +- Every Setup Center open path goes through the single refresh-and-render + entry point, so reopening the wizard never shows stale content. +- Six user-visible strings were never translated (the game editor's media + toggle labels, two Big Box empty-view messages, and the sidebar Filters + label); they now go through i18n in all five locales. +- A test file that fails and then passes on retry now prints the first + failed attempt's output, so the defect is identifiable instead of + passing silently on the retry. +- The standalone changed-line checker now applies ADR 0025 itself: changed + files excluded from coverage measurement (tests, scripts) no longer count + as misses, and a touched module fails only at 0% instead of an + unenforceable whole-file 95%. + +## [1.13.0] - 2026-09-19 + +### Added +- **Windows support (ADR 0048).** OpenBox runs natively on Windows alongside + Linux: a WebView2 native window (`native_host.exe`) that renders the same one + UI in a real window, `openbox.cmd` / `openbox.ps1` / `openbox-native.ps1` + launchers following the same native-host-then-browser ladder as the shell + scripts, a signed portable release zip (`OpenBox-x86_64-windows.zip` with + `.sha256` and `.sig`) built, attested, and signed by the same release job as + the AppImages, the compiled WebView2 host published as its own signed asset + (`OpenBox-x86_64-windows-native-host.exe`) so a source checkout gets the + native window without a toolchain, an installer (`scripts/install.ps1`) that + verifies the release key anchor, the checksum, and the Ed25519 signature + before extracting to `%LOCALAPPDATA%\OpenBox\share\openbox`, Start Menu plus + `openbox://` protocol registration, and a `windows-latest` CI job that runs + the full test suite and the portable gates. +- `pkg/platform_compat.py` is the single platform seam the port is built on: + file locking, process liveness/termination/suspend/resume, `/proc` + equivalents, command quoting, path openers, Steam discovery, and data-dir + resolution. The runtime stays dependency-free — stdlib plus `ctypes`. +- Windows binary names for every emulator definition (`native_exe_windows`), so + adapter detection, resume, and launch doctor work with Windows builds of + Dolphin, RetroArch, PCSX2, RPCS3, and the rest. + +### Fixed +- Process-group signalling can no longer reach group `0` or `1`. A stored + session whose `pgid` was missing, zero, or not a number could make pause, + stop, or shutdown signal the caller's own process group (or init) instead of + the game; the launcher now validates the group, refuses PID 1 outright, and + falls back to the game process's own group. +- Import each memories root once when the same folder is configured through + more than one spelling (an 8.3 short name, a differing case, or a trailing + separator), instead of scanning it twice and importing every screenshot + again. +- Canonicalize the resume state directory. A data root reached through an 8.3 + alias or a junction used to produce one `argv` carrying the resolved state + file next to an unresolved `--appendconfig` path; the state directory and + everything under it now share one spelling. + +## [1.12.1] - 2026-09-15 + +### Fixed +- Harden the AppImage update path: reject order-8 Ed25519 small-order + verification keys (ZIP-215/libsodium set), fail closed on non-object + GitHub releases payloads, normalize the signature digest before + validation, resolve a symlinked install destination so the real AppImage + is replaced, fsync the install directory, and escape `%` so AppImage + paths cannot inject desktop-entry field codes. +- Surface unreadable Steam libraries instead of skipping them silently: + `POST /api/import/steam` now carries an `errors` array naming the path and + the OS error (reliability row 12). +- Translate RetroAchievements 401/403 responses into "RetroAchievements + rejected those credentials" instead of a generic HTTP failure (row 13). +- Report an unreachable metadata database as a clean connection error in the + job panel — `URLError`/`TimeoutError` during LBDB sync no longer leak raw + socket messages (row 11). +- Automate the last manual reliability rows: non-UTF8 scan names, + read-only mounts, wrong credentials, offline sync, 400-character names, + deleting the selected game with details open, and rapid filter switching + are all gated tests now (`docs/reliability.md` has zero Manual rows). +- Ratchet coverage floors to 83% total / 58% web_app.py to match measured. +- Stop `test_launch_doctor.py` from writing a probe ROM into the real + `$HOME` during test runs. +- Return 404 when deleting a playlist that doesn't exist instead of a false + success, and reject scoped library exports without a name synchronously + (400) instead of failing later inside the export job. +- Explain empty Game Night queues: `POST /api/v2/party/queue` now returns an + `empty_reason` plus an exclusion breakdown (e.g. no games support N + players), rendered in the party setup status instead of a bare empty state. +- Type background jobs correctly in Activity: export, SteamGridDB, + ScreenScraper, auto-import, and reel jobs no longer masquerade as + `setup.scan` (with the wrong retry policy); new `library.export`, + `screenscraper.*`, `steamgrid.*`, `storefront.auto_import`, and + `clips.reel` operation types and policies. +- Fix "undefinedm" estimates in Backlog Radio rows served from the stored + playlist: hydrated picks now carry `estimated_minutes`, with a frontend + fallback for older entries. +- Stop the test suite from clobbering the developer's real library: an + import-order hazard in `tests/test_sse.py` wrote 100 synthetic games into + `~/.local/share/openbox-game-launcher/library.json` on every full run. + `run_all_tests.sh` and `scripts/check_tests.py` now export an isolated + `OPENBOX_DATA_DIR`, and the test module guards itself at import time. + +## [1.12.0] - 2026-09-14 + +### Living Library + +### Added +- **Smart collections:** save the active Backlog Radio search as a + named, living shelf. Collections store the query — not a snapshot — so + membership stays correct as the library changes. New routes + `GET/POST /api/v2/collections` and `POST /api/v2/collections/delete`, a + Collections sidebar section, and a "Save as collection" chip in the query + bar (backed by `pkg/parity/parity_collections.py`). +- **Game Story:** a per-game narrative tab in the detail pane, + served by `GET /api/v2/story?game_id=` — a deterministic projection + (`pkg/parity/parity_story.py`) over the game record, session journal, and + Moments: added date, first played, longest session, playtime milestones, + progress, and captured moments. +- **Per-game environment overrides:** Edit game → Launch gains a + `launch_env` field (`KEY=value` lines), validated at save and merged over + the launch environment in `pkg/state/launch.py`, plus an “Always confirm + before launch” per-game flag that asks before preflight. +- **Weekly automatic backups:** Settings gains an opt-in weekly + library backup with retention and a “last automatic backup” line; the + scheduler reuses the existing backup engine (`auto_backup_due()`). +- **SQLite self-enables for large libraries:** at 5,000+ games the + FTS read model turns itself on; an explicit env opt-out is honored and + small libraries see identical behavior. +- **Command palette learns:** recently chosen games and actions + rank first, from local usage counts — no telemetry. + +### Changed +- Replace the application logo with the new OpenBox GL mark: `openbox.svg` + (app icon and favicon) now carries the hexagonal OBGL monogram traced in + `assets/OpenBoxLogo.svg`, and `assets/openbox-logo.png` plus + `assets/OpenBoxGL.png` are re-rendered from it. +- Complete the logo rebrand with consistent square, transparent application + and README artwork generated from the supplied SVG. Native window and tray + icons use the bundled mark when running directly from source as well as + installed builds. Regenerate the artwork with `bash scripts/render_branding.sh` + (ImageMagick is needed only for development). + +### Fixed +- Route export downloads through the shared `send_bytes` writer so they + inherit the same partial-write discipline as every other response. +- Restore focus to the opening element on every dialog close: `closeDialog()` + in `static/dialogs.js` now owns a focus stack, so no caller needs its own + focus dance and competing Escape handlers can't blur the restored target. +- Project the per-game **Gamescope preset override** to the client: the field + saved correctly but `_project_game` never included it, so the Edit game + select always rendered blank. +- Serve large media files completely: `os.sendfile` on the timeout-managed + socket could stop mid-response and leave the transfer truncated, hanging + browsers waiting for the promised `Content-Length` bytes; the buffered + writer now resumes exactly where sendfile stopped. +- Honor the **Show Play insights** setting in the web UI: the setting was + saved but never included in the public settings payload, so the insights + panel always rendered. +- Restore the in-app document reader: the 1.11 security headers marked every + response `frame-ancestors 'none'`, which blocked the reader iframe from + ever loading `/api/document` or `/api/platform/document`; those endpoints + now allow same-origin framing (`frame-ancestors 'self'`), and closing the + reader also unloads the frame instead of only dropping the `src` + attribute. +- Keep the grid rendered when the library pane scrolls: the virtual window + treated the pane's scroll offset as grid-relative even though the library + header, drop zone, and insights panel sit above the grid, so scrolling + could blank the card grid; scroll offsets are now converted by the grid's + position inside the pane. +- Restore focus to the card when Escape closes the context menu: the + navigation Escape handler blurred the restored focus because it ran after + the menu handler had already consumed the key. +- Stop the resume affordance from probing the server for games that have no + server-issued stable id (e.g. client-only entries); the request could + never resolve and surfaced as a console error. + +### Documentation & Gates +- Enforce the CSP framing contract in the gate (Stage 2.8, + `scripts/check_csp.py`): only the document endpoints may relax + `frame-ancestors` to `'self'`, so the 1.11 reader regression can't + return (ADR 0047). +- Stabilize the perf-20k gate against single-run scheduler spikes: each + benchmarked endpoint now does one unmeasured warm-up request, and the p95 + statistic drops the single worst run of a five-run sample. Sustained + regressions still fail; one-off runner noise no longer flakes the gate + (ADR 0042). +- Move superseded 1.10.0 planning documents and 1.7.2/1.8.0 release notes to + `docs/archive/`; rewrite `docs/README.md` as a complete docs index (ADR 0041). +- Extend the runtime-modules gate to root `*.py` files, list all handler modules + explicitly in the route registry loader, and add the documented + `import pkg.parity` to `web_app.py` (ADR 0041). +- Repair the README screenshot pipeline (parity finder import, auth token, + lazy-loaded covers, virtualized-grid click) and regenerate the four + screenshots against the current UI. + +## [1.11.0] - 2026-09-12 + +### Every Second Counts +- Add Quick Resume, progress-aware session recaps, Moments capture/timeline, + and replay-buffer or screenshot-backed Record That clips with deterministic + highlight reels. +- Add the Library Time Machine with journal-backed timeline, bounded as-of + inspection, and safe revert previews that preserve the canonical state + boundary (ADR 0044). +- Add Backlog Radio, the deterministic natural-language query bar with + editable interpretation chips, and the Ctrl/Cmd-K command palette (ADRs + 0043 and 0045). + +### Showroom and Household +- Add the controller-friendly Arcade Room and Museum mode with bounded media + caching, reduced-motion rendering, real metadata fact cards, and an optional + salted kiosk PIN convenience boundary. +- Add opt-in, local-first Household members, challenges, results, shares, and + leaderboard projections over the existing sync folder; no account or hosted + service is required. + +### Handheld Migration and Polish +- Add Steam Bridge preview/apply/remove support for lossless `shortcuts.vdf` + files and `openbox --play ` launch dispatch. +- Add reviewable ES-DE `gamelist.xml` preview/apply with bounded parsing, + explicit source identity, source-digest checks, stale-plan rejection, and + transactional application. +- Add What's New/tips discovery, detail-pane Moments and Clips surfaces, + query chips, kiosk settings, and localized UI coverage across all five stock + locales. + +### Artwork and achievements +- Add an optional SteamGridDB artwork provider for searching, previewing, + applying, and bulk-matching covers, backgrounds, clear logos, icons, and + banners with a local cache and `STEAMGRIDDB_API_KEY` configuration. +- Add deterministic local OpenBox launcher trophies with a persisted trophy + case, separate from the optional RetroAchievements account integration. + +### Corrective hardening +- Complete the 1.10.1 sweep of state, save, import, media, launch, sync, and + frontend edge cases, with the full gate keeping changed-line coverage and + release contracts green. +- Keep memory-root imports stable across supported Python pathlib + implementations and ship a square vector icon accepted by Flatpak. + +## [1.10.0] - 2026-09-08 + +### Safe Library Sync +- Disable unsafe legacy full-library publish/pull with an explicit error before + mutation; preserve remote files and keep statistics sync available (ADR 0038). +- Add opt-in causal catalog sync with content-addressed events, device identity, + tombstones, outbox acknowledgement, bounded validation, stale previews, + recovery snapshots, and transactional recording at the canonical state + boundary (ADR 0039). +- Preserve every concurrent field and tombstone alternative with stable review + IDs, allowing independent conflict choices without silently acknowledging an + unresolved alternative. + +### LaunchBox Migration +- Add bounded LaunchBox XML preview/apply flows with explicit source IDs, path + and emulator mappings, exclusions, deterministic preview tokens, stale-plan + rejection, tamper detection, and transactional application (ADR 0040). +- Keep provider identity separate from numeric metadata IDs so distinct records + cannot merge implicitly. + +### Search, Facets & Shelf +- Wire the canonical JSON search and facet implementation through the HTTP + surface, with bounded limits, hidden-item handling, library order, and an + optional SQLite read model for large libraries. +- Add manual and shelf entry projection, editing, conversion, filtering, export, + and health/doctor semantics while preserving entry type until conversion is + explicit. +- Install all locale JSON assets with the system Makefile and verify locale + endpoints from the staged installed tree. + +### Launch & Performance +- Make launch reservations atomic by stable game ID and hold them until the + configured tracker finishes, including wrapper-exit and child-process cases. +- Reuse the validated warm state cache for large-library writes while keeping + direct state-store transactions isolated from mutable caller records and + results, including nested containers. +- Recompute weighted picker requests so “Again” can produce a new result and + handle mixed legacy timestamp formats safely. + +### Packaging & Gates +- Keep AppImage and Flatpak packaging relocatable and complete across locales, + desktop metadata, SBOM, update metadata, and installed-tree checks. +- Validate the x86_64 and emulated aarch64 paths, native WebKitGTK host build, + frontend checks, UI smoke, coverage floors, and strict seven-run performance + budgets for 10k/20k libraries. + +## [1.9.0] - 2026-09-04 + +### Mood Match — Adaptive Cover Theming +- New **Settings → Appearance** toggles "Adaptive cover theming" and "Adaptive theming in Big Box" (`mood_match_enabled`/`mood_match_bigbox`), off by default. +- `static/mood.js` extracts a live 5-color palette (primary, ink, secondary, glow, tint) from the selected/focused cover and applies it to the selected card, detail hero, play button hover, and Big Box background/cover ring via `--mood-*` CSS tokens. +- New tokens added to `static/app.css :root` and all five stock themes; palette extraction is a fast 4×4×4 RGB bin quantizer with a documented upgrade path (ADR 0026). + +### Picker +- New **"What should I play?"** picker replaces the random "Surprise me" button. Pick by available time, mood (action/chill/story/retro/party), familiarity (new/favorite), and number of players. +- `static/picker.js` dialog renders the top pick with an explanatory reason, quick Launch / Details / Again actions, and a "Just surprise me" fallback. +- `POST /api/v2/library/pick` returns scored, scoped recommendations via `pkg/parity/parity_picker.py` and `handlers/picker.py` (ADR 0028). + +### Constellation +- New **Tools → Constellation** opens a full-screen, pan/zoomable relationship graph of the library. +- Backend `GET /api/v2/library/constellation` returns capped, deterministic nodes/edges by series, developer, publisher, genre, platform family, and co-play history. +- `static/constellation.js` runs a client-side spring-electric layout on a canvas; clicking a node dispatches `app:show-game` to select it in the library. +- Edge colors are tokenized as `--constellation-edge-*` and defined in all five stock themes (ADR 0028). + +### Wrapped + Timeline +- New **Insights → Wrapped** opens a printable "Your Year in Games" report with playtime, sessions, streaks, progress, and top game/platform/genre. +- `GET /api/v2/insights/wrapped?year=YYYY` returns privacy-safe annual aggregates. +- New **History → Timeline** tab shows sessions grouped by day with covers and recording badges. +- `GET /api/v2/history/timeline?days=90` returns grouped sessions; recording values are basenames only (ADR 0029). + +### Mastery +- New **Tools → Mastery** opens a completionist dashboard with stacked per-platform and per-decade bars over local progress states (never/played/beaten/completed/mastered), plus a RetroAchievements column. +- `GET /api/v2/insights/mastery` returns platform/overall/decade aggregates read from the existing RA disk cache — zero new network calls (ADR 0030). +- `static/mastery.js` renders tokenized `--mastery-*` segments (defined in all five stock themes); clicking a segment filters the library to that platform. + +### Game Night +- New **Big Box → Game Night** party mode: set players (2–8) and session length, build a couch-multiplayer queue, spin the wheel, launch, and advance rounds. +- `pkg/parity/parity_party.py` filters by `max_players`, couch platforms / `controller_support`, path usability, and a 3x session-length budget; sorts rating-first with a random tiebreak (ADR 0031). +- Additive routes `POST /api/v2/party/queue`, `GET /api/v2/party/queue`, `POST /api/v2/party/next`; queue + round index persist in settings and survive restarts. +- `static/party.js` wheel, "Up next" cover strip, gamepad support via the existing `pollGamepads` edge detection, and keyboard fallback (arrows/Enter/N/Escape). +- Fixed M2 latent bug: `POST /api/v2/library/pick` hung on a live server because the handler re-read the already-consumed request body; POST handlers now take the parsed `payload` argument (ADR 0031). + +### Fixed +- **Constellation canvas was blank for all users**: canvas 2D ignores CSS `var()` colors (assignments silently keep black), so nodes and edges were invisible on dark themes. Colors now resolve via `getComputedStyle` with keyword fallbacks (ADR 0027). +- **Constellation layout exploded**: unclamped spring-electric steps teleported nodes off-canvas on tick one. Steps are clamped and repulsion corrected to Fruchterman-Reingold `k²/d` so pairs settle near distance `k` (ADR 0027). +- **Raw i18n keys in freshly opened UI**: module-level `t()` maps froze before the async locale arrived (Constellation kind labels), and the Insights panel rendered before it. Label maps resolve lazily and the panel re-renders on `localechange`. +- Constellation loading indicator now hides after a successful render. + +### Architecture — Parity Shims & State Registry +- Deleted all 28 root-level `parity_*.py` compatibility shims; the `MetaPathFinder` in `pkg/parity/__init__.py` is now the sole flat-import bridge (ADR 0003). +- Central `pkg/state/_deps.py` registry replaces 4 private `_ns()` helpers in `pkg/state/{cache,launch,media_probe,sse}.py`; `webapp_state._populate_deps()` registers all exported names at import time (ADR 0009). + +### SQLite Read Model Graduation +- `OPENBOX_ENABLE_SQLITE_READ=1` now actually changes behavior: `GET /api/explorer/facets` uses SQLite GROUP BY with `query_parity_check()` safety net; new `GET /api/v2/library/search?q=&limit=` uses FTS5 (or LIKE fallback). Default off = byte-identical behavior (ADR 0032, amends 0014). + +### LaunchBox Library Migration +- New `POST /api/v2/import/launchbox/preview` and `POST /api/v2/import/launchbox/apply` parse LaunchBox XML exports and merge via `merge_imported_games`. Emulator mappings are reported, not silently applied (ADR 0033). + +### Big Box Video Snaps +- Big Box stage mode now shows looping gameplay videos when available, with 600ms debounce, BGM ducking, and `prefers-reduced-motion` support. Falls back to static covers (ADR 0034). + +### Library Sync via Mounted Folder +- New `POST /api/v2/library/sync/publish` and `POST /api/v2/library/sync/pull` extend cloud sync to the full library with tombstones for deletions and last-writer-wins conflict resolution. Stats sync remains separate (ADR 0035). +- **Superseded in 1.10.0:** the legacy full-library routes now fail closed before mutation; use the opt-in causal catalog transport described by ADRs 0038–0039. + +### Manual/Shelf Entries +- New `POST /api/v2/library/manual-entry` adds games without a local file path (physical media, board games, console games). Marked with `manual_entry: true` (ADR 0036). + +### Gate & Hygiene +- `scripts/check_tests.py` now auto-wraps itself under `xvfb-run -a` when available, guarded by `OPENBOX_HEADLESS_GATE=1`. +- Corrected stale JS module count in `AGENTS.md` (27, not 20). + +## [1.8.0] - 2026-09-02 + +### Navigation & Routing +- **Keyboard and gamepad navigation** for the library grid and list view (arrows/Home/End/Page, `f` favorite, Escape clear, gamepad via configurable controller map with edge detection) (ADR 0021). +- **Hash routing**: refresh and shared links restore platform/playlist/preset/query/selection/sort via `#/key/value` hash fragments (ADR 0021). +- **Sortable list-view columns** with persisted direction (`list_sort`/`list_sort_dir` settings); screenshot lightbox with prev/next/zoom; cover skeleton loading shimmer. + +### ScreenScraper Provider +- **Per-ROM-hash metadata and media scraping** with credentials in `~/.env`, 1 req/s throttle, 429/5xx backoff, 30-day disk cache, region-priority media selection, and an https-only URL guard (ADR 0022). +- Additive v2 routes: status/test/search/info/match (batch, cancellable)/apply (durable job, downloads outside the state lock). +- Deliberately not wired into the LaunchBox match-review pipeline (ADR). + +### Custom Gamescope Presets +- **User-defined presets** (≤16, unique names, bounded ints) with per-game override; per-game `gamescope_preset` wins over global (completes ADR 0016). + +### Library Export +- **JSON/CSV export** with `all`/`platform`/`playlist` scopes, shareable-by-construction field projection, media paths opt-in, collision-safe filenames, and newest-10 rotation (ADR 0023). +- Additive v2 routes: queue durable job, download with Content-Disposition, list exports. + +### ARM64 & Flathub Prep +- **aarch64 AppImage** release artifacts alongside x86_64; architecture-aware self-update refuses non-matching-arch releases (ADR 0024, un-defers ADR 0013). +- CI matrices build/attest/publish both arches (aarch64 on `ubuntu-24.04-arm`). +- Flatpak manifest runtime bumped `org.gnome.Platform 46` → `49`; AppStream ``, ``, and `` added; `docs/flathub-checklist.md` (submission stays a maintainer decision). + +### Play Insights (Library) +- Play Insights now renders in the library with 30/90/365-day ranges, lazy IntersectionObserver load, debounced reload, and top-games deep links. + +### Fixed +- `gamescope_preset`/`mangohud_enabled`/`show_insights` settings persisted instead of silently dropped (M0 whitelist bug). +- Context-menu "add to playlist" (`addGamesToPlaylist`) and Big Box "Achievements" (`openAchievements`) no longer throw ReferenceErrors. +- Chosen UI language now survives reload via `openbox-locale` localStorage. +- `app:state-refreshed` event dispatched (debounced) from `library.js refresh()`. +- Changed-line coverage gate skips unmeasured test/script files (ADR 0025). + +## [1.7.2] - 2026-09-01 + +### Localization + +- Full **i18n system** with `data-i18n` attributes in `index.html`, `t(key)` in JS via `static/i18n.js`, and JSON locale files for **English, Spanish, German, French, and Portuguese** in `locales/`. +- Settings → Interface language selector populated from `available_locales` in `public_settings`; switching re-translates the UI without reload. +- `scripts/check_i18n.py` gate verifies 100% key coverage across all locale files; wired into `make check`. +- Removed the "Localization is planned for a future release" note; localization is now live. + +### Scale Foundation + +- Optional **SQLite read model** (`pkg/state/sqlite_readmodel.py`) behind `OPENBOX_ENABLE_SQLITE_READ=1` env flag. Provides FTS5 full-text search (with LIKE fallback), indexed filtered queries, and GROUP BY facets. Disabled by default; zero behavior change when off. +- `query_parity_check()` verifies SQLite results match the JSON read path. + +### Deck Polish + +- **Gamescope presets**: 8 profiles (Steam Deck, HD, 1080p, 1440p, 4K, integer, stretch, borderless) in `pkg/parity/parity_gamescope.py`; selectable in Settings → Controller. +- **MangoHud** performance overlay toggle; `apply_mangohud_env()` sets `MANGOHUD=1` on game launch when enabled. +- Controller bench tab in Settings with live gamepad SVG visualization. + +### Emulator Health + +- **BIOS SHA1 drift detection** in Launch Doctor: reports `BIOS_SHA1_DRIFT` when a BIOS file exists but its hash doesn't match the expected value in `emulator_defs/*.yaml`. +- Health badge CSS classes (ok/warn/fail) with tokens in `static/app.css` and all 5 themes. +- `GET /api/v2/emulators/registry?health=1` returns `bios_ok`/`firmware_ok`/`core_ok` per adapter. + +### Smart Collections & Backup Diff + +- **Visual chip builder** for filter presets: `rules_to_chips()` and `chips_to_rules()` in `pkg/parity/parity_filter_presets.py` convert between preset rules and UI chip descriptors. +- **Backup diff**: `GET /api/v2/backup/diff?archive=` compares current library against a backup archive, returning added/removed/changed game IDs and settings change status. + +### Gates & Release + +- `scripts/check_i18n.py` wired into `scripts/check_tests.py` as a strict gate. +- New runtime module `pkg/state/sqlite_readmodel.py` added to `runtime_modules.txt` (115 entries). +- 6 new locale-serving GET routes + 1 backup diff v2 route. +- Coverage: 81% total (floor 70%), web_app 55% (floor 54%), sqlite_readmodel 90% (floor 85%). +- Token baseline: 0 (15 new tokens across gamepad + health, all in `:root` + 5 themes). +- ADRs: `docs/adr/0014-sqlite-read-model.md`, `docs/adr/0015-i18n-system.md`. + +## [1.7.1] - 2026-08-29 + +### Play Insights + +- Local-first **Play Insights** dashboard: 366-day heatmap (levels 0-4), current/longest streak, top platforms/genres, momentum (last 30 vs previous 30) from `history` + `games` — no new storage, no telemetry. `GET /api/v2/insights/summary` + `GET /api/v2/insights/heatmap?days=&end_date=`. + +### Performance + +- Virtual spacer-window library grid with `localStorage['openbox-virtual-grid']` kill-switch, `IntersectionObserver` + `contain-intrinsic-size`, rAF coalescing preserved. +- Trigram search off main thread via `static/worker.search.js` with main-thread fallback; identical results. +- `pkg/state/cache.py` `FacetCache` LRU (64) + budget + epoch bump on `_invalidate_all()`; `state_store.py` 50 ms micro-batch coalesce, single fsync. +- `scripts/perf_bench.py` now supports `--json-out` alias and artifact-friendly JSON; gates remain 10k/20k (20k <15 ms for insights heatmap). + +### Setup & Launch Doctor Polish + +- Setup preview `preview_document` now includes human `message` (“Found 342 games — 12 need your pick →”) for progress storytelling. +- Launch Doctor every blocking check now carries `fix_action {kind, label, payload}` (`flatpak_install`, `reveal_bios_path`, `pick_core`, `explain_token`) — actionable buttons instead of red badge only. + +### Frontend & Themes + +- New tokens in `static/app.css` `:root`: `--overlay-insight-cell-0`…`--overlay-insight-cell-4`, `--border-insight`, `--shadow-insight`, `--surface-insight-card`, `--focus-ring`; all 5 themes updated. +- Insights panel CSS: cards, heatmap grid 53×7, ranked lists, streak, legend; lazy-loaded via `static/insights.js`. + +### Gates + +- `runtime_modules.txt` + shim `parity_insights.py`, `handlers/insights.py` wired; `routes.py` 4 new GETs; `v1_contracts.json` unchanged (frozen). +- `make check` green: ruff, py_compile, `check_tokens` 0, `check_v1_contract` 60 routes, `check_runtime` 114, `check_frontend` eslint/tsc. + +## [1.7.0] - 2026-08-26 + +### Library Setup Center + +- Guided **Set up library** workflow with preview-before-commit imports, paginated review, emulator readiness, metadata enrichment, and completion actions. +- Side-effect-free scan previews with idempotent commit, stale-preview guards, and `import_batch_id` tagging for post-import filtering. + +### Activity Center + +- Durable operation service backed by `operations.json` with queued/running/cancelling/done/partial/error/cancelled/interrupted states. +- Persistent top-bar Activity control, SSE progress, cancellation, retry/resume, and legacy `/api/jobs` compatibility. + +### Launch Doctor + +- Preflight validation for game paths, adapters, Flatpak/native executables, BIOS/firmware, and tokenized launch arguments. +- Registry-driven emulator detection with explicit ambiguity handling and launch precedence rules. + +### Additive v2 API + +- Exact `/api/v2/*` routes for setup preview/commit, emulator registry, launch preflight, metadata match review, and durable jobs (see ADR 0010). +- Stable error codes for preview staleness, unresolved candidates, job conflicts, and cloud sync failures. +- Canonical `library.json` schema remains version **6**; previews and operations use separate disposable storage. + +### Performance & scale + +- Formal support target of **20,000** games with query-cache correctness, bounded index fallback, and performance gates. + +### Packaging + +- Release-gated **x86_64** artifacts: Ubuntu 22.04 **AppImage** and **Flatpak** (runtime 25.08). +- No Flathub store submission in this release; no telemetry added. + +## [1.6.0] - 2026-08-23 + +### Architecture & State Management + +- Decomposed `webapp_state.py` into focused state modules: `pkg/state/imports.py` (import orchestration, duplicate merging, auto-import), `pkg/state/commands.py` (command execution), and `pkg/state/registry.py` (process and session tracking with typed `Session` dataclass), keeping a lightweight backwards-compatible re-export facade. +- Centralized launch token expansions (`{path}`, `{name}`, `{dir}`, `{stem}`, `{platform}`, `{app_id}`, `{heroic_app_id}`, `{lutris_id}`, `{rom_name}`, `{DataDir}`, etc.) into `pkg/parity/launch_tokens.py`. +- Consolidated caches and locks into coordinated `CacheEpoch` dataclass with atomic full invalidation (`_invalidate_all()`). + +### Frontend & Accessibility + +- Replaced details/summary tools dropdown with fully accessible WAI-ARIA button and menu pattern (`#toolsButton`, `#toolMenu`, and `#toolsWrap.open`) supporting full keyboard navigation (Arrows, Home, End, Escape, Tab). +- Memoized search index with LRU cache, debounced input, and bounded trigram expansion for instant lookups across 20k+ games. +- Implemented dialog focus traps with inert fallbacks and proper focus restoration. +- Memoized grid geometry calculation for faster library view rendering. + +### Security & Hardening + +- Added `frame-ancestors` directive to Content-Security-Policy (CSP) headers. +- Hardened exception handling across handlers, state imports, commands, and SSE streams to eliminate broad except catches and add structured error logging. +- Added input validation and authentication checks to native dialog, window, and emulator scan endpoints. +- Added performance benchmark write-path gate (<500ms for 10k games). + +## [1.5.1] - 2026-08-19 + +### Performance + +- Optimized state writes with dirty-field tracking, batched snapshot persistence, and cached library projections for large libraries. +- Accelerated import scanning, metadata batching, and archive inspection throughput. +- Streamlined BigBox CoverFlow rendering and indexed title search matching. +- Improved native host startup responsiveness with non-blocking IPC polling. + +### Changed + +- Enhanced cross-store import consolidation across Steam, Heroic, Lutris, Faugus, and ROMs using canonical identity normalization. +- Hardened CLI help formatting and argument parsing for headless and native host invocations. +- Updated parity compatibility shims and LaunchBox feature matrix documentation. + +### Fixed + +- Ensured completed background job futures are released synchronously to eliminate future and memory retention. +- Hardened import endpoint error handling and input validation against malformed payload structures. + +## [1.5.0] - 2026-08-18 + +### Added + +- Edit Game modal now features Previous (← Prev) and Next (Next →) navigation buttons to rapidly cycle and edit games in the active filtered/sorted library without reopening the modal. +- Media cleanup can now be scoped to single platforms in addition to full library scans via `POST /api/media/cleanup`. +- Added "Reset play statistics" to the game right-click context menu and bulk edit wizard, clearing `play_count`, `playtime_seconds`, and `last_played`. +- Filter presets and smart filter playlists can now filter games by capability rules: `has_saves`, `has_achievements`, `has_missing_media`, and `has_highscores`. +- Added `Ctrl+Alt+Q` (and `Ctrl+Alt+R`) global shortcut to shuffle and focus a random game in the desktop grid and list views. +- Search and filter queries now support acronym matching for game titles (e.g., `oot` matches *The Legend of Zelda: Ocarina of Time*, `mgs` matches *Metal Gear Solid*, `sotn` matches *Castlevania: Symphony of the Night*). +- Expanded dynamic launch variables for custom emulators and launch commands, supporting `{ImagePath}`, `{dir}`, `{Dir}`, `{file}`, `{File}`, `{stem}`, `{FileNameWithoutExtension}`, `{Platform}`, `{EmulatorDir}`, and `{DataDir}`. +- Added Proton and Wine prefix manager (`/api/wine/*`), Faugus scan and import (`/api/faugus/*`), and Eden Switch emulator definition (`emulator_defs/eden.yaml`, later renamed `eden-switch.yaml`). +- Added canonical identity normalization across Steam, Heroic, Lutris, Faugus, and ROMs with duplicate game consolidation in library health checks. +- Added `--fullscreen-width`, `--fullscreen-height`, and `--resolution ` CLI flags to control kiosk and native window viewport sizing. + +### Fixed + +- Resolved frontend module reference errors, prevented media dialog DOM accumulation, fixed play queue and tag selected game lookups, and expanded BigBox menu controller navigation. +- Worker job queues and webapp state cleanup now guarantee cleanup on failure and process termination to prevent queue and process leaks. +- Preserved shared media files during media deletion via realpath reference counting. +- Stale Unix socket single-instance focus handling and native host boot cleanup now recover cleanly from crashed instances. + +### Hardened + +- Active sessions are now persisted and reconciled against PID, start time, and cmdline verification, marking abandoned sessions and reattaching watchers. +- Launch preparation is transactional with explicit 8-phase lease tracking. +- Backups and exports centralize credential and secret redaction with manifest tracking, preserving local secrets on restore. +- Performance benchmark runner covers full operational matrix with 10k game scaling gates. + +## [1.4.0] - 2026-08-17 + +### Fixed + +- AppImage and Flatpak now bundle every static module, fixing 404s for util.js, state.js, library.js and other chunks that left the top bar unresponsive. Routes now handle future JavaScript chunks without manual table updates. Fixes #19. +- AppImage bundling now creates parent directories for pkg/parity modules so the build does not fail on packaged layouts. +- Build provenance, SBOM generation and packaging tests now cover the full static set so the UI ships complete. + +### Changed + +- Repository layout reorganized: docs moved to docs/, tests to tests/ and parity modules to pkg/parity with backwards compatible shims at root. Agent conventions and layout docs added. +- CI, test runner, coverage gates and token hygiene updated to handle both flat and packaged layouts. Docs paths fixed after reorg. + +## [1.3.0] - 2026-08-16 + +### Hardened + +- Plugin execution now uses bubblewrap with isolated namespaces, no network access, and temporary mounts for home, temporary, runtime, and removable-media paths when the host supports it. If the sandbox cannot be created, enabled plugins are skipped by default; `OPENBOX_ALLOW_UNSANDBOXED_PLUGINS=1` is an explicit opt-in for trusted local plugins. +- Gamescope regression tests now launch in their own process groups and terminate the full group on timeout, preventing nested gamescope processes from surviving the test gate. + +### Changed + +- Cloud sync, save and backup restore, launch handling, settings validation, metadata application, Lutris import, 7z validation, webhook validation, filter matching, and game resolution were split into focused helpers, keeping the security-sensitive paths easier to audit and test. +- The background job manager now has dedicated coverage for retries, cancellation, queue and name limits, bounded results, shutdown, and completed-future cleanup. +- Dead backend code and obsolete browser, screenshot, migration, and performance-capture scripts were removed. +- From-source setup documentation now explains the tokenized UI URL and the supported `OPENBOX_ENV_FILE`, data-directory, home-directory, and user-config locations. +- CI and release tooling now use the refreshed GitHub Actions and JavaScript dependencies, with CodeQL action components kept on one version and grouped Dependabot updates for future changes. + +## [1.2.0] - 2026-08-15 + +### Fixed + +- SteamOS AppImages no longer export bundled libraries into the host shell, fixing startup failures where `/bin/bash` could not resolve `rl_print_keybinding`. +- Webhook delivery rejects non-public destinations, pins validated DNS results, disables proxies and redirects, and bounds response reads. +- Library and save backups use private atomic files, reject unsafe archive entries, and protect restore paths against symlinks and archive replacement races. +- Native bridge authorization now requires the exact OpenBox origin, including its dynamic port. +- Gameyfin validates IDs before path construction, contains filesystem operations under the install root, requires HTTPS, and verifies supplied checksums. +- 7z extraction rejects links, operates on a bounded snapshot, and validates the staging tree before promotion. +- Media and document reads enforce approved roots, including symlinked parent checks. +- Environment loading accepts only owner-controlled files and supported keys, and no longer searches the current directory. +- Job and SSE queues now have explicit capacity, expiry, cleanup, and slow-client behavior. + +### Changed + +- Release artifacts require Ed25519 signatures against the pinned production public key. +- Release jobs separate build, provenance attestation, and publication permissions and refuse asset overwrites. +- Build and CI inputs are pinned, the SBOM is generated from the completed AppImage, and Puppeteer 25.7.0 resolves the audited npm dependency issues. +- Plugin catalogs require a pinned digest, HTTPS package URLs, and package checksums. + +## [1.1.0] - 2026-08-15 + +### Fixed + +- Cloud sync: the local-wins merge branch contained a dead condition (`remote_played > local_played` can never be true there), so newer per-field remote values were silently dropped. +- State backup now mirrors the latest committed primary, staged atomically, instead of aliasing the brand-new write. +- Plugin environment filtering fixed a typo (`GAMEFYIN_` -> `GAMEYFIN_`), so correctly spelled Gameyfin variables are stripped from plugin subprocesses. +- Play queue: skip flags recorded while advancing are now written back to state before a valid item is returned, so they can no longer silently disappear. +- Queue `path_exists` now checks the filesystem instead of reporting any nonempty path string as existing. +- OBS status: `recording` now requires a recording file produced within the last two minutes instead of reporting any running OBS process as actively recording. +- Steam and Lutris imports now verify the Flatpak app is actually installed (`flatpak info`) before building a `flatpak run` command. +- IGDB: time-to-beat came from a nonexistent `time_to_beat` field on the games endpoint. It now queries the separate `game_time_to_beats` endpoint and converts its seconds value to hours. +- Gameyfin: catalog requests the provider list once; raw responses are returned open and closed by the caller; the tautological `str(folder) if installed else str(folder)` is gone. +- Job manager: completed futures are now released via a done callback so job bookkeeping cannot accumulate indefinitely. +- Webhook retry: the injected clock now measures the sleep duration and warns when the wall-clock sleep overshoots. +- The emulator-defs YAML fallback parser no longer decides a key is a list based on whether its name ends in "s"; indented values build sequences from the actual shape. +- Native dialog bridge: selected paths are now JSON-quoted strings, so paths with spaces or quotes produce valid JSON and no longer leak the `g_strescape` allocation. +- Ed25519 point decoding now rejects out-of-range coordinates, small-order points, and off-curve values in both `updates.py` and `scripts/verify_release.py`, and checks the canonical scalar before point arithmetic. +- `remove_exclusion` returns the number of removed entries; the API route reports `removed` truthfully. +- `sanitize_settings` no longer iterates a non-dict input into garbage dropped-key lists. +- The 7z archive validator counts a final member even when the listing omits the trailing blank separator. +- Screenshot capture: the previously ignored `window_hint` now selects active-window flags for gnome-screenshot, spectacle, and scrot. + +### Changed + +- `_tdp_args` returns only the argument list; the unused milliwatt value is gone. + +## [1.0.1] - 2026-08-15 + +### Fixed + +- Big Box hybrid mode: platform buttons were emitting a broken `data-bigbox-AppState.platform` attribute that the click handler never matched, so switching platforms did nothing. The attribute now matches the selector. +- IGDB search sent a malformed `&AppState.platform =` query parameter instead of `platform=`, dropping the platform hint from searches. +- Custom-field keys in the details pane were rendered without escaping; a crafted key could inject HTML. Keys and values are now both escaped. +- The session token stayed in the browser address bar after load. It is now scrubbed from history immediately, with deeplink parameters preserved. + +### Changed + +- CSP tightened: `script-src 'self'` without `'unsafe-inline'`, plus `object-src 'none'` and `base-uri 'none'`. +- Requests without a Host header are now rejected instead of bypassing the loopback check. +- The SSE stream now carries the same security headers as every other response. +- The startup URL printed to stdout no longer contains the session token. +- The updater verifies an Ed25519 signature when a release publishes one, and skips with a loud warning while the public key is still the placeholder. +- WebKit rendering defaults changed: dmabuf renderer disabled unless `OPENBOX_ENABLE_DMABUF` is set (fixes silent window failures on AMD GPUs, including Steam Deck), and hardware acceleration switched to on-demand. + +### Hardened + +- The native host now validates full URIs (scheme, host, no userinfo, no control characters) before handing anything to the default handler. +- Reveal-in-folder is restricted to paths under the data directory or home directory. +- The native bridge rejects suspicious payloads instead of evaluating them. +- Plugin catalog downloads now require a valid sha256 checksum; entries without one are refused. +- Plugin subprocess environments are scrubbed of token, password, secret, and API-key variables. +- `before_launch` plugin hooks can no longer swap the launch binary or move the working directory outside the game or data directories; tampered results fall back to the original command. + +### Fixed (launch reliability) + +- AppImage launches now route through the fallback ladder instead of exec-ing the native host directly, and all launch failures are written to `~/.local/share/openbox-game-launcher/openbox-launch.log` instead of vanishing on a double-click. +- The native host writes its own log and the single-instance message is no longer invisible. +- Game Mode with no kiosk browser installed now prints the server URL instead of failing silently. + +### Verification + +- `./run_all_tests.sh`: 47 test files, 0 failures. +- `make check`: lint, compile checks, coverage floors green. +- CI smoke test now covers Big Box platform switching and IGDB search parameters; JS linting runs in CI; Dependabot watches GitHub Actions and npm. + +## [1.0.0] - 2026-08-14 + +### Added + +#### Native window (native-first) + +- `native_host.c` (C, WebKitGTK) renders the same `index.html`, `app.js`, and `app.css` as the web UI in a native window, with smooth scrolling, hardware acceleration, and the app's exact background color. The native host owns server lifecycle, single-instance locking, minimize-to-tray, window geometry persistence, and a fallback ladder (system-browser chrome-less app window, then the default browser) when WebKitGTK is missing or the host fails to start. +- `openbox` and `openbox-native` launchers resolve the share directory across repo, Makefile/Flatpak, and AppImage layouts; `openbox --web` remains the development opt-out to the loopback web UI in a browser. +- Native IPC: `/api/native/*` routes with dynamic capability reporting, plus a JS↔C bridge (`window.openboxNative`) for native dialogs, external opens, reveal-in-file-manager, and window chrome including Big Box fullscreen enter/exit. +- Server-Sent Events: `/api/events` streams session and job events; the job manager emits observer events and polling remains the fallback. SIGTERM/SIGINT teardown stops running sessions and drains work before exit. + +#### State and API contract + +- Schema v5 adds a host-owned `ui_state` block; existing games, settings, playlists, and history migrate untouched. +- The v1 API contract is frozen: `contracts.py` + `v1_contracts.json` (60 routes) with `scripts/check_v1_contract.py` wired into the coverage gate and CI, and `test_v1_aliases.py` pinning legacy aliases. + +#### Batch metadata auto-match + +- One "Auto-match library" action binds every unmatched game whose title exactly matches the LaunchBox Games Database, replacing the one-game-at-a-time dialog flow. Only exact normalized-title hits qualify, so ambiguous titles are left unmatched for manual confirmation. + +#### Frontend + +- Grid cover-grouping by aspect ratio (default), persisted as `cover_grouping` and toggleable from the library header. +- The topbar regroups into Library, Actions, and Tools zones; the empty-state import surface covers every storefront path. +- The dialog manager traps focus and closes on Escape. +- Scroll-lag fixes: rAF-coalesced grid rendering, backdrop blur removed from the base scroll path, hover-gated cover transitions, and a constrained workspace grid row. + +#### Tests and documentation + +- `test_native_ipc.py` and `test_sse.py` cover the native bridge and the event stream. +- The screenshot pipeline now waits for Big Box and game-detail views to settle and enriches fixture metadata (ESRB, max players, play time, region, play mode, wiki links). +- `docs/adr/0001-native-host.md` and `docs/native-host-contract.md` document the host/server split and the IPC contract. + +### Changed + +- The 260-method `Handler` class is split into capability mixins under `handlers/` (data, library, settings, media, metadata, imports, sessions, extensions, health, emulators, native). `web_app.py` drops from 3,755 to 1,628 lines and keeps server plumbing, shell/SSE serving, bootstrap, and lifecycle; each mixin's methods resolve the live `web_app` namespace at call time, so response bytes and route wiring are unchanged. Authentication is centralized in dispatch and route tables resolve module-qualified method names. +- The removed Tk interface no longer ships: AppImage dependencies drop python3-tk/tcl/tk in favor of WebKitGTK 4.1 and GTK dev headers, and the AppImage installs the `handlers/` package. CI compiles the native host (libwebkit2gtk-4.1-dev) on pull requests so C build breaks fail early, and release CI ships the compiled host and the logo asset. +- Packaging installs the runtime modules and `handlers/` (tracked in `runtime_modules.txt`), with SHARE resolution for Makefile/Flatpak layouts. + +### Fixed + +- The AppImage build now creates the `handlers/` directory so the handler split installs correctly. +- The `gen_sbom` usage example version, stale 0.9.0 release-date and localization claims, and SECURITY.md support rows (the 1.0.x row is pinned in tests). + +### Verification + +- Ran `./run_all_tests.sh`: 47 test files, 0 failures. +- Ran `make check`: lint, compile, tests, coverage floors (59.0% total vs 55.0% floor; 65.0% `web_app.py` vs 44.0% floor), and the v1 contract check all pass. +- UI smoke test (`scripts/ui_smoke.sh`) boots a real server, drives the grid, and asserts the Tools menu still opens under every stock theme with no page errors. + +## [0.9.0] - 2026-08-12 + +### Added + +#### LaunchBox media catalog and archive manuals + +- Full LaunchBox Games Database media downloads beyond covers, backgrounds, and screenshots: box backs, box spines, 3D boxes, clear logos, fanart, banners, title screens, cart fronts, cart backs, discs, and advertisement flyers. The metadata dialog, bulk download dialog, media audit, artwork gallery, image groups, and the auto-import setting all accept the expanded type set. +- Manuals are not in the LaunchBox feed, so the manual option now pulls a PDF or text manual (`MANUAL_SUFFIXES = (".pdf", ".txt")`) out of the game's own archive. The finder ranks `manual.pdf` first, then shortest and most recent candidates, caps the candidate list at 8 to stay safe on pathological archives, copies the winner next to the game's media, and never blocks a sync on a bad archive. When nothing is found, the game records a `manual: no manual in this archive` note that the UI surfaces. +- Metadata search maps the app's own platform names to their LaunchBox spellings: `Game Boy` to `Nintendo Game Boy`, `PlayStation` to `Sony Playstation`, `GameCube` to `Nintendo GameCube`, `Xbox` to `Microsoft Xbox`, and 22 more (26 aliases total, including `PC` to `Windows` and `DOS` to `MS-DOS`). Exact-name searches now rank platform-correct results first. +- The LaunchBox Games Database dialog shows library coverage facts (matched games, match ratio, and per-field media counts) once the local database is ready. +- Batch auto-match: one "Auto-match library" action binds every unmatched game whose title exactly matches the database, instead of matching one game at a time. Only exact normalized-title hits qualify, so ambiguous or partial titles are left unmatched for manual confirmation rather than guessed onto the wrong record. + +#### Desktop window + +- The desktop UI opens in a chrome-less app window by default instead of a browser tab: a Chromium-family browser opens with `--app=`, Firefox falls back to a separate window, and with no compatible browser the default browser is used. The Settings panel controls this with an "Open the UI in" option, and `--app-window` / `--no-app-window` override it at launch. + +#### Engineering gates + +- `make check` runs ruff lint, compile checks over every runtime module and test, the full test suite under coverage, and coverage floors in one command. CI enforces it on push, pull requests, and a weekly schedule. At the time, floors in `scripts/check_tests.py` were 60% total and 48% for `web_app.py` (now ratcheted higher; see `scripts/check_tests.py`), and the floors fail the build when coverage regresses. +- A version-sync check (`scripts/check_version_sync.py`) fails when `updates.py` disagrees with the README badge, metainfo, PARITY.md, the bug report template, or CHANGELOG, so a release can no longer ship with a stale version claim. +- Dev-only tooling (ruff, coverage) is pinned in `pyproject.toml` and lives in `.venv-dev`; the runtime app still has zero third-party dependencies. + +#### HTTP layer + +- The two monolithic dispatch chains (a 613-line GET and a 195-line POST if-chain) became a route registry in `routes.py`: 109 GET and 126 POST entries, each a named `Handler` method or dotted handler spec, with zero behavior change during the mechanical extraction. The contract-frozen v1 surface is 60 routes (`v1_contracts.json`). +- Structured errors in `api_errors.py`: every failure carries a stable machine code (`BAD_REQUEST`, `GAME_NOT_FOUND`, `MEDIA_NOT_FOUND`, `ROUTE_NOT_FOUND`, `MEDIA_JOB_RUNNING`, `STATE_UNAVAILABLE`, ...) and a per-request id that appears in the UI and the diagnostic log. POST handlers re-raise `ApiError` unchanged and convert legacy `ValueError`s into `400 BAD_REQUEST` instead of leaking them to the generic 500 path. +- A versioned `/api/v1` surface aliases the stable routes; legacy paths keep working for older clients. +- The library payload is gzip-compressed once per state change and served with a `private, no-cache` ETag, so polls get 304s when nothing changed: 5,000 games serve in about 2 ms at 638 KB instead of 13.8 MB (measured in `docs/development/PERF.md`). +- Settings saves now drop unknown keys against the settings key registry (`settings_schema.py`) with a diagnostic warning, instead of persisting junk from a stale client. + +#### Reliability + +- Rolling state snapshots: the state store keeps the last 5 committed states as timestamped JSON copies under `library.json.snapshots/`, rotated on every commit. `/api/state/recover` gained `dry_run` (preview what recovery would do) and `snapshot` (restore a named point in time) modes; snapshot names are strictly validated. +- Background jobs keep a bounded history of the last 50 finished jobs, exposed at `/api/jobs` and rendered in the Library Audit dialog as a jobs panel. +- Graceful shutdown: Ctrl-C / SIGTERM now stops running sessions and drains webhooks in the `finally` block instead of stranding game processes. +- The browser's session poll runs every 1s while a game is running and every 10s when idle, cutting idle wakeups on handhelds. +- Log redaction now covers RetroAchievements key and `client_secret` shapes in addition to tokens, passwords, and authorization headers. +- A local-only diagnostic report packager (`/api/diagnostic`) bundles redacted logs, system facts, and the version for pasting into issues. + +#### Frontend + +- `index.html` shrank from 3,117 lines to a 485-line shell; the JS and CSS live in `static/app.js` and `static/app.css`, served over `/static/*` with ETag caching and a filename whitelist. The server injects the token into the asset URLs so script and stylesheet loads authenticate. +- All 30 browser state globals moved into one `AppState` object; browser-only preferences (library view, image group, badge visibility, sidebar sections) persist through `localStorage` with a versioned key. +- Server errors surface in a persistent, dismissible banner with a "Copy details" action that includes the request id, and `window.onunhandledrejection` routes internal errors there. +- The `api()` helper maps 45 stable routes to `/api/v1` through a single `API_V1` map, so a future v2 rename is a one-object change. +- Accessibility: every dialog is `aria-modal`, toasts and lifecycle messages are live regions, the label floor rose from 11px to 12px, and the library heading leads the workspace. +- The interface language selector now ships English-only with an honest note; the five partial translations were removed until real localization lands. +- `scripts/ui_smoke.sh` boots a real server and drives the grid with puppeteer, asserting the grid renders, selection works, and no page errors fire. + +#### Release and supply chain + +- `scripts/gen_sbom.py` produces a CycloneDX 1.4 SBOM of the runtime modules, bundled data files, and the bundled Python stdlib. +- `scripts/sign_release.py` (Ed25519, maintainer-only) and `scripts/verify_release.py` (pure-stdlib RFC 8032 verification) sign and check release artifacts; `test_release_signing.py` proves the round trip and tamper detection. +- `scripts/release.sh` chains version sync, the make check gate, AppImage build, SBOM, checksum, optional signing, and a release notes draft, stopping before the human publish step. +- `scripts/gen_api_docs.py` generates the API v1 contract page from the live route tables. + +#### Documentation + +- `docs/reliability.md`: a 23-scenario edge case catalog (truncated state, full disk, orphaned sessions, wrong credentials, huge archives, broken symlinks, 20k-game libraries) with expected behavior and verification status per row. +- `SUPPORT.md`: the platform and runtime matrix plus reporting guidance. +- `TRIAGE.md`: response targets and the triage flow. + +### Fixed + +- Duplicate media cleanup now scans every media field, not just covers and backgrounds. +- Latent undefined names in `web_app.py` that would have crashed their code paths on first use: `automation.DEFAULT_ATTEMPTS`/`DEFAULT_TIMEOUT` (module referenced instead of the imported names) and `contained_path`/`read_limited` (used but not imported from `backend_io`). +- A stale `Request` import in the media catalog downloader. +- Lint debt across the gate rule set: default-argument calls (`home=Path.home()`), loop-variable closures, unused variables, lambda assignments, missing `check=` on `subprocess.run`, shebangs, and import placement. + +### Verification + +- Ran `./run_all_tests.sh`: 45 test files, 0 failures. +- Ran `make check`: lint, compile, tests, and coverage gates all pass (56% total, 44% `web_app.py`, against the then-current floors). +- UI smoke test (`scripts/ui_smoke.sh`) boots a real server and drives the grid with no page errors. +- `python3 scripts/check_version_sync.py` passes at 0.9.0. +- Perf bench at 5,000 games: gzip library 1.9ms / 638KB vs 13.7ms / 13.8MB plain (see `docs/development/PERF.md`). + +## [0.8.2] - 2026-08-12 + +### Added + +- Added Web UI surfaces for the persistent play queue, normalized game tags, Notification Center, and signed webhook settings. +- Added `/api/queue`, `/api/tags`, `/api/notifications`, and `/api/webhooks` contracts with bounded state, secret redaction, and destination validation. + +### Fixed + +- Box art now keeps its natural aspect ratio in the library grid, Big Box Stage, and CoverFlow views instead of being force-cropped into a single ratio, so games with non-standard artwork (for example SNES titles) display uncropped. Title-only covers keep the standard portrait box. + +### Verification + +- Ran `./run_all_tests.sh`: 39 test files, 0 failures. + +## [0.8.1] - 2026-08-09 + +### Fixed + +- Launching a game with no launch command and no matching platform profile now fails with a clear error before anything runs, instead of silently spawning a process that exits on the spot and reporting a normal session end. Games whose file is not executable get the same message. +- The web UI now shows the real outcome of a failed session. An immediate exit with a non-zero code reports "Session failed" with the exit code and a hint to check the launch command and emulator install, instead of the generic "Play time and history were saved" toast. +- Emulator installs no longer re-add the Flathub remote when it already exists, which previously made `flatpak remote-add --user` exit non-zero and abort the install. When a remote add or install does fail, the error now includes flatpak's own message instead of a bare non-zero status. + +### Verification + +- Added regression coverage for the emulator install path and ran the full suite. +- Ran `./run_all_tests.sh`: 36 test files, 0 failures. + +## [0.8.0] - 2026-08-07 + +### Added + +- Handheld performance profiles: per launch-profile TDP limits applied via `ryzenadj` at launch, with an optional restore limit when the session ends, gated by an `Apply handheld performance limits` setting (auto / always / off). `auto` applies only on Steam Deck / Bazzite game mode and battery-powered handhelds; a missing `ryzenadj` or permission error logs a warning and never blocks a launch. + +## [0.7.0] - 2026-08-02 + +### Fixed + +- Backup restore now merges archived settings back into `library.json` instead of a sidecar file the app never reads, so settings survive a restore. +- Restoring an older backup over a newer library is refused unless explicitly forced, and restore re-validates symlink parents after `mkdir` to close a zip-slip window. +- Save backups resolve relative `save_paths` against the game instead of the process working directory, and reject symlinked backup directories. +- Cloud sync propagates local deletions and resolves per-game conflicts by `last_played` instead of a stale global timestamp, so newer progress, ratings, and favorites win. +- Big Box exit no longer runs the app-exit `shutdown_commands`; only entering Big Box runs its configured commands. +- The media audit endpoint tolerates null media fields instead of returning a 500. +- Plugin updates are atomic with rollback on failure, and reinstalling a removed plugin no longer comes back disabled. +- The plugin `library` hook is TTL-cached so `/api/library` no longer blocks up to 5 seconds per plugin per request. +- Storefront catalog launch commands substitute their real identifiers (`{lutris_id}`, Steam, and Heroic). +- EmuMovies media searches URL-encode their query parameters. +- Bezel downloads extract into staging and swap atomically, so a corrupt archive no longer destroys the working bezel set. +- Emulator scan folders with `auto_update` enabled are re-scanned by the auto-import worker. +- `read_limited` rejects negative or huge `Content-Length` headers up front. +- Replaced backend jobs clean up their futures so the job manager does not grow unbounded. +- The state-store backup is written from the temp file before `os.replace`, so a crash cannot pair a fresh primary with a stale backup. +- Pre-release and build-suffixed release tags parse cleanly and are never treated as available updates. +- `.env` files strip inline comments while preserving `#` inside unquoted values. +- `openbox://` deeplinks reject foreign hosts and fail clearly when no server port is known instead of silently hitting a dead port. +- Folder-based session tracking matches path boundaries so `MyGame2` no longer matches `MyGame`. +- The native UI launches games detached from the launcher's process session. +- `rom_quality_score` no longer crashes on a missing or unreadable ROM. +- Duplicate-media cleanup prefers keeping the copy inside the allowed media roots. +- The metadata database temp zip is cleaned up when the download fails. +- User edits to stock themes are preserved instead of being reverted on every Themes dialog open. +- Gamescope guest detection also recognizes `STEAM_GAMESCOPE_RESTRICTED` sessions. +- IGDB token failures and Gameyfin provider fallbacks now surface readable errors. +- Flatpak packages include the diagnostic logging module. + +### Changed + +- The test runner now continues past failures and reports a per-file pass/fail summary instead of stopping at the first error. +- Tests no longer leak environment variables (`GITHUB_TOKEN`, `RA_*`, `OPENBOX_DATA_DIR`) into the shell or a real home directory. +- Documented `OPENBOX_DATA_DIR` and the RetroAchievements and EmuMovies environment variables in the README and `.env.example`. +- Refreshed user-facing documentation, package metadata, and installation guidance. + +### Verification + +- Added regression tests for every fix in this release. +- Ran `./run_all_tests.sh`: 32 test files, 0 failures. + +## [0.6.0] - 2026-07-30 + +### Added + +- LaunchBox-style advanced search in the Web UI, including field terms, quoted values, status filters, and negative terms. +- Ordered manual playlists with parent grouping, notes, membership editing, and keyboard-friendly reorder controls, alongside existing filter playlists. +- Game context actions, Ctrl/Shift multi-selection, configurable status badges, richer platform/category/playlist detail panes, related-game reasons, artwork galleries, and per-game launch profile overrides. +- Backup archive listing, manifest summaries, restore actions, expanded artwork groups, and metadata fields for controller support, disc count, portable games, and broken entries. + +### Changed + +- State persistence now uses schema migrations, stable game identities with legacy aliases, last-known-good recovery, process-safe transactions, atomic writes, and corruption preservation. +- Long-running backend jobs now expose bounded state, retries, cancellation, durations, and replacement protection. Request bodies, downloads, archives, media responses, save restores, backup restores, and plugin execution use bounded and validated paths. +- Packaging uses the runtime module manifest and stricter import and artifact checks. Flatpak support documentation and release validation were refreshed. + +### Verification + +- Added focused API and launch-profile regression tests. +- Ran `./run_all_tests.sh`, Python compilation, JavaScript syntax checks, diff validation, and a Chromium smoke test covering the new search, selection, context, playlist, backup, settings, media, and detail workflows. + +## [0.5.0] - 2026-07-30 + +### Added + +- Steam Game Mode guest support for Steam Deck, Bazzite, and similar gamescope sessions. Big Box opens fullscreen while Steam retains Input, Quick Access Menu, and TDP controls. +- Settings can remove all Steam-imported library entries at once. This keeps game files and media on disk. +- Local rotating diagnostic logs with a Settings copy button. Request and auto-import failures include timestamps, uncaught crashes include stack traces, and tokens and passwords are redacted. + +### Fixed + +- Session playtime and Gameyfin installs now use stable library identities after deletes or reorders. Gameyfin downloads stage safely and stream to disk. +- Storefront and update failures now return readable errors instead of dropped connections or uncaught errors. +- AppImage desktop launches now work through Gear Lever and desktop menus, and the AppImage instructions show the correct `--native` command for the Tk interface. +- Artwork downloads now use the active LaunchBox CDN instead of the retired image URL that returns a redirect loop. + +## [0.4.10] - 2026-07-30 + +### Fixed + +- AppImage desktop launches via Gear Lever and similar integrators: stop leaking bundled `LD_LIBRARY_PATH` into host `xdg-open`/browsers, open the UI with a clean-env `xdg-open`, use a unique `io.openbox.GameLauncher` desktop/icon id, and let `openbox://` start fall through to a normal server boot when no instance is running + +### In case you missed it + +If you jumped from an older build and skipped the last two releases: + +- **0.4.8 — Steam Game Mode guest:** `--game-mode` opens Big Box fullscreen under gamescope on Steam Deck, Bazzite, and similar handheld images. Guest sessions are detected automatically, Big Box opens without a manual deeplink, and Steam keeps Input, QAM, and TDP while OpenBox tags its UI and non-Steam launches for Steam's overlay path. +- **0.4.9 — library reliability:** session playtime and Gameyfin installs update the correct library entry after deletes or reorders, Gameyfin downloads stage then replace and stream to disk, Lutris CLI failures return JSON errors, and empty release checksum files fail cleanly. + +## [0.4.9] - 2026-07-30 + +### Fixed + +- Session completion now credits playtime and history by stable game identity, so deleting another library entry while a game is running no longer updates the wrong title +- Gameyfin installs resolve library updates by Gameyfin ID instead of a stale array index, keep existing installs until a download succeeds, and stream downloads to disk instead of buffering whole files in memory +- Gameyfin provider lists ignore malformed non-object entries; empty passwords on connection tests no longer overwrite a stored credential for the probe +- Lutris import and storefront catalog routes return JSON errors when the Lutris CLI fails or times out, instead of dropping the HTTP connection +- Empty release checksum files raise a clear update error instead of an IndexError + +## [0.4.8] - 2026-07-30 + +### Added + +- Steam Game Mode guest support: `--game-mode` opens Big Box fullscreen under gamescope on Steam Deck, Bazzite, and similar handheld images, detects guest sessions, and best-effort sets `STEAM_GAME` window props on the OpenBox UI and non-Steam launches while leaving Steam Input, QAM, and TDP controls with Steam +- `parity_gamescope.py` module with portable gamescope detection, kiosk browser launching, and window tagging helpers +- Deck/Bazzite nested gamescope emulation harness (`scripts/emulate_deck_gamemode.sh`, `test_gamescope_deck_emu.py`) for verifying guest behavior on desktop hosts + +### Changed + +- Big Box mode now opens automatically when running under a gamescope session, no manual deeplink needed + +## [0.4.7] - 2026-07-28 + +### Fixed + +- AppImage desktop integration now starts the bundled application through `AppRun` +- GitHub update checks, plugin catalog downloads, AppImage zsync metadata, and repository links now use the canonical `vindeckyy/OpenBoxGL` location after the repository rename + +## [0.4.6] - 2026-07-26 + +### Fixed + +- Authenticated POST API requests now reject non-object JSON and request-shape type errors with JSON `400` responses instead of dropping the connection + +### Changed + +- Public identity, support, trademark, and notice documentation consistently distinguishes OpenBox Game Launcher from the Openbox window manager +- The repository default branch, CI triggers, issue links, and contributor documentation now use `master` + +### Tests + +- Added real-HTTP API boundary regressions for authorization, validation, exception mapping, partial settings updates, secret sanitization, and lifecycle errors + +## [0.4.5] - 2026-07-24 + +### Added + +- Filter presets, explorer facets, Big Box quick-switch presets, and import exclusions +- `openbox://` deep links, keyboard launcher support, granular library backups, and restore rotation +- Process tracking modes, optional IGDB metadata search, and YAML emulator definition packs + +### Fixed + +- Session restart handling, launcher menu formatting, ROM paths containing spaces, malformed tracking values, and unsafe backup archive paths +- Responsive and keyboard-accessible library controls, lazy media loading, and reduced-motion behavior + +## [0.4.4] - 2026-07-24 + +### Fixed + +- Settings persistence on partial saves, storefront-only POSTs, JSON error responses on catalog routes, Gameyfin install recovery, premium route auth, and session polling guards + +## [0.4.3] - 2026-07-24 + +### Fixed + +- Settings save merges omitted keys from existing state. Partial storefront POSTs no longer wipe watch folders or storefront auto-import flags. +- Storefront save posts only storefront fields instead of spreading empty Settings form values +- GET `/api/storefront/catalog` and `/api/gameyfin/providers` return JSON errors instead of dropping the connection +- Gameyfin install worker reports error state on invalid `library_id`; UI times out after ~60s instead of polling forever +- Premium GET routes require authorization; `/api/update` catches malformed release JSON +- Session poll no longer overlaps `refresh()`; `openReader` guards missing documents + +## [0.4.2] - 2026-07-24 + +### Fixed + +- Gameyfin installs run in a background worker; the UI polls `/api/gameyfin/install/status` instead of blocking the server +- Storefront Gameyfin settings use dedicated `storefront*` form fields; saving Settings no longer touches Gameyfin credentials + +## [0.4.1] - 2026-07-24 + +### Fixed + +- Update check no longer fails when a release omits a separate `.sha256` file; GitHub asset digests are used instead +- Settings update check returns readable errors instead of a browser network failure +- Top bar and library header scale cleanly at any browser zoom level + +## [0.4.0] - 2026-07-24 + +### Added + +- Gameyfin storefront integration: browse owned library, import, install/uninstall on demand in desktop and Big Box +- Library and Big Box filters for installed-only vs all owned games +- Ludusavi and Hoard save-tool hooks from the game detail pane (when installed on PATH) + +## [0.3.0] - 2026-07-24 + +### Added + +- LaunchBox Premium-equivalent features without a subscription: custom fields, ESRB metadata and filters, list view, platform categories, and bulk edit wizard +- Drag-and-drop import zone with multi-emulator install chooser and ROM version ranking +- Steam trailer and GOG media auto-download, RetroAchievements 7z scanning, and richer achievement stats +- Big Box hybrid scoped search, attract mode, startup video, bundled media packs, and controller prompt packs +- Localization for English, Spanish, German, French, and Portuguese +- Xbox 360, loose arcade, and Vita3K title resolution import helpers +- `parity_premium.py` module and premium API routes + +### Changed + +- Expanded PARITY.md to mark premium workflows as done and free +- AppImage, Makefile, Flatpak manifest, and packaging tests include `parity_premium.py` + +## [0.2.0] - 2026-07-24 + +### Added + +- Storefront Manager with catalog browse, uninstalled import, and startup auto-import +- Game Discovery Center with curated local discovery lists +- Big Box Stage, Hybrid, and CoverFlow layouts with gamepad mapping, pause overlay, and screensaver +- Parity modules for import, media hygiene, saves, integrations, and storefront workflows +- ScummVM, RPCS3, and Vita3K dedicated import endpoints +- RetroAchievements Big Box filters, pause access, and emulator launch injection +- EmuMovies and Bezel Project integration hooks +- OBS recording auto-attach on session close +- MAME community high score export and import +- Platform documents pane and sidebar section management +- Searchable settings, welcome wizard, and library health audit +- Expanded API and parity integration test coverage + +### Changed + +- Expanded LaunchBox parity matrix with Linux equivalents for premium workflows +- AppImage and Makefile packaging updated for new modules + +## [0.1.0] - 2026-07-23 + +### Added + +- Initial public release +- Web UI and native Tk UI +- Steam, Heroic, and Lutris import +- LaunchBox Games Database sync and media scraping +- Emulator profiles with Flathub auto-install +- RetroAchievements integration +- Save discovery and versioned backups +- Session tracking and plugin hooks +- AppImage, Flatpak manifest, and Makefile install targets + +[Unreleased]: https://github.com/vindeckyy/OpenBoxGL/compare/v1.19.1...HEAD +[1.19.0]: https://github.com/vindeckyy/OpenBoxGL/compare/v1.18.0...v1.19.0 +[1.18.0]: https://github.com/vindeckyy/OpenBoxGL/compare/v1.17.0...v1.18.0 +[1.17.0]: https://github.com/vindeckyy/OpenBoxGL/compare/v1.16.1...v1.17.0 +[1.16.1]: https://github.com/vindeckyy/OpenBoxGL/compare/v1.16.0...v1.16.1 +[1.16.0]: https://github.com/vindeckyy/OpenBoxGL/compare/v1.15.0...v1.16.0 +[1.15.0]: https://github.com/vindeckyy/OpenBoxGL/compare/v1.14.0...v1.15.0 +[1.14.0]: https://github.com/vindeckyy/OpenBoxGL/compare/v1.13.1...v1.14.0 +[1.13.1]: https://github.com/vindeckyy/OpenBoxGL/compare/v1.13.0...v1.13.1 +[1.13.0]: https://github.com/vindeckyy/OpenBoxGL/compare/v1.12.1...v1.13.0 +[1.12.1]: https://github.com/vindeckyy/OpenBoxGL/compare/v1.12.0...v1.12.1 +[1.12.0]: https://github.com/vindeckyy/OpenBoxGL/compare/v1.11.0...v1.12.0 +[1.11.0]: https://github.com/vindeckyy/OpenBoxGL/compare/v1.10.0...v1.11.0 +[1.10.0]: https://github.com/vindeckyy/OpenBoxGL/compare/v1.9.0...v1.10.0 +[1.9.0]: https://github.com/vindeckyy/OpenBoxGL/compare/v1.8.0...v1.9.0 +[1.8.0]: https://github.com/vindeckyy/OpenBoxGL/compare/v1.7.2...v1.8.0 +[1.7.2]: https://github.com/vindeckyy/OpenBoxGL/compare/v1.7.1...v1.7.2 +[1.7.1]: https://github.com/vindeckyy/OpenBoxGL/compare/v1.7.0...v1.7.1 +[1.7.0]: https://github.com/vindeckyy/OpenBoxGL/compare/v1.6.0...v1.7.0 +[1.6.0]: https://github.com/vindeckyy/OpenBoxGL/compare/v1.5.1...v1.6.0 +[1.5.1]: https://github.com/vindeckyy/OpenBoxGL/compare/v1.5.0...v1.5.1 +[1.5.0]: https://github.com/vindeckyy/OpenBoxGL/compare/v1.4.0...v1.5.0 +[1.4.0]: https://github.com/vindeckyy/OpenBoxGL/compare/v1.3.0...v1.4.0 +[1.3.0]: https://github.com/vindeckyy/OpenBoxGL/compare/v1.2.0...v1.3.0 +[1.2.0]: https://github.com/vindeckyy/OpenBoxGL/compare/v1.1.0...v1.2.0 +[1.1.0]: https://github.com/vindeckyy/OpenBoxGL/compare/v1.0.1...v1.1.0 +[1.0.1]: https://github.com/vindeckyy/OpenBoxGL/compare/v1.0.0...v1.0.1 +[1.0.0]: https://github.com/vindeckyy/OpenBoxGL/releases/tag/v1.0.0 +[0.9.0]: https://github.com/vindeckyy/OpenBoxGL/releases/tag/v0.9.0 +[0.8.2]: https://github.com/vindeckyy/OpenBoxGL/releases/tag/v0.8.2 +[0.8.1]: https://github.com/vindeckyy/OpenBoxGL/releases/tag/v0.8.1 +[0.8.0]: https://github.com/vindeckyy/OpenBoxGL/releases/tag/v0.8.0 +[0.7.0]: https://github.com/vindeckyy/OpenBoxGL/releases/tag/v0.7.0 +[0.6.0]: https://github.com/vindeckyy/OpenBoxGL/releases/tag/v0.6.0 +[0.5.0]: https://github.com/vindeckyy/OpenBoxGL/releases/tag/v0.5.0 +[0.4.10]: https://github.com/vindeckyy/OpenBoxGL/releases/tag/v0.4.10 +[0.4.9]: https://github.com/vindeckyy/OpenBoxGL/releases/tag/v0.4.9 +[0.4.8]: https://github.com/vindeckyy/OpenBoxGL/releases/tag/v0.4.8 +[0.4.7]: https://github.com/vindeckyy/OpenBoxGL/releases/tag/v0.4.7 +[0.4.6]: https://github.com/vindeckyy/OpenBoxGL/releases/tag/v0.4.6 +[0.4.5]: https://github.com/vindeckyy/OpenBoxGL/releases/tag/v0.4.5 +[0.4.4]: https://github.com/vindeckyy/OpenBoxGL/releases/tag/v0.4.4 +[0.4.3]: https://github.com/vindeckyy/OpenBoxGL/releases/tag/v0.4.3 +[0.4.2]: https://github.com/vindeckyy/OpenBoxGL/releases/tag/v0.4.2 +[0.4.1]: https://github.com/vindeckyy/OpenBoxGL/releases/tag/v0.4.1 +[0.4.0]: https://github.com/vindeckyy/OpenBoxGL/releases/tag/v0.4.0 +[0.3.0]: https://github.com/vindeckyy/OpenBoxGL/releases/tag/v0.3.0 +[0.2.0]: https://github.com/vindeckyy/OpenBoxGL/releases/tag/v0.2.0 +[0.1.0]: https://github.com/vindeckyy/OpenBoxGL/releases/tag/v0.1.0 diff --git a/CODE_OF_CONDUCT.md b/docs/CODE_OF_CONDUCT.md similarity index 100% rename from CODE_OF_CONDUCT.md rename to docs/CODE_OF_CONDUCT.md diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md new file mode 100644 index 00000000..4edc3f2e --- /dev/null +++ b/docs/CONTRIBUTING.md @@ -0,0 +1,224 @@ +# Contributing to OpenBox + +Thank you for your interest in contributing to OpenBox. This document describes the workflow we expect for issues, pull requests, and local development. + +## Code of conduct + +Participation in this project is governed by [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md). By contributing, you agree to uphold those standards. + +## Before you start + +1. Search [existing issues](https://github.com/vindeckyy/OpenBoxGL/issues) to avoid duplicate work. +2. For substantial changes, open an issue first so maintainers can confirm direction. +3. Keep pull requests focused. One logical change per PR is easier to review. + +## Development setup + +### Requirements + +- Linux, Windows, or macOS 11 or newer (all three are supported targets) +- Python 3.10 or newer +- Git + +### Clone and run + +```bash +git clone https://github.com/vindeckyy/OpenBoxGL.git +cd OpenBoxGL +python3 web_app.py +``` + +On Windows use `python web_app.py`; `python3` there is a Microsoft Store alias that does not run scripts. On macOS, install a current Python (Homebrew: `brew install python@3.12`); the system `python3` is older than the 3.10 floor. Build the native window with `scripts/build_native_host_macos.sh`, or the whole app bundle with `scripts/build_macos_app.sh`. + +Optional local configuration can be loaded from an explicit `OPENBOX_ENV_FILE`, the data directory (or its parent), `~/.env`, or `~/.config/openbox-game-launcher/.env`. See `.env.example`. Never commit secrets, tokens, or personal credentials. + +### Native window + +```bash +python3 web_app.py --no-browser # loopback server (native host spawns this) +``` + +Build the native host with `make native-host`, then run `./openbox-native.sh`. + +On Windows build the WebView2 host with `scripts\build_native_host_windows.ps1` (needs the MSVC build tools, and `cmake` on the PATH), then run `.\openbox-native.ps1`. + +Any platform-dependent behavior belongs in `pkg/platform_compat.py` — the single seam for locking, process control, command quoting, path openers, and data-dir resolution (ADR 0048). Windows-only branches there are marked `# pragma: no cover` and are exercised by the `windows-latest` CI job. + +## Testing + +Run the full verification gate before submitting a pull request. This covers ruff lint, runtime-module drift, the frozen v1 contract, version sync, frontend lint, i18n key coverage (including the extra-keys rule), the CSP framing contract (`scripts/check_csp.py`), compile checks, the full test suite under coverage, coverage floors (83.0% total, 58.0% `web_app.py`, 95% changed-line, 85% new-module), and design-token hygiene. CI also enforces the same 95% changed-line floor (`check_changed_coverage.py --fail-under=95` in `ci.yml`): + +```bash +make check +``` + +The gate needs dev-only tooling (ruff and coverage) in `.venv-dev`, which `make check` creates on first run. The runtime app itself stays dependency-free. + +Run the plain test suite without coverage when iterating: + +```bash +./run_all_tests.sh +``` + +Run an individual module when iterating: + +```bash +make test-one TEST=tests/test_catalog.py +``` + +On Windows (no `make`, no bash) run the suite through the cross-platform runner, which reports `pass`/`fail`/`timeout` per file and exits non-zero on any failure: + +```powershell +python scripts\run_windows_tests.py +python scripts\run_windows_tests.py test_platform_compat.py +``` + +`make check` and `./run_all_tests.sh` stay Linux-only (coverage flooring, ruff, and the frontend/i18n/POSIX-packaging gates need the dev tooling and bash); `ci.yml` runs the same portable gates on `windows-latest`. A change that touches platform behavior must keep both jobs green. + +Check that the version in `updates.py` matches every published spot: + +```bash +make version-check +``` + +Packaging checks: + +```bash +./build_appimage.sh +python3 -B tests/test_packaging.py +``` + +All tests must pass on CI before a PR can be merged. + +## Coding guidelines + +- Match the style of surrounding code in each module. +- Prefer focused changes over broad refactors unless the refactor is the purpose of the PR. +- Keep user-facing strings clear and neutral. +- Do not commit ROMs, BIOS images, API tokens, or personal library data. +- Document non-obvious behavior with brief comments only where needed. + +### Python + +- Target Python 3.10+ syntax and standard library usage already present in the codebase. +- Avoid adding dependencies unless they are required and approved in the PR discussion. +- Use explicit error messages for user-facing validation failures. +- Keep the lint gate green: `.venv-dev/bin/ruff check .` must pass. The gate rule set lives in `pyproject.toml`. +- Any PR that changes `web_app.py` must add or update a test that exercises the touched route. +- Do not lower the coverage floors in `scripts/check_tests.py`; raise them when coverage improves. + +### Web UI (`index.html`) + +- Preserve existing UI patterns and accessibility conventions. +- Test dialog flows, Big Box navigation, and settings persistence manually when touching frontend logic. + +## Release process + +OpenBox follows **artifact-first** gates (ADR 0013). Stable publication requires +the exact tagged artifacts and their release workflow checks to be green, plus +maintainer review of the corresponding master CI/CodeQL results. The tag +workflows build and publish after the protected `release` environment approves; +they do not automatically wait for an unrelated master run. + +### Repository and release authority + +- Git tags and [GitHub Releases](https://github.com/vindeckyy/OpenBoxGL/releases) on this repository are the canonical publication channel for the AppImages, Flatpak bundle, Windows portable zip, macOS app bundle, SBOMs, and install scripts. +- Version strings are declared once in `updates.py`; `make version-check` (`scripts/check_version_sync.py`) must pass before tagging. +- Changelog, `openbox.metainfo.xml`, README release badge/latest/installer text, + PARITY lead, release-note compare link, SBOM fallback metadata, and issue + templates must agree with `updates.py`; the version check also covers SUPPORT, + SECURITY, and Flathub checklist markers. Review those documents for + release-specific validation claims as well. + +### RC soak (48–72 hours) + +Before a **stable** tag: + +1. Publish a prerelease artifact from CI (x86_64 and aarch64 AppImages plus the x86_64 Flatpak bundle). +2. Soak **48–72 hours** on supported distros. +3. Run Setup Center, Activity, Launch Doctor, and launch flows on the binary—not just from source. +4. Confirm `make check`, `tests/test_packaging.py`, and `./scripts/ui_smoke.sh` on the release commit. +5. Push an annotated `vX.Y.Z` tag only after the master CI and CodeQL runs for + that commit are green; the AppImage and Flatpak tag workflows then serialize + publication into the same GitHub Release. + +See [ADR 0013 (Artifact Gates & Release Criteria)](adr/0013-artifact-gates.md) for the full RC and release gate requirements. + +### No telemetry + +OpenBox does not add telemetry. Diagnostics (`/api/diagnostic`), artifacts, and Flatpak `finish-args` must not upload usage data. Do not introduce phone-home, analytics SDKs, or crash reporters that exfiltrate library contents. + +## Pull request process + +1. Fork the repository and create a feature branch from `master`. +2. Make your changes with clear commits. +3. Run `make check` (or `./run_all_tests.sh` when coverage is not available locally). +4. Open a pull request using the provided template. +5. Describe the problem, the solution, and the test plan. +6. Address review feedback promptly. + +### Commit messages + +Use concise, descriptive commit messages in the imperative mood: + +``` +Add storefront startup auto-import setting +Fix emulator dependency check for missing flatpak +docs: update parity matrix for OBS attach workflow +``` + +## Plugins + +The canonical plugin reference is `docs/plugin-api.md` (frozen v1 surface, +ADR 0050; Plugins 2.0 additions in 1.14.0). Summary of the essentials: + +- Plugins are Python packages under `/plugins//` with a + `plugin.json` manifest (`id`, `name`, `version` required; `entry` defaults + to `plugin.py`, `hooks` defaults to `[]`, `api_version` defaults to `1`). +- Six hooks: `before_launch`, `after_session`, `library`, `command`, + `library_source` (importer), `events` (lifecycle). Each hook is a function + named after the hook that reads one JSON object on stdin and returns a + JSON object on stdout. +- Sandboxed with bubblewrap (no network, no home dir, 5 s timeout, 2 MiB + payload cap). On hosts without bubblewrap, plugins run only after + per-plugin trust ("Trust and run", checksum-bound, ADR 0056); the legacy + `OPENBOX_ALLOW_UNSANDBOXED_PLUGINS=1` operator override is unchanged. +- A manifest may declare `permissions` (currently only `network`, granted + via an Android-style prompt), `settings` (JSON Schema subset rendered as + a form and injected into stdin payloads), and up to 32 `commands` for the + command palette. +- Bundled catalog entries live in `plugins/catalog.json` and + `plugin_catalog.py`. + +## Documentation + +Update relevant docs when behavior changes: + +- [README.md](../README.md) for user-facing overview changes +- [PARITY.md](PARITY.md) for capability status changes +- [CHANGELOG.md](CHANGELOG.md) for release-visible changes +- [openbox.metainfo.xml](../openbox.metainfo.xml) and [SECURITY.md](SECURITY.md) when releases or support policy change + +Parity-related modules live in `pkg/parity/` (e.g. `parity_import.py`, `parity_premium.py`, `parity_storefront.py`, `parity_discovery.py`, `parity_media.py`, `parity_saves.py`, `parity_integrations.py`, `parity_gamescope.py`, `parity_wine.py`, `parity_faugus.py`). Flat `import parity_*` keeps working through the `_ParityFlatFinder` bridge in `pkg/parity/__init__.py` (registered by `openbox.py`); there are no root shim files since 1.9.0 (ADR 0003). + +## Documentation site + +The public documentation is published at [openboxgl.github.io](https://openboxgl.github.io/). Site source and content changes belong in the [OpenBoxGL Pages repository](https://github.com/OpenBoxGL/openboxgl.github.io); run `bun install --frozen-lockfile` and `bun run test` there before pushing. + +## Security issues + +Do not open public issues for security vulnerabilities. Follow [SECURITY.md](SECURITY.md). + +## Developer Certificate of Origin + +This project uses the Developer Certificate of Origin. By contributing you certify that you have the right to submit the work under the open source license and that you agree to the DCO. Include a `Signed-off-by` line in every commit: + +``` +Signed-off-by: Your Name +``` + +Use `git commit -s` to add it automatically. A contribution that includes third-party assets must state the source, the applicable license, and that you have the right to submit it. See [DISCLAIMER.md](DISCLAIMER.md) and [TRADEMARKS.md](TRADEMARKS.md) for content and trademark boundaries. + +## Licensing + +By contributing, you agree that your contributions will be licensed under the [GNU Affero General Public License v3.0](../LICENSE) and that you have the right to license them as such. diff --git a/DISCLAIMER.md b/docs/DISCLAIMER.md similarity index 70% rename from DISCLAIMER.md rename to docs/DISCLAIMER.md index 0214b4f1..ba86ae7b 100644 --- a/DISCLAIMER.md +++ b/docs/DISCLAIMER.md @@ -30,11 +30,13 @@ References to third-party products, platforms, services, and compatibility targe ## 3. Content and anti-piracy policy -OpenBox does not distribute: +OpenBox does not distribute, host, or bundle: -1. **Bundled games or ROMs**: The repository and release artifacts contain no game files, ROMs, ISOs, or game binaries. -2. **System firmware or BIOS**: The repository and release artifacts contain no proprietary console BIOS images, system firmware, or decryption keys. -3. **DRM circumvention**: OpenBox does not bypass, tamper with, or circumvent digital rights management. It launches software through user-configured launchers and emulators. +1. **Bundled games or ROMs**: The repository and release artifacts contain no game files, ROMs, ISOs, or game binaries. No game files, ROM images, BIOS sets, or cover-art packs are vendored in the repository. Documentation screenshots are generated by this project from LaunchBox Games Database metadata and cover art with `scripts/capture_readme_screenshots.py`; they are not user-supplied captures, and they show titles and artwork that LaunchBox publishes for its own database. +2. **System firmware or BIOS**: The repository and release artifacts contain no proprietary console BIOS images, system firmware, or decryption keys. Import and save discovery merely read paths the user configured. Missing BIOS is reported as a dependency check, not silently fetched. +3. **DRM circumvention**: OpenBox does not bypass, tamper with, or circumvent digital rights management, encryption, or access controls. It launches software through user-configured launchers and emulators with tokenized commands that do not perform shell interpolation. User supplied files remain on the user's machine. + +Contributions that add an import path, emulator profile, or sample must not include a copyrighted file, key, or bypass. See [TRADEMARKS.md](TRADEMARKS.md) and the Developer Certificate of Origin in [CONTRIBUTING.md](CONTRIBUTING.md). --- @@ -45,15 +47,16 @@ OpenBox does not distribute: --- -## 5. Formal notices +## 5. Formal notices and designated agent -Do not post legal notices or private contact information in a public GitHub issue. +Do not post legal notices, takedown requests, or private contact information in a public GitHub issue, discussion, or pull request. GitHub is the host and the designated agent for content stored on GitHub. -- For copyright complaints about content hosted on GitHub, use GitHub's [copyright claims form](https://github.com/contact/dmca) and follow GitHub's published process. +- For copyright complaints about content hosted on GitHub, use GitHub's [copyright claims form](https://github.com/contact/dmca) and follow GitHub's published [DMCA process](https://docs.github.com/en/site-policy/content-removal-policies/dmca-takedown-policy). The project has no separate DMCA agent for GitHub hosted content. - For trademark complaints about misleading use of a name, logo, or brand on GitHub, use the [GitHub Trademark Policy](https://docs.github.com/en/site-policy/content-removal-policies/github-trademark-policy). - For security vulnerabilities, use the repository's [private security advisory form](https://github.com/vindeckyy/OpenBoxGL/security/advisories/new) as described in [SECURITY.md](SECURITY.md). +- For documentation site concerns, use the same GitHub forms for the Pages repository, or open an issue that describes the page without pasting private data. -The project cannot determine the legal validity of a complaint. Formal notices should use the applicable GitHub process or qualified legal counsel. +The project cannot determine the legal validity of a complaint. Formal notices should use the applicable GitHub process or qualified legal counsel. See also the site policy at [openboxgl.github.io/policies/dmca/](https://openboxgl.github.io/policies/dmca/) for the documentation site. --- diff --git a/docs/PARITY.md b/docs/PARITY.md new file mode 100644 index 00000000..c8ec6b3e --- /dev/null +++ b/docs/PARITY.md @@ -0,0 +1,286 @@ +# OpenBox Parity Matrix + +OpenBox tracks LaunchBox feature parity for Linux, Windows, and macOS environments. For contribution and release workflow, see [CONTRIBUTING.md](CONTRIBUTING.md) and [CHANGELOG.md](CHANGELOG.md). The latest release is **v2.0.0**, the release that turns the 1.x compatibility scaffolding into stable contracts: one server-computed capability contract now decides which platform-specific controls each host shows, so a stored gamescope preset no longer breaks Windows and macOS launches, Windows reports its real battery, Wine/Faugus/Flatpak management answers only on Linux, and storefront browsing uses each platform's own URL opener. The previous line was **v1.19.0** ("Now on macOS"), 1.18.0 was "Clearer, deeper library", 1.17.0 was "Every Launcher", and 1.16.0/1.16.1 were "Make it true" and its fix release. + +> **Legal disclaimer:** OpenBox is an independent open-source project and is NOT affiliated, associated, authorized, endorsed by, or in any way officially connected with LaunchBox or Unbroken Software, LLC. Reference to LaunchBox features is solely for software compatibility tracking and open-source parity comparison. + +Acceptance source: [LaunchBox product overview](https://www.launchbox-app.com/about), [Windows changelog](https://www.launchbox-app.com/about/changelog), and [plugin overview](https://feedback.launchbox-app.com/help/articles/1605395-plugins-overview). + +`done` means the workflow is usable end to end on Linux. `partial` means a deliberate Linux equivalent exists but LaunchBox’s Windows/premium surface is broader or unavailable. `missing` means no usable equivalent yet. + +> **Platform note:** since [ADR 0048](adr/0048-windows-port.md) OpenBox also runs on Windows (signed portable install, WebView2 window), and since [ADR 0085](adr/0085-macos-support.md) on macOS 11+ (signed universal app bundle, WKWebView window). The rows below describe **Linux** behavior; the Windows and macOS channels have their own sections below. Rows that name Linux-only machinery — AppImage/Flatpak packaging, gamescope/Game Mode, XDG desktop entries, Flathub emulator management — remain Linux-only and are marked so in the Windows and macOS sections. Since 1.19.1 one server-computed capability contract ([ADR 0086](adr/0086-platform-capability-contract.md)) decides which of those controls each host shows, so a control cannot appear on a platform that cannot run it. + +## 2.0 platform changes + +These are the capabilities 2.0 adds on top of the matrix below. They are listed here because +they change what a user can do on every platform, not because LaunchBox has them. + +| Capability | Linux | Windows | macOS | Notes | +|---|---|---|---|---| +| Portable library | done | done | done | Game paths under one folder are stored as tokens, so the folder can move between drives and machines (ADR 0090) | +| Library format 7 with rollback | done | done | done | The library upgrades on first start, keeps a byte-identical copy of the old file, and rolls back on request (ADR 0089) | +| Integrity check and media manifest | done | done | done | Missing, unreadable, and outside-folder paths, plus media changed behind the app's back (ADR 0090) | +| Compatibility report | done | done | done | Themes, backups, settings keys, plugins, API, and sync folder, each with a disposition | +| Versioned public API and host channel | done | done | done | `/api/v2` for clients, `/api/host` for the native window, with an OpenAPI document (ADR 0091) | +| Plugin API v2 | done | done | done | Versioned manifests, the JSON Lines runner, and versioned read-only host services (ADR 0092) | +| Backup format 2 | done | done | done | Per-file SHA-256 manifest, a per-member restore report, and a 1.19-compatible export (ADR 0093) | +| macOS notarization | n/a | n/a | conditional | A notarized bundle ships when the Developer ID secrets are available; otherwise the bundle stays ad-hoc (ADR 0094) | +| Windows ARM64 artifact | n/a | conditional | n/a | Ships after the `windows-11-arm` job builds and launches it; until then the installer refuses ARM64 (ADR 0095) | +| Flatpak submission | conditional | n/a | n/a | Submission-ready; the submission itself is a maintainer decision (ADR 0013) | + +## Capability matrix + +| Capability | Status | Acceptance check | +|---|---|---| +| Local library, metadata editing, search, filters, favorites | done | Changes persist and remain searchable | +| Platform and collection navigation | done | Counts and filters update from real games | +| Local executable and ROM import | done | Multi-disc M3U generation, multi-platform folder import, and emulator recommendations on import work | +| Steam installed-game import | done | Manifests are discovered across Steam libraries | +| Steam metadata and artwork | done | Selected game downloads real store data and media | +| SteamGridDB artwork provider | done | Optional `STEAMGRIDDB_API_KEY` provider searches, previews, applies, and bulk-matches cover, background, clear-logo, icon, and banner artwork with a local cache (1.11.0) | +| Steam launching | done | Imported App ID launches through Steam or its URI | +| Epic, GOG, and Amazon imports through Heroic | done | Installed manifests import and launch through Heroic | +| EA, Ubisoft, and Xbox imports | partial | Windows imports installed GOG, EA app, and Ubisoft Connect games natively; on Linux, Lutris/Heroic catalog import provides EA, Ubisoft, and Xbox/Game Pass tagging. Native Xbox PC package scanning remains unavailable on Linux (1.17.0) | +| MAME and FinalBurn full-set imports | done | DAT/XML metadata classifies and imports merged, split, and non-merged sets | +| LaunchBox Games Database matching | done | Official daily database sync, local matching, and selected metadata/media downloads work | +| Full LaunchBox media catalog | done | Box backs, spines, 3D boxes, clear logos, fanart, banners, title screens, carts, discs, and advertisement flyers download from the database alongside covers, backgrounds, and screenshots; manuals are user-supplied paths because the LaunchBox metadata feed ships no manual images | +| Media manager and image groups | done | Grid image groups, extended artwork groups, audits, bulk downloads, duplicate cleanup, region priority, and download limits work | +| Media automation queue | done | Auto-import enqueues poster, square, screenshot, video, music, and manual downloads into a durable queue with pause/resume/cancel/retry, per-item errors, global and per-group limits, provider priority, and LaunchBox media content validation (1.18) | +| Emulator profiles and per-game commands | done | Commands launch without a shell | +| Emulator installation and automatic configuration | done | Install, Update All, open emulator, recommend-on-import, and dependency checks work | +| Emulator settings and versions | done | Manage Emulators displays installed versions and update availability, supports an opt-out, and edits managed/global/per-platform settings; MAME and RetroArch settings render launch arguments and `--appendconfig` (1.18) | +| Archive extraction before launch | done | ZIP uses safe built-in extraction; 7z/RAR use installed 7z | +| Startup and shutdown screens | done | Screens follow the launched process from start through its actual exit | +| Play counts, time, and session history | done | Sessions persist with duration and exit status | +| Additional apps, versions, and documents | done | Extras launch from each game's detail pane | +| Manuals and reader | done | Reader toolbar supports page navigation, spread layout, light/dark themes, persisted appearance, remappable keys/controller buttons, and a system-viewer fallback | +| Save management | done | Discover, scan, retention limits, versioned backups, backup-on-close, hashed manifests, previewed selective restore, and an enable/disable toggle work | +| RetroAchievements | done | Optional account integration provides matching, progress, badges, Big Box filters, pause access, and emulator injection; it is separate from OpenBox launcher trophies | +| OpenBox launcher trophies | done | Deterministic local trophy rules evaluate library and history, persist award dates, and render the trophy case (1.11.0) | +| Video, music, and screenshot playback | done | Multi-category videos, library BGM, video/BGM mix, capture, and gallery work | +| Playlists, auto-filters, and saved filters | done | Platform, view, search rules, ordered manual members, parent playlists, and notes save, apply, update, and delete | +| Big Box controller-first navigation | done | Stage, hybrid, and CoverFlow layouts; filter/sort/RA filters; pause overlay; screensaver launch | +| Steam Game Mode / gamescope guest | done | `--game-mode` opens Big Box; guest detection; Steam launches keep Input; non-Steam windows get best-effort STEAM_GAME props | +| Gamescope presets | done | 8 deck/display profiles (Steam Deck, HD, 1080p, 1440p, 4K, integer, stretch, borderless) selectable from Settings → Controller (1.7.2) | +| MangoHud performance overlay | done | Optional on-screen performance overlay via `MANGOHUD=1` env on launch; toggle in Settings → Controller (1.7.2) | +| Themes and per-platform themes | done | Six stock CSS themes ship with the Web UI (five styled themes plus a WCAG-AAA High Contrast theme that proves the token contract); import, persist, apply live, and open-folder access work | +| Plugin manager and extension API | done | Local packages install and run hooks with checksum-bound trust, sandboxing, per-plugin settings, capability manifests, data/credential stores, storefront/metadata/media/stats/emulator/discovery providers, localized notifications, and bounded host services; curated community catalog is bundled (1.18) | +| Backups and restore | done | The web UI lists archives, shows manifests, restores selected archives, and creates a pre-restore safety copy. Backup diff API (`GET /api/v2/backup/diff`) compares current library against archives (1.7.2) | +| Library audit and missing-file checks | done | Files, provider-aware duplicates, media, extras, saves, and emulator configuration are audited | +| Linux packaging and updates | done | AppImage build, Flatpak manifest, Makefile install/uninstall, desktop entry, and verified update mechanism | +| Welcome wizard and first-run setup | done | Setup Center stepper with a six-step quick path and optional advanced steps, preview-before-commit imports, emulator readiness, metadata match review, and Launch check batch preflight | +| Durable background operations | done | Activity drawer with SSE progress, cancellation, retry/resume, and `operations.json` persistence | +| Launch readiness preflight | done | Launch Doctor validates paths, adapters, Flatpak/native executables, BIOS/firmware, and tokenized arguments before launch. BIOS SHA1 drift detection reports `BIOS_SHA1_DRIFT` when a BIOS file exists but its hash doesn't match the expected value (1.7.2) | +| Play Insights local analytics | done | Local 366-day playtime heatmap (levels 0–4), streaks, momentum, top platforms/genres with zero telemetry | +| Optional SQLite read model | done | `OPENBOX_ENABLE_SQLITE_READ=1` enables FTS5 search, indexed queries, and GROUP BY facets for libraries beyond the JSON path; the read model also self-enables at 5,000+ games unless explicitly opted out (1.12.0, ADR 0047). JSON remains source of truth. The formal release gates cover 10k/20k libraries, so performance above 20k is exploratory rather than release-gated (ADR 0032) | +| Visual chip builder for collections | done | Filter presets render as visual chips via `rules_to_chips`/`chips_to_rules` round-trip conversion (1.7.2) | +| Searchable settings pages | done | Settings dialog filters fields by name and related terms | +| Session history toggle and viewer | done | Play sessions can be disabled and are browsable from the History menu | +| Arrange-by scrollbar | done | Large sorted views expose a jump bar with group markers | +| Delete media on game removal | done | Removing a game can optionally delete its associated media files | +| Save backup on session close | done | Automatic save backups can run when a game session ends | +| Game progress automation | done | Playing and paused progress values can be updated from play time and idle days | +| Open emulator and install all | done | Installed emulators can be launched directly and bulk-installed from Flathub | +| Shutdown progress and force close | done | Exiting with running games shows shutdown status and supports force close | +| Ctrl+, settings shortcut | done | Opens Settings from the keyboard | +| Platform documents | done | Platform detail pane and document storage API work per platform | +| Sidebar filter section management | done | Hidden sidebar sections apply live from Settings | +| CoverFlow / 3D box models | done | CoverFlow layout plus CSS 3D box, jewel, long-jewel, double-jewel, and cassette models with rotation lock, per-platform cassette back/case style, and per-game/per-platform selection; the selected CoverFlow card renders the model | +| Game Discovery Center | done | Curated local discovery lists launch from the Discovery menu | +| EmuMovies / Bezel Project | done | Bezel downloads and EmuMovies credential/download work with licensed accounts; EmuMovies is also a queue provider for covers, box backs/spines/3D boxes, screenshots, title screens, marquees, cart fronts/backs, discs, clear logos, theme and snap videos, manuals, and music | +| Storefront Manager / uninstalled auto-import | done | Unified storefront dialog, catalog browse, uninstalled import, per-source platform/only-new options, and startup auto-import with per-source progress and a 30-second budget work; plugin storefronts share the same flow | +| Gameyfin self-hosted library | done | Catalog import, background install with status polling, install/uninstall on demand, desktop + Big Box owned/installed filters | +| Ludusavi / Hoard save tools | done | Optional CLI hooks from game detail when binaries are on PATH | +| ScummVM / RPCS3 / Vita3K library import | done | Dedicated import endpoints scan common emulator libraries; RPCS3 also reads external folders and ISOs from `games.yml` and `config.yml` | +| PICO-8, MSU-1, and WHDLoad imports | done | Dedicated source buttons and definitions import PICO-8 carts, MSU-1 patches, and Amiga WHDLoad folders (1.18) | +| MAME community high scores | done | Local high-score discovery parses MAME tables, renders a searchable table, and exports/imports sharing bundles | +| Steam achievements and playtime | done | Optional Steam Web API credentials import per-app achievements and playtime; provider tabs merge plugin/built-in achievement sources, and playtime/last-played merges never lower local values (1.18) | +| RetroArch netplay browser | done | Tools > Netplay lobby and the Big Box menu list RetroArch's public lobby through a cached backend proxy; no account is required (1.18) | +| OBS recording attach | done | Latest OBS recording auto-attaches on session close; manual attach remains available | +| Local-first library synchronization | done | Mounted-folder statistics sync remains available; opt-in causal catalog sync adds validated events, conflict review, tombstones, and recovery while unsafe legacy routes fail closed before mutation. No vendor cloud account is involved (ADRs 0038–0039) | +| Custom fields and bulk metadata wizard | done | Define custom fields in Settings, edit per game, and bulk update selected games | +| ESRB ratings filter and metadata | done | ESRB from LaunchBox database imports, sidebar filter, list view column, and bulk edit | +| List view and library columns | done | Grid/list toggle with sortable list columns including ESRB and progress | +| Drag-and-drop import wizard | done | Drop zone prompts for folder path with multi-emulator install chooser | +| Platform categories | done | Sidebar category navigation groups platforms by family | +| ROM version ranking on import | done | Duplicate ROM groups rank USA/world releases and skip betas on import | +| Steam trailer and GOG media download | done | Detail pane downloads Steam trailers and Heroic GOG artwork automatically | +| RetroAchievements 7z scanning and rich profile | done | ZIP and 7z ROM hashing, M3U/CHD/RVZ/WiiWare WAD matching, plus beaten/mastered stats in the achievement pane | +| Big Box hybrid scoped search | done | Hybrid mode exposes platform-scoped search while browsing | +| Attract mode and startup video | done | Separate attract delay, optional Big Box startup video, and screensaver launch | +| Bundled media packs (free) | done | Platform logos, controller prompts, and badge packs apply without a subscription | +| Localization | done | Locale catalogs and translated `data-i18n`/`t()` surfaces cover English, Spanish, German, French, and Portuguese; some operational/browser fallback labels remain English | +| Big Box shutdown apps on mode switch | done | Configurable commands run when leaving Big Box or shutting down, not when entering | +| Xbox 360 and loose arcade import | done | default.xex folder scan and Hypseus/Singe loose file import | +| Vita3K title resolution | done | Title IDs resolve to readable game names on import | +| Advanced search syntax | done | Field terms such as `platform:PC`, quoted values, status terms, and negative terms filter the live grid | +| Context actions and multi-selection | done | Right-click actions plus Ctrl/Shift range selection expose launch, favorite, progress, playlist, edit, and remove actions | +| Configurable status badges | done | Settings controls favorite, install, media, save, progress, storefront, achievement, rating, and hardware badges | +| Collection and related details | done | Platform, category, and playlist detail panes show stats, quick actions, rich related-game reasons, and artwork galleries | +| Per-game launch profile overrides | done | A game can select an installed emulator profile without changing the platform default | +| Persistent play queue | done | Queue controls add, remove, advance, reorder, and resolve missing games | +| Game tags and tag filtering | done | Tags normalize, count, filter, and bulk-edit through the Web UI | +| Notification Center | done | Persistent deduplicated notifications expose unread state and read/clear controls | +| Signed webhook automation | done | Event subscriptions, HTTPS validation, bounded retries, test delivery, and secret redaction work locally | +| Edit Game modal next/previous navigation | done | Previous and Next navigation controls in Game Edit modal rapidly cycle through active filtered library | +| Platform-scoped media cleanup | done | Scoped duplicate media detection and cleanup for single platforms or entire library | +| Reset play statistics | done | Context menu and bulk edit actions reset play count, play time, and last played date | +| Smart capability playlist rules | done | Filter presets and playlists filter by save files, RetroAchievements, and missing media status | +| Desktop random game shortcut | done | Ctrl+Alt+Q and Ctrl+Alt+R select and focus a random game in the desktop grid or list | +| Acronym search matching | done | Search queries match game title initials and acronyms (e.g. oot, mgs, sotn, ff) | +| Dynamic launch variables | done | Template expansion for {ImagePath}, {dir}, {file}, {stem}, {Platform}, {EmulatorDir}, and {DataDir} | +| Window resolution CLI options | done | --fullscreen-width, --fullscreen-height, and --resolution flags control viewport sizing | +| Proton and Wine prefix manager | done | Discover Proton runtimes, custom wine prefixes, and manage Windows runner profiles | +| Faugus launcher scan and import | done | Discover UMU configs and wine prefixes, and import Faugus games | +| Eden Nintendo Switch emulator | done | YAML emulator definition and profile support for Eden Switch emulator | +| Canonical identity deduplication | done | Cross-storefront canonical identification and deduplication | +| Keyboard and gamepad library navigation | done | Grid and list navigation with arrows/Home/End/Page, favorite, clear, and a configurable controller map (ADR 0021) | +| Hash routing | done | Refresh and shared links restore platform/playlist/preset/query/selection/sort (ADR 0021) | +| ScreenScraper per-ROM-hash scraping | done | Metadata and media scraping with credentials in ~/.env, 1 req/s throttle, 30-day cache (ADR 0022) | +| Custom gamescope presets | done | User-defined presets with per-game override, completing ADR 0016 | +| Library export | done | JSON/CSV export with platform/playlist scopes and automatic rotation (ADR 0023) | +| ARM64 (aarch64) AppImage | done | Release-gated aarch64 artifact alongside x86_64 with architecture-aware self-update (ADR 0024) | +| Adaptive cover theming (Mood Match) | done | Selecting a game tints accents from its cover palette via --mood-* tokens; off by default (1.9.0, ADR 0026) | +| Play assistant ("What should I play?") | done | Scored recommendations by time, mood, familiarity, and players via POST /api/v2/library/pick (1.9.0, ADR 0028) | +| Library relationship graph | done | Pan/zoomable Constellation canvas of series/developer/genre/co-play edges via GET /api/v2/library/constellation (1.9.0, ADR 0027) | +| Year-in-review report and session timeline | done | Printable Wrapped aggregates via GET /api/v2/insights/wrapped plus a History Timeline tab (1.9.0, ADR 0029) | +| Mastery completionist dashboard | done | Per-platform/decade progress bars with RetroAchievements cache columns via GET /api/v2/insights/mastery (1.9.0, ADR 0030) | +| Big Box party mode (Game Night) | done | Couch-multiplayer queue builder, spinning wheel, up-next strip, gamepad/keyboard control, persistent rounds (1.9.0, ADR 0031) | +| LaunchBox XML library migration | done | `POST /api/v2/import/launchbox/preview` and `/apply` parse LaunchBox XML exports with dedup and emulator reporting (1.9.0, ADR 0033) | +| Big Box video snaps | done | Stage mode shows looping gameplay videos with debounce, BGM duck, reduced-motion support (1.9.0, ADR 0034) | +| Full library catalog sync | done | Opt-in causal catalog transport records validated events, tombstones, outbox acknowledgements, recovery snapshots, and independently selectable conflicts; legacy routes fail closed before mutation. Launch paths, commands, credentials, and media remain local (ADRs 0038–0039) | +| Manual/shelf entries | done | Create, edit, filter, export, and explicit conversion for games without local files through the `/api/v2/library/manual-entry*` routes (1.10.0, ADR 0036) | +| Quick Resume and Moments | done | Resume-aware progress, session recaps, captures, and a per-game Moments timeline keep a player's place visible (1.11.0) | +| Library Time Machine | done | Journal-backed event history supports bounded timeline, as-of inspection, and safe revert previews (1.11.0, ADR 0044) | +| Backlog Radio and natural query | done | Habit-aware backlog recommendations, explainable query chips, and the Ctrl/Cmd-K command palette are local and deterministic (1.11.0, ADR 0043, ADR 0045) | +| Arcade Room and Museum mode | done | Controller-friendly platform room, museum facts, reduced-motion rendering, and optional local kiosk convenience boundary (1.11.0) | +| Household challenges and leaderboard | partial | Opt-in local records converge through the sync folder; no accounts or hosted multiplayer service (1.11.0) | +| Record That clips and reels | done | Replay-buffer or fail-safe screenshot capture plus bounded, deterministic highlight reels (1.11.0) | +| Steam Bridge shortcuts | done | Preview/apply/remove `shortcuts.vdf` entries and launch an OpenBox game with `openbox --play ` (1.11.0) | +| ES-DE gamelist import | done | Reviewable `gamelist.xml` preview/apply with stale-plan and source-digest protection (1.11.0) | +| Smart collections | done | Named Backlog Radio queries persist as live sidebar shelves evaluated at read time via `GET/POST /api/v2/collections` and `POST /api/v2/collections/delete` (1.12.0, ADR 0047) | +| Per-game Game Story | done | Detail-pane Story tab narrates added/first-played/milestone/moment history via `GET /api/v2/story` (1.12.0, ADR 0047) | +| Per-game launch environment and confirm | done | `launch_env` KEY=value overrides merge over the launch environment and `launch_confirm` asks before preflight, per game (1.12.0, ADR 0047) | +| Scheduled library backups | done | Opt-in weekly automatic backups with retention and a last-run line via `auto_backup_due()` (1.12.0, ADR 0047) | +| Library health score | done | A 0–100 score across five weighted dimensions (file integrity, duplicates, artwork, metadata, launch readiness) with a "Fix all" queue (dry-run preview first, every fix undoable), a Big Box health tile, and a scheduled rescan (1.14.0, ADR 0053) | +| Artwork Doctor | done | Bulk SteamGridDB artwork hygiene report with safe fix/undo helpers via `parity_artwork_hygiene.py` (1.14.0) | +| Missing-file repair wizard | done | Missing-path reports become fixable: relink games and media after a move, with candidate matching and review before apply (1.14.0, ADR 0051) | +| Duplicate game-record merge | done | Duplicate detection with reviewable merge plans that consolidate duplicate game records (1.14.0, ADR 0051) | +| Game DNA offline search | done | Title \| Smart toggle on the search box; Smart mode runs BM25 plus a curated 151-concept lexicon (English, German, Spanish, French, Portuguese) with "why" explanation chips and "More like this", fully offline (1.14.0, ADR 0052) | +| Backlog management | done | Every game carries a personal backlog layer: `progress:unplayed`, `my 4 stars`, `unrated by me` in smart queries; personal `user_rating` star widgets, "My rating" sort/bulk field, manual playtime logs, and dated notes through `/api/v2/library/playtime/*`, `/api/v2/library/notes/*`, `/api/v2/library/progress/set`, and `/api/v2/library/rating/set` (1.14.0, ADRs 0054, 0058) | +| Effortless metadata auto-scrape | done | One post-import `POST /api/v2/metadata/auto-scrape` queues a match pass and a media pass; all providers off by default, each opt-in through `GET`/`POST /api/v2/metadata/scrape-settings` and configurable in the Setup Center Options step (1.14.0) | +| ScreenScraper ROM-hash confidence | done | Independent MD5-only and CRC32-only matches must agree on the same ScreenScraper id before auto-apply; `POST /api/v2/screenscraper/match` accepts `apply_confident` for bulk runs, weaker evidence stays review-only (1.14.0, ADR 0059) | +| Thumbnail chooser | done | Every LaunchBox, SteamGridDB, and ScreenScraper result offers a Thumbnails button opening a chooser grouped by media kind; picking one applies exactly that image via `media_urls` (server-validated against provider candidates), and `GET /api/v2/metadata/media-candidates` lists LaunchBox candidates (1.14.0) | +| Story PNG export | done | The per-game Story card exports a PNG rendered client-side on a canvas; no new endpoint and no dependency (1.15.0) | +| Time Machine Compare | done | The Time Machine Compare tab picks one date or two and diffs the library read-only via `GET /api/v2/library/time-machine/compare` (`after=`, optional `before=`/`fields=`/`limit=`), listing added, removed, and per-field changed games with true totals even when the page truncates and warning when the start date predates the kept history (1.15.0) | +| Signed emulator-definition updates | done | A verified community definition pack installs into the per-user data directory and shadows the bundled set without overwriting it, verified against the release Ed25519 key via `updates.verify_artifact`; `GET /api/v2/emulators/defs/status`, `GET`/`POST /api/v2/emulators/defs/update`, and `POST /api/v2/emulators/defs/rollback` install, update, and roll back, local edits always win, and a signature failure raises a security notification (1.15.0, ADR 0061) | +| Emulator definitions panel | done | Tools > Emulators checks for, installs, and rolls back the signed community pack, lists locally edited definitions that are never overwritten, and states plainly when no pack has been published; `scripts/build_defs_pack.py` builds the byte-deterministic archive and index (1.15.0, ADR 0061) | +| High Contrast stock theme | done | A sixth stock theme redeclares every `:root` token with a maximum-contrast palette (21:1 body text, AAA foregrounds), proving the token contract holds end to end; every theme is checked by a contrast matrix (1.15.0) | +| Motion and reduced-motion contract | done | Every duration and easing is a motion token and one block zeroes them so reduced motion cannot regress by omission; dialogs play an exit, the toast is a single top-layer queue, and a live theme switch cross-fades (1.15.0, ADR 0063) | +| Settings > About | done | Reports version, platform, data folder, and whether OpenBox is in its native window or a browser tab, from the capabilities response (which gained `version`, `platform`, and `data_dir`) (1.15.0) | +| Launch Readiness | done | `POST /api/v2/launch/audit/scan` runs the Launch Doctor over every game in a cancellable job; `GET /api/v2/launch/audit` serves ready/warning/blocked totals, one row per root cause, and paginated member lists. Same check codes as the single-game Doctor; `flatpak` probes cached per app id (1.16.0) | +| Readiness badges on the grid | done | After a Launch Audit, games it found blocked or needing attention show a badge; the badges are withheld when the audit no longer matches the library (1.16.1, `GET /api/v2/launch/audit/status`). The scheduled health rescan also refreshes the audit | +| RetroArch under Flatpak | done | Cores load from the sandbox's cores directory, which the Doctor checks; a native launch keeps the system path (1.16.1, ADR 0067) | +| Flatpak folder access | done | The Launch Doctor offers Grant access when a Flatpak emulator cannot read a game's folder: read-only, after confirmation, with Remove access to undo it for the session (1.16.1, `POST /api/v2/launch/grant` and `/grant/undo`, ADR 0069) | +| Launch readiness group fixes and scoped relink | done | One Grant access or Install button covers a group that shares the fix; Find moved files opens the repair wizard limited to a missing-file group's games (1.16.1, ADR 0072) | +| Per-game RetroArch core | done | Choose core lists the installed cores and sets the core one game launches with; Use the default core restores the definition's (1.16.1, ADR 0072, ADR 0061 addendum) | +| Disc image platform choice | done | A .bin, .cue or .iso file several systems share waits for a platform choice in the import wizard and is not guessed (1.16.1, ADR 0072) | +| Launch check change notice | done | A change in the blocked or warning totals adds one notice to the feed (1.16.1, ADR 0072) | +| Native window first paint | done | Both native hosts paint the active theme's background before the page loads (Windows compiled in CI, not on the release machine; 1.16.1, ADR 0071) | +| Signed definition pack release | partial | The release workflow builds, signs and verifies the emulator-definition pack for each tag; the first signed pack needs a tagged CI run with the release key (1.16.1, ADR 0061 addendum) | +| System coverage: Genesis, Master System, Game Gear, Sega CD, PC Engine, Neo Geo Pocket, Atari 2600/7800/Lynx/Jaguar, WonderSwan, Virtual Boy, C64, MSX, Amiga, Dreamcast, 3DS, MS-DOS | done | Definitions for each (RetroArch cores by buildbot name; Flycast, Azahar and DOSBox Staging by Flathub id). Installing them still needs the emulator (1.16.1) | +| Restore Preview | done | Selecting a backup shows what the restore would remove, bring back, and overwrite (per-field), with true totals and a settings line; the restore button exists only after a successful diff (1.16.0) | +| Native Windows storefront imports | done | GOG Galaxy, EA app, and Ubisoft Connect games installed on Windows are discovered read-only from the registry and launcher manifests, merged by a stable provider id, and launched directly or through the launcher's URI (1.17.0, ADR 0073) | +| Game Night decks | done | A named deck stores players, minutes, and a filter query; building returns an 8-character seed, and the same seed and library reproduce the same draw (1.17.0, ADR 0074) | +| Price paid and cost per hour | done | A game records price paid and currency; Play Insights shows totals per currency and cost per hour for played, priced games; no conversion is performed (1.17.0, ADR 0075) | +| Translated server notices | done | Cloud sync, launch-check, definition, and webhook notices carry translation keys and parameters and render in all five locales, with the stored English text as fallback (1.17.0, ADR 0076) | +| Big Box pointer, wheel, trackball, and spinner navigation | done | Pointer drag, wheel ticks, and Shift+wheel page turns move the Big Box selection with configurable spinner sensitivity and wheel speed; a drag suppresses the synthetic click that would launch a game, and Back-hold opens the menu | +| Big Box battery and display filters | done | The battery indicator reads the Linux sysfs battery through `GET /api/native/battery` and falls back to the browser battery API; platforms, categories, and playlists can be hidden from Big Box | +| Remappable keyboard and controller input | done | Settings > Controller records keyboard shortcuts, clears controller buttons, warns when a D-pad button is reused, and asks before swapping a duplicate assignment; the same mappings drive the desktop and Big Box | +| Filter exclusions and richer smart rules | done | Platform, category, source, and smart filters cycle include → exclude → clear; presets and playlists accept source, achievement-source, region, `contains_any`/`not_contains_any`, and exclusion rules; exclusions survive refresh in the hash route | +| Configurable progress whitelist | done | Progress automation and the game editor use the enabled labels from Settings; the API rejects labels outside the whitelist | +| Sidebar section ordering | done | Settings can reorder search, view, filters, platforms, playlists, and Explorer, and hide Explorer like the other sections; legacy section ids are preserved on save | +| Discovery achievements shelf | done | The Discovery Center lists games with RetroAchievements or provider achievement totals, sorted by completion percentage then title | +| Platform rename and delete | done | Renaming migrates games, profiles, playlists, settings, and opt-in platform media; deleting requires the platform name and moves games to Trash (1.18 unreleased) | +| Storefront missing and repair | done | Steam imports record install-folder presence, show Missing instead of a play button, and hand Install/Verify to Steam (1.18 unreleased) | +| Per-provider integration tokens | done | Five optional providers store tokens locally; the settings API returns only masked values (1.18 unreleased) | +| Save manifests and selective restore | done | Backups carry file hashes and metadata; a version-history dialog previews every older backup, restore previews contents, supports dry run, and restores selected files (1.18 unreleased) | +| Bulk find and replace | done | Literal preview-and-apply over one metadata field for selected games (1.18.0) | +| Controller types and per-platform emulator overrides | done | Controller types are user-defined; platforms can override adapter, RetroArch core, and extra arguments (1.18.0) | +| Tray recent games | done | Linux, Windows, and macOS tray menus launch one of the eight most recent games (1.18.0; macOS from 1.19.0) | + +All LaunchBox Premium-equivalent workflows above are included in OpenBox without a subscription. OpenBox sets `premium_features_free: true` in settings and ships bundled media packs without a license gate. + +## Windows channel (x86_64) + +Since [ADR 0048](adr/0048-windows-port.md) the same source tree, the same tests, and +the same gates run on Windows. These rows record what is true on that channel +specifically; the matrix above is Linux behavior. `partial` means the Windows +build has a deliberate, documented narrower surface. + +| Capability | Status | Acceptance check | +|---|---|---| +| Native window | done | `native_host_win.c` (WebView2) renders the same UI in a real window, with remembered geometry, tray/minimize-to-tray, `openbox://` deeplinks, and one instance per data directory via a named pipe (1.13.0) | +| Portable install and verified update | done | `scripts/install.ps1` verifies the release key anchor, the SHA-256 sidecar, and the Ed25519 signature before extracting to `%LOCALAPPDATA%\OpenBox\share\openbox`, keeps `share\openbox.previous`, and registers Start Menu plus the `openbox://` handler; the in-app updater refuses to replace anything that is not an installed copy (1.13.0) | +| Compiled host without a toolchain | done | The release job builds, attests, and signs `native_host.exe` into the zip and publishes it standalone as `OpenBox-x86_64-windows-native-host.exe`; a source checkout can save it beside `web_app.py` and get the native window with no MSVC or WebView2 SDK (1.13.0) | +| Launcher ladder | done | `openbox.cmd` / `openbox.ps1` / `openbox-native.ps1` mirror the shell scripts: native host, else browser app window, else a plain tab; `openbox --web` forces the browser (1.13.0) | +| Emulator detection and launch | done | Every bundled definition carries `native_exe_windows`, enforced by `scripts/check_emulator_defs.py`, so adapter prefix detection, resume state, and Launch Doctor work with Windows builds (1.13.0, ADR 0060) | +| Library data location | done | `%LOCALAPPDATA%\openbox-game-launcher`; `OPENBOX_DATA_DIR` overrides everything and stored relative references keep POSIX separators so a library moves between platforms (1.13.0) | +| Reliability catalog | done | Windows-specific failure modes are catalogued in `docs/reliability.md` with the same Tested/Manual discipline as the shared rows (1.15, ADR 0062) | +| Windows uninstall | done | `scripts/uninstall.ps1` removes exactly what `install.ps1` creates — the install tree and its `share\openbox.previous` rollback copy, the user PATH entry, the Start Menu shortcut, and the `openbox://` registration — and never touches the library or settings in the data folder; `-WhatIf` previews it (1.15.0, ADR 0062) | +| Native storefront imports | done | GOG Galaxy, EA app, and Ubisoft Connect games installed on Windows import from the registry and launcher manifests, with provider ids for re-import matching (1.17.0, ADR 0073) | +| Epic and Heroic imports | done | Heroic ships on Windows, so its Epic, GOG, and Amazon entries import and open the same way they do on Linux (1.19.1, ADR 0086) | +| Battery status | done | `/api/native/battery` reads `GetSystemPowerStatus`: a desktop reports no battery, a laptop reports percentage and charging state, and the reading is cached for 30 seconds (1.19.1) | +| Lutris and Faugus imports | not applicable | Both launchers are Linux-only; their import sources are hidden on Windows, while Epic and Heroic still import through Heroic (1.19.1, ADR 0086) | +| Wine/Proton prefix manager | not applicable | Wine management is a Linux surface; the panel is hidden on Windows and `/api/wine/*` reports `available: false` (1.19.1, ADR 0086) | +| AppImage / Flatpak packaging | not applicable | Linux-only channels; the Windows artifact is the signed portable zip (ADR 0013) | +| gamescope / Steam Game Mode presets | not applicable | Linux-only; the gamescope preset, MangoHud, and handheld-performance controls are hidden on Windows, a stored preset is ignored at launch, and `--game-mode` exits with the Linux-only message (1.19.1, ADR 0086) | +| XDG desktop entry | not applicable | Replaced by the Start Menu shortcut and `HKCU` protocol registration | +| Flathub emulator management | not applicable | Flathub is a Linux store; Windows emulators are installed by the user, the install and folder-grant controls are hidden, and the grant route refuses the request (1.19.1, ADR 0086) | +| ARM64 Windows build | missing | Only x86_64 is released, while Linux ships an aarch64 AppImage (ADR 0024) | +| Cross-platform launch-command round-trip | partial | Stored commands use platform quoting rules (`shlex` on POSIX, MSVCRT on Windows), so a command written on one platform may not round-trip byte-for-byte to the other. Documented limitation, not a bug (ADR 0048) | + + +## macOS channel (macOS 11+, arm64 and x86_64) + +Since [ADR 0085](adr/0085-macos-support.md) the same source tree, tests, and +gates run on macOS. These rows record what is true on that channel +specifically; the matrix above is Linux behavior. + +| Capability | Status | Acceptance check | +|---|---|---| +| Native window | done | `native_host_mac.m` (WKWebView) renders the same UI in a real window, with remembered geometry, `NSStatusItem` tray/minimize-to-tray, `openbox://` deeplinks, and one instance per data directory via an `AF_UNIX` socket with the 104-byte `sun_path` limit checked before binding (1.19.0) | +| Universal install and verified update | done | `OpenBox-macos.zip` carries arm64 and x86_64 hosts and one merged interpreter; `scripts/install-macos.sh` verifies the release key anchor, SHA-256 sidecar, and Ed25519 signature before installing `OpenBox.app`; the in-app updater swaps the bundle after the server exits and keeps `OpenBox.app.previous` (1.19.0) | +| No-install app | done | The bundle ships a pinned universal CPython 3.12 and the CA bundle, so the app runs without Python or WebKit installed. `scripts/install-macos.sh` still runs the release verifier with `python3`, and a source checkout needs Python 3.10+ (1.19.0) | +| Emulator detection and launch | done | Every bundled definition carries `native_exe_macos`; an app bundle resolves through `Contents/Info.plist`, and an explicit empty value means "no verified macOS build" and is reported as not installed (1.19.0) | +| Library data location | done | `~/Library/Application Support/openbox-game-launcher`; `OPENBOX_DATA_DIR` overrides everything, and stored references keep POSIX separators so a library moves between platforms (1.19.0) | +| macOS saves | done | Save discovery covers Steam Cloud plus the documented macOS folders for PPSSPP, RPCS3, Dolphin, and RetroArch (1.19.0) | +| Notarization | missing | The bundle is ad-hoc signed; the first launch needs System Settings > Privacy & Security > Open Anyway. The release signature is verified by the installer and updater (1.19.0, ADR 0085) | +| AppImage / Flatpak packaging | not applicable | Linux-only channels; the macOS artifact is the signed universal zip (ADR 0013) | +| gamescope / Steam Game Mode presets | not applicable | Linux-only; the preset, MangoHud, and handheld-performance controls are hidden on macOS and `web_app.py --game-mode` exits with a clear message (1.19.0, capability-gated by ADR 0086) | +| Wine/Proton prefix manager | not applicable | Wine management is a Linux surface; the panel is hidden on macOS and `/api/wine/*` reports `available: false` (1.19.1, ADR 0086) | +| Flathub emulator management | not applicable | Flathub is a Linux store; macOS emulators are installed by the user, and the install and folder-grant controls are hidden (1.19.1, ADR 0086) | +| Plugin sandbox | not applicable | macOS has no bubblewrap; the documented no-bwrap rule applies | + + +## Intentionally not replicated on Linux + +These LaunchBox features have no practical Linux equivalent and are documented rather than emulated: + +| LaunchBox feature | OpenBox decision | +|---|---| +| Windows shell replacement | Not applicable on Linux desktop environments | +| LEDBlinky / cabinet LED control | Use external Linux arcade I/O tools instead | +| Teknoparrot arcade launcher | Use Lutris/Wine launch profiles for supported titles | +| Native Xbox PC package scanning | Use Heroic/Lutris/Xbox Cloud entries instead | +| Bundled proprietary media packs | Replaced by free bundled media packs in OpenBox | +| LaunchBox Premium account cloud library | Replaced by OpenBox's own mounted-folder sync plus local backups (no LaunchBox account involved; "cloud sync" rows above mean your folder, not their service) | +| LaunchBox online theme storefront | Replaced by local CSS theme import and open-folder workflow | +| Unbroken account sign-in, vendor cloud stats, online MAME leaderboards, media-store likes | No vendor account. Local library sync, local high-score tables, and the household leaderboard are the equivalents | +| XAML marquee screens, wall-view templates, theme option files, theme demos | OpenBox themes are CSS token overrides. Theme music and navigation sounds cover the sidecar-audio part; marquee and wall templates have no matching surface | +| Android export, 32-bit builds, Windows .NET runtime changes | No Android target. x86_64/aarch64 Linux and x86_64 Windows are the supported channels | +| Paid trial, Beta Spotlight, saved-theme trial behavior | No paywall or beta channel. What's New covers release notes | +| Keyboard automation that injects keystrokes into emulators | Windows input injection. Use launch arguments, environment variables, and emulator configuration instead | +| Scraping Steam media for non-Steam Windows games | Requires a Steam app id. SteamGridDB covers artwork for non-Steam games | +| Automatic MAME version downloads | Flathub and the AppImage are the supported channels; a custom emulator profile can point at any local MAME build | +| RetroArch online lobby as a hosted service | OpenBox only browses RetroArch's public lobby and copies a room address; it does not host or launch rooms | diff --git a/docs/README.md b/docs/README.md index fa4eb5df..64507079 100644 --- a/docs/README.md +++ b/docs/README.md @@ -2,11 +2,78 @@ The user-facing documentation site is a separate repository: `openboxgl.github.io` (Next.js static export, deployed to GitHub Pages at https://openboxgl.github.io). The marketing home page lives in `app/page.tsx` with its section components in `components/`, and user documentation lives under `content/docs/`. -The files under this `docs/` folder are engineering and planning documents that accompany the repo: +The files under this `docs/` folder are engineering, process, and release documents that accompany the repo: + +## Reference - `reliability.md` — edge case catalog of failure modes and expected behavior -- `native-host-contract.md` — contract between the page and the WebKitGTK native host -- `adr/0001-native-host.md` — accepted ADR for the native host decision -- `development/DESIGN.md`, `development/PERF.md`, `development/PRODUCT.md`, `development/COVERAGE.md`, `development/PLAN.md` — design, performance, product, coverage, and planning notes (local-only; kept out of git via `.gitignore`) +- `native-host-contract.md` — contract between the page and the native hosts (WebKitGTK on Linux, WebView2 on Windows, WKWebView on macOS) +- `PARITY.md` — feature parity tracker against LaunchBox +- `api-v2.md` — v2 REST API reference, generated from `routes.py`, `contracts.py`, and `routes/registry.py`; regenerate with `python3 scripts/gen_api_docs.py --v2`, do not edit by hand +- `plugin-api.md` — plugin API v1 reference: manifest, hooks, sandbox, trust, catalog (frozen per ADR 0050) +- `plugin-api-v2.md` — plugin API v2 reference: manifest fields, the JSON Lines runner, versioned host services, and how to port a v1 plugin (ADR 0092) +- `state-format.md` — the library state format, its envelope, migration, and rollback rules +- `portable-library.md` — portable paths: what moves, the move flow, and the integrity check +- `backups.md` — backup formats, the restore report, and which archive restores where +- `api-deprecations.md` — the deprecation ledger: what is retired, when, and in which version +- `flathub-checklist.md` — Flatpak submission readiness and the steps that stay with the maintainer +- `upgrade-2.0.md` — what changes for a user upgrading from 1.19, and how to roll back +- `development/2.0-LEDGER.md` — the 2.0 task ledger: every plan task with its status and evidence +- `development/REPRODUCIBILITY.md` — the reproducible-build report for the macOS bundle +- `development/SETTINGS-AUDIT.md` — the settings retirement audit for 2.0 +- `development/` — development conventions: `DESIGN.md` (design system: colors, typography, components), `HANDLER_CONVENTIONS.md`, `PERF.md` (performance budgets and measurements) +- `adr/` — Architectural Decision Records (ADR 0001 through 0100) covering native host, repository layout, parity shims, theme tokens, cache hierarchy, gate completion, lock ordering, state decomposition, namespace migration, setup preview, durable operations, emulator registry, artifact gates, SQLite read model, i18n system, gamescope presets, controller settings UI, BIOS SHA1 drift detection, backup diff API, smart collection chips, hash routing, ScreenScraper, library export, aarch64 artifacts, changed-line coverage, mood-match theming, library constellation, picker, wrapped timeline, mastery map, game night, SQLite read-model graduation, LaunchBox XML migration, Big Box video snaps, mounted-folder library sync, manual shelf entries, plugin capabilities and bounded services, managed emulator settings, causal sync safety, reviewable LaunchBox source identity, the docs archive layout, perf-gate warm-up with trimmed p95, the Library Time Machine journal contract, the deterministic query grammar contract, the Every Second Counts local-first surfaces, the Living Library additions (smart collections, Game Story, per-game launch options, scheduled backups, SQLite auto-enable, CSP framing gate), the Windows port, Windows installer rollback, the plugin API freeze, library repair and duplicate-merge contracts, Game DNA search, the library health score, the backlog progress enum, artwork hygiene, the plugin trust model, the plugin events hook, dated-notes migration, ROM-hash match auto-scrape, the feature-regression ratchets, the emulator-definition update channel, the Windows reliability contract, the motion and reduced-motion contract, the changelog-truth contract, Windows native storefront imports, Game Night decks, price paid/cost per hour, translated server notices, the GUI clarity and colour system, the durable media queue, save manifests and selective restore, platform rename and delete, native tray, deeplink, and battery, storefront auto-import policy, macOS support, and the platform capability contract. + +The 2.0 records follow: 0087 the 2.0 compatibility policy, 0088 the storage direction, 0089 the +library format v7, 0090 the portable library, 0091 the API v2 and host channel, 0092 the plugin +API v2, 0093 the backup format v2, 0094 macOS notarization, 0095 the Windows ARM64 channel, 0096 +Flathub submission readiness, 0097 reproducible builds, 0098 the scale policy, 0099 the +deprecation ledger, and 0100 the maintenance and 2.1 plan. + +## Gates + +`make check` runs the full gate. The feature-regression ratchets (ADR 0060) +can also be run alone with `make ratchets`, and their frozen contracts are +regenerated with `make contracts` after an intentional surface change. + +- `scripts/contracts/` — the frozen baselines. Every one is a **ratchet**: a + surface may grow freely, and may only shrink by moving an entry to a + `retired` ledger with a reason. Each baseline is itself ratcheted against + git, so deleting an entry to hide a removal also fails. +- `check_routes_contract.py` — the full HTTP route surface (the v1 contract + covers only 61 routes). +- `check_settings_contract.py` — the destructive `KNOWN_SETTINGS` allowlist; + retiring a user-data key requires a named data-preservation migration. +- `check_emulator_defs.py` — every definition carries `native_exe_windows` (ADR 0048), `native_exe_macos` (ADR 0085), and the other required keys. +- `check_frontend_modules.py` — the ES module graph: no dangling imports, no + orphaned modules. +- `check_reliability_catalog.py` — every `Tested` row in `reliability.md` names + a test that exists. +- `check_clock_coupling.py` — no unmarked test pins a calendar date against a + rolling `*_DAYS` window. + + +## Release + +- `CHANGELOG.md` — versioned history of all notable changes (Keep a Changelog format) +- `RELEASE_NOTES.md` — release notes for the current version +- `flathub-checklist.md` — remaining steps toward Flathub store submission + +The current shipped release is **v2.0.0**. The planning and execution records +below are historical archives, not an unfinished active roadmap. + +## Process and policies + +- `CONTRIBUTING.md` — development setup, test/gate workflow, coding conventions +- `TRIAGE.md` — issue triage process, severity labels, response targets +- `SUPPORT.md` — supported platforms, Python versions, data locations +- `SECURITY.md` — security policy and vulnerability reporting +- `CODE_OF_CONDUCT.md` — contributor covenant +- `DISCLAIMER.md` — legal disclaimer +- `TRADEMARKS.md` — trademark and naming policy + +## Archive + +- `archive/` — superseded planning documents and historical release notes kept for traceability: `NEXT_UPDATE_PLAN.md` (the executed 1.9.0→1.10.0 plan), `NEXT_UPDATE_PLAN-1.11.md` (the executed 1.11.0 planning record), `NEXT_UPDATE_PLAN-1.12.md` (the executed 1.12.0 plan), `NEXT_UPDATE_PLAN-1.14.1.md` (the 1.14.1 plan, folded into 1.15 and never cut as a patch release), `NEXT_UPDATE_EXECUTION.md` (the executed 1.9.0→1.10.0 ledger), `NEXT_UPDATE_EXECUTION-1.11.md` (the executed 1.11.0 ledger), `NEXT_UPDATE_EXECUTION-1.17.md` (the 1.17.0 implementation and verification record, shipped in 1.17.0), `HANDOFF-WINDOWS-PORT.md` (the Windows port handoff, shipped under ADR 0048), `HANDOFF-1.16.0.md` (the 1.16.0 execution record), `release-notes-1.7.2.md`, `release-notes-1.8.0.md`, `release-notes-1.11.0.md`, `release-notes-1.12.x-1.13.x.md`, `release-notes-1.14.0.md`, `release-notes-1.15.0.md`, `release-notes-1.16.0.md`, `release-notes-1.16.1.md` Do not add user-facing markdown here. Edit the docs site repo instead. diff --git a/docs/RELEASE_NOTES.md b/docs/RELEASE_NOTES.md new file mode 100644 index 00000000..f24bd52f --- /dev/null +++ b/docs/RELEASE_NOTES.md @@ -0,0 +1,104 @@ +# OpenBox release notes + +OpenBox v2.0.0 turns the 1.x compatibility scaffolding into stable contracts: the library can move, the integrations stop moving, and every supported platform gets a clean install. 1.19.1 was a platform-parity bug sweep; 1.19.0 made macOS a first-class channel, which exposed a class of defects where a Linux-only feature was offered on Windows or macOS and failed at click time. This release closes that class on all three platforms: one server-computed capability contract now decides which platform-specific controls exist, and the controls Windows and macOS keep use their own tools instead of Linux ones. Linux behavior is the reference and is unchanged. The complete list is in [CHANGELOG.md](https://github.com/vindeckyy/OpenBoxGL/blob/master/docs/CHANGELOG.md), and the code changes are in [v1.19.0...v1.19.1](https://github.com/vindeckyy/OpenBoxGL/compare/v1.19.0...v1.19.1). + +## OpenBox v2.0.0 (unreleased) + +OpenBox 2.0 turns the 1.x compatibility scaffolding into stable contracts. The three promises: +your library can move, your integrations stop moving, and every supported platform gets a clean +install. + +### Your library can move + +- **Portable library.** Game paths under one library folder are stored as `@library/...` tokens. + Move the folder to another drive or machine, point OpenBox at it, and the games resolve + without rewriting each game. **Settings > Library > Move library** previews the move and + refuses a stale preview. Extra apps, versions, and documents under the folder move with it. +- **Library format 7.** Libraries upgrade on first start. The old file is kept as + `library.v6.backup.json`, and `openbox --rollback-library` restores it byte-for-byte. +- **Integrity check and media manifest.** **Settings > Library > Check integrity** reports + missing, unreadable, and outside-folder game paths, and media files that were changed, + deleted, or added behind the app's back. Nothing is changed by a check. +- **Compatibility report.** **Settings > About > Show compatibility report** lists the library + format, each theme's token fallbacks, which backups restore on which version, unknown settings + keys, the public API and host channel, and the sync folder. + +### Your integrations stop moving + +- **The public API is `/api/v2`.** The retired `/api/v1` and unversioned routes are off by + default; `OPENBOX_ENABLE_V1_API=1` restores them through 2.0.x, and they are removed in 2.1. + `GET /api/v2/version` reports every contract version, and the OpenAPI document lists every + public route. +- **The native host has its own channel.** Windows, Linux, and macOS windows talk to + `/api/host/*` and send `X-OpenBox-API-Version: 2`. A window from another major version is + refused before the route runs. +- **Backup format 2.** Every backup records a SHA-256 for each file; a restore checks them all + before writing anything and reports what it wrote. A **1.19-compatible** export is available + when an older install must read a backup. + +### Platform installs + +- **macOS stays ad-hoc signed.** The 2.0 bundle is not notarized: the Privacy & Security + confirmation on first launch and after each update still applies, and the install docs + keep the caveat. The notarization machinery (entitlements, `notarytool`, stapling) stays + in the tree for a later release. +- **Windows ARM64.** The release publishes `OpenBox-arm64-windows.zip` and `OpenBox-arm64-windows-native-host.exe` from the `windows-11-arm` lane: the host is built for ARM64, attested, signed, and verified like the x86_64 assets, and the installer and updater select it on ARM64 machines by architecture. +- **Flatpak** stays submission-ready; the submission itself remains a maintainer decision. + +### Breaking changes + +| Change | What it means | How to keep working | +|---|---|---| +| Public API is `/api/v2` only | Scripts on `/api/v1` or unversioned paths stop working | Move to `/api/v2`, or set `OPENBOX_ENABLE_V1_API=1` through 2.0.x | +| Library format 7 | A 1.x build cannot read the library after the upgrade | Automatic on first start; `openbox --rollback-library` restores the v6 copy | +| Backup format 2 | A 1.x build cannot read a new backup | Use the 1.19-compatible export for archives a 1.x install must read | +| Host channel | New hosts use `/api/host/*` | Update the host; the old paths stay as internal aliases through 2.0.x | +| Retired `/api/v1` routes | Off by default | `OPENBOX_ENABLE_V1_API=1` | + +### Not in 2.0 + +- JSON stays the canonical store. SQLite remains a read model; the storage decision is recorded + in ADR 0088 with its measurements. +- The formal library scale stays at 20,000 games. 50,000 was measured and reported as + exploratory. +- Python 3.10 is still supported. +- Plugin API v2 is additive; v1 plugins keep working. + +## 1.19.1 (previous release) + +### What 1.19.1 fixes + +- **A stored gamescope preset no longer breaks Windows and macOS launches.** The preset is applied only on Linux, so it is never prepended to a Windows or macOS command line. The gamescope preset, MangoHud, and handheld-performance controls are hidden on those hosts, and `--game-mode` exits with the Linux-only message instead of starting a broken guest mode. +- **Windows reports its real battery.** The battery indicator reads `GetSystemPowerStatus`: a desktop reports no battery, a laptop reports its percentage and charging state, and the reading is cached for 30 seconds. Windows no longer gets a hard-coded "no battery" answer. +- **Wine, Faugus, and Flatpak management answer only on Linux.** The Wine/Proton manager reports `available: false` and never scans the disk off Linux, the Faugus scanner returns nothing, the Flatpak folder grant refuses the request before running anything, and the Launch Doctor stops offering a Flatpak install where Flatpak does not exist. +- **Storefront browsing uses the platform's own URL opener.** The Steam and Heroic catalogs no longer assume `xdg-open`, say clearly when a host has no opener, and a Git/MSYS `xdg-open` on `PATH` no longer wins over Explorer on Windows. +- **The storefront list matches the host.** `/api/v2/storefronts` lists Heroic on Linux and Windows, Lutris only on Linux, and GOG/EA/Ubisoft only on Windows; a stale client calling a native Windows import on another host receives a clear 400 instead of a failing scan. + +## One capability contract + +`GET /api/native/capabilities` now carries a `features` object: `gamescope`, `mangohud`, `handheld_perf`, `flatpak`, `wine`, `faugus`, `lutris`, `heroic`, `epic`, `native_storefronts`, and `desktop_entry`. The web UI gates every platform-specific control from that object through one `data-feature` marker, replacing the per-OS markup markers that made every new platform edit the page. A missing object means every feature is false, so a fetch that fails hides controls instead of exposing broken ones. The contract and its failure semantics are documented in [ADR 0086](https://github.com/vindeckyy/OpenBoxGL/blob/master/docs/adr/0086-platform-capability-contract.md). + +## Upgrading + +Upgrade from 1.19.0; there is no data migration, no route was removed, and no setting was deleted. Linux behavior is unchanged: gamescope presets, MangoHud, Wine/Proton management, Flatpak grants, and the Linux import sources work exactly as before. + +The installers from 1.19.0 are unchanged: + +```bash +VERSION=1.19.1 +curl --proto '=https' --tlsv1.2 --fail --location \ + --output install-macos.sh \ + "https://github.com/vindeckyy/OpenBoxGL/releases/download/v${VERSION}/install-macos.sh" +less install-macos.sh +OPENBOX_RELEASE_TAG="v${VERSION}" bash install-macos.sh +``` + +On Windows, extract the release archive and run `OpenBox\scripts\install.ps1`; on macOS, download `install-macos.sh` and run it with the tag pinned; on Linux, use the AppImage, the Flatpak, or `scripts/install.sh` as before. Each binary artifact (the AppImages, the Windows archive and native host, and the macOS bundle) ships with its SHA-256 sidecar and Ed25519 signature. The installer scripts are not signed; they pin the release key and verify the artifacts against it. + +## Known limits + +- The macOS bundle is still ad-hoc signed, not notarized, so the first launch and each new build need the Privacy & Security confirmation. +- Linux-only features remain unavailable on Windows and macOS by design; they are now hidden rather than broken. +- ARM64 Windows builds remain out of scope; the Windows artifact is x86_64. + +The [changelog](https://github.com/vindeckyy/OpenBoxGL/blob/master/docs/CHANGELOG.md) lists the main areas, not every change. diff --git a/docs/SECURITY.md b/docs/SECURITY.md new file mode 100644 index 00000000..e271466a --- /dev/null +++ b/docs/SECURITY.md @@ -0,0 +1,155 @@ +# Security Policy + +## Fixed in 1.16.0: what 1.15.x and earlier are exposed to + +If you run 1.15.x or older, these are the security defects 1.16.0 closes. None is +backported, so upgrading is the fix. + +- **Arbitrary `.json` read through a crafted `preview_id`.** Two code paths (the import + setup preview and the Launch Doctor) joined the request's `preview_id` into a file path + unchecked, so `../../…/settings` returned any JSON file on disk, including `settings.json` + with provider credentials. Every `preview_id` now goes through one validator and a + containment check. +- **Arbitrary file write through a game's `rom_name`.** Restoring a high-score bundle built + the destination filename from `rom_name`, so a traversal string wrote outside the + high-score folder. The name is reduced to a safe component and the destination is + checked to stay inside it. +- **Plugin sandbox could read the library.** The bubblewrap sandbox masked only `/home`, + `/tmp`, `/run`, `/mnt` and `/media`. With `OPENBOX_DATA_DIR` (a supported override) + pointing elsewhere, sandboxed plugins could read `library.json` and `settings.json`. + The resolved data directory is now masked wherever it lives. +- **Unescaped strings in the Wrapped report.** Game names and an error message reached + `innerHTML` without escaping. The CSP (`script-src 'self'`) blocked script execution, so + this was one policy change away from exploitable rather than exploitable; it is escaped + now and a source gate fails if a bare property reaches an HTML sink again. +- **A silently divergent JSON serializer.** When `orjson` happened to be installed, state + writes used it and could produce different output from the standard library. It is + removed; the runtime is standard-library only, enforced by `scripts/check_dependencies.py`. +- Smaller hardening: the RetroAchievements cache name is built from an integer, and the + `flatpak info` probes have a timeout so a hung Flatpak cannot hang a request. + +## Supported versions + +| Version | Supported | +| --- | --- | +| 2.0.x | Yes (current) | +| 1.19.x | No — upgrade required | +| 1.18.x | No — upgrade required | +| 1.16.x | No — upgrade required (see above) | +| 1.15.x | No — upgrade required | +| 1.14.x | No — upgrade required | +| 1.13.x | No — upgrade required | +| 1.12.x | No — upgrade required | +| 1.11.x | No — upgrade required | +| 1.10.x | No — upgrade required | +| 1.9.x | No — upgrade required | +| 1.8.x | No — upgrade required | +| 1.7.x | No — upgrade required | +| 1.6.x | No — upgrade required | +| 1.5.x | No — upgrade required | +| 1.4.x | No — upgrade required | +| 1.3.x | No — upgrade required | +| 1.2.x | No — upgrade required | +| 1.1.x | No — upgrade required | +| 1.0.x | No — upgrade required | +| 0.9.x | No — upgrade required | +| 0.8.x | No — upgrade required | +| 0.7.x | Best effort | +| 0.6.x | Best effort | +| 0.5.x | Best effort | +| 0.4.x | Best effort | +| < 0.4.0 | No | + +Security fixes are provided for the latest release on the `master` branch. Fixes land on `master` going forward; older lines are not backported — upgrade to the latest release to receive them. The 1.19.x line is the current maintained release; the older rows document the historical support policy and should not be read as promises of backported fixes. + +## Reporting a vulnerability + +Please do **not** report security vulnerabilities through public GitHub issues. + +Instead, report them privately using one of the following methods: + +1. Open a **private security advisory** on GitHub: [Create a security advisory](https://github.com/vindeckyy/OpenBoxGL/security/advisories/new) +2. If GitHub advisories are unavailable, open a minimal public issue asking for a private contact channel without disclosing exploit details + +Include as much of the following as possible: + +- Description of the issue and potential impact +- Steps to reproduce +- Affected version(s) +- Proof of concept, if available +- Suggested remediation, if known + +## Response expectations + +Maintainers aim to acknowledge valid reports within **5 business days** and provide a remediation plan or status update within **14 business days**, depending on severity and complexity. + +## Scope + +The following are in scope: + +- Remote code execution or privilege escalation in OpenBox server components +- Authentication or authorization bypass in the local web API +- Unsafe command execution introduced by OpenBox launch logic +- Credential leakage through logs, backups, or repository defaults + +The following are generally out of scope: + +- Vulnerabilities in third-party games, emulators, storefront clients, or operating system components launched by OpenBox +- Issues requiring physical access to an unlocked machine with an already running OpenBox instance +- Social engineering or phishing unrelated to OpenBox itself + +## Safe usage guidance + +- Run OpenBox on trusted local networks. The web UI uses a session token; do not expose it directly to the public internet without a reverse proxy and additional hardening. +- Do not commit `.env`, API tokens, RetroAchievements credentials, or EmuMovies credentials to the repository. +- Keep OpenBox updated to the latest release. +- Plugins run with an OS sandbox when bubblewrap is available. If bubblewrap is unavailable, OpenBox skips enabled plugins unless you trust that exact package in the Plugins manager (which then runs it unsandboxed) or `OPENBOX_ALLOW_UNSANDBOXED_PLUGINS=1` is set. Setting `OPENBOX_ALLOW_UNSANDBOXED_PLUGINS=1` disables the sandbox entirely, even when bubblewrap is available. Only use that override or trust for plugins you trust. macOS has no bubblewrap, so enabled plugins are skipped there unless the exact package is trusted. +- Integration tokens for IGDB, ScreenScraper, SteamGridDB, Gameyfin, and RetroAchievements are stored outside the public settings projection and returned only masked. Plugin capability credentials live in a separate store that backups exclude, and password-format plugin settings are blanked in backups. + +## Release signing + +Release publication is fail-closed. The workflow requires `OPENBOX_SIGNING_KEY`, derives an Ed25519 public key in a mode-`0600` temporary file, compares it with the committed `openbox-release.pub`, signs every published binary — the x86_64 and aarch64 AppImages, the Windows portable zip, the standalone Windows native-host executable, the macOS app bundle, and the emulator definition pack — and verifies each signature before upload. The updater and `scripts/install.sh` reject checksum-only releases. + +The macOS bundle is ad-hoc signed rather than notarized: it carries no Developer ID signature, so macOS asks the user to allow it the first time it is opened. The release signature above is what the installer (`scripts/install-macos.sh`) and the in-app updater verify; the ad-hoc code signature only proves the bundle was not modified after it was built. + +The committed `openbox-release.pub` pins the production release key. Maintainers must preserve these external release controls: + +- Generate a new Ed25519 key pair outside the repository and store only the private key as the `OPENBOX_SIGNING_KEY` repository secret. +- When rotating the signing key, replace `openbox-release.pub` and update `RELEASE_KEY_SHA256` in `scripts/install.sh` in the same reviewed change. +- Require approval for the `release` environment and protect `v*` tags against unreviewed pushes; use annotated, signed tags where the GitHub organization supports the signing-key policy. +- Publish `openbox-release.pub`, the `.sig`, the checksum, and `install.sh` together. Rotate the key and repeat the pin update if it is compromised. + +## Disclosure + +We prefer coordinated disclosure. Reporters will be credited in release notes when fixes ship, unless they request anonymity. + +## 2.0 threat-model addendum (W0-09, W7-07) + +This addendum lists what 2.0 adds to the attack surface, the control for each item, and the test that exercises it. Gaps are listed separately and are not claimed as controls. + +| Surface added or changed in 2.0 | Threat | Control | Test | +|---|---|---|---| +| Portable library tokens (`@library/...`) | A stored token escapes the library folder (`..`, drive prefix, leading separator, NUL, alternate data stream) | Tokens are refused at decode; containment is checked after normalization | `tests/test_portable_paths.py` | +| Portable library symlinks | A symlink inside the folder points outside it and is tokenized | A path that resolves outside the folder is not tokenized and is reported as `game_path_outside_root` | `tests/test_portable_library.py`, `tests/test_symlink_refusals.py` | +| Extra-app paths (`applications`, `versions`, `documents`) | The same escape and rebase risks as game paths | The same token rules; a move rebases each path slot by its own key | `tests/test_portable_library.py` (`ExtraAppPathTests`) | +| Move wizard | A stale preview applies to a library that has changed | Apply recomputes the plan under the store lock and refuses with `MOVE_PLAN_STALE` | `tests/test_library_move_http.py` | +| Library migration v6 to v7 | A crash or full disk leaves a truncated library | Backup written and fsynced first; primary replaced atomically; a failed step leaves the original file | `tests/test_state_migration_rollback.py` | +| Rollback | Rollback writes under a running server | Rollback refuses while a server answers on the recorded port | `tests/test_state_migration_rollback.py` | +| Library format newer than this build | An older build rewrites a newer library | Refused with `STATE_FUTURE_VERSION` without rewriting the file | `tests/test_state_v7.py` | +| Backup restore (format 2) | A tampered or unlisted member is written | Every member's size and SHA-256 are checked before any write; unlisted and missing members refuse the archive | `tests/test_backup_format_v2.py` | +| Backup archives | Path traversal and symlink members | Entries are validated before extraction; symlinks are refused | `tests/test_path_traversal.py`, `tests/test_symlink_refusals.py` | +| Backup format above this build | Silent misread of a newer archive | Refused with `BACKUP_FORMAT_UNSUPPORTED`; nothing is written | `tests/test_backup_format_v2.py` | +| Media manifest | A changed or substituted media file goes unnoticed | Size and mtime are checked on demand; a change triggers a hash comparison; untracked files are reported | `tests/test_media_manifest.py` | +| Host channel | A host built for another major API drives the server | `X-OpenBox-API-Version` is checked; a mismatch is refused with `HOST_API_UNSUPPORTED` before routing | `tests/test_api_v2_contract.py` | +| v1 API bridge | Retired unversioned routes return to the public surface | Off by default; `OPENBOX_ENABLE_V1_API=1` re-enables it | `tests/test_api_v2_contract.py` | +| Frontend requests | A request reaches an unversioned or unknown endpoint | Every `api()` call site must resolve to a live route; `/api/v1` may not appear in the frontend | `tests/test_frontend_endpoint_contract.py` | +| Failure messages | A code is shown raw or not at all | Five codes map to explanations; a test requires an English explanation for each | `tests/test_api_problem_details.py` | +| macOS bundle | An ad-hoc build is described as notarized | Notarization is claimed only with `spctl` evidence; the entitlements file is empty by default; the gate checks the pipeline | `tests/test_macos_release.py`, `docs/archive/HANDOFF-MACOS-NOTARY.md` | + +Gaps (not controls): + +- The Linux and Windows native hosts send `X-OpenBox-API-Version`, but they are compiled only in CI. Compilation was not run on this workstation. +- The macOS bundle is ad-hoc signed, not notarized. The maintainer decided 2.0 ships that way, so the Privacy & Security confirmation on first launch still applies (ADR 0094)ed. +- Plugin API v2 is implemented (ADR 0092). A v1 plugin keeps the v1 runner and the v1 payloads; a v2 plugin runs under the JSON Lines runner with an output cap. +- The pre-app migration page (W1-12) is implemented: the upgrade is reported before the library is used, and the rollback restores the copy of the old file. + diff --git a/docs/SUPPORT.md b/docs/SUPPORT.md new file mode 100644 index 00000000..9ba56825 --- /dev/null +++ b/docs/SUPPORT.md @@ -0,0 +1,70 @@ +# Support + +## Scale and architecture + +- **Formal library scale:** 20,000 games (blocking performance gates cover 10k and 20k scenarios). The optional SQLite read model (`OPENBOX_ENABLE_SQLITE_READ=1`) provides indexed search and facets for larger libraries while JSON remains canonical. +- **CPU architecture:** OpenBox 2.0.0 publishes signed **x86_64 and aarch64 AppImages**, signed **x86_64 and arm64 Windows portable zips**, and a signed **universal macOS app bundle** (arm64 and x86_64 in one archive). The Flatpak bundle is **x86_64 only**. The in-app updater and the release installers select the artifact that matches the running architecture and refuse a mismatched or unsigned artifact. + + The Windows native host is built per architecture against the WebView2 SDK pinned in `scripts/build_native_host_windows.ps1`: after downloading the SDK NuGet package the script verifies its SHA-256 against a committed hash (`$WEBVIEW2_SDK_SHA256`, overridable with the `WEBVIEW2_SDK_SHA256` environment variable when trying a new SDK version) and refuses to build when the hash does not match. To rotate the SDK, bump the script's default version, download the new package, and replace the pinned hash with the new package's SHA-256. + + The macOS bundle carries a pinned CPython build (python-build-standalone). `scripts/build_macos_app.sh` verifies the SHA-256 of every download, merges the arm64 and x86_64 interpreters into one universal tree, signs the bundle ad-hoc, and refuses to package it when the signature does not verify. +- **Interface language:** English, Spanish, German, French, and Portuguese (v1.7.2+). + +## Supported platforms + +OpenBox targets Linux on **x86_64 and aarch64**, Windows 10/11 on **x86_64 and arm64**, and macOS 11 or newer on **arm64 and x86_64**. Release CI validates the packaged paths and native host builds; hardware and distro coverage below describes the strength of the evidence rather than a promise that every combination is physically tested: + +| Environment | Status | +|---|---| +| Ubuntu LTS (release runners) | Release-gated build and test coverage | +| Fedora | Host and writable emulated-aarch64 build validation; runtime otherwise best effort | +| Arch Linux | Best effort | +| SteamOS / Steam Deck | Gamescope guest harness and controller-path coverage; physical maintainer pass unavailable | +| aarch64 desktops / handhelds | Release-gated AppImage/native build and emulated smoke; physical hardware unverified | +| Windows 10/11 (x86_64) | Full test suite on `windows-latest` CI; WebView2 host build exercised there; physical maintainer pass unavailable | +| macOS 11+ (arm64 and x86_64) | Full test suite on `macos-15` and `macos-15-intel` CI; universal bundle built, signed, and smoke-tested there; physical maintainer pass on Apple Silicon only | +| Other glibc distributions | Best effort | + +The aarch64 runner and emulated tests prove the build and packaged paths, not battery, display, controller, or gamescope behavior on a particular handheld. Report hardware-specific results with the model, distro image, and desktop/session details. + +## Supported runtimes + +- Python 3.10 or newer (CI runs 3.10 and 3.12) +- Linux: the native window needs WebKitGTK 4.1; `--web` and `python3 web_app.py` need no host library +- Windows: the native window needs the WebView2 runtime (shipped with Windows 11 and most Windows 10 systems); `openbox --web` and `python web_app.py` work without it +- macOS: the app bundle carries its own Python, so nothing has to be installed. A source checkout needs a Python 3.10 or newer; the system `python3` is older than that. The native window is WKWebView, which ships with macOS. +- Chromium-family browsers get the chrome-less app window; Firefox opens a separate window; no compatible browser falls back to the default browser + +## Removing OpenBox + +On Windows, `scripts/uninstall.ps1` removes a portable install: the application directory, its `PATH` +entry, the Start Menu shortcut, and the `openbox://` URL handler. It deliberately does **not** touch +your data — the library, artwork, and settings stay in `%LOCALAPPDATA%\openbox-game-launcher\` so a +reinstall finds them intact. Delete that directory yourself to remove your data as well. The script +prints what it removed and what it left behind; pass `-WhatIf` to preview without changing anything. + +On macOS, remove `OpenBox.app` from `~/Applications` (or wherever you installed it) and the +`~/.local/bin/openbox` link. Your data stays in `~/Library/Application Support/openbox-game-launcher`; +delete that directory yourself to remove it as well. A release is notarized only when its release notes +say so. Any other macOS bundle is ad-hoc signed, so macOS asks you to allow it the first time you open +it, and each new ad-hoc build asks again. + +## Reporting problems + +Open **Settings -> Library Audit** and use the diagnostic log, or paste the error banner's "Copy details" output (it includes a request id maintainers can correlate with the log). + +Bug reports should include: OpenBox version, distro and desktop, Python version when running from source, the diagnostic report from `/api/diagnostic` (Settings), and steps to reproduce. + +## Portable library + +If games show as missing after you moved your library folder, open **Settings > Library > Check integrity**. It names missing, unreadable, and outside-folder paths with counts. OpenBox never copies or deletes game files during a move. A move is a preview followed by an apply, and you can move the folder back the same way. Your data folder (saves, media, settings) does not move with the library folder. + +## Known behavior + +See `docs/reliability.md` for the full edge case catalog. Highlights: + +- Graceful shutdown stops tracked sessions, persists cleanup, and drains shutdown work. If a tracked game remains alive after the two-second grace window, OpenBox force-kills its process tree (on Linux and macOS the process group, falling back to the PID; on Windows the tree via job object / `TerminateProcess`); the native hosts also force-kill their server process tree if the server does not exit in time. +- OpenBox data lives in `~/.local/share/openbox-game-launcher/` on Linux, `%LOCALAPPDATA%\openbox-game-launcher\` on Windows, and `~/Library/Application Support/openbox-game-launcher/` on macOS (override with `OPENBOX_DATA_DIR` in the process environment before launch, read at import time per `openbox.py:20-21`); deleting `library.json` resets the library while media files stay. +- The loopback server writes per-launch `server.token` and `server.port` into the data directory (`web_app.py:751-753`) and deletes both on exit; prefer the `X-OpenBox-Token` header over a `token` query parameter. +- Optional `.env` discovery checks an explicit `OPENBOX_ENV_FILE` first, then the data directory and its parent, `~/.env`, and `~/.config/openbox-game-launcher/.env` (`env_config.py:116-140`). The data-directory choice itself must be exported before launch — a discovered `.env` is read too late for it. +- The web UI is local-only. Sharing the token in the URL with another machine is equivalent to handing over control of the instance. diff --git a/docs/TRADEMARKS.md b/docs/TRADEMARKS.md new file mode 100644 index 00000000..d1761bf2 --- /dev/null +++ b/docs/TRADEMARKS.md @@ -0,0 +1,33 @@ +# Trademark and branding policy + +OpenBox Game Launcher is an independent open-source game library manager and launcher for Linux, Windows, and macOS. + +## Project identity + +Use the full name **OpenBox Game Launcher** on first mention in public listings, package descriptions, screenshots, release announcements, and AppStream metadata. Short name **OpenBox** may be used after first mention. The project is unrelated to [Openbox](https://openbox.org/), the Linux window manager, and is not affiliated with its maintainers. + +The project is also independent from LaunchBox, Big Box, Unbroken Software, LLC, and every other third-party product named in the repository. Do not describe OpenBox Game Launcher as official, endorsed, sponsored, or produced by another project or company. Do not use composite names that suggest affiliation, such as OpenBox LaunchBox, OpenBox for LaunchBox, or LaunchBox on Linux by OpenBox. + +The `io.openbox.GameLauncher` application ID and `openbox://` URI scheme are technical identifiers under the FreeDesktop reverse DNS convention. They do not imply affiliation with the Openbox window manager and must not be presented as an endorsement. + +## Nominative fair use + +Third-party names, logos, and marks belong to their respective owners. OpenBox uses product names in documentation and interface text only when needed to describe: + +- compatibility or interoperability; +- supported import, launch, or metadata workflows; or +- a factual feature comparison against a named product on a named platform. + +When a third-party name appears, use it in plain text with correct capitalization and without altering its logo or trade dress. Do not place a third-party logo next to the OpenBox logo in a way that implies co-branding. When space allows, include the attribution line from [DISCLAIMER.md](DISCLAIMER.md) that marks are property of their owners. + +Platform names are trademarks of their owners: macOS, Apple, and WebKit are trademarks of Apple Inc., and Windows, Microsoft, and WebView2 are trademarks of Microsoft Corporation. They are named only to describe the platforms OpenBox runs on and the host runtimes it uses. + +## Third-party assets + +Do not copy third-party logos, artwork, cover art, screenshots, or other branded assets into the repository, release artifacts, or documentation site without a documented license or written permission. Showcase images are user supplied captures of user supplied games and follow the same rule. New contributions that include third-party assets must state the source, the applicable license, and that the contributor has the right to submit them. See [DISCLAIMER.md](DISCLAIMER.md) section 3 and the Developer Certificate of Origin in [CONTRIBUTING.md](CONTRIBUTING.md). + +## Formal notices + +Do not post private contact details or formal legal notices in public issues, discussions, or pull requests. Use GitHub's [copyright claims form](https://github.com/contact/dmca) for copyright complaints and the [GitHub Trademark Policy](https://docs.github.com/en/site-policy/content-removal-policies/github-trademark-policy) for trademark complaints. GitHub is the designated agent for content hosted on GitHub. The project cannot determine the legal validity of a complaint. Formal notices should use the applicable GitHub process or qualified legal counsel. + +This policy is a project guideline, not legal advice. It does not determine whether a particular use is lawful in any jurisdiction. Consult qualified counsel for jurisdiction-specific questions. diff --git a/TRIAGE.md b/docs/TRIAGE.md similarity index 100% rename from TRIAGE.md rename to docs/TRIAGE.md diff --git a/docs/adr/0001-native-host.md b/docs/adr/0001-native-host.md index 83f38596..d3d8071d 100644 --- a/docs/adr/0001-native-host.md +++ b/docs/adr/0001-native-host.md @@ -75,7 +75,10 @@ the library is absent. - Positive: one renderer, two hosts; feature parity is free; the Python runtime stays stdlib-only. -- Negative: a small amount of C to maintain (kept under ~600 lines); a system +- Negative: a small amount of C to maintain (now ~1,800 lines in `native_host.c`, + grown past the original ~600-line budget as dialogs, gamepad stub, and + single-instance handling landed), plus a second Windows host + (`native_host_win.c`, WebView2 — see ADR 0048); a system dependency on WebKitGTK. - Neutral: the loopback server remains the application core; only the product surface changes. diff --git a/docs/adr/0002-repository-layout.md b/docs/adr/0002-repository-layout.md new file mode 100644 index 00000000..b3f8fa4c --- /dev/null +++ b/docs/adr/0002-repository-layout.md @@ -0,0 +1,25 @@ +# ADR 0002: Repository layout, AGENTS.md and CI gates + +## Status +Accepted + +## Context +Root held 97 Python files (48 runtime + 49 tests), 12 markdown docs, and 4 shell scripts before the layout work. For a machine grep was fine, for a human there was no entry point or visible boundary. Handlers/static/scripts/themes were already well organized, root was the exception. Theming token layer existed (`--brand` etc. in `static/app.css :root`) but 625 raw hex values lived outside `:root`, forcing each theme to redeclare 51-74 rules. The coverage floor at the time (55% total) could not see untested new features. + +## Decision +- Move docs except README.md/LICENSE to docs/ +- Move tests to tests/, runner walks tests/ then root for compat +- Move parity_* (18 files, 14% of root) to pkg/parity/ as package, keep shims at root for compat during transition +- Keep at root: README.md, LICENSE, Makefile, pyproject.toml, runtime_modules.txt, openbox.py, index.html, native_host.c, static/, themes/, emulator_defs/, handlers/, scripts/ +- Add AGENTS.md (and CLAUDE.md symlink) stating layout, design system token rule, non-negotiables, tests, before-PR +- Add scripts/check_tokens.py with ratcheting baseline (625) +- Add diff coverage gate (lines added >=80% covered) via coverage json, and ratchet floors in scripts/check_tests.py +- Defer native/web/packaging moves to later phases + +## Consequences +- runtime_modules.txt, run_all_tests.sh, pyproject.toml, Makefile, build_appimage.sh updated to handle both layouts +- ~~Flat `import parity_*` continues to work through root shim modules; new parity implementation lives under `pkg/parity/`.~~ Root shims deleted in 1.9.0; flat `import parity_*` now resolves through the `_ParityFlatFinder` bridge in `pkg/parity/__init__.py` (ADR 0003). Token baseline ratcheted 625 → 0; coverage floors since raised (83.0 total / 58.0 web_app / **95 changed-line** / 85 new-module; the earlier 80% changed-line floor was superseded by ADR 0037). +- Themes shrink to palette when token set widens + +## Alternatives considered +Single giant move vs phased. Phased wins: each PR green, each step independently useful. diff --git a/docs/adr/0003-parity-shim-compat.md b/docs/adr/0003-parity-shim-compat.md new file mode 100644 index 00000000..ea8b0aac --- /dev/null +++ b/docs/adr/0003-parity-shim-compat.md @@ -0,0 +1,22 @@ +# ADR 0003: Parity shim compatibility + +Date: 2026-08-20 +Status: Accepted +Updated: 2026-09-04 — shim deletion landed in 1.9.0; the `_ParityFlatFinder` is the sole flat-import bridge. Correction: the actual shim count was 28, not 22 (the plan's "22" was stale). + +## Context + +The wide-improvements plan Step 5 proposed deleting the 28 root `parity_*.py` shims and replacing flat `import parity_*` with a lazy `MetaPathFinder` alias in `pkg/parity/__init__.py`. The shims did `sys.modules[__name__] = importlib.import_module("pkg.parity.")`. + +## Decision + +Keep the 28 shims for one release as a compatibility fallback. `import parity_*` before `import pkg.parity` would fail without the shim file on `sys.path`, so deleting shims now would break standalone `python3 -c "import parity_backup"` and any plugin that does flat imports without first importing `pkg.parity`. The shims already alias `sys.modules["parity_*"]` to `pkg.parity.parity_*` on first flat import. + +Runtime and tests continue to use both `import parity_*` (flat) and `from pkg.parity.parity_* import` (canonical) via the shim alias, which is verified by `tests/test_parity_playnite.py` and `python3 -c "import parity_backup; print(parity_backup.__file__)"`. + +## Consequences + +- ~~`runtime_modules.txt` retains both shim and canonical entries (96 lines) for this release.~~ Shims deleted in 1.9.0; `runtime_modules.txt` retains only canonical `pkg/parity/parity_*.py` entries (102 lines). +- `pkg/parity/__init__.py` remains a minimal package init (no eager import loop) to avoid the `parity_perf -> parity_gamescope` circular import that an eager `for p in glob(...): import_module(...)` would cause. +- ~~Next release will implement a lazy `MetaPathFinder` (`_ParityFlatFinder`) in `pkg/parity/__init__.py` and delete shims after updating all internal imports to `pkg.parity` and ensuring entry points (`openbox.py`, `web_app.py`) import `pkg.parity` early.~~ Done in 1.9.0: the `_ParityFlatFinder` is the sole flat-import bridge. Entry points (`openbox.py`) and test files import `pkg.parity` early to register the finder before any `import parity_*`. +- No functional regression; `make check` verifies flat imports still resolve. diff --git a/docs/adr/0004-theme-contract.md b/docs/adr/0004-theme-contract.md new file mode 100644 index 00000000..d3bcd29e --- /dev/null +++ b/docs/adr/0004-theme-contract.md @@ -0,0 +1,33 @@ +# ADR 0004: Theme token contract closure + +Date: 2026-08-20 +Status: Accepted +Updated: 2026-09-04 — token baseline ratcheted 343 → 0 on 2026-08-26 (`scripts/check_tokens.py:7`); all 6 themes are token-only `:root` files. Line refs below (`app.css:1-45`, `library.js:155`) are historical. + +## Context + +`static/app.css:1-45` defines the design system's `:root` tokens (61 colors, 4 radii, 11 fonts). Five stock themes override a subset, but none defined `--surface-deep` (`app.css:5` default `#141311`) which `library.js:155 .library{background:var(--surface-deep)}` relies on, causing warm bleed on cool themes like Midnight Circuit (`bg #070b14`). Raw hex outside `:root` also existed for shadow/overlay colors (`#0006`, `#050403d9`, etc.) counted separately by `scripts/check_tokens.py:8` baseline `343`. + +Visual output must not change, but token hygiene must ratchet. + +## Decision + +- Introduce shadow/overlay tokens in `static/app.css:1-45 :root` for the 17 4/8-digit hex values used outside `:root` and replace each usage with `var(--*)` with identical computed color. Values: `--shadow-cover #0006`, `--shadow-cover-strong #0007`, `--shadow-dialog #000c`, `--shadow-elevated #0009`, `--shadow-cover-selected #000a`, `--shadow-empty #0005`, `--accent-ghost #f0600055`, `--accent-ghost-soft #f0600044`, `--accent-ghost-faint #f060002e`, `--overlay-backdrop #050403d9`, `--overlay-backdrop-strong #05070bd9`, `--overlay-backdrop-soft #05070bcc`, `--overlay-screensaver-start #05070bcf`, `--overlay-screensaver-mid #05070b1f`, `--surface-sheet #171513dd`, `--border-sheet #ffffff55`, `--hero-scrim #0d0b0815`. + +- Add `--surface-deep` to each theme `:root` with theme-intentional deep values: Cinema Marquee `#080606`, Harbor Light `#f5f8fc`, Midnight Circuit `#05080f`, Nordic Mist `#0c1116`, Phosphor Terminal `#020402`. + +- Keep `app.css:5 #141311` fallback and document theme font overrides as intentional. + +- Add `tests/test_frontend_contract.py` asserting every `var(--*)` used after `:root` is defined in `:root` and each theme defines `surface-deep`. + +## Consequences + +- `tests/test_frontend_contract.py` now guards theme contract; ~~`python3 scripts/check_tokens.py` stays at 343 (baseline ratchet deferred until 6-digit theme outside hexes are also tokenized). Next sweep can ratchet baseline down.~~ Ratcheted to 0 on 2026-08-26 after token-only themes landed. + +- No visual regression: hex values preserved via indirection, verified by `content-visibility` and `contain-intrinsic-size` unchanged. + +- Themes now correctly render library and screenshot grid backgrounds per theme palette. + +## Update (1.15) + +There are now six stock themes (High Contrast joined in 1.14). Semantic ink tokens (`--ink-strong`, `--on-active`, `--on-danger`, `--border-input`, `--state-hover`, `--state-press`) replace the use of `--white` as a text colour, and a contrast matrix in `tests/test_stock_themes.py` checks every theme. Motion tokens are structural and must not be redeclared by a theme; see ADR 0063. diff --git a/docs/adr/0005-state-cache-hierarchy.md b/docs/adr/0005-state-cache-hierarchy.md new file mode 100644 index 00000000..d8a20437 --- /dev/null +++ b/docs/adr/0005-state-cache-hierarchy.md @@ -0,0 +1,29 @@ +# ADR 0005: State cache hierarchy and supervision + +Date: 2026-08-20 +Status: Accepted +Updated: 2026-09-04 — landed pieces: `pkg/parity/launch_tokens.py` exists (launch-token centralization done, no longer "future"); `pkg/state/imports.py` and `pkg/state/commands.py` exist (deferred moves done); `pkg/state/registry.py` exists (process-registry extraction done). `webapp_state.py` is now a 288-line thin re-export shim. "573-line hybrid" refs below are historical. + +## Context + +State supervision is split across `pkg/state/cache.py` (7 dicts, 5 locks, file probe TTL 120s/20k, media set 100k), `pkg/state/launch.py` (RUNNING/PROCESSES/SESSION_EVENTS), `pkg/state/media_probe.py`, `pkg/state/sse.py`, and `webapp_state.py` (573-line hybrid shim). Cache invalidation is manual (`bump_media_epoch` clears 6 caches), and `load_state_readonly` returns a mutable reference. Launch placeholder replacement is duplicated in four modules. `webapp_state.py` mixes facade and implementation, handlers import from it instead of `pkg/state`. + +## Decision + +- Canonical owner is `pkg/state/*`; `webapp_state.py` becomes a thin re-export shim retaining `TOKEN`, `ROOT`, `JOB_MANAGER`, `PROCESS_LOCK`, `RUNNING`, `SSE_*` for one release via `from pkg.state.X import`. + +- ~~Introduce `pkg/state/imports.py` (future) for `consolidate_existing_games`, `import_folder_path`, `merge_imported_games`, `auto_import_worker`, `sync_cloud` and `pkg/state/commands.py` for launch commands; defer move to a dedicated PR to avoid import-graph churn.~~ Done: both modules exist; identity/merge helpers live in `pkg/state/imports.py` (no separate `game_identity.py`, see ADR 0008 update). + +- ~~Centralize launch token table in `pkg/parity/launch_tokens.py` (future) deduplicating `openbox.py:102`, `webapp_state.py:119`, `pkg/state/launch.py:302`, `parity_emulator_defs.py:112`.~~ Done: `pkg/parity/launch_tokens.py` exists. + +- Harden `load_state_readonly` toward `MappingProxyType` or copy-on-write detection; add `CacheEpoch` dataclass to group epochs and single `_invalidate_all()` replacing manual 6-lock sequence. + +- Defer high-risk extraction of process registry `RUNNING/PROCESSES/SESSION_EVENTS/PROCESS_LOCK` into `pkg/state/registry.py` with typed `Session` dataclass until a dedicated stability PR with migration test for `active_sessions` JSON. + +## Consequences + +- No behavior change this release; only `pkg/parity/__init__.py` `_ParityFlatFinder` lands (ADR 0003 companion) proving flat import bridge. + +- `runtime_modules.txt` drifts are now caught by `scripts/check_runtime_modules.py` (Stage 2.3 in `check_tests.py`). + +- Follow-up PRs will move imports/commands and harden caches with new `tests/test_state_cache.py` covering proxy rejection and epoch invalidation. diff --git a/docs/adr/0006-gate-completion.md b/docs/adr/0006-gate-completion.md new file mode 100644 index 00000000..a0a10f39 --- /dev/null +++ b/docs/adr/0006-gate-completion.md @@ -0,0 +1,29 @@ +# ADR 0006: Gate completion and frontend coverage + +Date: 2026-08-20 +Status: Accepted +Updated: 2026-09-04 — `API_V1` now lives at `static/util.js:120` (not :115); the v1 surface is frozen at 60 contract entries (`v1_contracts.json`). The stale `check_tests.py` "parallel workers" comment flagged below is fixed as of this update. + +## Context + +`scripts/check_tests.py` ran ruff, v1 contract, version sync, py_compile, coverage, and tokens, but not frontend lint (`scripts/check_frontend.py` was orphaned, zero refs) and not runtime module drift (`runtime_modules.txt` manual). CI duplicated eslint inline (`ci.yml:71`) while `make check` never linted JS. `API_V1` in `static/util.js:115` (now :120) covered 38 routes while 15 legacy paths (`/api/premium/media-packs`, `/api/gameyfin/test`, `/api/themes/open-folder`, `/api/media/cleanup`, `/api/bigbox/mode`, etc.) stayed on fallback via `state.js:54 target=API_V1[..]||path`. The v1 surface is now frozen at 60 contract entries. + +## Decision + +- Wire `check_frontend.py` as Stage 2.6 in `check_tests.py` and `check_runtime_modules.py` as Stage 2.3. `check_runtime_modules.py` asserts every line exists, no duplicates, and required globs `handlers/*.py`, `pkg/state/*.py`, `pkg/parity/*.py`, `routes/*.py` are listed. + +- Fix `check_frontend.py` to prefer `scripts/eslint.config.mjs` then `static/eslint.config.mjs` and not silently degrade when npm is present on CI. + +- Extend `routes.py:V1_ALIASED_PREFIXES` with 14 legacy prefixes: `premium/media-packs`, `premium/media-packs/apply`, `storefront/import`, `gameyfin/test`, `import/scummvm`, `import/rpcs3`, `import/vita3k`, `themes/open-folder`, `ra/inject`, `media/cleanup`, `saves/scan/apply`, `bigbox/mode`, `games/bulk-wizard`, `extra/launch`. + +- Extend `static/util.js:API_V1` with matching snake_case keys (`premium_media_packs`, `gameyfin_test`, etc.) so `state.js:api` auto-translates via `API_V1[path.replace...]||path` with fallback for one release. + +- Fix `check_tests.py` comment claiming parallel workers while loop is serial due to gamescope X collision; keep serial. + +## Consequences + +- `make check` and CI `gate` now run the same frontend lint; orphan removed. Required drift fails the gate before AppImage ships. + +- 14 v1 routes become available as additive aliases; `gen_v1_contracts.py` and `check_v1_contract.py` pass without version bump; legacy paths still work via fallback. + +- No handler renamed, no route deleted, no coverage floor lowered. diff --git a/docs/adr/0007-lock-ordering.md b/docs/adr/0007-lock-ordering.md new file mode 100644 index 00000000..d5e41850 --- /dev/null +++ b/docs/adr/0007-lock-ordering.md @@ -0,0 +1,103 @@ +# ADR-0007: Lock ordering for state, process, and file synchronization + +Status: Accepted +Date: 2026-08-22 + +## Context + +OpenBox uses three independent synchronization primitives to protect +concurrent access to shared state: + +1. **PROCESS_LOCK** (`pkg/state/launch.py`) — a `threading.Lock` guarding + the in-memory process registry (`RUNNING`, `PROCESSES`). +2. **STATE_LOCK** (`pkg/state/cache.py`) — a `threading.Lock` guarding + cache-coherent reads of the persisted JSON state and all projection + caches built on top of it. +3. **_file_lock** (`state_store.py`) — an `fcntl.flock()` advisory file + lock serializing reads and writes to `library.json` on disk. + +A fourth, finer-grained lock — `_thread_lock` (`threading.RLock` inside +`JsonStateStore`) — protects the in-memory cache within a single store +instance and is always acquired before `_file_lock`. + +Multiple threads touch these locks concurrently: HTTP request handlers +through `transact_state`, game launch watchers through `finish_session`, +SSE publishers through `_build_public_state`, and background plugin +refreshers through `_refresh_plugins`. A lock-ordering violation between +any two of the three outer locks would create a deadlock window. + +## Decision + +Adopt and enforce a strict **sequential non-nesting** policy for the three +outer locks: + +``` +PROCESS_LOCK ←→ STATE_LOCK ←→ _file_lock +``` + +No thread may hold two of these three locks simultaneously. Each is +acquired, used, and released before any other outer lock is acquired. + +Within `state_store.py`, the internal ordering is: + +``` +_thread_lock → _file_lock +``` + +`_thread_lock` is always acquired first; `_file_lock` is always nested +inside it. This pairing is confined to `JsonStateStore` and never leaks +across module boundaries. + +When `STATE_LOCK` is held and a state read or write is required, the +caller invokes `load_state_readonly()` or `update_state()` which acquire +`_thread_lock` → `_file_lock` internally. This is safe because +`PROCESS_LOCK` is never held at the same time. + +The lock-acquisition order in the two main concurrency paths is: + +| Path | Order | +|---|---| +| HTTP handler → `transact_state` | STATE_LOCK → _thread_lock → _file_lock → (release all) → cache locks | +| Watcher thread → `finish_session` | PROCESS_LOCK → (release) → STATE_LOCK → _thread_lock → _file_lock → (release all) → PROCESS_LOCK | + +Cache-level locks (`PLUGIN_LIBRARY_LOCK`, `PUBLIC_STATE_LOCK`, +`PUBLIC_SETTINGS_LOCK`, `STATE_VIEW_LOCK`, etc.) are leaf locks acquired +only after `STATE_LOCK` is released, or in `_refresh_plugins` where they +are nested inside `STATE_LOCK` but never inside `PROCESS_LOCK`. + +## Consequences + +- **Deadlock prevention**: the three outer locks can never form a cycle + because no thread holds two of them at once. +- **Performance**: cache-level locks remain fine-grained and do not + contend with the process registry. +- **Testability**: new code acquiring any of these three locks must + demonstrate it does not hold another; review checklist updated. +- **Readability**: inline `///< lock ordering:` comments at every + acquisition site document the constraint at the point of use. + +## Addendum (1.16.1): the coalesced-write lock + +`state_store.py` adds a fourth lock to the in-process order, `_coalesce_lock`, which +guards the pending coalesced snapshot and its flush timer. The order inside a store is: + +``` +_thread_lock -> _file_lock -> _coalesce_lock +``` + +Two rules follow from it: + +- A full write (`_write_unlocked`) clears the pending coalesced snapshot, because the + in-memory state it was written from already carries that mutation. Flushing the + pending snapshot after a full write would roll the newer commit back. +- A coalesced mutation publishes its pending snapshot while still holding + `_thread_lock`, so a full commit cannot land between the mutation and its + publication. + +Trade-off: when another process changed `library.json`, a full write reloads the file +from disk, and a coalesced mutation that had not yet flushed is dropped rather than +written over the other process's change. Before 1.16.1 the opposite happened and that +change was lost. The drop is intended; it is recorded here so a later reader does not +restore the old behaviour. + +Covered by `tests/test_perf_writes.py` (`CoalesceCommitOrderingTests`). diff --git a/docs/adr/0008-webapp-state-decomposition.md b/docs/adr/0008-webapp-state-decomposition.md new file mode 100644 index 00000000..4e45d18e --- /dev/null +++ b/docs/adr/0008-webapp-state-decomposition.md @@ -0,0 +1,142 @@ +# ADR-0008: Decomposition of webapp_state.py into focused state modules + +Status: accepted (implementation diverged — see note) +Date: 2026-08-22 +Updated: 2026-09-04 — implemented differently than specified below: `pkg/state/game_identity.py` and `pkg/state/cloud.py` were never created. Identity/merge helpers (`game_identity`, `consolidate_existing_games`, …) and `sync_cloud` live in `pkg/state/imports.py`; `clean_commands`/`run_configured_commands` live in `pkg/state/commands.py`. `pkg/state/` now holds 11 modules (cache, commands, _deps, imports, launch, media_probe, operations, registry, sqlite_readmodel, sse, __init__) and `webapp_state.py` is a 288-line thin re-export shim (not ~80 lines). The module table, file diagram, and "573 → ~80 lines" claims below describe the proposal, not the outcome. + +## Context + +`webapp_state.py` is a 573-line hybrid shim that owns the mutable module state +(TOKEN, locks, caches) and the service helpers that `web_app.Handler` mixin +methods and `web_app.main()` call as bare names. `web_app.py` and `handlers/*.py` +import from it so every reference resolves statically. + +The file currently mixes three concerns: + +1. **Module-level state** -- `TOKEN`, `ROOT`, `LOGGER`, `INSTALLS`, `METADATA_JOB`, + `MEDIA_JOB`, `JOB_MANAGER`, `WATCH_STOP` and ~120 re-exported names from + `pkg.state.cache`, `pkg.state.launch`, `pkg.state.media_probe`, and + `pkg.state.sse`. + +2. **Game identity and consolidation** -- `game_identity`, `_launcher_label`, + `_filled_launch_command`, `_application_for_game`, `_append_unique_application`, + `_merge_source_fields`, `_merge_imported_game`, `_index_existing_games`, and + `consolidate_existing_games`. These functions encode how games from different + sources (Steam, Heroic, Lutris, Gameyfin, Faugus, arcade ROMs) are identified, + deduplicated, and merged. + +3. **Import orchestration** -- `import_folder_path`, `merge_imported_games`, and + `auto_import_worker`. These coordinate filesystem scanning, storefront imports + (Steam/Heroic/Lutris/Gameyfin), emulator scan configs, and the auto-import + watch loop. + +4. **Cloud sync and commands** -- `sync_cloud`, `clean_commands`, + `run_configured_commands`. These are standalone service functions with minimal + coupling to the identity/import logic. + +ADR 0005 identified the need for this decomposition but deferred it to avoid +import-graph churn. This ADR specifies the concrete decomposition strategy. + +## Decision + +Decompose `webapp_state.py` into four focused modules under `pkg/state/` plus a +thin re-export shim at the original location. + +### Target modules + +| Module | Responsibility | Functions extracted | +|---|---|---| +| `pkg/state/game_identity.py` | Game identity resolution, deduplication, and merge logic | `game_identity`, `_launcher_label`, `_filled_launch_command`, `_application_for_game`, `_append_unique_application`, `_merge_source_fields`, `_merge_imported_game`, `_index_existing_games`, `consolidate_existing_games` | +| `pkg/state/imports.py` | Import orchestration: folder scanning, storefront imports, auto-import watch loop | `import_folder_path`, `merge_imported_games`, `auto_import_worker` | +| `pkg/state/commands.py` | Launch command validation and execution | `clean_commands`, `run_configured_commands` | +| `pkg/state/cloud.py` | Cloud save synchronization | `sync_cloud` | + +### Re-export shim + +`webapp_state.py` becomes a thin re-export shim that: + +1. Retains `TOKEN`, `ROOT`, `LOGGER`, `INSTALLS`, `METADATA_JOB`, `MEDIA_JOB`, + `JOB_MANAGER`, `WATCH_STOP` as module-level state (these are the process-global + singletons that other modules reference by name). +2. Re-imports every currently-exported name from its new canonical location so + that `from webapp_state import game_identity` continues to resolve. +3. Retains all `pkg.state.cache`, `pkg.state.launch`, `pkg.state.media_probe`, + and `pkg.state.sse` re-exports unchanged -- these are facade aliases already + owned by `pkg/state/`, not decomposition targets. + +### Dependency direction + +``` +webapp_state.py (thin shim, re-exports only) + |-- pkg/state/game_identity.py (imports from parity_identity) + |-- pkg/state/imports.py (imports game_identity, importers, parity_import, ...) + |-- pkg/state/commands.py (imports openbox.load_state) + +-- pkg/state/cloud.py (imports cloud_sync, openbox.load_state, openbox.update_state) +``` + +No circular dependencies: `game_identity` depends only on `parity_identity` and +standard library. `imports` depends on `game_identity` and external parity modules. +`commands` and `cloud` are leaf modules with no intra-package deps. + +## Phased migration + +### Phase 1: Extract (no behavioral change) + +1. Create `pkg/state/game_identity.py` with the 9 identity/merge functions. + Move their imports (`parity_identity.cross_source_identity`, + `parity_identity.source_family`, `parity_identity.source_identities`, + `parity_storefront.catalog_entries_to_games`) into the new module. +2. Create `pkg/state/imports.py` with `import_folder_path`, + `merge_imported_games`, `auto_import_worker`. Move their imports + (`importers.*`, `parity_import.*`, `parity_import_policy.*`, + `parity_emulator_defs.*`, `parity_gameyfin.*`, `parity_storefront.*`, + `parity_media.normalize_video_fields`, `parity_media.enqueue_media_job`, + `parity_media.media_types_from_settings`, `catalog.apply_progress_automation`) + into the new module. +3. Create `pkg/state/commands.py` with `clean_commands`, `run_configured_commands`. +4. Create `pkg/state/cloud.py` with `sync_cloud`. Move `cloud_sync.sync_statistics` + import here. +5. Update `webapp_state.py` to import everything from the new modules and + re-export under the same names. + +### Phase 2: Update callers + +1. Update `handlers/*.py` and `web_app.py` to import directly from `pkg.state.*` + modules where appropriate, bypassing the shim. +2. Update `tests/` imports to exercise the new canonical paths. + +### Phase 3: Thin the shim + +1. Remove dead imports from `webapp_state.py` that are no longer needed by any + remaining caller. +2. Add a deprecation comment to the shim indicating new code should import from + `pkg.state.*`. + +## Rollback strategy + +Each phase is independently revertible: + +- **Phase 1 rollback**: Delete the four new modules, restore `webapp_state.py` + from git. No caller changes, no behavioral difference. +- **Phase 2 rollback**: Revert caller import changes. The shim still re-exports + everything, so all callers continue to work. +- **Phase 3 rollback**: Re-add removed imports to the shim. Callers importing + from the shim still work. + +The shim is never deleted in this ADR -- it remains as a compatibility layer +for one release, consistent with the parity shim approach in ADR 0003. + +## Consequences + +- **Positive**: `webapp_state.py` shrinks from ~573 lines to ~80 lines (module + state + re-exports). Each new module has a single responsibility and can be + understood, tested, and modified in isolation. +- **Positive**: Import graph becomes acyclic and explicit. The identity logic + no longer pulls in `cloud_sync`, `importers`, and `parity_import` at module + load time. +- **Positive**: `auto_import_worker` can be tested without importing the full + state shim, reducing test isolation problems. +- **Negative**: Four new files to maintain. The re-export shim adds a layer of + indirection that can confuse grep-based navigation. +- **Neutral**: No runtime behavior change. All existing callers continue to + resolve names through the shim until Phase 2 updates them. diff --git a/docs/adr/0009-ns-removal-migration.md b/docs/adr/0009-ns-removal-migration.md new file mode 100644 index 00000000..9a968450 --- /dev/null +++ b/docs/adr/0009-ns-removal-migration.md @@ -0,0 +1,260 @@ +# ADR-0009: Removal of `_ns()` late-binding pattern via dependency injection + +Status: accepted +Updated: 2026-09-04 — Phase 1 landed in 1.9.0: `pkg/state/_deps.py` central registry created, `webapp_state._populate_deps()` registers all exported names at import time, the 4 `_ns()` helpers now delegate to `_deps.get()` with `webapp_state` patching as a backward-compat first check. +Date: 2026-08-22 + +## Context + +`pkg/state/` modules (`cache.py`, `launch.py`, `media_probe.py`, `sse.py`) +resolve cross-module names at call time through an identical `_ns()` helper +duplicated in each of the four files: + +```python +def _ns(name, default): + mod = sys.modules.get("webapp_state") + if mod is not None and hasattr(mod, name): + return getattr(mod, name) + return default +``` + +`_ns()` performs a runtime `getattr` against the `webapp_state` module on every +call. When `webapp_state` is loaded, the resolved name wins; otherwise the +local default is used. This pattern exists to break circular imports: +`webapp_state.py` imports from every `pkg/state/*` module, so those modules +cannot import back from `webapp_state` at module scope. + +**Scale of the problem** + +| Module | `_ns()` call sites | Representative names resolved | +|---|---|---| +| `pkg/state/cache.py` | 24 | `DATA`, `STATE_LOCK`, `load_state`, `load_state_readonly`, `run_plugins`, `_build_public_state`, `_public_state_cached` | +| `pkg/state/launch.py` | 47 | `DATA`, `STATE_LOCK`, `load_state`, `update_state`, `resolve_library_game`, `finish_session`, `sync_cloud`, `run_plugins`, 20+ more | +| `pkg/state/media_probe.py` | 11 | `DATA`, `approved_media_path`, `probe_path`, `download_file`, `bump_media_epoch` | +| `pkg/state/sse.py` | 10 | `load_state`, `transact_state`, `PROCESS_LOCK`, `SESSION_EVENTS`, `broadcast_event`, `emit_notification`, `publish_event` | +| **Total** | **92** | Plus 4 identical function definitions (one per module) | + +**Why this is harmful** + +1. **Performance** -- every `_ns()` call traverses `sys.modules` and performs + `hasattr`/`getattr`. In hot paths (`_build_public_state`, + `_public_state_cached`, `transact_state`) this adds measurable overhead. +2. **Untraceable dependencies** -- static analysis, IDEs, and `grep` cannot + resolve `_ns("resolve_library_game", ...)` to its definition. Module + contracts are implicit and fragile. +3. **Type safety** -- every `_ns()` result is untyped. The default value is + the only type hint, and it is silently discarded when `webapp_state` wins. +4. **Duplication** -- the identical 4-line `_ns()` function is copy-pasted + across four files. +5. **Test isolation** -- testing a `pkg/state/*` module in isolation requires + either loading `webapp_state` (pulling in the full import graph) or + accepting that `_ns()` always returns the default. + +## Decision + +Replace all 92 `_ns()` call sites with explicit dependency injection. Each +module that currently calls `_ns()` will receive its dependencies through a +companion `_deps.py` registry module that is populated once at startup and +queried at call time. This preserves late binding (needed to break the import +cycle) while making the dependency graph explicit, typed, and greppable. + +### Architecture + +``` +pkg/state/_deps.py -- central dependency registry (typed dict) +pkg/state/cache.py -- uses deps.get() instead of _ns() +pkg/state/launch.py -- uses deps.get() instead of _ns() +pkg/state/media_probe.py -- uses deps.get() instead of _ns() +pkg/state/sse.py -- uses deps.get() instead of _ns() +webapp_state.py -- populates deps after all pkg/state/* loaded +``` + +### The registry (`pkg/state/_deps.py`) + +```python +"""Central dependency registry for pkg/state/ modules. + +Breaks the circular import between pkg/state/* and webapp_state.py +by providing a typed lookup that is populated once at startup. +""" + +from __future__ import annotations +from typing import Any, Callable, Dict + +_registry: Dict[str, Any] = {} + + +def register(name: str, value: Any) -> None: + """Register a dependency by name. Called once from webapp_state.py.""" + _registry[name] = value + + +def get(name: str, default: Any = None) -> Any: + """Look up a dependency. Returns *default* if not yet registered.""" + return _registry.get(name, default) +``` + +### Call-site transformation + +Before (current): + +```python +load_fn = _ns("load_state", load_state) +state = load_fn() +``` + +After: + +```python +from pkg.state import deps + +load_fn = deps.get("load_state", load_state) +state = load_fn() +``` + +The signature is identical: `deps.get("name", local_default)` replaces +`_ns("name", local_default)`. The local default remains as the fallback so +modules can be imported and tested without `webapp_state` being loaded. + +### Population site (`webapp_state.py`) + +After all `pkg.state.*` imports, `webapp_state.py` calls `register()` for each +name that `_ns()` currently resolves: + +```python +from pkg.state import deps + +deps.register("load_state", load_state) +deps.register("update_state", update_state) +deps.register("DATA", DATA) +deps.register("STATE_LOCK", STATE_LOCK) +# ... one register() per resolved name +``` + +This is a mechanical extraction of the current import list in `webapp_state.py`. + +## Options considered + +| Option | Verdict | Reason | +|---|---|---| +| A. Central registry (`_deps.py`) | **Chosen** | Explicit, greppable, typed, single registration site, minimal diff per module | +| B. Constructor injection | Rejected | Would require threading dependency objects through 40+ function signatures and every caller; massive API churn | +| C. Module-level `__getattr__` hook | Rejected | Replaces one implicit mechanism with another; no type safety; still untraceable | +| D. Keep `_ns()` and add type stubs | Rejected | Band-aid; does not solve performance, duplication, or test isolation | +| E. Split `webapp_state.py` then direct import | Deferred | ADR 0008 decomposition is prerequisite; after decomposition, some deps may become direct imports and drop from the registry | + +## Phased migration + +### Phase 1: Create `_deps.py` and wire population (no behavior change) + +1. Create `pkg/state/_deps.py` with `register()` and `get()`. +2. In `webapp_state.py`, add a `_populate_deps()` function that registers every + name currently resolved by `_ns()`. Call it at the bottom of + `webapp_state.py` after all imports. +3. Add `tests/test_deps_registry.py` verifying: + - `get()` returns `None` (or default) when a name is not registered. + - `register()` then `get()` round-trips the value. + - Duplicate `register()` overwrites the previous value. +4. `make check` must pass. + +### Phase 2: Migrate `pkg/state/sse.py` (10 sites) + +`sse.py` has the fewest call sites and no intra-package `_ns()` dependencies on +other `pkg/state/*` modules, making it the safest starting point. + +1. Add `from pkg.state import deps` to `sse.py`. +2. Replace each `_ns("name", default)` with `deps.get("name", default)`. +3. Delete the `_ns()` function from `sse.py`. +4. Add/update tests in `tests/test_sse.py` covering: + - Functions that resolve `load_state` from deps vs. local default. + - Functions that resolve `transact_state` from deps vs. local default. + - Functions that resolve `PROCESS_LOCK` and `SESSION_EVENTS`. +5. `make check` must pass. + +### Phase 3: Migrate `pkg/state/media_probe.py` (11 sites) + +1. Same mechanical replacement as Phase 2. +2. Delete `_ns()` from `media_probe.py`. +3. Update `tests/test_media_probe.py` covering: + - `approved_media_path` resolution from deps. + - `probe_path` resolution from deps. + - `DATA` resolution from deps. + - `download_file` and `bump_media_epoch` resolution. +4. `make check` must pass. + +### Phase 4: Migrate `pkg/state/cache.py` (24 sites) + +1. Replace all `_ns()` calls with `deps.get()`. +2. Delete `_ns()` from `cache.py`. +3. Update `tests/test_state_cache.py` covering: + - `load_state`, `load_state_readonly`, `update_state_with_result` resolution. + - `run_plugins` resolution. + - `DATA`, `STATE_LOCK` resolution. + - `_build_public_state`, `_public_state_cached` resolution. + - Cache epoch invalidation with deps active. +4. `make check` must pass. + +### Phase 5: Migrate `pkg/state/launch.py` (47 sites) + +`launch.py` has the most call sites and the widest dependency surface. + +1. Replace all `_ns()` calls with `deps.get()`. +2. Delete `_ns()` from `launch.py`. +3. Update `tests/test_launch.py` (or equivalent) covering: + - `start_game` 8-phase launch sequence with deps-resolved functions. + - `finish_session` with deps-resolved `load_state`, `update_state`, + `run_plugins`, `sync_cloud`. + - `control_game_session` with deps-resolved `session_event`. + - `resolve_library_game` with deps-resolved `load_state`. + - `reconcile_sessions_on_startup` with deps-resolved `_verify_process_identity`. +4. `make check` must pass. + +### Phase 6: Remove `webapp_state.py` re-export shim (optional, per ADR 0008) + +After all `_ns()` sites are migrated, the reason for `webapp_state.py`'s role as +a late-binding intermediary is removed for the `pkg/state/*` modules. This phase +is optional and gated on the completion of ADR 0008 decomposition: + +1. Remove `_populate_deps()` calls from `webapp_state.py` if all consumers now + import directly from `pkg.state.*`. +2. Delete `pkg/state/_deps.py` if no late binding is needed. +3. Or keep the registry as the canonical late-binding mechanism if ADR 0008 + decomposition is not yet complete. + +## Test strategy + +Each phase follows strict TDD: + +1. **Before migration**: write a test that calls the function under test with + `_ns()` active (i.e., with `webapp_state` loaded). Assert the resolved + dependency is used. +2. **During migration**: verify that `deps.get()` returns the same value that + `_ns()` returned. The test suite must not change behavior. +3. **After migration**: write a test that calls the function with `webapp_state` + **not** loaded, verifying the local default is used. This is the test that + was impossible with `_ns()` (it always loaded `webapp_state`). +4. **Regression**: every phase must pass `make check` (ruff, py_compile, full + test suite, coverage floors). + +### Coverage target + +Each migrated module must maintain or improve its current coverage. The new +`tests/test_deps_registry.py` must achieve 100% coverage on `_deps.py`. + +## Consequences + +- **Positive**: 92 implicit `sys.modules` lookups replaced with an explicit, + greppable `deps.get()` call. Static analysis and IDEs can trace every + dependency. +- **Positive**: `_ns()` function (4 identical copies) is deleted. One registry + module replaces four duplicated helpers. +- **Positive**: Each `pkg/state/*` module can be tested in isolation by not + populating the registry, exercising the local-default path. +- **Positive**: Performance improves in hot paths where `_ns()` was called per + invocation (`_build_public_state`, `transact_state`, `start_game`). +- **Negative**: One new file (`_deps.py`) to maintain. Registration in + `webapp_state.py` adds ~55 lines of mechanical `register()` calls. +- **Neutral**: No runtime behavior change. The local-default fallback ensures + identical semantics whether `webapp_state` is loaded or not. +- **Neutral**: If ADR 0008 decomposition completes, some `deps.get()` calls may + be replaced with direct imports, shrinking the registry over time. diff --git a/docs/adr/0010-v17-setup-preview-v2.md b/docs/adr/0010-v17-setup-preview-v2.md new file mode 100644 index 00000000..f4b9dc88 --- /dev/null +++ b/docs/adr/0010-v17-setup-preview-v2.md @@ -0,0 +1,80 @@ +# ADR 0010: v1.7 setup preview/commit and additive v2 contracts + +Date: 2026-08-25 +Status: Accepted + +## Context + +OpenBox v1.7 introduces a Library Setup Center that must preview imports and metadata work without mutating canonical state, then commit idempotently. The release adds exact v2 routes alongside every frozen `/api/v1/*` and legacy path. `library.json` must remain schema version **6** for rollback compatibility; preview data lives in separate atomic files outside canonical storage. + +## Decision + +### Preview and commit lifecycle + +- **Scan preview** (`POST /api/v2/setup/preview`) performs read-only classification: no writes to `library.json`, no installs, no archive extraction, no media downloads, and no ROM-folder mutations. +- Previews are stored in private atomic files under the OpenBox data directory, retained at most **10** previews for **24 hours**; active jobs keep their preview alive. +- Preview size is capped at **100,000** scanned entries; exceeding the cap returns a structured error rather than silent truncation. +- Results paginate via opaque cursors bound to preview ID and revision (`GET /api/v2/setup/preview/items`). +- Review decisions apply in batches of at most **200** (`POST /api/v2/setup/preview/decisions`). +- **Revalidate** (`POST /api/v2/setup/preview/revalidate`) compares source fingerprints and library signature before commit. +- **Commit** (`POST /api/v2/setup/commit`) stages generated files under the data directory, atomically promotes them, then performs one state transaction. Commit is idempotent: retrying the same preview cannot duplicate games or repeat completed merges. +- **Summary** (`GET /api/v2/setup/summary`) exposes library overview metrics for the Setup Center entry step. +- Expired or corrupt previews are safely discardable and never affect the library. + +### Additive exact v2 routes + +All routes require the existing OpenBox token and return structured request IDs on errors. Every frozen v1 route and legacy path remains registered unchanged. + +| Method | Path | +|---|---| +| GET | `/api/v2/setup/summary` | +| POST | `/api/v2/setup/preview` | +| GET | `/api/v2/setup/preview` | +| GET | `/api/v2/setup/preview/items` | +| POST | `/api/v2/setup/preview/decisions` | +| POST | `/api/v2/setup/preview/revalidate` | +| POST | `/api/v2/setup/commit` | +| GET | `/api/v2/emulators/registry` | +| POST | `/api/v2/launch/preflight` | +| POST | `/api/v2/launch/preflight/batch` | +| POST | `/api/v2/metadata/matches/preview` | +| GET | `/api/v2/metadata/matches/preview` | +| GET | `/api/v2/metadata/matches/items` | +| POST | `/api/v2/metadata/matches/decisions` | +| POST | `/api/v2/metadata/matches/apply` | +| GET | `/api/v2/jobs` | +| GET | `/api/v2/jobs/items` | +| POST | `/api/v2/jobs/cancel` | +| POST | `/api/v2/jobs/retry` | +| POST | `/api/v2/jobs/resume` | + +Routes are exact, non-parameterized paths compatible with the current router. Route registration must fail on duplicate method/path pairs. + +### Stable error codes + +| Code | Typical use | +|---|---| +| `PREVIEW_NOT_FOUND` | Preview ID missing or already discarded | +| `PREVIEW_EXPIRED` | Preview past retention window | +| `PREVIEW_STALE` | Source fingerprints changed since last validation | +| `PREVIEW_LIBRARY_CHANGED` | Library signature changed since preview classification | +| `UNRESOLVED_CANDIDATES` | Commit blocked by unresolved review decisions | +| `AMBIGUOUS_PLATFORM` | Scanner cannot resolve platform without explicit choice | +| `EMULATOR_REQUIRED` | Launch readiness blocked by missing emulator/adapter | +| `LAUNCH_PREFLIGHT_BLOCKED` | Launch Doctor found blocking checks | +| `JOB_STATE_CONFLICT` | Operation not in expected state for requested action | +| `JOB_NOT_CANCELLABLE` | Operation in unsafe commit/restore phase | +| `JOB_NOT_RESUMABLE` | No checkpoint-safe resume point | +| `CLOUD_REMOTE_INVALID` | Remote cloud JSON invalid or unreadable | + +### Schema version 6 + +- Canonical `library.json` schema version stays **6**. No schema bump in v1.7. +- Preview files, operation metadata, and import-batch tags are additive disposable storage or backward-compatible unknown fields (`emulator_id`, `emulator_adapter_id`, `import_batch_id`, bounded metadata-candidate rejection records). +- `emulator_def` remains a compatibility alias. + +## Consequences + +- Setup Center can trust preview/commit separation without breaking rollback to pre-1.7 state files. +- v2 contracts are documented and gated; v1 contract checks continue to pass without version bump. +- No frozen v1 path is dropped; no canonical schema migration is required. diff --git a/docs/adr/0011-durable-operations.md b/docs/adr/0011-durable-operations.md new file mode 100644 index 00000000..2b8b2921 --- /dev/null +++ b/docs/adr/0011-durable-operations.md @@ -0,0 +1,79 @@ +# ADR 0011: Durable operations and legacy jobs adapter + +Date: 2026-08-25 +Status: Accepted + +## Context + +OpenBox v1.7 replaces parallel in-memory job maps with one authoritative operation service for Setup scans, metadata work, media downloads, emulator installs, backups, and cloud sync. Long-running work must survive restarts, expose truthful cancellation/retry/resume semantics, and remain visible in the Activity Center. Existing clients still call `/api/jobs` and `/api/v1/jobs`. + +## Decision + +### Separate storage + +- Persist nonsecret operation metadata in **`operations.json`**, separate from `library.json`. +- Write files with mode **0600** and atomic replacement. +- Exclude previews and operation history from portable exports by default. +- Store detailed per-item failures separately with pagination; bounded error summaries on the operation record. + +### Operation model + +Each operation contains: `job_id`, immutable `root_job_id`, optional `retry_of` / `resume_of`; type and human-readable title; state, phase, current, total, message; created, started, updated, and finished timestamps; `can_cancel`, `can_retry`, and `can_resume`; sanitized input summary and checkpoint; result counts. + +### Lifecycle states + +| State | Meaning | +|---|---| +| `queued` | Accepted, not yet running | +| `running` | Active work | +| `cancelling` | Cancel acknowledged; stops after current bounded I/O | +| `done` | Completed successfully | +| `partial` | Completed with recoverable failures | +| `error` | Terminal failure | +| `cancelled` | Cancelled before unsafe phases | +| `interrupted` | Was queued/running/cancelling at prior shutdown | + +On startup, previously `queued`, `running`, or `cancelling` operations convert to **`interrupted`**. Resume only checkpoint-safe operations; otherwise expose Retry. Retry creates a new attempt linked via `retry_of`, preserving history. + +### Retention + +- Keep at most **100** operations. +- Drop completed items older than **30** days. + +### Runtime behavior + +- Persist progress at phase changes and no more than twice per second. +- Emit SSE at a bounded rate; always emit terminal events. +- Never claim an operation was cancelled while an unsafe commit/restore phase is already being atomically promoted. + +### SSE event additions + +- `job.queued` +- `job.progress` +- `job.cancelling` +- `job.finished` +- `job.interrupted` + +### v2 jobs API + +| Method | Path | +|---|---| +| GET | `/api/v2/jobs` | +| GET | `/api/v2/jobs/items` | +| POST | `/api/v2/jobs/cancel` | +| POST | `/api/v2/jobs/retry` | +| POST | `/api/v2/jobs/resume` | + +Structured errors use `JOB_STATE_CONFLICT`, `JOB_NOT_CANCELLABLE`, and `JOB_NOT_RESUMABLE` (see ADR 0010). + +### Legacy adapter + +- Existing **`/api/jobs`** and **`/api/v1/jobs`** remain available through an adapter to the new operation model. +- Adapter translates list/cancel/retry/resume semantics without duplicating authoritative state. + +## Consequences + +- Activity Center reads one durable snapshot, then subscribes to SSE. +- Partial success remains visibly distinct from success and failure. +- Legacy job consumers keep working through the adapter; it remains in place at + 1.14.0 with no removal scheduled. diff --git a/docs/adr/0012-emulator-registry.md b/docs/adr/0012-emulator-registry.md new file mode 100644 index 00000000..ccef2f5d --- /dev/null +++ b/docs/adr/0012-emulator-registry.md @@ -0,0 +1,58 @@ +# ADR 0012: Emulator registry schema and ambiguity policy + +Date: 2026-08-25 +Status: Accepted + +## Context + +Emulator detection, platform mapping, and Launch Doctor checks currently draw from duplicated constants and legacy strings. v1.7 makes versioned YAML in `emulator_defs/` the source of truth while keeping a dependency-free fallback parser and preserving custom user profiles. + +## Decision + +### Flat adapter-per-emulator-platform YAML + +- One **flat adapter definition per emulator/platform combination** in `emulator_defs/`. +- Each adapter defines: schema, adapter ID, emulator ID, display name, and platform; supported extensions; native executable and Flatpak application ID; `startup_args` as an argument list; optional core, BIOS, firmware, and filesystem requirements; detection patterns and priority hints. +- Group adapters by emulator ID at load time. +- Generate compatibility views for existing `EMULATORS`, recommendation, profile-discovery, and YAML APIs from the registry instead of contradictory constants. +- Read legacy `startup` strings for one release; validate and compile them into argument arrays. +- **`emulator_def`** remains a compatibility alias. + +### Fallback parser + +- Registry loading stays dependency-free and compatible with the existing fallback parser. +- Invalid definitions fail validation at load time with actionable errors. + +### Ambiguity policy + +- Extensions shared by multiple systems—especially **`.iso`**—return **every valid candidate** instead of silently selecting the first definition. +- An explicit scan configuration’s emulator/platform always wins. +- Auto-import must pass its saved `emulator_id` into the scanner. +- RetroArch adapters must specify platform-appropriate cores; no SNES9x command may be reused across every RetroArch platform. +- Missing cores, BIOS, or firmware are actionable Launch Doctor checks, never silent fallback. + +### Custom profiles + +- **Custom user profiles always win** and must never be overwritten by registry updates or imports. + +### Launch precedence + +Resolved launch command order: + +1. Per-game explicit launch command +2. Per-game selected launch profile/adapter +3. User-configured platform profile +4. Detected registry adapter +5. Direct executable fallback when valid + +### Registry and preflight API + +- **`GET /api/v2/emulators/registry`** exposes the registry-derived compatibility view. +- **`POST /api/v2/launch/preflight`** and **`POST /api/v2/launch/preflight/batch`** run Launch Doctor checks without launching. +- Blocking preflight returns **`LAUNCH_PREFLIGHT_BLOCKED`**; missing emulator selection returns **`EMULATOR_REQUIRED`**; unresolved platform choice returns **`AMBIGUOUS_PLATFORM`** (see ADR 0010). + +## Consequences + +- Import preview, emulator install, and Launch Doctor share one authoritative registry. +- Shared extensions no longer mis-launch due to silent first-match selection. +- User customization survives registry refreshes unchanged. diff --git a/docs/adr/0013-artifact-gates.md b/docs/adr/0013-artifact-gates.md new file mode 100644 index 00000000..160730c9 --- /dev/null +++ b/docs/adr/0013-artifact-gates.md @@ -0,0 +1,61 @@ +# ADR 0013: Artifact-first release gates and support matrix + +Date: 2026-08-25 +Status: Accepted +Amended: 2026-09-02 by ADR 0024 (ARM64 un-deferred for 1.8.0). + +This ADR records the v1.7/1.8 artifact decision and its historical support +matrix. The 1.10.0 release workflow and evidence are retained as historical +context in `docs/CONTRIBUTING.md`, `docs/SUPPORT.md`, and the release closure in +`docs/archive/NEXT_UPDATE_EXECUTION.md`. Current 1.11.0 evidence is recorded in +`docs/archive/NEXT_UPDATE_EXECUTION-1.11.md`; these documents qualify physical +hardware and Flathub claims instead of treating build output as hardware +validation. + +## Context + +OpenBox v1.7 is a flagship release for new Linux users. Shipping requires reproducible installable artifacts, explicit platform support, and gate checks that block publication until binaries pass—not documentation claims alone. The project adds no telemetry and does not conflate building a Flatpak bundle with Flathub store submission. + +## Decision + +### Artifact-first gates + +- Release publication is gated on **exact-artifact CI** results, not source-only green checks. +- Required gates include backend tests, frontend lint/checkJs, performance targets (10k/20k library scenarios), coverage floors, shellcheck, desktop-file, and AppStream validation as defined in the v1.7 test plan. +- RC soak of **48–72 hours** on tagged prerelease artifacts precedes stable publication (F27). + +### Platform matrix + +| Dimension | v1.7 support | 1.8.0 support | +|---|---|---| +| CPU architecture | **x86_64 only** | **x86_64 + aarch64** (ADR 0024) | +| ARM64 | Deferred | Accepted (ADR 0024) | +| Formal library scale | **20,000** games | **20,000** games | + +### AppImage + +- Build and gate on **Ubuntu 22.04** runner targeting **x86_64** AppImage artifacts. (1.8.0 adds an **aarch64** artifact built on `ubuntu-24.04-arm`; see ADR 0024.) +- AppImage is a release-gated deliverable, not an best-effort side build. + +### Flatpak + +- ~~Target Flatpak runtime **25.08**.~~ Target Flatpak runtime **org.gnome.Platform 49** (bumped 46 → 49 in 1.8.0; see CHANGELOG). +- Produce and release-gate an installable Flatpak bundle aligned with the AppImage feature set. +- **No Flathub store submission** in v1.7; producing the bundle is separate from publishing to Flathub. + +### Repository and release authority + +- Git tags and GitHub Releases on the OpenBox repository are the canonical publication channel for v1.7 artifacts. +- Changelog and version sync checks must agree before stable release (F27); Unreleased entries must not claim shipped status early. + +### Explicit exclusions + +- **No telemetry** added in v1.7. +- No runtime dependency additions (Python standard library only in AppImage). +- No reduction of frozen v1 route surface. + +## Consequences + +- Users receive tested x86_64 and aarch64 AppImages and a Flatpak bundle, or the release does not ship. +- Support expectations are explicit: x86_64 + aarch64, 20k games, Ubuntu 22.04-built x86_64 AppImage (aarch64 on ubuntu-24.04-arm), Flatpak org.gnome.Platform 49 runtime. +- Flathub submission remains a future, separate decision. diff --git a/docs/adr/0014-sqlite-read-model.md b/docs/adr/0014-sqlite-read-model.md new file mode 100644 index 00000000..db6067a1 --- /dev/null +++ b/docs/adr/0014-sqlite-read-model.md @@ -0,0 +1,38 @@ +# ADR 0014: SQLite Read Model + +**Date:** 2026-09-01 +**Status:** Accepted + +## Context + +OpenBox stores its canonical library state in `library.json` (schema v6). The JSON read path in `pkg/state/cache.py` with `load_state_view()` and `CacheEpoch` handles current library sizes well, passing 20k performance gates. However, as libraries grow toward 50k+ entries, the JSON read path's O(n) filtering and linear search become a scalability ceiling. + +The project's runtime dependency-free policy prohibits adding external database dependencies. Python's stdlib `sqlite3` module is available without any additional package. + +## Decision + +Add an **optional** SQLite read model (`pkg/state/sqlite_readmodel.py`) that: + +1. Lives behind the `OPENBOX_ENABLE_SQLITE_READ=1` environment flag — disabled by default. +2. Rebuilds from the canonical JSON state on demand via `ensure_fresh(state, signature)`. +3. Provides FTS5 full-text search with LIKE fallback for environments without FTS5. +4. Offers indexed filtered queries (platform, genre, favorite, hidden, installed) with limit/offset. +5. Computes facets via SQL `GROUP BY`. +6. Is a **read-only projection** — all writes go through the canonical JSON state store, then `invalidate()` triggers a rebuild on the next read. +7. Includes `query_parity_check()` to verify SQLite results match the JSON path. + +The database file lives alongside `library.json` in the data directory. It uses WAL journal mode for concurrent read access. + +## Consequences + +- **Positive**: Provides a forward path for 50k+ libraries without adding runtime dependencies. +- **Positive**: Flag-gated means zero behavior change for existing users. +- **Positive**: FTS5 fallback ensures compatibility with all SQLite builds. +- **Negative**: Rebuild cost is O(n) on first access or after invalidation. +- **Negative**: Additional complexity in the read path; users must opt in explicitly. + +## Alternatives Considered + +1. **Migrate to SQLite as canonical store**: Rejected — would break the JSON state store compatibility contract and require a schema migration. +2. **Add an external DB dependency**: Rejected — violates the dependency-free runtime policy. +3. **Optimize the JSON path further**: Already done in 1.7.1 with facet caching and write coalescing; diminishing returns for 50k+ scale. diff --git a/docs/adr/0015-i18n-system.md b/docs/adr/0015-i18n-system.md new file mode 100644 index 00000000..6613b896 --- /dev/null +++ b/docs/adr/0015-i18n-system.md @@ -0,0 +1,38 @@ +# ADR 0015: Internationalization System + +**Date:** 2026-09-01 +**Status:** Accepted + +## Context + +OpenBox's interface was English-only. The `pkg/parity/parity_premium.py` module contained a `STRINGS` dict with partial translations for Spanish, German, French, and Portuguese, but the frontend JavaScript never consumed these strings. The settings page had a locale selector marked "Localization is planned for a future release." + +`docs/PARITY.md` marked Localization as the only remaining "partial" parity row. + +## Decision + +Implement a full **data-i18n** internationalization system: + +1. **Locale files**: JSON files in `locales/{en,es,de,fr,pt}.json` with nested key structure (e.g., `nav.library`, `sidebar.search`, `settings.title`). +2. **HTML translation**: `data-i18n="key"` attributes on translatable elements; `data-i18n-placeholder`, `data-i18n-title`, `data-i18n-aria-label` for attributes. +3. **JS translation**: `t(key, params)` function in `static/i18n.js` with `{placeholder}` interpolation and English fallback. +4. **Locale loading**: `fetch('/locales/{locale}.json')` with `en.json` as the canonical fallback. +5. **Settings integration**: `available_locales` exposed in `public_settings`; locale selector populated dynamically; `setLocale()` re-translates without reload. +6. **Gate**: `scripts/check_i18n.py` verifies 100% key coverage across all locale files and that all `data-i18n`/`t()` keys exist in `en.json`. +7. **Packaging**: `locales/` bundled in AppImage and Flatpak. + +The existing `STRINGS` dict in `parity_premium.py` is deprecated; `strings_for()` remains as a shim for backward compatibility. + +## Consequences + +- **Positive**: Closes the last parity gap — Localization moves from "partial" to "done". +- **Positive**: Adding a new locale requires only a new JSON file and a route entry. +- **Positive**: Gate enforcement prevents key drift between locales. +- **Negative**: Every new UI string requires a key in `en.json` and translations in all 5 locale files. +- **Negative**: Some dynamically generated strings in JS still need manual `t()` calls. + +## Alternatives Considered + +1. **Use a JS i18n library (i18next, etc.)**: Rejected — violates the dependency-free frontend policy. +2. **Server-side translation**: Rejected — would require template rendering and break the static HTML + JS module architecture. +3. **Keep STRINGS dict approach**: Rejected — it was never wired to the frontend and had incomplete coverage. diff --git a/docs/adr/0016-gamescope-presets.md b/docs/adr/0016-gamescope-presets.md new file mode 100644 index 00000000..1548613d --- /dev/null +++ b/docs/adr/0016-gamescope-presets.md @@ -0,0 +1,29 @@ +# ADR 0016: Gamescope Presets + +**Date:** 2026-09-01 +**Status:** Accepted + +## Context + +Steam Deck and handheld users run games through `gamescope` to control resolution, scaling, and window behavior. Prior to 1.7.2, OpenBox only supported gamescope guest detection (`--game-mode`) without offering preset profiles. Users had to manually configure gamescope flags per game or through external tools. + +The Deck community commonly uses a small set of display profiles (Steam Deck native, HD, 1080p, 1440p, 4K, integer scale, stretch, borderless) that map to specific gamescope command-line arguments. + +## Decision + +Add a **gamescope preset system** in `pkg/parity/parity_gamescope.py` that: + +1. Defines 8 named presets as structured data (width, height, scaling mode, fullscreen mode). +2. Exposes `list_gamescope_presets()` returning JSON-serializable preset descriptors. +3. Exposes `merge_gamescope_preset(name, extra_args=None)` returning the gamescope command-line arguments for the selected preset, merged with any extra args. +4. Integrates with the Settings → Controller tab for preset selection. +5. Preserves existing `--game-mode` guest detection and nested-gamescope prevention. + +Presets are stored as Python data, not user state, keeping the feature dependency-free and deterministic. + +## Consequences + +- Users can switch display profiles from the UI without editing launch commands. +- ~~The preset list is fixed at 8; custom presets are a future consideration.~~ **Resolved in 1.8.0:** custom presets ship as `settings.gamescope_custom_presets` (≤16, validated in `clean_settings`), resolved by `get/merge/list_gamescope_presets(..., custom_presets=)` with custom names shadowing stock ones, applied at launch (per-game `gamescope_preset` game field wins over the global setting), and editable in Settings → Controller. +- Existing gamescope guest behavior is unchanged. +- `list_gamescope_presets()` returns lists (not tuples) to ensure JSON serialization parity between Python and HTTP responses. diff --git a/docs/adr/0017-controller-settings-ui.md b/docs/adr/0017-controller-settings-ui.md new file mode 100644 index 00000000..f18fda17 --- /dev/null +++ b/docs/adr/0017-controller-settings-ui.md @@ -0,0 +1,28 @@ +# ADR 0017: Controller Settings UI + +**Date:** 2026-09-01 +**Status:** Accepted + +## Context + +OpenBox's Big Box mode supports gamepad navigation, but controller configuration was limited to button mapping stored in `appSettings.controller_map`. There was no dedicated settings tab for controller-related options, and no way to test controller connectivity from within the app. + +The 1.7.2 release added gamescope presets and MangoHud toggle, both of which are controller/handheld-focused features that need a home in the settings UI. + +## Decision + +Add a **Settings → Controller tab** that: + +1. Houses gamescope preset selection (ADR 0016). +2. Houses the MangoHud performance overlay toggle. +3. Includes a **controller bench** — a live SVG gamepad visualization that reads `navigator.getGamepads()` and renders button/stick state in real time. +4. Uses token-driven CSS colors (`--surface-gamepad-body`, `--surface-gamepad-stick`, `--surface-gamepad-btn`, `--surface-gamepad-btn-active`, `--border-gamepad`, `--text-controller-status`) defined in `static/app.css :root` and overridden in all 5 stock themes. + +The SVG gamepad is markup-only (no external assets), keeping the feature dependency-free. + +## Consequences + +- Controller-related settings are grouped in one tab instead of scattered. +- Users can verify controller connectivity without launching a game. +- 6 new design tokens added to `:root` and all 6 themes; token baseline remains 0. +- `navigator.getGamepads()` polling runs only while the Controller tab is open. diff --git a/docs/adr/0018-bios-sha1-drift-detection.md b/docs/adr/0018-bios-sha1-drift-detection.md new file mode 100644 index 00000000..d42a98aa --- /dev/null +++ b/docs/adr/0018-bios-sha1-drift-detection.md @@ -0,0 +1,29 @@ +# ADR 0018: BIOS SHA1 Drift Detection + +**Date:** 2026-09-01 +**Status:** Accepted + +## Context + +Launch Doctor's preflight checks already verify BIOS/firmware existence and non-empty BIOS directories. However, a BIOS file can exist but be corrupted, be the wrong version, or be a different file entirely. Without hash verification, Launch Doctor would pass a game that fails to launch due to a mismatched BIOS. + +Emulator definitions in `emulator_defs/*.yaml` can specify an expected BIOS filename and, where known, an expected SHA1 hash. + +## Decision + +Extend the emulator health infrastructure in `pkg/parity/parity_launch_doctor.py` to: + +1. Check BIOS existence (existing behavior, preserved). +2. When a BIOS file exists and the emulator definition includes an expected SHA1 hash, compute the file's SHA1 and compare. +3. Report `BIOS_SHA1_DRIFT` when the hash doesn't match. +4. For definitions pointing to a BIOS directory (not a specific file), check directory existence and non-empty status (existing behavior, preserved). +5. Expose health status through `GET /api/v2/emulators/registry?health=1` with `bios_ok`, `firmware_ok`, and `core_ok` per adapter. + +SHA1 is computed using `hashlib` from the stdlib — no new dependencies. + +## Consequences + +- Launch Doctor catches corrupted or incorrect BIOS files before launch. +- `BIOS_SHA1_DRIFT` is a warning, not a hard block — users can still launch. +- 9 new health badge tokens added to `:root` and all 6 themes. +- Definitions without expected hashes fall back to existence-only checks. diff --git a/docs/adr/0019-backup-diff-api.md b/docs/adr/0019-backup-diff-api.md new file mode 100644 index 00000000..b0586bdf --- /dev/null +++ b/docs/adr/0019-backup-diff-api.md @@ -0,0 +1,27 @@ +# ADR 0019: Backup Diff API + +**Date:** 2026-09-01 +**Status:** Accepted + +## Context + +OpenBox supports whole-library backups as JSON archives. Users can create, rotate, and restore archives, but there was no way to compare the current library state against a backup without restoring it first. For libraries with thousands of games, a manual diff is impractical. + +## Decision + +Add a **backup diff endpoint** `GET /api/v2/backup/diff?archive=` that: + +1. Loads the named backup archive's manifest. +2. Compares it against the current library state. +3. Returns `added` (games in current library but not in backup), `removed` (games in backup but not in current library), and `changed` (games present in both but with differing fields). +4. Returns a `settings_changed` boolean and summary counts. +5. Handles invalid or missing archives with appropriate error responses. + +Implementation lives in `pkg/parity/parity_backup.py` as `diff_manifests()`, registered as a v2 route without modifying frozen v1 routes. + +## Consequences + +- Users can inspect what changed between a backup and current state without restoring. +- The diff is additive (new v2 route); v1 routes are unchanged. +- `diff_manifests()` operates on in-memory manifests; no disk writes. +- Large libraries may take longer to diff; the endpoint is synchronous. diff --git a/docs/adr/0020-smart-collection-chips.md b/docs/adr/0020-smart-collection-chips.md new file mode 100644 index 00000000..3e5b9da1 --- /dev/null +++ b/docs/adr/0020-smart-collection-chips.md @@ -0,0 +1,27 @@ +# ADR 0020: Smart Collection Chips + +**Date:** 2026-09-01 +**Status:** Accepted + +## Context + +OpenBox's filter preset system stores collection rules as JSON in `pkg/parity/parity_filter_presets.py`. The UI rendered these as a flat list of rule descriptors, which was hard to scan and edit for complex collections with many rules. + +Users needed a visual representation of filter rules that is both readable and round-trip faithful (editing a chip and saving must produce the same rule structure). + +## Decision + +Add **visual chip conversion helpers** in `pkg/parity/parity_filter_presets.py`: + +1. `rules_to_chips(rules)` converts filter preset rules into UI chip descriptors (`{label, type, value, operator}`). +2. `chips_to_rules(chips)` converts chip descriptors back into filter preset rules. +3. The conversion is round-trip faithful: `chips_to_rules(rules_to_chips(rules))` equals `rules` for all valid rule sets. +4. Chips render as visual badges in the UI, each showing the field, operator, and value. +5. Existing filter rule normalization and persistence is preserved. + +## Consequences + +- Smart collections are easier to scan and edit visually. +- The JSON rule structure is unchanged; chips are a presentation layer. +- Round-trip fidelity is enforced by tests. +- No new dependencies; chips are plain Python dicts rendered as HTML. diff --git a/docs/adr/0021-hash-routing.md b/docs/adr/0021-hash-routing.md new file mode 100644 index 00000000..fbab6a41 --- /dev/null +++ b/docs/adr/0021-hash-routing.md @@ -0,0 +1,26 @@ +# ADR 0021: Hash Routing for Library View State + +**Date:** 2026-09-02 +**Status:** Accepted + +## Context + +OpenBox has no routing: refreshing the browser (or the native WebKitGTK host) always returns to the default library view, losing the active platform, playlist, filter preset, search query, selected game, and sort. The only URL surface is four one-shot `?deeplink=` query modes read once at boot, which exist for `openbox://` deep links and must keep working unchanged. + +The app is a single persistent Library view with dialog overlays, not a multi-page SPA, so a full router framework is unnecessary; only *view context* needs to survive a reload. + +## Decision + +Encode the library view state in the URL hash via a new `static/router.js` module: + +1. **Grammar:** `#/key/value` pairs joined by `/`, in a fixed key order: `platform`, `category`, `playlist`, `preset`, `q`, `game`, `sort`. Values are `encodeURIComponent`-encoded; empty/default values are omitted. Example: `#/platform/SNES/q/mario/game/12`. +2. **Write path:** `syncHash()` is called at the end of `render()` and `renderGrid()`, and uses `history.replaceState` only — no history entries, no `hashchange` feedback loop. +3. **Apply path:** `applyHash()` parses the hash into `AppState` and toolbar inputs, and returns whether anything changed. It runs once before the first render, and on `hashchange` (user edits the URL, shared links) followed by `render()`. +4. **Out of scope on purpose:** the grid/list toggle stays a persisted setting (`library_view`), not a route value; the `?deeplink=` query modes are untouched and keep precedence for their one-shot behavior. + +## Consequences + +- Refreshing restores platform/playlist/preset/query/selection/sort; URLs are bookmarkable and shareable. +- No history spam: Back exits the app rather than stepping through view states — deliberate, since view transitions are not navigation in the user's mental model. +- The hash is presentation state only; nothing server-side parses it, so the v1/v2 route contracts are unaffected. +- Adding a new routed key means appending to `ROUTE_KEYS` order and one `pair()` in `applyHash()` plus one `push()` in `currentHash()`. diff --git a/docs/adr/0022-screenscraper-provider.md b/docs/adr/0022-screenscraper-provider.md new file mode 100644 index 00000000..040d26a7 --- /dev/null +++ b/docs/adr/0022-screenscraper-provider.md @@ -0,0 +1,29 @@ +# ADR 0022: ScreenScraper Provider + +**Date:** 2026-09-02 +**Status:** Accepted + +## Context + +OpenBox shipped two metadata providers (LaunchBox Games Database, IGDB) plus Steam/GOG/EmuMovies media. The emulation community's standard per-ROM-hash scraper — ScreenScraper — was absent, so ROM-based libraries could not be matched by hash, only by title similarity. ScreenScraper is community-run with per-user quotas, so integration must be cache-first and polite. + +## Decision + +Add `pkg/parity/parity_screenscraper.py` + `handlers/screenscraper.py` mirroring the IGDB provider pattern: + +1. **Transport:** pure-stdlib `urllib` + `backend_io.read_limited`, thread-locked 1 request/second minimum, retry with backoff on 429/5xx only, JSON `output=json`. +2. **Credentials:** `~/.env` via `env_config` (`SCREENSCRAPER_USER`/`SCREENSCRAPER_PASSWORD`, optional dev pair). Never settings JSON; never exported. +3. **Disk cache:** `cache/screenscraper/.json` under the data dir, 30-day TTL, for `jeuInfos.php` and `ssuserInfos.php` responses. +4. **Matching:** by `gameid`, or by ROM hash (`md5`/`sha1`/`crc` + `romtaille` + `romtype`, streamed with a 512 MB cap); platform→`systemeid` via a static table (absent platforms fall back to system-less hash matching, which the service supports). +5. **Media:** `medias[]` types map to OpenBox media kinds; per-kind winner chosen by region priority (settings.region_priority translated to SS region codes) then order; screenshots become a list. Downloads use the hardened `download_file` path and land under `media/screenscraper//`. +6. **Routes (additive v2):** `GET status` (configured + cache size), `POST test` (userInfos), `GET search?q&platform` (jeuRecherche), `POST info` (one game's metadata for review), `POST apply` (durable job: metadata fields + optional media downloads; `replace_existing` defaults false), `POST match` (batch hash-match job over ≤100 games, cancellable). +7. **UI:** metadata dialog gains "Search ScreenScraper" and "ScreenScraper hash-match" actions; Settings → Integrations gains a credential-check card. + +Deliberately NOT integrated into the LaunchBox match-review pipeline (`/api/v2/metadata/matches/*`): that pipeline is title-similarity based with class thresholds; hash matches are exact-or-miss and flow through their own reviewable apply job instead. Future unification can add SS as a match source without contract changes. + +## Consequences + +- ROM libraries get hash-exact metadata/media; nothing scrapes in the background — every network call is user-triggered. +- Quotas are respected via cache-first reads, throttling, and retry-only-on-429/5xx; the batch job caps at 100 games per run. +- The system-id table is data, correctable without touching logic. +- v1 surface untouched; new-module tests keep ≥85% coverage (achieved: 86% handler, 97% parity). diff --git a/docs/adr/0023-library-export.md b/docs/adr/0023-library-export.md new file mode 100644 index 00000000..91fddbe9 --- /dev/null +++ b/docs/adr/0023-library-export.md @@ -0,0 +1,26 @@ +# ADR 0023: Library Export + +**Date:** 2026-09-02 +**Status:** Accepted + +## Context + +OpenBox had no general library export: only highscores export (`POST /api/highscores/export`) and the redacted portable backup JSON (`GET /api/backup`) existed. Users migrating between machines, auditing their collection in a spreadsheet, or sharing a view of their library had no supported path. The 1.7.2 backup-diff API covered internal restore scenarios, not data-out scenarios. + +## Decision + +Add a shareable-by-construction game export in `pkg/parity/parity_export.py` + `handlers/export.py`: + +1. **Routes (additive v2):** `POST /api/v2/library/export` queues a durable job (Activity Center, cancellable); `GET /api/v2/library/export/download?file=` streams the file with `Content-Disposition: attachment`; `GET /api/v2/library/export/exports` lists existing exports (name, size, mtime). +2. **Formats:** JSON (`{application, kind, exported_at, count, include_media_paths, games[]}`) and CSV (one row per game, `DictWriter`). +3. **Scope (server-side, deterministic):** `all`, `platform:`, or `playlist:` (playlist membership by stable `game_id`). The client-side query grammar is intentionally not re-implemented server-side. +4. **Shareable by construction:** exports contain only the game-field projection (`EXPORT_GAME_FIELDS`); settings, credentials, webhooks, and history are never included, so `parity_redact` is not needed on this path. Media path fields are opt-in via `include_media_paths`. +5. **Storage:** `exports/openbox-library-.` in the data dir, name pattern enforced (`EXPORT_NAME_RE`) plus a same-second collision counter; keep the newest 10 files (`prune_exports`). Download validation = name regex + directory containment + existence. +6. **UI:** Settings → Advanced "Export library" card (format/scope/media-paths pickers) with job polling via `/api/jobs` and an automatic download link on completion. + +## Consequences + +- Data-out is a first-class workflow alongside backup/restore and backup-diff. +- CSV flattens list fields with `;`; consumers should split on that separator. +- Export filenames are second-granularity; concurrent exports within one second get `-N` suffixes rather than silently overwriting. +- Future scopes (e.g., "export current filtered view") can extend `EXPORT_SCOPES` without breaking the contract. diff --git a/docs/adr/0024-arm64-artifact-matrix.md b/docs/adr/0024-arm64-artifact-matrix.md new file mode 100644 index 00000000..990df6a1 --- /dev/null +++ b/docs/adr/0024-arm64-artifact-matrix.md @@ -0,0 +1,32 @@ +# ADR 0024: ARM64 (aarch64) AppImage artifact matrix + +**Date:** 2026-09-02 +**Status:** Accepted +**Amends:** ADR 0013 (un-defers ARM64) + +## Context + +ADR 0013 deferred ARM64 for v1.7. The OpenBox AppImage bundles the host CPython interpreter and its shared libraries, so an artifact's architecture follows the build host architecture — there is no cross-compile step, only a per-arch build on a matching runner. ARM64 Linux handhelds and SBCs (Steam Deck alternatives, Raspberry Pi-class boards running desktop distros) are a growing share of the Linux gaming audience, and the runtime is already dependency-free stdlib Python plus a WebKitGTK native host that builds cleanly on aarch64. + +The self-updater (`updates.py`) previously hard-coded `ASSET = "OpenBox-x86_64.AppImage"`, so an aarch64 user checking for updates would look for an x86_64 artifact and either find nothing or be offered the wrong architecture. + +GitHub's `ubuntu-22.04-arm` runner image enters deprecation on 2026-09-17 and is fully unsupported by 2027-04-17, so a new aarch64 job must not target it. + +## Decision + +Ship a two-architecture AppImage release matrix: **x86_64** and **aarch64**. + +1. **`build_appimage.sh`** is parameterized on `OPENBOX_ARCH` (default `uname -m`, normalized: `amd64`/`x64`→`x86_64`, `arm64`→`aarch64`). The output name, `appimagetool` download URL + pinned SHA-256, zsync name, and `ARCH=` env all follow the detected arch. The aarch64 `appimagetool` pin (`1b00524b…07fe`) was computed 2026-09-02 from the continuous release and is documented inline; the x86_64 pin is unchanged. +2. **`updates.py`** derives `ASSET` from the host arch via `_current_arch()`/`_arch_asset()`. `check_update` already keys the AppImage, checksum, and signature lookups off `ASSET` and raises when the asset is missing, so an aarch64 host is now offered only the aarch64 artifact and refuses a release that lacks it. No new refusal branch was needed. +3. **`scripts/release.sh`** builds one host arch per invocation (or the explicit `OPENBOX_ARCH`) and emits arch-suffixed artifacts: `OpenBox-.AppImage{,.zsync,.sha256,.sig}` and `OpenBox---sbom.json`. The tag-triggered release workflow invokes that path for both matrix entries. `sign_release.py`, `verify_release.py`, and `gen_sbom.py` are arch-agnostic (they operate on artifact paths passed as arguments). +4. **SBOM naming**: the x86_64 SBOM keeps the legacy unprefixed name `OpenBox--sbom.json` matching all prior releases; the aarch64 SBOM uses `OpenBox--aarch64-sbom.json` so the two are distinct. The existing `OpenBox-*-sbom.json` globs in tests and workflows match both. +5. **CI** (`release.yml`, renamed from `release-appimage.yml` in ADR 0048) builds and attests both arches in a matrix: x86_64 on `ubuntu-22.04` (the release glibc baseline), aarch64 on `ubuntu-24.04-arm` (the recommended, non-deprecating GitHub arm image). The publish job signs and uploads both. The regular `ci.yml` gate also covers x86_64 on `ubuntu-latest` and native aarch64 on `ubuntu-24.04-arm`; those runner labels intentionally differ from the release baseline. +6. **Native host** (`native_host.c`) builds unchanged on aarch64; the build scripts already fail loudly when `gcc`/`pkg-config`/`libwebkit2gtk-4.1-dev` are missing rather than shipping a stub. + +## Consequences + +- ARM64 users get a release-gated, signed AppImage and self-update path instead of a best-effort source run. +- A release now carries two AppImages, two zsyncs, two checksums, two signatures, and two SBOMs; the GitHub Release body lists all of them. +- The updater refuses an update when the running architecture's artifact is absent (e.g., an aarch64 host against an x86_64-only release) rather than installing the wrong arch. +- The x86_64 build runner (`ubuntu-22.04`) is itself on the 2026-09-17 deprecation path; migrating it to `ubuntu-24.04` is a separate follow-up (it would change the bundled glibc baseline and is out of 1.8.0 scope). +- Flathub submission remains a separate maintainer decision (ADR 0013); this ADR only governs the AppImage matrix. diff --git a/docs/adr/0025-changed-line-gate-skips-unmeasured-files.md b/docs/adr/0025-changed-line-gate-skips-unmeasured-files.md new file mode 100644 index 00000000..33f75ad8 --- /dev/null +++ b/docs/adr/0025-changed-line-gate-skips-unmeasured-files.md @@ -0,0 +1,18 @@ +# ADR 0025: Changed-Line Coverage Skips Unmeasured Files + +**Date:** 2026-09-02 +**Status:** Accepted + +## Context + +The changed-line coverage floor (`CHANGED_LINE_FLOOR = 80` at the time of this decision, later raised to 95% by ADR 0037) intersected the diff against the upstream base with coverage data. However, the coverage configuration omits `test_*` and `scripts/*` from measurement (`[tool.coverage.run] omit`), so those files never appear in the combined data. Every changed line in a test or script file was therefore counted as a miss, making the floor unpassable whenever a release edits an existing test file and the upstream base is behind (the normal state while developing a release). Releases 1.7.x never hit this because checks ran after the upstream push, when the diff base equals HEAD. + +## Decision + +In `measure_changed_lines()`, skip any changed file that is not present in the coverage data's `measured_files()`. Concretely: files the measurement config excludes (tests, scripts, build output) are neutral to the changed-line floor instead of counting as misses. The gate's own unit tests gain a `get_data()` fake and a regression case asserting unmeasured files are skipped. + +## Consequences + +- The floor measures what it can actually influence: runtime code. Test edits no longer mechanically fail the gate. +- Coverage of test files themselves is still enforced structurally: the gate py_compiles them and runs them to completion; failures fail the gate. +- This ADR did not change the floors at the time; the later 95% changed-line requirement is recorded in ADR 0037 and enforced by `scripts/check_tests.py` and CI. diff --git a/docs/adr/0026-mood-match-theming.md b/docs/adr/0026-mood-match-theming.md new file mode 100644 index 00000000..d81b9e4c --- /dev/null +++ b/docs/adr/0026-mood-match-theming.md @@ -0,0 +1,24 @@ +# ADR 0026: Mood Match Adaptive Cover Theming + +**Date:** 2026-09-03 +**Status:** Accepted + +## Context + +OpenBox 1.9.0 adds a "wow" moment: the selected/focused game's cover art drives a live color palette that tints selected cards, the detail hero, Big Box, and primary actions. This requires new CSS custom properties, JS palette extraction from same-origin cover images, and two persisted settings. + +## Decision + +1. **Token-only styling**: all adaptive colors are exposed as five `:root` tokens — `--mood-primary`, `--mood-ink`, `--mood-secondary`, `--mood-glow`, `--mood-tint` — defined in `static/app.css` and all five theme files. Components opt in by referencing `var(--mood-*)`; no raw hex is introduced in component rules. +2. **JS-driven overrides**: `static/mood.js` extracts a palette from the current cover and sets the five tokens as inline styles on `document.documentElement`, adding the `.mood-active` class. When disabled or no cover is present, the tokens are cleared, which restores `:root` defaults. +3. **Progressive, decorative-only**: only borders, shadows, hero tints, and hover states may change. `--text`, `--focus`, form colors, and accessibility-critical tokens are never overwritten. Ink color is chosen by WCAG contrast ratio against the primary color. +4. **Settings gates**: `mood_match_enabled` and `mood_match_bigbox` are persisted booleans, defaulted to `false`. The latter is ignored unless the former is `true` (enforced in both `clean_settings` and the JS application logic). +5. **64-bin RGB quantizer** is intentionally lightweight. The palette is extracted from a 48×48 canvas; dominant color is selected from 4×4×4 RGB bins, with luminance and saturation gating. This is marked `ponytail:` with a documented upgrade path to median-cut/k-means if users report poor palettes. +6. **No new routes or runtime dependencies**; only settings whitelisting, `public_settings`, the `static/mood.js` module, and CSS updates are required. + +## Consequences + +- All stock themes must define the five `--mood-*` tokens so disabled state renders identically to before. +- Cover media stays same-origin and un-CORS-tainted because `media()` resolves to `/api/media` on the same host; `img.crossOrigin = 'anonymous'` is a defensive extra. +- The feature auto-disables on platforms that lack canvas or `Image.decode()`, degrading gracefully. +- Big Box and library views share one palette state; the only behavioral difference is the `mood_match_bigbox` gate. diff --git a/docs/adr/0027-library-constellation.md b/docs/adr/0027-library-constellation.md new file mode 100644 index 00000000..ba44a646 --- /dev/null +++ b/docs/adr/0027-library-constellation.md @@ -0,0 +1,34 @@ +# ADR 0027: Library Constellation (relationship graph) + +**Date:** 2026-09-03 +**Status:** Accepted + +## Context + +M3 asks for a "wow" visualization of the library as a relationship graph. It should reveal clusters by series, developer, genre, platform family, and co-play history without requiring external services. + +## Decision + +1. **Additive GET route**: `GET /api/v2/library/constellation?kinds=...&limit=...` returns a capped, deterministic graph. `s`/`t` are indices into the `nodes` array to keep the payload small. +2. **Node cap before pairing**: games are ranked by playtime, favorites, and play count, then only the top `limit` (default 400, range 50–1000) are connected. This makes the pairwise scan O(limit²) and keeps the 20,000-game case instant because pairing never happens on the full library. +3. **Single strongest edge per pair**: for each pair of selected games, the highest-weight shared attribute wins; co-play can win if it produces the highest weight. This keeps the graph readable and avoids visual noise. +4. **Static platform family map**: `pkg/parity/parity_constellation.py` uses a curated `PLATFORM_FAMILIES` map, falling back to `"Other"`. Marked `ponytail:` — the upgrade path is to derive families from `platform_categories` settings once that taxonomy exists. +5. **Client-side force simulation**: `static/constellation.js` runs an Archimedean-spiral init with spring-electric ticks on `requestAnimationFrame`, capped to a few hundred nodes and alpha decay. Pan/zoom/click are supported, hover shows a tooltip, and clicking a node dispatches `app:show-game` to reuse the existing selection path. Upgrade path = web worker or Barnes-Hut for >500 nodes. +6. **Color tokens per edge kind**: six `--constellation-edge-*` tokens are added to `app.css :root` and all five theme files; the renderer uses them. + +## Consequences + +- No new persistent state; the graph is computed on demand from `games` and `history`. +- Co-play is bounded by a 7-day window and capped at 5 shared sessions per edge. +- Big real libraries remain responsive because only the ranked subset is laid out. + +## Amendment 2026-09-04 (release QA) + +First-look screenshots showed a blank canvas with raw kind labels. Three renderer defects, all fixed in `static/constellation.js`: + +1. **Canvas ignores CSS `var()`**: invalid `fillStyle`/`strokeStyle` assignments are silently dropped (black kept), so nodes and edges were invisible on dark themes. Tokens are now resolved through `getComputedStyle` once per render, with keyword fallbacks. +2. **Layout explosion**: unclamped spring-electric steps (repulsion up to ~7500 px/tick for close pairs) teleported every node off-canvas on the first tick. Per-tick displacement is clamped to 24 px and repulsion corrected to Fruchterman-Reingold `k²/d` so pairs settle near distance `k`. +3. **Frozen kind labels**: the label map evaluated `t()` at module load, before the async locale arrived. Labels resolve lazily on every dialog open; the loading indicator now hides after a successful render. +4. **Edgeless drift**: graphs with no shared attributes have no attraction, so repulsion scattered nodes thousands of px apart. Positions clamp into the largest circle fitting the viewport. + +Regression cover: `scripts/ui_smoke.cjs` opens the dialog and asserts non-blank canvas pixels, translated labels, and a hidden loading indicator. diff --git a/docs/adr/0028-picker.md b/docs/adr/0028-picker.md new file mode 100644 index 00000000..12b15a6a --- /dev/null +++ b/docs/adr/0028-picker.md @@ -0,0 +1,22 @@ +# ADR 0028: "What should I play?" Picker + +**Date:** 2026-09-03 +**Status:** Accepted + +## Context + +The user wanted a surprise-me style feature with a "wow" factor: a smart picker that asks "what should I play?" and recommends games. This requires a scoring algorithm, a new API route, a dialog UI, and i18n. + +## Decision + +1. **Dedicated route and parity module**: `POST /api/v2/library/pick` is handled by `handlers/picker.py` and uses `pkg/parity/parity_picker.py`. The handler resolves scope (all, platform, or playlist) and calls the pure function. +2. **Additive scoring with hard filters**: the picker filters by path existence, hidden, players, mood, familiarity, and typical session length. It then scores by play history, favorites, rating, recency, mood keyword match, and session fit, and does weighted random selection among the top 12. This is marked `ponytail:`; upgrades include hand-curated genre taxonomy and a `how_long_to_beat` source. +3. **Client-only rendering**: the result is rendered in `static/picker.js` using the same `media()` and `launch()` helpers as the rest of the UI. No raw styling is added; existing cover and button classes are reused, plus a small `picker-*` rule block for layout. +4. **i18n-first reason strings**: reason templates come from `locales/*.json` with interpolation, so every locale can customize the "why" text. +5. **No settings needed**: the picker is always available via the surprise button, with controls inside the dialog. + +## Consequences + +- The old random "surprise me" behavior is replaced by the picker dialog (it still has a "Just surprise me" fallback). +- New runtime modules require coverage >= 85% and are listed in `runtime_modules.txt`. +- The same `mood` enum is also used by M1's theming; the picker does not hardcode moods in the UI (populated via i18n). diff --git a/docs/adr/0029-wrapped-timeline.md b/docs/adr/0029-wrapped-timeline.md new file mode 100644 index 00000000..b3914d34 --- /dev/null +++ b/docs/adr/0029-wrapped-timeline.md @@ -0,0 +1,26 @@ +# ADR 0029: Wrapped annual report + replay timeline + +**Date:** 2026-09-03 +**Status:** Accepted + +## Context + +Players want a year-in-review report from local data and a better way to browse past sessions. M4 adds two additive GET routes and minimal UI. + +## Decision + +1. **Additive routes, no new state**: + - `GET /api/v2/insights/wrapped?year=YYYY` returns an annual summary. + - `GET /api/v2/history/timeline?days=90` returns history grouped by date. + Both consume existing `games` and `history`; no new persistence or telemetry. +2. **Privacy by construction**: `wrapped_summary` and `timeline_groups` expose only names, cover booleans, aggregates, and recording basenames. No settings, paths (except basename), or credentials leave the API. +3. **Frontend integration**: + - `static/wrapped.js` is a printable, full-screen dialog launched from the Insights panel header. + - `static/timeline.js` adds a "Timeline" tab to the existing History dialog. + - Both reuse `api`, `t`, `media`, and `app:show-game` events. +4. **Print styling**: a dedicated `@media print` block hides the dialog head and sets a light background. No canvas is used in the report, so printing is safe. + +## Consequences + +- RetroAchievements and save paths are not exposed; `recording` values are basenames only. +- The wrapped dialog is for the current year by default, but the year is user-selectable. diff --git a/docs/adr/0030-mastery-map.md b/docs/adr/0030-mastery-map.md new file mode 100644 index 00000000..4efb5e2e --- /dev/null +++ b/docs/adr/0030-mastery-map.md @@ -0,0 +1,19 @@ +# ADR 0030: Mastery Map completionist dashboard + +**Date:** 2026-09-04 +**Status:** Accepted + +## Context + +Completionist players want a per-platform and per-decade breakdown of local progress states alongside RetroAchievements hardcore progress. The RA cache on disk (`/retroachievements/*.json`, see `retroachievements.py` `cached()` + `match_game()`) already holds everything needed — no new network calls are required or desired (rate-limit safe). + +## Decision + +1. **Additive route, no new state**: `GET /api/v2/insights/mastery` returns `{platforms, overall, decades}` where each bucket holds `{never, played, beaten, completed, mastered, ra_tracked, ra_mastered, ra_avg_progress, total}`. Progress classification is local (`progress` field, falling back to playtime/play_count); RA fields come only from the disk cache and default to zeros when the cache is missing. +2. **Frontend integration**: `static/mastery.js` opens `#masteryDialog` from Tools → Mastery, rendering stacked horizontal bars per platform (or decade via the decade filter) with tokenized `--mastery-*` segments defined in `app.css :root` and all five stock themes. Clicking a segment dispatches `app:show-game` and sets the library platform filter via existing sidebar state. +3. **i18n**: state labels use a static `stateLabels()` map (literal `t('mastery.*')` keys evaluated at render time) so `check_i18n.py` sees every key and locale switching stays correct. + +## Consequences + +- RA data is never fetched by this feature; stale cache reads are acceptable for a dashboard and keep the feature offline-safe. +- One additive GET route; route-count test bumped accordingly. diff --git a/docs/adr/0031-game-night.md b/docs/adr/0031-game-night.md new file mode 100644 index 00000000..8f6aa203 --- /dev/null +++ b/docs/adr/0031-game-night.md @@ -0,0 +1,25 @@ +# ADR 0031: Game Night Big Box party mode + +**Date:** 2026-09-04 +**Status:** Accepted + +## Context + +Couch multiplayer needs a zero-setup flow: pick player count and session length, get a fair queue, spin for the first game, launch, advance rounds. All data already exists in `library.json` (`max_players`, `controller_support`, `platform`, `rating`); no new persistence beyond the queue itself is required. + +## Decision + +1. **Pure queue builder**: `pkg/parity/parity_party.py` exposes `build_party_queue(games, *, players=2, minutes=0, limit=50)`. Eligibility = visible, not `hide_in_bigbox`, path usable (same default as the picker), `max_players >= players`, and couch-suitable (platform in the static `COUCH_PLATFORMS` set — consoles, handhelds, arcade — or explicit per-game `controller_support`). A `minutes` budget excludes titles averaging more than 3x the session length (same factor as the picker). Sort is rating desc with a random tiebreak; output is capped at 50 game ids. +2. **Three additive routes** in `handlers/party.py` (`PartyHandlers` mixin): + - `POST /api/v2/party/queue` `{players, minutes}` builds via (1) and persists `{party_queue, party_players, party_index: 0}` through `transact_state`. + - `GET /api/v2/party/queue` returns `{queue, index}` from settings (defensively re-cleaned). + - `POST /api/v2/party/next` advances the persisted index with wrap-around and resolves the game name, returning `{game_id, name, index}`. Launch stays client-side via the existing `/api/launch` path. +3. **Settings**: `party_queue` (deduped, ≤50 game-id strings), `party_players` (int 2–8), `party_index` (int ≥0, clamped) in `KNOWN_SETTINGS` with a `_clean_party()` validator, so the queue survives restarts (consistent with ADR 0011). +4. **POST handler contract fix**: `_do_POST` reads `rfile` exactly once and passes the parsed dict to the handler. `handlers/picker.py` (M2) called `self.body()` a second time, which blocks on the exhausted keep-alive stream until the socket times out — POST `/api/v2/library/pick` hung live while passing unit tests. Fixed both picker and party handlers to take the `payload` argument; handler tests now pass parsed dicts. +5. **Frontend**: `static/party.js` renders `#partyOverlay` inside the Big Box section (setup card → wheel → launch/next-round). The wheel is a `conic-gradient` div spun with a 2.4 s `cubic-bezier(0.2, 0.8, 0.2, 1)` rotation landing the `queue[index]` segment under a top pointer; "Up next" shows the next 3 covers. Gamepad input reuses `pollGamepads()` edge detection via a `partyGamepad(edge)` branch (left/right adjust setup, play = build/spin/launch, up = next round, back = close, menu = Big Box menu). Keyboard fallback (arrows/Enter/N/Escape) is verified in `ui_smoke`; the overlay captures keys so Big Box navigation does not fire underneath. No new CSS tokens — the wheel reuses `--brand/--accent/--active/--focus`. +6. **Big Box menu**: a "Game Night" button in `#bigBoxMenu` opens the overlay (reuses the `menu` controller action; no new `controller_map` entry per the plan's default). + +## Consequences + +- One unpaired state shape: `party_index` persists alongside the queue so a restart resumes the round order. +- Steam Deck manual pass: pending maintainer verification; gamepad path shares the exercised `pollGamepads` edge pipeline and the CI keyboard path covers the same state machine. diff --git a/docs/adr/0032-sqlite-read-model-graduation.md b/docs/adr/0032-sqlite-read-model-graduation.md new file mode 100644 index 00000000..b4135969 --- /dev/null +++ b/docs/adr/0032-sqlite-read-model-graduation.md @@ -0,0 +1,31 @@ +# ADR 0032: SQLite read model graduation + +**Amends:** ADR 0014 (SQLite read model) +**Date:** 2026-09-04 +**Status:** Accepted + +## Context + +ADR 0014 introduced `pkg/state/sqlite_readmodel.py` behind the `OPENBOX_ENABLE_SQLITE_READ=1` environment flag. The module was complete (rebuild, ensure_fresh, query, search, facets, count, query_parity_check) with 90% test coverage, but was **never imported at runtime** — the flag was read only inside the module itself, and no query path consulted it. The README "Scale & Backups" section and PARITY row "Optional SQLite read model: done" advertised FTS5 search and GROUP BY facets that were unreachable. + +## Decision + +Graduate the read model from dead code to a wired, parity-checked query path: + +1. **Singleton**: `SQLITE_READ_MODEL = SqliteReadModel(DATA.parent / "sqlite_readmodel.db")` in `pkg/state/cache.py`, created at import time. No-op when the flag is unset (`enabled` returns `False`, all methods return empty results). + +2. **Invalidation**: `transact_state()` calls `SQLITE_READ_MODEL.invalidate()` after every write, alongside the existing `CACHE_EPOCH._invalidate_all()`. The next read triggers `ensure_fresh(state, signature)` which rebuilds only when the state signature changed. + +3. **Facets wiring**: `GET /api/explorer/facets` consults the SQLite model when enabled. Before serving SQLite results, `query_parity_check()` verifies the SQLite game IDs match the JSON game IDs. On mismatch, the JSON path is used for that request and a warning is logged. + +4. **Search endpoint**: New additive `GET /api/v2/library/search?q=&limit=` uses FTS5 (or LIKE fallback) when the SQLite model is enabled; falls back to a simple JSON title substring match when disabled. The `source` field in the response indicates which path was used (`"sqlite"` or `"json"`). + +5. **Full library `GET /api/library` stays JSON**: the frontend filters client-side via `worker.search.js`; SQL adds nothing there. This is a deliberate scope line — the SQLite model serves facets and server-side search only. + +## Consequences + +- `OPENBOX_ENABLE_SQLITE_READ=1` now actually changes behavior: facets are served from SQLite GROUP BY, and `/api/v2/library/search` uses FTS5. +- Flag off (default): byte-identical behavior to before. The singleton is a no-op. +- Parity check on first serve per signature prevents serving stale or wrong data. +- JSON remains the source of truth; SQLite is a read-only projection rebuilt on demand. +- `runtime_modules.txt` already includes `sqlite_readmodel.py`; no new runtime module was added (the singleton lives in `cache.py` which is already registered). diff --git a/docs/adr/0033-launchbox-xml-migration.md b/docs/adr/0033-launchbox-xml-migration.md new file mode 100644 index 00000000..e367dea2 --- /dev/null +++ b/docs/adr/0033-launchbox-xml-migration.md @@ -0,0 +1,29 @@ +# ADR 0033: LaunchBox library XML migration import + +**Date:** 2026-09-04 +**Status:** Accepted + +## Context + +OpenBox stores a `launchbox_db_id` field on every game and uses a LaunchBox metadata database (`metadata/launchbox.db`), but has no path to import a user's existing LaunchBox library. Users migrating from LaunchBox must re-add games manually. LaunchBox exports platform libraries as XML files (`Data/Platforms/*.xml`) containing `` elements with title, path, platform, genre, developer, publisher, rating, emulator ID, and media path fields. + +## Decision + +Add a two-phase LaunchBox XML migration import: + +1. **Parser** (`pkg/parity/parity_launchbox_import.py`): stdlib `xml.etree.ElementTree` only. Maps LaunchBox fields to OpenBox fields via a fixed `_FIELD_MAP`. Unknown fields are ignored. Malformed entries (missing ID or title) are skipped with a count, not an abort. + +2. **Preview route** (`POST /api/v2/import/launchbox/preview`): dry-run that parses the XML, deduplicates against the existing library by `launchbox_db_id` first then by name, and returns a report: total in XML, skipped malformed, duplicates, would-import count, emulator IDs found, and a 50-game preview sample. + +3. **Apply route** (`POST /api/v2/import/launchbox/apply`): parses the XML and merges via the existing `merge_imported_games` infrastructure. Returns added/found counts plus the emulator ID report. + +4. **Emulator mappings are reported, not applied**: LaunchBox `EmulatorId` values are internal to the user's LaunchBox setup and have no OpenBox equivalent. The report surfaces them so the user can configure emulators manually after import. + +5. **Deduplication**: `launchbox_db_id` first (exact match), then canonical `game_identity` via `merge_imported_games`. This prevents duplicate imports on re-runs. + +## Consequences + +- Users can migrate from LaunchBox by pointing at their exported XML. +- No emulator configuration is silently changed — the report makes manual resolution explicit. +- v1 route surface is untouched; both routes are additive `/api/v2/`. +- Re-running import is safe: dedup prevents duplicate games. diff --git a/docs/adr/0034-bigbox-video-snaps.md b/docs/adr/0034-bigbox-video-snaps.md new file mode 100644 index 00000000..ccba1381 --- /dev/null +++ b/docs/adr/0034-bigbox-video-snaps.md @@ -0,0 +1,31 @@ +# ADR 0034: Big Box video snaps + +**Date:** 2026-09-04 +**Status:** Accepted + +## Context + +Big Box mode (default "stage" view) renders a static cover image for the focused game. LaunchBox and Playnite both support video snaps — short looping gameplay videos that play when a game is selected in the carousel. OpenBox already stores `video_snap`, `video_theme`, `video_trailer`, and `video_recording` fields and has an `active_video()` helper, but Big Box never uses them. + +## Decision + +Add video snaps to Big Box default (stage) mode with minimal resource usage: + +1. **Single reused `