Publish Android releases to a self-hosted F-Droid repo
scripts/fdroid-publish.sh builds a signed arm64 APK and adds it to an F-Droid repo in a given directory, creating the app signing key and the repo on first use. The versionCode is the commit count, so any commit can ship without a manual version bump. Release builds are signed when src-tauri/gen/android/keystore.properties exists and stay unsigned otherwise. The manifest picks up the deep-link plugin's intent filter, which every build regenerates and was never committed.
This commit is contained in:
49
README.md
49
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
|
||||
|
||||
@@ -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",
|
||||
|
||||
218
scripts/fdroid-publish.sh
Executable file
218
scripts/fdroid-publish.sh
Executable file
@@ -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 <dir>/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 <dir>/repo (and <dir>/archive if you like), never <dir> 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" <<EOF
|
||||
|
||||
repo_url: ${FDROID_REPO_URL%/}
|
||||
repo_name: mtgc
|
||||
repo_description: >-
|
||||
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 <<EOF
|
||||
|
||||
Serve $dir/repo at $(cfg repo_url)
|
||||
(not $dir itself — it holds the repo signing key). Add this to F-Droid:
|
||||
|
||||
$(cfg repo_url)?fingerprint=$fingerprint
|
||||
EOF
|
||||
@@ -1,3 +1,4 @@
|
||||
import java.io.FileInputStream
|
||||
import java.util.Properties
|
||||
|
||||
plugins {
|
||||
@@ -13,6 +14,15 @@ val tauriProperties = Properties().apply {
|
||||
}
|
||||
}
|
||||
|
||||
// Release signing key, written by `scripts/fdroid-publish.sh --init-keystore`.
|
||||
// Without it, release builds come out unsigned as before.
|
||||
val keystoreProperties = Properties().apply {
|
||||
val propFile = rootProject.file("keystore.properties")
|
||||
if (propFile.exists()) {
|
||||
FileInputStream(propFile).use { load(it) }
|
||||
}
|
||||
}
|
||||
|
||||
android {
|
||||
compileSdk = 36
|
||||
namespace = "com.garrettmills.mtgc"
|
||||
@@ -24,6 +34,16 @@ android {
|
||||
versionCode = tauriProperties.getProperty("tauri.android.versionCode", "1").toInt()
|
||||
versionName = tauriProperties.getProperty("tauri.android.versionName", "1.0")
|
||||
}
|
||||
signingConfigs {
|
||||
if (!keystoreProperties.isEmpty) {
|
||||
create("release") {
|
||||
storeFile = file(keystoreProperties.getProperty("storeFile"))
|
||||
storePassword = keystoreProperties.getProperty("password")
|
||||
keyAlias = keystoreProperties.getProperty("keyAlias")
|
||||
keyPassword = keystoreProperties.getProperty("password")
|
||||
}
|
||||
}
|
||||
}
|
||||
buildTypes {
|
||||
getByName("debug") {
|
||||
manifestPlaceholders["usesCleartextTraffic"] = "true"
|
||||
@@ -37,6 +57,7 @@ android {
|
||||
}
|
||||
}
|
||||
getByName("release") {
|
||||
signingConfigs.findByName("release")?.let { signingConfig = it }
|
||||
isMinifyEnabled = true
|
||||
proguardFiles(
|
||||
*fileTree(".") { include("**/*.pro") }
|
||||
|
||||
@@ -28,6 +28,21 @@
|
||||
<!-- AndroidTV support -->
|
||||
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
|
||||
</intent-filter>
|
||||
<!-- DEEP LINK PLUGIN. AUTO-GENERATED. DO NOT REMOVE. -->
|
||||
<intent-filter >
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<!-- ChromeOS ARC++ uses a different action for deep links -->
|
||||
<action android:name="org.chromium.arc.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="mtgc" />
|
||||
<data android:host="auth" />
|
||||
<data android:path="/callback" />
|
||||
|
||||
|
||||
|
||||
</intent-filter>
|
||||
<!-- DEEP LINK PLUGIN. AUTO-GENERATED. DO NOT REMOVE. -->
|
||||
</activity>
|
||||
|
||||
<provider
|
||||
|
||||
Reference in New Issue
Block a user