From cbace73908d03baf11e9d384f20bc480837997c2 Mon Sep 17 00:00:00 2001 From: Hammed Abass Date: Thu, 23 Jul 2026 14:27:40 +0200 Subject: [PATCH 01/10] feat(network): support custom relay servers Add strict custom Iroh relay profiles with safe restart and rollback across the Rust core, Compose apps, and Apple apps. Preserve multi-relay invitations and fail closed on configuration or recovery mismatches. --- Cargo.lock | 240 ++- README.md | 26 + apple/Tests/AppModelTests.swift | 12 + .../Tests/AppPreferencesRepositoryTests.swift | 41 +- .../Tests/CoreRepositoryLifecycleTests.swift | 131 ++ apple/Tests/Fakes.swift | 11 +- apple/Tests/RelayConfigurationTests.swift | 95 ++ apple/Tests/SendModelTests.swift | 21 + apple/Tests/SettingsModelTests.swift | 103 ++ apple/VniDrop/Core/AppPreferences.swift | 114 +- apple/VniDrop/Core/CoreGateway.swift | 2 +- apple/VniDrop/Core/CoreRepository.swift | 118 +- apple/VniDrop/Features/App/AppModel.swift | 5 +- .../Features/Send/TransferDetailsView.swift | 71 +- .../Features/Settings/SettingsModel.swift | 150 ++ .../Features/Settings/SettingsScreen.swift | 20 + .../Features/Settings/SettingsSections.swift | 172 ++ apple/VniDrop/Resources/Localizable.xcstrings | 1500 +++++++++++++++++ crates/vnidrop/Cargo.toml | 1 + crates/vnidrop/src/api.rs | 112 ++ crates/vnidrop/src/lib.rs | 9 +- crates/vnidrop/src/repository.rs | 6 +- crates/vnidrop/src/runtime/delivery.rs | 29 +- crates/vnidrop/src/runtime/facade.rs | 54 +- crates/vnidrop/src/runtime/mod.rs | 135 +- crates/vnidrop/src/runtime/receive.rs | 26 +- crates/vnidrop/src/runtime/share.rs | 10 +- crates/vnidrop/src/tests.rs | 2 + crates/vnidrop/src/tests/network_config.rs | 186 ++ crates/vnidrop/src/tests/repository.rs | 1 + crates/vnidrop/src/tests/ticket.rs | 138 +- crates/vnidrop/src/ticket.rs | 86 +- crates/vnidrop/tests/custom_relay.rs | 106 ++ crates/vnidrop/tests/lifecycle.rs | 56 +- crates/vnidrop/tests/support/mod.rs | 98 +- localization/strings.json | 374 ++++ .../composeResources/values-de/strings.xml | 25 + .../composeResources/values-es/strings.xml | 25 + .../composeResources/values-fr/strings.xml | 25 + .../composeResources/values-it/strings.xml | 25 + .../composeResources/values-nl/strings.xml | 25 + .../composeResources/values-pl/strings.xml | 25 + .../composeResources/values-pt/strings.xml | 25 + .../composeResources/values-ru/strings.xml | 25 + .../composeResources/values/strings.xml | 25 + .../com/vnidrop/app/core/CoreLifecycleGate.kt | 51 + .../kotlin/com/vnidrop/app/core/CoreModels.kt | 15 +- .../com/vnidrop/app/core/CoreRepository.kt | 120 +- .../vnidrop/app/feature/app/AppViewModel.kt | 4 +- .../vnidrop/app/feature/send/SendScreen.kt | 8 +- .../vnidrop/app/feature/send/SendViewModel.kt | 7 +- .../app/feature/send/TransferDetails.kt | 118 +- .../app/feature/settings/NetworkSettings.kt | 199 +++ .../settings/RelaySettingsValidation.kt | 147 ++ .../app/feature/settings/SettingsOverview.kt | 18 + .../app/feature/settings/SettingsRoute.kt | 3 + .../app/feature/settings/SettingsScreen.kt | 21 + .../app/feature/settings/SettingsViewModel.kt | 159 +- .../preferences/AppPreferencesRepository.kt | 47 +- .../vnidrop/app/core/CoreLifecycleGateTest.kt | 82 + .../com/vnidrop/app/feature/ViewModelsTest.kt | 111 +- .../app/feature/send/TransferQrCodeTest.kt | 9 + .../settings/RelaySettingsValidationTest.kt | 83 + .../kotlin/com/vnidrop/app/support/Fakes.kt | 14 +- .../AppPreferencesRepositoryTest.kt | 67 + .../vnidrop/app/ui/FoundationComposeTest.kt | 91 + 66 files changed, 5692 insertions(+), 168 deletions(-) create mode 100644 apple/Tests/CoreRepositoryLifecycleTests.swift create mode 100644 apple/Tests/RelayConfigurationTests.swift create mode 100644 crates/vnidrop/src/tests/network_config.rs create mode 100644 crates/vnidrop/tests/custom_relay.rs create mode 100644 shared/src/commonMain/kotlin/com/vnidrop/app/core/CoreLifecycleGate.kt create mode 100644 shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/NetworkSettings.kt create mode 100644 shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/RelaySettingsValidation.kt create mode 100644 shared/src/commonTest/kotlin/com/vnidrop/app/core/CoreLifecycleGateTest.kt create mode 100644 shared/src/commonTest/kotlin/com/vnidrop/app/feature/settings/RelaySettingsValidationTest.kt diff --git a/Cargo.lock b/Cargo.lock index 9424d1d..fdc4485 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -61,12 +61,56 @@ dependencies = [ "libc", ] +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + [[package]] name = "anstyle" version = "1.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + [[package]] name = "anyhow" version = "1.0.103" @@ -518,6 +562,7 @@ version = "4.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f09628afdcc538b57f3c6341e9c8e9970f18e4a481690a64974d7023bd33548b" dependencies = [ + "anstream", "anstyle", "clap_lex", "strsim", @@ -556,6 +601,12 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + [[package]] name = "combine" version = "4.6.7" @@ -811,6 +862,20 @@ dependencies = [ "syn 2.0.118", ] +[[package]] +name = "dashmap" +version = "6.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6361d5c062261c78a176addb82d4c821ae42bed6089de0e12603cd25de2059c" +dependencies = [ + "cfg-if", + "crossbeam-utils", + "hashbrown 0.14.5", + "lock_api", + "once_cell", + "parking_lot_core", +] + [[package]] name = "data-encoding" version = "2.11.0" @@ -958,6 +1023,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" dependencies = [ "block-buffer 0.12.1", + "const-oid 0.10.2", "crypto-common 0.2.2", ] @@ -1474,6 +1540,12 @@ dependencies = [ "byteorder", ] +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + [[package]] name = "hashbrown" version = "0.15.5" @@ -2118,12 +2190,20 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "291065721ad7c477b972e581bbc528df031dc8eb5e39fe1ff3300ae5dfb157ef" dependencies = [ + "http-body-util", + "hyper", + "hyper-util", "iroh-metrics-derive", "itoa", "n0-error", "portable-atomic", + "reqwest", + "rustls", + "rustls-platform-verifier", "ryu", "serde", + "tokio", + "tokio-util", "tracing", ] @@ -2148,6 +2228,8 @@ dependencies = [ "blake3", "bytes", "cfg_aliases", + "clap", + "dashmap", "data-encoding", "derive_more", "getrandom 0.4.3", @@ -2168,17 +2250,28 @@ dependencies = [ "pin-project", "postcard", "rand 0.10.2", + "rcgen", + "reloadable-state", "reqwest", "rustls", + "rustls-cert-file-reader", + "rustls-cert-reloadable-resolver", "rustls-pki-types", "serde", "serde_bytes", + "serde_json", + "sha1 0.11.0", + "simdutf8", "strum", + "time", "tokio", "tokio-rustls", + "tokio-rustls-acme", "tokio-util", "tokio-websockets", + "toml 1.1.2+spec-1.1.0", "tracing", + "tracing-subscriber", "url", "vergen-gitcl", "webpki-roots", @@ -2264,6 +2357,12 @@ dependencies = [ "tracing", ] +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + [[package]] name = "itoa" version = "1.0.18" @@ -2997,6 +3096,12 @@ dependencies = [ "portable-atomic", ] +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + [[package]] name = "opaque-debug" version = "0.3.1" @@ -3539,6 +3644,23 @@ version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" +[[package]] +name = "reloadable-core" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1dc20ac1418988b60072d783c9f68e28a173fb63493c127952f6face3b40c6e0" + +[[package]] +name = "reloadable-state" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3853ef78d45b50f8b989896304a85239539d39b7f866a000e8846b9b72d74ce8" +dependencies = [ + "arc-swap", + "reloadable-core", + "tokio", +] + [[package]] name = "reqwest" version = "0.13.4" @@ -3562,6 +3684,8 @@ dependencies = [ "rustls", "rustls-pki-types", "rustls-platform-verifier", + "serde", + "serde_json", "sync_wrapper", "tokio", "tokio-rustls", @@ -3668,6 +3792,40 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-cert-file-reader" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8bb47c2a50fdfdaf95b0ac8b12620fc327da1fd4adbb30d0c56d866b005873ff" +dependencies = [ + "rustls-cert-read", + "rustls-pki-types", + "thiserror 2.0.18", + "tokio", +] + +[[package]] +name = "rustls-cert-read" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd46e8c5ae4de3345c4786a83f99ec7aff287209b9e26fa883c473aeb28f19d5" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "rustls-cert-reloadable-resolver" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe1baa8a3a1f05eaa9fc55aed4342867f70e5c170ea3bfed1b38c51a4857c0c8" +dependencies = [ + "futures-util", + "reloadable-state", + "rustls", + "rustls-cert-read", + "thiserror 2.0.18", +] + [[package]] name = "rustls-native-certs" version = "0.8.4" @@ -3898,6 +4056,15 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -3931,6 +4098,17 @@ dependencies = [ "digest 0.10.7", ] +[[package]] +name = "sha1" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.3", +] + [[package]] name = "sha1_smol" version = "1.0.1" @@ -4234,7 +4412,7 @@ dependencies = [ "percent-encoding", "rand 0.8.6", "rsa", - "sha1", + "sha1 0.10.6", "sha2 0.10.9", "smallvec", "sqlx-core", @@ -4614,6 +4792,34 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-rustls-acme" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1af8573b15fdad8d66da116198cd8fd8d87ff62a67c1c6c3df7f62da1170793f" +dependencies = [ + "async-trait", + "base64", + "chrono", + "futures", + "log", + "num-bigint", + "pem", + "proc-macro2", + "rcgen", + "reqwest", + "ring", + "rustls", + "serde", + "serde_json", + "thiserror 2.0.18", + "time", + "tokio", + "tokio-rustls", + "webpki-roots", + "x509-parser", +] + [[package]] name = "tokio-stream" version = "0.1.18" @@ -4672,6 +4878,21 @@ dependencies = [ "serde", ] +[[package]] +name = "toml" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow 1.0.3", +] + [[package]] name = "toml_datetime" version = "1.1.1+spec-1.1.0" @@ -4702,6 +4923,12 @@ dependencies = [ "winnow 1.0.3", ] +[[package]] +name = "toml_writer" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" + [[package]] name = "tower" version = "0.5.3" @@ -4915,7 +5142,7 @@ dependencies = [ "serde", "tempfile", "textwrap", - "toml", + "toml 0.5.11", "uniffi_internal_macros", "uniffi_meta", "uniffi_pipeline", @@ -4961,7 +5188,7 @@ dependencies = [ "quote", "serde", "syn 2.0.118", - "toml", + "toml 0.5.11", "uniffi_meta", ] @@ -5037,6 +5264,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + [[package]] name = "uuid" version = "1.23.4" @@ -5117,6 +5350,7 @@ dependencies = [ "futures-lite", "iroh", "iroh-blobs", + "iroh-relay", "irpc", "irpc-iroh", "libc", diff --git a/README.md b/README.md index 3172ea0..fb3b623 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,31 @@ mobile networks. If a direct path cannot be established, it can forward the same end-to-end encrypted connection through a relay. The relay forwards 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. + +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 +restores the previous one if it cannot connect. Invitations created for an old +relay configuration may need to be shared again; stopped shares never expose +their stale invitations. If a long relay profile makes an invitation too large +for a QR code, use the native share action or export the invitation file. + +Relay credentials embedded in URLs are deliberately rejected and bearer-token +authentication is not currently supported. A self-hosted relay must either +accept the connecting endpoints or authorize their endpoint IDs independently; +the current device ID is shown in **Settings → Network** for this purpose. +Configure the same relay profile on participating devices. A custom relay needs +a TLS certificate issued by a publicly trusted WebPKI certificate authority; +private or enterprise CAs installed only in the operating system are not used +in this version. For resilient deployments, configure at least two relays in +different failure domains. + ## Why Iroh and `iroh-blobs`? VniDrop combines a networking layer with its own sharing rules: @@ -90,6 +115,7 @@ people, especially when using **Anyone with this transfer**. - Safe receive destinations that do not silently overwrite existing files - Native SwiftUI apps on iOS, iPadOS, and macOS; Compose apps on Android, Windows, and Linux +- Strict custom HTTPS relay profiles with safe apply and rollback - Opt-in diagnostics with transfer contents, invitations, and file paths excluded diff --git a/apple/Tests/AppModelTests.swift b/apple/Tests/AppModelTests.swift index 24da979..1b9beb6 100644 --- a/apple/Tests/AppModelTests.swift +++ b/apple/Tests/AppModelTests.swift @@ -20,6 +20,18 @@ final class AppModelTests: XCTestCase { _ = makeModel(core, preferences: Fixtures.preferences()) await waitUntil { core.state.isInitialized } XCTAssertTrue(core.state.isInitialized) + XCTAssertEqual(core.initializedNetworkConfigurations, [.automatic]) + } + + func testInitializesCoreWithSavedCustomRelayConfiguration() async { + let core = FakeCoreGateway() + let preferences = Fixtures.preferences() + let configuration = RelayConfiguration(mode: .custom, relayURLs: ["https://relay.example"]) + preferences.setRelayConfiguration(configuration) + _ = makeModel(core, preferences: preferences) + + await waitUntil { core.state.isInitialized } + XCTAssertEqual(core.initializedNetworkConfigurations, [configuration]) } func testSelectDestination() { diff --git a/apple/Tests/AppPreferencesRepositoryTests.swift b/apple/Tests/AppPreferencesRepositoryTests.swift index eb4ef11..8983566 100644 --- a/apple/Tests/AppPreferencesRepositoryTests.swift +++ b/apple/Tests/AppPreferencesRepositoryTests.swift @@ -15,11 +15,12 @@ final class AppPreferencesRepositoryTests: XCTestCase { ) } - func testFallbacksWhenEmpty() { + func testMissingRelayProfileDefaultsToAutomatic() { let repo = AppPreferencesRepository(defaults: defaults(), fallback: fallback()) XCTAssertEqual(repo.preferences.username, "Default") XCTAssertEqual(repo.preferences.themeMode, .system) XCTAssertFalse(repo.preferences.notificationsEnabled) + XCTAssertEqual(repo.preferences.relayConfiguration, .automatic) } func testValuesPersistAndReload() { @@ -30,6 +31,10 @@ final class AppPreferencesRepositoryTests: XCTestCase { repo.setThemeMode(.dark) repo.setNotificationsEnabled(true) repo.setReceiveFolder(ReceiveFolder(kind: .iosSecurityScopedUrl, value: "file:///x", displayName: "Custom")) + repo.setRelayConfiguration(RelayConfiguration( + mode: .custom, + relayURLs: ["https://relay-one.example", "https://relay-two.example:443"] + )) // A fresh repository over the same store reflects the persisted values. let reloaded = AppPreferencesRepository(defaults: store, fallback: fb) @@ -38,6 +43,40 @@ final class AppPreferencesRepositoryTests: XCTestCase { XCTAssertTrue(reloaded.preferences.notificationsEnabled) XCTAssertEqual(reloaded.preferences.receiveFolder.displayName, "Custom") XCTAssertEqual(reloaded.preferences.receiveFolder.kind, .iosSecurityScopedUrl) + XCTAssertEqual(reloaded.preferences.relayConfiguration, RelayConfiguration( + mode: .custom, + relayURLs: ["https://relay-one.example", "https://relay-two.example:443"] + )) + XCTAssertNotNil(store.data(forKey: "relay_configuration")) + XCTAssertNil(store.object(forKey: "relay_mode")) + XCTAssertNil(store.object(forKey: "relay_urls")) + } + + func testCorruptedRelayProfileFailsClosed() { + let store = defaults() + store.set(Data("{".utf8), forKey: "relay_configuration") + + let repo = AppPreferencesRepository(defaults: store, fallback: fallback()) + + XCTAssertEqual( + repo.preferences.relayConfiguration, + RelayConfiguration(mode: .custom, relayURLs: []) + ) + } + + func testUnknownRelayModeFailsClosed() { + let store = defaults() + store.set( + Data(#"{"mode":"future-mode","relayURLs":["https://relay.example"]}"#.utf8), + forKey: "relay_configuration" + ) + + let repo = AppPreferencesRepository(defaults: store, fallback: fallback()) + + XCTAssertEqual( + repo.preferences.relayConfiguration, + RelayConfiguration(mode: .custom, relayURLs: []) + ) } func testResetReceiveFolderRestoresFallback() { diff --git a/apple/Tests/CoreRepositoryLifecycleTests.swift b/apple/Tests/CoreRepositoryLifecycleTests.swift new file mode 100644 index 0000000..cdd8c69 --- /dev/null +++ b/apple/Tests/CoreRepositoryLifecycleTests.swift @@ -0,0 +1,131 @@ +import Foundation +import XCTest +@preconcurrency import VnidropCore +@testable import VniDrop + +private enum BlockingCoreFactoryError: Error { + case stopped +} + +private final class BlockingCoreBindingFactory: CoreBindingFactory, @unchecked Sendable { + private let release = DispatchSemaphore(value: 0) + private let lock = NSLock() + private var initializeCallCount = 0 + private var initializationStarted = false + private var startWaiters: [CheckedContinuation] = [] + + var callCount: Int { + lock.lock() + defer { lock.unlock() } + return initializeCallCount + } + + func initialize( + appDataDir: String, + eventSink: CoreEventSink, + networkConfiguration: RelayConfiguration + ) throws -> VnidropCore { + lock.lock() + initializeCallCount += 1 + let call = initializeCallCount + initializationStarted = true + let waiters = startWaiters + startWaiters.removeAll() + lock.unlock() + waiters.forEach { $0.resume() } + + if call == 1 { + release.wait() + } + throw BlockingCoreFactoryError.stopped + } + + func waitUntilInitializationStarts() async { + await withCheckedContinuation { continuation in + lock.lock() + if initializationStarted { + lock.unlock() + continuation.resume() + } else { + startWaiters.append(continuation) + lock.unlock() + } + } + } + + func unblockInitialization() { + release.signal() + } +} + +@MainActor +final class CoreRepositoryLifecycleTests: XCTestCase { + func testIdleRequirementRejectsTransfersAndShares() throws { + XCTAssertNoThrow(try CoreNetworkLifecycle.requireIdle(activeTransfers: 0, activeShares: 0)) + XCTAssertThrowsError( + try CoreNetworkLifecycle.requireIdle(activeTransfers: 1, activeShares: 0) + ) { error in + XCTAssertEqual(error as? CoreNetworkLifecycleError, .activeNetworkWork) + } + XCTAssertThrowsError( + try CoreNetworkLifecycle.requireIdle(activeTransfers: 0, activeShares: 1) + ) { error in + XCTAssertEqual(error as? CoreNetworkLifecycleError, .activeNetworkWork) + } + } + + func testRestartSerializesInitializationAndRejectsNewNetworkWork() async { + let factory = BlockingCoreBindingFactory() + let repository = CoreRepository(coreFactory: factory) + let firstInitialization = Task { + await repository.initialize(appDataDir: "/tmp/first", networkConfiguration: .automatic) + } + await factory.waitUntilInitializationStarts() + + let safetyRelease = Task.detached { + try? await Task.sleep(nanoseconds: 1_000_000_000) + guard !Task.isCancelled else { return } + factory.unblockInitialization() + } + defer { + safetyRelease.cancel() + factory.unblockInitialization() + } + + let concurrentInitialization = await repository.initialize( + appDataDir: "/tmp/second", + networkConfiguration: RelayConfiguration(mode: .custom, relayURLs: ["https://relay.example"]) + ) + assertLifecycleFailure(concurrentInitialization, equals: .transitionInProgress) + + let share = await repository.shareSources( + [], + transferName: "Blocked", + senderName: "Tester", + accessPolicy: .requireApproval + ) + assertLifecycleFailure(share, equals: .transitionInProgress) + + let receive = await repository.receive(ticket: "ticket", outputDir: "/tmp", receiverName: "Tester") + assertLifecycleFailure(receive, equals: .transitionInProgress) + XCTAssertEqual(factory.callCount, 1) + + factory.unblockInitialization() + guard case .failure(let error) = await firstInitialization.value else { + return XCTFail("The blocking factory should fail the first initialization") + } + XCTAssertTrue(error is BlockingCoreFactoryError) + } + + private func assertLifecycleFailure( + _ result: Result, + equals expected: CoreNetworkLifecycleError, + file: StaticString = #filePath, + line: UInt = #line + ) { + guard case .failure(let error) = result else { + return XCTFail("Expected lifecycle failure \(expected)", file: file, line: line) + } + XCTAssertEqual(error as? CoreNetworkLifecycleError, expected, file: file, line: line) + } +} diff --git a/apple/Tests/Fakes.swift b/apple/Tests/Fakes.swift index ed1e99d..d2e8520 100644 --- a/apple/Tests/Fakes.swift +++ b/apple/Tests/Fakes.swift @@ -25,6 +25,8 @@ final class FakeCoreGateway: CoreGateway { var cancelResult: Result = .success(()) var deleteResult: Result = .success(()) var clearReceiveHistoryResult: Result = .success(0) + var initializeResult: Result = .success(()) + var initializeResults: [Result] = [] // Recorded calls private(set) var responses: [(id: String, accepted: Bool, reason: String?)] = [] @@ -35,11 +37,18 @@ final class FakeCoreGateway: CoreGateway { private(set) var lastReceiveTicket: String? private(set) var lastReceiveReceiverName: String? private(set) var lastShareAccessPolicy: ShareAccessPolicy? + private(set) var initializedNetworkConfigurations: [RelayConfiguration] = [] func setState(_ state: CoreState) { stateSubject.send(state) } func emit(_ signal: CoreSignal) { signalsSubject.send(signal) } - func initialize(appDataDir: String) async -> Result { + func initialize( + appDataDir: String, + networkConfiguration: RelayConfiguration + ) async -> Result { + initializedNetworkConfigurations.append(networkConfiguration) + let result = initializeResults.isEmpty ? initializeResult : initializeResults.removeFirst() + guard case .success = result else { return result } var s = stateSubject.value s.isInitialized = true stateSubject.send(s) diff --git a/apple/Tests/RelayConfigurationTests.swift b/apple/Tests/RelayConfigurationTests.swift new file mode 100644 index 0000000..5ed61bf --- /dev/null +++ b/apple/Tests/RelayConfigurationTests.swift @@ -0,0 +1,95 @@ +import XCTest +@testable import VniDrop + +final class RelayConfigurationTests: XCTestCase { + func testAutomaticModeIgnoresRelayDrafts() throws { + let result = try RelayConfigurationValidator.validate( + mode: .automatic, + relayURLs: ["not a URL"] + ) + XCTAssertEqual(result, .automatic) + } + + func testAutomaticModeRetainsPreviouslySavedRelayURLs() throws { + let result = try RelayConfigurationValidator.validate( + mode: .automatic, + relayURLs: ["not a URL"], + retainedRelayURLs: ["https://relay.example"] + ) + XCTAssertEqual(result, RelayConfiguration( + mode: .automatic, + relayURLs: ["https://relay.example"] + )) + } + + func testCustomModeTrimsValidHTTPSRelayURLs() throws { + let result = try RelayConfigurationValidator.validate( + mode: .custom, + relayURLs: [" https://relay.example/ ", "https://backup.example:443"] + ) + XCTAssertEqual(result, RelayConfiguration( + mode: .custom, + relayURLs: ["https://relay.example", "https://backup.example"] + )) + } + + func testCustomModeIgnoresEmptyURLRows() throws { + let result = try RelayConfigurationValidator.validate( + mode: .custom, + relayURLs: ["", " ", "https://relay.example"] + ) + XCTAssertEqual(result.relayURLs, ["https://relay.example"]) + } + + func testCustomModeRequiresAtLeastOneRelay() { + XCTAssertThrowsError(try RelayConfigurationValidator.validate(mode: .custom, relayURLs: [])) { error in + XCTAssertEqual(error as? RelayConfigurationValidationError, .missingURL) + } + } + + func testCustomModeRequiresHTTPS() { + XCTAssertThrowsError(try RelayConfigurationValidator.validate( + mode: .custom, + relayURLs: ["http://relay.example"] + )) { error in + XCTAssertEqual(error as? RelayConfigurationValidationError, .httpsRequired(index: 0)) + } + } + + func testCustomModeRejectsCredentialsQueryFragmentAndPath() { + let invalidURLs = [ + "https://user:password@relay.example", + "https://relay.example?token=secret", + "https://relay.example#fragment", + "https://relay.example/custom/path", + "https://relay.example:0", + "https://relay.example:99999", + ] + for relayURL in invalidURLs { + XCTAssertThrowsError( + try RelayConfigurationValidator.validate(mode: .custom, relayURLs: [relayURL]), + "Expected \(relayURL) to be rejected" + ) { error in + XCTAssertEqual(error as? RelayConfigurationValidationError, .invalidURL(index: 0)) + } + } + } + + func testCustomModeRejectsNormalizedDuplicate() { + XCTAssertThrowsError(try RelayConfigurationValidator.validate( + mode: .custom, + relayURLs: ["https://relay.example", "https://RELAY.example:443/"] + )) { error in + XCTAssertEqual(error as? RelayConfigurationValidationError, .duplicateURL(index: 1)) + } + } + + func testCustomModeRejectsMoreThanEightRelays() { + let relayURLs = (0...RelayConfigurationValidator.maximumRelayCount).map { + "https://relay-\($0).example" + } + XCTAssertThrowsError(try RelayConfigurationValidator.validate(mode: .custom, relayURLs: relayURLs)) { error in + XCTAssertEqual(error as? RelayConfigurationValidationError, .tooManyURLs) + } + } +} diff --git a/apple/Tests/SendModelTests.swift b/apple/Tests/SendModelTests.swift index 616ae67..859d868 100644 --- a/apple/Tests/SendModelTests.swift +++ b/apple/Tests/SendModelTests.swift @@ -55,4 +55,25 @@ final class SendModelTests: XCTestCase { let response = core.responses.first { $0.id == "req-1" } XCTAssertEqual(response?.accepted, false) } + + func testOnlyActiveShareExposesStoredInvitationTicket() { + XCTAssertEqual( + Fixtures.transfer(id: 1, direction: .send, status: .sharing).invitationPresentation, + .ready("ticket") + ) + XCTAssertEqual( + Fixtures.transfer(id: 2, direction: .send, status: .importing).invitationPresentation, + .preparing + ) + for status in [TransferStatus.stopped, .failed, .cancelled, .done] { + XCTAssertEqual( + Fixtures.transfer(id: 3, direction: .send, status: status).invitationPresentation, + .unavailable + ) + } + } + + func testOversizedInvitationReportsQRCodeUnavailable() { + XCTAssertNil(QRCode.generate(from: String(repeating: "x", count: 10_000))) + } } diff --git a/apple/Tests/SettingsModelTests.swift b/apple/Tests/SettingsModelTests.swift index a6623f1..e4d27a9 100644 --- a/apple/Tests/SettingsModelTests.swift +++ b/apple/Tests/SettingsModelTests.swift @@ -42,4 +42,107 @@ final class SettingsModelTests: XCTestCase { await waitUntil { core.deletedTransfers.count == 2 } XCTAssertEqual(Set(core.deletedTransfers), [2, 3]) } + + func testNetworkSettingsExposeCurrentEndpointId() { + let core = FakeCoreGateway() + let model = makeModel(core, preferences: Fixtures.preferences()) + + core.setState(CoreState( + isInitialized: true, + status: CoreStatus(endpointId: "endpoint-for-allowlist", activeTransfers: 0, activeShares: 0) + )) + + XCTAssertEqual(model.state.endpointId, "endpoint-for-allowlist") + } + + func testApplyCustomRelayRestartsCoreThenPersistsConfiguration() async { + let core = FakeCoreGateway() + let preferences = Fixtures.preferences() + let model = makeModel(core, preferences: preferences) + model.setRelayMode(.custom) + 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"]) + XCTAssertEqual(preferences.preferences.relayConfiguration, expected) + XCTAssertEqual(core.initializedNetworkConfigurations, [expected]) + XCTAssertFalse(model.state.relayConfigurationIsDirty) + } + + func testApplyingAutomaticRetainsLastCustomRelayURLs() async { + let core = FakeCoreGateway() + let preferences = Fixtures.preferences() + let relayURLs = ["https://relay.example", "https://backup.example"] + preferences.setRelayConfiguration(RelayConfiguration(mode: .custom, relayURLs: relayURLs)) + let model = makeModel(core, preferences: preferences) + + model.setRelayMode(.automatic) + model.applyRelayConfiguration() + + await waitUntil { preferences.preferences.relayConfiguration.mode == .automatic } + XCTAssertEqual(preferences.preferences.relayConfiguration.relayURLs, relayURLs) + XCTAssertEqual(core.initializedNetworkConfigurations, [ + RelayConfiguration(mode: .automatic, relayURLs: relayURLs), + ]) + model.setRelayMode(.custom) + XCTAssertEqual(model.state.relayURLs, relayURLs) + } + + func testApplyRelayIsBlockedWhileShareIsActive() async { + let core = FakeCoreGateway() + let preferences = Fixtures.preferences() + let model = makeModel(core, preferences: preferences) + core.setState(CoreState( + isInitialized: true, + status: CoreStatus(endpointId: "endpoint", activeTransfers: 0, activeShares: 1) + )) + model.setRelayMode(.custom) + model.setRelayURL("https://relay.example", at: 0) + + model.applyRelayConfiguration() + await Task.yield() + + XCTAssertTrue(core.initializedNetworkConfigurations.isEmpty) + XCTAssertEqual(preferences.preferences.relayConfiguration, .automatic) + XCTAssertEqual(model.state.relayApplyErrorKey, "relay_apply_active_transfers") + } + + func testRepositoryActiveWorkRejectionDoesNotAttemptRollback() async { + let core = FakeCoreGateway() + 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) + model.setRelayURL(attempted.relayURLs[0], at: 0) + + model.applyRelayConfiguration() + await waitUntil { + core.initializedNetworkConfigurations.count == 1 && !model.state.isApplyingRelayConfiguration + } + + XCTAssertEqual(core.initializedNetworkConfigurations, [attempted]) + XCTAssertEqual(preferences.preferences.relayConfiguration, .automatic) + XCTAssertTrue(model.state.hasActiveNetworkWork) + XCTAssertEqual(model.state.relayApplyErrorKey, "relay_apply_active_transfers") + } + + func testFailedRelayApplyRollsBackWithoutPersisting() async { + let core = FakeCoreGateway() + 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) + model.setRelayURL(attempted.relayURLs[0], at: 0) + + model.applyRelayConfiguration() + await waitUntil { core.initializedNetworkConfigurations.count == 2 } + + XCTAssertEqual(core.initializedNetworkConfigurations, [attempted, .automatic]) + XCTAssertEqual(preferences.preferences.relayConfiguration, .automatic) + XCTAssertEqual(model.state.relayApplyErrorKey, "relay_apply_failed") + } } diff --git a/apple/VniDrop/Core/AppPreferences.swift b/apple/VniDrop/Core/AppPreferences.swift index 74049c0..48ff1f9 100644 --- a/apple/VniDrop/Core/AppPreferences.swift +++ b/apple/VniDrop/Core/AppPreferences.swift @@ -19,6 +19,95 @@ enum FolderAccessStatus { case unavailable } +enum RelayPreferenceMode: String, Codable, CaseIterable, Sendable { + case automatic + case custom +} + +struct RelayConfiguration: Equatable, Codable, Sendable { + var mode: RelayPreferenceMode + var relayURLs: [String] + + static let automatic = RelayConfiguration(mode: .automatic, relayURLs: []) +} + +enum RelayConfigurationValidationError: Error, Equatable, Sendable { + case missingURL + case tooManyURLs + case httpsRequired(index: Int) + case invalidURL(index: Int) + case duplicateURL(index: Int) + + var urlIndex: Int? { + switch self { + case .httpsRequired(let index), .invalidURL(let index), .duplicateURL(let index): return index + case .missingURL, .tooManyURLs: return nil + } + } +} + +enum RelayConfigurationValidator { + static let maximumRelayCount = 8 + static let maximumRelayURLBytes = 2_048 + + static func validate( + mode: RelayPreferenceMode, + relayURLs: [String], + retainedRelayURLs: [String] = [] + ) throws -> RelayConfiguration { + guard mode == .custom else { + return RelayConfiguration(mode: .automatic, relayURLs: retainedRelayURLs) + } + + let relayEntries = relayURLs.enumerated().compactMap { index, value -> (Int, String)? in + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : (index, trimmed) + } + guard !relayEntries.isEmpty else { + throw RelayConfigurationValidationError.missingURL + } + guard relayEntries.count <= maximumRelayCount else { + throw RelayConfigurationValidationError.tooManyURLs + } + + var seen = Set() + var normalizedURLs: [String] = [] + for (index, relayURL) in relayEntries { + guard relayURL.lengthOfBytes(using: .utf8) <= maximumRelayURLBytes, + relayURL.rangeOfCharacter(from: .whitespacesAndNewlines.union(.controlCharacters)) == nil, + var components = URLComponents(string: relayURL) else { + throw RelayConfigurationValidationError.invalidURL(index: index) + } + guard components.scheme?.lowercased() == "https" else { + throw RelayConfigurationValidationError.httpsRequired(index: index) + } + guard + let host = components.host, + !host.isEmpty, + components.port.map({ (1...65_535).contains($0) }) ?? true, + components.user == nil, + components.password == nil, + components.query == nil, + components.fragment == nil, + components.path.isEmpty || components.path == "/" + else { + throw RelayConfigurationValidationError.invalidURL(index: index) + } + + components.scheme = "https" + components.host = host.lowercased() + if components.port == 443 { components.port = nil } + if components.path == "/" { components.path = "" } + guard let canonicalURL = components.string, seen.insert(canonicalURL).inserted else { + throw RelayConfigurationValidationError.duplicateURL(index: index) + } + normalizedURLs.append(canonicalURL) + } + + return RelayConfiguration(mode: .custom, relayURLs: normalizedURLs) + } +} + /// Persisted app preferences, ported from `preferences/AppPreferencesRepository.kt`. /// Backed by `UserDefaults` instead of DataStore; keys and semantics match. struct AppPreferences: Equatable { @@ -28,6 +117,7 @@ struct AppPreferences: Equatable { var notificationsEnabled: Bool var diagnosticsEnabled: Bool var diagnosticsInstallId: String + var relayConfiguration: RelayConfiguration } struct AppPreferencesDefaults { @@ -54,6 +144,7 @@ final class AppPreferencesRepository: ObservableObject { static let notificationsEnabled = "notifications_enabled" static let diagnosticsEnabled = "diagnostics_enabled" static let diagnosticsInstallId = "diagnostics_install_id" + static let relayConfiguration = "relay_configuration" } init(defaults: UserDefaults = .standard, fallback: AppPreferencesDefaults) { @@ -75,10 +166,25 @@ final class AppPreferencesRepository: ObservableObject { themeMode: themeMode, notificationsEnabled: notifications, diagnosticsEnabled: diagnostics, - diagnosticsInstallId: installId + diagnosticsInstallId: installId, + relayConfiguration: resolveRelayConfiguration(defaults) ) } + private static func resolveRelayConfiguration(_ defaults: UserDefaults) -> RelayConfiguration { + guard defaults.object(forKey: Key.relayConfiguration) != nil else { return .automatic } + guard + let data = defaults.data(forKey: Key.relayConfiguration), + let configuration = try? JSONDecoder().decode(RelayConfiguration.self, from: data) + else { + // A stored profile must never silently fall back to public relays. Keeping + // Custom selected with no URLs makes startup fail closed and lets Settings + // offer an explicit repair or reset to Automatic. + return RelayConfiguration(mode: .custom, relayURLs: []) + } + return configuration + } + private static func resolveReceiveFolder(_ defaults: UserDefaults, fallback: ReceiveFolder) -> ReceiveFolder { let kind = defaults.string(forKey: Key.receiveFolderKind) .flatMap(ReceiveFolderKind.init(rawValue:)) ?? fallback.kind @@ -123,6 +229,12 @@ final class AppPreferencesRepository: ObservableObject { reload() } + func setRelayConfiguration(_ configuration: RelayConfiguration) { + guard let encoded = try? JSONEncoder().encode(configuration) else { return } + defaults.set(encoded, forKey: Key.relayConfiguration) + reload() + } + @discardableResult func ensureDiagnosticsInstallId() -> String { let existing = preferences.diagnosticsInstallId diff --git a/apple/VniDrop/Core/CoreGateway.swift b/apple/VniDrop/Core/CoreGateway.swift index ee673f1..e7a5620 100644 --- a/apple/VniDrop/Core/CoreGateway.swift +++ b/apple/VniDrop/Core/CoreGateway.swift @@ -24,7 +24,7 @@ protocol CoreGateway: AnyObject { /// Coalesced change hints emitted by the event sink. var signals: AnyPublisher { get } - func initialize(appDataDir: String) async -> Result + func initialize(appDataDir: String, networkConfiguration: RelayConfiguration) async -> Result func shutdown() func shareSources( _ sources: [ShareSource], diff --git a/apple/VniDrop/Core/CoreRepository.swift b/apple/VniDrop/Core/CoreRepository.swift index 141e54d..1d8a58e 100644 --- a/apple/VniDrop/Core/CoreRepository.swift +++ b/apple/VniDrop/Core/CoreRepository.swift @@ -2,6 +2,58 @@ import Foundation import Combine @preconcurrency import VnidropCore +enum CoreNetworkLifecycleError: Error, Equatable, LocalizedError, Sendable { + case transitionInProgress + case activeNetworkWork + + var errorDescription: String? { + switch self { + case .transitionInProgress: return "A network restart is already in progress." + case .activeNetworkWork: return "Stop active transfers and shares before restarting the network." + } + } +} + +enum CoreNetworkLifecycle { + nonisolated static func requireIdle(activeTransfers: UInt64, activeShares: UInt64) throws { + guard activeTransfers == 0, activeShares == 0 else { + throw CoreNetworkLifecycleError.activeNetworkWork + } + } +} + +protocol CoreBindingFactory: Sendable { + func initialize( + appDataDir: String, + eventSink: CoreEventSink, + networkConfiguration: RelayConfiguration + ) throws -> VnidropCore +} + +struct NativeCoreBindingFactory: CoreBindingFactory { + func initialize( + appDataDir: String, + eventSink: CoreEventSink, + networkConfiguration: RelayConfiguration + ) throws -> VnidropCore { + let nativeConfiguration: CoreNetworkConfig + switch networkConfiguration.mode { + case .automatic: + nativeConfiguration = defaultCoreNetworkConfig() + case .custom: + nativeConfiguration = CoreNetworkConfig( + mode: .custom, + relayUrls: networkConfiguration.relayURLs + ) + } + return try VnidropCore.initializeWithNetworkConfig( + appDataDir: appDataDir, + eventSink: eventSink, + networkConfig: nativeConfiguration + ) + } +} + /// Swift port of `core/CoreRepository.kt`. Owns the `VnidropCore` handle, maps the /// generated UniFFI records into app domain models, publishes an observable /// `CoreState`, and emits coalesced `CoreSignal`s from the event sink. @@ -17,28 +69,63 @@ final class CoreRepository: ObservableObject, CoreGateway { /// Coalesced change hints; subscribe to react to approval/history/transfer changes. var signals: AnyPublisher { signalsSubject.eraseToAnyPublisher() } - // Set on the main actor (initialize/shutdown) but read from `queue` inside - // `runCore`; the underlying core is internally synchronized, so this crossing - // is safe. `nonisolated(unsafe)` documents that contract for Swift 6. + // Initialization swaps happen on `queue`; shutdown and snapshot reads may also + // access the handle from the main actor. The underlying core is internally + // synchronized, and `nonisolated(unsafe)` documents that crossing for Swift 6. private nonisolated(unsafe) var core: VnidropCore? private let queue = DispatchQueue(label: "com.vnidrop.core", qos: .userInitiated) + private let coreFactory: any CoreBindingFactory + private var isNetworkTransitionInProgress = false private lazy var sink = RepositoryEventSink { [weak self] event in Task { @MainActor in self?.handle(event: event) } } private nonisolated static let maxEvents = 200 + init(coreFactory: any CoreBindingFactory = NativeCoreBindingFactory()) { + self.coreFactory = coreFactory + } + // MARK: - Lifecycle - func initialize(appDataDir: String) async -> Result { - await runCore { [sink] in - self.core?.shutdown() - let created = try VnidropCore.initialize(appDataDir: appDataDir, eventSink: sink) - return created - }.map { created in + func initialize( + appDataDir: String, + networkConfiguration: RelayConfiguration + ) async -> Result { + guard !isNetworkTransitionInProgress else { + return .failure(CoreNetworkLifecycleError.transitionInProgress) + } + isNetworkTransitionInProgress = true + defer { isNetworkTransitionInProgress = false } + + let result = await runCore { [sink] in + if let existing = self.core { + let status = existing.status() + try CoreNetworkLifecycle.requireIdle( + activeTransfers: status.activeTransfers, + activeShares: status.activeShares + ) + existing.shutdown() + self.core = nil + } + let created = try self.coreFactory.initialize( + appDataDir: appDataDir, + eventSink: sink, + networkConfiguration: networkConfiguration + ) self.core = created + return created + } + switch result { + case .success: self.refreshSnapshot() self.state.isInitialized = true + return .success(()) + case .failure(let error): + if error as? CoreNetworkLifecycleError != .activeNetworkWork { + self.state = CoreState() + } + return .failure(error) } } @@ -56,6 +143,9 @@ final class CoreRepository: ObservableObject, CoreGateway { senderName: String, accessPolicy: ShareAccessPolicy ) async -> Result { + guard !isNetworkTransitionInProgress else { + return .failure(CoreNetworkLifecycleError.transitionInProgress) + } guard !sources.isEmpty else { return .failure(InvitationError.message("Select at least one file to share")) } @@ -89,7 +179,10 @@ final class CoreRepository: ObservableObject, CoreGateway { } func receive(ticket: String, outputDir: String, receiverName: String) async -> Result { - await runCore { + guard !isNetworkTransitionInProgress else { + return .failure(CoreNetworkLifecycleError.transitionInProgress) + } + return await runCore { try self.requireCore().receive( ticket: ticket, outputDir: outputDir, @@ -105,7 +198,10 @@ final class CoreRepository: ObservableObject, CoreGateway { outputDirectoryUrl: String, receiverName: String ) async -> Result { - await runCore { + guard !isNetworkTransitionInProgress else { + return .failure(CoreNetworkLifecycleError.transitionInProgress) + } + return await runCore { try withSecurityScopedAccess(pathOrUrl: outputDirectoryUrl) { try self.requireCore().receive( ticket: ticket, diff --git a/apple/VniDrop/Features/App/AppModel.swift b/apple/VniDrop/Features/App/AppModel.swift index f9df8b5..8357f26 100644 --- a/apple/VniDrop/Features/App/AppModel.swift +++ b/apple/VniDrop/Features/App/AppModel.swift @@ -26,7 +26,10 @@ final class AppModel: ObservableObject { AppLogger.info("lifecycle", "app started", ["platform": environment.name]) Task { - let result = await repository.initialize(appDataDir: environment.defaultCoreDataDir) + let result = await repository.initialize( + appDataDir: environment.defaultCoreDataDir, + networkConfiguration: preferences.preferences.relayConfiguration + ) if case .failure(let error) = result { messages.error(error) } } diff --git a/apple/VniDrop/Features/Send/TransferDetailsView.swift b/apple/VniDrop/Features/Send/TransferDetailsView.swift index 652bb72..6b046b0 100644 --- a/apple/VniDrop/Features/Send/TransferDetailsView.swift +++ b/apple/VniDrop/Features/Send/TransferDetailsView.swift @@ -43,12 +43,14 @@ struct TransferDetailsView: View { count: pendingReceivers + completedReceivers, onTap: model.openReceivers ) - DetailDestination( - title: String(localized: "transfer_share_title"), - description: String(localized: "transfer_share_description"), - count: 0, - onTap: model.openShare - ) + if transfer.invitationPresentation != .unavailable { + DetailDestination( + title: String(localized: "transfer_share_title"), + description: String(localized: "transfer_share_description"), + count: 0, + onTap: model.openShare + ) + } } if isActiveShare { @@ -276,24 +278,41 @@ struct TransferSharePanel: View { var body: some View { PanelContainer(title: String(localized: "transfer_share_title")) { - if let ticket = transfer.ticket { - qrCard(ticket: ticket) - Text(LocalizedStringKey("transfer_scan_qr")) - .font(VniType.bodySmall).foregroundStyle(colors.foregroundLighter) - .frame(maxWidth: .infinity) + switch transfer.invitationPresentation { + case .ready(let ticket): + let qrImage = QRCode.generate(from: ticket) + qrCard(image: qrImage) + if qrImage != nil { + Text(LocalizedStringKey("transfer_scan_qr")) + .font(VniType.bodySmall).foregroundStyle(colors.foregroundLighter) + .frame(maxWidth: .infinity) + } ShareActionsView(model: model, transfer: transfer, ticket: ticket) - } else { + case .preparing: Text(LocalizedStringKey("transfer_event_preparing")).foregroundStyle(colors.foregroundLighter) + case .unavailable: + Text(LocalizedStringKey( + transfer.status == .failed ? "transfer_event_failed" : "transfer_event_stopped" + )) + .foregroundStyle(colors.foregroundLighter) } } } - private func qrCard(ticket: String) -> some View { + private func qrCard(image: Image?) -> some View { ZStack { - if let qr = QRCode.generate(from: ticket) { - qr.interpolation(.none).resizable().scaledToFit().padding(14) + if let image { + image.interpolation(.none).resizable().scaledToFit().padding(14) } else { - ProgressView() + VStack(spacing: 10) { + Image(systemName: "qrcode") + .font(.system(size: 36, weight: .medium)) + Text(LocalizedStringKey("transfer_qr_unavailable")) + .font(VniType.bodySmall) + .multilineTextAlignment(.center) + } + .foregroundStyle(.black.opacity(0.72)) + .padding(22) } } .frame(width: 268, height: 268) @@ -302,6 +321,26 @@ struct TransferSharePanel: View { } } +enum TransferInvitationPresentation: Equatable { + case preparing + case ready(String) + case unavailable +} + +extension Transfer { + var invitationPresentation: TransferInvitationPresentation { + switch status { + case .importing: + return .preparing + case .sharing: + guard let ticket, !ticket.isEmpty else { return .preparing } + return .ready(ticket) + case .receiving, .done, .failed, .cancelled, .stopped: + return .unavailable + } + } +} + // MARK: - QR generation (CoreImage) enum QRCode { diff --git a/apple/VniDrop/Features/Settings/SettingsModel.swift b/apple/VniDrop/Features/Settings/SettingsModel.swift index 7f36840..5e03b20 100644 --- a/apple/VniDrop/Features/Settings/SettingsModel.swift +++ b/apple/VniDrop/Features/Settings/SettingsModel.swift @@ -7,6 +7,7 @@ enum SettingsSection: Hashable { case preferences case appearance case notifications + case network case storage case about case bugReport @@ -17,6 +18,7 @@ enum SettingsSection: Hashable { case .preferences: return "preferences_title" case .appearance: return "appearance_title" case .notifications: return "notifications_title" + case .network: return "settings_network_title" case .storage: return "storage_title" case .about: return "about_title" case .bugReport: return "about_bug_report" @@ -44,6 +46,14 @@ struct SettingsState: Equatable { var notificationsEnabled = false var notificationPermission: NotificationPermission = .notDetermined var diagnosticsEnabled = false + var relayMode: RelayPreferenceMode = .automatic + var relayURLs: [String] = [] + var relayValidationError: RelayConfigurationValidationError? + var relayConfigurationIsDirty = false + var isApplyingRelayConfiguration = false + var hasActiveNetworkWork = false + var endpointId: String? + var relayApplyErrorKey: String? var deviceInfo: DeviceInfo? var appVersion = "" var isLoadingDeviceInfo = false @@ -66,6 +76,13 @@ struct SettingsState: Equatable { && lhs.themeMode == rhs.themeMode && lhs.notificationsEnabled == rhs.notificationsEnabled && lhs.notificationPermission == rhs.notificationPermission && lhs.diagnosticsEnabled == rhs.diagnosticsEnabled && lhs.appVersion == rhs.appVersion + && lhs.relayMode == rhs.relayMode && lhs.relayURLs == rhs.relayURLs + && lhs.relayValidationError == rhs.relayValidationError + && lhs.relayConfigurationIsDirty == rhs.relayConfigurationIsDirty + && lhs.isApplyingRelayConfiguration == rhs.isApplyingRelayConfiguration + && lhs.hasActiveNetworkWork == rhs.hasActiveNetworkWork + && lhs.endpointId == rhs.endpointId + && lhs.relayApplyErrorKey == rhs.relayApplyErrorKey && lhs.isLoadingDeviceInfo == rhs.isLoadingDeviceInfo && lhs.bugWhatHappened == rhs.bugWhatHappened && lhs.bugExpected == rhs.bugExpected && lhs.bugSteps == rhs.bugSteps && lhs.bugContact == rhs.bugContact @@ -96,6 +113,7 @@ final class SettingsModel: ObservableObject { private var enableNotificationsAfterSettings = false private var usernamePersistTask: Task? private var hasLocalUsernameDraft = false + private var hasRelayConfigurationDraft = false private var cancellables = Set() init( @@ -133,10 +151,29 @@ final class SettingsModel: ObservableObject { self.state.themeMode = prefs.themeMode self.state.notificationsEnabled = prefs.notificationsEnabled self.state.diagnosticsEnabled = prefs.diagnosticsEnabled + if !self.hasRelayConfigurationDraft { + self.state.relayMode = prefs.relayConfiguration.mode + self.state.relayURLs = prefs.relayConfiguration.relayURLs + self.state.relayConfigurationIsDirty = false + } if folder != previousFolder { Task { await self.validateFolder(folder) } } } .store(in: &cancellables) + repository.statePublisher + .sink { [weak self] coreState in + guard let self else { return } + let hasActiveWork = (coreState.status?.activeTransfers ?? 0) > 0 + || (coreState.status?.activeShares ?? 0) > 0 + || coreState.transfers.contains(where: { $0.status.isActiveTransfer }) + self.state.hasActiveNetworkWork = hasActiveWork + self.state.endpointId = coreState.status?.endpointId + if !hasActiveWork && self.state.relayApplyErrorKey == "relay_apply_active_transfers" { + self.state.relayApplyErrorKey = nil + } + } + .store(in: &cancellables) + refreshNotificationPermission() loadDeviceInfo() } @@ -206,6 +243,119 @@ final class SettingsModel: ObservableObject { } } + // MARK: - Network + + func setRelayMode(_ mode: RelayPreferenceMode) { + hasRelayConfigurationDraft = true + state.relayMode = mode + if mode == .custom && state.relayURLs.isEmpty { state.relayURLs = [""] } + updateRelayConfigurationDraft() + } + + func setRelayURL(_ value: String, at index: Int) { + guard state.relayURLs.indices.contains(index) else { return } + hasRelayConfigurationDraft = true + state.relayURLs[index] = value + updateRelayConfigurationDraft() + } + + func addRelayURL() { + guard state.relayURLs.count < RelayConfigurationValidator.maximumRelayCount else { return } + hasRelayConfigurationDraft = true + state.relayURLs.append("") + updateRelayConfigurationDraft() + } + + func removeRelayURL(at index: Int) { + guard state.relayURLs.indices.contains(index) else { return } + hasRelayConfigurationDraft = true + state.relayURLs.remove(at: index) + if state.relayURLs.isEmpty { state.relayURLs = [""] } + updateRelayConfigurationDraft() + } + + func applyRelayConfiguration() { + guard !state.isApplyingRelayConfiguration, state.relayConfigurationIsDirty else { return } + + let configuration: RelayConfiguration + do { + configuration = try RelayConfigurationValidator.validate( + mode: state.relayMode, + relayURLs: state.relayURLs, + retainedRelayURLs: preferences.preferences.relayConfiguration.relayURLs + ) + } catch let error as RelayConfigurationValidationError { + state.relayValidationError = error + state.relayApplyErrorKey = nil + return + } catch { + return + } + + let coreState = repository.state + let hasActiveWork = (coreState.status?.activeTransfers ?? 0) > 0 + || (coreState.status?.activeShares ?? 0) > 0 + || coreState.transfers.contains(where: { $0.status.isActiveTransfer }) + guard !hasActiveWork else { + state.hasActiveNetworkWork = true + state.relayApplyErrorKey = "relay_apply_active_transfers" + messages.show(UiMessage(text: .resource("relay_apply_active_transfers"), tone: .warning)) + return + } + + let previousConfiguration = preferences.preferences.relayConfiguration + state.relayValidationError = nil + state.relayApplyErrorKey = nil + state.isApplyingRelayConfiguration = true + Task { + let applyResult = await repository.initialize( + appDataDir: environment.defaultCoreDataDir, + networkConfiguration: configuration + ) + switch applyResult { + case .success: + hasRelayConfigurationDraft = false + preferences.setRelayConfiguration(configuration) + state.isApplyingRelayConfiguration = false + state.relayConfigurationIsDirty = false + messages.show(UiMessage(text: .resource("relay_settings_applied"), tone: .success)) + case .failure(let error): + if let lifecycleError = error as? CoreNetworkLifecycleError { + state.isApplyingRelayConfiguration = false + switch lifecycleError { + case .activeNetworkWork: + state.hasActiveNetworkWork = true + state.relayApplyErrorKey = "relay_apply_active_transfers" + case .transitionInProgress: + state.relayApplyErrorKey = "relay_apply_failed" + } + return + } + let rollbackResult = await repository.initialize( + appDataDir: environment.defaultCoreDataDir, + networkConfiguration: previousConfiguration + ) + state.isApplyingRelayConfiguration = false + if case .success = rollbackResult { + state.relayApplyErrorKey = "relay_apply_failed" + messages.show(UiMessage(text: .resource("relay_apply_failed"), tone: .error)) + } else { + state.relayApplyErrorKey = "relay_restore_failed" + messages.show(UiMessage(text: .resource("relay_restore_failed"), tone: .error)) + } + } + } + } + + private func updateRelayConfigurationDraft() { + state.relayValidationError = nil + state.relayApplyErrorKey = nil + let saved = preferences.preferences.relayConfiguration + let draftURLs = state.relayMode == .automatic ? saved.relayURLs : state.relayURLs + state.relayConfigurationIsDirty = saved != RelayConfiguration(mode: state.relayMode, relayURLs: draftURLs) + hasRelayConfigurationDraft = state.relayConfigurationIsDirty + } + func setBugWhatHappened(_ value: String) { state.bugWhatHappened = value } func setBugExpected(_ value: String) { state.bugExpected = value } func setBugSteps(_ value: String) { state.bugSteps = value } diff --git a/apple/VniDrop/Features/Settings/SettingsScreen.swift b/apple/VniDrop/Features/Settings/SettingsScreen.swift index a83d210..abb9ae5 100644 --- a/apple/VniDrop/Features/Settings/SettingsScreen.swift +++ b/apple/VniDrop/Features/Settings/SettingsScreen.swift @@ -38,6 +38,17 @@ struct SettingsScreen: View { NavigationLink(value: SettingsSection.storage) { SettingsRow(icon: "internaldrive", title: String(localized: "storage_title"), value: nil) } + } + Section(String(localized: "settings_advanced_title")) { + NavigationLink(value: SettingsSection.network) { + SettingsRow( + icon: "network", + title: String(localized: "settings_network_title"), + value: relayModeLabel(model.state.relayMode) + ) + } + } + Section { NavigationLink(value: SettingsSection.about) { SettingsRow(icon: "info.circle", title: String(localized: "about_title"), value: nil) } @@ -99,6 +110,8 @@ private struct SettingsSectionContent: View { AppearanceSettings(model: model) case .notifications: NotificationSettings(model: model) + case .network: + NetworkSettings(model: model) case .storage: StorageSettings(model: model) case .about: @@ -109,6 +122,13 @@ private struct SettingsSectionContent: View { } } +func relayModeLabel(_ mode: RelayPreferenceMode) -> String { + switch mode { + case .automatic: return String(localized: "relay_mode_automatic") + case .custom: return String(localized: "relay_mode_custom") + } +} + struct SettingsRow: View { let icon: String let title: String diff --git a/apple/VniDrop/Features/Settings/SettingsSections.swift b/apple/VniDrop/Features/Settings/SettingsSections.swift index b055699..7c0fadc 100644 --- a/apple/VniDrop/Features/Settings/SettingsSections.swift +++ b/apple/VniDrop/Features/Settings/SettingsSections.swift @@ -57,6 +57,178 @@ struct NotificationSettings: View { } } +struct NetworkSettings: View { + @ObservedObject var model: SettingsModel + + var body: some View { + Section { + Picker( + String(localized: "settings_network_title"), + selection: Binding(get: { model.state.relayMode }, set: { model.setRelayMode($0) }) + ) { + ForEach(RelayPreferenceMode.allCases, id: \.self) { mode in + Text(relayModeLabel(mode)).tag(mode) + } + } + .pickerStyle(.segmented) + .labelsHidden() + .disabled(model.state.isApplyingRelayConfiguration) + } footer: { + Text(LocalizedStringKey( + model.state.relayMode == .automatic + ? "relay_mode_automatic_description" + : "relay_mode_custom_description" + )) + } + + Section { + Label { + Text(LocalizedStringKey("relay_privacy_description")) + .fixedSize(horizontal: false, vertical: true) + } icon: { + Image(systemName: "lock.shield") + } + .foregroundStyle(.secondary) + } + + if let endpointId = model.state.endpointId, !endpointId.isEmpty { + Section { + Text(String(format: String(localized: "approval_endpoint_id"), endpointId)) + .font(.footnote.monospaced()) + .textSelection(.enabled) + } + } + + if model.state.relayMode == .custom { + Section { + Label { + Text(LocalizedStringKey("relay_strict_warning")) + .fixedSize(horizontal: false, vertical: true) + } icon: { + Image(systemName: "exclamationmark.shield.fill") + } + .foregroundStyle(.orange) + + ForEach(Array(model.state.relayURLs.indices), id: \.self) { index in + VStack(alignment: .leading, spacing: 6) { + HStack { + TextField( + "https://relay.example.com", + text: Binding( + get: { + model.state.relayURLs.indices.contains(index) + ? model.state.relayURLs[index] + : "" + }, + set: { model.setRelayURL($0, at: index) } + ) + ) + #if os(iOS) + .keyboardType(.URL) + .textInputAutocapitalization(.never) + #endif + .autocorrectionDisabled() + .disabled(model.state.isApplyingRelayConfiguration) + + Button(role: .destructive) { + model.removeRelayURL(at: index) + } label: { + Image(systemName: "minus.circle.fill") + } + .buttonStyle(.borderless) + .accessibilityLabel(Text(LocalizedStringKey("relay_remove_url"))) + .disabled(model.state.isApplyingRelayConfiguration) + } + + if let error = model.state.relayValidationError, error.urlIndex == index { + Text(relayValidationMessage(error)) + .font(.caption) + .foregroundStyle(.red) + } + } + } + + Button(action: model.addRelayURL) { + Label(String(localized: "relay_add_url"), systemImage: "plus.circle") + } + .disabled( + model.state.relayURLs.count >= RelayConfigurationValidator.maximumRelayCount + || model.state.isApplyingRelayConfiguration + ) + } header: { + Text(LocalizedStringKey("relay_custom_urls_label")) + } footer: { + Text(LocalizedStringKey("relay_custom_urls_help")) + } + } + + if let error = model.state.relayValidationError, error.urlIndex == nil { + Section { + Label { + Text(relayValidationMessage(error)) + } icon: { + Image(systemName: "exclamationmark.triangle.fill") + } + .foregroundStyle(.red) + } + } + + if model.state.hasActiveNetworkWork || model.state.relayApplyErrorKey != nil { + Section { + Label { + Text(LocalizedStringKey( + model.state.hasActiveNetworkWork + ? "relay_apply_active_transfers" + : model.state.relayApplyErrorKey ?? "relay_apply_failed" + )) + } icon: { + Image(systemName: "exclamationmark.triangle.fill") + } + .foregroundStyle(.red) + } + } + + Section { + Button(action: model.applyRelayConfiguration) { + HStack { + Text(LocalizedStringKey( + model.state.isApplyingRelayConfiguration ? "relay_applying" : "relay_apply" + )) + if model.state.isApplyingRelayConfiguration { + Spacer() + ProgressView() + } + } + } + .disabled( + !model.state.relayConfigurationIsDirty + || model.state.isApplyingRelayConfiguration + || model.state.hasActiveNetworkWork + ) + } footer: { + Text(LocalizedStringKey("relay_apply_restart_description")) + } + } +} + +private func relayValidationMessage(_ error: RelayConfigurationValidationError) -> String { + switch error { + case .missingURL: + return String(localized: "relay_validation_missing_url") + case .tooManyURLs: + return String( + format: String(localized: "relay_validation_too_many_urls"), + RelayConfigurationValidator.maximumRelayCount + ) + case .httpsRequired(let index): + return String(format: String(localized: "relay_validation_https_required"), index + 1) + case .invalidURL(let index): + return String(format: String(localized: "relay_validation_invalid_url"), index + 1) + case .duplicateURL(let index): + return String(format: String(localized: "relay_validation_duplicate_url"), index + 1) + } +} + struct StorageSettings: View { @ObservedObject var model: SettingsModel @State private var showDeleteConfirmation = false diff --git a/apple/VniDrop/Resources/Localizable.xcstrings b/apple/VniDrop/Resources/Localizable.xcstrings index c40866d..8a891a8 100644 --- a/apple/VniDrop/Resources/Localizable.xcstrings +++ b/apple/VniDrop/Resources/Localizable.xcstrings @@ -10269,6 +10269,1326 @@ } } }, + "relay_add_url": { + "comment": "Apple Network settings button that appends another custom relay URL field.", + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "needs_review", + "value": "Relay-Server hinzufügen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Add relay server" + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "Añadir servidor de retransmisión" + } + }, + "fr": { + "stringUnit": { + "state": "needs_review", + "value": "Ajouter un serveur relais" + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "Aggiungi server relay" + } + }, + "nl": { + "stringUnit": { + "state": "needs_review", + "value": "Relayserver toevoegen" + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Dodaj serwer przekaźnikowy" + } + }, + "pt": { + "stringUnit": { + "state": "needs_review", + "value": "Adicionar servidor de retransmissão" + } + }, + "ru": { + "stringUnit": { + "state": "needs_review", + "value": "Добавить сервер-ретранслятор" + } + } + } + }, + "relay_apply": { + "comment": "Network settings button that activates the selected relay configuration.", + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "needs_review", + "value": "Netzwerkeinstellungen anwenden" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Apply network settings" + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "Aplicar ajustes de red" + } + }, + "fr": { + "stringUnit": { + "state": "needs_review", + "value": "Appliquer les réglages réseau" + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "Applica impostazioni di rete" + } + }, + "nl": { + "stringUnit": { + "state": "needs_review", + "value": "Netwerkinstellingen toepassen" + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Zastosuj ustawienia sieci" + } + }, + "pt": { + "stringUnit": { + "state": "needs_review", + "value": "Aplicar definições de rede" + } + }, + "ru": { + "stringUnit": { + "state": "needs_review", + "value": "Применить настройки сети" + } + } + } + }, + "relay_apply_active_transfers": { + "comment": "Network settings warning when relay configuration cannot change during active work.", + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "needs_review", + "value": "Beenden Sie alle aktiven Übertragungen und Freigaben, bevor Sie die Netzwerkeinstellungen anwenden." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Stop all active transfers and shares before applying network settings." + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "Detenga todas las transferencias y elementos compartidos activos antes de aplicar los ajustes de red." + } + }, + "fr": { + "stringUnit": { + "state": "needs_review", + "value": "Arrêtez tous les transferts et partages actifs avant d’appliquer les réglages réseau." + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "Interrompa tutti i trasferimenti e le condivisioni attivi prima di applicare le impostazioni di rete." + } + }, + "nl": { + "stringUnit": { + "state": "needs_review", + "value": "Stop alle actieve overdrachten en gedeelde items voordat u de netwerkinstellingen toepast." + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Zatrzymaj wszystkie aktywne transfery i udostępnienia przed zastosowaniem ustawień sieci." + } + }, + "pt": { + "stringUnit": { + "state": "needs_review", + "value": "Pare todas as transferências e partilhas ativas antes de aplicar as definições de rede." + } + }, + "ru": { + "stringUnit": { + "state": "needs_review", + "value": "Остановите все активные передачи и раздачи перед применением настроек сети." + } + } + } + }, + "relay_apply_failed": { + "comment": "Network settings error after a relay configuration fails and the previous one is restored.", + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "needs_review", + "value": "Diese Einstellungen konnten nicht angewendet werden. Die vorherigen Netzwerkeinstellungen wurden wiederhergestellt." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Could not apply these settings. The previous network settings were restored." + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "No se han podido aplicar estos ajustes. Se han restaurado los ajustes de red anteriores." + } + }, + "fr": { + "stringUnit": { + "state": "needs_review", + "value": "Impossible d’appliquer ces réglages. Les réglages réseau précédents ont été restaurés." + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "Impossibile applicare queste impostazioni. Sono state ripristinate le impostazioni di rete precedenti." + } + }, + "nl": { + "stringUnit": { + "state": "needs_review", + "value": "Deze instellingen konden niet worden toegepast. De vorige netwerkinstellingen zijn hersteld." + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Nie udało się zastosować tych ustawień. Przywrócono poprzednie ustawienia sieci." + } + }, + "pt": { + "stringUnit": { + "state": "needs_review", + "value": "Não foi possível aplicar estas definições. As definições de rede anteriores foram restauradas." + } + }, + "ru": { + "stringUnit": { + "state": "needs_review", + "value": "Не удалось применить эти настройки. Предыдущие настройки сети восстановлены." + } + } + } + }, + "relay_apply_restart_description": { + "comment": "Network settings explanation of restart and invitation effects when applying relay changes.", + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "needs_review", + "value": "Beim Anwenden wird die Netzwerkverbindung von VniDrop neu gestartet. Beenden Sie zuerst aktive Übertragungen und Freigaben. Vorhandene Einladungen müssen eventuell erneut geteilt werden." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Applying restarts VniDrop’s network connection. Stop active transfers and shares first. Existing invitations may need to be shared again." + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "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." + } + }, + "fr": { + "stringUnit": { + "state": "needs_review", + "value": "L’application de ces réglages redémarre la connexion réseau de VniDrop. Arrêtez d’abord les transferts et partages actifs. Il peut être nécessaire de partager à nouveau les invitations existantes." + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "L’applicazione riavvia la connessione di rete di VniDrop. Interrompa prima i trasferimenti e le condivisioni attivi. Potrebbe essere necessario condividere di nuovo gli inviti esistenti." + } + }, + "nl": { + "stringUnit": { + "state": "needs_review", + "value": "Bij het toepassen wordt de netwerkverbinding van VniDrop opnieuw gestart. Stop eerst actieve overdrachten en gedeelde items. Bestaande uitnodigingen moeten mogelijk opnieuw worden gedeeld." + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Zastosowanie ustawień ponownie uruchamia połączenie sieciowe VniDrop. Najpierw zatrzymaj aktywne transfery i udostępnienia. Istniejące zaproszenia mogą wymagać ponownego udostępnienia." + } + }, + "pt": { + "stringUnit": { + "state": "needs_review", + "value": "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." + } + }, + "ru": { + "stringUnit": { + "state": "needs_review", + "value": "При применении сетевое соединение VniDrop перезапускается. Сначала остановите активные передачи и раздачи. Возможно, существующие приглашения потребуется отправить повторно." + } + } + } + }, + "relay_applying": { + "comment": "Network settings button label while a relay configuration is being activated.", + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "needs_review", + "value": "Wird angewendet…" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Applying…" + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "Aplicando…" + } + }, + "fr": { + "stringUnit": { + "state": "needs_review", + "value": "Application…" + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "Applicazione…" + } + }, + "nl": { + "stringUnit": { + "state": "needs_review", + "value": "Toepassen…" + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Stosowanie…" + } + }, + "pt": { + "stringUnit": { + "state": "needs_review", + "value": "A aplicar…" + } + }, + "ru": { + "stringUnit": { + "state": "needs_review", + "value": "Применение…" + } + } + } + }, + "relay_custom_urls_help": { + "comment": "Network settings help for entering custom relay server URLs.", + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "needs_review", + "value": "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." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Enter one HTTPS relay URL per line. URL credentials are not supported. The TLS certificate must be issued by a publicly trusted certificate authority." + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "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." + } + }, + "fr": { + "stringUnit": { + "state": "needs_review", + "value": "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." + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "Inserisca un URL relay HTTPS per riga. Le credenziali negli URL non sono supportate. Il certificato TLS deve essere emesso da un’autorità di certificazione pubblicamente attendibile." + } + }, + "nl": { + "stringUnit": { + "state": "needs_review", + "value": "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." + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "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." + } + }, + "pt": { + "stringUnit": { + "state": "needs_review", + "value": "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." + } + }, + "ru": { + "stringUnit": { + "state": "needs_review", + "value": "Введите по одному HTTPS-адресу ретранслятора в строке. Учётные данные в URL-адресах не поддерживаются. Сертификат TLS должен быть выдан общедоступным доверенным центром сертификации." + } + } + } + }, + "relay_custom_urls_label": { + "comment": "Network settings label for the custom relay URL input.", + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "needs_review", + "value": "Relay-URLs" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Relay URLs" + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "URL de relés" + } + }, + "fr": { + "stringUnit": { + "state": "needs_review", + "value": "URL des relais" + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "URL relay" + } + }, + "nl": { + "stringUnit": { + "state": "needs_review", + "value": "Relay-URL's" + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Adresy URL przekaźników" + } + }, + "pt": { + "stringUnit": { + "state": "needs_review", + "value": "URLs dos retransmissores" + } + }, + "ru": { + "stringUnit": { + "state": "needs_review", + "value": "URL-адреса ретрансляторов" + } + } + } + }, + "relay_mode_automatic": { + "comment": "Network settings label for VniDrop's automatic public relay mode.", + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "needs_review", + "value": "Automatisch" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Automatic" + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "Automático" + } + }, + "fr": { + "stringUnit": { + "state": "needs_review", + "value": "Automatique" + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "Automatica" + } + }, + "nl": { + "stringUnit": { + "state": "needs_review", + "value": "Automatisch" + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Automatyczny" + } + }, + "pt": { + "stringUnit": { + "state": "needs_review", + "value": "Automático" + } + }, + "ru": { + "stringUnit": { + "state": "needs_review", + "value": "Автоматически" + } + } + } + }, + "relay_mode_automatic_description": { + "comment": "Network settings description of automatic public relay behavior.", + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "needs_review", + "value": "Verwendet die öffentliche Standard-Relay-Infrastruktur von VniDrop, wenn keine direkte Verbindung möglich ist." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Use VniDrop’s default public relay infrastructure when a direct connection is unavailable." + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "Usa la infraestructura pública de relés predeterminada de VniDrop cuando no haya una conexión directa disponible." + } + }, + "fr": { + "stringUnit": { + "state": "needs_review", + "value": "Utiliser l’infrastructure de relais publique par défaut de VniDrop lorsqu’une connexion directe est indisponible." + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "Usa l’infrastruttura relay pubblica predefinita di VniDrop quando non è disponibile una connessione diretta." + } + }, + "nl": { + "stringUnit": { + "state": "needs_review", + "value": "Gebruikt de standaard openbare relay-infrastructuur van VniDrop wanneer geen directe verbinding beschikbaar is." + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Używa domyślnej publicznej infrastruktury przekaźników VniDrop, gdy połączenie bezpośrednie jest niedostępne." + } + }, + "pt": { + "stringUnit": { + "state": "needs_review", + "value": "Utiliza a infraestrutura pública de retransmissores predefinida do VniDrop quando não está disponível uma ligação direta." + } + }, + "ru": { + "stringUnit": { + "state": "needs_review", + "value": "Использовать стандартную публичную инфраструктуру ретрансляторов VniDrop, если прямое соединение недоступно." + } + } + } + }, + "relay_mode_custom": { + "comment": "Network settings label for strict custom relay mode.", + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "needs_review", + "value": "Benutzerdefiniert" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Custom" + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "Personalizado" + } + }, + "fr": { + "stringUnit": { + "state": "needs_review", + "value": "Personnalisé" + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "Personalizzata" + } + }, + "nl": { + "stringUnit": { + "state": "needs_review", + "value": "Aangepast" + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Niestandardowy" + } + }, + "pt": { + "stringUnit": { + "state": "needs_review", + "value": "Personalizado" + } + }, + "ru": { + "stringUnit": { + "state": "needs_review", + "value": "Пользовательский" + } + } + } + }, + "relay_mode_custom_description": { + "comment": "Network settings description of strict custom relay behavior.", + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "needs_review", + "value": "Verwendet ausschließlich die unten aufgeführten Relay-Server." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Use only the relay servers below." + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "Usa únicamente los servidores de retransmisión indicados a continuación." + } + }, + "fr": { + "stringUnit": { + "state": "needs_review", + "value": "Utiliser uniquement les serveurs relais ci-dessous." + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "Usa solo i server relay indicati di seguito." + } + }, + "nl": { + "stringUnit": { + "state": "needs_review", + "value": "Gebruikt alleen de onderstaande relayservers." + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Używa wyłącznie poniższych serwerów przekaźnikowych." + } + }, + "pt": { + "stringUnit": { + "state": "needs_review", + "value": "Utiliza apenas os servidores de retransmissão abaixo." + } + }, + "ru": { + "stringUnit": { + "state": "needs_review", + "value": "Использовать только указанные ниже серверы-ретрансляторы." + } + } + } + }, + "relay_privacy_description": { + "comment": "Network settings privacy note about what relay operators can observe.", + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "needs_review", + "value": "Relays leiten verschlüsselten Datenverkehr weiter und können Ihre Dateien nicht lesen, ihr Betreiber kann jedoch Verbindungsmetadaten sehen." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Relays forward encrypted traffic and cannot read your files, but their operator can observe connection metadata." + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "Los relés reenvían tráfico cifrado y no pueden leer sus archivos, pero su operador puede observar los metadatos de conexión." + } + }, + "fr": { + "stringUnit": { + "state": "needs_review", + "value": "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." + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "I relay inoltrano traffico cifrato e non possono leggere i suoi file, ma il loro operatore può osservare i metadati di connessione." + } + }, + "nl": { + "stringUnit": { + "state": "needs_review", + "value": "Relays sturen versleuteld verkeer door en kunnen uw bestanden niet lezen, maar de beheerder kan verbindingsmetadata bekijken." + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Przekaźniki przesyłają zaszyfrowany ruch i nie mogą odczytać plików, ale ich operator może obserwować metadane połączenia." + } + }, + "pt": { + "stringUnit": { + "state": "needs_review", + "value": "Os retransmissores encaminham tráfego cifrado e não conseguem ler os seus ficheiros, mas o operador pode observar metadados da ligação." + } + }, + "ru": { + "stringUnit": { + "state": "needs_review", + "value": "Ретрансляторы передают зашифрованный трафик и не могут читать ваши файлы, но их оператор может видеть метаданные соединения." + } + } + } + }, + "relay_remove_url": { + "comment": "Apple Network settings accessibility label for removing one custom relay URL field.", + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "needs_review", + "value": "Relay-Server entfernen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Remove relay server" + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "Eliminar servidor de retransmisión" + } + }, + "fr": { + "stringUnit": { + "state": "needs_review", + "value": "Supprimer le serveur relais" + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "Rimuovi server relay" + } + }, + "nl": { + "stringUnit": { + "state": "needs_review", + "value": "Relayserver verwijderen" + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Usuń serwer przekaźnikowy" + } + }, + "pt": { + "stringUnit": { + "state": "needs_review", + "value": "Remover servidor de retransmissão" + } + }, + "ru": { + "stringUnit": { + "state": "needs_review", + "value": "Удалить сервер-ретранслятор" + } + } + } + }, + "relay_restore_failed": { + "comment": "Network settings severe error when neither new nor previous relay settings can initialize.", + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "needs_review", + "value": "Die vorherigen Netzwerkeinstellungen konnten nicht wiederhergestellt werden. Starten Sie VniDrop neu und prüfen Sie Ihre Relay-Konfiguration." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Could not restore the previous network settings. Restart VniDrop and review your relay configuration." + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "No se han podido restaurar los ajustes de red anteriores. Reinicie VniDrop y revise la configuración de relés." + } + }, + "fr": { + "stringUnit": { + "state": "needs_review", + "value": "Impossible de restaurer les réglages réseau précédents. Redémarrez VniDrop et vérifiez votre configuration de relais." + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "Impossibile ripristinare le impostazioni di rete precedenti. Riavvii VniDrop e verifichi la configurazione dei relay." + } + }, + "nl": { + "stringUnit": { + "state": "needs_review", + "value": "De vorige netwerkinstellingen konden niet worden hersteld. Start VniDrop opnieuw en controleer uw relayconfiguratie." + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Nie udało się przywrócić poprzednich ustawień sieci. Uruchom ponownie VniDrop i sprawdź konfigurację przekaźników." + } + }, + "pt": { + "stringUnit": { + "state": "needs_review", + "value": "Não foi possível restaurar as definições de rede anteriores. Reinicie o VniDrop e reveja a configuração dos retransmissores." + } + }, + "ru": { + "stringUnit": { + "state": "needs_review", + "value": "Не удалось восстановить предыдущие настройки сети. Перезапустите VniDrop и проверьте конфигурацию ретрансляторов." + } + } + } + }, + "relay_settings_applied": { + "comment": "Network settings confirmation after a relay configuration is activated.", + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "needs_review", + "value": "Netzwerkeinstellungen angewendet." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Network settings applied." + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "Ajustes de red aplicados." + } + }, + "fr": { + "stringUnit": { + "state": "needs_review", + "value": "Réglages réseau appliqués." + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "Impostazioni di rete applicate." + } + }, + "nl": { + "stringUnit": { + "state": "needs_review", + "value": "Netwerkinstellingen toegepast." + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Zastosowano ustawienia sieci." + } + }, + "pt": { + "stringUnit": { + "state": "needs_review", + "value": "Definições de rede aplicadas." + } + }, + "ru": { + "stringUnit": { + "state": "needs_review", + "value": "Настройки сети применены." + } + } + } + }, + "relay_strict_warning": { + "comment": "Network settings warning that custom relay mode has no public fallback or discovery.", + "extractionState": "manual", + "localizations": { + "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." + } + }, + "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." + } + }, + "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." + } + }, + "fr": { + "stringUnit": { + "state": "needs_review", + "value": "Le mode personnalisé est strict : VniDrop n’utilisera ni les relais publics ni la découverte publique comme solution de repli. Les autres appareils doivent pouvoir accéder aux relais configurés." + } + }, + "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." + } + }, + "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." + } + }, + "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." + } + }, + "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." + } + }, + "ru": { + "stringUnit": { + "state": "needs_review", + "value": "Пользовательский режим работает строго: VniDrop не будет переключаться на публичные ретрансляторы или публичное обнаружение. Другие устройства должны иметь доступ к настроенным ретрансляторам." + } + } + } + }, + "relay_validation_duplicate_url": { + "comment": "Network settings validation error for a repeated custom relay URL.", + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "needs_review", + "value": "Die Relay-URL in Zeile %1$d ist bereits zuvor eingetragen." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Relay URL on line %1$d duplicates an earlier entry." + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "La URL de relé de la línea %1$d duplica una entrada anterior." + } + }, + "fr": { + "stringUnit": { + "state": "needs_review", + "value": "L’URL de relais à la ligne %1$d est identique à une entrée précédente." + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "L’URL relay alla riga %1$d duplica una voce precedente." + } + }, + "nl": { + "stringUnit": { + "state": "needs_review", + "value": "De relay-URL op regel %1$d is gelijk aan een eerdere invoer." + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Adres URL przekaźnika w wierszu %1$d powtarza wcześniejszy wpis." + } + }, + "pt": { + "stringUnit": { + "state": "needs_review", + "value": "O URL do retransmissor na linha %1$d duplica uma entrada anterior." + } + }, + "ru": { + "stringUnit": { + "state": "needs_review", + "value": "URL-адрес ретранслятора в строке %1$d повторяет предыдущую запись." + } + } + } + }, + "relay_validation_https_required": { + "comment": "Network settings validation error when a custom relay URL is not HTTPS.", + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "needs_review", + "value": "Die Relay-URL in Zeile %1$d muss mit https:// beginnen." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Relay URL on line %1$d must start with https://." + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "La URL de relé de la línea %1$d debe empezar por https://." + } + }, + "fr": { + "stringUnit": { + "state": "needs_review", + "value": "L’URL de relais à la ligne %1$d doit commencer par https://." + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "L’URL relay alla riga %1$d deve iniziare con https://." + } + }, + "nl": { + "stringUnit": { + "state": "needs_review", + "value": "De relay-URL op regel %1$d moet beginnen met https://." + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Adres URL przekaźnika w wierszu %1$d musi zaczynać się od https://." + } + }, + "pt": { + "stringUnit": { + "state": "needs_review", + "value": "O URL do retransmissor na linha %1$d tem de começar por https://." + } + }, + "ru": { + "stringUnit": { + "state": "needs_review", + "value": "URL-адрес ретранслятора в строке %1$d должен начинаться с https://." + } + } + } + }, + "relay_validation_invalid_url": { + "comment": "Network settings validation error for a malformed custom relay URL.", + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "needs_review", + "value": "Die Relay-URL in Zeile %1$d ist ungültig." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Relay URL on line %1$d is not valid." + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "La URL de relé de la línea %1$d no es válida." + } + }, + "fr": { + "stringUnit": { + "state": "needs_review", + "value": "L’URL de relais à la ligne %1$d n’est pas valide." + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "L’URL relay alla riga %1$d non è valido." + } + }, + "nl": { + "stringUnit": { + "state": "needs_review", + "value": "De relay-URL op regel %1$d is ongeldig." + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Adres URL przekaźnika w wierszu %1$d jest nieprawidłowy." + } + }, + "pt": { + "stringUnit": { + "state": "needs_review", + "value": "O URL do retransmissor na linha %1$d não é válido." + } + }, + "ru": { + "stringUnit": { + "state": "needs_review", + "value": "URL-адрес ретранслятора в строке %1$d недействителен." + } + } + } + }, + "relay_validation_missing_url": { + "comment": "Network settings validation error when custom mode has no relay URL.", + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "needs_review", + "value": "Fügen Sie mindestens eine Relay-URL hinzu." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Add at least one relay URL." + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "Añada al menos una URL de relé." + } + }, + "fr": { + "stringUnit": { + "state": "needs_review", + "value": "Ajoutez au moins une URL de relais." + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "Aggiunga almeno un URL relay." + } + }, + "nl": { + "stringUnit": { + "state": "needs_review", + "value": "Voeg ten minste één relay-URL toe." + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Dodaj co najmniej jeden adres URL przekaźnika." + } + }, + "pt": { + "stringUnit": { + "state": "needs_review", + "value": "Adicione pelo menos um URL de retransmissor." + } + }, + "ru": { + "stringUnit": { + "state": "needs_review", + "value": "Добавьте хотя бы один URL-адрес ретранслятора." + } + } + } + }, + "relay_validation_too_many_urls": { + "comment": "Network settings validation error when too many custom relay URLs are entered.", + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "needs_review", + "value": "Sie können bis zu %1$d Relay-Server konfigurieren." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "You can configure up to %1$d relay servers." + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "Puede configurar hasta %1$d servidores de retransmisión." + } + }, + "fr": { + "stringUnit": { + "state": "needs_review", + "value": "Vous pouvez configurer jusqu’à %1$d serveurs relais." + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "Può configurare fino a %1$d server relay." + } + }, + "nl": { + "stringUnit": { + "state": "needs_review", + "value": "U kunt maximaal %1$d relayservers instellen." + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Możesz skonfigurować maksymalnie %1$d serwerów przekaźnikowych." + } + }, + "pt": { + "stringUnit": { + "state": "needs_review", + "value": "Pode configurar até %1$d servidores de retransmissão." + } + }, + "ru": { + "stringUnit": { + "state": "needs_review", + "value": "Можно настроить до %1$d серверов-ретрансляторов." + } + } + } + }, "send_access_anyone": { "comment": "Send access option: anyone with the invitation can receive.", "extractionState": "manual", @@ -11649,6 +12969,126 @@ } } }, + "settings_advanced_title": { + "comment": "Settings overview section header for expert configuration.", + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "needs_review", + "value": "Erweitert" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Advanced" + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "Avanzado" + } + }, + "fr": { + "stringUnit": { + "state": "needs_review", + "value": "Avancé" + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "Avanzate" + } + }, + "nl": { + "stringUnit": { + "state": "needs_review", + "value": "Geavanceerd" + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Zaawansowane" + } + }, + "pt": { + "stringUnit": { + "state": "needs_review", + "value": "Avançado" + } + }, + "ru": { + "stringUnit": { + "state": "needs_review", + "value": "Дополнительно" + } + } + } + }, + "settings_network_title": { + "comment": "Settings overview row and Network settings screen title.", + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "needs_review", + "value": "Netzwerk" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Network" + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "Red" + } + }, + "fr": { + "stringUnit": { + "state": "needs_review", + "value": "Réseau" + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "Rete" + } + }, + "nl": { + "stringUnit": { + "state": "needs_review", + "value": "Netwerk" + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Sieć" + } + }, + "pt": { + "stringUnit": { + "state": "needs_review", + "value": "Rede" + } + }, + "ru": { + "stringUnit": { + "state": "needs_review", + "value": "Сеть" + } + } + } + }, "settings_subtitle": { "comment": "Settings screen: subtitle summarizing what's configurable.", "extractionState": "manual", @@ -14721,6 +16161,66 @@ } } }, + "transfer_qr_unavailable": { + "comment": "Transfer share: shown when an invitation is too large to encode as a QR code.", + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "needs_review", + "value": "Für diese Einladung ist kein QR-Code verfügbar. Verwenden Sie stattdessen Teilen oder Herunterladen." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "QR unavailable for this invitation. Use Share or Download instead." + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "El QR no está disponible para esta invitación. Use Compartir o Descargar." + } + }, + "fr": { + "stringUnit": { + "state": "needs_review", + "value": "Le code QR n’est pas disponible pour cette invitation. Utilisez plutôt Partager ou Télécharger." + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "Il codice QR non è disponibile per questo invito. Utilizzi invece Condividi o Scarica." + } + }, + "nl": { + "stringUnit": { + "state": "needs_review", + "value": "QR is niet beschikbaar voor deze uitnodiging. Gebruik in plaats daarvan Delen of Downloaden." + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Kod QR jest niedostępny dla tego zaproszenia. Zamiast tego użyj opcji Udostępnij lub Pobierz." + } + }, + "pt": { + "stringUnit": { + "state": "needs_review", + "value": "O código QR não está disponível para este convite. Utilize Partilhar ou Transferir." + } + }, + "ru": { + "stringUnit": { + "state": "needs_review", + "value": "QR-код недоступен для этого приглашения. Используйте «Поделиться» или «Скачать»." + } + } + } + }, "transfer_receiver_accepted": { "comment": "Receiver status: approved, waiting for the download to complete.", "extractionState": "manual", diff --git a/crates/vnidrop/Cargo.toml b/crates/vnidrop/Cargo.toml index 9df0499..9eeaf94 100644 --- a/crates/vnidrop/Cargo.toml +++ b/crates/vnidrop/Cargo.toml @@ -36,4 +36,5 @@ uuid = { version = "1.23.3", features = ["v4", "serde"] } walkdir = "2.5.0" [dev-dependencies] +iroh-relay = { version = "1.0.0", features = ["server"] } tempfile = "3.27.0" diff --git a/crates/vnidrop/src/api.rs b/crates/vnidrop/src/api.rs index e3b3c68..f4fe087 100644 --- a/crates/vnidrop/src/api.rs +++ b/crates/vnidrop/src/api.rs @@ -1,9 +1,121 @@ use anyhow::Context; +use iroh::RelayUrl; use iroh_blobs::Hash; use serde::{Deserialize, Serialize}; +use std::{collections::BTreeSet, net::IpAddr, str::FromStr}; use crate::util::{non_empty, now_ms}; +pub(crate) const MAX_CUSTOM_RELAYS: usize = 8; +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, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, uniffi::Record)] +pub struct CoreNetworkConfig { + pub mode: CoreRelayMode, + pub relay_urls: Vec, +} + +impl Default for CoreNetworkConfig { + fn default() -> Self { + Self { + mode: CoreRelayMode::Automatic, + relay_urls: Vec::new(), + } + } +} + +impl CoreNetworkConfig { + pub(crate) fn validated_relay_urls(&self) -> anyhow::Result> { + match self.mode { + CoreRelayMode::Automatic => { + if !self.relay_urls.is_empty() { + anyhow::bail!("automatic relay mode must not include custom relay URLs"); + } + Ok(Vec::new()) + } + CoreRelayMode::Custom => { + if self.relay_urls.is_empty() { + anyhow::bail!("custom relay mode requires at least one relay URL"); + } + if self.relay_urls.len() > MAX_CUSTOM_RELAYS { + anyhow::bail!( + "custom relay mode supports at most {MAX_CUSTOM_RELAYS} relay URLs" + ); + } + + let mut seen = BTreeSet::new(); + let mut validated = Vec::with_capacity(self.relay_urls.len()); + for (index, value) in self.relay_urls.iter().enumerate() { + if value.is_empty() + || value + .chars() + .any(|character| character.is_whitespace() || character.is_control()) + { + anyhow::bail!( + "relay URL must be non-empty and contain no whitespace or control characters" + ); + } + if value.len() > MAX_RELAY_URL_BYTES { + anyhow::bail!( + "relay URL is {} bytes, limit is {MAX_RELAY_URL_BYTES}", + value.len() + ); + } + let url = RelayUrl::from_str(value) + .with_context(|| format!("invalid relay URL at position {}", index + 1))?; + let secure = url.scheme() == "https"; + let loopback_http = url.scheme() == "http" + && url.host_str().is_some_and(|host| { + host.eq_ignore_ascii_case("localhost") + || host + .trim_start_matches('[') + .trim_end_matches(']') + .parse::() + .is_ok_and(|address| address.is_loopback()) + }); + if !secure && !loopback_http { + anyhow::bail!( + "relay URL must use HTTPS; HTTP is allowed only for loopback development relays" + ); + } + if url.host_str().is_none() { + anyhow::bail!("relay URL must include a host"); + } + if url.port() == Some(0) { + anyhow::bail!("relay URL port must be between 1 and 65535"); + } + if value.contains('@') || !url.username().is_empty() || url.password().is_some() + { + anyhow::bail!("relay URL must not contain credentials"); + } + if url.query().is_some() || url.fragment().is_some() { + anyhow::bail!("relay URL must not contain a query or fragment"); + } + if url.path() != "/" { + anyhow::bail!("relay URL must not contain a path"); + } + if !seen.insert(url.clone()) { + anyhow::bail!("custom relay URLs must not contain duplicates"); + } + validated.push(url); + } + Ok(validated) + } + } + } +} + +#[uniffi::export] +pub fn default_core_network_config() -> CoreNetworkConfig { + CoreNetworkConfig::default() +} + #[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)] pub struct CoreLimits { pub max_sources: u64, diff --git a/crates/vnidrop/src/lib.rs b/crates/vnidrop/src/lib.rs index f958b5b..6028dd7 100644 --- a/crates/vnidrop/src/lib.rs +++ b/crates/vnidrop/src/lib.rs @@ -14,10 +14,11 @@ mod transfer_state; mod util; pub use api::{ - default_core_limits, CoreEvent, CoreEventSink, CoreLimits, CoreStorageUsage, PublishedOutput, - ReceiveOutputSink, ReceiveOutputSinkV2, ReceivedArtifact, ReceivedLocatorKind, ReceiverRequest, - RuntimeStatus, ShareMetadataInput, ShareResult, ShareSource, SourceKind, StoredTransfer, - TicketInspection, TransferAccessMode, TransferMetadata, + default_core_limits, default_core_network_config, CoreEvent, CoreEventSink, CoreLimits, + CoreNetworkConfig, CoreRelayMode, CoreStorageUsage, PublishedOutput, ReceiveOutputSink, + ReceiveOutputSinkV2, ReceivedArtifact, ReceivedLocatorKind, ReceiverRequest, RuntimeStatus, + ShareMetadataInput, ShareResult, ShareSource, SourceKind, StoredTransfer, TicketInspection, + TransferAccessMode, TransferMetadata, }; pub use error::VnidropError; pub use runtime::VnidropCore; diff --git a/crates/vnidrop/src/repository.rs b/crates/vnidrop/src/repository.rs index 9715789..e493888 100644 --- a/crates/vnidrop/src/repository.rs +++ b/crates/vnidrop/src/repository.rs @@ -49,6 +49,7 @@ pub(crate) struct PersistedShare { pub(crate) transfer_id: u64, pub(crate) local_id: String, pub(crate) content_hash: String, + pub(crate) ticket: Option, pub(crate) access_mode: String, } @@ -709,7 +710,7 @@ impl Repository { pub(crate) async fn list_active_shares(&self) -> Result> { let rows = sqlx::query( r#" - SELECT transfer_id, local_id, content_hash, access_mode + SELECT transfer_id, local_id, content_hash, ticket, access_mode FROM transfers WHERE direction = 'send' AND status = 'sharing' @@ -724,7 +725,8 @@ impl Repository { transfer_id: row.get::(0) as u64, local_id: row.get::(1), content_hash: row.get::(2), - access_mode: row.get::(3), + ticket: row.get::, _>(3), + access_mode: row.get::(4), }) .collect()) } diff --git a/crates/vnidrop/src/runtime/delivery.rs b/crates/vnidrop/src/runtime/delivery.rs index 9a85c40..8674480 100644 --- a/crates/vnidrop/src/runtime/delivery.rs +++ b/crates/vnidrop/src/runtime/delivery.rs @@ -1,12 +1,12 @@ -use std::{str::FromStr, sync::Arc, time::Duration}; +use std::{sync::Arc, time::Duration}; -use iroh_blobs::ticket::BlobTicket; use serde_json::json; -use super::CoreInner; +use super::{filter_peer_addr_for_relay_mode, CoreInner}; use crate::{ handshake::{DeliveryReceipt, DeliveryReceiptResponse, HandshakeService}, repository::PendingDeliveryReceipt, + ticket::parse_persisted_sender_address, }; const DELIVERY_RECEIPT_TIMEOUT: Duration = Duration::from_secs(5); @@ -60,8 +60,8 @@ impl CoreInner { } async fn deliver_pending_receipt(&self, pending: PendingDeliveryReceipt) { - let blob_ticket = match BlobTicket::from_str(&pending.sender_blob_ticket) { - Ok(ticket) => ticket, + let sender_addr = match parse_persisted_sender_address(&pending.sender_blob_ticket) { + Ok(addr) => addr, Err(error) => { tracing::warn!(%error, request_id = %pending.request_id, "discarded invalid pending delivery receipt"); let _ = self @@ -78,7 +78,24 @@ impl CoreInner { return; } }; - let client = HandshakeService::client(self.endpoint.clone(), blob_ticket.addr().clone()); + let sender_addr = match filter_peer_addr_for_relay_mode( + &sender_addr, + self.relay_mode, + &self.custom_relay_urls, + ) { + Ok(addr) => addr, + Err(error) => { + self.emit_transfer( + pending.local_transfer_id, + "receive", + "delivery", + "receipt-failed", + json!({ "reason": error.to_string() }), + ); + return; + } + }; + let client = HandshakeService::client(self.endpoint.clone(), sender_addr); let receipt = DeliveryReceipt { request_id: pending.request_id.clone(), transfer_id: pending.sender_transfer_id, diff --git a/crates/vnidrop/src/runtime/facade.rs b/crates/vnidrop/src/runtime/facade.rs index 26b1a1d..ea8f30a 100644 --- a/crates/vnidrop/src/runtime/facade.rs +++ b/crates/vnidrop/src/runtime/facade.rs @@ -6,9 +6,10 @@ use serde_json::json; use super::CoreInner; use crate::{ api::{ - CoreEvent, CoreEventSink, CoreLimits, CoreStorageUsage, ReceiveOutputSink, - ReceiveOutputSinkV2, ReceivedArtifact, ReceiverRequest, RuntimeStatus, ShareMetadataInput, - ShareResult, ShareSource, StoredTransfer, TicketInspection, TransferAccessMode, + CoreEvent, CoreEventSink, CoreLimits, CoreNetworkConfig, CoreStorageUsage, + ReceiveOutputSink, ReceiveOutputSinkV2, ReceivedArtifact, ReceiverRequest, RuntimeStatus, + ShareMetadataInput, ShareResult, ShareSource, StoredTransfer, TicketInspection, + TransferAccessMode, }, error::VnidropError, filesystem::platform_path, @@ -42,7 +43,26 @@ impl VnidropCore { app_data_dir: String, event_sink: Arc, ) -> Result, VnidropError> { - Self::initialize_with_limits(app_data_dir, event_sink, CoreLimits::default()) + Self::initialize_with_limits_and_network_config( + app_data_dir, + event_sink, + CoreLimits::default(), + CoreNetworkConfig::default(), + ) + } + + #[uniffi::constructor] + pub fn initialize_with_network_config( + app_data_dir: String, + event_sink: Arc, + network_config: CoreNetworkConfig, + ) -> Result, VnidropError> { + Self::initialize_with_limits_and_network_config( + app_data_dir, + event_sink, + CoreLimits::default(), + network_config, + ) } #[uniffi::constructor] @@ -50,15 +70,39 @@ impl VnidropCore { app_data_dir: String, event_sink: Arc, limits: CoreLimits, + ) -> Result, VnidropError> { + Self::initialize_with_limits_and_network_config( + app_data_dir, + event_sink, + limits, + CoreNetworkConfig::default(), + ) + } + + #[uniffi::constructor] + pub fn initialize_with_limits_and_network_config( + app_data_dir: String, + event_sink: Arc, + limits: CoreLimits, + network_config: CoreNetworkConfig, ) -> Result, VnidropError> { limits.validate().map_err(VnidropError::initialization)?; + let relay_urls = network_config + .validated_relay_urls() + .map_err(VnidropError::initialization)?; let runtime = tokio::runtime::Builder::new_multi_thread() .enable_all() .thread_name("vnidrop") .build()?; let app_data_dir = PathBuf::from(app_data_dir); let inner = runtime - .block_on(CoreInner::start(app_data_dir, event_sink, limits)) + .block_on(CoreInner::start( + app_data_dir, + event_sink, + limits, + network_config.mode, + relay_urls, + )) .map_err(VnidropError::initialization)?; Ok(Arc::new(Self { runtime, inner })) } diff --git a/crates/vnidrop/src/runtime/mod.rs b/crates/vnidrop/src/runtime/mod.rs index 55e3201..137017a 100644 --- a/crates/vnidrop/src/runtime/mod.rs +++ b/crates/vnidrop/src/runtime/mod.rs @@ -27,7 +27,10 @@ use std::{ use anyhow::Result; use futures_lite::StreamExt as _; -use iroh::{endpoint::presets, protocol::Router, Endpoint}; +use iroh::{ + endpoint::presets, protocol::Router, tls::CaTlsConfig, Endpoint, EndpointAddr, RelayConfig, + RelayMap, RelayMode, RelayUrl, +}; use iroh_blobs::{ format::collection::Collection, provider::events::{EventMask, EventSender}, @@ -45,16 +48,19 @@ use tokio::{ use crate::{ access_policy::{mode_from_storage, AccessPolicy}, - api::{CoreEvent, CoreEventSink, CoreLimits}, + api::{CoreEvent, CoreEventSink, CoreLimits, CoreRelayMode}, approval::ApprovalService, event_hub::EventHub, handshake::HandshakeService, logging::init_logging, repository::Repository, secret::load_or_create_secret, + ticket::ticket_matches_relay_profile, transfer_state::{TransferDirection, TransferStatus}, }; +const RELAY_CONNECT_TIMEOUT: Duration = Duration::from_secs(10); + /// Owns the Iroh endpoint, blob store, transfer history, and byte streaming. /// Kotlin owns app lifecycle and platform file picking. pub(super) struct CoreInner { @@ -66,6 +72,8 @@ pub(super) struct CoreInner { pub(super) event_hub: Arc, pub(super) approval: ApprovalService, pub(super) limits: CoreLimits, + pub(super) relay_mode: CoreRelayMode, + pub(super) custom_relay_urls: Vec, pub(super) transfer_slots: Semaphore, pub(super) access_policy: Arc, /// Sync mutex so cancel can remove + signal without awaiting (and without @@ -93,6 +101,8 @@ impl CoreInner { app_data_dir: PathBuf, event_sink: Arc, limits: CoreLimits, + relay_mode: CoreRelayMode, + relay_urls: Vec, ) -> Result> { tokio::fs::create_dir_all(&app_data_dir).await?; init_logging(&app_data_dir)?; @@ -105,11 +115,39 @@ impl CoreInner { add_protected: None, }); let store = FsStore::load_with_opts(store_root.join("blobs.db"), store_options).await?; - let endpoint = Endpoint::builder(presets::N0) - .secret_key(secret_key) - .bind() - .await?; - endpoint.online().await; + let endpoint = match relay_mode { + CoreRelayMode::Automatic => { + Endpoint::builder(presets::N0) + .secret_key(secret_key) + .bind() + .await? + } + CoreRelayMode::Custom => { + 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. + if url.scheme() == "http" { + RelayConfig::new(url, None) + } else { + RelayConfig::from(url) + } + })); + // Minimal leaves address lookup empty, so strict custom mode + // cannot silently publish or resolve addresses through N0. + Endpoint::builder(presets::Minimal) + .relay_mode(RelayMode::Custom(relay_map)) + .ca_tls_config(CaTlsConfig::embedded()) + .secret_key(secret_key) + .bind() + .await? + } + }; + if let Err(error) = + wait_for_relay(&endpoint, relay_mode, &relay_urls, RELAY_CONNECT_TIMEOUT).await + { + endpoint.close().await; + return Err(error); + } // Provider events are where the sender sees remote readers. The core // uses them for send progress and for the current approval gate. @@ -189,6 +227,27 @@ impl CoreInner { ); continue; }; + let relay_profile_matches = share.ticket.as_deref().is_some_and(|ticket| { + ticket_matches_relay_profile(ticket, &limits, relay_mode, &relay_urls) + .unwrap_or(false) + }); + if !relay_profile_matches { + repository + .transition_transfer_status( + transfer_id, + TransferStatus::Sharing, + TransferStatus::Stopped, + ) + .await?; + event_hub.emit_transfer( + transfer_id, + TransferDirection::Send.as_str(), + "recovery", + "share-stopped-network-profile-changed", + json!({ "reason": "saved ticket does not match the active relay profile" }), + ); + continue; + } let tag_name = share_tag_name(&share.local_id); store .tags() @@ -239,6 +298,8 @@ impl CoreInner { repository, event_hub, approval, + relay_mode, + custom_relay_urls: relay_urls, transfer_slots: Semaphore::new(limits.max_concurrent_transfers as usize), limits, access_policy, @@ -309,6 +370,66 @@ impl CoreInner { } } +pub(crate) fn filter_peer_addr_for_relay_mode( + addr: &EndpointAddr, + relay_mode: CoreRelayMode, + custom_relay_urls: &[RelayUrl], +) -> Result { + match relay_mode { + CoreRelayMode::Automatic => Ok(addr.clone()), + CoreRelayMode::Custom => { + let mut filtered = EndpointAddr::new(addr.id); + for ip_addr in addr.ip_addrs().copied() { + filtered = filtered.with_ip_addr(ip_addr); + } + for relay_url in addr + .relay_urls() + .filter(|relay_url| custom_relay_urls.contains(relay_url)) + .cloned() + { + filtered = filtered.with_relay_url(relay_url); + } + if filtered.is_empty() { + anyhow::bail!( + "invitation has no direct address or relay allowed by strict custom relay mode" + ); + } + Ok(filtered) + } + } +} + +pub(crate) async fn wait_for_relay( + endpoint: &Endpoint, + relay_mode: CoreRelayMode, + relay_urls: &[RelayUrl], + timeout: Duration, +) -> Result<()> { + 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 => { + let configured_relays = relay_urls + .iter() + .map(ToString::to_string) + .collect::>() + .join(", "); + anyhow::bail!( + "timed out after {} seconds while connecting to custom relays [{configured_relays}]; verify the URLs, TLS certificates, network access, and relay availability", + timeout.as_secs_f32(), + ); + } + } + } + Ok(()) +} + pub(super) fn share_tag_name(local_id: &str) -> String { format!("vnidrop/share/{local_id}") } diff --git a/crates/vnidrop/src/runtime/receive.rs b/crates/vnidrop/src/runtime/receive.rs index b72223f..9261bbb 100644 --- a/crates/vnidrop/src/runtime/receive.rs +++ b/crates/vnidrop/src/runtime/receive.rs @@ -9,12 +9,12 @@ use bytes::Bytes; use futures_lite::StreamExt as _; use iroh_blobs::{ api::proto::ExportRangesItem, api::remote::GetProgressItem, format::collection::Collection, - get::request::get_hash_seq_and_sizes, Hash, + get::request::get_hash_seq_and_sizes, ticket::BlobTicket, Hash, }; use serde_json::json; use tokio::sync::oneshot; -use super::{ActiveTransfer, CoreInner}; +use super::{filter_peer_addr_for_relay_mode, ActiveTransfer, CoreInner}; use crate::{ access_policy::mode_to_storage, api::{ @@ -28,7 +28,9 @@ use crate::{ }, handshake::{DeliveryReceipt, HandshakeResponse, HandshakeService}, repository::{PendingDeliveryReceiptInsert, ReceivedArtifactInsert, TransferUpsert}, - ticket::{parse_transfer_ticket_with_limits, ParsedTransferTicket}, + ticket::{ + encode_persisted_sender_address, parse_transfer_ticket_with_limits, ParsedTransferTicket, + }, transfer_state::{TransferDirection, TransferStatus}, }; @@ -192,7 +194,7 @@ impl CoreInner { .await .context("transfer limiter is closed") .map_err(VnidropError::internal)?; - let parsed = match parse_transfer_ticket_with_limits(&ticket, &self.limits) + let mut parsed = match parse_transfer_ticket_with_limits(&ticket, &self.limits) .context("failed to parse transfer ticket") { Ok(parsed) => parsed, @@ -205,6 +207,17 @@ impl CoreInner { return Err(error); } }; + let sender_addr = filter_peer_addr_for_relay_mode( + parsed.blob_ticket.addr(), + self.relay_mode, + &self.custom_relay_urls, + ) + .map_err(VnidropError::network)?; + parsed.blob_ticket = BlobTicket::new( + sender_addr, + parsed.blob_ticket.hash(), + parsed.blob_ticket.format(), + ); let transfer_id = parsed.metadata.transfer_id; self.persist_receive_start(transfer_id, &parsed, receiver_name.as_deref()) .await @@ -271,7 +284,8 @@ impl CoreInner { .map_err(VnidropError::filesystem)?; } let sender_addr = parsed.blob_ticket.addr().clone(); - let sender_blob_ticket = parsed.blob_ticket.to_string(); + let persisted_sender_address = encode_persisted_sender_address(&sender_addr) + .context("failed to encode sender address for delivery receipt")?; self.emit_transfer(transfer_id, "receive", "network", "connecting", json!({})); // Every VniDrop ticket carries metadata and must complete the handshake. @@ -355,7 +369,7 @@ impl CoreInner { self.repository .complete_receive_with_pending_receipt(PendingDeliveryReceiptInsert { local_transfer_id: transfer_id, - sender_blob_ticket: &sender_blob_ticket, + sender_blob_ticket: &persisted_sender_address, request_id: &delivery_receipt.request_id, sender_transfer_id: delivery_receipt.transfer_id, token: &delivery_receipt.token, diff --git a/crates/vnidrop/src/runtime/share.rs b/crates/vnidrop/src/runtime/share.rs index 1b2c211..849bba3 100644 --- a/crates/vnidrop/src/runtime/share.rs +++ b/crates/vnidrop/src/runtime/share.rs @@ -149,9 +149,13 @@ impl CoreInner { import.file_count, import.total_size, ); - let ticket = VnidropTicket::new(blob_ticket, ticket_metadata) - .encode() - .context("failed to encode VniDrop transfer ticket")?; + let ticket = VnidropTicket::new_with_relay_urls( + blob_ticket, + ticket_metadata, + &self.custom_relay_urls, + ) + .encode() + .context("failed to encode VniDrop transfer ticket")?; let content_hash = import.root_hash.to_string(); let local_id = self .repository diff --git a/crates/vnidrop/src/tests.rs b/crates/vnidrop/src/tests.rs index 859a73d..75e095a 100644 --- a/crates/vnidrop/src/tests.rs +++ b/crates/vnidrop/src/tests.rs @@ -8,6 +8,8 @@ mod filesystem_tests; mod handshake_tests; #[path = "tests/limits.rs"] mod limits_tests; +#[path = "tests/network_config.rs"] +mod network_config_tests; #[path = "tests/repository.rs"] mod repository_tests; #[path = "tests/runtime.rs"] diff --git a/crates/vnidrop/src/tests/network_config.rs b/crates/vnidrop/src/tests/network_config.rs new file mode 100644 index 0000000..e5f0437 --- /dev/null +++ b/crates/vnidrop/src/tests/network_config.rs @@ -0,0 +1,186 @@ +use std::time::{Duration, Instant}; + +use iroh::{endpoint::presets, Endpoint, EndpointAddr, RelayMode, RelayUrl, SecretKey}; + +use crate::{ + api::{ + default_core_network_config, CoreNetworkConfig, CoreRelayMode, MAX_CUSTOM_RELAYS, + MAX_RELAY_URL_BYTES, + }, + runtime::{filter_peer_addr_for_relay_mode, wait_for_relay}, +}; + +#[test] +fn default_network_config_uses_automatic_relays() { + assert_eq!( + default_core_network_config(), + CoreNetworkConfig { + mode: CoreRelayMode::Automatic, + relay_urls: Vec::new(), + } + ); + default_core_network_config() + .validated_relay_urls() + .unwrap(); +} + +#[test] +fn relay_mode_and_url_list_must_be_consistent() { + let automatic_with_url = CoreNetworkConfig { + mode: CoreRelayMode::Automatic, + relay_urls: vec!["https://relay.example.com".to_string()], + }; + assert!(automatic_with_url.validated_relay_urls().is_err()); + + let custom_without_url = CoreNetworkConfig { + mode: CoreRelayMode::Custom, + relay_urls: Vec::new(), + }; + assert!(custom_without_url.validated_relay_urls().is_err()); +} + +#[test] +fn custom_relay_urls_allow_https_and_loopback_http() { + let config = CoreNetworkConfig { + mode: CoreRelayMode::Custom, + relay_urls: vec![ + "https://relay.example.com".to_string(), + "http://localhost:3340".to_string(), + "http://127.0.0.1:3341".to_string(), + "http://[::1]:3342".to_string(), + ], + }; + + assert_eq!(config.validated_relay_urls().unwrap().len(), 4); +} + +#[test] +fn custom_relay_urls_reject_unsafe_or_ambiguous_values() { + for value in [ + "http://relay.example.com", + "https://user:password@relay.example.com", + "https://relay.example.com/path", + "https://relay.example.com?token=secret", + "https://relay.example.com#fragment", + "https://relay.example.com:0", + "https://relay.exa\tmple.com", + "https://@relay.example.com", + " https://relay.example.com", + ] { + let config = CoreNetworkConfig { + mode: CoreRelayMode::Custom, + relay_urls: vec![value.to_string()], + }; + assert!( + config.validated_relay_urls().is_err(), + "{value} should be rejected" + ); + } +} + +#[test] +fn custom_relay_urls_are_bounded_and_unique_after_normalization() { + let duplicates = CoreNetworkConfig { + mode: CoreRelayMode::Custom, + relay_urls: vec![ + "https://relay.example.com".to_string(), + "https://relay.example.com/".to_string(), + ], + }; + assert!(duplicates.validated_relay_urls().is_err()); + + let too_many = CoreNetworkConfig { + mode: CoreRelayMode::Custom, + relay_urls: (0..=MAX_CUSTOM_RELAYS) + .map(|index| format!("https://relay-{index}.example.com")) + .collect(), + }; + assert!(too_many.validated_relay_urls().is_err()); + + let too_long = CoreNetworkConfig { + mode: CoreRelayMode::Custom, + relay_urls: vec![format!( + "https://{}.example.com", + "a".repeat(MAX_RELAY_URL_BYTES) + )], + }; + assert!(too_long.validated_relay_urls().is_err()); +} + +#[test] +fn strict_custom_mode_filters_peer_relays_but_retains_direct_addresses() { + let allowed: RelayUrl = "https://allowed.relay.example.com".parse().unwrap(); + let disallowed: RelayUrl = "https://disallowed.relay.example.com".parse().unwrap(); + let direct = "192.0.2.1:4433".parse().unwrap(); + let addr = EndpointAddr::new(SecretKey::generate().public()) + .with_relay_url(allowed.clone()) + .with_relay_url(disallowed.clone()) + .with_ip_addr(direct); + + let filtered = filter_peer_addr_for_relay_mode( + &addr, + CoreRelayMode::Custom, + std::slice::from_ref(&allowed), + ) + .unwrap(); + assert_eq!( + filtered.relay_urls().cloned().collect::>(), + vec![allowed.clone()] + ); + assert_eq!( + filtered.ip_addrs().copied().collect::>(), + vec![direct] + ); + assert_eq!( + filter_peer_addr_for_relay_mode(&addr, CoreRelayMode::Automatic, &[]).unwrap(), + addr + ); + + let disallowed_only = + EndpointAddr::new(SecretKey::generate().public()).with_relay_url(disallowed); + assert!(filter_peer_addr_for_relay_mode( + &disallowed_only, + CoreRelayMode::Custom, + std::slice::from_ref(&allowed), + ) + .is_err()); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn unreachable_relay_wait_is_bounded_and_actionable_for_each_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()])) + .bind() + .await + .unwrap(); + let started = Instant::now(); + + let error = wait_for_relay( + &endpoint, + CoreRelayMode::Custom, + std::slice::from_ref(&relay_url), + Duration::from_millis(50), + ) + .await + .unwrap_err(); + + assert!(started.elapsed() < Duration::from_secs(1)); + assert!(error.to_string().contains(relay_url.as_str())); + assert!(error.to_string().contains("verify the URLs")); + + let automatic_error = wait_for_relay( + &endpoint, + CoreRelayMode::Automatic, + &[], + Duration::from_millis(50), + ) + .await + .unwrap_err(); + assert!(started.elapsed() < Duration::from_secs(1)); + assert!(automatic_error.to_string().contains("automatic relays")); + assert!(automatic_error + .to_string() + .contains("verify network access")); + endpoint.close().await; +} diff --git a/crates/vnidrop/src/tests/repository.rs b/crates/vnidrop/src/tests/repository.rs index 8bade55..eaa1b11 100644 --- a/crates/vnidrop/src/tests/repository.rs +++ b/crates/vnidrop/src/tests/repository.rs @@ -80,6 +80,7 @@ async fn persists_transfers_and_events_across_reopen() { assert_eq!(shares.len(), 1); assert_eq!(shares[0].transfer_id, 7); assert_eq!(shares[0].content_hash, "hash"); + assert_eq!(shares[0].ticket.as_deref(), Some("ticket")); assert_eq!(shares[0].access_mode, "approval_required"); repository diff --git a/crates/vnidrop/src/tests/ticket.rs b/crates/vnidrop/src/tests/ticket.rs index 47fec19..e5fcb13 100644 --- a/crates/vnidrop/src/tests/ticket.rs +++ b/crates/vnidrop/src/tests/ticket.rs @@ -1,11 +1,16 @@ +use std::net::{Ipv4Addr, SocketAddr}; + use data_encoding::BASE64URL_NOPAD; -use iroh::SecretKey; +use iroh::{RelayUrl, SecretKey}; use iroh_blobs::{ticket::BlobTicket, BlobFormat, Hash}; use serde_json::json; use crate::{ - api::{CoreLimits, TransferMetadata}, - ticket::{parse_transfer_ticket, parse_transfer_ticket_with_limits, VnidropTicket}, + api::{CoreLimits, CoreRelayMode, TransferMetadata}, + ticket::{ + encode_persisted_sender_address, parse_persisted_sender_address, parse_transfer_ticket, + parse_transfer_ticket_with_limits, ticket_matches_relay_profile, VnidropTicket, + }, }; fn blob_ticket(hash_byte: u8) -> BlobTicket { @@ -25,7 +30,7 @@ fn metadata_ticket_round_trips() { 3, 2048, ); - let encoded = VnidropTicket::new(blob_ticket.clone(), metadata.clone()) + let encoded = VnidropTicket::new_with_relay_urls(blob_ticket.clone(), metadata.clone(), &[]) .encode() .unwrap(); let parsed = parse_transfer_ticket(&encoded).unwrap(); @@ -38,7 +43,7 @@ fn metadata_ticket_round_trips() { fn metadata_ticket_tolerates_wrapped_whitespace() { let blob_ticket = blob_ticket(9); let metadata = TransferMetadata::new(7, "Wrapped", None, blob_ticket.hash(), 1, 10); - let encoded = VnidropTicket::new(blob_ticket.clone(), metadata) + let encoded = VnidropTicket::new_with_relay_urls(blob_ticket.clone(), metadata, &[]) .encode() .unwrap(); let wrapped = encoded @@ -52,6 +57,129 @@ fn metadata_ticket_tolerates_wrapped_whitespace() { assert_eq!(parsed.blob_ticket.hash(), blob_ticket.hash()); } +#[test] +fn metadata_ticket_restores_backup_relays_without_losing_direct_addresses() { + let secret = SecretKey::generate(); + let primary: RelayUrl = "https://a.relay.example.com".parse().unwrap(); + let backup: RelayUrl = "https://b.relay.example.com".parse().unwrap(); + let direct = SocketAddr::from((Ipv4Addr::LOCALHOST, 49152)); + let addr = iroh::EndpointAddr::new(secret.public()) + .with_relay_url(primary.clone()) + .with_ip_addr(direct); + let blob_ticket = BlobTicket::new(addr, Hash::new([11; 32]), BlobFormat::HashSeq); + let metadata = TransferMetadata::new(11, "Backed up", None, blob_ticket.hash(), 1, 10); + + let encoded = VnidropTicket::new_with_relay_urls( + blob_ticket, + metadata, + &[primary.clone(), backup.clone()], + ) + .encode() + .unwrap(); + let parsed = parse_transfer_ticket(&encoded).unwrap(); + + assert_eq!( + parsed + .blob_ticket + .addr() + .relay_urls() + .cloned() + .collect::>(), + vec![primary, backup] + ); + assert_eq!( + parsed + .blob_ticket + .addr() + .ip_addrs() + .copied() + .collect::>(), + vec![direct] + ); +} + +#[test] +fn saved_ticket_relay_profile_matching_is_mode_aware_and_order_insensitive() { + let relay_a: RelayUrl = "https://a.relay.example.com".parse().unwrap(); + let relay_b: RelayUrl = "https://b.relay.example.com".parse().unwrap(); + let relay_c: RelayUrl = "https://c.relay.example.com".parse().unwrap(); + let blob_ticket = blob_ticket(13); + let metadata = TransferMetadata::new(13, "Relay profile", None, blob_ticket.hash(), 1, 10); + let custom_ticket = VnidropTicket::new_with_relay_urls( + blob_ticket.clone(), + metadata.clone(), + &[relay_a.clone(), relay_b.clone()], + ) + .encode() + .unwrap(); + let automatic_ticket = VnidropTicket::new_with_relay_urls(blob_ticket, metadata, &[]) + .encode() + .unwrap(); + let limits = CoreLimits::default(); + + assert!(ticket_matches_relay_profile( + &custom_ticket, + &limits, + CoreRelayMode::Custom, + &[relay_b.clone(), relay_a.clone()], + ) + .unwrap()); + assert!(!ticket_matches_relay_profile( + &custom_ticket, + &limits, + CoreRelayMode::Custom, + &[relay_a.clone(), relay_c], + ) + .unwrap()); + assert!( + !ticket_matches_relay_profile(&custom_ticket, &limits, CoreRelayMode::Automatic, &[],) + .unwrap() + ); + assert!(ticket_matches_relay_profile( + &automatic_ticket, + &limits, + CoreRelayMode::Automatic, + &[], + ) + .unwrap()); + assert!(!ticket_matches_relay_profile( + &automatic_ticket, + &limits, + CoreRelayMode::Custom, + &[relay_a], + ) + .unwrap()); +} + +#[test] +fn persisted_sender_address_preserves_relays_and_accepts_legacy_blob_ticket() { + let secret = SecretKey::generate(); + let primary: RelayUrl = "https://a.relay.example.com".parse().unwrap(); + let backup: RelayUrl = "https://b.relay.example.com".parse().unwrap(); + let direct = SocketAddr::from((Ipv4Addr::LOCALHOST, 49153)); + let addr = iroh::EndpointAddr::new(secret.public()) + .with_relay_url(primary.clone()) + .with_relay_url(backup) + .with_ip_addr(direct); + + let encoded = encode_persisted_sender_address(&addr).unwrap(); + assert_eq!(parse_persisted_sender_address(&encoded).unwrap(), addr); + + let legacy_addr = iroh::EndpointAddr::new(secret.public()) + .with_relay_url(primary) + .with_ip_addr(direct); + let legacy = BlobTicket::new( + legacy_addr.clone(), + Hash::new([12; 32]), + BlobFormat::HashSeq, + ) + .to_string(); + assert_eq!( + parse_persisted_sender_address(&legacy).unwrap(), + legacy_addr + ); +} + #[test] fn invalid_ticket_is_rejected() { assert!(parse_transfer_ticket("not-a-ticket").is_err()); diff --git a/crates/vnidrop/src/ticket.rs b/crates/vnidrop/src/ticket.rs index 87f4b63..efb5951 100644 --- a/crates/vnidrop/src/ticket.rs +++ b/crates/vnidrop/src/ticket.rs @@ -1,27 +1,38 @@ -use std::str::FromStr; +use std::{collections::BTreeSet, str::FromStr}; use anyhow::{Context, Result}; use data_encoding::BASE64URL_NOPAD; +use iroh::{EndpointAddr, RelayUrl}; use iroh_blobs::ticket::BlobTicket; use serde::{Deserialize, Serialize}; -use crate::api::{CoreLimits, TransferMetadata}; +use crate::api::{CoreLimits, CoreNetworkConfig, CoreRelayMode, TransferMetadata}; const VNIDROP_TICKET_PREFIX: &str = "vnd1:"; const VNIDROP_TICKET_VERSION: u8 = 1; +const PERSISTED_SENDER_ADDRESS_PREFIX: &str = "vndaddr1:"; #[derive(Debug, Clone, Serialize, Deserialize)] pub(crate) struct VnidropTicket { version: u8, blob_ticket: String, + // BlobTicket's current wire format retains only one relay URL. The outer + // envelope carries backups so new receivers can rebuild the full address. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + relay_urls: Vec, metadata: TransferMetadata, } impl VnidropTicket { - pub(crate) fn new(blob_ticket: BlobTicket, metadata: TransferMetadata) -> Self { + pub(crate) fn new_with_relay_urls( + blob_ticket: BlobTicket, + metadata: TransferMetadata, + relay_urls: &[RelayUrl], + ) -> Self { Self { version: VNIDROP_TICKET_VERSION, blob_ticket: blob_ticket.to_string(), + relay_urls: relay_urls.iter().map(ToString::to_string).collect(), metadata, } } @@ -50,6 +61,35 @@ impl VnidropTicket { pub(crate) struct ParsedTransferTicket { pub(crate) blob_ticket: BlobTicket, pub(crate) metadata: TransferMetadata, + pub(crate) advertised_custom_relay_urls: Vec, +} + +#[derive(Debug, Serialize, Deserialize)] +struct PersistedSenderAddress { + addr: EndpointAddr, +} + +pub(crate) fn encode_persisted_sender_address(addr: &EndpointAddr) -> Result { + let bytes = serde_json::to_vec(&PersistedSenderAddress { addr: addr.clone() })?; + Ok(format!( + "{PERSISTED_SENDER_ADDRESS_PREFIX}{}", + BASE64URL_NOPAD.encode(&bytes) + )) +} + +pub(crate) fn parse_persisted_sender_address(value: &str) -> Result { + if let Some(encoded) = value.strip_prefix(PERSISTED_SENDER_ADDRESS_PREFIX) { + let bytes = BASE64URL_NOPAD + .decode(encoded.as_bytes()) + .context("invalid persisted sender address encoding")?; + let persisted: PersistedSenderAddress = + serde_json::from_slice(&bytes).context("invalid persisted sender address payload")?; + return Ok(persisted.addr); + } + + // Rows created before multi-relay invitations stored a raw BlobTicket. + let legacy = BlobTicket::from_str(value).context("invalid legacy sender BlobTicket")?; + Ok(legacy.addr().clone()) } #[cfg(test)] @@ -93,17 +133,55 @@ pub(crate) fn parse_transfer_ticket_with_limits( Some(ticket.metadata.transfer_name.as_str()), )?; limits.validate_metadata_text("sender name", ticket.metadata.sender_name.as_deref())?; - let blob_ticket = BlobTicket::from_str(&ticket.blob_ticket) + let mut blob_ticket = BlobTicket::from_str(&ticket.blob_ticket) .context("invalid BlobTicket inside VniDrop ticket")?; if ticket.metadata.content_hash != blob_ticket.hash().to_string() { anyhow::bail!("VniDrop ticket metadata hash does not match BlobTicket hash"); } + let advertised_custom_relay_urls = if ticket.relay_urls.is_empty() { + Vec::new() + } else { + CoreNetworkConfig { + mode: CoreRelayMode::Custom, + relay_urls: ticket.relay_urls, + } + .validated_relay_urls() + .context("invalid relay URLs inside VniDrop ticket")? + }; + if !advertised_custom_relay_urls.is_empty() { + let (mut addr, hash, format) = blob_ticket.into_parts(); + for relay_url in advertised_custom_relay_urls.iter().cloned() { + addr = addr.with_relay_url(relay_url); + } + blob_ticket = BlobTicket::new(addr, hash, format); + } Ok(ParsedTransferTicket { blob_ticket, metadata: ticket.metadata, + advertised_custom_relay_urls, }) } +pub(crate) fn ticket_matches_relay_profile( + value: &str, + limits: &CoreLimits, + relay_mode: CoreRelayMode, + custom_relay_urls: &[RelayUrl], +) -> Result { + let parsed = parse_transfer_ticket_with_limits(value, limits)?; + match relay_mode { + CoreRelayMode::Automatic => Ok(parsed.advertised_custom_relay_urls.is_empty()), + CoreRelayMode::Custom => { + let advertised = parsed + .advertised_custom_relay_urls + .into_iter() + .collect::>(); + let configured = custom_relay_urls.iter().cloned().collect::>(); + Ok(advertised == configured) + } + } +} + fn normalize_ticket_input(value: &str) -> String { // Tickets are commonly copied from text views or chat apps that insert line // breaks. Strip whitespace only; other corrupt characters should still be diff --git a/crates/vnidrop/tests/custom_relay.rs b/crates/vnidrop/tests/custom_relay.rs new file mode 100644 index 0000000..a89e7d3 --- /dev/null +++ b/crates/vnidrop/tests/custom_relay.rs @@ -0,0 +1,106 @@ +mod support; + +use std::str::FromStr; + +use data_encoding::BASE64URL_NOPAD; +use iroh::{EndpointAddr, RelayUrl}; +use iroh_blobs::ticket::BlobTicket; +use serde_json::Value; +use support::{receive_with_response, share_path, TestNode, TestRelay}; +use vnidrop::{CoreNetworkConfig, CoreRelayMode}; + +fn custom_config(relay_urls: &[&str]) -> CoreNetworkConfig { + CoreNetworkConfig { + mode: CoreRelayMode::Custom, + relay_urls: relay_urls.iter().map(ToString::to_string).collect(), + } +} + +fn read_blob_ticket(ticket: &str) -> (Value, BlobTicket) { + let encoded = ticket.strip_prefix("vnd1:").unwrap(); + let payload = BASE64URL_NOPAD.decode(encoded.as_bytes()).unwrap(); + let value: Value = serde_json::from_slice(&payload).unwrap(); + let blob_ticket = BlobTicket::from_str(value["blob_ticket"].as_str().unwrap()).unwrap(); + let (mut addr, hash, format) = blob_ticket.into_parts(); + for relay_url in value["relay_urls"].as_array().unwrap() { + addr = addr.with_relay_url(relay_url.as_str().unwrap().parse().unwrap()); + } + let blob_ticket = BlobTicket::new(addr, hash, format); + (value, blob_ticket) +} + +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(); + let relay_only_addr = EndpointAddr::new(blob_ticket.addr().id).with_relay_url(relay_url); + let relay_only_ticket = + BlobTicket::new(relay_only_addr, blob_ticket.hash(), blob_ticket.format()); + value["blob_ticket"] = Value::String(relay_only_ticket.to_string()); + let payload = serde_json::to_vec(&value).unwrap(); + format!("vnd1:{}", BASE64URL_NOPAD.encode(&payload)) +} + +#[test] +fn strict_custom_relay_is_advertised_and_transfers_without_direct_ticket_addresses() { + let relay = TestRelay::start(); + let backup_relay = "http://127.0.0.1:9"; + let relay_urls = [relay.url.as_str(), backup_relay]; + let sender = TestNode::with_network_config(custom_config(&relay_urls)); + let receiver = TestNode::with_network_config(custom_config(&[relay.url.as_str()])); + let source_dir = tempfile::tempdir().unwrap(); + let output_dir = tempfile::tempdir().unwrap(); + let source_path = source_dir.path().join("custom-relay.txt"); + std::fs::write(&source_path, b"through the custom relay").unwrap(); + + let share = share_path(&sender.core, &source_path, 401, "custom-relay.txt", false); + let (ticket_value, blob_ticket) = read_blob_ticket(&share.ticket); + let advertised_relays: Vec<_> = blob_ticket + .addr() + .relay_urls() + .map(ToString::to_string) + .collect(); + let configured_relay = RelayUrl::from_str(&relay.url).unwrap().to_string(); + let configured_backup = RelayUrl::from_str(backup_relay).unwrap().to_string(); + let envelope_relays = ticket_value["relay_urls"] + .as_array() + .unwrap() + .iter() + .map(|value| value.as_str().unwrap().to_string()) + .collect::>(); + assert_eq!( + envelope_relays, + vec![configured_relay.clone(), configured_backup.clone()] + ); + let mut configured_relays = vec![configured_relay.clone(), configured_backup]; + configured_relays.sort(); + assert_eq!(advertised_relays, configured_relays.clone()); + assert!(!advertised_relays.iter().any(|url| url.contains("n0"))); + assert!(!sender.core.status().addr.contains("iroh.link")); + + let relay_only_ticket = with_relay_only_address(&share.ticket, &relay.url); + let (_, relay_only_blob_ticket) = read_blob_ticket(&relay_only_ticket); + assert_eq!(relay_only_blob_ticket.addr().ip_addrs().count(), 0); + assert_eq!( + relay_only_blob_ticket + .addr() + .relay_urls() + .map(ToString::to_string) + .collect::>(), + configured_relays + ); + + receive_with_response( + &sender.core, + share.transfer_id, + receiver.core.arc(), + relay_only_ticket, + output_dir.path(), + true, + ) + .unwrap(); + + assert_eq!( + std::fs::read(output_dir.path().join("custom-relay.txt")).unwrap(), + b"through the custom relay" + ); +} diff --git a/crates/vnidrop/tests/lifecycle.rs b/crates/vnidrop/tests/lifecycle.rs index 4637e7d..59ada03 100644 --- a/crates/vnidrop/tests/lifecycle.rs +++ b/crates/vnidrop/tests/lifecycle.rs @@ -5,10 +5,10 @@ use std::time::Duration; use futures_lite::StreamExt as _; use iroh_blobs::store::fs::FsStore; -use support::{share_path, CoreGuard, RecordingSink, TestNode}; +use support::{share_path, CoreGuard, RecordingSink, TestNode, TestRelay}; use vnidrop::{ - CoreEvent, CoreEventSink, CoreLimits, ShareMetadataInput, ShareSource, SourceKind, - TransferAccessMode, + CoreEvent, CoreEventSink, CoreLimits, CoreNetworkConfig, CoreRelayMode, ShareMetadataInput, + ShareSource, SourceKind, TransferAccessMode, }; #[test] @@ -137,6 +137,56 @@ fn persisted_share_is_recovered_and_can_be_stopped_after_restart() { assert_eq!(restarted.status().active_shares, 0); } +#[test] +fn persisted_share_is_revoked_when_restarted_with_a_different_relay_profile() { + let relay_a = TestRelay::start(); + let relay_b = TestRelay::start(); + assert_ne!(relay_a.url, relay_b.url); + + let source_dir = tempfile::tempdir().unwrap(); + let core_dir = tempfile::tempdir().unwrap(); + let source_path = source_dir.path().join("stale-relay.txt"); + std::fs::write(&source_path, b"must not survive a relay profile change").unwrap(); + let sender = CoreGuard::start_with_network_config( + core_dir.path(), + Arc::new(RecordingSink::default()), + CoreNetworkConfig { + mode: CoreRelayMode::Custom, + relay_urls: vec![relay_a.url.clone()], + }, + ); + let share = share_path(&sender, &source_path, 120, "stale-relay.txt", false); + drop(sender); + drop(relay_a); + + let restarted = CoreGuard::start_with_network_config( + core_dir.path(), + Arc::new(RecordingSink::default()), + CoreNetworkConfig { + mode: CoreRelayMode::Custom, + relay_urls: vec![relay_b.url.clone()], + }, + ); + + assert_eq!(restarted.status().active_shares, 0); + let transfer = restarted + .list_transfers() + .unwrap() + .into_iter() + .find(|transfer| transfer.transfer_id == share.transfer_id) + .unwrap(); + assert_eq!(transfer.status, "stopped"); + assert!(restarted + .list_events(Some(share.transfer_id)) + .unwrap() + .iter() + .any(|event| { + event.phase == "recovery" && event.kind == "share-stopped-network-profile-changed" + })); + drop(restarted); + assert_eq!(share_tag_count(core_dir.path()), 0); +} + #[test] fn stopped_share_rejects_receive() { let source_dir = tempfile::tempdir().unwrap(); diff --git a/crates/vnidrop/tests/support/mod.rs b/crates/vnidrop/tests/support/mod.rs index d5cc1e2..f1acfa3 100644 --- a/crates/vnidrop/tests/support/mod.rs +++ b/crates/vnidrop/tests/support/mod.rs @@ -8,15 +8,16 @@ use std::{ path::Path, sync::{ atomic::{AtomicBool, Ordering}, - Arc, Condvar, Mutex, + mpsc, Arc, Condvar, Mutex, }, + thread::JoinHandle, time::{Duration, Instant}, }; use vnidrop::{ - CoreEvent, CoreEventSink, CoreLimits, PublishedOutput, ReceiveOutputSink, ReceiveOutputSinkV2, - ReceivedLocatorKind, ReceiverRequest, ShareMetadataInput, ShareResult, ShareSource, SourceKind, - TransferAccessMode, VnidropCore, VnidropError, + CoreEvent, CoreEventSink, CoreLimits, CoreNetworkConfig, PublishedOutput, ReceiveOutputSink, + ReceiveOutputSinkV2, ReceivedLocatorKind, ReceiverRequest, ShareMetadataInput, ShareResult, + ShareSource, SourceKind, TransferAccessMode, VnidropCore, VnidropError, }; #[derive(Default)] @@ -57,6 +58,21 @@ impl CoreGuard { ) } + pub fn start_with_network_config( + path: &Path, + sink: Arc, + network_config: CoreNetworkConfig, + ) -> Self { + Self( + VnidropCore::initialize_with_network_config( + path.to_string_lossy().to_string(), + sink, + network_config, + ) + .expect("test core should initialize with network config"), + ) + } + pub fn arc(&self) -> Arc { self.0.clone() } @@ -93,6 +109,80 @@ impl TestNode { sink, } } + + pub fn with_network_config(network_config: CoreNetworkConfig) -> Self { + let data_dir = tempfile::tempdir().unwrap(); + let sink = Arc::new(RecordingSink::default()); + let core = + CoreGuard::start_with_network_config(data_dir.path(), sink.clone(), network_config); + Self { + _data_dir: data_dir, + core, + sink, + } + } +} + +pub struct TestRelay { + pub url: String, + shutdown: Option>, + thread: Option>, +} + +impl TestRelay { + pub fn start() -> Self { + let (ready_tx, ready_rx) = mpsc::sync_channel(1); + let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel(); + let thread = std::thread::spawn(move || { + let runtime = tokio::runtime::Builder::new_multi_thread() + .enable_all() + .thread_name("vnidrop-test-relay") + .build() + .unwrap(); + runtime.block_on(async move { + let relay = + iroh_relay::server::RelayConfig::new((std::net::Ipv4Addr::LOCALHOST, 0)); + let mut config = iroh_relay::server::ServerConfig::default(); + config.relay = Some(relay); + let server = match iroh_relay::server::Server::spawn(config).await { + Ok(server) => server, + Err(error) => { + ready_tx.send(Err(error.to_string())).ok(); + return; + } + }; + let url = format!( + "http://{}", + server.http_addr().expect("HTTP relay should be bound") + ); + if ready_tx.send(Ok(url)).is_err() { + return; + } + shutdown_rx.await.ok(); + drop(server); + }); + }); + let url = ready_rx + .recv_timeout(Duration::from_secs(5)) + .expect("test relay should start") + .expect("test relay should bind"); + Self { + url, + shutdown: Some(shutdown_tx), + thread: Some(thread), + } + } +} + +impl Drop for TestRelay { + fn drop(&mut self) { + if let Some(shutdown) = self.shutdown.take() { + shutdown.send(()).ok(); + } + if let Some(thread) = self.thread.take() { + thread.join().unwrap(); + } + } } #[derive(Default)] diff --git a/localization/strings.json b/localization/strings.json index 0ad438b..acd721c 100644 --- a/localization/strings.json +++ b/localization/strings.json @@ -2483,6 +2483,338 @@ "ru": "Передача VniDrop" } }, + "relay_add_url": { + "context": "Apple Network settings button that appends another custom relay URL field.", + "translations": { + "en": "Add relay server", + "fr": "Ajouter un serveur relais", + "es": "Añadir servidor de retransmisión", + "it": "Aggiungi server relay", + "de": "Relay-Server hinzufügen", + "pt": "Adicionar servidor de retransmissão", + "pl": "Dodaj serwer przekaźnikowy", + "nl": "Relayserver toevoegen", + "ru": "Добавить сервер-ретранслятор" + } + }, + "relay_apply": { + "context": "Network settings button that activates the selected relay configuration.", + "translations": { + "en": "Apply network settings", + "fr": "Appliquer les réglages réseau", + "es": "Aplicar ajustes de red", + "it": "Applica impostazioni di rete", + "de": "Netzwerkeinstellungen anwenden", + "pt": "Aplicar definições de rede", + "pl": "Zastosuj ustawienia sieci", + "nl": "Netwerkinstellingen toepassen", + "ru": "Применить настройки сети" + } + }, + "relay_applying": { + "context": "Network settings button label while a relay configuration is being activated.", + "translations": { + "en": "Applying…", + "fr": "Application…", + "es": "Aplicando…", + "it": "Applicazione…", + "de": "Wird angewendet…", + "pt": "A aplicar…", + "pl": "Stosowanie…", + "nl": "Toepassen…", + "ru": "Применение…" + } + }, + "relay_apply_active_transfers": { + "context": "Network settings warning when relay configuration cannot change during active work.", + "translations": { + "en": "Stop all active transfers and shares before applying network settings.", + "fr": "Arrêtez tous les transferts et partages actifs avant d’appliquer les réglages réseau.", + "es": "Detenga todas las transferencias y elementos compartidos activos antes de aplicar los ajustes de red.", + "it": "Interrompa tutti i trasferimenti e le condivisioni attivi prima di applicare le impostazioni di rete.", + "de": "Beenden Sie alle aktiven Übertragungen und Freigaben, bevor Sie die Netzwerkeinstellungen anwenden.", + "pt": "Pare todas as transferências e partilhas ativas antes de aplicar as definições de rede.", + "pl": "Zatrzymaj wszystkie aktywne transfery i udostępnienia przed zastosowaniem ustawień sieci.", + "nl": "Stop alle actieve overdrachten en gedeelde items voordat u de netwerkinstellingen toepast.", + "ru": "Остановите все активные передачи и раздачи перед применением настроек сети." + } + }, + "relay_apply_failed": { + "context": "Network settings error after a relay configuration fails and the previous one is restored.", + "translations": { + "en": "Could not apply these settings. The previous network settings were restored.", + "fr": "Impossible d’appliquer ces réglages. Les réglages réseau précédents ont été restaurés.", + "es": "No se han podido aplicar estos ajustes. Se han restaurado los ajustes de red anteriores.", + "it": "Impossibile applicare queste impostazioni. Sono state ripristinate le impostazioni di rete precedenti.", + "de": "Diese Einstellungen konnten nicht angewendet werden. Die vorherigen Netzwerkeinstellungen wurden wiederhergestellt.", + "pt": "Não foi possível aplicar estas definições. As definições de rede anteriores foram restauradas.", + "pl": "Nie udało się zastosować tych ustawień. Przywrócono poprzednie ustawienia sieci.", + "nl": "Deze instellingen konden niet worden toegepast. De vorige netwerkinstellingen zijn hersteld.", + "ru": "Не удалось применить эти настройки. Предыдущие настройки сети восстановлены." + } + }, + "relay_apply_restart_description": { + "context": "Network settings explanation of restart and invitation effects when applying relay changes.", + "translations": { + "en": "Applying restarts VniDrop’s network connection. Stop active transfers and shares first. Existing invitations may need to be shared again.", + "fr": "L’application de ces réglages redémarre la connexion réseau de VniDrop. Arrêtez d’abord les transferts et partages actifs. Il peut être nécessaire de partager à nouveau les invitations existantes.", + "es": "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.", + "it": "L’applicazione riavvia la connessione di rete di VniDrop. Interrompa prima i trasferimenti e le condivisioni attivi. Potrebbe essere necessario condividere di nuovo gli inviti esistenti.", + "de": "Beim Anwenden wird die Netzwerkverbindung von VniDrop neu gestartet. Beenden Sie zuerst aktive Übertragungen und Freigaben. Vorhandene Einladungen müssen eventuell erneut geteilt werden.", + "pt": "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.", + "pl": "Zastosowanie ustawień ponownie uruchamia połączenie sieciowe VniDrop. Najpierw zatrzymaj aktywne transfery i udostępnienia. Istniejące zaproszenia mogą wymagać ponownego udostępnienia.", + "nl": "Bij het toepassen wordt de netwerkverbinding van VniDrop opnieuw gestart. Stop eerst actieve overdrachten en gedeelde items. Bestaande uitnodigingen moeten mogelijk opnieuw worden gedeeld.", + "ru": "При применении сетевое соединение VniDrop перезапускается. Сначала остановите активные передачи и раздачи. Возможно, существующие приглашения потребуется отправить повторно." + } + }, + "relay_custom_urls_help": { + "context": "Network settings help for entering custom relay server URLs.", + "translations": { + "en": "Enter one HTTPS relay URL per line. URL credentials are not supported. The TLS certificate must be issued by a publicly trusted certificate authority.", + "fr": "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.", + "es": "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.", + "it": "Inserisca un URL relay HTTPS per riga. Le credenziali negli URL non sono supportate. Il certificato TLS deve essere emesso da un’autorità di certificazione pubblicamente attendibile.", + "de": "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.", + "pt": "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.", + "pl": "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.", + "nl": "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.", + "ru": "Введите по одному HTTPS-адресу ретранслятора в строке. Учётные данные в URL-адресах не поддерживаются. Сертификат TLS должен быть выдан общедоступным доверенным центром сертификации." + } + }, + "relay_custom_urls_label": { + "context": "Network settings label for the custom relay URL input.", + "translations": { + "en": "Relay URLs", + "fr": "URL des relais", + "es": "URL de relés", + "it": "URL relay", + "de": "Relay-URLs", + "pt": "URLs dos retransmissores", + "pl": "Adresy URL przekaźników", + "nl": "Relay-URL's", + "ru": "URL-адреса ретрансляторов" + } + }, + "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": "Автоматически" + } + }, + "relay_mode_automatic_description": { + "context": "Network settings description of automatic public relay behavior.", + "translations": { + "en": "Use VniDrop’s default public relay infrastructure when a direct connection is unavailable.", + "fr": "Utiliser l’infrastructure de relais publique par défaut de VniDrop lorsqu’une connexion directe est indisponible.", + "es": "Usa la infraestructura pública de relés predeterminada de VniDrop cuando no haya una conexión directa disponible.", + "it": "Usa l’infrastruttura relay pubblica predefinita di VniDrop quando non è disponibile una connessione diretta.", + "de": "Verwendet die öffentliche Standard-Relay-Infrastruktur von VniDrop, wenn keine direkte Verbindung möglich ist.", + "pt": "Utiliza a infraestrutura pública de retransmissores predefinida do VniDrop quando não está disponível uma ligação direta.", + "pl": "Używa domyślnej publicznej infrastruktury przekaźników VniDrop, gdy połączenie bezpośrednie jest niedostępne.", + "nl": "Gebruikt de standaard openbare relay-infrastructuur van VniDrop wanneer geen directe verbinding beschikbaar is.", + "ru": "Использовать стандартную публичную инфраструктуру ретрансляторов VniDrop, если прямое соединение недоступно." + } + }, + "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": "Пользовательский" + } + }, + "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": "Использовать только указанные ниже серверы-ретрансляторы." + } + }, + "relay_privacy_description": { + "context": "Network settings privacy note about what relay operators can observe.", + "translations": { + "en": "Relays forward encrypted traffic and cannot read your files, but their operator can observe connection metadata.", + "fr": "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.", + "es": "Los relés reenvían tráfico cifrado y no pueden leer sus archivos, pero su operador puede observar los metadatos de conexión.", + "it": "I relay inoltrano traffico cifrato e non possono leggere i suoi file, ma il loro operatore può osservare i metadati di connessione.", + "de": "Relays leiten verschlüsselten Datenverkehr weiter und können Ihre Dateien nicht lesen, ihr Betreiber kann jedoch Verbindungsmetadaten sehen.", + "pt": "Os retransmissores encaminham tráfego cifrado e não conseguem ler os seus ficheiros, mas o operador pode observar metadados da ligação.", + "pl": "Przekaźniki przesyłają zaszyfrowany ruch i nie mogą odczytać plików, ale ich operator może obserwować metadane połączenia.", + "nl": "Relays sturen versleuteld verkeer door en kunnen uw bestanden niet lezen, maar de beheerder kan verbindingsmetadata bekijken.", + "ru": "Ретрансляторы передают зашифрованный трафик и не могут читать ваши файлы, но их оператор может видеть метаданные соединения." + } + }, + "relay_remove_url": { + "context": "Apple Network settings accessibility label for removing one custom relay URL field.", + "translations": { + "en": "Remove relay server", + "fr": "Supprimer le serveur relais", + "es": "Eliminar servidor de retransmisión", + "it": "Rimuovi server relay", + "de": "Relay-Server entfernen", + "pt": "Remover servidor de retransmissão", + "pl": "Usuń serwer przekaźnikowy", + "nl": "Relayserver verwijderen", + "ru": "Удалить сервер-ретранслятор" + } + }, + "relay_restore_failed": { + "context": "Network settings severe error when neither new nor previous relay settings can initialize.", + "translations": { + "en": "Could not restore the previous network settings. Restart VniDrop and review your relay configuration.", + "fr": "Impossible de restaurer les réglages réseau précédents. Redémarrez VniDrop et vérifiez votre configuration de relais.", + "es": "No se han podido restaurar los ajustes de red anteriores. Reinicie VniDrop y revise la configuración de relés.", + "it": "Impossibile ripristinare le impostazioni di rete precedenti. Riavvii VniDrop e verifichi la configurazione dei relay.", + "de": "Die vorherigen Netzwerkeinstellungen konnten nicht wiederhergestellt werden. Starten Sie VniDrop neu und prüfen Sie Ihre Relay-Konfiguration.", + "pt": "Não foi possível restaurar as definições de rede anteriores. Reinicie o VniDrop e reveja a configuração dos retransmissores.", + "pl": "Nie udało się przywrócić poprzednich ustawień sieci. Uruchom ponownie VniDrop i sprawdź konfigurację przekaźników.", + "nl": "De vorige netwerkinstellingen konden niet worden hersteld. Start VniDrop opnieuw en controleer uw relayconfiguratie.", + "ru": "Не удалось восстановить предыдущие настройки сети. Перезапустите VniDrop и проверьте конфигурацию ретрансляторов." + } + }, + "relay_settings_applied": { + "context": "Network settings confirmation after a relay configuration is activated.", + "translations": { + "en": "Network settings applied.", + "fr": "Réglages réseau appliqués.", + "es": "Ajustes de red aplicados.", + "it": "Impostazioni di rete applicate.", + "de": "Netzwerkeinstellungen angewendet.", + "pt": "Definições de rede aplicadas.", + "pl": "Zastosowano ustawienia sieci.", + "nl": "Netwerkinstellingen toegepast.", + "ru": "Настройки сети применены." + } + }, + "relay_strict_warning": { + "context": "Network settings warning that custom relay mode has no public fallback or discovery.", + "translations": { + "en": "Custom mode is strict: VniDrop will not fall back to public relays or public discovery. Other devices must be able to reach your configured relays.", + "fr": "Le mode personnalisé est strict : VniDrop n’utilisera ni les relais publics ni la découverte publique comme solution de repli. Les autres appareils doivent pouvoir accéder aux relais configurés.", + "es": "El modo personalizado es estricto: VniDrop no recurrirá a relés públicos ni al descubrimiento público. Los demás dispositivos deben poder acceder a los relés configurados.", + "it": "La modalità personalizzata è rigorosa: VniDrop non userà relay pubblici o il rilevamento pubblico come ripiego. Gli altri dispositivi devono poter raggiungere i relay configurati.", + "de": "Der benutzerdefinierte Modus ist strikt: VniDrop greift weder auf öffentliche Relays noch auf öffentliche Erkennung zurück. Andere Geräte müssen Ihre konfigurierten Relays erreichen können.", + "pt": "O modo personalizado é estrito: o VniDrop não recorrerá a retransmissores públicos nem à descoberta pública. Os outros dispositivos têm de conseguir aceder aos retransmissores configurados.", + "pl": "Tryb niestandardowy jest rygorystyczny: VniDrop nie użyje awaryjnie publicznych przekaźników ani publicznego wykrywania. Inne urządzenia muszą mieć dostęp do skonfigurowanych przekaźników.", + "nl": "De aangepaste modus is strikt: VniDrop valt niet terug op openbare relays of openbare detectie. Andere apparaten moeten uw ingestelde relays kunnen bereiken.", + "ru": "Пользовательский режим работает строго: VniDrop не будет переключаться на публичные ретрансляторы или публичное обнаружение. Другие устройства должны иметь доступ к настроенным ретрансляторам." + } + }, + "relay_validation_duplicate_url": { + "context": "Network settings validation error for a repeated custom relay URL.", + "args": [ + { + "name": "line", + "type": "int" + } + ], + "translations": { + "en": "Relay URL on line {line} duplicates an earlier entry.", + "fr": "L’URL de relais à la ligne {line} est identique à une entrée précédente.", + "es": "La URL de relé de la línea {line} duplica una entrada anterior.", + "it": "L’URL relay alla riga {line} duplica una voce precedente.", + "de": "Die Relay-URL in Zeile {line} ist bereits zuvor eingetragen.", + "pt": "O URL do retransmissor na linha {line} duplica uma entrada anterior.", + "pl": "Adres URL przekaźnika w wierszu {line} powtarza wcześniejszy wpis.", + "nl": "De relay-URL op regel {line} is gelijk aan een eerdere invoer.", + "ru": "URL-адрес ретранслятора в строке {line} повторяет предыдущую запись." + } + }, + "relay_validation_https_required": { + "context": "Network settings validation error when a custom relay URL is not HTTPS.", + "args": [ + { + "name": "line", + "type": "int" + } + ], + "translations": { + "en": "Relay URL on line {line} must start with https://.", + "fr": "L’URL de relais à la ligne {line} doit commencer par https://.", + "es": "La URL de relé de la línea {line} debe empezar por https://.", + "it": "L’URL relay alla riga {line} deve iniziare con https://.", + "de": "Die Relay-URL in Zeile {line} muss mit https:// beginnen.", + "pt": "O URL do retransmissor na linha {line} tem de começar por https://.", + "pl": "Adres URL przekaźnika w wierszu {line} musi zaczynać się od https://.", + "nl": "De relay-URL op regel {line} moet beginnen met https://.", + "ru": "URL-адрес ретранслятора в строке {line} должен начинаться с https://." + } + }, + "relay_validation_invalid_url": { + "context": "Network settings validation error for a malformed custom relay URL.", + "args": [ + { + "name": "line", + "type": "int" + } + ], + "translations": { + "en": "Relay URL on line {line} is not valid.", + "fr": "L’URL de relais à la ligne {line} n’est pas valide.", + "es": "La URL de relé de la línea {line} no es válida.", + "it": "L’URL relay alla riga {line} non è valido.", + "de": "Die Relay-URL in Zeile {line} ist ungültig.", + "pt": "O URL do retransmissor na linha {line} não é válido.", + "pl": "Adres URL przekaźnika w wierszu {line} jest nieprawidłowy.", + "nl": "De relay-URL op regel {line} is ongeldig.", + "ru": "URL-адрес ретранслятора в строке {line} недействителен." + } + }, + "relay_validation_missing_url": { + "context": "Network settings validation error when custom mode has no relay URL.", + "translations": { + "en": "Add at least one relay URL.", + "fr": "Ajoutez au moins une URL de relais.", + "es": "Añada al menos una URL de relé.", + "it": "Aggiunga almeno un URL relay.", + "de": "Fügen Sie mindestens eine Relay-URL hinzu.", + "pt": "Adicione pelo menos um URL de retransmissor.", + "pl": "Dodaj co najmniej jeden adres URL przekaźnika.", + "nl": "Voeg ten minste één relay-URL toe.", + "ru": "Добавьте хотя бы один URL-адрес ретранслятора." + } + }, + "relay_validation_too_many_urls": { + "context": "Network settings validation error when too many custom relay URLs are entered.", + "args": [ + { + "name": "maximum", + "type": "int" + } + ], + "translations": { + "en": "You can configure up to {maximum} relay servers.", + "fr": "Vous pouvez configurer jusqu’à {maximum} serveurs relais.", + "es": "Puede configurar hasta {maximum} servidores de retransmisión.", + "it": "Può configurare fino a {maximum} server relay.", + "de": "Sie können bis zu {maximum} Relay-Server konfigurieren.", + "pt": "Pode configurar até {maximum} servidores de retransmissão.", + "pl": "Możesz skonfigurować maksymalnie {maximum} serwerów przekaźnikowych.", + "nl": "U kunt maximaal {maximum} relayservers instellen.", + "ru": "Можно настроить до {maximum} серверов-ретрансляторов." + } + }, "send_access_anyone": { "context": "Send access option: anyone with the invitation can receive.", "translations": { @@ -2811,6 +3143,34 @@ "ru": "Ваши передачи" } }, + "settings_advanced_title": { + "context": "Settings overview section header for expert configuration.", + "translations": { + "en": "Advanced", + "fr": "Avancé", + "es": "Avanzado", + "it": "Avanzate", + "de": "Erweitert", + "pt": "Avançado", + "pl": "Zaawansowane", + "nl": "Geavanceerd", + "ru": "Дополнительно" + } + }, + "settings_network_title": { + "context": "Settings overview row and Network settings screen title.", + "translations": { + "en": "Network", + "fr": "Réseau", + "es": "Red", + "it": "Rete", + "de": "Netzwerk", + "pt": "Rede", + "pl": "Sieć", + "nl": "Netwerk", + "ru": "Сеть" + } + }, "settings_subtitle": { "context": "Settings screen: subtitle summarizing what's configurable.", "translations": { @@ -3692,6 +4052,20 @@ "ru": "Получатели" } }, + "transfer_qr_unavailable": { + "context": "Transfer share: shown when an invitation is too large to encode as a QR code.", + "translations": { + "en": "QR unavailable for this invitation. Use Share or Download instead.", + "fr": "Le code QR n’est pas disponible pour cette invitation. Utilisez plutôt Partager ou Télécharger.", + "es": "El QR no está disponible para esta invitación. Use Compartir o Descargar.", + "it": "Il codice QR non è disponibile per questo invito. Utilizzi invece Condividi o Scarica.", + "de": "Für diese Einladung ist kein QR-Code verfügbar. Verwenden Sie stattdessen Teilen oder Herunterladen.", + "pt": "O código QR não está disponível para este convite. Utilize Partilhar ou Transferir.", + "pl": "Kod QR jest niedostępny dla tego zaproszenia. Zamiast tego użyj opcji Udostępnij lub Pobierz.", + "nl": "QR is niet beschikbaar voor deze uitnodiging. Gebruik in plaats daarvan Delen of Downloaden.", + "ru": "QR-код недоступен для этого приглашения. Используйте «Поделиться» или «Скачать»." + } + }, "transfer_scan_qr": { "context": "Transfer share: caption under the QR code.", "translations": { diff --git a/shared/src/commonMain/composeResources/values-de/strings.xml b/shared/src/commonMain/composeResources/values-de/strings.xml index f497235..d3c1aa2 100644 --- a/shared/src/commonMain/composeResources/values-de/strings.xml +++ b/shared/src/commonMain/composeResources/values-de/strings.xml @@ -169,6 +169,28 @@ Übertragung prüfen Empfangen VniDrop-Übertragung + Relay-Server hinzufügen + Netzwerkeinstellungen anwenden + Wird angewendet… + Beenden Sie alle aktiven Übertragungen und Freigaben, bevor Sie die Netzwerkeinstellungen anwenden. + Diese Einstellungen konnten nicht angewendet werden. Die vorherigen Netzwerkeinstellungen wurden wiederhergestellt. + Beim Anwenden wird die Netzwerkverbindung von VniDrop neu gestartet. Beenden Sie zuerst aktive Übertragungen und Freigaben. Vorhandene Einladungen müssen eventuell erneut geteilt werden. + Geben Sie pro Zeile eine HTTPS-Relay-URL ein. Anmeldedaten in URLs werden nicht unterstützt. Das TLS-Zertifikat muss von einer öffentlich vertrauenswürdigen Zertifizierungsstelle ausgestellt sein. + Relay-URLs + Automatisch + Verwendet die öffentliche Standard-Relay-Infrastruktur von VniDrop, wenn keine direkte Verbindung möglich ist. + Benutzerdefiniert + Verwendet ausschließlich die unten aufgeführten Relay-Server. + Relays leiten verschlüsselten Datenverkehr weiter und können Ihre Dateien nicht lesen, ihr Betreiber kann jedoch Verbindungsmetadaten sehen. + Relay-Server entfernen + Die vorherigen Netzwerkeinstellungen konnten nicht wiederhergestellt werden. Starten Sie VniDrop neu und prüfen Sie Ihre Relay-Konfiguration. + Netzwerkeinstellungen angewendet. + Der benutzerdefinierte Modus ist strikt: VniDrop greift weder auf öffentliche Relays noch auf öffentliche Erkennung zurück. Andere Geräte müssen Ihre konfigurierten Relays erreichen können. + Die Relay-URL in Zeile %1$d ist bereits zuvor eingetragen. + Die Relay-URL in Zeile %1$d muss mit https:// beginnen. + Die Relay-URL in Zeile %1$d ist ungültig. + Fügen Sie mindestens eine Relay-URL hinzu. + Sie können bis zu %1$d Relay-Server konfigurieren. Jeder mit dieser Übertragung Keine Genehmigung erforderlich. Verwenden Sie dies nur für Objekte, die Sie unbedenklich teilen können. Jeder mit der Einladung kann herunterladen, bis Sie die Freigabe beenden. Verwenden Sie dies nicht für private oder sensible Objekte. @@ -192,6 +214,8 @@ Übertragung erstellt. Übertragungsdetails Ihre Übertragungen + Erweitert + Netzwerk Ihr Name, wo Übertragungen gesichert werden, Darstellung und Mitteilungen. Einstellungen Ausblenden @@ -250,6 +274,7 @@ Anfragen, Genehmigungen und abgeschlossene Zustellungen %1$d warten Empfänger + Für diese Einladung ist kein QR-Code verfügbar. Verwenden Sie stattdessen Teilen oder Herunterladen. Mit VniDrop scannen, um diese Übertragung zu empfangen QR-Code, Einladungsdatei und Optionen in der Nähe Teilen diff --git a/shared/src/commonMain/composeResources/values-es/strings.xml b/shared/src/commonMain/composeResources/values-es/strings.xml index b106a93..ff99db7 100644 --- a/shared/src/commonMain/composeResources/values-es/strings.xml +++ b/shared/src/commonMain/composeResources/values-es/strings.xml @@ -169,6 +169,28 @@ Revisar transferencia Recibir Transferencia de VniDrop + Añadir servidor de retransmisión + Aplicar ajustes de red + Aplicando… + Detenga todas las transferencias y elementos compartidos activos antes de aplicar los ajustes de red. + No se han podido aplicar estos ajustes. Se han restaurado los ajustes de red anteriores. + Al aplicar los ajustes, se reinicia la conexión de red de VniDrop. Detenga primero las transferencias y los elementos compartidos activos. Es posible que tenga que volver a compartir las invitaciones existentes. + Introduzca una URL HTTPS de relé por línea. No se admiten credenciales en las URL. El certificado TLS debe ser emitido por una autoridad de certificación de confianza pública. + URL de relés + Automático + Usa la infraestructura pública de relés predeterminada de VniDrop cuando no haya una conexión directa disponible. + Personalizado + Usa únicamente los servidores de retransmisión indicados a continuación. + Los relés reenvían tráfico cifrado y no pueden leer sus archivos, pero su operador puede observar los metadatos de conexión. + Eliminar servidor de retransmisión + No se han podido restaurar los ajustes de red anteriores. Reinicie VniDrop y revise la configuración de relés. + Ajustes de red aplicados. + El modo personalizado es estricto: VniDrop no recurrirá a relés públicos ni al descubrimiento público. Los demás dispositivos deben poder acceder a los relés configurados. + La URL de relé de la línea %1$d duplica una entrada anterior. + La URL de relé de la línea %1$d debe empezar por https://. + La URL de relé de la línea %1$d no es válida. + Añada al menos una URL de relé. + Puede configurar hasta %1$d servidores de retransmisión. Cualquiera que tenga esta transferencia No se requiere aprobación. Úselo solo para elementos que no le importe compartir. Cualquiera que tenga la invitación puede descargar hasta que deje de compartir. No lo use para elementos privados o sensibles. @@ -192,6 +214,8 @@ Transferencia creada. Detalles de la transferencia Sus transferencias + Avanzado + Red Su nombre, dónde se guardan las transferencias, la apariencia y las notificaciones. Ajustes Descartar @@ -250,6 +274,7 @@ Solicitudes, aprobaciones y entregas completadas %1$d en espera Destinatarios + El QR no está disponible para esta invitación. Use Compartir o Descargar. Escanee con VniDrop para recibir esta transferencia Código QR, archivo de invitación y opciones cercanas Compartir diff --git a/shared/src/commonMain/composeResources/values-fr/strings.xml b/shared/src/commonMain/composeResources/values-fr/strings.xml index d708fda..42b92bf 100644 --- a/shared/src/commonMain/composeResources/values-fr/strings.xml +++ b/shared/src/commonMain/composeResources/values-fr/strings.xml @@ -169,6 +169,28 @@ Vérifier le transfert Recevoir Transfert VniDrop + Ajouter un serveur relais + Appliquer les réglages réseau + Application… + Arrêtez tous les transferts et partages actifs avant d’appliquer les réglages réseau. + Impossible d’appliquer ces réglages. Les réglages réseau précédents ont été restaurés. + L’application de ces réglages redémarre la connexion réseau de VniDrop. Arrêtez d’abord les transferts et partages actifs. Il peut être nécessaire de partager à nouveau les invitations existantes. + Saisissez une URL de relais HTTPS par ligne. Les identifiants dans les URL ne sont pas pris en charge. Le certificat TLS doit être émis par une autorité de certification reconnue publiquement. + URL des relais + Automatique + Utiliser l’infrastructure de relais publique par défaut de VniDrop lorsqu’une connexion directe est indisponible. + Personnalisé + Utiliser uniquement les serveurs relais ci-dessous. + Les relais transmettent du trafic chiffré et ne peuvent pas lire vos fichiers, mais leur opérateur peut observer les métadonnées de connexion. + Supprimer le serveur relais + Impossible de restaurer les réglages réseau précédents. Redémarrez VniDrop et vérifiez votre configuration de relais. + Réglages réseau appliqués. + Le mode personnalisé est strict : VniDrop n’utilisera ni les relais publics ni la découverte publique comme solution de repli. Les autres appareils doivent pouvoir accéder aux relais configurés. + L’URL de relais à la ligne %1$d est identique à une entrée précédente. + L’URL de relais à la ligne %1$d doit commencer par https://. + L’URL de relais à la ligne %1$d n’est pas valide. + Ajoutez au moins une URL de relais. + Vous pouvez configurer jusqu’à %1$d serveurs relais. Toute personne disposant de ce transfert Aucune approbation requise. À n’utiliser que pour des éléments que vous êtes à l’aise de partager. Toute personne disposant de l’invitation peut télécharger jusqu’à ce que vous arrêtiez le partage. Ne l’utilisez pas pour des éléments privés ou sensibles. @@ -192,6 +214,8 @@ Transfert créé. Détails du transfert Vos transferts + Avancé + Réseau Votre nom, l’emplacement d’enregistrement des transferts, l’apparence et les notifications. Réglages Ignorer @@ -250,6 +274,7 @@ Demandes, approbations et livraisons terminées %1$d en attente Destinataires + Le code QR n’est pas disponible pour cette invitation. Utilisez plutôt Partager ou Télécharger. Scannez avec VniDrop pour recevoir ce transfert QR code, fichier d’invitation et options à proximité Partager diff --git a/shared/src/commonMain/composeResources/values-it/strings.xml b/shared/src/commonMain/composeResources/values-it/strings.xml index c1540af..2d64e59 100644 --- a/shared/src/commonMain/composeResources/values-it/strings.xml +++ b/shared/src/commonMain/composeResources/values-it/strings.xml @@ -169,6 +169,28 @@ Rivedi trasferimento Ricevi Trasferimento VniDrop + Aggiungi server relay + Applica impostazioni di rete + Applicazione… + Interrompa tutti i trasferimenti e le condivisioni attivi prima di applicare le impostazioni di rete. + Impossibile applicare queste impostazioni. Sono state ripristinate le impostazioni di rete precedenti. + L’applicazione riavvia la connessione di rete di VniDrop. Interrompa prima i trasferimenti e le condivisioni attivi. Potrebbe essere necessario condividere di nuovo gli inviti esistenti. + Inserisca un URL relay HTTPS per riga. Le credenziali negli URL non sono supportate. Il certificato TLS deve essere emesso da un’autorità di certificazione pubblicamente attendibile. + URL relay + Automatica + Usa l’infrastruttura relay pubblica predefinita di VniDrop quando non è disponibile una connessione diretta. + Personalizzata + Usa solo i server relay indicati di seguito. + I relay inoltrano traffico cifrato e non possono leggere i suoi file, ma il loro operatore può osservare i metadati di connessione. + Rimuovi server relay + Impossibile ripristinare le impostazioni di rete precedenti. Riavvii VniDrop e verifichi la configurazione dei relay. + Impostazioni di rete applicate. + La modalità personalizzata è rigorosa: VniDrop non userà relay pubblici o il rilevamento pubblico come ripiego. Gli altri dispositivi devono poter raggiungere i relay configurati. + L’URL relay alla riga %1$d duplica una voce precedente. + L’URL relay alla riga %1$d deve iniziare con https://. + L’URL relay alla riga %1$d non è valido. + Aggiunga almeno un URL relay. + Può configurare fino a %1$d server relay. Chiunque abbia questo trasferimento Nessuna approvazione richiesta. Da usare solo per elementi che non ha problemi a condividere. Chiunque abbia l’invito può scaricare finché non interrompe la condivisione. Non lo usi per elementi privati o sensibili. @@ -192,6 +214,8 @@ Trasferimento creato. Dettagli del trasferimento I suoi trasferimenti + Avanzate + Rete Il suo nome, dove vengono salvati i trasferimenti, l’aspetto e le notifiche. Impostazioni Ignora @@ -250,6 +274,7 @@ Richieste, approvazioni e consegne completate %1$d in attesa Destinatari + Il codice QR non è disponibile per questo invito. Utilizzi invece Condividi o Scarica. Scansioni con VniDrop per ricevere questo trasferimento Codice QR, file di invito e opzioni nelle vicinanze Condividi diff --git a/shared/src/commonMain/composeResources/values-nl/strings.xml b/shared/src/commonMain/composeResources/values-nl/strings.xml index f3f4f17..72fb6d0 100644 --- a/shared/src/commonMain/composeResources/values-nl/strings.xml +++ b/shared/src/commonMain/composeResources/values-nl/strings.xml @@ -169,6 +169,28 @@ Overdracht controleren Ontvangen VniDrop-overdracht + Relayserver toevoegen + Netwerkinstellingen toepassen + Toepassen… + Stop alle actieve overdrachten en gedeelde items voordat u de netwerkinstellingen toepast. + Deze instellingen konden niet worden toegepast. De vorige netwerkinstellingen zijn hersteld. + Bij het toepassen wordt de netwerkverbinding van VniDrop opnieuw gestart. Stop eerst actieve overdrachten en gedeelde items. Bestaande uitnodigingen moeten mogelijk opnieuw worden gedeeld. + Voer per regel één HTTPS-relay-URL in. Aanmeldgegevens in URL\'s worden niet ondersteund. Het TLS-certificaat moet zijn uitgegeven door een openbaar vertrouwde certificeringsinstantie. + Relay-URL\'s + Automatisch + Gebruikt de standaard openbare relay-infrastructuur van VniDrop wanneer geen directe verbinding beschikbaar is. + Aangepast + Gebruikt alleen de onderstaande relayservers. + Relays sturen versleuteld verkeer door en kunnen uw bestanden niet lezen, maar de beheerder kan verbindingsmetadata bekijken. + Relayserver verwijderen + De vorige netwerkinstellingen konden niet worden hersteld. Start VniDrop opnieuw en controleer uw relayconfiguratie. + Netwerkinstellingen toegepast. + De aangepaste modus is strikt: VniDrop valt niet terug op openbare relays of openbare detectie. Andere apparaten moeten uw ingestelde relays kunnen bereiken. + De relay-URL op regel %1$d is gelijk aan een eerdere invoer. + De relay-URL op regel %1$d moet beginnen met https://. + De relay-URL op regel %1$d is ongeldig. + Voeg ten minste één relay-URL toe. + U kunt maximaal %1$d relayservers instellen. Iedereen met deze overdracht Geen goedkeuring vereist. Gebruik dit alleen voor items die u gerust kunt delen. Iedereen met de uitnodiging kan downloaden totdat u stopt met delen. Gebruik dit niet voor privé- of gevoelige items. @@ -192,6 +214,8 @@ Overdracht aangemaakt. Overdrachtsdetails Uw overdrachten + Geavanceerd + Netwerk Uw naam, waar overdrachten worden bewaard, weergave en meldingen. Instellingen Sluiten @@ -250,6 +274,7 @@ Verzoeken, goedkeuringen en voltooide leveringen %1$d in behandeling Ontvangers + QR is niet beschikbaar voor deze uitnodiging. Gebruik in plaats daarvan Delen of Downloaden. Scan met VniDrop om deze overdracht te ontvangen QR-code, uitnodigingsbestand en opties in de buurt Delen diff --git a/shared/src/commonMain/composeResources/values-pl/strings.xml b/shared/src/commonMain/composeResources/values-pl/strings.xml index 6d7c74f..cb3a841 100644 --- a/shared/src/commonMain/composeResources/values-pl/strings.xml +++ b/shared/src/commonMain/composeResources/values-pl/strings.xml @@ -169,6 +169,28 @@ Przejrzyj transfer Odbierz Transfer VniDrop + Dodaj serwer przekaźnikowy + Zastosuj ustawienia sieci + Stosowanie… + Zatrzymaj wszystkie aktywne transfery i udostępnienia przed zastosowaniem ustawień sieci. + Nie udało się zastosować tych ustawień. Przywrócono poprzednie ustawienia sieci. + Zastosowanie ustawień ponownie uruchamia połączenie sieciowe VniDrop. Najpierw zatrzymaj aktywne transfery i udostępnienia. Istniejące zaproszenia mogą wymagać ponownego udostępnienia. + Wprowadź po jednym adresie URL HTTPS przekaźnika w każdym wierszu. Dane logowania w adresach URL nie są obsługiwane. Certyfikat TLS musi być wystawiony przez publicznie zaufany urząd certyfikacji. + Adresy URL przekaźników + Automatyczny + Używa domyślnej publicznej infrastruktury przekaźników VniDrop, gdy połączenie bezpośrednie jest niedostępne. + Niestandardowy + Używa wyłącznie poniższych serwerów przekaźnikowych. + Przekaźniki przesyłają zaszyfrowany ruch i nie mogą odczytać plików, ale ich operator może obserwować metadane połączenia. + Usuń serwer przekaźnikowy + Nie udało się przywrócić poprzednich ustawień sieci. Uruchom ponownie VniDrop i sprawdź konfigurację przekaźników. + Zastosowano ustawienia sieci. + Tryb niestandardowy jest rygorystyczny: VniDrop nie użyje awaryjnie publicznych przekaźników ani publicznego wykrywania. Inne urządzenia muszą mieć dostęp do skonfigurowanych przekaźników. + Adres URL przekaźnika w wierszu %1$d powtarza wcześniejszy wpis. + Adres URL przekaźnika w wierszu %1$d musi zaczynać się od https://. + Adres URL przekaźnika w wierszu %1$d jest nieprawidłowy. + Dodaj co najmniej jeden adres URL przekaźnika. + Możesz skonfigurować maksymalnie %1$d serwerów przekaźnikowych. Każdy, kto ma ten transfer Nie jest wymagane zatwierdzenie. Używaj tylko dla elementów, które możesz swobodnie udostępniać. Każdy, kto ma zaproszenie, może pobierać, dopóki nie zatrzymasz udostępniania. Nie używaj tego dla prywatnych ani wrażliwych elementów. @@ -192,6 +214,8 @@ Transfer utworzony. Szczegóły transferu Twoje transfery + Zaawansowane + Sieć Twoja nazwa, miejsce zapisu transferów, wygląd i powiadomienia. Ustawienia Zamknij @@ -250,6 +274,7 @@ Prośby, zatwierdzenia i ukończone dostawy Oczekujące: %1$d Odbiorcy + Kod QR jest niedostępny dla tego zaproszenia. Zamiast tego użyj opcji Udostępnij lub Pobierz. Zeskanuj za pomocą VniDrop, aby odebrać ten transfer Kod QR, plik zaproszenia i opcje w pobliżu Udostępnij diff --git a/shared/src/commonMain/composeResources/values-pt/strings.xml b/shared/src/commonMain/composeResources/values-pt/strings.xml index d40618d..b324393 100644 --- a/shared/src/commonMain/composeResources/values-pt/strings.xml +++ b/shared/src/commonMain/composeResources/values-pt/strings.xml @@ -169,6 +169,28 @@ Rever transferência Receber Transferência VniDrop + Adicionar servidor de retransmissão + Aplicar definições de rede + A aplicar… + Pare todas as transferências e partilhas ativas antes de aplicar as definições de rede. + Não foi possível aplicar estas definições. As definições de rede anteriores foram restauradas. + A aplicação reinicia a ligação de rede do VniDrop. Pare primeiro as transferências e partilhas ativas. Poderá ser necessário voltar a partilhar os convites existentes. + Introduza um URL HTTPS de retransmissor por linha. Não são suportadas credenciais nos URLs. O certificado TLS tem de ser emitido por uma autoridade de certificação publicamente reconhecida. + URLs dos retransmissores + Automático + Utiliza a infraestrutura pública de retransmissores predefinida do VniDrop quando não está disponível uma ligação direta. + Personalizado + Utiliza apenas os servidores de retransmissão abaixo. + Os retransmissores encaminham tráfego cifrado e não conseguem ler os seus ficheiros, mas o operador pode observar metadados da ligação. + Remover servidor de retransmissão + Não foi possível restaurar as definições de rede anteriores. Reinicie o VniDrop e reveja a configuração dos retransmissores. + Definições de rede aplicadas. + O modo personalizado é estrito: o VniDrop não recorrerá a retransmissores públicos nem à descoberta pública. Os outros dispositivos têm de conseguir aceder aos retransmissores configurados. + O URL do retransmissor na linha %1$d duplica uma entrada anterior. + O URL do retransmissor na linha %1$d tem de começar por https://. + O URL do retransmissor na linha %1$d não é válido. + Adicione pelo menos um URL de retransmissor. + Pode configurar até %1$d servidores de retransmissão. Qualquer pessoa com esta transferência Não é necessária aprovação. Utilize apenas para itens que não se importe de partilhar. Qualquer pessoa com o convite pode descarregar até parar de partilhar. Não utilize para itens privados ou sensíveis. @@ -192,6 +214,8 @@ Transferência criada. Detalhes da transferência As suas transferências + Avançado + Rede O seu nome, onde as transferências são guardadas, o aspeto e as notificações. Definições Ignorar @@ -250,6 +274,7 @@ Pedidos, aprovações e entregas concluídas %1$d em espera Destinatários + O código QR não está disponível para este convite. Utilize Partilhar ou Transferir. Leia com o VniDrop para receber esta transferência Código QR, ficheiro de convite e opções por perto Partilhar diff --git a/shared/src/commonMain/composeResources/values-ru/strings.xml b/shared/src/commonMain/composeResources/values-ru/strings.xml index 5e271cb..066de52 100644 --- a/shared/src/commonMain/composeResources/values-ru/strings.xml +++ b/shared/src/commonMain/composeResources/values-ru/strings.xml @@ -169,6 +169,28 @@ Проверить передачу Получить Передача VniDrop + Добавить сервер-ретранслятор + Применить настройки сети + Применение… + Остановите все активные передачи и раздачи перед применением настроек сети. + Не удалось применить эти настройки. Предыдущие настройки сети восстановлены. + При применении сетевое соединение VniDrop перезапускается. Сначала остановите активные передачи и раздачи. Возможно, существующие приглашения потребуется отправить повторно. + Введите по одному HTTPS-адресу ретранслятора в строке. Учётные данные в URL-адресах не поддерживаются. Сертификат TLS должен быть выдан общедоступным доверенным центром сертификации. + URL-адреса ретрансляторов + Автоматически + Использовать стандартную публичную инфраструктуру ретрансляторов VniDrop, если прямое соединение недоступно. + Пользовательский + Использовать только указанные ниже серверы-ретрансляторы. + Ретрансляторы передают зашифрованный трафик и не могут читать ваши файлы, но их оператор может видеть метаданные соединения. + Удалить сервер-ретранслятор + Не удалось восстановить предыдущие настройки сети. Перезапустите VniDrop и проверьте конфигурацию ретрансляторов. + Настройки сети применены. + Пользовательский режим работает строго: VniDrop не будет переключаться на публичные ретрансляторы или публичное обнаружение. Другие устройства должны иметь доступ к настроенным ретрансляторам. + URL-адрес ретранслятора в строке %1$d повторяет предыдущую запись. + URL-адрес ретранслятора в строке %1$d должен начинаться с https://. + URL-адрес ретранслятора в строке %1$d недействителен. + Добавьте хотя бы один URL-адрес ретранслятора. + Можно настроить до %1$d серверов-ретрансляторов. Любой, у кого есть эта передача Одобрение не требуется. Используйте только для объектов, которыми вы готовы поделиться. Любой, у кого есть приглашение, может загружать, пока вы не остановите общий доступ. Не используйте это для личных или конфиденциальных объектов. @@ -192,6 +214,8 @@ Передача создана. Сведения о передаче Ваши передачи + Дополнительно + Сеть Ваше имя, место сохранения передач, оформление и уведомления. Настройки Закрыть @@ -250,6 +274,7 @@ Запросы, одобрения и завершённые доставки Ожидают: %1$d Получатели + QR-код недоступен для этого приглашения. Используйте «Поделиться» или «Скачать». Отсканируйте с помощью VniDrop, чтобы получить эту передачу QR-код, файл приглашения и варианты поблизости Поделиться diff --git a/shared/src/commonMain/composeResources/values/strings.xml b/shared/src/commonMain/composeResources/values/strings.xml index 7767bf6..c593cf1 100644 --- a/shared/src/commonMain/composeResources/values/strings.xml +++ b/shared/src/commonMain/composeResources/values/strings.xml @@ -169,6 +169,28 @@ Review transfer Receive VniDrop transfer + Add relay server + Apply network settings + Applying… + Stop all active transfers and shares before applying network settings. + Could not apply these settings. The previous network settings were restored. + Applying restarts VniDrop’s network connection. Stop active transfers and shares first. Existing invitations may need to be shared again. + Enter one HTTPS relay URL per line. URL credentials are not supported. The TLS certificate must be issued by a publicly trusted certificate authority. + Relay URLs + Automatic + Use VniDrop’s default public relay infrastructure when a direct connection is unavailable. + Custom + Use only the relay servers below. + Relays forward encrypted traffic and cannot read your files, but their operator can observe connection metadata. + Remove relay server + Could not restore the previous network settings. Restart VniDrop and review your relay configuration. + Network settings applied. + Custom mode is strict: VniDrop will not fall back to public relays or public discovery. Other devices must be able to reach your configured relays. + Relay URL on line %1$d duplicates an earlier entry. + Relay URL on line %1$d must start with https://. + Relay URL on line %1$d is not valid. + Add at least one relay URL. + You can configure up to %1$d relay servers. Anyone with this transfer No approval is required. Only use this for items you are comfortable sharing. Anyone with the invitation can download until you stop sharing. Do not use this for private or sensitive items. @@ -192,6 +214,8 @@ Transfer created. Transfer details Your transfers + Advanced + Network Your name, where transfers are saved, appearance, and notifications. Settings Dismiss @@ -250,6 +274,7 @@ Requests, approvals, and completed deliveries %1$d waiting Receivers + QR unavailable for this invitation. Use Share or Download instead. Scan with VniDrop to receive this transfer QR code, invitation file, and nearby options Share diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/core/CoreLifecycleGate.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/core/CoreLifecycleGate.kt new file mode 100644 index 0000000..416c441 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/core/CoreLifecycleGate.kt @@ -0,0 +1,51 @@ +package com.vnidrop.app.core + +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext + +internal class CoreLifecycleBusyException(message: String) : IllegalStateException(message) + +internal class CoreLifecycleGate { + private val mutex = Mutex() + private var reconfiguring = false + private var inFlightCalls = 0 + + suspend fun withCall( + capture: () -> C, + block: suspend (C) -> T, + ): T { + val captured = mutex.withLock { + if (reconfiguring) throw CoreLifecycleBusyException("Core network configuration is changing") + val value = capture() + inFlightCalls += 1 + value + } + return try { + block(captured) + } finally { + withContext(NonCancellable) { + mutex.withLock { + check(inFlightCalls > 0) + inFlightCalls -= 1 + } + } + } + } + + suspend fun withReconfiguration(block: suspend () -> T): T { + mutex.withLock { + if (reconfiguring) throw CoreLifecycleBusyException("Core network configuration is already changing") + if (inFlightCalls > 0) throw CoreLifecycleBusyException("Core calls are still active") + reconfiguring = true + } + return try { + block() + } finally { + withContext(NonCancellable) { + mutex.withLock { reconfiguring = false } + } + } + } +} diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/core/CoreModels.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/core/CoreModels.kt index 72d0876..ca2168f 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/core/CoreModels.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/core/CoreModels.kt @@ -27,6 +27,16 @@ enum class ShareAccessPolicy { AnyoneWithTransfer, } +enum class RelayMode { + Automatic, + Custom, +} + +data class RelaySettings( + val mode: RelayMode = RelayMode.Automatic, + val relayUrls: List = emptyList(), +) + enum class TransferDirection { Send, Receive, @@ -142,7 +152,10 @@ interface CoreGateway { val state: StateFlow val signals: SharedFlow - suspend fun initialize(appDataDir: String): Result + suspend fun initialize( + appDataDir: String, + relaySettings: RelaySettings = RelaySettings(), + ): Result fun shutdown() suspend fun sharePath(path: String, transferName: String, senderName: String, accessPolicy: ShareAccessPolicy): Result suspend fun shareFileDescriptor( diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/core/CoreRepository.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/core/CoreRepository.kt index e1cc450..8095b47 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/core/CoreRepository.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/core/CoreRepository.kt @@ -16,6 +16,8 @@ import kotlinx.coroutines.withContext import kotlin.random.Random import uniffi.vnidrop.CoreEvent import uniffi.vnidrop.CoreEventSink +import uniffi.vnidrop.CoreNetworkConfig +import uniffi.vnidrop.CoreRelayMode import uniffi.vnidrop.ReceiveOutputSink import uniffi.vnidrop.ReceiveOutputSinkV2 import uniffi.vnidrop.ReceiverRequest @@ -28,6 +30,7 @@ import uniffi.vnidrop.TicketInspection import uniffi.vnidrop.TransferMetadata import uniffi.vnidrop.TransferAccessMode import uniffi.vnidrop.VnidropCore +import uniffi.vnidrop.defaultCoreNetworkConfig class CoreRepository( private val dispatcher: CoroutineDispatcher = Dispatchers.IO, @@ -42,6 +45,7 @@ class CoreRepository( override val signals: SharedFlow = _signals.asSharedFlow() private var core: VnidropCore? = null + private val lifecycleGate = CoreLifecycleGate() private val sink = object : CoreEventSink { override fun onEvent(event: CoreEvent) { @@ -60,12 +64,22 @@ class CoreRepository( } } - override suspend fun initialize(appDataDir: String): Result = runCore { - core?.shutdown() - core = VnidropCore.initialize(appDataDir, sink) - refreshSnapshot() - _state.update { it.copy(isInitialized = true) } - } + override suspend fun initialize(appDataDir: String, relaySettings: RelaySettings): Result = + runReconfiguration { + val previousCore = core + if (previousCore != null) { + val status = previousCore.status() + require(status.activeTransfers == 0UL && status.activeShares == 0UL) { + "Stop active transfers and shares before changing relay settings" + } + } + _state.update { it.copy(isInitialized = false, status = null) } + previousCore?.shutdown() + core = null + core = VnidropCore.initializeWithNetworkConfig(appDataDir, sink, relaySettings.toNative()) + refreshSnapshot(requireCore()) + _state.update { it.copy(isInitialized = true) } + } override fun shutdown() { core?.shutdown() @@ -114,37 +128,37 @@ class CoreRepository( accessPolicy = accessPolicy, ) - override suspend fun inspectTicket(ticket: String): Result = runCore { - requireCore().inspectTicket(ticket).toModel().also { inspection -> + override suspend fun inspectTicket(ticket: String): Result = runCore { activeCore -> + activeCore.inspectTicket(ticket).toModel().also { inspection -> _state.update { it.copy(lastInspection = inspection) } } } - override suspend fun receive(ticket: String, outputDir: String, receiverName: String): Result = runCore { - requireCore().receive(ticket, outputDir, receiverName.ifBlank { null }) - refreshSnapshot() + override suspend fun receive(ticket: String, outputDir: String, receiverName: String): Result = runCore { activeCore -> + activeCore.receive(ticket, outputDir, receiverName.ifBlank { null }) + refreshSnapshot(activeCore) } override suspend fun receiveWithOutputSink( ticket: String, outputSink: ReceiveOutputSink, receiverName: String, - ): Result = runCore { - requireCore().receiveWithOutputSink(ticket, outputSink, receiverName.ifBlank { null }) - refreshSnapshot() + ): Result = runCore { activeCore -> + activeCore.receiveWithOutputSink(ticket, outputSink, receiverName.ifBlank { null }) + refreshSnapshot(activeCore) } override suspend fun receiveWithOutputSinkV2( ticket: String, outputSink: ReceiveOutputSinkV2, receiverName: String, - ): Result = runCore { - requireCore().receiveWithOutputSinkV2(ticket, outputSink, receiverName.ifBlank { null }) - refreshSnapshot() + ): Result = runCore { activeCore -> + activeCore.receiveWithOutputSinkV2(ticket, outputSink, receiverName.ifBlank { null }) + refreshSnapshot(activeCore) } - override suspend fun storageUsage(): Result = runCore { - val usage = requireCore().storageUsage() + override suspend fun storageUsage(): Result = runCore { activeCore -> + val usage = activeCore.storageUsage() CoreStorageUsageModel( blobStoreBytes = usage.blobStoreBytes, databaseBytes = usage.databaseBytes, @@ -154,8 +168,8 @@ class CoreRepository( ) } - override suspend fun receivedArtifacts(): Result> = runCore { - requireCore().listReceivedArtifacts().map { artifact -> + override suspend fun receivedArtifacts(): Result> = runCore { activeCore -> + activeCore.listReceivedArtifacts().map { artifact -> ReceivedArtifactModel( id = artifact.id, locator = artifact.locator, @@ -165,47 +179,47 @@ class CoreRepository( } } - override suspend fun cancel(transferId: ULong): Result = runCore { - requireCore().cancelTransfer(transferId) - refreshSnapshot() + override suspend fun cancel(transferId: ULong): Result = runCore { activeCore -> + activeCore.cancelTransfer(transferId) + refreshSnapshot(activeCore) } - override suspend fun delete(transferId: ULong): Result = runCore { - requireCore().deleteTransfer(transferId) - refreshSnapshot() + override suspend fun delete(transferId: ULong): Result = runCore { activeCore -> + activeCore.deleteTransfer(transferId) + refreshSnapshot(activeCore) _signals.tryEmit(CoreSignal.ApprovalChanged(transferId)) _signals.tryEmit(CoreSignal.ReceiverHistoryChanged(transferId)) } - override suspend fun clearReceiveHistory(): Result = runCore { - val deleted = requireCore().deleteReceiveHistory() - refreshSnapshot() + override suspend fun clearReceiveHistory(): Result = runCore { activeCore -> + val deleted = activeCore.deleteReceiveHistory() + refreshSnapshot(activeCore) deleted } - override suspend fun receiverRequests(transferId: ULong): Result> = runCore { - requireCore().listReceiverRequests(transferId).map(ReceiverRequest::toModel) + override suspend fun receiverRequests(transferId: ULong): Result> = runCore { activeCore -> + activeCore.listReceiverRequests(transferId).map(ReceiverRequest::toModel) } override suspend fun respondReceiverRequest( requestId: String, accepted: Boolean, reason: String?, - ): Result = runCore { - requireCore().respondReceiverRequest(requestId, accepted, reason) + ): Result = runCore { activeCore -> + activeCore.respondReceiverRequest(requestId, accepted, reason) } - override suspend fun refresh(): Result = runCore { refreshSnapshot() } + override suspend fun refresh(): Result = runCore(::refreshSnapshot) override suspend fun shareSources( sources: List, transferName: String, senderName: String, accessPolicy: ShareAccessPolicy, - ): Result = runCore { + ): Result = runCore { activeCore -> require(sources.isNotEmpty()) { "Select at least one file to share" } withPlatformPathAccess(sources) { - requireCore().shareFiles( + activeCore.shareFiles( sources = sources, metadata = ShareMetadataInput( transferId = nextTransferId(), @@ -215,7 +229,7 @@ class CoreRepository( ), ).toModel() }.also { share -> - refreshSnapshot() + refreshSnapshot(activeCore) _state.update { it.copy(lastShare = share) } } } @@ -232,8 +246,7 @@ class CoreRepository( } } - private fun refreshSnapshot() { - val activeCore = requireCore() + private fun refreshSnapshot(activeCore: VnidropCore) { val status = activeCore.status() _state.update { it.copy( @@ -244,10 +257,25 @@ class CoreRepository( } } - private suspend fun runCore(block: suspend () -> T): Result = + private suspend fun runCore(block: suspend (VnidropCore) -> T): Result = withContext(dispatcher) { try { - Result.success(block()) + Result.success( + lifecycleGate.withCall( + capture = ::requireCore, + block = block, + ), + ) + } catch (error: Throwable) { + if (error is CancellationException) throw error + Result.failure(error) + } + } + + private suspend fun runReconfiguration(block: suspend () -> T): Result = + withContext(dispatcher) { + try { + Result.success(lifecycleGate.withReconfiguration(block)) } catch (error: Throwable) { if (error is CancellationException) throw error Result.failure(error) @@ -263,6 +291,14 @@ class CoreRepository( } } +private fun RelaySettings.toNative(): CoreNetworkConfig = when (mode) { + RelayMode.Automatic -> defaultCoreNetworkConfig() + RelayMode.Custom -> CoreNetworkConfig( + mode = CoreRelayMode.CUSTOM, + relayUrls = relayUrls, + ) +} + private fun CoreEvent.toModel(): CoreEventModel = CoreEventModel( id = id, timestamp = timestamp, diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/app/AppViewModel.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/app/AppViewModel.kt index 810a6dc..a90a163 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/app/AppViewModel.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/app/AppViewModel.kt @@ -15,6 +15,7 @@ import com.vnidrop.app.ui.theme.ThemeMode import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch @@ -46,7 +47,8 @@ class AppViewModel( AppLogger.info("lifecycle", "app started", mapOf("platform" to environment.name)) diagnostics?.record("app_open", mapOf("platform" to environment.name, "version" to environment.appVersion)) viewModelScope.launch { - repository.initialize(environment.defaultCoreDataDir).onFailure(messages::error) + val relaySettings = preferencesRepository.preferences.first().relaySettings + repository.initialize(environment.defaultCoreDataDir, relaySettings).onFailure(messages::error) } viewModelScope.launch { preferencesRepository.preferences.collect { preferences -> diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/SendScreen.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/SendScreen.kt index a49c2a7..b46a171 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/SendScreen.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/SendScreen.kt @@ -12,6 +12,7 @@ import com.vnidrop.app.core.CoreState import com.vnidrop.app.core.ReceiverDeliveryStatus import com.vnidrop.app.core.ShareAccessPolicy import com.vnidrop.app.core.TransferDirection +import com.vnidrop.app.core.TransferStatus import com.vnidrop.app.ui.components.AdaptiveDrawer import com.vnidrop.app.ui.state.WindowClass @@ -96,7 +97,12 @@ fun SendScreen( } } - if (selectedTransfer != null && state.detailPanel != null) { + val canShowDetailPanel = selectedTransfer != null && when (state.detailPanel) { + TransferDetailPanel.Share -> selectedTransfer.status in setOf(TransferStatus.Importing, TransferStatus.Sharing) + TransferDetailPanel.Activity, TransferDetailPanel.Receivers -> true + null -> false + } + if (selectedTransfer != null && state.detailPanel != null && canShowDetailPanel) { AdaptiveDrawer(windowClass = windowClass, onDismissRequest = onCloseDetailPanel) { when (state.detailPanel) { TransferDetailPanel.Activity -> TransferActivityPanel(coreState.events, selectedTransfer.transferId) diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/SendViewModel.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/SendViewModel.kt index 9a2db5f..8cfc7a1 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/SendViewModel.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/SendViewModel.kt @@ -209,7 +209,12 @@ class SendViewModel( ) } fun openActivity() = _state.update { it.copy(detailPanel = TransferDetailPanel.Activity) } - fun openShare() = _state.update { it.copy(detailPanel = TransferDetailPanel.Share) } + fun openShare() { + val selectedId = _state.value.selectedTransferId ?: return + val selected = coreState.value.transfers.firstOrNull { it.transferId == selectedId } ?: return + if (selected.status !in setOf(TransferStatus.Importing, TransferStatus.Sharing)) return + _state.update { it.copy(detailPanel = TransferDetailPanel.Share) } + } fun openReceivers() { val transferId = _state.value.selectedTransferId ?: return _state.update { it.copy(detailPanel = TransferDetailPanel.Receivers) } diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/TransferDetails.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/TransferDetails.kt index d6d3d75..51078df 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/TransferDetails.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/TransferDetails.kt @@ -46,6 +46,7 @@ import com.vnidrop.app.core.ReceiverDeliveryStatus import com.vnidrop.app.core.ReceiverRequestModel import com.vnidrop.app.core.ShareAccessPolicy import com.vnidrop.app.core.Transfer +import com.vnidrop.app.core.TransferStatus import com.vnidrop.app.ui.components.AppCard import com.vnidrop.app.ui.components.DestructiveButton import com.vnidrop.app.ui.components.PrimaryButton @@ -60,12 +61,19 @@ import com.vnidrop.app.ui.state.progressForReceiver import com.vnidrop.app.ui.theme.LocalVniDropColors import org.jetbrains.compose.resources.decodeToImageBitmap import org.jetbrains.compose.resources.stringResource +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import vnidrop.shared.generated.resources.* enum class InvitationAction { Export, Share, Nfc } +private sealed interface TransferQrRenderState { + data object Loading : TransferQrRenderState + data object Unavailable : TransferQrRenderState + data class Ready(val bitmap: androidx.compose.ui.graphics.ImageBitmap) : TransferQrRenderState +} + @Composable internal fun TransferDetails( transfer: Transfer, @@ -122,12 +130,29 @@ internal fun TransferDetails( count = pendingReceivers + completedReceivers, onClick = onReceivers, ) - HorizontalDivider(color = LocalVniDropColors.current.borderDefault) - DetailDestination( - title = stringResource(Res.string.transfer_share_title), - description = stringResource(Res.string.transfer_share_description), - onClick = onShare, - ) + when (transfer.status) { + TransferStatus.Sharing -> { + HorizontalDivider(color = LocalVniDropColors.current.borderDefault) + DetailDestination( + title = stringResource(Res.string.transfer_share_title), + description = stringResource(Res.string.transfer_share_description), + onClick = onShare, + ) + } + TransferStatus.Importing -> { + HorizontalDivider(color = LocalVniDropColors.current.borderDefault) + DetailDestination( + title = stringResource(Res.string.transfer_share_title), + description = stringResource(Res.string.transfer_event_preparing), + ) + } + TransferStatus.Receiving, + TransferStatus.Done, + TransferStatus.Failed, + TransferStatus.Cancelled, + TransferStatus.Stopped, + -> Unit + } } } } @@ -144,9 +169,12 @@ private fun receiversDescription(pending: Int, completed: Int): String = when { } @Composable -private fun DetailDestination(title: String, description: String, count: Int? = null, onClick: () -> Unit) { +private fun DetailDestination(title: String, description: String, count: Int? = null, onClick: (() -> Unit)? = null) { Row( - Modifier.fillMaxWidth().clickable(onClick = onClick).padding(16.dp), + Modifier + .fillMaxWidth() + .then(if (onClick == null) Modifier else Modifier.clickable(onClick = onClick)) + .padding(16.dp), verticalAlignment = Alignment.CenterVertically, ) { Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) { @@ -157,7 +185,9 @@ private fun DetailDestination(title: String, description: String, count: Int? = Text(count.toString(), modifier = Modifier.background(LocalVniDropColors.current.backgroundSelection, RoundedCornerShape(20.dp)).padding(horizontal = 9.dp, vertical = 3.dp)) Spacer(Modifier.width(8.dp)) } - PlatformIcon(AppIcon.ChevronRight, null, tint = LocalVniDropColors.current.foregroundLighter, modifier = Modifier.size(18.dp)) + if (onClick != null) { + PlatformIcon(AppIcon.ChevronRight, null, tint = LocalVniDropColors.current.foregroundLighter, modifier = Modifier.size(18.dp)) + } } } @@ -240,41 +270,71 @@ internal fun TransferSharePanel( DisposableEffect(actions) { onDispose(actions::cancelNfcWrite) } val ticket = transfer.ticket PanelContainer(stringResource(Res.string.transfer_share_title)) { + if (transfer.status == TransferStatus.Importing) { + Text(stringResource(Res.string.transfer_event_preparing), color = LocalVniDropColors.current.foregroundLighter) + return@PanelContainer + } + if (transfer.status != TransferStatus.Sharing) return@PanelContainer if (ticket == null) { Text(stringResource(Res.string.transfer_event_preparing), color = LocalVniDropColors.current.foregroundLighter) return@PanelContainer } - val renderedBitmap by produceState(qrBitmap, ticket, qrBitmap) { - if (value == null) { - value = withContext(Dispatchers.Default) { - runCatching { buildTransferQrCode(ticket).renderToBytes().decodeToImageBitmap() }.getOrNull() + val qrRenderState by produceState( + initialValue = qrBitmap?.let(TransferQrRenderState::Ready) ?: TransferQrRenderState.Loading, + key1 = ticket, + key2 = qrBitmap, + ) { + if (qrBitmap != null) { + value = TransferQrRenderState.Ready(qrBitmap) + return@produceState + } + value = try { + val bitmap = withContext(Dispatchers.Default) { + buildTransferQrCode(ticket).renderToBytes().decodeToImageBitmap() } - value?.let { onQrRendered(ticket, it) } + onQrRendered(ticket, bitmap) + TransferQrRenderState.Ready(bitmap) + } catch (error: CancellationException) { + throw error + } catch (_: Throwable) { + TransferQrRenderState.Unavailable } } - val renderedQr = renderedBitmap - Surface( - modifier = Modifier.align(Alignment.CenterHorizontally).size(268.dp), - shape = RoundedCornerShape(18.dp), - color = Color.White, - ) { - if (renderedQr != null) { + when (val qrState = qrRenderState) { + is TransferQrRenderState.Ready -> Surface( + modifier = Modifier.align(Alignment.CenterHorizontally).size(268.dp), + shape = RoundedCornerShape(18.dp), + color = Color.White, + ) { Image( - bitmap = renderedQr, + bitmap = qrState.bitmap, contentDescription = null, modifier = Modifier.padding(14.dp).fillMaxSize(), filterQuality = FilterQuality.None, ) - } else { + } + TransferQrRenderState.Loading -> Surface( + modifier = Modifier.align(Alignment.CenterHorizontally).size(268.dp), + shape = RoundedCornerShape(18.dp), + color = Color.White, + ) { Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { CircularProgressIndicator() } } + TransferQrRenderState.Unavailable -> Text( + stringResource(Res.string.transfer_qr_unavailable), + modifier = Modifier.align(Alignment.CenterHorizontally), + color = LocalVniDropColors.current.foregroundLighter, + style = MaterialTheme.typography.bodySmall, + ) + } + if (qrRenderState is TransferQrRenderState.Ready) { + Text( + stringResource(Res.string.transfer_scan_qr), + modifier = Modifier.align(Alignment.CenterHorizontally), + color = LocalVniDropColors.current.foregroundLighter, + style = MaterialTheme.typography.bodySmall, + ) } - Text( - stringResource(Res.string.transfer_scan_qr), - modifier = Modifier.align(Alignment.CenterHorizontally), - color = LocalVniDropColors.current.foregroundLighter, - style = MaterialTheme.typography.bodySmall, - ) if (actions.nfcAvailability != NfcShareAvailability.Hidden) { var writingNfc by remember(ticket) { mutableStateOf(false) } SecondaryButton( diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/NetworkSettings.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/NetworkSettings.kt new file mode 100644 index 0000000..3f3d43d --- /dev/null +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/NetworkSettings.kt @@ -0,0 +1,199 @@ +package com.vnidrop.app.feature.settings + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +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.ui.components.Field +import com.vnidrop.app.ui.components.PrimaryButton +import com.vnidrop.app.ui.icons.AppIcon +import com.vnidrop.app.ui.icons.PlatformIcon +import com.vnidrop.app.ui.theme.LocalVniDropColors +import org.jetbrains.compose.resources.stringResource +import vnidrop.shared.generated.resources.Res +import vnidrop.shared.generated.resources.approval_endpoint_id +import vnidrop.shared.generated.resources.relay_apply +import vnidrop.shared.generated.resources.relay_apply_active_transfers +import vnidrop.shared.generated.resources.relay_apply_failed +import vnidrop.shared.generated.resources.relay_apply_restart_description +import vnidrop.shared.generated.resources.relay_applying +import vnidrop.shared.generated.resources.relay_custom_urls_help +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_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 +import vnidrop.shared.generated.resources.relay_validation_duplicate_url +import vnidrop.shared.generated.resources.relay_validation_https_required +import vnidrop.shared.generated.resources.relay_validation_invalid_url +import vnidrop.shared.generated.resources.relay_validation_missing_url +import vnidrop.shared.generated.resources.relay_validation_too_many_urls +import vnidrop.shared.generated.resources.settings_network_title + +@Composable +internal fun NetworkSettings( + state: SettingsState, + onModeChanged: (RelayMode) -> Unit, + onUrlsChanged: (String) -> Unit, + onApply: () -> Unit, + onBack: () -> Unit, + showBack: Boolean, +) { + val colors = LocalVniDropColors.current + Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { + SettingsTopBar(stringResource(Res.string.settings_network_title), onBack, showBack) + state.endpointId?.takeIf(String::isNotBlank)?.let { endpointId -> + SelectionContainer { + Text( + stringResource(Res.string.approval_endpoint_id, endpointId), + color = colors.foregroundLighter, + style = MaterialTheme.typography.bodySmall, + ) + } + } + SettingsGroup { + RelayModeRow( + icon = AppIcon.Globe, + title = stringResource(Res.string.relay_mode_automatic), + description = stringResource(Res.string.relay_mode_automatic_description), + selected = state.relayMode == RelayMode.Automatic, + enabled = !state.isApplyingRelaySettings, + onClick = { onModeChanged(RelayMode.Automatic) }, + ) + SettingsDivider() + RelayModeRow( + icon = AppIcon.Radio, + title = stringResource(Res.string.relay_mode_custom), + description = stringResource(Res.string.relay_mode_custom_description), + selected = state.relayMode == RelayMode.Custom, + enabled = !state.isApplyingRelaySettings, + onClick = { onModeChanged(RelayMode.Custom) }, + ) + } + if (state.relayMode == RelayMode.Custom) { + Field( + value = state.relayUrlsText, + onValueChange = onUrlsChanged, + label = stringResource(Res.string.relay_custom_urls_label), + minLines = 3, + enabled = !state.isApplyingRelaySettings, + ) + Text( + stringResource(Res.string.relay_custom_urls_help), + color = colors.foregroundLighter, + style = MaterialTheme.typography.bodySmall, + ) + 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, + style = MaterialTheme.typography.bodySmall, + ) + } + relayErrorText(state)?.let { error -> + Text( + error, + color = colors.destructiveDefault, + style = MaterialTheme.typography.bodySmall, + fontWeight = FontWeight.Medium, + ) + } + Text( + stringResource(Res.string.relay_apply_restart_description), + color = colors.foregroundLighter, + style = MaterialTheme.typography.bodySmall, + ) + PrimaryButton( + text = stringResource( + if (state.isApplyingRelaySettings) Res.string.relay_applying else Res.string.relay_apply, + ), + onClick = onApply, + modifier = Modifier.fillMaxWidth(), + enabled = state.hasRelaySettingsChanges && + !state.isApplyingRelaySettings && + !state.hasActiveNetworkWork, + ) + } +} + +@Composable +private fun RelayModeRow( + icon: AppIcon, + title: String, + description: String, + selected: Boolean, + enabled: Boolean, + onClick: () -> Unit, +) { + SettingsRow( + icon = icon, + title = title, + subtitle = description, + selected = selected, + onClick = onClick.takeIf { enabled }, + showsDisclosure = false, + trailing = if (selected) { + { + PlatformIcon( + AppIcon.Check, + contentDescription = null, + tint = LocalVniDropColors.current.brandLink, + modifier = Modifier.size(20.dp), + ) + } + } else { + null + }, + ) +} + +@Composable +private fun relayErrorText(state: SettingsState): String? { + if (state.hasActiveNetworkWork || state.relayApplyError == RelaySettingsApplyError.ActiveTransfers) { + return stringResource(Res.string.relay_apply_active_transfers) + } + state.relayInputError?.let { error -> + return when (error) { + RelaySettingsInputError.MissingUrl -> stringResource(Res.string.relay_validation_missing_url) + is RelaySettingsInputError.TooManyUrls -> stringResource( + Res.string.relay_validation_too_many_urls, + error.maximum, + ) + is RelaySettingsInputError.HttpsRequired -> stringResource( + Res.string.relay_validation_https_required, + error.line, + ) + is RelaySettingsInputError.InvalidUrl -> stringResource( + Res.string.relay_validation_invalid_url, + error.line, + ) + is RelaySettingsInputError.DuplicateUrl -> stringResource( + Res.string.relay_validation_duplicate_url, + error.line, + ) + } + } + return when (state.relayApplyError) { + RelaySettingsApplyError.ApplyFailed -> stringResource(Res.string.relay_apply_failed) + RelaySettingsApplyError.RestoreFailed -> stringResource(Res.string.relay_restore_failed) + RelaySettingsApplyError.ActiveTransfers, + null, + -> null + } +} diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/RelaySettingsValidation.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/RelaySettingsValidation.kt new file mode 100644 index 0000000..1fc4099 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/RelaySettingsValidation.kt @@ -0,0 +1,147 @@ +package com.vnidrop.app.feature.settings + +import com.vnidrop.app.core.RelayMode +import com.vnidrop.app.core.RelaySettings + +sealed interface RelaySettingsInputError { + data object MissingUrl : RelaySettingsInputError + data class TooManyUrls(val maximum: Int) : RelaySettingsInputError + data class HttpsRequired(val line: Int) : RelaySettingsInputError + data class InvalidUrl(val line: Int) : RelaySettingsInputError + data class DuplicateUrl(val line: Int) : RelaySettingsInputError +} + +data class RelaySettingsValidation( + val settings: RelaySettings? = null, + val error: RelaySettingsInputError? = null, +) + +fun validateRelaySettings( + mode: RelayMode, + urlsText: String, + retainedUrls: List = emptyList(), +): RelaySettingsValidation { + if (mode == RelayMode.Automatic) { + return RelaySettingsValidation(RelaySettings(mode, retainedUrls)) + } + val lines = urlsText.lineSequence() + .mapIndexedNotNull { index, raw -> raw.trim().takeIf(String::isNotEmpty)?.let { index + 1 to it } } + .toList() + if (lines.isEmpty()) return RelaySettingsValidation(error = RelaySettingsInputError.MissingUrl) + if (lines.size > MaximumRelayUrls) { + return RelaySettingsValidation(error = RelaySettingsInputError.TooManyUrls(MaximumRelayUrls)) + } + + val normalized = mutableListOf() + for ((line, raw) in lines) { + when (val result = normalizeRelayUrl(raw)) { + RelayUrlResult.HttpsRequired -> { + return RelaySettingsValidation(error = RelaySettingsInputError.HttpsRequired(line)) + } + RelayUrlResult.Invalid -> { + return RelaySettingsValidation(error = RelaySettingsInputError.InvalidUrl(line)) + } + is RelayUrlResult.Valid -> { + if (result.url in normalized) { + return RelaySettingsValidation(error = RelaySettingsInputError.DuplicateUrl(line)) + } + normalized += result.url + } + } + } + return RelaySettingsValidation(RelaySettings(RelayMode.Custom, normalized)) +} + +private sealed interface RelayUrlResult { + data object HttpsRequired : RelayUrlResult + data object Invalid : RelayUrlResult + data class Valid(val url: String) : RelayUrlResult +} + +private fun normalizeRelayUrl(raw: String): RelayUrlResult { + if (!raw.startsWith(HttpsPrefix, ignoreCase = true)) return RelayUrlResult.HttpsRequired + if (raw.encodeToByteArray().size > MaximumRelayUrlLength || raw.any { it.isWhitespace() || it.isISOControl() }) { + return RelayUrlResult.Invalid + } + val remainder = raw.substring(HttpsPrefix.length) + if (remainder.isEmpty() || '?' in remainder || '#' in remainder) return RelayUrlResult.Invalid + val authority = remainder.substringBefore('/') + val path = remainder.removePrefix(authority) + if (!isValidAuthority(authority) || path !in setOf("", "/")) return RelayUrlResult.Invalid + + val normalizedAuthority = if (authority.startsWith('[')) { + val closingBracket = authority.indexOf(']') + authority.substring(0, closingBracket + 1).lowercase() + authority.substring(closingBracket + 1) + } else { + val portSeparator = authority.lastIndexOf(':').takeIf { authority.count { char -> char == ':' } == 1 } + if (portSeparator == null) authority.lowercase() + else authority.substring(0, portSeparator).lowercase() + authority.substring(portSeparator) + } + return RelayUrlResult.Valid("$HttpsPrefix${normalizedAuthority.removeSuffix(":443")}") +} + +private fun isValidAuthority(authority: String): Boolean { + if (authority.isBlank() || '@' in authority) return false + if (authority.startsWith('[')) { + val closingBracket = authority.indexOf(']') + if (closingBracket <= 1) return false + val address = authority.substring(1, closingBracket) + if (!isValidIpv6Address(address)) return false + return isValidPortSuffix(authority.substring(closingBracket + 1)) + } + if (authority.count { it == ':' } > 1) return false + val host = authority.substringBeforeLast(':', authority) + val portSuffix = authority.removePrefix(host) + if (host.isBlank() || host.startsWith('.') || host.endsWith('.') || host.startsWith('-') || host.endsWith('-')) return false + if (host.any { !it.isLetterOrDigit() && it != '.' && it != '-' }) return false + return isValidPortSuffix(portSuffix) +} + +private fun isValidIpv6Address(address: String): Boolean { + if (address.isEmpty() || ":::" in address) return false + val compressionIndex = address.indexOf("::") + val hasCompression = compressionIndex >= 0 + if (hasCompression && address.indexOf("::", compressionIndex + 2) >= 0) return false + if (!hasCompression && (address.startsWith(':') || address.endsWith(':'))) return false + + val left = if (hasCompression) address.substring(0, compressionIndex) else address + val right = if (hasCompression) address.substring(compressionIndex + 2) else "" + val segments = buildList { + if (left.isNotEmpty()) addAll(left.split(':')) + if (right.isNotEmpty()) addAll(right.split(':')) + } + if (segments.any(String::isEmpty)) return false + + var addressUnits = 0 + for ((index, segment) in segments.withIndex()) { + if ('.' in segment) { + if (index != segments.lastIndex || !isValidIpv4Tail(segment)) return false + addressUnits += 2 + } else { + if (segment.length !in 1..4 || segment.any { !it.isHexDigit() }) return false + addressUnits += 1 + } + } + return if (hasCompression) addressUnits < 8 else addressUnits == 8 +} + +private fun isValidIpv4Tail(address: String): Boolean { + val octets = address.split('.') + return octets.size == 4 && octets.all { octet -> + octet.isNotEmpty() && octet.all(Char::isDigit) && octet.toIntOrNull() in 0..255 + } +} + +private fun Char.isHexDigit(): Boolean = + this in '0'..'9' || lowercaseChar() in 'a'..'f' + +private fun isValidPortSuffix(suffix: String): Boolean { + if (suffix.isEmpty()) return true + if (!suffix.startsWith(':')) return false + val port = suffix.drop(1).toIntOrNull() ?: return false + return port in 1..65535 +} + +private const val HttpsPrefix = "https://" +private const val MaximumRelayUrls = 8 +private const val MaximumRelayUrlLength = 2_048 diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsOverview.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsOverview.kt index e5e262a..b0142e3 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsOverview.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsOverview.kt @@ -7,6 +7,7 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp +import com.vnidrop.app.core.RelayMode import com.vnidrop.app.ui.icons.AppIcon import com.vnidrop.app.ui.theme.ThemeMode import org.jetbrains.compose.resources.stringResource @@ -18,7 +19,10 @@ import vnidrop.shared.generated.resources.appearance_system_mode import vnidrop.shared.generated.resources.appearance_title 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.settings_title +import vnidrop.shared.generated.resources.settings_network_title import vnidrop.shared.generated.resources.storage_title @Composable @@ -34,6 +38,14 @@ internal fun SettingsOverview( fontWeight = FontWeight.Bold, ) SettingsGroup { + SettingsRow( + icon = AppIcon.Globe, + title = stringResource(Res.string.settings_network_title), + value = relayModeLabel(state.savedRelaySettings.mode), + selected = state.selectedSection == SettingsSection.Network, + onClick = { onSectionSelected(SettingsSection.Network) }, + ) + SettingsDivider() SettingsRow( icon = AppIcon.User, title = stringResource(Res.string.preferences_title), @@ -76,6 +88,12 @@ 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) +} + @Composable private fun themeModeLabel(mode: ThemeMode): String = when (mode) { ThemeMode.System -> stringResource(Res.string.appearance_system_mode) diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsRoute.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsRoute.kt index 93c3bd7..fa27f16 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsRoute.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsRoute.kt @@ -24,6 +24,9 @@ fun SettingsRoute(viewModel: SettingsViewModel, windowClass: WindowClass) { onSectionSelected = viewModel::selectSection, onUsernameChanged = viewModel::setUsername, onThemeModeChanged = viewModel::setThemeMode, + onRelayModeChanged = viewModel::setRelayMode, + onRelayUrlsChanged = viewModel::setRelayUrlsText, + onApplyRelaySettings = viewModel::applyRelaySettings, onChooseFolder = viewModel::chooseReceiveFolder, onResetFolder = viewModel::resetReceiveFolder, onNotificationsChanged = viewModel::setNotificationsEnabled, diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsScreen.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsScreen.kt index 35212a8..e779658 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsScreen.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsScreen.kt @@ -9,6 +9,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import com.vnidrop.app.ui.state.WindowClass +import com.vnidrop.app.core.RelayMode import com.vnidrop.app.ui.theme.ThemeMode @Composable @@ -30,6 +31,9 @@ fun SettingsScreen( onBugIncludeLogsChanged: (Boolean) -> Unit, onSubmitBugReport: () -> Unit, onDeleteAllTransfers: () -> Unit = {}, + onRelayModeChanged: (RelayMode) -> Unit = {}, + onRelayUrlsChanged: (String) -> Unit = {}, + onApplyRelaySettings: () -> Unit = {}, ) { if (windowClass == WindowClass.Desktop) { Row( @@ -60,6 +64,9 @@ fun SettingsScreen( onBugIncludeLogsChanged = onBugIncludeLogsChanged, onSubmitBugReport = onSubmitBugReport, onDeleteAllTransfers = onDeleteAllTransfers, + onRelayModeChanged = onRelayModeChanged, + onRelayUrlsChanged = onRelayUrlsChanged, + onApplyRelaySettings = onApplyRelaySettings, ) } } @@ -94,6 +101,9 @@ fun SettingsScreen( onBugIncludeLogsChanged = onBugIncludeLogsChanged, onSubmitBugReport = onSubmitBugReport, onDeleteAllTransfers = onDeleteAllTransfers, + onRelayModeChanged = onRelayModeChanged, + onRelayUrlsChanged = onRelayUrlsChanged, + onApplyRelaySettings = onApplyRelaySettings, ) } } @@ -120,11 +130,22 @@ private fun SettingsSectionContent( onBugIncludeLogsChanged: (Boolean) -> Unit, onSubmitBugReport: () -> Unit, onDeleteAllTransfers: () -> Unit, + onRelayModeChanged: (RelayMode) -> Unit, + onRelayUrlsChanged: (String) -> Unit, + onApplyRelaySettings: () -> Unit, ) { when (section) { SettingsSection.Overview -> Unit SettingsSection.Preferences -> PreferencesSettings(state, onUsernameChanged, onChooseFolder, onResetFolder, onBack, showBack) SettingsSection.Appearance -> AppearanceSettings(state.themeMode, onThemeModeChanged, onBack, showBack) + SettingsSection.Network -> NetworkSettings( + state = state, + onModeChanged = onRelayModeChanged, + onUrlsChanged = onRelayUrlsChanged, + onApply = onApplyRelaySettings, + onBack = onBack, + showBack = showBack, + ) SettingsSection.Notifications -> NotificationSettings(state, onNotificationsChanged, onOpenNotificationSettings, onBack, showBack) SettingsSection.Storage -> StorageSettings(state, onDeleteAllTransfers, onBack, showBack) SettingsSection.About -> AboutSettings( diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsViewModel.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsViewModel.kt index e3deea1..38ddb9d 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsViewModel.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsViewModel.kt @@ -7,8 +7,12 @@ import com.vnidrop.app.DeviceInfoProvider import com.vnidrop.app.PlatformEnvironment import com.vnidrop.app.core.FileSystemService import com.vnidrop.app.core.CoreGateway +import com.vnidrop.app.core.CoreLifecycleBusyException import com.vnidrop.app.core.FolderAccessStatus 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.diagnostics.BugReportDraft import com.vnidrop.app.diagnostics.BugReportService import com.vnidrop.app.diagnostics.DiagnosticsBuildConfig @@ -43,17 +47,25 @@ import vnidrop.shared.generated.resources.notifications_enabled_message import vnidrop.shared.generated.resources.notifications_permission_denied import vnidrop.shared.generated.resources.notifications_settings_open_failed import vnidrop.shared.generated.resources.notifications_unsupported +import vnidrop.shared.generated.resources.relay_settings_applied enum class SettingsSection { Overview, Preferences, Appearance, + Network, Notifications, Storage, About, BugReport, } +enum class RelaySettingsApplyError { + ActiveTransfers, + ApplyFailed, + RestoreFailed, +} + data class StorageBreakdown( val transferCacheBytes: ULong, val appDataBytes: ULong, @@ -74,6 +86,14 @@ data class SettingsState( val isValidatingFolder: Boolean = false, val supportsCustomReceiveFolders: Boolean = true, val themeMode: ThemeMode = ThemeMode.System, + val savedRelaySettings: RelaySettings = RelaySettings(), + val relayMode: RelayMode = RelayMode.Automatic, + val relayUrlsText: String = "", + val relayInputError: RelaySettingsInputError? = null, + val relayApplyError: RelaySettingsApplyError? = null, + val isApplyingRelaySettings: Boolean = false, + val hasActiveNetworkWork: Boolean = false, + val endpointId: String? = null, val notificationsEnabled: Boolean = false, val notificationPermission: NotificationPermission = NotificationPermission.NotDetermined, val diagnosticsEnabled: Boolean = false, @@ -90,7 +110,11 @@ data class SettingsState( val storage: StorageBreakdown? = null, val isCalculatingStorage: Boolean = false, val isDeletingTransfers: Boolean = false, -) +) { + val hasRelaySettingsChanges: Boolean + get() = relayMode != savedRelaySettings.mode || + (relayMode == RelayMode.Custom && relayUrlsText != savedRelaySettings.relayUrls.joinToString("\n")) +} sealed interface SettingsEffect { data object OpenReceiveFolderPicker : SettingsEffect @@ -121,6 +145,7 @@ class SettingsViewModel( private var enableNotificationsAfterSettings = false private var usernamePersistJob: Job? = null private var hasLocalUsernameDraft = false + private var hasLocalRelayDraft = false init { viewModelScope.launch { @@ -134,6 +159,13 @@ class SettingsViewModel( themeMode = preferences.themeMode, notificationsEnabled = preferences.notificationsEnabled, diagnosticsEnabled = preferences.diagnosticsEnabled, + savedRelaySettings = preferences.relaySettings, + relayMode = if (hasLocalRelayDraft) current.relayMode else preferences.relaySettings.mode, + relayUrlsText = if (hasLocalRelayDraft) { + current.relayUrlsText + } else { + preferences.relaySettings.relayUrls.joinToString("\n") + }, ) } if (receiveFolder != previousFolder) { @@ -141,6 +173,19 @@ class SettingsViewModel( } } } + viewModelScope.launch { + repository.state.collect { coreState -> + val status = coreState.status + val hasActiveWork = status?.let { it.activeTransfers > 0UL || it.activeShares > 0UL } == true || + coreState.transfers.any { it.status in ActiveTransferStatuses } + _state.update { + it.copy( + hasActiveNetworkWork = hasActiveWork, + endpointId = coreState.status?.endpointId, + ) + } + } + } refreshNotificationPermission() loadDeviceInfo() } @@ -218,6 +263,103 @@ class SettingsViewModel( viewModelScope.launch { preferencesRepository.setThemeMode(mode) } } + fun setRelayMode(mode: RelayMode) { + hasLocalRelayDraft = true + _state.update { + it.copy( + relayMode = mode, + relayInputError = null, + relayApplyError = null, + ) + } + hasLocalRelayDraft = _state.value.hasRelaySettingsChanges + } + + fun setRelayUrlsText(value: String) { + hasLocalRelayDraft = true + _state.update { + it.copy( + relayUrlsText = value, + relayInputError = null, + relayApplyError = null, + ) + } + hasLocalRelayDraft = _state.value.hasRelaySettingsChanges + } + + fun applyRelaySettings() { + val snapshot = _state.value + if (snapshot.isApplyingRelaySettings || !snapshot.hasRelaySettingsChanges) return + val validation = validateRelaySettings( + mode = snapshot.relayMode, + urlsText = snapshot.relayUrlsText, + retainedUrls = snapshot.savedRelaySettings.relayUrls, + ) + val desired = validation.settings + if (desired == null) { + _state.update { it.copy(relayInputError = validation.error, relayApplyError = null) } + return + } + if (snapshot.hasActiveNetworkWork) { + _state.update { + it.copy(relayInputError = null, relayApplyError = RelaySettingsApplyError.ActiveTransfers) + } + return + } + + _state.update { + it.copy( + isApplyingRelaySettings = true, + relayInputError = null, + relayApplyError = null, + ) + } + viewModelScope.launch { + val previous = snapshot.savedRelaySettings + val applied = repository.initialize(environment.defaultCoreDataDir, desired) + if (applied.isFailure) { + val busy = applied.exceptionOrNull() is CoreLifecycleBusyException + if (busy || repository.state.value.isInitialized) { + _state.update { + it.copy( + isApplyingRelaySettings = false, + relayApplyError = if (repository.state.value.isInitialized) { + RelaySettingsApplyError.ActiveTransfers + } else { + RelaySettingsApplyError.ApplyFailed + }, + ) + } + } else { + finishFailedRelayApply(previous) + } + return@launch + } + try { + preferencesRepository.setRelaySettings(desired) + } catch (error: CancellationException) { + throw error + } catch (_: Throwable) { + finishFailedRelayApply(previous) + return@launch + } + hasLocalRelayDraft = false + _state.update { + it.copy( + savedRelaySettings = desired, + relayMode = desired.mode, + relayUrlsText = desired.relayUrls.joinToString("\n"), + isApplyingRelaySettings = false, + relayInputError = null, + relayApplyError = null, + ) + } + messages.show( + UiMessage(UiText.Resource(Res.string.relay_settings_applied), UiMessageTone.Success), + ) + } + } + fun chooseReceiveFolder() { if (!fileSystemService.supportsCustomReceiveFolders) return viewModelScope.launch { effects.send(SettingsEffect.OpenReceiveFolderPicker) } @@ -417,7 +559,22 @@ class SettingsViewModel( _state.update { it.copy(folderAccessStatus = status, isValidatingFolder = false) } } + private suspend fun finishFailedRelayApply(previous: RelaySettings) { + val restored = repository.initialize(environment.defaultCoreDataDir, previous).isSuccess + _state.update { + it.copy( + isApplyingRelaySettings = false, + relayApplyError = if (restored) { + RelaySettingsApplyError.ApplyFailed + } else { + RelaySettingsApplyError.RestoreFailed + }, + ) + } + } + private companion object { const val UsernamePersistDebounceMs = 350L + val ActiveTransferStatuses = setOf(TransferStatus.Importing, TransferStatus.Sharing, TransferStatus.Receiving) } } diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/preferences/AppPreferencesRepository.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/preferences/AppPreferencesRepository.kt index d630af9..57d120a 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/preferences/AppPreferencesRepository.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/preferences/AppPreferencesRepository.kt @@ -3,12 +3,14 @@ package com.vnidrop.app.preferences import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.PreferenceDataStoreFactory -import androidx.datastore.preferences.core.edit -import androidx.datastore.preferences.core.emptyPreferences -import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.preferencesOf +import androidx.datastore.preferences.core.stringPreferencesKey import com.vnidrop.app.core.ReceiveFolder import com.vnidrop.app.core.ReceiveFolderKind +import com.vnidrop.app.core.RelayMode +import com.vnidrop.app.core.RelaySettings import com.vnidrop.app.ui.theme.ThemeMode import com.vnidrop.app.util.randomUuidString import kotlinx.coroutines.flow.Flow @@ -26,6 +28,7 @@ data class AppPreferences( val diagnosticsEnabled: Boolean = false, /** Stable anonymous install id; never an account or advertising id. */ val diagnosticsInstallId: String = "", + val relaySettings: RelaySettings = RelaySettings(), ) class AppPreferencesDefaults( @@ -44,6 +47,7 @@ interface PreferencesRepository { suspend fun setThemeMode(mode: ThemeMode) suspend fun setNotificationsEnabled(enabled: Boolean) suspend fun setDiagnosticsEnabled(enabled: Boolean) + suspend fun setRelaySettings(settings: RelaySettings) /** Ensures a durable install id exists and returns it. */ suspend fun ensureDiagnosticsInstallId(): String } @@ -53,8 +57,27 @@ class AppPreferencesRepository( private val defaults: AppPreferencesDefaults, ) : PreferencesRepository { override val preferences: Flow = dataStore.data - .catch { emit(emptyPreferences()) } + .catch { + emit(preferencesOf(PreferenceKeys.RelayMode to RelayMode.Custom.name)) + } .map { prefs -> + val storedRelayMode = prefs[PreferenceKeys.RelayMode] + val parsedRelayMode = storedRelayMode?.let(::relayModeOrNull) + val relayMode = when (storedRelayMode) { + null -> RelayMode.Automatic + else -> parsedRelayMode ?: RelayMode.Custom + } + val relayUrls = if (storedRelayMode != null && parsedRelayMode == null) { + emptyList() + } else { + prefs[PreferenceKeys.RelayUrls] + .orEmpty() + .lineSequence() + .map(String::trim) + .filter(String::isNotEmpty) + .distinct() + .toList() + } AppPreferences( username = prefs[PreferenceKeys.Username]?.takeIf { it.isNotBlank() } ?: defaults.username, receiveFolder = resolveReceiveFolder(prefs, defaults.receiveFolder), @@ -62,6 +85,10 @@ class AppPreferencesRepository( notificationsEnabled = prefs[PreferenceKeys.NotificationsEnabled] ?: defaults.notificationsEnabled, diagnosticsEnabled = prefs[PreferenceKeys.DiagnosticsEnabled] ?: defaults.diagnosticsEnabled, diagnosticsInstallId = prefs[PreferenceKeys.DiagnosticsInstallId].orEmpty(), + relaySettings = RelaySettings( + mode = relayMode, + relayUrls = relayUrls, + ), ) } @@ -101,6 +128,13 @@ class AppPreferencesRepository( } } + override suspend fun setRelaySettings(settings: RelaySettings) { + dataStore.edit { prefs -> + prefs[PreferenceKeys.RelayMode] = settings.mode.name + prefs[PreferenceKeys.RelayUrls] = settings.relayUrls.joinToString("\n") + } + } + override suspend fun ensureDiagnosticsInstallId(): String { val existing = preferences.first().diagnosticsInstallId if (existing.isNotBlank()) return existing @@ -128,6 +162,8 @@ private object PreferenceKeys { val NotificationsEnabled = booleanPreferencesKey("notifications_enabled") val DiagnosticsEnabled = booleanPreferencesKey("diagnostics_enabled") val DiagnosticsInstallId = stringPreferencesKey("diagnostics_install_id") + val RelayMode = stringPreferencesKey("relay_mode") + val RelayUrls = stringPreferencesKey("relay_urls") } private fun resolveReceiveFolder(prefs: Preferences, defaults: ReceiveFolder): ReceiveFolder { @@ -162,4 +198,7 @@ private fun receiveFolderKindOrNull(raw: String): ReceiveFolderKind? = private fun themeModeOrNull(raw: String): ThemeMode? = runCatching { ThemeMode.valueOf(raw) }.getOrNull() +private fun relayModeOrNull(raw: String): RelayMode? = + runCatching { RelayMode.valueOf(raw) }.getOrNull() + private const val AppPreferencesFileName = "app_preferences.preferences_pb" diff --git a/shared/src/commonTest/kotlin/com/vnidrop/app/core/CoreLifecycleGateTest.kt b/shared/src/commonTest/kotlin/com/vnidrop/app/core/CoreLifecycleGateTest.kt new file mode 100644 index 0000000..4982c91 --- /dev/null +++ b/shared/src/commonTest/kotlin/com/vnidrop/app/core/CoreLifecycleGateTest.kt @@ -0,0 +1,82 @@ +package com.vnidrop.app.core + +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith + +class CoreLifecycleGateTest { + @Test + fun concurrentCallsHoldIndependentLeasesAndBlockReconfiguration() = runTest { + val gate = CoreLifecycleGate() + val firstEntered = CompletableDeferred() + val secondEntered = CompletableDeferred() + val release = CompletableDeferred() + val first = launch { + gate.withCall(capture = { "core" }) { captured -> + assertEquals("core", captured) + firstEntered.complete(Unit) + release.await() + } + } + firstEntered.await() + val second = launch { + gate.withCall(capture = { "core" }) { + secondEntered.complete(Unit) + release.await() + } + } + secondEntered.await() + + assertFailsWith { + gate.withReconfiguration { error("must not run") } + } + + release.complete(Unit) + first.join() + second.join() + gate.withReconfiguration { } + } + + @Test + fun callsCannotStartDuringReconfiguration() = runTest { + val gate = CoreLifecycleGate() + val entered = CompletableDeferred() + val release = CompletableDeferred() + val reconfiguration = launch { + gate.withReconfiguration { + entered.complete(Unit) + release.await() + } + } + entered.await() + + assertFailsWith { + gate.withCall(capture = { "core" }) { error("must not run") } + } + + release.complete(Unit) + reconfiguration.join() + } + + @Test + fun cancellingCallReleasesItsLease() = runTest { + val gate = CoreLifecycleGate() + val entered = CompletableDeferred() + val call = launch { + gate.withCall(capture = { "core" }) { + entered.complete(Unit) + awaitCancellation() + } + } + entered.await() + + call.cancel() + call.join() + + gate.withReconfiguration { } + } +} diff --git a/shared/src/commonTest/kotlin/com/vnidrop/app/feature/ViewModelsTest.kt b/shared/src/commonTest/kotlin/com/vnidrop/app/feature/ViewModelsTest.kt index 28a0e81..2283cdd 100644 --- a/shared/src/commonTest/kotlin/com/vnidrop/app/feature/ViewModelsTest.kt +++ b/shared/src/commonTest/kotlin/com/vnidrop/app/feature/ViewModelsTest.kt @@ -3,12 +3,15 @@ package com.vnidrop.app.feature import com.vnidrop.app.DeviceInfo import com.vnidrop.app.PlatformEnvironment import com.vnidrop.app.core.CoreState +import com.vnidrop.app.core.CoreStatus import com.vnidrop.app.core.CoreSignal import com.vnidrop.app.core.PickedShareFile import com.vnidrop.app.core.ReceiveFolder import com.vnidrop.app.core.ReceiveFolderKind import com.vnidrop.app.core.ReceiverDeliveryStatus import com.vnidrop.app.core.ReceiverRequestModel +import com.vnidrop.app.core.RelayMode +import com.vnidrop.app.core.RelaySettings import com.vnidrop.app.core.Share import com.vnidrop.app.core.ShareAccessPolicy import com.vnidrop.app.core.Transfer @@ -24,6 +27,7 @@ import com.vnidrop.app.feature.receive.ReceiveHistoryDeleteTarget import com.vnidrop.app.feature.receive.ReceiveViewModel import com.vnidrop.app.feature.send.SendViewModel import com.vnidrop.app.feature.settings.SettingsSection +import com.vnidrop.app.feature.settings.RelaySettingsApplyError import com.vnidrop.app.feature.settings.SettingsViewModel import com.vnidrop.app.notifications.NotificationPermission import com.vnidrop.app.preferences.AppPreferences @@ -74,10 +78,26 @@ class ViewModelsTest { val viewModel = AppViewModel(environment(), core, preferences(), UiMessageController()) advanceUntilIdle() assertTrue(core.state.value.isInitialized) + assertEquals(listOf(RelaySettings()), core.initializedRelaySettings) viewModel.selectDestination(AppDestination.Settings) assertEquals(AppDestination.Settings, viewModel.state.value.destination) } + @Test + fun appViewModelInitializesCoreWithSavedRelaySettings() = runTest { + Dispatchers.setMain(StandardTestDispatcher(testScheduler)) + val custom = RelaySettings(RelayMode.Custom, listOf("https://relay.example.com")) + val preferences = preferences().apply { + mutablePreferences.value = mutablePreferences.value.copy(relaySettings = custom) + } + val core = FakeCoreGateway() + + AppViewModel(environment(), core, preferences, UiMessageController()) + advanceUntilIdle() + + assertEquals(listOf(custom), core.initializedRelaySettings) + } + @Test fun settingsEnablesNotificationsOnlyAfterPermission() = runTest { Dispatchers.setMain(StandardTestDispatcher(testScheduler)) @@ -116,6 +136,94 @@ class ViewModelsTest { assertEquals("Ada ", viewModel.state.value.username) } + @Test + fun settingsAppliesNormalizedCustomRelaysAndPersistsThem() = runTest { + Dispatchers.setMain(StandardTestDispatcher(testScheduler)) + val preferences = preferences() + val core = FakeCoreGateway() + val viewModel = settingsViewModel(preferences = preferences, repository = core) + advanceUntilIdle() + + viewModel.setRelayMode(RelayMode.Custom) + viewModel.setRelayUrlsText(" HTTPS://Relay.Example.com/ \nhttps://backup.example.com:443") + viewModel.applyRelaySettings() + advanceUntilIdle() + + val expected = RelaySettings( + RelayMode.Custom, + listOf("https://relay.example.com", "https://backup.example.com"), + ) + assertEquals(expected, preferences.mutablePreferences.value.relaySettings) + assertEquals(listOf(expected), core.initializedRelaySettings) + assertEquals(expected, viewModel.state.value.savedRelaySettings) + assertFalse(viewModel.state.value.hasRelaySettingsChanges) + } + + @Test + fun settingsIgnoresDuplicateApplyBeforeRestartBegins() = runTest { + Dispatchers.setMain(StandardTestDispatcher(testScheduler)) + val core = FakeCoreGateway() + val viewModel = settingsViewModel(repository = core) + advanceUntilIdle() + + viewModel.setRelayMode(RelayMode.Custom) + viewModel.setRelayUrlsText("https://relay.example.com") + viewModel.applyRelaySettings() + viewModel.applyRelaySettings() + assertTrue(viewModel.state.value.isApplyingRelaySettings) + advanceUntilIdle() + + assertEquals(1, core.initializedRelaySettings.size) + } + + @Test + fun settingsDoesNotRestartNetworkWhileTransfersAreActive() = runTest { + Dispatchers.setMain(StandardTestDispatcher(testScheduler)) + val core = FakeCoreGateway().apply { + mutableState.value = CoreState(status = CoreStatus("endpoint", 0UL, 1UL)) + } + val viewModel = settingsViewModel(repository = core) + advanceUntilIdle() + assertEquals("endpoint", viewModel.state.value.endpointId) + + viewModel.setRelayMode(RelayMode.Custom) + viewModel.setRelayUrlsText("https://relay.example.com") + viewModel.applyRelaySettings() + advanceUntilIdle() + + assertEquals(emptyList(), core.initializedRelaySettings) + assertEquals(RelaySettingsApplyError.ActiveTransfers, viewModel.state.value.relayApplyError) + } + + @Test + fun settingsRestoresPreviousNetworkConfigurationWhenApplyFails() = runTest { + Dispatchers.setMain(StandardTestDispatcher(testScheduler)) + val core = FakeCoreGateway().apply { + initializeHandler = { settings -> + if (settings.mode == RelayMode.Custom) Result.failure(IllegalStateException("unreachable")) + else Result.success(Unit) + } + } + val preferences = preferences() + val viewModel = settingsViewModel(preferences = preferences, repository = core) + advanceUntilIdle() + + viewModel.setRelayMode(RelayMode.Custom) + viewModel.setRelayUrlsText("https://relay.example.com") + viewModel.applyRelaySettings() + advanceUntilIdle() + + assertEquals( + listOf( + RelaySettings(RelayMode.Custom, listOf("https://relay.example.com")), + RelaySettings(), + ), + core.initializedRelaySettings, + ) + assertEquals(RelaySettings(), preferences.mutablePreferences.value.relaySettings) + assertEquals(RelaySettingsApplyError.ApplyFailed, viewModel.state.value.relayApplyError) + } + @Test fun settingsKeepsNotificationsDisabledWhenPermissionIsDenied() = runTest { Dispatchers.setMain(StandardTestDispatcher(testScheduler)) @@ -722,11 +830,12 @@ class ViewModelsTest { transport: DiagnosticsTransport = RecordingDiagnosticsTransport(), fileSystem: FakeFileSystemService = FakeFileSystemService(folder), diagnosticsIncluded: Boolean = false, + repository: FakeCoreGateway = FakeCoreGateway(), ) = SettingsViewModel( environment(), { DeviceInfo("Device", "Model", "OS", "Wi-Fi", "80%") }, fileSystem, - FakeCoreGateway(), + repository, preferences, notifications, UiMessageController(), diff --git a/shared/src/commonTest/kotlin/com/vnidrop/app/feature/send/TransferQrCodeTest.kt b/shared/src/commonTest/kotlin/com/vnidrop/app/feature/send/TransferQrCodeTest.kt index d90620a..5840be0 100644 --- a/shared/src/commonTest/kotlin/com/vnidrop/app/feature/send/TransferQrCodeTest.kt +++ b/shared/src/commonTest/kotlin/com/vnidrop/app/feature/send/TransferQrCodeTest.kt @@ -2,6 +2,7 @@ package com.vnidrop.app.feature.send import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFailsWith class TransferQrCodeTest { @Test @@ -19,4 +20,12 @@ class TransferQrCodeTest { assertEquals(101, qrCode.rawData.size) assertEquals(872, qrCode.canvasSize) } + + @Test + fun versionFortyCapacityBoundaryRejectsOversizedTicket() { + assertEquals(40, transferQrInformationDensity("a".repeat(2_953))) + assertFailsWith { + transferQrInformationDensity("a".repeat(2_954)) + } + } } diff --git a/shared/src/commonTest/kotlin/com/vnidrop/app/feature/settings/RelaySettingsValidationTest.kt b/shared/src/commonTest/kotlin/com/vnidrop/app/feature/settings/RelaySettingsValidationTest.kt new file mode 100644 index 0000000..72f9f3d --- /dev/null +++ b/shared/src/commonTest/kotlin/com/vnidrop/app/feature/settings/RelaySettingsValidationTest.kt @@ -0,0 +1,83 @@ +package com.vnidrop.app.feature.settings + +import com.vnidrop.app.core.RelayMode +import com.vnidrop.app.core.RelaySettings +import kotlin.test.Test +import kotlin.test.assertEquals + +class RelaySettingsValidationTest { + @Test + fun customRelayUrlsAreNormalized() { + val result = validateRelaySettings( + mode = RelayMode.Custom, + urlsText = " HTTPS://Relay.Example.com/ \nhttps://[2001:DB8::1]:443", + ) + + assertEquals( + RelaySettings( + RelayMode.Custom, + listOf("https://relay.example.com", "https://[2001:db8::1]"), + ), + result.settings, + ) + assertEquals(null, result.error) + } + + @Test + fun customRelayUrlsRequireHttpsAndRootPath() { + assertEquals( + RelaySettingsInputError.HttpsRequired(1), + validateRelaySettings(RelayMode.Custom, "http://relay.example.com").error, + ) + assertEquals( + RelaySettingsInputError.InvalidUrl(1), + validateRelaySettings(RelayMode.Custom, "https://relay.example.com/custom").error, + ) + } + + @Test + fun duplicateNormalizedRelayUrlsAreRejected() { + val result = validateRelaySettings( + RelayMode.Custom, + "https://relay.example.com\nHTTPS://RELAY.EXAMPLE.COM:443/", + ) + + assertEquals(RelaySettingsInputError.DuplicateUrl(2), result.error) + } + + @Test + fun structurallyValidIpv6RelayUrlsAreAccepted() { + val result = validateRelaySettings( + RelayMode.Custom, + "https://[::1]:443\nhttps://[2001:db8::1]\nhttps://[::ffff:192.0.2.1]", + ) + + assertEquals( + RelaySettings( + RelayMode.Custom, + listOf( + "https://[::1]", + "https://[2001:db8::1]", + "https://[::ffff:192.0.2.1]", + ), + ), + result.settings, + ) + } + + @Test + fun malformedIpv6RelayUrlsAreRejected() { + listOf( + "https://[::::]", + "https://[1::2::3]", + "https://[1:2:3:4:5:6:7]", + "https://[1:2:3:4:5:6:7:8::]", + ).forEach { url -> + assertEquals( + RelaySettingsInputError.InvalidUrl(1), + validateRelaySettings(RelayMode.Custom, url).error, + url, + ) + } + } +} diff --git a/shared/src/commonTest/kotlin/com/vnidrop/app/support/Fakes.kt b/shared/src/commonTest/kotlin/com/vnidrop/app/support/Fakes.kt index 8b310e8..10ad3a0 100644 --- a/shared/src/commonTest/kotlin/com/vnidrop/app/support/Fakes.kt +++ b/shared/src/commonTest/kotlin/com/vnidrop/app/support/Fakes.kt @@ -10,6 +10,7 @@ import com.vnidrop.app.core.PickedShareFile import com.vnidrop.app.core.ReceiveFolder import com.vnidrop.app.core.ReceivedArtifactModel import com.vnidrop.app.core.ReceivedStorageInspection +import com.vnidrop.app.core.RelaySettings import com.vnidrop.app.core.ReceiverRequestModel import com.vnidrop.app.core.Share import com.vnidrop.app.core.ShareAccessPolicy @@ -54,6 +55,8 @@ class FakeCoreGateway : CoreGateway { var lastReceiveTicket: String? = null var lastReceiveReceiverName: String? = null var lastShareAccessPolicy: ShareAccessPolicy? = null + val initializedRelaySettings = mutableListOf() + var initializeHandler: (RelaySettings) -> Result = { Result.success(Unit) } fun completeSuspendedReceive() { receiveGate?.complete(Unit) @@ -66,9 +69,11 @@ class FakeCoreGateway : CoreGateway { gate.await() } - override suspend fun initialize(appDataDir: String): Result { - mutableState.value = mutableState.value.copy(isInitialized = true) - return Result.success(Unit) + override suspend fun initialize(appDataDir: String, relaySettings: RelaySettings): Result { + initializedRelaySettings += relaySettings + return initializeHandler(relaySettings).onSuccess { + mutableState.value = mutableState.value.copy(isInitialized = true) + } } override fun shutdown() = Unit var lastShareSourceCount: Int = 0 @@ -201,6 +206,9 @@ class FakePreferencesRepository( override suspend fun setDiagnosticsEnabled(enabled: Boolean) { mutablePreferences.value = mutablePreferences.value.copy(diagnosticsEnabled = enabled) } + override suspend fun setRelaySettings(settings: RelaySettings) { + mutablePreferences.value = mutablePreferences.value.copy(relaySettings = settings) + } override suspend fun ensureDiagnosticsInstallId(): String { val existing = mutablePreferences.value.diagnosticsInstallId if (existing.isNotBlank()) return existing diff --git a/shared/src/jvmTest/kotlin/com/vnidrop/app/preferences/AppPreferencesRepositoryTest.kt b/shared/src/jvmTest/kotlin/com/vnidrop/app/preferences/AppPreferencesRepositoryTest.kt index b8693d0..062b12f 100644 --- a/shared/src/jvmTest/kotlin/com/vnidrop/app/preferences/AppPreferencesRepositoryTest.kt +++ b/shared/src/jvmTest/kotlin/com/vnidrop/app/preferences/AppPreferencesRepositoryTest.kt @@ -1,12 +1,19 @@ package com.vnidrop.app.preferences +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey import com.vnidrop.app.core.ReceiveFolder import com.vnidrop.app.core.ReceiveFolderKind +import com.vnidrop.app.core.RelayMode +import com.vnidrop.app.core.RelaySettings import com.vnidrop.app.ui.theme.ThemeMode import java.nio.file.Files import kotlin.test.Test import kotlin.test.assertEquals import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flow import kotlinx.coroutines.runBlocking class AppPreferencesRepositoryTest { @@ -19,6 +26,66 @@ class AppPreferencesRepositoryTest { assertEquals("Device Name", preferences.username) assertEquals(defaultFolder, preferences.receiveFolder) assertEquals(ThemeMode.System, preferences.themeMode) + assertEquals(RelaySettings(), preferences.relaySettings) + } + + @Test + fun customRelaySettingsArePersisted() = runBlocking { + val repository = repositoryForTest() + val custom = RelaySettings( + mode = RelayMode.Custom, + relayUrls = listOf("https://relay.example.com", "https://backup.example.com:443"), + ) + + repository.setRelaySettings(custom) + + assertEquals(custom, repository.preferences.first().relaySettings) + } + + @Test + fun unknownStoredRelayModeFailsClosedAndCanBeReset() = runBlocking { + val directory = Files.createTempDirectory("vnidrop-preferences-test").toString() + val dataStore = createAppPreferencesDataStore(directory) + dataStore.edit { preferences -> + preferences[stringPreferencesKey("relay_mode")] = "FUTURE_MODE" + 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.Custom), repository.preferences.first().relaySettings) + + repository.setRelaySettings(RelaySettings()) + assertEquals(RelaySettings(), repository.preferences.first().relaySettings) + } + + @Test + fun unreadablePreferencesFailClosedInsteadOfUsingPublicRelays() = runBlocking { + val dataStore = object : DataStore { + override val data = flow { + throw IllegalStateException("corrupt preferences") + } + + override suspend fun updateData( + transform: suspend (t: Preferences) -> Preferences, + ): Preferences = error("not used") + } + val repository = AppPreferencesRepository( + dataStore = dataStore, + defaults = AppPreferencesDefaults( + username = "Device Name", + receiveFolder = defaultFolder, + themeMode = ThemeMode.System, + ), + ) + + assertEquals(RelaySettings(RelayMode.Custom), repository.preferences.first().relaySettings) } @Test diff --git a/shared/src/jvmTest/kotlin/com/vnidrop/app/ui/FoundationComposeTest.kt b/shared/src/jvmTest/kotlin/com/vnidrop/app/ui/FoundationComposeTest.kt index 056d7f3..dcf63d6 100644 --- a/shared/src/jvmTest/kotlin/com/vnidrop/app/ui/FoundationComposeTest.kt +++ b/shared/src/jvmTest/kotlin/com/vnidrop/app/ui/FoundationComposeTest.kt @@ -113,6 +113,47 @@ class FoundationComposeTest { onNodeWithText("Get notified about new receive requests while VniDrop is in the background.").assertIsDisplayed() } + @Test + fun phoneSettingsOpensCustomRelayConfiguration() = runComposeUiTest { + val state = mutableStateOf(SettingsState(endpointId = "endpoint-for-allowlist")) + var applied = false + setContent { + VniDropTheme(isDarkTheme = false) { + SettingsScreen( + state = state.value, + windowClass = WindowClass.Phone, + onSectionSelected = { state.value = state.value.copy(selectedSection = it) }, + onUsernameChanged = {}, + onThemeModeChanged = {}, + onChooseFolder = {}, + onResetFolder = {}, + onNotificationsChanged = {}, + onOpenNotificationSettings = {}, + onDiagnosticsChanged = {}, + onBugWhatChanged = {}, + onBugExpectedChanged = {}, + onBugStepsChanged = {}, + onBugContactChanged = {}, + onBugIncludeLogsChanged = {}, + onSubmitBugReport = {}, + onRelayModeChanged = { state.value = state.value.copy(relayMode = it) }, + onRelayUrlsChanged = { state.value = state.value.copy(relayUrlsText = it) }, + onApplyRelaySettings = { applied = true }, + ) + } + } + + onNodeWithText("Network").performClick() + onNodeWithText("Device ID: endpoint-for-allowlist").assertIsDisplayed() + onNodeWithText("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.", + ).assertIsDisplayed() + onNodeWithText("Apply network settings").performClick() + runOnIdle { assertTrue(applied) } + } + @Test fun aboutSettingsShowsTheSharedProductAndPrivacyContent() = runComposeUiTest { setContent { @@ -536,6 +577,56 @@ class FoundationComposeTest { onNodeWithContentDescription("Close").assertIsDisplayed() } + @Test + fun stoppedAndFailedTransfersDoNotExposeStaleInvitations() = runComposeUiTest { + val transfer = mutableStateOf(outgoingTransfer().copy(status = TransferStatus.Stopped)) + setContent { + VniDropTheme(isDarkTheme = false) { + SendScreen( + coreState = CoreState(isInitialized = true, transfers = listOf(transfer.value)), + state = SendState( + selectedTransferId = transfer.value.transferId, + detailPanel = com.vnidrop.app.feature.send.TransferDetailPanel.Share, + ), + windowClass = WindowClass.Desktop, + onOpenComposer = {}, onDismissComposer = {}, onSelectFile = {}, onClearFile = {}, + onTransferNameChanged = {}, onSenderNameChanged = {}, onAccessPolicyChanged = {}, + onCreateShare = {}, onTransferSelected = {}, onCloseTransferDetails = {}, onCopyTicket = {}, + ) + } + } + + onAllNodesWithText("Share").assertCountEquals(0) + onAllNodesWithText("Save .vnd file").assertCountEquals(0) + + runOnIdle { transfer.value = transfer.value.copy(status = TransferStatus.Failed) } + onAllNodesWithText("Share").assertCountEquals(0) + onAllNodesWithText("Save .vnd file").assertCountEquals(0) + } + + @Test + fun oversizedInvitationShowsQrUnavailableInsteadOfLoadingForever() = runComposeUiTest { + val transfer = outgoingTransfer().copy(ticket = "a".repeat(2_954)) + setContent { + VniDropTheme(isDarkTheme = false) { + SendScreen( + coreState = CoreState(isInitialized = true, transfers = listOf(transfer)), + state = SendState( + selectedTransferId = transfer.transferId, + detailPanel = com.vnidrop.app.feature.send.TransferDetailPanel.Share, + ), + windowClass = WindowClass.Desktop, + onOpenComposer = {}, onDismissComposer = {}, onSelectFile = {}, onClearFile = {}, + onTransferNameChanged = {}, onSenderNameChanged = {}, onAccessPolicyChanged = {}, + onCreateShare = {}, onTransferSelected = {}, onCloseTransferDetails = {}, onCopyTicket = {}, + ) + } + } + + onNodeWithText("QR unavailable for this invitation. Use Share or Download instead.").assertIsDisplayed() + onNodeWithText("Save .vnd file").assertIsDisplayed() + } + @Test fun phoneReceiveEmptyStateOpensAcquisitionMethods() = runComposeUiTest { val state = mutableStateOf(ReceiveState()) From a0bcc5dbffbb159d8d206f4c9e4cbbd8ec923cc4 Mon Sep 17 00:00:00 2001 From: Hammed Abass Date: Thu, 23 Jul 2026 15:14:03 +0200 Subject: [PATCH 02/10] feat(network): add relay connection policies --- README.md | 19 +- apple/Tests/AppModelTests.swift | 2 +- .../Tests/AppPreferencesRepositoryTests.swift | 8 +- .../Tests/CoreRepositoryLifecycleTests.swift | 2 +- apple/Tests/RelayConfigurationTests.swift | 42 ++- apple/Tests/SettingsModelTests.swift | 20 +- apple/VniDrop/Core/AppPreferences.swift | 21 +- apple/VniDrop/Core/CoreRepository.swift | 11 +- .../Features/Settings/SettingsModel.swift | 4 +- .../Features/Settings/SettingsScreen.swift | 13 +- .../Features/Settings/SettingsSections.swift | 25 +- apple/VniDrop/Resources/Localizable.xcstrings | 312 ++++++++++++++++-- crates/vnidrop/src/api.rs | 18 +- crates/vnidrop/src/runtime/mod.rs | 86 ++++- crates/vnidrop/src/tests/network_config.rs | 86 +++-- crates/vnidrop/src/tests/ticket.rs | 24 +- crates/vnidrop/src/ticket.rs | 6 +- crates/vnidrop/tests/custom_relay.rs | 46 ++- crates/vnidrop/tests/lifecycle.rs | 4 +- localization/strings.json | 128 +++++-- .../composeResources/values-de/strings.xml | 12 +- .../composeResources/values-es/strings.xml | 12 +- .../composeResources/values-fr/strings.xml | 12 +- .../composeResources/values-it/strings.xml | 12 +- .../composeResources/values-nl/strings.xml | 12 +- .../composeResources/values-pl/strings.xml | 12 +- .../composeResources/values-pt/strings.xml | 12 +- .../composeResources/values-ru/strings.xml | 12 +- .../composeResources/values/strings.xml | 12 +- .../kotlin/com/vnidrop/app/core/CoreModels.kt | 7 +- .../com/vnidrop/app/core/CoreRepository.kt | 12 +- .../app/feature/settings/NetworkSettings.kt | 45 ++- .../settings/RelaySettingsValidation.kt | 5 +- .../app/feature/settings/SettingsOverview.kt | 6 +- .../app/feature/settings/SettingsViewModel.kt | 3 +- .../preferences/AppPreferencesRepository.kt | 7 +- .../com/vnidrop/app/feature/ViewModelsTest.kt | 45 ++- .../settings/RelaySettingsValidationTest.kt | 46 ++- .../AppPreferencesRepositoryTest.kt | 29 +- .../vnidrop/app/ui/FoundationComposeTest.kt | 6 +- 40 files changed, 942 insertions(+), 254 deletions(-) diff --git a/README.md b/README.md index fb3b623..395adf5 100644 --- a/README.md +++ b/README.md @@ -53,10 +53,21 @@ encrypted packets; it is not a VniDrop file store. ### Custom relay servers VniDrop uses Iroh's public relay and discovery infrastructure by default. In -**Settings → Network**, advanced users can instead configure up to eight custom -HTTPS servers that implement Iroh's relay protocol. Custom mode is strict: the -app uses only those relays and does not silently fall back to public relays or -public discovery. Direct peer-to-peer paths remain available. +**Settings → Network**, users can select one of four policies: + +- **Automatic (recommended):** use Iroh's public relays, with direct P2P/LAN + connections whenever possible. +- **Strict custom:** use only up to eight configured custom HTTPS relays or + direct connections. Startup reports an error if none of the custom relays can + be established. +- **Custom with direct fallback:** prefer the configured custom relays, but + continue with direct connections if they are unavailable. +- **Local only:** disable every relay and allow direct connections only, + primarily for devices on the same network. + +Strict custom, custom with direct fallback, and local only never use public +relays or public discovery, including relay addresses advertised by incoming +invitations. Applying a relay change restarts VniDrop's network engine, so active transfers and shares must be stopped first. The app tests the new configuration and diff --git a/apple/Tests/AppModelTests.swift b/apple/Tests/AppModelTests.swift index 1b9beb6..5f38949 100644 --- a/apple/Tests/AppModelTests.swift +++ b/apple/Tests/AppModelTests.swift @@ -26,7 +26,7 @@ final class AppModelTests: XCTestCase { func testInitializesCoreWithSavedCustomRelayConfiguration() async { let core = FakeCoreGateway() let preferences = Fixtures.preferences() - let configuration = RelayConfiguration(mode: .custom, relayURLs: ["https://relay.example"]) + let configuration = RelayConfiguration(mode: .strictCustom, relayURLs: ["https://relay.example"]) preferences.setRelayConfiguration(configuration) _ = makeModel(core, preferences: preferences) diff --git a/apple/Tests/AppPreferencesRepositoryTests.swift b/apple/Tests/AppPreferencesRepositoryTests.swift index 8983566..60acd67 100644 --- a/apple/Tests/AppPreferencesRepositoryTests.swift +++ b/apple/Tests/AppPreferencesRepositoryTests.swift @@ -32,7 +32,7 @@ final class AppPreferencesRepositoryTests: XCTestCase { repo.setNotificationsEnabled(true) repo.setReceiveFolder(ReceiveFolder(kind: .iosSecurityScopedUrl, value: "file:///x", displayName: "Custom")) repo.setRelayConfiguration(RelayConfiguration( - mode: .custom, + mode: .strictCustom, relayURLs: ["https://relay-one.example", "https://relay-two.example:443"] )) @@ -44,7 +44,7 @@ final class AppPreferencesRepositoryTests: XCTestCase { XCTAssertEqual(reloaded.preferences.receiveFolder.displayName, "Custom") XCTAssertEqual(reloaded.preferences.receiveFolder.kind, .iosSecurityScopedUrl) XCTAssertEqual(reloaded.preferences.relayConfiguration, RelayConfiguration( - mode: .custom, + mode: .strictCustom, relayURLs: ["https://relay-one.example", "https://relay-two.example:443"] )) XCTAssertNotNil(store.data(forKey: "relay_configuration")) @@ -60,7 +60,7 @@ final class AppPreferencesRepositoryTests: XCTestCase { XCTAssertEqual( repo.preferences.relayConfiguration, - RelayConfiguration(mode: .custom, relayURLs: []) + RelayConfiguration(mode: .strictCustom, relayURLs: []) ) } @@ -75,7 +75,7 @@ final class AppPreferencesRepositoryTests: XCTestCase { XCTAssertEqual( repo.preferences.relayConfiguration, - RelayConfiguration(mode: .custom, relayURLs: []) + RelayConfiguration(mode: .strictCustom, relayURLs: []) ) } diff --git a/apple/Tests/CoreRepositoryLifecycleTests.swift b/apple/Tests/CoreRepositoryLifecycleTests.swift index cdd8c69..7d0ce97 100644 --- a/apple/Tests/CoreRepositoryLifecycleTests.swift +++ b/apple/Tests/CoreRepositoryLifecycleTests.swift @@ -94,7 +94,7 @@ final class CoreRepositoryLifecycleTests: XCTestCase { let concurrentInitialization = await repository.initialize( appDataDir: "/tmp/second", - networkConfiguration: RelayConfiguration(mode: .custom, relayURLs: ["https://relay.example"]) + networkConfiguration: RelayConfiguration(mode: .strictCustom, relayURLs: ["https://relay.example"]) ) assertLifecycleFailure(concurrentInitialization, equals: .transitionInProgress) diff --git a/apple/Tests/RelayConfigurationTests.swift b/apple/Tests/RelayConfigurationTests.swift index 5ed61bf..2196849 100644 --- a/apple/Tests/RelayConfigurationTests.swift +++ b/apple/Tests/RelayConfigurationTests.swift @@ -2,6 +2,32 @@ import XCTest @testable import VniDrop final class RelayConfigurationTests: XCTestCase { + func testCustomFallbackValidatesAndPreservesItsMode() throws { + let result = try RelayConfigurationValidator.validate( + mode: .customWithDirectFallback, + relayURLs: ["https://relay.example/"] + ) + + XCTAssertEqual( + result, + RelayConfiguration( + mode: .customWithDirectFallback, + relayURLs: ["https://relay.example"] + ) + ) + } + + func testLocalOnlyRetainsPreviouslySavedRelayURLs() throws { + let retained = ["https://relay.example"] + let result = try RelayConfigurationValidator.validate( + mode: .localOnly, + relayURLs: ["not a URL"], + retainedRelayURLs: retained + ) + + XCTAssertEqual(result, RelayConfiguration(mode: .localOnly, relayURLs: retained)) + } + func testAutomaticModeIgnoresRelayDrafts() throws { let result = try RelayConfigurationValidator.validate( mode: .automatic, @@ -24,32 +50,32 @@ final class RelayConfigurationTests: XCTestCase { func testCustomModeTrimsValidHTTPSRelayURLs() throws { let result = try RelayConfigurationValidator.validate( - mode: .custom, + mode: .strictCustom, relayURLs: [" https://relay.example/ ", "https://backup.example:443"] ) XCTAssertEqual(result, RelayConfiguration( - mode: .custom, + mode: .strictCustom, relayURLs: ["https://relay.example", "https://backup.example"] )) } func testCustomModeIgnoresEmptyURLRows() throws { let result = try RelayConfigurationValidator.validate( - mode: .custom, + mode: .strictCustom, relayURLs: ["", " ", "https://relay.example"] ) XCTAssertEqual(result.relayURLs, ["https://relay.example"]) } func testCustomModeRequiresAtLeastOneRelay() { - XCTAssertThrowsError(try RelayConfigurationValidator.validate(mode: .custom, relayURLs: [])) { error in + XCTAssertThrowsError(try RelayConfigurationValidator.validate(mode: .strictCustom, relayURLs: [])) { error in XCTAssertEqual(error as? RelayConfigurationValidationError, .missingURL) } } func testCustomModeRequiresHTTPS() { XCTAssertThrowsError(try RelayConfigurationValidator.validate( - mode: .custom, + mode: .strictCustom, relayURLs: ["http://relay.example"] )) { error in XCTAssertEqual(error as? RelayConfigurationValidationError, .httpsRequired(index: 0)) @@ -67,7 +93,7 @@ final class RelayConfigurationTests: XCTestCase { ] for relayURL in invalidURLs { XCTAssertThrowsError( - try RelayConfigurationValidator.validate(mode: .custom, relayURLs: [relayURL]), + try RelayConfigurationValidator.validate(mode: .strictCustom, relayURLs: [relayURL]), "Expected \(relayURL) to be rejected" ) { error in XCTAssertEqual(error as? RelayConfigurationValidationError, .invalidURL(index: 0)) @@ -77,7 +103,7 @@ final class RelayConfigurationTests: XCTestCase { func testCustomModeRejectsNormalizedDuplicate() { XCTAssertThrowsError(try RelayConfigurationValidator.validate( - mode: .custom, + mode: .strictCustom, relayURLs: ["https://relay.example", "https://RELAY.example:443/"] )) { error in XCTAssertEqual(error as? RelayConfigurationValidationError, .duplicateURL(index: 1)) @@ -88,7 +114,7 @@ final class RelayConfigurationTests: XCTestCase { let relayURLs = (0...RelayConfigurationValidator.maximumRelayCount).map { "https://relay-\($0).example" } - XCTAssertThrowsError(try RelayConfigurationValidator.validate(mode: .custom, relayURLs: relayURLs)) { error in + XCTAssertThrowsError(try RelayConfigurationValidator.validate(mode: .strictCustom, relayURLs: relayURLs)) { error in XCTAssertEqual(error as? RelayConfigurationValidationError, .tooManyURLs) } } diff --git a/apple/Tests/SettingsModelTests.swift b/apple/Tests/SettingsModelTests.swift index e4d27a9..e565762 100644 --- a/apple/Tests/SettingsModelTests.swift +++ b/apple/Tests/SettingsModelTests.swift @@ -59,13 +59,13 @@ final class SettingsModelTests: XCTestCase { let core = FakeCoreGateway() let preferences = Fixtures.preferences() let model = makeModel(core, preferences: preferences) - model.setRelayMode(.custom) + model.setRelayMode(.strictCustom) model.setRelayURL(" https://relay.example/ ", at: 0) model.applyRelayConfiguration() - await waitUntil { preferences.preferences.relayConfiguration.mode == .custom } - let expected = RelayConfiguration(mode: .custom, relayURLs: ["https://relay.example"]) + await waitUntil { preferences.preferences.relayConfiguration.mode == .strictCustom } + let expected = RelayConfiguration(mode: .strictCustom, relayURLs: ["https://relay.example"]) XCTAssertEqual(preferences.preferences.relayConfiguration, expected) XCTAssertEqual(core.initializedNetworkConfigurations, [expected]) XCTAssertFalse(model.state.relayConfigurationIsDirty) @@ -75,7 +75,7 @@ final class SettingsModelTests: XCTestCase { let core = FakeCoreGateway() let preferences = Fixtures.preferences() let relayURLs = ["https://relay.example", "https://backup.example"] - preferences.setRelayConfiguration(RelayConfiguration(mode: .custom, relayURLs: relayURLs)) + preferences.setRelayConfiguration(RelayConfiguration(mode: .strictCustom, relayURLs: relayURLs)) let model = makeModel(core, preferences: preferences) model.setRelayMode(.automatic) @@ -86,7 +86,7 @@ final class SettingsModelTests: XCTestCase { XCTAssertEqual(core.initializedNetworkConfigurations, [ RelayConfiguration(mode: .automatic, relayURLs: relayURLs), ]) - model.setRelayMode(.custom) + model.setRelayMode(.strictCustom) XCTAssertEqual(model.state.relayURLs, relayURLs) } @@ -98,7 +98,7 @@ final class SettingsModelTests: XCTestCase { isInitialized: true, status: CoreStatus(endpointId: "endpoint", activeTransfers: 0, activeShares: 1) )) - model.setRelayMode(.custom) + model.setRelayMode(.strictCustom) model.setRelayURL("https://relay.example", at: 0) model.applyRelayConfiguration() @@ -114,8 +114,8 @@ final class SettingsModelTests: XCTestCase { core.initializeResult = .failure(CoreNetworkLifecycleError.activeNetworkWork) let preferences = Fixtures.preferences() let model = makeModel(core, preferences: preferences) - let attempted = RelayConfiguration(mode: .custom, relayURLs: ["https://relay.example"]) - model.setRelayMode(.custom) + let attempted = RelayConfiguration(mode: .strictCustom, relayURLs: ["https://relay.example"]) + model.setRelayMode(.strictCustom) model.setRelayURL(attempted.relayURLs[0], at: 0) model.applyRelayConfiguration() @@ -134,8 +134,8 @@ final class SettingsModelTests: XCTestCase { core.initializeResults = [.failure(TestError.unimplemented), .success(())] let preferences = Fixtures.preferences() let model = makeModel(core, preferences: preferences) - let attempted = RelayConfiguration(mode: .custom, relayURLs: ["https://relay.example"]) - model.setRelayMode(.custom) + let attempted = RelayConfiguration(mode: .strictCustom, relayURLs: ["https://relay.example"]) + model.setRelayMode(.strictCustom) model.setRelayURL(attempted.relayURLs[0], at: 0) model.applyRelayConfiguration() diff --git a/apple/VniDrop/Core/AppPreferences.swift b/apple/VniDrop/Core/AppPreferences.swift index 48ff1f9..cdd87e0 100644 --- a/apple/VniDrop/Core/AppPreferences.swift +++ b/apple/VniDrop/Core/AppPreferences.swift @@ -21,7 +21,13 @@ enum FolderAccessStatus { enum RelayPreferenceMode: String, Codable, CaseIterable, Sendable { case automatic - case custom + case strictCustom = "custom" + case customWithDirectFallback = "custom-with-direct-fallback" + case localOnly = "local-only" + + var usesCustomRelayURLs: Bool { + self == .strictCustom || self == .customWithDirectFallback + } } struct RelayConfiguration: Equatable, Codable, Sendable { @@ -55,8 +61,8 @@ enum RelayConfigurationValidator { relayURLs: [String], retainedRelayURLs: [String] = [] ) throws -> RelayConfiguration { - guard mode == .custom else { - return RelayConfiguration(mode: .automatic, relayURLs: retainedRelayURLs) + guard mode.usesCustomRelayURLs else { + return RelayConfiguration(mode: mode, relayURLs: retainedRelayURLs) } let relayEntries = relayURLs.enumerated().compactMap { index, value -> (Int, String)? in @@ -104,7 +110,7 @@ enum RelayConfigurationValidator { normalizedURLs.append(canonicalURL) } - return RelayConfiguration(mode: .custom, relayURLs: normalizedURLs) + return RelayConfiguration(mode: mode, relayURLs: normalizedURLs) } } @@ -177,10 +183,9 @@ final class AppPreferencesRepository: ObservableObject { let data = defaults.data(forKey: Key.relayConfiguration), let configuration = try? JSONDecoder().decode(RelayConfiguration.self, from: data) else { - // A stored profile must never silently fall back to public relays. Keeping - // Custom selected with no URLs makes startup fail closed and lets Settings - // offer an explicit repair or reset to Automatic. - return RelayConfiguration(mode: .custom, relayURLs: []) + // A stored profile must never silently fall back to public relays. Strict + // custom with no URLs makes startup fail closed until Settings repairs it. + return RelayConfiguration(mode: .strictCustom, relayURLs: []) } return configuration } diff --git a/apple/VniDrop/Core/CoreRepository.swift b/apple/VniDrop/Core/CoreRepository.swift index 1d8a58e..d98e969 100644 --- a/apple/VniDrop/Core/CoreRepository.swift +++ b/apple/VniDrop/Core/CoreRepository.swift @@ -40,11 +40,18 @@ struct NativeCoreBindingFactory: CoreBindingFactory { switch networkConfiguration.mode { case .automatic: nativeConfiguration = defaultCoreNetworkConfig() - case .custom: + case .strictCustom: nativeConfiguration = CoreNetworkConfig( - mode: .custom, + mode: .strictCustom, relayUrls: networkConfiguration.relayURLs ) + case .customWithDirectFallback: + nativeConfiguration = CoreNetworkConfig( + mode: .customWithDirectFallback, + relayUrls: networkConfiguration.relayURLs + ) + case .localOnly: + nativeConfiguration = CoreNetworkConfig(mode: .localOnly, relayUrls: []) } return try VnidropCore.initializeWithNetworkConfig( appDataDir: appDataDir, diff --git a/apple/VniDrop/Features/Settings/SettingsModel.swift b/apple/VniDrop/Features/Settings/SettingsModel.swift index 5e03b20..674b53a 100644 --- a/apple/VniDrop/Features/Settings/SettingsModel.swift +++ b/apple/VniDrop/Features/Settings/SettingsModel.swift @@ -248,7 +248,7 @@ final class SettingsModel: ObservableObject { func setRelayMode(_ mode: RelayPreferenceMode) { hasRelayConfigurationDraft = true state.relayMode = mode - if mode == .custom && state.relayURLs.isEmpty { state.relayURLs = [""] } + if mode.usesCustomRelayURLs && state.relayURLs.isEmpty { state.relayURLs = [""] } updateRelayConfigurationDraft() } @@ -351,7 +351,7 @@ final class SettingsModel: ObservableObject { state.relayValidationError = nil state.relayApplyErrorKey = nil let saved = preferences.preferences.relayConfiguration - let draftURLs = state.relayMode == .automatic ? saved.relayURLs : state.relayURLs + let draftURLs = state.relayMode.usesCustomRelayURLs ? state.relayURLs : saved.relayURLs state.relayConfigurationIsDirty = saved != RelayConfiguration(mode: state.relayMode, relayURLs: draftURLs) hasRelayConfigurationDraft = state.relayConfigurationIsDirty } diff --git a/apple/VniDrop/Features/Settings/SettingsScreen.swift b/apple/VniDrop/Features/Settings/SettingsScreen.swift index abb9ae5..bbc9951 100644 --- a/apple/VniDrop/Features/Settings/SettingsScreen.swift +++ b/apple/VniDrop/Features/Settings/SettingsScreen.swift @@ -125,7 +125,18 @@ private struct SettingsSectionContent: View { func relayModeLabel(_ mode: RelayPreferenceMode) -> String { switch mode { case .automatic: return String(localized: "relay_mode_automatic") - case .custom: return String(localized: "relay_mode_custom") + case .strictCustom: return String(localized: "relay_mode_custom") + case .customWithDirectFallback: return String(localized: "relay_mode_custom_direct_fallback") + case .localOnly: return String(localized: "relay_mode_local_only") + } +} + +func relayModeDescriptionKey(_ mode: RelayPreferenceMode) -> String { + switch mode { + case .automatic: return "relay_mode_automatic_description" + case .strictCustom: return "relay_mode_custom_description" + case .customWithDirectFallback: return "relay_mode_custom_direct_fallback_description" + case .localOnly: return "relay_mode_local_only_description" } } diff --git a/apple/VniDrop/Features/Settings/SettingsSections.swift b/apple/VniDrop/Features/Settings/SettingsSections.swift index 7c0fadc..4d7698e 100644 --- a/apple/VniDrop/Features/Settings/SettingsSections.swift +++ b/apple/VniDrop/Features/Settings/SettingsSections.swift @@ -70,15 +70,10 @@ struct NetworkSettings: View { Text(relayModeLabel(mode)).tag(mode) } } - .pickerStyle(.segmented) - .labelsHidden() + .pickerStyle(.inline) .disabled(model.state.isApplyingRelayConfiguration) } footer: { - Text(LocalizedStringKey( - model.state.relayMode == .automatic - ? "relay_mode_automatic_description" - : "relay_mode_custom_description" - )) + Text(LocalizedStringKey(relayModeDescriptionKey(model.state.relayMode))) } Section { @@ -99,15 +94,17 @@ struct NetworkSettings: View { } } - if model.state.relayMode == .custom { + if model.state.relayMode.usesCustomRelayURLs { Section { - Label { - Text(LocalizedStringKey("relay_strict_warning")) - .fixedSize(horizontal: false, vertical: true) - } icon: { - Image(systemName: "exclamationmark.shield.fill") + if model.state.relayMode == .strictCustom { + Label { + Text(LocalizedStringKey("relay_strict_warning")) + .fixedSize(horizontal: false, vertical: true) + } icon: { + Image(systemName: "exclamationmark.shield.fill") + } + .foregroundStyle(.orange) } - .foregroundStyle(.orange) ForEach(Array(model.state.relayURLs.indices), id: \.self) { index in VStack(alignment: .leading, spacing: 6) { diff --git a/apple/VniDrop/Resources/Localizable.xcstrings b/apple/VniDrop/Resources/Localizable.xcstrings index 8a891a8..f9e018b 100644 --- a/apple/VniDrop/Resources/Localizable.xcstrings +++ b/apple/VniDrop/Resources/Localizable.xcstrings @@ -10756,55 +10756,55 @@ "de": { "stringUnit": { "state": "needs_review", - "value": "Automatisch" + "value": "Automatisch (empfohlen)" } }, "en": { "stringUnit": { "state": "translated", - "value": "Automatic" + "value": "Automatic (recommended)" } }, "es": { "stringUnit": { "state": "needs_review", - "value": "Automático" + "value": "Automático (recomendado)" } }, "fr": { "stringUnit": { "state": "needs_review", - "value": "Automatique" + "value": "Automatique (recommandé)" } }, "it": { "stringUnit": { "state": "needs_review", - "value": "Automatica" + "value": "Automatica (consigliata)" } }, "nl": { "stringUnit": { "state": "needs_review", - "value": "Automatisch" + "value": "Automatisch (aanbevolen)" } }, "pl": { "stringUnit": { "state": "needs_review", - "value": "Automatyczny" + "value": "Automatyczny (zalecany)" } }, "pt": { "stringUnit": { "state": "needs_review", - "value": "Automático" + "value": "Automático (recomendado)" } }, "ru": { "stringUnit": { "state": "needs_review", - "value": "Автоматически" + "value": "Автоматически (рекомендуется)" } } } @@ -10876,55 +10876,55 @@ "de": { "stringUnit": { "state": "needs_review", - "value": "Benutzerdefiniert" + "value": "Strikt benutzerdefiniert" } }, "en": { "stringUnit": { "state": "translated", - "value": "Custom" + "value": "Strict custom" } }, "es": { "stringUnit": { "state": "needs_review", - "value": "Personalizado" + "value": "Personalizado estricto" } }, "fr": { "stringUnit": { "state": "needs_review", - "value": "Personnalisé" + "value": "Personnalisé strict" } }, "it": { "stringUnit": { "state": "needs_review", - "value": "Personalizzata" + "value": "Personalizzata rigorosa" } }, "nl": { "stringUnit": { "state": "needs_review", - "value": "Aangepast" + "value": "Strikt aangepast" } }, "pl": { "stringUnit": { "state": "needs_review", - "value": "Niestandardowy" + "value": "Ścisły niestandardowy" } }, "pt": { "stringUnit": { "state": "needs_review", - "value": "Personalizado" + "value": "Personalizado estrito" } }, "ru": { "stringUnit": { "state": "needs_review", - "value": "Пользовательский" + "value": "Строго пользовательский" } } } @@ -10936,55 +10936,295 @@ "de": { "stringUnit": { "state": "needs_review", - "value": "Verwendet ausschließlich die unten aufgeführten Relay-Server." + "value": "Verwendet nur die konfigurierten eigenen Relays oder Direktverbindungen. Meldet einen Fehler, wenn kein eigenes Relay erreichbar ist." } }, "en": { "stringUnit": { "state": "translated", - "value": "Use only the relay servers below." + "value": "Use only the configured custom relays or direct connections. Report an error if no custom relay can be established." } }, "es": { "stringUnit": { "state": "needs_review", - "value": "Usa únicamente los servidores de retransmisión indicados a continuación." + "value": "Usa solo los relés personalizados configurados o conexiones directas. Informa de un error si no se puede establecer ningún relé personalizado." } }, "fr": { "stringUnit": { "state": "needs_review", - "value": "Utiliser uniquement les serveurs relais ci-dessous." + "value": "Utilise uniquement les relais personnalisés configurés ou les connexions directes. Signale une erreur si aucun relais personnalisé ne peut être établi." } }, "it": { "stringUnit": { "state": "needs_review", - "value": "Usa solo i server relay indicati di seguito." + "value": "Usa solo i relay personalizzati configurati o connessioni dirette. Segnala un errore se non è possibile stabilire alcun relay personalizzato." } }, "nl": { "stringUnit": { "state": "needs_review", - "value": "Gebruikt alleen de onderstaande relayservers." + "value": "Gebruikt alleen de ingestelde aangepaste relays of rechtstreekse verbindingen. Meldt een fout als geen aangepaste relay bereikbaar is." } }, "pl": { "stringUnit": { "state": "needs_review", - "value": "Używa wyłącznie poniższych serwerów przekaźnikowych." + "value": "Używa tylko skonfigurowanych własnych przekaźników lub połączeń bezpośrednich. Zgłasza błąd, jeśli nie można połączyć się z żadnym własnym przekaźnikiem." } }, "pt": { "stringUnit": { "state": "needs_review", - "value": "Utiliza apenas os servidores de retransmissão abaixo." + "value": "Utiliza apenas os retransmissores personalizados configurados ou ligações diretas. Apresenta um erro se não for possível estabelecer nenhum retransmissor personalizado." } }, "ru": { "stringUnit": { "state": "needs_review", - "value": "Использовать только указанные ниже серверы-ретрансляторы." + "value": "Использует только настроенные пользовательские ретрансляторы или прямые соединения. Сообщает об ошибке, если ни один пользовательский ретранслятор недоступен." + } + } + } + }, + "relay_mode_custom_direct_fallback": { + "comment": "Network settings label for custom relays that allow direct-only startup fallback.", + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "needs_review", + "value": "Benutzerdefiniert mit direktem Rückfall" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Custom with direct fallback" + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "Personalizado con conexión directa de reserva" + } + }, + "fr": { + "stringUnit": { + "state": "needs_review", + "value": "Personnalisé avec repli direct" + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "Personalizzata con ripiego diretto" + } + }, + "nl": { + "stringUnit": { + "state": "needs_review", + "value": "Aangepast met directe terugval" + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Niestandardowy z trybem bezpośrednim" + } + }, + "pt": { + "stringUnit": { + "state": "needs_review", + "value": "Personalizado com alternativa direta" + } + }, + "ru": { + "stringUnit": { + "state": "needs_review", + "value": "Пользовательский с прямым резервом" + } + } + } + }, + "relay_mode_custom_direct_fallback_description": { + "comment": "Network settings description of custom relays with direct-only startup fallback.", + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "needs_review", + "value": "Bevorzugt die konfigurierten eigenen Relays. Sind sie nicht verfügbar, werden nur Direktverbindungen verwendet. Öffentliche Relays werden nie genutzt." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Prefer the configured custom relays. If unavailable, continue with direct connections only. Never use public relays." + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "Prefiere los relés personalizados configurados. Si no están disponibles, continúa solo con conexiones directas. Nunca usa relés públicos." + } + }, + "fr": { + "stringUnit": { + "state": "needs_review", + "value": "Préfère les relais personnalisés configurés. S’ils sont indisponibles, continue uniquement avec des connexions directes. N’utilise jamais les relais publics." + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "Preferisce i relay personalizzati configurati. Se non sono disponibili, continua solo con connessioni dirette. Non usa mai relay pubblici." + } + }, + "nl": { + "stringUnit": { + "state": "needs_review", + "value": "Geeft de voorkeur aan de ingestelde aangepaste relays. Als die niet beschikbaar zijn, worden alleen rechtstreekse verbindingen gebruikt. Openbare relays worden nooit gebruikt." + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Preferuje skonfigurowane własne przekaźniki. Jeśli są niedostępne, kontynuuje tylko przez połączenia bezpośrednie. Nigdy nie używa publicznych przekaźników." + } + }, + "pt": { + "stringUnit": { + "state": "needs_review", + "value": "Prefere os retransmissores personalizados configurados. Se não estiverem disponíveis, continua apenas com ligações diretas. Nunca utiliza retransmissores públicos." + } + }, + "ru": { + "stringUnit": { + "state": "needs_review", + "value": "Предпочитает настроенные пользовательские ретрансляторы. Если они недоступны, продолжает работу только через прямые соединения. Публичные ретрансляторы не используются." + } + } + } + }, + "relay_mode_local_only": { + "comment": "Network settings label for direct connections without any relay.", + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "needs_review", + "value": "Nur lokales Netzwerk" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Local only" + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "Solo red local" + } + }, + "fr": { + "stringUnit": { + "state": "needs_review", + "value": "Réseau local uniquement" + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "Solo rete locale" + } + }, + "nl": { + "stringUnit": { + "state": "needs_review", + "value": "Alleen lokaal netwerk" + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Tylko sieć lokalna" + } + }, + "pt": { + "stringUnit": { + "state": "needs_review", + "value": "Apenas rede local" + } + }, + "ru": { + "stringUnit": { + "state": "needs_review", + "value": "Только локальная сеть" + } + } + } + }, + "relay_mode_local_only_description": { + "comment": "Network settings description of direct-only local-network mode.", + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "needs_review", + "value": "Deaktiviert alle Relays und erlaubt nur Direktverbindungen, hauptsächlich für Geräte im selben Netzwerk." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Disable all relays and allow only direct connections, primarily for devices on the same network." + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "Desactiva todos los relés y permite solo conexiones directas, principalmente para dispositivos de la misma red." + } + }, + "fr": { + "stringUnit": { + "state": "needs_review", + "value": "Désactive tous les relais et autorise uniquement les connexions directes, principalement pour les appareils sur le même réseau." + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "Disattiva tutti i relay e consente solo connessioni dirette, soprattutto per dispositivi sulla stessa rete." + } + }, + "nl": { + "stringUnit": { + "state": "needs_review", + "value": "Schakelt alle relays uit en staat alleen rechtstreekse verbindingen toe, vooral voor apparaten in hetzelfde netwerk." + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Wyłącza wszystkie przekaźniki i zezwala tylko na połączenia bezpośrednie, głównie dla urządzeń w tej samej sieci." + } + }, + "pt": { + "stringUnit": { + "state": "needs_review", + "value": "Desativa todos os retransmissores e permite apenas ligações diretas, principalmente para dispositivos na mesma rede." + } + }, + "ru": { + "stringUnit": { + "state": "needs_review", + "value": "Отключает все ретрансляторы и разрешает только прямые соединения, прежде всего для устройств в одной сети." } } } @@ -11236,55 +11476,55 @@ "de": { "stringUnit": { "state": "needs_review", - "value": "Der benutzerdefinierte Modus ist strikt: VniDrop greift weder auf öffentliche Relays noch auf öffentliche Erkennung zurück. Andere Geräte müssen Ihre konfigurierten Relays erreichen können." + "value": "Der strikt benutzerdefinierte Modus startet nur, wenn mindestens ein konfiguriertes Relay erreichbar ist. VniDrop verwendet in diesem Modus nie öffentliche Relays oder öffentliche Erkennung." } }, "en": { "stringUnit": { "state": "translated", - "value": "Custom mode is strict: VniDrop will not fall back to public relays or public discovery. Other devices must be able to reach your configured relays." + "value": "Strict custom mode will not start unless at least one configured relay is reachable. VniDrop never uses public relays or public discovery in this mode." } }, "es": { "stringUnit": { "state": "needs_review", - "value": "El modo personalizado es estricto: VniDrop no recurrirá a relés públicos ni al descubrimiento público. Los demás dispositivos deben poder acceder a los relés configurados." + "value": "El modo personalizado estricto no se inicia a menos que se pueda acceder al menos a un relé configurado. VniDrop nunca usa relés ni descubrimiento públicos en este modo." } }, "fr": { "stringUnit": { "state": "needs_review", - "value": "Le mode personnalisé est strict : VniDrop n’utilisera ni les relais publics ni la découverte publique comme solution de repli. Les autres appareils doivent pouvoir accéder aux relais configurés." + "value": "Le mode personnalisé strict ne démarre que si au moins un relais configuré est accessible. VniDrop n’utilise jamais de relais ni de découverte publics dans ce mode." } }, "it": { "stringUnit": { "state": "needs_review", - "value": "La modalità personalizzata è rigorosa: VniDrop non userà relay pubblici o il rilevamento pubblico come ripiego. Gli altri dispositivi devono poter raggiungere i relay configurati." + "value": "La modalità personalizzata rigorosa si avvia solo se almeno un relay configurato è raggiungibile. In questa modalità VniDrop non usa mai relay o rilevamento pubblici." } }, "nl": { "stringUnit": { "state": "needs_review", - "value": "De aangepaste modus is strikt: VniDrop valt niet terug op openbare relays of openbare detectie. Andere apparaten moeten uw ingestelde relays kunnen bereiken." + "value": "De strikt aangepaste modus start alleen als minstens één ingestelde relay bereikbaar is. VniDrop gebruikt in deze modus nooit openbare relays of openbare detectie." } }, "pl": { "stringUnit": { "state": "needs_review", - "value": "Tryb niestandardowy jest rygorystyczny: VniDrop nie użyje awaryjnie publicznych przekaźników ani publicznego wykrywania. Inne urządzenia muszą mieć dostęp do skonfigurowanych przekaźników." + "value": "Ścisły tryb niestandardowy uruchamia się tylko wtedy, gdy co najmniej jeden skonfigurowany przekaźnik jest dostępny. VniDrop nigdy nie używa w tym trybie publicznych przekaźników ani publicznego wykrywania." } }, "pt": { "stringUnit": { "state": "needs_review", - "value": "O modo personalizado é estrito: o VniDrop não recorrerá a retransmissores públicos nem à descoberta pública. Os outros dispositivos têm de conseguir aceder aos retransmissores configurados." + "value": "O modo personalizado estrito só inicia se pelo menos um retransmissor configurado estiver acessível. Neste modo, o VniDrop nunca utiliza retransmissores nem descoberta públicos." } }, "ru": { "stringUnit": { "state": "needs_review", - "value": "Пользовательский режим работает строго: VniDrop не будет переключаться на публичные ретрансляторы или публичное обнаружение. Другие устройства должны иметь доступ к настроенным ретрансляторам." + "value": "Строго пользовательский режим запускается, только если доступен хотя бы один настроенный ретранслятор. В этом режиме VniDrop никогда не использует публичные ретрансляторы или публичное обнаружение." } } } diff --git a/crates/vnidrop/src/api.rs b/crates/vnidrop/src/api.rs index f4fe087..cfb3f50 100644 --- a/crates/vnidrop/src/api.rs +++ b/crates/vnidrop/src/api.rs @@ -12,7 +12,9 @@ pub(crate) const MAX_RELAY_URL_BYTES: usize = 2_048; #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, uniffi::Enum)] pub enum CoreRelayMode { Automatic, - Custom, + StrictCustom, + CustomWithDirectFallback, + LocalOnly, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, uniffi::Record)] @@ -33,13 +35,21 @@ impl Default for CoreNetworkConfig { impl CoreNetworkConfig { pub(crate) fn validated_relay_urls(&self) -> anyhow::Result> { match self.mode { - CoreRelayMode::Automatic => { + CoreRelayMode::Automatic | CoreRelayMode::LocalOnly => { if !self.relay_urls.is_empty() { - anyhow::bail!("automatic relay mode must not include custom relay URLs"); + anyhow::bail!( + "{} relay mode must not include custom relay URLs", + match self.mode { + CoreRelayMode::Automatic => "automatic", + CoreRelayMode::LocalOnly => "local-only", + CoreRelayMode::StrictCustom + | CoreRelayMode::CustomWithDirectFallback => unreachable!(), + } + ); } Ok(Vec::new()) } - CoreRelayMode::Custom => { + CoreRelayMode::StrictCustom | CoreRelayMode::CustomWithDirectFallback => { if self.relay_urls.is_empty() { anyhow::bail!("custom relay mode requires at least one relay URL"); } diff --git a/crates/vnidrop/src/runtime/mod.rs b/crates/vnidrop/src/runtime/mod.rs index 137017a..aee54ce 100644 --- a/crates/vnidrop/src/runtime/mod.rs +++ b/crates/vnidrop/src/runtime/mod.rs @@ -61,6 +61,23 @@ use crate::{ const RELAY_CONNECT_TIMEOUT: Duration = Duration::from_secs(10); +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum RelayStatus { + Disabled, + Connected, + Unreachable, +} + +impl RelayStatus { + fn as_str(self) -> &'static str { + match self { + Self::Disabled => "disabled", + Self::Connected => "connected", + Self::Unreachable => "unreachable", + } + } +} + /// Owns the Iroh endpoint, blob store, transfer history, and byte streaming. /// Kotlin owns app lifecycle and platform file picking. pub(super) struct CoreInner { @@ -122,7 +139,7 @@ impl CoreInner { .bind() .await? } - CoreRelayMode::Custom => { + CoreRelayMode::StrictCustom | CoreRelayMode::CustomWithDirectFallback => { let relay_map = RelayMap::from_iter(relay_urls.iter().cloned().map(|url| { // Loopback HTTP is a development escape hatch. Without TLS the // relay cannot serve Iroh's QUIC address-discovery endpoint. @@ -141,13 +158,22 @@ impl CoreInner { .bind() .await? } + CoreRelayMode::LocalOnly => { + Endpoint::builder(presets::Minimal) + .relay_mode(RelayMode::Disabled) + .secret_key(secret_key) + .bind() + .await? + } }; - if let Err(error) = - wait_for_relay(&endpoint, relay_mode, &relay_urls, RELAY_CONNECT_TIMEOUT).await - { - endpoint.close().await; - return Err(error); - } + let relay_status = + match wait_for_relay(&endpoint, relay_mode, &relay_urls, RELAY_CONNECT_TIMEOUT).await { + Ok(status) => status, + Err(error) => { + endpoint.close().await; + return Err(error); + } + }; // Provider events are where the sender sees remote readers. The core // uses them for send progress and for the current approval gate. @@ -160,6 +186,14 @@ impl CoreInner { limits.event_queue_capacity as usize, limits.max_events, )); + event_hub.emit_endpoint( + "network", + "relay-status", + json!({ + "mode": relay_mode_label(relay_mode), + "status": relay_status.as_str(), + }), + ); for recovered in recovered_transfers { event_hub.emit_transfer( recovered.transfer_id, @@ -377,7 +411,7 @@ pub(crate) fn filter_peer_addr_for_relay_mode( ) -> Result { match relay_mode { CoreRelayMode::Automatic => Ok(addr.clone()), - CoreRelayMode::Custom => { + CoreRelayMode::StrictCustom | CoreRelayMode::CustomWithDirectFallback => { let mut filtered = EndpointAddr::new(addr.id); for ip_addr in addr.ip_addrs().copied() { filtered = filtered.with_ip_addr(ip_addr); @@ -396,6 +430,16 @@ pub(crate) fn filter_peer_addr_for_relay_mode( } Ok(filtered) } + CoreRelayMode::LocalOnly => { + let mut filtered = EndpointAddr::new(addr.id); + for ip_addr in addr.ip_addrs().copied() { + filtered = filtered.with_ip_addr(ip_addr); + } + if filtered.is_empty() { + anyhow::bail!("invitation has no direct address allowed by local-only mode"); + } + Ok(filtered) + } } } @@ -404,17 +448,16 @@ pub(crate) async fn wait_for_relay( relay_mode: CoreRelayMode, relay_urls: &[RelayUrl], timeout: Duration, -) -> Result<()> { +) -> Result { + if relay_mode == CoreRelayMode::LocalOnly { + return Ok(RelayStatus::Disabled); + } if tokio::time::timeout(timeout, endpoint.online()) .await .is_err() { match relay_mode { - CoreRelayMode::Automatic => anyhow::bail!( - "timed out after {} seconds while connecting to automatic relays; verify network access and relay availability", - timeout.as_secs_f32(), - ), - CoreRelayMode::Custom => { + CoreRelayMode::StrictCustom => { let configured_relays = relay_urls .iter() .map(ToString::to_string) @@ -425,9 +468,22 @@ pub(crate) async fn wait_for_relay( timeout.as_secs_f32(), ); } + CoreRelayMode::Automatic | CoreRelayMode::CustomWithDirectFallback => { + return Ok(RelayStatus::Unreachable); + } + CoreRelayMode::LocalOnly => unreachable!(), } } - Ok(()) + Ok(RelayStatus::Connected) +} + +fn relay_mode_label(relay_mode: CoreRelayMode) -> &'static str { + match relay_mode { + CoreRelayMode::Automatic => "automatic", + CoreRelayMode::StrictCustom => "strict-custom", + CoreRelayMode::CustomWithDirectFallback => "custom-with-direct-fallback", + CoreRelayMode::LocalOnly => "local-only", + } } pub(super) fn share_tag_name(local_id: &str) -> String { diff --git a/crates/vnidrop/src/tests/network_config.rs b/crates/vnidrop/src/tests/network_config.rs index e5f0437..363ccf2 100644 --- a/crates/vnidrop/src/tests/network_config.rs +++ b/crates/vnidrop/src/tests/network_config.rs @@ -7,7 +7,7 @@ use crate::{ default_core_network_config, CoreNetworkConfig, CoreRelayMode, MAX_CUSTOM_RELAYS, MAX_RELAY_URL_BYTES, }, - runtime::{filter_peer_addr_for_relay_mode, wait_for_relay}, + runtime::{filter_peer_addr_for_relay_mode, wait_for_relay, RelayStatus}, }; #[test] @@ -32,17 +32,28 @@ fn relay_mode_and_url_list_must_be_consistent() { }; assert!(automatic_with_url.validated_relay_urls().is_err()); - let custom_without_url = CoreNetworkConfig { - mode: CoreRelayMode::Custom, - relay_urls: Vec::new(), + for mode in [ + CoreRelayMode::StrictCustom, + CoreRelayMode::CustomWithDirectFallback, + ] { + let custom_without_url = CoreNetworkConfig { + mode, + relay_urls: Vec::new(), + }; + assert!(custom_without_url.validated_relay_urls().is_err()); + } + + let local_only_with_url = CoreNetworkConfig { + mode: CoreRelayMode::LocalOnly, + relay_urls: vec!["https://relay.example.com".to_string()], }; - assert!(custom_without_url.validated_relay_urls().is_err()); + assert!(local_only_with_url.validated_relay_urls().is_err()); } #[test] fn custom_relay_urls_allow_https_and_loopback_http() { let config = CoreNetworkConfig { - mode: CoreRelayMode::Custom, + mode: CoreRelayMode::StrictCustom, relay_urls: vec![ "https://relay.example.com".to_string(), "http://localhost:3340".to_string(), @@ -68,7 +79,7 @@ fn custom_relay_urls_reject_unsafe_or_ambiguous_values() { " https://relay.example.com", ] { let config = CoreNetworkConfig { - mode: CoreRelayMode::Custom, + mode: CoreRelayMode::StrictCustom, relay_urls: vec![value.to_string()], }; assert!( @@ -81,7 +92,7 @@ fn custom_relay_urls_reject_unsafe_or_ambiguous_values() { #[test] fn custom_relay_urls_are_bounded_and_unique_after_normalization() { let duplicates = CoreNetworkConfig { - mode: CoreRelayMode::Custom, + mode: CoreRelayMode::StrictCustom, relay_urls: vec![ "https://relay.example.com".to_string(), "https://relay.example.com/".to_string(), @@ -90,7 +101,7 @@ fn custom_relay_urls_are_bounded_and_unique_after_normalization() { assert!(duplicates.validated_relay_urls().is_err()); let too_many = CoreNetworkConfig { - mode: CoreRelayMode::Custom, + mode: CoreRelayMode::StrictCustom, relay_urls: (0..=MAX_CUSTOM_RELAYS) .map(|index| format!("https://relay-{index}.example.com")) .collect(), @@ -98,7 +109,7 @@ fn custom_relay_urls_are_bounded_and_unique_after_normalization() { assert!(too_many.validated_relay_urls().is_err()); let too_long = CoreNetworkConfig { - mode: CoreRelayMode::Custom, + mode: CoreRelayMode::StrictCustom, relay_urls: vec![format!( "https://{}.example.com", "a".repeat(MAX_RELAY_URL_BYTES) @@ -119,7 +130,7 @@ fn strict_custom_mode_filters_peer_relays_but_retains_direct_addresses() { let filtered = filter_peer_addr_for_relay_mode( &addr, - CoreRelayMode::Custom, + CoreRelayMode::StrictCustom, std::slice::from_ref(&allowed), ) .unwrap(); @@ -140,14 +151,32 @@ fn strict_custom_mode_filters_peer_relays_but_retains_direct_addresses() { EndpointAddr::new(SecretKey::generate().public()).with_relay_url(disallowed); assert!(filter_peer_addr_for_relay_mode( &disallowed_only, - CoreRelayMode::Custom, + CoreRelayMode::StrictCustom, std::slice::from_ref(&allowed), ) .is_err()); + + let fallback_filtered = filter_peer_addr_for_relay_mode( + &addr, + CoreRelayMode::CustomWithDirectFallback, + std::slice::from_ref(&allowed), + ) + .unwrap(); + assert_eq!(fallback_filtered, filtered); + + let local_only = filter_peer_addr_for_relay_mode(&addr, CoreRelayMode::LocalOnly, &[]).unwrap(); + assert_eq!(local_only.relay_urls().count(), 0); + assert_eq!( + local_only.ip_addrs().copied().collect::>(), + vec![direct] + ); + assert!( + filter_peer_addr_for_relay_mode(&disallowed_only, CoreRelayMode::LocalOnly, &[]).is_err() + ); } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn unreachable_relay_wait_is_bounded_and_actionable_for_each_mode() { +async fn relay_wait_enforces_only_strict_custom_mode() { let relay_url: RelayUrl = "http://127.0.0.1:9".parse().unwrap(); let endpoint = Endpoint::builder(presets::Minimal) .relay_mode(RelayMode::custom([relay_url.clone()])) @@ -158,7 +187,7 @@ async fn unreachable_relay_wait_is_bounded_and_actionable_for_each_mode() { let error = wait_for_relay( &endpoint, - CoreRelayMode::Custom, + CoreRelayMode::StrictCustom, std::slice::from_ref(&relay_url), Duration::from_millis(50), ) @@ -169,18 +198,35 @@ async fn unreachable_relay_wait_is_bounded_and_actionable_for_each_mode() { assert!(error.to_string().contains(relay_url.as_str())); assert!(error.to_string().contains("verify the URLs")); - let automatic_error = wait_for_relay( + let automatic_status = wait_for_relay( &endpoint, CoreRelayMode::Automatic, &[], Duration::from_millis(50), ) .await - .unwrap_err(); + .unwrap(); assert!(started.elapsed() < Duration::from_secs(1)); - assert!(automatic_error.to_string().contains("automatic relays")); - assert!(automatic_error - .to_string() - .contains("verify network access")); + assert_eq!(automatic_status, RelayStatus::Unreachable); + + let fallback_status = wait_for_relay( + &endpoint, + CoreRelayMode::CustomWithDirectFallback, + std::slice::from_ref(&relay_url), + Duration::from_millis(50), + ) + .await + .unwrap(); + assert_eq!(fallback_status, RelayStatus::Unreachable); + + let local_only_status = wait_for_relay( + &endpoint, + CoreRelayMode::LocalOnly, + &[], + Duration::from_millis(50), + ) + .await + .unwrap(); + assert_eq!(local_only_status, RelayStatus::Disabled); endpoint.close().await; } diff --git a/crates/vnidrop/src/tests/ticket.rs b/crates/vnidrop/src/tests/ticket.rs index e5fcb13..67d5e90 100644 --- a/crates/vnidrop/src/tests/ticket.rs +++ b/crates/vnidrop/src/tests/ticket.rs @@ -120,14 +120,21 @@ fn saved_ticket_relay_profile_matching_is_mode_aware_and_order_insensitive() { assert!(ticket_matches_relay_profile( &custom_ticket, &limits, - CoreRelayMode::Custom, + CoreRelayMode::StrictCustom, + &[relay_b.clone(), relay_a.clone()], + ) + .unwrap()); + assert!(ticket_matches_relay_profile( + &custom_ticket, + &limits, + CoreRelayMode::CustomWithDirectFallback, &[relay_b.clone(), relay_a.clone()], ) .unwrap()); assert!(!ticket_matches_relay_profile( &custom_ticket, &limits, - CoreRelayMode::Custom, + CoreRelayMode::StrictCustom, &[relay_a.clone(), relay_c], ) .unwrap()); @@ -145,10 +152,21 @@ fn saved_ticket_relay_profile_matching_is_mode_aware_and_order_insensitive() { assert!(!ticket_matches_relay_profile( &automatic_ticket, &limits, - CoreRelayMode::Custom, + CoreRelayMode::StrictCustom, &[relay_a], ) .unwrap()); + assert!(ticket_matches_relay_profile( + &automatic_ticket, + &limits, + CoreRelayMode::LocalOnly, + &[], + ) + .unwrap()); + assert!( + !ticket_matches_relay_profile(&custom_ticket, &limits, CoreRelayMode::LocalOnly, &[],) + .unwrap() + ); } #[test] diff --git a/crates/vnidrop/src/ticket.rs b/crates/vnidrop/src/ticket.rs index efb5951..d2f108e 100644 --- a/crates/vnidrop/src/ticket.rs +++ b/crates/vnidrop/src/ticket.rs @@ -142,7 +142,7 @@ pub(crate) fn parse_transfer_ticket_with_limits( Vec::new() } else { CoreNetworkConfig { - mode: CoreRelayMode::Custom, + mode: CoreRelayMode::StrictCustom, relay_urls: ticket.relay_urls, } .validated_relay_urls() @@ -171,7 +171,7 @@ pub(crate) fn ticket_matches_relay_profile( let parsed = parse_transfer_ticket_with_limits(value, limits)?; match relay_mode { CoreRelayMode::Automatic => Ok(parsed.advertised_custom_relay_urls.is_empty()), - CoreRelayMode::Custom => { + CoreRelayMode::StrictCustom | CoreRelayMode::CustomWithDirectFallback => { let advertised = parsed .advertised_custom_relay_urls .into_iter() @@ -179,6 +179,8 @@ pub(crate) fn ticket_matches_relay_profile( let configured = custom_relay_urls.iter().cloned().collect::>(); Ok(advertised == configured) } + CoreRelayMode::LocalOnly => Ok(parsed.advertised_custom_relay_urls.is_empty() + && parsed.blob_ticket.addr().relay_urls().next().is_none()), } } diff --git a/crates/vnidrop/tests/custom_relay.rs b/crates/vnidrop/tests/custom_relay.rs index a89e7d3..b2dae70 100644 --- a/crates/vnidrop/tests/custom_relay.rs +++ b/crates/vnidrop/tests/custom_relay.rs @@ -11,24 +11,64 @@ use vnidrop::{CoreNetworkConfig, CoreRelayMode}; fn custom_config(relay_urls: &[&str]) -> CoreNetworkConfig { CoreNetworkConfig { - mode: CoreRelayMode::Custom, + mode: CoreRelayMode::StrictCustom, relay_urls: relay_urls.iter().map(ToString::to_string).collect(), } } +fn local_only_config() -> CoreNetworkConfig { + CoreNetworkConfig { + mode: CoreRelayMode::LocalOnly, + relay_urls: Vec::new(), + } +} + fn read_blob_ticket(ticket: &str) -> (Value, BlobTicket) { let encoded = ticket.strip_prefix("vnd1:").unwrap(); let payload = BASE64URL_NOPAD.decode(encoded.as_bytes()).unwrap(); let value: Value = serde_json::from_slice(&payload).unwrap(); let blob_ticket = BlobTicket::from_str(value["blob_ticket"].as_str().unwrap()).unwrap(); let (mut addr, hash, format) = blob_ticket.into_parts(); - for relay_url in value["relay_urls"].as_array().unwrap() { - addr = addr.with_relay_url(relay_url.as_str().unwrap().parse().unwrap()); + if let Some(relay_urls) = value["relay_urls"].as_array() { + for relay_url in relay_urls { + addr = addr.with_relay_url(relay_url.as_str().unwrap().parse().unwrap()); + } } let blob_ticket = BlobTicket::new(addr, hash, format); (value, blob_ticket) } +#[test] +fn local_only_mode_advertises_direct_addresses_and_transfers_on_lan() { + let sender = TestNode::with_network_config(local_only_config()); + let receiver = TestNode::with_network_config(local_only_config()); + let source_dir = tempfile::tempdir().unwrap(); + let output_dir = tempfile::tempdir().unwrap(); + let source_path = source_dir.path().join("local-only.txt"); + std::fs::write(&source_path, b"direct on the local network").unwrap(); + + let share = share_path(&sender.core, &source_path, 402, "local-only.txt", false); + let (ticket_value, blob_ticket) = read_blob_ticket(&share.ticket); + assert!(ticket_value.get("relay_urls").is_none()); + assert_eq!(blob_ticket.addr().relay_urls().count(), 0); + assert!(!sender.core.status().addr.contains("iroh.link")); + + receive_with_response( + &sender.core, + share.transfer_id, + receiver.core.arc(), + share.ticket, + output_dir.path(), + true, + ) + .unwrap(); + + assert_eq!( + std::fs::read(output_dir.path().join("local-only.txt")).unwrap(), + b"direct on the local network" + ); +} + fn with_relay_only_address(ticket: &str, relay_url: &str) -> String { let (mut value, blob_ticket) = read_blob_ticket(ticket); let relay_url: RelayUrl = relay_url.parse().unwrap(); diff --git a/crates/vnidrop/tests/lifecycle.rs b/crates/vnidrop/tests/lifecycle.rs index 59ada03..06bc25c 100644 --- a/crates/vnidrop/tests/lifecycle.rs +++ b/crates/vnidrop/tests/lifecycle.rs @@ -151,7 +151,7 @@ fn persisted_share_is_revoked_when_restarted_with_a_different_relay_profile() { core_dir.path(), Arc::new(RecordingSink::default()), CoreNetworkConfig { - mode: CoreRelayMode::Custom, + mode: CoreRelayMode::StrictCustom, relay_urls: vec![relay_a.url.clone()], }, ); @@ -163,7 +163,7 @@ fn persisted_share_is_revoked_when_restarted_with_a_different_relay_profile() { core_dir.path(), Arc::new(RecordingSink::default()), CoreNetworkConfig { - mode: CoreRelayMode::Custom, + mode: CoreRelayMode::StrictCustom, relay_urls: vec![relay_b.url.clone()], }, ); diff --git a/localization/strings.json b/localization/strings.json index acd721c..50d077f 100644 --- a/localization/strings.json +++ b/localization/strings.json @@ -2598,15 +2598,15 @@ "relay_mode_automatic": { "context": "Network settings label for VniDrop's automatic public relay mode.", "translations": { - "en": "Automatic", - "fr": "Automatique", - "es": "Automático", - "it": "Automatica", - "de": "Automatisch", - "pt": "Automático", - "pl": "Automatyczny", - "nl": "Automatisch", - "ru": "Автоматически" + "en": "Automatic (recommended)", + "fr": "Automatique (recommandé)", + "es": "Automático (recomendado)", + "it": "Automatica (consigliata)", + "de": "Automatisch (empfohlen)", + "pt": "Automático (recomendado)", + "pl": "Automatyczny (zalecany)", + "nl": "Automatisch (aanbevolen)", + "ru": "Автоматически (рекомендуется)" } }, "relay_mode_automatic_description": { @@ -2626,29 +2626,85 @@ "relay_mode_custom": { "context": "Network settings label for strict custom relay mode.", "translations": { - "en": "Custom", - "fr": "Personnalisé", - "es": "Personalizado", - "it": "Personalizzata", - "de": "Benutzerdefiniert", - "pt": "Personalizado", - "pl": "Niestandardowy", - "nl": "Aangepast", - "ru": "Пользовательский" + "en": "Strict custom", + "fr": "Personnalisé strict", + "es": "Personalizado estricto", + "it": "Personalizzata rigorosa", + "de": "Strikt benutzerdefiniert", + "pt": "Personalizado estrito", + "pl": "Ścisły niestandardowy", + "nl": "Strikt aangepast", + "ru": "Строго пользовательский" } }, "relay_mode_custom_description": { "context": "Network settings description of strict custom relay behavior.", "translations": { - "en": "Use only the relay servers below.", - "fr": "Utiliser uniquement les serveurs relais ci-dessous.", - "es": "Usa únicamente los servidores de retransmisión indicados a continuación.", - "it": "Usa solo i server relay indicati di seguito.", - "de": "Verwendet ausschließlich die unten aufgeführten Relay-Server.", - "pt": "Utiliza apenas os servidores de retransmissão abaixo.", - "pl": "Używa wyłącznie poniższych serwerów przekaźnikowych.", - "nl": "Gebruikt alleen de onderstaande relayservers.", - "ru": "Использовать только указанные ниже серверы-ретрансляторы." + "en": "Use only the configured custom relays or direct connections. Report an error if no custom relay can be established.", + "fr": "Utilise uniquement les relais personnalisés configurés ou les connexions directes. Signale une erreur si aucun relais personnalisé ne peut être établi.", + "es": "Usa solo los relés personalizados configurados o conexiones directas. Informa de un error si no se puede establecer ningún relé personalizado.", + "it": "Usa solo i relay personalizzati configurati o connessioni dirette. Segnala un errore se non è possibile stabilire alcun relay personalizzato.", + "de": "Verwendet nur die konfigurierten eigenen Relays oder Direktverbindungen. Meldet einen Fehler, wenn kein eigenes Relay erreichbar ist.", + "pt": "Utiliza apenas os retransmissores personalizados configurados ou ligações diretas. Apresenta um erro se não for possível estabelecer nenhum retransmissor personalizado.", + "pl": "Używa tylko skonfigurowanych własnych przekaźników lub połączeń bezpośrednich. Zgłasza błąd, jeśli nie można połączyć się z żadnym własnym przekaźnikiem.", + "nl": "Gebruikt alleen de ingestelde aangepaste relays of rechtstreekse verbindingen. Meldt een fout als geen aangepaste relay bereikbaar is.", + "ru": "Использует только настроенные пользовательские ретрансляторы или прямые соединения. Сообщает об ошибке, если ни один пользовательский ретранслятор недоступен." + } + }, + "relay_mode_custom_direct_fallback": { + "context": "Network settings label for custom relays that allow direct-only startup fallback.", + "translations": { + "en": "Custom with direct fallback", + "fr": "Personnalisé avec repli direct", + "es": "Personalizado con conexión directa de reserva", + "it": "Personalizzata con ripiego diretto", + "de": "Benutzerdefiniert mit direktem Rückfall", + "pt": "Personalizado com alternativa direta", + "pl": "Niestandardowy z trybem bezpośrednim", + "nl": "Aangepast met directe terugval", + "ru": "Пользовательский с прямым резервом" + } + }, + "relay_mode_custom_direct_fallback_description": { + "context": "Network settings description of custom relays with direct-only startup fallback.", + "translations": { + "en": "Prefer the configured custom relays. If unavailable, continue with direct connections only. Never use public relays.", + "fr": "Préfère les relais personnalisés configurés. S’ils sont indisponibles, continue uniquement avec des connexions directes. N’utilise jamais les relais publics.", + "es": "Prefiere los relés personalizados configurados. Si no están disponibles, continúa solo con conexiones directas. Nunca usa relés públicos.", + "it": "Preferisce i relay personalizzati configurati. Se non sono disponibili, continua solo con connessioni dirette. Non usa mai relay pubblici.", + "de": "Bevorzugt die konfigurierten eigenen Relays. Sind sie nicht verfügbar, werden nur Direktverbindungen verwendet. Öffentliche Relays werden nie genutzt.", + "pt": "Prefere os retransmissores personalizados configurados. Se não estiverem disponíveis, continua apenas com ligações diretas. Nunca utiliza retransmissores públicos.", + "pl": "Preferuje skonfigurowane własne przekaźniki. Jeśli są niedostępne, kontynuuje tylko przez połączenia bezpośrednie. Nigdy nie używa publicznych przekaźników.", + "nl": "Geeft de voorkeur aan de ingestelde aangepaste relays. Als die niet beschikbaar zijn, worden alleen rechtstreekse verbindingen gebruikt. Openbare relays worden nooit gebruikt.", + "ru": "Предпочитает настроенные пользовательские ретрансляторы. Если они недоступны, продолжает работу только через прямые соединения. Публичные ретрансляторы не используются." + } + }, + "relay_mode_local_only": { + "context": "Network settings label for direct connections without any relay.", + "translations": { + "en": "Local only", + "fr": "Réseau local uniquement", + "es": "Solo red local", + "it": "Solo rete locale", + "de": "Nur lokales Netzwerk", + "pt": "Apenas rede local", + "pl": "Tylko sieć lokalna", + "nl": "Alleen lokaal netwerk", + "ru": "Только локальная сеть" + } + }, + "relay_mode_local_only_description": { + "context": "Network settings description of direct-only local-network mode.", + "translations": { + "en": "Disable all relays and allow only direct connections, primarily for devices on the same network.", + "fr": "Désactive tous les relais et autorise uniquement les connexions directes, principalement pour les appareils sur le même réseau.", + "es": "Desactiva todos los relés y permite solo conexiones directas, principalmente para dispositivos de la misma red.", + "it": "Disattiva tutti i relay e consente solo connessioni dirette, soprattutto per dispositivi sulla stessa rete.", + "de": "Deaktiviert alle Relays und erlaubt nur Direktverbindungen, hauptsächlich für Geräte im selben Netzwerk.", + "pt": "Desativa todos os retransmissores e permite apenas ligações diretas, principalmente para dispositivos na mesma rede.", + "pl": "Wyłącza wszystkie przekaźniki i zezwala tylko na połączenia bezpośrednie, głównie dla urządzeń w tej samej sieci.", + "nl": "Schakelt alle relays uit en staat alleen rechtstreekse verbindingen toe, vooral voor apparaten in hetzelfde netwerk.", + "ru": "Отключает все ретрансляторы и разрешает только прямые соединения, прежде всего для устройств в одной сети." } }, "relay_privacy_description": { @@ -2710,15 +2766,15 @@ "relay_strict_warning": { "context": "Network settings warning that custom relay mode has no public fallback or discovery.", "translations": { - "en": "Custom mode is strict: VniDrop will not fall back to public relays or public discovery. Other devices must be able to reach your configured relays.", - "fr": "Le mode personnalisé est strict : VniDrop n’utilisera ni les relais publics ni la découverte publique comme solution de repli. Les autres appareils doivent pouvoir accéder aux relais configurés.", - "es": "El modo personalizado es estricto: VniDrop no recurrirá a relés públicos ni al descubrimiento público. Los demás dispositivos deben poder acceder a los relés configurados.", - "it": "La modalità personalizzata è rigorosa: VniDrop non userà relay pubblici o il rilevamento pubblico come ripiego. Gli altri dispositivi devono poter raggiungere i relay configurati.", - "de": "Der benutzerdefinierte Modus ist strikt: VniDrop greift weder auf öffentliche Relays noch auf öffentliche Erkennung zurück. Andere Geräte müssen Ihre konfigurierten Relays erreichen können.", - "pt": "O modo personalizado é estrito: o VniDrop não recorrerá a retransmissores públicos nem à descoberta pública. Os outros dispositivos têm de conseguir aceder aos retransmissores configurados.", - "pl": "Tryb niestandardowy jest rygorystyczny: VniDrop nie użyje awaryjnie publicznych przekaźników ani publicznego wykrywania. Inne urządzenia muszą mieć dostęp do skonfigurowanych przekaźników.", - "nl": "De aangepaste modus is strikt: VniDrop valt niet terug op openbare relays of openbare detectie. Andere apparaten moeten uw ingestelde relays kunnen bereiken.", - "ru": "Пользовательский режим работает строго: VniDrop не будет переключаться на публичные ретрансляторы или публичное обнаружение. Другие устройства должны иметь доступ к настроенным ретрансляторам." + "en": "Strict custom mode will not start unless at least one configured relay is reachable. VniDrop never uses public relays or public discovery in this mode.", + "fr": "Le mode personnalisé strict ne démarre que si au moins un relais configuré est accessible. VniDrop n’utilise jamais de relais ni de découverte publics dans ce mode.", + "es": "El modo personalizado estricto no se inicia a menos que se pueda acceder al menos a un relé configurado. VniDrop nunca usa relés ni descubrimiento públicos en este modo.", + "it": "La modalità personalizzata rigorosa si avvia solo se almeno un relay configurato è raggiungibile. In questa modalità VniDrop non usa mai relay o rilevamento pubblici.", + "de": "Der strikt benutzerdefinierte Modus startet nur, wenn mindestens ein konfiguriertes Relay erreichbar ist. VniDrop verwendet in diesem Modus nie öffentliche Relays oder öffentliche Erkennung.", + "pt": "O modo personalizado estrito só inicia se pelo menos um retransmissor configurado estiver acessível. Neste modo, o VniDrop nunca utiliza retransmissores nem descoberta públicos.", + "pl": "Ścisły tryb niestandardowy uruchamia się tylko wtedy, gdy co najmniej jeden skonfigurowany przekaźnik jest dostępny. VniDrop nigdy nie używa w tym trybie publicznych przekaźników ani publicznego wykrywania.", + "nl": "De strikt aangepaste modus start alleen als minstens één ingestelde relay bereikbaar is. VniDrop gebruikt in deze modus nooit openbare relays of openbare detectie.", + "ru": "Строго пользовательский режим запускается, только если доступен хотя бы один настроенный ретранслятор. В этом режиме VniDrop никогда не использует публичные ретрансляторы или публичное обнаружение." } }, "relay_validation_duplicate_url": { diff --git a/shared/src/commonMain/composeResources/values-de/strings.xml b/shared/src/commonMain/composeResources/values-de/strings.xml index d3c1aa2..e03f090 100644 --- a/shared/src/commonMain/composeResources/values-de/strings.xml +++ b/shared/src/commonMain/composeResources/values-de/strings.xml @@ -177,15 +177,19 @@ Beim Anwenden wird die Netzwerkverbindung von VniDrop neu gestartet. Beenden Sie zuerst aktive Übertragungen und Freigaben. Vorhandene Einladungen müssen eventuell erneut geteilt werden. Geben Sie pro Zeile eine HTTPS-Relay-URL ein. Anmeldedaten in URLs werden nicht unterstützt. Das TLS-Zertifikat muss von einer öffentlich vertrauenswürdigen Zertifizierungsstelle ausgestellt sein. Relay-URLs - Automatisch + Automatisch (empfohlen) Verwendet die öffentliche Standard-Relay-Infrastruktur von VniDrop, wenn keine direkte Verbindung möglich ist. - Benutzerdefiniert - Verwendet ausschließlich die unten aufgeführten Relay-Server. + Strikt benutzerdefiniert + Verwendet nur die konfigurierten eigenen Relays oder Direktverbindungen. Meldet einen Fehler, wenn kein eigenes Relay erreichbar ist. + Benutzerdefiniert mit direktem Rückfall + Bevorzugt die konfigurierten eigenen Relays. Sind sie nicht verfügbar, werden nur Direktverbindungen verwendet. Öffentliche Relays werden nie genutzt. + Nur lokales Netzwerk + Deaktiviert alle Relays und erlaubt nur Direktverbindungen, hauptsächlich für Geräte im selben Netzwerk. Relays leiten verschlüsselten Datenverkehr weiter und können Ihre Dateien nicht lesen, ihr Betreiber kann jedoch Verbindungsmetadaten sehen. Relay-Server entfernen Die vorherigen Netzwerkeinstellungen konnten nicht wiederhergestellt werden. Starten Sie VniDrop neu und prüfen Sie Ihre Relay-Konfiguration. Netzwerkeinstellungen angewendet. - Der benutzerdefinierte Modus ist strikt: VniDrop greift weder auf öffentliche Relays noch auf öffentliche Erkennung zurück. Andere Geräte müssen Ihre konfigurierten Relays erreichen können. + Der strikt benutzerdefinierte Modus startet nur, wenn mindestens ein konfiguriertes Relay erreichbar ist. VniDrop verwendet in diesem Modus nie öffentliche Relays oder öffentliche Erkennung. Die Relay-URL in Zeile %1$d ist bereits zuvor eingetragen. Die Relay-URL in Zeile %1$d muss mit https:// beginnen. Die Relay-URL in Zeile %1$d ist ungültig. diff --git a/shared/src/commonMain/composeResources/values-es/strings.xml b/shared/src/commonMain/composeResources/values-es/strings.xml index ff99db7..5c32bb0 100644 --- a/shared/src/commonMain/composeResources/values-es/strings.xml +++ b/shared/src/commonMain/composeResources/values-es/strings.xml @@ -177,15 +177,19 @@ Al aplicar los ajustes, se reinicia la conexión de red de VniDrop. Detenga primero las transferencias y los elementos compartidos activos. Es posible que tenga que volver a compartir las invitaciones existentes. Introduzca una URL HTTPS de relé por línea. No se admiten credenciales en las URL. El certificado TLS debe ser emitido por una autoridad de certificación de confianza pública. URL de relés - Automático + Automático (recomendado) Usa la infraestructura pública de relés predeterminada de VniDrop cuando no haya una conexión directa disponible. - Personalizado - Usa únicamente los servidores de retransmisión indicados a continuación. + Personalizado estricto + Usa solo los relés personalizados configurados o conexiones directas. Informa de un error si no se puede establecer ningún relé personalizado. + Personalizado con conexión directa de reserva + Prefiere los relés personalizados configurados. Si no están disponibles, continúa solo con conexiones directas. Nunca usa relés públicos. + Solo red local + Desactiva todos los relés y permite solo conexiones directas, principalmente para dispositivos de la misma red. Los relés reenvían tráfico cifrado y no pueden leer sus archivos, pero su operador puede observar los metadatos de conexión. Eliminar servidor de retransmisión No se han podido restaurar los ajustes de red anteriores. Reinicie VniDrop y revise la configuración de relés. Ajustes de red aplicados. - El modo personalizado es estricto: VniDrop no recurrirá a relés públicos ni al descubrimiento público. Los demás dispositivos deben poder acceder a los relés configurados. + El modo personalizado estricto no se inicia a menos que se pueda acceder al menos a un relé configurado. VniDrop nunca usa relés ni descubrimiento públicos en este modo. La URL de relé de la línea %1$d duplica una entrada anterior. La URL de relé de la línea %1$d debe empezar por https://. La URL de relé de la línea %1$d no es válida. diff --git a/shared/src/commonMain/composeResources/values-fr/strings.xml b/shared/src/commonMain/composeResources/values-fr/strings.xml index 42b92bf..cee1744 100644 --- a/shared/src/commonMain/composeResources/values-fr/strings.xml +++ b/shared/src/commonMain/composeResources/values-fr/strings.xml @@ -177,15 +177,19 @@ L’application de ces réglages redémarre la connexion réseau de VniDrop. Arrêtez d’abord les transferts et partages actifs. Il peut être nécessaire de partager à nouveau les invitations existantes. Saisissez une URL de relais HTTPS par ligne. Les identifiants dans les URL ne sont pas pris en charge. Le certificat TLS doit être émis par une autorité de certification reconnue publiquement. URL des relais - Automatique + Automatique (recommandé) Utiliser l’infrastructure de relais publique par défaut de VniDrop lorsqu’une connexion directe est indisponible. - Personnalisé - Utiliser uniquement les serveurs relais ci-dessous. + Personnalisé strict + Utilise uniquement les relais personnalisés configurés ou les connexions directes. Signale une erreur si aucun relais personnalisé ne peut être établi. + Personnalisé avec repli direct + Préfère les relais personnalisés configurés. S’ils sont indisponibles, continue uniquement avec des connexions directes. N’utilise jamais les relais publics. + Réseau local uniquement + Désactive tous les relais et autorise uniquement les connexions directes, principalement pour les appareils sur le même réseau. Les relais transmettent du trafic chiffré et ne peuvent pas lire vos fichiers, mais leur opérateur peut observer les métadonnées de connexion. Supprimer le serveur relais Impossible de restaurer les réglages réseau précédents. Redémarrez VniDrop et vérifiez votre configuration de relais. Réglages réseau appliqués. - Le mode personnalisé est strict : VniDrop n’utilisera ni les relais publics ni la découverte publique comme solution de repli. Les autres appareils doivent pouvoir accéder aux relais configurés. + Le mode personnalisé strict ne démarre que si au moins un relais configuré est accessible. VniDrop n’utilise jamais de relais ni de découverte publics dans ce mode. L’URL de relais à la ligne %1$d est identique à une entrée précédente. L’URL de relais à la ligne %1$d doit commencer par https://. L’URL de relais à la ligne %1$d n’est pas valide. diff --git a/shared/src/commonMain/composeResources/values-it/strings.xml b/shared/src/commonMain/composeResources/values-it/strings.xml index 2d64e59..25e6703 100644 --- a/shared/src/commonMain/composeResources/values-it/strings.xml +++ b/shared/src/commonMain/composeResources/values-it/strings.xml @@ -177,15 +177,19 @@ L’applicazione riavvia la connessione di rete di VniDrop. Interrompa prima i trasferimenti e le condivisioni attivi. Potrebbe essere necessario condividere di nuovo gli inviti esistenti. Inserisca un URL relay HTTPS per riga. Le credenziali negli URL non sono supportate. Il certificato TLS deve essere emesso da un’autorità di certificazione pubblicamente attendibile. URL relay - Automatica + Automatica (consigliata) Usa l’infrastruttura relay pubblica predefinita di VniDrop quando non è disponibile una connessione diretta. - Personalizzata - Usa solo i server relay indicati di seguito. + Personalizzata rigorosa + Usa solo i relay personalizzati configurati o connessioni dirette. Segnala un errore se non è possibile stabilire alcun relay personalizzato. + Personalizzata con ripiego diretto + Preferisce i relay personalizzati configurati. Se non sono disponibili, continua solo con connessioni dirette. Non usa mai relay pubblici. + Solo rete locale + Disattiva tutti i relay e consente solo connessioni dirette, soprattutto per dispositivi sulla stessa rete. I relay inoltrano traffico cifrato e non possono leggere i suoi file, ma il loro operatore può osservare i metadati di connessione. Rimuovi server relay Impossibile ripristinare le impostazioni di rete precedenti. Riavvii VniDrop e verifichi la configurazione dei relay. Impostazioni di rete applicate. - La modalità personalizzata è rigorosa: VniDrop non userà relay pubblici o il rilevamento pubblico come ripiego. Gli altri dispositivi devono poter raggiungere i relay configurati. + La modalità personalizzata rigorosa si avvia solo se almeno un relay configurato è raggiungibile. In questa modalità VniDrop non usa mai relay o rilevamento pubblici. L’URL relay alla riga %1$d duplica una voce precedente. L’URL relay alla riga %1$d deve iniziare con https://. L’URL relay alla riga %1$d non è valido. diff --git a/shared/src/commonMain/composeResources/values-nl/strings.xml b/shared/src/commonMain/composeResources/values-nl/strings.xml index 72fb6d0..a8d340a 100644 --- a/shared/src/commonMain/composeResources/values-nl/strings.xml +++ b/shared/src/commonMain/composeResources/values-nl/strings.xml @@ -177,15 +177,19 @@ Bij het toepassen wordt de netwerkverbinding van VniDrop opnieuw gestart. Stop eerst actieve overdrachten en gedeelde items. Bestaande uitnodigingen moeten mogelijk opnieuw worden gedeeld. Voer per regel één HTTPS-relay-URL in. Aanmeldgegevens in URL\'s worden niet ondersteund. Het TLS-certificaat moet zijn uitgegeven door een openbaar vertrouwde certificeringsinstantie. Relay-URL\'s - Automatisch + Automatisch (aanbevolen) Gebruikt de standaard openbare relay-infrastructuur van VniDrop wanneer geen directe verbinding beschikbaar is. - Aangepast - Gebruikt alleen de onderstaande relayservers. + Strikt aangepast + Gebruikt alleen de ingestelde aangepaste relays of rechtstreekse verbindingen. Meldt een fout als geen aangepaste relay bereikbaar is. + Aangepast met directe terugval + Geeft de voorkeur aan de ingestelde aangepaste relays. Als die niet beschikbaar zijn, worden alleen rechtstreekse verbindingen gebruikt. Openbare relays worden nooit gebruikt. + Alleen lokaal netwerk + Schakelt alle relays uit en staat alleen rechtstreekse verbindingen toe, vooral voor apparaten in hetzelfde netwerk. Relays sturen versleuteld verkeer door en kunnen uw bestanden niet lezen, maar de beheerder kan verbindingsmetadata bekijken. Relayserver verwijderen De vorige netwerkinstellingen konden niet worden hersteld. Start VniDrop opnieuw en controleer uw relayconfiguratie. Netwerkinstellingen toegepast. - De aangepaste modus is strikt: VniDrop valt niet terug op openbare relays of openbare detectie. Andere apparaten moeten uw ingestelde relays kunnen bereiken. + De strikt aangepaste modus start alleen als minstens één ingestelde relay bereikbaar is. VniDrop gebruikt in deze modus nooit openbare relays of openbare detectie. De relay-URL op regel %1$d is gelijk aan een eerdere invoer. De relay-URL op regel %1$d moet beginnen met https://. De relay-URL op regel %1$d is ongeldig. diff --git a/shared/src/commonMain/composeResources/values-pl/strings.xml b/shared/src/commonMain/composeResources/values-pl/strings.xml index cb3a841..abd0c02 100644 --- a/shared/src/commonMain/composeResources/values-pl/strings.xml +++ b/shared/src/commonMain/composeResources/values-pl/strings.xml @@ -177,15 +177,19 @@ Zastosowanie ustawień ponownie uruchamia połączenie sieciowe VniDrop. Najpierw zatrzymaj aktywne transfery i udostępnienia. Istniejące zaproszenia mogą wymagać ponownego udostępnienia. Wprowadź po jednym adresie URL HTTPS przekaźnika w każdym wierszu. Dane logowania w adresach URL nie są obsługiwane. Certyfikat TLS musi być wystawiony przez publicznie zaufany urząd certyfikacji. Adresy URL przekaźników - Automatyczny + Automatyczny (zalecany) Używa domyślnej publicznej infrastruktury przekaźników VniDrop, gdy połączenie bezpośrednie jest niedostępne. - Niestandardowy - Używa wyłącznie poniższych serwerów przekaźnikowych. + Ścisły niestandardowy + Używa tylko skonfigurowanych własnych przekaźników lub połączeń bezpośrednich. Zgłasza błąd, jeśli nie można połączyć się z żadnym własnym przekaźnikiem. + Niestandardowy z trybem bezpośrednim + Preferuje skonfigurowane własne przekaźniki. Jeśli są niedostępne, kontynuuje tylko przez połączenia bezpośrednie. Nigdy nie używa publicznych przekaźników. + Tylko sieć lokalna + Wyłącza wszystkie przekaźniki i zezwala tylko na połączenia bezpośrednie, głównie dla urządzeń w tej samej sieci. Przekaźniki przesyłają zaszyfrowany ruch i nie mogą odczytać plików, ale ich operator może obserwować metadane połączenia. Usuń serwer przekaźnikowy Nie udało się przywrócić poprzednich ustawień sieci. Uruchom ponownie VniDrop i sprawdź konfigurację przekaźników. Zastosowano ustawienia sieci. - Tryb niestandardowy jest rygorystyczny: VniDrop nie użyje awaryjnie publicznych przekaźników ani publicznego wykrywania. Inne urządzenia muszą mieć dostęp do skonfigurowanych przekaźników. + Ścisły tryb niestandardowy uruchamia się tylko wtedy, gdy co najmniej jeden skonfigurowany przekaźnik jest dostępny. VniDrop nigdy nie używa w tym trybie publicznych przekaźników ani publicznego wykrywania. Adres URL przekaźnika w wierszu %1$d powtarza wcześniejszy wpis. Adres URL przekaźnika w wierszu %1$d musi zaczynać się od https://. Adres URL przekaźnika w wierszu %1$d jest nieprawidłowy. diff --git a/shared/src/commonMain/composeResources/values-pt/strings.xml b/shared/src/commonMain/composeResources/values-pt/strings.xml index b324393..dc43f18 100644 --- a/shared/src/commonMain/composeResources/values-pt/strings.xml +++ b/shared/src/commonMain/composeResources/values-pt/strings.xml @@ -177,15 +177,19 @@ A aplicação reinicia a ligação de rede do VniDrop. Pare primeiro as transferências e partilhas ativas. Poderá ser necessário voltar a partilhar os convites existentes. Introduza um URL HTTPS de retransmissor por linha. Não são suportadas credenciais nos URLs. O certificado TLS tem de ser emitido por uma autoridade de certificação publicamente reconhecida. URLs dos retransmissores - Automático + Automático (recomendado) Utiliza a infraestrutura pública de retransmissores predefinida do VniDrop quando não está disponível uma ligação direta. - Personalizado - Utiliza apenas os servidores de retransmissão abaixo. + Personalizado estrito + Utiliza apenas os retransmissores personalizados configurados ou ligações diretas. Apresenta um erro se não for possível estabelecer nenhum retransmissor personalizado. + Personalizado com alternativa direta + Prefere os retransmissores personalizados configurados. Se não estiverem disponíveis, continua apenas com ligações diretas. Nunca utiliza retransmissores públicos. + Apenas rede local + Desativa todos os retransmissores e permite apenas ligações diretas, principalmente para dispositivos na mesma rede. Os retransmissores encaminham tráfego cifrado e não conseguem ler os seus ficheiros, mas o operador pode observar metadados da ligação. Remover servidor de retransmissão Não foi possível restaurar as definições de rede anteriores. Reinicie o VniDrop e reveja a configuração dos retransmissores. Definições de rede aplicadas. - O modo personalizado é estrito: o VniDrop não recorrerá a retransmissores públicos nem à descoberta pública. Os outros dispositivos têm de conseguir aceder aos retransmissores configurados. + O modo personalizado estrito só inicia se pelo menos um retransmissor configurado estiver acessível. Neste modo, o VniDrop nunca utiliza retransmissores nem descoberta públicos. O URL do retransmissor na linha %1$d duplica uma entrada anterior. O URL do retransmissor na linha %1$d tem de começar por https://. O URL do retransmissor na linha %1$d não é válido. diff --git a/shared/src/commonMain/composeResources/values-ru/strings.xml b/shared/src/commonMain/composeResources/values-ru/strings.xml index 066de52..d424d06 100644 --- a/shared/src/commonMain/composeResources/values-ru/strings.xml +++ b/shared/src/commonMain/composeResources/values-ru/strings.xml @@ -177,15 +177,19 @@ При применении сетевое соединение VniDrop перезапускается. Сначала остановите активные передачи и раздачи. Возможно, существующие приглашения потребуется отправить повторно. Введите по одному HTTPS-адресу ретранслятора в строке. Учётные данные в URL-адресах не поддерживаются. Сертификат TLS должен быть выдан общедоступным доверенным центром сертификации. URL-адреса ретрансляторов - Автоматически + Автоматически (рекомендуется) Использовать стандартную публичную инфраструктуру ретрансляторов VniDrop, если прямое соединение недоступно. - Пользовательский - Использовать только указанные ниже серверы-ретрансляторы. + Строго пользовательский + Использует только настроенные пользовательские ретрансляторы или прямые соединения. Сообщает об ошибке, если ни один пользовательский ретранслятор недоступен. + Пользовательский с прямым резервом + Предпочитает настроенные пользовательские ретрансляторы. Если они недоступны, продолжает работу только через прямые соединения. Публичные ретрансляторы не используются. + Только локальная сеть + Отключает все ретрансляторы и разрешает только прямые соединения, прежде всего для устройств в одной сети. Ретрансляторы передают зашифрованный трафик и не могут читать ваши файлы, но их оператор может видеть метаданные соединения. Удалить сервер-ретранслятор Не удалось восстановить предыдущие настройки сети. Перезапустите VniDrop и проверьте конфигурацию ретрансляторов. Настройки сети применены. - Пользовательский режим работает строго: VniDrop не будет переключаться на публичные ретрансляторы или публичное обнаружение. Другие устройства должны иметь доступ к настроенным ретрансляторам. + Строго пользовательский режим запускается, только если доступен хотя бы один настроенный ретранслятор. В этом режиме VniDrop никогда не использует публичные ретрансляторы или публичное обнаружение. URL-адрес ретранслятора в строке %1$d повторяет предыдущую запись. URL-адрес ретранслятора в строке %1$d должен начинаться с https://. URL-адрес ретранслятора в строке %1$d недействителен. diff --git a/shared/src/commonMain/composeResources/values/strings.xml b/shared/src/commonMain/composeResources/values/strings.xml index c593cf1..507b498 100644 --- a/shared/src/commonMain/composeResources/values/strings.xml +++ b/shared/src/commonMain/composeResources/values/strings.xml @@ -177,15 +177,19 @@ Applying restarts VniDrop’s network connection. Stop active transfers and shares first. Existing invitations may need to be shared again. Enter one HTTPS relay URL per line. URL credentials are not supported. The TLS certificate must be issued by a publicly trusted certificate authority. Relay URLs - Automatic + Automatic (recommended) Use VniDrop’s default public relay infrastructure when a direct connection is unavailable. - Custom - Use only the relay servers below. + Strict custom + Use only the configured custom relays or direct connections. Report an error if no custom relay can be established. + Custom with direct fallback + Prefer the configured custom relays. If unavailable, continue with direct connections only. Never use public relays. + Local only + Disable all relays and allow only direct connections, primarily for devices on the same network. Relays forward encrypted traffic and cannot read your files, but their operator can observe connection metadata. Remove relay server Could not restore the previous network settings. Restart VniDrop and review your relay configuration. Network settings applied. - Custom mode is strict: VniDrop will not fall back to public relays or public discovery. Other devices must be able to reach your configured relays. + Strict custom mode will not start unless at least one configured relay is reachable. VniDrop never uses public relays or public discovery in this mode. Relay URL on line %1$d duplicates an earlier entry. Relay URL on line %1$d must start with https://. Relay URL on line %1$d is not valid. diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/core/CoreModels.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/core/CoreModels.kt index ca2168f..c6dac49 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/core/CoreModels.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/core/CoreModels.kt @@ -29,7 +29,9 @@ enum class ShareAccessPolicy { enum class RelayMode { Automatic, - Custom, + StrictCustom, + CustomWithDirectFallback, + LocalOnly, } data class RelaySettings( @@ -37,6 +39,9 @@ data class RelaySettings( val relayUrls: List = emptyList(), ) +val RelayMode.usesCustomRelayUrls: Boolean + get() = this == RelayMode.StrictCustom || this == RelayMode.CustomWithDirectFallback + enum class TransferDirection { Send, Receive, diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/core/CoreRepository.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/core/CoreRepository.kt index 8095b47..0694237 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/core/CoreRepository.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/core/CoreRepository.kt @@ -293,10 +293,18 @@ class CoreRepository( private fun RelaySettings.toNative(): CoreNetworkConfig = when (mode) { RelayMode.Automatic -> defaultCoreNetworkConfig() - RelayMode.Custom -> CoreNetworkConfig( - mode = CoreRelayMode.CUSTOM, + RelayMode.StrictCustom -> CoreNetworkConfig( + mode = CoreRelayMode.STRICT_CUSTOM, relayUrls = relayUrls, ) + RelayMode.CustomWithDirectFallback -> CoreNetworkConfig( + mode = CoreRelayMode.CUSTOM_WITH_DIRECT_FALLBACK, + relayUrls = relayUrls, + ) + RelayMode.LocalOnly -> CoreNetworkConfig( + mode = CoreRelayMode.LOCAL_ONLY, + relayUrls = emptyList(), + ) } private fun CoreEvent.toModel(): CoreEventModel = CoreEventModel( diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/NetworkSettings.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/NetworkSettings.kt index 3f3d43d..899b61c 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/NetworkSettings.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/NetworkSettings.kt @@ -12,6 +12,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import com.vnidrop.app.core.RelayMode +import com.vnidrop.app.core.usesCustomRelayUrls import com.vnidrop.app.ui.components.Field import com.vnidrop.app.ui.components.PrimaryButton import com.vnidrop.app.ui.icons.AppIcon @@ -30,7 +31,11 @@ import vnidrop.shared.generated.resources.relay_custom_urls_label import vnidrop.shared.generated.resources.relay_mode_automatic import vnidrop.shared.generated.resources.relay_mode_automatic_description import vnidrop.shared.generated.resources.relay_mode_custom +import vnidrop.shared.generated.resources.relay_mode_custom_direct_fallback +import vnidrop.shared.generated.resources.relay_mode_custom_direct_fallback_description import vnidrop.shared.generated.resources.relay_mode_custom_description +import vnidrop.shared.generated.resources.relay_mode_local_only +import vnidrop.shared.generated.resources.relay_mode_local_only_description import vnidrop.shared.generated.resources.relay_privacy_description import vnidrop.shared.generated.resources.relay_restore_failed import vnidrop.shared.generated.resources.relay_strict_warning @@ -73,15 +78,33 @@ internal fun NetworkSettings( ) SettingsDivider() RelayModeRow( - icon = AppIcon.Radio, + icon = AppIcon.Shield, title = stringResource(Res.string.relay_mode_custom), description = stringResource(Res.string.relay_mode_custom_description), - selected = state.relayMode == RelayMode.Custom, + selected = state.relayMode == RelayMode.StrictCustom, enabled = !state.isApplyingRelaySettings, - onClick = { onModeChanged(RelayMode.Custom) }, + onClick = { onModeChanged(RelayMode.StrictCustom) }, + ) + SettingsDivider() + RelayModeRow( + icon = AppIcon.Radio, + title = stringResource(Res.string.relay_mode_custom_direct_fallback), + description = stringResource(Res.string.relay_mode_custom_direct_fallback_description), + selected = state.relayMode == RelayMode.CustomWithDirectFallback, + enabled = !state.isApplyingRelaySettings, + onClick = { onModeChanged(RelayMode.CustomWithDirectFallback) }, + ) + SettingsDivider() + RelayModeRow( + icon = AppIcon.CloudOff, + title = stringResource(Res.string.relay_mode_local_only), + description = stringResource(Res.string.relay_mode_local_only_description), + selected = state.relayMode == RelayMode.LocalOnly, + enabled = !state.isApplyingRelaySettings, + onClick = { onModeChanged(RelayMode.LocalOnly) }, ) } - if (state.relayMode == RelayMode.Custom) { + if (state.relayMode.usesCustomRelayUrls) { Field( value = state.relayUrlsText, onValueChange = onUrlsChanged, @@ -94,12 +117,14 @@ internal fun NetworkSettings( color = colors.foregroundLighter, style = MaterialTheme.typography.bodySmall, ) - Text( - stringResource(Res.string.relay_strict_warning), - color = colors.foregroundLight, - style = MaterialTheme.typography.bodySmall, - fontWeight = FontWeight.Medium, - ) + if (state.relayMode == RelayMode.StrictCustom) { + Text( + stringResource(Res.string.relay_strict_warning), + color = colors.foregroundLight, + style = MaterialTheme.typography.bodySmall, + fontWeight = FontWeight.Medium, + ) + } Text( stringResource(Res.string.relay_privacy_description), color = colors.foregroundLighter, diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/RelaySettingsValidation.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/RelaySettingsValidation.kt index 1fc4099..3f11add 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/RelaySettingsValidation.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/RelaySettingsValidation.kt @@ -2,6 +2,7 @@ package com.vnidrop.app.feature.settings import com.vnidrop.app.core.RelayMode import com.vnidrop.app.core.RelaySettings +import com.vnidrop.app.core.usesCustomRelayUrls sealed interface RelaySettingsInputError { data object MissingUrl : RelaySettingsInputError @@ -21,7 +22,7 @@ fun validateRelaySettings( urlsText: String, retainedUrls: List = emptyList(), ): RelaySettingsValidation { - if (mode == RelayMode.Automatic) { + if (!mode.usesCustomRelayUrls) { return RelaySettingsValidation(RelaySettings(mode, retainedUrls)) } val lines = urlsText.lineSequence() @@ -49,7 +50,7 @@ fun validateRelaySettings( } } } - return RelaySettingsValidation(RelaySettings(RelayMode.Custom, normalized)) + return RelaySettingsValidation(RelaySettings(mode, normalized)) } private sealed interface RelayUrlResult { diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsOverview.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsOverview.kt index b0142e3..997559f 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsOverview.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsOverview.kt @@ -21,6 +21,8 @@ import vnidrop.shared.generated.resources.notifications_title import vnidrop.shared.generated.resources.preferences_title import vnidrop.shared.generated.resources.relay_mode_automatic import vnidrop.shared.generated.resources.relay_mode_custom +import vnidrop.shared.generated.resources.relay_mode_custom_direct_fallback +import vnidrop.shared.generated.resources.relay_mode_local_only import vnidrop.shared.generated.resources.settings_title import vnidrop.shared.generated.resources.settings_network_title import vnidrop.shared.generated.resources.storage_title @@ -91,7 +93,9 @@ internal fun SettingsOverview( @Composable private fun relayModeLabel(mode: RelayMode): String = when (mode) { RelayMode.Automatic -> stringResource(Res.string.relay_mode_automatic) - RelayMode.Custom -> stringResource(Res.string.relay_mode_custom) + RelayMode.StrictCustom -> stringResource(Res.string.relay_mode_custom) + RelayMode.CustomWithDirectFallback -> stringResource(Res.string.relay_mode_custom_direct_fallback) + RelayMode.LocalOnly -> stringResource(Res.string.relay_mode_local_only) } @Composable diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsViewModel.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsViewModel.kt index 38ddb9d..15f1e63 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsViewModel.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsViewModel.kt @@ -13,6 +13,7 @@ import com.vnidrop.app.core.ReceiveFolder import com.vnidrop.app.core.RelayMode import com.vnidrop.app.core.RelaySettings import com.vnidrop.app.core.TransferStatus +import com.vnidrop.app.core.usesCustomRelayUrls import com.vnidrop.app.diagnostics.BugReportDraft import com.vnidrop.app.diagnostics.BugReportService import com.vnidrop.app.diagnostics.DiagnosticsBuildConfig @@ -113,7 +114,7 @@ data class SettingsState( ) { val hasRelaySettingsChanges: Boolean get() = relayMode != savedRelaySettings.mode || - (relayMode == RelayMode.Custom && relayUrlsText != savedRelaySettings.relayUrls.joinToString("\n")) + (relayMode.usesCustomRelayUrls && relayUrlsText != savedRelaySettings.relayUrls.joinToString("\n")) } sealed interface SettingsEffect { diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/preferences/AppPreferencesRepository.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/preferences/AppPreferencesRepository.kt index 57d120a..6f3090d 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/preferences/AppPreferencesRepository.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/preferences/AppPreferencesRepository.kt @@ -58,14 +58,14 @@ class AppPreferencesRepository( ) : PreferencesRepository { override val preferences: Flow = dataStore.data .catch { - emit(preferencesOf(PreferenceKeys.RelayMode to RelayMode.Custom.name)) + emit(preferencesOf(PreferenceKeys.RelayMode to RelayMode.StrictCustom.name)) } .map { prefs -> val storedRelayMode = prefs[PreferenceKeys.RelayMode] val parsedRelayMode = storedRelayMode?.let(::relayModeOrNull) val relayMode = when (storedRelayMode) { null -> RelayMode.Automatic - else -> parsedRelayMode ?: RelayMode.Custom + else -> parsedRelayMode ?: RelayMode.StrictCustom } val relayUrls = if (storedRelayMode != null && parsedRelayMode == null) { emptyList() @@ -199,6 +199,7 @@ private fun themeModeOrNull(raw: String): ThemeMode? = runCatching { ThemeMode.valueOf(raw) }.getOrNull() private fun relayModeOrNull(raw: String): RelayMode? = - runCatching { RelayMode.valueOf(raw) }.getOrNull() + if (raw == "Custom") RelayMode.StrictCustom + else runCatching { RelayMode.valueOf(raw) }.getOrNull() private const val AppPreferencesFileName = "app_preferences.preferences_pb" diff --git a/shared/src/commonTest/kotlin/com/vnidrop/app/feature/ViewModelsTest.kt b/shared/src/commonTest/kotlin/com/vnidrop/app/feature/ViewModelsTest.kt index 2283cdd..25e47e6 100644 --- a/shared/src/commonTest/kotlin/com/vnidrop/app/feature/ViewModelsTest.kt +++ b/shared/src/commonTest/kotlin/com/vnidrop/app/feature/ViewModelsTest.kt @@ -86,7 +86,7 @@ class ViewModelsTest { @Test fun appViewModelInitializesCoreWithSavedRelaySettings() = runTest { Dispatchers.setMain(StandardTestDispatcher(testScheduler)) - val custom = RelaySettings(RelayMode.Custom, listOf("https://relay.example.com")) + val custom = RelaySettings(RelayMode.StrictCustom, listOf("https://relay.example.com")) val preferences = preferences().apply { mutablePreferences.value = mutablePreferences.value.copy(relaySettings = custom) } @@ -144,13 +144,13 @@ class ViewModelsTest { val viewModel = settingsViewModel(preferences = preferences, repository = core) advanceUntilIdle() - viewModel.setRelayMode(RelayMode.Custom) + viewModel.setRelayMode(RelayMode.StrictCustom) viewModel.setRelayUrlsText(" HTTPS://Relay.Example.com/ \nhttps://backup.example.com:443") viewModel.applyRelaySettings() advanceUntilIdle() val expected = RelaySettings( - RelayMode.Custom, + RelayMode.StrictCustom, listOf("https://relay.example.com", "https://backup.example.com"), ) assertEquals(expected, preferences.mutablePreferences.value.relaySettings) @@ -159,6 +159,35 @@ class ViewModelsTest { assertFalse(viewModel.state.value.hasRelaySettingsChanges) } + @Test + fun settingsAppliesCustomFallbackAndLocalOnlyWhileRetainingRelayDrafts() = runTest { + Dispatchers.setMain(StandardTestDispatcher(testScheduler)) + val preferences = preferences() + val core = FakeCoreGateway() + val viewModel = settingsViewModel(preferences = preferences, repository = core) + advanceUntilIdle() + + viewModel.setRelayMode(RelayMode.CustomWithDirectFallback) + viewModel.setRelayUrlsText("https://relay.example.com") + viewModel.applyRelaySettings() + advanceUntilIdle() + viewModel.setRelayMode(RelayMode.LocalOnly) + viewModel.applyRelaySettings() + advanceUntilIdle() + + val fallback = RelaySettings( + RelayMode.CustomWithDirectFallback, + listOf("https://relay.example.com"), + ) + val localOnly = RelaySettings( + RelayMode.LocalOnly, + listOf("https://relay.example.com"), + ) + assertEquals(listOf(fallback, localOnly), core.initializedRelaySettings) + assertEquals(localOnly, preferences.mutablePreferences.value.relaySettings) + assertFalse(viewModel.state.value.hasRelaySettingsChanges) + } + @Test fun settingsIgnoresDuplicateApplyBeforeRestartBegins() = runTest { Dispatchers.setMain(StandardTestDispatcher(testScheduler)) @@ -166,7 +195,7 @@ class ViewModelsTest { val viewModel = settingsViewModel(repository = core) advanceUntilIdle() - viewModel.setRelayMode(RelayMode.Custom) + viewModel.setRelayMode(RelayMode.StrictCustom) viewModel.setRelayUrlsText("https://relay.example.com") viewModel.applyRelaySettings() viewModel.applyRelaySettings() @@ -186,7 +215,7 @@ class ViewModelsTest { advanceUntilIdle() assertEquals("endpoint", viewModel.state.value.endpointId) - viewModel.setRelayMode(RelayMode.Custom) + viewModel.setRelayMode(RelayMode.StrictCustom) viewModel.setRelayUrlsText("https://relay.example.com") viewModel.applyRelaySettings() advanceUntilIdle() @@ -200,7 +229,7 @@ class ViewModelsTest { Dispatchers.setMain(StandardTestDispatcher(testScheduler)) val core = FakeCoreGateway().apply { initializeHandler = { settings -> - if (settings.mode == RelayMode.Custom) Result.failure(IllegalStateException("unreachable")) + if (settings.mode == RelayMode.StrictCustom) Result.failure(IllegalStateException("unreachable")) else Result.success(Unit) } } @@ -208,14 +237,14 @@ class ViewModelsTest { val viewModel = settingsViewModel(preferences = preferences, repository = core) advanceUntilIdle() - viewModel.setRelayMode(RelayMode.Custom) + viewModel.setRelayMode(RelayMode.StrictCustom) viewModel.setRelayUrlsText("https://relay.example.com") viewModel.applyRelaySettings() advanceUntilIdle() assertEquals( listOf( - RelaySettings(RelayMode.Custom, listOf("https://relay.example.com")), + RelaySettings(RelayMode.StrictCustom, listOf("https://relay.example.com")), RelaySettings(), ), core.initializedRelaySettings, diff --git a/shared/src/commonTest/kotlin/com/vnidrop/app/feature/settings/RelaySettingsValidationTest.kt b/shared/src/commonTest/kotlin/com/vnidrop/app/feature/settings/RelaySettingsValidationTest.kt index 72f9f3d..639407e 100644 --- a/shared/src/commonTest/kotlin/com/vnidrop/app/feature/settings/RelaySettingsValidationTest.kt +++ b/shared/src/commonTest/kotlin/com/vnidrop/app/feature/settings/RelaySettingsValidationTest.kt @@ -6,16 +6,46 @@ import kotlin.test.Test import kotlin.test.assertEquals class RelaySettingsValidationTest { + @Test + fun customFallbackUsesTheSameValidatedRelayList() { + val result = validateRelaySettings( + mode = RelayMode.CustomWithDirectFallback, + urlsText = "https://relay.example.com/", + ) + + assertEquals( + RelaySettings( + RelayMode.CustomWithDirectFallback, + listOf("https://relay.example.com"), + ), + result.settings, + ) + } + + @Test + fun modesWithoutCustomRelaysRetainTheLastRelayList() { + val retained = listOf("https://relay.example.com") + + assertEquals( + RelaySettings(RelayMode.Automatic, retained), + validateRelaySettings(RelayMode.Automatic, "invalid", retained).settings, + ) + assertEquals( + RelaySettings(RelayMode.LocalOnly, retained), + validateRelaySettings(RelayMode.LocalOnly, "invalid", retained).settings, + ) + } + @Test fun customRelayUrlsAreNormalized() { val result = validateRelaySettings( - mode = RelayMode.Custom, + mode = RelayMode.StrictCustom, urlsText = " HTTPS://Relay.Example.com/ \nhttps://[2001:DB8::1]:443", ) assertEquals( RelaySettings( - RelayMode.Custom, + RelayMode.StrictCustom, listOf("https://relay.example.com", "https://[2001:db8::1]"), ), result.settings, @@ -27,18 +57,18 @@ class RelaySettingsValidationTest { fun customRelayUrlsRequireHttpsAndRootPath() { assertEquals( RelaySettingsInputError.HttpsRequired(1), - validateRelaySettings(RelayMode.Custom, "http://relay.example.com").error, + validateRelaySettings(RelayMode.StrictCustom, "http://relay.example.com").error, ) assertEquals( RelaySettingsInputError.InvalidUrl(1), - validateRelaySettings(RelayMode.Custom, "https://relay.example.com/custom").error, + validateRelaySettings(RelayMode.StrictCustom, "https://relay.example.com/custom").error, ) } @Test fun duplicateNormalizedRelayUrlsAreRejected() { val result = validateRelaySettings( - RelayMode.Custom, + RelayMode.StrictCustom, "https://relay.example.com\nHTTPS://RELAY.EXAMPLE.COM:443/", ) @@ -48,13 +78,13 @@ class RelaySettingsValidationTest { @Test fun structurallyValidIpv6RelayUrlsAreAccepted() { val result = validateRelaySettings( - RelayMode.Custom, + RelayMode.StrictCustom, "https://[::1]:443\nhttps://[2001:db8::1]\nhttps://[::ffff:192.0.2.1]", ) assertEquals( RelaySettings( - RelayMode.Custom, + RelayMode.StrictCustom, listOf( "https://[::1]", "https://[2001:db8::1]", @@ -75,7 +105,7 @@ class RelaySettingsValidationTest { ).forEach { url -> assertEquals( RelaySettingsInputError.InvalidUrl(1), - validateRelaySettings(RelayMode.Custom, url).error, + validateRelaySettings(RelayMode.StrictCustom, url).error, url, ) } diff --git a/shared/src/jvmTest/kotlin/com/vnidrop/app/preferences/AppPreferencesRepositoryTest.kt b/shared/src/jvmTest/kotlin/com/vnidrop/app/preferences/AppPreferencesRepositoryTest.kt index 062b12f..0959281 100644 --- a/shared/src/jvmTest/kotlin/com/vnidrop/app/preferences/AppPreferencesRepositoryTest.kt +++ b/shared/src/jvmTest/kotlin/com/vnidrop/app/preferences/AppPreferencesRepositoryTest.kt @@ -33,7 +33,7 @@ class AppPreferencesRepositoryTest { fun customRelaySettingsArePersisted() = runBlocking { val repository = repositoryForTest() val custom = RelaySettings( - mode = RelayMode.Custom, + mode = RelayMode.StrictCustom, relayUrls = listOf("https://relay.example.com", "https://backup.example.com:443"), ) @@ -42,6 +42,29 @@ class AppPreferencesRepositoryTest { assertEquals(custom, repository.preferences.first().relaySettings) } + @Test + fun legacyCustomRelayModeMigratesToStrictCustom() = runBlocking { + val directory = Files.createTempDirectory("vnidrop-preferences-test").toString() + val dataStore = createAppPreferencesDataStore(directory) + dataStore.edit { preferences -> + preferences[stringPreferencesKey("relay_mode")] = "Custom" + preferences[stringPreferencesKey("relay_urls")] = "https://relay.example.com" + } + val repository = AppPreferencesRepository( + dataStore = dataStore, + defaults = AppPreferencesDefaults( + username = "Device Name", + receiveFolder = defaultFolder, + themeMode = ThemeMode.System, + ), + ) + + assertEquals( + RelaySettings(RelayMode.StrictCustom, listOf("https://relay.example.com")), + repository.preferences.first().relaySettings, + ) + } + @Test fun unknownStoredRelayModeFailsClosedAndCanBeReset() = runBlocking { val directory = Files.createTempDirectory("vnidrop-preferences-test").toString() @@ -59,7 +82,7 @@ class AppPreferencesRepositoryTest { ), ) - assertEquals(RelaySettings(RelayMode.Custom), repository.preferences.first().relaySettings) + assertEquals(RelaySettings(RelayMode.StrictCustom), repository.preferences.first().relaySettings) repository.setRelaySettings(RelaySettings()) assertEquals(RelaySettings(), repository.preferences.first().relaySettings) @@ -85,7 +108,7 @@ class AppPreferencesRepositoryTest { ), ) - assertEquals(RelaySettings(RelayMode.Custom), repository.preferences.first().relaySettings) + assertEquals(RelaySettings(RelayMode.StrictCustom), repository.preferences.first().relaySettings) } @Test diff --git a/shared/src/jvmTest/kotlin/com/vnidrop/app/ui/FoundationComposeTest.kt b/shared/src/jvmTest/kotlin/com/vnidrop/app/ui/FoundationComposeTest.kt index dcf63d6..75c35dc 100644 --- a/shared/src/jvmTest/kotlin/com/vnidrop/app/ui/FoundationComposeTest.kt +++ b/shared/src/jvmTest/kotlin/com/vnidrop/app/ui/FoundationComposeTest.kt @@ -145,10 +145,10 @@ class FoundationComposeTest { onNodeWithText("Network").performClick() onNodeWithText("Device ID: endpoint-for-allowlist").assertIsDisplayed() - onNodeWithText("Custom").performClick() + onNodeWithText("Strict custom").performClick() onNodeWithText( - "Custom mode is strict: VniDrop will not fall back to public relays or public discovery. " + - "Other devices must be able to reach your configured relays.", + "Strict custom mode will not start unless at least one configured relay is reachable. " + + "VniDrop never uses public relays or public discovery in this mode.", ).assertIsDisplayed() onNodeWithText("Apply network settings").performClick() runOnIdle { assertTrue(applied) } From 7592d49a59ae188935a64f4f7034d046c36d5516 Mon Sep 17 00:00:00 2001 From: Hammed Abass Date: Thu, 23 Jul 2026 16:50:27 +0200 Subject: [PATCH 03/10] fix(deps): update iroh to 1.0.3 --- Cargo.lock | 159 +++++--------------------------------- crates/vnidrop/Cargo.toml | 4 +- 2 files changed, 21 insertions(+), 142 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index fdc4485..4a7f58f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -507,9 +507,9 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] name = "cfg_aliases" -version = "0.2.1" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" [[package]] name = "chacha20" @@ -827,41 +827,6 @@ dependencies = [ "syn 2.0.118", ] -[[package]] -name = "darling" -version = "0.20.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" -dependencies = [ - "darling_core", - "darling_macro", -] - -[[package]] -name = "darling_core" -version = "0.20.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" -dependencies = [ - "fnv", - "ident_case", - "proc-macro2", - "quote", - "strsim", - "syn 2.0.118", -] - -[[package]] -name = "darling_macro" -version = "0.20.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" -dependencies = [ - "darling_core", - "quote", - "syn 2.0.118", -] - [[package]] name = "dashmap" version = "6.2.1" @@ -899,7 +864,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccc2776f0c61eca1ca32528f85548abd1a4be8fb53d1b21c013e4f18da1e7090" dependencies = [ "data-encoding", - "syn 2.0.118", + "syn 1.0.109", ] [[package]] @@ -944,37 +909,6 @@ version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" -[[package]] -name = "derive_builder" -version = "0.20.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947" -dependencies = [ - "derive_builder_macro", -] - -[[package]] -name = "derive_builder_core" -version = "0.20.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" -dependencies = [ - "darling", - "proc-macro2", - "quote", - "syn 2.0.118", -] - -[[package]] -name = "derive_builder_macro" -version = "0.20.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" -dependencies = [ - "derive_builder_core", - "syn 2.0.118", -] - [[package]] name = "derive_more" version = "2.1.1" @@ -1933,12 +1867,6 @@ dependencies = [ "zerovec", ] -[[package]] -name = "ident_case" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" - [[package]] name = "identity-hash" version = "0.1.0" @@ -2038,9 +1966,9 @@ dependencies = [ [[package]] name = "iroh" -version = "1.0.1" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a2e38557969901f8b356d1ebd882253bab98cc81500d53e7bcbf33f56082303" +checksum = "460de6bc52163b41b1646931f2897e5ab986f0966ade444467fec25024751a72" dependencies = [ "backon", "blake3", @@ -2089,9 +2017,9 @@ dependencies = [ [[package]] name = "iroh-base" -version = "1.0.1" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61cdf012298adc13f5c2c821ad87214fecc9ac54c751301d45bf62b229a85da1" +checksum = "6be73e16ee21c923aca9b3121aaa0db936f7c7ecc156ff47b8dac944c68d59a8" dependencies = [ "curve25519-dalek", "data-encoding", @@ -2149,9 +2077,9 @@ dependencies = [ [[package]] name = "iroh-dns" -version = "1.0.1" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c24b83aae5ed4eced1c3724204c083c28c84c5d225a88e277eceeccce3dc3bd" +checksum = "46f6a9b39d18e6345f5c151afd299f2488e2cb5c520fe41b107b6bd3dc4c3349" dependencies = [ "arc-swap", "cfg_aliases", @@ -2221,9 +2149,9 @@ dependencies = [ [[package]] name = "iroh-relay" -version = "1.0.1" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f16a5505939f9250297ff2f1210b5142d13618f496eab03ce3f964fc47e2e2b" +checksum = "24bd586cf927f7b700f56ec3639b53cb5fa901ce284784051ff71092bfbf8193" dependencies = [ "blake3", "bytes", @@ -2273,7 +2201,6 @@ dependencies = [ "tracing", "tracing-subscriber", "url", - "vergen-gitcl", "webpki-roots", "ws_stream_wasm", ] @@ -2813,9 +2740,9 @@ dependencies = [ [[package]] name = "noq" -version = "1.0.1" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4bf95190af1bd4a00a10e8255ca0c8ddd9e9a9f5e79151d7a7eb6d56aff5dc89" +checksum = "e11803df44ac03a30988d61585ea50885d5428e42da944fe1e498799da7886a2" dependencies = [ "bytes", "cfg_aliases", @@ -2835,9 +2762,9 @@ dependencies = [ [[package]] name = "noq-proto" -version = "1.0.1" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aa6c890013591e709a3e45dd53501351b7e27e7ff3c7e9fc3dce43e300e7e9d3" +checksum = "334c3c9833f7b2c573cceb9896ddc7aaeb58c8807cbb63211b24d1fe88bf866e" dependencies = [ "aes-gcm", "bytes", @@ -2864,9 +2791,9 @@ dependencies = [ [[package]] name = "noq-udp" -version = "1.0.1" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3137a52df66c20090a889828d1c655f21f52294cba64e5c4fbb04fc83eee7c8e" +checksum = "bde7a5d5102f1cff03d482240f0ed20551661f63663620f4b26112ed751165e9" dependencies = [ "cfg_aliases", "libc", @@ -2978,15 +2905,6 @@ dependencies = [ "syn 2.0.118", ] -[[package]] -name = "num_threads" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" -dependencies = [ - "libc", -] - [[package]] name = "objc2" version = "0.6.4" @@ -4632,7 +4550,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.4.3", + "getrandom 0.3.4", "once_cell", "rustix", "windows-sys 0.61.2", @@ -4704,9 +4622,7 @@ checksum = "18dfaaeddcb932337b5e7866ee7d0ce9b76d2fd092997146f187ec09b4558a50" dependencies = [ "deranged", "js-sys", - "libc", "num-conv", - "num_threads", "powerfmt", "serde_core", "time-core", @@ -5294,43 +5210,6 @@ version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" -[[package]] -name = "vergen" -version = "9.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b849a1f6d8639e8de261e81ee0fc881e3e3620db1af9f2e0da015d4382ceaf75" -dependencies = [ - "anyhow", - "derive_builder", - "rustversion", - "vergen-lib", -] - -[[package]] -name = "vergen-gitcl" -version = "9.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77ff3b5300a085d6bcd8fc96a507f706a28ae3814693236c9b409db71a1d15b9" -dependencies = [ - "anyhow", - "derive_builder", - "rustversion", - "time", - "vergen", - "vergen-lib", -] - -[[package]] -name = "vergen-lib" -version = "9.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b34a29ba7e9c59e62f229ae1932fb1b8fb8a6fdcc99215a641913f5f5a59a569" -dependencies = [ - "anyhow", - "derive_builder", - "rustversion", -] - [[package]] name = "version_check" version = "0.9.5" @@ -5563,7 +5442,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.48.0", ] [[package]] diff --git a/crates/vnidrop/Cargo.toml b/crates/vnidrop/Cargo.toml index 9eeaf94..cfa2f4d 100644 --- a/crates/vnidrop/Cargo.toml +++ b/crates/vnidrop/Cargo.toml @@ -16,7 +16,7 @@ blake3 = "1.8.3" data-encoding = "2.11.0" futures = "0.3" futures-lite = "2.6.1" -iroh = "1.0.0" +iroh = "1.0.3" iroh-blobs = "0.103.0" irpc = "0.17.0" irpc-iroh = "0.17.0" @@ -36,5 +36,5 @@ uuid = { version = "1.23.3", features = ["v4", "serde"] } walkdir = "2.5.0" [dev-dependencies] -iroh-relay = { version = "1.0.0", features = ["server"] } +iroh-relay = { version = "1.0.3", features = ["server"] } tempfile = "3.27.0" From efb3c474d120cd132474e4ad94c9073fb966958f Mon Sep 17 00:00:00 2001 From: Hammed Abass Date: Thu, 23 Jul 2026 19:03:19 +0200 Subject: [PATCH 04/10] feat(settings): align relay and storage controls --- .../composeResources/values-de/strings.xml | 2 +- .../composeResources/values-es/strings.xml | 2 +- .../composeResources/values-fr/strings.xml | 2 +- .../composeResources/values-it/strings.xml | 2 +- .../composeResources/values-nl/strings.xml | 2 +- .../composeResources/values-pl/strings.xml | 2 +- .../composeResources/values-pt/strings.xml | 2 +- .../composeResources/values-ru/strings.xml | 2 +- .../composeResources/values/strings.xml | 2 +- .../app/feature/settings/NetworkSettings.kt | 165 ++++++++++++++---- .../settings/RelaySettingsValidation.kt | 6 +- .../app/feature/settings/SettingsOverview.kt | 20 ++- .../app/feature/settings/SettingsRoute.kt | 4 +- .../app/feature/settings/SettingsScreen.kt | 20 ++- .../app/feature/settings/SettingsViewModel.kt | 47 ++++- .../app/feature/settings/StorageSettings.kt | 69 ++++++-- .../com/vnidrop/app/feature/ViewModelsTest.kt | 44 ++++- .../settings/RelaySettingsValidationTest.kt | 18 +- .../vnidrop/app/ui/FoundationComposeTest.kt | 22 ++- 19 files changed, 326 insertions(+), 107 deletions(-) diff --git a/shared/src/commonMain/composeResources/values-de/strings.xml b/shared/src/commonMain/composeResources/values-de/strings.xml index e03f090..a4b86a1 100644 --- a/shared/src/commonMain/composeResources/values-de/strings.xml +++ b/shared/src/commonMain/composeResources/values-de/strings.xml @@ -175,7 +175,7 @@ Beenden Sie alle aktiven Übertragungen und Freigaben, bevor Sie die Netzwerkeinstellungen anwenden. Diese Einstellungen konnten nicht angewendet werden. Die vorherigen Netzwerkeinstellungen wurden wiederhergestellt. Beim Anwenden wird die Netzwerkverbindung von VniDrop neu gestartet. Beenden Sie zuerst aktive Übertragungen und Freigaben. Vorhandene Einladungen müssen eventuell erneut geteilt werden. - Geben Sie pro Zeile eine HTTPS-Relay-URL ein. Anmeldedaten in URLs werden nicht unterstützt. Das TLS-Zertifikat muss von einer öffentlich vertrauenswürdigen Zertifizierungsstelle ausgestellt sein. + Fügen Sie jede HTTPS-Relay-URL separat hinzu. Anmeldedaten in URLs werden nicht unterstützt. Das TLS-Zertifikat muss von einer öffentlich vertrauenswürdigen Zertifizierungsstelle ausgestellt sein. Relay-URLs Automatisch (empfohlen) Verwendet die öffentliche Standard-Relay-Infrastruktur von VniDrop, wenn keine direkte Verbindung möglich ist. diff --git a/shared/src/commonMain/composeResources/values-es/strings.xml b/shared/src/commonMain/composeResources/values-es/strings.xml index 5c32bb0..826cf85 100644 --- a/shared/src/commonMain/composeResources/values-es/strings.xml +++ b/shared/src/commonMain/composeResources/values-es/strings.xml @@ -175,7 +175,7 @@ Detenga todas las transferencias y elementos compartidos activos antes de aplicar los ajustes de red. No se han podido aplicar estos ajustes. Se han restaurado los ajustes de red anteriores. Al aplicar los ajustes, se reinicia la conexión de red de VniDrop. Detenga primero las transferencias y los elementos compartidos activos. Es posible que tenga que volver a compartir las invitaciones existentes. - Introduzca una URL HTTPS de relé por línea. No se admiten credenciales en las URL. El certificado TLS debe ser emitido por una autoridad de certificación de confianza pública. + Añada cada URL HTTPS de relé por separado. No se admiten credenciales en las URL. El certificado TLS debe ser emitido por una autoridad de certificación de confianza pública. URL de relés Automático (recomendado) Usa la infraestructura pública de relés predeterminada de VniDrop cuando no haya una conexión directa disponible. diff --git a/shared/src/commonMain/composeResources/values-fr/strings.xml b/shared/src/commonMain/composeResources/values-fr/strings.xml index cee1744..2d646c6 100644 --- a/shared/src/commonMain/composeResources/values-fr/strings.xml +++ b/shared/src/commonMain/composeResources/values-fr/strings.xml @@ -175,7 +175,7 @@ Arrêtez tous les transferts et partages actifs avant d’appliquer les réglages réseau. Impossible d’appliquer ces réglages. Les réglages réseau précédents ont été restaurés. L’application de ces réglages redémarre la connexion réseau de VniDrop. Arrêtez d’abord les transferts et partages actifs. Il peut être nécessaire de partager à nouveau les invitations existantes. - Saisissez une URL de relais HTTPS par ligne. Les identifiants dans les URL ne sont pas pris en charge. Le certificat TLS doit être émis par une autorité de certification reconnue publiquement. + Ajoutez chaque URL de relais HTTPS séparément. Les identifiants dans les URL ne sont pas pris en charge. Le certificat TLS doit être émis par une autorité de certification reconnue publiquement. URL des relais Automatique (recommandé) Utiliser l’infrastructure de relais publique par défaut de VniDrop lorsqu’une connexion directe est indisponible. diff --git a/shared/src/commonMain/composeResources/values-it/strings.xml b/shared/src/commonMain/composeResources/values-it/strings.xml index 25e6703..4d41143 100644 --- a/shared/src/commonMain/composeResources/values-it/strings.xml +++ b/shared/src/commonMain/composeResources/values-it/strings.xml @@ -175,7 +175,7 @@ Interrompa tutti i trasferimenti e le condivisioni attivi prima di applicare le impostazioni di rete. Impossibile applicare queste impostazioni. Sono state ripristinate le impostazioni di rete precedenti. L’applicazione riavvia la connessione di rete di VniDrop. Interrompa prima i trasferimenti e le condivisioni attivi. Potrebbe essere necessario condividere di nuovo gli inviti esistenti. - Inserisca un URL relay HTTPS per riga. Le credenziali negli URL non sono supportate. Il certificato TLS deve essere emesso da un’autorità di certificazione pubblicamente attendibile. + Aggiungi separatamente ogni URL relay HTTPS. Le credenziali negli URL non sono supportate. Il certificato TLS deve essere emesso da un’autorità di certificazione pubblicamente attendibile. URL relay Automatica (consigliata) Usa l’infrastruttura relay pubblica predefinita di VniDrop quando non è disponibile una connessione diretta. diff --git a/shared/src/commonMain/composeResources/values-nl/strings.xml b/shared/src/commonMain/composeResources/values-nl/strings.xml index a8d340a..5632874 100644 --- a/shared/src/commonMain/composeResources/values-nl/strings.xml +++ b/shared/src/commonMain/composeResources/values-nl/strings.xml @@ -175,7 +175,7 @@ Stop alle actieve overdrachten en gedeelde items voordat u de netwerkinstellingen toepast. Deze instellingen konden niet worden toegepast. De vorige netwerkinstellingen zijn hersteld. Bij het toepassen wordt de netwerkverbinding van VniDrop opnieuw gestart. Stop eerst actieve overdrachten en gedeelde items. Bestaande uitnodigingen moeten mogelijk opnieuw worden gedeeld. - Voer per regel één HTTPS-relay-URL in. Aanmeldgegevens in URL\'s worden niet ondersteund. Het TLS-certificaat moet zijn uitgegeven door een openbaar vertrouwde certificeringsinstantie. + Voeg elke HTTPS-relay-URL afzonderlijk toe. Aanmeldgegevens in URL\'s worden niet ondersteund. Het TLS-certificaat moet zijn uitgegeven door een openbaar vertrouwde certificeringsinstantie. Relay-URL\'s Automatisch (aanbevolen) Gebruikt de standaard openbare relay-infrastructuur van VniDrop wanneer geen directe verbinding beschikbaar is. diff --git a/shared/src/commonMain/composeResources/values-pl/strings.xml b/shared/src/commonMain/composeResources/values-pl/strings.xml index abd0c02..5d5c8f4 100644 --- a/shared/src/commonMain/composeResources/values-pl/strings.xml +++ b/shared/src/commonMain/composeResources/values-pl/strings.xml @@ -175,7 +175,7 @@ Zatrzymaj wszystkie aktywne transfery i udostępnienia przed zastosowaniem ustawień sieci. Nie udało się zastosować tych ustawień. Przywrócono poprzednie ustawienia sieci. Zastosowanie ustawień ponownie uruchamia połączenie sieciowe VniDrop. Najpierw zatrzymaj aktywne transfery i udostępnienia. Istniejące zaproszenia mogą wymagać ponownego udostępnienia. - Wprowadź po jednym adresie URL HTTPS przekaźnika w każdym wierszu. Dane logowania w adresach URL nie są obsługiwane. Certyfikat TLS musi być wystawiony przez publicznie zaufany urząd certyfikacji. + Dodaj każdy adres URL HTTPS serwera przekaźnikowego osobno. Dane logowania w adresach URL nie są obsługiwane. Certyfikat TLS musi być wystawiony przez publicznie zaufany urząd certyfikacji. Adresy URL przekaźników Automatyczny (zalecany) Używa domyślnej publicznej infrastruktury przekaźników VniDrop, gdy połączenie bezpośrednie jest niedostępne. diff --git a/shared/src/commonMain/composeResources/values-pt/strings.xml b/shared/src/commonMain/composeResources/values-pt/strings.xml index dc43f18..26e59da 100644 --- a/shared/src/commonMain/composeResources/values-pt/strings.xml +++ b/shared/src/commonMain/composeResources/values-pt/strings.xml @@ -175,7 +175,7 @@ Pare todas as transferências e partilhas ativas antes de aplicar as definições de rede. Não foi possível aplicar estas definições. As definições de rede anteriores foram restauradas. A aplicação reinicia a ligação de rede do VniDrop. Pare primeiro as transferências e partilhas ativas. Poderá ser necessário voltar a partilhar os convites existentes. - Introduza um URL HTTPS de retransmissor por linha. Não são suportadas credenciais nos URLs. O certificado TLS tem de ser emitido por uma autoridade de certificação publicamente reconhecida. + Adicione cada URL HTTPS de retransmissor separadamente. Não são suportadas credenciais nos URLs. O certificado TLS tem de ser emitido por uma autoridade de certificação publicamente reconhecida. URLs dos retransmissores Automático (recomendado) Utiliza a infraestrutura pública de retransmissores predefinida do VniDrop quando não está disponível uma ligação direta. diff --git a/shared/src/commonMain/composeResources/values-ru/strings.xml b/shared/src/commonMain/composeResources/values-ru/strings.xml index d424d06..4fac253 100644 --- a/shared/src/commonMain/composeResources/values-ru/strings.xml +++ b/shared/src/commonMain/composeResources/values-ru/strings.xml @@ -175,7 +175,7 @@ Остановите все активные передачи и раздачи перед применением настроек сети. Не удалось применить эти настройки. Предыдущие настройки сети восстановлены. При применении сетевое соединение VniDrop перезапускается. Сначала остановите активные передачи и раздачи. Возможно, существующие приглашения потребуется отправить повторно. - Введите по одному HTTPS-адресу ретранслятора в строке. Учётные данные в URL-адресах не поддерживаются. Сертификат TLS должен быть выдан общедоступным доверенным центром сертификации. + Добавляйте каждый HTTPS-адрес ретранслятора отдельно. Учётные данные в URL-адресах не поддерживаются. Сертификат TLS должен быть выдан общедоступным доверенным центром сертификации. URL-адреса ретрансляторов Автоматически (рекомендуется) Использовать стандартную публичную инфраструктуру ретрансляторов VniDrop, если прямое соединение недоступно. diff --git a/shared/src/commonMain/composeResources/values/strings.xml b/shared/src/commonMain/composeResources/values/strings.xml index 507b498..5cc6635 100644 --- a/shared/src/commonMain/composeResources/values/strings.xml +++ b/shared/src/commonMain/composeResources/values/strings.xml @@ -175,7 +175,7 @@ Stop all active transfers and shares before applying network settings. Could not apply these settings. The previous network settings were restored. Applying restarts VniDrop’s network connection. Stop active transfers and shares first. Existing invitations may need to be shared again. - Enter one HTTPS relay URL per line. URL credentials are not supported. The TLS certificate must be issued by a publicly trusted certificate authority. + Add each HTTPS relay URL separately. URL credentials are not supported. The TLS certificate must be issued by a publicly trusted certificate authority. Relay URLs Automatic (recommended) Use VniDrop’s default public relay infrastructure when a direct connection is unavailable. diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/NetworkSettings.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/NetworkSettings.kt index 899b61c..12a87e6 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/NetworkSettings.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/NetworkSettings.kt @@ -2,18 +2,26 @@ package com.vnidrop.app.feature.settings import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment 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 import com.vnidrop.app.ui.icons.PlatformIcon @@ -21,6 +29,7 @@ import com.vnidrop.app.ui.theme.LocalVniDropColors import org.jetbrains.compose.resources.stringResource import vnidrop.shared.generated.resources.Res import vnidrop.shared.generated.resources.approval_endpoint_id +import vnidrop.shared.generated.resources.relay_add_url import vnidrop.shared.generated.resources.relay_apply import vnidrop.shared.generated.resources.relay_apply_active_transfers import vnidrop.shared.generated.resources.relay_apply_failed @@ -31,12 +40,13 @@ 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_description 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_remove_url import vnidrop.shared.generated.resources.relay_restore_failed import vnidrop.shared.generated.resources.relay_strict_warning import vnidrop.shared.generated.resources.relay_validation_duplicate_url @@ -50,7 +60,9 @@ import vnidrop.shared.generated.resources.settings_network_title internal fun NetworkSettings( state: SettingsState, onModeChanged: (RelayMode) -> Unit, - onUrlsChanged: (String) -> Unit, + onUrlChanged: (Int, String) -> Unit, + onAddUrl: () -> Unit, + onRemoveUrl: (Int) -> Unit, onApply: () -> Unit, onBack: () -> Unit, showBack: Boolean, @@ -58,15 +70,6 @@ internal fun NetworkSettings( val colors = LocalVniDropColors.current Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { SettingsTopBar(stringResource(Res.string.settings_network_title), onBack, showBack) - state.endpointId?.takeIf(String::isNotBlank)?.let { endpointId -> - SelectionContainer { - Text( - stringResource(Res.string.approval_endpoint_id, endpointId), - color = colors.foregroundLighter, - style = MaterialTheme.typography.bodySmall, - ) - } - } SettingsGroup { RelayModeRow( icon = AppIcon.Globe, @@ -104,14 +107,52 @@ internal fun NetworkSettings( onClick = { onModeChanged(RelayMode.LocalOnly) }, ) } + Text( + stringResource(Res.string.relay_privacy_description), + color = colors.foregroundLighter, + style = MaterialTheme.typography.bodySmall, + ) + state.endpointId?.takeIf(String::isNotBlank)?.let { endpointId -> + SelectionContainer { + Text( + stringResource(Res.string.approval_endpoint_id, endpointId), + color = colors.foregroundLighter, + style = MaterialTheme.typography.bodySmall, + ) + } + } if (state.relayMode.usesCustomRelayUrls) { - Field( - value = state.relayUrlsText, - onValueChange = onUrlsChanged, - label = stringResource(Res.string.relay_custom_urls_label), - minLines = 3, - enabled = !state.isApplyingRelaySettings, + Text( + stringResource(Res.string.relay_custom_urls_label), + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, ) + SettingsGroup { + state.relayUrls.forEachIndexed { index, url -> + RelayUrlRow( + value = url, + error = relayUrlErrorText(state.relayInputError, index), + enabled = !state.isApplyingRelaySettings, + onValueChange = { onUrlChanged(index, it) }, + onRemove = { onRemoveUrl(index) }, + ) + if (index < state.relayUrls.lastIndex) { + SettingsDivider(startPadding = 14.dp) + } + } + if (state.relayUrls.isNotEmpty()) { + SettingsDivider(startPadding = 14.dp) + } + TextButton( + onClick = onAddUrl, + enabled = state.relayUrls.size < MaximumRelayUrls && !state.isApplyingRelaySettings, + modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp), + ) { + PlatformIcon(AppIcon.Add, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(Modifier.width(8.dp)) + Text(stringResource(Res.string.relay_add_url)) + } + } Text( stringResource(Res.string.relay_custom_urls_help), color = colors.foregroundLighter, @@ -125,13 +166,8 @@ internal fun NetworkSettings( fontWeight = FontWeight.Medium, ) } - Text( - stringResource(Res.string.relay_privacy_description), - color = colors.foregroundLighter, - style = MaterialTheme.typography.bodySmall, - ) } - relayErrorText(state)?.let { error -> + relayGlobalErrorText(state)?.let { error -> Text( error, color = colors.destructiveDefault, @@ -189,7 +225,70 @@ private fun RelayModeRow( } @Composable -private fun relayErrorText(state: SettingsState): String? { +private fun RelayUrlRow( + value: String, + error: String?, + enabled: Boolean, + onValueChange: (String) -> Unit, + onRemove: () -> Unit, +) { + val colors = LocalVniDropColors.current + Row( + modifier = Modifier + .fillMaxWidth() + .padding(start = 14.dp, end = 6.dp, top = 10.dp, bottom = 10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + OutlinedTextField( + value = value, + onValueChange = onValueChange, + modifier = Modifier.weight(1f), + enabled = enabled, + singleLine = true, + isError = error != null, + placeholder = { Text("https://relay.example.com") }, + supportingText = error?.let { + { + Text(it, color = colors.destructiveDefault) + } + }, + shape = RoundedCornerShape(8.dp), + ) + IconButton(onClick = onRemove, enabled = enabled) { + PlatformIcon( + AppIcon.Delete, + contentDescription = stringResource(Res.string.relay_remove_url), + tint = colors.destructiveDefault, + ) + } + } +} + +@Composable +private fun relayUrlErrorText(error: RelaySettingsInputError?, index: Int): String? = when (error) { + is RelaySettingsInputError.HttpsRequired -> if (error.line == index + 1) { + stringResource(Res.string.relay_validation_https_required, error.line) + } else { + null + } + is RelaySettingsInputError.InvalidUrl -> if (error.line == index + 1) { + stringResource(Res.string.relay_validation_invalid_url, error.line) + } else { + null + } + is RelaySettingsInputError.DuplicateUrl -> if (error.line == index + 1) { + stringResource(Res.string.relay_validation_duplicate_url, error.line) + } else { + null + } + RelaySettingsInputError.MissingUrl, + is RelaySettingsInputError.TooManyUrls, + null, + -> null +} + +@Composable +private fun relayGlobalErrorText(state: SettingsState): String? { if (state.hasActiveNetworkWork || state.relayApplyError == RelaySettingsApplyError.ActiveTransfers) { return stringResource(Res.string.relay_apply_active_transfers) } @@ -200,18 +299,10 @@ private fun relayErrorText(state: SettingsState): String? { Res.string.relay_validation_too_many_urls, error.maximum, ) - is RelaySettingsInputError.HttpsRequired -> stringResource( - Res.string.relay_validation_https_required, - error.line, - ) - is RelaySettingsInputError.InvalidUrl -> stringResource( - Res.string.relay_validation_invalid_url, - error.line, - ) - is RelaySettingsInputError.DuplicateUrl -> stringResource( - Res.string.relay_validation_duplicate_url, - error.line, - ) + is RelaySettingsInputError.HttpsRequired, + is RelaySettingsInputError.InvalidUrl, + is RelaySettingsInputError.DuplicateUrl, + -> null } } return when (state.relayApplyError) { diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/RelaySettingsValidation.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/RelaySettingsValidation.kt index 3f11add..c41e211 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/RelaySettingsValidation.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/RelaySettingsValidation.kt @@ -19,13 +19,13 @@ data class RelaySettingsValidation( fun validateRelaySettings( mode: RelayMode, - urlsText: String, + relayUrls: List, retainedUrls: List = emptyList(), ): RelaySettingsValidation { if (!mode.usesCustomRelayUrls) { return RelaySettingsValidation(RelaySettings(mode, retainedUrls)) } - val lines = urlsText.lineSequence() + val lines = relayUrls .mapIndexedNotNull { index, raw -> raw.trim().takeIf(String::isNotEmpty)?.let { index + 1 to it } } .toList() if (lines.isEmpty()) return RelaySettingsValidation(error = RelaySettingsInputError.MissingUrl) @@ -144,5 +144,5 @@ private fun isValidPortSuffix(suffix: String): Boolean { } private const val HttpsPrefix = "https://" -private const val MaximumRelayUrls = 8 +internal const val MaximumRelayUrls = 8 private const val MaximumRelayUrlLength = 2_048 diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsOverview.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsOverview.kt index 997559f..61835b5 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsOverview.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsOverview.kt @@ -40,14 +40,6 @@ internal fun SettingsOverview( fontWeight = FontWeight.Bold, ) SettingsGroup { - SettingsRow( - icon = AppIcon.Globe, - title = stringResource(Res.string.settings_network_title), - value = relayModeLabel(state.savedRelaySettings.mode), - selected = state.selectedSection == SettingsSection.Network, - onClick = { onSectionSelected(SettingsSection.Network) }, - ) - SettingsDivider() SettingsRow( icon = AppIcon.User, title = stringResource(Res.string.preferences_title), @@ -78,7 +70,17 @@ internal fun SettingsOverview( selected = state.selectedSection == SettingsSection.Storage, onClick = { onSectionSelected(SettingsSection.Storage) }, ) - SettingsDivider() + } + SettingsGroup { + SettingsRow( + icon = AppIcon.Globe, + title = stringResource(Res.string.settings_network_title), + value = relayModeLabel(state.savedRelaySettings.mode), + selected = state.selectedSection == SettingsSection.Network, + onClick = { onSectionSelected(SettingsSection.Network) }, + ) + } + SettingsGroup { SettingsRow( icon = AppIcon.Info, title = stringResource(Res.string.about_title), diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsRoute.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsRoute.kt index fa27f16..e9ec600 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsRoute.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsRoute.kt @@ -25,7 +25,9 @@ fun SettingsRoute(viewModel: SettingsViewModel, windowClass: WindowClass) { onUsernameChanged = viewModel::setUsername, onThemeModeChanged = viewModel::setThemeMode, onRelayModeChanged = viewModel::setRelayMode, - onRelayUrlsChanged = viewModel::setRelayUrlsText, + onRelayUrlChanged = viewModel::setRelayUrl, + onAddRelayUrl = viewModel::addRelayUrl, + onRemoveRelayUrl = viewModel::removeRelayUrl, onApplyRelaySettings = viewModel::applyRelaySettings, onChooseFolder = viewModel::chooseReceiveFolder, onResetFolder = viewModel::resetReceiveFolder, diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsScreen.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsScreen.kt index e779658..fb367a7 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsScreen.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsScreen.kt @@ -32,7 +32,9 @@ fun SettingsScreen( onSubmitBugReport: () -> Unit, onDeleteAllTransfers: () -> Unit = {}, onRelayModeChanged: (RelayMode) -> Unit = {}, - onRelayUrlsChanged: (String) -> Unit = {}, + onRelayUrlChanged: (Int, String) -> Unit = { _, _ -> }, + onAddRelayUrl: () -> Unit = {}, + onRemoveRelayUrl: (Int) -> Unit = {}, onApplyRelaySettings: () -> Unit = {}, ) { if (windowClass == WindowClass.Desktop) { @@ -65,7 +67,9 @@ fun SettingsScreen( onSubmitBugReport = onSubmitBugReport, onDeleteAllTransfers = onDeleteAllTransfers, onRelayModeChanged = onRelayModeChanged, - onRelayUrlsChanged = onRelayUrlsChanged, + onRelayUrlChanged = onRelayUrlChanged, + onAddRelayUrl = onAddRelayUrl, + onRemoveRelayUrl = onRemoveRelayUrl, onApplyRelaySettings = onApplyRelaySettings, ) } @@ -102,7 +106,9 @@ fun SettingsScreen( onSubmitBugReport = onSubmitBugReport, onDeleteAllTransfers = onDeleteAllTransfers, onRelayModeChanged = onRelayModeChanged, - onRelayUrlsChanged = onRelayUrlsChanged, + onRelayUrlChanged = onRelayUrlChanged, + onAddRelayUrl = onAddRelayUrl, + onRemoveRelayUrl = onRemoveRelayUrl, onApplyRelaySettings = onApplyRelaySettings, ) } @@ -131,7 +137,9 @@ private fun SettingsSectionContent( onSubmitBugReport: () -> Unit, onDeleteAllTransfers: () -> Unit, onRelayModeChanged: (RelayMode) -> Unit, - onRelayUrlsChanged: (String) -> Unit, + onRelayUrlChanged: (Int, String) -> Unit, + onAddRelayUrl: () -> Unit, + onRemoveRelayUrl: (Int) -> Unit, onApplyRelaySettings: () -> Unit, ) { when (section) { @@ -141,7 +149,9 @@ private fun SettingsSectionContent( SettingsSection.Network -> NetworkSettings( state = state, onModeChanged = onRelayModeChanged, - onUrlsChanged = onRelayUrlsChanged, + onUrlChanged = onRelayUrlChanged, + onAddUrl = onAddRelayUrl, + onRemoveUrl = onRemoveRelayUrl, onApply = onApplyRelaySettings, onBack = onBack, showBack = showBack, diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsViewModel.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsViewModel.kt index 15f1e63..48a96eb 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsViewModel.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsViewModel.kt @@ -89,7 +89,7 @@ data class SettingsState( val themeMode: ThemeMode = ThemeMode.System, val savedRelaySettings: RelaySettings = RelaySettings(), val relayMode: RelayMode = RelayMode.Automatic, - val relayUrlsText: String = "", + val relayUrls: List = emptyList(), val relayInputError: RelaySettingsInputError? = null, val relayApplyError: RelaySettingsApplyError? = null, val isApplyingRelaySettings: Boolean = false, @@ -114,7 +114,7 @@ data class SettingsState( ) { val hasRelaySettingsChanges: Boolean get() = relayMode != savedRelaySettings.mode || - (relayMode.usesCustomRelayUrls && relayUrlsText != savedRelaySettings.relayUrls.joinToString("\n")) + (relayMode.usesCustomRelayUrls && relayUrls != savedRelaySettings.relayUrls) } sealed interface SettingsEffect { @@ -162,10 +162,10 @@ class SettingsViewModel( diagnosticsEnabled = preferences.diagnosticsEnabled, savedRelaySettings = preferences.relaySettings, relayMode = if (hasLocalRelayDraft) current.relayMode else preferences.relaySettings.mode, - relayUrlsText = if (hasLocalRelayDraft) { - current.relayUrlsText + relayUrls = if (hasLocalRelayDraft) { + current.relayUrls } else { - preferences.relaySettings.relayUrls.joinToString("\n") + preferences.relaySettings.relayUrls }, ) } @@ -269,6 +269,7 @@ class SettingsViewModel( _state.update { it.copy( relayMode = mode, + relayUrls = if (mode.usesCustomRelayUrls && it.relayUrls.isEmpty()) listOf("") else it.relayUrls, relayInputError = null, relayApplyError = null, ) @@ -276,11 +277,39 @@ class SettingsViewModel( hasLocalRelayDraft = _state.value.hasRelaySettingsChanges } - fun setRelayUrlsText(value: String) { + fun setRelayUrl(index: Int, value: String) { + if (index !in _state.value.relayUrls.indices) return hasLocalRelayDraft = true _state.update { it.copy( - relayUrlsText = value, + relayUrls = it.relayUrls.toMutableList().apply { this[index] = value }, + relayInputError = null, + relayApplyError = null, + ) + } + hasLocalRelayDraft = _state.value.hasRelaySettingsChanges + } + + fun addRelayUrl() { + if (_state.value.relayUrls.size >= MaximumRelayUrls) return + hasLocalRelayDraft = true + _state.update { + it.copy( + relayUrls = it.relayUrls + "", + relayInputError = null, + relayApplyError = null, + ) + } + hasLocalRelayDraft = _state.value.hasRelaySettingsChanges + } + + fun removeRelayUrl(index: Int) { + if (index !in _state.value.relayUrls.indices) return + hasLocalRelayDraft = true + _state.update { + val remaining = it.relayUrls.toMutableList().apply { removeAt(index) } + it.copy( + relayUrls = remaining.ifEmpty { listOf("") }, relayInputError = null, relayApplyError = null, ) @@ -293,7 +322,7 @@ class SettingsViewModel( if (snapshot.isApplyingRelaySettings || !snapshot.hasRelaySettingsChanges) return val validation = validateRelaySettings( mode = snapshot.relayMode, - urlsText = snapshot.relayUrlsText, + relayUrls = snapshot.relayUrls, retainedUrls = snapshot.savedRelaySettings.relayUrls, ) val desired = validation.settings @@ -349,7 +378,7 @@ class SettingsViewModel( it.copy( savedRelaySettings = desired, relayMode = desired.mode, - relayUrlsText = desired.relayUrls.joinToString("\n"), + relayUrls = desired.relayUrls, isApplyingRelaySettings = false, relayInputError = null, relayApplyError = null, diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/StorageSettings.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/StorageSettings.kt index f5c4c75..3dd7d67 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/StorageSettings.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/StorageSettings.kt @@ -2,14 +2,21 @@ package com.vnidrop.app.feature.settings import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp -import com.vnidrop.app.ui.icons.AppIcon import com.vnidrop.app.ui.state.formatBytes import com.vnidrop.app.ui.theme.LocalVniDropColors import org.jetbrains.compose.resources.stringResource @@ -37,27 +44,26 @@ internal fun StorageSettings( val storage = state.storage if (storage == null || state.isCalculatingStorage) { SettingsGroup { - SettingsRow( - icon = AppIcon.Storage, + StorageRow( title = stringResource(Res.string.storage_calculating), - trailing = { CircularProgressIndicator() }, + trailing = { CircularProgressIndicator(Modifier.size(20.dp), strokeWidth = 2.dp) }, ) } } else { SettingsGroup { - StorageRow(AppIcon.TransferData, stringResource(Res.string.storage_transfer_data), storage.transferCacheBytes) - SettingsDivider() - StorageRow(AppIcon.Storage, stringResource(Res.string.storage_app_data), storage.appDataBytes) - SettingsDivider() - StorageRow(AppIcon.Temporary, stringResource(Res.string.storage_temporary), storage.temporaryBytes) - SettingsDivider() - SettingsRow( - icon = AppIcon.Folder, - title = stringResource(Res.string.storage_received_files), - value = formatBytes(storage.receivedBytes), + StorageRow(stringResource(Res.string.storage_received_files), storage.receivedBytes) + SettingsDivider(startPadding = 16.dp) + StorageRow(stringResource(Res.string.storage_transfer_data), storage.transferCacheBytes) + SettingsDivider(startPadding = 16.dp) + StorageRow(stringResource(Res.string.storage_app_data), storage.appDataBytes) + SettingsDivider(startPadding = 16.dp) + StorageRow(stringResource(Res.string.storage_temporary), storage.temporaryBytes) + SettingsDivider(startPadding = 16.dp) + StorageRow( + title = stringResource(Res.string.storage_total), + bytes = storage.deviceImpactBytes, + emphasized = true, ) - SettingsDivider() - StorageRow(AppIcon.TotalStorage, stringResource(Res.string.storage_total), storage.deviceImpactBytes) } } Button( @@ -76,6 +82,33 @@ internal fun StorageSettings( } @Composable -private fun StorageRow(icon: AppIcon, title: String, bytes: ULong) { - SettingsRow(icon = icon, title = title, value = formatBytes(bytes)) +private fun StorageRow( + title: String, + bytes: ULong? = null, + emphasized: Boolean = false, + trailing: @Composable (() -> Unit)? = null, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 52.dp) + .padding(horizontal = 16.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + title, + modifier = Modifier.weight(1f), + style = MaterialTheme.typography.bodyLarge, + fontWeight = if (emphasized) FontWeight.SemiBold else FontWeight.Normal, + ) + if (bytes != null) { + Text( + formatBytes(bytes), + color = LocalVniDropColors.current.foregroundLighter, + style = MaterialTheme.typography.bodyMedium, + fontWeight = if (emphasized) FontWeight.SemiBold else FontWeight.Normal, + ) + } + trailing?.invoke() + } } diff --git a/shared/src/commonTest/kotlin/com/vnidrop/app/feature/ViewModelsTest.kt b/shared/src/commonTest/kotlin/com/vnidrop/app/feature/ViewModelsTest.kt index 25e47e6..65d393a 100644 --- a/shared/src/commonTest/kotlin/com/vnidrop/app/feature/ViewModelsTest.kt +++ b/shared/src/commonTest/kotlin/com/vnidrop/app/feature/ViewModelsTest.kt @@ -28,6 +28,7 @@ import com.vnidrop.app.feature.receive.ReceiveViewModel import com.vnidrop.app.feature.send.SendViewModel import com.vnidrop.app.feature.settings.SettingsSection import com.vnidrop.app.feature.settings.RelaySettingsApplyError +import com.vnidrop.app.feature.settings.RelaySettingsInputError import com.vnidrop.app.feature.settings.SettingsViewModel import com.vnidrop.app.notifications.NotificationPermission import com.vnidrop.app.preferences.AppPreferences @@ -145,7 +146,9 @@ class ViewModelsTest { advanceUntilIdle() viewModel.setRelayMode(RelayMode.StrictCustom) - viewModel.setRelayUrlsText(" HTTPS://Relay.Example.com/ \nhttps://backup.example.com:443") + viewModel.setRelayUrl(0, " HTTPS://Relay.Example.com/ ") + viewModel.addRelayUrl() + viewModel.setRelayUrl(1, "https://backup.example.com:443") viewModel.applyRelaySettings() advanceUntilIdle() @@ -159,6 +162,37 @@ class ViewModelsTest { assertFalse(viewModel.state.value.hasRelaySettingsChanges) } + @Test + fun settingsRejectsInvalidRelayUrlWithoutEnteringLoadingState() = runTest { + Dispatchers.setMain(StandardTestDispatcher(testScheduler)) + val core = FakeCoreGateway() + val viewModel = settingsViewModel(repository = core) + advanceUntilIdle() + + viewModel.setRelayMode(RelayMode.StrictCustom) + viewModel.setRelayUrl(0, "relay.example.com") + viewModel.applyRelaySettings() + + assertFalse(viewModel.state.value.isApplyingRelaySettings) + assertEquals(RelaySettingsInputError.HttpsRequired(1), viewModel.state.value.relayInputError) + assertEquals(emptyList(), core.initializedRelaySettings) + } + + @Test + fun settingsEditsCustomRelaysAsIndividualRows() = runTest { + Dispatchers.setMain(StandardTestDispatcher(testScheduler)) + val viewModel = settingsViewModel() + advanceUntilIdle() + + viewModel.setRelayMode(RelayMode.StrictCustom) + viewModel.setRelayUrl(0, "https://first.example.com") + viewModel.addRelayUrl() + viewModel.setRelayUrl(1, "https://second.example.com") + viewModel.removeRelayUrl(0) + + assertEquals(listOf("https://second.example.com"), viewModel.state.value.relayUrls) + } + @Test fun settingsAppliesCustomFallbackAndLocalOnlyWhileRetainingRelayDrafts() = runTest { Dispatchers.setMain(StandardTestDispatcher(testScheduler)) @@ -168,7 +202,7 @@ class ViewModelsTest { advanceUntilIdle() viewModel.setRelayMode(RelayMode.CustomWithDirectFallback) - viewModel.setRelayUrlsText("https://relay.example.com") + viewModel.setRelayUrl(0, "https://relay.example.com") viewModel.applyRelaySettings() advanceUntilIdle() viewModel.setRelayMode(RelayMode.LocalOnly) @@ -196,7 +230,7 @@ class ViewModelsTest { advanceUntilIdle() viewModel.setRelayMode(RelayMode.StrictCustom) - viewModel.setRelayUrlsText("https://relay.example.com") + viewModel.setRelayUrl(0, "https://relay.example.com") viewModel.applyRelaySettings() viewModel.applyRelaySettings() assertTrue(viewModel.state.value.isApplyingRelaySettings) @@ -216,7 +250,7 @@ class ViewModelsTest { assertEquals("endpoint", viewModel.state.value.endpointId) viewModel.setRelayMode(RelayMode.StrictCustom) - viewModel.setRelayUrlsText("https://relay.example.com") + viewModel.setRelayUrl(0, "https://relay.example.com") viewModel.applyRelaySettings() advanceUntilIdle() @@ -238,7 +272,7 @@ class ViewModelsTest { advanceUntilIdle() viewModel.setRelayMode(RelayMode.StrictCustom) - viewModel.setRelayUrlsText("https://relay.example.com") + viewModel.setRelayUrl(0, "https://relay.example.com") viewModel.applyRelaySettings() advanceUntilIdle() diff --git a/shared/src/commonTest/kotlin/com/vnidrop/app/feature/settings/RelaySettingsValidationTest.kt b/shared/src/commonTest/kotlin/com/vnidrop/app/feature/settings/RelaySettingsValidationTest.kt index 639407e..cac78fb 100644 --- a/shared/src/commonTest/kotlin/com/vnidrop/app/feature/settings/RelaySettingsValidationTest.kt +++ b/shared/src/commonTest/kotlin/com/vnidrop/app/feature/settings/RelaySettingsValidationTest.kt @@ -10,7 +10,7 @@ class RelaySettingsValidationTest { fun customFallbackUsesTheSameValidatedRelayList() { val result = validateRelaySettings( mode = RelayMode.CustomWithDirectFallback, - urlsText = "https://relay.example.com/", + relayUrls = listOf("https://relay.example.com/"), ) assertEquals( @@ -28,11 +28,11 @@ class RelaySettingsValidationTest { assertEquals( RelaySettings(RelayMode.Automatic, retained), - validateRelaySettings(RelayMode.Automatic, "invalid", retained).settings, + validateRelaySettings(RelayMode.Automatic, listOf("invalid"), retained).settings, ) assertEquals( RelaySettings(RelayMode.LocalOnly, retained), - validateRelaySettings(RelayMode.LocalOnly, "invalid", retained).settings, + validateRelaySettings(RelayMode.LocalOnly, listOf("invalid"), retained).settings, ) } @@ -40,7 +40,7 @@ class RelaySettingsValidationTest { fun customRelayUrlsAreNormalized() { val result = validateRelaySettings( mode = RelayMode.StrictCustom, - urlsText = " HTTPS://Relay.Example.com/ \nhttps://[2001:DB8::1]:443", + relayUrls = listOf(" HTTPS://Relay.Example.com/ ", "https://[2001:DB8::1]:443"), ) assertEquals( @@ -57,11 +57,11 @@ class RelaySettingsValidationTest { fun customRelayUrlsRequireHttpsAndRootPath() { assertEquals( RelaySettingsInputError.HttpsRequired(1), - validateRelaySettings(RelayMode.StrictCustom, "http://relay.example.com").error, + validateRelaySettings(RelayMode.StrictCustom, listOf("http://relay.example.com")).error, ) assertEquals( RelaySettingsInputError.InvalidUrl(1), - validateRelaySettings(RelayMode.StrictCustom, "https://relay.example.com/custom").error, + validateRelaySettings(RelayMode.StrictCustom, listOf("https://relay.example.com/custom")).error, ) } @@ -69,7 +69,7 @@ class RelaySettingsValidationTest { fun duplicateNormalizedRelayUrlsAreRejected() { val result = validateRelaySettings( RelayMode.StrictCustom, - "https://relay.example.com\nHTTPS://RELAY.EXAMPLE.COM:443/", + listOf("https://relay.example.com", "HTTPS://RELAY.EXAMPLE.COM:443/"), ) assertEquals(RelaySettingsInputError.DuplicateUrl(2), result.error) @@ -79,7 +79,7 @@ class RelaySettingsValidationTest { fun structurallyValidIpv6RelayUrlsAreAccepted() { val result = validateRelaySettings( RelayMode.StrictCustom, - "https://[::1]:443\nhttps://[2001:db8::1]\nhttps://[::ffff:192.0.2.1]", + listOf("https://[::1]:443", "https://[2001:db8::1]", "https://[::ffff:192.0.2.1]"), ) assertEquals( @@ -105,7 +105,7 @@ class RelaySettingsValidationTest { ).forEach { url -> assertEquals( RelaySettingsInputError.InvalidUrl(1), - validateRelaySettings(RelayMode.StrictCustom, url).error, + validateRelaySettings(RelayMode.StrictCustom, listOf(url)).error, url, ) } diff --git a/shared/src/jvmTest/kotlin/com/vnidrop/app/ui/FoundationComposeTest.kt b/shared/src/jvmTest/kotlin/com/vnidrop/app/ui/FoundationComposeTest.kt index 75c35dc..2405ea3 100644 --- a/shared/src/jvmTest/kotlin/com/vnidrop/app/ui/FoundationComposeTest.kt +++ b/shared/src/jvmTest/kotlin/com/vnidrop/app/ui/FoundationComposeTest.kt @@ -136,8 +136,25 @@ class FoundationComposeTest { onBugContactChanged = {}, onBugIncludeLogsChanged = {}, onSubmitBugReport = {}, - onRelayModeChanged = { state.value = state.value.copy(relayMode = it) }, - onRelayUrlsChanged = { state.value = state.value.copy(relayUrlsText = it) }, + onRelayModeChanged = { + state.value = state.value.copy( + relayMode = it, + relayUrls = state.value.relayUrls.ifEmpty { listOf("") }, + ) + }, + onRelayUrlChanged = { index, value -> + state.value = state.value.copy( + relayUrls = state.value.relayUrls.toMutableList().apply { this[index] = value }, + ) + }, + onAddRelayUrl = { + state.value = state.value.copy(relayUrls = state.value.relayUrls + "") + }, + onRemoveRelayUrl = { index -> + state.value = state.value.copy( + relayUrls = state.value.relayUrls.toMutableList().apply { removeAt(index) }.ifEmpty { listOf("") }, + ) + }, onApplyRelaySettings = { applied = true }, ) } @@ -150,6 +167,7 @@ class FoundationComposeTest { "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("Add relay server").assertIsDisplayed() onNodeWithText("Apply network settings").performClick() runOnIdle { assertTrue(applied) } } From 7cc0e825f61b69850ebf6aea1e46875834a5e9df Mon Sep 17 00:00:00 2001 From: Hammed Abass Date: Thu, 23 Jul 2026 19:16:11 +0200 Subject: [PATCH 05/10] fix(settings): confirm deleting all transfers --- .../app/feature/settings/SettingsScreen.kt | 5 +- .../app/feature/settings/StorageSettings.kt | 59 ++++++++++++++++++- .../vnidrop/app/ui/FoundationComposeTest.kt | 40 +++++++++++++ 3 files changed, 102 insertions(+), 2 deletions(-) diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsScreen.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsScreen.kt index fb367a7..dbb29b6 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsScreen.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsScreen.kt @@ -48,6 +48,7 @@ fun SettingsScreen( Column(Modifier.weight(1f)) { SettingsSectionContent( state = state, + windowClass = windowClass, section = state.selectedSection.takeUnless { it == SettingsSection.Overview } ?: SettingsSection.Preferences, onBack = {}, showBack = false, @@ -79,6 +80,7 @@ fun SettingsScreen( SettingsSection.Overview -> SettingsOverview(state, onSectionSelected, largeTitle = true) else -> SettingsSectionContent( state = state, + windowClass = windowClass, section = state.selectedSection, onBack = { onSectionSelected( @@ -118,6 +120,7 @@ fun SettingsScreen( @Composable private fun SettingsSectionContent( state: SettingsState, + windowClass: WindowClass, section: SettingsSection, onBack: () -> Unit, showBack: Boolean, @@ -157,7 +160,7 @@ private fun SettingsSectionContent( showBack = showBack, ) SettingsSection.Notifications -> NotificationSettings(state, onNotificationsChanged, onOpenNotificationSettings, onBack, showBack) - SettingsSection.Storage -> StorageSettings(state, onDeleteAllTransfers, onBack, showBack) + SettingsSection.Storage -> StorageSettings(state, windowClass, onDeleteAllTransfers, onBack, showBack) SettingsSection.About -> AboutSettings( state = state, onDiagnosticsChanged = onDiagnosticsChanged, diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/StorageSettings.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/StorageSettings.kt index 3dd7d67..7011977 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/StorageSettings.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/StorageSettings.kt @@ -13,17 +13,28 @@ import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp +import com.vnidrop.app.ui.components.AdaptiveDrawer +import com.vnidrop.app.ui.components.DestructiveButton +import com.vnidrop.app.ui.components.SecondaryButton import com.vnidrop.app.ui.state.formatBytes +import com.vnidrop.app.ui.state.WindowClass import com.vnidrop.app.ui.theme.LocalVniDropColors import org.jetbrains.compose.resources.stringResource import vnidrop.shared.generated.resources.Res +import vnidrop.shared.generated.resources.button_cancel import vnidrop.shared.generated.resources.storage_app_data import vnidrop.shared.generated.resources.storage_calculating import vnidrop.shared.generated.resources.storage_delete_transfers +import vnidrop.shared.generated.resources.storage_delete_transfers_description import vnidrop.shared.generated.resources.storage_deleting import vnidrop.shared.generated.resources.storage_total import vnidrop.shared.generated.resources.storage_received_files @@ -35,10 +46,12 @@ import vnidrop.shared.generated.resources.storage_footer @Composable internal fun StorageSettings( state: SettingsState, + windowClass: WindowClass, onDeleteAllTransfers: () -> Unit, onBack: () -> Unit, showBack: Boolean, ) { + var showDeleteConfirmation by rememberSaveable { mutableStateOf(false) } Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { SettingsTopBar(stringResource(Res.string.storage_title), onBack, showBack) val storage = state.storage @@ -67,7 +80,7 @@ internal fun StorageSettings( } } Button( - onClick = onDeleteAllTransfers, + onClick = { showDeleteConfirmation = true }, enabled = !state.isDeletingTransfers, colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error), ) { @@ -79,6 +92,50 @@ internal fun StorageSettings( color = LocalVniDropColors.current.foregroundLighter, ) } + if (showDeleteConfirmation) { + AdaptiveDrawer( + windowClass = windowClass, + onDismissRequest = { showDeleteConfirmation = false }, + ) { + DeleteAllTransfersPanel( + onCancel = { showDeleteConfirmation = false }, + onConfirm = { + showDeleteConfirmation = false + onDeleteAllTransfers() + }, + ) + } + } +} + +@Composable +private fun DeleteAllTransfersPanel( + onCancel: () -> Unit, + onConfirm: () -> Unit, +) { + Column( + Modifier.fillMaxWidth().padding(horizontal = 20.dp, vertical = 14.dp), + verticalArrangement = Arrangement.spacedBy(14.dp), + ) { + Text( + stringResource(Res.string.storage_delete_transfers), + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + ) + Text( + stringResource(Res.string.storage_delete_transfers_description), + color = LocalVniDropColors.current.foregroundLighter, + style = MaterialTheme.typography.bodyMedium, + ) + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp, Alignment.End)) { + SecondaryButton(stringResource(Res.string.button_cancel), onClick = onCancel) + DestructiveButton( + stringResource(Res.string.storage_delete_transfers), + onClick = onConfirm, + modifier = Modifier.testTag("confirm-delete-all-transfers"), + ) + } + } } @Composable diff --git a/shared/src/jvmTest/kotlin/com/vnidrop/app/ui/FoundationComposeTest.kt b/shared/src/jvmTest/kotlin/com/vnidrop/app/ui/FoundationComposeTest.kt index 2405ea3..aeffd9b 100644 --- a/shared/src/jvmTest/kotlin/com/vnidrop/app/ui/FoundationComposeTest.kt +++ b/shared/src/jvmTest/kotlin/com/vnidrop/app/ui/FoundationComposeTest.kt @@ -64,6 +64,7 @@ import com.vnidrop.app.ui.theme.VniDropTheme import com.vnidrop.app.ui.theme.LocalVniDropColors import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFalse import kotlin.test.assertTrue @OptIn(ExperimentalTestApi::class) @@ -172,6 +173,45 @@ class FoundationComposeTest { runOnIdle { assertTrue(applied) } } + @Test + fun storageDeleteAllTransfersRequiresConfirmation() = runComposeUiTest { + var deleteRequested = false + setContent { + VniDropTheme(isDarkTheme = false) { + SettingsScreen( + state = SettingsState(selectedSection = SettingsSection.Storage), + windowClass = WindowClass.Desktop, + onSectionSelected = {}, + onUsernameChanged = {}, + onThemeModeChanged = {}, + onChooseFolder = {}, + onResetFolder = {}, + onNotificationsChanged = {}, + onOpenNotificationSettings = {}, + onDiagnosticsChanged = {}, + onBugWhatChanged = {}, + onBugExpectedChanged = {}, + onBugStepsChanged = {}, + onBugContactChanged = {}, + onBugIncludeLogsChanged = {}, + onSubmitBugReport = {}, + onDeleteAllTransfers = { deleteRequested = true }, + ) + } + } + + onNodeWithText("Delete all transfers").performClick() + onNodeWithText( + "This clears all sent and received transfer records from your history. Your received files are not deleted. " + + "Cached shared content that is no longer needed is reclaimed automatically, which may take a little time. " + + "This can’t be undone.", + ).assertIsDisplayed() + runOnIdle { assertFalse(deleteRequested) } + + onNodeWithTag("confirm-delete-all-transfers").performClick() + runOnIdle { assertTrue(deleteRequested) } + } + @Test fun aboutSettingsShowsTheSharedProductAndPrivacyContent() = runComposeUiTest { setContent { From 4074f4bee859d4b26c3d5de68712874ab3fa05e7 Mon Sep 17 00:00:00 2001 From: Hammed Abass Date: Thu, 23 Jul 2026 22:22:30 +0200 Subject: [PATCH 06/10] feat(storage): clear inactive transfer cache --- crates/vnidrop/CORE_FLOW.md | 5 ++ crates/vnidrop/src/api.rs | 46 ++++++++++++- crates/vnidrop/src/lib.rs | 10 +-- crates/vnidrop/tests/storage.rs | 26 +++++++ .../app/core/FileSystemService.android.kt | 2 +- .../composeResources/values-de/strings.xml | 10 ++- .../composeResources/values-es/strings.xml | 10 ++- .../composeResources/values-fr/strings.xml | 10 ++- .../composeResources/values-it/strings.xml | 10 ++- .../composeResources/values-nl/strings.xml | 10 ++- .../composeResources/values-pl/strings.xml | 10 ++- .../composeResources/values-pt/strings.xml | 10 ++- .../composeResources/values-ru/strings.xml | 10 ++- .../composeResources/values/strings.xml | 10 ++- .../kotlin/com/vnidrop/app/core/CoreModels.kt | 1 + .../com/vnidrop/app/core/CoreRepository.kt | 28 ++++++++ .../com/vnidrop/app/core/FileSystemService.kt | 2 +- .../app/feature/settings/SettingsRoute.kt | 1 + .../app/feature/settings/SettingsScreen.kt | 13 +++- .../app/feature/settings/SettingsViewModel.kt | 41 ++++++++++-- .../app/feature/settings/StorageSettings.kt | 67 ++++++++++++++++++- .../com/vnidrop/app/feature/ViewModelsTest.kt | 51 ++++++++++++++ .../kotlin/com/vnidrop/app/support/Fakes.kt | 8 ++- .../vnidrop/app/core/FileSystemService.jvm.kt | 25 ++++++- .../app/core/CoreRepositoryStorageTest.kt | 43 ++++++++++++ .../vnidrop/app/core/FileSystemServiceTest.kt | 29 ++++++++ .../vnidrop/app/ui/FoundationComposeTest.kt | 16 ++++- 27 files changed, 457 insertions(+), 47 deletions(-) create mode 100644 crates/vnidrop/tests/storage.rs create mode 100644 shared/src/jvmTest/kotlin/com/vnidrop/app/core/CoreRepositoryStorageTest.kt create mode 100644 shared/src/jvmTest/kotlin/com/vnidrop/app/core/FileSystemServiceTest.kt diff --git a/crates/vnidrop/CORE_FLOW.md b/crates/vnidrop/CORE_FLOW.md index 6fa08e9..f0d1fa4 100644 --- a/crates/vnidrop/CORE_FLOW.md +++ b/crates/vnidrop/CORE_FLOW.md @@ -91,6 +91,11 @@ or temporary tag. Receive downloads keep a temporary tag through export and beco reclaimable after publication. Restart reconciliation repairs active-share tags, removes orphan share tags, and never restores a stopped share. +The explicit transfer-cache action is available only when no transfer or share is +active. It shuts the core down cleanly, removes the app-owned blob store, and then +restarts the core with the same identity and network configuration. Deleting all +transfer records invokes the same cleanup after live shares have been stopped. + ## Resource Limits `CoreLimits` controls source count, collection files and bytes, path and ticket diff --git a/crates/vnidrop/src/api.rs b/crates/vnidrop/src/api.rs index cfb3f50..e387075 100644 --- a/crates/vnidrop/src/api.rs +++ b/crates/vnidrop/src/api.rs @@ -2,8 +2,9 @@ use anyhow::Context; use iroh::RelayUrl; use iroh_blobs::Hash; use serde::{Deserialize, Serialize}; -use std::{collections::BTreeSet, net::IpAddr, str::FromStr}; +use std::{collections::BTreeSet, net::IpAddr, path::PathBuf, str::FromStr}; +use crate::error::VnidropError; use crate::util::{non_empty, now_ms}; pub(crate) const MAX_CUSTOM_RELAYS: usize = 8; @@ -126,6 +127,49 @@ pub fn default_core_network_config() -> CoreNetworkConfig { CoreNetworkConfig::default() } +/// Removes the blob store after its owning core has shut down. +/// +/// Callers must verify that no transfer or share is active before shutdown. +#[uniffi::export] +pub fn clear_inactive_transfer_cache(app_data_dir: String) -> Result { + let result = (|| -> anyhow::Result { + let app_data_dir = PathBuf::from(app_data_dir); + anyhow::ensure!( + app_data_dir.is_absolute(), + "app data directory must be absolute" + ); + anyhow::ensure!( + app_data_dir.file_name().is_some(), + "app data directory must not be a filesystem root" + ); + let blobs = app_data_dir.join("blobs"); + let metadata = match std::fs::symlink_metadata(&blobs) { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(0), + Err(error) => return Err(error.into()), + }; + anyhow::ensure!( + metadata.is_dir() && !metadata.file_type().is_symlink(), + "blob store path is not a directory" + ); + let bytes = walkdir::WalkDir::new(&blobs) + .follow_links(false) + .into_iter() + .try_fold(0u64, |total, entry| { + let entry = entry?; + let metadata = entry.metadata()?; + Ok::<_, walkdir::Error>(if metadata.is_file() { + total.saturating_add(metadata.len()) + } else { + total + }) + })?; + std::fs::remove_dir_all(blobs)?; + Ok(bytes) + })(); + result.map_err(VnidropError::filesystem) +} + #[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)] pub struct CoreLimits { pub max_sources: u64, diff --git a/crates/vnidrop/src/lib.rs b/crates/vnidrop/src/lib.rs index 6028dd7..1ed14d8 100644 --- a/crates/vnidrop/src/lib.rs +++ b/crates/vnidrop/src/lib.rs @@ -14,11 +14,11 @@ mod transfer_state; mod util; pub use api::{ - default_core_limits, default_core_network_config, CoreEvent, CoreEventSink, CoreLimits, - CoreNetworkConfig, CoreRelayMode, CoreStorageUsage, PublishedOutput, ReceiveOutputSink, - ReceiveOutputSinkV2, ReceivedArtifact, ReceivedLocatorKind, ReceiverRequest, RuntimeStatus, - ShareMetadataInput, ShareResult, ShareSource, SourceKind, StoredTransfer, TicketInspection, - TransferAccessMode, TransferMetadata, + clear_inactive_transfer_cache, default_core_limits, default_core_network_config, CoreEvent, + CoreEventSink, CoreLimits, CoreNetworkConfig, CoreRelayMode, CoreStorageUsage, PublishedOutput, + ReceiveOutputSink, ReceiveOutputSinkV2, ReceivedArtifact, ReceivedLocatorKind, ReceiverRequest, + RuntimeStatus, ShareMetadataInput, ShareResult, ShareSource, SourceKind, StoredTransfer, + TicketInspection, TransferAccessMode, TransferMetadata, }; pub use error::VnidropError; pub use runtime::VnidropCore; diff --git a/crates/vnidrop/tests/storage.rs b/crates/vnidrop/tests/storage.rs new file mode 100644 index 0000000..248151b --- /dev/null +++ b/crates/vnidrop/tests/storage.rs @@ -0,0 +1,26 @@ +use vnidrop::clear_inactive_transfer_cache; + +#[test] +fn inactive_transfer_cache_is_removed_and_reports_reclaimed_bytes() { + let app_data = tempfile::tempdir().unwrap(); + let blobs = app_data.path().join("blobs"); + std::fs::create_dir_all(blobs.join("data")).unwrap(); + std::fs::write(blobs.join("data").join("payload"), vec![9u8; 4096]).unwrap(); + std::fs::write(blobs.join("blobs.db"), vec![3u8; 512]).unwrap(); + + let reclaimed = + clear_inactive_transfer_cache(app_data.path().to_string_lossy().into_owned()).unwrap(); + + assert_eq!(reclaimed, 4608); + assert!(!blobs.exists()); +} + +#[test] +fn inactive_transfer_cache_rejects_relative_app_data_paths() { + assert!(clear_inactive_transfer_cache("relative/path".to_string()).is_err()); +} + +#[test] +fn inactive_transfer_cache_rejects_filesystem_roots() { + assert!(clear_inactive_transfer_cache(std::path::MAIN_SEPARATOR_STR.to_string()).is_err()); +} diff --git a/shared/src/androidMain/kotlin/com/vnidrop/app/core/FileSystemService.android.kt b/shared/src/androidMain/kotlin/com/vnidrop/app/core/FileSystemService.android.kt index fb8bc5b..9bb4d11 100644 --- a/shared/src/androidMain/kotlin/com/vnidrop/app/core/FileSystemService.android.kt +++ b/shared/src/androidMain/kotlin/com/vnidrop/app/core/FileSystemService.android.kt @@ -103,7 +103,7 @@ private class AndroidFileSystemService( return ReceivedStorageInspection(bytes, existing, missing, inaccessible) } - override suspend fun temporaryUsage(): ULong = directorySize(context.cacheDir) + override suspend fun temporaryUsage(receiveFolder: ReceiveFolder): ULong = directorySize(context.cacheDir) override fun createReceiveOutputSink(folder: ReceiveFolder): ReceiveOutputSinkV2? = when (folder.kind) { diff --git a/shared/src/commonMain/composeResources/values-de/strings.xml b/shared/src/commonMain/composeResources/values-de/strings.xml index a4b86a1..88f4974 100644 --- a/shared/src/commonMain/composeResources/values-de/strings.xml +++ b/shared/src/commonMain/composeResources/values-de/strings.xml @@ -231,16 +231,20 @@ Wird empfangen Beendet Wird berechnet… + Übertragungscache leeren + Entfernt zwischengespeicherte Übertragungsinhalte, die nicht von einem laufenden Empfang oder einer aktiven Freigabe verwendet werden. Empfangene Dateien und der Übertragungsverlauf werden nicht gelöscht. + Cache wird geleert… Alle Übertragungen löschen - Dadurch werden alle Datensätze gesendeter und empfangener Übertragungen aus Ihrem Verlauf gelöscht. Ihre empfangenen Dateien werden nicht gelöscht. Nicht mehr benötigte zwischengespeicherte freigegebene Inhalte werden automatisch bereinigt; dies kann etwas dauern. Dies kann nicht rückgängig gemacht werden. + Dadurch werden alle Datensätze gesendeter und empfangener Übertragungen aus Ihrem Verlauf gelöscht und nicht benötigter Übertragungscache sofort freigegeben. Laufende Übertragungen und empfangene Dateien werden nicht gelöscht. Dies kann nicht rückgängig gemacht werden. App-Daten Wird gelöscht… - Übertragungsdaten umfassen Ihren Verlauf und zwischengespeicherte Inhalte aktiver Freigaben. Nicht mehr benötigter Cache wird nach dem Löschen der Datensätze automatisch bereinigt; dies kann etwas dauern. Empfangene Dateien werden für diese Übersicht erfasst, aber hier niemals gelöscht. + Der Übertragungscache enthält heruntergeladene oder importierte Inhalte, einschließlich Daten, die durch laufende Empfänge und aktive Freigaben geschützt sind. Beim Leeren wird nur nicht benötigter Cache entfernt. Empfangene Dateien werden hier niemals gelöscht. Empfangene Dateien Temporäre Dateien Speicher Gesamt - Übertragungsdaten + Übertragungscache + Übertragungscache geleert Alle Übertragungen gelöscht Wichtige Aktualisierungen zu dieser Übertragung ansehen Aktivität diff --git a/shared/src/commonMain/composeResources/values-es/strings.xml b/shared/src/commonMain/composeResources/values-es/strings.xml index 826cf85..cadea0e 100644 --- a/shared/src/commonMain/composeResources/values-es/strings.xml +++ b/shared/src/commonMain/composeResources/values-es/strings.xml @@ -231,16 +231,20 @@ Recibiendo Detenido Calculando… + Borrar caché de transferencias + Elimina el contenido de transferencia en caché que no esté siendo utilizado por una recepción en curso o un recurso compartido activo. Los archivos recibidos y el historial no se eliminan. + Borrando caché… Eliminar todas las transferencias - Esto borra de su historial todos los registros de transferencias enviadas y recibidas. Sus archivos recibidos no se eliminan. El contenido compartido en caché que ya no se necesita se recupera automáticamente, lo que puede tardar un poco. Esto no se puede deshacer. + Esto borra del historial todos los registros de transferencias enviadas y recibidas y libera inmediatamente la caché de transferencia no utilizada. Las transferencias en curso y los archivos recibidos no se eliminan. Esto no se puede deshacer. Datos de la aplicación Eliminando… - Los datos de transferencia incluyen su historial y el contenido en caché de los recursos compartidos activos. La caché innecesaria se recupera automáticamente tras eliminar los registros, lo que puede tardar un poco. Los archivos recibidos se registran para este resumen, pero nunca se eliminan aquí. + La caché de transferencias incluye contenido descargado o importado, incluidos los datos protegidos por recepciones en curso y recursos compartidos activos. Al borrarla solo se elimina la caché no utilizada. Los archivos recibidos nunca se eliminan aquí. Archivos recibidos Archivos temporales Almacenamiento Total - Datos de transferencia + Caché de transferencias + Caché de transferencias borrada Todas las transferencias eliminadas Vea las actualizaciones importantes de esta transferencia Actividad diff --git a/shared/src/commonMain/composeResources/values-fr/strings.xml b/shared/src/commonMain/composeResources/values-fr/strings.xml index 2d646c6..ec03364 100644 --- a/shared/src/commonMain/composeResources/values-fr/strings.xml +++ b/shared/src/commonMain/composeResources/values-fr/strings.xml @@ -231,16 +231,20 @@ Réception Arrêté Calcul… + Vider le cache des transferts + Supprime le contenu de transfert en cache qui n’est pas utilisé par une réception en cours ou un partage actif. Les fichiers reçus et l’historique ne sont pas supprimés. + Vidage du cache… Supprimer tous les transferts - Cela efface de votre historique tous les enregistrements de transferts envoyés et reçus. Vos fichiers reçus ne sont pas supprimés. Le contenu partagé mis en cache qui n’est plus nécessaire est récupéré automatiquement, ce qui peut prendre un peu de temps. Cette action est irréversible. + Cela efface tous les transferts envoyés et reçus de l’historique et libère immédiatement le cache inutilisé. Les transferts en cours et les fichiers reçus ne sont pas supprimés. Cette action est irréversible. Données de l’app Suppression… - Les données de transfert comprennent votre historique et le contenu mis en cache pour les partages actifs. Le cache inutile est récupéré automatiquement après la suppression des enregistrements, ce qui peut prendre un peu de temps. Les fichiers reçus sont suivis pour ce récapitulatif, mais ne sont jamais supprimés ici. + Le cache des transferts comprend le contenu téléchargé ou importé, y compris les données protégées par les réceptions en cours et les partages actifs. Le vider ne supprime que le cache inutilisé. Les fichiers reçus ne sont jamais supprimés ici. Fichiers reçus Fichiers temporaires Stockage Total - Données de transfert + Cache des transferts + Cache des transferts vidé Tous les transferts supprimés Consultez les mises à jour importantes de ce transfert Activité diff --git a/shared/src/commonMain/composeResources/values-it/strings.xml b/shared/src/commonMain/composeResources/values-it/strings.xml index 4d41143..ec630da 100644 --- a/shared/src/commonMain/composeResources/values-it/strings.xml +++ b/shared/src/commonMain/composeResources/values-it/strings.xml @@ -231,16 +231,20 @@ Ricezione Interrotto Calcolo… + Svuota cache trasferimenti + Rimuove il contenuto dei trasferimenti memorizzato nella cache che non è usato da una ricezione in corso o da una condivisione attiva. I file ricevuti e la cronologia non vengono eliminati. + Svuotamento cache… Elimina tutti i trasferimenti - Questo cancella dalla cronologia tutti i record dei trasferimenti inviati e ricevuti. I file ricevuti non vengono eliminati. Il contenuto condiviso nella cache che non serve più viene recuperato automaticamente, operazione che può richiedere un po’ di tempo. Questa azione non può essere annullata. + Elimina dalla cronologia tutti i trasferimenti inviati e ricevuti e libera immediatamente la cache inutilizzata. I trasferimenti in corso e i file ricevuti non vengono eliminati. Questa azione non può essere annullata. Dati dell’app Eliminazione… - I dati di trasferimento includono la cronologia e il contenuto nella cache per le condivisioni attive. La cache non necessaria viene recuperata automaticamente dopo la rimozione dei record, operazione che può richiedere un po’ di tempo. I file ricevuti vengono monitorati per questo riepilogo, ma non sono mai eliminati qui. + La cache dei trasferimenti include contenuti scaricati o importati, compresi i dati protetti da ricezioni in corso e condivisioni attive. Lo svuotamento rimuove solo la cache inutilizzata. I file ricevuti non vengono mai eliminati qui. File ricevuti File temporanei Archiviazione Totale - Dati di trasferimento + Cache trasferimenti + Cache trasferimenti svuotata Tutti i trasferimenti eliminati Veda gli aggiornamenti importanti di questo trasferimento Attività diff --git a/shared/src/commonMain/composeResources/values-nl/strings.xml b/shared/src/commonMain/composeResources/values-nl/strings.xml index 5632874..c7c35fe 100644 --- a/shared/src/commonMain/composeResources/values-nl/strings.xml +++ b/shared/src/commonMain/composeResources/values-nl/strings.xml @@ -231,16 +231,20 @@ Ontvangen Gestopt Berekenen… + Overdrachtscache wissen + Verwijdert overdrachtsinhoud uit de cache die niet wordt gebruikt door een lopende ontvangst of actieve share. Ontvangen bestanden en de overdrachtsgeschiedenis worden niet verwijderd. + Cache wissen… Alle overdrachten verwijderen - Hiermee worden alle records van verzonden en ontvangen overdrachten uit uw geschiedenis gewist. Uw ontvangen bestanden worden niet verwijderd. Gedeelde inhoud in de cache die niet meer nodig is, wordt automatisch opgeruimd; dit kan enige tijd duren. Dit kan niet ongedaan worden gemaakt. + Hiermee worden alle verzonden en ontvangen overdrachten uit de geschiedenis gewist en wordt ongebruikte overdrachtscache direct vrijgemaakt. Lopende overdrachten en ontvangen bestanden worden niet verwijderd. Dit kan niet ongedaan worden gemaakt. Appgegevens Verwijderen… - Overdrachtsgegevens omvatten uw geschiedenis en inhoud in de cache voor actieve shares. Onnodige cache wordt automatisch opgeruimd nadat overdrachtsrecords zijn verwijderd; dit kan enige tijd duren. Ontvangen bestanden worden voor dit overzicht bijgehouden, maar hier nooit verwijderd. + De overdrachtscache bevat gedownloade of geïmporteerde inhoud, inclusief gegevens die door lopende ontvangsten en actieve shares worden beschermd. Wissen verwijdert alleen ongebruikte cache. Ontvangen bestanden worden hier nooit verwijderd. Ontvangen bestanden Tijdelijke bestanden Opslag Totaal - Overdrachtsgegevens + Overdrachtscache + Overdrachtscache gewist Alle overdrachten verwijderd Bekijk belangrijke updates voor deze overdracht Activiteit diff --git a/shared/src/commonMain/composeResources/values-pl/strings.xml b/shared/src/commonMain/composeResources/values-pl/strings.xml index 5d5c8f4..0ef6773 100644 --- a/shared/src/commonMain/composeResources/values-pl/strings.xml +++ b/shared/src/commonMain/composeResources/values-pl/strings.xml @@ -231,16 +231,20 @@ Odbieranie Zatrzymany Obliczanie… + Wyczyść pamięć podręczną transferów + Usuwa zawartość transferów z pamięci podręcznej, która nie jest używana przez trwające odbieranie ani aktywne udostępnianie. Odebrane pliki i historia nie są usuwane. + Czyszczenie pamięci podręcznej… Usuń wszystkie transfery - Spowoduje to usunięcie z historii wszystkich rekordów wysłanych i odebranych transferów. Odebrane pliki nie zostaną usunięte. Niepotrzebna już zawartość udostępniona w pamięci podręcznej jest odzyskiwana automatycznie, co może chwilę potrwać. Tej operacji nie można cofnąć. + Usuwa z historii wszystkie wysłane i odebrane transfery oraz natychmiast zwalnia nieużywaną pamięć podręczną. Trwające transfery i odebrane pliki nie są usuwane. Tej operacji nie można cofnąć. Dane aplikacji Usuwanie… - Dane transferu obejmują historię oraz zawartość w pamięci podręcznej dla aktywnych udostępnień. Niepotrzebna pamięć podręczna jest odzyskiwana automatycznie po usunięciu rekordów, co może chwilę potrwać. Odebrane pliki są śledzone na potrzeby tego podsumowania, ale nigdy nie są tu usuwane. + Pamięć podręczna transferów zawiera pobraną lub zaimportowaną zawartość, w tym dane chronione przez trwające odbieranie i aktywne udostępnianie. Czyszczenie usuwa tylko nieużywaną pamięć podręczną. Odebrane pliki nigdy nie są tutaj usuwane. Odebrane pliki Pliki tymczasowe Pamięć Łącznie - Dane transferu + Pamięć podręczna transferów + Wyczyszczono pamięć podręczną transferów Usunięto wszystkie transfery Zobacz ważne aktualizacje tego transferu Aktywność diff --git a/shared/src/commonMain/composeResources/values-pt/strings.xml b/shared/src/commonMain/composeResources/values-pt/strings.xml index 26e59da..7b7851f 100644 --- a/shared/src/commonMain/composeResources/values-pt/strings.xml +++ b/shared/src/commonMain/composeResources/values-pt/strings.xml @@ -231,16 +231,20 @@ A receber Parada A calcular… + Limpar cache de transferências + Remove conteúdo de transferência em cache que não esteja a ser utilizado por uma receção em curso ou partilha ativa. Os ficheiros recebidos e o histórico não são eliminados. + A limpar cache… Eliminar todas as transferências - Isto elimina do histórico todos os registos de transferências enviadas e recebidas. Os ficheiros recebidos não são eliminados. O conteúdo partilhado em cache que já não é necessário é recuperado automaticamente, o que pode demorar algum tempo. Esta ação não pode ser anulada. + Isto elimina do histórico todas as transferências enviadas e recebidas e liberta imediatamente a cache não utilizada. As transferências em curso e os ficheiros recebidos não são eliminados. Esta ação não pode ser anulada. Dados da aplicação A eliminar… - Os dados de transferência incluem o histórico e o conteúdo em cache das partilhas ativas. A cache desnecessária é recuperada automaticamente após a remoção dos registos, o que pode demorar algum tempo. Os ficheiros recebidos são acompanhados para este resumo, mas nunca são eliminados aqui. + A cache de transferências inclui conteúdo descarregado ou importado, incluindo dados protegidos por receções em curso e partilhas ativas. A limpeza remove apenas a cache não utilizada. Os ficheiros recebidos nunca são eliminados aqui. Ficheiros recebidos Ficheiros temporários Armazenamento Total - Dados de transferência + Cache de transferências + Cache de transferências limpa Todas as transferências eliminadas Ver as atualizações importantes desta transferência Atividade diff --git a/shared/src/commonMain/composeResources/values-ru/strings.xml b/shared/src/commonMain/composeResources/values-ru/strings.xml index 4fac253..f05064c 100644 --- a/shared/src/commonMain/composeResources/values-ru/strings.xml +++ b/shared/src/commonMain/composeResources/values-ru/strings.xml @@ -231,16 +231,20 @@ Получение Остановлено Вычисление… + Очистить кэш передач + Удаляет кэшированное содержимое передач, которое не используется текущим приёмом или активной раздачей. Полученные файлы и история передач не удаляются. + Очистка кэша… Удалить все передачи - Это удалит из истории все записи об отправленных и полученных передачах. Полученные файлы не удаляются. Кэшированное общее содержимое, которое больше не требуется, освобождается автоматически; это может занять некоторое время. Это действие нельзя отменить. + Это удалит из истории все отправленные и полученные передачи и немедленно освободит неиспользуемый кэш. Текущие передачи и полученные файлы не удаляются. Это действие нельзя отменить. Данные приложения Удаление… - Данные передачи включают историю и кэшированное содержимое активных раздач. Ненужный кэш освобождается автоматически после удаления записей; это может занять некоторое время. Полученные файлы учитываются в этой сводке, но никогда не удаляются здесь. + Кэш передач содержит загруженные или импортированные данные, включая содержимое, защищённое текущими приёмами и активными раздачами. Очистка удаляет только неиспользуемый кэш. Полученные файлы здесь никогда не удаляются. Полученные файлы Временные файлы Хранилище Всего - Данные передачи + Кэш передач + Кэш передач очищен Все передачи удалены Просматривайте важные обновления этой передачи Активность diff --git a/shared/src/commonMain/composeResources/values/strings.xml b/shared/src/commonMain/composeResources/values/strings.xml index 5cc6635..573617d 100644 --- a/shared/src/commonMain/composeResources/values/strings.xml +++ b/shared/src/commonMain/composeResources/values/strings.xml @@ -231,16 +231,20 @@ Receiving Stopped Calculating… + Clear transfer cache + Removes cached transfer content after briefly restarting VniDrop. Finish ongoing transfers and stop active shares first. Received files and transfer history are not deleted. + Clearing cache… Delete all transfers - This clears all sent and received transfer records from your history. Your received files are not deleted. Cached shared content that is no longer needed is reclaimed automatically, which may take a little time. This can’t be undone. + This clears all sent and received transfer records from your history and immediately reclaims unused transfer cache. Ongoing transfers and received files are not deleted. This can’t be undone. App data Deleting… - Transfer data includes your history and cached content for active shares. Unneeded cache is reclaimed automatically after transfer records are removed, which may take a little time. Received files are tracked for this summary but are never deleted here. + Transfer cache includes downloaded or imported content used by transfers and shares. It can be cleared when no transfer or share is active. Received files are tracked for this summary but are never deleted here. Received files Temporary files Storage Total - Transfer data + Transfer cache + Transfer cache cleared All transfers deleted See important updates for this transfer Activity diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/core/CoreModels.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/core/CoreModels.kt index c6dac49..f3c7768 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/core/CoreModels.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/core/CoreModels.kt @@ -182,6 +182,7 @@ interface CoreGateway { suspend fun receiveWithOutputSink(ticket: String, outputSink: ReceiveOutputSink, receiverName: String): Result suspend fun receiveWithOutputSinkV2(ticket: String, outputSink: ReceiveOutputSinkV2, receiverName: String): Result suspend fun storageUsage(): Result + suspend fun clearTransferCache(): Result suspend fun receivedArtifacts(): Result> suspend fun cancel(transferId: ULong): Result suspend fun delete(transferId: ULong): Result diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/core/CoreRepository.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/core/CoreRepository.kt index 0694237..a467f48 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/core/CoreRepository.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/core/CoreRepository.kt @@ -30,6 +30,7 @@ import uniffi.vnidrop.TicketInspection import uniffi.vnidrop.TransferMetadata import uniffi.vnidrop.TransferAccessMode import uniffi.vnidrop.VnidropCore +import uniffi.vnidrop.clearInactiveTransferCache import uniffi.vnidrop.defaultCoreNetworkConfig class CoreRepository( @@ -45,6 +46,8 @@ class CoreRepository( override val signals: SharedFlow = _signals.asSharedFlow() private var core: VnidropCore? = null + private var currentAppDataDir: String? = null + private var currentRelaySettings = RelaySettings() private val lifecycleGate = CoreLifecycleGate() private val sink = object : CoreEventSink { @@ -77,6 +80,8 @@ class CoreRepository( previousCore?.shutdown() core = null core = VnidropCore.initializeWithNetworkConfig(appDataDir, sink, relaySettings.toNative()) + currentAppDataDir = appDataDir + currentRelaySettings = relaySettings refreshSnapshot(requireCore()) _state.update { it.copy(isInitialized = true) } } @@ -84,6 +89,7 @@ class CoreRepository( override fun shutdown() { core?.shutdown() core = null + currentAppDataDir = null _state.value = CoreState() } @@ -168,6 +174,28 @@ class CoreRepository( ) } + override suspend fun clearTransferCache(): Result = runReconfiguration { + val activeCore = requireCore() + val status = activeCore.status() + require(status.activeTransfers == 0UL && status.activeShares == 0UL) { + "Wait for active transfers and shares to finish before clearing the transfer cache" + } + val appDataDir = requireNotNull(currentAppDataDir) { "Core data directory is unavailable" } + val relaySettings = currentRelaySettings + _state.update { it.copy(isInitialized = false, status = null) } + activeCore.shutdown() + core = null + var reclaimed = 0UL + try { + reclaimed = clearInactiveTransferCache(appDataDir) + } finally { + core = VnidropCore.initializeWithNetworkConfig(appDataDir, sink, relaySettings.toNative()) + refreshSnapshot(requireCore()) + _state.update { it.copy(isInitialized = true) } + } + reclaimed + } + override suspend fun receivedArtifacts(): Result> = runCore { activeCore -> activeCore.listReceivedArtifacts().map { artifact -> ReceivedArtifactModel( diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/core/FileSystemService.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/core/FileSystemService.kt index 537f0b6..50355d4 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/core/FileSystemService.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/core/FileSystemService.kt @@ -40,7 +40,7 @@ interface FileSystemService { if (supportsCustomReceiveFolders) configuredFolder else defaultReceiveFolder() suspend fun validateReceiveFolder(folder: ReceiveFolder): FolderAccessStatus suspend fun inspectReceivedArtifacts(artifacts: List): ReceivedStorageInspection - suspend fun temporaryUsage(): ULong + suspend fun temporaryUsage(receiveFolder: ReceiveFolder): ULong fun createReceiveOutputSink(folder: ReceiveFolder): ReceiveOutputSinkV2? fun canRevealReceiveFolder(folder: ReceiveFolder): Boolean = false suspend fun revealReceiveFolder(folder: ReceiveFolder): Result = diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsRoute.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsRoute.kt index e9ec600..16bbdd3 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsRoute.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsRoute.kt @@ -41,5 +41,6 @@ fun SettingsRoute(viewModel: SettingsViewModel, windowClass: WindowClass) { onBugIncludeLogsChanged = viewModel::setBugIncludeLogs, onSubmitBugReport = viewModel::submitBugReport, onDeleteAllTransfers = viewModel::deleteAllTransfers, + onClearTransferCache = viewModel::clearTransferCache, ) } diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsScreen.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsScreen.kt index dbb29b6..920f983 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsScreen.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsScreen.kt @@ -31,6 +31,7 @@ fun SettingsScreen( onBugIncludeLogsChanged: (Boolean) -> Unit, onSubmitBugReport: () -> Unit, onDeleteAllTransfers: () -> Unit = {}, + onClearTransferCache: () -> Unit = {}, onRelayModeChanged: (RelayMode) -> Unit = {}, onRelayUrlChanged: (Int, String) -> Unit = { _, _ -> }, onAddRelayUrl: () -> Unit = {}, @@ -67,6 +68,7 @@ fun SettingsScreen( onBugIncludeLogsChanged = onBugIncludeLogsChanged, onSubmitBugReport = onSubmitBugReport, onDeleteAllTransfers = onDeleteAllTransfers, + onClearTransferCache = onClearTransferCache, onRelayModeChanged = onRelayModeChanged, onRelayUrlChanged = onRelayUrlChanged, onAddRelayUrl = onAddRelayUrl, @@ -107,6 +109,7 @@ fun SettingsScreen( onBugIncludeLogsChanged = onBugIncludeLogsChanged, onSubmitBugReport = onSubmitBugReport, onDeleteAllTransfers = onDeleteAllTransfers, + onClearTransferCache = onClearTransferCache, onRelayModeChanged = onRelayModeChanged, onRelayUrlChanged = onRelayUrlChanged, onAddRelayUrl = onAddRelayUrl, @@ -139,6 +142,7 @@ private fun SettingsSectionContent( onBugIncludeLogsChanged: (Boolean) -> Unit, onSubmitBugReport: () -> Unit, onDeleteAllTransfers: () -> Unit, + onClearTransferCache: () -> Unit, onRelayModeChanged: (RelayMode) -> Unit, onRelayUrlChanged: (Int, String) -> Unit, onAddRelayUrl: () -> Unit, @@ -160,7 +164,14 @@ private fun SettingsSectionContent( showBack = showBack, ) SettingsSection.Notifications -> NotificationSettings(state, onNotificationsChanged, onOpenNotificationSettings, onBack, showBack) - SettingsSection.Storage -> StorageSettings(state, windowClass, onDeleteAllTransfers, onBack, showBack) + SettingsSection.Storage -> StorageSettings( + state, + windowClass, + onDeleteAllTransfers, + onClearTransferCache, + onBack, + showBack, + ) SettingsSection.About -> AboutSettings( state = state, onDiagnosticsChanged = onDiagnosticsChanged, diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsViewModel.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsViewModel.kt index 48a96eb..1bde443 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsViewModel.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsViewModel.kt @@ -49,6 +49,8 @@ import vnidrop.shared.generated.resources.notifications_permission_denied import vnidrop.shared.generated.resources.notifications_settings_open_failed import vnidrop.shared.generated.resources.notifications_unsupported import vnidrop.shared.generated.resources.relay_settings_applied +import vnidrop.shared.generated.resources.storage_transfer_cache_cleared +import vnidrop.shared.generated.resources.storage_transfers_deleted enum class SettingsSection { Overview, @@ -111,6 +113,7 @@ data class SettingsState( val storage: StorageBreakdown? = null, val isCalculatingStorage: Boolean = false, val isDeletingTransfers: Boolean = false, + val isClearingTransferCache: Boolean = false, ) { val hasRelaySettingsChanges: Boolean get() = relayMode != savedRelaySettings.mode || @@ -208,6 +211,7 @@ class SettingsViewModel( viewModelScope.launch { _state.update { it.copy(isCalculatingStorage = true) } try { + val receiveFolder = _state.value.receiveFolder ?: fileSystemService.defaultReceiveFolder() val coreUsage = repository.storageUsage().getOrThrow() val artifacts = repository.receivedArtifacts().getOrThrow() val received = fileSystemService.inspectReceivedArtifacts(artifacts) @@ -216,7 +220,7 @@ class SettingsViewModel( storage = StorageBreakdown( transferCacheBytes = coreUsage.blobStoreBytes, appDataBytes = coreUsage.appDataBytes, - temporaryBytes = fileSystemService.temporaryUsage(), + temporaryBytes = fileSystemService.temporaryUsage(receiveFolder), receivedBytes = received.existingBytes, receivedFileCount = received.existingCount, missingReceivedFileCount = received.missingCount, @@ -235,18 +239,45 @@ class SettingsViewModel( } fun deleteAllTransfers() { - if (_state.value.isDeletingTransfers) return + if (_state.value.isDeletingTransfers || _state.value.isClearingTransferCache) return viewModelScope.launch { _state.update { it.copy(isDeletingTransfers = true) } val failures = repository.state.value.transfers .map { repository.delete(it.transferId) } .count { it.isFailure } + val cleanup = repository.clearTransferCache() _state.update { it.copy(isDeletingTransfers = false) } - if (failures == 0) { - loadStorageUsage() - } else { + loadStorageUsage() + if (failures > 0) { messages.error(IllegalStateException("Could not delete $failures transfer records")) + } else if (cleanup.isSuccess) { + messages.tryShow(UiMessage(UiText.Resource(Res.string.storage_transfers_deleted), UiMessageTone.Success)) } + cleanup.onFailure(messages::error) + } + } + + fun clearTransferCache() { + if ( + _state.value.isDeletingTransfers || + _state.value.isClearingTransferCache || + _state.value.hasActiveNetworkWork + ) return + viewModelScope.launch { + _state.update { it.copy(isClearingTransferCache = true) } + repository.clearTransferCache().fold( + onSuccess = { + _state.update { it.copy(isClearingTransferCache = false) } + loadStorageUsage() + messages.tryShow( + UiMessage(UiText.Resource(Res.string.storage_transfer_cache_cleared), UiMessageTone.Success), + ) + }, + onFailure = { error -> + _state.update { it.copy(isClearingTransferCache = false) } + messages.error(error) + }, + ) } } diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/StorageSettings.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/StorageSettings.kt index 7011977..d5d48fc 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/StorageSettings.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/StorageSettings.kt @@ -33,6 +33,9 @@ import vnidrop.shared.generated.resources.Res import vnidrop.shared.generated.resources.button_cancel import vnidrop.shared.generated.resources.storage_app_data import vnidrop.shared.generated.resources.storage_calculating +import vnidrop.shared.generated.resources.storage_clear_transfer_cache +import vnidrop.shared.generated.resources.storage_clear_transfer_cache_description +import vnidrop.shared.generated.resources.storage_clearing_transfer_cache import vnidrop.shared.generated.resources.storage_delete_transfers import vnidrop.shared.generated.resources.storage_delete_transfers_description import vnidrop.shared.generated.resources.storage_deleting @@ -48,10 +51,12 @@ internal fun StorageSettings( state: SettingsState, windowClass: WindowClass, onDeleteAllTransfers: () -> Unit, + onClearTransferCache: () -> Unit, onBack: () -> Unit, showBack: Boolean, ) { var showDeleteConfirmation by rememberSaveable { mutableStateOf(false) } + var showClearCacheConfirmation by rememberSaveable { mutableStateOf(false) } Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { SettingsTopBar(stringResource(Res.string.storage_title), onBack, showBack) val storage = state.storage @@ -79,9 +84,25 @@ internal fun StorageSettings( ) } } + SecondaryButton( + text = stringResource( + if (state.isClearingTransferCache) { + Res.string.storage_clearing_transfer_cache + } else { + Res.string.storage_clear_transfer_cache + }, + ), + onClick = { showClearCacheConfirmation = true }, + enabled = !state.isDeletingTransfers && + !state.isClearingTransferCache && + !state.isCalculatingStorage && + !state.hasActiveNetworkWork, + ) Button( onClick = { showDeleteConfirmation = true }, - enabled = !state.isDeletingTransfers, + enabled = !state.isDeletingTransfers && + !state.isClearingTransferCache && + !state.isCalculatingStorage, colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error), ) { Text(stringResource(if (state.isDeletingTransfers) Res.string.storage_deleting else Res.string.storage_delete_transfers)) @@ -92,6 +113,20 @@ internal fun StorageSettings( color = LocalVniDropColors.current.foregroundLighter, ) } + if (showClearCacheConfirmation) { + AdaptiveDrawer( + windowClass = windowClass, + onDismissRequest = { showClearCacheConfirmation = false }, + ) { + ClearTransferCachePanel( + onCancel = { showClearCacheConfirmation = false }, + onConfirm = { + showClearCacheConfirmation = false + onClearTransferCache() + }, + ) + } + } if (showDeleteConfirmation) { AdaptiveDrawer( windowClass = windowClass, @@ -108,6 +143,36 @@ internal fun StorageSettings( } } +@Composable +private fun ClearTransferCachePanel( + onCancel: () -> Unit, + onConfirm: () -> Unit, +) { + Column( + Modifier.fillMaxWidth().padding(horizontal = 20.dp, vertical = 14.dp), + verticalArrangement = Arrangement.spacedBy(14.dp), + ) { + Text( + stringResource(Res.string.storage_clear_transfer_cache), + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + ) + Text( + stringResource(Res.string.storage_clear_transfer_cache_description), + color = LocalVniDropColors.current.foregroundLighter, + style = MaterialTheme.typography.bodyMedium, + ) + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp, Alignment.End)) { + SecondaryButton(stringResource(Res.string.button_cancel), onClick = onCancel) + DestructiveButton( + stringResource(Res.string.storage_clear_transfer_cache), + onClick = onConfirm, + modifier = Modifier.testTag("confirm-clear-transfer-cache"), + ) + } + } +} + @Composable private fun DeleteAllTransfersPanel( onCancel: () -> Unit, diff --git a/shared/src/commonTest/kotlin/com/vnidrop/app/feature/ViewModelsTest.kt b/shared/src/commonTest/kotlin/com/vnidrop/app/feature/ViewModelsTest.kt index 65d393a..7d9535a 100644 --- a/shared/src/commonTest/kotlin/com/vnidrop/app/feature/ViewModelsTest.kt +++ b/shared/src/commonTest/kotlin/com/vnidrop/app/feature/ViewModelsTest.kt @@ -137,6 +137,57 @@ class ViewModelsTest { assertEquals("Ada ", viewModel.state.value.username) } + @Test + fun settingsClearsTransferCacheExplicitly() = runTest { + Dispatchers.setMain(StandardTestDispatcher(testScheduler)) + val core = FakeCoreGateway().apply { + clearTransferCacheResult = Result.success(3_700_000_000UL) + } + val viewModel = settingsViewModel(repository = core) + advanceUntilIdle() + + viewModel.clearTransferCache() + advanceUntilIdle() + + assertEquals(1, core.clearTransferCacheCount) + assertFalse(viewModel.state.value.isClearingTransferCache) + } + + @Test + fun settingsDoesNotClearTransferCacheDuringActiveNetworkWork() = runTest { + Dispatchers.setMain(StandardTestDispatcher(testScheduler)) + val core = FakeCoreGateway().apply { + mutableState.value = CoreState(status = CoreStatus("endpoint", 1UL, 0UL)) + } + val viewModel = settingsViewModel(repository = core) + advanceUntilIdle() + + viewModel.clearTransferCache() + advanceUntilIdle() + + assertEquals(0, core.clearTransferCacheCount) + } + + @Test + fun settingsDeleteAllTransfersImmediatelyClearsUnusedCache() = runTest { + Dispatchers.setMain(StandardTestDispatcher(testScheduler)) + val core = FakeCoreGateway().apply { + mutableState.value = CoreState( + isInitialized = true, + transfers = listOf(receivedTransfer(41UL, TransferStatus.Done)), + ) + } + val viewModel = settingsViewModel(repository = core) + advanceUntilIdle() + + viewModel.deleteAllTransfers() + advanceUntilIdle() + + assertEquals(listOf(41UL), core.deletedTransfers) + assertEquals(1, core.clearTransferCacheCount) + assertFalse(viewModel.state.value.isDeletingTransfers) + } + @Test fun settingsAppliesNormalizedCustomRelaysAndPersistsThem() = runTest { Dispatchers.setMain(StandardTestDispatcher(testScheduler)) diff --git a/shared/src/commonTest/kotlin/com/vnidrop/app/support/Fakes.kt b/shared/src/commonTest/kotlin/com/vnidrop/app/support/Fakes.kt index 10ad3a0..4e09b09 100644 --- a/shared/src/commonTest/kotlin/com/vnidrop/app/support/Fakes.kt +++ b/shared/src/commonTest/kotlin/com/vnidrop/app/support/Fakes.kt @@ -47,10 +47,12 @@ class FakeCoreGateway : CoreGateway { var receiveSuspend: Boolean = false private var receiveGate: CompletableDeferred? = null var deleteResult: Result = Result.success(Unit) + var clearTransferCacheResult: Result = Result.success(0UL) var clearReceiveHistoryResult: Result = Result.success(0UL) val deletedTransfers = mutableListOf() val cancelledTransfers = mutableListOf() var clearReceiveHistoryCount = 0 + var clearTransferCacheCount = 0 var receiveCount = 0 var lastReceiveTicket: String? = null var lastReceiveReceiverName: String? = null @@ -154,6 +156,10 @@ class FakeCoreGateway : CoreGateway { override suspend fun storageUsage(): Result = Result.success( CoreStorageUsageModel(0UL, 0UL, 0UL, 0UL, 0UL), ) + override suspend fun clearTransferCache(): Result { + clearTransferCacheCount += 1 + return clearTransferCacheResult + } override suspend fun receivedArtifacts(): Result> = Result.success(emptyList()) override suspend fun cancel(transferId: ULong): Result { cancelledTransfers += transferId @@ -252,7 +258,7 @@ class FakeFileSystemService( override suspend fun validateReceiveFolder(folder: ReceiveFolder) = FolderAccessStatus.Writable override suspend fun inspectReceivedArtifacts(artifacts: List) = ReceivedStorageInspection(artifacts.fold(0UL) { total, item -> total + item.logicalSize }, artifacts.size, 0, 0) - override suspend fun temporaryUsage(): ULong = 0UL + override suspend fun temporaryUsage(receiveFolder: ReceiveFolder): ULong = 0UL override fun createReceiveOutputSink(folder: ReceiveFolder): ReceiveOutputSinkV2? = null override fun canRevealReceiveFolder(folder: ReceiveFolder) = canRevealFolder override suspend fun revealReceiveFolder(folder: ReceiveFolder): Result { diff --git a/shared/src/jvmMain/kotlin/com/vnidrop/app/core/FileSystemService.jvm.kt b/shared/src/jvmMain/kotlin/com/vnidrop/app/core/FileSystemService.jvm.kt index 8e1541b..c566035 100644 --- a/shared/src/jvmMain/kotlin/com/vnidrop/app/core/FileSystemService.jvm.kt +++ b/shared/src/jvmMain/kotlin/com/vnidrop/app/core/FileSystemService.jvm.kt @@ -4,6 +4,8 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import uniffi.vnidrop.ReceiveOutputSinkV2 import java.io.File +import java.nio.file.Files +import java.nio.file.LinkOption @Composable actual fun rememberFileSystemService(): FileSystemService = @@ -42,7 +44,9 @@ private class JvmFileSystemService : FileSystemService { return ReceivedStorageInspection(bytes, existing, missing, 0) } - override suspend fun temporaryUsage(): ULong = 0UL + override suspend fun temporaryUsage(receiveFolder: ReceiveFolder): ULong { + return desktopTemporaryUsage(receiveFolder) + } override fun createReceiveOutputSink(folder: ReceiveFolder): ReceiveOutputSinkV2? = null @@ -65,3 +69,22 @@ private class JvmFileSystemService : FileSystemService { return repository.shareSources(sources, transferName, senderName, accessPolicy) } } + +internal fun desktopTemporaryUsage(receiveFolder: ReceiveFolder): ULong { + if (receiveFolder.kind != ReceiveFolderKind.FileSystemPath) return 0UL + val root = File(receiveFolder.value).toPath() + if (!Files.isDirectory(root, LinkOption.NOFOLLOW_LINKS)) return 0UL + return runCatching { + Files.walk(root).use { paths -> + paths.iterator().asSequence() + .filter { path -> + val name = path.fileName?.toString().orEmpty() + Files.isRegularFile(path, LinkOption.NOFOLLOW_LINKS) && + name.startsWith(".") && + name.contains(".vnidrop-") && + name.endsWith(".part") + } + .fold(0UL) { total, path -> total + Files.size(path).toULong() } + } + }.getOrDefault(0UL) +} diff --git a/shared/src/jvmTest/kotlin/com/vnidrop/app/core/CoreRepositoryStorageTest.kt b/shared/src/jvmTest/kotlin/com/vnidrop/app/core/CoreRepositoryStorageTest.kt new file mode 100644 index 0000000..4665b54 --- /dev/null +++ b/shared/src/jvmTest/kotlin/com/vnidrop/app/core/CoreRepositoryStorageTest.kt @@ -0,0 +1,43 @@ +package com.vnidrop.app.core + +import java.nio.file.Files +import kotlin.io.path.createTempDirectory +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class CoreRepositoryStorageTest { + @Test + fun cacheClearWaitsForActiveSharesThenRestartsWithTheSameIdentity() = runTest { + val appData = createTempDirectory("vnidrop-cache-clear") + val source = Files.write(appData.resolve("source.bin"), ByteArray(64 * 1024) { 5 }) + val repository = CoreRepository() + try { + assertTrue( + repository.initialize( + appData.toString(), + RelaySettings(mode = RelayMode.LocalOnly), + ).isSuccess, + ) + val endpointId = repository.state.value.status?.endpointId + val share = repository.sharePath( + path = source.toString(), + transferName = "source.bin", + senderName = "Sender", + accessPolicy = ShareAccessPolicy.RequireApproval, + ).getOrThrow() + + assertTrue(repository.clearTransferCache().isFailure) + assertTrue(repository.state.value.isInitialized) + + repository.delete(share.transferId).getOrThrow() + assertTrue(repository.clearTransferCache().getOrThrow() >= 64UL * 1024UL) + assertTrue(repository.state.value.isInitialized) + assertEquals(endpointId, repository.state.value.status?.endpointId) + } finally { + repository.shutdown() + appData.toFile().deleteRecursively() + } + } +} diff --git a/shared/src/jvmTest/kotlin/com/vnidrop/app/core/FileSystemServiceTest.kt b/shared/src/jvmTest/kotlin/com/vnidrop/app/core/FileSystemServiceTest.kt new file mode 100644 index 0000000..b8909f7 --- /dev/null +++ b/shared/src/jvmTest/kotlin/com/vnidrop/app/core/FileSystemServiceTest.kt @@ -0,0 +1,29 @@ +package com.vnidrop.app.core + +import java.nio.file.Files +import kotlin.io.path.createDirectories +import kotlin.io.path.createTempDirectory +import kotlin.test.Test +import kotlin.test.assertEquals + +class FileSystemServiceTest { + @Test + fun desktopTemporaryUsageCountsOnlyVnidropPartFiles() { + val root = createTempDirectory("vnidrop-temporary-usage") + try { + val nested = root.resolve("nested").createDirectories() + Files.write(nested.resolve(".photo.jpg.vnidrop-test.part"), ByteArray(7)) + Files.write(nested.resolve("photo.jpg"), ByteArray(13)) + Files.write(nested.resolve(".unrelated.part"), ByteArray(17)) + + assertEquals( + 7UL, + desktopTemporaryUsage( + ReceiveFolder(ReceiveFolderKind.FileSystemPath, root.toString(), "Test"), + ), + ) + } finally { + root.toFile().deleteRecursively() + } + } +} diff --git a/shared/src/jvmTest/kotlin/com/vnidrop/app/ui/FoundationComposeTest.kt b/shared/src/jvmTest/kotlin/com/vnidrop/app/ui/FoundationComposeTest.kt index aeffd9b..bbf7060 100644 --- a/shared/src/jvmTest/kotlin/com/vnidrop/app/ui/FoundationComposeTest.kt +++ b/shared/src/jvmTest/kotlin/com/vnidrop/app/ui/FoundationComposeTest.kt @@ -176,6 +176,7 @@ class FoundationComposeTest { @Test fun storageDeleteAllTransfersRequiresConfirmation() = runComposeUiTest { var deleteRequested = false + var cacheClearRequested = false setContent { VniDropTheme(isDarkTheme = false) { SettingsScreen( @@ -196,15 +197,24 @@ class FoundationComposeTest { onBugIncludeLogsChanged = {}, onSubmitBugReport = {}, onDeleteAllTransfers = { deleteRequested = true }, + onClearTransferCache = { cacheClearRequested = true }, ) } } + onNodeWithText("Clear transfer cache").performClick() + onNodeWithText( + "Removes cached transfer content after briefly restarting VniDrop. " + + "Finish ongoing transfers and stop active shares first. Received files and transfer history are not deleted.", + ).assertIsDisplayed() + runOnIdle { assertFalse(cacheClearRequested) } + onNodeWithTag("confirm-clear-transfer-cache").performClick() + runOnIdle { assertTrue(cacheClearRequested) } + onNodeWithText("Delete all transfers").performClick() onNodeWithText( - "This clears all sent and received transfer records from your history. Your received files are not deleted. " + - "Cached shared content that is no longer needed is reclaimed automatically, which may take a little time. " + - "This can’t be undone.", + "This clears all sent and received transfer records from your history and immediately reclaims unused transfer cache. " + + "Ongoing transfers and received files are not deleted. This can’t be undone.", ).assertIsDisplayed() runOnIdle { assertFalse(deleteRequested) } From 0448137d849baad30ee44ef1d23cc8b0790d1e74 Mon Sep 17 00:00:00 2001 From: Hammed Abass Date: Fri, 24 Jul 2026 00:11:58 +0200 Subject: [PATCH 07/10] fix(core): abort send when provider stream closes --- crates/vnidrop/src/runtime/mod.rs | 2 + crates/vnidrop/src/runtime/provider.rs | 107 +++++++++++++++++-------- crates/vnidrop/src/tests/runtime.rs | 42 +++++++++- crates/vnidrop/tests/transfer.rs | 29 ++++++- 4 files changed, 144 insertions(+), 36 deletions(-) diff --git a/crates/vnidrop/src/runtime/mod.rs b/crates/vnidrop/src/runtime/mod.rs index aee54ce..cb13930 100644 --- a/crates/vnidrop/src/runtime/mod.rs +++ b/crates/vnidrop/src/runtime/mod.rs @@ -16,6 +16,8 @@ mod share; mod storage; pub use facade::VnidropCore; +#[cfg(test)] +pub(crate) use provider::{consume_request_updates, RequestStreamOutcome}; use std::{ collections::{HashMap, HashSet}, diff --git a/crates/vnidrop/src/runtime/provider.rs b/crates/vnidrop/src/runtime/provider.rs index d6263fa..9447626 100644 --- a/crates/vnidrop/src/runtime/provider.rs +++ b/crates/vnidrop/src/runtime/provider.rs @@ -10,6 +10,31 @@ use tokio::sync::mpsc; use super::CoreInner; use crate::access_policy::AccessDecision; +#[derive(Debug, PartialEq, Eq)] +pub(crate) enum RequestStreamOutcome { + TerminalUpdateReceived, + Aborted, +} + +pub(crate) async fn consume_request_updates( + mut rx: irpc::channel::mpsc::Receiver, + mut handle_update: impl FnMut(RequestUpdate), +) -> RequestStreamOutcome { + let mut terminal_update_received = false; + while let Ok(Some(update)) = rx.recv().await { + terminal_update_received |= matches!( + update, + RequestUpdate::Completed(_) | RequestUpdate::Aborted(_) + ); + handle_update(update); + } + if terminal_update_received { + RequestStreamOutcome::TerminalUpdateReceived + } else { + RequestStreamOutcome::Aborted + } +} + impl CoreInner { pub(super) async fn spawn_provider_event_task( self: &Arc, @@ -302,7 +327,7 @@ impl CoreInner { transfer_id: u64, connection_id: u64, request_id: u64, - mut rx: irpc::channel::mpsc::Receiver, + rx: irpc::channel::mpsc::Receiver, ) { // Request update tasks are tied to individual provider streams. Router // shutdown closes those streams; only the long-lived provider receiver @@ -318,35 +343,35 @@ impl CoreInner { .cloned(); let core = self.clone(); tokio::spawn(async move { - while let Ok(Some(update)) = rx.recv().await { - match update { - RequestUpdate::Started(started) => core.emit_transfer( - transfer_id, - "send", - "transfer", - "started", - json!({ - "connection_id": connection_id, - "request_id": request_id, - "endpoint_id": endpoint_id, - "hash": started.hash.to_string(), - "size": started.size, - "index": started.index, - }), - ), - RequestUpdate::Progress(progress) => core.emit_transfer( - transfer_id, - "send", - "transfer", - "progress", - json!({ - "connection_id": connection_id, - "request_id": request_id, - "endpoint_id": endpoint_id, - "end_offset": progress.end_offset, - }), - ), - RequestUpdate::Completed(_) => core.emit_transfer( + let outcome = consume_request_updates(rx, |update| match update { + RequestUpdate::Started(started) => core.emit_transfer( + transfer_id, + "send", + "transfer", + "started", + json!({ + "connection_id": connection_id, + "request_id": request_id, + "endpoint_id": endpoint_id, + "hash": started.hash.to_string(), + "size": started.size, + "index": started.index, + }), + ), + RequestUpdate::Progress(progress) => core.emit_transfer( + transfer_id, + "send", + "transfer", + "progress", + json!({ + "connection_id": connection_id, + "request_id": request_id, + "endpoint_id": endpoint_id, + "end_offset": progress.end_offset, + }), + ), + RequestUpdate::Completed(_) => { + core.emit_transfer( transfer_id, "send", "transfer", @@ -356,8 +381,10 @@ impl CoreInner { "request_id": request_id, "endpoint_id": endpoint_id, }), - ), - RequestUpdate::Aborted(_) => core.emit_transfer( + ); + } + RequestUpdate::Aborted(_) => { + core.emit_transfer( transfer_id, "send", "transfer", @@ -367,8 +394,22 @@ impl CoreInner { "request_id": request_id, "endpoint_id": endpoint_id, }), - ), + ); } + }) + .await; + if outcome == RequestStreamOutcome::Aborted { + core.emit_transfer( + transfer_id, + "send", + "transfer", + "aborted", + json!({ + "connection_id": connection_id, + "request_id": request_id, + "endpoint_id": endpoint_id, + }), + ); } }); } diff --git a/crates/vnidrop/src/tests/runtime.rs b/crates/vnidrop/src/tests/runtime.rs index 8b3ca76..9c3bee8 100644 --- a/crates/vnidrop/src/tests/runtime.rs +++ b/crates/vnidrop/src/tests/runtime.rs @@ -1,9 +1,16 @@ -use std::sync::Arc; +use std::{sync::Arc, time::Duration}; -use iroh_blobs::Hash; +use iroh_blobs::{ + provider::{ + events::{RequestUpdate, TransferCompleted}, + TransferStats, + }, + Hash, +}; use crate::{ repository::{PendingDeliveryReceiptInsert, Repository, TransferUpsert}, + runtime::{consume_request_updates, RequestStreamOutcome}, transfer_state::{TransferDirection, TransferStatus}, CoreEvent, CoreEventSink, VnidropCore, VnidropError, }; @@ -14,6 +21,37 @@ impl CoreEventSink for TestSink { fn on_event(&self, _event: CoreEvent) {} } +#[test] +fn provider_request_stream_distinguishes_success_from_silent_abort() { + let runtime = tokio::runtime::Runtime::new().unwrap(); + runtime.block_on(async { + let (completed_tx, completed_rx) = irpc::channel::mpsc::channel(1); + completed_tx + .send(RequestUpdate::Completed(TransferCompleted { + stats: Box::new(TransferStats { + payload_bytes_sent: 5, + other_bytes_sent: 0, + other_bytes_read: 0, + duration: Duration::ZERO, + }), + })) + .await + .unwrap(); + drop(completed_tx); + assert_eq!( + consume_request_updates(completed_rx, |_| {}).await, + RequestStreamOutcome::TerminalUpdateReceived + ); + + let (aborted_tx, aborted_rx) = irpc::channel::mpsc::channel::(1); + drop(aborted_tx); + assert_eq!( + consume_request_updates(aborted_rx, |_| {}).await, + RequestStreamOutcome::Aborted + ); + }); +} + #[test] fn initializes_and_reports_endpoint() { let temp = tempfile::tempdir().unwrap(); diff --git a/crates/vnidrop/tests/transfer.rs b/crates/vnidrop/tests/transfer.rs index 6544150..680f0d1 100644 --- a/crates/vnidrop/tests/transfer.rs +++ b/crates/vnidrop/tests/transfer.rs @@ -1,7 +1,28 @@ mod support; +use std::time::{Duration, Instant}; + use support::{receive_with_response, share_path, TestNode}; -use vnidrop::VnidropError; +use vnidrop::{CoreEvent, VnidropError}; + +fn wait_for_sender_transfer_event(sender: &TestNode, transfer_id: u64, kind: &str) -> CoreEvent { + let started = Instant::now(); + loop { + if let Some(event) = sender.sink.events().into_iter().find(|event| { + event.transfer_id == Some(transfer_id) + && event.direction.as_deref() == Some("send") + && event.phase == "transfer" + && event.kind == kind + }) { + return event; + } + assert!( + started.elapsed() < Duration::from_secs(5), + "timed out waiting for sender transfer event {kind}" + ); + std::thread::sleep(Duration::from_millis(10)); + } +} #[test] fn transfers_file_between_two_cores() { @@ -50,6 +71,12 @@ fn transfers_file_between_two_cores() { artifacts[0].locator, output_dir.path().join("hello.txt").to_string_lossy() ); + let completed = wait_for_sender_transfer_event(&sender, share.transfer_id, "completed"); + assert!(completed.data_json.contains("\"connection_id\":")); + assert!(completed.data_json.contains("\"request_id\":")); + assert!(completed + .data_json + .contains(receiver.core.status().endpoint_id.as_str())); receiver.core.delete_receive_history().unwrap(); assert_eq!(receiver.core.list_received_artifacts().unwrap(), artifacts); From 425500ecf2a6daf1b265abfe3d51909c64f0c0e1 Mon Sep 17 00:00:00 2001 From: Hammed Abass Date: Fri, 24 Jul 2026 14:52:09 +0200 Subject: [PATCH 08/10] fix(core): report receiver failures to sender --- apple/VniDrop/Core/CoreModels.swift | 1 + apple/VniDrop/Core/CoreRepository.swift | 1 + .../Features/Send/TransferDetailsView.swift | 4 +- apple/VniDrop/Resources/Localizable.xcstrings | 60 ++++++++++ crates/vnidrop/src/approval.rs | 45 ++++++- crates/vnidrop/src/handshake.rs | 25 ++++ crates/vnidrop/src/repository.rs | 110 +++++++++++++++++- crates/vnidrop/src/runtime/delivery.rs | 34 ++++-- crates/vnidrop/src/runtime/receive.rs | 55 ++++++++- crates/vnidrop/src/tests/repository.rs | 67 ++++++++++- crates/vnidrop/src/tests/runtime.rs | 1 + crates/vnidrop/src/transfer_state.rs | 3 + crates/vnidrop/tests/transfer.rs | 31 +++++ .../composeResources/values-de/strings.xml | 1 + .../composeResources/values-es/strings.xml | 1 + .../composeResources/values-fr/strings.xml | 1 + .../composeResources/values-it/strings.xml | 1 + .../composeResources/values-nl/strings.xml | 1 + .../composeResources/values-pl/strings.xml | 1 + .../composeResources/values-pt/strings.xml | 1 + .../composeResources/values-ru/strings.xml | 1 + .../composeResources/values/strings.xml | 1 + .../kotlin/com/vnidrop/app/core/CoreModels.kt | 1 + .../com/vnidrop/app/core/CoreRepository.kt | 1 + .../app/feature/send/TransferDetails.kt | 4 +- 25 files changed, 430 insertions(+), 22 deletions(-) diff --git a/apple/VniDrop/Core/CoreModels.swift b/apple/VniDrop/Core/CoreModels.swift index 33fe647..c2cda88 100644 --- a/apple/VniDrop/Core/CoreModels.swift +++ b/apple/VniDrop/Core/CoreModels.swift @@ -88,6 +88,7 @@ enum ReceiverDeliveryStatus: Equatable, Sendable { case refused case expired case completed + case failed case unknown } diff --git a/apple/VniDrop/Core/CoreRepository.swift b/apple/VniDrop/Core/CoreRepository.swift index d98e969..e7751ad 100644 --- a/apple/VniDrop/Core/CoreRepository.swift +++ b/apple/VniDrop/Core/CoreRepository.swift @@ -512,6 +512,7 @@ private extension ReceiverRequest { case "refused": return .refused case "expired": return .expired case "completed": return .completed + case "failed": return .failed default: return .unknown } } diff --git a/apple/VniDrop/Features/Send/TransferDetailsView.swift b/apple/VniDrop/Features/Send/TransferDetailsView.swift index 6b046b0..2145154 100644 --- a/apple/VniDrop/Features/Send/TransferDetailsView.swift +++ b/apple/VniDrop/Features/Send/TransferDetailsView.swift @@ -237,6 +237,7 @@ private struct ReceiverRow: View { let name = receiver.receiverName ?? receiver.receiverDeviceName ?? String(localized: "transfer_nearby_device") let showLive = sendProgress != nil && receiver.status != .completed && receiver.status != .refused && receiver.status != .expired + && receiver.status != .failed HStack(alignment: .top, spacing: 12) { VStack(alignment: .leading, spacing: 6) { Text(name).font(VniType.bodyLarge).lineLimit(1) @@ -397,6 +398,7 @@ extension ReceiverDeliveryStatus { case .refused: return "transfer_receiver_refused" case .expired: return "transfer_receiver_expired" case .completed: return "transfer_receiver_completed" + case .failed: return "transfer_receiver_failed" case .unknown: return "transfer_receiver_unknown" } } @@ -404,7 +406,7 @@ extension ReceiverDeliveryStatus { func statusColor(_ colors: VniDropColors) -> Color { switch self { case .completed: return colors.brandDefault - case .refused, .expired: return colors.destructiveDefault + case .refused, .expired, .failed: return colors.destructiveDefault default: return colors.foregroundLighter } } diff --git a/apple/VniDrop/Resources/Localizable.xcstrings b/apple/VniDrop/Resources/Localizable.xcstrings index f9e018b..1d0b86e 100644 --- a/apple/VniDrop/Resources/Localizable.xcstrings +++ b/apple/VniDrop/Resources/Localizable.xcstrings @@ -16581,6 +16581,66 @@ } } }, + "transfer_receiver_failed": { + "comment": "Receiver status: the transfer failed.", + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "needs_review", + "value": "Übertragung fehlgeschlagen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Transfer failed" + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "Transferencia fallida" + } + }, + "fr": { + "stringUnit": { + "state": "needs_review", + "value": "Transfert échoué" + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "Trasferimento non riuscito" + } + }, + "nl": { + "stringUnit": { + "state": "needs_review", + "value": "Overdracht mislukt" + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Przesyłanie nie powiodło się" + } + }, + "pt": { + "stringUnit": { + "state": "needs_review", + "value": "A transferência falhou" + } + }, + "ru": { + "stringUnit": { + "state": "needs_review", + "value": "Передача не удалась" + } + } + } + }, "transfer_receiver_expired": { "comment": "Receiver status: the request expired.", "extractionState": "manual", diff --git a/crates/vnidrop/src/approval.rs b/crates/vnidrop/src/approval.rs index 23075f9..c0f273d 100644 --- a/crates/vnidrop/src/approval.rs +++ b/crates/vnidrop/src/approval.rs @@ -8,7 +8,10 @@ use uuid::Uuid; use crate::{ access_policy::{AccessPolicy, APPROVAL_SESSION_TTL_MS}, event_hub::EventHub, - handshake::{DeliveryReceipt, DeliveryReceiptResponse, HandshakeResponse, RequestTransfer}, + handshake::{ + DeliveryFailureReceipt, DeliveryReceipt, DeliveryReceiptResponse, HandshakeResponse, + RequestTransfer, + }, repository::{ReceiverRequestInsert, Repository}, transfer_state::ReceiverRequestStatus, util::now_ms, @@ -70,6 +73,46 @@ impl ApprovalService { } } + pub(crate) async fn fail_delivery( + &self, + remote_endpoint_id: String, + receipt: DeliveryFailureReceipt, + ) -> DeliveryReceiptResponse { + let token_hash = receipt_token_hash(&receipt.token); + match self + .repository + .fail_receiver_delivery( + &receipt.request_id, + receipt.transfer_id, + &remote_endpoint_id, + &token_hash, + &receipt.reason, + ) + .await + { + Ok(()) => { + self.event_hub.emit_transfer( + receipt.transfer_id, + "send", + "delivery", + "receiver-failed", + json!({ + "request_id": receipt.request_id, + "remote_endpoint_id": remote_endpoint_id, + "reason": receipt.reason, + }), + ); + DeliveryReceiptResponse::Recorded + } + Err(error) => { + tracing::warn!(%error, "rejected receiver delivery failure"); + DeliveryReceiptResponse::Rejected { + reason: "invalid-receipt".to_string(), + } + } + } + } + pub(crate) fn new( repository: Repository, event_hub: Arc, diff --git a/crates/vnidrop/src/handshake.rs b/crates/vnidrop/src/handshake.rs index 385a220..3cc7638 100644 --- a/crates/vnidrop/src/handshake.rs +++ b/crates/vnidrop/src/handshake.rs @@ -72,6 +72,14 @@ impl ProtocolHandler for HandshakeService { .await; let _ = tx.send(response).await; } + HandshakeMessage::ReportDeliveryFailure(message) => { + let WithChannels { inner, tx, .. } = message; + let response = self + .approval + .fail_delivery(remote_endpoint_id.clone(), inner) + .await; + let _ = tx.send(response).await; + } } } @@ -109,6 +117,13 @@ impl HandshakeClient { ) -> Result { self.inner.rpc(receipt).await } + + pub(crate) async fn report_delivery_failure( + &self, + receipt: DeliveryFailureReceipt, + ) -> Result { + self.inner.rpc(receipt).await + } } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -140,6 +155,14 @@ pub(crate) struct DeliveryReceipt { pub(crate) token: String, } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub(crate) struct DeliveryFailureReceipt { + pub(crate) request_id: String, + pub(crate) transfer_id: u64, + pub(crate) token: String, + pub(crate) reason: String, +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub(crate) enum DeliveryReceiptResponse { Recorded, @@ -153,4 +176,6 @@ enum HandshakeProtocol { RequestTransfer(RequestTransfer), #[rpc(tx=oneshot::Sender)] ReportDelivery(DeliveryReceipt), + #[rpc(tx=oneshot::Sender)] + ReportDeliveryFailure(DeliveryFailureReceipt), } diff --git a/crates/vnidrop/src/repository.rs b/crates/vnidrop/src/repository.rs index e493888..b913e8e 100644 --- a/crates/vnidrop/src/repository.rs +++ b/crates/vnidrop/src/repository.rs @@ -20,7 +20,7 @@ use crate::{ util::now_ms, }; -const SCHEMA_VERSION: i64 = 6; +const SCHEMA_VERSION: i64 = 7; #[derive(Debug, Clone)] pub(crate) struct Repository { @@ -86,6 +86,7 @@ pub(crate) struct PendingDeliveryReceipt { pub(crate) request_id: String, pub(crate) sender_transfer_id: u64, pub(crate) token: String, + pub(crate) failure_reason: Option, } pub(crate) struct PendingDeliveryReceiptInsert<'a> { @@ -94,6 +95,7 @@ pub(crate) struct PendingDeliveryReceiptInsert<'a> { pub(crate) request_id: &'a str, pub(crate) sender_transfer_id: u64, pub(crate) token: &'a str, + pub(crate) failure_reason: Option<&'a str>, } impl Repository { @@ -294,12 +296,24 @@ impl Repository { sender_blob_ticket TEXT NOT NULL, sender_transfer_id INTEGER NOT NULL, token TEXT NOT NULL, + failure_reason TEXT, created_at INTEGER NOT NULL ); "#, ) .execute(&self.pool) .await?; + let receipt_columns = sqlx::query("PRAGMA table_info(pending_delivery_receipts)") + .fetch_all(&self.pool) + .await?; + if !receipt_columns + .iter() + .any(|row| row.get::(1) == "failure_reason") + { + sqlx::query("ALTER TABLE pending_delivery_receipts ADD COLUMN failure_reason TEXT") + .execute(&self.pool) + .await?; + } sqlx::query(&format!("PRAGMA user_version = {SCHEMA_VERSION}")) .execute(&self.pool) @@ -561,13 +575,14 @@ impl Repository { r#" INSERT INTO pending_delivery_receipts ( request_id, local_transfer_id, sender_blob_ticket, - sender_transfer_id, token, created_at - ) VALUES (?1, ?2, ?3, ?4, ?5, ?6) + sender_transfer_id, token, failure_reason, created_at + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7) ON CONFLICT(request_id) DO UPDATE SET local_transfer_id = excluded.local_transfer_id, sender_blob_ticket = excluded.sender_blob_ticket, sender_transfer_id = excluded.sender_transfer_id, - token = excluded.token + token = excluded.token, + failure_reason = excluded.failure_reason "#, ) .bind(receipt.request_id) @@ -575,6 +590,7 @@ impl Repository { .bind(receipt.sender_blob_ticket) .bind(to_db_id(receipt.sender_transfer_id)?) .bind(receipt.token) + .bind(receipt.failure_reason) .bind(now_ms()) .execute(&mut *transaction) .await?; @@ -582,13 +598,46 @@ impl Repository { Ok(()) } + pub(crate) async fn queue_failed_delivery_receipt( + &self, + receipt: PendingDeliveryReceiptInsert<'_>, + ) -> Result<()> { + let Some(reason) = receipt.failure_reason else { + anyhow::bail!("failed delivery receipt requires a reason"); + }; + sqlx::query( + r#" + INSERT INTO pending_delivery_receipts ( + request_id, local_transfer_id, sender_blob_ticket, + sender_transfer_id, token, failure_reason, created_at + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7) + ON CONFLICT(request_id) DO UPDATE SET + local_transfer_id = excluded.local_transfer_id, + sender_blob_ticket = excluded.sender_blob_ticket, + sender_transfer_id = excluded.sender_transfer_id, + token = excluded.token, + failure_reason = excluded.failure_reason + "#, + ) + .bind(receipt.request_id) + .bind(to_db_id(receipt.local_transfer_id)?) + .bind(receipt.sender_blob_ticket) + .bind(to_db_id(receipt.sender_transfer_id)?) + .bind(receipt.token) + .bind(reason) + .bind(now_ms()) + .execute(&self.pool) + .await?; + Ok(()) + } + pub(crate) async fn list_pending_delivery_receipts( &self, ) -> Result> { let rows = sqlx::query( r#" SELECT local_transfer_id, sender_blob_ticket, request_id, - sender_transfer_id, token + sender_transfer_id, token, failure_reason FROM pending_delivery_receipts ORDER BY created_at ASC "#, @@ -603,6 +652,7 @@ impl Repository { request_id: row.get("request_id"), sender_transfer_id: row.get::("sender_transfer_id") as u64, token: row.get("token"), + failure_reason: row.get("failure_reason"), }) .collect()) } @@ -883,6 +933,56 @@ impl Repository { } } + pub(crate) async fn fail_receiver_delivery( + &self, + id: &str, + transfer_id: u64, + remote_endpoint_id: &str, + token_hash: &str, + reason: &str, + ) -> Result<()> { + let result = sqlx::query( + r#" + UPDATE receiver_requests + SET status = 'failed', reason = ?1 + WHERE id = ?2 AND transfer_id = ?3 AND remote_endpoint_id = ?4 AND receipt_token_hash = ?5 + AND status = 'accepted' + "#, + ) + .bind(reason) + .bind(id) + .bind(to_db_id(transfer_id)?) + .bind(remote_endpoint_id) + .bind(token_hash) + .execute(&self.pool) + .await?; + if result.rows_affected() == 1 { + return Ok(()); + } + let already_recorded = sqlx::query( + r#" + SELECT EXISTS( + SELECT 1 FROM receiver_requests + WHERE id = ?1 AND transfer_id = ?2 AND remote_endpoint_id = ?3 + AND receipt_token_hash = ?4 AND status = 'failed' + ) + "#, + ) + .bind(id) + .bind(to_db_id(transfer_id)?) + .bind(remote_endpoint_id) + .bind(token_hash) + .fetch_one(&self.pool) + .await? + .get::(0) + != 0; + if already_recorded { + Ok(()) + } else { + anyhow::bail!("delivery failure did not match an accepted receiver request") + } + } + pub(crate) async fn expire_pending_receiver_requests(&self, reason: &str) -> Result { let result = sqlx::query( r#" diff --git a/crates/vnidrop/src/runtime/delivery.rs b/crates/vnidrop/src/runtime/delivery.rs index 8674480..8938e2c 100644 --- a/crates/vnidrop/src/runtime/delivery.rs +++ b/crates/vnidrop/src/runtime/delivery.rs @@ -4,7 +4,9 @@ use serde_json::json; use super::{filter_peer_addr_for_relay_mode, CoreInner}; use crate::{ - handshake::{DeliveryReceipt, DeliveryReceiptResponse, HandshakeService}, + handshake::{ + DeliveryFailureReceipt, DeliveryReceipt, DeliveryReceiptResponse, HandshakeService, + }, repository::PendingDeliveryReceipt, ticket::parse_persisted_sender_address, }; @@ -96,13 +98,29 @@ impl CoreInner { } }; let client = HandshakeService::client(self.endpoint.clone(), sender_addr); - let receipt = DeliveryReceipt { - request_id: pending.request_id.clone(), - transfer_id: pending.sender_transfer_id, - token: pending.token, - }; - match tokio::time::timeout(DELIVERY_RECEIPT_TIMEOUT, client.report_delivery(receipt)).await - { + let request_id = pending.request_id.clone(); + let response = tokio::time::timeout(DELIVERY_RECEIPT_TIMEOUT, async { + if let Some(reason) = pending.failure_reason { + client + .report_delivery_failure(DeliveryFailureReceipt { + request_id, + transfer_id: pending.sender_transfer_id, + token: pending.token, + reason, + }) + .await + } else { + client + .report_delivery(DeliveryReceipt { + request_id, + transfer_id: pending.sender_transfer_id, + token: pending.token, + }) + .await + } + }) + .await; + match response { Ok(Ok(DeliveryReceiptResponse::Recorded)) => { if let Err(error) = self .repository diff --git a/crates/vnidrop/src/runtime/receive.rs b/crates/vnidrop/src/runtime/receive.rs index 9261bbb..d4b5671 100644 --- a/crates/vnidrop/src/runtime/receive.rs +++ b/crates/vnidrop/src/runtime/receive.rs @@ -1,7 +1,7 @@ use std::{ io, path::{Path, PathBuf}, - sync::Arc, + sync::{Arc, Mutex}, }; use anyhow::{Context, Result}; @@ -222,6 +222,10 @@ impl CoreInner { self.persist_receive_start(transfer_id, &parsed, receiver_name.as_deref()) .await .map_err(VnidropError::repository)?; + let persisted_sender_address = + encode_persisted_sender_address(parsed.blob_ticket.addr()) + .context("failed to encode sender address for delivery receipt")?; + let delivery_receipt = Arc::new(Mutex::new(None)); // Cancellation is cooperative: it stops our receive future and marks // local state while lower-level Iroh work unwinds naturally. let (shutdown_tx, mut shutdown_rx) = oneshot::channel(); @@ -237,7 +241,14 @@ impl CoreInner { ); let (result, cancelled) = tokio::select! { - result = self.receive_inner(transfer_id, parsed, target, receiver_name) => { + result = self.receive_inner( + transfer_id, + parsed, + target, + receiver_name, + persisted_sender_address.clone(), + delivery_receipt.clone(), + ) => { (result.map_err(VnidropError::transfer), false) }, _ = &mut shutdown_rx => (Err(VnidropError::cancelled("transfer cancelled")), true), @@ -267,6 +278,34 @@ impl CoreInner { ) .await; } + let receipt = delivery_receipt.lock().expect("delivery_receipt").take(); + if let Some(receipt) = receipt { + let reason = if cancelled { "cancelled" } else { error.code() }; + match self + .repository + .queue_failed_delivery_receipt(PendingDeliveryReceiptInsert { + local_transfer_id: transfer_id, + sender_blob_ticket: &persisted_sender_address, + request_id: &receipt.request_id, + sender_transfer_id: receipt.transfer_id, + token: &receipt.token, + failure_reason: Some(reason), + }) + .await + { + Ok(()) => self.delivery_receipt_notify.notify_one(), + Err(queue_error) => { + tracing::warn!(%queue_error, "failed to queue delivery failure receipt"); + self.emit_transfer( + transfer_id, + "receive", + "delivery", + "receipt-failed", + json!({ "reason": queue_error.to_string() }), + ); + } + } + } } result.map_err(anyhow::Error::new) } @@ -277,6 +316,8 @@ impl CoreInner { parsed: ParsedTransferTicket, target: ReceiveTarget, receiver_name: Option, + persisted_sender_address: String, + pending_delivery_receipt: Arc>>, ) -> Result<()> { if let ReceiveTarget::Directory(output_dir) = &target { tokio::fs::create_dir_all(output_dir) @@ -284,8 +325,6 @@ impl CoreInner { .map_err(VnidropError::filesystem)?; } let sender_addr = parsed.blob_ticket.addr().clone(); - let persisted_sender_address = encode_persisted_sender_address(&sender_addr) - .context("failed to encode sender address for delivery receipt")?; self.emit_transfer(transfer_id, "receive", "network", "connecting", json!({})); // Every VniDrop ticket carries metadata and must complete the handshake. @@ -297,6 +336,9 @@ impl CoreInner { receiver_name.as_deref(), ) .await?; + *pending_delivery_receipt + .lock() + .expect("pending_delivery_receipt") = Some(delivery_receipt.clone()); let connection = self .endpoint .connect(sender_addr.clone(), iroh_blobs::ALPN) @@ -373,9 +415,14 @@ impl CoreInner { request_id: &delivery_receipt.request_id, sender_transfer_id: delivery_receipt.transfer_id, token: &delivery_receipt.token, + failure_reason: None, }) .await .map_err(VnidropError::repository)?; + pending_delivery_receipt + .lock() + .expect("pending_delivery_receipt") + .take(); drop(download_tag); self.emit_transfer(transfer_id, "receive", "lifecycle", "done", json!({})); self.delivery_receipt_notify.notify_one(); diff --git a/crates/vnidrop/src/tests/repository.rs b/crates/vnidrop/src/tests/repository.rs index eaa1b11..dfe9222 100644 --- a/crates/vnidrop/src/tests/repository.rs +++ b/crates/vnidrop/src/tests/repository.rs @@ -66,7 +66,7 @@ async fn received_artifacts_survive_history_deletion() { async fn persists_transfers_and_events_across_reopen() { let temp = tempfile::tempdir().unwrap(); let repository = Repository::open(temp.path()).await.unwrap(); - assert_eq!(repository.schema_version().await.unwrap(), 6); + assert_eq!(repository.schema_version().await.unwrap(), 7); repository .insert_transfer(transfer( 7, @@ -135,6 +135,7 @@ async fn receive_completion_persists_delivery_receipt_until_recorded() { request_id: "request-93", sender_transfer_id: 39, token: "receipt-token", + failure_reason: None, }) .await .unwrap(); @@ -221,6 +222,68 @@ async fn receiver_request_can_only_be_resolved_once() { assert!(requests[0].completed_at.is_some()); } +#[tokio::test] +async fn authenticated_delivery_failure_marks_accepted_receiver_failed() { + let temp = tempfile::tempdir().unwrap(); + let repository = Repository::open(temp.path()).await.unwrap(); + repository + .insert_receiver_request(ReceiverRequestInsert { + id: "request-failed", + transfer_id: 78, + remote_endpoint_id: "node-a", + transfer_name: "demo", + receiver_name: Some("receiver"), + receiver_device_name: None, + app_version: "0.1.0", + }) + .await + .unwrap(); + repository + .update_receiver_request_status("request-failed", ReceiverRequestStatus::Accepted, None) + .await + .unwrap(); + repository + .set_receiver_receipt_token("request-failed", "token-hash") + .await + .unwrap(); + + repository + .fail_receiver_delivery( + "request-failed", + 78, + "node-a", + "token-hash", + "destination_exists", + ) + .await + .unwrap(); + repository + .fail_receiver_delivery( + "request-failed", + 78, + "node-a", + "token-hash", + "destination_exists", + ) + .await + .unwrap(); + assert!(repository + .fail_receiver_delivery( + "request-failed", + 78, + "node-b", + "token-hash", + "destination_exists", + ) + .await + .is_err()); + + let requests = repository.list_receiver_requests(78).await.unwrap(); + assert_eq!(requests.len(), 1); + assert_eq!(requests[0].status, "failed"); + assert_eq!(requests[0].reason.as_deref(), Some("destination_exists")); +} + #[tokio::test] async fn startup_expiration_is_idempotent_for_pending_requests() { let temp = tempfile::tempdir().unwrap(); @@ -582,7 +645,7 @@ async fn migrates_schema_v2_identity_without_losing_transfer() { pool.close().await; let repository = Repository::open(temp.path()).await.unwrap(); - assert_eq!(repository.schema_version().await.unwrap(), 6); + assert_eq!(repository.schema_version().await.unwrap(), 7); let stored = repository.list_transfers().await.unwrap().remove(0); assert_eq!(stored.transfer_id, 7); assert_eq!(stored.local_id, "legacy-7-send"); diff --git a/crates/vnidrop/src/tests/runtime.rs b/crates/vnidrop/src/tests/runtime.rs index 9c3bee8..895a448 100644 --- a/crates/vnidrop/src/tests/runtime.rs +++ b/crates/vnidrop/src/tests/runtime.rs @@ -164,6 +164,7 @@ fn startup_processes_persisted_delivery_receipts() { request_id: "request-94", sender_transfer_id: 49, token: "receipt-token", + failure_reason: None, }) .await .unwrap(); diff --git a/crates/vnidrop/src/transfer_state.rs b/crates/vnidrop/src/transfer_state.rs index 7952671..b13f619 100644 --- a/crates/vnidrop/src/transfer_state.rs +++ b/crates/vnidrop/src/transfer_state.rs @@ -87,6 +87,7 @@ pub(crate) enum ReceiverRequestStatus { Refused, Expired, Completed, + Failed, } impl ReceiverRequestStatus { @@ -97,6 +98,7 @@ impl ReceiverRequestStatus { Self::Refused => "refused", Self::Expired => "expired", Self::Completed => "completed", + Self::Failed => "failed", } } } @@ -111,6 +113,7 @@ impl TryFrom<&str> for ReceiverRequestStatus { "refused" => Ok(Self::Refused), "expired" => Ok(Self::Expired), "completed" => Ok(Self::Completed), + "failed" => Ok(Self::Failed), _ => bail!("unknown receiver request status: {value}"), } } diff --git a/crates/vnidrop/tests/transfer.rs b/crates/vnidrop/tests/transfer.rs index 680f0d1..64d909a 100644 --- a/crates/vnidrop/tests/transfer.rs +++ b/crates/vnidrop/tests/transfer.rs @@ -24,6 +24,30 @@ fn wait_for_sender_transfer_event(sender: &TestNode, transfer_id: u64, kind: &st } } +fn wait_for_receiver_status( + sender: &TestNode, + transfer_id: u64, + status: &str, +) -> vnidrop::ReceiverRequest { + let started = Instant::now(); + loop { + if let Some(request) = sender + .core + .list_receiver_requests(transfer_id) + .unwrap() + .into_iter() + .find(|request| request.status == status) + { + return request; + } + assert!( + started.elapsed() < Duration::from_secs(5), + "timed out waiting for receiver status {status}" + ); + std::thread::sleep(Duration::from_millis(10)); + } +} + #[test] fn transfers_file_between_two_cores() { let source_dir = tempfile::tempdir().unwrap(); @@ -162,4 +186,11 @@ fn receive_refuses_to_overwrite_existing_destination() { && event.kind == "failed" && event.data_json.contains("\"code\":\"destination_exists\"") })); + let failed = wait_for_receiver_status(&sender, share.transfer_id, "failed"); + assert_eq!(failed.reason.as_deref(), Some("destination_exists")); + assert!(sender.sink.events().iter().any(|event| { + event.transfer_id == Some(share.transfer_id) + && event.phase == "delivery" + && event.kind == "receiver-failed" + })); } diff --git a/shared/src/commonMain/composeResources/values-de/strings.xml b/shared/src/commonMain/composeResources/values-de/strings.xml index 88f4974..00dbdbc 100644 --- a/shared/src/commonMain/composeResources/values-de/strings.xml +++ b/shared/src/commonMain/composeResources/values-de/strings.xml @@ -274,6 +274,7 @@ Noch niemand hat diese Übertragung angefragt. Genehmigt – wartet auf Abschluss Erfolgreich empfangen + Übertragung fehlgeschlagen Anfrage abgelaufen Anfrage abgelehnt Wartet auf Ihre Genehmigung diff --git a/shared/src/commonMain/composeResources/values-es/strings.xml b/shared/src/commonMain/composeResources/values-es/strings.xml index cadea0e..24bc04c 100644 --- a/shared/src/commonMain/composeResources/values-es/strings.xml +++ b/shared/src/commonMain/composeResources/values-es/strings.xml @@ -274,6 +274,7 @@ Nadie ha solicitado aún esta transferencia. Aprobado: esperando a que se complete Recibido correctamente + Transferencia fallida Solicitud caducada Solicitud rechazada Esperando su aprobación diff --git a/shared/src/commonMain/composeResources/values-fr/strings.xml b/shared/src/commonMain/composeResources/values-fr/strings.xml index ec03364..6d239e5 100644 --- a/shared/src/commonMain/composeResources/values-fr/strings.xml +++ b/shared/src/commonMain/composeResources/values-fr/strings.xml @@ -274,6 +274,7 @@ Personne n’a encore demandé ce transfert. Approuvé — en attente de la fin Reçu avec succès + Transfert échoué Demande expirée Demande refusée En attente de votre approbation diff --git a/shared/src/commonMain/composeResources/values-it/strings.xml b/shared/src/commonMain/composeResources/values-it/strings.xml index ec630da..f6dbcb7 100644 --- a/shared/src/commonMain/composeResources/values-it/strings.xml +++ b/shared/src/commonMain/composeResources/values-it/strings.xml @@ -274,6 +274,7 @@ Nessuno ha ancora richiesto questo trasferimento. Approvato: in attesa del completamento Ricevuto correttamente + Trasferimento non riuscito Richiesta scaduta Richiesta rifiutata In attesa della sua approvazione diff --git a/shared/src/commonMain/composeResources/values-nl/strings.xml b/shared/src/commonMain/composeResources/values-nl/strings.xml index c7c35fe..c4dddac 100644 --- a/shared/src/commonMain/composeResources/values-nl/strings.xml +++ b/shared/src/commonMain/composeResources/values-nl/strings.xml @@ -274,6 +274,7 @@ Nog niemand heeft deze overdracht aangevraagd. Goedgekeurd — wachten op voltooiing Succesvol ontvangen + Overdracht mislukt Verzoek verlopen Verzoek geweigerd Wachten op uw goedkeuring diff --git a/shared/src/commonMain/composeResources/values-pl/strings.xml b/shared/src/commonMain/composeResources/values-pl/strings.xml index 0ef6773..83ccdd5 100644 --- a/shared/src/commonMain/composeResources/values-pl/strings.xml +++ b/shared/src/commonMain/composeResources/values-pl/strings.xml @@ -274,6 +274,7 @@ Nikt jeszcze nie poprosił o ten transfer. Zatwierdzono — oczekiwanie na ukończenie Odebrano pomyślnie + Przesyłanie nie powiodło się Prośba wygasła Prośba odrzucona Oczekiwanie na Twoje zatwierdzenie diff --git a/shared/src/commonMain/composeResources/values-pt/strings.xml b/shared/src/commonMain/composeResources/values-pt/strings.xml index 7b7851f..8f8be6c 100644 --- a/shared/src/commonMain/composeResources/values-pt/strings.xml +++ b/shared/src/commonMain/composeResources/values-pt/strings.xml @@ -274,6 +274,7 @@ Ainda ninguém pediu esta transferência. Aprovado — a aguardar conclusão Recebido com sucesso + A transferência falhou Pedido expirado Pedido recusado A aguardar a sua aprovação diff --git a/shared/src/commonMain/composeResources/values-ru/strings.xml b/shared/src/commonMain/composeResources/values-ru/strings.xml index f05064c..411b02c 100644 --- a/shared/src/commonMain/composeResources/values-ru/strings.xml +++ b/shared/src/commonMain/composeResources/values-ru/strings.xml @@ -274,6 +274,7 @@ Никто ещё не запросил эту передачу. Одобрено — ожидание завершения Успешно получено + Передача не удалась Запрос истёк Запрос отклонён Ожидание вашего одобрения diff --git a/shared/src/commonMain/composeResources/values/strings.xml b/shared/src/commonMain/composeResources/values/strings.xml index 573617d..7ab31fb 100644 --- a/shared/src/commonMain/composeResources/values/strings.xml +++ b/shared/src/commonMain/composeResources/values/strings.xml @@ -274,6 +274,7 @@ Nobody has requested this transfer yet. Approved — waiting for completion Received successfully + Transfer failed Request expired Request refused Waiting for your approval diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/core/CoreModels.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/core/CoreModels.kt index f3c7768..de11ba9 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/core/CoreModels.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/core/CoreModels.kt @@ -117,6 +117,7 @@ enum class ReceiverDeliveryStatus { Refused, Expired, Completed, + Failed, Unknown, } diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/core/CoreRepository.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/core/CoreRepository.kt index a467f48..543d259 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/core/CoreRepository.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/core/CoreRepository.kt @@ -433,6 +433,7 @@ private fun ReceiverRequest.toModel(): ReceiverRequestModel = ReceiverRequestMod "refused" -> ReceiverDeliveryStatus.Refused "expired" -> ReceiverDeliveryStatus.Expired "completed" -> ReceiverDeliveryStatus.Completed + "failed" -> ReceiverDeliveryStatus.Failed else -> ReceiverDeliveryStatus.Unknown }, reason = reason, diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/TransferDetails.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/TransferDetails.kt index 51078df..422db5d 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/TransferDetails.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/TransferDetails.kt @@ -409,13 +409,15 @@ private fun receiverStatusText(status: ReceiverDeliveryStatus) = stringResource( ReceiverDeliveryStatus.Refused -> Res.string.transfer_receiver_refused ReceiverDeliveryStatus.Expired -> Res.string.transfer_receiver_expired ReceiverDeliveryStatus.Completed -> Res.string.transfer_receiver_completed + ReceiverDeliveryStatus.Failed -> Res.string.transfer_receiver_failed ReceiverDeliveryStatus.Unknown -> Res.string.transfer_receiver_unknown }) @Composable private fun receiverStatusColor(status: ReceiverDeliveryStatus) = when (status) { ReceiverDeliveryStatus.Completed -> LocalVniDropColors.current.brandDefault - ReceiverDeliveryStatus.Refused, ReceiverDeliveryStatus.Expired -> LocalVniDropColors.current.destructiveDefault + ReceiverDeliveryStatus.Refused, ReceiverDeliveryStatus.Expired, ReceiverDeliveryStatus.Failed -> + LocalVniDropColors.current.destructiveDefault else -> LocalVniDropColors.current.foregroundLighter } From 1d049d08f20d6a67d021f2754edb43b14c290b31 Mon Sep 17 00:00:00 2001 From: Hammed Abass Date: Fri, 24 Jul 2026 16:02:46 +0200 Subject: [PATCH 09/10] fix(storage): release core before clearing cache --- .../com/vnidrop/app/core/CoreRepository.kt | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/core/CoreRepository.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/core/CoreRepository.kt index 543d259..4c80798 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/core/CoreRepository.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/core/CoreRepository.kt @@ -77,8 +77,8 @@ class CoreRepository( } } _state.update { it.copy(isInitialized = false, status = null) } - previousCore?.shutdown() core = null + disposeCore(previousCore) core = VnidropCore.initializeWithNetworkConfig(appDataDir, sink, relaySettings.toNative()) currentAppDataDir = appDataDir currentRelaySettings = relaySettings @@ -87,8 +87,9 @@ class CoreRepository( } override fun shutdown() { - core?.shutdown() + val activeCore = core core = null + disposeCore(activeCore) currentAppDataDir = null _state.value = CoreState() } @@ -183,8 +184,8 @@ class CoreRepository( val appDataDir = requireNotNull(currentAppDataDir) { "Core data directory is unavailable" } val relaySettings = currentRelaySettings _state.update { it.copy(isInitialized = false, status = null) } - activeCore.shutdown() core = null + disposeCore(activeCore) var reclaimed = 0UL try { reclaimed = clearInactiveTransferCache(appDataDir) @@ -196,6 +197,16 @@ class CoreRepository( reclaimed } + private fun disposeCore(activeCore: VnidropCore?) { + if (activeCore == null) return + try { + activeCore.shutdown() + } finally { + // UniFFI owns the Rust Arc; explicit disposal releases Windows file handles before cache deletion. + activeCore.close() + } + } + override suspend fun receivedArtifacts(): Result> = runCore { activeCore -> activeCore.listReceivedArtifacts().map { artifact -> ReceivedArtifactModel( From b724c1540fe9ee6ddcecd6c8f8da1337d8b01620 Mon Sep 17 00:00:00 2001 From: Hammed Abass Date: Fri, 24 Jul 2026 16:52:10 +0200 Subject: [PATCH 10/10] fix(shared): derive path names in Rust --- .../commonMain/kotlin/com/vnidrop/app/core/CoreRepository.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/core/CoreRepository.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/core/CoreRepository.kt index 4c80798..46689e7 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/core/CoreRepository.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/core/CoreRepository.kt @@ -105,7 +105,7 @@ class CoreRepository( ShareSource( kind = SourceKind.PATH, value = path, - displayName = path.substringAfterLast('/').ifBlank { null }, + displayName = null, isDirectory = false, ), ),