Merge pull request #1984 from bryanthaboi/feat/uri-launch-args

This commit is contained in:
bryanthaboi
2026-08-30 14:04:38 -04:00
committed by GitHub
31 changed files with 1715 additions and 255 deletions
+108 -1
View File
@@ -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
@@ -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 }}`
<sub>This comment was automatically generated. [View workflow run](https://github.com/${{ github.repository }}/actions/runs/${{ github.event.workflow_run.id }})</sub>
pr-number: ${{ steps.artifact.outputs.pr_number }}
comment-tag: ios-build-result
github-token: ${{ github.token }}
@@ -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'"<sub>This comment was automatically generated. [View workflow run](https://github.com/$GITHUB_REPOSITORY/actions/runs/$RUN_ID)</sub>"
echo "pr_number=$pr_number" >> "$GITHUB_OUTPUT"
{
echo 'message<<BUILD_MESSAGE'
printf '%s\n' "$message"
echo 'BUILD_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 }}
@@ -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 }}`
<sub>This comment was automatically generated. [View workflow run](https://github.com/${{ github.repository }}/actions/runs/${{ github.event.workflow_run.id }})</sub>
pr-number: ${{ steps.artifact.outputs.pr_number }}
comment-tag: switch-build-result
github-token: ${{ github.token }}
+49 -3
View File
@@ -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.
<div>
<a href="https://intradeus.github.io/http-protocol-redirector?r=sidestore://source?url=https://github.com/bryanthaboi/gen1recomp/raw/refs/heads/main/mobile/ios/app-repo.json"><img src="./.github/resources/sidestore-badge.png" alt="Add to SideStore" height="60"></a>
&nbsp;
+2 -2
View File
@@ -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.
+108 -68
View File
@@ -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
+21
View File
@@ -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:
-------------
@@ -69,6 +69,12 @@
<category android:name="android.intent.category.LAUNCHER" />
<category android:name="tv.ouya.intent.category.GAME" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="gen1recomp++" android:host="launch" />
</intent-filter>
<intent-filter>
<action android:name="android.hardware.usb.action.USB_DEVICE_ATTACHED" />
</intent-filter>
@@ -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::event::Event>(love::Module::M_EVENT);
if (eventmodule == nullptr || uri == nullptr)
return;
std::vector<love::Variant> 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
@@ -105,6 +105,7 @@ bool updateAppShortcuts(const std::vector<std::string> &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
@@ -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
{
@@ -148,6 +148,7 @@ public:
virtual bool updateShortcuts(const std::vector<std::string> &versions) const;
virtual std::string getLaunchGame() const;
virtual std::string getLaunchURI() const;
/**
* Blocking HTTPS GET into an absolute host path (Android only; false
@@ -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 },
@@ -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;
+25
View File
@@ -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.
+360 -3
View File
@@ -6,10 +6,326 @@
// Registered from a constructor so no LÖVE/SDL source needs to know about it.
#import <UIKit/UIKit.h>
#import <objc/runtime.h>
#import <sys/utsname.h>
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)]) {
+120
View File
@@ -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<CChar>?,
@@ -186,6 +189,123 @@ public final class GRPickerBridge: NSObject {
return envelope
}
@objc(installWebClipWithLabel:url:icon:iconLength:)
public static func installWebClip(label: UnsafePointer<CChar>?,
url: UnsafePointer<CChar>?,
icon: UnsafePointer<UInt8>?,
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:)
+11
View File
@@ -35,6 +35,17 @@
<string>gen1recomp++</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLName</key>
<string>gen1recomp++ launch options</string>
<key>CFBundleURLSchemes</key>
<array>
<string>gen1recomp++</string>
</array>
</dict>
</array>
<key>CFBundleShortVersionString</key>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleSignature</key>
+69
View File
@@ -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
"""
+1
View File
@@ -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
+1
View File
@@ -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.
+231 -22
View File
@@ -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
+4 -2
View File
@@ -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
+2 -2
View File
@@ -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 {}
+73
View File
@@ -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
+2 -1
View File
@@ -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
+92 -8
View File
@@ -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",
+10 -5
View File
@@ -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
+208
View File
@@ -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")
+23 -26
View File
@@ -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")