feat(app): iOS-skallen — Capacitor, Xcode-projektet og et CI-job, der bygger den

Femte trin mod iOS-appen. Fladen fra trin 3 pakket ind i en rigtig app.

- `app/`: Capacitor 8 med SPM (ingen CocoaPods). `npm run sync` bygger web
  med VERDANDE_TARGET=app og kopierer det ind i Xcode-projektet; `npm run
  open` åbner det på en Mac. Bundle-id `dk.nolimit.verdande`.
- `app/ios/` er committet. Webbygget og den genererede konfiguration i det er
  ikke — sync lægger dem der.
- `NSAllowsLocalNetworking`: en hjemmeserver uden certifikat kan nås med http
  på det lokale net; alt på internettet skal stadig være https.
- Ikon og opstartsbillede tegnes fra web/static/icon.svg
  (scripts/generate-ios-assets.js). Kvadratisk og uden gennemsigtighed, fordi
  iOS runder hjørnerne selv.
- CI-jobbet "iOS app shell" bygger og synker på Linux og tjekker, at
  app-bygget ikke registrerer en service worker. Ingen Xcode i CI; det dækker
  alt op til den.
- docs/ios.md: hvordan appen adskiller sig fra en browser, hvordan den bygges,
  hvad der er ejerens skridt (Apple-konto, signering, TestFlight), og hvad der
  mangler — notifikationer via APNs venter på en beslutning om en relay.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
kristianwind
2026-10-23 14:21:44 +02:00
co-authored by Claude Opus 5.5
parent b9028e8f7a
commit ce1e12417b
27 changed files with 2121 additions and 0 deletions
+88
View File
@@ -0,0 +1,88 @@
# The iOS app
The iOS app is the web interface bundled into a native shell with
[Capacitor](https://capacitorjs.com). It is not a second interface: the same
Svelte build runs in both, and everything that differs between a browser and the
app lives in `web/src/lib/platform.js`.
It talks to **any** Verdande server. The first screen asks for the address, checks
it with `/api/v1/ping`, and then signs in there.
## How the app differs from a browser
| | Browser | App |
|---|---|---|
| Where the interface comes from | the server | inside the app, `capacitor://localhost` |
| Which server | the one it was loaded from | the one its owner typed in |
| Session | httpOnly cookie | a session token, sent as `Authorization: Bearer` |
| Live sync | WebSocket with the cookie | WebSocket opened with a one-use ticket (`POST /ws-ticket`) |
| Pictures and downloads | plain `<img>` and links | fetched with the token and shown from a blob (`nativemedia.js`) |
| Service worker, Web Push | yes | no — WKWebView has neither |
| Passkeys | yes | not yet — a passkey is bound to the server's domain |
| Creating the first account | yes | no — done in a browser, then sign in from the app |
The server side of this is `client: "app"` on `/auth/login`, the
`capacitor://localhost` CORS rule, and the ticket. All three are in
`docs/openapi.yaml`.
An app session slides: every use moves its end forward, up to
`VERDANDE_APP_SESSION_TTL` (180 days) unused. It is listed under *Enheder* in
settings with the name the app gave itself, and can be signed out from there.
## Building
Everything up to Xcode runs anywhere, and runs in CI (`iOS app shell`):
```sh
cd app
npm ci
npm run sync # builds web with VERDANDE_TARGET=app, copies it into ios/
```
On a Mac with Xcode:
```sh
npm run open # opens ios/App/App.xcodeproj
```
Choose a simulator and press Run. In the app, type the address of a Verdande —
`http://localhost:8080` reaches a dev server on the same Mac from the simulator.
Plain `http` is allowed to **local** addresses only (`NSAllowsLocalNetworking` in
`Info.plist`), so a home server without a certificate works on the same network;
anything on the internet has to be `https`.
`app/ios/` is committed. The web build inside it (`App/App/public`) and the
generated config are not — `npm run sync` puts them there.
The icon and launch image come from `web/static/icon.svg`:
```sh
cd web && node scripts/generate-ios-assets.js
```
## Putting it on a phone
These steps are the account owner's — they involve Apple's terms, a payment and
signing keys.
1. **Apple Developer Program** membership (99 USD a year) on the Apple ID that
should own the app.
2. In Xcode → *Signing & Capabilities*: choose that team. The bundle id is
`dk.nolimit.verdande`; it can change until the first upload and never after.
3. *Product → Archive*, then *Distribute App → TestFlight*.
4. In App Store Connect, add testers to the TestFlight build.
## Not yet
- **Notifications.** Web Push does not exist in the app. Notifications on an
iPhone go through Apple's APNs, and the key that sends them is bound to the
app's bundle id — so it cannot live on every self-hosted server. That means a
small relay run by the app's publisher, which every instance with an app user
would talk to. That is a new connection from other people's servers, and it
waits on a decision before it is built.
- **The session token in the Keychain.** It is in the WebView's own storage for
now, behind one object in `platform.js` so the Keychain plugin can replace it
without anything else changing.
- **Passkeys** in the app.
- **Share sheet** (share text or a link *into* Verdande from another app).