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,
|
||||
|
||||
Reference in New Issue
Block a user