mirror of
https://github.com/sudosylabs/vnidrop.git
synced 2026-08-06 10:49:56 +02:00
Compare commits
50 Commits
5939489432
...
c7657c4b37
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c7657c4b37 | ||
| 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 | |||
| e22e395efc | |||
| 677c3ce47f | |||
| 20597e6e88 | |||
| 42f569dcf0 | |||
| 8b31c0fe78 | |||
| c9ef40f00f | |||
| 0448137d84 | |||
| 6180cb95ce | |||
| 2b2fe93293 | |||
| 3469b122c2 | |||
| 4074f4bee8 | |||
| 7cc0e825f6 | |||
| efb3c474d1 | |||
| 0e0d43bc4d | |||
| 081b59815c | |||
| 3c8267adc5 | |||
| 08e61c57af | |||
| 1563bf80d6 | |||
| ef42875ddb | |||
| 7592d49a59 | |||
| a0bcc5dbff | |||
| cbace73908 |
11
.github/workflows/apple.yml
vendored
11
.github/workflows/apple.yml
vendored
@@ -12,6 +12,7 @@ on:
|
||||
- "config.mk"
|
||||
- "make/**"
|
||||
- ".github/workflows/apple.yml"
|
||||
- "localization/**"
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
@@ -25,6 +26,7 @@ on:
|
||||
- "config.mk"
|
||||
- "make/**"
|
||||
- ".github/workflows/apple.yml"
|
||||
- "localization/**"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -63,5 +65,14 @@ 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.
|
||||
uses: oven-sh/setup-bun@v2
|
||||
|
||||
- name: Build and test Apple app
|
||||
run: make check-apple
|
||||
|
||||
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]]
|
||||
|
||||
2
Makefile
2
Makefile
@@ -113,7 +113,7 @@ apple-core: ## Build the Rust XCFramework and generated Swift bindings.
|
||||
@test "$(HOST_OS)" = macos || { printf 'Apple builds require macOS.\n' >&2; exit 1; }
|
||||
cd $(ROOT) && apple/scripts/build-core.sh $(APPLE_PROFILE)
|
||||
|
||||
apple-project: apple-core ## Generate the native Apple Xcode project.
|
||||
apple-project: apple-core localization ## Generate the native Apple Xcode project.
|
||||
cd $(ROOT)/apple && $(XCODEGEN) generate
|
||||
|
||||
open-apple-project: apple-project ## Generate and open the native Apple Xcode project.
|
||||
|
||||
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
|
||||
|
||||
|
||||
4
apple/.gitignore
vendored
4
apple/.gitignore
vendored
@@ -3,6 +3,10 @@
|
||||
VnidropCore/vnidrop.xcframework/
|
||||
VnidropCore/Sources/VnidropCore/Vnidrop.swift
|
||||
|
||||
# Generated from localization/strings.json (cd localization && bun run src/cli.ts generate)
|
||||
VniDrop/Resources/Localizable.xcstrings
|
||||
VniDrop/Generated/
|
||||
|
||||
# Generated by XcodeGen from project.yml
|
||||
VniDrop.xcodeproj/
|
||||
|
||||
|
||||
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,11 +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)
|
||||
XCTAssertFalse(repo.preferences.notificationsEnabled)
|
||||
XCTAssertEqual(repo.preferences.relayConfiguration, .automatic)
|
||||
}
|
||||
|
||||
func testValuesPersistAndReload() {
|
||||
@@ -28,16 +28,52 @@ final class AppPreferencesRepositoryTests: XCTestCase {
|
||||
let repo = AppPreferencesRepository(defaults: store, fallback: fb)
|
||||
repo.setUsername("Bob")
|
||||
repo.setThemeMode(.dark)
|
||||
repo.setNotificationsEnabled(true)
|
||||
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)
|
||||
XCTAssertEqual(reloaded.preferences.username, "Bob")
|
||||
XCTAssertEqual(reloaded.preferences.themeMode, .dark)
|
||||
XCTAssertTrue(reloaded.preferences.notificationsEnabled)
|
||||
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() {
|
||||
|
||||
@@ -11,7 +11,6 @@ final class ApprovalCoordinatorTests: XCTestCase {
|
||||
private func makeCoordinator(_ core: FakeCoreGateway) -> ApprovalCoordinator {
|
||||
ApprovalCoordinator(
|
||||
repository: core,
|
||||
preferences: Fixtures.preferences(),
|
||||
notifications: LocalNotificationService(),
|
||||
visibility: AppVisibility(),
|
||||
messages: UiMessageController()
|
||||
|
||||
50
apple/Tests/CoreDispatcherTests.swift
Normal file
50
apple/Tests/CoreDispatcherTests.swift
Normal file
@@ -0,0 +1,50 @@
|
||||
import XCTest
|
||||
@testable import VniDrop
|
||||
|
||||
final class CoreDispatcherTests: XCTestCase {
|
||||
|
||||
/// Regression guard for the receive-cancel deadlock: an interrupt-lane call
|
||||
/// must complete even while the serial lane is occupied by a blocking call.
|
||||
/// With a single shared queue (the old design) the interrupt would be stuck
|
||||
/// behind the blocked `receive`, and this would time out.
|
||||
func testInterruptCompletesWhileSerialLaneIsBlocked() async {
|
||||
let dispatcher = CoreDispatcher()
|
||||
let serialEntered = DispatchSemaphore(value: 0)
|
||||
let releaseSerial = DispatchSemaphore(value: 0)
|
||||
|
||||
// Occupy the serial lane with a call that blocks until we release it.
|
||||
let serialTask = Task {
|
||||
await dispatcher.run {
|
||||
serialEntered.signal()
|
||||
releaseSerial.wait()
|
||||
}
|
||||
}
|
||||
XCTAssertEqual(serialEntered.wait(timeout: .now() + 2), .success, "serial lane never started")
|
||||
|
||||
// The interrupt lane must run despite the serial lane being blocked.
|
||||
let interruptDone = DispatchSemaphore(value: 0)
|
||||
Task.detached {
|
||||
_ = await dispatcher.runInterrupt { 42 }
|
||||
interruptDone.signal()
|
||||
}
|
||||
XCTAssertEqual(
|
||||
interruptDone.wait(timeout: .now() + 2), .success,
|
||||
"interrupt lane was blocked behind the occupied serial lane")
|
||||
|
||||
releaseSerial.signal()
|
||||
_ = await serialTask.value
|
||||
}
|
||||
|
||||
func testRunPropagatesValuesAndErrors() async {
|
||||
let dispatcher = CoreDispatcher()
|
||||
|
||||
let value = await dispatcher.run { 7 }
|
||||
XCTAssertEqual(try? value.get(), 7)
|
||||
|
||||
let failure = await dispatcher.run { () -> Int in throw TestError.unimplemented }
|
||||
switch failure {
|
||||
case .success: XCTFail("expected the thrown error to propagate")
|
||||
case .failure(let error): XCTAssertTrue(error is TestError)
|
||||
}
|
||||
}
|
||||
}
|
||||
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)
|
||||
|
||||
@@ -40,7 +40,7 @@ final class ProgressDerivationTests: XCTestCase {
|
||||
event(phase: "import", kind: "started", json: "{}"),
|
||||
]
|
||||
let progress = progressForTransfer(events: events, transferId: 1)
|
||||
XCTAssertEqual(progress?.labelKey, "progress_preparing")
|
||||
XCTAssertEqual(progress?.labelKey, L10n.Progress.preparing)
|
||||
XCTAssertEqual(progress?.progress, 0.3)
|
||||
}
|
||||
|
||||
@@ -57,15 +57,15 @@ final class ProgressDerivationTests: XCTestCase {
|
||||
remoteEndpointId: "peer-a",
|
||||
totalSizeHint: 100
|
||||
)
|
||||
XCTAssertEqual(progress?.kind, "completed")
|
||||
XCTAssertEqual(progress?.labelKey, "progress_completed")
|
||||
XCTAssertEqual(progress?.kind, .completed)
|
||||
XCTAssertEqual(progress?.labelKey, L10n.Progress.completed)
|
||||
XCTAssertEqual(progress?.progress, 1)
|
||||
}
|
||||
|
||||
func testStatusLabelKeys() {
|
||||
XCTAssertEqual(statusLabelKey(.sharing), "status_available")
|
||||
XCTAssertEqual(statusLabelKey(.receiving), "status_receiving")
|
||||
XCTAssertEqual(statusLabelKey(.done), "status_completed")
|
||||
XCTAssertEqual(statusLabelKey(.sharing), L10n.Status.available)
|
||||
XCTAssertEqual(statusLabelKey(.receiving), L10n.Status.receiving)
|
||||
XCTAssertEqual(statusLabelKey(.done), L10n.Status.completed)
|
||||
}
|
||||
|
||||
private func event(phase: String, kind: String, json: String) -> CoreEventModel {
|
||||
|
||||
@@ -24,6 +24,9 @@ final class ReceiveModelTests: XCTestCase {
|
||||
XCTAssertEqual(model.state.historyDeleteTarget, .transfer(transferId: 5))
|
||||
|
||||
model.confirmHistoryDelete()
|
||||
// Must close immediately (not after the async delete) so the alert can't
|
||||
// re-present on macOS.
|
||||
XCTAssertNil(model.state.historyDeleteTarget)
|
||||
await waitUntil { core.deletedTransfers.contains(5) }
|
||||
XCTAssertEqual(core.deletedTransfers, [5])
|
||||
XCTAssertNil(model.state.historyDeleteTarget)
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,9 @@ final class SendModelTests: XCTestCase {
|
||||
XCTAssertTrue(model.state.isDeleteConfirmationOpen)
|
||||
|
||||
model.confirmDeleteTransfer()
|
||||
// Must close immediately (not after the async delete) so the alert can't
|
||||
// re-present on macOS.
|
||||
XCTAssertFalse(model.state.isDeleteConfirmationOpen)
|
||||
await waitUntil { core.deletedTransfers.contains(3) }
|
||||
XCTAssertEqual(core.deletedTransfers, [3])
|
||||
XCTAssertNil(model.state.selectedTransferId)
|
||||
@@ -55,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")
|
||||
}
|
||||
}
|
||||
|
||||
51
apple/Tests/TransferNotificationTests.swift
Normal file
51
apple/Tests/TransferNotificationTests.swift
Normal file
@@ -0,0 +1,51 @@
|
||||
import XCTest
|
||||
@testable import VniDrop
|
||||
|
||||
@MainActor
|
||||
final class TransferNotificationTests: XCTestCase {
|
||||
|
||||
func testTransferNotificationsFireForTerminalStatesOnly() {
|
||||
let transfers = [
|
||||
Fixtures.transfer(id: 1, direction: .send, status: .failed),
|
||||
Fixtures.transfer(id: 2, direction: .receive, status: .done),
|
||||
Fixtures.transfer(id: 3, direction: .receive, status: .failed),
|
||||
Fixtures.transfer(id: 4, direction: .receive, status: .receiving), // in-flight, ignored
|
||||
Fixtures.transfer(id: 5, direction: .send, status: .sharing), // active share, ignored
|
||||
Fixtures.transfer(id: 6, direction: .send, status: .done), // send-done isn't notified
|
||||
]
|
||||
let planned = plannedTransferNotifications(transfers, published: [])
|
||||
XCTAssertEqual(planned.map(\.kind), [.sendFailed, .receiveCompleted, .receiveFailed])
|
||||
XCTAssertEqual(planned.map(\.id), ["send-failed-1", "receive-completed-2", "receive-failed-3"])
|
||||
XCTAssertEqual(planned.first?.transferName, "Photos")
|
||||
}
|
||||
|
||||
func testTransferNotificationsSkipAlreadyPublished() {
|
||||
let transfers = [Fixtures.transfer(id: 2, direction: .receive, status: .done)]
|
||||
XCTAssertTrue(plannedTransferNotifications(transfers, published: ["receive-completed-2"]).isEmpty)
|
||||
}
|
||||
|
||||
func testReceiverNotificationsFireOnlyForCompletedReceivers() {
|
||||
let requests = [
|
||||
Fixtures.request(id: "a", requestedAt: 1, status: .completed),
|
||||
Fixtures.request(id: "b", requestedAt: 2, status: .accepted),
|
||||
Fixtures.request(id: "c", requestedAt: 3, status: .requested),
|
||||
]
|
||||
let planned = plannedReceiverNotifications(requests, published: [])
|
||||
XCTAssertEqual(planned.map(\.id), ["receiver-completed-a"])
|
||||
XCTAssertEqual(planned.first?.kind, .receiverCompleted)
|
||||
XCTAssertEqual(planned.first?.receiver, "Peer")
|
||||
XCTAssertEqual(planned.first?.transferName, "Photos")
|
||||
}
|
||||
|
||||
func testReceiverNotificationsSkipAlreadyPublished() {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -42,22 +42,22 @@ final class UserFacingErrorTests: XCTestCase {
|
||||
}
|
||||
|
||||
func testToUiTextMapsKnownReasons() {
|
||||
XCTAssertEqual(InvitationError.message("The transfer was refused").toUiText(), .resource("error_permission"))
|
||||
XCTAssertEqual(InvitationError.message("invalid ticket").toUiText(), .resource("error_invalid_ticket"))
|
||||
XCTAssertEqual(InvitationError.message("Select at least one file to share").toUiText(), .resource("error_share_empty"))
|
||||
XCTAssertEqual(InvitationError.message("Camera access is required").toUiText(), .resource("error_camera"))
|
||||
XCTAssertEqual(InvitationError.message("The transfer was refused").toUiText(), .resource(L10n.Error.permission))
|
||||
XCTAssertEqual(InvitationError.message("invalid ticket").toUiText(), .resource(L10n.Error.invalidTicket))
|
||||
XCTAssertEqual(InvitationError.message("Select at least one file to share").toUiText(), .resource(L10n.Error.shareEmpty))
|
||||
XCTAssertEqual(InvitationError.message("Camera access is required").toUiText(), .resource(L10n.Error.camera))
|
||||
}
|
||||
|
||||
func testToUiTextFallsBackToGeneric() {
|
||||
XCTAssertEqual(InvitationError.message("something entirely unexpected").toUiText(), .resource("error_generic"))
|
||||
XCTAssertEqual(InvitationError.message("something entirely unexpected").toUiText(), .resource(L10n.Error.generic))
|
||||
}
|
||||
|
||||
func testToUiTextMapsTypedTransferFailures() {
|
||||
XCTAssertEqual(VnidropError.FilesystemPermission(reason: "read-only folder").toUiText(), .resource("error_filesystem"))
|
||||
XCTAssertEqual(VnidropError.DestinationExists(reason: "target exists").toUiText(), .resource("error_destination_exists"))
|
||||
XCTAssertEqual(VnidropError.StorageFull(reason: "disk full").toUiText(), .resource("error_storage_full"))
|
||||
XCTAssertEqual(VnidropError.Network(reason: "offline").toUiText(), .resource("error_network"))
|
||||
XCTAssertEqual(VnidropError.InvalidInput(reason: "bad path").toUiText(), .resource("error_invalid_input"))
|
||||
XCTAssertEqual(VnidropError.FilesystemPermission(reason: "read-only folder").toUiText(), .resource(L10n.Error.filesystem))
|
||||
XCTAssertEqual(VnidropError.DestinationExists(reason: "target exists").toUiText(), .resource(L10n.Error.destinationExists))
|
||||
XCTAssertEqual(VnidropError.StorageFull(reason: "disk full").toUiText(), .resource(L10n.Error.storageFull))
|
||||
XCTAssertEqual(VnidropError.Network(reason: "offline").toUiText(), .resource(L10n.Error.network))
|
||||
XCTAssertEqual(VnidropError.InvalidInput(reason: "bad path").toUiText(), .resource(L10n.Error.invalidInput))
|
||||
XCTAssertFalse(VnidropError.FilesystemPermission(reason: "read-only").canRetryWithoutChangingInput)
|
||||
XCTAssertFalse(VnidropError.DestinationExists(reason: "target exists").canRetryWithoutChangingInput)
|
||||
XCTAssertTrue(VnidropError.Network(reason: "offline").canRetryWithoutChangingInput)
|
||||
|
||||
@@ -12,6 +12,7 @@ final class AppGraph: ObservableObject {
|
||||
let preferencesRepository: AppPreferencesRepository
|
||||
let filePreviewRepository: FilePreviewRepository
|
||||
let approvalCoordinator: ApprovalCoordinator
|
||||
let transferNotificationCoordinator: TransferNotificationCoordinator
|
||||
|
||||
init(dependencies: AppDependencies, coreRepository: CoreRepository? = nil) {
|
||||
self.dependencies = dependencies
|
||||
@@ -23,13 +24,17 @@ final class AppGraph: ObservableObject {
|
||||
username: dependencies.environment.defaultUsername,
|
||||
receiveFolder: dependencies.fileSystemService.defaultReceiveFolder(),
|
||||
themeMode: .system,
|
||||
notificationsEnabled: false,
|
||||
diagnosticsEnabled: false
|
||||
)
|
||||
)
|
||||
self.approvalCoordinator = ApprovalCoordinator(
|
||||
repository: coreRepository,
|
||||
preferences: preferencesRepository,
|
||||
notifications: dependencies.notificationService,
|
||||
visibility: visibility,
|
||||
messages: messages
|
||||
)
|
||||
self.transferNotificationCoordinator = TransferNotificationCoordinator(
|
||||
repository: coreRepository,
|
||||
notifications: dependencies.notificationService,
|
||||
visibility: visibility,
|
||||
messages: messages
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import SFSafeSymbols
|
||||
import SwiftUI
|
||||
|
||||
/// App root, ported from `App.kt`. Owns the object graph and feature models, wires
|
||||
@@ -62,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)
|
||||
@@ -111,7 +120,7 @@ struct RootView: View {
|
||||
#if os(macOS)
|
||||
NavigationSplitView {
|
||||
List(AppDestination.allCases, selection: sidebarBinding) { destination in
|
||||
Label(LocalizedStringKey(destination.labelKey), systemImage: destination.systemImage)
|
||||
Label(String(localized: destination.labelKey), systemSymbol: destination.systemSymbol)
|
||||
.tag(destination)
|
||||
}
|
||||
.navigationSplitViewColumnWidth(min: 180, ideal: 200, max: 260)
|
||||
@@ -123,7 +132,7 @@ struct RootView: View {
|
||||
ForEach(AppDestination.allCases) { destination in
|
||||
screen(for: destination, windowClass: windowClass)
|
||||
.tabItem {
|
||||
Label(LocalizedStringKey(destination.labelKey), systemImage: destination.systemImage)
|
||||
Label(String(localized: destination.labelKey), systemSymbol: destination.systemSymbol)
|
||||
}
|
||||
.tag(destination)
|
||||
}
|
||||
@@ -182,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
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Scene identifier for the single main window.
|
||||
private let mainWindowId = "main"
|
||||
|
||||
/// Native app entry point for iOS, iPadOS, and macOS.
|
||||
/// Opens `.vnd` invitations via `onOpenURL` and routes them to the receive flow.
|
||||
@main
|
||||
@@ -7,11 +10,21 @@ struct VniDropApp: App {
|
||||
@StateObject private var externalInvitations = ExternalInvitationController()
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
#if os(macOS)
|
||||
// A single-instance `Window` (not `WindowGroup`): the app must never open a
|
||||
// second window. `Window` also drops the ⌘N "New Window" command.
|
||||
Window(Text(verbatim: "VniDrop"), id: mainWindowId) {
|
||||
RootView(dependencies: makeAppDependencies(externalInvitations: externalInvitations))
|
||||
.ignoresSafeArea()
|
||||
.onOpenURL(perform: openInvitation)
|
||||
}
|
||||
#else
|
||||
WindowGroup(id: mainWindowId) {
|
||||
RootView(dependencies: makeAppDependencies(externalInvitations: externalInvitations))
|
||||
.ignoresSafeArea()
|
||||
.onOpenURL(perform: openInvitation)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
/// Reads a `.vnd` invitation document under a security scope, enforcing the
|
||||
|
||||
@@ -19,22 +19,116 @@ 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 {
|
||||
var username: String
|
||||
var receiveFolder: ReceiveFolder
|
||||
var themeMode: ThemeMode
|
||||
var notificationsEnabled: Bool
|
||||
var diagnosticsEnabled: Bool
|
||||
var diagnosticsInstallId: String
|
||||
var relayConfiguration: RelayConfiguration
|
||||
}
|
||||
|
||||
struct AppPreferencesDefaults {
|
||||
let username: String
|
||||
let receiveFolder: ReceiveFolder
|
||||
let themeMode: ThemeMode
|
||||
var notificationsEnabled: Bool = false
|
||||
var diagnosticsEnabled: Bool = false
|
||||
}
|
||||
|
||||
@@ -51,9 +145,9 @@ final class AppPreferencesRepository: ObservableObject {
|
||||
static let receiveFolderValue = "receive_folder_value"
|
||||
static let receiveFolderDisplayName = "receive_folder_display_name"
|
||||
static let themeMode = "theme_mode"
|
||||
static let notificationsEnabled = "notifications_enabled"
|
||||
static let diagnosticsEnabled = "diagnostics_enabled"
|
||||
static let diagnosticsInstallId = "diagnostics_install_id"
|
||||
static let relayConfiguration = "relay_configuration"
|
||||
}
|
||||
|
||||
init(defaults: UserDefaults = .standard, fallback: AppPreferencesDefaults) {
|
||||
@@ -66,19 +160,31 @@ final class AppPreferencesRepository: ObservableObject {
|
||||
let username = (defaults.string(forKey: Key.username)).flatMap { $0.isEmpty ? nil : $0 } ?? fallback.username
|
||||
let folder = resolveReceiveFolder(defaults, fallback: fallback.receiveFolder)
|
||||
let themeMode = defaults.string(forKey: Key.themeMode).flatMap(ThemeMode.init(rawValue:)) ?? fallback.themeMode
|
||||
let notifications = defaults.object(forKey: Key.notificationsEnabled) as? Bool ?? fallback.notificationsEnabled
|
||||
let diagnostics = defaults.object(forKey: Key.diagnosticsEnabled) as? Bool ?? fallback.diagnosticsEnabled
|
||||
let installId = defaults.string(forKey: Key.diagnosticsInstallId) ?? ""
|
||||
return AppPreferences(
|
||||
username: username,
|
||||
receiveFolder: folder,
|
||||
themeMode: themeMode,
|
||||
notificationsEnabled: notifications,
|
||||
diagnosticsEnabled: diagnostics,
|
||||
diagnosticsInstallId: installId
|
||||
diagnosticsInstallId: installId,
|
||||
relayConfiguration: resolveRelayConfiguration(defaults)
|
||||
)
|
||||
}
|
||||
|
||||
private static func resolveRelayConfiguration(_ defaults: UserDefaults) -> RelayConfiguration {
|
||||
guard defaults.object(forKey: Key.relayConfiguration) != nil else { return .automatic }
|
||||
guard
|
||||
let data = defaults.data(forKey: Key.relayConfiguration),
|
||||
let configuration = try? JSONDecoder().decode(RelayConfiguration.self, from: data)
|
||||
else {
|
||||
// A stored profile must never silently fall back to public relays. 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,13 +219,14 @@ final class AppPreferencesRepository: ObservableObject {
|
||||
reload()
|
||||
}
|
||||
|
||||
func setNotificationsEnabled(_ enabled: Bool) {
|
||||
defaults.set(enabled, forKey: Key.notificationsEnabled)
|
||||
func setDiagnosticsEnabled(_ enabled: Bool) {
|
||||
defaults.set(enabled, forKey: Key.diagnosticsEnabled)
|
||||
reload()
|
||||
}
|
||||
|
||||
func setDiagnosticsEnabled(_ enabled: Bool) {
|
||||
defaults.set(enabled, forKey: Key.diagnosticsEnabled)
|
||||
func setRelayConfiguration(_ configuration: RelayConfiguration) {
|
||||
guard let encoded = try? JSONEncoder().encode(configuration) else { return }
|
||||
defaults.set(encoded, forKey: Key.relayConfiguration)
|
||||
reload()
|
||||
}
|
||||
|
||||
|
||||
41
apple/VniDrop/Core/CoreDispatcher.swift
Normal file
41
apple/VniDrop/Core/CoreDispatcher.swift
Normal file
@@ -0,0 +1,41 @@
|
||||
import Foundation
|
||||
|
||||
/// Dispatch-queue labels for the core's serial and interrupt lanes.
|
||||
enum QueueLabel {
|
||||
static let core = "com.vnidrop.core"
|
||||
static let interrupt = "com.vnidrop.core.interrupt"
|
||||
}
|
||||
|
||||
/// Two-lane dispatcher for blocking core calls.
|
||||
///
|
||||
/// `run` serializes calls on one queue so the core is driven from a single lane.
|
||||
/// `runInterrupt` uses a *separate* concurrent lane, so an interrupt-style call
|
||||
/// (cancel) can reach the core while a blocking call (`receive`) still occupies
|
||||
/// the serial lane. The core is internally synchronized and explicitly supports
|
||||
/// cancel arriving from another thread mid-receive (see VnidropCore.block_on);
|
||||
/// a single shared queue would deadlock it.
|
||||
final class CoreDispatcher: Sendable {
|
||||
private let serialQueue: DispatchQueue
|
||||
private let interruptQueue: DispatchQueue
|
||||
|
||||
init(label: String = QueueLabel.core, interruptLabel: String = QueueLabel.interrupt) {
|
||||
serialQueue = DispatchQueue(label: label, qos: .userInitiated)
|
||||
interruptQueue = DispatchQueue(label: interruptLabel, qos: .userInitiated, attributes: .concurrent)
|
||||
}
|
||||
|
||||
/// Runs a blocking core call on the serial lane and hops the result back.
|
||||
func run<T: Sendable>(_ block: @escaping @Sendable () throws -> T) async -> Result<T, Error> {
|
||||
await withCheckedContinuation { continuation in
|
||||
serialQueue.async { continuation.resume(returning: Result { try block() }) }
|
||||
}
|
||||
}
|
||||
|
||||
/// Like `run`, but off the serial lane so it can interrupt a blocking call in
|
||||
/// flight there (e.g. cancel a `receive`). Only use for core calls that are
|
||||
/// safe to run concurrently with another core call.
|
||||
func runInterrupt<T: Sendable>(_ block: @escaping @Sendable () throws -> T) async -> Result<T, Error> {
|
||||
await withCheckedContinuation { continuation in
|
||||
interruptQueue.async { continuation.resume(returning: Result { try block() }) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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],
|
||||
|
||||
@@ -15,10 +15,56 @@ struct CoreEventModel: Equatable, Identifiable, Sendable {
|
||||
let timestamp: Int64
|
||||
let scope: String
|
||||
let transferId: UInt64?
|
||||
/// Raw wire values as emitted by the core. Interpret them through the typed
|
||||
/// `eventDirection` / `eventPhase` / `eventKind` accessors below — logic code
|
||||
/// should never compare these strings directly.
|
||||
let direction: String?
|
||||
let phase: String
|
||||
let kind: String
|
||||
let dataJson: String
|
||||
|
||||
var eventDirection: EventDirection? { direction.flatMap(EventDirection.init(rawValue:)) }
|
||||
var eventPhase: EventPhase? { EventPhase(rawValue: phase) }
|
||||
var eventKind: EventKind? { EventKind(rawValue: kind) }
|
||||
}
|
||||
|
||||
/// Direction of a core event, matching the wire strings the core emits.
|
||||
enum EventDirection: String, Equatable, Sendable {
|
||||
case send
|
||||
case receive
|
||||
}
|
||||
|
||||
/// Phase of a core progress event (the `phase` wire field).
|
||||
enum EventPhase: String, Equatable, Sendable {
|
||||
case importing = "import"
|
||||
case ticket
|
||||
case access
|
||||
case transfer
|
||||
case download
|
||||
case export
|
||||
case lifecycle
|
||||
case network
|
||||
case handshake
|
||||
case error
|
||||
}
|
||||
|
||||
/// Kind of a core progress event (the `kind` wire field).
|
||||
enum EventKind: String, Equatable, Sendable {
|
||||
case started
|
||||
case copyProgress = "copy-progress"
|
||||
case copyDone = "copy-done"
|
||||
case outboardProgress = "outboard-progress"
|
||||
case done
|
||||
case created
|
||||
case progress
|
||||
case completed
|
||||
case aborted
|
||||
case failed
|
||||
case connecting
|
||||
case connected
|
||||
case foundCollection = "found-collection"
|
||||
case cancelled
|
||||
case shareStopped = "share-stopped"
|
||||
}
|
||||
|
||||
enum ShareAccessPolicy: Equatable, Sendable {
|
||||
@@ -88,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?
|
||||
private let queue = DispatchQueue(label: "com.vnidrop.core", qos: .userInitiated)
|
||||
// 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,
|
||||
@@ -119,7 +224,9 @@ final class CoreRepository: ObservableObject, CoreGateway {
|
||||
// MARK: - Lifecycle actions
|
||||
|
||||
func cancel(transferId: UInt64) async -> Result<Void, Error> {
|
||||
await runCore {
|
||||
// Off the serial `queue`: a receive in flight is blocking it, and the
|
||||
// cancel signal must reach the core to unblock that receive.
|
||||
await runInterrupt {
|
||||
try self.requireCore().cancelTransfer(transferId: transferId)
|
||||
}.map { self.refreshSnapshot() }
|
||||
}
|
||||
@@ -253,17 +360,14 @@ final class CoreRepository: ObservableObject, CoreGateway {
|
||||
|
||||
/// Runs a blocking core call off the main actor and hops the result back.
|
||||
private nonisolated func runCore<T: Sendable>(_ block: @escaping @Sendable () throws -> T) async -> Result<T, Error> {
|
||||
await withCheckedContinuation { continuation in
|
||||
queue.async {
|
||||
let result: Result<T, Error>
|
||||
do {
|
||||
result = .success(try block())
|
||||
} catch {
|
||||
result = .failure(error)
|
||||
}
|
||||
continuation.resume(returning: result)
|
||||
}
|
||||
await dispatcher.run(block)
|
||||
}
|
||||
|
||||
/// Like `runCore`, but off the serial lane so it can interrupt a blocking
|
||||
/// call in flight there (e.g. cancel a `receive`). Only use for core calls
|
||||
/// that are safe to run concurrently with another core call.
|
||||
private nonisolated func runInterrupt<T: Sendable>(_ block: @escaping @Sendable () throws -> T) async -> Result<T, Error> {
|
||||
await dispatcher.runInterrupt(block)
|
||||
}
|
||||
|
||||
private nonisolated static func nextTransferId() -> UInt64 {
|
||||
@@ -298,14 +402,15 @@ private extension CoreEvent {
|
||||
}
|
||||
}
|
||||
|
||||
private let refreshPhases: Set<String> = ["lifecycle", "error", "ticket", "import", "download", "export", "handshake"]
|
||||
private let refreshKinds: Set<String> = [
|
||||
"started", "done", "created", "failed", "cancelled", "share-stopped", "found-collection", "connected",
|
||||
private let refreshPhases: Set<EventPhase> = [.lifecycle, .error, .ticket, .importing, .download, .export, .handshake]
|
||||
private let refreshKinds: Set<EventKind> = [
|
||||
.started, .done, .created, .failed, .cancelled, .shareStopped, .foundCollection, .connected,
|
||||
]
|
||||
|
||||
private extension CoreEventModel {
|
||||
var shouldRefreshTransfers: Bool {
|
||||
refreshPhases.contains(phase) && refreshKinds.contains(kind)
|
||||
guard let eventPhase, let eventKind else { return false }
|
||||
return refreshPhases.contains(eventPhase) && refreshKinds.contains(eventKind)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,12 +16,53 @@ struct LocalNotification {
|
||||
let body: String
|
||||
}
|
||||
|
||||
/// Presents notifications even while the app is active. Without a delegate the
|
||||
/// system drops the banner when the app is frontmost — very visible on macOS,
|
||||
/// where the app window is usually open when a transfer completes.
|
||||
private final class NotificationPresenter: NSObject, UNUserNotificationCenterDelegate {
|
||||
func userNotificationCenter(
|
||||
_ center: UNUserNotificationCenter,
|
||||
willPresent notification: UNNotification
|
||||
) 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`.
|
||||
@MainActor
|
||||
final class LocalNotificationService: ObservableObject {
|
||||
@Published private(set) var permission: NotificationPermission = .notDetermined
|
||||
|
||||
private let center = UNUserNotificationCenter.current()
|
||||
private let presenter = NotificationPresenter()
|
||||
|
||||
init() {
|
||||
center.delegate = presenter
|
||||
// Seed the permission immediately so gating (approval/lifecycle
|
||||
// notifications) never races a not-yet-refreshed `.notDetermined`.
|
||||
Task { _ = await refreshPermission() }
|
||||
}
|
||||
|
||||
func refreshPermission() async -> NotificationPermission {
|
||||
let settings = await center.notificationSettings()
|
||||
@@ -75,11 +116,6 @@ final class LocalNotificationService: ObservableObject {
|
||||
center.removeDeliveredNotifications(withIdentifiers: [id])
|
||||
}
|
||||
|
||||
func cancelAll() {
|
||||
center.removeAllPendingNotificationRequests()
|
||||
center.removeAllDeliveredNotifications()
|
||||
}
|
||||
|
||||
private static func map(_ status: UNAuthorizationStatus) -> NotificationPermission {
|
||||
switch status {
|
||||
case .authorized, .provisional, .ephemeral: return .granted
|
||||
|
||||
@@ -18,9 +18,9 @@ func windowClassFor(width: Double) -> WindowClass {
|
||||
/// resolved at the view layer.
|
||||
struct TransferProgress: Equatable {
|
||||
let transferId: UInt64?
|
||||
let phase: String
|
||||
let kind: String
|
||||
let labelKey: String
|
||||
let phase: EventPhase
|
||||
let kind: EventKind
|
||||
let labelKey: String.LocalizationValue
|
||||
let progress: Double?
|
||||
var detail: String? = nil
|
||||
/// Pre-resolved label that overrides `labelKey` when set (e.g. "Sending to 2",
|
||||
@@ -28,44 +28,31 @@ struct TransferProgress: Equatable {
|
||||
var label: String? = nil
|
||||
}
|
||||
|
||||
func statusLabelKey(_ status: TransferStatus) -> String {
|
||||
func statusLabelKey(_ status: TransferStatus) -> String.LocalizationValue {
|
||||
switch status {
|
||||
case .importing: return "status_preparing"
|
||||
case .sharing: return "status_available"
|
||||
case .receiving: return "status_receiving"
|
||||
case .done: return "status_completed"
|
||||
case .cancelled: return "status_cancelled"
|
||||
case .stopped: return "status_stopped"
|
||||
case .failed: return "status_failed"
|
||||
case .importing: return L10n.Status.preparing
|
||||
case .sharing: return L10n.Status.available
|
||||
case .receiving: return L10n.Status.receiving
|
||||
case .done: return L10n.Status.completed
|
||||
case .cancelled: return L10n.Status.cancelled
|
||||
case .stopped: return L10n.Status.stopped
|
||||
case .failed: return L10n.Status.failed
|
||||
}
|
||||
}
|
||||
|
||||
private let progressPhases: Set<String> = [
|
||||
"import", "ticket", "access", "transfer", "download", "export",
|
||||
"lifecycle", "network", "handshake", "error",
|
||||
]
|
||||
|
||||
private let progressKinds: Set<String> = [
|
||||
"started", "copy-progress", "copy-done", "outboard-progress", "done",
|
||||
"created", "progress", "completed", "aborted", "failed",
|
||||
"connecting", "connected", "found-collection",
|
||||
"cancelled", "share-stopped",
|
||||
]
|
||||
|
||||
/// Latest progress snapshot for a transfer. Events are newest-first.
|
||||
/// Latest progress snapshot for a transfer. Events are newest-first. Only events
|
||||
/// whose `phase` and `kind` map to known cases participate.
|
||||
func progressForTransfer(events: [CoreEventModel], transferId: UInt64) -> TransferProgress? {
|
||||
let relevant = events.filter { event in
|
||||
event.transferId == transferId
|
||||
&& progressPhases.contains(event.phase)
|
||||
&& progressKinds.contains(event.kind)
|
||||
event.transferId == transferId && event.eventPhase != nil && event.eventKind != nil
|
||||
}
|
||||
guard let latest = relevant.first else { return nil }
|
||||
guard let latest = relevant.first, let phase = latest.eventPhase, let kind = latest.eventKind else { return nil }
|
||||
let sizeHint = findKnownSize(events: events, transferId: transferId)
|
||||
return TransferProgress(
|
||||
transferId: transferId,
|
||||
phase: latest.phase,
|
||||
kind: latest.kind,
|
||||
labelKey: humanProgressLabel(latest),
|
||||
phase: phase,
|
||||
kind: kind,
|
||||
labelKey: humanProgressLabel(phase: phase, kind: kind),
|
||||
progress: parseProgress(latest.dataJson, sizeHint: sizeHint),
|
||||
detail: progressDetail(latest)
|
||||
)
|
||||
@@ -80,33 +67,34 @@ func progressForReceiver(
|
||||
) -> TransferProgress? {
|
||||
if remoteEndpointId.isEmpty { return nil }
|
||||
let connectionIds = connectionIdsForEndpoint(events: events, remoteEndpointId: remoteEndpointId)
|
||||
let receiverKinds: Set<EventKind> = [.started, .progress, .completed, .aborted]
|
||||
let transferEvents = events.filter { event in
|
||||
event.transferId == transferId
|
||||
&& event.direction == "send"
|
||||
&& event.phase == "transfer"
|
||||
&& ["started", "progress", "completed", "aborted"].contains(event.kind)
|
||||
&& event.eventDirection == .send
|
||||
&& event.eventPhase == .transfer
|
||||
&& (event.eventKind.map(receiverKinds.contains) ?? false)
|
||||
&& eventBelongsToReceiver(event, remoteEndpointId: remoteEndpointId, connectionIds: connectionIds)
|
||||
}
|
||||
if transferEvents.isEmpty { return nil }
|
||||
|
||||
let latest = transferEvents[0]
|
||||
if latest.kind == "aborted" {
|
||||
guard let latest = transferEvents.first, let latestKind = latest.eventKind else { return nil }
|
||||
if latestKind == .aborted {
|
||||
return TransferProgress(
|
||||
transferId: transferId, phase: "transfer", kind: "aborted",
|
||||
labelKey: "progress_interrupted", progress: nil, detail: nil
|
||||
transferId: transferId, phase: .transfer, kind: .aborted,
|
||||
labelKey: L10n.Progress.interrupted, progress: nil, detail: nil
|
||||
)
|
||||
}
|
||||
// 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
|
||||
)
|
||||
}
|
||||
let progress = aggregateReceiverProgress(events: transferEvents, totalSizeHint: totalSizeHint)
|
||||
if latest.kind == "completed" && (progress.map { $0 >= 0.999 } ?? true) {
|
||||
return TransferProgress(
|
||||
transferId: transferId, phase: "transfer", kind: "completed",
|
||||
labelKey: "progress_completed", progress: 1, detail: nil
|
||||
)
|
||||
}
|
||||
|
||||
return TransferProgress(
|
||||
transferId: transferId, phase: "transfer", kind: latest.kind,
|
||||
labelKey: "progress_sending", progress: progress, detail: progressDetail(latest)
|
||||
transferId: transferId, phase: .transfer, kind: latestKind,
|
||||
labelKey: L10n.Progress.sending, progress: progress, detail: progressDetail(latest)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -127,26 +115,26 @@ func formatBytes(_ size: UInt64) -> String {
|
||||
|
||||
// MARK: - Internals (ported literally from AppUiModels.kt)
|
||||
|
||||
private func humanProgressLabel(_ event: CoreEventModel) -> String {
|
||||
switch (event.phase, event.kind) {
|
||||
case ("import", "copy-progress"), ("import", "outboard-progress"), ("import", "started"):
|
||||
return "progress_preparing"
|
||||
case ("import", "done"): return "progress_ready"
|
||||
case ("ticket", "created"): return "progress_share_ready"
|
||||
case ("network", "connecting"): return "progress_connecting"
|
||||
case ("network", "connected"): return "progress_connected"
|
||||
case ("download", "found-collection"): return "progress_getting_ready"
|
||||
case ("download", "progress"): return "progress_downloading"
|
||||
case ("export", "progress"): return "progress_saving"
|
||||
case ("transfer", "progress"): return "progress_sending"
|
||||
case ("transfer", "started"): return "progress_connected"
|
||||
case ("transfer", "completed"): return "progress_completed"
|
||||
case ("lifecycle", "done"): return "progress_completed"
|
||||
case ("lifecycle", "cancelled"): return "progress_cancelled"
|
||||
private func humanProgressLabel(phase: EventPhase, kind: EventKind) -> String.LocalizationValue {
|
||||
switch (phase, kind) {
|
||||
case (.importing, .copyProgress), (.importing, .outboardProgress), (.importing, .started):
|
||||
return L10n.Progress.preparing
|
||||
case (.importing, .done): return L10n.Progress.ready
|
||||
case (.ticket, .created): return L10n.Progress.shareReady
|
||||
case (.network, .connecting): return L10n.Progress.connecting
|
||||
case (.network, .connected): return L10n.Progress.connected
|
||||
case (.download, .foundCollection): return L10n.Progress.gettingReady
|
||||
case (.download, .progress): return L10n.Progress.downloading
|
||||
case (.export, .progress): return L10n.Progress.saving
|
||||
case (.transfer, .progress): return L10n.Progress.sending
|
||||
case (.transfer, .started): return L10n.Progress.connected
|
||||
case (.transfer, .completed): return L10n.Progress.completed
|
||||
case (.lifecycle, .done): return L10n.Progress.completed
|
||||
case (.lifecycle, .cancelled): return L10n.Progress.cancelled
|
||||
default:
|
||||
if event.phase == "handshake" { return "progress_requesting_access" }
|
||||
if event.kind == "failed" { return "progress_failed" }
|
||||
return "progress_working"
|
||||
if phase == .handshake { return L10n.Progress.requestingAccess }
|
||||
if kind == .failed { return L10n.Progress.failed }
|
||||
return L10n.Progress.working
|
||||
}
|
||||
}
|
||||
|
||||
@@ -217,14 +205,14 @@ private func aggregateReceiverProgress(events: [CoreEventModel], totalSizeHint:
|
||||
order.append(requestKey)
|
||||
}
|
||||
if let size, size > 0 { state.size = size }
|
||||
switch event.kind {
|
||||
case "progress", "started":
|
||||
switch event.eventKind {
|
||||
case .progress, .started:
|
||||
if let endOffset { state.offset = max(state.offset, endOffset) }
|
||||
state.aborted = false
|
||||
case "completed":
|
||||
case .completed:
|
||||
state.completed = true
|
||||
if let s = state.size { state.offset = s }
|
||||
case "aborted":
|
||||
case .aborted:
|
||||
state.aborted = true
|
||||
default:
|
||||
break
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
|
||||
|
||||
@@ -27,7 +27,6 @@ final class ApprovalCoordinator: ObservableObject {
|
||||
@Published private(set) var state = ApprovalState()
|
||||
|
||||
private let repository: CoreGateway
|
||||
private let preferences: AppPreferencesRepository
|
||||
private let notifications: LocalNotificationService
|
||||
private let visibility: AppVisibility
|
||||
private let messages: UiMessageController
|
||||
@@ -37,13 +36,11 @@ final class ApprovalCoordinator: ObservableObject {
|
||||
|
||||
init(
|
||||
repository: CoreGateway,
|
||||
preferences: AppPreferencesRepository,
|
||||
notifications: LocalNotificationService,
|
||||
visibility: AppVisibility,
|
||||
messages: UiMessageController
|
||||
) {
|
||||
self.repository = repository
|
||||
self.preferences = preferences
|
||||
self.notifications = notifications
|
||||
self.visibility = visibility
|
||||
self.messages = messages
|
||||
@@ -68,17 +65,15 @@ final class ApprovalCoordinator: ObservableObject {
|
||||
.store(in: &cancellables)
|
||||
|
||||
// Recompute notifications when any input changes.
|
||||
Publishers.CombineLatest4(
|
||||
preferences.$preferences,
|
||||
Publishers.CombineLatest3(
|
||||
visibility.$isForeground,
|
||||
$state,
|
||||
notifications.$permission
|
||||
)
|
||||
.sink { [weak self] preferences, foreground, approvalState, permission in
|
||||
.sink { [weak self] foreground, approvalState, permission in
|
||||
guard let self else { return }
|
||||
Task {
|
||||
await self.synchronizeNotifications(
|
||||
enabled: preferences.notificationsEnabled,
|
||||
foreground: foreground,
|
||||
pending: approvalState.pending,
|
||||
permission: permission
|
||||
@@ -137,30 +132,41 @@ final class ApprovalCoordinator: ObservableObject {
|
||||
}
|
||||
|
||||
private func synchronizeNotifications(
|
||||
enabled: Bool,
|
||||
foreground: Bool,
|
||||
pending: [PendingApproval],
|
||||
permission: NotificationPermission
|
||||
) async {
|
||||
if foreground || !enabled || permission != .granted {
|
||||
notifications.cancelAll()
|
||||
// iOS suppresses notifications while the user is in the app (the modal shows
|
||||
// instead); macOS presents them even when active (the app window is usually
|
||||
// open), relying on the presenter delegate.
|
||||
#if os(iOS)
|
||||
let suppressed = foreground || permission != .granted
|
||||
#else
|
||||
let suppressed = permission != .granted
|
||||
#endif
|
||||
if suppressed {
|
||||
// Cancel only our own approval notifications — other coordinators
|
||||
// (e.g. transfer-lifecycle) manage their own and must not be wiped.
|
||||
for id in publishedNotificationIds { notifications.cancel(id: Self.notificationId(id)) }
|
||||
return
|
||||
}
|
||||
for request in pending where !publishedNotificationIds.contains(request.id) {
|
||||
// Reserve the id *before* awaiting: the CombineLatest can fire several
|
||||
// times near-simultaneously, and without this each pass re-adds the same
|
||||
// notification identifier. macOS coalesces a repeated add of an in-flight
|
||||
// id into a silent update and shows no banner.
|
||||
publishedNotificationIds.insert(request.id)
|
||||
let receiver = request.receiverName
|
||||
?? request.receiverDeviceName
|
||||
?? String(localized: "approval_nearby_device")
|
||||
let title = String(localized: "approval_connection_request")
|
||||
let body = String(
|
||||
format: String(localized: "approval_request_body"),
|
||||
receiver, request.transferName
|
||||
)
|
||||
?? String(localized: L10n.Approval.nearbyDevice)
|
||||
let title = String(localized: L10n.Approval.connectionRequest)
|
||||
let body = L10n.Approval.requestBody(receiver: receiver, transferName: request.transferName)
|
||||
let result = await notifications.publish(
|
||||
LocalNotification(id: Self.notificationId(request.id), title: title, body: body)
|
||||
)
|
||||
switch result {
|
||||
case .success: publishedNotificationIds.insert(request.id)
|
||||
case .failure(let error): messages.error(error)
|
||||
if case .failure(let error) = result {
|
||||
publishedNotificationIds.remove(request.id)
|
||||
messages.error(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import SwiftUI
|
||||
import SFSafeSymbols
|
||||
|
||||
/// Non-dismissable receiver-approval modal, presented as a native sheet that can't
|
||||
/// be swiped away. The endpoint id is the trusted identity; display names are
|
||||
@@ -38,32 +39,32 @@ private struct ApprovalSheet: View {
|
||||
|
||||
var body: some View {
|
||||
let busy = state.respondingIds.contains(request.id)
|
||||
let receiver = request.receiverName ?? request.receiverDeviceName ?? String(localized: "approval_nearby_device")
|
||||
let receiver = request.receiverName ?? request.receiverDeviceName ?? String(localized: L10n.Approval.nearbyDevice)
|
||||
VStack(spacing: 16) {
|
||||
Image(systemName: "checkmark.shield.fill")
|
||||
Image(systemSymbol: .checkmarkShieldFill)
|
||||
.font(.system(size: 44))
|
||||
.foregroundStyle(.tint)
|
||||
.padding(.top, 12)
|
||||
Text(LocalizedStringKey("approval_connection_request"))
|
||||
Text(String(localized: L10n.Approval.connectionRequest))
|
||||
.font(.title2).fontWeight(.semibold)
|
||||
Text(String(format: String(localized: "approval_request_body"), receiver, request.transferName))
|
||||
Text(L10n.Approval.requestBody(receiver: receiver, transferName: request.transferName))
|
||||
.multilineTextAlignment(.center)
|
||||
Text(String(format: String(localized: "approval_endpoint_id"), request.remoteEndpointId))
|
||||
Text(L10n.Approval.endpointId(deviceId: request.remoteEndpointId))
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
if state.pending.count > 1 {
|
||||
Text(String(format: String(localized: "approval_pending_count"), state.pending.count))
|
||||
Text(L10n.Approval.pendingCount(count: state.pending.count))
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
if busy { ProgressView() }
|
||||
VStack(spacing: 10) {
|
||||
Button(action: { onAccept(request.id) }) {
|
||||
Text(LocalizedStringKey("button_approve")).frame(maxWidth: .infinity)
|
||||
Text(String(localized: L10n.Button.approve)).frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.borderedProminent).controlSize(.large).disabled(busy)
|
||||
Button(role: .destructive, action: { onRefuse(request.id) }) {
|
||||
Text(LocalizedStringKey("button_refuse")).frame(maxWidth: .infinity)
|
||||
Text(String(localized: L10n.Button.refuse)).frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.bordered).controlSize(.large).disabled(busy)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
import Combine
|
||||
import Foundation
|
||||
|
||||
/// A transfer-lifecycle moment worth a local notification.
|
||||
enum TransferNotificationKind: Equatable {
|
||||
case sendFailed // A share you own failed.
|
||||
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`
|
||||
/// is the raw name (may be nil); the coordinator localizes and applies fallbacks.
|
||||
struct PlannedNotification: Equatable {
|
||||
let id: String
|
||||
let kind: TransferNotificationKind
|
||||
let transferName: String?
|
||||
let receiver: String?
|
||||
}
|
||||
|
||||
/// Pure: transfer-status notifications for this snapshot, excluding already-published
|
||||
/// ids. A terminal transfer yields at most one notification, keyed by (kind, id).
|
||||
func plannedTransferNotifications(_ transfers: [Transfer], published: Set<String>) -> [PlannedNotification] {
|
||||
transfers.compactMap { transfer in
|
||||
let kind: TransferNotificationKind
|
||||
switch (transfer.direction, transfer.status) {
|
||||
case (.send, .failed): kind = .sendFailed
|
||||
case (.receive, .done): kind = .receiveCompleted
|
||||
case (.receive, .failed): kind = .receiveFailed
|
||||
default: return nil
|
||||
}
|
||||
let id = transferNotificationId(kind, transferId: transfer.transferId)
|
||||
guard !published.contains(id) else { return nil }
|
||||
return PlannedNotification(id: id, kind: kind, transferName: transfer.transferName, receiver: nil)
|
||||
}
|
||||
}
|
||||
|
||||
/// Pure: one notification per receiver that has finished downloading a shared
|
||||
/// transfer, excluding already-published ids.
|
||||
func plannedReceiverNotifications(_ requests: [ReceiverRequestModel], published: Set<String>) -> [PlannedNotification] {
|
||||
requests.compactMap { request in
|
||||
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: kind,
|
||||
transferName: request.transferName,
|
||||
receiver: request.receiverName ?? request.receiverDeviceName
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func transferNotificationId(_ kind: TransferNotificationKind, transferId: UInt64) -> String {
|
||||
switch kind {
|
||||
case .sendFailed: return "send-failed-\(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)"
|
||||
}
|
||||
}
|
||||
|
||||
/// Fires local notifications for transfer-lifecycle moments (a receive finishing
|
||||
/// or failing, a share failing, a receiver completing), so a user who left the
|
||||
/// app can see the outcome. Approval prompts are handled by `ApprovalCoordinator`.
|
||||
///
|
||||
/// Gated on the OS notification permission (and, on iOS, on being backgrounded).
|
||||
/// Each moment is terminal, so it is marked seen the first time it is observed and
|
||||
/// never re-published. The first state snapshot — which includes existing history
|
||||
/// such as past receives — only primes those ids as seen, so only new transitions
|
||||
/// notify.
|
||||
@MainActor
|
||||
final class TransferNotificationCoordinator: ObservableObject {
|
||||
private let repository: CoreGateway
|
||||
private let notifications: LocalNotificationService
|
||||
private let visibility: AppVisibility
|
||||
private let messages: UiMessageController
|
||||
|
||||
private var published = Set<String>()
|
||||
private var primedTransfers = false
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
||||
init(
|
||||
repository: CoreGateway,
|
||||
notifications: LocalNotificationService,
|
||||
visibility: AppVisibility,
|
||||
messages: UiMessageController
|
||||
) {
|
||||
self.repository = repository
|
||||
self.notifications = notifications
|
||||
self.visibility = visibility
|
||||
self.messages = messages
|
||||
|
||||
repository.statePublisher
|
||||
.sink { [weak self] core in
|
||||
guard let self, core.isInitialized else { return }
|
||||
Task { await self.syncTransfers(core.transfers) }
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
repository.signals
|
||||
.sink { [weak self] signal in
|
||||
guard let self else { return }
|
||||
switch signal {
|
||||
case .receiverHistoryChanged(let transferId), .transfersChanged(let transferId):
|
||||
Task { await self.syncReceivers(transferId: transferId) }
|
||||
case .approvalChanged:
|
||||
break
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
/// iOS suppresses notifications while the user is in the app (the convention);
|
||||
/// macOS presents them even when active (also the convention — the app window
|
||||
/// is usually open), relying on the presenter delegate to show the banner.
|
||||
private var canPublish: Bool {
|
||||
guard notifications.permission == .granted else { return false }
|
||||
#if os(iOS)
|
||||
return !visibility.isForeground
|
||||
#else
|
||||
return true
|
||||
#endif
|
||||
}
|
||||
|
||||
private func syncTransfers(_ transfers: [Transfer]) async {
|
||||
let planned = plannedTransferNotifications(transfers, published: published)
|
||||
guard primedTransfers else {
|
||||
// The first snapshot includes existing history (e.g. past receives).
|
||||
// Mark those terminal transfers seen without notifying, so only new
|
||||
// transitions notify.
|
||||
primedTransfers = true
|
||||
for plan in planned { published.insert(plan.id) }
|
||||
return
|
||||
}
|
||||
for plan in planned { await deliver(plan) }
|
||||
}
|
||||
|
||||
private func syncReceivers(transferId: UInt64) async {
|
||||
let result = await repository.receiverRequests(transferId: transferId)
|
||||
switch result {
|
||||
case .success(let requests):
|
||||
for plan in plannedReceiverNotifications(requests, published: published) {
|
||||
await deliver(plan)
|
||||
}
|
||||
case .failure(let error):
|
||||
messages.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
/// Mark seen unconditionally (a terminal moment notifies at most once), then
|
||||
/// publish only when the gate allows.
|
||||
private func deliver(_ plan: PlannedNotification) async {
|
||||
published.insert(plan.id)
|
||||
guard canPublish else { return }
|
||||
let name = plan.transferName ?? String(localized: L10n.Receive.unknownTransfer)
|
||||
let notification: LocalNotification
|
||||
switch plan.kind {
|
||||
case .sendFailed:
|
||||
notification = LocalNotification(
|
||||
id: plan.id,
|
||||
title: String(localized: L10n.Notifications.sendFailedTitle),
|
||||
body: L10n.Notifications.sendFailedBody(transferName: name))
|
||||
case .receiveCompleted:
|
||||
notification = LocalNotification(
|
||||
id: plan.id,
|
||||
title: String(localized: L10n.Notifications.receiveCompletedTitle),
|
||||
body: L10n.Notifications.receiveCompletedBody(transferName: name))
|
||||
case .receiveFailed:
|
||||
notification = LocalNotification(
|
||||
id: plan.id,
|
||||
title: String(localized: L10n.Notifications.receiveFailedTitle),
|
||||
body: L10n.Notifications.receiveFailedBody(transferName: name))
|
||||
case .receiverCompleted:
|
||||
let receiver = plan.receiver ?? String(localized: L10n.Approval.nearbyDevice)
|
||||
notification = LocalNotification(
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
import SFSafeSymbols
|
||||
import SwiftUI
|
||||
|
||||
enum ReceiveMethodAvailability { case available, unavailable, hidden }
|
||||
|
||||
/// Invitation acquisition actions shared by the native Apple feature models.
|
||||
/// Invitation acquisition actions, ported from `ReceiveInvitationActions` (iosMain).
|
||||
@MainActor
|
||||
protocol ReceiveInvitationActions: AnyObject {
|
||||
var fileAvailability: ReceiveMethodAvailability { get }
|
||||
@@ -23,25 +24,25 @@ struct ReceiveMethodPanel: View {
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text(LocalizedStringKey("receive_choose_method_title")).font(VniType.titleLarge)
|
||||
Text(LocalizedStringKey("receive_choose_method_body")).foregroundStyle(colors.foregroundLighter)
|
||||
Text(String(localized: L10n.Receive.chooseMethodTitle)).font(VniType.titleLarge)
|
||||
Text(String(localized: L10n.Receive.chooseMethodBody)).foregroundStyle(colors.foregroundLighter)
|
||||
|
||||
MethodRow(
|
||||
icon: "doc", titleKey: "receive_method_file", descKey: "receive_method_file_description",
|
||||
icon: .doc, titleKey: L10n.Receive.methodFile, descKey: L10n.Receive.methodFileDescription,
|
||||
availability: actions.fileAvailability
|
||||
) { actions.pickInvitation { model.onInvitationResult(.invitationFile, $0) } }
|
||||
|
||||
if actions.qrAvailability != .hidden {
|
||||
MethodRow(
|
||||
icon: "qrcode.viewfinder", titleKey: "receive_method_scan", descKey: "receive_method_scan_description",
|
||||
icon: .qrcodeViewfinder, titleKey: L10n.Receive.methodScan, descKey: L10n.Receive.methodScanDescription,
|
||||
availability: actions.qrAvailability
|
||||
) { actions.scanQrCode { model.onInvitationResult(.qrCode, $0) } }
|
||||
}
|
||||
if actions.nfcAvailability != .hidden {
|
||||
MethodRow(
|
||||
icon: "wave.3.right",
|
||||
titleOverride: model.state.isWaitingForNfc ? String(localized: "receive_nfc_waiting") : nil,
|
||||
titleKey: "receive_method_nfc", descKey: "receive_method_nfc_description",
|
||||
icon: .wave3Right,
|
||||
titleOverride: model.state.isWaitingForNfc ? String(localized: L10n.Receive.nfcWaiting) : nil,
|
||||
titleKey: L10n.Receive.methodNfc, descKey: L10n.Receive.methodNfcDescription,
|
||||
availability: model.state.isWaitingForNfc ? .unavailable : actions.nfcAvailability
|
||||
) {
|
||||
model.setWaitingForNfc(true)
|
||||
@@ -56,10 +57,10 @@ struct ReceiveMethodPanel: View {
|
||||
|
||||
private struct MethodRow: View {
|
||||
@Environment(\.vniColors) private var colors
|
||||
let icon: String
|
||||
let icon: SFSymbol
|
||||
var titleOverride: String? = nil
|
||||
let titleKey: String
|
||||
let descKey: String
|
||||
let titleKey: String.LocalizationValue
|
||||
let descKey: String.LocalizationValue
|
||||
let availability: ReceiveMethodAvailability
|
||||
let onTap: () -> Void
|
||||
|
||||
@@ -67,20 +68,20 @@ private struct MethodRow: View {
|
||||
let enabled = availability == .available
|
||||
Button(action: onTap) {
|
||||
HStack(spacing: 14) {
|
||||
Image(systemName: icon).font(.system(size: 22))
|
||||
Image(systemSymbol: icon).font(.system(size: 22))
|
||||
.foregroundStyle(enabled ? colors.brandLink : colors.foregroundLighter)
|
||||
.frame(width: 24)
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
if let titleOverride {
|
||||
Text(titleOverride).font(VniType.bodyLarge)
|
||||
} else {
|
||||
Text(LocalizedStringKey(titleKey)).font(VniType.bodyLarge)
|
||||
Text(String(localized: titleKey)).font(VniType.bodyLarge)
|
||||
}
|
||||
Text(LocalizedStringKey(descKey)).font(VniType.bodySmall).foregroundStyle(colors.foregroundLighter)
|
||||
Text(String(localized: descKey)).font(VniType.bodySmall).foregroundStyle(colors.foregroundLighter)
|
||||
}
|
||||
Spacer()
|
||||
if availability == .unavailable {
|
||||
Text(LocalizedStringKey("value_unavailable")).font(VniType.labelSmall).foregroundStyle(colors.foregroundLighter)
|
||||
Text(String(localized: L10n.Value.unavailable)).font(VniType.labelSmall).foregroundStyle(colors.foregroundLighter)
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
@@ -102,7 +103,7 @@ struct InvitationReviewPanel: View {
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
Text(LocalizedStringKey("receive_review_title")).font(VniType.titleLarge)
|
||||
Text(String(localized: L10n.Receive.reviewTitle)).font(VniType.titleLarge)
|
||||
if state.isInspecting {
|
||||
ProgressView().frame(maxWidth: .infinity).padding(40)
|
||||
}
|
||||
@@ -110,28 +111,31 @@ struct InvitationReviewPanel: View {
|
||||
let metadata = inspection.metadata
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(metadata.transferName).font(VniType.bodyLarge).lineLimit(2)
|
||||
Text("\(metadata.fileCount) \(String(localized: "metadata_files").lowercased()) · \(formatBytes(metadata.totalSize))")
|
||||
Text(L10n.Format.separatedTriple(
|
||||
first: "\(metadata.fileCount)",
|
||||
second: String(localized: L10n.Metadata.files).lowercased(),
|
||||
third: formatBytes(metadata.totalSize)))
|
||||
.foregroundStyle(colors.foregroundLighter)
|
||||
}
|
||||
.padding(16)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(colors.backgroundSurface200, in: RoundedRectangle(cornerRadius: 14))
|
||||
|
||||
Field(label: String(localized: "field_receiver_name"),
|
||||
Field(label: String(localized: L10n.Field.receiverName),
|
||||
value: Binding(get: { state.receiverName }, set: { model.setReceiverName($0) }))
|
||||
Text(state.receiveFolder?.displayName ?? String(localized: "value_unavailable"))
|
||||
Text(state.receiveFolder?.displayName ?? String(localized: L10n.Value.unavailable))
|
||||
.font(VniType.bodySmall)
|
||||
.foregroundStyle(state.folderAccessStatus == .writable ? colors.foregroundLight : colors.destructiveDefault)
|
||||
|
||||
if state.isReceiving {
|
||||
let progressId = state.activeReceiveTransferId
|
||||
?? model.coreState.events.first { $0.direction == "receive" && $0.transferId != nil }?.transferId
|
||||
?? model.coreState.events.first { $0.eventDirection == .receive && $0.transferId != nil }?.transferId
|
||||
let progress = progressId.flatMap { progressForTransfer(events: model.coreState.events, transferId: $0) }
|
||||
ProgressRow(labelKey: progress?.labelKey ?? "progress_receiving", progress: progress?.progress, detail: progress?.detail)
|
||||
SecondaryButton(title: String(localized: "button_cancel_receive"), action: model.cancelActiveReceive)
|
||||
ProgressRow(labelKey: progress?.labelKey ?? L10n.Progress.receiving, progress: progress?.progress, detail: progress?.detail)
|
||||
SecondaryButton(title: String(localized: L10n.Button.cancelReceive), action: model.cancelActiveReceive)
|
||||
} else {
|
||||
PrimaryButton(
|
||||
title: String(localized: "button_receive"), action: model.receive,
|
||||
title: String(localized: L10n.Button.receive), action: model.receive,
|
||||
enabled: state.canReceive(coreInitialized: model.coreState.isInitialized)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -121,6 +121,10 @@ final class ReceiveModel: ObservableObject {
|
||||
func confirmHistoryDelete() {
|
||||
guard let target = state.historyDeleteTarget, !state.isDeletingHistory else { return }
|
||||
state.isDeletingHistory = true
|
||||
// Close synchronously: the alert's dismiss binding runs async and no-ops while
|
||||
// `isDeletingHistory`, which would otherwise leave the target set and macOS
|
||||
// re-present it.
|
||||
state.historyDeleteTarget = nil
|
||||
Task {
|
||||
let result: Result<Void, Error>
|
||||
switch target {
|
||||
@@ -133,7 +137,7 @@ final class ReceiveModel: ObservableObject {
|
||||
case .success:
|
||||
state.historyDeleteTarget = nil
|
||||
state.isDeletingHistory = false
|
||||
let key = target == .all ? "receive_history_cleared" : "transfer_deleted"
|
||||
let key = target == .all ? L10n.Receive.historyCleared : L10n.Transfer.deleted
|
||||
messages.tryShow(UiMessage(text: .resource(key), tone: .success))
|
||||
case .failure(let error):
|
||||
state.isDeletingHistory = false
|
||||
@@ -173,9 +177,9 @@ final class ReceiveModel: ObservableObject {
|
||||
resetAcquisition()
|
||||
let canReveal = fileSystemService.canRevealReceiveFolder(folder)
|
||||
messages.tryShow(UiMessage(
|
||||
text: .resource("receive_completed"),
|
||||
text: .resource(L10n.Receive.completed),
|
||||
tone: .success,
|
||||
actionLabel: canReveal ? .resource("button_show_in_files") : nil,
|
||||
actionLabel: canReveal ? .resource(L10n.Button.showInFiles) : nil,
|
||||
onAction: canReveal ? { self.revealReceiveFolder(folder) } : nil
|
||||
))
|
||||
case .failure(let error):
|
||||
@@ -192,8 +196,8 @@ final class ReceiveModel: ObservableObject {
|
||||
messages.tryShow(UiMessage(
|
||||
text: uiText,
|
||||
tone: .error,
|
||||
actionLabel: error.canRetryWithoutChangingInput ? .resource("button_retry") : nil,
|
||||
onAction: error.canRetryWithoutChangingInput ? { self.receive() } : nil
|
||||
actionLabel: .resource(L10n.Button.retry),
|
||||
onAction: { self.receive() }
|
||||
))
|
||||
}
|
||||
}
|
||||
@@ -202,7 +206,7 @@ final class ReceiveModel: ObservableObject {
|
||||
func cancelActiveReceive() {
|
||||
let transferId = state.activeReceiveTransferId
|
||||
?? coreState.transfers.first { $0.direction == .receive && $0.status == .receiving }?.transferId
|
||||
?? coreState.events.first { $0.direction == "receive" && $0.transferId != nil }?.transferId
|
||||
?? coreState.events.first { $0.eventDirection == .receive && $0.transferId != nil }?.transferId
|
||||
guard let transferId else { return }
|
||||
Task {
|
||||
let result = await repository.cancel(transferId: transferId)
|
||||
@@ -222,14 +226,14 @@ final class ReceiveModel: ObservableObject {
|
||||
Task {
|
||||
let result = await fileSystemService.revealReceiveFolder(folder)
|
||||
if case .failure = result {
|
||||
messages.show(UiMessage(text: .resource("receive_open_files_failed"), tone: .error))
|
||||
messages.show(UiMessage(text: .resource(L10n.Receive.openFilesFailed), tone: .error))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func inspectInvitation(_ method: ReceiveMethod, _ raw: String) {
|
||||
let ticket = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if ticket.isEmpty { return messages.error(.resource("error_invitation_empty")) }
|
||||
if ticket.isEmpty { return messages.error(.resource(L10n.Error.invitationEmpty)) }
|
||||
state.isAcquisitionOpen = true
|
||||
state.ticket = ticket
|
||||
state.method = method
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import SwiftUI
|
||||
import SFSafeSymbols
|
||||
|
||||
/// Receive screen, rebuilt on native SwiftUI. A grouped `List` of received
|
||||
/// transfers with swipe-to-delete, and the acquisition flow as a native sheet.
|
||||
@@ -22,17 +23,17 @@ struct ReceiveScreen: View {
|
||||
history
|
||||
}
|
||||
}
|
||||
.navigationTitle(Text(LocalizedStringKey("receive_title")))
|
||||
.navigationTitle(Text(String(localized: L10n.Receive.title)))
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .primaryAction) {
|
||||
Button(action: model.openAcquisition) {
|
||||
Label(String(localized: "button_receive_files"), systemImage: "plus")
|
||||
Label(String(localized: L10n.Button.receiveFiles), systemSymbol: .plus)
|
||||
}
|
||||
}
|
||||
if !deletable.isEmpty {
|
||||
ToolbarItem(placement: .primaryAction) {
|
||||
Button(role: .destructive, action: model.requestClearHistory) {
|
||||
Label(String(localized: "receive_clear_history"), systemImage: "trash")
|
||||
Label(String(localized: L10n.Receive.clearHistory), systemSymbol: .trash)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -50,11 +51,11 @@ struct ReceiveScreen: View {
|
||||
}
|
||||
}
|
||||
.alert(
|
||||
Text(LocalizedStringKey(clearAllPending ? "receive_clear_history_title" : "receive_delete_history_title")),
|
||||
Text(String(localized: clearAllPending ? L10n.Receive.clearHistoryTitle : L10n.Receive.deleteHistoryTitle)),
|
||||
isPresented: Binding(get: { model.state.historyDeleteTarget != nil }, set: { if !$0 { Task { @MainActor in model.dismissHistoryDelete() } } })
|
||||
) {
|
||||
Button(String(localized: "button_cancel"), role: .cancel, action: model.dismissHistoryDelete)
|
||||
Button(String(localized: clearAllPending ? "receive_clear_history" : "button_delete_transfer"),
|
||||
Button(String(localized: L10n.Button.cancel), role: .cancel, action: model.dismissHistoryDelete)
|
||||
Button(String(localized: clearAllPending ? L10n.Receive.clearHistory : L10n.Button.deleteTransfer),
|
||||
role: .destructive, action: model.confirmHistoryDelete)
|
||||
} message: {
|
||||
historyDeleteMessage
|
||||
@@ -74,27 +75,36 @@ struct ReceiveScreen: View {
|
||||
Button(role: .destructive) {
|
||||
model.requestDeleteHistoryItem(transfer.transferId)
|
||||
} label: {
|
||||
Label(String(localized: "button_delete_transfer"), systemImage: "trash")
|
||||
Label(String(localized: L10n.Button.deleteTransfer), systemSymbol: .trash)
|
||||
}
|
||||
}
|
||||
}
|
||||
.contextMenu {
|
||||
if transfer.status.isTerminalReceiveHistory {
|
||||
Button(role: .destructive) {
|
||||
model.requestDeleteHistoryItem(transfer.transferId)
|
||||
} label: {
|
||||
Label(String(localized: L10n.Button.deleteTransfer), systemSymbol: .trash)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
Text(LocalizedStringKey("receive_history_title"))
|
||||
Text(String(localized: L10n.Receive.historyTitle))
|
||||
} footer: {
|
||||
Text(LocalizedStringKey("receive_new_subtitle"))
|
||||
Text(String(localized: L10n.Receive.newSubtitle))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var emptyState: some View {
|
||||
ContentUnavailableView {
|
||||
Label(String(localized: "receive_empty_title"), systemImage: "tray.and.arrow.down")
|
||||
Label(String(localized: L10n.Receive.emptyTitle), systemSymbol: .trayAndArrowDown)
|
||||
} description: {
|
||||
Text(LocalizedStringKey("receive_empty_body"))
|
||||
Text(String(localized: L10n.Receive.emptyBody))
|
||||
} actions: {
|
||||
Button(action: model.openAcquisition) {
|
||||
Label(String(localized: "button_receive_files"), systemImage: "plus")
|
||||
Label(String(localized: L10n.Button.receiveFiles), systemSymbol: .plus)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.controlSize(.large)
|
||||
@@ -107,10 +117,10 @@ struct ReceiveScreen: View {
|
||||
private var historyDeleteMessage: some View {
|
||||
if let target = model.state.historyDeleteTarget {
|
||||
if target == .all {
|
||||
Text(LocalizedStringKey("receive_clear_history_description"))
|
||||
Text(String(localized: L10n.Receive.clearHistoryDescription))
|
||||
} else {
|
||||
Text(String(format: String(localized: "receive_delete_history_description"),
|
||||
transferName(for: target) ?? String(localized: "receive_unknown_transfer")))
|
||||
Text(L10n.Receive.deleteHistoryDescription(
|
||||
transferName: transferName(for: target) ?? String(localized: L10n.Receive.unknownTransfer)))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -129,14 +139,14 @@ private struct ReceiveTransferRow: View {
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: "doc")
|
||||
Image(systemSymbol: .doc)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: 40, height: 40)
|
||||
.background(.quaternary, in: RoundedRectangle(cornerRadius: 9))
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(transfer.transferName ?? String(localized: "receive_unknown_transfer"))
|
||||
Text(transfer.transferName ?? String(localized: L10n.Receive.unknownTransfer))
|
||||
.font(.body).lineLimit(1)
|
||||
Text("\(formatBytes(transfer.totalSize)) · \(statusLabel(transfer.status))")
|
||||
Text(L10n.Format.separatedPair(first: formatBytes(transfer.totalSize), second: statusLabel(transfer.status)))
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
if transfer.status == .receiving, let progress {
|
||||
ProgressRow(labelKey: progress.labelKey, progress: progress.progress, detail: progress.detail)
|
||||
|
||||
@@ -207,6 +207,9 @@ final class SendModel: ObservableObject {
|
||||
func confirmDeleteTransfer() {
|
||||
guard let transferId = state.selectedTransferId, !state.isDeleting else { return }
|
||||
state.isDeleting = true
|
||||
// Close synchronously: the alert's dismiss binding runs async and no-ops while
|
||||
// `isDeleting`, which would otherwise leave the flag true and macOS re-present it.
|
||||
state.isDeleteConfirmationOpen = false
|
||||
Task {
|
||||
let result = await repository.delete(transferId: transferId)
|
||||
switch result {
|
||||
@@ -217,7 +220,32 @@ final class SendModel: ObservableObject {
|
||||
state.receiverHistory = []
|
||||
state.isDeleteConfirmationOpen = false
|
||||
state.isDeleting = false
|
||||
messages.tryShow(UiMessage(text: .resource("transfer_deleted"), tone: .success))
|
||||
messages.tryShow(UiMessage(text: .resource(L10n.Transfer.deleted), tone: .success))
|
||||
case .failure(let error):
|
||||
state.isDeleting = false
|
||||
messages.error(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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)
|
||||
@@ -249,7 +277,7 @@ final class SendModel: ObservableObject {
|
||||
switch result {
|
||||
case .success:
|
||||
_ = await repository.refresh()
|
||||
messages.tryShow(UiMessage(text: .resource("transfer_event_stopped"), tone: .info))
|
||||
messages.tryShow(UiMessage(text: .resource(L10n.Transfer.eventStopped), tone: .info))
|
||||
case .failure(let error):
|
||||
messages.error(error)
|
||||
}
|
||||
@@ -261,10 +289,10 @@ final class SendModel: ObservableObject {
|
||||
func onInvitationResult(_ action: InvitationAction, _ result: Result<Void, Error>) {
|
||||
switch result {
|
||||
case .success:
|
||||
let key: String?
|
||||
let key: String.LocalizationValue?
|
||||
switch action {
|
||||
case .export: key = "transfer_invitation_saved"
|
||||
case .nfc: key = "transfer_nfc_written"
|
||||
case .export: key = L10n.Transfer.invitationSaved
|
||||
case .nfc: key = L10n.Transfer.nfcWritten
|
||||
case .share: key = nil // system share sheet already confirms
|
||||
}
|
||||
if let key { messages.tryShow(UiMessage(text: .resource(key), tone: .success)) }
|
||||
@@ -297,7 +325,14 @@ final class SendModel: ObservableObject {
|
||||
state.transferName = ""
|
||||
state.accessPolicy = .requireApproval
|
||||
state.isSharing = false
|
||||
messages.show(UiMessage(text: .resource("send_transfer_created"), tone: .success))
|
||||
// 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
|
||||
messages.error(error)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import SwiftUI
|
||||
import SFSafeSymbols
|
||||
|
||||
/// Send screen, rebuilt on native SwiftUI. A grouped `List` of outgoing transfers,
|
||||
/// with the composer and detail panels as native sheets and delete as an alert.
|
||||
@@ -6,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 }
|
||||
}
|
||||
@@ -27,11 +33,11 @@ struct SendScreen: View {
|
||||
catalog
|
||||
}
|
||||
}
|
||||
.navigationTitle(Text(LocalizedStringKey("send_title")))
|
||||
.navigationTitle(Text(String(localized: L10n.Send.title)))
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .primaryAction) {
|
||||
Button(action: model.openComposer) {
|
||||
Label(String(localized: "button_create_new_transfer"), systemImage: "plus")
|
||||
Label(String(localized: L10n.Button.createNewTransfer), systemSymbol: .plus)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -40,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 }),
|
||||
@@ -48,7 +66,23 @@ 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
|
||||
@@ -65,14 +99,14 @@ struct SendScreen: View {
|
||||
}
|
||||
}
|
||||
.alert(
|
||||
Text(LocalizedStringKey("transfer_delete_title")),
|
||||
Text(String(localized: L10n.Transfer.deleteTitle)),
|
||||
isPresented: Binding(get: { model.state.isDeleteConfirmationOpen }, set: { if !$0 { Task { @MainActor in model.dismissDeleteTransfer() } } })
|
||||
) {
|
||||
Button(String(localized: "button_cancel"), role: .cancel, action: model.dismissDeleteTransfer)
|
||||
Button(String(localized: "button_delete_transfer"), role: .destructive, action: model.confirmDeleteTransfer)
|
||||
Button(String(localized: L10n.Button.cancel), role: .cancel, action: model.dismissDeleteTransfer)
|
||||
Button(String(localized: L10n.Button.deleteTransfer), role: .destructive, action: model.confirmDeleteTransfer)
|
||||
} message: {
|
||||
Text(String(format: String(localized: "transfer_delete_description"),
|
||||
transfer.transferName ?? String(localized: "send_new_transfer_title")))
|
||||
Text(L10n.Transfer.deleteDescription(
|
||||
transferName: transfer.transferName ?? String(localized: L10n.Send.newTransferTitle)))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,23 +124,45 @@ 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(LocalizedStringKey("send_transfers_title"))
|
||||
Text(String(localized: L10n.Send.transfersTitle))
|
||||
} footer: {
|
||||
Text(LocalizedStringKey("send_subtitle"))
|
||||
Text(String(localized: L10n.Send.subtitle))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var emptyState: some View {
|
||||
ContentUnavailableView {
|
||||
Label(String(localized: "send_empty_title"), systemImage: "paperplane")
|
||||
Label(String(localized: L10n.Send.emptyTitle), systemSymbol: .paperplane)
|
||||
} description: {
|
||||
Text(LocalizedStringKey("send_empty_body"))
|
||||
Text(String(localized: L10n.Send.emptyBody))
|
||||
} actions: {
|
||||
Button(action: model.openComposer) {
|
||||
Label(String(localized: "button_create_new_transfer"), systemImage: "plus")
|
||||
Label(String(localized: L10n.Button.createNewTransfer), systemSymbol: .plus)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.controlSize(.large)
|
||||
@@ -127,21 +183,18 @@ struct SendScreen: View {
|
||||
private func sharingProgress(for transfer: Transfer) -> TransferProgress? {
|
||||
let active = (model.receiversByTransfer[transfer.transferId] ?? []).filter { $0.status == .accepted }
|
||||
if active.isEmpty { return nil }
|
||||
let fractions: [Double] = active.compactMap { receiver -> Double? in
|
||||
let progress = progressForReceiver(events: model.coreState.events, transferId: transfer.transferId,
|
||||
remoteEndpointId: receiver.remoteEndpointId, totalSizeHint: transfer.totalSize)
|
||||
guard progress?.kind == "started" || progress?.kind == "progress" else { return nil }
|
||||
return progress?.progress
|
||||
let fractions = active.compactMap {
|
||||
progressForReceiver(events: model.coreState.events, transferId: transfer.transferId,
|
||||
remoteEndpointId: $0.remoteEndpointId, totalSizeHint: transfer.totalSize)?.progress
|
||||
}
|
||||
guard !fractions.isEmpty else { return nil }
|
||||
let combined = fractions.reduce(0, +) / Double(fractions.count)
|
||||
let combined = fractions.isEmpty ? nil : fractions.reduce(0, +) / Double(fractions.count)
|
||||
if active.count == 1 {
|
||||
return TransferProgress(transferId: transfer.transferId, phase: "transfer", kind: "progress",
|
||||
labelKey: "progress_sending", progress: combined)
|
||||
return TransferProgress(transferId: transfer.transferId, phase: .transfer, kind: .progress,
|
||||
labelKey: L10n.Progress.sending, progress: combined)
|
||||
}
|
||||
return TransferProgress(transferId: transfer.transferId, phase: "transfer", kind: "progress",
|
||||
labelKey: "progress_sending", progress: combined,
|
||||
label: String(format: String(localized: "progress_sending_to_count"), active.count))
|
||||
return TransferProgress(transferId: transfer.transferId, phase: .transfer, kind: .progress,
|
||||
labelKey: L10n.Progress.sending, progress: combined,
|
||||
label: L10n.Progress.sendingToCount(count: active.count))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -157,19 +210,19 @@ private struct TransferListItem: View {
|
||||
.background(.quaternary, in: RoundedRectangle(cornerRadius: 9))
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
HStack {
|
||||
Text(transfer.transferName ?? String(localized: "send_new_transfer_title"))
|
||||
Text(transfer.transferName ?? String(localized: L10n.Send.newTransferTitle))
|
||||
.font(.body).lineLimit(1)
|
||||
Spacer()
|
||||
StatusPill(label: statusLabel(transfer.status), tone: transfer.status.pillTone)
|
||||
}
|
||||
Text("\(formatBytes(transfer.totalSize)) · \(accessPolicyLabel(transfer.accessPolicy))")
|
||||
Text(L10n.Format.separatedPair(first: formatBytes(transfer.totalSize), second: accessPolicyLabel(transfer.accessPolicy)))
|
||||
.font(.caption).foregroundStyle(.secondary).lineLimit(1)
|
||||
if let progress, transfer.status == .importing || transfer.status == .sharing {
|
||||
ProgressRow(labelKey: progress.labelKey, progress: progress.progress, detail: progress.detail, labelText: progress.label)
|
||||
.padding(.top, 2)
|
||||
}
|
||||
}
|
||||
Image(systemName: "chevron.forward")
|
||||
Image(systemSymbol: .chevronForward)
|
||||
.font(.footnote.weight(.semibold)).foregroundStyle(.tertiary)
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
@@ -184,7 +237,7 @@ struct FileArtwork: View {
|
||||
image.resizable().aspectRatio(contentMode: .fill)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
} else {
|
||||
Image(systemName: "doc")
|
||||
Image(systemSymbol: .doc)
|
||||
.font(.system(size: 18))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
@@ -192,13 +245,13 @@ struct FileArtwork: View {
|
||||
}
|
||||
|
||||
func statusLabel(_ status: TransferStatus) -> String {
|
||||
String(localized: String.LocalizationValue(statusLabelKey(status)))
|
||||
String(localized: statusLabelKey(status))
|
||||
}
|
||||
|
||||
func accessPolicyLabel(_ policy: ShareAccessPolicy) -> String {
|
||||
switch policy {
|
||||
case .requireApproval: return String(localized: "send_access_approval")
|
||||
case .anyoneWithTransfer: return String(localized: "send_access_anyone")
|
||||
case .requireApproval: return String(localized: L10n.Send.accessApproval)
|
||||
case .anyoneWithTransfer: return String(localized: L10n.Send.accessAnyone)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import SwiftUI
|
||||
import SFSafeSymbols
|
||||
|
||||
/// Transfer composer drawer, ported from `feature/send/TransferComposer.kt`.
|
||||
/// Two steps: choose files/folder, then review + name + access policy + share.
|
||||
@@ -23,13 +24,13 @@ struct TransferComposer: View {
|
||||
|
||||
private var chooseStep: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
Text(LocalizedStringKey("send_choose_file_title")).font(.title2).fontWeight(.semibold)
|
||||
Text(LocalizedStringKey("send_choose_file_body"))
|
||||
Text(String(localized: L10n.Send.chooseFileTitle)).font(.title2).fontWeight(.semibold)
|
||||
Text(String(localized: L10n.Send.chooseFileBody))
|
||||
.font(.subheadline).foregroundStyle(.secondary)
|
||||
VStack(spacing: 14) {
|
||||
Image(systemName: "doc").font(.system(size: 30)).foregroundStyle(.tint)
|
||||
PrimaryButton(title: String(localized: "button_choose_files"), action: model.selectFile).fixedSize()
|
||||
QuietButton(title: String(localized: "button_choose_folder"), action: model.selectFolder)
|
||||
Image(systemSymbol: .doc).font(.system(size: 30)).foregroundStyle(.tint)
|
||||
PrimaryButton(title: String(localized: L10n.Button.chooseFiles), action: model.selectFile).fixedSize()
|
||||
QuietButton(title: String(localized: L10n.Button.chooseFolder), action: model.selectFolder)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(28)
|
||||
@@ -39,9 +40,9 @@ struct TransferComposer: View {
|
||||
|
||||
private var reviewStep: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
Text(LocalizedStringKey("send_review_title")).font(.title2).fontWeight(.semibold)
|
||||
Text(String(localized: L10n.Send.reviewTitle)).font(.title2).fontWeight(.semibold)
|
||||
if state.selectedFiles.count > 1 {
|
||||
Text(String(format: String(localized: "send_selected_files_count"), state.selectedFiles.count))
|
||||
Text(L10n.Send.selectedFilesCount(count: state.selectedFiles.count))
|
||||
.font(.subheadline).foregroundStyle(.secondary)
|
||||
}
|
||||
ForEach(state.selectedFiles) { file in
|
||||
@@ -51,54 +52,66 @@ struct TransferComposer: View {
|
||||
onRemove: { model.removeSelectedFile(file.value) }
|
||||
)
|
||||
}
|
||||
Field(label: String(localized: "field_transfer_name"),
|
||||
Field(label: String(localized: L10n.Field.transferName),
|
||||
value: Binding(get: { state.transferName }, set: { model.setTransferName($0) }))
|
||||
Field(label: String(localized: "field_sender_name"),
|
||||
Field(label: String(localized: L10n.Field.senderName),
|
||||
value: Binding(get: { state.senderName }, set: { model.setSenderName($0) }))
|
||||
Text(LocalizedStringKey("send_access_title")).font(.headline)
|
||||
Text(String(localized: L10n.Send.accessTitle)).font(.headline)
|
||||
PolicyOption(
|
||||
icon: "checkmark.shield", titleKey: "send_access_approval", descKey: "send_access_approval_description",
|
||||
icon: .checkmarkShield, titleKey: L10n.Send.accessApproval, descKey: L10n.Send.accessApprovalDescription,
|
||||
selected: state.accessPolicy == .requireApproval,
|
||||
onTap: { model.setAccessPolicy(.requireApproval) }
|
||||
)
|
||||
PolicyOption(
|
||||
icon: "globe", titleKey: "send_access_anyone", descKey: "send_access_anyone_description",
|
||||
icon: .globe, titleKey: L10n.Send.accessAnyone, descKey: L10n.Send.accessAnyoneDescription,
|
||||
selected: state.accessPolicy == .anyoneWithTransfer,
|
||||
onTap: { model.setAccessPolicy(.anyoneWithTransfer) }
|
||||
)
|
||||
if state.accessPolicy == .anyoneWithTransfer {
|
||||
Label(String(localized: "send_access_anyone_warning"), systemImage: "exclamationmark.triangle.fill")
|
||||
Label(String(localized: L10n.Send.accessAnyoneWarning), systemSymbol: .exclamationmarkTriangleFill)
|
||||
.font(.caption).foregroundStyle(.orange)
|
||||
}
|
||||
actions
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var actions: some View {
|
||||
let shareTitle = state.isSharing
|
||||
? String(localized: "button_sharing_file") : String(localized: "button_share_file")
|
||||
let shareButton = PrimaryButton(
|
||||
? String(localized: L10n.Button.sharingFile) : String(localized: L10n.Button.shareFile)
|
||||
return VStack(spacing: 10) {
|
||||
PrimaryButton(
|
||||
title: shareTitle, action: model.createShare,
|
||||
enabled: state.canCreateShare(coreInitialized: model.coreState.isInitialized)
|
||||
)
|
||||
if windowClass == .phone {
|
||||
VStack(spacing: 8) {
|
||||
shareButton
|
||||
QuietButton(title: String(localized: "button_change_files"), action: model.selectFile, enabled: !state.isSharing)
|
||||
QuietButton(title: String(localized: "button_choose_folder"), action: model.selectFolder, enabled: !state.isSharing)
|
||||
}
|
||||
} else {
|
||||
HStack(spacing: 8) {
|
||||
shareButton.fixedSize()
|
||||
QuietButton(title: String(localized: "button_change_files"), action: model.selectFile, enabled: !state.isSharing)
|
||||
QuietButton(title: String(localized: "button_choose_folder"), action: model.selectFolder, enabled: !state.isSharing)
|
||||
QuietButton(title: String(localized: "button_clear"), action: model.clearSelectedSource, enabled: !state.isSharing)
|
||||
// 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 {
|
||||
let file: PickedShareFile
|
||||
let canRemove: Bool
|
||||
@@ -116,7 +129,7 @@ private struct SelectedFileCard: View {
|
||||
Spacer()
|
||||
if canRemove {
|
||||
Button(role: .destructive, action: onRemove) {
|
||||
Image(systemName: "trash")
|
||||
Image(systemSymbol: .trash)
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
.tint(.red)
|
||||
@@ -128,32 +141,32 @@ private struct SelectedFileCard: View {
|
||||
}
|
||||
|
||||
private var subtitle: String {
|
||||
if file.isDirectory { return String(localized: "send_folder_label") }
|
||||
if file.isDirectory { return String(localized: L10n.Send.folderLabel) }
|
||||
if let size = file.sizeBytes { return formatBytes(size) }
|
||||
return String(localized: "send_file_size_unknown")
|
||||
return String(localized: L10n.Send.fileSizeUnknown)
|
||||
}
|
||||
}
|
||||
|
||||
private struct PolicyOption: View {
|
||||
let icon: String
|
||||
let titleKey: String
|
||||
let descKey: String
|
||||
let icon: SFSymbol
|
||||
let titleKey: String.LocalizationValue
|
||||
let descKey: String.LocalizationValue
|
||||
let selected: Bool
|
||||
let onTap: () -> Void
|
||||
|
||||
var body: some View {
|
||||
Button(action: onTap) {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: icon)
|
||||
Image(systemSymbol: icon)
|
||||
.font(.system(size: 20))
|
||||
.foregroundStyle(selected ? AnyShapeStyle(.tint) : AnyShapeStyle(.secondary))
|
||||
.frame(width: 22)
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(LocalizedStringKey(titleKey))
|
||||
Text(LocalizedStringKey(descKey)).font(.caption).foregroundStyle(.secondary)
|
||||
Text(String(localized: titleKey))
|
||||
Text(String(localized: descKey)).font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
Image(systemName: selected ? "checkmark.circle.fill" : "circle")
|
||||
Image(systemSymbol: selected ? .checkmarkCircleFill : .circle)
|
||||
.foregroundStyle(selected ? AnyShapeStyle(.tint) : AnyShapeStyle(.tertiary))
|
||||
}
|
||||
.padding(14)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import SwiftUI
|
||||
import SFSafeSymbols
|
||||
import CoreImage.CIFilterBuiltins
|
||||
|
||||
/// Transfer details + drawer panels, ported from `feature/send/TransferDetails.kt`.
|
||||
@@ -23,78 +24,78 @@ struct TransferDetailsView: View {
|
||||
var body: some View {
|
||||
Form {
|
||||
Section {
|
||||
LabeledContent(String(localized: "metadata_status"), value: statusLabel(transfer.status))
|
||||
LabeledContent(String(localized: "metadata_size"), value: formatBytes(transfer.totalSize))
|
||||
LabeledContent(String(localized: "send_access_title"), value: accessPolicyLabel(transfer.accessPolicy))
|
||||
LabeledContent(String(localized: L10n.Metadata.status), value: statusLabel(transfer.status))
|
||||
LabeledContent(String(localized: L10n.Metadata.size), value: formatBytes(transfer.totalSize))
|
||||
LabeledContent(String(localized: L10n.Send.accessTitle), value: accessPolicyLabel(transfer.accessPolicy))
|
||||
} header: {
|
||||
Text(transfer.transferName ?? String(localized: "send_new_transfer_title"))
|
||||
Text(transfer.transferName ?? String(localized: L10n.Send.newTransferTitle))
|
||||
}
|
||||
|
||||
Section {
|
||||
DetailDestination(
|
||||
title: String(localized: "transfer_activity_title"),
|
||||
description: String(localized: "transfer_activity_description"),
|
||||
title: String(localized: L10n.Transfer.activityTitle),
|
||||
description: String(localized: L10n.Transfer.activityDescription),
|
||||
count: events.filter { $0.transferId == transfer.transferId && $0.isMeaningfulActivity }.count,
|
||||
onTap: model.openActivity
|
||||
)
|
||||
DetailDestination(
|
||||
title: String(localized: "transfer_receivers_title"),
|
||||
title: String(localized: L10n.Transfer.receiversTitle),
|
||||
description: receiversDescription(pendingReceivers, completedReceivers),
|
||||
count: pendingReceivers + completedReceivers,
|
||||
onTap: model.openReceivers
|
||||
)
|
||||
DetailDestination(
|
||||
title: String(localized: "transfer_share_title"),
|
||||
description: String(localized: "transfer_share_description"),
|
||||
count: 0,
|
||||
onTap: model.openShare
|
||||
)
|
||||
}
|
||||
|
||||
if isActiveShare {
|
||||
Section {
|
||||
if isActiveShare {
|
||||
Button(role: .destructive) {
|
||||
showStopConfirmation = true
|
||||
} label: {
|
||||
Label(String(localized: "send_stop_sharing"), systemImage: "stop.circle")
|
||||
Label(String(localized: L10n.Send.stopSharing), systemSymbol: .stopCircle)
|
||||
}
|
||||
}
|
||||
Button(role: .destructive, action: model.requestDeleteTransfer) {
|
||||
Label(String(localized: L10n.Button.deleteTransfer), systemSymbol: .trash)
|
||||
}
|
||||
}
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
.navigationTitle(Text(LocalizedStringKey("send_transfer_details_title")))
|
||||
.navigationTitle(Text(String(localized: L10n.Send.transferDetailsTitle)))
|
||||
#if os(iOS)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
#endif
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .primaryAction) {
|
||||
Button(role: .destructive, action: model.requestDeleteTransfer) {
|
||||
Image(systemName: "trash")
|
||||
Button(action: model.openShare) {
|
||||
Label(String(localized: L10n.Transfer.shareTitle), systemSymbol: .squareAndArrowUp)
|
||||
}
|
||||
.help(String(localized: L10n.Transfer.shareTitle))
|
||||
}
|
||||
}
|
||||
.confirmationDialog(
|
||||
Text(LocalizedStringKey("send_stop_sharing")),
|
||||
Text(String(localized: L10n.Send.stopSharing)),
|
||||
isPresented: $showStopConfirmation,
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
Button(String(localized: "send_stop_sharing"), role: .destructive) {
|
||||
Button(String(localized: L10n.Send.stopSharing), role: .destructive) {
|
||||
model.stopSharing(transferId: transfer.transferId)
|
||||
}
|
||||
Button(String(localized: "button_cancel"), role: .cancel) {}
|
||||
Button(String(localized: L10n.Button.cancel), role: .cancel) {}
|
||||
} message: {
|
||||
Text(LocalizedStringKey("send_stop_sharing_description"))
|
||||
Text(String(localized: L10n.Send.stopSharingDescription))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func receiversDescription(_ pending: Int, _ completed: Int) -> String {
|
||||
if pending > 0 && completed > 0 {
|
||||
return "\(String(format: String(localized: "transfer_receivers_pending"), pending)) · \(String(format: String(localized: "transfer_receivers_completed_count"), completed))"
|
||||
return L10n.Format.separatedPair(
|
||||
first: L10n.Transfer.receiversPending(count: pending),
|
||||
second: L10n.Transfer.receiversCompletedCount(count: completed))
|
||||
}
|
||||
if pending > 0 { return String(format: String(localized: "transfer_receivers_pending"), pending) }
|
||||
if completed > 0 { return String(format: String(localized: "transfer_receivers_completed_count"), completed) }
|
||||
return String(localized: "transfer_receivers_description")
|
||||
if pending > 0 { return L10n.Transfer.receiversPending(count: pending) }
|
||||
if completed > 0 { return L10n.Transfer.receiversCompletedCount(count: completed) }
|
||||
return String(localized: L10n.Transfer.receiversDescription)
|
||||
}
|
||||
|
||||
private struct DetailDestination: View {
|
||||
@@ -111,11 +112,11 @@ private struct DetailDestination: View {
|
||||
}
|
||||
Spacer()
|
||||
if count > 0 {
|
||||
Text("\(count)")
|
||||
Text(verbatim: "\(count)")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Image(systemName: "chevron.forward")
|
||||
Image(systemSymbol: .chevronForward)
|
||||
.font(.footnote.weight(.semibold)).foregroundStyle(.tertiary)
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
@@ -171,13 +172,13 @@ struct TransferActivityPanel: View {
|
||||
let visible = events
|
||||
.filter { $0.transferId == transferId && $0.isMeaningfulActivity }
|
||||
.sorted { $0.timestamp > $1.timestamp }
|
||||
PanelContainer(title: String(localized: "transfer_activity_title")) {
|
||||
PanelContainer(title: String(localized: L10n.Transfer.activityTitle)) {
|
||||
if visible.isEmpty {
|
||||
Text(LocalizedStringKey("transfer_no_activity")).foregroundStyle(colors.foregroundLighter)
|
||||
Text(String(localized: L10n.Transfer.noActivity)).foregroundStyle(colors.foregroundLighter)
|
||||
} else {
|
||||
ForEach(Array(visible.enumerated()), id: \.offset) { index, event in
|
||||
if index > 0 { Divider().overlay(colors.borderDefault) }
|
||||
Text(LocalizedStringKey(event.activityTitleKey))
|
||||
Text(String(localized: event.activityTitleKey))
|
||||
.fontWeight(.medium).padding(.vertical, 14)
|
||||
}
|
||||
}
|
||||
@@ -194,11 +195,11 @@ struct ReceiverHistoryPanel: View {
|
||||
let onCancel: (String) -> Void
|
||||
|
||||
var body: some View {
|
||||
PanelContainer(title: String(localized: "transfer_receivers_title")) {
|
||||
PanelContainer(title: String(localized: L10n.Transfer.receiversTitle)) {
|
||||
if loading {
|
||||
ProgressView().frame(maxWidth: .infinity).padding(40)
|
||||
} else if receivers.isEmpty {
|
||||
Text(LocalizedStringKey("transfer_no_receivers")).foregroundStyle(colors.foregroundLighter)
|
||||
Text(String(localized: L10n.Transfer.noReceivers)).foregroundStyle(colors.foregroundLighter)
|
||||
} else {
|
||||
ForEach(Array(receivers.enumerated()), id: \.element.id) { index, receiver in
|
||||
if index > 0 { Divider().overlay(colors.borderDefault) }
|
||||
@@ -232,9 +233,10 @@ private struct ReceiverRow: View {
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
let name = receiver.receiverName ?? receiver.receiverDeviceName ?? String(localized: "transfer_nearby_device")
|
||||
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)
|
||||
@@ -244,12 +246,13 @@ private struct ReceiverRow: View {
|
||||
if showLive, let sendProgress {
|
||||
ProgressRow(labelKey: sendProgress.labelKey, progress: sendProgress.progress, detail: sendProgress.detail, labelText: sendProgress.label)
|
||||
} else {
|
||||
Text(LocalizedStringKey(receiver.status.statusTextKey))
|
||||
Text(String(localized: receiver.status.statusTextKey))
|
||||
.font(VniType.bodySmall).fontWeight(.medium)
|
||||
.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)
|
||||
@@ -257,7 +260,7 @@ private struct ReceiverRow: View {
|
||||
Button(role: .destructive) {
|
||||
onCancel(receiver.id)
|
||||
} label: {
|
||||
Text(LocalizedStringKey("button_refuse"))
|
||||
Text(String(localized: L10n.Button.refuse))
|
||||
.font(VniType.bodySmall)
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
@@ -275,25 +278,40 @@ struct TransferSharePanel: View {
|
||||
let transfer: Transfer
|
||||
|
||||
var body: some View {
|
||||
PanelContainer(title: String(localized: "transfer_share_title")) {
|
||||
if let ticket = transfer.ticket {
|
||||
qrCard(ticket: ticket)
|
||||
Text(LocalizedStringKey("transfer_scan_qr"))
|
||||
PanelContainer(title: String(localized: L10n.Transfer.shareTitle)) {
|
||||
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 {
|
||||
Text(LocalizedStringKey("transfer_event_preparing")).foregroundStyle(colors.foregroundLighter)
|
||||
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)
|
||||
@@ -302,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 {
|
||||
@@ -334,38 +372,39 @@ extension CoreEventModel {
|
||||
"receiver-refused", "receiver-completed", "share-stopped", "failed"].contains(kind)
|
||||
}
|
||||
|
||||
var activityTitleKey: String {
|
||||
if phase == "import" && kind == "started" { return "transfer_event_preparing" }
|
||||
if phase == "ticket" && kind == "created" { return "transfer_event_ready" }
|
||||
if phase == "network" { return "transfer_event_connecting" }
|
||||
if phase == "download" { return "transfer_event_downloading" }
|
||||
if phase == "export" { return "transfer_event_saving" }
|
||||
if kind == "receiver-requested" { return "transfer_event_requested" }
|
||||
if kind == "receiver-accepted" || kind == "receiver-auto-approved" { return "transfer_event_approved" }
|
||||
if kind == "receiver-refused" { return "transfer_event_refused" }
|
||||
if kind == "receiver-completed" { return "transfer_event_completed" }
|
||||
if kind == "share-stopped" || (phase == "lifecycle" && kind == "cancelled") { return "transfer_event_stopped" }
|
||||
if kind == "failed" { return "transfer_event_failed" }
|
||||
return "transfer_event_updated"
|
||||
var activityTitleKey: String.LocalizationValue {
|
||||
if phase == "import" && kind == "started" { return L10n.Transfer.eventPreparing }
|
||||
if phase == "ticket" && kind == "created" { return L10n.Transfer.eventReady }
|
||||
if phase == "network" { return L10n.Transfer.eventConnecting }
|
||||
if phase == "download" { return L10n.Transfer.eventDownloading }
|
||||
if phase == "export" { return L10n.Transfer.eventSaving }
|
||||
if kind == "receiver-requested" { return L10n.Transfer.eventRequested }
|
||||
if kind == "receiver-accepted" || kind == "receiver-auto-approved" { return L10n.Transfer.eventApproved }
|
||||
if kind == "receiver-refused" { return L10n.Transfer.eventRefused }
|
||||
if kind == "receiver-completed" { return L10n.Transfer.eventCompleted }
|
||||
if kind == "share-stopped" || (phase == "lifecycle" && kind == "cancelled") { return L10n.Transfer.eventStopped }
|
||||
if kind == "failed" { return L10n.Transfer.eventFailed }
|
||||
return L10n.Transfer.eventUpdated
|
||||
}
|
||||
}
|
||||
|
||||
extension ReceiverDeliveryStatus {
|
||||
var statusTextKey: String {
|
||||
var statusTextKey: String.LocalizationValue {
|
||||
switch self {
|
||||
case .requested: return "transfer_receiver_requested"
|
||||
case .accepted: return "transfer_receiver_accepted"
|
||||
case .refused: return "transfer_receiver_refused"
|
||||
case .expired: return "transfer_receiver_expired"
|
||||
case .completed: return "transfer_receiver_completed"
|
||||
case .unknown: return "transfer_receiver_unknown"
|
||||
case .requested: return L10n.Transfer.receiverRequested
|
||||
case .accepted: return L10n.Transfer.receiverAccepted
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import SwiftUI
|
||||
|
||||
enum NfcShareAvailability { case available, unavailable, hidden }
|
||||
|
||||
/// Invitation delivery actions shared by the native Apple feature models.
|
||||
/// Invitation delivery actions, ported from `TransferShareActions` (iosMain).
|
||||
/// Platform implementations perform export, native share, and NFC write.
|
||||
@MainActor
|
||||
protocol TransferShareActions: AnyObject {
|
||||
@@ -30,7 +30,7 @@ struct ShareActionsView: View {
|
||||
VStack(spacing: 12) {
|
||||
if actions.nfcAvailability != .hidden {
|
||||
SecondaryButton(
|
||||
title: writingNfc ? String(localized: "transfer_nfc_waiting") : String(localized: "button_write_nfc"),
|
||||
title: writingNfc ? String(localized: L10n.Transfer.nfcWaiting) : String(localized: L10n.Button.writeNfc),
|
||||
action: {
|
||||
writingNfc = true
|
||||
actions.writeInvitationToNfc(ticket: ticket) { result in
|
||||
@@ -41,16 +41,16 @@ struct ShareActionsView: View {
|
||||
enabled: actions.nfcAvailability == .available && !writingNfc
|
||||
)
|
||||
if actions.nfcAvailability == .unavailable {
|
||||
Text(LocalizedStringKey("transfer_nfc_unavailable"))
|
||||
Text(String(localized: L10n.Transfer.nfcUnavailable))
|
||||
.font(VniType.bodySmall).foregroundStyle(colors.foregroundLighter)
|
||||
}
|
||||
}
|
||||
SecondaryButton(title: String(localized: "button_download_invitation"), action: {
|
||||
SecondaryButton(title: String(localized: L10n.Button.downloadInvitation), action: {
|
||||
actions.exportInvitation(ticket: ticket, transferName: transfer.transferName ?? "") {
|
||||
model.onInvitationResult(.export, $0)
|
||||
}
|
||||
})
|
||||
PrimaryButton(title: String(localized: "button_native_share"), action: {
|
||||
PrimaryButton(title: String(localized: L10n.Button.nativeShare), action: {
|
||||
actions.shareInvitation(ticket: ticket, transferName: transfer.transferName ?? "") {
|
||||
model.onInvitationResult(.share, $0)
|
||||
}
|
||||
|
||||
@@ -7,19 +7,21 @@ enum SettingsSection: Hashable {
|
||||
case preferences
|
||||
case appearance
|
||||
case notifications
|
||||
case network
|
||||
case storage
|
||||
case about
|
||||
case bugReport
|
||||
|
||||
var titleKey: String {
|
||||
var titleKey: String.LocalizationValue {
|
||||
switch self {
|
||||
case .overview: return "settings_title"
|
||||
case .preferences: return "preferences_title"
|
||||
case .appearance: return "appearance_title"
|
||||
case .notifications: return "notifications_title"
|
||||
case .storage: return "storage_title"
|
||||
case .about: return "about_title"
|
||||
case .bugReport: return "about_bug_report"
|
||||
case .overview: return L10n.Settings.title
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -41,9 +43,16 @@ struct SettingsState: Equatable {
|
||||
var isValidatingFolder = false
|
||||
var supportsCustomReceiveFolders = true
|
||||
var themeMode: ThemeMode = .system
|
||||
var notificationsEnabled = false
|
||||
var notificationPermission: NotificationPermission = .notDetermined
|
||||
var diagnosticsEnabled = false
|
||||
var relayMode: RelayPreferenceMode = .automatic
|
||||
var relayURLs: [String] = []
|
||||
var relayValidationError: RelayConfigurationValidationError?
|
||||
var relayConfigurationIsDirty = false
|
||||
var isApplyingRelayConfiguration = false
|
||||
var hasActiveNetworkWork = false
|
||||
var endpointId: String?
|
||||
var relayApplyErrorKey: String.LocalizationValue?
|
||||
var deviceInfo: DeviceInfo?
|
||||
var appVersion = ""
|
||||
var isLoadingDeviceInfo = false
|
||||
@@ -56,23 +65,34 @@ struct SettingsState: Equatable {
|
||||
var bugLogPreviewBytes = 0
|
||||
var storage: StorageBreakdown?
|
||||
var isCalculatingStorage = false
|
||||
var storageLoadFailed = false
|
||||
var isDeletingTransfers = false
|
||||
var isCleaningStorage = false
|
||||
|
||||
static func == (lhs: SettingsState, rhs: SettingsState) -> Bool {
|
||||
lhs.selectedSection == rhs.selectedSection && lhs.username == rhs.username
|
||||
&& lhs.receiveFolder == rhs.receiveFolder && lhs.folderAccessStatus == rhs.folderAccessStatus
|
||||
&& lhs.isValidatingFolder == rhs.isValidatingFolder
|
||||
&& lhs.supportsCustomReceiveFolders == rhs.supportsCustomReceiveFolders
|
||||
&& lhs.themeMode == rhs.themeMode && lhs.notificationsEnabled == rhs.notificationsEnabled
|
||||
&& 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
|
||||
&& lhs.bugIncludeLogs == rhs.bugIncludeLogs && lhs.isSubmittingBugReport == rhs.isSubmittingBugReport
|
||||
&& lhs.bugLogPreviewBytes == rhs.bugLogPreviewBytes
|
||||
&& lhs.storage == rhs.storage && lhs.isCalculatingStorage == rhs.isCalculatingStorage
|
||||
&& lhs.storageLoadFailed == rhs.storageLoadFailed
|
||||
&& lhs.isDeletingTransfers == rhs.isDeletingTransfers
|
||||
&& lhs.isCleaningStorage == rhs.isCleaningStorage
|
||||
&& lhs.deviceInfo?.operatingSystem == rhs.deviceInfo?.operatingSystem
|
||||
}
|
||||
}
|
||||
@@ -93,9 +113,9 @@ final class SettingsModel: ObservableObject {
|
||||
private let bugReports: BugReportService
|
||||
private let diagnosticsIncluded: Bool
|
||||
|
||||
private var enableNotificationsAfterSettings = false
|
||||
private var usernamePersistTask: Task<Void, Never>?
|
||||
private var hasLocalUsernameDraft = false
|
||||
private var hasRelayConfigurationDraft = false
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
||||
init(
|
||||
@@ -131,12 +151,30 @@ final class SettingsModel: ObservableObject {
|
||||
self.state.username = self.hasLocalUsernameDraft ? self.state.username : prefs.username
|
||||
self.state.receiveFolder = folder
|
||||
self.state.themeMode = prefs.themeMode
|
||||
self.state.notificationsEnabled = prefs.notificationsEnabled
|
||||
self.state.diagnosticsEnabled = prefs.diagnosticsEnabled
|
||||
if !self.hasRelayConfigurationDraft {
|
||||
self.state.relayMode = prefs.relayConfiguration.mode
|
||||
self.state.relayURLs = prefs.relayConfiguration.relayURLs
|
||||
self.state.relayConfigurationIsDirty = false
|
||||
}
|
||||
if folder != previousFolder { Task { await self.validateFolder(folder) } }
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
repository.statePublisher
|
||||
.sink { [weak self] coreState in
|
||||
guard let self else { return }
|
||||
let hasActiveWork = (coreState.status?.activeTransfers ?? 0) > 0
|
||||
|| (coreState.status?.activeShares ?? 0) > 0
|
||||
|| coreState.transfers.contains(where: { $0.status.isActiveTransfer })
|
||||
self.state.hasActiveNetworkWork = hasActiveWork
|
||||
self.state.endpointId = coreState.status?.endpointId
|
||||
if !hasActiveWork && self.state.relayApplyErrorKey == L10n.Relay.applyActiveTransfers {
|
||||
self.state.relayApplyErrorKey = nil
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
refreshNotificationPermission()
|
||||
loadDeviceInfo()
|
||||
}
|
||||
@@ -171,26 +209,23 @@ final class SettingsModel: ObservableObject {
|
||||
func onReceiveFolderPickFailed(_ reason: String) { messages.error(InvitationError.message(reason)) }
|
||||
func resetReceiveFolder() { preferences.resetReceiveFolder() }
|
||||
|
||||
func setNotificationsEnabled(_ enabled: Bool) {
|
||||
Task {
|
||||
if !enabled {
|
||||
preferences.setNotificationsEnabled(false)
|
||||
notifications.cancelAll()
|
||||
return
|
||||
/// Whether the current receive folder is the platform default (so the reset
|
||||
/// action can be hidden when it would be a no-op). Compared by location, not
|
||||
/// display name, which can differ once resolved.
|
||||
var isUsingDefaultReceiveFolder: Bool {
|
||||
guard let folder = state.receiveFolder else { return true }
|
||||
let fallback = fileSystemService.defaultReceiveFolder()
|
||||
return folder.kind == fallback.kind && folder.value == fallback.value
|
||||
}
|
||||
|
||||
/// Ask the OS for notification permission. This is the only time the app can
|
||||
/// grant it; disabling or fine-tuning afterwards happens in the Settings app.
|
||||
func requestNotifications() {
|
||||
Task {
|
||||
let permission = await notifications.requestPermission()
|
||||
state.notificationPermission = permission
|
||||
if permission == .granted {
|
||||
await enableNotifications()
|
||||
} else {
|
||||
preferences.setNotificationsEnabled(false)
|
||||
let key = permission == .unsupported ? "notifications_unsupported" : "notifications_permission_denied"
|
||||
messages.show(UiMessage(
|
||||
text: .resource(key),
|
||||
tone: .warning,
|
||||
actionLabel: permission == .denied ? .resource("button_open_settings") : nil,
|
||||
onAction: permission == .denied ? { self.openNotificationSettings() } : nil
|
||||
))
|
||||
if permission == .unsupported {
|
||||
messages.show(UiMessage(text: .resource(L10n.Notifications.unsupported), tone: .warning))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -200,12 +235,125 @@ final class SettingsModel: ObservableObject {
|
||||
Task {
|
||||
preferences.setDiagnosticsEnabled(enabled)
|
||||
messages.show(UiMessage(
|
||||
text: .resource(enabled ? "diagnostics_enabled_message" : "diagnostics_disabled_message"),
|
||||
text: .resource(enabled ? L10n.Diagnostics.enabledMessage : L10n.Diagnostics.disabledMessage),
|
||||
tone: .success
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
// 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 }
|
||||
@@ -219,11 +367,11 @@ final class SettingsModel: ObservableObject {
|
||||
let what = snapshot.bugWhatHappened.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let expected = snapshot.bugExpected.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if what.isEmpty {
|
||||
messages.show(UiMessage(text: .resource("bug_report_missing_what"), tone: .warning))
|
||||
messages.show(UiMessage(text: .resource(L10n.Bug.reportMissingWhat), tone: .warning))
|
||||
return
|
||||
}
|
||||
if expected.isEmpty {
|
||||
messages.show(UiMessage(text: .resource("bug_report_missing_expected"), tone: .warning))
|
||||
messages.show(UiMessage(text: .resource(L10n.Bug.reportMissingExpected), tone: .warning))
|
||||
return
|
||||
}
|
||||
state.isSubmittingBugReport = true
|
||||
@@ -242,58 +390,55 @@ final class SettingsModel: ObservableObject {
|
||||
state.bugSteps = ""
|
||||
state.bugContact = ""
|
||||
state.bugIncludeLogs = true
|
||||
messages.show(UiMessage(text: .resource("bug_report_submitted"), tone: .success))
|
||||
messages.show(UiMessage(text: .resource(L10n.Bug.reportSubmitted), tone: .success))
|
||||
onSuccess()
|
||||
case .failure:
|
||||
state.isSubmittingBugReport = false
|
||||
messages.show(UiMessage(text: .resource("bug_report_submit_failed"), tone: .error))
|
||||
messages.show(UiMessage(text: .resource(L10n.Bug.reportSubmitFailed), tone: .error))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func openNotificationSettings() {
|
||||
Task {
|
||||
enableNotificationsAfterSettings = true
|
||||
let result = await notifications.openSettings()
|
||||
if case .failure = result {
|
||||
enableNotificationsAfterSettings = false
|
||||
messages.show(UiMessage(text: .resource("notifications_settings_open_failed"), tone: .error))
|
||||
if case .failure = await notifications.openSettings() {
|
||||
messages.show(UiMessage(text: .resource(L10n.Notifications.settingsOpenFailed), tone: .error))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Re-read the OS permission (called on appear and when returning to the
|
||||
/// foreground, e.g. after a trip to Settings) so the toggle stays in sync.
|
||||
func refreshNotificationPermission() {
|
||||
Task {
|
||||
let permission = await notifications.refreshPermission()
|
||||
state.notificationPermission = permission
|
||||
if enableNotificationsAfterSettings {
|
||||
enableNotificationsAfterSettings = false
|
||||
if permission == .granted { await enableNotifications() }
|
||||
} else if permission != .granted && state.notificationsEnabled {
|
||||
preferences.setNotificationsEnabled(false)
|
||||
notifications.cancelAll()
|
||||
state.notificationPermission = await notifications.refreshPermission()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func enableNotifications() async {
|
||||
preferences.setNotificationsEnabled(true)
|
||||
messages.show(UiMessage(text: .resource("notifications_enabled_message"), tone: .success))
|
||||
}
|
||||
|
||||
// MARK: - Storage
|
||||
|
||||
/// Recomputes the on-disk usage breakdown off the main actor.
|
||||
/// Recomputes the on-disk usage breakdown off the main actor. Safe to call
|
||||
/// before the core is ready: it keeps the spinner up and waits for the core to
|
||||
/// finish initializing (it starts asynchronously at launch) rather than bailing.
|
||||
func loadStorageUsage() {
|
||||
if state.isCalculatingStorage { return }
|
||||
state.isCalculatingStorage = true
|
||||
state.storageLoadFailed = false
|
||||
let tempDir = NSTemporaryDirectory()
|
||||
Task {
|
||||
// The core initializes asynchronously at launch; poll briefly so opening
|
||||
// Storage early doesn't leave the summary stuck.
|
||||
var attempts = 0
|
||||
while !repository.state.isInitialized && attempts < 100 {
|
||||
try? await Task.sleep(nanoseconds: 100_000_000)
|
||||
attempts += 1
|
||||
}
|
||||
let coreResult = await repository.storageUsage()
|
||||
let artifactsResult = await repository.receivedArtifacts()
|
||||
guard case .success(let core) = coreResult,
|
||||
case .success(let artifacts) = artifactsResult else {
|
||||
state.isCalculatingStorage = false
|
||||
state.storageLoadFailed = true
|
||||
return
|
||||
}
|
||||
let diskSizes = await Task.detached {
|
||||
@@ -328,13 +473,90 @@ final class SettingsModel: ObservableObject {
|
||||
state.isDeletingTransfers = false
|
||||
if failures == 0 {
|
||||
loadStorageUsage()
|
||||
messages.show(UiMessage(text: .resource("storage_transfers_deleted"), tone: .success))
|
||||
messages.show(UiMessage(text: .resource(L10n.Storage.transfersDeleted), tone: .success))
|
||||
} else {
|
||||
messages.error(InvitationError.message("Could not delete \(failures) transfer records"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Reclaims disk space the core's transfer deletion doesn't touch: the app's
|
||||
/// temporary directory (leftover picker/staging copies) and any stray `.Trash`
|
||||
/// folders that accumulate inside app-owned directories. Never touches received
|
||||
/// files, the core database, or user-chosen receive folders.
|
||||
func freeUpSpace() {
|
||||
if state.isCleaningStorage { return }
|
||||
// Purging staging while a transfer is mid-flight could break it.
|
||||
let hasActive = repository.state.transfers.contains {
|
||||
$0.status == .sharing || $0.status == .importing || $0.status == .receiving
|
||||
}
|
||||
if hasActive {
|
||||
messages.tryShow(UiMessage(text: .resource(L10n.Storage.cleanupBusy), tone: .warning))
|
||||
return
|
||||
}
|
||||
state.isCleaningStorage = true
|
||||
let tempDir = NSTemporaryDirectory()
|
||||
let dataDir = environment.defaultCoreDataDir
|
||||
// Only clean the receive folder's trash when it is app-owned (iOS fixed
|
||||
// Documents), never a user-chosen macOS folder like ~/Downloads.
|
||||
let receiveTrashRoot = fileSystemService.supportsCustomReceiveFolders ? nil : state.receiveFolder?.value
|
||||
Task {
|
||||
let freed = await Task.detached {
|
||||
SettingsModel.reclaimJunk(tempDir: tempDir, dataDir: dataDir, receiveTrashRoot: receiveTrashRoot)
|
||||
}.value
|
||||
state.isCleaningStorage = false
|
||||
loadStorageUsage()
|
||||
messages.show(UiMessage(
|
||||
text: .dynamic(L10n.Storage.cleanupFreed(size: formatBytes(freed))),
|
||||
tone: .success
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
/// Deletes temp-directory contents and `.Trash` folders under the given roots,
|
||||
/// returning the number of bytes reclaimed. Runs off the main actor.
|
||||
nonisolated static func reclaimJunk(tempDir: String, dataDir: String, receiveTrashRoot: String?) -> UInt64 {
|
||||
let fm = FileManager.default
|
||||
var freed: UInt64 = 0
|
||||
// Empty the temporary directory.
|
||||
if let entries = try? fm.contentsOfDirectory(atPath: tempDir) {
|
||||
for name in entries {
|
||||
let path = (tempDir as NSString).appendingPathComponent(name)
|
||||
freed += itemSize(path)
|
||||
try? fm.removeItem(atPath: path)
|
||||
}
|
||||
}
|
||||
// Remove stray `.Trash` folders inside app-owned directories.
|
||||
for root in [dataDir, receiveTrashRoot].compactMap({ $0 }) {
|
||||
for trash in trashDirectories(under: root) {
|
||||
freed += directorySize(trash)
|
||||
try? fm.removeItem(atPath: trash)
|
||||
}
|
||||
}
|
||||
return freed
|
||||
}
|
||||
|
||||
/// Paths of every directory named `.Trash` under `root` (not descending into them).
|
||||
private nonisolated static func trashDirectories(under root: String) -> [String] {
|
||||
let url = URL(fileURLWithPath: root, isDirectory: true)
|
||||
guard let enumerator = FileManager.default.enumerator(
|
||||
at: url, includingPropertiesForKeys: [.isDirectoryKey]
|
||||
) else { return [] }
|
||||
var result: [String] = []
|
||||
for case let fileURL as URL in enumerator where fileURL.lastPathComponent == ".Trash" {
|
||||
result.append(fileURL.path)
|
||||
enumerator.skipDescendants()
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/// Allocated size of a file or directory (0 if missing).
|
||||
private nonisolated static func itemSize(_ path: String) -> UInt64 {
|
||||
var isDirectory: ObjCBool = false
|
||||
guard FileManager.default.fileExists(atPath: path, isDirectory: &isDirectory) else { return 0 }
|
||||
return isDirectory.boolValue ? directorySize(path) : fileSize(path)
|
||||
}
|
||||
|
||||
nonisolated static func fileSize(_ path: String) -> UInt64 {
|
||||
let values = try? URL(fileURLWithPath: path).resourceValues(
|
||||
forKeys: [.isRegularFileKey, .totalFileAllocatedSizeKey, .fileSizeKey]
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import SwiftUI
|
||||
import SFSafeSymbols
|
||||
|
||||
/// Settings screen, rebuilt on a native `Form` with `NavigationStack` push
|
||||
/// navigation. The model stays the source of truth via a derived path binding.
|
||||
@@ -25,26 +26,37 @@ struct SettingsScreen: View {
|
||||
Form {
|
||||
Section {
|
||||
NavigationLink(value: SettingsSection.preferences) {
|
||||
SettingsRow(icon: "person.crop.circle", title: String(localized: "preferences_title"), value: model.state.username)
|
||||
SettingsRow(icon: .personCropCircle, title: String(localized: L10n.Preferences.title), value: model.state.username)
|
||||
}
|
||||
NavigationLink(value: SettingsSection.appearance) {
|
||||
SettingsRow(icon: "sun.max", title: String(localized: "appearance_title"), value: themeModeLabel(model.state.themeMode))
|
||||
SettingsRow(icon: .sunMax, title: String(localized: L10n.Appearance.title), value: themeModeLabel(model.state.themeMode))
|
||||
}
|
||||
}
|
||||
Section {
|
||||
NavigationLink(value: SettingsSection.notifications) {
|
||||
SettingsRow(icon: "bell", title: String(localized: "notifications_title"), value: nil)
|
||||
SettingsRow(icon: .bell, title: String(localized: L10n.Notifications.title), value: nil)
|
||||
}
|
||||
NavigationLink(value: SettingsSection.storage) {
|
||||
SettingsRow(icon: "internaldrive", title: String(localized: "storage_title"), value: nil)
|
||||
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: "info.circle", title: String(localized: "about_title"), value: nil)
|
||||
SettingsRow(icon: .infoCircle, title: String(localized: L10n.About.title), value: nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
.navigationTitle(Text(LocalizedStringKey("settings_title")))
|
||||
.navigationTitle(Text(String(localized: L10n.Settings.title)))
|
||||
.navigationDestination(for: SettingsSection.self) { section in
|
||||
sectionForm(section)
|
||||
}
|
||||
@@ -57,7 +69,7 @@ struct SettingsScreen: View {
|
||||
SettingsSectionContent(model: model, section: section)
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
.navigationTitle(Text(LocalizedStringKey(section.titleKey)))
|
||||
.navigationTitle(Text(String(localized: section.titleKey)))
|
||||
|
||||
if section == .about {
|
||||
content
|
||||
@@ -69,7 +81,7 @@ struct SettingsScreen: View {
|
||||
Button {
|
||||
showBugReport = true
|
||||
} label: {
|
||||
Label(String(localized: "about_bug_report"), systemImage: "ladybug")
|
||||
Label(String(localized: L10n.About.bugReport), systemSymbol: .ladybug)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -99,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:
|
||||
@@ -109,14 +123,32 @@ 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: String
|
||||
let icon: SFSymbol
|
||||
let title: String
|
||||
let value: String?
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: icon)
|
||||
Image(systemSymbol: icon)
|
||||
.foregroundStyle(.tint)
|
||||
.frame(width: 26)
|
||||
Text(title).foregroundStyle(.primary)
|
||||
@@ -130,8 +162,8 @@ struct SettingsRow: View {
|
||||
|
||||
func themeModeLabel(_ mode: ThemeMode) -> String {
|
||||
switch mode {
|
||||
case .system: return String(localized: "appearance_system_mode")
|
||||
case .light: return String(localized: "appearance_light_mode")
|
||||
case .dark: return String(localized: "appearance_dark_mode")
|
||||
case .system: return String(localized: L10n.Appearance.systemMode)
|
||||
case .light: return String(localized: L10n.Appearance.lightMode)
|
||||
case .dark: return String(localized: L10n.Appearance.darkMode)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import SwiftUI
|
||||
import SFSafeSymbols
|
||||
|
||||
/// Settings section detail views, rebuilt as native `Form` content. Each view is
|
||||
/// placed inside a parent `Form`, so it returns `Section`s / rows directly.
|
||||
@@ -7,16 +8,26 @@ struct PreferencesSettings: View {
|
||||
@ObservedObject var model: SettingsModel
|
||||
|
||||
var body: some View {
|
||||
Section(String(localized: "field_username")) {
|
||||
TextField(String(localized: "field_username"),
|
||||
Section(String(localized: L10n.Field.username)) {
|
||||
TextField(String(localized: L10n.Field.username),
|
||||
text: Binding(get: { model.state.username }, set: { model.setUsername($0) }))
|
||||
}
|
||||
if model.state.supportsCustomReceiveFolders {
|
||||
Section(String(localized: "preferences_receive_folder_title")) {
|
||||
Text(model.state.receiveFolder?.displayName ?? String(localized: "value_unavailable"))
|
||||
.foregroundStyle(.secondary)
|
||||
Button(String(localized: "button_choose_folder"), action: model.chooseReceiveFolder)
|
||||
Button(String(localized: "button_reset_default"), action: model.resetReceiveFolder)
|
||||
Section(String(localized: L10n.Preferences.receiveFolderTitle)) {
|
||||
LabeledContent {
|
||||
Button(String(localized: L10n.Button.chooseFolder), action: model.chooseReceiveFolder)
|
||||
} label: {
|
||||
Label {
|
||||
Text(model.state.receiveFolder?.displayName ?? String(localized: L10n.Value.unavailable))
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
} icon: {
|
||||
Image(systemSymbol: .folder)
|
||||
}
|
||||
}
|
||||
if !model.isUsingDefaultReceiveFolder {
|
||||
Button(String(localized: L10n.Button.resetDefault), role: .cancel, action: model.resetReceiveFolder)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -27,7 +38,7 @@ struct AppearanceSettings: View {
|
||||
|
||||
var body: some View {
|
||||
Section {
|
||||
Picker(String(localized: "appearance_title"),
|
||||
Picker(String(localized: L10n.Appearance.title),
|
||||
selection: Binding(get: { model.state.themeMode }, set: { model.setThemeMode($0) })) {
|
||||
ForEach(ThemeMode.allCases, id: \.self) { mode in
|
||||
Text(themeModeLabel(mode)).tag(mode)
|
||||
@@ -44,16 +55,189 @@ struct NotificationSettings: View {
|
||||
|
||||
var body: some View {
|
||||
Section {
|
||||
Toggle(isOn: Binding(
|
||||
get: { model.state.notificationsEnabled },
|
||||
set: { model.setNotificationsEnabled($0) }
|
||||
)) {
|
||||
Text(LocalizedStringKey("notifications_local_title"))
|
||||
}
|
||||
if model.state.notificationPermission == .denied {
|
||||
Button(String(localized: "button_open_settings"), action: model.openNotificationSettings)
|
||||
Text(String(localized: L10n.Notifications.description)).foregroundStyle(.secondary)
|
||||
switch model.state.notificationPermission {
|
||||
case .notDetermined:
|
||||
Button(String(localized: L10n.Notifications.localTitle), action: model.requestNotifications)
|
||||
case .granted:
|
||||
// Allowed — the OS Settings app is where you disable or fine-tune.
|
||||
Text(String(localized: L10n.Notifications.enabledMessage)).foregroundStyle(.secondary)
|
||||
Button(String(localized: L10n.Button.openSettings), action: model.openNotificationSettings)
|
||||
case .denied:
|
||||
Text(String(localized: L10n.Notifications.permissionDenied)).foregroundStyle(.secondary)
|
||||
Button(String(localized: L10n.Button.openSettings), action: model.openNotificationSettings)
|
||||
case .unsupported:
|
||||
Text(String(localized: L10n.Notifications.unsupported)).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.onAppear { model.refreshNotificationPermission() }
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,57 +245,131 @@ struct StorageSettings: View {
|
||||
@ObservedObject var model: SettingsModel
|
||||
@State private var showDeleteConfirmation = false
|
||||
|
||||
private var isBusy: Bool {
|
||||
model.state.isCalculatingStorage || model.state.isCleaningStorage || model.state.isDeletingTransfers
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Section {
|
||||
if let storage = model.state.storage {
|
||||
LabeledContent(String(localized: "storage_received_files"), value: formatBytes(storage.receivedFiles))
|
||||
LabeledContent(String(localized: "storage_transfer_data"), value: formatBytes(storage.transferCache))
|
||||
LabeledContent(String(localized: "storage_app_data"), value: formatBytes(storage.appData))
|
||||
LabeledContent(String(localized: "storage_temporary"), value: formatBytes(storage.temporary))
|
||||
LabeledContent(String(localized: "storage_total")) {
|
||||
Text(formatBytes(storage.total)).fontWeight(.semibold)
|
||||
}
|
||||
} else {
|
||||
usageContent
|
||||
} header: {
|
||||
HStack {
|
||||
Text(LocalizedStringKey("storage_calculating")).foregroundStyle(.secondary)
|
||||
Text(String(localized: L10n.Storage.usageHeader))
|
||||
Spacer()
|
||||
ProgressView()
|
||||
if model.state.isCalculatingStorage {
|
||||
ProgressView().controlSize(.small)
|
||||
} else {
|
||||
Button(action: model.loadStorageUsage) {
|
||||
Label(String(localized: L10n.Storage.refresh), systemSymbol: .arrowClockwise)
|
||||
.labelStyle(.iconOnly)
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
.disabled(isBusy)
|
||||
.help(String(localized: L10n.Storage.refresh))
|
||||
}
|
||||
}
|
||||
} footer: {
|
||||
Text(LocalizedStringKey("storage_footer"))
|
||||
Text(String(localized: L10n.Storage.footer))
|
||||
}
|
||||
|
||||
// Reclaim reversible junk (temp + trash) — non-destructive to history.
|
||||
Section {
|
||||
Button(role: .destructive) {
|
||||
Button(action: model.freeUpSpace) {
|
||||
actionLabel(
|
||||
title: L10n.Storage.freeUpSpace,
|
||||
busyTitle: L10n.Storage.cleaning,
|
||||
isBusy: model.state.isCleaningStorage,
|
||||
symbol: .sparkles,
|
||||
tint: .accentColor
|
||||
)
|
||||
}
|
||||
// `.plain` so pressing the row dims the label instead of flipping it to
|
||||
// the white selection-highlight that the default form button style uses.
|
||||
.buttonStyle(.plain)
|
||||
.disabled(isBusy)
|
||||
} footer: {
|
||||
Text(String(localized: L10n.Storage.freeUpSpaceCaption))
|
||||
}
|
||||
|
||||
// Destructive: clears transfer history + cached share content.
|
||||
Section {
|
||||
Button {
|
||||
showDeleteConfirmation = true
|
||||
} label: {
|
||||
HStack {
|
||||
Text(model.state.isDeletingTransfers
|
||||
? String(localized: "storage_deleting")
|
||||
: String(localized: "storage_delete_transfers"))
|
||||
if model.state.isDeletingTransfers {
|
||||
Spacer()
|
||||
ProgressView()
|
||||
actionLabel(
|
||||
title: L10n.Storage.deleteTransfers,
|
||||
busyTitle: L10n.Storage.deleting,
|
||||
isBusy: model.state.isDeletingTransfers,
|
||||
symbol: .trash,
|
||||
tint: .red
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(isBusy)
|
||||
} footer: {
|
||||
Text(String(localized: L10n.Storage.deleteTransfersCaption))
|
||||
}
|
||||
}
|
||||
.disabled(model.state.isDeletingTransfers)
|
||||
}
|
||||
.onAppear { model.loadStorageUsage() }
|
||||
.task { model.loadStorageUsage() }
|
||||
.confirmationDialog(
|
||||
Text(LocalizedStringKey("storage_delete_transfers")),
|
||||
Text(String(localized: L10n.Storage.deleteTransfers)),
|
||||
isPresented: $showDeleteConfirmation,
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
Button(String(localized: "storage_delete_transfers"), role: .destructive) {
|
||||
Button(String(localized: L10n.Storage.deleteTransfers), role: .destructive) {
|
||||
model.deleteAllTransfers()
|
||||
}
|
||||
Button(String(localized: "button_cancel"), role: .cancel) {}
|
||||
Button(String(localized: L10n.Button.cancel), role: .cancel) {}
|
||||
} message: {
|
||||
Text(LocalizedStringKey("storage_delete_transfers_description"))
|
||||
Text(String(localized: L10n.Storage.deleteTransfersDescription))
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var usageContent: some View {
|
||||
if let storage = model.state.storage {
|
||||
LabeledContent(String(localized: L10n.Storage.receivedFiles), value: formatBytes(storage.receivedFiles))
|
||||
LabeledContent(String(localized: L10n.Storage.transferData), value: formatBytes(storage.transferCache))
|
||||
LabeledContent(String(localized: L10n.Storage.appData), value: formatBytes(storage.appData))
|
||||
LabeledContent(String(localized: L10n.Storage.temporary), value: formatBytes(storage.temporary))
|
||||
LabeledContent(String(localized: L10n.Storage.total)) {
|
||||
Text(formatBytes(storage.total)).fontWeight(.semibold)
|
||||
}
|
||||
} else if model.state.storageLoadFailed {
|
||||
// Genuine failure (core reported an error) — offer a retry.
|
||||
Button(action: model.loadStorageUsage) {
|
||||
Label(String(localized: L10n.Storage.unavailable), systemSymbol: .arrowClockwise)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
} else {
|
||||
// Loading, or waiting for the core to finish starting.
|
||||
HStack {
|
||||
Text(String(localized: L10n.Storage.calculating)).foregroundStyle(.secondary)
|
||||
Spacer()
|
||||
ProgressView().controlSize(.small)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A tinted, full-width button label with a leading symbol and a trailing
|
||||
/// spinner while busy. `.contentShape` keeps the whole row tappable.
|
||||
private func actionLabel(
|
||||
title: String.LocalizationValue,
|
||||
busyTitle: String.LocalizationValue,
|
||||
isBusy: Bool,
|
||||
symbol: SFSymbol,
|
||||
tint: Color
|
||||
) -> some View {
|
||||
HStack {
|
||||
Label(String(localized: isBusy ? busyTitle : title), systemSymbol: symbol)
|
||||
Spacer()
|
||||
if isBusy {
|
||||
ProgressView().controlSize(.small)
|
||||
}
|
||||
}
|
||||
.foregroundStyle(tint)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
}
|
||||
|
||||
struct AboutSettings: View {
|
||||
@@ -121,40 +379,40 @@ struct AboutSettings: View {
|
||||
|
||||
var body: some View {
|
||||
Section {
|
||||
Text(LocalizedStringKey("about_tagline")).font(.headline)
|
||||
Text(LocalizedStringKey("about_description")).foregroundStyle(.secondary)
|
||||
Text(String(localized: L10n.About.tagline)).font(.headline)
|
||||
Text(String(localized: L10n.About.description)).foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Section(String(localized: "about_is_title")) {
|
||||
AboutPoint("about_is_direct", "paperplane")
|
||||
AboutPoint("about_is_no_account", "person.crop.circle.badge.xmark")
|
||||
AboutPoint("about_is_in_control", "checkmark.shield")
|
||||
AboutPoint("about_is_encrypted", "lock")
|
||||
AboutPoint("about_is_open", "chevron.left.forwardslash.chevron.right")
|
||||
Section(String(localized: L10n.About.isTitle)) {
|
||||
AboutPoint(L10n.About.isDirect, .paperplane)
|
||||
AboutPoint(L10n.About.isNoAccount, .personCropCircleBadgeXmark)
|
||||
AboutPoint(L10n.About.isInControl, .checkmarkShield)
|
||||
AboutPoint(L10n.About.isEncrypted, .lock)
|
||||
AboutPoint(L10n.About.isOpen, .chevronLeftForwardslashChevronRight)
|
||||
}
|
||||
|
||||
Section(String(localized: "about_isnt_title")) {
|
||||
AboutPoint("about_isnt_cloud", "icloud.slash")
|
||||
AboutPoint("about_isnt_sync", "arrow.triangle.2.circlepath")
|
||||
AboutPoint("about_isnt_public", "megaphone")
|
||||
Section(String(localized: L10n.About.isntTitle)) {
|
||||
AboutPoint(L10n.About.isntCloud, .icloudSlash)
|
||||
AboutPoint(L10n.About.isntSync, .arrowTriangle2Circlepath)
|
||||
AboutPoint(L10n.About.isntPublic, .megaphone)
|
||||
}
|
||||
|
||||
Section(String(localized: "about_privacy_title")) {
|
||||
AboutPoint("about_privacy_capability", "qrcode")
|
||||
AboutPoint("about_privacy_deny", "hand.raised")
|
||||
AboutPoint("about_privacy_relay", "antenna.radiowaves.left.and.right")
|
||||
AboutPoint("about_privacy_local", "internaldrive")
|
||||
Section(String(localized: L10n.About.privacyTitle)) {
|
||||
AboutPoint(L10n.About.privacyCapability, .qrcode)
|
||||
AboutPoint(L10n.About.privacyDeny, .handRaised)
|
||||
AboutPoint(L10n.About.privacyRelay, .antennaRadiowavesLeftAndRight)
|
||||
AboutPoint(L10n.About.privacyLocal, .internaldrive)
|
||||
}
|
||||
|
||||
Section(String(localized: "about_title")) {
|
||||
LabeledContent(String(localized: "version_title"), value: model.state.appVersion)
|
||||
Section(String(localized: L10n.About.title)) {
|
||||
LabeledContent(String(localized: L10n.Version.title), value: model.state.appVersion)
|
||||
if let device = model.state.deviceInfo {
|
||||
LabeledContent(String(localized: "device_model_title"), value: device.deviceModel ?? "—")
|
||||
LabeledContent(String(localized: "os_version_title"), value: device.operatingSystem)
|
||||
LabeledContent(String(localized: L10n.Device.modelTitle), value: device.deviceModel ?? "—")
|
||||
LabeledContent(String(localized: L10n.Os.versionTitle), value: device.operatingSystem)
|
||||
}
|
||||
LabeledContent(String(localized: "about_license_label"), value: "Apache 2.0")
|
||||
LabeledContent(String(localized: L10n.About.licenseLabel), value: "Apache 2.0")
|
||||
Link(destination: Self.privacyPolicyURL) {
|
||||
Label(String(localized: "about_privacy_policy_label"), systemImage: "hand.raised")
|
||||
Label(String(localized: L10n.About.privacyPolicyLabel), systemSymbol: .handRaised)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -164,7 +422,7 @@ struct AboutSettings: View {
|
||||
get: { model.state.diagnosticsEnabled },
|
||||
set: { model.setDiagnosticsEnabled($0) }
|
||||
)) {
|
||||
Text(LocalizedStringKey("diagnostics_title"))
|
||||
Text(String(localized: L10n.Diagnostics.title))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -188,13 +446,13 @@ struct BugReportSheet: View {
|
||||
BugReportSettings(model: model, onSubmitted: { dismiss() })
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
.navigationTitle(Text(LocalizedStringKey("about_bug_report")))
|
||||
.navigationTitle(Text(String(localized: L10n.About.bugReport)))
|
||||
#if os(iOS)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
#endif
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button(String(localized: "button_cancel")) { dismiss() }
|
||||
Button(String(localized: L10n.Button.cancel)) { dismiss() }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -204,21 +462,21 @@ struct BugReportSheet: View {
|
||||
|
||||
/// A bullet-style informational row with an SF Symbol and wrapping localized text.
|
||||
private struct AboutPoint: View {
|
||||
let key: String
|
||||
let symbol: String
|
||||
let key: String.LocalizationValue
|
||||
let symbol: SFSymbol
|
||||
|
||||
init(_ key: String, _ symbol: String) {
|
||||
init(_ key: String.LocalizationValue, _ symbol: SFSymbol) {
|
||||
self.key = key
|
||||
self.symbol = symbol
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Label {
|
||||
Text(LocalizedStringKey(key))
|
||||
Text(String(localized: key))
|
||||
.font(.subheadline)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
} icon: {
|
||||
Image(systemName: symbol).foregroundStyle(.tint)
|
||||
Image(systemSymbol: symbol).foregroundStyle(.tint)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -228,36 +486,36 @@ struct BugReportSettings: View {
|
||||
var onSubmitted: () -> Void = {}
|
||||
|
||||
var body: some View {
|
||||
Section(String(localized: "bug_report_what_label")) {
|
||||
Section(String(localized: L10n.Bug.reportWhatLabel)) {
|
||||
TextField("", text: Binding(get: { model.state.bugWhatHappened }, set: { model.setBugWhatHappened($0) }),
|
||||
prompt: Text(LocalizedStringKey("bug_report_what_hint")), axis: .vertical)
|
||||
prompt: Text(String(localized: L10n.Bug.reportWhatHint)), axis: .vertical)
|
||||
.lineLimit(3, reservesSpace: true)
|
||||
.labelsHidden()
|
||||
}
|
||||
Section(String(localized: "bug_report_expected_label")) {
|
||||
Section(String(localized: L10n.Bug.reportExpectedLabel)) {
|
||||
TextField("", text: Binding(get: { model.state.bugExpected }, set: { model.setBugExpected($0) }),
|
||||
prompt: Text(LocalizedStringKey("bug_report_expected_hint")), axis: .vertical)
|
||||
prompt: Text(String(localized: L10n.Bug.reportExpectedHint)), axis: .vertical)
|
||||
.lineLimit(3, reservesSpace: true)
|
||||
.labelsHidden()
|
||||
}
|
||||
Section(String(localized: "bug_report_steps_label")) {
|
||||
Section(String(localized: L10n.Bug.reportStepsLabel)) {
|
||||
TextField("", text: Binding(get: { model.state.bugSteps }, set: { model.setBugSteps($0) }),
|
||||
prompt: Text(LocalizedStringKey("bug_report_steps_hint")), axis: .vertical)
|
||||
prompt: Text(String(localized: L10n.Bug.reportStepsHint)), axis: .vertical)
|
||||
.lineLimit(3, reservesSpace: true)
|
||||
.labelsHidden()
|
||||
}
|
||||
Section(String(localized: "bug_report_contact_label")) {
|
||||
Section(String(localized: L10n.Bug.reportContactLabel)) {
|
||||
TextField("", text: Binding(get: { model.state.bugContact }, set: { model.setBugContact($0) }),
|
||||
prompt: Text(LocalizedStringKey("bug_report_contact_hint")))
|
||||
prompt: Text(String(localized: L10n.Bug.reportContactHint)))
|
||||
.labelsHidden()
|
||||
}
|
||||
Section {
|
||||
Toggle(isOn: Binding(get: { model.state.bugIncludeLogs }, set: { model.setBugIncludeLogs($0) })) {
|
||||
Text(LocalizedStringKey("bug_report_include_logs"))
|
||||
Text(String(localized: L10n.Bug.reportIncludeLogs))
|
||||
}
|
||||
Button(action: { model.submitBugReport(onSuccess: onSubmitted) }) {
|
||||
Text(model.state.isSubmittingBugReport
|
||||
? String(localized: "bug_report_submitting") : String(localized: "bug_report_submit"))
|
||||
? String(localized: L10n.Bug.reportSubmitting) : String(localized: L10n.Bug.reportSubmit))
|
||||
}
|
||||
.disabled(model.state.isSubmittingBugReport)
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ private struct IosDeviceInfoProvider: DeviceInfoProvider {
|
||||
device.isBatteryMonitoringEnabled = true
|
||||
defer { device.isBatteryMonitoringEnabled = wasMonitoring }
|
||||
let level = device.batteryLevel
|
||||
return level >= 0 ? "\(Int(level * 100))%" : nil
|
||||
return level >= 0 ? L10n.Battery.levelValue(level: "\(Int(level * 100))") : nil
|
||||
}()
|
||||
return DeviceInfo(
|
||||
deviceName: device.name,
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"colors" : [
|
||||
{
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0.9685",
|
||||
"green" : "0.3315",
|
||||
"red" : "0.6606"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,10 @@
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>VniDrop</string>
|
||||
<!-- macOS: a single instance only; re-launching activates the running app
|
||||
instead of spawning another copy. -->
|
||||
<key>LSMultipleInstancesProhibited</key>
|
||||
<true/>
|
||||
<key>CFBundleDocumentTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
@@ -63,6 +67,10 @@
|
||||
<string>VniDrop uses the camera to scan transfer QR codes.</string>
|
||||
<key>NSLocalNetworkUsageDescription</key>
|
||||
<string>VniDrop needs local network access to send to other local devices if needed.</string>
|
||||
<!-- iPadOS: a single scene only — no second window via Stage Manager / split
|
||||
view. Mirrors the single-window macOS behavior. -->
|
||||
<key>UIApplicationSupportsMultipleScenes</key>
|
||||
<false/>
|
||||
<key>UIBackgroundModes</key>
|
||||
<array>
|
||||
<string>fetch</string>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -41,7 +41,7 @@ private struct SheetChrome<Content: View>: View {
|
||||
ScrollView { content().padding(.top, 4) }
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button(String(localized: "button_close"), action: onClose)
|
||||
Button(String(localized: L10n.Button.close), action: onClose)
|
||||
}
|
||||
}
|
||||
#if os(iOS)
|
||||
|
||||
@@ -30,7 +30,7 @@ struct StatusPill: View {
|
||||
// MARK: - ProgressRow
|
||||
|
||||
struct ProgressRow: View {
|
||||
let labelKey: String
|
||||
let labelKey: String.LocalizationValue
|
||||
let progress: Double?
|
||||
var detail: String? = nil
|
||||
/// Pre-resolved label; when set it overrides `labelKey`.
|
||||
@@ -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 {
|
||||
@@ -62,7 +62,7 @@ struct ProgressRow: View {
|
||||
if let labelText {
|
||||
Text(labelText)
|
||||
} else {
|
||||
Text(LocalizedStringKey(labelKey))
|
||||
Text(String(localized: labelKey))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import SwiftUI
|
||||
import SFSafeSymbols
|
||||
|
||||
/// Bottom toast host driven by `UiMessageController`, ported from
|
||||
/// `ui/feedback/VniDropSnackbarHost.kt`. Tone drives the accent color; errors get
|
||||
@@ -52,7 +53,7 @@ struct SnackbarHost: View {
|
||||
.buttonStyle(.borderless)
|
||||
}
|
||||
Button(action: dismiss) {
|
||||
Image(systemName: "xmark")
|
||||
Image(systemSymbol: .xmark)
|
||||
.font(.footnote.weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: 36, height: 36)
|
||||
|
||||
@@ -4,14 +4,14 @@ import Combine
|
||||
/// A localizable UI string: either a catalog key or dynamic text, ported from
|
||||
/// `UiText` in `ui/feedback/UiMessageController.kt`.
|
||||
enum UiText: Equatable {
|
||||
case resource(String) // Localizable.xcstrings key
|
||||
case resource(String.LocalizationValue) // Localizable.xcstrings key (use L10n.*)
|
||||
case dynamic(String)
|
||||
|
||||
/// Resolves to display text. Keys go through the string catalog.
|
||||
func resolved() -> String {
|
||||
switch self {
|
||||
case .dynamic(let value): return value
|
||||
case .resource(let key): return String(localized: String.LocalizationValue(key))
|
||||
case .resource(let value): return String(localized: value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,34 +8,34 @@ extension Error {
|
||||
if let vni = self as? VnidropError {
|
||||
switch vni {
|
||||
case .Ticket:
|
||||
return .resource("error_invalid_ticket")
|
||||
return .resource(L10n.Error.invalidTicket)
|
||||
case .Permission:
|
||||
return .resource("error_permission")
|
||||
return .resource(L10n.Error.permission)
|
||||
case .Filesystem:
|
||||
return .resource("error_filesystem")
|
||||
return .resource(L10n.Error.filesystem)
|
||||
case .FilesystemPermission:
|
||||
return .resource("error_filesystem")
|
||||
return .resource(L10n.Error.filesystem)
|
||||
case .DestinationExists:
|
||||
return .resource("error_destination_exists")
|
||||
return .resource(L10n.Error.destinationExists)
|
||||
case .StorageFull:
|
||||
return .resource("error_storage_full")
|
||||
return .resource(L10n.Error.storageFull)
|
||||
case .Network:
|
||||
return .resource("error_network")
|
||||
return .resource(L10n.Error.network)
|
||||
case .Transfer(let reason):
|
||||
return transferUiText(reason)
|
||||
case .Repository:
|
||||
return .resource("error_repository")
|
||||
return .resource(L10n.Error.repository)
|
||||
case .Cancelled:
|
||||
return .resource("error_generic")
|
||||
return .resource(L10n.Error.generic)
|
||||
case .InvalidInput:
|
||||
return .resource("error_invalid_input")
|
||||
return .resource(L10n.Error.invalidInput)
|
||||
case .Initialization(let reason):
|
||||
return initializationUiText(reason)
|
||||
case .Internal(let reason):
|
||||
return reasonHints(reason) ?? .resource("error_generic")
|
||||
return reasonHints(reason) ?? .resource(L10n.Error.generic)
|
||||
}
|
||||
}
|
||||
return reasonHints(technicalDetail) ?? .resource("error_generic")
|
||||
return reasonHints(technicalDetail) ?? .resource(L10n.Error.generic)
|
||||
}
|
||||
|
||||
/// True when the user intentionally backed out of a flow.
|
||||
@@ -76,23 +76,56 @@ 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") {
|
||||
return .resource("error_permission")
|
||||
return .resource(L10n.Error.permission)
|
||||
}
|
||||
return .resource("error_transfer")
|
||||
return .resource(L10n.Error.transfer)
|
||||
}
|
||||
|
||||
private func initializationUiText(_ reason: String) -> UiText {
|
||||
let detail = reason.lowercased()
|
||||
if detail.contains("native") && detail.contains("library") {
|
||||
return .resource("error_missing_native_library")
|
||||
return .resource(L10n.Error.missingNativeLibrary)
|
||||
}
|
||||
if detail.contains("socket") || detail.contains("bind") {
|
||||
return .resource("error_socket_bind")
|
||||
return .resource(L10n.Error.socketBind)
|
||||
}
|
||||
return .resource("error_initialization")
|
||||
return .resource(L10n.Error.initialization)
|
||||
}
|
||||
|
||||
private func reasonHints(_ detailRaw: String) -> UiText? {
|
||||
@@ -100,50 +133,50 @@ private func reasonHints(_ detailRaw: String) -> UiText? {
|
||||
if detail.isEmpty { return nil }
|
||||
|
||||
if detail.contains("still starting") || detail.contains("starting up") {
|
||||
return .resource("error_starting_up")
|
||||
return .resource(L10n.Error.startingUp)
|
||||
}
|
||||
if detail.contains("empty") && (detail.contains("invitation") || detail.contains("ticket") || detail.contains("qr")) {
|
||||
return .resource("error_invitation_empty")
|
||||
return .resource(L10n.Error.invitationEmpty)
|
||||
}
|
||||
if detail.contains("select at least one") || detail.contains("no files found") {
|
||||
return .resource("error_share_empty")
|
||||
return .resource(L10n.Error.shareEmpty)
|
||||
}
|
||||
if detail.contains("camera") {
|
||||
return .resource("error_camera")
|
||||
return .resource(L10n.Error.camera)
|
||||
}
|
||||
if detail.contains("nfc") || detail.contains("ndef")
|
||||
|| (detail.contains("read-only") && detail.contains("tag"))
|
||||
|| detail.contains("tag is too small") || detail.contains("no nfc tag") {
|
||||
return .resource("error_nfc")
|
||||
return .resource(L10n.Error.nfc)
|
||||
}
|
||||
if detail.contains("native") && detail.contains("library") {
|
||||
return .resource("error_missing_native_library")
|
||||
return .resource(L10n.Error.missingNativeLibrary)
|
||||
}
|
||||
if detail.contains("socket") || detail.contains("bind") {
|
||||
return .resource("error_socket_bind")
|
||||
return .resource(L10n.Error.socketBind)
|
||||
}
|
||||
if detail.contains("device information") || detail.contains("device info") {
|
||||
return .resource("error_device_info")
|
||||
return .resource(L10n.Error.deviceInfo)
|
||||
}
|
||||
if detail.contains("refused") || detail.contains("denied") || detail.contains("permission")
|
||||
|| detail.contains("not approved") || detail.contains("waiting for approval") {
|
||||
return .resource("error_permission")
|
||||
return .resource(L10n.Error.permission)
|
||||
}
|
||||
if detail.contains("invalid ticket") || detail.contains("ticket error")
|
||||
|| detail.contains("could not be read") || detail.contains("malformed")
|
||||
|| detail.contains("invitation could not be opened") {
|
||||
return .resource("error_invalid_ticket")
|
||||
return .resource(L10n.Error.invalidTicket)
|
||||
}
|
||||
if detail.contains("selected")
|
||||
&& (detail.contains("file") || detail.contains("folder") || detail.contains("document") || detail.contains("open")) {
|
||||
return .resource("error_selection_failed")
|
||||
return .resource(L10n.Error.selectionFailed)
|
||||
}
|
||||
if detail.contains("could not open the selected") || detail.contains("could not open selected") {
|
||||
return .resource("error_selection_failed")
|
||||
return .resource(L10n.Error.selectionFailed)
|
||||
}
|
||||
if detail.contains("document picker") || detail.contains("folder picker") || detail.contains("file descriptor")
|
||||
|| detail.contains("view controller") {
|
||||
return .resource("error_selection_failed")
|
||||
return .resource(L10n.Error.selectionFailed)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import Foundation
|
||||
import SFSafeSymbols
|
||||
|
||||
/// Top-level destinations, ported from `ui/navigation/AppDestination.kt`.
|
||||
enum AppDestination: String, CaseIterable, Identifiable {
|
||||
@@ -8,20 +9,20 @@ enum AppDestination: String, CaseIterable, Identifiable {
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
var labelKey: String {
|
||||
var labelKey: String.LocalizationValue {
|
||||
switch self {
|
||||
case .send: return "nav_send"
|
||||
case .receive: return "nav_receive"
|
||||
case .settings: return "nav_settings"
|
||||
case .send: return L10n.Nav.send
|
||||
case .receive: return L10n.Nav.receive
|
||||
case .settings: return L10n.Nav.settings
|
||||
}
|
||||
}
|
||||
|
||||
/// SF Symbol approximating the Compose line icon.
|
||||
var systemImage: String {
|
||||
var systemSymbol: SFSymbol {
|
||||
switch self {
|
||||
case .send: return "paperplane"
|
||||
case .receive: return "tray.and.arrow.down"
|
||||
case .settings: return "gearshape"
|
||||
case .send: return .paperplane
|
||||
case .receive: return .trayAndArrowDown
|
||||
case .settings: return .gearshape
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,7 +52,9 @@ struct VniDropColors {
|
||||
}
|
||||
|
||||
extension VniDropColors {
|
||||
/// The single brand accent used app-wide as the SwiftUI tint.
|
||||
/// The single brand accent used app-wide as the SwiftUI tint. Mirrored by the
|
||||
/// `AccentColor` asset (the OS-level global accent for the macOS sidebar etc.);
|
||||
/// keep the two in sync.
|
||||
static let brandPurple = Color.hsl(271, 91, 65)
|
||||
|
||||
static let light = VniDropColors(
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
# XcodeGen spec for the native SwiftUI VniDrop app (iOS/iPadOS/macOS).
|
||||
# Regenerate the project with: xcodegen generate (run from apple/)
|
||||
# Requires the Rust core first: apple/scripts/build-core.sh debug
|
||||
# Requires two generated inputs first (both gitignored), before xcodegen:
|
||||
# - Rust core: apple/scripts/build-core.sh debug
|
||||
# - Localization: (cd localization && bun run src/cli.ts generate)
|
||||
# -> VniDrop/Resources/Localizable.xcstrings, VniDrop/Generated/L10n.swift
|
||||
name: VniDrop
|
||||
options:
|
||||
bundleIdPrefix: com.vnidrop
|
||||
@@ -9,9 +12,20 @@ options:
|
||||
macOS: "15.0"
|
||||
createIntermediateGroups: true
|
||||
|
||||
# Project-wide build settings (applied to every target/config).
|
||||
settings:
|
||||
base:
|
||||
# Strip unreachable code from release binaries.
|
||||
DEAD_CODE_STRIPPING: YES
|
||||
# Flag user-facing strings that aren't localized (the app ships 9 languages).
|
||||
CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED: YES
|
||||
|
||||
packages:
|
||||
VnidropCore:
|
||||
path: VnidropCore
|
||||
SFSafeSymbols:
|
||||
url: https://github.com/SFSafeSymbols/SFSafeSymbols
|
||||
from: "5.3.0"
|
||||
|
||||
targets:
|
||||
VniDrop:
|
||||
@@ -41,6 +55,9 @@ targets:
|
||||
SWIFT_STRICT_CONCURRENCY: complete
|
||||
ENABLE_USER_SCRIPT_SANDBOXING: NO
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
|
||||
# App-wide accent (macOS sidebar selection, default control tints). The
|
||||
# AccentColor asset mirrors VniDropColors.brandPurple — keep them in sync.
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME: AccentColor
|
||||
configs:
|
||||
debug:
|
||||
CODE_SIGN_ENTITLEMENTS: VniDrop/Resources/VniDrop.entitlements
|
||||
@@ -48,9 +65,25 @@ targets:
|
||||
CODE_SIGN_ENTITLEMENTS: VniDrop/Resources/VniDrop.entitlements
|
||||
dependencies:
|
||||
- package: VnidropCore
|
||||
- package: SFSafeSymbols
|
||||
- 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(),
|
||||
let sender_addr = match filter_peer_addr_for_relay_mode(
|
||||
&sender_addr,
|
||||
self.relay_mode,
|
||||
&self.custom_relay_urls,
|
||||
) {
|
||||
Ok(addr) => addr,
|
||||
Err(error) => {
|
||||
self.emit_transfer(
|
||||
pending.local_transfer_id,
|
||||
"receive",
|
||||
"delivery",
|
||||
"receipt-failed",
|
||||
json!({ "reason": error.to_string() }),
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
let client = HandshakeService::client(self.endpoint.clone(), sender_addr);
|
||||
let 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,
|
||||
};
|
||||
match tokio::time::timeout(DELIVERY_RECEIPT_TIMEOUT, client.report_delivery(receipt)).await
|
||||
{
|
||||
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)
|
||||
let endpoint = match relay_mode {
|
||||
CoreRelayMode::Automatic => {
|
||||
Endpoint::builder(presets::N0)
|
||||
.secret_key(secret_key)
|
||||
.bind()
|
||||
.await?;
|
||||
endpoint.online().await;
|
||||
.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,8 +343,7 @@ impl CoreInner {
|
||||
.cloned();
|
||||
let core = self.clone();
|
||||
tokio::spawn(async move {
|
||||
while let Ok(Some(update)) = rx.recv().await {
|
||||
match update {
|
||||
let outcome = consume_request_updates(rx, |update| match update {
|
||||
RequestUpdate::Started(started) => core.emit_transfer(
|
||||
transfer_id,
|
||||
"send",
|
||||
@@ -346,7 +370,8 @@ impl CoreInner {
|
||||
"end_offset": progress.end_offset,
|
||||
}),
|
||||
),
|
||||
RequestUpdate::Completed(_) => core.emit_transfer(
|
||||
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,7 +149,11 @@ impl CoreInner {
|
||||
import.file_count,
|
||||
import.total_size,
|
||||
);
|
||||
let ticket = VnidropTicket::new(blob_ticket, ticket_metadata)
|
||||
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();
|
||||
|
||||
@@ -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)
|
||||
}),
|
||||
}) {
|
||||
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"
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -5,8 +5,11 @@
|
||||
*/
|
||||
import { mkdirSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { mkdir } from "node:fs/promises";
|
||||
import { dirname } from "node:path";
|
||||
import {
|
||||
APPLE_INFO_PLIST,
|
||||
APPLE_L10N_SWIFT,
|
||||
APPLE_XCSTRINGS,
|
||||
kmpValuesDir,
|
||||
KMP_RESOURCES,
|
||||
@@ -14,6 +17,7 @@ import {
|
||||
} from "../config";
|
||||
import { renderAndroid, type ParsedAndroid } from "../lib/android-xml";
|
||||
import { fromCanonical, type Flavor } from "../lib/placeholders";
|
||||
import { renderSwiftAccessors } from "../lib/swift-accessors";
|
||||
import { renderXcstrings, type XcCatalog, type XcEntry } from "../lib/xcstrings";
|
||||
import { targetsOf, type StringEntry, type StringsFile } from "../types";
|
||||
|
||||
@@ -111,6 +115,10 @@ export async function generate() {
|
||||
await Bun.write(APPLE_XCSTRINGS, renderXcstrings(buildXcstrings(doc)));
|
||||
console.log(`Wrote ${APPLE_XCSTRINGS}`);
|
||||
|
||||
await mkdir(dirname(APPLE_L10N_SWIFT), { recursive: true });
|
||||
await Bun.write(APPLE_L10N_SWIFT, renderSwiftAccessors(doc));
|
||||
console.log(`Wrote ${APPLE_L10N_SWIFT}`);
|
||||
|
||||
await syncInfoPlistLocalizations(doc.supportedLanguages);
|
||||
|
||||
for (const lang of doc.supportedLanguages) {
|
||||
|
||||
@@ -18,6 +18,12 @@ export const APPLE_INFO_PLIST = join(
|
||||
"apple/VniDrop/Resources/Info.plist",
|
||||
);
|
||||
|
||||
/** Generated Swift accessors (`L10n`) for compile-time-checked catalog keys. */
|
||||
export const APPLE_L10N_SWIFT = join(
|
||||
REPO_ROOT,
|
||||
"apple/VniDrop/Generated/L10n.swift",
|
||||
);
|
||||
|
||||
/** KMP / Compose Multiplatform resources root; one values[-lang]/strings.xml per language. */
|
||||
export const KMP_RESOURCES = join(
|
||||
REPO_ROOT,
|
||||
|
||||
139
localization/src/lib/swift-accessors.ts
Normal file
139
localization/src/lib/swift-accessors.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* Swift accessor generator.
|
||||
*
|
||||
* Emits a compile-time-checked stand-in for each localization key so Apple call
|
||||
* sites stop passing raw string literals. The runtime path is unchanged: plain
|
||||
* keys become `String.LocalizationValue` constants used with `String(localized:)`
|
||||
* exactly as before, and Apple's catalog lookup still does 100% of the work.
|
||||
*
|
||||
* Keys group by their first `_`-delimited segment (`button_…` -> `enum Button`);
|
||||
* the remainder becomes a camelCase member. Plain keys are `static let`
|
||||
* constants; keys with args become `static func` with typed, named parameters
|
||||
* derived from the entry's `args` metadata.
|
||||
*/
|
||||
import { targetsOf, type Arg, type StringEntry, type StringsFile } from "../types";
|
||||
|
||||
const HEADER = `// Generated by localization/ (bun run src/cli.ts generate). Do not edit.
|
||||
// Keys resolve through Apple's String Catalog exactly as a literal would; these
|
||||
// accessors only make the key compile-time-checked. If a runtime language
|
||||
// switcher (live \`.environment(\\.locale)\`) is ever added, switch the plain
|
||||
// \`static let\` constants to computed \`static var\` so the locale is not frozen.
|
||||
import Foundation
|
||||
`;
|
||||
|
||||
/** Swift type for a localization arg. */
|
||||
function swiftType(arg: Arg): string {
|
||||
switch (arg.type) {
|
||||
case "int":
|
||||
return "Int";
|
||||
case "double":
|
||||
return "Double";
|
||||
case "string":
|
||||
return "String";
|
||||
}
|
||||
}
|
||||
|
||||
/** `create_new_transfer` -> `createNewTransfer`. */
|
||||
function camel(segment: string): string {
|
||||
const parts = segment.split("_").filter(Boolean);
|
||||
return parts
|
||||
.map((p, i) => (i === 0 ? p : p.charAt(0).toUpperCase() + p.slice(1)))
|
||||
.join("");
|
||||
}
|
||||
|
||||
/** `button` -> `Button`. */
|
||||
function pascal(segment: string): string {
|
||||
const c = camel(segment);
|
||||
return c.charAt(0).toUpperCase() + c.slice(1);
|
||||
}
|
||||
|
||||
/** A key is groupable only when it starts with an identifier-safe segment. */
|
||||
function isGroupableKey(key: string): boolean {
|
||||
return /^[A-Za-z][A-Za-z0-9_]*$/.test(key);
|
||||
}
|
||||
|
||||
/** Collapse whitespace/newlines so a value fits on one doc-comment line. */
|
||||
function oneLine(text: string): string {
|
||||
return text.replace(/\s*\n\s*/g, " ").trim();
|
||||
}
|
||||
|
||||
/** The displayable text for a language (plural shows its `other` form). */
|
||||
function translationText(entry: StringEntry, lang: string): string | undefined {
|
||||
return entry.translations?.[lang] ?? entry.plural?.[lang]?.other;
|
||||
}
|
||||
|
||||
/**
|
||||
* A rich Quick Help block: source-language text as the abstract, then the raw
|
||||
* key, the context note, and every translation. Quick Help renders the Markdown.
|
||||
*/
|
||||
function docComment(key: string, entry: StringEntry, doc: StringsFile): string {
|
||||
const lines: string[] = [];
|
||||
const source = translationText(entry, doc.sourceLanguage);
|
||||
if (source) lines.push(oneLine(source), "");
|
||||
|
||||
lines.push(`Key: \`${key}\``);
|
||||
if (entry.context) lines.push(`Context: ${oneLine(entry.context)}`);
|
||||
lines.push("");
|
||||
|
||||
for (const lang of doc.supportedLanguages) {
|
||||
const value = translationText(entry, lang);
|
||||
if (value !== undefined) lines.push(`- ${lang}: ${oneLine(value)}`);
|
||||
}
|
||||
|
||||
return lines
|
||||
.map((line) => (line ? ` /// ${line}` : " ///"))
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
function memberFor(
|
||||
key: string,
|
||||
member: string,
|
||||
entry: StringEntry,
|
||||
doc: StringsFile,
|
||||
): string {
|
||||
const comment = docComment(key, entry, doc);
|
||||
const args = entry.args ?? [];
|
||||
|
||||
// Plain key: a #define-style constant. Apple resolves it via String(localized:).
|
||||
if (args.length === 0 && !entry.plural) {
|
||||
return `${comment}\n static let ${member}: String.LocalizationValue = "${key}"`;
|
||||
}
|
||||
|
||||
// Arg'd (or plural) key: a typed, named function that applies the arguments
|
||||
// through the same String(format: String(localized:)) path used before.
|
||||
const params = args.map((a) => `${a.name}: ${swiftType(a)}`).join(", ");
|
||||
const callArgs = args.map((a) => a.name).join(", ");
|
||||
// The positional format string lives in the catalog; look it up by key and
|
||||
// apply the args exactly as the hand-written call sites did.
|
||||
return `${comment}\n static func ${member}(${params}) -> String {\n String(format: String(localized: "${key}"), ${callArgs})\n }`;
|
||||
}
|
||||
|
||||
export function renderSwiftAccessors(doc: StringsFile): string {
|
||||
// group segment -> rendered members
|
||||
const groups = new Map<string, string[]>();
|
||||
|
||||
for (const [key, entry] of Object.entries(doc.strings)) {
|
||||
if (!targetsOf(entry).includes("apple")) continue;
|
||||
if (!isGroupableKey(key)) continue; // skips legacy `%@` literal keys
|
||||
|
||||
const underscore = key.indexOf("_");
|
||||
const groupSeg = underscore === -1 ? key : key.slice(0, underscore);
|
||||
const memberSeg = underscore === -1 ? key : key.slice(underscore + 1);
|
||||
const group = pascal(groupSeg);
|
||||
const member = camel(memberSeg) || camel(groupSeg);
|
||||
|
||||
const list = groups.get(group) ?? [];
|
||||
list.push(memberFor(key, member, entry, doc));
|
||||
groups.set(group, list);
|
||||
}
|
||||
|
||||
const body = [...groups.keys()]
|
||||
.sort()
|
||||
.map((group) => {
|
||||
const members = groups.get(group)!.join("\n");
|
||||
return ` enum ${group} {\n${members}\n }`;
|
||||
})
|
||||
.join("\n");
|
||||
|
||||
return `${HEADER}\nenum L10n {\n${body}\n}\n`;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user