Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions Palace.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@
08D9D47ABBE9431A93B73510 /* AudiobookDataManagerModelsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D45E9ABFFE8142AFB578979F /* AudiobookDataManagerModelsTests.swift */; };
08E5DBD9DF8A02E6C155BA2C /* ReaderTypographyButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = C41E92244AABB4F1FE944CF8 /* ReaderTypographyButton.swift */; };
091A2B3C4D5E6F7081921324 /* TPPPreferredAuthSelectionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8091A2B3C4D5E6F70819213 /* TPPPreferredAuthSelectionTests.swift */; };
092C37A2AA45C900DCCD7A8E /* AccountRegistryStoreSeamTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D5D921DA42F7B66434E0D5F /* AccountRegistryStoreSeamTests.swift */; };
097BAE0BCFDD15E6EC7C7854 /* BadgesViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A8B7ED878FCA626AD3519F6E /* BadgesViewModelTests.swift */; };
09D98A92B2EE80E093A1EDD5 /* AudiobookSessionManaging.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9F719C4DEA011976097371B /* AudiobookSessionManaging.swift */; };
0A007DDD59BEB4E160FFA769 /* OfflineAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8095146D959C854EDE502449 /* OfflineAction.swift */; };
Expand All @@ -66,6 +67,7 @@
0AD530FB6EABC6946F799D87 /* EPUBPositionAdapter.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3FBCE9F366E7A29724332D4 /* EPUBPositionAdapter.swift */; };
0AE0A0299BFC49CE91440D54 /* AccountDetailView+Constants.swift in Sources */ = {isa = PBXBuildFile; fileRef = 23EE1204A0914891AF2B954A /* AccountDetailView+Constants.swift */; };
0AE0A02A9BFC49CE91440D55 /* AccountDetailView+Constants.swift in Sources */ = {isa = PBXBuildFile; fileRef = 23EE1204A0914891AF2B954A /* AccountDetailView+Constants.swift */; };
0AE214B06AC5C39759675519 /* AccountRegistryStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = C49DDACF93DCF459DC310653 /* AccountRegistryStore.swift */; };
0B1838E6F21342A31DC01D12 /* AdobeDRMHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE0CCABB8C99DA55C43F3DA3 /* AdobeDRMHandler.swift */; };
0B461FFD655EC35ED6C3990A /* OPDSFeedCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = ACE8017D2ECF7E0FEBFAAB1A /* OPDSFeedCache.swift */; };
0B55EFB324FAF34D779EB878 /* StreamingReaderPresentationContractTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A213D524079F39BA952025A /* StreamingReaderPresentationContractTests.swift */; };
Expand Down Expand Up @@ -434,6 +436,7 @@
46D82B974AA50B5FF3AABA1F /* AudiobookLoaderDispatchTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D831302F218AC0EE46308A0 /* AudiobookLoaderDispatchTests.swift */; };
46E0E670ECA43B1BE1769CA7 /* AccountSwitchBorrowReauthCouplingContractTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE04BB7BA065CC171F20537B /* AccountSwitchBorrowReauthCouplingContractTests.swift */; };
4714FCCE477E3480FCAB241C /* BadgeServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C927D5FB4DCC629EEF09DE4C /* BadgeServiceTests.swift */; };
4731E519F95F0E4A6F77BFBF /* AccountRegistryStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = C49DDACF93DCF459DC310653 /* AccountRegistryStore.swift */; };
47E7D42D22FE22D4C9489931 /* SignInModalSheetPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = A8829DAED777C8EBAB199D77 /* SignInModalSheetPresenter.swift */; };
483261822C71A1A249B7F279 /* ContinueRowSectionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4057C151D980D26B04B8FC53 /* ContinueRowSectionTests.swift */; };
48333F55E5F2AC51E90C1DFC /* PDFKitThumbnailProviderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCEAFA525379AFA3FE0553DE /* PDFKitThumbnailProviderTests.swift */; };
Expand Down Expand Up @@ -2299,6 +2302,7 @@
1BA91B27F363A5BBFB4F5BB0 /* Adapters+Production.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "Adapters+Production.swift"; sourceTree = "<group>"; };
1CC83EBCF4E254E0E8077589 /* DownloadTaskPersistence.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DownloadTaskPersistence.swift; sourceTree = "<group>"; };
1D4B77CEDF228DC03714AD31 /* LocalFileAdapter.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = LocalFileAdapter.swift; sourceTree = "<group>"; };
1D5D921DA42F7B66434E0D5F /* AccountRegistryStoreSeamTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AccountRegistryStoreSeamTests.swift; sourceTree = "<group>"; };
1DA098468E3C460A466D22BD /* TPPPDFReaderSearchBindingTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TPPPDFReaderSearchBindingTests.swift; sourceTree = "<group>"; };
1DC9FC06EF6CCEED742C0D44 /* TPPSignInBusinessLogicExtendedTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TPPSignInBusinessLogicExtendedTests.swift; sourceTree = "<group>"; };
1DE545556CC9FC4FC7DFF4B8 /* DeveloperSettingsView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DeveloperSettingsView.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -3064,6 +3068,7 @@
C3D4E5F60718394051627384 /* MockSAMLAuthContext.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MockSAMLAuthContext.swift; sourceTree = "<group>"; };
C41E92244AABB4F1FE944CF8 /* ReaderTypographyButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReaderTypographyButton.swift; sourceTree = "<group>"; };
C49B562EC2240BFF6303698C /* AudiobookSkipIntervalSettingsTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AudiobookSkipIntervalSettingsTests.swift; sourceTree = "<group>"; };
C49DDACF93DCF459DC310653 /* AccountRegistryStore.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AccountRegistryStore.swift; sourceTree = "<group>"; };
C4B5C6D7E8F9A0B1C2D3E4F5 /* DownloadStartCoordinatorTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DownloadStartCoordinatorTests.swift; sourceTree = "<group>"; };
C4BD28685CFAA8815B0DE215 /* ExtensionCoverageTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ExtensionCoverageTests.swift; sourceTree = "<group>"; };
C54010275AF91385B8410DF1 /* BookOpenTracker.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = BookOpenTracker.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -4976,6 +4981,7 @@
2C353AC0344431D385FC89E6 /* AccountSwitchDependencies.swift */,
5CDD5AA4C49CC5D388BA9F31 /* AccountNetworking.swift */,
EF3B037BFF273D82D2D88CE5 /* AccountRegistryCache.swift */,
C49DDACF93DCF459DC310653 /* AccountRegistryStore.swift */,
);
path = Library;
sourceTree = "<group>";
Expand Down Expand Up @@ -6067,6 +6073,7 @@
41F50CFA2EBD6E2F19F568E0 /* TPPSignInCapabilitiesCharacterizationTests.swift */,
9963A35F959660D52B46E3EC /* AccountNetworkingSeamTests.swift */,
7A2745714D1B395FCB45D9C3 /* AccountRegistryCacheSeamTests.swift */,
1D5D921DA42F7B66434E0D5F /* AccountRegistryStoreSeamTests.swift */,
);
name = Decomp;
path = Decomp;
Expand Down Expand Up @@ -8068,6 +8075,7 @@
2FEE2557210153D5C94C4D28 /* MyBooksDownloadCenterAccountScopeSeamTests.swift in Sources */,
DE253F8C8C4CC3BA89F45090 /* AccountNetworkingSeamTests.swift in Sources */,
181FDBB5DF170830CFDA55D2 /* AccountRegistryCacheSeamTests.swift in Sources */,
092C37A2AA45C900DCCD7A8E /* AccountRegistryStoreSeamTests.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
Expand Down Expand Up @@ -8637,6 +8645,7 @@
4BD9BDFB996BF9E0576420CB /* AccountNetworking.swift in Sources */,
A7D4A0D17DACC00FB34F5289 /* TPPNetworkExecutor+AccountNetworking.swift in Sources */,
AEFABDFE2619088183740385 /* AccountRegistryCache.swift in Sources */,
0AE214B06AC5C39759675519 /* AccountRegistryStore.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
Expand Down Expand Up @@ -9209,6 +9218,7 @@
A81B474445D23D25DD33F00F /* AccountNetworking.swift in Sources */,
31E755A021BE24D982712F6B /* TPPNetworkExecutor+AccountNetworking.swift in Sources */,
D8AEB498F4BA7DC04FC5A30E /* AccountRegistryCache.swift in Sources */,
4731E519F95F0E4A6F77BFBF /* AccountRegistryStore.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
Expand Down
219 changes: 219 additions & 0 deletions Palace/Accounts/Library/AccountRegistryStore.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,219 @@
//
// AccountRegistryStore.swift
// Palace
//
// god-class decomposition — Wave 3 / 3a-2 (the second in-target collaborator
// split out of `AccountsManager`).
//
// Owns the account-registry STATE and all thread-safe access to it: the current
// catalog hash, the `[hash → [Account]]` sets, the O(1) `uuid → Account` index kept
// in lockstep with them, and the separate slim launch-hydration fallback. Extracted
// behind an injected collaborator so the hub carries no registry-state concurrency
// machinery and a packaged `AccountsManager` names none of it — the
// `AccountRegistryCache` / `AccountStateStore` precedent.
//
// A `final class`, NOT an `actor`, and NOT a protocol: `AccountsManager.account(_:)`
// is a SYNCHRONOUS `@objc TPPLibraryAccountsProvider` requirement, and the
// background-work drain choreography (`cancelAndDrainBackgroundWork`) depends on a
// synchronous `accountSetsLock.sync` read blocking behind the barrier — an actor
// would force `await` through the `@objc` conformance and delete that timing. The
// store therefore OWNS the same concurrent `DispatchQueue` and preserves the exact
// sync-read / `.async(flags:.barrier)`-write model verbatim. It is pure in-target
// state with no outward app-target edge, so it is a concrete injected instance
// (like `AccountStateStore`) and travels into `PalaceAccounts` with the hub.
//
// `@unchecked Sendable` invariant: the only mutable state is `_currentHash`,
// `accountSets`, and `accountByUUID`, read exclusively via `accountSetsLock.sync`
// (`performRead`) and written exclusively on the serial `.barrier` of the concurrent
// `accountSetsLock` (`performWrite` / `mutate`); `accountByUUID` is rebuilt inside the
// SAME barrier as `accountSets` so the two never desync. `slimAccountsByUUID` is
// guarded by its own `slimAccountsLock` NSLock — deliberately separate so the
// `account(_:)` full-index read and the slim fallback never contend on one lock; the
// slim set is a launch fallback only and never flips `currentBucketIsLoaded`. All
// boxed closures are invoked exactly once on that serial barrier, never concurrently.
//
// Copyright © 2026 The Palace Project. All rights reserved.
//

import Foundation

/// Documented carrier for a non-Sendable `() -> Void` handed to the `accountSetsLock`
/// barrier in `performWrite`. `@unchecked Sendable` invariant: the wrapped closure is
/// invoked exactly once, on the serial barrier of the concurrent `accountSetsLock`,
/// never concurrently.
private struct VoidWorkBox: @unchecked Sendable {
let work: () -> Void
}

/// Documented carrier for the non-Sendable `(inout [String: [Account]]) -> Void`
/// mutation closure handed to the `accountSetsLock` barrier in `mutate`. Same
/// `@unchecked Sendable` invariant as `VoidWorkBox`: invoked exactly once, on the
/// serial `.barrier`, never concurrently. Timing and the mutate-then-rebuild-index
/// contract are unchanged.
private struct AccountSetsMutationBox: @unchecked Sendable {
let mutate: (inout [String: [Account]]) -> Void
}

/// Thread-safe holder of the account-registry state. See the file header for the
/// `@unchecked Sendable` invariant and the class-not-actor rationale.
final class AccountRegistryStore: @unchecked Sendable {

/// The current catalog hash (`prod` / `beta` / custom-URL). Written on the
/// barrier, read under `accountSetsLock` — bundled into ONE critical section with
/// the bucket read by `accountsForCurrentHash` / `currentBucketIsLoaded` so a
/// concurrent library switch can never key the bucket to a stale hash.
private var _currentHash: String

private var accountSets = [String: [Account]]()

/// O(1) `uuid → Account` index derived from `accountSets`, kept in lockstep with
/// it under `accountSetsLock` (rebuilt in the SAME barrier as any `accountSets`
/// mutation — see `mutate`). Lets `account(_:)` resolve a UUID without a linear
/// scan over ~1142 accounts on the main-thread display path. MUST only change via
/// `mutate` so it can never desync from `accountSets`.
private var accountByUUID = [String: Account]()

private let accountSetsLock = DispatchQueue(label: "com.tpp.accountSetsLock", attributes: .concurrent)

/// Launch-hydration (CP-D1) slim lookup: the current + settings accounts (~2),
/// decoded synchronously at launch so `currentAccount` resolves within a few ms.
/// Deliberately SEPARATE from `accountSets`: the slim set backs `account(_:)`'s
/// FALLBACK only and MUST NOT flip `currentBucketIsLoaded` true (a truncated-picker
/// bug). Guarded by its own `NSLock` so the `account(_:)` `accountSetsLock` read and
/// this fallback never contend on the same lock.
private var slimAccountsByUUID = [String: Account]()
private let slimAccountsLock = NSLock()

init(currentHash: String = "") {
self._currentHash = currentHash
}

// MARK: - Concurrency primitives (private)

private func performRead<T>(_ block: () -> T) -> T {
return accountSetsLock.sync {
block()
}
}

private func performWrite(_ block: @escaping () -> Void) {
let box = VoidWorkBox(work: block)
accountSetsLock.async(flags: .barrier) {
box.work()
}
}

// MARK: - Current hash

/// Single synchronized read of the current hash, for callers that need only the
/// hash (never paired atomically with a bucket read — those use the atomic
/// methods below).
var currentHash: String {
performRead { self._currentHash }
}

func setCurrentHash(_ hash: String) {
performWrite { self._currentHash = hash }
}

// MARK: - Reads

func account(_ uuid: String) -> Account? {
if let full = performRead({ accountByUUID[uuid] }) {
return full
}
// CP-D1 launch-hydration fallback: before the full account list has
// materialized off-main, the slim snapshot backs current-account resolution.
// Full instances take precedence (checked first, above) once present.
return slimAccount(uuid)
}

/// Accounts for an explicit hash. Used by callers that already hold the hash they
/// want (the hub `accounts(key)` facade when a key is passed, plus tests).
func accounts(forKey key: String) -> [Account] {
performRead { self.accountSets[key] ?? [] }
}

/// Accounts for the CURRENT hash, read ATOMICALLY: the hash and the bucket are
/// sampled in ONE `performRead`, so a library-switch barrier cannot land between
/// them and key the bucket to a stale hash. Do NOT reimplement as
/// `accounts(forKey: currentHash)` — that is two separate lock acquisitions.
func accountsForCurrentHash() -> [Account] {
performRead { self.accountSets[self._currentHash] ?? [] }
}

/// Whether the CURRENT hash's bucket is non-empty, read ATOMICALLY (same
/// single-critical-section snapshot as `accountsForCurrentHash`). Reflects the
/// FULL account list only — the slim fallback never flips this true.
func currentBucketIsLoaded() -> Bool {
performRead { !(self.accountSets[self._currentHash]?.isEmpty ?? true) }
}

/// Whether an explicit hash's bucket is non-empty.
func bucketIsNonEmpty(hash: String) -> Bool {
performRead { self.accountSets[hash]?.isEmpty == false }
}

// MARK: - Writes

/// The ONLY sanctioned way to mutate `accountSets`. Applies `mutate` inside the
/// `accountSetsLock` barrier, then rebuilds `accountByUUID` in the SAME critical
/// section so the two can never desync. Adding a write that bypasses this silently
/// breaks `account(_:)` lookups (guarded by the index-coherence tests).
func mutate(_ mutate: @escaping (inout [String: [Account]]) -> Void) {
let box = AccountSetsMutationBox(mutate: mutate)
accountSetsLock.async(flags: .barrier) {
box.mutate(&self.accountSets)
self.accountByUUID = AccountRegistryStore.buildAccountIndex(self.accountSets)
}
}

// MARK: - Slim launch-hydration fallback

func storeSlim(_ accounts: [Account]) {
slimAccountsLock.lock()
defer { slimAccountsLock.unlock() }
for account in accounts {
slimAccountsByUUID[account.uuid] = account
}
}

func slimAccount(_ uuid: String) -> Account? {
slimAccountsLock.lock()
defer { slimAccountsLock.unlock() }
return slimAccountsByUUID[uuid]
}

// MARK: - Pure

/// Pure: flatten `accountSets` into a `uuid → Account` index. When a UUID appears
/// in more than one bucket, the last-enumerated wins — equivalent to the
/// nondeterministic "first across `values`" the prior linear scan returned.
static func buildAccountIndex(_ sets: [String: [Account]]) -> [String: Account] {
var index = [String: Account]()
for accounts in sets.values {
for account in accounts {
index[account.uuid] = account
}
}
return index
}

#if DEBUG
/// Test-only: reads `accountByUUID` and a freshly-rebuilt index in ONE
/// `performRead` and returns whether they are identical (by key set + object
/// identity). A mutant that rebuilds the index outside the mutation barrier opens
/// a window where a sync read observes updated `accountSets` but a stale
/// `accountByUUID`; hammering this under concurrent `mutate` catches it.
func _coherentSnapshot() -> Bool {
performRead {
let rebuilt = AccountRegistryStore.buildAccountIndex(self.accountSets)
guard rebuilt.count == self.accountByUUID.count else { return false }
for (uuid, account) in rebuilt {
guard let indexed = self.accountByUUID[uuid], indexed === account else { return false }
}
return true
}
}
#endif
}
Loading
Loading