Skip to content

refactor: extract AccountManager into AppState extension (Stage 4a)#43

Merged
DocNR merged 1 commit into
mainfrom
refactor/account-manager-extension
May 9, 2026
Merged

refactor: extract AccountManager into AppState extension (Stage 4a)#43
DocNR merged 1 commit into
mainfrom
refactor/account-manager-extension

Conversation

@DocNR
Copy link
Copy Markdown
Owner

@DocNR DocNR commented May 9, 2026

Summary

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.

Numbers

File Before After Δ
Clave/AppState.swift 893 LOC 611 LOC −282 (−31.6%)
Clave/AppState+AccountManager.swift 312 LOC +312 (new)

Combined 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 — was private, now internal (called by loadState cross-file)
  • recoverAccountsFromKeychainIfNeeded — Stage 2 deferred decision; moves now
  • cleanupOrphanLegacyKeychainEntry — Stage 2 deferred; moves now
  • persistAccountsListprivate, only inter-extension calls
  • persistAccounts
  • persistCurrentAccountPubkeyprivate, only inter-extension calls
  • switchToAccount(pubkey:)
  • addAccount(nsec:)
  • generateAccount()
  • deleteAccount(pubkey:)
  • rotateBunkerSecret()
  • importKey / generateKey / deleteKey legacy wrappers
  • refreshBunkerSecret() (no-op kept for source-compat)
  • // MARK: - Multi-account methods (Task 5) header

What stays in AppState.swift

  • @Observable stored properties (accounts, currentAccount, bunkerSecretsTick, etc.) — Swift forbids stored properties in extensions. Pointer comment added.
  • init() with NotificationCenter observers
  • loadState() coordinator — calls into the extension via cross-file extension dispatch

One AppState.swift access change

bunkerSecretsTick was private(set) var (file-scope setter). rotateBunkerSecret in the extension needs to mutate it cross-file, so dropped private(set) to internal-set. Same constraint pattern as Stage 3b's persistAccounts widen.

Cross-extension calls all resolve correctly

  • addAccountregisterWithProxy (Stage 3c) + fetchProfileIfNeeded (Stage 3b) ✓
  • deleteAccountunregisterWithProxy + unpairClientWithProxy (Stage 3c) + cachedImageURL (Stage 3b) ✓
  • switchToAccountloadCachedProfileImage + fetchProfileIfNeeded (Stage 3b) ✓

Zero behavior change

  • Function bodies preserved byte-for-byte
  • private preserved on persistAccountsList + persistCurrentAccountPubkey (same-file inter-extension calls only)
  • Modifiers loosened only where required by cross-file calls (loadAccounts, recoverAccountsFromKeychainIfNeeded, cleanupOrphanLegacyKeychainEntry: privateinternal)

Test impact: zero

  • AppStateMultiAccountTests calls appState.<method> via extension dispatch — works unchanged
  • AccountModelTests unaffected (Account struct unchanged from petname PR baseline)

Test plan

  • Pre-baseline on main (f51bc18): 231 passed / 0 failed
  • Post-extraction on branch: 231 passed / 0 failed
  • ** TEST SUCCEEDED ** in both runs (no test count change)
  • After merge: bump pbxproj 69 → 70 (covers sprint 5a + petname removal + this Stage 4a all bundled). Archive build 70 + on-device verify of account flows:
    • Cold launch → loadState exercises moved methods (loadAccounts, recoverAccountsFromKeychainIfNeeded, cleanupOrphanLegacyKeychainEntry)
    • Add a new account → addAccount + cross-extension calls fire
    • Switch accounts → switchToAccount + profile reload
    • Delete an account → ordered cleanup (Bug D fix preserved)
    • Bunker URI display in ConnectBunkerTabView reads appState.bunkerURI cross-file

Stage 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

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>
@DocNR DocNR merged commit cdb72f7 into main May 9, 2026
@DocNR DocNR deleted the refactor/account-manager-extension branch May 9, 2026 03:04
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant