From a5c87b43a480e36514d1138a5613b67046d3dedc Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Wed, 23 Sep 2026 13:05:04 -0400 Subject: [PATCH 01/12] feat: initial version --- .github/ISSUE_TEMPLATE/app-preset.yml | 90 ++++ .github/ISSUE_TEMPLATE/config.yml | 16 + .github/ISSUE_TEMPLATE/game-preset.yml | 78 +++ .github/workflows/approve-preset.yml | 122 +++++ .github/workflows/build-pages.yml | 69 +++ .github/workflows/check-preset.yml | 74 +++ .github/workflows/ci-tests.yml | 35 ++ .github/workflows/comment-command.yml | 29 ++ .gitignore | 5 + .readthedocs.yaml | 25 + LICENSE | 662 ++++++++++++++++++++++++- README.md | 71 ++- auto_approved_users.json | 9 + database/apps/.gitkeep | 0 database/games/.gitkeep | 0 docs/approverGuide.md | 13 + docs/botCommands.md | 8 + docs/developerSetup.md | 19 + docs/presetGuidelines.md | 21 + gh-pages-template/_config.yml | 3 + gh-pages-template/assets/js/app.js | 176 +++++++ gh-pages-template/index.html | 56 +++ package-lock.json | 16 + package.json | 14 + src/approval-queue.js | 82 +++ src/build-site.js | 51 ++ src/comment-command.js | 43 ++ src/database.js | 90 ++++ src/issue.js | 68 +++ src/presets.js | 230 +++++++++ src/statistics.js | 89 ++++ src/verify-approval.js | 34 ++ src/workflow-queue.js | 19 + tests/approval-queue.test.js | 115 +++++ tests/presets.test.js | 178 +++++++ 35 files changed, 2606 insertions(+), 4 deletions(-) create mode 100644 .github/ISSUE_TEMPLATE/app-preset.yml create mode 100644 .github/ISSUE_TEMPLATE/config.yml create mode 100644 .github/ISSUE_TEMPLATE/game-preset.yml create mode 100644 .github/workflows/approve-preset.yml create mode 100644 .github/workflows/build-pages.yml create mode 100644 .github/workflows/check-preset.yml create mode 100644 .github/workflows/ci-tests.yml create mode 100644 .github/workflows/comment-command.yml create mode 100644 .readthedocs.yaml create mode 100644 auto_approved_users.json create mode 100644 database/apps/.gitkeep create mode 100644 database/games/.gitkeep create mode 100644 docs/approverGuide.md create mode 100644 docs/botCommands.md create mode 100644 docs/developerSetup.md create mode 100644 docs/presetGuidelines.md create mode 100644 gh-pages-template/_config.yml create mode 100644 gh-pages-template/assets/js/app.js create mode 100644 gh-pages-template/index.html create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 src/approval-queue.js create mode 100644 src/build-site.js create mode 100644 src/comment-command.js create mode 100644 src/database.js create mode 100644 src/issue.js create mode 100644 src/presets.js create mode 100644 src/statistics.js create mode 100644 src/verify-approval.js create mode 100644 src/workflow-queue.js create mode 100644 tests/approval-queue.test.js create mode 100644 tests/presets.test.js diff --git a/.github/ISSUE_TEMPLATE/app-preset.yml b/.github/ISSUE_TEMPLATE/app-preset.yml new file mode 100644 index 0000000000..c06eac75e8 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/app-preset.yml @@ -0,0 +1,90 @@ +--- +name: App launch preset +description: Request one Sunshine launch option for a non-game app. +title: '[APP PRESET]: ' +labels: + - request-app-preset +body: + - type: markdown + attributes: + value: | + Apps have no GameDB record. A maintainer reviews the official URL and command before approval. + Submit one host OS and launch option per issue. + - type: input + id: app_name + attributes: + label: App name + description: Official app name. + validations: + required: true + - type: input + id: app_url + attributes: + label: Official app URL + description: HTTPS homepage or official source repository. + validations: + required: true + - type: input + id: app_image_url + attributes: + label: App image URL + description: Optional HTTPS image URL for the catalog. Do not enter a local file path. + - type: dropdown + id: os + attributes: + label: Host operating system + options: + - Windows + - Linux + - macOS + validations: + required: true + - type: dropdown + id: method + attributes: + label: Launch method + options: + - Native + - Steam + - Epic Games + - GOG + - Emulator + - Other + validations: + required: true + - type: input + id: preset_name + attributes: + label: Preset name + description: Short display name for this launch option. + validations: + required: true + - type: input + id: command + attributes: + label: Command + description: | + Use {{HOME}} for a variable path. Windows also allows {{SYSTEM_DRIVE}}, {{PROGRAM_FILES}}, and + {{PROGRAM_FILES_X86}}. Steam launcher URIs are stored as Sunshine detached commands automatically. + validations: + required: true + - type: input + id: working_dir + attributes: + label: Working directory + description: Optional Sunshine working-dir value. + - type: textarea + id: notes + attributes: + label: Notes + description: Setup instructions or prerequisites. + - type: textarea + id: replacement_reason + attributes: + label: Replacement reason + description: Explain why the existing preset should change. Required with the issue number below. + - type: input + id: replacement_issue + attributes: + label: Preset to replace (issue number) + description: Optional issue number shown with the published preset. Leave blank to add a new preset. diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000000..b43c7489d7 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,16 @@ +--- +# This file is centrally managed in https://github.com/LizardByte/.github/. +blank_issues_enabled: false +contact_links: + - name: Discussions + url: https://github.com/orgs/LizardByte/discussions + about: Community discussions + - name: Questions + url: https://github.com/orgs/LizardByte/discussions + about: Ask questions + - name: Feature Requests + url: https://github.com/orgs/LizardByte/discussions + about: Request new features + - name: Support Center + url: https://app.lizardbyte.dev/support + about: Official LizardByte support diff --git a/.github/ISSUE_TEMPLATE/game-preset.yml b/.github/ISSUE_TEMPLATE/game-preset.yml new file mode 100644 index 0000000000..3b5e90272c --- /dev/null +++ b/.github/ISSUE_TEMPLATE/game-preset.yml @@ -0,0 +1,78 @@ +--- +name: Game launch preset +description: Request one Sunshine launch option for a GameDB game. +title: '[GAME PRESET]: ' +labels: + - request-game-preset +body: + - type: markdown + attributes: + value: | + One issue represents one launch option. Provide the IGDB game URL; the bot resolves its ID and checks GameDB. + Use a descriptive preset name to distinguish stores, emulators, cores, or other choices. + - type: input + id: game_url + attributes: + label: IGDB game URL + description: Official URL containing the game slug, for example https://www.igdb.com/games/surviving-mars. + validations: + required: true + - type: dropdown + id: os + attributes: + label: Host operating system + options: + - Windows + - Linux + - macOS + validations: + required: true + - type: dropdown + id: method + attributes: + label: Launch method + options: + - Native + - Steam + - Epic Games + - GOG + - Emulator + - Other + validations: + required: true + - type: input + id: preset_name + attributes: + label: Preset name + description: Short display name, for example RetroArch with Snes9x. + validations: + required: true + - type: input + id: command + attributes: + label: Command + description: | + Use {{ROM_PATH}} or {{HOME}} for variable paths. Windows also allows {{SYSTEM_DRIVE}}, {{PROGRAM_FILES}}, + and {{PROGRAM_FILES_X86}}. Steam launcher URIs are stored as Sunshine detached commands automatically. + validations: + required: true + - type: input + id: working_dir + attributes: + label: Working directory + description: Optional Sunshine working-dir value. + - type: textarea + id: notes + attributes: + label: Notes + description: Setup instructions and required emulator settings. + - type: textarea + id: replacement_reason + attributes: + label: Replacement reason + description: Explain why the existing preset should change. Required with the issue number below. + - type: input + id: replacement_issue + attributes: + label: Preset to replace (issue number) + description: Optional issue number shown with the published preset. Leave blank to add a new preset. diff --git a/.github/workflows/approve-preset.yml b/.github/workflows/approve-preset.yml new file mode 100644 index 0000000000..73fde4ed61 --- /dev/null +++ b/.github/workflows/approve-preset.yml @@ -0,0 +1,122 @@ +--- +name: Approve preset request +run-name: ${{ github.event.label.name }} ${{ github.event.issue.title }} +permissions: {} + +env: + NODE_VERSION: '24' + +on: + issues: + types: + - labeled + +jobs: + approve: + if: github.event.label.name == 'approve-preset' + runs-on: ubuntu-latest + permissions: + actions: read + contents: read + issues: read + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Verify approval + id: verify + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + github-token: ${{ secrets.GH_BOT_TOKEN }} + script: | + const {verifyApprovalEvent} = require('./src/verify-approval.js') + await verifyApprovalEvent({github, context}) + + - name: Wait for approved database updates + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const {waitForOlderApprovals} = require('./src/workflow-queue.js') + await waitForOlderApprovals({github, context}) + + - 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: Verify issue has not changed + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + github-token: ${{ secrets.GH_BOT_TOKEN }} + script: | + const fs = require('node:fs') + const {verifyCurrentIssue} = require('./src/verify-approval.js') + try { + await verifyCurrentIssue({github, context}) + } catch (error) { + fs.writeFileSync('report.md', `Approval stopped: ${error.message}\n`) + throw error + } + + - name: Validate and publish + id: publish + env: + EVENT_FILE: ${{ github.event_path }} + ISSUE_NUMBER: ${{ github.event.issue.number }} + BOT_NAME: ${{ vars.GH_BOT_NAME }} + BOT_EMAIL: ${{ secrets.GH_BOT_EMAIL }} + TWITCH_CLIENT_ID: ${{ secrets.TWITCH_CLIENT_ID }} + TWITCH_CLIENT_SECRET: ${{ secrets.TWITCH_CLIENT_SECRET }} + shell: bash + run: | + 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 + node src/issue.js --event "$EVENT_FILE" --database database-branch/database \ + --mode approve --report report.md + git -C database-branch add database/games database/apps + if git -C database-branch diff --cached --quiet; then exit 0; fi + git -C database-branch commit -m "chore: resolve #$ISSUE_NUMBER" + if git -C database-branch push origin HEAD:database; then exit 0; fi + done + echo 'Database changed during all five publish attempts' >&2 + exit 1 + + - name: Comment result + if: always() && hashFiles('report.md') != '' + env: + GH_TOKEN: ${{ secrets.GH_BOT_TOKEN }} + ISSUE_NUMBER: ${{ github.event.issue.number }} + run: gh issue comment "$ISSUE_NUMBER" --body-file report.md + + - name: Close issue + if: steps.publish.outcome == 'success' + env: + GH_TOKEN: ${{ secrets.GH_BOT_TOKEN }} + ISSUE_NUMBER: ${{ github.event.issue.number }} + run: gh issue close "$ISSUE_NUMBER" --reason completed + + - name: Build updated site + if: steps.publish.outcome == 'success' + env: + GH_TOKEN: ${{ secrets.GH_BOT_TOKEN }} + run: gh workflow run build-pages.yml --ref master + + - name: Release approval queue + if: always() && steps.verify.outcome != 'skipped' + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + github-token: ${{ secrets.GH_BOT_TOKEN }} + script: | + const {releaseAndPromote} = require('./src/approval-queue.js') + await releaseAndPromote({github, context}) diff --git a/.github/workflows/build-pages.yml b/.github/workflows/build-pages.yml new file mode 100644 index 0000000000..2918cc807c --- /dev/null +++ b/.github/workflows/build-pages.yml @@ -0,0 +1,69 @@ +--- +name: Build Pages +permissions: {} + +env: + NODE_VERSION: '24' + +on: + pull_request: + push: + branches: + - master + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + build: + 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 + + - name: Setup Node + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: ${{ env.NODE_VERSION }} + + - name: Assemble site + run: node src/build-site.js --database database-branch/database --output site-build + + - name: Archive site + run: | + cd site-build + zip -qr ../build.zip . + + - name: Upload site artifact + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: site-source + path: build.zip + if-no-files-found: error + retention-days: 1 + + call-jekyll-build: + needs: build + if: github.event_name != 'pull_request' + permissions: + contents: read + uses: LizardByte/LizardByte.github.io/.github/workflows/jekyll-build.yml@master + secrets: + GH_BOT_EMAIL: ${{ secrets.GH_BOT_EMAIL }} + GH_BOT_TOKEN: ${{ secrets.GH_BOT_TOKEN }} + with: + clean_gh_pages: true + extract_archive: build.zip + gh_bot_name: ${{ vars.GH_BOT_NAME }} + site_artifact: site-source + target_branch: gh-pages diff --git a/.github/workflows/check-preset.yml b/.github/workflows/check-preset.yml new file mode 100644 index 0000000000..550e06698d --- /dev/null +++ b/.github/workflows/check-preset.yml @@ -0,0 +1,74 @@ +--- +name: Check preset request +permissions: {} + +env: + NODE_VERSION: '24' + +on: + issues: + types: + - opened + - edited + - reopened + - labeled + +concurrency: + group: ${{ github.workflow }}-${{ github.event.issue.number }} + cancel-in-progress: true + +jobs: + check: + if: | + ( + github.event.action != 'labeled' || + github.event.label.name == 'request-game-preset' || + github.event.label.name == 'request-app-preset' + ) && github.event.issue.state == 'open' && ( + contains(github.event.issue.labels.*.name, 'request-game-preset') || + contains(github.event.issue.labels.*.name, 'request-app-preset') + ) + runs-on: ubuntu-latest + permissions: + contents: read + issues: write + 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 + + - name: Setup Node + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: ${{ env.NODE_VERSION }} + + - name: Validate request + id: validate + env: + 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 + + - name: Post result + if: always() && hashFiles('report.md') != '' + env: + GH_TOKEN: ${{ github.token }} + ISSUE_NUMBER: ${{ github.event.issue.number }} + run: gh issue comment "$ISSUE_NUMBER" --body-file report.md + + - 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 + with: + github-token: ${{ secrets.GH_BOT_TOKEN }} + script: | + const {canApprove, queueIssueForApproval} = require('./src/approval-queue.js') + if (canApprove(context.payload.issue.user.id)) { + await queueIssueForApproval({github, context}) + } diff --git a/.github/workflows/ci-tests.yml b/.github/workflows/ci-tests.yml new file mode 100644 index 0000000000..7d1c685113 --- /dev/null +++ b/.github/workflows/ci-tests.yml @@ -0,0 +1,35 @@ +--- +name: CI Tests +permissions: {} + +env: + NODE_VERSION: '24' + +on: + pull_request: + push: + branches: + - master + +jobs: + tests: + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Setup Node + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: ${{ env.NODE_VERSION }} + + - name: Install dependencies + run: npm ci --ignore-scripts + + - name: Test + run: npm test + + - name: Lint + run: npm run lint diff --git a/.github/workflows/comment-command.yml b/.github/workflows/comment-command.yml new file mode 100644 index 0000000000..48a19cf3d2 --- /dev/null +++ b/.github/workflows/comment-command.yml @@ -0,0 +1,29 @@ +--- +name: Comment command +permissions: {} + +on: + issue_comment: + types: + - created + +jobs: + comment_command: + name: Comment command ${{ github.event.issue.number }} + if: | + github.actor != 'LizardByte-bot' && + startsWith(github.event.comment.body, '@LizardByte-bot') + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Handle command + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + github-token: ${{ secrets.GH_BOT_TOKEN }} + script: | + const {run} = require('./src/comment-command.js') + await run({github, context}) diff --git a/.gitignore b/.gitignore index e3f4af32f2..4b31929ca7 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,7 @@ # JetBrains IDEs .idea/ + +# Local site build and test artifacts +site-build/ +node_modules/ +coverage/ diff --git a/.readthedocs.yaml b/.readthedocs.yaml new file mode 100644 index 0000000000..e57188179f --- /dev/null +++ b/.readthedocs.yaml @@ -0,0 +1,25 @@ +--- +# Read the Docs configuration file +# See https://docs.readthedocs.io/en/stable/config-file/v2.html for details + +version: 2 + +build: + os: ubuntu-26.04 + tools: + ruby: "3.3" + apt_packages: + - 7zip + - jq + jobs: + install: + - | + mkdir -p "./tmp" + branch="master" + base_url="https://raw.githubusercontent.com/LizardByte/LizardByte.github.io" + url="${base_url}/refs/heads/${branch}/scripts/readthedocs_build.sh" + curl -sSL -o "./tmp/readthedocs_build.sh" "${url}" + chmod +x "./tmp/readthedocs_build.sh" + build: + html: + - GITHUB_WORKFLOW=build SITE_ARTIFACT=site-source EXTRACT_ARCHIVE=build.zip ./tmp/readthedocs_build.sh diff --git a/LICENSE b/LICENSE index 644d7cc1e8..0ad25db4bd 100644 --- a/LICENSE +++ b/LICENSE @@ -1 +1,661 @@ -No license, replace this file after using the template. + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. diff --git a/README.md b/README.md index eaabf03e0b..5be81e2516 100644 --- a/README.md +++ b/README.md @@ -4,10 +4,75 @@ alt="LizardByte icon" width="256" /> -

repo-name

-

Repo description. This should exactly match the description in GitHub.

+

PresetDB

+

Community Sunshine game and app launch presets.

