From 5de8d3b2ddd647e77bc31ee4145231187a4f53e4 Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Wed, 23 Sep 2026 19:02:20 -0400 Subject: [PATCH 1/6] feat: generalize launch presets and migrate database --- .github/ISSUE_TEMPLATE/app-preset.yml | 4 +- .github/ISSUE_TEMPLATE/game-emulator.yml | 6 +- .github/ISSUE_TEMPLATE/game-epic-games.yml | 4 +- .github/ISSUE_TEMPLATE/game-gog.yml | 6 +- .../ISSUE_TEMPLATE/game-microsoft-store.yml | 4 +- .github/ISSUE_TEMPLATE/game-native.yml | 6 +- .github/ISSUE_TEMPLATE/game-steam.yml | 4 +- .github/workflows/migrate-database.yml | 87 +++++++++++++++++++ README.md | 14 +-- docs/developerSetup.md | 8 ++ docs/presetGuidelines.md | 8 +- gh-pages-template/assets/js/app.js | 50 ++++++++--- gh-pages-template/index.html | 6 +- package.json | 4 +- src/build-site.js | 5 +- src/database.js | 26 +++--- src/issue.js | 4 +- src/migrate-database.js | 81 +++++++++++++++++ src/presets.js | 14 +-- src/record.js | 34 ++++++++ tests/migration.test.js | 60 +++++++++++++ tests/presets.test.js | 79 +++++++++++++---- 22 files changed, 429 insertions(+), 85 deletions(-) create mode 100644 .github/workflows/migrate-database.yml create mode 100644 src/migrate-database.js create mode 100644 src/record.js create mode 100644 tests/migration.test.js diff --git a/.github/ISSUE_TEMPLATE/app-preset.yml b/.github/ISSUE_TEMPLATE/app-preset.yml index ffe9811795..4457a9aeff 100644 --- a/.github/ISSUE_TEMPLATE/app-preset.yml +++ b/.github/ISSUE_TEMPLATE/app-preset.yml @@ -1,6 +1,6 @@ --- name: App launch preset -description: Request one Sunshine launch option for a non-game app. +description: Request one launch option for a non-game app. title: '[APP PRESET]: ' labels: - request-app-preset @@ -52,7 +52,7 @@ body: id: working_dir attributes: label: Working directory - description: Optional Sunshine working-dir value. + description: Optional working directory. - type: textarea id: notes attributes: diff --git a/.github/ISSUE_TEMPLATE/game-emulator.yml b/.github/ISSUE_TEMPLATE/game-emulator.yml index 5b05081a39..73ec6859e5 100644 --- a/.github/ISSUE_TEMPLATE/game-emulator.yml +++ b/.github/ISSUE_TEMPLATE/game-emulator.yml @@ -1,6 +1,6 @@ --- name: Emulator game preset -description: Request a Sunshine Emulator launch option for a GameDB game. +description: Request an emulator launch option for a GameDB game. title: '[EMULATOR GAME PRESET]: ' labels: - request-game-preset @@ -28,7 +28,7 @@ body: attributes: label: Command description: | - Portable Sunshine command for the emulator. Use {{ROM_PATH}} for the game and + Portable command for the emulator. Use {{ROM_PATH}} for the game and {{HOME}} for a variable home path. Put the emulator executable on the host PATH. Windows-only path placeholders, literal home directories, and reserved system names are rejected. validations: @@ -37,7 +37,7 @@ body: id: working_dir attributes: label: Working directory - description: Optional portable Sunshine working-dir value. + description: Optional portable working directory. - type: textarea id: notes attributes: diff --git a/.github/ISSUE_TEMPLATE/game-epic-games.yml b/.github/ISSUE_TEMPLATE/game-epic-games.yml index cc218fe74d..73346f9b4c 100644 --- a/.github/ISSUE_TEMPLATE/game-epic-games.yml +++ b/.github/ISSUE_TEMPLATE/game-epic-games.yml @@ -1,6 +1,6 @@ --- name: Epic Games game preset -description: Request a Sunshine Epic Games launch option for a GameDB game. +description: Request an Epic Games launch option for a GameDB game. title: '[EPIC GAMES GAME PRESET]: ' labels: - request-game-preset @@ -25,7 +25,7 @@ body: description: | Three-part Sandbox ID, Catalog ID, and Artifact ID from the Epic launcher shortcut, separated by colons or %3A. For example fn%3A4fe75bbc5a674f4f9b356b5c90567da5%3AFortnite. - The bot generates the Sunshine command for each supported launcher OS. + The bot generates the command for each supported launcher OS. validations: required: true - type: textarea diff --git a/.github/ISSUE_TEMPLATE/game-gog.yml b/.github/ISSUE_TEMPLATE/game-gog.yml index 0e06dfd23c..42506964a1 100644 --- a/.github/ISSUE_TEMPLATE/game-gog.yml +++ b/.github/ISSUE_TEMPLATE/game-gog.yml @@ -1,6 +1,6 @@ --- name: GOG game preset -description: Request a Sunshine GOG launch option for a GameDB game. +description: Request a GOG launch option for a GameDB game. title: '[GOG GAME PRESET]: ' labels: - request-game-preset @@ -33,7 +33,7 @@ body: attributes: label: Command description: | - Sunshine command to launch the game. Use {{HOME}} for a variable home path. + Command to launch the game. Use {{HOME}} for a variable home path. Windows also allows {{SYSTEM_DRIVE}}, {{PROGRAM_FILES}}, and {{PROGRAM_FILES_X86}}. Literal home directories and reserved system names are rejected. validations: @@ -42,7 +42,7 @@ body: id: working_dir attributes: label: Working directory - description: Optional Sunshine working-dir value. + description: Optional working directory. - type: textarea id: notes attributes: diff --git a/.github/ISSUE_TEMPLATE/game-microsoft-store.yml b/.github/ISSUE_TEMPLATE/game-microsoft-store.yml index 753e77a58a..a28ac5ebc9 100644 --- a/.github/ISSUE_TEMPLATE/game-microsoft-store.yml +++ b/.github/ISSUE_TEMPLATE/game-microsoft-store.yml @@ -1,6 +1,6 @@ --- name: Microsoft Store game preset -description: Request a Sunshine Microsoft Store launch option for a GameDB game. +description: Request a Microsoft Store launch option for a GameDB game. title: '[MICROSOFT STORE GAME PRESET]: ' labels: - request-game-preset @@ -25,7 +25,7 @@ body: description: | Installed app AUMID from Get-StartApps, for example Microsoft.WindowsCalculator_8wekyb3d8bbwe!App. A Store product ID will not launch the game. - The bot generates the Sunshine command for each supported launcher OS. + The bot generates the command for each supported launcher OS. validations: required: true - type: textarea diff --git a/.github/ISSUE_TEMPLATE/game-native.yml b/.github/ISSUE_TEMPLATE/game-native.yml index 808564a39a..35f74998fe 100644 --- a/.github/ISSUE_TEMPLATE/game-native.yml +++ b/.github/ISSUE_TEMPLATE/game-native.yml @@ -1,6 +1,6 @@ --- name: Native game preset -description: Request a Sunshine Native launch option for a GameDB game. +description: Request a native launch option for a GameDB game. title: '[NATIVE GAME PRESET]: ' labels: - request-game-preset @@ -33,7 +33,7 @@ body: attributes: label: Command description: | - Sunshine command to launch the game. Use {{HOME}} for a variable home path. + Command to launch the game. Use {{HOME}} for a variable home path. Windows also allows {{SYSTEM_DRIVE}}, {{PROGRAM_FILES}}, and {{PROGRAM_FILES_X86}}. Literal home directories and reserved system names are rejected. validations: @@ -42,7 +42,7 @@ body: id: working_dir attributes: label: Working directory - description: Optional Sunshine working-dir value. + description: Optional working directory. - type: textarea id: notes attributes: diff --git a/.github/ISSUE_TEMPLATE/game-steam.yml b/.github/ISSUE_TEMPLATE/game-steam.yml index 447e4a39b0..b85177eb1e 100644 --- a/.github/ISSUE_TEMPLATE/game-steam.yml +++ b/.github/ISSUE_TEMPLATE/game-steam.yml @@ -1,6 +1,6 @@ --- name: Steam game preset -description: Request a Sunshine Steam launch option for a GameDB game. +description: Request a Steam launch option for a GameDB game. title: '[STEAM GAME PRESET]: ' labels: - request-game-preset @@ -24,7 +24,7 @@ body: label: Launch ID description: | Steam numeric app ID from its store URL, for example 464920. - The bot generates the Sunshine command for each supported launcher OS. + The bot generates the command for each supported launcher OS. validations: required: true - type: textarea diff --git a/.github/workflows/migrate-database.yml b/.github/workflows/migrate-database.yml new file mode 100644 index 0000000000..dc7580aa99 --- /dev/null +++ b/.github/workflows/migrate-database.yml @@ -0,0 +1,87 @@ +--- +name: Migrate Database +permissions: {} + +env: + NODE_VERSION: '24' + +on: + push: + branches: + - master + workflow_dispatch: + +concurrency: + group: database-migrations + cancel-in-progress: false + +jobs: + migrate: + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Checkout database + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: database + path: database-branch + token: ${{ secrets.GH_BOT_TOKEN }} + fetch-depth: 0 + + - name: Setup Node + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: ${{ env.NODE_VERSION }} + + - name: Back up and migrate database + id: migrate + env: + BOT_NAME: ${{ vars.GH_BOT_NAME }} + BOT_EMAIL: ${{ secrets.GH_BOT_EMAIL }} + shell: bash + run: | + set -euo pipefail + git -C database-branch config user.name "$BOT_NAME" + git -C database-branch config user.email "$BOT_EMAIL" + for _ in 1 2 3 4 5; do + git -C database-branch fetch origin database + git -C database-branch reset --hard origin/database + pending=$(node src/migrate-database.js --database database-branch/database --pending) + if [[ -z "$pending" ]]; then + echo 'No pending database migrations' + exit 0 + fi + source_commit=$(git -C database-branch rev-parse HEAD) + backup_branch="database-backup-$source_commit" + backup_remote=$(git -C database-branch ls-remote origin "refs/heads/$backup_branch" | cut -f1) + if [[ -z "$backup_remote" ]]; then + git -C database-branch push origin "$source_commit:refs/heads/$backup_branch" + elif [[ "$backup_remote" != "$source_commit" ]]; then + echo "Backup branch $backup_branch does not match $source_commit" >&2 + exit 1 + fi + node src/migrate-database.js --database database-branch/database \ + --backup-branch "$backup_branch" --source-commit "$source_commit" + git -C database-branch add database + if git -C database-branch diff --cached --quiet; then + echo 'A pending migration produced no database changes' >&2 + exit 1 + fi + git -C database-branch commit -m 'chore: apply database migrations' + if git -C database-branch push origin HEAD:database; then + echo 'migrated=true' >> "$GITHUB_OUTPUT" + exit 0 + fi + done + echo 'Database changed during all five migration attempts' >&2 + exit 1 + + - name: Build updated site + if: steps.migrate.outputs.migrated == 'true' + env: + GH_TOKEN: ${{ secrets.GH_BOT_TOKEN }} + run: gh workflow run build-pages.yml --ref master diff --git a/README.md b/README.md index b8447d815b..0868a04648 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ width="256" />

PresetDB

-

Community Sunshine game and app launch presets.

+

Community game and app launch presets.

