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

@@ -96,7 +96,12 @@ struct RootView: View {
CoreStartingOverlay(
error: appModel.startupError,
detail: appModel.startupErrorDetail,
onRetry: appModel.retryStartup
onRetry: appModel.retryStartup,
recovery: appModel.startupRecovery,
isResettingIdentity: appModel.isResettingIdentity,
onResetIdentity: {
Task { await appModel.resetUnrecoverableIdentity() }
}
)
}
}
@@ -293,11 +298,41 @@ private struct CoreStartingOverlay: View {
/// Debug builds only; nil in Release.
let detail: String?
let onRetry: () -> Void
let recovery: AppStartupRecovery?
let isResettingIdentity: Bool
let onResetIdentity: () -> Void
@State private var confirmsIdentityReset = false
var body: some View {
ZStack {
backgroundColor.ignoresSafeArea()
if let error {
// A repairable identity comes first: it is the one failure the user can
// actually act on, and its own copy explains the consequences.
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 if let error {
VStack(spacing: 16) {
Image(systemSymbol: .exclamationmarkTriangleFill)
.font(.system(size: 34))
@@ -341,8 +376,28 @@ private struct CoreStartingOverlay: View {
}
}
.transition(.opacity)
.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))
}
.accessibilityElement(children: .combine)
.accessibilityLabel(Text(error?.resolved() ?? String(localized: L10n.App.starting)))
.accessibilityLabel(Text(accessibilityLabel))
}
/// Mirrors the three visual states, so VoiceOver never announces "Starting"
/// over a screen that has actually stopped and is asking for a decision.
private var accessibilityLabel: String {
if recovery == .identityUnrecoverable {
return String(localized: L10n.App.identityResetTitle)
}
return error?.resolved() ?? String(localized: L10n.App.starting)
}
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
@@ -763,4 +806,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.
@@ -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
}
}
}

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
}
}