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