fix(core): recover unrecoverable device identity

This commit is contained in:
2026-08-13 20:49:41 +02:00
parent bece2af179
commit 0ea9a8e49c
25 changed files with 739 additions and 36 deletions

View File

@@ -68,7 +68,13 @@ struct RootView: View {
// A small, unobtrusive indicator while the core finishes its async
// startup otherwise the lists look empty and the app feels stalled.
if !sendModel.coreState.isInitialized {
CoreStartingOverlay()
CoreStartingOverlay(
recovery: appModel.startupRecovery,
isResettingIdentity: appModel.isResettingIdentity,
onResetIdentity: {
Task { await appModel.resetUnrecoverableIdentity() }
}
)
}
}
.animation(.easeInOut(duration: 0.25), value: sendModel.coreState.isInitialized)
@@ -255,19 +261,59 @@ private struct ApprovalLayer: View {
/// A full-window cover with a centered spinner shown while the core is starting.
private struct CoreStartingOverlay: View {
let recovery: AppStartupRecovery?
let isResettingIdentity: Bool
let onResetIdentity: () -> Void
@State private var confirmsIdentityReset = false
var body: some View {
ZStack {
backgroundColor.ignoresSafeArea()
VStack(spacing: 16) {
ProgressView().controlSize(.large)
Text(String(localized: L10n.App.starting))
.font(.headline)
.foregroundStyle(.secondary)
if recovery == .identityUnrecoverable {
VStack(spacing: 18) {
Image(systemSymbol: .exclamationmarkTriangleFill)
.font(.system(size: 44))
.foregroundStyle(.orange)
Text(String(localized: L10n.App.identityResetTitle))
.font(.title2.bold())
Text(String(localized: L10n.App.identityResetMessage))
.multilineTextAlignment(.center)
.foregroundStyle(.secondary)
.frame(maxWidth: 420)
Button(role: .destructive) {
confirmsIdentityReset = true
} label: {
if isResettingIdentity {
ProgressView()
} else {
Text(String(localized: L10n.App.identityResetAction))
}
}
.buttonStyle(.borderedProminent)
.disabled(isResettingIdentity)
}
.padding(32)
} else {
VStack(spacing: 16) {
ProgressView().controlSize(.large)
Text(String(localized: L10n.App.starting))
.font(.headline)
.foregroundStyle(.secondary)
}
}
}
.transition(.opacity)
.accessibilityElement(children: .combine)
.accessibilityLabel(Text(String(localized: L10n.App.starting)))
.alert(
String(localized: L10n.App.identityResetTitle),
isPresented: $confirmsIdentityReset
) {
Button(String(localized: L10n.Button.cancel), role: .cancel) {}
Button(String(localized: L10n.App.identityResetAction), role: .destructive) {
onResetIdentity()
}
} message: {
Text(String(localized: L10n.App.identityResetConfirmation))
}
}
private var backgroundColor: Color {

View File

@@ -25,6 +25,7 @@ protocol CoreGateway: AnyObject {
var signals: AnyPublisher<CoreSignal, Never> { get }
func initialize(appDataDir: String, networkConfiguration: RelayConfiguration) async -> Result<Void, Error>
func resetUnrecoverableIdentity(appDataDir: String, networkConfiguration: RelayConfiguration) async -> Result<Void, Error>
func shutdown()
func shareSources(
_ sources: [ShareSource],

View File

@@ -28,36 +28,50 @@ protocol CoreBindingFactory: Sendable {
eventSink: CoreEventSink,
networkConfiguration: RelayConfiguration
) throws -> VnidropCore
func resetUnrecoverableIdentity(
appDataDir: String,
eventSink: CoreEventSink,
networkConfiguration: RelayConfiguration
) throws -> VnidropCore
}
struct NativeCoreBindingFactory: CoreBindingFactory {
private func nativeConfiguration(_ networkConfiguration: RelayConfiguration) -> CoreNetworkConfig {
switch networkConfiguration.mode {
case .automatic:
return defaultCoreNetworkConfig()
case .strictCustom:
return CoreNetworkConfig(mode: .strictCustom, relayUrls: networkConfiguration.relayURLs)
case .customWithDirectFallback:
return CoreNetworkConfig(mode: .customWithDirectFallback, relayUrls: networkConfiguration.relayURLs)
case .localOnly:
return CoreNetworkConfig(mode: .localOnly, relayUrls: [])
}
}
func initialize(
appDataDir: String,
eventSink: CoreEventSink,
networkConfiguration: RelayConfiguration
) throws -> VnidropCore {
let nativeConfiguration: CoreNetworkConfig
switch networkConfiguration.mode {
case .automatic:
nativeConfiguration = defaultCoreNetworkConfig()
case .strictCustom:
nativeConfiguration = CoreNetworkConfig(
mode: .strictCustom,
relayUrls: networkConfiguration.relayURLs
)
case .customWithDirectFallback:
nativeConfiguration = CoreNetworkConfig(
mode: .customWithDirectFallback,
relayUrls: networkConfiguration.relayURLs
)
case .localOnly:
nativeConfiguration = CoreNetworkConfig(mode: .localOnly, relayUrls: [])
}
return try VnidropCore.initializeWithLimitsAndNetworkConfig(
appDataDir: appDataDir,
eventSink: eventSink,
limits: defaultCoreLimits(),
networkConfig: nativeConfiguration
networkConfig: nativeConfiguration(networkConfiguration)
)
}
func resetUnrecoverableIdentity(
appDataDir: String,
eventSink: CoreEventSink,
networkConfiguration: RelayConfiguration
) throws -> VnidropCore {
try VnidropCore.resetUnrecoverableIdentityWithLimitsAndNetworkConfig(
appDataDir: appDataDir,
eventSink: eventSink,
limits: defaultCoreLimits(),
networkConfig: nativeConfiguration(networkConfiguration)
)
}
}
@@ -139,6 +153,35 @@ final class CoreRepository: ObservableObject, CoreGateway {
}
}
func resetUnrecoverableIdentity(
appDataDir: String,
networkConfiguration: RelayConfiguration
) async -> Result<Void, Error> {
guard !isNetworkTransitionInProgress else {
return .failure(CoreNetworkLifecycleError.transitionInProgress)
}
isNetworkTransitionInProgress = true
defer { isNetworkTransitionInProgress = false }
let result = await runCore { [sink] in
let created = try self.coreFactory.resetUnrecoverableIdentity(
appDataDir: appDataDir,
eventSink: sink,
networkConfiguration: networkConfiguration
)
self.core = created
return created
}
switch result {
case .success:
refreshSnapshot()
state.isInitialized = true
return .success(())
case .failure(let error):
state = CoreState()
return .failure(error)
}
}
func shutdown() {
core?.shutdown()
core = nil
@@ -522,4 +565,3 @@ private extension ReceiverRequest {

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.
@@ -7,10 +12,14 @@ import Combine
final class AppModel: ObservableObject {
@Published private(set) var destination: AppDestination = .send
@Published private(set) var themeMode: ThemeMode = .system
@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 appDataDir: String
private let networkConfiguration: RelayConfiguration
private var cancellables = Set<AnyCancellable>()
init(
@@ -22,15 +31,23 @@ final class AppModel: ObservableObject {
self.environment = environment
self.repository = repository
self.messages = messages
self.appDataDir = environment.defaultCoreDataDir
self.networkConfiguration = preferences.preferences.relayConfiguration
AppLogger.info("lifecycle", "app started", ["platform": environment.name])
Task {
let result = await repository.initialize(
appDataDir: environment.defaultCoreDataDir,
networkConfiguration: preferences.preferences.relayConfiguration
appDataDir: appDataDir,
networkConfiguration: networkConfiguration
)
if case .failure(let error) = result { messages.error(error) }
if case .failure(let error) = result {
if error.hasUnrecoverableEndpointIdentity {
startupRecovery = .identityUnrecoverable
} else {
messages.error(error)
}
}
}
preferences.$preferences
@@ -44,4 +61,30 @@ 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
case .failure(let 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
}
}
}

View File

@@ -107,7 +107,9 @@ extension Error {
var canRetryWithoutChangingInput: Bool {
guard let vni = self as? VnidropError else { return true }
switch vni {
case .FilesystemPermission, .DestinationExists, .InvalidInput: return false
case .FilesystemPermission, .DestinationExists, .InvalidInput,
.SecureStorageMissing, .SecureStorageCorrupted:
return false
default: return true
}
}