mirror of
https://github.com/sudosylabs/vnidrop.git
synced 2026-08-05 02:29:55 +02:00
feat(network): support custom relay servers
Add strict custom Iroh relay profiles with safe restart and rollback across the Rust core, Compose apps, and Apple apps. Preserve multi-relay invitations and fail closed on configuration or recovery mismatches.
This commit is contained in:
@@ -19,6 +19,95 @@ enum FolderAccessStatus {
|
||||
case unavailable
|
||||
}
|
||||
|
||||
enum RelayPreferenceMode: String, Codable, CaseIterable, Sendable {
|
||||
case automatic
|
||||
case custom
|
||||
}
|
||||
|
||||
struct RelayConfiguration: Equatable, Codable, Sendable {
|
||||
var mode: RelayPreferenceMode
|
||||
var relayURLs: [String]
|
||||
|
||||
static let automatic = RelayConfiguration(mode: .automatic, relayURLs: [])
|
||||
}
|
||||
|
||||
enum RelayConfigurationValidationError: Error, Equatable, Sendable {
|
||||
case missingURL
|
||||
case tooManyURLs
|
||||
case httpsRequired(index: Int)
|
||||
case invalidURL(index: Int)
|
||||
case duplicateURL(index: Int)
|
||||
|
||||
var urlIndex: Int? {
|
||||
switch self {
|
||||
case .httpsRequired(let index), .invalidURL(let index), .duplicateURL(let index): return index
|
||||
case .missingURL, .tooManyURLs: return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum RelayConfigurationValidator {
|
||||
static let maximumRelayCount = 8
|
||||
static let maximumRelayURLBytes = 2_048
|
||||
|
||||
static func validate(
|
||||
mode: RelayPreferenceMode,
|
||||
relayURLs: [String],
|
||||
retainedRelayURLs: [String] = []
|
||||
) throws -> RelayConfiguration {
|
||||
guard mode == .custom else {
|
||||
return RelayConfiguration(mode: .automatic, relayURLs: retainedRelayURLs)
|
||||
}
|
||||
|
||||
let relayEntries = relayURLs.enumerated().compactMap { index, value -> (Int, String)? in
|
||||
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return trimmed.isEmpty ? nil : (index, trimmed)
|
||||
}
|
||||
guard !relayEntries.isEmpty else {
|
||||
throw RelayConfigurationValidationError.missingURL
|
||||
}
|
||||
guard relayEntries.count <= maximumRelayCount else {
|
||||
throw RelayConfigurationValidationError.tooManyURLs
|
||||
}
|
||||
|
||||
var seen = Set<String>()
|
||||
var normalizedURLs: [String] = []
|
||||
for (index, relayURL) in relayEntries {
|
||||
guard relayURL.lengthOfBytes(using: .utf8) <= maximumRelayURLBytes,
|
||||
relayURL.rangeOfCharacter(from: .whitespacesAndNewlines.union(.controlCharacters)) == nil,
|
||||
var components = URLComponents(string: relayURL) else {
|
||||
throw RelayConfigurationValidationError.invalidURL(index: index)
|
||||
}
|
||||
guard components.scheme?.lowercased() == "https" else {
|
||||
throw RelayConfigurationValidationError.httpsRequired(index: index)
|
||||
}
|
||||
guard
|
||||
let host = components.host,
|
||||
!host.isEmpty,
|
||||
components.port.map({ (1...65_535).contains($0) }) ?? true,
|
||||
components.user == nil,
|
||||
components.password == nil,
|
||||
components.query == nil,
|
||||
components.fragment == nil,
|
||||
components.path.isEmpty || components.path == "/"
|
||||
else {
|
||||
throw RelayConfigurationValidationError.invalidURL(index: index)
|
||||
}
|
||||
|
||||
components.scheme = "https"
|
||||
components.host = host.lowercased()
|
||||
if components.port == 443 { components.port = nil }
|
||||
if components.path == "/" { components.path = "" }
|
||||
guard let canonicalURL = components.string, seen.insert(canonicalURL).inserted else {
|
||||
throw RelayConfigurationValidationError.duplicateURL(index: index)
|
||||
}
|
||||
normalizedURLs.append(canonicalURL)
|
||||
}
|
||||
|
||||
return RelayConfiguration(mode: .custom, relayURLs: normalizedURLs)
|
||||
}
|
||||
}
|
||||
|
||||
/// Persisted app preferences, ported from `preferences/AppPreferencesRepository.kt`.
|
||||
/// Backed by `UserDefaults` instead of DataStore; keys and semantics match.
|
||||
struct AppPreferences: Equatable {
|
||||
@@ -28,6 +117,7 @@ struct AppPreferences: Equatable {
|
||||
var notificationsEnabled: Bool
|
||||
var diagnosticsEnabled: Bool
|
||||
var diagnosticsInstallId: String
|
||||
var relayConfiguration: RelayConfiguration
|
||||
}
|
||||
|
||||
struct AppPreferencesDefaults {
|
||||
@@ -54,6 +144,7 @@ final class AppPreferencesRepository: ObservableObject {
|
||||
static let notificationsEnabled = "notifications_enabled"
|
||||
static let diagnosticsEnabled = "diagnostics_enabled"
|
||||
static let diagnosticsInstallId = "diagnostics_install_id"
|
||||
static let relayConfiguration = "relay_configuration"
|
||||
}
|
||||
|
||||
init(defaults: UserDefaults = .standard, fallback: AppPreferencesDefaults) {
|
||||
@@ -75,10 +166,25 @@ final class AppPreferencesRepository: ObservableObject {
|
||||
themeMode: themeMode,
|
||||
notificationsEnabled: notifications,
|
||||
diagnosticsEnabled: diagnostics,
|
||||
diagnosticsInstallId: installId
|
||||
diagnosticsInstallId: installId,
|
||||
relayConfiguration: resolveRelayConfiguration(defaults)
|
||||
)
|
||||
}
|
||||
|
||||
private static func resolveRelayConfiguration(_ defaults: UserDefaults) -> RelayConfiguration {
|
||||
guard defaults.object(forKey: Key.relayConfiguration) != nil else { return .automatic }
|
||||
guard
|
||||
let data = defaults.data(forKey: Key.relayConfiguration),
|
||||
let configuration = try? JSONDecoder().decode(RelayConfiguration.self, from: data)
|
||||
else {
|
||||
// A stored profile must never silently fall back to public relays. Keeping
|
||||
// Custom selected with no URLs makes startup fail closed and lets Settings
|
||||
// offer an explicit repair or reset to Automatic.
|
||||
return RelayConfiguration(mode: .custom, relayURLs: [])
|
||||
}
|
||||
return configuration
|
||||
}
|
||||
|
||||
private static func resolveReceiveFolder(_ defaults: UserDefaults, fallback: ReceiveFolder) -> ReceiveFolder {
|
||||
let kind = defaults.string(forKey: Key.receiveFolderKind)
|
||||
.flatMap(ReceiveFolderKind.init(rawValue:)) ?? fallback.kind
|
||||
@@ -123,6 +229,12 @@ final class AppPreferencesRepository: ObservableObject {
|
||||
reload()
|
||||
}
|
||||
|
||||
func setRelayConfiguration(_ configuration: RelayConfiguration) {
|
||||
guard let encoded = try? JSONEncoder().encode(configuration) else { return }
|
||||
defaults.set(encoded, forKey: Key.relayConfiguration)
|
||||
reload()
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func ensureDiagnosticsInstallId() -> String {
|
||||
let existing = preferences.diagnosticsInstallId
|
||||
|
||||
@@ -24,7 +24,7 @@ protocol CoreGateway: AnyObject {
|
||||
/// Coalesced change hints emitted by the event sink.
|
||||
var signals: AnyPublisher<CoreSignal, Never> { get }
|
||||
|
||||
func initialize(appDataDir: String) async -> Result<Void, Error>
|
||||
func initialize(appDataDir: String, networkConfiguration: RelayConfiguration) async -> Result<Void, Error>
|
||||
func shutdown()
|
||||
func shareSources(
|
||||
_ sources: [ShareSource],
|
||||
|
||||
@@ -2,6 +2,58 @@ import Foundation
|
||||
import Combine
|
||||
@preconcurrency import VnidropCore
|
||||
|
||||
enum CoreNetworkLifecycleError: Error, Equatable, LocalizedError, Sendable {
|
||||
case transitionInProgress
|
||||
case activeNetworkWork
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .transitionInProgress: return "A network restart is already in progress."
|
||||
case .activeNetworkWork: return "Stop active transfers and shares before restarting the network."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum CoreNetworkLifecycle {
|
||||
nonisolated static func requireIdle(activeTransfers: UInt64, activeShares: UInt64) throws {
|
||||
guard activeTransfers == 0, activeShares == 0 else {
|
||||
throw CoreNetworkLifecycleError.activeNetworkWork
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protocol CoreBindingFactory: Sendable {
|
||||
func initialize(
|
||||
appDataDir: String,
|
||||
eventSink: CoreEventSink,
|
||||
networkConfiguration: RelayConfiguration
|
||||
) throws -> VnidropCore
|
||||
}
|
||||
|
||||
struct NativeCoreBindingFactory: CoreBindingFactory {
|
||||
func initialize(
|
||||
appDataDir: String,
|
||||
eventSink: CoreEventSink,
|
||||
networkConfiguration: RelayConfiguration
|
||||
) throws -> VnidropCore {
|
||||
let nativeConfiguration: CoreNetworkConfig
|
||||
switch networkConfiguration.mode {
|
||||
case .automatic:
|
||||
nativeConfiguration = defaultCoreNetworkConfig()
|
||||
case .custom:
|
||||
nativeConfiguration = CoreNetworkConfig(
|
||||
mode: .custom,
|
||||
relayUrls: networkConfiguration.relayURLs
|
||||
)
|
||||
}
|
||||
return try VnidropCore.initializeWithNetworkConfig(
|
||||
appDataDir: appDataDir,
|
||||
eventSink: eventSink,
|
||||
networkConfig: nativeConfiguration
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Swift port of `core/CoreRepository.kt`. Owns the `VnidropCore` handle, maps the
|
||||
/// generated UniFFI records into app domain models, publishes an observable
|
||||
/// `CoreState`, and emits coalesced `CoreSignal`s from the event sink.
|
||||
@@ -17,28 +69,63 @@ final class CoreRepository: ObservableObject, CoreGateway {
|
||||
/// Coalesced change hints; subscribe to react to approval/history/transfer changes.
|
||||
var signals: AnyPublisher<CoreSignal, Never> { signalsSubject.eraseToAnyPublisher() }
|
||||
|
||||
// Set on the main actor (initialize/shutdown) but read from `queue` inside
|
||||
// `runCore`; the underlying core is internally synchronized, so this crossing
|
||||
// is safe. `nonisolated(unsafe)` documents that contract for Swift 6.
|
||||
// Initialization swaps happen on `queue`; shutdown and snapshot reads may also
|
||||
// access the handle from the main actor. The underlying core is internally
|
||||
// synchronized, and `nonisolated(unsafe)` documents that crossing for Swift 6.
|
||||
private nonisolated(unsafe) var core: VnidropCore?
|
||||
private let queue = DispatchQueue(label: "com.vnidrop.core", qos: .userInitiated)
|
||||
private let coreFactory: any CoreBindingFactory
|
||||
private var isNetworkTransitionInProgress = false
|
||||
private lazy var sink = RepositoryEventSink { [weak self] event in
|
||||
Task { @MainActor in self?.handle(event: event) }
|
||||
}
|
||||
|
||||
private nonisolated static let maxEvents = 200
|
||||
|
||||
init(coreFactory: any CoreBindingFactory = NativeCoreBindingFactory()) {
|
||||
self.coreFactory = coreFactory
|
||||
}
|
||||
|
||||
// MARK: - Lifecycle
|
||||
|
||||
func initialize(appDataDir: String) async -> Result<Void, Error> {
|
||||
await runCore { [sink] in
|
||||
self.core?.shutdown()
|
||||
let created = try VnidropCore.initialize(appDataDir: appDataDir, eventSink: sink)
|
||||
return created
|
||||
}.map { created in
|
||||
func initialize(
|
||||
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
|
||||
if let existing = self.core {
|
||||
let status = existing.status()
|
||||
try CoreNetworkLifecycle.requireIdle(
|
||||
activeTransfers: status.activeTransfers,
|
||||
activeShares: status.activeShares
|
||||
)
|
||||
existing.shutdown()
|
||||
self.core = nil
|
||||
}
|
||||
let created = try self.coreFactory.initialize(
|
||||
appDataDir: appDataDir,
|
||||
eventSink: sink,
|
||||
networkConfiguration: networkConfiguration
|
||||
)
|
||||
self.core = created
|
||||
return created
|
||||
}
|
||||
switch result {
|
||||
case .success:
|
||||
self.refreshSnapshot()
|
||||
self.state.isInitialized = true
|
||||
return .success(())
|
||||
case .failure(let error):
|
||||
if error as? CoreNetworkLifecycleError != .activeNetworkWork {
|
||||
self.state = CoreState()
|
||||
}
|
||||
return .failure(error)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,6 +143,9 @@ final class CoreRepository: ObservableObject, CoreGateway {
|
||||
senderName: String,
|
||||
accessPolicy: ShareAccessPolicy
|
||||
) async -> Result<Share, Error> {
|
||||
guard !isNetworkTransitionInProgress else {
|
||||
return .failure(CoreNetworkLifecycleError.transitionInProgress)
|
||||
}
|
||||
guard !sources.isEmpty else {
|
||||
return .failure(InvitationError.message("Select at least one file to share"))
|
||||
}
|
||||
@@ -89,7 +179,10 @@ final class CoreRepository: ObservableObject, CoreGateway {
|
||||
}
|
||||
|
||||
func receive(ticket: String, outputDir: String, receiverName: String) async -> Result<Void, Error> {
|
||||
await runCore {
|
||||
guard !isNetworkTransitionInProgress else {
|
||||
return .failure(CoreNetworkLifecycleError.transitionInProgress)
|
||||
}
|
||||
return await runCore {
|
||||
try self.requireCore().receive(
|
||||
ticket: ticket,
|
||||
outputDir: outputDir,
|
||||
@@ -105,7 +198,10 @@ final class CoreRepository: ObservableObject, CoreGateway {
|
||||
outputDirectoryUrl: String,
|
||||
receiverName: String
|
||||
) async -> Result<Void, Error> {
|
||||
await runCore {
|
||||
guard !isNetworkTransitionInProgress else {
|
||||
return .failure(CoreNetworkLifecycleError.transitionInProgress)
|
||||
}
|
||||
return await runCore {
|
||||
try withSecurityScopedAccess(pathOrUrl: outputDirectoryUrl) {
|
||||
try self.requireCore().receive(
|
||||
ticket: ticket,
|
||||
|
||||
@@ -26,7 +26,10 @@ final class AppModel: ObservableObject {
|
||||
AppLogger.info("lifecycle", "app started", ["platform": environment.name])
|
||||
|
||||
Task {
|
||||
let result = await repository.initialize(appDataDir: environment.defaultCoreDataDir)
|
||||
let result = await repository.initialize(
|
||||
appDataDir: environment.defaultCoreDataDir,
|
||||
networkConfiguration: preferences.preferences.relayConfiguration
|
||||
)
|
||||
if case .failure(let error) = result { messages.error(error) }
|
||||
}
|
||||
|
||||
|
||||
@@ -43,12 +43,14 @@ struct TransferDetailsView: View {
|
||||
count: pendingReceivers + completedReceivers,
|
||||
onTap: model.openReceivers
|
||||
)
|
||||
DetailDestination(
|
||||
title: String(localized: "transfer_share_title"),
|
||||
description: String(localized: "transfer_share_description"),
|
||||
count: 0,
|
||||
onTap: model.openShare
|
||||
)
|
||||
if transfer.invitationPresentation != .unavailable {
|
||||
DetailDestination(
|
||||
title: String(localized: "transfer_share_title"),
|
||||
description: String(localized: "transfer_share_description"),
|
||||
count: 0,
|
||||
onTap: model.openShare
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if isActiveShare {
|
||||
@@ -276,24 +278,41 @@ struct TransferSharePanel: View {
|
||||
|
||||
var body: some View {
|
||||
PanelContainer(title: String(localized: "transfer_share_title")) {
|
||||
if let ticket = transfer.ticket {
|
||||
qrCard(ticket: ticket)
|
||||
Text(LocalizedStringKey("transfer_scan_qr"))
|
||||
.font(VniType.bodySmall).foregroundStyle(colors.foregroundLighter)
|
||||
.frame(maxWidth: .infinity)
|
||||
switch transfer.invitationPresentation {
|
||||
case .ready(let ticket):
|
||||
let qrImage = QRCode.generate(from: ticket)
|
||||
qrCard(image: qrImage)
|
||||
if qrImage != nil {
|
||||
Text(LocalizedStringKey("transfer_scan_qr"))
|
||||
.font(VniType.bodySmall).foregroundStyle(colors.foregroundLighter)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
ShareActionsView(model: model, transfer: transfer, ticket: ticket)
|
||||
} else {
|
||||
case .preparing:
|
||||
Text(LocalizedStringKey("transfer_event_preparing")).foregroundStyle(colors.foregroundLighter)
|
||||
case .unavailable:
|
||||
Text(LocalizedStringKey(
|
||||
transfer.status == .failed ? "transfer_event_failed" : "transfer_event_stopped"
|
||||
))
|
||||
.foregroundStyle(colors.foregroundLighter)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func qrCard(ticket: String) -> some View {
|
||||
private func qrCard(image: Image?) -> some View {
|
||||
ZStack {
|
||||
if let qr = QRCode.generate(from: ticket) {
|
||||
qr.interpolation(.none).resizable().scaledToFit().padding(14)
|
||||
if let image {
|
||||
image.interpolation(.none).resizable().scaledToFit().padding(14)
|
||||
} else {
|
||||
ProgressView()
|
||||
VStack(spacing: 10) {
|
||||
Image(systemName: "qrcode")
|
||||
.font(.system(size: 36, weight: .medium))
|
||||
Text(LocalizedStringKey("transfer_qr_unavailable"))
|
||||
.font(VniType.bodySmall)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
.foregroundStyle(.black.opacity(0.72))
|
||||
.padding(22)
|
||||
}
|
||||
}
|
||||
.frame(width: 268, height: 268)
|
||||
@@ -302,6 +321,26 @@ struct TransferSharePanel: View {
|
||||
}
|
||||
}
|
||||
|
||||
enum TransferInvitationPresentation: Equatable {
|
||||
case preparing
|
||||
case ready(String)
|
||||
case unavailable
|
||||
}
|
||||
|
||||
extension Transfer {
|
||||
var invitationPresentation: TransferInvitationPresentation {
|
||||
switch status {
|
||||
case .importing:
|
||||
return .preparing
|
||||
case .sharing:
|
||||
guard let ticket, !ticket.isEmpty else { return .preparing }
|
||||
return .ready(ticket)
|
||||
case .receiving, .done, .failed, .cancelled, .stopped:
|
||||
return .unavailable
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - QR generation (CoreImage)
|
||||
|
||||
enum QRCode {
|
||||
|
||||
@@ -7,6 +7,7 @@ enum SettingsSection: Hashable {
|
||||
case preferences
|
||||
case appearance
|
||||
case notifications
|
||||
case network
|
||||
case storage
|
||||
case about
|
||||
case bugReport
|
||||
@@ -17,6 +18,7 @@ enum SettingsSection: Hashable {
|
||||
case .preferences: return "preferences_title"
|
||||
case .appearance: return "appearance_title"
|
||||
case .notifications: return "notifications_title"
|
||||
case .network: return "settings_network_title"
|
||||
case .storage: return "storage_title"
|
||||
case .about: return "about_title"
|
||||
case .bugReport: return "about_bug_report"
|
||||
@@ -44,6 +46,14 @@ struct SettingsState: Equatable {
|
||||
var notificationsEnabled = false
|
||||
var notificationPermission: NotificationPermission = .notDetermined
|
||||
var diagnosticsEnabled = false
|
||||
var relayMode: RelayPreferenceMode = .automatic
|
||||
var relayURLs: [String] = []
|
||||
var relayValidationError: RelayConfigurationValidationError?
|
||||
var relayConfigurationIsDirty = false
|
||||
var isApplyingRelayConfiguration = false
|
||||
var hasActiveNetworkWork = false
|
||||
var endpointId: String?
|
||||
var relayApplyErrorKey: String?
|
||||
var deviceInfo: DeviceInfo?
|
||||
var appVersion = ""
|
||||
var isLoadingDeviceInfo = false
|
||||
@@ -66,6 +76,13 @@ struct SettingsState: Equatable {
|
||||
&& lhs.themeMode == rhs.themeMode && lhs.notificationsEnabled == rhs.notificationsEnabled
|
||||
&& lhs.notificationPermission == rhs.notificationPermission
|
||||
&& lhs.diagnosticsEnabled == rhs.diagnosticsEnabled && lhs.appVersion == rhs.appVersion
|
||||
&& lhs.relayMode == rhs.relayMode && lhs.relayURLs == rhs.relayURLs
|
||||
&& lhs.relayValidationError == rhs.relayValidationError
|
||||
&& lhs.relayConfigurationIsDirty == rhs.relayConfigurationIsDirty
|
||||
&& lhs.isApplyingRelayConfiguration == rhs.isApplyingRelayConfiguration
|
||||
&& lhs.hasActiveNetworkWork == rhs.hasActiveNetworkWork
|
||||
&& lhs.endpointId == rhs.endpointId
|
||||
&& lhs.relayApplyErrorKey == rhs.relayApplyErrorKey
|
||||
&& lhs.isLoadingDeviceInfo == rhs.isLoadingDeviceInfo
|
||||
&& lhs.bugWhatHappened == rhs.bugWhatHappened && lhs.bugExpected == rhs.bugExpected
|
||||
&& lhs.bugSteps == rhs.bugSteps && lhs.bugContact == rhs.bugContact
|
||||
@@ -96,6 +113,7 @@ final class SettingsModel: ObservableObject {
|
||||
private var enableNotificationsAfterSettings = false
|
||||
private var usernamePersistTask: Task<Void, Never>?
|
||||
private var hasLocalUsernameDraft = false
|
||||
private var hasRelayConfigurationDraft = false
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
||||
init(
|
||||
@@ -133,10 +151,29 @@ final class SettingsModel: ObservableObject {
|
||||
self.state.themeMode = prefs.themeMode
|
||||
self.state.notificationsEnabled = prefs.notificationsEnabled
|
||||
self.state.diagnosticsEnabled = prefs.diagnosticsEnabled
|
||||
if !self.hasRelayConfigurationDraft {
|
||||
self.state.relayMode = prefs.relayConfiguration.mode
|
||||
self.state.relayURLs = prefs.relayConfiguration.relayURLs
|
||||
self.state.relayConfigurationIsDirty = false
|
||||
}
|
||||
if folder != previousFolder { Task { await self.validateFolder(folder) } }
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
repository.statePublisher
|
||||
.sink { [weak self] coreState in
|
||||
guard let self else { return }
|
||||
let hasActiveWork = (coreState.status?.activeTransfers ?? 0) > 0
|
||||
|| (coreState.status?.activeShares ?? 0) > 0
|
||||
|| coreState.transfers.contains(where: { $0.status.isActiveTransfer })
|
||||
self.state.hasActiveNetworkWork = hasActiveWork
|
||||
self.state.endpointId = coreState.status?.endpointId
|
||||
if !hasActiveWork && self.state.relayApplyErrorKey == "relay_apply_active_transfers" {
|
||||
self.state.relayApplyErrorKey = nil
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
refreshNotificationPermission()
|
||||
loadDeviceInfo()
|
||||
}
|
||||
@@ -206,6 +243,119 @@ final class SettingsModel: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Network
|
||||
|
||||
func setRelayMode(_ mode: RelayPreferenceMode) {
|
||||
hasRelayConfigurationDraft = true
|
||||
state.relayMode = mode
|
||||
if mode == .custom && state.relayURLs.isEmpty { state.relayURLs = [""] }
|
||||
updateRelayConfigurationDraft()
|
||||
}
|
||||
|
||||
func setRelayURL(_ value: String, at index: Int) {
|
||||
guard state.relayURLs.indices.contains(index) else { return }
|
||||
hasRelayConfigurationDraft = true
|
||||
state.relayURLs[index] = value
|
||||
updateRelayConfigurationDraft()
|
||||
}
|
||||
|
||||
func addRelayURL() {
|
||||
guard state.relayURLs.count < RelayConfigurationValidator.maximumRelayCount else { return }
|
||||
hasRelayConfigurationDraft = true
|
||||
state.relayURLs.append("")
|
||||
updateRelayConfigurationDraft()
|
||||
}
|
||||
|
||||
func removeRelayURL(at index: Int) {
|
||||
guard state.relayURLs.indices.contains(index) else { return }
|
||||
hasRelayConfigurationDraft = true
|
||||
state.relayURLs.remove(at: index)
|
||||
if state.relayURLs.isEmpty { state.relayURLs = [""] }
|
||||
updateRelayConfigurationDraft()
|
||||
}
|
||||
|
||||
func applyRelayConfiguration() {
|
||||
guard !state.isApplyingRelayConfiguration, state.relayConfigurationIsDirty else { return }
|
||||
|
||||
let configuration: RelayConfiguration
|
||||
do {
|
||||
configuration = try RelayConfigurationValidator.validate(
|
||||
mode: state.relayMode,
|
||||
relayURLs: state.relayURLs,
|
||||
retainedRelayURLs: preferences.preferences.relayConfiguration.relayURLs
|
||||
)
|
||||
} catch let error as RelayConfigurationValidationError {
|
||||
state.relayValidationError = error
|
||||
state.relayApplyErrorKey = nil
|
||||
return
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
let coreState = repository.state
|
||||
let hasActiveWork = (coreState.status?.activeTransfers ?? 0) > 0
|
||||
|| (coreState.status?.activeShares ?? 0) > 0
|
||||
|| coreState.transfers.contains(where: { $0.status.isActiveTransfer })
|
||||
guard !hasActiveWork else {
|
||||
state.hasActiveNetworkWork = true
|
||||
state.relayApplyErrorKey = "relay_apply_active_transfers"
|
||||
messages.show(UiMessage(text: .resource("relay_apply_active_transfers"), tone: .warning))
|
||||
return
|
||||
}
|
||||
|
||||
let previousConfiguration = preferences.preferences.relayConfiguration
|
||||
state.relayValidationError = nil
|
||||
state.relayApplyErrorKey = nil
|
||||
state.isApplyingRelayConfiguration = true
|
||||
Task {
|
||||
let applyResult = await repository.initialize(
|
||||
appDataDir: environment.defaultCoreDataDir,
|
||||
networkConfiguration: configuration
|
||||
)
|
||||
switch applyResult {
|
||||
case .success:
|
||||
hasRelayConfigurationDraft = false
|
||||
preferences.setRelayConfiguration(configuration)
|
||||
state.isApplyingRelayConfiguration = false
|
||||
state.relayConfigurationIsDirty = false
|
||||
messages.show(UiMessage(text: .resource("relay_settings_applied"), tone: .success))
|
||||
case .failure(let error):
|
||||
if let lifecycleError = error as? CoreNetworkLifecycleError {
|
||||
state.isApplyingRelayConfiguration = false
|
||||
switch lifecycleError {
|
||||
case .activeNetworkWork:
|
||||
state.hasActiveNetworkWork = true
|
||||
state.relayApplyErrorKey = "relay_apply_active_transfers"
|
||||
case .transitionInProgress:
|
||||
state.relayApplyErrorKey = "relay_apply_failed"
|
||||
}
|
||||
return
|
||||
}
|
||||
let rollbackResult = await repository.initialize(
|
||||
appDataDir: environment.defaultCoreDataDir,
|
||||
networkConfiguration: previousConfiguration
|
||||
)
|
||||
state.isApplyingRelayConfiguration = false
|
||||
if case .success = rollbackResult {
|
||||
state.relayApplyErrorKey = "relay_apply_failed"
|
||||
messages.show(UiMessage(text: .resource("relay_apply_failed"), tone: .error))
|
||||
} else {
|
||||
state.relayApplyErrorKey = "relay_restore_failed"
|
||||
messages.show(UiMessage(text: .resource("relay_restore_failed"), tone: .error))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func updateRelayConfigurationDraft() {
|
||||
state.relayValidationError = nil
|
||||
state.relayApplyErrorKey = nil
|
||||
let saved = preferences.preferences.relayConfiguration
|
||||
let draftURLs = state.relayMode == .automatic ? saved.relayURLs : state.relayURLs
|
||||
state.relayConfigurationIsDirty = saved != RelayConfiguration(mode: state.relayMode, relayURLs: draftURLs)
|
||||
hasRelayConfigurationDraft = state.relayConfigurationIsDirty
|
||||
}
|
||||
|
||||
func setBugWhatHappened(_ value: String) { state.bugWhatHappened = value }
|
||||
func setBugExpected(_ value: String) { state.bugExpected = value }
|
||||
func setBugSteps(_ value: String) { state.bugSteps = value }
|
||||
|
||||
@@ -38,6 +38,17 @@ struct SettingsScreen: View {
|
||||
NavigationLink(value: SettingsSection.storage) {
|
||||
SettingsRow(icon: "internaldrive", title: String(localized: "storage_title"), value: nil)
|
||||
}
|
||||
}
|
||||
Section(String(localized: "settings_advanced_title")) {
|
||||
NavigationLink(value: SettingsSection.network) {
|
||||
SettingsRow(
|
||||
icon: "network",
|
||||
title: String(localized: "settings_network_title"),
|
||||
value: relayModeLabel(model.state.relayMode)
|
||||
)
|
||||
}
|
||||
}
|
||||
Section {
|
||||
NavigationLink(value: SettingsSection.about) {
|
||||
SettingsRow(icon: "info.circle", title: String(localized: "about_title"), value: nil)
|
||||
}
|
||||
@@ -99,6 +110,8 @@ private struct SettingsSectionContent: View {
|
||||
AppearanceSettings(model: model)
|
||||
case .notifications:
|
||||
NotificationSettings(model: model)
|
||||
case .network:
|
||||
NetworkSettings(model: model)
|
||||
case .storage:
|
||||
StorageSettings(model: model)
|
||||
case .about:
|
||||
@@ -109,6 +122,13 @@ private struct SettingsSectionContent: View {
|
||||
}
|
||||
}
|
||||
|
||||
func relayModeLabel(_ mode: RelayPreferenceMode) -> String {
|
||||
switch mode {
|
||||
case .automatic: return String(localized: "relay_mode_automatic")
|
||||
case .custom: return String(localized: "relay_mode_custom")
|
||||
}
|
||||
}
|
||||
|
||||
struct SettingsRow: View {
|
||||
let icon: String
|
||||
let title: String
|
||||
|
||||
@@ -57,6 +57,178 @@ struct NotificationSettings: View {
|
||||
}
|
||||
}
|
||||
|
||||
struct NetworkSettings: View {
|
||||
@ObservedObject var model: SettingsModel
|
||||
|
||||
var body: some View {
|
||||
Section {
|
||||
Picker(
|
||||
String(localized: "settings_network_title"),
|
||||
selection: Binding(get: { model.state.relayMode }, set: { model.setRelayMode($0) })
|
||||
) {
|
||||
ForEach(RelayPreferenceMode.allCases, id: \.self) { mode in
|
||||
Text(relayModeLabel(mode)).tag(mode)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.labelsHidden()
|
||||
.disabled(model.state.isApplyingRelayConfiguration)
|
||||
} footer: {
|
||||
Text(LocalizedStringKey(
|
||||
model.state.relayMode == .automatic
|
||||
? "relay_mode_automatic_description"
|
||||
: "relay_mode_custom_description"
|
||||
))
|
||||
}
|
||||
|
||||
Section {
|
||||
Label {
|
||||
Text(LocalizedStringKey("relay_privacy_description"))
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
} icon: {
|
||||
Image(systemName: "lock.shield")
|
||||
}
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
if let endpointId = model.state.endpointId, !endpointId.isEmpty {
|
||||
Section {
|
||||
Text(String(format: String(localized: "approval_endpoint_id"), endpointId))
|
||||
.font(.footnote.monospaced())
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
}
|
||||
|
||||
if model.state.relayMode == .custom {
|
||||
Section {
|
||||
Label {
|
||||
Text(LocalizedStringKey("relay_strict_warning"))
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
} icon: {
|
||||
Image(systemName: "exclamationmark.shield.fill")
|
||||
}
|
||||
.foregroundStyle(.orange)
|
||||
|
||||
ForEach(Array(model.state.relayURLs.indices), id: \.self) { index in
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack {
|
||||
TextField(
|
||||
"https://relay.example.com",
|
||||
text: Binding(
|
||||
get: {
|
||||
model.state.relayURLs.indices.contains(index)
|
||||
? model.state.relayURLs[index]
|
||||
: ""
|
||||
},
|
||||
set: { model.setRelayURL($0, at: index) }
|
||||
)
|
||||
)
|
||||
#if os(iOS)
|
||||
.keyboardType(.URL)
|
||||
.textInputAutocapitalization(.never)
|
||||
#endif
|
||||
.autocorrectionDisabled()
|
||||
.disabled(model.state.isApplyingRelayConfiguration)
|
||||
|
||||
Button(role: .destructive) {
|
||||
model.removeRelayURL(at: index)
|
||||
} label: {
|
||||
Image(systemName: "minus.circle.fill")
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
.accessibilityLabel(Text(LocalizedStringKey("relay_remove_url")))
|
||||
.disabled(model.state.isApplyingRelayConfiguration)
|
||||
}
|
||||
|
||||
if let error = model.state.relayValidationError, error.urlIndex == index {
|
||||
Text(relayValidationMessage(error))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Button(action: model.addRelayURL) {
|
||||
Label(String(localized: "relay_add_url"), systemImage: "plus.circle")
|
||||
}
|
||||
.disabled(
|
||||
model.state.relayURLs.count >= RelayConfigurationValidator.maximumRelayCount
|
||||
|| model.state.isApplyingRelayConfiguration
|
||||
)
|
||||
} header: {
|
||||
Text(LocalizedStringKey("relay_custom_urls_label"))
|
||||
} footer: {
|
||||
Text(LocalizedStringKey("relay_custom_urls_help"))
|
||||
}
|
||||
}
|
||||
|
||||
if let error = model.state.relayValidationError, error.urlIndex == nil {
|
||||
Section {
|
||||
Label {
|
||||
Text(relayValidationMessage(error))
|
||||
} icon: {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
}
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
}
|
||||
|
||||
if model.state.hasActiveNetworkWork || model.state.relayApplyErrorKey != nil {
|
||||
Section {
|
||||
Label {
|
||||
Text(LocalizedStringKey(
|
||||
model.state.hasActiveNetworkWork
|
||||
? "relay_apply_active_transfers"
|
||||
: model.state.relayApplyErrorKey ?? "relay_apply_failed"
|
||||
))
|
||||
} icon: {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
}
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
Button(action: model.applyRelayConfiguration) {
|
||||
HStack {
|
||||
Text(LocalizedStringKey(
|
||||
model.state.isApplyingRelayConfiguration ? "relay_applying" : "relay_apply"
|
||||
))
|
||||
if model.state.isApplyingRelayConfiguration {
|
||||
Spacer()
|
||||
ProgressView()
|
||||
}
|
||||
}
|
||||
}
|
||||
.disabled(
|
||||
!model.state.relayConfigurationIsDirty
|
||||
|| model.state.isApplyingRelayConfiguration
|
||||
|| model.state.hasActiveNetworkWork
|
||||
)
|
||||
} footer: {
|
||||
Text(LocalizedStringKey("relay_apply_restart_description"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func relayValidationMessage(_ error: RelayConfigurationValidationError) -> String {
|
||||
switch error {
|
||||
case .missingURL:
|
||||
return String(localized: "relay_validation_missing_url")
|
||||
case .tooManyURLs:
|
||||
return String(
|
||||
format: String(localized: "relay_validation_too_many_urls"),
|
||||
RelayConfigurationValidator.maximumRelayCount
|
||||
)
|
||||
case .httpsRequired(let index):
|
||||
return String(format: String(localized: "relay_validation_https_required"), index + 1)
|
||||
case .invalidURL(let index):
|
||||
return String(format: String(localized: "relay_validation_invalid_url"), index + 1)
|
||||
case .duplicateURL(let index):
|
||||
return String(format: String(localized: "relay_validation_duplicate_url"), index + 1)
|
||||
}
|
||||
}
|
||||
|
||||
struct StorageSettings: View {
|
||||
@ObservedObject var model: SettingsModel
|
||||
@State private var showDeleteConfirmation = false
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user