Commit Graph
6 Commits
Author SHA1 Message Date
bvandeusen 010e9a2f85 server: bake the newest Android client into every image (operator call)
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 7s
CI & Build / Python tests (push) Successful in 11s
CI & Build / Build & push image (push) Successful in 47s
Reverses the placement decision made an hour ago. That one put the APK only on
the data volume, reasoning that ~55 MiB should not be charged to installs that
never touch Android. The operator's call is that ending the manual copy is worth
the megabytes, and it is their deployment.

CI now fetches the newest published client into the build context immediately
before the image build, so `:dev`, `:latest` and `:<version>` all ship one and a
`docker compose pull` delivers a new server and a new client together.

**Always the rolling `dev` release — the newest build there is.** A versioned
image therefore carries the newest client rather than one pinned to that
version. Deliberate: the two negotiate a sync protocol version before linking, so
a mismatch is caught by the handshake, and pinning would buy nothing the
handshake does not already provide.

**Fetched by the JOB, never by the Dockerfile.** The release is private, and a
token used inside a build ends up in the context or a layer.

**It cannot fail the image build.** No release yet, a network blip, a first-ever
build — all of them log a warning and produce an image with no client, which is a
state the server already supports. Half a pair is cleaned up rather than shipped:
a sidecar without its APK is worse than neither, because the server would be
describing something it cannot serve.

**The volume still wins.** `DATA_DIR/client/` is checked first and the baked copy
second, so an operator who deliberately drops a build in gets that build — and a
BROKEN drop-in falls through to the image's copy rather than taking the feature
offline, which is what makes the copy-order advice survivable instead of
load-bearing. Three tests cover the precedence, including that last case.

The baked copy lives inside the package, not under DATA_DIR: that path is a
volume mount, and anything the image wrote there would disappear behind it the
moment one is attached.

`client/.keep` is tracked so `COPY client/` cannot fail on a tree where the CI
step never ran; the artifacts themselves are gitignored, since a 55 MiB binary
does not belong in git history and is re-fetched on every build anyway.
2026-08-20 21:19:47 -04:00
bvandeusen d0a9c73bf9 android: sign the release build, and give it a version that rises
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m54s
Android / Kotlin + Rust (APK) (push) Failing after 4m10s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m15s
Desktop (Tauri) / Update manifest (push) Successful in 3s
Two separate reasons updates were impossible, both fixed here.

**Every CI build was signed with a different key** (issue #2803, measured with
`apksigner --print-certs` across two runs). No signing config meant AGP's debug
keystore, which AGP GENERATES when absent — and every job starts from a fresh
container. So no build could ever be installed over another: the only way
through was uninstall-then-install, which deletes the app's database and every
local note with it.

**versionCode was hardcoded to 1.** `build.gradle.kts` has read a
`THOUGHTSYNC_VERSION_CODE` property since the skeleton landed; nothing ever
passed it. Even with signing fixed, every APK would have claimed to be the same
version and nothing could tell a newer one existed. It now comes from
`GITHUB_RUN_NUMBER` — the same monotonic counter the desktop's version scheme
already uses, needing no state between runs and immune to the shallow checkout
that makes a commit count useless here. The version NAME comes from the
desktop's `build-version.sh`, so both surfaces report one product version rather
than two that can disagree.

**The alias is hardcoded, not a secret.** It is fixed for the life of the app and
already written into the certificate every install carries; hiding it would buy
nothing and stop this file describing its own signing. Two secrets, not three —
and PKCS12 cannot hold a key password distinct from the store password anyway,
so `keyPassword` is the same value by necessity rather than by shortcut.

**The lane now builds RELEASE when it can sign, debug when it cannot.** That is
not cosmetic. A debug APK is `debuggable`, which on a phone holding personal
notes and a device sync token means anyone with adb can read both.

Which meant confronting something the release path would have shipped quietly:
`cargoNdkDebug` was hardcoded to the debug Cargo profile and every variant took
its `.so` from it, so `assembleRelease` would have packaged an UNOPTIMISED store
and sync engine. Now one `cargoNdk` task takes its profile from a property, and
the whole run uses one profile. A debug/release task pair would have been the
tidier shape and would have made a run that both type-checks and packages pay
the four-minute cross-compile twice — this runner has no working Gradle or Cargo
cache, so that cost is real on every push.

The run prints the signing certificate after assembling, so the fingerprint can
be compared against the one recorded at generation. Signing with the wrong key
produces a perfectly valid APK that simply refuses to install — a failure that
otherwise surfaces on the device, long after the run is green.

`.gitignore` learns `*.jks`, `*.keystore`, `*.p12`, `*.b64` first, so generating
a keystore anywhere near this tree cannot go wrong.

