The guide has always been the first thing to read here and the last thing that actually gets read. It lived in AGENTS.md, with CLAUDE.md holding a single line pointing at it, and a pointer is a step somebody has to decide to take. What is skipped along with it are the rules that cannot be derived from the code: finished work is committed and pushed to main rather than offered, a commit message on the default branch is published on a page anyone can load, and the two Live Activity extensions must never share a derived data path. So the guide is CLAUDE.md now, which loads by itself, and AGENTS.md is the pointer for whoever looks for that name instead. The text is unchanged apart from the paragraph that described the old arrangement. Nothing is duplicated: a second copy drifts from the first, which is the whole reason the pointer existed. The two nested guides move the same way, and every link, README line, source comment and hook message naming the old path follows. One of those links was also aimed at an anchor that had been renamed out from under it.
106 lines
6.8 KiB
Markdown
106 lines
6.8 KiB
Markdown
# FTStore Admin — project guide for AI agents
|
|
|
|
FTStore Admin is the [`web/`](../web) dashboard as an iOS app: the operator app for
|
|
the FTStore Admin server, behind the same login as the web UI.
|
|
|
|
> **Monorepo.** Read the root [CLAUDE.md](../CLAUDE.md) first — it owns the tag namespace
|
|
> (`admin-v*`) and the rule that this directory never carries the upstream
|
|
> brand. This app was split out of [`manager/`](../manager) (where it was a hidden
|
|
> second tab); the git history of `App/*.swift` runs back through
|
|
> `manager/FTStore/Admin/` — it was `Console/*.swift` in between, so a
|
|
> `git log --follow` across that stretch needs `--find-renames`.
|
|
|
|
## Brand / identity
|
|
- App name `FTStore Admin` · bundle id **`app.ftstore.admin`** · target/scheme/product
|
|
`FTStoreAdmin`.
|
|
- The *server* in `web/` is also called FTStore Admin. In prose, say "the FTStore Admin
|
|
server" for `web/` and "the FTStore Admin app" for this one when both are in play.
|
|
|
|
## The rules that hold it together
|
|
|
|
- **Nothing secret ships in the app.** The only baked-in value is the base URL. Never add
|
|
a token, key or privileged path — the whole point is that a decrypted IPA is a login
|
|
form and nothing else.
|
|
- **No operator surface leaks back.** This app exists so `ios/` and `manager/` never need
|
|
one. Don't add a dashboard feature to either of them, not even behind a hidden gate;
|
|
that gate is what was removed here.
|
|
- **No Swift packages.** The target links system frameworks only. If something needs a
|
|
helper from `NimbleKit` or the FTStore engine, inline it in `Support/` with a note on
|
|
where it came from (that is what `AdminUIKit.swift` and `AnyCodable.swift` are). Adding
|
|
a package dependency is a decision, not a convenience.
|
|
- **`AdminSession` is the single door.** Every screen calls `session.get/send/act`; that is
|
|
the one place that attaches the token and the one place that reacts to a 401 (wipe the
|
|
local session, drop back to the login form). Don't call `AdminAPI` directly from a view.
|
|
- **The token is Keychain-only** (`AdminKeychain`, service `app.ftstore.admin`,
|
|
`AfterFirstUnlockThisDeviceOnly`) and the app's `URLSession` is ephemeral with
|
|
`httpShouldSetCookies = false`, so it is never written to the container. The password is
|
|
used for one login request and dropped.
|
|
- **Server-side auth is the real gate.** `withAuth()`/`withAdmin()` on every non-public
|
|
route is what protects the data; hiding the Users link from non-admins is presentation.
|
|
If you add a `web/` route this app calls, wrap it — middleware only checks that a cookie
|
|
*exists*, not that it is valid.
|
|
- `AdminModels.swift` mirrors the handlers' responses and holds every request body in
|
|
`AdminRequest`; keep the two in step when a route changes shape.
|
|
- Two endpoints don't take JSON — the chunked IPA upload and the tweak icon. They go
|
|
through `AdminAPI.upload` (multipart); the settings backup goes through
|
|
`AdminAPI.rawData`, because it is a document that has to round-trip byte-for-byte rather
|
|
than a model to decode and re-encode.
|
|
- **English and German, chosen by iOS.** `Resources/Localizable.xcstrings` holds both,
|
|
keyed by the English source string, and `de` is in the project's `knownRegions`. A
|
|
literal passed to `Text`/`Button`/`Label`/`Section`/`.navigationTitle`/`.alert` is a
|
|
`LocalizedStringKey` and looks itself up; a `String` built before it reaches a view does
|
|
not, so that is what `String.localized(…)` in `Support/AdminLocalization.swift` is for.
|
|
The shared components in `AdminComponents.swift` localize their own `String` labels,
|
|
which is why call sites keep passing plain English. A missing German entry renders the
|
|
English key. Never translate text the server sent.
|
|
- **The `.ipa` file importer needs the type declared.** `Info.plist` imports
|
|
`app.ftstore.ipa` (the identifier `ios/` exports) because `UTType(filenameExtension:)`
|
|
answers with a dynamic `dyn.…` type when nothing on the device has claimed the
|
|
extension, and the document browser then greys out every IPA. `UTType.ipaImportTypes`
|
|
widens the picker further (`.zip`, `.data`) for files tagged some other way, and
|
|
`AdminUploadView._pick` is what enforces `.ipa`.
|
|
- The IPA upload must keep posting **8 MiB chunks in order** (`UPLOAD_CHUNK_SIZE` in
|
|
`web/src/lib/pipeline/upload-staging.ts`) with a lowercase-UUID `uploadId` — the server
|
|
validates that id against a strict pattern before it becomes a file path. Chunk reads
|
|
run on a detached task so an 8 MiB read can't stutter the progress bar.
|
|
- A Library rename targets a *variant group*, not a build: send `groupKey` + the item's own
|
|
`renameScope` to `/api/library/apply`, never an edit to the entry row.
|
|
- Sheets get `.environmentObject(session)` explicitly — a missing `@EnvironmentObject` is a
|
|
crash, not a nil.
|
|
- The User-Agent (`FTStore-Admin/…`) deliberately does **not** match `web/`'s `APP_UA`
|
|
regex. FTStore Admin only calls session-authenticated routes, so it never needs the feed's
|
|
UA gate — and keeping it outside means nobody widens that gate to make this app work.
|
|
|
|
## Where things live
|
|
- `FTStoreAdmin/FTStoreAdminApp.swift` — the `@main`; owns the session and the re-lock on
|
|
backgrounding.
|
|
- `FTStoreAdmin/App/*` — transport, session, models, one view per dashboard screen.
|
|
Named for what it is against `Support/`: this app's own code, not helpers
|
|
inlined from elsewhere. It was `Console/*` until the operator app stopped
|
|
being called a console.
|
|
- `FTStoreAdmin/Support/*` — base URL/User-Agent, brand colour, the inlined helpers.
|
|
- `FTStoreAdmin.xcconfig` (`FTSTORE_PRODUCT_BUNDLE_IDENTIFIER`, `FTSTORE_ADMIN_BASE_URL`)
|
|
→ injected into `Info.plist` (`FTStoreAdminBaseURL`). **Never commit a real credential.**
|
|
- `app-repo.json` — the AltStore source template the dev workflow rewrites with `jq`.
|
|
Its `version`/`downloadURL` are placeholders for a stable `admin-v*` release; the dev
|
|
build overwrites every one of those fields.
|
|
|
|
## Distribution
|
|
Same shape as the app and the Manager: `admin-dev-repo.yml` builds every push touching
|
|
`admin/` and publishes the IPA plus a generated AltStore source
|
|
(`admin-devrepo.json`, from `admin/app-repo.json`) to the rolling **`admin-dev`**
|
|
prerelease. `admin-release.yml` cuts a draft release from an `admin-v*` tag when a
|
|
stable one is wanted.
|
|
|
|
The repo is private, so that source is reachable only by someone who already has access
|
|
— which is the point. There is still **no `web/` proxy and no in-app updater** for this
|
|
app: it talks to the session-gated `/api/*` routes, never to `/api/ftstore-*`.
|
|
|
|
## Build / validate
|
|
- macOS + Xcode only. `make` → `packages/FTStore-Admin.ipa`.
|
|
- Static checks (CI/Linux ok): `plistlib` parse of `FTStoreAdmin/Resources/Info.plist`, and
|
|
**grep this directory for `ghp_` / any credential — there must be none.**
|
|
|
|
## GPL-3.0
|
|
Keep the root `LICENSE` and the attributions intact; keep source public.
|