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.
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-Managerrepo; it now lives inmanager/insideFTStuff/FTStore, alongsideios/(the app it installs),web/(the FTStore Admin server it talks to) andadmin/(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.ZsignandIDeviceKittenare submodules here, not vendored copies; clone with--recurse-submodules.
Brand / identity
- App name
FTStore Manager· bundle idapp.ftstore.manager(must differ fromapp.ftstoreso 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), somakeand 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 Adminrepo: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-devrelease with anFTStore*.ipaasset (version = tag);dev= thedev-tagged release, tracked by the asset'supdatedAt(the version string is constant). Compare logic lives inFTStore/Manager/FTStoreUpdateManager.swift. - Install: OTA only (itms-services via Vapor
ServerInstaller). The device-pairing method was removed.InstallPreviewViewalready 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.inas the fallback), becauseitms-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, becauseinstalldasks 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: ourURLSessionandinstallddo not share a path. ServerInstaller.initdoes not throw. It recordsstartupErrorinstead, andInstallPreviewViewreports it. The oldtry!at the one call site turned a certificate the phone would not load into a crash.
- 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
- 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 appendsFTStoreSeed/to the IPA's ZIP before saving it.ManagerSeedImporterreads 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 noFTStoreSeed/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. _supportedVersionhere andSEED_FORMAT_VERSIONinweb/src/lib/landing/ipa-seed.tsare 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.
- The once-only guard is a fingerprint of the key material in
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, itsmanager.admin.*defaults, the now-deadFTStore.developerModeflag). 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 showsManagerHomeViewdirectly — 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 underViews/. - Config:
FTStore.xcconfig(FTSTORE_PRODUCT_BUNDLE_IDENTIFIER,FTSTORE_ADMIN_BASE_URL) → injected intoInfo.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).FTStoreUpdateManagerreads installed state fromStorage.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 theManager ReleaseGitHub Action. - Static checks (CI/Linux ok):
plistlib/jsonparse, and grep the repo + built binary forghp_/ any token — there must be none (the proxy design guarantees it).
GPL-3.0
Keep LICENSE and Feather/FTStore attribution intact; keep source public.