diff --git a/README.md b/README.md index 0efa6bc..8046b62 100644 --- a/README.md +++ b/README.md @@ -346,6 +346,55 @@ With the dev session running, Studio's device dropdown, Device Manager To build without Studio and without a dev server, use `cargo tauri android build` — that path needs no addr file. +### Distributing to phones (F-Droid) + +Releases go out through a small F-Droid repo you host yourself: add it to the +F-Droid app once, and every new build then shows up there as an update. +`scripts/fdroid-publish.sh` builds a signed arm64 APK and adds it to the repo +in a directory you choose. It needs fdroidserver: + +```bash +pipx install fdroidserver +``` + +The first run also makes the app's signing key and the repo. Give it the +address the repo will be served at: + +```bash +FDROID_REPO_URL=https://fdroid.example.com/repo ./scripts/fdroid-publish.sh --init-keystore ~/fdroid-mtgc +``` + +After that, publishing is just: + +```bash +./scripts/fdroid-publish.sh ~/fdroid-mtgc # or: npm run android:publish -- ~/fdroid-mtgc +``` + +It ends by printing the repo address with its fingerprint +(`https://…/repo?fingerprint=…`); add that in F-Droid under **Settings → +Repositories**. + +- **Serve only `~/fdroid-mtgc/repo`** (and `archive/`, which holds older + versions, if you like) — any static file server will do. Never serve the + directory itself: it holds the repo signing key (`keystore.p12`) and its + password (`config.yml`). +- **Back up both keys**: `~/.local/share/mtgc/android-release.jks` with + `src-tauri/gen/android/keystore.properties` (the app key and its password), + and the repo directory's `keystore.p12` with `config.yml`. Android only + installs an update signed with the same key as the installed app, so + without the app key every phone would have to uninstall and reinstall. +- **Versions are automatic.** The Android versionCode is the number of commits + on `HEAD`, and the version shown is e.g. `0.1.0-44-gece21c1`, so any commit + can ship without a bump. That's also why pushed history shouldn't be + rewritten. The script won't publish uncommitted changes without + `--allow-dirty`, or a versionCode the repo already has. +- A phone with a debug build, or anything signed with another key, has to + uninstall it before F-Droid can install the release, and uninstalling + deletes the app's data. Sync or **Export library…** first. + +Without `keystore.properties`, `cargo tauri android build` still produces an +unsigned APK as before. + ### Card data on a phone Downloading card data streams ~740MB of JSON to build a ~73MB database, which diff --git a/package.json b/package.json index 95f3478..0a82b90 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,8 @@ "preview": "vite preview", "tauri": "tauri", "sync-server:build": "scripts/sync-server-image.sh build", - "sync-server:push": "scripts/sync-server-image.sh push" + "sync-server:push": "scripts/sync-server-image.sh push", + "android:publish": "scripts/fdroid-publish.sh" }, "dependencies": { "@tauri-apps/api": "^2", diff --git a/scripts/fdroid-publish.sh b/scripts/fdroid-publish.sh new file mode 100755 index 0000000..8fcc0a6 --- /dev/null +++ b/scripts/fdroid-publish.sh @@ -0,0 +1,218 @@ +#!/usr/bin/env bash +# +# fdroid-publish.sh — build a signed release APK and add it to an F-Droid repo. +# +# Usage: +# FDROID_REPO_URL=https://fdroid.example.com/repo \ +# ./scripts/fdroid-publish.sh --init-keystore ~/fdroid-mtgc # first time +# ./scripts/fdroid-publish.sh ~/fdroid-mtgc # every release after +# +# Or through npm: `npm run android:publish -- ~/fdroid-mtgc`. +# +# Options: +# --init-keystore generate the app signing key (once, ever) and keystore.properties +# --allow-dirty publish uncommitted changes; the version gets a -dirty suffix +# +# Environment: +# MTGC_FDROID_DIR repo directory, if not given as an argument +# FDROID_REPO_URL public address of /repo; only needed the first time, to create the repo +# MTGC_ANDROID_KEYSTORE where --init-keystore puts the key (default ~/.local/share/mtgc/android-release.jks) +# +# Serve /repo (and /archive if you like), never itself: it holds +# the repo signing key and its password. Needs fdroidserver (`pipx install +# fdroidserver`) and the Android setup from ./scripts/android-setup.sh. +# +set -euo pipefail + +usage() { + sed -n '3,23p' "$0" | sed 's/^# \{0,1\}//' + exit "${1:-0}" +} + +info() { printf '\033[1;34m==>\033[0m %s\n' "$*"; } +warn() { printf '\033[1;33m[warn]\033[0m %s\n' "$*"; } +err() { printf '\033[1;31m[err]\033[0m %s\n' "$*" >&2; } + +APP_ID="com.garrettmills.mtgc" +APP_KEYSTORE="${MTGC_ANDROID_KEYSTORE:-$HOME/.local/share/mtgc/android-release.jks}" +APP_KEY_ALIAS="mtgc" + +init_keystore=false +allow_dirty=false +dir="${MTGC_FDROID_DIR:-}" +while [ $# -gt 0 ]; do + case "$1" in + --init-keystore) init_keystore=true ;; + --allow-dirty) allow_dirty=true ;; + -h | --help) usage ;; + -*) usage 1 ;; + *) dir="$1" ;; + esac + shift +done +[ -n "$dir" ] || usage 1 +mkdir -p "$dir" +dir="$(cd "$dir" && pwd)" + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$repo_root" +key_props="src-tauri/gen/android/keystore.properties" + +# --- 1. Tools --------------------------------------------------------------- + +source scripts/android-env.sh >/dev/null + +if ! command -v fdroid >/dev/null 2>&1; then + err "fdroid not found. Install fdroidserver: pipx install fdroidserver" + exit 1 +fi +build_tools="$(find "$ANDROID_HOME/build-tools" -mindepth 1 -maxdepth 1 -type d 2>/dev/null | sort -V | tail -1)" +apksigner="$build_tools/apksigner" +if [ ! -x "$apksigner" ]; then + err "apksigner not found under $ANDROID_HOME/build-tools (install a build-tools package)" + exit 1 +fi + +# --- 2. App signing key ----------------------------------------------------- +# +# Every update must be signed with the same key as the installed app, so this +# key is made once and kept forever. Lose it, and each phone has to uninstall +# the app before it will take a newer build. + +if $init_keystore; then + if [ -e "$APP_KEYSTORE" ] || [ -e "$key_props" ]; then + err "a signing key already exists ($APP_KEYSTORE or $key_props); not overwriting it" + exit 1 + fi + info "generating the app signing key at $APP_KEYSTORE" + mkdir -p "$(dirname "$APP_KEYSTORE")" + password="$(openssl rand -hex 24)" + keytool -genkeypair -keystore "$APP_KEYSTORE" -storetype PKCS12 \ + -storepass "$password" -alias "$APP_KEY_ALIAS" \ + -keyalg RSA -keysize 4096 -validity 10000 -dname "CN=mtgc" >/dev/null + chmod 600 "$APP_KEYSTORE" + ( + umask 077 + printf 'storeFile=%s\npassword=%s\nkeyAlias=%s\n' "$APP_KEYSTORE" "$password" "$APP_KEY_ALIAS" >"$key_props" + ) + warn "Back up $APP_KEYSTORE and $key_props (it holds the password)." + warn "Without them, phones can't update the app without uninstalling it first." +elif [ ! -f "$key_props" ]; then + err "no app signing key ($key_props). Run once with --init-keystore." + exit 1 +fi + +# --- 3. Version ------------------------------------------------------------- +# +# The versionCode is the commit count, so every commit on main can ship with no +# manual bump and F-Droid sees each one as newer than the last. + +version_code="$(git rev-list --count HEAD)" +base_version="$(node -p "require('./src-tauri/tauri.conf.json').version")" +version_name="$base_version-$version_code-g$(git rev-parse --short HEAD)" +if [ -n "$(git status --porcelain --untracked-files=no)" ]; then + if ! $allow_dirty; then + err "the working tree has uncommitted changes; commit them or pass --allow-dirty" + exit 1 + fi + version_name="$version_name-dirty" +fi + +# --- 4. The repo, the first time -------------------------------------------- + +if [ ! -f "$dir/config.yml" ]; then + if [ -z "${FDROID_REPO_URL:-}" ]; then + err "no F-Droid repo in $dir yet; set FDROID_REPO_URL to the address $dir/repo will be served at" + exit 1 + fi + info "creating the F-Droid repo in $dir" + (cd "$dir" && fdroid init --android-home "$ANDROID_HOME" --no-prompt -q) + cat >>"$dir/config.yml" <- + Releases of mtgc, a personal Magic: The Gathering library catalog. +archive_older: 3 +EOF + # `fdroid update` copies this into repo/icons/ itself. + cp src-tauri/icons/icon.png "$dir/icon.png" +fi + +if [ ! -f "$dir/metadata/$APP_ID.yml" ]; then + mkdir -p "$dir/metadata" + cat >"$dir/metadata/$APP_ID.yml" <<'EOF' +Categories: + - Games +License: MIT +AuthorName: Garrett Mills +SourceCode: https://code.garrettmills.dev/garrettmills/mtgc + +Name: mtgc +Summary: 'Personal Magic: The Gathering library catalog' +Description: |- + Catalog your Magic: The Gathering collection: rapid fuzzy card entry, rich + search (colors, commander color identity, mana value, type, price, format + legality) and deck organization. Works offline, and syncs between devices + through an mtgc sync server. +EOF +fi +# Release builds shorten resource paths, so `fdroid update` can't find the +# launcher icon inside the APK; hand it over as metadata instead. +if [ ! -f "$dir/metadata/$APP_ID/en-US/icon.png" ]; then + mkdir -p "$dir/metadata/$APP_ID/en-US" + cp src-tauri/icons/icon.png "$dir/metadata/$APP_ID/en-US/icon.png" +fi + +# --- 5. Only ever move forward ---------------------------------------------- + +newest=0 +for apk in "$dir"/repo/"${APP_ID}"_*.apk "$dir"/archive/"${APP_ID}"_*.apk; do + [ -e "$apk" ] || continue + code="${apk##*_}" + code="${code%.apk}" + [ "$code" -gt "$newest" ] && newest="$code" +done +if [ "$version_code" -le "$newest" ]; then + err "versionCode $version_code is not newer than $newest, already in the repo; commit something first" + exit 1 +fi + +# --- 6. Build --------------------------------------------------------------- + +info "building $version_name (versionCode $version_code)" +outputs="src-tauri/gen/android/app/build/outputs/apk/universal/release" +rm -rf "$outputs" +cargo tauri android build --apk --target aarch64 --ci \ + --config "{\"version\":\"$version_name\",\"bundle\":{\"android\":{\"versionCode\":$version_code}}}" + +apk="$(find "$outputs" -name '*.apk' | head -1)" +if [ -z "$apk" ]; then + err "the build produced no APK under $outputs" + exit 1 +fi +case "$apk" in + *unsigned*) err "the APK came out unsigned; check $key_props"; exit 1 ;; +esac +"$apksigner" verify "$apk" + +# --- 7. Publish ------------------------------------------------------------- + +cp "$apk" "$dir/repo/${APP_ID}_${version_code}.apk" +info "updating the repo index" +(cd "$dir" && fdroid update -q) + +# --- 8. Where to point F-Droid ---------------------------------------------- + +cfg() { sed -n "s/^$1: *//p" "$dir/config.yml" | head -1; } +fingerprint="$(keytool -list -v -keystore "$dir/$(cfg keystore)" -storepass "$(cfg keystorepass)" \ + -alias "$(cfg repo_keyalias)" | sed -n 's/^[[:space:]]*SHA256: *//p' | tr -d ':')" + +info "published $version_name" +cat < + + + + + + + + + + + + + + +