From ada0d8abe1bc239a6288aa5cf88ed79b7a01e258 Mon Sep 17 00:00:00 2001 From: bryanthaboi Date: Thu, 20 Aug 2026 17:30:48 -0400 Subject: [PATCH] updater stuff --- .github/workflows/release.yml | 29 +++- docs/updater.md | 27 +++- mobile/ANDROID.md | 20 ++- mobile/android/README.md | 2 +- mobile/android/app/build.gradle | 30 +++- .../android/app/src/main/AndroidManifest.xml | 16 ++ .../src/main/res/xml/full_update_paths.xml | 6 + mobile/android/build.gradle | 3 +- mobile/android/gradle.properties | 1 - .../gradle/wrapper/gradle-wrapper.properties | 2 +- mobile/android/love/build.gradle | 6 +- .../love/src/jni/love/src/common/android.cpp | 34 +++++ .../love/src/jni/love/src/common/android.h | 6 + .../jni/love/src/modules/system/System.cpp | 10 ++ .../src/jni/love/src/modules/system/System.h | 3 + .../love/src/modules/system/wrap_System.cpp | 8 + .../java/org/love2d/android/GameActivity.java | 99 ++++++++++++ scripts/build_android.sh | 49 ++++-- src/import/LauncherView.lua | 18 ++- src/update/Check.lua | 142 +++++++++++++++++- src/update/check_worker.lua | 127 ++++++++++++++-- tests/engine/update_check_tests.lua | 14 ++ 22 files changed, 596 insertions(+), 56 deletions(-) create mode 100644 mobile/android/app/src/main/res/xml/full_update_paths.xml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b41bc888..e3df3ec4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -373,10 +373,31 @@ jobs: unzip -l dist/win/gen1recomp-win64.zip | grep -F gen1tls.dll \ || { echo "::error::Windows zip is missing gen1tls.dll"; exit 1; } - - name: Build Android + - name: Materialize Android release signing key + env: + KEYSTORE_B64: ${{ secrets.ANDROID_RELEASE_KEYSTORE_B64 }} run: | set -euo pipefail - scripts/build_android.sh --version "${{ needs.version.outputs.version }}" + [ -n "$KEYSTORE_B64" ] || { + echo "::error::ANDROID_RELEASE_KEYSTORE_B64 is required for a publishable Android update" + exit 1 + } + python3 - <<'PY' + import base64, os, pathlib + encoded = os.environ["KEYSTORE_B64"] + path = pathlib.Path(os.environ["RUNNER_TEMP"]) / "gen1recomp-android-release.keystore" + path.write_bytes(base64.b64decode(encoded, validate=True)) + PY + + - name: Build Android + env: + GEN1RECOMP_ANDROID_KEYSTORE: ${{ runner.temp }}/gen1recomp-android-release.keystore + GEN1RECOMP_ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_RELEASE_KEYSTORE_PASSWORD }} + GEN1RECOMP_ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_RELEASE_KEY_ALIAS }} + GEN1RECOMP_ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_RELEASE_KEY_PASSWORD }} + run: | + set -euo pipefail + scripts/build_android.sh --release --version "${{ needs.version.outputs.version }}" - name: Install xcbeautify run: | @@ -498,8 +519,8 @@ jobs: [ -f "$arm64_appimage" ] || { echo "::error::$arm64_appimage not found (expected from the linux-arm64 job)"; exit 1; } cp "$arm64_appimage" "$outdir/gen1recomp-${v}-linux-arm64.AppImage" chmod +x "$outdir/gen1recomp-${v}-linux-arm64.AppImage" - apk="$(find dist/android/debug -name '*.apk' | head -1)" - [ -n "$apk" ] || { echo "::error::no Android APK found under dist/android/debug"; exit 1; } + apk="$(find dist/android/release -name '*.apk' | head -1)" + [ -n "$apk" ] || { echo "::error::no Android APK found under dist/android/release"; exit 1; } cp "$apk" "$outdir/gen1recomp-${v}-android.apk" ipa="dist/ios/gen1recomp++.ipa" diff --git a/docs/updater.md b/docs/updater.md index 5935b617..5e3b70d6 100644 --- a/docs/updater.md +++ b/docs/updater.md @@ -64,7 +64,8 @@ mounted or deleted as stale; the launcher directs the player to a full package. Each tagged release `vX.Y.Z` carries the existing per-platform archives (`gen1recomp-X.Y.Z-macos.zip`, `-windows.zip`, `-linux.zip`, -`-android.apk`) plus two assets the updater itself consumes: +`-linux-arm64.AppImage`, `-android.apk`, `-ios.ipa`, `-switch.zip`, Xbox and +PortMaster archives) plus two assets the updater itself consumes: - `gen1recomp-X.Y.Z.love` - the payload, matched by the exact pattern `gen1recomp-.love` (see `isPayloadName` in `Boot.lua` and @@ -75,8 +76,9 @@ Each tagged release `vX.Y.Z` carries the existing per-platform archives filename otherwise to match the asset name exactly. A release missing either asset is treated as "no in-place update available": -`Check` reports `needs_full` and sends the player to `Check.releaseUrl()` -(`https://github.com/bryanthaboi/gen1recomp/releases/latest`). +`Check` reports `needs_full`. It also selects the exact current platform asset +from the same release and persists the requirement, so it is visible again on +every launch, including offline launches. ## Save-directory layout @@ -85,6 +87,7 @@ Under the save directory (identity `pokemon-love2d`): ``` updates/gen1recomp-.love downloaded payload(s) updates/pending.txt crash-guard marker +updates/full-update.json persistent native-package requirement ``` `pending.txt` holds the filename of the payload currently being chainloaded. @@ -106,7 +109,8 @@ bundled game, in that case. against the GitHub releases API; safe to call every frame, it is a no-op once a check is in flight or has reached a terminal state. `Check.state()` reports `idle | checking | uptodate | available | downloading | ready | - needs_full | error` plus the latest version and download progress. + needs_full | full_downloading | full_ready | error` plus the latest version, + download progress, and (when applicable) the selected full-package asset. 3. **Download + verify**: on `available`, `Check.download()` tells the worker to fetch the payload, polling the growing `.part` file for progress. On completion the worker re-fetches `sha256sums.txt`, verifies @@ -117,6 +121,14 @@ bundled game, in that case. 4. **Restart to apply**: a `ready` payload just sits in `updates/` until the player relaunches; the next launch's Boot step (1) is what actually mounts and runs it. There is no in-session hot-swap. +5. **Native-package requirement**: when `minShell` or `payloadHost` is + incompatible, the worker writes `full-update.json` and surfaces a + persistent launcher control. Android downloads the release APK, verifies + its SHA-256 entry from `sha256sums.txt`, then invokes Android's Package + Installer. The installer asks the user for consent and enforces package, + version-code, and signing-certificate compatibility. iOS links the + sideload repository for a re-sideload; Xbox, desktop, and PortMaster builds + link their correctly named full package. Switch keeps its native OTA flow. ## Known limitations @@ -140,6 +152,13 @@ bundled game, in that case. still need a full reinstall (`minShell` / `payloadHost` gate → `needs_full`). Applying a downloaded payload on Android relaunches via `love.system.restartApp`; iOS still uses in-process `quit("restart")`. +- **Android full updates are user-confirmed and certificate-bound.** The app + uses a private `FileProvider` cache path plus + `Intent.ACTION_INSTALL_PACKAGE`, checks Android 8+'s per-app + "install unknown apps" setting, and never requests a silent install. The + release job must use the original long-lived Android signing key; a new key + causes Android to reject an in-place update and requires a one-time manual + reinstall. See [mobile/ANDROID.md](../mobile/ANDROID.md). - **Dev/source runs never self-update.** `Boot.run` returns immediately when `love.filesystem.isFused()` is false, and a working tree's `engine` is the `"0.0.0-dev"` placeholder that always reports up to date, so a source diff --git a/mobile/ANDROID.md b/mobile/ANDROID.md index 7680c614..8718896b 100644 --- a/mobile/ANDROID.md +++ b/mobile/ANDROID.md @@ -93,8 +93,9 @@ transport, exactly as a missing curl does. love-android 11.5a expects: - **JDK 17** -- Android SDK with **API 34** +- Android SDK with **API 36** (Android 16; latest 36.x Build-Tools) - NDK **25.2.9519653** (Apple Silicon host supported) +- **minSdk 19** (Android 4.4), **targetSdk 36** (Android 16) Set `ANDROID_SDK_ROOT` (or `ANDROID_HOME`), or let the script write `local.properties` when it finds `~/Library/Android/sdk`. @@ -122,15 +123,24 @@ scripts, tests, and mobile build sources are excluded. | `app.application_id` | `com.theboisclub.pokemonred` | | `app.name` | Pokemon Red | | `app.orientation` | `fullUser`. This is only the manifest default: SDL requests FULL_SENSOR at window creation (resizable window, no `SDL_HINT_ORIENTATIONS`), and `GameActivity.setOrientationBis` remaps that to FULL_USER so the device's rotation lock is honoured. | -| `app.version_name` / `app.version_code` | set from `--version X.Y.Z` (code = major*10000 + minor*100 + patch); left as-is if `--version` is omitted | -| Permissions | RECORD_AUDIO / WRITE_EXTERNAL_STORAGE stripped; VIBRATE + BLUETOOTH + INTERNET (link play, mod index) + ACTIVITY_RECOGNITION (step bridge) kept | +| `app.version_name` / `app.version_code` | set from `--version X.Y.Z` (code = major*1,000,000 + minor*1,000 + patch); left as-is if `--version` is omitted | +| Permissions | RECORD_AUDIO / WRITE_EXTERNAL_STORAGE stripped; VIBRATE + BLUETOOTH + INTERNET (link play, mod index) + ACTIVITY_RECOGNITION (step bridge) kept; REQUEST_INSTALL_PACKAGES is limited to the user-confirmed full-update installer | ## Releases `.github/workflows/release.yml` builds the APK with `--version` set to the release version and publishes it alongside the macOS/Windows/Linux builds as -`PokemonRed--android.apk`. +`gen1recomp--android.apk`. ## Signing -Signed with the default Android keystore (no setup required). +Production APKs are built with `scripts/build_android.sh --release`. They must +be signed with the same long-lived certificate as the currently installed app: +Android's Package Installer rejects an update with a different signing +certificate. Store that keystore and its passwords only in CI secrets, expose +them as `GEN1RECOMP_ANDROID_KEYSTORE`, +`GEN1RECOMP_ANDROID_KEYSTORE_PASSWORD`, `GEN1RECOMP_ANDROID_KEY_ALIAS`, and +`GEN1RECOMP_ANDROID_KEY_PASSWORD`, and never commit the keystore. A newly +created certificate cannot update users who have an APK signed by a different +legacy key; those users need one final manual reinstall before in-app updates +can take over. diff --git a/mobile/android/README.md b/mobile/android/README.md index ca2aebab..b77d4ab2 100644 --- a/mobile/android/README.md +++ b/mobile/android/README.md @@ -41,7 +41,7 @@ Quick Start: Before you start, install JDK 17 (not later not earlier). If you intend to build from Android Studio, skip this step as Android Studio bundles its own JDK 17. -Install Android SDK with SDK API 34 (34.x.y) and Android NDK 25.2.9519653, set the environment variable +Install Android SDK with SDK API 36 (latest 36.x Build-Tools) and Android NDK 25.2.9519653, set the environment variable `ANDROID_SDK_ROOT` to your Android SDK location and run: ``` diff --git a/mobile/android/app/build.gradle b/mobile/android/app/build.gradle index b5f49c2e..9a1e7354 100644 --- a/mobile/android/app/build.gradle +++ b/mobile/android/app/build.gradle @@ -10,9 +10,12 @@ android { applicationId project.properties["app.application_id"] versionCode project.properties["app.version_code"].toInteger() versionName project.properties["app.version_name"] - minSdk 16 - compileSdk 34 - targetSdk 34 + // NDK r25 no longer supports API 16; API 19 is Android 4.4 and keeps + // the native toolchain and package-installer bridge on a supported ABI. + minSdk 19 + // Android 16 / API 36: current Android distribution target. + compileSdk 36 + targetSdk 36 def getAppName = { def nameArray = project.properties["app.name_byte_array"] @@ -38,10 +41,31 @@ android { ORIENTATION:project.properties["app.orientation"], ] } + // Release signing lives outside the repository. The release build script + // requires all five values below, while debug builds intentionally remain + // usable without them. + def releaseStore = System.getenv("GEN1RECOMP_ANDROID_KEYSTORE") + def releaseStorePassword = System.getenv("GEN1RECOMP_ANDROID_KEYSTORE_PASSWORD") + def releaseKeyAlias = System.getenv("GEN1RECOMP_ANDROID_KEY_ALIAS") + def releaseKeyPassword = System.getenv("GEN1RECOMP_ANDROID_KEY_PASSWORD") + def hasReleaseSigning = releaseStore && releaseStorePassword && releaseKeyAlias && releaseKeyPassword + + if (hasReleaseSigning) { + signingConfigs { + release { + storeFile file(releaseStore) + storePassword releaseStorePassword + keyAlias releaseKeyAlias + keyPassword releaseKeyPassword + } + } + } + buildTypes { release { minifyEnabled true proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' + if (hasReleaseSigning) signingConfig signingConfigs.release } } flavorDimensions = ['mode', 'recording'] diff --git a/mobile/android/app/src/main/AndroidManifest.xml b/mobile/android/app/src/main/AndroidManifest.xml index 33ad1ea6..a12254e6 100644 --- a/mobile/android/app/src/main/AndroidManifest.xml +++ b/mobile/android/app/src/main/AndroidManifest.xml @@ -8,6 +8,10 @@ the link screen shows as "(Operation not permitted)" (issue #287). scripts/build_android.sh must not strip this again. --> + + + + + + + + + diff --git a/mobile/android/build.gradle b/mobile/android/build.gradle index 3ddb7b3e..48ebeee4 100644 --- a/mobile/android/build.gradle +++ b/mobile/android/build.gradle @@ -18,7 +18,8 @@ buildscript { mavenCentral() } dependencies { - classpath 'com.android.tools.build:gradle:8.1.1' + // Android 16 / API 36 requires Android Gradle Plugin 8.9+. + classpath 'com.android.tools.build:gradle:8.9.2' // NOTE: Do not place your application dependencies here; they belong // in the individual module build.gradle files diff --git a/mobile/android/gradle.properties b/mobile/android/gradle.properties index df2f35ec..8f9a1f99 100644 --- a/mobile/android/gradle.properties +++ b/mobile/android/gradle.properties @@ -15,7 +15,6 @@ app.version_name=11.5a # No need to modify anything past this line! android.enableJetifier=false android.useAndroidX=true -android.defaults.buildfeatures.buildconfig=true android.nonTransitiveRClass=true android.nonFinalResIds=true app.name=gen1recomp diff --git a/mobile/android/gradle/wrapper/gradle-wrapper.properties b/mobile/android/gradle/wrapper/gradle-wrapper.properties index 0c85a1f7..4eaec467 100644 --- a/mobile/android/gradle/wrapper/gradle-wrapper.properties +++ b/mobile/android/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.1-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip networkTimeout=10000 zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/mobile/android/love/build.gradle b/mobile/android/love/build.gradle index eb3dc16e..de825748 100644 --- a/mobile/android/love/build.gradle +++ b/mobile/android/love/build.gradle @@ -10,9 +10,9 @@ android { ndkVersion '25.2.9519653' defaultConfig { - minSdk 16 - compileSdk 34 - targetSdk 34 + minSdk 19 + compileSdk 36 + targetSdk 36 externalNativeBuild { ndkBuild { arguments "-j" + Runtime.runtime.availableProcessors() 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 6aa94eef..fd446869 100644 --- a/mobile/android/love/src/jni/love/src/common/android.cpp +++ b/mobile/android/love/src/jni/love/src/common/android.cpp @@ -283,6 +283,40 @@ bool restartApp() return result; } +bool installApk(const char *path) +{ + if (path == nullptr || path[0] == '\0') + return false; + + JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv(); + // This may be called from Lua's main thread, but use the activity object + // class just like httpDownload so a future worker caller does not depend on + // the system JNI class loader finding the app class. + void *rawActivity = SDL_AndroidGetActivity(); + if (rawActivity == nullptr) + return false; + jobject activityObj = (jobject) rawActivity; + jclass activity = env->GetObjectClass(activityObj); + env->DeleteLocalRef(activityObj); + + jmethodID method = env->GetStaticMethodID(activity, "installApk", + "(Ljava/lang/String;Ljava/lang/String;)Z"); + if (method == nullptr) + { + env->ExceptionClear(); + env->DeleteLocalRef(activity); + return false; + } + + jstring jpath = env->NewStringUTF(path); + jstring jroot = env->NewStringUTF(bridgeSaveDirectory()); + jboolean result = env->CallStaticBooleanMethod(activity, method, jpath, jroot); + env->DeleteLocalRef(jroot); + env->DeleteLocalRef(jpath); + env->DeleteLocalRef(activity); + return result; +} + bool updateAppShortcuts(const std::vector &versions) { JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv(); 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 0b56890c..64a493fd 100644 --- a/mobile/android/love/src/jni/love/src/common/android.h +++ b/mobile/android/love/src/jni/love/src/common/android.h @@ -90,6 +90,12 @@ bool syncHealthSteps(); **/ bool restartApp(); +/** + * Stages a checksum-verified APK from the current save directory and starts + * Android's user-confirmed Package Installer flow. Android-only. + **/ +bool installApk(const char *path); + /** * Dynamic App Shortcuts: updates Android ShortcutManager with ready game versions. **/ 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 329cff19..54a5be46 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 @@ -245,6 +245,16 @@ bool System::restartApp() const #endif } +bool System::installApk(const char *path) const +{ +#ifdef LOVE_ANDROID + return love::android::installApk(path); +#else + LOVE_UNUSED(path); + return false; +#endif +} + bool System::updateShortcuts(const std::vector &versions) const { #ifdef LOVE_ANDROID 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 17fd2e39..2fcfad46 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 @@ -143,6 +143,9 @@ public: **/ virtual bool restartApp() const; + /** Starts Android's user-confirmed install flow for a verified APK. */ + virtual bool installApk(const char *path) const; + virtual bool updateShortcuts(const std::vector &versions) const; virtual std::string getLaunchGame() const; 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 2405c607..0acdbf0c 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 @@ -132,6 +132,13 @@ int w_restartApp(lua_State *L) return 1; } +int w_installApk(lua_State *L) +{ + const char *path = luaL_checkstring(L, 1); + luax_pushboolean(L, instance()->installApk(path)); + return 1; +} + int w_httpDownload(lua_State *L) { const char *url = luaL_checkstring(L, 1); @@ -325,6 +332,7 @@ static const luaL_Reg functions[] = { "createFile", w_createFile }, { "syncHealthSteps", w_syncHealthSteps }, { "restartApp", w_restartApp }, + { "installApk", w_installApk }, { "updateShortcuts", w_updateShortcuts }, { "getLaunchGame", w_getLaunchGame }, { "httpDownload", w_httpDownload }, 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 e59fe574..2fe59948 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 @@ -45,6 +45,7 @@ import android.app.AlarmManager; import android.app.AlertDialog; import android.app.PendingIntent; import android.content.Context; +import android.content.ClipData; import android.content.DialogInterface; import android.content.Intent; import android.content.SharedPreferences; @@ -77,6 +78,7 @@ import android.view.*; import androidx.annotation.Keep; import androidx.core.app.ActivityCompat; +import androidx.core.content.FileProvider; public class GameActivity extends SDLActivity { private static DisplayMetrics metrics = null; @@ -696,6 +698,103 @@ public class GameActivity extends SDLActivity { return true; // unreachable, but keeps the JNI signature honest } + /** + * Stages a verified release APK in cache and asks Android's Package + * Installer to update this package. This never silently installs an APK: + * the platform owns both the unknown-sources consent and final install + * confirmation. `updateRoot` comes from the native save directory and is + * checked before any file is read, so a Lua caller cannot turn this into a + * general-purpose local-file sharing bridge. + */ + @Keep + public static boolean installApk(final String sourcePath, final String updateRoot) { + final GameActivity self = (GameActivity) mSingleton; + if (self == null || sourcePath == null || updateRoot == null) return false; + final File source; + try { + source = new File(sourcePath).getCanonicalFile(); + File root = new File(updateRoot, "updates").getCanonicalFile(); + String rootPath = root.getPath() + File.separator; + if (!source.getPath().startsWith(rootPath) + || !source.isFile() || source.length() == 0 + || !source.getName().matches("gen1recomp-[0-9]+\\.[0-9]+\\.[0-9]+-android\\.apk")) { + return false; + } + } catch (IOException e) { + Log.d("GameActivity", "invalid update APK path: " + e.getMessage()); + return false; + } + + // Android 8+ lets the user decide whether this app is trusted to + // request package installs. Send them to the per-app setting first; + // they deliberately tap Install again after granting it. + if (android.os.Build.VERSION.SDK_INT >= 26 + && !self.getPackageManager().canRequestPackageInstalls()) { + try { + Intent settings = new Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES, + Uri.parse("package:" + self.getPackageName())); + self.startActivity(settings); + return true; + } catch (Exception e) { + Log.d("GameActivity", "could not open install-source settings: " + e.getMessage()); + return false; + } + } + + // Copying an APK can be large; keep both I/O and checksum-verified + // source access off the UI thread. The FileProvider exposes this cache + // child only after it has been fully written and renamed. + new Thread(new Runnable() { + @Override public void run() { + File stagedDir = new File(self.getCacheDir(), "full-update"); + File partial = new File(stagedDir, "update.apk.part"); + File staged = new File(stagedDir, "update.apk"); + try { + if (!stagedDir.exists() && !stagedDir.mkdirs()) return; + copyFile(source, partial); + if (staged.exists() && !staged.delete()) return; + if (!partial.renameTo(staged)) return; + self.runOnUiThread(new Runnable() { + @Override public void run() { launchPackageInstaller(self, staged); } + }); + } catch (Exception e) { + Log.d("GameActivity", "could not stage update APK: " + e.getMessage()); + } finally { + if (partial.exists()) partial.delete(); + } + } + }, "gen1recomp-apk-stage").start(); + return true; + } + + private static void copyFile(File source, File destination) throws IOException { + InputStream in = new BufferedInputStream(new FileInputStream(source)); + OutputStream out = new BufferedOutputStream(new FileOutputStream(destination)); + try { + byte[] buffer = new byte[32768]; + int count; + while ((count = in.read(buffer)) != -1) out.write(buffer, 0, count); + } finally { + try { out.close(); } catch (IOException ignored) {} + try { in.close(); } catch (IOException ignored) {} + } + } + + private static void launchPackageInstaller(GameActivity activity, File apk) { + try { + Context context = activity.getApplicationContext(); + Uri uri = FileProvider.getUriForFile(context, + context.getPackageName() + ".full_update_provider", apk); + Intent install = new Intent(Intent.ACTION_INSTALL_PACKAGE); + install.setData(uri); + install.setClipData(ClipData.newRawUri("apk", uri)); + install.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); + activity.startActivity(install); + } catch (Exception e) { + Log.d("GameActivity", "could not open package installer: " + e.getMessage()); + } + } + @Keep public static String getLaunchGame() { return initialGame != null ? initialGame : ""; diff --git a/scripts/build_android.sh b/scripts/build_android.sh index 1661cfc5..e5b508a5 100755 --- a/scripts/build_android.sh +++ b/scripts/build_android.sh @@ -1,19 +1,20 @@ #!/usr/bin/env bash # Packages the LÖVE2D Pokémon Red port into an Android APK via love-android 11.5a. # -# Usage: scripts/build_android.sh [--version X.Y.Z] [--package-only] +# Usage: scripts/build_android.sh [--version X.Y.Z] [--release] [--package-only] # # --version X.Y.Z set app.version_name / app.version_code (else left as-is) +# --release build the production-signed release APK (requires the +# GEN1RECOMP_ANDROID_* signing environment variables) # --package-only zip game.love + apply branding; skip gradle # # Prerequisites: # - mobile/android vendored love-android tree at tag 11.5a (in-repo; see mobile/ANDROID.md) -# - Android SDK + NDK (SDK API 34, NDK 25.2.9519653) +# - Android SDK + NDK (SDK API 36, NDK 25.2.9519653) # - JDK 17 # # Output (after gradle): -# dist/android/debug/*.apk (convenience copy) -# mobile/android/app/build/outputs/apk/embedNoRecord/debug/*.apk +# dist/android/debug/*.apk (normal local build) or dist/android/release/*.apk set -euo pipefail @@ -26,6 +27,7 @@ APP_NAME="gen1recomp" APPLICATION_ID="com.theboisclub.pokemonred" LOVE_ANDROID_VERSION="11.5a" NDK_VERSION="25.2.9519653" +ANDROID_API="36" YELLOW_MANIFEST_RELATIVE="tools/rom_manifest_yellow.json" YELLOW_MANIFEST_URL="${YELLOW_MANIFEST_URL:-https://raw.githubusercontent.com/bryanthaboi/gen1recomp/main/tools/rom_manifest_yellow.json}" GOLD_MANIFEST_RELATIVE="tools/rom_manifest_gold.json" @@ -35,6 +37,7 @@ SILVER_MANIFEST_URL="${SILVER_MANIFEST_URL:-https://raw.githubusercontent.com/br VERSION="" PACKAGE_ONLY=false +RELEASE=false say() { printf '\033[1;32m==>\033[0m %s\n' "$*"; } warn() { printf '\033[1;33mwarn:\033[0m %s\n' "$*" >&2; } @@ -44,11 +47,12 @@ while [ $# -gt 0 ]; do case "$1" in --version) VERSION="$2"; shift ;; --package-only) PACKAGE_ONLY=true ;; + --release) RELEASE=true ;; -h|--help) sed -n '2,20p' "$0" exit 0 ;; - *) fail "unknown argument: $1 (try --version X.Y.Z or --package-only)" ;; + *) fail "unknown argument: $1 (try --version X.Y.Z, --release, or --package-only)" ;; esac shift done @@ -62,7 +66,22 @@ if [ -n "$VERSION" ]; then rest="${VERSION#*.}" minor="${rest%%.*}" patch="${rest##*.}" - VERSION_CODE=$((major * 10000 + minor * 100 + patch)) + # Reserve three digits for each lower component. This stays monotonic across + # 1.0.100 -> 1.1.0, unlike the old two-digit encoding, and remains inside + # Android's signed 32-bit versionCode range for normal release versions. + if [ "$minor" -gt 999 ] || [ "$patch" -gt 999 ] || [ "$major" -gt 2099 ]; then + fail "--version components exceed Android versionCode limits" + fi + VERSION_CODE=$((major * 1000000 + minor * 1000 + patch)) +fi + +if $RELEASE; then + for var in GEN1RECOMP_ANDROID_KEYSTORE GEN1RECOMP_ANDROID_KEYSTORE_PASSWORD \ + GEN1RECOMP_ANDROID_KEY_ALIAS GEN1RECOMP_ANDROID_KEY_PASSWORD; do + [ -n "${!var:-}" ] || fail "--release requires $var" + done + [ -f "$GEN1RECOMP_ANDROID_KEYSTORE" ] \ + || fail "Android signing keystore does not exist: $GEN1RECOMP_ANDROID_KEYSTORE" fi # --------------------------------------------------------------- preconditions @@ -389,13 +408,18 @@ require_android_sdk() { export ANDROID_SDK_ROOT=\$HOME/Library/Android/sdk or create mobile/android/local.properties with: sdk.dir=/path/to/Android/sdk - love-android $LOVE_ANDROID_VERSION expects SDK API 34 and NDK $NDK_VERSION + love-android $LOVE_ANDROID_VERSION expects SDK API $ANDROID_API and NDK $NDK_VERSION (see mobile/ANDROID.md)." fi export ANDROID_SDK_ROOT="$sdk" export ANDROID_HOME="$sdk" + if [ ! -d "$sdk/platforms/android-$ANDROID_API" ]; then + fail "Android SDK platform android-$ANDROID_API is not installed. + Install Android $ANDROID_API (and the latest 36.x Build-Tools) in SDK Manager." + fi + local props="$ANDROID_DIR/local.properties" # Always rewrite so a leftover Docker sdk.dir=/opt/android-sdk cannot stick. printf 'sdk.dir=%s\n' "$sdk" > "$props" @@ -412,7 +436,12 @@ require_android_sdk() { # --------------------------------------------------------------- gradle run_gradle() { - local task="assembleEmbedNoRecordDebug" + local variant="debug" + $RELEASE && variant="release" + # Keep this compatible with macOS's bundled Bash 3.2 (no ${var^}). + local variant_title="Debug" + $RELEASE && variant_title="Release" + local task="assembleEmbedNoRecord$variant_title" local build_dir="$ANDROID_DIR" # ndk-build is GNU make underneath and cannot cope with spaces anywhere in @@ -447,12 +476,12 @@ run_gradle() { You can still iterate on the .love payload with: scripts/build_android.sh --package-only" fi - local out_dir="$build_dir/app/build/outputs/apk/embedNoRecord/debug" + local out_dir="$build_dir/app/build/outputs/apk/embedNoRecord/$variant" if [ -d "$out_dir" ]; then say "APK output:" find "$out_dir" -name '*.apk' -exec ls -lh {} \; - local dist_dir="$DIST/debug" + local dist_dir="$DIST/$variant" rm -rf "$dist_dir" mkdir -p "$dist_dir" find "$out_dir" -name '*.apk' -exec cp {} "$dist_dir/" \; diff --git a/src/import/LauncherView.lua b/src/import/LauncherView.lua index 2d5d9418..eeee5ed3 100644 --- a/src/import/LauncherView.lua +++ b/src/import/LauncherView.lua @@ -1316,15 +1316,27 @@ function LauncherView._updateControl(imp) elseif status == "downloading" then local pct = st.progress and math.floor(st.progress * 100) or 0 return status, Strings("Updating %d%%", pct), nil, false + elseif status == "full_downloading" then + local pct = st.progress and math.floor(st.progress * 100) or 0 + return status, Strings("Downloading app %d%%", pct), nil, false elseif status == "available" then return status, st.latest and (Strings("Update v") .. st.latest) or Strings("Update"), function() pcall(imp.Check.download) end, true elseif status == "ready" then return status, Strings("Restart to update"), function() require("src.core.HostShell").restart() end, true - elseif status == "needs_full" then - return status, Strings("Open releases"), - function() love.system.openURL(imp.Check.releaseUrl()) end, true + elseif status == "needs_full" or status == "full_ready" then + local action = imp.Check.fullUpdateAction and imp.Check.fullUpdateAction() + local label = action and action.label or "Open releases" + local url = action and action.url or imp.Check.releaseUrl() + return status, Strings(label), + function() + if action and action.kind and imp.Check.performFullUpdate then + pcall(imp.Check.performFullUpdate) + else + love.system.openURL(url) + end + end, true end -- idle / uptodate / error: offer a manual check, with no glow. return status, Strings("Check for updates"), diff --git a/src/update/Check.lua b/src/update/Check.lua index c087485d..95f309cb 100644 --- a/src/update/Check.lua +++ b/src/update/Check.lua @@ -4,7 +4,8 @@ -- on a background love.thread worker (src/update/check_worker.lua); this module -- is only the thin main-thread state machine the UI polls. Two channels carry -- the conversation: --- "update_check_cmd" main -> worker: { cmd = "check" | "download" | "quit" } +-- "update_check_cmd" main -> worker: { cmd = "check" | "download" | +-- "download_full" | "quit" } -- "update_check_state" worker -> main: { status, latest, progress, error } -- -- Nothing here ever blocks or throws into the game loop: when love.thread is @@ -21,6 +22,42 @@ local Platform = require("src.core.Platform") Check.REPO = "bryanthaboi/gen1recomp" +-- Full native packages are deliberately named by release target, not by the +-- generic .love payload. Keeping the mapping here makes the release parser, +-- worker and launcher agree on exactly which asset a platform may offer. +-- Switch owns its native OTA launcher and therefore never reaches this code. +local function fullAssetName(version, osName, arch, port) + if port == "rg34xxsp" then + return "gen1recomp-" .. version .. "-rg34xxsp-stockos64-mod.zip" + elseif port == "portmaster" then + return "gen1recomp-" .. version .. "-sbc-portmaster.zip" + elseif osName == "Android" then + return "gen1recomp-" .. version .. "-android.apk" + elseif osName == "iOS" then + return "gen1recomp++-" .. version .. "-ios.ipa" + elseif osName == "OS X" or osName == "macOS" then + return "gen1recomp-" .. version .. "-macos.zip" + elseif osName == "Windows" then + return "gen1recomp-" .. version .. "-windows.zip" + elseif osName == "UWP" then + return "gen1recomp-" .. version .. "-xbox-uwp.zip" + elseif osName == "NX" then + return "gen1recomp-" .. version .. "-switch.zip" + elseif osName == "Linux" and (arch == "arm64" or arch == "aarch64") then + return "gen1recomp-" .. version .. "-linux-arm64.AppImage" + elseif osName == "Linux" then + return "gen1recomp-" .. version .. "-linux.zip" + end + return nil +end + +function Check.fullAssetName(version, osName, arch, port) + if type(version) ~= "string" or not version:match("^%d+%.%d+%.%d+$") then + return nil + end + return fullAssetName(version, osName, arch, port) +end + local CMD = "update_check_cmd" local STATE = "update_check_state" @@ -50,7 +87,7 @@ end -- document is not a release with a strict X.Y.Z tag. Json is injected so the -- worker can pass a filesystem-loaded codec; on the main thread / in tests it -- falls back to require. -function Check.parseRelease(jsonText, Json) +function Check.parseRelease(jsonText, Json, target) Json = Json or require("src.link.Json") local notJson = Json.describeUnexpected(jsonText) if notJson then return nil, notJson end @@ -66,11 +103,15 @@ function Check.parseRelease(jsonText, Json) return nil, "release tag is not X.Y.Z: " .. tostring(doc.tag_name) end local payloadName = "gen1recomp-" .. version .. ".love" + target = type(target) == "table" and target or {} + local fullName = fullAssetName(version, target.os, target.arch, target.port) return { version = version, payloadName = payloadName, payload = Check.pickAsset(doc.assets, payloadName), sums = Check.pickAsset(doc.assets, "sha256sums.txt"), + fullName = fullName, + full = fullName and Check.pickAsset(doc.assets, fullName) or nil, -- GitHub release body: already fetched with the update check, shown by -- the launcher's Patch notes footer button. notes = type(doc.body) == "string" and doc.body or "", @@ -106,6 +147,37 @@ local workerReady -- nil = untried, true = running, false = unavailable local requested -- a check has been asked for this session local cache = { status = "idle" } -- newest snapshot from the worker +local function target() + local osName = love and love.system and love.system.getOS and love.system.getOS() or nil + local arch = jit and jit.arch or nil + local port = os.getenv("POKEPORT_PORTMASTER") + return { os = osName, arch = arch, port = port } +end + +local function readPersistedFullRequirement() + if not (love and love.filesystem and love.filesystem.getInfo) then return nil end + local path = "updates/full-update.json" + if not love.filesystem.getInfo(path) then return nil end + local text = love.filesystem.read(path) + if type(text) ~= "string" then return nil end + local ok, Json = pcall(require, "src.link.Json") + if not ok or not Json then return nil end + local decodedOk, requirement = pcall(Json.decode, text) + if not decodedOk or type(requirement) ~= "table" then return nil end + if type(requirement.version) ~= "string" then return nil end + return requirement +end + +local persistedRequirement = readPersistedFullRequirement() +if persistedRequirement then + cache = { + status = "needs_full", + latest = persistedRequirement.version, + reason = persistedRequirement.reason, + full = persistedRequirement.full, + } +end + local function ensureWorker() if workerReady ~= nil then return workerReady end if not Platform.networkValidated() then @@ -167,11 +239,13 @@ function Check.start(force) end requested = true cache = { status = "checking", notes = cache.notes, latest = cache.latest } - cmdCh:push({ cmd = "check" }) + cmdCh:push({ cmd = "check", target = target() }) end --- Current snapshot: { status, latest, progress, error, notes }. status is one of --- idle | checking | uptodate | available | downloading | ready | needs_full | error. +-- Current snapshot: { status, latest, progress, error, notes, reason, full }. +-- status is one of +-- idle | checking | uptodate | available | downloading | ready | needs_full | +-- full_downloading | full_ready | error. function Check.state() drain() return { @@ -180,9 +254,67 @@ function Check.state() progress = cache.progress, error = cache.error, notes = cache.notes, + reason = cache.reason, + full = cache.full, } end +-- The full-update record is intentionally persistent. An offline launch still +-- tells the player why this native shell cannot run the downloaded release. +function Check.fullUpdateAction() + drain() + local st = Check.state() + if st.status ~= "needs_full" and st.status ~= "full_ready" then return nil end + local osName = love and love.system and love.system.getOS and love.system.getOS() or "" + if osName == "Android" and type(love.system.installApk) == "function" + and type(st.full) == "table" and type(st.full.url) == "string" then + if st.status == "full_ready" and type(st.full.path) == "string" then + return { label = "Install Android update", kind = "install" } + end + return { label = "Download Android update", kind = "download" } + end + if osName == "iOS" then + return { label = "Re-sideload app", url = + "https://github.com/bryanthaboi/gen1recomp/raw/refs/heads/main/mobile/ios/app-repo.json" } + elseif osName == "UWP" then + return { label = "Open Xbox install guide", url = Check.releaseUrl() } + elseif type(st.full) == "table" and type(st.full.url) == "string" then + return { label = "Download full update", url = st.full.url } + end + return { label = "Open releases", url = Check.releaseUrl() } +end + +function Check.downloadFull() + drain() + if not cmdCh or cache.status ~= "needs_full" then return false end + if not (cache.full and cache.full.url) then return false end + cache = { status = "full_downloading", latest = cache.latest, progress = 0, + reason = cache.reason, full = cache.full, notes = cache.notes } + cmdCh:push({ cmd = "download_full" }) + return true +end + +function Check.installFull() + drain() + if cache.status ~= "full_ready" then return false end + local path = cache.full and cache.full.path + if type(path) ~= "string" or path == "" then return false end + if not (love and love.system and type(love.system.installApk) == "function") then return false end + local ok, started = pcall(love.system.installApk, path) + return ok and started == true +end + +function Check.performFullUpdate() + local action = Check.fullUpdateAction() + if not action then return false end + if action.kind == "download" then return Check.downloadFull() end + if action.kind == "install" then return Check.installFull() end + if action.url and love and love.system and love.system.openURL then + return pcall(love.system.openURL, action.url) + end + return false +end + -- Start downloading the payload announced by an "available" check. A no-op in -- any other state (the worker still holds the release info from the check). function Check.download() diff --git a/src/update/check_worker.lua b/src/update/check_worker.lua index 175a5457..81138572 100644 --- a/src/update/check_worker.lua +++ b/src/update/check_worker.lua @@ -2,7 +2,8 @@ -- -- Runs on a love.thread so no curl call, sha256 pass or archive probe ever -- touches the render thread. Talks over two channels: --- "update_check_cmd" in: { cmd = "check" | "download" | "quit" } +-- "update_check_cmd" in: { cmd = "check" | "download" | +-- "download_full" | "quit" } -- "update_check_state" out: { status, latest, progress, error } -- -- Transport is HostShell: curl via io.popen on desktop, the JNI @@ -44,6 +45,11 @@ local Boot = loadModule("src/update/Boot.lua") local cmdCh = love.thread.getChannel("update_check_cmd") local stateCh = love.thread.getChannel("update_check_state") +-- The release chosen by the last check. Declare this before post() so status +-- messages consistently preserve its release notes instead of accidentally +-- reading a global named `pending`. +local pending = nil + local function post(t) if pending and type(t) == "table" and t.notes == nil then t.notes = pending.notes @@ -57,10 +63,6 @@ local saveDir = love.filesystem.getSaveDirectory() local API_URL = "https://api.github.com/repos/bryanthaboi/gen1recomp/releases/latest" --- the release picked by the last "check"; kept between commands so "download" --- knows the payload url/size/name without re-fetching -local pending = nil - -- --------------------------------------------------------------------------- -- shell / fetch -- --------------------------------------------------------------------------- @@ -138,6 +140,10 @@ local function verifyPayload(rel, payloadName, sumsText) return true end +local function verifyFullPackage(rel, assetName, sumsText) + return verifyPayload(rel, assetName, sumsText) +end + -- true = ok to run, false = payload needs a newer shell (needs_full). When Boot -- cannot probe (module missing during parallel dev, or a probe failure) we allow -- it: Boot.run's crash-guard handles a payload that turns out unrunnable. @@ -147,8 +153,34 @@ local function gatePasses(rel) if not info then return true end local shell = (Version and Version.shell) or 1 local payloadHost = (Version and Version.payloadHost) or "love" - if Boot.canHost then return Boot.canHost(info, shell, payloadHost) end - return not (info.minShell and info.minShell > shell) + if info.payloadHost and info.payloadHost ~= payloadHost then return false, "payload_host" end + if info.minShell and info.minShell > shell then return false, "min_shell" end + if Boot.canHost and not Boot.canHost(info, shell, payloadHost) then return false, "shell_gate" end + return true +end + +local function persistFullRequirement(rel, reason) + if not (rel and rel.version and Json) then return end + local full = rel.full + local record = { + version = rel.version, + reason = reason or "full_package_required", + full = full and { name = rel.fullName, url = full.url, size = full.size } or nil, + } + pcall(function() + love.filesystem.createDirectory("updates") + love.filesystem.write("updates/full-update.json", Json.encode(record)) + end) +end + +local function postFullRequirement(rel, reason) + persistFullRequirement(rel, reason) + post({ status = "needs_full", latest = rel and rel.version, reason = reason, + full = rel and rel.full and { name = rel.fullName, url = rel.full.url, size = rel.full.size } or nil }) +end + +local function clearFullRequirement() + pcall(function() love.filesystem.remove("updates/full-update.json") end) end local function cacheNotes(ver, notes) @@ -177,7 +209,7 @@ end -- check -- --------------------------------------------------------------------------- -local function doCheck() +local function doCheck(target) post({ status = "checking" }) if not canFetch() then @@ -193,7 +225,7 @@ local function doCheck() return end - local rel, perr = Check.parseRelease(body, Json) + local rel, perr = Check.parseRelease(body, Json, target) if not rel then post({ status = "error", error = perr or "bad release json" }) return @@ -212,6 +244,9 @@ local function doCheck() end if compareVersions(rel.version, currentEngine) <= 0 then + -- We are now running a native shell at least as new as GitHub's latest + -- release, so a former minShell/payloadHost prompt no longer applies. + clearFullRequirement() post({ status = "uptodate", latest = rel.version }) return end @@ -219,7 +254,7 @@ local function doCheck() -- A newer release, but without the .love payload or its sums we cannot do an -- in-place update: send the user to the full installers. if not (rel.payload and rel.payload.url and rel.sums and rel.sums.url) then - post({ status = "needs_full", latest = rel.version }) + postFullRequirement(rel, "payload_missing") return end @@ -229,9 +264,10 @@ local function doCheck() if love.filesystem.getInfo(finalRel) then local sums = fetchText(rel.sums.url) if sums and verifyPayload(finalRel, rel.payloadName, sums) then - if gatePasses(finalRel) == false then + local allowed, reason = gatePasses(finalRel) + if allowed == false then love.filesystem.remove(finalRel) - post({ status = "needs_full", latest = rel.version }) + postFullRequirement(rel, reason) return end post({ status = "ready", latest = rel.version }) @@ -353,9 +389,10 @@ local function doDownload() return end - if gatePasses(partRel) == false then + local allowed, reason = gatePasses(partRel) + if allowed == false then love.filesystem.remove(partRel) - post({ status = "needs_full", latest = rel.version }) + postFullRequirement(rel, reason) return end @@ -374,6 +411,63 @@ local function doDownload() post({ status = "ready", latest = rel.version }) end +-- Full native-package download. At present Android consumes the verified file +-- through its Package Installer bridge. Other platforms retain the same +-- release metadata and fall back to their platform-specific external update +-- channel rather than attempting to overwrite a running executable. +local function doDownloadFull() + if not (pending and pending.full and pending.full.url and pending.fullName + and pending.sums and pending.sums.url) then + post({ status = "error", error = "full package is unavailable" }) + return + end + + local rel = pending + local asset = rel.full + local name = rel.fullName + love.filesystem.createDirectory("updates") + local partRel = "updates/" .. name .. ".part" + local doneRel = "updates/" .. name + local partAbs = saveDir .. "/" .. partRel + local doneAbs = saveDir .. "/" .. doneRel + love.filesystem.remove(partRel) + love.filesystem.remove(doneRel) + post({ status = "full_downloading", latest = rel.version, progress = 0, + reason = "full_package_required", full = { name = name, url = asset.url, size = asset.size } }) + + local ok = HostShell and HostShell.httpDownload(asset.url, partAbs, UA, nil, 900) + if not ok then + love.filesystem.remove(partRel) + postFullRequirement(rel, "full_download_failed") + return + end + + local sums = fetchText(rel.sums.url) + if not sums then + love.filesystem.remove(partRel) + postFullRequirement(rel, "full_checksum_fetch_failed") + return + end + local valid, err = verifyFullPackage(partRel, name, sums) + if not valid then + love.filesystem.remove(partRel) + post({ status = "error", error = err or "full package verification failed" }) + return + end + if not os.rename(partAbs, doneAbs) then + local data = love.filesystem.read(partRel) + if not data then + post({ status = "error", error = "full package finalize failed" }) + return + end + love.filesystem.write(doneRel, data) + love.filesystem.remove(partRel) + end + persistFullRequirement(rel, "full_package_required") + post({ status = "full_ready", latest = rel.version, reason = "full_package_required", + full = { name = name, url = asset.url, size = asset.size, path = doneAbs } }) +end + -- --------------------------------------------------------------------------- -- command loop -- --------------------------------------------------------------------------- @@ -384,11 +478,14 @@ while true do if cmd.cmd == "quit" then break elseif cmd.cmd == "check" then - local ok, err = pcall(doCheck) + local ok, err = pcall(doCheck, cmd.target) if not ok then post({ status = "error", error = tostring(err) }) end elseif cmd.cmd == "download" then local ok, err = pcall(doDownload) if not ok then post({ status = "error", error = tostring(err) }) end + elseif cmd.cmd == "download_full" then + local ok, err = pcall(doDownloadFull) + if not ok then post({ status = "error", error = tostring(err) }) end end end end diff --git a/tests/engine/update_check_tests.lua b/tests/engine/update_check_tests.lua index 1fab2838..96d8111f 100644 --- a/tests/engine/update_check_tests.lua +++ b/tests/engine/update_check_tests.lua @@ -34,6 +34,20 @@ eq(rel.payload.size, 12345, "payload asset size picked") eq(rel.sums.url, "http://x/sums", "sums asset url picked") eq(rel.notes, "", "missing release body becomes empty notes") +-- Native package assets are target-specific. This is intentionally separate +-- from the generic .love payload, which a new shell may be unable to host. +local android = Check.parseRelease(body, nil, { os = "Android", arch = "arm64" }) +eq(android.fullName, "gen1recomp-1.4.2-android.apk", "Android package name derived") +eq(android.full, nil, "missing Android package is surfaced as nil") +eq(Check.fullAssetName("1.4.2", "Android", "arm64"), + "gen1recomp-1.4.2-android.apk", "Android full asset mapping") +eq(Check.fullAssetName("1.4.2", "Linux", "aarch64"), + "gen1recomp-1.4.2-linux-arm64.AppImage", "Linux ARM package mapping") +eq(Check.fullAssetName("1.4.2", "iOS", "arm64"), + "gen1recomp++-1.4.2-ios.ipa", "iOS package mapping") +eq(Check.fullAssetName("not-a-version", "Android", "arm64"), nil, + "invalid full-package version rejected") + local withNotes = Check.parseRelease(Json.encode({ tag_name = "v1.4.2", body = "## Issues closed\n\n- #1 cart padding",