diff --git a/.planning/NOTES.md b/.planning/NOTES.md index e934304..9d73af7 100644 --- a/.planning/NOTES.md +++ b/.planning/NOTES.md @@ -2394,3 +2394,196 @@ And `tag_status` was only ever written by the *insert* path, so a file another tagger stamped after import kept `untagged` for ever and its folder kept asking; `updateAudioFile` promotes it now, guarded on `untagged` so a deliberate `user_skipped_permanent` survives a rescan. + +## Android cross-compiles, unchanged (measured 2026-08-16) + +Plan 015's phase 0 gate, and it passed further than it was asked to: the +whole app builds for Android and produces a working 27 MB fat APK with +**no source changes at all**. + +Environment: Arch's `android-ndk-26` (`/opt/android-ndk`, r26d / +26.3.11579264 — the pinned version), platform `android-35` and +build-tools 34.0.0 from `~/Android/Sdk`. Note that Arch's +`/opt/android-sdk` carries *no* platforms, so `ANDROID_HOME` has to +point at `~/Android/Sdk` for the Gradle half while `ANDROID_NDK_HOME` +points at `/opt/android-ndk` for the Go half. + +``` +export ANDROID_NDK_HOME=/opt/android-ndk +export ANDROID_HOME="$HOME/Android/Sdk" ANDROID_SDK_ROOT="$HOME/Android/Sdk" +cd frontend && pnpm build && cd .. # main.go embeds frontend/dist +PATH="$PWD/scripts/toolbin:$PATH" go tool wails3 task android:package:fat +``` + +Results, all first-try: + +| | | +|---|---| +| `libwails.so` arm64-v8a | 29.9 MB, production, stripped | +| `libwails.so` x86_64 | 31.8 MB, production, stripped | +| `bin/yellowjacket.apk` | 27.3 MB, both ABIs | +| Go compile, per ABI | ~9 s | +| Gradle assemble | ~13 s cold | + +**The dependency that looked fatal is fine.** A `CGO_ENABLED=0` probe of +`./backend/... ./internal/...` for `android/arm64` compiles *everything* +except two packages, and both fail only because their Android +implementation is cgo: `ebitengine/oto/v3` (`driver_android.go` needs its +bundled **oboe** C++ backend) and `wails/v3/pkg/application` (the JNI +bridge). Both are exactly what the NDK supplies. `modernc.org/sqlite` — +the whole database layer, and the thing most likely to have no Android +target — is clean. Confirmed in the linked object rather than inferred: +`nm -D` shows `oto_oboe_Play` and the `oboe::` symbols, `readelf -d` +shows `libOpenSLES.so` as NEEDED, and the +`Java_com_wails_app_WailsBridge_native*` exports are present. The audio +backend is genuinely linked, not stubbed. + +Four things found on the way that are not obvious: + +- **`wails3 update build-assets` does not generate `build/android/`.** In + beta.8 it extracts only `internal/commands/updatable_build_assets`, + which is darwin/ios/linux/windows. The android tree comes from + `generate build-assets`, which extracts the *whole* asset FS and would + rewrite all of `build/`. So it was generated into a scratch dir and + `android/` copied across. CLAUDE.md claimed the refresh regenerates it; + that was wrong, and is corrected. +- **`update build-assets` does clobber nfpm's `homepage` and + `license`**, which `build/linux/nfpm/nfpm.yaml` says in a comment it + leaves alone. It reset them to `https://wails.io` and `MIT`. The + comment is wrong; those two fields need re-checking after any refresh. +- **The scaffold's `package:fat` shipped a debug arm64 library.** + `build` forwards `ARCH` to `compile:go:shared` but not `PRODUCTION`, + so the arm64 leg recomputed `BUILD_FLAGS` against an unset + `.PRODUCTION` and took the debug branch — while amd64, which + `package:fat` calls directly with `PRODUCTION: "true"`, was correct. + A release APK therefore carried a 40 MB unstripped debug library for + the phone ABI and a 31 MB production one for the emulator. Fixed in + `build/android/Taskfile.yml`, which is this repo's one edit to that + scaffold file and is commented as such. 34 MB APK before, 27 after. +- **The generated APK is not yet an identity.** `com.wails.app`, + `versionCode 1`, `versionName 1.0`, signed `CN=Android Debug`. That is + plan 015 phase 2 and none of it is a surprise, but it is worth knowing + that the scaffold happily produces an installable-once, + never-updatable APK by default. + +**Not established:** that it *runs*. There is no AVD or system image on +this machine and no device attached, so nothing has launched the APK. +Every runtime concern plan 015 lists as out of scope is still out of +scope and still real — MPRIS in particular is compiled *in*, because +Go's `android` GOOS implies the `linux` build tag. + +## The Android build runs, and stops on one line (measured 2026-08-16) + +The APK installs and launches on an emulator. `libwails.so` loads, the +JNI bridge comes up — and the process is gone six milliseconds later. + +**The cause is `backend/system/buildUserDirPath`.** It switches on +`runtime.GOOS` with cases for `darwin`, `linux` and `windows` and a +`default:` returning `errUnsupportedOS`. `runtime.GOOS` is `"android"`, +so it takes the default, `NewYellowJacketApp` fails, and `main()` calls +`os.Exit(1)`. `YJ_HOME` overrides that path on every OS, so an +`android` case pointing at the app-private directory is the shape of +the fix. It is the *first* thing that stops it, not the only one. + +**What cost the time was not finding the bug, it was that the failure +is invisible in all three places you would look.** Worth knowing before +meeting it: + +- **Go's stdout does not reach logcat.** An app's fd 1 and 2 go to + `/dev/null`, so the `slog` line naming the error is discarded. + `setprop log.redirect-stdio true` does not help — that redirects the + *Java* runtime's `System.out`, not a c-shared native library's. +- **`os.Exit` leaves no evidence.** No panic, no `AndroidRuntime` + stack, nothing in `/data/tombstones`, nothing in `logcat -b crash` or + dropbox. The only signal present is `Zygote: exited due to signal 9`, + which reads as "the system killed it" and sends you looking at the + low-memory killer. +- **ActivityManager restarts it faster than you can observe it.** + `pidof` always answers and `am start` always says `Status: ok`, so + the app looks alive while crash-looping several times a second. The + honest check is whether it is the *same pid* a few seconds later, + which is what `make android-smoke` asserts. + +The tell is `I/WailsBridge: Wails bridge initialized` followed +immediately by a new pid doing the same thing. + +**Emulator environment**, which is not the obvious one on Arch: Gradle +needs a *platform*, and `/opt/android-sdk` (the `android-sdk` package) +has an NDK and build-tools but an empty `platforms/`. So `ANDROID_HOME` +points at `~/Android/Sdk` (user-owned, where sdkmanager writes) while +`ANDROID_NDK_HOME` points at `/opt/android-ndk` — two SDKs, one for +each half of the build. The image is +`system-images;android-35;google_apis;x86_64` (~3.5 GB with the +emulator sdkmanager pulls alongside it): `google_apis` rather than +`default` because this is a WebView app and that image carries the +Chrome-based WebView. KVM is present and usable here; without it a 30 s +boot becomes tens of minutes, which reads as a hung target. + +Operating all of this is `scripts/android-emulator.sh` and the +`make android-*` targets, documented in +`.pi/skills/yellowjacket-dev/references/android-tier.md`. + +## What the Wails v3 Android docs say, and where they are wrong (2026-08-16) + +Read after phase 0, before phase 2. Sources: `ANDROID.md` shipped inside +`wails/v3@v3.0.0-beta.8` (authoritative for our exact version) and +`v3.wails.io/guides/mobile/*`. + +**Two claims in `ANDROID.md` are wrong for beta.8, and both were +checked.** Its Configuration section says to put `APP_ID: com.example. +myapp` in `build/config.yml` and that this "controls the package name". +Neither half holds. `wails3 task` builds its variable set from CLI +`KEY=VALUE` arguments and the Taskfile tree and **never reads +`config.yml`** (`internal/commands/task.go`); adding `APP_ID` there and +running `android:run:device --dry` still emits +`am start -n com.wails.app/`. And `APP_ID` feeds only the adb commands +in the android Taskfile — uninstall, launch, log filter — never Gradle, +whose `applicationId` is a literal in `app/build.gradle`. So the +identity is necessarily declared **twice** and nothing enforces +agreement. Both are set now, each with a comment pointing at the other. + +**The fix for the crash we found is a documented API.** +`application.Mobile.StoragePath()` returns the app's private internal +files directory (`getFilesDir()` on Android, Application Support on +iOS) and — the useful part — is **build-tag-free**: `mobile.go` declares +the interface and `mobile_stub.go` returns `""` on desktop. Since +`resolveUserDirPath` already lets `YJ_HOME` override the path on every +OS, the whole fix is to set that override from `StoragePath()` early in +`main()` when it is non-empty. No `//go:build` split, no new import in +`backend/system` (which must stay Wails-free — the `indexbuild` tag +split exists for exactly that), and desktop behaviour is untouched +because the stub returns empty. + +The same section gives the general rule: branch on +`application.System.IsMobile()` / `IsPlatform(application.PlatformAndroid)` +rather than build tags, because it compiles everywhere. + +**`android` implies `linux` is documented**, which confirms rather than +discovers the MPRIS problem: `//go:build linux` files are in the Android +build and desktop-Linux-only ones need `linux && !android`. + +**A finding for the runtime plan, not this one: the folder picker does +not exist on Android.** Open-*directory* dialogs "return an error — SAF +yields tree URIs, not filesystem paths", and save-file dialogs likewise. +This app's entire first run is "choose your music folder", and its +library model is filesystem paths. That is a design problem, not a +porting detail, and it is larger than the data-directory one. + +**The scaffold ships its own android tasks**, and they are worth knowing +before writing anything: `android:run`, `run:device`, `deploy-emulator`, +`deploy-device`, `package`, `package:fat`, `bundle`/`bundle:fat` (AAB +for Play), `studio`, `device:list`, `logs`, `logs:all`, `clean`, and an +internal `ensure-emulator`. `make android-*` deliberately does not wrap +most of them. Two reasons it does not just use `android:logs`: that task +greps logcat for `(Wails|yellowjacket)`, which matches the `WailsBridge` +tag but **not** the app's own process tag (`app.yellowjacket`, lowercase) +and **not** `ActivityManager`'s "has died" line — the one that tells you +it crashed. And `ensure-emulator` takes whatever `-list-avds | tail -1` +returns, with no pidfile and no boot wait, so it cannot be stopped or +sequenced by a Makefile. + +Two smaller things. Debug builds log framework diagnostics to logcat +under the `Wails` tag and are inspectable from `chrome://inspect`; +production builds compile that out — so a debug APK is the more +informative one when something is wrong. And the docs recommend +`build-tools;35.0.0`; 34.0.0 is what is installed here and builds fine. diff --git a/.planning/plans/active/015-android-release-pipeline.md b/.planning/plans/active/015-android-release-pipeline.md new file mode 100644 index 0000000..e26ed59 --- /dev/null +++ b/.planning/plans/active/015-android-release-pipeline.md @@ -0,0 +1,383 @@ +# 015 — Android release pipeline + +Ship an Android APK from CI on every version tag, published to the Gitea +generic package registry so Obtainium can poll a plain URL. + +The baseline is `~/Development/ljos`, whose `.gitea/workflows/ci.yml` +`android:` job has been through the failure modes already. Most of what +follows is a transcription of that job onto this repo's conventions; +where it differs, the difference is argued. + +## What this is not + +**This ships a pipeline, not a usable Android music player.** The +success criterion is a signed, installable APK that launches — not an +app anyone would want. Explicitly out of scope, and each is real: + +- `backend/mediacontrols/mpris_linux.go` **will be compiled on Android**. + Go's `android` GOOS implies the `linux` build tag, so the `//go:build + linux` file is in the build and MPRIS will look for a session bus that + does not exist. It compiles; it will error at runtime. +- `backend/system` resolves XDG paths. Android has no XDG. +- The explore catalog artifact is ~0.6 GB. Nothing on a phone wants that. +- The shell is a desktop shell: an eleven-item sidebar, a 800×600 + measured minimum, a transport bar. None of that is a phone layout. +- The library scanner walks a filesystem Android does not grant. + +Those are the *next* plan, if there is one. Conflating them with this one +is how a build pipeline takes six weeks. + +## Phase 0 — the gate [DONE 2026-08-16] + +**Passed, further than asked.** No source changes were needed; a full +27 MB fat APK built first try, both ABIs, production-stripped. Numbers, +the environment and four non-obvious findings are in +`.planning/NOTES.md` — including a scaffold bug that put a *debug* +library in the release APK's phone ABI, fixed here. + +**It also installs and launches on an emulator, and then exits.** One +line stops it: `backend/system/buildUserDirPath` switches on +`runtime.GOOS` and Android takes the `default:` branch returning +`errUnsupportedOS`, so `main()` hits `os.Exit(1)` six milliseconds +after the JNI bridge comes up. That is the *first* thing that stops it, +not the only one — see the "not this" section above, all of which is +still true and still out of scope. + +The emulator tier that found it is now part of the harness: +`scripts/android-emulator.sh`, the `make android-*` targets, and +`.pi/skills/yellowjacket-dev/references/android-tier.md`. It exists +because the failure is invisible in all three places anyone would look +(no panic, no tombstone, no crash buffer) and ActivityManager restarts +the app fast enough that `pidof` always answers — so the tier's +assertion is "same pid after N seconds", not "it started". + +Original phase 0 text follows, kept because its reasoning is what the +later phases rest on. + + +Everything downstream is wasted if the c-shared link fails. Establish it +by hand, locally, before writing a line of YAML. + +Already established, by probe rather than by assumption: + +``` +GOOS=android GOARCH=arm64 CGO_ENABLED=0 go build ./backend/... ./internal/... +``` + +compiles the entire tree. Exactly two packages fail, and both fail only +because their Android implementation is cgo: + +- `ebitengine/oto/v3` — `driver_android.go` needs the bundled **oboe** + C++ backend. Oto supports Android natively; there is no Java audio + glue to write. +- `wails/v3/pkg/application` — `mobile_features_android.go` needs the + JNI bridge. + +`modernc.org/sqlite` (the whole database layer), `beep`, `godbus` and +every `backend/` package are clean. **No source changes are known to be +required**, which is the single most surprising finding here and the +reason this plan is worth doing at all. + +What Phase 0 must actually verify: + +1. Install NDK **r26d** (`26.3.11579264`) locally. Pinned, not "whatever + sdkmanager gives you" — ljos's AGENTS.md records newer NDKs breaking + this build. +2. Generate the scaffolding (Phase 1) and run + `wails3 task android:compile:go:shared ARCH=arm64` by hand. +3. Confirm `build/android/app/src/main/jniLibs/arm64-v8a/libwails.so` + exists and is an ARM64 shared object. +4. Repeat for `amd64` (the emulator ABI). + +**If the link fails, stop and re-plan.** The likely culprits, in order: +alsa (oto must select oboe, not ALSA — if it reaches for `alsa.pc` the +build tags are wrong), and `main.go`'s `//go:embed all:frontend/dist` +combined with the generated `main_android.gen.go` overlay. + +Deliverable: a note in `.planning/NOTES.md` recording the exact command +and the NDK version that produced a `.so`, or the reason it cannot. + +## Phase 1 — un-ignore and commit the Android scaffolding [DONE] + +Done as a side-effect of phase 0, which could not run without it. One +correction to the text below: **step 1 is wrong.** `update +build-assets` does not generate the android tree (NOTES.md explains); +it was generated with `generate build-assets` into a scratch dir and +`android/` copied across. CLAUDE.md is corrected to match. Steps 2-5 +were done as written. + + +`build/android/` is gitignored (`.gitignore:72`) and its `includes:` +entry was dropped from `Taskfile.yml` during plan 009. That was correct +when nothing could target Android and is what has to be undone. + +1. `wails3 task common:update:build-assets` — beta.8 embeds + `internal/commands/build_assets/android/`, so this generates the tree. +2. Remove `build/android/` from `.gitignore`; add `build/ios/`'s reason + to a comment so the asymmetry is explained rather than looking like an + oversight. +3. Add `android: ./build/android/Taskfile.yml` to `Taskfile.yml`'s + `includes:`. +4. **Gitignore the tree's own output**, or the repo grows a few hundred + Gradle intermediates. ljos has exactly this problem — its + `app/build/android/app/build/**` is committed. Ignore: + - `build/android/app/build/` + - `build/android/app/src/main/jniLibs/` + - `build/android/overlay.json` and `build/android/gen/` +5. `make build-prod` and `make test` still pass — the new include must + not perturb the desktop path. + +**The refresh hazard has to be written down.** CLAUDE.md's Packaging +section already says `build/`'s platform metadata is regenerated from +`build/config.yml` and hand edits are lost. Phase 2 edits `build.gradle` +by hand. Extend that paragraph to name `build/android/app/build.gradle` +specifically, because the loss is silent and the symptom (a debug-signed +APK) appears months later as a failed update. + +## Phase 2 — make the APK identifiable and updatable [DONE 2026-08-16] + +**Narrower than planned, because beta.8's scaffold is ahead of ljos's +beta.3: the release signing config already exists** and reads the four +`ANDROID_KEYSTORE_*` variables with a debug-keystore fallback. So this +phase was identity and versioning only. Verified end to end: + +| | | +|---|---| +| package | `app.yellowjacket` (was `com.wails.app`) | +| versionCode / versionName | `10301` / `1.3.1`, from `YJ_VERSION_CODE` / `YJ_VERSION` | +| label | `YellowJacket` | +| signing | throwaway keystore -> `Signer #1 DN: CN=YellowJacket Test`, not the debug key | +| ABIs | arm64-v8a + x86_64, both production-stripped | + +Installs and launches under the new identity. Still exits on the known +`buildUserDirPath` bug, which is phase 0's finding and not this phase's. + +Two things this phase learned that the text below did not know: + +- **The identity has to be declared twice.** `applicationId` in + `app/build.gradle` is what Gradle installs; `APP_ID` in + `build/android/Taskfile.yml` is what every adb-driven task targets. + `ANDROID.md` says to set `APP_ID` in `build/config.yml` — that does + nothing in beta.8, verified with `--dry`. Both are set, each + commented pointing at the other. +- **The launcher activity is not under the applicationId.** It stays + `com.wails.app.MainActivity` (the scaffold's Java package), so + `am start -n app.yellowjacket/.MainActivity` resolves the dot against + the wrong package and fails. `scripts/android-emulator.sh` carries the + fully-qualified name and a comment saying why. + +The `keytool` PKCS12 note below was confirmed verbatim: given a +`-keypass` differing from `-storepass` it prints "Different store and +key passwords not supported for PKCS12 KeyStores. Ignoring +user-specified -keypass value." + +Original phase 2 text follows. + + +Edit `build/android/app/build.gradle`, following ljos's, whose comments +are worth reading before writing this: + +- `applicationId "app.yellowjacket"` — matches `config.yml`'s + `productIdentifier`. The `namespace` stays `com.wails.app` (it is the + Java package, not the app identity). +- `versionCode Integer.parseInt(System.getenv("YJ_VERSION_CODE") ?: "1")` + — **`Integer.parseInt`, not `(...) as Integer`**. Groovy binds the + parentheses to `versionCode` first, so the cast reads as + `versionCode("1") as Integer`, which sets a String and then casts the + setter's null return; Gradle fails the whole project with "Value is + null" at that line. +- `versionName System.getenv("YJ_VERSION") ?: "0.0.0"`. +- `abiFilters 'arm64-v8a', 'x86_64'`. +- A `release` signing config reading `ANDROID_KEYSTORE_FILE` / + `_PASSWORD` / `ANDROID_KEY_ALIAS` / `ANDROID_KEY_PASSWORD`, falling + back to the debug keystore only when no keystore is supplied. + +**Android orders releases by an integer and refuses anything not greater +than what is installed.** A hardcoded `versionCode 1` means the first +install is the last: every later build is rejected as a downgrade and the +only fix is an uninstall. `1.3.1 -> 10301`, monotonic as long as minor +and patch stay under 100. + +**Signing is not optional past the first install.** Android refuses to +update an app whose signing key changed, and the debug keystore differs +between every machine and every runner — so an unsigned CI build is a +decision to reinstall by hand forever. The job must **refuse to build** +without the keystore rather than quietly produce an APK that can never be +updated. + +There is **one password and two required secrets**. keytool has defaulted +to PKCS12 since JDK 9 regardless of the `.jks` extension, and PKCS12 +cannot hold a separate key password — given `-keypass` it warns and +ignores it. So `ANDROID_KEY_PASSWORD` defaults to the store password and +`ANDROID_KEY_ALIAS` to `yellowjacket`. Asking for a second password that +cannot exist is how someone sets a wrong value and debugs Gradle at +midnight. + +Add `make android` → `PATH="$(TOOLBIN):$$PATH" go tool wails3 task +android:package:fat`, beside `build-prod`. `make skill-check` fails on a +documented target that does not exist, so document it only once it does. + +## Phase 3 — the workflow [DONE 2026-08-16] + +`.gitea/workflows/android-apk.yml`, plus `docs/android-release.md` as +the operating document its error messages point at (phase 4's +documentation half; the secrets themselves still have to be created by +hand — see the table there). + +Three departures from the text below, all argued in the file: + +- **No `continue-on-error`.** The plan inherited it from ljos, where + the Android job shares a pipeline with a server deploy that must + never go red over a phone build. Here it is standalone and can + neither delay nor redden anything, so a release step that fails + silently is strictly worse than one that fails visibly. +- **No cached `wails3` binary.** The plan budgeted for ljos's + `tools-bin` copy. Unnecessary: the CLI is a vendored `go tool`, and + the runner already bind-mounts `GOCACHE`/`GOMODCACHE` for every job, + so it is warm from `ci.yml`'s own `make bindings-check`. The GTK and + WebKit *dev* headers are still installed, because `go tool wails3` + links them. +- **A fourth cache volume, `/cache/gradle`.** Not in the plan and worth + ~700 MB a run. + +Four publish-gates were added and each was checked against a real APK: +both ABIs present, `versionCode` equal to the one derived from the tag, +a non-empty artifact, and **not signed with the debug key** — verified +by pointing the check at a deliberately debug-signed build, which it +refused. + +Rehearsed locally with the exact CI invocation +(`make android ANDROID_SDK=... ANDROID_NDK=...`, `YJ_VERSION`, +`YJ_VERSION_CODE`, a throwaway keystore): `app.yellowjacket`, +versionCode 10301, versionName 1.3.1, label YellowJacket, both ABIs, +`Signer #1 DN: CN=YellowJacket`. Not yet run on the runner. + +Original phase 3 text follows. + + +New file: `.gitea/workflows/android-apk.yml`. **Not a job in `ci.yml`.** +`ci.yml` runs on every branch push and is the workflow that gates; the +runner is capacity 1, and a 45-minute Android build in it would put every +push behind an SDK download. + +```yaml +on: + push: + tags: ["v*"] + workflow_dispatch: +``` + +This is where the baseline genuinely diverges. ljos computes its version +in CI (`scripts/next-version.sh`) and gates the Android job on +`needs.release.outputs.version != ''`, with an `always()` whose absence +would silently kill the manual path. **This repo has no release +automation** — tags are pushed by hand and `homebrew-formula.yml` already +keys on `v*`. So there is no `needs:`, no `always()`, and no status +function to get wrong: the tag *is* the version, and a dispatch falls +back to `git describe --tags --abbrev=0`. + +Container, matching `ci.yml`'s conventions (`ubuntu:24.04`, clone by hand +with `PACKAGE_TOKEN` rather than `actions/checkout`, which is a JS action +needing node before any step has installed it): + +```yaml +container: + image: ubuntu:24.04 + volumes: + - /home/logan/docker/gitea/data/runner/cache/tool:/cache/tool + - /home/logan/docker/gitea/data/runner/cache/android-sdk:/cache/android-sdk +``` + +The SDK path must be inside the runner's `valid_volumes` allowlist — +a directory outside it makes the job **fail to start**, not silently skip +the mount. `/cache/tool` is already allowed and already holds the Go +toolchain `ci.yml` downloads. + +`continue-on-error: true` and `timeout-minutes: 45`. Advisory, because a +tag's other three workflows must not go red over a phone build, and a +backstop because a wedged SDK download must not hold the only runner slot +for hours. + +Steps: + +1. **System packages.** `ci.yml`'s set plus `unzip` and `openjdk-17-jdk`. + `libasound2-dev` stays — it is for the *host* `wails3` build, not the + Android cross-build, which uses oboe. +2. **Go toolchain** — reuse `ci.yml`'s `/cache/tool/go` block verbatim. +3. **Android SDK and NDK (cached).** ljos's `install_if_missing` + idempotent guard, unchanged: cmdline-tools 11076708, `platform-tools`, + `platforms;android-34`, `build-tools;34.0.0`, `ndk;26.3.11579264`. + sdkmanager is itself idempotent but still spends minutes verifying, + which is why the explicit directory guards are there. ~3 GB and most of + the job's wall clock on the first run; a directory listing after. +4. **wails3.** Cheaper here than in ljos, which pins + `go install …/wails3@$version` against `app/go.mod`. This repo vendors + the CLI (`go tool wails3`, `scripts/toolbin/wails3`), so the version is + already pinned by `go.mod` and there is nothing to drift. It still + *links* GTK and WebKit, so cache the built binary in + `/cache/android-sdk/tools-bin` keyed on the wails version — and note + ljos's finding that **caching the binary alone turned a slow job into + a broken one**: `wails3` is dynamically linked, so the runtime + packages are needed even on a cache hit. Here they are already in + step 1. +5. **Frontend + codegen.** `pnpm install --frozen-lockfile && pnpm build` + (pnpm, not ljos's npm), then `make generate`. `main.go` embeds + `frontend/dist`, so nothing Go-side typechecks without it. +6. **Decode the keystore.** Refuse to build if `ANDROID_KEYSTORE_B64` is + unset, with the sentence explaining why (Phase 2). Decide the absolute + path *here* and export it via `$GITHUB_ENV` — **`${{ env.HOME }}` + evaluates to an empty string in Gitea's expression context**, which + turned `$HOME/x.jks` into `/x.jks` and surfaced as a missing file + fifty-five seconds into a Gradle run. +7. **Build.** Compute `YJ_VERSION_CODE` from the tag, verify the keystore + opens with `keytool -list` *before* Gradle does (Gradle only notices at + `:app:validateSigningRelease`, a minute in, and reports it as a missing + file), then `make android`. +8. **Verify the signature.** `apksigner verify --print-certs`, and print + the SHA-256 with the note that a change to it breaks every future + update. **Nothing here pipes into `head`**: under `set -o pipefail`, + `head -1` exits early, the producer takes SIGPIPE, and the step fails + with 141 *after* printing a perfectly good APK. Use `find … -print + -quit` and a captured variable. +9. **Publish** to `api/packages/${OWNER}/generic/yellowjacket-android`, + authenticating `--user "${OWNER}:${PACKAGE_TOKEN}"` — the same + credential pair `arch-package.yml` already uses, not ljos's + `REGISTRY_USER`/`REGISTRY_TOKEN`. Two copies: a versioned one for + history and a fixed `latest/yellowjacket.apk` that Obtainium watches. + Gitea refuses to overwrite, so delete `latest` first. The generic + registry is readable **without credentials**, which is what lets + Obtainium poll a plain URL with no token and no public source mirror. + +## Phase 4 — secrets and documentation + +Secrets to create on the repo (all under Settings → Actions → Secrets): + +| Secret | Required | Note | +|---|---|---| +| `ANDROID_KEYSTORE_B64` | yes | `base64 -w0 yellowjacket-release.jks` | +| `ANDROID_KEYSTORE_PASSWORD` | yes | | +| `ANDROID_KEY_ALIAS` | no | defaults to `yellowjacket` | +| `ANDROID_KEY_PASSWORD` | no | defaults to the store password | +| `PACKAGE_TOKEN` | already exists | used by `arch-package.yml` | + +Write the keytool command, the Obtainium URL and the signing-key warning +into a docs page — this is the part of ljos's setup that lives in +`docs/clients.md` and is referenced from the workflow's error messages, +so the messages have somewhere to point. + +Then extend CLAUDE.md's CI section: it currently says "four workflows, +three of them package and publish; only `ci.yml` gates". That becomes +five, with the same sentence still true. + +## Order and stopping points + +Phase 0 gates everything. Phases 1–2 are one commit's worth of work and +are verifiable locally without CI. Phase 3 is the only part that needs a +runner, and its first run will be slow and will probably fail once on +something in the SDK step — budget for that rather than treating it as a +setback. + +**Stop after Phase 0 if the c-shared link does not work.** Every later +phase is scaffolding for a build that does not exist, and the honest +outcome is a NOTES.md entry saying which package cannot cross-compile and +what it would take. diff --git a/CLAUDE.md b/CLAUDE.md index bae21c5..1757605 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1768,10 +1768,24 @@ Feature branches and PRs are the norm, but direct pushes to `main` are allowed. ## CI -Four workflows in `.gitea/workflows/`. Three of them package and -publish (`arch-package`, `homebrew-formula`, `index-artifact`); only -`ci.yml` gates, and it is the one to look at when deciding whether a -push was healthy. +Five workflows in `.gitea/workflows/`. Four of them package and +publish (`arch-package`, `homebrew-formula`, `index-artifact`, +`android-apk`); only `ci.yml` gates, and it is the one to look at when +deciding whether a push was healthy. + +**`android-apk.yml` is the only one keyed on a tag and the only one +that can lose something irrecoverable.** It builds the signed fat APK +on every `v*` tag and publishes it to the *generic* registry, which is +readable without credentials — the reason Obtainium can poll a plain +URL. Android refuses to update an app whose signing certificate +changed, and the only remedy is an uninstall that takes the user's +library with it, so the job **refuses to build** without the keystore +secret rather than falling through to Gradle's debug-key default, and +**refuses to publish** an artifact whose certificate says `CN=Android +Debug`. It is deliberately not a job in `ci.yml`: that workflow runs on +every branch push, this one takes tens of minutes on a cold cache, and +the runner has capacity 1. `docs/android-release.md` is the operating +document. Two jobs, both in an `ubuntu:24.04` container: @@ -1853,11 +1867,20 @@ four bit the packaging recipes: **`build/`'s platform metadata is generated from `build/config.yml`.** `wails3 task common:update:build-assets` rewrites `Info.plist`, the `.desktop` template, `nfpm.yaml` and the Windows manifest from that -one file — so a hand edit to any of them is lost on the next refresh, -and the two fields it does *not* own (nfpm's `homepage` and `license`) -say so in place. That refresh also regenerates `build/ios/` and -`build/android/`, which this repo does not carry: they are gitignored -rather than deleted-and-rediscovered, and their `includes:` entries -are dropped from `Taskfile.yml`. `build/config.yml`'s `version` is the +one file — so a hand edit to any of them is lost on the next refresh. +nfpm's `homepage` and `license` say in place that the refresh does not +own them, and **that comment is wrong**: a refresh reset them to +`https://wails.io` and `MIT`. Re-check those two after any refresh. + +**That refresh does not touch the mobile trees**, contrary to what this +file said for five phases. `update build-assets` extracts only +`updatable_build_assets` (darwin/ios/linux/windows); `build/android/` +and `build/ios/` come from `generate build-assets`, which rewrites the +whole of `build/`. So `build/android/` is **committed and hand-edited +like source** — it was generated once into a scratch directory and +copied across (plan 015), it carries one deliberate edit to its +`Taskfile.yml`, and only its output is gitignored. `build/ios/` is +still not carried and its `includes:` entry is still dropped. +`build/config.yml`'s `version` is the *metadata* version and is not what the app reports — `main.version` is stamped at link time from the packaging recipe's git-derived version.