mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-21 13:09:54 +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 \
|
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
@@ -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
@@ -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.
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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/" \;
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user