Also corrects the record: the comment this replaces cited "Scribe task 2136" as
though it were a standing rule. It is not one — none of the 46 always-on rules
mentions signing keys. 2136 is a desktop-updater task whose REASONING got
repeated until it sounded like policy. The reasoning holds, and holds harder on
Android where a key cannot be rotated without the original, so the practice is
unchanged; the citation is now honest about what it is.
2026-08-20 19:03:57 -04:00
bvandeusenandClaude Opus 5 eb3dc3d893 gitignore: don't let a downloaded APK into history
Debug APKs get pulled into the working tree for emulator testing. They are ~57 MB
and come from CI artifacts, so they are never a source — but nothing stopped
`git add -A` from committing one permanently.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 08:31:44 -04:00
bvandeusenandClaude Opus 5 20907abf6e android: a Kotlin/Compose app that drives the Rust core (M12 step 5)
Android / Kotlin + Rust (debug APK) (push) Failing after 1m20s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m24s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m0s
Desktop (Tauri) / Update manifest (push) Successful in 5s
The skeleton, and the lane that builds it. Gradle invokes cargo-ndk to
cross-compile thoughtsync-ffi for four ABIs, generates the Kotlin bindings from
the resulting .so, and packages both.

BUILT ON MINSTREL'S TOOLCHAIN, not a fresh guess. Gradle 9.1.0 / AGP 9.0.1 /
Kotlin 2.3.21 on JDK 25 is the combination already proven in this family on
ci-android, including the JDK 22+ native-access opt-in the launcher JVM needs
and the artifact-upload action pinned by SHA (issues 2255 / 2270). It also
independently confirms the JDK 25 call made on ci-rust-android in step 3.

Gradle wiring worth noting:

  * ExecOperations, not project.exec — the latter was REMOVED in Gradle 9, and
    touching `project` at execution time is also what breaks the configuration
    cache this build enables.
  * The cargo task's inputs are the Rust SOURCES, not the workspace directory.
    Declaring the directory would make Gradle hash target/, which is gigabytes.
  * Bindings are generated with `--library` against the built .so, so they can
    never describe a different version of the Rust than the one being packaged.
  * cargo runs --locked, so an Android build cannot silently re-resolve the
    lockfile the desktop lanes are gated on.

JNA is a real dependency, with the @aar classifier. The plain jar builds fine
and fails at runtime with UnsatisfiedLinkError, which is the worst way to learn
it. R8 keep rules for JNA and the bindings are in for the same reason — that
failure would otherwise appear only in a minified release.

The UI is a working board, not a debug screen: capture field, note list, empty
state, error banner, and an honest failure screen for a store that won't open.
Rules 23/24 — a surface ships at quality from the first commit. Capture uses the
IME action key because the north star is a thought captured in under a second,
and leaves the title empty so the core derives it from the first body line.

Every core call runs on Dispatchers.IO: they are blocking FFI into synchronous
SQLite, and running them on the main thread is exactly the jank going native was
meant to avoid.

The launcher icon reuses frontend/public/icon-maskable-512.png as an adaptive
foreground on the brand #F5C518 — the same asset and colour the web app already
ships, so the three surfaces wear one face.

No signing config. A release keystore that has passed through an agent session
or shell history is compromised by construction (task 2136); it has to be
generated by the operator and reach CI only as a secret. CI builds debug.

CI can only prove this BUILDS — a Linux runner cannot execute an APK, so feel
and on-device correctness remain an operator pass on an emulator.

Scribe #2739.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 15:24:30 -04:00
bvandeusenandClaude Opus 5 0a7480cf9b core: extract the store and sync engine into a shared crate (M12 step 1)
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 48s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 1m50s
Desktop (Tauri) / Update manifest (push) Skipped
Android becomes a native Kotlin client over this same code (Scribe note 2730), so
the local store and sync engine stop being modules of the desktop app and become
`thoughtsync-core`, a crate with no UI framework in it at all.

This is a move, not a rewrite, and the measurement is why: every file in local/
and sync/ already carried ZERO Tauri references — 4,980 of 6,372 lines. The
coupling was 473 lines of command shim, which stays behind in the desktop crate
as src/commands/. Kept as git renames so history follows the files.

The desktop imports them under their old names (`use thoughtsync_core::{local,
sync}`) so every call site reads exactly as before. What moved is where they
live, not what they are.

Two things a workspace changes that are easy to miss, both caught before pushing:

[profile.release] now lives at the workspace ROOT. Cargo silently ignores
profiles declared by a non-root member — leaving it in the desktop crate would
have dropped lto/strip/opt-level from every release build with only a warning.

And a workspace shares ONE target dir, so the bundles moved from
desktop/src-tauri/target to target/. Thirteen references across publish-release,
debundle-graphics, verify.sh, package-prebuilt and the workflow now point there.
Pinning target-dir back would have been the smaller diff, but the Android lane
also produces Rust artifacts and they do not belong under desktop/.

Also retires the Tauri Android lane in the same push rather than leaving a path
that is being replaced: gen/android, android.yml and docs/android-dev.md are
gone, the mobile_entry_point attribute with them, and the lib drops to rlib —
staticlib/cdylib existed for Tauri mobile, and the .so Android loads will be
built from the core crate instead. Rule 22, no parallel path.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 23:12:26 -04:00
bvandeusen cefffa2bd6 Initial commit 2026-07-19 12:34:56 -04:00