# 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.