219 lines
9.9 KiB
Markdown
219 lines
9.9 KiB
Markdown
# Waters Checklist — native iOS & Android apps
|
|||
|
|
|
||
|
|
The checklist app now ships to **three** targets, all built from the same front-end files in
|
||
|
|
`../server/public/`:
|
||
|
|
|
||
|
|
| Target | Where | Updates when |
|
||
|
|
|---|---|---|
|
||
|
|
| Website | `check.watersmedia.org` | you deploy — immediately |
|
||
|
|
| iOS app | App Store (unlisted) | a new build is uploaded **and released** |
|
||
|
|
| Android app | Play Store (unlisted track) | a new AAB is uploaded **and released** |
|
||
|
|
|
||
|
|
> **The one thing that matters:** the store apps bundle a *copy* of the front-end inside the
|
||
|
|
> app binary. They have no mechanism to notice a web deploy — not on relaunch, not on
|
||
|
|
> sign-out, not ever. Deploy the website alone and phones keep the UI they were last built
|
||
|
|
> with, silently, while the website moves on. So after any front-end change, run:
|
||
|
|
>
|
||
|
|
> ```bash
|
||
|
|
> ./release.sh
|
||
|
|
> ```
|
||
|
|
>
|
||
|
|
> from the project root (not from `mobile/`). That's the whole workflow.
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Everyday release
|
||
|
|
|
||
|
|
```bash
|
||
|
|
cd /Users/Studio/Documents/recurring-checklist-app
|
||
|
|
./release.sh # all three targets, bumps both build numbers
|
||
|
|
./release.sh --version 1.1.0 # …and set the version users see
|
||
|
|
./release.sh --web # website only — for a server/cron/email change
|
||
|
|
```
|
||
|
|
|
||
|
|
Once both API keys are configured (below), that one command deploys the website, uploads the
|
||
|
|
iOS build to App Store Connect and releases the Android build to the internal track — no
|
||
|
|
consoles, no dragging files. Builds also land in `dist-release/` either way.
|
||
|
|
|
||
|
|
**What "fully automated" can and cannot mean.** The website is live the second it deploys.
|
||
|
|
The stores are not, and no automation changes that: Play takes a few minutes to process an
|
||
|
|
internal-track release, and Apple takes ~15 minutes to process a build before TestFlight
|
||
|
|
testers can install it. Anything beyond TestFlight — a public App Store release — waits on
|
||
|
|
human review. The upload is automated; the stores' clocks are not.
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Architecture, in one screen
|
||
|
|
|
||
|
|
The apps are **not** a browser pointed at the website — Apple rejects that under Guideline
|
||
|
|
4.2. They bundle the front-end and treat the server as a JSON API.
|
||
|
|
|
||
|
|
That creates exactly one problem: on the web, front-end and API share an origin, so
|
||
|
|
`/api/state` and `/equip-images/x.jpg` just work. Inside an app the page is served from
|
||
|
|
`capacitor://localhost` (iOS) or `https://localhost` (Android), where those paths resolve
|
||
|
|
against the app bundle and 404. Two files solve it:
|
||
|
|
|
||
|
|
- **`../server/public/native-bridge.js`** — shared by *all three* targets, loaded before
|
||
|
|
`app.js`. In a browser it is a deliberate no-op. In an app it patches `window.fetch` to
|
||
|
|
re-point server-absolute paths, exposes `resolveUrl()` for the few `<img>`/`<a>` sites,
|
||
|
|
routes `navigator.vibrate` → native haptics, and opens external links in the system
|
||
|
|
browser instead of blanking the WebView.
|
||
|
|
- **`src/native-shell.js`** — native only, esbuild-bundled and injected into app bundles
|
||
|
|
only. Publishes `window.CapPlugins` synchronously, then handles splash handover, status
|
||
|
|
bar, Android's hardware Back button, foreground resume, and online/offline.
|
||
|
|
|
||
|
|
`server.js` carries a matching **`NATIVE_CORS_ORIGINS`** allowlist (an allowlist, not `*`;
|
||
|
|
auth is a Bearer token with no cookies, so `Allow-Credentials` is deliberately omitted).
|
||
|
|
|
||
|
|
`www/` is generated. It is wiped and rebuilt from `../server/public` on every sync — editing
|
||
|
|
it is always a mistake.
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Toolchain (already installed, no Homebrew on this Mac)
|
||
|
|
|
||
|
|
| | |
|
||
|
|
|---|---|
|
||
|
|
| JDK 21 | `~/.jdks/temurin-21` |
|
||
|
|
| Android SDK | `~/Library/Android/sdk` (API 36 + 35, build-tools, platform-tools) |
|
||
|
|
| iOS deps | **Swift Package Manager, not CocoaPods** — system Ruby is 2.6, too old for CocoaPods |
|
||
|
|
|
||
|
|
None of it is on the default `PATH`, so **every Android command must source the env first**:
|
||
|
|
|
||
|
|
```bash
|
||
|
|
. mobile/scripts/env.sh
|
||
|
|
```
|
||
|
|
|
||
|
|
Skip that and Gradle fails with the misleading *"Unable to locate a Java Runtime."*
|
||
|
|
`release.sh` does it for you.
|
||
|
|
|
||
|
|
To rebuild the toolchain on a new Mac, `scripts/env.sh` carries the exact reinstall commands
|
||
|
|
in its error messages.
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## iOS one-time setup
|
||
|
|
|
||
|
|
**Signing is DONE** (2026-09-14). Team **LIVING WATERS CHURCH**, Team ID `M87US675B3`, in
|
||
|
|
`mobile/ios-signing.config`. `release.sh --ios` produces a signed App Store `.ipa`
|
||
|
|
unattended — `-allowProvisioningUpdates` created the distribution certificate, the
|
||
|
|
provisioning profile and the App ID registration on its own.
|
||
|
|
|
||
|
|
If you ever set this up again (new Mac, new team): sign into Xcode ▸ Settings ▸ Accounts,
|
||
|
|
then put the Team ID in `ios-signing.config` and run `./release.sh --ios`. Xcode does the
|
||
|
|
rest. Finding the Team ID is the fiddly part — Xcode 26 shows your teams but never prints
|
||
|
|
their IDs, and caches nothing readable. Decode any profile you already have instead:
|
||
|
|
|
||
|
|
```bash
|
||
|
|
security cms -D -i ~/Library/Developer/Xcode/UserData/Provisioning\ Profiles/*.mobileprovision \
|
||
|
|
| grep -A1 -E "TeamIdentifier|TeamName"
|
||
|
|
```
|
||
|
|
|
||
|
|
Still to do in the portals:
|
||
|
|
|
||
|
|
1. **App Store Connect** → Apps → **+** → New App, bundle ID `org.waterschurch.checklist`.
|
||
|
|
Needed before a build can be uploaded.
|
||
|
|
2. For unlisted distribution, request it at
|
||
|
|
[Apple's unlisted app request form](https://developer.apple.com/contact/request/unlisted-app-distribution/).
|
||
|
|
Apple approves it manually — allow a few days. Until then use **TestFlight**, which works
|
||
|
|
immediately and is fine for volunteers.
|
||
|
|
|
||
|
|
### Automated uploads (App Store Connect API key)
|
||
|
|
|
||
|
|
With a key configured, `release.sh` validates the build against Apple's acceptance checks and
|
||
|
|
uploads it — no Organizer, no Transporter. Without one it just hands you the `.ipa`.
|
||
|
|
|
||
|
|
Creating the key needs your Apple ID and 2FA, so it's yours to do:
|
||
|
|
|
||
|
|
1. [appstoreconnect.apple.com](https://appstoreconnect.apple.com) → **Users and Access** →
|
||
|
|
**Integrations** → **App Store Connect API** → **Team Keys**.
|
||
|
|
2. **+** → name it (e.g. `Checklist release`) → Access: **App Manager** → Generate.
|
||
|
|
*(App Manager is the minimum that can upload builds. Developer is not enough.)*
|
||
|
|
3. **Download the .p8 — you get exactly one chance.** Apple never shows it again; a lost key
|
||
|
|
can only be revoked and replaced.
|
||
|
|
4. Move it where `altool` looks, keeping Apple's filename exactly:
|
||
|
|
```bash
|
||
|
|
mv ~/Downloads/AuthKey_*.p8 ~/.appstoreconnect/private_keys/
|
||
|
|
```
|
||
|
|
5. Copy two values off that page into `ios-signing.config`:
|
||
|
|
- **KEY ID** — 10 chars, in the key's own row
|
||
|
|
- **Issuer ID** — a UUID shown *above the table*, shared by every key on the team
|
||
|
|
(a classic mix-up: the Issuer ID is not the key's ID)
|
||
|
|
```
|
||
|
|
ASC_KEY_ID=XXXXXXXXXX
|
||
|
|
ASC_ISSUER_ID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
|
||
|
|
```
|
||
|
|
6. Confirm it works — authenticates only, uploads nothing:
|
||
|
|
```bash
|
||
|
|
./mobile/scripts/verify-asc-key.sh
|
||
|
|
```
|
||
|
|
|
||
|
|
The `.p8` is a credential that can ship builds as your team. It lives outside the repo in
|
||
|
|
`~/.appstoreconnect/private_keys/` (chmod 600), and `ios-signing.config` is gitignored. Back
|
||
|
|
the `.p8` up next to the Android keystore — losing it is recoverable (revoke and regenerate),
|
||
|
|
but it costs you the round trip.
|
||
|
|
|
||
|
|
## Android one-time setup
|
||
|
|
|
||
|
|
Already done — the upload key exists and `release.sh` produces a signed AAB.
|
||
|
|
|
||
|
|
⚠️ **Back up `mobile/keystore/upload-keystore.jks` and `mobile/keystore.properties` off this
|
||
|
|
Mac** (password manager / encrypted drive). Play identifies every update by that key. Both
|
||
|
|
files are gitignored and excluded from the deploy tarball.
|
||
|
|
|
||
|
|
Remaining: **Play Console** → Create app → bundle ID `org.waterschurch.checklist` → set up
|
||
|
|
an internal testing track → upload the AAB → add testers by email.
|
||
|
|
|
||
|
|
### Android uploads (Play Developer API)
|
||
|
|
|
||
|
|
With a service-account key present, `release.sh` uploads the `.aab` and releases it to the
|
||
|
|
internal track by itself. Without one it just leaves the file in `dist-release/`.
|
||
|
|
|
||
|
|
**Play requires the very first bundle of a new app to go through the Console UI by hand.**
|
||
|
|
The API can only take over from the second release onward — so do one manual upload first,
|
||
|
|
then this works forever after.
|
||
|
|
|
||
|
|
1. **Play Console** → *Users and permissions* → note you'll come back here in step 5.
|
||
|
|
2. [console.cloud.google.com](https://console.cloud.google.com) → create (or pick) a project →
|
||
|
|
**APIs & Services** → enable **Google Play Android Developer API**.
|
||
|
|
3. **IAM & Admin → Service Accounts → Create**. Name it `play-release`. No Cloud roles are
|
||
|
|
needed — permission comes from Play, not from Cloud IAM.
|
||
|
|
4. On that service account → **Keys → Add key → Create new key → JSON**. Save it as:
|
||
|
|
```
|
||
|
|
mobile/play-service-account.json
|
||
|
|
```
|
||
|
|
5. Back in **Play Console → Users and permissions → Invite user**, paste the service
|
||
|
|
account's `client_email` (ends `.iam.gserviceaccount.com`). Grant it, for this app:
|
||
|
|
*Release to testing tracks* and *View app information*. Nothing more.
|
||
|
|
6. Confirm it works — opens an edit and abandons it, changing nothing:
|
||
|
|
```bash
|
||
|
|
cd mobile && node scripts/upload-play.js --dry-run
|
||
|
|
```
|
||
|
|
|
||
|
|
Pick a different track with `PLAY_TRACK=alpha ./release.sh`. Default is `internal`.
|
||
|
|
|
||
|
|
The JSON key can ship releases as you — it's gitignored, and belongs in your password manager
|
||
|
|
alongside the upload keystore.
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Useful commands
|
||
|
|
|
||
|
|
```bash
|
||
|
|
. mobile/scripts/env.sh # required before any Android command
|
||
|
|
|
||
|
|
cd mobile
|
||
|
|
npm run sync:web # rebuild www/ from ../server/public
|
||
|
|
npm run sync # …and push it into both native projects
|
||
|
|
npm run open:ios # open in Xcode
|
||
|
|
npm run open:android # open in Android Studio (if installed)
|
||
|
|
npm run assets # regenerate icons/splash from assets/*.png
|
||
|
|
|
||
|
|
cd mobile/android && ./gradlew :app:assembleDebug # debug APK, for a device or emulator
|
||
|
|
```
|
||
|
|
|
||
|
|
**If you regenerate assets**, re-apply the fix in
|
||
|
|
`android/app/src/main/res/mipmap-anydpi-v26/ic_launcher*.xml` — `@capacitor/assets` insets
|
||
|
|
both adaptive-icon layers by 16.7%, which stops the gradient background reaching the
|
||
|
|
launcher's mask. The files carry a comment explaining it.
|