# 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.md` intentionally 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](https://github.com/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 in `AboutView.swift`, the > "Built on Feather" entry in `app-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.sh` so the > rebrand never mangles it — do not remove it (or `CLAUDE.md`) from that script's > exclusion list. - **Brand constants:** app name `FTStore` · bundle id `app.ftstore` · developer `FTStore Team` · website `ftstore.app` · repo `FTStuff/FTStore`. Every `developerName` / author field says `FTStore Team`, never a person and never a bare `FTStore`. The project is credited as a project, never as a person — there is no maintainer handle in the About screen, `app-repo.json` or 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 `ProxyTarget`s; 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 drops `Feather/`, `Feather.xcodeproj`, `Makefile`, … next to `ios/`. Step 0 of `apply-rebrand.sh` relocates them, folding into ours on collision. Never hand-move them. - On a merge conflict, **keep ours** in `.gitmodules` (`ios/Zsign`, not `Zsign`), `.gitignore` and `.github/workflows/`. - `web/`, `manager/` and `admin/` 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 their `app.ftstore.manager` / `app.ftstore.admin` identities 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.yml` | | `web-v*` | the FTStore Admin server | `web-release.yml` | | `manager-v*` | FTStore Manager | `stable-release.yml` | | `admin-v*` | the FTStore Admin app | `admin-release.yml` | | `dev` | the app's rolling prerelease | `ios-dev-repo.yml` | | `manager-dev` | the Manager's rolling prerelease | `manager-dev-repo.yml` | | `admin-dev` | the console app's rolling prerelease | `admin-dev-repo.yml` | The three `dev` tags are deliberately distinct: the app and the Manager were separate repos where both were called `dev`, 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 its `FTStore.xcconfig` version 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 whichever `app-repo.json` carries that bundle id, and commits the version bump back to the branch so the next dev build stays ahead of stable. Pushing a `v*` / `manager-v*` tag by hand no longer builds anything — the old `ios-release.yml` / `manager-release.yml` / `ios-update-repo.yml` were 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.yml` deletes every older `v*` (or `manager-v*`) release and its tag, keeping `KEEP_RELEASES` — currently `1`. Nothing else is reachable from it: the pattern is anchored and numeric, so `dev`, `manager-dev`, `admin-dev`, `web-v*` and `admin-v*` cannot match, and the job is gated on `needs: release`, so a failed build deletes nothing. - **`KEEP_RELEASES` also bounds the AltStore version history.** Each entry in a manifest's `versions` array 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_latest` keeps 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.yml` is unrelated and stays manual: it is the `web-v*` namespace, where the Docker images have to go too. - **The landing page is the one public surface, and it spans two products.** `web/` serves `ftstore.app` at `/`; its default flow takes a `.p12`, a `.mobileprovision` and the password, appends `FTStoreSeed/` to the Manager IPA's ZIP (which `manager/`'s `ManagerSeedImporter` imports at first launch), **signs the result**, and hands the visitor an `itms-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//…` and every `/api` route keep their existing gate, and only `/`, `/robots.txt` and `/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. So `POST /api/public/manager/install` now takes the certificate, signs with `zsign` built from `manager/Zsign` into the app image, and parks the result in `storage/install/.ipa` for 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//…`, and everything unknown answers 404; - the page must not claim the certificate stays on the device. It does not. - `web/Dockerfile` builds from the **repository root**, not `web/`, because it compiles `manager/Zsign`. `Dockerfile.worker` still builds from `web/`. - **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.swift` still 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 in `web/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.telegramBannerDismissed` in `UserDefaults`, 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//ftstore-admin-app` and `-worker`. The Compose project is pinned to `name: ftstore` so volumes stay `ftstore_*` 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._allAppsWithSource` rather 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, named `FTStore`; installs made before that rename are corrected in place by `Storage.addDefaultSourceIfNeeded`. - **Store tags are decided in one place.** `Official`, `Recommended` and the red `Paid` are built by `ASRepository.App.storeTags` (`ios/…/Views/Sources/Apps/StoreTagView.swift`) and drawn by `StoreTagsView`, 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: `Paid` is a warning, and giving it the promotional treatment would read as an endorsement. - **A paid tweak carries `paid` and `pricing` in `ftstore.json`.** `paid` is the tag and stands alone: a tweak can be known to cost money before anyone has written down what it costs. `pricing` holds `monthly` / `yearly` / `lifetime` plus an optional `currency` (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 when `paid` is 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.ts` and nowhere else. The `source_*` settings that used to hold them are gone, along with the "Source Metadata" card, `/api/branding` and the `useBranding` hook. 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_domain` survives in a "Server" card because it is deployment plumbing, not branding. - **IPA downloads always require FTStore's User-Agent.** `/d//.ipa` checks it unconditionally (`web/src/app/d/[token]/[name]/route.ts`). There used to be a `download_require_app_ua` setting 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_branch` do 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_token` **is** still live: it is the fallback the Manager proxy and the activity feed use when `ftstore_app_github_token` is 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/` ships `FTStoreAdmin/Resources/Localizable.xcstrings` (en + de) and iOS chooses. Most of the app needs no call-site change, because SwiftUI reads a *literal* in `Text`/`Button`/`Label`/`Section`/`.navigationTitle`/`.alert` as a `LocalizedStringKey`. `String.localized(…)` (`Support/AdminLocalization.swift`) is for the other half: a `String` built before it reached a view, which SwiftUI renders verbatim. `de` must stay in the project's `knownRegions` or the catalog is never compiled in. - `web/` resolves the locale from `Accept-Language` in the two `/admin` layouts and hands it down through `LocaleProvider`; `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 `addSource` flag on `PUT /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/feedback` and `AdminFeedbackView`. 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 `sourceId` | never | What follows from "public", and must keep holding: - **`serializeTicket` / `serializeComment` are the boundary** (`web/src/lib/feedback.ts`). The `"app"` audience must never emit `installId`, `clientVersion`, `systemVersion`, `deviceModel`, `authorUserId` or `operatorName`. 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.authorUserId` for the audit trail and stripped before the app sees it, and the team badge is drawn from `authorRole`, never from the display name. `RESERVED_NAME_KEYS` refuses the names that would imitate it. - **A status change is written into the thread**, as a `FeedbackComment` carrying `statusFrom`/`statusTo` and 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**: `hidden` on a ticket and on a comment (reversible, and its author still sees it marked as removed), `FeedbackAuthor.blocked` to 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. - **`FeedbackReaction` has no unique index and cannot have one**: `commentId` is nullable and Postgres treats NULLs in a unique index as distinct. `toggleReaction` dedupes in a transaction, and every count is `distinct installId`. - The app's install id is **the same one tweak requests use** (`FeedbackManager.installId` reads `TweakRequestManager.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/register` enforces it; the page's `GET` is 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 sync` and `"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.xcstrings` in `ios/` and `manager/` — 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-label`s, 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: ```sh 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 into `main` and push `main`.) - **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-By` trailer (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?"** ```sh 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 `--merge` only if the user asks for a wholesale catch-up. Either way, re-run `scripts/apply-rebrand.sh` afterwards 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/` (both `Feather*` and `FTStore*` spellings, plus `Makefile`, `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*` inside `ios/` (xcodeproj, xcworkspace, xcconfig, scheme, source dir, `FeatherApp.swift`, bridging header, entitlements, and the CoreData model `FTStore.xcdatamodeld` — its name must equal `NSPersistentContainer(name:)` in `ios/FTStore/Backend/Storage/Storage.swift`). - Re-points the workspace's `README.md`/`LICENSE` refs at `../`, since the workspace sits one level below them now. - Rewrites identifiers/URLs: `thewonderofyou.Feather`→`app.ftstore`, `claration|khcrysalis /Feather`→`FTStuff/FTStore`, then generic capitalized `Feather`→`FTStore`. - Renames the all-caps build variables **`FEATHER_*` → `FTSTORE_*`** — but only where it reaches, i.e. `ios/`. The same variables in `manager/` and `admin/`, and the `grep`/`sed` pair that reads and rewrites `FTSTORE_PROJECT_VERSION` in 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 through `agvtool new-marketing-version`:** every target sets `MARKETING_VERSION = $(FTSTORE_PROJECT_VERSION)` with `GENERATE_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:** bare `FEATHER` also appears in the GPL-3.0 modification notices atop `ServerInstaller*.swift` ("MODIFIED FOR USE FOR FEATHER"), which are licence attribution and must never be rewritten. - Leaves intentionally untouched: **the whole of `web/`, `manager/` and `admin/`** and `.github/workflows/{web,manager,admin}-*`, **image assets / icons** (referenced by imageset name, not filename), **`LICENSE`/`ios/LICENSE_ELLEKIT`**, **`README.md`**, **`CLAUDE.md`** and **`AGENTS.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`→`FTStore` rule: **`Feather Developer`** (About credits), **`Feather authors`** (the comment above them), and **`Feather, made approachable`** (the Manager tagline, which must stay identical to `app-repo.json`'s `localizedDescription`). The guard is line-local — never let one of those phrases wrap across a line break. - `app-repo.json` brand fields via `jq`; `README.md` is 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) ```sh 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. ```sh 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: 1. `web/src/lib/settings/backup.ts` — `LEGACY_SETTINGS_BACKUP_TYPES`, the type string in backups taken before the rename. 2. `manager/…/ManagerConfig.swift` — `manager.ftrepoBaseURL`, the pre-rename UserDefaults key, read once and migrated. 3. `ios/…/DownloadManager.swift` — the pre-rename host in the User-Agent allowlist. 4. `web/src/lib/github/activity.ts` — `BLOCKED_NAMES`, which is what keeps the name out of the public feed, and its fixtures in `activity.test.ts`. Deleting either publishes the thing it was meant to hide. 5. `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/`: ```sh 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.