updater stuff

This commit is contained in:
bryanthaboi
2026-08-20 17:30:48 -04:00
parent dbecc345e3
commit ada0d8abe1
22 changed files with 596 additions and 56 deletions
+25 -4
View File
@@ -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"
+23 -4
View File
@@ -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-<version>.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-<X.Y.Z>.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
+15 -5
View File
@@ -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-<version>-android.apk`.
`gen1recomp-<version>-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.
+1 -1
View File
@@ -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:
```
+27 -3
View File
@@ -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']
@@ -8,6 +8,10 @@
the link screen shows as "(Operation not permitted)" (issue #287).
scripts/build_android.sh must not strip this again. -->
<uses-permission android:name="android.permission.INTERNET" />
<!-- Required only to hand a checksum-verified, user-selected GitHub release
APK to Android's own Package Installer. Android still shows the install
confirmation and enforces package/signing-key/version compatibility. -->
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<!-- Step bridge: love.system.syncHealthSteps reads the hardware step
counter, which Android 10+ gates behind this runtime permission.
Requested only on the first sync call (the Pokéwalker mod's SYNC
@@ -35,6 +39,18 @@
<meta-data
android:name="android.allow_multiple_resumed_activities"
android:value="true" />
<!-- The full-update APK is copied into this small cache subdirectory
before it is handed to Package Installer. Keep the provider private
and expose only that directory, never a storage root. -->
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.full_update_provider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/full_update_paths" />
</provider>
<activity
android:name="org.love2d.android.GameActivity"
android:exported="true"
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Deliberately narrow: FileProvider may grant only the staged APK, never
arbitrary app, external, or shared storage. -->
<paths xmlns:android="http://schemas.android.com/apk/res/android">
<cache-path name="full_update" path="full-update/" />
</paths>
+2 -1
View File
@@ -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
-1
View File
@@ -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
+1 -1
View File
@@ -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
+3 -3
View File
@@ -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()
@@ -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<std::string> &versions)
{
JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv();
@@ -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.
**/
@@ -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<std::string> &versions) const
{
#ifdef LOVE_ANDROID
@@ -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<std::string> &versions) const;
virtual std::string getLaunchGame() const;
@@ -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 },
@@ -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 : "";
+39 -10
View File
@@ -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/" \;
+15 -3
View File
@@ -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"),
+137 -5
View File
@@ -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()
+112 -15
View File
@@ -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
+14
View File
@@ -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",