Open-source audiobook player for iOS and watchOS. Swift, hybrid UIKit-Coordinators + SwiftUI (MVVM).
Companion to the Android app; both share the BookPlayer backend (auth, per-user cloud sync, subscriptions).
Handles offline audio playback, background/lock-screen playback, cloud sync, auth, and paid entitlements —
so the highest-severity defects are in memory/concurrency, player & AVAudioSession lifecycle, thread-correct
persistence, and auth/entitlement handling. Default branch: develop.
This file is the architecture & conventions ground truth. It is read by human reviewers and by the automated PR reviewer (
.github/workflows/claude-review.yml, which loads.github/claude/review-guide.mdas its rubric and this file as the codebase reference). Keep it accurate; a stale claim here becomes a bad review. When you change an invariant described here, update this file in the same PR.
The on-disk tree is deeply misleading. Before judging where code should live, know this:
- Main app source lives under the nested
BookPlayer/BookPlayer/directory:Player/,Settings/,Profile/,Library/,Search/,Import/,Loading/,SecondOnboarding/,Coordinators/,Services/,Utils/,AppIntents/, and the media-server integrationsJellyfin/,AudiobookShelf/,Hardcover/. - The
BookPlayerKitframework compiles the top-levelShared/folder. TheBookPlayerKit/directory itself only holdsBookPlayerKit.h+Info.plist. Everything cross-target lives inShared/and ispublic. Shared/is compiled into BOTHBookPlayerKit(iOS) andBookPlayerWatchKit(watchOS). Every file inShared/therefore has twoPBXBuildFileentries — one per framework. Adding aShared/file to only one target breaks the watch build.- The top-level
Player/,Services/,Coordinators/,Library/folders are EMPTY STUBS — ignore them. Never add files there. Real code is underBookPlayer/BookPlayer/…orShared/. - Dead code to ignore:
BookPlayer/RootViewController.swiftreferencesBaseViewController/BaseViewModel, which do not exist in the repo — it is orphaned, not part of the live SwiftUI flow.
Nine native targets in BookPlayer.xcodeproj (no Package.swift, no app .xcworkspace):
| Target | Product / type | Role | Source |
|---|---|---|---|
BookPlayer |
"Audiobook Player" · app | iOS app | BookPlayer/BookPlayer/… |
BookPlayerKit |
framework (iOS) | Shared framework | top-level Shared/ |
BookPlayerWatchKit |
framework (watchOS) | Shared framework | top-level Shared/ (same files) |
BookPlayerWatch |
app (watchOS) | Watch app | BookPlayerWatch/ |
BookPlayerWidgetsPhone |
app-extension | iOS widgets | BookPlayerWidgets/ (Phone/ + Shared/) |
BookPlayerWidgetsWatch |
app-extension | watchOS widgets/complications | BookPlayerWidgets/Shared/ |
BookPlayerIntents |
app-extension | legacy SiriKit INExtension |
BookPlayerIntents/ |
BookPlayerShareExtension |
app-extension | Share-sheet import | BookPlayerShareExtension/ |
BookPlayerTests |
"Audiobook PlayerTests" · unit-test | Unit + perf tests | BookPlayerTests/ |
Dependency rule: app → BookPlayerKit (import BookPlayerKit). Shared/ must not import app-layer
types — that breaks the framework boundary and is a 🔴 finding. Watch/shared code selects the framework with:
#if os(watchOS)
import BookPlayerWatchKit
#else
import BookPlayerKit
#endifDo not "fix" or collapse these conditional imports — they are the mechanism that lets one shared codebase
compile for both platforms. BookPlayerIntents (SiriKit Intents) is legacy and distinct from the modern
App Intents in the top-level BookPlayer/BookPlayer/AppIntents/ folder — don't conflate them.
RevenueCat (purchases-ios, ~5.33), Sentry (sentry-cocoa, exact 8.36.0), Kingfisher (~7.9),
JellyfinAPI (jellyfin-sdk-swift, ~0.4), MarqueeLabel (~4.0.5), DeviceKit (~5.1),
IDZSwiftCommonCrypto (~0.13.1), Themeable (~3.0), ZipArchive (~2.3), DirectoryWatcher (~2.8.6).
BlurHashDecode.swift is vendored (SwiftLint-excluded). RevenueCat + Kingfisher link into the frameworks;
most others link into the app. SwiftLint runs as a build-phase run-script; Sourcery is run manually (not
a build phase) and its output is committed.
- CI (
.github/workflows/ci.yml): runnermacos-26, Xcode 26.4,build-for-testingthentest-without-buildingwith theUnit Teststest plan,-only-testing:BookPlayerTests, simulatoriPhone 17. It first copiesDebug.template.xcconfig→Debug.xcconfig. Triggers on push tomain/developand PRs todevelop. - Lint/format is enforced by tools — do NOT flag style they own.
.swiftlint.ymldisables:line_length,identifier_name,type_name,type_body_length,file_length,nesting,force_try,trailing_comma,trailing_newline,trailing_whitespace,switch_case_alignment,private_over_fileprivate,opening_brace,large_tuple,orphaned_doc_comment,todo. ExcludesBookPlayer/Generated/AutoMockable.generated.swift,BookPlayerTests,BlurHashDecode.swift. - SwiftFormat (
.swiftformat): 4-space indent, explicitself(--self insert),--wraparguments afterfirst,--ifdef noindent. A second Appleswift-formatconfig (.swift-format) says 2-space / 120-col. The two formatters disagree on indentation — do not flag indentation width. force_try/try!is allowed by lint in general. Only flag it on untrusted/remote/decoded data (network, JSON, S3, server payloads), where a bad payload crashes the app.
BookPlayer/AppDelegate.swift(@UIApplicationMain): registers defaults, notification observers, background-refresh tasks,MPRemoteCommandCentertargets, RevenueCat, Sentry, and callsAppServices.shared.setupCoreServices()(async DI). It does not create the window (scene-based).BookPlayer/SceneDelegate.swift: strongly ownsstartingNavigationControllerand aLoadingCoordinator; builds theUIWindow, callscoordinator.start(), sets the nav controller as root.LoadingViewController→LoadingViewModel.initializeDataIfNeeded()→DataInitializerCoordinator(@MainActor) awaitsAppServices.shared.setupCoreServicesTask, handles CoreData errors / backup restore, runs one-time first-launch defaults, then firesonFinish.LoadingCoordinator.didFinishLoadingSequence()force-unwrapsAppServices.shared.coreServices!, buildsMainCoordinator, retains it, and callsstart().MainCoordinator.start()hosts SwiftUIMainViewinsideAppHostingViewController(aUIHostingControllersubclass that only overrides orientation) and modally presents it full-screen over the loading nav controller.MainViewis aTabView(Library / Profile / Settings [+ iOS 26 Search]) with a mini-player overlay and a.fullScreenCoverfor the player.
Invariant: the strong chain SceneDelegate → LoadingCoordinator.mainCoordinator → MainCoordinator is the
only thing keeping MainCoordinator alive — don't sever it. Boot ordering matters: several call sites
force-unwrap AppServices.shared.coreServices!, relying on DataInitializerCoordinator having awaited setup
first. Reordering boot risks a launch crash.
@MainActor final class AppServiceswithstatic let shared+private init(). Owns the asyncsetupCoreServicesTask, theDatabaseInitializer, and a sharedPlayerState.CoreServices(BookPlayer/Utils/CoreServices.swift) is a struct of exactly 10 services:accountService,dataManager,hardcoverService,libraryService,playbackService,playerLoaderService,playerManager,preferencesService(PreferencesSyncService),syncService,watchService(PhoneWatchConnectivityService).- Two-step
init()+setup(...)DI pattern: services are created empty then configured, e.g.A new service should follow this pattern and be wired throughlet service = LibraryService() service.setup(dataManager: dataManager, audioMetadataService: audioMetadataService)
AppServices/CoreServices— not instantiated ad hoc in a view. (PlayerManagerandPhoneWatchConnectivityServicetake everything viainit.) - Services → coordinators:
CoreServicesis passed whole intoMainCoordinator.init(...), which also builds coordinator-scoped services (ImportManager,ListSyncRefreshService,SingleFileDownloadService,JellyfinConnectionService,AudiobookShelfConnectionService). - Services → SwiftUI:
ObservableObjects (playerManager,importManager,singleFileDownloadService,listSyncRefreshService) via.environmentObject; the rest via.environment(\.key, …). The environment keys live inBookPlayer/Utils/Extensions/Environment+BookPlayer.swift(@Entry). Each@Entrydefault is a throwaway placeholder (an un-setup()service). A view that reads the environment default instead of the injected instance silently gets a non-functional service — verify real injection byMainCoordinator. - App Intents DI: only
playerLoaderServiceandlibraryServiceare registered viaAppDependencyManager.shared.add(...)insetupCoreServices(). The extension targets consume them with@Dependency(under#if !MAIN_APP); the main app instead resolves viaawait AppServices.shared.awaitCoreServices(). A new@Dependencytype must beadd()-ed or intents trap.
@MainActor protocol Coordinator: AnyObject { var flow: BPCoordinatorPresentationFlow; func start() }.BPCoordinatorPresentationFlowhas three concrete variants (factory sugar in parens):BPPushPresentationFlow(.pushFlow),BPModalPresentationFlow(.modalFlow),BPModalOnlyPresentationFlow(.modalOnlyFlow, whosenavigationControllergetter is afatalErrortrap). All back-references (navigationController,presentingController) areunowned— presenting a flow whose presenter was dismissed crashes.- Live coordinators:
MainCoordinator(the SwiftUI bridge — aNSObject, notCoordinator-conforming, alsoPurchasesDelegate/Themeable/AlertPresenter),LoadingCoordinator,DataInitializerCoordinator,ImportCoordinator,SecondOnboardingCoordinator,SupportFlowCoordinator.LibraryListCoordinator/PlayerCoordinator/ItemListCoordinatorno longer exist — those flows are SwiftUI now. MainCoordinator.importCoordinatorisweak— the presented VC/flow must retain the import coordinator.- Keep new coordinators and view models
@MainActor.
- SwiftUI-era (dominant):
@MainActor class XViewModel: XViewModelProtocolwhere the protocol is@MainActor …: ObservableObject. VMs live next to their view in the feature folder. Services are constructor-injected, sourced from the view's@Environment. Navigation usesvar onTransition: BPTransition<Routes>?with a nestedenum Routes(BPTransition<T> = (T) -> Void). - UIKit-era (legacy, mostly
Loading*):MVVMControllerProtocol+@MainActor ViewModelProtocolwith aweak var coordinator. The genericBaseViewController/BaseViewModelbase classes are not in the repo.
Custom Notification.Names (namespaced with the bundle id at runtime) are the app ⇄ BookPlayerKit ⇄ Watch ⇄
CarPlay event bus. Declared in Shared/Extensions/Notification+BookPlayerKit.swift (framework-wide):
.chapterChange, .bookReady, .bookPlayed, .bookPaused, .bookEnd, .bookPlaying, .accountUpdate,
.logout, .messageReceived, .folderProgressUpdated, .uploadProgressUpdated, .uploadCompleted,
.listeningProgressChanged; and app-internal ones in BookPlayer/Utils/Extensions/Notification+BookPlayer.swift.
PlayerManageris the dominant publisher of playback events;PhoneWatchConnectivityServiceandCarPlayManagerare the dominant cross-target subscribers;AccountServiceis the auth/account hub..logout(posted byAccountService.logout()) fans out teardown toSyncService,PreferencesSyncService, and Watch/Profile views..accountUpdatepropagates subscription state.- Because these names cross target/process boundaries, renaming a name or its raw string silently breaks cross-process delivery — treat renames as behavior changes.
- Store lives in the App Group container:
containerURL(forSecurityApplicationGroupIdentifier: Constants.ApplicationGroupIdentifier)! + "BookPlayer.sqlite"(shared with widgets/watch/extension). The force-unwrap crashes if the App Group entitlement is misconfigured. CoreDataStack.swift:shouldInferMappingModelAutomatically = false(migrations are manual),shouldMigrateStoreAutomatically = true.viewContextfor UI reads; a single cached lazybackgroundContextfor background work — bothautomaticallyMergesChangesFromParent = true.DataManageris the facade (getContext(),getBackgroundContext(),saveSyncContext, debouncedscheduleSaveContext).saveContextfatalErrors on any save failure — feeding it inconsistent state (e.g. a unique-constraint conflict) is a hard crash. No merge policy is set anywhere → the defaultNSErrorMergePolicyturns write conflicts into crashes rather than reconciling them.- Never pass an
NSManagedObjectacross threads/contexts or out of a service. Convert to a thread-safe value snapshot first (Shared/CoreData/Lightweight-Models/):SimpleLibraryItem,SimpleChapter,SimpleBookmark,SimpleTheme,SimpleAccount,SimplePlaybackRecord,SimpleHardcoverBook,SimpleItemType,LibraryItemRef,PlayableChapter.PlayableItemis the one exception — afinal class: NSObject(mutable,Codable), not an immutable struct — scrutinize its mutation/threading. - Entities (
Shared/CoreData/Backed-Models/): abstractLibraryItem(itsencode/init(from:)fatalError— concrete subclassesBook/Foldermust be used), plusLibrary,Bookmark,Chapter,Account,Theme,PlaybackRecord,HardcoverBook.ItemType: Int16 { folder, bound, book }. - Migration is manual and staged (
Shared/CoreData/Migrations/DataMigrationManager.swift+DBVersion.swift, currentlyv1…v11, current modelAudiobook Player 11). It migrates one version at a time using explicit.xcmappingmodels where present (v1→v2 … v3→v4,v7→v8 … v10→v11; the v4–v7 hops rely on inference). A model change requires: (1) new.xcdatamodelversion + bump.xccurrentversion; (2) newDBVersioncase +model(); (3) a mapping model registered inmappingModelName()(inference is OFF, so anything non-trivial fails without one); (4) any custom data population added to the post-migration step; (5) bundled resources.DatabaseInitializer+DatabaseBackupServiceare the only safety net for a failed migration (the migrator deletes the old store before moving the new one into place — interruption = data loss).
TasksDataManager.swiftowns theModelContainer. Store isapplicationSupportDirectory/bp-synctasks.sqlite— the app-support dir, NOT the App Group, separate from the CoreData store. CloudKit disabled.container = try! ModelContainer(...)—try!crashes on any container/migration failure.- Versioned schema:
SchemaV1(10 models) →SchemaV2(11 models, addsMatchUuidsTaskModel+ auuidfield). App code always uses the V2 typealiases.@Modeltypes:SyncTasksContainer,SyncTaskReferenceModel(@Attribute(.unique) id), and per-job payload models. MigrationPlan.swift(SchemaMigrationPlan) has a customv1ToV2stage that reads UUIDs out of the CoreDataLibraryItemtable — it requiresMigrationPlan.injectedCoreDataContextto be set first, elsefatalError. This is the one coupling between the two stores; set it before theModelContaineris built.ModelContextis per-actor and notSendable. All task-queue reads/writes go throughpublic actor SyncTasksStorage: ModelActor(Shared/Services/Sync/SyncTasksStorage.swift) with a single confinedModelContext. Do not share/pass aModelContextacross actors or threads.- Realm is gone (Realm → SwiftData migration is complete). Only inert remnants remain
(
DataManager.getSyncTasksRealmURL()is dead; a stale comment inLibraryService). Don't reintroduce it.
PlayerManager.swift is final class PlayerManager: NSObject, PlayerManagerProtocol, ObservableObject (~1500
lines). It is the highest-risk file in the app.
- Exactly one
AVPlayerat a time (var audioPlayer). It is recreated viasetupPlayerInstance()onmediaServicesWereResetand on.failedstatus. Recreation must re-add the 1-second periodic time observer (removed first, or it leaks onto an orphaned player) and re-bind the time-control passthrough. - Named single-purpose cancellables, each
.cancel()'d before rebind (distinct from the multi-sinkdisposeBag):timeControlSubscription(bridges the recreatable player'stimeControlStatusinto a stableCurrentValueSubjectsoisPlayingobservers survive recreation),playableChapterSubscription,isPlayingSubscription,nowPlayingClaimSubscription. Match this pattern; don't convert a named rebind-able subscription into adisposeBagentry. - Invariant — every
currentItemreassignment must be followed bybindPlayableChapterSubscription(3 call sites:load,loadRemoteURLAsset,reloadCurrentItem). Missing it silently breaks the end-of-chapter sleep timer and the Now Playing chapter title (the.chapterChangenotification is posted from that sink). - KVO on
AVPlayerItem.statusis balanced viaobserveStatusdidSet + ahasObserverRegisteredguard; nulling the player item resets the guard. An imbalance crashes onremoveObserver. - AVAudioSession: activated in
play()(.playback/.spokenAudio); deactivated ~0.1s after.paused(delay avoids clipping). Interruption observer is kept on.began(so.ended+.shouldResumecan resume) and removed on user pause/stop.mediaServicesWereResetre-applies the category and recreates the player. Session-activation failurefatalErrors in production (only downgraded to a Sentry capture on TestFlight) — a real crash surface. - Background task pairing lives in
AppServices.loadAndKeepAlive(...):beginBackgroundTask(withName: "streaming-playback")must be matched byendBackgroundTaskon all three paths — error, success, and the expiration handler — guarded by the.invalidsentinel to avoid a double-end. Any new begin without a matching end on every path (incl. errors) is a 🔴 leak/expiration crash. MPRemoteCommandCentertargets are wired once inAppDelegate.setupMPRemoteCommands()(play/pause/toggle, skip fwd/back, change-position); re-adding targets duplicates actions.MPNowPlayingInfoCenteris pushed after every relevant mutation.SleepTimer.swiftis a singleton (.shared) with@Published state(.off / .countdown / .endOfChapter). Countdown uses aTimer.publishsubscription;.endOfChapterobserves.chapterChange/.bookEnd.reset()cancels the subscription and removes observers, andsetTimeralways resets first. It emits three publishers consumed byPlayerManager(threshold volume-fade, end→pause, turned-on→.sleepbookmark; shake-to-resume viaShakeMotionService).- Related services (protocols marked
/// sourcery: AutoMockable):SpeedService(per-book vs global speed),ShakeMotionService,PlayerLoaderService,WidgetReloadService.PlayerManagerProtocolis AutoMockable but its mock does not reproduceObservableObject/@Published— Combine-dependent tests need the concrete class.
NetworkClient.swift(URLSession): base URL from Info.plist config (scheme/domain/port). Bearer token pulled from Keychain (.token) per request — but only whenuseKeychain == true(the default). Errors map toBookPlayerError(4xxdecodeErrorResponse→.networkError/.networkErrorWithCode,5xx→.networkError). Decoder is.iso8601.- The bearer token must never be attached to S3/presigned or third-party (Jellyfin/ABS/Hardcover) URLs.
Uploads to S3 presigned PUTs use
useKeychain: false(the URL carries its own auth); media-server calls use their own connection tokens. Any newrequest(url:...)must setuseKeychaindeliberately — the defaulttrueattaches the JWT to whatever host is passed. - Background
URLSessions (Shared/Network/BPURLSession.swift): two sessions (.backgroundand.background.cellular) chosen by theallowCellularDatadefault; downloads viaBPDownloadURLSession. SyncService.swiftis@Observable.isActiveispublic private(set)and must be mutated only viaupdateSyncEnabled(_:)/logout()(both hop to@MainActor). It is driven by.logout(→ teardown, clears scheduled-contents flag, resets jobs) and.accountUpdate(→updateSyncEnabled(hasSyncEnabled())) notifications. AteardownTaskis awaited at the top of the sync-contents entry points so a fast logout→login can't let a lateresetAllJobs()wipe freshly-scheduled jobs — preserve this ordering. Everyschedule*method short-circuits onguard isActive.- Sync = the
proentitlement only (see below). Job types (SyncJobType):upload, update, move, renameFolder, delete, shallowDelete, setBookmark, deleteBookmark, uploadArtwork, matchUuid. - Download verification:
verifyDownloadedFilerejects truncated files by comparingAVURLAssetduration to the stored duration (tolerancemax(2, expected*0.02)); completion is broadcast only after verification. Don't skip it — it prevents promoting a truncated book.
BuildConfiguration/*.xcconfigdefineBP_*keys;Info.plistsubstitutes them ($(BP_…));Shared/Configuration.swift(ConfigurationKeys) reads them.Bundle.configurationValue(for:)usestry!— a missing key crashes at launch. Keys: API scheme/domain/port, bundle id, RevenueCat key, Sentry DSN,BP_MOCKED_BEARER_TOKEN.Debug.xcconfigandRelease.xcconfigare gitignored and hold the working-tree real values — never commit or overwrite them. Nuance a reviewer should know:Debug.xcconfigis untracked and holds real prod secrets;Release.xcconfigis also gitignored but is already tracked with placeholder values (replace.me) — CI (ci_scripts/ci_post_clone.sh) rewrites it from Xcode Cloud env vars at build time. A new secret must be added to the template (Debug.template.xcconfig) + the CI script +Info.plist+ConfigurationKeysin lockstep, never inlined — or thetry!crashes Release builds. Hardcoded API keys / tokens / Sentry DSN / RevenueCat key are a 🔴 finding.- Test-account backdoor:
AccountService.loginTestAccount(...)hardcodes a real Apple userId/email and setshasSubscription = true, reached viaBP_MOCKED_BEARER_TOKEN. It must stay inert (empty token) in production.
kSecClassGenericPassword, service = the bundle identifier, accessibilitykSecAttrAccessibleAfterFirstUnlock(so background sync/downloads work while locked). Stores JWT.token,.jellyfinConnection,.audiobookshelfConnection,.hardcoverToken.- Correction to older docs: the Keychain is NOT scoped via an App Group /
kSecAttrAccessGroup— there is no access group set; sharing relies on the default app access group. (The App Groupgroup.$(BP_BUNDLE_IDENTIFIER).filesis used forUserDefaults.sharedDefaultsand file storage, not the Keychain.) - Every mutation emits
valueUpdatedPublisher.send((key, deleted:));HardcoverServiceobserves it to start/stop tracking on token changes.
- RevenueCat entitlements
AccessLevel { free, plus, pro }.hasSyncEnabled()=proentitlement active;hasPlusAccess()=plusORpro(with a localdonationMadefallback when cached info is nil). - These are client-side cached RevenueCat reads — UX gating only. Never let
hasSyncEnabled()/isActivebecome the sole gate for a server-billed resource; the server validates the entitlement. Purchases are guarded byAppEnvironment.isPurchaseEnabled(disabled on TestFlight). login(...)stores the JWT thenPurchases.logIn(revenuecatId ?? appleUserId)(server's RC id wins).logout()removes the token, resets the account,Purchases.logOut, and posts.logout.- Auth entry points: Apple/Google sign-in (
Profile/Login/), Passkeys/WebAuthn (Profile/Passkey/, relying partybookplayer.app, endpoints/v1/passkey/*), Watch credential transfer.
All three store secrets in the Keychain (never UserDefaults), persist custom headers alongside connection
data, and share the error type MediaServerIntegration/IntegrationError.swift (note sessionExpired(serverName:)
isSessionExpired). Jellyfin & AudiobookShelf share theMediaServerIntegration/protocol + UI layer.
- Session-expiry contract: a 401/403 from an authenticated call maps to
sessionExpired(serverName:)(a recoverable "sign in again" path that preserves the connection). Pre-sign-in probes (findServer/ping) must bypass this mapping — otherwise an unrelated saved server gets mis-thrown into re-auth, and users land in the duplicate-connection trap. - Jellyfin (
Jellyfin/,@MainActor @Observable JellyfinConnectionService, backed byjellyfin-sdk-swift): add-server validates via a transientPendingServerwithout mutating the liveclient;rebuildClientis the single client-construction choke point and must forwardcustomHeaders; theJellyfinHeaderInjectormust never overwriteAuthorization(so Cloudflare-Access headers can't clobber the token). Downloads are delegated toSingleFileDownloadService. - AudiobookShelf (
AudiobookShelf/,@MainActor @Observable, hand-rolled URLSession):Authorization: Bearerapplied after custom headers; re-auth/delete fire a fire-and-forget/logoutto avoid orphan tokens; image URLs keep the token in the header (KingfisherrequestModifier), not the URL, so a rotated token can't poison the disk cache. URL-encoding footgun: thefilterparam is manually percent-encoded (+→%2B,/→%2F) because ABS/Express corrupts+in a query value — don't route it throughURLQueryItem. - Hardcover (
Hardcover/,@Observable HardcoverService, GraphQL): two-way reading-progress sync, not a media server. StatusHardcoverBook.Status { local=0, library=1, reading=2, read=3 }; only 1/2/3 are ever POSTed (.localis a local-only marker). Monotonic guards prevent backwards writes; auto-match on import has explicit duplicate detection; API failures are log-and-swallowed so a Hardcover outage never blocks local playback. Token is Keychain.hardcoverToken; no token → subscriptions torn down.
- Import (
Import/): files enter from the document picker / drag-drop, the share extension (writes into the App Group folder, picked up by aDirectoryWatcher), URL-open/intents, and remote downloads — all converging onImportManager(ObservableObject).ImportOperation(asyncOperation, thread-safe via a barrierlockQueue) detects folder organization, handles zip/lpf (SSZipArchive), collision-safe-copies into the Processed folder, and uses balancedstart/stopAccessingSecurityScopedResource. A copy failureSentrySDK.captures thenfatalErrors — an intentional but real crash surface for bad imports. Book/folder records are created viaLibraryService.createBook/createFolder; artwork is extracted lazily byArtworkService, not inline. App-managed source files are removed after copy. - Library (
Library/ItemList/…, backed byShared/Services/LibraryService.swift): the main list, folders, and drag-drop reordering. Ordering model (query-time sort):orderRankmeans ONLY the user's custom arrangement (written by drag/reverse/Custom-freeze/one-shot sorts and by sync; never by an automatic sort). An automatic sticky sort is applied at fetch time —resolveSortDescriptorsinLibraryServicemaps the location's effective sort toSortType.sortDescriptors(localizedStandardCompare:+orderRanktie-break); sync may overwrite ranks freely — the rendered order only follows ranks where the effective sort resolves to rank order: Custom,.unresolved/bound locations, and any target with nopreferencesServicewired. watchOS wires a pull-onlyPreferencesSyncService(constructed inExtensionDelegate, bootstrapped at launch, refreshed before each list sync inRemoteItemListViewModel), so the watch renders the same sticky sort as the phone; nothing on the watch writes sort prefs, and the pull is gated on the sync entitlement so free accounts never make the request. Rank updates always sync (no auto-sort suppression exists anymore). Both mutation invariants live infreezeVisibleOrder(at:transform:)— the single core ofreorderItems/reverseContents/adoptCurrentOrderAsCustom: (1) capture-before-flip — the effective (visible) descriptors are resolved before the pref flips to.custom, else the mutation acts on rank order instead of what the user sees; (2) the.custompref write precedes the rank rebuild so the next fetch doesn't re-sort the user's arrangement away. Route any new user-arrangement rank mutation through that helper (the one-shot materialization for.unresolvedlocations insortContentsis the deliberate exception — it has no pref key to flip). Playback prev/next walksgetOrderedSiblings(visible order, lightweight entries), not rank cursors. On logout,PreferencesSyncServicefreezes automatically-sorted locations into ranks before wipinglibrary_sort:*, so sign-out doesn't visibly re-scramble the library. UI reads onviewContext, background onbackgroundContext, onlySimple*snapshots cross back to UI. - Search (
Search/): local-only CoreData search (LibraryService.searchAllBooks), 0.3s debounce, results grouped by parent folder. Remote search lives in the integration view models, not here — don't expect network calls inSearch/. - Settings (
Settings/): SwiftUIForm+SettingsScreenroute enum; gating readsaccountService.accessLevel. Integrations entry isSettingsIntegrationsSectionView→MediaServersView/ Hardcover.
- Prefer native Apple / SwiftUI APIs over custom implementations.
- Localization: every user-facing string via
"key".localized(Shared/Extensions/String+BookPlayer.swift→NSLocalizedString; no SwiftGen/L10n). New keys go inBookPlayer/Base.lproj/Localizable.strings; ~27 locales are community-translated — flag a missing Base key or a hardcoded literal, but do not nitpick the wording of existing translations. - Accessibility is first-class (audiobook app, many low-vision users). New interactive SwiftUI controls need
accessibilityLabel(andaccessibilityValuewhere stateful) and must respect Dynamic Type — use thebpFont(_:)modifier, not fixed.font(.system(size:)).Services/VoiceOverService.swiftbuilds VoiceOver strings; live content uses theDynamicAccessibilityLabelmechanism, not a static snapshot. - Mocks are Sourcery
AutoMockable: mark a protocol/// sourcery: AutoMockable; output isBookPlayer/Generated/AutoMockable.generated.swift(DO NOT EDIT, SwiftLint-excluded, regenerate via Sourcery overTemplates/AutoMockable.stencil). A protocol change needs regeneration or the test target won't build. New service logic should come with a test inBookPlayerTests/(XCTest only — no Swift Testing). - App Group correctness: data/defaults/files shared with widgets/watch/extension must use the App Group
container /
UserDefaults.sharedDefaults, not.standard. The App Group idgroup.$(BP_BUNDLE_IDENTIFIER).filesmust stay consistent across the app, watch, widgets, and share-extension entitlements — it is the sole data channel for widgets and the share extension. - Combine: long-lived subscriptions →
private var disposeBag = Set<AnyCancellable>()+.store(in:); single-purpose → a namedAnyCancellable?that is.cancel()'d before rebind.[weak self]in sinks/closures is the norm — match it. A sink that touches UI needs.receive(on: DispatchQueue.main). @MainActoris the UI/service isolation convention. Off-main → main hops are explicit (Task { @MainActor in … }/.receive(on:)).
The crash surfaces and invariants most likely to be broken by a change. (The full severity rubric lives in
.github/claude/review-guide.md; this is the architecture-backed "why".)
- CoreData threading: never pass an
NSManagedObjectacross threads — useSimple*/Playable*snapshots; UI onviewContext, background onbackgroundContext.saveContextfatalErrors; there is no merge policy, so conflicts crash. - CoreData model change without the full 5-step manual-migration ritual (auto-inference is OFF) → crashes existing installs.
- SwiftData: don't share a
ModelContextacross actors; the sync-queue lives behind theSyncTasksStorageactor;MigrationPlan.injectedCoreDataContextmust be set before the container is built. - Retain cycles / Combine leaks: missing
[weak self]in a sink; anAnyCancellablenot stored; a named subscription not.cancel()'d before rebind (PlayerManagerdepends on this). - UI/state mutated off the main actor without a
@MainActorhop /.receive(on: .main). - Player / AVAudioSession lifecycle:
currentItemswap without re-binding the chapter subscription; unbalanced KVO on player-item status; unhandled interruption /mediaServicesWereReset; abeginBackgroundTaskwithout a matchingendBackgroundTaskon every path including errors. SyncService.isActiveassigned directly instead of viaupdateSyncEnabled(_:)/logout(), or the.logout/.accountUpdate/teardownTask-await contract broken.- Entitlement gating that trusts client-only RevenueCat state for a server-billed resource, or gates sync
without going through
AccountService. - Secrets: committing/overwriting real
Debug.xcconfig/Release.xcconfig, or hardcoding a key instead of the xcconfig →Configurationpath;BP_MOCKED_BEARER_TOKEN/loginTestAccountleft live in prod. - Force-unwrap /
try!on remote or decoded data (network / JSON / S3) — a bad payload crashes. - App Group correctness for anything consumed by widgets/watch/extension.
BookPlayerKitboundary:Shared/importing app-layer types.- Integration session-expiry / token contracts (see the integrations section).
- Hand-editing
Generated/AutoMockable.generated.swift; adding code to a top-level empty stub folder.