mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-21 21:16:28 +02:00
updater stuff
This commit is contained in:
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
```
|
||||
|
||||
@@ -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>
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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/" \;
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user