@@ -19,15 +19,15 @@ Approved requests
-Community maintained launch presets for [Sunshine](https://github.com/LizardByte/Sunshine). Each game or app can have many presets. Native, GOG, and app commands target a chosen host OS. Steam, Epic Games, and Microsoft Store submissions use a launch ID to generate commands for their supported launcher OSes; emulator submissions use a portable command. GitHub issue numbers provide stable IDs, while the bot generates names and accepts optional emulator variants. +Community maintained game and app launch presets, with [Sunshine](https://github.com/LizardByte/Sunshine) as the primary consumer. Each game or app can have many presets. Native, GOG, and app commands target a chosen host OS. Steam, Epic Games, and Microsoft Store submissions use a launch ID to generate commands for their supported launcher OSes; emulator submissions use a portable command. GitHub issue numbers provide stable IDs, while the bot generates names and accepts optional emulator variants. Each GitHub issue requests **one preset**. Game requests must identify a [GameDB](https://app.lizardbyte.dev/GameDB/) record. App requests use a separate form and require a name and official source URL for maintainer review. A bot validates the request, and an authorized reviewer enters it into the approval queue. No code or pull request is needed to contribute. ## Browse and use -The [website](https://app.lizardbyte.dev/PresetDB/) shows games and apps with available presets. Each preset includes a Sunshine application JSON snippet. Replace supported path placeholders with paths on your host. Review community commands before running them. Game cover images come from GameDB; app images are submitted as HTTPS URLs for the catalog. These image URLs are not used as Sunshine `image-path` values, which require local files. +The [website](https://app.lizardbyte.dev/PresetDB/) shows games and apps with available presets. Each preset shows its launch command and offers a Sunshine application JSON export. Replace supported path placeholders with paths on your host. Review community commands before running them. Game cover images come from GameDB; app images are submitted as HTTPS URLs for the catalog. These image URLs are not used as Sunshine `image-path` values, which require local files. -The published JSON API contains `index.json`, `games/.json`, and `apps/.json`. GameDB uses the same numeric game IDs as IGDB. An entry contains all its presets. Each preset receives a stable `issue-` ID generated by the bot. +The published JSON API contains `index.json`, `games/.json`, and `apps/.json`. GameDB uses the same numeric game IDs as IGDB. An entry contains all its presets. Each preset receives the originating issue number as a stable string ID, such as "4". Records use schema version 2 with a `command` field or a `commands_by_os` map and an optional `working_directory`; the website builds Sunshine JSON from these generic fields. The migration workflow updates version 1 records on the database branch; site and approval reads also convert them during the rollout. ## Database growth @@ -47,7 +47,7 @@ The website publishes [catalog statistics](https://app.lizardbyte.dev/PresetDB/s 1. Read the [preset guidelines](docs/presetGuidelines.md). For a game, copy its [IGDB game URL](https://www.igdb.com/) and [choose a game method form](https://github.com/LizardByte/PresetDB/issues/new/choose). The bot resolves the URL slug to the IGDB numeric ID and checks GameDB. For another app, [open an app preset request](https://github.com/LizardByte/PresetDB/issues/new?template=app-preset.yml) with its official URL. 2. Fill in one launch option. Native and GOG forms ask for the host OS. Store forms ask only for the launch ID; the emulator form asks for a portable command and an optional variant name. The bot generates the preset name. App requests still ask for the host OS. -3. The store forms share one Launch ID concept: a numeric Steam app ID, an Epic three-part launch ID, or a Microsoft Store AUMID. The bot generates OS-specific Sunshine commands. Native, GOG, and Emulator forms ask for a Command. Validation and the website never execute commands. +3. The store forms share one Launch ID concept: a numeric Steam app ID, an Epic three-part launch ID, or a Microsoft Store AUMID. The bot generates OS-specific launch commands. Native, GOG, and Emulator forms ask for a Command. Validation and the website never execute commands. 4. To replace a preset, provide its issue number and explain the change. The bot preserves the original preset ID. ProtonDB compatibility summaries are attributed to [ProtonDB contributors](https://github.com/bdefore/protondb-data) and published under the [Open Database License](https://opendatacommons.org/licenses/odbl/). The website refreshes available tiers during its Pages build. @@ -56,7 +56,7 @@ After validation, a listed trusted game contributor enters the approval queue au Supported path placeholders in manual Command and Working directory are `{{ROM_PATH}}` for emulator games and `{{HOME}}`; Windows also allows `{{SYSTEM_DRIVE}}`, `{{PROGRAM_FILES}}`, and `{{PROGRAM_FILES_X86}}`. Unknown placeholders, literal home directories, and Windows reserved device names are rejected. Literal commands still require maintainer review. -Game submissions resolve the submitted slug through [IGDB authenticated API](https://api-docs.igdb.com/), then check the resolved ID and slug against the live [GameDB JSON API](https://github.com/LizardByte/GameDB#data). An unavailable or mismatched result blocks approval. App submissions have no GameDB counterpart and always need separate maintainer review. Steam generates Windows, Linux, and macOS commands; Epic Games generates Windows and macOS commands; Microsoft Store generates a Windows command. Launcher support does not prove that a particular game has a build for every OS, so reviewers should check availability. Steam presets link to [ProtonDB](https://www.protondb.com/) and the website shows its Linux compatibility tier when available at build time. Compatibility reports are community data and do not block approval. Launcher ID commands may outlive Sunshine process tracking; the stream may need to be ended manually. +Game submissions resolve the submitted slug through [IGDB authenticated API](https://api-docs.igdb.com/), then check the resolved ID and slug against the live [GameDB JSON API](https://github.com/LizardByte/GameDB#data). An unavailable or mismatched result blocks approval. App submissions have no GameDB counterpart and always need separate maintainer review. Steam generates Windows, Linux, and macOS commands (`steam://` on Windows, `setsid steam` on Linux, and `open` on macOS); Epic Games generates Windows and macOS commands; Microsoft Store generates a Windows command. Launcher support does not prove that a particular game has a build for every OS, so reviewers should check availability. Steam presets link to [ProtonDB](https://www.protondb.com/) and the website shows its Linux compatibility tier when available at build time. Compatibility reports are community data and do not block approval. Launcher ID commands may outlive Sunshine process tracking; the stream may need to be ended manually. ## Repository and deployment @@ -67,6 +67,8 @@ Game submissions resolve the submitted slug through [IGDB authenticated API](htt - `auto_approved_users.json`: trusted approvers and game submitters eligible for automatic queueing. - `.readthedocs.yaml`: pull request preview builds using the shared LizardByte Jekyll script. +On each push to master, the Migrate Database workflow checks `database/migrations.json` for completed migration IDs. Before applying pending migrations, it creates a `database-backup-` branch pointing to the untouched database. It then commits the migrated records and history file to the active `database` branch and triggers a fresh Pages build. Re-runs skip completed migrations. The backup branch is never used as the working database. + The Pages workflow creates an archive from the database and site template, then calls the same [LizardByte Jekyll build workflow](https://github.com/LizardByte/LizardByte.github.io/blob/master/.github/workflows/jekyll-build.yml) used by GameDB and ThemerrDB. It deploys to `gh-pages` after changes to `master` or an approved database update. The empty `database` branch is initialized with `database/apps` and `database/games`. GitHub Pages serves the empty orphan `gh-pages` branch. Configure `GH_BOT_TOKEN`, `GH_BOT_EMAIL`, `GH_BOT_NAME`, `TWITCH_CLIENT_ID`, and `TWITCH_CLIENT_SECRET` as in the existing LizardByte database projects. `GH_BOT_TOKEN` needs issue, contents, and Actions write access to publish the database and dispatch the Pages build. The Twitch credentials resolve IGDB slugs; GameDB itself needs no credentials. The request, approval, and method labels are configured in the repository. Set the GitHub repository description to the heading description above. The `database` branch retains the `database/` directory; approved records are written there. diff --git a/docs/developerSetup.md b/docs/developerSetup.md index 6659258375..2dbf9d72b9 100644 --- a/docs/developerSetup.md +++ b/docs/developerSetup.md @@ -8,6 +8,14 @@ npm test npm run lint ``` +List pending database migrations without changing records with: + +```shell +node src/migrate-database.js --database database --pending +``` + +The `Migrate Database` workflow runs on pushes to `master`. It creates a backup branch from the current database commit before changing records, then writes applied migration IDs and backup details to `database/migrations.json` on the active `database` branch. Re-runs skip IDs already in that file. + Build the static site from the local database layout with: ```shell diff --git a/docs/presetGuidelines.md b/docs/presetGuidelines.md index da256ebeb7..06f1fdb6d0 100644 --- a/docs/presetGuidelines.md +++ b/docs/presetGuidelines.md @@ -1,6 +1,6 @@ # Preset Guidelines -One issue requests one Sunshine launch option. Native, GOG, and app commands target a chosen host OS. Store IDs generate commands for each supported launcher OS; emulator commands must be portable across hosts. Submit another issue for a different store, emulator, or core. The bot generates names from the game or app name and launch method, plus the host OS when selected. +One issue requests one launch option. Native, GOG, and app commands target a chosen host OS. Store IDs generate commands for each supported launcher OS; emulator commands must be portable across hosts. Submit another issue for a different store, emulator, or core. The bot generates names from the game or app name and launch method, plus the host OS when selected. ## Games @@ -14,11 +14,11 @@ Provide the official HTTPS homepage or source repository. An app image is option ## Commands and paths -Steam, Epic Games, and Microsoft Store forms each have one Launch ID field. Steam takes the numeric app ID from its store URL. Epic Games takes the three-part Sandbox ID, Catalog ID, and Artifact ID from the launcher shortcut, separated by colons or %3A. Microsoft Store takes the installed app [AUMID](https://learn.microsoft.com/en-us/windows/configuration/store/find-aumid) from Get-StartApps; a Store product ID cannot launch the installed game. The bot generates Sunshine commands for each supported launcher OS. These are launcher capabilities; reviewers must check whether the game itself supports each platform. +Steam, Epic Games, and Microsoft Store forms each have one Launch ID field. Steam takes the numeric app ID from its store URL. Epic Games takes the three-part Sandbox ID, Catalog ID, and Artifact ID from the launcher shortcut, separated by colons or %3A. Microsoft Store takes the installed app [AUMID](https://learn.microsoft.com/en-us/windows/configuration/store/find-aumid) from Get-StartApps; a Store product ID cannot launch the installed game. The bot generates commands for each supported launcher OS. These are launcher capabilities; reviewers must check whether the game itself supports each platform. -Native, GOG, and Emulator forms require a Command. GOG games can launch without Galaxy, so no single GOG ID command is assumed. Emulator commands must work across host OSes, using executable names on the host PATH and portable path placeholders. A maintainer reviews manual commands before approval. Store launchers can exit before their game; Sunshine may keep the stream open until the user ends it. PresetDB uses only Sunshine cmd. +Native, GOG, and Emulator forms require a Command. GOG games can launch without Galaxy, so no single GOG ID command is assumed. Emulator commands must work across host OSes, using executable names on the host PATH and portable path placeholders. A maintainer reviews manual commands before approval. Store launchers can exit before their game; Sunshine may keep the stream open until the user ends it. PresetDB stores generic command fields; the website can export a Sunshine application JSON object using `cmd`. -The supported path placeholders are ROM_PATH and HOME, written with double curly braces in the form. Windows Native, GOG, and app commands also support SYSTEM_DRIVE, PROGRAM_FILES, and PROGRAM_FILES_X86. Replace placeholders with paths on the Sunshine host before use. ROM_PATH is for emulator games. Literal user home paths and Windows reserved device names are rejected in Command and Working directory. +The supported path placeholders are ROM_PATH and HOME, written with double curly braces in the form. Windows Native, GOG, and app commands also support SYSTEM_DRIVE, PROGRAM_FILES, and PROGRAM_FILES_X86. Replace placeholders with paths on the host before use. ROM_PATH is for emulator games. Literal user home paths and Windows reserved device names are rejected in Command and Working directory. Steam presets link to [ProtonDB](https://www.protondb.com/). The website shows the reported Linux compatibility tier when the Pages build can fetch one; no rating means unknown, not incompatible. Compatibility data comes from [ProtonDB contributors](https://github.com/bdefore/protondb-data) under the [Open Database License](https://opendatacommons.org/licenses/odbl/). diff --git a/gh-pages-template/assets/js/app.js b/gh-pages-template/assets/js/app.js index 532bfa0f00..cceb1ac20d 100644 --- a/gh-pages-template/assets/js/app.js +++ b/gh-pages-template/assets/js/app.js @@ -12,10 +12,16 @@ function filterItems(index, query, kind, os) { ).sort((a, b) => a.name.localeCompare(b.name)); } +function commandForOs(preset, os = 'Windows') { + const command = preset.commands_by_os?.[os] || preset.command; + if (!command) throw new Error('Choose an available host OS'); + return command; +} + function sunshineSnippet(preset, os = 'Windows') { - const sunshine = preset.sunshine_by_os?.[os] || preset.sunshine; - if (!sunshine) throw new Error('Choose an available host OS'); - return JSON.stringify(sunshine, null, 2); + const snippet = { name: preset.name, cmd: commandForOs(preset, os) }; + if (preset.working_directory) snippet['working-dir'] = preset.working_directory; + return JSON.stringify(snippet, null, 2); } function normalizeBasePath(value) { @@ -75,10 +81,10 @@ function appendProtonRating(body, preset) { } function hostSelection(body, preset) { - if (!preset.sunshine_by_os) return null; + if (!preset.commands_by_os) return null; const label = element('label', 'form-label', 'Host OS'); const select = element('select', 'form-select rounded-0 mb-3'); - for (const host of Object.keys(preset.sunshine_by_os)) { + for (const host of Object.keys(preset.commands_by_os)) { const option = element('option', '', host); option.value = host; select.append(option); @@ -111,23 +117,45 @@ function renderPresetCard(preset) { if (preset.notes) body.append(element('p', 'card-text', preset.notes)); appendProtonRating(body, preset); const hostSelect = hostSelection(body, preset); + const commandText = () => commandForOs(preset, hostSelect?.value); const snippet = () => sunshineSnippet(preset, hostSelect?.value); const command = element('pre', 'p-3 rounded bg-dark text-light overflow-auto'); - const code = element('code', '', snippet()); + const code = element('code', '', commandText()); command.append(code); - if (hostSelect) hostSelect.addEventListener('change', () => { code.textContent = snippet(); }); body.append(command); - const copy = element('button', 'btn btn-warning rounded-0', 'Copy Sunshine JSON'); + const copy = element('button', 'btn btn-warning rounded-0', 'Copy command'); copy.type = 'button'; copy.addEventListener('click', async () => { try { - await navigator.clipboard.writeText(snippet()); + await navigator.clipboard.writeText(commandText()); copy.textContent = 'Copied'; } catch { - copy.textContent = 'Select and copy the JSON above'; + copy.textContent = 'Select and copy the command above'; } }); body.append(copy); + const details = element('details', 'mt-3'); + details.append(element('summary', 'mb-2', 'Sunshine JSON')); + const sunshineCode = element('code', '', snippet()); + const sunshineJson = element('pre', 'p-3 rounded bg-dark text-light overflow-auto'); + sunshineJson.append(sunshineCode); + details.append(sunshineJson); + const copyJson = element('button', 'btn btn-outline-secondary rounded-0', 'Copy Sunshine JSON'); + copyJson.type = 'button'; + copyJson.addEventListener('click', async () => { + try { + await navigator.clipboard.writeText(snippet()); + copyJson.textContent = 'Copied'; + } catch { + copyJson.textContent = 'Select and copy the JSON above'; + } + }); + details.append(copyJson); + body.append(details); + if (hostSelect) hostSelect.addEventListener('change', () => { + code.textContent = commandText(); + sunshineCode.textContent = snippet(); + }); appendIssueLinks(body, preset); card.append(body); column.append(card); @@ -221,4 +249,4 @@ function boot() { } if (typeof document !== 'undefined') document.addEventListener('DOMContentLoaded', boot); -if (typeof module !== 'undefined') module.exports = { filterItems, sunshineSnippet, normalizeBasePath }; +if (typeof module !== 'undefined') module.exports = { filterItems, commandForOs, sunshineSnippet, normalizeBasePath }; diff --git a/gh-pages-template/index.html b/gh-pages-template/index.html index 042ee808db..0b711f7d08 100644 --- a/gh-pages-template/index.html +++ b/gh-pages-template/index.html @@ -1,5 +1,5 @@ --- -title: Sunshine Presets +title: PresetDB layout: page full-width: true after-content: @@ -14,8 +14,8 @@
-

Sunshine game and app presets

-

Community launch options for Windows, Linux, and macOS. Find a game or app, then choose the preset that fits your host.

+

Game and app launch presets

+

Community launch commands for Windows, Linux, and macOS. Browse presets, copy a command, or export Sunshine application JSON.

Contribute a game preset Contribute an app preset diff --git a/package.json b/package.json index 6dbcc7a574..e09e70d63c 100644 --- a/package.json +++ b/package.json @@ -2,14 +2,14 @@ "name": "presetdb", "version": "0.0.0", "private": true, - "description": "Community Sunshine game and app launch presets.", + "description": "Community game and app launch presets.", "license": "AGPL-3.0-only", "engines": { "node": ">=20" }, "scripts": { "test": "node --test tests/*.test.js", - "lint": "node --check src/presets.js && node --check src/database.js && node --check src/issue.js && node --check src/build-site.js && node --check src/statistics.js && node --check src/approval-queue.js && node --check src/comment-command.js && node --check src/verify-approval.js && node --check src/workflow-queue.js && node --check gh-pages-template/assets/js/app.js", + "lint": "node --check src/presets.js && node --check src/record.js && node --check src/migrate-database.js && node --check src/database.js && node --check src/issue.js && node --check src/build-site.js && node --check src/statistics.js && node --check src/approval-queue.js && node --check src/comment-command.js && node --check src/verify-approval.js && node --check src/workflow-queue.js && node --check gh-pages-template/assets/js/app.js", "test:ci": "node --test --experimental-test-coverage --test-reporter=junit --test-reporter=lcov --test-reporter-destination=junit.xml --test-reporter-destination=lcov.info tests/*.test.js" } } diff --git a/src/build-site.js b/src/build-site.js index 33b38b70b0..c01d26ec57 100644 --- a/src/build-site.js +++ b/src/build-site.js @@ -3,10 +3,11 @@ const fs = require('node:fs'); const path = require('node:path'); const { buildStatistics } = require('./statistics'); +const { normalizeRecord } = require('./record'); function supportedOs(preset) { if (preset.os) return [preset.os]; - return preset.sunshine_by_os ? Object.keys(preset.sunshine_by_os) : ['Windows', 'Linux', 'macOS']; + return preset.commands_by_os ? Object.keys(preset.commands_by_os) : ['Windows', 'Linux', 'macOS']; } const PROTON_TIERS = new Set(['borked', 'bronze', 'silver', 'gold', 'platinum', 'native']); @@ -51,7 +52,7 @@ async function buildSite(database, template, output, fetcher = globalThis.fetch) const target = path.join(output, folder); fs.mkdirSync(target, { recursive: true }); for (const file of files) { - const item = JSON.parse(fs.readFileSync(path.join(directory, file), 'utf8')); + const item = normalizeRecord(JSON.parse(fs.readFileSync(path.join(directory, file), 'utf8'))); if (item.kind !== kind || !Array.isArray(item.presets) || item.presets.length === 0 || String(item.id) !== path.basename(file, '.json')) { throw new Error(`Invalid database record: ${folder}/${file}`); diff --git a/src/database.js b/src/database.js index 8d235b1e4c..77a14c898c 100644 --- a/src/database.js +++ b/src/database.js @@ -3,6 +3,7 @@ const fs = require('node:fs'); const path = require('node:path'); const { PresetError } = require('./presets'); +const { normalizeRecord } = require('./record'); function recordPath(root, preset) { const folder = preset.kind === 'game' ? 'games' : 'apps'; @@ -12,7 +13,7 @@ function recordPath(root, preset) { function readRecord(file) { if (!fs.existsSync(file)) return null; - const record = JSON.parse(fs.readFileSync(file, 'utf8')); + const record = normalizeRecord(JSON.parse(fs.readFileSync(file, 'utf8'))); if (!record || !Array.isArray(record.presets)) throw new PresetError(`Invalid database record: ${file}`); return record; } @@ -25,7 +26,7 @@ function prepareRecord(root, preset) { let record = readRecord(file); if (!record) { record = { - schema_version: 1, kind: preset.kind, id: preset.kind === 'game' ? preset.gameId : preset.appId, + schema_version: 2, kind: preset.kind, id: preset.kind === 'game' ? preset.gameId : preset.appId, name, source_url: sourceUrl, image_url: null, presets: [] }; } @@ -79,7 +80,7 @@ function mergePreset(root, preset, { const { file, record, name } = prepareRecord(root, preset); const previous = replacementIndex(record, preset); const originIssue = previous >= 0 ? record.presets[previous].origin_issue : issueNumber; - const presetId = `issue-${originIssue}`; + const presetId = String(originIssue); const history = previous >= 0 ? [...(record.presets[previous].history || [])] : []; if (history.some(item => item.issue === issueNumber)) { throw new PresetError(`Issue #${issueNumber} has already been approved for this preset`); @@ -93,17 +94,12 @@ function mergePreset(root, preset, { id: presetId, name: generatedName, os: preset.os, method: preset.method, ...(preset.variantName ? { variant_name: preset.variantName } : {}), ...(preset.launchId ? { launch_id: preset.launchId } : {}), - ...(preset.commandsByOs ? { - sunshine_by_os: Object.fromEntries(Object.entries(preset.commandsByOs).map(([os, cmd]) => [ - os, { name: name + ' (' + os + ', ' + methodLabel(preset.method) + ')', cmd } - ])) - } : { - sunshine: { - name: generatedName, - cmd: preset.command, - ...(preset.workingDir ? { 'working-dir': preset.workingDir } : {}) - } - }), + ...(preset.commandsByOs + ? { commands_by_os: preset.commandsByOs } + : { + command: preset.command, + ...(preset.workingDir ? { working_directory: preset.workingDir } : {}) + }), notes: preset.notes, origin_issue: originIssue, source_issue: issueNumber, @@ -113,7 +109,7 @@ function mergePreset(root, preset, { }; if (previous >= 0) record.presets[previous] = entry; else record.presets.push(entry); - record.presets.sort((a, b) => a.id.localeCompare(b.id)); + record.presets.sort((a, b) => Number(a.id) - Number(b.id)); if (write) { fs.mkdirSync(path.dirname(file), { recursive: true }); fs.writeFileSync(file, `${JSON.stringify(record, null, 2)}\n`); diff --git a/src/issue.js b/src/issue.js index ac01649d47..021cd16231 100644 --- a/src/issue.js +++ b/src/issue.js @@ -69,12 +69,12 @@ async function main(args = process.argv.slice(2)) { }); const item = result.kind === 'game' ? `GameDB game ${result.preset.gameId}` : `app ${result.preset.appName}`; const entry = result.record.presets.find(preset => preset.id === result.id); - const preview = entry.sunshine || entry.sunshine_by_os; + const preview = entry.commands_by_os || { command: entry.command, ...(entry.working_directory ? { working_directory: entry.working_directory } : {}) }; const methodLine = result.kind === 'game' ? `- Method: ${result.preset.method}\n` : ''; message = `Preset ${result.action === 'replace' ? 'replacement' : 'request'} validated for ${item}.\n\n` + `- Host: ${result.preset.os || 'OS independent'}\n` + methodLine + `- Preset ID: \`${result.id}\`\n` + `- Status: ${options.mode === 'approve' ? 'approved and saved' : 'awaiting maintainer review'}\n\n` + - `Sunshine application JSON preview:\n\n\`\`\`json\n${JSON.stringify(preview, null, 2)}\n\`\`\`\n`; + `Launch command preview:\n\n\`\`\`json\n${JSON.stringify(preview, null, 2)}\n\`\`\`\n`; success = true; } catch (error) { message = `Preset validation failed: ${String(error.message).replace(/[\r\n]+/g, ' ').slice(0, 500)}\n`; diff --git a/src/migrate-database.js b/src/migrate-database.js new file mode 100644 index 0000000000..b82896019a --- /dev/null +++ b/src/migrate-database.js @@ -0,0 +1,81 @@ +'use strict'; + +const fs = require('node:fs'); +const path = require('node:path'); +const { normalizeRecord } = require('./record'); + +const MIGRATIONS = [{ + id: '001-generic-launch-presets', + apply(database) { + for (const folder of ['games', 'apps']) { + const directory = path.join(database, folder); + if (!fs.existsSync(directory)) continue; + for (const file of fs.readdirSync(directory).filter(name => name.endsWith('.json'))) { + const recordPath = path.join(directory, file); + const record = JSON.parse(fs.readFileSync(recordPath, 'utf8')); + const migrated = normalizeRecord(record); + if (migrated !== record) fs.writeFileSync(recordPath, JSON.stringify(migrated, null, 2) + '\n'); + } + } + } +}]; + +function migrationHistory(database) { + const file = path.join(database, 'migrations.json'); + if (!fs.existsSync(file)) return { applied: [] }; + const history = JSON.parse(fs.readFileSync(file, 'utf8')); + if (!history || !Array.isArray(history.applied) || + history.applied.some(entry => typeof entry.id !== 'string') || + new Set(history.applied.map(entry => entry.id)).size !== history.applied.length) { + throw new Error('Invalid database migration history'); + } + return history; +} + +function pendingMigrations(database) { + const applied = new Set(migrationHistory(database).applied.map(entry => entry.id)); + return MIGRATIONS.filter(migration => !applied.has(migration.id)); +} + +function applyMigrations(database, { backupBranch, sourceCommit, appliedAt = new Date().toISOString() } = {}) { + const pending = pendingMigrations(database); + if (pending.length === 0) return []; + if (!/^database-backup-[a-z0-9-]+$/.test(backupBranch || '') || + !/^[0-9a-f]{40}$/.test(sourceCommit || '')) { + throw new Error('A database backup branch and its source commit are required'); + } + const history = migrationHistory(database); + for (const migration of pending) { + migration.apply(database); + history.applied.push({ + id: migration.id, + backup_branch: backupBranch, + source_commit: sourceCommit, + applied_at: appliedAt + }); + } + fs.writeFileSync(path.join(database, 'migrations.json'), JSON.stringify(history, null, 2) + '\n'); + return pending.map(migration => migration.id); +} + +function main(args = process.argv.slice(2)) { + const options = {}; + for (let i = 0; i < args.length; i++) { + if (args[i] === '--pending') options.pending = true; + else if (args[i].startsWith('--') && args[i + 1]) options[args[i++].slice(2)] = args[i]; + else throw new Error('Use --database with --pending or --backup-branch and --source-commit'); + } + if (!options.database) throw new Error('--database is required'); + if (options.pending) { + console.log(pendingMigrations(options.database).map(migration => migration.id).join('\n')); + return; + } + const applied = applyMigrations(options.database, { + backupBranch: options['backup-branch'], sourceCommit: options['source-commit'] + }); + console.log(applied.length ? 'Applied migrations: ' + applied.join(', ') : 'No pending migrations'); +} + +if (require.main === module) main(); + +module.exports = { MIGRATIONS, migrationHistory, pendingMigrations, applyMigrations }; diff --git a/src/presets.js b/src/presets.js index 3304576f3f..286595275b 100644 --- a/src/presets.js +++ b/src/presets.js @@ -72,7 +72,7 @@ function validatePlaceholders(value, os, label) { } function validatePortablePath(value, os, label) { - // Personal home locations cannot be shared between Sunshine hosts. + // Personal home locations cannot be shared between hosts. const normalized = value.replaceAll('\\', '/'); const homeRoot = /(?:^|[\s"'=])(?:~\/|[a-z]:\/(?:users|documents and settings)\/|\/(?:home|users)\/)/i; const homeVariable = /%userprofile%|%homepath%|\$home|\$\{home\}|\$\(home\)/i; @@ -127,9 +127,9 @@ function steamLaunch(id) { } const uri = 'steam://rungameid/' + id; return { launchId: id, commandsByOs: { - Windows: 'cmd /c start "" "' + uri + '"', - Linux: 'steam "' + uri + '"', - macOS: 'open "' + uri + '"' + Windows: uri, + Linux: 'setsid steam ' + uri, + macOS: 'open ' + uri } }; } @@ -141,8 +141,8 @@ function epicLaunch(id) { const launchId = parts.join('%3A'); const uri = 'com.epicgames.launcher://apps/' + launchId + '?action=launch&silent=true'; return { launchId, commandsByOs: { - Windows: 'cmd /c start "" "' + uri + '"', - macOS: 'open "' + uri + '"' + Windows: uri, + macOS: 'open ' + uri } }; } @@ -308,4 +308,4 @@ async function validateGameDb(preset, fetcher = globalThis.fetch, credentials = return preset; } -module.exports = { PresetError, parseIssue, validateFields, validateGameDb, fetchGameDb, resolveIgdbSlug, validatePlaceholders, slug }; +module.exports = { PresetError, parseIssue, validateFields, validateGameDb, fetchGameDb, resolveIgdbSlug, validatePlaceholders, slug, generatedLaunch }; diff --git a/src/record.js b/src/record.js new file mode 100644 index 0000000000..63536bdbb1 --- /dev/null +++ b/src/record.js @@ -0,0 +1,34 @@ +'use strict'; + +const { PresetError, generatedLaunch } = require('./presets'); + +function normalizeRecord(record) { + if (!record || !Array.isArray(record.presets)) throw new PresetError('Invalid database record'); + if (record.schema_version === 2) return record; + if (record.schema_version !== 1) throw new PresetError('Unsupported database schema version'); + + return { + ...record, + schema_version: 2, + presets: record.presets.map(preset => { + const item = { ...preset }; + const legacyId = /^issue-([1-9]\d*)$/.exec(item.id); + if (legacyId) item.id = legacyId[1]; + if (item.sunshine_by_os) { + const method = { steam: 'Steam', 'epic-games': 'Epic Games', 'microsoft-store': 'Microsoft Store' }[item.method]; + item.commands_by_os = method && item.launch_id + ? generatedLaunch({ launchId: item.launch_id }, method).commandsByOs + : Object.fromEntries(Object.entries(item.sunshine_by_os).map(([os, launch]) => [os, launch.cmd])); + delete item.sunshine_by_os; + } + if (item.sunshine) { + item.command = item.sunshine.cmd; + if (item.sunshine['working-dir']) item.working_directory = item.sunshine['working-dir']; + delete item.sunshine; + } + return item; + }) + }; +} + +module.exports = { normalizeRecord }; diff --git a/tests/migration.test.js b/tests/migration.test.js new file mode 100644 index 0000000000..08d41203e0 --- /dev/null +++ b/tests/migration.test.js @@ -0,0 +1,60 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { applyMigrations, migrationHistory, pendingMigrations } = require('../src/migrate-database'); + +test('migration records its backup and does not rerun', t => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'preset-migrate-')); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + const games = path.join(root, 'games'); + fs.mkdirSync(games); + const file = path.join(games, '123.json'); + fs.writeFileSync(file, JSON.stringify({ + schema_version: 1, kind: 'game', id: 123, name: 'Example', + presets: [{ id: 'issue-4', name: 'Example (Native)', method: 'native', os: 'Windows', + sunshine: { name: 'Example (Native)', cmd: 'example.exe', 'working-dir': 'C:\\Games' } }] + })); + const sourceCommit = 'a'.repeat(40); + const backupBranch = 'database-backup-' + sourceCommit; + assert.deepEqual(pendingMigrations(root).map(item => item.id), ['001-generic-launch-presets']); + assert.deepEqual(applyMigrations(root, { + backupBranch, sourceCommit, appliedAt: '2026-09-23T00:00:00.000Z' + }), ['001-generic-launch-presets']); + const record = JSON.parse(fs.readFileSync(file, 'utf8')); + assert.equal(record.schema_version, 2); + assert.equal(record.presets[0].id, '4'); + assert.equal(record.presets[0].command, 'example.exe'); + assert.equal(record.presets[0].working_directory, 'C:\\Games'); + assert.ok(!Object.hasOwn(record.presets[0], 'sunshine')); + assert.deepEqual(migrationHistory(root), { applied: [{ + id: '001-generic-launch-presets', + backup_branch: backupBranch, + source_commit: sourceCommit, + applied_at: '2026-09-23T00:00:00.000Z' + }] }); + const content = fs.readFileSync(file, 'utf8'); + assert.deepEqual(pendingMigrations(root), []); + assert.deepEqual(applyMigrations(root, { backupBranch: 'database-backup-other' }), []); + assert.equal(fs.readFileSync(file, 'utf8'), content); +}); + +test('migration requires a backup reference before changing the database', t => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'preset-migrate-backup-')); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + assert.throws(() => applyMigrations(root, {}), /backup branch/); + assert.deepEqual(pendingMigrations(root).map(item => item.id), ['001-generic-launch-presets']); + assert.ok(!fs.existsSync(path.join(root, 'migrations.json'))); +}); + +test('migration history rejects duplicate IDs', t => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'preset-migrate-history-')); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + fs.writeFileSync(path.join(root, 'migrations.json'), JSON.stringify({ + applied: [{ id: '001-generic-launch-presets' }, { id: '001-generic-launch-presets' }] + })); + assert.throws(() => pendingMigrations(root), /Invalid database migration history/); +}); diff --git a/tests/presets.test.js b/tests/presets.test.js index d39dc7cba4..1ec4b64b11 100644 --- a/tests/presets.test.js +++ b/tests/presets.test.js @@ -9,7 +9,7 @@ const { parseIssue, validateFields, validateGameDb, resolveIgdbSlug } = require( const { mergePreset } = require('../src/database'); const { processIssue } = require('../src/issue'); const { buildSite } = require('../src/build-site'); -const { filterItems, sunshineSnippet, normalizeBasePath } = require('../gh-pages-template/assets/js/app'); +const { filterItems, commandForOs, sunshineSnippet, normalizeBasePath } = require('../gh-pages-template/assets/js/app'); const credentials = { clientId: 'client-id', clientSecret: 'client-secret' }; const gameValues = { @@ -120,14 +120,17 @@ test('store IDs generate commands for each launcher OS without asking for OS', ( const steam = validateFields({ ...base, method: 'Steam', launchId: '464920' }, 'game'); assert.equal(steam.os, null); assert.deepEqual(steam.commandsByOs, { - Windows: 'cmd /c start "" "steam://rungameid/464920"', - Linux: 'steam "steam://rungameid/464920"', - macOS: 'open "steam://rungameid/464920"' + Windows: 'steam://rungameid/464920', + Linux: 'setsid steam steam://rungameid/464920', + macOS: 'open steam://rungameid/464920' }); const epic = validateFields({ ...base, method: 'Epic Games', launchId: 'fn:4fe75bbc5a674f4f9b356b5c90567da5:Fortnite' }, 'game'); assert.equal(epic.launchId, 'fn%3A4fe75bbc5a674f4f9b356b5c90567da5%3AFortnite'); - assert.deepEqual(Object.keys(epic.commandsByOs), ['Windows', 'macOS']); + assert.deepEqual(epic.commandsByOs, { + Windows: 'com.epicgames.launcher://apps/fn%3A4fe75bbc5a674f4f9b356b5c90567da5%3AFortnite?action=launch&silent=true', + macOS: 'open com.epicgames.launcher://apps/fn%3A4fe75bbc5a674f4f9b356b5c90567da5%3AFortnite?action=launch&silent=true' + }); const aumid = 'Microsoft.WindowsCalculator_8wekyb3d8bbwe!App'; const store = validateFields({ ...base, method: 'Microsoft Store', launchId: aumid }, 'game'); assert.deepEqual(store.commandsByOs, { Windows: 'explorer.exe shell:AppsFolder\\' + aumid }); @@ -176,7 +179,7 @@ test('approved presets get issue IDs and replacements preserve identity', async const root = fs.mkdtempSync(path.join(os.tmpdir(), 'preset-db-')); t.after(() => fs.rmSync(root, { recursive: true, force: true })); const first = await validateGameDb(validateFields(gameValues, 'game'), mockApis, credentials); - assert.equal(mergePreset(root, first, { issueNumber: 10, approvedBy: 'maintainer' }, { write: true }).id, 'issue-10'); + assert.equal(mergePreset(root, first, { issueNumber: 10, approvedBy: 'maintainer' }, { write: true }).id, '10'); assert.throws(() => mergePreset(root, first, { issueNumber: 11, approvedBy: 'maintainer' }), /already exists/); assert.throws(() => mergePreset(root, { ...first, variantName: 'retroarch snes9x' }, { issueNumber: 11, approvedBy: 'maintainer' }), /already exists/); @@ -184,27 +187,29 @@ test('approved presets get issue IDs and replacements preserve identity', async mergePreset(root, second, { issueNumber: 12, approvedBy: 'maintainer' }, { write: true }); const replacement = await validateGameDb(validateFields({ ...gameValues, command: 'new-command', replacementIssue: '10', replacementReason: 'Old command failed' }, 'game'), mockApis, credentials); - assert.equal(mergePreset(root, replacement, { issueNumber: 13, approvedBy: 'maintainer' }, { write: true }).id, 'issue-10'); + assert.equal(mergePreset(root, replacement, { issueNumber: 13, approvedBy: 'maintainer' }, { write: true }).id, '10'); const record = JSON.parse(fs.readFileSync(path.join(root, 'games/100245.json'))); + assert.equal(record.schema_version, 2); assert.equal(record.presets.length, 2); - assert.equal(record.presets.find(item => item.id === 'issue-10').sunshine.cmd, 'new-command'); - assert.equal(record.presets.find(item => item.id === 'issue-10').name, 'One Tap Hero (Emulator: RetroArch Snes9x)'); - assert.equal(record.presets.find(item => item.id === 'issue-10').origin_issue, 10); - assert.equal(record.presets.find(item => item.id === 'issue-10').source_issue, 13); + assert.deepEqual(record.presets.map(item => item.id), ['10', '12']); + assert.equal(record.presets.find(item => item.id === '10').command, 'new-command'); + assert.equal(record.presets.find(item => item.id === '10').name, 'One Tap Hero (Emulator: RetroArch Snes9x)'); + assert.equal(record.presets.find(item => item.id === '10').origin_issue, 10); + assert.equal(record.presets.find(item => item.id === '10').source_issue, 13); assert.throws(() => validateFields({ ...gameValues, replacementIssue: '10' }, 'game'), /replacement needs both/); assert.throws(() => mergePreset(root, { ...first, replacementIssue: 99, replacementReason: 'Fix' }, { issueNumber: 14, approvedBy: 'maintainer' }), /No preset/); }); -test('Steam app ID publishes one logical preset with host-specific Sunshine commands', async t => { +test('Steam app ID publishes one logical preset with host-specific commands', async t => { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'preset-steam-')); t.after(() => fs.rmSync(root, { recursive: true, force: true })); const steam = await validateGameDb(validateFields({ ...gameValues, method: 'Steam', variantName: '', command: '', workingDir: '', launchId: '464920' }, 'game'), mockApis, credentials); const { record } = mergePreset(root, steam, { issueNumber: 30, approvedBy: 'reviewer' }, { write: true }); assert.equal(record.presets[0].launch_id, '464920'); - assert.equal(record.presets[0].sunshine_by_os.Windows.cmd, 'cmd /c start "" "steam://rungameid/464920"'); - assert.equal(record.presets[0].sunshine_by_os.Linux.cmd, 'steam "steam://rungameid/464920"'); + assert.equal(record.presets[0].commands_by_os.Windows, 'steam://rungameid/464920'); + assert.equal(record.presets[0].commands_by_os.Linux, 'setsid steam steam://rungameid/464920'); assert.equal(record.presets[0].name, 'One Tap Hero (Steam)'); assert.ok(!Object.hasOwn(record.presets[0], 'detached')); assert.ok(!Object.hasOwn(record.presets[0], 'image-path')); @@ -240,7 +245,10 @@ test('site filters and serializes Sunshine application JSON', () => { apps: [{ id: 'app', name: 'Media', operating_systems: ['Linux'], preset_count: 1 }] }; assert.deepEqual(filterItems(index, 'hal', 'game', 'Windows').map(item => item.name), ['Halo']); assert.equal(filterItems(index, '', 'all', 'macOS').length, 0); - assert.equal(JSON.parse(sunshineSnippet({ sunshine: { name: 'Halo', cmd: 'game.exe' } })).cmd, 'game.exe'); + assert.equal(commandForOs({ command: 'game.exe' }), 'game.exe'); + assert.deepEqual(JSON.parse(sunshineSnippet({ name: 'Halo', command: 'game.exe', working_directory: 'C:\\Games' })), + { name: 'Halo', cmd: 'game.exe', 'working-dir': 'C:\\Games' }); + assert.throws(() => commandForOs({ commands_by_os: { Windows: 'game.exe' } }, 'Linux'), /available host OS/); assert.equal(normalizeBasePath('/PresetDB'), '/PresetDB'); assert.equal(normalizeBasePath('en/pr-123'), '/en/pr-123'); }); @@ -263,7 +271,46 @@ test('Pages build adds a ProtonDB tier to Steam presets and keeps the source lin assert.deepEqual(published.presets[0].protondb, { tier: 'gold', reports: 73 }); assert.equal(published.presets[0].protondb_url, 'https://www.protondb.com/app/464920'); assert.equal(JSON.parse(sunshineSnippet(published.presets[0], 'Linux')).cmd, - 'steam "steam://rungameid/464920"'); + 'setsid steam steam://rungameid/464920'); +}); + +test('legacy approved records publish generic commands and short IDs', async t => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'preset-legacy-')); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + const database = path.join(root, 'database'); + const output = path.join(root, 'site'); + fs.mkdirSync(path.join(database, 'games'), { recursive: true }); + const record = { + schema_version: 1, kind: 'game', id: 100245, name: 'One Tap Hero', + source_url: 'https://www.igdb.com/games/one-tap-hero', + presets: [{ id: 'issue-4', name: 'One Tap Hero (Steam)', os: null, method: 'steam', + launch_id: '464920', origin_issue: 4, source_issue: 4, + sunshine_by_os: { Windows: { cmd: 'cmd /c start "" "steam://rungameid/464920"' }, + Linux: { cmd: 'steam "steam://rungameid/464920"' }, + macOS: { cmd: 'open "steam://rungameid/464920"' } } }] + }; + fs.writeFileSync(path.join(database, 'games/100245.json'), JSON.stringify(record)); + await buildSite(database, path.join(__dirname, '..', 'gh-pages-template'), output, + async () => ({ ok: false, status: 404 })); + const published = JSON.parse(fs.readFileSync(path.join(output, 'games/100245.json'))); + assert.equal(published.schema_version, 2); + assert.equal(published.presets[0].id, '4'); + assert.deepEqual(published.presets[0].commands_by_os, { + Windows: 'steam://rungameid/464920', + Linux: 'setsid steam steam://rungameid/464920', + macOS: 'open steam://rungameid/464920' + }); + assert.ok(!Object.hasOwn(published.presets[0], 'sunshine_by_os')); + const replacement = await validateGameDb(validateFields({ ...gameValues, method: 'Steam', + variantName: '', command: '', workingDir: '', launchId: '464920', + replacementIssue: '4', replacementReason: 'Refresh the launcher command' }, 'game'), mockApis, credentials); + const merged = mergePreset(database, replacement, { issueNumber: 50, approvedBy: 'reviewer' }, { write: true }); + assert.equal(merged.id, '4'); + const stored = JSON.parse(fs.readFileSync(path.join(database, 'games/100245.json'))); + assert.equal(stored.schema_version, 2); + assert.equal(stored.presets[0].id, '4'); + assert.equal(stored.presets[0].source_issue, 50); + assert.equal(stored.presets[0].commands_by_os.Linux, 'setsid steam steam://rungameid/464920'); }); test('game method labels select the form and reject conflicting issue fields', async t => { From 2b3a7be32eab6b5027d0d568d10c0e4011db8d8b Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Wed, 23 Sep 2026 19:14:00 -0400 Subject: [PATCH 2/6] test: cover migration preview and preset exports --- .github/workflows/build-pages.yml | 9 +++ README.md | 2 +- docs/developerSetup.md | 2 + gh-pages-template/assets/js/app.js | 2 +- src/issue.js | 2 +- src/migrate-database.js | 8 ++- tests/issue-cli.test.js | 33 +++++++++++ tests/migration-cli.test.js | 36 ++++++++++++ tests/migration.test.js | 34 +++++++++++ tests/site-card.test.js | 91 ++++++++++++++++++++++++++++++ 10 files changed, 213 insertions(+), 6 deletions(-) create mode 100644 tests/issue-cli.test.js create mode 100644 tests/migration-cli.test.js create mode 100644 tests/site-card.test.js diff --git a/.github/workflows/build-pages.yml b/.github/workflows/build-pages.yml index 757cbabde7..5d696fd556 100644 --- a/.github/workflows/build-pages.yml +++ b/.github/workflows/build-pages.yml @@ -36,6 +36,15 @@ jobs: with: node-version: ${{ env.NODE_VERSION }} + - name: Preview database migrations + if: github.event_name == 'pull_request' + shell: bash + run: | + set -euo pipefail + source_commit=$(git -C database-branch rev-parse HEAD) + node src/migrate-database.js --database database-branch/database \ + --backup-branch "database-backup-$source_commit" --source-commit "$source_commit" + - name: Assemble site run: node src/build-site.js --database database-branch/database --output site-build diff --git a/README.md b/README.md index 0868a04648..11aa05a6e2 100644 --- a/README.md +++ b/README.md @@ -69,7 +69,7 @@ Game submissions resolve the submitted slug through [IGDB authenticated API](htt On each push to master, the Migrate Database workflow checks `database/migrations.json` for completed migration IDs. Before applying pending migrations, it creates a `database-backup-` branch pointing to the untouched database. It then commits the migrated records and history file to the active `database` branch and triggers a fresh Pages build. Re-runs skip completed migrations. The backup branch is never used as the working database. -The Pages workflow creates an archive from the database and site template, then calls the same [LizardByte Jekyll build workflow](https://github.com/LizardByte/LizardByte.github.io/blob/master/.github/workflows/jekyll-build.yml) used by GameDB and ThemerrDB. It deploys to `gh-pages` after changes to `master` or an approved database update. +For pull requests, the Pages workflow applies pending migrations in its temporary database checkout before building the preview. It does not commit or push those preview changes. The Pages workflow creates an archive from the database and site template, then calls the same [LizardByte Jekyll build workflow](https://github.com/LizardByte/LizardByte.github.io/blob/master/.github/workflows/jekyll-build.yml) used by GameDB and ThemerrDB. It deploys to `gh-pages` after changes to `master` or an approved database update. The empty `database` branch is initialized with `database/apps` and `database/games`. GitHub Pages serves the empty orphan `gh-pages` branch. Configure `GH_BOT_TOKEN`, `GH_BOT_EMAIL`, `GH_BOT_NAME`, `TWITCH_CLIENT_ID`, and `TWITCH_CLIENT_SECRET` as in the existing LizardByte database projects. `GH_BOT_TOKEN` needs issue, contents, and Actions write access to publish the database and dispatch the Pages build. The Twitch credentials resolve IGDB slugs; GameDB itself needs no credentials. The request, approval, and method labels are configured in the repository. Set the GitHub repository description to the heading description above. The `database` branch retains the `database/` directory; approved records are written there. diff --git a/docs/developerSetup.md b/docs/developerSetup.md index 2dbf9d72b9..427c5e6fb6 100644 --- a/docs/developerSetup.md +++ b/docs/developerSetup.md @@ -32,4 +32,6 @@ SITE_ARTIFACT=update.zip EXTRACT_ARCHIVE=build.zip ``` +For pull requests, Build Pages applies pending migrations in the runner checkout of the database branch before assembling the preview. It does not commit or push that checkout. The Migrate Database workflow creates the real backup branch and updates the database only after a push to master. + The `Build Pages` workflow publishes a check run named `call-jekyll-build / Build Jekyll`, uploads an `update` artifact containing `build.zip`, and the shared script extracts that nested archive before building with the organization theme. Hosted preview and Pages deployment require the repository, credentials, and branch settings described in the README. diff --git a/gh-pages-template/assets/js/app.js b/gh-pages-template/assets/js/app.js index cceb1ac20d..c2d40a8ce1 100644 --- a/gh-pages-template/assets/js/app.js +++ b/gh-pages-template/assets/js/app.js @@ -249,4 +249,4 @@ function boot() { } if (typeof document !== 'undefined') document.addEventListener('DOMContentLoaded', boot); -if (typeof module !== 'undefined') module.exports = { filterItems, commandForOs, sunshineSnippet, normalizeBasePath }; +if (typeof module !== 'undefined') module.exports = { filterItems, commandForOs, sunshineSnippet, renderPresetCard, normalizeBasePath }; diff --git a/src/issue.js b/src/issue.js index 021cd16231..a6cff9b36a 100644 --- a/src/issue.js +++ b/src/issue.js @@ -87,4 +87,4 @@ async function main(args = process.argv.slice(2)) { if (require.main === module) main().catch(error => { console.error(error); process.exitCode = 1; }); -module.exports = { processIssue, requestKind }; +module.exports = { processIssue, requestKind, main }; diff --git a/src/migrate-database.js b/src/migrate-database.js index b82896019a..e188e83a3e 100644 --- a/src/migrate-database.js +++ b/src/migrate-database.js @@ -67,15 +67,17 @@ function main(args = process.argv.slice(2)) { } if (!options.database) throw new Error('--database is required'); if (options.pending) { - console.log(pendingMigrations(options.database).map(migration => migration.id).join('\n')); - return; + const pending = pendingMigrations(options.database).map(migration => migration.id); + console.log(pending.join('\n')); + return pending; } const applied = applyMigrations(options.database, { backupBranch: options['backup-branch'], sourceCommit: options['source-commit'] }); console.log(applied.length ? 'Applied migrations: ' + applied.join(', ') : 'No pending migrations'); + return applied; } if (require.main === module) main(); -module.exports = { MIGRATIONS, migrationHistory, pendingMigrations, applyMigrations }; +module.exports = { MIGRATIONS, migrationHistory, pendingMigrations, applyMigrations, main }; diff --git a/tests/issue-cli.test.js b/tests/issue-cli.test.js new file mode 100644 index 0000000000..a4805f6cab --- /dev/null +++ b/tests/issue-cli.test.js @@ -0,0 +1,33 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { main } = require('../src/issue'); + +test('issue command writes a contributor review with the short preset ID and command preview', async t => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'preset-issue-cli-')); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + const eventFile = path.join(root, 'event.json'); + const reportFile = path.join(root, 'report.md'); + fs.writeFileSync(eventFile, JSON.stringify({ issue: { + number: 51, + labels: ['request-app-preset'], + body: [ + '### App name', 'App One', + '### Official app URL', 'https://example.org/app', + '### Host operating system', 'Windows', + '### Command', 'app.exe' + ].join('\n\n') + } })); + await main(['--event', eventFile, '--database', path.join(root, 'database'), + '--mode', 'check', '--report', reportFile]); + const report = fs.readFileSync(reportFile, 'utf8'); + assert.ok(report.includes('Preset ID: ' + String.fromCharCode(96) + '51' + String.fromCharCode(96))); + assert.match(report, /Launch command preview:/); + assert.match(report, /"command": "app.exe"/); + assert.match(report, /awaiting maintainer review/); + assert.doesNotMatch(report, /sunshine/); +}); diff --git a/tests/migration-cli.test.js b/tests/migration-cli.test.js new file mode 100644 index 0000000000..43386ee482 --- /dev/null +++ b/tests/migration-cli.test.js @@ -0,0 +1,36 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { main } = require('../src/migrate-database'); + +test('migration command reports pending work, applies it once, and records its backup', t => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'preset-migration-cli-')); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + const games = path.join(root, 'games'); + fs.mkdirSync(games); + const recordFile = path.join(games, '123.json'); + fs.writeFileSync(recordFile, JSON.stringify({ + schema_version: 1, kind: 'game', id: 123, name: 'Example', + presets: [{ + id: 'issue-4', name: 'Example (Native)', method: 'native', os: 'Windows', + sunshine: { name: 'Example (Native)', cmd: 'example.exe' } + }] + })); + const sourceCommit = 'b'.repeat(40); + const backupBranch = 'database-backup-' + sourceCommit; + assert.deepEqual(main(['--database', root, '--pending']), ['001-generic-launch-presets']); + assert.deepEqual(main(['--database', root, '--backup-branch', backupBranch, + '--source-commit', sourceCommit]), ['001-generic-launch-presets']); + assert.equal(JSON.parse(fs.readFileSync(recordFile, 'utf8')).presets[0].id, '4'); + assert.deepEqual(main(['--database', root, '--pending']), []); + assert.deepEqual(main(['--database', root]), []); + const history = JSON.parse(fs.readFileSync(path.join(root, 'migrations.json'), 'utf8')); + assert.equal(history.applied[0].backup_branch, backupBranch); + assert.equal(history.applied[0].source_commit, sourceCommit); + assert.throws(() => main([]), /database is required/); + assert.throws(() => main(['--database']), /Use --database/); +}); diff --git a/tests/migration.test.js b/tests/migration.test.js index 08d41203e0..06f9baabd1 100644 --- a/tests/migration.test.js +++ b/tests/migration.test.js @@ -58,3 +58,37 @@ test('migration history rejects duplicate IDs', t => { })); assert.throws(() => pendingMigrations(root), /Invalid database migration history/); }); + +test('first migration rewrites a legacy Steam preset ID, commands, and storage key', t => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'preset-migrate-steam-')); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + const games = path.join(root, 'games'); + fs.mkdirSync(games); + const file = path.join(games, '141114.json'); + fs.writeFileSync(file, JSON.stringify({ + schema_version: 1, kind: 'game', id: 141114, name: '007 First Light', + presets: [{ + id: 'issue-4', name: '007 First Light (Steam)', os: null, method: 'steam', + launch_id: '3768760', sunshine_by_os: { + Windows: { cmd: 'cmd /c start "" "steam://rungameid/3768760"' }, + Linux: { cmd: 'steam "steam://rungameid/3768760"' }, + macOS: { cmd: 'open "steam://rungameid/3768760"' } + } + }] + })); + const sourceCommit = 'c'.repeat(40); + applyMigrations(root, { + backupBranch: 'database-backup-' + sourceCommit, + sourceCommit, + appliedAt: '2026-09-23T00:00:00.000Z' + }); + const migrated = JSON.parse(fs.readFileSync(file, 'utf8')); + assert.equal(migrated.schema_version, 2); + assert.equal(migrated.presets[0].id, '4'); + assert.deepEqual(migrated.presets[0].commands_by_os, { + Windows: 'steam://rungameid/3768760', + Linux: 'setsid steam steam://rungameid/3768760', + macOS: 'open steam://rungameid/3768760' + }); + assert.ok(!Object.hasOwn(migrated.presets[0], 'sunshine_by_os')); +}); diff --git a/tests/site-card.test.js b/tests/site-card.test.js new file mode 100644 index 0000000000..2d0a7d79e3 --- /dev/null +++ b/tests/site-card.test.js @@ -0,0 +1,91 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { renderPresetCard } = require('../gh-pages-template/assets/js/app'); + +class FakeElement { + constructor(tagName) { + this.tagName = tagName; + this.children = []; + this.handlers = {}; + this.textContent = ''; + this.style = {}; + } + + append(...nodes) { + this.children.push(...nodes); + if (this.tagName === 'select' && this.value === undefined && nodes.length) { + this.value = nodes[0].value; + } + } + + addEventListener(name, handler) { + this.handlers[name] = handler; + } +} + +function findAll(root, tagName) { + return [root, ...root.children.flatMap(child => findAll(child, tagName))] + .filter(node => node.tagName === tagName); +} + +test('site card changes host command and copies command and Sunshine JSON', async t => { + const previousDocument = Object.getOwnPropertyDescriptor(globalThis, 'document'); + const previousNavigator = Object.getOwnPropertyDescriptor(globalThis, 'navigator'); + const copied = []; + Object.defineProperty(globalThis, 'document', { + configurable: true, value: { createElement: tag => new FakeElement(tag) } + }); + Object.defineProperty(globalThis, 'navigator', { + configurable: true, value: { clipboard: { writeText: async value => copied.push(value) } } + }); + t.after(() => { + if (previousDocument) Object.defineProperty(globalThis, 'document', previousDocument); + else delete globalThis.document; + if (previousNavigator) Object.defineProperty(globalThis, 'navigator', previousNavigator); + else delete globalThis.navigator; + }); + + const card = renderPresetCard({ + name: 'Example (Steam)', + notes: 'Install Steam first.', + commands_by_os: { + Windows: 'steam://rungameid/464920', + Linux: 'setsid steam steam://rungameid/464920' + }, + protondb: { tier: 'gold', reports: 73 }, + protondb_url: 'https://www.protondb.com/app/464920', + origin_issue: 4, + source_issue: 9 + }); + const select = findAll(card, 'select')[0]; + const codes = findAll(card, 'code'); + const buttons = findAll(card, 'button'); + assert.equal(select.value, 'Windows'); + assert.equal(codes[0].textContent, 'steam://rungameid/464920'); + assert.equal(JSON.parse(codes[1].textContent).cmd, 'steam://rungameid/464920'); + assert.ok(findAll(card, 'a').some(link => link.href?.endsWith('/issues/4'))); + assert.ok(findAll(card, 'a').some(link => link.textContent === 'ProtonDB: Gold (73 reports)')); + + select.value = 'Linux'; + select.handlers.change(); + assert.equal(codes[0].textContent, 'setsid steam steam://rungameid/464920'); + assert.equal(JSON.parse(codes[1].textContent).cmd, 'setsid steam steam://rungameid/464920'); + + await buttons[0].handlers.click(); + await buttons[1].handlers.click(); + assert.deepEqual(copied, [codes[0].textContent, codes[1].textContent]); + assert.equal(buttons[0].textContent, 'Copied'); + assert.equal(buttons[1].textContent, 'Copied'); + + navigator.clipboard.writeText = async () => { throw new Error('clipboard unavailable'); }; + await buttons[0].handlers.click(); + await buttons[1].handlers.click(); + assert.match(buttons[0].textContent, /Select and copy the command/); + assert.match(buttons[1].textContent, /Select and copy the JSON/); + + const native = renderPresetCard({ name: 'Example (Windows)', command: 'example.exe' }); + assert.equal(findAll(native, 'select').length, 0); + assert.equal(findAll(native, 'code')[0].textContent, 'example.exe'); +}); From 91f8b953a420f239e332aab3aff234fdb27033d3 Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Wed, 23 Sep 2026 19:38:41 -0400 Subject: [PATCH 3/6] feat: use clean preset names and label launch methods --- .github/ISSUE_TEMPLATE/app-preset.yml | 2 +- .github/workflows/check-preset.yml | 18 +++++++++++++++++- README.md | 6 +++--- docs/presetGuidelines.md | 4 ++-- gh-pages-template/assets/js/app.js | 25 +++++++++++++++++++++++-- src/database.js | 17 +---------------- src/issue.js | 16 +++++++++++++++- src/record.js | 1 + tests/issue-cli.test.js | 16 ++++++++++++++-- tests/migration.test.js | 1 + tests/presets.test.js | 8 ++++---- tests/site-card.test.js | 15 +++++++++++++-- 12 files changed, 95 insertions(+), 34 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/app-preset.yml b/.github/ISSUE_TEMPLATE/app-preset.yml index 4457a9aeff..dc92d334b2 100644 --- a/.github/ISSUE_TEMPLATE/app-preset.yml +++ b/.github/ISSUE_TEMPLATE/app-preset.yml @@ -9,7 +9,7 @@ body: attributes: value: | Apps have no GameDB record. A maintainer reviews the official URL and command before approval. - Submit one host OS and command per issue. The preset name is generated from the app name and OS. + Submit one host OS and command per issue. The preset name is the app name; the host OS is shown separately. - type: input id: app_name attributes: diff --git a/.github/workflows/check-preset.yml b/.github/workflows/check-preset.yml index 550e06698d..02e95cc495 100644 --- a/.github/workflows/check-preset.yml +++ b/.github/workflows/check-preset.yml @@ -53,7 +53,7 @@ jobs: EVENT_FILE: ${{ github.event_path }} TWITCH_CLIENT_ID: ${{ secrets.TWITCH_CLIENT_ID }} TWITCH_CLIENT_SECRET: ${{ secrets.TWITCH_CLIENT_SECRET }} - run: node src/issue.js --event "$EVENT_FILE" --database database-branch/database --mode check --report report.md + run: node src/issue.js --event "$EVENT_FILE" --database database-branch/database --mode check --report report.md --title-output issue-title.txt - name: Post result if: always() && hashFiles('report.md') != '' @@ -62,6 +62,22 @@ jobs: ISSUE_NUMBER: ${{ github.event.issue.number }} run: gh issue comment "$ISSUE_NUMBER" --body-file report.md + - name: Update issue title + if: steps.validate.outcome == 'success' + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + github-token: ${{ github.token }} + script: | + const fs = require('node:fs') + const title = fs.readFileSync('issue-title.txt', 'utf8').trim() + if (context.payload.issue.title !== title) { + await github.rest.issues.update({ + ...context.repo, + issue_number: context.issue.number, + title + }) + } + - name: Queue trusted game submission if: steps.validate.outcome == 'success' && contains(github.event.issue.labels.*.name, 'request-game-preset') uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 diff --git a/README.md b/README.md index 11aa05a6e2..e753c6d640 100644 --- a/README.md +++ b/README.md @@ -19,13 +19,13 @@ Approved requests

-Community maintained game and app launch presets, with [Sunshine](https://github.com/LizardByte/Sunshine) as the primary consumer. Each game or app can have many presets. Native, GOG, and app commands target a chosen host OS. Steam, Epic Games, and Microsoft Store submissions use a launch ID to generate commands for their supported launcher OSes; emulator submissions use a portable command. GitHub issue numbers provide stable IDs, while the bot generates names and accepts optional emulator variants. +Community maintained game and app launch presets, with [Sunshine](https://github.com/LizardByte/Sunshine) as the primary consumer. Each game or app can have many presets. Native, GOG, and app commands target a chosen host OS. Steam, Epic Games, and Microsoft Store submissions use a launch ID to generate commands for their supported launcher OSes; emulator submissions use a portable command. GitHub issue numbers provide stable IDs. Preset names use the game or app name; launch method, host OS, and optional emulator variant stay in separate fields. Each GitHub issue requests **one preset**. Game requests must identify a [GameDB](https://app.lizardbyte.dev/GameDB/) record. App requests use a separate form and require a name and official source URL for maintainer review. A bot validates the request, and an authorized reviewer enters it into the approval queue. No code or pull request is needed to contribute. ## Browse and use -The [website](https://app.lizardbyte.dev/PresetDB/) shows games and apps with available presets. Each preset shows its launch command and offers a Sunshine application JSON export. Replace supported path placeholders with paths on your host. Review community commands before running them. Game cover images come from GameDB; app images are submitted as HTTPS URLs for the catalog. These image URLs are not used as Sunshine `image-path` values, which require local files. +The [website](https://app.lizardbyte.dev/PresetDB/) shows games and apps with available presets. Each preset shows its launch method and host details as badges, its launch command, and a Sunshine application JSON export. The exported application name is the game or app name. Replace supported path placeholders with paths on your host. Review community commands before running them. Game cover images come from GameDB; app images are submitted as HTTPS URLs for the catalog. These image URLs are not used as Sunshine `image-path` values, which require local files. The published JSON API contains `index.json`, `games/.json`, and `apps/.json`. GameDB uses the same numeric game IDs as IGDB. An entry contains all its presets. Each preset receives the originating issue number as a stable string ID, such as "4". Records use schema version 2 with a `command` field or a `commands_by_os` map and an optional `working_directory`; the website builds Sunshine JSON from these generic fields. The migration workflow updates version 1 records on the database branch; site and approval reads also convert them during the rollout. @@ -46,7 +46,7 @@ The website publishes [catalog statistics](https://app.lizardbyte.dev/PresetDB/s ## Contribute 1. Read the [preset guidelines](docs/presetGuidelines.md). For a game, copy its [IGDB game URL](https://www.igdb.com/) and [choose a game method form](https://github.com/LizardByte/PresetDB/issues/new/choose). The bot resolves the URL slug to the IGDB numeric ID and checks GameDB. For another app, [open an app preset request](https://github.com/LizardByte/PresetDB/issues/new?template=app-preset.yml) with its official URL. -2. Fill in one launch option. Native and GOG forms ask for the host OS. Store forms ask only for the launch ID; the emulator form asks for a portable command and an optional variant name. The bot generates the preset name. App requests still ask for the host OS. +2. Fill in one launch option. Native and GOG forms ask for the host OS. Store forms ask only for the launch ID; the emulator form asks for a portable command and an optional variant name. The bot derives the issue title and preset name from the validated game or app name. App requests still ask for the host OS. 3. The store forms share one Launch ID concept: a numeric Steam app ID, an Epic three-part launch ID, or a Microsoft Store AUMID. The bot generates OS-specific launch commands. Native, GOG, and Emulator forms ask for a Command. Validation and the website never execute commands. 4. To replace a preset, provide its issue number and explain the change. The bot preserves the original preset ID. diff --git a/docs/presetGuidelines.md b/docs/presetGuidelines.md index 06f1fdb6d0..011f808c90 100644 --- a/docs/presetGuidelines.md +++ b/docs/presetGuidelines.md @@ -1,6 +1,6 @@ # Preset Guidelines -One issue requests one launch option. Native, GOG, and app commands target a chosen host OS. Store IDs generate commands for each supported launcher OS; emulator commands must be portable across hosts. Submit another issue for a different store, emulator, or core. The bot generates names from the game or app name and launch method, plus the host OS when selected. +One issue requests one launch option. Native, GOG, and app commands target a chosen host OS. Store IDs generate commands for each supported launcher OS; emulator commands must be portable across hosts. Submit another issue for a different store, emulator, or core. The bot uses the game or app name as the preset name. Launch method, host OS, and emulator variant are separate metadata shown as badges on the website. The request check fills in the issue title after validation. ## Games @@ -10,7 +10,7 @@ Choose the Native, Steam, Epic Games, GOG, Microsoft Store, or Emulator issue fo ## Apps -Provide the official HTTPS homepage or source repository. An app image is optional and must be an HTTPS URL. App requests have no launch method or variant field; the generated name uses the app name and OS. App requests have no GameDB record and always require separate maintainer review. +Provide the official HTTPS homepage or source repository. An app image is optional and must be an HTTPS URL. App requests have no launch method or variant field; the generated preset name uses only the app name. App requests have no GameDB record and always require separate maintainer review. ## Commands and paths diff --git a/gh-pages-template/assets/js/app.js b/gh-pages-template/assets/js/app.js index c2d40a8ce1..828b2c2317 100644 --- a/gh-pages-template/assets/js/app.js +++ b/gh-pages-template/assets/js/app.js @@ -80,6 +80,26 @@ function appendProtonRating(body, preset) { body.append(proton); } +function appendPresetBadges(body, preset, kind) { + const badges = []; + const methods = { + native: 'Native', steam: 'Steam', 'epic-games': 'Epic Games', + gog: 'GOG', 'microsoft-store': 'Microsoft Store', emulator: 'Emulator' + }; + if (kind === 'game' && methods[preset.method]) { + badges.push(element('span', 'badge rounded-pill bg-warning text-dark me-2', methods[preset.method])); + } + if (preset.os) badges.push(element('span', 'badge rounded-pill bg-secondary me-2', preset.os)); + if (preset.variant_name) { + badges.push(element('span', 'badge rounded-pill bg-info text-dark me-2', preset.variant_name)); + } + if (badges.length) { + const row = element('div', 'mb-3'); + row.append(...badges); + body.append(row); + } +} + function hostSelection(body, preset) { if (!preset.commands_by_os) return null; const label = element('label', 'form-label', 'Host OS'); @@ -109,11 +129,12 @@ function appendIssueLinks(body, preset) { } } -function renderPresetCard(preset) { +function renderPresetCard(preset, kind = 'game') { const column = element('div', 'col'); const card = element('article', 'card h-100 rounded-0 shadow-sm'); const body = element('div', 'card-body'); body.append(element('h3', 'h5 card-title fw-bold', preset.name)); + appendPresetBadges(body, preset, kind); if (preset.notes) body.append(element('p', 'card-text', preset.notes)); appendProtonRating(body, preset); const hostSelect = hostSelection(body, preset); @@ -212,7 +233,7 @@ function boot() { if (image) detail.prepend(image); if (record.game_db_url) detail.append(element('span', 'mx-2'), safeLink(record.game_db_url, 'View in GameDB ↗')); const presets = element('div', 'row row-cols-1 row-cols-lg-2 g-4 mt-2'); - for (const preset of record.presets) presets.append(renderPresetCard(preset)); + for (const preset of record.presets) presets.append(renderPresetCard(preset, item.kind)); detail.append(presets); detail.scrollIntoView({ behavior: 'smooth', block: 'start' }); } catch (error) { diff --git a/src/database.js b/src/database.js index 77a14c898c..1fadab514b 100644 --- a/src/database.js +++ b/src/database.js @@ -44,20 +44,6 @@ function prepareRecord(root, preset) { return { file, record, name }; } -function methodLabel(method) { - return { - native: 'Native', steam: 'Steam', 'epic-games': 'Epic Games', - gog: 'GOG', 'microsoft-store': 'Microsoft Store', emulator: 'Emulator' - }[method]; -} - -function displayName(name, preset) { - if (preset.kind === 'app') return `${name} (${preset.os})`; - const method = methodLabel(preset.method); - const variant = preset.variantName ? `: ${preset.variantName}` : ''; - return preset.os ? name + ' (' + preset.os + ', ' + method + variant + ')' : name + ' (' + method + variant + ')'; -} - function replacementIndex(record, preset) { const previous = preset.replacementIssue == null ? -1 : record.presets.findIndex(item => item.origin_issue === preset.replacementIssue || item.source_issue === preset.replacementIssue @@ -89,9 +75,8 @@ function mergePreset(root, preset, { issue: issueNumber, action: previous >= 0 ? 'replace' : 'add', author_id: authorId, author_login: authorLogin, approved_at: approvedAt }); - const generatedName = displayName(name, preset); const entry = { - id: presetId, name: generatedName, os: preset.os, method: preset.method, + id: presetId, name, os: preset.os, method: preset.method, ...(preset.variantName ? { variant_name: preset.variantName } : {}), ...(preset.launchId ? { launch_id: preset.launchId } : {}), ...(preset.commandsByOs diff --git a/src/issue.js b/src/issue.js index a6cff9b36a..5fc7275114 100644 --- a/src/issue.js +++ b/src/issue.js @@ -46,6 +46,14 @@ async function processIssue(event, database, { approve = false, actor = '', fetc return { ...merged, preset, kind }; } +function issueTitle(preset) { + const name = preset.kind === 'app' ? preset.appName : preset.gameName; + const prefix = preset.kind === 'app' + ? 'APP PRESET' : preset.method.replaceAll('-', ' ').toUpperCase() + ' GAME PRESET'; + const detail = preset.os || preset.variantName; + return '[' + prefix + ']: ' + name + (detail ? ' (' + detail + ')' : ''); +} + function argsToObject(args) { const options = {}; for (let i = 0; i < args.length; i += 2) { @@ -61,12 +69,14 @@ function argsToObject(args) { async function main(args = process.argv.slice(2)) { const options = argsToObject(args); let message; + let title; let success = false; try { const event = JSON.parse(fs.readFileSync(options.event, 'utf8')); const result = await processIssue(event, options.database, { approve: options.mode === 'approve', actor: process.env.GITHUB_ACTOR || 'local' }); + title = issueTitle(result.preset); const item = result.kind === 'game' ? `GameDB game ${result.preset.gameId}` : `app ${result.preset.appName}`; const entry = result.record.presets.find(preset => preset.id === result.id); const preview = entry.commands_by_os || { command: entry.command, ...(entry.working_directory ? { working_directory: entry.working_directory } : {}) }; @@ -81,10 +91,14 @@ async function main(args = process.argv.slice(2)) { } fs.mkdirSync(path.dirname(options.report), { recursive: true }); fs.writeFileSync(options.report, message); + if (success && options['title-output']) { + fs.mkdirSync(path.dirname(options['title-output']), { recursive: true }); + fs.writeFileSync(options['title-output'], title + '\n'); + } console.log(message); if (!success) process.exitCode = 1; } if (require.main === module) main().catch(error => { console.error(error); process.exitCode = 1; }); -module.exports = { processIssue, requestKind, main }; +module.exports = { processIssue, requestKind, issueTitle, main }; diff --git a/src/record.js b/src/record.js index 63536bdbb1..5a3eebe22e 100644 --- a/src/record.js +++ b/src/record.js @@ -14,6 +14,7 @@ function normalizeRecord(record) { const item = { ...preset }; const legacyId = /^issue-([1-9]\d*)$/.exec(item.id); if (legacyId) item.id = legacyId[1]; + item.name = record.name; if (item.sunshine_by_os) { const method = { steam: 'Steam', 'epic-games': 'Epic Games', 'microsoft-store': 'Microsoft Store' }[item.method]; item.commands_by_os = method && item.launch_id diff --git a/tests/issue-cli.test.js b/tests/issue-cli.test.js index a4805f6cab..cce62415be 100644 --- a/tests/issue-cli.test.js +++ b/tests/issue-cli.test.js @@ -5,13 +5,14 @@ const assert = require('node:assert/strict'); const fs = require('node:fs'); const os = require('node:os'); const path = require('node:path'); -const { main } = require('../src/issue'); +const { main, issueTitle } = require('../src/issue'); test('issue command writes a contributor review with the short preset ID and command preview', async t => { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'preset-issue-cli-')); t.after(() => fs.rmSync(root, { recursive: true, force: true })); const eventFile = path.join(root, 'event.json'); const reportFile = path.join(root, 'report.md'); + const titleFile = path.join(root, 'issue-title.txt'); fs.writeFileSync(eventFile, JSON.stringify({ issue: { number: 51, labels: ['request-app-preset'], @@ -23,11 +24,22 @@ test('issue command writes a contributor review with the short preset ID and com ].join('\n\n') } })); await main(['--event', eventFile, '--database', path.join(root, 'database'), - '--mode', 'check', '--report', reportFile]); + '--mode', 'check', '--report', reportFile, '--title-output', titleFile]); const report = fs.readFileSync(reportFile, 'utf8'); + assert.equal(fs.readFileSync(titleFile, 'utf8').trim(), '[APP PRESET]: App One (Windows)'); assert.ok(report.includes('Preset ID: ' + String.fromCharCode(96) + '51' + String.fromCharCode(96))); assert.match(report, /Launch command preview:/); assert.match(report, /"command": "app.exe"/); assert.match(report, /awaiting maintainer review/); assert.doesNotMatch(report, /sunshine/); }); + +test('validated issue titles identify launch method and keep it out of the preset name', () => { + assert.equal(issueTitle({ kind: 'game', gameName: '007 First Light', method: 'steam' }), + '[STEAM GAME PRESET]: 007 First Light'); + assert.equal(issueTitle({ kind: 'game', gameName: 'Example', method: 'native', os: 'Linux' }), + '[NATIVE GAME PRESET]: Example (Linux)'); + assert.equal(issueTitle({ kind: 'game', gameName: 'Example', method: 'emulator', + variantName: 'RetroArch Snes9x' }), + '[EMULATOR GAME PRESET]: Example (RetroArch Snes9x)'); +}); diff --git a/tests/migration.test.js b/tests/migration.test.js index 06f9baabd1..cbc89e68fe 100644 --- a/tests/migration.test.js +++ b/tests/migration.test.js @@ -85,6 +85,7 @@ test('first migration rewrites a legacy Steam preset ID, commands, and storage k const migrated = JSON.parse(fs.readFileSync(file, 'utf8')); assert.equal(migrated.schema_version, 2); assert.equal(migrated.presets[0].id, '4'); + assert.equal(migrated.presets[0].name, '007 First Light'); assert.deepEqual(migrated.presets[0].commands_by_os, { Windows: 'steam://rungameid/3768760', Linux: 'setsid steam steam://rungameid/3768760', diff --git a/tests/presets.test.js b/tests/presets.test.js index 1ec4b64b11..91dfadac75 100644 --- a/tests/presets.test.js +++ b/tests/presets.test.js @@ -193,7 +193,7 @@ test('approved presets get issue IDs and replacements preserve identity', async assert.equal(record.presets.length, 2); assert.deepEqual(record.presets.map(item => item.id), ['10', '12']); assert.equal(record.presets.find(item => item.id === '10').command, 'new-command'); - assert.equal(record.presets.find(item => item.id === '10').name, 'One Tap Hero (Emulator: RetroArch Snes9x)'); + assert.equal(record.presets.find(item => item.id === '10').name, 'One Tap Hero'); assert.equal(record.presets.find(item => item.id === '10').origin_issue, 10); assert.equal(record.presets.find(item => item.id === '10').source_issue, 13); assert.throws(() => validateFields({ ...gameValues, replacementIssue: '10' }, 'game'), /replacement needs both/); @@ -210,7 +210,7 @@ test('Steam app ID publishes one logical preset with host-specific commands', as assert.equal(record.presets[0].launch_id, '464920'); assert.equal(record.presets[0].commands_by_os.Windows, 'steam://rungameid/464920'); assert.equal(record.presets[0].commands_by_os.Linux, 'setsid steam steam://rungameid/464920'); - assert.equal(record.presets[0].name, 'One Tap Hero (Steam)'); + assert.equal(record.presets[0].name, 'One Tap Hero'); assert.ok(!Object.hasOwn(record.presets[0], 'detached')); assert.ok(!Object.hasOwn(record.presets[0], 'image-path')); }); @@ -231,7 +231,7 @@ test('issue processing and site build publish both game and app JSON', async t = assert.deepEqual(index.games[0].operating_systems, ['Linux', 'macOS', 'Windows']); assert.equal(index.apps[0].id, 'app-one'); assert.equal(index.apps[0].image_url, 'https://example.org/icon.png'); - assert.equal(JSON.parse(fs.readFileSync(path.join(output, 'apps/app-one.json'))).presets[0].name, 'App One (Linux)'); + assert.equal(JSON.parse(fs.readFileSync(path.join(output, 'apps/app-one.json'))).presets[0].name, 'App One'); assert.ok(fs.existsSync(path.join(output, 'games/100245.json'))); assert.ok(fs.existsSync(path.join(output, 'apps/app-one.json'))); assert.ok(fs.existsSync(path.join(output, 'top_contributors.svg'))); @@ -283,7 +283,7 @@ test('legacy approved records publish generic commands and short IDs', async t = const record = { schema_version: 1, kind: 'game', id: 100245, name: 'One Tap Hero', source_url: 'https://www.igdb.com/games/one-tap-hero', - presets: [{ id: 'issue-4', name: 'One Tap Hero (Steam)', os: null, method: 'steam', + presets: [{ id: 'issue-4', name: 'One Tap Hero', os: null, method: 'steam', launch_id: '464920', origin_issue: 4, source_issue: 4, sunshine_by_os: { Windows: { cmd: 'cmd /c start "" "steam://rungameid/464920"' }, Linux: { cmd: 'steam "steam://rungameid/464920"' }, diff --git a/tests/site-card.test.js b/tests/site-card.test.js index 2d0a7d79e3..6c70bc27b3 100644 --- a/tests/site-card.test.js +++ b/tests/site-card.test.js @@ -48,7 +48,8 @@ test('site card changes host command and copies command and Sunshine JSON', asyn }); const card = renderPresetCard({ - name: 'Example (Steam)', + name: 'Example', + method: 'steam', notes: 'Install Steam first.', commands_by_os: { Windows: 'steam://rungameid/464920', @@ -63,6 +64,8 @@ test('site card changes host command and copies command and Sunshine JSON', asyn const codes = findAll(card, 'code'); const buttons = findAll(card, 'button'); assert.equal(select.value, 'Windows'); + assert.ok(findAll(card, 'span').some(badge => badge.textContent === 'Steam')); + assert.equal(JSON.parse(codes[1].textContent).name, 'Example'); assert.equal(codes[0].textContent, 'steam://rungameid/464920'); assert.equal(JSON.parse(codes[1].textContent).cmd, 'steam://rungameid/464920'); assert.ok(findAll(card, 'a').some(link => link.href?.endsWith('/issues/4'))); @@ -85,7 +88,15 @@ test('site card changes host command and copies command and Sunshine JSON', asyn assert.match(buttons[0].textContent, /Select and copy the command/); assert.match(buttons[1].textContent, /Select and copy the JSON/); - const native = renderPresetCard({ name: 'Example (Windows)', command: 'example.exe' }); + const native = renderPresetCard({ name: 'Example', method: 'native', os: 'Windows', command: 'example.exe' }); assert.equal(findAll(native, 'select').length, 0); assert.equal(findAll(native, 'code')[0].textContent, 'example.exe'); + assert.deepEqual(findAll(native, 'span').map(badge => badge.textContent), ['Native', 'Windows']); + const app = renderPresetCard({ name: 'App One', method: 'native', os: 'Linux', + command: 'app-one' }, 'app'); + assert.deepEqual(findAll(app, 'span').map(badge => badge.textContent), ['Linux']); + const emulator = renderPresetCard({ name: 'Example', method: 'emulator', + variant_name: 'RetroArch Snes9x', command: 'retroarch' }); + assert.deepEqual(findAll(emulator, 'span').map(badge => badge.textContent), + ['Emulator', 'RetroArch Snes9x']); }); From 11b1bd41e3d2a0f057e34db432eed04cf346d22b Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Wed, 23 Sep 2026 19:41:41 -0400 Subject: [PATCH 4/6] ci: wrap issue check command --- .github/workflows/check-preset.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/check-preset.yml b/.github/workflows/check-preset.yml index 02e95cc495..841d7f9464 100644 --- a/.github/workflows/check-preset.yml +++ b/.github/workflows/check-preset.yml @@ -53,7 +53,9 @@ jobs: EVENT_FILE: ${{ github.event_path }} TWITCH_CLIENT_ID: ${{ secrets.TWITCH_CLIENT_ID }} TWITCH_CLIENT_SECRET: ${{ secrets.TWITCH_CLIENT_SECRET }} - run: node src/issue.js --event "$EVENT_FILE" --database database-branch/database --mode check --report report.md --title-output issue-title.txt + run: | + node src/issue.js --event "$EVENT_FILE" --database database-branch/database \ + --mode check --report report.md --title-output issue-title.txt - name: Post result if: always() && hashFiles('report.md') != '' From 8db4a0322cd711581378e30988ff5c3cef91d074 Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Wed, 23 Sep 2026 20:11:12 -0400 Subject: [PATCH 5/6] fix: show launch methods in browse cards --- gh-pages-template/assets/js/app.js | 30 ++++++++++++++++++++++-------- src/build-site.js | 3 ++- tests/presets.test.js | 4 ++++ tests/site-card.test.js | 5 ++++- 4 files changed, 32 insertions(+), 10 deletions(-) diff --git a/gh-pages-template/assets/js/app.js b/gh-pages-template/assets/js/app.js index 828b2c2317..e55d1ee554 100644 --- a/gh-pages-template/assets/js/app.js +++ b/gh-pages-template/assets/js/app.js @@ -1,5 +1,10 @@ 'use strict'; +const METHOD_NAMES = { + native: 'Native', steam: 'Steam', 'epic-games': 'Epic Games', + gog: 'GOG', 'microsoft-store': 'Microsoft Store', emulator: 'Emulator' +}; + function filterItems(index, query, kind, os) { const needle = query.trim().toLowerCase(); return [ @@ -26,7 +31,8 @@ function sunshineSnippet(preset, os = 'Windows') { function normalizeBasePath(value) { const configured = String(value || ''); - return configured.includes('{{') ? '' : `/${configured}`.replace(/\/+/g, '/').replace(/\/$/, ''); + if (configured.includes('{{')) return '/PresetDB'; + return ('/' + configured).replace(/\/+/g, '/').replace(/\/$/, '') || '/PresetDB'; } function element(tag, className, content) { @@ -82,12 +88,8 @@ function appendProtonRating(body, preset) { function appendPresetBadges(body, preset, kind) { const badges = []; - const methods = { - native: 'Native', steam: 'Steam', 'epic-games': 'Epic Games', - gog: 'GOG', 'microsoft-store': 'Microsoft Store', emulator: 'Emulator' - }; - if (kind === 'game' && methods[preset.method]) { - badges.push(element('span', 'badge rounded-pill bg-warning text-dark me-2', methods[preset.method])); + if (kind === 'game' && METHOD_NAMES[preset.method]) { + badges.push(element('span', 'badge rounded-pill bg-warning text-dark me-2', METHOD_NAMES[preset.method])); } if (preset.os) badges.push(element('span', 'badge rounded-pill bg-secondary me-2', preset.os)); if (preset.variant_name) { @@ -100,6 +102,16 @@ function appendPresetBadges(body, preset, kind) { } } +function browseMethodBadges(methods) { + const labels = [...new Set(methods || [])].map(method => METHOD_NAMES[method]).filter(Boolean); + if (!labels.length) return null; + const row = element('span', 'd-block mt-3'); + for (const label of labels) { + row.append(element('span', 'badge rounded-pill bg-warning text-dark me-2', label)); + } + return row; +} + function hostSelection(body, preset) { if (!preset.commands_by_os) return null; const label = element('label', 'form-label', 'Host OS'); @@ -207,6 +219,8 @@ function boot() { button.append(element('span', 'text-uppercase small text-warning fw-bold', item.kind)); button.append(element('span', 'd-block h5 mt-2 mb-1 fw-bold', item.name)); button.append(element('span', 'd-block text-muted', `${item.preset_count} preset${item.preset_count === 1 ? '' : 's'} · ${item.operating_systems.join(', ')}`)); + const methodBadges = browseMethodBadges(item.launch_methods); + if (methodBadges) button.append(methodBadges); button.addEventListener('click', () => { const url = new URL(globalThis.location.href); url.searchParams.set('kind', item.kind); @@ -270,4 +284,4 @@ function boot() { } if (typeof document !== 'undefined') document.addEventListener('DOMContentLoaded', boot); -if (typeof module !== 'undefined') module.exports = { filterItems, commandForOs, sunshineSnippet, renderPresetCard, normalizeBasePath }; +if (typeof module !== 'undefined') module.exports = { filterItems, commandForOs, sunshineSnippet, renderPresetCard, browseMethodBadges, normalizeBasePath }; diff --git a/src/build-site.js b/src/build-site.js index c01d26ec57..3b18eb243c 100644 --- a/src/build-site.js +++ b/src/build-site.js @@ -63,7 +63,8 @@ async function buildSite(database, template, output, fetcher = globalThis.fetch) index[folder].push({ id: item.id, name: item.name, preset_count: item.presets.length, image_url: item.image_url || null, - operating_systems: [...new Set(item.presets.flatMap(supportedOs))].sort((a, b) => a.localeCompare(b)) + operating_systems: [...new Set(item.presets.flatMap(supportedOs))].sort((a, b) => a.localeCompare(b)), + ...(kind === 'game' ? { launch_methods: [...new Set(item.presets.map(preset => preset.method))].sort() } : {}) }); } index[folder].sort((a, b) => a.name.localeCompare(b.name) || String(a.id).localeCompare(String(b.id))); diff --git a/tests/presets.test.js b/tests/presets.test.js index 91dfadac75..da4f9853e8 100644 --- a/tests/presets.test.js +++ b/tests/presets.test.js @@ -229,6 +229,7 @@ test('issue processing and site build publish both game and app JSON', async t = const index = await buildSite(database, path.join(__dirname, '..', 'gh-pages-template'), output); assert.equal(index.games[0].preset_count, 1); assert.deepEqual(index.games[0].operating_systems, ['Linux', 'macOS', 'Windows']); + assert.deepEqual(index.games[0].launch_methods, ['emulator']); assert.equal(index.apps[0].id, 'app-one'); assert.equal(index.apps[0].image_url, 'https://example.org/icon.png'); assert.equal(JSON.parse(fs.readFileSync(path.join(output, 'apps/app-one.json'))).presets[0].name, 'App One'); @@ -251,6 +252,8 @@ test('site filters and serializes Sunshine application JSON', () => { assert.throws(() => commandForOs({ commands_by_os: { Windows: 'game.exe' } }, 'Linux'), /available host OS/); assert.equal(normalizeBasePath('/PresetDB'), '/PresetDB'); assert.equal(normalizeBasePath('en/pr-123'), '/en/pr-123'); + assert.equal(normalizeBasePath(''), '/PresetDB'); + assert.equal(normalizeBasePath('{{ site.baseurl }}'), '/PresetDB'); }); test('Pages build adds a ProtonDB tier to Steam presets and keeps the source link', async t => { @@ -267,6 +270,7 @@ test('Pages build adds a ProtonDB tier to Steam presets and keeps the source lin }; const index = await buildSite(database, path.join(__dirname, '..', 'gh-pages-template'), output, fetcher); assert.deepEqual(index.games[0].operating_systems, ['Linux', 'macOS', 'Windows']); + assert.deepEqual(index.games[0].launch_methods, ['steam']); const published = JSON.parse(fs.readFileSync(path.join(output, 'games/100245.json'))); assert.deepEqual(published.presets[0].protondb, { tier: 'gold', reports: 73 }); assert.equal(published.presets[0].protondb_url, 'https://www.protondb.com/app/464920'); diff --git a/tests/site-card.test.js b/tests/site-card.test.js index 6c70bc27b3..6274f0beb7 100644 --- a/tests/site-card.test.js +++ b/tests/site-card.test.js @@ -2,7 +2,7 @@ const test = require('node:test'); const assert = require('node:assert/strict'); -const { renderPresetCard } = require('../gh-pages-template/assets/js/app'); +const { renderPresetCard, browseMethodBadges } = require('../gh-pages-template/assets/js/app'); class FakeElement { constructor(tagName) { @@ -99,4 +99,7 @@ test('site card changes host command and copies command and Sunshine JSON', asyn variant_name: 'RetroArch Snes9x', command: 'retroarch' }); assert.deepEqual(findAll(emulator, 'span').map(badge => badge.textContent), ['Emulator', 'RetroArch Snes9x']); + const browseBadges = browseMethodBadges(['steam', 'gog', 'steam']); + assert.deepEqual(browseBadges.children.map(badge => badge.textContent), ['Steam', 'GOG']); + assert.equal(browseMethodBadges([]), null); }); From 8bb272b8b7394a84aa3c430ec474bc2bf4494178 Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Wed, 23 Sep 2026 20:13:39 -0400 Subject: [PATCH 6/6] fix: sort launch methods explicitly --- src/build-site.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/build-site.js b/src/build-site.js index 3b18eb243c..1b9058fdba 100644 --- a/src/build-site.js +++ b/src/build-site.js @@ -64,7 +64,7 @@ async function buildSite(database, template, output, fetcher = globalThis.fetch) id: item.id, name: item.name, preset_count: item.presets.length, image_url: item.image_url || null, operating_systems: [...new Set(item.presets.flatMap(supportedOs))].sort((a, b) => a.localeCompare(b)), - ...(kind === 'game' ? { launch_methods: [...new Set(item.presets.map(preset => preset.method))].sort() } : {}) + ...(kind === 'game' ? { launch_methods: [...new Set(item.presets.map(preset => preset.method))].sort((a, b) => a.localeCompare(b)) } : {}) }); } index[folder].sort((a, b) => a.name.localeCompare(b.name) || String(a.id).localeCompare(String(b.id)));