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
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
29 changes: 26 additions & 3 deletions Vault/Sources/VaultiOS/VaultRoot.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
29 changes: 19 additions & 10 deletions Vault/Sources/VaultiOS/Views/VaultMainScene.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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:))
}
}
}

Expand Down
49 changes: 49 additions & 0 deletions Vault/Sources/VaultiOS/Views/VaultStoreFailureView.swift
Original file line number Diff line number Diff line change
@@ -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")
}
}
}
}
}
41 changes: 41 additions & 0 deletions Vault/Tests/VaultiOSTests/VaultStoreFailureViewSnapshotTests.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.