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

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

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 никогда не использует публичные ретрансляторы или публичное обнаружение."
}
}
}