refactor: extract AccountManager into AppState extension (Stage 4a)#43
Merged
Conversation
Stage 4a — penultimate sub-extraction in the AppState god-object split. Moves all account lifecycle + bunker-URI + persistence logic out of AppState.swift into a dedicated extension file. Cleanup follow-up to the petname-removal PR (#42) that simplified the surface first. Moves out of Clave/AppState.swift (16 methods + 1 MARK header): - var bunkerURI (computed) + bunkerURI(for:) (per-account) - loadAccounts (was private, now internal — called by loadState) - recoverAccountsFromKeychainIfNeeded (Stage 2 deferred — moves now) - cleanupOrphanLegacyKeychainEntry (Stage 2 deferred — moves now) - persistAccountsList (private, stays private — only inter-extension) - persistAccounts - persistCurrentAccountPubkey (private, stays private) - switchToAccount(pubkey:) - addAccount(nsec:) - generateAccount() - deleteAccount(pubkey:) - rotateBunkerSecret() - importKey / generateKey / deleteKey (legacy wrappers preserved) - refreshBunkerSecret() (no-op kept for source-compat) - "// MARK: - Multi-account methods (Task 5)" header Into the new file: - Clave/AppState+AccountManager.swift (312 LOC) — extension AppState What stays in AppState.swift: - @observable stored properties (accounts, currentAccount, bunkerSecretsTick, etc.) — Swift forbids stored properties in extensions. Pointer comment added at the multi-account state block. - init() with NotificationCenter observers - loadState() coordinator (calls into the extension via cross-file extension dispatch — works because all are extension AppState on the same final class in the same module) One AppState.swift access change required: - bunkerSecretsTick was `private(set) var` (file-scope setter). rotateBunkerSecret in the extension needs to mutate it across files, so dropped `private(set)` to internal-set. Same constraint pattern as Stage 3b's persistAccounts widen. Cross-extension calls all resolve correctly: - addAccount calls registerWithProxy (Stage 3c) + fetchProfileIfNeeded (Stage 3b) — works - deleteAccount calls unregisterWithProxy + unpairClientWithProxy (Stage 3c) + cachedImageURL (Stage 3b) — works - switchToAccount calls loadCachedProfileImage + fetchProfileIfNeeded (Stage 3b) — works Zero behavior change: - Function bodies preserved byte-for-byte - All `private` modifiers preserved on persistAccountsList + persistCurrentAccountPubkey (only called within extension file — same-file private access) - Modifiers loosened only where strictly required by cross-file calls (loadAccounts, recoverAccountsFromKeychainIfNeeded, cleanupOrphanLegacyKeychainEntry: private -> internal because loadState in AppState.swift calls them) Test impact: zero changes. - AppStateMultiAccountTests calls appState.<method> via extension dispatch — works unchanged. External callers unchanged: - ClaveApp + 7 view files call appState.<method> — extension dispatch. No pbxproj edits — Clave/ directory is auto-synced. Verification: - xcodebuild test -skip-testing:ClaveUITests on iPhone 17 / iOS 26.4: - Pre-baseline (main @ f51bc18): 231 passed / 0 failed - Post-extraction (this branch): 231 passed / 0 failed - ** TEST SUCCEEDED ** in both runs AppState.swift: 893 -> 611 LOC (-282, -31.6%). Combined Stages 1+2+3+petname+4a: 2,338 -> 611 LOC (-73.9% from sprint start). Stage 4b (PendingApprovalCoordinator, ~290 LOC) is the final extraction. After it lands, AppState should reach the BACKLOG sketched ~300 LOC slim target. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
3 tasks
DocNR
added a commit
that referenced
this pull request
May 9, 2026
Internal-only build bundling three merges since build 69 was tagged: - Sprint 5a (#41): bunker pair-cap bypass fix - Petname removal (#42): -108 LOC dead code - Stage 4a (#43): AccountManager extension, -282 AppState LOC Bumps CURRENT_PROJECT_VERSION 69 -> 70 across 4 targets x 2 configs. Cumulative AppState refactor reduction: 2,338 -> 611 LOC (-73.9%). Stage 4b (PendingApprovalCoordinator) is the final refactor PR. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
3 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Stage 4a — penultimate sub-extraction in the AppState god-object split. Moves all account lifecycle + bunker-URI + persistence logic out of
AppState.swiftinto a dedicated extension file. Cleanup follow-up to the petname removal PR (#42) that simplified the surface first.Numbers
Clave/AppState.swiftClave/AppState+AccountManager.swiftCombined Stages 1+2+3+petname+4a: AppState.swift down from 2,338 → 611 LOC (−73.9%).
What moved (16 methods + 1 MARK)
var bunkerURI(computed) +bunkerURI(for:)(per-account)loadAccounts— wasprivate, nowinternal(called byloadStatecross-file)recoverAccountsFromKeychainIfNeeded— Stage 2 deferred decision; moves nowcleanupOrphanLegacyKeychainEntry— Stage 2 deferred; moves nowpersistAccountsList—private, only inter-extension callspersistAccountspersistCurrentAccountPubkey—private, only inter-extension callsswitchToAccount(pubkey:)addAccount(nsec:)generateAccount()deleteAccount(pubkey:)rotateBunkerSecret()importKey/generateKey/deleteKeylegacy wrappersrefreshBunkerSecret()(no-op kept for source-compat)// MARK: - Multi-account methods (Task 5)headerWhat stays in AppState.swift
@Observablestored properties (accounts,currentAccount,bunkerSecretsTick, etc.) — Swift forbids stored properties in extensions. Pointer comment added.init()with NotificationCenter observersloadState()coordinator — calls into the extension via cross-file extension dispatchOne AppState.swift access change
bunkerSecretsTickwasprivate(set) var(file-scope setter).rotateBunkerSecretin the extension needs to mutate it cross-file, so droppedprivate(set)to internal-set. Same constraint pattern as Stage 3b'spersistAccountswiden.Cross-extension calls all resolve correctly
addAccount→registerWithProxy(Stage 3c) +fetchProfileIfNeeded(Stage 3b) ✓deleteAccount→unregisterWithProxy+unpairClientWithProxy(Stage 3c) +cachedImageURL(Stage 3b) ✓switchToAccount→loadCachedProfileImage+fetchProfileIfNeeded(Stage 3b) ✓Zero behavior change
privatepreserved onpersistAccountsList+persistCurrentAccountPubkey(same-file inter-extension calls only)private→internal)Test impact: zero
AppStateMultiAccountTestscallsappState.<method>via extension dispatch — works unchangedAccountModelTestsunaffected (Account struct unchanged from petname PR baseline)Test plan
main(f51bc18): 231 passed / 0 failed ✅** TEST SUCCEEDED **in both runs (no test count change)loadStateexercises moved methods (loadAccounts,recoverAccountsFromKeychainIfNeeded,cleanupOrphanLegacyKeychainEntry)addAccount+ cross-extension calls fireswitchToAccount+ profile reloadappState.bunkerURIcross-fileStage 4b
PendingApprovalCoordinator(~290 LOC) is the final extraction. After it lands, AppState should reach the BACKLOG-sketched ~300 LOC slim target — ~−87% from the original 2,338.🤖 Generated with Claude Code