- Indentation: 2 spaces
- Semicolons: Required
- Quotes: Single quotes for strings
- Naming: PascalCase for types/interfaces, camelCase for variables/functions
backend/— Node.js/Express backend for Docker-era API compatibilitybackend-swift/— production Swift/Vapor backend for personal jailbroken iPhone deploymentfrontend/— React SPA (TypeScript, Vite, Tailwind CSS)e2e/— Playwright E2E tests (pnpm)references/ApplePackage/— Swift reference implementation (source of truth)- Multi-stage Docker build (single container serves both)
┌─ Browser (Client) ─────────────────────────────────────┐
│ Account management UI │
│ Credentials in IndexedDB: email, password, cookies, │
│ passwordToken, DSID, deviceIdentifier, pod │
│ │
│ Calls backend-swift APIs: │
│ POST /api/apple/authenticate │
│ POST /api/apple/purchase │
│ POST /api/apple/versions │
│ POST /api/apple/version-metadata │
│ POST /api/downloads/apple │
└──────────────────────┬─────────────────────────────────┘
│ Authenticated local HTTP API
┌─ backend-swift ──────┴─────────────────────────────────┐
│ Apple protocol client (AsyncHTTPClient HTTP/1.1): │
│ 1. Bag fetch → resolve authenticateAccount URL │
│ with default auth endpoint when bag omits it │
│ 2. Authenticate → token, cookies, store front, pod │
│ 3. Purchase → acquire license │
│ 4. Download info → CDN URL + SINFs + metadata │
│ 5. Version listing/lookup │
│ │
│ IPA pipeline: │
│ - Download IPA from Apple CDN │
│ - Inject SINFs and iTunesMetadata.plist │
│ - Decrypt locally through unfaird │
│ - Serve completed IPA and install manifest │
└──────────────────────────────────────────────────────┘
Key invariant: backend-swift owns Apple protocol execution for personal self-hosted use. The browser manages account records and submits the selected account object to Swift APIs for Apple operations. Swift handles Apple TLS, cookie merging, pod routing, license acquisition, download info retrieval, IPA download, SINF injection, and local decryption in one process.
The Swift reference at references/ApplePackage/ is the source of truth for Apple protocol behavior:
- Field mappings (iTunes API → Software type) use Swift
CodingKeys - Authentication flow, bag endpoint, pod routing, error codes
- Always consult the reference when making protocol changes
The backend (backend/src/routes/search.ts) maps raw iTunes API fields to our Software type, matching the Swift CodingKeys in references/ApplePackage/Sources/ApplePackage/Models/Software.swift:
| iTunes Field | Software Field |
|---|---|
trackId |
id |
bundleId |
bundleID |
trackName |
name |
artworkUrl512 |
artworkUrl |
currentVersionReleaseDate |
releaseDate |
All other fields (version, price, artistName, sellerName, description, averageUserRating, userRatingCount, screenshotUrls, minimumOsVersion, fileSizeBytes, releaseNotes, formattedPrice, primaryGenreName) keep their original names.
The backend also extracts the results array from the iTunes wrapper { resultCount, results } before sending to the frontend.
Device identifiers are per-account, not global:
- Generated as 12 random hex chars (6 bytes) at account creation via
generateDeviceId() - Editable during login, immutable after authentication
- Stored in IndexedDB on the
Accountobject asdeviceIdentifier - Passed to all Apple protocol calls (auth, purchase, download, version listing)
After authentication, Apple returns a pod header:
- Store API:
p{pod}-buy.itunes.apple.com(default:p25-buy.itunes.apple.com) - Purchase API:
p{pod}-buy.itunes.apple.com(default:buy.itunes.apple.com) - Pod is stored on the Account object and used for all subsequent API calls
- Host selection lives in
backend-swift/Sources/UnfairDaemonCore/AppleProtocolService.swift
backend-swift/Sources/UnfairDaemonCore/AppleProtocolService.swift mirrors the necessary ApplePackage flows:
authenticatefetches the bag, sets theguidquery once, follows Apple auth redirects, detects 2FA, stores storefront and pod.purchasesendsbuyProduct, usesSTDQ, retries withGAMEfor failure2059, and returns updated cookies.downloadInfosendsvolumeStoreDownloadProduct, returns CDN URL, base64 SINFs, bundle versions, and binary iTunes metadata.listVersionsandversionMetadatause the same Apple download-product response shape as ApplePackage.- AsyncHTTPClient is configured for HTTP/1.1 and manual redirects for Apple protocol requests.
- Express backend for Docker-era API compatibility
- ESM modules (
"type": "module"in package.json) tsxfor development,tscfor production build- SINF injector also handles optional
iTunesMetadata.plistinjection at IPA root - Bag proxy for
init.itunes.apple.com
backend-swift/is the production jailbroken iPhone backend.- It embeds AssppWeb API routes, Apple protocol execution, static frontend serving, and unfaird IPA processing into one Swift/Vapor launchd service.
- Package ID is
wiki.qaq.unfaird; launchd label iswiki.qaq.unfaird; default port is8080. backend-swift/Package.swiftdepends on sibling../../unfairwhen built from this repository.- Root
make buildis the single production packaging entry: it builds the frontend, builds the Swift iOS backend, and emits the rootless deb. - Root
make installdepends onmake buildand installs the generated deb onDEVICE_HOSTor Theos device variables (THEOS_DEVICE_IP,THEOS_DEVICE_USER,THEOS_DEVICE_PORT).
backend/src/utils/route.ts— shared Express route helpers (getIdParam,requireAccountHash,verifyTaskOwnership)backend/src/config.ts— centralized constants (MAX_DOWNLOAD_SIZE,DOWNLOAD_TIMEOUT_MS,BAG_TIMEOUT_MS,BAG_MAX_BYTES,MIN_ACCOUNT_HASH_LENGTH) and env-var config (disableHttpsRedirectviaUNSAFE_DANGEROUSLY_DISABLE_HTTPS_REDIRECT)
- React 19, React Router 7, Zustand for state
- Tailwind CSS 4 for styling
- Vite for build tooling
- IndexedDB for credential storage (via
idb) frontend/src/api/apple.tswraps Swift Apple protocol APIs for auth, purchase, backend download creation, version listing, and version metadata.frontend/src/api/client.tspreserves structured API error payloads throughApiError.frontend/src/apple/config.tskeeps storefront constants and per-account device ID generation.
- Alert —
<Alert type="error|success|warning">for status messages (replaces inline alert divs) - Modal —
<Modal open={bool} onClose={fn} title={string}>for dialog overlays - Spinner — inline SVG loading spinner for buttons
- CountrySelect — optgroup-based country dropdown with "Available Regions" + "All Regions"
- AppIcon — 3 sizes (40/56/80px), rounded corners, letter fallback
- Badge — color-coded status pill
- ProgressBar — gray track, blue fill, percentage label
- icons — shared SVG icon components (
HomeIcon,AccountsIcon,SearchIcon,DownloadsIcon,SettingsIcon,SunIcon,MoonIcon,SystemIcon) used by Sidebar, MobileNav, and MobileHeader
utils/error.ts—getErrorMessage(e, fallback)for standardized catch-block error extractionutils/crypto.ts— AES-GCM encrypt/decrypt for account export/importutils/account.ts—accountHash(),accountStoreCountry(),firstAccountCountry()
- React / library imports (
useState,useNavigate,useTranslation) - Layout components (
PageContainer) - Common components (
AppIcon,Alert,Spinner,Modal,CountrySelect) - Sibling components within the same feature folder (e.g.,
DownloadIteminsideDownload/) - Hooks / stores (
useAccounts,useSettingsStore) - Apple API modules (
authenticate,purchaseApp,apiPost) - Utilities (
accountHash,getErrorMessage) - Config (
countryCodeMap,storeIdToCountry) - Types (
type Software)
Enforcement: Every PR must verify import ordering. Common mistakes:
- Putting hooks/stores before layout/common components
- Putting config before utilities
- Putting type imports in the middle instead of last
accountHash is a SHA-256 of the account email. It is treated as a local owner label for downloads and packages. Access control comes from the optional ACCESS_PASSWORD gate and the browser's selected account set.
- Apple API responses (bag XML, iTunes search results,
customerMessagefields) are treated as trusted content. No additional sanitization is applied beyond what React's text rendering provides (nodangerouslySetInnerHTML). - Apple CDN redirects during IPA download are trusted. The initial URL is validated against
*.apple.com, and redirect targets from Apple's CDN infrastructure (e.g., Akamai) are followed. The response body is saved to disk — it is never reflected back to the requester.
Credentials (passwords, passwordToken, cookies) are stored in IndexedDB for local account management. When the user authenticates, purchases, lists versions, or starts a download, the frontend sends the selected account object to backend-swift, and the Swift process performs the Apple network operation.
The settings endpoint (/api/settings) must never reflect request headers (x-forwarded-host, host, etc.) in its response body. Use server-side values only (config.*, process.uptime()).
- Early returns to reduce nesting
try/catchfor async operations- Express error middleware for centralized handling
- Type-safe error responses
2034/2042: Token expired — re-authentication requiredcustomerMessage === 'Your password has changed.': Password token invalidaction.urlending intermsPage: Terms acceptance required (throw with URL)
cd backend && npx vitest run # Node environment
cd frontend && npx vitest run # jsdom environment with fake-indexeddbcd e2e && pnpm test # Local (requires Docker on port 8080)
docker compose --profile test run --rm playwright # Docker-based
bash e2e/docker-test.sh # Full Docker build + test flowE2E tests import from ./fixtures instead of @playwright/test.
E2E tests cover:
- Add account flow (device ID field, randomize button, auth)
- Account detail (device ID, pod display)
- Settings page (no global device ID section)
- Search/lookup by bundle ID (verifies iTunes field mapping)
- Downloads API (backend Apple protocol execution, iTunesMetadata support, package lifecycle)
Test credentials are stored in environment variables (TEST_EMAIL, TEST_PASSWORD, TEST_DEVICE_ID, TEST_BUNDLE_ID) and must never be committed to the repository.
docker compose up --build -d # Builds and runs on port 8080Single container serves both the Express backend and the Vite-built React SPA. SPA routes are handled by serving index.html for all non-API paths.
The compose.yml includes a playwright service under the test profile:
docker compose --profile test run --rm playwrightThis runs Playwright inside the official mcr.microsoft.com/playwright image, connecting to the app container via Docker internal DNS (http://asspp:8080). The asspp service has a healthcheck so the test container waits until the app is ready.
The e2e/docker-test.sh script automates the Docker build and Playwright test flow.
Who: Developers and power users managing Apple app downloads outside the App Store — sideloading IPAs, managing multiple Apple IDs, tracking licenses. Technical audience, likely running this alongside terminals or Xcode.
Task: Authenticate Apple accounts → search apps → acquire licenses → download/compile IPAs → install.
Feel: A sharp utility. Precise like a package manager, clear like Apple's developer tools. Confident, quiet, functional. Not playful, not corporate.
- Primary accent:
blue-600/blue-700(hover) — trust + system authority, echoes Apple dev tooling - Backgrounds:
gray-50(app),white(cards/surfaces) - Text:
gray-900(primary),gray-600(secondary),gray-400(tertiary) - Borders:
gray-200(default),gray-300(hover) — use sparingly, prefer background tinting for containment - Status badges: Muted tones —
green(completed),blue(downloading),yellow(paused),purple(injecting),red(failed),gray(pending) - Alerts:
red-50/red-700(error),amber-50/amber-700(warning),green-50/green-700(success)
- System font stack (Inter / SF Pro fallback)
- Weight scale:
500(medium, workhorse),600(semibold, page titles and key labels only). Avoid700in body. - Size scale:
xs(12px),sm(14px),base(16px),lg(18px),xl(20px),2xl(24px)
- Base unit:
4px - Consistent vertical rhythm:
space-y-4within sections,space-y-6between sections - Page padding:
px-4 sm:px-6,py-6 - Container:
max-w-5xl(1024px)
- Single elevation: white cards on
gray-50background - No shadows. Borders only where they serve function (form inputs, dividers, interactive boundaries)
- Rounded corners:
rounded-lg(8px) for cards,rounded-md(6px) for inputs/buttons,rounded-fullfor badges - Prefer background tinting (
gray-50→gray-100) over borders for visual containment
- Desktop: fixed sidebar (240px /
w-60) + scrollable main content - Mobile: bottom tab bar with safe-area padding
- Breakpoint:
md:(768px) for sidebar ↔ bottom nav switch - Page structure:
PageContainerwith title + optional action button, then content
- Buttons: Primary (
bg-blue-600 text-white), Secondary (border border-gray-300 text-gray-700), Danger (text-red-600 border-red-300) - Inputs:
rounded-md border-gray-300 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 - Cards: White background,
border border-gray-200 rounded-lg, no shadow - Badge: Color-coded pill (
rounded-full px-2 py-0.5 text-xs font-medium) - ProgressBar: Gray track, blue fill, percentage label
- AppIcon: 3 sizes (40/56/80px), rounded corners, letter fallback
- Nav active state:
bg-blue-50 text-blue-700(sidebar),text-blue-600(mobile)
These rules prevent the codebase from becoming messy after merging PRs. Enforce them on every change.
Problem: transition-colors on static containers (cards, sections, alerts, badges) causes visible color flashing when the page loads in dark mode — the element briefly renders in light colors then transitions to dark.
Rule: Only use transition-colors on interactive elements that change color on user interaction:
- Buttons (hover state)
- Links (hover state)
- Form inputs and selects (focus state)
- Nav items (hover/active state)
Never use transition-colors on:
- Card containers (
bg-white dark:bg-gray-900 rounded-lg border ...) - Section wrappers (
<section>with background) - Alert/warning banners (use the
<Alert>component) - Badge pills
- ProgressBar tracks
- Modal containers
- AppIcon fallback containers
- Empty state placeholder containers
Exception: Layout chrome (Sidebar, MobileNav, MobileHeader, PageContainer) may keep transition-colors duration-200 for smooth theme toggle animation, since these persist across navigations.
All navigation and theme icons live in components/common/icons.tsx. When Sidebar, MobileNav, or MobileHeader need icons, import from there. Never duplicate icon SVG components inline.
Before merging any frontend PR, verify imports follow the convention in every changed file:
1. React / library imports
2. Layout components
3. Common components
4. Sibling components (same feature folder)
5. Hooks / stores
6. Apple protocol / API modules
7. Utilities
8. Config
9. Types (always last)
Empty states (shown when a list has no items) use a consistent pattern:
border-2 border-dashed(not solid border)bg-gray-50 dark:bg-gray-900/30background- No
transition-colors(removed to prevent dark mode flashing) - Centered icon in a white circle, title, description, optional CTA button
Always pair light and dark variants consistently:
- Primary text:
text-gray-900 dark:text-white - Secondary text:
text-gray-600 dark:text-gray-400ortext-gray-500 dark:text-gray-400 - Tertiary text:
text-gray-400 dark:text-gray-500 - Card background:
bg-white dark:bg-gray-900 - Page background:
bg-gray-50 dark:bg-gray-950 - Card border:
border-gray-200 dark:border-gray-800 - Input border:
border-gray-300 dark:border-gray-700
When the same UI pattern appears in 3+ components, extract it to components/common/. Current shared components:
Alert,Modal,Spinner,CountrySelect,AppIcon,Badge,ProgressBar,icons
When adding new common components, update this AGENTS.md file accordingly.
Problem: Plain <a href="/api/..."> tags and window.open("/api/...") make regular browser navigations that cannot carry custom HTTP headers. When ACCESS_PASSWORD is set, the accessAuth middleware requires an X-Access-Token header, so these requests fail with 401.
Rule: Never use <a href> or window.open for /api/ endpoints that require authentication. Instead, use fetch() with authHeaders() from api/client.ts, then trigger a download via blob URL:
const res = await fetch(url, { headers: authHeaders() });
const blob = await res.blob();
const blobUrl = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = blobUrl;
a.download = filename;
a.click();
URL.revokeObjectURL(blobUrl);Exceptions: Routes that the backend explicitly skips auth for (/auth/*, /install/*) may use plain links — e.g., itms-services:// install URLs are fine since /install/* is public.