mirror of
https://github.com/sudosylabs/vnidrop.git
synced 2026-08-06 02:39:57 +02:00
Compare commits
34 Commits
e22e395efc
...
b8e8dd8644
| Author | SHA1 | Date | |
|---|---|---|---|
| b8e8dd8644 | |||
| 83b66c5eb7 | |||
| 81e84b14f8 | |||
| c81cb7c8b6 | |||
| 319af6f2de | |||
| 3abd4d0cfd | |||
| b66cb8c1f1 | |||
| 98da43b122 | |||
| 7d3f1b9862 | |||
| 83ddf9f059 | |||
| f513a6118e | |||
| 35f06a0b6b | |||
| b65bac021f | |||
| d2924f7ce6 | |||
| 3042005226 | |||
| 9b15a388d8 | |||
| c23f7916bb | |||
|
|
0f8f89641a | ||
| b724c1540f | |||
| 1d049d08f2 | |||
| aab5f243ca | |||
| 065d57e896 | |||
| c7ebaee15b | |||
| 425500ecf2 | |||
| 30dfabf8e7 | |||
| 4bd51106e8 | |||
| 6e2c8b4b2d | |||
| 0448137d84 | |||
| 4074f4bee8 | |||
| 7cc0e825f6 | |||
| efb3c474d1 | |||
| 7592d49a59 | |||
| a0bcc5dbff | |||
| cbace73908 |
4
.github/workflows/apple.yml
vendored
4
.github/workflows/apple.yml
vendored
@@ -65,6 +65,10 @@ jobs:
|
||||
- name: Install XcodeGen
|
||||
run: brew install xcodegen
|
||||
|
||||
- name: Install SwiftLint
|
||||
# Required by the VniDrop target's SwiftLint build phase (typed-resources rules).
|
||||
run: brew install swiftlint
|
||||
|
||||
- name: Install Bun
|
||||
# The Apple l10n catalog (Localizable.xcstrings) and L10n.swift are
|
||||
# generated from localization/strings.json at build time, not tracked.
|
||||
|
||||
11
AGENTS.md
11
AGENTS.md
@@ -48,6 +48,15 @@ Domain docs (reference, do not paste into PRs):
|
||||
8. **Every bug fix includes a regression test** at the lowest layer that catches it.
|
||||
9. After code changes, run the **relevant** checks in [Build and test](#build-and-test)
|
||||
and fix failures before finishing.
|
||||
10. **`localization/strings.json` is the single source of truth for all localized
|
||||
strings.** The KMP Compose resources (`shared/src/commonMain/composeResources/
|
||||
values*/strings.xml`) and the Apple catalog + accessors
|
||||
(`apple/VniDrop/Resources/Localizable.xcstrings`, `apple/VniDrop/Generated/
|
||||
L10n.swift`) are **generated** by the loc CLI (`cd localization && bun run
|
||||
src/cli.ts generate`) — never hand-edit them. To add/change a string: edit
|
||||
`strings.json` (set `targets` to `kmp`, `apple`, or omit for both), then
|
||||
regenerate. A key referenced in code but only present in a generated file will
|
||||
be silently dropped the next time generation runs.
|
||||
|
||||
---
|
||||
|
||||
@@ -291,6 +300,8 @@ branch from updated `master`.
|
||||
- Flaky multi-minute sleeps in tests
|
||||
- Unsigned commits when signing is required
|
||||
- Force-push or secret commits without explicit user direction
|
||||
- Hand-editing generated localization files (`values*/strings.xml`,
|
||||
`Localizable.xcstrings`, `L10n.swift`) instead of `localization/strings.json`
|
||||
|
||||
---
|
||||
|
||||
|
||||
389
Cargo.lock
generated
389
Cargo.lock
generated
@@ -61,12 +61,56 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anstream"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d"
|
||||
dependencies = [
|
||||
"anstyle",
|
||||
"anstyle-parse",
|
||||
"anstyle-query",
|
||||
"anstyle-wincon",
|
||||
"colorchoice",
|
||||
"is_terminal_polyfill",
|
||||
"utf8parse",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anstyle"
|
||||
version = "1.0.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
|
||||
|
||||
[[package]]
|
||||
name = "anstyle-parse"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e"
|
||||
dependencies = [
|
||||
"utf8parse",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anstyle-query"
|
||||
version = "1.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anstyle-wincon"
|
||||
version = "3.0.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
|
||||
dependencies = [
|
||||
"anstyle",
|
||||
"once_cell_polyfill",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anyhow"
|
||||
version = "1.0.103"
|
||||
@@ -463,9 +507,9 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||
|
||||
[[package]]
|
||||
name = "cfg_aliases"
|
||||
version = "0.2.1"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
|
||||
checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"
|
||||
|
||||
[[package]]
|
||||
name = "chacha20"
|
||||
@@ -518,6 +562,7 @@ version = "4.6.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f09628afdcc538b57f3c6341e9c8e9970f18e4a481690a64974d7023bd33548b"
|
||||
dependencies = [
|
||||
"anstream",
|
||||
"anstyle",
|
||||
"clap_lex",
|
||||
"strsim",
|
||||
@@ -556,6 +601,12 @@ dependencies = [
|
||||
"thiserror 2.0.18",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "colorchoice"
|
||||
version = "1.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
|
||||
|
||||
[[package]]
|
||||
name = "combine"
|
||||
version = "4.6.7"
|
||||
@@ -777,38 +828,17 @@ dependencies = [
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling"
|
||||
version = "0.20.11"
|
||||
name = "dashmap"
|
||||
version = "6.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee"
|
||||
checksum = "e6361d5c062261c78a176addb82d4c821ae42bed6089de0e12603cd25de2059c"
|
||||
dependencies = [
|
||||
"darling_core",
|
||||
"darling_macro",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling_core"
|
||||
version = "0.20.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e"
|
||||
dependencies = [
|
||||
"fnv",
|
||||
"ident_case",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"strsim",
|
||||
"syn 2.0.118",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling_macro"
|
||||
version = "0.20.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead"
|
||||
dependencies = [
|
||||
"darling_core",
|
||||
"quote",
|
||||
"syn 2.0.118",
|
||||
"cfg-if",
|
||||
"crossbeam-utils",
|
||||
"hashbrown 0.14.5",
|
||||
"lock_api",
|
||||
"once_cell",
|
||||
"parking_lot_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -834,7 +864,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ccc2776f0c61eca1ca32528f85548abd1a4be8fb53d1b21c013e4f18da1e7090"
|
||||
dependencies = [
|
||||
"data-encoding",
|
||||
"syn 2.0.118",
|
||||
"syn 1.0.109",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -879,37 +909,6 @@ version = "0.5.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
|
||||
|
||||
[[package]]
|
||||
name = "derive_builder"
|
||||
version = "0.20.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947"
|
||||
dependencies = [
|
||||
"derive_builder_macro",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "derive_builder_core"
|
||||
version = "0.20.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8"
|
||||
dependencies = [
|
||||
"darling",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.118",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "derive_builder_macro"
|
||||
version = "0.20.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c"
|
||||
dependencies = [
|
||||
"derive_builder_core",
|
||||
"syn 2.0.118",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "derive_more"
|
||||
version = "2.1.1"
|
||||
@@ -958,6 +957,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
|
||||
dependencies = [
|
||||
"block-buffer 0.12.1",
|
||||
"const-oid 0.10.2",
|
||||
"crypto-common 0.2.2",
|
||||
]
|
||||
|
||||
@@ -1474,6 +1474,12 @@ dependencies = [
|
||||
"byteorder",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.14.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.15.5"
|
||||
@@ -1861,12 +1867,6 @@ dependencies = [
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ident_case"
|
||||
version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
|
||||
|
||||
[[package]]
|
||||
name = "identity-hash"
|
||||
version = "0.1.0"
|
||||
@@ -1966,9 +1966,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "iroh"
|
||||
version = "1.0.1"
|
||||
version = "1.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1a2e38557969901f8b356d1ebd882253bab98cc81500d53e7bcbf33f56082303"
|
||||
checksum = "460de6bc52163b41b1646931f2897e5ab986f0966ade444467fec25024751a72"
|
||||
dependencies = [
|
||||
"backon",
|
||||
"blake3",
|
||||
@@ -2017,9 +2017,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "iroh-base"
|
||||
version = "1.0.1"
|
||||
version = "1.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "61cdf012298adc13f5c2c821ad87214fecc9ac54c751301d45bf62b229a85da1"
|
||||
checksum = "6be73e16ee21c923aca9b3121aaa0db936f7c7ecc156ff47b8dac944c68d59a8"
|
||||
dependencies = [
|
||||
"curve25519-dalek",
|
||||
"data-encoding",
|
||||
@@ -2077,9 +2077,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "iroh-dns"
|
||||
version = "1.0.1"
|
||||
version = "1.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4c24b83aae5ed4eced1c3724204c083c28c84c5d225a88e277eceeccce3dc3bd"
|
||||
checksum = "46f6a9b39d18e6345f5c151afd299f2488e2cb5c520fe41b107b6bd3dc4c3349"
|
||||
dependencies = [
|
||||
"arc-swap",
|
||||
"cfg_aliases",
|
||||
@@ -2118,12 +2118,20 @@ version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "291065721ad7c477b972e581bbc528df031dc8eb5e39fe1ff3300ae5dfb157ef"
|
||||
dependencies = [
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
"hyper-util",
|
||||
"iroh-metrics-derive",
|
||||
"itoa",
|
||||
"n0-error",
|
||||
"portable-atomic",
|
||||
"reqwest",
|
||||
"rustls",
|
||||
"rustls-platform-verifier",
|
||||
"ryu",
|
||||
"serde",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
@@ -2141,13 +2149,15 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "iroh-relay"
|
||||
version = "1.0.1"
|
||||
version = "1.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9f16a5505939f9250297ff2f1210b5142d13618f496eab03ce3f964fc47e2e2b"
|
||||
checksum = "24bd586cf927f7b700f56ec3639b53cb5fa901ce284784051ff71092bfbf8193"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"bytes",
|
||||
"cfg_aliases",
|
||||
"clap",
|
||||
"dashmap",
|
||||
"data-encoding",
|
||||
"derive_more",
|
||||
"getrandom 0.4.3",
|
||||
@@ -2168,19 +2178,29 @@ dependencies = [
|
||||
"pin-project",
|
||||
"postcard",
|
||||
"rand 0.10.2",
|
||||
"rcgen",
|
||||
"reloadable-state",
|
||||
"reqwest",
|
||||
"rustls",
|
||||
"rustls-cert-file-reader",
|
||||
"rustls-cert-reloadable-resolver",
|
||||
"rustls-pki-types",
|
||||
"serde",
|
||||
"serde_bytes",
|
||||
"serde_json",
|
||||
"sha1 0.11.0",
|
||||
"simdutf8",
|
||||
"strum",
|
||||
"time",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tokio-rustls-acme",
|
||||
"tokio-util",
|
||||
"tokio-websockets",
|
||||
"toml 1.1.2+spec-1.1.0",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"url",
|
||||
"vergen-gitcl",
|
||||
"webpki-roots",
|
||||
"ws_stream_wasm",
|
||||
]
|
||||
@@ -2264,6 +2284,12 @@ dependencies = [
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "is_terminal_polyfill"
|
||||
version = "1.70.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.18"
|
||||
@@ -2714,9 +2740,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "noq"
|
||||
version = "1.0.1"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4bf95190af1bd4a00a10e8255ca0c8ddd9e9a9f5e79151d7a7eb6d56aff5dc89"
|
||||
checksum = "e11803df44ac03a30988d61585ea50885d5428e42da944fe1e498799da7886a2"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"cfg_aliases",
|
||||
@@ -2736,9 +2762,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "noq-proto"
|
||||
version = "1.0.1"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "aa6c890013591e709a3e45dd53501351b7e27e7ff3c7e9fc3dce43e300e7e9d3"
|
||||
checksum = "334c3c9833f7b2c573cceb9896ddc7aaeb58c8807cbb63211b24d1fe88bf866e"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"bytes",
|
||||
@@ -2765,9 +2791,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "noq-udp"
|
||||
version = "1.0.1"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3137a52df66c20090a889828d1c655f21f52294cba64e5c4fbb04fc83eee7c8e"
|
||||
checksum = "bde7a5d5102f1cff03d482240f0ed20551661f63663620f4b26112ed751165e9"
|
||||
dependencies = [
|
||||
"cfg_aliases",
|
||||
"libc",
|
||||
@@ -2879,15 +2905,6 @@ dependencies = [
|
||||
"syn 2.0.118",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num_threads"
|
||||
version = "0.1.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2"
|
||||
version = "0.6.4"
|
||||
@@ -2997,6 +3014,12 @@ dependencies = [
|
||||
"portable-atomic",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "once_cell_polyfill"
|
||||
version = "1.70.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
|
||||
|
||||
[[package]]
|
||||
name = "opaque-debug"
|
||||
version = "0.3.1"
|
||||
@@ -3539,6 +3562,23 @@ version = "0.8.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
|
||||
|
||||
[[package]]
|
||||
name = "reloadable-core"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1dc20ac1418988b60072d783c9f68e28a173fb63493c127952f6face3b40c6e0"
|
||||
|
||||
[[package]]
|
||||
name = "reloadable-state"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3853ef78d45b50f8b989896304a85239539d39b7f866a000e8846b9b72d74ce8"
|
||||
dependencies = [
|
||||
"arc-swap",
|
||||
"reloadable-core",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "reqwest"
|
||||
version = "0.13.4"
|
||||
@@ -3562,6 +3602,8 @@ dependencies = [
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"rustls-platform-verifier",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
@@ -3668,6 +3710,40 @@ dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-cert-file-reader"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8bb47c2a50fdfdaf95b0ac8b12620fc327da1fd4adbb30d0c56d866b005873ff"
|
||||
dependencies = [
|
||||
"rustls-cert-read",
|
||||
"rustls-pki-types",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-cert-read"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dd46e8c5ae4de3345c4786a83f99ec7aff287209b9e26fa883c473aeb28f19d5"
|
||||
dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-cert-reloadable-resolver"
|
||||
version = "0.7.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fe1baa8a3a1f05eaa9fc55aed4342867f70e5c170ea3bfed1b38c51a4857c0c8"
|
||||
dependencies = [
|
||||
"futures-util",
|
||||
"reloadable-state",
|
||||
"rustls",
|
||||
"rustls-cert-read",
|
||||
"thiserror 2.0.18",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-native-certs"
|
||||
version = "0.8.4"
|
||||
@@ -3898,6 +3974,15 @@ dependencies = [
|
||||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_spanned"
|
||||
version = "1.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_urlencoded"
|
||||
version = "0.7.1"
|
||||
@@ -3931,6 +4016,17 @@ dependencies = [
|
||||
"digest 0.10.7",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha1"
|
||||
version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures 0.3.0",
|
||||
"digest 0.11.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha1_smol"
|
||||
version = "1.0.1"
|
||||
@@ -4234,7 +4330,7 @@ dependencies = [
|
||||
"percent-encoding",
|
||||
"rand 0.8.6",
|
||||
"rsa",
|
||||
"sha1",
|
||||
"sha1 0.10.6",
|
||||
"sha2 0.10.9",
|
||||
"smallvec",
|
||||
"sqlx-core",
|
||||
@@ -4454,7 +4550,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"getrandom 0.4.3",
|
||||
"getrandom 0.3.4",
|
||||
"once_cell",
|
||||
"rustix",
|
||||
"windows-sys 0.61.2",
|
||||
@@ -4526,9 +4622,7 @@ checksum = "18dfaaeddcb932337b5e7866ee7d0ce9b76d2fd092997146f187ec09b4558a50"
|
||||
dependencies = [
|
||||
"deranged",
|
||||
"js-sys",
|
||||
"libc",
|
||||
"num-conv",
|
||||
"num_threads",
|
||||
"powerfmt",
|
||||
"serde_core",
|
||||
"time-core",
|
||||
@@ -4614,6 +4708,34 @@ dependencies = [
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-rustls-acme"
|
||||
version = "0.9.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1af8573b15fdad8d66da116198cd8fd8d87ff62a67c1c6c3df7f62da1170793f"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"base64",
|
||||
"chrono",
|
||||
"futures",
|
||||
"log",
|
||||
"num-bigint",
|
||||
"pem",
|
||||
"proc-macro2",
|
||||
"rcgen",
|
||||
"reqwest",
|
||||
"ring",
|
||||
"rustls",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.18",
|
||||
"time",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"webpki-roots",
|
||||
"x509-parser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-stream"
|
||||
version = "0.1.18"
|
||||
@@ -4672,6 +4794,21 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml"
|
||||
version = "1.1.2+spec-1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee"
|
||||
dependencies = [
|
||||
"indexmap",
|
||||
"serde_core",
|
||||
"serde_spanned",
|
||||
"toml_datetime",
|
||||
"toml_parser",
|
||||
"toml_writer",
|
||||
"winnow 1.0.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_datetime"
|
||||
version = "1.1.1+spec-1.1.0"
|
||||
@@ -4702,6 +4839,12 @@ dependencies = [
|
||||
"winnow 1.0.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_writer"
|
||||
version = "1.1.1+spec-1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db"
|
||||
|
||||
[[package]]
|
||||
name = "tower"
|
||||
version = "0.5.3"
|
||||
@@ -4915,7 +5058,7 @@ dependencies = [
|
||||
"serde",
|
||||
"tempfile",
|
||||
"textwrap",
|
||||
"toml",
|
||||
"toml 0.5.11",
|
||||
"uniffi_internal_macros",
|
||||
"uniffi_meta",
|
||||
"uniffi_pipeline",
|
||||
@@ -4961,7 +5104,7 @@ dependencies = [
|
||||
"quote",
|
||||
"serde",
|
||||
"syn 2.0.118",
|
||||
"toml",
|
||||
"toml 0.5.11",
|
||||
"uniffi_meta",
|
||||
]
|
||||
|
||||
@@ -5037,6 +5180,12 @@ version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
|
||||
|
||||
[[package]]
|
||||
name = "utf8parse"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
|
||||
|
||||
[[package]]
|
||||
name = "uuid"
|
||||
version = "1.23.4"
|
||||
@@ -5061,43 +5210,6 @@ version = "0.2.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
|
||||
|
||||
[[package]]
|
||||
name = "vergen"
|
||||
version = "9.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b849a1f6d8639e8de261e81ee0fc881e3e3620db1af9f2e0da015d4382ceaf75"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"derive_builder",
|
||||
"rustversion",
|
||||
"vergen-lib",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "vergen-gitcl"
|
||||
version = "9.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "77ff3b5300a085d6bcd8fc96a507f706a28ae3814693236c9b409db71a1d15b9"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"derive_builder",
|
||||
"rustversion",
|
||||
"time",
|
||||
"vergen",
|
||||
"vergen-lib",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "vergen-lib"
|
||||
version = "9.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b34a29ba7e9c59e62f229ae1932fb1b8fb8a6fdcc99215a641913f5f5a59a569"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"derive_builder",
|
||||
"rustversion",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "version_check"
|
||||
version = "0.9.5"
|
||||
@@ -5117,6 +5229,7 @@ dependencies = [
|
||||
"futures-lite",
|
||||
"iroh",
|
||||
"iroh-blobs",
|
||||
"iroh-relay",
|
||||
"irpc",
|
||||
"irpc-iroh",
|
||||
"libc",
|
||||
@@ -5329,7 +5442,7 @@ version = "0.1.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.48.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
37
README.md
37
README.md
@@ -50,6 +50,42 @@ mobile networks. If a direct path cannot be established, it can forward the
|
||||
same end-to-end encrypted connection through a relay. The relay forwards
|
||||
encrypted packets; it is not a VniDrop file store.
|
||||
|
||||
### Custom relay servers
|
||||
|
||||
VniDrop uses Iroh's public relay and discovery infrastructure by default. In
|
||||
**Settings → Network**, users can select one of four policies:
|
||||
|
||||
- **Automatic (recommended):** use Iroh's public relays, with direct P2P/LAN
|
||||
connections whenever possible.
|
||||
- **Strict custom:** use only up to eight configured custom HTTPS relays or
|
||||
direct connections. Startup reports an error if none of the custom relays can
|
||||
be established.
|
||||
- **Custom with direct fallback:** prefer the configured custom relays, but
|
||||
continue with direct connections if they are unavailable.
|
||||
- **Local only:** disable every relay and allow direct connections only,
|
||||
primarily for devices on the same network.
|
||||
|
||||
Strict custom, custom with direct fallback, and local only never use public
|
||||
relays or public discovery, including relay addresses advertised by incoming
|
||||
invitations.
|
||||
|
||||
Applying a relay change restarts VniDrop's network engine, so active transfers
|
||||
and shares must be stopped first. The app tests the new configuration and
|
||||
restores the previous one if it cannot connect. Invitations created for an old
|
||||
relay configuration may need to be shared again; stopped shares never expose
|
||||
their stale invitations. If a long relay profile makes an invitation too large
|
||||
for a QR code, use the native share action or export the invitation file.
|
||||
|
||||
Relay credentials embedded in URLs are deliberately rejected and bearer-token
|
||||
authentication is not currently supported. A self-hosted relay must either
|
||||
accept the connecting endpoints or authorize their endpoint IDs independently;
|
||||
the current device ID is shown in **Settings → Network** for this purpose.
|
||||
Configure the same relay profile on participating devices. A custom relay needs
|
||||
a TLS certificate issued by a publicly trusted WebPKI certificate authority;
|
||||
private or enterprise CAs installed only in the operating system are not used
|
||||
in this version. For resilient deployments, configure at least two relays in
|
||||
different failure domains.
|
||||
|
||||
## Why Iroh and `iroh-blobs`?
|
||||
|
||||
VniDrop combines a networking layer with its own sharing rules:
|
||||
@@ -90,6 +126,7 @@ people, especially when using **Anyone with this transfer**.
|
||||
- Safe receive destinations that do not silently overwrite existing files
|
||||
- Native SwiftUI apps on iOS, iPadOS, and macOS; Compose apps on Android,
|
||||
Windows, and Linux
|
||||
- Strict custom HTTPS relay profiles with safe apply and rollback
|
||||
- Opt-in diagnostics with transfer contents, invitations, and file paths
|
||||
excluded
|
||||
|
||||
|
||||
34
apple/.swiftlint.yml
Normal file
34
apple/.swiftlint.yml
Normal file
@@ -0,0 +1,34 @@
|
||||
# Focused lint for the native app: enforce the typed-resources convention only
|
||||
# (no default style rules, so this stays signal, not noise).
|
||||
only_rules:
|
||||
- custom_rules
|
||||
|
||||
included:
|
||||
- VniDrop
|
||||
|
||||
excluded:
|
||||
- VniDrop/Generated
|
||||
|
||||
custom_rules:
|
||||
raw_localized_string:
|
||||
name: "Raw localized key"
|
||||
regex: 'String\(localized:\s*"'
|
||||
message: "Use a typed L10n.* accessor, not a raw key string."
|
||||
severity: warning
|
||||
raw_localized_string_key:
|
||||
name: "Raw LocalizedStringKey"
|
||||
regex: 'LocalizedStringKey\("'
|
||||
message: "Use a typed L10n.* accessor instead of a raw key."
|
||||
severity: warning
|
||||
raw_sf_symbol:
|
||||
name: "Raw SF Symbol"
|
||||
regex: 'system(Name|Image):\s*"'
|
||||
message: "Use SFSafeSymbols: Image(systemSymbol:) or systemSymbol:."
|
||||
severity: warning
|
||||
raw_swiftui_string_literal:
|
||||
name: "Raw SwiftUI string"
|
||||
# A non-empty string literal as the leading arg of a view initializer is an
|
||||
# implicit LocalizedStringKey. Empty labels (e.g. Picker("", …)) are allowed.
|
||||
regex: '\b(Text|Label|Button|Toggle|Link|NavigationLink|Section|Picker|Stepper|TextField|SecureField|DisclosureGroup|Menu|GroupBox)\("[^"]'
|
||||
message: "Pass a typed L10n.* accessor (or Text(verbatim:)), not a raw string literal."
|
||||
severity: warning
|
||||
@@ -20,6 +20,18 @@ final class AppModelTests: XCTestCase {
|
||||
_ = makeModel(core, preferences: Fixtures.preferences())
|
||||
await waitUntil { core.state.isInitialized }
|
||||
XCTAssertTrue(core.state.isInitialized)
|
||||
XCTAssertEqual(core.initializedNetworkConfigurations, [.automatic])
|
||||
}
|
||||
|
||||
func testInitializesCoreWithSavedCustomRelayConfiguration() async {
|
||||
let core = FakeCoreGateway()
|
||||
let preferences = Fixtures.preferences()
|
||||
let configuration = RelayConfiguration(mode: .strictCustom, relayURLs: ["https://relay.example"])
|
||||
preferences.setRelayConfiguration(configuration)
|
||||
_ = makeModel(core, preferences: preferences)
|
||||
|
||||
await waitUntil { core.state.isInitialized }
|
||||
XCTAssertEqual(core.initializedNetworkConfigurations, [configuration])
|
||||
}
|
||||
|
||||
func testSelectDestination() {
|
||||
|
||||
@@ -15,10 +15,11 @@ final class AppPreferencesRepositoryTests: XCTestCase {
|
||||
)
|
||||
}
|
||||
|
||||
func testFallbacksWhenEmpty() {
|
||||
func testMissingRelayProfileDefaultsToAutomatic() {
|
||||
let repo = AppPreferencesRepository(defaults: defaults(), fallback: fallback())
|
||||
XCTAssertEqual(repo.preferences.username, "Default")
|
||||
XCTAssertEqual(repo.preferences.themeMode, .system)
|
||||
XCTAssertEqual(repo.preferences.relayConfiguration, .automatic)
|
||||
}
|
||||
|
||||
func testValuesPersistAndReload() {
|
||||
@@ -28,6 +29,10 @@ final class AppPreferencesRepositoryTests: XCTestCase {
|
||||
repo.setUsername("Bob")
|
||||
repo.setThemeMode(.dark)
|
||||
repo.setReceiveFolder(ReceiveFolder(kind: .iosSecurityScopedUrl, value: "file:///x", displayName: "Custom"))
|
||||
repo.setRelayConfiguration(RelayConfiguration(
|
||||
mode: .strictCustom,
|
||||
relayURLs: ["https://relay-one.example", "https://relay-two.example:443"]
|
||||
))
|
||||
|
||||
// A fresh repository over the same store reflects the persisted values.
|
||||
let reloaded = AppPreferencesRepository(defaults: store, fallback: fb)
|
||||
@@ -35,6 +40,40 @@ final class AppPreferencesRepositoryTests: XCTestCase {
|
||||
XCTAssertEqual(reloaded.preferences.themeMode, .dark)
|
||||
XCTAssertEqual(reloaded.preferences.receiveFolder.displayName, "Custom")
|
||||
XCTAssertEqual(reloaded.preferences.receiveFolder.kind, .iosSecurityScopedUrl)
|
||||
XCTAssertEqual(reloaded.preferences.relayConfiguration, RelayConfiguration(
|
||||
mode: .strictCustom,
|
||||
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: .strictCustom, 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: .strictCustom, relayURLs: [])
|
||||
)
|
||||
}
|
||||
|
||||
func testResetReceiveFolderRestoresFallback() {
|
||||
|
||||
131
apple/Tests/CoreRepositoryLifecycleTests.swift
Normal file
131
apple/Tests/CoreRepositoryLifecycleTests.swift
Normal 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: .strictCustom, 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)
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,8 @@ final class FakeCoreGateway: CoreGateway {
|
||||
var cancelResult: Result<Void, Error> = .success(())
|
||||
var deleteResult: Result<Void, Error> = .success(())
|
||||
var clearReceiveHistoryResult: Result<UInt64, Error> = .success(0)
|
||||
var initializeResult: Result<Void, Error> = .success(())
|
||||
var initializeResults: [Result<Void, Error>] = []
|
||||
|
||||
// Recorded calls
|
||||
private(set) var responses: [(id: String, accepted: Bool, reason: String?)] = []
|
||||
@@ -35,11 +37,18 @@ final class FakeCoreGateway: CoreGateway {
|
||||
private(set) var lastReceiveTicket: String?
|
||||
private(set) var lastReceiveReceiverName: String?
|
||||
private(set) var lastShareAccessPolicy: ShareAccessPolicy?
|
||||
private(set) var initializedNetworkConfigurations: [RelayConfiguration] = []
|
||||
|
||||
func setState(_ state: CoreState) { stateSubject.send(state) }
|
||||
func emit(_ signal: CoreSignal) { signalsSubject.send(signal) }
|
||||
|
||||
func initialize(appDataDir: String) async -> Result<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
|
||||
s.isInitialized = true
|
||||
stateSubject.send(s)
|
||||
|
||||
121
apple/Tests/RelayConfigurationTests.swift
Normal file
121
apple/Tests/RelayConfigurationTests.swift
Normal file
@@ -0,0 +1,121 @@
|
||||
import XCTest
|
||||
@testable import VniDrop
|
||||
|
||||
final class RelayConfigurationTests: XCTestCase {
|
||||
func testCustomFallbackValidatesAndPreservesItsMode() throws {
|
||||
let result = try RelayConfigurationValidator.validate(
|
||||
mode: .customWithDirectFallback,
|
||||
relayURLs: ["https://relay.example/"]
|
||||
)
|
||||
|
||||
XCTAssertEqual(
|
||||
result,
|
||||
RelayConfiguration(
|
||||
mode: .customWithDirectFallback,
|
||||
relayURLs: ["https://relay.example"]
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
func testLocalOnlyRetainsPreviouslySavedRelayURLs() throws {
|
||||
let retained = ["https://relay.example"]
|
||||
let result = try RelayConfigurationValidator.validate(
|
||||
mode: .localOnly,
|
||||
relayURLs: ["not a URL"],
|
||||
retainedRelayURLs: retained
|
||||
)
|
||||
|
||||
XCTAssertEqual(result, RelayConfiguration(mode: .localOnly, relayURLs: retained))
|
||||
}
|
||||
|
||||
func testAutomaticModeIgnoresRelayDrafts() throws {
|
||||
let result = try RelayConfigurationValidator.validate(
|
||||
mode: .automatic,
|
||||
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: .strictCustom,
|
||||
relayURLs: [" https://relay.example/ ", "https://backup.example:443"]
|
||||
)
|
||||
XCTAssertEqual(result, RelayConfiguration(
|
||||
mode: .strictCustom,
|
||||
relayURLs: ["https://relay.example", "https://backup.example"]
|
||||
))
|
||||
}
|
||||
|
||||
func testCustomModeIgnoresEmptyURLRows() throws {
|
||||
let result = try RelayConfigurationValidator.validate(
|
||||
mode: .strictCustom,
|
||||
relayURLs: ["", " ", "https://relay.example"]
|
||||
)
|
||||
XCTAssertEqual(result.relayURLs, ["https://relay.example"])
|
||||
}
|
||||
|
||||
func testCustomModeRequiresAtLeastOneRelay() {
|
||||
XCTAssertThrowsError(try RelayConfigurationValidator.validate(mode: .strictCustom, relayURLs: [])) { error in
|
||||
XCTAssertEqual(error as? RelayConfigurationValidationError, .missingURL)
|
||||
}
|
||||
}
|
||||
|
||||
func testCustomModeRequiresHTTPS() {
|
||||
XCTAssertThrowsError(try RelayConfigurationValidator.validate(
|
||||
mode: .strictCustom,
|
||||
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: .strictCustom, relayURLs: [relayURL]),
|
||||
"Expected \(relayURL) to be rejected"
|
||||
) { error in
|
||||
XCTAssertEqual(error as? RelayConfigurationValidationError, .invalidURL(index: 0))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func testCustomModeRejectsNormalizedDuplicate() {
|
||||
XCTAssertThrowsError(try RelayConfigurationValidator.validate(
|
||||
mode: .strictCustom,
|
||||
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: .strictCustom, relayURLs: relayURLs)) { error in
|
||||
XCTAssertEqual(error as? RelayConfigurationValidationError, .tooManyURLs)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -58,4 +58,25 @@ final class SendModelTests: XCTestCase {
|
||||
let response = core.responses.first { $0.id == "req-1" }
|
||||
XCTAssertEqual(response?.accepted, false)
|
||||
}
|
||||
|
||||
func testOnlyActiveShareExposesStoredInvitationTicket() {
|
||||
XCTAssertEqual(
|
||||
Fixtures.transfer(id: 1, direction: .send, status: .sharing).invitationPresentation,
|
||||
.ready("ticket")
|
||||
)
|
||||
XCTAssertEqual(
|
||||
Fixtures.transfer(id: 2, direction: .send, status: .importing).invitationPresentation,
|
||||
.preparing
|
||||
)
|
||||
for status in [TransferStatus.stopped, .failed, .cancelled, .done] {
|
||||
XCTAssertEqual(
|
||||
Fixtures.transfer(id: 3, direction: .send, status: status).invitationPresentation,
|
||||
.unavailable
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func testOversizedInvitationReportsQRCodeUnavailable() {
|
||||
XCTAssertNil(QRCode.generate(from: String(repeating: "x", count: 10_000)))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,4 +42,107 @@ final class SettingsModelTests: XCTestCase {
|
||||
await waitUntil { core.deletedTransfers.count == 2 }
|
||||
XCTAssertEqual(Set(core.deletedTransfers), [2, 3])
|
||||
}
|
||||
|
||||
func testNetworkSettingsExposeCurrentEndpointId() {
|
||||
let core = FakeCoreGateway()
|
||||
let model = makeModel(core, preferences: Fixtures.preferences())
|
||||
|
||||
core.setState(CoreState(
|
||||
isInitialized: true,
|
||||
status: CoreStatus(endpointId: "endpoint-for-allowlist", activeTransfers: 0, activeShares: 0)
|
||||
))
|
||||
|
||||
XCTAssertEqual(model.state.endpointId, "endpoint-for-allowlist")
|
||||
}
|
||||
|
||||
func testApplyCustomRelayRestartsCoreThenPersistsConfiguration() async {
|
||||
let core = FakeCoreGateway()
|
||||
let preferences = Fixtures.preferences()
|
||||
let model = makeModel(core, preferences: preferences)
|
||||
model.setRelayMode(.strictCustom)
|
||||
model.setRelayURL(" https://relay.example/ ", at: 0)
|
||||
|
||||
model.applyRelayConfiguration()
|
||||
|
||||
await waitUntil { preferences.preferences.relayConfiguration.mode == .strictCustom }
|
||||
let expected = RelayConfiguration(mode: .strictCustom, relayURLs: ["https://relay.example"])
|
||||
XCTAssertEqual(preferences.preferences.relayConfiguration, expected)
|
||||
XCTAssertEqual(core.initializedNetworkConfigurations, [expected])
|
||||
XCTAssertFalse(model.state.relayConfigurationIsDirty)
|
||||
}
|
||||
|
||||
func testApplyingAutomaticRetainsLastCustomRelayURLs() async {
|
||||
let core = FakeCoreGateway()
|
||||
let preferences = Fixtures.preferences()
|
||||
let relayURLs = ["https://relay.example", "https://backup.example"]
|
||||
preferences.setRelayConfiguration(RelayConfiguration(mode: .strictCustom, 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(.strictCustom)
|
||||
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(.strictCustom)
|
||||
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: .strictCustom, relayURLs: ["https://relay.example"])
|
||||
model.setRelayMode(.strictCustom)
|
||||
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: .strictCustom, relayURLs: ["https://relay.example"])
|
||||
model.setRelayMode(.strictCustom)
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,4 +41,11 @@ final class TransferNotificationTests: XCTestCase {
|
||||
let requests = [Fixtures.request(id: "a", requestedAt: 1, status: .completed)]
|
||||
XCTAssertTrue(plannedReceiverNotifications(requests, published: ["receiver-completed-a"]).isEmpty)
|
||||
}
|
||||
|
||||
func testReceiverNotificationsFireForFailedReceivers() {
|
||||
let requests = [Fixtures.request(id: "x", requestedAt: 1, status: .failed)]
|
||||
let planned = plannedReceiverNotifications(requests, published: [])
|
||||
XCTAssertEqual(planned.map(\.id), ["receiver-failed-x"])
|
||||
XCTAssertEqual(planned.first?.kind, .receiverFailed)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,6 +63,14 @@ struct RootView: View {
|
||||
onRefuse: approvals.refuse
|
||||
)
|
||||
}
|
||||
.overlay {
|
||||
// A small, unobtrusive indicator while the core finishes its async
|
||||
// startup — otherwise the lists look empty and the app feels stalled.
|
||||
if !sendModel.coreState.isInitialized {
|
||||
CoreStartingOverlay()
|
||||
}
|
||||
}
|
||||
.animation(.easeInOut(duration: 0.25), value: sendModel.coreState.isInitialized)
|
||||
.vniDropTheme(isDark: isDark)
|
||||
.preferredColorScheme(appModel.themeMode.preferredColorScheme)
|
||||
.environment(\.vniColors, isDark ? .dark : .light)
|
||||
@@ -183,6 +191,32 @@ struct RootView: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// A full-window cover with a centered spinner shown while the core is starting.
|
||||
private struct CoreStartingOverlay: View {
|
||||
var body: some View {
|
||||
ZStack {
|
||||
backgroundColor.ignoresSafeArea()
|
||||
VStack(spacing: 16) {
|
||||
ProgressView().controlSize(.large)
|
||||
Text(String(localized: L10n.App.starting))
|
||||
.font(.headline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.transition(.opacity)
|
||||
.accessibilityElement(children: .combine)
|
||||
.accessibilityLabel(Text(String(localized: L10n.App.starting)))
|
||||
}
|
||||
|
||||
private var backgroundColor: Color {
|
||||
#if os(iOS)
|
||||
Color(uiColor: .systemBackground)
|
||||
#else
|
||||
Color(nsColor: .windowBackgroundColor)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
import UIKit
|
||||
#else
|
||||
|
||||
@@ -19,6 +19,101 @@ enum FolderAccessStatus {
|
||||
case unavailable
|
||||
}
|
||||
|
||||
enum RelayPreferenceMode: String, Codable, CaseIterable, Sendable {
|
||||
case automatic
|
||||
case strictCustom = "custom"
|
||||
case customWithDirectFallback = "custom-with-direct-fallback"
|
||||
case localOnly = "local-only"
|
||||
|
||||
var usesCustomRelayURLs: Bool {
|
||||
self == .strictCustom || self == .customWithDirectFallback
|
||||
}
|
||||
}
|
||||
|
||||
struct RelayConfiguration: Equatable, Codable, Sendable {
|
||||
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.usesCustomRelayURLs else {
|
||||
return RelayConfiguration(mode: mode, 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: mode, relayURLs: normalizedURLs)
|
||||
}
|
||||
}
|
||||
|
||||
/// Persisted app preferences, ported from `preferences/AppPreferencesRepository.kt`.
|
||||
/// Backed by `UserDefaults` instead of DataStore; keys and semantics match.
|
||||
struct AppPreferences: Equatable {
|
||||
@@ -27,6 +122,7 @@ struct AppPreferences: Equatable {
|
||||
var themeMode: ThemeMode
|
||||
var diagnosticsEnabled: Bool
|
||||
var diagnosticsInstallId: String
|
||||
var relayConfiguration: RelayConfiguration
|
||||
}
|
||||
|
||||
struct AppPreferencesDefaults {
|
||||
@@ -51,6 +147,7 @@ final class AppPreferencesRepository: ObservableObject {
|
||||
static let themeMode = "theme_mode"
|
||||
static let diagnosticsEnabled = "diagnostics_enabled"
|
||||
static let diagnosticsInstallId = "diagnostics_install_id"
|
||||
static let relayConfiguration = "relay_configuration"
|
||||
}
|
||||
|
||||
init(defaults: UserDefaults = .standard, fallback: AppPreferencesDefaults) {
|
||||
@@ -70,10 +167,24 @@ final class AppPreferencesRepository: ObservableObject {
|
||||
receiveFolder: folder,
|
||||
themeMode: themeMode,
|
||||
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. Strict
|
||||
// custom with no URLs makes startup fail closed until Settings repairs it.
|
||||
return RelayConfiguration(mode: .strictCustom, relayURLs: [])
|
||||
}
|
||||
return configuration
|
||||
}
|
||||
|
||||
private static func resolveReceiveFolder(_ defaults: UserDefaults, fallback: ReceiveFolder) -> ReceiveFolder {
|
||||
let kind = defaults.string(forKey: Key.receiveFolderKind)
|
||||
.flatMap(ReceiveFolderKind.init(rawValue:)) ?? fallback.kind
|
||||
@@ -113,6 +224,12 @@ final class AppPreferencesRepository: ObservableObject {
|
||||
reload()
|
||||
}
|
||||
|
||||
func setRelayConfiguration(_ configuration: RelayConfiguration) {
|
||||
guard let encoded = try? JSONEncoder().encode(configuration) else { return }
|
||||
defaults.set(encoded, forKey: Key.relayConfiguration)
|
||||
reload()
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func ensureDiagnosticsInstallId() -> String {
|
||||
let existing = preferences.diagnosticsInstallId
|
||||
|
||||
@@ -24,7 +24,7 @@ protocol CoreGateway: AnyObject {
|
||||
/// Coalesced change hints emitted by the event sink.
|
||||
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 shareSources(
|
||||
_ sources: [ShareSource],
|
||||
|
||||
@@ -134,6 +134,7 @@ enum ReceiverDeliveryStatus: Equatable, Sendable {
|
||||
case refused
|
||||
case expired
|
||||
case completed
|
||||
case failed
|
||||
case unknown
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,65 @@ import Foundation
|
||||
import Combine
|
||||
@preconcurrency import VnidropCore
|
||||
|
||||
enum CoreNetworkLifecycleError: Error, Equatable, LocalizedError, Sendable {
|
||||
case transitionInProgress
|
||||
case activeNetworkWork
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .transitionInProgress: return "A network restart is already in progress."
|
||||
case .activeNetworkWork: return "Stop active transfers and shares before restarting the network."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum CoreNetworkLifecycle {
|
||||
nonisolated static func requireIdle(activeTransfers: UInt64, activeShares: UInt64) throws {
|
||||
guard activeTransfers == 0, activeShares == 0 else {
|
||||
throw CoreNetworkLifecycleError.activeNetworkWork
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protocol CoreBindingFactory: Sendable {
|
||||
func initialize(
|
||||
appDataDir: String,
|
||||
eventSink: CoreEventSink,
|
||||
networkConfiguration: RelayConfiguration
|
||||
) throws -> VnidropCore
|
||||
}
|
||||
|
||||
struct NativeCoreBindingFactory: CoreBindingFactory {
|
||||
func initialize(
|
||||
appDataDir: String,
|
||||
eventSink: CoreEventSink,
|
||||
networkConfiguration: RelayConfiguration
|
||||
) throws -> VnidropCore {
|
||||
let nativeConfiguration: CoreNetworkConfig
|
||||
switch networkConfiguration.mode {
|
||||
case .automatic:
|
||||
nativeConfiguration = defaultCoreNetworkConfig()
|
||||
case .strictCustom:
|
||||
nativeConfiguration = CoreNetworkConfig(
|
||||
mode: .strictCustom,
|
||||
relayUrls: networkConfiguration.relayURLs
|
||||
)
|
||||
case .customWithDirectFallback:
|
||||
nativeConfiguration = CoreNetworkConfig(
|
||||
mode: .customWithDirectFallback,
|
||||
relayUrls: networkConfiguration.relayURLs
|
||||
)
|
||||
case .localOnly:
|
||||
nativeConfiguration = CoreNetworkConfig(mode: .localOnly, relayUrls: [])
|
||||
}
|
||||
return try VnidropCore.initializeWithNetworkConfig(
|
||||
appDataDir: appDataDir,
|
||||
eventSink: eventSink,
|
||||
networkConfig: nativeConfiguration
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Swift port of `core/CoreRepository.kt`. Owns the `VnidropCore` handle, maps the
|
||||
/// generated UniFFI records into app domain models, publishes an observable
|
||||
/// `CoreState`, and emits coalesced `CoreSignal`s from the event sink.
|
||||
@@ -17,28 +76,65 @@ final class CoreRepository: ObservableObject, CoreGateway {
|
||||
/// Coalesced change hints; subscribe to react to approval/history/transfer changes.
|
||||
var signals: AnyPublisher<CoreSignal, Never> { signalsSubject.eraseToAnyPublisher() }
|
||||
|
||||
// Set on the main actor (initialize/shutdown) but read from `queue` inside
|
||||
// `runCore`; the underlying core is internally synchronized, so this crossing
|
||||
// is safe. `nonisolated(unsafe)` documents that contract for Swift 6.
|
||||
// Initialization swaps happen on `queue`; shutdown and snapshot reads may also
|
||||
// access the handle from the main actor. The underlying core is internally
|
||||
// synchronized, and `nonisolated(unsafe)` documents that crossing for Swift 6.
|
||||
private nonisolated(unsafe) var core: VnidropCore?
|
||||
// Core calls run through `dispatcher` (see runCore/runInterrupt); the factory
|
||||
// and transition flag drive relay-aware (re)initialization.
|
||||
private let dispatcher = CoreDispatcher()
|
||||
private let coreFactory: any CoreBindingFactory
|
||||
private var isNetworkTransitionInProgress = false
|
||||
private lazy var sink = RepositoryEventSink { [weak self] event in
|
||||
Task { @MainActor in self?.handle(event: event) }
|
||||
}
|
||||
|
||||
private nonisolated static let maxEvents = 200
|
||||
|
||||
init(coreFactory: any CoreBindingFactory = NativeCoreBindingFactory()) {
|
||||
self.coreFactory = coreFactory
|
||||
}
|
||||
|
||||
// MARK: - Lifecycle
|
||||
|
||||
func initialize(appDataDir: String) async -> Result<Void, Error> {
|
||||
await runCore { [sink] in
|
||||
self.core?.shutdown()
|
||||
let created = try VnidropCore.initialize(appDataDir: appDataDir, eventSink: sink)
|
||||
return created
|
||||
}.map { created in
|
||||
func initialize(
|
||||
appDataDir: String,
|
||||
networkConfiguration: RelayConfiguration
|
||||
) async -> Result<Void, Error> {
|
||||
guard !isNetworkTransitionInProgress else {
|
||||
return .failure(CoreNetworkLifecycleError.transitionInProgress)
|
||||
}
|
||||
isNetworkTransitionInProgress = true
|
||||
defer { isNetworkTransitionInProgress = false }
|
||||
|
||||
let result = await runCore { [sink] in
|
||||
if let existing = self.core {
|
||||
let status = existing.status()
|
||||
try CoreNetworkLifecycle.requireIdle(
|
||||
activeTransfers: status.activeTransfers,
|
||||
activeShares: status.activeShares
|
||||
)
|
||||
existing.shutdown()
|
||||
self.core = nil
|
||||
}
|
||||
let created = try self.coreFactory.initialize(
|
||||
appDataDir: appDataDir,
|
||||
eventSink: sink,
|
||||
networkConfiguration: networkConfiguration
|
||||
)
|
||||
self.core = created
|
||||
return created
|
||||
}
|
||||
switch result {
|
||||
case .success:
|
||||
self.refreshSnapshot()
|
||||
self.state.isInitialized = true
|
||||
return .success(())
|
||||
case .failure(let error):
|
||||
if error as? CoreNetworkLifecycleError != .activeNetworkWork {
|
||||
self.state = CoreState()
|
||||
}
|
||||
return .failure(error)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,6 +152,9 @@ final class CoreRepository: ObservableObject, CoreGateway {
|
||||
senderName: String,
|
||||
accessPolicy: ShareAccessPolicy
|
||||
) async -> Result<Share, Error> {
|
||||
guard !isNetworkTransitionInProgress else {
|
||||
return .failure(CoreNetworkLifecycleError.transitionInProgress)
|
||||
}
|
||||
guard !sources.isEmpty else {
|
||||
return .failure(InvitationError.message("Select at least one file to share"))
|
||||
}
|
||||
@@ -89,7 +188,10 @@ final class CoreRepository: ObservableObject, CoreGateway {
|
||||
}
|
||||
|
||||
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(
|
||||
ticket: ticket,
|
||||
outputDir: outputDir,
|
||||
@@ -105,7 +207,10 @@ final class CoreRepository: ObservableObject, CoreGateway {
|
||||
outputDirectoryUrl: String,
|
||||
receiverName: String
|
||||
) async -> Result<Void, Error> {
|
||||
await runCore {
|
||||
guard !isNetworkTransitionInProgress else {
|
||||
return .failure(CoreNetworkLifecycleError.transitionInProgress)
|
||||
}
|
||||
return await runCore {
|
||||
try withSecurityScopedAccess(pathOrUrl: outputDirectoryUrl) {
|
||||
try self.requireCore().receive(
|
||||
ticket: ticket,
|
||||
@@ -409,6 +514,7 @@ private extension ReceiverRequest {
|
||||
case "refused": return .refused
|
||||
case "expired": return .expired
|
||||
case "completed": return .completed
|
||||
case "failed": return .failed
|
||||
default: return .unknown
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,27 @@ private final class NotificationPresenter: NSObject, UNUserNotificationCenterDel
|
||||
) async -> UNNotificationPresentationOptions {
|
||||
[.banner, .sound, .list]
|
||||
}
|
||||
|
||||
/// Handle a notification tap inside the running instance and bring the existing
|
||||
/// window forward, rather than letting the default launch behavior surface (which
|
||||
/// on macOS can spin up a second process). The approval/transfer UI is driven by
|
||||
/// core state, so activating the window is enough to reveal a pending approval.
|
||||
func userNotificationCenter(
|
||||
_ center: UNUserNotificationCenter,
|
||||
didReceive response: UNNotificationResponse
|
||||
) async {
|
||||
#if os(macOS)
|
||||
await MainActor.run {
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
// Reopen/focus the single main window (activation triggers SwiftUI's
|
||||
// reopen handling when it was closed).
|
||||
for window in NSApp.windows where window.canBecomeMain {
|
||||
window.makeKeyAndOrderFront(nil)
|
||||
break
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
/// Local notification service backed by `UNUserNotificationCenter`.
|
||||
|
||||
@@ -82,7 +82,9 @@ func progressForReceiver(
|
||||
labelKey: L10n.Progress.interrupted, progress: nil, detail: nil
|
||||
)
|
||||
}
|
||||
if latestKind == .completed && !transferEvents.contains(where: { $0.eventKind == .progress || $0.eventKind == .started }) {
|
||||
// Events are newest-first, so a completed latest event is terminal even when
|
||||
// progress/started events precede it — it must show as Completed, not Sending.
|
||||
if latestKind == .completed {
|
||||
return TransferProgress(
|
||||
transferId: transferId, phase: .transfer, kind: .completed,
|
||||
labelKey: L10n.Progress.completed, progress: 1, detail: nil
|
||||
|
||||
@@ -26,7 +26,10 @@ final class AppModel: ObservableObject {
|
||||
AppLogger.info("lifecycle", "app started", ["platform": environment.name])
|
||||
|
||||
Task {
|
||||
let result = await repository.initialize(appDataDir: environment.defaultCoreDataDir)
|
||||
let result = await repository.initialize(
|
||||
appDataDir: environment.defaultCoreDataDir,
|
||||
networkConfiguration: preferences.preferences.relayConfiguration
|
||||
)
|
||||
if case .failure(let error) = result { messages.error(error) }
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ enum TransferNotificationKind: Equatable {
|
||||
case receiveCompleted // An incoming transfer finished downloading.
|
||||
case receiveFailed // An incoming transfer failed.
|
||||
case receiverCompleted // A receiver finished downloading your shared transfer.
|
||||
case receiverFailed // A receiver's download of your shared transfer failed.
|
||||
}
|
||||
|
||||
/// A notification resolved from core state but not yet published. `transferName`
|
||||
@@ -39,11 +40,17 @@ func plannedTransferNotifications(_ transfers: [Transfer], published: Set<String
|
||||
/// transfer, excluding already-published ids.
|
||||
func plannedReceiverNotifications(_ requests: [ReceiverRequestModel], published: Set<String>) -> [PlannedNotification] {
|
||||
requests.compactMap { request in
|
||||
guard request.status == .completed else { return nil }
|
||||
let id = "receiver-completed-\(request.id)"
|
||||
let kind: TransferNotificationKind
|
||||
let idPrefix: String
|
||||
switch request.status {
|
||||
case .completed: kind = .receiverCompleted; idPrefix = "receiver-completed"
|
||||
case .failed: kind = .receiverFailed; idPrefix = "receiver-failed"
|
||||
default: return nil
|
||||
}
|
||||
let id = "\(idPrefix)-\(request.id)"
|
||||
guard !published.contains(id) else { return nil }
|
||||
return PlannedNotification(
|
||||
id: id, kind: .receiverCompleted,
|
||||
id: id, kind: kind,
|
||||
transferName: request.transferName,
|
||||
receiver: request.receiverName ?? request.receiverDeviceName
|
||||
)
|
||||
@@ -56,6 +63,7 @@ private func transferNotificationId(_ kind: TransferNotificationKind, transferId
|
||||
case .receiveCompleted: return "receive-completed-\(transferId)"
|
||||
case .receiveFailed: return "receive-failed-\(transferId)"
|
||||
case .receiverCompleted: return "receiver-completed-\(transferId)"
|
||||
case .receiverFailed: return "receiver-failed-\(transferId)"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -176,6 +184,12 @@ final class TransferNotificationCoordinator: ObservableObject {
|
||||
id: plan.id,
|
||||
title: String(localized: L10n.Notifications.receiverCompletedTitle),
|
||||
body: L10n.Notifications.receiverCompletedBody(receiver: receiver, transferName: name))
|
||||
case .receiverFailed:
|
||||
let receiver = plan.receiver ?? String(localized: L10n.Approval.nearbyDevice)
|
||||
notification = LocalNotification(
|
||||
id: plan.id,
|
||||
title: String(localized: L10n.Notifications.receiverFailedTitle),
|
||||
body: L10n.Notifications.receiverFailedBody(receiver: receiver, transferName: name))
|
||||
}
|
||||
if case .failure(let error) = await notifications.publish(notification) {
|
||||
messages.error(error)
|
||||
|
||||
@@ -79,6 +79,15 @@ struct ReceiveScreen: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.contextMenu {
|
||||
if transfer.status.isTerminalReceiveHistory {
|
||||
Button(role: .destructive) {
|
||||
model.requestDeleteHistoryItem(transfer.transferId)
|
||||
} label: {
|
||||
Label(String(localized: L10n.Button.deleteTransfer), systemSymbol: .trash)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
Text(String(localized: L10n.Receive.historyTitle))
|
||||
|
||||
@@ -228,6 +228,31 @@ final class SendModel: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
/// Deletes a transfer by id, independent of the detail selection — used by the
|
||||
/// list context menu so it can act inline without navigating into the detail.
|
||||
func deleteTransfer(id: UInt64) {
|
||||
if state.isDeleting { return }
|
||||
state.isDeleting = true
|
||||
Task {
|
||||
let result = await repository.delete(transferId: id)
|
||||
switch result {
|
||||
case .success:
|
||||
filePreviewRepository.remove(transferId: id)
|
||||
if state.selectedTransferId == id {
|
||||
state.selectedTransferId = nil
|
||||
state.detailPanel = nil
|
||||
state.receiverHistory = []
|
||||
}
|
||||
state.isDeleting = false
|
||||
_ = await repository.refresh()
|
||||
messages.tryShow(UiMessage(text: .resource(L10n.Transfer.deleted), tone: .success))
|
||||
case .failure(let error):
|
||||
state.isDeleting = false
|
||||
messages.error(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Cancels/refuses a single receiver by responding to its request negatively.
|
||||
/// Uses the core's `respondReceiverRequest` (no backend change); applies to
|
||||
/// receivers that are still pending or accepted.
|
||||
@@ -300,6 +325,13 @@ final class SendModel: ObservableObject {
|
||||
state.transferName = ""
|
||||
state.accessPolicy = .requireApproval
|
||||
state.isSharing = false
|
||||
// Jump straight to the new transfer's share panel (QR + delivery) rather
|
||||
// than dropping the user on the list to drill in manually. Refresh first
|
||||
// so the transfer exists in state before it's selected.
|
||||
_ = await repository.refresh()
|
||||
state.selectedTransferId = share.transferId
|
||||
state.detailPanel = .share
|
||||
refreshReceivers(share.transferId)
|
||||
messages.show(UiMessage(text: .resource(L10n.Send.transferCreated), tone: .success))
|
||||
case .failure(let error):
|
||||
state.isSharing = false
|
||||
|
||||
@@ -7,6 +7,11 @@ struct SendScreen: View {
|
||||
@ObservedObject var model: SendModel
|
||||
let windowClass: WindowClass
|
||||
|
||||
/// Transfer whose share panel is presented inline from the list context menu.
|
||||
@State private var shareTarget: Transfer?
|
||||
/// Transfer pending an inline (list-level) delete confirmation.
|
||||
@State private var deleteTarget: Transfer?
|
||||
|
||||
private var outgoing: [Transfer] {
|
||||
model.coreState.transfers.filter { $0.direction == .send }
|
||||
}
|
||||
@@ -41,6 +46,18 @@ struct SendScreen: View {
|
||||
detailView(for: transfer)
|
||||
}
|
||||
}
|
||||
// Attached inside the NavigationStack (a different sheet host than the
|
||||
// composer drawer on the outer body, so the two don't clash). Opens the
|
||||
// share panel over the list without navigating into the transfer detail.
|
||||
.adaptiveDrawer(
|
||||
isPresented: Binding(get: { shareTarget != nil }, set: { if !$0 { shareTarget = nil } }),
|
||||
windowClass: windowClass,
|
||||
onDismiss: { shareTarget = nil }
|
||||
) {
|
||||
if let shareTarget {
|
||||
TransferSharePanel(model: model, transfer: shareTarget)
|
||||
}
|
||||
}
|
||||
}
|
||||
.adaptiveDrawer(
|
||||
isPresented: Binding(get: { model.state.isComposerOpen }, set: { _ in }),
|
||||
@@ -49,8 +66,24 @@ struct SendScreen: View {
|
||||
) {
|
||||
TransferComposer(model: model, windowClass: windowClass)
|
||||
}
|
||||
.alert(
|
||||
Text(String(localized: L10n.Transfer.deleteTitle)),
|
||||
isPresented: Binding(get: { deleteTarget != nil }, set: { if !$0 { deleteTarget = nil } })
|
||||
) {
|
||||
Button(String(localized: L10n.Button.cancel), role: .cancel) { deleteTarget = nil }
|
||||
Button(String(localized: L10n.Button.deleteTransfer), role: .destructive) {
|
||||
if let target = deleteTarget { model.deleteTransfer(id: target.transferId) }
|
||||
deleteTarget = nil
|
||||
}
|
||||
} message: {
|
||||
if let target = deleteTarget {
|
||||
Text(L10n.Transfer.deleteDescription(
|
||||
transferName: target.transferName ?? String(localized: L10n.Send.newTransferTitle)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// The pushed transfer details view, with its detail-panel sheet and delete
|
||||
/// alert attached here so they present from the detail's own context (presenting
|
||||
/// modals from the parent stack while a detail is pushed is unreliable on macOS).
|
||||
@@ -91,6 +124,28 @@ struct SendScreen: View {
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.contextMenu {
|
||||
if transfer.ticket != nil {
|
||||
Button {
|
||||
shareTarget = transfer
|
||||
} label: {
|
||||
Label(String(localized: L10n.Transfer.shareTitle), systemSymbol: .squareAndArrowUp)
|
||||
}
|
||||
}
|
||||
if transfer.status == .sharing {
|
||||
Button(role: .destructive) {
|
||||
model.stopSharing(transferId: transfer.transferId)
|
||||
} label: {
|
||||
Label(String(localized: L10n.Send.stopSharing), systemSymbol: .stopCircle)
|
||||
}
|
||||
}
|
||||
Divider()
|
||||
Button(role: .destructive) {
|
||||
deleteTarget = transfer
|
||||
} label: {
|
||||
Label(String(localized: L10n.Button.deleteTransfer), systemSymbol: .trash)
|
||||
}
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
Text(String(localized: L10n.Send.transfersTitle))
|
||||
|
||||
@@ -75,29 +75,41 @@ struct TransferComposer: View {
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var actions: some View {
|
||||
let shareTitle = state.isSharing
|
||||
? String(localized: L10n.Button.sharingFile) : String(localized: L10n.Button.shareFile)
|
||||
let shareButton = PrimaryButton(
|
||||
title: shareTitle, action: model.createShare,
|
||||
enabled: state.canCreateShare(coreInitialized: model.coreState.isInitialized)
|
||||
)
|
||||
if windowClass == .phone {
|
||||
VStack(spacing: 8) {
|
||||
shareButton
|
||||
QuietButton(title: String(localized: L10n.Button.changeFiles), action: model.selectFile, enabled: !state.isSharing)
|
||||
QuietButton(title: String(localized: L10n.Button.chooseFolder), action: model.selectFolder, enabled: !state.isSharing)
|
||||
}
|
||||
} else {
|
||||
HStack(spacing: 8) {
|
||||
shareButton.fixedSize()
|
||||
QuietButton(title: String(localized: L10n.Button.changeFiles), action: model.selectFile, enabled: !state.isSharing)
|
||||
QuietButton(title: String(localized: L10n.Button.chooseFolder), action: model.selectFolder, enabled: !state.isSharing)
|
||||
QuietButton(title: String(localized: L10n.Button.clear), action: model.clearSelectedSource, enabled: !state.isSharing)
|
||||
return VStack(spacing: 10) {
|
||||
PrimaryButton(
|
||||
title: shareTitle, action: model.createShare,
|
||||
enabled: state.canCreateShare(coreInitialized: model.coreState.isInitialized)
|
||||
)
|
||||
// Secondary source actions as an even row of bordered buttons rather than
|
||||
// bare text links, so they read as controls and align with the primary.
|
||||
HStack(spacing: 10) {
|
||||
sourceButton(title: L10n.Button.changeFiles, symbol: .docBadgeArrowUp, action: model.selectFile)
|
||||
sourceButton(title: L10n.Button.chooseFolder, symbol: .folder, action: model.selectFolder)
|
||||
if windowClass != .phone {
|
||||
sourceButton(title: L10n.Button.clear, symbol: .xmark, action: model.clearSelectedSource)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func sourceButton(
|
||||
title: String.LocalizationValue, symbol: SFSymbol, action: @escaping () -> Void
|
||||
) -> some View {
|
||||
Button(action: action) {
|
||||
Label(String(localized: title), systemSymbol: symbol)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.85)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(minHeight: 20)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.large)
|
||||
.tint(.secondary)
|
||||
.disabled(state.isSharing)
|
||||
}
|
||||
}
|
||||
|
||||
private struct SelectedFileCard: View {
|
||||
|
||||
@@ -44,22 +44,19 @@ struct TransferDetailsView: View {
|
||||
count: pendingReceivers + completedReceivers,
|
||||
onTap: model.openReceivers
|
||||
)
|
||||
DetailDestination(
|
||||
title: String(localized: L10n.Transfer.shareTitle),
|
||||
description: String(localized: L10n.Transfer.shareDescription),
|
||||
count: 0,
|
||||
onTap: model.openShare
|
||||
)
|
||||
}
|
||||
|
||||
if isActiveShare {
|
||||
Section {
|
||||
Section {
|
||||
if isActiveShare {
|
||||
Button(role: .destructive) {
|
||||
showStopConfirmation = true
|
||||
} label: {
|
||||
Label(String(localized: L10n.Send.stopSharing), systemSymbol: .stopCircle)
|
||||
}
|
||||
}
|
||||
Button(role: .destructive, action: model.requestDeleteTransfer) {
|
||||
Label(String(localized: L10n.Button.deleteTransfer), systemSymbol: .trash)
|
||||
}
|
||||
}
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
@@ -69,9 +66,10 @@ struct TransferDetailsView: View {
|
||||
#endif
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .primaryAction) {
|
||||
Button(role: .destructive, action: model.requestDeleteTransfer) {
|
||||
Image(systemSymbol: .trash)
|
||||
Button(action: model.openShare) {
|
||||
Label(String(localized: L10n.Transfer.shareTitle), systemSymbol: .squareAndArrowUp)
|
||||
}
|
||||
.help(String(localized: L10n.Transfer.shareTitle))
|
||||
}
|
||||
}
|
||||
.confirmationDialog(
|
||||
@@ -114,7 +112,7 @@ private struct DetailDestination: View {
|
||||
}
|
||||
Spacer()
|
||||
if count > 0 {
|
||||
Text("\(count)")
|
||||
Text(verbatim: "\(count)")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
@@ -238,6 +236,7 @@ private struct ReceiverRow: View {
|
||||
let name = receiver.receiverName ?? receiver.receiverDeviceName ?? String(localized: L10n.Transfer.nearbyDevice)
|
||||
let showLive = sendProgress != nil && receiver.status != .completed
|
||||
&& receiver.status != .refused && receiver.status != .expired
|
||||
&& receiver.status != .failed
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text(name).font(VniType.bodyLarge).lineLimit(1)
|
||||
@@ -252,7 +251,8 @@ private struct ReceiverRow: View {
|
||||
.foregroundStyle(receiver.status.statusColor(colors))
|
||||
}
|
||||
if let reason = receiver.reason, !reason.isEmpty {
|
||||
Text(reason).font(VniType.bodySmall).foregroundStyle(colors.foregroundLighter)
|
||||
Text(receiverReasonUiText(reason).resolved())
|
||||
.font(VniType.bodySmall).foregroundStyle(colors.foregroundLighter)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
@@ -279,24 +279,39 @@ struct TransferSharePanel: View {
|
||||
|
||||
var body: some View {
|
||||
PanelContainer(title: String(localized: L10n.Transfer.shareTitle)) {
|
||||
if let ticket = transfer.ticket {
|
||||
qrCard(ticket: ticket)
|
||||
Text(String(localized: L10n.Transfer.scanQr))
|
||||
.font(VniType.bodySmall).foregroundStyle(colors.foregroundLighter)
|
||||
.frame(maxWidth: .infinity)
|
||||
switch transfer.invitationPresentation {
|
||||
case .ready(let ticket):
|
||||
let qrImage = QRCode.generate(from: ticket)
|
||||
qrCard(image: qrImage)
|
||||
if qrImage != nil {
|
||||
Text(String(localized: L10n.Transfer.scanQr))
|
||||
.font(VniType.bodySmall).foregroundStyle(colors.foregroundLighter)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
ShareActionsView(model: model, transfer: transfer, ticket: ticket)
|
||||
} else {
|
||||
case .preparing:
|
||||
Text(String(localized: L10n.Transfer.eventPreparing)).foregroundStyle(colors.foregroundLighter)
|
||||
case .unavailable:
|
||||
Text(String(localized: transfer.status == .failed ? L10n.Transfer.eventFailed : L10n.Transfer.eventStopped))
|
||||
.foregroundStyle(colors.foregroundLighter)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func qrCard(ticket: String) -> some View {
|
||||
private func qrCard(image: Image?) -> some View {
|
||||
ZStack {
|
||||
if let qr = QRCode.generate(from: ticket) {
|
||||
qr.interpolation(.none).resizable().scaledToFit().padding(14)
|
||||
if let image {
|
||||
image.interpolation(.none).resizable().scaledToFit().padding(14)
|
||||
} else {
|
||||
ProgressView()
|
||||
VStack(spacing: 10) {
|
||||
Image(systemSymbol: .qrcode)
|
||||
.font(.system(size: 36, weight: .medium))
|
||||
Text(String(localized: L10n.Transfer.qrUnavailable))
|
||||
.font(VniType.bodySmall)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
.foregroundStyle(.black.opacity(0.72))
|
||||
.padding(22)
|
||||
}
|
||||
}
|
||||
.frame(width: 268, height: 268)
|
||||
@@ -305,6 +320,26 @@ struct TransferSharePanel: View {
|
||||
}
|
||||
}
|
||||
|
||||
enum TransferInvitationPresentation: Equatable {
|
||||
case preparing
|
||||
case ready(String)
|
||||
case unavailable
|
||||
}
|
||||
|
||||
extension Transfer {
|
||||
var invitationPresentation: TransferInvitationPresentation {
|
||||
switch status {
|
||||
case .importing:
|
||||
return .preparing
|
||||
case .sharing:
|
||||
guard let ticket, !ticket.isEmpty else { return .preparing }
|
||||
return .ready(ticket)
|
||||
case .receiving, .done, .failed, .cancelled, .stopped:
|
||||
return .unavailable
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - QR generation (CoreImage)
|
||||
|
||||
enum QRCode {
|
||||
@@ -361,6 +396,7 @@ extension ReceiverDeliveryStatus {
|
||||
case .refused: return L10n.Transfer.receiverRefused
|
||||
case .expired: return L10n.Transfer.receiverExpired
|
||||
case .completed: return L10n.Transfer.receiverCompleted
|
||||
case .failed: return L10n.Transfer.receiverFailed
|
||||
case .unknown: return L10n.Transfer.receiverUnknown
|
||||
}
|
||||
}
|
||||
@@ -368,7 +404,7 @@ extension ReceiverDeliveryStatus {
|
||||
func statusColor(_ colors: VniDropColors) -> Color {
|
||||
switch self {
|
||||
case .completed: return colors.brandDefault
|
||||
case .refused, .expired: return colors.destructiveDefault
|
||||
case .refused, .expired, .failed: return colors.destructiveDefault
|
||||
default: return colors.foregroundLighter
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ enum SettingsSection: Hashable {
|
||||
case preferences
|
||||
case appearance
|
||||
case notifications
|
||||
case network
|
||||
case storage
|
||||
case about
|
||||
case bugReport
|
||||
@@ -17,6 +18,7 @@ enum SettingsSection: Hashable {
|
||||
case .preferences: return L10n.Preferences.title
|
||||
case .appearance: return L10n.Appearance.title
|
||||
case .notifications: return L10n.Notifications.title
|
||||
case .network: return L10n.Settings.networkTitle
|
||||
case .storage: return L10n.Storage.title
|
||||
case .about: return L10n.About.title
|
||||
case .bugReport: return L10n.About.bugReport
|
||||
@@ -43,6 +45,14 @@ struct SettingsState: Equatable {
|
||||
var themeMode: ThemeMode = .system
|
||||
var notificationPermission: NotificationPermission = .notDetermined
|
||||
var diagnosticsEnabled = false
|
||||
var relayMode: RelayPreferenceMode = .automatic
|
||||
var relayURLs: [String] = []
|
||||
var relayValidationError: RelayConfigurationValidationError?
|
||||
var relayConfigurationIsDirty = false
|
||||
var isApplyingRelayConfiguration = false
|
||||
var hasActiveNetworkWork = false
|
||||
var endpointId: String?
|
||||
var relayApplyErrorKey: String.LocalizationValue?
|
||||
var deviceInfo: DeviceInfo?
|
||||
var appVersion = ""
|
||||
var isLoadingDeviceInfo = false
|
||||
@@ -67,6 +77,13 @@ struct SettingsState: Equatable {
|
||||
&& lhs.themeMode == rhs.themeMode
|
||||
&& lhs.notificationPermission == rhs.notificationPermission
|
||||
&& lhs.diagnosticsEnabled == rhs.diagnosticsEnabled && lhs.appVersion == rhs.appVersion
|
||||
&& lhs.relayMode == rhs.relayMode && lhs.relayURLs == rhs.relayURLs
|
||||
&& lhs.relayValidationError == rhs.relayValidationError
|
||||
&& lhs.relayConfigurationIsDirty == rhs.relayConfigurationIsDirty
|
||||
&& lhs.isApplyingRelayConfiguration == rhs.isApplyingRelayConfiguration
|
||||
&& lhs.hasActiveNetworkWork == rhs.hasActiveNetworkWork
|
||||
&& lhs.endpointId == rhs.endpointId
|
||||
&& lhs.relayApplyErrorKey == rhs.relayApplyErrorKey
|
||||
&& lhs.isLoadingDeviceInfo == rhs.isLoadingDeviceInfo
|
||||
&& lhs.bugWhatHappened == rhs.bugWhatHappened && lhs.bugExpected == rhs.bugExpected
|
||||
&& lhs.bugSteps == rhs.bugSteps && lhs.bugContact == rhs.bugContact
|
||||
@@ -98,6 +115,7 @@ final class SettingsModel: ObservableObject {
|
||||
|
||||
private var usernamePersistTask: Task<Void, Never>?
|
||||
private var hasLocalUsernameDraft = false
|
||||
private var hasRelayConfigurationDraft = false
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
||||
init(
|
||||
@@ -134,10 +152,29 @@ final class SettingsModel: ObservableObject {
|
||||
self.state.receiveFolder = folder
|
||||
self.state.themeMode = prefs.themeMode
|
||||
self.state.diagnosticsEnabled = prefs.diagnosticsEnabled
|
||||
if !self.hasRelayConfigurationDraft {
|
||||
self.state.relayMode = prefs.relayConfiguration.mode
|
||||
self.state.relayURLs = prefs.relayConfiguration.relayURLs
|
||||
self.state.relayConfigurationIsDirty = false
|
||||
}
|
||||
if folder != previousFolder { Task { await self.validateFolder(folder) } }
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
repository.statePublisher
|
||||
.sink { [weak self] coreState in
|
||||
guard let self else { return }
|
||||
let hasActiveWork = (coreState.status?.activeTransfers ?? 0) > 0
|
||||
|| (coreState.status?.activeShares ?? 0) > 0
|
||||
|| coreState.transfers.contains(where: { $0.status.isActiveTransfer })
|
||||
self.state.hasActiveNetworkWork = hasActiveWork
|
||||
self.state.endpointId = coreState.status?.endpointId
|
||||
if !hasActiveWork && self.state.relayApplyErrorKey == L10n.Relay.applyActiveTransfers {
|
||||
self.state.relayApplyErrorKey = nil
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
refreshNotificationPermission()
|
||||
loadDeviceInfo()
|
||||
}
|
||||
@@ -204,6 +241,119 @@ final class SettingsModel: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Network
|
||||
|
||||
func setRelayMode(_ mode: RelayPreferenceMode) {
|
||||
hasRelayConfigurationDraft = true
|
||||
state.relayMode = mode
|
||||
if mode.usesCustomRelayURLs && 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 = L10n.Relay.applyActiveTransfers
|
||||
messages.show(UiMessage(text: .resource(L10n.Relay.applyActiveTransfers), 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(L10n.Relay.settingsApplied), tone: .success))
|
||||
case .failure(let error):
|
||||
if let lifecycleError = error as? CoreNetworkLifecycleError {
|
||||
state.isApplyingRelayConfiguration = false
|
||||
switch lifecycleError {
|
||||
case .activeNetworkWork:
|
||||
state.hasActiveNetworkWork = true
|
||||
state.relayApplyErrorKey = L10n.Relay.applyActiveTransfers
|
||||
case .transitionInProgress:
|
||||
state.relayApplyErrorKey = L10n.Relay.applyFailed
|
||||
}
|
||||
return
|
||||
}
|
||||
let rollbackResult = await repository.initialize(
|
||||
appDataDir: environment.defaultCoreDataDir,
|
||||
networkConfiguration: previousConfiguration
|
||||
)
|
||||
state.isApplyingRelayConfiguration = false
|
||||
if case .success = rollbackResult {
|
||||
state.relayApplyErrorKey = L10n.Relay.applyFailed
|
||||
messages.show(UiMessage(text: .resource(L10n.Relay.applyFailed), tone: .error))
|
||||
} else {
|
||||
state.relayApplyErrorKey = L10n.Relay.restoreFailed
|
||||
messages.show(UiMessage(text: .resource(L10n.Relay.restoreFailed), tone: .error))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func updateRelayConfigurationDraft() {
|
||||
state.relayValidationError = nil
|
||||
state.relayApplyErrorKey = nil
|
||||
let saved = preferences.preferences.relayConfiguration
|
||||
let draftURLs = state.relayMode.usesCustomRelayURLs ? state.relayURLs : saved.relayURLs
|
||||
state.relayConfigurationIsDirty = saved != RelayConfiguration(mode: state.relayMode, relayURLs: draftURLs)
|
||||
hasRelayConfigurationDraft = state.relayConfigurationIsDirty
|
||||
}
|
||||
|
||||
func setBugWhatHappened(_ value: String) { state.bugWhatHappened = value }
|
||||
func setBugExpected(_ value: String) { state.bugExpected = value }
|
||||
func setBugSteps(_ value: String) { state.bugSteps = value }
|
||||
|
||||
@@ -39,6 +39,17 @@ struct SettingsScreen: View {
|
||||
NavigationLink(value: SettingsSection.storage) {
|
||||
SettingsRow(icon: .internaldrive, title: String(localized: L10n.Storage.title), value: nil)
|
||||
}
|
||||
}
|
||||
Section(String(localized: L10n.Settings.advancedTitle)) {
|
||||
NavigationLink(value: SettingsSection.network) {
|
||||
SettingsRow(
|
||||
icon: .network,
|
||||
title: String(localized: L10n.Settings.networkTitle),
|
||||
value: relayModeLabel(model.state.relayMode)
|
||||
)
|
||||
}
|
||||
}
|
||||
Section {
|
||||
NavigationLink(value: SettingsSection.about) {
|
||||
SettingsRow(icon: .infoCircle, title: String(localized: L10n.About.title), value: nil)
|
||||
}
|
||||
@@ -100,6 +111,8 @@ private struct SettingsSectionContent: View {
|
||||
AppearanceSettings(model: model)
|
||||
case .notifications:
|
||||
NotificationSettings(model: model)
|
||||
case .network:
|
||||
NetworkSettings(model: model)
|
||||
case .storage:
|
||||
StorageSettings(model: model)
|
||||
case .about:
|
||||
@@ -110,6 +123,24 @@ private struct SettingsSectionContent: View {
|
||||
}
|
||||
}
|
||||
|
||||
func relayModeLabel(_ mode: RelayPreferenceMode) -> String {
|
||||
switch mode {
|
||||
case .automatic: return String(localized: L10n.Relay.modeAutomatic)
|
||||
case .strictCustom: return String(localized: L10n.Relay.modeCustom)
|
||||
case .customWithDirectFallback: return String(localized: L10n.Relay.modeCustomDirectFallback)
|
||||
case .localOnly: return String(localized: L10n.Relay.modeLocalOnly)
|
||||
}
|
||||
}
|
||||
|
||||
func relayModeDescription(_ mode: RelayPreferenceMode) -> String.LocalizationValue {
|
||||
switch mode {
|
||||
case .automatic: return L10n.Relay.modeAutomaticDescription
|
||||
case .strictCustom: return L10n.Relay.modeCustomDescription
|
||||
case .customWithDirectFallback: return L10n.Relay.modeCustomDirectFallbackDescription
|
||||
case .localOnly: return L10n.Relay.modeLocalOnlyDescription
|
||||
}
|
||||
}
|
||||
|
||||
struct SettingsRow: View {
|
||||
let icon: SFSymbol
|
||||
let title: String
|
||||
|
||||
@@ -74,6 +74,173 @@ struct NotificationSettings: View {
|
||||
}
|
||||
}
|
||||
|
||||
struct NetworkSettings: View {
|
||||
@ObservedObject var model: SettingsModel
|
||||
|
||||
var body: some View {
|
||||
Section {
|
||||
Picker(
|
||||
"",
|
||||
selection: Binding(get: { model.state.relayMode }, set: { model.setRelayMode($0) })
|
||||
) {
|
||||
ForEach(RelayPreferenceMode.allCases, id: \.self) { mode in
|
||||
Text(relayModeLabel(mode)).tag(mode)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.inline)
|
||||
.labelsHidden()
|
||||
.disabled(model.state.isApplyingRelayConfiguration)
|
||||
} header: {
|
||||
Text(String(localized: L10n.Settings.networkTitle))
|
||||
} footer: {
|
||||
Text(String(localized: relayModeDescription(model.state.relayMode)))
|
||||
}
|
||||
|
||||
Section {
|
||||
Label {
|
||||
Text(String(localized: L10n.Relay.privacyDescription))
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
} icon: {
|
||||
Image(systemSymbol: .lockShield)
|
||||
}
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
if let endpointId = model.state.endpointId, !endpointId.isEmpty {
|
||||
Section {
|
||||
Text(L10n.Approval.endpointId(deviceId: endpointId))
|
||||
.font(.footnote.monospaced())
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
}
|
||||
|
||||
if model.state.relayMode.usesCustomRelayURLs {
|
||||
Section {
|
||||
if model.state.relayMode == .strictCustom {
|
||||
Label {
|
||||
Text(String(localized: L10n.Relay.strictWarning))
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
} icon: {
|
||||
Image(systemSymbol: .exclamationmarkShieldFill)
|
||||
}
|
||||
.foregroundStyle(.orange)
|
||||
}
|
||||
|
||||
ForEach(Array(model.state.relayURLs.indices), id: \.self) { index in
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack {
|
||||
TextField(
|
||||
"",
|
||||
text: Binding(
|
||||
get: {
|
||||
model.state.relayURLs.indices.contains(index)
|
||||
? model.state.relayURLs[index]
|
||||
: ""
|
||||
},
|
||||
set: { model.setRelayURL($0, at: index) }
|
||||
),
|
||||
// `Text(verbatim:)` avoids macOS markdown-linkifying the
|
||||
// URL-shaped placeholder into a purple link.
|
||||
prompt: Text(verbatim: "https://relay.example.com")
|
||||
)
|
||||
.labelsHidden()
|
||||
#if os(iOS)
|
||||
.keyboardType(.URL)
|
||||
.textInputAutocapitalization(.never)
|
||||
#endif
|
||||
.autocorrectionDisabled()
|
||||
.disabled(model.state.isApplyingRelayConfiguration)
|
||||
|
||||
Button(role: .destructive) {
|
||||
model.removeRelayURL(at: index)
|
||||
} label: {
|
||||
Image(systemSymbol: .minusCircleFill)
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
.accessibilityLabel(Text(String(localized: L10n.Relay.removeUrl)))
|
||||
.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: L10n.Relay.addUrl), systemSymbol: .plusCircle)
|
||||
}
|
||||
.disabled(
|
||||
model.state.relayURLs.count >= RelayConfigurationValidator.maximumRelayCount
|
||||
|| model.state.isApplyingRelayConfiguration
|
||||
)
|
||||
} header: {
|
||||
Text(String(localized: L10n.Relay.customUrlsLabel))
|
||||
} footer: {
|
||||
Text(String(localized: L10n.Relay.customUrlsHelp))
|
||||
}
|
||||
}
|
||||
|
||||
if let error = model.state.relayValidationError, error.urlIndex == nil {
|
||||
Section {
|
||||
Label {
|
||||
Text(relayValidationMessage(error))
|
||||
} icon: {
|
||||
Image(systemSymbol: .exclamationmarkTriangleFill)
|
||||
}
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
}
|
||||
|
||||
if model.state.hasActiveNetworkWork || model.state.relayApplyErrorKey != nil {
|
||||
Section {
|
||||
Label {
|
||||
Text(String(localized: model.state.hasActiveNetworkWork ? L10n.Relay.applyActiveTransfers : (model.state.relayApplyErrorKey ?? L10n.Relay.applyFailed)))
|
||||
} icon: {
|
||||
Image(systemSymbol: .exclamationmarkTriangleFill)
|
||||
}
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
Button(action: model.applyRelayConfiguration) {
|
||||
HStack {
|
||||
Text(String(localized: model.state.isApplyingRelayConfiguration ? L10n.Relay.applying : L10n.Relay.apply))
|
||||
if model.state.isApplyingRelayConfiguration {
|
||||
Spacer()
|
||||
ProgressView()
|
||||
}
|
||||
}
|
||||
}
|
||||
.disabled(
|
||||
!model.state.relayConfigurationIsDirty
|
||||
|| model.state.isApplyingRelayConfiguration
|
||||
|| model.state.hasActiveNetworkWork
|
||||
)
|
||||
} footer: {
|
||||
Text(String(localized: L10n.Relay.applyRestartDescription))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func relayValidationMessage(_ error: RelayConfigurationValidationError) -> String {
|
||||
switch error {
|
||||
case .missingURL:
|
||||
return String(localized: L10n.Relay.validationMissingUrl)
|
||||
case .tooManyURLs:
|
||||
return L10n.Relay.validationTooManyUrls(maximum: RelayConfigurationValidator.maximumRelayCount)
|
||||
case .httpsRequired(let index):
|
||||
return L10n.Relay.validationHttpsRequired(line: index + 1)
|
||||
case .invalidURL(let index):
|
||||
return L10n.Relay.validationInvalidUrl(line: index + 1)
|
||||
case .duplicateURL(let index):
|
||||
return L10n.Relay.validationDuplicateUrl(line: index + 1)
|
||||
}
|
||||
}
|
||||
|
||||
struct StorageSettings: View {
|
||||
@ObservedObject var model: SettingsModel
|
||||
@State private var showDeleteConfirmation = false
|
||||
|
||||
@@ -42,7 +42,7 @@ struct ProgressRow: View {
|
||||
label.font(.subheadline).lineLimit(1)
|
||||
Spacer()
|
||||
if let progress {
|
||||
Text("\(Int(progress * 100))%").font(.caption).foregroundStyle(.secondary)
|
||||
Text(verbatim: "\(Int(progress * 100))%").font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
if let detail {
|
||||
|
||||
@@ -76,6 +76,39 @@ extension Error {
|
||||
}
|
||||
}
|
||||
|
||||
/// Maps a receiver delivery/refusal reason code to a user-facing message, never
|
||||
/// surfacing the raw core code (e.g. `destination_exists`). Unknown codes fall back
|
||||
/// to the substring hints, then a generic message.
|
||||
func receiverReasonUiText(_ reason: String) -> UiText {
|
||||
switch reason {
|
||||
case "destination_exists":
|
||||
return .resource(L10n.Error.destinationExists)
|
||||
case "filesystem", "filesystem_permission_denied":
|
||||
return .resource(L10n.Error.filesystem)
|
||||
case "permission_denied", "approval-required", "approval-expired",
|
||||
"unknown-transfer", "missing-endpoint-id", "invalid-receipt":
|
||||
return .resource(L10n.Error.permission)
|
||||
case "storage_full":
|
||||
return .resource(L10n.Error.storageFull)
|
||||
case "network":
|
||||
return .resource(L10n.Error.network)
|
||||
case "invalid_ticket":
|
||||
return .resource(L10n.Error.invalidTicket)
|
||||
case "transfer":
|
||||
return .resource(L10n.Error.transfer)
|
||||
case "repository", "repository-error":
|
||||
return .resource(L10n.Error.repository)
|
||||
case "invalid_input":
|
||||
return .resource(L10n.Error.invalidInput)
|
||||
case "initialization":
|
||||
return .resource(L10n.Error.initialization)
|
||||
case "cancelled", "internal":
|
||||
return .resource(L10n.Error.generic)
|
||||
default:
|
||||
return reasonHints(reason) ?? .resource(L10n.Error.generic)
|
||||
}
|
||||
}
|
||||
|
||||
private func transferUiText(_ reason: String) -> UiText {
|
||||
let detail = reason.lowercased()
|
||||
if detail.contains("refused") || detail.contains("denied") || detail.contains("not approved") {
|
||||
|
||||
@@ -69,6 +69,21 @@ targets:
|
||||
- sdk: SystemConfiguration.framework
|
||||
- sdk: Security.framework
|
||||
- sdk: libresolv.tbd
|
||||
preBuildScripts:
|
||||
# Enforce the typed-resources convention (see .swiftlint.yml). Required: fails
|
||||
# the build if SwiftLint is missing so the rules can't be silently bypassed.
|
||||
- name: SwiftLint (typed resources)
|
||||
basedOnDependencyAnalysis: false
|
||||
script: |
|
||||
# Xcode runs build phases with a minimal PATH that omits Homebrew, so add
|
||||
# the common Homebrew bin dirs (Apple Silicon + Intel) before resolving it.
|
||||
export PATH="/opt/homebrew/bin:/usr/local/bin:$PATH"
|
||||
if which swiftlint >/dev/null; then
|
||||
swiftlint lint --config "${SRCROOT}/.swiftlint.yml"
|
||||
else
|
||||
echo "error: SwiftLint not installed — run 'brew install swiftlint'"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
VniDropTests:
|
||||
type: bundle.unit-test
|
||||
|
||||
@@ -91,6 +91,11 @@ or temporary tag. Receive downloads keep a temporary tag through export and beco
|
||||
reclaimable after publication. Restart reconciliation repairs active-share tags,
|
||||
removes orphan share tags, and never restores a stopped share.
|
||||
|
||||
The explicit transfer-cache action is available only when no transfer or share is
|
||||
active. It shuts the core down cleanly, removes the app-owned blob store, and then
|
||||
restarts the core with the same identity and network configuration. Deleting all
|
||||
transfer records invokes the same cleanup after live shares have been stopped.
|
||||
|
||||
## Resource Limits
|
||||
|
||||
`CoreLimits` controls source count, collection files and bytes, path and ticket
|
||||
|
||||
@@ -16,7 +16,7 @@ blake3 = "1.8.3"
|
||||
data-encoding = "2.11.0"
|
||||
futures = "0.3"
|
||||
futures-lite = "2.6.1"
|
||||
iroh = "1.0.0"
|
||||
iroh = "1.0.3"
|
||||
iroh-blobs = "0.103.0"
|
||||
irpc = "0.17.0"
|
||||
irpc-iroh = "0.17.0"
|
||||
@@ -36,4 +36,5 @@ uuid = { version = "1.23.3", features = ["v4", "serde"] }
|
||||
walkdir = "2.5.0"
|
||||
|
||||
[dev-dependencies]
|
||||
iroh-relay = { version = "1.0.3", features = ["server"] }
|
||||
tempfile = "3.27.0"
|
||||
|
||||
@@ -1,9 +1,175 @@
|
||||
use anyhow::Context;
|
||||
use iroh::RelayUrl;
|
||||
use iroh_blobs::Hash;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::{collections::BTreeSet, net::IpAddr, path::PathBuf, str::FromStr};
|
||||
|
||||
use crate::error::VnidropError;
|
||||
use crate::util::{non_empty, now_ms};
|
||||
|
||||
pub(crate) const MAX_CUSTOM_RELAYS: usize = 8;
|
||||
pub(crate) const MAX_RELAY_URL_BYTES: usize = 2_048;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, uniffi::Enum)]
|
||||
pub enum CoreRelayMode {
|
||||
Automatic,
|
||||
StrictCustom,
|
||||
CustomWithDirectFallback,
|
||||
LocalOnly,
|
||||
}
|
||||
|
||||
#[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 | CoreRelayMode::LocalOnly => {
|
||||
if !self.relay_urls.is_empty() {
|
||||
anyhow::bail!(
|
||||
"{} relay mode must not include custom relay URLs",
|
||||
match self.mode {
|
||||
CoreRelayMode::Automatic => "automatic",
|
||||
CoreRelayMode::LocalOnly => "local-only",
|
||||
CoreRelayMode::StrictCustom
|
||||
| CoreRelayMode::CustomWithDirectFallback => unreachable!(),
|
||||
}
|
||||
);
|
||||
}
|
||||
Ok(Vec::new())
|
||||
}
|
||||
CoreRelayMode::StrictCustom | CoreRelayMode::CustomWithDirectFallback => {
|
||||
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()
|
||||
}
|
||||
|
||||
/// Removes the blob store after its owning core has shut down.
|
||||
///
|
||||
/// Callers must verify that no transfer or share is active before shutdown.
|
||||
#[uniffi::export]
|
||||
pub fn clear_inactive_transfer_cache(app_data_dir: String) -> Result<u64, VnidropError> {
|
||||
let result = (|| -> anyhow::Result<u64> {
|
||||
let app_data_dir = PathBuf::from(app_data_dir);
|
||||
anyhow::ensure!(
|
||||
app_data_dir.is_absolute(),
|
||||
"app data directory must be absolute"
|
||||
);
|
||||
anyhow::ensure!(
|
||||
app_data_dir.file_name().is_some(),
|
||||
"app data directory must not be a filesystem root"
|
||||
);
|
||||
let blobs = app_data_dir.join("blobs");
|
||||
let metadata = match std::fs::symlink_metadata(&blobs) {
|
||||
Ok(metadata) => metadata,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(0),
|
||||
Err(error) => return Err(error.into()),
|
||||
};
|
||||
anyhow::ensure!(
|
||||
metadata.is_dir() && !metadata.file_type().is_symlink(),
|
||||
"blob store path is not a directory"
|
||||
);
|
||||
let bytes = walkdir::WalkDir::new(&blobs)
|
||||
.follow_links(false)
|
||||
.into_iter()
|
||||
.try_fold(0u64, |total, entry| {
|
||||
let entry = entry?;
|
||||
let metadata = entry.metadata()?;
|
||||
Ok::<_, walkdir::Error>(if metadata.is_file() {
|
||||
total.saturating_add(metadata.len())
|
||||
} else {
|
||||
total
|
||||
})
|
||||
})?;
|
||||
std::fs::remove_dir_all(blobs)?;
|
||||
Ok(bytes)
|
||||
})();
|
||||
result.map_err(VnidropError::filesystem)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)]
|
||||
pub struct CoreLimits {
|
||||
pub max_sources: u64,
|
||||
|
||||
@@ -8,7 +8,10 @@ use uuid::Uuid;
|
||||
use crate::{
|
||||
access_policy::{AccessPolicy, APPROVAL_SESSION_TTL_MS},
|
||||
event_hub::EventHub,
|
||||
handshake::{DeliveryReceipt, DeliveryReceiptResponse, HandshakeResponse, RequestTransfer},
|
||||
handshake::{
|
||||
DeliveryFailureReceipt, DeliveryReceipt, DeliveryReceiptResponse, HandshakeResponse,
|
||||
RequestTransfer,
|
||||
},
|
||||
repository::{ReceiverRequestInsert, Repository},
|
||||
transfer_state::ReceiverRequestStatus,
|
||||
util::now_ms,
|
||||
@@ -70,6 +73,46 @@ impl ApprovalService {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn fail_delivery(
|
||||
&self,
|
||||
remote_endpoint_id: String,
|
||||
receipt: DeliveryFailureReceipt,
|
||||
) -> DeliveryReceiptResponse {
|
||||
let token_hash = receipt_token_hash(&receipt.token);
|
||||
match self
|
||||
.repository
|
||||
.fail_receiver_delivery(
|
||||
&receipt.request_id,
|
||||
receipt.transfer_id,
|
||||
&remote_endpoint_id,
|
||||
&token_hash,
|
||||
&receipt.reason,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(()) => {
|
||||
self.event_hub.emit_transfer(
|
||||
receipt.transfer_id,
|
||||
"send",
|
||||
"delivery",
|
||||
"receiver-failed",
|
||||
json!({
|
||||
"request_id": receipt.request_id,
|
||||
"remote_endpoint_id": remote_endpoint_id,
|
||||
"reason": receipt.reason,
|
||||
}),
|
||||
);
|
||||
DeliveryReceiptResponse::Recorded
|
||||
}
|
||||
Err(error) => {
|
||||
tracing::warn!(%error, "rejected receiver delivery failure");
|
||||
DeliveryReceiptResponse::Rejected {
|
||||
reason: "invalid-receipt".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn new(
|
||||
repository: Repository,
|
||||
event_hub: Arc<EventHub>,
|
||||
|
||||
@@ -72,6 +72,14 @@ impl ProtocolHandler for HandshakeService {
|
||||
.await;
|
||||
let _ = tx.send(response).await;
|
||||
}
|
||||
HandshakeMessage::ReportDeliveryFailure(message) => {
|
||||
let WithChannels { inner, tx, .. } = message;
|
||||
let response = self
|
||||
.approval
|
||||
.fail_delivery(remote_endpoint_id.clone(), inner)
|
||||
.await;
|
||||
let _ = tx.send(response).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -109,6 +117,13 @@ impl HandshakeClient {
|
||||
) -> Result<DeliveryReceiptResponse, irpc::Error> {
|
||||
self.inner.rpc(receipt).await
|
||||
}
|
||||
|
||||
pub(crate) async fn report_delivery_failure(
|
||||
&self,
|
||||
receipt: DeliveryFailureReceipt,
|
||||
) -> Result<DeliveryReceiptResponse, irpc::Error> {
|
||||
self.inner.rpc(receipt).await
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -140,6 +155,14 @@ pub(crate) struct DeliveryReceipt {
|
||||
pub(crate) token: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub(crate) struct DeliveryFailureReceipt {
|
||||
pub(crate) request_id: String,
|
||||
pub(crate) transfer_id: u64,
|
||||
pub(crate) token: String,
|
||||
pub(crate) reason: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub(crate) enum DeliveryReceiptResponse {
|
||||
Recorded,
|
||||
@@ -153,4 +176,6 @@ enum HandshakeProtocol {
|
||||
RequestTransfer(RequestTransfer),
|
||||
#[rpc(tx=oneshot::Sender<DeliveryReceiptResponse>)]
|
||||
ReportDelivery(DeliveryReceipt),
|
||||
#[rpc(tx=oneshot::Sender<DeliveryReceiptResponse>)]
|
||||
ReportDeliveryFailure(DeliveryFailureReceipt),
|
||||
}
|
||||
|
||||
@@ -14,7 +14,8 @@ mod transfer_state;
|
||||
mod util;
|
||||
|
||||
pub use api::{
|
||||
default_core_limits, CoreEvent, CoreEventSink, CoreLimits, CoreStorageUsage, PublishedOutput,
|
||||
clear_inactive_transfer_cache, default_core_limits, default_core_network_config, CoreEvent,
|
||||
CoreEventSink, CoreLimits, CoreNetworkConfig, CoreRelayMode, CoreStorageUsage, PublishedOutput,
|
||||
ReceiveOutputSink, ReceiveOutputSinkV2, ReceivedArtifact, ReceivedLocatorKind, ReceiverRequest,
|
||||
RuntimeStatus, ShareMetadataInput, ShareResult, ShareSource, SourceKind, StoredTransfer,
|
||||
TicketInspection, TransferAccessMode, TransferMetadata,
|
||||
|
||||
@@ -20,7 +20,7 @@ use crate::{
|
||||
util::now_ms,
|
||||
};
|
||||
|
||||
const SCHEMA_VERSION: i64 = 6;
|
||||
const SCHEMA_VERSION: i64 = 7;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct Repository {
|
||||
@@ -49,6 +49,7 @@ pub(crate) struct PersistedShare {
|
||||
pub(crate) transfer_id: u64,
|
||||
pub(crate) local_id: String,
|
||||
pub(crate) content_hash: String,
|
||||
pub(crate) ticket: Option<String>,
|
||||
pub(crate) access_mode: String,
|
||||
}
|
||||
|
||||
@@ -85,6 +86,7 @@ pub(crate) struct PendingDeliveryReceipt {
|
||||
pub(crate) request_id: String,
|
||||
pub(crate) sender_transfer_id: u64,
|
||||
pub(crate) token: String,
|
||||
pub(crate) failure_reason: Option<String>,
|
||||
}
|
||||
|
||||
pub(crate) struct PendingDeliveryReceiptInsert<'a> {
|
||||
@@ -93,6 +95,7 @@ pub(crate) struct PendingDeliveryReceiptInsert<'a> {
|
||||
pub(crate) request_id: &'a str,
|
||||
pub(crate) sender_transfer_id: u64,
|
||||
pub(crate) token: &'a str,
|
||||
pub(crate) failure_reason: Option<&'a str>,
|
||||
}
|
||||
|
||||
impl Repository {
|
||||
@@ -293,12 +296,24 @@ impl Repository {
|
||||
sender_blob_ticket TEXT NOT NULL,
|
||||
sender_transfer_id INTEGER NOT NULL,
|
||||
token TEXT NOT NULL,
|
||||
failure_reason TEXT,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
"#,
|
||||
)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
let receipt_columns = sqlx::query("PRAGMA table_info(pending_delivery_receipts)")
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
if !receipt_columns
|
||||
.iter()
|
||||
.any(|row| row.get::<String, _>(1) == "failure_reason")
|
||||
{
|
||||
sqlx::query("ALTER TABLE pending_delivery_receipts ADD COLUMN failure_reason TEXT")
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
}
|
||||
|
||||
sqlx::query(&format!("PRAGMA user_version = {SCHEMA_VERSION}"))
|
||||
.execute(&self.pool)
|
||||
@@ -560,13 +575,14 @@ impl Repository {
|
||||
r#"
|
||||
INSERT INTO pending_delivery_receipts (
|
||||
request_id, local_transfer_id, sender_blob_ticket,
|
||||
sender_transfer_id, token, created_at
|
||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6)
|
||||
sender_transfer_id, token, failure_reason, created_at
|
||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
|
||||
ON CONFLICT(request_id) DO UPDATE SET
|
||||
local_transfer_id = excluded.local_transfer_id,
|
||||
sender_blob_ticket = excluded.sender_blob_ticket,
|
||||
sender_transfer_id = excluded.sender_transfer_id,
|
||||
token = excluded.token
|
||||
token = excluded.token,
|
||||
failure_reason = excluded.failure_reason
|
||||
"#,
|
||||
)
|
||||
.bind(receipt.request_id)
|
||||
@@ -574,6 +590,7 @@ impl Repository {
|
||||
.bind(receipt.sender_blob_ticket)
|
||||
.bind(to_db_id(receipt.sender_transfer_id)?)
|
||||
.bind(receipt.token)
|
||||
.bind(receipt.failure_reason)
|
||||
.bind(now_ms())
|
||||
.execute(&mut *transaction)
|
||||
.await?;
|
||||
@@ -581,13 +598,46 @@ impl Repository {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn queue_failed_delivery_receipt(
|
||||
&self,
|
||||
receipt: PendingDeliveryReceiptInsert<'_>,
|
||||
) -> Result<()> {
|
||||
let Some(reason) = receipt.failure_reason else {
|
||||
anyhow::bail!("failed delivery receipt requires a reason");
|
||||
};
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO pending_delivery_receipts (
|
||||
request_id, local_transfer_id, sender_blob_ticket,
|
||||
sender_transfer_id, token, failure_reason, created_at
|
||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
|
||||
ON CONFLICT(request_id) DO UPDATE SET
|
||||
local_transfer_id = excluded.local_transfer_id,
|
||||
sender_blob_ticket = excluded.sender_blob_ticket,
|
||||
sender_transfer_id = excluded.sender_transfer_id,
|
||||
token = excluded.token,
|
||||
failure_reason = excluded.failure_reason
|
||||
"#,
|
||||
)
|
||||
.bind(receipt.request_id)
|
||||
.bind(to_db_id(receipt.local_transfer_id)?)
|
||||
.bind(receipt.sender_blob_ticket)
|
||||
.bind(to_db_id(receipt.sender_transfer_id)?)
|
||||
.bind(receipt.token)
|
||||
.bind(reason)
|
||||
.bind(now_ms())
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn list_pending_delivery_receipts(
|
||||
&self,
|
||||
) -> Result<Vec<PendingDeliveryReceipt>> {
|
||||
let rows = sqlx::query(
|
||||
r#"
|
||||
SELECT local_transfer_id, sender_blob_ticket, request_id,
|
||||
sender_transfer_id, token
|
||||
sender_transfer_id, token, failure_reason
|
||||
FROM pending_delivery_receipts
|
||||
ORDER BY created_at ASC
|
||||
"#,
|
||||
@@ -602,6 +652,7 @@ impl Repository {
|
||||
request_id: row.get("request_id"),
|
||||
sender_transfer_id: row.get::<i64, _>("sender_transfer_id") as u64,
|
||||
token: row.get("token"),
|
||||
failure_reason: row.get("failure_reason"),
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
@@ -709,7 +760,7 @@ impl Repository {
|
||||
pub(crate) async fn list_active_shares(&self) -> Result<Vec<PersistedShare>> {
|
||||
let rows = sqlx::query(
|
||||
r#"
|
||||
SELECT transfer_id, local_id, content_hash, access_mode
|
||||
SELECT transfer_id, local_id, content_hash, ticket, access_mode
|
||||
FROM transfers
|
||||
WHERE direction = 'send'
|
||||
AND status = 'sharing'
|
||||
@@ -724,7 +775,8 @@ impl Repository {
|
||||
transfer_id: row.get::<i64, _>(0) as u64,
|
||||
local_id: row.get::<String, _>(1),
|
||||
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())
|
||||
}
|
||||
@@ -881,6 +933,56 @@ impl Repository {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn fail_receiver_delivery(
|
||||
&self,
|
||||
id: &str,
|
||||
transfer_id: u64,
|
||||
remote_endpoint_id: &str,
|
||||
token_hash: &str,
|
||||
reason: &str,
|
||||
) -> Result<()> {
|
||||
let result = sqlx::query(
|
||||
r#"
|
||||
UPDATE receiver_requests
|
||||
SET status = 'failed', reason = ?1
|
||||
WHERE id = ?2 AND transfer_id = ?3 AND remote_endpoint_id = ?4 AND receipt_token_hash = ?5
|
||||
AND status = 'accepted'
|
||||
"#,
|
||||
)
|
||||
.bind(reason)
|
||||
.bind(id)
|
||||
.bind(to_db_id(transfer_id)?)
|
||||
.bind(remote_endpoint_id)
|
||||
.bind(token_hash)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
if result.rows_affected() == 1 {
|
||||
return Ok(());
|
||||
}
|
||||
let already_recorded = sqlx::query(
|
||||
r#"
|
||||
SELECT EXISTS(
|
||||
SELECT 1 FROM receiver_requests
|
||||
WHERE id = ?1 AND transfer_id = ?2 AND remote_endpoint_id = ?3
|
||||
AND receipt_token_hash = ?4 AND status = 'failed'
|
||||
)
|
||||
"#,
|
||||
)
|
||||
.bind(id)
|
||||
.bind(to_db_id(transfer_id)?)
|
||||
.bind(remote_endpoint_id)
|
||||
.bind(token_hash)
|
||||
.fetch_one(&self.pool)
|
||||
.await?
|
||||
.get::<i64, _>(0)
|
||||
!= 0;
|
||||
if already_recorded {
|
||||
Ok(())
|
||||
} else {
|
||||
anyhow::bail!("delivery failure did not match an accepted receiver request")
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn expire_pending_receiver_requests(&self, reason: &str) -> Result<u64> {
|
||||
let result = sqlx::query(
|
||||
r#"
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
use std::{str::FromStr, sync::Arc, time::Duration};
|
||||
use std::{sync::Arc, time::Duration};
|
||||
|
||||
use iroh_blobs::ticket::BlobTicket;
|
||||
use serde_json::json;
|
||||
|
||||
use super::CoreInner;
|
||||
use super::{filter_peer_addr_for_relay_mode, CoreInner};
|
||||
use crate::{
|
||||
handshake::{DeliveryReceipt, DeliveryReceiptResponse, HandshakeService},
|
||||
handshake::{
|
||||
DeliveryFailureReceipt, DeliveryReceipt, DeliveryReceiptResponse, HandshakeService,
|
||||
},
|
||||
repository::PendingDeliveryReceipt,
|
||||
ticket::parse_persisted_sender_address,
|
||||
};
|
||||
|
||||
const DELIVERY_RECEIPT_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
@@ -60,8 +62,8 @@ impl CoreInner {
|
||||
}
|
||||
|
||||
async fn deliver_pending_receipt(&self, pending: PendingDeliveryReceipt) {
|
||||
let blob_ticket = match BlobTicket::from_str(&pending.sender_blob_ticket) {
|
||||
Ok(ticket) => ticket,
|
||||
let sender_addr = match parse_persisted_sender_address(&pending.sender_blob_ticket) {
|
||||
Ok(addr) => addr,
|
||||
Err(error) => {
|
||||
tracing::warn!(%error, request_id = %pending.request_id, "discarded invalid pending delivery receipt");
|
||||
let _ = self
|
||||
@@ -78,14 +80,47 @@ impl CoreInner {
|
||||
return;
|
||||
}
|
||||
};
|
||||
let client = HandshakeService::client(self.endpoint.clone(), blob_ticket.addr().clone());
|
||||
let receipt = DeliveryReceipt {
|
||||
request_id: pending.request_id.clone(),
|
||||
transfer_id: pending.sender_transfer_id,
|
||||
token: pending.token,
|
||||
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;
|
||||
}
|
||||
};
|
||||
match tokio::time::timeout(DELIVERY_RECEIPT_TIMEOUT, client.report_delivery(receipt)).await
|
||||
{
|
||||
let client = HandshakeService::client(self.endpoint.clone(), sender_addr);
|
||||
let request_id = pending.request_id.clone();
|
||||
let response = tokio::time::timeout(DELIVERY_RECEIPT_TIMEOUT, async {
|
||||
if let Some(reason) = pending.failure_reason {
|
||||
client
|
||||
.report_delivery_failure(DeliveryFailureReceipt {
|
||||
request_id,
|
||||
transfer_id: pending.sender_transfer_id,
|
||||
token: pending.token,
|
||||
reason,
|
||||
})
|
||||
.await
|
||||
} else {
|
||||
client
|
||||
.report_delivery(DeliveryReceipt {
|
||||
request_id,
|
||||
transfer_id: pending.sender_transfer_id,
|
||||
token: pending.token,
|
||||
})
|
||||
.await
|
||||
}
|
||||
})
|
||||
.await;
|
||||
match response {
|
||||
Ok(Ok(DeliveryReceiptResponse::Recorded)) => {
|
||||
if let Err(error) = self
|
||||
.repository
|
||||
|
||||
@@ -6,9 +6,10 @@ use serde_json::json;
|
||||
use super::CoreInner;
|
||||
use crate::{
|
||||
api::{
|
||||
CoreEvent, CoreEventSink, CoreLimits, CoreStorageUsage, ReceiveOutputSink,
|
||||
ReceiveOutputSinkV2, ReceivedArtifact, ReceiverRequest, RuntimeStatus, ShareMetadataInput,
|
||||
ShareResult, ShareSource, StoredTransfer, TicketInspection, TransferAccessMode,
|
||||
CoreEvent, CoreEventSink, CoreLimits, CoreNetworkConfig, CoreStorageUsage,
|
||||
ReceiveOutputSink, ReceiveOutputSinkV2, ReceivedArtifact, ReceiverRequest, RuntimeStatus,
|
||||
ShareMetadataInput, ShareResult, ShareSource, StoredTransfer, TicketInspection,
|
||||
TransferAccessMode,
|
||||
},
|
||||
error::VnidropError,
|
||||
filesystem::platform_path,
|
||||
@@ -42,7 +43,26 @@ impl VnidropCore {
|
||||
app_data_dir: String,
|
||||
event_sink: Arc<dyn CoreEventSink>,
|
||||
) -> 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]
|
||||
@@ -50,15 +70,39 @@ impl VnidropCore {
|
||||
app_data_dir: String,
|
||||
event_sink: Arc<dyn CoreEventSink>,
|
||||
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> {
|
||||
limits.validate().map_err(VnidropError::initialization)?;
|
||||
let relay_urls = network_config
|
||||
.validated_relay_urls()
|
||||
.map_err(VnidropError::initialization)?;
|
||||
let runtime = tokio::runtime::Builder::new_multi_thread()
|
||||
.enable_all()
|
||||
.thread_name("vnidrop")
|
||||
.build()?;
|
||||
let app_data_dir = PathBuf::from(app_data_dir);
|
||||
let inner = runtime
|
||||
.block_on(CoreInner::start(app_data_dir, event_sink, limits))
|
||||
.block_on(CoreInner::start(
|
||||
app_data_dir,
|
||||
event_sink,
|
||||
limits,
|
||||
network_config.mode,
|
||||
relay_urls,
|
||||
))
|
||||
.map_err(VnidropError::initialization)?;
|
||||
Ok(Arc::new(Self { runtime, inner }))
|
||||
}
|
||||
|
||||
@@ -16,6 +16,8 @@ mod share;
|
||||
mod storage;
|
||||
|
||||
pub use facade::VnidropCore;
|
||||
#[cfg(test)]
|
||||
pub(crate) use provider::{consume_request_updates, RequestStreamOutcome};
|
||||
|
||||
use std::{
|
||||
collections::{HashMap, HashSet},
|
||||
@@ -27,7 +29,10 @@ use std::{
|
||||
|
||||
use anyhow::Result;
|
||||
use futures_lite::StreamExt as _;
|
||||
use iroh::{endpoint::presets, protocol::Router, Endpoint};
|
||||
use iroh::{
|
||||
endpoint::presets, protocol::Router, tls::CaTlsConfig, Endpoint, EndpointAddr, RelayConfig,
|
||||
RelayMap, RelayMode, RelayUrl,
|
||||
};
|
||||
use iroh_blobs::{
|
||||
format::collection::Collection,
|
||||
provider::events::{EventMask, EventSender},
|
||||
@@ -45,16 +50,36 @@ use tokio::{
|
||||
|
||||
use crate::{
|
||||
access_policy::{mode_from_storage, AccessPolicy},
|
||||
api::{CoreEvent, CoreEventSink, CoreLimits},
|
||||
api::{CoreEvent, CoreEventSink, CoreLimits, CoreRelayMode},
|
||||
approval::ApprovalService,
|
||||
event_hub::EventHub,
|
||||
handshake::HandshakeService,
|
||||
logging::init_logging,
|
||||
repository::Repository,
|
||||
secret::load_or_create_secret,
|
||||
ticket::ticket_matches_relay_profile,
|
||||
transfer_state::{TransferDirection, TransferStatus},
|
||||
};
|
||||
|
||||
const RELAY_CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum RelayStatus {
|
||||
Disabled,
|
||||
Connected,
|
||||
Unreachable,
|
||||
}
|
||||
|
||||
impl RelayStatus {
|
||||
fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Disabled => "disabled",
|
||||
Self::Connected => "connected",
|
||||
Self::Unreachable => "unreachable",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Owns the Iroh endpoint, blob store, transfer history, and byte streaming.
|
||||
/// Kotlin owns app lifecycle and platform file picking.
|
||||
pub(super) struct CoreInner {
|
||||
@@ -66,6 +91,8 @@ pub(super) struct CoreInner {
|
||||
pub(super) event_hub: Arc<EventHub>,
|
||||
pub(super) approval: ApprovalService,
|
||||
pub(super) limits: CoreLimits,
|
||||
pub(super) relay_mode: CoreRelayMode,
|
||||
pub(super) custom_relay_urls: Vec<RelayUrl>,
|
||||
pub(super) transfer_slots: Semaphore,
|
||||
pub(super) access_policy: Arc<AccessPolicy>,
|
||||
/// Sync mutex so cancel can remove + signal without awaiting (and without
|
||||
@@ -93,6 +120,8 @@ impl CoreInner {
|
||||
app_data_dir: PathBuf,
|
||||
event_sink: Arc<dyn CoreEventSink>,
|
||||
limits: CoreLimits,
|
||||
relay_mode: CoreRelayMode,
|
||||
relay_urls: Vec<RelayUrl>,
|
||||
) -> Result<Arc<Self>> {
|
||||
tokio::fs::create_dir_all(&app_data_dir).await?;
|
||||
init_logging(&app_data_dir)?;
|
||||
@@ -105,11 +134,48 @@ impl CoreInner {
|
||||
add_protected: None,
|
||||
});
|
||||
let store = FsStore::load_with_opts(store_root.join("blobs.db"), store_options).await?;
|
||||
let endpoint = Endpoint::builder(presets::N0)
|
||||
.secret_key(secret_key)
|
||||
.bind()
|
||||
.await?;
|
||||
endpoint.online().await;
|
||||
let endpoint = match relay_mode {
|
||||
CoreRelayMode::Automatic => {
|
||||
Endpoint::builder(presets::N0)
|
||||
.secret_key(secret_key)
|
||||
.bind()
|
||||
.await?
|
||||
}
|
||||
CoreRelayMode::StrictCustom | CoreRelayMode::CustomWithDirectFallback => {
|
||||
let relay_map = RelayMap::from_iter(relay_urls.iter().cloned().map(|url| {
|
||||
// Loopback HTTP is a development escape hatch. Without TLS the
|
||||
// relay cannot serve Iroh's QUIC address-discovery endpoint.
|
||||
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?
|
||||
}
|
||||
CoreRelayMode::LocalOnly => {
|
||||
Endpoint::builder(presets::Minimal)
|
||||
.relay_mode(RelayMode::Disabled)
|
||||
.secret_key(secret_key)
|
||||
.bind()
|
||||
.await?
|
||||
}
|
||||
};
|
||||
let relay_status =
|
||||
match wait_for_relay(&endpoint, relay_mode, &relay_urls, RELAY_CONNECT_TIMEOUT).await {
|
||||
Ok(status) => status,
|
||||
Err(error) => {
|
||||
endpoint.close().await;
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
|
||||
// Provider events are where the sender sees remote readers. The core
|
||||
// uses them for send progress and for the current approval gate.
|
||||
@@ -122,6 +188,14 @@ impl CoreInner {
|
||||
limits.event_queue_capacity as usize,
|
||||
limits.max_events,
|
||||
));
|
||||
event_hub.emit_endpoint(
|
||||
"network",
|
||||
"relay-status",
|
||||
json!({
|
||||
"mode": relay_mode_label(relay_mode),
|
||||
"status": relay_status.as_str(),
|
||||
}),
|
||||
);
|
||||
for recovered in recovered_transfers {
|
||||
event_hub.emit_transfer(
|
||||
recovered.transfer_id,
|
||||
@@ -189,6 +263,27 @@ impl CoreInner {
|
||||
);
|
||||
continue;
|
||||
};
|
||||
let relay_profile_matches = share.ticket.as_deref().is_some_and(|ticket| {
|
||||
ticket_matches_relay_profile(ticket, &limits, relay_mode, &relay_urls)
|
||||
.unwrap_or(false)
|
||||
});
|
||||
if !relay_profile_matches {
|
||||
repository
|
||||
.transition_transfer_status(
|
||||
transfer_id,
|
||||
TransferStatus::Sharing,
|
||||
TransferStatus::Stopped,
|
||||
)
|
||||
.await?;
|
||||
event_hub.emit_transfer(
|
||||
transfer_id,
|
||||
TransferDirection::Send.as_str(),
|
||||
"recovery",
|
||||
"share-stopped-network-profile-changed",
|
||||
json!({ "reason": "saved ticket does not match the active relay profile" }),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
let tag_name = share_tag_name(&share.local_id);
|
||||
store
|
||||
.tags()
|
||||
@@ -239,6 +334,8 @@ impl CoreInner {
|
||||
repository,
|
||||
event_hub,
|
||||
approval,
|
||||
relay_mode,
|
||||
custom_relay_urls: relay_urls,
|
||||
transfer_slots: Semaphore::new(limits.max_concurrent_transfers as usize),
|
||||
limits,
|
||||
access_policy,
|
||||
@@ -309,6 +406,88 @@ 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::StrictCustom | CoreRelayMode::CustomWithDirectFallback => {
|
||||
let mut filtered = EndpointAddr::new(addr.id);
|
||||
for ip_addr in addr.ip_addrs().copied() {
|
||||
filtered = filtered.with_ip_addr(ip_addr);
|
||||
}
|
||||
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)
|
||||
}
|
||||
CoreRelayMode::LocalOnly => {
|
||||
let mut filtered = EndpointAddr::new(addr.id);
|
||||
for ip_addr in addr.ip_addrs().copied() {
|
||||
filtered = filtered.with_ip_addr(ip_addr);
|
||||
}
|
||||
if filtered.is_empty() {
|
||||
anyhow::bail!("invitation has no direct address allowed by local-only mode");
|
||||
}
|
||||
Ok(filtered)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn wait_for_relay(
|
||||
endpoint: &Endpoint,
|
||||
relay_mode: CoreRelayMode,
|
||||
relay_urls: &[RelayUrl],
|
||||
timeout: Duration,
|
||||
) -> Result<RelayStatus> {
|
||||
if relay_mode == CoreRelayMode::LocalOnly {
|
||||
return Ok(RelayStatus::Disabled);
|
||||
}
|
||||
if tokio::time::timeout(timeout, endpoint.online())
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
match relay_mode {
|
||||
CoreRelayMode::StrictCustom => {
|
||||
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(),
|
||||
);
|
||||
}
|
||||
CoreRelayMode::Automatic | CoreRelayMode::CustomWithDirectFallback => {
|
||||
return Ok(RelayStatus::Unreachable);
|
||||
}
|
||||
CoreRelayMode::LocalOnly => unreachable!(),
|
||||
}
|
||||
}
|
||||
Ok(RelayStatus::Connected)
|
||||
}
|
||||
|
||||
fn relay_mode_label(relay_mode: CoreRelayMode) -> &'static str {
|
||||
match relay_mode {
|
||||
CoreRelayMode::Automatic => "automatic",
|
||||
CoreRelayMode::StrictCustom => "strict-custom",
|
||||
CoreRelayMode::CustomWithDirectFallback => "custom-with-direct-fallback",
|
||||
CoreRelayMode::LocalOnly => "local-only",
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn share_tag_name(local_id: &str) -> String {
|
||||
format!("vnidrop/share/{local_id}")
|
||||
}
|
||||
|
||||
@@ -10,6 +10,31 @@ use tokio::sync::mpsc;
|
||||
use super::CoreInner;
|
||||
use crate::access_policy::AccessDecision;
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub(crate) enum RequestStreamOutcome {
|
||||
TerminalUpdateReceived,
|
||||
Aborted,
|
||||
}
|
||||
|
||||
pub(crate) async fn consume_request_updates(
|
||||
mut rx: irpc::channel::mpsc::Receiver<RequestUpdate>,
|
||||
mut handle_update: impl FnMut(RequestUpdate),
|
||||
) -> RequestStreamOutcome {
|
||||
let mut terminal_update_received = false;
|
||||
while let Ok(Some(update)) = rx.recv().await {
|
||||
terminal_update_received |= matches!(
|
||||
update,
|
||||
RequestUpdate::Completed(_) | RequestUpdate::Aborted(_)
|
||||
);
|
||||
handle_update(update);
|
||||
}
|
||||
if terminal_update_received {
|
||||
RequestStreamOutcome::TerminalUpdateReceived
|
||||
} else {
|
||||
RequestStreamOutcome::Aborted
|
||||
}
|
||||
}
|
||||
|
||||
impl CoreInner {
|
||||
pub(super) async fn spawn_provider_event_task(
|
||||
self: &Arc<Self>,
|
||||
@@ -302,7 +327,7 @@ impl CoreInner {
|
||||
transfer_id: u64,
|
||||
connection_id: u64,
|
||||
request_id: u64,
|
||||
mut rx: irpc::channel::mpsc::Receiver<RequestUpdate>,
|
||||
rx: irpc::channel::mpsc::Receiver<RequestUpdate>,
|
||||
) {
|
||||
// Request update tasks are tied to individual provider streams. Router
|
||||
// shutdown closes those streams; only the long-lived provider receiver
|
||||
@@ -318,35 +343,35 @@ impl CoreInner {
|
||||
.cloned();
|
||||
let core = self.clone();
|
||||
tokio::spawn(async move {
|
||||
while let Ok(Some(update)) = rx.recv().await {
|
||||
match update {
|
||||
RequestUpdate::Started(started) => core.emit_transfer(
|
||||
transfer_id,
|
||||
"send",
|
||||
"transfer",
|
||||
"started",
|
||||
json!({
|
||||
"connection_id": connection_id,
|
||||
"request_id": request_id,
|
||||
"endpoint_id": endpoint_id,
|
||||
"hash": started.hash.to_string(),
|
||||
"size": started.size,
|
||||
"index": started.index,
|
||||
}),
|
||||
),
|
||||
RequestUpdate::Progress(progress) => core.emit_transfer(
|
||||
transfer_id,
|
||||
"send",
|
||||
"transfer",
|
||||
"progress",
|
||||
json!({
|
||||
"connection_id": connection_id,
|
||||
"request_id": request_id,
|
||||
"endpoint_id": endpoint_id,
|
||||
"end_offset": progress.end_offset,
|
||||
}),
|
||||
),
|
||||
RequestUpdate::Completed(_) => core.emit_transfer(
|
||||
let outcome = consume_request_updates(rx, |update| match update {
|
||||
RequestUpdate::Started(started) => core.emit_transfer(
|
||||
transfer_id,
|
||||
"send",
|
||||
"transfer",
|
||||
"started",
|
||||
json!({
|
||||
"connection_id": connection_id,
|
||||
"request_id": request_id,
|
||||
"endpoint_id": endpoint_id,
|
||||
"hash": started.hash.to_string(),
|
||||
"size": started.size,
|
||||
"index": started.index,
|
||||
}),
|
||||
),
|
||||
RequestUpdate::Progress(progress) => core.emit_transfer(
|
||||
transfer_id,
|
||||
"send",
|
||||
"transfer",
|
||||
"progress",
|
||||
json!({
|
||||
"connection_id": connection_id,
|
||||
"request_id": request_id,
|
||||
"endpoint_id": endpoint_id,
|
||||
"end_offset": progress.end_offset,
|
||||
}),
|
||||
),
|
||||
RequestUpdate::Completed(_) => {
|
||||
core.emit_transfer(
|
||||
transfer_id,
|
||||
"send",
|
||||
"transfer",
|
||||
@@ -356,8 +381,10 @@ impl CoreInner {
|
||||
"request_id": request_id,
|
||||
"endpoint_id": endpoint_id,
|
||||
}),
|
||||
),
|
||||
RequestUpdate::Aborted(_) => core.emit_transfer(
|
||||
);
|
||||
}
|
||||
RequestUpdate::Aborted(_) => {
|
||||
core.emit_transfer(
|
||||
transfer_id,
|
||||
"send",
|
||||
"transfer",
|
||||
@@ -367,8 +394,22 @@ impl CoreInner {
|
||||
"request_id": request_id,
|
||||
"endpoint_id": endpoint_id,
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
})
|
||||
.await;
|
||||
if outcome == RequestStreamOutcome::Aborted {
|
||||
core.emit_transfer(
|
||||
transfer_id,
|
||||
"send",
|
||||
"transfer",
|
||||
"aborted",
|
||||
json!({
|
||||
"connection_id": connection_id,
|
||||
"request_id": request_id,
|
||||
"endpoint_id": endpoint_id,
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use std::{
|
||||
io,
|
||||
path::{Path, PathBuf},
|
||||
sync::Arc,
|
||||
sync::{Arc, Mutex},
|
||||
};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
@@ -9,12 +9,12 @@ use bytes::Bytes;
|
||||
use futures_lite::StreamExt as _;
|
||||
use iroh_blobs::{
|
||||
api::proto::ExportRangesItem, api::remote::GetProgressItem, format::collection::Collection,
|
||||
get::request::get_hash_seq_and_sizes, Hash,
|
||||
get::request::get_hash_seq_and_sizes, ticket::BlobTicket, Hash,
|
||||
};
|
||||
use serde_json::json;
|
||||
use tokio::sync::oneshot;
|
||||
|
||||
use super::{ActiveTransfer, CoreInner};
|
||||
use super::{filter_peer_addr_for_relay_mode, ActiveTransfer, CoreInner};
|
||||
use crate::{
|
||||
access_policy::mode_to_storage,
|
||||
api::{
|
||||
@@ -28,7 +28,9 @@ use crate::{
|
||||
},
|
||||
handshake::{DeliveryReceipt, HandshakeResponse, HandshakeService},
|
||||
repository::{PendingDeliveryReceiptInsert, ReceivedArtifactInsert, TransferUpsert},
|
||||
ticket::{parse_transfer_ticket_with_limits, ParsedTransferTicket},
|
||||
ticket::{
|
||||
encode_persisted_sender_address, parse_transfer_ticket_with_limits, ParsedTransferTicket,
|
||||
},
|
||||
transfer_state::{TransferDirection, TransferStatus},
|
||||
};
|
||||
|
||||
@@ -192,7 +194,7 @@ impl CoreInner {
|
||||
.await
|
||||
.context("transfer limiter is closed")
|
||||
.map_err(VnidropError::internal)?;
|
||||
let parsed = match parse_transfer_ticket_with_limits(&ticket, &self.limits)
|
||||
let mut parsed = match parse_transfer_ticket_with_limits(&ticket, &self.limits)
|
||||
.context("failed to parse transfer ticket")
|
||||
{
|
||||
Ok(parsed) => parsed,
|
||||
@@ -205,10 +207,25 @@ impl CoreInner {
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
let sender_addr = filter_peer_addr_for_relay_mode(
|
||||
parsed.blob_ticket.addr(),
|
||||
self.relay_mode,
|
||||
&self.custom_relay_urls,
|
||||
)
|
||||
.map_err(VnidropError::network)?;
|
||||
parsed.blob_ticket = BlobTicket::new(
|
||||
sender_addr,
|
||||
parsed.blob_ticket.hash(),
|
||||
parsed.blob_ticket.format(),
|
||||
);
|
||||
let transfer_id = parsed.metadata.transfer_id;
|
||||
self.persist_receive_start(transfer_id, &parsed, receiver_name.as_deref())
|
||||
.await
|
||||
.map_err(VnidropError::repository)?;
|
||||
let persisted_sender_address =
|
||||
encode_persisted_sender_address(parsed.blob_ticket.addr())
|
||||
.context("failed to encode sender address for delivery receipt")?;
|
||||
let delivery_receipt = Arc::new(Mutex::new(None));
|
||||
// Cancellation is cooperative: it stops our receive future and marks
|
||||
// local state while lower-level Iroh work unwinds naturally.
|
||||
let (shutdown_tx, mut shutdown_rx) = oneshot::channel();
|
||||
@@ -224,7 +241,14 @@ impl CoreInner {
|
||||
);
|
||||
|
||||
let (result, cancelled) = tokio::select! {
|
||||
result = self.receive_inner(transfer_id, parsed, target, receiver_name) => {
|
||||
result = self.receive_inner(
|
||||
transfer_id,
|
||||
parsed,
|
||||
target,
|
||||
receiver_name,
|
||||
persisted_sender_address.clone(),
|
||||
delivery_receipt.clone(),
|
||||
) => {
|
||||
(result.map_err(VnidropError::transfer), false)
|
||||
},
|
||||
_ = &mut shutdown_rx => (Err(VnidropError::cancelled("transfer cancelled")), true),
|
||||
@@ -254,6 +278,34 @@ impl CoreInner {
|
||||
)
|
||||
.await;
|
||||
}
|
||||
let receipt = delivery_receipt.lock().expect("delivery_receipt").take();
|
||||
if let Some(receipt) = receipt {
|
||||
let reason = if cancelled { "cancelled" } else { error.code() };
|
||||
match self
|
||||
.repository
|
||||
.queue_failed_delivery_receipt(PendingDeliveryReceiptInsert {
|
||||
local_transfer_id: transfer_id,
|
||||
sender_blob_ticket: &persisted_sender_address,
|
||||
request_id: &receipt.request_id,
|
||||
sender_transfer_id: receipt.transfer_id,
|
||||
token: &receipt.token,
|
||||
failure_reason: Some(reason),
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(()) => self.delivery_receipt_notify.notify_one(),
|
||||
Err(queue_error) => {
|
||||
tracing::warn!(%queue_error, "failed to queue delivery failure receipt");
|
||||
self.emit_transfer(
|
||||
transfer_id,
|
||||
"receive",
|
||||
"delivery",
|
||||
"receipt-failed",
|
||||
json!({ "reason": queue_error.to_string() }),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
result.map_err(anyhow::Error::new)
|
||||
}
|
||||
@@ -264,6 +316,8 @@ impl CoreInner {
|
||||
parsed: ParsedTransferTicket,
|
||||
target: ReceiveTarget,
|
||||
receiver_name: Option<String>,
|
||||
persisted_sender_address: String,
|
||||
pending_delivery_receipt: Arc<Mutex<Option<DeliveryReceipt>>>,
|
||||
) -> Result<()> {
|
||||
if let ReceiveTarget::Directory(output_dir) = &target {
|
||||
tokio::fs::create_dir_all(output_dir)
|
||||
@@ -271,7 +325,6 @@ impl CoreInner {
|
||||
.map_err(VnidropError::filesystem)?;
|
||||
}
|
||||
let sender_addr = parsed.blob_ticket.addr().clone();
|
||||
let sender_blob_ticket = parsed.blob_ticket.to_string();
|
||||
|
||||
self.emit_transfer(transfer_id, "receive", "network", "connecting", json!({}));
|
||||
// Every VniDrop ticket carries metadata and must complete the handshake.
|
||||
@@ -283,6 +336,9 @@ impl CoreInner {
|
||||
receiver_name.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
*pending_delivery_receipt
|
||||
.lock()
|
||||
.expect("pending_delivery_receipt") = Some(delivery_receipt.clone());
|
||||
let connection = self
|
||||
.endpoint
|
||||
.connect(sender_addr.clone(), iroh_blobs::ALPN)
|
||||
@@ -355,13 +411,18 @@ impl CoreInner {
|
||||
self.repository
|
||||
.complete_receive_with_pending_receipt(PendingDeliveryReceiptInsert {
|
||||
local_transfer_id: transfer_id,
|
||||
sender_blob_ticket: &sender_blob_ticket,
|
||||
sender_blob_ticket: &persisted_sender_address,
|
||||
request_id: &delivery_receipt.request_id,
|
||||
sender_transfer_id: delivery_receipt.transfer_id,
|
||||
token: &delivery_receipt.token,
|
||||
failure_reason: None,
|
||||
})
|
||||
.await
|
||||
.map_err(VnidropError::repository)?;
|
||||
pending_delivery_receipt
|
||||
.lock()
|
||||
.expect("pending_delivery_receipt")
|
||||
.take();
|
||||
drop(download_tag);
|
||||
self.emit_transfer(transfer_id, "receive", "lifecycle", "done", json!({}));
|
||||
self.delivery_receipt_notify.notify_one();
|
||||
|
||||
@@ -149,9 +149,13 @@ impl CoreInner {
|
||||
import.file_count,
|
||||
import.total_size,
|
||||
);
|
||||
let ticket = VnidropTicket::new(blob_ticket, ticket_metadata)
|
||||
.encode()
|
||||
.context("failed to encode VniDrop transfer ticket")?;
|
||||
let ticket = VnidropTicket::new_with_relay_urls(
|
||||
blob_ticket,
|
||||
ticket_metadata,
|
||||
&self.custom_relay_urls,
|
||||
)
|
||||
.encode()
|
||||
.context("failed to encode VniDrop transfer ticket")?;
|
||||
let content_hash = import.root_hash.to_string();
|
||||
let local_id = self
|
||||
.repository
|
||||
|
||||
@@ -8,6 +8,8 @@ mod filesystem_tests;
|
||||
mod handshake_tests;
|
||||
#[path = "tests/limits.rs"]
|
||||
mod limits_tests;
|
||||
#[path = "tests/network_config.rs"]
|
||||
mod network_config_tests;
|
||||
#[path = "tests/repository.rs"]
|
||||
mod repository_tests;
|
||||
#[path = "tests/runtime.rs"]
|
||||
|
||||
232
crates/vnidrop/src/tests/network_config.rs
Normal file
232
crates/vnidrop/src/tests/network_config.rs
Normal file
@@ -0,0 +1,232 @@
|
||||
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, RelayStatus},
|
||||
};
|
||||
|
||||
#[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());
|
||||
|
||||
for mode in [
|
||||
CoreRelayMode::StrictCustom,
|
||||
CoreRelayMode::CustomWithDirectFallback,
|
||||
] {
|
||||
let custom_without_url = CoreNetworkConfig {
|
||||
mode,
|
||||
relay_urls: Vec::new(),
|
||||
};
|
||||
assert!(custom_without_url.validated_relay_urls().is_err());
|
||||
}
|
||||
|
||||
let local_only_with_url = CoreNetworkConfig {
|
||||
mode: CoreRelayMode::LocalOnly,
|
||||
relay_urls: vec!["https://relay.example.com".to_string()],
|
||||
};
|
||||
assert!(local_only_with_url.validated_relay_urls().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_relay_urls_allow_https_and_loopback_http() {
|
||||
let config = CoreNetworkConfig {
|
||||
mode: CoreRelayMode::StrictCustom,
|
||||
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::StrictCustom,
|
||||
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::StrictCustom,
|
||||
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::StrictCustom,
|
||||
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::StrictCustom,
|
||||
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::StrictCustom,
|
||||
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::StrictCustom,
|
||||
std::slice::from_ref(&allowed),
|
||||
)
|
||||
.is_err());
|
||||
|
||||
let fallback_filtered = filter_peer_addr_for_relay_mode(
|
||||
&addr,
|
||||
CoreRelayMode::CustomWithDirectFallback,
|
||||
std::slice::from_ref(&allowed),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(fallback_filtered, filtered);
|
||||
|
||||
let local_only = filter_peer_addr_for_relay_mode(&addr, CoreRelayMode::LocalOnly, &[]).unwrap();
|
||||
assert_eq!(local_only.relay_urls().count(), 0);
|
||||
assert_eq!(
|
||||
local_only.ip_addrs().copied().collect::<Vec<_>>(),
|
||||
vec![direct]
|
||||
);
|
||||
assert!(
|
||||
filter_peer_addr_for_relay_mode(&disallowed_only, CoreRelayMode::LocalOnly, &[]).is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn relay_wait_enforces_only_strict_custom_mode() {
|
||||
let relay_url: RelayUrl = "http://127.0.0.1:9".parse().unwrap();
|
||||
let endpoint = Endpoint::builder(presets::Minimal)
|
||||
.relay_mode(RelayMode::custom([relay_url.clone()]))
|
||||
.bind()
|
||||
.await
|
||||
.unwrap();
|
||||
let started = Instant::now();
|
||||
|
||||
let error = wait_for_relay(
|
||||
&endpoint,
|
||||
CoreRelayMode::StrictCustom,
|
||||
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_status = wait_for_relay(
|
||||
&endpoint,
|
||||
CoreRelayMode::Automatic,
|
||||
&[],
|
||||
Duration::from_millis(50),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(started.elapsed() < Duration::from_secs(1));
|
||||
assert_eq!(automatic_status, RelayStatus::Unreachable);
|
||||
|
||||
let fallback_status = wait_for_relay(
|
||||
&endpoint,
|
||||
CoreRelayMode::CustomWithDirectFallback,
|
||||
std::slice::from_ref(&relay_url),
|
||||
Duration::from_millis(50),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(fallback_status, RelayStatus::Unreachable);
|
||||
|
||||
let local_only_status = wait_for_relay(
|
||||
&endpoint,
|
||||
CoreRelayMode::LocalOnly,
|
||||
&[],
|
||||
Duration::from_millis(50),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(local_only_status, RelayStatus::Disabled);
|
||||
endpoint.close().await;
|
||||
}
|
||||
@@ -66,7 +66,7 @@ async fn received_artifacts_survive_history_deletion() {
|
||||
async fn persists_transfers_and_events_across_reopen() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let repository = Repository::open(temp.path()).await.unwrap();
|
||||
assert_eq!(repository.schema_version().await.unwrap(), 6);
|
||||
assert_eq!(repository.schema_version().await.unwrap(), 7);
|
||||
repository
|
||||
.insert_transfer(transfer(
|
||||
7,
|
||||
@@ -80,6 +80,7 @@ async fn persists_transfers_and_events_across_reopen() {
|
||||
assert_eq!(shares.len(), 1);
|
||||
assert_eq!(shares[0].transfer_id, 7);
|
||||
assert_eq!(shares[0].content_hash, "hash");
|
||||
assert_eq!(shares[0].ticket.as_deref(), Some("ticket"));
|
||||
assert_eq!(shares[0].access_mode, "approval_required");
|
||||
|
||||
repository
|
||||
@@ -134,6 +135,7 @@ async fn receive_completion_persists_delivery_receipt_until_recorded() {
|
||||
request_id: "request-93",
|
||||
sender_transfer_id: 39,
|
||||
token: "receipt-token",
|
||||
failure_reason: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -220,6 +222,68 @@ async fn receiver_request_can_only_be_resolved_once() {
|
||||
assert!(requests[0].completed_at.is_some());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn authenticated_delivery_failure_marks_accepted_receiver_failed() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let repository = Repository::open(temp.path()).await.unwrap();
|
||||
repository
|
||||
.insert_receiver_request(ReceiverRequestInsert {
|
||||
id: "request-failed",
|
||||
transfer_id: 78,
|
||||
remote_endpoint_id: "node-a",
|
||||
transfer_name: "demo",
|
||||
receiver_name: Some("receiver"),
|
||||
receiver_device_name: None,
|
||||
app_version: "0.1.0",
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
repository
|
||||
.update_receiver_request_status("request-failed", ReceiverRequestStatus::Accepted, None)
|
||||
.await
|
||||
.unwrap();
|
||||
repository
|
||||
.set_receiver_receipt_token("request-failed", "token-hash")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
repository
|
||||
.fail_receiver_delivery(
|
||||
"request-failed",
|
||||
78,
|
||||
"node-a",
|
||||
"token-hash",
|
||||
"destination_exists",
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
repository
|
||||
.fail_receiver_delivery(
|
||||
"request-failed",
|
||||
78,
|
||||
"node-a",
|
||||
"token-hash",
|
||||
"destination_exists",
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(repository
|
||||
.fail_receiver_delivery(
|
||||
"request-failed",
|
||||
78,
|
||||
"node-b",
|
||||
"token-hash",
|
||||
"destination_exists",
|
||||
)
|
||||
.await
|
||||
.is_err());
|
||||
|
||||
let requests = repository.list_receiver_requests(78).await.unwrap();
|
||||
assert_eq!(requests.len(), 1);
|
||||
assert_eq!(requests[0].status, "failed");
|
||||
assert_eq!(requests[0].reason.as_deref(), Some("destination_exists"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn startup_expiration_is_idempotent_for_pending_requests() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
@@ -581,7 +645,7 @@ async fn migrates_schema_v2_identity_without_losing_transfer() {
|
||||
pool.close().await;
|
||||
|
||||
let repository = Repository::open(temp.path()).await.unwrap();
|
||||
assert_eq!(repository.schema_version().await.unwrap(), 6);
|
||||
assert_eq!(repository.schema_version().await.unwrap(), 7);
|
||||
let stored = repository.list_transfers().await.unwrap().remove(0);
|
||||
assert_eq!(stored.transfer_id, 7);
|
||||
assert_eq!(stored.local_id, "legacy-7-send");
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
use std::sync::Arc;
|
||||
use std::{sync::Arc, time::Duration};
|
||||
|
||||
use iroh_blobs::Hash;
|
||||
use iroh_blobs::{
|
||||
provider::{
|
||||
events::{RequestUpdate, TransferCompleted},
|
||||
TransferStats,
|
||||
},
|
||||
Hash,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
repository::{PendingDeliveryReceiptInsert, Repository, TransferUpsert},
|
||||
runtime::{consume_request_updates, RequestStreamOutcome},
|
||||
transfer_state::{TransferDirection, TransferStatus},
|
||||
CoreEvent, CoreEventSink, VnidropCore, VnidropError,
|
||||
};
|
||||
@@ -14,6 +21,37 @@ impl CoreEventSink for TestSink {
|
||||
fn on_event(&self, _event: CoreEvent) {}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_request_stream_distinguishes_success_from_silent_abort() {
|
||||
let runtime = tokio::runtime::Runtime::new().unwrap();
|
||||
runtime.block_on(async {
|
||||
let (completed_tx, completed_rx) = irpc::channel::mpsc::channel(1);
|
||||
completed_tx
|
||||
.send(RequestUpdate::Completed(TransferCompleted {
|
||||
stats: Box::new(TransferStats {
|
||||
payload_bytes_sent: 5,
|
||||
other_bytes_sent: 0,
|
||||
other_bytes_read: 0,
|
||||
duration: Duration::ZERO,
|
||||
}),
|
||||
}))
|
||||
.await
|
||||
.unwrap();
|
||||
drop(completed_tx);
|
||||
assert_eq!(
|
||||
consume_request_updates(completed_rx, |_| {}).await,
|
||||
RequestStreamOutcome::TerminalUpdateReceived
|
||||
);
|
||||
|
||||
let (aborted_tx, aborted_rx) = irpc::channel::mpsc::channel::<RequestUpdate>(1);
|
||||
drop(aborted_tx);
|
||||
assert_eq!(
|
||||
consume_request_updates(aborted_rx, |_| {}).await,
|
||||
RequestStreamOutcome::Aborted
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn initializes_and_reports_endpoint() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
@@ -126,6 +164,7 @@ fn startup_processes_persisted_delivery_receipts() {
|
||||
request_id: "request-94",
|
||||
sender_transfer_id: 49,
|
||||
token: "receipt-token",
|
||||
failure_reason: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
use std::net::{Ipv4Addr, SocketAddr};
|
||||
|
||||
use data_encoding::BASE64URL_NOPAD;
|
||||
use iroh::SecretKey;
|
||||
use iroh::{RelayUrl, SecretKey};
|
||||
use iroh_blobs::{ticket::BlobTicket, BlobFormat, Hash};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::{
|
||||
api::{CoreLimits, TransferMetadata},
|
||||
ticket::{parse_transfer_ticket, parse_transfer_ticket_with_limits, VnidropTicket},
|
||||
api::{CoreLimits, CoreRelayMode, TransferMetadata},
|
||||
ticket::{
|
||||
encode_persisted_sender_address, parse_persisted_sender_address, parse_transfer_ticket,
|
||||
parse_transfer_ticket_with_limits, ticket_matches_relay_profile, VnidropTicket,
|
||||
},
|
||||
};
|
||||
|
||||
fn blob_ticket(hash_byte: u8) -> BlobTicket {
|
||||
@@ -25,7 +30,7 @@ fn metadata_ticket_round_trips() {
|
||||
3,
|
||||
2048,
|
||||
);
|
||||
let encoded = VnidropTicket::new(blob_ticket.clone(), metadata.clone())
|
||||
let encoded = VnidropTicket::new_with_relay_urls(blob_ticket.clone(), metadata.clone(), &[])
|
||||
.encode()
|
||||
.unwrap();
|
||||
let parsed = parse_transfer_ticket(&encoded).unwrap();
|
||||
@@ -38,7 +43,7 @@ fn metadata_ticket_round_trips() {
|
||||
fn metadata_ticket_tolerates_wrapped_whitespace() {
|
||||
let blob_ticket = blob_ticket(9);
|
||||
let metadata = TransferMetadata::new(7, "Wrapped", None, blob_ticket.hash(), 1, 10);
|
||||
let encoded = VnidropTicket::new(blob_ticket.clone(), metadata)
|
||||
let encoded = VnidropTicket::new_with_relay_urls(blob_ticket.clone(), metadata, &[])
|
||||
.encode()
|
||||
.unwrap();
|
||||
let wrapped = encoded
|
||||
@@ -52,6 +57,147 @@ fn metadata_ticket_tolerates_wrapped_whitespace() {
|
||||
assert_eq!(parsed.blob_ticket.hash(), blob_ticket.hash());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn metadata_ticket_restores_backup_relays_without_losing_direct_addresses() {
|
||||
let secret = SecretKey::generate();
|
||||
let primary: RelayUrl = "https://a.relay.example.com".parse().unwrap();
|
||||
let backup: RelayUrl = "https://b.relay.example.com".parse().unwrap();
|
||||
let direct = SocketAddr::from((Ipv4Addr::LOCALHOST, 49152));
|
||||
let addr = iroh::EndpointAddr::new(secret.public())
|
||||
.with_relay_url(primary.clone())
|
||||
.with_ip_addr(direct);
|
||||
let blob_ticket = BlobTicket::new(addr, Hash::new([11; 32]), BlobFormat::HashSeq);
|
||||
let metadata = TransferMetadata::new(11, "Backed up", None, blob_ticket.hash(), 1, 10);
|
||||
|
||||
let encoded = VnidropTicket::new_with_relay_urls(
|
||||
blob_ticket,
|
||||
metadata,
|
||||
&[primary.clone(), backup.clone()],
|
||||
)
|
||||
.encode()
|
||||
.unwrap();
|
||||
let parsed = parse_transfer_ticket(&encoded).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
parsed
|
||||
.blob_ticket
|
||||
.addr()
|
||||
.relay_urls()
|
||||
.cloned()
|
||||
.collect::<Vec<_>>(),
|
||||
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::StrictCustom,
|
||||
&[relay_b.clone(), relay_a.clone()],
|
||||
)
|
||||
.unwrap());
|
||||
assert!(ticket_matches_relay_profile(
|
||||
&custom_ticket,
|
||||
&limits,
|
||||
CoreRelayMode::CustomWithDirectFallback,
|
||||
&[relay_b.clone(), relay_a.clone()],
|
||||
)
|
||||
.unwrap());
|
||||
assert!(!ticket_matches_relay_profile(
|
||||
&custom_ticket,
|
||||
&limits,
|
||||
CoreRelayMode::StrictCustom,
|
||||
&[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::StrictCustom,
|
||||
&[relay_a],
|
||||
)
|
||||
.unwrap());
|
||||
assert!(ticket_matches_relay_profile(
|
||||
&automatic_ticket,
|
||||
&limits,
|
||||
CoreRelayMode::LocalOnly,
|
||||
&[],
|
||||
)
|
||||
.unwrap());
|
||||
assert!(
|
||||
!ticket_matches_relay_profile(&custom_ticket, &limits, CoreRelayMode::LocalOnly, &[],)
|
||||
.unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persisted_sender_address_preserves_relays_and_accepts_legacy_blob_ticket() {
|
||||
let secret = SecretKey::generate();
|
||||
let primary: RelayUrl = "https://a.relay.example.com".parse().unwrap();
|
||||
let backup: RelayUrl = "https://b.relay.example.com".parse().unwrap();
|
||||
let direct = SocketAddr::from((Ipv4Addr::LOCALHOST, 49153));
|
||||
let addr = iroh::EndpointAddr::new(secret.public())
|
||||
.with_relay_url(primary.clone())
|
||||
.with_relay_url(backup)
|
||||
.with_ip_addr(direct);
|
||||
|
||||
let encoded = encode_persisted_sender_address(&addr).unwrap();
|
||||
assert_eq!(parse_persisted_sender_address(&encoded).unwrap(), addr);
|
||||
|
||||
let legacy_addr = iroh::EndpointAddr::new(secret.public())
|
||||
.with_relay_url(primary)
|
||||
.with_ip_addr(direct);
|
||||
let legacy = BlobTicket::new(
|
||||
legacy_addr.clone(),
|
||||
Hash::new([12; 32]),
|
||||
BlobFormat::HashSeq,
|
||||
)
|
||||
.to_string();
|
||||
assert_eq!(
|
||||
parse_persisted_sender_address(&legacy).unwrap(),
|
||||
legacy_addr
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_ticket_is_rejected() {
|
||||
assert!(parse_transfer_ticket("not-a-ticket").is_err());
|
||||
|
||||
@@ -1,27 +1,38 @@
|
||||
use std::str::FromStr;
|
||||
use std::{collections::BTreeSet, str::FromStr};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use data_encoding::BASE64URL_NOPAD;
|
||||
use iroh::{EndpointAddr, RelayUrl};
|
||||
use iroh_blobs::ticket::BlobTicket;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::api::{CoreLimits, TransferMetadata};
|
||||
use crate::api::{CoreLimits, CoreNetworkConfig, CoreRelayMode, TransferMetadata};
|
||||
|
||||
const VNIDROP_TICKET_PREFIX: &str = "vnd1:";
|
||||
const VNIDROP_TICKET_VERSION: u8 = 1;
|
||||
const PERSISTED_SENDER_ADDRESS_PREFIX: &str = "vndaddr1:";
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub(crate) struct VnidropTicket {
|
||||
version: u8,
|
||||
blob_ticket: String,
|
||||
// BlobTicket's current wire format retains only one relay URL. The outer
|
||||
// envelope carries backups so new receivers can rebuild the full address.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
relay_urls: Vec<String>,
|
||||
metadata: TransferMetadata,
|
||||
}
|
||||
|
||||
impl VnidropTicket {
|
||||
pub(crate) fn new(blob_ticket: BlobTicket, metadata: TransferMetadata) -> Self {
|
||||
pub(crate) fn new_with_relay_urls(
|
||||
blob_ticket: BlobTicket,
|
||||
metadata: TransferMetadata,
|
||||
relay_urls: &[RelayUrl],
|
||||
) -> Self {
|
||||
Self {
|
||||
version: VNIDROP_TICKET_VERSION,
|
||||
blob_ticket: blob_ticket.to_string(),
|
||||
relay_urls: relay_urls.iter().map(ToString::to_string).collect(),
|
||||
metadata,
|
||||
}
|
||||
}
|
||||
@@ -50,6 +61,35 @@ impl VnidropTicket {
|
||||
pub(crate) struct ParsedTransferTicket {
|
||||
pub(crate) blob_ticket: BlobTicket,
|
||||
pub(crate) metadata: TransferMetadata,
|
||||
pub(crate) advertised_custom_relay_urls: Vec<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)]
|
||||
@@ -93,17 +133,57 @@ pub(crate) fn parse_transfer_ticket_with_limits(
|
||||
Some(ticket.metadata.transfer_name.as_str()),
|
||||
)?;
|
||||
limits.validate_metadata_text("sender name", ticket.metadata.sender_name.as_deref())?;
|
||||
let blob_ticket = BlobTicket::from_str(&ticket.blob_ticket)
|
||||
let mut blob_ticket = BlobTicket::from_str(&ticket.blob_ticket)
|
||||
.context("invalid BlobTicket inside VniDrop ticket")?;
|
||||
if ticket.metadata.content_hash != blob_ticket.hash().to_string() {
|
||||
anyhow::bail!("VniDrop ticket metadata hash does not match BlobTicket hash");
|
||||
}
|
||||
let advertised_custom_relay_urls = if ticket.relay_urls.is_empty() {
|
||||
Vec::new()
|
||||
} else {
|
||||
CoreNetworkConfig {
|
||||
mode: CoreRelayMode::StrictCustom,
|
||||
relay_urls: ticket.relay_urls,
|
||||
}
|
||||
.validated_relay_urls()
|
||||
.context("invalid relay URLs inside VniDrop ticket")?
|
||||
};
|
||||
if !advertised_custom_relay_urls.is_empty() {
|
||||
let (mut addr, hash, format) = blob_ticket.into_parts();
|
||||
for relay_url in advertised_custom_relay_urls.iter().cloned() {
|
||||
addr = addr.with_relay_url(relay_url);
|
||||
}
|
||||
blob_ticket = BlobTicket::new(addr, hash, format);
|
||||
}
|
||||
Ok(ParsedTransferTicket {
|
||||
blob_ticket,
|
||||
metadata: ticket.metadata,
|
||||
advertised_custom_relay_urls,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn ticket_matches_relay_profile(
|
||||
value: &str,
|
||||
limits: &CoreLimits,
|
||||
relay_mode: CoreRelayMode,
|
||||
custom_relay_urls: &[RelayUrl],
|
||||
) -> Result<bool> {
|
||||
let parsed = parse_transfer_ticket_with_limits(value, limits)?;
|
||||
match relay_mode {
|
||||
CoreRelayMode::Automatic => Ok(parsed.advertised_custom_relay_urls.is_empty()),
|
||||
CoreRelayMode::StrictCustom | CoreRelayMode::CustomWithDirectFallback => {
|
||||
let advertised = parsed
|
||||
.advertised_custom_relay_urls
|
||||
.into_iter()
|
||||
.collect::<BTreeSet<_>>();
|
||||
let configured = custom_relay_urls.iter().cloned().collect::<BTreeSet<_>>();
|
||||
Ok(advertised == configured)
|
||||
}
|
||||
CoreRelayMode::LocalOnly => Ok(parsed.advertised_custom_relay_urls.is_empty()
|
||||
&& parsed.blob_ticket.addr().relay_urls().next().is_none()),
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_ticket_input(value: &str) -> String {
|
||||
// Tickets are commonly copied from text views or chat apps that insert line
|
||||
// breaks. Strip whitespace only; other corrupt characters should still be
|
||||
|
||||
@@ -87,6 +87,7 @@ pub(crate) enum ReceiverRequestStatus {
|
||||
Refused,
|
||||
Expired,
|
||||
Completed,
|
||||
Failed,
|
||||
}
|
||||
|
||||
impl ReceiverRequestStatus {
|
||||
@@ -97,6 +98,7 @@ impl ReceiverRequestStatus {
|
||||
Self::Refused => "refused",
|
||||
Self::Expired => "expired",
|
||||
Self::Completed => "completed",
|
||||
Self::Failed => "failed",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -111,6 +113,7 @@ impl TryFrom<&str> for ReceiverRequestStatus {
|
||||
"refused" => Ok(Self::Refused),
|
||||
"expired" => Ok(Self::Expired),
|
||||
"completed" => Ok(Self::Completed),
|
||||
"failed" => Ok(Self::Failed),
|
||||
_ => bail!("unknown receiver request status: {value}"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,14 +73,23 @@ fn public_share_receives_without_sender_approval() {
|
||||
assert_eq!(deliveries[0].receiver_name.as_deref(), Some("Receiver"));
|
||||
assert_eq!(deliveries[0].status, "completed");
|
||||
assert!(deliveries[0].completed_at.is_some());
|
||||
assert!(
|
||||
sender.sink.events().iter().any(|event| {
|
||||
// The repository commit becomes visible just before the receipt handler
|
||||
// emits its event, so completion and sink observation are not atomic.
|
||||
let started = Instant::now();
|
||||
loop {
|
||||
if sender.sink.events().iter().any(|event| {
|
||||
event.phase == "delivery"
|
||||
&& event.kind == "receiver-completed"
|
||||
&& event.transfer_id == Some(share.transfer_id)
|
||||
}),
|
||||
"delivery receipts must emit a delivery phase event for UI live updates"
|
||||
);
|
||||
}) {
|
||||
break;
|
||||
}
|
||||
assert!(
|
||||
started.elapsed() < Duration::from_secs(5),
|
||||
"delivery receipts must emit a delivery phase event for UI live updates"
|
||||
);
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
146
crates/vnidrop/tests/custom_relay.rs
Normal file
146
crates/vnidrop/tests/custom_relay.rs
Normal file
@@ -0,0 +1,146 @@
|
||||
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::StrictCustom,
|
||||
relay_urls: relay_urls.iter().map(ToString::to_string).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
fn local_only_config() -> CoreNetworkConfig {
|
||||
CoreNetworkConfig {
|
||||
mode: CoreRelayMode::LocalOnly,
|
||||
relay_urls: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn read_blob_ticket(ticket: &str) -> (Value, BlobTicket) {
|
||||
let encoded = ticket.strip_prefix("vnd1:").unwrap();
|
||||
let payload = BASE64URL_NOPAD.decode(encoded.as_bytes()).unwrap();
|
||||
let value: Value = serde_json::from_slice(&payload).unwrap();
|
||||
let blob_ticket = BlobTicket::from_str(value["blob_ticket"].as_str().unwrap()).unwrap();
|
||||
let (mut addr, hash, format) = blob_ticket.into_parts();
|
||||
if let Some(relay_urls) = value["relay_urls"].as_array() {
|
||||
for relay_url in relay_urls {
|
||||
addr = addr.with_relay_url(relay_url.as_str().unwrap().parse().unwrap());
|
||||
}
|
||||
}
|
||||
let blob_ticket = BlobTicket::new(addr, hash, format);
|
||||
(value, blob_ticket)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_only_mode_advertises_direct_addresses_and_transfers_on_lan() {
|
||||
let sender = TestNode::with_network_config(local_only_config());
|
||||
let receiver = TestNode::with_network_config(local_only_config());
|
||||
let source_dir = tempfile::tempdir().unwrap();
|
||||
let output_dir = tempfile::tempdir().unwrap();
|
||||
let source_path = source_dir.path().join("local-only.txt");
|
||||
std::fs::write(&source_path, b"direct on the local network").unwrap();
|
||||
|
||||
let share = share_path(&sender.core, &source_path, 402, "local-only.txt", false);
|
||||
let (ticket_value, blob_ticket) = read_blob_ticket(&share.ticket);
|
||||
assert!(ticket_value.get("relay_urls").is_none());
|
||||
assert_eq!(blob_ticket.addr().relay_urls().count(), 0);
|
||||
assert!(!sender.core.status().addr.contains("iroh.link"));
|
||||
|
||||
receive_with_response(
|
||||
&sender.core,
|
||||
share.transfer_id,
|
||||
receiver.core.arc(),
|
||||
share.ticket,
|
||||
output_dir.path(),
|
||||
true,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
std::fs::read(output_dir.path().join("local-only.txt")).unwrap(),
|
||||
b"direct on the local network"
|
||||
);
|
||||
}
|
||||
|
||||
fn with_relay_only_address(ticket: &str, relay_url: &str) -> String {
|
||||
let (mut value, blob_ticket) = read_blob_ticket(ticket);
|
||||
let relay_url: RelayUrl = relay_url.parse().unwrap();
|
||||
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"
|
||||
);
|
||||
}
|
||||
@@ -5,10 +5,10 @@ use std::time::Duration;
|
||||
|
||||
use futures_lite::StreamExt as _;
|
||||
use iroh_blobs::store::fs::FsStore;
|
||||
use support::{share_path, CoreGuard, RecordingSink, TestNode};
|
||||
use support::{share_path, CoreGuard, RecordingSink, TestNode, TestRelay};
|
||||
use vnidrop::{
|
||||
CoreEvent, CoreEventSink, CoreLimits, ShareMetadataInput, ShareSource, SourceKind,
|
||||
TransferAccessMode,
|
||||
CoreEvent, CoreEventSink, CoreLimits, CoreNetworkConfig, CoreRelayMode, ShareMetadataInput,
|
||||
ShareSource, SourceKind, TransferAccessMode,
|
||||
};
|
||||
|
||||
#[test]
|
||||
@@ -137,6 +137,56 @@ fn persisted_share_is_recovered_and_can_be_stopped_after_restart() {
|
||||
assert_eq!(restarted.status().active_shares, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persisted_share_is_revoked_when_restarted_with_a_different_relay_profile() {
|
||||
let relay_a = TestRelay::start();
|
||||
let relay_b = TestRelay::start();
|
||||
assert_ne!(relay_a.url, relay_b.url);
|
||||
|
||||
let source_dir = tempfile::tempdir().unwrap();
|
||||
let core_dir = tempfile::tempdir().unwrap();
|
||||
let source_path = source_dir.path().join("stale-relay.txt");
|
||||
std::fs::write(&source_path, b"must not survive a relay profile change").unwrap();
|
||||
let sender = CoreGuard::start_with_network_config(
|
||||
core_dir.path(),
|
||||
Arc::new(RecordingSink::default()),
|
||||
CoreNetworkConfig {
|
||||
mode: CoreRelayMode::StrictCustom,
|
||||
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::StrictCustom,
|
||||
relay_urls: vec![relay_b.url.clone()],
|
||||
},
|
||||
);
|
||||
|
||||
assert_eq!(restarted.status().active_shares, 0);
|
||||
let transfer = restarted
|
||||
.list_transfers()
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.find(|transfer| transfer.transfer_id == share.transfer_id)
|
||||
.unwrap();
|
||||
assert_eq!(transfer.status, "stopped");
|
||||
assert!(restarted
|
||||
.list_events(Some(share.transfer_id))
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|event| {
|
||||
event.phase == "recovery" && event.kind == "share-stopped-network-profile-changed"
|
||||
}));
|
||||
drop(restarted);
|
||||
assert_eq!(share_tag_count(core_dir.path()), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stopped_share_rejects_receive() {
|
||||
let source_dir = tempfile::tempdir().unwrap();
|
||||
|
||||
26
crates/vnidrop/tests/storage.rs
Normal file
26
crates/vnidrop/tests/storage.rs
Normal file
@@ -0,0 +1,26 @@
|
||||
use vnidrop::clear_inactive_transfer_cache;
|
||||
|
||||
#[test]
|
||||
fn inactive_transfer_cache_is_removed_and_reports_reclaimed_bytes() {
|
||||
let app_data = tempfile::tempdir().unwrap();
|
||||
let blobs = app_data.path().join("blobs");
|
||||
std::fs::create_dir_all(blobs.join("data")).unwrap();
|
||||
std::fs::write(blobs.join("data").join("payload"), vec![9u8; 4096]).unwrap();
|
||||
std::fs::write(blobs.join("blobs.db"), vec![3u8; 512]).unwrap();
|
||||
|
||||
let reclaimed =
|
||||
clear_inactive_transfer_cache(app_data.path().to_string_lossy().into_owned()).unwrap();
|
||||
|
||||
assert_eq!(reclaimed, 4608);
|
||||
assert!(!blobs.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inactive_transfer_cache_rejects_relative_app_data_paths() {
|
||||
assert!(clear_inactive_transfer_cache("relative/path".to_string()).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inactive_transfer_cache_rejects_filesystem_roots() {
|
||||
assert!(clear_inactive_transfer_cache(std::path::MAIN_SEPARATOR_STR.to_string()).is_err());
|
||||
}
|
||||
@@ -8,15 +8,16 @@ use std::{
|
||||
path::Path,
|
||||
sync::{
|
||||
atomic::{AtomicBool, Ordering},
|
||||
Arc, Condvar, Mutex,
|
||||
mpsc, Arc, Condvar, Mutex,
|
||||
},
|
||||
thread::JoinHandle,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
use vnidrop::{
|
||||
CoreEvent, CoreEventSink, CoreLimits, PublishedOutput, ReceiveOutputSink, ReceiveOutputSinkV2,
|
||||
ReceivedLocatorKind, ReceiverRequest, ShareMetadataInput, ShareResult, ShareSource, SourceKind,
|
||||
TransferAccessMode, VnidropCore, VnidropError,
|
||||
CoreEvent, CoreEventSink, CoreLimits, CoreNetworkConfig, PublishedOutput, ReceiveOutputSink,
|
||||
ReceiveOutputSinkV2, ReceivedLocatorKind, ReceiverRequest, ShareMetadataInput, ShareResult,
|
||||
ShareSource, SourceKind, TransferAccessMode, VnidropCore, VnidropError,
|
||||
};
|
||||
|
||||
#[derive(Default)]
|
||||
@@ -57,6 +58,21 @@ impl CoreGuard {
|
||||
)
|
||||
}
|
||||
|
||||
pub fn start_with_network_config(
|
||||
path: &Path,
|
||||
sink: Arc<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> {
|
||||
self.0.clone()
|
||||
}
|
||||
@@ -93,6 +109,80 @@ impl TestNode {
|
||||
sink,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_network_config(network_config: CoreNetworkConfig) -> Self {
|
||||
let data_dir = tempfile::tempdir().unwrap();
|
||||
let sink = Arc::new(RecordingSink::default());
|
||||
let core =
|
||||
CoreGuard::start_with_network_config(data_dir.path(), sink.clone(), network_config);
|
||||
Self {
|
||||
_data_dir: data_dir,
|
||||
core,
|
||||
sink,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct TestRelay {
|
||||
pub url: String,
|
||||
shutdown: Option<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)]
|
||||
|
||||
@@ -1,7 +1,52 @@
|
||||
mod support;
|
||||
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use support::{receive_with_response, share_path, TestNode};
|
||||
use vnidrop::VnidropError;
|
||||
use vnidrop::{CoreEvent, VnidropError};
|
||||
|
||||
fn wait_for_sender_transfer_event(sender: &TestNode, transfer_id: u64, kind: &str) -> CoreEvent {
|
||||
let started = Instant::now();
|
||||
loop {
|
||||
if let Some(event) = sender.sink.events().into_iter().find(|event| {
|
||||
event.transfer_id == Some(transfer_id)
|
||||
&& event.direction.as_deref() == Some("send")
|
||||
&& event.phase == "transfer"
|
||||
&& event.kind == kind
|
||||
}) {
|
||||
return event;
|
||||
}
|
||||
assert!(
|
||||
started.elapsed() < Duration::from_secs(5),
|
||||
"timed out waiting for sender transfer event {kind}"
|
||||
);
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
}
|
||||
|
||||
fn wait_for_receiver_status(
|
||||
sender: &TestNode,
|
||||
transfer_id: u64,
|
||||
status: &str,
|
||||
) -> vnidrop::ReceiverRequest {
|
||||
let started = Instant::now();
|
||||
loop {
|
||||
if let Some(request) = sender
|
||||
.core
|
||||
.list_receiver_requests(transfer_id)
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.find(|request| request.status == status)
|
||||
{
|
||||
return request;
|
||||
}
|
||||
assert!(
|
||||
started.elapsed() < Duration::from_secs(5),
|
||||
"timed out waiting for receiver status {status}"
|
||||
);
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transfers_file_between_two_cores() {
|
||||
@@ -50,6 +95,12 @@ fn transfers_file_between_two_cores() {
|
||||
artifacts[0].locator,
|
||||
output_dir.path().join("hello.txt").to_string_lossy()
|
||||
);
|
||||
let completed = wait_for_sender_transfer_event(&sender, share.transfer_id, "completed");
|
||||
assert!(completed.data_json.contains("\"connection_id\":"));
|
||||
assert!(completed.data_json.contains("\"request_id\":"));
|
||||
assert!(completed
|
||||
.data_json
|
||||
.contains(receiver.core.status().endpoint_id.as_str()));
|
||||
|
||||
receiver.core.delete_receive_history().unwrap();
|
||||
assert_eq!(receiver.core.list_received_artifacts().unwrap(), artifacts);
|
||||
@@ -135,4 +186,11 @@ fn receive_refuses_to_overwrite_existing_destination() {
|
||||
&& event.kind == "failed"
|
||||
&& event.data_json.contains("\"code\":\"destination_exists\"")
|
||||
}));
|
||||
let failed = wait_for_receiver_status(&sender, share.transfer_id, "failed");
|
||||
assert_eq!(failed.reason.as_deref(), Some("destination_exists"));
|
||||
assert!(sender.sink.events().iter().any(|event| {
|
||||
event.transfer_id == Some(share.transfer_id)
|
||||
&& event.phase == "delivery"
|
||||
&& event.kind == "receiver-failed"
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -321,6 +321,20 @@
|
||||
"ru": "О приложении"
|
||||
}
|
||||
},
|
||||
"app_starting": {
|
||||
"context": "Shown briefly at launch while the core is still starting up.",
|
||||
"translations": {
|
||||
"en": "Starting…",
|
||||
"fr": "Démarrage…",
|
||||
"es": "Iniciando…",
|
||||
"it": "Avvio…",
|
||||
"de": "Wird gestartet…",
|
||||
"pt": "A iniciar…",
|
||||
"pl": "Uruchamianie…",
|
||||
"nl": "Bezig met starten…",
|
||||
"ru": "Запуск…"
|
||||
}
|
||||
},
|
||||
"appearance_auto_description": {
|
||||
"context": "Settings > Appearance: description for the System/auto option.",
|
||||
"translations": {
|
||||
@@ -814,6 +828,20 @@
|
||||
"ru": "Назад"
|
||||
}
|
||||
},
|
||||
"button_more_actions": {
|
||||
"context": "Accessibility label for a button that opens more actions for an item.",
|
||||
"translations": {
|
||||
"en": "More actions",
|
||||
"fr": "Plus d’actions",
|
||||
"es": "Más acciones",
|
||||
"it": "Altre azioni",
|
||||
"de": "Weitere Aktionen",
|
||||
"pt": "Mais ações",
|
||||
"pl": "Więcej działań",
|
||||
"nl": "Meer acties",
|
||||
"ru": "Другие действия"
|
||||
}
|
||||
},
|
||||
"button_cancel": {
|
||||
"context": "Button: cancel the current action or dialog.",
|
||||
"translations": {
|
||||
@@ -1812,9 +1840,6 @@
|
||||
},
|
||||
"notifications_receive_completed_body": {
|
||||
"context": "Notification body shown when an incoming transfer finishes downloading. {transferName} = transfer name.",
|
||||
"targets": [
|
||||
"apple"
|
||||
],
|
||||
"args": [
|
||||
{
|
||||
"name": "transferName",
|
||||
@@ -1835,9 +1860,6 @@
|
||||
},
|
||||
"notifications_receive_completed_title": {
|
||||
"context": "Notification title shown when an incoming transfer finishes downloading.",
|
||||
"targets": [
|
||||
"apple"
|
||||
],
|
||||
"translations": {
|
||||
"en": "Download complete",
|
||||
"fr": "Téléchargement terminé",
|
||||
@@ -1852,9 +1874,6 @@
|
||||
},
|
||||
"notifications_receive_failed_body": {
|
||||
"context": "Notification body shown when an incoming transfer fails. {transferName} = transfer name.",
|
||||
"targets": [
|
||||
"apple"
|
||||
],
|
||||
"args": [
|
||||
{
|
||||
"name": "transferName",
|
||||
@@ -1875,9 +1894,6 @@
|
||||
},
|
||||
"notifications_receive_failed_title": {
|
||||
"context": "Notification title shown when an incoming transfer fails.",
|
||||
"targets": [
|
||||
"apple"
|
||||
],
|
||||
"translations": {
|
||||
"en": "Download failed",
|
||||
"fr": "Échec du téléchargement",
|
||||
@@ -1892,9 +1908,6 @@
|
||||
},
|
||||
"notifications_receiver_completed_body": {
|
||||
"context": "Notification body shown to the sender when a receiver finishes downloading a shared transfer. {receiver} = receiver name, {transferName} = transfer name.",
|
||||
"targets": [
|
||||
"apple"
|
||||
],
|
||||
"args": [
|
||||
{
|
||||
"name": "receiver",
|
||||
@@ -1919,9 +1932,6 @@
|
||||
},
|
||||
"notifications_receiver_completed_title": {
|
||||
"context": "Notification title shown to the sender when a receiver finishes downloading a shared transfer.",
|
||||
"targets": [
|
||||
"apple"
|
||||
],
|
||||
"translations": {
|
||||
"en": "Transfer received",
|
||||
"fr": "Transfert reçu",
|
||||
@@ -1934,11 +1944,46 @@
|
||||
"ru": "Передача получена"
|
||||
}
|
||||
},
|
||||
"notifications_receiver_failed_body": {
|
||||
"context": "Notification body shown to the sender when a receiver's download fails. {receiver} = receiver name, {transferName} = transfer name.",
|
||||
"args": [
|
||||
{
|
||||
"name": "receiver",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"name": "transferName",
|
||||
"type": "string"
|
||||
}
|
||||
],
|
||||
"translations": {
|
||||
"en": "{receiver} couldn't receive “{transferName}”",
|
||||
"fr": "{receiver} n'a pas pu recevoir « {transferName} »",
|
||||
"es": "{receiver} no pudo recibir «{transferName}»",
|
||||
"it": "{receiver} non ha potuto ricevere “{transferName}”",
|
||||
"de": "{receiver} konnte „{transferName}“ nicht empfangen",
|
||||
"pt": "{receiver} não conseguiu receber “{transferName}”",
|
||||
"pl": "{receiver} nie mógł odebrać „{transferName}”",
|
||||
"nl": "{receiver} kon “{transferName}” niet ontvangen",
|
||||
"ru": "{receiver} не удалось получить «{transferName}»"
|
||||
}
|
||||
},
|
||||
"notifications_receiver_failed_title": {
|
||||
"context": "Notification title shown to the sender when a receiver's download fails.",
|
||||
"translations": {
|
||||
"en": "Delivery failed",
|
||||
"fr": "Échec de l'envoi",
|
||||
"es": "Error en la entrega",
|
||||
"it": "Consegna non riuscita",
|
||||
"de": "Übertragung fehlgeschlagen",
|
||||
"pt": "Falha na entrega",
|
||||
"pl": "Dostarczenie nie powiodło się",
|
||||
"nl": "Levering mislukt",
|
||||
"ru": "Ошибка доставки"
|
||||
}
|
||||
},
|
||||
"notifications_send_failed_body": {
|
||||
"context": "Notification body shown to the sender when a shared transfer fails. {transferName} = transfer name.",
|
||||
"targets": [
|
||||
"apple"
|
||||
],
|
||||
"args": [
|
||||
{
|
||||
"name": "transferName",
|
||||
@@ -1959,9 +2004,6 @@
|
||||
},
|
||||
"notifications_send_failed_title": {
|
||||
"context": "Notification title shown to the sender when a shared transfer fails.",
|
||||
"targets": [
|
||||
"apple"
|
||||
],
|
||||
"translations": {
|
||||
"en": "Sharing failed",
|
||||
"fr": "Échec du partage",
|
||||
@@ -2658,6 +2700,394 @@
|
||||
"ru": "Передача VniDrop"
|
||||
}
|
||||
},
|
||||
"relay_add_url": {
|
||||
"context": "Apple Network settings button that appends another custom relay URL field.",
|
||||
"translations": {
|
||||
"en": "Add relay server",
|
||||
"fr": "Ajouter un serveur relais",
|
||||
"es": "Añadir servidor de retransmisión",
|
||||
"it": "Aggiungi server relay",
|
||||
"de": "Relay-Server hinzufügen",
|
||||
"pt": "Adicionar servidor de retransmissão",
|
||||
"pl": "Dodaj serwer przekaźnikowy",
|
||||
"nl": "Relayserver toevoegen",
|
||||
"ru": "Добавить сервер-ретранслятор"
|
||||
}
|
||||
},
|
||||
"relay_apply": {
|
||||
"context": "Network settings button that activates the selected relay configuration.",
|
||||
"translations": {
|
||||
"en": "Apply network settings",
|
||||
"fr": "Appliquer les réglages réseau",
|
||||
"es": "Aplicar ajustes de red",
|
||||
"it": "Applica impostazioni di rete",
|
||||
"de": "Netzwerkeinstellungen anwenden",
|
||||
"pt": "Aplicar definições de rede",
|
||||
"pl": "Zastosuj ustawienia sieci",
|
||||
"nl": "Netwerkinstellingen toepassen",
|
||||
"ru": "Применить настройки сети"
|
||||
}
|
||||
},
|
||||
"relay_applying": {
|
||||
"context": "Network settings button label while a relay configuration is being activated.",
|
||||
"translations": {
|
||||
"en": "Applying…",
|
||||
"fr": "Application…",
|
||||
"es": "Aplicando…",
|
||||
"it": "Applicazione…",
|
||||
"de": "Wird angewendet…",
|
||||
"pt": "A aplicar…",
|
||||
"pl": "Stosowanie…",
|
||||
"nl": "Toepassen…",
|
||||
"ru": "Применение…"
|
||||
}
|
||||
},
|
||||
"relay_apply_active_transfers": {
|
||||
"context": "Network settings warning when relay configuration cannot change during active work.",
|
||||
"translations": {
|
||||
"en": "Stop all active transfers and shares before applying network settings.",
|
||||
"fr": "Arrêtez tous les transferts et partages actifs avant d’appliquer les réglages réseau.",
|
||||
"es": "Detenga todas las transferencias y elementos compartidos activos antes de aplicar los ajustes de red.",
|
||||
"it": "Interrompa tutti i trasferimenti e le condivisioni attivi prima di applicare le impostazioni di rete.",
|
||||
"de": "Beenden Sie alle aktiven Übertragungen und Freigaben, bevor Sie die Netzwerkeinstellungen anwenden.",
|
||||
"pt": "Pare todas as transferências e partilhas ativas antes de aplicar as definições de rede.",
|
||||
"pl": "Zatrzymaj wszystkie aktywne transfery i udostępnienia przed zastosowaniem ustawień sieci.",
|
||||
"nl": "Stop alle actieve overdrachten en gedeelde items voordat u de netwerkinstellingen toepast.",
|
||||
"ru": "Остановите все активные передачи и раздачи перед применением настроек сети."
|
||||
}
|
||||
},
|
||||
"relay_apply_failed": {
|
||||
"context": "Network settings error after a relay configuration fails and the previous one is restored.",
|
||||
"translations": {
|
||||
"en": "Could not apply these settings. The previous network settings were restored.",
|
||||
"fr": "Impossible d’appliquer ces réglages. Les réglages réseau précédents ont été restaurés.",
|
||||
"es": "No se han podido aplicar estos ajustes. Se han restaurado los ajustes de red anteriores.",
|
||||
"it": "Impossibile applicare queste impostazioni. Sono state ripristinate le impostazioni di rete precedenti.",
|
||||
"de": "Diese Einstellungen konnten nicht angewendet werden. Die vorherigen Netzwerkeinstellungen wurden wiederhergestellt.",
|
||||
"pt": "Não foi possível aplicar estas definições. As definições de rede anteriores foram restauradas.",
|
||||
"pl": "Nie udało się zastosować tych ustawień. Przywrócono poprzednie ustawienia sieci.",
|
||||
"nl": "Deze instellingen konden niet worden toegepast. De vorige netwerkinstellingen zijn hersteld.",
|
||||
"ru": "Не удалось применить эти настройки. Предыдущие настройки сети восстановлены."
|
||||
}
|
||||
},
|
||||
"relay_apply_restart_description": {
|
||||
"context": "Network settings explanation of restart and invitation effects when applying relay changes.",
|
||||
"translations": {
|
||||
"en": "Applying restarts VniDrop’s network connection. Stop active transfers and shares first. Existing invitations may need to be shared again.",
|
||||
"fr": "L’application de ces réglages redémarre la connexion réseau de VniDrop. Arrêtez d’abord les transferts et partages actifs. Il peut être nécessaire de partager à nouveau les invitations existantes.",
|
||||
"es": "Al aplicar los ajustes, se reinicia la conexión de red de VniDrop. Detenga primero las transferencias y los elementos compartidos activos. Es posible que tenga que volver a compartir las invitaciones existentes.",
|
||||
"it": "L’applicazione riavvia la connessione di rete di VniDrop. Interrompa prima i trasferimenti e le condivisioni attivi. Potrebbe essere necessario condividere di nuovo gli inviti esistenti.",
|
||||
"de": "Beim Anwenden wird die Netzwerkverbindung von VniDrop neu gestartet. Beenden Sie zuerst aktive Übertragungen und Freigaben. Vorhandene Einladungen müssen eventuell erneut geteilt werden.",
|
||||
"pt": "A aplicação reinicia a ligação de rede do VniDrop. Pare primeiro as transferências e partilhas ativas. Poderá ser necessário voltar a partilhar os convites existentes.",
|
||||
"pl": "Zastosowanie ustawień ponownie uruchamia połączenie sieciowe VniDrop. Najpierw zatrzymaj aktywne transfery i udostępnienia. Istniejące zaproszenia mogą wymagać ponownego udostępnienia.",
|
||||
"nl": "Bij het toepassen wordt de netwerkverbinding van VniDrop opnieuw gestart. Stop eerst actieve overdrachten en gedeelde items. Bestaande uitnodigingen moeten mogelijk opnieuw worden gedeeld.",
|
||||
"ru": "При применении сетевое соединение VniDrop перезапускается. Сначала остановите активные передачи и раздачи. Возможно, существующие приглашения потребуется отправить повторно."
|
||||
}
|
||||
},
|
||||
"relay_custom_urls_help": {
|
||||
"context": "Network settings help for entering custom relay server URLs.",
|
||||
"translations": {
|
||||
"en": "Enter one HTTPS relay URL per line. URL credentials are not supported. The TLS certificate must be issued by a publicly trusted certificate authority.",
|
||||
"fr": "Saisissez une URL de relais HTTPS par ligne. Les identifiants dans les URL ne sont pas pris en charge. Le certificat TLS doit être émis par une autorité de certification reconnue publiquement.",
|
||||
"es": "Introduzca una URL HTTPS de relé por línea. No se admiten credenciales en las URL. El certificado TLS debe ser emitido por una autoridad de certificación de confianza pública.",
|
||||
"it": "Inserisca un URL relay HTTPS per riga. Le credenziali negli URL non sono supportate. Il certificato TLS deve essere emesso da un’autorità di certificazione pubblicamente attendibile.",
|
||||
"de": "Geben Sie pro Zeile eine HTTPS-Relay-URL ein. Anmeldedaten in URLs werden nicht unterstützt. Das TLS-Zertifikat muss von einer öffentlich vertrauenswürdigen Zertifizierungsstelle ausgestellt sein.",
|
||||
"pt": "Introduza um URL HTTPS de retransmissor por linha. Não são suportadas credenciais nos URLs. O certificado TLS tem de ser emitido por uma autoridade de certificação publicamente reconhecida.",
|
||||
"pl": "Wprowadź po jednym adresie URL HTTPS przekaźnika w każdym wierszu. Dane logowania w adresach URL nie są obsługiwane. Certyfikat TLS musi być wystawiony przez publicznie zaufany urząd certyfikacji.",
|
||||
"nl": "Voer per regel één HTTPS-relay-URL in. Aanmeldgegevens in URL's worden niet ondersteund. Het TLS-certificaat moet zijn uitgegeven door een openbaar vertrouwde certificeringsinstantie.",
|
||||
"ru": "Введите по одному HTTPS-адресу ретранслятора в строке. Учётные данные в URL-адресах не поддерживаются. Сертификат TLS должен быть выдан общедоступным доверенным центром сертификации."
|
||||
}
|
||||
},
|
||||
"relay_custom_urls_label": {
|
||||
"context": "Network settings label for the custom relay URL input.",
|
||||
"translations": {
|
||||
"en": "Relay URLs",
|
||||
"fr": "URL des relais",
|
||||
"es": "URL de relés",
|
||||
"it": "URL relay",
|
||||
"de": "Relay-URLs",
|
||||
"pt": "URLs dos retransmissores",
|
||||
"pl": "Adresy URL przekaźników",
|
||||
"nl": "Relay-URL's",
|
||||
"ru": "URL-адреса ретрансляторов"
|
||||
}
|
||||
},
|
||||
"relay_mode_automatic": {
|
||||
"context": "Network settings label for VniDrop's automatic public relay mode.",
|
||||
"translations": {
|
||||
"en": "Automatic (recommended)",
|
||||
"fr": "Automatique (recommandé)",
|
||||
"es": "Automático (recomendado)",
|
||||
"it": "Automatica (consigliata)",
|
||||
"de": "Automatisch (empfohlen)",
|
||||
"pt": "Automático (recomendado)",
|
||||
"pl": "Automatyczny (zalecany)",
|
||||
"nl": "Automatisch (aanbevolen)",
|
||||
"ru": "Автоматически (рекомендуется)"
|
||||
}
|
||||
},
|
||||
"relay_mode_automatic_description": {
|
||||
"context": "Network settings description of automatic public relay behavior.",
|
||||
"translations": {
|
||||
"en": "Use VniDrop’s default public relay infrastructure when a direct connection is unavailable.",
|
||||
"fr": "Utiliser l’infrastructure de relais publique par défaut de VniDrop lorsqu’une connexion directe est indisponible.",
|
||||
"es": "Usa la infraestructura pública de relés predeterminada de VniDrop cuando no haya una conexión directa disponible.",
|
||||
"it": "Usa l’infrastruttura relay pubblica predefinita di VniDrop quando non è disponibile una connessione diretta.",
|
||||
"de": "Verwendet die öffentliche Standard-Relay-Infrastruktur von VniDrop, wenn keine direkte Verbindung möglich ist.",
|
||||
"pt": "Utiliza a infraestrutura pública de retransmissores predefinida do VniDrop quando não está disponível uma ligação direta.",
|
||||
"pl": "Używa domyślnej publicznej infrastruktury przekaźników VniDrop, gdy połączenie bezpośrednie jest niedostępne.",
|
||||
"nl": "Gebruikt de standaard openbare relay-infrastructuur van VniDrop wanneer geen directe verbinding beschikbaar is.",
|
||||
"ru": "Использовать стандартную публичную инфраструктуру ретрансляторов VniDrop, если прямое соединение недоступно."
|
||||
}
|
||||
},
|
||||
"relay_mode_custom": {
|
||||
"context": "Network settings label for strict custom relay mode.",
|
||||
"translations": {
|
||||
"en": "Strict custom",
|
||||
"fr": "Personnalisé strict",
|
||||
"es": "Personalizado estricto",
|
||||
"it": "Personalizzata rigorosa",
|
||||
"de": "Strikt benutzerdefiniert",
|
||||
"pt": "Personalizado estrito",
|
||||
"pl": "Ścisły niestandardowy",
|
||||
"nl": "Strikt aangepast",
|
||||
"ru": "Строго пользовательский"
|
||||
}
|
||||
},
|
||||
"relay_mode_custom_description": {
|
||||
"context": "Network settings description of strict custom relay behavior.",
|
||||
"translations": {
|
||||
"en": "Use only the configured custom relays or direct connections. Report an error if no custom relay can be established.",
|
||||
"fr": "Utilise uniquement les relais personnalisés configurés ou les connexions directes. Signale une erreur si aucun relais personnalisé ne peut être établi.",
|
||||
"es": "Usa solo los relés personalizados configurados o conexiones directas. Informa de un error si no se puede establecer ningún relé personalizado.",
|
||||
"it": "Usa solo i relay personalizzati configurati o connessioni dirette. Segnala un errore se non è possibile stabilire alcun relay personalizzato.",
|
||||
"de": "Verwendet nur die konfigurierten eigenen Relays oder Direktverbindungen. Meldet einen Fehler, wenn kein eigenes Relay erreichbar ist.",
|
||||
"pt": "Utiliza apenas os retransmissores personalizados configurados ou ligações diretas. Apresenta um erro se não for possível estabelecer nenhum retransmissor personalizado.",
|
||||
"pl": "Używa tylko skonfigurowanych własnych przekaźników lub połączeń bezpośrednich. Zgłasza błąd, jeśli nie można połączyć się z żadnym własnym przekaźnikiem.",
|
||||
"nl": "Gebruikt alleen de ingestelde aangepaste relays of rechtstreekse verbindingen. Meldt een fout als geen aangepaste relay bereikbaar is.",
|
||||
"ru": "Использует только настроенные пользовательские ретрансляторы или прямые соединения. Сообщает об ошибке, если ни один пользовательский ретранслятор недоступен."
|
||||
}
|
||||
},
|
||||
"relay_mode_custom_direct_fallback": {
|
||||
"context": "Network settings label for custom relays that allow direct-only startup fallback.",
|
||||
"translations": {
|
||||
"en": "Custom with direct fallback",
|
||||
"fr": "Personnalisé avec repli direct",
|
||||
"es": "Personalizado con conexión directa de reserva",
|
||||
"it": "Personalizzata con ripiego diretto",
|
||||
"de": "Benutzerdefiniert mit direktem Rückfall",
|
||||
"pt": "Personalizado com alternativa direta",
|
||||
"pl": "Niestandardowy z trybem bezpośrednim",
|
||||
"nl": "Aangepast met directe terugval",
|
||||
"ru": "Пользовательский с прямым резервом"
|
||||
}
|
||||
},
|
||||
"relay_mode_custom_direct_fallback_description": {
|
||||
"context": "Network settings description of custom relays with direct-only startup fallback.",
|
||||
"translations": {
|
||||
"en": "Prefer the configured custom relays. If unavailable, continue with direct connections only. Never use public relays.",
|
||||
"fr": "Préfère les relais personnalisés configurés. S’ils sont indisponibles, continue uniquement avec des connexions directes. N’utilise jamais les relais publics.",
|
||||
"es": "Prefiere los relés personalizados configurados. Si no están disponibles, continúa solo con conexiones directas. Nunca usa relés públicos.",
|
||||
"it": "Preferisce i relay personalizzati configurati. Se non sono disponibili, continua solo con connessioni dirette. Non usa mai relay pubblici.",
|
||||
"de": "Bevorzugt die konfigurierten eigenen Relays. Sind sie nicht verfügbar, werden nur Direktverbindungen verwendet. Öffentliche Relays werden nie genutzt.",
|
||||
"pt": "Prefere os retransmissores personalizados configurados. Se não estiverem disponíveis, continua apenas com ligações diretas. Nunca utiliza retransmissores públicos.",
|
||||
"pl": "Preferuje skonfigurowane własne przekaźniki. Jeśli są niedostępne, kontynuuje tylko przez połączenia bezpośrednie. Nigdy nie używa publicznych przekaźników.",
|
||||
"nl": "Geeft de voorkeur aan de ingestelde aangepaste relays. Als die niet beschikbaar zijn, worden alleen rechtstreekse verbindingen gebruikt. Openbare relays worden nooit gebruikt.",
|
||||
"ru": "Предпочитает настроенные пользовательские ретрансляторы. Если они недоступны, продолжает работу только через прямые соединения. Публичные ретрансляторы не используются."
|
||||
}
|
||||
},
|
||||
"relay_mode_local_only": {
|
||||
"context": "Network settings label for direct connections without any relay.",
|
||||
"translations": {
|
||||
"en": "Local only",
|
||||
"fr": "Réseau local uniquement",
|
||||
"es": "Solo red local",
|
||||
"it": "Solo rete locale",
|
||||
"de": "Nur lokales Netzwerk",
|
||||
"pt": "Apenas rede local",
|
||||
"pl": "Tylko sieć lokalna",
|
||||
"nl": "Alleen lokaal netwerk",
|
||||
"ru": "Только локальная сеть"
|
||||
}
|
||||
},
|
||||
"relay_mode_local_only_description": {
|
||||
"context": "Network settings description of direct-only local-network mode.",
|
||||
"translations": {
|
||||
"en": "Disable all relays and allow only direct connections, primarily for devices on the same network.",
|
||||
"fr": "Désactive tous les relais et autorise uniquement les connexions directes, principalement pour les appareils sur le même réseau.",
|
||||
"es": "Desactiva todos los relés y permite solo conexiones directas, principalmente para dispositivos de la misma red.",
|
||||
"it": "Disattiva tutti i relay e consente solo connessioni dirette, soprattutto per dispositivi sulla stessa rete.",
|
||||
"de": "Deaktiviert alle Relays und erlaubt nur Direktverbindungen, hauptsächlich für Geräte im selben Netzwerk.",
|
||||
"pt": "Desativa todos os retransmissores e permite apenas ligações diretas, principalmente para dispositivos na mesma rede.",
|
||||
"pl": "Wyłącza wszystkie przekaźniki i zezwala tylko na połączenia bezpośrednie, głównie dla urządzeń w tej samej sieci.",
|
||||
"nl": "Schakelt alle relays uit en staat alleen rechtstreekse verbindingen toe, vooral voor apparaten in hetzelfde netwerk.",
|
||||
"ru": "Отключает все ретрансляторы и разрешает только прямые соединения, прежде всего для устройств в одной сети."
|
||||
}
|
||||
},
|
||||
"relay_privacy_description": {
|
||||
"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": "Strict custom mode will not start unless at least one configured relay is reachable. VniDrop never uses public relays or public discovery in this mode.",
|
||||
"fr": "Le mode personnalisé strict ne démarre que si au moins un relais configuré est accessible. VniDrop n’utilise jamais de relais ni de découverte publics dans ce mode.",
|
||||
"es": "El modo personalizado estricto no se inicia a menos que se pueda acceder al menos a un relé configurado. VniDrop nunca usa relés ni descubrimiento públicos en este modo.",
|
||||
"it": "La modalità personalizzata rigorosa si avvia solo se almeno un relay configurato è raggiungibile. In questa modalità VniDrop non usa mai relay o rilevamento pubblici.",
|
||||
"de": "Der strikt benutzerdefinierte Modus startet nur, wenn mindestens ein konfiguriertes Relay erreichbar ist. VniDrop verwendet in diesem Modus nie öffentliche Relays oder öffentliche Erkennung.",
|
||||
"pt": "O modo personalizado estrito só inicia se pelo menos um retransmissor configurado estiver acessível. Neste modo, o VniDrop nunca utiliza retransmissores nem descoberta públicos.",
|
||||
"pl": "Ścisły tryb niestandardowy uruchamia się tylko wtedy, gdy co najmniej jeden skonfigurowany przekaźnik jest dostępny. VniDrop nigdy nie używa w tym trybie publicznych przekaźników ani publicznego wykrywania.",
|
||||
"nl": "De strikt aangepaste modus start alleen als minstens één ingestelde relay bereikbaar is. VniDrop gebruikt in deze modus nooit openbare relays of openbare detectie.",
|
||||
"ru": "Строго пользовательский режим запускается, только если доступен хотя бы один настроенный ретранслятор. В этом режиме VniDrop никогда не использует публичные ретрансляторы или публичное обнаружение."
|
||||
}
|
||||
},
|
||||
"relay_validation_duplicate_url": {
|
||||
"context": "Network settings validation error for a repeated custom relay URL.",
|
||||
"args": [
|
||||
{
|
||||
"name": "line",
|
||||
"type": "int"
|
||||
}
|
||||
],
|
||||
"translations": {
|
||||
"en": "Relay URL on line {line} duplicates an earlier entry.",
|
||||
"fr": "L’URL de relais à la ligne {line} est identique à une entrée précédente.",
|
||||
"es": "La URL de relé de la línea {line} duplica una entrada anterior.",
|
||||
"it": "L’URL relay alla riga {line} duplica una voce precedente.",
|
||||
"de": "Die Relay-URL in Zeile {line} ist bereits zuvor eingetragen.",
|
||||
"pt": "O URL do retransmissor na linha {line} duplica uma entrada anterior.",
|
||||
"pl": "Adres URL przekaźnika w wierszu {line} powtarza wcześniejszy wpis.",
|
||||
"nl": "De relay-URL op regel {line} is gelijk aan een eerdere invoer.",
|
||||
"ru": "URL-адрес ретранслятора в строке {line} повторяет предыдущую запись."
|
||||
}
|
||||
},
|
||||
"relay_validation_https_required": {
|
||||
"context": "Network settings validation error when a custom relay URL is not HTTPS.",
|
||||
"args": [
|
||||
{
|
||||
"name": "line",
|
||||
"type": "int"
|
||||
}
|
||||
],
|
||||
"translations": {
|
||||
"en": "Relay URL on line {line} must start with https://.",
|
||||
"fr": "L’URL de relais à la ligne {line} doit commencer par https://.",
|
||||
"es": "La URL de relé de la línea {line} debe empezar por https://.",
|
||||
"it": "L’URL relay alla riga {line} deve iniziare con https://.",
|
||||
"de": "Die Relay-URL in Zeile {line} muss mit https:// beginnen.",
|
||||
"pt": "O URL do retransmissor na linha {line} tem de começar por https://.",
|
||||
"pl": "Adres URL przekaźnika w wierszu {line} musi zaczynać się od https://.",
|
||||
"nl": "De relay-URL op regel {line} moet beginnen met https://.",
|
||||
"ru": "URL-адрес ретранслятора в строке {line} должен начинаться с https://."
|
||||
}
|
||||
},
|
||||
"relay_validation_invalid_url": {
|
||||
"context": "Network settings validation error for a malformed custom relay URL.",
|
||||
"args": [
|
||||
{
|
||||
"name": "line",
|
||||
"type": "int"
|
||||
}
|
||||
],
|
||||
"translations": {
|
||||
"en": "Relay URL on line {line} is not valid.",
|
||||
"fr": "L’URL de relais à la ligne {line} n’est pas valide.",
|
||||
"es": "La URL de relé de la línea {line} no es válida.",
|
||||
"it": "L’URL relay alla riga {line} non è valido.",
|
||||
"de": "Die Relay-URL in Zeile {line} ist ungültig.",
|
||||
"pt": "O URL do retransmissor na linha {line} não é válido.",
|
||||
"pl": "Adres URL przekaźnika w wierszu {line} jest nieprawidłowy.",
|
||||
"nl": "De relay-URL op regel {line} is ongeldig.",
|
||||
"ru": "URL-адрес ретранслятора в строке {line} недействителен."
|
||||
}
|
||||
},
|
||||
"relay_validation_missing_url": {
|
||||
"context": "Network settings validation error when custom mode has no relay URL.",
|
||||
"translations": {
|
||||
"en": "Add at least one relay URL.",
|
||||
"fr": "Ajoutez au moins une URL de relais.",
|
||||
"es": "Añada al menos una URL de relé.",
|
||||
"it": "Aggiunga almeno un URL relay.",
|
||||
"de": "Fügen Sie mindestens eine Relay-URL hinzu.",
|
||||
"pt": "Adicione pelo menos um URL de retransmissor.",
|
||||
"pl": "Dodaj co najmniej jeden adres URL przekaźnika.",
|
||||
"nl": "Voeg ten minste één relay-URL toe.",
|
||||
"ru": "Добавьте хотя бы один URL-адрес ретранслятора."
|
||||
}
|
||||
},
|
||||
"relay_validation_too_many_urls": {
|
||||
"context": "Network settings validation error when too many custom relay URLs are entered.",
|
||||
"args": [
|
||||
{
|
||||
"name": "maximum",
|
||||
"type": "int"
|
||||
}
|
||||
],
|
||||
"translations": {
|
||||
"en": "You can configure up to {maximum} relay servers.",
|
||||
"fr": "Vous pouvez configurer jusqu’à {maximum} serveurs relais.",
|
||||
"es": "Puede configurar hasta {maximum} servidores de retransmisión.",
|
||||
"it": "Può configurare fino a {maximum} server relay.",
|
||||
"de": "Sie können bis zu {maximum} Relay-Server konfigurieren.",
|
||||
"pt": "Pode configurar até {maximum} servidores de retransmissão.",
|
||||
"pl": "Możesz skonfigurować maksymalnie {maximum} serwerów przekaźnikowych.",
|
||||
"nl": "U kunt maximaal {maximum} relayservers instellen.",
|
||||
"ru": "Можно настроить до {maximum} серверов-ретрансляторов."
|
||||
}
|
||||
},
|
||||
"send_access_anyone": {
|
||||
"context": "Send access option: anyone with the invitation can receive.",
|
||||
"translations": {
|
||||
@@ -2986,6 +3416,34 @@
|
||||
"ru": "Ваши передачи"
|
||||
}
|
||||
},
|
||||
"settings_advanced_title": {
|
||||
"context": "Settings overview section header for expert configuration.",
|
||||
"translations": {
|
||||
"en": "Advanced",
|
||||
"fr": "Avancé",
|
||||
"es": "Avanzado",
|
||||
"it": "Avanzate",
|
||||
"de": "Erweitert",
|
||||
"pt": "Avançado",
|
||||
"pl": "Zaawansowane",
|
||||
"nl": "Geavanceerd",
|
||||
"ru": "Дополнительно"
|
||||
}
|
||||
},
|
||||
"settings_network_title": {
|
||||
"context": "Settings overview row and Network settings screen title.",
|
||||
"translations": {
|
||||
"en": "Network",
|
||||
"fr": "Réseau",
|
||||
"es": "Red",
|
||||
"it": "Rete",
|
||||
"de": "Netzwerk",
|
||||
"pt": "Rede",
|
||||
"pl": "Sieć",
|
||||
"nl": "Netwerk",
|
||||
"ru": "Сеть"
|
||||
}
|
||||
},
|
||||
"settings_subtitle": {
|
||||
"context": "Settings screen: subtitle summarizing what's configurable.",
|
||||
"translations": {
|
||||
@@ -3188,13 +3646,64 @@
|
||||
"ru": "Освобождено {size}"
|
||||
}
|
||||
},
|
||||
"storage_clear_transfer_cache": {
|
||||
"context": "Settings > Storage: button that clears cached transfer content (KMP).",
|
||||
"targets": [
|
||||
"kmp"
|
||||
],
|
||||
"translations": {
|
||||
"en": "Clear transfer cache",
|
||||
"fr": "Vider le cache des transferts",
|
||||
"es": "Borrar caché de transferencias",
|
||||
"it": "Svuota cache trasferimenti",
|
||||
"de": "Übertragungscache leeren",
|
||||
"pt": "Limpar cache de transferências",
|
||||
"pl": "Wyczyść pamięć podręczną transferów",
|
||||
"nl": "Overdrachtscache wissen",
|
||||
"ru": "Очистить кэш передач"
|
||||
}
|
||||
},
|
||||
"storage_clear_transfer_cache_description": {
|
||||
"context": "Settings > Storage: description under the clear-transfer-cache button (KMP).",
|
||||
"targets": [
|
||||
"kmp"
|
||||
],
|
||||
"translations": {
|
||||
"en": "Removes cached transfer content after briefly restarting VniDrop. Finish ongoing transfers and stop active shares first. Received files and transfer history are not deleted.",
|
||||
"fr": "Supprime le contenu de transfert en cache qui n’est pas utilisé par une réception en cours ou un partage actif. Les fichiers reçus et l’historique ne sont pas supprimés.",
|
||||
"es": "Elimina el contenido de transferencia en caché que no esté siendo utilizado por una recepción en curso o un recurso compartido activo. Los archivos recibidos y el historial no se eliminan.",
|
||||
"it": "Rimuove il contenuto dei trasferimenti memorizzato nella cache che non è usato da una ricezione in corso o da una condivisione attiva. I file ricevuti e la cronologia non vengono eliminati.",
|
||||
"de": "Entfernt zwischengespeicherte Übertragungsinhalte, die nicht von einem laufenden Empfang oder einer aktiven Freigabe verwendet werden. Empfangene Dateien und der Übertragungsverlauf werden nicht gelöscht.",
|
||||
"pt": "Remove conteúdo de transferência em cache que não esteja a ser utilizado por uma receção em curso ou partilha ativa. Os ficheiros recebidos e o histórico não são eliminados.",
|
||||
"pl": "Usuwa zawartość transferów z pamięci podręcznej, która nie jest używana przez trwające odbieranie ani aktywne udostępnianie. Odebrane pliki i historia nie są usuwane.",
|
||||
"nl": "Verwijdert overdrachtsinhoud uit de cache die niet wordt gebruikt door een lopende ontvangst of actieve share. Ontvangen bestanden en de overdrachtsgeschiedenis worden niet verwijderd.",
|
||||
"ru": "Удаляет кэшированное содержимое передач, которое не используется текущим приёмом или активной раздачей. Полученные файлы и история передач не удаляются."
|
||||
}
|
||||
},
|
||||
"storage_clearing_transfer_cache": {
|
||||
"context": "Settings > Storage: clear-transfer-cache button while clearing is in progress (KMP).",
|
||||
"targets": [
|
||||
"kmp"
|
||||
],
|
||||
"translations": {
|
||||
"en": "Clearing cache…",
|
||||
"fr": "Vidage du cache…",
|
||||
"es": "Borrando caché…",
|
||||
"it": "Svuotamento cache…",
|
||||
"de": "Cache wird geleert…",
|
||||
"pt": "A limpar cache…",
|
||||
"pl": "Czyszczenie pamięci podręcznej…",
|
||||
"nl": "Cache wissen…",
|
||||
"ru": "Очистка кэша…"
|
||||
}
|
||||
},
|
||||
"storage_delete_transfers_caption": {
|
||||
"context": "Settings > Storage: caption under the destructive delete-all button.",
|
||||
"translations": {
|
||||
"en": "Clears your send and receive history and the app's cached share content. Received files on disk are kept.",
|
||||
"fr": "Efface votre historique d'envois et de réceptions ainsi que le contenu de partage mis en cache par l'app. Les fichiers reçus sur le disque sont conservés.",
|
||||
"en": "Clears your send and receive history and the app’s cached share content. Received files on disk are kept.",
|
||||
"fr": "Efface votre historique d’envois et de réceptions ainsi que le contenu de partage mis en cache par l’app. Les fichiers reçus sur le disque sont conservés.",
|
||||
"es": "Borra tu historial de envíos y recepciones y el contenido compartido en caché de la app. Los archivos recibidos en el disco se conservan.",
|
||||
"it": "Cancella la cronologia di invii e ricezioni e i contenuti di condivisione memorizzati dall'app. I file ricevuti sul disco vengono mantenuti.",
|
||||
"it": "Cancella la cronologia di invii e ricezioni e i contenuti di condivisione memorizzati dall’app. I file ricevuti sul disco vengono mantenuti.",
|
||||
"de": "Löscht deinen Sende- und Empfangsverlauf sowie die zwischengespeicherten Freigabeinhalte der App. Empfangene Dateien auf dem Datenträger bleiben erhalten.",
|
||||
"pt": "Limpa o teu histórico de envios e receções e o conteúdo de partilha em cache da app. Os ficheiros recebidos no disco são mantidos.",
|
||||
"pl": "Czyści historię wysyłania i odbierania oraz zapisane w pamięci podręcznej udostępniane treści. Odebrane pliki na dysku zostają zachowane.",
|
||||
@@ -3230,6 +3739,23 @@
|
||||
"ru": "Обновить"
|
||||
}
|
||||
},
|
||||
"storage_transfer_cache_cleared": {
|
||||
"context": "Settings > Storage: confirmation that the transfer cache was cleared (KMP).",
|
||||
"targets": [
|
||||
"kmp"
|
||||
],
|
||||
"translations": {
|
||||
"en": "Transfer cache cleared",
|
||||
"fr": "Cache des transferts vidé",
|
||||
"es": "Caché de transferencias borrada",
|
||||
"it": "Cache trasferimenti svuotata",
|
||||
"de": "Übertragungscache geleert",
|
||||
"pt": "Cache de transferências limpa",
|
||||
"pl": "Wyczyszczono pamięć podręczną transferów",
|
||||
"nl": "Overdrachtscache gewist",
|
||||
"ru": "Кэш передач очищен"
|
||||
}
|
||||
},
|
||||
"storage_unavailable": {
|
||||
"context": "Settings > Storage: shown when usage couldn't be calculated yet.",
|
||||
"translations": {
|
||||
@@ -3289,15 +3815,15 @@
|
||||
"storage_delete_transfers_description": {
|
||||
"context": "Settings > Storage: confirmation body for deleting all transfer records.",
|
||||
"translations": {
|
||||
"en": "This clears all sent and received transfer records from your history. Your received files are not deleted. Cached shared content that is no longer needed is reclaimed automatically, which may take a little time. This can’t be undone.",
|
||||
"fr": "Cela efface de votre historique tous les enregistrements de transferts envoyés et reçus. Vos fichiers reçus ne sont pas supprimés. Le contenu partagé mis en cache qui n’est plus nécessaire est récupéré automatiquement, ce qui peut prendre un peu de temps. Cette action est irréversible.",
|
||||
"es": "Esto borra de su historial todos los registros de transferencias enviadas y recibidas. Sus archivos recibidos no se eliminan. El contenido compartido en caché que ya no se necesita se recupera automáticamente, lo que puede tardar un poco. Esto no se puede deshacer.",
|
||||
"it": "Questo cancella dalla cronologia tutti i record dei trasferimenti inviati e ricevuti. I file ricevuti non vengono eliminati. Il contenuto condiviso nella cache che non serve più viene recuperato automaticamente, operazione che può richiedere un po’ di tempo. Questa azione non può essere annullata.",
|
||||
"de": "Dadurch werden alle Datensätze gesendeter und empfangener Übertragungen aus Ihrem Verlauf gelöscht. Ihre empfangenen Dateien werden nicht gelöscht. Nicht mehr benötigte zwischengespeicherte freigegebene Inhalte werden automatisch bereinigt; dies kann etwas dauern. Dies kann nicht rückgängig gemacht werden.",
|
||||
"pt": "Isto elimina do histórico todos os registos de transferências enviadas e recebidas. Os ficheiros recebidos não são eliminados. O conteúdo partilhado em cache que já não é necessário é recuperado automaticamente, o que pode demorar algum tempo. Esta ação não pode ser anulada.",
|
||||
"pl": "Spowoduje to usunięcie z historii wszystkich rekordów wysłanych i odebranych transferów. Odebrane pliki nie zostaną usunięte. Niepotrzebna już zawartość udostępniona w pamięci podręcznej jest odzyskiwana automatycznie, co może chwilę potrwać. Tej operacji nie można cofnąć.",
|
||||
"nl": "Hiermee worden alle records van verzonden en ontvangen overdrachten uit uw geschiedenis gewist. Uw ontvangen bestanden worden niet verwijderd. Gedeelde inhoud in de cache die niet meer nodig is, wordt automatisch opgeruimd; dit kan enige tijd duren. Dit kan niet ongedaan worden gemaakt.",
|
||||
"ru": "Это удалит из истории все записи об отправленных и полученных передачах. Полученные файлы не удаляются. Кэшированное общее содержимое, которое больше не требуется, освобождается автоматически; это может занять некоторое время. Это действие нельзя отменить."
|
||||
"en": "This clears all sent and received transfer records from your history and immediately reclaims unused transfer cache. Ongoing transfers and received files are not deleted. This can’t be undone.",
|
||||
"fr": "Cela efface tous les transferts envoyés et reçus de l’historique et libère immédiatement le cache inutilisé. Les transferts en cours et les fichiers reçus ne sont pas supprimés. Cette action est irréversible.",
|
||||
"es": "Esto borra del historial todos los registros de transferencias enviadas y recibidas y libera inmediatamente la caché de transferencia no utilizada. Las transferencias en curso y los archivos recibidos no se eliminan. Esto no se puede deshacer.",
|
||||
"it": "Elimina dalla cronologia tutti i trasferimenti inviati e ricevuti e libera immediatamente la cache inutilizzata. I trasferimenti in corso e i file ricevuti non vengono eliminati. Questa azione non può essere annullata.",
|
||||
"de": "Dadurch werden alle Datensätze gesendeter und empfangener Übertragungen aus Ihrem Verlauf gelöscht und nicht benötigter Übertragungscache sofort freigegeben. Laufende Übertragungen und empfangene Dateien werden nicht gelöscht. Dies kann nicht rückgängig gemacht werden.",
|
||||
"pt": "Isto elimina do histórico todas as transferências enviadas e recebidas e liberta imediatamente a cache não utilizada. As transferências em curso e os ficheiros recebidos não são eliminados. Esta ação não pode ser anulada.",
|
||||
"pl": "Usuwa z historii wszystkie wysłane i odebrane transfery oraz natychmiast zwalnia nieużywaną pamięć podręczną. Trwające transfery i odebrane pliki nie są usuwane. Tej operacji nie można cofnąć.",
|
||||
"nl": "Hiermee worden alle verzonden en ontvangen overdrachten uit de geschiedenis gewist en wordt ongebruikte overdrachtscache direct vrijgemaakt. Lopende overdrachten en ontvangen bestanden worden niet verwijderd. Dit kan niet ongedaan worden gemaakt.",
|
||||
"ru": "Это удалит из истории все отправленные и полученные передачи и немедленно освободит неиспользуемый кэш. Текущие передачи и полученные файлы не удаляются. Это действие нельзя отменить."
|
||||
}
|
||||
},
|
||||
"storage_app_data": {
|
||||
@@ -3889,6 +4415,20 @@
|
||||
"ru": "Запрос истёк"
|
||||
}
|
||||
},
|
||||
"transfer_receiver_failed": {
|
||||
"context": "Receiver status: the delivery to this receiver failed.",
|
||||
"translations": {
|
||||
"en": "Delivery failed",
|
||||
"fr": "Échec de l'envoi",
|
||||
"es": "Error en la entrega",
|
||||
"it": "Consegna non riuscita",
|
||||
"de": "Übertragung fehlgeschlagen",
|
||||
"pt": "Falha na entrega",
|
||||
"pl": "Dostarczenie nie powiodło się",
|
||||
"nl": "Levering mislukt",
|
||||
"ru": "Ошибка доставки"
|
||||
}
|
||||
},
|
||||
"transfer_receiver_refused": {
|
||||
"context": "Receiver status: the request was refused.",
|
||||
"translations": {
|
||||
@@ -3999,6 +4539,20 @@
|
||||
"ru": "Получатели"
|
||||
}
|
||||
},
|
||||
"transfer_qr_unavailable": {
|
||||
"context": "Transfer share: shown when an invitation is too large to encode as a QR code.",
|
||||
"translations": {
|
||||
"en": "QR unavailable for this invitation. Use Share or Download instead.",
|
||||
"fr": "Le code QR n’est pas disponible pour cette invitation. Utilisez plutôt Partager ou Télécharger.",
|
||||
"es": "El QR no está disponible para esta invitación. Use Compartir o Descargar.",
|
||||
"it": "Il codice QR non è disponibile per questo invito. Utilizzi invece Condividi o Scarica.",
|
||||
"de": "Für diese Einladung ist kein QR-Code verfügbar. Verwenden Sie stattdessen Teilen oder Herunterladen.",
|
||||
"pt": "O código QR não está disponível para este convite. Utilize Partilhar ou Transferir.",
|
||||
"pl": "Kod QR jest niedostępny dla tego zaproszenia. Zamiast tego użyj opcji Udostępnij lub Pobierz.",
|
||||
"nl": "QR is niet beschikbaar voor deze uitnodiging. Gebruik in plaats daarvan Delen of Downloaden.",
|
||||
"ru": "QR-код недоступен для этого приглашения. Используйте «Поделиться» или «Скачать»."
|
||||
}
|
||||
},
|
||||
"transfer_scan_qr": {
|
||||
"context": "Transfer share: caption under the QR code.",
|
||||
"translations": {
|
||||
|
||||
@@ -32,7 +32,7 @@ lists, animation, accessibility:
|
||||
| Architecture | Keep **MVVM-style** ViewModels: immutable `*State`, `StateFlow`, **named methods**. Do not force MVI `onEvent` sealed hierarchies unless asked. |
|
||||
| Structure | Feature packages under `com.vnidrop.app.feature.*`; thin route/wiring + screen/composables. |
|
||||
| Theme | Only `LocalVniDropColors` / `VniDropThemeTokens` (`ui/theme/VniDropTheme.kt`). Brand primary light ≈ `#A855F7` (HSL 271, 91%, 65%). |
|
||||
| Strings | CMP composeResources / `Res.string.*` — not Android `R` in `commonMain`. |
|
||||
| Strings | CMP composeResources / `Res.string.*` — not Android `R` in `commonMain`. `values*/strings.xml` are **generated** from `localization/strings.json` (source of truth) via the loc CLI — add/edit keys there, never in the XML. |
|
||||
| DI | Follow existing `AppGraph` construction; no unprompted Hilt/Koin migration. |
|
||||
| Platform | `androidMain` / `jvmMain` for pickers, SAF, NFC/QR, and desktop integration. |
|
||||
| Dependencies | Before adding Jetpack/AndroidX to `commonMain`, verify multiplatform artifacts for all targets. |
|
||||
|
||||
@@ -103,7 +103,31 @@ private class AndroidFileSystemService(
|
||||
return ReceivedStorageInspection(bytes, existing, missing, inaccessible)
|
||||
}
|
||||
|
||||
override suspend fun temporaryUsage(): ULong = directorySize(context.cacheDir)
|
||||
override suspend fun temporaryUsage(receiveFolder: ReceiveFolder): ULong = directorySize(context.cacheDir)
|
||||
|
||||
override suspend fun reclaimTemporaryStorage(appDataDir: String, receiveFolder: ReceiveFolder): ULong {
|
||||
var reclaimed = 0UL
|
||||
context.cacheDir.listFiles().orEmpty().forEach { entry ->
|
||||
val size = if (entry.isDirectory) directorySize(entry) else entry.length().coerceAtLeast(0L).toULong()
|
||||
if (entry.deleteRecursively()) reclaimed += size
|
||||
}
|
||||
val appDataRoot = File(appDataDir)
|
||||
val appDataIsOwned = runCatching {
|
||||
val appDataPath = appDataRoot.canonicalPath
|
||||
val filesPath = context.filesDir.canonicalPath
|
||||
appDataPath == filesPath || appDataPath.startsWith(filesPath + File.separator)
|
||||
}.getOrDefault(false)
|
||||
if (appDataIsOwned) {
|
||||
appDataRoot.walkTopDown()
|
||||
.filter { it.isDirectory && it.name == ".Trash" }
|
||||
.toList()
|
||||
.forEach { trash ->
|
||||
val size = directorySize(trash)
|
||||
if (trash.deleteRecursively()) reclaimed += size
|
||||
}
|
||||
}
|
||||
return reclaimed
|
||||
}
|
||||
|
||||
override fun createReceiveOutputSink(folder: ReceiveFolder): ReceiveOutputSinkV2? =
|
||||
when (folder.kind) {
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
|
||||
<!-- fluent icon imported by shared/tools/import_platform_icons.py. -->
|
||||
<path android:pathData="M12 7.75C11.0335 7.75 10.25 6.9665 10.25 6C10.25 5.0335 11.0335 4.25 12 4.25C12.9665 4.25 13.75 5.0335 13.75 6C13.75 6.9665 12.9665 7.75 12 7.75ZM12 13.75C11.0335 13.75 10.25 12.9665 10.25 12C10.25 11.0335 11.0335 10.25 12 10.25C12.9665 10.25 13.75 11.0335 13.75 12C13.75 12.9665 12.9665 13.75 12 13.75ZM10.25 18C10.25 18.9665 11.0335 19.75 12 19.75C12.9665 19.75 13.75 18.9665 13.75 18C13.75 17.0335 12.9665 16.25 12 16.25C11.0335 16.25 10.25 17.0335 10.25 18Z" android:fillColor="#FF000000" />
|
||||
</vector>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
|
||||
<!-- fluent icon imported by shared/tools/import_platform_icons.py. -->
|
||||
<path android:pathData="M10.2499 2.99985C10.6641 2.99985 10.9999 3.33571 10.9999 3.74985C10.9999 4.16407 10.6642 4.49985 10.2499 4.49985H6.74994C5.50735 4.49985 4.50002 5.50728 4.49994 6.74985V17.2499L4.51166 17.4803C4.62708 18.6147 5.58511 19.4999 6.74994 19.4999H17.2499C18.4926 19.4999 19.4999 18.4925 19.4999 17.2499V15.2499C19.5 14.8357 19.8358 14.4999 20.2499 14.4999C20.6641 14.4999 20.9999 14.8357 20.9999 15.2499V17.2499C20.9999 19.3209 19.321 20.9999 17.2499 20.9999H6.74994C4.74372 20.9999 3.10545 19.4244 3.00482 17.4432L2.99994 17.2499V6.74985C3.00002 4.67885 4.67892 2.99985 6.74994 2.99985H10.2499ZM14.9365 2.06821C15.2025 1.94588 15.5159 1.98997 15.7382 2.18052L22.7382 8.18052C22.8985 8.31798 22.9937 8.5173 22.9999 8.72837C23.006 8.93935 22.9224 9.14336 22.7704 9.28989L15.7704 16.0399C15.554 16.2486 15.2337 16.3077 14.957 16.1903C14.6801 16.0726 14.4999 15.8007 14.4999 15.4999V12.5243C13.4856 12.5878 12.5295 12.7971 11.5605 13.2928C10.4241 13.8742 9.21636 14.8744 7.87103 16.5956L7.59955 16.9501C7.40582 17.2081 7.06877 17.3138 6.76263 17.2118C6.4564 17.1097 6.24994 16.8227 6.24994 16.4999C6.24996 13.6426 6.93653 10.9108 8.41791 8.87192C9.79374 6.97863 11.8288 5.72528 14.4999 5.52817V2.74985L14.5078 2.64146C14.5441 2.39271 14.7037 2.17528 14.9365 2.06821ZM15.9999 6.24985C15.9999 6.66407 15.6642 6.99985 15.2499 6.99985C12.6932 6.99985 10.8549 8.06982 9.63177 9.75278C8.75381 10.961 8.17788 12.5099 7.91498 14.2459C8.91528 13.1985 9.89191 12.4608 10.8769 11.9569C12.3601 11.1981 13.8009 10.9999 15.2499 10.9999C15.4488 10.9999 15.6396 11.079 15.7802 11.2196C15.9208 11.3602 15.9999 11.551 15.9999 11.7499V13.7342L21.1347 8.78208L15.9999 4.37974V6.24985Z" android:fillColor="#FF000000" />
|
||||
</vector>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
|
||||
<!-- fluent icon imported by shared/tools/import_platform_icons.py. -->
|
||||
<path android:pathData="M8.66535 15.7345C8.90969 15.9075 9.20192 15.9999 9.50129 15.9989V15.9949C9.78358 15.9955 10.0598 15.9132 10.2957 15.7582C10.5316 15.6031 10.7168 15.3822 10.8283 15.1229L11.4413 13.2589C11.5829 12.8366 11.8207 12.4531 12.1361 12.1386C12.4514 11.8241 12.8356 11.5873 13.2583 11.4469L15.0363 10.8689C15.3227 10.7683 15.5701 10.5799 15.7432 10.3305C15.9164 10.0812 16.0065 9.7836 16.0007 9.48009C15.9949 9.17659 15.8936 8.88262 15.7111 8.64003C15.5286 8.39744 15.2743 8.21858 14.9843 8.12889L13.2293 7.55889C12.8048 7.41763 12.419 7.1793 12.1027 6.86283C11.7864 6.54637 11.5483 6.1605 11.4073 5.73589L10.8293 3.95889C10.7285 3.67586 10.542 3.43127 10.2958 3.25908C10.0496 3.0869 9.7559 2.99567 9.45547 2.99809C9.15504 3.00051 8.86282 3.09645 8.61942 3.27258C8.37602 3.44871 8.19353 3.69628 8.09729 3.98089L7.51429 5.77289C7.37371 6.18515 7.14135 6.56012 6.83474 6.8695C6.52813 7.17888 6.15528 7.4146 5.74429 7.55889L3.96729 8.12989C3.75592 8.20382 3.56463 8.32589 3.40855 8.48647C3.25247 8.64704 3.13587 8.84172 3.06798 9.05511C3.00008 9.2685 2.98274 9.49475 3.01733 9.716C3.05193 9.93724 3.1375 10.1474 3.26729 10.3299C3.44418 10.5789 3.69508 10.7657 3.98429 10.8639L5.73829 11.4329C6.16392 11.5753 6.55049 11.815 6.86729 12.1329C6.95236 12.2173 7.03186 12.3072 7.10529 12.4019C7.30447 12.6597 7.45833 12.9495 7.56029 13.2589L8.13829 15.0339C8.23676 15.3166 8.42102 15.5615 8.66535 15.7345ZM8.29129 11.4849C8.17979 11.3392 8.05851 11.2012 7.92829 11.0719H7.93129C7.44841 10.5886 6.85948 10.2247 6.21129 10.0089L4.61129 9.50089L6.22229 8.97689C6.85983 8.75615 7.43806 8.39176 7.91229 7.91189C8.38447 7.42897 8.74047 6.84487 8.95329 6.20389L9.46829 4.62189L9.98429 6.20889C10.1997 6.85585 10.5633 7.44351 11.0462 7.92497C11.529 8.40643 12.1177 8.76837 12.7653 8.98189L14.3843 9.50389L12.7953 10.0189C12.1489 10.2338 11.5616 10.5965 11.08 11.0782C10.5984 11.56 10.2359 12.1474 10.0213 12.7939L9.50629 14.3759L8.99129 12.7909C8.83406 12.3189 8.59732 11.8772 8.29129 11.4849ZM16.332 20.7819C16.1415 20.6461 15.9981 20.4541 15.922 20.2329L15.594 19.2259C15.5302 19.0344 15.4231 18.8602 15.281 18.7169C15.1385 18.574 14.9645 18.4664 14.773 18.4029L13.782 18.0799C13.5516 18.0026 13.3516 17.8541 13.211 17.6559C13.0737 17.4636 13.0002 17.2331 13.001 16.9969C13.0013 16.7591 13.0757 16.5274 13.214 16.3339C13.3512 16.1394 13.5466 15.9934 13.772 15.9169L14.778 15.5909C14.965 15.5245 15.1343 15.4162 15.273 15.2742C15.4117 15.1322 15.516 14.9604 15.578 14.7719L15.902 13.7799C15.9782 13.5557 16.1222 13.3606 16.3141 13.2218C16.5059 13.0829 16.7362 13.0071 16.973 13.0048C17.2099 13.0025 17.4416 13.0738 17.6361 13.2089C17.8306 13.344 17.9784 13.5362 18.059 13.7589L18.388 14.7729C18.4524 14.963 18.5599 15.1356 18.7022 15.2771C18.8445 15.4187 19.0176 15.5254 19.208 15.5889L20.2 15.9119C20.4287 15.9834 20.629 16.1251 20.7727 16.3169C20.9163 16.5086 20.9959 16.7408 21.0002 16.9803C21.0045 17.2199 20.9333 17.4547 20.7967 17.6516C20.6601 17.8484 20.4649 17.9972 20.239 18.0769L19.225 18.4059C19.0349 18.4707 18.8622 18.5781 18.72 18.7199C18.5777 18.8627 18.4705 19.0366 18.407 19.2279L18.085 20.2169C18.0075 20.4472 17.8586 20.6469 17.66 20.7869C17.468 20.9249 17.2375 20.9991 17.001 20.9989C16.7607 20.9989 16.5265 20.9229 16.332 20.7819ZM15.3014 16.999C15.6943 17.1386 16.051 17.3646 16.3452 17.6602C16.6394 17.956 16.8635 18.314 17.001 18.7079C17.1399 18.3158 17.3646 17.9597 17.6587 17.6656C17.9528 17.3715 18.3089 17.1468 18.701 17.0079C18.3026 16.8685 17.9406 16.6412 17.642 16.3429C17.3469 16.049 17.1217 15.6926 16.983 15.2999C16.8449 15.6898 16.6223 16.0444 16.3312 16.3383C16.0407 16.6316 15.689 16.8572 15.3014 16.999Z" android:fillColor="#FF000000" />
|
||||
</vector>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
|
||||
<!-- fluent icon imported by shared/tools/import_platform_icons.py. -->
|
||||
<path android:pathData="M19.25 4.5C19.3881 4.5 19.5 4.61193 19.5 4.75V19.25C19.5 19.3881 19.3881 19.5 19.25 19.5H4.75C4.61193 19.5 4.5 19.3881 4.5 19.25V4.75C4.5 4.61193 4.61193 4.5 4.75 4.5H19.25ZM4.75 3C3.7835 3 3 3.7835 3 4.75V19.25C3 20.2165 3.7835 21 4.75 21H19.25C20.2165 21 21 20.2165 21 19.25V4.75C21 3.7835 20.2165 3 19.25 3H4.75Z" android:fillColor="#FF000000" />
|
||||
</vector>
|
||||
@@ -0,0 +1,7 @@
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
|
||||
<!-- lucide icon imported by shared/tools/import_platform_icons.py. -->
|
||||
<path android:pathData="M 13 12 A 1 1 0 1 0 11 12 A 1 1 0 1 0 13 12" android:fillColor="#00000000" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
|
||||
<path android:pathData="M 13 5 A 1 1 0 1 0 11 5 A 1 1 0 1 0 13 5" android:fillColor="#00000000" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
|
||||
<path android:pathData="M 13 19 A 1 1 0 1 0 11 19 A 1 1 0 1 0 13 19" android:fillColor="#00000000" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
|
||||
</vector>
|
||||
@@ -0,0 +1,9 @@
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
|
||||
<!-- lucide icon imported by shared/tools/import_platform_icons.py. -->
|
||||
<path android:pathData="M 21 5 A 3 3 0 1 0 15 5 A 3 3 0 1 0 21 5" android:fillColor="#00000000" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
|
||||
<path android:pathData="M 9 12 A 3 3 0 1 0 3 12 A 3 3 0 1 0 9 12" android:fillColor="#00000000" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
|
||||
<path android:pathData="M 21 19 A 3 3 0 1 0 15 19 A 3 3 0 1 0 21 19" android:fillColor="#00000000" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
|
||||
<path android:pathData="M 8.59 13.51 L 15.42 17.49" android:fillColor="#00000000" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
|
||||
<path android:pathData="M 15.41 6.51 L 8.59 10.49" android:fillColor="#00000000" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
|
||||
</vector>
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
|
||||
<!-- lucide icon imported by shared/tools/import_platform_icons.py. -->
|
||||
<path android:pathData="M11.017 2.814a1 1 0 0 1 1.966 0l1.051 5.558a2 2 0 0 0 1.594 1.594l5.558 1.051a1 1 0 0 1 0 1.966l-5.558 1.051a2 2 0 0 0-1.594 1.594l-1.051 5.558a1 1 0 0 1-1.966 0l-1.051-5.558a2 2 0 0 0-1.594-1.594l-5.558-1.051a1 1 0 0 1 0-1.966l5.558-1.051a2 2 0 0 0 1.594-1.594z" android:fillColor="#00000000" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
|
||||
<path android:pathData="M20 2v4" android:fillColor="#00000000" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
|
||||
<path android:pathData="M22 4h-4" android:fillColor="#00000000" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
|
||||
<path android:pathData="M 6 20 A 2 2 0 1 0 2 20 A 2 2 0 1 0 6 20" android:fillColor="#00000000" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
|
||||
</vector>
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
|
||||
<!-- lucide icon imported by shared/tools/import_platform_icons.py. -->
|
||||
<path android:pathData="M 22 12 A 10 10 0 1 0 2 12 A 10 10 0 1 0 22 12" android:fillColor="#00000000" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
|
||||
<path android:pathData="M 10 9 H 14 A 1 1 0 0 1 15 10 V 14 A 1 1 0 0 1 14 15 H 10 A 1 1 0 0 1 9 14 V 10 A 1 1 0 0 1 10 9 Z" android:fillColor="#00000000" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
|
||||
</vector>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="960" android:viewportHeight="960">
|
||||
<!-- material icon imported by shared/tools/import_platform_icons.py. -->
|
||||
<path android:fillColor="#FF000000" android:pathData="M480,800Q447,800 423.5,776.5Q400,753 400,720Q400,687 423.5,663.5Q447,640 480,640Q513,640 536.5,663.5Q560,687 560,720Q560,753 536.5,776.5Q513,800 480,800ZM480,560Q447,560 423.5,536.5Q400,513 400,480Q400,447 423.5,423.5Q447,400 480,400Q513,400 536.5,423.5Q560,447 560,480Q560,513 536.5,536.5Q513,560 480,560ZM480,320Q447,320 423.5,296.5Q400,273 400,240Q400,207 423.5,183.5Q447,160 480,160Q513,160 536.5,183.5Q560,207 560,240Q560,273 536.5,296.5Q513,320 480,320Z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="960" android:viewportHeight="960">
|
||||
<!-- material icon imported by shared/tools/import_platform_icons.py. -->
|
||||
<path android:fillColor="#FF000000" android:pathData="M720,880Q670,880 635,845Q600,810 600,760Q600,753 601,745.5Q602,738 604,732L322,568Q305,583 284,591.5Q263,600 240,600Q190,600 155,565Q120,530 120,480Q120,430 155,395Q190,360 240,360Q263,360 284,368.5Q305,377 322,392L604,228Q602,222 601,214.5Q600,207 600,200Q600,150 635,115Q670,80 720,80Q770,80 805,115Q840,150 840,200Q840,250 805,285Q770,320 720,320Q697,320 676,311.5Q655,303 638,288L356,452Q358,458 359,465.5Q360,473 360,480Q360,487 359,494.5Q358,502 356,508L638,672Q655,657 676,648.5Q697,640 720,640Q770,640 805,675Q840,710 840,760Q840,810 805,845Q770,880 720,880ZM720,240Q737,240 748.5,228.5Q760,217 760,200Q760,183 748.5,171.5Q737,160 720,160Q703,160 691.5,171.5Q680,183 680,200Q680,217 691.5,228.5Q703,240 720,240ZM240,520Q257,520 268.5,508.5Q280,497 280,480Q280,463 268.5,451.5Q257,440 240,440Q223,440 211.5,451.5Q200,463 200,480Q200,497 211.5,508.5Q223,520 240,520ZM720,800Q737,800 748.5,788.5Q760,777 760,760Q760,743 748.5,731.5Q737,720 720,720Q703,720 691.5,731.5Q680,743 680,760Q680,777 691.5,788.5Q703,800 720,800ZM720,200Q720,200 720,200Q720,200 720,200Q720,200 720,200Q720,200 720,200Q720,200 720,200Q720,200 720,200Q720,200 720,200Q720,200 720,200ZM240,480Q240,480 240,480Q240,480 240,480Q240,480 240,480Q240,480 240,480Q240,480 240,480Q240,480 240,480Q240,480 240,480Q240,480 240,480ZM720,760Q720,760 720,760Q720,760 720,760Q720,760 720,760Q720,760 720,760Q720,760 720,760Q720,760 720,760Q720,760 720,760Q720,760 720,760Z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
|
||||
<!-- material icon imported by shared/tools/import_platform_icons.py. -->
|
||||
<path android:fillColor="#FF000000" android:pathData="M19,8.3Q18.875,8.3 18.738,8.225Q18.6,8.15 18.55,8L17.75,6.25L16,5.45Q15.85,5.4 15.775,5.262Q15.7,5.125 15.7,5Q15.7,4.875 15.775,4.737Q15.85,4.6 16,4.55L17.75,3.75L18.55,2Q18.6,1.85 18.738,1.775Q18.875,1.7 19,1.7Q19.125,1.7 19.263,1.775Q19.4,1.85 19.45,2L20.25,3.75L22,4.55Q22.15,4.6 22.225,4.737Q22.3,4.875 22.3,5Q22.3,5.125 22.225,5.262Q22.15,5.4 22,5.45L20.25,6.25L19.45,8Q19.4,8.15 19.263,8.225Q19.125,8.3 19,8.3ZM19,22.3Q18.875,22.3 18.738,22.225Q18.6,22.15 18.55,22L17.75,20.25L16,19.45Q15.85,19.4 15.775,19.262Q15.7,19.125 15.7,19Q15.7,18.875 15.775,18.738Q15.85,18.6 16,18.55L17.75,17.75L18.55,16Q18.6,15.85 18.738,15.775Q18.875,15.7 19,15.7Q19.125,15.7 19.263,15.775Q19.4,15.85 19.45,16L20.25,17.75L22,18.55Q22.15,18.6 22.225,18.738Q22.3,18.875 22.3,19Q22.3,19.125 22.225,19.262Q22.15,19.4 22,19.45L20.25,20.25L19.45,22Q19.4,22.15 19.263,22.225Q19.125,22.3 19,22.3ZM9,18.575Q8.725,18.575 8.475,18.425Q8.225,18.275 8.1,18L6.5,14.5L3,12.9Q2.725,12.775 2.575,12.525Q2.425,12.275 2.425,12Q2.425,11.725 2.575,11.475Q2.725,11.225 3,11.1L6.5,9.5L8.1,6Q8.225,5.725 8.475,5.575Q8.725,5.425 9,5.425Q9.275,5.425 9.525,5.575Q9.775,5.725 9.9,6L11.5,9.5L15,11.1Q15.275,11.225 15.425,11.475Q15.575,11.725 15.575,12Q15.575,12.275 15.425,12.525Q15.275,12.775 15,12.9L11.5,14.5L9.9,18Q9.775,18.275 9.525,18.425Q9.275,18.575 9,18.575ZM9,15.15 L10,13 12.15,12 10,11 9,8.85 8,11 5.85,12 8,13ZM9,12Z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="960" android:viewportHeight="960">
|
||||
<!-- material icon imported by shared/tools/import_platform_icons.py. -->
|
||||
<path android:fillColor="#FF000000" android:pathData="M360,640L600,640Q617,640 628.5,628.5Q640,617 640,600L640,360Q640,343 628.5,331.5Q617,320 600,320L360,320Q343,320 331.5,331.5Q320,343 320,360L320,600Q320,617 331.5,628.5Q343,640 360,640ZM480,880Q397,880 324,848.5Q251,817 197,763Q143,709 111.5,636Q80,563 80,480Q80,397 111.5,324Q143,251 197,197Q251,143 324,111.5Q397,80 480,80Q563,80 636,111.5Q709,143 763,197Q817,251 848.5,324Q880,397 880,480Q880,563 848.5,636Q817,709 763,763Q709,817 636,848.5Q563,880 480,880ZM480,800Q614,800 707,707Q800,614 800,480Q800,346 707,253Q614,160 480,160Q346,160 253,253Q160,346 160,480Q160,614 253,707Q346,800 480,800ZM480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Z" />
|
||||
</vector>
|
||||
@@ -22,6 +22,7 @@
|
||||
<string name="about_privacy_title">Datenschutz & Sicherheit</string>
|
||||
<string name="about_tagline">Senden Sie Dateien direkt. Behalten Sie die Kontrolle darüber, wer sie empfängt.</string>
|
||||
<string name="about_title">Über</string>
|
||||
<string name="app_starting">Wird gestartet…</string>
|
||||
<string name="appearance_auto_description">Der hellen oder dunklen Darstellung dieses Geräts folgen.</string>
|
||||
<string name="appearance_dark_mode">Dunkelmodus</string>
|
||||
<string name="appearance_light_mode">Hellmodus</string>
|
||||
@@ -54,6 +55,7 @@
|
||||
<string name="bug_report_what_label">Was ist passiert?</string>
|
||||
<string name="button_approve">Genehmigen</string>
|
||||
<string name="button_back">Zurück</string>
|
||||
<string name="button_more_actions">Weitere Aktionen</string>
|
||||
<string name="button_cancel">Abbrechen</string>
|
||||
<string name="button_cancel_receive">Abbrechen</string>
|
||||
<string name="button_change_files">Dateien ändern</string>
|
||||
@@ -121,6 +123,16 @@
|
||||
<string name="notifications_enabled_message">Mitteilungen aktiviert.</string>
|
||||
<string name="notifications_local_title">Mitteilungen erlauben</string>
|
||||
<string name="notifications_permission_denied">Mitteilungen sind für VniDrop deaktiviert. Sie können sie in den Einstellungen aktivieren.</string>
|
||||
<string name="notifications_receive_completed_body">„%1$s“ wurde vollständig heruntergeladen.</string>
|
||||
<string name="notifications_receive_completed_title">Download abgeschlossen</string>
|
||||
<string name="notifications_receive_failed_body">„%1$s“ konnte nicht empfangen werden.</string>
|
||||
<string name="notifications_receive_failed_title">Download fehlgeschlagen</string>
|
||||
<string name="notifications_receiver_completed_body">%1$s hat „%2$s“ vollständig empfangen.</string>
|
||||
<string name="notifications_receiver_completed_title">Übertragung empfangen</string>
|
||||
<string name="notifications_receiver_failed_body">%1$s konnte „%2$s“ nicht empfangen</string>
|
||||
<string name="notifications_receiver_failed_title">Übertragung fehlgeschlagen</string>
|
||||
<string name="notifications_send_failed_body">„%1$s“ konnte nicht geteilt werden.</string>
|
||||
<string name="notifications_send_failed_title">Freigabe fehlgeschlagen</string>
|
||||
<string name="notifications_settings_open_failed">Die Mitteilungseinstellungen konnten nicht geöffnet werden.</string>
|
||||
<string name="notifications_title">Mitteilungen</string>
|
||||
<string name="notifications_unsupported">Mitteilungen sind auf diesem Gerät nicht verfügbar.</string>
|
||||
@@ -169,6 +181,32 @@
|
||||
<string name="receive_review_title">Übertragung prüfen</string>
|
||||
<string name="receive_title">Empfangen</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 (empfohlen)</string>
|
||||
<string name="relay_mode_automatic_description">Verwendet die öffentliche Standard-Relay-Infrastruktur von VniDrop, wenn keine direkte Verbindung möglich ist.</string>
|
||||
<string name="relay_mode_custom">Strikt benutzerdefiniert</string>
|
||||
<string name="relay_mode_custom_description">Verwendet nur die konfigurierten eigenen Relays oder Direktverbindungen. Meldet einen Fehler, wenn kein eigenes Relay erreichbar ist.</string>
|
||||
<string name="relay_mode_custom_direct_fallback">Benutzerdefiniert mit direktem Rückfall</string>
|
||||
<string name="relay_mode_custom_direct_fallback_description">Bevorzugt die konfigurierten eigenen Relays. Sind sie nicht verfügbar, werden nur Direktverbindungen verwendet. Öffentliche Relays werden nie genutzt.</string>
|
||||
<string name="relay_mode_local_only">Nur lokales Netzwerk</string>
|
||||
<string name="relay_mode_local_only_description">Deaktiviert alle Relays und erlaubt nur Direktverbindungen, hauptsächlich für Geräte im selben Netzwerk.</string>
|
||||
<string name="relay_privacy_description">Relays leiten verschlüsselten Datenverkehr weiter und können Ihre Dateien nicht lesen, ihr Betreiber kann jedoch Verbindungsmetadaten sehen.</string>
|
||||
<string name="relay_remove_url">Relay-Server entfernen</string>
|
||||
<string name="relay_restore_failed">Die vorherigen Netzwerkeinstellungen konnten nicht wiederhergestellt werden. Starten Sie VniDrop neu und prüfen Sie Ihre Relay-Konfiguration.</string>
|
||||
<string name="relay_settings_applied">Netzwerkeinstellungen angewendet.</string>
|
||||
<string name="relay_strict_warning">Der strikt benutzerdefinierte Modus startet nur, wenn mindestens ein konfiguriertes Relay erreichbar ist. VniDrop verwendet in diesem Modus nie öffentliche Relays oder öffentliche Erkennung.</string>
|
||||
<string name="relay_validation_duplicate_url">Die Relay-URL in Zeile %1$d ist bereits zuvor eingetragen.</string>
|
||||
<string name="relay_validation_https_required">Die Relay-URL in Zeile %1$d muss mit https:// beginnen.</string>
|
||||
<string name="relay_validation_invalid_url">Die Relay-URL in Zeile %1$d ist ungültig.</string>
|
||||
<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_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>
|
||||
@@ -192,6 +230,8 @@
|
||||
<string name="send_transfer_created">Übertragung erstellt.</string>
|
||||
<string name="send_transfer_details_title">Übertragungsdetails</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_title">Einstellungen</string>
|
||||
<string name="snackbar_dismiss">Ausblenden</string>
|
||||
@@ -206,14 +246,18 @@
|
||||
<string name="storage_cleaning">Wird bereinigt…</string>
|
||||
<string name="storage_cleanup_busy">Beende aktive Übertragungen, bevor du Speicher freigibst</string>
|
||||
<string name="storage_cleanup_freed">%1$s freigegeben</string>
|
||||
<string name="storage_clear_transfer_cache">Übertragungscache leeren</string>
|
||||
<string name="storage_clear_transfer_cache_description">Entfernt zwischengespeicherte Übertragungsinhalte, die nicht von einem laufenden Empfang oder einer aktiven Freigabe verwendet werden. Empfangene Dateien und der Übertragungsverlauf werden nicht gelöscht.</string>
|
||||
<string name="storage_clearing_transfer_cache">Cache wird geleert…</string>
|
||||
<string name="storage_delete_transfers_caption">Löscht deinen Sende- und Empfangsverlauf sowie die zwischengespeicherten Freigabeinhalte der App. Empfangene Dateien auf dem Datenträger bleiben erhalten.</string>
|
||||
<string name="storage_free_up_space_caption">Entfernt temporäre Dateien und Reste früherer Übertragungen. Deine Übertragungen und empfangenen Dateien bleiben erhalten.</string>
|
||||
<string name="storage_refresh">Aktualisieren</string>
|
||||
<string name="storage_transfer_cache_cleared">Übertragungscache geleert</string>
|
||||
<string name="storage_unavailable">Die Speichernutzung ist noch nicht verfügbar</string>
|
||||
<string name="storage_usage_header">Auf diesem Gerät</string>
|
||||
<string name="storage_free_up_space">Speicher freigeben</string>
|
||||
<string name="storage_delete_transfers">Alle Übertragungen löschen</string>
|
||||
<string name="storage_delete_transfers_description">Dadurch werden alle Datensätze gesendeter und empfangener Übertragungen aus Ihrem Verlauf gelöscht. Ihre empfangenen Dateien werden nicht gelöscht. Nicht mehr benötigte zwischengespeicherte freigegebene Inhalte werden automatisch bereinigt; dies kann etwas dauern. Dies kann nicht rückgängig gemacht werden.</string>
|
||||
<string name="storage_delete_transfers_description">Dadurch werden alle Datensätze gesendeter und empfangener Übertragungen aus Ihrem Verlauf gelöscht und nicht benötigter Übertragungscache sofort freigegeben. Laufende Übertragungen und empfangene Dateien werden nicht gelöscht. Dies kann nicht rückgängig gemacht werden.</string>
|
||||
<string name="storage_app_data">App-Daten</string>
|
||||
<string name="storage_deleting">Wird gelöscht…</string>
|
||||
<string name="storage_footer">Übertragungsdaten umfassen Ihren Verlauf und zwischengespeicherte Inhalte aktiver Freigaben. Nicht mehr benötigter Cache wird nach dem Löschen der Datensätze automatisch bereinigt; dies kann etwas dauern. Empfangene Dateien werden für diese Übersicht erfasst, aber hier niemals gelöscht.</string>
|
||||
@@ -252,6 +296,7 @@
|
||||
<string name="transfer_receiver_accepted">Genehmigt – wartet auf Abschluss</string>
|
||||
<string name="transfer_receiver_completed">Erfolgreich empfangen</string>
|
||||
<string name="transfer_receiver_expired">Anfrage abgelaufen</string>
|
||||
<string name="transfer_receiver_failed">Übertragung fehlgeschlagen</string>
|
||||
<string name="transfer_receiver_refused">Anfrage abgelehnt</string>
|
||||
<string name="transfer_receiver_requested">Wartet auf Ihre Genehmigung</string>
|
||||
<string name="transfer_receiver_unknown">Status nicht verfügbar</string>
|
||||
@@ -259,6 +304,7 @@
|
||||
<string name="transfer_receivers_description">Anfragen, Genehmigungen und abgeschlossene Zustellungen</string>
|
||||
<string name="transfer_receivers_pending">%1$d warten</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_share_description">QR-Code, Einladungsdatei und Optionen in der Nähe</string>
|
||||
<string name="transfer_share_title">Teilen</string>
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
<string name="about_privacy_title">Privacidad y seguridad</string>
|
||||
<string name="about_tagline">Envíe archivos directamente. Mantenga el control de quién los recibe.</string>
|
||||
<string name="about_title">Acerca de</string>
|
||||
<string name="app_starting">Iniciando…</string>
|
||||
<string name="appearance_auto_description">Seguir la apariencia clara u oscura de este dispositivo.</string>
|
||||
<string name="appearance_dark_mode">Modo oscuro</string>
|
||||
<string name="appearance_light_mode">Modo claro</string>
|
||||
@@ -54,6 +55,7 @@
|
||||
<string name="bug_report_what_label">¿Qué ocurrió?</string>
|
||||
<string name="button_approve">Aprobar</string>
|
||||
<string name="button_back">Atrás</string>
|
||||
<string name="button_more_actions">Más acciones</string>
|
||||
<string name="button_cancel">Cancelar</string>
|
||||
<string name="button_cancel_receive">Cancelar</string>
|
||||
<string name="button_change_files">Cambiar archivos</string>
|
||||
@@ -121,6 +123,16 @@
|
||||
<string name="notifications_enabled_message">Notificaciones activadas.</string>
|
||||
<string name="notifications_local_title">Permitir notificaciones</string>
|
||||
<string name="notifications_permission_denied">Las notificaciones están desactivadas para VniDrop. Puede activarlas en Ajustes.</string>
|
||||
<string name="notifications_receive_completed_body">«%1$s» terminó de descargarse.</string>
|
||||
<string name="notifications_receive_completed_title">Descarga completada</string>
|
||||
<string name="notifications_receive_failed_body">No se pudo recibir «%1$s».</string>
|
||||
<string name="notifications_receive_failed_title">Error en la descarga</string>
|
||||
<string name="notifications_receiver_completed_body">%1$s terminó de recibir «%2$s».</string>
|
||||
<string name="notifications_receiver_completed_title">Transferencia recibida</string>
|
||||
<string name="notifications_receiver_failed_body">%1$s no pudo recibir «%2$s»</string>
|
||||
<string name="notifications_receiver_failed_title">Error en la entrega</string>
|
||||
<string name="notifications_send_failed_body">No se pudo compartir «%1$s».</string>
|
||||
<string name="notifications_send_failed_title">Error al compartir</string>
|
||||
<string name="notifications_settings_open_failed">No se pudieron abrir los ajustes de notificaciones.</string>
|
||||
<string name="notifications_title">Notificaciones</string>
|
||||
<string name="notifications_unsupported">Las notificaciones no están disponibles en este dispositivo.</string>
|
||||
@@ -169,6 +181,32 @@
|
||||
<string name="receive_review_title">Revisar transferencia</string>
|
||||
<string name="receive_title">Recibir</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 (recomendado)</string>
|
||||
<string name="relay_mode_automatic_description">Usa la infraestructura pública de relés predeterminada de VniDrop cuando no haya una conexión directa disponible.</string>
|
||||
<string name="relay_mode_custom">Personalizado estricto</string>
|
||||
<string name="relay_mode_custom_description">Usa solo los relés personalizados configurados o conexiones directas. Informa de un error si no se puede establecer ningún relé personalizado.</string>
|
||||
<string name="relay_mode_custom_direct_fallback">Personalizado con conexión directa de reserva</string>
|
||||
<string name="relay_mode_custom_direct_fallback_description">Prefiere los relés personalizados configurados. Si no están disponibles, continúa solo con conexiones directas. Nunca usa relés públicos.</string>
|
||||
<string name="relay_mode_local_only">Solo red local</string>
|
||||
<string name="relay_mode_local_only_description">Desactiva todos los relés y permite solo conexiones directas, principalmente para dispositivos de la misma red.</string>
|
||||
<string name="relay_privacy_description">Los relés reenvían tráfico cifrado y no pueden leer sus archivos, pero su operador puede observar los metadatos de conexión.</string>
|
||||
<string name="relay_remove_url">Eliminar servidor de retransmisión</string>
|
||||
<string name="relay_restore_failed">No se han podido restaurar los ajustes de red anteriores. Reinicie VniDrop y revise la configuración de relés.</string>
|
||||
<string name="relay_settings_applied">Ajustes de red aplicados.</string>
|
||||
<string name="relay_strict_warning">El modo personalizado estricto no se inicia a menos que se pueda acceder al menos a un relé configurado. VniDrop nunca usa relés ni descubrimiento públicos en este modo.</string>
|
||||
<string name="relay_validation_duplicate_url">La URL de relé de la línea %1$d duplica una entrada anterior.</string>
|
||||
<string name="relay_validation_https_required">La URL de relé de la línea %1$d debe empezar por https://.</string>
|
||||
<string name="relay_validation_invalid_url">La URL de relé de la línea %1$d no es válida.</string>
|
||||
<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_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>
|
||||
@@ -192,6 +230,8 @@
|
||||
<string name="send_transfer_created">Transferencia creada.</string>
|
||||
<string name="send_transfer_details_title">Detalles de la transferencia</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_title">Ajustes</string>
|
||||
<string name="snackbar_dismiss">Descartar</string>
|
||||
@@ -206,14 +246,18 @@
|
||||
<string name="storage_cleaning">Limpiando…</string>
|
||||
<string name="storage_cleanup_busy">Finaliza las transferencias activas antes de liberar espacio</string>
|
||||
<string name="storage_cleanup_freed">Se liberó %1$s</string>
|
||||
<string name="storage_clear_transfer_cache">Borrar caché de transferencias</string>
|
||||
<string name="storage_clear_transfer_cache_description">Elimina el contenido de transferencia en caché que no esté siendo utilizado por una recepción en curso o un recurso compartido activo. Los archivos recibidos y el historial no se eliminan.</string>
|
||||
<string name="storage_clearing_transfer_cache">Borrando caché…</string>
|
||||
<string name="storage_delete_transfers_caption">Borra tu historial de envíos y recepciones y el contenido compartido en caché de la app. Los archivos recibidos en el disco se conservan.</string>
|
||||
<string name="storage_free_up_space_caption">Elimina los archivos temporales y los restos de transferencias anteriores. Tus transferencias y archivos recibidos se conservan.</string>
|
||||
<string name="storage_refresh">Actualizar</string>
|
||||
<string name="storage_transfer_cache_cleared">Caché de transferencias borrada</string>
|
||||
<string name="storage_unavailable">El uso de almacenamiento aún no está disponible</string>
|
||||
<string name="storage_usage_header">En este dispositivo</string>
|
||||
<string name="storage_free_up_space">Liberar espacio</string>
|
||||
<string name="storage_delete_transfers">Eliminar todas las transferencias</string>
|
||||
<string name="storage_delete_transfers_description">Esto borra de su historial todos los registros de transferencias enviadas y recibidas. Sus archivos recibidos no se eliminan. El contenido compartido en caché que ya no se necesita se recupera automáticamente, lo que puede tardar un poco. Esto no se puede deshacer.</string>
|
||||
<string name="storage_delete_transfers_description">Esto borra del historial todos los registros de transferencias enviadas y recibidas y libera inmediatamente la caché de transferencia no utilizada. Las transferencias en curso y los archivos recibidos no se eliminan. Esto no se puede deshacer.</string>
|
||||
<string name="storage_app_data">Datos de la aplicación</string>
|
||||
<string name="storage_deleting">Eliminando…</string>
|
||||
<string name="storage_footer">Los datos de transferencia incluyen su historial y el contenido en caché de los recursos compartidos activos. La caché innecesaria se recupera automáticamente tras eliminar los registros, lo que puede tardar un poco. Los archivos recibidos se registran para este resumen, pero nunca se eliminan aquí.</string>
|
||||
@@ -252,6 +296,7 @@
|
||||
<string name="transfer_receiver_accepted">Aprobado: esperando a que se complete</string>
|
||||
<string name="transfer_receiver_completed">Recibido correctamente</string>
|
||||
<string name="transfer_receiver_expired">Solicitud caducada</string>
|
||||
<string name="transfer_receiver_failed">Error en la entrega</string>
|
||||
<string name="transfer_receiver_refused">Solicitud rechazada</string>
|
||||
<string name="transfer_receiver_requested">Esperando su aprobación</string>
|
||||
<string name="transfer_receiver_unknown">Estado no disponible</string>
|
||||
@@ -259,6 +304,7 @@
|
||||
<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_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_share_description">Código QR, archivo de invitación y opciones cercanas</string>
|
||||
<string name="transfer_share_title">Compartir</string>
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
<string name="about_privacy_title">Confidentialité et sécurité</string>
|
||||
<string name="about_tagline">Envoyez des fichiers directement. Gardez le contrôle de qui les reçoit.</string>
|
||||
<string name="about_title">À propos</string>
|
||||
<string name="app_starting">Démarrage…</string>
|
||||
<string name="appearance_auto_description">Suivre l’apparence claire ou sombre de cet appareil.</string>
|
||||
<string name="appearance_dark_mode">Mode sombre</string>
|
||||
<string name="appearance_light_mode">Mode clair</string>
|
||||
@@ -54,6 +55,7 @@
|
||||
<string name="bug_report_what_label">Que s’est-il passé ?</string>
|
||||
<string name="button_approve">Approuver</string>
|
||||
<string name="button_back">Retour</string>
|
||||
<string name="button_more_actions">Plus d’actions</string>
|
||||
<string name="button_cancel">Annuler</string>
|
||||
<string name="button_cancel_receive">Annuler</string>
|
||||
<string name="button_change_files">Modifier les fichiers</string>
|
||||
@@ -121,6 +123,16 @@
|
||||
<string name="notifications_enabled_message">Notifications activées.</string>
|
||||
<string name="notifications_local_title">Autoriser les notifications</string>
|
||||
<string name="notifications_permission_denied">Les notifications sont désactivées pour VniDrop. Vous pouvez les activer dans les Réglages.</string>
|
||||
<string name="notifications_receive_completed_body">« %1$s » a fini de se télécharger.</string>
|
||||
<string name="notifications_receive_completed_title">Téléchargement terminé</string>
|
||||
<string name="notifications_receive_failed_body">« %1$s » n’a pas pu être reçu.</string>
|
||||
<string name="notifications_receive_failed_title">Échec du téléchargement</string>
|
||||
<string name="notifications_receiver_completed_body">%1$s a fini de recevoir « %2$s ».</string>
|
||||
<string name="notifications_receiver_completed_title">Transfert reçu</string>
|
||||
<string name="notifications_receiver_failed_body">%1$s n\'a pas pu recevoir « %2$s »</string>
|
||||
<string name="notifications_receiver_failed_title">Échec de l\'envoi</string>
|
||||
<string name="notifications_send_failed_body">« %1$s » n’a pas pu être partagé.</string>
|
||||
<string name="notifications_send_failed_title">Échec du partage</string>
|
||||
<string name="notifications_settings_open_failed">Impossible d’ouvrir les réglages de notifications.</string>
|
||||
<string name="notifications_title">Notifications</string>
|
||||
<string name="notifications_unsupported">Les notifications ne sont pas disponibles sur cet appareil.</string>
|
||||
@@ -169,6 +181,32 @@
|
||||
<string name="receive_review_title">Vérifier le transfert</string>
|
||||
<string name="receive_title">Recevoir</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 d’appliquer les réglages réseau.</string>
|
||||
<string name="relay_apply_failed">Impossible d’appliquer ces réglages. Les réglages réseau précédents ont été restaurés.</string>
|
||||
<string name="relay_apply_restart_description">L’application de ces réglages redémarre la connexion réseau de VniDrop. Arrêtez d’abord les transferts et partages actifs. Il peut être nécessaire de partager à nouveau les invitations existantes.</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 (recommandé)</string>
|
||||
<string name="relay_mode_automatic_description">Utiliser l’infrastructure de relais publique par défaut de VniDrop lorsqu’une connexion directe est indisponible.</string>
|
||||
<string name="relay_mode_custom">Personnalisé strict</string>
|
||||
<string name="relay_mode_custom_description">Utilise uniquement les relais personnalisés configurés ou les connexions directes. Signale une erreur si aucun relais personnalisé ne peut être établi.</string>
|
||||
<string name="relay_mode_custom_direct_fallback">Personnalisé avec repli direct</string>
|
||||
<string name="relay_mode_custom_direct_fallback_description">Préfère les relais personnalisés configurés. S’ils sont indisponibles, continue uniquement avec des connexions directes. N’utilise jamais les relais publics.</string>
|
||||
<string name="relay_mode_local_only">Réseau local uniquement</string>
|
||||
<string name="relay_mode_local_only_description">Désactive tous les relais et autorise uniquement les connexions directes, principalement pour les appareils sur le même réseau.</string>
|
||||
<string name="relay_privacy_description">Les relais transmettent du trafic chiffré et ne peuvent pas lire vos fichiers, mais leur opérateur peut observer les métadonnées de connexion.</string>
|
||||
<string name="relay_remove_url">Supprimer le serveur relais</string>
|
||||
<string name="relay_restore_failed">Impossible de restaurer les réglages réseau précédents. Redémarrez VniDrop et vérifiez votre configuration de relais.</string>
|
||||
<string name="relay_settings_applied">Réglages réseau appliqués.</string>
|
||||
<string name="relay_strict_warning">Le mode personnalisé strict ne démarre que si au moins un relais configuré est accessible. VniDrop n’utilise jamais de relais ni de découverte publics dans ce mode.</string>
|
||||
<string name="relay_validation_duplicate_url">L’URL de relais à la ligne %1$d est identique à une entrée précédente.</string>
|
||||
<string name="relay_validation_https_required">L’URL de relais à la ligne %1$d doit commencer par https://.</string>
|
||||
<string name="relay_validation_invalid_url">L’URL de relais à la ligne %1$d n’est 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_description">Aucune approbation requise. À n’utiliser que pour des éléments que vous êtes à l’aise de partager.</string>
|
||||
<string name="send_access_anyone_warning">Toute personne disposant de l’invitation peut télécharger jusqu’à ce que vous arrêtiez le partage. Ne l’utilisez pas pour des éléments privés ou sensibles.</string>
|
||||
@@ -192,6 +230,8 @@
|
||||
<string name="send_transfer_created">Transfert créé.</string>
|
||||
<string name="send_transfer_details_title">Détails du transfert</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, l’emplacement d’enregistrement des transferts, l’apparence et les notifications.</string>
|
||||
<string name="settings_title">Réglages</string>
|
||||
<string name="snackbar_dismiss">Ignorer</string>
|
||||
@@ -206,14 +246,18 @@
|
||||
<string name="storage_cleaning">Nettoyage…</string>
|
||||
<string name="storage_cleanup_busy">Terminez les transferts en cours avant de libérer de l\'espace</string>
|
||||
<string name="storage_cleanup_freed">%1$s libéré</string>
|
||||
<string name="storage_delete_transfers_caption">Efface votre historique d\'envois et de réceptions ainsi que le contenu de partage mis en cache par l\'app. Les fichiers reçus sur le disque sont conservés.</string>
|
||||
<string name="storage_clear_transfer_cache">Vider le cache des transferts</string>
|
||||
<string name="storage_clear_transfer_cache_description">Supprime le contenu de transfert en cache qui n’est pas utilisé par une réception en cours ou un partage actif. Les fichiers reçus et l’historique ne sont pas supprimés.</string>
|
||||
<string name="storage_clearing_transfer_cache">Vidage du cache…</string>
|
||||
<string name="storage_delete_transfers_caption">Efface votre historique d’envois et de réceptions ainsi que le contenu de partage mis en cache par l’app. Les fichiers reçus sur le disque sont conservés.</string>
|
||||
<string name="storage_free_up_space_caption">Supprime les fichiers temporaires et les résidus des transferts précédents. Vos transferts et fichiers reçus sont conservés.</string>
|
||||
<string name="storage_refresh">Actualiser</string>
|
||||
<string name="storage_transfer_cache_cleared">Cache des transferts vidé</string>
|
||||
<string name="storage_unavailable">L\'utilisation du stockage n\'est pas encore disponible</string>
|
||||
<string name="storage_usage_header">Sur cet appareil</string>
|
||||
<string name="storage_free_up_space">Libérer de l\'espace</string>
|
||||
<string name="storage_delete_transfers">Supprimer tous les transferts</string>
|
||||
<string name="storage_delete_transfers_description">Cela efface de votre historique tous les enregistrements de transferts envoyés et reçus. Vos fichiers reçus ne sont pas supprimés. Le contenu partagé mis en cache qui n’est plus nécessaire est récupéré automatiquement, ce qui peut prendre un peu de temps. Cette action est irréversible.</string>
|
||||
<string name="storage_delete_transfers_description">Cela efface tous les transferts envoyés et reçus de l’historique et libère immédiatement le cache inutilisé. Les transferts en cours et les fichiers reçus ne sont pas supprimés. Cette action est irréversible.</string>
|
||||
<string name="storage_app_data">Données de l’app</string>
|
||||
<string name="storage_deleting">Suppression…</string>
|
||||
<string name="storage_footer">Les données de transfert comprennent votre historique et le contenu mis en cache pour les partages actifs. Le cache inutile est récupéré automatiquement après la suppression des enregistrements, ce qui peut prendre un peu de temps. Les fichiers reçus sont suivis pour ce récapitulatif, mais ne sont jamais supprimés ici.</string>
|
||||
@@ -252,6 +296,7 @@
|
||||
<string name="transfer_receiver_accepted">Approuvé — en attente de la fin</string>
|
||||
<string name="transfer_receiver_completed">Reçu avec succès</string>
|
||||
<string name="transfer_receiver_expired">Demande expirée</string>
|
||||
<string name="transfer_receiver_failed">Échec de l\'envoi</string>
|
||||
<string name="transfer_receiver_refused">Demande refusée</string>
|
||||
<string name="transfer_receiver_requested">En attente de votre approbation</string>
|
||||
<string name="transfer_receiver_unknown">Statut indisponible</string>
|
||||
@@ -259,6 +304,7 @@
|
||||
<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_title">Destinataires</string>
|
||||
<string name="transfer_qr_unavailable">Le code QR n’est 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_share_description">QR code, fichier d’invitation et options à proximité</string>
|
||||
<string name="transfer_share_title">Partager</string>
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
<string name="about_privacy_title">Privacy e sicurezza</string>
|
||||
<string name="about_tagline">Invii file direttamente. Mantenga il controllo su chi li riceve.</string>
|
||||
<string name="about_title">Informazioni</string>
|
||||
<string name="app_starting">Avvio…</string>
|
||||
<string name="appearance_auto_description">Segue l’aspetto chiaro o scuro di questo dispositivo.</string>
|
||||
<string name="appearance_dark_mode">Modalità scura</string>
|
||||
<string name="appearance_light_mode">Modalità chiara</string>
|
||||
@@ -54,6 +55,7 @@
|
||||
<string name="bug_report_what_label">Cosa è accaduto?</string>
|
||||
<string name="button_approve">Approva</string>
|
||||
<string name="button_back">Indietro</string>
|
||||
<string name="button_more_actions">Altre azioni</string>
|
||||
<string name="button_cancel">Annulla</string>
|
||||
<string name="button_cancel_receive">Annulla</string>
|
||||
<string name="button_change_files">Cambia file</string>
|
||||
@@ -121,6 +123,16 @@
|
||||
<string name="notifications_enabled_message">Notifiche attivate.</string>
|
||||
<string name="notifications_local_title">Consenti le notifiche</string>
|
||||
<string name="notifications_permission_denied">Le notifiche sono disattivate per VniDrop. Può attivarle in Impostazioni.</string>
|
||||
<string name="notifications_receive_completed_body">«%1$s» è stato scaricato.</string>
|
||||
<string name="notifications_receive_completed_title">Download completato</string>
|
||||
<string name="notifications_receive_failed_body">Impossibile ricevere «%1$s».</string>
|
||||
<string name="notifications_receive_failed_title">Download non riuscito</string>
|
||||
<string name="notifications_receiver_completed_body">%1$s ha finito di ricevere «%2$s».</string>
|
||||
<string name="notifications_receiver_completed_title">Trasferimento ricevuto</string>
|
||||
<string name="notifications_receiver_failed_body">%1$s non ha potuto ricevere “%2$s”</string>
|
||||
<string name="notifications_receiver_failed_title">Consegna non riuscita</string>
|
||||
<string name="notifications_send_failed_body">Impossibile condividere «%1$s».</string>
|
||||
<string name="notifications_send_failed_title">Condivisione non riuscita</string>
|
||||
<string name="notifications_settings_open_failed">Impossibile aprire le impostazioni delle notifiche.</string>
|
||||
<string name="notifications_title">Notifiche</string>
|
||||
<string name="notifications_unsupported">Le notifiche non sono disponibili su questo dispositivo.</string>
|
||||
@@ -169,6 +181,32 @@
|
||||
<string name="receive_review_title">Rivedi trasferimento</string>
|
||||
<string name="receive_title">Ricevi</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">L’applicazione riavvia la connessione di rete di VniDrop. Interrompa prima i trasferimenti e le condivisioni attivi. Potrebbe essere necessario condividere di nuovo gli inviti esistenti.</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 un’autorità di certificazione pubblicamente attendibile.</string>
|
||||
<string name="relay_custom_urls_label">URL relay</string>
|
||||
<string name="relay_mode_automatic">Automatica (consigliata)</string>
|
||||
<string name="relay_mode_automatic_description">Usa l’infrastruttura relay pubblica predefinita di VniDrop quando non è disponibile una connessione diretta.</string>
|
||||
<string name="relay_mode_custom">Personalizzata rigorosa</string>
|
||||
<string name="relay_mode_custom_description">Usa solo i relay personalizzati configurati o connessioni dirette. Segnala un errore se non è possibile stabilire alcun relay personalizzato.</string>
|
||||
<string name="relay_mode_custom_direct_fallback">Personalizzata con ripiego diretto</string>
|
||||
<string name="relay_mode_custom_direct_fallback_description">Preferisce i relay personalizzati configurati. Se non sono disponibili, continua solo con connessioni dirette. Non usa mai relay pubblici.</string>
|
||||
<string name="relay_mode_local_only">Solo rete locale</string>
|
||||
<string name="relay_mode_local_only_description">Disattiva tutti i relay e consente solo connessioni dirette, soprattutto per dispositivi sulla stessa rete.</string>
|
||||
<string name="relay_privacy_description">I relay inoltrano traffico cifrato e non possono leggere i suoi file, ma il loro operatore può osservare i metadati di connessione.</string>
|
||||
<string name="relay_remove_url">Rimuovi server relay</string>
|
||||
<string name="relay_restore_failed">Impossibile ripristinare le impostazioni di rete precedenti. Riavvii VniDrop e verifichi la configurazione dei relay.</string>
|
||||
<string name="relay_settings_applied">Impostazioni di rete applicate.</string>
|
||||
<string name="relay_strict_warning">La modalità personalizzata rigorosa si avvia solo se almeno un relay configurato è raggiungibile. In questa modalità VniDrop non usa mai relay o rilevamento pubblici.</string>
|
||||
<string name="relay_validation_duplicate_url">L’URL relay alla riga %1$d duplica una voce precedente.</string>
|
||||
<string name="relay_validation_https_required">L’URL relay alla riga %1$d deve iniziare con https://.</string>
|
||||
<string name="relay_validation_invalid_url">L’URL 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_description">Nessuna approvazione richiesta. Da usare solo per elementi che non ha problemi a condividere.</string>
|
||||
<string name="send_access_anyone_warning">Chiunque abbia l’invito può scaricare finché non interrompe la condivisione. Non lo usi per elementi privati o sensibili.</string>
|
||||
@@ -192,6 +230,8 @@
|
||||
<string name="send_transfer_created">Trasferimento creato.</string>
|
||||
<string name="send_transfer_details_title">Dettagli del trasferimento</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, l’aspetto e le notifiche.</string>
|
||||
<string name="settings_title">Impostazioni</string>
|
||||
<string name="snackbar_dismiss">Ignora</string>
|
||||
@@ -206,14 +246,18 @@
|
||||
<string name="storage_cleaning">Pulizia…</string>
|
||||
<string name="storage_cleanup_busy">Completa i trasferimenti attivi prima di liberare spazio</string>
|
||||
<string name="storage_cleanup_freed">Liberati %1$s</string>
|
||||
<string name="storage_delete_transfers_caption">Cancella la cronologia di invii e ricezioni e i contenuti di condivisione memorizzati dall\'app. I file ricevuti sul disco vengono mantenuti.</string>
|
||||
<string name="storage_clear_transfer_cache">Svuota cache trasferimenti</string>
|
||||
<string name="storage_clear_transfer_cache_description">Rimuove il contenuto dei trasferimenti memorizzato nella cache che non è usato da una ricezione in corso o da una condivisione attiva. I file ricevuti e la cronologia non vengono eliminati.</string>
|
||||
<string name="storage_clearing_transfer_cache">Svuotamento cache…</string>
|
||||
<string name="storage_delete_transfers_caption">Cancella la cronologia di invii e ricezioni e i contenuti di condivisione memorizzati dall’app. I file ricevuti sul disco vengono mantenuti.</string>
|
||||
<string name="storage_free_up_space_caption">Rimuove i file temporanei e i residui dei trasferimenti precedenti. I tuoi trasferimenti e i file ricevuti vengono mantenuti.</string>
|
||||
<string name="storage_refresh">Aggiorna</string>
|
||||
<string name="storage_transfer_cache_cleared">Cache trasferimenti svuotata</string>
|
||||
<string name="storage_unavailable">L\'utilizzo dello spazio non è ancora disponibile</string>
|
||||
<string name="storage_usage_header">Su questo dispositivo</string>
|
||||
<string name="storage_free_up_space">Libera spazio</string>
|
||||
<string name="storage_delete_transfers">Elimina tutti i trasferimenti</string>
|
||||
<string name="storage_delete_transfers_description">Questo cancella dalla cronologia tutti i record dei trasferimenti inviati e ricevuti. I file ricevuti non vengono eliminati. Il contenuto condiviso nella cache che non serve più viene recuperato automaticamente, operazione che può richiedere un po’ di tempo. Questa azione non può essere annullata.</string>
|
||||
<string name="storage_delete_transfers_description">Elimina dalla cronologia tutti i trasferimenti inviati e ricevuti e libera immediatamente la cache inutilizzata. I trasferimenti in corso e i file ricevuti non vengono eliminati. Questa azione non può essere annullata.</string>
|
||||
<string name="storage_app_data">Dati dell’app</string>
|
||||
<string name="storage_deleting">Eliminazione…</string>
|
||||
<string name="storage_footer">I dati di trasferimento includono la cronologia e il contenuto nella cache per le condivisioni attive. La cache non necessaria viene recuperata automaticamente dopo la rimozione dei record, operazione che può richiedere un po’ di tempo. I file ricevuti vengono monitorati per questo riepilogo, ma non sono mai eliminati qui.</string>
|
||||
@@ -252,6 +296,7 @@
|
||||
<string name="transfer_receiver_accepted">Approvato: in attesa del completamento</string>
|
||||
<string name="transfer_receiver_completed">Ricevuto correttamente</string>
|
||||
<string name="transfer_receiver_expired">Richiesta scaduta</string>
|
||||
<string name="transfer_receiver_failed">Consegna non riuscita</string>
|
||||
<string name="transfer_receiver_refused">Richiesta rifiutata</string>
|
||||
<string name="transfer_receiver_requested">In attesa della sua approvazione</string>
|
||||
<string name="transfer_receiver_unknown">Stato non disponibile</string>
|
||||
@@ -259,6 +304,7 @@
|
||||
<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_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_share_description">Codice QR, file di invito e opzioni nelle vicinanze</string>
|
||||
<string name="transfer_share_title">Condividi</string>
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
<string name="about_privacy_title">Privacy en beveiliging</string>
|
||||
<string name="about_tagline">Verstuur bestanden rechtstreeks. Houd controle over wie ze ontvangt.</string>
|
||||
<string name="about_title">Over</string>
|
||||
<string name="app_starting">Bezig met starten…</string>
|
||||
<string name="appearance_auto_description">De lichte of donkere weergave van dit apparaat volgen.</string>
|
||||
<string name="appearance_dark_mode">Donkere modus</string>
|
||||
<string name="appearance_light_mode">Lichte modus</string>
|
||||
@@ -54,6 +55,7 @@
|
||||
<string name="bug_report_what_label">Wat is er gebeurd?</string>
|
||||
<string name="button_approve">Goedkeuren</string>
|
||||
<string name="button_back">Terug</string>
|
||||
<string name="button_more_actions">Meer acties</string>
|
||||
<string name="button_cancel">Annuleren</string>
|
||||
<string name="button_cancel_receive">Annuleren</string>
|
||||
<string name="button_change_files">Bestanden wijzigen</string>
|
||||
@@ -121,6 +123,16 @@
|
||||
<string name="notifications_enabled_message">Meldingen ingeschakeld.</string>
|
||||
<string name="notifications_local_title">Meldingen toestaan</string>
|
||||
<string name="notifications_permission_denied">Meldingen zijn uitgeschakeld voor VniDrop. U kunt ze inschakelen in Instellingen.</string>
|
||||
<string name="notifications_receive_completed_body">‘%1$s’ is volledig gedownload.</string>
|
||||
<string name="notifications_receive_completed_title">Download voltooid</string>
|
||||
<string name="notifications_receive_failed_body">‘%1$s’ kon niet worden ontvangen.</string>
|
||||
<string name="notifications_receive_failed_title">Download mislukt</string>
|
||||
<string name="notifications_receiver_completed_body">%1$s heeft ‘%2$s’ volledig ontvangen.</string>
|
||||
<string name="notifications_receiver_completed_title">Overdracht ontvangen</string>
|
||||
<string name="notifications_receiver_failed_body">%1$s kon “%2$s” niet ontvangen</string>
|
||||
<string name="notifications_receiver_failed_title">Levering mislukt</string>
|
||||
<string name="notifications_send_failed_body">‘%1$s’ kon niet worden gedeeld.</string>
|
||||
<string name="notifications_send_failed_title">Delen mislukt</string>
|
||||
<string name="notifications_settings_open_failed">De meldingsinstellingen konden niet worden geopend.</string>
|
||||
<string name="notifications_title">Meldingen</string>
|
||||
<string name="notifications_unsupported">Meldingen zijn niet beschikbaar op dit apparaat.</string>
|
||||
@@ -169,6 +181,32 @@
|
||||
<string name="receive_review_title">Overdracht controleren</string>
|
||||
<string name="receive_title">Ontvangen</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 (aanbevolen)</string>
|
||||
<string name="relay_mode_automatic_description">Gebruikt de standaard openbare relay-infrastructuur van VniDrop wanneer geen directe verbinding beschikbaar is.</string>
|
||||
<string name="relay_mode_custom">Strikt aangepast</string>
|
||||
<string name="relay_mode_custom_description">Gebruikt alleen de ingestelde aangepaste relays of rechtstreekse verbindingen. Meldt een fout als geen aangepaste relay bereikbaar is.</string>
|
||||
<string name="relay_mode_custom_direct_fallback">Aangepast met directe terugval</string>
|
||||
<string name="relay_mode_custom_direct_fallback_description">Geeft de voorkeur aan de ingestelde aangepaste relays. Als die niet beschikbaar zijn, worden alleen rechtstreekse verbindingen gebruikt. Openbare relays worden nooit gebruikt.</string>
|
||||
<string name="relay_mode_local_only">Alleen lokaal netwerk</string>
|
||||
<string name="relay_mode_local_only_description">Schakelt alle relays uit en staat alleen rechtstreekse verbindingen toe, vooral voor apparaten in hetzelfde netwerk.</string>
|
||||
<string name="relay_privacy_description">Relays sturen versleuteld verkeer door en kunnen uw bestanden niet lezen, maar de beheerder kan verbindingsmetadata bekijken.</string>
|
||||
<string name="relay_remove_url">Relayserver verwijderen</string>
|
||||
<string name="relay_restore_failed">De vorige netwerkinstellingen konden niet worden hersteld. Start VniDrop opnieuw en controleer uw relayconfiguratie.</string>
|
||||
<string name="relay_settings_applied">Netwerkinstellingen toegepast.</string>
|
||||
<string name="relay_strict_warning">De strikt aangepaste modus start alleen als minstens één ingestelde relay bereikbaar is. VniDrop gebruikt in deze modus nooit openbare relays of openbare detectie.</string>
|
||||
<string name="relay_validation_duplicate_url">De relay-URL op regel %1$d is gelijk aan een eerdere invoer.</string>
|
||||
<string name="relay_validation_https_required">De relay-URL op regel %1$d moet beginnen met https://.</string>
|
||||
<string name="relay_validation_invalid_url">De relay-URL op regel %1$d is ongeldig.</string>
|
||||
<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_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>
|
||||
@@ -192,6 +230,8 @@
|
||||
<string name="send_transfer_created">Overdracht aangemaakt.</string>
|
||||
<string name="send_transfer_details_title">Overdrachtsdetails</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_title">Instellingen</string>
|
||||
<string name="snackbar_dismiss">Sluiten</string>
|
||||
@@ -206,14 +246,18 @@
|
||||
<string name="storage_cleaning">Opschonen…</string>
|
||||
<string name="storage_cleanup_busy">Voltooi actieve overdrachten voordat je ruimte vrijmaakt</string>
|
||||
<string name="storage_cleanup_freed">%1$s vrijgemaakt</string>
|
||||
<string name="storage_clear_transfer_cache">Overdrachtscache wissen</string>
|
||||
<string name="storage_clear_transfer_cache_description">Verwijdert overdrachtsinhoud uit de cache die niet wordt gebruikt door een lopende ontvangst of actieve share. Ontvangen bestanden en de overdrachtsgeschiedenis worden niet verwijderd.</string>
|
||||
<string name="storage_clearing_transfer_cache">Cache wissen…</string>
|
||||
<string name="storage_delete_transfers_caption">Wist je verzend- en ontvangstgeschiedenis en de gecachte deelinhoud van de app. Ontvangen bestanden op schijf blijven behouden.</string>
|
||||
<string name="storage_free_up_space_caption">Verwijdert tijdelijke bestanden en resten van eerdere overdrachten. Je overdrachten en ontvangen bestanden blijven behouden.</string>
|
||||
<string name="storage_refresh">Vernieuwen</string>
|
||||
<string name="storage_transfer_cache_cleared">Overdrachtscache gewist</string>
|
||||
<string name="storage_unavailable">Opslaggebruik is nog niet beschikbaar</string>
|
||||
<string name="storage_usage_header">Op dit apparaat</string>
|
||||
<string name="storage_free_up_space">Ruimte vrijmaken</string>
|
||||
<string name="storage_delete_transfers">Alle overdrachten verwijderen</string>
|
||||
<string name="storage_delete_transfers_description">Hiermee worden alle records van verzonden en ontvangen overdrachten uit uw geschiedenis gewist. Uw ontvangen bestanden worden niet verwijderd. Gedeelde inhoud in de cache die niet meer nodig is, wordt automatisch opgeruimd; dit kan enige tijd duren. Dit kan niet ongedaan worden gemaakt.</string>
|
||||
<string name="storage_delete_transfers_description">Hiermee worden alle verzonden en ontvangen overdrachten uit de geschiedenis gewist en wordt ongebruikte overdrachtscache direct vrijgemaakt. Lopende overdrachten en ontvangen bestanden worden niet verwijderd. Dit kan niet ongedaan worden gemaakt.</string>
|
||||
<string name="storage_app_data">Appgegevens</string>
|
||||
<string name="storage_deleting">Verwijderen…</string>
|
||||
<string name="storage_footer">Overdrachtsgegevens omvatten uw geschiedenis en inhoud in de cache voor actieve shares. Onnodige cache wordt automatisch opgeruimd nadat overdrachtsrecords zijn verwijderd; dit kan enige tijd duren. Ontvangen bestanden worden voor dit overzicht bijgehouden, maar hier nooit verwijderd.</string>
|
||||
@@ -252,6 +296,7 @@
|
||||
<string name="transfer_receiver_accepted">Goedgekeurd — wachten op voltooiing</string>
|
||||
<string name="transfer_receiver_completed">Succesvol ontvangen</string>
|
||||
<string name="transfer_receiver_expired">Verzoek verlopen</string>
|
||||
<string name="transfer_receiver_failed">Levering mislukt</string>
|
||||
<string name="transfer_receiver_refused">Verzoek geweigerd</string>
|
||||
<string name="transfer_receiver_requested">Wachten op uw goedkeuring</string>
|
||||
<string name="transfer_receiver_unknown">Status niet beschikbaar</string>
|
||||
@@ -259,6 +304,7 @@
|
||||
<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_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_share_description">QR-code, uitnodigingsbestand en opties in de buurt</string>
|
||||
<string name="transfer_share_title">Delen</string>
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
<string name="about_privacy_title">Prywatność i bezpieczeństwo</string>
|
||||
<string name="about_tagline">Wysyłaj pliki bezpośrednio. Zachowaj kontrolę nad tym, kto je otrzymuje.</string>
|
||||
<string name="about_title">Informacje</string>
|
||||
<string name="app_starting">Uruchamianie…</string>
|
||||
<string name="appearance_auto_description">Dopasuj do jasnego lub ciemnego wyglądu tego urządzenia.</string>
|
||||
<string name="appearance_dark_mode">Tryb ciemny</string>
|
||||
<string name="appearance_light_mode">Tryb jasny</string>
|
||||
@@ -54,6 +55,7 @@
|
||||
<string name="bug_report_what_label">Co się stało?</string>
|
||||
<string name="button_approve">Zatwierdź</string>
|
||||
<string name="button_back">Wstecz</string>
|
||||
<string name="button_more_actions">Więcej działań</string>
|
||||
<string name="button_cancel">Anuluj</string>
|
||||
<string name="button_cancel_receive">Anuluj</string>
|
||||
<string name="button_change_files">Zmień pliki</string>
|
||||
@@ -121,6 +123,16 @@
|
||||
<string name="notifications_enabled_message">Powiadomienia włączone.</string>
|
||||
<string name="notifications_local_title">Zezwól na powiadomienia</string>
|
||||
<string name="notifications_permission_denied">Powiadomienia są wyłączone dla VniDrop. Możesz je włączyć w Ustawieniach.</string>
|
||||
<string name="notifications_receive_completed_body">Zakończono pobieranie „%1$s”.</string>
|
||||
<string name="notifications_receive_completed_title">Pobieranie zakończone</string>
|
||||
<string name="notifications_receive_failed_body">Nie udało się odebrać „%1$s”.</string>
|
||||
<string name="notifications_receive_failed_title">Pobieranie nie powiodło się</string>
|
||||
<string name="notifications_receiver_completed_body">%1$s zakończył odbieranie „%2$s”.</string>
|
||||
<string name="notifications_receiver_completed_title">Transfer odebrany</string>
|
||||
<string name="notifications_receiver_failed_body">%1$s nie mógł odebrać „%2$s”</string>
|
||||
<string name="notifications_receiver_failed_title">Dostarczenie nie powiodło się</string>
|
||||
<string name="notifications_send_failed_body">Nie udało się udostępnić „%1$s”.</string>
|
||||
<string name="notifications_send_failed_title">Udostępnianie nie powiodło się</string>
|
||||
<string name="notifications_settings_open_failed">Nie udało się otworzyć ustawień powiadomień.</string>
|
||||
<string name="notifications_title">Powiadomienia</string>
|
||||
<string name="notifications_unsupported">Powiadomienia nie są dostępne na tym urządzeniu.</string>
|
||||
@@ -169,6 +181,32 @@
|
||||
<string name="receive_review_title">Przejrzyj transfer</string>
|
||||
<string name="receive_title">Odbierz</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 (zalecany)</string>
|
||||
<string name="relay_mode_automatic_description">Używa domyślnej publicznej infrastruktury przekaźników VniDrop, gdy połączenie bezpośrednie jest niedostępne.</string>
|
||||
<string name="relay_mode_custom">Ścisły niestandardowy</string>
|
||||
<string name="relay_mode_custom_description">Używa tylko skonfigurowanych własnych przekaźników lub połączeń bezpośrednich. Zgłasza błąd, jeśli nie można połączyć się z żadnym własnym przekaźnikiem.</string>
|
||||
<string name="relay_mode_custom_direct_fallback">Niestandardowy z trybem bezpośrednim</string>
|
||||
<string name="relay_mode_custom_direct_fallback_description">Preferuje skonfigurowane własne przekaźniki. Jeśli są niedostępne, kontynuuje tylko przez połączenia bezpośrednie. Nigdy nie używa publicznych przekaźników.</string>
|
||||
<string name="relay_mode_local_only">Tylko sieć lokalna</string>
|
||||
<string name="relay_mode_local_only_description">Wyłącza wszystkie przekaźniki i zezwala tylko na połączenia bezpośrednie, głównie dla urządzeń w tej samej sieci.</string>
|
||||
<string name="relay_privacy_description">Przekaźniki przesyłają zaszyfrowany ruch i nie mogą odczytać plików, ale ich operator może obserwować metadane połączenia.</string>
|
||||
<string name="relay_remove_url">Usuń serwer przekaźnikowy</string>
|
||||
<string name="relay_restore_failed">Nie udało się przywrócić poprzednich ustawień sieci. Uruchom ponownie VniDrop i sprawdź konfigurację przekaźników.</string>
|
||||
<string name="relay_settings_applied">Zastosowano ustawienia sieci.</string>
|
||||
<string name="relay_strict_warning">Ścisły tryb niestandardowy uruchamia się tylko wtedy, gdy co najmniej jeden skonfigurowany przekaźnik jest dostępny. VniDrop nigdy nie używa w tym trybie publicznych przekaźników ani publicznego wykrywania.</string>
|
||||
<string name="relay_validation_duplicate_url">Adres URL przekaźnika w wierszu %1$d powtarza wcześniejszy wpis.</string>
|
||||
<string name="relay_validation_https_required">Adres URL przekaźnika w wierszu %1$d musi zaczynać się od https://.</string>
|
||||
<string name="relay_validation_invalid_url">Adres URL przekaźnika w wierszu %1$d jest nieprawidłowy.</string>
|
||||
<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_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>
|
||||
@@ -192,6 +230,8 @@
|
||||
<string name="send_transfer_created">Transfer utworzony.</string>
|
||||
<string name="send_transfer_details_title">Szczegóły transferu</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_title">Ustawienia</string>
|
||||
<string name="snackbar_dismiss">Zamknij</string>
|
||||
@@ -206,14 +246,18 @@
|
||||
<string name="storage_cleaning">Czyszczenie…</string>
|
||||
<string name="storage_cleanup_busy">Zakończ aktywne transfery przed zwolnieniem miejsca</string>
|
||||
<string name="storage_cleanup_freed">Zwolniono %1$s</string>
|
||||
<string name="storage_clear_transfer_cache">Wyczyść pamięć podręczną transferów</string>
|
||||
<string name="storage_clear_transfer_cache_description">Usuwa zawartość transferów z pamięci podręcznej, która nie jest używana przez trwające odbieranie ani aktywne udostępnianie. Odebrane pliki i historia nie są usuwane.</string>
|
||||
<string name="storage_clearing_transfer_cache">Czyszczenie pamięci podręcznej…</string>
|
||||
<string name="storage_delete_transfers_caption">Czyści historię wysyłania i odbierania oraz zapisane w pamięci podręcznej udostępniane treści. Odebrane pliki na dysku zostają zachowane.</string>
|
||||
<string name="storage_free_up_space_caption">Usuwa pliki tymczasowe i pozostałości po wcześniejszych transferach. Twoje transfery i odebrane pliki zostają zachowane.</string>
|
||||
<string name="storage_refresh">Odśwież</string>
|
||||
<string name="storage_transfer_cache_cleared">Wyczyszczono pamięć podręczną transferów</string>
|
||||
<string name="storage_unavailable">Wykorzystanie pamięci nie jest jeszcze dostępne</string>
|
||||
<string name="storage_usage_header">Na tym urządzeniu</string>
|
||||
<string name="storage_free_up_space">Zwolnij miejsce</string>
|
||||
<string name="storage_delete_transfers">Usuń wszystkie transfery</string>
|
||||
<string name="storage_delete_transfers_description">Spowoduje to usunięcie z historii wszystkich rekordów wysłanych i odebranych transferów. Odebrane pliki nie zostaną usunięte. Niepotrzebna już zawartość udostępniona w pamięci podręcznej jest odzyskiwana automatycznie, co może chwilę potrwać. Tej operacji nie można cofnąć.</string>
|
||||
<string name="storage_delete_transfers_description">Usuwa z historii wszystkie wysłane i odebrane transfery oraz natychmiast zwalnia nieużywaną pamięć podręczną. Trwające transfery i odebrane pliki nie są usuwane. Tej operacji nie można cofnąć.</string>
|
||||
<string name="storage_app_data">Dane aplikacji</string>
|
||||
<string name="storage_deleting">Usuwanie…</string>
|
||||
<string name="storage_footer">Dane transferu obejmują historię oraz zawartość w pamięci podręcznej dla aktywnych udostępnień. Niepotrzebna pamięć podręczna jest odzyskiwana automatycznie po usunięciu rekordów, co może chwilę potrwać. Odebrane pliki są śledzone na potrzeby tego podsumowania, ale nigdy nie są tu usuwane.</string>
|
||||
@@ -252,6 +296,7 @@
|
||||
<string name="transfer_receiver_accepted">Zatwierdzono — oczekiwanie na ukończenie</string>
|
||||
<string name="transfer_receiver_completed">Odebrano pomyślnie</string>
|
||||
<string name="transfer_receiver_expired">Prośba wygasła</string>
|
||||
<string name="transfer_receiver_failed">Dostarczenie nie powiodło się</string>
|
||||
<string name="transfer_receiver_refused">Prośba odrzucona</string>
|
||||
<string name="transfer_receiver_requested">Oczekiwanie na Twoje zatwierdzenie</string>
|
||||
<string name="transfer_receiver_unknown">Status niedostępny</string>
|
||||
@@ -259,6 +304,7 @@
|
||||
<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_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_share_description">Kod QR, plik zaproszenia i opcje w pobliżu</string>
|
||||
<string name="transfer_share_title">Udostępnij</string>
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
<string name="about_privacy_title">Privacidade e segurança</string>
|
||||
<string name="about_tagline">Envie ficheiros diretamente. Mantenha o controlo sobre quem os recebe.</string>
|
||||
<string name="about_title">Acerca de</string>
|
||||
<string name="app_starting">A iniciar…</string>
|
||||
<string name="appearance_auto_description">Acompanhar o aspeto claro ou escuro deste dispositivo.</string>
|
||||
<string name="appearance_dark_mode">Modo escuro</string>
|
||||
<string name="appearance_light_mode">Modo claro</string>
|
||||
@@ -54,6 +55,7 @@
|
||||
<string name="bug_report_what_label">O que aconteceu?</string>
|
||||
<string name="button_approve">Aprovar</string>
|
||||
<string name="button_back">Voltar</string>
|
||||
<string name="button_more_actions">Mais ações</string>
|
||||
<string name="button_cancel">Cancelar</string>
|
||||
<string name="button_cancel_receive">Cancelar</string>
|
||||
<string name="button_change_files">Alterar ficheiros</string>
|
||||
@@ -121,6 +123,16 @@
|
||||
<string name="notifications_enabled_message">Notificações ativadas.</string>
|
||||
<string name="notifications_local_title">Permitir notificações</string>
|
||||
<string name="notifications_permission_denied">As notificações estão desativadas para o VniDrop. Pode ativá-las nas Definições.</string>
|
||||
<string name="notifications_receive_completed_body">«%1$s» concluiu a transferência.</string>
|
||||
<string name="notifications_receive_completed_title">Transferência concluída</string>
|
||||
<string name="notifications_receive_failed_body">Não foi possível receber «%1$s».</string>
|
||||
<string name="notifications_receive_failed_title">Falha na transferência</string>
|
||||
<string name="notifications_receiver_completed_body">%1$s terminou de receber «%2$s».</string>
|
||||
<string name="notifications_receiver_completed_title">Transferência recebida</string>
|
||||
<string name="notifications_receiver_failed_body">%1$s não conseguiu receber “%2$s”</string>
|
||||
<string name="notifications_receiver_failed_title">Falha na entrega</string>
|
||||
<string name="notifications_send_failed_body">Não foi possível partilhar «%1$s».</string>
|
||||
<string name="notifications_send_failed_title">Falha na partilha</string>
|
||||
<string name="notifications_settings_open_failed">Não foi possível abrir as definições de notificações.</string>
|
||||
<string name="notifications_title">Notificações</string>
|
||||
<string name="notifications_unsupported">As notificações não estão disponíveis neste dispositivo.</string>
|
||||
@@ -169,6 +181,32 @@
|
||||
<string name="receive_review_title">Rever transferência</string>
|
||||
<string name="receive_title">Receber</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 (recomendado)</string>
|
||||
<string name="relay_mode_automatic_description">Utiliza a infraestrutura pública de retransmissores predefinida do VniDrop quando não está disponível uma ligação direta.</string>
|
||||
<string name="relay_mode_custom">Personalizado estrito</string>
|
||||
<string name="relay_mode_custom_description">Utiliza apenas os retransmissores personalizados configurados ou ligações diretas. Apresenta um erro se não for possível estabelecer nenhum retransmissor personalizado.</string>
|
||||
<string name="relay_mode_custom_direct_fallback">Personalizado com alternativa direta</string>
|
||||
<string name="relay_mode_custom_direct_fallback_description">Prefere os retransmissores personalizados configurados. Se não estiverem disponíveis, continua apenas com ligações diretas. Nunca utiliza retransmissores públicos.</string>
|
||||
<string name="relay_mode_local_only">Apenas rede local</string>
|
||||
<string name="relay_mode_local_only_description">Desativa todos os retransmissores e permite apenas ligações diretas, principalmente para dispositivos na mesma rede.</string>
|
||||
<string name="relay_privacy_description">Os retransmissores encaminham tráfego cifrado e não conseguem ler os seus ficheiros, mas o operador pode observar metadados da ligação.</string>
|
||||
<string name="relay_remove_url">Remover servidor de retransmissão</string>
|
||||
<string name="relay_restore_failed">Não foi possível restaurar as definições de rede anteriores. Reinicie o VniDrop e reveja a configuração dos retransmissores.</string>
|
||||
<string name="relay_settings_applied">Definições de rede aplicadas.</string>
|
||||
<string name="relay_strict_warning">O modo personalizado estrito só inicia se pelo menos um retransmissor configurado estiver acessível. Neste modo, o VniDrop nunca utiliza retransmissores nem descoberta públicos.</string>
|
||||
<string name="relay_validation_duplicate_url">O URL do retransmissor na linha %1$d duplica uma entrada anterior.</string>
|
||||
<string name="relay_validation_https_required">O URL do retransmissor na linha %1$d tem de começar por https://.</string>
|
||||
<string name="relay_validation_invalid_url">O URL do retransmissor na linha %1$d não é válido.</string>
|
||||
<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_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>
|
||||
@@ -192,6 +230,8 @@
|
||||
<string name="send_transfer_created">Transferência criada.</string>
|
||||
<string name="send_transfer_details_title">Detalhes da transferência</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_title">Definições</string>
|
||||
<string name="snackbar_dismiss">Ignorar</string>
|
||||
@@ -206,14 +246,18 @@
|
||||
<string name="storage_cleaning">A limpar…</string>
|
||||
<string name="storage_cleanup_busy">Conclui as transferências ativas antes de libertar espaço</string>
|
||||
<string name="storage_cleanup_freed">Libertado %1$s</string>
|
||||
<string name="storage_clear_transfer_cache">Limpar cache de transferências</string>
|
||||
<string name="storage_clear_transfer_cache_description">Remove conteúdo de transferência em cache que não esteja a ser utilizado por uma receção em curso ou partilha ativa. Os ficheiros recebidos e o histórico não são eliminados.</string>
|
||||
<string name="storage_clearing_transfer_cache">A limpar cache…</string>
|
||||
<string name="storage_delete_transfers_caption">Limpa o teu histórico de envios e receções e o conteúdo de partilha em cache da app. Os ficheiros recebidos no disco são mantidos.</string>
|
||||
<string name="storage_free_up_space_caption">Remove ficheiros temporários e resíduos de transferências anteriores. As tuas transferências e ficheiros recebidos são mantidos.</string>
|
||||
<string name="storage_refresh">Atualizar</string>
|
||||
<string name="storage_transfer_cache_cleared">Cache de transferências limpa</string>
|
||||
<string name="storage_unavailable">A utilização do armazenamento ainda não está disponível</string>
|
||||
<string name="storage_usage_header">Neste dispositivo</string>
|
||||
<string name="storage_free_up_space">Libertar espaço</string>
|
||||
<string name="storage_delete_transfers">Eliminar todas as transferências</string>
|
||||
<string name="storage_delete_transfers_description">Isto elimina do histórico todos os registos de transferências enviadas e recebidas. Os ficheiros recebidos não são eliminados. O conteúdo partilhado em cache que já não é necessário é recuperado automaticamente, o que pode demorar algum tempo. Esta ação não pode ser anulada.</string>
|
||||
<string name="storage_delete_transfers_description">Isto elimina do histórico todas as transferências enviadas e recebidas e liberta imediatamente a cache não utilizada. As transferências em curso e os ficheiros recebidos não são eliminados. Esta ação não pode ser anulada.</string>
|
||||
<string name="storage_app_data">Dados da aplicação</string>
|
||||
<string name="storage_deleting">A eliminar…</string>
|
||||
<string name="storage_footer">Os dados de transferência incluem o histórico e o conteúdo em cache das partilhas ativas. A cache desnecessária é recuperada automaticamente após a remoção dos registos, o que pode demorar algum tempo. Os ficheiros recebidos são acompanhados para este resumo, mas nunca são eliminados aqui.</string>
|
||||
@@ -252,6 +296,7 @@
|
||||
<string name="transfer_receiver_accepted">Aprovado — a aguardar conclusão</string>
|
||||
<string name="transfer_receiver_completed">Recebido com sucesso</string>
|
||||
<string name="transfer_receiver_expired">Pedido expirado</string>
|
||||
<string name="transfer_receiver_failed">Falha na entrega</string>
|
||||
<string name="transfer_receiver_refused">Pedido recusado</string>
|
||||
<string name="transfer_receiver_requested">A aguardar a sua aprovação</string>
|
||||
<string name="transfer_receiver_unknown">Estado indisponível</string>
|
||||
@@ -259,6 +304,7 @@
|
||||
<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_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_share_description">Código QR, ficheiro de convite e opções por perto</string>
|
||||
<string name="transfer_share_title">Partilhar</string>
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
<string name="about_privacy_title">Конфиденциальность и безопасность</string>
|
||||
<string name="about_tagline">Отправляйте файлы напрямую. Сохраняйте контроль над тем, кто их получает.</string>
|
||||
<string name="about_title">О приложении</string>
|
||||
<string name="app_starting">Запуск…</string>
|
||||
<string name="appearance_auto_description">Следовать светлому или тёмному оформлению этого устройства.</string>
|
||||
<string name="appearance_dark_mode">Тёмный режим</string>
|
||||
<string name="appearance_light_mode">Светлый режим</string>
|
||||
@@ -54,6 +55,7 @@
|
||||
<string name="bug_report_what_label">Что произошло?</string>
|
||||
<string name="button_approve">Одобрить</string>
|
||||
<string name="button_back">Назад</string>
|
||||
<string name="button_more_actions">Другие действия</string>
|
||||
<string name="button_cancel">Отмена</string>
|
||||
<string name="button_cancel_receive">Отмена</string>
|
||||
<string name="button_change_files">Изменить файлы</string>
|
||||
@@ -121,6 +123,16 @@
|
||||
<string name="notifications_enabled_message">Уведомления включены.</string>
|
||||
<string name="notifications_local_title">Разрешить уведомления</string>
|
||||
<string name="notifications_permission_denied">Уведомления отключены для VniDrop. Вы можете включить их в Настройках.</string>
|
||||
<string name="notifications_receive_completed_body">«%1$s» завершил загрузку.</string>
|
||||
<string name="notifications_receive_completed_title">Загрузка завершена</string>
|
||||
<string name="notifications_receive_failed_body">Не удалось получить «%1$s».</string>
|
||||
<string name="notifications_receive_failed_title">Ошибка загрузки</string>
|
||||
<string name="notifications_receiver_completed_body">%1$s завершил получение «%2$s».</string>
|
||||
<string name="notifications_receiver_completed_title">Передача получена</string>
|
||||
<string name="notifications_receiver_failed_body">%1$s не удалось получить «%2$s»</string>
|
||||
<string name="notifications_receiver_failed_title">Ошибка доставки</string>
|
||||
<string name="notifications_send_failed_body">Не удалось поделиться «%1$s».</string>
|
||||
<string name="notifications_send_failed_title">Не удалось поделиться</string>
|
||||
<string name="notifications_settings_open_failed">Не удалось открыть настройки уведомлений.</string>
|
||||
<string name="notifications_title">Уведомления</string>
|
||||
<string name="notifications_unsupported">Уведомления недоступны на этом устройстве.</string>
|
||||
@@ -169,6 +181,32 @@
|
||||
<string name="receive_review_title">Проверить передачу</string>
|
||||
<string name="receive_title">Получить</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_mode_custom_direct_fallback">Пользовательский с прямым резервом</string>
|
||||
<string name="relay_mode_custom_direct_fallback_description">Предпочитает настроенные пользовательские ретрансляторы. Если они недоступны, продолжает работу только через прямые соединения. Публичные ретрансляторы не используются.</string>
|
||||
<string name="relay_mode_local_only">Только локальная сеть</string>
|
||||
<string name="relay_mode_local_only_description">Отключает все ретрансляторы и разрешает только прямые соединения, прежде всего для устройств в одной сети.</string>
|
||||
<string name="relay_privacy_description">Ретрансляторы передают зашифрованный трафик и не могут читать ваши файлы, но их оператор может видеть метаданные соединения.</string>
|
||||
<string name="relay_remove_url">Удалить сервер-ретранслятор</string>
|
||||
<string name="relay_restore_failed">Не удалось восстановить предыдущие настройки сети. Перезапустите VniDrop и проверьте конфигурацию ретрансляторов.</string>
|
||||
<string name="relay_settings_applied">Настройки сети применены.</string>
|
||||
<string name="relay_strict_warning">Строго пользовательский режим запускается, только если доступен хотя бы один настроенный ретранслятор. В этом режиме VniDrop никогда не использует публичные ретрансляторы или публичное обнаружение.</string>
|
||||
<string name="relay_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_description">Одобрение не требуется. Используйте только для объектов, которыми вы готовы поделиться.</string>
|
||||
<string name="send_access_anyone_warning">Любой, у кого есть приглашение, может загружать, пока вы не остановите общий доступ. Не используйте это для личных или конфиденциальных объектов.</string>
|
||||
@@ -192,6 +230,8 @@
|
||||
<string name="send_transfer_created">Передача создана.</string>
|
||||
<string name="send_transfer_details_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_title">Настройки</string>
|
||||
<string name="snackbar_dismiss">Закрыть</string>
|
||||
@@ -206,14 +246,18 @@
|
||||
<string name="storage_cleaning">Очистка…</string>
|
||||
<string name="storage_cleanup_busy">Завершите активные передачи перед освобождением места</string>
|
||||
<string name="storage_cleanup_freed">Освобождено %1$s</string>
|
||||
<string name="storage_clear_transfer_cache">Очистить кэш передач</string>
|
||||
<string name="storage_clear_transfer_cache_description">Удаляет кэшированное содержимое передач, которое не используется текущим приёмом или активной раздачей. Полученные файлы и история передач не удаляются.</string>
|
||||
<string name="storage_clearing_transfer_cache">Очистка кэша…</string>
|
||||
<string name="storage_delete_transfers_caption">Очищает историю отправки и получения и кэшированное содержимое общих ресурсов. Полученные файлы на диске сохраняются.</string>
|
||||
<string name="storage_free_up_space_caption">Удаляет временные файлы и остатки прошлых передач. Ваши передачи и полученные файлы сохраняются.</string>
|
||||
<string name="storage_refresh">Обновить</string>
|
||||
<string name="storage_transfer_cache_cleared">Кэш передач очищен</string>
|
||||
<string name="storage_unavailable">Данные об использовании хранилища пока недоступны</string>
|
||||
<string name="storage_usage_header">На этом устройстве</string>
|
||||
<string name="storage_free_up_space">Освободить место</string>
|
||||
<string name="storage_delete_transfers">Удалить все передачи</string>
|
||||
<string name="storage_delete_transfers_description">Это удалит из истории все записи об отправленных и полученных передачах. Полученные файлы не удаляются. Кэшированное общее содержимое, которое больше не требуется, освобождается автоматически; это может занять некоторое время. Это действие нельзя отменить.</string>
|
||||
<string name="storage_delete_transfers_description">Это удалит из истории все отправленные и полученные передачи и немедленно освободит неиспользуемый кэш. Текущие передачи и полученные файлы не удаляются. Это действие нельзя отменить.</string>
|
||||
<string name="storage_app_data">Данные приложения</string>
|
||||
<string name="storage_deleting">Удаление…</string>
|
||||
<string name="storage_footer">Данные передачи включают историю и кэшированное содержимое активных раздач. Ненужный кэш освобождается автоматически после удаления записей; это может занять некоторое время. Полученные файлы учитываются в этой сводке, но никогда не удаляются здесь.</string>
|
||||
@@ -252,6 +296,7 @@
|
||||
<string name="transfer_receiver_accepted">Одобрено — ожидание завершения</string>
|
||||
<string name="transfer_receiver_completed">Успешно получено</string>
|
||||
<string name="transfer_receiver_expired">Запрос истёк</string>
|
||||
<string name="transfer_receiver_failed">Ошибка доставки</string>
|
||||
<string name="transfer_receiver_refused">Запрос отклонён</string>
|
||||
<string name="transfer_receiver_requested">Ожидание вашего одобрения</string>
|
||||
<string name="transfer_receiver_unknown">Статус недоступен</string>
|
||||
@@ -259,6 +304,7 @@
|
||||
<string name="transfer_receivers_description">Запросы, одобрения и завершённые доставки</string>
|
||||
<string name="transfer_receivers_pending">Ожидают: %1$d</string>
|
||||
<string name="transfer_receivers_title">Получатели</string>
|
||||
<string name="transfer_qr_unavailable">QR-код недоступен для этого приглашения. Используйте «Поделиться» или «Скачать».</string>
|
||||
<string name="transfer_scan_qr">Отсканируйте с помощью VniDrop, чтобы получить эту передачу</string>
|
||||
<string name="transfer_share_description">QR-код, файл приглашения и варианты поблизости</string>
|
||||
<string name="transfer_share_title">Поделиться</string>
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
<string name="about_privacy_title">Privacy & security</string>
|
||||
<string name="about_tagline">Send files directly. Stay in control of who receives them.</string>
|
||||
<string name="about_title">About</string>
|
||||
<string name="app_starting">Starting…</string>
|
||||
<string name="appearance_auto_description">Match this device’s light or dark appearance.</string>
|
||||
<string name="appearance_dark_mode">Dark mode</string>
|
||||
<string name="appearance_light_mode">Light mode</string>
|
||||
@@ -54,6 +55,7 @@
|
||||
<string name="bug_report_what_label">What happened?</string>
|
||||
<string name="button_approve">Approve</string>
|
||||
<string name="button_back">Back</string>
|
||||
<string name="button_more_actions">More actions</string>
|
||||
<string name="button_cancel">Cancel</string>
|
||||
<string name="button_cancel_receive">Cancel</string>
|
||||
<string name="button_change_files">Change files</string>
|
||||
@@ -121,6 +123,16 @@
|
||||
<string name="notifications_enabled_message">Notifications enabled.</string>
|
||||
<string name="notifications_local_title">Allow notifications</string>
|
||||
<string name="notifications_permission_denied">Notifications are turned off for VniDrop. You can enable them in Settings.</string>
|
||||
<string name="notifications_receive_completed_body">“%1$s” finished downloading.</string>
|
||||
<string name="notifications_receive_completed_title">Download complete</string>
|
||||
<string name="notifications_receive_failed_body">“%1$s” couldn’t be received.</string>
|
||||
<string name="notifications_receive_failed_title">Download failed</string>
|
||||
<string name="notifications_receiver_completed_body">%1$s finished receiving “%2$s”.</string>
|
||||
<string name="notifications_receiver_completed_title">Transfer received</string>
|
||||
<string name="notifications_receiver_failed_body">%1$s couldn\'t receive “%2$s”</string>
|
||||
<string name="notifications_receiver_failed_title">Delivery failed</string>
|
||||
<string name="notifications_send_failed_body">“%1$s” couldn’t be shared.</string>
|
||||
<string name="notifications_send_failed_title">Sharing failed</string>
|
||||
<string name="notifications_settings_open_failed">Could not open notification settings.</string>
|
||||
<string name="notifications_title">Notifications</string>
|
||||
<string name="notifications_unsupported">Notifications are not available on this device.</string>
|
||||
@@ -169,6 +181,32 @@
|
||||
<string name="receive_review_title">Review transfer</string>
|
||||
<string name="receive_title">Receive</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 VniDrop’s 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 (recommended)</string>
|
||||
<string name="relay_mode_automatic_description">Use VniDrop’s default public relay infrastructure when a direct connection is unavailable.</string>
|
||||
<string name="relay_mode_custom">Strict custom</string>
|
||||
<string name="relay_mode_custom_description">Use only the configured custom relays or direct connections. Report an error if no custom relay can be established.</string>
|
||||
<string name="relay_mode_custom_direct_fallback">Custom with direct fallback</string>
|
||||
<string name="relay_mode_custom_direct_fallback_description">Prefer the configured custom relays. If unavailable, continue with direct connections only. Never use public relays.</string>
|
||||
<string name="relay_mode_local_only">Local only</string>
|
||||
<string name="relay_mode_local_only_description">Disable all relays and allow only direct connections, primarily for devices on the same network.</string>
|
||||
<string name="relay_privacy_description">Relays forward encrypted traffic and cannot read your files, but their operator can observe connection metadata.</string>
|
||||
<string name="relay_remove_url">Remove relay server</string>
|
||||
<string name="relay_restore_failed">Could not restore the previous network settings. Restart VniDrop and review your relay configuration.</string>
|
||||
<string name="relay_settings_applied">Network settings applied.</string>
|
||||
<string name="relay_strict_warning">Strict custom mode will not start unless at least one configured relay is reachable. VniDrop never uses public relays or public discovery in this mode.</string>
|
||||
<string name="relay_validation_duplicate_url">Relay URL on line %1$d duplicates an earlier entry.</string>
|
||||
<string name="relay_validation_https_required">Relay URL on line %1$d must start with https://.</string>
|
||||
<string name="relay_validation_invalid_url">Relay URL on line %1$d is not valid.</string>
|
||||
<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_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>
|
||||
@@ -192,6 +230,8 @@
|
||||
<string name="send_transfer_created">Transfer created.</string>
|
||||
<string name="send_transfer_details_title">Transfer details</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_title">Settings</string>
|
||||
<string name="snackbar_dismiss">Dismiss</string>
|
||||
@@ -206,14 +246,18 @@
|
||||
<string name="storage_cleaning">Cleaning up…</string>
|
||||
<string name="storage_cleanup_busy">Finish active transfers before freeing up space</string>
|
||||
<string name="storage_cleanup_freed">Freed %1$s</string>
|
||||
<string name="storage_delete_transfers_caption">Clears your send and receive history and the app\'s cached share content. Received files on disk are kept.</string>
|
||||
<string name="storage_clear_transfer_cache">Clear transfer cache</string>
|
||||
<string name="storage_clear_transfer_cache_description">Removes cached transfer content after briefly restarting VniDrop. Finish ongoing transfers and stop active shares first. Received files and transfer history are not deleted.</string>
|
||||
<string name="storage_clearing_transfer_cache">Clearing cache…</string>
|
||||
<string name="storage_delete_transfers_caption">Clears your send and receive history and the app’s cached share content. Received files on disk are kept.</string>
|
||||
<string name="storage_free_up_space_caption">Removes temporary files and leftover trash from earlier transfers. Your transfers and received files are kept.</string>
|
||||
<string name="storage_refresh">Refresh</string>
|
||||
<string name="storage_transfer_cache_cleared">Transfer cache cleared</string>
|
||||
<string name="storage_unavailable">Storage usage isn\'t available yet</string>
|
||||
<string name="storage_usage_header">On this device</string>
|
||||
<string name="storage_free_up_space">Free up space</string>
|
||||
<string name="storage_delete_transfers">Delete all transfers</string>
|
||||
<string name="storage_delete_transfers_description">This clears all sent and received transfer records from your history. Your received files are not deleted. Cached shared content that is no longer needed is reclaimed automatically, which may take a little time. This can’t be undone.</string>
|
||||
<string name="storage_delete_transfers_description">This clears all sent and received transfer records from your history and immediately reclaims unused transfer cache. Ongoing transfers and received files are not deleted. This can’t be undone.</string>
|
||||
<string name="storage_app_data">App data</string>
|
||||
<string name="storage_deleting">Deleting…</string>
|
||||
<string name="storage_footer">Transfer data includes your history and cached content for active shares. Unneeded cache is reclaimed automatically after transfer records are removed, which may take a little time. Received files are tracked for this summary but are never deleted here.</string>
|
||||
@@ -252,6 +296,7 @@
|
||||
<string name="transfer_receiver_accepted">Approved — waiting for completion</string>
|
||||
<string name="transfer_receiver_completed">Received successfully</string>
|
||||
<string name="transfer_receiver_expired">Request expired</string>
|
||||
<string name="transfer_receiver_failed">Delivery failed</string>
|
||||
<string name="transfer_receiver_refused">Request refused</string>
|
||||
<string name="transfer_receiver_requested">Waiting for your approval</string>
|
||||
<string name="transfer_receiver_unknown">Status unavailable</string>
|
||||
@@ -259,6 +304,7 @@
|
||||
<string name="transfer_receivers_description">Requests, approvals, and completed deliveries</string>
|
||||
<string name="transfer_receivers_pending">%1$d waiting</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_share_description">QR code, invitation file, and nearby options</string>
|
||||
<string name="transfer_share_title">Share</string>
|
||||
|
||||
@@ -6,6 +6,13 @@ import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
@@ -14,6 +21,8 @@ import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.Lifecycle
|
||||
@@ -49,6 +58,9 @@ import com.vnidrop.app.ui.theme.rememberResolvedDarkTheme
|
||||
import kotlinx.coroutines.flow.filter
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import vnidrop.shared.generated.resources.Res
|
||||
import vnidrop.shared.generated.resources.app_starting
|
||||
|
||||
@Composable
|
||||
fun App(
|
||||
@@ -217,6 +229,32 @@ fun App(
|
||||
)
|
||||
}
|
||||
windowChrome?.invoke()
|
||||
val startingLabel = stringResource(Res.string.app_starting)
|
||||
AnimatedVisibility(
|
||||
visible = !sendCoreState.isInitialized,
|
||||
enter = fadeIn(),
|
||||
exit = fadeOut(),
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(LocalVniDropColors.current.backgroundSurface100)
|
||||
.semantics { contentDescription = startingLabel },
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
CircularProgressIndicator()
|
||||
Text(
|
||||
startingLabel,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = LocalVniDropColors.current.foregroundLighter,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import com.vnidrop.app.feature.approvals.ApprovalCoordinator
|
||||
import com.vnidrop.app.feature.send.AppFilePreviewRepository
|
||||
import com.vnidrop.app.feature.send.createPlatformPreviewStore
|
||||
import com.vnidrop.app.logging.AppLogger
|
||||
import com.vnidrop.app.notifications.TransferNotificationCoordinator
|
||||
import com.vnidrop.app.platform.AppVisibility
|
||||
import com.vnidrop.app.preferences.AppPreferencesDefaults
|
||||
import com.vnidrop.app.preferences.AppPreferencesRepository
|
||||
@@ -62,6 +63,14 @@ class AppGraph(
|
||||
messages = messages,
|
||||
scope = applicationScope,
|
||||
)
|
||||
val transferNotificationCoordinator = TransferNotificationCoordinator(
|
||||
repository = coreRepository,
|
||||
preferencesRepository = preferencesRepository,
|
||||
notifications = dependencies.localNotificationService,
|
||||
visibility = visibility,
|
||||
messages = messages,
|
||||
scope = applicationScope,
|
||||
)
|
||||
|
||||
init {
|
||||
AppLogger.initialize(dependencies.environment.defaultCoreDataDir)
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,21 @@ enum class ShareAccessPolicy {
|
||||
AnyoneWithTransfer,
|
||||
}
|
||||
|
||||
enum class RelayMode {
|
||||
Automatic,
|
||||
StrictCustom,
|
||||
CustomWithDirectFallback,
|
||||
LocalOnly,
|
||||
}
|
||||
|
||||
data class RelaySettings(
|
||||
val mode: RelayMode = RelayMode.Automatic,
|
||||
val relayUrls: List<String> = emptyList(),
|
||||
)
|
||||
|
||||
val RelayMode.usesCustomRelayUrls: Boolean
|
||||
get() = this == RelayMode.StrictCustom || this == RelayMode.CustomWithDirectFallback
|
||||
|
||||
enum class TransferDirection {
|
||||
Send,
|
||||
Receive,
|
||||
@@ -102,6 +117,7 @@ enum class ReceiverDeliveryStatus {
|
||||
Refused,
|
||||
Expired,
|
||||
Completed,
|
||||
Failed,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
@@ -142,7 +158,10 @@ interface CoreGateway {
|
||||
val state: StateFlow<CoreState>
|
||||
val signals: SharedFlow<CoreSignal>
|
||||
|
||||
suspend fun initialize(appDataDir: String): Result<Unit>
|
||||
suspend fun initialize(
|
||||
appDataDir: String,
|
||||
relaySettings: RelaySettings = RelaySettings(),
|
||||
): Result<Unit>
|
||||
fun shutdown()
|
||||
suspend fun sharePath(path: String, transferName: String, senderName: String, accessPolicy: ShareAccessPolicy): Result<Share>
|
||||
suspend fun shareFileDescriptor(
|
||||
@@ -164,6 +183,7 @@ interface CoreGateway {
|
||||
suspend fun receiveWithOutputSink(ticket: String, outputSink: ReceiveOutputSink, receiverName: String): Result<Unit>
|
||||
suspend fun receiveWithOutputSinkV2(ticket: String, outputSink: ReceiveOutputSinkV2, receiverName: String): Result<Unit>
|
||||
suspend fun storageUsage(): Result<CoreStorageUsageModel>
|
||||
suspend fun clearTransferCache(): Result<ULong>
|
||||
suspend fun receivedArtifacts(): Result<List<ReceivedArtifactModel>>
|
||||
suspend fun cancel(transferId: ULong): Result<Unit>
|
||||
suspend fun delete(transferId: ULong): Result<Unit>
|
||||
|
||||
@@ -16,6 +16,8 @@ import kotlinx.coroutines.withContext
|
||||
import kotlin.random.Random
|
||||
import uniffi.vnidrop.CoreEvent
|
||||
import uniffi.vnidrop.CoreEventSink
|
||||
import uniffi.vnidrop.CoreNetworkConfig
|
||||
import uniffi.vnidrop.CoreRelayMode
|
||||
import uniffi.vnidrop.ReceiveOutputSink
|
||||
import uniffi.vnidrop.ReceiveOutputSinkV2
|
||||
import uniffi.vnidrop.ReceiverRequest
|
||||
@@ -28,6 +30,8 @@ import uniffi.vnidrop.TicketInspection
|
||||
import uniffi.vnidrop.TransferMetadata
|
||||
import uniffi.vnidrop.TransferAccessMode
|
||||
import uniffi.vnidrop.VnidropCore
|
||||
import uniffi.vnidrop.clearInactiveTransferCache
|
||||
import uniffi.vnidrop.defaultCoreNetworkConfig
|
||||
|
||||
class CoreRepository(
|
||||
private val dispatcher: CoroutineDispatcher = Dispatchers.IO,
|
||||
@@ -42,6 +46,9 @@ class CoreRepository(
|
||||
override val signals: SharedFlow<CoreSignal> = _signals.asSharedFlow()
|
||||
|
||||
private var core: VnidropCore? = null
|
||||
private var currentAppDataDir: String? = null
|
||||
private var currentRelaySettings = RelaySettings()
|
||||
private val lifecycleGate = CoreLifecycleGate()
|
||||
|
||||
private val sink = object : CoreEventSink {
|
||||
override fun onEvent(event: CoreEvent) {
|
||||
@@ -60,16 +67,30 @@ class CoreRepository(
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun initialize(appDataDir: String): Result<Unit> = runCore {
|
||||
core?.shutdown()
|
||||
core = VnidropCore.initialize(appDataDir, sink)
|
||||
refreshSnapshot()
|
||||
_state.update { it.copy(isInitialized = true) }
|
||||
}
|
||||
override suspend fun initialize(appDataDir: String, relaySettings: RelaySettings): Result<Unit> =
|
||||
runReconfiguration {
|
||||
val previousCore = core
|
||||
if (previousCore != null) {
|
||||
val status = previousCore.status()
|
||||
require(status.activeTransfers == 0UL && status.activeShares == 0UL) {
|
||||
"Stop active transfers and shares before changing relay settings"
|
||||
}
|
||||
}
|
||||
_state.update { it.copy(isInitialized = false, status = null) }
|
||||
core = null
|
||||
disposeCore(previousCore)
|
||||
core = VnidropCore.initializeWithNetworkConfig(appDataDir, sink, relaySettings.toNative())
|
||||
currentAppDataDir = appDataDir
|
||||
currentRelaySettings = relaySettings
|
||||
refreshSnapshot(requireCore())
|
||||
_state.update { it.copy(isInitialized = true) }
|
||||
}
|
||||
|
||||
override fun shutdown() {
|
||||
core?.shutdown()
|
||||
val activeCore = core
|
||||
core = null
|
||||
disposeCore(activeCore)
|
||||
currentAppDataDir = null
|
||||
_state.value = CoreState()
|
||||
}
|
||||
|
||||
@@ -84,7 +105,7 @@ class CoreRepository(
|
||||
ShareSource(
|
||||
kind = SourceKind.PATH,
|
||||
value = path,
|
||||
displayName = path.substringAfterLast('/').ifBlank { null },
|
||||
displayName = null,
|
||||
isDirectory = false,
|
||||
),
|
||||
),
|
||||
@@ -114,37 +135,37 @@ class CoreRepository(
|
||||
accessPolicy = accessPolicy,
|
||||
)
|
||||
|
||||
override suspend fun inspectTicket(ticket: String): Result<TicketInspectionModel> = runCore {
|
||||
requireCore().inspectTicket(ticket).toModel().also { inspection ->
|
||||
override suspend fun inspectTicket(ticket: String): Result<TicketInspectionModel> = runCore { activeCore ->
|
||||
activeCore.inspectTicket(ticket).toModel().also { inspection ->
|
||||
_state.update { it.copy(lastInspection = inspection) }
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun receive(ticket: String, outputDir: String, receiverName: String): Result<Unit> = runCore {
|
||||
requireCore().receive(ticket, outputDir, receiverName.ifBlank { null })
|
||||
refreshSnapshot()
|
||||
override suspend fun receive(ticket: String, outputDir: String, receiverName: String): Result<Unit> = runCore { activeCore ->
|
||||
activeCore.receive(ticket, outputDir, receiverName.ifBlank { null })
|
||||
refreshSnapshot(activeCore)
|
||||
}
|
||||
|
||||
override suspend fun receiveWithOutputSink(
|
||||
ticket: String,
|
||||
outputSink: ReceiveOutputSink,
|
||||
receiverName: String,
|
||||
): Result<Unit> = runCore {
|
||||
requireCore().receiveWithOutputSink(ticket, outputSink, receiverName.ifBlank { null })
|
||||
refreshSnapshot()
|
||||
): Result<Unit> = runCore { activeCore ->
|
||||
activeCore.receiveWithOutputSink(ticket, outputSink, receiverName.ifBlank { null })
|
||||
refreshSnapshot(activeCore)
|
||||
}
|
||||
|
||||
override suspend fun receiveWithOutputSinkV2(
|
||||
ticket: String,
|
||||
outputSink: ReceiveOutputSinkV2,
|
||||
receiverName: String,
|
||||
): Result<Unit> = runCore {
|
||||
requireCore().receiveWithOutputSinkV2(ticket, outputSink, receiverName.ifBlank { null })
|
||||
refreshSnapshot()
|
||||
): Result<Unit> = runCore { activeCore ->
|
||||
activeCore.receiveWithOutputSinkV2(ticket, outputSink, receiverName.ifBlank { null })
|
||||
refreshSnapshot(activeCore)
|
||||
}
|
||||
|
||||
override suspend fun storageUsage(): Result<CoreStorageUsageModel> = runCore {
|
||||
val usage = requireCore().storageUsage()
|
||||
override suspend fun storageUsage(): Result<CoreStorageUsageModel> = runCore { activeCore ->
|
||||
val usage = activeCore.storageUsage()
|
||||
CoreStorageUsageModel(
|
||||
blobStoreBytes = usage.blobStoreBytes,
|
||||
databaseBytes = usage.databaseBytes,
|
||||
@@ -154,8 +175,40 @@ class CoreRepository(
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun receivedArtifacts(): Result<List<ReceivedArtifactModel>> = runCore {
|
||||
requireCore().listReceivedArtifacts().map { artifact ->
|
||||
override suspend fun clearTransferCache(): Result<ULong> = runReconfiguration {
|
||||
val activeCore = requireCore()
|
||||
val status = activeCore.status()
|
||||
require(status.activeTransfers == 0UL && status.activeShares == 0UL) {
|
||||
"Wait for active transfers and shares to finish before clearing the transfer cache"
|
||||
}
|
||||
val appDataDir = requireNotNull(currentAppDataDir) { "Core data directory is unavailable" }
|
||||
val relaySettings = currentRelaySettings
|
||||
_state.update { it.copy(isInitialized = false, status = null) }
|
||||
core = null
|
||||
disposeCore(activeCore)
|
||||
var reclaimed = 0UL
|
||||
try {
|
||||
reclaimed = clearInactiveTransferCache(appDataDir)
|
||||
} finally {
|
||||
core = VnidropCore.initializeWithNetworkConfig(appDataDir, sink, relaySettings.toNative())
|
||||
refreshSnapshot(requireCore())
|
||||
_state.update { it.copy(isInitialized = true) }
|
||||
}
|
||||
reclaimed
|
||||
}
|
||||
|
||||
private fun disposeCore(activeCore: VnidropCore?) {
|
||||
if (activeCore == null) return
|
||||
try {
|
||||
activeCore.shutdown()
|
||||
} finally {
|
||||
// UniFFI owns the Rust Arc; explicit disposal releases Windows file handles before cache deletion.
|
||||
activeCore.close()
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun receivedArtifacts(): Result<List<ReceivedArtifactModel>> = runCore { activeCore ->
|
||||
activeCore.listReceivedArtifacts().map { artifact ->
|
||||
ReceivedArtifactModel(
|
||||
id = artifact.id,
|
||||
locator = artifact.locator,
|
||||
@@ -165,47 +218,47 @@ class CoreRepository(
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun cancel(transferId: ULong): Result<Unit> = runCore {
|
||||
requireCore().cancelTransfer(transferId)
|
||||
refreshSnapshot()
|
||||
override suspend fun cancel(transferId: ULong): Result<Unit> = runCore { activeCore ->
|
||||
activeCore.cancelTransfer(transferId)
|
||||
refreshSnapshot(activeCore)
|
||||
}
|
||||
|
||||
override suspend fun delete(transferId: ULong): Result<Unit> = runCore {
|
||||
requireCore().deleteTransfer(transferId)
|
||||
refreshSnapshot()
|
||||
override suspend fun delete(transferId: ULong): Result<Unit> = runCore { activeCore ->
|
||||
activeCore.deleteTransfer(transferId)
|
||||
refreshSnapshot(activeCore)
|
||||
_signals.tryEmit(CoreSignal.ApprovalChanged(transferId))
|
||||
_signals.tryEmit(CoreSignal.ReceiverHistoryChanged(transferId))
|
||||
}
|
||||
|
||||
override suspend fun clearReceiveHistory(): Result<ULong> = runCore {
|
||||
val deleted = requireCore().deleteReceiveHistory()
|
||||
refreshSnapshot()
|
||||
override suspend fun clearReceiveHistory(): Result<ULong> = runCore { activeCore ->
|
||||
val deleted = activeCore.deleteReceiveHistory()
|
||||
refreshSnapshot(activeCore)
|
||||
deleted
|
||||
}
|
||||
|
||||
override suspend fun receiverRequests(transferId: ULong): Result<List<ReceiverRequestModel>> = runCore {
|
||||
requireCore().listReceiverRequests(transferId).map(ReceiverRequest::toModel)
|
||||
override suspend fun receiverRequests(transferId: ULong): Result<List<ReceiverRequestModel>> = runCore { activeCore ->
|
||||
activeCore.listReceiverRequests(transferId).map(ReceiverRequest::toModel)
|
||||
}
|
||||
|
||||
override suspend fun respondReceiverRequest(
|
||||
requestId: String,
|
||||
accepted: Boolean,
|
||||
reason: String?,
|
||||
): Result<Unit> = runCore {
|
||||
requireCore().respondReceiverRequest(requestId, accepted, reason)
|
||||
): Result<Unit> = runCore { activeCore ->
|
||||
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(
|
||||
sources: List<ShareSource>,
|
||||
transferName: String,
|
||||
senderName: String,
|
||||
accessPolicy: ShareAccessPolicy,
|
||||
): Result<Share> = runCore {
|
||||
): Result<Share> = runCore { activeCore ->
|
||||
require(sources.isNotEmpty()) { "Select at least one file to share" }
|
||||
withPlatformPathAccess(sources) {
|
||||
requireCore().shareFiles(
|
||||
activeCore.shareFiles(
|
||||
sources = sources,
|
||||
metadata = ShareMetadataInput(
|
||||
transferId = nextTransferId(),
|
||||
@@ -215,7 +268,7 @@ class CoreRepository(
|
||||
),
|
||||
).toModel()
|
||||
}.also { share ->
|
||||
refreshSnapshot()
|
||||
refreshSnapshot(activeCore)
|
||||
_state.update { it.copy(lastShare = share) }
|
||||
}
|
||||
}
|
||||
@@ -232,8 +285,7 @@ class CoreRepository(
|
||||
}
|
||||
}
|
||||
|
||||
private fun refreshSnapshot() {
|
||||
val activeCore = requireCore()
|
||||
private fun refreshSnapshot(activeCore: VnidropCore) {
|
||||
val status = activeCore.status()
|
||||
_state.update {
|
||||
it.copy(
|
||||
@@ -244,10 +296,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) {
|
||||
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) {
|
||||
if (error is CancellationException) throw error
|
||||
Result.failure(error)
|
||||
@@ -263,6 +330,22 @@ class CoreRepository(
|
||||
}
|
||||
}
|
||||
|
||||
private fun RelaySettings.toNative(): CoreNetworkConfig = when (mode) {
|
||||
RelayMode.Automatic -> defaultCoreNetworkConfig()
|
||||
RelayMode.StrictCustom -> CoreNetworkConfig(
|
||||
mode = CoreRelayMode.STRICT_CUSTOM,
|
||||
relayUrls = relayUrls,
|
||||
)
|
||||
RelayMode.CustomWithDirectFallback -> CoreNetworkConfig(
|
||||
mode = CoreRelayMode.CUSTOM_WITH_DIRECT_FALLBACK,
|
||||
relayUrls = relayUrls,
|
||||
)
|
||||
RelayMode.LocalOnly -> CoreNetworkConfig(
|
||||
mode = CoreRelayMode.LOCAL_ONLY,
|
||||
relayUrls = emptyList(),
|
||||
)
|
||||
}
|
||||
|
||||
private fun CoreEvent.toModel(): CoreEventModel = CoreEventModel(
|
||||
id = id,
|
||||
timestamp = timestamp,
|
||||
@@ -361,6 +444,7 @@ private fun ReceiverRequest.toModel(): ReceiverRequestModel = ReceiverRequestMod
|
||||
"refused" -> ReceiverDeliveryStatus.Refused
|
||||
"expired" -> ReceiverDeliveryStatus.Expired
|
||||
"completed" -> ReceiverDeliveryStatus.Completed
|
||||
"failed" -> ReceiverDeliveryStatus.Failed
|
||||
else -> ReceiverDeliveryStatus.Unknown
|
||||
},
|
||||
reason = reason,
|
||||
|
||||
@@ -40,7 +40,9 @@ interface FileSystemService {
|
||||
if (supportsCustomReceiveFolders) configuredFolder else defaultReceiveFolder()
|
||||
suspend fun validateReceiveFolder(folder: ReceiveFolder): FolderAccessStatus
|
||||
suspend fun inspectReceivedArtifacts(artifacts: List<ReceivedArtifactModel>): ReceivedStorageInspection
|
||||
suspend fun temporaryUsage(): ULong
|
||||
suspend fun temporaryUsage(receiveFolder: ReceiveFolder): ULong
|
||||
/** Reclaims only app-owned temporary files and returns the number of bytes removed. */
|
||||
suspend fun reclaimTemporaryStorage(appDataDir: String, receiveFolder: ReceiveFolder): ULong
|
||||
fun createReceiveOutputSink(folder: ReceiveFolder): ReceiveOutputSinkV2?
|
||||
fun canRevealReceiveFolder(folder: ReceiveFolder): Boolean = false
|
||||
suspend fun revealReceiveFolder(folder: ReceiveFolder): Result<Unit> =
|
||||
|
||||
@@ -15,6 +15,7 @@ import com.vnidrop.app.ui.theme.ThemeMode
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@@ -46,7 +47,8 @@ class AppViewModel(
|
||||
AppLogger.info("lifecycle", "app started", mapOf("platform" to environment.name))
|
||||
diagnostics?.record("app_open", mapOf("platform" to environment.name, "version" to environment.appVersion))
|
||||
viewModelScope.launch {
|
||||
repository.initialize(environment.defaultCoreDataDir).onFailure(messages::error)
|
||||
val relaySettings = preferencesRepository.preferences.first().relaySettings
|
||||
repository.initialize(environment.defaultCoreDataDir, relaySettings).onFailure(messages::error)
|
||||
}
|
||||
viewModelScope.launch {
|
||||
preferencesRepository.preferences.collect { preferences ->
|
||||
|
||||
@@ -294,7 +294,7 @@ private fun InvitationReviewPanel(
|
||||
Text(
|
||||
when (error) {
|
||||
is UiText.Dynamic -> error.value
|
||||
is UiText.Resource -> stringResource(error.resource)
|
||||
is UiText.Resource -> stringResource(error.resource, *error.formatArgs.toTypedArray())
|
||||
},
|
||||
color = LocalVniDropColors.current.destructiveDefault,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
|
||||
@@ -19,17 +19,25 @@ import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.FloatingActionButton
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
@@ -58,12 +66,16 @@ import org.jetbrains.compose.resources.stringResource
|
||||
import org.jetbrains.compose.resources.decodeToImageBitmap
|
||||
import vnidrop.shared.generated.resources.Res
|
||||
import vnidrop.shared.generated.resources.button_create_new_transfer
|
||||
import vnidrop.shared.generated.resources.button_delete_transfer
|
||||
import vnidrop.shared.generated.resources.button_more_actions
|
||||
import vnidrop.shared.generated.resources.send_empty_body
|
||||
import vnidrop.shared.generated.resources.send_empty_title
|
||||
import vnidrop.shared.generated.resources.send_new_transfer_description
|
||||
import vnidrop.shared.generated.resources.send_new_transfer_title
|
||||
import vnidrop.shared.generated.resources.send_title
|
||||
import vnidrop.shared.generated.resources.send_stop_sharing
|
||||
import vnidrop.shared.generated.resources.send_transfers_title
|
||||
import vnidrop.shared.generated.resources.transfer_share_title
|
||||
|
||||
@Composable
|
||||
internal fun SendFloatingAction(onClick: () -> Unit, modifier: Modifier = Modifier) {
|
||||
@@ -86,6 +98,9 @@ internal fun TransferCatalog(
|
||||
windowClass: WindowClass,
|
||||
onOpenComposer: () -> Unit,
|
||||
onTransferSelected: (ULong) -> Unit,
|
||||
onShare: (ULong) -> Unit = {},
|
||||
onStopSharing: (ULong) -> Unit = {},
|
||||
onDelete: (ULong) -> Unit = {},
|
||||
) {
|
||||
val usesFloatingAction = usesMobilePresentation(LocalUiPlatform.current, windowClass)
|
||||
LazyColumn(
|
||||
@@ -129,6 +144,9 @@ internal fun TransferCatalog(
|
||||
thumbnailBytes = transferThumbnails[transfer.transferId],
|
||||
progress = progress,
|
||||
onClick = { onTransferSelected(transfer.transferId) },
|
||||
onShare = { onShare(transfer.transferId) },
|
||||
onStopSharing = { onStopSharing(transfer.transferId) },
|
||||
onDelete = { onDelete(transfer.transferId) },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -192,6 +210,9 @@ private fun TransferListItem(
|
||||
thumbnailBytes: ByteArray?,
|
||||
progress: TransferProgress?,
|
||||
onClick: () -> Unit,
|
||||
onShare: () -> Unit,
|
||||
onStopSharing: () -> Unit,
|
||||
onDelete: () -> Unit,
|
||||
) {
|
||||
val colors = LocalVniDropColors.current
|
||||
Surface(onClick = onClick, modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(16.dp), color = colors.backgroundSurface200) {
|
||||
@@ -227,8 +248,68 @@ private fun TransferListItem(
|
||||
ProgressRow(label = progress.label, progress = progress.progress, detail = progress.detail)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.width(8.dp))
|
||||
PlatformIcon(AppIcon.ChevronRight, contentDescription = null, tint = colors.foregroundLighter, modifier = Modifier.size(18.dp))
|
||||
TransferActionsMenu(transfer, onShare, onStopSharing, onDelete)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TransferActionsMenu(
|
||||
transfer: Transfer,
|
||||
onShare: () -> Unit,
|
||||
onStopSharing: () -> Unit,
|
||||
onDelete: () -> Unit,
|
||||
) {
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
val moreActionsLabel = stringResource(Res.string.button_more_actions)
|
||||
Box {
|
||||
IconButton(
|
||||
onClick = { expanded = true },
|
||||
modifier = Modifier.semantics {
|
||||
contentDescription = moreActionsLabel
|
||||
},
|
||||
) {
|
||||
PlatformIcon(
|
||||
AppIcon.MoreVertical,
|
||||
contentDescription = null,
|
||||
tint = LocalVniDropColors.current.foregroundLighter,
|
||||
)
|
||||
}
|
||||
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
|
||||
if (transfer.ticket != null) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(Res.string.transfer_share_title)) },
|
||||
onClick = {
|
||||
expanded = false
|
||||
onShare()
|
||||
},
|
||||
leadingIcon = { PlatformIcon(AppIcon.Share, contentDescription = null) },
|
||||
)
|
||||
}
|
||||
if (transfer.status == TransferStatus.Sharing) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(Res.string.send_stop_sharing)) },
|
||||
onClick = {
|
||||
expanded = false
|
||||
onStopSharing()
|
||||
},
|
||||
leadingIcon = { PlatformIcon(AppIcon.StopCircle, contentDescription = null) },
|
||||
)
|
||||
}
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(Res.string.button_delete_transfer)) },
|
||||
onClick = {
|
||||
expanded = false
|
||||
onDelete()
|
||||
},
|
||||
leadingIcon = {
|
||||
PlatformIcon(
|
||||
AppIcon.Delete,
|
||||
contentDescription = null,
|
||||
tint = LocalVniDropColors.current.destructiveDefault,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,6 +46,11 @@ fun SendRoute(
|
||||
onAccessPolicyChanged = viewModel::setAccessPolicy,
|
||||
onCreateShare = viewModel::createShare,
|
||||
onTransferSelected = viewModel::openTransfer,
|
||||
onShareTransfer = { transferId ->
|
||||
viewModel.openTransfer(transferId)
|
||||
viewModel.openShare()
|
||||
},
|
||||
onStopSharing = viewModel::stopSharing,
|
||||
onCloseTransferDetails = viewModel::closeTransferDetails,
|
||||
onCopyTicket = viewModel::copyTicket,
|
||||
onActivity = viewModel::openActivity,
|
||||
@@ -53,7 +58,8 @@ fun SendRoute(
|
||||
onShare = viewModel::openShare,
|
||||
onCloseDetailPanel = viewModel::closeDetailPanel,
|
||||
onInvitationResult = viewModel::onInvitationResult,
|
||||
onRequestDelete = viewModel::requestDeleteTransfer,
|
||||
onRequestDelete = { viewModel.requestDeleteTransfer() },
|
||||
onRequestDeleteTransfer = { viewModel.requestDeleteTransfer(it) },
|
||||
onDismissDelete = viewModel::dismissDeleteTransfer,
|
||||
onConfirmDelete = viewModel::confirmDeleteTransfer,
|
||||
)
|
||||
|
||||
@@ -12,6 +12,7 @@ import com.vnidrop.app.core.CoreState
|
||||
import com.vnidrop.app.core.ReceiverDeliveryStatus
|
||||
import com.vnidrop.app.core.ShareAccessPolicy
|
||||
import com.vnidrop.app.core.TransferDirection
|
||||
import com.vnidrop.app.core.TransferStatus
|
||||
import com.vnidrop.app.ui.components.AdaptiveDrawer
|
||||
import com.vnidrop.app.ui.state.WindowClass
|
||||
|
||||
@@ -32,6 +33,8 @@ fun SendScreen(
|
||||
onAccessPolicyChanged: (ShareAccessPolicy) -> Unit,
|
||||
onCreateShare: () -> Unit,
|
||||
onTransferSelected: (ULong) -> Unit,
|
||||
onShareTransfer: (ULong) -> Unit = {},
|
||||
onStopSharing: (ULong) -> Unit = {},
|
||||
onCloseTransferDetails: () -> Unit,
|
||||
onCopyTicket: (String) -> Unit,
|
||||
onActivity: () -> Unit = {},
|
||||
@@ -40,11 +43,13 @@ fun SendScreen(
|
||||
onCloseDetailPanel: () -> Unit = {},
|
||||
onInvitationResult: (InvitationAction, Result<Unit>) -> Unit = { _, _ -> },
|
||||
onRequestDelete: () -> Unit = {},
|
||||
onRequestDeleteTransfer: (ULong) -> Unit = {},
|
||||
onDismissDelete: () -> Unit = {},
|
||||
onConfirmDelete: () -> Unit = {},
|
||||
) {
|
||||
val outgoingTransfers = coreState.transfers.filter { it.direction == TransferDirection.Send }
|
||||
val selectedTransfer = state.selectedTransferId?.let { id -> outgoingTransfers.firstOrNull { it.transferId == id } }
|
||||
val deleteTarget = state.deleteTargetTransferId?.let { id -> outgoingTransfers.firstOrNull { it.transferId == id } }
|
||||
val qrCache = remember { mutableStateMapOf<String, ImageBitmap>() }
|
||||
LaunchedEffect(outgoingTransfers.mapNotNull { it.ticket }) {
|
||||
qrCache.keys.retainAll(outgoingTransfers.mapNotNull { it.ticket }.toSet())
|
||||
@@ -63,6 +68,7 @@ fun SendScreen(
|
||||
onActivity = onActivity,
|
||||
onReceivers = onReceivers,
|
||||
onShare = onShare,
|
||||
onStopSharing = { onStopSharing(selectedTransfer.transferId) },
|
||||
onDelete = onRequestDelete,
|
||||
)
|
||||
} else {
|
||||
@@ -74,6 +80,9 @@ fun SendScreen(
|
||||
windowClass = windowClass,
|
||||
onOpenComposer = onOpenComposer,
|
||||
onTransferSelected = onTransferSelected,
|
||||
onShare = onShareTransfer,
|
||||
onStopSharing = onStopSharing,
|
||||
onDelete = onRequestDeleteTransfer,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -96,7 +105,12 @@ fun SendScreen(
|
||||
}
|
||||
}
|
||||
|
||||
if (selectedTransfer != null && state.detailPanel != null) {
|
||||
val canShowDetailPanel = selectedTransfer != null && when (state.detailPanel) {
|
||||
TransferDetailPanel.Share -> selectedTransfer.status in setOf(TransferStatus.Importing, TransferStatus.Sharing)
|
||||
TransferDetailPanel.Activity, TransferDetailPanel.Receivers -> true
|
||||
null -> false
|
||||
}
|
||||
if (selectedTransfer != null && state.detailPanel != null && canShowDetailPanel) {
|
||||
AdaptiveDrawer(windowClass = windowClass, onDismissRequest = onCloseDetailPanel) {
|
||||
when (state.detailPanel) {
|
||||
TransferDetailPanel.Activity -> TransferActivityPanel(coreState.events, selectedTransfer.transferId)
|
||||
@@ -117,10 +131,10 @@ fun SendScreen(
|
||||
}
|
||||
}
|
||||
|
||||
if (selectedTransfer != null && state.isDeleteConfirmationOpen) {
|
||||
if (deleteTarget != null && state.isDeleteConfirmationOpen) {
|
||||
AdaptiveDrawer(windowClass = windowClass, onDismissRequest = onDismissDelete) {
|
||||
DeleteTransferPanel(
|
||||
transferName = selectedTransfer.transferName,
|
||||
transferName = deleteTarget.transferName,
|
||||
isDeleting = state.isDeleting,
|
||||
onCancel = onDismissDelete,
|
||||
onConfirm = onConfirmDelete,
|
||||
|
||||
@@ -44,6 +44,7 @@ data class SendState(
|
||||
val receiversByTransfer: Map<ULong, List<ReceiverRequestModel>> = emptyMap(),
|
||||
val isLoadingReceivers: Boolean = false,
|
||||
val isDeleteConfirmationOpen: Boolean = false,
|
||||
val deleteTargetTransferId: ULong? = null,
|
||||
val isDeleting: Boolean = false,
|
||||
) {
|
||||
val selectedFile: PickedShareFile? get() = selectedFiles.singleOrNull()
|
||||
@@ -209,19 +210,31 @@ class SendViewModel(
|
||||
)
|
||||
}
|
||||
fun openActivity() = _state.update { it.copy(detailPanel = TransferDetailPanel.Activity) }
|
||||
fun openShare() = _state.update { it.copy(detailPanel = TransferDetailPanel.Share) }
|
||||
fun openShare() {
|
||||
val selectedId = _state.value.selectedTransferId ?: return
|
||||
val selected = coreState.value.transfers.firstOrNull { it.transferId == selectedId } ?: return
|
||||
if (selected.status !in setOf(TransferStatus.Importing, TransferStatus.Sharing)) return
|
||||
_state.update { it.copy(detailPanel = TransferDetailPanel.Share) }
|
||||
}
|
||||
fun openReceivers() {
|
||||
val transferId = _state.value.selectedTransferId ?: return
|
||||
_state.update { it.copy(detailPanel = TransferDetailPanel.Receivers) }
|
||||
refreshReceivers(transferId)
|
||||
}
|
||||
fun closeDetailPanel() = _state.update { it.copy(detailPanel = null) }
|
||||
fun requestDeleteTransfer() = _state.update { it.copy(isDeleteConfirmationOpen = true) }
|
||||
fun requestDeleteTransfer(transferId: ULong? = null) = _state.update {
|
||||
it.copy(
|
||||
isDeleteConfirmationOpen = true,
|
||||
deleteTargetTransferId = transferId ?: it.selectedTransferId,
|
||||
)
|
||||
}
|
||||
fun dismissDeleteTransfer() {
|
||||
if (!_state.value.isDeleting) _state.update { it.copy(isDeleteConfirmationOpen = false) }
|
||||
if (!_state.value.isDeleting) {
|
||||
_state.update { it.copy(isDeleteConfirmationOpen = false, deleteTargetTransferId = null) }
|
||||
}
|
||||
}
|
||||
fun confirmDeleteTransfer() {
|
||||
val transferId = _state.value.selectedTransferId ?: return
|
||||
val transferId = _state.value.deleteTargetTransferId ?: return
|
||||
if (_state.value.isDeleting) return
|
||||
viewModelScope.launch {
|
||||
_state.update { it.copy(isDeleting = true) }
|
||||
@@ -234,6 +247,7 @@ class SendViewModel(
|
||||
detailPanel = null,
|
||||
receiverHistory = emptyList(),
|
||||
isDeleteConfirmationOpen = false,
|
||||
deleteTargetTransferId = null,
|
||||
isDeleting = false,
|
||||
)
|
||||
}
|
||||
@@ -280,6 +294,7 @@ class SendViewModel(
|
||||
onSuccess = { share ->
|
||||
current.selectedFiles.firstNotNullOfOrNull { it.thumbnailBytes }
|
||||
?.let { filePreviewRepository.save(share.transferId, it) }
|
||||
repository.refresh()
|
||||
_state.update {
|
||||
it.copy(
|
||||
isComposerOpen = false,
|
||||
@@ -287,8 +302,11 @@ class SendViewModel(
|
||||
transferName = "",
|
||||
accessPolicy = ShareAccessPolicy.RequireApproval,
|
||||
isSharing = false,
|
||||
selectedTransferId = share.transferId,
|
||||
detailPanel = TransferDetailPanel.Share,
|
||||
)
|
||||
}
|
||||
refreshReceivers(share.transferId)
|
||||
messages.show(UiMessage(UiText.Resource(Res.string.send_transfer_created), UiMessageTone.Success))
|
||||
},
|
||||
onFailure = { error ->
|
||||
@@ -299,6 +317,15 @@ class SendViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
fun stopSharing(transferId: ULong) {
|
||||
viewModelScope.launch {
|
||||
repository.cancel(transferId).fold(
|
||||
onSuccess = { repository.refresh() },
|
||||
onFailure = messages::error,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun defaultTransferName(files: List<PickedShareFile>): String = when {
|
||||
files.isEmpty() -> ""
|
||||
files.size == 1 && files.first().isDirectory -> files.first().displayName
|
||||
|
||||
@@ -17,6 +17,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.RadioButton
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
@@ -174,22 +175,51 @@ private fun ReviewFileStep(
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
if (windowClass == WindowClass.Phone) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
ShareButton(state, coreInitialized, onCreateShare, Modifier.fillMaxWidth())
|
||||
QuietButton(stringResource(Res.string.button_change_files), onClick = onSelectFile, modifier = Modifier.fillMaxWidth(), enabled = !state.isSharing)
|
||||
QuietButton(stringResource(Res.string.button_choose_folder), onClick = onSelectFolder, modifier = Modifier.fillMaxWidth(), enabled = !state.isSharing)
|
||||
}
|
||||
} else {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
ShareButton(state, coreInitialized, onCreateShare)
|
||||
QuietButton(stringResource(Res.string.button_change_files), onClick = onSelectFile, enabled = !state.isSharing)
|
||||
QuietButton(stringResource(Res.string.button_choose_folder), onClick = onSelectFolder, enabled = !state.isSharing)
|
||||
QuietButton(stringResource(Res.string.button_clear), onClick = onClearFile, enabled = !state.isSharing)
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
ShareButton(state, coreInitialized, onCreateShare, Modifier.fillMaxWidth())
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
SourceButton(
|
||||
text = stringResource(Res.string.button_change_files),
|
||||
icon = AppIcon.File,
|
||||
onClick = onSelectFile,
|
||||
modifier = Modifier.weight(1f),
|
||||
enabled = !state.isSharing,
|
||||
)
|
||||
SourceButton(
|
||||
text = stringResource(Res.string.button_choose_folder),
|
||||
icon = AppIcon.Folder,
|
||||
onClick = onSelectFolder,
|
||||
modifier = Modifier.weight(1f),
|
||||
enabled = !state.isSharing,
|
||||
)
|
||||
if (windowClass != WindowClass.Phone) {
|
||||
SourceButton(
|
||||
text = stringResource(Res.string.button_clear),
|
||||
icon = AppIcon.Close,
|
||||
onClick = onClearFile,
|
||||
modifier = Modifier.weight(1f),
|
||||
enabled = !state.isSharing,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SourceButton(
|
||||
text: String,
|
||||
icon: AppIcon,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
enabled: Boolean,
|
||||
) {
|
||||
OutlinedButton(onClick = onClick, modifier = modifier, enabled = enabled) {
|
||||
PlatformIcon(icon, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(text, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ShareButton(state: SendState, coreInitialized: Boolean, onCreateShare: () -> Unit, modifier: Modifier = Modifier) {
|
||||
PrimaryButton(
|
||||
|
||||
@@ -46,6 +46,7 @@ import com.vnidrop.app.core.ReceiverDeliveryStatus
|
||||
import com.vnidrop.app.core.ReceiverRequestModel
|
||||
import com.vnidrop.app.core.ShareAccessPolicy
|
||||
import com.vnidrop.app.core.Transfer
|
||||
import com.vnidrop.app.core.TransferStatus
|
||||
import com.vnidrop.app.ui.components.AppCard
|
||||
import com.vnidrop.app.ui.components.DestructiveButton
|
||||
import com.vnidrop.app.ui.components.PrimaryButton
|
||||
@@ -60,12 +61,19 @@ import com.vnidrop.app.ui.state.progressForReceiver
|
||||
import com.vnidrop.app.ui.theme.LocalVniDropColors
|
||||
import org.jetbrains.compose.resources.decodeToImageBitmap
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import vnidrop.shared.generated.resources.*
|
||||
|
||||
enum class InvitationAction { Export, Share, Nfc }
|
||||
|
||||
private sealed interface TransferQrRenderState {
|
||||
data object Loading : TransferQrRenderState
|
||||
data object Unavailable : TransferQrRenderState
|
||||
data class Ready(val bitmap: androidx.compose.ui.graphics.ImageBitmap) : TransferQrRenderState
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun TransferDetails(
|
||||
transfer: Transfer,
|
||||
@@ -76,6 +84,7 @@ internal fun TransferDetails(
|
||||
onActivity: () -> Unit,
|
||||
onReceivers: () -> Unit,
|
||||
onShare: () -> Unit,
|
||||
onStopSharing: () -> Unit,
|
||||
onDelete: () -> Unit,
|
||||
) {
|
||||
LazyColumn(
|
||||
@@ -92,8 +101,14 @@ internal fun TransferDetails(
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
IconButton(onClick = onDelete) {
|
||||
PlatformIcon(AppIcon.Delete, stringResource(Res.string.button_delete_transfer), tint = LocalVniDropColors.current.destructiveDefault)
|
||||
if (transfer.status in setOf(TransferStatus.Importing, TransferStatus.Sharing)) {
|
||||
IconButton(onClick = onShare) {
|
||||
PlatformIcon(
|
||||
AppIcon.Share,
|
||||
stringResource(Res.string.transfer_share_title),
|
||||
tint = LocalVniDropColors.current.brandLink,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -122,13 +137,29 @@ internal fun TransferDetails(
|
||||
count = pendingReceivers + completedReceivers,
|
||||
onClick = onReceivers,
|
||||
)
|
||||
HorizontalDivider(color = LocalVniDropColors.current.borderDefault)
|
||||
DetailDestination(
|
||||
title = stringResource(Res.string.transfer_share_title),
|
||||
description = stringResource(Res.string.transfer_share_description),
|
||||
onClick = onShare,
|
||||
}
|
||||
}
|
||||
}
|
||||
item {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
if (transfer.status == TransferStatus.Sharing) {
|
||||
DestructiveButton(
|
||||
stringResource(Res.string.send_stop_sharing),
|
||||
onClick = onStopSharing,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
leadingIcon = {
|
||||
PlatformIcon(AppIcon.StopCircle, null, modifier = Modifier.size(18.dp))
|
||||
},
|
||||
)
|
||||
}
|
||||
DestructiveButton(
|
||||
stringResource(Res.string.button_delete_transfer),
|
||||
onClick = onDelete,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
leadingIcon = {
|
||||
PlatformIcon(AppIcon.Delete, null, modifier = Modifier.size(18.dp))
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -144,9 +175,12 @@ private fun receiversDescription(pending: Int, completed: Int): String = when {
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DetailDestination(title: String, description: String, count: Int? = null, onClick: () -> Unit) {
|
||||
private fun DetailDestination(title: String, description: String, count: Int? = null, onClick: (() -> Unit)? = null) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth().clickable(onClick = onClick).padding(16.dp),
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.then(if (onClick == null) Modifier else Modifier.clickable(onClick = onClick))
|
||||
.padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) {
|
||||
@@ -157,7 +191,9 @@ private fun DetailDestination(title: String, description: String, count: Int? =
|
||||
Text(count.toString(), modifier = Modifier.background(LocalVniDropColors.current.backgroundSelection, RoundedCornerShape(20.dp)).padding(horizontal = 9.dp, vertical = 3.dp))
|
||||
Spacer(Modifier.width(8.dp))
|
||||
}
|
||||
PlatformIcon(AppIcon.ChevronRight, null, tint = LocalVniDropColors.current.foregroundLighter, modifier = Modifier.size(18.dp))
|
||||
if (onClick != null) {
|
||||
PlatformIcon(AppIcon.ChevronRight, null, tint = LocalVniDropColors.current.foregroundLighter, modifier = Modifier.size(18.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -240,41 +276,71 @@ internal fun TransferSharePanel(
|
||||
DisposableEffect(actions) { onDispose(actions::cancelNfcWrite) }
|
||||
val ticket = transfer.ticket
|
||||
PanelContainer(stringResource(Res.string.transfer_share_title)) {
|
||||
if (transfer.status == TransferStatus.Importing) {
|
||||
Text(stringResource(Res.string.transfer_event_preparing), color = LocalVniDropColors.current.foregroundLighter)
|
||||
return@PanelContainer
|
||||
}
|
||||
if (transfer.status != TransferStatus.Sharing) return@PanelContainer
|
||||
if (ticket == null) {
|
||||
Text(stringResource(Res.string.transfer_event_preparing), color = LocalVniDropColors.current.foregroundLighter)
|
||||
return@PanelContainer
|
||||
}
|
||||
val renderedBitmap by produceState(qrBitmap, ticket, qrBitmap) {
|
||||
if (value == null) {
|
||||
value = withContext(Dispatchers.Default) {
|
||||
runCatching { buildTransferQrCode(ticket).renderToBytes().decodeToImageBitmap() }.getOrNull()
|
||||
val qrRenderState by produceState<TransferQrRenderState>(
|
||||
initialValue = qrBitmap?.let(TransferQrRenderState::Ready) ?: TransferQrRenderState.Loading,
|
||||
key1 = ticket,
|
||||
key2 = qrBitmap,
|
||||
) {
|
||||
if (qrBitmap != null) {
|
||||
value = TransferQrRenderState.Ready(qrBitmap)
|
||||
return@produceState
|
||||
}
|
||||
value = try {
|
||||
val bitmap = withContext(Dispatchers.Default) {
|
||||
buildTransferQrCode(ticket).renderToBytes().decodeToImageBitmap()
|
||||
}
|
||||
value?.let { onQrRendered(ticket, it) }
|
||||
onQrRendered(ticket, bitmap)
|
||||
TransferQrRenderState.Ready(bitmap)
|
||||
} catch (error: CancellationException) {
|
||||
throw error
|
||||
} catch (_: Throwable) {
|
||||
TransferQrRenderState.Unavailable
|
||||
}
|
||||
}
|
||||
val renderedQr = renderedBitmap
|
||||
Surface(
|
||||
modifier = Modifier.align(Alignment.CenterHorizontally).size(268.dp),
|
||||
shape = RoundedCornerShape(18.dp),
|
||||
color = Color.White,
|
||||
) {
|
||||
if (renderedQr != null) {
|
||||
when (val qrState = qrRenderState) {
|
||||
is TransferQrRenderState.Ready -> Surface(
|
||||
modifier = Modifier.align(Alignment.CenterHorizontally).size(268.dp),
|
||||
shape = RoundedCornerShape(18.dp),
|
||||
color = Color.White,
|
||||
) {
|
||||
Image(
|
||||
bitmap = renderedQr,
|
||||
bitmap = qrState.bitmap,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.padding(14.dp).fillMaxSize(),
|
||||
filterQuality = FilterQuality.None,
|
||||
)
|
||||
} else {
|
||||
}
|
||||
TransferQrRenderState.Loading -> Surface(
|
||||
modifier = Modifier.align(Alignment.CenterHorizontally).size(268.dp),
|
||||
shape = RoundedCornerShape(18.dp),
|
||||
color = Color.White,
|
||||
) {
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { CircularProgressIndicator() }
|
||||
}
|
||||
TransferQrRenderState.Unavailable -> Text(
|
||||
stringResource(Res.string.transfer_qr_unavailable),
|
||||
modifier = Modifier.align(Alignment.CenterHorizontally),
|
||||
color = LocalVniDropColors.current.foregroundLighter,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
if (qrRenderState is TransferQrRenderState.Ready) {
|
||||
Text(
|
||||
stringResource(Res.string.transfer_scan_qr),
|
||||
modifier = Modifier.align(Alignment.CenterHorizontally),
|
||||
color = LocalVniDropColors.current.foregroundLighter,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
Text(
|
||||
stringResource(Res.string.transfer_scan_qr),
|
||||
modifier = Modifier.align(Alignment.CenterHorizontally),
|
||||
color = LocalVniDropColors.current.foregroundLighter,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
if (actions.nfcAvailability != NfcShareAvailability.Hidden) {
|
||||
var writingNfc by remember(ticket) { mutableStateOf(false) }
|
||||
SecondaryButton(
|
||||
@@ -349,13 +415,15 @@ private fun receiverStatusText(status: ReceiverDeliveryStatus) = stringResource(
|
||||
ReceiverDeliveryStatus.Refused -> Res.string.transfer_receiver_refused
|
||||
ReceiverDeliveryStatus.Expired -> Res.string.transfer_receiver_expired
|
||||
ReceiverDeliveryStatus.Completed -> Res.string.transfer_receiver_completed
|
||||
ReceiverDeliveryStatus.Failed -> Res.string.transfer_receiver_failed
|
||||
ReceiverDeliveryStatus.Unknown -> Res.string.transfer_receiver_unknown
|
||||
})
|
||||
|
||||
@Composable
|
||||
private fun receiverStatusColor(status: ReceiverDeliveryStatus) = when (status) {
|
||||
ReceiverDeliveryStatus.Completed -> LocalVniDropColors.current.brandDefault
|
||||
ReceiverDeliveryStatus.Refused, ReceiverDeliveryStatus.Expired -> LocalVniDropColors.current.destructiveDefault
|
||||
ReceiverDeliveryStatus.Refused, ReceiverDeliveryStatus.Expired, ReceiverDeliveryStatus.Failed ->
|
||||
LocalVniDropColors.current.destructiveDefault
|
||||
else -> LocalVniDropColors.current.foregroundLighter
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,315 @@
|
||||
package com.vnidrop.app.feature.settings
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vnidrop.app.core.RelayMode
|
||||
import com.vnidrop.app.core.usesCustomRelayUrls
|
||||
import com.vnidrop.app.ui.components.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_add_url
|
||||
import vnidrop.shared.generated.resources.relay_apply
|
||||
import vnidrop.shared.generated.resources.relay_apply_active_transfers
|
||||
import vnidrop.shared.generated.resources.relay_apply_failed
|
||||
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_mode_custom_direct_fallback
|
||||
import vnidrop.shared.generated.resources.relay_mode_custom_direct_fallback_description
|
||||
import vnidrop.shared.generated.resources.relay_mode_local_only
|
||||
import vnidrop.shared.generated.resources.relay_mode_local_only_description
|
||||
import vnidrop.shared.generated.resources.relay_privacy_description
|
||||
import vnidrop.shared.generated.resources.relay_remove_url
|
||||
import vnidrop.shared.generated.resources.relay_restore_failed
|
||||
import vnidrop.shared.generated.resources.relay_strict_warning
|
||||
import vnidrop.shared.generated.resources.relay_validation_duplicate_url
|
||||
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,
|
||||
onUrlChanged: (Int, String) -> Unit,
|
||||
onAddUrl: () -> Unit,
|
||||
onRemoveUrl: (Int) -> 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)
|
||||
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.Shield,
|
||||
title = stringResource(Res.string.relay_mode_custom),
|
||||
description = stringResource(Res.string.relay_mode_custom_description),
|
||||
selected = state.relayMode == RelayMode.StrictCustom,
|
||||
enabled = !state.isApplyingRelaySettings,
|
||||
onClick = { onModeChanged(RelayMode.StrictCustom) },
|
||||
)
|
||||
SettingsDivider()
|
||||
RelayModeRow(
|
||||
icon = AppIcon.Radio,
|
||||
title = stringResource(Res.string.relay_mode_custom_direct_fallback),
|
||||
description = stringResource(Res.string.relay_mode_custom_direct_fallback_description),
|
||||
selected = state.relayMode == RelayMode.CustomWithDirectFallback,
|
||||
enabled = !state.isApplyingRelaySettings,
|
||||
onClick = { onModeChanged(RelayMode.CustomWithDirectFallback) },
|
||||
)
|
||||
SettingsDivider()
|
||||
RelayModeRow(
|
||||
icon = AppIcon.CloudOff,
|
||||
title = stringResource(Res.string.relay_mode_local_only),
|
||||
description = stringResource(Res.string.relay_mode_local_only_description),
|
||||
selected = state.relayMode == RelayMode.LocalOnly,
|
||||
enabled = !state.isApplyingRelaySettings,
|
||||
onClick = { onModeChanged(RelayMode.LocalOnly) },
|
||||
)
|
||||
}
|
||||
Text(
|
||||
stringResource(Res.string.relay_privacy_description),
|
||||
color = colors.foregroundLighter,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
state.endpointId?.takeIf(String::isNotBlank)?.let { endpointId ->
|
||||
SelectionContainer {
|
||||
Text(
|
||||
stringResource(Res.string.approval_endpoint_id, endpointId),
|
||||
color = colors.foregroundLighter,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
}
|
||||
if (state.relayMode.usesCustomRelayUrls) {
|
||||
Text(
|
||||
stringResource(Res.string.relay_custom_urls_label),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
SettingsGroup {
|
||||
state.relayUrls.forEachIndexed { index, url ->
|
||||
RelayUrlRow(
|
||||
value = url,
|
||||
error = relayUrlErrorText(state.relayInputError, index),
|
||||
enabled = !state.isApplyingRelaySettings,
|
||||
onValueChange = { onUrlChanged(index, it) },
|
||||
onRemove = { onRemoveUrl(index) },
|
||||
)
|
||||
if (index < state.relayUrls.lastIndex) {
|
||||
SettingsDivider(startPadding = 14.dp)
|
||||
}
|
||||
}
|
||||
if (state.relayUrls.isNotEmpty()) {
|
||||
SettingsDivider(startPadding = 14.dp)
|
||||
}
|
||||
TextButton(
|
||||
onClick = onAddUrl,
|
||||
enabled = state.relayUrls.size < MaximumRelayUrls && !state.isApplyingRelaySettings,
|
||||
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp),
|
||||
) {
|
||||
PlatformIcon(AppIcon.Add, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(stringResource(Res.string.relay_add_url))
|
||||
}
|
||||
}
|
||||
Text(
|
||||
stringResource(Res.string.relay_custom_urls_help),
|
||||
color = colors.foregroundLighter,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
if (state.relayMode == RelayMode.StrictCustom) {
|
||||
Text(
|
||||
stringResource(Res.string.relay_strict_warning),
|
||||
color = colors.foregroundLight,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontWeight = FontWeight.Medium,
|
||||
)
|
||||
}
|
||||
}
|
||||
relayGlobalErrorText(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 RelayUrlRow(
|
||||
value: String,
|
||||
error: String?,
|
||||
enabled: Boolean,
|
||||
onValueChange: (String) -> Unit,
|
||||
onRemove: () -> Unit,
|
||||
) {
|
||||
val colors = LocalVniDropColors.current
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 14.dp, end = 6.dp, top = 10.dp, bottom = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = value,
|
||||
onValueChange = onValueChange,
|
||||
modifier = Modifier.weight(1f),
|
||||
enabled = enabled,
|
||||
singleLine = true,
|
||||
isError = error != null,
|
||||
placeholder = { Text("https://relay.example.com") },
|
||||
supportingText = error?.let {
|
||||
{
|
||||
Text(it, color = colors.destructiveDefault)
|
||||
}
|
||||
},
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
)
|
||||
IconButton(onClick = onRemove, enabled = enabled) {
|
||||
PlatformIcon(
|
||||
AppIcon.Delete,
|
||||
contentDescription = stringResource(Res.string.relay_remove_url),
|
||||
tint = colors.destructiveDefault,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun relayUrlErrorText(error: RelaySettingsInputError?, index: Int): String? = when (error) {
|
||||
is RelaySettingsInputError.HttpsRequired -> if (error.line == index + 1) {
|
||||
stringResource(Res.string.relay_validation_https_required, error.line)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
is RelaySettingsInputError.InvalidUrl -> if (error.line == index + 1) {
|
||||
stringResource(Res.string.relay_validation_invalid_url, error.line)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
is RelaySettingsInputError.DuplicateUrl -> if (error.line == index + 1) {
|
||||
stringResource(Res.string.relay_validation_duplicate_url, error.line)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
RelaySettingsInputError.MissingUrl,
|
||||
is RelaySettingsInputError.TooManyUrls,
|
||||
null,
|
||||
-> null
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun relayGlobalErrorText(state: SettingsState): String? {
|
||||
if (state.hasActiveNetworkWork || state.relayApplyError == RelaySettingsApplyError.ActiveTransfers) {
|
||||
return stringResource(Res.string.relay_apply_active_transfers)
|
||||
}
|
||||
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,
|
||||
is RelaySettingsInputError.InvalidUrl,
|
||||
is RelaySettingsInputError.DuplicateUrl,
|
||||
-> null
|
||||
}
|
||||
}
|
||||
return when (state.relayApplyError) {
|
||||
RelaySettingsApplyError.ApplyFailed -> stringResource(Res.string.relay_apply_failed)
|
||||
RelaySettingsApplyError.RestoreFailed -> stringResource(Res.string.relay_restore_failed)
|
||||
RelaySettingsApplyError.ActiveTransfers,
|
||||
null,
|
||||
-> null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
package com.vnidrop.app.feature.settings
|
||||
|
||||
import com.vnidrop.app.core.RelayMode
|
||||
import com.vnidrop.app.core.RelaySettings
|
||||
import com.vnidrop.app.core.usesCustomRelayUrls
|
||||
|
||||
sealed interface RelaySettingsInputError {
|
||||
data object MissingUrl : RelaySettingsInputError
|
||||
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,
|
||||
relayUrls: List<String>,
|
||||
retainedUrls: List<String> = emptyList(),
|
||||
): RelaySettingsValidation {
|
||||
if (!mode.usesCustomRelayUrls) {
|
||||
return RelaySettingsValidation(RelaySettings(mode, retainedUrls))
|
||||
}
|
||||
val lines = relayUrls
|
||||
.mapIndexedNotNull { index, raw -> raw.trim().takeIf(String::isNotEmpty)?.let { index + 1 to it } }
|
||||
.toList()
|
||||
if (lines.isEmpty()) return RelaySettingsValidation(error = RelaySettingsInputError.MissingUrl)
|
||||
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(mode, 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://"
|
||||
internal const val MaximumRelayUrls = 8
|
||||
private const val MaximumRelayUrlLength = 2_048
|
||||
@@ -7,6 +7,7 @@ import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vnidrop.app.core.RelayMode
|
||||
import com.vnidrop.app.ui.icons.AppIcon
|
||||
import com.vnidrop.app.ui.theme.ThemeMode
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
@@ -18,7 +19,12 @@ import vnidrop.shared.generated.resources.appearance_system_mode
|
||||
import vnidrop.shared.generated.resources.appearance_title
|
||||
import vnidrop.shared.generated.resources.notifications_title
|
||||
import vnidrop.shared.generated.resources.preferences_title
|
||||
import vnidrop.shared.generated.resources.relay_mode_automatic
|
||||
import vnidrop.shared.generated.resources.relay_mode_custom
|
||||
import vnidrop.shared.generated.resources.relay_mode_custom_direct_fallback
|
||||
import vnidrop.shared.generated.resources.relay_mode_local_only
|
||||
import vnidrop.shared.generated.resources.settings_title
|
||||
import vnidrop.shared.generated.resources.settings_network_title
|
||||
import vnidrop.shared.generated.resources.storage_title
|
||||
|
||||
@Composable
|
||||
@@ -64,7 +70,17 @@ internal fun SettingsOverview(
|
||||
selected = state.selectedSection == SettingsSection.Storage,
|
||||
onClick = { onSectionSelected(SettingsSection.Storage) },
|
||||
)
|
||||
SettingsDivider()
|
||||
}
|
||||
SettingsGroup {
|
||||
SettingsRow(
|
||||
icon = AppIcon.Globe,
|
||||
title = stringResource(Res.string.settings_network_title),
|
||||
value = relayModeLabel(state.savedRelaySettings.mode),
|
||||
selected = state.selectedSection == SettingsSection.Network,
|
||||
onClick = { onSectionSelected(SettingsSection.Network) },
|
||||
)
|
||||
}
|
||||
SettingsGroup {
|
||||
SettingsRow(
|
||||
icon = AppIcon.Info,
|
||||
title = stringResource(Res.string.about_title),
|
||||
@@ -76,6 +92,14 @@ internal fun SettingsOverview(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun relayModeLabel(mode: RelayMode): String = when (mode) {
|
||||
RelayMode.Automatic -> stringResource(Res.string.relay_mode_automatic)
|
||||
RelayMode.StrictCustom -> stringResource(Res.string.relay_mode_custom)
|
||||
RelayMode.CustomWithDirectFallback -> stringResource(Res.string.relay_mode_custom_direct_fallback)
|
||||
RelayMode.LocalOnly -> stringResource(Res.string.relay_mode_local_only)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun themeModeLabel(mode: ThemeMode): String = when (mode) {
|
||||
ThemeMode.System -> stringResource(Res.string.appearance_system_mode)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user