diff --git a/Vault/Sources/VaultFeed/Storage/VaultStore/SwiftData/PersistedLocalVaultStoreFactory.swift b/Vault/Sources/VaultFeed/Storage/VaultStore/SwiftData/PersistedLocalVaultStoreFactory.swift index e84dd2a7e..adbc7dc03 100644 --- a/Vault/Sources/VaultFeed/Storage/VaultStore/SwiftData/PersistedLocalVaultStoreFactory.swift +++ b/Vault/Sources/VaultFeed/Storage/VaultStore/SwiftData/PersistedLocalVaultStoreFactory.swift @@ -122,6 +122,21 @@ public final class PersistedLocalVaultStoreFactory { } } +extension PersistedLocalVaultStore { + /// An empty in-memory store, for use as a safe fallback when the + /// on-disk store cannot be opened: it keeps every consumer of the + /// composition graph valid (app, autofill extension, rehash + /// services) while guaranteeing nothing is written to the broken + /// on-disk store. + public static func inMemory() throws -> PersistedLocalVaultStore { + let container = try ModelContainer( + for: PersistedVaultItem.self, PersistedVaultTag.self, + configurations: ModelConfiguration(isStoredInMemoryOnly: true), + ) + return PersistedLocalVaultStore(modelContainer: container) + } +} + protocol PersistedLocalVaultStoreOpening { func open(storeURL: URL) throws -> PersistedLocalVaultStore } diff --git a/Vault/Sources/VaultiOS/VaultRoot.swift b/Vault/Sources/VaultiOS/VaultRoot.swift index e5cbd3eea..2737acbbe 100644 --- a/Vault/Sources/VaultiOS/VaultRoot.swift +++ b/Vault/Sources/VaultiOS/VaultRoot.swift @@ -38,10 +38,33 @@ public enum VaultRoot { @MainActor static let vaultStorageDirectory: URL = VaultSharedStorage.directory(fileManager: fileManager) + /// Non-nil when the on-disk vault store could not be opened (even + /// after recovery) and `vaultStore` is an empty in-memory fallback. + /// The main scene checks this before wiring `setup()` and shows a + /// failure screen instead of the vault. @MainActor - public static let vaultStore: PersistedLocalVaultStore = - PersistedLocalVaultStoreFactory(storageDirectory: vaultStorageDirectory) - .makeVaultStore() + public private(set) static var vaultStoreLoadFailureMessage: String? + + @MainActor + public static let vaultStore: PersistedLocalVaultStore = { + do { + return try PersistedLocalVaultStoreFactory(storageDirectory: vaultStorageDirectory) + .makeVaultStoreOrThrow() + } catch { + // Fall back to an empty in-memory store instead of crashing at + // launch: the composition graph stays valid for every consumer + // and the scene shows a failure screen. The failed store files + // were archived beside the store by the factory's recovery. + vaultStoreLoadFailureMessage = error.localizedDescription + do { + return try .inMemory() + } catch { + // In-memory container creation has no external failure + // modes; if even this fails the process cannot run. + fatalError("Unable to create fallback in-memory store: \(error)") + } + } + }() public static let backupPasswordStore: some BackupPasswordStore = BackupPasswordStoreImpl(secureStorage: secureStorage) diff --git a/Vault/Sources/VaultiOS/Views/VaultMainScene.swift b/Vault/Sources/VaultiOS/Views/VaultMainScene.swift index dbcc9e98c..aa0646629 100644 --- a/Vault/Sources/VaultiOS/Views/VaultMainScene.swift +++ b/Vault/Sources/VaultiOS/Views/VaultMainScene.swift @@ -14,20 +14,29 @@ public struct VaultMainScene: Scene { @State private var injector: VaultInjector = VaultRoot.vaultInjector public init() { - VaultRoot.setup() + // Don't wire auto-backup and widget reloads when the store failed + // to open: the fallback store is empty, and backing it up would + // replace a good backup with an empty vault. + if VaultRoot.vaultStoreLoadFailureMessage == nil { + VaultRoot.setup() + } } public var body: some Scene { WindowGroup { - VaultMainNavigationView( - pasteboard: pasteboard, - localSettings: localSettings, - deviceAuthenticationService: deviceAuthenticationService, - vaultDataModel: vaultDataModel, - injector: injector, - ) - .installToast(position: .top) - .onOpenURL(perform: handle(url:)) + if let failureMessage = VaultRoot.vaultStoreLoadFailureMessage { + VaultStoreFailureView(message: failureMessage) + } else { + VaultMainNavigationView( + pasteboard: pasteboard, + localSettings: localSettings, + deviceAuthenticationService: deviceAuthenticationService, + vaultDataModel: vaultDataModel, + injector: injector, + ) + .installToast(position: .top) + .onOpenURL(perform: handle(url:)) + } } } diff --git a/Vault/Sources/VaultiOS/Views/VaultStoreFailureView.swift b/Vault/Sources/VaultiOS/Views/VaultStoreFailureView.swift new file mode 100644 index 000000000..413858fb9 --- /dev/null +++ b/Vault/Sources/VaultiOS/Views/VaultStoreFailureView.swift @@ -0,0 +1,49 @@ +import Foundation +import SwiftUI + +/// Shown instead of the vault when the on-disk store could not be opened, +/// replacing what was previously a hard crash at launch. +/// +/// Deliberately static: no retry that could write to the broken store, no +/// destructive "start fresh" action (that needs its own design — see +/// MANIFESTO C6), and no diagnostics upload (C3). +struct VaultStoreFailureView: View { + let message: String? + + var body: some View { + Form { + Section { + PlaceholderView( + systemIcon: "externaldrive.trianglebadge.exclamationmark", + title: "Vault Unavailable", + subtitle: "The vault's data store could not be opened.", + ) + .padding() + .containerRelativeFrame(.horizontal) + .foregroundStyle(.red) + } + + Section { + Text( + "Your data was not deleted. The unreadable store files were moved aside, next to the store, so they can be inspected or recovered later.", + ) + Text( + "Quit and relaunch the app to try again. If this keeps happening, reinstall the app and restore from a backup PDF.", + ) + } header: { + Text("What Now") + } + + if let message { + Section { + Text(message) + .font(.caption) + .fontDesign(.monospaced) + .foregroundStyle(.secondary) + } header: { + Text("Details") + } + } + } + } +} diff --git a/Vault/Tests/VaultiOSTests/VaultStoreFailureViewSnapshotTests.swift b/Vault/Tests/VaultiOSTests/VaultStoreFailureViewSnapshotTests.swift new file mode 100644 index 000000000..ded29c06c --- /dev/null +++ b/Vault/Tests/VaultiOSTests/VaultStoreFailureViewSnapshotTests.swift @@ -0,0 +1,41 @@ +import Foundation +import SwiftUI +import TestHelpers +import Testing +@testable import VaultiOS + +@MainActor +final class VaultStoreFailureViewSnapshotTests { + @Test + func layout() { + let colorSchemes: [ColorScheme] = [.light, .dark] + let dynamicTypeSizes: [DynamicTypeSize] = [.xSmall, .medium, .xxLarge] + for colorScheme in colorSchemes { + for dynamicTypeSize in dynamicTypeSizes { + let snapshottingView = VaultStoreFailureView( + message: "Unable to connect to PersistedLocalVaultStore", + ) + .dynamicTypeSize(dynamicTypeSize) + .preferredColorScheme(colorScheme) + .framedForTest() + let named = "\(colorScheme)_\(dynamicTypeSize)" + + assertSnapshot( + of: snapshottingView, + as: .image, + named: named, + ) + } + } + } + + @Test + func layoutWithoutDetails() { + let snapshottingView = VaultStoreFailureView(message: nil) + .dynamicTypeSize(.medium) + .preferredColorScheme(.light) + .framedForTest() + + assertSnapshot(of: snapshottingView, as: .image) + } +} diff --git a/Vault/Tests/VaultiOSTests/__Snapshots__/VaultStoreFailureViewSnapshotTests/layout.dark_medium.png b/Vault/Tests/VaultiOSTests/__Snapshots__/VaultStoreFailureViewSnapshotTests/layout.dark_medium.png new file mode 100644 index 000000000..6e926942a Binary files /dev/null and b/Vault/Tests/VaultiOSTests/__Snapshots__/VaultStoreFailureViewSnapshotTests/layout.dark_medium.png differ diff --git a/Vault/Tests/VaultiOSTests/__Snapshots__/VaultStoreFailureViewSnapshotTests/layout.dark_xSmall.png b/Vault/Tests/VaultiOSTests/__Snapshots__/VaultStoreFailureViewSnapshotTests/layout.dark_xSmall.png new file mode 100644 index 000000000..35596746a Binary files /dev/null and b/Vault/Tests/VaultiOSTests/__Snapshots__/VaultStoreFailureViewSnapshotTests/layout.dark_xSmall.png differ diff --git a/Vault/Tests/VaultiOSTests/__Snapshots__/VaultStoreFailureViewSnapshotTests/layout.dark_xxLarge.png b/Vault/Tests/VaultiOSTests/__Snapshots__/VaultStoreFailureViewSnapshotTests/layout.dark_xxLarge.png new file mode 100644 index 000000000..816fa0e5f Binary files /dev/null and b/Vault/Tests/VaultiOSTests/__Snapshots__/VaultStoreFailureViewSnapshotTests/layout.dark_xxLarge.png differ diff --git a/Vault/Tests/VaultiOSTests/__Snapshots__/VaultStoreFailureViewSnapshotTests/layout.light_medium.png b/Vault/Tests/VaultiOSTests/__Snapshots__/VaultStoreFailureViewSnapshotTests/layout.light_medium.png new file mode 100644 index 000000000..6e926942a Binary files /dev/null and b/Vault/Tests/VaultiOSTests/__Snapshots__/VaultStoreFailureViewSnapshotTests/layout.light_medium.png differ diff --git a/Vault/Tests/VaultiOSTests/__Snapshots__/VaultStoreFailureViewSnapshotTests/layout.light_xSmall.png b/Vault/Tests/VaultiOSTests/__Snapshots__/VaultStoreFailureViewSnapshotTests/layout.light_xSmall.png new file mode 100644 index 000000000..35596746a Binary files /dev/null and b/Vault/Tests/VaultiOSTests/__Snapshots__/VaultStoreFailureViewSnapshotTests/layout.light_xSmall.png differ diff --git a/Vault/Tests/VaultiOSTests/__Snapshots__/VaultStoreFailureViewSnapshotTests/layout.light_xxLarge.png b/Vault/Tests/VaultiOSTests/__Snapshots__/VaultStoreFailureViewSnapshotTests/layout.light_xxLarge.png new file mode 100644 index 000000000..816fa0e5f Binary files /dev/null and b/Vault/Tests/VaultiOSTests/__Snapshots__/VaultStoreFailureViewSnapshotTests/layout.light_xxLarge.png differ diff --git a/Vault/Tests/VaultiOSTests/__Snapshots__/VaultStoreFailureViewSnapshotTests/layoutWithoutDetails.1.png b/Vault/Tests/VaultiOSTests/__Snapshots__/VaultStoreFailureViewSnapshotTests/layoutWithoutDetails.1.png new file mode 100644 index 000000000..0dbb6e17f Binary files /dev/null and b/Vault/Tests/VaultiOSTests/__Snapshots__/VaultStoreFailureViewSnapshotTests/layoutWithoutDetails.1.png differ