- SonarCloud + Pages build status + Last Pages deployment + Last database update + Open game requests + Open app requests + Approved requests
+ +Community maintained launch presets for [Sunshine](https://github.com/LizardByte/Sunshine). Each game or app can have many presets across Windows, Linux, and macOS. Launch methods include direct executables, Steam, Epic Games, GOG, emulators, and other options. GitHub issue numbers provide stable preset IDs, while descriptive names distinguish emulator cores or store editions. + +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 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. + +## Database growth + +The website publishes [catalog statistics](https://app.lizardbyte.dev/PresetDB/stats.json) with game, app, preset, and contribution counts. The charts update from approved issue history whenever Pages is built. + +
+ Game count + App count + Preset count +
+ +![Contribution leaderboard](https://app.lizardbyte.dev/PresetDB/top_contributors.svg) + +![Preset growth](https://app.lizardbyte.dev/PresetDB/preset_growth.svg) + +## Contribute + +1. Read the [preset guidelines](docs/presetGuidelines.md). For a game, copy its [IGDB game URL](https://www.igdb.com/) and [open a game preset request](https://github.com/LizardByte/PresetDB/issues/new?template=game-preset.yml). 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. Choose the host OS and launch method, then give the preset a descriptive name such as `RetroArch with Snes9x` or `Steam URI`. Open another issue for another OS or option. +3. Fill in one **Command**. Sunshine's [app examples](https://github.com/LizardByte/Sunshine/blob/master/docs/app_examples.md) show different Steam URI forms for Windows, Linux, and macOS. The bot stores Steam launcher URIs as Sunshine detached commands. Epic launcher URIs and executable commands become `cmd`. Commands are stored as text and never executed by validation or by the website. +4. To replace a preset, provide its issue number and explain the change. The bot preserves the original preset ID. + +After validation, a listed trusted game contributor enters the approval queue automatically. Other requests wait for a listed approver or repository admin to comment `@LizardByte-bot approve`. App requests always require this separate review. The queue processes one approval at a time; see the [approver guide](docs/approverGuide.md) and [bot commands](docs/botCommands.md). + +Supported path placeholders in Command and Working directory are `{{ROM_PATH}}` and `{{HOME}}`; Windows also allows `{{SYSTEM_DRIVE}}`, `{{PROGRAM_FILES}}`, and `{{PROGRAM_FILES_X86}}`. Unknown or malformed placeholders are rejected. Literal commands still require maintainer review. + +Game submissions resolve the submitted slug through [IGDB's 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 and Epic launcher URI syntax is checked against the selected host OS and method; binary commands still need human review. + +## Repository and deployment + +- `src/`: issue parsing, validation, database updates, and site assembly. +- `database/`: starter database layout; approved records live on the `database` branch. +- `gh-pages-template/`: Jekyll pages and browser assets. +- `.github/workflows/`: request validation, approval, tests, and Pages build. +- `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. + +Before enabling automation in a new repository, create the `database` and `gh-pages` branches from `master`, set Pages to deploy from `gh-pages`, and 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. Create the `request-game-preset`, `request-app-preset`, `approve-queue`, and `approve-preset` labels. Set the GitHub repository description to the heading description above. The `database` branch retains the `database/` directory; approved records are written there. + +Connect this repository to Read the Docs and enable pull request preview builds. The preview configuration downloads the `site-source` artifact from the Pages build job and uses the shared LizardByte theme. See [developer setup](docs/developerSetup.md) for local commands. + +## Local checks + +Run `npm test` and `npm run lint`. To assemble the site locally, run `node src/build-site.js --database database --output site-build`, then serve `site-build` with a static server. + +## License + +AGPL-3.0-only. See [LICENSE](LICENSE). diff --git a/auto_approved_users.json b/auto_approved_users.json new file mode 100644 index 0000000000..3ec9e36631 --- /dev/null +++ b/auto_approved_users.json @@ -0,0 +1,9 @@ +[ + { + "commands": [ + "*" + ], + "user_id": 42013603, + "username": "ReenigneArcher" + } +] diff --git a/database/apps/.gitkeep b/database/apps/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/database/games/.gitkeep b/database/games/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/docs/approverGuide.md b/docs/approverGuide.md new file mode 100644 index 0000000000..eb883e44e0 --- /dev/null +++ b/docs/approverGuide.md @@ -0,0 +1,13 @@ +# Approver Guide + +This guide is for listed approvers and repository administrators reviewing game and app preset issues. + +1. Open the oldest validated request in the [game queue](https://github.com/LizardByte/PresetDB/issues?q=is%3Aopen+label%3Arequest-game-preset) or [app queue](https://github.com/LizardByte/PresetDB/issues?q=is%3Aopen+label%3Arequest-app-preset). Check the latest validation comment after the last issue edit. +2. For games, check the IGDB URL and resolved GameDB record. For apps, inspect the official URL and optional image URL. App requests always need a separate review, including when a trusted contributor submits them. +3. Check the host OS, launch method, command, working directory, and setup notes against the [preset guidelines](presetGuidelines.md) and [Sunshine examples](https://github.com/LizardByte/Sunshine/blob/master/docs/app_examples.md). Review literal commands carefully; automated validation does not run them. +4. Search the published record for an equivalent name, OS, and method. A replacement must identify an existing preset issue and explain the change. +5. Comment `@LizardByte-bot approve` to enter the approval queue. The bot adds `approve-queue` and starts `approve-preset` when the active slot is free. It revalidates against IGDB and GameDB before writing to the `database` branch. + +The approval workflow comments its result. A successful approval closes the issue and requests a Pages rebuild. On failure, it removes the issue from the queue so the next request can proceed; fix the issue and queue it again. The `approve-preset` label remains on successfully closed issues for the approved count badge. + +The trusted user list lives in [`auto_approved_users.json`](../auto_approved_users.json). Add or remove an approver by changing that file in a reviewed pull request. A listed game submitter is queued automatically after successful validation. Repository administrators can also approve by comment. diff --git a/docs/botCommands.md b/docs/botCommands.md new file mode 100644 index 0000000000..d4fd52909e --- /dev/null +++ b/docs/botCommands.md @@ -0,0 +1,8 @@ +# Bot Commands + +On an open game or app preset issue, a listed approver or repository administrator can comment: + +- `@LizardByte-bot approve` to put the request in the approval queue. +- `@LizardByte-bot check` to rerun validation. + +The issue author may also use `@LizardByte-bot check`. The bot reacts to commands it accepts. Editing an issue body runs validation automatically. diff --git a/docs/developerSetup.md b/docs/developerSetup.md new file mode 100644 index 0000000000..ea4da8a0a1 --- /dev/null +++ b/docs/developerSetup.md @@ -0,0 +1,19 @@ +# Developer Setup + +The application, automation helpers, and site builder use Node.js 24. Install dependencies and run local checks with: + +```shell +npm ci --ignore-scripts +npm test +npm run lint +``` + +Build the static site from the local database layout with: + +```shell +node src/build-site.js --database database --output site-build +``` + +The site builder writes `index.json`, `stats.json`, two SVG contribution charts, and game and app records. It uses the `gh-pages-template` directory as the Jekyll source. The Pages workflow packages that output for the shared LizardByte Jekyll workflow. + +Read the Docs pull request previews use `.readthedocs.yaml` and the shared `readthedocs_build.sh` script. Connect the repository to Read the Docs, enable pull request builds, and ensure the `Build Pages` workflow can upload its `site-source` artifact for the PR commit. The shared script downloads `build.zip` from that artifact and builds the preview 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 new file mode 100644 index 0000000000..ba7ee8ebea --- /dev/null +++ b/docs/presetGuidelines.md @@ -0,0 +1,21 @@ +# Preset Guidelines + +One issue requests one Sunshine launch option for one host operating system. Submit another issue for a different store, emulator, core, or operating system. Give each option a descriptive name so it can be distinguished from presets already listed on the website. + +## Games + +Use the public IGDB game URL. The bot resolves its slug to a numeric IGDB ID and requires the same game and slug in GameDB. The catalog obtains the game cover from GameDB, so the form has no image field. + +Choose the launch method that describes the command: Native, Steam, Epic Games, GOG, Emulator, or Other. Emulator details such as console, launcher, and core belong in the preset name or notes. They are not required fields. + +## Apps + +Provide the official HTTPS homepage or source repository. An app image is optional and must be an HTTPS URL. App requests have no GameDB record and always require separate maintainer review. + +## Commands and paths + +Enter one command. Sunshine's [application examples](https://github.com/LizardByte/Sunshine/blob/master/docs/app_examples.md) show the platform-specific Steam and Epic forms. Steam launcher URIs are stored as Sunshine detached commands; Epic launcher URIs and executable commands are stored as normal commands. The validator checks known URI forms against the selected OS and launch method. A maintainer reviews other commands before approval. + +The supported path placeholders are `{{ROM_PATH}}` and `{{HOME}}`. Windows also supports `{{SYSTEM_DRIVE}}`, `{{PROGRAM_FILES}}`, and `{{PROGRAM_FILES_X86}}`. These placeholders must be replaced with paths on the Sunshine host before use. Do not include a private username or a path that only exists on your computer. + +For replacements, enter the issue number displayed with the published preset and explain what changed. The original preset ID is retained. diff --git a/gh-pages-template/_config.yml b/gh-pages-template/_config.yml new file mode 100644 index 0000000000..4ffc83489a --- /dev/null +++ b/gh-pages-template/_config.yml @@ -0,0 +1,3 @@ +--- +# LizardByte's shared Jekyll theme supplies the site layout. +site-js: [] diff --git a/gh-pages-template/assets/js/app.js b/gh-pages-template/assets/js/app.js new file mode 100644 index 0000000000..46cf6ddedf --- /dev/null +++ b/gh-pages-template/assets/js/app.js @@ -0,0 +1,176 @@ +'use strict'; + +function filterItems(index, query, kind, os) { + const needle = query.trim().toLowerCase(); + return [ + ...(index.games || []).map(item => ({ ...item, kind: 'game' })), + ...(index.apps || []).map(item => ({ ...item, kind: 'app' })) + ].filter(item => + (kind === 'all' || item.kind === kind) && + (os === 'all' || item.operating_systems.includes(os)) && + item.name.toLowerCase().includes(needle) + ).sort((a, b) => a.name.localeCompare(b.name)); +} + +function sunshineSnippet(preset) { + return JSON.stringify(preset.sunshine, null, 2); +} + +function normalizeBasePath(value) { + const configured = String(value || ''); + return configured.includes('{{') ? '' : `/${configured}`.replace(/\/+/g, '/').replace(/\/$/, ''); +} + +function element(tag, className, content) { + const node = document.createElement(tag); + if (className) node.className = className; + if (content !== undefined) node.textContent = content; + return node; +} + +function safeLink(url, text) { + const link = element('a', '', text); + try { + const parsed = new URL(url); + if (parsed.protocol === 'https:') { + link.href = parsed.href; + link.target = '_blank'; + link.rel = 'noopener noreferrer'; + } + } catch { /* The link stays plain text if the record has a bad URL. */ } + return link; +} + +function safeImage(url) { + if (!url) return null; + try { + const parsed = new URL(url); + if (parsed.protocol !== 'https:') return null; + const img = element('img', 'd-block mx-auto mb-3'); + img.src = parsed.href; + img.alt = ''; + img.width = 100; + img.height = 130; + img.style.objectFit = 'contain'; + img.loading = 'lazy'; + return img; + } catch { return null; } +} + +function boot() { + const base = normalizeBasePath(globalThis.PRESET_BASE); + const search = document.getElementById('preset-search'); + const kind = document.getElementById('preset-kind'); + const os = document.getElementById('preset-os'); + const list = document.getElementById('preset-list'); + const status = document.getElementById('preset-status'); + const detail = document.getElementById('preset-detail'); + let index; + + function renderList() { + if (!index) return; + const items = filterItems(index, search.value, kind.value, os.value); + list.replaceChildren(); + status.textContent = items.length ? `${items.length} games and apps found` : 'No matching presets yet.'; + for (const item of items) { + const column = element('div', 'col'); + const button = element('button', 'card h-100 w-100 text-start border-0 shadow-sm rounded-0 p-4'); + button.type = 'button'; + const image = safeImage(item.image_url); + if (image) button.append(image); + 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(', ')}`)); + button.addEventListener('click', () => { + const url = new URL(globalThis.location.href); + url.searchParams.set('kind', item.kind); + url.searchParams.set('id', String(item.id)); + globalThis.history.pushState(null, '', url); + showRecord(item); + }); + column.append(button); + list.append(column); + } + } + + async function showRecord(item) { + detail.hidden = false; + detail.replaceChildren(element('p', '', 'Loading launch options…')); + try { + const folder = item.kind === 'game' ? 'games' : 'apps'; + const response = await fetch(`${base}/${folder}/${encodeURIComponent(item.id)}.json`); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const record = await response.json(); + const heading = element('h2', 'fw-bold', record.name); + detail.replaceChildren(heading, safeLink(record.source_url, item.kind === 'game' ? 'View on IGDB ↗' : 'Official app site ↗')); + const image = safeImage(record.image_url); + 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) { + const column = element('div', 'col'); + const card = element('article', 'card h-100 rounded-0 shadow-sm'); + const body = element('div', 'card-body'); + const methodLabels = { native: 'Native', steam: 'Steam', 'epic-games': 'Epic Games', gog: 'GOG', emulator: 'Emulator', other: 'Other' }; + const label = [preset.os, methodLabels[preset.method] || preset.method, preset.name].join(' · '); + body.append(element('h3', 'h5 card-title fw-bold', label)); + if (preset.notes) body.append(element('p', 'card-text', preset.notes)); + const command = element('pre', 'p-3 rounded bg-dark text-light overflow-auto'); + command.append(element('code', '', sunshineSnippet(preset))); + body.append(command); + const copy = element('button', 'btn btn-warning rounded-0', 'Copy Sunshine JSON'); + copy.type = 'button'; + copy.addEventListener('click', async () => { + try { + await navigator.clipboard.writeText(sunshineSnippet(preset)); + copy.textContent = 'Copied'; + } catch { + copy.textContent = 'Select and copy the JSON above'; + } + }); + body.append(copy); + if (preset.origin_issue) body.append(element('span', 'ms-3'), safeLink(`https://github.com/LizardByte/PresetDB/issues/${preset.origin_issue}`, `Preset issue #${preset.origin_issue} ↗`)); + if (preset.source_issue && preset.source_issue !== preset.origin_issue) { + body.append(element('span', 'ms-3'), safeLink(`https://github.com/LizardByte/PresetDB/issues/${preset.source_issue}`, 'Latest update ↗')); + } + card.append(body); + column.append(card); + presets.append(column); + } + detail.append(presets); + detail.scrollIntoView({ behavior: 'smooth', block: 'start' }); + } catch (error) { + detail.replaceChildren(element('p', 'text-danger', `Could not load presets: ${error.message}`)); + } + } + + async function load() { + try { + const response = await fetch(`${base}/index.json`); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + index = await response.json(); + renderList(); + const params = new URLSearchParams(globalThis.location.search); + const requestedKind = params.get('kind'); + const requestedId = params.get('id'); + if (requestedId && ['game', 'app'].includes(requestedKind)) { + const item = filterItems(index, '', requestedKind, 'all').find(entry => String(entry.id) === requestedId); + if (item) showRecord(item); + } + } catch (error) { + status.textContent = `Could not load the preset index: ${error.message}`; + } + } + + for (const control of [search, kind, os]) control.addEventListener('input', renderList); + globalThis.addEventListener('popstate', () => { + const params = new URLSearchParams(globalThis.location.search); + const item = index && filterItems(index, '', params.get('kind'), 'all').find(entry => String(entry.id) === params.get('id')); + if (item) showRecord(item); + else detail.hidden = true; + }); + load(); +} + +if (typeof document !== 'undefined') document.addEventListener('DOMContentLoaded', boot); +if (typeof module !== 'undefined') module.exports = { filterItems, sunshineSnippet, normalizeBasePath }; diff --git a/gh-pages-template/index.html b/gh-pages-template/index.html new file mode 100644 index 0000000000..5c26bfe58c --- /dev/null +++ b/gh-pages-template/index.html @@ -0,0 +1,56 @@ +--- +title: Sunshine Presets +layout: page +full-width: true +after-content: + - donate.html + - support.html +js: + - /PresetDB/assets/js/app.js +--- + + + +
+
+
+

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.

+

+ Contribute a game preset + Contribute an app preset +

+
+
+ +
+

Browse presets

+
+
+ + +
+
+ + +
+
+ + +
+
+

Loading presets…

+
+
+ + +
diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000000..0e354c38f5 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,16 @@ +{ + "name": "presetdb", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "presetdb", + "version": "0.0.0", + "license": "AGPL-3.0-only", + "engines": { + "node": ">=20" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000000..3f119c17f5 --- /dev/null +++ b/package.json @@ -0,0 +1,14 @@ +{ + "name": "presetdb", + "version": "0.0.0", + "private": true, + "description": "Community Sunshine 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" + } +} diff --git a/src/approval-queue.js b/src/approval-queue.js new file mode 100644 index 0000000000..f1c485c1a3 --- /dev/null +++ b/src/approval-queue.js @@ -0,0 +1,82 @@ +'use strict'; + +const fs = require('node:fs'); +const path = require('node:path'); + +const QUEUE_LABEL = 'approve-queue'; +const APPROVE_LABEL = 'approve-preset'; +const REQUEST_LABELS = new Set(['request-game-preset', 'request-app-preset']); + +function labelNames(issue) { + return new Set((issue.labels || []).map(label => typeof label === 'string' ? label : label.name)); +} + +function isPresetRequest(issue) { + const labels = labelNames(issue); + return issue.state === 'open' && !issue.pull_request && + [...REQUEST_LABELS].filter(label => labels.has(label)).length === 1; +} + +function loadApprovers(file = path.join(__dirname, '..', 'auto_approved_users.json')) { + const users = JSON.parse(fs.readFileSync(file, 'utf8')); + if (!Array.isArray(users)) throw new Error('auto_approved_users.json must contain a list'); + return users; +} + +function canApprove(userId, command = 'approve', users = loadApprovers()) { + const id = String(userId ?? ''); + return id !== '' && users.some(user => String(user.user_id) === id && + Array.isArray(user.commands) && (user.commands.includes('*') || user.commands.includes(command))); +} + +function repoParams(context) { + return { owner: context.repo.owner, repo: context.repo.repo }; +} + +async function listOpenIssuesWithLabel({ github, context, label }) { + const issues = await github.paginate(github.rest.issues.listForRepo, { + ...repoParams(context), state: 'open', labels: label, per_page: 100 + }); + return issues.filter(issue => !issue.pull_request); +} + +async function hasActiveApproval({ github, context, issueNumber }) { + const issues = await listOpenIssuesWithLabel({ github, context, label: APPROVE_LABEL }); + return issues.some(issue => issue.number !== Number(issueNumber)); +} + +async function queueIssueForApproval({ github, context, issue = context.payload.issue }) { + if (!isPresetRequest(issue)) throw new Error('Only one open game or app preset request can be queued'); + const labels = labelNames(issue); + if (labels.has(APPROVE_LABEL)) return [QUEUE_LABEL, APPROVE_LABEL]; + const active = await hasActiveApproval({ github, context, issueNumber: issue.number }); + const labelsToAdd = active ? [QUEUE_LABEL] : [QUEUE_LABEL, APPROVE_LABEL]; + await github.rest.issues.addLabels({ + ...repoParams(context), issue_number: issue.number, labels: labelsToAdd + }); + return labelsToAdd; +} + +async function releaseAndPromote({ github, context, issueNumber = context.issue.number }) { + const params = { ...repoParams(context), issue_number: issueNumber }; + const { data: issue } = await github.rest.issues.get(params); + const labels = labelNames(issue); + const labelsToRemove = issue.state === 'closed' ? [QUEUE_LABEL] : [APPROVE_LABEL, QUEUE_LABEL]; + for (const label of labelsToRemove) { + if (labels.has(label)) await github.rest.issues.removeLabel({ ...params, name: label }); + } + if (await hasActiveApproval({ github, context, issueNumber })) return null; + const queued = await listOpenIssuesWithLabel({ github, context, label: QUEUE_LABEL }); + const next = queued.filter(item => item.number !== Number(issueNumber) && isPresetRequest(item)) + .sort((a, b) => new Date(a.created_at) - new Date(b.created_at) || a.number - b.number)[0]; + if (!next) return null; + await github.rest.issues.addLabels({ + ...repoParams(context), issue_number: next.number, labels: [APPROVE_LABEL] + }); + return next; +} + +module.exports = { + APPROVE_LABEL, QUEUE_LABEL, canApprove, hasActiveApproval, isPresetRequest, + labelNames, listOpenIssuesWithLabel, loadApprovers, queueIssueForApproval, releaseAndPromote +}; diff --git a/src/build-site.js b/src/build-site.js new file mode 100644 index 0000000000..570ca8c32e --- /dev/null +++ b/src/build-site.js @@ -0,0 +1,51 @@ +'use strict'; + +const fs = require('node:fs'); +const path = require('node:path'); +const { buildStatistics } = require('./statistics'); + +function buildSite(database, template, output) { + fs.mkdirSync(output, { recursive: true }); + fs.cpSync(template, output, { recursive: true }); + const index = { schema_version: 1, games: [], apps: [] }; + const records = []; + for (const [folder, kind] of [['games', 'game'], ['apps', 'app']]) { + const directory = path.join(database, folder); + if (!fs.existsSync(directory)) continue; + const files = fs.readdirSync(directory).filter(file => file.endsWith('.json')).sort(); + 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')); + 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}`); + } + fs.copyFileSync(path.join(directory, file), path.join(target, file)); + records.push(item); + 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.map(preset => preset.os))].sort() + }); + } + index[folder].sort((a, b) => a.name.localeCompare(b.name) || String(a.id).localeCompare(String(b.id))); + } + fs.writeFileSync(path.join(output, 'index.json'), `${JSON.stringify(index, null, 2)}\n`); + const statistics = buildStatistics(index, records); + fs.writeFileSync(path.join(output, 'stats.json'), `${JSON.stringify(statistics.data, null, 2)}\n`); + fs.writeFileSync(path.join(output, 'top_contributors.svg'), statistics.contributorsSvg); + fs.writeFileSync(path.join(output, 'preset_growth.svg'), statistics.growthSvg); + return index; +} + +function main(args = process.argv.slice(2)) { + const values = {}; + for (let i = 0; i < args.length; i += 2) values[args[i]] = args[i + 1]; + if (!values['--database'] || !values['--output']) throw new Error('Use --database and --output'); + buildSite(values['--database'], values['--template'] || 'gh-pages-template', values['--output']); +} + +if (require.main === module) main(); + +module.exports = { buildSite }; diff --git a/src/comment-command.js b/src/comment-command.js new file mode 100644 index 0000000000..f9a041eb1d --- /dev/null +++ b/src/comment-command.js @@ -0,0 +1,43 @@ +'use strict'; + +const { canApprove, isPresetRequest, labelNames, queueIssueForApproval } = require('./approval-queue'); + +function commandFromComment(body) { + const match = String(body || '').trim().match(/^@LizardByte-bot\s+(approve|check)\s*$/i); + return match ? match[1].toLowerCase() : null; +} + +async function isAuthorized({ github, context, command }) { + const user = context.payload.comment.user; + if (canApprove(user.id, command)) return true; + if (command === 'check' && user.id === context.payload.issue.user.id) return true; + try { + const { data } = await github.rest.repos.getCollaboratorPermissionLevel({ + ...context.repo, username: user.login + }); + return data.permission === 'admin'; + } catch { + return false; + } +} + +async function run({ github, context }) { + const command = commandFromComment(context.payload.comment.body); + if (!command || !isPresetRequest(context.payload.issue)) return false; + if (!await isAuthorized({ github, context, command })) return false; + if (command === 'approve') { + await queueIssueForApproval({ github, context }); + } else { + const issue = context.payload.issue; + const label = labelNames(issue).has('request-game-preset') ? 'request-game-preset' : 'request-app-preset'; + await github.rest.issues.removeLabel({ ...context.repo, issue_number: issue.number, name: label }); + await new Promise(resolve => setTimeout(resolve, 1000)); + await github.rest.issues.addLabels({ ...context.repo, issue_number: issue.number, labels: [label] }); + } + await github.rest.reactions.createForIssueComment({ + ...context.repo, comment_id: context.payload.comment.id, content: '+1' + }); + return true; +} + +module.exports = { commandFromComment, isAuthorized, run }; diff --git a/src/database.js b/src/database.js new file mode 100644 index 0000000000..94899bcaef --- /dev/null +++ b/src/database.js @@ -0,0 +1,90 @@ +'use strict'; + +const fs = require('node:fs'); +const path = require('node:path'); +const { PresetError } = require('./presets'); + +function recordPath(root, preset) { + const folder = preset.kind === 'game' ? 'games' : 'apps'; + const id = preset.kind === 'game' ? String(preset.gameId) : preset.appId; + return path.join(root, folder, `${id}.json`); +} + +function readRecord(file) { + if (!fs.existsSync(file)) return null; + const record = JSON.parse(fs.readFileSync(file, 'utf8')); + if (!record || !Array.isArray(record.presets)) throw new PresetError(`Invalid database record: ${file}`); + return record; +} + +function mergePreset(root, preset, { + issueNumber, approvedBy, authorId = null, authorLogin = null, approvedAt = new Date().toISOString() +}, { write = false } = {}) { + const file = recordPath(root, preset); + let record = readRecord(file); + const name = preset.kind === 'game' ? preset.gameName : preset.appName; + const sourceUrl = preset.kind === 'game' + ? `https://www.igdb.com/games/${preset.gameSlug}` : preset.appUrl; + if (!record) { + record = { + schema_version: 1, kind: preset.kind, id: preset.kind === 'game' ? preset.gameId : preset.appId, + name, source_url: sourceUrl, image_url: null, presets: [] + }; + } + if (record.kind !== preset.kind || record.name !== name) { + if (preset.kind === 'app') throw new PresetError('An app with the same slug but a different name already exists'); + record.name = name; + } + record.source_url = sourceUrl; + if (preset.kind === 'game') { + record.igdb_slug = preset.gameSlug; + record.game_db_url = `https://app.lizardbyte.dev/GameDB/browse/games/?id=${preset.gameId}`; + record.image_url = preset.gameImageUrl; + } + else if (preset.appImageUrl) record.image_url = preset.appImageUrl; + const previous = preset.replacementIssue == null ? -1 : record.presets.findIndex(item => + item.origin_issue === preset.replacementIssue || item.source_issue === preset.replacementIssue + ); + if (preset.replacementIssue != null && previous < 0) { + throw new PresetError(`No preset for issue #${preset.replacementIssue} exists under this ${preset.kind}`); + } + const normalizedName = preset.presetName.normalize('NFKC').toLocaleLowerCase(); + if (record.presets.some((item, index) => index !== previous && item.os === preset.os && + item.method === preset.method && item.name.normalize('NFKC').toLocaleLowerCase() === normalizedName)) { + throw new PresetError('A preset with this name, OS, and method already exists'); + } + const originIssue = previous >= 0 ? record.presets[previous].origin_issue : issueNumber; + const presetId = `issue-${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`); + } + history.push({ + issue: issueNumber, action: previous >= 0 ? 'replace' : 'add', + author_id: authorId, author_login: authorLogin, approved_at: approvedAt + }); + const entry = { + id: presetId, name: preset.presetName, os: preset.os, method: preset.method, + sunshine: { + name: `${name} (${preset.presetName})`, + ...(preset.commandMode === 'detached' ? { detached: [preset.command] } : { cmd: preset.command }), + ...(preset.workingDir ? { 'working-dir': preset.workingDir } : {}) + }, + notes: preset.notes, + origin_issue: originIssue, + source_issue: issueNumber, + replacement_reason: preset.replacementReason || null, + approved_by: approvedBy, + history + }; + if (previous >= 0) record.presets[previous] = entry; + else record.presets.push(entry); + record.presets.sort((a, b) => a.id.localeCompare(b.id)); + if (write) { + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, `${JSON.stringify(record, null, 2)}\n`); + } + return { file, record, id: presetId, action: previous >= 0 ? 'replace' : 'add' }; +} + +module.exports = { mergePreset, readRecord, recordPath }; diff --git a/src/issue.js b/src/issue.js new file mode 100644 index 0000000000..70e1d22d63 --- /dev/null +++ b/src/issue.js @@ -0,0 +1,68 @@ +'use strict'; + +const fs = require('node:fs'); +const path = require('node:path'); +const { parseIssue, validateFields, validateGameDb, PresetError } = require('./presets'); +const { mergePreset } = require('./database'); + +function requestKind(issue) { + const labels = new Set((issue.labels || []).map(label => typeof label === 'string' ? label : label.name)); + const game = labels.has('request-game-preset'); + const app = labels.has('request-app-preset'); + if (game === app) throw new PresetError('Issue must have exactly one request type label'); + return game ? 'game' : 'app'; +} + +async function processIssue(event, database, { approve = false, actor = '', fetcher = globalThis.fetch, credentials = {} } = {}) { + const issue = event.issue; + if (!issue || !Number.isInteger(issue.number)) throw new PresetError('A GitHub issue event is required'); + const kind = requestKind(issue); + const fields = parseIssue(issue.body || ''); + const preset = await validateGameDb(validateFields(fields, kind), fetcher, credentials); + const merged = mergePreset(database, preset, { + issueNumber: issue.number, approvedBy: actor, + authorId: issue.user?.id ?? null, authorLogin: issue.user?.login ?? null + }, { write: approve }); + return { ...merged, preset, kind }; +} + +function argsToObject(args) { + const options = {}; + for (let i = 0; i < args.length; i += 2) { + if (!args[i]?.startsWith('--') || !args[i + 1]) throw new Error('Use --event, --database, --mode, and --report'); + options[args[i].slice(2)] = args[i + 1]; + } + if (!options.event || !options.database || !['check', 'approve'].includes(options.mode) || !options.report) { + throw new Error('Use --event, --database, --mode, and --report'); + } + return options; +} + +async function main(args = process.argv.slice(2)) { + const options = argsToObject(args); + let message; + 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' + }); + 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); + message = `Preset ${result.action === 'replace' ? 'replacement' : 'request'} validated for ${item}.\n\n` + + `- Host: ${result.preset.os}\n- Method: ${result.preset.method}\n- Preset ID: \`${result.id}\`\n` + + `- Status: ${options.mode === 'approve' ? 'approved and saved' : 'awaiting maintainer review'}\n\n` + + `Sunshine application preview:\n\n\`\`\`json\n${JSON.stringify(entry.sunshine, 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); + 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 }; diff --git a/src/presets.js b/src/presets.js new file mode 100644 index 0000000000..60953b46b0 --- /dev/null +++ b/src/presets.js @@ -0,0 +1,230 @@ +'use strict'; + +const GAME_DB = 'https://app.lizardbyte.dev/GameDB'; +const PATH_PLACEHOLDERS = new Set(['ROM_PATH', 'HOME', 'SYSTEM_DRIVE', 'PROGRAM_FILES', 'PROGRAM_FILES_X86']); +const WINDOWS_PATH_PLACEHOLDERS = new Set(['SYSTEM_DRIVE', 'PROGRAM_FILES', 'PROGRAM_FILES_X86']); +const FIELD_NAMES = { + 'IGDB game URL': 'gameUrl', + 'App name': 'appName', + 'Official app URL': 'appUrl', + 'App image URL': 'appImageUrl', + 'Host operating system': 'os', + 'Launch method': 'method', + 'Preset name': 'presetName', + Command: 'command', + 'Working directory': 'workingDir', + Notes: 'notes', + 'Preset to replace (issue number)': 'replacementIssue', + 'Replacement reason': 'replacementReason' +}; + +class PresetError extends Error {} + +function parseIssue(body) { + const result = {}; + const sections = body.split(/^### (.+)\s*$/m); + for (let i = 1; i < sections.length; i += 2) { + const key = FIELD_NAMES[sections[i].trim()]; + if (key) { + if (Object.hasOwn(result, key)) throw new PresetError(`Duplicate issue field: ${key}`); + const value = sections[i + 1].trim(); + result[key] = value === '_No response_' ? '' : value; + } + } + return result; +} + +function field(values, name, { required = false, limit = 1024, singleLine = false } = {}) { + const value = String(values[name] || '').trim(); + if (required && !value) throw new PresetError(`${name} is required`); + if (value.length > limit || /[\x00-\x08\x0B-\x1F\x7F]/.test(value) || (singleLine && /[\r\n]/.test(value))) { + throw new PresetError(`${name} is too long or contains invalid characters`); + } + return value; +} + +function positiveId(value, label) { + if (!/^[1-9][0-9]{0,14}$/.test(value) || !Number.isSafeInteger(Number(value))) { + throw new PresetError(`${label} must be a positive issue number`); + } + return Number(value); +} + +function slug(value) { + const result = value.toLowerCase().normalize('NFKD').replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, ''); + if (!result) throw new PresetError('Name must contain letters or digits'); + return result; +} + +function validatePlaceholders(value, os, label) { + const tokens = [...value.matchAll(/\{\{([A-Z0-9_]+)\}\}/g)]; + const remainder = value.replace(/\{\{[A-Z0-9_]+\}\}/g, ''); + if (remainder.includes('{{') || remainder.includes('}}')) { + throw new PresetError(`${label} contains a malformed path placeholder`); + } + for (const [, token] of tokens) { + if (!PATH_PLACEHOLDERS.has(token)) throw new PresetError(`${label} uses unsupported placeholder {{${token}}}`); + if (os !== 'Windows' && WINDOWS_PATH_PLACEHOLDERS.has(token)) { + throw new PresetError(`{{${token}}} is available on Windows only`); + } + } +} + +function validateFields(values, kind) { + if (kind !== 'game' && kind !== 'app') throw new PresetError('Exactly one request type is required'); + const os = field(values, 'os', { required: true, limit: 20, singleLine: true }); + const method = field(values, 'method', { required: true, limit: 30, singleLine: true }); + if (!['Windows', 'Linux', 'macOS'].includes(os) || + !['Native', 'Steam', 'Epic Games', 'GOG', 'Emulator', 'Other'].includes(method)) { + throw new PresetError('Choose a supported host OS and launch method'); + } + const presetName = field(values, 'presetName', { required: true, limit: 100, singleLine: true }); + + let gameId = null; + let gameSlug = null; + let appName = null; + let appUrl = null; + let appId = null; + let appImageUrl = null; + if (kind === 'game') { + const gameUrl = field(values, 'gameUrl', { required: true, limit: 300, singleLine: true }); + let url; + try { url = new URL(gameUrl); } catch { throw new PresetError('IGDB game URL is invalid'); } + const match = url.pathname.match(/^\/games\/([a-z0-9-]+)\/?$/); + if (url.origin !== 'https://www.igdb.com' || url.search || url.hash || !match) { + throw new PresetError('IGDB game URL must be https://www.igdb.com/games/'); + } + gameSlug = match[1]; + } else { + appName = field(values, 'appName', { required: true, limit: 100, singleLine: true }); + appId = slug(appName); + appUrl = field(values, 'appUrl', { required: true, limit: 500, singleLine: true }); + let url; + try { url = new URL(appUrl); } catch { throw new PresetError('Official app URL must be a valid HTTPS URL'); } + if (url.protocol !== 'https:' || !url.hostname || url.username || url.password) { + throw new PresetError('Official app URL must be a valid HTTPS URL'); + } + appImageUrl = field(values, 'appImageUrl', { limit: 500, singleLine: true }); + if (appImageUrl) { + try { url = new URL(appImageUrl); } catch { throw new PresetError('App image URL must be a valid HTTPS URL'); } + if (url.protocol !== 'https:' || !url.hostname || url.username || url.password) { + throw new PresetError('App image URL must be a valid HTTPS URL'); + } + } + } + + const command = field(values, 'command', { required: true, singleLine: true }); + const workingDir = field(values, 'workingDir', { limit: 512, singleLine: true }); + validatePlaceholders(command, os, 'Launch command'); + validatePlaceholders(workingDir, os, 'Working directory'); + const steamUri = /steam:\/\//i.test(command); + const epicUri = /com\.epicgames\.launcher:\/\//i.test(command); + if (steamUri && method !== 'Steam') { + throw new PresetError('Steam URI requires the Steam launch method'); + } + if (epicUri && method !== 'Epic Games') { + throw new PresetError('Epic Games URI requires the Epic Games launch method'); + } + if (steamUri) { + const uri = 'steam:\/\/(?:rungameid\/[0-9]+|open\/bigpicture)'; + const pattern = os === 'Windows' ? `^${uri}$` : os === 'Linux' ? `^setsid steam ${uri}$` : `^open ${uri}$`; + if (!new RegExp(pattern, 'i').test(command)) { + throw new PresetError(`Steam URI must use Sunshine's ${os} command form`); + } + } + if (epicUri) { + if (os !== 'Windows' || !/^com\.epicgames\.launcher:\/\/apps\/[^\s?]+(?:\?[^\s]+)?$/i.test(command)) { + throw new PresetError('Epic Games launcher URI is supported for Windows only'); + } + } + + const replacementText = field(values, 'replacementIssue', { limit: 15, singleLine: true }); + const replacementIssue = replacementText ? positiveId(replacementText, 'Preset to replace') : null; + const replacementReason = field(values, 'replacementReason', { limit: 500 }); + if (Boolean(replacementIssue) !== Boolean(replacementReason)) { + throw new PresetError('A replacement needs both the existing issue number and a reason'); + } + return { + kind, gameId, gameSlug, appId, appName, appUrl, appImageUrl, presetName, replacementIssue, + os, method: slug(method), command, commandMode: steamUri ? 'detached' : 'cmd', + workingDir: workingDir || null, + notes: field(values, 'notes', { limit: 2000 }) || null, + replacementReason + }; +} + +async function fetchGameDb(id, fetcher = globalThis.fetch) { + let response; + try { + response = await fetcher(`${GAME_DB}/games/${id}.json`, { headers: { Accept: 'application/json' }, signal: AbortSignal.timeout(15000) }); + } catch (error) { + throw new PresetError(`GameDB game ${id} lookup failed: ${error.message}`); + } + if (!response.ok) throw new PresetError(`GameDB game ${id} lookup returned HTTP ${response.status}`); + let record; + try { record = await response.json(); } catch { throw new PresetError(`GameDB game ${id} returned invalid JSON`); } + if (!record || record.id !== id || typeof record.name !== 'string' || !record.name.trim() || + typeof record.slug !== 'string') { + throw new PresetError(`GameDB game ${id} returned an invalid record`); + } + return record; +} + +async function resolveIgdbSlug(gameSlug, { + fetcher = globalThis.fetch, + clientId = process.env.TWITCH_CLIENT_ID, + clientSecret = process.env.TWITCH_CLIENT_SECRET +} = {}) { + if (!clientId || !clientSecret) throw new PresetError('IGDB credentials are not configured'); + let tokenResponse; + try { + tokenResponse = await fetcher('https://id.twitch.tv/oauth2/token', { + method: 'POST', + headers: { Accept: 'application/json', 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ client_id: clientId, client_secret: clientSecret, grant_type: 'client_credentials' }), + signal: AbortSignal.timeout(15000) + }); + } catch (error) { + throw new PresetError(`IGDB authentication failed: ${error.message}`); + } + if (!tokenResponse.ok) throw new PresetError(`IGDB authentication returned HTTP ${tokenResponse.status}`); + let accessToken; + try { accessToken = (await tokenResponse.json()).access_token; } catch { /* checked below */ } + if (typeof accessToken !== 'string' || !accessToken) throw new PresetError('IGDB authentication returned no access token'); + + let gameResponse; + try { + gameResponse = await fetcher('https://api.igdb.com/v4/games', { + method: 'POST', + headers: { Accept: 'application/json', 'Client-ID': clientId, Authorization: `Bearer ${accessToken}` }, + body: `fields id,slug; where slug = "${gameSlug}"; limit 2;`, + signal: AbortSignal.timeout(15000) + }); + } catch (error) { + throw new PresetError(`IGDB slug lookup failed: ${error.message}`); + } + if (!gameResponse.ok) throw new PresetError(`IGDB slug lookup returned HTTP ${gameResponse.status}`); + let games; + try { games = await gameResponse.json(); } catch { throw new PresetError('IGDB slug lookup returned invalid JSON'); } + if (!Array.isArray(games) || games.length !== 1 || games[0]?.slug !== gameSlug || + !Number.isSafeInteger(games[0]?.id) || games[0].id <= 0) { + throw new PresetError(`IGDB did not return exactly one game for slug ${gameSlug}`); + } + return games[0].id; +} + +async function validateGameDb(preset, fetcher = globalThis.fetch, credentials = {}) { + if (preset.kind === 'app') return preset; + preset.gameId = await resolveIgdbSlug(preset.gameSlug, { fetcher, ...credentials }); + const game = await fetchGameDb(preset.gameId, fetcher); + if (game.slug !== preset.gameSlug) { + throw new PresetError(`IGDB slug ${preset.gameSlug} does not match GameDB game ${preset.gameId} (${game.slug})`); + } + preset.gameName = game.name; + const cover = game.cover?.url; + preset.gameImageUrl = typeof cover === 'string' && /^\/\/images\.igdb\.com\//.test(cover) + ? `https:${cover}` : null; + return preset; +} + +module.exports = { PresetError, parseIssue, validateFields, validateGameDb, fetchGameDb, resolveIgdbSlug, validatePlaceholders, slug }; diff --git a/src/statistics.js b/src/statistics.js new file mode 100644 index 0000000000..04b5bb9532 --- /dev/null +++ b/src/statistics.js @@ -0,0 +1,89 @@ +'use strict'; + +function escapeXml(value) { + return String(value).replace(/[&<>"']/g, character => ({ + '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' + })[character]); +} + +function svgFrame(title, description, height, content) { + return `` + + `${escapeXml(title)}${escapeXml(description)}` + + `` + + `` + + `${escapeXml(title)}${content}\n`; +} + +function contributionHistory(records) { + return records.flatMap(record => record.presets.flatMap(preset => preset.history || [])) + .filter(item => item && ['add', 'replace'].includes(item.action)); +} + +function contributorSvg(history) { + const contributors = new Map(); + for (const item of history) { + if (!item.author_login) continue; + const key = item.author_id == null ? item.author_login.toLowerCase() : String(item.author_id); + const prior = contributors.get(key) || { login: item.author_login, count: 0 }; + prior.login = item.author_login; + prior.count += 1; + contributors.set(key, prior); + } + const top = [...contributors.values()].sort((a, b) => b.count - a.count || a.login.localeCompare(b.login)).slice(0, 10); + if (top.length === 0) { + return svgFrame('Contribution leaderboard', 'No approved contributions yet.', 150, + 'No approved contributions yet'); + } + const maximum = top[0].count; + const rows = top.map((item, index) => { + const y = 91 + index * 47; + const width = Math.max(4, Math.round(480 * item.count / maximum)); + return `${escapeXml(item.login)}` + + `` + + `${item.count}`; + }).join(''); + return svgFrame('Contribution leaderboard', 'Approved new and replacement preset submissions by GitHub user.', + 105 + top.length * 47, rows); +} + +function growthSvg(history) { + const additions = history.filter(item => item.action === 'add' && typeof item.approved_at === 'string' && + !Number.isNaN(Date.parse(item.approved_at))) + .sort((a, b) => Date.parse(a.approved_at) - Date.parse(b.approved_at)); + if (additions.length === 0) { + return svgFrame('Preset growth', 'No approved presets yet.', 300, + 'No approved presets yet'); + } + const points = additions.map((_, index) => { + const x = additions.length === 1 ? 440 : 70 + index * 740 / (additions.length - 1); + const y = 245 - (index + 1) * 145 / additions.length; + return [Math.round(x), Math.round(y)]; + }); + const line = points.map(([x, y]) => `${x},${y}`).join(' '); + const dots = points.map(([x, y]) => ``).join(''); + const firstDate = additions[0].approved_at.slice(0, 10); + const lastDate = additions.at(-1).approved_at.slice(0, 10); + const content = '' + + `${dots}` + + `${escapeXml(firstDate)}` + + `${escapeXml(lastDate)}` + + `${additions.length} presets`; + return svgFrame('Preset growth', 'Cumulative number of approved new presets by approval date.', 300, content); +} + +function buildStatistics(index, records) { + const history = contributionHistory(records); + return { + data: { + schema_version: 1, + game_count: index.games.length, + app_count: index.apps.length, + preset_count: records.reduce((total, record) => total + record.presets.length, 0), + contribution_count: history.length + }, + contributorsSvg: contributorSvg(history), + growthSvg: growthSvg(history) + }; +} + +module.exports = { buildStatistics, contributionHistory, contributorSvg, growthSvg }; diff --git a/src/verify-approval.js b/src/verify-approval.js new file mode 100644 index 0000000000..d7cb9a9941 --- /dev/null +++ b/src/verify-approval.js @@ -0,0 +1,34 @@ +'use strict'; + +const { APPROVE_LABEL, QUEUE_LABEL, canApprove, isPresetRequest, labelNames } = require('./approval-queue'); + +async function verifyApprovalEvent({ github, context }) { + const issue = context.payload.issue; + const labels = labelNames(issue); + if (!isPresetRequest(issue) || !labels.has(QUEUE_LABEL) || !labels.has(APPROVE_LABEL)) { + throw new Error('Approval requires an open queued preset request'); + } + const sender = context.payload.sender; + if (sender.login === 'LizardByte-bot' || canApprove(sender.id)) return; + let permission; + try { + const { data } = await github.rest.repos.getCollaboratorPermissionLevel({ + ...context.repo, username: sender.login + }); + permission = data.permission; + } catch { /* A non-collaborator has no approval permission. */ } + if (permission !== 'admin') throw new Error('Only a listed approver or repository admin may approve'); +} + +async function verifyCurrentIssue({ github, context }) { + const { data: current } = await github.rest.issues.get({ + ...context.repo, issue_number: context.payload.issue.number + }); + const labels = labelNames(current); + if (!isPresetRequest(current) || !labels.has(QUEUE_LABEL) || !labels.has(APPROVE_LABEL) || + current.body !== context.payload.issue.body) { + throw new Error('Issue changed after approval was queued; validate and approve it again'); + } +} + +module.exports = { verifyApprovalEvent, verifyCurrentIssue }; diff --git a/src/workflow-queue.js b/src/workflow-queue.js new file mode 100644 index 0000000000..b02bb18edb --- /dev/null +++ b/src/workflow-queue.js @@ -0,0 +1,19 @@ +'use strict'; + +async function waitForOlderApprovals({ github, context, intervalMs = 30000, timeoutMs = 3600000 }) { + const started = Date.now(); + while (true) { + const runs = await github.paginate(github.rest.actions.listWorkflowRuns, { + ...context.repo, workflow_id: 'approve-preset.yml', status: 'in_progress', per_page: 100 + }); + const older = runs.filter(run => run.status === 'in_progress' && run.id < context.runId && + String(run.display_title || '').startsWith('approve-preset ')); + if (older.length === 0) return; + if (Date.now() - started >= timeoutMs) { + throw new Error(`Timed out waiting for older approval run ${older[0].id}`); + } + await new Promise(resolve => setTimeout(resolve, intervalMs)); + } +} + +module.exports = { waitForOlderApprovals }; diff --git a/tests/approval-queue.test.js b/tests/approval-queue.test.js new file mode 100644 index 0000000000..e80d41b924 --- /dev/null +++ b/tests/approval-queue.test.js @@ -0,0 +1,115 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { canApprove, queueIssueForApproval, releaseAndPromote } = require('../src/approval-queue'); +const { commandFromComment, run: runComment } = require('../src/comment-command'); +const { verifyApprovalEvent, verifyCurrentIssue } = require('../src/verify-approval'); +const { waitForOlderApprovals } = require('../src/workflow-queue'); +const { buildStatistics } = require('../src/statistics'); + +function issue(number, createdAt) { + return { + number, state: 'open', created_at: createdAt, + labels: [{ name: 'request-game-preset' }], user: { id: 900, login: 'contributor' } + }; +} + +function mockGithub(issues) { + const actions = []; + const github = { + paginate: async (_endpoint, params) => params.labels + ? issues.filter(item => item.state === 'open' && item.labels.some(label => label.name === params.labels)) + : [], + rest: { + issues: { + listForRepo() {}, + addLabels: async ({ issue_number, labels }) => { + actions.push(['add', issue_number, ...labels]); + const target = issues.find(item => item.number === issue_number); + for (const name of labels) if (!target.labels.some(label => label.name === name)) target.labels.push({ name }); + }, + removeLabel: async ({ issue_number, name }) => { + actions.push(['remove', issue_number, name]); + const target = issues.find(item => item.number === issue_number); + target.labels = target.labels.filter(label => label.name !== name); + }, + get: async ({ issue_number }) => ({ data: issues.find(item => item.number === issue_number) }) + }, + repos: { getCollaboratorPermissionLevel: async () => ({ data: { permission: 'read' } }) }, + reactions: { createForIssueComment: async () => { actions.push(['reaction']); } }, + actions: { listWorkflowRuns() {} } + } + }; + return { github, actions }; +} + +test('the shared approver list grants its recorded user IDs', () => { + assert.equal(canApprove(42013603), true); + assert.equal(canApprove(900), false); + assert.equal(commandFromComment('@LizardByte-bot approve'), 'approve'); + assert.equal(commandFromComment('@LizardByte-bot approve extra'), null); +}); + +test('approval labels serialize requests and promote the oldest queued issue', async () => { + const issues = [issue(1, '2026-01-01T00:00:00Z'), issue(2, '2026-01-02T00:00:00Z'), + issue(3, '2026-01-03T00:00:00Z')]; + const { github, actions } = mockGithub(issues); + const context = { repo: { owner: 'LizardByte', repo: 'PresetDB' }, issue: { number: 1 }, payload: {} }; + assert.deepEqual(await queueIssueForApproval({ github, context, issue: issues[0] }), ['approve-queue', 'approve-preset']); + assert.deepEqual(await queueIssueForApproval({ github, context, issue: issues[2] }), ['approve-queue']); + assert.deepEqual(await queueIssueForApproval({ github, context, issue: issues[1] }), ['approve-queue']); + issues[0].state = 'closed'; + const next = await releaseAndPromote({ github, context }); + assert.equal(next.number, 2); + assert.ok(issues[0].labels.some(label => label.name === 'approve-preset')); + assert.ok(!issues[0].labels.some(label => label.name === 'approve-queue')); + assert.ok(actions.some(action => action[0] === 'add' && action[1] === 2 && action[2] === 'approve-preset')); +}); + +test('bot approval commands require an approver or repository admin', async () => { + const current = issue(5, '2026-01-01T00:00:00Z'); + const { github } = mockGithub([current]); + const context = { repo: { owner: 'LizardByte', repo: 'PresetDB' }, payload: { + issue: current, comment: { id: 10, body: '@LizardByte-bot approve', user: { id: 900, login: 'untrusted' } } + } }; + assert.equal(await runComment({ github, context }), false); + context.payload.comment.user = { id: 42013603, login: 'ReenigneArcher' }; + assert.equal(await runComment({ github, context }), true); + assert.ok(current.labels.some(label => label.name === 'approve-preset')); + context.payload.sender = { id: 999, login: 'LizardByte-bot' }; + await verifyApprovalEvent({ github, context }); + context.payload.sender = { id: 900, login: 'untrusted' }; + await assert.rejects(verifyApprovalEvent({ github, context }), /Only a listed approver/); +}); + +test('approval stops when an issue changes after it was queued', async () => { + const current = issue(8, '2026-01-01T00:00:00Z'); + current.body = 'original command'; + current.labels.push({ name: 'approve-queue' }, { name: 'approve-preset' }); + const { github } = mockGithub([current]); + const context = { repo: { owner: 'LizardByte', repo: 'PresetDB' }, payload: { + issue: { ...current, body: 'original command' } + } }; + await verifyCurrentIssue({ github, context }); + current.body = 'changed command'; + await assert.rejects(verifyCurrentIssue({ github, context }), /changed after approval/); +}); + +test('workflow wait exits when no older approval is running', async () => { + const { github } = mockGithub([]); + await waitForOlderApprovals({ github, context: { repo: { owner: 'LizardByte', repo: 'PresetDB' }, runId: 5 } }); +}); + +test('published statistics count approvals and escape contributor names', () => { + const records = [{ presets: [{ history: [ + { issue: 1, action: 'add', author_id: 1, author_login: 'alice<&', approved_at: '2026-01-01T00:00:00Z' }, + { issue: 2, action: 'replace', author_id: 1, author_login: 'alice<&', approved_at: '2026-01-02T00:00:00Z' } + ] }] }]; + const stats = buildStatistics({ games: [{}], apps: [] }, records); + assert.equal(stats.data.game_count, 1); + assert.equal(stats.data.preset_count, 1); + assert.equal(stats.data.contribution_count, 2); + assert.match(stats.contributorsSvg, /alice<&/); + assert.match(stats.growthSvg, /1 presets/); +}); diff --git a/tests/presets.test.js b/tests/presets.test.js new file mode 100644 index 0000000000..e3fd47ca35 --- /dev/null +++ b/tests/presets.test.js @@ -0,0 +1,178 @@ +'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 { parseIssue, validateFields, validateGameDb, resolveIgdbSlug } = require('../src/presets'); +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 credentials = { clientId: 'client-id', clientSecret: 'client-secret' }; +const gameValues = { + gameUrl: 'https://www.igdb.com/games/one-tap-hero', os: 'Windows', method: 'Emulator', + presetName: 'RetroArch Snes9x', command: 'retroarch -L snes9x "{{ROM_PATH}}"', + workingDir: '{{HOME}}', notes: 'Install the core first.' +}; + +function mockApis(url, options) { + if (url === 'https://id.twitch.tv/oauth2/token') { + assert.equal(options.method, 'POST'); + assert.equal(options.body.get('client_id'), credentials.clientId); + assert.equal(options.body.get('client_secret'), credentials.clientSecret); + return Promise.resolve({ ok: true, json: async () => ({ access_token: 'test-token' }) }); + } + if (url === 'https://api.igdb.com/v4/games') { + assert.equal(options.method, 'POST'); + assert.equal(options.headers['Client-ID'], credentials.clientId); + assert.equal(options.headers.Authorization, 'Bearer test-token'); + assert.match(options.body, /where slug = "one-tap-hero"/); + return Promise.resolve({ ok: true, json: async () => [{ id: 100245, slug: 'one-tap-hero' }] }); + } + assert.equal(url, 'https://app.lizardbyte.dev/GameDB/games/100245.json'); + return Promise.resolve({ ok: true, json: async () => ({ + id: 100245, name: 'One Tap Hero', slug: 'one-tap-hero', + cover: { url: '//images.igdb.com/igdb/image/upload/t_thumb/cover.jpg' } + }) }); +} + +function formBody(values) { + const labels = { + gameUrl: 'IGDB game URL', appName: 'App name', appUrl: 'Official app URL', + appImageUrl: 'App image URL', os: 'Host operating system', method: 'Launch method', + presetName: 'Preset name', command: 'Command', workingDir: 'Working directory', + notes: 'Notes', replacementIssue: 'Preset to replace (issue number)', + replacementReason: 'Replacement reason' + }; + return Object.entries(values).map(([key, value]) => `### ${labels[key]}\n\n${value}`).join('\n\n'); +} + +test('form parses the slug URL and rejects duplicate fields', () => { + assert.equal(parseIssue(formBody(gameValues)).command, gameValues.command); + assert.throws(() => parseIssue(`${formBody(gameValues)}\n\n### Command\n\nother`), /Duplicate issue field/); +}); + +test('game submission needs no ID and validates path placeholders', () => { + const preset = validateFields(gameValues, 'game'); + assert.equal(preset.gameId, null); + assert.equal(preset.gameSlug, 'one-tap-hero'); + assert.equal(preset.commandMode, 'cmd'); + assert.equal(validateFields({ ...gameValues, command: '{{PROGRAM_FILES}}\\Game\\game.exe' }, 'game').os, 'Windows'); + assert.throws(() => validateFields({ ...gameValues, os: 'Linux', command: '{{PROGRAM_FILES}}/game' }, 'game'), /Windows only/); + assert.throws(() => validateFields({ ...gameValues, command: '{{EMULATOR_PATH}} -L core' }, 'game'), /unsupported placeholder/); + assert.throws(() => validateFields({ ...gameValues, command: '{{rom_path}}' }, 'game'), /malformed path placeholder/); +}); + +test('IGDB slug resolves to ID and GameDB verifies it', async () => { + const preset = await validateGameDb(validateFields(gameValues, 'game'), mockApis, credentials); + assert.equal(preset.gameId, 100245); + assert.equal(preset.gameName, 'One Tap Hero'); + assert.match(preset.gameImageUrl, /^https:\/\/images\.igdb\.com\//); + await assert.rejects(validateGameDb(validateFields(gameValues, 'game'), async (url, options) => + url.includes('GameDB') ? { ok: true, json: async () => ({ id: 100245, name: 'Other', slug: 'other' }) } : mockApis(url, options), + credentials), /does not match/); + await assert.rejects(validateGameDb(validateFields(gameValues, 'game'), async (url, options) => + url.includes('GameDB') ? { ok: false, status: 404 } : mockApis(url, options), + credentials), /HTTP 404/); +}); + +test('IGDB resolution rejects missing credentials and missing or ambiguous records', async () => { + await assert.rejects(resolveIgdbSlug('one-tap-hero', { fetcher: mockApis, clientId: '', clientSecret: '' }), /credentials/); + for (const games of [[], [{ id: 1, slug: 'one-tap-hero' }, { id: 2, slug: 'one-tap-hero' }]]) { + await assert.rejects(resolveIgdbSlug('one-tap-hero', { ...credentials, fetcher: async (url, options) => + url.includes('/v4/games') ? { ok: true, json: async () => games } : mockApis(url, options) + }), /exactly one game/); + } +}); + +test('apps use an HTTPS image URL and have separate review identity', () => { + const values = { appName: 'My App', appUrl: 'https://example.org/app', appImageUrl: 'https://example.org/icon.png', + os: 'macOS', method: 'Native', presetName: 'Installed', command: '{{HOME}}/Applications/my-app' }; + const app = validateFields(values, 'app'); + assert.equal(app.appId, 'my-app'); + assert.equal(app.appImageUrl, values.appImageUrl); + assert.throws(() => validateFields({ ...values, appUrl: 'http://example.org' }, 'app'), /HTTPS/); + assert.throws(() => validateFields({ ...values, appImageUrl: 'C:\\icon.png' }, 'app'), /image URL/); + assert.throws(() => validateFields({ ...values, command: '{{APP_PATH}}' }, 'app'), /unsupported placeholder/); +}); + +test('one command classifies Steam and Epic URI forms by host OS', () => { + const base = { ...gameValues, method: 'Steam', presetName: 'Steam URI' }; + assert.equal(validateFields({ ...base, command: 'steam://rungameid/464920' }, 'game').commandMode, 'detached'); + assert.throws(() => validateFields({ ...base, os: 'Linux', command: 'steam://rungameid/464920' }, 'game'), /Linux/); + assert.equal(validateFields({ ...base, os: 'Linux', command: 'setsid steam steam://rungameid/464920' }, 'game').commandMode, 'detached'); + assert.equal(validateFields({ ...base, os: 'macOS', command: 'open steam://rungameid/464920' }, 'game').commandMode, 'detached'); + assert.throws(() => validateFields({ ...base, method: 'Native', command: 'steam://rungameid/464920' }, 'game'), /Steam launch method/); + assert.equal(validateFields({ ...base, command: 'steam -applaunch 464920' }, 'game').commandMode, 'cmd'); + const epic = { ...base, method: 'Epic Games', command: 'com.epicgames.launcher://apps/abc?action=launch&silent=true' }; + assert.equal(validateFields(epic, 'game').commandMode, 'cmd'); + assert.throws(() => validateFields({ ...epic, os: 'Linux' }, 'game'), /Windows only/); +}); + +test('approved presets get issue IDs and replacements preserve identity', async t => { + 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.throws(() => mergePreset(root, first, { issueNumber: 11, approvedBy: 'maintainer' }), /already exists/); + const second = await validateGameDb(validateFields({ ...gameValues, presetName: 'RetroArch Bsnes' }, 'game'), mockApis, credentials); + 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'); + const record = JSON.parse(fs.readFileSync(path.join(root, 'games/100245.json'))); + 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').origin_issue, 10); + assert.equal(record.presets.find(item => item.id === 'issue-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 URI publishes as Sunshine detached command without an image path', 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', + presetName: 'Steam URI', command: 'steam://rungameid/464920' }, 'game'), mockApis, credentials); + const { record } = mergePreset(root, steam, { issueNumber: 30, approvedBy: 'reviewer' }, { write: true }); + assert.deepEqual(record.presets[0].sunshine.detached, ['steam://rungameid/464920']); + assert.ok(!Object.hasOwn(record.presets[0].sunshine, 'cmd')); + assert.ok(!Object.hasOwn(record.presets[0].sunshine, 'image-path')); +}); + +test('issue processing and site build publish both game and app JSON', async t => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'preset-site-')); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + const database = path.join(root, 'database'); + const output = path.join(root, 'site'); + await processIssue({ issue: { number: 21, labels: [{ name: 'request-game-preset' }], body: formBody(gameValues) } }, + database, { approve: true, actor: 'reviewer', fetcher: mockApis, credentials }); + await processIssue({ issue: { number: 22, labels: [{ name: 'request-app-preset' }], body: formBody({ + appName: 'App One', appUrl: 'https://example.org', appImageUrl: 'https://example.org/icon.png', + os: 'Linux', method: 'Native', presetName: 'Installed', command: '{{HOME}}/app-one' + }) } }, database, { approve: true, actor: 'reviewer' }); + const index = buildSite(database, path.join(__dirname, '..', 'gh-pages-template'), output); + assert.equal(index.games[0].preset_count, 1); + assert.equal(index.apps[0].id, 'app-one'); + assert.equal(index.apps[0].image_url, 'https://example.org/icon.png'); + 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'))); + assert.ok(fs.existsSync(path.join(output, 'preset_growth.svg'))); + assert.equal(JSON.parse(fs.readFileSync(path.join(output, 'stats.json'))).preset_count, 2); + assert.ok(!fs.readFileSync(path.join(output, 'index.json'), 'utf8').includes('{{ROM_PATH}}')); +}); + +test('site filters and serializes Sunshine application JSON', () => { + const index = { games: [{ id: 1, name: 'Halo', operating_systems: ['Windows'], preset_count: 2 }], + 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(normalizeBasePath('/PresetDB'), '/PresetDB'); + assert.equal(normalizeBasePath('en/pr-123'), '/en/pr-123'); +}); From aed64f0b5203a8dbf79a6e0fc650ac8f84b80d25 Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Wed, 23 Sep 2026 13:46:27 -0400 Subject: [PATCH 02/12] fix: address Sonar findings and publish Codecov reports --- .github/workflows/ci-tests.yml | 37 +++++++++- .gitignore | 2 + README.md | 2 + gh-pages-template/index.html | 2 +- package.json | 3 +- src/build-site.js | 2 +- src/comment-command.js | 2 +- src/database.js | 18 ++++- src/presets.js | 130 +++++++++++++++++---------------- tests/approval-queue.test.js | 3 +- 10 files changed, 130 insertions(+), 71 deletions(-) diff --git a/.github/workflows/ci-tests.yml b/.github/workflows/ci-tests.yml index 7d1c685113..94e595a047 100644 --- a/.github/workflows/ci-tests.yml +++ b/.github/workflows/ci-tests.yml @@ -29,7 +29,42 @@ jobs: run: npm ci --ignore-scripts - name: Test - run: npm test + id: test + run: npm run test:ci + + - name: Upload test coverage + # any except cancelled or skipped + if: + always() && + ( + steps.test.outcome == 'success' || + steps.test.outcome == 'failure' + ) && + startsWith(github.repository, 'LizardByte/') + uses: codecov/codecov-action@303a32d7a59b442fa8d48b6a1cc6825c09c847a5 # v7.1.1 + with: + fail_ci_if_error: true + files: ./lcov.info + report_type: coverage + token: ${{ secrets.CODECOV_TOKEN }} + verbose: true + + - name: Upload test results + # any except cancelled or skipped + if: + always() && + ( + steps.test.outcome == 'success' || + steps.test.outcome == 'failure' + ) && + startsWith(github.repository, 'LizardByte/') + uses: codecov/codecov-action@303a32d7a59b442fa8d48b6a1cc6825c09c847a5 # v7.1.1 + with: + fail_ci_if_error: true + files: ./junit.xml + report_type: test_results + token: ${{ secrets.CODECOV_TOKEN }} + verbose: true - name: Lint run: npm run lint diff --git a/.gitignore b/.gitignore index 4b31929ca7..c18fedade2 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,5 @@ site-build/ node_modules/ coverage/ +junit.xml +lcov.info diff --git a/README.md b/README.md index 5be81e2516..e95bfd1ef6 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,8 @@
Pages build status + Codecov + SonarCloud Last Pages deployment Last database update Open game requests diff --git a/gh-pages-template/index.html b/gh-pages-template/index.html index 5c26bfe58c..d6ea6b9fa8 100644 --- a/gh-pages-template/index.html +++ b/gh-pages-template/index.html @@ -48,7 +48,7 @@

Browse presets

-

Loading presets…

+ Loading presets…
diff --git a/package.json b/package.json index 3f119c17f5..6dbcc7a574 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ }, "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/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 570ca8c32e..24bbb8aaab 100644 --- a/src/build-site.js +++ b/src/build-site.js @@ -26,7 +26,7 @@ function buildSite(database, template, output) { 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.map(preset => preset.os))].sort() + operating_systems: [...new Set(item.presets.map(preset => preset.os))].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/comment-command.js b/src/comment-command.js index f9a041eb1d..059fe9dfc6 100644 --- a/src/comment-command.js +++ b/src/comment-command.js @@ -3,7 +3,7 @@ const { canApprove, isPresetRequest, labelNames, queueIssueForApproval } = require('./approval-queue'); function commandFromComment(body) { - const match = String(body || '').trim().match(/^@LizardByte-bot\s+(approve|check)\s*$/i); + const match = /^@LizardByte-bot\s+(approve|check)\s*$/i.exec(String(body || '').trim()); return match ? match[1].toLowerCase() : null; } diff --git a/src/database.js b/src/database.js index 94899bcaef..cfd868862a 100644 --- a/src/database.js +++ b/src/database.js @@ -17,14 +17,12 @@ function readRecord(file) { return record; } -function mergePreset(root, preset, { - issueNumber, approvedBy, authorId = null, authorLogin = null, approvedAt = new Date().toISOString() -}, { write = false } = {}) { +function prepareRecord(root, preset) { const file = recordPath(root, preset); - let record = readRecord(file); const name = preset.kind === 'game' ? preset.gameName : preset.appName; const sourceUrl = preset.kind === 'game' ? `https://www.igdb.com/games/${preset.gameSlug}` : preset.appUrl; + let record = readRecord(file); if (!record) { record = { schema_version: 1, kind: preset.kind, id: preset.kind === 'game' ? preset.gameId : preset.appId, @@ -42,6 +40,10 @@ function mergePreset(root, preset, { record.image_url = preset.gameImageUrl; } else if (preset.appImageUrl) record.image_url = preset.appImageUrl; + return { file, record, name }; +} + +function replacementIndex(record, preset) { const previous = preset.replacementIssue == null ? -1 : record.presets.findIndex(item => item.origin_issue === preset.replacementIssue || item.source_issue === preset.replacementIssue ); @@ -53,6 +55,14 @@ function mergePreset(root, preset, { item.method === preset.method && item.name.normalize('NFKC').toLocaleLowerCase() === normalizedName)) { throw new PresetError('A preset with this name, OS, and method already exists'); } + return previous; +} + +function mergePreset(root, preset, { + issueNumber, approvedBy, authorId = null, authorLogin = null, approvedAt = new Date().toISOString() +}, { write = false } = {}) { + 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 history = previous >= 0 ? [...(record.presets[previous].history || [])] : []; diff --git a/src/presets.js b/src/presets.js index 60953b46b0..d43f96fd54 100644 --- a/src/presets.js +++ b/src/presets.js @@ -22,7 +22,7 @@ class PresetError extends Error {} function parseIssue(body) { const result = {}; - const sections = body.split(/^### (.+)\s*$/m); + const sections = body.split(/^### ([^\r\n]+)$/m); for (let i = 1; i < sections.length; i += 2) { const key = FIELD_NAMES[sections[i].trim()]; if (key) { @@ -44,7 +44,7 @@ function field(values, name, { required = false, limit = 1024, singleLine = fals } function positiveId(value, label) { - if (!/^[1-9][0-9]{0,14}$/.test(value) || !Number.isSafeInteger(Number(value))) { + if (!/^[1-9]\d{0,14}$/.test(value) || !Number.isSafeInteger(Number(value))) { throw new PresetError(`${label} must be a positive issue number`); } return Number(value); @@ -70,53 +70,49 @@ function validatePlaceholders(value, os, label) { } } -function validateFields(values, kind) { - if (kind !== 'game' && kind !== 'app') throw new PresetError('Exactly one request type is required'); - const os = field(values, 'os', { required: true, limit: 20, singleLine: true }); - const method = field(values, 'method', { required: true, limit: 30, singleLine: true }); - if (!['Windows', 'Linux', 'macOS'].includes(os) || - !['Native', 'Steam', 'Epic Games', 'GOG', 'Emulator', 'Other'].includes(method)) { - throw new PresetError('Choose a supported host OS and launch method'); +function gameIdentity(values) { + const gameUrl = field(values, 'gameUrl', { required: true, limit: 300, singleLine: true }); + let url; + try { url = new URL(gameUrl); } catch { throw new PresetError('IGDB game URL is invalid'); } + const match = /^\/games\/([a-z0-9-]+)\/?$/.exec(url.pathname); + if (url.origin !== 'https://www.igdb.com' || url.search || url.hash || !match) { + throw new PresetError('IGDB game URL must be https://www.igdb.com/games/'); } - const presetName = field(values, 'presetName', { required: true, limit: 100, singleLine: true }); + return { + gameId: null, gameSlug: match[1], appId: null, appName: null, appUrl: null, appImageUrl: null + }; +} - let gameId = null; - let gameSlug = null; - let appName = null; - let appUrl = null; - let appId = null; - let appImageUrl = null; - if (kind === 'game') { - const gameUrl = field(values, 'gameUrl', { required: true, limit: 300, singleLine: true }); - let url; - try { url = new URL(gameUrl); } catch { throw new PresetError('IGDB game URL is invalid'); } - const match = url.pathname.match(/^\/games\/([a-z0-9-]+)\/?$/); - if (url.origin !== 'https://www.igdb.com' || url.search || url.hash || !match) { - throw new PresetError('IGDB game URL must be https://www.igdb.com/games/'); - } - gameSlug = match[1]; - } else { - appName = field(values, 'appName', { required: true, limit: 100, singleLine: true }); - appId = slug(appName); - appUrl = field(values, 'appUrl', { required: true, limit: 500, singleLine: true }); - let url; - try { url = new URL(appUrl); } catch { throw new PresetError('Official app URL must be a valid HTTPS URL'); } - if (url.protocol !== 'https:' || !url.hostname || url.username || url.password) { - throw new PresetError('Official app URL must be a valid HTTPS URL'); - } - appImageUrl = field(values, 'appImageUrl', { limit: 500, singleLine: true }); - if (appImageUrl) { - try { url = new URL(appImageUrl); } catch { throw new PresetError('App image URL must be a valid HTTPS URL'); } - if (url.protocol !== 'https:' || !url.hostname || url.username || url.password) { - throw new PresetError('App image URL must be a valid HTTPS URL'); - } - } +function httpsUrl(value, label) { + let url; + try { url = new URL(value); } catch { throw new PresetError(`${label} must be a valid HTTPS URL`); } + if (url.protocol !== 'https:' || !url.hostname || url.username || url.password) { + throw new PresetError(`${label} must be a valid HTTPS URL`); } +} - const command = field(values, 'command', { required: true, singleLine: true }); - const workingDir = field(values, 'workingDir', { limit: 512, singleLine: true }); - validatePlaceholders(command, os, 'Launch command'); - validatePlaceholders(workingDir, os, 'Working directory'); +function appIdentity(values) { + const appName = field(values, 'appName', { required: true, limit: 100, singleLine: true }); + const appUrl = field(values, 'appUrl', { required: true, limit: 500, singleLine: true }); + httpsUrl(appUrl, 'Official app URL'); + const appImageUrl = field(values, 'appImageUrl', { limit: 500, singleLine: true }); + if (appImageUrl) httpsUrl(appImageUrl, 'App image URL'); + return { + gameId: null, gameSlug: null, appId: slug(appName), appName, appUrl, appImageUrl + }; +} + +function validateSteamUri(command, os) { + const uri = String.raw`steam://(?:rungameid/\d+|open/bigpicture)`; + let pattern = `^${uri}$`; + if (os === 'Linux') pattern = `^setsid steam ${uri}$`; + else if (os === 'macOS') pattern = `^open ${uri}$`; + if (!new RegExp(pattern, 'i').test(command)) { + throw new PresetError(`Steam URI must use Sunshine's ${os} command form`); + } +} + +function validateLaunchCommand(command, os, method) { const steamUri = /steam:\/\//i.test(command); const epicUri = /com\.epicgames\.launcher:\/\//i.test(command); if (steamUri && method !== 'Steam') { @@ -125,31 +121,43 @@ function validateFields(values, kind) { if (epicUri && method !== 'Epic Games') { throw new PresetError('Epic Games URI requires the Epic Games launch method'); } - if (steamUri) { - const uri = 'steam:\/\/(?:rungameid\/[0-9]+|open\/bigpicture)'; - const pattern = os === 'Windows' ? `^${uri}$` : os === 'Linux' ? `^setsid steam ${uri}$` : `^open ${uri}$`; - if (!new RegExp(pattern, 'i').test(command)) { - throw new PresetError(`Steam URI must use Sunshine's ${os} command form`); - } - } - if (epicUri) { - if (os !== 'Windows' || !/^com\.epicgames\.launcher:\/\/apps\/[^\s?]+(?:\?[^\s]+)?$/i.test(command)) { - throw new PresetError('Epic Games launcher URI is supported for Windows only'); - } + if (steamUri) validateSteamUri(command, os); + if (epicUri && (os !== 'Windows' || !/^com\.epicgames\.launcher:\/\/apps\/[^\s?]+(?:\?[^\s]+)?$/i.test(command))) { + throw new PresetError('Epic Games launcher URI is supported for Windows only'); } + return steamUri ? 'detached' : 'cmd'; +} +function replacementFields(values) { const replacementText = field(values, 'replacementIssue', { limit: 15, singleLine: true }); const replacementIssue = replacementText ? positiveId(replacementText, 'Preset to replace') : null; const replacementReason = field(values, 'replacementReason', { limit: 500 }); if (Boolean(replacementIssue) !== Boolean(replacementReason)) { throw new PresetError('A replacement needs both the existing issue number and a reason'); } + return { replacementIssue, replacementReason }; +} + +function validateFields(values, kind) { + if (kind !== 'game' && kind !== 'app') throw new PresetError('Exactly one request type is required'); + const os = field(values, 'os', { required: true, limit: 20, singleLine: true }); + const method = field(values, 'method', { required: true, limit: 30, singleLine: true }); + if (!['Windows', 'Linux', 'macOS'].includes(os) || + !['Native', 'Steam', 'Epic Games', 'GOG', 'Emulator', 'Other'].includes(method)) { + throw new PresetError('Choose a supported host OS and launch method'); + } + const presetName = field(values, 'presetName', { required: true, limit: 100, singleLine: true }); + const identity = kind === 'game' ? gameIdentity(values) : appIdentity(values); + const command = field(values, 'command', { required: true, singleLine: true }); + const workingDir = field(values, 'workingDir', { limit: 512, singleLine: true }); + validatePlaceholders(command, os, 'Launch command'); + validatePlaceholders(workingDir, os, 'Working directory'); + const commandMode = validateLaunchCommand(command, os, method); return { - kind, gameId, gameSlug, appId, appName, appUrl, appImageUrl, presetName, replacementIssue, - os, method: slug(method), command, commandMode: steamUri ? 'detached' : 'cmd', + kind, ...identity, presetName, ...replacementFields(values), + os, method: slug(method), command, commandMode, workingDir: workingDir || null, - notes: field(values, 'notes', { limit: 2000 }) || null, - replacementReason + notes: field(values, 'notes', { limit: 2000 }) || null }; } @@ -222,7 +230,7 @@ async function validateGameDb(preset, fetcher = globalThis.fetch, credentials = } preset.gameName = game.name; const cover = game.cover?.url; - preset.gameImageUrl = typeof cover === 'string' && /^\/\/images\.igdb\.com\//.test(cover) + preset.gameImageUrl = typeof cover === 'string' && cover.startsWith('//images.igdb.com/') ? `https:${cover}` : null; return preset; } diff --git a/tests/approval-queue.test.js b/tests/approval-queue.test.js index e80d41b924..d148c503f6 100644 --- a/tests/approval-queue.test.js +++ b/tests/approval-queue.test.js @@ -98,7 +98,8 @@ test('approval stops when an issue changes after it was queued', async () => { test('workflow wait exits when no older approval is running', async () => { const { github } = mockGithub([]); - await waitForOlderApprovals({ github, context: { repo: { owner: 'LizardByte', repo: 'PresetDB' }, runId: 5 } }); + await assert.doesNotReject(waitForOlderApprovals({ github, + context: { repo: { owner: 'LizardByte', repo: 'PresetDB' }, runId: 5 } })); }); test('published statistics count approvals and escape contributor names', () => { From 5fbbe9b02c145f8e6f59c80069c16fe7fdc673fb Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Wed, 23 Sep 2026 13:53:04 -0400 Subject: [PATCH 03/12] docs: use Codecov badge until dashboard feed is available --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index e95bfd1ef6..2bf7281eee 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@
Pages build status - Codecov + Codecov SonarCloud Last Pages deployment Last database update From 0fe8146ad19cc3b1034d15d2b3c44965a7b1bc4b Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Wed, 23 Sep 2026 14:01:56 -0400 Subject: [PATCH 04/12] docs: restore dashboard Codecov badge --- .github/workflows/ci-tests.yml | 6 +++--- README.md | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci-tests.yml b/.github/workflows/ci-tests.yml index 94e595a047..2be839ec22 100644 --- a/.github/workflows/ci-tests.yml +++ b/.github/workflows/ci-tests.yml @@ -32,6 +32,9 @@ jobs: id: test run: npm run test:ci + - name: Lint + run: npm run lint + - name: Upload test coverage # any except cancelled or skipped if: @@ -65,6 +68,3 @@ jobs: report_type: test_results token: ${{ secrets.CODECOV_TOKEN }} verbose: true - - - name: Lint - run: npm run lint diff --git a/README.md b/README.md index 2bf7281eee..f9d94551b8 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@
Pages build status - Codecov + Codecov SonarCloud Last Pages deployment Last database update @@ -67,7 +67,7 @@ Game submissions resolve the submitted slug through [IGDB's authenticated API](h 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. -Before enabling automation in a new repository, create the `database` and `gh-pages` branches from `master`, set Pages to deploy from `gh-pages`, and 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. Create the `request-game-preset`, `request-app-preset`, `approve-queue`, and `approve-preset` labels. Set the GitHub repository description to the heading description above. The `database` branch retains the `database/` directory; approved records are written there. +The empty `database` branch is initialized with `database/apps` and `database/games`. Before enabling automation, create the `gh-pages` branch from `master`, set Pages to deploy from `gh-pages`, and 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. Create the `request-game-preset`, `request-app-preset`, `approve-queue`, and `approve-preset` labels. Set the GitHub repository description to the heading description above. The `database` branch retains the `database/` directory; approved records are written there. Connect this repository to Read the Docs and enable pull request preview builds. The preview configuration downloads the `site-source` artifact from the Pages build job and uses the shared LizardByte theme. See [developer setup](docs/developerSetup.md) for local commands. From 827faaf91433cfb7f34c17f4850cf8f9b5b3a1af Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Wed, 23 Sep 2026 14:44:02 -0400 Subject: [PATCH 05/12] Refine preset submission methods and generated names --- .github/ISSUE_TEMPLATE/app-preset.yml | 24 +-------- .github/ISSUE_TEMPLATE/game-preset.yml | 15 +++--- .readthedocs.yaml | 2 +- README.md | 12 ++--- docs/approverGuide.md | 4 +- docs/developerSetup.md | 10 +++- docs/presetGuidelines.md | 10 ++-- gh-pages-template/assets/js/app.js | 4 +- src/database.js | 26 +++++++--- src/issue.js | 3 +- src/presets.js | 71 +++++++++++++++++--------- tests/presets.test.js | 61 +++++++++++++++------- 12 files changed, 144 insertions(+), 98 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/app-preset.yml b/.github/ISSUE_TEMPLATE/app-preset.yml index c06eac75e8..ffe9811795 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 launch option per issue. + Submit one host OS and command per issue. The preset name is generated from the app name and OS. - type: input id: app_name attributes: @@ -39,33 +39,13 @@ body: - macOS validations: required: true - - type: dropdown - id: method - attributes: - label: Launch method - options: - - Native - - Steam - - Epic Games - - GOG - - Emulator - - Other - validations: - required: true - - type: input - id: preset_name - attributes: - label: Preset name - description: Short display name for this launch option. - validations: - required: true - type: input id: command attributes: label: Command description: | Use {{HOME}} for a variable path. Windows also allows {{SYSTEM_DRIVE}}, {{PROGRAM_FILES}}, and - {{PROGRAM_FILES_X86}}. Steam launcher URIs are stored as Sunshine detached commands automatically. + {{PROGRAM_FILES_X86}}. Use only host-independent paths; literal home directories are rejected. validations: required: true - type: input diff --git a/.github/ISSUE_TEMPLATE/game-preset.yml b/.github/ISSUE_TEMPLATE/game-preset.yml index 3b5e90272c..1e9a0f4c9a 100644 --- a/.github/ISSUE_TEMPLATE/game-preset.yml +++ b/.github/ISSUE_TEMPLATE/game-preset.yml @@ -9,7 +9,7 @@ body: attributes: value: | One issue represents one launch option. Provide the IGDB game URL; the bot resolves its ID and checks GameDB. - Use a descriptive preset name to distinguish stores, emulators, cores, or other choices. + Add an emulator variant only when the operating system and launch method cannot distinguish the preset. - type: input id: game_url attributes: @@ -36,24 +36,23 @@ body: - Steam - Epic Games - GOG + - Microsoft Store - Emulator - - Other validations: required: true - type: input - id: preset_name + id: variant_name attributes: - label: Preset name - description: Short display name, for example RetroArch with Snes9x. - validations: - required: true + label: Emulator variant name + description: Optional emulator, console, or core label when needed to distinguish launch options. - type: input id: command attributes: label: Command description: | Use {{ROM_PATH}} or {{HOME}} for variable paths. Windows also allows {{SYSTEM_DRIVE}}, {{PROGRAM_FILES}}, - and {{PROGRAM_FILES_X86}}. Steam launcher URIs are stored as Sunshine detached commands automatically. + and {{PROGRAM_FILES_X86}}. Use only host-independent paths; literal home directories are rejected. + Steam launcher URIs require detached commands; use a Steam executable command. validations: required: true - type: input diff --git a/.readthedocs.yaml b/.readthedocs.yaml index e57188179f..89e2d2c8f2 100644 --- a/.readthedocs.yaml +++ b/.readthedocs.yaml @@ -22,4 +22,4 @@ build: chmod +x "./tmp/readthedocs_build.sh" build: html: - - GITHUB_WORKFLOW=build SITE_ARTIFACT=site-source EXTRACT_ARCHIVE=build.zip ./tmp/readthedocs_build.sh + - ./tmp/readthedocs_build.sh diff --git a/README.md b/README.md index f9d94551b8..d565c9dad0 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ Approved requests
-Community maintained launch presets for [Sunshine](https://github.com/LizardByte/Sunshine). Each game or app can have many presets across Windows, Linux, and macOS. Launch methods include direct executables, Steam, Epic Games, GOG, emulators, and other options. GitHub issue numbers provide stable preset IDs, while descriptive names distinguish emulator cores or store editions. +Community maintained launch presets for [Sunshine](https://github.com/LizardByte/Sunshine). Each game or app can have many presets across Windows, Linux, and macOS. Game launch methods include Native, Steam, Epic Games, GOG, Microsoft Store, and Emulator. App presets use the app name and host OS. 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. @@ -46,15 +46,15 @@ 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 [open a game preset request](https://github.com/LizardByte/PresetDB/issues/new?template=game-preset.yml). 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. Choose the host OS and launch method, then give the preset a descriptive name such as `RetroArch with Snes9x` or `Steam URI`. Open another issue for another OS or option. -3. Fill in one **Command**. Sunshine's [app examples](https://github.com/LizardByte/Sunshine/blob/master/docs/app_examples.md) show different Steam URI forms for Windows, Linux, and macOS. The bot stores Steam launcher URIs as Sunshine detached commands. Epic launcher URIs and executable commands become `cmd`. Commands are stored as text and never executed by validation or by the website. +2. Fill in **one** launch option. Choose the host OS and, for a game, its launch method. Add an emulator variant such as `RetroArch Snes9x` only when needed to distinguish emulator presets. The bot generates the preset name. +3. Fill in one **Command**. The bot publishes it as Sunshine `cmd`. Sunshine's [app examples](https://github.com/LizardByte/Sunshine/blob/master/docs/app_examples.md) use detached commands for Steam launcher URIs, so use a Steam executable command instead. Commands are stored as text and never executed by validation or by the website. 4. To replace a preset, provide its issue number and explain the change. The bot preserves the original preset ID. After validation, a listed trusted game contributor enters the approval queue automatically. Other requests wait for a listed approver or repository admin to comment `@LizardByte-bot approve`. App requests always require this separate review. The queue processes one approval at a time; see the [approver guide](docs/approverGuide.md) and [bot commands](docs/botCommands.md). -Supported path placeholders in Command and Working directory are `{{ROM_PATH}}` and `{{HOME}}`; Windows also allows `{{SYSTEM_DRIVE}}`, `{{PROGRAM_FILES}}`, and `{{PROGRAM_FILES_X86}}`. Unknown or malformed placeholders are rejected. Literal commands still require maintainer review. +Supported path placeholders in 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's 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 and Epic launcher URI syntax is checked against the selected host OS and method; binary commands still need human review. +Game submissions resolve the submitted slug through [IGDB's 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 URIs are rejected because they require detached commands; Epic URI syntax is checked against Windows and the Epic Games method. Binary commands still need human review. ## Repository and deployment @@ -69,7 +69,7 @@ The Pages workflow creates an archive from the database and site template, then The empty `database` branch is initialized with `database/apps` and `database/games`. Before enabling automation, create the `gh-pages` branch from `master`, set Pages to deploy from `gh-pages`, and 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. Create the `request-game-preset`, `request-app-preset`, `approve-queue`, and `approve-preset` labels. Set the GitHub repository description to the heading description above. The `database` branch retains the `database/` directory; approved records are written there. -Connect this repository to Read the Docs and enable pull request preview builds. The preview configuration downloads the `site-source` artifact from the Pages build job and uses the shared LizardByte theme. See [developer setup](docs/developerSetup.md) for local commands. +Connect this repository to Read the Docs and enable pull request preview builds. Set `GITHUB_WORKFLOW=build`, `SITE_ARTIFACT=site-source`, and `EXTRACT_ARCHIVE=build.zip` in the Read the Docs project environment. The shared script downloads the Pages build artifact and extracts the nested site archive. See [developer setup](docs/developerSetup.md) for local commands. ## Local checks diff --git a/docs/approverGuide.md b/docs/approverGuide.md index eb883e44e0..d80bd280e6 100644 --- a/docs/approverGuide.md +++ b/docs/approverGuide.md @@ -4,8 +4,8 @@ This guide is for listed approvers and repository administrators reviewing game 1. Open the oldest validated request in the [game queue](https://github.com/LizardByte/PresetDB/issues?q=is%3Aopen+label%3Arequest-game-preset) or [app queue](https://github.com/LizardByte/PresetDB/issues?q=is%3Aopen+label%3Arequest-app-preset). Check the latest validation comment after the last issue edit. 2. For games, check the IGDB URL and resolved GameDB record. For apps, inspect the official URL and optional image URL. App requests always need a separate review, including when a trusted contributor submits them. -3. Check the host OS, launch method, command, working directory, and setup notes against the [preset guidelines](presetGuidelines.md) and [Sunshine examples](https://github.com/LizardByte/Sunshine/blob/master/docs/app_examples.md). Review literal commands carefully; automated validation does not run them. -4. Search the published record for an equivalent name, OS, and method. A replacement must identify an existing preset issue and explain the change. +3. Check the host OS, game launch method, command, working directory, and setup notes against the [preset guidelines](presetGuidelines.md) and [Sunshine examples](https://github.com/LizardByte/Sunshine/blob/master/docs/app_examples.md). Review literal commands carefully; automated validation does not run them. +4. Search the published record for the same OS, method, and emulator variant. A replacement must identify an existing preset issue and explain the change. 5. Comment `@LizardByte-bot approve` to enter the approval queue. The bot adds `approve-queue` and starts `approve-preset` when the active slot is free. It revalidates against IGDB and GameDB before writing to the `database` branch. The approval workflow comments its result. A successful approval closes the issue and requests a Pages rebuild. On failure, it removes the issue from the queue so the next request can proceed; fix the issue and queue it again. The `approve-preset` label remains on successfully closed issues for the approved count badge. diff --git a/docs/developerSetup.md b/docs/developerSetup.md index ea4da8a0a1..78205ad036 100644 --- a/docs/developerSetup.md +++ b/docs/developerSetup.md @@ -16,4 +16,12 @@ node src/build-site.js --database database --output site-build The site builder writes `index.json`, `stats.json`, two SVG contribution charts, and game and app records. It uses the `gh-pages-template` directory as the Jekyll source. The Pages workflow packages that output for the shared LizardByte Jekyll workflow. -Read the Docs pull request previews use `.readthedocs.yaml` and the shared `readthedocs_build.sh` script. Connect the repository to Read the Docs, enable pull request builds, and ensure the `Build Pages` workflow can upload its `site-source` artifact for the PR commit. The shared script downloads `build.zip` from that artifact and builds the preview with the organization theme. Hosted preview and Pages deployment require the repository, credentials, and branch settings described in the README. +Read the Docs pull request previews use `.readthedocs.yaml` and the shared `readthedocs_build.sh` script. Connect the repository to Read the Docs, enable pull request builds, and set these project environment variables: + +```text +GITHUB_WORKFLOW=build +SITE_ARTIFACT=site-source +EXTRACT_ARCHIVE=build.zip +``` + +The `Build Pages` workflow publishes a check run named `build`, uploads a `site-source` 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 ba7ee8ebea..3955f0215e 100644 --- a/docs/presetGuidelines.md +++ b/docs/presetGuidelines.md @@ -1,21 +1,21 @@ # Preset Guidelines -One issue requests one Sunshine launch option for one host operating system. Submit another issue for a different store, emulator, core, or operating system. Give each option a descriptive name so it can be distinguished from presets already listed on the website. +One issue requests one Sunshine launch option for one host operating system. Submit another issue for a different store, emulator, core, or operating system. The bot generates a name from the game or app name, host OS, and game launch method. ## Games Use the public IGDB game URL. The bot resolves its slug to a numeric IGDB ID and requires the same game and slug in GameDB. The catalog obtains the game cover from GameDB, so the form has no image field. -Choose the launch method that describes the command: Native, Steam, Epic Games, GOG, Emulator, or Other. Emulator details such as console, launcher, and core belong in the preset name or notes. They are not required fields. +Choose the launch method that describes the command: Native, Steam, Epic Games, GOG, Microsoft Store, or Emulator. Microsoft Store is available on Windows only. An optional emulator variant can identify the console, launcher, or core when multiple emulator presets share an OS. ## Apps -Provide the official HTTPS homepage or source repository. An app image is optional and must be an HTTPS URL. 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 name uses the app name and OS. App requests have no GameDB record and always require separate maintainer review. ## Commands and paths -Enter one command. Sunshine's [application examples](https://github.com/LizardByte/Sunshine/blob/master/docs/app_examples.md) show the platform-specific Steam and Epic forms. Steam launcher URIs are stored as Sunshine detached commands; Epic launcher URIs and executable commands are stored as normal commands. The validator checks known URI forms against the selected OS and launch method. A maintainer reviews other commands before approval. +Enter one command. PresetDB publishes it as Sunshine `cmd`. Sunshine's [application examples](https://github.com/LizardByte/Sunshine/blob/master/docs/app_examples.md) use detached commands for Steam launcher URIs, so submit a Steam executable command instead. Epic launcher URIs are supported for Windows Epic Games presets. A maintainer reviews other commands before approval. -The supported path placeholders are `{{ROM_PATH}}` and `{{HOME}}`. Windows also supports `{{SYSTEM_DRIVE}}`, `{{PROGRAM_FILES}}`, and `{{PROGRAM_FILES_X86}}`. These placeholders must be replaced with paths on the Sunshine host before use. Do not include a private username or a path that only exists on your computer. +The supported path placeholders are `{{ROM_PATH}}` and `{{HOME}}`. Windows also supports `{{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. For replacements, enter the issue number displayed with the published preset and explain what changed. The original preset ID is retained. diff --git a/gh-pages-template/assets/js/app.js b/gh-pages-template/assets/js/app.js index 46cf6ddedf..7690e4acbc 100644 --- a/gh-pages-template/assets/js/app.js +++ b/gh-pages-template/assets/js/app.js @@ -111,9 +111,7 @@ function boot() { const column = element('div', 'col'); const card = element('article', 'card h-100 rounded-0 shadow-sm'); const body = element('div', 'card-body'); - const methodLabels = { native: 'Native', steam: 'Steam', 'epic-games': 'Epic Games', gog: 'GOG', emulator: 'Emulator', other: 'Other' }; - const label = [preset.os, methodLabels[preset.method] || preset.method, preset.name].join(' · '); - body.append(element('h3', 'h5 card-title fw-bold', label)); + body.append(element('h3', 'h5 card-title fw-bold', preset.name)); if (preset.notes) body.append(element('p', 'card-text', preset.notes)); const command = element('pre', 'p-3 rounded bg-dark text-light overflow-auto'); command.append(element('code', '', sunshineSnippet(preset))); diff --git a/src/database.js b/src/database.js index cfd868862a..9573b6b02a 100644 --- a/src/database.js +++ b/src/database.js @@ -43,6 +43,17 @@ function prepareRecord(root, preset) { return { file, record, name }; } +function displayName(name, preset) { + if (preset.kind === 'app') return `${name} (${preset.os})`; + const labels = { + native: 'Native', steam: 'Steam', 'epic-games': 'Epic Games', + gog: 'GOG', 'microsoft-store': 'Microsoft Store', emulator: 'Emulator' + }; + const method = labels[preset.method]; + const variant = preset.variantName ? `: ${preset.variantName}` : ''; + return `${name} (${preset.os}, ${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 @@ -50,10 +61,11 @@ function replacementIndex(record, preset) { if (preset.replacementIssue != null && previous < 0) { throw new PresetError(`No preset for issue #${preset.replacementIssue} exists under this ${preset.kind}`); } - const normalizedName = preset.presetName.normalize('NFKC').toLocaleLowerCase(); + const normalizedVariant = (preset.variantName || '').normalize('NFKC').toLocaleLowerCase(); if (record.presets.some((item, index) => index !== previous && item.os === preset.os && - item.method === preset.method && item.name.normalize('NFKC').toLocaleLowerCase() === normalizedName)) { - throw new PresetError('A preset with this name, OS, and method already exists'); + item.method === preset.method && + (item.variant_name || '').normalize('NFKC').toLocaleLowerCase() === normalizedVariant)) { + throw new PresetError('A preset with this OS, method, and variant already exists'); } return previous; } @@ -73,11 +85,13 @@ 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: preset.presetName, os: preset.os, method: preset.method, + id: presetId, name: generatedName, os: preset.os, method: preset.method, + ...(preset.variantName ? { variant_name: preset.variantName } : {}), sunshine: { - name: `${name} (${preset.presetName})`, - ...(preset.commandMode === 'detached' ? { detached: [preset.command] } : { cmd: preset.command }), + name: generatedName, + cmd: preset.command, ...(preset.workingDir ? { 'working-dir': preset.workingDir } : {}) }, notes: preset.notes, diff --git a/src/issue.js b/src/issue.js index 70e1d22d63..80cfaff31e 100644 --- a/src/issue.js +++ b/src/issue.js @@ -49,8 +49,9 @@ 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 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}\n- Method: ${result.preset.method}\n- Preset ID: \`${result.id}\`\n` + + `- Host: ${result.preset.os}\n` + methodLine + `- Preset ID: \`${result.id}\`\n` + `- Status: ${options.mode === 'approve' ? 'approved and saved' : 'awaiting maintainer review'}\n\n` + `Sunshine application preview:\n\n\`\`\`json\n${JSON.stringify(entry.sunshine, null, 2)}\n\`\`\`\n`; success = true; diff --git a/src/presets.js b/src/presets.js index d43f96fd54..20a26664d2 100644 --- a/src/presets.js +++ b/src/presets.js @@ -10,7 +10,7 @@ const FIELD_NAMES = { 'App image URL': 'appImageUrl', 'Host operating system': 'os', 'Launch method': 'method', - 'Preset name': 'presetName', + 'Emulator variant name': 'variantName', Command: 'command', 'Working directory': 'workingDir', Notes: 'notes', @@ -70,6 +70,19 @@ function validatePlaceholders(value, os, label) { } } +function validatePortablePath(value, os, label) { + // Personal home locations cannot be shared between Sunshine hosts. + const normalized = value.replace(/\\/g, '/'); + const literalHome = /(?:^|[\s"'=])(?:~(?:\/|$)|[a-z]:\/(?:users|documents and settings)\/[^/\s"']+|\/(?:home|users)\/[^/\s"']+|%userprofile%|%homepath%|\$(?:home|\{home\}|\(home\)))/i; + if (literalHome.test(normalized)) { + throw new PresetError(`${label} contains a literal home directory; use {{HOME}}`); + } + if (os === 'Windows' && + /(?:^|[\/\s"'=])(?:con|prn|aux|nul|com[1-9\u00B9\u00B2\u00B3]|lpt[1-9\u00B9\u00B2\u00B3])(?:\.[^/\s"']*)?(?=$|[\/\s"'])/i.test(normalized)) { + throw new PresetError(`${label} contains a reserved Windows device name`); + } +} + function gameIdentity(values) { const gameUrl = field(values, 'gameUrl', { required: true, limit: 300, singleLine: true }); let url; @@ -97,35 +110,27 @@ function appIdentity(values) { httpsUrl(appUrl, 'Official app URL'); const appImageUrl = field(values, 'appImageUrl', { limit: 500, singleLine: true }); if (appImageUrl) httpsUrl(appImageUrl, 'App image URL'); + const appId = slug(appName); + if (/^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])$/.test(appId)) { + throw new PresetError('App name resolves to a reserved Windows file name'); + } return { - gameId: null, gameSlug: null, appId: slug(appName), appName, appUrl, appImageUrl + gameId: null, gameSlug: null, appId, appName, appUrl, appImageUrl }; } -function validateSteamUri(command, os) { - const uri = String.raw`steam://(?:rungameid/\d+|open/bigpicture)`; - let pattern = `^${uri}$`; - if (os === 'Linux') pattern = `^setsid steam ${uri}$`; - else if (os === 'macOS') pattern = `^open ${uri}$`; - if (!new RegExp(pattern, 'i').test(command)) { - throw new PresetError(`Steam URI must use Sunshine's ${os} command form`); - } -} - function validateLaunchCommand(command, os, method) { const steamUri = /steam:\/\//i.test(command); const epicUri = /com\.epicgames\.launcher:\/\//i.test(command); - if (steamUri && method !== 'Steam') { - throw new PresetError('Steam URI requires the Steam launch method'); + if (steamUri) { + throw new PresetError('Steam URI needs a detached command; use a Steam executable command instead'); } if (epicUri && method !== 'Epic Games') { throw new PresetError('Epic Games URI requires the Epic Games launch method'); } - if (steamUri) validateSteamUri(command, os); if (epicUri && (os !== 'Windows' || !/^com\.epicgames\.launcher:\/\/apps\/[^\s?]+(?:\?[^\s]+)?$/i.test(command))) { throw new PresetError('Epic Games launcher URI is supported for Windows only'); } - return steamUri ? 'detached' : 'cmd'; } function replacementFields(values) { @@ -141,21 +146,39 @@ function replacementFields(values) { function validateFields(values, kind) { if (kind !== 'game' && kind !== 'app') throw new PresetError('Exactly one request type is required'); const os = field(values, 'os', { required: true, limit: 20, singleLine: true }); - const method = field(values, 'method', { required: true, limit: 30, singleLine: true }); - if (!['Windows', 'Linux', 'macOS'].includes(os) || - !['Native', 'Steam', 'Epic Games', 'GOG', 'Emulator', 'Other'].includes(method)) { - throw new PresetError('Choose a supported host OS and launch method'); + if (!['Windows', 'Linux', 'macOS'].includes(os)) { + throw new PresetError('Choose a supported host OS'); + } + const suppliedMethod = field(values, 'method', { limit: 30, singleLine: true }); + const method = kind === 'app' ? 'Native' : suppliedMethod; + if (kind === 'app' && suppliedMethod) { + throw new PresetError('App requests do not have a launch method'); + } + if (kind === 'game' && + !['Native', 'Steam', 'Epic Games', 'GOG', 'Microsoft Store', 'Emulator'].includes(method)) { + throw new PresetError('Choose a supported game launch method'); + } + if (method === 'Microsoft Store' && os !== 'Windows') { + throw new PresetError('Microsoft Store is available on Windows only'); + } + const variantName = field(values, 'variantName', { limit: 100, singleLine: true }); + if (variantName && method !== 'Emulator') { + throw new PresetError('An emulator variant name requires the Emulator launch method'); } - const presetName = field(values, 'presetName', { required: true, limit: 100, singleLine: true }); const identity = kind === 'game' ? gameIdentity(values) : appIdentity(values); const command = field(values, 'command', { required: true, singleLine: true }); const workingDir = field(values, 'workingDir', { limit: 512, singleLine: true }); validatePlaceholders(command, os, 'Launch command'); validatePlaceholders(workingDir, os, 'Working directory'); - const commandMode = validateLaunchCommand(command, os, method); + if (method !== 'Emulator' && (command.includes('{{ROM_PATH}}') || workingDir.includes('{{ROM_PATH}}'))) { + throw new PresetError('{{ROM_PATH}} requires the Emulator launch method'); + } + validatePortablePath(command, os, 'Launch command'); + validatePortablePath(workingDir, os, 'Working directory'); + validateLaunchCommand(command, os, method); return { - kind, ...identity, presetName, ...replacementFields(values), - os, method: slug(method), command, commandMode, + kind, ...identity, variantName: variantName || null, ...replacementFields(values), + os, method: slug(method), command, workingDir: workingDir || null, notes: field(values, 'notes', { limit: 2000 }) || null }; diff --git a/tests/presets.test.js b/tests/presets.test.js index e3fd47ca35..df33aec8d2 100644 --- a/tests/presets.test.js +++ b/tests/presets.test.js @@ -14,7 +14,7 @@ const { filterItems, sunshineSnippet, normalizeBasePath } = require('../gh-pages const credentials = { clientId: 'client-id', clientSecret: 'client-secret' }; const gameValues = { gameUrl: 'https://www.igdb.com/games/one-tap-hero', os: 'Windows', method: 'Emulator', - presetName: 'RetroArch Snes9x', command: 'retroarch -L snes9x "{{ROM_PATH}}"', + variantName: 'RetroArch Snes9x', command: 'retroarch -L snes9x "{{ROM_PATH}}"', workingDir: '{{HOME}}', notes: 'Install the core first.' }; @@ -43,7 +43,7 @@ function formBody(values) { const labels = { gameUrl: 'IGDB game URL', appName: 'App name', appUrl: 'Official app URL', appImageUrl: 'App image URL', os: 'Host operating system', method: 'Launch method', - presetName: 'Preset name', command: 'Command', workingDir: 'Working directory', + variantName: 'Emulator variant name', command: 'Command', workingDir: 'Working directory', notes: 'Notes', replacementIssue: 'Preset to replace (issue number)', replacementReason: 'Replacement reason' }; @@ -59,11 +59,22 @@ test('game submission needs no ID and validates path placeholders', () => { const preset = validateFields(gameValues, 'game'); assert.equal(preset.gameId, null); assert.equal(preset.gameSlug, 'one-tap-hero'); - assert.equal(preset.commandMode, 'cmd'); + assert.equal(preset.variantName, 'RetroArch Snes9x'); assert.equal(validateFields({ ...gameValues, command: '{{PROGRAM_FILES}}\\Game\\game.exe' }, 'game').os, 'Windows'); assert.throws(() => validateFields({ ...gameValues, os: 'Linux', command: '{{PROGRAM_FILES}}/game' }, 'game'), /Windows only/); assert.throws(() => validateFields({ ...gameValues, command: '{{EMULATOR_PATH}} -L core' }, 'game'), /unsupported placeholder/); assert.throws(() => validateFields({ ...gameValues, command: '{{rom_path}}' }, 'game'), /malformed path placeholder/); + for (const command of ['C:\\Users\\Alice\\Game\\game.exe', '%USERPROFILE%\\Game\\game.exe']) { + assert.throws(() => validateFields({ ...gameValues, command }, 'game'), /literal home directory/); + } + for (const workingDir of ['C:\\Games\\CON.txt', 'C:\\Games\\COM1\\Game', 'C:\\Games\\COM\u00B9']) { + assert.throws(() => validateFields({ ...gameValues, workingDir }, 'game'), /reserved Windows device name/); + } + assert.throws(() => validateFields({ ...gameValues, os: 'Linux', workingDir: '/home/alice/Games' }, 'game'), + /literal home directory/); + assert.throws(() => validateFields({ ...gameValues, os: 'macOS', workingDir: '/Users/alice/Games' }, 'game'), + /literal home directory/); + assert.throws(() => validateFields({ ...gameValues, workingDir: '~/Games' }, 'game'), /literal home directory/); }); test('IGDB slug resolves to ID and GameDB verifies it', async () => { @@ -90,26 +101,33 @@ test('IGDB resolution rejects missing credentials and missing or ambiguous recor test('apps use an HTTPS image URL and have separate review identity', () => { const values = { appName: 'My App', appUrl: 'https://example.org/app', appImageUrl: 'https://example.org/icon.png', - os: 'macOS', method: 'Native', presetName: 'Installed', command: '{{HOME}}/Applications/my-app' }; + os: 'macOS', command: '{{HOME}}/Applications/my-app' }; const app = validateFields(values, 'app'); assert.equal(app.appId, 'my-app'); assert.equal(app.appImageUrl, values.appImageUrl); assert.throws(() => validateFields({ ...values, appUrl: 'http://example.org' }, 'app'), /HTTPS/); assert.throws(() => validateFields({ ...values, appImageUrl: 'C:\\icon.png' }, 'app'), /image URL/); + assert.throws(() => validateFields({ ...values, appName: 'CON' }, 'app'), /reserved Windows file name/); assert.throws(() => validateFields({ ...values, command: '{{APP_PATH}}' }, 'app'), /unsupported placeholder/); + assert.equal(app.method, 'native'); + assert.throws(() => validateFields({ ...values, method: 'Steam' }, 'app'), /do not have a launch method/); + assert.throws(() => validateFields({ ...values, command: '{{ROM_PATH}}' }, 'app'), /ROM_PATH.*Emulator/); }); -test('one command classifies Steam and Epic URI forms by host OS', () => { - const base = { ...gameValues, method: 'Steam', presetName: 'Steam URI' }; - assert.equal(validateFields({ ...base, command: 'steam://rungameid/464920' }, 'game').commandMode, 'detached'); - assert.throws(() => validateFields({ ...base, os: 'Linux', command: 'steam://rungameid/464920' }, 'game'), /Linux/); - assert.equal(validateFields({ ...base, os: 'Linux', command: 'setsid steam steam://rungameid/464920' }, 'game').commandMode, 'detached'); - assert.equal(validateFields({ ...base, os: 'macOS', command: 'open steam://rungameid/464920' }, 'game').commandMode, 'detached'); - assert.throws(() => validateFields({ ...base, method: 'Native', command: 'steam://rungameid/464920' }, 'game'), /Steam launch method/); - assert.equal(validateFields({ ...base, command: 'steam -applaunch 464920' }, 'game').commandMode, 'cmd'); +test('game methods validate host OS and command forms', () => { + const base = { ...gameValues, variantName: '' }; + assert.throws(() => validateFields({ ...base, method: 'Steam', command: 'steam://rungameid/464920' }, 'game'), + /detached command/); + assert.equal(validateFields({ ...base, method: 'Steam', command: 'steam -applaunch 464920' }, 'game').method, 'steam'); const epic = { ...base, method: 'Epic Games', command: 'com.epicgames.launcher://apps/abc?action=launch&silent=true' }; - assert.equal(validateFields(epic, 'game').commandMode, 'cmd'); + assert.equal(validateFields(epic, 'game').method, 'epic-games'); assert.throws(() => validateFields({ ...epic, os: 'Linux' }, 'game'), /Windows only/); + assert.equal(validateFields({ ...base, method: 'Microsoft Store', command: 'explorer.exe shell:AppsFolder\\Game!App' }, 'game').method, + 'microsoft-store'); + assert.throws(() => validateFields({ ...base, os: 'Linux', method: 'Microsoft Store' }, 'game'), /Windows only/); + assert.throws(() => validateFields({ ...base, method: 'Other' }, 'game'), /supported game launch method/); + assert.throws(() => validateFields({ ...base, method: 'Native' }, 'game'), /ROM_PATH.*Emulator/); + assert.throws(() => validateFields({ ...base, method: 'Native', variantName: 'Alternate' }, 'game'), /Emulator/); }); test('approved presets get issue IDs and replacements preserve identity', async t => { @@ -118,7 +136,9 @@ test('approved presets get issue IDs and replacements preserve identity', async const first = await validateGameDb(validateFields(gameValues, 'game'), mockApis, credentials); assert.equal(mergePreset(root, first, { issueNumber: 10, approvedBy: 'maintainer' }, { write: true }).id, 'issue-10'); assert.throws(() => mergePreset(root, first, { issueNumber: 11, approvedBy: 'maintainer' }), /already exists/); - const second = await validateGameDb(validateFields({ ...gameValues, presetName: 'RetroArch Bsnes' }, 'game'), mockApis, credentials); + assert.throws(() => mergePreset(root, { ...first, variantName: 'retroarch snes9x' }, + { issueNumber: 11, approvedBy: 'maintainer' }), /already exists/); + const second = await validateGameDb(validateFields({ ...gameValues, variantName: 'RetroArch Bsnes' }, 'game'), mockApis, credentials); 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); @@ -126,6 +146,7 @@ test('approved presets get issue IDs and replacements preserve identity', async const record = JSON.parse(fs.readFileSync(path.join(root, 'games/100245.json'))); 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 (Windows, 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.throws(() => validateFields({ ...gameValues, replacementIssue: '10' }, 'game'), /replacement needs both/); @@ -133,14 +154,15 @@ test('approved presets get issue IDs and replacements preserve identity', async { issueNumber: 14, approvedBy: 'maintainer' }), /No preset/); }); -test('Steam URI publishes as Sunshine detached command without an image path', async t => { +test('Steam executable publishes as a Sunshine command without an image path', 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', - presetName: 'Steam URI', command: 'steam://rungameid/464920' }, 'game'), mockApis, credentials); + variantName: '', command: 'steam -applaunch 464920' }, 'game'), mockApis, credentials); const { record } = mergePreset(root, steam, { issueNumber: 30, approvedBy: 'reviewer' }, { write: true }); - assert.deepEqual(record.presets[0].sunshine.detached, ['steam://rungameid/464920']); - assert.ok(!Object.hasOwn(record.presets[0].sunshine, 'cmd')); + assert.equal(record.presets[0].sunshine.cmd, 'steam -applaunch 464920'); + assert.equal(record.presets[0].sunshine.name, 'One Tap Hero (Windows, Steam)'); + assert.ok(!Object.hasOwn(record.presets[0].sunshine, 'detached')); assert.ok(!Object.hasOwn(record.presets[0].sunshine, 'image-path')); }); @@ -153,12 +175,13 @@ test('issue processing and site build publish both game and app JSON', async t = database, { approve: true, actor: 'reviewer', fetcher: mockApis, credentials }); await processIssue({ issue: { number: 22, labels: [{ name: 'request-app-preset' }], body: formBody({ appName: 'App One', appUrl: 'https://example.org', appImageUrl: 'https://example.org/icon.png', - os: 'Linux', method: 'Native', presetName: 'Installed', command: '{{HOME}}/app-one' + os: 'Linux', command: '{{HOME}}/app-one' }) } }, database, { approve: true, actor: 'reviewer' }); const index = buildSite(database, path.join(__dirname, '..', 'gh-pages-template'), output); assert.equal(index.games[0].preset_count, 1); 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.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'))); From c811f69f9af25c50e0cf9d4418f7117ff4735a5c Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Wed, 23 Sep 2026 14:52:40 -0400 Subject: [PATCH 06/12] Simplify portable path validation for Sonar --- .readthedocs.yaml | 2 +- src/presets.js | 9 +++++---- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/.readthedocs.yaml b/.readthedocs.yaml index 89e2d2c8f2..e57188179f 100644 --- a/.readthedocs.yaml +++ b/.readthedocs.yaml @@ -22,4 +22,4 @@ build: chmod +x "./tmp/readthedocs_build.sh" build: html: - - ./tmp/readthedocs_build.sh + - GITHUB_WORKFLOW=build SITE_ARTIFACT=site-source EXTRACT_ARCHIVE=build.zip ./tmp/readthedocs_build.sh diff --git a/src/presets.js b/src/presets.js index 20a26664d2..ca085fafc8 100644 --- a/src/presets.js +++ b/src/presets.js @@ -72,13 +72,14 @@ function validatePlaceholders(value, os, label) { function validatePortablePath(value, os, label) { // Personal home locations cannot be shared between Sunshine hosts. - const normalized = value.replace(/\\/g, '/'); - const literalHome = /(?:^|[\s"'=])(?:~(?:\/|$)|[a-z]:\/(?:users|documents and settings)\/[^/\s"']+|\/(?:home|users)\/[^/\s"']+|%userprofile%|%homepath%|\$(?:home|\{home\}|\(home\)))/i; - if (literalHome.test(normalized)) { + const normalized = value.replaceAll('\\', '/'); + const homeRoot = /(?:^|[\s"'=])(?:~\/|[a-z]:\/(?:users|documents and settings)\/|\/(?:home|users)\/)/i; + const homeVariable = /%userprofile%|%homepath%|\$home|\$\{home\}|\$\(home\)/i; + if (homeRoot.test(normalized) || homeVariable.test(normalized)) { throw new PresetError(`${label} contains a literal home directory; use {{HOME}}`); } if (os === 'Windows' && - /(?:^|[\/\s"'=])(?:con|prn|aux|nul|com[1-9\u00B9\u00B2\u00B3]|lpt[1-9\u00B9\u00B2\u00B3])(?:\.[^/\s"']*)?(?=$|[\/\s"'])/i.test(normalized)) { + /(?:^|[/\s"'=])(?:con|prn|aux|nul|com[1-9\u00B9\u00B2\u00B3]|lpt[1-9\u00B9\u00B2\u00B3])(?:\.[^/\s"']*)?(?=$|[/\s"'])/i.test(normalized)) { throw new PresetError(`${label} contains a reserved Windows device name`); } } From f283ece88b1ed67de99c94ce380888178bb1ebc2 Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Wed, 23 Sep 2026 15:00:07 -0400 Subject: [PATCH 07/12] Generate ThemerrDB-style request titles and fix preview artifact --- .github/ISSUE_TEMPLATE/app-preset.yml | 2 +- .github/ISSUE_TEMPLATE/game-preset.yml | 2 +- .github/workflows/check-preset.yml | 19 ++++++++++++++++++- .gitignore | 2 ++ .readthedocs.yaml | 2 +- README.md | 4 ++-- docs/developerSetup.md | 2 +- src/issue.js | 8 +++++++- tests/presets.test.js | 9 ++++++--- 9 files changed, 39 insertions(+), 11 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/app-preset.yml b/.github/ISSUE_TEMPLATE/app-preset.yml index ffe9811795..2abf89a986 100644 --- a/.github/ISSUE_TEMPLATE/app-preset.yml +++ b/.github/ISSUE_TEMPLATE/app-preset.yml @@ -1,7 +1,7 @@ --- name: App launch preset description: Request one Sunshine launch option for a non-game app. -title: '[APP PRESET]: ' +title: '[APP]: ' labels: - request-app-preset body: diff --git a/.github/ISSUE_TEMPLATE/game-preset.yml b/.github/ISSUE_TEMPLATE/game-preset.yml index 1e9a0f4c9a..58321c0ae5 100644 --- a/.github/ISSUE_TEMPLATE/game-preset.yml +++ b/.github/ISSUE_TEMPLATE/game-preset.yml @@ -1,7 +1,7 @@ --- name: Game launch preset description: Request one Sunshine launch option for a GameDB game. -title: '[GAME PRESET]: ' +title: '[GAME]: ' labels: - request-game-preset body: diff --git a/.github/workflows/check-preset.yml b/.github/workflows/check-preset.yml index 550e06698d..11758bcb7d 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 title.md - name: Post result if: always() && hashFiles('report.md') != '' @@ -62,6 +64,21 @@ jobs: ISSUE_NUMBER: ${{ github.event.issue.number }} run: gh issue comment "$ISSUE_NUMBER" --body-file report.md + - name: Name request + if: steps.validate.outcome == 'success' && hashFiles('title.md') != '' + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const fs = require('node:fs') + const title = fs.readFileSync('title.md', 'utf8').trim() + if (title !== context.payload.issue.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/.gitignore b/.gitignore index c18fedade2..52b11a9714 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,5 @@ node_modules/ coverage/ junit.xml lcov.info +report.md +title.md diff --git a/.readthedocs.yaml b/.readthedocs.yaml index e57188179f..596738f4f2 100644 --- a/.readthedocs.yaml +++ b/.readthedocs.yaml @@ -22,4 +22,4 @@ build: chmod +x "./tmp/readthedocs_build.sh" build: html: - - GITHUB_WORKFLOW=build SITE_ARTIFACT=site-source EXTRACT_ARCHIVE=build.zip ./tmp/readthedocs_build.sh + - GITHUB_WORKFLOW=build SITE_ARTIFACT=site-source.zip EXTRACT_ARCHIVE=build.zip ./tmp/readthedocs_build.sh diff --git a/README.md b/README.md index d565c9dad0..28e492ff28 100644 --- a/README.md +++ b/README.md @@ -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 [open a game preset request](https://github.com/LizardByte/PresetDB/issues/new?template=game-preset.yml). 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. Choose the host OS and, for a game, its launch method. Add an emulator variant such as `RetroArch Snes9x` only when needed to distinguish emulator presets. The bot generates the preset name. +2. Fill in **one** launch option. Choose the host OS and, for a game, its launch method. Add an emulator variant such as `RetroArch Snes9x` only when needed to distinguish emulator presets. The bot generates the preset name and updates the issue title, as ThemerrDB does. 3. Fill in one **Command**. The bot publishes it as Sunshine `cmd`. Sunshine's [app examples](https://github.com/LizardByte/Sunshine/blob/master/docs/app_examples.md) use detached commands for Steam launcher URIs, so use a Steam executable command instead. Commands are stored as text and never executed by validation or by the website. 4. To replace a preset, provide its issue number and explain the change. The bot preserves the original preset ID. @@ -69,7 +69,7 @@ The Pages workflow creates an archive from the database and site template, then The empty `database` branch is initialized with `database/apps` and `database/games`. Before enabling automation, create the `gh-pages` branch from `master`, set Pages to deploy from `gh-pages`, and 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. Create the `request-game-preset`, `request-app-preset`, `approve-queue`, and `approve-preset` labels. Set the GitHub repository description to the heading description above. The `database` branch retains the `database/` directory; approved records are written there. -Connect this repository to Read the Docs and enable pull request preview builds. Set `GITHUB_WORKFLOW=build`, `SITE_ARTIFACT=site-source`, and `EXTRACT_ARCHIVE=build.zip` in the Read the Docs project environment. The shared script downloads the Pages build artifact and extracts the nested site archive. See [developer setup](docs/developerSetup.md) for local commands. +Connect this repository to Read the Docs and enable pull request preview builds. Set `GITHUB_WORKFLOW=build`, `SITE_ARTIFACT=site-source.zip`, and `EXTRACT_ARCHIVE=build.zip` in the Read the Docs project environment. The shared script downloads the Pages build artifact and extracts the nested site archive. See [developer setup](docs/developerSetup.md) for local commands. ## Local checks diff --git a/docs/developerSetup.md b/docs/developerSetup.md index 78205ad036..99f2d0fba0 100644 --- a/docs/developerSetup.md +++ b/docs/developerSetup.md @@ -20,7 +20,7 @@ Read the Docs pull request previews use `.readthedocs.yaml` and the shared `read ```text GITHUB_WORKFLOW=build -SITE_ARTIFACT=site-source +SITE_ARTIFACT=site-source.zip EXTRACT_ARCHIVE=build.zip ``` diff --git a/src/issue.js b/src/issue.js index 80cfaff31e..aa1e68fdc0 100644 --- a/src/issue.js +++ b/src/issue.js @@ -23,7 +23,9 @@ async function processIssue(event, database, { approve = false, actor = '', fetc issueNumber: issue.number, approvedBy: actor, authorId: issue.user?.id ?? null, authorLogin: issue.user?.login ?? null }, { write: approve }); - return { ...merged, preset, kind }; + const entry = merged.record.presets.find(item => item.id === merged.id); + const title = `[${kind.toUpperCase()}]: ${entry.name}`; + return { ...merged, preset, kind, title }; } function argsToObject(args) { @@ -54,6 +56,10 @@ async function main(args = process.argv.slice(2)) { `- Host: ${result.preset.os}\n` + methodLine + `- Preset ID: \`${result.id}\`\n` + `- Status: ${options.mode === 'approve' ? 'approved and saved' : 'awaiting maintainer review'}\n\n` + `Sunshine application preview:\n\n\`\`\`json\n${JSON.stringify(entry.sunshine, null, 2)}\n\`\`\`\n`; + if (options.title) { + fs.mkdirSync(path.dirname(options.title), { recursive: true }); + fs.writeFileSync(options.title, `${result.title}\n`); + } success = true; } catch (error) { message = `Preset validation failed: ${String(error.message).replace(/[\r\n]+/g, ' ').slice(0, 500)}\n`; diff --git a/tests/presets.test.js b/tests/presets.test.js index df33aec8d2..777053d812 100644 --- a/tests/presets.test.js +++ b/tests/presets.test.js @@ -171,12 +171,15 @@ test('issue processing and site build publish both game and app JSON', async t = t.after(() => fs.rmSync(root, { recursive: true, force: true })); const database = path.join(root, 'database'); const output = path.join(root, 'site'); - await processIssue({ issue: { number: 21, labels: [{ name: 'request-game-preset' }], body: formBody(gameValues) } }, - database, { approve: true, actor: 'reviewer', fetcher: mockApis, credentials }); - await processIssue({ issue: { number: 22, labels: [{ name: 'request-app-preset' }], body: formBody({ + const gameRequest = await processIssue({ + issue: { number: 21, labels: [{ name: 'request-game-preset' }], body: formBody(gameValues) } + }, database, { approve: true, actor: 'reviewer', fetcher: mockApis, credentials }); + assert.equal(gameRequest.title, '[GAME]: One Tap Hero (Windows, Emulator: RetroArch Snes9x)'); + const appRequest = await processIssue({ issue: { number: 22, labels: [{ name: 'request-app-preset' }], body: formBody({ appName: 'App One', appUrl: 'https://example.org', appImageUrl: 'https://example.org/icon.png', os: 'Linux', command: '{{HOME}}/app-one' }) } }, database, { approve: true, actor: 'reviewer' }); + assert.equal(appRequest.title, '[APP]: App One (Linux)'); const index = buildSite(database, path.join(__dirname, '..', 'gh-pages-template'), output); assert.equal(index.games[0].preset_count, 1); assert.equal(index.apps[0].id, 'app-one'); From c144c43501257429c9c82da7c686742789d7bf6c Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Wed, 23 Sep 2026 15:11:14 -0400 Subject: [PATCH 08/12] Align Read the Docs previews with ThemerrDB --- .github/ISSUE_TEMPLATE/app-preset.yml | 2 +- .github/ISSUE_TEMPLATE/game-preset.yml | 2 +- .github/workflows/build-pages.yml | 5 ++--- .github/workflows/check-preset.yml | 19 +------------------ .gitignore | 2 -- .readthedocs.yaml | 5 ++++- README.md | 4 ++-- docs/developerSetup.md | 6 +++--- src/issue.js | 8 +------- tests/presets.test.js | 9 +++------ 10 files changed, 18 insertions(+), 44 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/app-preset.yml b/.github/ISSUE_TEMPLATE/app-preset.yml index 2abf89a986..ffe9811795 100644 --- a/.github/ISSUE_TEMPLATE/app-preset.yml +++ b/.github/ISSUE_TEMPLATE/app-preset.yml @@ -1,7 +1,7 @@ --- name: App launch preset description: Request one Sunshine launch option for a non-game app. -title: '[APP]: ' +title: '[APP PRESET]: ' labels: - request-app-preset body: diff --git a/.github/ISSUE_TEMPLATE/game-preset.yml b/.github/ISSUE_TEMPLATE/game-preset.yml index 58321c0ae5..1e9a0f4c9a 100644 --- a/.github/ISSUE_TEMPLATE/game-preset.yml +++ b/.github/ISSUE_TEMPLATE/game-preset.yml @@ -1,7 +1,7 @@ --- name: Game launch preset description: Request one Sunshine launch option for a GameDB game. -title: '[GAME]: ' +title: '[GAME PRESET]: ' labels: - request-game-preset body: diff --git a/.github/workflows/build-pages.yml b/.github/workflows/build-pages.yml index 2918cc807c..757cbabde7 100644 --- a/.github/workflows/build-pages.yml +++ b/.github/workflows/build-pages.yml @@ -47,14 +47,13 @@ jobs: - name: Upload site artifact uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: - name: site-source + name: update path: build.zip if-no-files-found: error retention-days: 1 call-jekyll-build: needs: build - if: github.event_name != 'pull_request' permissions: contents: read uses: LizardByte/LizardByte.github.io/.github/workflows/jekyll-build.yml@master @@ -65,5 +64,5 @@ jobs: clean_gh_pages: true extract_archive: build.zip gh_bot_name: ${{ vars.GH_BOT_NAME }} - site_artifact: site-source + site_artifact: update target_branch: gh-pages diff --git a/.github/workflows/check-preset.yml b/.github/workflows/check-preset.yml index 11758bcb7d..550e06698d 100644 --- a/.github/workflows/check-preset.yml +++ b/.github/workflows/check-preset.yml @@ -53,9 +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 --title title.md + run: node src/issue.js --event "$EVENT_FILE" --database database-branch/database --mode check --report report.md - name: Post result if: always() && hashFiles('report.md') != '' @@ -64,21 +62,6 @@ jobs: ISSUE_NUMBER: ${{ github.event.issue.number }} run: gh issue comment "$ISSUE_NUMBER" --body-file report.md - - name: Name request - if: steps.validate.outcome == 'success' && hashFiles('title.md') != '' - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 - with: - script: | - const fs = require('node:fs') - const title = fs.readFileSync('title.md', 'utf8').trim() - if (title !== context.payload.issue.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/.gitignore b/.gitignore index 52b11a9714..c18fedade2 100644 --- a/.gitignore +++ b/.gitignore @@ -7,5 +7,3 @@ node_modules/ coverage/ junit.xml lcov.info -report.md -title.md diff --git a/.readthedocs.yaml b/.readthedocs.yaml index 596738f4f2..587e03ee99 100644 --- a/.readthedocs.yaml +++ b/.readthedocs.yaml @@ -22,4 +22,7 @@ build: chmod +x "./tmp/readthedocs_build.sh" build: html: - - GITHUB_WORKFLOW=build SITE_ARTIFACT=site-source.zip EXTRACT_ARCHIVE=build.zip ./tmp/readthedocs_build.sh + - | + GITHUB_WORKFLOW="call-jekyll-build / Build Jekyll" \ + SITE_ARTIFACT=update.zip EXTRACT_ARCHIVE=build.zip \ + ./tmp/readthedocs_build.sh diff --git a/README.md b/README.md index 28e492ff28..9700a53d3b 100644 --- a/README.md +++ b/README.md @@ -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 [open a game preset request](https://github.com/LizardByte/PresetDB/issues/new?template=game-preset.yml). 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. Choose the host OS and, for a game, its launch method. Add an emulator variant such as `RetroArch Snes9x` only when needed to distinguish emulator presets. The bot generates the preset name and updates the issue title, as ThemerrDB does. +2. Fill in **one** launch option. Choose the host OS and, for a game, its launch method. Add an emulator variant such as `RetroArch Snes9x` only when needed to distinguish emulator presets. The bot generates the preset name. 3. Fill in one **Command**. The bot publishes it as Sunshine `cmd`. Sunshine's [app examples](https://github.com/LizardByte/Sunshine/blob/master/docs/app_examples.md) use detached commands for Steam launcher URIs, so use a Steam executable command instead. Commands are stored as text and never executed by validation or by the website. 4. To replace a preset, provide its issue number and explain the change. The bot preserves the original preset ID. @@ -69,7 +69,7 @@ The Pages workflow creates an archive from the database and site template, then The empty `database` branch is initialized with `database/apps` and `database/games`. Before enabling automation, create the `gh-pages` branch from `master`, set Pages to deploy from `gh-pages`, and 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. Create the `request-game-preset`, `request-app-preset`, `approve-queue`, and `approve-preset` labels. Set the GitHub repository description to the heading description above. The `database` branch retains the `database/` directory; approved records are written there. -Connect this repository to Read the Docs and enable pull request preview builds. Set `GITHUB_WORKFLOW=build`, `SITE_ARTIFACT=site-source.zip`, and `EXTRACT_ARCHIVE=build.zip` in the Read the Docs project environment. The shared script downloads the Pages build artifact and extracts the nested site archive. See [developer setup](docs/developerSetup.md) for local commands. +Connect this repository to Read the Docs and enable pull request preview builds. Set `GITHUB_WORKFLOW=call-jekyll-build / Build Jekyll`, `SITE_ARTIFACT=update.zip`, and `EXTRACT_ARCHIVE=build.zip` in the Read the Docs project environment. The shared script downloads the Pages build artifact and extracts the nested site archive. See [developer setup](docs/developerSetup.md) for local commands. ## Local checks diff --git a/docs/developerSetup.md b/docs/developerSetup.md index 99f2d0fba0..6659258375 100644 --- a/docs/developerSetup.md +++ b/docs/developerSetup.md @@ -19,9 +19,9 @@ The site builder writes `index.json`, `stats.json`, two SVG contribution charts, Read the Docs pull request previews use `.readthedocs.yaml` and the shared `readthedocs_build.sh` script. Connect the repository to Read the Docs, enable pull request builds, and set these project environment variables: ```text -GITHUB_WORKFLOW=build -SITE_ARTIFACT=site-source.zip +GITHUB_WORKFLOW=call-jekyll-build / Build Jekyll +SITE_ARTIFACT=update.zip EXTRACT_ARCHIVE=build.zip ``` -The `Build Pages` workflow publishes a check run named `build`, uploads a `site-source` 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. +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/src/issue.js b/src/issue.js index aa1e68fdc0..80cfaff31e 100644 --- a/src/issue.js +++ b/src/issue.js @@ -23,9 +23,7 @@ async function processIssue(event, database, { approve = false, actor = '', fetc issueNumber: issue.number, approvedBy: actor, authorId: issue.user?.id ?? null, authorLogin: issue.user?.login ?? null }, { write: approve }); - const entry = merged.record.presets.find(item => item.id === merged.id); - const title = `[${kind.toUpperCase()}]: ${entry.name}`; - return { ...merged, preset, kind, title }; + return { ...merged, preset, kind }; } function argsToObject(args) { @@ -56,10 +54,6 @@ async function main(args = process.argv.slice(2)) { `- Host: ${result.preset.os}\n` + methodLine + `- Preset ID: \`${result.id}\`\n` + `- Status: ${options.mode === 'approve' ? 'approved and saved' : 'awaiting maintainer review'}\n\n` + `Sunshine application preview:\n\n\`\`\`json\n${JSON.stringify(entry.sunshine, null, 2)}\n\`\`\`\n`; - if (options.title) { - fs.mkdirSync(path.dirname(options.title), { recursive: true }); - fs.writeFileSync(options.title, `${result.title}\n`); - } success = true; } catch (error) { message = `Preset validation failed: ${String(error.message).replace(/[\r\n]+/g, ' ').slice(0, 500)}\n`; diff --git a/tests/presets.test.js b/tests/presets.test.js index 777053d812..df33aec8d2 100644 --- a/tests/presets.test.js +++ b/tests/presets.test.js @@ -171,15 +171,12 @@ test('issue processing and site build publish both game and app JSON', async t = t.after(() => fs.rmSync(root, { recursive: true, force: true })); const database = path.join(root, 'database'); const output = path.join(root, 'site'); - const gameRequest = await processIssue({ - issue: { number: 21, labels: [{ name: 'request-game-preset' }], body: formBody(gameValues) } - }, database, { approve: true, actor: 'reviewer', fetcher: mockApis, credentials }); - assert.equal(gameRequest.title, '[GAME]: One Tap Hero (Windows, Emulator: RetroArch Snes9x)'); - const appRequest = await processIssue({ issue: { number: 22, labels: [{ name: 'request-app-preset' }], body: formBody({ + await processIssue({ issue: { number: 21, labels: [{ name: 'request-game-preset' }], body: formBody(gameValues) } }, + database, { approve: true, actor: 'reviewer', fetcher: mockApis, credentials }); + await processIssue({ issue: { number: 22, labels: [{ name: 'request-app-preset' }], body: formBody({ appName: 'App One', appUrl: 'https://example.org', appImageUrl: 'https://example.org/icon.png', os: 'Linux', command: '{{HOME}}/app-one' }) } }, database, { approve: true, actor: 'reviewer' }); - assert.equal(appRequest.title, '[APP]: App One (Linux)'); const index = buildSite(database, path.join(__dirname, '..', 'gh-pages-template'), output); assert.equal(index.games[0].preset_count, 1); assert.equal(index.apps[0].id, 'app-one'); From 6005fb57ebf77966a4ad92bdb18090bdb9a2e67b Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Wed, 23 Sep 2026 15:17:31 -0400 Subject: [PATCH 09/12] Document initialized gh-pages branch --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 9700a53d3b..dc794bc2d2 100644 --- a/README.md +++ b/README.md @@ -67,7 +67,7 @@ Game submissions resolve the submitted slug through [IGDB's authenticated API](h 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`. Before enabling automation, create the `gh-pages` branch from `master`, set Pages to deploy from `gh-pages`, and 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. Create the `request-game-preset`, `request-app-preset`, `approve-queue`, and `approve-preset` labels. Set the GitHub repository description to the heading description above. The `database` branch retains the `database/` directory; approved records are written there. +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. Create the `request-game-preset`, `request-app-preset`, `approve-queue`, and `approve-preset` labels. Set the GitHub repository description to the heading description above. The `database` branch retains the `database/` directory; approved records are written there. Connect this repository to Read the Docs and enable pull request preview builds. Set `GITHUB_WORKFLOW=call-jekyll-build / Build Jekyll`, `SITE_ARTIFACT=update.zip`, and `EXTRACT_ARCHIVE=build.zip` in the Read the Docs project environment. The shared script downloads the Pages build artifact and extracts the nested site archive. See [developer setup](docs/developerSetup.md) for local commands. From 216ce00028c326d54ccbe70a684eb95a426794ab Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Wed, 23 Sep 2026 16:36:02 -0400 Subject: [PATCH 10/12] Generate storefront commands from launch IDs --- .github/ISSUE_TEMPLATE/game-preset.yml | 22 +++++-- README.md | 6 +- docs/approverGuide.md | 2 +- docs/presetGuidelines.md | 6 +- src/database.js | 1 + src/presets.js | 56 ++++++++++++---- tests/presets.test.js | 88 +++++++++++++++++++++----- 7 files changed, 140 insertions(+), 41 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/game-preset.yml b/.github/ISSUE_TEMPLATE/game-preset.yml index 1e9a0f4c9a..c54d9200bf 100644 --- a/.github/ISSUE_TEMPLATE/game-preset.yml +++ b/.github/ISSUE_TEMPLATE/game-preset.yml @@ -9,6 +9,7 @@ body: attributes: value: | One issue represents one launch option. Provide the IGDB game URL; the bot resolves its ID and checks GameDB. + For Steam, Epic Games, or Microsoft Store, enter the matching launch ID; the bot creates the command. Add an emulator variant only when the operating system and launch method cannot distinguish the preset. - type: input id: game_url @@ -40,6 +41,17 @@ body: - Emulator validations: required: true + - type: input + id: launch_id + attributes: + label: Launch ID + description: | + Required for Steam, Epic Games, and Microsoft Store; leave blank for Native, GOG, or Emulator. + Steam: numeric app ID from a store URL, for example 464920. + Epic Games: three-part ID between /apps/ and ? in a launcher shortcut, + for example fn%3A4fe75bbc5a674f4f9b356b5c90567da5%3AFortnite. + Microsoft Store: installed app's AUMID from Get-StartApps, + for example Microsoft.WindowsCalculator_8wekyb3d8bbwe!App. A Store product ID will not launch it. - type: input id: variant_name attributes: @@ -50,16 +62,14 @@ body: attributes: label: Command description: | - Use {{ROM_PATH}} or {{HOME}} for variable paths. Windows also allows {{SYSTEM_DRIVE}}, {{PROGRAM_FILES}}, - and {{PROGRAM_FILES_X86}}. Use only host-independent paths; literal home directories are rejected. - Steam launcher URIs require detached commands; use a Steam executable command. - validations: - required: true + Required for Native, GOG, and Emulator. Leave blank for Steam, Epic Games, and Microsoft Store. + Use {{ROM_PATH}} or {{HOME}} for variable paths. Windows also allows {{SYSTEM_DRIVE}}, + {{PROGRAM_FILES}}, and {{PROGRAM_FILES_X86}}. Literal home directories are rejected. - type: input id: working_dir attributes: label: Working directory - description: Optional Sunshine working-dir value. + description: Optional Sunshine working-dir value for Native, GOG, and Emulator only. - type: textarea id: notes attributes: diff --git a/README.md b/README.md index dc794bc2d2..a9feb2b5ab 100644 --- a/README.md +++ b/README.md @@ -47,14 +47,14 @@ 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 [open a game preset request](https://github.com/LizardByte/PresetDB/issues/new?template=game-preset.yml). 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. Choose the host OS and, for a game, its launch method. Add an emulator variant such as `RetroArch Snes9x` only when needed to distinguish emulator presets. The bot generates the preset name. -3. Fill in one **Command**. The bot publishes it as Sunshine `cmd`. Sunshine's [app examples](https://github.com/LizardByte/Sunshine/blob/master/docs/app_examples.md) use detached commands for Steam launcher URIs, so use a Steam executable command instead. Commands are stored as text and never executed by validation or by the website. +3. For Steam, Epic Games, or Microsoft Store, enter one **Launch ID** in the shared field: a Steam app ID, Epic's three-part launch ID, or a Microsoft Store AUMID. The bot generates Sunshine `cmd` from that ID and the host OS. For Native, GOG, and Emulator, enter one **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. After validation, a listed trusted game contributor enters the approval queue automatically. Other requests wait for a listed approver or repository admin to comment `@LizardByte-bot approve`. App requests always require this separate review. The queue processes one approval at a time; see the [approver guide](docs/approverGuide.md) and [bot commands](docs/botCommands.md). -Supported path placeholders in 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. +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's 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 URIs are rejected because they require detached commands; Epic URI syntax is checked against Windows and the Epic Games method. Binary commands still need human review. +Game submissions resolve the submitted slug through [IGDB's 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 supports all three host OSes, Epic Games supports Windows and macOS, and Microsoft Store requires a Windows AUMID. GOG and other manual commands need human review. Launcher ID commands may outlive Sunshine's process tracking; the stream may need to be ended manually. ## Repository and deployment diff --git a/docs/approverGuide.md b/docs/approverGuide.md index d80bd280e6..2420537f4c 100644 --- a/docs/approverGuide.md +++ b/docs/approverGuide.md @@ -4,7 +4,7 @@ This guide is for listed approvers and repository administrators reviewing game 1. Open the oldest validated request in the [game queue](https://github.com/LizardByte/PresetDB/issues?q=is%3Aopen+label%3Arequest-game-preset) or [app queue](https://github.com/LizardByte/PresetDB/issues?q=is%3Aopen+label%3Arequest-app-preset). Check the latest validation comment after the last issue edit. 2. For games, check the IGDB URL and resolved GameDB record. For apps, inspect the official URL and optional image URL. App requests always need a separate review, including when a trusted contributor submits them. -3. Check the host OS, game launch method, command, working directory, and setup notes against the [preset guidelines](presetGuidelines.md) and [Sunshine examples](https://github.com/LizardByte/Sunshine/blob/master/docs/app_examples.md). Review literal commands carefully; automated validation does not run them. +3. Check the host OS, game launch method, store launch ID or manual command, working directory, and setup notes against the [preset guidelines](presetGuidelines.md) and [Sunshine examples](https://github.com/LizardByte/Sunshine/blob/master/docs/app_examples.md). Confirm the store ID belongs to the requested game. Automated validation checks ID syntax and never runs commands. 4. Search the published record for the same OS, method, and emulator variant. A replacement must identify an existing preset issue and explain the change. 5. Comment `@LizardByte-bot approve` to enter the approval queue. The bot adds `approve-queue` and starts `approve-preset` when the active slot is free. It revalidates against IGDB and GameDB before writing to the `database` branch. diff --git a/docs/presetGuidelines.md b/docs/presetGuidelines.md index 3955f0215e..27448972fb 100644 --- a/docs/presetGuidelines.md +++ b/docs/presetGuidelines.md @@ -6,7 +6,7 @@ One issue requests one Sunshine launch option for one host operating system. Sub Use the public IGDB game URL. The bot resolves its slug to a numeric IGDB ID and requires the same game and slug in GameDB. The catalog obtains the game cover from GameDB, so the form has no image field. -Choose the launch method that describes the command: Native, Steam, Epic Games, GOG, Microsoft Store, or Emulator. Microsoft Store is available on Windows only. An optional emulator variant can identify the console, launcher, or core when multiple emulator presets share an OS. +Choose Native, Steam, Epic Games, GOG, Microsoft Store, or Emulator. Steam accepts Windows, Linux, and macOS; Epic Games accepts Windows and macOS; Microsoft Store accepts Windows only. An optional emulator variant can identify the console, launcher, or core when multiple emulator presets share an OS. ## Apps @@ -14,7 +14,9 @@ Provide the official HTTPS homepage or source repository. An app image is option ## Commands and paths -Enter one command. PresetDB publishes it as Sunshine `cmd`. Sunshine's [application examples](https://github.com/LizardByte/Sunshine/blob/master/docs/app_examples.md) use detached commands for Steam launcher URIs, so submit a Steam executable command instead. Epic launcher URIs are supported for Windows Epic Games presets. A maintainer reviews other commands before approval. +Use the single **Launch ID** field for Steam, Epic Games, or Microsoft Store. For Steam, enter the numeric app ID from a Steam store URL. For Epic Games, enter the three-part Sandbox ID, Catalog ID, and Artifact ID from the game's launcher shortcut, separated by colons or `%3A`. For Microsoft Store, enter the installed app's [AUMID](https://learn.microsoft.com/en-us/windows/configuration/store/find-aumid), available through `Get-StartApps`; a Store product ID opens a store page and cannot launch the installed game. Leave Command and Working directory blank for these methods. The bot generates the OS-specific Sunshine `cmd` and stores the launch ID with the preset. + +For Native, GOG, and Emulator, provide one Command. GOG games can launch without Galaxy, so no single GOG ID command is assumed. A maintainer reviews these 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`, with no detached command field. The supported path placeholders are `{{ROM_PATH}}` and `{{HOME}}`. Windows also supports `{{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. diff --git a/src/database.js b/src/database.js index 9573b6b02a..3eb3dfa360 100644 --- a/src/database.js +++ b/src/database.js @@ -89,6 +89,7 @@ function mergePreset(root, preset, { const entry = { id: presetId, name: generatedName, os: preset.os, method: preset.method, ...(preset.variantName ? { variant_name: preset.variantName } : {}), + ...(preset.launchId ? { launch_id: preset.launchId } : {}), sunshine: { name: generatedName, cmd: preset.command, diff --git a/src/presets.js b/src/presets.js index ca085fafc8..598587773e 100644 --- a/src/presets.js +++ b/src/presets.js @@ -10,6 +10,7 @@ const FIELD_NAMES = { 'App image URL': 'appImageUrl', 'Host operating system': 'os', 'Launch method': 'method', + 'Launch ID': 'launchId', 'Emulator variant name': 'variantName', Command: 'command', 'Working directory': 'workingDir', @@ -120,17 +121,41 @@ function appIdentity(values) { }; } -function validateLaunchCommand(command, os, method) { - const steamUri = /steam:\/\//i.test(command); - const epicUri = /com\.epicgames\.launcher:\/\//i.test(command); - if (steamUri) { - throw new PresetError('Steam URI needs a detached command; use a Steam executable command instead'); +function generatedLaunch(values, method, os) { + const id = field(values, 'launchId', { limit: 300, singleLine: true }); + if (!['Steam', 'Epic Games', 'Microsoft Store'].includes(method)) { + if (id) throw new PresetError('Launch ID requires Steam, Epic Games, or Microsoft Store'); + return null; } - if (epicUri && method !== 'Epic Games') { - throw new PresetError('Epic Games URI requires the Epic Games launch method'); + + if (method === 'Steam') { + if (!/^[1-9]\d{0,9}$/.test(id) || Number(id) > 4294967295) { + throw new PresetError('Steam app ID must be a positive 32-bit number'); + } + const uri = 'steam://rungameid/' + id; + const command = os === 'Windows' ? 'cmd /c start "" "' + uri + '"' + : os === 'macOS' ? 'open "' + uri + '"' : 'steam "' + uri + '"'; + return { command, launchId: id }; + } + if (method === 'Epic Games') { + const parts = id.split(/%3A|:/i); + if (parts.length !== 3 || parts.some(part => !/^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/.test(part))) { + throw new PresetError('Epic launch ID must contain Sandbox ID, Catalog ID, and Artifact ID'); + } + const launchId = parts.join('%3A'); + const uri = 'com.epicgames.launcher://apps/' + launchId + '?action=launch&silent=true'; + const command = os === 'Windows' ? 'cmd /c start "" "' + uri + '"' : 'open "' + uri + '"'; + return { command, launchId }; } - if (epicUri && (os !== 'Windows' || !/^com\.epicgames\.launcher:\/\/apps\/[^\s?]+(?:\?[^\s]+)?$/i.test(command))) { - throw new PresetError('Epic Games launcher URI is supported for Windows only'); + if (!/^[A-Za-z0-9][A-Za-z0-9._-]*_[A-Za-z0-9]{13}![A-Za-z0-9][A-Za-z0-9._-]*$/.test(id)) { + throw new PresetError('Microsoft Store AUMID must contain a package family name and application ID'); + } + return { command: 'explorer.exe shell:AppsFolder\\' + id, launchId: id }; +} + +function validateManualLaunchCommand(command) { + if (/steam:\/\//i.test(command) || /com\.epicgames\.launcher:\/\//i.test(command)) { + throw new PresetError('Launcher URIs require their matching store ID field'); } } @@ -162,13 +187,20 @@ function validateFields(values, kind) { if (method === 'Microsoft Store' && os !== 'Windows') { throw new PresetError('Microsoft Store is available on Windows only'); } + if (method === 'Epic Games' && os === 'Linux') { + throw new PresetError('Epic Games launcher IDs are available on Windows and macOS only'); + } const variantName = field(values, 'variantName', { limit: 100, singleLine: true }); if (variantName && method !== 'Emulator') { throw new PresetError('An emulator variant name requires the Emulator launch method'); } const identity = kind === 'game' ? gameIdentity(values) : appIdentity(values); - const command = field(values, 'command', { required: true, singleLine: true }); + const launch = generatedLaunch(values, method, os); + const submittedCommand = field(values, 'command', { required: !launch, singleLine: true }); const workingDir = field(values, 'workingDir', { limit: 512, singleLine: true }); + if (launch && submittedCommand) throw new PresetError('Do not provide a command for a store ID launch'); + if (launch && workingDir) throw new PresetError('Working directory is not used with a store ID launch'); + const command = launch ? launch.command : submittedCommand; validatePlaceholders(command, os, 'Launch command'); validatePlaceholders(workingDir, os, 'Working directory'); if (method !== 'Emulator' && (command.includes('{{ROM_PATH}}') || workingDir.includes('{{ROM_PATH}}'))) { @@ -176,10 +208,10 @@ function validateFields(values, kind) { } validatePortablePath(command, os, 'Launch command'); validatePortablePath(workingDir, os, 'Working directory'); - validateLaunchCommand(command, os, method); + if (!launch) validateManualLaunchCommand(command); return { kind, ...identity, variantName: variantName || null, ...replacementFields(values), - os, method: slug(method), command, + os, method: slug(method), command, launchId: launch ? launch.launchId : null, workingDir: workingDir || null, notes: field(values, 'notes', { limit: 2000 }) || null }; diff --git a/tests/presets.test.js b/tests/presets.test.js index df33aec8d2..f433b77958 100644 --- a/tests/presets.test.js +++ b/tests/presets.test.js @@ -43,6 +43,7 @@ function formBody(values) { const labels = { gameUrl: 'IGDB game URL', appName: 'App name', appUrl: 'Official app URL', appImageUrl: 'App image URL', os: 'Host operating system', method: 'Launch method', + launchId: 'Launch ID', variantName: 'Emulator variant name', command: 'Command', workingDir: 'Working directory', notes: 'Notes', replacementIssue: 'Preset to replace (issue number)', replacementReason: 'Replacement reason' @@ -111,23 +112,75 @@ test('apps use an HTTPS image URL and have separate review identity', () => { assert.throws(() => validateFields({ ...values, command: '{{APP_PATH}}' }, 'app'), /unsupported placeholder/); assert.equal(app.method, 'native'); assert.throws(() => validateFields({ ...values, method: 'Steam' }, 'app'), /do not have a launch method/); + assert.throws(() => validateFields({ ...values, launchId: '464920' }, 'app'), /Launch ID requires/); assert.throws(() => validateFields({ ...values, command: '{{ROM_PATH}}' }, 'app'), /ROM_PATH.*Emulator/); }); -test('game methods validate host OS and command forms', () => { - const base = { ...gameValues, variantName: '' }; - assert.throws(() => validateFields({ ...base, method: 'Steam', command: 'steam://rungameid/464920' }, 'game'), - /detached command/); - assert.equal(validateFields({ ...base, method: 'Steam', command: 'steam -applaunch 464920' }, 'game').method, 'steam'); - const epic = { ...base, method: 'Epic Games', command: 'com.epicgames.launcher://apps/abc?action=launch&silent=true' }; - assert.equal(validateFields(epic, 'game').method, 'epic-games'); - assert.throws(() => validateFields({ ...epic, os: 'Linux' }, 'game'), /Windows only/); - assert.equal(validateFields({ ...base, method: 'Microsoft Store', command: 'explorer.exe shell:AppsFolder\\Game!App' }, 'game').method, - 'microsoft-store'); - assert.throws(() => validateFields({ ...base, os: 'Linux', method: 'Microsoft Store' }, 'game'), /Windows only/); - assert.throws(() => validateFields({ ...base, method: 'Other' }, 'game'), /supported game launch method/); - assert.throws(() => validateFields({ ...base, method: 'Native' }, 'game'), /ROM_PATH.*Emulator/); - assert.throws(() => validateFields({ ...base, method: 'Native', variantName: 'Alternate' }, 'game'), /Emulator/); +test('store IDs generate OS-specific Sunshine commands', () => { + const base = { ...gameValues, variantName: '', command: '', workingDir: '' }; + const steamCommands = { + Windows: 'cmd /c start "" "steam://rungameid/464920"', + Linux: 'steam "steam://rungameid/464920"', + macOS: 'open "steam://rungameid/464920"' + }; + for (const [os, command] of Object.entries(steamCommands)) { + const preset = validateFields({ ...base, os, method: 'Steam', launchId: '464920' }, 'game'); + assert.equal(preset.command, command); + assert.equal(preset.launchId, '464920'); + } + const epic = 'fn%3A4fe75bbc5a674f4f9b356b5c90567da5%3AFortnite'; + const epicUri = 'com.epicgames.launcher://apps/' + epic + '?action=launch&silent=true'; + for (const [os, command] of [ + ['Windows', 'cmd /c start "" "' + epicUri + '"'], + ['macOS', 'open "' + epicUri + '"'] + ]) { + const preset = validateFields({ ...base, os, method: 'Epic Games', + launchId: 'fn:4fe75bbc5a674f4f9b356b5c90567da5:Fortnite' }, 'game'); + assert.equal(preset.launchId, epic); + assert.equal(preset.command, command); + } + const aumid = 'Microsoft.WindowsCalculator_8wekyb3d8bbwe!App'; + const store = validateFields({ ...base, method: 'Microsoft Store', launchId: aumid }, 'game'); + assert.equal(store.command, 'explorer.exe shell:AppsFolder\\' + aumid); + assert.equal(store.launchId, aumid); + assert.equal(parseIssue(formBody({ ...base, method: 'Steam', launchId: '464920' })).launchId, '464920'); +}); + +test('store IDs validate syntax, method, and host OS', () => { + const base = { ...gameValues, variantName: '', command: '', workingDir: '' }; + assert.throws(() => validateFields({ ...base, method: 'Steam' }, 'game'), /Steam app ID/); + assert.throws(() => validateFields({ ...base, method: 'Steam', launchId: '1&whoami' }, 'game'), /Steam app ID/); + assert.throws(() => validateFields({ ...base, method: 'Steam', launchId: '4294967296' }, 'game'), /Steam app ID/); + assert.throws(() => validateFields({ ...base, method: 'Steam', launchId: '464920', + command: 'other.exe' }, 'game'), /Do not provide a command/); + assert.throws(() => validateFields({ ...base, method: 'Steam', launchId: '464920', + workingDir: '{{HOME}}' }, 'game'), /Working directory is not used/); + assert.throws(() => validateFields({ ...base, method: 'Native', launchId: '464920', + command: 'game.exe' }, 'game'), /Launch ID requires/); + assert.throws(() => validateFields({ ...base, method: 'Epic Games', os: 'Linux', + launchId: 'fn:catalog:Fortnite' }, 'game'), /Windows and macOS only/); + assert.throws(() => validateFields({ ...base, method: 'Epic Games', + launchId: 'fn:catalog:Game&calc' }, 'game'), /Epic launch ID/); + assert.throws(() => validateFields({ ...base, method: 'Microsoft Store', + launchId: '9WZDNCRFHVJL' }, 'game'), /Microsoft Store AUMID/); + assert.throws(() => validateFields({ ...base, method: 'Microsoft Store', os: 'Linux', + launchId: 'Microsoft.WindowsCalculator_8wekyb3d8bbwe!App' }, 'game'), /Windows only/); +}); + +test('Native, GOG, and Emulator still require reviewed commands', () => { + const base = { ...gameValues, variantName: '', command: '', workingDir: '' }; + for (const method of ['Native', 'GOG', 'Emulator']) { + assert.throws(() => validateFields({ ...base, method }, 'game'), /command is required/); + } + assert.equal(validateFields({ ...base, method: 'GOG', command: 'game.exe' }, 'game').command, 'game.exe'); + assert.throws(() => validateFields({ ...base, method: 'Other', command: 'game.exe' }, 'game'), + /supported game launch method/); + assert.throws(() => validateFields({ ...base, method: 'Native', command: 'steam://rungameid/464920' }, 'game'), + /Launcher URIs require/); + assert.throws(() => validateFields({ ...base, method: 'Native', command: '{{ROM_PATH}}' }, 'game'), + /ROM_PATH.*Emulator/); + assert.throws(() => validateFields({ ...base, method: 'Native', command: 'game.exe', + variantName: 'Alternate' }, 'game'), /Emulator/); }); test('approved presets get issue IDs and replacements preserve identity', async t => { @@ -154,13 +207,14 @@ test('approved presets get issue IDs and replacements preserve identity', async { issueNumber: 14, approvedBy: 'maintainer' }), /No preset/); }); -test('Steam executable publishes as a Sunshine command without an image path', async t => { +test('Steam app ID publishes a generated Sunshine command without detached or image paths', 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: 'steam -applaunch 464920' }, 'game'), mockApis, credentials); + variantName: '', command: '', workingDir: '', launchId: '464920' }, 'game'), mockApis, credentials); const { record } = mergePreset(root, steam, { issueNumber: 30, approvedBy: 'reviewer' }, { write: true }); - assert.equal(record.presets[0].sunshine.cmd, 'steam -applaunch 464920'); + assert.equal(record.presets[0].launch_id, '464920'); + assert.equal(record.presets[0].sunshine.cmd, 'cmd /c start "" "steam://rungameid/464920"'); assert.equal(record.presets[0].sunshine.name, 'One Tap Hero (Windows, Steam)'); assert.ok(!Object.hasOwn(record.presets[0].sunshine, 'detached')); assert.ok(!Object.hasOwn(record.presets[0].sunshine, 'image-path')); From c0864ec501961d4d16a0d1093df544e8c79d65b8 Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Wed, 23 Sep 2026 17:01:18 -0400 Subject: [PATCH 11/12] Split game preset forms and show ProtonDB compatibility --- .github/ISSUE_TEMPLATE/game-emulator.yml | 55 +++++++ .github/ISSUE_TEMPLATE/game-epic-games.yml | 45 ++++++ .github/ISSUE_TEMPLATE/game-gog.yml | 60 +++++++ .../ISSUE_TEMPLATE/game-microsoft-store.yml | 45 ++++++ .github/ISSUE_TEMPLATE/game-native.yml | 60 +++++++ .github/ISSUE_TEMPLATE/game-preset.yml | 87 ---------- .github/ISSUE_TEMPLATE/game-steam.yml | 44 ++++++ README.md | 14 +- docs/approverGuide.md | 4 +- docs/presetGuidelines.md | 12 +- gh-pages-template/assets/js/app.js | 34 +++- gh-pages-template/index.html | 2 +- src/build-site.js | 50 +++++- src/database.js | 31 ++-- src/issue.js | 25 ++- src/presets.js | 127 ++++++++------- tests/presets.test.js | 148 ++++++++++++------ 17 files changed, 611 insertions(+), 232 deletions(-) create mode 100644 .github/ISSUE_TEMPLATE/game-emulator.yml create mode 100644 .github/ISSUE_TEMPLATE/game-epic-games.yml create mode 100644 .github/ISSUE_TEMPLATE/game-gog.yml create mode 100644 .github/ISSUE_TEMPLATE/game-microsoft-store.yml create mode 100644 .github/ISSUE_TEMPLATE/game-native.yml delete mode 100644 .github/ISSUE_TEMPLATE/game-preset.yml create mode 100644 .github/ISSUE_TEMPLATE/game-steam.yml diff --git a/.github/ISSUE_TEMPLATE/game-emulator.yml b/.github/ISSUE_TEMPLATE/game-emulator.yml new file mode 100644 index 0000000000..5b05081a39 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/game-emulator.yml @@ -0,0 +1,55 @@ +--- +name: Emulator game preset +description: Request a Sunshine Emulator launch option for a GameDB game. +title: '[EMULATOR GAME PRESET]: ' +labels: + - request-game-preset + - method-emulator +body: + - type: markdown + attributes: + value: | + One issue represents one Emulator launch option. Provide the IGDB game URL; + the bot resolves its ID and checks GameDB. + - type: input + id: game_url + attributes: + label: IGDB game URL + description: Official URL containing the game slug, for example https://www.igdb.com/games/surviving-mars. + validations: + required: true + - type: input + id: variant_name + attributes: + label: Emulator variant name + description: Optional emulator, console, or core label when needed to distinguish launch options. + - type: input + id: command + attributes: + label: Command + description: | + Portable Sunshine 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: + required: true + - type: input + id: working_dir + attributes: + label: Working directory + description: Optional portable Sunshine working-dir value. + - type: textarea + id: notes + attributes: + label: Notes + description: Setup instructions or prerequisites. + - type: textarea + id: replacement_reason + attributes: + label: Replacement reason + description: Explain why the existing preset should change. Required with the issue number below. + - type: input + id: replacement_issue + attributes: + label: Preset to replace (issue number) + description: Optional issue number shown with the published preset. Leave blank to add a new preset. diff --git a/.github/ISSUE_TEMPLATE/game-epic-games.yml b/.github/ISSUE_TEMPLATE/game-epic-games.yml new file mode 100644 index 0000000000..cc218fe74d --- /dev/null +++ b/.github/ISSUE_TEMPLATE/game-epic-games.yml @@ -0,0 +1,45 @@ +--- +name: Epic Games game preset +description: Request a Sunshine Epic Games launch option for a GameDB game. +title: '[EPIC GAMES GAME PRESET]: ' +labels: + - request-game-preset + - method-epic-games +body: + - type: markdown + attributes: + value: | + One issue represents one Epic Games launch option. Provide the IGDB game URL; + the bot resolves its ID and checks GameDB. + - type: input + id: game_url + attributes: + label: IGDB game URL + description: Official URL containing the game slug, for example https://www.igdb.com/games/surviving-mars. + validations: + required: true + - type: input + id: launch_id + attributes: + label: Launch ID + 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. + validations: + required: true + - type: textarea + id: notes + attributes: + label: Notes + description: Setup instructions or prerequisites. + - type: textarea + id: replacement_reason + attributes: + label: Replacement reason + description: Explain why the existing preset should change. Required with the issue number below. + - type: input + id: replacement_issue + attributes: + label: Preset to replace (issue number) + description: Optional issue number shown with the published preset. Leave blank to add a new preset. diff --git a/.github/ISSUE_TEMPLATE/game-gog.yml b/.github/ISSUE_TEMPLATE/game-gog.yml new file mode 100644 index 0000000000..0e06dfd23c --- /dev/null +++ b/.github/ISSUE_TEMPLATE/game-gog.yml @@ -0,0 +1,60 @@ +--- +name: GOG game preset +description: Request a Sunshine GOG launch option for a GameDB game. +title: '[GOG GAME PRESET]: ' +labels: + - request-game-preset + - method-gog +body: + - type: markdown + attributes: + value: | + One issue represents one GOG launch option. Provide the IGDB game URL; + the bot resolves its ID and checks GameDB. + - type: input + id: game_url + attributes: + label: IGDB game URL + description: Official URL containing the game slug, for example https://www.igdb.com/games/surviving-mars. + validations: + required: true + - type: dropdown + id: os + attributes: + label: Host operating system + options: + - Windows + - Linux + - macOS + validations: + required: true + - type: input + id: command + attributes: + label: Command + description: | + Sunshine 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: + required: true + - type: input + id: working_dir + attributes: + label: Working directory + description: Optional Sunshine working-dir value. + - type: textarea + id: notes + attributes: + label: Notes + description: Setup instructions or prerequisites. + - type: textarea + id: replacement_reason + attributes: + label: Replacement reason + description: Explain why the existing preset should change. Required with the issue number below. + - type: input + id: replacement_issue + attributes: + label: Preset to replace (issue number) + description: Optional issue number shown with the published preset. Leave blank to add a new preset. diff --git a/.github/ISSUE_TEMPLATE/game-microsoft-store.yml b/.github/ISSUE_TEMPLATE/game-microsoft-store.yml new file mode 100644 index 0000000000..753e77a58a --- /dev/null +++ b/.github/ISSUE_TEMPLATE/game-microsoft-store.yml @@ -0,0 +1,45 @@ +--- +name: Microsoft Store game preset +description: Request a Sunshine Microsoft Store launch option for a GameDB game. +title: '[MICROSOFT STORE GAME PRESET]: ' +labels: + - request-game-preset + - method-microsoft-store +body: + - type: markdown + attributes: + value: | + One issue represents one Microsoft Store launch option. Provide the IGDB game URL; + the bot resolves its ID and checks GameDB. + - type: input + id: game_url + attributes: + label: IGDB game URL + description: Official URL containing the game slug, for example https://www.igdb.com/games/surviving-mars. + validations: + required: true + - type: input + id: launch_id + attributes: + label: Launch ID + 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. + validations: + required: true + - type: textarea + id: notes + attributes: + label: Notes + description: Setup instructions or prerequisites. + - type: textarea + id: replacement_reason + attributes: + label: Replacement reason + description: Explain why the existing preset should change. Required with the issue number below. + - type: input + id: replacement_issue + attributes: + label: Preset to replace (issue number) + description: Optional issue number shown with the published preset. Leave blank to add a new preset. diff --git a/.github/ISSUE_TEMPLATE/game-native.yml b/.github/ISSUE_TEMPLATE/game-native.yml new file mode 100644 index 0000000000..808564a39a --- /dev/null +++ b/.github/ISSUE_TEMPLATE/game-native.yml @@ -0,0 +1,60 @@ +--- +name: Native game preset +description: Request a Sunshine Native launch option for a GameDB game. +title: '[NATIVE GAME PRESET]: ' +labels: + - request-game-preset + - method-native +body: + - type: markdown + attributes: + value: | + One issue represents one Native launch option. Provide the IGDB game URL; + the bot resolves its ID and checks GameDB. + - type: input + id: game_url + attributes: + label: IGDB game URL + description: Official URL containing the game slug, for example https://www.igdb.com/games/surviving-mars. + validations: + required: true + - type: dropdown + id: os + attributes: + label: Host operating system + options: + - Windows + - Linux + - macOS + validations: + required: true + - type: input + id: command + attributes: + label: Command + description: | + Sunshine 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: + required: true + - type: input + id: working_dir + attributes: + label: Working directory + description: Optional Sunshine working-dir value. + - type: textarea + id: notes + attributes: + label: Notes + description: Setup instructions or prerequisites. + - type: textarea + id: replacement_reason + attributes: + label: Replacement reason + description: Explain why the existing preset should change. Required with the issue number below. + - type: input + id: replacement_issue + attributes: + label: Preset to replace (issue number) + description: Optional issue number shown with the published preset. Leave blank to add a new preset. diff --git a/.github/ISSUE_TEMPLATE/game-preset.yml b/.github/ISSUE_TEMPLATE/game-preset.yml deleted file mode 100644 index c54d9200bf..0000000000 --- a/.github/ISSUE_TEMPLATE/game-preset.yml +++ /dev/null @@ -1,87 +0,0 @@ ---- -name: Game launch preset -description: Request one Sunshine launch option for a GameDB game. -title: '[GAME PRESET]: ' -labels: - - request-game-preset -body: - - type: markdown - attributes: - value: | - One issue represents one launch option. Provide the IGDB game URL; the bot resolves its ID and checks GameDB. - For Steam, Epic Games, or Microsoft Store, enter the matching launch ID; the bot creates the command. - Add an emulator variant only when the operating system and launch method cannot distinguish the preset. - - type: input - id: game_url - attributes: - label: IGDB game URL - description: Official URL containing the game slug, for example https://www.igdb.com/games/surviving-mars. - validations: - required: true - - type: dropdown - id: os - attributes: - label: Host operating system - options: - - Windows - - Linux - - macOS - validations: - required: true - - type: dropdown - id: method - attributes: - label: Launch method - options: - - Native - - Steam - - Epic Games - - GOG - - Microsoft Store - - Emulator - validations: - required: true - - type: input - id: launch_id - attributes: - label: Launch ID - description: | - Required for Steam, Epic Games, and Microsoft Store; leave blank for Native, GOG, or Emulator. - Steam: numeric app ID from a store URL, for example 464920. - Epic Games: three-part ID between /apps/ and ? in a launcher shortcut, - for example fn%3A4fe75bbc5a674f4f9b356b5c90567da5%3AFortnite. - Microsoft Store: installed app's AUMID from Get-StartApps, - for example Microsoft.WindowsCalculator_8wekyb3d8bbwe!App. A Store product ID will not launch it. - - type: input - id: variant_name - attributes: - label: Emulator variant name - description: Optional emulator, console, or core label when needed to distinguish launch options. - - type: input - id: command - attributes: - label: Command - description: | - Required for Native, GOG, and Emulator. Leave blank for Steam, Epic Games, and Microsoft Store. - Use {{ROM_PATH}} or {{HOME}} for variable paths. Windows also allows {{SYSTEM_DRIVE}}, - {{PROGRAM_FILES}}, and {{PROGRAM_FILES_X86}}. Literal home directories are rejected. - - type: input - id: working_dir - attributes: - label: Working directory - description: Optional Sunshine working-dir value for Native, GOG, and Emulator only. - - type: textarea - id: notes - attributes: - label: Notes - description: Setup instructions and required emulator settings. - - type: textarea - id: replacement_reason - attributes: - label: Replacement reason - description: Explain why the existing preset should change. Required with the issue number below. - - type: input - id: replacement_issue - attributes: - label: Preset to replace (issue number) - description: Optional issue number shown with the published preset. Leave blank to add a new preset. diff --git a/.github/ISSUE_TEMPLATE/game-steam.yml b/.github/ISSUE_TEMPLATE/game-steam.yml new file mode 100644 index 0000000000..447e4a39b0 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/game-steam.yml @@ -0,0 +1,44 @@ +--- +name: Steam game preset +description: Request a Sunshine Steam launch option for a GameDB game. +title: '[STEAM GAME PRESET]: ' +labels: + - request-game-preset + - method-steam +body: + - type: markdown + attributes: + value: | + One issue represents one Steam launch option. Provide the IGDB game URL; + the bot resolves its ID and checks GameDB. + - type: input + id: game_url + attributes: + label: IGDB game URL + description: Official URL containing the game slug, for example https://www.igdb.com/games/surviving-mars. + validations: + required: true + - type: input + id: launch_id + attributes: + 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. + validations: + required: true + - type: textarea + id: notes + attributes: + label: Notes + description: Setup instructions or prerequisites. + - type: textarea + id: replacement_reason + attributes: + label: Replacement reason + description: Explain why the existing preset should change. Required with the issue number below. + - type: input + id: replacement_issue + attributes: + label: Preset to replace (issue number) + description: Optional issue number shown with the published preset. Leave blank to add a new preset. diff --git a/README.md b/README.md index a9feb2b5ab..b8447d815b 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ Approved requests
-Community maintained launch presets for [Sunshine](https://github.com/LizardByte/Sunshine). Each game or app can have many presets across Windows, Linux, and macOS. Game launch methods include Native, Steam, Epic Games, GOG, Microsoft Store, and Emulator. App presets use the app name and host OS. GitHub issue numbers provide stable IDs, while the bot generates names and accepts optional emulator variants. +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. 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. @@ -45,16 +45,18 @@ 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 [open a game preset request](https://github.com/LizardByte/PresetDB/issues/new?template=game-preset.yml). 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. Choose the host OS and, for a game, its launch method. Add an emulator variant such as `RetroArch Snes9x` only when needed to distinguish emulator presets. The bot generates the preset name. -3. For Steam, Epic Games, or Microsoft Store, enter one **Launch ID** in the shared field: a Steam app ID, Epic's three-part launch ID, or a Microsoft Store AUMID. The bot generates Sunshine `cmd` from that ID and the host OS. For Native, GOG, and Emulator, enter one **Command**. Validation and the website never execute commands. +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. 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. + After validation, a listed trusted game contributor enters the approval queue automatically. Other requests wait for a listed approver or repository admin to comment `@LizardByte-bot approve`. App requests always require this separate review. The queue processes one approval at a time; see the [approver guide](docs/approverGuide.md) and [bot commands](docs/botCommands.md). 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's 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 supports all three host OSes, Epic Games supports Windows and macOS, and Microsoft Store requires a Windows AUMID. GOG and other manual commands need human review. Launcher ID commands may outlive Sunshine's 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; 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 +69,7 @@ Game submissions resolve the submitted slug through [IGDB's authenticated API](h 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. Create the `request-game-preset`, `request-app-preset`, `approve-queue`, and `approve-preset` labels. Set the GitHub repository description to the heading description above. The `database` branch retains the `database/` directory; approved records are written there. +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. Connect this repository to Read the Docs and enable pull request preview builds. Set `GITHUB_WORKFLOW=call-jekyll-build / Build Jekyll`, `SITE_ARTIFACT=update.zip`, and `EXTRACT_ARCHIVE=build.zip` in the Read the Docs project environment. The shared script downloads the Pages build artifact and extracts the nested site archive. See [developer setup](docs/developerSetup.md) for local commands. diff --git a/docs/approverGuide.md b/docs/approverGuide.md index 2420537f4c..c5c791e2e4 100644 --- a/docs/approverGuide.md +++ b/docs/approverGuide.md @@ -4,8 +4,8 @@ This guide is for listed approvers and repository administrators reviewing game 1. Open the oldest validated request in the [game queue](https://github.com/LizardByte/PresetDB/issues?q=is%3Aopen+label%3Arequest-game-preset) or [app queue](https://github.com/LizardByte/PresetDB/issues?q=is%3Aopen+label%3Arequest-app-preset). Check the latest validation comment after the last issue edit. 2. For games, check the IGDB URL and resolved GameDB record. For apps, inspect the official URL and optional image URL. App requests always need a separate review, including when a trusted contributor submits them. -3. Check the host OS, game launch method, store launch ID or manual command, working directory, and setup notes against the [preset guidelines](presetGuidelines.md) and [Sunshine examples](https://github.com/LizardByte/Sunshine/blob/master/docs/app_examples.md). Confirm the store ID belongs to the requested game. Automated validation checks ID syntax and never runs commands. -4. Search the published record for the same OS, method, and emulator variant. A replacement must identify an existing preset issue and explain the change. +3. Check the selected host OS for Native, GOG, or app requests. For Steam, Epic Games, and Microsoft Store, check the launch ID, generated commands, and which platforms the game actually supports. For Emulator, confirm the manual command and placeholders are portable. Review setup notes against the [preset guidelines](presetGuidelines.md) and [Sunshine examples](https://github.com/LizardByte/Sunshine/blob/master/docs/app_examples.md). Automated validation checks syntax and never runs commands. ProtonDB reports inform Linux compatibility for Steam but are not an approval gate. +4. Search the published record for the same method, host OS when present, and emulator variant. A replacement must identify an existing preset issue and explain the change. 5. Comment `@LizardByte-bot approve` to enter the approval queue. The bot adds `approve-queue` and starts `approve-preset` when the active slot is free. It revalidates against IGDB and GameDB before writing to the `database` branch. The approval workflow comments its result. A successful approval closes the issue and requests a Pages rebuild. On failure, it removes the issue from the queue so the next request can proceed; fix the issue and queue it again. The `approve-preset` label remains on successfully closed issues for the approved count badge. diff --git a/docs/presetGuidelines.md b/docs/presetGuidelines.md index 27448972fb..da256ebeb7 100644 --- a/docs/presetGuidelines.md +++ b/docs/presetGuidelines.md @@ -1,12 +1,12 @@ # Preset Guidelines -One issue requests one Sunshine launch option for one host operating system. Submit another issue for a different store, emulator, core, or operating system. The bot generates a name from the game or app name, host OS, and game launch method. +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. ## Games Use the public IGDB game URL. The bot resolves its slug to a numeric IGDB ID and requires the same game and slug in GameDB. The catalog obtains the game cover from GameDB, so the form has no image field. -Choose Native, Steam, Epic Games, GOG, Microsoft Store, or Emulator. Steam accepts Windows, Linux, and macOS; Epic Games accepts Windows and macOS; Microsoft Store accepts Windows only. An optional emulator variant can identify the console, launcher, or core when multiple emulator presets share an OS. +Choose the Native, Steam, Epic Games, GOG, Microsoft Store, or Emulator issue form. Only Native and GOG game forms ask for a host OS. Steam can generate Windows, Linux, and macOS commands; Epic Games can generate Windows and macOS commands; Microsoft Store can generate a Windows command. An optional emulator variant can identify a console, launcher, or core. ## Apps @@ -14,10 +14,12 @@ Provide the official HTTPS homepage or source repository. An app image is option ## Commands and paths -Use the single **Launch ID** field for Steam, Epic Games, or Microsoft Store. For Steam, enter the numeric app ID from a Steam store URL. For Epic Games, enter the three-part Sandbox ID, Catalog ID, and Artifact ID from the game's launcher shortcut, separated by colons or `%3A`. For Microsoft Store, enter the installed app's [AUMID](https://learn.microsoft.com/en-us/windows/configuration/store/find-aumid), available through `Get-StartApps`; a Store product ID opens a store page and cannot launch the installed game. Leave Command and Working directory blank for these methods. The bot generates the OS-specific Sunshine `cmd` and stores the launch ID with the preset. +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. -For Native, GOG, and Emulator, provide one Command. GOG games can launch without Galaxy, so no single GOG ID command is assumed. A maintainer reviews these 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`, with no detached command field. +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. -The supported path placeholders are `{{ROM_PATH}}` and `{{HOME}}`. Windows also supports `{{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 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. + +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/). For replacements, enter the issue number displayed with the published preset and explain what changed. The original preset ID is retained. diff --git a/gh-pages-template/assets/js/app.js b/gh-pages-template/assets/js/app.js index 7690e4acbc..d4249821b1 100644 --- a/gh-pages-template/assets/js/app.js +++ b/gh-pages-template/assets/js/app.js @@ -12,8 +12,10 @@ function filterItems(index, query, kind, os) { ).sort((a, b) => a.name.localeCompare(b.name)); } -function sunshineSnippet(preset) { - return JSON.stringify(preset.sunshine, null, 2); +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); } function normalizeBasePath(value) { @@ -113,14 +115,38 @@ function boot() { const body = element('div', 'card-body'); body.append(element('h3', 'h5 card-title fw-bold', preset.name)); if (preset.notes) body.append(element('p', 'card-text', preset.notes)); + if (preset.protondb_url) { + const tier = preset.protondb?.tier; + const reports = preset.protondb?.reports; + const rating = tier ? 'ProtonDB: ' + tier[0].toUpperCase() + tier.slice(1) + + (reports === null ? '' : ' (' + reports + ' reports)') : 'Check Linux compatibility on ProtonDB'; + const proton = element('p', 'card-text'); + proton.append(safeLink(preset.protondb_url, rating)); + body.append(proton); + } + let hostSelect; + if (preset.sunshine_by_os) { + const hostLabel = element('label', 'form-label', 'Host OS'); + hostSelect = element('select', 'form-select rounded-0 mb-3'); + for (const host of Object.keys(preset.sunshine_by_os)) { + const option = element('option', '', host); + option.value = host; + hostSelect.append(option); + } + hostLabel.append(hostSelect); + body.append(hostLabel); + } + const snippet = () => sunshineSnippet(preset, hostSelect?.value); const command = element('pre', 'p-3 rounded bg-dark text-light overflow-auto'); - command.append(element('code', '', sunshineSnippet(preset))); + const code = element('code', '', snippet()); + 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'); copy.type = 'button'; copy.addEventListener('click', async () => { try { - await navigator.clipboard.writeText(sunshineSnippet(preset)); + await navigator.clipboard.writeText(snippet()); copy.textContent = 'Copied'; } catch { copy.textContent = 'Select and copy the JSON above'; diff --git a/gh-pages-template/index.html b/gh-pages-template/index.html index d6ea6b9fa8..042ee808db 100644 --- a/gh-pages-template/index.html +++ b/gh-pages-template/index.html @@ -17,7 +17,7 @@

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.

- Contribute a game preset + Contribute a game preset Contribute an app preset

diff --git a/src/build-site.js b/src/build-site.js index 24bbb8aaab..33b38b70b0 100644 --- a/src/build-site.js +++ b/src/build-site.js @@ -4,11 +4,46 @@ const fs = require('node:fs'); const path = require('node:path'); const { buildStatistics } = require('./statistics'); -function buildSite(database, template, output) { +function supportedOs(preset) { + if (preset.os) return [preset.os]; + return preset.sunshine_by_os ? Object.keys(preset.sunshine_by_os) : ['Windows', 'Linux', 'macOS']; +} + +const PROTON_TIERS = new Set(['borked', 'bronze', 'silver', 'gold', 'platinum', 'native']); + +async function protonDbRating(appId, fetcher) { + try { + const response = await fetcher('https://www.protondb.com/api/v1/reports/summaries/' + appId + '.json', { + headers: { Accept: 'application/json' }, signal: AbortSignal.timeout(5000) + }); + if (!response.ok) return null; + const summary = await response.json(); + if (!summary || !PROTON_TIERS.has(summary.tier)) return null; + return { + tier: summary.tier, + reports: Number.isInteger(summary.total) && summary.total >= 0 ? summary.total : null + }; + } catch { + return null; + } +} + +async function addProtonDb(item, fetcher, cache) { + for (const preset of item.presets) { + if (preset.method !== 'steam' || !/^[1-9]\d{0,9}$/.test(preset.launch_id || '')) continue; + const appId = preset.launch_id; + preset.protondb_url = 'https://www.protondb.com/app/' + appId; + if (!cache.has(appId)) cache.set(appId, await protonDbRating(appId, fetcher)); + preset.protondb = cache.get(appId); + } +} + +async function buildSite(database, template, output, fetcher = globalThis.fetch) { fs.mkdirSync(output, { recursive: true }); fs.cpSync(template, output, { recursive: true }); const index = { schema_version: 1, games: [], apps: [] }; const records = []; + const protonCache = new Map(); for (const [folder, kind] of [['games', 'game'], ['apps', 'app']]) { const directory = path.join(database, folder); if (!fs.existsSync(directory)) continue; @@ -21,12 +56,13 @@ function buildSite(database, template, output) { String(item.id) !== path.basename(file, '.json')) { throw new Error(`Invalid database record: ${folder}/${file}`); } - fs.copyFileSync(path.join(directory, file), path.join(target, file)); + await addProtonDb(item, fetcher, protonCache); + fs.writeFileSync(path.join(target, file), JSON.stringify(item, null, 2) + '\n'); records.push(item); 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.map(preset => preset.os))].sort((a, b) => a.localeCompare(b)) + operating_systems: [...new Set(item.presets.flatMap(supportedOs))].sort((a, b) => a.localeCompare(b)) }); } index[folder].sort((a, b) => a.name.localeCompare(b.name) || String(a.id).localeCompare(String(b.id))); @@ -39,13 +75,13 @@ function buildSite(database, template, output) { return index; } -function main(args = process.argv.slice(2)) { +async function main(args = process.argv.slice(2)) { const values = {}; for (let i = 0; i < args.length; i += 2) values[args[i]] = args[i + 1]; if (!values['--database'] || !values['--output']) throw new Error('Use --database and --output'); - buildSite(values['--database'], values['--template'] || 'gh-pages-template', values['--output']); + await buildSite(values['--database'], values['--template'] || 'gh-pages-template', values['--output']); } -if (require.main === module) main(); +if (require.main === module) main().catch(error => { console.error(error); process.exitCode = 1; }); -module.exports = { buildSite }; +module.exports = { buildSite, protonDbRating }; diff --git a/src/database.js b/src/database.js index 3eb3dfa360..8d235b1e4c 100644 --- a/src/database.js +++ b/src/database.js @@ -43,15 +43,18 @@ function prepareRecord(root, preset) { return { file, record, name }; } -function displayName(name, preset) { - if (preset.kind === 'app') return `${name} (${preset.os})`; - const labels = { +function methodLabel(method) { + return { native: 'Native', steam: 'Steam', 'epic-games': 'Epic Games', gog: 'GOG', 'microsoft-store': 'Microsoft Store', emulator: 'Emulator' - }; - const method = labels[preset.method]; + }[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 `${name} (${preset.os}, ${method}${variant})`; + return preset.os ? name + ' (' + preset.os + ', ' + method + variant + ')' : name + ' (' + method + variant + ')'; } function replacementIndex(record, preset) { @@ -90,11 +93,17 @@ 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 } : {}), - sunshine: { - name: generatedName, - cmd: preset.command, - ...(preset.workingDir ? { 'working-dir': preset.workingDir } : {}) - }, + ...(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 } : {}) + } + }), notes: preset.notes, origin_issue: originIssue, source_issue: issueNumber, diff --git a/src/issue.js b/src/issue.js index 80cfaff31e..ac01649d47 100644 --- a/src/issue.js +++ b/src/issue.js @@ -5,6 +5,23 @@ const path = require('node:path'); const { parseIssue, validateFields, validateGameDb, PresetError } = require('./presets'); const { mergePreset } = require('./database'); +const METHOD_LABELS = { + 'method-native': 'Native', + 'method-steam': 'Steam', + 'method-epic-games': 'Epic Games', + 'method-gog': 'GOG', + 'method-microsoft-store': 'Microsoft Store', + 'method-emulator': 'Emulator' +}; + +function requestMethod(issue, kind) { + if (kind === 'app') return null; + const labels = (issue.labels || []).map(label => typeof label === 'string' ? label : label.name); + const methods = labels.filter(label => Object.hasOwn(METHOD_LABELS, label)); + if (methods.length !== 1) throw new PresetError('Game issue must have exactly one launch method label'); + return METHOD_LABELS[methods[0]]; +} + function requestKind(issue) { const labels = new Set((issue.labels || []).map(label => typeof label === 'string' ? label : label.name)); const game = labels.has('request-game-preset'); @@ -18,6 +35,9 @@ async function processIssue(event, database, { approve = false, actor = '', fetc if (!issue || !Number.isInteger(issue.number)) throw new PresetError('A GitHub issue event is required'); const kind = requestKind(issue); const fields = parseIssue(issue.body || ''); + const method = requestMethod(issue, kind); + if (fields.method && fields.method !== method) throw new PresetError('Issue launch method does not match its template'); + if (method) fields.method = method; const preset = await validateGameDb(validateFields(fields, kind), fetcher, credentials); const merged = mergePreset(database, preset, { issueNumber: issue.number, approvedBy: actor, @@ -49,11 +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 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}\n` + methodLine + `- Preset ID: \`${result.id}\`\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 preview:\n\n\`\`\`json\n${JSON.stringify(entry.sunshine, null, 2)}\n\`\`\`\n`; + `Sunshine application JSON 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/presets.js b/src/presets.js index 598587773e..3304576f3f 100644 --- a/src/presets.js +++ b/src/presets.js @@ -79,7 +79,7 @@ function validatePortablePath(value, os, label) { if (homeRoot.test(normalized) || homeVariable.test(normalized)) { throw new PresetError(`${label} contains a literal home directory; use {{HOME}}`); } - if (os === 'Windows' && + if ((os === 'Windows' || os === null) && /(?:^|[/\s"'=])(?:con|prn|aux|nul|com[1-9\u00B9\u00B2\u00B3]|lpt[1-9\u00B9\u00B2\u00B3])(?:\.[^/\s"']*)?(?=$|[/\s"'])/i.test(normalized)) { throw new PresetError(`${label} contains a reserved Windows device name`); } @@ -121,36 +121,44 @@ function appIdentity(values) { }; } -function generatedLaunch(values, method, os) { - const id = field(values, 'launchId', { limit: 300, singleLine: true }); - if (!['Steam', 'Epic Games', 'Microsoft Store'].includes(method)) { - if (id) throw new PresetError('Launch ID requires Steam, Epic Games, or Microsoft Store'); - return null; +function steamLaunch(id) { + if (!/^[1-9]\d{0,9}$/.test(id) || Number(id) > 4294967295) { + throw new PresetError('Steam app ID must be a positive 32-bit number'); } + const uri = 'steam://rungameid/' + id; + return { launchId: id, commandsByOs: { + Windows: 'cmd /c start "" "' + uri + '"', + Linux: 'steam "' + uri + '"', + macOS: 'open "' + uri + '"' + } }; +} - if (method === 'Steam') { - if (!/^[1-9]\d{0,9}$/.test(id) || Number(id) > 4294967295) { - throw new PresetError('Steam app ID must be a positive 32-bit number'); - } - const uri = 'steam://rungameid/' + id; - const command = os === 'Windows' ? 'cmd /c start "" "' + uri + '"' - : os === 'macOS' ? 'open "' + uri + '"' : 'steam "' + uri + '"'; - return { command, launchId: id }; - } - if (method === 'Epic Games') { - const parts = id.split(/%3A|:/i); - if (parts.length !== 3 || parts.some(part => !/^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/.test(part))) { - throw new PresetError('Epic launch ID must contain Sandbox ID, Catalog ID, and Artifact ID'); - } - const launchId = parts.join('%3A'); - const uri = 'com.epicgames.launcher://apps/' + launchId + '?action=launch&silent=true'; - const command = os === 'Windows' ? 'cmd /c start "" "' + uri + '"' : 'open "' + uri + '"'; - return { command, launchId }; +function epicLaunch(id) { + const parts = id.split(/%3A|:/i); + if (parts.length !== 3 || parts.some(part => !/^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/.test(part))) { + throw new PresetError('Epic launch ID must contain Sandbox ID, Catalog ID, and Artifact 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 + '"' + } }; +} + +function microsoftStoreLaunch(id) { if (!/^[A-Za-z0-9][A-Za-z0-9._-]*_[A-Za-z0-9]{13}![A-Za-z0-9][A-Za-z0-9._-]*$/.test(id)) { throw new PresetError('Microsoft Store AUMID must contain a package family name and application ID'); } - return { command: 'explorer.exe shell:AppsFolder\\' + id, launchId: id }; + return { launchId: id, commandsByOs: { Windows: 'explorer.exe shell:AppsFolder\\' + id } }; +} + +function generatedLaunch(values, method) { + const id = field(values, 'launchId', { limit: 300, singleLine: true }); + const generators = { Steam: steamLaunch, 'Epic Games': epicLaunch, 'Microsoft Store': microsoftStoreLaunch }; + if (Object.hasOwn(generators, method)) return generators[method](id); + if (id) throw new PresetError('Launch ID requires Steam, Epic Games, or Microsoft Store'); + return null; } function validateManualLaunchCommand(command) { @@ -169,50 +177,59 @@ function replacementFields(values) { return { replacementIssue, replacementReason }; } -function validateFields(values, kind) { - if (kind !== 'game' && kind !== 'app') throw new PresetError('Exactly one request type is required'); - const os = field(values, 'os', { required: true, limit: 20, singleLine: true }); - if (!['Windows', 'Linux', 'macOS'].includes(os)) { - throw new PresetError('Choose a supported host OS'); - } +function gameMethod(values, kind) { const suppliedMethod = field(values, 'method', { limit: 30, singleLine: true }); - const method = kind === 'app' ? 'Native' : suppliedMethod; - if (kind === 'app' && suppliedMethod) { - throw new PresetError('App requests do not have a launch method'); + if (kind === 'app') { + if (suppliedMethod) throw new PresetError('App requests do not have a launch method'); + return 'Native'; } - if (kind === 'game' && - !['Native', 'Steam', 'Epic Games', 'GOG', 'Microsoft Store', 'Emulator'].includes(method)) { + if (!['Native', 'Steam', 'Epic Games', 'GOG', 'Microsoft Store', 'Emulator'].includes(suppliedMethod)) { throw new PresetError('Choose a supported game launch method'); } - if (method === 'Microsoft Store' && os !== 'Windows') { - throw new PresetError('Microsoft Store is available on Windows only'); - } - if (method === 'Epic Games' && os === 'Linux') { - throw new PresetError('Epic Games launcher IDs are available on Windows and macOS only'); - } - const variantName = field(values, 'variantName', { limit: 100, singleLine: true }); - if (variantName && method !== 'Emulator') { - throw new PresetError('An emulator variant name requires the Emulator launch method'); - } - const identity = kind === 'game' ? gameIdentity(values) : appIdentity(values); - const launch = generatedLaunch(values, method, os); + return suppliedMethod; +} + +function hostOs(values, kind, method) { + const required = kind === 'app' || method === 'Native' || method === 'GOG'; + const os = field(values, 'os', { required, limit: 20, singleLine: true }); + if (os && !['Windows', 'Linux', 'macOS'].includes(os)) throw new PresetError('Choose a supported host OS'); + if (!required && os) throw new PresetError('This launch method does not accept a host OS'); + return os || null; +} + +function manualCommand(values, method, os, launch) { const submittedCommand = field(values, 'command', { required: !launch, singleLine: true }); const workingDir = field(values, 'workingDir', { limit: 512, singleLine: true }); if (launch && submittedCommand) throw new PresetError('Do not provide a command for a store ID launch'); if (launch && workingDir) throw new PresetError('Working directory is not used with a store ID launch'); - const command = launch ? launch.command : submittedCommand; - validatePlaceholders(command, os, 'Launch command'); + if (launch) return { command: null, workingDir: null }; + validatePlaceholders(submittedCommand, os, 'Launch command'); validatePlaceholders(workingDir, os, 'Working directory'); - if (method !== 'Emulator' && (command.includes('{{ROM_PATH}}') || workingDir.includes('{{ROM_PATH}}'))) { + if (method !== 'Emulator' && + (submittedCommand.includes('{{ROM_PATH}}') || workingDir.includes('{{ROM_PATH}}'))) { throw new PresetError('{{ROM_PATH}} requires the Emulator launch method'); } - validatePortablePath(command, os, 'Launch command'); + validatePortablePath(submittedCommand, os, 'Launch command'); validatePortablePath(workingDir, os, 'Working directory'); - if (!launch) validateManualLaunchCommand(command); + validateManualLaunchCommand(submittedCommand); + return { command: submittedCommand, workingDir: workingDir || null }; +} + +function validateFields(values, kind) { + if (kind !== 'game' && kind !== 'app') throw new PresetError('Exactly one request type is required'); + const method = gameMethod(values, kind); + const os = hostOs(values, kind, method); + const variantName = field(values, 'variantName', { limit: 100, singleLine: true }); + if (variantName && method !== 'Emulator') { + throw new PresetError('An emulator variant name requires the Emulator launch method'); + } + const identity = kind === 'game' ? gameIdentity(values) : appIdentity(values); + const launch = generatedLaunch(values, method); + const { command, workingDir } = manualCommand(values, method, os, launch); return { kind, ...identity, variantName: variantName || null, ...replacementFields(values), - os, method: slug(method), command, launchId: launch ? launch.launchId : null, - workingDir: workingDir || null, + os, method: slug(method), command, launchId: launch?.launchId || null, + commandsByOs: launch?.commandsByOs || null, workingDir, notes: field(values, 'notes', { limit: 2000 }) || null }; } diff --git a/tests/presets.test.js b/tests/presets.test.js index f433b77958..d39dc7cba4 100644 --- a/tests/presets.test.js +++ b/tests/presets.test.js @@ -13,7 +13,7 @@ const { filterItems, sunshineSnippet, normalizeBasePath } = require('../gh-pages const credentials = { clientId: 'client-id', clientSecret: 'client-secret' }; const gameValues = { - gameUrl: 'https://www.igdb.com/games/one-tap-hero', os: 'Windows', method: 'Emulator', + gameUrl: 'https://www.igdb.com/games/one-tap-hero', method: 'Emulator', variantName: 'RetroArch Snes9x', command: 'retroarch -L snes9x "{{ROM_PATH}}"', workingDir: '{{HOME}}', notes: 'Install the core first.' }; @@ -56,24 +56,23 @@ test('form parses the slug URL and rejects duplicate fields', () => { assert.throws(() => parseIssue(`${formBody(gameValues)}\n\n### Command\n\nother`), /Duplicate issue field/); }); -test('game submission needs no ID and validates path placeholders', () => { +test('emulator needs no OS and validates portable path placeholders', () => { const preset = validateFields(gameValues, 'game'); assert.equal(preset.gameId, null); assert.equal(preset.gameSlug, 'one-tap-hero'); + assert.equal(preset.os, null); assert.equal(preset.variantName, 'RetroArch Snes9x'); - assert.equal(validateFields({ ...gameValues, command: '{{PROGRAM_FILES}}\\Game\\game.exe' }, 'game').os, 'Windows'); - assert.throws(() => validateFields({ ...gameValues, os: 'Linux', command: '{{PROGRAM_FILES}}/game' }, 'game'), /Windows only/); + assert.throws(() => validateFields({ ...gameValues, os: 'Windows' }, 'game'), /does not accept a host OS/); + assert.throws(() => validateFields({ ...gameValues, command: '{{PROGRAM_FILES}}/game' }, 'game'), /Windows only/); assert.throws(() => validateFields({ ...gameValues, command: '{{EMULATOR_PATH}} -L core' }, 'game'), /unsupported placeholder/); assert.throws(() => validateFields({ ...gameValues, command: '{{rom_path}}' }, 'game'), /malformed path placeholder/); for (const command of ['C:\\Users\\Alice\\Game\\game.exe', '%USERPROFILE%\\Game\\game.exe']) { assert.throws(() => validateFields({ ...gameValues, command }, 'game'), /literal home directory/); } - for (const workingDir of ['C:\\Games\\CON.txt', 'C:\\Games\\COM1\\Game', 'C:\\Games\\COM\u00B9']) { + for (const workingDir of ['C:\\Games\\CON.txt', 'C:\\Games\\COM1\\Game']) { assert.throws(() => validateFields({ ...gameValues, workingDir }, 'game'), /reserved Windows device name/); } - assert.throws(() => validateFields({ ...gameValues, os: 'Linux', workingDir: '/home/alice/Games' }, 'game'), - /literal home directory/); - assert.throws(() => validateFields({ ...gameValues, os: 'macOS', workingDir: '/Users/alice/Games' }, 'game'), + assert.throws(() => validateFields({ ...gameValues, workingDir: '/home/alice/Games' }, 'game'), /literal home directory/); assert.throws(() => validateFields({ ...gameValues, workingDir: '~/Games' }, 'game'), /literal home directory/); }); @@ -116,37 +115,26 @@ test('apps use an HTTPS image URL and have separate review identity', () => { assert.throws(() => validateFields({ ...values, command: '{{ROM_PATH}}' }, 'app'), /ROM_PATH.*Emulator/); }); -test('store IDs generate OS-specific Sunshine commands', () => { +test('store IDs generate commands for each launcher OS without asking for OS', () => { const base = { ...gameValues, variantName: '', command: '', workingDir: '' }; - const steamCommands = { + 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"' - }; - for (const [os, command] of Object.entries(steamCommands)) { - const preset = validateFields({ ...base, os, method: 'Steam', launchId: '464920' }, 'game'); - assert.equal(preset.command, command); - assert.equal(preset.launchId, '464920'); - } - const epic = 'fn%3A4fe75bbc5a674f4f9b356b5c90567da5%3AFortnite'; - const epicUri = 'com.epicgames.launcher://apps/' + epic + '?action=launch&silent=true'; - for (const [os, command] of [ - ['Windows', 'cmd /c start "" "' + epicUri + '"'], - ['macOS', 'open "' + epicUri + '"'] - ]) { - const preset = validateFields({ ...base, os, method: 'Epic Games', - launchId: 'fn:4fe75bbc5a674f4f9b356b5c90567da5:Fortnite' }, 'game'); - assert.equal(preset.launchId, epic); - assert.equal(preset.command, command); - } + }); + 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']); const aumid = 'Microsoft.WindowsCalculator_8wekyb3d8bbwe!App'; const store = validateFields({ ...base, method: 'Microsoft Store', launchId: aumid }, 'game'); - assert.equal(store.command, 'explorer.exe shell:AppsFolder\\' + aumid); - assert.equal(store.launchId, aumid); + assert.deepEqual(store.commandsByOs, { Windows: 'explorer.exe shell:AppsFolder\\' + aumid }); assert.equal(parseIssue(formBody({ ...base, method: 'Steam', launchId: '464920' })).launchId, '464920'); }); -test('store IDs validate syntax, method, and host OS', () => { +test('store IDs validate syntax and reject host OS or manual commands', () => { const base = { ...gameValues, variantName: '', command: '', workingDir: '' }; assert.throws(() => validateFields({ ...base, method: 'Steam' }, 'game'), /Steam app ID/); assert.throws(() => validateFields({ ...base, method: 'Steam', launchId: '1&whoami' }, 'game'), /Steam app ID/); @@ -155,32 +143,33 @@ test('store IDs validate syntax, method, and host OS', () => { command: 'other.exe' }, 'game'), /Do not provide a command/); assert.throws(() => validateFields({ ...base, method: 'Steam', launchId: '464920', workingDir: '{{HOME}}' }, 'game'), /Working directory is not used/); - assert.throws(() => validateFields({ ...base, method: 'Native', launchId: '464920', + assert.throws(() => validateFields({ ...base, method: 'Native', os: 'Windows', launchId: '464920', command: 'game.exe' }, 'game'), /Launch ID requires/); assert.throws(() => validateFields({ ...base, method: 'Epic Games', os: 'Linux', - launchId: 'fn:catalog:Fortnite' }, 'game'), /Windows and macOS only/); + launchId: 'fn:catalog:Fortnite' }, 'game'), /does not accept a host OS/); assert.throws(() => validateFields({ ...base, method: 'Epic Games', launchId: 'fn:catalog:Game&calc' }, 'game'), /Epic launch ID/); assert.throws(() => validateFields({ ...base, method: 'Microsoft Store', launchId: '9WZDNCRFHVJL' }, 'game'), /Microsoft Store AUMID/); - assert.throws(() => validateFields({ ...base, method: 'Microsoft Store', os: 'Linux', - launchId: 'Microsoft.WindowsCalculator_8wekyb3d8bbwe!App' }, 'game'), /Windows only/); }); -test('Native, GOG, and Emulator still require reviewed commands', () => { +test('Native and GOG require OS; Emulator accepts a portable command', () => { const base = { ...gameValues, variantName: '', command: '', workingDir: '' }; - for (const method of ['Native', 'GOG', 'Emulator']) { - assert.throws(() => validateFields({ ...base, method }, 'game'), /command is required/); + for (const method of ['Native', 'GOG']) { + assert.throws(() => validateFields({ ...base, method }, 'game'), /os is required/); + assert.throws(() => validateFields({ ...base, method, os: 'Windows' }, 'game'), /command is required/); } - assert.equal(validateFields({ ...base, method: 'GOG', command: 'game.exe' }, 'game').command, 'game.exe'); + assert.throws(() => validateFields({ ...base, method: 'Emulator' }, 'game'), /command is required/); + assert.equal(validateFields({ ...base, method: 'GOG', os: 'Windows', command: 'game.exe' }, 'game').command, + 'game.exe'); assert.throws(() => validateFields({ ...base, method: 'Other', command: 'game.exe' }, 'game'), /supported game launch method/); - assert.throws(() => validateFields({ ...base, method: 'Native', command: 'steam://rungameid/464920' }, 'game'), - /Launcher URIs require/); - assert.throws(() => validateFields({ ...base, method: 'Native', command: '{{ROM_PATH}}' }, 'game'), - /ROM_PATH.*Emulator/); - assert.throws(() => validateFields({ ...base, method: 'Native', command: 'game.exe', - variantName: 'Alternate' }, 'game'), /Emulator/); + assert.throws(() => validateFields({ ...base, method: 'Native', os: 'Windows', + command: 'steam://rungameid/464920' }, 'game'), /Launcher URIs require/); + assert.throws(() => validateFields({ ...base, method: 'Native', os: 'Windows', + command: '{{ROM_PATH}}' }, 'game'), /ROM_PATH.*Emulator/); + assert.throws(() => validateFields({ ...base, method: 'Native', os: 'Windows', + command: 'game.exe', variantName: 'Alternate' }, 'game'), /Emulator/); }); test('approved presets get issue IDs and replacements preserve identity', async t => { @@ -199,7 +188,7 @@ test('approved presets get issue IDs and replacements preserve identity', async const record = JSON.parse(fs.readFileSync(path.join(root, 'games/100245.json'))); 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 (Windows, Emulator: RetroArch Snes9x)'); + 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.throws(() => validateFields({ ...gameValues, replacementIssue: '10' }, 'game'), /replacement needs both/); @@ -207,17 +196,18 @@ test('approved presets get issue IDs and replacements preserve identity', async { issueNumber: 14, approvedBy: 'maintainer' }), /No preset/); }); -test('Steam app ID publishes a generated Sunshine command without detached or image paths', async t => { +test('Steam app ID publishes one logical preset with host-specific Sunshine 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.cmd, 'cmd /c start "" "steam://rungameid/464920"'); - assert.equal(record.presets[0].sunshine.name, 'One Tap Hero (Windows, Steam)'); - assert.ok(!Object.hasOwn(record.presets[0].sunshine, 'detached')); - assert.ok(!Object.hasOwn(record.presets[0].sunshine, 'image-path')); + 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.ok(!Object.hasOwn(record.presets[0], 'detached')); + assert.ok(!Object.hasOwn(record.presets[0], 'image-path')); }); test('issue processing and site build publish both game and app JSON', async t => { @@ -225,14 +215,15 @@ test('issue processing and site build publish both game and app JSON', async t = t.after(() => fs.rmSync(root, { recursive: true, force: true })); const database = path.join(root, 'database'); const output = path.join(root, 'site'); - await processIssue({ issue: { number: 21, labels: [{ name: 'request-game-preset' }], body: formBody(gameValues) } }, + await processIssue({ issue: { number: 21, labels: [{ name: 'request-game-preset' }, { name: 'method-emulator' }], body: formBody(gameValues) } }, database, { approve: true, actor: 'reviewer', fetcher: mockApis, credentials }); await processIssue({ issue: { number: 22, labels: [{ name: 'request-app-preset' }], body: formBody({ appName: 'App One', appUrl: 'https://example.org', appImageUrl: 'https://example.org/icon.png', os: 'Linux', command: '{{HOME}}/app-one' }) } }, database, { approve: true, actor: 'reviewer' }); - const index = buildSite(database, path.join(__dirname, '..', 'gh-pages-template'), output); + 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.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)'); @@ -253,3 +244,56 @@ test('site filters and serializes Sunshine application JSON', () => { assert.equal(normalizeBasePath('/PresetDB'), '/PresetDB'); assert.equal(normalizeBasePath('en/pr-123'), '/en/pr-123'); }); + +test('Pages build adds a ProtonDB tier to Steam presets and keeps the source link', async t => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'preset-proton-')); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + const database = path.join(root, 'database'); + const output = path.join(root, 'site'); + const steam = await validateGameDb(validateFields({ ...gameValues, method: 'Steam', + variantName: '', command: '', workingDir: '', launchId: '464920' }, 'game'), mockApis, credentials); + mergePreset(database, steam, { issueNumber: 31, approvedBy: 'reviewer' }, { write: true }); + const fetcher = async url => { + assert.equal(url, 'https://www.protondb.com/api/v1/reports/summaries/464920.json'); + return { ok: true, json: async () => ({ tier: 'gold', total: 73 }) }; + }; + const index = await buildSite(database, path.join(__dirname, '..', 'gh-pages-template'), output, fetcher); + assert.deepEqual(index.games[0].operating_systems, ['Linux', 'macOS', 'Windows']); + 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"'); +}); + +test('game method labels select the form and reject conflicting issue fields', async t => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'preset-method-')); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + const issue = { number: 42, body: formBody({ gameUrl: gameValues.gameUrl, + launchId: '464920' }), labels: ['request-game-preset', 'method-steam'] }; + const result = await processIssue({ issue }, root, { fetcher: mockApis, credentials }); + assert.equal(result.preset.method, 'steam'); + await assert.rejects(processIssue({ issue: { ...issue, labels: ['request-game-preset'] } }, + root, { fetcher: mockApis, credentials }), /exactly one launch method label/); + await assert.rejects(processIssue({ issue: { ...issue, + labels: ['request-game-preset', 'method-steam', 'method-native'] } }, + root, { fetcher: mockApis, credentials }), /exactly one launch method label/); + await assert.rejects(processIssue({ issue: { ...issue, + body: formBody({ gameUrl: gameValues.gameUrl, method: 'Native', launchId: '464920' }) } }, + root, { fetcher: mockApis, credentials }), /does not match its template/); +}); + +test('missing ProtonDB ratings remain unknown without failing the Pages build', async t => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'preset-proton-missing-')); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + const database = path.join(root, 'database'); + const output = path.join(root, 'site'); + const steam = await validateGameDb(validateFields({ ...gameValues, method: 'Steam', + variantName: '', command: '', workingDir: '', launchId: '464920' }, 'game'), mockApis, credentials); + mergePreset(database, steam, { issueNumber: 43, approvedBy: 'reviewer' }, { write: true }); + 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.presets[0].protondb, null); + assert.equal(published.presets[0].protondb_url, 'https://www.protondb.com/app/464920'); +}); From fed77813c32d0a173f072e495f9e82ef5641b53d Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Wed, 23 Sep 2026 17:06:03 -0400 Subject: [PATCH 12/12] Simplify catalog preset card rendering --- gh-pages-template/assets/js/app.js | 128 +++++++++++++++++------------ 1 file changed, 76 insertions(+), 52 deletions(-) diff --git a/gh-pages-template/assets/js/app.js b/gh-pages-template/assets/js/app.js index d4249821b1..532bfa0f00 100644 --- a/gh-pages-template/assets/js/app.js +++ b/gh-pages-template/assets/js/app.js @@ -59,6 +59,81 @@ function safeImage(url) { } catch { return null; } } +function protonRatingLabel(preset) { + const tier = preset.protondb?.tier; + if (!tier) return 'Check Linux compatibility on ProtonDB'; + let label = 'ProtonDB: ' + tier[0].toUpperCase() + tier.slice(1); + if (Number.isInteger(preset.protondb.reports)) label += ' (' + preset.protondb.reports + ' reports)'; + return label; +} + +function appendProtonRating(body, preset) { + if (!preset.protondb_url) return; + const proton = element('p', 'card-text'); + proton.append(safeLink(preset.protondb_url, protonRatingLabel(preset))); + body.append(proton); +} + +function hostSelection(body, preset) { + if (!preset.sunshine_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)) { + const option = element('option', '', host); + option.value = host; + select.append(option); + } + label.append(select); + body.append(label); + return select; +} + +function appendIssueLinks(body, preset) { + if (preset.origin_issue) { + body.append(element('span', 'ms-3'), safeLink( + 'https://github.com/LizardByte/PresetDB/issues/' + preset.origin_issue, + 'Preset issue #' + preset.origin_issue + ' ↗' + )); + } + if (preset.source_issue && preset.source_issue !== preset.origin_issue) { + body.append(element('span', 'ms-3'), safeLink( + 'https://github.com/LizardByte/PresetDB/issues/' + preset.source_issue, + 'Latest update ↗' + )); + } +} + +function renderPresetCard(preset) { + 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)); + if (preset.notes) body.append(element('p', 'card-text', preset.notes)); + appendProtonRating(body, preset); + const hostSelect = hostSelection(body, preset); + const snippet = () => sunshineSnippet(preset, hostSelect?.value); + const command = element('pre', 'p-3 rounded bg-dark text-light overflow-auto'); + const code = element('code', '', snippet()); + 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'); + copy.type = 'button'; + copy.addEventListener('click', async () => { + try { + await navigator.clipboard.writeText(snippet()); + copy.textContent = 'Copied'; + } catch { + copy.textContent = 'Select and copy the JSON above'; + } + }); + body.append(copy); + appendIssueLinks(body, preset); + card.append(body); + column.append(card); + return column; +} + function boot() { const base = normalizeBasePath(globalThis.PRESET_BASE); const search = document.getElementById('preset-search'); @@ -109,58 +184,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) { - 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)); - if (preset.notes) body.append(element('p', 'card-text', preset.notes)); - if (preset.protondb_url) { - const tier = preset.protondb?.tier; - const reports = preset.protondb?.reports; - const rating = tier ? 'ProtonDB: ' + tier[0].toUpperCase() + tier.slice(1) + - (reports === null ? '' : ' (' + reports + ' reports)') : 'Check Linux compatibility on ProtonDB'; - const proton = element('p', 'card-text'); - proton.append(safeLink(preset.protondb_url, rating)); - body.append(proton); - } - let hostSelect; - if (preset.sunshine_by_os) { - const hostLabel = element('label', 'form-label', 'Host OS'); - hostSelect = element('select', 'form-select rounded-0 mb-3'); - for (const host of Object.keys(preset.sunshine_by_os)) { - const option = element('option', '', host); - option.value = host; - hostSelect.append(option); - } - hostLabel.append(hostSelect); - body.append(hostLabel); - } - const snippet = () => sunshineSnippet(preset, hostSelect?.value); - const command = element('pre', 'p-3 rounded bg-dark text-light overflow-auto'); - const code = element('code', '', snippet()); - 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'); - copy.type = 'button'; - copy.addEventListener('click', async () => { - try { - await navigator.clipboard.writeText(snippet()); - copy.textContent = 'Copied'; - } catch { - copy.textContent = 'Select and copy the JSON above'; - } - }); - body.append(copy); - if (preset.origin_issue) body.append(element('span', 'ms-3'), safeLink(`https://github.com/LizardByte/PresetDB/issues/${preset.origin_issue}`, `Preset issue #${preset.origin_issue} ↗`)); - if (preset.source_issue && preset.source_issue !== preset.origin_issue) { - body.append(element('span', 'ms-3'), safeLink(`https://github.com/LizardByte/PresetDB/issues/${preset.source_issue}`, 'Latest update ↗')); - } - card.append(body); - column.append(card); - presets.append(column); - } + for (const preset of record.presets) presets.append(renderPresetCard(preset)); detail.append(presets); detail.scrollIntoView({ behavior: 'smooth', block: 'start' }); } catch (error) {