diff --git a/README.md b/README.md index fb3b623..395adf5 100644 --- a/README.md +++ b/README.md @@ -53,10 +53,21 @@ encrypted packets; it is not a VniDrop file store. ### Custom relay servers VniDrop uses Iroh's public relay and discovery infrastructure by default. In -**Settings → Network**, advanced users can instead configure up to eight custom -HTTPS servers that implement Iroh's relay protocol. Custom mode is strict: the -app uses only those relays and does not silently fall back to public relays or -public discovery. Direct peer-to-peer paths remain available. +**Settings → Network**, users can select one of four policies: + +- **Automatic (recommended):** use Iroh's public relays, with direct P2P/LAN + connections whenever possible. +- **Strict custom:** use only up to eight configured custom HTTPS relays or + direct connections. Startup reports an error if none of the custom relays can + be established. +- **Custom with direct fallback:** prefer the configured custom relays, but + continue with direct connections if they are unavailable. +- **Local only:** disable every relay and allow direct connections only, + primarily for devices on the same network. + +Strict custom, custom with direct fallback, and local only never use public +relays or public discovery, including relay addresses advertised by incoming +invitations. Applying a relay change restarts VniDrop's network engine, so active transfers and shares must be stopped first. The app tests the new configuration and diff --git a/apple/Tests/AppModelTests.swift b/apple/Tests/AppModelTests.swift index 1b9beb6..5f38949 100644 --- a/apple/Tests/AppModelTests.swift +++ b/apple/Tests/AppModelTests.swift @@ -26,7 +26,7 @@ final class AppModelTests: XCTestCase { func testInitializesCoreWithSavedCustomRelayConfiguration() async { let core = FakeCoreGateway() let preferences = Fixtures.preferences() - let configuration = RelayConfiguration(mode: .custom, relayURLs: ["https://relay.example"]) + let configuration = RelayConfiguration(mode: .strictCustom, relayURLs: ["https://relay.example"]) preferences.setRelayConfiguration(configuration) _ = makeModel(core, preferences: preferences) diff --git a/apple/Tests/AppPreferencesRepositoryTests.swift b/apple/Tests/AppPreferencesRepositoryTests.swift index 8983566..60acd67 100644 --- a/apple/Tests/AppPreferencesRepositoryTests.swift +++ b/apple/Tests/AppPreferencesRepositoryTests.swift @@ -32,7 +32,7 @@ final class AppPreferencesRepositoryTests: XCTestCase { repo.setNotificationsEnabled(true) repo.setReceiveFolder(ReceiveFolder(kind: .iosSecurityScopedUrl, value: "file:///x", displayName: "Custom")) repo.setRelayConfiguration(RelayConfiguration( - mode: .custom, + mode: .strictCustom, relayURLs: ["https://relay-one.example", "https://relay-two.example:443"] )) @@ -44,7 +44,7 @@ final class AppPreferencesRepositoryTests: XCTestCase { XCTAssertEqual(reloaded.preferences.receiveFolder.displayName, "Custom") XCTAssertEqual(reloaded.preferences.receiveFolder.kind, .iosSecurityScopedUrl) XCTAssertEqual(reloaded.preferences.relayConfiguration, RelayConfiguration( - mode: .custom, + mode: .strictCustom, relayURLs: ["https://relay-one.example", "https://relay-two.example:443"] )) XCTAssertNotNil(store.data(forKey: "relay_configuration")) @@ -60,7 +60,7 @@ final class AppPreferencesRepositoryTests: XCTestCase { XCTAssertEqual( repo.preferences.relayConfiguration, - RelayConfiguration(mode: .custom, relayURLs: []) + RelayConfiguration(mode: .strictCustom, relayURLs: []) ) } @@ -75,7 +75,7 @@ final class AppPreferencesRepositoryTests: XCTestCase { XCTAssertEqual( repo.preferences.relayConfiguration, - RelayConfiguration(mode: .custom, relayURLs: []) + RelayConfiguration(mode: .strictCustom, relayURLs: []) ) } diff --git a/apple/Tests/CoreRepositoryLifecycleTests.swift b/apple/Tests/CoreRepositoryLifecycleTests.swift index cdd8c69..7d0ce97 100644 --- a/apple/Tests/CoreRepositoryLifecycleTests.swift +++ b/apple/Tests/CoreRepositoryLifecycleTests.swift @@ -94,7 +94,7 @@ final class CoreRepositoryLifecycleTests: XCTestCase { let concurrentInitialization = await repository.initialize( appDataDir: "/tmp/second", - networkConfiguration: RelayConfiguration(mode: .custom, relayURLs: ["https://relay.example"]) + networkConfiguration: RelayConfiguration(mode: .strictCustom, relayURLs: ["https://relay.example"]) ) assertLifecycleFailure(concurrentInitialization, equals: .transitionInProgress) diff --git a/apple/Tests/RelayConfigurationTests.swift b/apple/Tests/RelayConfigurationTests.swift index 5ed61bf..2196849 100644 --- a/apple/Tests/RelayConfigurationTests.swift +++ b/apple/Tests/RelayConfigurationTests.swift @@ -2,6 +2,32 @@ import XCTest @testable import VniDrop final class RelayConfigurationTests: XCTestCase { + func testCustomFallbackValidatesAndPreservesItsMode() throws { + let result = try RelayConfigurationValidator.validate( + mode: .customWithDirectFallback, + relayURLs: ["https://relay.example/"] + ) + + XCTAssertEqual( + result, + RelayConfiguration( + mode: .customWithDirectFallback, + relayURLs: ["https://relay.example"] + ) + ) + } + + func testLocalOnlyRetainsPreviouslySavedRelayURLs() throws { + let retained = ["https://relay.example"] + let result = try RelayConfigurationValidator.validate( + mode: .localOnly, + relayURLs: ["not a URL"], + retainedRelayURLs: retained + ) + + XCTAssertEqual(result, RelayConfiguration(mode: .localOnly, relayURLs: retained)) + } + func testAutomaticModeIgnoresRelayDrafts() throws { let result = try RelayConfigurationValidator.validate( mode: .automatic, @@ -24,32 +50,32 @@ final class RelayConfigurationTests: XCTestCase { func testCustomModeTrimsValidHTTPSRelayURLs() throws { let result = try RelayConfigurationValidator.validate( - mode: .custom, + mode: .strictCustom, relayURLs: [" https://relay.example/ ", "https://backup.example:443"] ) XCTAssertEqual(result, RelayConfiguration( - mode: .custom, + mode: .strictCustom, relayURLs: ["https://relay.example", "https://backup.example"] )) } func testCustomModeIgnoresEmptyURLRows() throws { let result = try RelayConfigurationValidator.validate( - mode: .custom, + mode: .strictCustom, relayURLs: ["", " ", "https://relay.example"] ) XCTAssertEqual(result.relayURLs, ["https://relay.example"]) } func testCustomModeRequiresAtLeastOneRelay() { - XCTAssertThrowsError(try RelayConfigurationValidator.validate(mode: .custom, relayURLs: [])) { error in + XCTAssertThrowsError(try RelayConfigurationValidator.validate(mode: .strictCustom, relayURLs: [])) { error in XCTAssertEqual(error as? RelayConfigurationValidationError, .missingURL) } } func testCustomModeRequiresHTTPS() { XCTAssertThrowsError(try RelayConfigurationValidator.validate( - mode: .custom, + mode: .strictCustom, relayURLs: ["http://relay.example"] )) { error in XCTAssertEqual(error as? RelayConfigurationValidationError, .httpsRequired(index: 0)) @@ -67,7 +93,7 @@ final class RelayConfigurationTests: XCTestCase { ] for relayURL in invalidURLs { XCTAssertThrowsError( - try RelayConfigurationValidator.validate(mode: .custom, relayURLs: [relayURL]), + try RelayConfigurationValidator.validate(mode: .strictCustom, relayURLs: [relayURL]), "Expected \(relayURL) to be rejected" ) { error in XCTAssertEqual(error as? RelayConfigurationValidationError, .invalidURL(index: 0)) @@ -77,7 +103,7 @@ final class RelayConfigurationTests: XCTestCase { func testCustomModeRejectsNormalizedDuplicate() { XCTAssertThrowsError(try RelayConfigurationValidator.validate( - mode: .custom, + mode: .strictCustom, relayURLs: ["https://relay.example", "https://RELAY.example:443/"] )) { error in XCTAssertEqual(error as? RelayConfigurationValidationError, .duplicateURL(index: 1)) @@ -88,7 +114,7 @@ final class RelayConfigurationTests: XCTestCase { let relayURLs = (0...RelayConfigurationValidator.maximumRelayCount).map { "https://relay-\($0).example" } - XCTAssertThrowsError(try RelayConfigurationValidator.validate(mode: .custom, relayURLs: relayURLs)) { error in + XCTAssertThrowsError(try RelayConfigurationValidator.validate(mode: .strictCustom, relayURLs: relayURLs)) { error in XCTAssertEqual(error as? RelayConfigurationValidationError, .tooManyURLs) } } diff --git a/apple/Tests/SettingsModelTests.swift b/apple/Tests/SettingsModelTests.swift index e4d27a9..e565762 100644 --- a/apple/Tests/SettingsModelTests.swift +++ b/apple/Tests/SettingsModelTests.swift @@ -59,13 +59,13 @@ final class SettingsModelTests: XCTestCase { let core = FakeCoreGateway() let preferences = Fixtures.preferences() let model = makeModel(core, preferences: preferences) - model.setRelayMode(.custom) + model.setRelayMode(.strictCustom) model.setRelayURL(" https://relay.example/ ", at: 0) model.applyRelayConfiguration() - await waitUntil { preferences.preferences.relayConfiguration.mode == .custom } - let expected = RelayConfiguration(mode: .custom, relayURLs: ["https://relay.example"]) + await waitUntil { preferences.preferences.relayConfiguration.mode == .strictCustom } + let expected = RelayConfiguration(mode: .strictCustom, relayURLs: ["https://relay.example"]) XCTAssertEqual(preferences.preferences.relayConfiguration, expected) XCTAssertEqual(core.initializedNetworkConfigurations, [expected]) XCTAssertFalse(model.state.relayConfigurationIsDirty) @@ -75,7 +75,7 @@ final class SettingsModelTests: XCTestCase { let core = FakeCoreGateway() let preferences = Fixtures.preferences() let relayURLs = ["https://relay.example", "https://backup.example"] - preferences.setRelayConfiguration(RelayConfiguration(mode: .custom, relayURLs: relayURLs)) + preferences.setRelayConfiguration(RelayConfiguration(mode: .strictCustom, relayURLs: relayURLs)) let model = makeModel(core, preferences: preferences) model.setRelayMode(.automatic) @@ -86,7 +86,7 @@ final class SettingsModelTests: XCTestCase { XCTAssertEqual(core.initializedNetworkConfigurations, [ RelayConfiguration(mode: .automatic, relayURLs: relayURLs), ]) - model.setRelayMode(.custom) + model.setRelayMode(.strictCustom) XCTAssertEqual(model.state.relayURLs, relayURLs) } @@ -98,7 +98,7 @@ final class SettingsModelTests: XCTestCase { isInitialized: true, status: CoreStatus(endpointId: "endpoint", activeTransfers: 0, activeShares: 1) )) - model.setRelayMode(.custom) + model.setRelayMode(.strictCustom) model.setRelayURL("https://relay.example", at: 0) model.applyRelayConfiguration() @@ -114,8 +114,8 @@ final class SettingsModelTests: XCTestCase { core.initializeResult = .failure(CoreNetworkLifecycleError.activeNetworkWork) let preferences = Fixtures.preferences() let model = makeModel(core, preferences: preferences) - let attempted = RelayConfiguration(mode: .custom, relayURLs: ["https://relay.example"]) - model.setRelayMode(.custom) + let attempted = RelayConfiguration(mode: .strictCustom, relayURLs: ["https://relay.example"]) + model.setRelayMode(.strictCustom) model.setRelayURL(attempted.relayURLs[0], at: 0) model.applyRelayConfiguration() @@ -134,8 +134,8 @@ final class SettingsModelTests: XCTestCase { core.initializeResults = [.failure(TestError.unimplemented), .success(())] let preferences = Fixtures.preferences() let model = makeModel(core, preferences: preferences) - let attempted = RelayConfiguration(mode: .custom, relayURLs: ["https://relay.example"]) - model.setRelayMode(.custom) + let attempted = RelayConfiguration(mode: .strictCustom, relayURLs: ["https://relay.example"]) + model.setRelayMode(.strictCustom) model.setRelayURL(attempted.relayURLs[0], at: 0) model.applyRelayConfiguration() diff --git a/apple/VniDrop/Core/AppPreferences.swift b/apple/VniDrop/Core/AppPreferences.swift index 48ff1f9..cdd87e0 100644 --- a/apple/VniDrop/Core/AppPreferences.swift +++ b/apple/VniDrop/Core/AppPreferences.swift @@ -21,7 +21,13 @@ enum FolderAccessStatus { enum RelayPreferenceMode: String, Codable, CaseIterable, Sendable { case automatic - case custom + case strictCustom = "custom" + case customWithDirectFallback = "custom-with-direct-fallback" + case localOnly = "local-only" + + var usesCustomRelayURLs: Bool { + self == .strictCustom || self == .customWithDirectFallback + } } struct RelayConfiguration: Equatable, Codable, Sendable { @@ -55,8 +61,8 @@ enum RelayConfigurationValidator { relayURLs: [String], retainedRelayURLs: [String] = [] ) throws -> RelayConfiguration { - guard mode == .custom else { - return RelayConfiguration(mode: .automatic, relayURLs: retainedRelayURLs) + guard mode.usesCustomRelayURLs else { + return RelayConfiguration(mode: mode, relayURLs: retainedRelayURLs) } let relayEntries = relayURLs.enumerated().compactMap { index, value -> (Int, String)? in @@ -104,7 +110,7 @@ enum RelayConfigurationValidator { normalizedURLs.append(canonicalURL) } - return RelayConfiguration(mode: .custom, relayURLs: normalizedURLs) + return RelayConfiguration(mode: mode, relayURLs: normalizedURLs) } } @@ -177,10 +183,9 @@ final class AppPreferencesRepository: ObservableObject { 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: []) + // A stored profile must never silently fall back to public relays. Strict + // custom with no URLs makes startup fail closed until Settings repairs it. + return RelayConfiguration(mode: .strictCustom, relayURLs: []) } return configuration } diff --git a/apple/VniDrop/Core/CoreRepository.swift b/apple/VniDrop/Core/CoreRepository.swift index 1d8a58e..d98e969 100644 --- a/apple/VniDrop/Core/CoreRepository.swift +++ b/apple/VniDrop/Core/CoreRepository.swift @@ -40,11 +40,18 @@ struct NativeCoreBindingFactory: CoreBindingFactory { switch networkConfiguration.mode { case .automatic: nativeConfiguration = defaultCoreNetworkConfig() - case .custom: + case .strictCustom: nativeConfiguration = CoreNetworkConfig( - mode: .custom, + mode: .strictCustom, relayUrls: networkConfiguration.relayURLs ) + case .customWithDirectFallback: + nativeConfiguration = CoreNetworkConfig( + mode: .customWithDirectFallback, + relayUrls: networkConfiguration.relayURLs + ) + case .localOnly: + nativeConfiguration = CoreNetworkConfig(mode: .localOnly, relayUrls: []) } return try VnidropCore.initializeWithNetworkConfig( appDataDir: appDataDir, diff --git a/apple/VniDrop/Features/Settings/SettingsModel.swift b/apple/VniDrop/Features/Settings/SettingsModel.swift index 5e03b20..674b53a 100644 --- a/apple/VniDrop/Features/Settings/SettingsModel.swift +++ b/apple/VniDrop/Features/Settings/SettingsModel.swift @@ -248,7 +248,7 @@ final class SettingsModel: ObservableObject { func setRelayMode(_ mode: RelayPreferenceMode) { hasRelayConfigurationDraft = true state.relayMode = mode - if mode == .custom && state.relayURLs.isEmpty { state.relayURLs = [""] } + if mode.usesCustomRelayURLs && state.relayURLs.isEmpty { state.relayURLs = [""] } updateRelayConfigurationDraft() } @@ -351,7 +351,7 @@ final class SettingsModel: ObservableObject { state.relayValidationError = nil state.relayApplyErrorKey = nil let saved = preferences.preferences.relayConfiguration - let draftURLs = state.relayMode == .automatic ? saved.relayURLs : state.relayURLs + let draftURLs = state.relayMode.usesCustomRelayURLs ? state.relayURLs : saved.relayURLs state.relayConfigurationIsDirty = saved != RelayConfiguration(mode: state.relayMode, relayURLs: draftURLs) hasRelayConfigurationDraft = state.relayConfigurationIsDirty } diff --git a/apple/VniDrop/Features/Settings/SettingsScreen.swift b/apple/VniDrop/Features/Settings/SettingsScreen.swift index abb9ae5..bbc9951 100644 --- a/apple/VniDrop/Features/Settings/SettingsScreen.swift +++ b/apple/VniDrop/Features/Settings/SettingsScreen.swift @@ -125,7 +125,18 @@ 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") + case .strictCustom: return String(localized: "relay_mode_custom") + case .customWithDirectFallback: return String(localized: "relay_mode_custom_direct_fallback") + case .localOnly: return String(localized: "relay_mode_local_only") + } +} + +func relayModeDescriptionKey(_ mode: RelayPreferenceMode) -> String { + switch mode { + case .automatic: return "relay_mode_automatic_description" + case .strictCustom: return "relay_mode_custom_description" + case .customWithDirectFallback: return "relay_mode_custom_direct_fallback_description" + case .localOnly: return "relay_mode_local_only_description" } } diff --git a/apple/VniDrop/Features/Settings/SettingsSections.swift b/apple/VniDrop/Features/Settings/SettingsSections.swift index 7c0fadc..4d7698e 100644 --- a/apple/VniDrop/Features/Settings/SettingsSections.swift +++ b/apple/VniDrop/Features/Settings/SettingsSections.swift @@ -70,15 +70,10 @@ struct NetworkSettings: View { Text(relayModeLabel(mode)).tag(mode) } } - .pickerStyle(.segmented) - .labelsHidden() + .pickerStyle(.inline) .disabled(model.state.isApplyingRelayConfiguration) } footer: { - Text(LocalizedStringKey( - model.state.relayMode == .automatic - ? "relay_mode_automatic_description" - : "relay_mode_custom_description" - )) + Text(LocalizedStringKey(relayModeDescriptionKey(model.state.relayMode))) } Section { @@ -99,15 +94,17 @@ struct NetworkSettings: View { } } - if model.state.relayMode == .custom { + if model.state.relayMode.usesCustomRelayURLs { Section { - Label { - Text(LocalizedStringKey("relay_strict_warning")) - .fixedSize(horizontal: false, vertical: true) - } icon: { - Image(systemName: "exclamationmark.shield.fill") + if model.state.relayMode == .strictCustom { + Label { + Text(LocalizedStringKey("relay_strict_warning")) + .fixedSize(horizontal: false, vertical: true) + } icon: { + Image(systemName: "exclamationmark.shield.fill") + } + .foregroundStyle(.orange) } - .foregroundStyle(.orange) ForEach(Array(model.state.relayURLs.indices), id: \.self) { index in VStack(alignment: .leading, spacing: 6) { diff --git a/apple/VniDrop/Resources/Localizable.xcstrings b/apple/VniDrop/Resources/Localizable.xcstrings index 8a891a8..f9e018b 100644 --- a/apple/VniDrop/Resources/Localizable.xcstrings +++ b/apple/VniDrop/Resources/Localizable.xcstrings @@ -10756,55 +10756,55 @@ "de": { "stringUnit": { "state": "needs_review", - "value": "Automatisch" + "value": "Automatisch (empfohlen)" } }, "en": { "stringUnit": { "state": "translated", - "value": "Automatic" + "value": "Automatic (recommended)" } }, "es": { "stringUnit": { "state": "needs_review", - "value": "Automático" + "value": "Automático (recomendado)" } }, "fr": { "stringUnit": { "state": "needs_review", - "value": "Automatique" + "value": "Automatique (recommandé)" } }, "it": { "stringUnit": { "state": "needs_review", - "value": "Automatica" + "value": "Automatica (consigliata)" } }, "nl": { "stringUnit": { "state": "needs_review", - "value": "Automatisch" + "value": "Automatisch (aanbevolen)" } }, "pl": { "stringUnit": { "state": "needs_review", - "value": "Automatyczny" + "value": "Automatyczny (zalecany)" } }, "pt": { "stringUnit": { "state": "needs_review", - "value": "Automático" + "value": "Automático (recomendado)" } }, "ru": { "stringUnit": { "state": "needs_review", - "value": "Автоматически" + "value": "Автоматически (рекомендуется)" } } } @@ -10876,55 +10876,55 @@ "de": { "stringUnit": { "state": "needs_review", - "value": "Benutzerdefiniert" + "value": "Strikt benutzerdefiniert" } }, "en": { "stringUnit": { "state": "translated", - "value": "Custom" + "value": "Strict custom" } }, "es": { "stringUnit": { "state": "needs_review", - "value": "Personalizado" + "value": "Personalizado estricto" } }, "fr": { "stringUnit": { "state": "needs_review", - "value": "Personnalisé" + "value": "Personnalisé strict" } }, "it": { "stringUnit": { "state": "needs_review", - "value": "Personalizzata" + "value": "Personalizzata rigorosa" } }, "nl": { "stringUnit": { "state": "needs_review", - "value": "Aangepast" + "value": "Strikt aangepast" } }, "pl": { "stringUnit": { "state": "needs_review", - "value": "Niestandardowy" + "value": "Ścisły niestandardowy" } }, "pt": { "stringUnit": { "state": "needs_review", - "value": "Personalizado" + "value": "Personalizado estrito" } }, "ru": { "stringUnit": { "state": "needs_review", - "value": "Пользовательский" + "value": "Строго пользовательский" } } } @@ -10936,55 +10936,295 @@ "de": { "stringUnit": { "state": "needs_review", - "value": "Verwendet ausschließlich die unten aufgeführten Relay-Server." + "value": "Verwendet nur die konfigurierten eigenen Relays oder Direktverbindungen. Meldet einen Fehler, wenn kein eigenes Relay erreichbar ist." } }, "en": { "stringUnit": { "state": "translated", - "value": "Use only the relay servers below." + "value": "Use only the configured custom relays or direct connections. Report an error if no custom relay can be established." } }, "es": { "stringUnit": { "state": "needs_review", - "value": "Usa únicamente los servidores de retransmisión indicados a continuación." + "value": "Usa solo los relés personalizados configurados o conexiones directas. Informa de un error si no se puede establecer ningún relé personalizado." } }, "fr": { "stringUnit": { "state": "needs_review", - "value": "Utiliser uniquement les serveurs relais ci-dessous." + "value": "Utilise uniquement les relais personnalisés configurés ou les connexions directes. Signale une erreur si aucun relais personnalisé ne peut être établi." } }, "it": { "stringUnit": { "state": "needs_review", - "value": "Usa solo i server relay indicati di seguito." + "value": "Usa solo i relay personalizzati configurati o connessioni dirette. Segnala un errore se non è possibile stabilire alcun relay personalizzato." } }, "nl": { "stringUnit": { "state": "needs_review", - "value": "Gebruikt alleen de onderstaande relayservers." + "value": "Gebruikt alleen de ingestelde aangepaste relays of rechtstreekse verbindingen. Meldt een fout als geen aangepaste relay bereikbaar is." } }, "pl": { "stringUnit": { "state": "needs_review", - "value": "Używa wyłącznie poniższych serwerów przekaźnikowych." + "value": "Używa tylko skonfigurowanych własnych przekaźników lub połączeń bezpośrednich. Zgłasza błąd, jeśli nie można połączyć się z żadnym własnym przekaźnikiem." } }, "pt": { "stringUnit": { "state": "needs_review", - "value": "Utiliza apenas os servidores de retransmissão abaixo." + "value": "Utiliza apenas os retransmissores personalizados configurados ou ligações diretas. Apresenta um erro se não for possível estabelecer nenhum retransmissor personalizado." } }, "ru": { "stringUnit": { "state": "needs_review", - "value": "Использовать только указанные ниже серверы-ретрансляторы." + "value": "Использует только настроенные пользовательские ретрансляторы или прямые соединения. Сообщает об ошибке, если ни один пользовательский ретранслятор недоступен." + } + } + } + }, + "relay_mode_custom_direct_fallback": { + "comment": "Network settings label for custom relays that allow direct-only startup fallback.", + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "needs_review", + "value": "Benutzerdefiniert mit direktem Rückfall" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Custom with direct fallback" + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "Personalizado con conexión directa de reserva" + } + }, + "fr": { + "stringUnit": { + "state": "needs_review", + "value": "Personnalisé avec repli direct" + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "Personalizzata con ripiego diretto" + } + }, + "nl": { + "stringUnit": { + "state": "needs_review", + "value": "Aangepast met directe terugval" + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Niestandardowy z trybem bezpośrednim" + } + }, + "pt": { + "stringUnit": { + "state": "needs_review", + "value": "Personalizado com alternativa direta" + } + }, + "ru": { + "stringUnit": { + "state": "needs_review", + "value": "Пользовательский с прямым резервом" + } + } + } + }, + "relay_mode_custom_direct_fallback_description": { + "comment": "Network settings description of custom relays with direct-only startup fallback.", + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "needs_review", + "value": "Bevorzugt die konfigurierten eigenen Relays. Sind sie nicht verfügbar, werden nur Direktverbindungen verwendet. Öffentliche Relays werden nie genutzt." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Prefer the configured custom relays. If unavailable, continue with direct connections only. Never use public relays." + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "Prefiere los relés personalizados configurados. Si no están disponibles, continúa solo con conexiones directas. Nunca usa relés públicos." + } + }, + "fr": { + "stringUnit": { + "state": "needs_review", + "value": "Préfère les relais personnalisés configurés. S’ils sont indisponibles, continue uniquement avec des connexions directes. N’utilise jamais les relais publics." + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "Preferisce i relay personalizzati configurati. Se non sono disponibili, continua solo con connessioni dirette. Non usa mai relay pubblici." + } + }, + "nl": { + "stringUnit": { + "state": "needs_review", + "value": "Geeft de voorkeur aan de ingestelde aangepaste relays. Als die niet beschikbaar zijn, worden alleen rechtstreekse verbindingen gebruikt. Openbare relays worden nooit gebruikt." + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Preferuje skonfigurowane własne przekaźniki. Jeśli są niedostępne, kontynuuje tylko przez połączenia bezpośrednie. Nigdy nie używa publicznych przekaźników." + } + }, + "pt": { + "stringUnit": { + "state": "needs_review", + "value": "Prefere os retransmissores personalizados configurados. Se não estiverem disponíveis, continua apenas com ligações diretas. Nunca utiliza retransmissores públicos." + } + }, + "ru": { + "stringUnit": { + "state": "needs_review", + "value": "Предпочитает настроенные пользовательские ретрансляторы. Если они недоступны, продолжает работу только через прямые соединения. Публичные ретрансляторы не используются." + } + } + } + }, + "relay_mode_local_only": { + "comment": "Network settings label for direct connections without any relay.", + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "needs_review", + "value": "Nur lokales Netzwerk" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Local only" + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "Solo red local" + } + }, + "fr": { + "stringUnit": { + "state": "needs_review", + "value": "Réseau local uniquement" + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "Solo rete locale" + } + }, + "nl": { + "stringUnit": { + "state": "needs_review", + "value": "Alleen lokaal netwerk" + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Tylko sieć lokalna" + } + }, + "pt": { + "stringUnit": { + "state": "needs_review", + "value": "Apenas rede local" + } + }, + "ru": { + "stringUnit": { + "state": "needs_review", + "value": "Только локальная сеть" + } + } + } + }, + "relay_mode_local_only_description": { + "comment": "Network settings description of direct-only local-network mode.", + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "needs_review", + "value": "Deaktiviert alle Relays und erlaubt nur Direktverbindungen, hauptsächlich für Geräte im selben Netzwerk." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Disable all relays and allow only direct connections, primarily for devices on the same network." + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "Desactiva todos los relés y permite solo conexiones directas, principalmente para dispositivos de la misma red." + } + }, + "fr": { + "stringUnit": { + "state": "needs_review", + "value": "Désactive tous les relais et autorise uniquement les connexions directes, principalement pour les appareils sur le même réseau." + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "Disattiva tutti i relay e consente solo connessioni dirette, soprattutto per dispositivi sulla stessa rete." + } + }, + "nl": { + "stringUnit": { + "state": "needs_review", + "value": "Schakelt alle relays uit en staat alleen rechtstreekse verbindingen toe, vooral voor apparaten in hetzelfde netwerk." + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Wyłącza wszystkie przekaźniki i zezwala tylko na połączenia bezpośrednie, głównie dla urządzeń w tej samej sieci." + } + }, + "pt": { + "stringUnit": { + "state": "needs_review", + "value": "Desativa todos os retransmissores e permite apenas ligações diretas, principalmente para dispositivos na mesma rede." + } + }, + "ru": { + "stringUnit": { + "state": "needs_review", + "value": "Отключает все ретрансляторы и разрешает только прямые соединения, прежде всего для устройств в одной сети." } } } @@ -11236,55 +11476,55 @@ "de": { "stringUnit": { "state": "needs_review", - "value": "Der benutzerdefinierte Modus ist strikt: VniDrop greift weder auf öffentliche Relays noch auf öffentliche Erkennung zurück. Andere Geräte müssen Ihre konfigurierten Relays erreichen können." + "value": "Der strikt benutzerdefinierte Modus startet nur, wenn mindestens ein konfiguriertes Relay erreichbar ist. VniDrop verwendet in diesem Modus nie öffentliche Relays oder öffentliche Erkennung." } }, "en": { "stringUnit": { "state": "translated", - "value": "Custom mode is strict: VniDrop will not fall back to public relays or public discovery. Other devices must be able to reach your configured relays." + "value": "Strict custom mode will not start unless at least one configured relay is reachable. VniDrop never uses public relays or public discovery in this mode." } }, "es": { "stringUnit": { "state": "needs_review", - "value": "El modo personalizado es estricto: VniDrop no recurrirá a relés públicos ni al descubrimiento público. Los demás dispositivos deben poder acceder a los relés configurados." + "value": "El modo personalizado estricto no se inicia a menos que se pueda acceder al menos a un relé configurado. VniDrop nunca usa relés ni descubrimiento públicos en este modo." } }, "fr": { "stringUnit": { "state": "needs_review", - "value": "Le mode personnalisé est strict : VniDrop n’utilisera ni les relais publics ni la découverte publique comme solution de repli. Les autres appareils doivent pouvoir accéder aux relais configurés." + "value": "Le mode personnalisé strict ne démarre que si au moins un relais configuré est accessible. VniDrop n’utilise jamais de relais ni de découverte publics dans ce mode." } }, "it": { "stringUnit": { "state": "needs_review", - "value": "La modalità personalizzata è rigorosa: VniDrop non userà relay pubblici o il rilevamento pubblico come ripiego. Gli altri dispositivi devono poter raggiungere i relay configurati." + "value": "La modalità personalizzata rigorosa si avvia solo se almeno un relay configurato è raggiungibile. In questa modalità VniDrop non usa mai relay o rilevamento pubblici." } }, "nl": { "stringUnit": { "state": "needs_review", - "value": "De aangepaste modus is strikt: VniDrop valt niet terug op openbare relays of openbare detectie. Andere apparaten moeten uw ingestelde relays kunnen bereiken." + "value": "De strikt aangepaste modus start alleen als minstens één ingestelde relay bereikbaar is. VniDrop gebruikt in deze modus nooit openbare relays of openbare detectie." } }, "pl": { "stringUnit": { "state": "needs_review", - "value": "Tryb niestandardowy jest rygorystyczny: VniDrop nie użyje awaryjnie publicznych przekaźników ani publicznego wykrywania. Inne urządzenia muszą mieć dostęp do skonfigurowanych przekaźników." + "value": "Ścisły tryb niestandardowy uruchamia się tylko wtedy, gdy co najmniej jeden skonfigurowany przekaźnik jest dostępny. VniDrop nigdy nie używa w tym trybie publicznych przekaźników ani publicznego wykrywania." } }, "pt": { "stringUnit": { "state": "needs_review", - "value": "O modo personalizado é estrito: o VniDrop não recorrerá a retransmissores públicos nem à descoberta pública. Os outros dispositivos têm de conseguir aceder aos retransmissores configurados." + "value": "O modo personalizado estrito só inicia se pelo menos um retransmissor configurado estiver acessível. Neste modo, o VniDrop nunca utiliza retransmissores nem descoberta públicos." } }, "ru": { "stringUnit": { "state": "needs_review", - "value": "Пользовательский режим работает строго: VniDrop не будет переключаться на публичные ретрансляторы или публичное обнаружение. Другие устройства должны иметь доступ к настроенным ретрансляторам." + "value": "Строго пользовательский режим запускается, только если доступен хотя бы один настроенный ретранслятор. В этом режиме VniDrop никогда не использует публичные ретрансляторы или публичное обнаружение." } } } diff --git a/crates/vnidrop/src/api.rs b/crates/vnidrop/src/api.rs index f4fe087..cfb3f50 100644 --- a/crates/vnidrop/src/api.rs +++ b/crates/vnidrop/src/api.rs @@ -12,7 +12,9 @@ pub(crate) const MAX_RELAY_URL_BYTES: usize = 2_048; #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, uniffi::Enum)] pub enum CoreRelayMode { Automatic, - Custom, + StrictCustom, + CustomWithDirectFallback, + LocalOnly, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, uniffi::Record)] @@ -33,13 +35,21 @@ impl Default for CoreNetworkConfig { impl CoreNetworkConfig { pub(crate) fn validated_relay_urls(&self) -> anyhow::Result> { match self.mode { - CoreRelayMode::Automatic => { + CoreRelayMode::Automatic | CoreRelayMode::LocalOnly => { if !self.relay_urls.is_empty() { - anyhow::bail!("automatic relay mode must not include custom relay URLs"); + anyhow::bail!( + "{} relay mode must not include custom relay URLs", + match self.mode { + CoreRelayMode::Automatic => "automatic", + CoreRelayMode::LocalOnly => "local-only", + CoreRelayMode::StrictCustom + | CoreRelayMode::CustomWithDirectFallback => unreachable!(), + } + ); } Ok(Vec::new()) } - CoreRelayMode::Custom => { + CoreRelayMode::StrictCustom | CoreRelayMode::CustomWithDirectFallback => { if self.relay_urls.is_empty() { anyhow::bail!("custom relay mode requires at least one relay URL"); } diff --git a/crates/vnidrop/src/runtime/mod.rs b/crates/vnidrop/src/runtime/mod.rs index 137017a..aee54ce 100644 --- a/crates/vnidrop/src/runtime/mod.rs +++ b/crates/vnidrop/src/runtime/mod.rs @@ -61,6 +61,23 @@ use crate::{ const RELAY_CONNECT_TIMEOUT: Duration = Duration::from_secs(10); +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum RelayStatus { + Disabled, + Connected, + Unreachable, +} + +impl RelayStatus { + fn as_str(self) -> &'static str { + match self { + Self::Disabled => "disabled", + Self::Connected => "connected", + Self::Unreachable => "unreachable", + } + } +} + /// Owns the Iroh endpoint, blob store, transfer history, and byte streaming. /// Kotlin owns app lifecycle and platform file picking. pub(super) struct CoreInner { @@ -122,7 +139,7 @@ impl CoreInner { .bind() .await? } - CoreRelayMode::Custom => { + CoreRelayMode::StrictCustom | CoreRelayMode::CustomWithDirectFallback => { let relay_map = RelayMap::from_iter(relay_urls.iter().cloned().map(|url| { // Loopback HTTP is a development escape hatch. Without TLS the // relay cannot serve Iroh's QUIC address-discovery endpoint. @@ -141,13 +158,22 @@ impl CoreInner { .bind() .await? } + CoreRelayMode::LocalOnly => { + Endpoint::builder(presets::Minimal) + .relay_mode(RelayMode::Disabled) + .secret_key(secret_key) + .bind() + .await? + } }; - if let Err(error) = - wait_for_relay(&endpoint, relay_mode, &relay_urls, RELAY_CONNECT_TIMEOUT).await - { - endpoint.close().await; - return Err(error); - } + let relay_status = + match wait_for_relay(&endpoint, relay_mode, &relay_urls, RELAY_CONNECT_TIMEOUT).await { + Ok(status) => status, + Err(error) => { + endpoint.close().await; + return Err(error); + } + }; // Provider events are where the sender sees remote readers. The core // uses them for send progress and for the current approval gate. @@ -160,6 +186,14 @@ impl CoreInner { limits.event_queue_capacity as usize, limits.max_events, )); + event_hub.emit_endpoint( + "network", + "relay-status", + json!({ + "mode": relay_mode_label(relay_mode), + "status": relay_status.as_str(), + }), + ); for recovered in recovered_transfers { event_hub.emit_transfer( recovered.transfer_id, @@ -377,7 +411,7 @@ pub(crate) fn filter_peer_addr_for_relay_mode( ) -> Result { match relay_mode { CoreRelayMode::Automatic => Ok(addr.clone()), - CoreRelayMode::Custom => { + CoreRelayMode::StrictCustom | CoreRelayMode::CustomWithDirectFallback => { let mut filtered = EndpointAddr::new(addr.id); for ip_addr in addr.ip_addrs().copied() { filtered = filtered.with_ip_addr(ip_addr); @@ -396,6 +430,16 @@ pub(crate) fn filter_peer_addr_for_relay_mode( } Ok(filtered) } + CoreRelayMode::LocalOnly => { + let mut filtered = EndpointAddr::new(addr.id); + for ip_addr in addr.ip_addrs().copied() { + filtered = filtered.with_ip_addr(ip_addr); + } + if filtered.is_empty() { + anyhow::bail!("invitation has no direct address allowed by local-only mode"); + } + Ok(filtered) + } } } @@ -404,17 +448,16 @@ pub(crate) async fn wait_for_relay( relay_mode: CoreRelayMode, relay_urls: &[RelayUrl], timeout: Duration, -) -> Result<()> { +) -> Result { + if relay_mode == CoreRelayMode::LocalOnly { + return Ok(RelayStatus::Disabled); + } if tokio::time::timeout(timeout, endpoint.online()) .await .is_err() { match relay_mode { - CoreRelayMode::Automatic => anyhow::bail!( - "timed out after {} seconds while connecting to automatic relays; verify network access and relay availability", - timeout.as_secs_f32(), - ), - CoreRelayMode::Custom => { + CoreRelayMode::StrictCustom => { let configured_relays = relay_urls .iter() .map(ToString::to_string) @@ -425,9 +468,22 @@ pub(crate) async fn wait_for_relay( timeout.as_secs_f32(), ); } + CoreRelayMode::Automatic | CoreRelayMode::CustomWithDirectFallback => { + return Ok(RelayStatus::Unreachable); + } + CoreRelayMode::LocalOnly => unreachable!(), } } - Ok(()) + Ok(RelayStatus::Connected) +} + +fn relay_mode_label(relay_mode: CoreRelayMode) -> &'static str { + match relay_mode { + CoreRelayMode::Automatic => "automatic", + CoreRelayMode::StrictCustom => "strict-custom", + CoreRelayMode::CustomWithDirectFallback => "custom-with-direct-fallback", + CoreRelayMode::LocalOnly => "local-only", + } } pub(super) fn share_tag_name(local_id: &str) -> String { diff --git a/crates/vnidrop/src/tests/network_config.rs b/crates/vnidrop/src/tests/network_config.rs index e5f0437..363ccf2 100644 --- a/crates/vnidrop/src/tests/network_config.rs +++ b/crates/vnidrop/src/tests/network_config.rs @@ -7,7 +7,7 @@ use crate::{ default_core_network_config, CoreNetworkConfig, CoreRelayMode, MAX_CUSTOM_RELAYS, MAX_RELAY_URL_BYTES, }, - runtime::{filter_peer_addr_for_relay_mode, wait_for_relay}, + runtime::{filter_peer_addr_for_relay_mode, wait_for_relay, RelayStatus}, }; #[test] @@ -32,17 +32,28 @@ fn relay_mode_and_url_list_must_be_consistent() { }; assert!(automatic_with_url.validated_relay_urls().is_err()); - let custom_without_url = CoreNetworkConfig { - mode: CoreRelayMode::Custom, - relay_urls: Vec::new(), + for mode in [ + CoreRelayMode::StrictCustom, + CoreRelayMode::CustomWithDirectFallback, + ] { + let custom_without_url = CoreNetworkConfig { + mode, + relay_urls: Vec::new(), + }; + assert!(custom_without_url.validated_relay_urls().is_err()); + } + + let local_only_with_url = CoreNetworkConfig { + mode: CoreRelayMode::LocalOnly, + relay_urls: vec!["https://relay.example.com".to_string()], }; - assert!(custom_without_url.validated_relay_urls().is_err()); + assert!(local_only_with_url.validated_relay_urls().is_err()); } #[test] fn custom_relay_urls_allow_https_and_loopback_http() { let config = CoreNetworkConfig { - mode: CoreRelayMode::Custom, + mode: CoreRelayMode::StrictCustom, relay_urls: vec![ "https://relay.example.com".to_string(), "http://localhost:3340".to_string(), @@ -68,7 +79,7 @@ fn custom_relay_urls_reject_unsafe_or_ambiguous_values() { " https://relay.example.com", ] { let config = CoreNetworkConfig { - mode: CoreRelayMode::Custom, + mode: CoreRelayMode::StrictCustom, relay_urls: vec![value.to_string()], }; assert!( @@ -81,7 +92,7 @@ fn custom_relay_urls_reject_unsafe_or_ambiguous_values() { #[test] fn custom_relay_urls_are_bounded_and_unique_after_normalization() { let duplicates = CoreNetworkConfig { - mode: CoreRelayMode::Custom, + mode: CoreRelayMode::StrictCustom, relay_urls: vec![ "https://relay.example.com".to_string(), "https://relay.example.com/".to_string(), @@ -90,7 +101,7 @@ fn custom_relay_urls_are_bounded_and_unique_after_normalization() { assert!(duplicates.validated_relay_urls().is_err()); let too_many = CoreNetworkConfig { - mode: CoreRelayMode::Custom, + mode: CoreRelayMode::StrictCustom, relay_urls: (0..=MAX_CUSTOM_RELAYS) .map(|index| format!("https://relay-{index}.example.com")) .collect(), @@ -98,7 +109,7 @@ fn custom_relay_urls_are_bounded_and_unique_after_normalization() { assert!(too_many.validated_relay_urls().is_err()); let too_long = CoreNetworkConfig { - mode: CoreRelayMode::Custom, + mode: CoreRelayMode::StrictCustom, relay_urls: vec![format!( "https://{}.example.com", "a".repeat(MAX_RELAY_URL_BYTES) @@ -119,7 +130,7 @@ fn strict_custom_mode_filters_peer_relays_but_retains_direct_addresses() { let filtered = filter_peer_addr_for_relay_mode( &addr, - CoreRelayMode::Custom, + CoreRelayMode::StrictCustom, std::slice::from_ref(&allowed), ) .unwrap(); @@ -140,14 +151,32 @@ fn strict_custom_mode_filters_peer_relays_but_retains_direct_addresses() { EndpointAddr::new(SecretKey::generate().public()).with_relay_url(disallowed); assert!(filter_peer_addr_for_relay_mode( &disallowed_only, - CoreRelayMode::Custom, + CoreRelayMode::StrictCustom, std::slice::from_ref(&allowed), ) .is_err()); + + let fallback_filtered = filter_peer_addr_for_relay_mode( + &addr, + CoreRelayMode::CustomWithDirectFallback, + std::slice::from_ref(&allowed), + ) + .unwrap(); + assert_eq!(fallback_filtered, filtered); + + let local_only = filter_peer_addr_for_relay_mode(&addr, CoreRelayMode::LocalOnly, &[]).unwrap(); + assert_eq!(local_only.relay_urls().count(), 0); + assert_eq!( + local_only.ip_addrs().copied().collect::>(), + vec![direct] + ); + assert!( + filter_peer_addr_for_relay_mode(&disallowed_only, CoreRelayMode::LocalOnly, &[]).is_err() + ); } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn unreachable_relay_wait_is_bounded_and_actionable_for_each_mode() { +async fn relay_wait_enforces_only_strict_custom_mode() { let relay_url: RelayUrl = "http://127.0.0.1:9".parse().unwrap(); let endpoint = Endpoint::builder(presets::Minimal) .relay_mode(RelayMode::custom([relay_url.clone()])) @@ -158,7 +187,7 @@ async fn unreachable_relay_wait_is_bounded_and_actionable_for_each_mode() { let error = wait_for_relay( &endpoint, - CoreRelayMode::Custom, + CoreRelayMode::StrictCustom, std::slice::from_ref(&relay_url), Duration::from_millis(50), ) @@ -169,18 +198,35 @@ async fn unreachable_relay_wait_is_bounded_and_actionable_for_each_mode() { assert!(error.to_string().contains(relay_url.as_str())); assert!(error.to_string().contains("verify the URLs")); - let automatic_error = wait_for_relay( + let automatic_status = wait_for_relay( &endpoint, CoreRelayMode::Automatic, &[], Duration::from_millis(50), ) .await - .unwrap_err(); + .unwrap(); assert!(started.elapsed() < Duration::from_secs(1)); - assert!(automatic_error.to_string().contains("automatic relays")); - assert!(automatic_error - .to_string() - .contains("verify network access")); + assert_eq!(automatic_status, RelayStatus::Unreachable); + + let fallback_status = wait_for_relay( + &endpoint, + CoreRelayMode::CustomWithDirectFallback, + std::slice::from_ref(&relay_url), + Duration::from_millis(50), + ) + .await + .unwrap(); + assert_eq!(fallback_status, RelayStatus::Unreachable); + + let local_only_status = wait_for_relay( + &endpoint, + CoreRelayMode::LocalOnly, + &[], + Duration::from_millis(50), + ) + .await + .unwrap(); + assert_eq!(local_only_status, RelayStatus::Disabled); endpoint.close().await; } diff --git a/crates/vnidrop/src/tests/ticket.rs b/crates/vnidrop/src/tests/ticket.rs index e5fcb13..67d5e90 100644 --- a/crates/vnidrop/src/tests/ticket.rs +++ b/crates/vnidrop/src/tests/ticket.rs @@ -120,14 +120,21 @@ fn saved_ticket_relay_profile_matching_is_mode_aware_and_order_insensitive() { assert!(ticket_matches_relay_profile( &custom_ticket, &limits, - CoreRelayMode::Custom, + CoreRelayMode::StrictCustom, + &[relay_b.clone(), relay_a.clone()], + ) + .unwrap()); + assert!(ticket_matches_relay_profile( + &custom_ticket, + &limits, + CoreRelayMode::CustomWithDirectFallback, &[relay_b.clone(), relay_a.clone()], ) .unwrap()); assert!(!ticket_matches_relay_profile( &custom_ticket, &limits, - CoreRelayMode::Custom, + CoreRelayMode::StrictCustom, &[relay_a.clone(), relay_c], ) .unwrap()); @@ -145,10 +152,21 @@ fn saved_ticket_relay_profile_matching_is_mode_aware_and_order_insensitive() { assert!(!ticket_matches_relay_profile( &automatic_ticket, &limits, - CoreRelayMode::Custom, + CoreRelayMode::StrictCustom, &[relay_a], ) .unwrap()); + assert!(ticket_matches_relay_profile( + &automatic_ticket, + &limits, + CoreRelayMode::LocalOnly, + &[], + ) + .unwrap()); + assert!( + !ticket_matches_relay_profile(&custom_ticket, &limits, CoreRelayMode::LocalOnly, &[],) + .unwrap() + ); } #[test] diff --git a/crates/vnidrop/src/ticket.rs b/crates/vnidrop/src/ticket.rs index efb5951..d2f108e 100644 --- a/crates/vnidrop/src/ticket.rs +++ b/crates/vnidrop/src/ticket.rs @@ -142,7 +142,7 @@ pub(crate) fn parse_transfer_ticket_with_limits( Vec::new() } else { CoreNetworkConfig { - mode: CoreRelayMode::Custom, + mode: CoreRelayMode::StrictCustom, relay_urls: ticket.relay_urls, } .validated_relay_urls() @@ -171,7 +171,7 @@ pub(crate) fn ticket_matches_relay_profile( let parsed = parse_transfer_ticket_with_limits(value, limits)?; match relay_mode { CoreRelayMode::Automatic => Ok(parsed.advertised_custom_relay_urls.is_empty()), - CoreRelayMode::Custom => { + CoreRelayMode::StrictCustom | CoreRelayMode::CustomWithDirectFallback => { let advertised = parsed .advertised_custom_relay_urls .into_iter() @@ -179,6 +179,8 @@ pub(crate) fn ticket_matches_relay_profile( let configured = custom_relay_urls.iter().cloned().collect::>(); Ok(advertised == configured) } + CoreRelayMode::LocalOnly => Ok(parsed.advertised_custom_relay_urls.is_empty() + && parsed.blob_ticket.addr().relay_urls().next().is_none()), } } diff --git a/crates/vnidrop/tests/custom_relay.rs b/crates/vnidrop/tests/custom_relay.rs index a89e7d3..b2dae70 100644 --- a/crates/vnidrop/tests/custom_relay.rs +++ b/crates/vnidrop/tests/custom_relay.rs @@ -11,24 +11,64 @@ use vnidrop::{CoreNetworkConfig, CoreRelayMode}; fn custom_config(relay_urls: &[&str]) -> CoreNetworkConfig { CoreNetworkConfig { - mode: CoreRelayMode::Custom, + mode: CoreRelayMode::StrictCustom, relay_urls: relay_urls.iter().map(ToString::to_string).collect(), } } +fn local_only_config() -> CoreNetworkConfig { + CoreNetworkConfig { + mode: CoreRelayMode::LocalOnly, + relay_urls: Vec::new(), + } +} + fn read_blob_ticket(ticket: &str) -> (Value, BlobTicket) { let encoded = ticket.strip_prefix("vnd1:").unwrap(); let payload = BASE64URL_NOPAD.decode(encoded.as_bytes()).unwrap(); let value: Value = serde_json::from_slice(&payload).unwrap(); let blob_ticket = BlobTicket::from_str(value["blob_ticket"].as_str().unwrap()).unwrap(); let (mut addr, hash, format) = blob_ticket.into_parts(); - for relay_url in value["relay_urls"].as_array().unwrap() { - addr = addr.with_relay_url(relay_url.as_str().unwrap().parse().unwrap()); + if let Some(relay_urls) = value["relay_urls"].as_array() { + for relay_url in relay_urls { + addr = addr.with_relay_url(relay_url.as_str().unwrap().parse().unwrap()); + } } let blob_ticket = BlobTicket::new(addr, hash, format); (value, blob_ticket) } +#[test] +fn local_only_mode_advertises_direct_addresses_and_transfers_on_lan() { + let sender = TestNode::with_network_config(local_only_config()); + let receiver = TestNode::with_network_config(local_only_config()); + let source_dir = tempfile::tempdir().unwrap(); + let output_dir = tempfile::tempdir().unwrap(); + let source_path = source_dir.path().join("local-only.txt"); + std::fs::write(&source_path, b"direct on the local network").unwrap(); + + let share = share_path(&sender.core, &source_path, 402, "local-only.txt", false); + let (ticket_value, blob_ticket) = read_blob_ticket(&share.ticket); + assert!(ticket_value.get("relay_urls").is_none()); + assert_eq!(blob_ticket.addr().relay_urls().count(), 0); + assert!(!sender.core.status().addr.contains("iroh.link")); + + receive_with_response( + &sender.core, + share.transfer_id, + receiver.core.arc(), + share.ticket, + output_dir.path(), + true, + ) + .unwrap(); + + assert_eq!( + std::fs::read(output_dir.path().join("local-only.txt")).unwrap(), + b"direct on the local network" + ); +} + fn with_relay_only_address(ticket: &str, relay_url: &str) -> String { let (mut value, blob_ticket) = read_blob_ticket(ticket); let relay_url: RelayUrl = relay_url.parse().unwrap(); diff --git a/crates/vnidrop/tests/lifecycle.rs b/crates/vnidrop/tests/lifecycle.rs index 59ada03..06bc25c 100644 --- a/crates/vnidrop/tests/lifecycle.rs +++ b/crates/vnidrop/tests/lifecycle.rs @@ -151,7 +151,7 @@ fn persisted_share_is_revoked_when_restarted_with_a_different_relay_profile() { core_dir.path(), Arc::new(RecordingSink::default()), CoreNetworkConfig { - mode: CoreRelayMode::Custom, + mode: CoreRelayMode::StrictCustom, relay_urls: vec![relay_a.url.clone()], }, ); @@ -163,7 +163,7 @@ fn persisted_share_is_revoked_when_restarted_with_a_different_relay_profile() { core_dir.path(), Arc::new(RecordingSink::default()), CoreNetworkConfig { - mode: CoreRelayMode::Custom, + mode: CoreRelayMode::StrictCustom, relay_urls: vec![relay_b.url.clone()], }, ); diff --git a/localization/strings.json b/localization/strings.json index acd721c..50d077f 100644 --- a/localization/strings.json +++ b/localization/strings.json @@ -2598,15 +2598,15 @@ "relay_mode_automatic": { "context": "Network settings label for VniDrop's automatic public relay mode.", "translations": { - "en": "Automatic", - "fr": "Automatique", - "es": "Automático", - "it": "Automatica", - "de": "Automatisch", - "pt": "Automático", - "pl": "Automatyczny", - "nl": "Automatisch", - "ru": "Автоматически" + "en": "Automatic (recommended)", + "fr": "Automatique (recommandé)", + "es": "Automático (recomendado)", + "it": "Automatica (consigliata)", + "de": "Automatisch (empfohlen)", + "pt": "Automático (recomendado)", + "pl": "Automatyczny (zalecany)", + "nl": "Automatisch (aanbevolen)", + "ru": "Автоматически (рекомендуется)" } }, "relay_mode_automatic_description": { @@ -2626,29 +2626,85 @@ "relay_mode_custom": { "context": "Network settings label for strict custom relay mode.", "translations": { - "en": "Custom", - "fr": "Personnalisé", - "es": "Personalizado", - "it": "Personalizzata", - "de": "Benutzerdefiniert", - "pt": "Personalizado", - "pl": "Niestandardowy", - "nl": "Aangepast", - "ru": "Пользовательский" + "en": "Strict custom", + "fr": "Personnalisé strict", + "es": "Personalizado estricto", + "it": "Personalizzata rigorosa", + "de": "Strikt benutzerdefiniert", + "pt": "Personalizado estrito", + "pl": "Ścisły niestandardowy", + "nl": "Strikt aangepast", + "ru": "Строго пользовательский" } }, "relay_mode_custom_description": { "context": "Network settings description of strict custom relay behavior.", "translations": { - "en": "Use only the relay servers below.", - "fr": "Utiliser uniquement les serveurs relais ci-dessous.", - "es": "Usa únicamente los servidores de retransmisión indicados a continuación.", - "it": "Usa solo i server relay indicati di seguito.", - "de": "Verwendet ausschließlich die unten aufgeführten Relay-Server.", - "pt": "Utiliza apenas os servidores de retransmissão abaixo.", - "pl": "Używa wyłącznie poniższych serwerów przekaźnikowych.", - "nl": "Gebruikt alleen de onderstaande relayservers.", - "ru": "Использовать только указанные ниже серверы-ретрансляторы." + "en": "Use only the configured custom relays or direct connections. Report an error if no custom relay can be established.", + "fr": "Utilise uniquement les relais personnalisés configurés ou les connexions directes. Signale une erreur si aucun relais personnalisé ne peut être établi.", + "es": "Usa solo los relés personalizados configurados o conexiones directas. Informa de un error si no se puede establecer ningún relé personalizado.", + "it": "Usa solo i relay personalizzati configurati o connessioni dirette. Segnala un errore se non è possibile stabilire alcun relay personalizzato.", + "de": "Verwendet nur die konfigurierten eigenen Relays oder Direktverbindungen. Meldet einen Fehler, wenn kein eigenes Relay erreichbar ist.", + "pt": "Utiliza apenas os retransmissores personalizados configurados ou ligações diretas. Apresenta um erro se não for possível estabelecer nenhum retransmissor personalizado.", + "pl": "Używa tylko skonfigurowanych własnych przekaźników lub połączeń bezpośrednich. Zgłasza błąd, jeśli nie można połączyć się z żadnym własnym przekaźnikiem.", + "nl": "Gebruikt alleen de ingestelde aangepaste relays of rechtstreekse verbindingen. Meldt een fout als geen aangepaste relay bereikbaar is.", + "ru": "Использует только настроенные пользовательские ретрансляторы или прямые соединения. Сообщает об ошибке, если ни один пользовательский ретранслятор недоступен." + } + }, + "relay_mode_custom_direct_fallback": { + "context": "Network settings label for custom relays that allow direct-only startup fallback.", + "translations": { + "en": "Custom with direct fallback", + "fr": "Personnalisé avec repli direct", + "es": "Personalizado con conexión directa de reserva", + "it": "Personalizzata con ripiego diretto", + "de": "Benutzerdefiniert mit direktem Rückfall", + "pt": "Personalizado com alternativa direta", + "pl": "Niestandardowy z trybem bezpośrednim", + "nl": "Aangepast met directe terugval", + "ru": "Пользовательский с прямым резервом" + } + }, + "relay_mode_custom_direct_fallback_description": { + "context": "Network settings description of custom relays with direct-only startup fallback.", + "translations": { + "en": "Prefer the configured custom relays. If unavailable, continue with direct connections only. Never use public relays.", + "fr": "Préfère les relais personnalisés configurés. S’ils sont indisponibles, continue uniquement avec des connexions directes. N’utilise jamais les relais publics.", + "es": "Prefiere los relés personalizados configurados. Si no están disponibles, continúa solo con conexiones directas. Nunca usa relés públicos.", + "it": "Preferisce i relay personalizzati configurati. Se non sono disponibili, continua solo con connessioni dirette. Non usa mai relay pubblici.", + "de": "Bevorzugt die konfigurierten eigenen Relays. Sind sie nicht verfügbar, werden nur Direktverbindungen verwendet. Öffentliche Relays werden nie genutzt.", + "pt": "Prefere os retransmissores personalizados configurados. Se não estiverem disponíveis, continua apenas com ligações diretas. Nunca utiliza retransmissores públicos.", + "pl": "Preferuje skonfigurowane własne przekaźniki. Jeśli są niedostępne, kontynuuje tylko przez połączenia bezpośrednie. Nigdy nie używa publicznych przekaźników.", + "nl": "Geeft de voorkeur aan de ingestelde aangepaste relays. Als die niet beschikbaar zijn, worden alleen rechtstreekse verbindingen gebruikt. Openbare relays worden nooit gebruikt.", + "ru": "Предпочитает настроенные пользовательские ретрансляторы. Если они недоступны, продолжает работу только через прямые соединения. Публичные ретрансляторы не используются." + } + }, + "relay_mode_local_only": { + "context": "Network settings label for direct connections without any relay.", + "translations": { + "en": "Local only", + "fr": "Réseau local uniquement", + "es": "Solo red local", + "it": "Solo rete locale", + "de": "Nur lokales Netzwerk", + "pt": "Apenas rede local", + "pl": "Tylko sieć lokalna", + "nl": "Alleen lokaal netwerk", + "ru": "Только локальная сеть" + } + }, + "relay_mode_local_only_description": { + "context": "Network settings description of direct-only local-network mode.", + "translations": { + "en": "Disable all relays and allow only direct connections, primarily for devices on the same network.", + "fr": "Désactive tous les relais et autorise uniquement les connexions directes, principalement pour les appareils sur le même réseau.", + "es": "Desactiva todos los relés y permite solo conexiones directas, principalmente para dispositivos de la misma red.", + "it": "Disattiva tutti i relay e consente solo connessioni dirette, soprattutto per dispositivi sulla stessa rete.", + "de": "Deaktiviert alle Relays und erlaubt nur Direktverbindungen, hauptsächlich für Geräte im selben Netzwerk.", + "pt": "Desativa todos os retransmissores e permite apenas ligações diretas, principalmente para dispositivos na mesma rede.", + "pl": "Wyłącza wszystkie przekaźniki i zezwala tylko na połączenia bezpośrednie, głównie dla urządzeń w tej samej sieci.", + "nl": "Schakelt alle relays uit en staat alleen rechtstreekse verbindingen toe, vooral voor apparaten in hetzelfde netwerk.", + "ru": "Отключает все ретрансляторы и разрешает только прямые соединения, прежде всего для устройств в одной сети." } }, "relay_privacy_description": { @@ -2710,15 +2766,15 @@ "relay_strict_warning": { "context": "Network settings warning that custom relay mode has no public fallback or discovery.", "translations": { - "en": "Custom mode is strict: VniDrop will not fall back to public relays or public discovery. Other devices must be able to reach your configured relays.", - "fr": "Le mode personnalisé est strict : VniDrop n’utilisera ni les relais publics ni la découverte publique comme solution de repli. Les autres appareils doivent pouvoir accéder aux relais configurés.", - "es": "El modo personalizado es estricto: VniDrop no recurrirá a relés públicos ni al descubrimiento público. Los demás dispositivos deben poder acceder a los relés configurados.", - "it": "La modalità personalizzata è rigorosa: VniDrop non userà relay pubblici o il rilevamento pubblico come ripiego. Gli altri dispositivi devono poter raggiungere i relay configurati.", - "de": "Der benutzerdefinierte Modus ist strikt: VniDrop greift weder auf öffentliche Relays noch auf öffentliche Erkennung zurück. Andere Geräte müssen Ihre konfigurierten Relays erreichen können.", - "pt": "O modo personalizado é estrito: o VniDrop não recorrerá a retransmissores públicos nem à descoberta pública. Os outros dispositivos têm de conseguir aceder aos retransmissores configurados.", - "pl": "Tryb niestandardowy jest rygorystyczny: VniDrop nie użyje awaryjnie publicznych przekaźników ani publicznego wykrywania. Inne urządzenia muszą mieć dostęp do skonfigurowanych przekaźników.", - "nl": "De aangepaste modus is strikt: VniDrop valt niet terug op openbare relays of openbare detectie. Andere apparaten moeten uw ingestelde relays kunnen bereiken.", - "ru": "Пользовательский режим работает строго: VniDrop не будет переключаться на публичные ретрансляторы или публичное обнаружение. Другие устройства должны иметь доступ к настроенным ретрансляторам." + "en": "Strict custom mode will not start unless at least one configured relay is reachable. VniDrop never uses public relays or public discovery in this mode.", + "fr": "Le mode personnalisé strict ne démarre que si au moins un relais configuré est accessible. VniDrop n’utilise jamais de relais ni de découverte publics dans ce mode.", + "es": "El modo personalizado estricto no se inicia a menos que se pueda acceder al menos a un relé configurado. VniDrop nunca usa relés ni descubrimiento públicos en este modo.", + "it": "La modalità personalizzata rigorosa si avvia solo se almeno un relay configurato è raggiungibile. In questa modalità VniDrop non usa mai relay o rilevamento pubblici.", + "de": "Der strikt benutzerdefinierte Modus startet nur, wenn mindestens ein konfiguriertes Relay erreichbar ist. VniDrop verwendet in diesem Modus nie öffentliche Relays oder öffentliche Erkennung.", + "pt": "O modo personalizado estrito só inicia se pelo menos um retransmissor configurado estiver acessível. Neste modo, o VniDrop nunca utiliza retransmissores nem descoberta públicos.", + "pl": "Ścisły tryb niestandardowy uruchamia się tylko wtedy, gdy co najmniej jeden skonfigurowany przekaźnik jest dostępny. VniDrop nigdy nie używa w tym trybie publicznych przekaźników ani publicznego wykrywania.", + "nl": "De strikt aangepaste modus start alleen als minstens één ingestelde relay bereikbaar is. VniDrop gebruikt in deze modus nooit openbare relays of openbare detectie.", + "ru": "Строго пользовательский режим запускается, только если доступен хотя бы один настроенный ретранслятор. В этом режиме VniDrop никогда не использует публичные ретрансляторы или публичное обнаружение." } }, "relay_validation_duplicate_url": { diff --git a/shared/src/commonMain/composeResources/values-de/strings.xml b/shared/src/commonMain/composeResources/values-de/strings.xml index d3c1aa2..e03f090 100644 --- a/shared/src/commonMain/composeResources/values-de/strings.xml +++ b/shared/src/commonMain/composeResources/values-de/strings.xml @@ -177,15 +177,19 @@ Beim Anwenden wird die Netzwerkverbindung von VniDrop neu gestartet. Beenden Sie zuerst aktive Übertragungen und Freigaben. Vorhandene Einladungen müssen eventuell erneut geteilt werden. Geben Sie pro Zeile eine HTTPS-Relay-URL ein. Anmeldedaten in URLs werden nicht unterstützt. Das TLS-Zertifikat muss von einer öffentlich vertrauenswürdigen Zertifizierungsstelle ausgestellt sein. Relay-URLs - Automatisch + Automatisch (empfohlen) Verwendet die öffentliche Standard-Relay-Infrastruktur von VniDrop, wenn keine direkte Verbindung möglich ist. - Benutzerdefiniert - Verwendet ausschließlich die unten aufgeführten Relay-Server. + Strikt benutzerdefiniert + Verwendet nur die konfigurierten eigenen Relays oder Direktverbindungen. Meldet einen Fehler, wenn kein eigenes Relay erreichbar ist. + Benutzerdefiniert mit direktem Rückfall + Bevorzugt die konfigurierten eigenen Relays. Sind sie nicht verfügbar, werden nur Direktverbindungen verwendet. Öffentliche Relays werden nie genutzt. + Nur lokales Netzwerk + Deaktiviert alle Relays und erlaubt nur Direktverbindungen, hauptsächlich für Geräte im selben Netzwerk. Relays leiten verschlüsselten Datenverkehr weiter und können Ihre Dateien nicht lesen, ihr Betreiber kann jedoch Verbindungsmetadaten sehen. Relay-Server entfernen Die vorherigen Netzwerkeinstellungen konnten nicht wiederhergestellt werden. Starten Sie VniDrop neu und prüfen Sie Ihre Relay-Konfiguration. Netzwerkeinstellungen angewendet. - Der benutzerdefinierte Modus ist strikt: VniDrop greift weder auf öffentliche Relays noch auf öffentliche Erkennung zurück. Andere Geräte müssen Ihre konfigurierten Relays erreichen können. + Der strikt benutzerdefinierte Modus startet nur, wenn mindestens ein konfiguriertes Relay erreichbar ist. VniDrop verwendet in diesem Modus nie öffentliche Relays oder öffentliche Erkennung. Die Relay-URL in Zeile %1$d ist bereits zuvor eingetragen. Die Relay-URL in Zeile %1$d muss mit https:// beginnen. Die Relay-URL in Zeile %1$d ist ungültig. diff --git a/shared/src/commonMain/composeResources/values-es/strings.xml b/shared/src/commonMain/composeResources/values-es/strings.xml index ff99db7..5c32bb0 100644 --- a/shared/src/commonMain/composeResources/values-es/strings.xml +++ b/shared/src/commonMain/composeResources/values-es/strings.xml @@ -177,15 +177,19 @@ Al aplicar los ajustes, se reinicia la conexión de red de VniDrop. Detenga primero las transferencias y los elementos compartidos activos. Es posible que tenga que volver a compartir las invitaciones existentes. Introduzca una URL HTTPS de relé por línea. No se admiten credenciales en las URL. El certificado TLS debe ser emitido por una autoridad de certificación de confianza pública. URL de relés - Automático + Automático (recomendado) Usa la infraestructura pública de relés predeterminada de VniDrop cuando no haya una conexión directa disponible. - Personalizado - Usa únicamente los servidores de retransmisión indicados a continuación. + Personalizado estricto + Usa solo los relés personalizados configurados o conexiones directas. Informa de un error si no se puede establecer ningún relé personalizado. + Personalizado con conexión directa de reserva + Prefiere los relés personalizados configurados. Si no están disponibles, continúa solo con conexiones directas. Nunca usa relés públicos. + Solo red local + Desactiva todos los relés y permite solo conexiones directas, principalmente para dispositivos de la misma red. Los relés reenvían tráfico cifrado y no pueden leer sus archivos, pero su operador puede observar los metadatos de conexión. Eliminar servidor de retransmisión No se han podido restaurar los ajustes de red anteriores. Reinicie VniDrop y revise la configuración de relés. Ajustes de red aplicados. - El modo personalizado es estricto: VniDrop no recurrirá a relés públicos ni al descubrimiento público. Los demás dispositivos deben poder acceder a los relés configurados. + El modo personalizado estricto no se inicia a menos que se pueda acceder al menos a un relé configurado. VniDrop nunca usa relés ni descubrimiento públicos en este modo. La URL de relé de la línea %1$d duplica una entrada anterior. La URL de relé de la línea %1$d debe empezar por https://. La URL de relé de la línea %1$d no es válida. diff --git a/shared/src/commonMain/composeResources/values-fr/strings.xml b/shared/src/commonMain/composeResources/values-fr/strings.xml index 42b92bf..cee1744 100644 --- a/shared/src/commonMain/composeResources/values-fr/strings.xml +++ b/shared/src/commonMain/composeResources/values-fr/strings.xml @@ -177,15 +177,19 @@ L’application de ces réglages redémarre la connexion réseau de VniDrop. Arrêtez d’abord les transferts et partages actifs. Il peut être nécessaire de partager à nouveau les invitations existantes. Saisissez une URL de relais HTTPS par ligne. Les identifiants dans les URL ne sont pas pris en charge. Le certificat TLS doit être émis par une autorité de certification reconnue publiquement. URL des relais - Automatique + Automatique (recommandé) Utiliser l’infrastructure de relais publique par défaut de VniDrop lorsqu’une connexion directe est indisponible. - Personnalisé - Utiliser uniquement les serveurs relais ci-dessous. + Personnalisé strict + Utilise uniquement les relais personnalisés configurés ou les connexions directes. Signale une erreur si aucun relais personnalisé ne peut être établi. + Personnalisé avec repli direct + Préfère les relais personnalisés configurés. S’ils sont indisponibles, continue uniquement avec des connexions directes. N’utilise jamais les relais publics. + Réseau local uniquement + Désactive tous les relais et autorise uniquement les connexions directes, principalement pour les appareils sur le même réseau. Les relais transmettent du trafic chiffré et ne peuvent pas lire vos fichiers, mais leur opérateur peut observer les métadonnées de connexion. Supprimer le serveur relais Impossible de restaurer les réglages réseau précédents. Redémarrez VniDrop et vérifiez votre configuration de relais. Réglages réseau appliqués. - Le mode personnalisé est strict : VniDrop n’utilisera ni les relais publics ni la découverte publique comme solution de repli. Les autres appareils doivent pouvoir accéder aux relais configurés. + Le mode personnalisé strict ne démarre que si au moins un relais configuré est accessible. VniDrop n’utilise jamais de relais ni de découverte publics dans ce mode. L’URL de relais à la ligne %1$d est identique à une entrée précédente. L’URL de relais à la ligne %1$d doit commencer par https://. L’URL de relais à la ligne %1$d n’est pas valide. diff --git a/shared/src/commonMain/composeResources/values-it/strings.xml b/shared/src/commonMain/composeResources/values-it/strings.xml index 2d64e59..25e6703 100644 --- a/shared/src/commonMain/composeResources/values-it/strings.xml +++ b/shared/src/commonMain/composeResources/values-it/strings.xml @@ -177,15 +177,19 @@ L’applicazione riavvia la connessione di rete di VniDrop. Interrompa prima i trasferimenti e le condivisioni attivi. Potrebbe essere necessario condividere di nuovo gli inviti esistenti. Inserisca un URL relay HTTPS per riga. Le credenziali negli URL non sono supportate. Il certificato TLS deve essere emesso da un’autorità di certificazione pubblicamente attendibile. URL relay - Automatica + Automatica (consigliata) Usa l’infrastruttura relay pubblica predefinita di VniDrop quando non è disponibile una connessione diretta. - Personalizzata - Usa solo i server relay indicati di seguito. + Personalizzata rigorosa + Usa solo i relay personalizzati configurati o connessioni dirette. Segnala un errore se non è possibile stabilire alcun relay personalizzato. + Personalizzata con ripiego diretto + Preferisce i relay personalizzati configurati. Se non sono disponibili, continua solo con connessioni dirette. Non usa mai relay pubblici. + Solo rete locale + Disattiva tutti i relay e consente solo connessioni dirette, soprattutto per dispositivi sulla stessa rete. I relay inoltrano traffico cifrato e non possono leggere i suoi file, ma il loro operatore può osservare i metadati di connessione. Rimuovi server relay Impossibile ripristinare le impostazioni di rete precedenti. Riavvii VniDrop e verifichi la configurazione dei relay. Impostazioni di rete applicate. - La modalità personalizzata è rigorosa: VniDrop non userà relay pubblici o il rilevamento pubblico come ripiego. Gli altri dispositivi devono poter raggiungere i relay configurati. + La modalità personalizzata rigorosa si avvia solo se almeno un relay configurato è raggiungibile. In questa modalità VniDrop non usa mai relay o rilevamento pubblici. L’URL relay alla riga %1$d duplica una voce precedente. L’URL relay alla riga %1$d deve iniziare con https://. L’URL relay alla riga %1$d non è valido. diff --git a/shared/src/commonMain/composeResources/values-nl/strings.xml b/shared/src/commonMain/composeResources/values-nl/strings.xml index 72fb6d0..a8d340a 100644 --- a/shared/src/commonMain/composeResources/values-nl/strings.xml +++ b/shared/src/commonMain/composeResources/values-nl/strings.xml @@ -177,15 +177,19 @@ Bij het toepassen wordt de netwerkverbinding van VniDrop opnieuw gestart. Stop eerst actieve overdrachten en gedeelde items. Bestaande uitnodigingen moeten mogelijk opnieuw worden gedeeld. Voer per regel één HTTPS-relay-URL in. Aanmeldgegevens in URL\'s worden niet ondersteund. Het TLS-certificaat moet zijn uitgegeven door een openbaar vertrouwde certificeringsinstantie. Relay-URL\'s - Automatisch + Automatisch (aanbevolen) Gebruikt de standaard openbare relay-infrastructuur van VniDrop wanneer geen directe verbinding beschikbaar is. - Aangepast - Gebruikt alleen de onderstaande relayservers. + Strikt aangepast + Gebruikt alleen de ingestelde aangepaste relays of rechtstreekse verbindingen. Meldt een fout als geen aangepaste relay bereikbaar is. + Aangepast met directe terugval + Geeft de voorkeur aan de ingestelde aangepaste relays. Als die niet beschikbaar zijn, worden alleen rechtstreekse verbindingen gebruikt. Openbare relays worden nooit gebruikt. + Alleen lokaal netwerk + Schakelt alle relays uit en staat alleen rechtstreekse verbindingen toe, vooral voor apparaten in hetzelfde netwerk. Relays sturen versleuteld verkeer door en kunnen uw bestanden niet lezen, maar de beheerder kan verbindingsmetadata bekijken. Relayserver verwijderen De vorige netwerkinstellingen konden niet worden hersteld. Start VniDrop opnieuw en controleer uw relayconfiguratie. Netwerkinstellingen toegepast. - De aangepaste modus is strikt: VniDrop valt niet terug op openbare relays of openbare detectie. Andere apparaten moeten uw ingestelde relays kunnen bereiken. + De strikt aangepaste modus start alleen als minstens één ingestelde relay bereikbaar is. VniDrop gebruikt in deze modus nooit openbare relays of openbare detectie. De relay-URL op regel %1$d is gelijk aan een eerdere invoer. De relay-URL op regel %1$d moet beginnen met https://. De relay-URL op regel %1$d is ongeldig. diff --git a/shared/src/commonMain/composeResources/values-pl/strings.xml b/shared/src/commonMain/composeResources/values-pl/strings.xml index cb3a841..abd0c02 100644 --- a/shared/src/commonMain/composeResources/values-pl/strings.xml +++ b/shared/src/commonMain/composeResources/values-pl/strings.xml @@ -177,15 +177,19 @@ Zastosowanie ustawień ponownie uruchamia połączenie sieciowe VniDrop. Najpierw zatrzymaj aktywne transfery i udostępnienia. Istniejące zaproszenia mogą wymagać ponownego udostępnienia. Wprowadź po jednym adresie URL HTTPS przekaźnika w każdym wierszu. Dane logowania w adresach URL nie są obsługiwane. Certyfikat TLS musi być wystawiony przez publicznie zaufany urząd certyfikacji. Adresy URL przekaźników - Automatyczny + Automatyczny (zalecany) Używa domyślnej publicznej infrastruktury przekaźników VniDrop, gdy połączenie bezpośrednie jest niedostępne. - Niestandardowy - Używa wyłącznie poniższych serwerów przekaźnikowych. + Ścisły niestandardowy + Używa tylko skonfigurowanych własnych przekaźników lub połączeń bezpośrednich. Zgłasza błąd, jeśli nie można połączyć się z żadnym własnym przekaźnikiem. + Niestandardowy z trybem bezpośrednim + Preferuje skonfigurowane własne przekaźniki. Jeśli są niedostępne, kontynuuje tylko przez połączenia bezpośrednie. Nigdy nie używa publicznych przekaźników. + Tylko sieć lokalna + Wyłącza wszystkie przekaźniki i zezwala tylko na połączenia bezpośrednie, głównie dla urządzeń w tej samej sieci. Przekaźniki przesyłają zaszyfrowany ruch i nie mogą odczytać plików, ale ich operator może obserwować metadane połączenia. Usuń serwer przekaźnikowy Nie udało się przywrócić poprzednich ustawień sieci. Uruchom ponownie VniDrop i sprawdź konfigurację przekaźników. Zastosowano ustawienia sieci. - Tryb niestandardowy jest rygorystyczny: VniDrop nie użyje awaryjnie publicznych przekaźników ani publicznego wykrywania. Inne urządzenia muszą mieć dostęp do skonfigurowanych przekaźników. + Ścisły tryb niestandardowy uruchamia się tylko wtedy, gdy co najmniej jeden skonfigurowany przekaźnik jest dostępny. VniDrop nigdy nie używa w tym trybie publicznych przekaźników ani publicznego wykrywania. Adres URL przekaźnika w wierszu %1$d powtarza wcześniejszy wpis. Adres URL przekaźnika w wierszu %1$d musi zaczynać się od https://. Adres URL przekaźnika w wierszu %1$d jest nieprawidłowy. diff --git a/shared/src/commonMain/composeResources/values-pt/strings.xml b/shared/src/commonMain/composeResources/values-pt/strings.xml index b324393..dc43f18 100644 --- a/shared/src/commonMain/composeResources/values-pt/strings.xml +++ b/shared/src/commonMain/composeResources/values-pt/strings.xml @@ -177,15 +177,19 @@ A aplicação reinicia a ligação de rede do VniDrop. Pare primeiro as transferências e partilhas ativas. Poderá ser necessário voltar a partilhar os convites existentes. Introduza um URL HTTPS de retransmissor por linha. Não são suportadas credenciais nos URLs. O certificado TLS tem de ser emitido por uma autoridade de certificação publicamente reconhecida. URLs dos retransmissores - Automático + Automático (recomendado) Utiliza a infraestrutura pública de retransmissores predefinida do VniDrop quando não está disponível uma ligação direta. - Personalizado - Utiliza apenas os servidores de retransmissão abaixo. + Personalizado estrito + Utiliza apenas os retransmissores personalizados configurados ou ligações diretas. Apresenta um erro se não for possível estabelecer nenhum retransmissor personalizado. + Personalizado com alternativa direta + Prefere os retransmissores personalizados configurados. Se não estiverem disponíveis, continua apenas com ligações diretas. Nunca utiliza retransmissores públicos. + Apenas rede local + Desativa todos os retransmissores e permite apenas ligações diretas, principalmente para dispositivos na mesma rede. Os retransmissores encaminham tráfego cifrado e não conseguem ler os seus ficheiros, mas o operador pode observar metadados da ligação. Remover servidor de retransmissão Não foi possível restaurar as definições de rede anteriores. Reinicie o VniDrop e reveja a configuração dos retransmissores. Definições de rede aplicadas. - O modo personalizado é estrito: o VniDrop não recorrerá a retransmissores públicos nem à descoberta pública. Os outros dispositivos têm de conseguir aceder aos retransmissores configurados. + O modo personalizado estrito só inicia se pelo menos um retransmissor configurado estiver acessível. Neste modo, o VniDrop nunca utiliza retransmissores nem descoberta públicos. O URL do retransmissor na linha %1$d duplica uma entrada anterior. O URL do retransmissor na linha %1$d tem de começar por https://. O URL do retransmissor na linha %1$d não é válido. diff --git a/shared/src/commonMain/composeResources/values-ru/strings.xml b/shared/src/commonMain/composeResources/values-ru/strings.xml index 066de52..d424d06 100644 --- a/shared/src/commonMain/composeResources/values-ru/strings.xml +++ b/shared/src/commonMain/composeResources/values-ru/strings.xml @@ -177,15 +177,19 @@ При применении сетевое соединение VniDrop перезапускается. Сначала остановите активные передачи и раздачи. Возможно, существующие приглашения потребуется отправить повторно. Введите по одному HTTPS-адресу ретранслятора в строке. Учётные данные в URL-адресах не поддерживаются. Сертификат TLS должен быть выдан общедоступным доверенным центром сертификации. URL-адреса ретрансляторов - Автоматически + Автоматически (рекомендуется) Использовать стандартную публичную инфраструктуру ретрансляторов VniDrop, если прямое соединение недоступно. - Пользовательский - Использовать только указанные ниже серверы-ретрансляторы. + Строго пользовательский + Использует только настроенные пользовательские ретрансляторы или прямые соединения. Сообщает об ошибке, если ни один пользовательский ретранслятор недоступен. + Пользовательский с прямым резервом + Предпочитает настроенные пользовательские ретрансляторы. Если они недоступны, продолжает работу только через прямые соединения. Публичные ретрансляторы не используются. + Только локальная сеть + Отключает все ретрансляторы и разрешает только прямые соединения, прежде всего для устройств в одной сети. Ретрансляторы передают зашифрованный трафик и не могут читать ваши файлы, но их оператор может видеть метаданные соединения. Удалить сервер-ретранслятор Не удалось восстановить предыдущие настройки сети. Перезапустите VniDrop и проверьте конфигурацию ретрансляторов. Настройки сети применены. - Пользовательский режим работает строго: VniDrop не будет переключаться на публичные ретрансляторы или публичное обнаружение. Другие устройства должны иметь доступ к настроенным ретрансляторам. + Строго пользовательский режим запускается, только если доступен хотя бы один настроенный ретранслятор. В этом режиме VniDrop никогда не использует публичные ретрансляторы или публичное обнаружение. URL-адрес ретранслятора в строке %1$d повторяет предыдущую запись. URL-адрес ретранслятора в строке %1$d должен начинаться с https://. URL-адрес ретранслятора в строке %1$d недействителен. diff --git a/shared/src/commonMain/composeResources/values/strings.xml b/shared/src/commonMain/composeResources/values/strings.xml index c593cf1..507b498 100644 --- a/shared/src/commonMain/composeResources/values/strings.xml +++ b/shared/src/commonMain/composeResources/values/strings.xml @@ -177,15 +177,19 @@ Applying restarts VniDrop’s network connection. Stop active transfers and shares first. Existing invitations may need to be shared again. Enter one HTTPS relay URL per line. URL credentials are not supported. The TLS certificate must be issued by a publicly trusted certificate authority. Relay URLs - Automatic + Automatic (recommended) Use VniDrop’s default public relay infrastructure when a direct connection is unavailable. - Custom - Use only the relay servers below. + Strict custom + Use only the configured custom relays or direct connections. Report an error if no custom relay can be established. + Custom with direct fallback + Prefer the configured custom relays. If unavailable, continue with direct connections only. Never use public relays. + Local only + Disable all relays and allow only direct connections, primarily for devices on the same network. Relays forward encrypted traffic and cannot read your files, but their operator can observe connection metadata. Remove relay server Could not restore the previous network settings. Restart VniDrop and review your relay configuration. Network settings applied. - Custom mode is strict: VniDrop will not fall back to public relays or public discovery. Other devices must be able to reach your configured relays. + Strict custom mode will not start unless at least one configured relay is reachable. VniDrop never uses public relays or public discovery in this mode. Relay URL on line %1$d duplicates an earlier entry. Relay URL on line %1$d must start with https://. Relay URL on line %1$d is not valid. diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/core/CoreModels.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/core/CoreModels.kt index ca2168f..c6dac49 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/core/CoreModels.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/core/CoreModels.kt @@ -29,7 +29,9 @@ enum class ShareAccessPolicy { enum class RelayMode { Automatic, - Custom, + StrictCustom, + CustomWithDirectFallback, + LocalOnly, } data class RelaySettings( @@ -37,6 +39,9 @@ data class RelaySettings( val relayUrls: List = emptyList(), ) +val RelayMode.usesCustomRelayUrls: Boolean + get() = this == RelayMode.StrictCustom || this == RelayMode.CustomWithDirectFallback + enum class TransferDirection { Send, Receive, diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/core/CoreRepository.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/core/CoreRepository.kt index 8095b47..0694237 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/core/CoreRepository.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/core/CoreRepository.kt @@ -293,10 +293,18 @@ class CoreRepository( private fun RelaySettings.toNative(): CoreNetworkConfig = when (mode) { RelayMode.Automatic -> defaultCoreNetworkConfig() - RelayMode.Custom -> CoreNetworkConfig( - mode = CoreRelayMode.CUSTOM, + RelayMode.StrictCustom -> CoreNetworkConfig( + mode = CoreRelayMode.STRICT_CUSTOM, relayUrls = relayUrls, ) + RelayMode.CustomWithDirectFallback -> CoreNetworkConfig( + mode = CoreRelayMode.CUSTOM_WITH_DIRECT_FALLBACK, + relayUrls = relayUrls, + ) + RelayMode.LocalOnly -> CoreNetworkConfig( + mode = CoreRelayMode.LOCAL_ONLY, + relayUrls = emptyList(), + ) } private fun CoreEvent.toModel(): CoreEventModel = CoreEventModel( diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/NetworkSettings.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/NetworkSettings.kt index 3f3d43d..899b61c 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/NetworkSettings.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/NetworkSettings.kt @@ -12,6 +12,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import com.vnidrop.app.core.RelayMode +import com.vnidrop.app.core.usesCustomRelayUrls import com.vnidrop.app.ui.components.Field import com.vnidrop.app.ui.components.PrimaryButton import com.vnidrop.app.ui.icons.AppIcon @@ -30,7 +31,11 @@ import vnidrop.shared.generated.resources.relay_custom_urls_label import vnidrop.shared.generated.resources.relay_mode_automatic import vnidrop.shared.generated.resources.relay_mode_automatic_description import vnidrop.shared.generated.resources.relay_mode_custom +import vnidrop.shared.generated.resources.relay_mode_custom_direct_fallback +import vnidrop.shared.generated.resources.relay_mode_custom_direct_fallback_description import vnidrop.shared.generated.resources.relay_mode_custom_description +import vnidrop.shared.generated.resources.relay_mode_local_only +import vnidrop.shared.generated.resources.relay_mode_local_only_description import vnidrop.shared.generated.resources.relay_privacy_description import vnidrop.shared.generated.resources.relay_restore_failed import vnidrop.shared.generated.resources.relay_strict_warning @@ -73,15 +78,33 @@ internal fun NetworkSettings( ) SettingsDivider() RelayModeRow( - icon = AppIcon.Radio, + icon = AppIcon.Shield, title = stringResource(Res.string.relay_mode_custom), description = stringResource(Res.string.relay_mode_custom_description), - selected = state.relayMode == RelayMode.Custom, + selected = state.relayMode == RelayMode.StrictCustom, enabled = !state.isApplyingRelaySettings, - onClick = { onModeChanged(RelayMode.Custom) }, + onClick = { onModeChanged(RelayMode.StrictCustom) }, + ) + SettingsDivider() + RelayModeRow( + icon = AppIcon.Radio, + title = stringResource(Res.string.relay_mode_custom_direct_fallback), + description = stringResource(Res.string.relay_mode_custom_direct_fallback_description), + selected = state.relayMode == RelayMode.CustomWithDirectFallback, + enabled = !state.isApplyingRelaySettings, + onClick = { onModeChanged(RelayMode.CustomWithDirectFallback) }, + ) + SettingsDivider() + RelayModeRow( + icon = AppIcon.CloudOff, + title = stringResource(Res.string.relay_mode_local_only), + description = stringResource(Res.string.relay_mode_local_only_description), + selected = state.relayMode == RelayMode.LocalOnly, + enabled = !state.isApplyingRelaySettings, + onClick = { onModeChanged(RelayMode.LocalOnly) }, ) } - if (state.relayMode == RelayMode.Custom) { + if (state.relayMode.usesCustomRelayUrls) { Field( value = state.relayUrlsText, onValueChange = onUrlsChanged, @@ -94,12 +117,14 @@ internal fun NetworkSettings( color = colors.foregroundLighter, style = MaterialTheme.typography.bodySmall, ) - Text( - stringResource(Res.string.relay_strict_warning), - color = colors.foregroundLight, - style = MaterialTheme.typography.bodySmall, - fontWeight = FontWeight.Medium, - ) + if (state.relayMode == RelayMode.StrictCustom) { + Text( + stringResource(Res.string.relay_strict_warning), + color = colors.foregroundLight, + style = MaterialTheme.typography.bodySmall, + fontWeight = FontWeight.Medium, + ) + } Text( stringResource(Res.string.relay_privacy_description), color = colors.foregroundLighter, diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/RelaySettingsValidation.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/RelaySettingsValidation.kt index 1fc4099..3f11add 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/RelaySettingsValidation.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/RelaySettingsValidation.kt @@ -2,6 +2,7 @@ package com.vnidrop.app.feature.settings import com.vnidrop.app.core.RelayMode import com.vnidrop.app.core.RelaySettings +import com.vnidrop.app.core.usesCustomRelayUrls sealed interface RelaySettingsInputError { data object MissingUrl : RelaySettingsInputError @@ -21,7 +22,7 @@ fun validateRelaySettings( urlsText: String, retainedUrls: List = emptyList(), ): RelaySettingsValidation { - if (mode == RelayMode.Automatic) { + if (!mode.usesCustomRelayUrls) { return RelaySettingsValidation(RelaySettings(mode, retainedUrls)) } val lines = urlsText.lineSequence() @@ -49,7 +50,7 @@ fun validateRelaySettings( } } } - return RelaySettingsValidation(RelaySettings(RelayMode.Custom, normalized)) + return RelaySettingsValidation(RelaySettings(mode, normalized)) } private sealed interface RelayUrlResult { diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsOverview.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsOverview.kt index b0142e3..997559f 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsOverview.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsOverview.kt @@ -21,6 +21,8 @@ import vnidrop.shared.generated.resources.notifications_title import vnidrop.shared.generated.resources.preferences_title import vnidrop.shared.generated.resources.relay_mode_automatic import vnidrop.shared.generated.resources.relay_mode_custom +import vnidrop.shared.generated.resources.relay_mode_custom_direct_fallback +import vnidrop.shared.generated.resources.relay_mode_local_only import vnidrop.shared.generated.resources.settings_title import vnidrop.shared.generated.resources.settings_network_title import vnidrop.shared.generated.resources.storage_title @@ -91,7 +93,9 @@ internal fun SettingsOverview( @Composable private fun relayModeLabel(mode: RelayMode): String = when (mode) { RelayMode.Automatic -> stringResource(Res.string.relay_mode_automatic) - RelayMode.Custom -> stringResource(Res.string.relay_mode_custom) + RelayMode.StrictCustom -> stringResource(Res.string.relay_mode_custom) + RelayMode.CustomWithDirectFallback -> stringResource(Res.string.relay_mode_custom_direct_fallback) + RelayMode.LocalOnly -> stringResource(Res.string.relay_mode_local_only) } @Composable diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsViewModel.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsViewModel.kt index 38ddb9d..15f1e63 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsViewModel.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsViewModel.kt @@ -13,6 +13,7 @@ import com.vnidrop.app.core.ReceiveFolder import com.vnidrop.app.core.RelayMode import com.vnidrop.app.core.RelaySettings import com.vnidrop.app.core.TransferStatus +import com.vnidrop.app.core.usesCustomRelayUrls import com.vnidrop.app.diagnostics.BugReportDraft import com.vnidrop.app.diagnostics.BugReportService import com.vnidrop.app.diagnostics.DiagnosticsBuildConfig @@ -113,7 +114,7 @@ data class SettingsState( ) { val hasRelaySettingsChanges: Boolean get() = relayMode != savedRelaySettings.mode || - (relayMode == RelayMode.Custom && relayUrlsText != savedRelaySettings.relayUrls.joinToString("\n")) + (relayMode.usesCustomRelayUrls && relayUrlsText != savedRelaySettings.relayUrls.joinToString("\n")) } sealed interface SettingsEffect { diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/preferences/AppPreferencesRepository.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/preferences/AppPreferencesRepository.kt index 57d120a..6f3090d 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/preferences/AppPreferencesRepository.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/preferences/AppPreferencesRepository.kt @@ -58,14 +58,14 @@ class AppPreferencesRepository( ) : PreferencesRepository { override val preferences: Flow = dataStore.data .catch { - emit(preferencesOf(PreferenceKeys.RelayMode to RelayMode.Custom.name)) + emit(preferencesOf(PreferenceKeys.RelayMode to RelayMode.StrictCustom.name)) } .map { prefs -> val storedRelayMode = prefs[PreferenceKeys.RelayMode] val parsedRelayMode = storedRelayMode?.let(::relayModeOrNull) val relayMode = when (storedRelayMode) { null -> RelayMode.Automatic - else -> parsedRelayMode ?: RelayMode.Custom + else -> parsedRelayMode ?: RelayMode.StrictCustom } val relayUrls = if (storedRelayMode != null && parsedRelayMode == null) { emptyList() @@ -199,6 +199,7 @@ private fun themeModeOrNull(raw: String): ThemeMode? = runCatching { ThemeMode.valueOf(raw) }.getOrNull() private fun relayModeOrNull(raw: String): RelayMode? = - runCatching { RelayMode.valueOf(raw) }.getOrNull() + if (raw == "Custom") RelayMode.StrictCustom + else runCatching { RelayMode.valueOf(raw) }.getOrNull() private const val AppPreferencesFileName = "app_preferences.preferences_pb" diff --git a/shared/src/commonTest/kotlin/com/vnidrop/app/feature/ViewModelsTest.kt b/shared/src/commonTest/kotlin/com/vnidrop/app/feature/ViewModelsTest.kt index 2283cdd..25e47e6 100644 --- a/shared/src/commonTest/kotlin/com/vnidrop/app/feature/ViewModelsTest.kt +++ b/shared/src/commonTest/kotlin/com/vnidrop/app/feature/ViewModelsTest.kt @@ -86,7 +86,7 @@ class ViewModelsTest { @Test fun appViewModelInitializesCoreWithSavedRelaySettings() = runTest { Dispatchers.setMain(StandardTestDispatcher(testScheduler)) - val custom = RelaySettings(RelayMode.Custom, listOf("https://relay.example.com")) + val custom = RelaySettings(RelayMode.StrictCustom, listOf("https://relay.example.com")) val preferences = preferences().apply { mutablePreferences.value = mutablePreferences.value.copy(relaySettings = custom) } @@ -144,13 +144,13 @@ class ViewModelsTest { val viewModel = settingsViewModel(preferences = preferences, repository = core) advanceUntilIdle() - viewModel.setRelayMode(RelayMode.Custom) + viewModel.setRelayMode(RelayMode.StrictCustom) viewModel.setRelayUrlsText(" HTTPS://Relay.Example.com/ \nhttps://backup.example.com:443") viewModel.applyRelaySettings() advanceUntilIdle() val expected = RelaySettings( - RelayMode.Custom, + RelayMode.StrictCustom, listOf("https://relay.example.com", "https://backup.example.com"), ) assertEquals(expected, preferences.mutablePreferences.value.relaySettings) @@ -159,6 +159,35 @@ class ViewModelsTest { assertFalse(viewModel.state.value.hasRelaySettingsChanges) } + @Test + fun settingsAppliesCustomFallbackAndLocalOnlyWhileRetainingRelayDrafts() = runTest { + Dispatchers.setMain(StandardTestDispatcher(testScheduler)) + val preferences = preferences() + val core = FakeCoreGateway() + val viewModel = settingsViewModel(preferences = preferences, repository = core) + advanceUntilIdle() + + viewModel.setRelayMode(RelayMode.CustomWithDirectFallback) + viewModel.setRelayUrlsText("https://relay.example.com") + viewModel.applyRelaySettings() + advanceUntilIdle() + viewModel.setRelayMode(RelayMode.LocalOnly) + viewModel.applyRelaySettings() + advanceUntilIdle() + + val fallback = RelaySettings( + RelayMode.CustomWithDirectFallback, + listOf("https://relay.example.com"), + ) + val localOnly = RelaySettings( + RelayMode.LocalOnly, + listOf("https://relay.example.com"), + ) + assertEquals(listOf(fallback, localOnly), core.initializedRelaySettings) + assertEquals(localOnly, preferences.mutablePreferences.value.relaySettings) + assertFalse(viewModel.state.value.hasRelaySettingsChanges) + } + @Test fun settingsIgnoresDuplicateApplyBeforeRestartBegins() = runTest { Dispatchers.setMain(StandardTestDispatcher(testScheduler)) @@ -166,7 +195,7 @@ class ViewModelsTest { val viewModel = settingsViewModel(repository = core) advanceUntilIdle() - viewModel.setRelayMode(RelayMode.Custom) + viewModel.setRelayMode(RelayMode.StrictCustom) viewModel.setRelayUrlsText("https://relay.example.com") viewModel.applyRelaySettings() viewModel.applyRelaySettings() @@ -186,7 +215,7 @@ class ViewModelsTest { advanceUntilIdle() assertEquals("endpoint", viewModel.state.value.endpointId) - viewModel.setRelayMode(RelayMode.Custom) + viewModel.setRelayMode(RelayMode.StrictCustom) viewModel.setRelayUrlsText("https://relay.example.com") viewModel.applyRelaySettings() advanceUntilIdle() @@ -200,7 +229,7 @@ class ViewModelsTest { Dispatchers.setMain(StandardTestDispatcher(testScheduler)) val core = FakeCoreGateway().apply { initializeHandler = { settings -> - if (settings.mode == RelayMode.Custom) Result.failure(IllegalStateException("unreachable")) + if (settings.mode == RelayMode.StrictCustom) Result.failure(IllegalStateException("unreachable")) else Result.success(Unit) } } @@ -208,14 +237,14 @@ class ViewModelsTest { val viewModel = settingsViewModel(preferences = preferences, repository = core) advanceUntilIdle() - viewModel.setRelayMode(RelayMode.Custom) + viewModel.setRelayMode(RelayMode.StrictCustom) viewModel.setRelayUrlsText("https://relay.example.com") viewModel.applyRelaySettings() advanceUntilIdle() assertEquals( listOf( - RelaySettings(RelayMode.Custom, listOf("https://relay.example.com")), + RelaySettings(RelayMode.StrictCustom, listOf("https://relay.example.com")), RelaySettings(), ), core.initializedRelaySettings, diff --git a/shared/src/commonTest/kotlin/com/vnidrop/app/feature/settings/RelaySettingsValidationTest.kt b/shared/src/commonTest/kotlin/com/vnidrop/app/feature/settings/RelaySettingsValidationTest.kt index 72f9f3d..639407e 100644 --- a/shared/src/commonTest/kotlin/com/vnidrop/app/feature/settings/RelaySettingsValidationTest.kt +++ b/shared/src/commonTest/kotlin/com/vnidrop/app/feature/settings/RelaySettingsValidationTest.kt @@ -6,16 +6,46 @@ import kotlin.test.Test import kotlin.test.assertEquals class RelaySettingsValidationTest { + @Test + fun customFallbackUsesTheSameValidatedRelayList() { + val result = validateRelaySettings( + mode = RelayMode.CustomWithDirectFallback, + urlsText = "https://relay.example.com/", + ) + + assertEquals( + RelaySettings( + RelayMode.CustomWithDirectFallback, + listOf("https://relay.example.com"), + ), + result.settings, + ) + } + + @Test + fun modesWithoutCustomRelaysRetainTheLastRelayList() { + val retained = listOf("https://relay.example.com") + + assertEquals( + RelaySettings(RelayMode.Automatic, retained), + validateRelaySettings(RelayMode.Automatic, "invalid", retained).settings, + ) + assertEquals( + RelaySettings(RelayMode.LocalOnly, retained), + validateRelaySettings(RelayMode.LocalOnly, "invalid", retained).settings, + ) + } + @Test fun customRelayUrlsAreNormalized() { val result = validateRelaySettings( - mode = RelayMode.Custom, + mode = RelayMode.StrictCustom, urlsText = " HTTPS://Relay.Example.com/ \nhttps://[2001:DB8::1]:443", ) assertEquals( RelaySettings( - RelayMode.Custom, + RelayMode.StrictCustom, listOf("https://relay.example.com", "https://[2001:db8::1]"), ), result.settings, @@ -27,18 +57,18 @@ class RelaySettingsValidationTest { fun customRelayUrlsRequireHttpsAndRootPath() { assertEquals( RelaySettingsInputError.HttpsRequired(1), - validateRelaySettings(RelayMode.Custom, "http://relay.example.com").error, + validateRelaySettings(RelayMode.StrictCustom, "http://relay.example.com").error, ) assertEquals( RelaySettingsInputError.InvalidUrl(1), - validateRelaySettings(RelayMode.Custom, "https://relay.example.com/custom").error, + validateRelaySettings(RelayMode.StrictCustom, "https://relay.example.com/custom").error, ) } @Test fun duplicateNormalizedRelayUrlsAreRejected() { val result = validateRelaySettings( - RelayMode.Custom, + RelayMode.StrictCustom, "https://relay.example.com\nHTTPS://RELAY.EXAMPLE.COM:443/", ) @@ -48,13 +78,13 @@ class RelaySettingsValidationTest { @Test fun structurallyValidIpv6RelayUrlsAreAccepted() { val result = validateRelaySettings( - RelayMode.Custom, + RelayMode.StrictCustom, "https://[::1]:443\nhttps://[2001:db8::1]\nhttps://[::ffff:192.0.2.1]", ) assertEquals( RelaySettings( - RelayMode.Custom, + RelayMode.StrictCustom, listOf( "https://[::1]", "https://[2001:db8::1]", @@ -75,7 +105,7 @@ class RelaySettingsValidationTest { ).forEach { url -> assertEquals( RelaySettingsInputError.InvalidUrl(1), - validateRelaySettings(RelayMode.Custom, url).error, + validateRelaySettings(RelayMode.StrictCustom, url).error, url, ) } diff --git a/shared/src/jvmTest/kotlin/com/vnidrop/app/preferences/AppPreferencesRepositoryTest.kt b/shared/src/jvmTest/kotlin/com/vnidrop/app/preferences/AppPreferencesRepositoryTest.kt index 062b12f..0959281 100644 --- a/shared/src/jvmTest/kotlin/com/vnidrop/app/preferences/AppPreferencesRepositoryTest.kt +++ b/shared/src/jvmTest/kotlin/com/vnidrop/app/preferences/AppPreferencesRepositoryTest.kt @@ -33,7 +33,7 @@ class AppPreferencesRepositoryTest { fun customRelaySettingsArePersisted() = runBlocking { val repository = repositoryForTest() val custom = RelaySettings( - mode = RelayMode.Custom, + mode = RelayMode.StrictCustom, relayUrls = listOf("https://relay.example.com", "https://backup.example.com:443"), ) @@ -42,6 +42,29 @@ class AppPreferencesRepositoryTest { assertEquals(custom, repository.preferences.first().relaySettings) } + @Test + fun legacyCustomRelayModeMigratesToStrictCustom() = runBlocking { + val directory = Files.createTempDirectory("vnidrop-preferences-test").toString() + val dataStore = createAppPreferencesDataStore(directory) + dataStore.edit { preferences -> + preferences[stringPreferencesKey("relay_mode")] = "Custom" + preferences[stringPreferencesKey("relay_urls")] = "https://relay.example.com" + } + val repository = AppPreferencesRepository( + dataStore = dataStore, + defaults = AppPreferencesDefaults( + username = "Device Name", + receiveFolder = defaultFolder, + themeMode = ThemeMode.System, + ), + ) + + assertEquals( + RelaySettings(RelayMode.StrictCustom, listOf("https://relay.example.com")), + repository.preferences.first().relaySettings, + ) + } + @Test fun unknownStoredRelayModeFailsClosedAndCanBeReset() = runBlocking { val directory = Files.createTempDirectory("vnidrop-preferences-test").toString() @@ -59,7 +82,7 @@ class AppPreferencesRepositoryTest { ), ) - assertEquals(RelaySettings(RelayMode.Custom), repository.preferences.first().relaySettings) + assertEquals(RelaySettings(RelayMode.StrictCustom), repository.preferences.first().relaySettings) repository.setRelaySettings(RelaySettings()) assertEquals(RelaySettings(), repository.preferences.first().relaySettings) @@ -85,7 +108,7 @@ class AppPreferencesRepositoryTest { ), ) - assertEquals(RelaySettings(RelayMode.Custom), repository.preferences.first().relaySettings) + assertEquals(RelaySettings(RelayMode.StrictCustom), repository.preferences.first().relaySettings) } @Test diff --git a/shared/src/jvmTest/kotlin/com/vnidrop/app/ui/FoundationComposeTest.kt b/shared/src/jvmTest/kotlin/com/vnidrop/app/ui/FoundationComposeTest.kt index dcf63d6..75c35dc 100644 --- a/shared/src/jvmTest/kotlin/com/vnidrop/app/ui/FoundationComposeTest.kt +++ b/shared/src/jvmTest/kotlin/com/vnidrop/app/ui/FoundationComposeTest.kt @@ -145,10 +145,10 @@ class FoundationComposeTest { onNodeWithText("Network").performClick() onNodeWithText("Device ID: endpoint-for-allowlist").assertIsDisplayed() - onNodeWithText("Custom").performClick() + onNodeWithText("Strict custom").performClick() onNodeWithText( - "Custom mode is strict: VniDrop will not fall back to public relays or public discovery. " + - "Other devices must be able to reach your configured relays.", + "Strict custom mode will not start unless at least one configured relay is reachable. " + + "VniDrop never uses public relays or public discovery in this mode.", ).assertIsDisplayed() onNodeWithText("Apply network settings").performClick() runOnIdle { assertTrue(applied) }