mirror of
https://github.com/bryanthaboi/gen1recomp
synced 2026-09-26 13:33:27 -04:00
Merge pull request #1984 from bryanthaboi/feat/uri-launch-args
This commit is contained in:
+108
-1
@@ -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 }}
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)]) {
|
||||
|
||||
@@ -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:)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
"""
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user