diff --git a/.github/ISSUE_TEMPLATE/app-preset.yml b/.github/ISSUE_TEMPLATE/app-preset.yml index ffe9811795..dc92d334b2 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 @@ -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: @@ -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/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/.github/workflows/check-preset.yml b/.github/workflows/check-preset.yml index 550e06698d..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 + 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 +64,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/.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..e753c6d640 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. 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 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 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 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 @@ -46,8 +46,8 @@ 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. -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. +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. 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,7 +67,9 @@ 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. -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. +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. + +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 6659258375..427c5e6fb6 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 @@ -24,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/docs/presetGuidelines.md b/docs/presetGuidelines.md index da256ebeb7..011f808c90 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 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,15 +10,15 @@ 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 -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..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 [ @@ -12,15 +17,22 @@ 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) { 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) { @@ -74,11 +86,37 @@ function appendProtonRating(body, preset) { body.append(proton); } +function appendPresetBadges(body, preset, kind) { + const badges = []; + 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) { + 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 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.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); @@ -103,31 +141,54 @@ 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); + 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); @@ -158,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); @@ -184,7 +247,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) { @@ -221,4 +284,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, renderPresetCard, browseMethodBadges, 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..1b9058fdba 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}`); @@ -62,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((a, b) => a.localeCompare(b)) } : {}) }); } index[folder].sort((a, b) => a.name.localeCompare(b.name) || String(a.id).localeCompare(String(b.id))); diff --git a/src/database.js b/src/database.js index 8d235b1e4c..1fadab514b 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: [] }; } @@ -43,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 @@ -79,7 +66,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`); @@ -88,22 +75,16 @@ 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 ? { - 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 +94,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..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,30 +69,36 @@ 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.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`; } 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 }; +module.exports = { processIssue, requestKind, issueTitle, main }; diff --git a/src/migrate-database.js b/src/migrate-database.js new file mode 100644 index 0000000000..e188e83a3e --- /dev/null +++ b/src/migrate-database.js @@ -0,0 +1,83 @@ +'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) { + 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, main }; 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..5a3eebe22e --- /dev/null +++ b/src/record.js @@ -0,0 +1,35 @@ +'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]; + 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 + ? 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/issue-cli.test.js b/tests/issue-cli.test.js new file mode 100644 index 0000000000..cce62415be --- /dev/null +++ b/tests/issue-cli.test.js @@ -0,0 +1,45 @@ +'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, 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'], + 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, '--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-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 new file mode 100644 index 0000000000..cbc89e68fe --- /dev/null +++ b/tests/migration.test.js @@ -0,0 +1,95 @@ +'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/); +}); + +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.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', + macOS: 'open steam://rungameid/3768760' + }); + assert.ok(!Object.hasOwn(migrated.presets[0], 'sunshine_by_os')); +}); diff --git a/tests/presets.test.js b/tests/presets.test.js index d39dc7cba4..da4f9853e8 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,28 +187,30 @@ 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'); + 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].name, 'One Tap Hero (Steam)'); + 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'); assert.ok(!Object.hasOwn(record.presets[0], 'detached')); assert.ok(!Object.hasOwn(record.presets[0], 'image-path')); }); @@ -224,9 +229,10 @@ 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 (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'))); @@ -240,9 +246,14 @@ 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'); + 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 => { @@ -259,11 +270,51 @@ 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'); 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', 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 => { diff --git a/tests/site-card.test.js b/tests/site-card.test.js new file mode 100644 index 0000000000..6274f0beb7 --- /dev/null +++ b/tests/site-card.test.js @@ -0,0 +1,105 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { renderPresetCard, browseMethodBadges } = 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', + method: '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.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'))); + 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', 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']); + const browseBadges = browseMethodBadges(['steam', 'gog', 'steam']); + assert.deepEqual(browseBadges.children.map(badge => badge.textContent), ['Steam', 'GOG']); + assert.equal(browseMethodBadges([]), null); +});