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 \ unzip -l dist/win/gen1recomp-win64.zip | grep -F gen1tls.dll \
|| { echo "::error::Windows zip is missing gen1tls.dll"; exit 1; } || { 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: | run: |
set -euo pipefail 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 - name: Install xcbeautify
run: | run: |
@@ -498,8 +519,8 @@ jobs:
[ -f "$arm64_appimage" ] || { echo "::error::$arm64_appimage not found (expected from the linux-arm64 job)"; exit 1; } [ -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" cp "$arm64_appimage" "$outdir/gen1recomp-${v}-linux-arm64.AppImage"
chmod +x "$outdir/gen1recomp-${v}-linux-arm64.AppImage" chmod +x "$outdir/gen1recomp-${v}-linux-arm64.AppImage"
apk="$(find dist/android/debug -name '*.apk' | head -1)" apk="$(find dist/android/release -name '*.apk' | head -1)"
[ -n "$apk" ] || { echo "::error::no Android APK found under dist/android/debug"; exit 1; } [ -n "$apk" ] || { echo "::error::no Android APK found under dist/android/release"; exit 1; }
cp "$apk" "$outdir/gen1recomp-${v}-android.apk" cp "$apk" "$outdir/gen1recomp-${v}-android.apk"
ipa="dist/ios/gen1recomp++.ipa" 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 Each tagged release `vX.Y.Z` carries the existing per-platform archives
(`gen1recomp-X.Y.Z-macos.zip`, `-windows.zip`, `-linux.zip`, (`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-X.Y.Z.love` - the payload, matched by the exact pattern
`gen1recomp-<version>.love` (see `isPayloadName` in `Boot.lua` and `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. filename otherwise to match the asset name exactly.
A release missing either asset is treated as "no in-place update available": A release missing either asset is treated as "no in-place update available":
`Check` reports `needs_full` and sends the player to `Check.releaseUrl()` `Check` reports `needs_full`. It also selects the exact current platform asset
(`https://github.com/bryanthaboi/gen1recomp/releases/latest`). from the same release and persists the requirement, so it is visible again on
every launch, including offline launches.
## Save-directory layout ## Save-directory layout
@@ -85,6 +87,7 @@ Under the save directory (identity `pokemon-love2d`):
``` ```
updates/gen1recomp-<X.Y.Z>.love downloaded payload(s) updates/gen1recomp-<X.Y.Z>.love downloaded payload(s)
updates/pending.txt crash-guard marker 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. `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 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()` once a check is in flight or has reached a terminal state. `Check.state()`
reports `idle | checking | uptodate | available | downloading | ready | 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 3. **Download + verify**: on `available`, `Check.download()` tells the
worker to fetch the payload, polling the growing `.part` file for worker to fetch the payload, polling the growing `.part` file for
progress. On completion the worker re-fetches `sha256sums.txt`, verifies 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 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 player relaunches; the next launch's Boot step (1) is what actually
mounts and runs it. There is no in-session hot-swap. 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 ## Known limitations
@@ -140,6 +152,13 @@ bundled game, in that case.
still need a full reinstall (`minShell` / `payloadHost` gate → still need a full reinstall (`minShell` / `payloadHost` gate →
`needs_full`). Applying a downloaded payload on Android relaunches via `needs_full`). Applying a downloaded payload on Android relaunches via
`love.system.restartApp`; iOS still uses in-process `quit("restart")`. `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 - **Dev/source runs never self-update.** `Boot.run` returns immediately when
`love.filesystem.isFused()` is false, and a working tree's `engine` is the `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 `"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: love-android 11.5a expects:
- **JDK 17** - **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) - 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 Set `ANDROID_SDK_ROOT` (or `ANDROID_HOME`), or let the script write
`local.properties` when it finds `~/Library/Android/sdk`. `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.application_id` | `com.theboisclub.pokemonred` |
| `app.name` | Pokemon Red | | `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.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 | | `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 | | 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 ## Releases
`.github/workflows/release.yml` builds the APK with `--version` set to the `.github/workflows/release.yml` builds the APK with `--version` set to the
release version and publishes it alongside the macOS/Windows/Linux builds as release version and publishes it alongside the macOS/Windows/Linux builds as
`PokemonRed-<version>-android.apk`. `gen1recomp-<version>-android.apk`.
## Signing ## 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 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. 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: `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"] applicationId project.properties["app.application_id"]
versionCode project.properties["app.version_code"].toInteger() versionCode project.properties["app.version_code"].toInteger()
versionName project.properties["app.version_name"] versionName project.properties["app.version_name"]
minSdk 16 // NDK r25 no longer supports API 16; API 19 is Android 4.4 and keeps
compileSdk 34 // the native toolchain and package-installer bridge on a supported ABI.
targetSdk 34 minSdk 19
// Android 16 / API 36: current Android distribution target.
compileSdk 36
targetSdk 36
def getAppName = { def getAppName = {
def nameArray = project.properties["app.name_byte_array"] def nameArray = project.properties["app.name_byte_array"]
@@ -38,10 +41,31 @@ android {
ORIENTATION:project.properties["app.orientation"], 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 { buildTypes {
release { release {
minifyEnabled true minifyEnabled true
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
if (hasReleaseSigning) signingConfig signingConfigs.release
} }
} }
flavorDimensions = ['mode', 'recording'] flavorDimensions = ['mode', 'recording']
@@ -8,6 +8,10 @@
the link screen shows as "(Operation not permitted)" (issue #287). the link screen shows as "(Operation not permitted)" (issue #287).
scripts/build_android.sh must not strip this again. --> scripts/build_android.sh must not strip this again. -->
<uses-permission android:name="android.permission.INTERNET" /> <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 <!-- Step bridge: love.system.syncHealthSteps reads the hardware step
counter, which Android 10+ gates behind this runtime permission. counter, which Android 10+ gates behind this runtime permission.
Requested only on the first sync call (the Pokéwalker mod's SYNC Requested only on the first sync call (the Pokéwalker mod's SYNC
@@ -35,6 +39,18 @@
<meta-data <meta-data
android:name="android.allow_multiple_resumed_activities" android:name="android.allow_multiple_resumed_activities"
android:value="true" /> 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 <activity
android:name="org.love2d.android.GameActivity" android:name="org.love2d.android.GameActivity"
android:exported="true" 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() mavenCentral()
} }
dependencies { 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 // NOTE: Do not place your application dependencies here; they belong
// in the individual module build.gradle files // 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! # No need to modify anything past this line!
android.enableJetifier=false android.enableJetifier=false
android.useAndroidX=true android.useAndroidX=true
android.defaults.buildfeatures.buildconfig=true
android.nonTransitiveRClass=true android.nonTransitiveRClass=true
android.nonFinalResIds=true android.nonFinalResIds=true
app.name=gen1recomp app.name=gen1recomp
+1 -1
View File
@@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists 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 networkTimeout=10000
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists zipStorePath=wrapper/dists
+3 -3
View File
@@ -10,9 +10,9 @@ android {
ndkVersion '25.2.9519653' ndkVersion '25.2.9519653'
defaultConfig { defaultConfig {
minSdk 16 minSdk 19
compileSdk 34 compileSdk 36
targetSdk 34 targetSdk 36
externalNativeBuild { externalNativeBuild {
ndkBuild { ndkBuild {
arguments "-j" + Runtime.runtime.availableProcessors() arguments "-j" + Runtime.runtime.availableProcessors()
@@ -283,6 +283,40 @@ bool restartApp()
return result; 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) bool updateAppShortcuts(const std::vector<std::string> &versions)
{ {
JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv(); JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv();
@@ -90,6 +90,12 @@ bool syncHealthSteps();
**/ **/
bool restartApp(); 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. * Dynamic App Shortcuts: updates Android ShortcutManager with ready game versions.
**/ **/
@@ -245,6 +245,16 @@ bool System::restartApp() const
#endif #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 bool System::updateShortcuts(const std::vector<std::string> &versions) const
{ {
#ifdef LOVE_ANDROID #ifdef LOVE_ANDROID
@@ -143,6 +143,9 @@ public:
**/ **/
virtual bool restartApp() const; 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 bool updateShortcuts(const std::vector<std::string> &versions) const;
virtual std::string getLaunchGame() const; virtual std::string getLaunchGame() const;
@@ -132,6 +132,13 @@ int w_restartApp(lua_State *L)
return 1; 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) int w_httpDownload(lua_State *L)
{ {
const char *url = luaL_checkstring(L, 1); const char *url = luaL_checkstring(L, 1);
@@ -325,6 +332,7 @@ static const luaL_Reg functions[] =
{ "createFile", w_createFile }, { "createFile", w_createFile },
{ "syncHealthSteps", w_syncHealthSteps }, { "syncHealthSteps", w_syncHealthSteps },
{ "restartApp", w_restartApp }, { "restartApp", w_restartApp },
{ "installApk", w_installApk },
{ "updateShortcuts", w_updateShortcuts }, { "updateShortcuts", w_updateShortcuts },
{ "getLaunchGame", w_getLaunchGame }, { "getLaunchGame", w_getLaunchGame },
{ "httpDownload", w_httpDownload }, { "httpDownload", w_httpDownload },
@@ -45,6 +45,7 @@ import android.app.AlarmManager;
import android.app.AlertDialog; import android.app.AlertDialog;
import android.app.PendingIntent; import android.app.PendingIntent;
import android.content.Context; import android.content.Context;
import android.content.ClipData;
import android.content.DialogInterface; import android.content.DialogInterface;
import android.content.Intent; import android.content.Intent;
import android.content.SharedPreferences; import android.content.SharedPreferences;
@@ -77,6 +78,7 @@ import android.view.*;
import androidx.annotation.Keep; import androidx.annotation.Keep;
import androidx.core.app.ActivityCompat; import androidx.core.app.ActivityCompat;
import androidx.core.content.FileProvider;
public class GameActivity extends SDLActivity { public class GameActivity extends SDLActivity {
private static DisplayMetrics metrics = null; private static DisplayMetrics metrics = null;
@@ -696,6 +698,103 @@ public class GameActivity extends SDLActivity {
return true; // unreachable, but keeps the JNI signature honest 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 @Keep
public static String getLaunchGame() { public static String getLaunchGame() {
return initialGame != null ? initialGame : ""; return initialGame != null ? initialGame : "";
+39 -10
View File
@@ -1,19 +1,20 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Packages the LÖVE2D Pokémon Red port into an Android APK via love-android 11.5a. # 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) # --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 # --package-only zip game.love + apply branding; skip gradle
# #
# Prerequisites: # Prerequisites:
# - mobile/android vendored love-android tree at tag 11.5a (in-repo; see mobile/ANDROID.md) # - 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 # - JDK 17
# #
# Output (after gradle): # Output (after gradle):
# dist/android/debug/*.apk (convenience copy) # dist/android/debug/*.apk (normal local build) or dist/android/release/*.apk
# mobile/android/app/build/outputs/apk/embedNoRecord/debug/*.apk
set -euo pipefail set -euo pipefail
@@ -26,6 +27,7 @@ APP_NAME="gen1recomp"
APPLICATION_ID="com.theboisclub.pokemonred" APPLICATION_ID="com.theboisclub.pokemonred"
LOVE_ANDROID_VERSION="11.5a" LOVE_ANDROID_VERSION="11.5a"
NDK_VERSION="25.2.9519653" NDK_VERSION="25.2.9519653"
ANDROID_API="36"
YELLOW_MANIFEST_RELATIVE="tools/rom_manifest_yellow.json" 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}" 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" GOLD_MANIFEST_RELATIVE="tools/rom_manifest_gold.json"
@@ -35,6 +37,7 @@ SILVER_MANIFEST_URL="${SILVER_MANIFEST_URL:-https://raw.githubusercontent.com/br
VERSION="" VERSION=""
PACKAGE_ONLY=false PACKAGE_ONLY=false
RELEASE=false
say() { printf '\033[1;32m==>\033[0m %s\n' "$*"; } say() { printf '\033[1;32m==>\033[0m %s\n' "$*"; }
warn() { printf '\033[1;33mwarn:\033[0m %s\n' "$*" >&2; } warn() { printf '\033[1;33mwarn:\033[0m %s\n' "$*" >&2; }
@@ -44,11 +47,12 @@ while [ $# -gt 0 ]; do
case "$1" in case "$1" in
--version) VERSION="$2"; shift ;; --version) VERSION="$2"; shift ;;
--package-only) PACKAGE_ONLY=true ;; --package-only) PACKAGE_ONLY=true ;;
--release) RELEASE=true ;;
-h|--help) -h|--help)
sed -n '2,20p' "$0" sed -n '2,20p' "$0"
exit 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 esac
shift shift
done done
@@ -62,7 +66,22 @@ if [ -n "$VERSION" ]; then
rest="${VERSION#*.}" rest="${VERSION#*.}"
minor="${rest%%.*}" minor="${rest%%.*}"
patch="${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 fi
# --------------------------------------------------------------- preconditions # --------------------------------------------------------------- preconditions
@@ -389,13 +408,18 @@ require_android_sdk() {
export ANDROID_SDK_ROOT=\$HOME/Library/Android/sdk export ANDROID_SDK_ROOT=\$HOME/Library/Android/sdk
or create mobile/android/local.properties with: or create mobile/android/local.properties with:
sdk.dir=/path/to/Android/sdk 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)." (see mobile/ANDROID.md)."
fi fi
export ANDROID_SDK_ROOT="$sdk" export ANDROID_SDK_ROOT="$sdk"
export ANDROID_HOME="$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" local props="$ANDROID_DIR/local.properties"
# Always rewrite so a leftover Docker sdk.dir=/opt/android-sdk cannot stick. # Always rewrite so a leftover Docker sdk.dir=/opt/android-sdk cannot stick.
printf 'sdk.dir=%s\n' "$sdk" > "$props" printf 'sdk.dir=%s\n' "$sdk" > "$props"
@@ -412,7 +436,12 @@ require_android_sdk() {
# --------------------------------------------------------------- gradle # --------------------------------------------------------------- gradle
run_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" local build_dir="$ANDROID_DIR"
# ndk-build is GNU make underneath and cannot cope with spaces anywhere in # 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" You can still iterate on the .love payload with: scripts/build_android.sh --package-only"
fi 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 if [ -d "$out_dir" ]; then
say "APK output:" say "APK output:"
find "$out_dir" -name '*.apk' -exec ls -lh {} \; find "$out_dir" -name '*.apk' -exec ls -lh {} \;
local dist_dir="$DIST/debug" local dist_dir="$DIST/$variant"
rm -rf "$dist_dir" rm -rf "$dist_dir"
mkdir -p "$dist_dir" mkdir -p "$dist_dir"
find "$out_dir" -name '*.apk' -exec cp {} "$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 elseif status == "downloading" then
local pct = st.progress and math.floor(st.progress * 100) or 0 local pct = st.progress and math.floor(st.progress * 100) or 0
return status, Strings("Updating %d%%", pct), nil, false 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 elseif status == "available" then
return status, st.latest and (Strings("Update v") .. st.latest) return status, st.latest and (Strings("Update v") .. st.latest)
or Strings("Update"), function() pcall(imp.Check.download) end, true or Strings("Update"), function() pcall(imp.Check.download) end, true
elseif status == "ready" then elseif status == "ready" then
return status, Strings("Restart to update"), return status, Strings("Restart to update"),
function() require("src.core.HostShell").restart() end, true function() require("src.core.HostShell").restart() end, true
elseif status == "needs_full" then elseif status == "needs_full" or status == "full_ready" then
return status, Strings("Open releases"), local action = imp.Check.fullUpdateAction and imp.Check.fullUpdateAction()
function() love.system.openURL(imp.Check.releaseUrl()) end, true 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 end
-- idle / uptodate / error: offer a manual check, with no glow. -- idle / uptodate / error: offer a manual check, with no glow.
return status, Strings("Check for updates"), 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 -- 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 -- is only the thin main-thread state machine the UI polls. Two channels carry
-- the conversation: -- 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 } -- "update_check_state" worker -> main: { status, latest, progress, error }
-- --
-- Nothing here ever blocks or throws into the game loop: when love.thread is -- 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" 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 CMD = "update_check_cmd"
local STATE = "update_check_state" 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 -- 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 -- worker can pass a filesystem-loaded codec; on the main thread / in tests it
-- falls back to require. -- falls back to require.
function Check.parseRelease(jsonText, Json) function Check.parseRelease(jsonText, Json, target)
Json = Json or require("src.link.Json") Json = Json or require("src.link.Json")
local notJson = Json.describeUnexpected(jsonText) local notJson = Json.describeUnexpected(jsonText)
if notJson then return nil, notJson end 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) return nil, "release tag is not X.Y.Z: " .. tostring(doc.tag_name)
end end
local payloadName = "gen1recomp-" .. version .. ".love" local payloadName = "gen1recomp-" .. version .. ".love"
target = type(target) == "table" and target or {}
local fullName = fullAssetName(version, target.os, target.arch, target.port)
return { return {
version = version, version = version,
payloadName = payloadName, payloadName = payloadName,
payload = Check.pickAsset(doc.assets, payloadName), payload = Check.pickAsset(doc.assets, payloadName),
sums = Check.pickAsset(doc.assets, "sha256sums.txt"), 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 -- GitHub release body: already fetched with the update check, shown by
-- the launcher's Patch notes footer button. -- the launcher's Patch notes footer button.
notes = type(doc.body) == "string" and doc.body or "", 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 requested -- a check has been asked for this session
local cache = { status = "idle" } -- newest snapshot from the worker 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() local function ensureWorker()
if workerReady ~= nil then return workerReady end if workerReady ~= nil then return workerReady end
if not Platform.networkValidated() then if not Platform.networkValidated() then
@@ -167,11 +239,13 @@ function Check.start(force)
end end
requested = true requested = true
cache = { status = "checking", notes = cache.notes, latest = cache.latest } cache = { status = "checking", notes = cache.notes, latest = cache.latest }
cmdCh:push({ cmd = "check" }) cmdCh:push({ cmd = "check", target = target() })
end end
-- Current snapshot: { status, latest, progress, error, notes }. status is one of -- Current snapshot: { status, latest, progress, error, notes, reason, full }.
-- idle | checking | uptodate | available | downloading | ready | needs_full | error. -- status is one of
-- idle | checking | uptodate | available | downloading | ready | needs_full |
-- full_downloading | full_ready | error.
function Check.state() function Check.state()
drain() drain()
return { return {
@@ -180,9 +254,67 @@ function Check.state()
progress = cache.progress, progress = cache.progress,
error = cache.error, error = cache.error,
notes = cache.notes, notes = cache.notes,
reason = cache.reason,
full = cache.full,
} }
end 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 -- 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). -- any other state (the worker still holds the release info from the check).
function Check.download() 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 -- Runs on a love.thread so no curl call, sha256 pass or archive probe ever
-- touches the render thread. Talks over two channels: -- 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 } -- "update_check_state" out: { status, latest, progress, error }
-- --
-- Transport is HostShell: curl via io.popen on desktop, the JNI -- 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 cmdCh = love.thread.getChannel("update_check_cmd")
local stateCh = love.thread.getChannel("update_check_state") 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) local function post(t)
if pending and type(t) == "table" and t.notes == nil then if pending and type(t) == "table" and t.notes == nil then
t.notes = pending.notes 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" 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 -- shell / fetch
-- --------------------------------------------------------------------------- -- ---------------------------------------------------------------------------
@@ -138,6 +140,10 @@ local function verifyPayload(rel, payloadName, sumsText)
return true return true
end 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 -- 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 -- 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. -- 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 if not info then return true end
local shell = (Version and Version.shell) or 1 local shell = (Version and Version.shell) or 1
local payloadHost = (Version and Version.payloadHost) or "love" local payloadHost = (Version and Version.payloadHost) or "love"
if Boot.canHost then return Boot.canHost(info, shell, payloadHost) end if info.payloadHost and info.payloadHost ~= payloadHost then return false, "payload_host" end
return not (info.minShell and info.minShell > shell) 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 end
local function cacheNotes(ver, notes) local function cacheNotes(ver, notes)
@@ -177,7 +209,7 @@ end
-- check -- check
-- --------------------------------------------------------------------------- -- ---------------------------------------------------------------------------
local function doCheck() local function doCheck(target)
post({ status = "checking" }) post({ status = "checking" })
if not canFetch() then if not canFetch() then
@@ -193,7 +225,7 @@ local function doCheck()
return return
end end
local rel, perr = Check.parseRelease(body, Json) local rel, perr = Check.parseRelease(body, Json, target)
if not rel then if not rel then
post({ status = "error", error = perr or "bad release json" }) post({ status = "error", error = perr or "bad release json" })
return return
@@ -212,6 +244,9 @@ local function doCheck()
end end
if compareVersions(rel.version, currentEngine) <= 0 then 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 }) post({ status = "uptodate", latest = rel.version })
return return
end end
@@ -219,7 +254,7 @@ local function doCheck()
-- A newer release, but without the .love payload or its sums we cannot do an -- 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. -- 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 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 return
end end
@@ -229,9 +264,10 @@ local function doCheck()
if love.filesystem.getInfo(finalRel) then if love.filesystem.getInfo(finalRel) then
local sums = fetchText(rel.sums.url) local sums = fetchText(rel.sums.url)
if sums and verifyPayload(finalRel, rel.payloadName, sums) then 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) love.filesystem.remove(finalRel)
post({ status = "needs_full", latest = rel.version }) postFullRequirement(rel, reason)
return return
end end
post({ status = "ready", latest = rel.version }) post({ status = "ready", latest = rel.version })
@@ -353,9 +389,10 @@ local function doDownload()
return return
end end
if gatePasses(partRel) == false then local allowed, reason = gatePasses(partRel)
if allowed == false then
love.filesystem.remove(partRel) love.filesystem.remove(partRel)
post({ status = "needs_full", latest = rel.version }) postFullRequirement(rel, reason)
return return
end end
@@ -374,6 +411,63 @@ local function doDownload()
post({ status = "ready", latest = rel.version }) post({ status = "ready", latest = rel.version })
end 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 -- command loop
-- --------------------------------------------------------------------------- -- ---------------------------------------------------------------------------
@@ -384,11 +478,14 @@ while true do
if cmd.cmd == "quit" then if cmd.cmd == "quit" then
break break
elseif cmd.cmd == "check" then 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 if not ok then post({ status = "error", error = tostring(err) }) end
elseif cmd.cmd == "download" then elseif cmd.cmd == "download" then
local ok, err = pcall(doDownload) local ok, err = pcall(doDownload)
if not ok then post({ status = "error", error = tostring(err) }) end 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 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.sums.url, "http://x/sums", "sums asset url picked")
eq(rel.notes, "", "missing release body becomes empty notes") 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({ local withNotes = Check.parseRelease(Json.encode({
tag_name = "v1.4.2", tag_name = "v1.4.2",
body = "## Issues closed\n\n- #1 cart padding", body = "## Issues closed\n\n- #1 cart padding",