feat(network): support custom relay servers

Add strict custom Iroh relay profiles with safe restart and rollback across the Rust core, Compose apps, and Apple apps. Preserve multi-relay invitations and fail closed on configuration or recovery mismatches.
This commit is contained in:
2026-07-23 14:27:40 +02:00
parent 5939489432
commit cbace73908
66 changed files with 5692 additions and 168 deletions

240
Cargo.lock generated
View File

@@ -61,12 +61,56 @@ dependencies = [
"libc", "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]] [[package]]
name = "anstyle" name = "anstyle"
version = "1.0.14" version = "1.0.14"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" 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]] [[package]]
name = "anyhow" name = "anyhow"
version = "1.0.103" version = "1.0.103"
@@ -518,6 +562,7 @@ version = "4.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f09628afdcc538b57f3c6341e9c8e9970f18e4a481690a64974d7023bd33548b" checksum = "f09628afdcc538b57f3c6341e9c8e9970f18e4a481690a64974d7023bd33548b"
dependencies = [ dependencies = [
"anstream",
"anstyle", "anstyle",
"clap_lex", "clap_lex",
"strsim", "strsim",
@@ -556,6 +601,12 @@ dependencies = [
"thiserror 2.0.18", "thiserror 2.0.18",
] ]
[[package]]
name = "colorchoice"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
[[package]] [[package]]
name = "combine" name = "combine"
version = "4.6.7" version = "4.6.7"
@@ -811,6 +862,20 @@ dependencies = [
"syn 2.0.118", "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]] [[package]]
name = "data-encoding" name = "data-encoding"
version = "2.11.0" version = "2.11.0"
@@ -958,6 +1023,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
dependencies = [ dependencies = [
"block-buffer 0.12.1", "block-buffer 0.12.1",
"const-oid 0.10.2",
"crypto-common 0.2.2", "crypto-common 0.2.2",
] ]
@@ -1474,6 +1540,12 @@ dependencies = [
"byteorder", "byteorder",
] ]
[[package]]
name = "hashbrown"
version = "0.14.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
[[package]] [[package]]
name = "hashbrown" name = "hashbrown"
version = "0.15.5" version = "0.15.5"
@@ -2118,12 +2190,20 @@ version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "291065721ad7c477b972e581bbc528df031dc8eb5e39fe1ff3300ae5dfb157ef" checksum = "291065721ad7c477b972e581bbc528df031dc8eb5e39fe1ff3300ae5dfb157ef"
dependencies = [ dependencies = [
"http-body-util",
"hyper",
"hyper-util",
"iroh-metrics-derive", "iroh-metrics-derive",
"itoa", "itoa",
"n0-error", "n0-error",
"portable-atomic", "portable-atomic",
"reqwest",
"rustls",
"rustls-platform-verifier",
"ryu", "ryu",
"serde", "serde",
"tokio",
"tokio-util",
"tracing", "tracing",
] ]
@@ -2148,6 +2228,8 @@ dependencies = [
"blake3", "blake3",
"bytes", "bytes",
"cfg_aliases", "cfg_aliases",
"clap",
"dashmap",
"data-encoding", "data-encoding",
"derive_more", "derive_more",
"getrandom 0.4.3", "getrandom 0.4.3",
@@ -2168,17 +2250,28 @@ dependencies = [
"pin-project", "pin-project",
"postcard", "postcard",
"rand 0.10.2", "rand 0.10.2",
"rcgen",
"reloadable-state",
"reqwest", "reqwest",
"rustls", "rustls",
"rustls-cert-file-reader",
"rustls-cert-reloadable-resolver",
"rustls-pki-types", "rustls-pki-types",
"serde", "serde",
"serde_bytes", "serde_bytes",
"serde_json",
"sha1 0.11.0",
"simdutf8",
"strum", "strum",
"time",
"tokio", "tokio",
"tokio-rustls", "tokio-rustls",
"tokio-rustls-acme",
"tokio-util", "tokio-util",
"tokio-websockets", "tokio-websockets",
"toml 1.1.2+spec-1.1.0",
"tracing", "tracing",
"tracing-subscriber",
"url", "url",
"vergen-gitcl", "vergen-gitcl",
"webpki-roots", "webpki-roots",
@@ -2264,6 +2357,12 @@ dependencies = [
"tracing", "tracing",
] ]
[[package]]
name = "is_terminal_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
[[package]] [[package]]
name = "itoa" name = "itoa"
version = "1.0.18" version = "1.0.18"
@@ -2997,6 +3096,12 @@ dependencies = [
"portable-atomic", "portable-atomic",
] ]
[[package]]
name = "once_cell_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
[[package]] [[package]]
name = "opaque-debug" name = "opaque-debug"
version = "0.3.1" version = "0.3.1"
@@ -3539,6 +3644,23 @@ version = "0.8.11"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" 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]] [[package]]
name = "reqwest" name = "reqwest"
version = "0.13.4" version = "0.13.4"
@@ -3562,6 +3684,8 @@ dependencies = [
"rustls", "rustls",
"rustls-pki-types", "rustls-pki-types",
"rustls-platform-verifier", "rustls-platform-verifier",
"serde",
"serde_json",
"sync_wrapper", "sync_wrapper",
"tokio", "tokio",
"tokio-rustls", "tokio-rustls",
@@ -3668,6 +3792,40 @@ dependencies = [
"zeroize", "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]] [[package]]
name = "rustls-native-certs" name = "rustls-native-certs"
version = "0.8.4" version = "0.8.4"
@@ -3898,6 +4056,15 @@ dependencies = [
"zmij", "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]] [[package]]
name = "serde_urlencoded" name = "serde_urlencoded"
version = "0.7.1" version = "0.7.1"
@@ -3931,6 +4098,17 @@ dependencies = [
"digest 0.10.7", "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]] [[package]]
name = "sha1_smol" name = "sha1_smol"
version = "1.0.1" version = "1.0.1"
@@ -4234,7 +4412,7 @@ dependencies = [
"percent-encoding", "percent-encoding",
"rand 0.8.6", "rand 0.8.6",
"rsa", "rsa",
"sha1", "sha1 0.10.6",
"sha2 0.10.9", "sha2 0.10.9",
"smallvec", "smallvec",
"sqlx-core", "sqlx-core",
@@ -4614,6 +4792,34 @@ dependencies = [
"tokio", "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]] [[package]]
name = "tokio-stream" name = "tokio-stream"
version = "0.1.18" version = "0.1.18"
@@ -4672,6 +4878,21 @@ dependencies = [
"serde", "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]] [[package]]
name = "toml_datetime" name = "toml_datetime"
version = "1.1.1+spec-1.1.0" version = "1.1.1+spec-1.1.0"
@@ -4702,6 +4923,12 @@ dependencies = [
"winnow 1.0.3", "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]] [[package]]
name = "tower" name = "tower"
version = "0.5.3" version = "0.5.3"
@@ -4915,7 +5142,7 @@ dependencies = [
"serde", "serde",
"tempfile", "tempfile",
"textwrap", "textwrap",
"toml", "toml 0.5.11",
"uniffi_internal_macros", "uniffi_internal_macros",
"uniffi_meta", "uniffi_meta",
"uniffi_pipeline", "uniffi_pipeline",
@@ -4961,7 +5188,7 @@ dependencies = [
"quote", "quote",
"serde", "serde",
"syn 2.0.118", "syn 2.0.118",
"toml", "toml 0.5.11",
"uniffi_meta", "uniffi_meta",
] ]
@@ -5037,6 +5264,12 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
[[package]]
name = "utf8parse"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]] [[package]]
name = "uuid" name = "uuid"
version = "1.23.4" version = "1.23.4"
@@ -5117,6 +5350,7 @@ dependencies = [
"futures-lite", "futures-lite",
"iroh", "iroh",
"iroh-blobs", "iroh-blobs",
"iroh-relay",
"irpc", "irpc",
"irpc-iroh", "irpc-iroh",
"libc", "libc",

View File

@@ -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 same end-to-end encrypted connection through a relay. The relay forwards
encrypted packets; it is not a VniDrop file store. 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`? ## Why Iroh and `iroh-blobs`?
VniDrop combines a networking layer with its own sharing rules: 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 - Safe receive destinations that do not silently overwrite existing files
- Native SwiftUI apps on iOS, iPadOS, and macOS; Compose apps on Android, - Native SwiftUI apps on iOS, iPadOS, and macOS; Compose apps on Android,
Windows, and Linux Windows, and Linux
- Strict custom HTTPS relay profiles with safe apply and rollback
- Opt-in diagnostics with transfer contents, invitations, and file paths - Opt-in diagnostics with transfer contents, invitations, and file paths
excluded excluded

View File

@@ -20,6 +20,18 @@ final class AppModelTests: XCTestCase {
_ = makeModel(core, preferences: Fixtures.preferences()) _ = makeModel(core, preferences: Fixtures.preferences())
await waitUntil { core.state.isInitialized } await waitUntil { core.state.isInitialized }
XCTAssertTrue(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() { func testSelectDestination() {

View File

@@ -15,11 +15,12 @@ final class AppPreferencesRepositoryTests: XCTestCase {
) )
} }
func testFallbacksWhenEmpty() { func testMissingRelayProfileDefaultsToAutomatic() {
let repo = AppPreferencesRepository(defaults: defaults(), fallback: fallback()) let repo = AppPreferencesRepository(defaults: defaults(), fallback: fallback())
XCTAssertEqual(repo.preferences.username, "Default") XCTAssertEqual(repo.preferences.username, "Default")
XCTAssertEqual(repo.preferences.themeMode, .system) XCTAssertEqual(repo.preferences.themeMode, .system)
XCTAssertFalse(repo.preferences.notificationsEnabled) XCTAssertFalse(repo.preferences.notificationsEnabled)
XCTAssertEqual(repo.preferences.relayConfiguration, .automatic)
} }
func testValuesPersistAndReload() { func testValuesPersistAndReload() {
@@ -30,6 +31,10 @@ final class AppPreferencesRepositoryTests: XCTestCase {
repo.setThemeMode(.dark) repo.setThemeMode(.dark)
repo.setNotificationsEnabled(true) repo.setNotificationsEnabled(true)
repo.setReceiveFolder(ReceiveFolder(kind: .iosSecurityScopedUrl, value: "file:///x", displayName: "Custom")) 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. // A fresh repository over the same store reflects the persisted values.
let reloaded = AppPreferencesRepository(defaults: store, fallback: fb) let reloaded = AppPreferencesRepository(defaults: store, fallback: fb)
@@ -38,6 +43,40 @@ final class AppPreferencesRepositoryTests: XCTestCase {
XCTAssertTrue(reloaded.preferences.notificationsEnabled) XCTAssertTrue(reloaded.preferences.notificationsEnabled)
XCTAssertEqual(reloaded.preferences.receiveFolder.displayName, "Custom") XCTAssertEqual(reloaded.preferences.receiveFolder.displayName, "Custom")
XCTAssertEqual(reloaded.preferences.receiveFolder.kind, .iosSecurityScopedUrl) 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() { func testResetReceiveFolderRestoresFallback() {

View File

@@ -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<Void, Never>] = []
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<T>(
_ result: Result<T, Error>,
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)
}
}

View File

@@ -25,6 +25,8 @@ final class FakeCoreGateway: CoreGateway {
var cancelResult: Result<Void, Error> = .success(()) var cancelResult: Result<Void, Error> = .success(())
var deleteResult: Result<Void, Error> = .success(()) var deleteResult: Result<Void, Error> = .success(())
var clearReceiveHistoryResult: Result<UInt64, Error> = .success(0) var clearReceiveHistoryResult: Result<UInt64, Error> = .success(0)
var initializeResult: Result<Void, Error> = .success(())
var initializeResults: [Result<Void, Error>] = []
// Recorded calls // Recorded calls
private(set) var responses: [(id: String, accepted: Bool, reason: String?)] = [] 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 lastReceiveTicket: String?
private(set) var lastReceiveReceiverName: String? private(set) var lastReceiveReceiverName: String?
private(set) var lastShareAccessPolicy: ShareAccessPolicy? private(set) var lastShareAccessPolicy: ShareAccessPolicy?
private(set) var initializedNetworkConfigurations: [RelayConfiguration] = []
func setState(_ state: CoreState) { stateSubject.send(state) } func setState(_ state: CoreState) { stateSubject.send(state) }
func emit(_ signal: CoreSignal) { signalsSubject.send(signal) } func emit(_ signal: CoreSignal) { signalsSubject.send(signal) }
func initialize(appDataDir: String) async -> Result<Void, Error> { func initialize(
appDataDir: String,
networkConfiguration: RelayConfiguration
) async -> Result<Void, Error> {
initializedNetworkConfigurations.append(networkConfiguration)
let result = initializeResults.isEmpty ? initializeResult : initializeResults.removeFirst()
guard case .success = result else { return result }
var s = stateSubject.value var s = stateSubject.value
s.isInitialized = true s.isInitialized = true
stateSubject.send(s) stateSubject.send(s)

View File

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

View File

@@ -55,4 +55,25 @@ final class SendModelTests: XCTestCase {
let response = core.responses.first { $0.id == "req-1" } let response = core.responses.first { $0.id == "req-1" }
XCTAssertEqual(response?.accepted, false) 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)))
}
} }

View File

@@ -42,4 +42,107 @@ final class SettingsModelTests: XCTestCase {
await waitUntil { core.deletedTransfers.count == 2 } await waitUntil { core.deletedTransfers.count == 2 }
XCTAssertEqual(Set(core.deletedTransfers), [2, 3]) 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")
}
} }

View File

@@ -19,6 +19,95 @@ enum FolderAccessStatus {
case unavailable case unavailable
} }
enum RelayPreferenceMode: String, Codable, CaseIterable, Sendable {
case automatic
case custom
}
struct RelayConfiguration: Equatable, Codable, Sendable {
var mode: RelayPreferenceMode
var relayURLs: [String]
static let automatic = RelayConfiguration(mode: .automatic, relayURLs: [])
}
enum RelayConfigurationValidationError: Error, Equatable, Sendable {
case missingURL
case tooManyURLs
case httpsRequired(index: Int)
case invalidURL(index: Int)
case duplicateURL(index: Int)
var urlIndex: Int? {
switch self {
case .httpsRequired(let index), .invalidURL(let index), .duplicateURL(let index): return index
case .missingURL, .tooManyURLs: return nil
}
}
}
enum RelayConfigurationValidator {
static let maximumRelayCount = 8
static let maximumRelayURLBytes = 2_048
static func validate(
mode: RelayPreferenceMode,
relayURLs: [String],
retainedRelayURLs: [String] = []
) throws -> RelayConfiguration {
guard mode == .custom else {
return RelayConfiguration(mode: .automatic, relayURLs: retainedRelayURLs)
}
let relayEntries = relayURLs.enumerated().compactMap { index, value -> (Int, String)? in
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? nil : (index, trimmed)
}
guard !relayEntries.isEmpty else {
throw RelayConfigurationValidationError.missingURL
}
guard relayEntries.count <= maximumRelayCount else {
throw RelayConfigurationValidationError.tooManyURLs
}
var seen = Set<String>()
var normalizedURLs: [String] = []
for (index, relayURL) in relayEntries {
guard relayURL.lengthOfBytes(using: .utf8) <= maximumRelayURLBytes,
relayURL.rangeOfCharacter(from: .whitespacesAndNewlines.union(.controlCharacters)) == nil,
var components = URLComponents(string: relayURL) else {
throw RelayConfigurationValidationError.invalidURL(index: index)
}
guard components.scheme?.lowercased() == "https" else {
throw RelayConfigurationValidationError.httpsRequired(index: index)
}
guard
let host = components.host,
!host.isEmpty,
components.port.map({ (1...65_535).contains($0) }) ?? true,
components.user == nil,
components.password == nil,
components.query == nil,
components.fragment == nil,
components.path.isEmpty || components.path == "/"
else {
throw RelayConfigurationValidationError.invalidURL(index: index)
}
components.scheme = "https"
components.host = host.lowercased()
if components.port == 443 { components.port = nil }
if components.path == "/" { components.path = "" }
guard let canonicalURL = components.string, seen.insert(canonicalURL).inserted else {
throw RelayConfigurationValidationError.duplicateURL(index: index)
}
normalizedURLs.append(canonicalURL)
}
return RelayConfiguration(mode: .custom, relayURLs: normalizedURLs)
}
}
/// Persisted app preferences, ported from `preferences/AppPreferencesRepository.kt`. /// Persisted app preferences, ported from `preferences/AppPreferencesRepository.kt`.
/// Backed by `UserDefaults` instead of DataStore; keys and semantics match. /// Backed by `UserDefaults` instead of DataStore; keys and semantics match.
struct AppPreferences: Equatable { struct AppPreferences: Equatable {
@@ -28,6 +117,7 @@ struct AppPreferences: Equatable {
var notificationsEnabled: Bool var notificationsEnabled: Bool
var diagnosticsEnabled: Bool var diagnosticsEnabled: Bool
var diagnosticsInstallId: String var diagnosticsInstallId: String
var relayConfiguration: RelayConfiguration
} }
struct AppPreferencesDefaults { struct AppPreferencesDefaults {
@@ -54,6 +144,7 @@ final class AppPreferencesRepository: ObservableObject {
static let notificationsEnabled = "notifications_enabled" static let notificationsEnabled = "notifications_enabled"
static let diagnosticsEnabled = "diagnostics_enabled" static let diagnosticsEnabled = "diagnostics_enabled"
static let diagnosticsInstallId = "diagnostics_install_id" static let diagnosticsInstallId = "diagnostics_install_id"
static let relayConfiguration = "relay_configuration"
} }
init(defaults: UserDefaults = .standard, fallback: AppPreferencesDefaults) { init(defaults: UserDefaults = .standard, fallback: AppPreferencesDefaults) {
@@ -75,10 +166,25 @@ final class AppPreferencesRepository: ObservableObject {
themeMode: themeMode, themeMode: themeMode,
notificationsEnabled: notifications, notificationsEnabled: notifications,
diagnosticsEnabled: diagnostics, 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 { private static func resolveReceiveFolder(_ defaults: UserDefaults, fallback: ReceiveFolder) -> ReceiveFolder {
let kind = defaults.string(forKey: Key.receiveFolderKind) let kind = defaults.string(forKey: Key.receiveFolderKind)
.flatMap(ReceiveFolderKind.init(rawValue:)) ?? fallback.kind .flatMap(ReceiveFolderKind.init(rawValue:)) ?? fallback.kind
@@ -123,6 +229,12 @@ final class AppPreferencesRepository: ObservableObject {
reload() reload()
} }
func setRelayConfiguration(_ configuration: RelayConfiguration) {
guard let encoded = try? JSONEncoder().encode(configuration) else { return }
defaults.set(encoded, forKey: Key.relayConfiguration)
reload()
}
@discardableResult @discardableResult
func ensureDiagnosticsInstallId() -> String { func ensureDiagnosticsInstallId() -> String {
let existing = preferences.diagnosticsInstallId let existing = preferences.diagnosticsInstallId

View File

@@ -24,7 +24,7 @@ protocol CoreGateway: AnyObject {
/// Coalesced change hints emitted by the event sink. /// Coalesced change hints emitted by the event sink.
var signals: AnyPublisher<CoreSignal, Never> { get } var signals: AnyPublisher<CoreSignal, Never> { get }
func initialize(appDataDir: String) async -> Result<Void, Error> func initialize(appDataDir: String, networkConfiguration: RelayConfiguration) async -> Result<Void, Error>
func shutdown() func shutdown()
func shareSources( func shareSources(
_ sources: [ShareSource], _ sources: [ShareSource],

View File

@@ -2,6 +2,58 @@ import Foundation
import Combine import Combine
@preconcurrency import VnidropCore @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 /// Swift port of `core/CoreRepository.kt`. Owns the `VnidropCore` handle, maps the
/// generated UniFFI records into app domain models, publishes an observable /// generated UniFFI records into app domain models, publishes an observable
/// `CoreState`, and emits coalesced `CoreSignal`s from the event sink. /// `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. /// Coalesced change hints; subscribe to react to approval/history/transfer changes.
var signals: AnyPublisher<CoreSignal, Never> { signalsSubject.eraseToAnyPublisher() } var signals: AnyPublisher<CoreSignal, Never> { signalsSubject.eraseToAnyPublisher() }
// Set on the main actor (initialize/shutdown) but read from `queue` inside // Initialization swaps happen on `queue`; shutdown and snapshot reads may also
// `runCore`; the underlying core is internally synchronized, so this crossing // access the handle from the main actor. The underlying core is internally
// is safe. `nonisolated(unsafe)` documents that contract for Swift 6. // synchronized, and `nonisolated(unsafe)` documents that crossing for Swift 6.
private nonisolated(unsafe) var core: VnidropCore? private nonisolated(unsafe) var core: VnidropCore?
private let queue = DispatchQueue(label: "com.vnidrop.core", qos: .userInitiated) 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 private lazy var sink = RepositoryEventSink { [weak self] event in
Task { @MainActor in self?.handle(event: event) } Task { @MainActor in self?.handle(event: event) }
} }
private nonisolated static let maxEvents = 200 private nonisolated static let maxEvents = 200
init(coreFactory: any CoreBindingFactory = NativeCoreBindingFactory()) {
self.coreFactory = coreFactory
}
// MARK: - Lifecycle // MARK: - Lifecycle
func initialize(appDataDir: String) async -> Result<Void, Error> { func initialize(
await runCore { [sink] in appDataDir: String,
self.core?.shutdown() networkConfiguration: RelayConfiguration
let created = try VnidropCore.initialize(appDataDir: appDataDir, eventSink: sink) ) async -> Result<Void, Error> {
return created guard !isNetworkTransitionInProgress else {
}.map { created in 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 self.core = created
return created
}
switch result {
case .success:
self.refreshSnapshot() self.refreshSnapshot()
self.state.isInitialized = true 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, senderName: String,
accessPolicy: ShareAccessPolicy accessPolicy: ShareAccessPolicy
) async -> Result<Share, Error> { ) async -> Result<Share, Error> {
guard !isNetworkTransitionInProgress else {
return .failure(CoreNetworkLifecycleError.transitionInProgress)
}
guard !sources.isEmpty else { guard !sources.isEmpty else {
return .failure(InvitationError.message("Select at least one file to share")) return .failure(InvitationError.message("Select at least one file to share"))
} }
@@ -89,7 +179,10 @@ final class CoreRepository: ObservableObject, CoreGateway {
} }
func receive(ticket: String, outputDir: String, receiverName: String) async -> Result<Void, Error> { func receive(ticket: String, outputDir: String, receiverName: String) async -> Result<Void, Error> {
await runCore { guard !isNetworkTransitionInProgress else {
return .failure(CoreNetworkLifecycleError.transitionInProgress)
}
return await runCore {
try self.requireCore().receive( try self.requireCore().receive(
ticket: ticket, ticket: ticket,
outputDir: outputDir, outputDir: outputDir,
@@ -105,7 +198,10 @@ final class CoreRepository: ObservableObject, CoreGateway {
outputDirectoryUrl: String, outputDirectoryUrl: String,
receiverName: String receiverName: String
) async -> Result<Void, Error> { ) async -> Result<Void, Error> {
await runCore { guard !isNetworkTransitionInProgress else {
return .failure(CoreNetworkLifecycleError.transitionInProgress)
}
return await runCore {
try withSecurityScopedAccess(pathOrUrl: outputDirectoryUrl) { try withSecurityScopedAccess(pathOrUrl: outputDirectoryUrl) {
try self.requireCore().receive( try self.requireCore().receive(
ticket: ticket, ticket: ticket,

View File

@@ -26,7 +26,10 @@ final class AppModel: ObservableObject {
AppLogger.info("lifecycle", "app started", ["platform": environment.name]) AppLogger.info("lifecycle", "app started", ["platform": environment.name])
Task { 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) } if case .failure(let error) = result { messages.error(error) }
} }

View File

@@ -43,12 +43,14 @@ struct TransferDetailsView: View {
count: pendingReceivers + completedReceivers, count: pendingReceivers + completedReceivers,
onTap: model.openReceivers onTap: model.openReceivers
) )
DetailDestination( if transfer.invitationPresentation != .unavailable {
title: String(localized: "transfer_share_title"), DetailDestination(
description: String(localized: "transfer_share_description"), title: String(localized: "transfer_share_title"),
count: 0, description: String(localized: "transfer_share_description"),
onTap: model.openShare count: 0,
) onTap: model.openShare
)
}
} }
if isActiveShare { if isActiveShare {
@@ -276,24 +278,41 @@ struct TransferSharePanel: View {
var body: some View { var body: some View {
PanelContainer(title: String(localized: "transfer_share_title")) { PanelContainer(title: String(localized: "transfer_share_title")) {
if let ticket = transfer.ticket { switch transfer.invitationPresentation {
qrCard(ticket: ticket) case .ready(let ticket):
Text(LocalizedStringKey("transfer_scan_qr")) let qrImage = QRCode.generate(from: ticket)
.font(VniType.bodySmall).foregroundStyle(colors.foregroundLighter) qrCard(image: qrImage)
.frame(maxWidth: .infinity) if qrImage != nil {
Text(LocalizedStringKey("transfer_scan_qr"))
.font(VniType.bodySmall).foregroundStyle(colors.foregroundLighter)
.frame(maxWidth: .infinity)
}
ShareActionsView(model: model, transfer: transfer, ticket: ticket) ShareActionsView(model: model, transfer: transfer, ticket: ticket)
} else { case .preparing:
Text(LocalizedStringKey("transfer_event_preparing")).foregroundStyle(colors.foregroundLighter) 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 { ZStack {
if let qr = QRCode.generate(from: ticket) { if let image {
qr.interpolation(.none).resizable().scaledToFit().padding(14) image.interpolation(.none).resizable().scaledToFit().padding(14)
} else { } 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) .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) // MARK: - QR generation (CoreImage)
enum QRCode { enum QRCode {

View File

@@ -7,6 +7,7 @@ enum SettingsSection: Hashable {
case preferences case preferences
case appearance case appearance
case notifications case notifications
case network
case storage case storage
case about case about
case bugReport case bugReport
@@ -17,6 +18,7 @@ enum SettingsSection: Hashable {
case .preferences: return "preferences_title" case .preferences: return "preferences_title"
case .appearance: return "appearance_title" case .appearance: return "appearance_title"
case .notifications: return "notifications_title" case .notifications: return "notifications_title"
case .network: return "settings_network_title"
case .storage: return "storage_title" case .storage: return "storage_title"
case .about: return "about_title" case .about: return "about_title"
case .bugReport: return "about_bug_report" case .bugReport: return "about_bug_report"
@@ -44,6 +46,14 @@ struct SettingsState: Equatable {
var notificationsEnabled = false var notificationsEnabled = false
var notificationPermission: NotificationPermission = .notDetermined var notificationPermission: NotificationPermission = .notDetermined
var diagnosticsEnabled = false 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 deviceInfo: DeviceInfo?
var appVersion = "" var appVersion = ""
var isLoadingDeviceInfo = false var isLoadingDeviceInfo = false
@@ -66,6 +76,13 @@ struct SettingsState: Equatable {
&& lhs.themeMode == rhs.themeMode && lhs.notificationsEnabled == rhs.notificationsEnabled && lhs.themeMode == rhs.themeMode && lhs.notificationsEnabled == rhs.notificationsEnabled
&& lhs.notificationPermission == rhs.notificationPermission && lhs.notificationPermission == rhs.notificationPermission
&& lhs.diagnosticsEnabled == rhs.diagnosticsEnabled && lhs.appVersion == rhs.appVersion && 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.isLoadingDeviceInfo == rhs.isLoadingDeviceInfo
&& lhs.bugWhatHappened == rhs.bugWhatHappened && lhs.bugExpected == rhs.bugExpected && lhs.bugWhatHappened == rhs.bugWhatHappened && lhs.bugExpected == rhs.bugExpected
&& lhs.bugSteps == rhs.bugSteps && lhs.bugContact == rhs.bugContact && lhs.bugSteps == rhs.bugSteps && lhs.bugContact == rhs.bugContact
@@ -96,6 +113,7 @@ final class SettingsModel: ObservableObject {
private var enableNotificationsAfterSettings = false private var enableNotificationsAfterSettings = false
private var usernamePersistTask: Task<Void, Never>? private var usernamePersistTask: Task<Void, Never>?
private var hasLocalUsernameDraft = false private var hasLocalUsernameDraft = false
private var hasRelayConfigurationDraft = false
private var cancellables = Set<AnyCancellable>() private var cancellables = Set<AnyCancellable>()
init( init(
@@ -133,10 +151,29 @@ final class SettingsModel: ObservableObject {
self.state.themeMode = prefs.themeMode self.state.themeMode = prefs.themeMode
self.state.notificationsEnabled = prefs.notificationsEnabled self.state.notificationsEnabled = prefs.notificationsEnabled
self.state.diagnosticsEnabled = prefs.diagnosticsEnabled 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) } } if folder != previousFolder { Task { await self.validateFolder(folder) } }
} }
.store(in: &cancellables) .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() refreshNotificationPermission()
loadDeviceInfo() 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 setBugWhatHappened(_ value: String) { state.bugWhatHappened = value }
func setBugExpected(_ value: String) { state.bugExpected = value } func setBugExpected(_ value: String) { state.bugExpected = value }
func setBugSteps(_ value: String) { state.bugSteps = value } func setBugSteps(_ value: String) { state.bugSteps = value }

View File

@@ -38,6 +38,17 @@ struct SettingsScreen: View {
NavigationLink(value: SettingsSection.storage) { NavigationLink(value: SettingsSection.storage) {
SettingsRow(icon: "internaldrive", title: String(localized: "storage_title"), value: nil) 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) { NavigationLink(value: SettingsSection.about) {
SettingsRow(icon: "info.circle", title: String(localized: "about_title"), value: nil) SettingsRow(icon: "info.circle", title: String(localized: "about_title"), value: nil)
} }
@@ -99,6 +110,8 @@ private struct SettingsSectionContent: View {
AppearanceSettings(model: model) AppearanceSettings(model: model)
case .notifications: case .notifications:
NotificationSettings(model: model) NotificationSettings(model: model)
case .network:
NetworkSettings(model: model)
case .storage: case .storage:
StorageSettings(model: model) StorageSettings(model: model)
case .about: 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 { struct SettingsRow: View {
let icon: String let icon: String
let title: String let title: String

View File

@@ -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 { struct StorageSettings: View {
@ObservedObject var model: SettingsModel @ObservedObject var model: SettingsModel
@State private var showDeleteConfirmation = false @State private var showDeleteConfirmation = false

File diff suppressed because it is too large Load Diff

View File

@@ -36,4 +36,5 @@ uuid = { version = "1.23.3", features = ["v4", "serde"] }
walkdir = "2.5.0" walkdir = "2.5.0"
[dev-dependencies] [dev-dependencies]
iroh-relay = { version = "1.0.0", features = ["server"] }
tempfile = "3.27.0" tempfile = "3.27.0"

View File

@@ -1,9 +1,121 @@
use anyhow::Context; use anyhow::Context;
use iroh::RelayUrl;
use iroh_blobs::Hash; use iroh_blobs::Hash;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::{collections::BTreeSet, net::IpAddr, str::FromStr};
use crate::util::{non_empty, now_ms}; 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<String>,
}
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<Vec<RelayUrl>> {
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::<IpAddr>()
.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)] #[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)]
pub struct CoreLimits { pub struct CoreLimits {
pub max_sources: u64, pub max_sources: u64,

View File

@@ -14,10 +14,11 @@ mod transfer_state;
mod util; mod util;
pub use api::{ pub use api::{
default_core_limits, CoreEvent, CoreEventSink, CoreLimits, CoreStorageUsage, PublishedOutput, default_core_limits, default_core_network_config, CoreEvent, CoreEventSink, CoreLimits,
ReceiveOutputSink, ReceiveOutputSinkV2, ReceivedArtifact, ReceivedLocatorKind, ReceiverRequest, CoreNetworkConfig, CoreRelayMode, CoreStorageUsage, PublishedOutput, ReceiveOutputSink,
RuntimeStatus, ShareMetadataInput, ShareResult, ShareSource, SourceKind, StoredTransfer, ReceiveOutputSinkV2, ReceivedArtifact, ReceivedLocatorKind, ReceiverRequest, RuntimeStatus,
TicketInspection, TransferAccessMode, TransferMetadata, ShareMetadataInput, ShareResult, ShareSource, SourceKind, StoredTransfer, TicketInspection,
TransferAccessMode, TransferMetadata,
}; };
pub use error::VnidropError; pub use error::VnidropError;
pub use runtime::VnidropCore; pub use runtime::VnidropCore;

View File

@@ -49,6 +49,7 @@ pub(crate) struct PersistedShare {
pub(crate) transfer_id: u64, pub(crate) transfer_id: u64,
pub(crate) local_id: String, pub(crate) local_id: String,
pub(crate) content_hash: String, pub(crate) content_hash: String,
pub(crate) ticket: Option<String>,
pub(crate) access_mode: String, pub(crate) access_mode: String,
} }
@@ -709,7 +710,7 @@ impl Repository {
pub(crate) async fn list_active_shares(&self) -> Result<Vec<PersistedShare>> { pub(crate) async fn list_active_shares(&self) -> Result<Vec<PersistedShare>> {
let rows = sqlx::query( let rows = sqlx::query(
r#" r#"
SELECT transfer_id, local_id, content_hash, access_mode SELECT transfer_id, local_id, content_hash, ticket, access_mode
FROM transfers FROM transfers
WHERE direction = 'send' WHERE direction = 'send'
AND status = 'sharing' AND status = 'sharing'
@@ -724,7 +725,8 @@ impl Repository {
transfer_id: row.get::<i64, _>(0) as u64, transfer_id: row.get::<i64, _>(0) as u64,
local_id: row.get::<String, _>(1), local_id: row.get::<String, _>(1),
content_hash: row.get::<String, _>(2), content_hash: row.get::<String, _>(2),
access_mode: row.get::<String, _>(3), ticket: row.get::<Option<String>, _>(3),
access_mode: row.get::<String, _>(4),
}) })
.collect()) .collect())
} }

View File

@@ -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 serde_json::json;
use super::CoreInner; use super::{filter_peer_addr_for_relay_mode, CoreInner};
use crate::{ use crate::{
handshake::{DeliveryReceipt, DeliveryReceiptResponse, HandshakeService}, handshake::{DeliveryReceipt, DeliveryReceiptResponse, HandshakeService},
repository::PendingDeliveryReceipt, repository::PendingDeliveryReceipt,
ticket::parse_persisted_sender_address,
}; };
const DELIVERY_RECEIPT_TIMEOUT: Duration = Duration::from_secs(5); const DELIVERY_RECEIPT_TIMEOUT: Duration = Duration::from_secs(5);
@@ -60,8 +60,8 @@ impl CoreInner {
} }
async fn deliver_pending_receipt(&self, pending: PendingDeliveryReceipt) { async fn deliver_pending_receipt(&self, pending: PendingDeliveryReceipt) {
let blob_ticket = match BlobTicket::from_str(&pending.sender_blob_ticket) { let sender_addr = match parse_persisted_sender_address(&pending.sender_blob_ticket) {
Ok(ticket) => ticket, Ok(addr) => addr,
Err(error) => { Err(error) => {
tracing::warn!(%error, request_id = %pending.request_id, "discarded invalid pending delivery receipt"); tracing::warn!(%error, request_id = %pending.request_id, "discarded invalid pending delivery receipt");
let _ = self let _ = self
@@ -78,7 +78,24 @@ impl CoreInner {
return; 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 { let receipt = DeliveryReceipt {
request_id: pending.request_id.clone(), request_id: pending.request_id.clone(),
transfer_id: pending.sender_transfer_id, transfer_id: pending.sender_transfer_id,

View File

@@ -6,9 +6,10 @@ use serde_json::json;
use super::CoreInner; use super::CoreInner;
use crate::{ use crate::{
api::{ api::{
CoreEvent, CoreEventSink, CoreLimits, CoreStorageUsage, ReceiveOutputSink, CoreEvent, CoreEventSink, CoreLimits, CoreNetworkConfig, CoreStorageUsage,
ReceiveOutputSinkV2, ReceivedArtifact, ReceiverRequest, RuntimeStatus, ShareMetadataInput, ReceiveOutputSink, ReceiveOutputSinkV2, ReceivedArtifact, ReceiverRequest, RuntimeStatus,
ShareResult, ShareSource, StoredTransfer, TicketInspection, TransferAccessMode, ShareMetadataInput, ShareResult, ShareSource, StoredTransfer, TicketInspection,
TransferAccessMode,
}, },
error::VnidropError, error::VnidropError,
filesystem::platform_path, filesystem::platform_path,
@@ -42,7 +43,26 @@ impl VnidropCore {
app_data_dir: String, app_data_dir: String,
event_sink: Arc<dyn CoreEventSink>, event_sink: Arc<dyn CoreEventSink>,
) -> Result<Arc<Self>, VnidropError> { ) -> Result<Arc<Self>, 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<dyn CoreEventSink>,
network_config: CoreNetworkConfig,
) -> Result<Arc<Self>, VnidropError> {
Self::initialize_with_limits_and_network_config(
app_data_dir,
event_sink,
CoreLimits::default(),
network_config,
)
} }
#[uniffi::constructor] #[uniffi::constructor]
@@ -50,15 +70,39 @@ impl VnidropCore {
app_data_dir: String, app_data_dir: String,
event_sink: Arc<dyn CoreEventSink>, event_sink: Arc<dyn CoreEventSink>,
limits: CoreLimits, limits: CoreLimits,
) -> Result<Arc<Self>, 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<dyn CoreEventSink>,
limits: CoreLimits,
network_config: CoreNetworkConfig,
) -> Result<Arc<Self>, VnidropError> { ) -> Result<Arc<Self>, VnidropError> {
limits.validate().map_err(VnidropError::initialization)?; 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() let runtime = tokio::runtime::Builder::new_multi_thread()
.enable_all() .enable_all()
.thread_name("vnidrop") .thread_name("vnidrop")
.build()?; .build()?;
let app_data_dir = PathBuf::from(app_data_dir); let app_data_dir = PathBuf::from(app_data_dir);
let inner = runtime 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)?; .map_err(VnidropError::initialization)?;
Ok(Arc::new(Self { runtime, inner })) Ok(Arc::new(Self { runtime, inner }))
} }

View File

@@ -27,7 +27,10 @@ use std::{
use anyhow::Result; use anyhow::Result;
use futures_lite::StreamExt as _; 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::{ use iroh_blobs::{
format::collection::Collection, format::collection::Collection,
provider::events::{EventMask, EventSender}, provider::events::{EventMask, EventSender},
@@ -45,16 +48,19 @@ use tokio::{
use crate::{ use crate::{
access_policy::{mode_from_storage, AccessPolicy}, access_policy::{mode_from_storage, AccessPolicy},
api::{CoreEvent, CoreEventSink, CoreLimits}, api::{CoreEvent, CoreEventSink, CoreLimits, CoreRelayMode},
approval::ApprovalService, approval::ApprovalService,
event_hub::EventHub, event_hub::EventHub,
handshake::HandshakeService, handshake::HandshakeService,
logging::init_logging, logging::init_logging,
repository::Repository, repository::Repository,
secret::load_or_create_secret, secret::load_or_create_secret,
ticket::ticket_matches_relay_profile,
transfer_state::{TransferDirection, TransferStatus}, transfer_state::{TransferDirection, TransferStatus},
}; };
const RELAY_CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
/// Owns the Iroh endpoint, blob store, transfer history, and byte streaming. /// Owns the Iroh endpoint, blob store, transfer history, and byte streaming.
/// Kotlin owns app lifecycle and platform file picking. /// Kotlin owns app lifecycle and platform file picking.
pub(super) struct CoreInner { pub(super) struct CoreInner {
@@ -66,6 +72,8 @@ pub(super) struct CoreInner {
pub(super) event_hub: Arc<EventHub>, pub(super) event_hub: Arc<EventHub>,
pub(super) approval: ApprovalService, pub(super) approval: ApprovalService,
pub(super) limits: CoreLimits, pub(super) limits: CoreLimits,
pub(super) relay_mode: CoreRelayMode,
pub(super) custom_relay_urls: Vec<RelayUrl>,
pub(super) transfer_slots: Semaphore, pub(super) transfer_slots: Semaphore,
pub(super) access_policy: Arc<AccessPolicy>, pub(super) access_policy: Arc<AccessPolicy>,
/// Sync mutex so cancel can remove + signal without awaiting (and without /// Sync mutex so cancel can remove + signal without awaiting (and without
@@ -93,6 +101,8 @@ impl CoreInner {
app_data_dir: PathBuf, app_data_dir: PathBuf,
event_sink: Arc<dyn CoreEventSink>, event_sink: Arc<dyn CoreEventSink>,
limits: CoreLimits, limits: CoreLimits,
relay_mode: CoreRelayMode,
relay_urls: Vec<RelayUrl>,
) -> Result<Arc<Self>> { ) -> Result<Arc<Self>> {
tokio::fs::create_dir_all(&app_data_dir).await?; tokio::fs::create_dir_all(&app_data_dir).await?;
init_logging(&app_data_dir)?; init_logging(&app_data_dir)?;
@@ -105,11 +115,39 @@ impl CoreInner {
add_protected: None, add_protected: None,
}); });
let store = FsStore::load_with_opts(store_root.join("blobs.db"), store_options).await?; let store = FsStore::load_with_opts(store_root.join("blobs.db"), store_options).await?;
let endpoint = Endpoint::builder(presets::N0) let endpoint = match relay_mode {
.secret_key(secret_key) CoreRelayMode::Automatic => {
.bind() Endpoint::builder(presets::N0)
.await?; .secret_key(secret_key)
endpoint.online().await; .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 // Provider events are where the sender sees remote readers. The core
// uses them for send progress and for the current approval gate. // uses them for send progress and for the current approval gate.
@@ -189,6 +227,27 @@ impl CoreInner {
); );
continue; 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); let tag_name = share_tag_name(&share.local_id);
store store
.tags() .tags()
@@ -239,6 +298,8 @@ impl CoreInner {
repository, repository,
event_hub, event_hub,
approval, approval,
relay_mode,
custom_relay_urls: relay_urls,
transfer_slots: Semaphore::new(limits.max_concurrent_transfers as usize), transfer_slots: Semaphore::new(limits.max_concurrent_transfers as usize),
limits, limits,
access_policy, 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<EndpointAddr> {
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::<Vec<_>>()
.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 { pub(super) fn share_tag_name(local_id: &str) -> String {
format!("vnidrop/share/{local_id}") format!("vnidrop/share/{local_id}")
} }

View File

@@ -9,12 +9,12 @@ use bytes::Bytes;
use futures_lite::StreamExt as _; use futures_lite::StreamExt as _;
use iroh_blobs::{ use iroh_blobs::{
api::proto::ExportRangesItem, api::remote::GetProgressItem, format::collection::Collection, 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 serde_json::json;
use tokio::sync::oneshot; use tokio::sync::oneshot;
use super::{ActiveTransfer, CoreInner}; use super::{filter_peer_addr_for_relay_mode, ActiveTransfer, CoreInner};
use crate::{ use crate::{
access_policy::mode_to_storage, access_policy::mode_to_storage,
api::{ api::{
@@ -28,7 +28,9 @@ use crate::{
}, },
handshake::{DeliveryReceipt, HandshakeResponse, HandshakeService}, handshake::{DeliveryReceipt, HandshakeResponse, HandshakeService},
repository::{PendingDeliveryReceiptInsert, ReceivedArtifactInsert, TransferUpsert}, 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}, transfer_state::{TransferDirection, TransferStatus},
}; };
@@ -192,7 +194,7 @@ impl CoreInner {
.await .await
.context("transfer limiter is closed") .context("transfer limiter is closed")
.map_err(VnidropError::internal)?; .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") .context("failed to parse transfer ticket")
{ {
Ok(parsed) => parsed, Ok(parsed) => parsed,
@@ -205,6 +207,17 @@ impl CoreInner {
return Err(error); 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; let transfer_id = parsed.metadata.transfer_id;
self.persist_receive_start(transfer_id, &parsed, receiver_name.as_deref()) self.persist_receive_start(transfer_id, &parsed, receiver_name.as_deref())
.await .await
@@ -271,7 +284,8 @@ impl CoreInner {
.map_err(VnidropError::filesystem)?; .map_err(VnidropError::filesystem)?;
} }
let sender_addr = parsed.blob_ticket.addr().clone(); 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!({})); self.emit_transfer(transfer_id, "receive", "network", "connecting", json!({}));
// Every VniDrop ticket carries metadata and must complete the handshake. // Every VniDrop ticket carries metadata and must complete the handshake.
@@ -355,7 +369,7 @@ impl CoreInner {
self.repository self.repository
.complete_receive_with_pending_receipt(PendingDeliveryReceiptInsert { .complete_receive_with_pending_receipt(PendingDeliveryReceiptInsert {
local_transfer_id: transfer_id, local_transfer_id: transfer_id,
sender_blob_ticket: &sender_blob_ticket, sender_blob_ticket: &persisted_sender_address,
request_id: &delivery_receipt.request_id, request_id: &delivery_receipt.request_id,
sender_transfer_id: delivery_receipt.transfer_id, sender_transfer_id: delivery_receipt.transfer_id,
token: &delivery_receipt.token, token: &delivery_receipt.token,

View File

@@ -149,9 +149,13 @@ impl CoreInner {
import.file_count, import.file_count,
import.total_size, import.total_size,
); );
let ticket = VnidropTicket::new(blob_ticket, ticket_metadata) let ticket = VnidropTicket::new_with_relay_urls(
.encode() blob_ticket,
.context("failed to encode VniDrop transfer ticket")?; ticket_metadata,
&self.custom_relay_urls,
)
.encode()
.context("failed to encode VniDrop transfer ticket")?;
let content_hash = import.root_hash.to_string(); let content_hash = import.root_hash.to_string();
let local_id = self let local_id = self
.repository .repository

View File

@@ -8,6 +8,8 @@ mod filesystem_tests;
mod handshake_tests; mod handshake_tests;
#[path = "tests/limits.rs"] #[path = "tests/limits.rs"]
mod limits_tests; mod limits_tests;
#[path = "tests/network_config.rs"]
mod network_config_tests;
#[path = "tests/repository.rs"] #[path = "tests/repository.rs"]
mod repository_tests; mod repository_tests;
#[path = "tests/runtime.rs"] #[path = "tests/runtime.rs"]

View File

@@ -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<_>>(),
vec![allowed.clone()]
);
assert_eq!(
filtered.ip_addrs().copied().collect::<Vec<_>>(),
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;
}

View File

@@ -80,6 +80,7 @@ async fn persists_transfers_and_events_across_reopen() {
assert_eq!(shares.len(), 1); assert_eq!(shares.len(), 1);
assert_eq!(shares[0].transfer_id, 7); assert_eq!(shares[0].transfer_id, 7);
assert_eq!(shares[0].content_hash, "hash"); assert_eq!(shares[0].content_hash, "hash");
assert_eq!(shares[0].ticket.as_deref(), Some("ticket"));
assert_eq!(shares[0].access_mode, "approval_required"); assert_eq!(shares[0].access_mode, "approval_required");
repository repository

View File

@@ -1,11 +1,16 @@
use std::net::{Ipv4Addr, SocketAddr};
use data_encoding::BASE64URL_NOPAD; use data_encoding::BASE64URL_NOPAD;
use iroh::SecretKey; use iroh::{RelayUrl, SecretKey};
use iroh_blobs::{ticket::BlobTicket, BlobFormat, Hash}; use iroh_blobs::{ticket::BlobTicket, BlobFormat, Hash};
use serde_json::json; use serde_json::json;
use crate::{ use crate::{
api::{CoreLimits, TransferMetadata}, api::{CoreLimits, CoreRelayMode, TransferMetadata},
ticket::{parse_transfer_ticket, parse_transfer_ticket_with_limits, VnidropTicket}, 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 { fn blob_ticket(hash_byte: u8) -> BlobTicket {
@@ -25,7 +30,7 @@ fn metadata_ticket_round_trips() {
3, 3,
2048, 2048,
); );
let encoded = VnidropTicket::new(blob_ticket.clone(), metadata.clone()) let encoded = VnidropTicket::new_with_relay_urls(blob_ticket.clone(), metadata.clone(), &[])
.encode() .encode()
.unwrap(); .unwrap();
let parsed = parse_transfer_ticket(&encoded).unwrap(); let parsed = parse_transfer_ticket(&encoded).unwrap();
@@ -38,7 +43,7 @@ fn metadata_ticket_round_trips() {
fn metadata_ticket_tolerates_wrapped_whitespace() { fn metadata_ticket_tolerates_wrapped_whitespace() {
let blob_ticket = blob_ticket(9); let blob_ticket = blob_ticket(9);
let metadata = TransferMetadata::new(7, "Wrapped", None, blob_ticket.hash(), 1, 10); 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() .encode()
.unwrap(); .unwrap();
let wrapped = encoded let wrapped = encoded
@@ -52,6 +57,129 @@ fn metadata_ticket_tolerates_wrapped_whitespace() {
assert_eq!(parsed.blob_ticket.hash(), blob_ticket.hash()); 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<_>>(),
vec![primary, backup]
);
assert_eq!(
parsed
.blob_ticket
.addr()
.ip_addrs()
.copied()
.collect::<Vec<_>>(),
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] #[test]
fn invalid_ticket_is_rejected() { fn invalid_ticket_is_rejected() {
assert!(parse_transfer_ticket("not-a-ticket").is_err()); assert!(parse_transfer_ticket("not-a-ticket").is_err());

View File

@@ -1,27 +1,38 @@
use std::str::FromStr; use std::{collections::BTreeSet, str::FromStr};
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use data_encoding::BASE64URL_NOPAD; use data_encoding::BASE64URL_NOPAD;
use iroh::{EndpointAddr, RelayUrl};
use iroh_blobs::ticket::BlobTicket; use iroh_blobs::ticket::BlobTicket;
use serde::{Deserialize, Serialize}; 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_PREFIX: &str = "vnd1:";
const VNIDROP_TICKET_VERSION: u8 = 1; const VNIDROP_TICKET_VERSION: u8 = 1;
const PERSISTED_SENDER_ADDRESS_PREFIX: &str = "vndaddr1:";
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct VnidropTicket { pub(crate) struct VnidropTicket {
version: u8, version: u8,
blob_ticket: String, 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<String>,
metadata: TransferMetadata, metadata: TransferMetadata,
} }
impl VnidropTicket { 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 { Self {
version: VNIDROP_TICKET_VERSION, version: VNIDROP_TICKET_VERSION,
blob_ticket: blob_ticket.to_string(), blob_ticket: blob_ticket.to_string(),
relay_urls: relay_urls.iter().map(ToString::to_string).collect(),
metadata, metadata,
} }
} }
@@ -50,6 +61,35 @@ impl VnidropTicket {
pub(crate) struct ParsedTransferTicket { pub(crate) struct ParsedTransferTicket {
pub(crate) blob_ticket: BlobTicket, pub(crate) blob_ticket: BlobTicket,
pub(crate) metadata: TransferMetadata, pub(crate) metadata: TransferMetadata,
pub(crate) advertised_custom_relay_urls: Vec<RelayUrl>,
}
#[derive(Debug, Serialize, Deserialize)]
struct PersistedSenderAddress {
addr: EndpointAddr,
}
pub(crate) fn encode_persisted_sender_address(addr: &EndpointAddr) -> Result<String> {
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<EndpointAddr> {
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)] #[cfg(test)]
@@ -93,17 +133,55 @@ pub(crate) fn parse_transfer_ticket_with_limits(
Some(ticket.metadata.transfer_name.as_str()), Some(ticket.metadata.transfer_name.as_str()),
)?; )?;
limits.validate_metadata_text("sender name", ticket.metadata.sender_name.as_deref())?; 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")?; .context("invalid BlobTicket inside VniDrop ticket")?;
if ticket.metadata.content_hash != blob_ticket.hash().to_string() { if ticket.metadata.content_hash != blob_ticket.hash().to_string() {
anyhow::bail!("VniDrop ticket metadata hash does not match BlobTicket hash"); 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 { Ok(ParsedTransferTicket {
blob_ticket, blob_ticket,
metadata: ticket.metadata, 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<bool> {
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::<BTreeSet<_>>();
let configured = custom_relay_urls.iter().cloned().collect::<BTreeSet<_>>();
Ok(advertised == configured)
}
}
}
fn normalize_ticket_input(value: &str) -> String { fn normalize_ticket_input(value: &str) -> String {
// Tickets are commonly copied from text views or chat apps that insert line // Tickets are commonly copied from text views or chat apps that insert line
// breaks. Strip whitespace only; other corrupt characters should still be // breaks. Strip whitespace only; other corrupt characters should still be

View File

@@ -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::<Vec<_>>();
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::<Vec<_>>(),
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"
);
}

View File

@@ -5,10 +5,10 @@ use std::time::Duration;
use futures_lite::StreamExt as _; use futures_lite::StreamExt as _;
use iroh_blobs::store::fs::FsStore; use iroh_blobs::store::fs::FsStore;
use support::{share_path, CoreGuard, RecordingSink, TestNode}; use support::{share_path, CoreGuard, RecordingSink, TestNode, TestRelay};
use vnidrop::{ use vnidrop::{
CoreEvent, CoreEventSink, CoreLimits, ShareMetadataInput, ShareSource, SourceKind, CoreEvent, CoreEventSink, CoreLimits, CoreNetworkConfig, CoreRelayMode, ShareMetadataInput,
TransferAccessMode, ShareSource, SourceKind, TransferAccessMode,
}; };
#[test] #[test]
@@ -137,6 +137,56 @@ fn persisted_share_is_recovered_and_can_be_stopped_after_restart() {
assert_eq!(restarted.status().active_shares, 0); 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] #[test]
fn stopped_share_rejects_receive() { fn stopped_share_rejects_receive() {
let source_dir = tempfile::tempdir().unwrap(); let source_dir = tempfile::tempdir().unwrap();

View File

@@ -8,15 +8,16 @@ use std::{
path::Path, path::Path,
sync::{ sync::{
atomic::{AtomicBool, Ordering}, atomic::{AtomicBool, Ordering},
Arc, Condvar, Mutex, mpsc, Arc, Condvar, Mutex,
}, },
thread::JoinHandle,
time::{Duration, Instant}, time::{Duration, Instant},
}; };
use vnidrop::{ use vnidrop::{
CoreEvent, CoreEventSink, CoreLimits, PublishedOutput, ReceiveOutputSink, ReceiveOutputSinkV2, CoreEvent, CoreEventSink, CoreLimits, CoreNetworkConfig, PublishedOutput, ReceiveOutputSink,
ReceivedLocatorKind, ReceiverRequest, ShareMetadataInput, ShareResult, ShareSource, SourceKind, ReceiveOutputSinkV2, ReceivedLocatorKind, ReceiverRequest, ShareMetadataInput, ShareResult,
TransferAccessMode, VnidropCore, VnidropError, ShareSource, SourceKind, TransferAccessMode, VnidropCore, VnidropError,
}; };
#[derive(Default)] #[derive(Default)]
@@ -57,6 +58,21 @@ impl CoreGuard {
) )
} }
pub fn start_with_network_config(
path: &Path,
sink: Arc<dyn CoreEventSink>,
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<VnidropCore> { pub fn arc(&self) -> Arc<VnidropCore> {
self.0.clone() self.0.clone()
} }
@@ -93,6 +109,80 @@ impl TestNode {
sink, 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<tokio::sync::oneshot::Sender<()>>,
thread: Option<JoinHandle<()>>,
}
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)] #[derive(Default)]

View File

@@ -2483,6 +2483,338 @@
"ru": "Передача VniDrop" "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 dappliquer 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 dappliquer 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 VniDrops network connection. Stop active transfers and shares first. Existing invitations may need to be shared again.",
"fr": "Lapplication de ces réglages redémarre la connexion réseau de VniDrop. Arrêtez dabord les transferts et partages actifs. Il peut être nécessaire de partager à nouveau les invitations existantes.",
"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": "Lapplicazione riavvia la connessione di rete di VniDrop. Interrompa prima i trasferimenti e le condivisioni attivi. Potrebbe essere necessario condividere di nuovo gli inviti esistenti.",
"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 unautorità 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 VniDrops default public relay infrastructure when a direct connection is unavailable.",
"fr": "Utiliser linfrastructure de relais publique par défaut de VniDrop lorsquune 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 linfrastruttura 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 nutilisera ni les relais publics ni la découverte publique comme solution de repli. Les autres appareils doivent pouvoir accéder aux relais configurés.",
"es": "El modo personalizado es estricto: VniDrop no recurrirá a relés públicos ni al descubrimiento público. Los demás dispositivos deben poder acceder a los relés configurados.",
"it": "La modalità personalizzata è rigorosa: VniDrop non userà relay pubblici o il rilevamento pubblico come ripiego. Gli altri dispositivi devono poter raggiungere i relay configurati.",
"de": "Der benutzerdefinierte Modus ist strikt: VniDrop greift weder auf öffentliche Relays noch auf öffentliche Erkennung zurück. Andere Geräte müssen Ihre konfigurierten Relays erreichen können.",
"pt": "O modo personalizado é estrito: o VniDrop não recorrerá a retransmissores públicos nem à descoberta pública. Os outros dispositivos têm de conseguir aceder aos retransmissores configurados.",
"pl": "Tryb niestandardowy jest rygorystyczny: VniDrop nie użyje awaryjnie publicznych przekaźników ani publicznego wykrywania. Inne urządzenia muszą mieć dostęp do skonfigurowanych przekaźników.",
"nl": "De aangepaste modus is strikt: VniDrop valt niet terug op openbare relays of openbare detectie. Andere apparaten moeten uw ingestelde relays kunnen bereiken.",
"ru": "Пользовательский режим работает строго: VniDrop не будет переключаться на публичные ретрансляторы или публичное обнаружение. Другие устройства должны иметь доступ к настроенным ретрансляторам."
}
},
"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": "LURL 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": "LURL 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": "LURL de relais à la ligne {line} doit commencer par https://.",
"es": "La URL de relé de la línea {line} debe empezar por https://.",
"it": "LURL 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": "LURL de relais à la ligne {line} nest pas valide.",
"es": "La URL de relé de la línea {line} no es válida.",
"it": "LURL 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": { "send_access_anyone": {
"context": "Send access option: anyone with the invitation can receive.", "context": "Send access option: anyone with the invitation can receive.",
"translations": { "translations": {
@@ -2811,6 +3143,34 @@
"ru": "Ваши передачи" "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": { "settings_subtitle": {
"context": "Settings screen: subtitle summarizing what's configurable.", "context": "Settings screen: subtitle summarizing what's configurable.",
"translations": { "translations": {
@@ -3692,6 +4052,20 @@
"ru": "Получатели" "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 nest 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": { "transfer_scan_qr": {
"context": "Transfer share: caption under the QR code.", "context": "Transfer share: caption under the QR code.",
"translations": { "translations": {

View File

@@ -169,6 +169,28 @@
<string name="receive_review_title">Übertragung prüfen</string> <string name="receive_review_title">Übertragung prüfen</string>
<string name="receive_title">Empfangen</string> <string name="receive_title">Empfangen</string>
<string name="receive_unknown_transfer">VniDrop-Übertragung</string> <string name="receive_unknown_transfer">VniDrop-Übertragung</string>
<string name="relay_add_url">Relay-Server hinzufügen</string>
<string name="relay_apply">Netzwerkeinstellungen anwenden</string>
<string name="relay_applying">Wird angewendet…</string>
<string name="relay_apply_active_transfers">Beenden Sie alle aktiven Übertragungen und Freigaben, bevor Sie die Netzwerkeinstellungen anwenden.</string>
<string name="relay_apply_failed">Diese Einstellungen konnten nicht angewendet werden. Die vorherigen Netzwerkeinstellungen wurden wiederhergestellt.</string>
<string name="relay_apply_restart_description">Beim Anwenden wird die Netzwerkverbindung von VniDrop neu gestartet. Beenden Sie zuerst aktive Übertragungen und Freigaben. Vorhandene Einladungen müssen eventuell erneut geteilt werden.</string>
<string name="relay_custom_urls_help">Geben Sie pro Zeile eine HTTPS-Relay-URL ein. Anmeldedaten in URLs werden nicht unterstützt. Das TLS-Zertifikat muss von einer öffentlich vertrauenswürdigen Zertifizierungsstelle ausgestellt sein.</string>
<string name="relay_custom_urls_label">Relay-URLs</string>
<string name="relay_mode_automatic">Automatisch</string>
<string name="relay_mode_automatic_description">Verwendet die öffentliche Standard-Relay-Infrastruktur von VniDrop, wenn keine direkte Verbindung möglich ist.</string>
<string name="relay_mode_custom">Benutzerdefiniert</string>
<string name="relay_mode_custom_description">Verwendet ausschließlich die unten aufgeführten Relay-Server.</string>
<string name="relay_privacy_description">Relays leiten verschlüsselten Datenverkehr weiter und können Ihre Dateien nicht lesen, ihr Betreiber kann jedoch Verbindungsmetadaten sehen.</string>
<string name="relay_remove_url">Relay-Server entfernen</string>
<string name="relay_restore_failed">Die vorherigen Netzwerkeinstellungen konnten nicht wiederhergestellt werden. Starten Sie VniDrop neu und prüfen Sie Ihre Relay-Konfiguration.</string>
<string name="relay_settings_applied">Netzwerkeinstellungen angewendet.</string>
<string name="relay_strict_warning">Der benutzerdefinierte Modus ist strikt: VniDrop greift weder auf öffentliche Relays noch auf öffentliche Erkennung zurück. Andere Geräte müssen Ihre konfigurierten Relays erreichen können.</string>
<string name="relay_validation_duplicate_url">Die Relay-URL in Zeile %1$d ist bereits zuvor eingetragen.</string>
<string name="relay_validation_https_required">Die Relay-URL in Zeile %1$d muss mit https:// beginnen.</string>
<string name="relay_validation_invalid_url">Die Relay-URL in Zeile %1$d ist ungültig.</string>
<string name="relay_validation_missing_url">Fügen Sie mindestens eine Relay-URL hinzu.</string>
<string name="relay_validation_too_many_urls">Sie können bis zu %1$d Relay-Server konfigurieren.</string>
<string name="send_access_anyone">Jeder mit dieser Übertragung</string> <string name="send_access_anyone">Jeder mit dieser Übertragung</string>
<string name="send_access_anyone_description">Keine Genehmigung erforderlich. Verwenden Sie dies nur für Objekte, die Sie unbedenklich teilen können.</string> <string name="send_access_anyone_description">Keine Genehmigung erforderlich. Verwenden Sie dies nur für Objekte, die Sie unbedenklich teilen können.</string>
<string name="send_access_anyone_warning">Jeder mit der Einladung kann herunterladen, bis Sie die Freigabe beenden. Verwenden Sie dies nicht für private oder sensible Objekte.</string> <string name="send_access_anyone_warning">Jeder mit der Einladung kann herunterladen, bis Sie die Freigabe beenden. Verwenden Sie dies nicht für private oder sensible Objekte.</string>
@@ -192,6 +214,8 @@
<string name="send_transfer_created">Übertragung erstellt.</string> <string name="send_transfer_created">Übertragung erstellt.</string>
<string name="send_transfer_details_title">Übertragungsdetails</string> <string name="send_transfer_details_title">Übertragungsdetails</string>
<string name="send_transfers_title">Ihre Übertragungen</string> <string name="send_transfers_title">Ihre Übertragungen</string>
<string name="settings_advanced_title">Erweitert</string>
<string name="settings_network_title">Netzwerk</string>
<string name="settings_subtitle">Ihr Name, wo Übertragungen gesichert werden, Darstellung und Mitteilungen.</string> <string name="settings_subtitle">Ihr Name, wo Übertragungen gesichert werden, Darstellung und Mitteilungen.</string>
<string name="settings_title">Einstellungen</string> <string name="settings_title">Einstellungen</string>
<string name="snackbar_dismiss">Ausblenden</string> <string name="snackbar_dismiss">Ausblenden</string>
@@ -250,6 +274,7 @@
<string name="transfer_receivers_description">Anfragen, Genehmigungen und abgeschlossene Zustellungen</string> <string name="transfer_receivers_description">Anfragen, Genehmigungen und abgeschlossene Zustellungen</string>
<string name="transfer_receivers_pending">%1$d warten</string> <string name="transfer_receivers_pending">%1$d warten</string>
<string name="transfer_receivers_title">Empfänger</string> <string name="transfer_receivers_title">Empfänger</string>
<string name="transfer_qr_unavailable">Für diese Einladung ist kein QR-Code verfügbar. Verwenden Sie stattdessen Teilen oder Herunterladen.</string>
<string name="transfer_scan_qr">Mit VniDrop scannen, um diese Übertragung zu empfangen</string> <string name="transfer_scan_qr">Mit VniDrop scannen, um diese Übertragung zu empfangen</string>
<string name="transfer_share_description">QR-Code, Einladungsdatei und Optionen in der Nähe</string> <string name="transfer_share_description">QR-Code, Einladungsdatei und Optionen in der Nähe</string>
<string name="transfer_share_title">Teilen</string> <string name="transfer_share_title">Teilen</string>

View File

@@ -169,6 +169,28 @@
<string name="receive_review_title">Revisar transferencia</string> <string name="receive_review_title">Revisar transferencia</string>
<string name="receive_title">Recibir</string> <string name="receive_title">Recibir</string>
<string name="receive_unknown_transfer">Transferencia de VniDrop</string> <string name="receive_unknown_transfer">Transferencia de VniDrop</string>
<string name="relay_add_url">Añadir servidor de retransmisión</string>
<string name="relay_apply">Aplicar ajustes de red</string>
<string name="relay_applying">Aplicando…</string>
<string name="relay_apply_active_transfers">Detenga todas las transferencias y elementos compartidos activos antes de aplicar los ajustes de red.</string>
<string name="relay_apply_failed">No se han podido aplicar estos ajustes. Se han restaurado los ajustes de red anteriores.</string>
<string name="relay_apply_restart_description">Al aplicar los ajustes, se reinicia la conexión de red de VniDrop. Detenga primero las transferencias y los elementos compartidos activos. Es posible que tenga que volver a compartir las invitaciones existentes.</string>
<string name="relay_custom_urls_help">Introduzca una URL HTTPS de relé por línea. No se admiten credenciales en las URL. El certificado TLS debe ser emitido por una autoridad de certificación de confianza pública.</string>
<string name="relay_custom_urls_label">URL de relés</string>
<string name="relay_mode_automatic">Automático</string>
<string name="relay_mode_automatic_description">Usa la infraestructura pública de relés predeterminada de VniDrop cuando no haya una conexión directa disponible.</string>
<string name="relay_mode_custom">Personalizado</string>
<string name="relay_mode_custom_description">Usa únicamente los servidores de retransmisión indicados a continuación.</string>
<string name="relay_privacy_description">Los relés reenvían tráfico cifrado y no pueden leer sus archivos, pero su operador puede observar los metadatos de conexión.</string>
<string name="relay_remove_url">Eliminar servidor de retransmisión</string>
<string name="relay_restore_failed">No se han podido restaurar los ajustes de red anteriores. Reinicie VniDrop y revise la configuración de relés.</string>
<string name="relay_settings_applied">Ajustes de red aplicados.</string>
<string name="relay_strict_warning">El modo personalizado es estricto: VniDrop no recurrirá a relés públicos ni al descubrimiento público. Los demás dispositivos deben poder acceder a los relés configurados.</string>
<string name="relay_validation_duplicate_url">La URL de relé de la línea %1$d duplica una entrada anterior.</string>
<string name="relay_validation_https_required">La URL de relé de la línea %1$d debe empezar por https://.</string>
<string name="relay_validation_invalid_url">La URL de relé de la línea %1$d no es válida.</string>
<string name="relay_validation_missing_url">Añada al menos una URL de relé.</string>
<string name="relay_validation_too_many_urls">Puede configurar hasta %1$d servidores de retransmisión.</string>
<string name="send_access_anyone">Cualquiera que tenga esta transferencia</string> <string name="send_access_anyone">Cualquiera que tenga esta transferencia</string>
<string name="send_access_anyone_description">No se requiere aprobación. Úselo solo para elementos que no le importe compartir.</string> <string name="send_access_anyone_description">No se requiere aprobación. Úselo solo para elementos que no le importe compartir.</string>
<string name="send_access_anyone_warning">Cualquiera que tenga la invitación puede descargar hasta que deje de compartir. No lo use para elementos privados o sensibles.</string> <string name="send_access_anyone_warning">Cualquiera que tenga la invitación puede descargar hasta que deje de compartir. No lo use para elementos privados o sensibles.</string>
@@ -192,6 +214,8 @@
<string name="send_transfer_created">Transferencia creada.</string> <string name="send_transfer_created">Transferencia creada.</string>
<string name="send_transfer_details_title">Detalles de la transferencia</string> <string name="send_transfer_details_title">Detalles de la transferencia</string>
<string name="send_transfers_title">Sus transferencias</string> <string name="send_transfers_title">Sus transferencias</string>
<string name="settings_advanced_title">Avanzado</string>
<string name="settings_network_title">Red</string>
<string name="settings_subtitle">Su nombre, dónde se guardan las transferencias, la apariencia y las notificaciones.</string> <string name="settings_subtitle">Su nombre, dónde se guardan las transferencias, la apariencia y las notificaciones.</string>
<string name="settings_title">Ajustes</string> <string name="settings_title">Ajustes</string>
<string name="snackbar_dismiss">Descartar</string> <string name="snackbar_dismiss">Descartar</string>
@@ -250,6 +274,7 @@
<string name="transfer_receivers_description">Solicitudes, aprobaciones y entregas completadas</string> <string name="transfer_receivers_description">Solicitudes, aprobaciones y entregas completadas</string>
<string name="transfer_receivers_pending">%1$d en espera</string> <string name="transfer_receivers_pending">%1$d en espera</string>
<string name="transfer_receivers_title">Destinatarios</string> <string name="transfer_receivers_title">Destinatarios</string>
<string name="transfer_qr_unavailable">El QR no está disponible para esta invitación. Use Compartir o Descargar.</string>
<string name="transfer_scan_qr">Escanee con VniDrop para recibir esta transferencia</string> <string name="transfer_scan_qr">Escanee con VniDrop para recibir esta transferencia</string>
<string name="transfer_share_description">Código QR, archivo de invitación y opciones cercanas</string> <string name="transfer_share_description">Código QR, archivo de invitación y opciones cercanas</string>
<string name="transfer_share_title">Compartir</string> <string name="transfer_share_title">Compartir</string>

View File

@@ -169,6 +169,28 @@
<string name="receive_review_title">Vérifier le transfert</string> <string name="receive_review_title">Vérifier le transfert</string>
<string name="receive_title">Recevoir</string> <string name="receive_title">Recevoir</string>
<string name="receive_unknown_transfer">Transfert VniDrop</string> <string name="receive_unknown_transfer">Transfert VniDrop</string>
<string name="relay_add_url">Ajouter un serveur relais</string>
<string name="relay_apply">Appliquer les réglages réseau</string>
<string name="relay_applying">Application…</string>
<string name="relay_apply_active_transfers">Arrêtez tous les transferts et partages actifs avant dappliquer les réglages réseau.</string>
<string name="relay_apply_failed">Impossible dappliquer ces réglages. Les réglages réseau précédents ont été restaurés.</string>
<string name="relay_apply_restart_description">Lapplication de ces réglages redémarre la connexion réseau de VniDrop. Arrêtez dabord les transferts et partages actifs. Il peut être nécessaire de partager à nouveau les invitations existantes.</string>
<string name="relay_custom_urls_help">Saisissez une URL de relais HTTPS par ligne. Les identifiants dans les URL ne sont pas pris en charge. Le certificat TLS doit être émis par une autorité de certification reconnue publiquement.</string>
<string name="relay_custom_urls_label">URL des relais</string>
<string name="relay_mode_automatic">Automatique</string>
<string name="relay_mode_automatic_description">Utiliser linfrastructure de relais publique par défaut de VniDrop lorsquune connexion directe est indisponible.</string>
<string name="relay_mode_custom">Personnalisé</string>
<string name="relay_mode_custom_description">Utiliser uniquement les serveurs relais ci-dessous.</string>
<string name="relay_privacy_description">Les relais transmettent du trafic chiffré et ne peuvent pas lire vos fichiers, mais leur opérateur peut observer les métadonnées de connexion.</string>
<string name="relay_remove_url">Supprimer le serveur relais</string>
<string name="relay_restore_failed">Impossible de restaurer les réglages réseau précédents. Redémarrez VniDrop et vérifiez votre configuration de relais.</string>
<string name="relay_settings_applied">Réglages réseau appliqués.</string>
<string name="relay_strict_warning">Le mode personnalisé est strict : VniDrop nutilisera ni les relais publics ni la découverte publique comme solution de repli. Les autres appareils doivent pouvoir accéder aux relais configurés.</string>
<string name="relay_validation_duplicate_url">LURL de relais à la ligne %1$d est identique à une entrée précédente.</string>
<string name="relay_validation_https_required">LURL de relais à la ligne %1$d doit commencer par https://.</string>
<string name="relay_validation_invalid_url">LURL de relais à la ligne %1$d nest pas valide.</string>
<string name="relay_validation_missing_url">Ajoutez au moins une URL de relais.</string>
<string name="relay_validation_too_many_urls">Vous pouvez configurer jusquà %1$d serveurs relais.</string>
<string name="send_access_anyone">Toute personne disposant de ce transfert</string> <string name="send_access_anyone">Toute personne disposant de ce transfert</string>
<string name="send_access_anyone_description">Aucune approbation requise. À nutiliser que pour des éléments que vous êtes à laise de partager.</string> <string name="send_access_anyone_description">Aucune approbation requise. À nutiliser que pour des éléments que vous êtes à laise de partager.</string>
<string name="send_access_anyone_warning">Toute personne disposant de linvitation peut télécharger jusquà ce que vous arrêtiez le partage. Ne lutilisez pas pour des éléments privés ou sensibles.</string> <string name="send_access_anyone_warning">Toute personne disposant de linvitation peut télécharger jusquà ce que vous arrêtiez le partage. Ne lutilisez pas pour des éléments privés ou sensibles.</string>
@@ -192,6 +214,8 @@
<string name="send_transfer_created">Transfert créé.</string> <string name="send_transfer_created">Transfert créé.</string>
<string name="send_transfer_details_title">Détails du transfert</string> <string name="send_transfer_details_title">Détails du transfert</string>
<string name="send_transfers_title">Vos transferts</string> <string name="send_transfers_title">Vos transferts</string>
<string name="settings_advanced_title">Avancé</string>
<string name="settings_network_title">Réseau</string>
<string name="settings_subtitle">Votre nom, lemplacement denregistrement des transferts, lapparence et les notifications.</string> <string name="settings_subtitle">Votre nom, lemplacement denregistrement des transferts, lapparence et les notifications.</string>
<string name="settings_title">Réglages</string> <string name="settings_title">Réglages</string>
<string name="snackbar_dismiss">Ignorer</string> <string name="snackbar_dismiss">Ignorer</string>
@@ -250,6 +274,7 @@
<string name="transfer_receivers_description">Demandes, approbations et livraisons terminées</string> <string name="transfer_receivers_description">Demandes, approbations et livraisons terminées</string>
<string name="transfer_receivers_pending">%1$d en attente</string> <string name="transfer_receivers_pending">%1$d en attente</string>
<string name="transfer_receivers_title">Destinataires</string> <string name="transfer_receivers_title">Destinataires</string>
<string name="transfer_qr_unavailable">Le code QR nest pas disponible pour cette invitation. Utilisez plutôt Partager ou Télécharger.</string>
<string name="transfer_scan_qr">Scannez avec VniDrop pour recevoir ce transfert</string> <string name="transfer_scan_qr">Scannez avec VniDrop pour recevoir ce transfert</string>
<string name="transfer_share_description">QR code, fichier dinvitation et options à proximité</string> <string name="transfer_share_description">QR code, fichier dinvitation et options à proximité</string>
<string name="transfer_share_title">Partager</string> <string name="transfer_share_title">Partager</string>

View File

@@ -169,6 +169,28 @@
<string name="receive_review_title">Rivedi trasferimento</string> <string name="receive_review_title">Rivedi trasferimento</string>
<string name="receive_title">Ricevi</string> <string name="receive_title">Ricevi</string>
<string name="receive_unknown_transfer">Trasferimento VniDrop</string> <string name="receive_unknown_transfer">Trasferimento VniDrop</string>
<string name="relay_add_url">Aggiungi server relay</string>
<string name="relay_apply">Applica impostazioni di rete</string>
<string name="relay_applying">Applicazione…</string>
<string name="relay_apply_active_transfers">Interrompa tutti i trasferimenti e le condivisioni attivi prima di applicare le impostazioni di rete.</string>
<string name="relay_apply_failed">Impossibile applicare queste impostazioni. Sono state ripristinate le impostazioni di rete precedenti.</string>
<string name="relay_apply_restart_description">Lapplicazione riavvia la connessione di rete di VniDrop. Interrompa prima i trasferimenti e le condivisioni attivi. Potrebbe essere necessario condividere di nuovo gli inviti esistenti.</string>
<string name="relay_custom_urls_help">Inserisca un URL relay HTTPS per riga. Le credenziali negli URL non sono supportate. Il certificato TLS deve essere emesso da unautorità di certificazione pubblicamente attendibile.</string>
<string name="relay_custom_urls_label">URL relay</string>
<string name="relay_mode_automatic">Automatica</string>
<string name="relay_mode_automatic_description">Usa linfrastruttura relay pubblica predefinita di VniDrop quando non è disponibile una connessione diretta.</string>
<string name="relay_mode_custom">Personalizzata</string>
<string name="relay_mode_custom_description">Usa solo i server relay indicati di seguito.</string>
<string name="relay_privacy_description">I relay inoltrano traffico cifrato e non possono leggere i suoi file, ma il loro operatore può osservare i metadati di connessione.</string>
<string name="relay_remove_url">Rimuovi server relay</string>
<string name="relay_restore_failed">Impossibile ripristinare le impostazioni di rete precedenti. Riavvii VniDrop e verifichi la configurazione dei relay.</string>
<string name="relay_settings_applied">Impostazioni di rete applicate.</string>
<string name="relay_strict_warning">La modalità personalizzata è rigorosa: VniDrop non userà relay pubblici o il rilevamento pubblico come ripiego. Gli altri dispositivi devono poter raggiungere i relay configurati.</string>
<string name="relay_validation_duplicate_url">LURL relay alla riga %1$d duplica una voce precedente.</string>
<string name="relay_validation_https_required">LURL relay alla riga %1$d deve iniziare con https://.</string>
<string name="relay_validation_invalid_url">LURL relay alla riga %1$d non è valido.</string>
<string name="relay_validation_missing_url">Aggiunga almeno un URL relay.</string>
<string name="relay_validation_too_many_urls">Può configurare fino a %1$d server relay.</string>
<string name="send_access_anyone">Chiunque abbia questo trasferimento</string> <string name="send_access_anyone">Chiunque abbia questo trasferimento</string>
<string name="send_access_anyone_description">Nessuna approvazione richiesta. Da usare solo per elementi che non ha problemi a condividere.</string> <string name="send_access_anyone_description">Nessuna approvazione richiesta. Da usare solo per elementi che non ha problemi a condividere.</string>
<string name="send_access_anyone_warning">Chiunque abbia linvito può scaricare finché non interrompe la condivisione. Non lo usi per elementi privati o sensibili.</string> <string name="send_access_anyone_warning">Chiunque abbia linvito può scaricare finché non interrompe la condivisione. Non lo usi per elementi privati o sensibili.</string>
@@ -192,6 +214,8 @@
<string name="send_transfer_created">Trasferimento creato.</string> <string name="send_transfer_created">Trasferimento creato.</string>
<string name="send_transfer_details_title">Dettagli del trasferimento</string> <string name="send_transfer_details_title">Dettagli del trasferimento</string>
<string name="send_transfers_title">I suoi trasferimenti</string> <string name="send_transfers_title">I suoi trasferimenti</string>
<string name="settings_advanced_title">Avanzate</string>
<string name="settings_network_title">Rete</string>
<string name="settings_subtitle">Il suo nome, dove vengono salvati i trasferimenti, laspetto e le notifiche.</string> <string name="settings_subtitle">Il suo nome, dove vengono salvati i trasferimenti, laspetto e le notifiche.</string>
<string name="settings_title">Impostazioni</string> <string name="settings_title">Impostazioni</string>
<string name="snackbar_dismiss">Ignora</string> <string name="snackbar_dismiss">Ignora</string>
@@ -250,6 +274,7 @@
<string name="transfer_receivers_description">Richieste, approvazioni e consegne completate</string> <string name="transfer_receivers_description">Richieste, approvazioni e consegne completate</string>
<string name="transfer_receivers_pending">%1$d in attesa</string> <string name="transfer_receivers_pending">%1$d in attesa</string>
<string name="transfer_receivers_title">Destinatari</string> <string name="transfer_receivers_title">Destinatari</string>
<string name="transfer_qr_unavailable">Il codice QR non è disponibile per questo invito. Utilizzi invece Condividi o Scarica.</string>
<string name="transfer_scan_qr">Scansioni con VniDrop per ricevere questo trasferimento</string> <string name="transfer_scan_qr">Scansioni con VniDrop per ricevere questo trasferimento</string>
<string name="transfer_share_description">Codice QR, file di invito e opzioni nelle vicinanze</string> <string name="transfer_share_description">Codice QR, file di invito e opzioni nelle vicinanze</string>
<string name="transfer_share_title">Condividi</string> <string name="transfer_share_title">Condividi</string>

View File

@@ -169,6 +169,28 @@
<string name="receive_review_title">Overdracht controleren</string> <string name="receive_review_title">Overdracht controleren</string>
<string name="receive_title">Ontvangen</string> <string name="receive_title">Ontvangen</string>
<string name="receive_unknown_transfer">VniDrop-overdracht</string> <string name="receive_unknown_transfer">VniDrop-overdracht</string>
<string name="relay_add_url">Relayserver toevoegen</string>
<string name="relay_apply">Netwerkinstellingen toepassen</string>
<string name="relay_applying">Toepassen…</string>
<string name="relay_apply_active_transfers">Stop alle actieve overdrachten en gedeelde items voordat u de netwerkinstellingen toepast.</string>
<string name="relay_apply_failed">Deze instellingen konden niet worden toegepast. De vorige netwerkinstellingen zijn hersteld.</string>
<string name="relay_apply_restart_description">Bij het toepassen wordt de netwerkverbinding van VniDrop opnieuw gestart. Stop eerst actieve overdrachten en gedeelde items. Bestaande uitnodigingen moeten mogelijk opnieuw worden gedeeld.</string>
<string name="relay_custom_urls_help">Voer per regel één HTTPS-relay-URL in. Aanmeldgegevens in URL\'s worden niet ondersteund. Het TLS-certificaat moet zijn uitgegeven door een openbaar vertrouwde certificeringsinstantie.</string>
<string name="relay_custom_urls_label">Relay-URL\'s</string>
<string name="relay_mode_automatic">Automatisch</string>
<string name="relay_mode_automatic_description">Gebruikt de standaard openbare relay-infrastructuur van VniDrop wanneer geen directe verbinding beschikbaar is.</string>
<string name="relay_mode_custom">Aangepast</string>
<string name="relay_mode_custom_description">Gebruikt alleen de onderstaande relayservers.</string>
<string name="relay_privacy_description">Relays sturen versleuteld verkeer door en kunnen uw bestanden niet lezen, maar de beheerder kan verbindingsmetadata bekijken.</string>
<string name="relay_remove_url">Relayserver verwijderen</string>
<string name="relay_restore_failed">De vorige netwerkinstellingen konden niet worden hersteld. Start VniDrop opnieuw en controleer uw relayconfiguratie.</string>
<string name="relay_settings_applied">Netwerkinstellingen toegepast.</string>
<string name="relay_strict_warning">De aangepaste modus is strikt: VniDrop valt niet terug op openbare relays of openbare detectie. Andere apparaten moeten uw ingestelde relays kunnen bereiken.</string>
<string name="relay_validation_duplicate_url">De relay-URL op regel %1$d is gelijk aan een eerdere invoer.</string>
<string name="relay_validation_https_required">De relay-URL op regel %1$d moet beginnen met https://.</string>
<string name="relay_validation_invalid_url">De relay-URL op regel %1$d is ongeldig.</string>
<string name="relay_validation_missing_url">Voeg ten minste één relay-URL toe.</string>
<string name="relay_validation_too_many_urls">U kunt maximaal %1$d relayservers instellen.</string>
<string name="send_access_anyone">Iedereen met deze overdracht</string> <string name="send_access_anyone">Iedereen met deze overdracht</string>
<string name="send_access_anyone_description">Geen goedkeuring vereist. Gebruik dit alleen voor items die u gerust kunt delen.</string> <string name="send_access_anyone_description">Geen goedkeuring vereist. Gebruik dit alleen voor items die u gerust kunt delen.</string>
<string name="send_access_anyone_warning">Iedereen met de uitnodiging kan downloaden totdat u stopt met delen. Gebruik dit niet voor privé- of gevoelige items.</string> <string name="send_access_anyone_warning">Iedereen met de uitnodiging kan downloaden totdat u stopt met delen. Gebruik dit niet voor privé- of gevoelige items.</string>
@@ -192,6 +214,8 @@
<string name="send_transfer_created">Overdracht aangemaakt.</string> <string name="send_transfer_created">Overdracht aangemaakt.</string>
<string name="send_transfer_details_title">Overdrachtsdetails</string> <string name="send_transfer_details_title">Overdrachtsdetails</string>
<string name="send_transfers_title">Uw overdrachten</string> <string name="send_transfers_title">Uw overdrachten</string>
<string name="settings_advanced_title">Geavanceerd</string>
<string name="settings_network_title">Netwerk</string>
<string name="settings_subtitle">Uw naam, waar overdrachten worden bewaard, weergave en meldingen.</string> <string name="settings_subtitle">Uw naam, waar overdrachten worden bewaard, weergave en meldingen.</string>
<string name="settings_title">Instellingen</string> <string name="settings_title">Instellingen</string>
<string name="snackbar_dismiss">Sluiten</string> <string name="snackbar_dismiss">Sluiten</string>
@@ -250,6 +274,7 @@
<string name="transfer_receivers_description">Verzoeken, goedkeuringen en voltooide leveringen</string> <string name="transfer_receivers_description">Verzoeken, goedkeuringen en voltooide leveringen</string>
<string name="transfer_receivers_pending">%1$d in behandeling</string> <string name="transfer_receivers_pending">%1$d in behandeling</string>
<string name="transfer_receivers_title">Ontvangers</string> <string name="transfer_receivers_title">Ontvangers</string>
<string name="transfer_qr_unavailable">QR is niet beschikbaar voor deze uitnodiging. Gebruik in plaats daarvan Delen of Downloaden.</string>
<string name="transfer_scan_qr">Scan met VniDrop om deze overdracht te ontvangen</string> <string name="transfer_scan_qr">Scan met VniDrop om deze overdracht te ontvangen</string>
<string name="transfer_share_description">QR-code, uitnodigingsbestand en opties in de buurt</string> <string name="transfer_share_description">QR-code, uitnodigingsbestand en opties in de buurt</string>
<string name="transfer_share_title">Delen</string> <string name="transfer_share_title">Delen</string>

View File

@@ -169,6 +169,28 @@
<string name="receive_review_title">Przejrzyj transfer</string> <string name="receive_review_title">Przejrzyj transfer</string>
<string name="receive_title">Odbierz</string> <string name="receive_title">Odbierz</string>
<string name="receive_unknown_transfer">Transfer VniDrop</string> <string name="receive_unknown_transfer">Transfer VniDrop</string>
<string name="relay_add_url">Dodaj serwer przekaźnikowy</string>
<string name="relay_apply">Zastosuj ustawienia sieci</string>
<string name="relay_applying">Stosowanie…</string>
<string name="relay_apply_active_transfers">Zatrzymaj wszystkie aktywne transfery i udostępnienia przed zastosowaniem ustawień sieci.</string>
<string name="relay_apply_failed">Nie udało się zastosować tych ustawień. Przywrócono poprzednie ustawienia sieci.</string>
<string name="relay_apply_restart_description">Zastosowanie ustawień ponownie uruchamia połączenie sieciowe VniDrop. Najpierw zatrzymaj aktywne transfery i udostępnienia. Istniejące zaproszenia mogą wymagać ponownego udostępnienia.</string>
<string name="relay_custom_urls_help">Wprowadź po jednym adresie URL HTTPS przekaźnika w każdym wierszu. Dane logowania w adresach URL nie są obsługiwane. Certyfikat TLS musi być wystawiony przez publicznie zaufany urząd certyfikacji.</string>
<string name="relay_custom_urls_label">Adresy URL przekaźników</string>
<string name="relay_mode_automatic">Automatyczny</string>
<string name="relay_mode_automatic_description">Używa domyślnej publicznej infrastruktury przekaźników VniDrop, gdy połączenie bezpośrednie jest niedostępne.</string>
<string name="relay_mode_custom">Niestandardowy</string>
<string name="relay_mode_custom_description">Używa wyłącznie poniższych serwerów przekaźnikowych.</string>
<string name="relay_privacy_description">Przekaźniki przesyłają zaszyfrowany ruch i nie mogą odczytać plików, ale ich operator może obserwować metadane połączenia.</string>
<string name="relay_remove_url">Usuń serwer przekaźnikowy</string>
<string name="relay_restore_failed">Nie udało się przywrócić poprzednich ustawień sieci. Uruchom ponownie VniDrop i sprawdź konfigurację przekaźników.</string>
<string name="relay_settings_applied">Zastosowano ustawienia sieci.</string>
<string name="relay_strict_warning">Tryb niestandardowy jest rygorystyczny: VniDrop nie użyje awaryjnie publicznych przekaźników ani publicznego wykrywania. Inne urządzenia muszą mieć dostęp do skonfigurowanych przekaźników.</string>
<string name="relay_validation_duplicate_url">Adres URL przekaźnika w wierszu %1$d powtarza wcześniejszy wpis.</string>
<string name="relay_validation_https_required">Adres URL przekaźnika w wierszu %1$d musi zaczynać się od https://.</string>
<string name="relay_validation_invalid_url">Adres URL przekaźnika w wierszu %1$d jest nieprawidłowy.</string>
<string name="relay_validation_missing_url">Dodaj co najmniej jeden adres URL przekaźnika.</string>
<string name="relay_validation_too_many_urls">Możesz skonfigurować maksymalnie %1$d serwerów przekaźnikowych.</string>
<string name="send_access_anyone">Każdy, kto ma ten transfer</string> <string name="send_access_anyone">Każdy, kto ma ten transfer</string>
<string name="send_access_anyone_description">Nie jest wymagane zatwierdzenie. Używaj tylko dla elementów, które możesz swobodnie udostępniać.</string> <string name="send_access_anyone_description">Nie jest wymagane zatwierdzenie. Używaj tylko dla elementów, które możesz swobodnie udostępniać.</string>
<string name="send_access_anyone_warning">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.</string> <string name="send_access_anyone_warning">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.</string>
@@ -192,6 +214,8 @@
<string name="send_transfer_created">Transfer utworzony.</string> <string name="send_transfer_created">Transfer utworzony.</string>
<string name="send_transfer_details_title">Szczegóły transferu</string> <string name="send_transfer_details_title">Szczegóły transferu</string>
<string name="send_transfers_title">Twoje transfery</string> <string name="send_transfers_title">Twoje transfery</string>
<string name="settings_advanced_title">Zaawansowane</string>
<string name="settings_network_title">Sieć</string>
<string name="settings_subtitle">Twoja nazwa, miejsce zapisu transferów, wygląd i powiadomienia.</string> <string name="settings_subtitle">Twoja nazwa, miejsce zapisu transferów, wygląd i powiadomienia.</string>
<string name="settings_title">Ustawienia</string> <string name="settings_title">Ustawienia</string>
<string name="snackbar_dismiss">Zamknij</string> <string name="snackbar_dismiss">Zamknij</string>
@@ -250,6 +274,7 @@
<string name="transfer_receivers_description">Prośby, zatwierdzenia i ukończone dostawy</string> <string name="transfer_receivers_description">Prośby, zatwierdzenia i ukończone dostawy</string>
<string name="transfer_receivers_pending">Oczekujące: %1$d</string> <string name="transfer_receivers_pending">Oczekujące: %1$d</string>
<string name="transfer_receivers_title">Odbiorcy</string> <string name="transfer_receivers_title">Odbiorcy</string>
<string name="transfer_qr_unavailable">Kod QR jest niedostępny dla tego zaproszenia. Zamiast tego użyj opcji Udostępnij lub Pobierz.</string>
<string name="transfer_scan_qr">Zeskanuj za pomocą VniDrop, aby odebrać ten transfer</string> <string name="transfer_scan_qr">Zeskanuj za pomocą VniDrop, aby odebrać ten transfer</string>
<string name="transfer_share_description">Kod QR, plik zaproszenia i opcje w pobliżu</string> <string name="transfer_share_description">Kod QR, plik zaproszenia i opcje w pobliżu</string>
<string name="transfer_share_title">Udostępnij</string> <string name="transfer_share_title">Udostępnij</string>

View File

@@ -169,6 +169,28 @@
<string name="receive_review_title">Rever transferência</string> <string name="receive_review_title">Rever transferência</string>
<string name="receive_title">Receber</string> <string name="receive_title">Receber</string>
<string name="receive_unknown_transfer">Transferência VniDrop</string> <string name="receive_unknown_transfer">Transferência VniDrop</string>
<string name="relay_add_url">Adicionar servidor de retransmissão</string>
<string name="relay_apply">Aplicar definições de rede</string>
<string name="relay_applying">A aplicar…</string>
<string name="relay_apply_active_transfers">Pare todas as transferências e partilhas ativas antes de aplicar as definições de rede.</string>
<string name="relay_apply_failed">Não foi possível aplicar estas definições. As definições de rede anteriores foram restauradas.</string>
<string name="relay_apply_restart_description">A aplicação reinicia a ligação de rede do VniDrop. Pare primeiro as transferências e partilhas ativas. Poderá ser necessário voltar a partilhar os convites existentes.</string>
<string name="relay_custom_urls_help">Introduza um URL HTTPS de retransmissor por linha. Não são suportadas credenciais nos URLs. O certificado TLS tem de ser emitido por uma autoridade de certificação publicamente reconhecida.</string>
<string name="relay_custom_urls_label">URLs dos retransmissores</string>
<string name="relay_mode_automatic">Automático</string>
<string name="relay_mode_automatic_description">Utiliza a infraestrutura pública de retransmissores predefinida do VniDrop quando não está disponível uma ligação direta.</string>
<string name="relay_mode_custom">Personalizado</string>
<string name="relay_mode_custom_description">Utiliza apenas os servidores de retransmissão abaixo.</string>
<string name="relay_privacy_description">Os retransmissores encaminham tráfego cifrado e não conseguem ler os seus ficheiros, mas o operador pode observar metadados da ligação.</string>
<string name="relay_remove_url">Remover servidor de retransmissão</string>
<string name="relay_restore_failed">Não foi possível restaurar as definições de rede anteriores. Reinicie o VniDrop e reveja a configuração dos retransmissores.</string>
<string name="relay_settings_applied">Definições de rede aplicadas.</string>
<string name="relay_strict_warning">O modo personalizado é estrito: o VniDrop não recorrerá a retransmissores públicos nem à descoberta pública. Os outros dispositivos têm de conseguir aceder aos retransmissores configurados.</string>
<string name="relay_validation_duplicate_url">O URL do retransmissor na linha %1$d duplica uma entrada anterior.</string>
<string name="relay_validation_https_required">O URL do retransmissor na linha %1$d tem de começar por https://.</string>
<string name="relay_validation_invalid_url">O URL do retransmissor na linha %1$d não é válido.</string>
<string name="relay_validation_missing_url">Adicione pelo menos um URL de retransmissor.</string>
<string name="relay_validation_too_many_urls">Pode configurar até %1$d servidores de retransmissão.</string>
<string name="send_access_anyone">Qualquer pessoa com esta transferência</string> <string name="send_access_anyone">Qualquer pessoa com esta transferência</string>
<string name="send_access_anyone_description">Não é necessária aprovação. Utilize apenas para itens que não se importe de partilhar.</string> <string name="send_access_anyone_description">Não é necessária aprovação. Utilize apenas para itens que não se importe de partilhar.</string>
<string name="send_access_anyone_warning">Qualquer pessoa com o convite pode descarregar até parar de partilhar. Não utilize para itens privados ou sensíveis.</string> <string name="send_access_anyone_warning">Qualquer pessoa com o convite pode descarregar até parar de partilhar. Não utilize para itens privados ou sensíveis.</string>
@@ -192,6 +214,8 @@
<string name="send_transfer_created">Transferência criada.</string> <string name="send_transfer_created">Transferência criada.</string>
<string name="send_transfer_details_title">Detalhes da transferência</string> <string name="send_transfer_details_title">Detalhes da transferência</string>
<string name="send_transfers_title">As suas transferências</string> <string name="send_transfers_title">As suas transferências</string>
<string name="settings_advanced_title">Avançado</string>
<string name="settings_network_title">Rede</string>
<string name="settings_subtitle">O seu nome, onde as transferências são guardadas, o aspeto e as notificações.</string> <string name="settings_subtitle">O seu nome, onde as transferências são guardadas, o aspeto e as notificações.</string>
<string name="settings_title">Definições</string> <string name="settings_title">Definições</string>
<string name="snackbar_dismiss">Ignorar</string> <string name="snackbar_dismiss">Ignorar</string>
@@ -250,6 +274,7 @@
<string name="transfer_receivers_description">Pedidos, aprovações e entregas concluídas</string> <string name="transfer_receivers_description">Pedidos, aprovações e entregas concluídas</string>
<string name="transfer_receivers_pending">%1$d em espera</string> <string name="transfer_receivers_pending">%1$d em espera</string>
<string name="transfer_receivers_title">Destinatários</string> <string name="transfer_receivers_title">Destinatários</string>
<string name="transfer_qr_unavailable">O código QR não está disponível para este convite. Utilize Partilhar ou Transferir.</string>
<string name="transfer_scan_qr">Leia com o VniDrop para receber esta transferência</string> <string name="transfer_scan_qr">Leia com o VniDrop para receber esta transferência</string>
<string name="transfer_share_description">Código QR, ficheiro de convite e opções por perto</string> <string name="transfer_share_description">Código QR, ficheiro de convite e opções por perto</string>
<string name="transfer_share_title">Partilhar</string> <string name="transfer_share_title">Partilhar</string>

View File

@@ -169,6 +169,28 @@
<string name="receive_review_title">Проверить передачу</string> <string name="receive_review_title">Проверить передачу</string>
<string name="receive_title">Получить</string> <string name="receive_title">Получить</string>
<string name="receive_unknown_transfer">Передача VniDrop</string> <string name="receive_unknown_transfer">Передача VniDrop</string>
<string name="relay_add_url">Добавить сервер-ретранслятор</string>
<string name="relay_apply">Применить настройки сети</string>
<string name="relay_applying">Применение…</string>
<string name="relay_apply_active_transfers">Остановите все активные передачи и раздачи перед применением настроек сети.</string>
<string name="relay_apply_failed">Не удалось применить эти настройки. Предыдущие настройки сети восстановлены.</string>
<string name="relay_apply_restart_description">При применении сетевое соединение VniDrop перезапускается. Сначала остановите активные передачи и раздачи. Возможно, существующие приглашения потребуется отправить повторно.</string>
<string name="relay_custom_urls_help">Введите по одному HTTPS-адресу ретранслятора в строке. Учётные данные в URL-адресах не поддерживаются. Сертификат TLS должен быть выдан общедоступным доверенным центром сертификации.</string>
<string name="relay_custom_urls_label">URL-адреса ретрансляторов</string>
<string name="relay_mode_automatic">Автоматически</string>
<string name="relay_mode_automatic_description">Использовать стандартную публичную инфраструктуру ретрансляторов VniDrop, если прямое соединение недоступно.</string>
<string name="relay_mode_custom">Пользовательский</string>
<string name="relay_mode_custom_description">Использовать только указанные ниже серверы-ретрансляторы.</string>
<string name="relay_privacy_description">Ретрансляторы передают зашифрованный трафик и не могут читать ваши файлы, но их оператор может видеть метаданные соединения.</string>
<string name="relay_remove_url">Удалить сервер-ретранслятор</string>
<string name="relay_restore_failed">Не удалось восстановить предыдущие настройки сети. Перезапустите VniDrop и проверьте конфигурацию ретрансляторов.</string>
<string name="relay_settings_applied">Настройки сети применены.</string>
<string name="relay_strict_warning">Пользовательский режим работает строго: VniDrop не будет переключаться на публичные ретрансляторы или публичное обнаружение. Другие устройства должны иметь доступ к настроенным ретрансляторам.</string>
<string name="relay_validation_duplicate_url">URL-адрес ретранслятора в строке %1$d повторяет предыдущую запись.</string>
<string name="relay_validation_https_required">URL-адрес ретранслятора в строке %1$d должен начинаться с https://.</string>
<string name="relay_validation_invalid_url">URL-адрес ретранслятора в строке %1$d недействителен.</string>
<string name="relay_validation_missing_url">Добавьте хотя бы один URL-адрес ретранслятора.</string>
<string name="relay_validation_too_many_urls">Можно настроить до %1$d серверов-ретрансляторов.</string>
<string name="send_access_anyone">Любой, у кого есть эта передача</string> <string name="send_access_anyone">Любой, у кого есть эта передача</string>
<string name="send_access_anyone_description">Одобрение не требуется. Используйте только для объектов, которыми вы готовы поделиться.</string> <string name="send_access_anyone_description">Одобрение не требуется. Используйте только для объектов, которыми вы готовы поделиться.</string>
<string name="send_access_anyone_warning">Любой, у кого есть приглашение, может загружать, пока вы не остановите общий доступ. Не используйте это для личных или конфиденциальных объектов.</string> <string name="send_access_anyone_warning">Любой, у кого есть приглашение, может загружать, пока вы не остановите общий доступ. Не используйте это для личных или конфиденциальных объектов.</string>
@@ -192,6 +214,8 @@
<string name="send_transfer_created">Передача создана.</string> <string name="send_transfer_created">Передача создана.</string>
<string name="send_transfer_details_title">Сведения о передаче</string> <string name="send_transfer_details_title">Сведения о передаче</string>
<string name="send_transfers_title">Ваши передачи</string> <string name="send_transfers_title">Ваши передачи</string>
<string name="settings_advanced_title">Дополнительно</string>
<string name="settings_network_title">Сеть</string>
<string name="settings_subtitle">Ваше имя, место сохранения передач, оформление и уведомления.</string> <string name="settings_subtitle">Ваше имя, место сохранения передач, оформление и уведомления.</string>
<string name="settings_title">Настройки</string> <string name="settings_title">Настройки</string>
<string name="snackbar_dismiss">Закрыть</string> <string name="snackbar_dismiss">Закрыть</string>
@@ -250,6 +274,7 @@
<string name="transfer_receivers_description">Запросы, одобрения и завершённые доставки</string> <string name="transfer_receivers_description">Запросы, одобрения и завершённые доставки</string>
<string name="transfer_receivers_pending">Ожидают: %1$d</string> <string name="transfer_receivers_pending">Ожидают: %1$d</string>
<string name="transfer_receivers_title">Получатели</string> <string name="transfer_receivers_title">Получатели</string>
<string name="transfer_qr_unavailable">QR-код недоступен для этого приглашения. Используйте «Поделиться» или «Скачать».</string>
<string name="transfer_scan_qr">Отсканируйте с помощью VniDrop, чтобы получить эту передачу</string> <string name="transfer_scan_qr">Отсканируйте с помощью VniDrop, чтобы получить эту передачу</string>
<string name="transfer_share_description">QR-код, файл приглашения и варианты поблизости</string> <string name="transfer_share_description">QR-код, файл приглашения и варианты поблизости</string>
<string name="transfer_share_title">Поделиться</string> <string name="transfer_share_title">Поделиться</string>

View File

@@ -169,6 +169,28 @@
<string name="receive_review_title">Review transfer</string> <string name="receive_review_title">Review transfer</string>
<string name="receive_title">Receive</string> <string name="receive_title">Receive</string>
<string name="receive_unknown_transfer">VniDrop transfer</string> <string name="receive_unknown_transfer">VniDrop transfer</string>
<string name="relay_add_url">Add relay server</string>
<string name="relay_apply">Apply network settings</string>
<string name="relay_applying">Applying…</string>
<string name="relay_apply_active_transfers">Stop all active transfers and shares before applying network settings.</string>
<string name="relay_apply_failed">Could not apply these settings. The previous network settings were restored.</string>
<string name="relay_apply_restart_description">Applying restarts VniDrops network connection. Stop active transfers and shares first. Existing invitations may need to be shared again.</string>
<string name="relay_custom_urls_help">Enter one HTTPS relay URL per line. URL credentials are not supported. The TLS certificate must be issued by a publicly trusted certificate authority.</string>
<string name="relay_custom_urls_label">Relay URLs</string>
<string name="relay_mode_automatic">Automatic</string>
<string name="relay_mode_automatic_description">Use VniDrops default public relay infrastructure when a direct connection is unavailable.</string>
<string name="relay_mode_custom">Custom</string>
<string name="relay_mode_custom_description">Use only the relay servers below.</string>
<string name="relay_privacy_description">Relays forward encrypted traffic and cannot read your files, but their operator can observe connection metadata.</string>
<string name="relay_remove_url">Remove relay server</string>
<string name="relay_restore_failed">Could not restore the previous network settings. Restart VniDrop and review your relay configuration.</string>
<string name="relay_settings_applied">Network settings applied.</string>
<string name="relay_strict_warning">Custom mode is strict: VniDrop will not fall back to public relays or public discovery. Other devices must be able to reach your configured relays.</string>
<string name="relay_validation_duplicate_url">Relay URL on line %1$d duplicates an earlier entry.</string>
<string name="relay_validation_https_required">Relay URL on line %1$d must start with https://.</string>
<string name="relay_validation_invalid_url">Relay URL on line %1$d is not valid.</string>
<string name="relay_validation_missing_url">Add at least one relay URL.</string>
<string name="relay_validation_too_many_urls">You can configure up to %1$d relay servers.</string>
<string name="send_access_anyone">Anyone with this transfer</string> <string name="send_access_anyone">Anyone with this transfer</string>
<string name="send_access_anyone_description">No approval is required. Only use this for items you are comfortable sharing.</string> <string name="send_access_anyone_description">No approval is required. Only use this for items you are comfortable sharing.</string>
<string name="send_access_anyone_warning">Anyone with the invitation can download until you stop sharing. Do not use this for private or sensitive items.</string> <string name="send_access_anyone_warning">Anyone with the invitation can download until you stop sharing. Do not use this for private or sensitive items.</string>
@@ -192,6 +214,8 @@
<string name="send_transfer_created">Transfer created.</string> <string name="send_transfer_created">Transfer created.</string>
<string name="send_transfer_details_title">Transfer details</string> <string name="send_transfer_details_title">Transfer details</string>
<string name="send_transfers_title">Your transfers</string> <string name="send_transfers_title">Your transfers</string>
<string name="settings_advanced_title">Advanced</string>
<string name="settings_network_title">Network</string>
<string name="settings_subtitle">Your name, where transfers are saved, appearance, and notifications.</string> <string name="settings_subtitle">Your name, where transfers are saved, appearance, and notifications.</string>
<string name="settings_title">Settings</string> <string name="settings_title">Settings</string>
<string name="snackbar_dismiss">Dismiss</string> <string name="snackbar_dismiss">Dismiss</string>
@@ -250,6 +274,7 @@
<string name="transfer_receivers_description">Requests, approvals, and completed deliveries</string> <string name="transfer_receivers_description">Requests, approvals, and completed deliveries</string>
<string name="transfer_receivers_pending">%1$d waiting</string> <string name="transfer_receivers_pending">%1$d waiting</string>
<string name="transfer_receivers_title">Receivers</string> <string name="transfer_receivers_title">Receivers</string>
<string name="transfer_qr_unavailable">QR unavailable for this invitation. Use Share or Download instead.</string>
<string name="transfer_scan_qr">Scan with VniDrop to receive this transfer</string> <string name="transfer_scan_qr">Scan with VniDrop to receive this transfer</string>
<string name="transfer_share_description">QR code, invitation file, and nearby options</string> <string name="transfer_share_description">QR code, invitation file, and nearby options</string>
<string name="transfer_share_title">Share</string> <string name="transfer_share_title">Share</string>

View File

@@ -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 <C, T> 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 <T> 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 }
}
}
}
}

View File

@@ -27,6 +27,16 @@ enum class ShareAccessPolicy {
AnyoneWithTransfer, AnyoneWithTransfer,
} }
enum class RelayMode {
Automatic,
Custom,
}
data class RelaySettings(
val mode: RelayMode = RelayMode.Automatic,
val relayUrls: List<String> = emptyList(),
)
enum class TransferDirection { enum class TransferDirection {
Send, Send,
Receive, Receive,
@@ -142,7 +152,10 @@ interface CoreGateway {
val state: StateFlow<CoreState> val state: StateFlow<CoreState>
val signals: SharedFlow<CoreSignal> val signals: SharedFlow<CoreSignal>
suspend fun initialize(appDataDir: String): Result<Unit> suspend fun initialize(
appDataDir: String,
relaySettings: RelaySettings = RelaySettings(),
): Result<Unit>
fun shutdown() fun shutdown()
suspend fun sharePath(path: String, transferName: String, senderName: String, accessPolicy: ShareAccessPolicy): Result<Share> suspend fun sharePath(path: String, transferName: String, senderName: String, accessPolicy: ShareAccessPolicy): Result<Share>
suspend fun shareFileDescriptor( suspend fun shareFileDescriptor(

View File

@@ -16,6 +16,8 @@ import kotlinx.coroutines.withContext
import kotlin.random.Random import kotlin.random.Random
import uniffi.vnidrop.CoreEvent import uniffi.vnidrop.CoreEvent
import uniffi.vnidrop.CoreEventSink import uniffi.vnidrop.CoreEventSink
import uniffi.vnidrop.CoreNetworkConfig
import uniffi.vnidrop.CoreRelayMode
import uniffi.vnidrop.ReceiveOutputSink import uniffi.vnidrop.ReceiveOutputSink
import uniffi.vnidrop.ReceiveOutputSinkV2 import uniffi.vnidrop.ReceiveOutputSinkV2
import uniffi.vnidrop.ReceiverRequest import uniffi.vnidrop.ReceiverRequest
@@ -28,6 +30,7 @@ import uniffi.vnidrop.TicketInspection
import uniffi.vnidrop.TransferMetadata import uniffi.vnidrop.TransferMetadata
import uniffi.vnidrop.TransferAccessMode import uniffi.vnidrop.TransferAccessMode
import uniffi.vnidrop.VnidropCore import uniffi.vnidrop.VnidropCore
import uniffi.vnidrop.defaultCoreNetworkConfig
class CoreRepository( class CoreRepository(
private val dispatcher: CoroutineDispatcher = Dispatchers.IO, private val dispatcher: CoroutineDispatcher = Dispatchers.IO,
@@ -42,6 +45,7 @@ class CoreRepository(
override val signals: SharedFlow<CoreSignal> = _signals.asSharedFlow() override val signals: SharedFlow<CoreSignal> = _signals.asSharedFlow()
private var core: VnidropCore? = null private var core: VnidropCore? = null
private val lifecycleGate = CoreLifecycleGate()
private val sink = object : CoreEventSink { private val sink = object : CoreEventSink {
override fun onEvent(event: CoreEvent) { override fun onEvent(event: CoreEvent) {
@@ -60,12 +64,22 @@ class CoreRepository(
} }
} }
override suspend fun initialize(appDataDir: String): Result<Unit> = runCore { override suspend fun initialize(appDataDir: String, relaySettings: RelaySettings): Result<Unit> =
core?.shutdown() runReconfiguration {
core = VnidropCore.initialize(appDataDir, sink) val previousCore = core
refreshSnapshot() if (previousCore != null) {
_state.update { it.copy(isInitialized = true) } 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() { override fun shutdown() {
core?.shutdown() core?.shutdown()
@@ -114,37 +128,37 @@ class CoreRepository(
accessPolicy = accessPolicy, accessPolicy = accessPolicy,
) )
override suspend fun inspectTicket(ticket: String): Result<TicketInspectionModel> = runCore { override suspend fun inspectTicket(ticket: String): Result<TicketInspectionModel> = runCore { activeCore ->
requireCore().inspectTicket(ticket).toModel().also { inspection -> activeCore.inspectTicket(ticket).toModel().also { inspection ->
_state.update { it.copy(lastInspection = inspection) } _state.update { it.copy(lastInspection = inspection) }
} }
} }
override suspend fun receive(ticket: String, outputDir: String, receiverName: String): Result<Unit> = runCore { override suspend fun receive(ticket: String, outputDir: String, receiverName: String): Result<Unit> = runCore { activeCore ->
requireCore().receive(ticket, outputDir, receiverName.ifBlank { null }) activeCore.receive(ticket, outputDir, receiverName.ifBlank { null })
refreshSnapshot() refreshSnapshot(activeCore)
} }
override suspend fun receiveWithOutputSink( override suspend fun receiveWithOutputSink(
ticket: String, ticket: String,
outputSink: ReceiveOutputSink, outputSink: ReceiveOutputSink,
receiverName: String, receiverName: String,
): Result<Unit> = runCore { ): Result<Unit> = runCore { activeCore ->
requireCore().receiveWithOutputSink(ticket, outputSink, receiverName.ifBlank { null }) activeCore.receiveWithOutputSink(ticket, outputSink, receiverName.ifBlank { null })
refreshSnapshot() refreshSnapshot(activeCore)
} }
override suspend fun receiveWithOutputSinkV2( override suspend fun receiveWithOutputSinkV2(
ticket: String, ticket: String,
outputSink: ReceiveOutputSinkV2, outputSink: ReceiveOutputSinkV2,
receiverName: String, receiverName: String,
): Result<Unit> = runCore { ): Result<Unit> = runCore { activeCore ->
requireCore().receiveWithOutputSinkV2(ticket, outputSink, receiverName.ifBlank { null }) activeCore.receiveWithOutputSinkV2(ticket, outputSink, receiverName.ifBlank { null })
refreshSnapshot() refreshSnapshot(activeCore)
} }
override suspend fun storageUsage(): Result<CoreStorageUsageModel> = runCore { override suspend fun storageUsage(): Result<CoreStorageUsageModel> = runCore { activeCore ->
val usage = requireCore().storageUsage() val usage = activeCore.storageUsage()
CoreStorageUsageModel( CoreStorageUsageModel(
blobStoreBytes = usage.blobStoreBytes, blobStoreBytes = usage.blobStoreBytes,
databaseBytes = usage.databaseBytes, databaseBytes = usage.databaseBytes,
@@ -154,8 +168,8 @@ class CoreRepository(
) )
} }
override suspend fun receivedArtifacts(): Result<List<ReceivedArtifactModel>> = runCore { override suspend fun receivedArtifacts(): Result<List<ReceivedArtifactModel>> = runCore { activeCore ->
requireCore().listReceivedArtifacts().map { artifact -> activeCore.listReceivedArtifacts().map { artifact ->
ReceivedArtifactModel( ReceivedArtifactModel(
id = artifact.id, id = artifact.id,
locator = artifact.locator, locator = artifact.locator,
@@ -165,47 +179,47 @@ class CoreRepository(
} }
} }
override suspend fun cancel(transferId: ULong): Result<Unit> = runCore { override suspend fun cancel(transferId: ULong): Result<Unit> = runCore { activeCore ->
requireCore().cancelTransfer(transferId) activeCore.cancelTransfer(transferId)
refreshSnapshot() refreshSnapshot(activeCore)
} }
override suspend fun delete(transferId: ULong): Result<Unit> = runCore { override suspend fun delete(transferId: ULong): Result<Unit> = runCore { activeCore ->
requireCore().deleteTransfer(transferId) activeCore.deleteTransfer(transferId)
refreshSnapshot() refreshSnapshot(activeCore)
_signals.tryEmit(CoreSignal.ApprovalChanged(transferId)) _signals.tryEmit(CoreSignal.ApprovalChanged(transferId))
_signals.tryEmit(CoreSignal.ReceiverHistoryChanged(transferId)) _signals.tryEmit(CoreSignal.ReceiverHistoryChanged(transferId))
} }
override suspend fun clearReceiveHistory(): Result<ULong> = runCore { override suspend fun clearReceiveHistory(): Result<ULong> = runCore { activeCore ->
val deleted = requireCore().deleteReceiveHistory() val deleted = activeCore.deleteReceiveHistory()
refreshSnapshot() refreshSnapshot(activeCore)
deleted deleted
} }
override suspend fun receiverRequests(transferId: ULong): Result<List<ReceiverRequestModel>> = runCore { override suspend fun receiverRequests(transferId: ULong): Result<List<ReceiverRequestModel>> = runCore { activeCore ->
requireCore().listReceiverRequests(transferId).map(ReceiverRequest::toModel) activeCore.listReceiverRequests(transferId).map(ReceiverRequest::toModel)
} }
override suspend fun respondReceiverRequest( override suspend fun respondReceiverRequest(
requestId: String, requestId: String,
accepted: Boolean, accepted: Boolean,
reason: String?, reason: String?,
): Result<Unit> = runCore { ): Result<Unit> = runCore { activeCore ->
requireCore().respondReceiverRequest(requestId, accepted, reason) activeCore.respondReceiverRequest(requestId, accepted, reason)
} }
override suspend fun refresh(): Result<Unit> = runCore { refreshSnapshot() } override suspend fun refresh(): Result<Unit> = runCore(::refreshSnapshot)
override suspend fun shareSources( override suspend fun shareSources(
sources: List<ShareSource>, sources: List<ShareSource>,
transferName: String, transferName: String,
senderName: String, senderName: String,
accessPolicy: ShareAccessPolicy, accessPolicy: ShareAccessPolicy,
): Result<Share> = runCore { ): Result<Share> = runCore { activeCore ->
require(sources.isNotEmpty()) { "Select at least one file to share" } require(sources.isNotEmpty()) { "Select at least one file to share" }
withPlatformPathAccess(sources) { withPlatformPathAccess(sources) {
requireCore().shareFiles( activeCore.shareFiles(
sources = sources, sources = sources,
metadata = ShareMetadataInput( metadata = ShareMetadataInput(
transferId = nextTransferId(), transferId = nextTransferId(),
@@ -215,7 +229,7 @@ class CoreRepository(
), ),
).toModel() ).toModel()
}.also { share -> }.also { share ->
refreshSnapshot() refreshSnapshot(activeCore)
_state.update { it.copy(lastShare = share) } _state.update { it.copy(lastShare = share) }
} }
} }
@@ -232,8 +246,7 @@ class CoreRepository(
} }
} }
private fun refreshSnapshot() { private fun refreshSnapshot(activeCore: VnidropCore) {
val activeCore = requireCore()
val status = activeCore.status() val status = activeCore.status()
_state.update { _state.update {
it.copy( it.copy(
@@ -244,10 +257,25 @@ class CoreRepository(
} }
} }
private suspend fun <T> runCore(block: suspend () -> T): Result<T> = private suspend fun <T> runCore(block: suspend (VnidropCore) -> T): Result<T> =
withContext(dispatcher) { withContext(dispatcher) {
try { 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 <T> runReconfiguration(block: suspend () -> T): Result<T> =
withContext(dispatcher) {
try {
Result.success(lifecycleGate.withReconfiguration(block))
} catch (error: Throwable) { } catch (error: Throwable) {
if (error is CancellationException) throw error if (error is CancellationException) throw error
Result.failure(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( private fun CoreEvent.toModel(): CoreEventModel = CoreEventModel(
id = id, id = id,
timestamp = timestamp, timestamp = timestamp,

View File

@@ -15,6 +15,7 @@ import com.vnidrop.app.ui.theme.ThemeMode
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -46,7 +47,8 @@ class AppViewModel(
AppLogger.info("lifecycle", "app started", mapOf("platform" to environment.name)) AppLogger.info("lifecycle", "app started", mapOf("platform" to environment.name))
diagnostics?.record("app_open", mapOf("platform" to environment.name, "version" to environment.appVersion)) diagnostics?.record("app_open", mapOf("platform" to environment.name, "version" to environment.appVersion))
viewModelScope.launch { 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 { viewModelScope.launch {
preferencesRepository.preferences.collect { preferences -> preferencesRepository.preferences.collect { preferences ->

View File

@@ -12,6 +12,7 @@ import com.vnidrop.app.core.CoreState
import com.vnidrop.app.core.ReceiverDeliveryStatus import com.vnidrop.app.core.ReceiverDeliveryStatus
import com.vnidrop.app.core.ShareAccessPolicy import com.vnidrop.app.core.ShareAccessPolicy
import com.vnidrop.app.core.TransferDirection import com.vnidrop.app.core.TransferDirection
import com.vnidrop.app.core.TransferStatus
import com.vnidrop.app.ui.components.AdaptiveDrawer import com.vnidrop.app.ui.components.AdaptiveDrawer
import com.vnidrop.app.ui.state.WindowClass 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) { AdaptiveDrawer(windowClass = windowClass, onDismissRequest = onCloseDetailPanel) {
when (state.detailPanel) { when (state.detailPanel) {
TransferDetailPanel.Activity -> TransferActivityPanel(coreState.events, selectedTransfer.transferId) TransferDetailPanel.Activity -> TransferActivityPanel(coreState.events, selectedTransfer.transferId)

View File

@@ -209,7 +209,12 @@ class SendViewModel(
) )
} }
fun openActivity() = _state.update { it.copy(detailPanel = TransferDetailPanel.Activity) } 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() { fun openReceivers() {
val transferId = _state.value.selectedTransferId ?: return val transferId = _state.value.selectedTransferId ?: return
_state.update { it.copy(detailPanel = TransferDetailPanel.Receivers) } _state.update { it.copy(detailPanel = TransferDetailPanel.Receivers) }

View File

@@ -46,6 +46,7 @@ import com.vnidrop.app.core.ReceiverDeliveryStatus
import com.vnidrop.app.core.ReceiverRequestModel import com.vnidrop.app.core.ReceiverRequestModel
import com.vnidrop.app.core.ShareAccessPolicy import com.vnidrop.app.core.ShareAccessPolicy
import com.vnidrop.app.core.Transfer 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.AppCard
import com.vnidrop.app.ui.components.DestructiveButton import com.vnidrop.app.ui.components.DestructiveButton
import com.vnidrop.app.ui.components.PrimaryButton 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 com.vnidrop.app.ui.theme.LocalVniDropColors
import org.jetbrains.compose.resources.decodeToImageBitmap import org.jetbrains.compose.resources.decodeToImageBitmap
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import vnidrop.shared.generated.resources.* import vnidrop.shared.generated.resources.*
enum class InvitationAction { Export, Share, Nfc } 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 @Composable
internal fun TransferDetails( internal fun TransferDetails(
transfer: Transfer, transfer: Transfer,
@@ -122,12 +130,29 @@ internal fun TransferDetails(
count = pendingReceivers + completedReceivers, count = pendingReceivers + completedReceivers,
onClick = onReceivers, onClick = onReceivers,
) )
HorizontalDivider(color = LocalVniDropColors.current.borderDefault) when (transfer.status) {
DetailDestination( TransferStatus.Sharing -> {
title = stringResource(Res.string.transfer_share_title), HorizontalDivider(color = LocalVniDropColors.current.borderDefault)
description = stringResource(Res.string.transfer_share_description), DetailDestination(
onClick = onShare, 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 @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( 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, verticalAlignment = Alignment.CenterVertically,
) { ) {
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) { 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)) Text(count.toString(), modifier = Modifier.background(LocalVniDropColors.current.backgroundSelection, RoundedCornerShape(20.dp)).padding(horizontal = 9.dp, vertical = 3.dp))
Spacer(Modifier.width(8.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) } DisposableEffect(actions) { onDispose(actions::cancelNfcWrite) }
val ticket = transfer.ticket val ticket = transfer.ticket
PanelContainer(stringResource(Res.string.transfer_share_title)) { 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) { if (ticket == null) {
Text(stringResource(Res.string.transfer_event_preparing), color = LocalVniDropColors.current.foregroundLighter) Text(stringResource(Res.string.transfer_event_preparing), color = LocalVniDropColors.current.foregroundLighter)
return@PanelContainer return@PanelContainer
} }
val renderedBitmap by produceState(qrBitmap, ticket, qrBitmap) { val qrRenderState by produceState<TransferQrRenderState>(
if (value == null) { initialValue = qrBitmap?.let(TransferQrRenderState::Ready) ?: TransferQrRenderState.Loading,
value = withContext(Dispatchers.Default) { key1 = ticket,
runCatching { buildTransferQrCode(ticket).renderToBytes().decodeToImageBitmap() }.getOrNull() 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 when (val qrState = qrRenderState) {
Surface( is TransferQrRenderState.Ready -> Surface(
modifier = Modifier.align(Alignment.CenterHorizontally).size(268.dp), modifier = Modifier.align(Alignment.CenterHorizontally).size(268.dp),
shape = RoundedCornerShape(18.dp), shape = RoundedCornerShape(18.dp),
color = Color.White, color = Color.White,
) { ) {
if (renderedQr != null) {
Image( Image(
bitmap = renderedQr, bitmap = qrState.bitmap,
contentDescription = null, contentDescription = null,
modifier = Modifier.padding(14.dp).fillMaxSize(), modifier = Modifier.padding(14.dp).fillMaxSize(),
filterQuality = FilterQuality.None, 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() } 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) { if (actions.nfcAvailability != NfcShareAvailability.Hidden) {
var writingNfc by remember(ticket) { mutableStateOf(false) } var writingNfc by remember(ticket) { mutableStateOf(false) }
SecondaryButton( SecondaryButton(

View File

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

View File

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

View File

@@ -7,6 +7,7 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.vnidrop.app.core.RelayMode
import com.vnidrop.app.ui.icons.AppIcon import com.vnidrop.app.ui.icons.AppIcon
import com.vnidrop.app.ui.theme.ThemeMode import com.vnidrop.app.ui.theme.ThemeMode
import org.jetbrains.compose.resources.stringResource 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.appearance_title
import vnidrop.shared.generated.resources.notifications_title import vnidrop.shared.generated.resources.notifications_title
import vnidrop.shared.generated.resources.preferences_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_title
import vnidrop.shared.generated.resources.settings_network_title
import vnidrop.shared.generated.resources.storage_title import vnidrop.shared.generated.resources.storage_title
@Composable @Composable
@@ -34,6 +38,14 @@ internal fun SettingsOverview(
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
) )
SettingsGroup { 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( SettingsRow(
icon = AppIcon.User, icon = AppIcon.User,
title = stringResource(Res.string.preferences_title), 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 @Composable
private fun themeModeLabel(mode: ThemeMode): String = when (mode) { private fun themeModeLabel(mode: ThemeMode): String = when (mode) {
ThemeMode.System -> stringResource(Res.string.appearance_system_mode) ThemeMode.System -> stringResource(Res.string.appearance_system_mode)

View File

@@ -24,6 +24,9 @@ fun SettingsRoute(viewModel: SettingsViewModel, windowClass: WindowClass) {
onSectionSelected = viewModel::selectSection, onSectionSelected = viewModel::selectSection,
onUsernameChanged = viewModel::setUsername, onUsernameChanged = viewModel::setUsername,
onThemeModeChanged = viewModel::setThemeMode, onThemeModeChanged = viewModel::setThemeMode,
onRelayModeChanged = viewModel::setRelayMode,
onRelayUrlsChanged = viewModel::setRelayUrlsText,
onApplyRelaySettings = viewModel::applyRelaySettings,
onChooseFolder = viewModel::chooseReceiveFolder, onChooseFolder = viewModel::chooseReceiveFolder,
onResetFolder = viewModel::resetReceiveFolder, onResetFolder = viewModel::resetReceiveFolder,
onNotificationsChanged = viewModel::setNotificationsEnabled, onNotificationsChanged = viewModel::setNotificationsEnabled,

View File

@@ -9,6 +9,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.vnidrop.app.ui.state.WindowClass import com.vnidrop.app.ui.state.WindowClass
import com.vnidrop.app.core.RelayMode
import com.vnidrop.app.ui.theme.ThemeMode import com.vnidrop.app.ui.theme.ThemeMode
@Composable @Composable
@@ -30,6 +31,9 @@ fun SettingsScreen(
onBugIncludeLogsChanged: (Boolean) -> Unit, onBugIncludeLogsChanged: (Boolean) -> Unit,
onSubmitBugReport: () -> Unit, onSubmitBugReport: () -> Unit,
onDeleteAllTransfers: () -> Unit = {}, onDeleteAllTransfers: () -> Unit = {},
onRelayModeChanged: (RelayMode) -> Unit = {},
onRelayUrlsChanged: (String) -> Unit = {},
onApplyRelaySettings: () -> Unit = {},
) { ) {
if (windowClass == WindowClass.Desktop) { if (windowClass == WindowClass.Desktop) {
Row( Row(
@@ -60,6 +64,9 @@ fun SettingsScreen(
onBugIncludeLogsChanged = onBugIncludeLogsChanged, onBugIncludeLogsChanged = onBugIncludeLogsChanged,
onSubmitBugReport = onSubmitBugReport, onSubmitBugReport = onSubmitBugReport,
onDeleteAllTransfers = onDeleteAllTransfers, onDeleteAllTransfers = onDeleteAllTransfers,
onRelayModeChanged = onRelayModeChanged,
onRelayUrlsChanged = onRelayUrlsChanged,
onApplyRelaySettings = onApplyRelaySettings,
) )
} }
} }
@@ -94,6 +101,9 @@ fun SettingsScreen(
onBugIncludeLogsChanged = onBugIncludeLogsChanged, onBugIncludeLogsChanged = onBugIncludeLogsChanged,
onSubmitBugReport = onSubmitBugReport, onSubmitBugReport = onSubmitBugReport,
onDeleteAllTransfers = onDeleteAllTransfers, onDeleteAllTransfers = onDeleteAllTransfers,
onRelayModeChanged = onRelayModeChanged,
onRelayUrlsChanged = onRelayUrlsChanged,
onApplyRelaySettings = onApplyRelaySettings,
) )
} }
} }
@@ -120,11 +130,22 @@ private fun SettingsSectionContent(
onBugIncludeLogsChanged: (Boolean) -> Unit, onBugIncludeLogsChanged: (Boolean) -> Unit,
onSubmitBugReport: () -> Unit, onSubmitBugReport: () -> Unit,
onDeleteAllTransfers: () -> Unit, onDeleteAllTransfers: () -> Unit,
onRelayModeChanged: (RelayMode) -> Unit,
onRelayUrlsChanged: (String) -> Unit,
onApplyRelaySettings: () -> Unit,
) { ) {
when (section) { when (section) {
SettingsSection.Overview -> Unit SettingsSection.Overview -> Unit
SettingsSection.Preferences -> PreferencesSettings(state, onUsernameChanged, onChooseFolder, onResetFolder, onBack, showBack) SettingsSection.Preferences -> PreferencesSettings(state, onUsernameChanged, onChooseFolder, onResetFolder, onBack, showBack)
SettingsSection.Appearance -> AppearanceSettings(state.themeMode, onThemeModeChanged, 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.Notifications -> NotificationSettings(state, onNotificationsChanged, onOpenNotificationSettings, onBack, showBack)
SettingsSection.Storage -> StorageSettings(state, onDeleteAllTransfers, onBack, showBack) SettingsSection.Storage -> StorageSettings(state, onDeleteAllTransfers, onBack, showBack)
SettingsSection.About -> AboutSettings( SettingsSection.About -> AboutSettings(

View File

@@ -7,8 +7,12 @@ import com.vnidrop.app.DeviceInfoProvider
import com.vnidrop.app.PlatformEnvironment import com.vnidrop.app.PlatformEnvironment
import com.vnidrop.app.core.FileSystemService import com.vnidrop.app.core.FileSystemService
import com.vnidrop.app.core.CoreGateway import com.vnidrop.app.core.CoreGateway
import com.vnidrop.app.core.CoreLifecycleBusyException
import com.vnidrop.app.core.FolderAccessStatus import com.vnidrop.app.core.FolderAccessStatus
import com.vnidrop.app.core.ReceiveFolder 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.BugReportDraft
import com.vnidrop.app.diagnostics.BugReportService import com.vnidrop.app.diagnostics.BugReportService
import com.vnidrop.app.diagnostics.DiagnosticsBuildConfig 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_permission_denied
import vnidrop.shared.generated.resources.notifications_settings_open_failed import vnidrop.shared.generated.resources.notifications_settings_open_failed
import vnidrop.shared.generated.resources.notifications_unsupported import vnidrop.shared.generated.resources.notifications_unsupported
import vnidrop.shared.generated.resources.relay_settings_applied
enum class SettingsSection { enum class SettingsSection {
Overview, Overview,
Preferences, Preferences,
Appearance, Appearance,
Network,
Notifications, Notifications,
Storage, Storage,
About, About,
BugReport, BugReport,
} }
enum class RelaySettingsApplyError {
ActiveTransfers,
ApplyFailed,
RestoreFailed,
}
data class StorageBreakdown( data class StorageBreakdown(
val transferCacheBytes: ULong, val transferCacheBytes: ULong,
val appDataBytes: ULong, val appDataBytes: ULong,
@@ -74,6 +86,14 @@ data class SettingsState(
val isValidatingFolder: Boolean = false, val isValidatingFolder: Boolean = false,
val supportsCustomReceiveFolders: Boolean = true, val supportsCustomReceiveFolders: Boolean = true,
val themeMode: ThemeMode = ThemeMode.System, 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 notificationsEnabled: Boolean = false,
val notificationPermission: NotificationPermission = NotificationPermission.NotDetermined, val notificationPermission: NotificationPermission = NotificationPermission.NotDetermined,
val diagnosticsEnabled: Boolean = false, val diagnosticsEnabled: Boolean = false,
@@ -90,7 +110,11 @@ data class SettingsState(
val storage: StorageBreakdown? = null, val storage: StorageBreakdown? = null,
val isCalculatingStorage: Boolean = false, val isCalculatingStorage: Boolean = false,
val isDeletingTransfers: Boolean = false, val isDeletingTransfers: Boolean = false,
) ) {
val hasRelaySettingsChanges: Boolean
get() = relayMode != savedRelaySettings.mode ||
(relayMode == RelayMode.Custom && relayUrlsText != savedRelaySettings.relayUrls.joinToString("\n"))
}
sealed interface SettingsEffect { sealed interface SettingsEffect {
data object OpenReceiveFolderPicker : SettingsEffect data object OpenReceiveFolderPicker : SettingsEffect
@@ -121,6 +145,7 @@ class SettingsViewModel(
private var enableNotificationsAfterSettings = false private var enableNotificationsAfterSettings = false
private var usernamePersistJob: Job? = null private var usernamePersistJob: Job? = null
private var hasLocalUsernameDraft = false private var hasLocalUsernameDraft = false
private var hasLocalRelayDraft = false
init { init {
viewModelScope.launch { viewModelScope.launch {
@@ -134,6 +159,13 @@ class SettingsViewModel(
themeMode = preferences.themeMode, themeMode = preferences.themeMode,
notificationsEnabled = preferences.notificationsEnabled, notificationsEnabled = preferences.notificationsEnabled,
diagnosticsEnabled = preferences.diagnosticsEnabled, 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) { 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() refreshNotificationPermission()
loadDeviceInfo() loadDeviceInfo()
} }
@@ -218,6 +263,103 @@ class SettingsViewModel(
viewModelScope.launch { preferencesRepository.setThemeMode(mode) } 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() { fun chooseReceiveFolder() {
if (!fileSystemService.supportsCustomReceiveFolders) return if (!fileSystemService.supportsCustomReceiveFolders) return
viewModelScope.launch { effects.send(SettingsEffect.OpenReceiveFolderPicker) } viewModelScope.launch { effects.send(SettingsEffect.OpenReceiveFolderPicker) }
@@ -417,7 +559,22 @@ class SettingsViewModel(
_state.update { it.copy(folderAccessStatus = status, isValidatingFolder = false) } _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 { private companion object {
const val UsernamePersistDebounceMs = 350L const val UsernamePersistDebounceMs = 350L
val ActiveTransferStatuses = setOf(TransferStatus.Importing, TransferStatus.Sharing, TransferStatus.Receiving)
} }
} }

View File

@@ -3,12 +3,14 @@ package com.vnidrop.app.preferences
import androidx.datastore.core.DataStore import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.PreferenceDataStoreFactory 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.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.ReceiveFolder
import com.vnidrop.app.core.ReceiveFolderKind 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.ui.theme.ThemeMode
import com.vnidrop.app.util.randomUuidString import com.vnidrop.app.util.randomUuidString
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
@@ -26,6 +28,7 @@ data class AppPreferences(
val diagnosticsEnabled: Boolean = false, val diagnosticsEnabled: Boolean = false,
/** Stable anonymous install id; never an account or advertising id. */ /** Stable anonymous install id; never an account or advertising id. */
val diagnosticsInstallId: String = "", val diagnosticsInstallId: String = "",
val relaySettings: RelaySettings = RelaySettings(),
) )
class AppPreferencesDefaults( class AppPreferencesDefaults(
@@ -44,6 +47,7 @@ interface PreferencesRepository {
suspend fun setThemeMode(mode: ThemeMode) suspend fun setThemeMode(mode: ThemeMode)
suspend fun setNotificationsEnabled(enabled: Boolean) suspend fun setNotificationsEnabled(enabled: Boolean)
suspend fun setDiagnosticsEnabled(enabled: Boolean) suspend fun setDiagnosticsEnabled(enabled: Boolean)
suspend fun setRelaySettings(settings: RelaySettings)
/** Ensures a durable install id exists and returns it. */ /** Ensures a durable install id exists and returns it. */
suspend fun ensureDiagnosticsInstallId(): String suspend fun ensureDiagnosticsInstallId(): String
} }
@@ -53,8 +57,27 @@ class AppPreferencesRepository(
private val defaults: AppPreferencesDefaults, private val defaults: AppPreferencesDefaults,
) : PreferencesRepository { ) : PreferencesRepository {
override val preferences: Flow<AppPreferences> = dataStore.data override val preferences: Flow<AppPreferences> = dataStore.data
.catch { emit(emptyPreferences()) } .catch {
emit(preferencesOf(PreferenceKeys.RelayMode to RelayMode.Custom.name))
}
.map { prefs -> .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( AppPreferences(
username = prefs[PreferenceKeys.Username]?.takeIf { it.isNotBlank() } ?: defaults.username, username = prefs[PreferenceKeys.Username]?.takeIf { it.isNotBlank() } ?: defaults.username,
receiveFolder = resolveReceiveFolder(prefs, defaults.receiveFolder), receiveFolder = resolveReceiveFolder(prefs, defaults.receiveFolder),
@@ -62,6 +85,10 @@ class AppPreferencesRepository(
notificationsEnabled = prefs[PreferenceKeys.NotificationsEnabled] ?: defaults.notificationsEnabled, notificationsEnabled = prefs[PreferenceKeys.NotificationsEnabled] ?: defaults.notificationsEnabled,
diagnosticsEnabled = prefs[PreferenceKeys.DiagnosticsEnabled] ?: defaults.diagnosticsEnabled, diagnosticsEnabled = prefs[PreferenceKeys.DiagnosticsEnabled] ?: defaults.diagnosticsEnabled,
diagnosticsInstallId = prefs[PreferenceKeys.DiagnosticsInstallId].orEmpty(), 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 { override suspend fun ensureDiagnosticsInstallId(): String {
val existing = preferences.first().diagnosticsInstallId val existing = preferences.first().diagnosticsInstallId
if (existing.isNotBlank()) return existing if (existing.isNotBlank()) return existing
@@ -128,6 +162,8 @@ private object PreferenceKeys {
val NotificationsEnabled = booleanPreferencesKey("notifications_enabled") val NotificationsEnabled = booleanPreferencesKey("notifications_enabled")
val DiagnosticsEnabled = booleanPreferencesKey("diagnostics_enabled") val DiagnosticsEnabled = booleanPreferencesKey("diagnostics_enabled")
val DiagnosticsInstallId = stringPreferencesKey("diagnostics_install_id") val DiagnosticsInstallId = stringPreferencesKey("diagnostics_install_id")
val RelayMode = stringPreferencesKey("relay_mode")
val RelayUrls = stringPreferencesKey("relay_urls")
} }
private fun resolveReceiveFolder(prefs: Preferences, defaults: ReceiveFolder): ReceiveFolder { private fun resolveReceiveFolder(prefs: Preferences, defaults: ReceiveFolder): ReceiveFolder {
@@ -162,4 +198,7 @@ private fun receiveFolderKindOrNull(raw: String): ReceiveFolderKind? =
private fun themeModeOrNull(raw: String): ThemeMode? = private fun themeModeOrNull(raw: String): ThemeMode? =
runCatching { ThemeMode.valueOf(raw) }.getOrNull() runCatching { ThemeMode.valueOf(raw) }.getOrNull()
private fun relayModeOrNull(raw: String): RelayMode? =
runCatching { RelayMode.valueOf(raw) }.getOrNull()
private const val AppPreferencesFileName = "app_preferences.preferences_pb" private const val AppPreferencesFileName = "app_preferences.preferences_pb"

View File

@@ -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<Unit>()
val secondEntered = CompletableDeferred<Unit>()
val release = CompletableDeferred<Unit>()
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<CoreLifecycleBusyException> {
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<Unit>()
val release = CompletableDeferred<Unit>()
val reconfiguration = launch {
gate.withReconfiguration {
entered.complete(Unit)
release.await()
}
}
entered.await()
assertFailsWith<CoreLifecycleBusyException> {
gate.withCall(capture = { "core" }) { error("must not run") }
}
release.complete(Unit)
reconfiguration.join()
}
@Test
fun cancellingCallReleasesItsLease() = runTest {
val gate = CoreLifecycleGate()
val entered = CompletableDeferred<Unit>()
val call = launch {
gate.withCall(capture = { "core" }) {
entered.complete(Unit)
awaitCancellation()
}
}
entered.await()
call.cancel()
call.join()
gate.withReconfiguration { }
}
}

View File

@@ -3,12 +3,15 @@ package com.vnidrop.app.feature
import com.vnidrop.app.DeviceInfo import com.vnidrop.app.DeviceInfo
import com.vnidrop.app.PlatformEnvironment import com.vnidrop.app.PlatformEnvironment
import com.vnidrop.app.core.CoreState import com.vnidrop.app.core.CoreState
import com.vnidrop.app.core.CoreStatus
import com.vnidrop.app.core.CoreSignal import com.vnidrop.app.core.CoreSignal
import com.vnidrop.app.core.PickedShareFile import com.vnidrop.app.core.PickedShareFile
import com.vnidrop.app.core.ReceiveFolder import com.vnidrop.app.core.ReceiveFolder
import com.vnidrop.app.core.ReceiveFolderKind import com.vnidrop.app.core.ReceiveFolderKind
import com.vnidrop.app.core.ReceiverDeliveryStatus import com.vnidrop.app.core.ReceiverDeliveryStatus
import com.vnidrop.app.core.ReceiverRequestModel 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.Share
import com.vnidrop.app.core.ShareAccessPolicy import com.vnidrop.app.core.ShareAccessPolicy
import com.vnidrop.app.core.Transfer 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.receive.ReceiveViewModel
import com.vnidrop.app.feature.send.SendViewModel import com.vnidrop.app.feature.send.SendViewModel
import com.vnidrop.app.feature.settings.SettingsSection 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.feature.settings.SettingsViewModel
import com.vnidrop.app.notifications.NotificationPermission import com.vnidrop.app.notifications.NotificationPermission
import com.vnidrop.app.preferences.AppPreferences import com.vnidrop.app.preferences.AppPreferences
@@ -74,10 +78,26 @@ class ViewModelsTest {
val viewModel = AppViewModel(environment(), core, preferences(), UiMessageController()) val viewModel = AppViewModel(environment(), core, preferences(), UiMessageController())
advanceUntilIdle() advanceUntilIdle()
assertTrue(core.state.value.isInitialized) assertTrue(core.state.value.isInitialized)
assertEquals(listOf(RelaySettings()), core.initializedRelaySettings)
viewModel.selectDestination(AppDestination.Settings) viewModel.selectDestination(AppDestination.Settings)
assertEquals(AppDestination.Settings, viewModel.state.value.destination) 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 @Test
fun settingsEnablesNotificationsOnlyAfterPermission() = runTest { fun settingsEnablesNotificationsOnlyAfterPermission() = runTest {
Dispatchers.setMain(StandardTestDispatcher(testScheduler)) Dispatchers.setMain(StandardTestDispatcher(testScheduler))
@@ -116,6 +136,94 @@ class ViewModelsTest {
assertEquals("Ada ", viewModel.state.value.username) 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 @Test
fun settingsKeepsNotificationsDisabledWhenPermissionIsDenied() = runTest { fun settingsKeepsNotificationsDisabledWhenPermissionIsDenied() = runTest {
Dispatchers.setMain(StandardTestDispatcher(testScheduler)) Dispatchers.setMain(StandardTestDispatcher(testScheduler))
@@ -722,11 +830,12 @@ class ViewModelsTest {
transport: DiagnosticsTransport = RecordingDiagnosticsTransport(), transport: DiagnosticsTransport = RecordingDiagnosticsTransport(),
fileSystem: FakeFileSystemService = FakeFileSystemService(folder), fileSystem: FakeFileSystemService = FakeFileSystemService(folder),
diagnosticsIncluded: Boolean = false, diagnosticsIncluded: Boolean = false,
repository: FakeCoreGateway = FakeCoreGateway(),
) = SettingsViewModel( ) = SettingsViewModel(
environment(), environment(),
{ DeviceInfo("Device", "Model", "OS", "Wi-Fi", "80%") }, { DeviceInfo("Device", "Model", "OS", "Wi-Fi", "80%") },
fileSystem, fileSystem,
FakeCoreGateway(), repository,
preferences, preferences,
notifications, notifications,
UiMessageController(), UiMessageController(),

View File

@@ -2,6 +2,7 @@ package com.vnidrop.app.feature.send
import kotlin.test.Test import kotlin.test.Test
import kotlin.test.assertEquals import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
class TransferQrCodeTest { class TransferQrCodeTest {
@Test @Test
@@ -19,4 +20,12 @@ class TransferQrCodeTest {
assertEquals(101, qrCode.rawData.size) assertEquals(101, qrCode.rawData.size)
assertEquals(872, qrCode.canvasSize) assertEquals(872, qrCode.canvasSize)
} }
@Test
fun versionFortyCapacityBoundaryRejectsOversizedTicket() {
assertEquals(40, transferQrInformationDensity("a".repeat(2_953)))
assertFailsWith<IllegalArgumentException> {
transferQrInformationDensity("a".repeat(2_954))
}
}
} }

View File

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

View File

@@ -10,6 +10,7 @@ import com.vnidrop.app.core.PickedShareFile
import com.vnidrop.app.core.ReceiveFolder import com.vnidrop.app.core.ReceiveFolder
import com.vnidrop.app.core.ReceivedArtifactModel import com.vnidrop.app.core.ReceivedArtifactModel
import com.vnidrop.app.core.ReceivedStorageInspection import com.vnidrop.app.core.ReceivedStorageInspection
import com.vnidrop.app.core.RelaySettings
import com.vnidrop.app.core.ReceiverRequestModel import com.vnidrop.app.core.ReceiverRequestModel
import com.vnidrop.app.core.Share import com.vnidrop.app.core.Share
import com.vnidrop.app.core.ShareAccessPolicy import com.vnidrop.app.core.ShareAccessPolicy
@@ -54,6 +55,8 @@ class FakeCoreGateway : CoreGateway {
var lastReceiveTicket: String? = null var lastReceiveTicket: String? = null
var lastReceiveReceiverName: String? = null var lastReceiveReceiverName: String? = null
var lastShareAccessPolicy: ShareAccessPolicy? = null var lastShareAccessPolicy: ShareAccessPolicy? = null
val initializedRelaySettings = mutableListOf<RelaySettings>()
var initializeHandler: (RelaySettings) -> Result<Unit> = { Result.success(Unit) }
fun completeSuspendedReceive() { fun completeSuspendedReceive() {
receiveGate?.complete(Unit) receiveGate?.complete(Unit)
@@ -66,9 +69,11 @@ class FakeCoreGateway : CoreGateway {
gate.await() gate.await()
} }
override suspend fun initialize(appDataDir: String): Result<Unit> { override suspend fun initialize(appDataDir: String, relaySettings: RelaySettings): Result<Unit> {
mutableState.value = mutableState.value.copy(isInitialized = true) initializedRelaySettings += relaySettings
return Result.success(Unit) return initializeHandler(relaySettings).onSuccess {
mutableState.value = mutableState.value.copy(isInitialized = true)
}
} }
override fun shutdown() = Unit override fun shutdown() = Unit
var lastShareSourceCount: Int = 0 var lastShareSourceCount: Int = 0
@@ -201,6 +206,9 @@ class FakePreferencesRepository(
override suspend fun setDiagnosticsEnabled(enabled: Boolean) { override suspend fun setDiagnosticsEnabled(enabled: Boolean) {
mutablePreferences.value = mutablePreferences.value.copy(diagnosticsEnabled = enabled) 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 { override suspend fun ensureDiagnosticsInstallId(): String {
val existing = mutablePreferences.value.diagnosticsInstallId val existing = mutablePreferences.value.diagnosticsInstallId
if (existing.isNotBlank()) return existing if (existing.isNotBlank()) return existing

View File

@@ -1,12 +1,19 @@
package com.vnidrop.app.preferences 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.ReceiveFolder
import com.vnidrop.app.core.ReceiveFolderKind 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.ui.theme.ThemeMode
import java.nio.file.Files import java.nio.file.Files
import kotlin.test.Test import kotlin.test.Test
import kotlin.test.assertEquals import kotlin.test.assertEquals
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
class AppPreferencesRepositoryTest { class AppPreferencesRepositoryTest {
@@ -19,6 +26,66 @@ class AppPreferencesRepositoryTest {
assertEquals("Device Name", preferences.username) assertEquals("Device Name", preferences.username)
assertEquals(defaultFolder, preferences.receiveFolder) assertEquals(defaultFolder, preferences.receiveFolder)
assertEquals(ThemeMode.System, preferences.themeMode) 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<Preferences> {
override val data = flow<Preferences> {
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 @Test

View File

@@ -113,6 +113,47 @@ class FoundationComposeTest {
onNodeWithText("Get notified about new receive requests while VniDrop is in the background.").assertIsDisplayed() 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 @Test
fun aboutSettingsShowsTheSharedProductAndPrivacyContent() = runComposeUiTest { fun aboutSettingsShowsTheSharedProductAndPrivacyContent() = runComposeUiTest {
setContent { setContent {
@@ -536,6 +577,56 @@ class FoundationComposeTest {
onNodeWithContentDescription("Close").assertIsDisplayed() 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 @Test
fun phoneReceiveEmptyStateOpensAcquisitionMethods() = runComposeUiTest { fun phoneReceiveEmptyStateOpensAcquisitionMethods() = runComposeUiTest {
val state = mutableStateOf(ReceiveState()) val state = mutableStateOf(ReceiveState())