mirror of
https://github.com/sudosylabs/vnidrop.git
synced 2026-08-14 14:19:57 +02:00
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:
@@ -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 {
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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 {
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user