Merge remote-tracking branch 'origin/feat/device-history' into feat/device-history-apple

# Conflicts:
#	apple/VniDrop/App/RootView.swift
#	apple/VniDrop/Features/App/AppModel.swift
This commit is contained in:
2026-08-13 22:31:38 +02:00
25 changed files with 748 additions and 32 deletions

View File

@@ -1,5 +1,10 @@
import Foundation
import Combine
import VnidropCore
enum AppStartupRecovery: Equatable {
case identityUnrecoverable
}
/// Top-level app state, ported from `feature/app/AppViewModel.kt`. Initializes the
/// core on launch and tracks the selected destination + theme.
@@ -10,16 +15,21 @@ final class AppModel: ObservableObject {
/// Why startup failed, or nil while it is still in progress or has succeeded.
/// The startup overlay covers the snackbar, so without this a failed
/// `initialize` was indistinguishable from an app that never finished loading.
/// Stays nil for failures `startupRecovery` can offer a repair for, so the
/// user is shown the repair rather than a dead end.
@Published private(set) var startupError: UiText?
/// Untranslated failure detail, kept for the debug overlay only. The friendly
/// message alone cannot distinguish a missing keychain item from a database
/// fault, which makes a startup failure undiagnosable on a real device.
@Published private(set) var startupErrorDetail: String?
@Published private(set) var startupRecovery: AppStartupRecovery?
@Published private(set) var isResettingIdentity = false
private let environment: PlatformEnvironment
private let repository: CoreGateway
private let messages: UiMessageController
private let relayConfiguration: RelayConfiguration
private let appDataDir: String
private let networkConfiguration: RelayConfiguration
private var cancellables = Set<AnyCancellable>()
init(
@@ -31,7 +41,8 @@ final class AppModel: ObservableObject {
self.environment = environment
self.repository = repository
self.messages = messages
self.relayConfiguration = preferences.preferences.relayConfiguration
self.appDataDir = environment.defaultCoreDataDir
self.networkConfiguration = preferences.preferences.relayConfiguration
AppLogger.info("lifecycle", "app started", ["platform": environment.name])
@@ -49,12 +60,19 @@ final class AppModel: ObservableObject {
func initializeCore() async {
startupError = nil
startupErrorDetail = nil
startupRecovery = nil
let result = await repository.initialize(
appDataDir: environment.defaultCoreDataDir,
networkConfiguration: relayConfiguration
appDataDir: appDataDir,
networkConfiguration: networkConfiguration
)
if case .failure(let error) = result {
AppLogger.error("lifecycle", "core initialization failed", error)
// A repairable identity gets the reset flow instead of a generic
// failure, which would offer only a retry that cannot succeed.
if error.hasUnrecoverableEndpointIdentity {
startupRecovery = .identityUnrecoverable
return
}
startupError = error.toUiText()
#if DEBUG
startupErrorDetail = error.technicalDetail
@@ -72,4 +90,33 @@ final class AppModel: ObservableObject {
guard destination != self.destination else { return }
self.destination = destination
}
func resetUnrecoverableIdentity() async {
guard startupRecovery == .identityUnrecoverable, !isResettingIdentity else { return }
isResettingIdentity = true
defer { isResettingIdentity = false }
let result = await repository.resetUnrecoverableIdentity(
appDataDir: appDataDir,
networkConfiguration: networkConfiguration
)
switch result {
case .success:
startupRecovery = nil
startupError = nil
startupErrorDetail = nil
case .failure(let error):
AppLogger.error("lifecycle", "identity reset failed", error)
messages.error(error)
}
}
}
private extension Error {
var hasUnrecoverableEndpointIdentity: Bool {
guard let error = self as? VnidropError else { return false }
switch error {
case .SecureStorageMissing, .SecureStorageCorrupted: return true
default: return false
}
}
}