diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 587e557c..6e2df8d3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -121,7 +121,7 @@ jobs: echo "changed=true" >> "$GITHUB_OUTPUT" exit 0 fi - if git diff --name-only "$BASE_SHA" "$HEAD_SHA" | grep -Eq '^(scripts/build_switch\.sh$|scripts/switch/|docs/switch-.*\.md$|tests/switch_ci_workflows_test\.lua$|tests/switch_transfer_docs_test\.lua$|\.github/workflows/(ci|release|switch-artifact-comment)\.yml$|src/core/(NxAssetOverlay|Platform|GameVersion)\.lua$|src/import/CacheFs\.lua$|tests/engine/(assets_version_fallback|nx_generated_guard|nx_yellow_boot|switch_diagnostics|cache_fs_gold_nx_load)_test\.lua$|tests/engine/platform_nx)'; then + if git diff --name-only "$BASE_SHA" "$HEAD_SHA" | grep -Eq '^(scripts/build_switch\.sh$|scripts/switch/|docs/switch-.*\.md$|tests/switch_ci_workflows_test\.lua$|tests/switch_transfer_docs_test\.lua$|\.github/workflows/(ci|release|platform-artifact-comment)\.yml$|src/core/(NxAssetOverlay|Platform|GameVersion)\.lua$|src/import/CacheFs\.lua$|tests/engine/(assets_version_fallback|nx_generated_guard|nx_yellow_boot|switch_diagnostics|cache_fs_gold_nx_load)_test\.lua$|tests/engine/platform_nx)'; then echo "changed=true" >> "$GITHUB_OUTPUT" else echo "changed=false" >> "$GITHUB_OUTPUT" @@ -383,6 +383,113 @@ jobs: if-no-files-found: error retention-days: 7 + desktop-builds: + name: macOS, Windows, and Linux x86_64 builds + runs-on: macos-latest + steps: + - uses: actions/checkout@v7 + - name: install desktop build tools + run: | + brew install imagemagick squashfs + love_zip="$RUNNER_TEMP/love-11.5-macos.zip" + curl -fL --retry 2 --connect-timeout 15 \ + "https://github.com/love2d/love/releases/download/11.5/love-11.5-macos.zip" \ + -o "$love_zip" + love_root="$RUNNER_TEMP/love-11.5" + rm -rf "$love_root" + mkdir -p "$love_root" + unzip -q "$love_zip" -d "$love_root" + love_app="$(find "$love_root" -maxdepth 2 -type d -name 'love.app' -print -quit)" + [ -d "$love_app" ] || { echo "LÖVE.app was not found"; exit 1; } + echo "LOVE_APP=$love_app" >> "$GITHUB_ENV" + - name: build desktop packages + run: scripts/build.sh all --version 0.0.0 --no-notarize + - name: upload macOS artifact + uses: actions/upload-artifact@v7 + with: + name: gen1recomp-macos + path: dist/mac/gen1recomp-macos.zip + if-no-files-found: error + retention-days: 7 + - name: upload Windows artifact + uses: actions/upload-artifact@v7 + with: + name: gen1recomp-win64 + path: dist/win/gen1recomp-win64.zip + if-no-files-found: error + retention-days: 7 + - name: upload Linux x86_64 artifact + uses: actions/upload-artifact@v7 + with: + name: gen1recomp-linux-x86_64 + path: | + dist/linux/gen1recomp-linux-x86_64.AppImage + dist/linux/gen1recomp-linux-x86_64.AppImage.sha256 + if-no-files-found: error + retention-days: 7 + + android-build: + name: Android APK build + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - name: set up Java 17 + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '17' + - name: set up Android SDK + uses: android-actions/setup-android@v3 + - name: install Android build dependencies + run: | + yes | sdkmanager --licenses >/dev/null || true + sdkmanager \ + "platform-tools" \ + "platforms;android-36" \ + "build-tools;36.0.0" \ + "ndk;25.2.9519653" + - name: build Android APK + run: scripts/build_android.sh --version 0.0.0 + - name: upload Android artifact + uses: actions/upload-artifact@v7 + with: + name: gen1recomp-android-apk + path: dist/android/debug/*.apk + if-no-files-found: error + retention-days: 7 + + rg34xxsp-build: + name: RG34XXSP PortMaster build + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - name: build RG34XXSP package + run: ./build-rg34xxsp.sh --version 0.0.0 + - name: upload RG34XXSP artifact + uses: actions/upload-artifact@v7 + with: + name: gen1recomp-rg34xxsp + path: dist/rg34xxsp/gen1recomp-rg34xxsp-stockos64-mod.zip + if-no-files-found: error + retention-days: 7 + + linux-arm-sbc-build: + name: Linux ARM SBC PortMaster build + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - name: build Linux ARM SBC package + env: + GEN1RECOMP_SOURCE_DIR: ${{ github.workspace }} + run: ./build-linux-arm-sbc.sh --version 0.0.0 + - name: upload Linux ARM SBC artifact + uses: actions/upload-artifact@v7 + with: + name: gen1recomp-linux-arm-sbc + path: dist/linux-arm-sbc/gen1recomp-sbc-portmaster.zip + if-no-files-found: error + retention-days: 7 + headless: name: headless suites (no ROM) runs-on: ubuntu-latest diff --git a/.github/workflows/ios-artifact-comment.yml b/.github/workflows/ios-artifact-comment.yml deleted file mode 100644 index 3ba5386f..00000000 --- a/.github/workflows/ios-artifact-comment.yml +++ /dev/null @@ -1,55 +0,0 @@ -name: iOS artifact comment - -on: - workflow_run: - workflows: [ci] - types: [completed] - -permissions: - contents: read - issues: write - pull-requests: write - -jobs: - comment: - if: github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.conclusion == 'success' - runs-on: ubuntu-latest - steps: - - id: artifact - env: - GH_TOKEN: ${{ github.token }} - RUN_ID: ${{ github.event.workflow_run.id }} - HEAD_BRANCH: ${{ github.event.workflow_run.head_branch }} - HEAD_REPOSITORY: ${{ github.event.workflow_run.head_repository.full_name }} - run: | - artifact_id="$(gh api "repos/$GITHUB_REPOSITORY/actions/runs/$RUN_ID/artifacts" --jq '.artifacts[] | select(.name == "gen1recomp++-ios-ipa") | .id')" - [ -n "$artifact_id" ] || exit 0 - head_owner="${HEAD_REPOSITORY%%/*}" - pr_number="$(gh api "repos/$GITHUB_REPOSITORY/pulls?state=open&head=$head_owner:$HEAD_BRANCH" --jq '.[0].number // empty')" - [ -n "$pr_number" ] || exit 0 - echo "artifact_url=https://github.com/$GITHUB_REPOSITORY/actions/runs/$RUN_ID/artifacts/$artifact_id" >> "$GITHUB_OUTPUT" - echo "pr_number=$pr_number" >> "$GITHUB_OUTPUT" - # Upsert via comment-tag only — do not delete-all bot comments (clobbers Switch). - - name: Get build info - id: build-info - env: - HEAD_SHA: ${{ github.event.workflow_run.head_sha }} - run: | - commit_hash="$(printf '%s' "$HEAD_SHA" | cut -c1-7)" - build_time="$(date "+%Y-%m-%d %H:%M:%S")" - echo "hash=$commit_hash" >> "$GITHUB_OUTPUT" - echo "time=$build_time" >> "$GITHUB_OUTPUT" - - name: comment iOS artifact - if: steps.artifact.outputs.pr_number != '' - uses: thollander/actions-comment-pull-request@v3 - with: - message: | - [gen1recomp++.ipa](${{ steps.artifact.outputs.artifact_url }}) - - **Commit**: [#${{ steps.build-info.outputs.hash }}](https://github.com/${{ github.event.workflow_run.head_repository.full_name }}/commit/${{ github.event.workflow_run.head_sha }}) - **Build Time**: `${{ steps.build-info.outputs.time }}` - - This comment was automatically generated. [View workflow run](https://github.com/${{ github.repository }}/actions/runs/${{ github.event.workflow_run.id }}) - pr-number: ${{ steps.artifact.outputs.pr_number }} - comment-tag: ios-build-result - github-token: ${{ github.token }} diff --git a/.github/workflows/platform-artifact-comment.yml b/.github/workflows/platform-artifact-comment.yml new file mode 100644 index 00000000..84740580 --- /dev/null +++ b/.github/workflows/platform-artifact-comment.yml @@ -0,0 +1,78 @@ +name: platform artifact comment + +on: + workflow_run: + workflows: [ci] + types: [completed] + +concurrency: + group: platform-artifact-comment-${{ github.event.workflow_run.head_repository.full_name }}-${{ github.event.workflow_run.head_branch }} + cancel-in-progress: false + +permissions: + contents: read + issues: write + pull-requests: write + +jobs: + comment: + if: github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.conclusion == 'success' + runs-on: ubuntu-latest + steps: + - id: artifacts + env: + GH_TOKEN: ${{ github.token }} + RUN_ID: ${{ github.event.workflow_run.id }} + HEAD_BRANCH: ${{ github.event.workflow_run.head_branch }} + HEAD_REPOSITORY: ${{ github.event.workflow_run.head_repository.full_name }} + run: | + set -euo pipefail + artifacts="$(gh api "repos/$GITHUB_REPOSITORY/actions/runs/$RUN_ID/artifacts")" + head_owner="${HEAD_REPOSITORY%%/*}" + pr_number="$(gh api "repos/$GITHUB_REPOSITORY/pulls?state=open&head=$head_owner:$HEAD_BRANCH" --jq '.[0].number // empty')" + [ -n "$pr_number" ] || exit 0 + + message="## CI build artifacts" + found=0 + while IFS='|' read -r platform artifact_name artifact_file; do + artifact_id="$(printf '%s' "$artifacts" | jq -r --arg name "$artifact_name" '[.artifacts[] | select(.name == $name) | .id][0] // empty')" + if [ -n "$artifact_id" ] && [ "$artifact_id" != null ]; then + artifact_url="https://github.com/$GITHUB_REPOSITORY/actions/runs/$RUN_ID/artifacts/$artifact_id" + message+=$'\n'"- **$platform**: [$artifact_file]($artifact_url)" + found=1 + fi + done <<'ARTIFACTS' + macOS|gen1recomp++-macos|gen1recomp++-macos.zip + Windows|gen1recomp-win64|gen1recomp-win64.zip + Linux x86_64|gen1recomp-linux-x86_64|gen1recomp-linux-x86_64.AppImage + Android|gen1recomp-android-apk|gen1recomp-android.apk + iOS|gen1recomp++-ios-ipa|gen1recomp++.ipa + Nintendo Switch|gen1recomp-switch-nro|gen1recomp-switch.nro + Xbox UWP|gen1recomp-xbox-uwp|gen1recomp-xbox-uwp.zip + Linux arm64|gen1recomp-linux-arm64|gen1recomp-linux-arm64.AppImage + Linux Flatpak|gen1recomp-linux-flatpak|gen1recomp-linux.flatpak + RG34XXSP|gen1recomp-rg34xxsp|gen1recomp-rg34xxsp-stockos64-mod.zip + Linux ARM SBC|gen1recomp-linux-arm-sbc|gen1recomp-sbc-portmaster.zip + ARTIFACTS + + [ "$found" -eq 1 ] || exit 0 + commit_hash="$(printf '%s' "${{ github.event.workflow_run.head_sha }}" | cut -c1-7)" + build_time="$(date -u "+%Y-%m-%d %H:%M:%S UTC")" + message+=$'\n\n'"**Commit**: [#$commit_hash](https://github.com/$HEAD_REPOSITORY/commit/${{ github.event.workflow_run.head_sha }})" + message+=$'\n'"**Build Time**: \`$build_time\`" + message+=$'\n\n'"This comment was automatically generated. [View workflow run](https://github.com/$GITHUB_REPOSITORY/actions/runs/$RUN_ID)" + + echo "pr_number=$pr_number" >> "$GITHUB_OUTPUT" + { + echo 'message<> "$GITHUB_OUTPUT" + - name: comment platform artifacts + if: steps.artifacts.outputs.pr_number != '' + uses: thollander/actions-comment-pull-request@v3 + with: + message: ${{ steps.artifacts.outputs.message }} + pr-number: ${{ steps.artifacts.outputs.pr_number }} + comment-tag: platform-build-result + github-token: ${{ github.token }} diff --git a/.github/workflows/switch-artifact-comment.yml b/.github/workflows/switch-artifact-comment.yml deleted file mode 100644 index 286f2f62..00000000 --- a/.github/workflows/switch-artifact-comment.yml +++ /dev/null @@ -1,55 +0,0 @@ -name: Switch artifact comment - -on: - workflow_run: - workflows: [ci] - types: [completed] - -permissions: - contents: read - issues: write - pull-requests: write - -jobs: - comment: - if: github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.conclusion == 'success' - runs-on: ubuntu-latest - steps: - - id: artifact - env: - GH_TOKEN: ${{ github.token }} - RUN_ID: ${{ github.event.workflow_run.id }} - HEAD_BRANCH: ${{ github.event.workflow_run.head_branch }} - HEAD_REPOSITORY: ${{ github.event.workflow_run.head_repository.full_name }} - run: | - artifact_id="$(gh api "repos/$GITHUB_REPOSITORY/actions/runs/$RUN_ID/artifacts" --jq '.artifacts[] | select(.name == "gen1recomp-switch-nro") | .id')" - [ -n "$artifact_id" ] || exit 0 - head_owner="${HEAD_REPOSITORY%%/*}" - pr_number="$(gh api "repos/$GITHUB_REPOSITORY/pulls?state=open&head=$head_owner:$HEAD_BRANCH" --jq '.[0].number // empty')" - [ -n "$pr_number" ] || exit 0 - echo "artifact_url=https://github.com/$GITHUB_REPOSITORY/actions/runs/$RUN_ID/artifacts/$artifact_id" >> "$GITHUB_OUTPUT" - echo "pr_number=$pr_number" >> "$GITHUB_OUTPUT" - # Upsert via comment-tag only — do not delete-all bot comments (clobbers iOS). - - name: Get build info - id: build-info - env: - HEAD_SHA: ${{ github.event.workflow_run.head_sha }} - run: | - commit_hash="$(printf '%s' "$HEAD_SHA" | cut -c1-7)" - build_time="$(date "+%Y-%m-%d %H:%M:%S")" - echo "hash=$commit_hash" >> "$GITHUB_OUTPUT" - echo "time=$build_time" >> "$GITHUB_OUTPUT" - - name: comment Switch artifact - if: steps.artifact.outputs.pr_number != '' - uses: thollander/actions-comment-pull-request@v3 - with: - message: | - [gen1recomp-switch.nro](${{ steps.artifact.outputs.artifact_url }}) - - **Commit**: [#${{ steps.build-info.outputs.hash }}](https://github.com/${{ github.event.workflow_run.head_repository.full_name }}/commit/${{ github.event.workflow_run.head_sha }}) - **Build Time**: `${{ steps.build-info.outputs.time }}` - - This comment was automatically generated. [View workflow run](https://github.com/${{ github.repository }}/actions/runs/${{ github.event.workflow_run.id }}) - pr-number: ${{ steps.artifact.outputs.pr_number }} - comment-tag: switch-build-result - github-token: ${{ github.token }} diff --git a/README.md b/README.md index 00307312..543c69fd 100644 --- a/README.md +++ b/README.md @@ -237,6 +237,7 @@ entry: a desktop shortcut per game, a Steam entry, or a handheld frontend. | Option | Effect | | --- | --- | | `--game=red` | boot Red, skipping the launcher (`blue`, `yellow`, `gold`, `silver` and `crystal` too, or just `r` / `b` / `y` / `g` / `s` / `c`) | +| `--cart=id` | boot the installed custom cart with that id | | `--slot=2` | load that save slot; takes a slot number or a slot id | | `--launcher` | open the launcher anyway, so you can edit a shortcut you already made | | `--no-sync` | skip the save sync a linked device otherwise runs before the game boots (`POKEPORT_LAUNCH_SYNC=0`) | @@ -246,9 +247,48 @@ If this device is linked for save sync, a shortcut now syncs before it boots so CONTINUE never loads a save another device has already moved past. The screen shows what it is doing and any button skips straight into the game; a sync conflict opens the launcher so you can pick a copy rather than booting -over one. Android launcher intents take the same path, on the environment -defaults above since an intent carries no arguments, and never run the update -check. +over one. + +Android and iOS also accept the same launch request as a URL: + +```text +gen1recomp++://launch?game=red +``` + +The URL parameters correspond to the desktop options: + +| URL | Effect | +| --- | --- | +| `gen1recomp++://launch?game=red` | boot Red directly | +| `gen1recomp++://launch?game=red&cart=my_cart` | boot the installed custom cart `my_cart` | +| `gen1recomp++://launch?game=red&slot=2` | boot Red and select save slot 2 | +| `gen1recomp++://launch?game=red&launcher=1` | open the launcher on Red instead | +| `gen1recomp++://launch?game=red&sync=0` | skip save sync | +| `gen1recomp++://launch?game=red&update=1` | check for an update before booting | + +The `game` value accepts the same full names and aliases as `--game`. Boolean +parameters accept `1`/`0`, `true`/`false`, `yes`/`no`, and `on`/`off`. Percent-encode +values that contain characters reserved by URLs; the app decodes query values +before applying them. `cart` is the installed cart id shown in the Custom Carts +screen. Unknown parameters are ignored, and an invalid game or cart falls back +to the launcher. + +To test a link on Android, use the installed application package: + +```bash +adb shell am start -a android.intent.action.VIEW \ + -d 'gen1recomp++://launch?game=red' \ + com.theboisclub.pokemonred +``` + +To test a link in the iOS Simulator: + +```bash +xcrun simctl openurl booted 'gen1recomp++://launch?game=red' +``` + +On a physical iPhone or iPad, open the URL from another app that can hand off +custom URLs, such as Notes, Messages, or Safari. ## Linux desktop (AppImage / Flatpak) @@ -287,6 +327,12 @@ Every release ships `gen1recomp++-*-ios.ipa`. Sideload it with AltStore build and install from source on a Mac instead, see [docs/ios-install.md](docs/ios-install.md). +On iOS, long-press an imported game cartridge in the launcher and choose the +Home Screen action. For custom carts, open Custom Carts and use Home Screen on +the cart row you want. iOS opens a configuration profile in Safari; approve it +from Settings when prompted. The generated entry keeps the game's or cart's +artwork and launches through the same `gen1recomp++://launch` URL format. +
Add to SideStore   diff --git a/docs/switch-build.md b/docs/switch-build.md index f84a4d11..cb4095be 100644 --- a/docs/switch-build.md +++ b/docs/switch-build.md @@ -188,8 +188,8 @@ or the Switch-related workflow YAML), CI runs: head is that repo (same-repo push/PR). Fork CI never runs fused. Fork PRs into the main repo also skip Switch fused (offline selftest still runs) so untrusted head code is not executed on the self-hosted Mac; iOS device build eligibility is unchanged. Fused also waits for a successful offline selftest before starting on the Mac runner. 3. On successful PR fused builds, a follow-up workflow posts a PR comment linking the Actions artifact named `gen1recomp-switch-nro` - (comment tag `switch-build-result`; see - `.github/workflows/switch-artifact-comment.yml`). + (comment tag `platform-build-result`; see + `.github/workflows/platform-artifact-comment.yml`). Unrelated PRs do not burn the self-hosted Mac on Switch packaging. diff --git a/main.lua b/main.lua index 1fc2c818..7e0d632f 100644 --- a/main.lua +++ b/main.lua @@ -22,6 +22,16 @@ local PlatformHooks = require("src.core.PlatformHooks") local HostDisplay = require("src.core.HostDisplay") local GameViewport = require("src.render.GameViewport") +local function applySavedOrientation() + local ok, savedOptions = pcall(function() + return require("src.core.SaveData").loadOptions() + end) + if not ok or type(savedOptions) ~= "table" then savedOptions = {} end + pcall(function() + require("src.core.Orientation").applyOptions(savedOptions) + end) +end + -- Global emergency quit: holding Start + Select for 5 seconds forcefully terminates LOVE. local emergencyQuitTimer = 0 @@ -125,6 +135,7 @@ local Game, EditorApp, Importer, TouchEditor, Studio, Prelaunch -- launcher, whatever put the game on screen this time. local launchedIntoGame = false local RELAUNCH_MARKER = "relaunch_to_launcher.txt" +local launchOptionsSuppressed = false local onlineClient, onlineClientResolved local function onlineClientModule() @@ -405,8 +416,7 @@ local function returnToLauncher(opts) SessionLifecycle.endMountedSession(currentVersion) - require("src.core.Orientation").applyOptions( - require("src.core.SaveData").loadOptions()) + applySavedOrientation() local preload = require("src.mods.LauncherMods").translationStrings() if preload then require("src.core.Strings").load({ strings = preload }) end @@ -510,6 +520,71 @@ function bootGame(version, cartId, opts) Game.speedOverride = (autopilot or driverCo) and 1 or speedOverride end +local function showLauncher(version) + LaunchOptions.pendingTab = version + if not Importer then + Importer = makeLauncher({ initialTab = version }) + end +end + +local function startLaunchRequest(request) + if type(request) ~= "table" then return false end + + local version = request.game + if request.launcher or not version then + if Game then + returnToLauncher({ tab = version }) + else + showLauncher(version) + end + return true + end + + local RomImporter = require("src.import.RomImporter") + if Game then returnToLauncher() end + Importer = nil + if Prelaunch then return true end + + local cartId + if request.cartSpecified then + local ok, cart = pcall(function() + return require("src.carts.CartStore").get(request.cart) + end) + if not ok or type(cart) ~= "table" or cart.base ~= version then + showLauncher(version) + return true + end + cartId = request.cart + end + + if not RomImporter.isReady(version) then + showLauncher(version) + return true + end + + local function bootShortcut() + if request.slot then LaunchOptions.selectSlot(version, request.slot) end + launchedIntoGame = true + bootGame(version, cartId) + end + + Prelaunch = require("src.core.Prelaunch").new({ + version = version, + tasks = request.tasks or {}, + done = function(outcome) + if outcome == "restart" then return end + Prelaunch = nil + if outcome == "launcher" then + showLauncher(version) + return + end + bootShortcut() + end, + }) + if not Prelaunch then bootShortcut() end + return true +end + function love.load(args) -- Before anything can shell out (update check, mod index, ROM picker), -- claim one hidden console on Windows so those children inherit it instead @@ -555,8 +630,7 @@ function love.load(args) -- shows: SDL created the window with no orientation hint, so without this -- the launcher would rotate freely until options are applied at boot. -- No-op on desktop / iOS / when options.lua does not exist yet. - require("src.core.Orientation").applyOptions( - require("src.core.SaveData").loadOptions()) + applySavedOrientation() -- Standalone editor. A bare `--editor` run has no launcher behind it, so -- Close quits; --save points it at a specific file, otherwise it opens the @@ -573,13 +647,14 @@ function love.load(args) end local RomImporter = require("src.import.RomImporter") + local resolvedLaunch = LaunchOptions.resolveRequest(arg, args) local forceImport = os.getenv("POKEPORT_FORCE_IMPORT") == "1" local importPath = os.getenv("POKEPORT_IMPORT_ROM") -- Scripted / headless runs pick their game from POKEPORT_VERSION, then -- POKEPORT_GAME / --game= (LaunchOptions), then Red. Drivers for Gold -- must honor POKEPORT_GAME=gold the same way a desktop shortcut does. local scriptedVersion = os.getenv("POKEPORT_VERSION") - or LaunchOptions.resolve(arg) + or resolvedLaunch.game or "red" local ready = RomImporter.isReady(scriptedVersion) -- Scripted / headless runs have to reach the game with no human pressing @@ -645,36 +720,17 @@ function love.load(args) -- would boot the same game again and that close would restart again, -- forever (#887). Consumed on read, so the very next launch is normal. local relaunched = love.filesystem.getInfo(RELAUNCH_MARKER) ~= nil - if relaunched then pcall(love.filesystem.remove, RELAUNCH_MARKER) end + if relaunched then + launchOptionsSuppressed = true + pcall(love.filesystem.remove, RELAUNCH_MARKER) + end - local launchGame, launchSlot = LaunchOptions.resolve(arg) - if launchGame and not relaunched and not LaunchOptions.forceLauncher(arg) then - if RomImporter.isReady(launchGame) then - local function bootShortcut() - if launchSlot then LaunchOptions.selectSlot(launchGame, launchSlot) end - launchedIntoGame = true - bootGame(launchGame) - end - Prelaunch = require("src.core.Prelaunch").new({ - version = launchGame, - tasks = LaunchOptions.tasks(args, arg), - done = function(outcome) - if outcome == "restart" then return end - Prelaunch = nil - if outcome == "launcher" then - LaunchOptions.pendingTab = launchGame - Importer = makeLauncher() - return - end - bootShortcut() - end, - }) - if not Prelaunch then bootShortcut() end - return - end - -- Not importable yet: open the launcher already showing that game, so the - -- shortcut still lands the player where they meant to go. - LaunchOptions.pendingTab = launchGame + if not relaunched and resolvedLaunch.game and not resolvedLaunch.launcher + and startLaunchRequest(resolvedLaunch) then + return + end + if not relaunched and resolvedLaunch.launcher and resolvedLaunch.game then + LaunchOptions.pendingTab = resolvedLaunch.game end -- Interactive: the launcher always runs. Red, Blue, Yellow, and Gold are @@ -695,6 +751,8 @@ function love.update(dt) if editorMode then return EditorApp.update(dt) end if TouchEditor then return TouchEditor.update(dt) end if Studio then return Studio.update(dt) end + local launchURI = LaunchOptions.pollURI() + if launchURI then love.handlers.intent_uri(launchURI) end if Prelaunch then return Prelaunch:update(dt) end local client = onlineClientModule() if client then pcall(client.update, dt) end @@ -1035,42 +1093,17 @@ function love.handlers.audioreset() end function love.handlers.intent_game(version) - if type(version) ~= "string" or version == "" then return end - version = version:lower():gsub("^%s+", ""):gsub("%s+$", "") - local GameVersion = require("src.core.GameVersion") - if GameVersion.VERSIONS and not GameVersion.VERSIONS[version] then return end + local request = LaunchOptions.fromGame(version) + if request then startLaunchRequest(request) end +end - local RomImporter = require("src.import.RomImporter") - if not RomImporter.isReady(version) then return end - - local currentVersion = GameVersion.get() - if Game and currentVersion == version then - return +function love.handlers.intent_uri(uri) + local request = LaunchOptions.parseURI(uri) + if request then + startLaunchRequest(request) + elseif LaunchOptions.isLaunchURI(uri) then + startLaunchRequest({}) end - - if Game then - returnToLauncher() - end - Importer = nil - if Prelaunch then return end - - local tasks = LaunchOptions.tasks(arg, arg) - tasks.update = false - Prelaunch = require("src.core.Prelaunch").new({ - version = version, - tasks = tasks, - done = function(outcome) - if outcome == "restart" then return end - Prelaunch = nil - if outcome == "launcher" then - LaunchOptions.pendingTab = version - Importer = makeLauncher() - return - end - bootGame(version) - end, - }) - if not Prelaunch then bootGame(version) end end function love.touchpressed(id, x, y, dx, dy, pressure) @@ -1327,6 +1360,13 @@ function love.quit() end function love.filedropped(file) + local filename = file and file.getFilename and file:getFilename() + if LaunchOptions.isLaunchURI(filename) then + local request = LaunchOptions.parseURI(filename) + if launchOptionsSuppressed then return end + startLaunchRequest(request or {}) + return + end if editorMode and EditorApp and EditorApp.filedropped then return EditorApp.filedropped(file) end diff --git a/mobile/android/README.md b/mobile/android/README.md index b77d4ab2..a8067cd6 100644 --- a/mobile/android/README.md +++ b/mobile/android/README.md @@ -30,6 +30,27 @@ git submodule update --init --force --recursive In the repository directory. For the last command, add `--depth 1` if needed. +Launch URLs +----------- + +The gen1recomp application accepts launch URLs using the `gen1recomp++` scheme: + +```text +gen1recomp++://launch?game=red +gen1recomp++://launch?game=red&slot=2 +gen1recomp++://launch?game=red&launcher=1 +``` + +The complete parameter list and iOS testing command are documented in the +repository [Launch Options](../../README.md#launch-options) section. Test an +installed Android build with: + +```bash +adb shell am start -a android.intent.action.VIEW \ + -d 'gen1recomp++://launch?game=red' \ + com.theboisclub.pokemonred +``` + Instructions: ------------- diff --git a/mobile/android/app/src/main/AndroidManifest.xml b/mobile/android/app/src/main/AndroidManifest.xml index 6c8010de..e431119b 100644 --- a/mobile/android/app/src/main/AndroidManifest.xml +++ b/mobile/android/app/src/main/AndroidManifest.xml @@ -69,6 +69,12 @@ + + + + + + diff --git a/mobile/android/love/src/jni/love/src/common/android.cpp b/mobile/android/love/src/jni/love/src/common/android.cpp index fd446869..52d7ee3d 100644 --- a/mobile/android/love/src/jni/love/src/common/android.cpp +++ b/mobile/android/love/src/jni/love/src/common/android.cpp @@ -381,6 +381,38 @@ std::string getLaunchGame() return result; } +std::string getLaunchURI() +{ + JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv(); + jclass activity = env->FindClass("org/love2d/android/GameActivity"); + if (activity == nullptr) + return ""; + + jmethodID method = env->GetStaticMethodID(activity, "getLaunchURI", "()Ljava/lang/String;"); + if (method == nullptr) + { + env->ExceptionClear(); + env->DeleteLocalRef(activity); + return ""; + } + + jstring juri = (jstring) env->CallStaticObjectMethod(activity, method); + if (juri == nullptr) + { + env->DeleteLocalRef(activity); + return ""; + } + + const char *str = env->GetStringUTFChars(juri, nullptr); + std::string result = (str != nullptr) ? str : ""; + if (str != nullptr) + env->ReleaseStringUTFChars(juri, str); + + env->DeleteLocalRef(juri); + env->DeleteLocalRef(activity); + return result; +} + bool httpDownload(const char *url, const char *destPath, const char *userAgent, const char *accept) { if (url == nullptr || destPath == nullptr) @@ -1601,6 +1633,20 @@ static void pushGameIntentEvent(const char *game) msg->release(); } +static void pushLaunchURIEvent(const char *uri) +{ + auto eventmodule = love::Module::getInstance(love::Module::M_EVENT); + if (eventmodule == nullptr || uri == nullptr) + return; + + std::vector args; + args.push_back(love::Variant(std::string(uri))); + + love::event::Message *msg = new love::event::Message("intent_uri", args); + eventmodule->push(msg); + msg->release(); +} + extern "C" JNIEXPORT void JNICALL Java_org_love2d_android_GameActivity_nativeOnGameIntent(JNIEnv *env, jclass cls, jstring game) { @@ -1615,4 +1661,18 @@ Java_org_love2d_android_GameActivity_nativeOnGameIntent(JNIEnv *env, jclass cls, } } +extern "C" JNIEXPORT void JNICALL +Java_org_love2d_android_GameActivity_nativeOnLaunchURI(JNIEnv *env, jclass cls, jstring uri) +{ + (void) cls; + if (uri == nullptr) + return; + const char *str = env->GetStringUTFChars(uri, nullptr); + if (str != nullptr) + { + pushLaunchURIEvent(str); + env->ReleaseStringUTFChars(uri, str); + } +} + #endif // LOVE_ANDROID diff --git a/mobile/android/love/src/jni/love/src/common/android.h b/mobile/android/love/src/jni/love/src/common/android.h index 64a493fd..ba420b0f 100644 --- a/mobile/android/love/src/jni/love/src/common/android.h +++ b/mobile/android/love/src/jni/love/src/common/android.h @@ -105,6 +105,7 @@ bool updateAppShortcuts(const std::vector &versions); * Returns the game version requested via initial launch Intent (if any). **/ std::string getLaunchGame(); +std::string getLaunchURI(); /** * Blocking HTTPS GET into destPath (GameActivity.httpDownload). Android has diff --git a/mobile/android/love/src/jni/love/src/modules/system/System.cpp b/mobile/android/love/src/jni/love/src/modules/system/System.cpp index 68d1cd5a..1dd3b56b 100644 --- a/mobile/android/love/src/jni/love/src/modules/system/System.cpp +++ b/mobile/android/love/src/jni/love/src/modules/system/System.cpp @@ -275,6 +275,15 @@ std::string System::getLaunchGame() const #endif } +std::string System::getLaunchURI() const +{ +#ifdef LOVE_ANDROID + return love::android::getLaunchURI(); +#else + return ""; +#endif +} + bool System::httpDownload(const char *url, const char *destPath, const char *userAgent, const char *accept) const { diff --git a/mobile/android/love/src/jni/love/src/modules/system/System.h b/mobile/android/love/src/jni/love/src/modules/system/System.h index a15c3bea..7ed44292 100644 --- a/mobile/android/love/src/jni/love/src/modules/system/System.h +++ b/mobile/android/love/src/jni/love/src/modules/system/System.h @@ -148,6 +148,7 @@ public: virtual bool updateShortcuts(const std::vector &versions) const; virtual std::string getLaunchGame() const; + virtual std::string getLaunchURI() const; /** * Blocking HTTPS GET into an absolute host path (Android only; false diff --git a/mobile/android/love/src/jni/love/src/modules/system/wrap_System.cpp b/mobile/android/love/src/jni/love/src/modules/system/wrap_System.cpp index 0f65b740..fa8d2eca 100644 --- a/mobile/android/love/src/jni/love/src/modules/system/wrap_System.cpp +++ b/mobile/android/love/src/jni/love/src/modules/system/wrap_System.cpp @@ -319,6 +319,16 @@ int w_getLaunchGame(lua_State *L) return 1; } +int w_getLaunchURI(lua_State *L) +{ + std::string uri = instance()->getLaunchURI(); + if (uri.empty()) + lua_pushnil(L); + else + luax_pushstring(L, uri); + return 1; +} + static const luaL_Reg functions[] = { { "getOS", w_getOS }, @@ -336,6 +346,7 @@ static const luaL_Reg functions[] = { "installApk", w_installApk }, { "updateShortcuts", w_updateShortcuts }, { "getLaunchGame", w_getLaunchGame }, + { "getLaunchURI", w_getLaunchURI }, { "httpDownload", w_httpDownload }, { "httpPost", w_httpPost }, { "httpRequest", w_httpRequest }, diff --git a/mobile/android/love/src/main/java/org/love2d/android/GameActivity.java b/mobile/android/love/src/main/java/org/love2d/android/GameActivity.java index f41baae0..eeca2f56 100644 --- a/mobile/android/love/src/main/java/org/love2d/android/GameActivity.java +++ b/mobile/android/love/src/main/java/org/love2d/android/GameActivity.java @@ -172,7 +172,10 @@ public class GameActivity extends SDLActivity { private static native void nativeOnGameIntent(String game); + private static native void nativeOnLaunchURI(String uri); + private static String initialGame = ""; + private static String initialLaunchURI = ""; private AudioManager.OnAudioFocusChangeListener audioFocusListener = null; private Object audioFocusRequest = null; @@ -247,6 +250,10 @@ public class GameActivity extends SDLActivity { if (startIntent != null && startIntent.hasExtra("game")) { initialGame = startIntent.getStringExtra("game"); } + Uri launchURI = getLaunchURI(startIntent); + if (launchURI != null) { + initialLaunchURI = launchURI.toString(); + } if (!embed) { Intent intent = getIntent(); handleIntent(intent); @@ -280,19 +287,34 @@ public class GameActivity extends SDLActivity { @Override protected void onNewIntent(Intent intent) { Log.d("GameActivity", "onNewIntent() with " + intent); - if (intent != null && intent.hasExtra("game")) { + Uri launchURI = getLaunchURI(intent); + if (launchURI != null) { + nativeOnLaunchURI(launchURI.toString()); + } else if (intent != null && intent.hasExtra("game")) { String game = intent.getStringExtra("game"); if (game != null && !game.isEmpty()) { nativeOnGameIntent(game); } } - if (!embed) { + if (!embed && launchURI == null) { handleIntent(intent); resetNative(); startNative(); } } + private static Uri getLaunchURI(Intent intent) { + if (intent == null) return null; + Uri uri = intent.getData(); + if (uri == null) return null; + String scheme = uri.getScheme(); + String host = uri.getHost(); + if (scheme == null || host == null) return null; + if (!"gen1recomp++".equalsIgnoreCase(scheme)) return null; + if (!"launch".equalsIgnoreCase(host)) return null; + return uri; + } + protected void handleIntent(Intent intent) { Uri game = intent.getData(); @@ -855,6 +877,11 @@ public class GameActivity extends SDLActivity { return initialGame != null ? initialGame : ""; } + @Keep + public static String getLaunchURI() { + return initialLaunchURI != null ? initialLaunchURI : ""; + } + @Keep public static boolean updateAppShortcuts(String[] readyVersions) { GameActivity self = (GameActivity) mSingleton; diff --git a/mobile/ios/README.md b/mobile/ios/README.md index 3eb908e8..00db2f8a 100644 --- a/mobile/ios/README.md +++ b/mobile/ios/README.md @@ -19,6 +19,31 @@ The build enables `UIFileSharingEnabled` and Files and Finder. Files copied into the app's Documents directory are used by the game on its next activation. +## Launch URLs + +The app registers the `gen1recomp++` URL scheme. Use the shared launch format +to start a game or open the launcher: + +```text +gen1recomp++://launch?game=red +gen1recomp++://launch?game=red&slot=2 +gen1recomp++://launch?game=red&launcher=1 +``` + +The complete parameter list and Android testing command are documented in the +repository [Launch Options](../../README.md#launch-options) section. The iOS +Simulator can open a URL with: + +```bash +xcrun simctl openurl booted 'gen1recomp++://launch?game=red' +``` + +On a device, long-press an imported game cartridge to open its actions and +choose Home Screen. Custom carts have their own Home Screen action in the +Custom Carts list. Approve the downloaded configuration profile from Settings +to add the entry. The entry uses the game or cart label artwork and launches +through the shared URL scheme. + Existing installations are migrated automatically. Files from the old private `Application Support/pokemon-love2d` directory are merged into Documents on launch; conflicts are retained with a `.legacy` suffix. diff --git a/mobile/ios/native/GRBootstrap.m b/mobile/ios/native/GRBootstrap.m index f41439a4..9d1c1fb9 100644 --- a/mobile/ios/native/GRBootstrap.m +++ b/mobile/ios/native/GRBootstrap.m @@ -6,10 +6,326 @@ // Registered from a constructor so no LÖVE/SDL source needs to know about it. #import +#import #import +static NSString *GRPendingLaunchURI; +static IMP GRApplicationOpenURLOriginal; +static IMP GRApplicationOpenURLLegacyOriginal; +static IMP GRApplicationConfigurationOriginal; +static IMP GRApplicationWillFinishLaunchingOriginal; +static IMP GRApplicationDidFinishLaunchingOriginal; +static IMP GRApplicationSetDelegateOriginal; +static Class GRApplicationDelegateClass; +static IMP GRSceneWillConnectOriginal; +static IMP GRSceneOpenURLContextsOriginal; +static IMP GRSceneSetDelegateOriginal; +static Class GRSceneDelegateClass; + +static void GRInstallApplicationURLHooks(void); +static void GRInstallSceneURLHooksForClass(Class sceneDelegateClass); + +static BOOL GRIsLaunchURL(NSURL *url) +{ + return url.scheme.length > 0 && url.host.length > 0 + && [url.scheme caseInsensitiveCompare:@"gen1recomp++"] == NSOrderedSame + && [url.host caseInsensitiveCompare:@"launch"] == NSOrderedSame; +} + +static void GRStoreLaunchURL(NSURL *url) +{ + if (!GRIsLaunchURL(url)) return; + @synchronized ([UIApplication class]) { + GRPendingLaunchURI = [url.absoluteString copy]; + } +} + +static NSString *GRTakeLaunchURI(void) +{ + @synchronized ([UIApplication class]) { + NSString *uri = [GRPendingLaunchURI copy]; + GRPendingLaunchURI = nil; + return uri; + } +} + +static void GRStoreLaunchURLContexts(NSSet *contexts) +{ + for (id context in contexts) { + if (![context respondsToSelector:@selector(URL)]) continue; + GRStoreLaunchURL([context URL]); + } +} + +static void GRStoreLaunchOptions(NSDictionary *options) +{ + if (![options isKindOfClass:[NSDictionary class]]) return; + GRStoreLaunchURL(options[UIApplicationLaunchOptionsURLKey]); + NSDictionary *activities = options[UIApplicationLaunchOptionsUserActivityDictionaryKey]; + if (![activities isKindOfClass:[NSDictionary class]]) return; + for (id activity in activities.allValues) { + if (![activity respondsToSelector:@selector(webpageURL)]) continue; + GRStoreLaunchURL([activity webpageURL]); + } +} + +static BOOL GRApplicationOpenURL(id self, SEL selector, + UIApplication *application, NSURL *url, + NSDictionary *options) +{ + GRStoreLaunchURL(url); + if (GRApplicationOpenURLOriginal) { + typedef BOOL (*GROpenURL)(id, SEL, UIApplication *, NSURL *, NSDictionary *); + return ((GROpenURL)GRApplicationOpenURLOriginal)(self, selector, + application, url, options); + } + return YES; +} + +static BOOL GRApplicationOpenURLLegacy(id self, SEL selector, + UIApplication *application, NSURL *url, + NSString *sourceApplication, + id annotation) +{ + GRStoreLaunchURL(url); + if (GRApplicationOpenURLLegacyOriginal) { + typedef BOOL (*GROpenURLLegacy)(id, SEL, UIApplication *, NSURL *, NSString *, id); + return ((GROpenURLLegacy)GRApplicationOpenURLLegacyOriginal)( + self, selector, application, url, sourceApplication, annotation); + } + return YES; +} + +static BOOL GRApplicationWillFinishLaunching(id self, SEL selector, + UIApplication *application, + NSDictionary *options) +{ + GRStoreLaunchOptions(options); + if (GRApplicationWillFinishLaunchingOriginal) { + typedef BOOL (*GRWillFinishLaunching)(id, SEL, UIApplication *, NSDictionary *); + return ((GRWillFinishLaunching)GRApplicationWillFinishLaunchingOriginal)( + self, selector, application, options); + } + return YES; +} + +static BOOL GRApplicationDidFinishLaunching(id self, SEL selector, + UIApplication *application, + NSDictionary *options) +{ + GRStoreLaunchOptions(options); + if (GRApplicationDidFinishLaunchingOriginal) { + typedef BOOL (*GRDidFinishLaunching)(id, SEL, UIApplication *, NSDictionary *); + return ((GRDidFinishLaunching)GRApplicationDidFinishLaunchingOriginal)( + self, selector, application, options); + } + return YES; +} + +static id GRApplicationConfiguration(id self, SEL selector, + UIApplication *application, + id session, id options) +{ + if ([options respondsToSelector:@selector(URLContexts)]) { + GRStoreLaunchURLContexts([options URLContexts]); + } + if (GRApplicationConfigurationOriginal) { + typedef id (*GRConfiguration)(id, SEL, UIApplication *, id, id); + return ((GRConfiguration)GRApplicationConfigurationOriginal)( + self, selector, application, session, options); + } + return nil; +} + +static void GRSceneWillConnect(id self, SEL selector, id scene, + id session, id options) +{ + if ([options respondsToSelector:@selector(URLContexts)]) { + GRStoreLaunchURLContexts([options URLContexts]); + } + if (GRSceneWillConnectOriginal) { + typedef void (*GRWillConnect)(id, SEL, id, id, id); + ((GRWillConnect)GRSceneWillConnectOriginal)( + self, selector, scene, session, options); + } +} + +static void GRSceneOpenURLContexts(id self, SEL selector, id scene, + NSSet *contexts) +{ + GRStoreLaunchURLContexts(contexts); + if (GRSceneOpenURLContextsOriginal) { + typedef void (*GROpenURLContexts)(id, SEL, id, NSSet *); + ((GROpenURLContexts)GRSceneOpenURLContextsOriginal)( + self, selector, scene, contexts); + } +} + +static void GRApplicationSetDelegate(id self, SEL selector, id delegate) +{ + typedef void (*GRSetDelegate)(id, SEL, id); + ((GRSetDelegate)GRApplicationSetDelegateOriginal)(self, selector, delegate); + GRInstallApplicationURLHooks(); + if (delegate) GRInstallSceneURLHooksForClass([delegate class]); +} + +static void GRSceneSetDelegate(id self, SEL selector, id delegate) +{ + typedef void (*GRSetDelegate)(id, SEL, id); + ((GRSetDelegate)GRSceneSetDelegateOriginal)(self, selector, delegate); + if (delegate) GRInstallSceneURLHooksForClass([delegate class]); +} + +static void GRInstallDelegateURLHooks(void) +{ + Method applicationSetDelegate = class_getInstanceMethod( + [UIApplication class], @selector(setDelegate:)); + if (applicationSetDelegate && !GRApplicationSetDelegateOriginal) { + GRApplicationSetDelegateOriginal = method_getImplementation(applicationSetDelegate); + method_setImplementation(applicationSetDelegate, (IMP)GRApplicationSetDelegate); + } + + Method sceneSetDelegate = class_getInstanceMethod([UIScene class], @selector(setDelegate:)); + if (sceneSetDelegate && !GRSceneSetDelegateOriginal) { + GRSceneSetDelegateOriginal = method_getImplementation(sceneSetDelegate); + method_setImplementation(sceneSetDelegate, (IMP)GRSceneSetDelegate); + } +} + +static void GRInstallSceneURLHooksForClass(Class sceneDelegateClass) +{ + if (!sceneDelegateClass || sceneDelegateClass == GRSceneDelegateClass) return; + SEL willConnect = @selector(scene:willConnectToSession:options:); + SEL openURLContexts = @selector(scene:openURLContexts:); + Method willConnectMethod = class_getInstanceMethod(sceneDelegateClass, willConnect); + Method openURLContextsMethod = class_getInstanceMethod(sceneDelegateClass, openURLContexts); + + GRSceneDelegateClass = sceneDelegateClass; + GRSceneWillConnectOriginal = willConnectMethod + ? method_getImplementation(willConnectMethod) : NULL; + GRSceneOpenURLContextsOriginal = openURLContextsMethod + ? method_getImplementation(openURLContextsMethod) : NULL; + + if (willConnectMethod) { + if (!class_addMethod(sceneDelegateClass, willConnect, + (IMP)GRSceneWillConnect, + method_getTypeEncoding(willConnectMethod))) { + method_setImplementation(willConnectMethod, (IMP)GRSceneWillConnect); + } + } else { + class_addMethod(sceneDelegateClass, willConnect, + (IMP)GRSceneWillConnect, "v@:@@@"); + } + if (openURLContextsMethod) { + if (!class_addMethod(sceneDelegateClass, openURLContexts, + (IMP)GRSceneOpenURLContexts, + method_getTypeEncoding(openURLContextsMethod))) { + method_setImplementation(openURLContextsMethod, + (IMP)GRSceneOpenURLContexts); + } + } else { + class_addMethod(sceneDelegateClass, openURLContexts, + (IMP)GRSceneOpenURLContexts, "v@:@@"); + } +} + +static void GRInstallSceneURLHooks(void) +{ + for (UIScene *scene in UIApplication.sharedApplication.connectedScenes) { + id delegate = scene.delegate; + if (delegate) GRInstallSceneURLHooksForClass([delegate class]); + } +} + +static void GRInstallApplicationURLHooks(void) +{ + id delegate = UIApplication.sharedApplication.delegate; + Class delegateClass = delegate ? [delegate class] : Nil; + if (!delegateClass || delegateClass == GRApplicationDelegateClass) return; + GRApplicationDelegateClass = delegateClass; + GRApplicationOpenURLOriginal = NULL; + GRApplicationOpenURLLegacyOriginal = NULL; + GRApplicationConfigurationOriginal = NULL; + GRApplicationWillFinishLaunchingOriginal = NULL; + GRApplicationDidFinishLaunchingOriginal = NULL; + + SEL willFinish = @selector(application:willFinishLaunchingWithOptions:); + Method willFinishMethod = class_getInstanceMethod(delegateClass, willFinish); + if (willFinishMethod) { + GRApplicationWillFinishLaunchingOriginal = method_getImplementation(willFinishMethod); + if (!class_addMethod(delegateClass, willFinish, (IMP)GRApplicationWillFinishLaunching, + method_getTypeEncoding(willFinishMethod))) { + method_setImplementation(willFinishMethod, (IMP)GRApplicationWillFinishLaunching); + } + } else { + class_addMethod(delegateClass, willFinish, (IMP)GRApplicationWillFinishLaunching, + "c@:@@"); + } + + SEL didFinish = @selector(application:didFinishLaunchingWithOptions:); + Method didFinishMethod = class_getInstanceMethod(delegateClass, didFinish); + if (didFinishMethod) { + GRApplicationDidFinishLaunchingOriginal = method_getImplementation(didFinishMethod); + if (!class_addMethod(delegateClass, didFinish, (IMP)GRApplicationDidFinishLaunching, + method_getTypeEncoding(didFinishMethod))) { + method_setImplementation(didFinishMethod, (IMP)GRApplicationDidFinishLaunching); + } + } else { + class_addMethod(delegateClass, didFinish, (IMP)GRApplicationDidFinishLaunching, + "c@:@@"); + } + + SEL openURL = @selector(application:openURL:options:); + Method openURLMethod = class_getInstanceMethod(delegateClass, openURL); + if (openURLMethod) { + GRApplicationOpenURLOriginal = method_getImplementation(openURLMethod); + if (!class_addMethod(delegateClass, openURL, (IMP)GRApplicationOpenURL, + method_getTypeEncoding(openURLMethod))) { + method_setImplementation(openURLMethod, (IMP)GRApplicationOpenURL); + } + } else { + class_addMethod(delegateClass, openURL, (IMP)GRApplicationOpenURL, + "c@:@@@"); + } + + SEL configuration = @selector(application:configurationForConnectingSceneSession:options:); + Method configurationMethod = class_getInstanceMethod(delegateClass, configuration); + if (configurationMethod) { + GRApplicationConfigurationOriginal = method_getImplementation(configurationMethod); + if (!class_addMethod(delegateClass, configuration, + (IMP)GRApplicationConfiguration, + method_getTypeEncoding(configurationMethod))) { + method_setImplementation(configurationMethod, + (IMP)GRApplicationConfiguration); + } + } + + SEL legacyOpenURL = @selector(application:openURL:sourceApplication:annotation:); + Method legacyMethod = class_getInstanceMethod(delegateClass, legacyOpenURL); + if (legacyMethod) { + GRApplicationOpenURLLegacyOriginal = method_getImplementation(legacyMethod); + if (!class_addMethod(delegateClass, legacyOpenURL, + (IMP)GRApplicationOpenURLLegacy, + method_getTypeEncoding(legacyMethod))) { + method_setImplementation(legacyMethod, (IMP)GRApplicationOpenURLLegacy); + } + } +} + +@interface NSURL (GRWebClipDataURL) +- (BOOL)safari_isHTTPFamilyURL; +@end + +@implementation NSURL (GRWebClipDataURL) +- (BOOL)safari_isHTTPFamilyURL +{ + return YES; +} +@end + @interface GRDeviceBridge : NSObject + (NSString *)deviceModel; ++ (NSString *)launchURI; @end @implementation GRDeviceBridge @@ -26,15 +342,56 @@ } return @""; } + ++ (NSString *)launchURI +{ + return GRTakeLaunchURI() ?: @""; +} @end __attribute__((constructor)) static void GRBootstrapInstall(void) { - [[NSNotificationCenter defaultCenter] - addObserverForName:UIApplicationDidBecomeActiveNotification + GRInstallDelegateURLHooks(); + NSNotificationCenter *center = [NSNotificationCenter defaultCenter]; + [center addObserverForName:UIApplicationDidFinishLaunchingNotification + object:nil + queue:[NSOperationQueue mainQueue] + usingBlock:^(NSNotification *note) { + GRStoreLaunchOptions(note.userInfo); + GRInstallApplicationURLHooks(); + GRInstallSceneURLHooks(); + }]; + [center addObserverForName:UIApplicationDidBecomeActiveNotification + object:nil + queue:[NSOperationQueue mainQueue] + usingBlock:^(NSNotification *note) { + GRInstallApplicationURLHooks(); + GRInstallSceneURLHooks(); + }]; + [center addObserverForName:UISceneWillConnectNotification + object:nil + queue:[NSOperationQueue mainQueue] + usingBlock:^(NSNotification *note) { + GRInstallSceneURLHooks(); + id scene = note.object; + id delegate = [scene respondsToSelector:@selector(delegate)] + ? [scene delegate] : nil; + if (delegate) GRInstallSceneURLHooksForClass([delegate class]); + }]; + [center addObserverForName:UISceneDidActivateNotification + object:nil + queue:[NSOperationQueue mainQueue] + usingBlock:^(NSNotification *note) { + GRInstallSceneURLHooks(); + }]; + dispatch_async(dispatch_get_main_queue(), ^{ + GRInstallApplicationURLHooks(); + GRInstallSceneURLHooks(); + }); + [center addObserverForName:UIApplicationDidBecomeActiveNotification object:nil - queue:[NSOperationQueue mainQueue] + queue:[NSOperationQueue mainQueue] usingBlock:^(NSNotification *note) { Class bridge = NSClassFromString(@"GRPickerBridge"); if ([bridge respondsToSelector:@selector(preparePublicDocuments)]) { diff --git a/mobile/ios/native/GRPickerBridge.swift b/mobile/ios/native/GRPickerBridge.swift index 26de1c85..54e0bf2c 100644 --- a/mobile/ios/native/GRPickerBridge.swift +++ b/mobile/ios/native/GRPickerBridge.swift @@ -14,6 +14,8 @@ import UIKit import UniformTypeIdentifiers import CryptoKit +import Network +import SafariServices @objc(GRPickerBridge) public final class GRPickerBridge: NSObject { @@ -28,6 +30,7 @@ public final class GRPickerBridge: NSObject { private static var liveDelegates: [PickerDelegate] = [] private static let loveIdentity = "pokemon-love2d" + private static var profileServer: NWListener? @objc(httpDownloadWithUrl:destination:userAgent:accept:) public static func httpDownload(url: UnsafePointer?, @@ -186,6 +189,123 @@ public final class GRPickerBridge: NSObject { return envelope } + @objc(installWebClipWithLabel:url:icon:iconLength:) + public static func installWebClip(label: UnsafePointer?, + url: UnsafePointer?, + icon: UnsafePointer?, + iconLength: Int32) -> Bool { + guard let url, let icon, iconLength > 0, + let launchURL = URL(string: String(cString: url)), + launchURL.scheme?.lowercased() == "gen1recomp++", + launchURL.host?.lowercased() == "launch" else { return false } + + let rawLabel = label.map { String(cString: $0) } ?? "gen1recomp++" + let displayName = String(rawLabel.replacingOccurrences(of: "\r", with: " ") + .replacingOccurrences(of: "\n", with: " ").prefix(48)) + guard let source = UIImage(data: Data(bytes: icon, count: Int(iconLength))) else { + return false + } + guard source.size.width > 0, source.size.height > 0 else { return false } + + let iconSize = CGSize(width: 180, height: 180) + let renderer = UIGraphicsImageRenderer(size: iconSize) + let iconData = renderer.pngData(actions: { context in + context.cgContext.setFillColor(UIColor.black.cgColor) + context.cgContext.fill(CGRect(origin: .zero, size: iconSize)) + let scale = min(160 / source.size.width, 160 / source.size.height) + let size = CGSize(width: source.size.width * scale, + height: source.size.height * scale) + let rect = CGRect(x: (iconSize.width - size.width) / 2, + y: (iconSize.height - size.height) / 2, + width: size.width, height: size.height) + source.draw(in: rect) + }) + + let uuid = UUID().uuidString + let payloadIdentifier = "com.theboisclub.gen1recompplusplus.webclip.\(uuid)" + let description = "Web Clip for launching \(displayName) in gen1recomp++" + let webClip: [String: Any] = [ + "FullScreen": true, + "Icon": iconData, + "IsRemovable": true, + "Label": displayName, + "Precomposed": false, + "PayloadDescription": description, + "PayloadDisplayName": displayName, + "PayloadIdentifier": payloadIdentifier, + "PayloadOrganization": "gen1recomp++", + "PayloadType": "com.apple.webClip.managed", + "PayloadUUID": uuid, + "PayloadVersion": 1, + "TargetApplicationBundleIdentifier": "com.theboisclub.gen1recompplusplus", + "URL": launchURL.absoluteString, + ] + let profile: [String: Any] = [ + "ConsentText": [ + "default": "This profile installs a Home Screen entry for \(displayName)" + ], + "PayloadContent": [webClip], + "PayloadDescription": description, + "PayloadDisplayName": displayName, + "PayloadIdentifier": payloadIdentifier, + "PayloadOrganization": "gen1recomp++", + "PayloadRemovalDisallowed": false, + "PayloadType": "Configuration", + "PayloadUUID": UUID().uuidString, + "PayloadVersion": 1, + ] + guard let profileData = try? PropertyListSerialization.data( + fromPropertyList: profile, format: .xml, options: 0) else { + return false + } + + guard let server = try? NWListener(using: .tcp, on: .any) else { + return false + } + profileServer?.cancel() + let serverQueue = DispatchQueue(label: "com.theboisclub.gen1recompplusplus.webclip") + server.newConnectionHandler = { connection in + connection.stateUpdateHandler = { state in + guard case .ready = state else { + if case .failed = state { connection.cancel() } + return + } + connection.receive(minimumIncompleteLength: 1, maximumLength: 8192) { _, _, _, _ in + var response = Data("HTTP/1.1 200 OK\r\nContent-Type: application/x-apple-aspen-config\r\nContent-Disposition: attachment; filename=gen1recomp.mobileconfig\r\nContent-Length: \(profileData.count)\r\nConnection: close\r\n\r\n".utf8) + response.append(profileData) + connection.send(content: response, completion: .contentProcessed { _ in + connection.cancel() + }) + } + } + connection.start(queue: serverQueue) + } + server.stateUpdateHandler = { state in + guard case .ready = state, let port = server.port?.rawValue else { + if case .failed = state { server.cancel() } + return + } + DispatchQueue.main.async { + guard let scene = UIApplication.shared.connectedScenes + .compactMap({ $0 as? UIWindowScene }) + .first(where: { $0.activationState == .foregroundActive }), + let root = scene.windows.first(where: { $0.isKeyWindow })?.rootViewController, + let profileURL = URL(string: "http://127.0.0.1:\(port)/gen1recomp.mobileconfig") else { + server.cancel() + return + } + var presenter = root + while let presented = presenter.presentedViewController { + presenter = presented + } + presenter.present(SFSafariViewController(url: profileURL), animated: true) + } + } + profileServer = server + server.start(queue: serverQueue) + return true + } + // MARK: - Entry points called from liblove (C strings on purpose) @objc(presentPickerWithKind:saveDir:) diff --git a/mobile/ios/overlays/love-ios.plist b/mobile/ios/overlays/love-ios.plist index d2f97a54..9677e4e3 100644 --- a/mobile/ios/overlays/love-ios.plist +++ b/mobile/ios/overlays/love-ios.plist @@ -35,6 +35,17 @@ gen1recomp++ CFBundlePackageType APPL + CFBundleURLTypes + + + CFBundleURLName + gen1recomp++ launch options + CFBundleURLSchemes + + gen1recomp++ + + + CFBundleShortVersionString $(MARKETING_VERSION) CFBundleSignature diff --git a/mobile/ios/patch_love_src.py b/mobile/ios/patch_love_src.py index 9f268443..2d4caf71 100644 --- a/mobile/ios/patch_love_src.py +++ b/mobile/ios/patch_love_src.py @@ -176,6 +176,9 @@ WRAP_REGISTRATION = """#ifdef LOVE_IOS { "syncHealthSteps", w_syncHealthSteps }, { "httpDownload", w_httpDownload }, { "httpRequest", w_httpRequest }, + { "getLaunchURI", w_getLaunchURI }, + { "pollLaunchURI", w_pollLaunchURI }, + { "installWebClip", w_installWebClip }, #endif """ @@ -220,6 +223,9 @@ WRAP_SYNC_REGISTRATION = """#ifdef LOVE_IOS { "syncHealthSteps", w_syncHealthSteps }, { "httpDownload", w_httpDownload }, { "httpRequest", w_httpRequest }, + { "getLaunchURI", w_getLaunchURI }, + { "pollLaunchURI", w_pollLaunchURI }, + { "installWebClip", w_installWebClip }, #endif """ @@ -346,6 +352,69 @@ int w_httpRequest(lua_State *L) lua_pushlstring(L, (const char *) bytes, (size_t) length); return 1; } + +int w_installWebClip(lua_State *L) +{ + const char *label = luaL_optstring(L, 1, "gen1recomp++"); + const char *url = luaL_checkstring(L, 2); + size_t iconLength = 0; + const char *icon = luaL_checklstring(L, 3, &iconLength); + if (iconLength > 0x7fffffff) + { + lua_pushboolean(L, 0); + return 1; + } + Class cls = objc_getClass("GRPickerBridge"); + if (cls == nullptr) + { + lua_pushboolean(L, 0); + return 1; + } + typedef signed char (*GRInstall)(Class, SEL, const char *, const char *, + const unsigned char *, int); + signed char ok = ((GRInstall)objc_msgSend)( + cls, sel_registerName("installWebClipWithLabel:url:icon:iconLength:"), + label, url, (const unsigned char *) icon, (int) iconLength); + lua_pushboolean(L, ok != 0); + return 1; +} + +static int w_launchURI(lua_State *L) +{ + Class cls = objc_getClass("GRDeviceBridge"); + if (cls == nullptr) + { + lua_pushnil(L); + return 1; + } + typedef id (*GRObj)(Class, SEL); + id value = ((GRObj)objc_msgSend)(cls, sel_registerName("launchURI")); + if (value == nullptr) + { + lua_pushnil(L); + return 1; + } + typedef const char *(*GRUTF8)(id, SEL); + const char *bytes = ((GRUTF8)objc_msgSend)(value, + sel_registerName("UTF8String")); + if (bytes == nullptr || bytes[0] == '\\0') + { + lua_pushnil(L); + return 1; + } + lua_pushstring(L, bytes); + return 1; +} + +int w_getLaunchURI(lua_State *L) +{ + return w_launchURI(L); +} + +int w_pollLaunchURI(lua_State *L) +{ + return w_launchURI(L); +} #endif """ diff --git a/scripts/build_android.sh b/scripts/build_android.sh index dbe4ab4b..8c17e916 100755 --- a/scripts/build_android.sh +++ b/scripts/build_android.sh @@ -88,6 +88,7 @@ if [ -n "$VERSION" ]; then fail "--version components exceed Android versionCode limits" fi VERSION_CODE=$((major * 1000000 + minor * 1000 + patch)) + if [ "$VERSION_CODE" -eq 0 ]; then VERSION_CODE=1; fi fi if $RELEASE; then diff --git a/scripts/test.sh b/scripts/test.sh index d7fe201e..8ca30903 100755 --- a/scripts/test.sh +++ b/scripts/test.sh @@ -126,6 +126,7 @@ run_tier "T0 NX generated-path static guard" "$LUA" tests/engine/nx_generated_gu run_tier "T0 NX Yellow/Blue boot (dynamic paths)" "$LUA" tests/engine/nx_yellow_boot_test.lua run_tier "T0 NX Gold cache load (maps.lua prefix)" "$LUA" tests/engine/cache_fs_gold_nx_load_test.lua run_tier "T0 touch-controls pad cursor" "$LUA" tests/engine/touch_controls_pad_cursor_test.lua +run_tier "T0 URI launch arguments" "$LUA" tests/engine/launch_uri_args_test.lua run_tier "T1/T2 engine invariants + parity gates" "$LUA" tests/run_engine.lua # Gen 2 / Crystal: ROM-free (own fixtures, or a self-skip on a missing cache), # so it runs here rather than behind the Red content gate below. diff --git a/src/core/LaunchOptions.lua b/src/core/LaunchOptions.lua index 9939d556..c3182045 100644 --- a/src/core/LaunchOptions.lua +++ b/src/core/LaunchOptions.lua @@ -24,12 +24,67 @@ local GameVersion = require("src.core.GameVersion") local LaunchOptions = {} +local normalizeVersion +local argValue +local argFlag + +local function trim(value) + if type(value) ~= "string" then return nil end + value = value:gsub("^%s+", ""):gsub("%s+$", "") + return value ~= "" and value or nil +end + +local function normalizeCart(value) + value = trim(value) + if not value or #value > 64 or not value:match("^[%w_%-]+$") then + return nil + end + return value +end + +local function encodeURIComponent(value) + return tostring(value):gsub("([^%w%-%._~])", function(char) + return ("%%%02X"):format(char:byte()) + end) +end + +local function decodeURIComponent(value) + local result = {} + local index = 1 + while index <= #value do + local char = value:sub(index, index) + if char == "%" then + local hex = value:sub(index + 1, index + 2) + if not hex:match("^%x%x$") then return nil end + result[#result + 1] = string.char(tonumber(hex, 16)) + index = index + 3 + else + result[#result + 1] = char + index = index + 1 + end + end + return table.concat(result) +end + +local function booleanValue(value) + if value == nil then return nil end + value = trim(value) + if not value then return true end + value = value:lower() + if value == "1" or value == "true" or value == "yes" or value == "on" then + return true + end + if value == "0" or value == "false" or value == "no" or value == "off" then + return false + end + return nil +end -- Set by main.lua when a requested game turns out not to be importable yet: -- the launcher opens on that tab instead of booting. LaunchOptions.pendingTab = nil -local function normalizeVersion(v) +normalizeVersion = function(v) if type(v) ~= "string" then return nil end v = v:lower():gsub("^%s+", ""):gsub("%s+$", "") if v == "" then return nil end @@ -48,7 +103,7 @@ local function normalizeVersion(v) end -- Pull "--flag value" (and "--flag=value") out of LOVE's arg table. -local function argValue(argv, name) +argValue = function(argv, name) if type(argv) ~= "table" then return nil end for i = 1, #argv do local a = argv[i] @@ -61,7 +116,7 @@ local function argValue(argv, name) return nil end -local function argFlag(argv, name) +argFlag = function(argv, name) if type(argv) ~= "table" then return false end for i = 1, #argv do local a = argv[i] @@ -72,53 +127,207 @@ end local cachedIntentGame = nil --- Returns version, slotId (either may be nil). Command line wins over env, --- so a shortcut can override a machine-wide default. -function LaunchOptions.resolve(argv) +local function uriAuthority(uri) + if type(uri) ~= "string" or uri == "" then return nil end + + local withoutFragment = uri:match("^([^#]*)") + local queryStart = withoutFragment:find("?", 1, true) + local authority = queryStart and withoutFragment:sub(1, queryStart - 1) + or withoutFragment + local scheme, host, path = authority:match( + "^([%a][%w+%-%.]*):%/%/([^/]*)(.*)$") + if not scheme or scheme:lower() ~= "gen1recomp++" + or host:lower() ~= "launch" or (path ~= "" and path ~= "/") then + return nil + end + return withoutFragment, queryStart +end + +function LaunchOptions.isLaunchURI(uri) + return uriAuthority(uri) ~= nil +end + +function LaunchOptions.parseURI(uri) + local withoutFragment, queryStart = uriAuthority(uri) + if not withoutFragment then return nil end + local query = queryStart and withoutFragment:sub(queryStart + 1) or "" + + local values = {} + for pair in query:gmatch("[^&]+") do + local equals = pair:find("=", 1, true) + local rawKey = equals and pair:sub(1, equals - 1) or pair + local rawValue = equals and pair:sub(equals + 1) or "" + local key = decodeURIComponent(rawKey) + local value = decodeURIComponent(rawValue) + if not key or not value then return nil end + key = key:lower() + if key ~= "" and values[key] == nil then + values[key] = value + end + end + + local request = { + source = "uri", + game = normalizeVersion(values.game), + cart = normalizeCart(values.cart), + slot = trim(values.slot), + gameSpecified = values.game ~= nil, + cartSpecified = values.cart ~= nil, + } + request.launcher = booleanValue(values.launcher) + request.sync = booleanValue(values.sync) + request.update = booleanValue(values.update) + return request +end + +local function launchURIRaw(method) + if type(love) ~= "table" or type(love.system) ~= "table" + or type(love.system[method]) ~= "function" then + return nil + end + local ok, uri = pcall(love.system[method]) + if not ok or type(uri) ~= "string" or uri == "" then return nil end + return uri +end + +local function launchURIRequest() + return LaunchOptions.parseURI(launchURIRaw("getLaunchURI")) +end + +local function intentGame() if cachedIntentGame == nil then - if love.system and love.system.getOS and love.system.getOS() == "Android" - and love.system.getLaunchGame then + if type(love) == "table" and type(love.system) == "table" + and type(love.system.getOS) == "function" + and love.system.getOS() == "Android" + and type(love.system.getLaunchGame) == "function" then cachedIntentGame = normalizeVersion(love.system.getLaunchGame()) or false else cachedIntentGame = false end end - local intentGame = cachedIntentGame or nil + return cachedIntentGame or nil +end +local function request(argv, rawArgv, uri) local game = normalizeVersion(argValue(argv, "game")) - or intentGame - or normalizeVersion(os.getenv("POKEPORT_GAME")) - or normalizeVersion(os.getenv("POKEPORT_LAUNCH")) - local slot = argValue(argv, "slot") or os.getenv("POKEPORT_SLOT") - if type(slot) == "string" then - slot = slot:gsub("^%s+", ""):gsub("%s+$", "") - if slot == "" then slot = nil end + if not game then + if uri and uri.gameSpecified then + game = uri.game + else + game = intentGame() + or normalizeVersion(os.getenv("POKEPORT_GAME")) + or normalizeVersion(os.getenv("POKEPORT_LAUNCH")) + end end - return game, slot + local slot = trim(argValue(argv, "slot")) + or (uri and uri.slot) + or trim(os.getenv("POKEPORT_SLOT")) + local rawCart = argValue(argv, "cart") + local cart = normalizeCart(rawCart) + local cartSpecified = rawCart ~= nil + if rawCart == nil and uri and uri.cartSpecified then + cart = uri.cart + cartSpecified = true + end + local launcher + if argFlag(argv, "launcher") or argFlag(rawArgv, "launcher") then + launcher = true + elseif uri and uri.launcher ~= nil then + launcher = uri.launcher + else + launcher = os.getenv("POKEPORT_FORCE_LAUNCHER") == "1" + end + return { + game = game, + cart = cart, + slot = slot, + cartSpecified = cartSpecified, + launcher = launcher, + uri = uri, + } +end + +-- Returns version, slotId (either may be nil). Command line wins over env, +-- so a shortcut can override a machine-wide default. +function LaunchOptions.resolve(argv) + local resolved = LaunchOptions.resolveRequest(argv, nil) + return resolved.game, resolved.slot end function LaunchOptions.forceLauncher(argv) - return argFlag(argv, "launcher") or os.getenv("POKEPORT_FORCE_LAUNCHER") == "1" + return LaunchOptions.resolveRequest(argv, nil).launcher end -local function taskFlag(argv, rawArgv, name, env) +local function taskFlag(argv, rawArgv, name, env, uriValue) if argFlag(argv, "no-" .. name) or argFlag(rawArgv, "no-" .. name) then return false end if argFlag(argv, name) or argFlag(rawArgv, name) then return true end + if uriValue ~= nil then return uriValue end local v = os.getenv(env) if v == "1" then return true end if v == "0" then return false end return nil end -function LaunchOptions.tasks(argv, rawArgv) +function LaunchOptions.tasks(argv, rawArgv, uri) + uri = uri or launchURIRequest() return { - sync = taskFlag(argv, rawArgv, "sync", "POKEPORT_LAUNCH_SYNC"), - update = taskFlag(argv, rawArgv, "update", "POKEPORT_LAUNCH_UPDATE") == true, + sync = taskFlag(argv, rawArgv, "sync", "POKEPORT_LAUNCH_SYNC", + uri and uri.sync), + update = taskFlag(argv, rawArgv, "update", "POKEPORT_LAUNCH_UPDATE", + uri and uri.update) == true, } end +function LaunchOptions.resolveRequest(argv, rawArgv) + local uri = launchURIRequest() + local resolved = request(argv, rawArgv, uri) + resolved.tasks = LaunchOptions.tasks(argv, rawArgv, uri) + return resolved +end + +function LaunchOptions.pollURI() + return launchURIRaw("pollLaunchURI") +end + +function LaunchOptions.fromGame(game) + local normalized = normalizeVersion(game) + if not normalized then return nil end + local tasks = LaunchOptions.tasks({}, {}) + tasks.update = false + return { + source = "intent", + game = normalized, + cart = nil, + cartSpecified = false, + slot = nil, + launcher = false, + tasks = tasks, + } +end + +function LaunchOptions.uriFor(version, options) + options = options or {} + version = normalizeVersion(version) + if not version then return nil end + local query = { "game=" .. encodeURIComponent(version) } + local cart = normalizeCart(options.cart) + if cart then query[#query + 1] = "cart=" .. encodeURIComponent(cart) end + local slot = trim(options.slot) + if slot then query[#query + 1] = "slot=" .. encodeURIComponent(slot) end + if options.launcher ~= nil then + query[#query + 1] = "launcher=" .. (options.launcher and "1" or "0") + end + if options.sync ~= nil then + query[#query + 1] = "sync=" .. (options.sync and "1" or "0") + end + if options.update ~= nil then + query[#query + 1] = "update=" .. (options.update and "1" or "0") + end + return "gen1recomp++://launch?" .. table.concat(query, "&") +end + -- Point a version at a save slot before it boots. Accepts either a slot id -- ("slot2") or a 1-based index ("2"), because a shortcut author should not -- have to know the internal id scheme. A slot that does not exist is diff --git a/src/core/Orientation.lua b/src/core/Orientation.lua index 26a867e6..753be5d4 100644 --- a/src/core/Orientation.lua +++ b/src/core/Orientation.lua @@ -50,12 +50,14 @@ function Orientation.modeLabel(mode) end function Orientation.isAndroid() - if not love or not love.system or not love.system.getOS then return false end + if type(love) ~= "table" or type(love.system) ~= "table" + or type(love.system.getOS) ~= "function" then return false end return love.system.getOS() == "Android" end function Orientation.isIOS() - if not love or not love.system or not love.system.getOS then return false end + if type(love) ~= "table" or type(love.system) ~= "table" + or type(love.system.getOS) ~= "function" then return false end return love.system.getOS() == "iOS" end diff --git a/src/core/SaveData.lua b/src/core/SaveData.lua index 90e80472..6a32f5ea 100644 --- a/src/core/SaveData.lua +++ b/src/core/SaveData.lua @@ -126,12 +126,12 @@ end -- what the mods panel needs to notice a mod dropped beside the game by hand -- (LauncherMods.strays). Empty on Android/iOS and outside LOVE. function SaveData.gameFolders() - if not (love and love.filesystem) then return {} end + if type(love) ~= "table" or type(love.filesystem) ~= "table" then return {} end -- Desktop only: portable mode carries the save (and, since issue #74, the -- ROM cache) in the game folder next to the executable/source. On -- Android/iOS the source is a read-only package with no such folder, so -- portable mode never applies there. - if love.system and love.system.getOS then + if type(love.system) == "table" and type(love.system.getOS) == "function" then local osName = love.system.getOS() if osName ~= "Windows" and osName ~= "Linux" and osName ~= "OS X" then return {} diff --git a/src/core/WebClip.lua b/src/core/WebClip.lua new file mode 100644 index 00000000..f2cce448 --- /dev/null +++ b/src/core/WebClip.lua @@ -0,0 +1,73 @@ +local GameVersion = require("src.core.GameVersion") +local LaunchOptions = require("src.core.LaunchOptions") +local CartStore = require("src.carts.CartStore") + +local WebClip = {} + +local WEB_CLIP_TITLES = { + red = "Pokémon Red", + blue = "Pokémon Blue", + yellow = "Pokémon Yellow", + gold = "Pokémon Gold", + silver = "Pokémon Silver", + crystal = "Pokémon Crystal", +} + +local function cleanLabel(value) + value = tostring(value or ""):gsub("[\r\n]", " ") + return value:sub(1, 48) +end + +local function fileBytes(path) + if not (love and love.filesystem and love.filesystem.read) then return nil end + local ok, bytes = pcall(love.filesystem.read, path) + return ok and type(bytes) == "string" and bytes ~= "" and bytes or nil +end + +function WebClip.spec(version, cartId) + local info = GameVersion.info(version) + if not info then return nil end + + local title = WEB_CLIP_TITLES[version] or info.displayName + local artPath = "assets/labels/" .. tostring(version) .. ".png" + if cartId then + local ok, cart = pcall(CartStore.get, cartId) + if not ok or type(cart) ~= "table" or cart.base ~= version then return nil end + title = cart.title or cart.id + local artOk, bytes = pcall(CartStore.labelArt, cartId) + if not artOk or type(bytes) ~= "string" or bytes == "" then + bytes = fileBytes(artPath) + end + return { + title = cleanLabel(title), + url = LaunchOptions.uriFor(version, { cart = cartId }), + icon = bytes, + cart = cartId, + } + end + + return { + title = cleanLabel(title), + url = LaunchOptions.uriFor(version), + icon = fileBytes(artPath), + } +end + +function WebClip.install(version, cartId) + if type(love) ~= "table" or type(love.system) ~= "table" + or type(love.system.installWebClip) ~= "function" then + return false, "Home Screen entries are only available on iOS" + end + local spec = WebClip.spec(version, cartId) + if not spec or not spec.icon or not spec.url then + return false, "This game has no artwork to use for its Home Screen entry" + end + local ok, installed = pcall(love.system.installWebClip, + spec.title, spec.url, spec.icon) + if not ok or installed ~= true then + return false, "iOS could not open the Home Screen entry installer" + end + return true, spec.title +end + +return WebClip diff --git a/src/import/CacheFs.lua b/src/import/CacheFs.lua index deedb517..8edc6f06 100644 --- a/src/import/CacheFs.lua +++ b/src/import/CacheFs.lua @@ -230,7 +230,8 @@ local function resolvePortableRoot() if not resolveMkdir() then return nil end local base = require("src.core.SaveData").portableBaseDir() if not base then return nil end - if love.filesystem.getSource and base == love.filesystem.getSource() then + if type(love.filesystem) == "table" and love.filesystem.getSource + and base == love.filesystem.getSource() then -- source run: the folder is already the physfs source portableRoot = base elseif mountReadable(base) then diff --git a/src/import/LauncherView.lua b/src/import/LauncherView.lua index d7347498..5a2c324c 100644 --- a/src/import/LauncherView.lua +++ b/src/import/LauncherView.lua @@ -35,6 +35,7 @@ local Transition = require("src.ui.kit.Transition") local GameVersion = require("src.core.GameVersion") local Version = require("src.core.Version") local Strings = require("src.core.Strings") +local WebClip = require("src.core.WebClip") local PAL = Theme.PAL local LauncherView = {} @@ -44,6 +45,7 @@ local COMMUNITY_URL = "https://bois.icu" -- One dedup window covers a touch release plus the mouse click SDL -- synthesizes for the same tap. local ACT_DEDUP = 0.35 +local LONG_PRESS_SECONDS = 0.60 -- Finger travel past this (px) is a drag, not a tap. local TAP_SLOP2 = 16 * 16 local MIN_SKIN_ROWS = 4 @@ -249,7 +251,7 @@ function LauncherView.touchpressed(imp, id, x, y) if not imp._flex then return end imp._touchAt = imp._touchAt or {} imp._touchAt[tostring(id)] = { - x = x, y = y, + x = x, y = y, started = love.timer.getTime(), region = tabScrollMax(imp) > 0 and inRect(imp._tabRegionRect, x, y), } end @@ -284,7 +286,7 @@ function LauncherView.touchreleased(imp, id, x, y) if not imp._flex then return end local start = imp._touchAt and imp._touchAt[tostring(id)] if imp._touchAt then imp._touchAt[tostring(id)] = nil end - if start and start.dragged then + if start and (start.dragged or start.longPressed) then -- Suppress the mouse click SDL will synthesize for this same gesture. imp._suppressClickUntil = love.timer.getTime() + ACT_DEDUP return @@ -297,6 +299,25 @@ function LauncherView.touchreleased(imp, id, x, y) imp._clickPt = { x = x, y = y } end +local function triggerLongPress(imp, x, y, w, h, version) + if not imp.ios or imp._modalUpNow then return false end + local touches = imp._touchAt + if not touches then return false end + local now = love.timer.getTime() + for _, touch in pairs(touches) do + if not touch.dragged and not touch.longPressed + and inRect({ x = x, y = y, w = w, h = h }, touch.x, touch.y) + and now - (touch.started or now) >= LONG_PRESS_SECONDS then + touch.longPressed = true + imp._suppressClickUntil = now + ACT_DEDUP + imp._clickPt = nil + imp._gameManage = version + return true + end + end + return false +end + -- Synthetic click for the gamepad virtual cursor. function LauncherView.clickAt(imp, x, y) if not imp._flex then return end @@ -721,7 +742,7 @@ local function cartSendFinish(shader, mode, spin) end) end -local function cartridgeButton(imp, x, y, w, h, key, skin, gameName, action) +local function cartridgeButton(imp, x, y, w, h, key, skin, gameName, action, version) local state = cartridgeState(imp, skin.cacheKey) markNoDrag(imp, x, y, w, h) local focused = Kit.focusable(key, x, y, w, h) @@ -729,6 +750,8 @@ local function cartridgeButton(imp, x, y, w, h, key, skin, gameName, action) local active = state.active local cx, cy = x + w / 2, y + h / 2 + triggerLongPress(imp, x, y, w, h, version) + if Kit.mouseClicked and Kit.hit(x, y, w, h) and not Kit.blockClicks then if Kit.mouseDown then state.active = true @@ -2231,7 +2254,7 @@ local function buildGamePanel(imp, x, y, w, availH, m, version, budgetH) local cartW = math.min(cartAreaW, math.floor(playH * 0.88)) local cartX = lx + math.floor((cartAreaW - cartW) / 2) cartridgeButton(imp, cartX, ly, cartW, playH, "play-" .. version, - skin, gameName, function() imp:play(version, true) end) + skin, gameName, function() imp:play(version, true) end, version) imp._gearIcon = imp._gearIcon or love.graphics.newImage("assets/launcher/gear.png") btn(imp, lx + lw - mgW, ly, mgW, mgW, "manage-" .. version, "", { @@ -4200,6 +4223,20 @@ end local SEAL_WORD = { open = "open", ["sealed+"] = "sealed+" } +local function webClipAvailable(imp) + return imp.ios and love.system and love.system.installWebClip ~= nil +end + +local function requestWebClip(imp, version, cartId) + local ok, text = WebClip.install(version, cartId) + imp._webClipNotice = { + key = tostring(version) .. ":" .. tostring(cartId or ""), + ok = ok, + text = ok and ("Home Screen entry ready for " .. tostring(text)) or text, + } + return ok +end + local function cartRowLabel(row) local seal = Strings(SEAL_WORD[row.seal] or "sealed") return Strings("%s - v%s - %s", tostring(row.title or row.id), @@ -4219,11 +4256,20 @@ local function buildCartModal(imp, m) local rowH = m.btnH local pagerH = math.max(Kit.tapMin(), math.floor(30 * m.s)) local notice = imp._cartNotice + local canWebClip = webClipAvailable(imp) + local webClipNotice = imp._webClipNotice + local webClipPrefix = tostring(version) .. ":" + if not webClipNotice or tostring(webClipNotice.key):sub(1, #webClipPrefix) + ~= webClipPrefix then + webClipNotice = nil + end local noticeH = notice and (Kit.wrapHeight("small", notice, w - 2 * pad, 2) + gap) or 0 + local webClipNoticeH = webClipNotice + and (Kit.wrapHeight("small", webClipNotice.text, w - 2 * pad, 2) + gap) or 0 local emptyH = (#rows == 0) and (Kit.textHeight("small") + gap) or 0 local fixed = pad + Kit.textHeight("button") + math.floor(12 * m.s) - + noticeH + emptyH + 2 * (rowH + gap) + rowH + pad + + noticeH + webClipNoticeH + emptyH + 2 * (rowH + gap) + rowH + pad local perPage = Kit.rowsThatFit(m.H - 2 * m.pad - fixed, rowH, gap, 1, 8) local pageKey = "cartpop-" .. tostring(version) local first, last, cur, pages = Kit.pageBounds(page(imp, pageKey), #rows, perPage) @@ -4239,6 +4285,10 @@ local function buildCartModal(imp, m) cy = cy + Kit.textWrapped("small", notice, px + pad, cy, pw - 2 * pad, PAL.detail, 2) + gap end + if webClipNotice then + cy = cy + Kit.textWrapped("small", webClipNotice.text, px + pad, cy, + pw - 2 * pad, webClipNotice.ok and PAL.green or PAL.red, 2) + gap + end btn(imp, px + pad, cy, pw - 2 * pad, rowH, "cartpop-vanilla", baseName, { kind = (active == nil) and "primary" or "ghost", font = "small", action = function() imp:_selectCart(version, nil) end }) @@ -4251,14 +4301,27 @@ local function buildCartModal(imp, m) local expGap = math.floor(6 * m.s) local expW = math.min(chipWidth(Strings("Export"), m), math.floor((pw - 2 * pad) * 0.35)) + local webClipW = canWebClip + and math.min(chipWidth(Strings("Home Screen"), m), + math.floor((pw - 2 * pad) * 0.32)) or 0 for i = first, last do local row = rows[i] local rowKey = "cartpop-id-" .. tostring(row.id) local pickW = pw - 2 * pad - expW - expGap + if canWebClip then pickW = pickW - webClipW - expGap end btn(imp, px + pad, cy, pickW, rowH, rowKey, cartRowLabel(row), { kind = (active == row.id) and "primary" or "ghost", font = "small", action = function() imp:_selectCart(version, row.id) end }) - btn(imp, px + pad + pickW + expGap, cy, expW, rowH, rowKey .. "-export", + if canWebClip then + btn(imp, px + pad + pickW + expGap, cy, webClipW, rowH, + rowKey .. "-webclip", Strings("Home Screen"), { kind = "accent", font = "small", + action = function() + if requestWebClip(imp, version, row.id) then imp._cartPopup = nil end + end }) + end + local exportX = px + pad + pickW + expGap + if canWebClip then exportX = exportX + webClipW + expGap end + btn(imp, exportX, cy, expW, rowH, rowKey .. "-export", Strings("Export"), { kind = "accent", font = "small", action = function() imp:exportCart(row.id) end }) cy = cy + rowH + gap @@ -4903,6 +4966,8 @@ local function buildGameManageModal(imp, m) local info = GameVersion.info(version) local ready = imp.ready[version] or false local mdl = romModel(imp, version, info, ready, info == nil) + local skin = cartSkin(imp, version) + local cartId = skin.cartId local gameName = info and (info.launcherName or info.displayName) or tostring(version) local saveDir = love.filesystem.getSaveDirectory @@ -4910,6 +4975,10 @@ local function buildGameManageModal(imp, m) -- The folder link is desktop-only: Android and NX have no browsable path to -- open, and both already print their own transfer hint on the slot card. local canOpenFolder = saveDir and not imp.android and not imp.isNX + local canWebClip = ready and webClipAvailable(imp) + local webClipKey = tostring(version) .. ":" .. tostring(cartId or "") + local webClipNotice = imp._webClipNotice + if not webClipNotice or webClipNotice.key ~= webClipKey then webClipNotice = nil end local pad = math.floor(18 * m.s) local w = math.floor(460 * m.s) @@ -4920,9 +4989,12 @@ local function buildGameManageModal(imp, m) bodyW, 3) local pathH = saveDir and (Kit.textHeight("micro") + math.floor(8 * m.s)) or 0 - local nBtns = 1 + (canOpenFolder and 1 or 0) + 1 + local noticeH = webClipNotice + and (Kit.wrapHeight("small", webClipNotice.text, bodyW, 2) + gap) or 0 + local nBtns = 1 + (canWebClip and 1 or 0) + (canOpenFolder and 1 or 0) + 1 local h = pad + Kit.textHeight("button") + math.floor(8 * m.s) + detailH - + math.floor(12 * m.s) + pathH + nBtns * (m.btnH + gap) - gap + pad + + math.floor(12 * m.s) + pathH + noticeH + + nBtns * (m.btnH + gap) - gap + pad local px, py, pw = modalPanel(m, w, h) local cy = py + pad @@ -4940,6 +5012,10 @@ local function buildGameManageModal(imp, m) px + pad, cy, PAL.faint) cy = cy + Kit.textHeight("micro") + math.floor(8 * m.s) end + if webClipNotice then + cy = cy + Kit.textWrapped("small", webClipNotice.text, px + pad, cy, + pw - 2 * pad, webClipNotice.ok and PAL.green or PAL.red, 2) + gap + end btn(imp, px + pad, cy, pw - 2 * pad, m.btnH, "manage-rom", mdl.label or Strings("Re-import ROM"), { @@ -4950,6 +5026,14 @@ local function buildGameManageModal(imp, m) if fn then fn() end end or nil }) cy = cy + m.btnH + gap + if canWebClip then + btn(imp, px + pad, cy, pw - 2 * pad, m.btnH, "manage-webclip", + Strings("Add to Home Screen"), { kind = "accent", font = "small", + action = function() + if requestWebClip(imp, version, cartId) then imp._gameManage = nil end + end }) + cy = cy + m.btnH + gap + end if canOpenFolder then btn(imp, px + pad, cy, pw - 2 * pad, m.btnH, "manage-folder", Strings("Open folder"), { kind = "accent", font = "small", diff --git a/tests/engine/launch_prelaunch_bug1657.lua b/tests/engine/launch_prelaunch_bug1657.lua index 471c5491..135076c5 100644 --- a/tests/engine/launch_prelaunch_bug1657.lua +++ b/tests/engine/launch_prelaunch_bug1657.lua @@ -281,11 +281,16 @@ do "function love%.handlers%.intent_game.-\n(.-)\nend\n") check(handler ~= nil, "main.lua still defines the Android intent handler") handler = handler or "" - check(handler:find("Prelaunch", 1, true) ~= nil, - "an Android intent runs the same pre-boot stage a shortcut does") - check(handler:find("tasks.update = false", 1, true) ~= nil, - "but never the update check, which would restart the app unattended") - local bootAt = handler:find("if not Prelaunch then bootGame", 1, true) + check(handler:find("LaunchOptions.fromGame", 1, true) ~= nil, + "an Android intent enters the shared launch request") + check(src:find("tasks = request.tasks or {}", 1, true) ~= nil, + "the shared request runs the same pre-boot stage a shortcut does") + local optionsFile = assert(io.open("src/core/LaunchOptions.lua", "r")) + local optionsSrc = optionsFile:read("*a") + optionsFile:close() + check(optionsSrc:find("tasks.update = false", 1, true) ~= nil, + "legacy Android intents never run the update check") + local bootAt = src:find("if not Prelaunch then bootShortcut()", 1, true) check(bootAt ~= nil, "and boots on the same frame when there is no stage to run") end diff --git a/tests/engine/launch_uri_args_test.lua b/tests/engine/launch_uri_args_test.lua new file mode 100644 index 00000000..9e675dcd --- /dev/null +++ b/tests/engine/launch_uri_args_test.lua @@ -0,0 +1,208 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local check, eq = T.check, T.eq +love = love or require("tests.love_stub") + +local LaunchOptions = require("src.core.LaunchOptions") +local savedSystem = love.system +local savedGetenv = os.getenv +local env = {} + +os.getenv = function(name) + if env[name] ~= nil then return env[name] end + return savedGetenv(name) +end + +do + local request = LaunchOptions.parseURI( + "gen1recomp++://launch?game=r&cart=custom_cart&slot=slot%202&sync=0&update=1&launcher=1") + eq(request.game, "red", "URI aliases normalize to the canonical game") + eq(request.cart, "custom_cart", "URI cart ids are preserved") + check(request.cartSpecified, "URI records an explicit cart value") + eq(request.slot, "slot 2", "URI values are percent-decoded") + eq(request.sync, false, "sync=0 disables sync") + eq(request.update, true, "update=1 enables updates") + eq(request.launcher, true, "launcher=1 forces the launcher") + check(request.gameSpecified, "URI records an explicit game value") +end + +do + local request = LaunchOptions.parseURI( + "GEN1RECOMP++://LAUNCH?game=blue&sync=true&update=off") + eq(request.game, "blue", "URI scheme and host are case-insensitive") + eq(request.sync, true, "boolean URI values accept true") + eq(request.update, false, "boolean URI values accept off") + local direct = LaunchOptions.parseURI("gen1recomp++://launch?game=red") + eq(direct.launcher, nil, "omitted launcher does not force the launcher") + eq(direct.sync, nil, "omitted sync keeps the normal sync default") + eq(direct.update, nil, "omitted update keeps the normal update default") + eq(LaunchOptions.uriFor("r", { cart = "custom cart", slot = "slot 2" }), + "gen1recomp++://launch?game=red&slot=slot%202", + "URI builder ignores invalid cart ids") + eq(LaunchOptions.uriFor("red", { cart = "custom_cart", slot = "slot 2" }), + "gen1recomp++://launch?game=red&cart=custom_cart&slot=slot%202", + "URI builder encodes cart and slot values") + check(LaunchOptions.parseURI("gen1recomp++://other?game=red") == nil, + "unknown URI hosts are rejected") + check(LaunchOptions.parseURI("https://launch?game=red") == nil, + "unknown URI schemes are rejected") + check(LaunchOptions.parseURI("gen1recomp++://launch?game=%") == nil, + "malformed percent escapes are rejected") + check(LaunchOptions.parseURI("gen1recomp++://launch/red") == nil, + "path-style URI variants are rejected") + check(LaunchOptions.isLaunchURI("gen1recomp++://launch?game=%") , + "recognized URI authority survives malformed query values") +end + +do + love.system = { + getOS = function() return "Android" end, + getLaunchGame = function() return "yellow" end, + getLaunchURI = function() + return "gen1recomp++://launch?game=gold&slot=slot9&sync=0&update=1" + end, + } + env.POKEPORT_GAME = "silver" + env.POKEPORT_SLOT = "slot8" + env.POKEPORT_LAUNCH_SYNC = "1" + env.POKEPORT_LAUNCH_UPDATE = "0" + + local resolved = LaunchOptions.resolveRequest({ + "--game=red", "--slot=slot2", "--no-sync", "--update" }, {}) + eq(resolved.game, "red", "command-line game overrides URI and environment") + eq(resolved.slot, "slot2", "command-line slot overrides URI and environment") + eq(resolved.tasks.sync, false, "command-line no-sync overrides URI") + eq(resolved.tasks.update, true, "command-line update overrides URI") + + resolved = LaunchOptions.resolveRequest({}, {}) + eq(resolved.game, "gold", "URI game overrides the Android legacy intent and environment") + eq(resolved.slot, "slot9", "URI slot overrides the environment") + eq(resolved.tasks.sync, false, "URI sync overrides the environment") + eq(resolved.tasks.update, true, "URI update overrides the environment") + + love.system.getLaunchURI = function() + return "gen1recomp++://launch?game=not-a-game&launcher=0" + end + env.POKEPORT_GAME = "silver" + env.POKEPORT_FORCE_LAUNCHER = "1" + resolved = LaunchOptions.resolveRequest({}, {}) + eq(resolved.game, nil, "unsupported URI games do not fall through to the environment") + eq(resolved.launcher, false, "launcher=0 overrides the environment") +end + +do + local uriCalls = 0 + love.system = { + getLaunchURI = function() + uriCalls = uriCalls + 1 + if uriCalls == 1 then + return "gen1recomp++://launch?game=red&sync=0&update=1" + end + return nil + end, + } + env.POKEPORT_LAUNCH_SYNC = nil + env.POKEPORT_LAUNCH_UPDATE = nil + local resolved = LaunchOptions.resolveRequest({}, {}) + eq(uriCalls, 1, "one launch request consumes one cold-start URI") + eq(resolved.game, "red", "cold-start URI remains available to boot resolution") + eq(resolved.tasks.sync, false, "cold-start URI sync reaches preflight") + eq(resolved.tasks.update, true, "cold-start URI update reaches preflight") +end + +do + love.system.getLaunchURI = function() return nil end + env.POKEPORT_GAME = nil + env.POKEPORT_SLOT = nil + env.POKEPORT_LAUNCH_SYNC = nil + env.POKEPORT_LAUNCH_UPDATE = nil + local request = LaunchOptions.fromGame("g") + eq(request.game, "gold", "legacy Android game intents use shared normalization") + eq(request.tasks.update, false, "legacy Android intents do not run updates") +end + +local function read(path) + local file = assert(io.open(path, "r")) + local body = file:read("*a") + file:close() + return body +end + +local main = read("main.lua") +local java = read("mobile/android/love/src/main/java/org/love2d/android/GameActivity.java") +local androidManifest = read("mobile/android/app/src/main/AndroidManifest.xml") +local plist = read("mobile/ios/overlays/love-ios.plist") +local artifactWorkflow = read(".github/workflows/platform-artifact-comment.yml") + +check(main:find("function love.handlers.intent_uri", 1, true) ~= nil, + "main.lua defines the URI intent handler") +check(main:find("LaunchOptions.parseURI(filename)", 1, true) ~= nil, + "iOS SDL URL drop events are parsed before file import") +check(main:find("startLaunchRequest(request or {})", 1, true) ~= nil, + "malformed recognized URLs fall back to the launcher") +check(main:find("tasks = request.tasks or {}", 1, true) ~= nil, + "URI launches use the shared prelaunch task request") +check(main:find("LaunchOptions.pollURI()", 1, true) ~= nil, + "iOS warm URL opens are polled through the shared request path") +check(java:find("nativeOnLaunchURI", 1, true) ~= nil, + "Android forwards warm URI intents to native") +check(java:find("getLaunchURI", 1, true) ~= nil, + "Android exposes the cold-start URI") +check(androidManifest:find('android:scheme="gen1recomp++"', 1, true) ~= nil, + "Android registers the gen1recomp++ scheme") +check(androidManifest:find('android:host="launch"', 1, true) ~= nil, + "Android restricts the URI host to launch") +check(plist:find("CFBundleURLTypes", 1, true) ~= nil, + "iOS registers URL types") +check(plist:find("gen1recomp++", 1, true) ~= nil, + "iOS registers the gen1recomp++ scheme") +check(artifactWorkflow:find("workflows: [ci]", 1, true) ~= nil, + "artifact comments are driven by the unified CI workflow") +check(artifactWorkflow:find("cancel-in-progress: false", 1, true) ~= nil, + "artifact comment runs are serialized per branch") +check(artifactWorkflow:find('comment-tag: platform-build-result', 1, true) ~= nil, + "all platform artifacts use one stable comment tag") +check(artifactWorkflow:find("gen1recomp-android-apk", 1, true) ~= nil, + "the unified artifact comment includes Android") +check(artifactWorkflow:find("gen1recomp-win64", 1, true) ~= nil, + "the unified artifact comment includes Windows") +check(artifactWorkflow:find("gen1recomp-linux-x86_64", 1, true) ~= nil, + "the unified artifact comment includes Linux x86_64") +local pickerBridge = read("mobile/ios/native/GRPickerBridge.swift") +local bootstrap = read("mobile/ios/native/GRBootstrap.m") +local iosPatch = read("mobile/ios/patch_love_src.py") +local launcherView = read("src/import/LauncherView.lua") +local webClip = read("src/core/WebClip.lua") +check(pickerBridge:find("installWebClipWithLabel:url:icon:iconLength:", 1, true) ~= nil, + "iOS exposes managed Home Screen profile installation") +check(pickerBridge:find("SFSafariViewController", 1, true) ~= nil, + "iOS presents the profile through Safari") +check(bootstrap:find("safari_isHTTPFamilyURL", 1, true) ~= nil, + "iOS accepts the profile data URL in Safari") +check(bootstrap:find("UIApplicationLaunchOptionsURLKey", 1, true) ~= nil, + "iOS captures cold-start launch URLs") +check(bootstrap:find("GRApplicationOpenURL", 1, true) ~= nil, + "iOS captures warm launch URLs") +check(bootstrap:find("GRSceneWillConnect", 1, true) ~= nil, + "iOS captures scene cold-start URLs") +check(bootstrap:find("GRSceneOpenURLContexts", 1, true) ~= nil, + "iOS captures scene warm launch URLs") +check(iosPatch:find('"installWebClip", w_installWebClip', 1, true) ~= nil, + "iOS patches the WebClip bridge into love.system") +check(iosPatch:find('"pollLaunchURI", w_pollLaunchURI', 1, true) ~= nil, + "iOS exposes a warm launch URI poll") +check(launcherView:find("LONG_PRESS_SECONDS", 1, true) ~= nil, + "iOS launcher tracks long presses on ready cartridges") +check(launcherView:find("Add to Home Screen", 1, true) ~= nil, + "iOS launcher exposes the Home Screen action") +check(launcherView:find("Home Screen", 1, true) ~= nil, + "iOS exposes a Home Screen action for each installed cart") +check(webClip:find("LaunchOptions.uriFor", 1, true) ~= nil, + "WebClip URLs use the shared launch URI builder") +check(webClip:find("Pokémon Red", 1, true) ~= nil, + "Red Home Screen entries use the accented Pokémon label") + +os.getenv = savedGetenv +love.system = savedSystem +T.finish("URI launch arguments") diff --git a/tests/switch_ci_workflows_test.lua b/tests/switch_ci_workflows_test.lua index 9cf381be..d37a34c0 100644 --- a/tests/switch_ci_workflows_test.lua +++ b/tests/switch_ci_workflows_test.lua @@ -26,12 +26,11 @@ end -- Also gates the NX runtime modules and the NX engine suites so an NX -- runtime regression cannot slip past switch-selftest / switch-build. local SWITCH_PATH_REGEX = - [[^(scripts/build_switch\.sh$|scripts/switch/|docs/switch-.*\.md$|tests/switch_ci_workflows_test\.lua$|tests/switch_transfer_docs_test\.lua$|\.github/workflows/(ci|release|switch-artifact-comment)\.yml$|src/core/(NxAssetOverlay|Platform|GameVersion)\.lua$|src/import/CacheFs\.lua$|tests/engine/(assets_version_fallback|nx_generated_guard|nx_yellow_boot|switch_diagnostics|cache_fs_gold_nx_load)_test\.lua$|tests/engine/platform_nx)]] + [[^(scripts/build_switch\.sh$|scripts/switch/|docs/switch-.*\.md$|tests/switch_ci_workflows_test\.lua$|tests/switch_transfer_docs_test\.lua$|\.github/workflows/(ci|release|platform-artifact-comment)\.yml$|src/core/(NxAssetOverlay|Platform|GameVersion)\.lua$|src/import/CacheFs\.lua$|tests/engine/(assets_version_fallback|nx_generated_guard|nx_yellow_boot|switch_diagnostics|cache_fs_gold_nx_load)_test\.lua$|tests/engine/platform_nx)]] local ci = read(".github/workflows/ci.yml") local release = read(".github/workflows/release.yml") -local comment_wf = read(".github/workflows/switch-artifact-comment.yml") -local ios_comment_wf = read(".github/workflows/ios-artifact-comment.yml") +local comment_wf = read(".github/workflows/platform-artifact-comment.yml") -- --- SWCI-01: path detector --- mustContain(ci, "switch-changes:", "ci.yml") @@ -129,28 +128,26 @@ do end -- --- SWCI-06 / SWCI-07 / SWFIX-01: PR artifact comment (no delete-all clobber) --- -mustContain(comment_wf, "workflows: [ci]", "switch-artifact-comment") -mustContain(comment_wf, "gen1recomp-switch-nro", "switch-artifact-comment") -mustContain(comment_wf, "comment-tag: switch-build-result", "switch-artifact-comment") -mustContain(comment_wf, "pull_request", "switch-artifact-comment") -mustContain(comment_wf, "conclusion == 'success'", "switch-artifact-comment") -mustContain(comment_wf, 'exit 0', "switch-artifact-comment no-op") -mustContain(comment_wf, "**Commit**:", "switch-artifact-comment") -mustContain(comment_wf, "**Build Time**:", "switch-artifact-comment") -mustContain(comment_wf, "View workflow run", "switch-artifact-comment") -mustContain(comment_wf, "thollander/actions-comment-pull-request@v3", "switch-artifact-comment") -mustNotContain(comment_wf, "delete-comment", "switch-artifact-comment") -mustNotContain(comment_wf, "izhangzhihao/delete-comment", "switch-artifact-comment") - -mustContain(ios_comment_wf, "comment-tag: ios-build-result", "ios-artifact-comment") -mustContain(ios_comment_wf, "thollander/actions-comment-pull-request@v3", "ios-artifact-comment") -mustNotContain(ios_comment_wf, "delete-comment", "ios-artifact-comment") -mustNotContain(ios_comment_wf, "izhangzhihao/delete-comment", "ios-artifact-comment") --- Distinct tags so both commenters can coexist on the same PR -check(comment_wf:find("comment-tag: switch-build-result", 1, true) - and ios_comment_wf:find("comment-tag: ios-build-result", 1, true) - and comment_wf:find("comment-tag: ios-build-result", 1, true) == nil, - "iOS and Switch comment-tags must be distinct and present") +mustContain(comment_wf, "workflows: [ci]", "platform-artifact-comment") +for _, artifact in ipairs({ + "gen1recomp++-macos", + "gen1recomp++-ios-ipa", + "gen1recomp-switch-nro", + "gen1recomp-xbox-uwp", + "gen1recomp-linux-arm64", +}) do + mustContain(comment_wf, artifact, "platform-artifact-comment") +end +mustContain(comment_wf, "comment-tag: platform-build-result", "platform-artifact-comment") +mustContain(comment_wf, "pull_request", "platform-artifact-comment") +mustContain(comment_wf, "conclusion == 'success'", "platform-artifact-comment") +mustContain(comment_wf, 'exit 0', "platform-artifact-comment no-op") +mustContain(comment_wf, "**Commit**:", "platform-artifact-comment") +mustContain(comment_wf, "**Build Time**:", "platform-artifact-comment") +mustContain(comment_wf, "View workflow run", "platform-artifact-comment") +mustContain(comment_wf, "thollander/actions-comment-pull-request@v3", "platform-artifact-comment") +mustNotContain(comment_wf, "delete-comment", "platform-artifact-comment") +mustNotContain(comment_wf, "izhangzhihao/delete-comment", "platform-artifact-comment") -- --- SWCI-08 / SWCI-09: docs CI vs release --- local build_doc = read("docs/switch-build.md") @@ -161,7 +158,7 @@ mustContain(build_doc, "ubuntu-latest", "switch-build.md") mustContain(build_doc, "selftest_build_switch.sh", "switch-build.md") mustContain(build_doc, "main repo", "switch-build.md") mustContain(build_doc, "gen1recomp-switch-nro", "switch-build.md") -mustContain(build_doc, "switch-build-result", "switch-build.md") +mustContain(build_doc, "platform-build-result", "switch-build.md") mustContain(build_doc, "hard gate", "switch-build.md") mustContain(build_doc, "continue-on-error", "switch-build.md") mustContain(build_doc, "nacptool", "switch-build.md")