feat(network): add relay connection policies

This commit is contained in:
2026-07-23 15:14:03 +02:00
parent cbace73908
commit a0bcc5dbff
40 changed files with 942 additions and 254 deletions

View File

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

View File

@@ -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)

View File

@@ -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: [])
)
}

View File

@@ -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)

View File

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

View File

@@ -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()

View File

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

View File

@@ -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,

View File

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

View File

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

View File

@@ -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,8 +94,9 @@ struct NetworkSettings: View {
}
}
if model.state.relayMode == .custom {
if model.state.relayMode.usesCustomRelayURLs {
Section {
if model.state.relayMode == .strictCustom {
Label {
Text(LocalizedStringKey("relay_strict_warning"))
.fixedSize(horizontal: false, vertical: true)
@@ -108,6 +104,7 @@ struct NetworkSettings: View {
Image(systemName: "exclamationmark.shield.fill")
}
.foregroundStyle(.orange)
}
ForEach(Array(model.state.relayURLs.indices), id: \.self) { index in
VStack(alignment: .leading, spacing: 6) {

View File

@@ -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. Sils sont indisponibles, continue uniquement avec des connexions directes. Nutilise 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 nutilisera 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 nutilise 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 никогда не использует публичные ретрансляторы или публичное обнаружение."
}
}
}

View File

@@ -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<Vec<RelayUrl>> {
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");
}

View File

@@ -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
{
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<EndpointAddr> {
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<RelayStatus> {
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 {

View File

@@ -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());
for mode in [
CoreRelayMode::StrictCustom,
CoreRelayMode::CustomWithDirectFallback,
] {
let custom_without_url = CoreNetworkConfig {
mode: CoreRelayMode::Custom,
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!(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<_>>(),
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;
}

View File

@@ -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]

View File

@@ -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::<BTreeSet<_>>();
Ok(advertised == configured)
}
CoreRelayMode::LocalOnly => Ok(parsed.advertised_custom_relay_urls.is_empty()
&& parsed.blob_ticket.addr().relay_urls().next().is_none()),
}
}

View File

@@ -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() {
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();

View File

@@ -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()],
},
);

View File

@@ -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. Sils sont indisponibles, continue uniquement avec des connexions directes. Nutilise 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 nutilisera 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 nutilise 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": {

View File

@@ -177,15 +177,19 @@
<string name="relay_apply_restart_description">Beim Anwenden wird die Netzwerkverbindung von VniDrop neu gestartet. Beenden Sie zuerst aktive Übertragungen und Freigaben. Vorhandene Einladungen müssen eventuell erneut geteilt werden.</string>
<string name="relay_custom_urls_help">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.</string>
<string name="relay_custom_urls_label">Relay-URLs</string>
<string name="relay_mode_automatic">Automatisch</string>
<string name="relay_mode_automatic">Automatisch (empfohlen)</string>
<string name="relay_mode_automatic_description">Verwendet die öffentliche Standard-Relay-Infrastruktur von VniDrop, wenn keine direkte Verbindung möglich ist.</string>
<string name="relay_mode_custom">Benutzerdefiniert</string>
<string name="relay_mode_custom_description">Verwendet ausschließlich die unten aufgeführten Relay-Server.</string>
<string name="relay_mode_custom">Strikt benutzerdefiniert</string>
<string name="relay_mode_custom_description">Verwendet nur die konfigurierten eigenen Relays oder Direktverbindungen. Meldet einen Fehler, wenn kein eigenes Relay erreichbar ist.</string>
<string name="relay_mode_custom_direct_fallback">Benutzerdefiniert mit direktem Rückfall</string>
<string name="relay_mode_custom_direct_fallback_description">Bevorzugt die konfigurierten eigenen Relays. Sind sie nicht verfügbar, werden nur Direktverbindungen verwendet. Öffentliche Relays werden nie genutzt.</string>
<string name="relay_mode_local_only">Nur lokales Netzwerk</string>
<string name="relay_mode_local_only_description">Deaktiviert alle Relays und erlaubt nur Direktverbindungen, hauptsächlich für Geräte im selben Netzwerk.</string>
<string name="relay_privacy_description">Relays leiten verschlüsselten Datenverkehr weiter und können Ihre Dateien nicht lesen, ihr Betreiber kann jedoch Verbindungsmetadaten sehen.</string>
<string name="relay_remove_url">Relay-Server entfernen</string>
<string name="relay_restore_failed">Die vorherigen Netzwerkeinstellungen konnten nicht wiederhergestellt werden. Starten Sie VniDrop neu und prüfen Sie Ihre Relay-Konfiguration.</string>
<string name="relay_settings_applied">Netzwerkeinstellungen angewendet.</string>
<string name="relay_strict_warning">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.</string>
<string name="relay_strict_warning">Der strikt benutzerdefinierte Modus startet nur, wenn mindestens ein konfiguriertes Relay erreichbar ist. VniDrop verwendet in diesem Modus nie öffentliche Relays oder öffentliche Erkennung.</string>
<string name="relay_validation_duplicate_url">Die Relay-URL in Zeile %1$d ist bereits zuvor eingetragen.</string>
<string name="relay_validation_https_required">Die Relay-URL in Zeile %1$d muss mit https:// beginnen.</string>
<string name="relay_validation_invalid_url">Die Relay-URL in Zeile %1$d ist ungültig.</string>

View File

@@ -177,15 +177,19 @@
<string name="relay_apply_restart_description">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.</string>
<string name="relay_custom_urls_help">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.</string>
<string name="relay_custom_urls_label">URL de relés</string>
<string name="relay_mode_automatic">Automático</string>
<string name="relay_mode_automatic">Automático (recomendado)</string>
<string name="relay_mode_automatic_description">Usa la infraestructura pública de relés predeterminada de VniDrop cuando no haya una conexión directa disponible.</string>
<string name="relay_mode_custom">Personalizado</string>
<string name="relay_mode_custom_description">Usa únicamente los servidores de retransmisión indicados a continuación.</string>
<string name="relay_mode_custom">Personalizado estricto</string>
<string name="relay_mode_custom_description">Usa solo los relés personalizados configurados o conexiones directas. Informa de un error si no se puede establecer ningún relé personalizado.</string>
<string name="relay_mode_custom_direct_fallback">Personalizado con conexión directa de reserva</string>
<string name="relay_mode_custom_direct_fallback_description">Prefiere los relés personalizados configurados. Si no están disponibles, continúa solo con conexiones directas. Nunca usa relés públicos.</string>
<string name="relay_mode_local_only">Solo red local</string>
<string name="relay_mode_local_only_description">Desactiva todos los relés y permite solo conexiones directas, principalmente para dispositivos de la misma red.</string>
<string name="relay_privacy_description">Los relés reenvían tráfico cifrado y no pueden leer sus archivos, pero su operador puede observar los metadatos de conexión.</string>
<string name="relay_remove_url">Eliminar servidor de retransmisión</string>
<string name="relay_restore_failed">No se han podido restaurar los ajustes de red anteriores. Reinicie VniDrop y revise la configuración de relés.</string>
<string name="relay_settings_applied">Ajustes de red aplicados.</string>
<string name="relay_strict_warning">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.</string>
<string name="relay_strict_warning">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.</string>
<string name="relay_validation_duplicate_url">La URL de relé de la línea %1$d duplica una entrada anterior.</string>
<string name="relay_validation_https_required">La URL de relé de la línea %1$d debe empezar por https://.</string>
<string name="relay_validation_invalid_url">La URL de relé de la línea %1$d no es válida.</string>

View File

@@ -177,15 +177,19 @@
<string name="relay_apply_restart_description">Lapplication de ces réglages redémarre la connexion réseau de VniDrop. Arrêtez dabord les transferts et partages actifs. Il peut être nécessaire de partager à nouveau les invitations existantes.</string>
<string name="relay_custom_urls_help">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.</string>
<string name="relay_custom_urls_label">URL des relais</string>
<string name="relay_mode_automatic">Automatique</string>
<string name="relay_mode_automatic">Automatique (recommandé)</string>
<string name="relay_mode_automatic_description">Utiliser linfrastructure de relais publique par défaut de VniDrop lorsquune connexion directe est indisponible.</string>
<string name="relay_mode_custom">Personnalisé</string>
<string name="relay_mode_custom_description">Utiliser uniquement les serveurs relais ci-dessous.</string>
<string name="relay_mode_custom">Personnalisé strict</string>
<string name="relay_mode_custom_description">Utilise uniquement les relais personnalisés configurés ou les connexions directes. Signale une erreur si aucun relais personnalisé ne peut être établi.</string>
<string name="relay_mode_custom_direct_fallback">Personnalisé avec repli direct</string>
<string name="relay_mode_custom_direct_fallback_description">Préfère les relais personnalisés configurés. Sils sont indisponibles, continue uniquement avec des connexions directes. Nutilise jamais les relais publics.</string>
<string name="relay_mode_local_only">Réseau local uniquement</string>
<string name="relay_mode_local_only_description">Désactive tous les relais et autorise uniquement les connexions directes, principalement pour les appareils sur le même réseau.</string>
<string name="relay_privacy_description">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.</string>
<string name="relay_remove_url">Supprimer le serveur relais</string>
<string name="relay_restore_failed">Impossible de restaurer les réglages réseau précédents. Redémarrez VniDrop et vérifiez votre configuration de relais.</string>
<string name="relay_settings_applied">Réglages réseau appliqués.</string>
<string name="relay_strict_warning">Le mode personnalisé est strict : VniDrop nutilisera ni les relais publics ni la découverte publique comme solution de repli. Les autres appareils doivent pouvoir accéder aux relais configurés.</string>
<string name="relay_strict_warning">Le mode personnalisé strict ne démarre que si au moins un relais configuré est accessible. VniDrop nutilise jamais de relais ni de découverte publics dans ce mode.</string>
<string name="relay_validation_duplicate_url">LURL de relais à la ligne %1$d est identique à une entrée précédente.</string>
<string name="relay_validation_https_required">LURL de relais à la ligne %1$d doit commencer par https://.</string>
<string name="relay_validation_invalid_url">LURL de relais à la ligne %1$d nest pas valide.</string>

View File

@@ -177,15 +177,19 @@
<string name="relay_apply_restart_description">Lapplicazione riavvia la connessione di rete di VniDrop. Interrompa prima i trasferimenti e le condivisioni attivi. Potrebbe essere necessario condividere di nuovo gli inviti esistenti.</string>
<string name="relay_custom_urls_help">Inserisca un URL relay HTTPS per riga. Le credenziali negli URL non sono supportate. Il certificato TLS deve essere emesso da unautorità di certificazione pubblicamente attendibile.</string>
<string name="relay_custom_urls_label">URL relay</string>
<string name="relay_mode_automatic">Automatica</string>
<string name="relay_mode_automatic">Automatica (consigliata)</string>
<string name="relay_mode_automatic_description">Usa linfrastruttura relay pubblica predefinita di VniDrop quando non è disponibile una connessione diretta.</string>
<string name="relay_mode_custom">Personalizzata</string>
<string name="relay_mode_custom_description">Usa solo i server relay indicati di seguito.</string>
<string name="relay_mode_custom">Personalizzata rigorosa</string>
<string name="relay_mode_custom_description">Usa solo i relay personalizzati configurati o connessioni dirette. Segnala un errore se non è possibile stabilire alcun relay personalizzato.</string>
<string name="relay_mode_custom_direct_fallback">Personalizzata con ripiego diretto</string>
<string name="relay_mode_custom_direct_fallback_description">Preferisce i relay personalizzati configurati. Se non sono disponibili, continua solo con connessioni dirette. Non usa mai relay pubblici.</string>
<string name="relay_mode_local_only">Solo rete locale</string>
<string name="relay_mode_local_only_description">Disattiva tutti i relay e consente solo connessioni dirette, soprattutto per dispositivi sulla stessa rete.</string>
<string name="relay_privacy_description">I relay inoltrano traffico cifrato e non possono leggere i suoi file, ma il loro operatore può osservare i metadati di connessione.</string>
<string name="relay_remove_url">Rimuovi server relay</string>
<string name="relay_restore_failed">Impossibile ripristinare le impostazioni di rete precedenti. Riavvii VniDrop e verifichi la configurazione dei relay.</string>
<string name="relay_settings_applied">Impostazioni di rete applicate.</string>
<string name="relay_strict_warning">La modalità personalizzata è rigorosa: VniDrop non userà relay pubblici o il rilevamento pubblico come ripiego. Gli altri dispositivi devono poter raggiungere i relay configurati.</string>
<string name="relay_strict_warning">La modalità personalizzata rigorosa si avvia solo se almeno un relay configurato è raggiungibile. In questa modalità VniDrop non usa mai relay o rilevamento pubblici.</string>
<string name="relay_validation_duplicate_url">LURL relay alla riga %1$d duplica una voce precedente.</string>
<string name="relay_validation_https_required">LURL relay alla riga %1$d deve iniziare con https://.</string>
<string name="relay_validation_invalid_url">LURL relay alla riga %1$d non è valido.</string>

View File

@@ -177,15 +177,19 @@
<string name="relay_apply_restart_description">Bij het toepassen wordt de netwerkverbinding van VniDrop opnieuw gestart. Stop eerst actieve overdrachten en gedeelde items. Bestaande uitnodigingen moeten mogelijk opnieuw worden gedeeld.</string>
<string name="relay_custom_urls_help">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.</string>
<string name="relay_custom_urls_label">Relay-URL\'s</string>
<string name="relay_mode_automatic">Automatisch</string>
<string name="relay_mode_automatic">Automatisch (aanbevolen)</string>
<string name="relay_mode_automatic_description">Gebruikt de standaard openbare relay-infrastructuur van VniDrop wanneer geen directe verbinding beschikbaar is.</string>
<string name="relay_mode_custom">Aangepast</string>
<string name="relay_mode_custom_description">Gebruikt alleen de onderstaande relayservers.</string>
<string name="relay_mode_custom">Strikt aangepast</string>
<string name="relay_mode_custom_description">Gebruikt alleen de ingestelde aangepaste relays of rechtstreekse verbindingen. Meldt een fout als geen aangepaste relay bereikbaar is.</string>
<string name="relay_mode_custom_direct_fallback">Aangepast met directe terugval</string>
<string name="relay_mode_custom_direct_fallback_description">Geeft de voorkeur aan de ingestelde aangepaste relays. Als die niet beschikbaar zijn, worden alleen rechtstreekse verbindingen gebruikt. Openbare relays worden nooit gebruikt.</string>
<string name="relay_mode_local_only">Alleen lokaal netwerk</string>
<string name="relay_mode_local_only_description">Schakelt alle relays uit en staat alleen rechtstreekse verbindingen toe, vooral voor apparaten in hetzelfde netwerk.</string>
<string name="relay_privacy_description">Relays sturen versleuteld verkeer door en kunnen uw bestanden niet lezen, maar de beheerder kan verbindingsmetadata bekijken.</string>
<string name="relay_remove_url">Relayserver verwijderen</string>
<string name="relay_restore_failed">De vorige netwerkinstellingen konden niet worden hersteld. Start VniDrop opnieuw en controleer uw relayconfiguratie.</string>
<string name="relay_settings_applied">Netwerkinstellingen toegepast.</string>
<string name="relay_strict_warning">De aangepaste modus is strikt: VniDrop valt niet terug op openbare relays of openbare detectie. Andere apparaten moeten uw ingestelde relays kunnen bereiken.</string>
<string name="relay_strict_warning">De strikt aangepaste modus start alleen als minstens één ingestelde relay bereikbaar is. VniDrop gebruikt in deze modus nooit openbare relays of openbare detectie.</string>
<string name="relay_validation_duplicate_url">De relay-URL op regel %1$d is gelijk aan een eerdere invoer.</string>
<string name="relay_validation_https_required">De relay-URL op regel %1$d moet beginnen met https://.</string>
<string name="relay_validation_invalid_url">De relay-URL op regel %1$d is ongeldig.</string>

View File

@@ -177,15 +177,19 @@
<string name="relay_apply_restart_description">Zastosowanie ustawień ponownie uruchamia połączenie sieciowe VniDrop. Najpierw zatrzymaj aktywne transfery i udostępnienia. Istniejące zaproszenia mogą wymagać ponownego udostępnienia.</string>
<string name="relay_custom_urls_help">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.</string>
<string name="relay_custom_urls_label">Adresy URL przekaźników</string>
<string name="relay_mode_automatic">Automatyczny</string>
<string name="relay_mode_automatic">Automatyczny (zalecany)</string>
<string name="relay_mode_automatic_description">Używa domyślnej publicznej infrastruktury przekaźników VniDrop, gdy połączenie bezpośrednie jest niedostępne.</string>
<string name="relay_mode_custom">Niestandardowy</string>
<string name="relay_mode_custom_description">Używa wyłącznie poniższych serwerów przekaźnikowych.</string>
<string name="relay_mode_custom">Ścisły niestandardowy</string>
<string name="relay_mode_custom_description">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.</string>
<string name="relay_mode_custom_direct_fallback">Niestandardowy z trybem bezpośrednim</string>
<string name="relay_mode_custom_direct_fallback_description">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.</string>
<string name="relay_mode_local_only">Tylko sieć lokalna</string>
<string name="relay_mode_local_only_description">Wyłącza wszystkie przekaźniki i zezwala tylko na połączenia bezpośrednie, głównie dla urządzeń w tej samej sieci.</string>
<string name="relay_privacy_description">Przekaźniki przesyłają zaszyfrowany ruch i nie mogą odczytać plików, ale ich operator może obserwować metadane połączenia.</string>
<string name="relay_remove_url">Usuń serwer przekaźnikowy</string>
<string name="relay_restore_failed">Nie udało się przywrócić poprzednich ustawień sieci. Uruchom ponownie VniDrop i sprawdź konfigurację przekaźników.</string>
<string name="relay_settings_applied">Zastosowano ustawienia sieci.</string>
<string name="relay_strict_warning">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.</string>
<string name="relay_strict_warning">Ś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.</string>
<string name="relay_validation_duplicate_url">Adres URL przekaźnika w wierszu %1$d powtarza wcześniejszy wpis.</string>
<string name="relay_validation_https_required">Adres URL przekaźnika w wierszu %1$d musi zaczynać się od https://.</string>
<string name="relay_validation_invalid_url">Adres URL przekaźnika w wierszu %1$d jest nieprawidłowy.</string>

View File

@@ -177,15 +177,19 @@
<string name="relay_apply_restart_description">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.</string>
<string name="relay_custom_urls_help">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.</string>
<string name="relay_custom_urls_label">URLs dos retransmissores</string>
<string name="relay_mode_automatic">Automático</string>
<string name="relay_mode_automatic">Automático (recomendado)</string>
<string name="relay_mode_automatic_description">Utiliza a infraestrutura pública de retransmissores predefinida do VniDrop quando não está disponível uma ligação direta.</string>
<string name="relay_mode_custom">Personalizado</string>
<string name="relay_mode_custom_description">Utiliza apenas os servidores de retransmissão abaixo.</string>
<string name="relay_mode_custom">Personalizado estrito</string>
<string name="relay_mode_custom_description">Utiliza apenas os retransmissores personalizados configurados ou ligações diretas. Apresenta um erro se não for possível estabelecer nenhum retransmissor personalizado.</string>
<string name="relay_mode_custom_direct_fallback">Personalizado com alternativa direta</string>
<string name="relay_mode_custom_direct_fallback_description">Prefere os retransmissores personalizados configurados. Se não estiverem disponíveis, continua apenas com ligações diretas. Nunca utiliza retransmissores públicos.</string>
<string name="relay_mode_local_only">Apenas rede local</string>
<string name="relay_mode_local_only_description">Desativa todos os retransmissores e permite apenas ligações diretas, principalmente para dispositivos na mesma rede.</string>
<string name="relay_privacy_description">Os retransmissores encaminham tráfego cifrado e não conseguem ler os seus ficheiros, mas o operador pode observar metadados da ligação.</string>
<string name="relay_remove_url">Remover servidor de retransmissão</string>
<string name="relay_restore_failed">Não foi possível restaurar as definições de rede anteriores. Reinicie o VniDrop e reveja a configuração dos retransmissores.</string>
<string name="relay_settings_applied">Definições de rede aplicadas.</string>
<string name="relay_strict_warning">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.</string>
<string name="relay_strict_warning">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.</string>
<string name="relay_validation_duplicate_url">O URL do retransmissor na linha %1$d duplica uma entrada anterior.</string>
<string name="relay_validation_https_required">O URL do retransmissor na linha %1$d tem de começar por https://.</string>
<string name="relay_validation_invalid_url">O URL do retransmissor na linha %1$d não é válido.</string>

View File

@@ -177,15 +177,19 @@
<string name="relay_apply_restart_description">При применении сетевое соединение VniDrop перезапускается. Сначала остановите активные передачи и раздачи. Возможно, существующие приглашения потребуется отправить повторно.</string>
<string name="relay_custom_urls_help">Введите по одному HTTPS-адресу ретранслятора в строке. Учётные данные в URL-адресах не поддерживаются. Сертификат TLS должен быть выдан общедоступным доверенным центром сертификации.</string>
<string name="relay_custom_urls_label">URL-адреса ретрансляторов</string>
<string name="relay_mode_automatic">Автоматически</string>
<string name="relay_mode_automatic">Автоматически (рекомендуется)</string>
<string name="relay_mode_automatic_description">Использовать стандартную публичную инфраструктуру ретрансляторов VniDrop, если прямое соединение недоступно.</string>
<string name="relay_mode_custom">Пользовательский</string>
<string name="relay_mode_custom_description">Использовать только указанные ниже серверы-ретрансляторы.</string>
<string name="relay_mode_custom">Строго пользовательский</string>
<string name="relay_mode_custom_description">Использует только настроенные пользовательские ретрансляторы или прямые соединения. Сообщает об ошибке, если ни один пользовательский ретранслятор недоступен.</string>
<string name="relay_mode_custom_direct_fallback">Пользовательский с прямым резервом</string>
<string name="relay_mode_custom_direct_fallback_description">Предпочитает настроенные пользовательские ретрансляторы. Если они недоступны, продолжает работу только через прямые соединения. Публичные ретрансляторы не используются.</string>
<string name="relay_mode_local_only">Только локальная сеть</string>
<string name="relay_mode_local_only_description">Отключает все ретрансляторы и разрешает только прямые соединения, прежде всего для устройств в одной сети.</string>
<string name="relay_privacy_description">Ретрансляторы передают зашифрованный трафик и не могут читать ваши файлы, но их оператор может видеть метаданные соединения.</string>
<string name="relay_remove_url">Удалить сервер-ретранслятор</string>
<string name="relay_restore_failed">Не удалось восстановить предыдущие настройки сети. Перезапустите VniDrop и проверьте конфигурацию ретрансляторов.</string>
<string name="relay_settings_applied">Настройки сети применены.</string>
<string name="relay_strict_warning">Пользовательский режим работает строго: VniDrop не будет переключаться на публичные ретрансляторы или публичное обнаружение. Другие устройства должны иметь доступ к настроенным ретрансляторам.</string>
<string name="relay_strict_warning">Строго пользовательский режим запускается, только если доступен хотя бы один настроенный ретранслятор. В этом режиме VniDrop никогда не использует публичные ретрансляторы или публичное обнаружение.</string>
<string name="relay_validation_duplicate_url">URL-адрес ретранслятора в строке %1$d повторяет предыдущую запись.</string>
<string name="relay_validation_https_required">URL-адрес ретранслятора в строке %1$d должен начинаться с https://.</string>
<string name="relay_validation_invalid_url">URL-адрес ретранслятора в строке %1$d недействителен.</string>

View File

@@ -177,15 +177,19 @@
<string name="relay_apply_restart_description">Applying restarts VniDrops network connection. Stop active transfers and shares first. Existing invitations may need to be shared again.</string>
<string name="relay_custom_urls_help">Enter one HTTPS relay URL per line. URL credentials are not supported. The TLS certificate must be issued by a publicly trusted certificate authority.</string>
<string name="relay_custom_urls_label">Relay URLs</string>
<string name="relay_mode_automatic">Automatic</string>
<string name="relay_mode_automatic">Automatic (recommended)</string>
<string name="relay_mode_automatic_description">Use VniDrops default public relay infrastructure when a direct connection is unavailable.</string>
<string name="relay_mode_custom">Custom</string>
<string name="relay_mode_custom_description">Use only the relay servers below.</string>
<string name="relay_mode_custom">Strict custom</string>
<string name="relay_mode_custom_description">Use only the configured custom relays or direct connections. Report an error if no custom relay can be established.</string>
<string name="relay_mode_custom_direct_fallback">Custom with direct fallback</string>
<string name="relay_mode_custom_direct_fallback_description">Prefer the configured custom relays. If unavailable, continue with direct connections only. Never use public relays.</string>
<string name="relay_mode_local_only">Local only</string>
<string name="relay_mode_local_only_description">Disable all relays and allow only direct connections, primarily for devices on the same network.</string>
<string name="relay_privacy_description">Relays forward encrypted traffic and cannot read your files, but their operator can observe connection metadata.</string>
<string name="relay_remove_url">Remove relay server</string>
<string name="relay_restore_failed">Could not restore the previous network settings. Restart VniDrop and review your relay configuration.</string>
<string name="relay_settings_applied">Network settings applied.</string>
<string name="relay_strict_warning">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.</string>
<string name="relay_strict_warning">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.</string>
<string name="relay_validation_duplicate_url">Relay URL on line %1$d duplicates an earlier entry.</string>
<string name="relay_validation_https_required">Relay URL on line %1$d must start with https://.</string>
<string name="relay_validation_invalid_url">Relay URL on line %1$d is not valid.</string>

View File

@@ -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<String> = emptyList(),
)
val RelayMode.usesCustomRelayUrls: Boolean
get() = this == RelayMode.StrictCustom || this == RelayMode.CustomWithDirectFallback
enum class TransferDirection {
Send,
Receive,

View File

@@ -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(

View File

@@ -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,
)
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,

View File

@@ -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<String> = 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 {

View File

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

View File

@@ -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 {

View File

@@ -58,14 +58,14 @@ class AppPreferencesRepository(
) : PreferencesRepository {
override val preferences: Flow<AppPreferences> = 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"

View File

@@ -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,

View File

@@ -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,
)
}

View File

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

View File

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