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.
122 lines
7.9 KiB
Markdown
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.
|