capgo and adk autoupdates. card tags

This commit is contained in:
azykov@mail.ru
2026-09-29 19:36:08 +03:00
parent 1981d976eb
commit b6fb2fe40f
43 changed files with 1329 additions and 54 deletions
+35 -2
View File
@@ -1,5 +1,20 @@
apply plugin: 'com.android.application'
// ---- Cardy: versioning and signing ----
// versionCode from the build time (UTC): yyDDDHHmm (year, day of year, time), e.g. 262721430.
// Grows with every build and stays below Android's limit (2100000000) until 2099.
// Override with -PcardyVersionCode=... (the APK publishing script passes the value it records).
def cardyVersionCode = project.findProperty('cardyVersionCode') ?: new Date().format('yyDDDHHmm', TimeZone.getTimeZone('UTC'))
def cardyVersionName = project.findProperty('cardyVersionName') ?: '1.0'
// Release key: android/keystore.properties (not in git) with storeFile, storePassword, keyAlias, keyPassword.
// Debug builds use the same key so that Android Studio builds and published APKs can replace each other.
def keystoreProps = new Properties()
def keystoreFile = rootProject.file('keystore.properties')
if (keystoreFile.exists()) keystoreFile.withInputStream { keystoreProps.load(it) }
def hasReleaseKey = ['storeFile', 'storePassword', 'keyAlias', 'keyPassword'].every { keystoreProps.getProperty(it)?.trim() }
android {
namespace = "com.aggtaa.cardy"
compileSdk = rootProject.ext.compileSdkVersion
@@ -7,8 +22,8 @@ android {
applicationId "com.aggtaa.cardy"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 1
versionName "1.0"
versionCode cardyVersionCode as Integer
versionName cardyVersionName
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
@@ -16,14 +31,32 @@ android {
ignoreAssetsPattern = '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~'
}
}
signingConfigs {
if (hasReleaseKey) {
cardy {
storeFile file(keystoreProps.getProperty('storeFile').trim())
storePassword keystoreProps.getProperty('storePassword')
keyAlias keystoreProps.getProperty('keyAlias').trim()
keyPassword keystoreProps.getProperty('keyPassword')
}
}
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
if (hasReleaseKey) signingConfig signingConfigs.cardy
}
debug {
if (hasReleaseKey) signingConfig signingConfigs.cardy
}
}
}
if (!hasReleaseKey) {
logger.warn('Cardy: android/keystore.properties is missing or incomplete: debug builds use the debug key, release builds are unsigned.')
}
repositories {
flatDir{
dirs '../capacitor-cordova-android-plugins/src/main/libs', 'libs'
@@ -11,6 +11,7 @@ apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
dependencies {
implementation project(':capacitor-app')
implementation project(':capacitor-preferences')
implementation project(':capgo-capacitor-updater')
}
@@ -0,0 +1,37 @@
{
"version": 3,
"artifactType": {
"type": "APK",
"kind": "Directory"
},
"applicationId": "com.aggtaa.cardy",
"variantName": "release",
"elements": [
{
"type": "SINGLE",
"filters": [],
"attributes": [],
"versionCode": 1,
"versionName": "1.0",
"outputFile": "app-release.apk"
}
],
"elementType": "File",
"baselineProfiles": [
{
"minApi": 28,
"maxApi": 30,
"baselineProfiles": [
"baselineProfiles/1/app-release.dm"
]
},
{
"minApi": 31,
"maxApi": 2147483647,
"baselineProfiles": [
"baselineProfiles/0/app-release.dm"
]
}
],
"minSdkVersionForDexing": 24
}
@@ -32,6 +32,11 @@
</activity>
<!-- Results of self-updates via PackageInstaller (ApkUpdaterPlugin). -->
<receiver
android:name=".ApkInstallReceiver"
android:exported="false" />
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
@@ -49,4 +54,7 @@
<!-- Scanning key QR codes and card barcodes (getUserMedia in the WebView). -->
<uses-permission android:name="android.permission.CAMERA" />
<uses-feature android:name="android.hardware.camera" android:required="false" />
<!-- Self-update: install newer APKs of this app downloaded from the Cardy server. -->
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<uses-permission android:name="android.permission.UPDATE_PACKAGES_WITHOUT_USER_ACTION" />
</manifest>
@@ -0,0 +1,15 @@
package com.aggtaa.cardy;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
/** Receives PackageInstaller results for self-updates (see ApkUpdaterPlugin). */
public class ApkInstallReceiver extends BroadcastReceiver {
@Override
public void onReceive(Context context, Intent intent) {
if (ApkUpdaterPlugin.ACTION_INSTALL_STATUS.equals(intent.getAction())) {
ApkUpdaterPlugin.onInstallStatus(intent);
}
}
}
@@ -0,0 +1,186 @@
package com.aggtaa.cardy;
import android.app.PendingIntent;
import android.content.Intent;
import android.content.pm.PackageInstaller;
import android.net.Uri;
import android.os.Build;
import android.provider.Settings;
import com.getcapacitor.JSObject;
import com.getcapacitor.Plugin;
import com.getcapacitor.PluginCall;
import com.getcapacitor.PluginMethod;
import com.getcapacitor.annotation.CapacitorPlugin;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.HttpURLConnection;
import java.net.URL;
import java.security.MessageDigest;
/**
* Self-update of the whole app (outside of Play Store): downloads an APK, checks its SHA-256 and hands
* it to the system installer. Android asks the user to confirm unless it allows an update without user
* action (Android 12+, when this app installed itself last time).
*
* Events: "progress" { downloaded, total }, "status" { status: "installing" | "confirm" | "failed", message }.
* A successful install replaces and restarts the app, so there is no "done" event.
*/
@CapacitorPlugin(name = "ApkUpdater")
public class ApkUpdaterPlugin extends Plugin {
static final String ACTION_INSTALL_STATUS = "com.aggtaa.cardy.APK_INSTALL_STATUS";
private static ApkUpdaterPlugin instance;
private volatile boolean busy = false;
@Override
public void load() {
instance = this;
}
/** Whether the user allowed this app to install apps ("Install unknown apps"). */
@PluginMethod
public void canInstall(PluginCall call) {
JSObject result = new JSObject();
result.put("allowed", Build.VERSION.SDK_INT < 26 || getContext().getPackageManager().canRequestPackageInstalls());
call.resolve(result);
}
/** Opens the system screen where the user allows installing apps from Cardy. */
@PluginMethod
public void openInstallSettings(PluginCall call) {
if (Build.VERSION.SDK_INT >= 26) {
Intent intent = new Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES, Uri.parse("package:" + getContext().getPackageName()));
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
getContext().startActivity(intent);
}
call.resolve();
}
/** Downloads { url } , verifies { sha256 } and starts the installation. Resolves once installation has started. */
@PluginMethod
public void downloadAndInstall(PluginCall call) {
String url = call.getString("url");
String sha256 = call.getString("sha256");
if (url == null || sha256 == null) {
call.reject("url and sha256 are required");
return;
}
if (busy) {
call.reject("An update is already in progress");
return;
}
busy = true;
new Thread(() -> {
File apk = new File(getContext().getCacheDir(), "cardy-update.apk");
try {
download(url, apk);
String actual = sha256Hex(apk);
if (!actual.equalsIgnoreCase(sha256)) throw new IOException("Checksum mismatch");
install(apk);
call.resolve();
} catch (Exception e) {
busy = false;
apk.delete();
call.reject(e.getMessage() != null ? e.getMessage() : e.toString());
}
}).start();
}
private void download(String url, File target) throws IOException {
HttpURLConnection conn = (HttpURLConnection) new URL(url).openConnection();
conn.setConnectTimeout(20000);
conn.setReadTimeout(30000);
try {
if (conn.getResponseCode() != 200) throw new IOException("Download failed: HTTP " + conn.getResponseCode());
long total = conn.getContentLengthLong();
try (InputStream in = conn.getInputStream(); OutputStream out = new FileOutputStream(target)) {
byte[] buffer = new byte[64 * 1024];
long done = 0;
long lastReport = 0;
int n;
while ((n = in.read(buffer)) != -1) {
out.write(buffer, 0, n);
done += n;
if (done - lastReport >= 256 * 1024 || done == total) {
lastReport = done;
JSObject progress = new JSObject();
progress.put("downloaded", done);
progress.put("total", total);
notifyListeners("progress", progress);
}
}
}
} finally {
conn.disconnect();
}
}
private static String sha256Hex(File file) throws Exception {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
try (InputStream in = new FileInputStream(file)) {
byte[] buffer = new byte[64 * 1024];
int n;
while ((n = in.read(buffer)) != -1) digest.update(buffer, 0, n);
}
StringBuilder hex = new StringBuilder();
for (byte b : digest.digest()) hex.append(String.format("%02x", b));
return hex.toString();
}
private void install(File apk) throws IOException {
PackageInstaller installer = getContext().getPackageManager().getPackageInstaller();
PackageInstaller.SessionParams params = new PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL);
params.setAppPackageName(getContext().getPackageName());
if (Build.VERSION.SDK_INT >= 31) {
// Granted only when Android allows it (this app is the installer of record); otherwise it asks.
params.setRequireUserAction(PackageInstaller.SessionParams.USER_ACTION_NOT_REQUIRED);
}
int sessionId = installer.createSession(params);
try (PackageInstaller.Session session = installer.openSession(sessionId)) {
try (InputStream in = new FileInputStream(apk); OutputStream out = session.openWrite("cardy.apk", 0, apk.length())) {
byte[] buffer = new byte[64 * 1024];
int n;
while ((n = in.read(buffer)) != -1) out.write(buffer, 0, n);
session.fsync(out);
}
Intent intent = new Intent(getContext(), ApkInstallReceiver.class).setAction(ACTION_INSTALL_STATUS);
int flags = PendingIntent.FLAG_UPDATE_CURRENT | (Build.VERSION.SDK_INT >= 31 ? PendingIntent.FLAG_MUTABLE : 0);
PendingIntent pending = PendingIntent.getBroadcast(getContext(), sessionId, intent, flags);
session.commit(pending.getIntentSender());
}
JSObject status = new JSObject();
status.put("status", "installing");
notifyListeners("status", status);
}
/** Called by ApkInstallReceiver with the installer's result. */
static void onInstallStatus(Intent intent) {
ApkUpdaterPlugin plugin = instance;
int status = intent.getIntExtra(PackageInstaller.EXTRA_STATUS, PackageInstaller.STATUS_FAILURE);
if (status == PackageInstaller.STATUS_PENDING_USER_ACTION) {
// Android wants the user to confirm: show its dialog.
Intent confirm = intent.getParcelableExtra(Intent.EXTRA_INTENT);
if (confirm != null && plugin != null) {
confirm.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
plugin.getContext().startActivity(confirm);
JSObject result = new JSObject();
result.put("status", "confirm");
plugin.notifyListeners("status", result);
}
return;
}
if (plugin == null) return;
plugin.busy = false;
if (status != PackageInstaller.STATUS_SUCCESS) {
JSObject result = new JSObject();
result.put("status", "failed");
String message = intent.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE);
result.put("message", message != null ? message : "Installation failed (" + status + ")");
plugin.notifyListeners("status", result);
}
}
}
@@ -7,6 +7,7 @@ public class MainActivity extends BridgeActivity {
@Override
public void onCreate(Bundle savedInstanceState) {
registerPlugin(ScreenBrightnessPlugin.class);
registerPlugin(ApkUpdaterPlugin.class);
super.onCreate(savedInstanceState);
}
}
@@ -7,3 +7,6 @@ project(':capacitor-app').projectDir = new File('../../node_modules/.pnpm/@capac
include ':capacitor-preferences'
project(':capacitor-preferences').projectDir = new File('../../node_modules/.pnpm/@capacitor+preferences@8.0.1_@capacitor+core@8.5.2/node_modules/@capacitor/preferences/android')
include ':capgo-capacitor-updater'
project(':capgo-capacitor-updater').projectDir = new File('../../node_modules/.pnpm/@capgo+capacitor-updater@8.51.25_@capacitor+core@8.5.2/node_modules/@capgo/capacitor-updater/android')
@@ -0,0 +1,6 @@
# Copy to keystore.properties (same folder, not committed) and fill in.
# Use forward slashes in the path.
storeFile=D:/Documents/Security/android_keychain/cardy.jks
storePassword=
keyAlias=
keyPassword=
+24
View File
@@ -1,13 +1,37 @@
import type { CapacitorConfig } from '@capacitor/cli';
// Live reload (scripts/android-dev.mjs): the app loads the Vite dev server instead of the bundled build.
// Only set while syncing for development; a normal `build:android` bundles the web build.
const devUrl = process.env.CAP_DEV_URL;
const config: CapacitorConfig = {
appId: 'com.aggtaa.cardy',
appName: 'Cardy',
webDir: 'dist-capacitor',
backgroundColor: '#0f1115',
// No Capacitor logging even in debug builds: it writes plugin results (e.g. the stored access key
// read from Preferences) and the JS console to logcat. Debug JS via chrome://inspect instead.
loggingBehavior: 'none',
android: {
backgroundColor: '#0f1115',
},
plugins: {
// Live updates in manual mode (src/updater.ts): the app decides when to check, download and apply.
CapacitorUpdater: {
autoUpdate: 'off',
// Never talk to Capgo's cloud: no stats/telemetry, no update or channel requests.
statsUrl: '',
updateUrl: '',
channelUrl: '',
// A new bundle must call notifyAppReady() within this time, otherwise it is rolled back.
appReadyTimeout: 10000,
// A newly installed APK starts from its own built-in bundle.
resetWhenUpdate: true,
autoDeleteFailed: true,
autoDeletePrevious: true,
},
},
...(devUrl ? { server: { url: devUrl, cleartext: true } } : {}),
};
export default config;
+5
View File
@@ -9,6 +9,9 @@
"preview": "vite preview",
"start": "vite build && vite preview",
"build:android": "vite build --mode capacitor && cap sync android",
"android:dev": "node scripts/android-dev.mjs",
"publish:android": "node scripts/publish-android-update.mjs",
"publish:android-apk": "node scripts/publish-android-apk.mjs",
"typecheck": "tsc -p tsconfig.json",
"test": "vitest run"
},
@@ -17,6 +20,7 @@
"@capacitor/app": "^8.1.1",
"@capacitor/core": "^8.5.2",
"@capacitor/preferences": "^8.0.1",
"@capgo/capacitor-updater": "^8.51.25",
"@cardy/shared": "workspace:*",
"barcode-detector": "^3.2.2",
"bwip-js": "^4.11.4",
@@ -30,6 +34,7 @@
"@types/react": "^19.3.0",
"@types/react-dom": "^19.3.0",
"@vitejs/plugin-react": "^6.1.1",
"fflate": "^0.8.3",
"typescript": "^7.0.2",
"vite": "^8.3.1",
"vite-plugin-pwa": "^1.3.0",
+39
View File
@@ -0,0 +1,39 @@
// Live reload on an Android device/emulator connected via adb.
//
// 1. `pnpm dev` in mobile-client must be running (Vite on PORT, default 10003).
// 2. adb reverse makes http://localhost:PORT on the phone reach the dev server on this computer.
// localhost counts as a secure context, so the camera and crypto.subtle (PIN) keep working.
// 3. `cap run android` syncs a config pointing the WebView at the dev server, builds and installs the app.
// Extra arguments are passed to `cap run` (e.g. --target <deviceId>).
//
// Afterwards run `pnpm build:android` before building an APK to share: it bundles the web build again.
import { spawnSync } from 'node:child_process';
import { existsSync } from 'node:fs';
import path from 'node:path';
const port = process.env.PORT || '10003';
const devUrl = `http://localhost:${port}/m/`;
function findAdb() {
const exe = process.platform === 'win32' ? 'adb.exe' : 'adb';
const sdk = process.env.ANDROID_HOME || process.env.ANDROID_SDK_ROOT || (process.env.LOCALAPPDATA && path.join(process.env.LOCALAPPDATA, 'Android', 'Sdk'));
const fromSdk = sdk && path.join(sdk, 'platform-tools', exe);
return fromSdk && existsSync(fromSdk) ? fromSdk : exe;
}
const adb = findAdb();
const reverse = spawnSync(adb, ['reverse', `tcp:${port}`, `tcp:${port}`], { stdio: 'inherit' });
if (reverse.status !== 0) {
console.error(`\n"${adb} reverse" failed: connect a device with USB debugging (or start an emulator) and make sure adb is installed.`);
process.exit(1);
}
console.log(`adb reverse tcp:${port} -> this computer. The app will load ${devUrl}\n`);
const run = spawnSync('pnpm', ['exec', 'cap', 'run', 'android', ...process.argv.slice(2)], {
stdio: 'inherit',
shell: true,
env: { ...process.env, CAP_DEV_URL: devUrl },
});
console.log('\nReminder: the installed app now loads the dev server. Run `pnpm build:android` before building an APK to share.');
process.exit(run.status ?? 1);
@@ -0,0 +1,92 @@
// Publishes a new APK for in-app self-update (use it whenever the native part changes: plugins,
// AndroidManifest, Java code; web-only changes go through publish-android-update.mjs):
// 1. builds the Capacitor web bundle and syncs it into the Android project,
// 2. builds a release APK signed with the key from android/keystore.properties,
// versionCode = build time (yyDDDHHmm, UTC), versionName = web build version,
// 3. copies it to updates/android/cardy-<versionCode>.apk and writes updates/android/apk.json.
// Web updates published afterwards require this APK (see minNativeBuild in publish-android-update.mjs).
//
// Needs a JDK (JAVA_HOME, or Android Studio's bundled one) and the Android SDK (android/local.properties).
import { execSync } from 'node:child_process';
import { createHash } from 'node:crypto';
import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
import path from 'node:path';
const root = path.resolve(import.meta.dirname, '..');
const androidDir = path.join(root, 'android');
const outDir = path.join(root, 'updates', 'android');
const KEEP_APKS = 2;
function fail(message) {
console.error(`\n${message}`);
process.exit(1);
}
// ---- preconditions ----
const keystoreProps = path.join(androidDir, 'keystore.properties');
if (!existsSync(keystoreProps)) fail(`Missing ${keystoreProps}: copy keystore.properties.example and fill it in.`);
const props = Object.fromEntries(
readFileSync(keystoreProps, 'utf8')
.split(/\r?\n/)
.filter((l) => l.includes('=') && !l.trimStart().startsWith('#'))
.map((l) => [l.slice(0, l.indexOf('=')).trim(), l.slice(l.indexOf('=') + 1).trim()]),
);
for (const key of ['storeFile', 'storePassword', 'keyAlias', 'keyPassword']) {
if (!props[key]) fail(`${keystoreProps}: ${key} is empty.`);
}
if (!existsSync(props.storeFile)) fail(`Keystore not found: ${props.storeFile}`);
const env = { ...process.env };
if (!env.JAVA_HOME) {
const studioJbr = 'C:/Program Files/Android/Android Studio/jbr';
if (existsSync(studioJbr)) env.JAVA_HOME = studioJbr;
else fail('Set JAVA_HOME to a JDK (e.g. Android Studio\'s "jbr" folder).');
}
// ---- version ----
const now = new Date();
const dayOfYear = Math.floor((Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()) - Date.UTC(now.getUTCFullYear(), 0, 0)) / 86_400_000);
const pad = (n, w) => String(n).padStart(w, '0');
const versionCode = Number(
`${pad(now.getUTCFullYear() % 100, 2)}${pad(dayOfYear, 3)}${pad(now.getUTCHours(), 2)}${pad(now.getUTCMinutes(), 2)}`,
);
// ---- build ----
execSync('pnpm exec vite build --mode capacitor', { cwd: root, stdio: 'inherit' });
execSync('pnpm exec cap sync android', { cwd: root, stdio: 'inherit' });
const { version: versionName } = JSON.parse(readFileSync(path.join(root, 'dist-capacitor', 'cardy-version.json'), 'utf8'));
const gradlew = process.platform === 'win32' ? 'gradlew.bat' : './gradlew';
execSync(`${gradlew} assembleRelease -PcardyVersionCode=${versionCode} -PcardyVersionName=${versionName}`, {
cwd: androidDir,
stdio: 'inherit',
env,
});
const built = path.join(androidDir, 'app', 'build', 'outputs', 'apk', 'release', 'app-release.apk');
if (!existsSync(built)) fail(`Signed APK not found at ${built} (is the signing configured?).`);
// ---- publish ----
mkdirSync(outDir, { recursive: true });
const apkName = `cardy-${versionCode}.apk`;
copyFileSync(built, path.join(outDir, apkName));
const data = readFileSync(path.join(outDir, apkName));
const sha256 = createHash('sha256').update(data).digest('hex');
// apk.json last: clients never see an APK that is not there yet.
writeFileSync(
path.join(outDir, 'apk.json'),
JSON.stringify({ versionCode, versionName, url: apkName, sha256, size: data.length, publishedAt: now.toISOString() }, null, 2),
);
const apks = readdirSync(outDir)
.filter((f) => /^cardy-\d+\.apk$/.test(f))
.sort((a, b) => statSync(path.join(outDir, b)).mtimeMs - statSync(path.join(outDir, a)).mtimeMs);
for (const old of apks.slice(KEEP_APKS)) unlinkSync(path.join(outDir, old));
console.log(`\nPublished APK ${versionName} (versionCode ${versionCode}, ${(data.length / 1024 / 1024).toFixed(1)} MB)`);
console.log(` ${path.join(outDir, apkName)}`);
console.log('Served at /m/android/apk.json; web updates published from now on require this APK.');
@@ -0,0 +1,64 @@
// Publishes a live update of the Android app's web part:
// 1. builds the Capacitor web bundle (its version comes from the build time),
// 2. zips it into updates/android/bundle-<version>.zip,
// 3. writes updates/android/version.json (version, zip, SHA-256, minimum native build).
// The mobile-client dev/preview server serves updates/android at /m/android/, i.e.
// https://dev.aggtaa.com/m/android/version.json. Installed apps pick the update up on the next check.
//
// minNativeBuild is the versionCode of the latest published APK (updates/android/apk.json, see
// publish-android-apk.mjs): after an APK release, web updates go only to that APK or newer, older
// installations are offered the APK instead. Publish an APK whenever the native part changes
// (plugins, AndroidManifest, Java code).
import { execSync } from 'node:child_process';
import { createHash } from 'node:crypto';
import { mkdirSync, readdirSync, readFileSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
import path from 'node:path';
import { zipSync } from 'fflate';
const root = path.resolve(import.meta.dirname, '..');
const distDir = path.join(root, 'dist-capacitor');
const outDir = path.join(root, 'updates', 'android');
const KEEP_BUNDLES = 3;
execSync('pnpm exec vite build --mode capacitor', { cwd: root, stdio: 'inherit' });
const { version } = JSON.parse(readFileSync(path.join(distDir, 'cardy-version.json'), 'utf8'));
function collect(dir, prefix = '') {
const files = {};
for (const name of readdirSync(dir)) {
const full = path.join(dir, name);
const rel = prefix ? `${prefix}/${name}` : name;
if (statSync(full).isDirectory()) Object.assign(files, collect(full, rel));
else files[rel] = readFileSync(full);
}
return files;
}
const zip = Buffer.from(zipSync(collect(distDir), { level: 6 }));
const checksum = createHash('sha256').update(zip).digest('hex');
let minNativeBuild = 1;
try {
minNativeBuild = Number(JSON.parse(readFileSync(path.join(outDir, 'apk.json'), 'utf8')).versionCode) || 1;
} catch {
// no APK published yet: any installed APK can take the bundle
}
mkdirSync(outDir, { recursive: true });
const zipName = `bundle-${version}.zip`;
writeFileSync(path.join(outDir, zipName), zip);
// version.json last: clients never see a version whose zip is not there yet.
writeFileSync(
path.join(outDir, 'version.json'),
JSON.stringify({ version, url: zipName, checksum, minNativeBuild, publishedAt: new Date().toISOString() }, null, 2),
);
// Keep a few recent bundles (clients may be mid-download of the previous one).
const bundles = readdirSync(outDir)
.filter((f) => /^bundle-.+\.zip$/.test(f))
.sort((a, b) => statSync(path.join(outDir, b)).mtimeMs - statSync(path.join(outDir, a)).mtimeMs);
for (const old of bundles.slice(KEEP_BUNDLES)) unlinkSync(path.join(outDir, old));
console.log(`\nPublished Android update ${version} (${(zip.length / 1024).toFixed(0)} KiB, native build >= ${minNativeBuild})`);
console.log(` ${path.join(outDir, zipName)}`);
console.log('Served at /m/android/version.json by the mobile-client dev/preview server.');
+84
View File
@@ -0,0 +1,84 @@
import { useState } from 'react';
import { apkInstaller, type PublishedApk } from './updater.js';
import { ConfirmDialog } from './ui.js';
type State =
| { step: 'idle' }
| { step: 'permission' }
| { step: 'downloading'; downloaded: number; total: number }
| { step: 'installing' }
| { step: 'confirm' }
| { step: 'error'; message: string };
const mb = (bytes: number) => (bytes / 1024 / 1024).toFixed(1);
/** "App update available" on the card list: downloads the APK and hands it to Android's installer. */
export function ApkUpdateBanner({ apk }: { apk: PublishedApk }) {
const [state, setState] = useState<State>({ step: 'idle' });
const install = async () => {
try {
if (!(await apkInstaller.canInstall())) {
setState({ step: 'permission' });
return;
}
setState({ step: 'downloading', downloaded: 0, total: apk.size });
await apkInstaller.install(apk, (e) => {
if (e.kind === 'progress') setState({ step: 'downloading', downloaded: e.downloaded, total: e.total > 0 ? e.total : apk.size });
else if (e.status === 'installing') setState({ step: 'installing' });
else if (e.status === 'confirm') setState({ step: 'confirm' });
else setState({ step: 'error', message: e.message ?? 'Installation failed' });
});
} catch (e) {
setState({ step: 'error', message: (e as Error).message });
}
};
let text: string;
let action: string | undefined = 'Install';
switch (state.step) {
case 'downloading':
text = `Downloading the update… ${mb(state.downloaded)} / ${mb(state.total)} MB`;
action = undefined;
break;
case 'installing':
text = 'Installing… the app will restart.';
action = undefined;
break;
case 'confirm':
text = 'Confirm the installation in the Android dialog.';
action = 'Retry';
break;
case 'error':
text = `Update failed: ${state.message}`;
action = 'Retry';
break;
default:
text = `A new version of the app is available (${apk.versionName}, ${mb(apk.size)} MB).`;
}
return (
<>
<div className="banner info update-banner" role="status">
<span>{text}</span>
{action && (
<button className="text-button strong" onClick={install}>
{action}
</button>
)}
</div>
{state.step === 'permission' && (
<ConfirmDialog
title="Allow app updates"
message="Android needs your permission for Cardy to install its own updates. Turn on “Allow from this source” on the next screen, come back and tap Install again."
confirmLabel="Open settings"
onCancel={() => setState({ step: 'idle' })}
onConfirm={() => {
setState({ step: 'idle' });
void apkInstaller.openInstallSettings();
}}
/>
)}
</>
);
}
+47 -3
View File
@@ -5,6 +5,14 @@ import { useNavigation, type LoginPrefill } from './navigation.js';
import { onBackButton, onDeepLink, onResumeAfter } from './platform.js';
import { cardImageIds, images, storage, type PinRecord, type Session, type UsageMap } from './storage.js';
import { refreshCardList, syncImages } from './sync.js';
import {
applyPendingUpdate,
checkForApkUpdate,
checkForUpdate,
consumeUpdateReload,
markAppReady,
type PublishedApk,
} from './updater.js';
import { CardScreen } from './screens/CardScreen.js';
import { CardsScreen, type SyncState } from './screens/CardsScreen.js';
import { EditScreen } from './screens/EditScreen.js';
@@ -29,13 +37,17 @@ export function App() {
);
}, []);
if (!boot) return <div className="splash" />;
return <Main boot={boot} />;
return <Main boot={boot} justUpdated={JUST_UPDATED} />;
}
function Main({ boot }: { boot: Boot }) {
/** Read once per page load (render functions may run twice). */
const JUST_UPDATED = consumeUpdateReload();
function Main({ boot, justUpdated }: { boot: Boot; justUpdated: boolean }) {
const [session, setSession] = useState(boot.session);
const [pin, setPin] = useState(boot.pin);
const [locked, setLocked] = useState(!!boot.pin);
// Not locked after a reload that applied an update: the user was unlocked a moment ago.
const [locked, setLocked] = useState(() => !!boot.pin && !justUpdated);
const [cards, setCards] = useState(boot.cards);
const [usage, setUsage] = useState(boot.usage);
const [imageEpoch, setImageEpoch] = useState(0);
@@ -184,6 +196,37 @@ function Main({ boot }: { boot: Boot }) {
useEffect(() => onResumeAfter(RELOCK_AFTER_MS, () => pin && setLocked(true)), [pin]);
// ---- live updates (Android app only) ----
// The app rendered: confirm this bundle works (a bundle that never gets here is rolled back).
useEffect(() => void markAppReady(), []);
// Reloading is safe only on the unlocked card list with no download running: never while a card is
// shown at the till, while editing, or on the lock screen (the reload skips the PIN).
const safeToReload = !locked && !lockedLogin && route?.name === 'cards' && !sync.busy;
const safeRef = useRef(safeToReload);
safeRef.current = safeToReload;
// A newer APK (native part changed) is offered on the card list; installing it needs the user's OK.
const [apkUpdate, setApkUpdate] = useState<PublishedApk>();
const lastApkCheck = useRef(0);
useEffect(() => {
const check = () => {
void checkForUpdate(() => safeRef.current);
if (Date.now() - lastApkCheck.current >= 60_000) {
lastApkCheck.current = Date.now();
void checkForApkUpdate().then(setApkUpdate);
}
};
check();
return onResumeAfter(0, check);
}, []);
useEffect(() => {
if (safeToReload) void applyPendingUpdate();
}, [safeToReload]);
// Android back button = history back; on the lock screen and at the root it exits the app.
const lockedRef = useRef({ locked, lockedLogin });
lockedRef.current = { locked, lockedLogin };
@@ -261,6 +304,7 @@ function Main({ boot }: { boot: Boot }) {
imageEpoch={imageEpoch}
sync={sync}
session={session}
apkUpdate={apkUpdate}
onDismissSync={() => setSync({ busy: false })}
onRefresh={refresh}
onOpen={(id) => push({ name: 'card', id })}
+2 -1
View File
@@ -1,6 +1,6 @@
import { useEffect, useRef, useState, type ReactNode } from 'react';
import { PIN_LENGTH } from './pin.js';
import { Icon } from './ui.js';
import { Icon, VersionLabel } from './ui.js';
/**
* Four-dot PIN entry with an on-screen keypad (a hardware keyboard works too).
@@ -81,6 +81,7 @@ export function PinPad({
</button>
</div>
<div className="pin-footer">{footer}</div>
<VersionLabel />
</div>
);
}
+20
View File
@@ -0,0 +1,20 @@
/**
* Version of a web build, derived from the build time (UTC): "YYYY.MDD.Hmmss", e.g. 2026.929.151230
* for 2026-09-29 15:12:30. Valid semver (no leading zeros) and ordered like the build time.
*/
export function buildVersionFromDate(date: Date): string {
const md = (date.getUTCMonth() + 1) * 100 + date.getUTCDate();
const hms = date.getUTCHours() * 10000 + date.getUTCMinutes() * 100 + date.getUTCSeconds();
return `${date.getUTCFullYear()}.${md}.${hms}`;
}
/** Numeric comparison of dot-separated versions; non-numeric parts count as 0. */
export function compareVersions(a: string, b: string): number {
const pa = a.split('.').map((p) => Number.parseInt(p, 10) || 0);
const pb = b.split('.').map((p) => Number.parseInt(p, 10) || 0);
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
const diff = (pa[i] ?? 0) - (pb[i] ?? 0);
if (diff !== 0) return Math.sign(diff);
}
return 0;
}
+4
View File
@@ -0,0 +1,4 @@
/** Build version, "YYYY.MDD.Hmmss" from the build time (UTC); "dev" on the dev server. */
declare const __BUILD_VERSION__: string;
/** Base URL of Android live updates (version.json and bundle zips), without a trailing slash. */
declare const __UPDATE_URL__: string;
+17
View File
@@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest';
import type { Card } from '@cardy/shared';
import { normalizeServerUrl } from './api.js';
import { buildVersionFromDate, compareVersions } from './buildVersion.js';
import { detectedFormatToCodeType } from './codeFormats.js';
import { checkPin, createPinRecord } from './pin.js';
import { cardsNeedingImages, runPool, sortCards } from './sync.js';
@@ -12,6 +13,7 @@ const card = (id: string, name: string, extra: Partial<Card> = {}): Card => ({
code: '1',
codeType: 'QR',
comment: '',
tags: [],
logo: null,
images: [],
imagesVersion: 0,
@@ -78,6 +80,21 @@ describe('PIN', () => {
});
});
describe('build versions', () => {
it('derive a semver-compatible version from the build time (UTC)', () => {
expect(buildVersionFromDate(new Date('2026-09-29T15:12:30Z'))).toBe('2026.929.151230');
expect(buildVersionFromDate(new Date('2026-01-05T00:00:07Z'))).toBe('2026.105.7');
});
it('orders versions like build times', () => {
const times = ['2026-01-05T00:00:07Z', '2026-01-05T09:59:59Z', '2026-01-05T10:00:00Z', '2026-09-29T15:12:30Z', '2026-10-01T00:00:00Z', '2027-01-01T00:00:00Z'];
const versions = times.map((t) => buildVersionFromDate(new Date(t)));
for (let i = 1; i < versions.length; i++) expect(compareVersions(versions[i]!, versions[i - 1]!)).toBe(1);
expect(compareVersions('2026.929.151230', '2026.929.151230')).toBe(0);
expect(compareVersions('2026.929.1', 'dev')).toBe(1);
});
});
describe('helpers', () => {
it('normalizes the server address', () => {
expect(normalizeServerUrl(' dev.aggtaa.com/api/ ')).toBe('https://dev.aggtaa.com/api');
+2 -1
View File
@@ -1,7 +1,7 @@
import { useEffect, useState } from 'react';
import { CODE_TYPE_LABELS, textColorOn, type Card } from '@cardy/shared';
import { setMaxBrightness } from '../platform.js';
import { Banner, CardBadge, CodeImage, ConfirmDialog, Icon, IconButton, Screen, useStoredImage } from '../ui.js';
import { Banner, CardBadge, CodeImage, ConfirmDialog, Icon, IconButton, Screen, TagList, useStoredImage } from '../ui.js';
export function CardScreen({
card,
@@ -64,6 +64,7 @@ export function CardScreen({
<span className="muted">{CODE_TYPE_LABELS[card.codeType]}</span>
</div>
{card.tags?.length > 0 && <TagList tags={card.tags} />}
{card.comment && <p className="card-comment">{card.comment}</p>}
{card.images.length > 0 && (
+15 -9
View File
@@ -1,8 +1,10 @@
import { useMemo, useRef, useState } from 'react';
import { textColorOn, type Card } from '@cardy/shared';
import { cardMatchesQuery, textColorOn, type Card } from '@cardy/shared';
import { ApkUpdateBanner } from '../ApkUpdateBanner.js';
import type { Session, UsageMap } from '../storage.js';
import type { PublishedApk } from '../updater.js';
import { sortCards } from '../sync.js';
import { Banner, CardBadge, ConfirmDialog, Icon, IconButton, Screen } from '../ui.js';
import { Banner, CardBadge, ConfirmDialog, Icon, IconButton, Screen, VersionLabel } from '../ui.js';
export interface SyncState {
busy: boolean;
@@ -17,6 +19,7 @@ export function CardsScreen({
imageEpoch,
sync,
session,
apkUpdate,
onDismissSync,
onRefresh,
onOpen,
@@ -28,6 +31,7 @@ export function CardsScreen({
imageEpoch: number;
sync: SyncState;
session: Session;
apkUpdate?: PublishedApk;
onDismissSync: () => void;
onRefresh: () => void;
onOpen: (id: string) => void;
@@ -38,14 +42,12 @@ export function CardsScreen({
const [confirmKey, setConfirmKey] = useState(false);
const filterInput = useRef<HTMLInputElement>(null);
const visible = useMemo(() => {
const q = filter.trim().toLowerCase();
const sorted = sortCards(cards, usage);
return q ? sorted.filter((c) => c.name.toLowerCase().includes(q)) : sorted;
}, [cards, usage, filter]);
// Search by name, tags and comment.
const visible = useMemo(() => sortCards(cards, usage).filter((c) => cardMatchesQuery(c, filter)), [cards, usage, filter]);
return (
<Screen
className="cards-screen"
title="Cards"
left={<IconButton icon="key" label="Change key" onClick={() => setConfirmKey(true)} />}
right={<IconButton icon="refresh" label="Refresh" onClick={onRefresh} disabled={sync.busy} spinning={sync.busy} />}
@@ -59,8 +61,8 @@ export function CardsScreen({
autoCapitalize="off"
autoCorrect="off"
spellCheck={false}
placeholder="Filter by name"
aria-label="Filter by name"
placeholder="Search by name, tag or comment"
aria-label="Search by name, tag or comment"
value={filter}
onChange={(e) => setFilter(e.target.value)}
/>
@@ -78,6 +80,8 @@ export function CardsScreen({
)}
</div>
{apkUpdate && <ApkUpdateBanner apk={apkUpdate} />}
{sync.busy && sync.progress && sync.progress.total > 0 && (
<Banner kind="info">
Downloading images {sync.progress.done}/{sync.progress.total}
@@ -108,6 +112,8 @@ export function CardsScreen({
</div>
)}
<VersionLabel />
<button className="fab" onClick={onAdd} aria-label="Add card">
<Icon name="plus" size={28} />
</button>
+21 -3
View File
@@ -4,6 +4,8 @@ import {
CODE_TYPES,
CODE_TYPE_LABELS,
cardFieldsSchema,
formatTags,
parseTags,
randomCardColor,
type Card,
type CardFields,
@@ -14,7 +16,7 @@ import { ApiError, type Api } from '../api.js';
import { compressImage, fileNameFor } from '../imageTools.js';
import { ScannerView, scanImageFile } from '../ScannerView.js';
import { images } from '../storage.js';
import { Banner, CardBadge, CodeImage, Icon, IconButton, Screen, useBlobUrl, useStoredImage } from '../ui.js';
import { Banner, CardBadge, CodeImage, Icon, IconButton, Screen, TagList, useBlobUrl, useStoredImage } from '../ui.js';
type LogoChange = { kind: 'keep' } | { kind: 'remove' } | { kind: 'new'; blob: Blob };
interface NewImage {
@@ -45,6 +47,8 @@ export function EditScreen({
? { name: card.name, color: card.color, code: card.code, codeType: card.codeType, comment: card.comment ?? '' }
: { name: '', color: randomCardColor(), code: '', codeType: 'EAN13', comment: '' },
);
// Edited as typed ("a, b"); parsed into the tag list on save.
const [tagsText, setTagsText] = useState(formatTags(card?.tags));
// Set once the card exists on the server (also after a partially failed first save).
const [savedId, setSavedId] = useState(card?.id);
const [hadLogo, setHadLogo] = useState<ImageRef | null>(card?.logo ?? null);
@@ -93,7 +97,7 @@ export function EditScreen({
});
const save = async () => {
const parsed = cardFieldsSchema.safeParse(fields);
const parsed = cardFieldsSchema.safeParse({ ...fields, tags: tagsText });
if (!parsed.success) {
setError(parsed.error.issues.map((i) => i.message).join('. '));
return;
@@ -108,7 +112,9 @@ export function EditScreen({
let addedDone = false;
try {
const form = new FormData();
for (const [k, v] of Object.entries(parsed.data)) form.append(k, v);
for (const [k, v] of Object.entries(parsed.data)) {
if (v !== undefined) form.append(k, Array.isArray(v) ? v.join(',') : v);
}
if (logo.kind === 'new') form.append('logo', logo.blob, fileNameFor(logo.blob, 'logo'));
if (logo.kind === 'remove' && hadLogo) form.append('removeLogo', 'true');
current = savedId ? await api.updateCard(savedId, form) : await api.createCard(form);
@@ -272,6 +278,18 @@ export function EditScreen({
/>
</label>
<label className="field">
<span>Tags</span>
<input
value={tagsText}
onChange={(e) => setTagsText(e.target.value)}
placeholder="Comma-separated, e.g. food, pharmacy"
autoCapitalize="off"
aria-label="Tags"
/>
{parseTags(tagsText).length > 0 && <TagList tags={parseTags(tagsText)} />}
</label>
<div className="field">
<span>Photos</span>
<div className="photo-grid">
+2 -1
View File
@@ -5,7 +5,7 @@ import type { LoginPrefill } from '../navigation.js';
import { defaultServerUrl } from '../platform.js';
import { ScannerView } from '../ScannerView.js';
import type { Session } from '../storage.js';
import { Banner, Icon, IconButton, Screen } from '../ui.js';
import { Banner, Icon, IconButton, Screen, VersionLabel } from '../ui.js';
export type { LoginPrefill };
@@ -136,6 +136,7 @@ export function LoginScreen({
{busy ? 'Checking…' : 'Sign in'}
</button>
</form>
<VersionLabel />
</Screen>
);
}
+72
View File
@@ -112,6 +112,29 @@ textarea:focus {
border-color: var(--accent);
}
.tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin: 0;
padding: 0;
list-style: none;
}
.tag {
padding: 3px 10px;
border-radius: 999px;
background: var(--surface-2);
border: 1px solid var(--border);
color: var(--text);
font-size: 0.8rem;
overflow-wrap: anywhere;
}
.card-screen .tags {
margin: 0 0 14px;
}
.card-comment {
white-space: pre-wrap;
overflow-wrap: anywhere;
@@ -275,6 +298,15 @@ textarea:focus {
color: var(--accent);
}
.update-banner {
align-items: center;
}
.update-banner .text-button {
padding: 4px 8px;
white-space: nowrap;
}
.banner-close {
background: none;
border: none;
@@ -291,6 +323,8 @@ textarea:focus {
/* ---- login ---- */
.login .screen-body {
/* No floating button here: the version label sits at the very bottom. */
padding-bottom: calc(12px + var(--safe-bottom));
display: flex;
flex-direction: column;
gap: 14px;
@@ -413,6 +447,44 @@ textarea:focus {
min-height: 44px;
}
/* Pinned to the bottom of the login and PIN screens. */
.version-label {
margin-top: auto;
padding-top: 16px;
text-align: center;
color: var(--muted);
font-size: 0.75rem;
opacity: 0.7;
font-variant-numeric: tabular-nums;
}
/* Card list: the label is the footer below the tiles, level with the floating button. */
.cards-screen .screen-body {
display: flex;
flex-direction: column;
padding-bottom: calc(40px + var(--safe-bottom));
}
.cards-screen .version-label {
padding-top: 48px;
padding-right: 72px;
padding-left: 72px;
}
/* The PIN screen is centered vertically: keep the label out of the flow. */
.pin {
position: relative;
}
.pin .version-label {
position: absolute;
left: 0;
right: 0;
bottom: calc(10px + var(--safe-bottom));
margin: 0;
padding: 0;
}
/* ---- cards list ---- */
.filter {
+17
View File
@@ -41,6 +41,23 @@ export function IconButton({ icon, label, onClick, disabled, spinning }: { icon:
);
}
export function TagList({ tags }: { tags: readonly string[] }) {
return (
<ul className="tags" aria-label="Tags">
{tags.map((tag) => (
<li key={tag} className="tag">
{tag}
</li>
))}
</ul>
);
}
/** Version of the running web build (built-in or live-updated bundle). */
export function VersionLabel() {
return <div className="version-label">Version {__BUILD_VERSION__}</div>;
}
// ---- layout ----
export function Screen({ title, left, right, children, className }: { title?: ReactNode; left?: ReactNode; right?: ReactNode; children: ReactNode; className?: string }) {
+183
View File
@@ -0,0 +1,183 @@
import { App } from '@capacitor/app';
import { registerPlugin, type PluginListenerHandle } from '@capacitor/core';
import { CapacitorUpdater } from '@capgo/capacitor-updater';
import { compareVersions } from './buildVersion.js';
import { isNative } from './platform.js';
/**
* Live updates of the Android app's web part (Capgo updater in manual mode, own server):
* the app always runs a local bundle (built-in or downloaded), so everything keeps working offline.
* When dev.aggtaa.com is reachable and publishes a newer version, the bundle is downloaded in the
* background and applied right away on a safe screen, otherwise via next() (on backgrounding).
*/
export const BUILD_VERSION = __BUILD_VERSION__;
const UPDATE_URL = __UPDATE_URL__;
const CHECK_TIMEOUT_MS = 10_000;
/** Do not check more often than this (launch + every return to the foreground). */
const MIN_CHECK_INTERVAL_MS = 60_000;
/** Set right before an immediate reload so the fresh bundle does not ask for the PIN again. */
const RELOAD_MARK = 'cardy:update-reload';
interface PublishedVersion {
version: string;
/** Bundle zip, relative to version.json or absolute. */
url: string;
/** SHA-256 of the zip, hex. */
checksum: string;
/** Lowest native build (android versionCode) the bundle works with. */
minNativeBuild?: number;
}
// Imported statically on purpose: a Capacitor plugin object is a Proxy that answers any property,
// including `then`, so it must never be the resolved value of a Promise (the Promise would call
// CapacitorUpdater.then() -> "not implemented on android").
const updater = CapacitorUpdater;
let checking = false;
let lastCheck = 0;
/** Downloaded bundle waiting for a safe moment (also queued with next()). */
let pendingBundleId: string | undefined;
/** Must run early on every start: otherwise the updater rolls back to the previous bundle. */
export async function markAppReady(): Promise<void> {
if (!isNative) return;
try {
await updater.notifyAppReady();
} catch (e) {
console.warn('notifyAppReady failed', e);
}
}
/** True once, right after this app reloaded itself to apply an update. */
export function consumeUpdateReload(): boolean {
try {
const at = Number(localStorage.getItem(RELOAD_MARK) ?? 0);
localStorage.removeItem(RELOAD_MARK);
return at > 0 && Date.now() - at < 60_000;
} catch {
return false;
}
}
/**
* Checks the server for a newer bundle and downloads it. Applies it immediately when
* `canApplyNow()` says so, otherwise queues it for the next backgrounding.
*/
export async function checkForUpdate(canApplyNow: () => boolean): Promise<void> {
if (!isNative || BUILD_VERSION === 'dev' || checking) return;
if (Date.now() - lastCheck < MIN_CHECK_INTERVAL_MS) return;
checking = true;
lastCheck = Date.now();
try {
const res = await fetch(`${UPDATE_URL}/version.json`, { cache: 'no-store', signal: AbortSignal.timeout(CHECK_TIMEOUT_MS) });
if (!res.ok) return;
const published = (await res.json()) as PublishedVersion;
if (!published?.version || !published.url || compareVersions(published.version, BUILD_VERSION) <= 0) return;
const nativeBuild = Number((await App.getInfo()).build) || 0;
if (published.minNativeBuild && nativeBuild < published.minNativeBuild) {
console.info(`Update ${published.version} needs a newer app (build ${published.minNativeBuild}, installed ${nativeBuild})`);
return;
}
const { bundles } = await updater.list();
let bundle = bundles.find((b) => b.version === published.version && b.status !== 'error' && b.status !== 'downloading');
bundle ??= await updater.download({
url: new URL(published.url, `${UPDATE_URL}/`).href,
version: published.version,
checksum: published.checksum,
});
if (canApplyNow()) await applyNow(bundle.id);
else {
await updater.next({ id: bundle.id });
pendingBundleId = bundle.id;
}
} catch (e) {
// Offline, server down, bad bundle: keep running the current version.
console.warn('Update check failed', e);
} finally {
checking = false;
}
}
// ---- APK self-update (native part changed: plugins, manifest, Java) ----
export interface PublishedApk {
versionCode: number;
versionName: string;
/** APK file, relative to apk.json or absolute. */
url: string;
sha256: string;
size: number;
}
export type ApkInstallEvent =
| { kind: 'progress'; downloaded: number; total: number }
| { kind: 'status'; status: 'installing' | 'confirm' | 'failed'; message?: string };
interface ApkUpdaterPlugin {
canInstall(): Promise<{ allowed: boolean }>;
openInstallSettings(): Promise<void>;
downloadAndInstall(options: { url: string; sha256: string }): Promise<void>;
addListener(event: 'progress', cb: (e: { downloaded: number; total: number }) => void): Promise<PluginListenerHandle>;
addListener(event: 'status', cb: (e: { status: 'installing' | 'confirm' | 'failed'; message?: string }) => void): Promise<PluginListenerHandle>;
}
/** Native plugin in android/app (ApkUpdaterPlugin.java). Used directly, never through a Promise. */
const ApkUpdater = registerPlugin<ApkUpdaterPlugin>('ApkUpdater');
/** A newer APK than the installed one, if the server publishes it. */
export async function checkForApkUpdate(): Promise<PublishedApk | undefined> {
if (!isNative || BUILD_VERSION === 'dev') return undefined;
try {
const res = await fetch(`${UPDATE_URL}/apk.json`, { cache: 'no-store', signal: AbortSignal.timeout(CHECK_TIMEOUT_MS) });
if (!res.ok) return undefined;
const apk = (await res.json()) as PublishedApk;
const installed = Number((await App.getInfo()).build) || 0;
return apk?.versionCode > installed && apk.url && apk.sha256 ? apk : undefined;
} catch {
return undefined;
}
}
export const apkInstaller = {
canInstall: async () => (await ApkUpdater.canInstall()).allowed,
openInstallSettings: () => ApkUpdater.openInstallSettings(),
/** Downloads and hands the APK to Android; `onEvent` reports progress and the installer's status. */
async install(apk: PublishedApk, onEvent: (e: ApkInstallEvent) => void): Promise<void> {
const handles: PluginListenerHandle[] = [];
const cleanup = () => void Promise.all(handles.splice(0).map((h) => h.remove()));
handles.push(
await ApkUpdater.addListener('progress', (e) => onEvent({ kind: 'progress', ...e })),
await ApkUpdater.addListener('status', (e) => {
onEvent({ kind: 'status', ...e });
if (e.status === 'failed') cleanup();
}),
);
try {
await ApkUpdater.downloadAndInstall({ url: new URL(apk.url, `${UPDATE_URL}/`).href, sha256: apk.sha256 });
} catch (e) {
cleanup();
throw e;
}
// Listeners stay until the installer reports "failed"; a successful install restarts the app.
},
};
/** Applies a queued update now (call when the app reaches a safe screen). */
export async function applyPendingUpdate(): Promise<void> {
if (pendingBundleId) await applyNow(pendingBundleId);
}
async function applyNow(id: string): Promise<void> {
try {
localStorage.setItem(RELOAD_MARK, String(Date.now()));
} catch {
// the PIN will simply be asked again
}
// Reloads the WebView from the new bundle; nothing after this runs.
await updater.set({ id });
}
+75 -15
View File
@@ -1,39 +1,94 @@
import { createReadStream, existsSync, statSync } from 'node:fs';
import type { IncomingMessage, ServerResponse } from 'node:http';
import path from 'node:path';
import react from '@vitejs/plugin-react';
import { defineConfig, loadEnv, type Plugin, type ProxyOptions } from 'vite';
import { VitePWA } from 'vite-plugin-pwa';
import { buildVersionFromDate } from './src/buildVersion.js';
// Web build: public at https://dev.aggtaa.com/m/, the reverse proxy strips "/m" (X-Forwarded-Prefix).
// Capacitor build (--mode capacitor): served from the app bundle root.
//
// Env (mobile-client/.env or process env):
// PORT dev/preview server port (default 10003)
// ALLOWED_HOSTS comma-separated public host names (default dev.aggtaa.com)
// BACKEND_URL local dev only: server-be address (default http://127.0.0.1:10002)
// PORT dev/preview server port (default 10003)
// ALLOWED_HOSTS comma-separated public host names (default dev.aggtaa.com)
// BACKEND_URL local dev only: server-be address (default http://127.0.0.1:10002)
// CARDY_UPDATE_URL where the Android app looks for live updates (default https://dev.aggtaa.com/m/android)
// CARDY_BUILD_VERSION override the build version (default: from the build time)
const WEB_BASE = '/m/';
/** Published Android updates (scripts/publish-android-update.mjs, publish-android-apk.mjs), served at /m/android/. */
const UPDATES_DIR = path.resolve('updates/android');
const UPDATES_PATH = `${WEB_BASE}android/`;
type Next = () => void;
/** The proxy strips /m; put it back so Vite's base-aware dev and preview servers find the files. */
function restoreStrippedBase(): Plugin {
const rewrite = (req: { url?: string }, _res: unknown, next: () => void) => {
const url = req.url ?? '/';
if (!url.startsWith(WEB_BASE) && !url.startsWith('/api/') && url !== '/api') req.url = WEB_BASE.slice(0, -1) + url;
next();
function restoreStrippedBase(req: IncomingMessage, _res: ServerResponse, next: Next) {
const url = req.url ?? '/';
if (!url.startsWith(WEB_BASE) && !url.startsWith('/api/') && url !== '/api') req.url = WEB_BASE.slice(0, -1) + url;
next();
}
/** version.json and bundle zips for the Android app; CORS is open because the app runs on https://localhost. */
function serveAndroidUpdates(req: IncomingMessage, res: ServerResponse, next: Next) {
const url = (req.url ?? '').split('?')[0] ?? '';
if (!url.startsWith(UPDATES_PATH) || (req.method !== 'GET' && req.method !== 'HEAD')) return next();
const name = decodeURIComponent(url.slice(UPDATES_PATH.length));
const file = path.join(UPDATES_DIR, name);
res.setHeader('Access-Control-Allow-Origin', '*');
if (!/^[A-Za-z0-9._-]+$/.test(name) || !existsSync(file)) {
res.statusCode = 404;
res.end();
return;
}
const isJson = name.endsWith('.json');
res.setHeader(
'Content-Type',
isJson ? 'application/json' : name.endsWith('.apk') ? 'application/vnd.android.package-archive' : 'application/zip',
);
if (!isJson) res.setHeader('Content-Length', String(statSync(file).size));
// Bundle files are named by version and never change.
res.setHeader('Cache-Control', isJson ? 'no-cache' : 'public, max-age=31536000, immutable');
if (req.method === 'HEAD') {
res.end();
return;
}
createReadStream(file).pipe(res);
}
function cardyServer(): Plugin {
const install = (use: (handler: (req: IncomingMessage, res: ServerResponse, next: Next) => void) => void) => {
use(restoreStrippedBase);
use(serveAndroidUpdates);
};
return {
name: 'cardy-restore-stripped-base',
configureServer: (server) => void server.middlewares.use(rewrite),
configurePreviewServer: (server) => void server.middlewares.use(rewrite),
name: 'cardy-server',
configureServer: (server) => install((h) => server.middlewares.use(h)),
configurePreviewServer: (server) => install((h) => server.middlewares.use(h)),
};
}
export default defineConfig(({ mode }) => {
/** Writes cardy-version.json next to the build: read by the update publishing script. */
function emitVersionFile(version: string): Plugin {
return {
name: 'cardy-version-file',
apply: 'build',
generateBundle() {
this.emitFile({ type: 'asset', fileName: 'cardy-version.json', source: JSON.stringify({ version }) });
},
};
}
export default defineConfig(({ mode, command }) => {
const env = loadEnv(mode, process.cwd(), '');
const capacitor = mode === 'capacitor';
const buildVersion = process.env.CARDY_BUILD_VERSION || (command === 'build' ? buildVersionFromDate(new Date()) : 'dev');
// Local development without the reverse proxy: /api/* -> server-be with /api stripped.
const proxy: Record<string, ProxyOptions> = {
'/api': {
target: env.BACKEND_URL || 'http://127.0.0.1:10002',
rewrite: (path) => path.replace(/^\/api/, '') || '/',
rewrite: (p) => p.replace(/^\/api/, '') || '/',
headers: { 'X-Forwarded-Prefix': '/api' },
},
};
@@ -47,9 +102,14 @@ export default defineConfig(({ mode }) => {
return {
base: capacitor ? './' : WEB_BASE,
define: {
__BUILD_VERSION__: JSON.stringify(buildVersion),
__UPDATE_URL__: JSON.stringify((env.CARDY_UPDATE_URL || 'https://dev.aggtaa.com/m/android').replace(/\/+$/, '')),
},
plugins: [
react(),
restoreStrippedBase(),
cardyServer(),
...(capacitor ? [emitVersionFile(buildVersion)] : []),
VitePWA({
disable: capacitor,
registerType: 'autoUpdate',
@@ -75,7 +135,7 @@ export default defineConfig(({ mode }) => {
// bwip-js and the zxing wasm module are large but needed offline.
maximumFileSizeToCacheInBytes: 8 * 1024 * 1024,
navigateFallback: 'index.html',
navigateFallbackDenylist: [/^\/api(\/|$)/],
navigateFallbackDenylist: [/^\/api(\/|$)/, /\/android\//],
},
}),
],