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

7.9 KiB

FTStore Manager — project guide for AI agents

FTStore Manager is a stripped-down fork of FTStore (itself a fork of 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 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/ — 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.