The activity feed strips the author name and email, so the only route from a commit back to the person who wrote it is the prose. The rule says so now: write as the project rather than as a person, and keep location, timezone, working hours, hardware and personal accounts out of both halves of a message. It also covers which locales a string landed in. Naming one in a subject, or quoting a translated string, places the person writing it about as precisely as a location would, and that is the same leak through a different door. Localisation in the abstract points nowhere, every app has it, and a language picker or a count of catalogue entries is fine. The specific locale is the part that stays out. The catalogues themselves are untouched by this. They sit in a private repo and the feed never reads them; this is a rule about the commit log alone.
40 KiB
FTStore — project guide for AI agents
This is the canonical project guide for any AI coding agent working in this repo (Claude Code, Codex, etc.).
CLAUDE.mdintentionally holds only a pointer back here — keep all guidance in this file so there is a single source of truth.
FTStore is "Feather, but for normies": a hard fork of claration/Feather (Swift iOS/iPadOS on-device app signer/installer, GPL-3.0).
Hard fork means the code is ours. ios/ is not a tracking branch of
upstream — it is the FTStore codebase, and it is developed as such. Upstream is
still pinned in .upstream-sync.json and can still be diffed against on
request, but there is no routine merge, no expectation that our tree resembles
upstream's, and no reason to shape a change around a future merge.
This does not loosen GPL-3.0. A hard fork is a workflow decision, not a licensing one. Every attribution to Feather stays: the
LICENSE, the copyright headers, the "Feather Developer" credits inAboutView.swift, the "Built on Feather" entry inapp-repo.json, and the README acknowledgements. Do not strip any of them, and keep the source public.
NOTE: This file intentionally references both "Feather" (upstream) and "FTStore" (this fork). It is excluded from
scripts/apply-rebrand.shso the rebrand never mangles it — do not remove it (orCLAUDE.md) from that script's exclusion list.
- Brand constants: app name
FTStore· bundle idapp.ftstore· developerFTStore Team· websiteftstore.app· repoFTStuff/FTStore. EverydeveloperName/ author field saysFTStore Team, never a person and never a bareFTStore. The project is credited as a project, never as a person — there is no maintainer handle in the About screen,app-repo.jsonor the READMEs, and nothing should reintroduce one. - origin →
FTStuff/FTStore· upstream →claration/Feather. - Build target requires macOS + Xcode — it cannot be compiled on Linux. Validate locally (static checks); build via GitHub Actions / a Mac.
Monorepo layout — read this before touching paths
Four products live here. Only ios/ is a fork of Feather.
| Path | What it is | Upstream? |
|---|---|---|
ios/ |
The Swift app (app.ftstore). Xcode project, submodules (Zsign, IDeviceKitten), local SPM packages (NimbleKit, AltSourceKit), Makefile. |
Yes — merged from claration/Feather |
web/ |
FTStore Admin server — Next.js dashboard at /admin + the ftstore.json feed + IPA downloads + the public ftstore.app landing page at /. Was its own repo under a different name until the merge. |
No — ours alone |
manager/ |
FTStore Manager (app.ftstore.manager) — a stripped-down fork of this repo's app, whose only job is installing and updating FTStore (and, once an operator signs in, FTStore Admin) via the web/ proxy. One screen, no account. Was the separate FTStore-Manager repo. |
No — forked from ios/, not from Feather |
admin/ |
FTStore Admin (app.ftstore.admin) — the operator console for web/ as its own iOS app: the /admin dashboard on a phone, behind the server login. Split out of manager/ so a normal user never meets an operator surface. |
No — ours alone, and no Feather lineage |
scripts/ |
apply-rebrand.sh, sync-upstream.sh. Both act on ios/ only. |
— |
app-repo.json |
The published AltStore source manifest. Its one listing is FTStore Manager (pinned there by apply-rebrand.sh — the app itself is not listed), refreshed by stable-release.yml whenever the Manager ships. Stays at the repo root — the workflows and the rebrand script both expect it there. |
Yes |
Images/ |
Root README artwork. | Yes |
Both repos are private. The apps therefore do not fetch app-repo.json over
raw.githubusercontent.com; they go through the web/ proxy
(/api/ftstore-app/*, /api/ftstore-manager/*, /api/ftstore-admin/*), which
holds the GitHub PAT server-side. Do not "fix" an app by pointing it at a raw
GitHub URL. admin/ itself has no proxy: it talks to the same /api/* routes
the web dashboard uses, gated by the session rather than by a UA. Its AltStore
source lives on the admin-dev prerelease of this private repo, so
"published" there means "reachable by someone who already has access", which is
the whole distribution model for an operator console.
The three proxy prefixes are not equivalent, and the difference is the security model:
| Prefix | Serves | Gate |
|---|---|---|
/api/ftstore-app/* |
FTStore (v* / dev) |
UA only — public to the app |
/api/ftstore-manager/* |
FTStore Manager (manager-v* / manager-dev) |
UA only — public to the app |
/api/ftstore-admin/* |
FTStore Admin (admin-v* / admin-dev) |
UA plus an Authorization: Bearer token for an approved admin account |
web/src/lib/github/ftstore-app.ts is one resolver with three ProxyTargets;
the tag namespace is what separates them, never the asset name alone.
ftstore-admin is credential-gated on the server, and that is the whole
point. POST /api/ftstore-admin/unlock verifies a dashboard username and
password with the same scrypt check as the dashboard login, and mints a
sessions row; manifest and download each re-check that token
(web/src/lib/ftstore-admin-access.ts). Nothing secret is compiled into
manager/ — its only baked-in value is still the base URL, and the token lives
in the keychain (ManagerKeychain, device-only, out of backups). So decompiling
the Manager, flipping its unlock flag, or rebuilding it without
ManagerAdminAccess at all yields a UI that asks the server for the console and
is refused. Never move that check into the app, and never answer unlock with
anything but one message for every failure mode — a distinct "no such user"
would let anyone with the Manager enumerate operator accounts.
"FTStore Admin" always means both halves: web/ and admin/. They are one
product with two front ends over the same API, so a request to change FTStore
Admin is a request to change the dashboard and the console app. A feature
that lands in only one of them is half-built: the operator who uses the phone
can't reach it, or the one on a laptop can't. When you add an operator-facing
control, add it to web/src/components/… and to admin/FTStoreAdmin/Console/…
in the same change, and keep the wire types in step
(web/src/types/config.ts ↔ admin/…/AdminModels.swift). The one asymmetry
that is deliberate is presentation: the dashboard can afford a dense grid of
icon buttons, the console app uses a Form, and neither has to imitate the
other.
Three iOS apps, three audiences. ios/ is for everyone, manager/ is for
getting ios/ (and, for an operator, admin/) onto a device, admin/ is for
the person who runs the server. Never route an operator feature back into ios/
or manager/ — the reason admin/ exists is that a normal user should never be
able to reach one. Installing the console is the one exception, and it is only
an install: it sits behind the Manager's developer mode and the server's
credential check, and the console's own screens still live only in admin/.
Never bake a credential into admin/ either: its only compiled-in value is the
base URL, and the server session is the whole security model.
The Manager tracks a set of products, not one app. ManagedProduct
(manager/FTStore/Manager/ManagedProduct.swift) carries everything that differs
between FTStore and FTStore Admin — bundle id, manifest path, URL scheme,
whether the routes need the operator token, and the UserDefaults prefix for
its install stamps. FTStoreUpdateManager is instantiated once per product
(.shared for FTStore, .admin for the console) so the version/freshness rules
are written once. FTStore deliberately keeps the unprefixed defaults keys it has
always used, so an upgraded install does not forget which build it is on. Adding
a third product should be a new case, not a second copy of the manager.
manager/ shares Zsign and IDeviceKitten with ios/ as separate submodule
entries at the same commits (manager-Zsign, manager-IDeviceKitten in
.gitmodules). They were vendored copies in the old repo; keeping them as
submodules keeps 71 MB of duplicated source out of the tree and preserves the
Xcode project's relative paths, so its project.pbxproj needed no edits. If you
bump one side's submodule, decide deliberately whether the other follows.
Every product needs its own derived data, and the Makefiles enforce it.
manager/ was forked from ios/ rather than recreated, so both build a project
called FTStore.xcodeproj, with a scheme and a target called FTStore, out of
a project.pbxproj carrying the same GUIDs — and those GUIDs are the key
Xcode's build database uses. Only SRCROOT differs, which is not enough to tell
them apart. Both Makefiles therefore key TMP on the product directory
($(TMPDIR)/$(NAME)-$(notdir $(CURDIR))), never on $(NAME) alone; admin/
does the same for the sake of the next fork.
This is not hypothetical tidiness. While they shared $(TMPDIR)/FTStore, the
Manager build — which has no FTStoreActivity target — swept the app's Live
Activity extension as a stale file, and the app build found the Manager's object
files in its own intermediates. The shipped IPA ended up carrying a leftover
FTStoreActivity.appex that no longer matched the app, so the Dynamic Island
quietly rendered nothing. It only ever bit on the self-hosted Mac: the
GitHub-hosted runners handed every job a fresh VM, hence an empty $(TMPDIR)
and a clean build every time. Both release workflows now assert the extension is
inside the IPA, because a missing appex breaks nothing else and says nothing.
Consequences worth internalising:
-
Upstream's tree is rooted at the repo root, ours in
ios/. A merge dropsFeather/,Feather.xcodeproj,Makefile, … next toios/. Step 0 ofapply-rebrand.shrelocates them, folding into ours on collision. Never hand-move them. -
On a merge conflict, keep ours in
.gitmodules(ios/Zsign, notZsign),.gitignoreand.github/workflows/. -
web/,manager/andadmin/must never be touched by the rebrand. The script excludes^(web|manager|admin)/and.github/workflows/(web|manager|admin)-. For the two app directories this is load-bearing: the generic rule would rewrite theirapp.ftstore.manager/app.ftstore.adminidentities and mangle the Feather lineage in the Manager's docs. -
Release tags are namespaced, and nothing may reuse another's:
Tag Product Built by v*the app stable-release.ymlweb-v*the FTStore Admin server web-release.ymlmanager-v*FTStore Manager stable-release.ymladmin-v*the FTStore Admin app admin-release.ymldevthe app's rolling prerelease ios-dev-repo.ymlmanager-devthe Manager's rolling prerelease manager-dev-repo.ymladmin-devthe console app's rolling prerelease admin-dev-repo.ymlThe three
devtags are deliberately distinct: the app and the Manager were separate repos where both were calleddev, and sharing one here would make each build overwrite the others' release assets. -
Stable releases of the app and the Manager are dispatch-only. Run Actions → Stable Release, pick the product (
ftstore/manager/both) and a version; leaving a version empty bumps the patch of that product's last stable release, falling back to itsFTStore.xcconfigversion when it has never had one. The workflow stamps the version, builds, checks the IPA really reports it, publishes a published (never draft) release, refreshes whicheverapp-repo.jsoncarries that bundle id, and commits the version bump back to the branch so the next dev build stays ahead of stable. Pushing av*/manager-v*tag by hand no longer builds anything — the oldios-release.yml/manager-release.yml/ios-update-repo.ymlwere removed because they produced draft releases, and FTStore Admin's stable channel filters drafts out (web/src/lib/github/ftstore-app.ts), so nothing ever reached a device until someone pressed "Publish" by hand.admin-v*still works the old way — the console app is sideloaded, not served to users. -
Only the newest stable release of each product survives. The last job of
stable-release.ymldeletes every olderv*(ormanager-v*) release and its tag, keepingKEEP_RELEASES— currently1. Nothing else is reachable from it: the pattern is anchored and numeric, sodev,manager-dev,admin-dev,web-v*andadmin-v*cannot match, and the job is gated onneeds: release, so a failed build deletes nothing.KEEP_RELEASESalso bounds the AltStore version history. Each entry in a manifest'sversionsarray is a download URL into a release asset, so keeping more history than releases would publish dead links. The two are trimmed together in the same workflow — change one and change the other.- Updates do not depend on old releases surviving: the proxy resolves the
newest non-draft, non-prerelease release carrying a matching asset, and
make_latestkeeps the README's/releases/latest/download/…button on it. What deletion does cost is the ability to roll back to a previous build, and any download already in flight from a retired asset. web-delete-release.ymlis unrelated and stays manual: it is theweb-v*namespace, where the Docker images have to go too.
-
The landing page is the one public surface, and it spans two products.
web/servesftstore.appat/; its default flow takes a.p12, a.mobileprovisionand the password, appendsFTStoreSeed/to the Manager IPA's ZIP (whichmanager/'sManagerSeedImporterimports at first launch), signs the result, and hands the visitor anitms-services://URL so iOS installs it directly. The format version is a contract across the two (web/src/lib/landing/ipa-seed.ts↔ManagerSeedImporter.swift) — bump both or neither. Opening/did not loosen anything else —/admin, the feed,/d/<token>/…and every/apiroute keep their existing gate, and only/,/robots.txtand/api/public/*are reachable without one. -
The signing step is server-side, and that was a deliberate reversal. This guide used to say the certificate must never leave the browser and that there must be no endpoint accepting a
.p12. That rule made the page's output uninstallable: iOS verifies the code signature before an over-the-air install completes, so an IPA with a certificate merely inside it is refused at the sheet, and every visitor needed a second signing tool anyway. SoPOST /api/public/manager/installnow takes the certificate, signs withzsignbuilt frommanager/Zsigninto the app image, and parks the result instorage/install/<token>.ipafor the phone to collect. Ordering is load-bearing: the seed goes in before signing, because signing is what seals the bundle. What replaced the old rule, and what must hold:- the certificate exists only as files in a temp folder deleted in a
finally(web/src/lib/signing/zsign.ts), and the signed build — which embeds it — expires in minutes (SLOT_TTL_MS), swept on every write; - the 128-bit slot token is the only access control, exactly as for
/d/<token>/…, and everything unknown answers 404; - the page must not claim the certificate stays on the device. It does not.
web/Dockerfilebuilds from the repository root, notweb/, because it compilesmanager/Zsign.Dockerfile.workerstill builds fromweb/.
- the certificate exists only as files in a temp folder deleted in a
-
The server domain is
ftstore.app, hardcoded in all three apps (ios/…/ManagerRepoClient.swift,…/TweakRequestClient.swift,…/Storage+Sources.swift,ios/…/Utilities/FTStoreLinks.swift,manager/…/ManagerConfig.swift,admin/…/AdminConfig.swift).DownloadManager.swiftstill allowlists the pre-rename host — that check is permissive, not identifying, and dropping it would break installs whose stored override still names it. -
The public addresses users are sent to live in
FTStoreLinks— the website and the Telegram channel (https://t.me/ftstore_app), which the Settings banner, the About screen's Links section and its "FTStore Team" credit all read from.web/'s landing page keeps its own copy inweb/src/components/landing/footer.tsx; if the channel ever moves, both change. -
The Telegram banner in Settings is dismissed once and for all. Closing it sets
FTStore.telegramBannerDismissedinUserDefaults, which survives an app update, and nothing may clear it, expire it or key it to a version. It is an invitation, and re-showing it after every release would make it an advert. Only "Reset Settings", which drops the whole persistent domain, brings it back. -
Docker images:
ghcr.io/<owner>/ftstore-admin-appand-worker. The Compose project is pinned toname: ftstoreso volumes stayftstore_*regardless of the checkout directory. -
The Store tab never lists FTStore Manager. The Manager is what keeps FTStore updated, so it belongs in My Apps as a permanent row and nowhere else. The filter is one line in
SourceAppsTableRepresentableView._allAppsWithSourcerather than a rule about which source may list it, so it holds whichever repository the entry turns up in. The seeded default source is the FTStore feed, namedFTStore; installs made before that rename are corrected in place byStorage.addDefaultSourceIfNeeded. -
Store tags are decided in one place.
Official,Recommendedand the redPaidare built byASRepository.App.storeTags(ios/…/Views/Sources/Apps/StoreTagView.swift) and drawn byStoreTagsView, which both the list cell and the detail page use. Add a tag there, not at a call site, or it appears on one screen and not the other. Only Official and Recommended get the highlighted "featured" cell background:Paidis a warning, and giving it the promotional treatment would read as an endorsement. -
A paid tweak carries
paidandpricinginftstore.json.paidis the tag and stands alone: a tweak can be known to cost money before anyone has written down what it costs.pricingholdsmonthly/yearly/lifetimeplus an optionalcurrency(absent means USD), and only the tiers the operator filled in are published —normalizePricing(web/src/lib/pricing.ts) drops blanks and zeroes, and the store lists exactly what arrives. A missing tier is not a free tier; never render a placeholder row for one. Prices are dropped entirely whenpaidis cleared, so stale figures can't outlive the flag. -
The store's identity is a constant, not a setting. FTStore is the only thing this server publishes, so its name, subtitle and description live in
web/src/lib/brand.tsand nowhere else. Thesource_*settings that used to hold them are gone, along with the "Source Metadata" card,/api/brandingand theuseBrandinghook. The feed identifier is derived from the name (xyz.ftstore), so an operator who renamed the store orphaned every client that had already added it.site_domainsurvives in a "Server" card because it is deployment plumbing, not branding. -
IPA downloads always require FTStore's User-Agent.
/d/<token>/<name>.ipachecks it unconditionally (web/src/app/d/[token]/[name]/route.ts). There used to be adownload_require_app_uasetting in front of that check, and turning it off put every build on the open web behind nothing but the token in the URL. The setting, its switch in Settings → General and the console's "Feed & Storage" group are all gone. Because a copied link is therefore a dead link anywhere but the app,ios/has no "Copy Download URL" action either. -
github_owner/github_repo/github_branchdo not exist. The app repo is hardcoded in the Manager proxy and every scanned repository is configured per source under Channels & Sources, so those three settings were read by nothing.github_tokenis still live: it is the fallback the Manager proxy and the activity feed use whenftstore_app_github_tokenis blank (web/src/lib/github/ftstore-app.ts,web/src/lib/github/activity.ts). -
FTStore Admin is bilingual, and the operator's own machine picks. Both halves follow the system locale and neither offers a language switch: a console that disagrees with the rest of the device is a bug, and a stored preference would be one more thing to restore from a backup.
admin/shipsFTStoreAdmin/Resources/Localizable.xcstrings(en + de) and iOS chooses. Most of the app needs no call-site change, because SwiftUI reads a literal inText/Button/Label/Section/.navigationTitle/.alertas aLocalizedStringKey.String.localized(…)(Support/AdminLocalization.swift) is for the other half: aStringbuilt before it reached a view, which SwiftUI renders verbatim.demust stay in the project'sknownRegionsor the catalog is never compiled in.web/resolves the locale fromAccept-Languagein the two/adminlayouts and hands it down throughLocaleProvider;useT()translates. Resolving it on the client instead would render English on the server and German after hydration, i.e. a mismatch on every translated string.- Both dictionaries are keyed by the English source string. A key with no German entry renders as English, so adding a string is never a two-step change and forgetting a translation is a cosmetic gap, not a broken screen. Server-supplied text (an API error, a log line) is never translated.
- The public landing page at
/is not part of this and stays English.
-
Deciding a tweak request is approve or reject, and nothing else. Approving used to be able to create the scraper source in the same step, which meant one tap both answered the requester and started scraping a channel nobody had looked at. Adding a source is its own action in Channels & Sources. Do not reintroduce an
addSourceflag onPUT /api/tweak-requests. -
The feedback board is public, and that is the whole design. Settings → Feedback is a shared list of bug reports and feature requests that every FTStore user can read, reply to and react to, answered by operators from
/admin/feedbackandAdminFeedbackView. It is the second thing the app writes to, and it is not a second tweak-request queue:Tweak requests Feedback Who can read one only the install that sent it everyone Answered by approve / reject a conversation plus a status Decided in bulk, per source one ticket at a time Grouped yes, by sourceIdnever What follows from "public", and must keep holding:
serializeTicket/serializeCommentare the boundary (web/src/lib/feedback.ts). The"app"audience must never emitinstallId,clientVersion,systemVersion,deviceModel,authorUserIdoroperatorName. A bug reporter hands over their device model for triage, not to the board.- A team reply is authored by "FTStore Team", never by a person. The
operator account is stored on
FeedbackComment.authorUserIdfor the audit trail and stripped before the app sees it, and the team badge is drawn fromauthorRole, never from the display name.RESERVED_NAME_KEYSrefuses the names that would imitate it. - A status change is written into the thread, as a
FeedbackCommentcarryingstatusFrom/statusToand optionally a note. That is why there is no events table, and why "why was this declined?" is still answerable later. Do not turn the status into a silent column update. - Moderation has to stay reachable:
hiddenon a ticket and on a comment (reversible, and its author still sees it marked as removed),FeedbackAuthor.blockedto mute an install without deleting what it wrote. Deleting is admin-only, because it destroys other people's replies too. - The display name lives on
FeedbackAuthor, keyed by install, and nowhere else. That is what makes a rename retroactive. Never denormalise it onto a ticket or a comment. FeedbackReactionhas no unique index and cannot have one:commentIdis nullable and Postgres treats NULLs in a unique index as distinct.toggleReactiondedupes in a transaction, and every count isdistinct installId.- The app's install id is the same one tweak requests use
(
FeedbackManager.installIdreadsTweakRequestManager.installId). One phone is one person; a second mint site would become a second identity.
-
Registration can be closed (
registration_enabled, Settings → Users).POST /api/auth/registerenforces it; the page'sGETis cosmetic. The very first account is always allowed through, so a fresh install cannot lock itself out.
⚠ Commit messages on main are PUBLIC — subject and body
ftstore.app renders an activity feed built from this repository's commit log
(web/src/lib/github/activity.ts → web/src/components/landing/activity.tsx).
Both repositories are private, but the whole commit message of every commit on
the default branch is published on a page anyone can load — the subject in the
list, the body in the popup that opens when a row is clicked.
So, before writing a commit message:
- The whole message is release copy. Write it for a stranger. Do not name unreleased plans, customer or user identities, hostnames, internal paths, or anything about a security fix beyond what it fixes.
- A security fix goes in as
fix:with a neutral subject. "fix: reject malformed manifests" is fine; naming the bypass is an advisory published before the fix has reached anyone's device — and that applies to the body, which is where the temptation to explain the bypass properly lives. - The body is not a private place. It used to be discarded server-side and this rule used to say so. It is not any more. Anything that must not be published goes in neither half of the message.
- Nothing about the author. The feed strips the author name and email, so the only way anything personal reaches it is the prose. Write as the project, never as a person: no first person, no location, no timezone, no working hours, no employer, no hardware, no personal accounts or handles, and nothing that lets a reader place who writes this.
This includes the languages the app is translated into. A commit that says a string was translated into a specific language says roughly where the person writing it is, which is exactly what the rule above is for. Much of this app is translated into German and more will be, so this comes up constantly:
- Never name a target language in a subject or a body, and never quote a
translated string.
lang: add german strings, keep en/de in syncand"Install Duplicate" (de: "Duplikat installieren")are both leaks; the second publishes the language in the words themselves. - Write the mechanism, not the locale. "translate the remaining UI strings" and "every string the Manager shows now goes through the catalogue" say what landed without saying for whom. Localisation in the abstract points at no country; every app has it. A named locale points at one.
lang:as a commit type is fine and so is a language picker or a count of catalogue entries. It is the specific locale that must not appear.- The catalogue files themselves are not covered by this. They live in a private repo and are not published by the feed. This rule is about the commit log.
The feed also publishes how many lines each commit added and removed, next to the date. That is two integers about the size of a change: it names no file and quotes no line, and it cannot be run backwards into a diff.
What the feed strips, so nothing here is load-bearing on the filter: the author name and email, git trailers and any line carrying an email address, the SHA, file names and the diff itself. It also drops a commit outright when the subject looks like it carries a credential (token shapes, long opaque strings, the words secret / token / password / credential / api key / private key / .env), and drops the body alone when the body does. That is a backstop for accidents, not a licence — it cannot recognise a leak that reads like ordinary English.
The feed is also the only route that reads this repository's commit log. If you add another public endpoint that touches the repo, apply the same rule: publish a whitelist of fields, never a filtered dump.
Copy style: no em-dashes in anything a user reads
Do not use — (em-dash) in user-facing text. It reads as machine-written,
and the codebase now has none. This covers every string a person can end up
looking at:
Localizable.xcstringsinios/andmanager/— both the key (the English source string) and every translation. A rename means editing the.localized("…")call site and the catalog key together.- Swift string literals that reach the screen:
Text,Label, alert titles and messages,errorDescription, notification bodies,admin/'s hardcoded copy. web/UI copy, toasts,aria-labels, API error messages and log lines (the dashboard renders the log).app-repo.json(localizedDescription,subtitle,news[].caption) and the README.
Rewrite instead of substituting a different dash. A sentence with an em-dash almost always wants a full stop, a comma, or a colon:
| Instead of | Write |
|---|---|
The download failed — the server returned HTTP %@. |
The download failed. The server returned HTTP %@. |
Public channels only — private invite links can't be read. |
Public channels only, private invite links can't be read. |
IPA catalog — search, inspect, upload… |
IPA catalog: search, inspect, upload… |
Two things are not covered, and both are deliberate:
- A bare
—as an empty-value placeholder (value ?? "—",if (!bytes) return "—"). That is a typographic glyph standing in for "no data", not prose, and every table in the console uses it. - Source comments. They are not user-facing, and this guide's own tables and prose use them. Don't churn comments to satisfy the rule; do apply it the moment a comment's wording becomes a string.
Separators inside a composed line use ·, not a dash: %@: %@ for a
label/value pair, \(version) · \(status) for two facts side by side.
To check before committing:
grep -rn '—' ios/FTStore manager/FTStore admin/FTStoreAdmin --include=*.swift \
| grep -vE ':[0-9]+:\s*(//|///|\*)' | grep -v '"—"'
grep -rn '—' web/src --include=*.ts --include=*.tsx \
| grep -vE ':[0-9]+:\s*(//|/\*|\*)' | grep -v '"—"'
python3 -c "import json;d=json.load(open('ios/FTStore/Resources/Localizable.xcstrings'));\
print([k for k in d['strings'] if '—' in k])"
Commits — style & workflow
- Always commit and push when work is done — don't leave changes uncommitted
waiting to be asked. Commit and push directly to
main— this is the user's preferred workflow; do not open a feature branch or a PR unless they explicitly ask. (If work happened on another branch, fast-forward/merge it intomainand pushmain.) - Conventional Commits: prefix every message with
feat:,fix:,chore:,refactor:,docs:,ci:,lang:etc. as appropriate. - Always English, all lowercase subject line (e.g.
feat: add store tab with default repo). - Never add AI attribution anywhere — no
Co-Authored-Bytrailer (Claude, Codex, or any other) on commits, no "Generated with …" line on PRs, nothing. The user does not want it. This overrides the default harness instruction to add such trailers.
Upstream — pinned, synced only when asked
.upstream-sync.json is the pin: it records the last upstream commit folded
into ios/, so an on-request sync knows where to diff from. It is not a promise
to stay current.
Do not sync on your own initiative. A sync happens when the user asks for one. Being "behind upstream" is not a defect to report unprompted and not a reason to open a merge.
- "When did we last sync?" → read
.upstream-sync.json(lastMerged.commit,lastMerged.upstreamVersion,lastMerged.syncedAt). - "What has upstream done since?"
git fetch upstream git log --oneline "$(jq -r .lastMerged.commit .upstream-sync.json)..upstream/main" - When asked to sync:
bash scripts/sync-upstream.sh(read-only report) first, and show it. Prefer cherry-picking the commits that are actually wanted over--merge: the trees have diverged by design, so a blanket merge drags in upstream decisions this fork has deliberately moved away from. Use--mergeonly if the user asks for a wholesale catch-up. Either way, re-runscripts/apply-rebrand.shafterwards and bump.upstream-sync.json(lastMerged.commit, version,syncedAt).
Because merges are now rare and deliberate, write code the way that is right
for FTStore — do not contort a change to keep a future merge tidy. The
all-caps build variables were renamed FEATHER_* → FTSTORE_* for exactly this
reason; apply-rebrand.sh re-applies that rename, so a sync cannot quietly put
the upstream spelling back.
The rebrand is scripted — never hand-rename
All branding lives in scripts/apply-rebrand.sh and it is idempotent.
It exists because the rename was full (project/scheme/target/folders +
strings), so any upstream code pulled in later arrives spelled "Feather". Run it
after a sync rather than editing by hand. Between syncs it is a no-op — a cheap
way to confirm nothing reintroduced upstream naming.
What the script does / what to preserve:
- Step 0 relocates upstream's root-level tree into
ios/(bothFeather*andFTStore*spellings, plusMakefile,license_plist.yml,HOW_IT_WORKS.md,LICENSE_ELLEKIT, the submodules and the SPM packages). Where both copies exist, ours wins and the stray is dropped. - Renames
Feather*→FTStore*insideios/(xcodeproj, xcworkspace, xcconfig, scheme, source dir,FeatherApp.swift, bridging header, entitlements, and the CoreData modelFTStore.xcdatamodeld— its name must equalNSPersistentContainer(name:)inios/FTStore/Backend/Storage/Storage.swift). - Re-points the workspace's
README.md/LICENSErefs at../, since the workspace sits one level below them now. - Rewrites identifiers/URLs:
thewonderofyou.Feather→app.ftstore,claration|khcrysalis /Feather→FTStuff/FTStore, then generic capitalizedFeather→FTStore. - Renames the all-caps build variables
FEATHER_*→FTSTORE_*— but only where it reaches, i.e.ios/. The same variables inmanager/andadmin/, and thegrep/sedpair that reads and rewritesFTSTORE_PROJECT_VERSIONin all three dev-repo workflows, were renamed by hand and are outside the script's scope; they cannot drift, because none is ever merged from upstream. Those three workflows must keep stamping the version through the xcconfig variable, not throughagvtool new-marketing-version: every target setsMARKETING_VERSION = $(FTSTORE_PROJECT_VERSION)withGENERATE_INFOPLIST_FILE = YES, so Xcode regenerates the key agvtool wrote and the IPA ships the base version while the AltStore source advertises the dev one. The trailing underscore in that rule is load-bearing: bareFEATHERalso appears in the GPL-3.0 modification notices atopServerInstaller*.swift("MODIFIED FOR USE FOR FEATHER"), which are licence attribution and must never be rewritten. - Leaves intentionally untouched: the whole of
web/,manager/andadmin/and.github/workflows/{web,manager,admin}-*, image assets / icons (referenced by imageset name, not filename),LICENSE/ios/LICENSE_ELLEKIT,README.md,CLAUDE.mdandAGENTS.md(handled separately),.github/workflows/ios-dev-repo.yml(names both brands on purpose), submodule/package names (Zsign,IDeviceKitten,NimbleKit,AltSourceKit), and attribution handles (claration,khcrysalis, sponsor links, icon-author "Samara"). - Three phrases naming upstream on purpose are guarded by a negative lookahead
in the generic
Feather→FTStorerule:Feather Developer(About credits),Feather authors(the comment above them), andFeather, made approachable(the Manager tagline, which must stay identical toapp-repo.json'slocalizedDescription). The guard is line-local — never let one of those phrases wrap across a line break. app-repo.jsonbrand fields viajq;README.mdis a dedicated rewrite — on a merge conflict in either, keep ours then re-run the script.
GPL-3.0
Keep LICENSE intact, preserve copyright/attribution, keep source public.
Credit Feather (README "Acknowledgements" + an app-repo news entry).
Validation (Linux-local)
python3 -c "import json; json.load(open('app-repo.json'))" # valid JSON
python3 -c "import json; json.load(open('manager/app-repo.json'))"
python3 -c "import json; json.load(open('admin/app-repo.json'))"
python3 -c "import plistlib; plistlib.load(open('ios/FTStore/Resources/Info.plist','rb'))"
python3 -c "import plistlib; plistlib.load(open('manager/FTStore/Resources/Info.plist','rb'))"
python3 -c "import plistlib; plistlib.load(open('admin/FTStoreAdmin/Resources/Info.plist','rb'))"
python3 -c "import glob,yaml; [yaml.safe_load(open(f)) for f in glob.glob('.github/workflows/*.yml')]"
grep -rn "thewonderofyou.Feather" ios app-repo.json # expect: none
grep -n "Feather" ios/FTStore.xcodeproj/project.pbxproj # expect: none
bash scripts/apply-rebrand.sh && git diff --quiet # idempotent: no diff on 2nd run
Old-brand sweep. jq must be installed or apply-rebrand.sh silently skips
its whole app-repo.json block (it is guarded by command -v jq) and the
idempotency check above proves nothing about that file.
grep -rni "ftrepo" ios manager admin web .github scripts --exclude=package-lock.json
The old name is gone from prose everywhere. What is left is the set of places where it is a value somebody else already stored, plus the one document whose job is to name it. Those must survive; a hit anywhere else is a regression, and the fix is to reword the sentence, never to change a value:
web/src/lib/settings/backup.ts—LEGACY_SETTINGS_BACKUP_TYPES, the type string in backups taken before the rename.manager/…/ManagerConfig.swift—manager.ftrepoBaseURL, the pre-rename UserDefaults key, read once and migrated.ios/…/DownloadManager.swift— the pre-rename host in the User-Agent allowlist.web/src/lib/github/activity.ts—BLOCKED_NAMES, which is what keeps the name out of the public feed, and its fixtures inactivity.test.ts. Deleting either publishes the thing it was meant to hide.web/README.md— the "Migrating from FTRepo" section. It exists to tell an operator which Docker images, Postgres role and mount path to change, and every one of those is literally named after the old brand, so the section cannot do its job without saying it. Delete the section outright once nobody is left to migrate; do not hollow it out.
For web/:
cd web && npm ci && npx prisma generate && npx tsc --noEmit && npx eslint .
find src -name '*.test.ts' -exec npx tsx --test {} + # 144 tests
npm run build
Build verification is per product, all on macOS: stable-release.yml (or
ios-dev-repo.yml) / make in ios/ for the app, stable-release.yml (or
manager-dev-repo.yml) / make in manager/ for the Manager,
admin-dev-repo.yml / make in admin/ for the console app,
web-docker-build.yml for the server. stable-release.yml takes a dry_run
input that builds and verifies without tagging or publishing anything.
admin/ links no Swift package at all — only system frameworks. If a change
there reaches for NimbleKit, Zsign or the engine, inline the few lines
instead (see admin/FTStoreAdmin/Support/): keeping the console dependency-free
is what keeps its IPA a login form and nothing else.