Files
Felitendo e40b2e69d2 docs: put the project guide where an agent already looks
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.
2026-09-01 08:42:03 +02:00

122 lines
7.9 KiB
Markdown

# FTStore Manager — project guide for AI agents
FTStore Manager is a **stripped-down fork of [FTStore](https://github.com/FTStuff/FTStore)**
(itself a fork of [Feather](https://github.com/claration/Feather), GPL-3.0). Its only job:
**install and update FTStore** from a **private** FTStore Admin, after the user imports a signing
certificate.
> **Monorepo.** This used to be the separate `FTStuff/FTStore-Manager` repo; it now
> lives in `manager/` inside `FTStuff/FTStore`, alongside `ios/` (the app it
> installs), `web/` (the FTStore Admin server it talks to) and `admin/` (that
> server's console app). Read the root [CLAUDE.md](../CLAUDE.md) first — it owns
> the tag namespaces (`manager-v*`, `manager-dev`) and the rule that this
> directory never carries the upstream brand. `Zsign` and `IDeviceKitten` are
> submodules here, not vendored copies; clone with `--recurse-submodules`.
## Brand / identity
- App name `FTStore Manager` · bundle id **`app.ftstore.manager`** (must differ from
`app.ftstore` so installing FTStore is never a self-install → OTA works without pairing).
- Internal Xcode target/scheme/product name is still `FTStore` (only the display name and
bundle id changed), so `make` and the project file work unchanged.
## How it works (the important part)
- **The GitHub PAT is never in the app.** Downloads go through the **FTStore Admin proxy**
(`FTStore Admin` repo: `src/app/api/ftstore-app/manifest` + `/download`), which holds the token
server-side. The app only knows the FTStore Admin base URL.
- **Channels:** `stable` = newest non-`dev` release with an `FTStore*.ipa` asset (version =
tag); `dev` = the `dev`-tagged release, tracked by the asset's `updatedAt` (the version
string is constant). Compare logic lives in `FTStore/Manager/FTStoreUpdateManager.swift`.
- **Install:** OTA only (itms-services via Vapor `ServerInstaller`). The device-pairing
method was removed. `InstallPreviewView` already uses OTA for a non-self-install.
- **Two methods, and they are the same two FTStore has**, under the same names in
Settings: **Fully Local** serves both the manifest and the payload off this
device, over TLS for a name under the install certificate's own domain;
**Semi Local** serves the payload over plain http on the LAN and gets the
manifest from `ManifestService` (FTStore Admin first, `api.palera.in` as the
fallback), because `itms-services://` will not take a manifest from a phone.
- **A Fully Local install that cannot resolve its own hostname is stopped before
it starts.** `ServerInstaller.selfCheck()` splits the question in two: a name
that resolves to nothing is fatal, because `installd` asks the same resolver
and would sit on "Ready" forever, and the alert offers Semi Local instead. A
name that resolves but does not answer our probe is **not** fatal, and must
not become so: our `URLSession` and `installd` do not share a path.
- `ServerInstaller.init` does not throw. It records `startupError` instead, and
`InstallPreviewView` reports it. The old `try!` at the one call site turned a
certificate the phone would not load into a crash.
- **A build can arrive with its certificate already in it.** ftstore.app's landing page
lets someone hand over a `.p12` + `.mobileprovision` + password *in their browser*, and
appends `FTStoreSeed/` to the IPA's ZIP before saving it. `ManagerSeedImporter` reads that
folder once at launch and imports the certificate as default, so a first install has no
setup step at all. A plain IPA has no `FTStoreSeed/` and the importer no-ops.
- The once-only guard is a **fingerprint of the key material** in `UserDefaults`, not a
bool: the bundle is read-only so the seed cannot be consumed, and reinstalling with a
*different* certificate must still seed. It is only recorded once the import actually
reached the database, so a failure retries next launch.
- `_supportedVersion` here and `SEED_FORMAT_VERSION` in
`web/src/lib/landing/ipa-seed.ts` are one contract — bump both or neither.
- The seed holds the password in the clear. That is deliberate and the website says so;
do not "fix" it with obfuscation, which would only make the honesty harder.
## No operator surface lives here
The `web/` dashboard used to ride along as a second tab (`FTStore/Admin/`), hidden behind
a developer-mode switch. It is its own app now — [`admin/`](../admin) — and it must stay
there:
- **This app is for normal users.** One screen, no account, no tabs. Anything that needs a
dashboard login belongs in `admin/`, never behind a hidden gate here.
- The install path must stay account-free — `/api/ftstore-app/*` is UA-gated only, on
purpose, so a fresh device can always get FTStore onto it.
- `ManagerLegacyConsole.purge()` runs at launch and wipes what the old in-app console left
behind (its Keychain session, its `manager.admin.*` defaults, the now-dead
`FTStore.developerMode` flag). Keep it: an install that updated across the split is
otherwise still holding a live server session nothing can reach.
## The Dynamic Island
`FTStoreActivity/` is a Live Activity widget extension, added to
`FTStore.xcodeproj` by hand. It follows an install through download → sign →
install and is the app's island with the pause button removed: this
`DownloadManager` has no pause, so there is no `InstallActivityIntents.swift`
and no `isPaused` in `ContentState`. Read `FTStoreActivity/README.md` before
touching it — two things there are easy to break and invisible when broken:
`InstallActivityAttributes.swift` must stay a member of *both* targets, and the
extension's bundle id must stay under the app's.
**The island ends when the installer answers, not when the install sheet
closes.** Tapping *Open* launches what was just installed and leaves the sheet
standing, so ending on dismissal left it saying "Installing" until somebody came
back to close a receipt they were finished with. Dismissal still ends it, which
is what covers a flow that never gets an answer. The app made the same mistake
and carries the same pair of endings.
## Where things live
- New Manager code: `FTStore/Manager/*` + `FTStore/FTStoreManagerApp.swift` (the `@main`,
which shows `ManagerHomeView` directly — there is no root/tab container).
- Live Activity: `FTStoreActivity/*` (the widget target) +
`Backend/Observable/{LiveActivityManager,InstallActivityAttributes}.swift`.
- Reused engine (unchanged): `Backend/Storage`, `Backend/Server`, `Backend/Observable`
(`DownloadManager`, `AppInstallManager`, `OptionsManager`, `UpdateManager` [inert]),
`Utilities/Handlers` (sign/archive/cert/zsign/tweak),
`Utilities/{FR,CertificateReader,MachO,ARDecompression}`, `Extensions`, and the
certificate/install/signing-setup views under `Views/`.
- Config: `FTStore.xcconfig` (`FTSTORE_PRODUCT_BUNDLE_IDENTIFIER`, `FTSTORE_ADMIN_BASE_URL`)
→ injected into `Info.plist` (`FTStoreAdminBaseURL`). **Never commit a real PAT.**
## Integration seams (when changing the engine)
- `AppInstallManager.install(InstallRequest)` is the Manager's entry into the existing
download → cert-gate → sign → install pipeline (reuses the private `_startStoreDownload`).
- `FTStoreUpdateManager` reads installed state from `Storage.newestSignedApp(identifier: "app.ftstore")`.
## Dependencies
Vendored (no submodules): `Zsign`, `NimbleKit`, `AltSourceKit`, `IDeviceKitten`. Remote
SPM: `Vapor`, `Zip`, `SWCompression`, `Nuke`, `LicensePlist`. `AltSourceKit`/`IDeviceKitten`/
`LicensePlist` stay linked because the engine still references a few helpers from them; they
can be pruned in Xcode if desired.
## Build / validate
- macOS + Xcode only. `make` → `packages/FTStore-Manager.ipa`, or the `Manager Release` GitHub Action.
- Static checks (CI/Linux ok): `plistlib`/`json` parse, and **grep the repo + built binary
for `ghp_` / any token — there must be none** (the proxy design guarantees it).
## GPL-3.0
Keep `LICENSE` and Feather/FTStore attribution intact; keep source public.