Compare commits
29 Commits
feat/scree
...
feat/devic
| Author | SHA1 | Date | |
|---|---|---|---|
| e740942f63 | |||
| eefedd0cb0 | |||
| 429987785e | |||
| 931b297321 | |||
| 1cd09a2ec3 | |||
| b128457137 | |||
| 564b86c28c | |||
| 0ec0daef2a | |||
| c852a68f28 | |||
| 225ff9ad22 | |||
| 677fc3c6d5 | |||
| 3441280599 | |||
| d8587851a3 | |||
| cba51ad504 | |||
| 0f70663263 | |||
| 94a8ba2103 | |||
| 268fbf161d | |||
| dc23e87c56 | |||
| 1369df4578 | |||
| 256ff89423 | |||
| 3c89c34c6e | |||
| 178def0629 | |||
| 5f5f7e0515 | |||
| e041fbeda2 | |||
| 7aa99304b2 | |||
| 9fbcf653e8 | |||
| 4cfee786fc | |||
| 0388422318 | |||
| 7afc7d0892 |
7
.claude/settings.local.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(swift test *)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
5
.gitignore
vendored
@@ -20,12 +20,11 @@ node_modules/
|
|||||||
target/
|
target/
|
||||||
.junie
|
.junie
|
||||||
config.override.mk
|
config.override.mk
|
||||||
|
bin/
|
||||||
|
|
||||||
# Local design export scratch
|
# Local design export scratch
|
||||||
output/
|
output/
|
||||||
|
.scratch/
|
||||||
.screenshots
|
.screenshots
|
||||||
apple/RELEASE-MACOS.md
|
apple/RELEASE-MACOS.md
|
||||||
apple/Generated/*.xcconfig
|
apple/Generated/*.xcconfig
|
||||||
|
|
||||||
# Compliance material — kept locally, never committed
|
|
||||||
compliance/
|
|
||||||
|
|||||||
283
Cargo.lock
generated
@@ -219,6 +219,18 @@ dependencies = [
|
|||||||
"syn 2.0.118",
|
"syn 2.0.118",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "async-broadcast"
|
||||||
|
version = "0.7.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532"
|
||||||
|
dependencies = [
|
||||||
|
"event-listener",
|
||||||
|
"event-listener-strategy",
|
||||||
|
"futures-core",
|
||||||
|
"pin-project-lite",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "async-channel"
|
name = "async-channel"
|
||||||
version = "2.5.0"
|
version = "2.5.0"
|
||||||
@@ -244,6 +256,17 @@ dependencies = [
|
|||||||
"tokio",
|
"tokio",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "async-recursion"
|
||||||
|
version = "1.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.118",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "async-trait"
|
name = "async-trait"
|
||||||
version = "0.1.89"
|
version = "0.1.89"
|
||||||
@@ -421,6 +444,15 @@ dependencies = [
|
|||||||
"hybrid-array",
|
"hybrid-array",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "block-padding"
|
||||||
|
version = "0.3.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93"
|
||||||
|
dependencies = [
|
||||||
|
"generic-array",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "block2"
|
name = "block2"
|
||||||
version = "0.6.2"
|
version = "0.6.2"
|
||||||
@@ -483,6 +515,15 @@ dependencies = [
|
|||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cbc"
|
||||||
|
version = "0.1.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6"
|
||||||
|
dependencies = [
|
||||||
|
"cipher",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cc"
|
name = "cc"
|
||||||
version = "1.2.65"
|
version = "1.2.65"
|
||||||
@@ -864,7 +905,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "ccc2776f0c61eca1ca32528f85548abd1a4be8fb53d1b21c013e4f18da1e7090"
|
checksum = "ccc2776f0c61eca1ca32528f85548abd1a4be8fb53d1b21c013e4f18da1e7090"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"data-encoding",
|
"data-encoding",
|
||||||
"syn 1.0.109",
|
"syn 2.0.118",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1049,6 +1090,12 @@ version = "0.6.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d"
|
checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "endi"
|
||||||
|
version = "1.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "enum-assoc"
|
name = "enum-assoc"
|
||||||
version = "1.3.0"
|
version = "1.3.0"
|
||||||
@@ -1060,6 +1107,27 @@ dependencies = [
|
|||||||
"syn 2.0.118",
|
"syn 2.0.118",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "enumflags2"
|
||||||
|
version = "0.7.12"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef"
|
||||||
|
dependencies = [
|
||||||
|
"enumflags2_derive",
|
||||||
|
"serde",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "enumflags2_derive"
|
||||||
|
version = "0.7.12"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.118",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "equivalent"
|
name = "equivalent"
|
||||||
version = "1.0.2"
|
version = "1.0.2"
|
||||||
@@ -1930,6 +1998,7 @@ version = "0.1.4"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
|
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"block-padding",
|
||||||
"generic-array",
|
"generic-array",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -2511,6 +2580,15 @@ version = "2.8.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4"
|
checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "memoffset"
|
||||||
|
version = "0.9.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a"
|
||||||
|
dependencies = [
|
||||||
|
"autocfg",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "minimal-lexical"
|
name = "minimal-lexical"
|
||||||
version = "0.2.1"
|
version = "0.2.1"
|
||||||
@@ -2811,6 +2889,20 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "num"
|
||||||
|
version = "0.4.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23"
|
||||||
|
dependencies = [
|
||||||
|
"num-bigint",
|
||||||
|
"num-complex",
|
||||||
|
"num-integer",
|
||||||
|
"num-iter",
|
||||||
|
"num-rational",
|
||||||
|
"num-traits",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "num-bigint"
|
name = "num-bigint"
|
||||||
version = "0.4.6"
|
version = "0.4.6"
|
||||||
@@ -2837,6 +2929,15 @@ dependencies = [
|
|||||||
"zeroize",
|
"zeroize",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "num-complex"
|
||||||
|
version = "0.4.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495"
|
||||||
|
dependencies = [
|
||||||
|
"num-traits",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "num-conv"
|
name = "num-conv"
|
||||||
version = "0.2.2"
|
version = "0.2.2"
|
||||||
@@ -2863,6 +2964,17 @@ dependencies = [
|
|||||||
"num-traits",
|
"num-traits",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "num-rational"
|
||||||
|
version = "0.4.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824"
|
||||||
|
dependencies = [
|
||||||
|
"num-bigint",
|
||||||
|
"num-integer",
|
||||||
|
"num-traits",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "num-traits"
|
name = "num-traits"
|
||||||
version = "0.2.19"
|
version = "0.2.19"
|
||||||
@@ -3032,6 +3144,16 @@ version = "0.2.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
|
checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ordered-stream"
|
||||||
|
version = "0.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50"
|
||||||
|
dependencies = [
|
||||||
|
"futures-core",
|
||||||
|
"pin-project-lite",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "papaya"
|
name = "papaya"
|
||||||
version = "0.2.4"
|
version = "0.2.4"
|
||||||
@@ -3866,6 +3988,25 @@ dependencies = [
|
|||||||
"syn 2.0.118",
|
"syn 2.0.118",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "secret-service"
|
||||||
|
version = "5.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9a62d7f86047af0077255a29494136b9aaaf697c76ff70b8e49cded4e2623c14"
|
||||||
|
dependencies = [
|
||||||
|
"aes",
|
||||||
|
"cbc",
|
||||||
|
"futures-util",
|
||||||
|
"generic-array",
|
||||||
|
"getrandom 0.2.17",
|
||||||
|
"hkdf",
|
||||||
|
"num",
|
||||||
|
"once_cell",
|
||||||
|
"serde",
|
||||||
|
"sha2 0.10.9",
|
||||||
|
"zbus",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "security-framework"
|
name = "security-framework"
|
||||||
version = "3.7.0"
|
version = "3.7.0"
|
||||||
@@ -3974,6 +4115,17 @@ dependencies = [
|
|||||||
"zmij",
|
"zmij",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_repr"
|
||||||
|
version = "0.1.21"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 3.0.3",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "serde_spanned"
|
name = "serde_spanned"
|
||||||
version = "1.1.1"
|
version = "1.1.1"
|
||||||
@@ -4485,6 +4637,17 @@ dependencies = [
|
|||||||
"unicode-ident",
|
"unicode-ident",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "syn"
|
||||||
|
version = "3.0.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"unicode-ident",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "syn-mid"
|
name = "syn-mid"
|
||||||
version = "0.5.4"
|
version = "0.5.4"
|
||||||
@@ -4550,7 +4713,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"fastrand",
|
"fastrand",
|
||||||
"getrandom 0.3.4",
|
"getrandom 0.4.3",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"rustix",
|
"rustix",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
@@ -4684,6 +4847,7 @@ dependencies = [
|
|||||||
"signal-hook-registry",
|
"signal-hook-registry",
|
||||||
"socket2",
|
"socket2",
|
||||||
"tokio-macros",
|
"tokio-macros",
|
||||||
|
"tracing",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -4977,6 +5141,17 @@ version = "1.20.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
|
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "uds_windows"
|
||||||
|
version = "1.2.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e"
|
||||||
|
dependencies = [
|
||||||
|
"memoffset",
|
||||||
|
"tempfile",
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "unicode-bidi"
|
name = "unicode-bidi"
|
||||||
version = "0.3.18"
|
version = "0.3.18"
|
||||||
@@ -5227,14 +5402,19 @@ dependencies = [
|
|||||||
"data-encoding",
|
"data-encoding",
|
||||||
"futures",
|
"futures",
|
||||||
"futures-lite",
|
"futures-lite",
|
||||||
|
"getrandom 0.3.4",
|
||||||
"iroh",
|
"iroh",
|
||||||
"iroh-blobs",
|
"iroh-blobs",
|
||||||
"iroh-relay",
|
"iroh-relay",
|
||||||
"irpc",
|
"irpc",
|
||||||
"irpc-iroh",
|
"irpc-iroh",
|
||||||
|
"jni 0.21.1",
|
||||||
"libc",
|
"libc",
|
||||||
"n0-future",
|
"n0-future",
|
||||||
|
"ndk-context",
|
||||||
"num_cpus",
|
"num_cpus",
|
||||||
|
"secret-service",
|
||||||
|
"security-framework",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sqlx",
|
"sqlx",
|
||||||
@@ -5247,6 +5427,7 @@ dependencies = [
|
|||||||
"uniffi",
|
"uniffi",
|
||||||
"uuid",
|
"uuid",
|
||||||
"walkdir",
|
"walkdir",
|
||||||
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -5442,7 +5623,7 @@ version = "0.1.11"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"windows-sys 0.48.0",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -5916,6 +6097,62 @@ dependencies = [
|
|||||||
"synstructure",
|
"synstructure",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zbus"
|
||||||
|
version = "5.18.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "fe18fb60dc696039e738717b76eaea21e7a4489bbb1885020b43c94236d7e98a"
|
||||||
|
dependencies = [
|
||||||
|
"async-broadcast",
|
||||||
|
"async-recursion",
|
||||||
|
"async-trait",
|
||||||
|
"enumflags2",
|
||||||
|
"event-listener",
|
||||||
|
"futures-core",
|
||||||
|
"futures-lite",
|
||||||
|
"hex",
|
||||||
|
"libc",
|
||||||
|
"ordered-stream",
|
||||||
|
"rustix",
|
||||||
|
"serde",
|
||||||
|
"serde_repr",
|
||||||
|
"tokio",
|
||||||
|
"tracing",
|
||||||
|
"uds_windows",
|
||||||
|
"uuid",
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
"winnow 1.0.3",
|
||||||
|
"zbus_macros",
|
||||||
|
"zbus_names",
|
||||||
|
"zvariant",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zbus_macros"
|
||||||
|
version = "5.18.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "fe96480bed92df2b442a1a30df364e12d08eed03aeb061f2b8dc6afb2be91119"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro-crate",
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.118",
|
||||||
|
"zbus_names",
|
||||||
|
"zvariant",
|
||||||
|
"zvariant_utils",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zbus_names"
|
||||||
|
version = "4.3.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d8bf88b4a3ff53e883001e0e0115b297a9d53c31b9c1edd2bfdd853e3428624e"
|
||||||
|
dependencies = [
|
||||||
|
"serde",
|
||||||
|
"winnow 1.0.3",
|
||||||
|
"zvariant",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zerocopy"
|
name = "zerocopy"
|
||||||
version = "0.8.52"
|
version = "0.8.52"
|
||||||
@@ -6015,3 +6252,43 @@ name = "zmij"
|
|||||||
version = "1.0.21"
|
version = "1.0.21"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
|
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zvariant"
|
||||||
|
version = "5.13.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "bee2a0bcd2a907786a456fff45aaaaf54c9ba5f50b71ae9ec1a4edd200c94911"
|
||||||
|
dependencies = [
|
||||||
|
"endi",
|
||||||
|
"enumflags2",
|
||||||
|
"serde",
|
||||||
|
"winnow 1.0.3",
|
||||||
|
"zvariant_derive",
|
||||||
|
"zvariant_utils",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zvariant_derive"
|
||||||
|
version = "5.13.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "38a708216a18780796770bfe3f4739c7c83a3e8f789b755534bbbc06e4e23e12"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro-crate",
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.118",
|
||||||
|
"zvariant_utils",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zvariant_utils"
|
||||||
|
version = "3.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "90cb9383f9b45290407a1258b202d3f8f01db719eb60b4e4055c6375af4fc7c7"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"serde",
|
||||||
|
"syn 2.0.118",
|
||||||
|
"winnow 1.0.3",
|
||||||
|
]
|
||||||
|
|||||||
524
DESIGN-DEVICE-HISTORY.md
Normal file
@@ -0,0 +1,524 @@
|
|||||||
|
# Design — Saved devices and targeted transfers
|
||||||
|
|
||||||
|
Status: **proposed for the experimental 0.3.x line**.
|
||||||
|
|
||||||
|
This document supersedes the previous device-history design. The implementation
|
||||||
|
currently on `feat/device-history` is an unreleased prototype. Its database and
|
||||||
|
wire formats are not compatibility commitments and may be replaced.
|
||||||
|
|
||||||
|
The feature lets two VniDrop installations remember one another after a
|
||||||
|
successful transfer, with explicit consent on both devices. A saved device can
|
||||||
|
then request a new transfer without another invitation, QR scan, or NFC tap.
|
||||||
|
The receiver must still approve every transfer.
|
||||||
|
|
||||||
|
The Rust core, protocol, persistence, credential-storage integration, and
|
||||||
|
platform contracts are the first delivery scope. Product UI is intentionally
|
||||||
|
deferred to a separate design and implementation session.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Vocabulary and invariants
|
||||||
|
|
||||||
|
### Saved device
|
||||||
|
|
||||||
|
A `SavedDevice` is a remote VniDrop **app-installation identity**. It is not a
|
||||||
|
person, account, address-book contact, or reliably identifiable piece of
|
||||||
|
physical hardware.
|
||||||
|
|
||||||
|
The identity is the remote iroh endpoint identity. A reinstall or unrecoverable
|
||||||
|
endpoint-key loss creates a new identity and requires a new successful transfer
|
||||||
|
and mutual consent. Display names, platform hints, IP addresses, and physical
|
||||||
|
device properties must never merge identities.
|
||||||
|
|
||||||
|
### Device relationship
|
||||||
|
|
||||||
|
A `DeviceRelationship` is a mutually acknowledged relationship between two
|
||||||
|
saved-device identities. It contains two directional grants: one issued in
|
||||||
|
each direction. The relationship is usable only after both grants have been
|
||||||
|
acknowledged.
|
||||||
|
|
||||||
|
### Targeted transfer
|
||||||
|
|
||||||
|
A `TargetedTransfer` is an immutable one-sender, one-receiver transfer. It is a
|
||||||
|
separate domain from the existing invitation-based `Share`, which may serve
|
||||||
|
multiple receivers.
|
||||||
|
|
||||||
|
The following invariants are mandatory:
|
||||||
|
|
||||||
|
- Saving a device requires a fully completed authenticated transfer and
|
||||||
|
explicit consent on both devices.
|
||||||
|
- Remembering a device never authorizes automatic receipt. Every targeted
|
||||||
|
transfer requires explicit receiver approval.
|
||||||
|
- A targeted transfer has exactly one sender identity, one receiver identity,
|
||||||
|
one transfer ID, and one immutable manifest.
|
||||||
|
- Authorization is bound to the selected receiver. A leaked capability or
|
||||||
|
ticket must not authorize any other identity.
|
||||||
|
- Relays may forward end-to-end encrypted traffic according to the active
|
||||||
|
network profile, but VniDrop has no intermediary file store, relationship
|
||||||
|
service, delivery queue, push service, or account system.
|
||||||
|
- Existing invitation-based transfers retain their current behavior and domain
|
||||||
|
model.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Goals and non-goals
|
||||||
|
|
||||||
|
### Goals
|
||||||
|
|
||||||
|
- Send to a previously saved device without exchanging another invitation.
|
||||||
|
- Make mutual consent cryptographically enforceable rather than a UI promise.
|
||||||
|
- Keep receiver approval mandatory for each new transfer.
|
||||||
|
- Give forget, revoke, block, cancellation, and deletion immediate local
|
||||||
|
security effect even when the peer is offline.
|
||||||
|
- Persist accepted interrupted transfers so they can resume when both devices
|
||||||
|
are online again.
|
||||||
|
- Protect endpoint identity keys and relationship secrets with platform-backed
|
||||||
|
credential storage.
|
||||||
|
- Provide versioned, typed Rust and UniFFI contracts that every platform can
|
||||||
|
exercise before UI work begins.
|
||||||
|
|
||||||
|
### Non-goals
|
||||||
|
|
||||||
|
- Automatic acceptance or unattended writes to a receiver's device.
|
||||||
|
- Offline store-and-forward, automatic peer polling, background inboxes, or
|
||||||
|
push notifications.
|
||||||
|
- Server-side device discovery, relationship storage, history synchronization,
|
||||||
|
backup, export, or restoration onto another installation.
|
||||||
|
- Presence indicators or a promise that a suspended mobile application is
|
||||||
|
reachable.
|
||||||
|
- Groups or a multi-recipient variant of `TargetedTransfer`.
|
||||||
|
- Associating several saved devices with a person or account.
|
||||||
|
- UI screens, navigation, wording, and presentation architecture in this phase.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Network and privacy model
|
||||||
|
|
||||||
|
Saved-device operations use the same configured iroh network profile as
|
||||||
|
ordinary transfers:
|
||||||
|
|
||||||
|
- `Automatic` may use configured/default relays and direct paths.
|
||||||
|
- Custom-relay modes remain restricted to their configured relays and fallback
|
||||||
|
policy.
|
||||||
|
- `LocalOnly` must not silently enable public discovery or a relay.
|
||||||
|
|
||||||
|
An endpoint ID authenticates a peer; it is not, by itself, a routable address.
|
||||||
|
Address discovery and file transport may use a relay. VniDrop and the endpoints
|
||||||
|
still provide end-to-end authentication and encryption, so the relay cannot
|
||||||
|
decrypt content or authorize a recipient. A relay may observe transport
|
||||||
|
metadata such as network addresses, timing, and volume. VniDrop must not claim
|
||||||
|
that relayed traffic is anonymous, metadata-free, or relay-free.
|
||||||
|
|
||||||
|
VniDrop does not upload a transfer for later delivery. The sender and receiver
|
||||||
|
cores must both be reachable while an offer is negotiated. A relay cannot wake
|
||||||
|
a terminated or suspended application. The first release therefore reports a
|
||||||
|
typed unavailable or timeout result when the receiver's core cannot answer.
|
||||||
|
|
||||||
|
Current direct address candidates may be exchanged over an authenticated
|
||||||
|
connection and cached for the connection or a short local lifetime. The app
|
||||||
|
must not accumulate a historical IP-address log.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Identity and credential custody
|
||||||
|
|
||||||
|
The endpoint private key and all relationship capability secrets are protected
|
||||||
|
by platform-backed credential storage:
|
||||||
|
|
||||||
|
| Platform | Required protection |
|
||||||
|
|---|---|
|
||||||
|
| Apple | Keychain with a non-synchronizing, device-appropriate accessibility class |
|
||||||
|
| Android | Keystore-backed encryption; only ciphertext may live outside Keystore |
|
||||||
|
| Windows | DPAPI scoped to the current user |
|
||||||
|
| Linux | Secret Service/libsecret |
|
||||||
|
|
||||||
|
There is no plaintext fallback.
|
||||||
|
|
||||||
|
Rust owns identity use, cryptographic operations, relationship state, and
|
||||||
|
authorization. Platforms provide a narrow secure-secret-store adapter. Public
|
||||||
|
bindings exchange opaque handles and typed outcomes, never raw grants, pairing
|
||||||
|
tokens, or private keys.
|
||||||
|
|
||||||
|
If the endpoint identity key is temporarily unavailable, networking is
|
||||||
|
temporarily unavailable because VniDrop cannot authenticate as the same
|
||||||
|
endpoint. If the endpoint key is available but relationship grants are not,
|
||||||
|
ordinary invitation transfers remain available while saved-device operations
|
||||||
|
fail closed. Neither case may generate a replacement identity automatically.
|
||||||
|
|
||||||
|
### 4.1 Legacy endpoint-key migration
|
||||||
|
|
||||||
|
Migration of an existing endpoint key must be recoverable:
|
||||||
|
|
||||||
|
1. Read the legacy key.
|
||||||
|
2. Write it to protected storage.
|
||||||
|
3. Read it back and prove that it derives the same endpoint ID.
|
||||||
|
4. Commit a storage-version marker.
|
||||||
|
5. Only then remove the legacy copy.
|
||||||
|
|
||||||
|
A crash at any step must preserve at least one valid copy and must not change
|
||||||
|
the endpoint identity. Confirmed unrecoverable loss or an explicit identity
|
||||||
|
reset is required before replacement.
|
||||||
|
|
||||||
|
Secrets must not synchronize through platform cloud backup. Restored metadata
|
||||||
|
without its device-bound secrets reconciles to disabled relationships, never a
|
||||||
|
cloned identity.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Pairing eligibility
|
||||||
|
|
||||||
|
Only a **fully completed authenticated transfer** creates pairing eligibility.
|
||||||
|
A handshake, partial download, failed export, cancellation, decline, or failed
|
||||||
|
transfer does not qualify. Either the sender or receiver may initiate pairing
|
||||||
|
after a qualifying transfer.
|
||||||
|
|
||||||
|
During the qualifying transfer, the peers establish a cryptographic,
|
||||||
|
single-use pairing eligibility capability bound to:
|
||||||
|
|
||||||
|
- Both endpoint identities.
|
||||||
|
- The qualifying transfer/session.
|
||||||
|
- The saved-device protocol version.
|
||||||
|
- A 24-hour local expiry.
|
||||||
|
|
||||||
|
The capability becomes usable only after the transfer reaches its durable
|
||||||
|
completed state. It is stored locally in encrypted form without filenames or a
|
||||||
|
transfer-history record. It is deleted when consumed, declined, expired,
|
||||||
|
forgotten, blocked, or reset.
|
||||||
|
|
||||||
|
Requests without valid eligibility are silently rejected. This prevents a
|
||||||
|
modified stranger from generating unsolicited pairing prompts.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Mutual-consent protocol
|
||||||
|
|
||||||
|
The protocol uses explicit pending states rather than exposing partial contacts
|
||||||
|
as usable saved devices:
|
||||||
|
|
||||||
|
- `PendingOutgoing`
|
||||||
|
- `PendingIncoming`
|
||||||
|
- `Saved`
|
||||||
|
|
||||||
|
The normal exchange is:
|
||||||
|
|
||||||
|
1. Alice locally chooses to remember Bob after a qualifying transfer.
|
||||||
|
2. Alice sends a token-bound pairing request.
|
||||||
|
3. Bob explicitly consents.
|
||||||
|
4. Alice and Bob exchange fresh directional grants.
|
||||||
|
5. Alice acknowledges Bob's grant.
|
||||||
|
6. Both sides activate the relationship as `Saved` only after the mutual
|
||||||
|
exchange is acknowledged.
|
||||||
|
|
||||||
|
Failure before activation remains a bounded pending operation and cannot be
|
||||||
|
used to initiate a transfer. Pending operations expire and are recoverable or
|
||||||
|
cleaned after crashes.
|
||||||
|
|
||||||
|
If both devices initiate simultaneously, the protocol deterministically merges
|
||||||
|
the attempts using the endpoint identities and the transfer-bound eligibility
|
||||||
|
capability. It creates one relationship and one active grant per direction,
|
||||||
|
without duplicate prompts or rows.
|
||||||
|
|
||||||
|
Declining consumes the eligibility for that qualifying transfer. It cannot
|
||||||
|
prompt again. A later completed transfer may establish new eligibility, but
|
||||||
|
another request still requires fresh local initiation.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Directional grants
|
||||||
|
|
||||||
|
Each direction has one active, high-entropy capability bound to:
|
||||||
|
|
||||||
|
- Issuer endpoint identity.
|
||||||
|
- Holder endpoint identity.
|
||||||
|
- Relationship generation.
|
||||||
|
- Minimum negotiated protocol generation.
|
||||||
|
|
||||||
|
Proof uses the authenticated iroh channel plus established, domain-separated
|
||||||
|
cryptographic primitives, challenge binding, and replay protection. Display
|
||||||
|
names, addresses, and transfer IDs alone are never authentication. The protocol
|
||||||
|
must have independent, reviewable test vectors.
|
||||||
|
|
||||||
|
Relationships do not expire merely through inactivity. They remain until
|
||||||
|
forget, block, explicit revocation, identity loss, or reset. Long-unseen devices
|
||||||
|
may later be represented as inactive by UI, but inactivity does not silently
|
||||||
|
remove permission.
|
||||||
|
|
||||||
|
Activating a replacement grant first makes the prior relationship generation
|
||||||
|
locally invalid. Exactly one generation is active per direction. Minimal
|
||||||
|
non-secret revocation tombstones are retained for as long as an old generation
|
||||||
|
could otherwise be replayed; tombstones contain no names, filenames, transfer
|
||||||
|
history, or capability material.
|
||||||
|
|
||||||
|
An established relationship records its minimum supported protocol generation
|
||||||
|
and must never silently downgrade below it.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Forget, block, and identity replacement
|
||||||
|
|
||||||
|
### Forget
|
||||||
|
|
||||||
|
Forget makes the local relationship and its grants unusable immediately,
|
||||||
|
cancels active or resumable targeted transfers for that relationship, removes
|
||||||
|
relationship secrets and metadata, and sends a signed/bound best-effort remote
|
||||||
|
revocation when possible. Correctness never depends on remote delivery.
|
||||||
|
|
||||||
|
An independently approved invitation transfer already in progress may continue
|
||||||
|
because it belongs to the existing share domain.
|
||||||
|
|
||||||
|
### Block
|
||||||
|
|
||||||
|
Block is identity-wide and immediate. It rejects or cancels current and future
|
||||||
|
traffic from the blocked endpoint across:
|
||||||
|
|
||||||
|
- Pairing and grant operations.
|
||||||
|
- Targeted offers and transfers.
|
||||||
|
- Ordinary invitation handshakes and transfers.
|
||||||
|
- Revocation and probing endpoints, except for indistinguishable rejection
|
||||||
|
needed to avoid exposing block state.
|
||||||
|
|
||||||
|
Blocking deletes active relationship grants but retains the minimal identity
|
||||||
|
deny record and replay tombstones. Unblocking removes only the deny rule. It
|
||||||
|
does not restore grants, relationships, or cancelled transfers. Saving the
|
||||||
|
device again requires another qualifying transfer and fresh mutual consent.
|
||||||
|
|
||||||
|
A peer reinstall produces a new endpoint identity. It is never linked to the
|
||||||
|
old device by name, address, or platform. The old saved entry remains
|
||||||
|
unavailable until forgotten; the new identity follows the complete first-
|
||||||
|
transfer and consent flow.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Targeted-transfer model
|
||||||
|
|
||||||
|
`TargetedTransfer` is not an access mode on an ordinary share. It has its own
|
||||||
|
protocol types, repository records, authorization rules, and public APIs.
|
||||||
|
Internal blob storage, import, hashing, streaming, and output-sink machinery may
|
||||||
|
be reused.
|
||||||
|
|
||||||
|
The following fields are immutable after creation:
|
||||||
|
|
||||||
|
- Transfer ID.
|
||||||
|
- Sender endpoint identity.
|
||||||
|
- Receiver endpoint identity.
|
||||||
|
- Manifest identity and content hashes.
|
||||||
|
- File count and total size.
|
||||||
|
|
||||||
|
Sending identical content to several saved devices creates independent
|
||||||
|
targeted transfers. Internal blobs may be deduplicated, but approval, progress,
|
||||||
|
cancellation, retry, authorization, and durable state remain independent.
|
||||||
|
|
||||||
|
The durable state machine is:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Preparing -> Offering -> AwaitingApproval -> Approved -> Connecting
|
||||||
|
-> Transferring -> Completed
|
||||||
|
\-> Interrupted -> Connecting
|
||||||
|
|
||||||
|
Terminal alternatives: Declined, Cancelled, Failed, Deleted
|
||||||
|
```
|
||||||
|
|
||||||
|
Rust centrally validates transitions. Platform code invokes typed operations
|
||||||
|
and consumes snapshots/events; it cannot fabricate states.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Offer and approval protocol
|
||||||
|
|
||||||
|
An offer is online-only and bounded:
|
||||||
|
|
||||||
|
1. The sender creates an immutable targeted transfer for one saved-device
|
||||||
|
identity.
|
||||||
|
2. The peers authenticate the saved relationship and negotiate the targeted-
|
||||||
|
transfer protocol version.
|
||||||
|
3. The sender submits a bounded offer containing a stable transfer ID and an
|
||||||
|
authenticated manifest summary, but no reusable ordinary-share ticket.
|
||||||
|
4. The receiver validates all framing, limits, identity bindings, relay-policy
|
||||||
|
compatibility, and manifest claims before surfacing approval.
|
||||||
|
5. The receiver explicitly approves or declines.
|
||||||
|
6. On approval, the sender issues authorization bound to the exact transfer,
|
||||||
|
manifest, and receiver endpoint.
|
||||||
|
7. The receiver pulls the content through the existing safe streaming and
|
||||||
|
output-sink machinery.
|
||||||
|
|
||||||
|
The approved authorization covers the exact manifest, content hashes, sizes,
|
||||||
|
sender, receiver, transfer ID, and protocol generation. Any mismatch or content
|
||||||
|
mutation invalidates the transfer and requires a new transfer ID and approval.
|
||||||
|
A leaked capability must fail when presented by another endpoint.
|
||||||
|
|
||||||
|
Every operation is idempotent. Replaying the same pairing request, offer,
|
||||||
|
approval, acknowledgement, cancellation, or completion returns the existing
|
||||||
|
result and cannot create duplicate prompts, grants, authorizations, or rows.
|
||||||
|
|
||||||
|
Declining rejects only that transfer. It neither forgets nor blocks the sender.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Online, interruption, and deletion semantics
|
||||||
|
|
||||||
|
An unapproved offer exists only in a bounded live-session queue. Sender
|
||||||
|
cancellation, decline, timeout, disconnect, or core restart removes it. There
|
||||||
|
is no sender-held offline offer, receiver polling loop, background inbox, or
|
||||||
|
automatic retry that can produce a later prompt.
|
||||||
|
|
||||||
|
After approval, the transfer and its recipient-scoped authorization are
|
||||||
|
durable. Interruption retains verified progress and may resume when both devices
|
||||||
|
are online again. Resuming the same immutable transfer does not request another
|
||||||
|
approval. Changed content or metadata requires a new transfer.
|
||||||
|
|
||||||
|
Cancellation before approval withdraws the offer. Cancellation after approval
|
||||||
|
stops authorization and active streaming synchronously before asynchronous
|
||||||
|
cleanup. It affects only that transfer.
|
||||||
|
|
||||||
|
Deletion must make authorization unusable, stop content service for that
|
||||||
|
transfer, remove resumable state, and clean related secrets. Remote cleanup is
|
||||||
|
best-effort; immediate durable local denial is mandatory.
|
||||||
|
|
||||||
|
Several separately approved targeted transfers may run concurrently between
|
||||||
|
the same devices under existing global stream and resource limits.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Local data and consistency
|
||||||
|
|
||||||
|
The private application database may contain only the relationship and transfer
|
||||||
|
metadata needed for the feature, including:
|
||||||
|
|
||||||
|
- Endpoint identity/public identifier.
|
||||||
|
- User-owned local label and untrusted platform/name hints.
|
||||||
|
- Pending/saved/blocked/revoked state and state revision.
|
||||||
|
- Opaque secure-store handles.
|
||||||
|
- Protocol and relationship generation.
|
||||||
|
- Last successful authenticated contact time.
|
||||||
|
- Minimal replay and revocation tombstones.
|
||||||
|
- Durable targeted-transfer state after approval.
|
||||||
|
|
||||||
|
It must not become a transfer-history log. Pairing does not justify retaining
|
||||||
|
filenames, previous IP addresses, or lists of past transfers.
|
||||||
|
|
||||||
|
Credential-store and SQLite updates cannot share a native transaction. Use
|
||||||
|
recoverable staged transitions:
|
||||||
|
|
||||||
|
1. Write secret material under a versioned opaque handle.
|
||||||
|
2. Verify the protected write.
|
||||||
|
3. Commit metadata referencing that handle in a non-active state.
|
||||||
|
4. Finalize activation.
|
||||||
|
|
||||||
|
Startup reconciliation removes orphaned secrets and disables metadata whose
|
||||||
|
required secrets are missing. Revocation becomes locally effective before any
|
||||||
|
network notification. Relationship mutations are serialized per remote
|
||||||
|
endpoint, while unrelated devices proceed concurrently. Database, relationship,
|
||||||
|
and credential-store guards must never be held across network awaits.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. Core and platform contract
|
||||||
|
|
||||||
|
The Rust core exposes separate typed models and operations for:
|
||||||
|
|
||||||
|
- Pairing eligibility and pending pairing requests.
|
||||||
|
- Listing, renaming, forgetting, blocking, and unblocking saved devices.
|
||||||
|
- Creating and submitting targeted transfers.
|
||||||
|
- Approving, declining, cancelling, resuming, and deleting transfers.
|
||||||
|
- Querying durable state and current capability availability.
|
||||||
|
- Subscribing to typed events carrying stable IDs and monotonic state revisions.
|
||||||
|
|
||||||
|
Bindings must not expose raw secrets or generic state mutation. Events are
|
||||||
|
wake-up notifications, not authoritative storage. They may be delivered at
|
||||||
|
least once; consumers deduplicate by stable ID and revision, then query current
|
||||||
|
state after reconnect or restart.
|
||||||
|
|
||||||
|
Failures remain typed where callers can act differently, including:
|
||||||
|
|
||||||
|
- Device unavailable or offer timeout.
|
||||||
|
- Protocol incompatibility or forbidden downgrade.
|
||||||
|
- Revoked or blocked relationship.
|
||||||
|
- Relay-policy incompatibility.
|
||||||
|
- Secure storage locked, unavailable, missing, or corrupted.
|
||||||
|
- Approval decline, cancellation, interruption, and invalid transition.
|
||||||
|
|
||||||
|
Production errors and diagnostics must not expose endpoint IDs, direct
|
||||||
|
addresses, tickets, grants, pairing capabilities, filenames, or secret-store
|
||||||
|
payloads.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14. Limits and hostile-peer handling
|
||||||
|
|
||||||
|
A saved relationship proves a remote app identity and permits it to request
|
||||||
|
approval. It does not make remote metadata, filenames, paths, sizes, messages,
|
||||||
|
or content trusted.
|
||||||
|
|
||||||
|
The feature reuses all existing filesystem safety, output-sink, no-overwrite,
|
||||||
|
ticket validation, and resource-limit invariants. Before approval it also
|
||||||
|
enforces:
|
||||||
|
|
||||||
|
- One unresolved offer per sender identity.
|
||||||
|
- A bounded global pending-offer queue.
|
||||||
|
- Strict request, manifest, metadata, file-count, and size limits.
|
||||||
|
- Connection, pairing, offer, approval, and acknowledgement timeouts.
|
||||||
|
- Per-identity cooldown after repeated malformed traffic or declines.
|
||||||
|
- Silent rejection of unauthenticated, ineligible, blocked, or invalid traffic.
|
||||||
|
- A configurable `CoreLimits.max_saved_devices`, defaulting to 256.
|
||||||
|
|
||||||
|
These are control-plane and local-resource protections. They do not impose a
|
||||||
|
quota on accepted transfers, files, bytes, or bandwidth.
|
||||||
|
|
||||||
|
VniDrop cannot protect against a compromised or unlocked endpoint, malicious
|
||||||
|
files the receiver knowingly accepts, operating-system credential compromise,
|
||||||
|
network traffic analysis, or a reinstalled peer appearing under a new identity.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 15. Compatibility and release policy
|
||||||
|
|
||||||
|
Saved devices and targeted transfers use explicit, versioned protocol
|
||||||
|
capabilities. A peer without compatible support cannot be paired or receive a
|
||||||
|
targeted transfer and falls back to the existing invitation flow. A targeted
|
||||||
|
transfer must never be reinterpreted as an ordinary share for compatibility.
|
||||||
|
|
||||||
|
The feature is gated as experimental in the 0.3.x line. The wire protocol is
|
||||||
|
versioned from its first merge. Removing the experimental gate requires:
|
||||||
|
|
||||||
|
- Stable migrations from every released database version.
|
||||||
|
- Compatible Apple, Android, Windows, and Linux credential-store adapters.
|
||||||
|
- Rust and platform contract coverage.
|
||||||
|
- Stable downgrade, revocation, recovery, and lifecycle behavior.
|
||||||
|
- No regression in invitation-based multi-recipient transfers.
|
||||||
|
|
||||||
|
The unreleased `feat/device-history` schema, held offers, polling behavior,
|
||||||
|
expiring grants, `Contact` terminology, Apple-only feature UI, and ordinary-
|
||||||
|
share offer authorization are prototype artifacts. They may be removed without
|
||||||
|
a migration. Useful low-level cryptographic, repository, protocol, and test
|
||||||
|
patterns may be retained only after they are checked against this design.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 16. Verification requirements
|
||||||
|
|
||||||
|
Rust tests must deterministically cover:
|
||||||
|
|
||||||
|
- Mutual consent, decline, simultaneous initiation, timeouts, and lost
|
||||||
|
acknowledgements.
|
||||||
|
- Pairing eligibility after completion and rejection after every non-completed
|
||||||
|
outcome.
|
||||||
|
- Replay, malformed input, spoofed identity, blocking, revocation, grant
|
||||||
|
rotation, and protocol downgrade.
|
||||||
|
- Recipient-bound authorization and rejection of leaked capabilities.
|
||||||
|
- Direct, relay, custom-relay, local-only, and incompatible-profile behavior.
|
||||||
|
- Restart and recovery at every durable state.
|
||||||
|
- Cancellation, deletion, forget, and block during active streaming.
|
||||||
|
- Credential-store failure and crash-point reconciliation.
|
||||||
|
- Concurrent independent targeted transfers.
|
||||||
|
- Existing invitation-based multi-recipient behavior remaining unchanged.
|
||||||
|
|
||||||
|
Each platform secure-storage adapter requires contract coverage for create,
|
||||||
|
read, update, delete, locked/unavailable behavior, migration, device-bound
|
||||||
|
persistence, orphan cleanup, and redaction. Platform harnesses must prove that
|
||||||
|
secrets do not appear in generated bindings, logs, diagnostics, or ordinary
|
||||||
|
database columns.
|
||||||
|
|
||||||
|
The core/platform foundation is complete only when these contracts are
|
||||||
|
implemented, documented, exposed through typed UniFFI APIs, and pass the
|
||||||
|
relevant Rust and platform checks. UI polish is not part of that completion
|
||||||
|
boundary.
|
||||||
@@ -7,6 +7,7 @@ import androidx.activity.ComponentActivity
|
|||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.enableEdgeToEdge
|
import androidx.activity.enableEdgeToEdge
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
|
import com.vnidrop.app.core.initializeAndroidCoreRuntime
|
||||||
import com.vnidrop.app.feature.receive.ExternalInvitationController
|
import com.vnidrop.app.feature.receive.ExternalInvitationController
|
||||||
import com.vnidrop.app.feature.receive.MaxVniDropInvitationBytes
|
import com.vnidrop.app.feature.receive.MaxVniDropInvitationBytes
|
||||||
import com.vnidrop.app.feature.receive.VniDropInvitationExtension
|
import com.vnidrop.app.feature.receive.VniDropInvitationExtension
|
||||||
@@ -22,6 +23,7 @@ class MainActivity : ComponentActivity() {
|
|||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
enableEdgeToEdge()
|
enableEdgeToEdge()
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
initializeAndroidCoreRuntime(applicationContext)
|
||||||
setContent {
|
setContent {
|
||||||
App(rememberAndroidAppDependencies(this, externalInvitations))
|
App(rememberAndroidAppDependencies(this, externalInvitations))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,43 +0,0 @@
|
|||||||
// swift-tools-version:5.9
|
|
||||||
import PackageDescription
|
|
||||||
|
|
||||||
// Core/UI Swift sources built as a library so the shared logic can be typechecked
|
|
||||||
// and unit-tested from the command line (macOS). The iOS/macOS app target in the
|
|
||||||
// Xcode project links the same sources plus the app entry point.
|
|
||||||
let package = Package(
|
|
||||||
name: "VniDropApp",
|
|
||||||
defaultLocalization: "en",
|
|
||||||
platforms: [
|
|
||||||
.iOS(.v16),
|
|
||||||
.macOS(.v13),
|
|
||||||
],
|
|
||||||
products: [
|
|
||||||
.library(name: "VniDropApp", targets: ["VniDropApp"]),
|
|
||||||
],
|
|
||||||
dependencies: [
|
|
||||||
.package(path: "VnidropCore"),
|
|
||||||
],
|
|
||||||
targets: [
|
|
||||||
.target(
|
|
||||||
name: "VniDropApp",
|
|
||||||
dependencies: [.product(name: "VnidropCore", package: "VnidropCore")],
|
|
||||||
path: "VniDrop",
|
|
||||||
// The @main entry belongs to the Xcode app target only; excluding it
|
|
||||||
// keeps this library free of a conflicting `_main` symbol for tests.
|
|
||||||
exclude: ["Resources", "App/VniDropApp.swift"],
|
|
||||||
// The Rust core (iroh network stack) links these system libraries. The
|
|
||||||
// Xcode app target must add the same frameworks under "Link Binary With
|
|
||||||
// Libraries" (SystemConfiguration, Security, libresolv).
|
|
||||||
linkerSettings: [
|
|
||||||
.linkedFramework("SystemConfiguration"),
|
|
||||||
.linkedFramework("Security"),
|
|
||||||
.linkedLibrary("resolv"),
|
|
||||||
]
|
|
||||||
),
|
|
||||||
.testTarget(
|
|
||||||
name: "VniDropAppTests",
|
|
||||||
dependencies: ["VniDropApp"],
|
|
||||||
path: "Tests"
|
|
||||||
),
|
|
||||||
]
|
|
||||||
)
|
|
||||||
@@ -18,9 +18,8 @@ apple/
|
|||||||
UI/Theme|Components|Navigation|Feedback|Shell/
|
UI/Theme|Components|Navigation|Feedback|Shell/
|
||||||
Platform/ # pickers, QR, NFC, share/export, per-OS file services
|
Platform/ # pickers, QR, NFC, share/export, per-OS file services
|
||||||
Resources/ # Localizable.xcstrings, Info.plist, entitlements, assets
|
Resources/ # Localizable.xcstrings, Info.plist, entitlements, assets
|
||||||
Tests/ # XCTest (ported progress-derivation assertions)
|
Tests/ # XCTest bundle (VniDropTests target)
|
||||||
Package.swift # builds VniDrop/ as a library for CLI build/test
|
project.yml # XcodeGen spec for the iOS/macOS app and test targets
|
||||||
project.yml # XcodeGen spec for the iOS/macOS app target
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Build & run
|
## Build & run
|
||||||
@@ -72,19 +71,22 @@ opt in with `APPLE_CODE_SIGNING=YES`. For signed builds from Xcode, create the
|
|||||||
ignored `apple/Local.xcconfig` and override the signing settings there, including
|
ignored `apple/Local.xcconfig` and override the signing settings there, including
|
||||||
the development team.
|
the development team.
|
||||||
|
|
||||||
## Command-line typecheck & tests
|
## Typecheck & tests
|
||||||
|
|
||||||
`Package.swift` builds the same sources as a library (minus the `@main` entry),
|
The Xcode project is the only build definition: it owns the UI, its package
|
||||||
so the shared logic can be checked and unit-tested without Xcode:
|
dependencies, and the `VniDropTests` bundle (module `VniDrop`, which is what the
|
||||||
|
tests import). Everything runs through `xcodebuild`:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd apple
|
make check-apple # iOS simulator unit tests
|
||||||
swift build # macOS
|
make build-apple-macos # unsigned macOS build (typecheck)
|
||||||
swift test # runs Tests/ (ported progress-derivation assertions)
|
|
||||||
# iOS typecheck:
|
|
||||||
swift build --triple arm64-apple-ios16.0-simulator --sdk "$(xcrun --sdk iphonesimulator --show-sdk-path)"
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
There is deliberately no SwiftPM manifest for the app. A second build definition
|
||||||
|
would duplicate the target's package dependencies, and the previous one had
|
||||||
|
already drifted out of sync with `project.yml` badly enough that neither
|
||||||
|
`swift build` nor `swift test` worked.
|
||||||
|
|
||||||
## Generated / ignored artifacts
|
## Generated / ignored artifacts
|
||||||
|
|
||||||
`build-core.sh` produces build outputs that are gitignored (see `apple/.gitignore`):
|
`build-core.sh` produces build outputs that are gitignored (see `apple/.gitignore`):
|
||||||
@@ -106,8 +108,7 @@ Rust crate itself is never changed.
|
|||||||
## System frameworks
|
## System frameworks
|
||||||
|
|
||||||
The Rust core (iroh network stack) links `SystemConfiguration`, `Security`, and
|
The Rust core (iroh network stack) links `SystemConfiguration`, `Security`, and
|
||||||
`libresolv`. These are declared in both `Package.swift` (for CLI build/test) and
|
`libresolv`. These are declared in `project.yml` for the app target.
|
||||||
`project.yml` (for the app target).
|
|
||||||
|
|
||||||
## Parity & scope
|
## Parity & scope
|
||||||
|
|
||||||
|
|||||||
@@ -1,229 +0,0 @@
|
|||||||
# Publier VniDrop sur TestFlight interne — guide complet
|
|
||||||
|
|
||||||
Ce guide décrit **toutes les étapes** pour compiler l'application et l'envoyer sur
|
|
||||||
**TestFlight interne** (aucune revue Apple n'est nécessaire pour les testeurs internes).
|
|
||||||
|
|
||||||
- **Bundle ID** : `com.vnidrop.app`
|
|
||||||
- **Team ID Apple** : `A8A4JSMV5D`
|
|
||||||
- **Branche à utiliser** : `feat/release-test-flight`
|
|
||||||
|
|
||||||
> ⚠️ **Point crucial** : il faut compiler avec un **Xcode de version finale (release)**,
|
|
||||||
> par exemple **Xcode 26** — **pas** une version bêta. Un envoi construit avec un Xcode
|
|
||||||
> bêta est **refusé** par App Store Connect (« Unsupported SDK or Xcode version »).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Prérequis
|
|
||||||
|
|
||||||
- Un Mac sous **macOS stable** (pas une bêta) avec **Xcode 26** installé.
|
|
||||||
- Un **identifiant Apple** (gratuit) — **aucun abonnement développeur payant n'est
|
|
||||||
nécessaire de votre côté**. Le propriétaire du compte vous invitera sur le sien.
|
|
||||||
- Une connexion internet.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Obtenir l'accès au compte développeur
|
|
||||||
|
|
||||||
Le propriétaire du compte doit vous inviter (une seule fois) :
|
|
||||||
|
|
||||||
1. Sur **App Store Connect** → **Utilisateurs et accès** → **Ajouter un utilisateur**.
|
|
||||||
2. Il saisit **votre identifiant Apple** et vous attribue le rôle **Admin**
|
|
||||||
(nécessaire pour gérer la signature) ou au minimum **App Manager**.
|
|
||||||
3. Vous recevez un e-mail d'invitation — **acceptez-le**.
|
|
||||||
|
|
||||||
Ensuite, dans **Xcode** → menu **Xcode → Settings → Accounts** → **+** →
|
|
||||||
connectez-vous avec **votre** identifiant Apple. L'équipe **VniDrop (A8A4JSMV5D)**
|
|
||||||
doit apparaître.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Installer les outils
|
|
||||||
|
|
||||||
Dans le Terminal :
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Homebrew (si absent) : voir https://brew.sh
|
|
||||||
# Outils de génération de projet et de qualité de code
|
|
||||||
brew install xcodegen swiftlint
|
|
||||||
|
|
||||||
# Rust (pour compiler le cœur natif)
|
|
||||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
|
|
||||||
```
|
|
||||||
|
|
||||||
Installez aussi les **Command Line Tools** de Xcode si demandé :
|
|
||||||
```bash
|
|
||||||
xcode-select --install
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Récupérer le projet
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git clone <URL_DU_DEPOT> vnidrop
|
|
||||||
cd vnidrop
|
|
||||||
git checkout feat/release-test-flight
|
|
||||||
```
|
|
||||||
|
|
||||||
> Le fichier `.xcodeproj`, le `Local.xcconfig` et le framework compilé ne sont **pas**
|
|
||||||
> versionnés : ils seront (re)générés localement aux étapes suivantes.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Configurer la signature
|
|
||||||
|
|
||||||
Créez le fichier **`apple/Local.xcconfig`** (ignoré par git) avec ce contenu :
|
|
||||||
|
|
||||||
```
|
|
||||||
DEVELOPMENT_TEAM = A8A4JSMV5D
|
|
||||||
CODE_SIGN_STYLE = Automatic
|
|
||||||
CODE_SIGNING_ALLOWED = YES
|
|
||||||
```
|
|
||||||
|
|
||||||
Cela active la signature sur cette machine sans modifier la configuration partagée
|
|
||||||
(qui reste non signée pour l'intégration continue).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. Compiler le cœur Rust
|
|
||||||
|
|
||||||
Depuis la racine du dépôt :
|
|
||||||
|
|
||||||
```bash
|
|
||||||
apple/scripts/build-core.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
Cela produit `apple/VnidropCore/vnidrop.xcframework` (avec la tranche **arm64 device**
|
|
||||||
requise pour TestFlight) et les liaisons Swift.
|
|
||||||
|
|
||||||
- La compilation **debug** (par défaut) convient parfaitement pour TestFlight.
|
|
||||||
- Si vous voulez une compilation **release** : `apple/scripts/build-core.sh release`.
|
|
||||||
Sur macOS stable, cela devrait fonctionner. En cas d'erreur `can't find crate`
|
|
||||||
(dylibs de macros corrompus), nettoyez et repassez en debug :
|
|
||||||
```bash
|
|
||||||
cargo clean
|
|
||||||
apple/scripts/build-core.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. Incrémenter le numéro de build
|
|
||||||
|
|
||||||
Chaque envoi doit avoir un **numéro de build unique et supérieur** au précédent.
|
|
||||||
Dans **`apple/project.yml`**, cherchez `CURRENT_PROJECT_VERSION` et mettez **`4`**
|
|
||||||
(les numéros 1 à 3 ont déjà été utilisés) :
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
CURRENT_PROJECT_VERSION: "4"
|
|
||||||
```
|
|
||||||
|
|
||||||
> Pour tout envoi ultérieur, augmentez encore ce nombre (5, 6, …).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. Générer le projet Xcode
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd apple
|
|
||||||
xcodegen generate
|
|
||||||
```
|
|
||||||
|
|
||||||
Cela crée `apple/VniDrop.xcodeproj` à partir de `project.yml` et du `Local.xcconfig`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. Archiver dans Xcode
|
|
||||||
|
|
||||||
1. Ouvrez **`apple/VniDrop.xcodeproj`** dans **Xcode 26**.
|
|
||||||
2. En haut, sélectionnez le schéma **VniDrop** et la destination
|
|
||||||
**Any iOS Device (arm64)** (surtout **pas** un simulateur).
|
|
||||||
3. Menu **Product → Archive**.
|
|
||||||
4. À la fin, la fenêtre **Organizer** s'ouvre avec votre archive.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 10. Envoyer sur App Store Connect
|
|
||||||
|
|
||||||
1. Dans l'**Organizer**, sélectionnez l'archive → **Distribute App**.
|
|
||||||
2. Choisissez **App Store Connect** → **Upload**.
|
|
||||||
3. Laissez les options par défaut (**signature automatique**) → **Upload**.
|
|
||||||
4. La question sur le chiffrement **ne sera pas posée** (déjà réglée dans l'Info.plist).
|
|
||||||
|
|
||||||
Patientez quelques minutes : le build apparaît ensuite dans App Store Connect avec le
|
|
||||||
statut **« En cours de traitement »**, puis devient disponible.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 11. Publier sur TestFlight interne (sans revue)
|
|
||||||
|
|
||||||
1. Sur **App Store Connect** → l'app **VniDrop** → onglet **TestFlight**.
|
|
||||||
2. Attendez que le build passe de **« En cours de traitement »** à disponible.
|
|
||||||
3. Section **Tests internes** → créez un groupe (ou utilisez celui par défaut) →
|
|
||||||
ajoutez les **testeurs internes** (ce sont des utilisateurs de l'équipe App Store
|
|
||||||
Connect ; le propriétaire les ajoute via **Utilisateurs et accès** si besoin).
|
|
||||||
4. Activez le build pour le groupe.
|
|
||||||
5. Les testeurs reçoivent un e-mail, installent l'app **TestFlight**, acceptent, puis
|
|
||||||
installent VniDrop. **Aucune revue Apple** n'est requise pour les tests internes.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 12. Solution de repli pour la signature
|
|
||||||
|
|
||||||
Si, à l'étape 9/10, Xcode **refuse de créer un certificat de distribution**
|
|
||||||
automatiquement (limitation possible des comptes individuels), le **propriétaire du
|
|
||||||
compte** doit fournir les éléments de signature :
|
|
||||||
|
|
||||||
1. Portail développeur → **Certificates** → créer un certificat **Apple Distribution**,
|
|
||||||
puis l'**exporter en `.p12`** (avec la clé privée) depuis le Trousseau (Keychain).
|
|
||||||
2. **Profiles** → créer un profil de provisioning **App Store** pour `com.vnidrop.app`.
|
|
||||||
3. Vous transmet le `.p12` (+ son mot de passe) et le profil.
|
|
||||||
|
|
||||||
De votre côté :
|
|
||||||
- Importez le `.p12` dans le **Trousseau** (double-clic).
|
|
||||||
- Dans Xcode, désactivez la signature automatique et sélectionnez la signature
|
|
||||||
**manuelle** avec ce profil, puis reprenez l'archivage (étape 9).
|
|
||||||
|
|
||||||
> 🔒 Un `.p12` de distribution permet de signer des apps au nom du propriétaire :
|
|
||||||
> à n'utiliser qu'entre personnes de confiance. Le certificat peut être révoqué ensuite.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 13. Dépannage
|
|
||||||
|
|
||||||
- **« Unsupported SDK or Xcode version »** → vous compilez avec un Xcode **bêta**.
|
|
||||||
Utilisez **Xcode 26 (release)**.
|
|
||||||
- **Échec de la phase SwiftLint** → `brew install swiftlint` (obligatoire, la build
|
|
||||||
échoue sinon).
|
|
||||||
- **Numéro de build déjà utilisé** → augmentez `CURRENT_PROJECT_VERSION` puis
|
|
||||||
`xcodegen generate` à nouveau.
|
|
||||||
- **L'app n'apparaît pas dans TestFlight** → attendez la fin du « traitement » ; la
|
|
||||||
conformité export est déjà déclarée, aucune action supplémentaire.
|
|
||||||
- **Le même Xcode pour le cœur Rust et l'archivage n'est pas obligatoire** (le cœur est
|
|
||||||
du Rust), mais l'**archivage** doit impérativement se faire avec **Xcode 26 release**.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Récapitulatif express
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 1. Outils
|
|
||||||
brew install xcodegen swiftlint
|
|
||||||
|
|
||||||
# 2. Projet
|
|
||||||
git checkout feat/release-test-flight
|
|
||||||
|
|
||||||
# 3. Signature : créer apple/Local.xcconfig (voir §5)
|
|
||||||
|
|
||||||
# 4. Cœur natif
|
|
||||||
apple/scripts/build-core.sh
|
|
||||||
|
|
||||||
# 5. Numéro de build : CURRENT_PROJECT_VERSION -> 4 dans apple/project.yml
|
|
||||||
|
|
||||||
# 6. Projet Xcode
|
|
||||||
cd apple && xcodegen generate
|
|
||||||
|
|
||||||
# 7. Xcode 26 : schéma VniDrop, destination « Any iOS Device (arm64) »,
|
|
||||||
# Product → Archive → Distribute App → App Store Connect → Upload
|
|
||||||
|
|
||||||
# 8. App Store Connect → TestFlight → Tests internes → ajouter les testeurs
|
|
||||||
```
|
|
||||||
640
apple/Tests/ContactsModelTests.swift
Normal file
@@ -0,0 +1,640 @@
|
|||||||
|
import XCTest
|
||||||
|
@testable import VniDrop
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class ContactsModelTests: XCTestCase {
|
||||||
|
private func makeModel(
|
||||||
|
_ gateway: FakeCoreGateway
|
||||||
|
) -> (ContactsModel, AppPreferencesRepository) {
|
||||||
|
let defaults = UserDefaults(suiteName: "contacts-tests-\(UUID().uuidString)")!
|
||||||
|
let preferences = AppPreferencesRepository(
|
||||||
|
defaults: defaults,
|
||||||
|
fallback: AppPreferencesDefaults(
|
||||||
|
username: "tester",
|
||||||
|
receiveFolder: ReceiveFolder(
|
||||||
|
kind: .fileSystemPath,
|
||||||
|
value: "/tmp",
|
||||||
|
displayName: "Downloads"
|
||||||
|
),
|
||||||
|
themeMode: .system
|
||||||
|
)
|
||||||
|
)
|
||||||
|
let model = ContactsModel(
|
||||||
|
repository: gateway,
|
||||||
|
messages: UiMessageController(),
|
||||||
|
preferences: preferences,
|
||||||
|
fileSystemService: FakeFileSystemService()
|
||||||
|
)
|
||||||
|
return (model, preferences)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func contact(
|
||||||
|
_ endpointId: String,
|
||||||
|
label: String? = nil,
|
||||||
|
remoteName: String? = nil,
|
||||||
|
canSend: Bool = true
|
||||||
|
) -> DeviceContact {
|
||||||
|
DeviceContact(
|
||||||
|
endpointId: endpointId,
|
||||||
|
localLabel: label,
|
||||||
|
remoteDisplayName: remoteName,
|
||||||
|
lastTransferAt: nil,
|
||||||
|
createdAt: 0,
|
||||||
|
canSend: canSend
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func offer(_ offerId: String, from endpointId: String = "peer") -> IncomingOfferModel {
|
||||||
|
IncomingOfferModel(
|
||||||
|
offerId: offerId,
|
||||||
|
fromEndpointId: endpointId,
|
||||||
|
senderDisplayName: "Peer",
|
||||||
|
transferName: "photos",
|
||||||
|
fileCount: 2,
|
||||||
|
totalBytes: 1_024,
|
||||||
|
receivedAt: 0
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testRefreshLoadsContactsBlocksAndPrompts() async {
|
||||||
|
let gateway = FakeCoreGateway()
|
||||||
|
gateway.contactsResult = .success([contact("a"), contact("b")])
|
||||||
|
gateway.blockedResult = .success(["blocked-one"])
|
||||||
|
gateway.pairings = [PendingPairingModel(endpointId: "c", displayName: "Laptop", receivedAt: 0)]
|
||||||
|
gateway.offers = [offer("offer-1")]
|
||||||
|
let (model, _) = makeModel(gateway)
|
||||||
|
|
||||||
|
await model.refresh()
|
||||||
|
|
||||||
|
XCTAssertEqual(model.state.contacts.count, 2)
|
||||||
|
XCTAssertEqual(model.state.blocked, ["blocked-one"])
|
||||||
|
XCTAssertEqual(model.state.currentPairing?.endpointId, "c")
|
||||||
|
XCTAssertEqual(model.state.currentOffer?.offerId, "offer-1")
|
||||||
|
XCTAssertFalse(model.state.isLoading)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Accepting an offer is the only path that yields a ticket; the caller needs
|
||||||
|
/// it to run the receive with its own destination.
|
||||||
|
func testAcceptingAnOfferReturnsTheTicket() async {
|
||||||
|
let gateway = FakeCoreGateway()
|
||||||
|
gateway.offers = [offer("offer-1")]
|
||||||
|
gateway.offerTicket = "vnd1:abc"
|
||||||
|
let (model, _) = makeModel(gateway)
|
||||||
|
await model.refresh()
|
||||||
|
|
||||||
|
let ticket = await model.respondToOffer(offerId: "offer-1", accepted: true)
|
||||||
|
|
||||||
|
XCTAssertEqual(ticket, "vnd1:abc")
|
||||||
|
XCTAssertTrue(model.state.pendingOffers.isEmpty)
|
||||||
|
XCTAssertEqual(gateway.offerResponses.map(\.accepted), [true])
|
||||||
|
}
|
||||||
|
|
||||||
|
func testDecliningAnOfferYieldsNoTicketAndClearsThePrompt() async {
|
||||||
|
let gateway = FakeCoreGateway()
|
||||||
|
gateway.offers = [offer("offer-1")]
|
||||||
|
let (model, _) = makeModel(gateway)
|
||||||
|
await model.refresh()
|
||||||
|
|
||||||
|
let ticket = await model.respondToOffer(offerId: "offer-1", accepted: false)
|
||||||
|
|
||||||
|
XCTAssertNil(ticket, "a declined offer must not hand over a capability")
|
||||||
|
XCTAssertTrue(model.state.pendingOffers.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Declining to be remembered must leave nothing behind for the peer.
|
||||||
|
func testDecliningPairingClearsThePromptWithoutAddingAContact() async {
|
||||||
|
let gateway = FakeCoreGateway()
|
||||||
|
gateway.pairings = [PendingPairingModel(endpointId: "peer", displayName: nil, receivedAt: 0)]
|
||||||
|
let (model, _) = makeModel(gateway)
|
||||||
|
await model.refresh()
|
||||||
|
|
||||||
|
await model.respondToPairing(endpointId: "peer", accepted: false)
|
||||||
|
|
||||||
|
XCTAssertTrue(model.state.pendingPairings.isEmpty)
|
||||||
|
XCTAssertTrue(model.state.contacts.isEmpty)
|
||||||
|
XCTAssertEqual(gateway.pairingResponses.map(\.accepted), [false])
|
||||||
|
}
|
||||||
|
|
||||||
|
func testAcceptingPairingAddsTheContact() async {
|
||||||
|
let gateway = FakeCoreGateway()
|
||||||
|
gateway.pairings = [PendingPairingModel(endpointId: "peer", displayName: "Laptop", receivedAt: 0)]
|
||||||
|
let (model, _) = makeModel(gateway)
|
||||||
|
await model.refresh()
|
||||||
|
gateway.contactsResult = .success([contact("peer", remoteName: "Laptop")])
|
||||||
|
|
||||||
|
await model.respondToPairing(endpointId: "peer", accepted: true)
|
||||||
|
|
||||||
|
XCTAssertTrue(model.state.pendingPairings.isEmpty)
|
||||||
|
XCTAssertEqual(model.state.contacts.map(\.endpointId), ["peer"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func testForgettingClearsTheSelectionAndReloads() async {
|
||||||
|
let gateway = FakeCoreGateway()
|
||||||
|
gateway.contactsResult = .success([contact("peer")])
|
||||||
|
let (model, _) = makeModel(gateway)
|
||||||
|
await model.refresh()
|
||||||
|
model.select("peer")
|
||||||
|
|
||||||
|
gateway.contactsResult = .success([])
|
||||||
|
await model.forget(endpointId: "peer")
|
||||||
|
|
||||||
|
XCTAssertEqual(gateway.forgottenContacts, ["peer"])
|
||||||
|
XCTAssertNil(model.state.selectedEndpointId)
|
||||||
|
XCTAssertTrue(model.state.contacts.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testBlockingRemovesTheContactAndKeepsItListedAsBlocked() async {
|
||||||
|
let gateway = FakeCoreGateway()
|
||||||
|
gateway.contactsResult = .success([contact("peer")])
|
||||||
|
let (model, _) = makeModel(gateway)
|
||||||
|
await model.refresh()
|
||||||
|
model.select("peer")
|
||||||
|
|
||||||
|
gateway.contactsResult = .success([])
|
||||||
|
gateway.blockedResult = .success(["peer"])
|
||||||
|
await model.block(endpointId: "peer")
|
||||||
|
|
||||||
|
XCTAssertEqual(gateway.blockedContactIds, ["peer"])
|
||||||
|
XCTAssertNil(model.state.selectedEndpointId)
|
||||||
|
XCTAssertEqual(model.state.blocked, ["peer"])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An empty label clears the override rather than storing whitespace, so the
|
||||||
|
/// row falls back to the name the device reports.
|
||||||
|
func testBlankLabelClearsTheLocalName() async {
|
||||||
|
let gateway = FakeCoreGateway()
|
||||||
|
let (model, _) = makeModel(gateway)
|
||||||
|
|
||||||
|
await model.setLabel(endpointId: "peer", label: " ")
|
||||||
|
|
||||||
|
XCTAssertEqual(gateway.contactLabels.count, 1)
|
||||||
|
XCTAssertNil(gateway.contactLabels[0].label)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testLabelIsTrimmedBeforeStoring() async {
|
||||||
|
let gateway = FakeCoreGateway()
|
||||||
|
let (model, _) = makeModel(gateway)
|
||||||
|
|
||||||
|
await model.setLabel(endpointId: "peer", label: " Work Mac ")
|
||||||
|
|
||||||
|
XCTAssertEqual(gateway.contactLabels[0].label, "Work Mac")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The core holds the lifetime in memory only, so the stored preference is
|
||||||
|
/// the durable copy and both have to move together.
|
||||||
|
func testGrantLifetimeIsPersistedAndPushedToTheCore() async {
|
||||||
|
let gateway = FakeCoreGateway()
|
||||||
|
let (model, preferences) = makeModel(gateway)
|
||||||
|
|
||||||
|
model.setGrantLifetime(.days365)
|
||||||
|
await Task.yield()
|
||||||
|
|
||||||
|
XCTAssertEqual(model.state.grantLifetime, .days365)
|
||||||
|
XCTAssertEqual(preferences.preferences.grantLifetime, .days365)
|
||||||
|
XCTAssertEqual(gateway.grantLifetimes.last, .days365)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testDefaultGrantLifetimeIsNinetyDays() {
|
||||||
|
let gateway = FakeCoreGateway()
|
||||||
|
let (model, _) = makeModel(gateway)
|
||||||
|
|
||||||
|
XCTAssertEqual(model.state.grantLifetime, .days90)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The local label wins over whatever the peer calls itself.
|
||||||
|
func testDisplayNamePrefersTheLocalLabel() {
|
||||||
|
let subject = contact("peer", label: "Work Mac", remoteName: "Totally Not Evil")
|
||||||
|
|
||||||
|
XCTAssertEqual(subject.displayName, "Work Mac")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testDisplayNameFallsBackToTheReportedName() {
|
||||||
|
let subject = contact("peer", remoteName: "Laptop")
|
||||||
|
|
||||||
|
XCTAssertEqual(subject.displayName, "Laptop")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Files picked for a device go out as an offer, never as an invitation
|
||||||
|
/// anyone holding the ticket could use.
|
||||||
|
func testSendingToAContactUsesTheContactDestination() async {
|
||||||
|
let gateway = FakeCoreGateway()
|
||||||
|
let files = FakeFileSystemService()
|
||||||
|
let defaults = UserDefaults(suiteName: "contacts-send-\(UUID().uuidString)")!
|
||||||
|
let preferences = AppPreferencesRepository(
|
||||||
|
defaults: defaults,
|
||||||
|
fallback: AppPreferencesDefaults(
|
||||||
|
username: "tester",
|
||||||
|
receiveFolder: ReceiveFolder(kind: .fileSystemPath, value: "/tmp", displayName: "Downloads"),
|
||||||
|
themeMode: .system
|
||||||
|
)
|
||||||
|
)
|
||||||
|
let model = ContactsModel(
|
||||||
|
repository: gateway,
|
||||||
|
messages: UiMessageController(),
|
||||||
|
preferences: preferences,
|
||||||
|
fileSystemService: files
|
||||||
|
)
|
||||||
|
gateway.sendToContactResult = .success(
|
||||||
|
ContactSendOutcome(
|
||||||
|
share: Share(
|
||||||
|
transferId: 1, ticket: "vnd1:x", transferName: "doc",
|
||||||
|
contentHash: "h", fileCount: 1, totalSize: 2
|
||||||
|
),
|
||||||
|
delivered: true
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
model.chooseFilesToSend(to: "peer")
|
||||||
|
XCTAssertTrue(model.pendingFilePick)
|
||||||
|
await model.onFilesPicked([
|
||||||
|
PickedShareFile(value: "/tmp/doc.txt", displayName: "doc.txt", isDirectory: false)
|
||||||
|
])
|
||||||
|
|
||||||
|
XCTAssertEqual(files.shareDestinations, [.contact(endpointId: "peer")])
|
||||||
|
XCTAssertEqual(gateway.sentToContacts, ["peer"])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A pick that arrives with no target must not be sent anywhere.
|
||||||
|
func testPickedFilesWithoutATargetAreIgnored() async {
|
||||||
|
let gateway = FakeCoreGateway()
|
||||||
|
let files = FakeFileSystemService()
|
||||||
|
let defaults = UserDefaults(suiteName: "contacts-send-\(UUID().uuidString)")!
|
||||||
|
let preferences = AppPreferencesRepository(
|
||||||
|
defaults: defaults,
|
||||||
|
fallback: AppPreferencesDefaults(
|
||||||
|
username: "tester",
|
||||||
|
receiveFolder: ReceiveFolder(kind: .fileSystemPath, value: "/tmp", displayName: "Downloads"),
|
||||||
|
themeMode: .system
|
||||||
|
)
|
||||||
|
)
|
||||||
|
let model = ContactsModel(
|
||||||
|
repository: gateway,
|
||||||
|
messages: UiMessageController(),
|
||||||
|
preferences: preferences,
|
||||||
|
fileSystemService: files
|
||||||
|
)
|
||||||
|
|
||||||
|
await model.onFilesPicked([
|
||||||
|
PickedShareFile(value: "/tmp/doc.txt", displayName: "doc.txt", isDirectory: false)
|
||||||
|
])
|
||||||
|
|
||||||
|
XCTAssertTrue(files.shareDestinations.isEmpty)
|
||||||
|
XCTAssertTrue(gateway.sentToContacts.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Polling is opt-in: it tells every contact the app was opened.
|
||||||
|
func testForegroundCheckIsSkippedUnlessEnabled() async {
|
||||||
|
let gateway = FakeCoreGateway()
|
||||||
|
let (model, _) = makeModel(gateway)
|
||||||
|
|
||||||
|
await model.checkForOffersOnForeground()
|
||||||
|
|
||||||
|
XCTAssertEqual(gateway.pollCount, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testForegroundCheckRunsOnceEnabled() async {
|
||||||
|
let gateway = FakeCoreGateway()
|
||||||
|
let (model, preferences) = makeModel(gateway)
|
||||||
|
|
||||||
|
model.setCheckForOffersOnOpen(true)
|
||||||
|
await model.checkForOffersOnForeground()
|
||||||
|
|
||||||
|
XCTAssertEqual(gateway.pollCount, 1)
|
||||||
|
XCTAssertTrue(preferences.preferences.checkForOffersOnOpen)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The explicit "check now" ignores the setting: the user just asked.
|
||||||
|
func testExplicitCheckRunsEvenWhenTheSettingIsOff() async {
|
||||||
|
let gateway = FakeCoreGateway()
|
||||||
|
gateway.pollResult = .success(2)
|
||||||
|
let (model, _) = makeModel(gateway)
|
||||||
|
|
||||||
|
let collected = await model.collectWaitingOffers()
|
||||||
|
|
||||||
|
XCTAssertEqual(collected, 2)
|
||||||
|
XCTAssertEqual(gateway.pollCount, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A transfer that could not be delivered is reported as waiting, not as a
|
||||||
|
/// success nobody has received.
|
||||||
|
func testAnUndeliveredSendIsReportedAsWaiting() async {
|
||||||
|
let gateway = FakeCoreGateway()
|
||||||
|
let files = FakeFileSystemService()
|
||||||
|
let defaults = UserDefaults(suiteName: "contacts-held-\(UUID().uuidString)")!
|
||||||
|
let preferences = AppPreferencesRepository(
|
||||||
|
defaults: defaults,
|
||||||
|
fallback: AppPreferencesDefaults(
|
||||||
|
username: "tester",
|
||||||
|
receiveFolder: ReceiveFolder(kind: .fileSystemPath, value: "/tmp", displayName: "Downloads"),
|
||||||
|
themeMode: .system
|
||||||
|
)
|
||||||
|
)
|
||||||
|
let messages = UiMessageController()
|
||||||
|
let model = ContactsModel(
|
||||||
|
repository: gateway,
|
||||||
|
messages: messages,
|
||||||
|
preferences: preferences,
|
||||||
|
fileSystemService: files
|
||||||
|
)
|
||||||
|
gateway.sendToContactResult = .success(
|
||||||
|
ContactSendOutcome(
|
||||||
|
share: Share(
|
||||||
|
transferId: 1, ticket: "vnd1:x", transferName: "doc",
|
||||||
|
contentHash: "h", fileCount: 1, totalSize: 2
|
||||||
|
),
|
||||||
|
delivered: false
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
model.chooseFilesToSend(to: "peer")
|
||||||
|
await model.onFilesPicked([
|
||||||
|
PickedShareFile(value: "/tmp/doc.txt", displayName: "doc.txt", isDirectory: false)
|
||||||
|
])
|
||||||
|
|
||||||
|
XCTAssertEqual(messages.current?.tone, .info)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testHeldOffersAreLoadedForDisplay() async {
|
||||||
|
let gateway = FakeCoreGateway()
|
||||||
|
gateway.heldOffersResult = .success([
|
||||||
|
HeldOfferModel(
|
||||||
|
offerId: "held-1",
|
||||||
|
endpointId: "peer",
|
||||||
|
transferId: 1,
|
||||||
|
transferName: "doc",
|
||||||
|
fileCount: 1,
|
||||||
|
totalBytes: 2,
|
||||||
|
createdAt: 0
|
||||||
|
)
|
||||||
|
])
|
||||||
|
let (model, _) = makeModel(gateway)
|
||||||
|
|
||||||
|
await model.refresh()
|
||||||
|
|
||||||
|
XCTAssertEqual(model.state.heldOffers.map(\.offerId), ["held-1"])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Offering an existing transfer reuses it rather than creating another.
|
||||||
|
func testOfferingAnExistingTransferReportsAcceptance() async {
|
||||||
|
let gateway = FakeCoreGateway()
|
||||||
|
gateway.offerTransferResult = .success(
|
||||||
|
ContactSendOutcome(
|
||||||
|
share: Share(
|
||||||
|
transferId: 7, ticket: "vnd1:x", transferName: "doc",
|
||||||
|
contentHash: "h", fileCount: 1, totalSize: 2
|
||||||
|
),
|
||||||
|
delivered: true
|
||||||
|
)
|
||||||
|
)
|
||||||
|
let (model, _) = makeModel(gateway)
|
||||||
|
|
||||||
|
let delivered = await model.offerTransfer(transferId: 7, to: contact("peer"))
|
||||||
|
|
||||||
|
XCTAssertTrue(delivered)
|
||||||
|
XCTAssertEqual(gateway.offeredTransfers.map(\.transferId), [7])
|
||||||
|
XCTAssertEqual(gateway.offeredTransfers.map(\.endpointId), ["peer"])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An offer to a closed device is reported as waiting, not accepted.
|
||||||
|
func testOfferingToAClosedDeviceReportsItAsWaiting() async {
|
||||||
|
let gateway = FakeCoreGateway()
|
||||||
|
gateway.offerTransferResult = .success(
|
||||||
|
ContactSendOutcome(
|
||||||
|
share: Share(
|
||||||
|
transferId: 7, ticket: "vnd1:x", transferName: "doc",
|
||||||
|
contentHash: "h", fileCount: 1, totalSize: 2
|
||||||
|
),
|
||||||
|
delivered: false
|
||||||
|
)
|
||||||
|
)
|
||||||
|
let (model, _) = makeModel(gateway)
|
||||||
|
|
||||||
|
let delivered = await model.offerTransfer(transferId: 7, to: contact("peer"))
|
||||||
|
|
||||||
|
XCTAssertFalse(delivered)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A refusal by the person on the other device is information, not an error.
|
||||||
|
func testADeclinedOfferIsReportedWithoutAnErrorTone() async {
|
||||||
|
let gateway = FakeCoreGateway()
|
||||||
|
gateway.offerTransferResult = .failure(
|
||||||
|
InvitationError.raw("permission error: device did not accept the transfer: receiver-declined")
|
||||||
|
)
|
||||||
|
let defaults = UserDefaults(suiteName: "contacts-declined-\(UUID().uuidString)")!
|
||||||
|
let preferences = AppPreferencesRepository(
|
||||||
|
defaults: defaults,
|
||||||
|
fallback: AppPreferencesDefaults(
|
||||||
|
username: "tester",
|
||||||
|
receiveFolder: ReceiveFolder(kind: .fileSystemPath, value: "/tmp", displayName: "Downloads"),
|
||||||
|
themeMode: .system
|
||||||
|
)
|
||||||
|
)
|
||||||
|
let messages = UiMessageController()
|
||||||
|
let model = ContactsModel(
|
||||||
|
repository: gateway,
|
||||||
|
messages: messages,
|
||||||
|
preferences: preferences,
|
||||||
|
fileSystemService: FakeFileSystemService()
|
||||||
|
)
|
||||||
|
|
||||||
|
let delivered = await model.offerTransfer(transferId: 7, to: contact("peer"))
|
||||||
|
|
||||||
|
XCTAssertFalse(delivered)
|
||||||
|
XCTAssertEqual(messages.current?.tone, .info)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testUnreachableContactIsSurfacedForRepairing() async {
|
||||||
|
let gateway = FakeCoreGateway()
|
||||||
|
gateway.contactsResult = .success([contact("peer", canSend: false)])
|
||||||
|
let (model, _) = makeModel(gateway)
|
||||||
|
|
||||||
|
await model.refresh()
|
||||||
|
|
||||||
|
XCTAssertEqual(model.state.contacts.first?.canSend, false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Post-transfer suggestions
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class PairingSuggestionTests: XCTestCase {
|
||||||
|
private func makeModel(
|
||||||
|
_ gateway: FakeCoreGateway,
|
||||||
|
defaults: UserDefaults
|
||||||
|
) -> (ContactsModel, AppPreferencesRepository) {
|
||||||
|
let preferences = AppPreferencesRepository(
|
||||||
|
defaults: defaults,
|
||||||
|
fallback: AppPreferencesDefaults(
|
||||||
|
username: "tester",
|
||||||
|
receiveFolder: ReceiveFolder(
|
||||||
|
kind: .fileSystemPath,
|
||||||
|
value: "/tmp",
|
||||||
|
displayName: "Downloads"
|
||||||
|
),
|
||||||
|
themeMode: .system
|
||||||
|
)
|
||||||
|
)
|
||||||
|
let model = ContactsModel(
|
||||||
|
repository: gateway,
|
||||||
|
messages: UiMessageController(),
|
||||||
|
preferences: preferences,
|
||||||
|
fileSystemService: FakeFileSystemService()
|
||||||
|
)
|
||||||
|
return (model, preferences)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func newDefaults() -> UserDefaults {
|
||||||
|
UserDefaults(suiteName: "suggestion-tests-\(UUID().uuidString)")!
|
||||||
|
}
|
||||||
|
|
||||||
|
private func completedReceive(from peerId: String?) -> Transfer {
|
||||||
|
Transfer(
|
||||||
|
localId: "local-1",
|
||||||
|
transferId: 1,
|
||||||
|
direction: .receive,
|
||||||
|
status: .done,
|
||||||
|
peerId: peerId,
|
||||||
|
transferName: "photos",
|
||||||
|
contentHash: nil,
|
||||||
|
fileCount: 1,
|
||||||
|
totalSize: 10,
|
||||||
|
ticket: nil,
|
||||||
|
accessPolicy: .requireApproval,
|
||||||
|
createdAt: 0,
|
||||||
|
updatedAt: 0
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func state(with transfers: [Transfer]) -> CoreState {
|
||||||
|
var core = CoreState()
|
||||||
|
core.isInitialized = true
|
||||||
|
core.transfers = transfers
|
||||||
|
return core
|
||||||
|
}
|
||||||
|
|
||||||
|
func testCompletedReceiveSuggestsItsSender() async {
|
||||||
|
let gateway = FakeCoreGateway()
|
||||||
|
let (model, _) = makeModel(gateway, defaults: newDefaults())
|
||||||
|
await model.refresh()
|
||||||
|
|
||||||
|
gateway.setState(state(with: [completedReceive(from: "sender-endpoint")]))
|
||||||
|
await Task.yield()
|
||||||
|
|
||||||
|
XCTAssertEqual(model.state.currentSuggestion?.endpointId, "sender-endpoint")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A transfer that never recorded a peer cannot be turned into a suggestion.
|
||||||
|
func testReceiveWithoutAPeerIsNotSuggested() async {
|
||||||
|
let gateway = FakeCoreGateway()
|
||||||
|
let (model, _) = makeModel(gateway, defaults: newDefaults())
|
||||||
|
await model.refresh()
|
||||||
|
|
||||||
|
gateway.setState(state(with: [completedReceive(from: nil)]))
|
||||||
|
await Task.yield()
|
||||||
|
|
||||||
|
XCTAssertNil(model.state.currentSuggestion)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testAlreadyRememberedDeviceIsNotSuggested() async {
|
||||||
|
let gateway = FakeCoreGateway()
|
||||||
|
gateway.contactsResult = .success([
|
||||||
|
DeviceContact(
|
||||||
|
endpointId: "sender-endpoint",
|
||||||
|
localLabel: nil,
|
||||||
|
remoteDisplayName: nil,
|
||||||
|
lastTransferAt: nil,
|
||||||
|
createdAt: 0,
|
||||||
|
canSend: true
|
||||||
|
)
|
||||||
|
])
|
||||||
|
let (model, _) = makeModel(gateway, defaults: newDefaults())
|
||||||
|
await model.refresh()
|
||||||
|
|
||||||
|
gateway.setState(state(with: [completedReceive(from: "sender-endpoint")]))
|
||||||
|
await Task.yield()
|
||||||
|
|
||||||
|
XCTAssertNil(model.state.currentSuggestion)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testBlockedDeviceIsNotSuggested() async {
|
||||||
|
let gateway = FakeCoreGateway()
|
||||||
|
gateway.blockedResult = .success(["sender-endpoint"])
|
||||||
|
let (model, _) = makeModel(gateway, defaults: newDefaults())
|
||||||
|
await model.refresh()
|
||||||
|
|
||||||
|
gateway.setState(state(with: [completedReceive(from: "sender-endpoint")]))
|
||||||
|
await Task.yield()
|
||||||
|
|
||||||
|
XCTAssertNil(model.state.currentSuggestion)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Declining has to stick, or every later transfer with the same device
|
||||||
|
/// re-asks the question the user already answered.
|
||||||
|
func testDecliningIsRememberedAcrossLaterTransfers() async {
|
||||||
|
let defaults = newDefaults()
|
||||||
|
let gateway = FakeCoreGateway()
|
||||||
|
let (model, preferences) = makeModel(gateway, defaults: defaults)
|
||||||
|
await model.refresh()
|
||||||
|
gateway.setState(state(with: [completedReceive(from: "sender-endpoint")]))
|
||||||
|
await Task.yield()
|
||||||
|
let suggestion = try? XCTUnwrap(model.state.currentSuggestion)
|
||||||
|
|
||||||
|
model.declineSuggestion(suggestion!)
|
||||||
|
|
||||||
|
XCTAssertNil(model.state.currentSuggestion)
|
||||||
|
XCTAssertTrue(preferences.preferences.declinedPairingSuggestions.contains("sender-endpoint"))
|
||||||
|
|
||||||
|
// A second transfer with the same device must stay silent.
|
||||||
|
gateway.setState(CoreState())
|
||||||
|
gateway.setState(state(with: [completedReceive(from: "sender-endpoint")]))
|
||||||
|
await Task.yield()
|
||||||
|
XCTAssertNil(model.state.currentSuggestion)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testAcceptingASuggestionIssuesAGrantUnderTheLocalUsername() async {
|
||||||
|
let gateway = FakeCoreGateway()
|
||||||
|
let (model, _) = makeModel(gateway, defaults: newDefaults())
|
||||||
|
await model.refresh()
|
||||||
|
gateway.setState(state(with: [completedReceive(from: "sender-endpoint")]))
|
||||||
|
await Task.yield()
|
||||||
|
let suggestion = try? XCTUnwrap(model.state.currentSuggestion)
|
||||||
|
|
||||||
|
await model.acceptSuggestion(suggestion!)
|
||||||
|
|
||||||
|
XCTAssertEqual(gateway.allowedDevices.map(\.endpointId), ["sender-endpoint"])
|
||||||
|
XCTAssertEqual(gateway.allowedDevices.first?.displayName, "tester")
|
||||||
|
XCTAssertNil(model.state.currentSuggestion)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pairing deliberately after declining should work, so the decline is
|
||||||
|
/// cleared rather than blocking the device forever.
|
||||||
|
func testAcceptingClearsAnEarlierDecline() async {
|
||||||
|
let defaults = newDefaults()
|
||||||
|
let gateway = FakeCoreGateway()
|
||||||
|
let (model, preferences) = makeModel(gateway, defaults: defaults)
|
||||||
|
let suggestion = PairingSuggestion(
|
||||||
|
endpointId: "sender-endpoint",
|
||||||
|
displayName: nil,
|
||||||
|
transferName: nil
|
||||||
|
)
|
||||||
|
model.declineSuggestion(suggestion)
|
||||||
|
XCTAssertTrue(preferences.preferences.declinedPairingSuggestions.contains("sender-endpoint"))
|
||||||
|
|
||||||
|
await model.acceptSuggestion(suggestion)
|
||||||
|
|
||||||
|
XCTAssertFalse(preferences.preferences.declinedPairingSuggestions.contains("sender-endpoint"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testTheSameDeviceIsOnlySuggestedOnce() async {
|
||||||
|
let gateway = FakeCoreGateway()
|
||||||
|
let (model, _) = makeModel(gateway, defaults: newDefaults())
|
||||||
|
await model.refresh()
|
||||||
|
|
||||||
|
gateway.setState(state(with: [completedReceive(from: "sender-endpoint")]))
|
||||||
|
await Task.yield()
|
||||||
|
gateway.setState(state(with: [completedReceive(from: "sender-endpoint")]))
|
||||||
|
await Task.yield()
|
||||||
|
|
||||||
|
XCTAssertEqual(model.state.suggestions.count, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -81,6 +81,98 @@ final class FakeCoreGateway: CoreGateway {
|
|||||||
return responseResult
|
return responseResult
|
||||||
}
|
}
|
||||||
func refresh() async -> Result<Void, Error> { .success(()) }
|
func refresh() async -> Result<Void, Error> { .success(()) }
|
||||||
|
|
||||||
|
// MARK: Device history
|
||||||
|
|
||||||
|
var contactsResult: Result<[DeviceContact], Error> = .success([])
|
||||||
|
var pairings: [PendingPairingModel] = []
|
||||||
|
var offers: [IncomingOfferModel] = []
|
||||||
|
var respondToPairingResult: Result<Bool, Error> = .success(true)
|
||||||
|
/// Ticket handed back when an offer is accepted; nil models a declined one.
|
||||||
|
var offerTicket: String? = "vnd1:offered"
|
||||||
|
var sendToContactResult: Result<ContactSendOutcome, Error> = .failure(TestError.unimplemented)
|
||||||
|
var heldOffersResult: Result<[HeldOfferModel], Error> = .success([])
|
||||||
|
var pollResult: Result<UInt64, Error> = .success(0)
|
||||||
|
private(set) var pollCount = 0
|
||||||
|
var forgetContactResult: Result<Void, Error> = .success(())
|
||||||
|
var blockedResult: Result<[String], Error> = .success([])
|
||||||
|
|
||||||
|
private(set) var allowedDevices: [(endpointId: String, displayName: String?)] = []
|
||||||
|
private(set) var pairingResponses: [(endpointId: String, accepted: Bool)] = []
|
||||||
|
private(set) var offerResponses: [(offerId: String, accepted: Bool)] = []
|
||||||
|
private(set) var forgottenContacts: [String] = []
|
||||||
|
private(set) var forgetAllCount = 0
|
||||||
|
private(set) var blockedContactIds: [String] = []
|
||||||
|
private(set) var unblockedContactIds: [String] = []
|
||||||
|
private(set) var contactLabels: [(endpointId: String, label: String?)] = []
|
||||||
|
private(set) var grantLifetimes: [GrantLifetimeOption] = []
|
||||||
|
private(set) var sentToContacts: [String] = []
|
||||||
|
|
||||||
|
func contacts() async -> Result<[DeviceContact], Error> { contactsResult }
|
||||||
|
func pendingPairings() async -> [PendingPairingModel] { pairings }
|
||||||
|
func pendingOffers() async -> [IncomingOfferModel] { offers }
|
||||||
|
func allowDeviceToReachMe(endpointId: String, displayName: String?) async -> Result<Void, Error> {
|
||||||
|
allowedDevices.append((endpointId, displayName))
|
||||||
|
return .success(())
|
||||||
|
}
|
||||||
|
func respondToPairing(endpointId: String, accepted: Bool) async -> Result<Bool, Error> {
|
||||||
|
pairingResponses.append((endpointId, accepted))
|
||||||
|
if case .success = respondToPairingResult {
|
||||||
|
pairings.removeAll { $0.endpointId == endpointId }
|
||||||
|
}
|
||||||
|
return respondToPairingResult
|
||||||
|
}
|
||||||
|
func respondToOffer(offerId: String, accepted: Bool) async -> String? {
|
||||||
|
offerResponses.append((offerId, accepted))
|
||||||
|
offers.removeAll { $0.offerId == offerId }
|
||||||
|
return accepted ? offerTicket : nil
|
||||||
|
}
|
||||||
|
func sendToContact(
|
||||||
|
endpointId: String,
|
||||||
|
sources: [ShareSource],
|
||||||
|
transferName: String,
|
||||||
|
senderName: String
|
||||||
|
) async -> Result<ContactSendOutcome, Error> {
|
||||||
|
sentToContacts.append(endpointId)
|
||||||
|
return sendToContactResult
|
||||||
|
}
|
||||||
|
private(set) var offeredTransfers: [(transferId: UInt64, endpointId: String)] = []
|
||||||
|
var offerTransferResult: Result<ContactSendOutcome, Error> = .failure(TestError.unimplemented)
|
||||||
|
|
||||||
|
func offerTransferToContact(
|
||||||
|
transferId: UInt64,
|
||||||
|
endpointId: String
|
||||||
|
) async -> Result<ContactSendOutcome, Error> {
|
||||||
|
offeredTransfers.append((transferId, endpointId))
|
||||||
|
return offerTransferResult
|
||||||
|
}
|
||||||
|
func heldOffers() async -> Result<[HeldOfferModel], Error> { heldOffersResult }
|
||||||
|
func pollContactsForOffers() async -> Result<UInt64, Error> {
|
||||||
|
pollCount += 1
|
||||||
|
return pollResult
|
||||||
|
}
|
||||||
|
func forgetContact(endpointId: String) async -> Result<Void, Error> {
|
||||||
|
forgottenContacts.append(endpointId)
|
||||||
|
return forgetContactResult
|
||||||
|
}
|
||||||
|
func forgetAllContacts() async -> Result<UInt64, Error> {
|
||||||
|
forgetAllCount += 1
|
||||||
|
return .success(0)
|
||||||
|
}
|
||||||
|
func blockContact(endpointId: String) async -> Result<Void, Error> {
|
||||||
|
blockedContactIds.append(endpointId)
|
||||||
|
return .success(())
|
||||||
|
}
|
||||||
|
func unblockContact(endpointId: String) async -> Result<Void, Error> {
|
||||||
|
unblockedContactIds.append(endpointId)
|
||||||
|
return .success(())
|
||||||
|
}
|
||||||
|
func blockedContacts() async -> Result<[String], Error> { blockedResult }
|
||||||
|
func setContactLabel(endpointId: String, label: String?) async -> Result<Void, Error> {
|
||||||
|
contactLabels.append((endpointId, label))
|
||||||
|
return .success(())
|
||||||
|
}
|
||||||
|
func setGrantLifetime(_ lifetime: GrantLifetimeOption) async { grantLifetimes.append(lifetime) }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Minimal `FileSystemService` fake — a writable path receive folder, no reveal.
|
/// Minimal `FileSystemService` fake — a writable path receive folder, no reveal.
|
||||||
@@ -92,8 +184,21 @@ final class FakeFileSystemService: FileSystemService {
|
|||||||
func defaultReceiveFolder() -> ReceiveFolder { folder }
|
func defaultReceiveFolder() -> ReceiveFolder { folder }
|
||||||
func validateReceiveFolder(_ folder: ReceiveFolder) async -> FolderAccessStatus { .writable }
|
func validateReceiveFolder(_ folder: ReceiveFolder) async -> FolderAccessStatus { .writable }
|
||||||
func canRevealReceiveFolder(_ folder: ReceiveFolder) -> Bool { false }
|
func canRevealReceiveFolder(_ folder: ReceiveFolder) -> Bool { false }
|
||||||
func sharePickedFiles(repository: CoreGateway, files: [PickedShareFile], transferName: String, senderName: String, accessPolicy: ShareAccessPolicy) async -> Result<Share, Error> {
|
private(set) var shareDestinations: [ShareDestination] = []
|
||||||
await repository.shareSources([], transferName: transferName, senderName: senderName, accessPolicy: accessPolicy)
|
|
||||||
|
func sharePickedFiles(repository: CoreGateway, files: [PickedShareFile], transferName: String, senderName: String, destination: ShareDestination) async -> Result<ContactSendOutcome, Error> {
|
||||||
|
shareDestinations.append(destination)
|
||||||
|
switch destination {
|
||||||
|
case .invitation(let accessPolicy):
|
||||||
|
return await repository.shareSources(
|
||||||
|
[], transferName: transferName, senderName: senderName, accessPolicy: accessPolicy
|
||||||
|
)
|
||||||
|
.map { ContactSendOutcome(share: $0, delivered: true) }
|
||||||
|
case .contact(let endpointId):
|
||||||
|
return await repository.sendToContact(
|
||||||
|
endpointId: endpointId, sources: [], transferName: transferName, senderName: senderName
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,44 +0,0 @@
|
|||||||
import XCTest
|
|
||||||
|
|
||||||
/// Captures App Store screenshots by driving the running app and grabbing the
|
|
||||||
/// full-screen image on each tab. Run via `apple/scripts/appstore-screenshots.sh`,
|
|
||||||
/// which boots the target simulator, runs this test, and extracts the attachments
|
|
||||||
/// at the device's native resolution (e.g. 2064×2752 on the 13-inch iPad).
|
|
||||||
final class ScreenshotTests: XCTestCase {
|
|
||||||
|
|
||||||
override func setUp() {
|
|
||||||
continueAfterFailure = false
|
|
||||||
}
|
|
||||||
|
|
||||||
func testCaptureAppStoreScreenshots() {
|
|
||||||
let app = XCUIApplication()
|
|
||||||
app.launch()
|
|
||||||
|
|
||||||
// The top pill exposes the three primary tabs. Tap by accessibility label
|
|
||||||
// when available, falling back to a normalized coordinate on the pill.
|
|
||||||
let tabs: [(name: String, dx: CGFloat)] = [
|
|
||||||
("01-Send", 0.407),
|
|
||||||
("02-Receive", 0.487),
|
|
||||||
("03-Settings", 0.579),
|
|
||||||
]
|
|
||||||
|
|
||||||
for tab in tabs {
|
|
||||||
let label = String(tab.name.dropFirst(3)) // "Send" / "Receive" / "Settings"
|
|
||||||
let button = app.buttons[label]
|
|
||||||
if button.waitForExistence(timeout: 10), button.isHittable {
|
|
||||||
button.tap()
|
|
||||||
} else {
|
|
||||||
app.coordinate(withNormalizedOffset: CGVector(dx: tab.dx, dy: 0.039)).tap()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Let the tab transition and any content settle before capturing.
|
|
||||||
Thread.sleep(forTimeInterval: 1.5)
|
|
||||||
|
|
||||||
let screenshot = XCUIScreen.main.screenshot()
|
|
||||||
let attachment = XCTAttachment(screenshot: screenshot)
|
|
||||||
attachment.name = tab.name
|
|
||||||
attachment.lifetime = .keepAlways
|
|
||||||
add(attachment)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -7,24 +7,19 @@ import Combine
|
|||||||
final class AppGraph: ObservableObject {
|
final class AppGraph: ObservableObject {
|
||||||
let dependencies: AppDependencies
|
let dependencies: AppDependencies
|
||||||
let coreRepository: CoreRepository
|
let coreRepository: CoreRepository
|
||||||
/// The gateway the feature models and coordinators observe. Normally the real
|
|
||||||
/// `coreRepository`; in screenshot builds a fixture is injected so the UI shows
|
|
||||||
/// deterministic content without the Rust core (see `ScreenshotSupport`).
|
|
||||||
let gateway: CoreGateway
|
|
||||||
let visibility = AppVisibility()
|
let visibility = AppVisibility()
|
||||||
let messages = UiMessageController()
|
let messages = UiMessageController()
|
||||||
let preferencesRepository: AppPreferencesRepository
|
let preferencesRepository: AppPreferencesRepository
|
||||||
let filePreviewRepository: FilePreviewRepository
|
let filePreviewRepository: FilePreviewRepository
|
||||||
let approvalCoordinator: ApprovalCoordinator
|
let approvalCoordinator: ApprovalCoordinator
|
||||||
|
let contactsModel: ContactsModel
|
||||||
let transferNotificationCoordinator: TransferNotificationCoordinator
|
let transferNotificationCoordinator: TransferNotificationCoordinator
|
||||||
let backgroundActivity: BackgroundActivityController
|
let backgroundActivity: BackgroundActivityController
|
||||||
|
|
||||||
init(dependencies: AppDependencies, coreRepository: CoreRepository? = nil, coreGateway: CoreGateway? = nil) {
|
init(dependencies: AppDependencies, coreRepository: CoreRepository? = nil) {
|
||||||
self.dependencies = dependencies
|
self.dependencies = dependencies
|
||||||
let coreRepository = coreRepository ?? CoreRepository()
|
let coreRepository = coreRepository ?? CoreRepository()
|
||||||
self.coreRepository = coreRepository
|
self.coreRepository = coreRepository
|
||||||
let gateway = coreGateway ?? coreRepository
|
|
||||||
self.gateway = gateway
|
|
||||||
self.filePreviewRepository = FilePreviewRepository(appDataDir: dependencies.environment.defaultCoreDataDir)
|
self.filePreviewRepository = FilePreviewRepository(appDataDir: dependencies.environment.defaultCoreDataDir)
|
||||||
self.preferencesRepository = AppPreferencesRepository(
|
self.preferencesRepository = AppPreferencesRepository(
|
||||||
fallback: AppPreferencesDefaults(
|
fallback: AppPreferencesDefaults(
|
||||||
@@ -33,14 +28,20 @@ final class AppGraph: ObservableObject {
|
|||||||
themeMode: .system
|
themeMode: .system
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
self.contactsModel = ContactsModel(
|
||||||
|
repository: coreRepository,
|
||||||
|
messages: messages,
|
||||||
|
preferences: preferencesRepository,
|
||||||
|
fileSystemService: dependencies.fileSystemService
|
||||||
|
)
|
||||||
self.approvalCoordinator = ApprovalCoordinator(
|
self.approvalCoordinator = ApprovalCoordinator(
|
||||||
repository: gateway,
|
repository: coreRepository,
|
||||||
notifications: dependencies.notificationService,
|
notifications: dependencies.notificationService,
|
||||||
visibility: visibility,
|
visibility: visibility,
|
||||||
messages: messages
|
messages: messages
|
||||||
)
|
)
|
||||||
self.transferNotificationCoordinator = TransferNotificationCoordinator(
|
self.transferNotificationCoordinator = TransferNotificationCoordinator(
|
||||||
repository: gateway,
|
repository: coreRepository,
|
||||||
notifications: dependencies.notificationService,
|
notifications: dependencies.notificationService,
|
||||||
visibility: visibility,
|
visibility: visibility,
|
||||||
messages: messages
|
messages: messages
|
||||||
|
|||||||
@@ -12,39 +12,24 @@ struct RootView: View {
|
|||||||
|
|
||||||
@Environment(\.scenePhase) private var scenePhase
|
@Environment(\.scenePhase) private var scenePhase
|
||||||
|
|
||||||
#if DEBUG
|
|
||||||
@State private var screenshotScenario: ScreenshotScenario?
|
|
||||||
#endif
|
|
||||||
|
|
||||||
init(dependencies: AppDependencies) {
|
init(dependencies: AppDependencies) {
|
||||||
#if DEBUG
|
|
||||||
let scenario = ScreenshotScenario.current
|
|
||||||
let graph = AppGraph(
|
|
||||||
dependencies: dependencies,
|
|
||||||
coreGateway: scenario.map { ScreenshotCoreGateway(scenario: $0) }
|
|
||||||
)
|
|
||||||
_screenshotScenario = State(initialValue: scenario)
|
|
||||||
#else
|
|
||||||
let graph = AppGraph(dependencies: dependencies)
|
let graph = AppGraph(dependencies: dependencies)
|
||||||
#endif
|
|
||||||
_graph = StateObject(wrappedValue: graph)
|
_graph = StateObject(wrappedValue: graph)
|
||||||
// The feature models observe `graph.gateway` — the real core normally, or the
|
|
||||||
// injected screenshot fixture — so a screenshot build never starts the core.
|
|
||||||
_appModel = StateObject(wrappedValue: AppModel(
|
_appModel = StateObject(wrappedValue: AppModel(
|
||||||
environment: dependencies.environment,
|
environment: dependencies.environment,
|
||||||
repository: graph.gateway,
|
repository: graph.coreRepository,
|
||||||
preferences: graph.preferencesRepository,
|
preferences: graph.preferencesRepository,
|
||||||
messages: graph.messages
|
messages: graph.messages
|
||||||
))
|
))
|
||||||
_sendModel = StateObject(wrappedValue: SendModel(
|
_sendModel = StateObject(wrappedValue: SendModel(
|
||||||
repository: graph.gateway,
|
repository: graph.coreRepository,
|
||||||
fileSystemService: dependencies.fileSystemService,
|
fileSystemService: dependencies.fileSystemService,
|
||||||
preferences: graph.preferencesRepository,
|
preferences: graph.preferencesRepository,
|
||||||
filePreviewRepository: graph.filePreviewRepository,
|
filePreviewRepository: graph.filePreviewRepository,
|
||||||
messages: graph.messages
|
messages: graph.messages
|
||||||
))
|
))
|
||||||
_receiveModel = StateObject(wrappedValue: ReceiveModel(
|
_receiveModel = StateObject(wrappedValue: ReceiveModel(
|
||||||
repository: graph.gateway,
|
repository: graph.coreRepository,
|
||||||
fileSystemService: dependencies.fileSystemService,
|
fileSystemService: dependencies.fileSystemService,
|
||||||
preferences: graph.preferencesRepository,
|
preferences: graph.preferencesRepository,
|
||||||
messages: graph.messages
|
messages: graph.messages
|
||||||
@@ -53,7 +38,7 @@ struct RootView: View {
|
|||||||
environment: dependencies.environment,
|
environment: dependencies.environment,
|
||||||
deviceInfoProvider: dependencies.deviceInfoProvider,
|
deviceInfoProvider: dependencies.deviceInfoProvider,
|
||||||
fileSystemService: dependencies.fileSystemService,
|
fileSystemService: dependencies.fileSystemService,
|
||||||
repository: graph.gateway,
|
repository: graph.coreRepository,
|
||||||
preferences: graph.preferencesRepository,
|
preferences: graph.preferencesRepository,
|
||||||
notifications: dependencies.notificationService,
|
notifications: dependencies.notificationService,
|
||||||
messages: graph.messages,
|
messages: graph.messages,
|
||||||
@@ -75,6 +60,11 @@ struct RootView: View {
|
|||||||
approvals: graph.approvalCoordinator,
|
approvals: graph.approvalCoordinator,
|
||||||
sendModel: sendModel
|
sendModel: sendModel
|
||||||
)
|
)
|
||||||
|
ContactPromptLayer(
|
||||||
|
contacts: graph.contactsModel,
|
||||||
|
receiveModel: receiveModel,
|
||||||
|
approvals: graph.approvalCoordinator
|
||||||
|
)
|
||||||
// Top-most so the toast is never covered by the approval overlay's
|
// Top-most so the toast is never covered by the approval overlay's
|
||||||
// full-bleed clear layer. Observes the live `graph.messages` directly.
|
// full-bleed clear layer. Observes the live `graph.messages` directly.
|
||||||
SnackbarHost(controller: graph.messages)
|
SnackbarHost(controller: graph.messages)
|
||||||
@@ -93,15 +83,6 @@ struct RootView: View {
|
|||||||
}
|
}
|
||||||
.platformPickers(settingsModel: settingsModel)
|
.platformPickers(settingsModel: settingsModel)
|
||||||
.task { await consumeExternalInvitations() }
|
.task { await consumeExternalInvitations() }
|
||||||
#if DEBUG
|
|
||||||
// Once the (fixture) core reports ready, drive the app into the target screen.
|
|
||||||
.task(id: sendModel.coreState.isInitialized) {
|
|
||||||
guard let scenario = screenshotScenario, sendModel.coreState.isInitialized else { return }
|
|
||||||
appModel.selectDestination(.send)
|
|
||||||
sendModel.openTransfer(ScreenshotCoreGateway.transferId)
|
|
||||||
if scenario == .share { sendModel.openShare() }
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
.onChange(of: scenePhase) { _, phase in
|
.onChange(of: scenePhase) { _, phase in
|
||||||
switch phase {
|
switch phase {
|
||||||
case .active:
|
case .active:
|
||||||
@@ -112,6 +93,9 @@ struct RootView: View {
|
|||||||
// unfocused/occluded (common on macOS) live events may not have
|
// unfocused/occluded (common on macOS) live events may not have
|
||||||
// rendered, leaving progress/status stale.
|
// rendered, leaving progress/status stale.
|
||||||
Task { _ = await graph.coreRepository.refresh() }
|
Task { _ = await graph.coreRepository.refresh() }
|
||||||
|
// Opt-in and foreground-only: collecting transfers held for this
|
||||||
|
// device also tells every contact that the app was opened.
|
||||||
|
Task { await graph.contactsModel.checkForOffersOnForeground() }
|
||||||
case .background:
|
case .background:
|
||||||
graph.visibility.setForeground(false)
|
graph.visibility.setForeground(false)
|
||||||
// Hold the process open for iOS's grace window so an active
|
// Hold the process open for iOS's grace window so an active
|
||||||
@@ -189,9 +173,10 @@ struct RootView: View {
|
|||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private func screen(for destination: AppDestination, windowClass: WindowClass) -> some View {
|
private func screen(for destination: AppDestination, windowClass: WindowClass) -> some View {
|
||||||
switch destination {
|
switch destination {
|
||||||
case .send: SendScreen(model: sendModel, windowClass: windowClass)
|
case .send: SendScreen(model: sendModel, contacts: graph.contactsModel, windowClass: windowClass)
|
||||||
case .receive: ReceiveScreen(model: receiveModel, windowClass: windowClass)
|
case .receive: ReceiveScreen(model: receiveModel, windowClass: windowClass)
|
||||||
case .settings: SettingsScreen(model: settingsModel, windowClass: windowClass)
|
case .settings:
|
||||||
|
SettingsScreen(model: settingsModel, contacts: graph.contactsModel, windowClass: windowClass)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -307,3 +292,57 @@ import UIKit
|
|||||||
#else
|
#else
|
||||||
import AppKit
|
import AppKit
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
/// Hosts the device-history consent prompts, alongside `ApprovalLayer`.
|
||||||
|
///
|
||||||
|
/// Separate from the approval layer because the two never compete: an approval
|
||||||
|
/// belongs to a transfer this device is sending, and these belong to a device
|
||||||
|
/// asking to reach it. Both are suppressed while the other is up so the user is
|
||||||
|
/// never answering two modals at once.
|
||||||
|
private struct ContactPromptLayer: View {
|
||||||
|
@ObservedObject var contacts: ContactsModel
|
||||||
|
let receiveModel: ReceiveModel
|
||||||
|
@ObservedObject var approvals: ApprovalCoordinator
|
||||||
|
|
||||||
|
@State private var showPrompt = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ContactPromptHost(
|
||||||
|
isPresented: $showPrompt,
|
||||||
|
state: contacts.state,
|
||||||
|
onPairingResponse: { endpointId, accepted in
|
||||||
|
Task { await contacts.respondToPairing(endpointId: endpointId, accepted: accepted) }
|
||||||
|
},
|
||||||
|
onOfferResponse: { offerId, accepted in
|
||||||
|
Task {
|
||||||
|
// The ticket is released only on acceptance; the receive then
|
||||||
|
// runs through the ordinary path so the platform picks the
|
||||||
|
// destination.
|
||||||
|
if let ticket = await contacts.respondToOffer(offerId: offerId, accepted: accepted) {
|
||||||
|
receiveModel.receiveOffered(ticket: ticket)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSuggestionResponse: { suggestion, accepted in
|
||||||
|
if accepted {
|
||||||
|
Task { await contacts.acceptSuggestion(suggestion) }
|
||||||
|
} else {
|
||||||
|
contacts.declineSuggestion(suggestion)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.onChange(of: promptKey) { _, key in
|
||||||
|
showPrompt = key != nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One identity for "is there something to answer", so an offer replacing a
|
||||||
|
/// pairing prompt re-presents rather than silently swapping content.
|
||||||
|
private var promptKey: String? {
|
||||||
|
guard approvals.state.current == nil else { return nil }
|
||||||
|
if let offer = contacts.state.currentOffer { return "offer-\(offer.offerId)" }
|
||||||
|
if let pairing = contacts.state.currentPairing { return "pairing-\(pairing.endpointId)" }
|
||||||
|
if let suggestion = contacts.state.currentSuggestion { return "suggest-\(suggestion.endpointId)" }
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,109 +0,0 @@
|
|||||||
#if DEBUG
|
|
||||||
import Foundation
|
|
||||||
import Combine
|
|
||||||
import VnidropCore
|
|
||||||
|
|
||||||
/// Which marketing screen to stage. Selected via the `-VniScreenshot <name>` launch
|
|
||||||
/// argument (read from `NSArgumentDomain`), set by the App Store screenshot UI test.
|
|
||||||
enum ScreenshotScenario: String {
|
|
||||||
case transferDetails = "transfer-details" // Send Anywhere: the detail view
|
|
||||||
case share // Share Securely: the QR / share panel
|
|
||||||
case approval // Choose Receivers: the receive-request modal
|
|
||||||
|
|
||||||
/// The active scenario for this launch, or `nil` in a normal run.
|
|
||||||
static var current: ScreenshotScenario? {
|
|
||||||
guard let raw = UserDefaults.standard.string(forKey: "VniScreenshot") else { return nil }
|
|
||||||
return ScreenshotScenario(rawValue: raw)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A fixture `CoreGateway` that publishes deterministic content instead of driving
|
|
||||||
/// the Rust core, so App Store screenshots are stable and localized. Only the reads
|
|
||||||
/// the screenshot screens need are meaningful; mutations are inert.
|
|
||||||
@MainActor
|
|
||||||
final class ScreenshotCoreGateway: CoreGateway {
|
|
||||||
private let scenario: ScreenshotScenario
|
|
||||||
private let subject: CurrentValueSubject<CoreState, Never>
|
|
||||||
private let signalSubject = PassthroughSubject<CoreSignal, Never>()
|
|
||||||
|
|
||||||
/// Deterministic fixture transfer shown across every scenario.
|
|
||||||
static let transferId: UInt64 = 1
|
|
||||||
private let fixtureTransfer = Transfer(
|
|
||||||
localId: "screenshot-1",
|
|
||||||
transferId: ScreenshotCoreGateway.transferId,
|
|
||||||
direction: .send,
|
|
||||||
status: .sharing,
|
|
||||||
peerId: nil,
|
|
||||||
transferName: "Transfer.MOV",
|
|
||||||
contentHash: "b1946ac92492d2347c6235b4d2611184",
|
|
||||||
fileCount: 1,
|
|
||||||
totalSize: 9_100_000,
|
|
||||||
ticket: "vnd://screenshot-demo-ticket-abcdefghijklmnopqrstuvwxyz0123456789",
|
|
||||||
accessPolicy: .requireApproval,
|
|
||||||
createdAt: 1_722_000_000,
|
|
||||||
updatedAt: 1_722_000_000
|
|
||||||
)
|
|
||||||
|
|
||||||
init(scenario: ScreenshotScenario) {
|
|
||||||
self.scenario = scenario
|
|
||||||
self.subject = CurrentValueSubject(CoreState())
|
|
||||||
}
|
|
||||||
|
|
||||||
var state: CoreState { subject.value }
|
|
||||||
var statePublisher: AnyPublisher<CoreState, Never> { subject.eraseToAnyPublisher() }
|
|
||||||
var signals: AnyPublisher<CoreSignal, Never> { signalSubject.eraseToAnyPublisher() }
|
|
||||||
|
|
||||||
func initialize(appDataDir: String, networkConfiguration: RelayConfiguration) async -> Result<Void, Error> {
|
|
||||||
subject.value = CoreState(
|
|
||||||
isInitialized: true,
|
|
||||||
status: CoreStatus(endpointId: "screenshot-endpoint", activeTransfers: 1, activeShares: 1),
|
|
||||||
events: [],
|
|
||||||
transfers: [fixtureTransfer],
|
|
||||||
lastShare: nil,
|
|
||||||
lastInspection: nil
|
|
||||||
)
|
|
||||||
// Nudge the approval coordinator to (re)read requests for the sharing transfer.
|
|
||||||
signalSubject.send(.approvalChanged(transferId: Self.transferId))
|
|
||||||
return .success(())
|
|
||||||
}
|
|
||||||
|
|
||||||
func receiverRequests(transferId: UInt64) async -> Result<[ReceiverRequestModel], Error> {
|
|
||||||
guard scenario == .approval else { return .success([]) }
|
|
||||||
return .success([
|
|
||||||
ReceiverRequestModel(
|
|
||||||
id: "screenshot-request-1",
|
|
||||||
transferId: Self.transferId,
|
|
||||||
remoteEndpointId: "k51qzi5uqu5d-screenshot-peer-endpoint-identity",
|
|
||||||
transferName: "Transfer.MOV",
|
|
||||||
receiverName: nil,
|
|
||||||
receiverDeviceName: "Mac mini",
|
|
||||||
appVersion: "1.0",
|
|
||||||
status: .requested,
|
|
||||||
reason: nil,
|
|
||||||
requestedAt: 1_722_000_000,
|
|
||||||
respondedAt: nil,
|
|
||||||
completedAt: nil
|
|
||||||
)
|
|
||||||
])
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Inert mutations (screenshots never exercise these)
|
|
||||||
|
|
||||||
func shutdown() {}
|
|
||||||
func shareSources(_ sources: [ShareSource], transferName: String, senderName: String, accessPolicy: ShareAccessPolicy) async -> Result<Share, Error> {
|
|
||||||
.failure(ScreenshotGatewayError.unsupported)
|
|
||||||
}
|
|
||||||
func inspectTicket(_ ticket: String) async -> Result<TicketInspectionModel, Error> { .failure(ScreenshotGatewayError.unsupported) }
|
|
||||||
func receive(ticket: String, outputDir: String, receiverName: String) async -> Result<Void, Error> { .success(()) }
|
|
||||||
func receiveIntoSecurityScopedDirectory(ticket: String, outputDirectoryUrl: String, receiverName: String) async -> Result<Void, Error> { .success(()) }
|
|
||||||
func cancel(transferId: UInt64) async -> Result<Void, Error> { .success(()) }
|
|
||||||
func delete(transferId: UInt64) async -> Result<Void, Error> { .success(()) }
|
|
||||||
func clearReceiveHistory() async -> Result<UInt64, Error> { .success(0) }
|
|
||||||
func storageUsage() async -> Result<CoreStorageUsageModel, Error> { .success(CoreStorageUsageModel(blobStoreBytes: 0, appDataBytes: 0)) }
|
|
||||||
func receivedArtifacts() async -> Result<[ReceivedArtifactModel], Error> { .success([]) }
|
|
||||||
func respondReceiverRequest(requestId: String, accepted: Bool, reason: String?) async -> Result<Void, Error> { .success(()) }
|
|
||||||
func refresh() async -> Result<Void, Error> { .success(()) }
|
|
||||||
}
|
|
||||||
|
|
||||||
private enum ScreenshotGatewayError: Error { case unsupported }
|
|
||||||
#endif
|
|
||||||
@@ -122,6 +122,14 @@ struct AppPreferences: Equatable {
|
|||||||
var themeMode: ThemeMode
|
var themeMode: ThemeMode
|
||||||
var diagnosticsInstallId: String
|
var diagnosticsInstallId: String
|
||||||
var relayConfiguration: RelayConfiguration
|
var relayConfiguration: RelayConfiguration
|
||||||
|
/// Idle lifetime applied to grants this device issues from now on.
|
||||||
|
var grantLifetime: GrantLifetimeOption
|
||||||
|
/// Devices the user declined to remember. Persisted so a repeat transfer
|
||||||
|
/// with the same device does not re-ask forever.
|
||||||
|
var declinedPairingSuggestions: Set<String>
|
||||||
|
/// Whether opening the app asks remembered devices for waiting transfers.
|
||||||
|
/// Off by default: it reveals app-open times to every contact.
|
||||||
|
var checkForOffersOnOpen: Bool
|
||||||
}
|
}
|
||||||
|
|
||||||
struct AppPreferencesDefaults {
|
struct AppPreferencesDefaults {
|
||||||
@@ -145,6 +153,9 @@ final class AppPreferencesRepository: ObservableObject {
|
|||||||
static let themeMode = "theme_mode"
|
static let themeMode = "theme_mode"
|
||||||
static let diagnosticsInstallId = "diagnostics_install_id"
|
static let diagnosticsInstallId = "diagnostics_install_id"
|
||||||
static let relayConfiguration = "relay_configuration"
|
static let relayConfiguration = "relay_configuration"
|
||||||
|
static let grantLifetime = "grant_lifetime"
|
||||||
|
static let declinedPairingSuggestions = "declined_pairing_suggestions"
|
||||||
|
static let checkForOffersOnOpen = "check_for_offers_on_open"
|
||||||
}
|
}
|
||||||
|
|
||||||
init(defaults: UserDefaults = .standard, fallback: AppPreferencesDefaults) {
|
init(defaults: UserDefaults = .standard, fallback: AppPreferencesDefaults) {
|
||||||
@@ -158,12 +169,18 @@ final class AppPreferencesRepository: ObservableObject {
|
|||||||
let folder = resolveReceiveFolder(defaults, fallback: fallback.receiveFolder)
|
let folder = resolveReceiveFolder(defaults, fallback: fallback.receiveFolder)
|
||||||
let themeMode = defaults.string(forKey: Key.themeMode).flatMap(ThemeMode.init(rawValue:)) ?? fallback.themeMode
|
let themeMode = defaults.string(forKey: Key.themeMode).flatMap(ThemeMode.init(rawValue:)) ?? fallback.themeMode
|
||||||
let installId = defaults.string(forKey: Key.diagnosticsInstallId) ?? ""
|
let installId = defaults.string(forKey: Key.diagnosticsInstallId) ?? ""
|
||||||
|
let grantLifetime = defaults.string(forKey: Key.grantLifetime)
|
||||||
|
.flatMap(GrantLifetimeOption.init(rawValue:)) ?? .days90
|
||||||
|
let declined = Set(defaults.stringArray(forKey: Key.declinedPairingSuggestions) ?? [])
|
||||||
return AppPreferences(
|
return AppPreferences(
|
||||||
username: username,
|
username: username,
|
||||||
receiveFolder: folder,
|
receiveFolder: folder,
|
||||||
themeMode: themeMode,
|
themeMode: themeMode,
|
||||||
diagnosticsInstallId: installId,
|
diagnosticsInstallId: installId,
|
||||||
relayConfiguration: resolveRelayConfiguration(defaults)
|
relayConfiguration: resolveRelayConfiguration(defaults),
|
||||||
|
grantLifetime: grantLifetime,
|
||||||
|
declinedPairingSuggestions: declined,
|
||||||
|
checkForOffersOnOpen: defaults.bool(forKey: Key.checkForOffersOnOpen)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -209,6 +226,32 @@ final class AppPreferencesRepository: ObservableObject {
|
|||||||
setReceiveFolder(fallback.receiveFolder)
|
setReceiveFolder(fallback.receiveFolder)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func declinePairingSuggestion(_ endpointId: String) {
|
||||||
|
var declined = preferences.declinedPairingSuggestions
|
||||||
|
declined.insert(endpointId)
|
||||||
|
defaults.set(Array(declined), forKey: Key.declinedPairingSuggestions)
|
||||||
|
reload()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clears the decline so the device can be suggested again, used when the
|
||||||
|
/// user pairs with it deliberately.
|
||||||
|
func clearDeclinedPairingSuggestion(_ endpointId: String) {
|
||||||
|
var declined = preferences.declinedPairingSuggestions
|
||||||
|
guard declined.remove(endpointId) != nil else { return }
|
||||||
|
defaults.set(Array(declined), forKey: Key.declinedPairingSuggestions)
|
||||||
|
reload()
|
||||||
|
}
|
||||||
|
|
||||||
|
func setCheckForOffersOnOpen(_ enabled: Bool) {
|
||||||
|
defaults.set(enabled, forKey: Key.checkForOffersOnOpen)
|
||||||
|
reload()
|
||||||
|
}
|
||||||
|
|
||||||
|
func setGrantLifetime(_ lifetime: GrantLifetimeOption) {
|
||||||
|
defaults.set(lifetime.rawValue, forKey: Key.grantLifetime)
|
||||||
|
reload()
|
||||||
|
}
|
||||||
|
|
||||||
func setThemeMode(_ mode: ThemeMode) {
|
func setThemeMode(_ mode: ThemeMode) {
|
||||||
defaults.set(mode.rawValue, forKey: Key.themeMode)
|
defaults.set(mode.rawValue, forKey: Key.themeMode)
|
||||||
reload()
|
reload()
|
||||||
|
|||||||
@@ -47,4 +47,42 @@ protocol CoreGateway: AnyObject {
|
|||||||
func receiverRequests(transferId: UInt64) async -> Result<[ReceiverRequestModel], Error>
|
func receiverRequests(transferId: UInt64) async -> Result<[ReceiverRequestModel], Error>
|
||||||
func respondReceiverRequest(requestId: String, accepted: Bool, reason: String?) async -> Result<Void, Error>
|
func respondReceiverRequest(requestId: String, accepted: Bool, reason: String?) async -> Result<Void, Error>
|
||||||
func refresh() async -> Result<Void, Error>
|
func refresh() async -> Result<Void, Error>
|
||||||
|
|
||||||
|
// MARK: Device history
|
||||||
|
|
||||||
|
func contacts() async -> Result<[DeviceContact], Error>
|
||||||
|
func pendingPairings() async -> [PendingPairingModel]
|
||||||
|
func pendingOffers() async -> [IncomingOfferModel]
|
||||||
|
/// Hand a device a revocable capability to reach this one.
|
||||||
|
func allowDeviceToReachMe(endpointId: String, displayName: String?) async -> Result<Void, Error>
|
||||||
|
/// Accept or decline a device's offer to be remembered.
|
||||||
|
func respondToPairing(endpointId: String, accepted: Bool) async -> Result<Bool, Error>
|
||||||
|
/// Answer an incoming offer. Returns the ticket on acceptance, which the
|
||||||
|
/// caller passes to `receive` with a platform-appropriate destination.
|
||||||
|
func respondToOffer(offerId: String, accepted: Bool) async -> String?
|
||||||
|
func sendToContact(
|
||||||
|
endpointId: String,
|
||||||
|
sources: [ShareSource],
|
||||||
|
transferName: String,
|
||||||
|
senderName: String
|
||||||
|
) async -> Result<ContactSendOutcome, Error>
|
||||||
|
/// Offer an existing share to a remembered device, alongside its QR code.
|
||||||
|
func offerTransferToContact(
|
||||||
|
transferId: UInt64,
|
||||||
|
endpointId: String
|
||||||
|
) async -> Result<ContactSendOutcome, Error>
|
||||||
|
/// Transfers this device is holding for contacts that were not running.
|
||||||
|
func heldOffers() async -> Result<[HeldOfferModel], Error>
|
||||||
|
/// Ask remembered devices whether they hold anything for this one.
|
||||||
|
///
|
||||||
|
/// Only ever called from a foreground transition or an explicit user action:
|
||||||
|
/// it reveals to every contact that this device is awake.
|
||||||
|
func pollContactsForOffers() async -> Result<UInt64, Error>
|
||||||
|
func forgetContact(endpointId: String) async -> Result<Void, Error>
|
||||||
|
func forgetAllContacts() async -> Result<UInt64, Error>
|
||||||
|
func blockContact(endpointId: String) async -> Result<Void, Error>
|
||||||
|
func unblockContact(endpointId: String) async -> Result<Void, Error>
|
||||||
|
func blockedContacts() async -> Result<[String], Error>
|
||||||
|
func setContactLabel(endpointId: String, label: String?) async -> Result<Void, Error>
|
||||||
|
func setGrantLifetime(_ lifetime: GrantLifetimeOption) async
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -72,6 +72,16 @@ enum ShareAccessPolicy: Equatable, Sendable {
|
|||||||
case anyoneWithTransfer
|
case anyoneWithTransfer
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Where a picked selection is going.
|
||||||
|
///
|
||||||
|
/// A contact destination deliberately carries no access policy: the core forces
|
||||||
|
/// approval-required for offers, so exposing the choice here would imply a
|
||||||
|
/// setting that does not exist.
|
||||||
|
enum ShareDestination: Equatable, Sendable {
|
||||||
|
case invitation(accessPolicy: ShareAccessPolicy)
|
||||||
|
case contact(endpointId: String)
|
||||||
|
}
|
||||||
|
|
||||||
enum TransferDirection: Equatable, Sendable {
|
enum TransferDirection: Equatable, Sendable {
|
||||||
case send
|
case send
|
||||||
case receive
|
case receive
|
||||||
@@ -168,6 +178,10 @@ enum CoreSignal: Equatable, Sendable {
|
|||||||
case receiverHistoryChanged(transferId: UInt64)
|
case receiverHistoryChanged(transferId: UInt64)
|
||||||
/// Transfer status/history changed enough to re-read the durable snapshot.
|
/// Transfer status/history changed enough to re-read the durable snapshot.
|
||||||
case transfersChanged(transferId: UInt64)
|
case transfersChanged(transferId: UInt64)
|
||||||
|
/// Device history changed: a contact was added, forgotten, or blocked.
|
||||||
|
case contactsChanged
|
||||||
|
/// An incoming offer arrived or was answered.
|
||||||
|
case offersChanged
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Transfer helpers (ported from AppUiModels.kt)
|
// MARK: - Transfer helpers (ported from AppUiModels.kt)
|
||||||
@@ -186,3 +200,112 @@ extension TransferStatus {
|
|||||||
self == .done || self == .failed || self == .cancelled
|
self == .done || self == .failed || self == .cancelled
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Device history
|
||||||
|
|
||||||
|
/// A device the user has chosen to remember.
|
||||||
|
///
|
||||||
|
/// `localLabel` is the user's own name for the device and is authoritative for
|
||||||
|
/// display; `remoteDisplayName` is whatever the device last called itself and is
|
||||||
|
/// untrusted. The endpoint id is the only real identity.
|
||||||
|
struct DeviceContact: Equatable, Identifiable, Sendable {
|
||||||
|
let endpointId: String
|
||||||
|
let localLabel: String?
|
||||||
|
let remoteDisplayName: String?
|
||||||
|
let lastTransferAt: Int64?
|
||||||
|
let createdAt: Int64
|
||||||
|
/// Whether a live grant is held. False once the peer revoked, the grant
|
||||||
|
/// lapsed, or the peer reinstalled and lost its identity.
|
||||||
|
let canSend: Bool
|
||||||
|
|
||||||
|
var id: String { endpointId }
|
||||||
|
|
||||||
|
/// Name to show, preferring the local label the peer cannot influence.
|
||||||
|
var displayName: String {
|
||||||
|
if let localLabel, !localLabel.isEmpty { return localLabel }
|
||||||
|
if let remoteDisplayName, !remoteDisplayName.isEmpty { return remoteDisplayName }
|
||||||
|
return String(localized: L10n.Approval.nearbyDevice)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Short prefix of the endpoint id, for telling apart devices claiming the
|
||||||
|
/// same name.
|
||||||
|
var shortFingerprint: String { String(endpointId.prefix(8)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A device offering to be remembered, awaiting this user's decision.
|
||||||
|
struct PendingPairingModel: Equatable, Identifiable, Sendable {
|
||||||
|
let endpointId: String
|
||||||
|
let displayName: String?
|
||||||
|
let receivedAt: Int64
|
||||||
|
|
||||||
|
var id: String { endpointId }
|
||||||
|
|
||||||
|
var resolvedName: String {
|
||||||
|
guard let displayName, !displayName.isEmpty else {
|
||||||
|
return String(localized: L10n.Approval.nearbyDevice)
|
||||||
|
}
|
||||||
|
return displayName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A transfer a remembered device is offering. Carries no ticket: that is a
|
||||||
|
/// capability and the core releases it only once the user accepts.
|
||||||
|
struct IncomingOfferModel: Equatable, Identifiable, Sendable {
|
||||||
|
let offerId: String
|
||||||
|
let fromEndpointId: String
|
||||||
|
let senderDisplayName: String?
|
||||||
|
let transferName: String
|
||||||
|
let fileCount: UInt64
|
||||||
|
let totalBytes: UInt64
|
||||||
|
let receivedAt: Int64
|
||||||
|
|
||||||
|
var id: String { offerId }
|
||||||
|
|
||||||
|
var resolvedSenderName: String {
|
||||||
|
guard let senderDisplayName, !senderDisplayName.isEmpty else {
|
||||||
|
return String(localized: L10n.Approval.nearbyDevice)
|
||||||
|
}
|
||||||
|
return senderDisplayName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A transfer waiting for its target device to come back online.
|
||||||
|
struct HeldOfferModel: Equatable, Identifiable, Sendable {
|
||||||
|
let offerId: String
|
||||||
|
let endpointId: String
|
||||||
|
let transferId: UInt64
|
||||||
|
let transferName: String
|
||||||
|
let fileCount: UInt64
|
||||||
|
let totalBytes: UInt64
|
||||||
|
let createdAt: Int64
|
||||||
|
|
||||||
|
var id: String { offerId }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Outcome of sending straight to a remembered device.
|
||||||
|
struct ContactSendOutcome: Equatable, Sendable {
|
||||||
|
let share: Share
|
||||||
|
/// False when the device was not running: the transfer is held locally and
|
||||||
|
/// collected the next time that device opens the app.
|
||||||
|
let delivered: Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How long a remembered device stays reachable while unused. The countdown
|
||||||
|
/// restarts on every transfer.
|
||||||
|
enum GrantLifetimeOption: String, CaseIterable, Identifiable, Sendable {
|
||||||
|
case days30
|
||||||
|
case days90
|
||||||
|
case days365
|
||||||
|
case never
|
||||||
|
|
||||||
|
var id: String { rawValue }
|
||||||
|
|
||||||
|
var days: Int? {
|
||||||
|
switch self {
|
||||||
|
case .days30: return 30
|
||||||
|
case .days90: return 90
|
||||||
|
case .days365: return 365
|
||||||
|
case .never: return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -53,9 +53,10 @@ struct NativeCoreBindingFactory: CoreBindingFactory {
|
|||||||
case .localOnly:
|
case .localOnly:
|
||||||
nativeConfiguration = CoreNetworkConfig(mode: .localOnly, relayUrls: [])
|
nativeConfiguration = CoreNetworkConfig(mode: .localOnly, relayUrls: [])
|
||||||
}
|
}
|
||||||
return try VnidropCore.initializeWithNetworkConfig(
|
return try VnidropCore.initializeWithExperimentalSavedDevices(
|
||||||
appDataDir: appDataDir,
|
appDataDir: appDataDir,
|
||||||
eventSink: eventSink,
|
eventSink: eventSink,
|
||||||
|
limits: defaultCoreLimits(),
|
||||||
networkConfig: nativeConfiguration
|
networkConfig: nativeConfiguration
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -293,6 +294,122 @@ final class CoreRepository: ObservableObject, CoreGateway {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Device history
|
||||||
|
|
||||||
|
func contacts() async -> Result<[DeviceContact], Error> {
|
||||||
|
await runCore {
|
||||||
|
try self.requireCore().listContacts().map { $0.toModel() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func pendingPairings() async -> [PendingPairingModel] {
|
||||||
|
let result = await runCore { try self.requireCore().listPendingPairings().map { $0.toModel() } }
|
||||||
|
return (try? result.get()) ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
func pendingOffers() async -> [IncomingOfferModel] {
|
||||||
|
let result = await runCore { try self.requireCore().listPendingOffers().map { $0.toModel() } }
|
||||||
|
return (try? result.get()) ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
func allowDeviceToReachMe(endpointId: String, displayName: String?) async -> Result<Void, Error> {
|
||||||
|
await runCore {
|
||||||
|
try self.requireCore().allowDeviceToReachMe(endpointId: endpointId, displayName: displayName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func respondToPairing(endpointId: String, accepted: Bool) async -> Result<Bool, Error> {
|
||||||
|
await runCore {
|
||||||
|
try self.requireCore().respondToPairing(endpointId: endpointId, accepted: accepted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func respondToOffer(offerId: String, accepted: Bool) async -> String? {
|
||||||
|
let result = await runCore {
|
||||||
|
try self.requireCore().respondToOffer(offerId: offerId, accepted: accepted)
|
||||||
|
}
|
||||||
|
return (try? result.get()) ?? nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func sendToContact(
|
||||||
|
endpointId: String,
|
||||||
|
sources: [ShareSource],
|
||||||
|
transferName: String,
|
||||||
|
senderName: String
|
||||||
|
) async -> Result<ContactSendOutcome, Error> {
|
||||||
|
guard !isNetworkTransitionInProgress else {
|
||||||
|
return .failure(CoreNetworkLifecycleError.transitionInProgress)
|
||||||
|
}
|
||||||
|
guard !sources.isEmpty else {
|
||||||
|
return .failure(InvitationError.shareEmpty)
|
||||||
|
}
|
||||||
|
return await runCore {
|
||||||
|
// The access mode is forced to approval-required by the core for
|
||||||
|
// offers; passing it here only keeps the metadata well-formed.
|
||||||
|
let result = try self.requireCore().sendToContact(
|
||||||
|
endpointId: endpointId,
|
||||||
|
sources: sources,
|
||||||
|
metadata: ShareMetadataInput(
|
||||||
|
transferId: Self.nextTransferId(),
|
||||||
|
transferName: transferName.isEmpty ? nil : transferName,
|
||||||
|
senderName: senderName.isEmpty ? nil : senderName,
|
||||||
|
accessMode: .approvalRequired
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return ContactSendOutcome(share: result.share.toModel(), delivered: result.delivered)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func offerTransferToContact(
|
||||||
|
transferId: UInt64,
|
||||||
|
endpointId: String
|
||||||
|
) async -> Result<ContactSendOutcome, Error> {
|
||||||
|
await runCore {
|
||||||
|
let result = try self.requireCore().offerTransferToContact(
|
||||||
|
transferId: transferId, endpointId: endpointId
|
||||||
|
)
|
||||||
|
return ContactSendOutcome(share: result.share.toModel(), delivered: result.delivered)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func heldOffers() async -> Result<[HeldOfferModel], Error> {
|
||||||
|
await runCore { try self.requireCore().listHeldOffers().map { $0.toModel() } }
|
||||||
|
}
|
||||||
|
|
||||||
|
func pollContactsForOffers() async -> Result<UInt64, Error> {
|
||||||
|
await runCore { try self.requireCore().pollContactsForOffers() }
|
||||||
|
}
|
||||||
|
|
||||||
|
func forgetContact(endpointId: String) async -> Result<Void, Error> {
|
||||||
|
await runCore { try self.requireCore().forgetContact(endpointId: endpointId) }
|
||||||
|
}
|
||||||
|
|
||||||
|
func forgetAllContacts() async -> Result<UInt64, Error> {
|
||||||
|
await runCore { try self.requireCore().forgetAllContacts() }
|
||||||
|
}
|
||||||
|
|
||||||
|
func blockContact(endpointId: String) async -> Result<Void, Error> {
|
||||||
|
await runCore { try self.requireCore().blockContact(endpointId: endpointId) }
|
||||||
|
}
|
||||||
|
|
||||||
|
func unblockContact(endpointId: String) async -> Result<Void, Error> {
|
||||||
|
await runCore { try self.requireCore().unblockContact(endpointId: endpointId) }
|
||||||
|
}
|
||||||
|
|
||||||
|
func blockedContacts() async -> Result<[String], Error> {
|
||||||
|
await runCore { try self.requireCore().listBlockedContacts() }
|
||||||
|
}
|
||||||
|
|
||||||
|
func setContactLabel(endpointId: String, label: String?) async -> Result<Void, Error> {
|
||||||
|
await runCore {
|
||||||
|
try self.requireCore().setContactLabel(endpointId: endpointId, label: label)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func setGrantLifetime(_ lifetime: GrantLifetimeOption) async {
|
||||||
|
_ = await runCore { try self.requireCore().setGrantLifetime(lifetime: lifetime.toNative()) }
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Event sink handling (ported from CoreRepository.sink)
|
// MARK: - Event sink handling (ported from CoreRepository.sink)
|
||||||
|
|
||||||
private func handle(event: CoreEvent) {
|
private func handle(event: CoreEvent) {
|
||||||
@@ -302,6 +419,14 @@ final class CoreRepository: ObservableObject, CoreGateway {
|
|||||||
if events.count > Self.maxEvents { events = Array(events.prefix(Self.maxEvents)) }
|
if events.count > Self.maxEvents { events = Array(events.prefix(Self.maxEvents)) }
|
||||||
state.events = events
|
state.events = events
|
||||||
|
|
||||||
|
// Contacts and offers are endpoint-scoped: they carry no transfer id, so
|
||||||
|
// they are dispatched before the transfer-scoped handling below.
|
||||||
|
switch model.phase {
|
||||||
|
case "contacts": signalsSubject.send(.contactsChanged)
|
||||||
|
case "offer": signalsSubject.send(.offersChanged)
|
||||||
|
default: break
|
||||||
|
}
|
||||||
|
|
||||||
guard let transferId = model.transferId else { return }
|
guard let transferId = model.transferId else { return }
|
||||||
switch model.phase {
|
switch model.phase {
|
||||||
case "approval", "access": signalsSubject.send(.approvalChanged(transferId: transferId))
|
case "approval", "access": signalsSubject.send(.approvalChanged(transferId: transferId))
|
||||||
@@ -519,3 +644,61 @@ private extension ReceiverRequest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
extension ContactSummary {
|
||||||
|
func toModel() -> DeviceContact {
|
||||||
|
DeviceContact(
|
||||||
|
endpointId: endpointId,
|
||||||
|
localLabel: localLabel,
|
||||||
|
remoteDisplayName: remoteDisplayName,
|
||||||
|
lastTransferAt: lastTransferAt,
|
||||||
|
createdAt: createdAt,
|
||||||
|
canSend: canSend
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension PendingPairing {
|
||||||
|
func toModel() -> PendingPairingModel {
|
||||||
|
PendingPairingModel(endpointId: endpointId, displayName: displayName, receivedAt: receivedAt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension IncomingOffer {
|
||||||
|
func toModel() -> IncomingOfferModel {
|
||||||
|
IncomingOfferModel(
|
||||||
|
offerId: offerId,
|
||||||
|
fromEndpointId: fromEndpointId,
|
||||||
|
senderDisplayName: senderDisplayName,
|
||||||
|
transferName: transferName,
|
||||||
|
fileCount: fileCount,
|
||||||
|
totalBytes: totalBytes,
|
||||||
|
receivedAt: receivedAt
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension HeldOfferSummary {
|
||||||
|
func toModel() -> HeldOfferModel {
|
||||||
|
HeldOfferModel(
|
||||||
|
offerId: offerId,
|
||||||
|
endpointId: endpointId,
|
||||||
|
transferId: transferId,
|
||||||
|
transferName: transferName,
|
||||||
|
fileCount: fileCount,
|
||||||
|
totalBytes: totalBytes,
|
||||||
|
createdAt: createdAt
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension GrantLifetimeOption {
|
||||||
|
func toNative() -> GrantLifetimeSetting {
|
||||||
|
switch self {
|
||||||
|
case .days30: return .days30
|
||||||
|
case .days90: return .days90
|
||||||
|
case .days365: return .days365
|
||||||
|
case .never: return .never
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -33,13 +33,16 @@ protocol FileSystemService {
|
|||||||
func revealReceiveFolder(_ folder: ReceiveFolder) async -> Result<Void, Error>
|
func revealReceiveFolder(_ folder: ReceiveFolder) async -> Result<Void, Error>
|
||||||
/// Releases only app-owned picker copies; never deletes original user sources.
|
/// Releases only app-owned picker copies; never deletes original user sources.
|
||||||
func discardPickedFiles(_ files: [PickedShareFile]) async
|
func discardPickedFiles(_ files: [PickedShareFile]) async
|
||||||
|
/// Imports a picked selection, either as an invitation or straight to a
|
||||||
|
/// remembered device. One entry point so the platform's security-scoped
|
||||||
|
/// access handling covers both.
|
||||||
func sharePickedFiles(
|
func sharePickedFiles(
|
||||||
repository: CoreGateway,
|
repository: CoreGateway,
|
||||||
files: [PickedShareFile],
|
files: [PickedShareFile],
|
||||||
transferName: String,
|
transferName: String,
|
||||||
senderName: String,
|
senderName: String,
|
||||||
accessPolicy: ShareAccessPolicy
|
destination: ShareDestination
|
||||||
) async -> Result<Share, Error>
|
) async -> Result<ContactSendOutcome, Error>
|
||||||
}
|
}
|
||||||
|
|
||||||
extension FileSystemService {
|
extension FileSystemService {
|
||||||
|
|||||||
185
apple/VniDrop/Features/Contacts/ContactPrompts.swift
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
import SFSafeSymbols
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// Consent prompts for device history, presented as sheets like the receiver
|
||||||
|
/// approval modal.
|
||||||
|
///
|
||||||
|
/// Both are dismissable by answering only. An incoming offer in particular must
|
||||||
|
/// not be acceptable by accident, and a swipe-away would leave the sender
|
||||||
|
/// waiting on a decision that never comes.
|
||||||
|
struct ContactPromptHost: View {
|
||||||
|
/// Driven by the host so a prompt is never presented while another sheet is
|
||||||
|
/// still animating out — macOS silently drops the second one.
|
||||||
|
@Binding var isPresented: Bool
|
||||||
|
let state: ContactsState
|
||||||
|
let onPairingResponse: (String, Bool) -> Void
|
||||||
|
let onOfferResponse: (String, Bool) -> Void
|
||||||
|
let onSuggestionResponse: (PairingSuggestion, Bool) -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Color.clear
|
||||||
|
.sheet(isPresented: $isPresented) {
|
||||||
|
// Ordered by who is waiting: a sender is blocked on an offer, a
|
||||||
|
// pairing request keeps until its consent window lapses, and a
|
||||||
|
// post-transfer suggestion has nobody waiting at all.
|
||||||
|
if let offer = state.currentOffer {
|
||||||
|
OfferSheet(
|
||||||
|
offer: offer,
|
||||||
|
busy: state.busyOfferIds.contains(offer.offerId),
|
||||||
|
onRespond: onOfferResponse
|
||||||
|
)
|
||||||
|
.interactiveDismissDisabled(true)
|
||||||
|
.modifier(ContactPromptDetents())
|
||||||
|
} else if let pairing = state.currentPairing {
|
||||||
|
PairingSheet(
|
||||||
|
pairing: pairing,
|
||||||
|
busy: state.busyEndpoints.contains(pairing.endpointId),
|
||||||
|
onRespond: onPairingResponse
|
||||||
|
)
|
||||||
|
.interactiveDismissDisabled(true)
|
||||||
|
.modifier(ContactPromptDetents())
|
||||||
|
} else if let suggestion = state.currentSuggestion {
|
||||||
|
// Lowest priority: nobody is waiting on this answer, it just
|
||||||
|
// follows a transfer that already finished.
|
||||||
|
SuggestionSheet(
|
||||||
|
suggestion: suggestion,
|
||||||
|
busy: state.busyEndpoints.contains(suggestion.endpointId),
|
||||||
|
onRespond: onSuggestionResponse
|
||||||
|
)
|
||||||
|
.interactiveDismissDisabled(true)
|
||||||
|
.modifier(ContactPromptDetents())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct ContactPromptDetents: ViewModifier {
|
||||||
|
func body(content: Content) -> some View {
|
||||||
|
#if os(iOS)
|
||||||
|
content.presentationDetents([.medium])
|
||||||
|
#else
|
||||||
|
content.frame(minWidth: 420, minHeight: 300)
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// "A remembered device wants to send you files."
|
||||||
|
private struct OfferSheet: View {
|
||||||
|
let offer: IncomingOfferModel
|
||||||
|
let busy: Bool
|
||||||
|
let onRespond: (String, Bool) -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
Image(systemSymbol: .trayAndArrowDownFill)
|
||||||
|
.font(.system(size: 44))
|
||||||
|
.foregroundStyle(.tint)
|
||||||
|
.padding(.top, 12)
|
||||||
|
Text(String(localized: L10n.Offer.title))
|
||||||
|
.font(.title2).fontWeight(.semibold)
|
||||||
|
Text(L10n.Offer.body(device: offer.resolvedSenderName, transferName: offer.transferName))
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
Text(L10n.Transfer.fileCount(count: Int(offer.fileCount)))
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Button(role: .cancel) {
|
||||||
|
onRespond(offer.offerId, false)
|
||||||
|
} label: {
|
||||||
|
Text(String(localized: L10n.Offer.decline)).frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
Button {
|
||||||
|
onRespond(offer.offerId, true)
|
||||||
|
} label: {
|
||||||
|
Text(String(localized: L10n.Offer.accept)).frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
}
|
||||||
|
.disabled(busy)
|
||||||
|
}
|
||||||
|
.padding(20)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// "This device offered to let you reach it. Remember it?"
|
||||||
|
private struct PairingSheet: View {
|
||||||
|
let pairing: PendingPairingModel
|
||||||
|
let busy: Bool
|
||||||
|
let onRespond: (String, Bool) -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
Image(systemSymbol: .macbookAndIphone)
|
||||||
|
.font(.system(size: 44))
|
||||||
|
.foregroundStyle(.tint)
|
||||||
|
.padding(.top, 12)
|
||||||
|
Text(String(localized: L10n.Pairing.requestTitle))
|
||||||
|
.font(.title2).fontWeight(.semibold)
|
||||||
|
Text(L10n.Pairing.requestBody(device: pairing.resolvedName))
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
// Names are peer-supplied; the endpoint id is what actually identifies
|
||||||
|
// the device.
|
||||||
|
Text(L10n.Approval.endpointId(deviceId: pairing.endpointId))
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Button(role: .cancel) {
|
||||||
|
onRespond(pairing.endpointId, false)
|
||||||
|
} label: {
|
||||||
|
Text(String(localized: L10n.Pairing.decline)).frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
Button {
|
||||||
|
onRespond(pairing.endpointId, true)
|
||||||
|
} label: {
|
||||||
|
Text(String(localized: L10n.Pairing.accept)).frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
}
|
||||||
|
.disabled(busy)
|
||||||
|
}
|
||||||
|
.padding(20)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// "You just transferred with this device. Let it reach you next time?"
|
||||||
|
private struct SuggestionSheet: View {
|
||||||
|
let suggestion: PairingSuggestion
|
||||||
|
let busy: Bool
|
||||||
|
let onRespond: (PairingSuggestion, Bool) -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
Image(systemSymbol: .clockArrowCirclepath)
|
||||||
|
.font(.system(size: 44))
|
||||||
|
.foregroundStyle(.tint)
|
||||||
|
.padding(.top, 12)
|
||||||
|
Text(String(localized: L10n.Pairing.allowTitle))
|
||||||
|
.font(.title2).fontWeight(.semibold)
|
||||||
|
Text(L10n.Pairing.requestBody(device: suggestion.resolvedName))
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
Text(String(localized: L10n.Pairing.allowBody))
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Button(role: .cancel) {
|
||||||
|
onRespond(suggestion, false)
|
||||||
|
} label: {
|
||||||
|
Text(String(localized: L10n.Pairing.decline)).frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
Button {
|
||||||
|
onRespond(suggestion, true)
|
||||||
|
} label: {
|
||||||
|
Text(String(localized: L10n.Pairing.allowConfirm)).frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
}
|
||||||
|
.disabled(busy)
|
||||||
|
}
|
||||||
|
.padding(20)
|
||||||
|
}
|
||||||
|
}
|
||||||
451
apple/VniDrop/Features/Contacts/ContactsModel.swift
Normal file
@@ -0,0 +1,451 @@
|
|||||||
|
import Combine
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// A device worth remembering after a completed transfer.
|
||||||
|
///
|
||||||
|
/// Only a suggestion: nothing is issued until the user agrees, because being
|
||||||
|
/// reachable is a standing permission and a transfer is a one-off.
|
||||||
|
struct PairingSuggestion: Equatable, Identifiable {
|
||||||
|
let endpointId: String
|
||||||
|
let displayName: String?
|
||||||
|
let transferName: String?
|
||||||
|
|
||||||
|
var id: String { endpointId }
|
||||||
|
|
||||||
|
var resolvedName: String {
|
||||||
|
guard let displayName, !displayName.isEmpty else {
|
||||||
|
return String(localized: L10n.Approval.nearbyDevice)
|
||||||
|
}
|
||||||
|
return displayName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ContactsState: Equatable {
|
||||||
|
var contacts: [DeviceContact] = []
|
||||||
|
var blocked: [String] = []
|
||||||
|
var pendingPairings: [PendingPairingModel] = []
|
||||||
|
var pendingOffers: [IncomingOfferModel] = []
|
||||||
|
var grantLifetime: GrantLifetimeOption = .days90
|
||||||
|
var isLoading = false
|
||||||
|
/// Endpoints with an in-flight decision, so a row can disable itself without
|
||||||
|
/// blocking the rest of the list.
|
||||||
|
var busyEndpoints: Set<String> = []
|
||||||
|
var busyOfferIds: Set<String> = []
|
||||||
|
var suggestions: [PairingSuggestion] = []
|
||||||
|
/// Transfers this device is holding for contacts that were not running.
|
||||||
|
var heldOffers: [HeldOfferModel] = []
|
||||||
|
var checkForOffersOnOpen = false
|
||||||
|
var isCheckingForOffers = false
|
||||||
|
var selectedEndpointId: String?
|
||||||
|
|
||||||
|
var selected: DeviceContact? {
|
||||||
|
guard let selectedEndpointId else { return nil }
|
||||||
|
return contacts.first { $0.endpointId == selectedEndpointId }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One prompt at a time: pairing consent is a modal decision and stacking
|
||||||
|
/// sheets on top of each other reads as a loop of dialogs.
|
||||||
|
var currentPairing: PendingPairingModel? { pendingPairings.first }
|
||||||
|
var currentOffer: IncomingOfferModel? { pendingOffers.first }
|
||||||
|
var currentSuggestion: PairingSuggestion? { suggestions.first }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drives the device-history surfaces: the list, its detail, and the two
|
||||||
|
/// consent prompts. Ported in the MVVM shape used by the other feature models.
|
||||||
|
@MainActor
|
||||||
|
final class ContactsModel: ObservableObject {
|
||||||
|
@Published private(set) var state = ContactsState()
|
||||||
|
|
||||||
|
/// Set when the detail screen asks for a file picker; the platform picker
|
||||||
|
/// modifier observes it, mirroring `SendModel`.
|
||||||
|
@Published var pendingFilePick = false
|
||||||
|
/// Device the picked files are destined for.
|
||||||
|
@Published private(set) var sendTarget: String?
|
||||||
|
|
||||||
|
private let repository: CoreGateway
|
||||||
|
private let messages: UiMessageController
|
||||||
|
private let preferences: AppPreferencesRepository
|
||||||
|
private let fileSystemService: FileSystemService
|
||||||
|
private var cancellables = Set<AnyCancellable>()
|
||||||
|
|
||||||
|
init(
|
||||||
|
repository: CoreGateway,
|
||||||
|
messages: UiMessageController,
|
||||||
|
preferences: AppPreferencesRepository,
|
||||||
|
fileSystemService: FileSystemService
|
||||||
|
) {
|
||||||
|
self.repository = repository
|
||||||
|
self.messages = messages
|
||||||
|
self.preferences = preferences
|
||||||
|
self.fileSystemService = fileSystemService
|
||||||
|
state.grantLifetime = preferences.preferences.grantLifetime
|
||||||
|
state.checkForOffersOnOpen = preferences.preferences.checkForOffersOnOpen
|
||||||
|
|
||||||
|
repository.signals
|
||||||
|
.sink { [weak self] signal in
|
||||||
|
guard let self else { return }
|
||||||
|
switch signal {
|
||||||
|
case .contactsChanged:
|
||||||
|
Task { await self.refresh() }
|
||||||
|
case .offersChanged:
|
||||||
|
Task { await self.refreshOffers() }
|
||||||
|
case .receiverHistoryChanged(let transferId), .transfersChanged(let transferId):
|
||||||
|
// A completed delivery names the device that received from us.
|
||||||
|
Task { await self.considerSendPeers(transferId: transferId) }
|
||||||
|
case .approvalChanged:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
|
||||||
|
repository.statePublisher
|
||||||
|
.sink { [weak self] core in
|
||||||
|
guard let self, core.isInitialized else { return }
|
||||||
|
self.considerReceivePeers(core.transfers)
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
|
||||||
|
repository.statePublisher
|
||||||
|
.map(\.isInitialized)
|
||||||
|
.removeDuplicates()
|
||||||
|
.sink { [weak self] isInitialized in
|
||||||
|
guard let self, isInitialized else { return }
|
||||||
|
// The core owns the lifetime; push the stored preference on start
|
||||||
|
// so a restart does not silently fall back to the default.
|
||||||
|
Task {
|
||||||
|
await self.repository.setGrantLifetime(self.state.grantLifetime)
|
||||||
|
await self.refresh()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Loading
|
||||||
|
|
||||||
|
func refresh() async {
|
||||||
|
state.isLoading = true
|
||||||
|
defer { state.isLoading = false }
|
||||||
|
|
||||||
|
switch await repository.contacts() {
|
||||||
|
case .success(let contacts):
|
||||||
|
state.contacts = contacts
|
||||||
|
case .failure(let error):
|
||||||
|
messages.error(error)
|
||||||
|
}
|
||||||
|
if case .success(let blocked) = await repository.blockedContacts() {
|
||||||
|
state.blocked = blocked
|
||||||
|
}
|
||||||
|
if case .success(let held) = await repository.heldOffers() {
|
||||||
|
state.heldOffers = held
|
||||||
|
}
|
||||||
|
state.pendingPairings = await repository.pendingPairings()
|
||||||
|
await refreshOffers()
|
||||||
|
}
|
||||||
|
|
||||||
|
func refreshOffers() async {
|
||||||
|
state.pendingOffers = await repository.pendingOffers()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Post-transfer suggestions
|
||||||
|
|
||||||
|
/// A completed receive names its sender, so that device becomes a candidate.
|
||||||
|
private func considerReceivePeers(_ transfers: [Transfer]) {
|
||||||
|
let candidates = transfers
|
||||||
|
.filter { $0.direction == .receive && $0.status == .done }
|
||||||
|
.compactMap { transfer -> PairingSuggestion? in
|
||||||
|
guard let peerId = transfer.peerId else { return nil }
|
||||||
|
return PairingSuggestion(
|
||||||
|
endpointId: peerId,
|
||||||
|
displayName: nil,
|
||||||
|
transferName: transfer.transferName
|
||||||
|
)
|
||||||
|
}
|
||||||
|
add(suggestions: candidates)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A completed delivery names the device we sent to.
|
||||||
|
private func considerSendPeers(transferId: UInt64) async {
|
||||||
|
guard case .success(let requests) = await repository.receiverRequests(transferId: transferId) else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let candidates = requests
|
||||||
|
.filter { $0.status == .completed }
|
||||||
|
.map { request in
|
||||||
|
PairingSuggestion(
|
||||||
|
endpointId: request.remoteEndpointId,
|
||||||
|
displayName: request.receiverName ?? request.receiverDeviceName,
|
||||||
|
transferName: request.transferName
|
||||||
|
)
|
||||||
|
}
|
||||||
|
add(suggestions: candidates)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Filters candidates down to devices actually worth asking about.
|
||||||
|
private func add(suggestions candidates: [PairingSuggestion]) {
|
||||||
|
let known = Set(state.contacts.map(\.endpointId))
|
||||||
|
let blocked = Set(state.blocked)
|
||||||
|
let declined = preferences.preferences.declinedPairingSuggestions
|
||||||
|
let pending = Set(state.suggestions.map(\.endpointId))
|
||||||
|
|
||||||
|
let fresh = candidates.filter { candidate in
|
||||||
|
!known.contains(candidate.endpointId)
|
||||||
|
&& !blocked.contains(candidate.endpointId)
|
||||||
|
&& !declined.contains(candidate.endpointId)
|
||||||
|
&& !pending.contains(candidate.endpointId)
|
||||||
|
}
|
||||||
|
guard !fresh.isEmpty else { return }
|
||||||
|
state.suggestions.append(contentsOf: fresh)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Agree to be reachable by a suggested device.
|
||||||
|
func acceptSuggestion(_ suggestion: PairingSuggestion) async {
|
||||||
|
state.suggestions.removeAll { $0.endpointId == suggestion.endpointId }
|
||||||
|
preferences.clearDeclinedPairingSuggestion(suggestion.endpointId)
|
||||||
|
await allowDeviceToReachMe(
|
||||||
|
endpointId: suggestion.endpointId,
|
||||||
|
displayName: preferences.preferences.username
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decline, and remember the decline so the next transfer does not re-ask.
|
||||||
|
func declineSuggestion(_ suggestion: PairingSuggestion) {
|
||||||
|
state.suggestions.removeAll { $0.endpointId == suggestion.endpointId }
|
||||||
|
preferences.declinePairingSuggestion(suggestion.endpointId)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Collecting waiting transfers
|
||||||
|
|
||||||
|
func setCheckForOffersOnOpen(_ enabled: Bool) {
|
||||||
|
state.checkForOffersOnOpen = enabled
|
||||||
|
preferences.setCheckForOffersOnOpen(enabled)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Called when the app comes to the foreground.
|
||||||
|
///
|
||||||
|
/// Opt-in, because asking every contact whether they have something waiting
|
||||||
|
/// also tells them the app was opened. Never runs in the background.
|
||||||
|
func checkForOffersOnForeground() async {
|
||||||
|
guard state.checkForOffersOnOpen else { return }
|
||||||
|
_ = await collectWaitingOffers()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Explicit "check now". Returns how many transfers were collected so the
|
||||||
|
/// caller can report an empty result, which a silent refresh cannot.
|
||||||
|
@discardableResult
|
||||||
|
func collectWaitingOffers() async -> UInt64 {
|
||||||
|
guard !state.isCheckingForOffers else { return 0 }
|
||||||
|
state.isCheckingForOffers = true
|
||||||
|
defer { state.isCheckingForOffers = false }
|
||||||
|
|
||||||
|
switch await repository.pollContactsForOffers() {
|
||||||
|
case .success(let collected):
|
||||||
|
await refreshOffers()
|
||||||
|
return collected
|
||||||
|
case .failure(let error):
|
||||||
|
messages.error(error)
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Selection
|
||||||
|
|
||||||
|
func select(_ endpointId: String?) { state.selectedEndpointId = endpointId }
|
||||||
|
|
||||||
|
// MARK: - Pairing consent
|
||||||
|
|
||||||
|
/// Agree to be reachable by a device, typically right after a transfer.
|
||||||
|
func allowDeviceToReachMe(endpointId: String, displayName: String?) async {
|
||||||
|
state.busyEndpoints.insert(endpointId)
|
||||||
|
defer { state.busyEndpoints.remove(endpointId) }
|
||||||
|
|
||||||
|
if case .failure(let error) = await repository.allowDeviceToReachMe(
|
||||||
|
endpointId: endpointId,
|
||||||
|
displayName: displayName
|
||||||
|
) {
|
||||||
|
messages.error(error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Answer a device's offer to be remembered.
|
||||||
|
func respondToPairing(endpointId: String, accepted: Bool) async {
|
||||||
|
state.busyEndpoints.insert(endpointId)
|
||||||
|
defer { state.busyEndpoints.remove(endpointId) }
|
||||||
|
|
||||||
|
switch await repository.respondToPairing(endpointId: endpointId, accepted: accepted) {
|
||||||
|
case .success:
|
||||||
|
// Drop the prompt immediately: the core has already consumed it, and
|
||||||
|
// leaving it on screen invites a second answer that does nothing.
|
||||||
|
state.pendingPairings.removeAll { $0.endpointId == endpointId }
|
||||||
|
if accepted { await refresh() }
|
||||||
|
case .failure(let error):
|
||||||
|
messages.error(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Incoming offers
|
||||||
|
|
||||||
|
/// Answer an incoming offer. Returns the ticket when accepted so the caller
|
||||||
|
/// can run the receive with a platform-appropriate destination; the core
|
||||||
|
/// releases it only on acceptance.
|
||||||
|
func respondToOffer(offerId: String, accepted: Bool) async -> String? {
|
||||||
|
state.busyOfferIds.insert(offerId)
|
||||||
|
defer { state.busyOfferIds.remove(offerId) }
|
||||||
|
|
||||||
|
let ticket = await repository.respondToOffer(offerId: offerId, accepted: accepted)
|
||||||
|
state.pendingOffers.removeAll { $0.offerId == offerId }
|
||||||
|
return ticket
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Sending to a device
|
||||||
|
|
||||||
|
/// Start choosing files to send to a remembered device.
|
||||||
|
func chooseFilesToSend(to endpointId: String) {
|
||||||
|
sendTarget = endpointId
|
||||||
|
pendingFilePick = true
|
||||||
|
}
|
||||||
|
|
||||||
|
func onFilePickFailed(_ reason: String) {
|
||||||
|
sendTarget = nil
|
||||||
|
messages.error(InvitationError.raw(reason))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Send the picked selection straight to the chosen device.
|
||||||
|
///
|
||||||
|
/// Only the receiving user is prompted; this call returns once they have
|
||||||
|
/// answered, so the button stays busy until then.
|
||||||
|
func onFilesPicked(_ files: [PickedShareFile]) async {
|
||||||
|
guard let endpointId = sendTarget else { return }
|
||||||
|
sendTarget = nil
|
||||||
|
guard !files.isEmpty else { return }
|
||||||
|
|
||||||
|
state.busyEndpoints.insert(endpointId)
|
||||||
|
defer { state.busyEndpoints.remove(endpointId) }
|
||||||
|
|
||||||
|
let result = await fileSystemService.sharePickedFiles(
|
||||||
|
repository: repository,
|
||||||
|
files: files,
|
||||||
|
transferName: files.count == 1 ? files[0].displayName : "",
|
||||||
|
senderName: preferences.preferences.username,
|
||||||
|
destination: .contact(endpointId: endpointId)
|
||||||
|
)
|
||||||
|
await fileSystemService.discardPickedFiles(files)
|
||||||
|
switch result {
|
||||||
|
case .success(let outcome):
|
||||||
|
// A closed app is a delay, not a failure: say so rather than
|
||||||
|
// reporting success for something nobody has received.
|
||||||
|
let text: UiText = outcome.delivered
|
||||||
|
? .resource(L10n.Send.transferCreated)
|
||||||
|
: .resource(L10n.Contacts.offerHeld)
|
||||||
|
messages.tryShow(UiMessage(text: text, tone: outcome.delivered ? .success : .info))
|
||||||
|
await refresh()
|
||||||
|
case .failure(let error):
|
||||||
|
messages.error(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fire-and-report variant of ``offerTransfer(transferId:to:)``.
|
||||||
|
///
|
||||||
|
/// Owned by the model rather than a view so the request survives the picker
|
||||||
|
/// being dismissed: the answer depends on a person at the other device.
|
||||||
|
func offerTransferInBackground(transferId: UInt64, to contact: DeviceContact) {
|
||||||
|
Task { await offerTransfer(transferId: transferId, to: contact) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Push an existing transfer to a remembered device.
|
||||||
|
///
|
||||||
|
/// Returns whether it landed, so the caller can distinguish "accepted" from
|
||||||
|
/// "waiting for that device to open the app".
|
||||||
|
@discardableResult
|
||||||
|
func offerTransfer(transferId: UInt64, to contact: DeviceContact) async -> Bool {
|
||||||
|
state.busyEndpoints.insert(contact.endpointId)
|
||||||
|
defer { state.busyEndpoints.remove(contact.endpointId) }
|
||||||
|
|
||||||
|
switch await repository.offerTransferToContact(
|
||||||
|
transferId: transferId,
|
||||||
|
endpointId: contact.endpointId
|
||||||
|
) {
|
||||||
|
case .success(let outcome):
|
||||||
|
let text: UiText = outcome.delivered
|
||||||
|
? .dynamic(L10n.Contacts.sentToDevice(device: contact.displayName))
|
||||||
|
: .resource(L10n.Contacts.offerHeld)
|
||||||
|
messages.tryShow(UiMessage(text: text, tone: outcome.delivered ? .success : .info))
|
||||||
|
await refresh()
|
||||||
|
return outcome.delivered
|
||||||
|
case .failure(let error) where error.offerRefusal != nil:
|
||||||
|
// The offer was delivered and a person said no, or nobody answered.
|
||||||
|
// Neither is a failure of this device, so neither is shown as one.
|
||||||
|
let text = error.offerRefusal == .declined
|
||||||
|
? L10n.Contacts.declinedByDevice(device: contact.displayName)
|
||||||
|
: L10n.Contacts.noAnswer(device: contact.displayName)
|
||||||
|
messages.tryShow(UiMessage(text: .dynamic(text), tone: .info))
|
||||||
|
await refresh()
|
||||||
|
return false
|
||||||
|
case .failure(let error):
|
||||||
|
messages.error(error)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Management
|
||||||
|
|
||||||
|
func setLabel(endpointId: String, label: String) async {
|
||||||
|
let trimmed = label.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if case .failure(let error) = await repository.setContactLabel(
|
||||||
|
endpointId: endpointId,
|
||||||
|
label: trimmed.isEmpty ? nil : trimmed
|
||||||
|
) {
|
||||||
|
messages.error(error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
func forget(endpointId: String) async {
|
||||||
|
state.busyEndpoints.insert(endpointId)
|
||||||
|
defer { state.busyEndpoints.remove(endpointId) }
|
||||||
|
|
||||||
|
if case .failure(let error) = await repository.forgetContact(endpointId: endpointId) {
|
||||||
|
messages.error(error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if state.selectedEndpointId == endpointId { state.selectedEndpointId = nil }
|
||||||
|
await refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
func forgetAll() async {
|
||||||
|
if case .failure(let error) = await repository.forgetAllContacts() {
|
||||||
|
messages.error(error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
state.selectedEndpointId = nil
|
||||||
|
await refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
func block(endpointId: String) async {
|
||||||
|
state.busyEndpoints.insert(endpointId)
|
||||||
|
defer { state.busyEndpoints.remove(endpointId) }
|
||||||
|
|
||||||
|
if case .failure(let error) = await repository.blockContact(endpointId: endpointId) {
|
||||||
|
messages.error(error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if state.selectedEndpointId == endpointId { state.selectedEndpointId = nil }
|
||||||
|
await refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
func unblock(endpointId: String) async {
|
||||||
|
if case .failure(let error) = await repository.unblockContact(endpointId: endpointId) {
|
||||||
|
messages.error(error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
func setGrantLifetime(_ lifetime: GrantLifetimeOption) {
|
||||||
|
state.grantLifetime = lifetime
|
||||||
|
preferences.setGrantLifetime(lifetime)
|
||||||
|
Task { await repository.setGrantLifetime(lifetime) }
|
||||||
|
}
|
||||||
|
}
|
||||||
344
apple/VniDrop/Features/Contacts/ContactsScreen.swift
Normal file
@@ -0,0 +1,344 @@
|
|||||||
|
import SFSafeSymbols
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// Device history: the remembered devices, their detail, and the block list.
|
||||||
|
///
|
||||||
|
/// Pushed from Settings rather than owning a tab — it is a management surface,
|
||||||
|
/// not part of the send/receive flow.
|
||||||
|
struct ContactsScreen: View {
|
||||||
|
@ObservedObject var model: ContactsModel
|
||||||
|
/// Reports an empty result, which a silent refresh cannot convey.
|
||||||
|
let onNothingWaiting: () -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Form {
|
||||||
|
Section {
|
||||||
|
Text(String(localized: L10n.Contacts.subtitle))
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
if model.state.contacts.isEmpty {
|
||||||
|
Section {
|
||||||
|
ContactsEmptyState()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Section(String(localized: L10n.Contacts.title)) {
|
||||||
|
ForEach(model.state.contacts) { contact in
|
||||||
|
NavigationLink(value: SettingsSection.contactDetail(endpointId: contact.endpointId)) {
|
||||||
|
ContactRow(contact: contact)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !model.state.heldOffers.isEmpty {
|
||||||
|
Section(String(localized: L10n.Contacts.waitingTitle)) {
|
||||||
|
ForEach(model.state.heldOffers) { offer in
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(offer.transferName)
|
||||||
|
Text(String(offer.endpointId.prefix(16)))
|
||||||
|
.font(.caption.monospaced())
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.lineLimit(1)
|
||||||
|
.truncationMode(.middle)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text(String(localized: L10n.Contacts.waitingHint))
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !model.state.blocked.isEmpty {
|
||||||
|
Section(String(localized: L10n.Contacts.blockedTitle)) {
|
||||||
|
ForEach(model.state.blocked, id: \.self) { endpointId in
|
||||||
|
BlockedRow(endpointId: endpointId) {
|
||||||
|
Task { await model.unblock(endpointId: endpointId) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text(String(localized: L10n.Contacts.unblockHint))
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
CollectOffersSection(model: model, onNothingWaiting: onNothingWaiting)
|
||||||
|
|
||||||
|
GrantLifetimeSection(model: model)
|
||||||
|
|
||||||
|
if !model.state.contacts.isEmpty {
|
||||||
|
Section {
|
||||||
|
ForgetAllButton { Task { await model.forgetAll() } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.formStyle(.grouped)
|
||||||
|
.navigationTitle(Text(String(localized: L10n.Contacts.title)))
|
||||||
|
.task { await model.refresh() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct ContactsEmptyState: View {
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 8) {
|
||||||
|
Image(systemSymbol: .macbookAndIphone)
|
||||||
|
.font(.system(size: 32))
|
||||||
|
.foregroundStyle(.tint)
|
||||||
|
Text(String(localized: L10n.Contacts.emptyTitle))
|
||||||
|
.font(.headline)
|
||||||
|
Text(String(localized: L10n.Contacts.emptyBody))
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct ContactRow: View {
|
||||||
|
let contact: DeviceContact
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(contact.displayName)
|
||||||
|
if contact.canSend {
|
||||||
|
if let lastTransferAt = contact.lastTransferAt {
|
||||||
|
Text(L10n.Contacts.lastTransfer(date: Self.format(lastTransferAt)))
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Reachability is derived from holding a live grant, so this is
|
||||||
|
// the honest signal that sending will not work.
|
||||||
|
Label(
|
||||||
|
String(localized: L10n.Contacts.unreachable),
|
||||||
|
systemSymbol: .exclamationmarkTriangleFill
|
||||||
|
)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.orange)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func format(_ millis: Int64) -> String {
|
||||||
|
let date = Date(timeIntervalSince1970: TimeInterval(millis) / 1_000)
|
||||||
|
return date.formatted(.relative(presentation: .named))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct BlockedRow: View {
|
||||||
|
let endpointId: String
|
||||||
|
let onUnblock: () -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack {
|
||||||
|
Text(String(endpointId.prefix(16)))
|
||||||
|
.font(.callout.monospaced())
|
||||||
|
.lineLimit(1)
|
||||||
|
.truncationMode(.middle)
|
||||||
|
Spacer()
|
||||||
|
Button(String(localized: L10n.Contacts.unblock), action: onUnblock)
|
||||||
|
.buttonStyle(.borderless)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct CollectOffersSection: View {
|
||||||
|
@ObservedObject var model: ContactsModel
|
||||||
|
let onNothingWaiting: () -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Section {
|
||||||
|
Toggle(
|
||||||
|
String(localized: L10n.Contacts.checkOnOpen),
|
||||||
|
isOn: Binding(
|
||||||
|
get: { model.state.checkForOffersOnOpen },
|
||||||
|
set: { model.setCheckForOffersOnOpen($0) }
|
||||||
|
)
|
||||||
|
)
|
||||||
|
Button {
|
||||||
|
Task {
|
||||||
|
let collected = await model.collectWaitingOffers()
|
||||||
|
if collected == 0 { onNothingWaiting() }
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
HStack {
|
||||||
|
Text(String(localized: L10n.Contacts.checkNow))
|
||||||
|
if model.state.isCheckingForOffers {
|
||||||
|
Spacer()
|
||||||
|
ProgressView().controlSize(.small)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.disabled(model.state.isCheckingForOffers)
|
||||||
|
} footer: {
|
||||||
|
// The privacy cost is the point of the setting, so it is stated
|
||||||
|
// where the switch is, not buried elsewhere.
|
||||||
|
Text(String(localized: L10n.Contacts.checkOnOpenHint))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct GrantLifetimeSection: View {
|
||||||
|
@ObservedObject var model: ContactsModel
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Section {
|
||||||
|
Picker(
|
||||||
|
String(localized: L10n.Contacts.grantLifetimeTitle),
|
||||||
|
selection: Binding(
|
||||||
|
get: { model.state.grantLifetime },
|
||||||
|
set: { model.setGrantLifetime($0) }
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
ForEach(GrantLifetimeOption.allCases) { option in
|
||||||
|
Text(Self.label(option)).tag(option)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text(String(localized: L10n.Contacts.grantLifetimeHint))
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func label(_ option: GrantLifetimeOption) -> String {
|
||||||
|
guard let days = option.days else {
|
||||||
|
return String(localized: L10n.Contacts.grantLifetimeNever)
|
||||||
|
}
|
||||||
|
return L10n.Contacts.grantLifetimeDays(count: days)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct ForgetAllButton: View {
|
||||||
|
let onConfirm: () -> Void
|
||||||
|
@State private var isConfirming = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Button(role: .destructive) {
|
||||||
|
isConfirming = true
|
||||||
|
} label: {
|
||||||
|
Text(String(localized: L10n.Contacts.forgetAll))
|
||||||
|
}
|
||||||
|
.confirmationDialog(
|
||||||
|
String(localized: L10n.Contacts.forgetAll),
|
||||||
|
isPresented: $isConfirming,
|
||||||
|
titleVisibility: .visible
|
||||||
|
) {
|
||||||
|
Button(String(localized: L10n.Contacts.forgetAll), role: .destructive, action: onConfirm)
|
||||||
|
} message: {
|
||||||
|
Text(String(localized: L10n.Contacts.forgetBody))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Detail for one remembered device: rename, send, forget, block.
|
||||||
|
struct ContactDetailScreen: View {
|
||||||
|
@ObservedObject var model: ContactsModel
|
||||||
|
let endpointId: String
|
||||||
|
|
||||||
|
@State private var label = ""
|
||||||
|
@State private var isConfirmingForget = false
|
||||||
|
@State private var isConfirmingBlock = false
|
||||||
|
|
||||||
|
private var contact: DeviceContact? {
|
||||||
|
model.state.contacts.first { $0.endpointId == endpointId }
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Form {
|
||||||
|
if let contact {
|
||||||
|
Section {
|
||||||
|
TextField(
|
||||||
|
String(localized: L10n.Contacts.nameField),
|
||||||
|
text: $label,
|
||||||
|
prompt: Text(contact.displayName)
|
||||||
|
)
|
||||||
|
.onSubmit { commitLabel() }
|
||||||
|
Text(String(localized: L10n.Contacts.nameHint))
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Section {
|
||||||
|
// The endpoint id is the only real identity: two devices can
|
||||||
|
// claim the same name, but not the same key. Shown in full
|
||||||
|
// and selectable so it can actually be compared.
|
||||||
|
Text(L10n.Approval.endpointId(deviceId: contact.endpointId))
|
||||||
|
.font(.caption.monospaced())
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.textSelection(.enabled)
|
||||||
|
}
|
||||||
|
|
||||||
|
if contact.canSend {
|
||||||
|
Section {
|
||||||
|
Button {
|
||||||
|
model.chooseFilesToSend(to: endpointId)
|
||||||
|
} label: {
|
||||||
|
Label(
|
||||||
|
String(localized: L10n.Contacts.sendTo),
|
||||||
|
systemSymbol: .paperplane
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.disabled(model.state.busyEndpoints.contains(endpointId))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Section {
|
||||||
|
Label(
|
||||||
|
String(localized: L10n.Contacts.unreachableBody),
|
||||||
|
systemSymbol: .exclamationmarkTriangleFill
|
||||||
|
)
|
||||||
|
.font(.footnote)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Section {
|
||||||
|
Button(role: .destructive) {
|
||||||
|
isConfirmingForget = true
|
||||||
|
} label: {
|
||||||
|
Text(String(localized: L10n.Contacts.forget))
|
||||||
|
}
|
||||||
|
Button(role: .destructive) {
|
||||||
|
isConfirmingBlock = true
|
||||||
|
} label: {
|
||||||
|
Text(String(localized: L10n.Contacts.block))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.disabled(model.state.busyEndpoints.contains(endpointId))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.formStyle(.grouped)
|
||||||
|
.navigationTitle(Text(contact?.displayName ?? ""))
|
||||||
|
.contactSendPickers(model: model)
|
||||||
|
.onAppear { label = contact?.localLabel ?? "" }
|
||||||
|
.onDisappear { commitLabel() }
|
||||||
|
.confirmationDialog(
|
||||||
|
String(localized: L10n.Contacts.forget),
|
||||||
|
isPresented: $isConfirmingForget,
|
||||||
|
titleVisibility: .visible
|
||||||
|
) {
|
||||||
|
Button(String(localized: L10n.Contacts.forget), role: .destructive) {
|
||||||
|
Task { await model.forget(endpointId: endpointId) }
|
||||||
|
}
|
||||||
|
} message: {
|
||||||
|
Text(String(localized: L10n.Contacts.forgetBody))
|
||||||
|
}
|
||||||
|
.confirmationDialog(
|
||||||
|
String(localized: L10n.Contacts.block),
|
||||||
|
isPresented: $isConfirmingBlock,
|
||||||
|
titleVisibility: .visible
|
||||||
|
) {
|
||||||
|
Button(String(localized: L10n.Contacts.block), role: .destructive) {
|
||||||
|
Task { await model.block(endpointId: endpointId) }
|
||||||
|
}
|
||||||
|
} message: {
|
||||||
|
Text(String(localized: L10n.Contacts.unblockHint))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func commitLabel() {
|
||||||
|
guard label != (contact?.localLabel ?? "") else { return }
|
||||||
|
Task { await model.setLabel(endpointId: endpointId, label: label) }
|
||||||
|
}
|
||||||
|
}
|
||||||
73
apple/VniDrop/Features/Contacts/DevicePickerSheet.swift
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import SFSafeSymbols
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// Picks a remembered device to send an existing transfer to.
|
||||||
|
///
|
||||||
|
/// Offered next to the QR code as another way to deliver the same invitation,
|
||||||
|
/// not as a second share of the same files.
|
||||||
|
struct DevicePickerSheet: View {
|
||||||
|
@ObservedObject var model: ContactsModel
|
||||||
|
let transferId: UInt64
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
|
/// Only devices holding a live grant: the rest cannot be reached until they
|
||||||
|
/// are paired again, so offering them here would fail on tap.
|
||||||
|
private var reachable: [DeviceContact] {
|
||||||
|
model.state.contacts.filter(\.canSend)
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
Group {
|
||||||
|
if reachable.isEmpty {
|
||||||
|
ContentUnavailableView {
|
||||||
|
Label(
|
||||||
|
String(localized: L10n.Contacts.pickDeviceTitle),
|
||||||
|
systemSymbol: .macbookAndIphone
|
||||||
|
)
|
||||||
|
} description: {
|
||||||
|
Text(String(localized: L10n.Contacts.pickDeviceEmpty))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
List(reachable) { contact in
|
||||||
|
Button {
|
||||||
|
// Close first. The other device's user has to accept,
|
||||||
|
// which can take as long as they take, and holding a
|
||||||
|
// modal open on someone else's decision reads as a
|
||||||
|
// hang. The outcome arrives as a message instead.
|
||||||
|
dismiss()
|
||||||
|
model.offerTransferInBackground(transferId: transferId, to: contact)
|
||||||
|
} label: {
|
||||||
|
HStack {
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(contact.displayName)
|
||||||
|
Text(contact.shortFingerprint)
|
||||||
|
.font(.caption.monospaced())
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
if model.state.busyEndpoints.contains(contact.endpointId) {
|
||||||
|
ProgressView().controlSize(.small)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.disabled(model.state.busyEndpoints.contains(contact.endpointId))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle(Text(String(localized: L10n.Contacts.pickDeviceTitle)))
|
||||||
|
#if os(iOS)
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
#endif
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .cancellationAction) {
|
||||||
|
Button(String(localized: L10n.Button.cancel)) { dismiss() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.task { await model.refresh() }
|
||||||
|
#if os(macOS)
|
||||||
|
.frame(minWidth: 380, minHeight: 320)
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -111,7 +111,7 @@ final class TransferNotificationCoordinator: ObservableObject {
|
|||||||
switch signal {
|
switch signal {
|
||||||
case .receiverHistoryChanged(let transferId), .transfersChanged(let transferId):
|
case .receiverHistoryChanged(let transferId), .transfersChanged(let transferId):
|
||||||
Task { await self.syncReceivers(transferId: transferId) }
|
Task { await self.syncReceivers(transferId: transferId) }
|
||||||
case .approvalChanged:
|
case .approvalChanged, .contactsChanged, .offersChanged:
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ enum ReceiveMethod {
|
|||||||
case invitationFile
|
case invitationFile
|
||||||
case qrCode
|
case qrCode
|
||||||
case nfc
|
case nfc
|
||||||
|
/// Pushed by a remembered device and already accepted by the user, so no
|
||||||
|
/// invitation was acquired by hand.
|
||||||
|
case offer
|
||||||
}
|
}
|
||||||
|
|
||||||
enum ReceiveHistoryDeleteTarget: Equatable {
|
enum ReceiveHistoryDeleteTarget: Equatable {
|
||||||
@@ -154,6 +157,40 @@ final class ReceiveModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Receive a transfer the user has already accepted in the offer prompt.
|
||||||
|
///
|
||||||
|
/// The consent happened in that prompt, so this does not ask again: it
|
||||||
|
/// inspects the ticket and starts, falling back to the ordinary review sheet
|
||||||
|
/// only when the destination is not usable and the user has to fix it.
|
||||||
|
func receiveOffered(ticket: String) {
|
||||||
|
let trimmed = ticket.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !trimmed.isEmpty else { return messages.error(.resource(L10n.Error.invitationEmpty)) }
|
||||||
|
state.ticket = trimmed
|
||||||
|
state.method = .offer
|
||||||
|
state.inspection = nil
|
||||||
|
state.isInspecting = true
|
||||||
|
Task {
|
||||||
|
switch await repository.inspectTicket(trimmed) {
|
||||||
|
case .success(let inspection):
|
||||||
|
state.inspection = inspection
|
||||||
|
state.isInspecting = false
|
||||||
|
if state.canReceive(coreInitialized: coreState.isInitialized) {
|
||||||
|
receive()
|
||||||
|
} else {
|
||||||
|
// Usually a missing or unwritable destination: show the review
|
||||||
|
// sheet so the user can point it somewhere valid.
|
||||||
|
state.isAcquisitionOpen = true
|
||||||
|
}
|
||||||
|
case .failure(let error):
|
||||||
|
state.ticket = ""
|
||||||
|
state.method = nil
|
||||||
|
state.inspection = nil
|
||||||
|
state.isInspecting = false
|
||||||
|
messages.error(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func receive() {
|
func receive() {
|
||||||
let current = state
|
let current = state
|
||||||
guard let folder = current.receiveFolder else { return }
|
guard let folder = current.receiveFolder else { return }
|
||||||
|
|||||||
@@ -96,6 +96,8 @@ final class SendModel: ObservableObject {
|
|||||||
case .receiverHistoryChanged(let id), .approvalChanged(let id):
|
case .receiverHistoryChanged(let id), .approvalChanged(let id):
|
||||||
if id == self.state.selectedTransferId { self.refreshReceivers(id) }
|
if id == self.state.selectedTransferId { self.refreshReceivers(id) }
|
||||||
self.refreshReceiverStatuses(for: id)
|
self.refreshReceiverStatuses(for: id)
|
||||||
|
case .contactsChanged, .offersChanged:
|
||||||
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.store(in: &cancellables)
|
.store(in: &cancellables)
|
||||||
@@ -351,9 +353,9 @@ final class SendModel: ObservableObject {
|
|||||||
files: current.selectedFiles,
|
files: current.selectedFiles,
|
||||||
transferName: current.transferName.trimmingCharacters(in: .whitespacesAndNewlines),
|
transferName: current.transferName.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||||
senderName: current.senderName.trimmingCharacters(in: .whitespacesAndNewlines),
|
senderName: current.senderName.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||||
accessPolicy: current.accessPolicy
|
destination: .invitation(accessPolicy: current.accessPolicy)
|
||||||
)
|
)
|
||||||
switch result {
|
switch result.map(\.share) {
|
||||||
case .success(let share):
|
case .success(let share):
|
||||||
await fileSystemService.discardPickedFiles(current.selectedFiles)
|
await fileSystemService.discardPickedFiles(current.selectedFiles)
|
||||||
if let thumb = current.selectedFiles.compactMap(\.thumbnailData).first {
|
if let thumb = current.selectedFiles.compactMap(\.thumbnailData).first {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import SFSafeSymbols
|
|||||||
/// with the composer and detail panels as native sheets and delete as an alert.
|
/// with the composer and detail panels as native sheets and delete as an alert.
|
||||||
struct SendScreen: View {
|
struct SendScreen: View {
|
||||||
@ObservedObject var model: SendModel
|
@ObservedObject var model: SendModel
|
||||||
|
@ObservedObject var contacts: ContactsModel
|
||||||
let windowClass: WindowClass
|
let windowClass: WindowClass
|
||||||
|
|
||||||
/// Transfer pending an inline (list-level) delete confirmation.
|
/// Transfer pending an inline (list-level) delete confirmation.
|
||||||
@@ -60,7 +61,7 @@ struct SendScreen: View {
|
|||||||
onDismissed: model.shareSheetDidDismiss
|
onDismissed: model.shareSheetDidDismiss
|
||||||
) {
|
) {
|
||||||
if let shareTarget {
|
if let shareTarget {
|
||||||
TransferSharePanel(model: model, transfer: shareTarget)
|
TransferSharePanel(model: model, contacts: contacts, transfer: shareTarget)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -93,7 +94,7 @@ struct SendScreen: View {
|
|||||||
/// alert attached here so they present from the detail's own context (presenting
|
/// alert attached here so they present from the detail's own context (presenting
|
||||||
/// modals from the parent stack while a detail is pushed is unreliable on macOS).
|
/// modals from the parent stack while a detail is pushed is unreliable on macOS).
|
||||||
private func detailView(for transfer: Transfer) -> some View {
|
private func detailView(for transfer: Transfer) -> some View {
|
||||||
TransferDetailsView(model: model, transfer: transfer, events: model.coreState.events)
|
TransferDetailsView(model: model, contacts: contacts, transfer: transfer, events: model.coreState.events)
|
||||||
.adaptiveDrawer(
|
.adaptiveDrawer(
|
||||||
isPresented: Binding(get: { model.state.detailPanel != nil }, set: { _ in }),
|
isPresented: Binding(get: { model.state.detailPanel != nil }, set: { _ in }),
|
||||||
windowClass: windowClass,
|
windowClass: windowClass,
|
||||||
@@ -101,7 +102,7 @@ struct SendScreen: View {
|
|||||||
onDismissed: model.shareSheetDidDismiss
|
onDismissed: model.shareSheetDidDismiss
|
||||||
) {
|
) {
|
||||||
if let panel = model.state.detailPanel {
|
if let panel = model.state.detailPanel {
|
||||||
DetailPanelContent(model: model, transfer: transfer, panel: panel)
|
DetailPanelContent(model: model, contacts: contacts, transfer: transfer, panel: panel)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.alert(
|
.alert(
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import CoreImage.CIFilterBuiltins
|
|||||||
|
|
||||||
struct TransferDetailsView: View {
|
struct TransferDetailsView: View {
|
||||||
@ObservedObject var model: SendModel
|
@ObservedObject var model: SendModel
|
||||||
|
@ObservedObject var contacts: ContactsModel
|
||||||
let transfer: Transfer
|
let transfer: Transfer
|
||||||
let events: [CoreEventModel]
|
let events: [CoreEventModel]
|
||||||
@State private var showStopConfirmation = false
|
@State private var showStopConfirmation = false
|
||||||
@@ -129,6 +130,7 @@ private struct DetailDestination: View {
|
|||||||
|
|
||||||
struct DetailPanelContent: View {
|
struct DetailPanelContent: View {
|
||||||
@ObservedObject var model: SendModel
|
@ObservedObject var model: SendModel
|
||||||
|
@ObservedObject var contacts: ContactsModel
|
||||||
let transfer: Transfer
|
let transfer: Transfer
|
||||||
let panel: TransferDetailPanel
|
let panel: TransferDetailPanel
|
||||||
|
|
||||||
@@ -146,7 +148,7 @@ struct DetailPanelContent: View {
|
|||||||
onAccept: model.acceptReceiver
|
onAccept: model.acceptReceiver
|
||||||
)
|
)
|
||||||
case .share:
|
case .share:
|
||||||
TransferSharePanel(model: model, transfer: transfer)
|
TransferSharePanel(model: model, contacts: contacts, transfer: transfer)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -296,6 +298,7 @@ private struct ReceiverRow: View {
|
|||||||
struct TransferSharePanel: View {
|
struct TransferSharePanel: View {
|
||||||
@Environment(\.vniColors) private var colors
|
@Environment(\.vniColors) private var colors
|
||||||
@ObservedObject var model: SendModel
|
@ObservedObject var model: SendModel
|
||||||
|
@ObservedObject var contacts: ContactsModel
|
||||||
let transfer: Transfer
|
let transfer: Transfer
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -309,7 +312,7 @@ struct TransferSharePanel: View {
|
|||||||
.font(VniType.bodySmall).foregroundStyle(colors.foregroundLighter)
|
.font(VniType.bodySmall).foregroundStyle(colors.foregroundLighter)
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
}
|
}
|
||||||
ShareActionsView(model: model, transfer: transfer, ticket: ticket)
|
ShareActionsView(model: model, contacts: contacts, transfer: transfer, ticket: ticket)
|
||||||
case .preparing:
|
case .preparing:
|
||||||
Text(String(localized: L10n.Transfer.eventPreparing)).foregroundStyle(colors.foregroundLighter)
|
Text(String(localized: L10n.Transfer.eventPreparing)).foregroundStyle(colors.foregroundLighter)
|
||||||
case .unavailable:
|
case .unavailable:
|
||||||
|
|||||||
@@ -20,14 +20,25 @@ protocol TransferShareActions: AnyObject {
|
|||||||
struct ShareActionsView: View {
|
struct ShareActionsView: View {
|
||||||
@Environment(\.vniColors) private var colors
|
@Environment(\.vniColors) private var colors
|
||||||
@ObservedObject var model: SendModel
|
@ObservedObject var model: SendModel
|
||||||
|
@ObservedObject var contacts: ContactsModel
|
||||||
let transfer: Transfer
|
let transfer: Transfer
|
||||||
let ticket: String
|
let ticket: String
|
||||||
|
|
||||||
@State private var actions: TransferShareActions = makePlatformShareActions()
|
@State private var actions: TransferShareActions = makePlatformShareActions()
|
||||||
@State private var writingNfc = false
|
@State private var writingNfc = false
|
||||||
|
@State private var choosingDevice = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 12) {
|
VStack(spacing: 12) {
|
||||||
|
// Sending straight to a remembered device is another way to deliver
|
||||||
|
// this same invitation, so it belongs with the other delivery
|
||||||
|
// methods rather than in a separate flow.
|
||||||
|
if contacts.state.contacts.contains(where: \.canSend) {
|
||||||
|
SecondaryButton(
|
||||||
|
title: String(localized: L10n.Contacts.sendToDevice),
|
||||||
|
action: { choosingDevice = true }
|
||||||
|
)
|
||||||
|
}
|
||||||
if actions.nfcAvailability != .hidden {
|
if actions.nfcAvailability != .hidden {
|
||||||
SecondaryButton(
|
SecondaryButton(
|
||||||
title: writingNfc ? String(localized: L10n.Transfer.nfcWaiting) : String(localized: L10n.Button.writeNfc),
|
title: writingNfc ? String(localized: L10n.Transfer.nfcWaiting) : String(localized: L10n.Button.writeNfc),
|
||||||
@@ -57,5 +68,8 @@ struct ShareActionsView: View {
|
|||||||
}, enabled: actions.canUseNativeShare)
|
}, enabled: actions.canUseNativeShare)
|
||||||
}
|
}
|
||||||
.onDisappear { actions.cancelNfcWrite() }
|
.onDisappear { actions.cancelNfcWrite() }
|
||||||
|
.sheet(isPresented: $choosingDevice) {
|
||||||
|
DevicePickerSheet(model: contacts, transferId: transfer.transferId)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,10 @@ enum SettingsSection: Hashable {
|
|||||||
case appearance
|
case appearance
|
||||||
case notifications
|
case notifications
|
||||||
case network
|
case network
|
||||||
|
case contacts
|
||||||
|
/// One device's detail. Part of this enum because the Settings stack has a
|
||||||
|
/// typed path: a link carrying any other value type cannot push onto it.
|
||||||
|
case contactDetail(endpointId: String)
|
||||||
case storage
|
case storage
|
||||||
case about
|
case about
|
||||||
case bugReport
|
case bugReport
|
||||||
@@ -19,6 +23,7 @@ enum SettingsSection: Hashable {
|
|||||||
case .appearance: return L10n.Appearance.title
|
case .appearance: return L10n.Appearance.title
|
||||||
case .notifications: return L10n.Notifications.title
|
case .notifications: return L10n.Notifications.title
|
||||||
case .network: return L10n.Settings.networkTitle
|
case .network: return L10n.Settings.networkTitle
|
||||||
|
case .contacts, .contactDetail: return L10n.Contacts.title
|
||||||
case .storage: return L10n.Storage.title
|
case .storage: return L10n.Storage.title
|
||||||
case .about: return L10n.About.title
|
case .about: return L10n.About.title
|
||||||
case .bugReport: return L10n.About.bugReport
|
case .bugReport: return L10n.About.bugReport
|
||||||
@@ -174,6 +179,12 @@ final class SettingsModel: ObservableObject {
|
|||||||
loadDeviceInfo()
|
loadDeviceInfo()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Surfaces "nothing waiting" from the contacts screen, which has no
|
||||||
|
/// message controller of its own.
|
||||||
|
func reportNothingWaiting() {
|
||||||
|
messages.tryShow(UiMessage(text: .resource(L10n.Contacts.checkNone), tone: .info))
|
||||||
|
}
|
||||||
|
|
||||||
func selectSection(_ section: SettingsSection) {
|
func selectSection(_ section: SettingsSection) {
|
||||||
state.selectedSection = section
|
state.selectedSection = section
|
||||||
if section == .about || section == .bugReport {
|
if section == .about || section == .bugReport {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import SFSafeSymbols
|
|||||||
/// navigation. The model stays the source of truth via a derived path binding.
|
/// navigation. The model stays the source of truth via a derived path binding.
|
||||||
struct SettingsScreen: View {
|
struct SettingsScreen: View {
|
||||||
@ObservedObject var model: SettingsModel
|
@ObservedObject var model: SettingsModel
|
||||||
|
@ObservedObject var contacts: ContactsModel
|
||||||
let windowClass: WindowClass
|
let windowClass: WindowClass
|
||||||
@State private var showBugReport = false
|
@State private var showBugReport = false
|
||||||
|
|
||||||
@@ -14,6 +15,8 @@ struct SettingsScreen: View {
|
|||||||
switch model.state.selectedSection {
|
switch model.state.selectedSection {
|
||||||
case .overview: return []
|
case .overview: return []
|
||||||
case .bugReport: return [.about, .bugReport]
|
case .bugReport: return [.about, .bugReport]
|
||||||
|
case .contactDetail(let endpointId):
|
||||||
|
return [.contacts, .contactDetail(endpointId: endpointId)]
|
||||||
case let section: return [section]
|
case let section: return [section]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -54,6 +57,15 @@ struct SettingsScreen: View {
|
|||||||
NavigationLink(value: SettingsSection.storage) {
|
NavigationLink(value: SettingsSection.storage) {
|
||||||
SettingsRow(icon: .internaldrive, title: String(localized: L10n.Storage.title), value: nil)
|
SettingsRow(icon: .internaldrive, title: String(localized: L10n.Storage.title), value: nil)
|
||||||
}
|
}
|
||||||
|
NavigationLink(value: SettingsSection.contacts) {
|
||||||
|
SettingsRow(
|
||||||
|
icon: .macbookAndIphone,
|
||||||
|
title: String(localized: L10n.Contacts.title),
|
||||||
|
value: contacts.state.contacts.isEmpty
|
||||||
|
? nil
|
||||||
|
: String(contacts.state.contacts.count)
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Section(String(localized: L10n.Settings.advancedTitle)) {
|
Section(String(localized: L10n.Settings.advancedTitle)) {
|
||||||
NavigationLink(value: SettingsSection.network) {
|
NavigationLink(value: SettingsSection.network) {
|
||||||
@@ -80,6 +92,21 @@ struct SettingsScreen: View {
|
|||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private func sectionForm(_ section: SettingsSection) -> some View {
|
private func sectionForm(_ section: SettingsSection) -> some View {
|
||||||
|
// Contacts brings its own Form and push destination, so it is not wrapped
|
||||||
|
// in the shared section chrome.
|
||||||
|
if case .contactDetail(let endpointId) = section {
|
||||||
|
ContactDetailScreen(model: contacts, endpointId: endpointId)
|
||||||
|
} else if section == .contacts {
|
||||||
|
ContactsScreen(model: contacts) {
|
||||||
|
model.reportNothingWaiting()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
settingsSectionForm(section)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func settingsSectionForm(_ section: SettingsSection) -> some View {
|
||||||
let content = Form {
|
let content = Form {
|
||||||
SettingsSectionContent(model: model, section: section)
|
SettingsSectionContent(model: model, section: section)
|
||||||
}
|
}
|
||||||
@@ -130,6 +157,9 @@ private struct SettingsSectionContent: View {
|
|||||||
NetworkSettings(model: model)
|
NetworkSettings(model: model)
|
||||||
case .storage:
|
case .storage:
|
||||||
StorageSettings(model: model)
|
StorageSettings(model: model)
|
||||||
|
case .contacts, .contactDetail:
|
||||||
|
// Rendered by SettingsScreen itself, which owns the contacts model.
|
||||||
|
EmptyView()
|
||||||
case .about:
|
case .about:
|
||||||
AboutSettings(model: model)
|
AboutSettings(model: model)
|
||||||
case .bugReport:
|
case .bugReport:
|
||||||
|
|||||||
@@ -59,15 +59,24 @@ struct IosFileSystemService: FileSystemService {
|
|||||||
files: [PickedShareFile],
|
files: [PickedShareFile],
|
||||||
transferName: String,
|
transferName: String,
|
||||||
senderName: String,
|
senderName: String,
|
||||||
accessPolicy: ShareAccessPolicy
|
destination: ShareDestination
|
||||||
) async -> Result<Share, Error> {
|
) async -> Result<ContactSendOutcome, Error> {
|
||||||
guard !files.isEmpty else {
|
guard !files.isEmpty else {
|
||||||
return .failure(InvitationError.shareEmpty)
|
return .failure(InvitationError.shareEmpty)
|
||||||
}
|
}
|
||||||
let sources = files.map { $0.toIosShareSource() }
|
let sources = files.map { $0.toIosShareSource() }
|
||||||
|
switch destination {
|
||||||
|
case .invitation(let accessPolicy):
|
||||||
return await repository.shareSources(
|
return await repository.shareSources(
|
||||||
sources, transferName: transferName, senderName: senderName, accessPolicy: accessPolicy
|
sources, transferName: transferName, senderName: senderName, accessPolicy: accessPolicy
|
||||||
)
|
)
|
||||||
|
.map { ContactSendOutcome(share: $0, delivered: true) }
|
||||||
|
case .contact(let endpointId):
|
||||||
|
return await repository.sendToContact(
|
||||||
|
endpointId: endpointId, sources: sources,
|
||||||
|
transferName: transferName, senderName: senderName
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func validateSecurityScopedUrl(_ value: String) -> FolderAccessStatus {
|
private func validateSecurityScopedUrl(_ value: String) -> FolderAccessStatus {
|
||||||
|
|||||||
@@ -39,8 +39,8 @@ struct MacFileSystemService: FileSystemService {
|
|||||||
files: [PickedShareFile],
|
files: [PickedShareFile],
|
||||||
transferName: String,
|
transferName: String,
|
||||||
senderName: String,
|
senderName: String,
|
||||||
accessPolicy: ShareAccessPolicy
|
destination: ShareDestination
|
||||||
) async -> Result<Share, Error> {
|
) async -> Result<ContactSendOutcome, Error> {
|
||||||
guard !files.isEmpty else {
|
guard !files.isEmpty else {
|
||||||
return .failure(InvitationError.shareEmpty)
|
return .failure(InvitationError.shareEmpty)
|
||||||
}
|
}
|
||||||
@@ -63,9 +63,18 @@ struct MacFileSystemService: FileSystemService {
|
|||||||
let sources = files.map {
|
let sources = files.map {
|
||||||
ShareSource(kind: .path, value: $0.value, displayName: $0.displayName, isDirectory: $0.isDirectory)
|
ShareSource(kind: .path, value: $0.value, displayName: $0.displayName, isDirectory: $0.isDirectory)
|
||||||
}
|
}
|
||||||
|
switch destination {
|
||||||
|
case .invitation(let accessPolicy):
|
||||||
return await repository.shareSources(
|
return await repository.shareSources(
|
||||||
sources, transferName: transferName, senderName: senderName, accessPolicy: accessPolicy
|
sources, transferName: transferName, senderName: senderName, accessPolicy: accessPolicy
|
||||||
)
|
)
|
||||||
|
.map { ContactSendOutcome(share: $0, delivered: true) }
|
||||||
|
case .contact(let endpointId):
|
||||||
|
return await repository.sendToContact(
|
||||||
|
endpointId: endpointId, sources: sources,
|
||||||
|
transferName: transferName, senderName: senderName
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
@@ -70,6 +70,33 @@ struct SendPickers: ViewModifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// File picker for "send to this device", reusing the share picker's selection
|
||||||
|
/// handling so security-scoped bookmarks are captured the same way.
|
||||||
|
struct ContactSendPickers: ViewModifier {
|
||||||
|
@ObservedObject var model: ContactsModel
|
||||||
|
|
||||||
|
func body(content: Content) -> some View {
|
||||||
|
content
|
||||||
|
.fileImporter(
|
||||||
|
isPresented: $model.pendingFilePick,
|
||||||
|
allowedContentTypes: [.item],
|
||||||
|
allowsMultipleSelection: true
|
||||||
|
) { result in
|
||||||
|
switch result {
|
||||||
|
case .success(let urls):
|
||||||
|
let files = urls.compactMap { PickerSupport.pickedFile(from: $0, isDirectory: false) }
|
||||||
|
if files.isEmpty {
|
||||||
|
model.onFilePickFailed("The selected document could not be opened")
|
||||||
|
} else {
|
||||||
|
Task { await model.onFilesPicked(files) }
|
||||||
|
}
|
||||||
|
case .failure(let error):
|
||||||
|
if !error.isUserCancellation { model.onFilePickFailed(error.technicalDetail) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
enum PickerSupport {
|
enum PickerSupport {
|
||||||
static func receiveFolder(from url: URL) -> ReceiveFolder {
|
static func receiveFolder(from url: URL) -> ReceiveFolder {
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
@@ -129,4 +156,8 @@ extension View {
|
|||||||
func sendPickers(model: SendModel) -> some View {
|
func sendPickers(model: SendModel) -> some View {
|
||||||
modifier(SendPickers(model: model))
|
modifier(SendPickers(model: model))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func contactSendPickers(model: ContactsModel) -> some View {
|
||||||
|
modifier(ContactSendPickers(model: model))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,12 +20,6 @@
|
|||||||
<true/>
|
<true/>
|
||||||
<key>com.apple.security.network.client</key>
|
<key>com.apple.security.network.client</key>
|
||||||
<true/>
|
<true/>
|
||||||
<!-- Required, not optional: VniDrop's iroh (QUIC/UDP) endpoint listens for
|
|
||||||
and ACCEPTS inbound connections from peers — either device can initiate
|
|
||||||
a transfer. See HandshakeService::accept in crates/vnidrop/src/handshake.rs.
|
|
||||||
Removing this breaks the receive/serve half of every transfer under the
|
|
||||||
sandbox. App Store automated review may flag it (no classic TcpListener);
|
|
||||||
justify via App Review Information rather than removing. -->
|
|
||||||
<key>com.apple.security.network.server</key>
|
<key>com.apple.security.network.server</key>
|
||||||
<true/>
|
<true/>
|
||||||
</dict>
|
</dict>
|
||||||
|
|||||||
@@ -3,6 +3,12 @@ import VnidropCore
|
|||||||
|
|
||||||
/// Maps technical failures to stable, user-facing catalog keys. Ported from
|
/// Maps technical failures to stable, user-facing catalog keys. Ported from
|
||||||
/// `ui/feedback/UserFacingError.kt`. Never exposes raw `reason=` blobs.
|
/// `ui/feedback/UserFacingError.kt`. Never exposes raw `reason=` blobs.
|
||||||
|
/// How an offered transfer ended without being accepted.
|
||||||
|
enum OfferRefusal {
|
||||||
|
case declined
|
||||||
|
case noAnswer
|
||||||
|
}
|
||||||
|
|
||||||
extension Error {
|
extension Error {
|
||||||
func toUiText() -> UiText {
|
func toUiText() -> UiText {
|
||||||
if let invitation = self as? InvitationError {
|
if let invitation = self as? InvitationError {
|
||||||
@@ -32,6 +38,12 @@ extension Error {
|
|||||||
return .resource(L10n.Error.generic)
|
return .resource(L10n.Error.generic)
|
||||||
case .InvalidInput:
|
case .InvalidInput:
|
||||||
return .resource(L10n.Error.invalidInput)
|
return .resource(L10n.Error.invalidInput)
|
||||||
|
case .InvalidTransition:
|
||||||
|
return .resource(L10n.Error.invalidInput)
|
||||||
|
case .SecureStorageLocked, .SecureStorageUnavailable:
|
||||||
|
return .resource(L10n.Error.startingUp)
|
||||||
|
case .SecureStorageMissing, .SecureStorageCorrupted:
|
||||||
|
return .resource(L10n.Error.generic)
|
||||||
case .Initialization(let reason):
|
case .Initialization(let reason):
|
||||||
return initializationUiText(reason)
|
return initializationUiText(reason)
|
||||||
case .Internal(let reason):
|
case .Internal(let reason):
|
||||||
@@ -57,6 +69,19 @@ extension Error {
|
|||||||
|| haystack.contains("user canceled")
|
|| haystack.contains("user canceled")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The other device answered, and the answer was no.
|
||||||
|
///
|
||||||
|
/// Not a failure of this device: the offer was delivered and a person
|
||||||
|
/// declined it, so it is reported as information rather than an error.
|
||||||
|
var offerRefusal: OfferRefusal? {
|
||||||
|
let haystack = technicalDetail.lowercased()
|
||||||
|
if haystack.contains("receiver-declined") || haystack.contains("declined-recently") {
|
||||||
|
return .declined
|
||||||
|
}
|
||||||
|
if haystack.contains("no-response") { return .noAnswer }
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
/// Prefers a `VnidropError` reason; else the localized description.
|
/// Prefers a `VnidropError` reason; else the localized description.
|
||||||
var technicalDetail: String {
|
var technicalDetail: String {
|
||||||
if let vni = self as? VnidropError {
|
if let vni = self as? VnidropError {
|
||||||
@@ -64,7 +89,9 @@ extension Error {
|
|||||||
case .Initialization(let r), .Ticket(let r), .Filesystem(let r), .FilesystemPermission(let r),
|
case .Initialization(let r), .Ticket(let r), .Filesystem(let r), .FilesystemPermission(let r),
|
||||||
.DestinationExists(let r), .StorageFull(let r), .Network(let r),
|
.DestinationExists(let r), .StorageFull(let r), .Network(let r),
|
||||||
.Transfer(let r), .Permission(let r), .Repository(let r), .Cancelled(let r),
|
.Transfer(let r), .Permission(let r), .Repository(let r), .Cancelled(let r),
|
||||||
.InvalidInput(let r), .Internal(let r):
|
.InvalidInput(let r), .InvalidTransition(let r), .SecureStorageLocked(let r),
|
||||||
|
.SecureStorageMissing(let r), .SecureStorageCorrupted(let r),
|
||||||
|
.SecureStorageUnavailable(let r), .Internal(let r):
|
||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -159,25 +159,6 @@ targets:
|
|||||||
dependencies:
|
dependencies:
|
||||||
- target: VniDrop
|
- target: VniDrop
|
||||||
|
|
||||||
# UI test bundle used only to capture App Store screenshots (iOS/iPad).
|
|
||||||
# Driven by apple/scripts/appstore-screenshots.sh.
|
|
||||||
VniDropUITests:
|
|
||||||
type: bundle.ui-testing
|
|
||||||
supportedDestinations: [iOS]
|
|
||||||
configFiles:
|
|
||||||
Debug: Signing.xcconfig
|
|
||||||
Release: Signing.xcconfig
|
|
||||||
Release-Direct: Signing.xcconfig
|
|
||||||
sources:
|
|
||||||
- path: UITests
|
|
||||||
settings:
|
|
||||||
base:
|
|
||||||
GENERATE_INFOPLIST_FILE: YES
|
|
||||||
SWIFT_VERSION: "6.0"
|
|
||||||
TEST_TARGET_NAME: VniDrop
|
|
||||||
dependencies:
|
|
||||||
- target: VniDrop
|
|
||||||
|
|
||||||
schemes:
|
schemes:
|
||||||
VniDrop:
|
VniDrop:
|
||||||
build:
|
build:
|
||||||
@@ -207,13 +188,3 @@ schemes:
|
|||||||
config: Release-Direct
|
config: Release-Direct
|
||||||
archive:
|
archive:
|
||||||
config: Release-Direct
|
config: Release-Direct
|
||||||
|
|
||||||
# App Store screenshot capture (see apple/scripts/appstore-screenshots.sh).
|
|
||||||
VniDropScreenshots:
|
|
||||||
build:
|
|
||||||
targets:
|
|
||||||
VniDrop: all
|
|
||||||
test:
|
|
||||||
config: Debug
|
|
||||||
targets:
|
|
||||||
- VniDropUITests
|
|
||||||
|
|||||||
@@ -1,105 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
#
|
|
||||||
# Captures App Store screenshots for the VniDrop iOS/iPad app by running the
|
|
||||||
# VniDropUITests screenshot test on a simulator and extracting the attachments
|
|
||||||
# at the device's native resolution.
|
|
||||||
#
|
|
||||||
# Usage:
|
|
||||||
# apple/scripts/appstore-screenshots.sh [output-dir]
|
|
||||||
#
|
|
||||||
# Environment:
|
|
||||||
# SCREENSHOT_DEVICE Simulator device name (default: "iPad Pro 13-inch (M5)")
|
|
||||||
# 13-inch iPad → 2064×2752, accepted by App Store Connect.
|
|
||||||
#
|
|
||||||
# The output directory receives one PNG per tab (01-Send.png, 02-Receive.png, …).
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
||||||
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
|
||||||
APPLE_DIR="$REPO_ROOT/apple"
|
|
||||||
|
|
||||||
DEVICE="${SCREENSHOT_DEVICE:-iPad Pro 13-inch (M5)}"
|
|
||||||
OUT_DIR="${1:-$HOME/Desktop/vnidrop-appstore-screenshots}"
|
|
||||||
|
|
||||||
echo "==> Regenerating Xcode project (picks up the screenshot target)"
|
|
||||||
(cd "$APPLE_DIR" && xcodegen generate >/dev/null)
|
|
||||||
|
|
||||||
echo "==> Resolving simulator: $DEVICE"
|
|
||||||
# Match the device name literally (it contains parentheses, e.g. "(M5)"), then
|
|
||||||
# pull the UUID from the same line.
|
|
||||||
DEVICE_LINE="$(xcrun simctl list devices available | grep -F "$DEVICE (" | head -1)"
|
|
||||||
UDID="$(printf '%s' "$DEVICE_LINE" \
|
|
||||||
| grep -oiE '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}' | head -1)"
|
|
||||||
if [ -z "${UDID:-}" ]; then
|
|
||||||
echo "error: no available simulator named '$DEVICE'." >&2
|
|
||||||
echo " list options with: xcrun simctl list devices available" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
echo " udid: $UDID"
|
|
||||||
|
|
||||||
echo "==> Booting simulator"
|
|
||||||
xcrun simctl boot "$UDID" 2>/dev/null || true
|
|
||||||
xcrun simctl bootstatus "$UDID" -b >/dev/null 2>&1 || true
|
|
||||||
|
|
||||||
# Clean marketing status bar (Apple's 9:41, full signal/battery).
|
|
||||||
xcrun simctl status_bar "$UDID" override \
|
|
||||||
--time "9:41" \
|
|
||||||
--batteryState charged --batteryLevel 100 \
|
|
||||||
--cellularMode active --cellularBars 4 \
|
|
||||||
--wifiMode active --wifiBars 3 >/dev/null 2>&1 || true
|
|
||||||
|
|
||||||
RESULT_DIR="$(mktemp -d)"
|
|
||||||
RESULT="$RESULT_DIR/screenshots.xcresult"
|
|
||||||
ATT_DIR="$RESULT_DIR/attachments"
|
|
||||||
trap 'rm -rf "$RESULT_DIR"' EXIT
|
|
||||||
|
|
||||||
echo "==> Running UI screenshot test (this builds and launches the app)"
|
|
||||||
xcodebuild test \
|
|
||||||
-project "$APPLE_DIR/VniDrop.xcodeproj" \
|
|
||||||
-scheme VniDropScreenshots \
|
|
||||||
-destination "platform=iOS Simulator,id=$UDID" \
|
|
||||||
-resultBundlePath "$RESULT" \
|
|
||||||
-only-testing:VniDropUITests \
|
|
||||||
CODE_SIGNING_ALLOWED=NO \
|
|
||||||
| tail -12
|
|
||||||
|
|
||||||
echo "==> Extracting screenshots"
|
|
||||||
xcrun xcresulttool export attachments --path "$RESULT" --output-path "$ATT_DIR"
|
|
||||||
|
|
||||||
mkdir -p "$OUT_DIR"
|
|
||||||
python3 - "$ATT_DIR" "$OUT_DIR" <<'PY'
|
|
||||||
import json, os, re, shutil, sys
|
|
||||||
|
|
||||||
att_dir, out_dir = sys.argv[1], sys.argv[2]
|
|
||||||
manifest = os.path.join(att_dir, "manifest.json")
|
|
||||||
with open(manifest) as f:
|
|
||||||
data = json.load(f)
|
|
||||||
|
|
||||||
# Xcode suffixes attachment names with "_<n>_<uuid>.png"; keep only "NN-Name".
|
|
||||||
pattern = re.compile(r"^(\d\d-[A-Za-z]+)")
|
|
||||||
count = 0
|
|
||||||
|
|
||||||
def walk(node):
|
|
||||||
global count
|
|
||||||
if isinstance(node, dict):
|
|
||||||
name = node.get("suggestedHumanReadableName")
|
|
||||||
src = node.get("exportedFileName")
|
|
||||||
m = pattern.match(name) if name else None
|
|
||||||
if m and src:
|
|
||||||
base = f"{m.group(1)}.png"
|
|
||||||
shutil.copyfile(os.path.join(att_dir, src), os.path.join(out_dir, base))
|
|
||||||
print(f" {base}")
|
|
||||||
count += 1
|
|
||||||
for v in node.values():
|
|
||||||
walk(v)
|
|
||||||
elif isinstance(node, list):
|
|
||||||
for v in node:
|
|
||||||
walk(v)
|
|
||||||
|
|
||||||
walk(data)
|
|
||||||
if count == 0:
|
|
||||||
sys.exit("error: no named screenshots found in the result bundle")
|
|
||||||
PY
|
|
||||||
|
|
||||||
echo "==> Done. Screenshots in: $OUT_DIR"
|
|
||||||
ls -1 "$OUT_DIR"
|
|
||||||
@@ -16,6 +16,7 @@ blake3 = "1.8.3"
|
|||||||
data-encoding = "2.11.0"
|
data-encoding = "2.11.0"
|
||||||
futures = "0.3"
|
futures = "0.3"
|
||||||
futures-lite = "2.6.1"
|
futures-lite = "2.6.1"
|
||||||
|
getrandom = "0.3.4"
|
||||||
iroh = "1.0.3"
|
iroh = "1.0.3"
|
||||||
iroh-blobs = "0.103.0"
|
iroh-blobs = "0.103.0"
|
||||||
irpc = "0.17.0"
|
irpc = "0.17.0"
|
||||||
@@ -35,6 +36,20 @@ uniffi = { version = "=0.29.4", features = ["tokio"] }
|
|||||||
uuid = { version = "1.23.3", features = ["v4", "serde"] }
|
uuid = { version = "1.23.3", features = ["v4", "serde"] }
|
||||||
walkdir = "2.5.0"
|
walkdir = "2.5.0"
|
||||||
|
|
||||||
|
[target.'cfg(target_os = "linux")'.dependencies]
|
||||||
|
secret-service = { version = "5.1.0", default-features = false, features = ["rt-tokio-crypto-rust"] }
|
||||||
|
|
||||||
|
[target.'cfg(any(target_os = "macos", target_os = "ios"))'.dependencies]
|
||||||
|
security-framework = { version = "3.7.0", features = ["OSX_10_15"] }
|
||||||
|
|
||||||
|
[target.'cfg(target_os = "android")'.dependencies]
|
||||||
|
jni = "0.21.1"
|
||||||
|
ndk-context = "0.1.1"
|
||||||
|
|
||||||
|
[target.'cfg(target_os = "windows")'.dependencies]
|
||||||
|
windows-sys = { version = "0.61.2", features = ["Win32_Foundation", "Win32_Security_Cryptography", "Win32_Storage_FileSystem"] }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
iroh-relay = { version = "1.0.3", features = ["server"] }
|
iroh-relay = { version = "1.0.3", features = ["server"] }
|
||||||
|
secret-service = { version = "5.1.0", default-features = false, features = ["rt-tokio-crypto-rust"] }
|
||||||
tempfile = "3.27.0"
|
tempfile = "3.27.0"
|
||||||
|
|||||||
@@ -29,13 +29,6 @@ impl AccessPolicy {
|
|||||||
self.modes.write().await.insert(transfer_id, mode);
|
self.modes.write().await.insert(transfer_id, mode);
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) async fn allows_without_approval(&self, transfer_id: u64) -> bool {
|
|
||||||
matches!(
|
|
||||||
self.modes.read().await.get(&transfer_id),
|
|
||||||
Some(TransferAccessMode::Public)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) async fn remove_transfer(&self, transfer_id: u64) {
|
pub(crate) async fn remove_transfer(&self, transfer_id: u64) {
|
||||||
self.modes.write().await.remove(&transfer_id);
|
self.modes.write().await.remove(&transfer_id);
|
||||||
self.approved_sessions
|
self.approved_sessions
|
||||||
|
|||||||
@@ -10,6 +10,85 @@ use crate::util::{non_empty, now_ms};
|
|||||||
pub(crate) const MAX_CUSTOM_RELAYS: usize = 8;
|
pub(crate) const MAX_CUSTOM_RELAYS: usize = 8;
|
||||||
pub(crate) const MAX_RELAY_URL_BYTES: usize = 2_048;
|
pub(crate) const MAX_RELAY_URL_BYTES: usize = 2_048;
|
||||||
|
|
||||||
|
/// Versions the additive public domain seam and its two experimental wire protocols.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, uniffi::Record)]
|
||||||
|
pub struct ExperimentalSavedDeviceCapabilities {
|
||||||
|
pub domain_contract_version: u16,
|
||||||
|
pub relationship_protocol_version: u16,
|
||||||
|
pub targeted_transfer_protocol_version: u16,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[uniffi::export]
|
||||||
|
pub fn experimental_saved_device_capabilities() -> ExperimentalSavedDeviceCapabilities {
|
||||||
|
ExperimentalSavedDeviceCapabilities {
|
||||||
|
domain_contract_version: 1,
|
||||||
|
relationship_protocol_version: 1,
|
||||||
|
targeted_transfer_protocol_version: 1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A remote VniDrop app-installation identity that completed mutual consent.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, uniffi::Record)]
|
||||||
|
pub struct SavedDevice {
|
||||||
|
pub endpoint_id: String,
|
||||||
|
pub local_label: Option<String>,
|
||||||
|
pub remote_display_name: Option<String>,
|
||||||
|
pub created_at: i64,
|
||||||
|
pub last_authenticated_at: Option<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Durable consent lifecycle for one remote app-installation identity.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, uniffi::Enum)]
|
||||||
|
pub enum DeviceRelationshipState {
|
||||||
|
PendingOutgoing,
|
||||||
|
PendingIncoming,
|
||||||
|
Saved,
|
||||||
|
Revoked,
|
||||||
|
Blocked,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Public relationship state; directional grant material remains core-private.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, uniffi::Record)]
|
||||||
|
pub struct DeviceRelationship {
|
||||||
|
pub remote_endpoint_id: String,
|
||||||
|
pub state: DeviceRelationshipState,
|
||||||
|
pub generation: u64,
|
||||||
|
pub minimum_protocol_version: u16,
|
||||||
|
pub created_at: i64,
|
||||||
|
pub updated_at: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rust-owned lifecycle for an immutable one-sender, one-receiver transfer.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, uniffi::Enum)]
|
||||||
|
pub enum TargetedTransferState {
|
||||||
|
Preparing,
|
||||||
|
Offering,
|
||||||
|
AwaitingApproval,
|
||||||
|
Approved,
|
||||||
|
Connecting,
|
||||||
|
Transferring,
|
||||||
|
Interrupted,
|
||||||
|
Completed,
|
||||||
|
Declined,
|
||||||
|
Cancelled,
|
||||||
|
Failed,
|
||||||
|
Deleted,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Immutable recipient-bound transfer snapshot, separate from an ordinary share.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, uniffi::Record)]
|
||||||
|
pub struct TargetedTransfer {
|
||||||
|
pub id: String,
|
||||||
|
pub sender_endpoint_id: String,
|
||||||
|
pub receiver_endpoint_id: String,
|
||||||
|
pub manifest_id: String,
|
||||||
|
pub file_count: u64,
|
||||||
|
pub total_size: u64,
|
||||||
|
pub state: TargetedTransferState,
|
||||||
|
pub created_at: i64,
|
||||||
|
pub updated_at: i64,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, uniffi::Enum)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, uniffi::Enum)]
|
||||||
pub enum CoreRelayMode {
|
pub enum CoreRelayMode {
|
||||||
Automatic,
|
Automatic,
|
||||||
@@ -180,6 +259,8 @@ pub struct CoreLimits {
|
|||||||
pub max_metadata_bytes: u64,
|
pub max_metadata_bytes: u64,
|
||||||
pub max_events: u64,
|
pub max_events: u64,
|
||||||
pub max_pending_approvals: u64,
|
pub max_pending_approvals: u64,
|
||||||
|
/// Incoming pairing offers awaiting the local user's decision.
|
||||||
|
pub max_pending_offers: u64,
|
||||||
pub max_concurrent_transfers: u64,
|
pub max_concurrent_transfers: u64,
|
||||||
pub event_queue_capacity: u64,
|
pub event_queue_capacity: u64,
|
||||||
}
|
}
|
||||||
@@ -198,6 +279,9 @@ impl Default for CoreLimits {
|
|||||||
max_events: 500,
|
max_events: 500,
|
||||||
// Bound handshake spam / notification pressure on the sender.
|
// Bound handshake spam / notification pressure on the sender.
|
||||||
max_pending_approvals: 64,
|
max_pending_approvals: 64,
|
||||||
|
// A pairing prompt needs the user in front of the device, so this
|
||||||
|
// is far smaller than the handshake queue.
|
||||||
|
max_pending_offers: 16,
|
||||||
max_concurrent_transfers: 8,
|
max_concurrent_transfers: 8,
|
||||||
event_queue_capacity: 1_024,
|
event_queue_capacity: 1_024,
|
||||||
}
|
}
|
||||||
@@ -215,6 +299,7 @@ impl CoreLimits {
|
|||||||
("max_metadata_bytes", self.max_metadata_bytes),
|
("max_metadata_bytes", self.max_metadata_bytes),
|
||||||
("max_events", self.max_events),
|
("max_events", self.max_events),
|
||||||
("max_pending_approvals", self.max_pending_approvals),
|
("max_pending_approvals", self.max_pending_approvals),
|
||||||
|
("max_pending_offers", self.max_pending_offers),
|
||||||
("max_concurrent_transfers", self.max_concurrent_transfers),
|
("max_concurrent_transfers", self.max_concurrent_transfers),
|
||||||
("event_queue_capacity", self.event_queue_capacity),
|
("event_queue_capacity", self.event_queue_capacity),
|
||||||
];
|
];
|
||||||
@@ -225,6 +310,7 @@ impl CoreLimits {
|
|||||||
}
|
}
|
||||||
for (name, value) in [
|
for (name, value) in [
|
||||||
("max_pending_approvals", self.max_pending_approvals),
|
("max_pending_approvals", self.max_pending_approvals),
|
||||||
|
("max_pending_offers", self.max_pending_offers),
|
||||||
("max_concurrent_transfers", self.max_concurrent_transfers),
|
("max_concurrent_transfers", self.max_concurrent_transfers),
|
||||||
("event_queue_capacity", self.event_queue_capacity),
|
("event_queue_capacity", self.event_queue_capacity),
|
||||||
] {
|
] {
|
||||||
@@ -452,6 +538,80 @@ pub struct TicketInspection {
|
|||||||
pub metadata: TransferMetadata,
|
pub metadata: TransferMetadata,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A device the user has chosen to remember.
|
||||||
|
///
|
||||||
|
/// Deliberately carries no grant material: capabilities never cross the UniFFI
|
||||||
|
/// boundary, only the fact that one exists (`can_send`).
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)]
|
||||||
|
pub struct ContactSummary {
|
||||||
|
pub endpoint_id: String,
|
||||||
|
/// Set locally by the user. Authoritative for display.
|
||||||
|
pub local_label: Option<String>,
|
||||||
|
/// Last name the device claimed. Untrusted; never promoted to the label.
|
||||||
|
pub remote_display_name: Option<String>,
|
||||||
|
pub last_transfer_at: Option<i64>,
|
||||||
|
pub created_at: i64,
|
||||||
|
/// Whether this device can currently be sent to, i.e. a live grant is held.
|
||||||
|
/// False after the peer revoked, expired, or reinstalled.
|
||||||
|
pub can_send: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Outcome of sending straight to a remembered device.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)]
|
||||||
|
pub struct ContactSendResult {
|
||||||
|
pub share: ShareResult,
|
||||||
|
/// False when the device was not running: the transfer is held here and the
|
||||||
|
/// device collects it the next time it opens VniDrop.
|
||||||
|
pub delivered: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A transfer this device is holding until its target comes back online.
|
||||||
|
///
|
||||||
|
/// Cancelling the underlying transfer withdraws it.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)]
|
||||||
|
pub struct HeldOfferSummary {
|
||||||
|
pub offer_id: String,
|
||||||
|
pub endpoint_id: String,
|
||||||
|
pub transfer_id: u64,
|
||||||
|
pub transfer_name: String,
|
||||||
|
pub file_count: u64,
|
||||||
|
pub total_bytes: u64,
|
||||||
|
pub created_at: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A transfer a paired device is offering.
|
||||||
|
///
|
||||||
|
/// The ticket is deliberately absent: it is a capability, and it is handed over
|
||||||
|
/// only when the user accepts.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)]
|
||||||
|
pub struct IncomingOffer {
|
||||||
|
pub offer_id: String,
|
||||||
|
pub from_endpoint_id: String,
|
||||||
|
pub sender_display_name: Option<String>,
|
||||||
|
pub transfer_name: String,
|
||||||
|
pub file_count: u64,
|
||||||
|
pub total_bytes: u64,
|
||||||
|
pub received_at: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A device offering to be remembered, awaiting the local user's decision.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)]
|
||||||
|
pub struct PendingPairing {
|
||||||
|
pub endpoint_id: String,
|
||||||
|
pub display_name: Option<String>,
|
||||||
|
pub received_at: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How long a grant survives without use, renewed on every accepted proof.
|
||||||
|
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, uniffi::Enum)]
|
||||||
|
pub enum GrantLifetimeSetting {
|
||||||
|
Days30,
|
||||||
|
#[default]
|
||||||
|
Days90,
|
||||||
|
Days365,
|
||||||
|
Never,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)]
|
#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)]
|
||||||
pub struct ReceiverRequest {
|
pub struct ReceiverRequest {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ use tokio::sync::{oneshot, Mutex};
|
|||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
access_policy::{AccessPolicy, APPROVAL_SESSION_TTL_MS},
|
access_policy::{AccessDecision, AccessPolicy, APPROVAL_SESSION_TTL_MS},
|
||||||
event_hub::EventHub,
|
event_hub::EventHub,
|
||||||
handshake::{
|
handshake::{
|
||||||
DeliveryFailureReceipt, DeliveryReceipt, DeliveryReceiptResponse, HandshakeResponse,
|
DeliveryFailureReceipt, DeliveryReceipt, DeliveryReceiptResponse, HandshakeResponse,
|
||||||
@@ -198,10 +198,15 @@ impl ApprovalService {
|
|||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(true) => {
|
Ok(true) => {
|
||||||
|
// An existing access session means this endpoint was already
|
||||||
|
// authorised: either the share is public, or the sender pushed
|
||||||
|
// this transfer to them. Prompting again would ask the sender
|
||||||
|
// to approve a transfer they themselves initiated.
|
||||||
if self
|
if self
|
||||||
.access_policy
|
.access_policy
|
||||||
.allows_without_approval(request.transfer_id)
|
.decide(request.transfer_id, Some(&remote_endpoint_id))
|
||||||
.await
|
.await
|
||||||
|
== AccessDecision::Allow
|
||||||
{
|
{
|
||||||
self.allow_without_sender_decision(remote_endpoint_id, request)
|
self.allow_without_sender_decision(remote_endpoint_id, request)
|
||||||
.await
|
.await
|
||||||
|
|||||||
627
crates/vnidrop/src/contacts.rs
Normal file
@@ -0,0 +1,627 @@
|
|||||||
|
//! Storage for device history: contacts, the grants that make them usable, and
|
||||||
|
//! the block list.
|
||||||
|
//!
|
||||||
|
//! Split out of [`crate::repository`] to keep that file focused; the tables are
|
||||||
|
//! created as part of the same schema migration and share its pool.
|
||||||
|
//!
|
||||||
|
//! Grant secrets live here. They are key material and follow the same rule as
|
||||||
|
//! tickets: never logged, never emitted in an event, never returned across the
|
||||||
|
//! UniFFI boundary.
|
||||||
|
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use sqlx::{Row, SqlitePool};
|
||||||
|
|
||||||
|
use crate::grant::{parse_secret, GrantId, HeldGrant, IssuedGrant};
|
||||||
|
|
||||||
|
/// How long a dead grant is kept before being swept.
|
||||||
|
///
|
||||||
|
/// A revoked grant stays as a tombstone so a returning peer is told `Revoked`
|
||||||
|
/// rather than `Unknown`; after this long, a peer that has not come back is
|
||||||
|
/// unlikely to, and the row is noise.
|
||||||
|
pub(crate) const DEAD_GRANT_RETENTION_MS: i64 = 30 * 24 * 60 * 60 * 1_000;
|
||||||
|
|
||||||
|
/// A device the user has transferred with and chosen to remember.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub(crate) struct Contact {
|
||||||
|
pub(crate) endpoint_id: String,
|
||||||
|
/// Set by the local user. Never overwritten by a name the remote claims.
|
||||||
|
pub(crate) local_label: Option<String>,
|
||||||
|
/// Last name the remote sent. Untrusted display data.
|
||||||
|
pub(crate) remote_display_name: Option<String>,
|
||||||
|
/// Encoded `EndpointAddr` from the last successful connection, so the peer
|
||||||
|
/// stays dialable in relay profiles without public address lookup.
|
||||||
|
pub(crate) last_known_addr: Option<String>,
|
||||||
|
pub(crate) created_at: i64,
|
||||||
|
pub(crate) last_transfer_at: Option<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn ensure_schema(pool: &SqlitePool) -> Result<()> {
|
||||||
|
sqlx::query(
|
||||||
|
r#"
|
||||||
|
CREATE TABLE IF NOT EXISTS contacts (
|
||||||
|
endpoint_id TEXT PRIMARY KEY,
|
||||||
|
local_label TEXT,
|
||||||
|
remote_display_name TEXT,
|
||||||
|
last_known_addr TEXT,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
last_transfer_at INTEGER
|
||||||
|
);
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// Authoritative side: only the issuer can validate or revoke these.
|
||||||
|
sqlx::query(
|
||||||
|
r#"
|
||||||
|
CREATE TABLE IF NOT EXISTS grants_issued (
|
||||||
|
grant_id TEXT PRIMARY KEY,
|
||||||
|
grant_secret TEXT NOT NULL,
|
||||||
|
issued_to_endpoint_id TEXT NOT NULL,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
expires_at INTEGER,
|
||||||
|
revoked_at INTEGER
|
||||||
|
);
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
sqlx::query(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_grants_issued_endpoint ON grants_issued(issued_to_endpoint_id);",
|
||||||
|
)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
r#"
|
||||||
|
CREATE TABLE IF NOT EXISTS grants_held (
|
||||||
|
grant_id TEXT PRIMARY KEY,
|
||||||
|
grant_secret TEXT NOT NULL,
|
||||||
|
peer_endpoint_id TEXT NOT NULL,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
expires_at INTEGER
|
||||||
|
);
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
sqlx::query(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_grants_held_endpoint ON grants_held(peer_endpoint_id);",
|
||||||
|
)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
r#"
|
||||||
|
CREATE TABLE IF NOT EXISTS held_offers (
|
||||||
|
offer_id TEXT PRIMARY KEY,
|
||||||
|
endpoint_id TEXT NOT NULL,
|
||||||
|
transfer_id INTEGER NOT NULL,
|
||||||
|
ticket TEXT NOT NULL,
|
||||||
|
transfer_name TEXT NOT NULL,
|
||||||
|
sender_display_name TEXT,
|
||||||
|
file_count INTEGER NOT NULL,
|
||||||
|
total_bytes INTEGER NOT NULL,
|
||||||
|
created_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
sqlx::query("CREATE INDEX IF NOT EXISTS idx_held_offers_endpoint ON held_offers(endpoint_id);")
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
r#"
|
||||||
|
CREATE TABLE IF NOT EXISTS blocked_endpoints (
|
||||||
|
endpoint_id TEXT PRIMARY KEY,
|
||||||
|
created_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An offer that could not be delivered because the target was not running.
|
||||||
|
///
|
||||||
|
/// Held on this device, not a server: the share stays here and the receiver
|
||||||
|
/// collects the ticket when its app next comes to the foreground.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub(crate) struct HeldOffer {
|
||||||
|
pub(crate) offer_id: String,
|
||||||
|
pub(crate) endpoint_id: String,
|
||||||
|
pub(crate) transfer_id: u64,
|
||||||
|
pub(crate) ticket: String,
|
||||||
|
pub(crate) transfer_name: String,
|
||||||
|
pub(crate) sender_display_name: Option<String>,
|
||||||
|
pub(crate) file_count: u64,
|
||||||
|
pub(crate) total_bytes: u64,
|
||||||
|
pub(crate) created_at: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Contacts, grants, and blocks over the shared repository pool.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub(crate) struct ContactStore {
|
||||||
|
pool: SqlitePool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ContactStore {
|
||||||
|
pub(crate) fn new(pool: SqlitePool) -> Self {
|
||||||
|
Self { pool }
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- contacts ---------------------------------------------------------
|
||||||
|
|
||||||
|
/// Record a contact, or refresh the untrusted display name of an existing
|
||||||
|
/// one. The local label is deliberately left untouched.
|
||||||
|
pub(crate) async fn upsert_contact(
|
||||||
|
&self,
|
||||||
|
endpoint_id: &str,
|
||||||
|
remote_display_name: Option<&str>,
|
||||||
|
now_ms: i64,
|
||||||
|
) -> Result<()> {
|
||||||
|
sqlx::query(
|
||||||
|
r#"
|
||||||
|
INSERT INTO contacts (endpoint_id, remote_display_name, created_at)
|
||||||
|
VALUES (?1, ?2, ?3)
|
||||||
|
ON CONFLICT(endpoint_id) DO UPDATE SET
|
||||||
|
remote_display_name = COALESCE(excluded.remote_display_name, contacts.remote_display_name)
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(endpoint_id)
|
||||||
|
.bind(remote_display_name)
|
||||||
|
.bind(now_ms)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn set_contact_label(
|
||||||
|
&self,
|
||||||
|
endpoint_id: &str,
|
||||||
|
label: Option<&str>,
|
||||||
|
) -> Result<()> {
|
||||||
|
sqlx::query("UPDATE contacts SET local_label = ?2 WHERE endpoint_id = ?1")
|
||||||
|
.bind(endpoint_id)
|
||||||
|
.bind(label)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn touch_transfer(&self, endpoint_id: &str, now_ms: i64) -> Result<()> {
|
||||||
|
sqlx::query("UPDATE contacts SET last_transfer_at = ?2 WHERE endpoint_id = ?1")
|
||||||
|
.bind(endpoint_id)
|
||||||
|
.bind(now_ms)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn set_last_known_addr(&self, endpoint_id: &str, addr: &str) -> Result<()> {
|
||||||
|
sqlx::query("UPDATE contacts SET last_known_addr = ?2 WHERE endpoint_id = ?1")
|
||||||
|
.bind(endpoint_id)
|
||||||
|
.bind(addr)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn list_contacts(&self) -> Result<Vec<Contact>> {
|
||||||
|
let rows = sqlx::query(
|
||||||
|
r#"
|
||||||
|
SELECT endpoint_id, local_label, remote_display_name, last_known_addr,
|
||||||
|
created_at, last_transfer_at
|
||||||
|
FROM contacts
|
||||||
|
ORDER BY COALESCE(last_transfer_at, created_at) DESC
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(rows
|
||||||
|
.into_iter()
|
||||||
|
.map(|row| Contact {
|
||||||
|
endpoint_id: row.get(0),
|
||||||
|
local_label: row.get(1),
|
||||||
|
remote_display_name: row.get(2),
|
||||||
|
last_known_addr: row.get(3),
|
||||||
|
created_at: row.get(4),
|
||||||
|
last_transfer_at: row.get(5),
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn find_contact(&self, endpoint_id: &str) -> Result<Option<Contact>> {
|
||||||
|
Ok(self
|
||||||
|
.list_contacts()
|
||||||
|
.await?
|
||||||
|
.into_iter()
|
||||||
|
.find(|contact| contact.endpoint_id == endpoint_id))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove a contact and every grant in both directions.
|
||||||
|
///
|
||||||
|
/// Returns the ids of the grants this device had issued, so the caller can
|
||||||
|
/// send the best-effort revoke notification. Deletion succeeds regardless of
|
||||||
|
/// whether that notification is ever delivered.
|
||||||
|
pub(crate) async fn delete_contact(&self, endpoint_id: &str) -> Result<Vec<GrantId>> {
|
||||||
|
let issued = self.issued_grant_ids_for(endpoint_id).await?;
|
||||||
|
let mut tx = self.pool.begin().await?;
|
||||||
|
sqlx::query("DELETE FROM grants_issued WHERE issued_to_endpoint_id = ?1")
|
||||||
|
.bind(endpoint_id)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
sqlx::query("DELETE FROM grants_held WHERE peer_endpoint_id = ?1")
|
||||||
|
.bind(endpoint_id)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
sqlx::query("DELETE FROM contacts WHERE endpoint_id = ?1")
|
||||||
|
.bind(endpoint_id)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
tx.commit().await?;
|
||||||
|
Ok(issued)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wholesale delete, for the same surface that clears transfer history.
|
||||||
|
pub(crate) async fn delete_all_contacts(&self) -> Result<Vec<GrantId>> {
|
||||||
|
let issued = self.all_issued_grant_ids().await?;
|
||||||
|
let mut tx = self.pool.begin().await?;
|
||||||
|
sqlx::query("DELETE FROM grants_issued")
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
sqlx::query("DELETE FROM grants_held")
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
sqlx::query("DELETE FROM contacts")
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
tx.commit().await?;
|
||||||
|
Ok(issued)
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- issued grants ----------------------------------------------------
|
||||||
|
|
||||||
|
pub(crate) async fn insert_issued_grant(&self, grant: &IssuedGrant) -> Result<()> {
|
||||||
|
sqlx::query(
|
||||||
|
r#"
|
||||||
|
INSERT INTO grants_issued
|
||||||
|
(grant_id, grant_secret, issued_to_endpoint_id, created_at, expires_at, revoked_at)
|
||||||
|
VALUES (?1, ?2, ?3, ?4, ?5, NULL)
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(grant.grant_id.encode())
|
||||||
|
.bind(grant.secret.encode())
|
||||||
|
.bind(&grant.issued_to_endpoint_id)
|
||||||
|
.bind(grant.created_at)
|
||||||
|
.bind(grant.expires_at)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Look up a grant by the id a peer presented.
|
||||||
|
///
|
||||||
|
/// A row whose secret fails to parse is corrupt storage, not a usable
|
||||||
|
/// grant: surface the error rather than silently refusing the peer, which
|
||||||
|
/// would look like revocation.
|
||||||
|
pub(crate) async fn find_issued_grant(&self, grant_id: GrantId) -> Result<Option<IssuedGrant>> {
|
||||||
|
let row = sqlx::query(
|
||||||
|
r#"
|
||||||
|
SELECT grant_id, grant_secret, issued_to_endpoint_id, created_at, expires_at, revoked_at
|
||||||
|
FROM grants_issued
|
||||||
|
WHERE grant_id = ?1
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(grant_id.encode())
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await?;
|
||||||
|
row.map(row_to_issued_grant).transpose()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Push the idle deadline forward after an accepted proof.
|
||||||
|
pub(crate) async fn renew_issued_grant(
|
||||||
|
&self,
|
||||||
|
grant_id: GrantId,
|
||||||
|
expires_at: Option<i64>,
|
||||||
|
) -> Result<()> {
|
||||||
|
sqlx::query("UPDATE grants_issued SET expires_at = ?2 WHERE grant_id = ?1")
|
||||||
|
.bind(grant_id.encode())
|
||||||
|
.bind(expires_at)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// End the relationship from the issuing side. Tombstoned rather than
|
||||||
|
/// deleted so a later attempt is answered `Revoked` instead of `Unknown`.
|
||||||
|
pub(crate) async fn revoke_issued_grant(&self, grant_id: GrantId, now_ms: i64) -> Result<()> {
|
||||||
|
sqlx::query(
|
||||||
|
"UPDATE grants_issued SET revoked_at = ?2 WHERE grant_id = ?1 AND revoked_at IS NULL",
|
||||||
|
)
|
||||||
|
.bind(grant_id.encode())
|
||||||
|
.bind(now_ms)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn revoke_issued_grants_for(
|
||||||
|
&self,
|
||||||
|
endpoint_id: &str,
|
||||||
|
now_ms: i64,
|
||||||
|
) -> Result<Vec<GrantId>> {
|
||||||
|
let ids = self.issued_grant_ids_for(endpoint_id).await?;
|
||||||
|
sqlx::query(
|
||||||
|
"UPDATE grants_issued SET revoked_at = ?2 WHERE issued_to_endpoint_id = ?1 AND revoked_at IS NULL",
|
||||||
|
)
|
||||||
|
.bind(endpoint_id)
|
||||||
|
.bind(now_ms)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(ids)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn issued_grant_ids_for(&self, endpoint_id: &str) -> Result<Vec<GrantId>> {
|
||||||
|
let rows =
|
||||||
|
sqlx::query("SELECT grant_id FROM grants_issued WHERE issued_to_endpoint_id = ?1")
|
||||||
|
.bind(endpoint_id)
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await?;
|
||||||
|
rows.into_iter()
|
||||||
|
.map(|row| GrantId::decode(row.get::<String, _>(0).as_str()))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn all_issued_grant_ids(&self) -> Result<Vec<GrantId>> {
|
||||||
|
let rows = sqlx::query("SELECT grant_id FROM grants_issued")
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await?;
|
||||||
|
rows.into_iter()
|
||||||
|
.map(|row| GrantId::decode(row.get::<String, _>(0).as_str()))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- held grants ------------------------------------------------------
|
||||||
|
|
||||||
|
pub(crate) async fn insert_held_grant(&self, grant: &HeldGrant) -> Result<()> {
|
||||||
|
sqlx::query(
|
||||||
|
r#"
|
||||||
|
INSERT INTO grants_held
|
||||||
|
(grant_id, grant_secret, peer_endpoint_id, created_at, expires_at)
|
||||||
|
VALUES (?1, ?2, ?3, ?4, ?5)
|
||||||
|
ON CONFLICT(grant_id) DO UPDATE SET
|
||||||
|
grant_secret = excluded.grant_secret,
|
||||||
|
expires_at = excluded.expires_at
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(grant.grant_id.encode())
|
||||||
|
.bind(grant.secret.encode())
|
||||||
|
.bind(&grant.peer_endpoint_id)
|
||||||
|
.bind(grant.created_at)
|
||||||
|
.bind(grant.expires_at)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The capability to reach `peer_endpoint_id`, if this device holds one.
|
||||||
|
///
|
||||||
|
/// Newest wins: re-pairing issues a fresh grant, and the old one is dead on
|
||||||
|
/// the issuer's side anyway.
|
||||||
|
pub(crate) async fn held_grant_for(&self, peer_endpoint_id: &str) -> Result<Option<HeldGrant>> {
|
||||||
|
let row = sqlx::query(
|
||||||
|
r#"
|
||||||
|
SELECT grant_id, grant_secret, peer_endpoint_id, created_at, expires_at
|
||||||
|
FROM grants_held
|
||||||
|
WHERE peer_endpoint_id = ?1
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT 1
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(peer_endpoint_id)
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await?;
|
||||||
|
row.map(row_to_held_grant).transpose()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drop a grant this device holds, after the issuer reported it dead.
|
||||||
|
pub(crate) async fn delete_held_grant(&self, grant_id: GrantId) -> Result<()> {
|
||||||
|
sqlx::query("DELETE FROM grants_held WHERE grant_id = ?1")
|
||||||
|
.bind(grant_id.encode())
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- block list -------------------------------------------------------
|
||||||
|
|
||||||
|
/// Block an endpoint and revoke anything it still holds, so blocking is not
|
||||||
|
/// merely cosmetic while a live grant remains.
|
||||||
|
pub(crate) async fn block_endpoint(&self, endpoint_id: &str, now_ms: i64) -> Result<()> {
|
||||||
|
self.revoke_issued_grants_for(endpoint_id, now_ms).await?;
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO blocked_endpoints (endpoint_id, created_at) VALUES (?1, ?2)
|
||||||
|
ON CONFLICT(endpoint_id) DO NOTHING",
|
||||||
|
)
|
||||||
|
.bind(endpoint_id)
|
||||||
|
.bind(now_ms)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn unblock_endpoint(&self, endpoint_id: &str) -> Result<()> {
|
||||||
|
sqlx::query("DELETE FROM blocked_endpoints WHERE endpoint_id = ?1")
|
||||||
|
.bind(endpoint_id)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn is_blocked(&self, endpoint_id: &str) -> Result<bool> {
|
||||||
|
let row =
|
||||||
|
sqlx::query("SELECT EXISTS(SELECT 1 FROM blocked_endpoints WHERE endpoint_id = ?1)")
|
||||||
|
.bind(endpoint_id)
|
||||||
|
.fetch_one(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(row.get::<i64, _>(0) == 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn list_blocked(&self) -> Result<Vec<String>> {
|
||||||
|
let rows =
|
||||||
|
sqlx::query("SELECT endpoint_id FROM blocked_endpoints ORDER BY created_at DESC")
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(rows.into_iter().map(|row| row.get(0)).collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- held offers ------------------------------------------------------
|
||||||
|
|
||||||
|
pub(crate) async fn insert_held_offer(&self, offer: &HeldOffer) -> Result<()> {
|
||||||
|
sqlx::query(
|
||||||
|
r#"
|
||||||
|
INSERT INTO held_offers
|
||||||
|
(offer_id, endpoint_id, transfer_id, ticket, transfer_name,
|
||||||
|
sender_display_name, file_count, total_bytes, created_at)
|
||||||
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(&offer.offer_id)
|
||||||
|
.bind(&offer.endpoint_id)
|
||||||
|
.bind(offer.transfer_id as i64)
|
||||||
|
.bind(&offer.ticket)
|
||||||
|
.bind(&offer.transfer_name)
|
||||||
|
.bind(offer.sender_display_name.as_deref())
|
||||||
|
.bind(offer.file_count as i64)
|
||||||
|
.bind(offer.total_bytes as i64)
|
||||||
|
.bind(offer.created_at)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Offers waiting for one device to come and collect them.
|
||||||
|
pub(crate) async fn held_offers_for(&self, endpoint_id: &str) -> Result<Vec<HeldOffer>> {
|
||||||
|
let rows = sqlx::query(
|
||||||
|
r#"
|
||||||
|
SELECT offer_id, endpoint_id, transfer_id, ticket, transfer_name,
|
||||||
|
sender_display_name, file_count, total_bytes, created_at
|
||||||
|
FROM held_offers
|
||||||
|
WHERE endpoint_id = ?1
|
||||||
|
ORDER BY created_at ASC
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(endpoint_id)
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(rows.into_iter().map(row_to_held_offer).collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn list_held_offers(&self) -> Result<Vec<HeldOffer>> {
|
||||||
|
let rows = sqlx::query(
|
||||||
|
r#"
|
||||||
|
SELECT offer_id, endpoint_id, transfer_id, ticket, transfer_name,
|
||||||
|
sender_display_name, file_count, total_bytes, created_at
|
||||||
|
FROM held_offers
|
||||||
|
ORDER BY created_at ASC
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(rows.into_iter().map(row_to_held_offer).collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Consumed once handed over, so a device polling twice is not offered the
|
||||||
|
/// same transfer again.
|
||||||
|
pub(crate) async fn delete_held_offers(&self, offer_ids: &[String]) -> Result<()> {
|
||||||
|
let mut tx = self.pool.begin().await?;
|
||||||
|
for offer_id in offer_ids {
|
||||||
|
sqlx::query("DELETE FROM held_offers WHERE offer_id = ?1")
|
||||||
|
.bind(offer_id)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
tx.commit().await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn delete_held_offers_for_transfer(&self, transfer_id: u64) -> Result<()> {
|
||||||
|
sqlx::query("DELETE FROM held_offers WHERE transfer_id = ?1")
|
||||||
|
.bind(transfer_id as i64)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- maintenance ------------------------------------------------------
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(crate) async fn corrupt_secret_for_test(&self, grant_id: GrantId) -> Result<()> {
|
||||||
|
sqlx::query("UPDATE grants_issued SET grant_secret = 'not-hex' WHERE grant_id = ?1")
|
||||||
|
.bind(grant_id.encode())
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drop grants that lapsed or were revoked long enough ago that no peer
|
||||||
|
/// still needs to be told. Keeps tombstones bounded.
|
||||||
|
pub(crate) async fn purge_dead_grants(&self, before_ms: i64) -> Result<u64> {
|
||||||
|
let issued = sqlx::query(
|
||||||
|
"DELETE FROM grants_issued
|
||||||
|
WHERE (expires_at IS NOT NULL AND expires_at < ?1)
|
||||||
|
OR (revoked_at IS NOT NULL AND revoked_at < ?1)",
|
||||||
|
)
|
||||||
|
.bind(before_ms)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await?
|
||||||
|
.rows_affected();
|
||||||
|
Ok(issued)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn row_to_held_offer(row: sqlx::sqlite::SqliteRow) -> HeldOffer {
|
||||||
|
HeldOffer {
|
||||||
|
offer_id: row.get(0),
|
||||||
|
endpoint_id: row.get(1),
|
||||||
|
transfer_id: row.get::<i64, _>(2) as u64,
|
||||||
|
ticket: row.get(3),
|
||||||
|
transfer_name: row.get(4),
|
||||||
|
sender_display_name: row.get(5),
|
||||||
|
file_count: row.get::<i64, _>(6) as u64,
|
||||||
|
total_bytes: row.get::<i64, _>(7) as u64,
|
||||||
|
created_at: row.get(8),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn row_to_issued_grant(row: sqlx::sqlite::SqliteRow) -> Result<IssuedGrant> {
|
||||||
|
let grant_id = GrantId::decode(row.get::<String, _>(0).as_str())?;
|
||||||
|
let secret = parse_secret(row.get::<String, _>(1).as_str())
|
||||||
|
.context("stored grant secret is unusable")?;
|
||||||
|
Ok(IssuedGrant {
|
||||||
|
grant_id,
|
||||||
|
secret,
|
||||||
|
issued_to_endpoint_id: row.get(2),
|
||||||
|
created_at: row.get(3),
|
||||||
|
expires_at: row.get(4),
|
||||||
|
revoked_at: row.get(5),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn row_to_held_grant(row: sqlx::sqlite::SqliteRow) -> Result<HeldGrant> {
|
||||||
|
let grant_id = GrantId::decode(row.get::<String, _>(0).as_str())?;
|
||||||
|
let secret = parse_secret(row.get::<String, _>(1).as_str())
|
||||||
|
.context("stored grant secret is unusable")?;
|
||||||
|
Ok(HeldGrant {
|
||||||
|
grant_id,
|
||||||
|
secret,
|
||||||
|
peer_endpoint_id: row.get(2),
|
||||||
|
created_at: row.get(3),
|
||||||
|
expires_at: row.get(4),
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -26,15 +26,23 @@ pub enum VnidropError {
|
|||||||
Cancelled { reason: String },
|
Cancelled { reason: String },
|
||||||
#[error("invalid input: {reason}")]
|
#[error("invalid input: {reason}")]
|
||||||
InvalidInput { reason: String },
|
InvalidInput { reason: String },
|
||||||
|
#[error("invalid targeted transfer transition: {reason}")]
|
||||||
|
InvalidTransition { reason: String },
|
||||||
|
#[error("secure storage is locked: {reason}")]
|
||||||
|
SecureStorageLocked { reason: String },
|
||||||
|
#[error("secure storage item is missing: {reason}")]
|
||||||
|
SecureStorageMissing { reason: String },
|
||||||
|
#[error("secure storage item is corrupted: {reason}")]
|
||||||
|
SecureStorageCorrupted { reason: String },
|
||||||
|
#[error("secure storage is unavailable: {reason}")]
|
||||||
|
SecureStorageUnavailable { reason: String },
|
||||||
#[error("internal error: {reason}")]
|
#[error("internal error: {reason}")]
|
||||||
Internal { reason: String },
|
Internal { reason: String },
|
||||||
}
|
}
|
||||||
|
|
||||||
impl VnidropError {
|
impl VnidropError {
|
||||||
pub(crate) fn initialization(error: impl Into<anyhow::Error>) -> Self {
|
pub(crate) fn initialization(error: impl Into<anyhow::Error>) -> Self {
|
||||||
Self::Initialization {
|
Self::from_error(error.into(), |reason| Self::Initialization { reason })
|
||||||
reason: error.into().to_string(),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn ticket(error: impl Into<anyhow::Error>) -> Self {
|
pub(crate) fn ticket(error: impl Into<anyhow::Error>) -> Self {
|
||||||
@@ -93,6 +101,11 @@ impl VnidropError {
|
|||||||
Self::Repository { .. } => "repository",
|
Self::Repository { .. } => "repository",
|
||||||
Self::Cancelled { .. } => "cancelled",
|
Self::Cancelled { .. } => "cancelled",
|
||||||
Self::InvalidInput { .. } => "invalid_input",
|
Self::InvalidInput { .. } => "invalid_input",
|
||||||
|
Self::InvalidTransition { .. } => "invalid_transition",
|
||||||
|
Self::SecureStorageLocked { .. } => "secure_storage_locked",
|
||||||
|
Self::SecureStorageMissing { .. } => "secure_storage_missing",
|
||||||
|
Self::SecureStorageCorrupted { .. } => "secure_storage_corrupted",
|
||||||
|
Self::SecureStorageUnavailable { .. } => "secure_storage_unavailable",
|
||||||
Self::Internal { .. } => "internal",
|
Self::Internal { .. } => "internal",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -111,6 +124,11 @@ impl VnidropError {
|
|||||||
| Self::Repository { reason }
|
| Self::Repository { reason }
|
||||||
| Self::Cancelled { reason }
|
| Self::Cancelled { reason }
|
||||||
| Self::InvalidInput { reason }
|
| Self::InvalidInput { reason }
|
||||||
|
| Self::InvalidTransition { reason }
|
||||||
|
| Self::SecureStorageLocked { reason }
|
||||||
|
| Self::SecureStorageMissing { reason }
|
||||||
|
| Self::SecureStorageCorrupted { reason }
|
||||||
|
| Self::SecureStorageUnavailable { reason }
|
||||||
| Self::Internal { reason } => reason,
|
| Self::Internal { reason } => reason,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -163,6 +181,11 @@ impl VnidropError {
|
|||||||
Self::Repository { .. } => Self::Repository { reason },
|
Self::Repository { .. } => Self::Repository { reason },
|
||||||
Self::Cancelled { .. } => Self::Cancelled { reason },
|
Self::Cancelled { .. } => Self::Cancelled { reason },
|
||||||
Self::InvalidInput { .. } => Self::InvalidInput { reason },
|
Self::InvalidInput { .. } => Self::InvalidInput { reason },
|
||||||
|
Self::InvalidTransition { .. } => Self::InvalidTransition { reason },
|
||||||
|
Self::SecureStorageLocked { .. } => Self::SecureStorageLocked { reason },
|
||||||
|
Self::SecureStorageMissing { .. } => Self::SecureStorageMissing { reason },
|
||||||
|
Self::SecureStorageCorrupted { .. } => Self::SecureStorageCorrupted { reason },
|
||||||
|
Self::SecureStorageUnavailable { .. } => Self::SecureStorageUnavailable { reason },
|
||||||
Self::Internal { .. } => Self::Internal { reason },
|
Self::Internal { .. } => Self::Internal { reason },
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
364
crates/vnidrop/src/grant.rs
Normal file
@@ -0,0 +1,364 @@
|
|||||||
|
//! Grants: the capability a device issues so a known peer may reach it.
|
||||||
|
//!
|
||||||
|
//! A history entry is not "I remember this endpoint id", it is "this device
|
||||||
|
//! issued me a capability". The issuer is the only party that can validate a
|
||||||
|
//! grant, which is what makes both consent and revocation enforceable: refusing
|
||||||
|
//! to issue leaves the peer with nothing usable, and deleting the issued record
|
||||||
|
//! ends the relationship without the peer's cooperation.
|
||||||
|
//!
|
||||||
|
//! This module is pure: no storage, no network, no clock of its own. Callers
|
||||||
|
//! supply `now_ms` so expiry and renewal stay testable.
|
||||||
|
|
||||||
|
use std::fmt;
|
||||||
|
|
||||||
|
use anyhow::{bail, Context, Result};
|
||||||
|
use data_encoding::HEXLOWER;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
/// Domain separator for the possession proof. Changing this invalidates every
|
||||||
|
/// outstanding grant, so it is versioned rather than edited.
|
||||||
|
const PROOF_CONTEXT: &[u8] = b"vnidrop-grant-v1";
|
||||||
|
|
||||||
|
const GRANT_ID_LEN: usize = 16;
|
||||||
|
const GRANT_SECRET_LEN: usize = 32;
|
||||||
|
const CHALLENGE_LEN: usize = 32;
|
||||||
|
const PROOF_LEN: usize = 32;
|
||||||
|
|
||||||
|
/// Opaque public identifier for a grant. Safe to send in the clear.
|
||||||
|
#[derive(Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||||
|
pub(crate) struct GrantId([u8; GRANT_ID_LEN]);
|
||||||
|
|
||||||
|
impl GrantId {
|
||||||
|
pub(crate) fn generate() -> Self {
|
||||||
|
Self(random_bytes())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn encode(&self) -> String {
|
||||||
|
HEXLOWER.encode(&self.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn decode(value: &str) -> Result<Self> {
|
||||||
|
let bytes = HEXLOWER
|
||||||
|
.decode(value.as_bytes())
|
||||||
|
.context("invalid grant id encoding")?;
|
||||||
|
let bytes: [u8; GRANT_ID_LEN] = bytes
|
||||||
|
.try_into()
|
||||||
|
.map_err(|_| anyhow::anyhow!("invalid grant id length"))?;
|
||||||
|
Ok(Self(bytes))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Debug for GrantId {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
write!(f, "GrantId({})", self.encode())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Key material. Never logged, never emitted in an event, never returned across
|
||||||
|
/// the UniFFI boundary.
|
||||||
|
#[derive(Clone, PartialEq, Eq)]
|
||||||
|
pub(crate) struct GrantSecret([u8; GRANT_SECRET_LEN]);
|
||||||
|
|
||||||
|
impl GrantSecret {
|
||||||
|
pub(crate) fn generate() -> Self {
|
||||||
|
Self(random_bytes())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn encode(&self) -> String {
|
||||||
|
HEXLOWER.encode(&self.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn decode(value: &str) -> Result<Self> {
|
||||||
|
let bytes = HEXLOWER
|
||||||
|
.decode(value.as_bytes())
|
||||||
|
.context("invalid grant secret encoding")?;
|
||||||
|
let bytes: [u8; GRANT_SECRET_LEN] = bytes
|
||||||
|
.try_into()
|
||||||
|
.map_err(|_| anyhow::anyhow!("invalid grant secret length"))?;
|
||||||
|
Ok(Self(bytes))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Redacted on purpose: a secret must not reach a log line through a derived
|
||||||
|
// Debug on some enclosing struct.
|
||||||
|
impl fmt::Debug for GrantSecret {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
f.write_str("GrantSecret(redacted)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Random challenge sent by the issuer to bind a proof to one connection.
|
||||||
|
#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub(crate) struct Challenge([u8; CHALLENGE_LEN]);
|
||||||
|
|
||||||
|
impl Challenge {
|
||||||
|
pub(crate) fn generate() -> Self {
|
||||||
|
Self(random_bytes())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(crate) fn from_bytes(bytes: [u8; CHALLENGE_LEN]) -> Self {
|
||||||
|
Self(bytes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Debug for Challenge {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
f.write_str("Challenge(..)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Proof that the sender holds the secret behind `grant_id`.
|
||||||
|
#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub(crate) struct GrantProof {
|
||||||
|
pub(crate) grant_id: GrantId,
|
||||||
|
mac: [u8; PROOF_LEN],
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Debug for GrantProof {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
f.debug_struct("GrantProof")
|
||||||
|
.field("grant_id", &self.grant_id)
|
||||||
|
.finish_non_exhaustive()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Why a presented proof was not accepted.
|
||||||
|
///
|
||||||
|
/// `Revoked` is reported to the peer so its client can drop the dead entry.
|
||||||
|
/// `Unknown` is deliberately also used for blocked endpoints, so blocking
|
||||||
|
/// cannot be detected by probing.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub(crate) enum GrantRejection {
|
||||||
|
Unknown,
|
||||||
|
Revoked,
|
||||||
|
Expired,
|
||||||
|
WrongEndpoint,
|
||||||
|
BadProof,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl GrantRejection {
|
||||||
|
pub(crate) fn as_str(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Unknown => "unknown",
|
||||||
|
Self::Revoked => "revoked",
|
||||||
|
Self::Expired => "expired",
|
||||||
|
Self::WrongEndpoint => "wrong-endpoint",
|
||||||
|
Self::BadProof => "bad-proof",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A grant as held by the party that issued it. This is the authoritative
|
||||||
|
/// record: `grants_held` on the peer is only a copy for display.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub(crate) struct IssuedGrant {
|
||||||
|
pub(crate) grant_id: GrantId,
|
||||||
|
pub(crate) secret: GrantSecret,
|
||||||
|
/// The grant is usable only by this endpoint, so it cannot be lent onward.
|
||||||
|
pub(crate) issued_to_endpoint_id: String,
|
||||||
|
pub(crate) created_at: i64,
|
||||||
|
/// Idle expiry, pushed forward on every accepted proof. `None` never expires.
|
||||||
|
pub(crate) expires_at: Option<i64>,
|
||||||
|
pub(crate) revoked_at: Option<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IssuedGrant {
|
||||||
|
pub(crate) fn mint(
|
||||||
|
issued_to_endpoint_id: String,
|
||||||
|
now_ms: i64,
|
||||||
|
lifetime: GrantLifetime,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
grant_id: GrantId::generate(),
|
||||||
|
secret: GrantSecret::generate(),
|
||||||
|
issued_to_endpoint_id,
|
||||||
|
created_at: now_ms,
|
||||||
|
expires_at: lifetime.deadline_from(now_ms),
|
||||||
|
revoked_at: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validate a proof presented by `remote_endpoint_id` over this connection's
|
||||||
|
/// challenge. Returns the renewed expiry the caller must persist.
|
||||||
|
///
|
||||||
|
/// Checks run in a fixed order so a caller cannot learn more from an early
|
||||||
|
/// return than from a late one: revocation and expiry are properties of the
|
||||||
|
/// issuer's own record, and the endpoint binding is checked before the MAC
|
||||||
|
/// so a stolen grant cannot be probed for validity from another device.
|
||||||
|
pub(crate) fn accept(
|
||||||
|
&self,
|
||||||
|
proof: &GrantProof,
|
||||||
|
challenge: &Challenge,
|
||||||
|
issuer_endpoint_id: &str,
|
||||||
|
remote_endpoint_id: &str,
|
||||||
|
now_ms: i64,
|
||||||
|
lifetime: GrantLifetime,
|
||||||
|
) -> Result<Option<i64>, GrantRejection> {
|
||||||
|
if proof.grant_id != self.grant_id {
|
||||||
|
return Err(GrantRejection::Unknown);
|
||||||
|
}
|
||||||
|
if self.revoked_at.is_some() {
|
||||||
|
return Err(GrantRejection::Revoked);
|
||||||
|
}
|
||||||
|
if self.is_expired(now_ms) {
|
||||||
|
return Err(GrantRejection::Expired);
|
||||||
|
}
|
||||||
|
if remote_endpoint_id != self.issued_to_endpoint_id {
|
||||||
|
return Err(GrantRejection::WrongEndpoint);
|
||||||
|
}
|
||||||
|
|
||||||
|
let expected = compute_proof(
|
||||||
|
&self.secret,
|
||||||
|
challenge,
|
||||||
|
issuer_endpoint_id,
|
||||||
|
remote_endpoint_id,
|
||||||
|
);
|
||||||
|
// Constant-time: blake3::Hash's PartialEq is constant-time by design.
|
||||||
|
if !constant_time_eq(&expected, &proof.mac) {
|
||||||
|
return Err(GrantRejection::BadProof);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(lifetime.deadline_from(now_ms))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn is_expired(&self, now_ms: i64) -> bool {
|
||||||
|
self.expires_at
|
||||||
|
.is_some_and(|expires_at| expires_at < now_ms)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A grant as held by the party it was issued to: the capability used to reach
|
||||||
|
/// the peer that minted it.
|
||||||
|
///
|
||||||
|
/// `expires_at` here is advisory only — a copy of what the issuer said at issue
|
||||||
|
/// time, useful for showing "expires soon" in the UI. The issuer's record is
|
||||||
|
/// authoritative and may have been renewed or revoked since.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub(crate) struct HeldGrant {
|
||||||
|
pub(crate) grant_id: GrantId,
|
||||||
|
pub(crate) secret: GrantSecret,
|
||||||
|
/// The peer that issued this grant, and therefore the only one it works on.
|
||||||
|
pub(crate) peer_endpoint_id: String,
|
||||||
|
pub(crate) created_at: i64,
|
||||||
|
pub(crate) expires_at: Option<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HeldGrant {
|
||||||
|
/// Build the proof to present to the issuing peer.
|
||||||
|
pub(crate) fn prove(&self, challenge: &Challenge, self_endpoint_id: &str) -> GrantProof {
|
||||||
|
prove(
|
||||||
|
self.grant_id,
|
||||||
|
&self.secret,
|
||||||
|
challenge,
|
||||||
|
&self.peer_endpoint_id,
|
||||||
|
self_endpoint_id,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How long a grant survives without use. Grants expire on idleness rather than
|
||||||
|
/// age, so a relationship in regular use never lapses while a forgotten one
|
||||||
|
/// cleans itself up.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub(crate) enum GrantLifetime {
|
||||||
|
Days(u32),
|
||||||
|
Never,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl GrantLifetime {
|
||||||
|
pub(crate) const DEFAULT_DAYS: u32 = 90;
|
||||||
|
|
||||||
|
pub(crate) fn deadline_from(self, now_ms: i64) -> Option<i64> {
|
||||||
|
match self {
|
||||||
|
Self::Never => None,
|
||||||
|
Self::Days(days) => Some(now_ms + i64::from(days) * 24 * 60 * 60 * 1_000),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for GrantLifetime {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::Days(Self::DEFAULT_DAYS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<crate::api::GrantLifetimeSetting> for GrantLifetime {
|
||||||
|
fn from(setting: crate::api::GrantLifetimeSetting) -> Self {
|
||||||
|
match setting {
|
||||||
|
crate::api::GrantLifetimeSetting::Days30 => Self::Days(30),
|
||||||
|
crate::api::GrantLifetimeSetting::Days90 => Self::Days(90),
|
||||||
|
crate::api::GrantLifetimeSetting::Days365 => Self::Days(365),
|
||||||
|
crate::api::GrantLifetimeSetting::Never => Self::Never,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build the proof for a grant this device holds.
|
||||||
|
pub(crate) fn prove(
|
||||||
|
grant_id: GrantId,
|
||||||
|
secret: &GrantSecret,
|
||||||
|
challenge: &Challenge,
|
||||||
|
issuer_endpoint_id: &str,
|
||||||
|
holder_endpoint_id: &str,
|
||||||
|
) -> GrantProof {
|
||||||
|
GrantProof {
|
||||||
|
grant_id,
|
||||||
|
mac: compute_proof(secret, challenge, issuer_endpoint_id, holder_endpoint_id),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Keyed MAC over the challenge and both endpoint identities.
|
||||||
|
///
|
||||||
|
/// Binding the challenge stops a captured proof being replayed; binding both
|
||||||
|
/// endpoint ids stops it being replayed against a different peer. Lengths are
|
||||||
|
/// prefixed so two different id pairs cannot produce the same input.
|
||||||
|
fn compute_proof(
|
||||||
|
secret: &GrantSecret,
|
||||||
|
challenge: &Challenge,
|
||||||
|
issuer_endpoint_id: &str,
|
||||||
|
holder_endpoint_id: &str,
|
||||||
|
) -> [u8; PROOF_LEN] {
|
||||||
|
let mut input = Vec::with_capacity(
|
||||||
|
PROOF_CONTEXT.len()
|
||||||
|
+ CHALLENGE_LEN
|
||||||
|
+ issuer_endpoint_id.len()
|
||||||
|
+ holder_endpoint_id.len()
|
||||||
|
+ 16,
|
||||||
|
);
|
||||||
|
input.extend_from_slice(PROOF_CONTEXT);
|
||||||
|
input.extend_from_slice(&challenge.0);
|
||||||
|
push_length_prefixed(&mut input, issuer_endpoint_id.as_bytes());
|
||||||
|
push_length_prefixed(&mut input, holder_endpoint_id.as_bytes());
|
||||||
|
*blake3::keyed_hash(&secret.0, &input).as_bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn push_length_prefixed(buffer: &mut Vec<u8>, bytes: &[u8]) {
|
||||||
|
buffer.extend_from_slice(&(bytes.len() as u64).to_le_bytes());
|
||||||
|
buffer.extend_from_slice(bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn constant_time_eq(left: &[u8; PROOF_LEN], right: &[u8; PROOF_LEN]) -> bool {
|
||||||
|
// blake3::Hash compares in constant time; reuse it rather than hand-rolling.
|
||||||
|
blake3::Hash::from_bytes(*left) == blake3::Hash::from_bytes(*right)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cryptographically secure random bytes.
|
||||||
|
///
|
||||||
|
/// Panics if the OS entropy source fails. That is unrecoverable and must never
|
||||||
|
/// degrade into a weak grant, so it is not surfaced as a fallible API.
|
||||||
|
fn random_bytes<const N: usize>() -> [u8; N] {
|
||||||
|
let mut bytes = [0u8; N];
|
||||||
|
getrandom::fill(&mut bytes).expect("OS entropy source unavailable");
|
||||||
|
bytes
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse a stored grant secret, rejecting anything malformed rather than
|
||||||
|
/// silently producing a grant that can never validate.
|
||||||
|
pub(crate) fn parse_secret(value: &str) -> Result<GrantSecret> {
|
||||||
|
let secret = GrantSecret::decode(value)?;
|
||||||
|
if secret.0.iter().all(|byte| *byte == 0) {
|
||||||
|
bail!("refusing an all-zero grant secret");
|
||||||
|
}
|
||||||
|
Ok(secret)
|
||||||
|
}
|
||||||
@@ -52,13 +52,6 @@ impl HandshakeService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl ProtocolHandler for HandshakeService {
|
impl ProtocolHandler for HandshakeService {
|
||||||
// This handler ACCEPTS inbound QUIC connections from peers: iroh binds a
|
|
||||||
// listening endpoint and this device responds to connections initiated by
|
|
||||||
// the remote side. On macOS this is why the App Sandbox requires the
|
|
||||||
// `com.apple.security.network.server` entitlement (see
|
|
||||||
// apple/VniDrop/Resources/VniDrop.entitlements) in addition to
|
|
||||||
// `network.client` — either peer can initiate a transfer, so both the
|
|
||||||
// client and server sides of a connection are used.
|
|
||||||
async fn accept(&self, connection: Connection) -> Result<(), AcceptError> {
|
async fn accept(&self, connection: Connection) -> Result<(), AcceptError> {
|
||||||
let remote_endpoint_id = connection.remote_id().to_string();
|
let remote_endpoint_id = connection.remote_id().to_string();
|
||||||
|
|
||||||
|
|||||||
@@ -1,24 +1,39 @@
|
|||||||
mod access_policy;
|
mod access_policy;
|
||||||
mod api;
|
mod api;
|
||||||
mod approval;
|
mod approval;
|
||||||
|
mod contacts;
|
||||||
mod error;
|
mod error;
|
||||||
mod event_hub;
|
mod event_hub;
|
||||||
mod filesystem;
|
mod filesystem;
|
||||||
|
mod grant;
|
||||||
mod handshake;
|
mod handshake;
|
||||||
mod logging;
|
mod logging;
|
||||||
|
mod offer;
|
||||||
|
mod offer_inbox;
|
||||||
|
mod pairing;
|
||||||
mod repository;
|
mod repository;
|
||||||
mod runtime;
|
mod runtime;
|
||||||
mod secret;
|
mod secret;
|
||||||
|
#[allow(
|
||||||
|
dead_code,
|
||||||
|
reason = "the private custody seam is activated by platform credential adapters"
|
||||||
|
)]
|
||||||
|
mod secure_secret;
|
||||||
|
mod targeted_transfer;
|
||||||
mod ticket;
|
mod ticket;
|
||||||
mod transfer_state;
|
mod transfer_state;
|
||||||
mod util;
|
mod util;
|
||||||
|
|
||||||
pub use api::{
|
pub use api::{
|
||||||
clear_inactive_transfer_cache, default_core_limits, default_core_network_config, CoreEvent,
|
clear_inactive_transfer_cache, default_core_limits, default_core_network_config,
|
||||||
CoreEventSink, CoreLimits, CoreNetworkConfig, CoreRelayMode, CoreStorageUsage, PublishedOutput,
|
experimental_saved_device_capabilities, ContactSendResult, ContactSummary, CoreEvent,
|
||||||
|
CoreEventSink, CoreLimits, CoreNetworkConfig, CoreRelayMode, CoreStorageUsage,
|
||||||
|
DeviceRelationship, DeviceRelationshipState, ExperimentalSavedDeviceCapabilities,
|
||||||
|
GrantLifetimeSetting, HeldOfferSummary, IncomingOffer, PendingPairing, PublishedOutput,
|
||||||
ReceiveOutputSink, ReceiveOutputSinkV2, ReceivedArtifact, ReceivedLocatorKind, ReceiverRequest,
|
ReceiveOutputSink, ReceiveOutputSinkV2, ReceivedArtifact, ReceivedLocatorKind, ReceiverRequest,
|
||||||
RuntimeStatus, ShareMetadataInput, ShareResult, ShareSource, SourceKind, StoredTransfer,
|
RuntimeStatus, SavedDevice, ShareMetadataInput, ShareResult, ShareSource, SourceKind,
|
||||||
TicketInspection, TransferAccessMode, TransferMetadata,
|
StoredTransfer, TargetedTransfer, TargetedTransferState, TicketInspection, TransferAccessMode,
|
||||||
|
TransferMetadata,
|
||||||
};
|
};
|
||||||
pub use error::VnidropError;
|
pub use error::VnidropError;
|
||||||
pub use runtime::VnidropCore;
|
pub use runtime::VnidropCore;
|
||||||
|
|||||||
335
crates/vnidrop/src/offer.rs
Normal file
@@ -0,0 +1,335 @@
|
|||||||
|
//! The contacts protocol: how paired devices reach each other directly.
|
||||||
|
//!
|
||||||
|
//! Separate ALPN from the transfer handshake because the trust model differs.
|
||||||
|
//! `/vnidrop/handshake/2` serves anyone holding a ticket, subject to sender
|
||||||
|
//! approval. This one serves nobody without a grant (see [`crate::grant`]), so
|
||||||
|
//! an unpaired device cannot even raise a prompt on the far side.
|
||||||
|
//!
|
||||||
|
//! Every request except grant delivery carries a proof over a challenge this
|
||||||
|
//! connection issued, so a captured proof cannot be replayed onto another
|
||||||
|
//! connection.
|
||||||
|
|
||||||
|
use std::fmt;
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
use iroh::{
|
||||||
|
endpoint::Connection,
|
||||||
|
protocol::{AcceptError, ProtocolHandler},
|
||||||
|
Endpoint, EndpointAddr,
|
||||||
|
};
|
||||||
|
use irpc::{channel::oneshot, rpc_requests, Client, WithChannels};
|
||||||
|
use irpc_iroh::{read_request, IrohLazyRemoteConnection};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
grant::{Challenge, GrantId, GrantProof},
|
||||||
|
offer_inbox::OfferInbox,
|
||||||
|
pairing::PairingService,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub(crate) struct OfferService {
|
||||||
|
pairing: PairingService,
|
||||||
|
inbox: OfferInbox,
|
||||||
|
/// This device's endpoint id. Grants we issued are bound to it, so proofs
|
||||||
|
/// must be verified against it rather than against whatever a peer claims.
|
||||||
|
self_endpoint_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Debug for OfferService {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
f.write_str("OfferService")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl OfferService {
|
||||||
|
pub(crate) const ALPN: &'static [u8] = b"/vnidrop/offer/1";
|
||||||
|
|
||||||
|
pub(crate) fn new(
|
||||||
|
pairing: PairingService,
|
||||||
|
inbox: OfferInbox,
|
||||||
|
self_endpoint_id: String,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
pairing,
|
||||||
|
inbox,
|
||||||
|
self_endpoint_id,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn client(endpoint: Endpoint, addr: EndpointAddr) -> OfferClient {
|
||||||
|
OfferClient {
|
||||||
|
inner: Client::boxed(IrohLazyRemoteConnection::new(
|
||||||
|
endpoint,
|
||||||
|
addr,
|
||||||
|
Self::ALPN.to_vec(),
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl OfferService {
|
||||||
|
/// Validate the grant, then hand the offer to the local user.
|
||||||
|
///
|
||||||
|
/// A refusal names the grant failure so the peer can drop a dead entry;
|
||||||
|
/// `Unknown` covers both "never issued" and "blocked", which is what keeps
|
||||||
|
/// blocking undetectable.
|
||||||
|
async fn handle_offer(
|
||||||
|
&self,
|
||||||
|
remote_endpoint_id: &str,
|
||||||
|
challenge: &Challenge,
|
||||||
|
offer: SubmitOffer,
|
||||||
|
) -> OfferResponse {
|
||||||
|
if let Err(rejection) = self
|
||||||
|
.pairing
|
||||||
|
.verify_and_renew(
|
||||||
|
&offer.proof,
|
||||||
|
challenge,
|
||||||
|
&self.self_endpoint_id,
|
||||||
|
remote_endpoint_id,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
return OfferResponse::Refused {
|
||||||
|
reason: rejection.as_str().to_string(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
self.inbox
|
||||||
|
.submit(
|
||||||
|
remote_endpoint_id.to_string(),
|
||||||
|
offer.transfer_name,
|
||||||
|
offer.sender_display_name,
|
||||||
|
offer.file_count,
|
||||||
|
offer.total_bytes,
|
||||||
|
offer.ticket,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl OfferService {
|
||||||
|
/// Hand a device the offers this one is holding for it.
|
||||||
|
///
|
||||||
|
/// Needs no grant proof: iroh has already authenticated the remote endpoint
|
||||||
|
/// key, and the only thing returned is what this device already decided to
|
||||||
|
/// send to precisely that endpoint. A stranger polling gets an empty list.
|
||||||
|
async fn handle_poll(&self, remote_endpoint_id: &str) -> PolledOffers {
|
||||||
|
PolledOffers {
|
||||||
|
offers: self.pairing.collect_held_offers(remote_endpoint_id).await,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ProtocolHandler for OfferService {
|
||||||
|
/// Accepts inbound connections from paired peers.
|
||||||
|
///
|
||||||
|
/// The challenge is per connection and never leaves this scope, which is
|
||||||
|
/// what binds a proof to one session: a proof captured from an earlier
|
||||||
|
/// connection cannot be presented on a later one.
|
||||||
|
async fn accept(&self, connection: Connection) -> Result<(), AcceptError> {
|
||||||
|
let remote_endpoint_id = connection.remote_id().to_string();
|
||||||
|
let challenge = Challenge::generate();
|
||||||
|
|
||||||
|
while let Some(message) = read_request::<OfferProtocol>(&connection).await? {
|
||||||
|
match message {
|
||||||
|
OfferMessage::RequestChallenge(message) => {
|
||||||
|
let WithChannels { tx, .. } = message;
|
||||||
|
let _ = tx
|
||||||
|
.send(ChallengeResponse {
|
||||||
|
challenge: challenge.clone(),
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
OfferMessage::DeliverGrant(message) => {
|
||||||
|
let WithChannels { inner, tx, .. } = message;
|
||||||
|
let response = self
|
||||||
|
.pairing
|
||||||
|
.receive_grant(remote_endpoint_id.clone(), inner)
|
||||||
|
.await;
|
||||||
|
let _ = tx.send(response).await;
|
||||||
|
}
|
||||||
|
OfferMessage::RevokeGrant(message) => {
|
||||||
|
let WithChannels { inner, tx, .. } = message;
|
||||||
|
let response = self
|
||||||
|
.pairing
|
||||||
|
.receive_revocation(remote_endpoint_id.clone(), inner)
|
||||||
|
.await;
|
||||||
|
let _ = tx.send(response).await;
|
||||||
|
}
|
||||||
|
OfferMessage::PollOffers(message) => {
|
||||||
|
let WithChannels { tx, .. } = message;
|
||||||
|
let response = self.handle_poll(&remote_endpoint_id).await;
|
||||||
|
let _ = tx.send(response).await;
|
||||||
|
}
|
||||||
|
OfferMessage::SubmitOffer(message) => {
|
||||||
|
let WithChannels { inner, tx, .. } = message;
|
||||||
|
let response = self
|
||||||
|
.handle_offer(&remote_endpoint_id, &challenge, inner)
|
||||||
|
.await;
|
||||||
|
let _ = tx.send(response).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
connection.closed().await;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub(crate) struct OfferClient {
|
||||||
|
inner: Client<OfferProtocol>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl OfferClient {
|
||||||
|
pub(crate) async fn poll_offers(&self) -> Result<PolledOffers, irpc::Error> {
|
||||||
|
self.inner.rpc(PollOffers).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn request_challenge(&self) -> Result<Challenge, irpc::Error> {
|
||||||
|
Ok(self.inner.rpc(RequestChallenge).await?.challenge)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn submit_offer(
|
||||||
|
&self,
|
||||||
|
offer: SubmitOffer,
|
||||||
|
) -> Result<OfferResponse, irpc::Error> {
|
||||||
|
self.inner.rpc(offer).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn deliver_grant(
|
||||||
|
&self,
|
||||||
|
grant: DeliverGrant,
|
||||||
|
) -> Result<GrantDeliveryResponse, irpc::Error> {
|
||||||
|
self.inner.rpc(grant).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn revoke_grant(
|
||||||
|
&self,
|
||||||
|
revocation: RevokeGrant,
|
||||||
|
) -> Result<RevocationResponse, irpc::Error> {
|
||||||
|
self.inner.rpc(revocation).await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub(crate) struct RequestChallenge;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub(crate) struct ChallengeResponse {
|
||||||
|
pub(crate) challenge: Challenge,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hand a peer the capability to reach this device.
|
||||||
|
///
|
||||||
|
/// Carries the secret itself, which is safe only because the iroh connection is
|
||||||
|
/// already authenticated and encrypted to the recipient's endpoint key. The
|
||||||
|
/// recipient still has to consent before it is stored.
|
||||||
|
#[derive(Clone, Serialize, Deserialize)]
|
||||||
|
pub(crate) struct DeliverGrant {
|
||||||
|
pub(crate) grant_id: GrantId,
|
||||||
|
/// Hex-encoded grant secret.
|
||||||
|
pub(crate) secret: String,
|
||||||
|
pub(crate) expires_at: Option<i64>,
|
||||||
|
/// Untrusted display data, shown only after the user consents.
|
||||||
|
pub(crate) display_name: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// The secret must not reach a log line through a derived Debug.
|
||||||
|
impl fmt::Debug for DeliverGrant {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
f.debug_struct("DeliverGrant")
|
||||||
|
.field("grant_id", &self.grant_id)
|
||||||
|
.field("display_name", &self.display_name)
|
||||||
|
.finish_non_exhaustive()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
pub(crate) enum GrantDeliveryResponse {
|
||||||
|
/// Held pending the local user's decision. Not yet a contact.
|
||||||
|
AwaitingConsent,
|
||||||
|
/// Stored: the local user had already agreed to remember this device.
|
||||||
|
Stored,
|
||||||
|
Rejected {
|
||||||
|
reason: String,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tell a peer that a grant it holds is dead, so its entry disappears promptly
|
||||||
|
/// rather than at its next attempt. Best effort: revocation is already complete
|
||||||
|
/// on the issuing side before this is sent.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub(crate) struct RevokeGrant {
|
||||||
|
pub(crate) grant_id: GrantId,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
pub(crate) enum RevocationResponse {
|
||||||
|
Removed,
|
||||||
|
/// No such grant held from this peer. Also returned when the grant belongs
|
||||||
|
/// to someone else, so a stranger cannot probe for grant ids.
|
||||||
|
Unknown,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hand a paired device a ticket for content it may fetch.
|
||||||
|
///
|
||||||
|
/// The ticket is a capability, so this is sent only over a connection where the
|
||||||
|
/// grant proof has already been presented.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub(crate) struct SubmitOffer {
|
||||||
|
pub(crate) proof: GrantProof,
|
||||||
|
pub(crate) ticket: String,
|
||||||
|
pub(crate) transfer_name: String,
|
||||||
|
pub(crate) sender_display_name: Option<String>,
|
||||||
|
pub(crate) file_count: u64,
|
||||||
|
pub(crate) total_bytes: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
pub(crate) enum OfferResponse {
|
||||||
|
/// The receiving user agreed. They fetch the content themselves next.
|
||||||
|
Accepted,
|
||||||
|
/// The receiving user said no, or never answered.
|
||||||
|
Declined { reason: String },
|
||||||
|
/// The grant did not validate. Names the reason so a peer holding a dead
|
||||||
|
/// grant can clear it.
|
||||||
|
Refused { reason: String },
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ask a device whether it is holding anything for this one.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub(crate) struct PollOffers;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub(crate) struct PolledOffers {
|
||||||
|
pub(crate) offers: Vec<PolledOffer>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An offer collected by polling rather than pushed. Carries the ticket because
|
||||||
|
/// the sender already decided to send it to this endpoint; the local user still
|
||||||
|
/// confirms before anything is fetched.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub(crate) struct PolledOffer {
|
||||||
|
pub(crate) ticket: String,
|
||||||
|
pub(crate) transfer_name: String,
|
||||||
|
pub(crate) sender_display_name: Option<String>,
|
||||||
|
pub(crate) file_count: u64,
|
||||||
|
pub(crate) total_bytes: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[rpc_requests(message = OfferMessage)]
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
enum OfferProtocol {
|
||||||
|
#[rpc(tx=oneshot::Sender<ChallengeResponse>)]
|
||||||
|
RequestChallenge(RequestChallenge),
|
||||||
|
#[rpc(tx=oneshot::Sender<GrantDeliveryResponse>)]
|
||||||
|
DeliverGrant(DeliverGrant),
|
||||||
|
#[rpc(tx=oneshot::Sender<RevocationResponse>)]
|
||||||
|
RevokeGrant(RevokeGrant),
|
||||||
|
#[rpc(tx=oneshot::Sender<OfferResponse>)]
|
||||||
|
SubmitOffer(SubmitOffer),
|
||||||
|
#[rpc(tx=oneshot::Sender<PolledOffers>)]
|
||||||
|
PollOffers(PollOffers),
|
||||||
|
}
|
||||||
277
crates/vnidrop/src/offer_inbox.rs
Normal file
@@ -0,0 +1,277 @@
|
|||||||
|
//! Incoming transfer offers from paired devices.
|
||||||
|
//!
|
||||||
|
//! An offer is only a delivery mechanism for a ticket: it replaces the QR code,
|
||||||
|
//! not the transfer. Accepting hands the ticket to the platform layer, which
|
||||||
|
//! runs the ordinary receive with its own destination rules.
|
||||||
|
//!
|
||||||
|
//! Nothing in this inbox is persisted: a prompt belongs to a live connection,
|
||||||
|
//! so a restart correctly loses it rather than resurrecting one whose sender is
|
||||||
|
//! long gone. Offers the *sender* could not deliver are a different thing and
|
||||||
|
//! do persist — see `held_offers` in [`crate::contacts`].
|
||||||
|
|
||||||
|
use std::{collections::HashMap, sync::Arc, time::Duration};
|
||||||
|
|
||||||
|
use serde_json::json;
|
||||||
|
use tokio::sync::{oneshot, Mutex};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::{event_hub::EventHub, offer::OfferResponse, util::now_ms};
|
||||||
|
|
||||||
|
/// How long the sender waits for the receiving user to decide.
|
||||||
|
const OFFER_WAIT_TIMEOUT: Duration = Duration::from_secs(120);
|
||||||
|
|
||||||
|
/// Quiet period after a decline, so a paired device cannot re-prompt on a loop.
|
||||||
|
/// A contact is not a stranger, but it is not unlimited either.
|
||||||
|
const DECLINE_COOLDOWN_MS: i64 = 60 * 1_000;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub(crate) struct PendingOffer {
|
||||||
|
pub(crate) offer_id: String,
|
||||||
|
pub(crate) from_endpoint_id: String,
|
||||||
|
pub(crate) sender_display_name: Option<String>,
|
||||||
|
pub(crate) transfer_name: String,
|
||||||
|
pub(crate) file_count: u64,
|
||||||
|
pub(crate) total_bytes: u64,
|
||||||
|
pub(crate) received_at: i64,
|
||||||
|
/// Released to the caller only once the local user accepts.
|
||||||
|
ticket: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Waiter {
|
||||||
|
endpoint_id: String,
|
||||||
|
responder: oneshot::Sender<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub(crate) struct OfferInbox {
|
||||||
|
event_hub: Arc<EventHub>,
|
||||||
|
pending: Arc<Mutex<HashMap<String, PendingOffer>>>,
|
||||||
|
waiters: Arc<Mutex<HashMap<String, Waiter>>>,
|
||||||
|
/// Endpoint → time before which new offers are refused.
|
||||||
|
cooldowns: Arc<Mutex<HashMap<String, i64>>>,
|
||||||
|
max_pending: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl OfferInbox {
|
||||||
|
pub(crate) fn new(event_hub: Arc<EventHub>, max_pending: usize) -> Self {
|
||||||
|
Self {
|
||||||
|
event_hub,
|
||||||
|
pending: Arc::new(Mutex::new(HashMap::new())),
|
||||||
|
waiters: Arc::new(Mutex::new(HashMap::new())),
|
||||||
|
cooldowns: Arc::new(Mutex::new(HashMap::new())),
|
||||||
|
max_pending,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Surface an offer and block until the local user decides.
|
||||||
|
///
|
||||||
|
/// The caller has already proven a live grant, so this is a known device;
|
||||||
|
/// the limits here bound nuisance rather than attack.
|
||||||
|
pub(crate) async fn submit(
|
||||||
|
&self,
|
||||||
|
from_endpoint_id: String,
|
||||||
|
transfer_name: String,
|
||||||
|
sender_display_name: Option<String>,
|
||||||
|
file_count: u64,
|
||||||
|
total_bytes: u64,
|
||||||
|
ticket: String,
|
||||||
|
) -> OfferResponse {
|
||||||
|
let now = now_ms();
|
||||||
|
{
|
||||||
|
let mut cooldowns = self.cooldowns.lock().await;
|
||||||
|
cooldowns.retain(|_, until| *until > now);
|
||||||
|
if cooldowns.contains_key(&from_endpoint_id) {
|
||||||
|
return OfferResponse::Declined {
|
||||||
|
reason: "declined-recently".to_string(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let offer_id = Uuid::new_v4().to_string();
|
||||||
|
let (tx, rx) = oneshot::channel();
|
||||||
|
{
|
||||||
|
let mut pending = self.pending.lock().await;
|
||||||
|
if pending.len() >= self.max_pending {
|
||||||
|
return OfferResponse::Declined {
|
||||||
|
reason: "too-many-pending-offers".to_string(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// One prompt per device at a time: a second offer would stack
|
||||||
|
// notifications for the same sender.
|
||||||
|
if pending
|
||||||
|
.values()
|
||||||
|
.any(|offer| offer.from_endpoint_id == from_endpoint_id)
|
||||||
|
{
|
||||||
|
return OfferResponse::Declined {
|
||||||
|
reason: "offer-already-pending".to_string(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
pending.insert(
|
||||||
|
offer_id.clone(),
|
||||||
|
PendingOffer {
|
||||||
|
offer_id: offer_id.clone(),
|
||||||
|
from_endpoint_id: from_endpoint_id.clone(),
|
||||||
|
sender_display_name: sender_display_name.clone(),
|
||||||
|
transfer_name: transfer_name.clone(),
|
||||||
|
file_count,
|
||||||
|
total_bytes,
|
||||||
|
received_at: now,
|
||||||
|
ticket,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
self.waiters.lock().await.insert(
|
||||||
|
offer_id.clone(),
|
||||||
|
Waiter {
|
||||||
|
endpoint_id: from_endpoint_id.clone(),
|
||||||
|
responder: tx,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// The ticket is deliberately absent: an event is a log record, and a
|
||||||
|
// ticket is a capability.
|
||||||
|
self.event_hub.emit_endpoint(
|
||||||
|
"offer",
|
||||||
|
"offer-received",
|
||||||
|
json!({
|
||||||
|
"offer_id": offer_id,
|
||||||
|
"from_endpoint_id": from_endpoint_id,
|
||||||
|
"sender_display_name": sender_display_name,
|
||||||
|
"transfer_name": transfer_name,
|
||||||
|
"file_count": file_count,
|
||||||
|
"total_bytes": total_bytes,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
match tokio::time::timeout(OFFER_WAIT_TIMEOUT, rx).await {
|
||||||
|
Ok(Ok(true)) => OfferResponse::Accepted,
|
||||||
|
Ok(Ok(false)) => OfferResponse::Declined {
|
||||||
|
reason: "receiver-declined".to_string(),
|
||||||
|
},
|
||||||
|
// Dropped responder or timeout: clear the prompt so it cannot
|
||||||
|
// linger after the sender has given up.
|
||||||
|
Ok(Err(_)) | Err(_) => {
|
||||||
|
self.discard(&offer_id).await;
|
||||||
|
OfferResponse::Declined {
|
||||||
|
reason: "no-response".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add an offer collected by polling.
|
||||||
|
///
|
||||||
|
/// Unlike [`Self::submit`] there is no remote waiting on the answer: the
|
||||||
|
/// sender handed the ticket over and moved on, so this returns immediately.
|
||||||
|
pub(crate) async fn enqueue(
|
||||||
|
&self,
|
||||||
|
from_endpoint_id: String,
|
||||||
|
transfer_name: String,
|
||||||
|
sender_display_name: Option<String>,
|
||||||
|
file_count: u64,
|
||||||
|
total_bytes: u64,
|
||||||
|
ticket: String,
|
||||||
|
) -> bool {
|
||||||
|
let offer_id = uuid::Uuid::new_v4().to_string();
|
||||||
|
{
|
||||||
|
let mut pending = self.pending.lock().await;
|
||||||
|
if pending.len() >= self.max_pending {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if pending
|
||||||
|
.values()
|
||||||
|
.any(|offer| offer.from_endpoint_id == from_endpoint_id)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
pending.insert(
|
||||||
|
offer_id.clone(),
|
||||||
|
PendingOffer {
|
||||||
|
offer_id: offer_id.clone(),
|
||||||
|
from_endpoint_id: from_endpoint_id.clone(),
|
||||||
|
sender_display_name: sender_display_name.clone(),
|
||||||
|
transfer_name: transfer_name.clone(),
|
||||||
|
file_count,
|
||||||
|
total_bytes,
|
||||||
|
received_at: now_ms(),
|
||||||
|
ticket,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
self.event_hub.emit_endpoint(
|
||||||
|
"offer",
|
||||||
|
"offer-collected",
|
||||||
|
json!({
|
||||||
|
"offer_id": offer_id,
|
||||||
|
"from_endpoint_id": from_endpoint_id,
|
||||||
|
"sender_display_name": sender_display_name,
|
||||||
|
"transfer_name": transfer_name,
|
||||||
|
"file_count": file_count,
|
||||||
|
"total_bytes": total_bytes,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn list(&self) -> Vec<PendingOffer> {
|
||||||
|
self.pending.lock().await.values().cloned().collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Record the local user's decision.
|
||||||
|
///
|
||||||
|
/// Returns the ticket on acceptance: it leaves the core at the moment of
|
||||||
|
/// consent and not before, so a declined offer never hands over a
|
||||||
|
/// capability. The caller then runs the ordinary receive with it.
|
||||||
|
pub(crate) async fn respond(&self, offer_id: &str, accepted: bool) -> Option<String> {
|
||||||
|
let offer = self.pending.lock().await.remove(offer_id)?;
|
||||||
|
let waiter = self.waiters.lock().await.remove(offer_id);
|
||||||
|
|
||||||
|
if !accepted {
|
||||||
|
self.cooldowns.lock().await.insert(
|
||||||
|
offer.from_endpoint_id.clone(),
|
||||||
|
now_ms() + DECLINE_COOLDOWN_MS,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if let Some(waiter) = waiter {
|
||||||
|
let _ = waiter.responder.send(accepted);
|
||||||
|
}
|
||||||
|
self.event_hub.emit_endpoint(
|
||||||
|
"offer",
|
||||||
|
if accepted {
|
||||||
|
"offer-accepted"
|
||||||
|
} else {
|
||||||
|
"offer-declined"
|
||||||
|
},
|
||||||
|
json!({
|
||||||
|
"offer_id": offer_id,
|
||||||
|
"from_endpoint_id": offer.from_endpoint_id,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
accepted.then_some(offer.ticket)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drop every prompt from a device, used when it is forgotten or blocked
|
||||||
|
/// while an offer is on screen.
|
||||||
|
pub(crate) async fn discard_from(&self, endpoint_id: &str) {
|
||||||
|
let ids: Vec<String> = {
|
||||||
|
let pending = self.pending.lock().await;
|
||||||
|
pending
|
||||||
|
.values()
|
||||||
|
.filter(|offer| offer.from_endpoint_id == endpoint_id)
|
||||||
|
.map(|offer| offer.offer_id.clone())
|
||||||
|
.collect()
|
||||||
|
};
|
||||||
|
for offer_id in ids {
|
||||||
|
self.discard(&offer_id).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn discard(&self, offer_id: &str) {
|
||||||
|
self.pending.lock().await.remove(offer_id);
|
||||||
|
if let Some(waiter) = self.waiters.lock().await.remove(offer_id) {
|
||||||
|
let _ = waiter.responder.send(false);
|
||||||
|
let _ = waiter.endpoint_id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
386
crates/vnidrop/src/pairing.rs
Normal file
@@ -0,0 +1,386 @@
|
|||||||
|
//! Consent and grant exchange for device history.
|
||||||
|
//!
|
||||||
|
//! Mirrors [`crate::approval`]: the protocol handler stays thin and the
|
||||||
|
//! decisions live here. The rule this module exists to enforce is that a device
|
||||||
|
//! is remembered only if *both* sides agree — refusing to issue a grant leaves
|
||||||
|
//! the peer with a contact entry that cannot do anything.
|
||||||
|
|
||||||
|
use std::{collections::HashMap, sync::Arc, time::Duration};
|
||||||
|
|
||||||
|
use serde_json::json;
|
||||||
|
use tokio::sync::Mutex;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
contacts::ContactStore,
|
||||||
|
event_hub::EventHub,
|
||||||
|
grant::{
|
||||||
|
Challenge, GrantLifetime, GrantProof, GrantRejection, GrantSecret, HeldGrant, IssuedGrant,
|
||||||
|
},
|
||||||
|
offer::{DeliverGrant, GrantDeliveryResponse, PolledOffer, RevocationResponse, RevokeGrant},
|
||||||
|
util::now_ms,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// How long an incoming grant waits for the local user's decision.
|
||||||
|
///
|
||||||
|
/// Bounded so a peer cannot park entries in memory indefinitely, and short
|
||||||
|
/// enough that a stale prompt does not outlive the context the user remembers.
|
||||||
|
const CONSENT_WINDOW: Duration = Duration::from_secs(10 * 60);
|
||||||
|
|
||||||
|
/// A grant a peer has offered, waiting on the local user.
|
||||||
|
///
|
||||||
|
/// Not persisted: if the app restarts, the prompt is gone and the peer can
|
||||||
|
/// offer again. Persisting would resurrect prompts whose context the user has
|
||||||
|
/// long forgotten.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub(crate) struct PendingGrant {
|
||||||
|
pub(crate) peer_endpoint_id: String,
|
||||||
|
pub(crate) display_name: Option<String>,
|
||||||
|
pub(crate) received_at: i64,
|
||||||
|
grant: HeldGrant,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub(crate) struct PairingService {
|
||||||
|
contacts: ContactStore,
|
||||||
|
event_hub: Arc<EventHub>,
|
||||||
|
/// Keyed by peer endpoint id: one outstanding offer per peer, so a peer
|
||||||
|
/// cannot flood the prompt queue by reconnecting.
|
||||||
|
pending: Arc<Mutex<HashMap<String, PendingGrant>>>,
|
||||||
|
max_pending: usize,
|
||||||
|
max_metadata_bytes: u64,
|
||||||
|
lifetime: Arc<Mutex<GrantLifetime>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PairingService {
|
||||||
|
pub(crate) fn new(
|
||||||
|
contacts: ContactStore,
|
||||||
|
event_hub: Arc<EventHub>,
|
||||||
|
max_pending: usize,
|
||||||
|
max_metadata_bytes: u64,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
contacts,
|
||||||
|
event_hub,
|
||||||
|
pending: Arc::new(Mutex::new(HashMap::new())),
|
||||||
|
max_pending,
|
||||||
|
max_metadata_bytes,
|
||||||
|
lifetime: Arc::new(Mutex::new(GrantLifetime::default())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn set_grant_lifetime(&self, lifetime: GrantLifetime) {
|
||||||
|
*self.lifetime.lock().await = lifetime;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn grant_lifetime(&self) -> GrantLifetime {
|
||||||
|
*self.lifetime.lock().await
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- inbound ----------------------------------------------------------
|
||||||
|
|
||||||
|
/// A peer offers this device the capability to reach it.
|
||||||
|
///
|
||||||
|
/// Never stored on arrival: an unsolicited grant would otherwise create a
|
||||||
|
/// contact the local user never agreed to. It waits for consent instead.
|
||||||
|
pub(crate) async fn receive_grant(
|
||||||
|
&self,
|
||||||
|
peer_endpoint_id: String,
|
||||||
|
delivery: DeliverGrant,
|
||||||
|
) -> GrantDeliveryResponse {
|
||||||
|
if self
|
||||||
|
.contacts
|
||||||
|
.is_blocked(&peer_endpoint_id)
|
||||||
|
.await
|
||||||
|
.unwrap_or(false)
|
||||||
|
{
|
||||||
|
// Indistinguishable from any other refusal: blocking must not be
|
||||||
|
// detectable by probing.
|
||||||
|
return GrantDeliveryResponse::Rejected {
|
||||||
|
reason: "not-accepted".to_string(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if delivery
|
||||||
|
.display_name
|
||||||
|
.as_deref()
|
||||||
|
.is_some_and(|name| name.len() as u64 > self.max_metadata_bytes)
|
||||||
|
{
|
||||||
|
return GrantDeliveryResponse::Rejected {
|
||||||
|
reason: "metadata-too-large".to_string(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
let secret = match GrantSecret::decode(&delivery.secret) {
|
||||||
|
Ok(secret) => secret,
|
||||||
|
Err(_) => {
|
||||||
|
return GrantDeliveryResponse::Rejected {
|
||||||
|
reason: "malformed-grant".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let now = now_ms();
|
||||||
|
let held = HeldGrant {
|
||||||
|
grant_id: delivery.grant_id,
|
||||||
|
secret,
|
||||||
|
peer_endpoint_id: peer_endpoint_id.clone(),
|
||||||
|
created_at: now,
|
||||||
|
expires_at: delivery.expires_at,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Already a contact: the user agreed to this relationship, so a refreshed
|
||||||
|
// grant (re-pairing, or a renewal after reinstall) replaces the old one
|
||||||
|
// without prompting again.
|
||||||
|
let already_known = self
|
||||||
|
.contacts
|
||||||
|
.find_contact(&peer_endpoint_id)
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
.flatten()
|
||||||
|
.is_some();
|
||||||
|
if already_known {
|
||||||
|
if self.contacts.insert_held_grant(&held).await.is_err() {
|
||||||
|
return GrantDeliveryResponse::Rejected {
|
||||||
|
reason: "storage-error".to_string(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
self.emit(
|
||||||
|
"grant-refreshed",
|
||||||
|
json!({ "peer_endpoint_id": peer_endpoint_id }),
|
||||||
|
);
|
||||||
|
return GrantDeliveryResponse::Stored;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut pending = self.pending.lock().await;
|
||||||
|
self.drop_expired(&mut pending, now);
|
||||||
|
if !pending.contains_key(&peer_endpoint_id) && pending.len() >= self.max_pending {
|
||||||
|
drop(pending);
|
||||||
|
return GrantDeliveryResponse::Rejected {
|
||||||
|
reason: "too-many-pending".to_string(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
pending.insert(
|
||||||
|
peer_endpoint_id.clone(),
|
||||||
|
PendingGrant {
|
||||||
|
peer_endpoint_id: peer_endpoint_id.clone(),
|
||||||
|
display_name: delivery.display_name.clone(),
|
||||||
|
received_at: now,
|
||||||
|
grant: held,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
drop(pending);
|
||||||
|
|
||||||
|
self.emit(
|
||||||
|
"pairing-requested",
|
||||||
|
json!({
|
||||||
|
"peer_endpoint_id": peer_endpoint_id,
|
||||||
|
"display_name": delivery.display_name,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
GrantDeliveryResponse::AwaitingConsent
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A peer reports that a grant this device holds is dead.
|
||||||
|
///
|
||||||
|
/// Only the issuer may retire its own grant, so the held record must name
|
||||||
|
/// this peer. A mismatch answers `Unknown` rather than an error, so a
|
||||||
|
/// stranger cannot probe for grant ids belonging to someone else.
|
||||||
|
pub(crate) async fn receive_revocation(
|
||||||
|
&self,
|
||||||
|
peer_endpoint_id: String,
|
||||||
|
revocation: RevokeGrant,
|
||||||
|
) -> RevocationResponse {
|
||||||
|
let held = self
|
||||||
|
.contacts
|
||||||
|
.held_grant_for(&peer_endpoint_id)
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
.flatten();
|
||||||
|
let Some(held) = held else {
|
||||||
|
return RevocationResponse::Unknown;
|
||||||
|
};
|
||||||
|
if held.grant_id != revocation.grant_id {
|
||||||
|
return RevocationResponse::Unknown;
|
||||||
|
}
|
||||||
|
if self
|
||||||
|
.contacts
|
||||||
|
.delete_held_grant(revocation.grant_id)
|
||||||
|
.await
|
||||||
|
.is_err()
|
||||||
|
{
|
||||||
|
return RevocationResponse::Unknown;
|
||||||
|
}
|
||||||
|
self.emit(
|
||||||
|
"contact-revoked-by-peer",
|
||||||
|
json!({ "peer_endpoint_id": peer_endpoint_id }),
|
||||||
|
);
|
||||||
|
RevocationResponse::Removed
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- local decisions --------------------------------------------------
|
||||||
|
|
||||||
|
pub(crate) async fn list_pending_grants(&self) -> Vec<PendingGrant> {
|
||||||
|
let mut pending = self.pending.lock().await;
|
||||||
|
self.drop_expired(&mut pending, now_ms());
|
||||||
|
pending.values().cloned().collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Accept a peer's offer to be remembered.
|
||||||
|
///
|
||||||
|
/// Stores their grant and records the contact. Issuing our own grant in
|
||||||
|
/// return is a separate decision the caller makes, because "I want to reach
|
||||||
|
/// them" and "they may reach me" are independent.
|
||||||
|
pub(crate) async fn accept_pending_grant(
|
||||||
|
&self,
|
||||||
|
peer_endpoint_id: &str,
|
||||||
|
) -> anyhow::Result<bool> {
|
||||||
|
let pending = {
|
||||||
|
let mut pending = self.pending.lock().await;
|
||||||
|
self.drop_expired(&mut pending, now_ms());
|
||||||
|
pending.remove(peer_endpoint_id)
|
||||||
|
};
|
||||||
|
let Some(pending) = pending else {
|
||||||
|
return Ok(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
self.contacts
|
||||||
|
.upsert_contact(
|
||||||
|
peer_endpoint_id,
|
||||||
|
pending.display_name.as_deref(),
|
||||||
|
pending.received_at,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
self.contacts.insert_held_grant(&pending.grant).await?;
|
||||||
|
self.emit(
|
||||||
|
"contact-added",
|
||||||
|
json!({ "peer_endpoint_id": peer_endpoint_id }),
|
||||||
|
);
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decline to be reachable through this peer's grant. The grant is dropped
|
||||||
|
/// unstored, so nothing about the peer is retained.
|
||||||
|
pub(crate) async fn decline_pending_grant(&self, peer_endpoint_id: &str) -> bool {
|
||||||
|
let removed = {
|
||||||
|
let mut pending = self.pending.lock().await;
|
||||||
|
pending.remove(peer_endpoint_id).is_some()
|
||||||
|
};
|
||||||
|
if removed {
|
||||||
|
self.emit(
|
||||||
|
"pairing-declined",
|
||||||
|
json!({ "peer_endpoint_id": peer_endpoint_id }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
removed
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mint a grant for a peer: our consent to be reached by them.
|
||||||
|
///
|
||||||
|
/// The caller delivers it over the offer protocol. Persisted before
|
||||||
|
/// delivery so a grant we may already have handed over is never forgotten.
|
||||||
|
pub(crate) async fn issue_grant(&self, peer_endpoint_id: &str) -> anyhow::Result<IssuedGrant> {
|
||||||
|
let lifetime = self.grant_lifetime().await;
|
||||||
|
let grant = IssuedGrant::mint(peer_endpoint_id.to_string(), now_ms(), lifetime);
|
||||||
|
self.contacts.insert_issued_grant(&grant).await?;
|
||||||
|
self.contacts
|
||||||
|
.upsert_contact(peer_endpoint_id, None, now_ms())
|
||||||
|
.await?;
|
||||||
|
self.emit(
|
||||||
|
"grant-issued",
|
||||||
|
json!({ "peer_endpoint_id": peer_endpoint_id }),
|
||||||
|
);
|
||||||
|
Ok(grant)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Held offers addressed to `endpoint_id`, consumed as they are handed over.
|
||||||
|
///
|
||||||
|
/// Deleting on delivery is what keeps a device that polls twice from being
|
||||||
|
/// offered the same transfer again.
|
||||||
|
pub(crate) async fn collect_held_offers(&self, endpoint_id: &str) -> Vec<PolledOffer> {
|
||||||
|
if self.contacts.is_blocked(endpoint_id).await.unwrap_or(false) {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
let Ok(held) = self.contacts.held_offers_for(endpoint_id).await else {
|
||||||
|
return Vec::new();
|
||||||
|
};
|
||||||
|
if held.is_empty() {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
let ids: Vec<String> = held.iter().map(|offer| offer.offer_id.clone()).collect();
|
||||||
|
if let Err(error) = self.contacts.delete_held_offers(&ids).await {
|
||||||
|
// Handing the same offer over twice is worse than not handing it
|
||||||
|
// over at all, so a failed consume aborts the delivery.
|
||||||
|
tracing::warn!(%error, "failed to consume held offers");
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
self.emit(
|
||||||
|
"held-offers-collected",
|
||||||
|
json!({ "peer_endpoint_id": endpoint_id, "count": held.len() }),
|
||||||
|
);
|
||||||
|
held.into_iter()
|
||||||
|
.map(|offer| PolledOffer {
|
||||||
|
ticket: offer.ticket,
|
||||||
|
transfer_name: offer.transfer_name,
|
||||||
|
sender_display_name: offer.sender_display_name,
|
||||||
|
file_count: offer.file_count,
|
||||||
|
total_bytes: offer.total_bytes,
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validate a proof a peer presented, and push the idle deadline forward.
|
||||||
|
///
|
||||||
|
/// The grant record is ours: we issued it, so we are the only party that
|
||||||
|
/// can decide it is still alive. A blocked endpoint is answered `Unknown`,
|
||||||
|
/// the same as one we never issued to.
|
||||||
|
pub(crate) async fn verify_and_renew(
|
||||||
|
&self,
|
||||||
|
proof: &GrantProof,
|
||||||
|
challenge: &Challenge,
|
||||||
|
issuer_endpoint_id: &str,
|
||||||
|
remote_endpoint_id: &str,
|
||||||
|
) -> Result<(), GrantRejection> {
|
||||||
|
if self
|
||||||
|
.contacts
|
||||||
|
.is_blocked(remote_endpoint_id)
|
||||||
|
.await
|
||||||
|
.unwrap_or(false)
|
||||||
|
{
|
||||||
|
return Err(GrantRejection::Unknown);
|
||||||
|
}
|
||||||
|
let grant = self
|
||||||
|
.contacts
|
||||||
|
.find_issued_grant(proof.grant_id)
|
||||||
|
.await
|
||||||
|
.map_err(|_| GrantRejection::Unknown)?
|
||||||
|
.ok_or(GrantRejection::Unknown)?;
|
||||||
|
|
||||||
|
let now = now_ms();
|
||||||
|
let lifetime = self.grant_lifetime().await;
|
||||||
|
let renewed = grant.accept(
|
||||||
|
proof,
|
||||||
|
challenge,
|
||||||
|
issuer_endpoint_id,
|
||||||
|
remote_endpoint_id,
|
||||||
|
now,
|
||||||
|
lifetime,
|
||||||
|
)?;
|
||||||
|
// A failed renewal is not grounds to refuse a peer that just proved
|
||||||
|
// possession; the grant stays valid until its existing deadline.
|
||||||
|
if let Err(error) = self
|
||||||
|
.contacts
|
||||||
|
.renew_issued_grant(proof.grant_id, renewed)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
tracing::warn!(%error, "failed to renew grant deadline");
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn drop_expired(&self, pending: &mut HashMap<String, PendingGrant>, now_ms: i64) {
|
||||||
|
let window = CONSENT_WINDOW.as_millis() as i64;
|
||||||
|
pending.retain(|_, entry| now_ms - entry.received_at < window);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn emit(&self, kind: &str, data: serde_json::Value) {
|
||||||
|
self.event_hub.emit_endpoint("contacts", kind, data);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,11 +16,12 @@ use uuid::Uuid;
|
|||||||
use crate::{
|
use crate::{
|
||||||
access_policy::mode_from_storage,
|
access_policy::mode_from_storage,
|
||||||
api::{CoreEvent, ReceivedArtifact, ReceivedLocatorKind, ReceiverRequest, StoredTransfer},
|
api::{CoreEvent, ReceivedArtifact, ReceivedLocatorKind, ReceiverRequest, StoredTransfer},
|
||||||
|
contacts::ContactStore,
|
||||||
transfer_state::{ReceiverRequestStatus, TransferDirection, TransferStatus},
|
transfer_state::{ReceiverRequestStatus, TransferDirection, TransferStatus},
|
||||||
util::now_ms,
|
util::now_ms,
|
||||||
};
|
};
|
||||||
|
|
||||||
const SCHEMA_VERSION: i64 = 7;
|
const SCHEMA_VERSION: i64 = 10;
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub(crate) struct Repository {
|
pub(crate) struct Repository {
|
||||||
@@ -315,12 +316,29 @@ impl Repository {
|
|||||||
.await?;
|
.await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
crate::contacts::ensure_schema(&self.pool).await?;
|
||||||
|
crate::secure_secret::ensure_schema(&self.pool).await?;
|
||||||
|
|
||||||
sqlx::query(&format!("PRAGMA user_version = {SCHEMA_VERSION}"))
|
sqlx::query(&format!("PRAGMA user_version = {SCHEMA_VERSION}"))
|
||||||
.execute(&self.pool)
|
.execute(&self.pool)
|
||||||
.await?;
|
.await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Device history, grants, and the block list. Shares this pool so the
|
||||||
|
/// tables migrate together with the rest of the schema.
|
||||||
|
pub(crate) fn contacts(&self) -> ContactStore {
|
||||||
|
ContactStore::new(self.pool.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(
|
||||||
|
dead_code,
|
||||||
|
reason = "the private custody seam is activated by platform credential adapters"
|
||||||
|
)]
|
||||||
|
pub(crate) fn protected_secrets(&self) -> crate::secure_secret::SecretMetadataStore {
|
||||||
|
crate::secure_secret::SecretMetadataStore::new(self.pool.clone())
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
pub(crate) async fn schema_version(&self) -> Result<i64> {
|
pub(crate) async fn schema_version(&self) -> Result<i64> {
|
||||||
let row = sqlx::query("PRAGMA user_version")
|
let row = sqlx::query("PRAGMA user_version")
|
||||||
|
|||||||
695
crates/vnidrop/src/runtime/contacts.rs
Normal file
@@ -0,0 +1,695 @@
|
|||||||
|
//! Runtime operations for device history: pairing, forgetting, and blocking.
|
||||||
|
//!
|
||||||
|
//! The protocol side lives in [`crate::offer`] and the decisions in
|
||||||
|
//! [`crate::pairing`]; this is where those meet the endpoint and the UniFFI
|
||||||
|
//! surface.
|
||||||
|
|
||||||
|
use std::{sync::Arc, time::Duration};
|
||||||
|
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use iroh::{EndpointAddr, EndpointId};
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
use super::{CoreInner, POLL_MIN_INTERVAL_MS};
|
||||||
|
use crate::{
|
||||||
|
api::{
|
||||||
|
ContactSendResult, ContactSummary, GrantLifetimeSetting, HeldOfferSummary, IncomingOffer,
|
||||||
|
PendingPairing, ShareMetadataInput, ShareResult, ShareSource, TransferAccessMode,
|
||||||
|
},
|
||||||
|
contacts::HeldOffer,
|
||||||
|
error::VnidropError,
|
||||||
|
grant::{GrantId, HeldGrant},
|
||||||
|
offer::{
|
||||||
|
DeliverGrant, GrantDeliveryResponse, OfferResponse, OfferService, RevokeGrant, SubmitOffer,
|
||||||
|
},
|
||||||
|
ticket::{encode_persisted_sender_address, parse_persisted_sender_address},
|
||||||
|
transfer_state::{TransferDirection, TransferStatus},
|
||||||
|
util::now_ms,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// How long to wait for a device to answer before treating it as not running.
|
||||||
|
///
|
||||||
|
/// Without this an offline peer never fails, it just keeps being retried, and
|
||||||
|
/// the offer is never handed to the hold-for-later path.
|
||||||
|
const OFFER_CONNECT_TIMEOUT: Duration = Duration::from_secs(15);
|
||||||
|
|
||||||
|
/// Whether a device may be polled again yet.
|
||||||
|
///
|
||||||
|
/// Split out because the surrounding call needs two live nodes to exercise,
|
||||||
|
/// while the window itself is worth asserting on its own.
|
||||||
|
pub(crate) fn should_poll(last_polled_ms: Option<i64>, now_ms: i64) -> bool {
|
||||||
|
last_polled_ms.is_none_or(|last| now_ms - last >= POLL_MIN_INTERVAL_MS)
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CoreInner {
|
||||||
|
pub(super) async fn list_contacts(&self) -> Result<Vec<ContactSummary>> {
|
||||||
|
let contacts = self
|
||||||
|
.repository
|
||||||
|
.contacts()
|
||||||
|
.list_contacts()
|
||||||
|
.await
|
||||||
|
.map_err(VnidropError::repository)?;
|
||||||
|
let store = self.repository.contacts();
|
||||||
|
let mut summaries = Vec::with_capacity(contacts.len());
|
||||||
|
for contact in contacts {
|
||||||
|
// "Can I reach them" is exactly "do I hold a live grant", so the two
|
||||||
|
// never drift apart in the UI.
|
||||||
|
let can_send = store
|
||||||
|
.held_grant_for(&contact.endpoint_id)
|
||||||
|
.await
|
||||||
|
.map_err(VnidropError::repository)?
|
||||||
|
.is_some();
|
||||||
|
summaries.push(ContactSummary {
|
||||||
|
endpoint_id: contact.endpoint_id,
|
||||||
|
local_label: contact.local_label,
|
||||||
|
remote_display_name: contact.remote_display_name,
|
||||||
|
last_transfer_at: contact.last_transfer_at,
|
||||||
|
created_at: contact.created_at,
|
||||||
|
can_send,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(summaries)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) async fn list_pending_pairings(&self) -> Vec<PendingPairing> {
|
||||||
|
self.pairing
|
||||||
|
.list_pending_grants()
|
||||||
|
.await
|
||||||
|
.into_iter()
|
||||||
|
.map(|pending| PendingPairing {
|
||||||
|
endpoint_id: pending.peer_endpoint_id,
|
||||||
|
display_name: pending.display_name,
|
||||||
|
received_at: pending.received_at,
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Agree to be remembered by a peer, and hand them the capability to reach
|
||||||
|
/// us.
|
||||||
|
///
|
||||||
|
/// The grant is persisted before delivery: a grant that may already have
|
||||||
|
/// arrived must never be one we have forgotten issuing, or the peer would
|
||||||
|
/// hold a capability we cannot validate or revoke.
|
||||||
|
pub(super) async fn allow_device_to_reach_me(
|
||||||
|
self: &Arc<Self>,
|
||||||
|
endpoint_id: String,
|
||||||
|
display_name: Option<String>,
|
||||||
|
) -> Result<()> {
|
||||||
|
self.limits
|
||||||
|
.validate_metadata_text("display name", display_name.as_deref())
|
||||||
|
.map_err(VnidropError::invalid_input)?;
|
||||||
|
if self
|
||||||
|
.repository
|
||||||
|
.contacts()
|
||||||
|
.is_blocked(&endpoint_id)
|
||||||
|
.await
|
||||||
|
.map_err(VnidropError::repository)?
|
||||||
|
{
|
||||||
|
return Err(VnidropError::invalid_input(anyhow::anyhow!(
|
||||||
|
"endpoint is blocked; unblock it before pairing"
|
||||||
|
))
|
||||||
|
.into());
|
||||||
|
}
|
||||||
|
|
||||||
|
let grant = self
|
||||||
|
.pairing
|
||||||
|
.issue_grant(&endpoint_id)
|
||||||
|
.await
|
||||||
|
.map_err(VnidropError::repository)?;
|
||||||
|
|
||||||
|
let addr = self.contact_addr(&endpoint_id).await?;
|
||||||
|
let client = OfferService::client(self.endpoint.clone(), addr);
|
||||||
|
let response = client
|
||||||
|
.deliver_grant(DeliverGrant {
|
||||||
|
grant_id: grant.grant_id,
|
||||||
|
secret: grant.secret.encode(),
|
||||||
|
expires_at: grant.expires_at,
|
||||||
|
display_name,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.context("failed to deliver grant")
|
||||||
|
.map_err(VnidropError::transfer)?;
|
||||||
|
|
||||||
|
match response {
|
||||||
|
GrantDeliveryResponse::AwaitingConsent | GrantDeliveryResponse::Stored => {
|
||||||
|
self.remember_addr(&endpoint_id).await;
|
||||||
|
self.emit_endpoint(
|
||||||
|
"contacts",
|
||||||
|
"grant-delivered",
|
||||||
|
json!({ "peer_endpoint_id": endpoint_id }),
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
GrantDeliveryResponse::Rejected { reason } => {
|
||||||
|
// The peer would not take it, so the grant we just minted can
|
||||||
|
// never be used. Retire it rather than leaving a live
|
||||||
|
// capability nobody holds.
|
||||||
|
let _ = self
|
||||||
|
.repository
|
||||||
|
.contacts()
|
||||||
|
.revoke_issued_grant(grant.grant_id, now_ms())
|
||||||
|
.await;
|
||||||
|
Err(
|
||||||
|
VnidropError::transfer(anyhow::anyhow!("peer refused the pairing: {reason}"))
|
||||||
|
.into(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Share content and push the ticket straight to a paired device.
|
||||||
|
///
|
||||||
|
/// Two things make this one prompt rather than two: the share is created
|
||||||
|
/// with the ticket never leaving this device except over the authenticated
|
||||||
|
/// offer connection, and the target endpoint is pre-authorised so the
|
||||||
|
/// handshake it runs next does not ask us to approve a transfer we started.
|
||||||
|
pub(super) async fn send_to_contact(
|
||||||
|
self: &Arc<Self>,
|
||||||
|
endpoint_id: String,
|
||||||
|
sources: Vec<ShareSource>,
|
||||||
|
mut metadata: ShareMetadataInput,
|
||||||
|
) -> Result<ContactSendResult> {
|
||||||
|
let store = self.repository.contacts();
|
||||||
|
let grant = store
|
||||||
|
.held_grant_for(&endpoint_id)
|
||||||
|
.await
|
||||||
|
.map_err(VnidropError::repository)?
|
||||||
|
.ok_or_else(|| {
|
||||||
|
VnidropError::permission(anyhow::anyhow!(
|
||||||
|
"no live grant for this device; pair with it again"
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
// Invariant: an offer-created share is never public. The recipient is a
|
||||||
|
// specific device, so serving it to anyone holding the ticket would
|
||||||
|
// widen access beyond what the user asked for.
|
||||||
|
metadata.access_mode = TransferAccessMode::ApprovalRequired;
|
||||||
|
let sender_name = metadata.sender_name.clone();
|
||||||
|
let share = self.share_files(sources, metadata).await?;
|
||||||
|
self.offer_share(endpoint_id, grant, share, sender_name.as_deref())
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Offer a share that already exists, so a transfer created for an
|
||||||
|
/// invitation can also be pushed to a remembered device.
|
||||||
|
///
|
||||||
|
/// The ticket is the one already stored for the transfer: this adds another
|
||||||
|
/// way to deliver it, it does not create a second share of the same files.
|
||||||
|
pub(super) async fn offer_transfer_to_contact(
|
||||||
|
self: &Arc<Self>,
|
||||||
|
transfer_id: u64,
|
||||||
|
endpoint_id: String,
|
||||||
|
) -> Result<ContactSendResult> {
|
||||||
|
let grant = self
|
||||||
|
.repository
|
||||||
|
.contacts()
|
||||||
|
.held_grant_for(&endpoint_id)
|
||||||
|
.await
|
||||||
|
.map_err(VnidropError::repository)?
|
||||||
|
.ok_or_else(|| {
|
||||||
|
VnidropError::permission(anyhow::anyhow!(
|
||||||
|
"no live grant for this device; pair with it again"
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let stored = self
|
||||||
|
.repository
|
||||||
|
.list_transfers()
|
||||||
|
.await
|
||||||
|
.map_err(VnidropError::repository)?
|
||||||
|
.into_iter()
|
||||||
|
.find(|transfer| transfer.transfer_id == transfer_id)
|
||||||
|
.ok_or_else(|| {
|
||||||
|
VnidropError::invalid_input(anyhow::anyhow!("unknown transfer {transfer_id}"))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
// Only a live share can be offered: a stopped one no longer serves its
|
||||||
|
// content, so handing out its ticket would promise nothing.
|
||||||
|
if stored.direction != TransferDirection::Send.as_str()
|
||||||
|
|| stored.status != TransferStatus::Sharing.as_str()
|
||||||
|
{
|
||||||
|
return Err(VnidropError::invalid_input(anyhow::anyhow!(
|
||||||
|
"transfer {transfer_id} is not an active share"
|
||||||
|
))
|
||||||
|
.into());
|
||||||
|
}
|
||||||
|
let ticket = stored.ticket.clone().ok_or_else(|| {
|
||||||
|
VnidropError::invalid_input(anyhow::anyhow!("transfer {transfer_id} has no invitation"))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let share = ShareResult {
|
||||||
|
transfer_id,
|
||||||
|
ticket,
|
||||||
|
hash: stored.content_hash.unwrap_or_default(),
|
||||||
|
transfer_name: stored.transfer_name.unwrap_or_default(),
|
||||||
|
file_count: stored.file_count,
|
||||||
|
total_size: stored.total_size,
|
||||||
|
};
|
||||||
|
self.offer_share(endpoint_id, grant, share, None).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deliver an offer for `share`, holding it when the device is not running.
|
||||||
|
async fn offer_share(
|
||||||
|
self: &Arc<Self>,
|
||||||
|
endpoint_id: String,
|
||||||
|
grant: HeldGrant,
|
||||||
|
share: ShareResult,
|
||||||
|
sender_name: Option<&str>,
|
||||||
|
) -> Result<ContactSendResult> {
|
||||||
|
let store = self.repository.contacts();
|
||||||
|
|
||||||
|
// An unreachable device is the common case on mobile, not an error: the
|
||||||
|
// share stays here and the ticket waits for the peer to come and get it.
|
||||||
|
let outcome = match self
|
||||||
|
.deliver_offer(&endpoint_id, &grant, &share, sender_name)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(outcome) => outcome,
|
||||||
|
Err(error) => {
|
||||||
|
self.hold_offer(&endpoint_id, &share, sender_name).await?;
|
||||||
|
tracing::debug!(%error, "offer held for later pickup");
|
||||||
|
return Ok(ContactSendResult {
|
||||||
|
share,
|
||||||
|
delivered: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
match outcome {
|
||||||
|
OfferResponse::Accepted => {
|
||||||
|
store
|
||||||
|
.touch_transfer(&endpoint_id, now_ms())
|
||||||
|
.await
|
||||||
|
.map_err(VnidropError::repository)?;
|
||||||
|
self.remember_addr(&endpoint_id).await;
|
||||||
|
self.emit_transfer(
|
||||||
|
share.transfer_id,
|
||||||
|
"send",
|
||||||
|
"offer",
|
||||||
|
"offer-accepted",
|
||||||
|
json!({ "peer_endpoint_id": endpoint_id }),
|
||||||
|
);
|
||||||
|
Ok(ContactSendResult {
|
||||||
|
share,
|
||||||
|
delivered: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
OfferResponse::Declined { reason } | OfferResponse::Refused { reason } => {
|
||||||
|
let _ = self.cancel_idle_or_share(share.transfer_id).await;
|
||||||
|
self.emit_transfer(
|
||||||
|
share.transfer_id,
|
||||||
|
"send",
|
||||||
|
"offer",
|
||||||
|
"offer-refused",
|
||||||
|
json!({ "peer_endpoint_id": endpoint_id, "reason": reason }),
|
||||||
|
);
|
||||||
|
// A refusal naming a dead grant is the peer telling us to stop
|
||||||
|
// believing we can reach them.
|
||||||
|
if matches!(reason.as_str(), "revoked" | "unknown" | "expired") {
|
||||||
|
let _ = store.delete_held_grant(grant.grant_id).await;
|
||||||
|
}
|
||||||
|
Err(VnidropError::permission(anyhow::anyhow!(
|
||||||
|
"device did not accept the transfer: {reason}"
|
||||||
|
))
|
||||||
|
.into())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Keep an undeliverable offer on this device.
|
||||||
|
///
|
||||||
|
/// The target is pre-authorised now rather than at pickup: it will dial
|
||||||
|
/// straight back after collecting the ticket, and the session outlives the
|
||||||
|
/// round trip.
|
||||||
|
async fn hold_offer(
|
||||||
|
self: &Arc<Self>,
|
||||||
|
endpoint_id: &str,
|
||||||
|
share: &ShareResult,
|
||||||
|
sender_name: Option<&str>,
|
||||||
|
) -> Result<()> {
|
||||||
|
self.access_policy
|
||||||
|
.approve_endpoint(share.transfer_id, endpoint_id.to_string())
|
||||||
|
.await;
|
||||||
|
self.repository
|
||||||
|
.contacts()
|
||||||
|
.insert_held_offer(&HeldOffer {
|
||||||
|
offer_id: uuid::Uuid::new_v4().to_string(),
|
||||||
|
endpoint_id: endpoint_id.to_string(),
|
||||||
|
transfer_id: share.transfer_id,
|
||||||
|
ticket: share.ticket.clone(),
|
||||||
|
transfer_name: share.transfer_name.clone(),
|
||||||
|
sender_display_name: sender_name.map(ToOwned::to_owned),
|
||||||
|
file_count: share.file_count,
|
||||||
|
total_bytes: share.total_size,
|
||||||
|
created_at: now_ms(),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(VnidropError::repository)?;
|
||||||
|
self.emit_transfer(
|
||||||
|
share.transfer_id,
|
||||||
|
"send",
|
||||||
|
"offer",
|
||||||
|
"offer-held",
|
||||||
|
json!({ "peer_endpoint_id": endpoint_id }),
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ask remembered devices whether they are holding anything for this one.
|
||||||
|
///
|
||||||
|
/// Deliberately only ever called from a foreground transition or an explicit
|
||||||
|
/// user action: polling reveals to every contact that the app was opened,
|
||||||
|
/// which is why it is neither automatic nor backgrounded.
|
||||||
|
pub(super) async fn poll_contacts_for_offers(self: &Arc<Self>) -> Result<u64> {
|
||||||
|
let store = self.repository.contacts();
|
||||||
|
let contacts = store
|
||||||
|
.list_contacts()
|
||||||
|
.await
|
||||||
|
.map_err(VnidropError::repository)?;
|
||||||
|
let now = now_ms();
|
||||||
|
let mut collected = 0u64;
|
||||||
|
|
||||||
|
for contact in contacts {
|
||||||
|
if store
|
||||||
|
.is_blocked(&contact.endpoint_id)
|
||||||
|
.await
|
||||||
|
.unwrap_or(false)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
{
|
||||||
|
// Rate limited per device so repeated app switching does not
|
||||||
|
// turn into a presence beacon.
|
||||||
|
let mut polled = self.last_polled.lock().await;
|
||||||
|
if !should_poll(polled.get(&contact.endpoint_id).copied(), now) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
polled.insert(contact.endpoint_id.clone(), now);
|
||||||
|
}
|
||||||
|
|
||||||
|
let Ok(addr) = self.contact_addr(&contact.endpoint_id).await else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let client = OfferService::client(self.endpoint.clone(), addr);
|
||||||
|
let Ok(polled) = client.poll_offers().await else {
|
||||||
|
// Offline is the expected outcome, not a failure worth surfacing.
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
for offer in polled.offers {
|
||||||
|
let added = self
|
||||||
|
.offers
|
||||||
|
.enqueue(
|
||||||
|
contact.endpoint_id.clone(),
|
||||||
|
offer.transfer_name,
|
||||||
|
offer.sender_display_name,
|
||||||
|
offer.file_count,
|
||||||
|
offer.total_bytes,
|
||||||
|
offer.ticket,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
if added {
|
||||||
|
collected += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.remember_addr(&contact.endpoint_id).await;
|
||||||
|
}
|
||||||
|
Ok(collected)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn deliver_offer(
|
||||||
|
self: &Arc<Self>,
|
||||||
|
endpoint_id: &str,
|
||||||
|
grant: &HeldGrant,
|
||||||
|
share: &ShareResult,
|
||||||
|
sender_name: Option<&str>,
|
||||||
|
) -> Result<OfferResponse> {
|
||||||
|
let addr = self.contact_addr(endpoint_id).await?;
|
||||||
|
let client = OfferService::client(self.endpoint.clone(), addr);
|
||||||
|
let challenge = tokio::time::timeout(OFFER_CONNECT_TIMEOUT, client.request_challenge())
|
||||||
|
.await
|
||||||
|
.map_err(|_| VnidropError::transfer(anyhow::anyhow!("device did not answer in time")))?
|
||||||
|
.context("device is not reachable")
|
||||||
|
.map_err(VnidropError::transfer)?;
|
||||||
|
|
||||||
|
// Authorise before offering: the receiver may dial back the instant it
|
||||||
|
// accepts, and an unauthorised endpoint would be refused by the
|
||||||
|
// provider.
|
||||||
|
self.access_policy
|
||||||
|
.approve_endpoint(share.transfer_id, endpoint_id.to_string())
|
||||||
|
.await;
|
||||||
|
|
||||||
|
client
|
||||||
|
.submit_offer(SubmitOffer {
|
||||||
|
proof: grant.prove(&challenge, &self.endpoint.id().to_string()),
|
||||||
|
ticket: share.ticket.clone(),
|
||||||
|
transfer_name: share.transfer_name.clone(),
|
||||||
|
sender_display_name: sender_name.map(ToOwned::to_owned),
|
||||||
|
file_count: share.file_count,
|
||||||
|
total_bytes: share.total_size,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.context("failed to deliver the offer")
|
||||||
|
.map_err(VnidropError::transfer)
|
||||||
|
.map_err(Into::into)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Transfers waiting for their target to come back online.
|
||||||
|
pub(super) async fn list_held_offers(&self) -> Result<Vec<HeldOfferSummary>> {
|
||||||
|
let held = self
|
||||||
|
.repository
|
||||||
|
.contacts()
|
||||||
|
.list_held_offers()
|
||||||
|
.await
|
||||||
|
.map_err(VnidropError::repository)?;
|
||||||
|
Ok(held
|
||||||
|
.into_iter()
|
||||||
|
.map(|offer| HeldOfferSummary {
|
||||||
|
offer_id: offer.offer_id,
|
||||||
|
endpoint_id: offer.endpoint_id,
|
||||||
|
transfer_id: offer.transfer_id,
|
||||||
|
transfer_name: offer.transfer_name,
|
||||||
|
file_count: offer.file_count,
|
||||||
|
total_bytes: offer.total_bytes,
|
||||||
|
created_at: offer.created_at,
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) async fn list_pending_offers(&self) -> Vec<IncomingOffer> {
|
||||||
|
self.offers
|
||||||
|
.list()
|
||||||
|
.await
|
||||||
|
.into_iter()
|
||||||
|
.map(|offer| IncomingOffer {
|
||||||
|
offer_id: offer.offer_id,
|
||||||
|
from_endpoint_id: offer.from_endpoint_id,
|
||||||
|
sender_display_name: offer.sender_display_name,
|
||||||
|
transfer_name: offer.transfer_name,
|
||||||
|
file_count: offer.file_count,
|
||||||
|
total_bytes: offer.total_bytes,
|
||||||
|
received_at: offer.received_at,
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Answer an incoming offer. Returns the ticket when accepted, so the
|
||||||
|
/// platform layer can run the ordinary receive with its own destination.
|
||||||
|
pub(super) async fn respond_to_offer(
|
||||||
|
&self,
|
||||||
|
offer_id: String,
|
||||||
|
accepted: bool,
|
||||||
|
) -> Option<String> {
|
||||||
|
self.offers.respond(&offer_id, accepted).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) async fn respond_to_pairing(
|
||||||
|
&self,
|
||||||
|
endpoint_id: String,
|
||||||
|
accepted: bool,
|
||||||
|
) -> Result<bool> {
|
||||||
|
if accepted {
|
||||||
|
self.pairing
|
||||||
|
.accept_pending_grant(&endpoint_id)
|
||||||
|
.await
|
||||||
|
.map_err(VnidropError::repository)
|
||||||
|
.map_err(Into::into)
|
||||||
|
} else {
|
||||||
|
Ok(self.pairing.decline_pending_grant(&endpoint_id).await)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stop a peer from reaching us and drop the relationship locally.
|
||||||
|
///
|
||||||
|
/// Revocation completes locally first: the notification is best effort and
|
||||||
|
/// the peer losing access must not depend on being online to hear about it.
|
||||||
|
pub(super) async fn forget_contact(self: &Arc<Self>, endpoint_id: String) -> Result<()> {
|
||||||
|
let store = self.repository.contacts();
|
||||||
|
let revoked = store
|
||||||
|
.delete_contact(&endpoint_id)
|
||||||
|
.await
|
||||||
|
.map_err(VnidropError::repository)?;
|
||||||
|
// A prompt on screen from a device we just forgot would be actionable
|
||||||
|
// with a grant that no longer exists.
|
||||||
|
self.offers.discard_from(&endpoint_id).await;
|
||||||
|
self.emit_endpoint(
|
||||||
|
"contacts",
|
||||||
|
"contact-forgotten",
|
||||||
|
json!({ "peer_endpoint_id": endpoint_id, "revoked": revoked.len() }),
|
||||||
|
);
|
||||||
|
self.notify_revoked(endpoint_id, revoked).await;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Forget every device at once, alongside the existing history-clearing
|
||||||
|
/// actions. Every peer loses access; each is notified best effort.
|
||||||
|
pub(super) async fn forget_all_contacts(self: &Arc<Self>) -> Result<u64> {
|
||||||
|
let store = self.repository.contacts();
|
||||||
|
let contacts = store
|
||||||
|
.list_contacts()
|
||||||
|
.await
|
||||||
|
.map_err(VnidropError::repository)?;
|
||||||
|
let revoked = store
|
||||||
|
.delete_all_contacts()
|
||||||
|
.await
|
||||||
|
.map_err(VnidropError::repository)?;
|
||||||
|
for contact in &contacts {
|
||||||
|
self.offers.discard_from(&contact.endpoint_id).await;
|
||||||
|
}
|
||||||
|
self.emit_endpoint(
|
||||||
|
"contacts",
|
||||||
|
"contacts-cleared",
|
||||||
|
json!({ "contacts": contacts.len(), "revoked": revoked.len() }),
|
||||||
|
);
|
||||||
|
for contact in contacts.iter() {
|
||||||
|
self.notify_revoked(contact.endpoint_id.clone(), revoked.clone())
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
Ok(revoked.len() as u64)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) async fn block_contact(self: &Arc<Self>, endpoint_id: String) -> Result<()> {
|
||||||
|
let store = self.repository.contacts();
|
||||||
|
let revoked = store
|
||||||
|
.revoke_issued_grants_for(&endpoint_id, now_ms())
|
||||||
|
.await
|
||||||
|
.map_err(VnidropError::repository)?;
|
||||||
|
store
|
||||||
|
.block_endpoint(&endpoint_id, now_ms())
|
||||||
|
.await
|
||||||
|
.map_err(VnidropError::repository)?;
|
||||||
|
store
|
||||||
|
.delete_contact(&endpoint_id)
|
||||||
|
.await
|
||||||
|
.map_err(VnidropError::repository)?;
|
||||||
|
self.offers.discard_from(&endpoint_id).await;
|
||||||
|
self.emit_endpoint(
|
||||||
|
"contacts",
|
||||||
|
"contact-blocked",
|
||||||
|
json!({ "peer_endpoint_id": endpoint_id }),
|
||||||
|
);
|
||||||
|
// A blocked peer is told nothing: silence here is what makes blocking
|
||||||
|
// undetectable, unlike ordinary revocation.
|
||||||
|
let _ = revoked;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) async fn unblock_contact(&self, endpoint_id: String) -> Result<()> {
|
||||||
|
self.repository
|
||||||
|
.contacts()
|
||||||
|
.unblock_endpoint(&endpoint_id)
|
||||||
|
.await
|
||||||
|
.map_err(VnidropError::repository)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) async fn list_blocked_contacts(&self) -> Result<Vec<String>> {
|
||||||
|
self.repository
|
||||||
|
.contacts()
|
||||||
|
.list_blocked()
|
||||||
|
.await
|
||||||
|
.map_err(VnidropError::repository)
|
||||||
|
.map_err(Into::into)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) async fn set_contact_label(
|
||||||
|
&self,
|
||||||
|
endpoint_id: String,
|
||||||
|
label: Option<String>,
|
||||||
|
) -> Result<()> {
|
||||||
|
self.limits
|
||||||
|
.validate_metadata_text("contact label", label.as_deref())
|
||||||
|
.map_err(VnidropError::invalid_input)?;
|
||||||
|
self.repository
|
||||||
|
.contacts()
|
||||||
|
.set_contact_label(&endpoint_id, label.as_deref())
|
||||||
|
.await
|
||||||
|
.map_err(VnidropError::repository)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) async fn set_grant_lifetime(&self, setting: GrantLifetimeSetting) {
|
||||||
|
self.pairing.set_grant_lifetime(setting.into()).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Best-effort "your entry is dead" notification, so the peer's list clears
|
||||||
|
/// promptly instead of at its next attempt.
|
||||||
|
async fn notify_revoked(self: &Arc<Self>, endpoint_id: String, revoked: Vec<GrantId>) {
|
||||||
|
if revoked.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let Ok(addr) = self.contact_addr(&endpoint_id).await else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let client = OfferService::client(self.endpoint.clone(), addr);
|
||||||
|
for grant_id in revoked {
|
||||||
|
if let Err(error) = client.revoke_grant(RevokeGrant { grant_id }).await {
|
||||||
|
tracing::debug!(%error, "revocation notice undeliverable; peer will learn on next attempt");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where to dial a contact.
|
||||||
|
///
|
||||||
|
/// Prefers the address cached from the last successful connection, which is
|
||||||
|
/// what keeps contacts usable in relay profiles that do not resolve
|
||||||
|
/// endpoint ids through public discovery.
|
||||||
|
async fn contact_addr(&self, endpoint_id: &str) -> Result<EndpointAddr> {
|
||||||
|
let cached = self
|
||||||
|
.repository
|
||||||
|
.contacts()
|
||||||
|
.find_contact(endpoint_id)
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
.flatten()
|
||||||
|
.and_then(|contact| contact.last_known_addr)
|
||||||
|
.and_then(|encoded| parse_persisted_sender_address(&encoded).ok());
|
||||||
|
if let Some(addr) = cached {
|
||||||
|
return Ok(addr);
|
||||||
|
}
|
||||||
|
let parsed: EndpointId = endpoint_id
|
||||||
|
.parse()
|
||||||
|
.context("contact has an unusable endpoint id")
|
||||||
|
.map_err(VnidropError::invalid_input)?;
|
||||||
|
Ok(EndpointAddr::from(parsed))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Refresh the cached address after a successful exchange.
|
||||||
|
async fn remember_addr(&self, endpoint_id: &str) {
|
||||||
|
let Ok(parsed) = endpoint_id.parse::<EndpointId>() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Some(info) = self.endpoint.remote_info(parsed).await else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let mut addr = EndpointAddr::from(parsed);
|
||||||
|
addr.addrs = info.addrs().map(|entry| entry.addr().clone()).collect();
|
||||||
|
if let Ok(encoded) = encode_persisted_sender_address(&addr) {
|
||||||
|
let _ = self
|
||||||
|
.repository
|
||||||
|
.contacts()
|
||||||
|
.set_last_known_addr(endpoint_id, &encoded)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,16 +3,18 @@ use std::{future::Future, path::PathBuf, sync::Arc};
|
|||||||
use anyhow::Context;
|
use anyhow::Context;
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
|
||||||
use super::CoreInner;
|
use super::{CoreInner, IdentityMode};
|
||||||
use crate::{
|
use crate::{
|
||||||
api::{
|
api::{
|
||||||
CoreEvent, CoreEventSink, CoreLimits, CoreNetworkConfig, CoreStorageUsage,
|
ContactSendResult, ContactSummary, CoreEvent, CoreEventSink, CoreLimits, CoreNetworkConfig,
|
||||||
|
CoreStorageUsage, GrantLifetimeSetting, HeldOfferSummary, IncomingOffer, PendingPairing,
|
||||||
ReceiveOutputSink, ReceiveOutputSinkV2, ReceivedArtifact, ReceiverRequest, RuntimeStatus,
|
ReceiveOutputSink, ReceiveOutputSinkV2, ReceivedArtifact, ReceiverRequest, RuntimeStatus,
|
||||||
ShareMetadataInput, ShareResult, ShareSource, StoredTransfer, TicketInspection,
|
ShareMetadataInput, ShareResult, ShareSource, StoredTransfer, TicketInspection,
|
||||||
TransferAccessMode,
|
TransferAccessMode,
|
||||||
},
|
},
|
||||||
error::VnidropError,
|
error::VnidropError,
|
||||||
filesystem::platform_path,
|
filesystem::platform_path,
|
||||||
|
secure_secret::{lock_profile, platform_secret_store},
|
||||||
ticket::parse_transfer_ticket_with_limits,
|
ticket::parse_transfer_ticket_with_limits,
|
||||||
transfer_state::{TransferDirection, TransferStatus},
|
transfer_state::{TransferDirection, TransferStatus},
|
||||||
};
|
};
|
||||||
@@ -34,6 +36,34 @@ impl VnidropCore {
|
|||||||
fn block_on<F: Future>(&self, future: F) -> F::Output {
|
fn block_on<F: Future>(&self, future: F) -> F::Output {
|
||||||
self.runtime.handle().block_on(future)
|
self.runtime.handle().block_on(future)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn initialize_with_identity_mode(
|
||||||
|
app_data_dir: String,
|
||||||
|
event_sink: Arc<dyn CoreEventSink>,
|
||||||
|
limits: CoreLimits,
|
||||||
|
network_config: CoreNetworkConfig,
|
||||||
|
identity_mode: IdentityMode,
|
||||||
|
) -> 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 inner = runtime
|
||||||
|
.block_on(CoreInner::start(
|
||||||
|
PathBuf::from(app_data_dir),
|
||||||
|
event_sink,
|
||||||
|
limits,
|
||||||
|
network_config.mode,
|
||||||
|
relay_urls,
|
||||||
|
identity_mode,
|
||||||
|
))
|
||||||
|
.map_err(VnidropError::initialization)?;
|
||||||
|
Ok(Arc::new(Self { runtime, inner }))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[uniffi::export]
|
#[uniffi::export]
|
||||||
@@ -86,25 +116,39 @@ impl VnidropCore {
|
|||||||
limits: CoreLimits,
|
limits: CoreLimits,
|
||||||
network_config: CoreNetworkConfig,
|
network_config: CoreNetworkConfig,
|
||||||
) -> Result<Arc<Self>, VnidropError> {
|
) -> Result<Arc<Self>, VnidropError> {
|
||||||
limits.validate().map_err(VnidropError::initialization)?;
|
Self::initialize_with_identity_mode(
|
||||||
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,
|
app_data_dir,
|
||||||
event_sink,
|
event_sink,
|
||||||
limits,
|
limits,
|
||||||
network_config.mode,
|
network_config,
|
||||||
relay_urls,
|
IdentityMode::Legacy,
|
||||||
))
|
)
|
||||||
.map_err(VnidropError::initialization)?;
|
}
|
||||||
Ok(Arc::new(Self { runtime, inner }))
|
|
||||||
|
/// Starts the experimental saved-device core with a platform-protected identity.
|
||||||
|
#[uniffi::constructor]
|
||||||
|
pub fn initialize_with_experimental_saved_devices(
|
||||||
|
app_data_dir: String,
|
||||||
|
event_sink: Arc<dyn CoreEventSink>,
|
||||||
|
limits: CoreLimits,
|
||||||
|
network_config: CoreNetworkConfig,
|
||||||
|
) -> Result<Arc<Self>, VnidropError> {
|
||||||
|
let app_data_path = PathBuf::from(app_data_dir);
|
||||||
|
std::fs::create_dir_all(&app_data_path).map_err(VnidropError::filesystem)?;
|
||||||
|
let app_data_path =
|
||||||
|
std::fs::canonicalize(app_data_path).map_err(VnidropError::filesystem)?;
|
||||||
|
let profile_lock = lock_profile(&app_data_path)?;
|
||||||
|
let store = platform_secret_store(&app_data_path)?;
|
||||||
|
Self::initialize_with_identity_mode(
|
||||||
|
app_data_path.to_string_lossy().into_owned(),
|
||||||
|
event_sink,
|
||||||
|
limits,
|
||||||
|
network_config,
|
||||||
|
IdentityMode::Protected {
|
||||||
|
store,
|
||||||
|
profile_lock,
|
||||||
|
},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn status(&self) -> RuntimeStatus {
|
pub fn status(&self) -> RuntimeStatus {
|
||||||
@@ -271,6 +315,148 @@ impl VnidropCore {
|
|||||||
.map_err(VnidropError::permission)
|
.map_err(VnidropError::permission)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Devices the user has chosen to remember.
|
||||||
|
pub fn list_contacts(&self) -> Result<Vec<ContactSummary>, VnidropError> {
|
||||||
|
self.block_on(self.inner.list_contacts())
|
||||||
|
.map_err(VnidropError::repository)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Share content and push it straight to a paired device.
|
||||||
|
///
|
||||||
|
/// Only the receiving user is prompted: this device authorised the target
|
||||||
|
/// when it created the offer.
|
||||||
|
pub fn send_to_contact(
|
||||||
|
&self,
|
||||||
|
endpoint_id: String,
|
||||||
|
sources: Vec<ShareSource>,
|
||||||
|
metadata: ShareMetadataInput,
|
||||||
|
) -> Result<ContactSendResult, VnidropError> {
|
||||||
|
self.block_on(self.inner.send_to_contact(endpoint_id, sources, metadata))
|
||||||
|
.map_err(VnidropError::transfer)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ask remembered devices whether they are holding transfers for this one.
|
||||||
|
///
|
||||||
|
/// Call only from a foreground transition or an explicit user action: it
|
||||||
|
/// tells every contact that this device is awake. Returns how many offers
|
||||||
|
/// were collected.
|
||||||
|
pub fn poll_contacts_for_offers(&self) -> Result<u64, VnidropError> {
|
||||||
|
self.block_on(self.inner.poll_contacts_for_offers())
|
||||||
|
.map_err(VnidropError::transfer)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Offer an existing share to a remembered device.
|
||||||
|
///
|
||||||
|
/// Another way to deliver the invitation already created for a transfer,
|
||||||
|
/// alongside the QR code — not a second share of the same files.
|
||||||
|
pub fn offer_transfer_to_contact(
|
||||||
|
&self,
|
||||||
|
transfer_id: u64,
|
||||||
|
endpoint_id: String,
|
||||||
|
) -> Result<ContactSendResult, VnidropError> {
|
||||||
|
self.block_on(
|
||||||
|
self.inner
|
||||||
|
.offer_transfer_to_contact(transfer_id, endpoint_id),
|
||||||
|
)
|
||||||
|
.map_err(VnidropError::transfer)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Transfers this device is holding for contacts that were not running.
|
||||||
|
pub fn list_held_offers(&self) -> Result<Vec<HeldOfferSummary>, VnidropError> {
|
||||||
|
self.block_on(self.inner.list_held_offers())
|
||||||
|
.map_err(VnidropError::repository)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Transfers paired devices are offering, awaiting this user's decision.
|
||||||
|
pub fn list_pending_offers(&self) -> Vec<IncomingOffer> {
|
||||||
|
self.block_on(self.inner.list_pending_offers())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Accept or decline an incoming offer.
|
||||||
|
///
|
||||||
|
/// Returns the ticket when accepted, which the caller passes to `receive`
|
||||||
|
/// with its own destination. Declining returns none: a refused offer never
|
||||||
|
/// yields a capability.
|
||||||
|
pub fn respond_to_offer(&self, offer_id: String, accepted: bool) -> Option<String> {
|
||||||
|
self.block_on(self.inner.respond_to_offer(offer_id, accepted))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Devices offering to be remembered, awaiting the local user's decision.
|
||||||
|
pub fn list_pending_pairings(&self) -> Vec<PendingPairing> {
|
||||||
|
self.block_on(self.inner.list_pending_pairings())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Agree to be reachable by a device, handing it a revocable capability.
|
||||||
|
///
|
||||||
|
/// Independent of whether that device agrees to be reachable by us: each
|
||||||
|
/// direction is a separate decision.
|
||||||
|
pub fn allow_device_to_reach_me(
|
||||||
|
&self,
|
||||||
|
endpoint_id: String,
|
||||||
|
display_name: Option<String>,
|
||||||
|
) -> Result<(), VnidropError> {
|
||||||
|
self.block_on(
|
||||||
|
self.inner
|
||||||
|
.allow_device_to_reach_me(endpoint_id, display_name),
|
||||||
|
)
|
||||||
|
.map_err(VnidropError::transfer)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Accept or decline a device's offer to be remembered. Returns false when
|
||||||
|
/// the offer already lapsed.
|
||||||
|
pub fn respond_to_pairing(
|
||||||
|
&self,
|
||||||
|
endpoint_id: String,
|
||||||
|
accepted: bool,
|
||||||
|
) -> Result<bool, VnidropError> {
|
||||||
|
self.block_on(self.inner.respond_to_pairing(endpoint_id, accepted))
|
||||||
|
.map_err(VnidropError::repository)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Forget a device and revoke its access. Takes effect locally at once; the
|
||||||
|
/// peer is notified best effort.
|
||||||
|
pub fn forget_contact(&self, endpoint_id: String) -> Result<(), VnidropError> {
|
||||||
|
self.block_on(self.inner.forget_contact(endpoint_id))
|
||||||
|
.map_err(VnidropError::repository)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Forget every device at once. Returns how many grants were revoked.
|
||||||
|
pub fn forget_all_contacts(&self) -> Result<u64, VnidropError> {
|
||||||
|
self.block_on(self.inner.forget_all_contacts())
|
||||||
|
.map_err(VnidropError::repository)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Refuse a device outright. Unlike forgetting, the peer is told nothing.
|
||||||
|
pub fn block_contact(&self, endpoint_id: String) -> Result<(), VnidropError> {
|
||||||
|
self.block_on(self.inner.block_contact(endpoint_id))
|
||||||
|
.map_err(VnidropError::repository)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn unblock_contact(&self, endpoint_id: String) -> Result<(), VnidropError> {
|
||||||
|
self.block_on(self.inner.unblock_contact(endpoint_id))
|
||||||
|
.map_err(VnidropError::repository)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn list_blocked_contacts(&self) -> Result<Vec<String>, VnidropError> {
|
||||||
|
self.block_on(self.inner.list_blocked_contacts())
|
||||||
|
.map_err(VnidropError::repository)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_contact_label(
|
||||||
|
&self,
|
||||||
|
endpoint_id: String,
|
||||||
|
label: Option<String>,
|
||||||
|
) -> Result<(), VnidropError> {
|
||||||
|
self.block_on(self.inner.set_contact_label(endpoint_id, label))
|
||||||
|
.map_err(VnidropError::repository)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Idle lifetime applied to grants issued from now on. Existing grants keep
|
||||||
|
/// the lifetime they were issued with until they next renew.
|
||||||
|
pub fn set_grant_lifetime(&self, lifetime: GrantLifetimeSetting) {
|
||||||
|
self.block_on(self.inner.set_grant_lifetime(lifetime));
|
||||||
|
}
|
||||||
|
|
||||||
pub fn list_transfers(&self) -> Result<Vec<StoredTransfer>, VnidropError> {
|
pub fn list_transfers(&self) -> Result<Vec<StoredTransfer>, VnidropError> {
|
||||||
self.block_on(self.inner.repository.list_transfers())
|
self.block_on(self.inner.repository.list_transfers())
|
||||||
.map_err(VnidropError::repository)
|
.map_err(VnidropError::repository)
|
||||||
|
|||||||
@@ -54,6 +54,13 @@ impl CoreInner {
|
|||||||
drop(active_shares);
|
drop(active_shares);
|
||||||
self.unregister_transfer_hashes(transfer_id).await;
|
self.unregister_transfer_hashes(transfer_id).await;
|
||||||
self.access_policy.remove_transfer(transfer_id).await;
|
self.access_policy.remove_transfer(transfer_id).await;
|
||||||
|
// An offer waiting for pickup would hand out a ticket for content
|
||||||
|
// this device no longer serves.
|
||||||
|
let _ = self
|
||||||
|
.repository
|
||||||
|
.contacts()
|
||||||
|
.delete_held_offers_for_transfer(transfer_id)
|
||||||
|
.await;
|
||||||
self.store.tags().delete(share_tag_name(&local_id)).await?;
|
self.store.tags().delete(share_tag_name(&local_id)).await?;
|
||||||
self.emit_transfer(transfer_id, "send", "lifecycle", "share-stopped", json!({}));
|
self.emit_transfer(transfer_id, "send", "lifecycle", "share-stopped", json!({}));
|
||||||
return Ok(());
|
return Ok(());
|
||||||
@@ -179,7 +186,7 @@ impl CoreInner {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) async fn shutdown(&self) {
|
pub(crate) async fn shutdown(&self) {
|
||||||
if self.shutdown_started.swap(true, Ordering::SeqCst) {
|
if self.shutdown_started.swap(true, Ordering::SeqCst) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,9 @@
|
|||||||
//! - [`receive`] — ticket receive, download, export
|
//! - [`receive`] — ticket receive, download, export
|
||||||
//! - [`lifecycle`] — cancel/delete/shutdown/status/access
|
//! - [`lifecycle`] — cancel/delete/shutdown/status/access
|
||||||
//! - [`provider`] — blob provider events and per-connection send progress
|
//! - [`provider`] — blob provider events and per-connection send progress
|
||||||
|
//! - [`contacts`] — device history: pairing, forgetting, blocking
|
||||||
|
|
||||||
|
mod contacts;
|
||||||
mod delivery;
|
mod delivery;
|
||||||
mod facade;
|
mod facade;
|
||||||
mod lifecycle;
|
mod lifecycle;
|
||||||
@@ -15,6 +17,8 @@ mod receive;
|
|||||||
mod share;
|
mod share;
|
||||||
mod storage;
|
mod storage;
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(crate) use self::contacts::should_poll;
|
||||||
pub use facade::VnidropCore;
|
pub use facade::VnidropCore;
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
pub(crate) use provider::{consume_request_updates, RequestStreamOutcome};
|
pub(crate) use provider::{consume_request_updates, RequestStreamOutcome};
|
||||||
@@ -55,14 +59,22 @@ use crate::{
|
|||||||
event_hub::EventHub,
|
event_hub::EventHub,
|
||||||
handshake::HandshakeService,
|
handshake::HandshakeService,
|
||||||
logging::init_logging,
|
logging::init_logging,
|
||||||
|
offer::OfferService,
|
||||||
|
offer_inbox::OfferInbox,
|
||||||
|
pairing::PairingService,
|
||||||
repository::Repository,
|
repository::Repository,
|
||||||
secret::load_or_create_secret,
|
secret::load_or_create_secret,
|
||||||
|
secure_secret::{start_endpoint_identity, ProfileLock, SecretCustody, SecureSecretStore},
|
||||||
ticket::ticket_matches_relay_profile,
|
ticket::ticket_matches_relay_profile,
|
||||||
transfer_state::{TransferDirection, TransferStatus},
|
transfer_state::{TransferDirection, TransferStatus},
|
||||||
};
|
};
|
||||||
|
|
||||||
const RELAY_CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
|
const RELAY_CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
|
||||||
|
|
||||||
|
/// Minimum gap between polls of the same device, so switching in and out of the
|
||||||
|
/// app does not announce presence to every contact repeatedly.
|
||||||
|
pub(super) const POLL_MIN_INTERVAL_MS: i64 = 5 * 60 * 1_000;
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub(crate) enum RelayStatus {
|
pub(crate) enum RelayStatus {
|
||||||
Disabled,
|
Disabled,
|
||||||
@@ -88,8 +100,14 @@ pub(super) struct CoreInner {
|
|||||||
pub(super) router: Router,
|
pub(super) router: Router,
|
||||||
pub(super) store: FsStore,
|
pub(super) store: FsStore,
|
||||||
pub(super) repository: Repository,
|
pub(super) repository: Repository,
|
||||||
|
_secret_custody: Option<SecretCustody>,
|
||||||
|
_profile_lock: Option<ProfileLock>,
|
||||||
pub(super) event_hub: Arc<EventHub>,
|
pub(super) event_hub: Arc<EventHub>,
|
||||||
pub(super) approval: ApprovalService,
|
pub(super) approval: ApprovalService,
|
||||||
|
pub(super) pairing: PairingService,
|
||||||
|
pub(super) offers: OfferInbox,
|
||||||
|
/// Endpoint → last poll time, for the rate limit above.
|
||||||
|
pub(super) last_polled: TokioMutex<HashMap<String, i64>>,
|
||||||
pub(super) limits: CoreLimits,
|
pub(super) limits: CoreLimits,
|
||||||
pub(super) relay_mode: CoreRelayMode,
|
pub(super) relay_mode: CoreRelayMode,
|
||||||
pub(super) custom_relay_urls: Vec<RelayUrl>,
|
pub(super) custom_relay_urls: Vec<RelayUrl>,
|
||||||
@@ -115,6 +133,14 @@ pub(super) struct ActiveTransfer {
|
|||||||
pub(super) cancel: oneshot::Sender<()>,
|
pub(super) cancel: oneshot::Sender<()>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(super) enum IdentityMode {
|
||||||
|
Legacy,
|
||||||
|
Protected {
|
||||||
|
store: Arc<dyn SecureSecretStore>,
|
||||||
|
profile_lock: ProfileLock,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
impl CoreInner {
|
impl CoreInner {
|
||||||
pub(super) async fn start(
|
pub(super) async fn start(
|
||||||
app_data_dir: PathBuf,
|
app_data_dir: PathBuf,
|
||||||
@@ -122,11 +148,26 @@ impl CoreInner {
|
|||||||
limits: CoreLimits,
|
limits: CoreLimits,
|
||||||
relay_mode: CoreRelayMode,
|
relay_mode: CoreRelayMode,
|
||||||
relay_urls: Vec<RelayUrl>,
|
relay_urls: Vec<RelayUrl>,
|
||||||
|
identity_mode: IdentityMode,
|
||||||
) -> Result<Arc<Self>> {
|
) -> Result<Arc<Self>> {
|
||||||
tokio::fs::create_dir_all(&app_data_dir).await?;
|
tokio::fs::create_dir_all(&app_data_dir).await?;
|
||||||
init_logging(&app_data_dir)?;
|
init_logging(&app_data_dir)?;
|
||||||
let secret_key = load_or_create_secret(&app_data_dir).await?;
|
|
||||||
let repository = Repository::open(&app_data_dir).await?;
|
let repository = Repository::open(&app_data_dir).await?;
|
||||||
|
let (secret_key, secret_custody, profile_lock) = match identity_mode {
|
||||||
|
IdentityMode::Legacy => (load_or_create_secret(&app_data_dir).await?, None, None),
|
||||||
|
IdentityMode::Protected {
|
||||||
|
store,
|
||||||
|
profile_lock,
|
||||||
|
} => {
|
||||||
|
let (secret_key, custody) = start_endpoint_identity(
|
||||||
|
repository.protected_secrets(),
|
||||||
|
store,
|
||||||
|
&app_data_dir.join("iroh.secret"),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
(secret_key, Some(custody), Some(profile_lock))
|
||||||
|
}
|
||||||
|
};
|
||||||
let store_root = app_data_dir.join("blobs");
|
let store_root = app_data_dir.join("blobs");
|
||||||
let mut store_options = FsStoreOptions::new(&store_root);
|
let mut store_options = FsStoreOptions::new(&store_root);
|
||||||
store_options.gc = Some(GcConfig {
|
store_options.gc = Some(GcConfig {
|
||||||
@@ -321,9 +362,28 @@ impl CoreInner {
|
|||||||
limits.max_metadata_bytes,
|
limits.max_metadata_bytes,
|
||||||
);
|
);
|
||||||
let handshake = HandshakeService::new(approval.clone());
|
let handshake = HandshakeService::new(approval.clone());
|
||||||
|
let pairing = PairingService::new(
|
||||||
|
repository.contacts(),
|
||||||
|
event_hub.clone(),
|
||||||
|
limits.max_pending_offers as usize,
|
||||||
|
limits.max_metadata_bytes,
|
||||||
|
);
|
||||||
|
// Sweep grants dead long enough that no peer still needs the tombstone.
|
||||||
|
if let Err(error) = repository
|
||||||
|
.contacts()
|
||||||
|
.purge_dead_grants(crate::util::now_ms() - crate::contacts::DEAD_GRANT_RETENTION_MS)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
tracing::warn!(%error, "failed to sweep dead grants");
|
||||||
|
}
|
||||||
|
let offers = OfferInbox::new(event_hub.clone(), limits.max_pending_offers as usize);
|
||||||
let router = Router::builder(endpoint.clone())
|
let router = Router::builder(endpoint.clone())
|
||||||
.accept(iroh_blobs::ALPN, blobs)
|
.accept(iroh_blobs::ALPN, blobs)
|
||||||
.accept(HandshakeService::ALPN, handshake)
|
.accept(HandshakeService::ALPN, handshake)
|
||||||
|
.accept(
|
||||||
|
OfferService::ALPN,
|
||||||
|
OfferService::new(pairing.clone(), offers.clone(), endpoint.id().to_string()),
|
||||||
|
)
|
||||||
.spawn();
|
.spawn();
|
||||||
|
|
||||||
let inner = Arc::new(Self {
|
let inner = Arc::new(Self {
|
||||||
@@ -332,8 +392,13 @@ impl CoreInner {
|
|||||||
router,
|
router,
|
||||||
store,
|
store,
|
||||||
repository,
|
repository,
|
||||||
|
_secret_custody: secret_custody,
|
||||||
|
_profile_lock: profile_lock,
|
||||||
event_hub,
|
event_hub,
|
||||||
approval,
|
approval,
|
||||||
|
pairing,
|
||||||
|
offers,
|
||||||
|
last_polled: TokioMutex::new(HashMap::new()),
|
||||||
relay_mode,
|
relay_mode,
|
||||||
custom_relay_urls: relay_urls,
|
custom_relay_urls: relay_urls,
|
||||||
transfer_slots: Semaphore::new(limits.max_concurrent_transfers as usize),
|
transfer_slots: Semaphore::new(limits.max_concurrent_transfers as usize),
|
||||||
|
|||||||
750
crates/vnidrop/src/secure_secret.rs
Normal file
@@ -0,0 +1,750 @@
|
|||||||
|
use std::{collections::HashSet, fmt, io, path::Path, sync::Arc, time::Duration};
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
use std::{collections::HashMap, sync::Mutex};
|
||||||
|
|
||||||
|
use data_encoding::HEXLOWER;
|
||||||
|
use iroh::SecretKey;
|
||||||
|
use sqlx::{Row, SqlitePool};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::{error::VnidropError, util::now_ms};
|
||||||
|
|
||||||
|
#[cfg(any(test, target_os = "android"))]
|
||||||
|
pub(crate) mod android;
|
||||||
|
#[cfg(any(target_os = "macos", target_os = "ios"))]
|
||||||
|
pub(crate) mod apple;
|
||||||
|
#[cfg(any(test, target_os = "linux"))]
|
||||||
|
pub(crate) mod linux;
|
||||||
|
mod platform;
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
pub(crate) mod windows;
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(crate) use platform::scope_store;
|
||||||
|
pub(crate) use platform::{lock_profile, platform_secret_store, ProfileLock};
|
||||||
|
|
||||||
|
const SECRET_BYTES: usize = 32;
|
||||||
|
const HANDLE_NAMESPACE: &str = "vnidrop";
|
||||||
|
const HANDLE_VERSION: &str = "v1";
|
||||||
|
|
||||||
|
#[derive(Clone, PartialEq, Eq)]
|
||||||
|
pub(crate) struct SecretMaterial(Vec<u8>);
|
||||||
|
|
||||||
|
impl SecretMaterial {
|
||||||
|
pub(crate) fn new(bytes: Vec<u8>) -> Result<Self, VnidropError> {
|
||||||
|
if bytes.len() != SECRET_BYTES || bytes.iter().all(|byte| *byte == 0) {
|
||||||
|
return Err(VnidropError::SecureStorageCorrupted {
|
||||||
|
reason: "protected secret has invalid key material".to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(Self(bytes))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn endpoint_id(&self) -> String {
|
||||||
|
let bytes: [u8; SECRET_BYTES] = self.0.as_slice().try_into().expect("validated length");
|
||||||
|
SecretKey::from_bytes(&bytes).public().to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn into_secret_key(self) -> SecretKey {
|
||||||
|
let bytes: [u8; SECRET_BYTES] = self.0.try_into().expect("validated length");
|
||||||
|
SecretKey::from_bytes(&bytes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Debug for SecretMaterial {
|
||||||
|
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
formatter.write_str("SecretMaterial(redacted)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, PartialEq, Eq, Hash)]
|
||||||
|
pub(crate) struct SecretHandle(String);
|
||||||
|
|
||||||
|
impl SecretHandle {
|
||||||
|
fn generate(kind: SecretKind) -> Self {
|
||||||
|
Self(format!(
|
||||||
|
"{HANDLE_NAMESPACE}/{HANDLE_VERSION}/{}/{}",
|
||||||
|
kind.as_str(),
|
||||||
|
Uuid::new_v4()
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn as_str(&self) -> &str {
|
||||||
|
&self.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Debug for SecretHandle {
|
||||||
|
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
formatter
|
||||||
|
.debug_tuple("SecretHandle")
|
||||||
|
.field(&self.0)
|
||||||
|
.finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(crate) fn secret_handle_for_test(value: String) -> SecretHandle {
|
||||||
|
SecretHandle(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub(crate) enum SecretKind {
|
||||||
|
EndpointIdentity,
|
||||||
|
RelationshipGrant,
|
||||||
|
PairingEligibility,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SecretKind {
|
||||||
|
fn as_str(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::EndpointIdentity => "endpoint-identity",
|
||||||
|
Self::RelationshipGrant => "relationship-grant",
|
||||||
|
Self::PairingEligibility => "pairing-eligibility",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse(value: &str) -> Result<Self, VnidropError> {
|
||||||
|
match value {
|
||||||
|
"endpoint-identity" => Ok(Self::EndpointIdentity),
|
||||||
|
"relationship-grant" => Ok(Self::RelationshipGrant),
|
||||||
|
"pairing-eligibility" => Ok(Self::PairingEligibility),
|
||||||
|
_ => Err(VnidropError::SecureStorageCorrupted {
|
||||||
|
reason: "protected secret has an unknown kind".to_string(),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub(crate) enum SecureSecretStoreError {
|
||||||
|
#[error("credential store is locked")]
|
||||||
|
Locked,
|
||||||
|
#[error("credential is missing")]
|
||||||
|
Missing,
|
||||||
|
#[error("credential is corrupted")]
|
||||||
|
Corrupted,
|
||||||
|
#[error("credential store is unavailable")]
|
||||||
|
Unavailable,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Opaque credential-store boundary implemented by each supported platform.
|
||||||
|
///
|
||||||
|
/// Implementations must persist material outside ordinary application storage and
|
||||||
|
/// must never include material in errors or diagnostics.
|
||||||
|
pub(crate) trait SecureSecretStore: Send + Sync {
|
||||||
|
fn put(
|
||||||
|
&self,
|
||||||
|
handle: &SecretHandle,
|
||||||
|
material: SecretMaterial,
|
||||||
|
) -> Result<(), SecureSecretStoreError>;
|
||||||
|
fn get(&self, handle: &SecretHandle) -> Result<SecretMaterial, SecureSecretStoreError>;
|
||||||
|
fn delete(&self, handle: &SecretHandle) -> Result<(), SecureSecretStoreError>;
|
||||||
|
fn list_handles(&self) -> Result<Vec<SecretHandle>, SecureSecretStoreError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
enum SecretMetadataState {
|
||||||
|
Staged,
|
||||||
|
Active,
|
||||||
|
Disabled,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SecretMetadataState {
|
||||||
|
fn as_str(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Staged => "staged",
|
||||||
|
Self::Active => "active",
|
||||||
|
Self::Disabled => "disabled",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse(value: &str) -> Result<Self, VnidropError> {
|
||||||
|
match value {
|
||||||
|
"staged" => Ok(Self::Staged),
|
||||||
|
"active" => Ok(Self::Active),
|
||||||
|
"disabled" => Ok(Self::Disabled),
|
||||||
|
_ => Err(VnidropError::SecureStorageCorrupted {
|
||||||
|
reason: "protected secret has an unknown metadata state".to_string(),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
struct SecretMetadata {
|
||||||
|
handle: SecretHandle,
|
||||||
|
kind: SecretKind,
|
||||||
|
state: SecretMetadataState,
|
||||||
|
expected_identity: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn ensure_schema(pool: &SqlitePool) -> anyhow::Result<()> {
|
||||||
|
sqlx::query(
|
||||||
|
r#"
|
||||||
|
CREATE TABLE IF NOT EXISTS protected_secret_refs (
|
||||||
|
handle TEXT PRIMARY KEY,
|
||||||
|
kind TEXT NOT NULL,
|
||||||
|
state TEXT NOT NULL,
|
||||||
|
expected_identity TEXT,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
updated_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
sqlx::query(
|
||||||
|
r#"
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS protected_secret_one_endpoint_identity
|
||||||
|
ON protected_secret_refs(kind)
|
||||||
|
WHERE kind = 'endpoint-identity' AND state != 'disabled'
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub(crate) struct SecretMetadataStore {
|
||||||
|
pool: SqlitePool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SecretMetadataStore {
|
||||||
|
pub(crate) fn new(pool: SqlitePool) -> Self {
|
||||||
|
Self { pool }
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn stage(
|
||||||
|
&self,
|
||||||
|
handle: &SecretHandle,
|
||||||
|
kind: SecretKind,
|
||||||
|
expected_identity: Option<&str>,
|
||||||
|
) -> Result<(), VnidropError> {
|
||||||
|
let now = now_ms();
|
||||||
|
sqlx::query(
|
||||||
|
r#"
|
||||||
|
INSERT INTO protected_secret_refs
|
||||||
|
(handle, kind, state, expected_identity, created_at, updated_at)
|
||||||
|
VALUES (?1, ?2, 'staged', ?3, ?4, ?4)
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(handle.as_str())
|
||||||
|
.bind(kind.as_str())
|
||||||
|
.bind(expected_identity)
|
||||||
|
.bind(now)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(VnidropError::repository)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn activate(&self, handle: &SecretHandle) -> Result<(), VnidropError> {
|
||||||
|
self.set_state(handle, SecretMetadataState::Active).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn disable(&self, handle: &SecretHandle) -> Result<(), VnidropError> {
|
||||||
|
self.set_state(handle, SecretMetadataState::Disabled).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn set_state(
|
||||||
|
&self,
|
||||||
|
handle: &SecretHandle,
|
||||||
|
state: SecretMetadataState,
|
||||||
|
) -> Result<(), VnidropError> {
|
||||||
|
sqlx::query(
|
||||||
|
"UPDATE protected_secret_refs SET state = ?2, updated_at = ?3 WHERE handle = ?1",
|
||||||
|
)
|
||||||
|
.bind(handle.as_str())
|
||||||
|
.bind(state.as_str())
|
||||||
|
.bind(now_ms())
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(VnidropError::repository)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn find(&self, handle: &SecretHandle) -> Result<Option<SecretMetadata>, VnidropError> {
|
||||||
|
let row = sqlx::query(
|
||||||
|
"SELECT handle, kind, state, expected_identity FROM protected_secret_refs WHERE handle = ?1",
|
||||||
|
)
|
||||||
|
.bind(handle.as_str())
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(VnidropError::repository)?;
|
||||||
|
row.map(row_to_metadata).transpose()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list(&self) -> Result<Vec<SecretMetadata>, VnidropError> {
|
||||||
|
let rows = sqlx::query(
|
||||||
|
"SELECT handle, kind, state, expected_identity FROM protected_secret_refs ORDER BY handle",
|
||||||
|
)
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(VnidropError::repository)?;
|
||||||
|
rows.into_iter().map(row_to_metadata).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn find_active_kind(
|
||||||
|
&self,
|
||||||
|
kind: SecretKind,
|
||||||
|
) -> Result<Option<SecretMetadata>, VnidropError> {
|
||||||
|
let row = sqlx::query(
|
||||||
|
r#"
|
||||||
|
SELECT handle, kind, state, expected_identity
|
||||||
|
FROM protected_secret_refs
|
||||||
|
WHERE kind = ?1 AND state = 'active'
|
||||||
|
ORDER BY created_at ASC
|
||||||
|
LIMIT 1
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(kind.as_str())
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(VnidropError::repository)?;
|
||||||
|
row.map(row_to_metadata).transpose()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn contains_kind(&self, kind: SecretKind) -> Result<bool, VnidropError> {
|
||||||
|
let row = sqlx::query("SELECT 1 FROM protected_secret_refs WHERE kind = ?1 LIMIT 1")
|
||||||
|
.bind(kind.as_str())
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(VnidropError::repository)?;
|
||||||
|
Ok(row.is_some())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn row_to_metadata(row: sqlx::sqlite::SqliteRow) -> Result<SecretMetadata, VnidropError> {
|
||||||
|
Ok(SecretMetadata {
|
||||||
|
handle: SecretHandle(row.get(0)),
|
||||||
|
kind: SecretKind::parse(row.get::<String, _>(1).as_str())?,
|
||||||
|
state: SecretMetadataState::parse(row.get::<String, _>(2).as_str())?,
|
||||||
|
expected_identity: row.get(3),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) struct SecretCustody {
|
||||||
|
metadata: SecretMetadataStore,
|
||||||
|
store: Arc<dyn SecureSecretStore>,
|
||||||
|
#[cfg(test)]
|
||||||
|
crash_point: Mutex<Option<CustodyCrashPoint>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn start_endpoint_identity(
|
||||||
|
metadata: SecretMetadataStore,
|
||||||
|
store: Arc<dyn SecureSecretStore>,
|
||||||
|
legacy_path: &Path,
|
||||||
|
) -> Result<(SecretKey, SecretCustody), VnidropError> {
|
||||||
|
let (custody, _) = SecretCustody::start(metadata, store).await?;
|
||||||
|
let secret_key = custody
|
||||||
|
.initialize_endpoint_identity(legacy_path)
|
||||||
|
.await?
|
||||||
|
.into_secret_key();
|
||||||
|
Ok((secret_key, custody))
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SecretCustody {
|
||||||
|
pub(crate) async fn start(
|
||||||
|
metadata: SecretMetadataStore,
|
||||||
|
store: Arc<dyn SecureSecretStore>,
|
||||||
|
) -> Result<(Self, ReconciliationSummary), VnidropError> {
|
||||||
|
let custody = Self::from_parts(metadata, store);
|
||||||
|
let summary = custody.reconcile().await?;
|
||||||
|
Ok((custody, summary))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(crate) fn new(metadata: SecretMetadataStore, store: Arc<dyn SecureSecretStore>) -> Self {
|
||||||
|
Self::from_parts(metadata, store)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn from_parts(metadata: SecretMetadataStore, store: Arc<dyn SecureSecretStore>) -> Self {
|
||||||
|
Self {
|
||||||
|
metadata,
|
||||||
|
store,
|
||||||
|
#[cfg(test)]
|
||||||
|
crash_point: Mutex::new(None),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn protect(
|
||||||
|
&self,
|
||||||
|
kind: SecretKind,
|
||||||
|
material: SecretMaterial,
|
||||||
|
expected_identity: Option<&str>,
|
||||||
|
) -> Result<SecretHandle, VnidropError> {
|
||||||
|
validate_material(kind, &material, expected_identity)?;
|
||||||
|
let handle = SecretHandle::generate(kind);
|
||||||
|
self.store
|
||||||
|
.put(&handle, material.clone())
|
||||||
|
.map_err(map_store_error)?;
|
||||||
|
#[cfg(test)]
|
||||||
|
self.maybe_crash(CustodyCrashPoint::StoreWrite)?;
|
||||||
|
let stored = self.store.get(&handle).map_err(map_store_error)?;
|
||||||
|
if stored != material {
|
||||||
|
return Err(VnidropError::SecureStorageCorrupted {
|
||||||
|
reason: "credential store did not preserve protected material".to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
validate_material(kind, &stored, expected_identity)?;
|
||||||
|
if let Err(error) = self.metadata.stage(&handle, kind, expected_identity).await {
|
||||||
|
self.delete_if_present(&handle)?;
|
||||||
|
return Err(error);
|
||||||
|
}
|
||||||
|
#[cfg(test)]
|
||||||
|
self.maybe_crash(CustodyCrashPoint::MetadataStage)?;
|
||||||
|
self.metadata.activate(&handle).await?;
|
||||||
|
#[cfg(test)]
|
||||||
|
self.maybe_crash(CustodyCrashPoint::MetadataActivation)?;
|
||||||
|
Ok(handle)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn load(&self, handle: &SecretHandle) -> Result<SecretMaterial, VnidropError> {
|
||||||
|
let metadata = self.metadata.find(handle).await?.ok_or_else(|| {
|
||||||
|
VnidropError::SecureStorageMissing {
|
||||||
|
reason: "protected secret metadata is missing".to_string(),
|
||||||
|
}
|
||||||
|
})?;
|
||||||
|
if metadata.state != SecretMetadataState::Active {
|
||||||
|
return Err(VnidropError::SecureStorageUnavailable {
|
||||||
|
reason: "protected secret is not active".to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let material = self.store.get(handle).map_err(map_store_error)?;
|
||||||
|
validate_material(
|
||||||
|
metadata.kind,
|
||||||
|
&material,
|
||||||
|
metadata.expected_identity.as_deref(),
|
||||||
|
)?;
|
||||||
|
Ok(material)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn migrate_legacy_endpoint_identity(
|
||||||
|
&self,
|
||||||
|
legacy_path: &Path,
|
||||||
|
) -> Result<SecretHandle, VnidropError> {
|
||||||
|
if let Some(active) = self
|
||||||
|
.metadata
|
||||||
|
.find_active_kind(SecretKind::EndpointIdentity)
|
||||||
|
.await?
|
||||||
|
{
|
||||||
|
let protected = self.load(&active.handle).await?;
|
||||||
|
match read_legacy_endpoint_identity(legacy_path).await {
|
||||||
|
Ok(legacy) => {
|
||||||
|
if legacy.endpoint_id() != protected.endpoint_id() {
|
||||||
|
return Err(VnidropError::SecureStorageCorrupted {
|
||||||
|
reason: "legacy endpoint key does not match protected identity"
|
||||||
|
.to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
tokio::fs::remove_file(legacy_path)
|
||||||
|
.await
|
||||||
|
.map_err(VnidropError::filesystem)?;
|
||||||
|
}
|
||||||
|
Err(VnidropError::SecureStorageMissing { .. }) => {}
|
||||||
|
Err(error) => return Err(error),
|
||||||
|
}
|
||||||
|
return Ok(active.handle);
|
||||||
|
}
|
||||||
|
|
||||||
|
let legacy = read_legacy_endpoint_identity(legacy_path).await?;
|
||||||
|
let endpoint_id = legacy.endpoint_id();
|
||||||
|
let handle = self
|
||||||
|
.protect(
|
||||||
|
SecretKind::EndpointIdentity,
|
||||||
|
legacy,
|
||||||
|
Some(endpoint_id.as_str()),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
tokio::fs::remove_file(legacy_path)
|
||||||
|
.await
|
||||||
|
.map_err(VnidropError::filesystem)?;
|
||||||
|
Ok(handle)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn initialize_endpoint_identity(
|
||||||
|
&self,
|
||||||
|
legacy_path: &Path,
|
||||||
|
) -> Result<SecretMaterial, VnidropError> {
|
||||||
|
if self
|
||||||
|
.metadata
|
||||||
|
.find_active_kind(SecretKind::EndpointIdentity)
|
||||||
|
.await?
|
||||||
|
.is_some()
|
||||||
|
{
|
||||||
|
let handle = self.migrate_legacy_endpoint_identity(legacy_path).await?;
|
||||||
|
return self.load(&handle).await;
|
||||||
|
}
|
||||||
|
if self
|
||||||
|
.metadata
|
||||||
|
.contains_kind(SecretKind::EndpointIdentity)
|
||||||
|
.await?
|
||||||
|
{
|
||||||
|
return Err(VnidropError::SecureStorageUnavailable {
|
||||||
|
reason: "protected endpoint identity is disabled".to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
match tokio::fs::try_exists(legacy_path).await {
|
||||||
|
Ok(true) => {
|
||||||
|
let handle = self.migrate_legacy_endpoint_identity(legacy_path).await?;
|
||||||
|
self.load(&handle).await
|
||||||
|
}
|
||||||
|
Ok(false) => {
|
||||||
|
let secret = SecretKey::generate();
|
||||||
|
let material = SecretMaterial::new(secret.to_bytes().to_vec())?;
|
||||||
|
let endpoint_id = material.endpoint_id();
|
||||||
|
match self
|
||||||
|
.protect(
|
||||||
|
SecretKind::EndpointIdentity,
|
||||||
|
material,
|
||||||
|
Some(endpoint_id.as_str()),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(handle) => self.load(&handle).await,
|
||||||
|
Err(error) => {
|
||||||
|
let winner = tokio::time::timeout(Duration::from_secs(1), async {
|
||||||
|
loop {
|
||||||
|
if let Some(active) = self
|
||||||
|
.metadata
|
||||||
|
.find_active_kind(SecretKind::EndpointIdentity)
|
||||||
|
.await?
|
||||||
|
{
|
||||||
|
return self.load(&active.handle).await;
|
||||||
|
}
|
||||||
|
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
match winner {
|
||||||
|
Ok(result) => result,
|
||||||
|
Err(_) => Err(error),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(error) => Err(VnidropError::filesystem(error)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn reconcile(&self) -> Result<ReconciliationSummary, VnidropError> {
|
||||||
|
let metadata = self.metadata.list().await?;
|
||||||
|
let stored_handles = self.store.list_handles().map_err(map_store_error)?;
|
||||||
|
let known_handles = metadata
|
||||||
|
.iter()
|
||||||
|
.map(|entry| entry.handle.clone())
|
||||||
|
.collect::<HashSet<_>>();
|
||||||
|
let mut summary = ReconciliationSummary::default();
|
||||||
|
|
||||||
|
for entry in metadata {
|
||||||
|
if entry.state == SecretMetadataState::Disabled {
|
||||||
|
self.delete_if_present(&entry.handle)?;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
match self.store.get(&entry.handle) {
|
||||||
|
Ok(material) => {
|
||||||
|
if validate_material(entry.kind, &material, entry.expected_identity.as_deref())
|
||||||
|
.is_err()
|
||||||
|
{
|
||||||
|
self.metadata.disable(&entry.handle).await?;
|
||||||
|
self.delete_if_present(&entry.handle)?;
|
||||||
|
summary.disabled += 1;
|
||||||
|
} else if entry.state == SecretMetadataState::Staged {
|
||||||
|
self.metadata.activate(&entry.handle).await?;
|
||||||
|
summary.staged_activated += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(SecureSecretStoreError::Missing | SecureSecretStoreError::Corrupted) => {
|
||||||
|
self.metadata.disable(&entry.handle).await?;
|
||||||
|
self.delete_if_present(&entry.handle)?;
|
||||||
|
summary.disabled += 1;
|
||||||
|
}
|
||||||
|
Err(error) => return Err(map_store_error(error)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for handle in stored_handles {
|
||||||
|
if !known_handles.contains(&handle) {
|
||||||
|
self.store.delete(&handle).map_err(map_store_error)?;
|
||||||
|
summary.orphans_deleted += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(summary)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn delete_if_present(&self, handle: &SecretHandle) -> Result<(), VnidropError> {
|
||||||
|
match self.store.delete(handle) {
|
||||||
|
Ok(()) | Err(SecureSecretStoreError::Missing) => Ok(()),
|
||||||
|
Err(error) => Err(map_store_error(error)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(crate) fn crash_once_at(&self, point: CustodyCrashPoint) {
|
||||||
|
*self.crash_point.lock().unwrap() = Some(point);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
fn maybe_crash(&self, point: CustodyCrashPoint) -> Result<(), VnidropError> {
|
||||||
|
let mut crash_point = self.crash_point.lock().unwrap();
|
||||||
|
if *crash_point == Some(point) {
|
||||||
|
*crash_point = None;
|
||||||
|
return Err(VnidropError::Internal {
|
||||||
|
reason: format!("simulated custody crash at {point:?}"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn read_legacy_endpoint_identity(path: &Path) -> Result<SecretMaterial, VnidropError> {
|
||||||
|
let encoded = match tokio::fs::read_to_string(path).await {
|
||||||
|
Ok(encoded) => encoded,
|
||||||
|
Err(error) if error.kind() == io::ErrorKind::NotFound => {
|
||||||
|
return Err(VnidropError::SecureStorageMissing {
|
||||||
|
reason: "no protected or legacy endpoint identity exists".to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Err(error) => return Err(VnidropError::filesystem(error)),
|
||||||
|
};
|
||||||
|
let bytes = HEXLOWER.decode(encoded.trim().as_bytes()).map_err(|_| {
|
||||||
|
VnidropError::SecureStorageCorrupted {
|
||||||
|
reason: "legacy endpoint key encoding is invalid".to_string(),
|
||||||
|
}
|
||||||
|
})?;
|
||||||
|
SecretMaterial::new(bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||||
|
pub(crate) struct ReconciliationSummary {
|
||||||
|
pub(crate) orphans_deleted: u64,
|
||||||
|
pub(crate) staged_activated: u64,
|
||||||
|
pub(crate) disabled: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub(crate) enum CustodyCrashPoint {
|
||||||
|
StoreWrite,
|
||||||
|
MetadataStage,
|
||||||
|
MetadataActivation,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_material(
|
||||||
|
kind: SecretKind,
|
||||||
|
material: &SecretMaterial,
|
||||||
|
expected_identity: Option<&str>,
|
||||||
|
) -> Result<(), VnidropError> {
|
||||||
|
if kind == SecretKind::EndpointIdentity {
|
||||||
|
let expected_identity =
|
||||||
|
expected_identity.ok_or_else(|| VnidropError::SecureStorageCorrupted {
|
||||||
|
reason: "endpoint identity metadata lacks its expected endpoint id".to_string(),
|
||||||
|
})?;
|
||||||
|
if material.endpoint_id() != expected_identity {
|
||||||
|
return Err(VnidropError::SecureStorageCorrupted {
|
||||||
|
reason: "protected endpoint identity does not match its endpoint id".to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn map_store_error(error: SecureSecretStoreError) -> VnidropError {
|
||||||
|
let reason = error.to_string();
|
||||||
|
match error {
|
||||||
|
SecureSecretStoreError::Locked => VnidropError::SecureStorageLocked { reason },
|
||||||
|
SecureSecretStoreError::Missing => VnidropError::SecureStorageMissing { reason },
|
||||||
|
SecureSecretStoreError::Corrupted => VnidropError::SecureStorageCorrupted { reason },
|
||||||
|
SecureSecretStoreError::Unavailable => VnidropError::SecureStorageUnavailable { reason },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub(crate) enum ReferenceStoreFailure {
|
||||||
|
Locked,
|
||||||
|
Unavailable,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[derive(Default)]
|
||||||
|
pub(crate) struct FaultInjectingSecretStore {
|
||||||
|
values: Mutex<HashMap<SecretHandle, SecretMaterial>>,
|
||||||
|
failure: Mutex<Option<ReferenceStoreFailure>>,
|
||||||
|
corrupted: Mutex<Vec<SecretHandle>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
impl FaultInjectingSecretStore {
|
||||||
|
pub(crate) fn fail_with(&self, failure: Option<ReferenceStoreFailure>) {
|
||||||
|
*self.failure.lock().unwrap() = failure;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn remove_for_test(&self, handle: &SecretHandle) {
|
||||||
|
self.values.lock().unwrap().remove(handle);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn corrupt_for_test(&self, handle: &SecretHandle) {
|
||||||
|
self.corrupted.lock().unwrap().push(handle.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn only_handle_for_test(&self) -> SecretHandle {
|
||||||
|
let handles = self
|
||||||
|
.values
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.keys()
|
||||||
|
.cloned()
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
assert_eq!(handles.len(), 1, "expected exactly one protected secret");
|
||||||
|
handles.into_iter().next().unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn check_available(&self) -> Result<(), SecureSecretStoreError> {
|
||||||
|
match *self.failure.lock().unwrap() {
|
||||||
|
Some(ReferenceStoreFailure::Locked) => Err(SecureSecretStoreError::Locked),
|
||||||
|
Some(ReferenceStoreFailure::Unavailable) => Err(SecureSecretStoreError::Unavailable),
|
||||||
|
None => Ok(()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
impl SecureSecretStore for FaultInjectingSecretStore {
|
||||||
|
fn put(
|
||||||
|
&self,
|
||||||
|
handle: &SecretHandle,
|
||||||
|
material: SecretMaterial,
|
||||||
|
) -> Result<(), SecureSecretStoreError> {
|
||||||
|
self.check_available()?;
|
||||||
|
self.values.lock().unwrap().insert(handle.clone(), material);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get(&self, handle: &SecretHandle) -> Result<SecretMaterial, SecureSecretStoreError> {
|
||||||
|
self.check_available()?;
|
||||||
|
if self.corrupted.lock().unwrap().contains(handle) {
|
||||||
|
return Err(SecureSecretStoreError::Corrupted);
|
||||||
|
}
|
||||||
|
self.values
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.get(handle)
|
||||||
|
.cloned()
|
||||||
|
.ok_or(SecureSecretStoreError::Missing)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn delete(&self, handle: &SecretHandle) -> Result<(), SecureSecretStoreError> {
|
||||||
|
self.check_available()?;
|
||||||
|
self.values.lock().unwrap().remove(handle);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn list_handles(&self) -> Result<Vec<SecretHandle>, SecureSecretStoreError> {
|
||||||
|
self.check_available()?;
|
||||||
|
Ok(self.values.lock().unwrap().keys().cloned().collect())
|
||||||
|
}
|
||||||
|
}
|
||||||
318
crates/vnidrop/src/secure_secret/android.rs
Normal file
@@ -0,0 +1,318 @@
|
|||||||
|
use std::{
|
||||||
|
fs::{self, File, OpenOptions},
|
||||||
|
io::{Read, Write},
|
||||||
|
path::{Path, PathBuf},
|
||||||
|
sync::{Arc, Mutex},
|
||||||
|
};
|
||||||
|
|
||||||
|
use data_encoding::HEXLOWER;
|
||||||
|
|
||||||
|
use super::{SecretHandle, SecretMaterial, SecureSecretStore, SecureSecretStoreError};
|
||||||
|
|
||||||
|
const RECORD_MAGIC: &[u8; 8] = b"VNDASK01";
|
||||||
|
const RECORD_STAGED: u8 = 0;
|
||||||
|
const RECORD_SEALED: u8 = 1;
|
||||||
|
const RECORD_EXTENSION: &str = "vns";
|
||||||
|
const KEY_ALIAS_PREFIX: &str = "vnidrop.secret.v1.";
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub(crate) struct AndroidSealedValue {
|
||||||
|
pub(crate) nonce: Vec<u8>,
|
||||||
|
pub(crate) ciphertext: Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Performs AES-GCM operations with a non-exportable key held by Android Keystore.
|
||||||
|
///
|
||||||
|
/// Implementations create one key per alias, let Keystore generate the encryption
|
||||||
|
/// nonce, and never return key material to Rust.
|
||||||
|
pub(crate) trait AndroidKeystore: Send + Sync {
|
||||||
|
fn seal(
|
||||||
|
&self,
|
||||||
|
alias: &str,
|
||||||
|
plaintext: &[u8],
|
||||||
|
) -> Result<AndroidSealedValue, SecureSecretStoreError>;
|
||||||
|
fn open(
|
||||||
|
&self,
|
||||||
|
alias: &str,
|
||||||
|
sealed: &AndroidSealedValue,
|
||||||
|
) -> Result<Vec<u8>, SecureSecretStoreError>;
|
||||||
|
fn delete(&self, alias: &str) -> Result<(), SecureSecretStoreError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Android secret-store adapter whose ordinary storage contains authenticated
|
||||||
|
/// ciphertext only.
|
||||||
|
///
|
||||||
|
/// `no_backup_dir` must be the directory returned by Android
|
||||||
|
/// `Context.getNoBackupFilesDir()`. The Android host owns acquiring that Context;
|
||||||
|
/// secret values never cross that initialization boundary.
|
||||||
|
pub(crate) struct AndroidSecureSecretStore {
|
||||||
|
records_dir: PathBuf,
|
||||||
|
keystore: Arc<dyn AndroidKeystore>,
|
||||||
|
mutation_lock: Mutex<()>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AndroidSecureSecretStore {
|
||||||
|
pub(crate) fn new(
|
||||||
|
no_backup_dir: &Path,
|
||||||
|
keystore: Arc<dyn AndroidKeystore>,
|
||||||
|
) -> Result<Self, SecureSecretStoreError> {
|
||||||
|
if !no_backup_dir.is_absolute() {
|
||||||
|
return Err(SecureSecretStoreError::Unavailable);
|
||||||
|
}
|
||||||
|
let records_dir = no_backup_dir.join("vnidrop-protected-secrets-v1");
|
||||||
|
fs::create_dir_all(&records_dir).map_err(map_io_error)?;
|
||||||
|
set_private_directory_permissions(&records_dir)?;
|
||||||
|
Ok(Self {
|
||||||
|
records_dir,
|
||||||
|
keystore,
|
||||||
|
mutation_lock: Mutex::new(()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn record_path(&self, handle: &SecretHandle) -> PathBuf {
|
||||||
|
let encoded = HEXLOWER.encode(handle.as_str().as_bytes());
|
||||||
|
self.records_dir
|
||||||
|
.join(format!("{encoded}.{RECORD_EXTENSION}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn alias(handle: &SecretHandle) -> String {
|
||||||
|
let digest = blake3::hash(handle.as_str().as_bytes());
|
||||||
|
format!("{KEY_ALIAS_PREFIX}{}", HEXLOWER.encode(digest.as_bytes()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_record(
|
||||||
|
&self,
|
||||||
|
handle: &SecretHandle,
|
||||||
|
state: u8,
|
||||||
|
sealed: Option<&AndroidSealedValue>,
|
||||||
|
) -> Result<(), SecureSecretStoreError> {
|
||||||
|
let bytes = encode_record(handle, state, sealed)?;
|
||||||
|
let path = self.record_path(handle);
|
||||||
|
let temporary = path.with_extension(format!("{RECORD_EXTENSION}.tmp"));
|
||||||
|
let mut file = OpenOptions::new()
|
||||||
|
.create(true)
|
||||||
|
.truncate(true)
|
||||||
|
.write(true)
|
||||||
|
.open(&temporary)
|
||||||
|
.map_err(map_io_error)?;
|
||||||
|
set_private_file_permissions(&temporary)?;
|
||||||
|
file.write_all(&bytes).map_err(map_io_error)?;
|
||||||
|
file.sync_all().map_err(map_io_error)?;
|
||||||
|
fs::rename(&temporary, &path).map_err(map_io_error)?;
|
||||||
|
sync_directory(&self.records_dir)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_record(
|
||||||
|
&self,
|
||||||
|
handle: &SecretHandle,
|
||||||
|
) -> Result<AndroidSealedValue, SecureSecretStoreError> {
|
||||||
|
let mut bytes = Vec::new();
|
||||||
|
File::open(self.record_path(handle))
|
||||||
|
.map_err(map_io_error)?
|
||||||
|
.read_to_end(&mut bytes)
|
||||||
|
.map_err(map_io_error)?;
|
||||||
|
decode_record(&bytes, handle)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(crate) fn record_path_for_test(&self, handle: &SecretHandle) -> PathBuf {
|
||||||
|
self.record_path(handle)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(crate) fn stage_for_test(
|
||||||
|
&self,
|
||||||
|
handle: &SecretHandle,
|
||||||
|
) -> Result<(), SecureSecretStoreError> {
|
||||||
|
self.write_record(handle, RECORD_STAGED, None)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(crate) fn secret_handle_for_test(value: &str) -> SecretHandle {
|
||||||
|
SecretHandle(value.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SecureSecretStore for AndroidSecureSecretStore {
|
||||||
|
fn put(
|
||||||
|
&self,
|
||||||
|
handle: &SecretHandle,
|
||||||
|
material: SecretMaterial,
|
||||||
|
) -> Result<(), SecureSecretStoreError> {
|
||||||
|
let _mutation = self
|
||||||
|
.mutation_lock
|
||||||
|
.lock()
|
||||||
|
.map_err(|_| SecureSecretStoreError::Unavailable)?;
|
||||||
|
let record_exists = match fs::metadata(self.record_path(handle)) {
|
||||||
|
Ok(_) => true,
|
||||||
|
Err(error) if error.kind() == std::io::ErrorKind::NotFound => false,
|
||||||
|
Err(error) => return Err(map_io_error(error)),
|
||||||
|
};
|
||||||
|
if !record_exists {
|
||||||
|
self.write_record(handle, RECORD_STAGED, None)?;
|
||||||
|
}
|
||||||
|
let alias = Self::alias(handle);
|
||||||
|
let sealed = self.keystore.seal(&alias, &material.0)?;
|
||||||
|
if sealed.nonce.is_empty() || sealed.ciphertext.is_empty() {
|
||||||
|
return Err(SecureSecretStoreError::Corrupted);
|
||||||
|
}
|
||||||
|
self.write_record(handle, RECORD_SEALED, Some(&sealed))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get(&self, handle: &SecretHandle) -> Result<SecretMaterial, SecureSecretStoreError> {
|
||||||
|
let sealed = self.read_record(handle)?;
|
||||||
|
let plaintext = self.keystore.open(&Self::alias(handle), &sealed)?;
|
||||||
|
SecretMaterial::new(plaintext).map_err(|_| SecureSecretStoreError::Corrupted)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn delete(&self, handle: &SecretHandle) -> Result<(), SecureSecretStoreError> {
|
||||||
|
let _mutation = self
|
||||||
|
.mutation_lock
|
||||||
|
.lock()
|
||||||
|
.map_err(|_| SecureSecretStoreError::Unavailable)?;
|
||||||
|
match self.keystore.delete(&Self::alias(handle)) {
|
||||||
|
Ok(()) | Err(SecureSecretStoreError::Missing) => {}
|
||||||
|
Err(error) => return Err(error),
|
||||||
|
}
|
||||||
|
let path = self.record_path(handle);
|
||||||
|
match fs::remove_file(path) {
|
||||||
|
Ok(()) => sync_directory(&self.records_dir)?,
|
||||||
|
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
|
||||||
|
Err(error) => return Err(map_io_error(error)),
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn list_handles(&self) -> Result<Vec<SecretHandle>, SecureSecretStoreError> {
|
||||||
|
let mut handles = Vec::new();
|
||||||
|
for entry in fs::read_dir(&self.records_dir).map_err(map_io_error)? {
|
||||||
|
let entry = entry.map_err(map_io_error)?;
|
||||||
|
let path = entry.path();
|
||||||
|
if path.extension().and_then(|value| value.to_str()) != Some(RECORD_EXTENSION) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let stem = path
|
||||||
|
.file_stem()
|
||||||
|
.and_then(|value| value.to_str())
|
||||||
|
.ok_or(SecureSecretStoreError::Corrupted)?;
|
||||||
|
let decoded = HEXLOWER
|
||||||
|
.decode(stem.as_bytes())
|
||||||
|
.map_err(|_| SecureSecretStoreError::Corrupted)?;
|
||||||
|
let handle =
|
||||||
|
String::from_utf8(decoded).map_err(|_| SecureSecretStoreError::Corrupted)?;
|
||||||
|
handles.push(SecretHandle(handle));
|
||||||
|
}
|
||||||
|
handles.sort_by(|left, right| left.as_str().cmp(right.as_str()));
|
||||||
|
Ok(handles)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn encode_record(
|
||||||
|
handle: &SecretHandle,
|
||||||
|
state: u8,
|
||||||
|
sealed: Option<&AndroidSealedValue>,
|
||||||
|
) -> Result<Vec<u8>, SecureSecretStoreError> {
|
||||||
|
let handle_bytes = handle.as_str().as_bytes();
|
||||||
|
let handle_len =
|
||||||
|
u16::try_from(handle_bytes.len()).map_err(|_| SecureSecretStoreError::Corrupted)?;
|
||||||
|
let (nonce, ciphertext) = match (state, sealed) {
|
||||||
|
(RECORD_STAGED, None) => (&[][..], &[][..]),
|
||||||
|
(RECORD_SEALED, Some(value)) => (value.nonce.as_slice(), value.ciphertext.as_slice()),
|
||||||
|
_ => return Err(SecureSecretStoreError::Corrupted),
|
||||||
|
};
|
||||||
|
let nonce_len = u16::try_from(nonce.len()).map_err(|_| SecureSecretStoreError::Corrupted)?;
|
||||||
|
let ciphertext_len =
|
||||||
|
u32::try_from(ciphertext.len()).map_err(|_| SecureSecretStoreError::Corrupted)?;
|
||||||
|
let mut record = Vec::with_capacity(
|
||||||
|
RECORD_MAGIC.len() + 1 + 2 + 2 + 4 + handle_bytes.len() + nonce.len() + ciphertext.len(),
|
||||||
|
);
|
||||||
|
record.extend_from_slice(RECORD_MAGIC);
|
||||||
|
record.push(state);
|
||||||
|
record.extend_from_slice(&handle_len.to_be_bytes());
|
||||||
|
record.extend_from_slice(&nonce_len.to_be_bytes());
|
||||||
|
record.extend_from_slice(&ciphertext_len.to_be_bytes());
|
||||||
|
record.extend_from_slice(handle_bytes);
|
||||||
|
record.extend_from_slice(nonce);
|
||||||
|
record.extend_from_slice(ciphertext);
|
||||||
|
Ok(record)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decode_record(
|
||||||
|
bytes: &[u8],
|
||||||
|
expected_handle: &SecretHandle,
|
||||||
|
) -> Result<AndroidSealedValue, SecureSecretStoreError> {
|
||||||
|
const HEADER_LEN: usize = 8 + 1 + 2 + 2 + 4;
|
||||||
|
if bytes.len() < HEADER_LEN || &bytes[..8] != RECORD_MAGIC {
|
||||||
|
return Err(SecureSecretStoreError::Corrupted);
|
||||||
|
}
|
||||||
|
let state = bytes[8];
|
||||||
|
let handle_len = usize::from(u16::from_be_bytes([bytes[9], bytes[10]]));
|
||||||
|
let nonce_len = usize::from(u16::from_be_bytes([bytes[11], bytes[12]]));
|
||||||
|
let ciphertext_len = usize::try_from(u32::from_be_bytes([
|
||||||
|
bytes[13], bytes[14], bytes[15], bytes[16],
|
||||||
|
]))
|
||||||
|
.map_err(|_| SecureSecretStoreError::Corrupted)?;
|
||||||
|
let expected_len = HEADER_LEN
|
||||||
|
.checked_add(handle_len)
|
||||||
|
.and_then(|value| value.checked_add(nonce_len))
|
||||||
|
.and_then(|value| value.checked_add(ciphertext_len))
|
||||||
|
.ok_or(SecureSecretStoreError::Corrupted)?;
|
||||||
|
if bytes.len() != expected_len || state != RECORD_SEALED {
|
||||||
|
return Err(SecureSecretStoreError::Corrupted);
|
||||||
|
}
|
||||||
|
let handle_end = HEADER_LEN + handle_len;
|
||||||
|
let handle = std::str::from_utf8(&bytes[HEADER_LEN..handle_end])
|
||||||
|
.map_err(|_| SecureSecretStoreError::Corrupted)?;
|
||||||
|
if handle != expected_handle.as_str() {
|
||||||
|
return Err(SecureSecretStoreError::Corrupted);
|
||||||
|
}
|
||||||
|
let nonce_end = handle_end + nonce_len;
|
||||||
|
if nonce_len == 0 || ciphertext_len == 0 {
|
||||||
|
return Err(SecureSecretStoreError::Corrupted);
|
||||||
|
}
|
||||||
|
Ok(AndroidSealedValue {
|
||||||
|
nonce: bytes[handle_end..nonce_end].to_vec(),
|
||||||
|
ciphertext: bytes[nonce_end..].to_vec(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn map_io_error(error: std::io::Error) -> SecureSecretStoreError {
|
||||||
|
match error.kind() {
|
||||||
|
std::io::ErrorKind::NotFound => SecureSecretStoreError::Missing,
|
||||||
|
std::io::ErrorKind::InvalidData => SecureSecretStoreError::Corrupted,
|
||||||
|
_ => SecureSecretStoreError::Unavailable,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sync_directory(path: &Path) -> Result<(), SecureSecretStoreError> {
|
||||||
|
File::open(path)
|
||||||
|
.and_then(|directory| directory.sync_all())
|
||||||
|
.map_err(map_io_error)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
fn set_private_directory_permissions(path: &Path) -> Result<(), SecureSecretStoreError> {
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
fs::set_permissions(path, fs::Permissions::from_mode(0o700)).map_err(map_io_error)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(unix))]
|
||||||
|
fn set_private_directory_permissions(_path: &Path) -> Result<(), SecureSecretStoreError> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
fn set_private_file_permissions(path: &Path) -> Result<(), SecureSecretStoreError> {
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
fs::set_permissions(path, fs::Permissions::from_mode(0o600)).map_err(map_io_error)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(unix))]
|
||||||
|
fn set_private_file_permissions(_path: &Path) -> Result<(), SecureSecretStoreError> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
#[path = "android_native.rs"]
|
||||||
|
pub(crate) mod native;
|
||||||
494
crates/vnidrop/src/secure_secret/android_native.rs
Normal file
@@ -0,0 +1,494 @@
|
|||||||
|
use jni::{
|
||||||
|
errors::Error as JniError,
|
||||||
|
objects::{GlobalRef, JByteArray, JObject, JString, JValue},
|
||||||
|
sys::jboolean,
|
||||||
|
JNIEnv, JavaVM,
|
||||||
|
};
|
||||||
|
use std::{panic::AssertUnwindSafe, sync::Mutex};
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
const ANDROID_KEYSTORE: &str = "AndroidKeyStore";
|
||||||
|
const AES: &str = "AES";
|
||||||
|
const TRANSFORMATION: &str = "AES/GCM/NoPadding";
|
||||||
|
|
||||||
|
static ANDROID_APPLICATION_CONTEXT: Mutex<Option<GlobalRef>> = Mutex::new(None);
|
||||||
|
|
||||||
|
#[unsafe(no_mangle)]
|
||||||
|
pub extern "system" fn Java_com_vnidrop_app_core_AndroidCoreRuntime_initialize(
|
||||||
|
mut env: JNIEnv<'_>,
|
||||||
|
_receiver: JObject<'_>,
|
||||||
|
context: JObject<'_>,
|
||||||
|
) -> jboolean {
|
||||||
|
std::panic::catch_unwind(AssertUnwindSafe(|| {
|
||||||
|
initialize_android_context(&mut env, context)
|
||||||
|
}))
|
||||||
|
.ok()
|
||||||
|
.and_then(Result::ok)
|
||||||
|
.map_or(0, |()| 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn initialize_android_context(
|
||||||
|
env: &mut JNIEnv<'_>,
|
||||||
|
context: JObject<'_>,
|
||||||
|
) -> Result<(), SecureSecretStoreError> {
|
||||||
|
let mut stored = ANDROID_APPLICATION_CONTEXT
|
||||||
|
.lock()
|
||||||
|
.map_err(|_| SecureSecretStoreError::Unavailable)?;
|
||||||
|
if stored.is_some() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let vm = env
|
||||||
|
.get_java_vm()
|
||||||
|
.map_err(|_| SecureSecretStoreError::Unavailable)?;
|
||||||
|
let context = env
|
||||||
|
.new_global_ref(context)
|
||||||
|
.map_err(|_| SecureSecretStoreError::Unavailable)?;
|
||||||
|
unsafe {
|
||||||
|
ndk_context::initialize_android_context(
|
||||||
|
vm.get_java_vm_pointer().cast(),
|
||||||
|
context.as_obj().as_raw().cast(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
*stored = Some(context);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// JNI-backed Android Keystore engine. The VM pointer comes from the Android
|
||||||
|
/// runtime; no Context or secret bytes are exposed through UniFFI.
|
||||||
|
pub(crate) struct AndroidJniKeystore {
|
||||||
|
vm: JavaVM,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AndroidJniKeystore {
|
||||||
|
/// Constructs the engine after the Android runtime has initialized
|
||||||
|
/// `ndk-context` with the process Java VM.
|
||||||
|
pub(crate) fn from_android_runtime() -> Result<Self, SecureSecretStoreError> {
|
||||||
|
let context = std::panic::catch_unwind(ndk_context::android_context)
|
||||||
|
.map_err(|_| SecureSecretStoreError::Unavailable)?;
|
||||||
|
let vm = context.vm();
|
||||||
|
if vm.is_null() {
|
||||||
|
return Err(SecureSecretStoreError::Unavailable);
|
||||||
|
}
|
||||||
|
// Android owns the process VM for longer than every core instance.
|
||||||
|
let vm = unsafe { JavaVM::from_raw(vm.cast()) }
|
||||||
|
.map_err(|_| SecureSecretStoreError::Unavailable)?;
|
||||||
|
Ok(Self { vm })
|
||||||
|
}
|
||||||
|
|
||||||
|
fn with_env<T>(
|
||||||
|
&self,
|
||||||
|
operation: impl FnOnce(&mut JNIEnv<'_>) -> Result<T, SecureSecretStoreError>,
|
||||||
|
) -> Result<T, SecureSecretStoreError> {
|
||||||
|
let mut env = self
|
||||||
|
.vm
|
||||||
|
.attach_current_thread()
|
||||||
|
.map_err(|_| SecureSecretStoreError::Unavailable)?;
|
||||||
|
operation(&mut env)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn no_backup_files_dir(&self) -> Result<PathBuf, SecureSecretStoreError> {
|
||||||
|
self.with_env(|env| {
|
||||||
|
let context = std::panic::catch_unwind(ndk_context::android_context)
|
||||||
|
.map_err(|_| SecureSecretStoreError::Unavailable)?
|
||||||
|
.context();
|
||||||
|
if context.is_null() {
|
||||||
|
return Err(SecureSecretStoreError::Unavailable);
|
||||||
|
}
|
||||||
|
let context = local_ref_from_process_context(env, context.cast())?;
|
||||||
|
let directory = env
|
||||||
|
.call_method(&context, "getNoBackupFilesDir", "()Ljava/io/File;", &[])
|
||||||
|
.map_err(|error| map_jni_error(env, error))?
|
||||||
|
.l()
|
||||||
|
.map_err(|error| map_jni_error(env, error))?;
|
||||||
|
if directory.is_null() {
|
||||||
|
return Err(SecureSecretStoreError::Unavailable);
|
||||||
|
}
|
||||||
|
let path = env
|
||||||
|
.call_method(&directory, "getAbsolutePath", "()Ljava/lang/String;", &[])
|
||||||
|
.map_err(|error| map_jni_error(env, error))?
|
||||||
|
.l()
|
||||||
|
.map_err(|error| map_jni_error(env, error))?;
|
||||||
|
let path = JString::from(path);
|
||||||
|
let path: String = env
|
||||||
|
.get_string(&path)
|
||||||
|
.map_err(|error| map_jni_error(env, error))?
|
||||||
|
.into();
|
||||||
|
Ok(PathBuf::from(path))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn create_store_from_android_runtime(
|
||||||
|
) -> Result<Arc<dyn SecureSecretStore>, SecureSecretStoreError> {
|
||||||
|
let keystore = Arc::new(AndroidJniKeystore::from_android_runtime()?);
|
||||||
|
let no_backup_dir = keystore.no_backup_files_dir()?;
|
||||||
|
Ok(Arc::new(AndroidSecureSecretStore::new(
|
||||||
|
&no_backup_dir,
|
||||||
|
keystore,
|
||||||
|
)?))
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AndroidKeystore for AndroidJniKeystore {
|
||||||
|
fn seal(
|
||||||
|
&self,
|
||||||
|
alias: &str,
|
||||||
|
plaintext: &[u8],
|
||||||
|
) -> Result<AndroidSealedValue, SecureSecretStoreError> {
|
||||||
|
self.with_env(|env| {
|
||||||
|
let key_store = load_key_store(env)?;
|
||||||
|
let alias_string = env
|
||||||
|
.new_string(alias)
|
||||||
|
.map_err(|error| map_jni_error(env, error))?;
|
||||||
|
let alias_object = JObject::from(alias_string);
|
||||||
|
let contains = env
|
||||||
|
.call_method(
|
||||||
|
&key_store,
|
||||||
|
"containsAlias",
|
||||||
|
"(Ljava/lang/String;)Z",
|
||||||
|
&[JValue::Object(&alias_object)],
|
||||||
|
)
|
||||||
|
.map_err(|error| map_jni_error(env, error))?
|
||||||
|
.z()
|
||||||
|
.map_err(|error| map_jni_error(env, error))?;
|
||||||
|
if !contains {
|
||||||
|
generate_key(env, alias)?;
|
||||||
|
}
|
||||||
|
let key = get_key(env, &key_store, alias)?;
|
||||||
|
let cipher = cipher_instance(env)?;
|
||||||
|
env.call_method(
|
||||||
|
&cipher,
|
||||||
|
"init",
|
||||||
|
"(ILjava/security/Key;)V",
|
||||||
|
&[JValue::Int(1), JValue::Object(&key)],
|
||||||
|
)
|
||||||
|
.map_err(|error| map_jni_error(env, error))?;
|
||||||
|
let plaintext = env
|
||||||
|
.byte_array_from_slice(plaintext)
|
||||||
|
.map_err(|error| map_jni_error(env, error))?;
|
||||||
|
let plaintext_object = JObject::from(plaintext);
|
||||||
|
let ciphertext = env
|
||||||
|
.call_method(
|
||||||
|
&cipher,
|
||||||
|
"doFinal",
|
||||||
|
"([B)[B",
|
||||||
|
&[JValue::Object(&plaintext_object)],
|
||||||
|
)
|
||||||
|
.map_err(|error| map_jni_error(env, error))?
|
||||||
|
.l()
|
||||||
|
.map_err(|error| map_jni_error(env, error))?;
|
||||||
|
let nonce = env
|
||||||
|
.call_method(&cipher, "getIV", "()[B", &[])
|
||||||
|
.map_err(|error| map_jni_error(env, error))?
|
||||||
|
.l()
|
||||||
|
.map_err(|error| map_jni_error(env, error))?;
|
||||||
|
Ok(AndroidSealedValue {
|
||||||
|
nonce: env
|
||||||
|
.convert_byte_array(JByteArray::from(nonce))
|
||||||
|
.map_err(|error| map_jni_error(env, error))?,
|
||||||
|
ciphertext: env
|
||||||
|
.convert_byte_array(JByteArray::from(ciphertext))
|
||||||
|
.map_err(|error| map_jni_error(env, error))?,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn open(
|
||||||
|
&self,
|
||||||
|
alias: &str,
|
||||||
|
sealed: &AndroidSealedValue,
|
||||||
|
) -> Result<Vec<u8>, SecureSecretStoreError> {
|
||||||
|
self.with_env(|env| {
|
||||||
|
let key_store = load_key_store(env)?;
|
||||||
|
let key = get_key(env, &key_store, alias)?;
|
||||||
|
let cipher = cipher_instance(env)?;
|
||||||
|
let nonce = env
|
||||||
|
.byte_array_from_slice(&sealed.nonce)
|
||||||
|
.map_err(|error| map_jni_error(env, error))?;
|
||||||
|
let nonce_object = JObject::from(nonce);
|
||||||
|
let parameters = env
|
||||||
|
.new_object(
|
||||||
|
"javax/crypto/spec/GCMParameterSpec",
|
||||||
|
"(I[B)V",
|
||||||
|
&[JValue::Int(128), JValue::Object(&nonce_object)],
|
||||||
|
)
|
||||||
|
.map_err(|error| map_jni_error(env, error))?;
|
||||||
|
env.call_method(
|
||||||
|
&cipher,
|
||||||
|
"init",
|
||||||
|
"(ILjava/security/Key;Ljava/security/spec/AlgorithmParameterSpec;)V",
|
||||||
|
&[
|
||||||
|
JValue::Int(2),
|
||||||
|
JValue::Object(&key),
|
||||||
|
JValue::Object(¶meters),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.map_err(|error| map_jni_error(env, error))?;
|
||||||
|
let ciphertext = env
|
||||||
|
.byte_array_from_slice(&sealed.ciphertext)
|
||||||
|
.map_err(|error| map_jni_error(env, error))?;
|
||||||
|
let ciphertext_object = JObject::from(ciphertext);
|
||||||
|
let plaintext = env
|
||||||
|
.call_method(
|
||||||
|
&cipher,
|
||||||
|
"doFinal",
|
||||||
|
"([B)[B",
|
||||||
|
&[JValue::Object(&ciphertext_object)],
|
||||||
|
)
|
||||||
|
.map_err(|error| map_jni_error(env, error))?
|
||||||
|
.l()
|
||||||
|
.map_err(|error| map_jni_error(env, error))?;
|
||||||
|
env.convert_byte_array(JByteArray::from(plaintext))
|
||||||
|
.map_err(|error| map_jni_error(env, error))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn delete(&self, alias: &str) -> Result<(), SecureSecretStoreError> {
|
||||||
|
self.with_env(|env| {
|
||||||
|
let key_store = load_key_store(env)?;
|
||||||
|
let alias = env
|
||||||
|
.new_string(alias)
|
||||||
|
.map_err(|error| map_jni_error(env, error))?;
|
||||||
|
let alias_object = JObject::from(alias);
|
||||||
|
env.call_method(
|
||||||
|
&key_store,
|
||||||
|
"deleteEntry",
|
||||||
|
"(Ljava/lang/String;)V",
|
||||||
|
&[JValue::Object(&alias_object)],
|
||||||
|
)
|
||||||
|
.map_err(|error| map_jni_error(env, error))?;
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_key_store<'local>(
|
||||||
|
env: &mut JNIEnv<'local>,
|
||||||
|
) -> Result<JObject<'local>, SecureSecretStoreError> {
|
||||||
|
let provider = env
|
||||||
|
.new_string(ANDROID_KEYSTORE)
|
||||||
|
.map_err(|error| map_jni_error(env, error))?;
|
||||||
|
let provider_object = JObject::from(provider);
|
||||||
|
let key_store = env
|
||||||
|
.call_static_method(
|
||||||
|
"java/security/KeyStore",
|
||||||
|
"getInstance",
|
||||||
|
"(Ljava/lang/String;)Ljava/security/KeyStore;",
|
||||||
|
&[JValue::Object(&provider_object)],
|
||||||
|
)
|
||||||
|
.map_err(|error| map_jni_error(env, error))?
|
||||||
|
.l()
|
||||||
|
.map_err(|error| map_jni_error(env, error))?;
|
||||||
|
env.call_method(
|
||||||
|
&key_store,
|
||||||
|
"load",
|
||||||
|
"(Ljava/security/KeyStore$LoadStoreParameter;)V",
|
||||||
|
&[JValue::Object(&JObject::null())],
|
||||||
|
)
|
||||||
|
.map_err(|error| map_jni_error(env, error))?;
|
||||||
|
Ok(key_store)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn generate_key(env: &mut JNIEnv<'_>, alias: &str) -> Result<(), SecureSecretStoreError> {
|
||||||
|
let algorithm = env
|
||||||
|
.new_string(AES)
|
||||||
|
.map_err(|error| map_jni_error(env, error))?;
|
||||||
|
let provider = env
|
||||||
|
.new_string(ANDROID_KEYSTORE)
|
||||||
|
.map_err(|error| map_jni_error(env, error))?;
|
||||||
|
let algorithm_object = JObject::from(algorithm);
|
||||||
|
let provider_object = JObject::from(provider);
|
||||||
|
let generator = env
|
||||||
|
.call_static_method(
|
||||||
|
"javax/crypto/KeyGenerator",
|
||||||
|
"getInstance",
|
||||||
|
"(Ljava/lang/String;Ljava/lang/String;)Ljavax/crypto/KeyGenerator;",
|
||||||
|
&[
|
||||||
|
JValue::Object(&algorithm_object),
|
||||||
|
JValue::Object(&provider_object),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.map_err(|error| map_jni_error(env, error))?
|
||||||
|
.l()
|
||||||
|
.map_err(|error| map_jni_error(env, error))?;
|
||||||
|
let alias = env
|
||||||
|
.new_string(alias)
|
||||||
|
.map_err(|error| map_jni_error(env, error))?;
|
||||||
|
let alias_object = JObject::from(alias);
|
||||||
|
let builder = env
|
||||||
|
.new_object(
|
||||||
|
"android/security/keystore/KeyGenParameterSpec$Builder",
|
||||||
|
"(Ljava/lang/String;I)V",
|
||||||
|
&[JValue::Object(&alias_object), JValue::Int(3)],
|
||||||
|
)
|
||||||
|
.map_err(|error| map_jni_error(env, error))?;
|
||||||
|
let modes = java_string_array(env, "GCM")?;
|
||||||
|
env.call_method(
|
||||||
|
&builder,
|
||||||
|
"setBlockModes",
|
||||||
|
"([Ljava/lang/String;)Landroid/security/keystore/KeyGenParameterSpec$Builder;",
|
||||||
|
&[JValue::Object(&modes)],
|
||||||
|
)
|
||||||
|
.map_err(|error| map_jni_error(env, error))?;
|
||||||
|
let paddings = java_string_array(env, "NoPadding")?;
|
||||||
|
env.call_method(
|
||||||
|
&builder,
|
||||||
|
"setEncryptionPaddings",
|
||||||
|
"([Ljava/lang/String;)Landroid/security/keystore/KeyGenParameterSpec$Builder;",
|
||||||
|
&[JValue::Object(&paddings)],
|
||||||
|
)
|
||||||
|
.map_err(|error| map_jni_error(env, error))?;
|
||||||
|
env.call_method(
|
||||||
|
&builder,
|
||||||
|
"setKeySize",
|
||||||
|
"(I)Landroid/security/keystore/KeyGenParameterSpec$Builder;",
|
||||||
|
&[JValue::Int(256)],
|
||||||
|
)
|
||||||
|
.map_err(|error| map_jni_error(env, error))?;
|
||||||
|
env.call_method(
|
||||||
|
&builder,
|
||||||
|
"setRandomizedEncryptionRequired",
|
||||||
|
"(Z)Landroid/security/keystore/KeyGenParameterSpec$Builder;",
|
||||||
|
&[JValue::Bool(1)],
|
||||||
|
)
|
||||||
|
.map_err(|error| map_jni_error(env, error))?;
|
||||||
|
let parameters = env
|
||||||
|
.call_method(
|
||||||
|
&builder,
|
||||||
|
"build",
|
||||||
|
"()Landroid/security/keystore/KeyGenParameterSpec;",
|
||||||
|
&[],
|
||||||
|
)
|
||||||
|
.map_err(|error| map_jni_error(env, error))?
|
||||||
|
.l()
|
||||||
|
.map_err(|error| map_jni_error(env, error))?;
|
||||||
|
env.call_method(
|
||||||
|
&generator,
|
||||||
|
"init",
|
||||||
|
"(Ljava/security/spec/AlgorithmParameterSpec;)V",
|
||||||
|
&[JValue::Object(¶meters)],
|
||||||
|
)
|
||||||
|
.map_err(|error| map_jni_error(env, error))?;
|
||||||
|
env.call_method(&generator, "generateKey", "()Ljavax/crypto/SecretKey;", &[])
|
||||||
|
.map_err(|error| map_jni_error(env, error))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_key<'local>(
|
||||||
|
env: &mut JNIEnv<'local>,
|
||||||
|
key_store: &JObject<'local>,
|
||||||
|
alias: &str,
|
||||||
|
) -> Result<JObject<'local>, SecureSecretStoreError> {
|
||||||
|
let alias = env
|
||||||
|
.new_string(alias)
|
||||||
|
.map_err(|error| map_jni_error(env, error))?;
|
||||||
|
let alias_object = JObject::from(alias);
|
||||||
|
let key = env
|
||||||
|
.call_method(
|
||||||
|
key_store,
|
||||||
|
"getKey",
|
||||||
|
"(Ljava/lang/String;[C)Ljava/security/Key;",
|
||||||
|
&[
|
||||||
|
JValue::Object(&alias_object),
|
||||||
|
JValue::Object(&JObject::null()),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.map_err(|error| map_jni_error(env, error))?
|
||||||
|
.l()
|
||||||
|
.map_err(|error| map_jni_error(env, error))?;
|
||||||
|
if key.is_null() {
|
||||||
|
return Err(SecureSecretStoreError::Missing);
|
||||||
|
}
|
||||||
|
Ok(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cipher_instance<'local>(
|
||||||
|
env: &mut JNIEnv<'local>,
|
||||||
|
) -> Result<JObject<'local>, SecureSecretStoreError> {
|
||||||
|
let transformation = env
|
||||||
|
.new_string(TRANSFORMATION)
|
||||||
|
.map_err(|error| map_jni_error(env, error))?;
|
||||||
|
let transformation_object = JObject::from(transformation);
|
||||||
|
env.call_static_method(
|
||||||
|
"javax/crypto/Cipher",
|
||||||
|
"getInstance",
|
||||||
|
"(Ljava/lang/String;)Ljavax/crypto/Cipher;",
|
||||||
|
&[JValue::Object(&transformation_object)],
|
||||||
|
)
|
||||||
|
.map_err(|error| map_jni_error(env, error))?
|
||||||
|
.l()
|
||||||
|
.map_err(|error| map_jni_error(env, error))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn java_string_array<'local>(
|
||||||
|
env: &mut JNIEnv<'local>,
|
||||||
|
value: &str,
|
||||||
|
) -> Result<JObject<'local>, SecureSecretStoreError> {
|
||||||
|
let class = env
|
||||||
|
.find_class("java/lang/String")
|
||||||
|
.map_err(|error| map_jni_error(env, error))?;
|
||||||
|
let array = env
|
||||||
|
.new_object_array(1, class, JObject::null())
|
||||||
|
.map_err(|error| map_jni_error(env, error))?;
|
||||||
|
let value = env
|
||||||
|
.new_string(value)
|
||||||
|
.map_err(|error| map_jni_error(env, error))?;
|
||||||
|
env.set_object_array_element(&array, 0, value)
|
||||||
|
.map_err(|error| map_jni_error(env, error))?;
|
||||||
|
Ok(JObject::from(array))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn map_jni_error(env: &mut JNIEnv<'_>, _error: JniError) -> SecureSecretStoreError {
|
||||||
|
let has_exception = env.exception_check().unwrap_or(false);
|
||||||
|
if !has_exception {
|
||||||
|
return SecureSecretStoreError::Unavailable;
|
||||||
|
}
|
||||||
|
let exception = match env.exception_occurred() {
|
||||||
|
Ok(exception) => exception,
|
||||||
|
Err(_) => return SecureSecretStoreError::Unavailable,
|
||||||
|
};
|
||||||
|
let _ = env.exception_clear();
|
||||||
|
if is_instance_of(
|
||||||
|
env,
|
||||||
|
&exception,
|
||||||
|
"android/security/keystore/UserNotAuthenticatedException",
|
||||||
|
) {
|
||||||
|
SecureSecretStoreError::Locked
|
||||||
|
} else if is_instance_of(
|
||||||
|
env,
|
||||||
|
&exception,
|
||||||
|
"android/security/keystore/KeyPermanentlyInvalidatedException",
|
||||||
|
) || is_instance_of(env, &exception, "javax/crypto/AEADBadTagException")
|
||||||
|
|| is_instance_of(env, &exception, "javax/crypto/BadPaddingException")
|
||||||
|
{
|
||||||
|
SecureSecretStoreError::Corrupted
|
||||||
|
} else if is_instance_of(env, &exception, "java/security/UnrecoverableKeyException") {
|
||||||
|
SecureSecretStoreError::Missing
|
||||||
|
} else {
|
||||||
|
SecureSecretStoreError::Unavailable
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_instance_of(env: &mut JNIEnv<'_>, object: &JObject<'_>, class: &str) -> bool {
|
||||||
|
env.is_instance_of(object, class).unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn local_ref_from_process_context<'local>(
|
||||||
|
env: &mut JNIEnv<'local>,
|
||||||
|
context: jni::sys::jobject,
|
||||||
|
) -> Result<JObject<'local>, SecureSecretStoreError> {
|
||||||
|
let interface = env.get_native_interface();
|
||||||
|
// ndk-context retains a process-wide global Context reference. JNI NewLocalRef
|
||||||
|
// is required before representing it as a frame-bound JObject.
|
||||||
|
let context = unsafe {
|
||||||
|
let new_local_ref = (**interface)
|
||||||
|
.NewLocalRef
|
||||||
|
.ok_or(SecureSecretStoreError::Unavailable)?;
|
||||||
|
new_local_ref(interface, context)
|
||||||
|
};
|
||||||
|
if context.is_null() {
|
||||||
|
return Err(SecureSecretStoreError::Unavailable);
|
||||||
|
}
|
||||||
|
// NewLocalRef created this reference in the currently attached JNI frame.
|
||||||
|
Ok(unsafe { JObject::from_raw(context) })
|
||||||
|
}
|
||||||
207
crates/vnidrop/src/secure_secret/apple.rs
Normal file
@@ -0,0 +1,207 @@
|
|||||||
|
use super::{SecretHandle, SecretMaterial, SecureSecretStore, SecureSecretStoreError};
|
||||||
|
use security_framework::{
|
||||||
|
access_control::{ProtectionMode, SecAccessControl},
|
||||||
|
item::{ItemClass, ItemSearchOptions, Limit, SearchResult},
|
||||||
|
passwords::{
|
||||||
|
delete_generic_password_options, generic_password, set_generic_password_options,
|
||||||
|
PasswordOptions,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
const SERVICE: &str = "com.vnidrop.secure-secrets.v1";
|
||||||
|
const ACCOUNT_ATTRIBUTE: &str = "acct";
|
||||||
|
const ERR_SEC_PARAM: i32 = -50;
|
||||||
|
const ERR_SEC_AUTH_FAILED: i32 = -25_293;
|
||||||
|
const ERR_SEC_NOT_AVAILABLE: i32 = -25_291;
|
||||||
|
const ERR_SEC_ITEM_NOT_FOUND: i32 = -25_300;
|
||||||
|
const ERR_SEC_INTERACTION_NOT_ALLOWED: i32 = -25_308;
|
||||||
|
const ERR_SEC_DECODE: i32 = -26_275;
|
||||||
|
const ERR_SEC_MISSING_ENTITLEMENT: i32 = -34_018;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
enum AppleAccessibility {
|
||||||
|
AfterFirstUnlockThisDeviceOnly,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub(crate) struct AppleKeychainPolicy {
|
||||||
|
accessibility: AppleAccessibility,
|
||||||
|
synchronizable: bool,
|
||||||
|
data_protection_keychain: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for AppleKeychainPolicy {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
accessibility: AppleAccessibility::AfterFirstUnlockThisDeviceOnly,
|
||||||
|
synchronizable: false,
|
||||||
|
data_protection_keychain: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) trait AppleKeychainApi: Send + Sync {
|
||||||
|
fn put(
|
||||||
|
&self,
|
||||||
|
service: &str,
|
||||||
|
account: &str,
|
||||||
|
material: &[u8],
|
||||||
|
policy: AppleKeychainPolicy,
|
||||||
|
) -> Result<(), i32>;
|
||||||
|
fn get(&self, service: &str, account: &str) -> Result<Vec<u8>, i32>;
|
||||||
|
fn delete(&self, service: &str, account: &str) -> Result<(), i32>;
|
||||||
|
fn list_accounts(&self, service: &str) -> Result<Vec<String>, i32>;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct SystemAppleKeychain;
|
||||||
|
|
||||||
|
impl SystemAppleKeychain {
|
||||||
|
fn options(service: &str, account: &str) -> PasswordOptions {
|
||||||
|
let mut options = PasswordOptions::new_generic_password(service, account);
|
||||||
|
options.set_access_synchronized(Some(false));
|
||||||
|
options.use_protected_keychain();
|
||||||
|
options
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AppleKeychainApi for SystemAppleKeychain {
|
||||||
|
fn put(
|
||||||
|
&self,
|
||||||
|
service: &str,
|
||||||
|
account: &str,
|
||||||
|
material: &[u8],
|
||||||
|
policy: AppleKeychainPolicy,
|
||||||
|
) -> Result<(), i32> {
|
||||||
|
debug_assert_eq!(policy, AppleKeychainPolicy::default());
|
||||||
|
let access_control = SecAccessControl::create_with_protection(
|
||||||
|
Some(ProtectionMode::AccessibleAfterFirstUnlockThisDeviceOnly),
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
.map_err(|error| error.code())?;
|
||||||
|
let mut options = Self::options(service, account);
|
||||||
|
options.set_access_control(access_control);
|
||||||
|
set_generic_password_options(material, options).map_err(|error| error.code())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get(&self, service: &str, account: &str) -> Result<Vec<u8>, i32> {
|
||||||
|
generic_password(Self::options(service, account)).map_err(|error| error.code())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn delete(&self, service: &str, account: &str) -> Result<(), i32> {
|
||||||
|
delete_generic_password_options(Self::options(service, account))
|
||||||
|
.map_err(|error| error.code())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn list_accounts(&self, service: &str) -> Result<Vec<String>, i32> {
|
||||||
|
let mut options = ItemSearchOptions::new();
|
||||||
|
options
|
||||||
|
.class(ItemClass::generic_password())
|
||||||
|
.service(service)
|
||||||
|
.cloud_sync(Some(false))
|
||||||
|
.load_attributes(true)
|
||||||
|
.limit(Limit::All);
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
options.ignore_legacy_keychains();
|
||||||
|
|
||||||
|
let results = match options.search() {
|
||||||
|
Ok(results) => results,
|
||||||
|
Err(error) if error.code() == ERR_SEC_ITEM_NOT_FOUND => return Ok(Vec::new()),
|
||||||
|
Err(error) => return Err(error.code()),
|
||||||
|
};
|
||||||
|
results
|
||||||
|
.into_iter()
|
||||||
|
.map(|result| match result {
|
||||||
|
SearchResult::Dict(_) => result
|
||||||
|
.simplify_dict()
|
||||||
|
.and_then(|attributes| attributes.get(ACCOUNT_ATTRIBUTE).cloned())
|
||||||
|
.ok_or(ERR_SEC_DECODE),
|
||||||
|
_ => Err(ERR_SEC_DECODE),
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stores VniDrop's protected material in Apple's device-local data-protection Keychain.
|
||||||
|
pub(crate) struct AppleKeychainSecretStore {
|
||||||
|
api: Arc<dyn AppleKeychainApi>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AppleKeychainSecretStore {
|
||||||
|
pub(crate) fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
api: Arc::new(SystemAppleKeychain),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(crate) fn with_api(api: impl AppleKeychainApi + 'static) -> Self {
|
||||||
|
Self { api: Arc::new(api) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SecureSecretStore for AppleKeychainSecretStore {
|
||||||
|
fn put(
|
||||||
|
&self,
|
||||||
|
handle: &SecretHandle,
|
||||||
|
material: SecretMaterial,
|
||||||
|
) -> Result<(), SecureSecretStoreError> {
|
||||||
|
self.api
|
||||||
|
.put(
|
||||||
|
SERVICE,
|
||||||
|
handle.as_str(),
|
||||||
|
&material.0,
|
||||||
|
AppleKeychainPolicy::default(),
|
||||||
|
)
|
||||||
|
.map_err(map_status)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get(&self, handle: &SecretHandle) -> Result<SecretMaterial, SecureSecretStoreError> {
|
||||||
|
let material = self.api.get(SERVICE, handle.as_str()).map_err(map_status)?;
|
||||||
|
SecretMaterial::new(material).map_err(|_| SecureSecretStoreError::Corrupted)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn delete(&self, handle: &SecretHandle) -> Result<(), SecureSecretStoreError> {
|
||||||
|
self.api
|
||||||
|
.delete(SERVICE, handle.as_str())
|
||||||
|
.map_err(map_status)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn list_handles(&self) -> Result<Vec<SecretHandle>, SecureSecretStoreError> {
|
||||||
|
self.api
|
||||||
|
.list_accounts(SERVICE)
|
||||||
|
.map(|accounts| accounts.into_iter().map(SecretHandle).collect())
|
||||||
|
.map_err(map_status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn map_status(status: i32) -> SecureSecretStoreError {
|
||||||
|
match status {
|
||||||
|
ERR_SEC_ITEM_NOT_FOUND => SecureSecretStoreError::Missing,
|
||||||
|
ERR_SEC_INTERACTION_NOT_ALLOWED | ERR_SEC_AUTH_FAILED => SecureSecretStoreError::Locked,
|
||||||
|
ERR_SEC_DECODE | ERR_SEC_PARAM => SecureSecretStoreError::Corrupted,
|
||||||
|
ERR_SEC_NOT_AVAILABLE | ERR_SEC_MISSING_ENTITLEMENT => SecureSecretStoreError::Unavailable,
|
||||||
|
_ => SecureSecretStoreError::Unavailable,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(crate) fn expected_policy_for_test() -> AppleKeychainPolicy {
|
||||||
|
AppleKeychainPolicy::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(crate) fn service_for_test() -> &'static str {
|
||||||
|
SERVICE
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(crate) fn handle_for_test(value: &str) -> SecretHandle {
|
||||||
|
SecretHandle(value.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(crate) fn map_status_for_test(status: i32) -> SecureSecretStoreError {
|
||||||
|
map_status(status)
|
||||||
|
}
|
||||||
181
crates/vnidrop/src/secure_secret/linux.rs
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
use std::{collections::HashMap, sync::Arc};
|
||||||
|
|
||||||
|
use secret_service::{blocking::SecretService, EncryptionType, Error};
|
||||||
|
|
||||||
|
use super::{
|
||||||
|
SecretHandle, SecretMaterial, SecureSecretStore, SecureSecretStoreError, HANDLE_NAMESPACE,
|
||||||
|
HANDLE_VERSION,
|
||||||
|
};
|
||||||
|
|
||||||
|
const ATTRIBUTE_APPLICATION: &str = "application";
|
||||||
|
const ATTRIBUTE_HANDLE: &str = "vnidrop-handle";
|
||||||
|
const APPLICATION_ID: &str = "com.vnidrop.VniDrop";
|
||||||
|
const ITEM_LABEL: &str = "VniDrop protected secret";
|
||||||
|
|
||||||
|
pub(crate) trait LinuxSecretServiceApi: Send + Sync {
|
||||||
|
fn put(&self, handle: &str, material: &[u8]) -> Result<(), SecureSecretStoreError>;
|
||||||
|
fn get(&self, handle: &str) -> Result<Vec<u8>, SecureSecretStoreError>;
|
||||||
|
fn delete(&self, handle: &str) -> Result<(), SecureSecretStoreError>;
|
||||||
|
fn list_handles(&self) -> Result<Vec<String>, SecureSecretStoreError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
struct SystemLinuxSecretService;
|
||||||
|
|
||||||
|
impl SystemLinuxSecretService {
|
||||||
|
fn connect() -> Result<Self, SecureSecretStoreError> {
|
||||||
|
SecretService::connect(EncryptionType::Dh).map_err(map_error)?;
|
||||||
|
Ok(Self)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn service(&self) -> Result<SecretService<'_>, SecureSecretStoreError> {
|
||||||
|
SecretService::connect(EncryptionType::Dh).map_err(map_error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LinuxSecretServiceApi for SystemLinuxSecretService {
|
||||||
|
fn put(&self, handle: &str, material: &[u8]) -> Result<(), SecureSecretStoreError> {
|
||||||
|
let service = self.service()?;
|
||||||
|
let collection = service.get_default_collection().map_err(map_error)?;
|
||||||
|
if collection.is_locked().map_err(map_error)? {
|
||||||
|
return Err(SecureSecretStoreError::Locked);
|
||||||
|
}
|
||||||
|
collection
|
||||||
|
.create_item(
|
||||||
|
ITEM_LABEL,
|
||||||
|
HashMap::from([
|
||||||
|
(ATTRIBUTE_APPLICATION, APPLICATION_ID),
|
||||||
|
(ATTRIBUTE_HANDLE, handle),
|
||||||
|
]),
|
||||||
|
material,
|
||||||
|
true,
|
||||||
|
"application/octet-stream",
|
||||||
|
)
|
||||||
|
.map_err(map_error)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get(&self, handle: &str) -> Result<Vec<u8>, SecureSecretStoreError> {
|
||||||
|
let service = self.service()?;
|
||||||
|
let result = service
|
||||||
|
.search_items(HashMap::from([
|
||||||
|
(ATTRIBUTE_APPLICATION, APPLICATION_ID),
|
||||||
|
(ATTRIBUTE_HANDLE, handle),
|
||||||
|
]))
|
||||||
|
.map_err(map_error)?;
|
||||||
|
if !result.locked.is_empty() {
|
||||||
|
return Err(SecureSecretStoreError::Locked);
|
||||||
|
}
|
||||||
|
let mut items = result.unlocked.into_iter();
|
||||||
|
let item = items.next().ok_or(SecureSecretStoreError::Missing)?;
|
||||||
|
if items.next().is_some() {
|
||||||
|
return Err(SecureSecretStoreError::Corrupted);
|
||||||
|
}
|
||||||
|
item.get_secret().map_err(map_error)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn delete(&self, handle: &str) -> Result<(), SecureSecretStoreError> {
|
||||||
|
let service = self.service()?;
|
||||||
|
let result = service
|
||||||
|
.search_items(HashMap::from([
|
||||||
|
(ATTRIBUTE_APPLICATION, APPLICATION_ID),
|
||||||
|
(ATTRIBUTE_HANDLE, handle),
|
||||||
|
]))
|
||||||
|
.map_err(map_error)?;
|
||||||
|
if !result.locked.is_empty() {
|
||||||
|
return Err(SecureSecretStoreError::Locked);
|
||||||
|
}
|
||||||
|
if result.unlocked.is_empty() {
|
||||||
|
return Err(SecureSecretStoreError::Missing);
|
||||||
|
}
|
||||||
|
for item in result.unlocked {
|
||||||
|
item.delete().map_err(map_error)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn list_handles(&self) -> Result<Vec<String>, SecureSecretStoreError> {
|
||||||
|
let service = self.service()?;
|
||||||
|
let result = service
|
||||||
|
.search_items(HashMap::from([(ATTRIBUTE_APPLICATION, APPLICATION_ID)]))
|
||||||
|
.map_err(map_error)?;
|
||||||
|
if !result.locked.is_empty() {
|
||||||
|
return Err(SecureSecretStoreError::Locked);
|
||||||
|
}
|
||||||
|
result
|
||||||
|
.unlocked
|
||||||
|
.into_iter()
|
||||||
|
.map(|item| {
|
||||||
|
item.get_attributes()
|
||||||
|
.map_err(map_error)?
|
||||||
|
.remove(ATTRIBUTE_HANDLE)
|
||||||
|
.ok_or(SecureSecretStoreError::Corrupted)
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) struct LinuxSecretServiceStore {
|
||||||
|
api: Arc<dyn LinuxSecretServiceApi>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LinuxSecretServiceStore {
|
||||||
|
pub(super) fn connect() -> Result<Self, SecureSecretStoreError> {
|
||||||
|
Ok(Self {
|
||||||
|
api: Arc::new(SystemLinuxSecretService::connect()?),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(crate) fn with_api(api: Arc<dyn LinuxSecretServiceApi>) -> Self {
|
||||||
|
Self { api }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SecureSecretStore for LinuxSecretServiceStore {
|
||||||
|
fn put(
|
||||||
|
&self,
|
||||||
|
handle: &SecretHandle,
|
||||||
|
material: SecretMaterial,
|
||||||
|
) -> Result<(), SecureSecretStoreError> {
|
||||||
|
self.api.put(handle.as_str(), &material.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get(&self, handle: &SecretHandle) -> Result<SecretMaterial, SecureSecretStoreError> {
|
||||||
|
let bytes = self.api.get(handle.as_str())?;
|
||||||
|
SecretMaterial::new(bytes).map_err(|_| SecureSecretStoreError::Corrupted)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn delete(&self, handle: &SecretHandle) -> Result<(), SecureSecretStoreError> {
|
||||||
|
self.api.delete(handle.as_str())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn list_handles(&self) -> Result<Vec<SecretHandle>, SecureSecretStoreError> {
|
||||||
|
let expected_prefix = format!("{HANDLE_NAMESPACE}/{HANDLE_VERSION}/");
|
||||||
|
let mut handles = self
|
||||||
|
.api
|
||||||
|
.list_handles()?
|
||||||
|
.into_iter()
|
||||||
|
.map(|handle| {
|
||||||
|
if handle.starts_with(&expected_prefix) {
|
||||||
|
Ok(SecretHandle(handle))
|
||||||
|
} else {
|
||||||
|
Err(SecureSecretStoreError::Corrupted)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect::<Result<Vec<_>, _>>()?;
|
||||||
|
handles.sort_by(|left, right| left.as_str().cmp(right.as_str()));
|
||||||
|
Ok(handles)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn map_error(error: Error) -> SecureSecretStoreError {
|
||||||
|
match error {
|
||||||
|
Error::Locked | Error::Prompt => SecureSecretStoreError::Locked,
|
||||||
|
Error::NoResult => SecureSecretStoreError::Missing,
|
||||||
|
Error::Crypto(_) => SecureSecretStoreError::Corrupted,
|
||||||
|
Error::Unavailable | Error::Zvariant(_) | Error::Zbus(_) | Error::ZbusFdo(_) => {
|
||||||
|
SecureSecretStoreError::Unavailable
|
||||||
|
}
|
||||||
|
_ => SecureSecretStoreError::Unavailable,
|
||||||
|
}
|
||||||
|
}
|
||||||
138
crates/vnidrop/src/secure_secret/platform.rs
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
use std::{
|
||||||
|
fs::{File, OpenOptions},
|
||||||
|
path::Path,
|
||||||
|
sync::Arc,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[cfg(any(target_os = "android", target_os = "windows", target_os = "linux"))]
|
||||||
|
use super::map_store_error;
|
||||||
|
use super::{
|
||||||
|
SecretHandle, SecretMaterial, SecureSecretStore, SecureSecretStoreError, HANDLE_NAMESPACE,
|
||||||
|
HANDLE_VERSION,
|
||||||
|
};
|
||||||
|
use crate::error::VnidropError;
|
||||||
|
|
||||||
|
struct ScopedSecretStore {
|
||||||
|
inner: Arc<dyn SecureSecretStore>,
|
||||||
|
physical_prefix: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) struct ProfileLock {
|
||||||
|
_file: File,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn lock_profile(app_data_dir: &Path) -> Result<ProfileLock, VnidropError> {
|
||||||
|
let file = OpenOptions::new()
|
||||||
|
.create(true)
|
||||||
|
.truncate(false)
|
||||||
|
.read(true)
|
||||||
|
.write(true)
|
||||||
|
.open(app_data_dir.join("protected-secrets.lock"))
|
||||||
|
.map_err(VnidropError::filesystem)?;
|
||||||
|
file.try_lock()
|
||||||
|
.map_err(|_| VnidropError::SecureStorageUnavailable {
|
||||||
|
reason: "another protected core is already using this profile".to_string(),
|
||||||
|
})?;
|
||||||
|
Ok(ProfileLock { _file: file })
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ScopedSecretStore {
|
||||||
|
fn new(app_data_dir: &Path, inner: Arc<dyn SecureSecretStore>) -> Self {
|
||||||
|
let profile = blake3::hash(app_data_dir.to_string_lossy().as_bytes()).to_hex();
|
||||||
|
Self {
|
||||||
|
inner,
|
||||||
|
physical_prefix: format!("{HANDLE_NAMESPACE}/{HANDLE_VERSION}/scope-{profile}/"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn physical_handle(
|
||||||
|
&self,
|
||||||
|
handle: &SecretHandle,
|
||||||
|
) -> Result<SecretHandle, SecureSecretStoreError> {
|
||||||
|
let logical_prefix = format!("{HANDLE_NAMESPACE}/{HANDLE_VERSION}/");
|
||||||
|
let suffix = handle
|
||||||
|
.as_str()
|
||||||
|
.strip_prefix(&logical_prefix)
|
||||||
|
.ok_or(SecureSecretStoreError::Corrupted)?;
|
||||||
|
Ok(SecretHandle(format!("{}{suffix}", self.physical_prefix)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SecureSecretStore for ScopedSecretStore {
|
||||||
|
fn put(
|
||||||
|
&self,
|
||||||
|
handle: &SecretHandle,
|
||||||
|
material: SecretMaterial,
|
||||||
|
) -> Result<(), SecureSecretStoreError> {
|
||||||
|
self.inner.put(&self.physical_handle(handle)?, material)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get(&self, handle: &SecretHandle) -> Result<SecretMaterial, SecureSecretStoreError> {
|
||||||
|
self.inner.get(&self.physical_handle(handle)?)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn delete(&self, handle: &SecretHandle) -> Result<(), SecureSecretStoreError> {
|
||||||
|
self.inner.delete(&self.physical_handle(handle)?)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn list_handles(&self) -> Result<Vec<SecretHandle>, SecureSecretStoreError> {
|
||||||
|
let handles = self
|
||||||
|
.inner
|
||||||
|
.list_handles()?
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|handle| {
|
||||||
|
handle
|
||||||
|
.as_str()
|
||||||
|
.strip_prefix(&self.physical_prefix)
|
||||||
|
.map(|suffix| {
|
||||||
|
SecretHandle(format!("{HANDLE_NAMESPACE}/{HANDLE_VERSION}/{suffix}"))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
Ok(handles)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn scope_store(
|
||||||
|
app_data_dir: &Path,
|
||||||
|
store: Arc<dyn SecureSecretStore>,
|
||||||
|
) -> Arc<dyn SecureSecretStore> {
|
||||||
|
Arc::new(ScopedSecretStore::new(app_data_dir, store))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(any(target_os = "macos", target_os = "ios"))]
|
||||||
|
pub(crate) fn platform_secret_store(
|
||||||
|
app_data_dir: &Path,
|
||||||
|
) -> Result<Arc<dyn SecureSecretStore>, VnidropError> {
|
||||||
|
Ok(scope_store(
|
||||||
|
app_data_dir,
|
||||||
|
Arc::new(super::apple::AppleKeychainSecretStore::new()),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
pub(crate) fn platform_secret_store(
|
||||||
|
app_data_dir: &Path,
|
||||||
|
) -> Result<Arc<dyn SecureSecretStore>, VnidropError> {
|
||||||
|
super::android::native::create_store_from_android_runtime()
|
||||||
|
.map(|store| scope_store(app_data_dir, store))
|
||||||
|
.map_err(map_store_error)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
pub(crate) fn platform_secret_store(
|
||||||
|
app_data_dir: &Path,
|
||||||
|
) -> Result<Arc<dyn SecureSecretStore>, VnidropError> {
|
||||||
|
super::windows::WindowsDpapiSecretStore::new(app_data_dir.join("protected-secrets-v1"))
|
||||||
|
.map(|store| scope_store(app_data_dir, Arc::new(store)))
|
||||||
|
.map_err(map_store_error)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
pub(crate) fn platform_secret_store(
|
||||||
|
app_data_dir: &Path,
|
||||||
|
) -> Result<Arc<dyn SecureSecretStore>, VnidropError> {
|
||||||
|
super::linux::LinuxSecretServiceStore::connect()
|
||||||
|
.map(|store| scope_store(app_data_dir, Arc::new(store)))
|
||||||
|
.map_err(map_store_error)
|
||||||
|
}
|
||||||
473
crates/vnidrop/src/secure_secret/windows.rs
Normal file
@@ -0,0 +1,473 @@
|
|||||||
|
use std::{
|
||||||
|
collections::HashSet,
|
||||||
|
ffi::OsStr,
|
||||||
|
fs::{self, OpenOptions},
|
||||||
|
io::{self, Write},
|
||||||
|
os::windows::ffi::OsStrExt,
|
||||||
|
path::{Path, PathBuf},
|
||||||
|
ptr,
|
||||||
|
sync::Arc,
|
||||||
|
};
|
||||||
|
|
||||||
|
use data_encoding::HEXLOWER;
|
||||||
|
use windows_sys::Win32::{
|
||||||
|
Foundation::{
|
||||||
|
GetLastError, LocalFree, ERROR_ACCESS_DENIED, ERROR_ALREADY_EXISTS,
|
||||||
|
ERROR_CALL_NOT_IMPLEMENTED, ERROR_FILE_EXISTS, ERROR_NOT_SUPPORTED,
|
||||||
|
ERROR_PASSWORD_RESTRICTION,
|
||||||
|
},
|
||||||
|
Security::Cryptography::{
|
||||||
|
CryptProtectData, CryptUnprotectData, CRYPTPROTECT_UI_FORBIDDEN, CRYPT_INTEGER_BLOB,
|
||||||
|
},
|
||||||
|
Storage::FileSystem::{MoveFileExW, MOVEFILE_REPLACE_EXISTING, MOVEFILE_WRITE_THROUGH},
|
||||||
|
};
|
||||||
|
|
||||||
|
use super::{SecretHandle, SecretMaterial, SecureSecretStore, SecureSecretStoreError};
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
use super::SecretKind;
|
||||||
|
|
||||||
|
const ENVELOPE_MAGIC: &[u8; 8] = b"VNIDPAPI";
|
||||||
|
const ENVELOPE_VERSION: u8 = 1;
|
||||||
|
const FILE_EXTENSION: &str = "dpapi";
|
||||||
|
const MAX_ENVELOPE_BYTES: usize = 64 * 1024;
|
||||||
|
const DEFAULT_CONTEXT: &[u8] = b"com.vnidrop.secure-secret.dpapi.v1.current-user";
|
||||||
|
const PROTECTED_PAYLOAD_MAGIC: &[u8] = b"VNIDROP-SECRET-V1";
|
||||||
|
|
||||||
|
/// Current-user DPAPI storage backed by atomically published protected blobs.
|
||||||
|
pub(crate) struct WindowsDpapiSecretStore {
|
||||||
|
directory: PathBuf,
|
||||||
|
protector: Arc<DpapiProtector>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WindowsDpapiSecretStore {
|
||||||
|
pub(crate) fn new(directory: impl AsRef<Path>) -> Result<Self, SecureSecretStoreError> {
|
||||||
|
Self::with_protector(directory, Arc::new(DpapiProtector::new()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn with_protector(
|
||||||
|
directory: impl AsRef<Path>,
|
||||||
|
protector: Arc<DpapiProtector>,
|
||||||
|
) -> Result<Self, SecureSecretStoreError> {
|
||||||
|
let directory = directory.as_ref().to_path_buf();
|
||||||
|
fs::create_dir_all(&directory).map_err(map_io_error)?;
|
||||||
|
cleanup_interrupted_writes(&directory)?;
|
||||||
|
Ok(Self {
|
||||||
|
directory,
|
||||||
|
protector,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn path_for(&self, handle: &SecretHandle) -> PathBuf {
|
||||||
|
let digest = blake3::hash(handle.as_str().as_bytes());
|
||||||
|
self.directory.join(format!(
|
||||||
|
"{}.{}",
|
||||||
|
HEXLOWER.encode(digest.as_bytes()),
|
||||||
|
FILE_EXTENSION
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(crate) fn with_context_for_test(
|
||||||
|
directory: impl AsRef<Path>,
|
||||||
|
context: &[u8],
|
||||||
|
) -> Result<Self, SecureSecretStoreError> {
|
||||||
|
Self::with_protector(
|
||||||
|
directory,
|
||||||
|
Arc::new(DpapiProtector::with_context_for_test(context)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(crate) fn path_for_test(&self, handle: &SecretHandle) -> PathBuf {
|
||||||
|
self.path_for(handle)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(crate) fn relationship_handle_for_test() -> SecretHandle {
|
||||||
|
SecretHandle::generate(SecretKind::RelationshipGrant)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SecureSecretStore for WindowsDpapiSecretStore {
|
||||||
|
fn put(
|
||||||
|
&self,
|
||||||
|
handle: &SecretHandle,
|
||||||
|
material: SecretMaterial,
|
||||||
|
) -> Result<(), SecureSecretStoreError> {
|
||||||
|
let destination = self.path_for(handle);
|
||||||
|
let replace_existing = match self.get(handle) {
|
||||||
|
Ok(existing) if existing == material => return Ok(()),
|
||||||
|
Ok(_) => true,
|
||||||
|
Err(SecureSecretStoreError::Missing) => false,
|
||||||
|
Err(error) => return Err(error),
|
||||||
|
};
|
||||||
|
|
||||||
|
let ciphertext = self.protector.protect(handle, &material.0)?;
|
||||||
|
let envelope = encode_envelope(handle, &ciphertext)?;
|
||||||
|
let temporary = self.directory.join(format!(
|
||||||
|
"{}.tmp-{}",
|
||||||
|
destination
|
||||||
|
.file_stem()
|
||||||
|
.and_then(OsStr::to_str)
|
||||||
|
.ok_or(SecureSecretStoreError::Unavailable)?,
|
||||||
|
uuid::Uuid::new_v4()
|
||||||
|
));
|
||||||
|
let mut temporary_guard = TemporaryFile::new(temporary);
|
||||||
|
let mut file = OpenOptions::new()
|
||||||
|
.write(true)
|
||||||
|
.create_new(true)
|
||||||
|
.open(temporary_guard.path())
|
||||||
|
.map_err(map_io_error)?;
|
||||||
|
file.write_all(&envelope).map_err(map_io_error)?;
|
||||||
|
file.sync_all().map_err(map_io_error)?;
|
||||||
|
drop(file);
|
||||||
|
|
||||||
|
match move_write_through(temporary_guard.path(), &destination, replace_existing) {
|
||||||
|
Ok(()) => {
|
||||||
|
temporary_guard.disarm();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
Err(error)
|
||||||
|
if matches!(
|
||||||
|
error.raw_os_error().map(|code| code as u32),
|
||||||
|
Some(ERROR_ALREADY_EXISTS) | Some(ERROR_FILE_EXISTS)
|
||||||
|
) =>
|
||||||
|
{
|
||||||
|
match self.get(handle) {
|
||||||
|
Ok(existing) if existing == material => Ok(()),
|
||||||
|
Ok(_) => {
|
||||||
|
move_write_through(temporary_guard.path(), &destination, true)
|
||||||
|
.map_err(map_io_error)?;
|
||||||
|
temporary_guard.disarm();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
Err(error) => Err(error),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(error) => Err(map_io_error(error)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get(&self, handle: &SecretHandle) -> Result<SecretMaterial, SecureSecretStoreError> {
|
||||||
|
let envelope = fs::read(self.path_for(handle)).map_err(map_io_error)?;
|
||||||
|
let ciphertext = decode_envelope(&envelope, handle)?;
|
||||||
|
let plaintext = self.protector.unprotect(handle, ciphertext)?;
|
||||||
|
SecretMaterial::new(plaintext).map_err(|_| SecureSecretStoreError::Corrupted)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn delete(&self, handle: &SecretHandle) -> Result<(), SecureSecretStoreError> {
|
||||||
|
fs::remove_file(self.path_for(handle)).map_err(map_io_error)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn list_handles(&self) -> Result<Vec<SecretHandle>, SecureSecretStoreError> {
|
||||||
|
let mut handles = Vec::new();
|
||||||
|
let mut unique = HashSet::new();
|
||||||
|
for entry in fs::read_dir(&self.directory).map_err(map_io_error)? {
|
||||||
|
let entry = entry.map_err(map_io_error)?;
|
||||||
|
let path = entry.path();
|
||||||
|
if path.extension() != Some(OsStr::new(FILE_EXTENSION)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let envelope = fs::read(&path).map_err(map_io_error)?;
|
||||||
|
let (handle, _) = decode_envelope_parts(&envelope)?;
|
||||||
|
if self.path_for(&handle) != path || !unique.insert(handle.clone()) {
|
||||||
|
return Err(SecureSecretStoreError::Corrupted);
|
||||||
|
}
|
||||||
|
handles.push(handle);
|
||||||
|
}
|
||||||
|
handles.sort_by(|left, right| left.as_str().cmp(right.as_str()));
|
||||||
|
Ok(handles)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn encode_envelope(
|
||||||
|
handle: &SecretHandle,
|
||||||
|
ciphertext: &[u8],
|
||||||
|
) -> Result<Vec<u8>, SecureSecretStoreError> {
|
||||||
|
let handle_bytes = handle.as_str().as_bytes();
|
||||||
|
let handle_len =
|
||||||
|
u16::try_from(handle_bytes.len()).map_err(|_| SecureSecretStoreError::Unavailable)?;
|
||||||
|
let ciphertext_len =
|
||||||
|
u32::try_from(ciphertext.len()).map_err(|_| SecureSecretStoreError::Unavailable)?;
|
||||||
|
let capacity = ENVELOPE_MAGIC.len() + 1 + 2 + 4 + handle_bytes.len() + ciphertext.len();
|
||||||
|
if capacity > MAX_ENVELOPE_BYTES {
|
||||||
|
return Err(SecureSecretStoreError::Unavailable);
|
||||||
|
}
|
||||||
|
let mut envelope = Vec::with_capacity(capacity);
|
||||||
|
envelope.extend_from_slice(ENVELOPE_MAGIC);
|
||||||
|
envelope.push(ENVELOPE_VERSION);
|
||||||
|
envelope.extend_from_slice(&handle_len.to_le_bytes());
|
||||||
|
envelope.extend_from_slice(&ciphertext_len.to_le_bytes());
|
||||||
|
envelope.extend_from_slice(handle_bytes);
|
||||||
|
envelope.extend_from_slice(ciphertext);
|
||||||
|
Ok(envelope)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decode_envelope<'a>(
|
||||||
|
envelope: &'a [u8],
|
||||||
|
expected_handle: &SecretHandle,
|
||||||
|
) -> Result<&'a [u8], SecureSecretStoreError> {
|
||||||
|
let (handle, ciphertext) = decode_envelope_parts(envelope)?;
|
||||||
|
if handle != *expected_handle {
|
||||||
|
return Err(SecureSecretStoreError::Corrupted);
|
||||||
|
}
|
||||||
|
Ok(ciphertext)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decode_envelope_parts(envelope: &[u8]) -> Result<(SecretHandle, &[u8]), SecureSecretStoreError> {
|
||||||
|
const HEADER_BYTES: usize = 8 + 1 + 2 + 4;
|
||||||
|
if envelope.len() < HEADER_BYTES
|
||||||
|
|| envelope.len() > MAX_ENVELOPE_BYTES
|
||||||
|
|| &envelope[..8] != ENVELOPE_MAGIC
|
||||||
|
|| envelope[8] != ENVELOPE_VERSION
|
||||||
|
{
|
||||||
|
return Err(SecureSecretStoreError::Corrupted);
|
||||||
|
}
|
||||||
|
let handle_len = usize::from(u16::from_le_bytes([envelope[9], envelope[10]]));
|
||||||
|
let ciphertext_len =
|
||||||
|
u32::from_le_bytes([envelope[11], envelope[12], envelope[13], envelope[14]]) as usize;
|
||||||
|
let handle_end = HEADER_BYTES
|
||||||
|
.checked_add(handle_len)
|
||||||
|
.ok_or(SecureSecretStoreError::Corrupted)?;
|
||||||
|
let envelope_end = handle_end
|
||||||
|
.checked_add(ciphertext_len)
|
||||||
|
.ok_or(SecureSecretStoreError::Corrupted)?;
|
||||||
|
if handle_len == 0 || ciphertext_len == 0 || envelope_end != envelope.len() {
|
||||||
|
return Err(SecureSecretStoreError::Corrupted);
|
||||||
|
}
|
||||||
|
let handle = std::str::from_utf8(&envelope[HEADER_BYTES..handle_end])
|
||||||
|
.map_err(|_| SecureSecretStoreError::Corrupted)?;
|
||||||
|
Ok((
|
||||||
|
SecretHandle(handle.to_owned()),
|
||||||
|
&envelope[handle_end..envelope_end],
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cleanup_interrupted_writes(directory: &Path) -> Result<(), SecureSecretStoreError> {
|
||||||
|
for entry in fs::read_dir(directory).map_err(map_io_error)? {
|
||||||
|
let entry = entry.map_err(map_io_error)?;
|
||||||
|
let name = entry.file_name();
|
||||||
|
if name.to_string_lossy().contains(".tmp-") {
|
||||||
|
fs::remove_file(entry.path()).map_err(map_io_error)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
struct TemporaryFile {
|
||||||
|
path: PathBuf,
|
||||||
|
armed: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TemporaryFile {
|
||||||
|
fn new(path: PathBuf) -> Self {
|
||||||
|
Self { path, armed: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn path(&self) -> &Path {
|
||||||
|
&self.path
|
||||||
|
}
|
||||||
|
|
||||||
|
fn disarm(&mut self) {
|
||||||
|
self.armed = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for TemporaryFile {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
if self.armed {
|
||||||
|
let _ = fs::remove_file(&self.path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn move_write_through(source: &Path, destination: &Path, replace_existing: bool) -> io::Result<()> {
|
||||||
|
let source = wide_path(source);
|
||||||
|
let destination = wide_path(destination);
|
||||||
|
// The files share a directory, so MoveFileEx publishes the fully flushed blob as one rename.
|
||||||
|
let flags = if replace_existing {
|
||||||
|
MOVEFILE_WRITE_THROUGH | MOVEFILE_REPLACE_EXISTING
|
||||||
|
} else {
|
||||||
|
MOVEFILE_WRITE_THROUGH
|
||||||
|
};
|
||||||
|
let moved = unsafe { MoveFileExW(source.as_ptr(), destination.as_ptr(), flags) };
|
||||||
|
if moved == 0 {
|
||||||
|
Err(io::Error::last_os_error())
|
||||||
|
} else {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn wide_path(path: &Path) -> Vec<u16> {
|
||||||
|
path.as_os_str().encode_wide().chain(Some(0)).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
struct DpapiProtector {
|
||||||
|
context: Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DpapiProtector {
|
||||||
|
fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
context: DEFAULT_CONTEXT.to_vec(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
fn with_context_for_test(context: &[u8]) -> Self {
|
||||||
|
Self {
|
||||||
|
context: context.to_vec(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn entropy(&self, handle: &SecretHandle) -> [u8; 32] {
|
||||||
|
let mut hasher = blake3::Hasher::new();
|
||||||
|
hasher.update(&self.context);
|
||||||
|
hasher.update(&[0]);
|
||||||
|
hasher.update(handle.as_str().as_bytes());
|
||||||
|
*hasher.finalize().as_bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn protect(
|
||||||
|
&self,
|
||||||
|
handle: &SecretHandle,
|
||||||
|
plaintext: &[u8],
|
||||||
|
) -> Result<Vec<u8>, SecureSecretStoreError> {
|
||||||
|
let mut payload = encode_protected_payload(plaintext)?;
|
||||||
|
let input = blob(&payload)?;
|
||||||
|
let entropy_bytes = self.entropy(handle);
|
||||||
|
let entropy = blob(&entropy_bytes)?;
|
||||||
|
let mut output = CRYPT_INTEGER_BLOB::default();
|
||||||
|
// Omitting CRYPTPROTECT_LOCAL_MACHINE binds the blob to the current Windows user.
|
||||||
|
let success = unsafe {
|
||||||
|
CryptProtectData(
|
||||||
|
&input,
|
||||||
|
ptr::null(),
|
||||||
|
&entropy,
|
||||||
|
ptr::null(),
|
||||||
|
ptr::null(),
|
||||||
|
CRYPTPROTECT_UI_FORBIDDEN,
|
||||||
|
&mut output,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
let result = if success == 0 {
|
||||||
|
Err(map_dpapi_error(false))
|
||||||
|
} else {
|
||||||
|
copy_and_free(output, false)
|
||||||
|
};
|
||||||
|
payload.fill(0);
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
fn unprotect(
|
||||||
|
&self,
|
||||||
|
handle: &SecretHandle,
|
||||||
|
ciphertext: &[u8],
|
||||||
|
) -> Result<Vec<u8>, SecureSecretStoreError> {
|
||||||
|
let input = blob(ciphertext)?;
|
||||||
|
let entropy_bytes = self.entropy(handle);
|
||||||
|
let entropy = blob(&entropy_bytes)?;
|
||||||
|
let mut output = CRYPT_INTEGER_BLOB::default();
|
||||||
|
let success = unsafe {
|
||||||
|
CryptUnprotectData(
|
||||||
|
&input,
|
||||||
|
ptr::null_mut(),
|
||||||
|
&entropy,
|
||||||
|
ptr::null(),
|
||||||
|
ptr::null(),
|
||||||
|
CRYPTPROTECT_UI_FORBIDDEN,
|
||||||
|
&mut output,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
if success == 0 {
|
||||||
|
return Err(map_dpapi_error(true));
|
||||||
|
}
|
||||||
|
let mut payload = copy_and_free(output, true)?;
|
||||||
|
let plaintext = decode_protected_payload(&payload).map(<[u8]>::to_vec);
|
||||||
|
payload.fill(0);
|
||||||
|
plaintext
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn encode_protected_payload(plaintext: &[u8]) -> Result<Vec<u8>, SecureSecretStoreError> {
|
||||||
|
let length = u32::try_from(plaintext.len()).map_err(|_| SecureSecretStoreError::Unavailable)?;
|
||||||
|
let mut payload = Vec::with_capacity(PROTECTED_PAYLOAD_MAGIC.len() + 4 + plaintext.len() + 32);
|
||||||
|
payload.extend_from_slice(PROTECTED_PAYLOAD_MAGIC);
|
||||||
|
payload.extend_from_slice(&length.to_le_bytes());
|
||||||
|
payload.extend_from_slice(plaintext);
|
||||||
|
let digest = blake3::hash(&payload);
|
||||||
|
payload.extend_from_slice(digest.as_bytes());
|
||||||
|
Ok(payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decode_protected_payload(payload: &[u8]) -> Result<&[u8], SecureSecretStoreError> {
|
||||||
|
let header_end = PROTECTED_PAYLOAD_MAGIC.len() + 4;
|
||||||
|
if payload.len() < header_end + 32 || !payload.starts_with(PROTECTED_PAYLOAD_MAGIC) {
|
||||||
|
return Err(SecureSecretStoreError::Corrupted);
|
||||||
|
}
|
||||||
|
let length = u32::from_le_bytes(
|
||||||
|
payload[PROTECTED_PAYLOAD_MAGIC.len()..header_end]
|
||||||
|
.try_into()
|
||||||
|
.map_err(|_| SecureSecretStoreError::Corrupted)?,
|
||||||
|
) as usize;
|
||||||
|
let material_end = header_end
|
||||||
|
.checked_add(length)
|
||||||
|
.ok_or(SecureSecretStoreError::Corrupted)?;
|
||||||
|
if material_end
|
||||||
|
.checked_add(32)
|
||||||
|
.ok_or(SecureSecretStoreError::Corrupted)?
|
||||||
|
!= payload.len()
|
||||||
|
{
|
||||||
|
return Err(SecureSecretStoreError::Corrupted);
|
||||||
|
}
|
||||||
|
let expected = blake3::hash(&payload[..material_end]);
|
||||||
|
if expected.as_bytes() != &payload[material_end..] {
|
||||||
|
return Err(SecureSecretStoreError::Corrupted);
|
||||||
|
}
|
||||||
|
Ok(&payload[header_end..material_end])
|
||||||
|
}
|
||||||
|
|
||||||
|
fn blob(bytes: &[u8]) -> Result<CRYPT_INTEGER_BLOB, SecureSecretStoreError> {
|
||||||
|
Ok(CRYPT_INTEGER_BLOB {
|
||||||
|
cbData: u32::try_from(bytes.len()).map_err(|_| SecureSecretStoreError::Unavailable)?,
|
||||||
|
pbData: bytes.as_ptr().cast_mut(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn copy_and_free(
|
||||||
|
output: CRYPT_INTEGER_BLOB,
|
||||||
|
clear_before_free: bool,
|
||||||
|
) -> Result<Vec<u8>, SecureSecretStoreError> {
|
||||||
|
if output.pbData.is_null() || output.cbData == 0 {
|
||||||
|
return Err(SecureSecretStoreError::Corrupted);
|
||||||
|
}
|
||||||
|
let result = unsafe {
|
||||||
|
let bytes = std::slice::from_raw_parts(output.pbData, output.cbData as usize).to_vec();
|
||||||
|
if clear_before_free {
|
||||||
|
ptr::write_bytes(output.pbData, 0, output.cbData as usize);
|
||||||
|
}
|
||||||
|
LocalFree(output.pbData.cast());
|
||||||
|
bytes
|
||||||
|
};
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn map_dpapi_error(unprotecting: bool) -> SecureSecretStoreError {
|
||||||
|
let code = unsafe { GetLastError() };
|
||||||
|
match code {
|
||||||
|
ERROR_ACCESS_DENIED | ERROR_PASSWORD_RESTRICTION => SecureSecretStoreError::Locked,
|
||||||
|
ERROR_NOT_SUPPORTED | ERROR_CALL_NOT_IMPLEMENTED => SecureSecretStoreError::Unavailable,
|
||||||
|
_ if unprotecting => SecureSecretStoreError::Corrupted,
|
||||||
|
_ => SecureSecretStoreError::Unavailable,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn map_io_error(error: io::Error) -> SecureSecretStoreError {
|
||||||
|
match error.kind() {
|
||||||
|
io::ErrorKind::NotFound => SecureSecretStoreError::Missing,
|
||||||
|
io::ErrorKind::PermissionDenied => SecureSecretStoreError::Locked,
|
||||||
|
io::ErrorKind::InvalidData => SecureSecretStoreError::Corrupted,
|
||||||
|
_ => SecureSecretStoreError::Unavailable,
|
||||||
|
}
|
||||||
|
}
|
||||||
59
crates/vnidrop/src/targeted_transfer.rs
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
use crate::{api::TargetedTransferState, error::VnidropError};
|
||||||
|
|
||||||
|
impl TargetedTransferState {
|
||||||
|
/// Validates a durable state change without exposing foreign state mutation.
|
||||||
|
pub fn validate_transition_to(self, next: Self) -> Result<(), VnidropError> {
|
||||||
|
let allowed = matches!(
|
||||||
|
(self, next),
|
||||||
|
(
|
||||||
|
Self::Preparing,
|
||||||
|
Self::Offering | Self::Cancelled | Self::Failed
|
||||||
|
) | (
|
||||||
|
Self::Offering,
|
||||||
|
Self::AwaitingApproval | Self::Cancelled | Self::Failed
|
||||||
|
) | (
|
||||||
|
Self::AwaitingApproval,
|
||||||
|
Self::Approved | Self::Declined | Self::Cancelled | Self::Failed
|
||||||
|
) | (
|
||||||
|
Self::Approved,
|
||||||
|
Self::Connecting | Self::Cancelled | Self::Failed
|
||||||
|
) | (
|
||||||
|
Self::Connecting,
|
||||||
|
Self::Transferring | Self::Interrupted | Self::Cancelled | Self::Failed
|
||||||
|
) | (
|
||||||
|
Self::Transferring,
|
||||||
|
Self::Completed | Self::Interrupted | Self::Cancelled | Self::Failed
|
||||||
|
) | (
|
||||||
|
Self::Interrupted,
|
||||||
|
Self::Connecting | Self::Cancelled | Self::Failed | Self::Deleted
|
||||||
|
) | (
|
||||||
|
Self::Completed | Self::Declined | Self::Cancelled | Self::Failed,
|
||||||
|
Self::Deleted
|
||||||
|
)
|
||||||
|
);
|
||||||
|
if allowed {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(VnidropError::InvalidTransition {
|
||||||
|
reason: format!("{} -> {}", self.as_str(), next.as_str()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fn as_str(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Preparing => "preparing",
|
||||||
|
Self::Offering => "offering",
|
||||||
|
Self::AwaitingApproval => "awaiting_approval",
|
||||||
|
Self::Approved => "approved",
|
||||||
|
Self::Connecting => "connecting",
|
||||||
|
Self::Transferring => "transferring",
|
||||||
|
Self::Interrupted => "interrupted",
|
||||||
|
Self::Completed => "completed",
|
||||||
|
Self::Declined => "declined",
|
||||||
|
Self::Cancelled => "cancelled",
|
||||||
|
Self::Failed => "failed",
|
||||||
|
Self::Deleted => "deleted",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,15 @@
|
|||||||
#[path = "tests/access_policy.rs"]
|
#[path = "tests/access_policy.rs"]
|
||||||
mod access_policy_tests;
|
mod access_policy_tests;
|
||||||
|
#[path = "tests/contact_polling.rs"]
|
||||||
|
mod contact_polling_tests;
|
||||||
|
#[path = "tests/contacts.rs"]
|
||||||
|
mod contacts_tests;
|
||||||
#[path = "tests/error.rs"]
|
#[path = "tests/error.rs"]
|
||||||
mod error_tests;
|
mod error_tests;
|
||||||
#[path = "tests/filesystem.rs"]
|
#[path = "tests/filesystem.rs"]
|
||||||
mod filesystem_tests;
|
mod filesystem_tests;
|
||||||
|
#[path = "tests/grant.rs"]
|
||||||
|
mod grant_tests;
|
||||||
#[path = "tests/handshake.rs"]
|
#[path = "tests/handshake.rs"]
|
||||||
mod handshake_tests;
|
mod handshake_tests;
|
||||||
#[path = "tests/limits.rs"]
|
#[path = "tests/limits.rs"]
|
||||||
@@ -16,6 +22,18 @@ mod repository_tests;
|
|||||||
mod runtime_tests;
|
mod runtime_tests;
|
||||||
#[path = "tests/secret.rs"]
|
#[path = "tests/secret.rs"]
|
||||||
mod secret_tests;
|
mod secret_tests;
|
||||||
|
#[path = "tests/secure_secret_android.rs"]
|
||||||
|
mod secure_secret_android_tests;
|
||||||
|
#[cfg(any(target_os = "macos", target_os = "ios"))]
|
||||||
|
#[path = "tests/secure_secret_apple.rs"]
|
||||||
|
mod secure_secret_apple_tests;
|
||||||
|
#[path = "tests/secure_secret_linux.rs"]
|
||||||
|
mod secure_secret_linux_tests;
|
||||||
|
#[path = "tests/secure_secret.rs"]
|
||||||
|
mod secure_secret_tests;
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
#[path = "tests/secure_secret_windows.rs"]
|
||||||
|
mod secure_secret_windows_tests;
|
||||||
#[path = "tests/ticket.rs"]
|
#[path = "tests/ticket.rs"]
|
||||||
mod ticket_tests;
|
mod ticket_tests;
|
||||||
#[path = "tests/transfer_state.rs"]
|
#[path = "tests/transfer_state.rs"]
|
||||||
|
|||||||
30
crates/vnidrop/src/tests/contact_polling.rs
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
use crate::runtime::should_poll;
|
||||||
|
|
||||||
|
const MINUTE_MS: i64 = 60 * 1_000;
|
||||||
|
const NOW: i64 = 1_700_000_000_000;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_device_never_polled_is_polled() {
|
||||||
|
assert!(should_poll(None, NOW));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_device_polled_recently_is_skipped() {
|
||||||
|
// Switching in and out of the app must not re-announce presence.
|
||||||
|
assert!(!should_poll(Some(NOW), NOW));
|
||||||
|
assert!(!should_poll(Some(NOW - MINUTE_MS), NOW));
|
||||||
|
assert!(!should_poll(Some(NOW - 4 * MINUTE_MS), NOW));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_device_polled_before_the_window_is_polled_again() {
|
||||||
|
assert!(should_poll(Some(NOW - 5 * MINUTE_MS), NOW));
|
||||||
|
assert!(should_poll(Some(NOW - 60 * MINUTE_MS), NOW));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A clock that jumped backwards must not lock polling out forever.
|
||||||
|
#[test]
|
||||||
|
fn a_future_timestamp_is_treated_as_recent_rather_than_permanent() {
|
||||||
|
assert!(!should_poll(Some(NOW + MINUTE_MS), NOW));
|
||||||
|
assert!(should_poll(Some(NOW + MINUTE_MS), NOW + 6 * MINUTE_MS));
|
||||||
|
}
|
||||||
386
crates/vnidrop/src/tests/contacts.rs
Normal file
@@ -0,0 +1,386 @@
|
|||||||
|
use crate::{
|
||||||
|
contacts::ContactStore,
|
||||||
|
grant::{Challenge, GrantId, GrantLifetime, GrantRejection, HeldGrant, IssuedGrant},
|
||||||
|
repository::Repository,
|
||||||
|
};
|
||||||
|
|
||||||
|
const PEER: &str = "peer-endpoint";
|
||||||
|
const SELF_ID: &str = "self-endpoint";
|
||||||
|
const NOW: i64 = 1_700_000_000_000;
|
||||||
|
const DAY_MS: i64 = 24 * 60 * 60 * 1_000;
|
||||||
|
|
||||||
|
async fn store(temp: &tempfile::TempDir) -> (Repository, ContactStore) {
|
||||||
|
let repository = Repository::open(temp.path()).await.unwrap();
|
||||||
|
let contacts = repository.contacts();
|
||||||
|
(repository, contacts)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn contact_with_issued_grant(contacts: &ContactStore) -> IssuedGrant {
|
||||||
|
contacts
|
||||||
|
.upsert_contact(PEER, Some("Peer Laptop"), NOW)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let grant = IssuedGrant::mint(PEER.to_string(), NOW, GrantLifetime::default());
|
||||||
|
contacts.insert_issued_grant(&grant).await.unwrap();
|
||||||
|
grant
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn contacts_and_grants_survive_reopening_the_same_data_dir() {
|
||||||
|
let temp = tempfile::tempdir().unwrap();
|
||||||
|
let minted = {
|
||||||
|
let (repository, contacts) = store(&temp).await;
|
||||||
|
let grant = contact_with_issued_grant(&contacts).await;
|
||||||
|
contacts
|
||||||
|
.insert_held_grant(&HeldGrant {
|
||||||
|
grant_id: GrantId::generate(),
|
||||||
|
secret: grant.secret.clone(),
|
||||||
|
peer_endpoint_id: PEER.to_string(),
|
||||||
|
created_at: NOW,
|
||||||
|
expires_at: Some(NOW + 90 * DAY_MS),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
drop(repository);
|
||||||
|
grant
|
||||||
|
};
|
||||||
|
|
||||||
|
let (_repository, contacts) = store(&temp).await;
|
||||||
|
|
||||||
|
let reloaded = contacts
|
||||||
|
.find_issued_grant(minted.grant_id)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.expect("issued grant persisted");
|
||||||
|
assert_eq!(reloaded.secret, minted.secret);
|
||||||
|
assert_eq!(reloaded.issued_to_endpoint_id, PEER);
|
||||||
|
assert!(contacts.held_grant_for(PEER).await.unwrap().is_some());
|
||||||
|
assert_eq!(contacts.list_contacts().await.unwrap().len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_persisted_grant_still_validates_a_proof() {
|
||||||
|
let temp = tempfile::tempdir().unwrap();
|
||||||
|
let (_repository, contacts) = store(&temp).await;
|
||||||
|
let minted = contact_with_issued_grant(&contacts).await;
|
||||||
|
|
||||||
|
// The round trip through hex storage must not disturb the secret.
|
||||||
|
let reloaded = contacts
|
||||||
|
.find_issued_grant(minted.grant_id)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.expect("issued grant persisted");
|
||||||
|
let challenge = Challenge::generate();
|
||||||
|
let held = HeldGrant {
|
||||||
|
grant_id: minted.grant_id,
|
||||||
|
secret: minted.secret.clone(),
|
||||||
|
peer_endpoint_id: SELF_ID.to_string(),
|
||||||
|
created_at: NOW,
|
||||||
|
expires_at: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let outcome = reloaded.accept(
|
||||||
|
&held.prove(&challenge, PEER),
|
||||||
|
&challenge,
|
||||||
|
SELF_ID,
|
||||||
|
PEER,
|
||||||
|
NOW,
|
||||||
|
GrantLifetime::default(),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(outcome.is_ok(), "expected acceptance, got {outcome:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn renewal_is_persisted() {
|
||||||
|
let temp = tempfile::tempdir().unwrap();
|
||||||
|
let (_repository, contacts) = store(&temp).await;
|
||||||
|
let minted = contact_with_issued_grant(&contacts).await;
|
||||||
|
let renewed_to = Some(NOW + 120 * DAY_MS);
|
||||||
|
|
||||||
|
contacts
|
||||||
|
.renew_issued_grant(minted.grant_id, renewed_to)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let reloaded = contacts
|
||||||
|
.find_issued_grant(minted.grant_id)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.expect("issued grant persisted");
|
||||||
|
assert_eq!(reloaded.expires_at, renewed_to);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn revocation_is_tombstoned_so_the_peer_learns_it_was_revoked() {
|
||||||
|
let temp = tempfile::tempdir().unwrap();
|
||||||
|
let (_repository, contacts) = store(&temp).await;
|
||||||
|
let minted = contact_with_issued_grant(&contacts).await;
|
||||||
|
|
||||||
|
contacts
|
||||||
|
.revoke_issued_grant(minted.grant_id, NOW)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let reloaded = contacts
|
||||||
|
.find_issued_grant(minted.grant_id)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.expect("a revoked grant is kept as a tombstone, not deleted");
|
||||||
|
assert_eq!(reloaded.revoked_at, Some(NOW));
|
||||||
|
|
||||||
|
// A tombstone answers Revoked, never Unknown: the peer needs to know to
|
||||||
|
// drop the entry rather than retry forever.
|
||||||
|
let challenge = Challenge::generate();
|
||||||
|
let held = HeldGrant {
|
||||||
|
grant_id: minted.grant_id,
|
||||||
|
secret: minted.secret.clone(),
|
||||||
|
peer_endpoint_id: SELF_ID.to_string(),
|
||||||
|
created_at: NOW,
|
||||||
|
expires_at: None,
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
reloaded.accept(
|
||||||
|
&held.prove(&challenge, PEER),
|
||||||
|
&challenge,
|
||||||
|
SELF_ID,
|
||||||
|
PEER,
|
||||||
|
NOW,
|
||||||
|
GrantLifetime::default(),
|
||||||
|
),
|
||||||
|
Err(GrantRejection::Revoked)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn deleting_a_contact_removes_both_directions_and_reports_issued_grants() {
|
||||||
|
let temp = tempfile::tempdir().unwrap();
|
||||||
|
let (_repository, contacts) = store(&temp).await;
|
||||||
|
let minted = contact_with_issued_grant(&contacts).await;
|
||||||
|
let held_id = GrantId::generate();
|
||||||
|
contacts
|
||||||
|
.insert_held_grant(&HeldGrant {
|
||||||
|
grant_id: held_id,
|
||||||
|
secret: minted.secret.clone(),
|
||||||
|
peer_endpoint_id: PEER.to_string(),
|
||||||
|
created_at: NOW,
|
||||||
|
expires_at: None,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let to_notify = contacts.delete_contact(PEER).await.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(to_notify, vec![minted.grant_id]);
|
||||||
|
assert!(contacts.list_contacts().await.unwrap().is_empty());
|
||||||
|
assert!(contacts
|
||||||
|
.find_issued_grant(minted.grant_id)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.is_none());
|
||||||
|
assert!(contacts.held_grant_for(PEER).await.unwrap().is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn deleting_all_contacts_clears_every_grant() {
|
||||||
|
let temp = tempfile::tempdir().unwrap();
|
||||||
|
let (_repository, contacts) = store(&temp).await;
|
||||||
|
contact_with_issued_grant(&contacts).await;
|
||||||
|
contacts
|
||||||
|
.upsert_contact("other-peer", None, NOW)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let other = IssuedGrant::mint("other-peer".to_string(), NOW, GrantLifetime::default());
|
||||||
|
contacts.insert_issued_grant(&other).await.unwrap();
|
||||||
|
|
||||||
|
let to_notify = contacts.delete_all_contacts().await.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(to_notify.len(), 2);
|
||||||
|
assert!(contacts.list_contacts().await.unwrap().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_local_label_is_never_overwritten_by_a_name_the_remote_claims() {
|
||||||
|
let temp = tempfile::tempdir().unwrap();
|
||||||
|
let (_repository, contacts) = store(&temp).await;
|
||||||
|
contacts
|
||||||
|
.upsert_contact(PEER, Some("Original"), NOW)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
contacts
|
||||||
|
.set_contact_label(PEER, Some("My Laptop"))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
contacts
|
||||||
|
.upsert_contact(PEER, Some("Totally Not Evil"), NOW + 1)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let contact = contacts.find_contact(PEER).await.unwrap().expect("contact");
|
||||||
|
assert_eq!(contact.local_label.as_deref(), Some("My Laptop"));
|
||||||
|
assert_eq!(
|
||||||
|
contact.remote_display_name.as_deref(),
|
||||||
|
Some("Totally Not Evil"),
|
||||||
|
"the claimed name is still recorded, just not promoted to the label"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn upsert_keeps_the_original_creation_time_and_records_activity() {
|
||||||
|
let temp = tempfile::tempdir().unwrap();
|
||||||
|
let (_repository, contacts) = store(&temp).await;
|
||||||
|
contacts.upsert_contact(PEER, None, NOW).await.unwrap();
|
||||||
|
|
||||||
|
contacts
|
||||||
|
.upsert_contact(PEER, None, NOW + 5 * DAY_MS)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
contacts
|
||||||
|
.touch_transfer(PEER, NOW + 6 * DAY_MS)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let contact = contacts.find_contact(PEER).await.unwrap().expect("contact");
|
||||||
|
assert_eq!(contact.created_at, NOW);
|
||||||
|
assert_eq!(contact.last_transfer_at, Some(NOW + 6 * DAY_MS));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn the_last_known_address_is_remembered_for_later_dialing() {
|
||||||
|
let temp = tempfile::tempdir().unwrap();
|
||||||
|
let (_repository, contacts) = store(&temp).await;
|
||||||
|
contacts.upsert_contact(PEER, None, NOW).await.unwrap();
|
||||||
|
|
||||||
|
contacts
|
||||||
|
.set_last_known_addr(PEER, "vndaddr1:encoded")
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let contact = contacts.find_contact(PEER).await.unwrap().expect("contact");
|
||||||
|
assert_eq!(contact.last_known_addr.as_deref(), Some("vndaddr1:encoded"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn blocking_revokes_outstanding_grants_so_it_is_not_merely_cosmetic() {
|
||||||
|
let temp = tempfile::tempdir().unwrap();
|
||||||
|
let (_repository, contacts) = store(&temp).await;
|
||||||
|
let minted = contact_with_issued_grant(&contacts).await;
|
||||||
|
|
||||||
|
contacts.block_endpoint(PEER, NOW).await.unwrap();
|
||||||
|
|
||||||
|
assert!(contacts.is_blocked(PEER).await.unwrap());
|
||||||
|
let reloaded = contacts
|
||||||
|
.find_issued_grant(minted.grant_id)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.expect("grant kept as tombstone");
|
||||||
|
assert_eq!(reloaded.revoked_at, Some(NOW));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn unblocking_does_not_restore_the_revoked_grant() {
|
||||||
|
let temp = tempfile::tempdir().unwrap();
|
||||||
|
let (_repository, contacts) = store(&temp).await;
|
||||||
|
let minted = contact_with_issued_grant(&contacts).await;
|
||||||
|
contacts.block_endpoint(PEER, NOW).await.unwrap();
|
||||||
|
|
||||||
|
contacts.unblock_endpoint(PEER).await.unwrap();
|
||||||
|
|
||||||
|
assert!(!contacts.is_blocked(PEER).await.unwrap());
|
||||||
|
let reloaded = contacts
|
||||||
|
.find_issued_grant(minted.grant_id)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.expect("grant kept as tombstone");
|
||||||
|
assert!(
|
||||||
|
reloaded.revoked_at.is_some(),
|
||||||
|
"unblocking must not silently hand back access; the peer has to pair again"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn newest_held_grant_wins_after_re_pairing() {
|
||||||
|
let temp = tempfile::tempdir().unwrap();
|
||||||
|
let (_repository, contacts) = store(&temp).await;
|
||||||
|
let older = HeldGrant {
|
||||||
|
grant_id: GrantId::generate(),
|
||||||
|
secret: IssuedGrant::mint(PEER.to_string(), NOW, GrantLifetime::default()).secret,
|
||||||
|
peer_endpoint_id: PEER.to_string(),
|
||||||
|
created_at: NOW,
|
||||||
|
expires_at: None,
|
||||||
|
};
|
||||||
|
let newer = HeldGrant {
|
||||||
|
grant_id: GrantId::generate(),
|
||||||
|
secret: IssuedGrant::mint(PEER.to_string(), NOW, GrantLifetime::default()).secret,
|
||||||
|
peer_endpoint_id: PEER.to_string(),
|
||||||
|
created_at: NOW + DAY_MS,
|
||||||
|
expires_at: None,
|
||||||
|
};
|
||||||
|
contacts.insert_held_grant(&older).await.unwrap();
|
||||||
|
contacts.insert_held_grant(&newer).await.unwrap();
|
||||||
|
|
||||||
|
let selected = contacts.held_grant_for(PEER).await.unwrap().expect("grant");
|
||||||
|
|
||||||
|
assert_eq!(selected.grant_id, newer.grant_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_held_grant_is_dropped_once_the_issuer_reports_it_dead() {
|
||||||
|
let temp = tempfile::tempdir().unwrap();
|
||||||
|
let (_repository, contacts) = store(&temp).await;
|
||||||
|
let held = HeldGrant {
|
||||||
|
grant_id: GrantId::generate(),
|
||||||
|
secret: IssuedGrant::mint(PEER.to_string(), NOW, GrantLifetime::default()).secret,
|
||||||
|
peer_endpoint_id: PEER.to_string(),
|
||||||
|
created_at: NOW,
|
||||||
|
expires_at: None,
|
||||||
|
};
|
||||||
|
contacts.insert_held_grant(&held).await.unwrap();
|
||||||
|
|
||||||
|
contacts.delete_held_grant(held.grant_id).await.unwrap();
|
||||||
|
|
||||||
|
assert!(contacts.held_grant_for(PEER).await.unwrap().is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn purging_drops_lapsed_and_revoked_grants_but_keeps_live_ones() {
|
||||||
|
let temp = tempfile::tempdir().unwrap();
|
||||||
|
let (_repository, contacts) = store(&temp).await;
|
||||||
|
let live = contact_with_issued_grant(&contacts).await;
|
||||||
|
let lapsed = IssuedGrant::mint(
|
||||||
|
"stale-peer".to_string(),
|
||||||
|
NOW - 400 * DAY_MS,
|
||||||
|
GrantLifetime::Days(1),
|
||||||
|
);
|
||||||
|
contacts.insert_issued_grant(&lapsed).await.unwrap();
|
||||||
|
|
||||||
|
let purged = contacts.purge_dead_grants(NOW).await.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(purged, 1);
|
||||||
|
assert!(contacts
|
||||||
|
.find_issued_grant(live.grant_id)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.is_some());
|
||||||
|
assert!(contacts
|
||||||
|
.find_issued_grant(lapsed.grant_id)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_corrupt_stored_secret_is_an_error_not_a_silent_refusal() {
|
||||||
|
let temp = tempfile::tempdir().unwrap();
|
||||||
|
let (_repository, contacts) = store(&temp).await;
|
||||||
|
let minted = contact_with_issued_grant(&contacts).await;
|
||||||
|
contacts
|
||||||
|
.corrupt_secret_for_test(minted.grant_id)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Refusing the peer here would be indistinguishable from revocation, so the
|
||||||
|
// corruption has to surface instead.
|
||||||
|
assert!(contacts.find_issued_grant(minted.grant_id).await.is_err());
|
||||||
|
}
|
||||||
@@ -44,6 +44,22 @@ fn transfer_boundary_preserves_typed_errors_through_context() {
|
|||||||
assert_eq!(classified.code(), "network");
|
assert_eq!(classified.code(), "network");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn initialization_boundary_preserves_secure_storage_failures() {
|
||||||
|
let error = anyhow::Error::new(VnidropError::SecureStorageLocked {
|
||||||
|
reason: "credential store is locked".to_string(),
|
||||||
|
})
|
||||||
|
.context("endpoint identity could not be loaded");
|
||||||
|
|
||||||
|
let classified = VnidropError::initialization(error);
|
||||||
|
|
||||||
|
assert!(matches!(
|
||||||
|
classified,
|
||||||
|
VnidropError::SecureStorageLocked { ref reason }
|
||||||
|
if reason == "endpoint identity could not be loaded"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn transfer_boundary_classifies_database_failures() {
|
fn transfer_boundary_classifies_database_failures() {
|
||||||
let transfer = VnidropError::transfer(sqlx::Error::RowNotFound);
|
let transfer = VnidropError::transfer(sqlx::Error::RowNotFound);
|
||||||
|
|||||||
226
crates/vnidrop/src/tests/grant.rs
Normal file
@@ -0,0 +1,226 @@
|
|||||||
|
use crate::grant::{
|
||||||
|
parse_secret, prove, Challenge, GrantId, GrantLifetime, GrantRejection, GrantSecret,
|
||||||
|
IssuedGrant,
|
||||||
|
};
|
||||||
|
|
||||||
|
const ISSUER: &str = "issuer-endpoint";
|
||||||
|
const HOLDER: &str = "holder-endpoint";
|
||||||
|
const DAY_MS: i64 = 24 * 60 * 60 * 1_000;
|
||||||
|
|
||||||
|
fn issued(now_ms: i64) -> IssuedGrant {
|
||||||
|
IssuedGrant::mint(HOLDER.to_string(), now_ms, GrantLifetime::default())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn accept_with(
|
||||||
|
grant: &IssuedGrant,
|
||||||
|
challenge: &Challenge,
|
||||||
|
remote_endpoint_id: &str,
|
||||||
|
now_ms: i64,
|
||||||
|
) -> Result<Option<i64>, GrantRejection> {
|
||||||
|
let proof = prove(
|
||||||
|
grant.grant_id,
|
||||||
|
&grant.secret,
|
||||||
|
challenge,
|
||||||
|
ISSUER,
|
||||||
|
remote_endpoint_id,
|
||||||
|
);
|
||||||
|
grant.accept(
|
||||||
|
&proof,
|
||||||
|
challenge,
|
||||||
|
ISSUER,
|
||||||
|
remote_endpoint_id,
|
||||||
|
now_ms,
|
||||||
|
GrantLifetime::default(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn accepts_a_valid_proof_and_returns_the_renewed_deadline() {
|
||||||
|
let now = 1_700_000_000_000;
|
||||||
|
let grant = issued(now);
|
||||||
|
let challenge = Challenge::generate();
|
||||||
|
|
||||||
|
let renewed = accept_with(&grant, &challenge, HOLDER, now + DAY_MS).expect("proof accepted");
|
||||||
|
|
||||||
|
assert_eq!(renewed, Some(now + DAY_MS + 90 * DAY_MS));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn renewal_extends_past_the_original_expiry() {
|
||||||
|
let now = 1_700_000_000_000;
|
||||||
|
let grant = issued(now);
|
||||||
|
let original = grant.expires_at.expect("default lifetime expires");
|
||||||
|
|
||||||
|
// Used one day before lapsing: the new deadline must be later than the old.
|
||||||
|
let use_at = original - DAY_MS;
|
||||||
|
let renewed = accept_with(&grant, &Challenge::generate(), HOLDER, use_at)
|
||||||
|
.expect("proof accepted")
|
||||||
|
.expect("renewed deadline");
|
||||||
|
|
||||||
|
assert!(renewed > original);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_a_proof_bound_to_a_different_challenge() {
|
||||||
|
let now = 1_700_000_000_000;
|
||||||
|
let grant = issued(now);
|
||||||
|
let captured = Challenge::from_bytes([7u8; 32]);
|
||||||
|
let proof = prove(grant.grant_id, &grant.secret, &captured, ISSUER, HOLDER);
|
||||||
|
|
||||||
|
// Replaying a captured proof against a fresh challenge must fail.
|
||||||
|
let outcome = grant.accept(
|
||||||
|
&proof,
|
||||||
|
&Challenge::from_bytes([9u8; 32]),
|
||||||
|
ISSUER,
|
||||||
|
HOLDER,
|
||||||
|
now,
|
||||||
|
GrantLifetime::default(),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(outcome, Err(GrantRejection::BadProof));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_a_proof_from_an_endpoint_the_grant_was_not_issued_to() {
|
||||||
|
let now = 1_700_000_000_000;
|
||||||
|
let grant = issued(now);
|
||||||
|
|
||||||
|
let outcome = accept_with(&grant, &Challenge::generate(), "someone-else", now);
|
||||||
|
|
||||||
|
assert_eq!(outcome, Err(GrantRejection::WrongEndpoint));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_a_proof_replayed_against_a_different_issuer() {
|
||||||
|
let now = 1_700_000_000_000;
|
||||||
|
let grant = issued(now);
|
||||||
|
let challenge = Challenge::generate();
|
||||||
|
let proof = prove(
|
||||||
|
grant.grant_id,
|
||||||
|
&grant.secret,
|
||||||
|
&challenge,
|
||||||
|
"other-issuer",
|
||||||
|
HOLDER,
|
||||||
|
);
|
||||||
|
|
||||||
|
let outcome = grant.accept(
|
||||||
|
&proof,
|
||||||
|
&challenge,
|
||||||
|
ISSUER,
|
||||||
|
HOLDER,
|
||||||
|
now,
|
||||||
|
GrantLifetime::default(),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(outcome, Err(GrantRejection::BadProof));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_a_revoked_grant_distinguishably() {
|
||||||
|
let now = 1_700_000_000_000;
|
||||||
|
let mut grant = issued(now);
|
||||||
|
grant.revoked_at = Some(now);
|
||||||
|
|
||||||
|
// Revocation is reported as such so the peer can drop the dead entry.
|
||||||
|
assert_eq!(
|
||||||
|
accept_with(&grant, &Challenge::generate(), HOLDER, now),
|
||||||
|
Err(GrantRejection::Revoked)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_an_idle_grant_after_its_deadline() {
|
||||||
|
let now = 1_700_000_000_000;
|
||||||
|
let grant = issued(now);
|
||||||
|
let expires_at = grant.expires_at.expect("default lifetime expires");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
accept_with(&grant, &Challenge::generate(), HOLDER, expires_at),
|
||||||
|
Ok(Some(expires_at + 90 * DAY_MS)),
|
||||||
|
"a grant is still usable on its deadline"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
accept_with(&grant, &Challenge::generate(), HOLDER, expires_at + 1),
|
||||||
|
Err(GrantRejection::Expired)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_a_proof_for_a_different_grant_id() {
|
||||||
|
let now = 1_700_000_000_000;
|
||||||
|
let grant = issued(now);
|
||||||
|
let other = issued(now);
|
||||||
|
let challenge = Challenge::generate();
|
||||||
|
let proof = prove(other.grant_id, &other.secret, &challenge, ISSUER, HOLDER);
|
||||||
|
|
||||||
|
let outcome = grant.accept(
|
||||||
|
&proof,
|
||||||
|
&challenge,
|
||||||
|
ISSUER,
|
||||||
|
HOLDER,
|
||||||
|
now,
|
||||||
|
GrantLifetime::default(),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(outcome, Err(GrantRejection::Unknown));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn never_lifetime_produces_no_deadline() {
|
||||||
|
let now = 1_700_000_000_000;
|
||||||
|
let grant = IssuedGrant::mint(HOLDER.to_string(), now, GrantLifetime::Never);
|
||||||
|
assert_eq!(grant.expires_at, None);
|
||||||
|
|
||||||
|
let challenge = Challenge::generate();
|
||||||
|
let proof = prove(grant.grant_id, &grant.secret, &challenge, ISSUER, HOLDER);
|
||||||
|
let renewed = grant
|
||||||
|
.accept(
|
||||||
|
&proof,
|
||||||
|
&challenge,
|
||||||
|
ISSUER,
|
||||||
|
HOLDER,
|
||||||
|
now + 10_000 * DAY_MS,
|
||||||
|
GrantLifetime::Never,
|
||||||
|
)
|
||||||
|
.expect("proof accepted");
|
||||||
|
|
||||||
|
assert_eq!(renewed, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn grant_ids_and_secrets_round_trip_through_storage_encoding() {
|
||||||
|
let id = GrantId::generate();
|
||||||
|
assert_eq!(GrantId::decode(&id.encode()).expect("decodes"), id);
|
||||||
|
|
||||||
|
let secret = GrantSecret::generate();
|
||||||
|
assert_eq!(parse_secret(&secret.encode()).expect("decodes"), secret);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_malformed_or_degenerate_stored_secrets() {
|
||||||
|
assert!(parse_secret("not-hex").is_err());
|
||||||
|
assert!(parse_secret("aabb").is_err(), "wrong length");
|
||||||
|
assert!(
|
||||||
|
parse_secret(&"00".repeat(32)).is_err(),
|
||||||
|
"an all-zero secret means corrupt storage, not a usable grant"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn secrets_are_redacted_in_debug_output() {
|
||||||
|
let secret = GrantSecret::generate();
|
||||||
|
let rendered = format!("{secret:?}");
|
||||||
|
|
||||||
|
assert!(!rendered.contains(&secret.encode()));
|
||||||
|
assert_eq!(rendered, "GrantSecret(redacted)");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn generated_grants_are_unique() {
|
||||||
|
let now = 1_700_000_000_000;
|
||||||
|
let first = issued(now);
|
||||||
|
let second = issued(now);
|
||||||
|
|
||||||
|
assert_ne!(first.grant_id, second.grant_id);
|
||||||
|
assert_ne!(first.secret, second.secret);
|
||||||
|
}
|
||||||
@@ -66,7 +66,7 @@ async fn received_artifacts_survive_history_deletion() {
|
|||||||
async fn persists_transfers_and_events_across_reopen() {
|
async fn persists_transfers_and_events_across_reopen() {
|
||||||
let temp = tempfile::tempdir().unwrap();
|
let temp = tempfile::tempdir().unwrap();
|
||||||
let repository = Repository::open(temp.path()).await.unwrap();
|
let repository = Repository::open(temp.path()).await.unwrap();
|
||||||
assert_eq!(repository.schema_version().await.unwrap(), 7);
|
assert_eq!(repository.schema_version().await.unwrap(), 10);
|
||||||
repository
|
repository
|
||||||
.insert_transfer(transfer(
|
.insert_transfer(transfer(
|
||||||
7,
|
7,
|
||||||
@@ -645,7 +645,7 @@ async fn migrates_schema_v2_identity_without_losing_transfer() {
|
|||||||
pool.close().await;
|
pool.close().await;
|
||||||
|
|
||||||
let repository = Repository::open(temp.path()).await.unwrap();
|
let repository = Repository::open(temp.path()).await.unwrap();
|
||||||
assert_eq!(repository.schema_version().await.unwrap(), 7);
|
assert_eq!(repository.schema_version().await.unwrap(), 10);
|
||||||
let stored = repository.list_transfers().await.unwrap().remove(0);
|
let stored = repository.list_transfers().await.unwrap().remove(0);
|
||||||
assert_eq!(stored.transfer_id, 7);
|
assert_eq!(stored.transfer_id, 7);
|
||||||
assert_eq!(stored.local_id, "legacy-7-send");
|
assert_eq!(stored.local_id, "legacy-7-send");
|
||||||
|
|||||||
@@ -10,9 +10,10 @@ use iroh_blobs::{
|
|||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
repository::{PendingDeliveryReceiptInsert, Repository, TransferUpsert},
|
repository::{PendingDeliveryReceiptInsert, Repository, TransferUpsert},
|
||||||
runtime::{consume_request_updates, RequestStreamOutcome},
|
runtime::{consume_request_updates, CoreInner, IdentityMode, RequestStreamOutcome},
|
||||||
|
secure_secret::{lock_profile, FaultInjectingSecretStore},
|
||||||
transfer_state::{TransferDirection, TransferStatus},
|
transfer_state::{TransferDirection, TransferStatus},
|
||||||
CoreEvent, CoreEventSink, VnidropCore, VnidropError,
|
CoreEvent, CoreEventSink, CoreLimits, CoreRelayMode, VnidropCore, VnidropError,
|
||||||
};
|
};
|
||||||
|
|
||||||
struct TestSink;
|
struct TestSink;
|
||||||
@@ -65,6 +66,46 @@ fn initializes_and_reports_endpoint() {
|
|||||||
core.shutdown();
|
core.shutdown();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn protected_runtime_restart_preserves_identity_without_plaintext_fallback() {
|
||||||
|
let temp = tempfile::tempdir().unwrap();
|
||||||
|
let store = Arc::new(FaultInjectingSecretStore::default());
|
||||||
|
let first = CoreInner::start(
|
||||||
|
temp.path().to_path_buf(),
|
||||||
|
Arc::new(TestSink),
|
||||||
|
CoreLimits::default(),
|
||||||
|
CoreRelayMode::LocalOnly,
|
||||||
|
Vec::new(),
|
||||||
|
IdentityMode::Protected {
|
||||||
|
store: store.clone(),
|
||||||
|
profile_lock: lock_profile(temp.path()).unwrap(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let endpoint_id = first.endpoint.id();
|
||||||
|
first.shutdown().await;
|
||||||
|
drop(first);
|
||||||
|
|
||||||
|
let restarted = CoreInner::start(
|
||||||
|
temp.path().to_path_buf(),
|
||||||
|
Arc::new(TestSink),
|
||||||
|
CoreLimits::default(),
|
||||||
|
CoreRelayMode::LocalOnly,
|
||||||
|
Vec::new(),
|
||||||
|
IdentityMode::Protected {
|
||||||
|
store,
|
||||||
|
profile_lock: lock_profile(temp.path()).unwrap(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(restarted.endpoint.id(), endpoint_id);
|
||||||
|
assert!(!temp.path().join("iroh.secret").exists());
|
||||||
|
restarted.shutdown().await;
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn invalid_receive_ticket_is_typed_and_persisted_as_event() {
|
fn invalid_receive_ticket_is_typed_and_persisted_as_event() {
|
||||||
let temp = tempfile::tempdir().unwrap();
|
let temp = tempfile::tempdir().unwrap();
|
||||||
|
|||||||
396
crates/vnidrop/src/tests/secure_secret.rs
Normal file
@@ -0,0 +1,396 @@
|
|||||||
|
use std::{
|
||||||
|
io::{self, Write},
|
||||||
|
sync::{Arc, Mutex},
|
||||||
|
};
|
||||||
|
|
||||||
|
use data_encoding::HEXLOWER;
|
||||||
|
use iroh::SecretKey;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
repository::Repository,
|
||||||
|
secure_secret::{
|
||||||
|
lock_profile, scope_store, CustodyCrashPoint, FaultInjectingSecretStore,
|
||||||
|
ReferenceStoreFailure, SecretCustody, SecretKind, SecretMaterial, SecureSecretStore,
|
||||||
|
},
|
||||||
|
VnidropError,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Clone, Default)]
|
||||||
|
struct CapturedOutput(Arc<Mutex<Vec<u8>>>);
|
||||||
|
|
||||||
|
struct CapturedWriter(CapturedOutput);
|
||||||
|
|
||||||
|
impl Write for CapturedWriter {
|
||||||
|
fn write(&mut self, bytes: &[u8]) -> io::Result<usize> {
|
||||||
|
self.0 .0.lock().unwrap().extend_from_slice(bytes);
|
||||||
|
Ok(bytes.len())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn flush(&mut self) -> io::Result<()> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_profile_allows_only_one_protected_core_mutator() {
|
||||||
|
let temp = tempfile::tempdir().unwrap();
|
||||||
|
let first = lock_profile(temp.path()).unwrap();
|
||||||
|
|
||||||
|
assert!(matches!(
|
||||||
|
lock_profile(temp.path()),
|
||||||
|
Err(VnidropError::SecureStorageUnavailable { .. })
|
||||||
|
));
|
||||||
|
|
||||||
|
drop(first);
|
||||||
|
assert!(lock_profile(temp.path()).is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn reconciliation_is_scoped_to_one_application_profile() {
|
||||||
|
let root = tempfile::tempdir().unwrap();
|
||||||
|
let first_dir = root.path().join("first");
|
||||||
|
let second_dir = root.path().join("second");
|
||||||
|
std::fs::create_dir_all(&first_dir).unwrap();
|
||||||
|
std::fs::create_dir_all(&second_dir).unwrap();
|
||||||
|
let shared_platform_store = Arc::new(FaultInjectingSecretStore::default());
|
||||||
|
let first_store = scope_store(&first_dir, shared_platform_store.clone());
|
||||||
|
let second_store = scope_store(&second_dir, shared_platform_store);
|
||||||
|
let first_repository = Repository::open(&first_dir).await.unwrap();
|
||||||
|
let second_repository = Repository::open(&second_dir).await.unwrap();
|
||||||
|
let first = SecretCustody::new(first_repository.protected_secrets(), first_store.clone());
|
||||||
|
let second = SecretCustody::new(second_repository.protected_secrets(), second_store.clone());
|
||||||
|
let first_handle = first
|
||||||
|
.protect(
|
||||||
|
SecretKind::RelationshipGrant,
|
||||||
|
SecretMaterial::new(vec![0x31; 32]).unwrap(),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let second_handle = second
|
||||||
|
.protect(
|
||||||
|
SecretKind::RelationshipGrant,
|
||||||
|
SecretMaterial::new(vec![0x42; 32]).unwrap(),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
drop(first);
|
||||||
|
let (restarted, summary) =
|
||||||
|
SecretCustody::start(first_repository.protected_secrets(), first_store)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(summary.orphans_deleted, 0);
|
||||||
|
assert_eq!(
|
||||||
|
restarted.load(&first_handle).await.unwrap(),
|
||||||
|
SecretMaterial::new(vec![0x31; 32]).unwrap()
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
second.load(&second_handle).await.unwrap(),
|
||||||
|
SecretMaterial::new(vec![0x42; 32]).unwrap()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn custody_maps_reference_store_failures_to_typed_core_errors() {
|
||||||
|
let temp = tempfile::tempdir().unwrap();
|
||||||
|
let repository = Repository::open(temp.path()).await.unwrap();
|
||||||
|
let store = Arc::new(FaultInjectingSecretStore::default());
|
||||||
|
let custody = SecretCustody::new(repository.protected_secrets(), store.clone());
|
||||||
|
let secret = SecretMaterial::new(vec![0x5a; 32]).unwrap();
|
||||||
|
let handle = custody
|
||||||
|
.protect(SecretKind::RelationshipGrant, secret.clone(), None)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(custody.load(&handle).await.unwrap(), secret);
|
||||||
|
|
||||||
|
store.fail_with(Some(ReferenceStoreFailure::Locked));
|
||||||
|
assert!(matches!(
|
||||||
|
custody.load(&handle).await,
|
||||||
|
Err(VnidropError::SecureStorageLocked { .. })
|
||||||
|
));
|
||||||
|
|
||||||
|
store.fail_with(Some(ReferenceStoreFailure::Unavailable));
|
||||||
|
assert!(matches!(
|
||||||
|
custody.load(&handle).await,
|
||||||
|
Err(VnidropError::SecureStorageUnavailable { .. })
|
||||||
|
));
|
||||||
|
|
||||||
|
store.fail_with(None);
|
||||||
|
store.remove_for_test(&handle);
|
||||||
|
assert!(matches!(
|
||||||
|
custody.load(&handle).await,
|
||||||
|
Err(VnidropError::SecureStorageMissing { .. })
|
||||||
|
));
|
||||||
|
|
||||||
|
let corrupted_handle = custody
|
||||||
|
.protect(SecretKind::RelationshipGrant, secret, None)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
store.corrupt_for_test(&corrupted_handle);
|
||||||
|
assert!(matches!(
|
||||||
|
custody.load(&corrupted_handle).await,
|
||||||
|
Err(VnidropError::SecureStorageCorrupted { .. })
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn reconciliation_repairs_staged_metadata_and_disables_unusable_secrets() {
|
||||||
|
let temp = tempfile::tempdir().unwrap();
|
||||||
|
let mut repository = Repository::open(temp.path()).await.unwrap();
|
||||||
|
let store = Arc::new(FaultInjectingSecretStore::default());
|
||||||
|
let custody = SecretCustody::new(repository.protected_secrets(), store.clone());
|
||||||
|
|
||||||
|
custody.crash_once_at(CustodyCrashPoint::StoreWrite);
|
||||||
|
assert!(custody
|
||||||
|
.protect(
|
||||||
|
SecretKind::PairingEligibility,
|
||||||
|
SecretMaterial::new(vec![0x11; 32]).unwrap(),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.is_err());
|
||||||
|
drop(custody);
|
||||||
|
drop(repository);
|
||||||
|
repository = Repository::open(temp.path()).await.unwrap();
|
||||||
|
let (custody, summary) = SecretCustody::start(repository.protected_secrets(), store.clone())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(summary.orphans_deleted, 1);
|
||||||
|
assert_eq!(summary.staged_activated, 0);
|
||||||
|
|
||||||
|
custody.crash_once_at(CustodyCrashPoint::MetadataStage);
|
||||||
|
assert!(custody
|
||||||
|
.protect(
|
||||||
|
SecretKind::PairingEligibility,
|
||||||
|
SecretMaterial::new(vec![0x22; 32]).unwrap(),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.is_err());
|
||||||
|
let staged_handle = store.only_handle_for_test();
|
||||||
|
drop(custody);
|
||||||
|
drop(repository);
|
||||||
|
repository = Repository::open(temp.path()).await.unwrap();
|
||||||
|
let (custody, summary) = SecretCustody::start(repository.protected_secrets(), store.clone())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(summary.staged_activated, 1);
|
||||||
|
assert_eq!(
|
||||||
|
custody.load(&staged_handle).await.unwrap(),
|
||||||
|
SecretMaterial::new(vec![0x22; 32]).unwrap()
|
||||||
|
);
|
||||||
|
|
||||||
|
store.remove_for_test(&staged_handle);
|
||||||
|
drop(custody);
|
||||||
|
drop(repository);
|
||||||
|
repository = Repository::open(temp.path()).await.unwrap();
|
||||||
|
let (custody, summary) = SecretCustody::start(repository.protected_secrets(), store.clone())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(summary.disabled, 1);
|
||||||
|
assert!(matches!(
|
||||||
|
custody.load(&staged_handle).await,
|
||||||
|
Err(VnidropError::SecureStorageUnavailable { .. })
|
||||||
|
));
|
||||||
|
|
||||||
|
let corrupted = custody
|
||||||
|
.protect(
|
||||||
|
SecretKind::RelationshipGrant,
|
||||||
|
SecretMaterial::new(vec![0x33; 32]).unwrap(),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
store.corrupt_for_test(&corrupted);
|
||||||
|
drop(custody);
|
||||||
|
drop(repository);
|
||||||
|
let repository = Repository::open(temp.path()).await.unwrap();
|
||||||
|
let (custody, summary) = SecretCustody::start(repository.protected_secrets(), store.clone())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(summary.disabled, 1);
|
||||||
|
assert!(matches!(
|
||||||
|
custody.load(&corrupted).await,
|
||||||
|
Err(VnidropError::SecureStorageUnavailable { .. })
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn endpoint_migration_preserves_identity_across_crash_and_rejects_replacement() {
|
||||||
|
let temp = tempfile::tempdir().unwrap();
|
||||||
|
let legacy_path = temp.path().join("iroh.secret");
|
||||||
|
let original = SecretKey::generate();
|
||||||
|
std::fs::write(&legacy_path, HEXLOWER.encode(&original.to_bytes())).unwrap();
|
||||||
|
let repository = Repository::open(temp.path()).await.unwrap();
|
||||||
|
let store = Arc::new(FaultInjectingSecretStore::default());
|
||||||
|
let custody = SecretCustody::new(repository.protected_secrets(), store.clone());
|
||||||
|
|
||||||
|
custody.crash_once_at(CustodyCrashPoint::MetadataActivation);
|
||||||
|
assert!(custody
|
||||||
|
.migrate_legacy_endpoint_identity(&legacy_path)
|
||||||
|
.await
|
||||||
|
.is_err());
|
||||||
|
assert!(
|
||||||
|
legacy_path.exists(),
|
||||||
|
"legacy key must survive before activation"
|
||||||
|
);
|
||||||
|
|
||||||
|
drop(custody);
|
||||||
|
drop(repository);
|
||||||
|
let repository = Repository::open(temp.path()).await.unwrap();
|
||||||
|
let (custody, summary) = SecretCustody::start(repository.protected_secrets(), store.clone())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(summary.staged_activated, 0);
|
||||||
|
let handle = custody
|
||||||
|
.migrate_legacy_endpoint_identity(&legacy_path)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(!legacy_path.exists());
|
||||||
|
assert_eq!(
|
||||||
|
custody.load(&handle).await.unwrap(),
|
||||||
|
SecretMaterial::new(original.to_bytes().to_vec()).unwrap()
|
||||||
|
);
|
||||||
|
|
||||||
|
let replacement = SecretKey::generate();
|
||||||
|
std::fs::write(&legacy_path, HEXLOWER.encode(&replacement.to_bytes())).unwrap();
|
||||||
|
assert!(matches!(
|
||||||
|
custody.migrate_legacy_endpoint_identity(&legacy_path).await,
|
||||||
|
Err(VnidropError::SecureStorageCorrupted { .. })
|
||||||
|
));
|
||||||
|
assert!(legacy_path.exists());
|
||||||
|
assert_eq!(
|
||||||
|
custody.load(&handle).await.unwrap(),
|
||||||
|
SecretMaterial::new(original.to_bytes().to_vec()).unwrap()
|
||||||
|
);
|
||||||
|
|
||||||
|
let missing = temp.path().join("missing.secret");
|
||||||
|
let empty_store = Arc::new(FaultInjectingSecretStore::default());
|
||||||
|
let other_dir = temp.path().join("other");
|
||||||
|
std::fs::create_dir(&other_dir).unwrap();
|
||||||
|
let other_repository = Repository::open(&other_dir).await.unwrap();
|
||||||
|
let empty_custody = SecretCustody::new(other_repository.protected_secrets(), empty_store);
|
||||||
|
assert!(matches!(
|
||||||
|
empty_custody
|
||||||
|
.migrate_legacy_endpoint_identity(&missing)
|
||||||
|
.await,
|
||||||
|
Err(VnidropError::SecureStorageMissing { .. })
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn first_install_identity_is_protected_once_and_never_silently_replaced() {
|
||||||
|
let temp = tempfile::tempdir().unwrap();
|
||||||
|
let legacy_path = temp.path().join("iroh.secret");
|
||||||
|
let repository = Repository::open(temp.path()).await.unwrap();
|
||||||
|
let store = Arc::new(FaultInjectingSecretStore::default());
|
||||||
|
let (custody, _) = SecretCustody::start(repository.protected_secrets(), store.clone())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let original = custody
|
||||||
|
.initialize_endpoint_identity(&legacy_path)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(!legacy_path.exists());
|
||||||
|
let handle = store.only_handle_for_test();
|
||||||
|
drop(custody);
|
||||||
|
drop(repository);
|
||||||
|
|
||||||
|
let repository = Repository::open(temp.path()).await.unwrap();
|
||||||
|
let (custody, _) = SecretCustody::start(repository.protected_secrets(), store.clone())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
custody
|
||||||
|
.initialize_endpoint_identity(&legacy_path)
|
||||||
|
.await
|
||||||
|
.unwrap(),
|
||||||
|
original
|
||||||
|
);
|
||||||
|
|
||||||
|
store.remove_for_test(&handle);
|
||||||
|
drop(custody);
|
||||||
|
drop(repository);
|
||||||
|
let repository = Repository::open(temp.path()).await.unwrap();
|
||||||
|
let (custody, summary) = SecretCustody::start(repository.protected_secrets(), store.clone())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(summary.disabled, 1);
|
||||||
|
assert!(matches!(
|
||||||
|
custody.initialize_endpoint_identity(&legacy_path).await,
|
||||||
|
Err(VnidropError::SecureStorageUnavailable { .. })
|
||||||
|
));
|
||||||
|
assert!(store.list_handles().unwrap().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn concurrent_first_starts_converge_on_one_protected_endpoint_identity() {
|
||||||
|
let temp = tempfile::tempdir().unwrap();
|
||||||
|
let legacy_path = temp.path().join("iroh.secret");
|
||||||
|
let repository = Repository::open(temp.path()).await.unwrap();
|
||||||
|
let store = Arc::new(FaultInjectingSecretStore::default());
|
||||||
|
let first = SecretCustody::new(repository.protected_secrets(), store.clone());
|
||||||
|
let second = SecretCustody::new(repository.protected_secrets(), store.clone());
|
||||||
|
|
||||||
|
let (first_identity, second_identity) = tokio::join!(
|
||||||
|
first.initialize_endpoint_identity(&legacy_path),
|
||||||
|
second.initialize_endpoint_identity(&legacy_path),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(first_identity.unwrap(), second_identity.unwrap());
|
||||||
|
assert_eq!(store.list_handles().unwrap().len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn protected_material_is_absent_from_database_and_diagnostics() {
|
||||||
|
let temp = tempfile::tempdir().unwrap();
|
||||||
|
let repository = Repository::open(temp.path()).await.unwrap();
|
||||||
|
let store = Arc::new(FaultInjectingSecretStore::default());
|
||||||
|
let custody = SecretCustody::new(repository.protected_secrets(), store.clone());
|
||||||
|
let raw = (0u8..32).map(|value| value + 1).collect::<Vec<_>>();
|
||||||
|
let encoded = HEXLOWER.encode(&raw);
|
||||||
|
let material = SecretMaterial::new(raw.clone()).unwrap();
|
||||||
|
|
||||||
|
let handle = custody
|
||||||
|
.protect(SecretKind::RelationshipGrant, material.clone(), None)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert!(handle
|
||||||
|
.as_str()
|
||||||
|
.starts_with("vnidrop/v1/relationship-grant/"));
|
||||||
|
assert_eq!(format!("{material:?}"), "SecretMaterial(redacted)");
|
||||||
|
store.corrupt_for_test(&handle);
|
||||||
|
let error = custody.load(&handle).await.unwrap_err().to_string();
|
||||||
|
assert!(!error.contains(&encoded));
|
||||||
|
|
||||||
|
let captured = CapturedOutput::default();
|
||||||
|
let writer_output = captured.clone();
|
||||||
|
let subscriber = tracing_subscriber::fmt()
|
||||||
|
.without_time()
|
||||||
|
.with_writer(move || CapturedWriter(writer_output.clone()))
|
||||||
|
.finish();
|
||||||
|
let _subscriber = tracing::subscriber::set_default(subscriber);
|
||||||
|
tracing::info!(material = ?material, error, "custody diagnostic");
|
||||||
|
let diagnostics = String::from_utf8(captured.0.lock().unwrap().clone()).unwrap();
|
||||||
|
assert!(!diagnostics.contains(&encoded));
|
||||||
|
|
||||||
|
assert!(repository.list_events(None, 500).await.unwrap().is_empty());
|
||||||
|
|
||||||
|
let mut persisted = Vec::new();
|
||||||
|
for entry in std::fs::read_dir(temp.path()).unwrap() {
|
||||||
|
let path = entry.unwrap().path();
|
||||||
|
if path.is_file() {
|
||||||
|
persisted.extend(std::fs::read(path).unwrap());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert!(!persisted.windows(raw.len()).any(|window| window == raw));
|
||||||
|
assert!(!persisted
|
||||||
|
.windows(encoded.len())
|
||||||
|
.any(|window| window == encoded.as_bytes()));
|
||||||
|
}
|
||||||
180
crates/vnidrop/src/tests/secure_secret_android.rs
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
use std::{
|
||||||
|
collections::HashMap,
|
||||||
|
fs,
|
||||||
|
sync::{Arc, Mutex},
|
||||||
|
};
|
||||||
|
|
||||||
|
use tempfile::TempDir;
|
||||||
|
|
||||||
|
use crate::secure_secret::{
|
||||||
|
android::{
|
||||||
|
secret_handle_for_test, AndroidKeystore, AndroidSealedValue, AndroidSecureSecretStore,
|
||||||
|
},
|
||||||
|
SecretHandle, SecretMaterial, SecureSecretStore, SecureSecretStoreError,
|
||||||
|
};
|
||||||
|
|
||||||
|
const TEST_SECRET_BYTES: usize = 32;
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct FakeKeystore {
|
||||||
|
keys: Mutex<HashMap<String, u8>>,
|
||||||
|
seal_failure: Mutex<Option<SecureSecretStoreError>>,
|
||||||
|
delete_failure: Mutex<Option<SecureSecretStoreError>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AndroidKeystore for FakeKeystore {
|
||||||
|
fn seal(
|
||||||
|
&self,
|
||||||
|
alias: &str,
|
||||||
|
plaintext: &[u8],
|
||||||
|
) -> Result<AndroidSealedValue, SecureSecretStoreError> {
|
||||||
|
if let Some(error) = self.seal_failure.lock().unwrap().take() {
|
||||||
|
return Err(error);
|
||||||
|
}
|
||||||
|
let mask = 0xa7;
|
||||||
|
self.keys.lock().unwrap().insert(alias.to_string(), mask);
|
||||||
|
Ok(AndroidSealedValue {
|
||||||
|
nonce: vec![4; 12],
|
||||||
|
ciphertext: plaintext.iter().map(|byte| byte ^ mask).collect(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn open(
|
||||||
|
&self,
|
||||||
|
alias: &str,
|
||||||
|
sealed: &AndroidSealedValue,
|
||||||
|
) -> Result<Vec<u8>, SecureSecretStoreError> {
|
||||||
|
let mask = *self
|
||||||
|
.keys
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.get(alias)
|
||||||
|
.ok_or(SecureSecretStoreError::Missing)?;
|
||||||
|
Ok(sealed.ciphertext.iter().map(|byte| byte ^ mask).collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn delete(&self, alias: &str) -> Result<(), SecureSecretStoreError> {
|
||||||
|
if let Some(error) = self.delete_failure.lock().unwrap().take() {
|
||||||
|
return Err(error);
|
||||||
|
}
|
||||||
|
self.keys.lock().unwrap().remove(alias);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fixture() -> (TempDir, AndroidSecureSecretStore, Arc<FakeKeystore>) {
|
||||||
|
let directory = TempDir::new().unwrap();
|
||||||
|
let keystore = Arc::new(FakeKeystore::default());
|
||||||
|
let store = AndroidSecureSecretStore::new(directory.path(), keystore.clone()).unwrap();
|
||||||
|
(directory, store, keystore)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle() -> SecretHandle {
|
||||||
|
secret_handle_for_test("vnidrop/v1/endpoint-identity/test")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn adapter_round_trips_lists_and_deletes_without_plaintext_persistence() {
|
||||||
|
let (directory, store, keystore) = fixture();
|
||||||
|
let handle = handle();
|
||||||
|
let plaintext = vec![0x5a; TEST_SECRET_BYTES];
|
||||||
|
let material = SecretMaterial::new(plaintext.clone()).unwrap();
|
||||||
|
|
||||||
|
store.put(&handle, material.clone()).unwrap();
|
||||||
|
|
||||||
|
let persisted = fs::read(store.record_path_for_test(&handle)).unwrap();
|
||||||
|
assert!(!persisted
|
||||||
|
.windows(plaintext.len())
|
||||||
|
.any(|window| window == plaintext));
|
||||||
|
|
||||||
|
drop(store);
|
||||||
|
let restarted = AndroidSecureSecretStore::new(directory.path(), keystore).unwrap();
|
||||||
|
assert_eq!(restarted.list_handles().unwrap(), vec![handle.clone()]);
|
||||||
|
assert_eq!(restarted.get(&handle).unwrap(), material);
|
||||||
|
|
||||||
|
restarted.delete(&handle).unwrap();
|
||||||
|
assert!(restarted.list_handles().unwrap().is_empty());
|
||||||
|
assert!(matches!(
|
||||||
|
restarted.get(&handle),
|
||||||
|
Err(SecureSecretStoreError::Missing)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn staged_crash_record_remains_discoverable_and_fails_closed() {
|
||||||
|
let (_directory, store, _keystore) = fixture();
|
||||||
|
let handle = handle();
|
||||||
|
store.stage_for_test(&handle).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(store.list_handles().unwrap(), vec![handle.clone()]);
|
||||||
|
assert!(matches!(
|
||||||
|
store.get(&handle),
|
||||||
|
Err(SecureSecretStoreError::Corrupted)
|
||||||
|
));
|
||||||
|
store.delete(&handle).unwrap();
|
||||||
|
assert!(store.list_handles().unwrap().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tampering_and_missing_keystore_keys_are_distinct_failures() {
|
||||||
|
let (_directory, store, keystore) = fixture();
|
||||||
|
let handle = handle();
|
||||||
|
store
|
||||||
|
.put(
|
||||||
|
&handle,
|
||||||
|
SecretMaterial::new(vec![9; TEST_SECRET_BYTES]).unwrap(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
keystore.keys.lock().unwrap().clear();
|
||||||
|
assert!(matches!(
|
||||||
|
store.get(&handle),
|
||||||
|
Err(SecureSecretStoreError::Missing)
|
||||||
|
));
|
||||||
|
|
||||||
|
fs::write(store.record_path_for_test(&handle), b"tampered").unwrap();
|
||||||
|
assert!(matches!(
|
||||||
|
store.get(&handle),
|
||||||
|
Err(SecureSecretStoreError::Corrupted)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn failed_key_deletion_retains_the_record_for_safe_retry() {
|
||||||
|
let (_directory, store, keystore) = fixture();
|
||||||
|
let handle = handle();
|
||||||
|
store
|
||||||
|
.put(
|
||||||
|
&handle,
|
||||||
|
SecretMaterial::new(vec![7; TEST_SECRET_BYTES]).unwrap(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
*keystore.delete_failure.lock().unwrap() = Some(SecureSecretStoreError::Locked);
|
||||||
|
|
||||||
|
assert!(matches!(
|
||||||
|
store.delete(&handle),
|
||||||
|
Err(SecureSecretStoreError::Locked)
|
||||||
|
));
|
||||||
|
assert_eq!(store.list_handles().unwrap(), vec![handle.clone()]);
|
||||||
|
|
||||||
|
store.delete(&handle).unwrap();
|
||||||
|
assert!(store.list_handles().unwrap().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn failed_replacement_keeps_the_previous_secret_readable() {
|
||||||
|
let (_directory, store, keystore) = fixture();
|
||||||
|
let handle = handle();
|
||||||
|
let original = SecretMaterial::new(vec![3; TEST_SECRET_BYTES]).unwrap();
|
||||||
|
store.put(&handle, original.clone()).unwrap();
|
||||||
|
*keystore.seal_failure.lock().unwrap() = Some(SecureSecretStoreError::Locked);
|
||||||
|
|
||||||
|
assert!(matches!(
|
||||||
|
store.put(
|
||||||
|
&handle,
|
||||||
|
SecretMaterial::new(vec![8; TEST_SECRET_BYTES]).unwrap()
|
||||||
|
),
|
||||||
|
Err(SecureSecretStoreError::Locked)
|
||||||
|
));
|
||||||
|
assert_eq!(store.get(&handle).unwrap(), original);
|
||||||
|
}
|
||||||
188
crates/vnidrop/src/tests/secure_secret_apple.rs
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
use std::{
|
||||||
|
collections::HashMap,
|
||||||
|
sync::{Arc, Mutex},
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::secure_secret::{
|
||||||
|
apple::{
|
||||||
|
expected_policy_for_test, handle_for_test, map_status_for_test, service_for_test,
|
||||||
|
AppleKeychainApi, AppleKeychainPolicy, AppleKeychainSecretStore,
|
||||||
|
},
|
||||||
|
SecretMaterial, SecureSecretStore, SecureSecretStoreError,
|
||||||
|
};
|
||||||
|
|
||||||
|
const ERR_SEC_AUTH_FAILED: i32 = -25_293;
|
||||||
|
const ERR_SEC_NOT_AVAILABLE: i32 = -25_291;
|
||||||
|
const ERR_SEC_ITEM_NOT_FOUND: i32 = -25_300;
|
||||||
|
const ERR_SEC_INTERACTION_NOT_ALLOWED: i32 = -25_308;
|
||||||
|
const ERR_SEC_DECODE: i32 = -26_275;
|
||||||
|
|
||||||
|
#[derive(Clone, Default)]
|
||||||
|
struct RecordingKeychain {
|
||||||
|
state: Arc<Mutex<RecordingState>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct RecordingState {
|
||||||
|
entries: HashMap<(String, String), Vec<u8>>,
|
||||||
|
last_policy: Option<AppleKeychainPolicy>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AppleKeychainApi for RecordingKeychain {
|
||||||
|
fn put(
|
||||||
|
&self,
|
||||||
|
service: &str,
|
||||||
|
account: &str,
|
||||||
|
material: &[u8],
|
||||||
|
policy: AppleKeychainPolicy,
|
||||||
|
) -> Result<(), i32> {
|
||||||
|
let mut state = self.state.lock().unwrap();
|
||||||
|
state.last_policy = Some(policy);
|
||||||
|
state.entries.insert(
|
||||||
|
(service.to_string(), account.to_string()),
|
||||||
|
material.to_vec(),
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get(&self, service: &str, account: &str) -> Result<Vec<u8>, i32> {
|
||||||
|
self.state
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.entries
|
||||||
|
.get(&(service.to_string(), account.to_string()))
|
||||||
|
.cloned()
|
||||||
|
.ok_or(ERR_SEC_ITEM_NOT_FOUND)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn delete(&self, service: &str, account: &str) -> Result<(), i32> {
|
||||||
|
self.state
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.entries
|
||||||
|
.remove(&(service.to_string(), account.to_string()))
|
||||||
|
.map(|_| ())
|
||||||
|
.ok_or(ERR_SEC_ITEM_NOT_FOUND)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn list_accounts(&self, service: &str) -> Result<Vec<String>, i32> {
|
||||||
|
Ok(self
|
||||||
|
.state
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.entries
|
||||||
|
.keys()
|
||||||
|
.filter(|(entry_service, _)| entry_service == service)
|
||||||
|
.map(|(_, account)| account.clone())
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn adapter_creates_replaces_reads_lists_and_deletes_only_its_service() {
|
||||||
|
let api = RecordingKeychain::default();
|
||||||
|
api.state.lock().unwrap().entries.insert(
|
||||||
|
("com.example.unrelated".to_string(), "leave-me".to_string()),
|
||||||
|
vec![0x77; 32],
|
||||||
|
);
|
||||||
|
let store = AppleKeychainSecretStore::with_api(api.clone());
|
||||||
|
let owned = handle_for_test("vnidrop/v1/endpoint-identity/apple-test");
|
||||||
|
|
||||||
|
store
|
||||||
|
.put(&owned, SecretMaterial::new(vec![0x31; 32]).unwrap())
|
||||||
|
.unwrap();
|
||||||
|
drop(store);
|
||||||
|
|
||||||
|
let reopened_store = AppleKeychainSecretStore::with_api(api.clone());
|
||||||
|
assert_eq!(
|
||||||
|
reopened_store.get(&owned).unwrap(),
|
||||||
|
SecretMaterial::new(vec![0x31; 32]).unwrap()
|
||||||
|
);
|
||||||
|
reopened_store
|
||||||
|
.put(&owned, SecretMaterial::new(vec![0x42; 32]).unwrap())
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
reopened_store.get(&owned).unwrap(),
|
||||||
|
SecretMaterial::new(vec![0x42; 32]).unwrap()
|
||||||
|
);
|
||||||
|
assert_eq!(reopened_store.list_handles().unwrap(), vec![owned.clone()]);
|
||||||
|
reopened_store.delete(&owned).unwrap();
|
||||||
|
assert!(matches!(
|
||||||
|
reopened_store.get(&owned),
|
||||||
|
Err(SecureSecretStoreError::Missing)
|
||||||
|
));
|
||||||
|
assert!(api
|
||||||
|
.state
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.entries
|
||||||
|
.contains_key(&("com.example.unrelated".to_string(), "leave-me".to_string())));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn adapter_always_requests_device_local_non_synchronizing_protection() {
|
||||||
|
let api = RecordingKeychain::default();
|
||||||
|
let store = AppleKeychainSecretStore::with_api(api.clone());
|
||||||
|
|
||||||
|
store
|
||||||
|
.put(
|
||||||
|
&handle_for_test("vnidrop/v1/relationship-grant/apple-policy"),
|
||||||
|
SecretMaterial::new(vec![0x51; 32]).unwrap(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
api.state.lock().unwrap().last_policy,
|
||||||
|
Some(expected_policy_for_test())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn apple_statuses_map_to_fail_closed_contract_outcomes() {
|
||||||
|
assert!(matches!(
|
||||||
|
map_status_for_test(ERR_SEC_INTERACTION_NOT_ALLOWED),
|
||||||
|
SecureSecretStoreError::Locked
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
map_status_for_test(ERR_SEC_AUTH_FAILED),
|
||||||
|
SecureSecretStoreError::Locked
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
map_status_for_test(ERR_SEC_ITEM_NOT_FOUND),
|
||||||
|
SecureSecretStoreError::Missing
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
map_status_for_test(ERR_SEC_DECODE),
|
||||||
|
SecureSecretStoreError::Corrupted
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
map_status_for_test(ERR_SEC_NOT_AVAILABLE),
|
||||||
|
SecureSecretStoreError::Unavailable
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
map_status_for_test(-1),
|
||||||
|
SecureSecretStoreError::Unavailable
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn malformed_keychain_values_are_corrupted_without_diagnostic_disclosure() {
|
||||||
|
let api = RecordingKeychain::default();
|
||||||
|
let secret = vec![0x6d; 31];
|
||||||
|
api.state.lock().unwrap().entries.insert(
|
||||||
|
(
|
||||||
|
service_for_test().to_string(),
|
||||||
|
"vnidrop/v1/pairing-eligibility/corrupt".to_string(),
|
||||||
|
),
|
||||||
|
secret.clone(),
|
||||||
|
);
|
||||||
|
let store = AppleKeychainSecretStore::with_api(api);
|
||||||
|
|
||||||
|
let error = store
|
||||||
|
.get(&handle_for_test("vnidrop/v1/pairing-eligibility/corrupt"))
|
||||||
|
.unwrap_err();
|
||||||
|
|
||||||
|
assert!(matches!(&error, SecureSecretStoreError::Corrupted));
|
||||||
|
assert!(!format!("{error:?}").contains(&data_encoding::HEXLOWER.encode(&secret)));
|
||||||
|
}
|
||||||
169
crates/vnidrop/src/tests/secure_secret_linux.rs
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
use std::{
|
||||||
|
collections::HashMap,
|
||||||
|
sync::{Arc, Mutex},
|
||||||
|
};
|
||||||
|
|
||||||
|
use secret_service::Error;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
repository::Repository,
|
||||||
|
secure_secret::{
|
||||||
|
linux::{map_error, LinuxSecretServiceApi, LinuxSecretServiceStore},
|
||||||
|
SecretCustody, SecretHandle, SecretKind, SecretMaterial, SecureSecretStore,
|
||||||
|
SecureSecretStoreError,
|
||||||
|
},
|
||||||
|
VnidropError,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct RecordingSecretService {
|
||||||
|
values: Mutex<HashMap<String, Vec<u8>>>,
|
||||||
|
failure: Mutex<Option<SecureSecretStoreError>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RecordingSecretService {
|
||||||
|
fn failure(&self) -> Result<(), SecureSecretStoreError> {
|
||||||
|
match &*self.failure.lock().unwrap() {
|
||||||
|
Some(SecureSecretStoreError::Locked) => Err(SecureSecretStoreError::Locked),
|
||||||
|
Some(SecureSecretStoreError::Missing) => Err(SecureSecretStoreError::Missing),
|
||||||
|
Some(SecureSecretStoreError::Corrupted) => Err(SecureSecretStoreError::Corrupted),
|
||||||
|
Some(SecureSecretStoreError::Unavailable) => Err(SecureSecretStoreError::Unavailable),
|
||||||
|
None => Ok(()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LinuxSecretServiceApi for RecordingSecretService {
|
||||||
|
fn put(&self, handle: &str, material: &[u8]) -> Result<(), SecureSecretStoreError> {
|
||||||
|
self.failure()?;
|
||||||
|
self.values
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.insert(handle.to_string(), material.to_vec());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get(&self, handle: &str) -> Result<Vec<u8>, SecureSecretStoreError> {
|
||||||
|
self.failure()?;
|
||||||
|
self.values
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.get(handle)
|
||||||
|
.cloned()
|
||||||
|
.ok_or(SecureSecretStoreError::Missing)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn delete(&self, handle: &str) -> Result<(), SecureSecretStoreError> {
|
||||||
|
self.failure()?;
|
||||||
|
self.values
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.remove(handle)
|
||||||
|
.map(|_| ())
|
||||||
|
.ok_or(SecureSecretStoreError::Missing)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn list_handles(&self) -> Result<Vec<String>, SecureSecretStoreError> {
|
||||||
|
self.failure()?;
|
||||||
|
Ok(self.values.lock().unwrap().keys().cloned().collect())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle(suffix: &str) -> SecretHandle {
|
||||||
|
crate::secure_secret::secret_handle_for_test(format!("vnidrop/v1/relationship-grant/{suffix}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn adapter_survives_restart_and_deletes_only_the_selected_item() {
|
||||||
|
let api = Arc::new(RecordingSecretService::default());
|
||||||
|
let first = handle("first");
|
||||||
|
let second = handle("second");
|
||||||
|
let material = SecretMaterial::new(vec![0x5a; 32]).unwrap();
|
||||||
|
let store = LinuxSecretServiceStore::with_api(api.clone());
|
||||||
|
store.put(&first, material.clone()).unwrap();
|
||||||
|
store
|
||||||
|
.put(&second, SecretMaterial::new(vec![0x6b; 32]).unwrap())
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let restarted = LinuxSecretServiceStore::with_api(api);
|
||||||
|
assert_eq!(restarted.get(&first).unwrap(), material);
|
||||||
|
assert_eq!(
|
||||||
|
restarted.list_handles().unwrap(),
|
||||||
|
vec![first.clone(), second]
|
||||||
|
);
|
||||||
|
restarted.delete(&first).unwrap();
|
||||||
|
assert!(matches!(
|
||||||
|
restarted.get(&first),
|
||||||
|
Err(SecureSecretStoreError::Missing)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn transient_backend_failures_do_not_delete_protected_metadata_or_material() {
|
||||||
|
let temp = tempfile::tempdir().unwrap();
|
||||||
|
let repository = Repository::open(temp.path()).await.unwrap();
|
||||||
|
let api = Arc::new(RecordingSecretService::default());
|
||||||
|
let store = Arc::new(LinuxSecretServiceStore::with_api(api.clone()));
|
||||||
|
let custody = SecretCustody::new(repository.protected_secrets(), store.clone());
|
||||||
|
let protected = custody
|
||||||
|
.protect(
|
||||||
|
SecretKind::RelationshipGrant,
|
||||||
|
SecretMaterial::new(vec![0x7c; 32]).unwrap(),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
*api.failure.lock().unwrap() = Some(SecureSecretStoreError::Unavailable);
|
||||||
|
drop(custody);
|
||||||
|
assert!(matches!(
|
||||||
|
SecretCustody::start(repository.protected_secrets(), store.clone()).await,
|
||||||
|
Err(VnidropError::SecureStorageUnavailable { .. })
|
||||||
|
));
|
||||||
|
|
||||||
|
*api.failure.lock().unwrap() = None;
|
||||||
|
let (restarted, _) = SecretCustody::start(repository.protected_secrets(), store)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
restarted.load(&protected).await.unwrap(),
|
||||||
|
SecretMaterial::new(vec![0x7c; 32]).unwrap()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn failures_are_typed_and_secret_material_is_redacted() {
|
||||||
|
let api = Arc::new(RecordingSecretService::default());
|
||||||
|
let store = LinuxSecretServiceStore::with_api(api.clone());
|
||||||
|
let secret = SecretMaterial::new(vec![0x7c; 32]).unwrap();
|
||||||
|
assert_eq!(format!("{secret:?}"), "SecretMaterial(redacted)");
|
||||||
|
|
||||||
|
for failure in [
|
||||||
|
SecureSecretStoreError::Locked,
|
||||||
|
SecureSecretStoreError::Unavailable,
|
||||||
|
SecureSecretStoreError::Corrupted,
|
||||||
|
] {
|
||||||
|
*api.failure.lock().unwrap() = Some(failure);
|
||||||
|
assert!(store.get(&handle("failure")).is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn secret_service_errors_map_without_exposing_details() {
|
||||||
|
assert!(matches!(
|
||||||
|
map_error(Error::Locked),
|
||||||
|
SecureSecretStoreError::Locked
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
map_error(Error::NoResult),
|
||||||
|
SecureSecretStoreError::Missing
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
map_error(Error::Crypto("distinctive-secret")),
|
||||||
|
SecureSecretStoreError::Corrupted
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
map_error(Error::Unavailable),
|
||||||
|
SecureSecretStoreError::Unavailable
|
||||||
|
));
|
||||||
|
}
|
||||||
186
crates/vnidrop/src/tests/secure_secret_windows.rs
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
use std::{fs, sync::Arc};
|
||||||
|
|
||||||
|
use data_encoding::HEXLOWER;
|
||||||
|
use iroh::SecretKey;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
repository::Repository,
|
||||||
|
secure_secret::{
|
||||||
|
windows::WindowsDpapiSecretStore, CustodyCrashPoint, SecretCustody, SecretMaterial,
|
||||||
|
SecureSecretStore, SecureSecretStoreError,
|
||||||
|
},
|
||||||
|
VnidropError,
|
||||||
|
};
|
||||||
|
|
||||||
|
fn handle() -> crate::secure_secret::SecretHandle {
|
||||||
|
WindowsDpapiSecretStore::relationship_handle_for_test()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn material(seed: u8) -> SecretMaterial {
|
||||||
|
SecretMaterial::new(vec![seed; 32]).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn round_trip_survives_adapter_restart_and_never_persists_plaintext() {
|
||||||
|
let directory = tempfile::tempdir().unwrap();
|
||||||
|
let handle = handle();
|
||||||
|
let secret = material(0xa7);
|
||||||
|
|
||||||
|
WindowsDpapiSecretStore::new(directory.path())
|
||||||
|
.unwrap()
|
||||||
|
.put(&handle, secret.clone())
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let restarted = WindowsDpapiSecretStore::new(directory.path()).unwrap();
|
||||||
|
assert_eq!(restarted.get(&handle).unwrap(), secret);
|
||||||
|
assert_eq!(restarted.list_handles().unwrap(), vec![handle]);
|
||||||
|
|
||||||
|
for entry in fs::read_dir(directory.path()).unwrap() {
|
||||||
|
let bytes = fs::read(entry.unwrap().path()).unwrap();
|
||||||
|
assert!(!bytes.windows(32).any(|window| window == [0xa7; 32]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn delete_removes_only_the_selected_protected_value() {
|
||||||
|
let directory = tempfile::tempdir().unwrap();
|
||||||
|
let store = WindowsDpapiSecretStore::new(directory.path()).unwrap();
|
||||||
|
let retained = handle();
|
||||||
|
let removed = handle();
|
||||||
|
store.put(&retained, material(1)).unwrap();
|
||||||
|
store.put(&removed, material(2)).unwrap();
|
||||||
|
|
||||||
|
store.delete(&removed).unwrap();
|
||||||
|
|
||||||
|
assert!(matches!(
|
||||||
|
store.get(&removed),
|
||||||
|
Err(SecureSecretStoreError::Missing)
|
||||||
|
));
|
||||||
|
assert_eq!(store.get(&retained).unwrap(), material(1));
|
||||||
|
assert_eq!(store.list_handles().unwrap(), vec![retained]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn repeated_put_is_idempotent_and_atomically_updates_changed_material() {
|
||||||
|
let directory = tempfile::tempdir().unwrap();
|
||||||
|
let store = WindowsDpapiSecretStore::new(directory.path()).unwrap();
|
||||||
|
let handle = handle();
|
||||||
|
|
||||||
|
store.put(&handle, material(6)).unwrap();
|
||||||
|
let first_blob = fs::read(store.path_for_test(&handle)).unwrap();
|
||||||
|
store.put(&handle, material(6)).unwrap();
|
||||||
|
assert_eq!(fs::read(store.path_for_test(&handle)).unwrap(), first_blob);
|
||||||
|
|
||||||
|
store.put(&handle, material(7)).unwrap();
|
||||||
|
assert_eq!(store.get(&handle).unwrap(), material(7));
|
||||||
|
assert!(!fs::read(store.path_for_test(&handle))
|
||||||
|
.unwrap()
|
||||||
|
.windows(32)
|
||||||
|
.any(|window| window == [7; 32]));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn missing_corrupt_and_wrong_context_values_fail_closed() {
|
||||||
|
let directory = tempfile::tempdir().unwrap();
|
||||||
|
let handle = handle();
|
||||||
|
let store = WindowsDpapiSecretStore::new(directory.path()).unwrap();
|
||||||
|
assert!(matches!(
|
||||||
|
store.get(&handle),
|
||||||
|
Err(SecureSecretStoreError::Missing)
|
||||||
|
));
|
||||||
|
|
||||||
|
store.put(&handle, material(3)).unwrap();
|
||||||
|
fs::write(store.path_for_test(&handle), b"not a protected envelope").unwrap();
|
||||||
|
assert!(matches!(
|
||||||
|
store.get(&handle),
|
||||||
|
Err(SecureSecretStoreError::Corrupted)
|
||||||
|
));
|
||||||
|
|
||||||
|
let isolated = tempfile::tempdir().unwrap();
|
||||||
|
let original =
|
||||||
|
WindowsDpapiSecretStore::with_context_for_test(isolated.path(), b"first-context").unwrap();
|
||||||
|
original.put(&handle, material(4)).unwrap();
|
||||||
|
let wrong_context =
|
||||||
|
WindowsDpapiSecretStore::with_context_for_test(isolated.path(), b"second-context").unwrap();
|
||||||
|
assert!(matches!(
|
||||||
|
wrong_context.get(&handle),
|
||||||
|
Err(SecureSecretStoreError::Corrupted)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn interrupted_replacement_preserves_the_previous_value() {
|
||||||
|
let directory = tempfile::tempdir().unwrap();
|
||||||
|
let handle = handle();
|
||||||
|
let store = WindowsDpapiSecretStore::new(directory.path()).unwrap();
|
||||||
|
store.put(&handle, material(5)).unwrap();
|
||||||
|
let temporary = directory.path().join("interrupted.tmp-123");
|
||||||
|
fs::write(&temporary, b"incomplete protected replacement").unwrap();
|
||||||
|
|
||||||
|
let restarted = WindowsDpapiSecretStore::new(directory.path()).unwrap();
|
||||||
|
|
||||||
|
assert!(!temporary.exists());
|
||||||
|
assert_eq!(restarted.get(&handle).unwrap(), material(5));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unusable_backing_path_is_reported_as_unavailable() {
|
||||||
|
let directory = tempfile::tempdir().unwrap();
|
||||||
|
let file = directory.path().join("not-a-directory");
|
||||||
|
fs::write(&file, b"occupied").unwrap();
|
||||||
|
|
||||||
|
assert!(matches!(
|
||||||
|
WindowsDpapiSecretStore::new(&file),
|
||||||
|
Err(SecureSecretStoreError::Unavailable)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn endpoint_migration_survives_activation_crash_without_changing_identity() {
|
||||||
|
let directory = tempfile::tempdir().unwrap();
|
||||||
|
let app_data = directory.path().join("app-data");
|
||||||
|
fs::create_dir(&app_data).unwrap();
|
||||||
|
let legacy = app_data.join("iroh.secret");
|
||||||
|
let original = SecretKey::generate();
|
||||||
|
fs::write(&legacy, HEXLOWER.encode(&original.to_bytes())).unwrap();
|
||||||
|
|
||||||
|
let repository = Repository::open(&app_data).await.unwrap();
|
||||||
|
let protected_directory = app_data.join("protected-secrets");
|
||||||
|
let store = Arc::new(WindowsDpapiSecretStore::new(&protected_directory).unwrap());
|
||||||
|
let custody = SecretCustody::new(repository.protected_secrets(), store);
|
||||||
|
custody.crash_once_at(CustodyCrashPoint::MetadataActivation);
|
||||||
|
assert!(custody
|
||||||
|
.migrate_legacy_endpoint_identity(&legacy)
|
||||||
|
.await
|
||||||
|
.is_err());
|
||||||
|
assert!(legacy.exists());
|
||||||
|
drop(custody);
|
||||||
|
drop(repository);
|
||||||
|
|
||||||
|
let repository = Repository::open(&app_data).await.unwrap();
|
||||||
|
let restarted_store = Arc::new(WindowsDpapiSecretStore::new(&protected_directory).unwrap());
|
||||||
|
let (custody, _) = SecretCustody::start(repository.protected_secrets(), restarted_store)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let handle = custody
|
||||||
|
.migrate_legacy_endpoint_identity(&legacy)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(!legacy.exists());
|
||||||
|
assert_eq!(
|
||||||
|
custody.load(&handle).await.unwrap(),
|
||||||
|
SecretMaterial::new(original.to_bytes().to_vec()).unwrap()
|
||||||
|
);
|
||||||
|
|
||||||
|
let replacement = SecretKey::generate();
|
||||||
|
fs::write(&legacy, HEXLOWER.encode(&replacement.to_bytes())).unwrap();
|
||||||
|
assert!(matches!(
|
||||||
|
custody.migrate_legacy_endpoint_identity(&legacy).await,
|
||||||
|
Err(VnidropError::SecureStorageCorrupted { .. })
|
||||||
|
));
|
||||||
|
assert!(legacy.exists());
|
||||||
|
assert_eq!(
|
||||||
|
custody.load(&handle).await.unwrap(),
|
||||||
|
SecretMaterial::new(original.to_bytes().to_vec()).unwrap()
|
||||||
|
);
|
||||||
|
}
|
||||||
141
crates/vnidrop/tests/experimental_domain.rs
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
mod support;
|
||||||
|
|
||||||
|
use support::TestNode;
|
||||||
|
use vnidrop::{
|
||||||
|
experimental_saved_device_capabilities, DeviceRelationship, DeviceRelationshipState,
|
||||||
|
ExperimentalSavedDeviceCapabilities, SavedDevice, ShareMetadataInput, ShareSource, SourceKind,
|
||||||
|
TargetedTransfer, TargetedTransferState, TransferAccessMode, VnidropError,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn saved_device_protocols_are_explicitly_experimental_and_versioned() {
|
||||||
|
assert_eq!(
|
||||||
|
experimental_saved_device_capabilities(),
|
||||||
|
ExperimentalSavedDeviceCapabilities {
|
||||||
|
domain_contract_version: 1,
|
||||||
|
relationship_protocol_version: 1,
|
||||||
|
targeted_transfer_protocol_version: 1,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn saved_devices_relationships_and_targeted_transfers_are_distinct_contracts() {
|
||||||
|
let device = SavedDevice {
|
||||||
|
endpoint_id: "receiver-endpoint".to_string(),
|
||||||
|
local_label: Some("Kitchen tablet".to_string()),
|
||||||
|
remote_display_name: Some("Tablet".to_string()),
|
||||||
|
created_at: 1_000,
|
||||||
|
last_authenticated_at: Some(2_000),
|
||||||
|
};
|
||||||
|
let relationship = DeviceRelationship {
|
||||||
|
remote_endpoint_id: device.endpoint_id.clone(),
|
||||||
|
state: DeviceRelationshipState::Saved,
|
||||||
|
generation: 4,
|
||||||
|
minimum_protocol_version: 1,
|
||||||
|
created_at: 1_000,
|
||||||
|
updated_at: 2_000,
|
||||||
|
};
|
||||||
|
let transfer = TargetedTransfer {
|
||||||
|
id: "targeted-transfer-id".to_string(),
|
||||||
|
sender_endpoint_id: "sender-endpoint".to_string(),
|
||||||
|
receiver_endpoint_id: device.endpoint_id.clone(),
|
||||||
|
manifest_id: "immutable-manifest-id".to_string(),
|
||||||
|
file_count: 2,
|
||||||
|
total_size: 42,
|
||||||
|
state: TargetedTransferState::AwaitingApproval,
|
||||||
|
created_at: 3_000,
|
||||||
|
updated_at: 3_000,
|
||||||
|
};
|
||||||
|
|
||||||
|
assert_eq!(relationship.remote_endpoint_id, device.endpoint_id);
|
||||||
|
assert_eq!(relationship.state, DeviceRelationshipState::Saved);
|
||||||
|
assert_eq!(
|
||||||
|
transfer.receiver_endpoint_id,
|
||||||
|
relationship.remote_endpoint_id
|
||||||
|
);
|
||||||
|
assert_eq!(transfer.state, TargetedTransferState::AwaitingApproval);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn targeted_transfer_transitions_are_validated_by_the_domain() {
|
||||||
|
use TargetedTransferState as State;
|
||||||
|
|
||||||
|
let valid = [
|
||||||
|
(State::Preparing, State::Offering),
|
||||||
|
(State::Offering, State::AwaitingApproval),
|
||||||
|
(State::AwaitingApproval, State::Approved),
|
||||||
|
(State::AwaitingApproval, State::Declined),
|
||||||
|
(State::Approved, State::Connecting),
|
||||||
|
(State::Connecting, State::Transferring),
|
||||||
|
(State::Connecting, State::Interrupted),
|
||||||
|
(State::Transferring, State::Completed),
|
||||||
|
(State::Transferring, State::Interrupted),
|
||||||
|
(State::Interrupted, State::Connecting),
|
||||||
|
(State::Completed, State::Deleted),
|
||||||
|
(State::Declined, State::Deleted),
|
||||||
|
(State::Cancelled, State::Deleted),
|
||||||
|
(State::Failed, State::Deleted),
|
||||||
|
];
|
||||||
|
for (current, next) in valid {
|
||||||
|
current
|
||||||
|
.validate_transition_to(next)
|
||||||
|
.unwrap_or_else(|error| panic!("{current:?} -> {next:?} failed: {error}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let error = State::Completed
|
||||||
|
.validate_transition_to(State::Transferring)
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(matches!(error, VnidropError::InvalidTransition { .. }));
|
||||||
|
assert_eq!(
|
||||||
|
error.to_string(),
|
||||||
|
"invalid targeted transfer transition: completed -> transferring"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn experimental_domain_seam_does_not_change_multi_receiver_shares() {
|
||||||
|
let source_dir = tempfile::tempdir().unwrap();
|
||||||
|
let first_output = tempfile::tempdir().unwrap();
|
||||||
|
let second_output = tempfile::tempdir().unwrap();
|
||||||
|
let source_path = source_dir.path().join("shared.txt");
|
||||||
|
std::fs::write(&source_path, b"shared with both receivers").unwrap();
|
||||||
|
let sender = TestNode::new();
|
||||||
|
let first_receiver = TestNode::new();
|
||||||
|
let second_receiver = TestNode::new();
|
||||||
|
let share = sender
|
||||||
|
.core
|
||||||
|
.share_files(
|
||||||
|
vec![ShareSource {
|
||||||
|
kind: SourceKind::Path,
|
||||||
|
value: source_path.to_string_lossy().into_owned(),
|
||||||
|
display_name: Some("shared.txt".to_string()),
|
||||||
|
is_directory: false,
|
||||||
|
}],
|
||||||
|
ShareMetadataInput {
|
||||||
|
transfer_id: 90_001,
|
||||||
|
transfer_name: Some("Existing share".to_string()),
|
||||||
|
sender_name: Some("Sender".to_string()),
|
||||||
|
access_mode: TransferAccessMode::Public,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
for (receiver, output) in [
|
||||||
|
(&first_receiver, first_output.path()),
|
||||||
|
(&second_receiver, second_output.path()),
|
||||||
|
] {
|
||||||
|
receiver
|
||||||
|
.core
|
||||||
|
.receive(
|
||||||
|
share.ticket.clone(),
|
||||||
|
output.to_string_lossy().into_owned(),
|
||||||
|
Some("Receiver".to_string()),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
std::fs::read(output.join("shared.txt")).unwrap(),
|
||||||
|
b"shared with both receivers"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
651
crates/vnidrop/tests/offer.rs
Normal file
@@ -0,0 +1,651 @@
|
|||||||
|
//! Send-to-contact offers between two real nodes.
|
||||||
|
|
||||||
|
mod support;
|
||||||
|
|
||||||
|
use std::{
|
||||||
|
path::Path,
|
||||||
|
sync::Arc,
|
||||||
|
time::{Duration, Instant},
|
||||||
|
};
|
||||||
|
|
||||||
|
use support::{RecordingSink, TestNode};
|
||||||
|
use vnidrop::{
|
||||||
|
ContactSendResult, IncomingOffer, ShareMetadataInput, ShareSource, SourceKind,
|
||||||
|
TransferAccessMode, VnidropCore, VnidropError,
|
||||||
|
};
|
||||||
|
|
||||||
|
fn endpoint_id(node: &TestNode) -> String {
|
||||||
|
node.core.status().endpoint_id
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Establish a one-way relationship: `issuer` becomes reachable by `holder`.
|
||||||
|
fn pair(issuer: &TestNode, holder: &TestNode) {
|
||||||
|
let issuer_id = endpoint_id(issuer);
|
||||||
|
issuer
|
||||||
|
.core
|
||||||
|
.allow_device_to_reach_me(endpoint_id(holder), Some("Issuer".to_string()))
|
||||||
|
.expect("grant delivered");
|
||||||
|
|
||||||
|
let started = Instant::now();
|
||||||
|
while !holder
|
||||||
|
.core
|
||||||
|
.list_pending_pairings()
|
||||||
|
.iter()
|
||||||
|
.any(|pending| pending.endpoint_id == issuer_id)
|
||||||
|
{
|
||||||
|
assert!(
|
||||||
|
started.elapsed() < Duration::from_secs(10),
|
||||||
|
"pairing offer never surfaced"
|
||||||
|
);
|
||||||
|
std::thread::sleep(Duration::from_millis(25));
|
||||||
|
}
|
||||||
|
holder
|
||||||
|
.core
|
||||||
|
.respond_to_pairing(issuer_id, true)
|
||||||
|
.expect("consent recorded");
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sources(path: &Path) -> Vec<ShareSource> {
|
||||||
|
vec![ShareSource {
|
||||||
|
kind: SourceKind::Path,
|
||||||
|
value: path.to_string_lossy().to_string(),
|
||||||
|
display_name: Some("shared.txt".to_string()),
|
||||||
|
is_directory: false,
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn metadata(transfer_id: u64) -> ShareMetadataInput {
|
||||||
|
ShareMetadataInput {
|
||||||
|
transfer_id,
|
||||||
|
transfer_name: Some("shared.txt".to_string()),
|
||||||
|
sender_name: Some("Sender".to_string()),
|
||||||
|
access_mode: TransferAccessMode::ApprovalRequired,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Send in the background: the call blocks until the receiver decides.
|
||||||
|
fn send_in_background(
|
||||||
|
core: Arc<VnidropCore>,
|
||||||
|
to: String,
|
||||||
|
path: &Path,
|
||||||
|
transfer_id: u64,
|
||||||
|
) -> std::thread::JoinHandle<Result<ContactSendResult, VnidropError>> {
|
||||||
|
let sources = sources(path);
|
||||||
|
std::thread::spawn(move || core.send_to_contact(to, sources, metadata(transfer_id)))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn wait_for_offer(core: &VnidropCore) -> IncomingOffer {
|
||||||
|
let started = Instant::now();
|
||||||
|
loop {
|
||||||
|
if let Some(offer) = core.list_pending_offers().into_iter().next() {
|
||||||
|
return offer;
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
started.elapsed() < Duration::from_secs(10),
|
||||||
|
"offer never surfaced on the receiver"
|
||||||
|
);
|
||||||
|
std::thread::sleep(Duration::from_millis(25));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The whole point: the receiver is asked exactly once, the sender not at all.
|
||||||
|
#[test]
|
||||||
|
fn an_accepted_offer_transfers_without_prompting_the_sender() {
|
||||||
|
let source_dir = tempfile::tempdir().unwrap();
|
||||||
|
let source_path = source_dir.path().join("shared.txt");
|
||||||
|
std::fs::write(&source_path, b"offered content").unwrap();
|
||||||
|
let output_dir = tempfile::tempdir().unwrap();
|
||||||
|
|
||||||
|
let sender = TestNode::new();
|
||||||
|
let receiver = TestNode::new();
|
||||||
|
// The sender must be able to reach the receiver, so the receiver issues.
|
||||||
|
pair(&receiver, &sender);
|
||||||
|
|
||||||
|
let handle = send_in_background(
|
||||||
|
sender.core.arc(),
|
||||||
|
endpoint_id(&receiver),
|
||||||
|
&source_path,
|
||||||
|
4_001,
|
||||||
|
);
|
||||||
|
|
||||||
|
let offer = wait_for_offer(&receiver.core);
|
||||||
|
assert_eq!(offer.from_endpoint_id, endpoint_id(&sender));
|
||||||
|
assert_eq!(offer.transfer_name, "shared.txt");
|
||||||
|
assert_eq!(offer.file_count, 1);
|
||||||
|
assert_eq!(offer.sender_display_name.as_deref(), Some("Sender"));
|
||||||
|
|
||||||
|
let ticket = receiver
|
||||||
|
.core
|
||||||
|
.respond_to_offer(offer.offer_id, true)
|
||||||
|
.expect("accepting yields the ticket");
|
||||||
|
let share = handle.join().unwrap().expect("offer accepted");
|
||||||
|
|
||||||
|
receiver
|
||||||
|
.core
|
||||||
|
.receive(
|
||||||
|
ticket,
|
||||||
|
output_dir.path().to_string_lossy().to_string(),
|
||||||
|
Some("Receiver".to_string()),
|
||||||
|
)
|
||||||
|
.expect("receive completes");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
std::fs::read(output_dir.path().join("shared.txt")).unwrap(),
|
||||||
|
b"offered content"
|
||||||
|
);
|
||||||
|
|
||||||
|
// The sender was never asked: the only receiver request on its side was
|
||||||
|
// recorded as already approved.
|
||||||
|
let requests = sender
|
||||||
|
.core
|
||||||
|
.list_receiver_requests(share.share.transfer_id)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(requests.len(), 1);
|
||||||
|
assert!(
|
||||||
|
matches!(requests[0].status.as_str(), "accepted" | "completed"),
|
||||||
|
"sender should not have been prompted, got status {}",
|
||||||
|
requests[0].status
|
||||||
|
);
|
||||||
|
assert!(requests[0].reason.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Declining yields no ticket and stops the share.
|
||||||
|
#[test]
|
||||||
|
fn a_declined_offer_yields_no_ticket() {
|
||||||
|
let source_dir = tempfile::tempdir().unwrap();
|
||||||
|
let source_path = source_dir.path().join("shared.txt");
|
||||||
|
std::fs::write(&source_path, b"offered content").unwrap();
|
||||||
|
|
||||||
|
let sender = TestNode::new();
|
||||||
|
let receiver = TestNode::new();
|
||||||
|
pair(&receiver, &sender);
|
||||||
|
|
||||||
|
let handle = send_in_background(
|
||||||
|
sender.core.arc(),
|
||||||
|
endpoint_id(&receiver),
|
||||||
|
&source_path,
|
||||||
|
4_002,
|
||||||
|
);
|
||||||
|
let offer = wait_for_offer(&receiver.core);
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
receiver
|
||||||
|
.core
|
||||||
|
.respond_to_offer(offer.offer_id, false)
|
||||||
|
.is_none(),
|
||||||
|
"a declined offer must not hand over a ticket"
|
||||||
|
);
|
||||||
|
|
||||||
|
let outcome = handle.join().unwrap();
|
||||||
|
assert!(outcome.is_err(), "sender should see the refusal");
|
||||||
|
assert!(receiver.core.list_pending_offers().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A device with no grant cannot offer at all.
|
||||||
|
#[test]
|
||||||
|
fn sending_without_a_grant_is_refused_locally() {
|
||||||
|
let source_dir = tempfile::tempdir().unwrap();
|
||||||
|
let source_path = source_dir.path().join("shared.txt");
|
||||||
|
std::fs::write(&source_path, b"content").unwrap();
|
||||||
|
|
||||||
|
let sender = TestNode::new();
|
||||||
|
let receiver = TestNode::new();
|
||||||
|
|
||||||
|
let outcome = sender.core.send_to_contact(
|
||||||
|
endpoint_id(&receiver),
|
||||||
|
sources(&source_path),
|
||||||
|
metadata(4_003),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(outcome.is_err(), "no grant means nothing to send with");
|
||||||
|
assert!(receiver.core.list_pending_offers().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// After the peer revokes, the offer is refused and the dead grant is dropped.
|
||||||
|
#[test]
|
||||||
|
fn a_revoked_grant_cannot_be_used_to_offer() {
|
||||||
|
let source_dir = tempfile::tempdir().unwrap();
|
||||||
|
let source_path = source_dir.path().join("shared.txt");
|
||||||
|
std::fs::write(&source_path, b"content").unwrap();
|
||||||
|
|
||||||
|
let sender = TestNode::new();
|
||||||
|
let receiver = TestNode::new();
|
||||||
|
pair(&receiver, &sender);
|
||||||
|
// The receiver decides it no longer wants to hear from the sender.
|
||||||
|
receiver
|
||||||
|
.core
|
||||||
|
.forget_contact(endpoint_id(&sender))
|
||||||
|
.expect("forgotten");
|
||||||
|
|
||||||
|
let outcome = sender.core.send_to_contact(
|
||||||
|
endpoint_id(&receiver),
|
||||||
|
sources(&source_path),
|
||||||
|
metadata(4_004),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(outcome.is_err());
|
||||||
|
assert!(receiver.core.list_pending_offers().is_empty());
|
||||||
|
let contacts = sender.core.list_contacts().unwrap();
|
||||||
|
assert!(
|
||||||
|
contacts.iter().all(|contact| !contact.can_send),
|
||||||
|
"a refusal naming a dead grant must clear the sender's belief it can reach them"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An offer-created share is never public, whatever the caller asked for.
|
||||||
|
#[test]
|
||||||
|
fn an_offer_share_is_never_public() {
|
||||||
|
let source_dir = tempfile::tempdir().unwrap();
|
||||||
|
let source_path = source_dir.path().join("shared.txt");
|
||||||
|
std::fs::write(&source_path, b"content").unwrap();
|
||||||
|
|
||||||
|
let sender = TestNode::new();
|
||||||
|
let receiver = TestNode::new();
|
||||||
|
pair(&receiver, &sender);
|
||||||
|
|
||||||
|
let core = sender.core.arc();
|
||||||
|
let to = endpoint_id(&receiver);
|
||||||
|
let sources = sources(&source_path);
|
||||||
|
let handle = std::thread::spawn(move || {
|
||||||
|
core.send_to_contact(
|
||||||
|
to,
|
||||||
|
sources,
|
||||||
|
ShareMetadataInput {
|
||||||
|
transfer_id: 4_005,
|
||||||
|
transfer_name: Some("shared.txt".to_string()),
|
||||||
|
sender_name: None,
|
||||||
|
// Deliberately asking for the wider mode.
|
||||||
|
access_mode: TransferAccessMode::Public,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
});
|
||||||
|
|
||||||
|
let offer = wait_for_offer(&receiver.core);
|
||||||
|
receiver.core.respond_to_offer(offer.offer_id, true);
|
||||||
|
let share = handle.join().unwrap().expect("offer accepted");
|
||||||
|
|
||||||
|
let stored = sender
|
||||||
|
.core
|
||||||
|
.list_transfers()
|
||||||
|
.unwrap()
|
||||||
|
.into_iter()
|
||||||
|
.find(|transfer| transfer.transfer_id == share.share.transfer_id)
|
||||||
|
.expect("share recorded");
|
||||||
|
assert_eq!(stored.access_mode, TransferAccessMode::ApprovalRequired);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A second offer while one is on screen is refused rather than stacked.
|
||||||
|
#[test]
|
||||||
|
fn only_one_offer_per_device_is_pending_at_a_time() {
|
||||||
|
let source_dir = tempfile::tempdir().unwrap();
|
||||||
|
let source_path = source_dir.path().join("shared.txt");
|
||||||
|
std::fs::write(&source_path, b"content").unwrap();
|
||||||
|
|
||||||
|
let sender = TestNode::new();
|
||||||
|
let receiver = TestNode::new();
|
||||||
|
pair(&receiver, &sender);
|
||||||
|
|
||||||
|
let first = send_in_background(
|
||||||
|
sender.core.arc(),
|
||||||
|
endpoint_id(&receiver),
|
||||||
|
&source_path,
|
||||||
|
4_006,
|
||||||
|
);
|
||||||
|
wait_for_offer(&receiver.core);
|
||||||
|
|
||||||
|
let second = sender.core.send_to_contact(
|
||||||
|
endpoint_id(&receiver),
|
||||||
|
sources(&source_path),
|
||||||
|
metadata(4_007),
|
||||||
|
);
|
||||||
|
assert!(second.is_err(), "a second prompt must not stack");
|
||||||
|
assert_eq!(receiver.core.list_pending_offers().len(), 1);
|
||||||
|
|
||||||
|
let offer = receiver.core.list_pending_offers().remove(0);
|
||||||
|
receiver.core.respond_to_offer(offer.offer_id, false);
|
||||||
|
let _ = first.join().unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Forgetting a device clears any prompt it left on screen, which would
|
||||||
|
/// otherwise be actionable with a grant that no longer exists.
|
||||||
|
#[test]
|
||||||
|
fn forgetting_a_device_clears_its_pending_offer() {
|
||||||
|
let source_dir = tempfile::tempdir().unwrap();
|
||||||
|
let source_path = source_dir.path().join("shared.txt");
|
||||||
|
std::fs::write(&source_path, b"content").unwrap();
|
||||||
|
|
||||||
|
let sender = TestNode::new();
|
||||||
|
let receiver = TestNode::new();
|
||||||
|
pair(&receiver, &sender);
|
||||||
|
|
||||||
|
let handle = send_in_background(
|
||||||
|
sender.core.arc(),
|
||||||
|
endpoint_id(&receiver),
|
||||||
|
&source_path,
|
||||||
|
4_008,
|
||||||
|
);
|
||||||
|
wait_for_offer(&receiver.core);
|
||||||
|
|
||||||
|
receiver
|
||||||
|
.core
|
||||||
|
.forget_contact(endpoint_id(&sender))
|
||||||
|
.expect("forgotten");
|
||||||
|
|
||||||
|
assert!(receiver.core.list_pending_offers().is_empty());
|
||||||
|
assert!(handle.join().unwrap().is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The ordinary QR path still prompts the sender: pre-authorisation applies
|
||||||
|
/// only to transfers the sender pushed.
|
||||||
|
#[test]
|
||||||
|
fn an_ordinary_ticket_receive_still_prompts_the_sender() {
|
||||||
|
let source_dir = tempfile::tempdir().unwrap();
|
||||||
|
let source_path = source_dir.path().join("shared.txt");
|
||||||
|
std::fs::write(&source_path, b"content").unwrap();
|
||||||
|
let output_dir = tempfile::tempdir().unwrap();
|
||||||
|
|
||||||
|
let sender_dir = tempfile::tempdir().unwrap();
|
||||||
|
let sink = Arc::new(RecordingSink::default());
|
||||||
|
let sender = support::CoreGuard::start(sender_dir.path(), sink);
|
||||||
|
let receiver = TestNode::new();
|
||||||
|
|
||||||
|
let share = sender
|
||||||
|
.share_files(sources(&source_path), metadata(4_009))
|
||||||
|
.expect("shared");
|
||||||
|
|
||||||
|
let core = receiver.core.arc();
|
||||||
|
let ticket = share.ticket.clone();
|
||||||
|
let output = output_dir.path().to_string_lossy().to_string();
|
||||||
|
let handle =
|
||||||
|
std::thread::spawn(move || core.receive(ticket, output, Some("Receiver".to_string())));
|
||||||
|
|
||||||
|
let request = support::wait_for_receiver_request(&sender, share.transfer_id);
|
||||||
|
assert_eq!(
|
||||||
|
request.status, "requested",
|
||||||
|
"an unsolicited ticket receive must still ask the sender"
|
||||||
|
);
|
||||||
|
sender
|
||||||
|
.respond_receiver_request(request.id, true, None)
|
||||||
|
.unwrap();
|
||||||
|
handle.join().unwrap().expect("receive completes");
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Held offers and the foreground pull
|
||||||
|
|
||||||
|
/// Restartable node, for simulating a device that was not running.
|
||||||
|
struct RestartableNode {
|
||||||
|
dir: tempfile::TempDir,
|
||||||
|
core: Option<support::CoreGuard>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RestartableNode {
|
||||||
|
fn new() -> Self {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let core = support::CoreGuard::start(dir.path(), Arc::new(RecordingSink::default()));
|
||||||
|
Self {
|
||||||
|
dir,
|
||||||
|
core: Some(core),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn core(&self) -> &VnidropCore {
|
||||||
|
self.core.as_ref().expect("node is running")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn stop(&mut self) {
|
||||||
|
if let Some(core) = self.core.take() {
|
||||||
|
core.shutdown();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn start(&mut self) {
|
||||||
|
self.core = Some(support::CoreGuard::start(
|
||||||
|
self.dir.path(),
|
||||||
|
Arc::new(RecordingSink::default()),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pair so `sender` may reach the restartable node.
|
||||||
|
fn pair_with_restartable(sender: &TestNode, receiver: &RestartableNode) {
|
||||||
|
let receiver_id = receiver.core().status().endpoint_id;
|
||||||
|
receiver
|
||||||
|
.core()
|
||||||
|
.allow_device_to_reach_me(endpoint_id(sender), Some("Receiver".to_string()))
|
||||||
|
.expect("grant delivered");
|
||||||
|
|
||||||
|
let started = Instant::now();
|
||||||
|
while !sender
|
||||||
|
.core
|
||||||
|
.list_pending_pairings()
|
||||||
|
.iter()
|
||||||
|
.any(|pending| pending.endpoint_id == receiver_id)
|
||||||
|
{
|
||||||
|
assert!(
|
||||||
|
started.elapsed() < Duration::from_secs(10),
|
||||||
|
"pairing offer never surfaced"
|
||||||
|
);
|
||||||
|
std::thread::sleep(Duration::from_millis(25));
|
||||||
|
}
|
||||||
|
sender
|
||||||
|
.core
|
||||||
|
.respond_to_pairing(receiver_id, true)
|
||||||
|
.expect("consent recorded");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The whole point of §11: a closed app is not an error, it is a delay.
|
||||||
|
#[test]
|
||||||
|
fn an_offer_to_a_device_that_is_not_running_is_held_and_collected_later() {
|
||||||
|
let source_dir = tempfile::tempdir().unwrap();
|
||||||
|
let source_path = source_dir.path().join("shared.txt");
|
||||||
|
std::fs::write(&source_path, b"held content").unwrap();
|
||||||
|
let output_dir = tempfile::tempdir().unwrap();
|
||||||
|
|
||||||
|
let sender = TestNode::new();
|
||||||
|
let mut receiver = RestartableNode::new();
|
||||||
|
pair_with_restartable(&sender, &receiver);
|
||||||
|
let receiver_id = receiver.core().status().endpoint_id;
|
||||||
|
|
||||||
|
receiver.stop();
|
||||||
|
|
||||||
|
let outcome = sender
|
||||||
|
.core
|
||||||
|
.send_to_contact(receiver_id, sources(&source_path), metadata(5_001))
|
||||||
|
.expect("an unreachable device is not a failure");
|
||||||
|
assert!(
|
||||||
|
!outcome.delivered,
|
||||||
|
"nothing was delivered, the offer is waiting"
|
||||||
|
);
|
||||||
|
let held = sender.core.list_held_offers().unwrap();
|
||||||
|
assert_eq!(held.len(), 1);
|
||||||
|
assert_eq!(held[0].transfer_id, outcome.share.transfer_id);
|
||||||
|
|
||||||
|
receiver.start();
|
||||||
|
let collected = receiver
|
||||||
|
.core()
|
||||||
|
.poll_contacts_for_offers()
|
||||||
|
.expect("poll succeeds");
|
||||||
|
|
||||||
|
assert_eq!(collected, 1);
|
||||||
|
let offer = receiver.core().list_pending_offers().remove(0);
|
||||||
|
assert_eq!(offer.transfer_name, "shared.txt");
|
||||||
|
|
||||||
|
let ticket = receiver
|
||||||
|
.core()
|
||||||
|
.respond_to_offer(offer.offer_id, true)
|
||||||
|
.expect("accepting yields the ticket");
|
||||||
|
receiver
|
||||||
|
.core()
|
||||||
|
.receive(
|
||||||
|
ticket,
|
||||||
|
output_dir.path().to_string_lossy().to_string(),
|
||||||
|
Some("Receiver".to_string()),
|
||||||
|
)
|
||||||
|
.expect("receive completes");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
std::fs::read(output_dir.path().join("shared.txt")).unwrap(),
|
||||||
|
b"held content"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
sender.core.list_held_offers().unwrap().is_empty(),
|
||||||
|
"a collected offer is no longer held"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Collected offers are consumed, so a second pull does not re-deliver them.
|
||||||
|
#[test]
|
||||||
|
fn polling_twice_does_not_collect_the_same_offer_again() {
|
||||||
|
let source_dir = tempfile::tempdir().unwrap();
|
||||||
|
let source_path = source_dir.path().join("shared.txt");
|
||||||
|
std::fs::write(&source_path, b"content").unwrap();
|
||||||
|
|
||||||
|
let sender = TestNode::new();
|
||||||
|
let mut receiver = RestartableNode::new();
|
||||||
|
pair_with_restartable(&sender, &receiver);
|
||||||
|
let receiver_id = receiver.core().status().endpoint_id;
|
||||||
|
receiver.stop();
|
||||||
|
sender
|
||||||
|
.core
|
||||||
|
.send_to_contact(receiver_id, sources(&source_path), metadata(5_002))
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// A fresh core each time, so the per-device poll rate limit does not mask
|
||||||
|
// the consume-on-delivery behaviour being asserted here.
|
||||||
|
receiver.start();
|
||||||
|
assert_eq!(receiver.core().poll_contacts_for_offers().unwrap(), 1);
|
||||||
|
receiver.stop();
|
||||||
|
receiver.start();
|
||||||
|
assert_eq!(
|
||||||
|
receiver.core().poll_contacts_for_offers().unwrap(),
|
||||||
|
0,
|
||||||
|
"the offer was already handed over"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cancelling the transfer withdraws the ticket that was waiting for pickup.
|
||||||
|
#[test]
|
||||||
|
fn cancelling_a_transfer_withdraws_its_held_offer() {
|
||||||
|
let source_dir = tempfile::tempdir().unwrap();
|
||||||
|
let source_path = source_dir.path().join("shared.txt");
|
||||||
|
std::fs::write(&source_path, b"content").unwrap();
|
||||||
|
|
||||||
|
let sender = TestNode::new();
|
||||||
|
let mut receiver = RestartableNode::new();
|
||||||
|
pair_with_restartable(&sender, &receiver);
|
||||||
|
let receiver_id = receiver.core().status().endpoint_id;
|
||||||
|
receiver.stop();
|
||||||
|
let outcome = sender
|
||||||
|
.core
|
||||||
|
.send_to_contact(receiver_id, sources(&source_path), metadata(5_004))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(sender.core.list_held_offers().unwrap().len(), 1);
|
||||||
|
|
||||||
|
sender
|
||||||
|
.core
|
||||||
|
.cancel_transfer(outcome.share.transfer_id)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert!(sender.core.list_held_offers().unwrap().is_empty());
|
||||||
|
receiver.start();
|
||||||
|
assert_eq!(receiver.core().poll_contacts_for_offers().unwrap(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A device with no relationship learns nothing by polling.
|
||||||
|
#[test]
|
||||||
|
fn polling_a_device_that_holds_nothing_for_you_returns_nothing() {
|
||||||
|
let sender = TestNode::new();
|
||||||
|
let receiver = TestNode::new();
|
||||||
|
pair(&receiver, &sender);
|
||||||
|
|
||||||
|
assert_eq!(receiver.core.poll_contacts_for_offers().unwrap(), 0);
|
||||||
|
assert!(receiver.core.list_pending_offers().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A transfer created for an invitation can also be pushed to a device: the
|
||||||
|
/// same ticket, another way to deliver it.
|
||||||
|
#[test]
|
||||||
|
fn an_existing_share_can_be_offered_to_a_contact() {
|
||||||
|
let source_dir = tempfile::tempdir().unwrap();
|
||||||
|
let source_path = source_dir.path().join("shared.txt");
|
||||||
|
std::fs::write(&source_path, b"existing share").unwrap();
|
||||||
|
let output_dir = tempfile::tempdir().unwrap();
|
||||||
|
|
||||||
|
let sender = TestNode::new();
|
||||||
|
let receiver = TestNode::new();
|
||||||
|
pair(&receiver, &sender);
|
||||||
|
|
||||||
|
// An ordinary share, as if the user had created it for a QR code.
|
||||||
|
let share = sender
|
||||||
|
.core
|
||||||
|
.share_files(sources(&source_path), metadata(6_001))
|
||||||
|
.expect("shared");
|
||||||
|
|
||||||
|
let core = sender.core.arc();
|
||||||
|
let to = endpoint_id(&receiver);
|
||||||
|
let handle = std::thread::spawn(move || core.offer_transfer_to_contact(share.transfer_id, to));
|
||||||
|
|
||||||
|
let offer = wait_for_offer(&receiver.core);
|
||||||
|
let ticket = receiver
|
||||||
|
.core
|
||||||
|
.respond_to_offer(offer.offer_id, true)
|
||||||
|
.expect("accepting yields the ticket");
|
||||||
|
let outcome = handle.join().unwrap().expect("offer accepted");
|
||||||
|
|
||||||
|
assert!(outcome.delivered);
|
||||||
|
assert_eq!(
|
||||||
|
outcome.share.transfer_id, share.transfer_id,
|
||||||
|
"offering reuses the existing transfer rather than creating another"
|
||||||
|
);
|
||||||
|
assert_eq!(ticket, share.ticket, "the invitation is the stored one");
|
||||||
|
|
||||||
|
receiver
|
||||||
|
.core
|
||||||
|
.receive(
|
||||||
|
ticket,
|
||||||
|
output_dir.path().to_string_lossy().to_string(),
|
||||||
|
Some("Receiver".to_string()),
|
||||||
|
)
|
||||||
|
.expect("receive completes");
|
||||||
|
assert_eq!(
|
||||||
|
std::fs::read(output_dir.path().join("shared.txt")).unwrap(),
|
||||||
|
b"existing share"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A stopped share serves nothing, so its ticket must not be handed out.
|
||||||
|
#[test]
|
||||||
|
fn a_stopped_share_cannot_be_offered() {
|
||||||
|
let source_dir = tempfile::tempdir().unwrap();
|
||||||
|
let source_path = source_dir.path().join("shared.txt");
|
||||||
|
std::fs::write(&source_path, b"content").unwrap();
|
||||||
|
|
||||||
|
let sender = TestNode::new();
|
||||||
|
let receiver = TestNode::new();
|
||||||
|
pair(&receiver, &sender);
|
||||||
|
let share = sender
|
||||||
|
.core
|
||||||
|
.share_files(sources(&source_path), metadata(6_002))
|
||||||
|
.expect("shared");
|
||||||
|
sender.core.cancel_transfer(share.transfer_id).unwrap();
|
||||||
|
|
||||||
|
let outcome = sender
|
||||||
|
.core
|
||||||
|
.offer_transfer_to_contact(share.transfer_id, endpoint_id(&receiver));
|
||||||
|
|
||||||
|
assert!(outcome.is_err());
|
||||||
|
assert!(receiver.core.list_pending_offers().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Offering an unknown transfer is rejected rather than silently doing nothing.
|
||||||
|
#[test]
|
||||||
|
fn offering_an_unknown_transfer_is_rejected() {
|
||||||
|
let sender = TestNode::new();
|
||||||
|
let receiver = TestNode::new();
|
||||||
|
pair(&receiver, &sender);
|
||||||
|
|
||||||
|
assert!(sender
|
||||||
|
.core
|
||||||
|
.offer_transfer_to_contact(9_999, endpoint_id(&receiver))
|
||||||
|
.is_err());
|
||||||
|
}
|
||||||
252
crates/vnidrop/tests/pairing.rs
Normal file
@@ -0,0 +1,252 @@
|
|||||||
|
//! Device history pairing over the offer ALPN, between two real nodes.
|
||||||
|
|
||||||
|
mod support;
|
||||||
|
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use support::TestNode;
|
||||||
|
use vnidrop::VnidropCore;
|
||||||
|
|
||||||
|
fn endpoint_id(node: &TestNode) -> String {
|
||||||
|
node.core.status().endpoint_id
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The pairing prompt arrives asynchronously on the peer's side.
|
||||||
|
fn wait_for_pending_pairing(core: &VnidropCore, from_endpoint: &str) {
|
||||||
|
let started = Instant::now();
|
||||||
|
loop {
|
||||||
|
if core
|
||||||
|
.list_pending_pairings()
|
||||||
|
.iter()
|
||||||
|
.any(|pending| pending.endpoint_id == from_endpoint)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
started.elapsed() < Duration::from_secs(10),
|
||||||
|
"pairing offer from {from_endpoint} never surfaced"
|
||||||
|
);
|
||||||
|
std::thread::sleep(Duration::from_millis(25));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Alice agrees to be reachable by Bob; Bob consents; Bob can now reach Alice.
|
||||||
|
#[test]
|
||||||
|
fn a_delivered_grant_becomes_a_contact_only_after_the_peer_consents() {
|
||||||
|
let alice = TestNode::new();
|
||||||
|
let bob = TestNode::new();
|
||||||
|
let bob_id = endpoint_id(&bob);
|
||||||
|
let alice_id = endpoint_id(&alice);
|
||||||
|
|
||||||
|
alice
|
||||||
|
.core
|
||||||
|
.allow_device_to_reach_me(bob_id.clone(), Some("Alice Laptop".to_string()))
|
||||||
|
.expect("grant delivered");
|
||||||
|
|
||||||
|
// Delivery alone must not create a contact: Bob has not agreed yet.
|
||||||
|
wait_for_pending_pairing(&bob.core, &alice_id);
|
||||||
|
assert!(
|
||||||
|
bob.core.list_contacts().unwrap().is_empty(),
|
||||||
|
"an undelivered-consent grant must not appear as a contact"
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(bob
|
||||||
|
.core
|
||||||
|
.respond_to_pairing(alice_id.clone(), true)
|
||||||
|
.expect("consent recorded"));
|
||||||
|
|
||||||
|
let contacts = bob.core.list_contacts().unwrap();
|
||||||
|
assert_eq!(contacts.len(), 1);
|
||||||
|
assert_eq!(contacts[0].endpoint_id, alice_id);
|
||||||
|
assert!(
|
||||||
|
contacts[0].can_send,
|
||||||
|
"holding a live grant is what makes a contact reachable"
|
||||||
|
);
|
||||||
|
assert!(bob.core.list_pending_pairings().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Declining leaves nothing behind: no contact, no stored capability.
|
||||||
|
#[test]
|
||||||
|
fn declining_a_pairing_stores_nothing() {
|
||||||
|
let alice = TestNode::new();
|
||||||
|
let bob = TestNode::new();
|
||||||
|
let alice_id = endpoint_id(&alice);
|
||||||
|
|
||||||
|
alice
|
||||||
|
.core
|
||||||
|
.allow_device_to_reach_me(endpoint_id(&bob), None)
|
||||||
|
.expect("grant delivered");
|
||||||
|
wait_for_pending_pairing(&bob.core, &alice_id);
|
||||||
|
|
||||||
|
assert!(bob
|
||||||
|
.core
|
||||||
|
.respond_to_pairing(alice_id.clone(), false)
|
||||||
|
.unwrap());
|
||||||
|
|
||||||
|
assert!(bob.core.list_contacts().unwrap().is_empty());
|
||||||
|
assert!(bob.core.list_pending_pairings().is_empty());
|
||||||
|
assert!(
|
||||||
|
!bob.core.respond_to_pairing(alice_id, true).unwrap(),
|
||||||
|
"a declined offer cannot be accepted afterwards"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The pairing is directional: Alice issuing to Bob does not let Alice reach Bob.
|
||||||
|
#[test]
|
||||||
|
fn each_direction_is_a_separate_decision() {
|
||||||
|
let alice = TestNode::new();
|
||||||
|
let bob = TestNode::new();
|
||||||
|
let alice_id = endpoint_id(&alice);
|
||||||
|
let bob_id = endpoint_id(&bob);
|
||||||
|
|
||||||
|
alice
|
||||||
|
.core
|
||||||
|
.allow_device_to_reach_me(bob_id.clone(), None)
|
||||||
|
.expect("grant delivered");
|
||||||
|
wait_for_pending_pairing(&bob.core, &alice_id);
|
||||||
|
bob.core.respond_to_pairing(alice_id.clone(), true).unwrap();
|
||||||
|
|
||||||
|
// Alice recorded Bob as a contact when she issued, but she holds no grant
|
||||||
|
// from him, so she cannot reach him.
|
||||||
|
let alice_contacts = alice.core.list_contacts().unwrap();
|
||||||
|
assert_eq!(alice_contacts.len(), 1);
|
||||||
|
assert_eq!(alice_contacts[0].endpoint_id, bob_id);
|
||||||
|
assert!(
|
||||||
|
!alice_contacts[0].can_send,
|
||||||
|
"issuing a grant does not grant the issuer anything in return"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Revoking kills the peer's entry without their cooperation, and tells them.
|
||||||
|
#[test]
|
||||||
|
fn forgetting_a_contact_revokes_the_peers_access() {
|
||||||
|
let alice = TestNode::new();
|
||||||
|
let bob = TestNode::new();
|
||||||
|
let alice_id = endpoint_id(&alice);
|
||||||
|
let bob_id = endpoint_id(&bob);
|
||||||
|
|
||||||
|
alice
|
||||||
|
.core
|
||||||
|
.allow_device_to_reach_me(bob_id.clone(), None)
|
||||||
|
.expect("grant delivered");
|
||||||
|
wait_for_pending_pairing(&bob.core, &alice_id);
|
||||||
|
bob.core.respond_to_pairing(alice_id.clone(), true).unwrap();
|
||||||
|
assert!(bob.core.list_contacts().unwrap()[0].can_send);
|
||||||
|
|
||||||
|
alice.core.forget_contact(bob_id).expect("forgotten");
|
||||||
|
|
||||||
|
// Best-effort notification: Bob is online, so his dead entry should clear
|
||||||
|
// promptly rather than at his next attempt.
|
||||||
|
let started = Instant::now();
|
||||||
|
loop {
|
||||||
|
let contacts = bob.core.list_contacts().unwrap();
|
||||||
|
let cleared = contacts.first().is_none_or(|contact| !contact.can_send);
|
||||||
|
if cleared {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
started.elapsed() < Duration::from_secs(10),
|
||||||
|
"revocation notice never reached the peer"
|
||||||
|
);
|
||||||
|
std::thread::sleep(Duration::from_millis(25));
|
||||||
|
}
|
||||||
|
assert!(alice.core.list_contacts().unwrap().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A blocked device is refused, and cannot tell blocking from any other refusal.
|
||||||
|
#[test]
|
||||||
|
fn a_blocked_device_cannot_pair() {
|
||||||
|
let alice = TestNode::new();
|
||||||
|
let bob = TestNode::new();
|
||||||
|
let bob_id = endpoint_id(&bob);
|
||||||
|
|
||||||
|
bob.core
|
||||||
|
.block_contact(endpoint_id(&alice))
|
||||||
|
.expect("blocked");
|
||||||
|
|
||||||
|
let outcome = alice.core.allow_device_to_reach_me(bob_id, None);
|
||||||
|
|
||||||
|
assert!(outcome.is_err(), "a blocked peer must refuse the grant");
|
||||||
|
assert!(bob.core.list_pending_pairings().is_empty());
|
||||||
|
assert!(bob.core.list_contacts().unwrap().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Blocking locally also prevents pairing outward, so the block is symmetric
|
||||||
|
/// from the user's point of view.
|
||||||
|
#[test]
|
||||||
|
fn blocking_prevents_issuing_a_grant_to_that_device() {
|
||||||
|
let alice = TestNode::new();
|
||||||
|
let bob = TestNode::new();
|
||||||
|
let bob_id = endpoint_id(&bob);
|
||||||
|
|
||||||
|
alice.core.block_contact(bob_id.clone()).expect("blocked");
|
||||||
|
|
||||||
|
let outcome = alice.core.allow_device_to_reach_me(bob_id.clone(), None);
|
||||||
|
assert!(outcome.is_err());
|
||||||
|
|
||||||
|
alice
|
||||||
|
.core
|
||||||
|
.unblock_contact(bob_id.clone())
|
||||||
|
.expect("unblocked");
|
||||||
|
assert!(alice.core.list_blocked_contacts().unwrap().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Re-pairing an existing contact refreshes the grant without a second prompt.
|
||||||
|
#[test]
|
||||||
|
fn re_pairing_a_known_contact_does_not_prompt_again() {
|
||||||
|
let alice = TestNode::new();
|
||||||
|
let bob = TestNode::new();
|
||||||
|
let alice_id = endpoint_id(&alice);
|
||||||
|
let bob_id = endpoint_id(&bob);
|
||||||
|
|
||||||
|
alice
|
||||||
|
.core
|
||||||
|
.allow_device_to_reach_me(bob_id.clone(), None)
|
||||||
|
.unwrap();
|
||||||
|
wait_for_pending_pairing(&bob.core, &alice_id);
|
||||||
|
bob.core.respond_to_pairing(alice_id.clone(), true).unwrap();
|
||||||
|
|
||||||
|
alice
|
||||||
|
.core
|
||||||
|
.allow_device_to_reach_me(bob_id, None)
|
||||||
|
.expect("re-issued");
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
bob.core.list_pending_pairings().is_empty(),
|
||||||
|
"an established contact must not raise a fresh consent prompt"
|
||||||
|
);
|
||||||
|
assert_eq!(bob.core.list_contacts().unwrap().len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The user's own label survives whatever the remote later calls itself.
|
||||||
|
#[test]
|
||||||
|
fn a_local_label_survives_a_remote_rename() {
|
||||||
|
let alice = TestNode::new();
|
||||||
|
let bob = TestNode::new();
|
||||||
|
let alice_id = endpoint_id(&alice);
|
||||||
|
let bob_id = endpoint_id(&bob);
|
||||||
|
|
||||||
|
alice
|
||||||
|
.core
|
||||||
|
.allow_device_to_reach_me(bob_id.clone(), Some("Alice Laptop".to_string()))
|
||||||
|
.unwrap();
|
||||||
|
wait_for_pending_pairing(&bob.core, &alice_id);
|
||||||
|
bob.core.respond_to_pairing(alice_id.clone(), true).unwrap();
|
||||||
|
bob.core
|
||||||
|
.set_contact_label(alice_id.clone(), Some("Work Mac".to_string()))
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
alice
|
||||||
|
.core
|
||||||
|
.allow_device_to_reach_me(bob_id, Some("Totally Not Evil".to_string()))
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let contact = bob
|
||||||
|
.core
|
||||||
|
.list_contacts()
|
||||||
|
.unwrap()
|
||||||
|
.into_iter()
|
||||||
|
.find(|contact| contact.endpoint_id == alice_id)
|
||||||
|
.expect("contact");
|
||||||
|
assert_eq!(contact.local_label.as_deref(), Some("Work Mac"));
|
||||||
|
}
|
||||||
@@ -1740,57 +1740,6 @@
|
|||||||
"ru": "Получайте уведомления об активности передач, пока VniDrop работает в фоне."
|
"ru": "Получайте уведомления об активности передач, пока VniDrop работает в фоне."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"notifications_background_sharing_body": {
|
|
||||||
"context": "Android foreground-service notification: explains why VniDrop stays active.",
|
|
||||||
"targets": [
|
|
||||||
"kmp"
|
|
||||||
],
|
|
||||||
"translations": {
|
|
||||||
"en": "VniDrop is ready to share your files in the background.",
|
|
||||||
"fr": "VniDrop est prêt à partager vos fichiers en arrière-plan.",
|
|
||||||
"es": "VniDrop está listo para compartir sus archivos en segundo plano.",
|
|
||||||
"it": "VniDrop è pronto a condividere i tuoi file in background.",
|
|
||||||
"de": "VniDrop kann Ihre Dateien im Hintergrund freigeben.",
|
|
||||||
"pt": "O VniDrop está pronto para partilhar os seus ficheiros em segundo plano.",
|
|
||||||
"pl": "VniDrop jest gotowy do udostępniania plików w tle.",
|
|
||||||
"nl": "VniDrop is klaar om uw bestanden op de achtergrond te delen.",
|
|
||||||
"ru": "VniDrop готов отправлять ваши файлы в фоновом режиме."
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"notifications_background_sharing_channel": {
|
|
||||||
"context": "Android system notification channel for an active outgoing share.",
|
|
||||||
"targets": [
|
|
||||||
"kmp"
|
|
||||||
],
|
|
||||||
"translations": {
|
|
||||||
"en": "Active transfers",
|
|
||||||
"fr": "Transferts actifs",
|
|
||||||
"es": "Transferencias activas",
|
|
||||||
"it": "Trasferimenti attivi",
|
|
||||||
"de": "Aktive Übertragungen",
|
|
||||||
"pt": "Transferências ativas",
|
|
||||||
"pl": "Aktywne transfery",
|
|
||||||
"nl": "Actieve overdrachten",
|
|
||||||
"ru": "Активные передачи"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"notifications_background_sharing_title": {
|
|
||||||
"context": "Android foreground-service notification title while an outgoing share is available.",
|
|
||||||
"targets": [
|
|
||||||
"kmp"
|
|
||||||
],
|
|
||||||
"translations": {
|
|
||||||
"en": "Sharing in the background",
|
|
||||||
"fr": "Partage en arrière-plan",
|
|
||||||
"es": "Compartiendo en segundo plano",
|
|
||||||
"it": "Condivisione in background",
|
|
||||||
"de": "Freigabe im Hintergrund",
|
|
||||||
"pt": "Partilha em segundo plano",
|
|
||||||
"pl": "Udostępnianie w tle",
|
|
||||||
"nl": "Delen op de achtergrond",
|
|
||||||
"ru": "Отправка в фоне"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"notifications_enabled_message": {
|
"notifications_enabled_message": {
|
||||||
"context": "Settings > Notifications: confirmation when notifications are enabled.",
|
"context": "Settings > Notifications: confirmation when notifications are enabled.",
|
||||||
"translations": {
|
"translations": {
|
||||||
@@ -4668,6 +4617,713 @@
|
|||||||
"nl": "App-versie",
|
"nl": "App-versie",
|
||||||
"ru": "Версия приложения"
|
"ru": "Версия приложения"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"contacts_title": {
|
||||||
|
"context": "Devices screen: title of the list of remembered devices.",
|
||||||
|
"translations": {
|
||||||
|
"en": "Devices",
|
||||||
|
"fr": "Appareils",
|
||||||
|
"es": "Dispositivos",
|
||||||
|
"it": "Dispositivi",
|
||||||
|
"de": "Geräte",
|
||||||
|
"pt": "Dispositivos",
|
||||||
|
"pl": "Urządzenia",
|
||||||
|
"nl": "Apparaten",
|
||||||
|
"ru": "Устройства"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"contacts_subtitle": {
|
||||||
|
"context": "Devices screen: one-line explanation under the title.",
|
||||||
|
"translations": {
|
||||||
|
"en": "Devices you have transferred with can receive files without a new invitation.",
|
||||||
|
"fr": "Les appareils avec lesquels vous avez déjà échangé peuvent recevoir des fichiers sans nouvelle invitation.",
|
||||||
|
"es": "Los dispositivos con los que ya has compartido pueden recibir archivos sin una nueva invitación.",
|
||||||
|
"it": "I dispositivi con cui hai già scambiato file possono riceverne altri senza un nuovo invito.",
|
||||||
|
"de": "Geräte, mit denen Sie bereits Dateien ausgetauscht haben, können ohne neue Einladung Dateien empfangen.",
|
||||||
|
"pt": "Os dispositivos com os quais já transferiu podem receber ficheiros sem um novo convite.",
|
||||||
|
"pl": "Urządzenia, z którymi już przesyłano pliki, mogą je odbierać bez nowego zaproszenia.",
|
||||||
|
"nl": "Apparaten waarmee je al hebt overgedragen, kunnen bestanden ontvangen zonder nieuwe uitnodiging.",
|
||||||
|
"ru": "Устройства, с которыми вы уже обменивались файлами, могут получать их без нового приглашения."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"contacts_empty_title": {
|
||||||
|
"context": "Devices screen: empty-state title when no device has been remembered yet.",
|
||||||
|
"translations": {
|
||||||
|
"en": "No remembered devices",
|
||||||
|
"fr": "Aucun appareil enregistré",
|
||||||
|
"es": "Ningún dispositivo guardado",
|
||||||
|
"it": "Nessun dispositivo memorizzato",
|
||||||
|
"de": "Keine gespeicherten Geräte",
|
||||||
|
"pt": "Nenhum dispositivo guardado",
|
||||||
|
"pl": "Brak zapamiętanych urządzeń",
|
||||||
|
"nl": "Geen onthouden apparaten",
|
||||||
|
"ru": "Нет сохранённых устройств"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"contacts_empty_body": {
|
||||||
|
"context": "Devices screen: empty-state explanation of how a device gets remembered.",
|
||||||
|
"translations": {
|
||||||
|
"en": "After a transfer, both devices can choose to remember each other.",
|
||||||
|
"fr": "Après un transfert, les deux appareils peuvent choisir de se mémoriser mutuellement.",
|
||||||
|
"es": "Tras una transferencia, ambos dispositivos pueden elegir recordarse mutuamente.",
|
||||||
|
"it": "Dopo un trasferimento, entrambi i dispositivi possono scegliere di ricordarsi a vicenda.",
|
||||||
|
"de": "Nach einer Übertragung können beide Geräte einander speichern.",
|
||||||
|
"pt": "Após uma transferência, ambos os dispositivos podem optar por lembrar-se um do outro.",
|
||||||
|
"pl": "Po przesłaniu plików oba urządzenia mogą zapamiętać się nawzajem.",
|
||||||
|
"nl": "Na een overdracht kunnen beide apparaten elkaar onthouden.",
|
||||||
|
"ru": "После передачи оба устройства могут запомнить друг друга."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"contacts_unreachable": {
|
||||||
|
"context": "Devices screen: badge on a device that can no longer be sent to.",
|
||||||
|
"translations": {
|
||||||
|
"en": "Needs pairing again",
|
||||||
|
"fr": "Nouvel appairage nécessaire",
|
||||||
|
"es": "Requiere emparejar de nuevo",
|
||||||
|
"it": "Richiede un nuovo abbinamento",
|
||||||
|
"de": "Muss erneut gekoppelt werden",
|
||||||
|
"pt": "É preciso emparelhar novamente",
|
||||||
|
"pl": "Wymaga ponownego sparowania",
|
||||||
|
"nl": "Opnieuw koppelen vereist",
|
||||||
|
"ru": "Требуется повторное сопряжение"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"contacts_unreachable_body": {
|
||||||
|
"context": "Device detail: explains why a remembered device can no longer be reached.",
|
||||||
|
"translations": {
|
||||||
|
"en": "This device withdrew access, or reinstalled VniDrop. Transfer to it once more to remember it again.",
|
||||||
|
"fr": "Cet appareil a retiré l’accès ou a réinstallé VniDrop. Effectuez un nouveau transfert pour le mémoriser à nouveau.",
|
||||||
|
"es": "Este dispositivo retiró el acceso o reinstaló VniDrop. Realiza otra transferencia para volver a recordarlo.",
|
||||||
|
"it": "Questo dispositivo ha revocato l’accesso o ha reinstallato VniDrop. Effettua un altro trasferimento per memorizzarlo di nuovo.",
|
||||||
|
"de": "Dieses Gerät hat den Zugriff entzogen oder VniDrop neu installiert. Übertragen Sie erneut, um es wieder zu speichern.",
|
||||||
|
"pt": "Este dispositivo retirou o acesso ou reinstalou o VniDrop. Faça outra transferência para o voltar a guardar.",
|
||||||
|
"pl": "To urządzenie cofnęło dostęp lub ponownie zainstalowało VniDrop. Wykonaj kolejne przesłanie, aby zapamiętać je ponownie.",
|
||||||
|
"nl": "Dit apparaat heeft de toegang ingetrokken of VniDrop opnieuw geïnstalleerd. Draag opnieuw over om het weer te onthouden.",
|
||||||
|
"ru": "Это устройство отозвало доступ или переустановило VniDrop. Выполните новую передачу, чтобы снова его запомнить."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"contacts_name_field": {
|
||||||
|
"context": "Device detail: text field label for the name the local user gives a device.",
|
||||||
|
"translations": {
|
||||||
|
"en": "Name on this device",
|
||||||
|
"fr": "Nom sur cet appareil",
|
||||||
|
"es": "Nombre en este dispositivo",
|
||||||
|
"it": "Nome su questo dispositivo",
|
||||||
|
"de": "Name auf diesem Gerät",
|
||||||
|
"pt": "Nome neste dispositivo",
|
||||||
|
"pl": "Nazwa na tym urządzeniu",
|
||||||
|
"nl": "Naam op dit apparaat",
|
||||||
|
"ru": "Имя на этом устройстве"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"contacts_name_hint": {
|
||||||
|
"context": "Device detail: note that the local name is never changed by the other device.",
|
||||||
|
"translations": {
|
||||||
|
"en": "Only you see this name. The other device can never change it.",
|
||||||
|
"fr": "Vous seul voyez ce nom. L’autre appareil ne peut jamais le modifier.",
|
||||||
|
"es": "Solo tú ves este nombre. El otro dispositivo nunca puede cambiarlo.",
|
||||||
|
"it": "Solo tu vedi questo nome. L’altro dispositivo non può mai modificarlo.",
|
||||||
|
"de": "Nur Sie sehen diesen Namen. Das andere Gerät kann ihn nie ändern.",
|
||||||
|
"pt": "Só você vê este nome. O outro dispositivo nunca o pode alterar.",
|
||||||
|
"pl": "Tylko Ty widzisz tę nazwę. Drugie urządzenie nigdy jej nie zmieni.",
|
||||||
|
"nl": "Alleen jij ziet deze naam. Het andere apparaat kan die nooit wijzigen.",
|
||||||
|
"ru": "Это имя видите только вы. Другое устройство не может его изменить."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"contacts_forget": {
|
||||||
|
"context": "Device detail: button that removes a device and revokes its access.",
|
||||||
|
"translations": {
|
||||||
|
"en": "Forget device",
|
||||||
|
"fr": "Oublier l’appareil",
|
||||||
|
"es": "Olvidar dispositivo",
|
||||||
|
"it": "Dimentica dispositivo",
|
||||||
|
"de": "Gerät entfernen",
|
||||||
|
"pt": "Esquecer dispositivo",
|
||||||
|
"pl": "Zapomnij urządzenie",
|
||||||
|
"nl": "Apparaat vergeten",
|
||||||
|
"ru": "Забыть устройство"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"contacts_forget_body": {
|
||||||
|
"context": "Device detail: confirmation explaining what forgetting a device does.",
|
||||||
|
"translations": {
|
||||||
|
"en": "This device will no longer be able to send you files without a new invitation. Files already received are kept.",
|
||||||
|
"fr": "Cet appareil ne pourra plus vous envoyer de fichiers sans nouvelle invitation. Les fichiers déjà reçus sont conservés.",
|
||||||
|
"es": "Este dispositivo ya no podrá enviarte archivos sin una nueva invitación. Los archivos ya recibidos se conservan.",
|
||||||
|
"it": "Questo dispositivo non potrà più inviarti file senza un nuovo invito. I file già ricevuti vengono conservati.",
|
||||||
|
"de": "Dieses Gerät kann Ihnen ohne neue Einladung keine Dateien mehr senden. Bereits empfangene Dateien bleiben erhalten.",
|
||||||
|
"pt": "Este dispositivo deixará de lhe poder enviar ficheiros sem um novo convite. Os ficheiros já recebidos são mantidos.",
|
||||||
|
"pl": "To urządzenie nie będzie mogło wysyłać Ci plików bez nowego zaproszenia. Już odebrane pliki pozostaną.",
|
||||||
|
"nl": "Dit apparaat kan je zonder nieuwe uitnodiging geen bestanden meer sturen. Reeds ontvangen bestanden blijven behouden.",
|
||||||
|
"ru": "Это устройство больше не сможет отправлять вам файлы без нового приглашения. Уже полученные файлы сохранятся."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"contacts_forget_all": {
|
||||||
|
"context": "Devices screen: button that forgets every remembered device at once.",
|
||||||
|
"translations": {
|
||||||
|
"en": "Forget all devices",
|
||||||
|
"fr": "Oublier tous les appareils",
|
||||||
|
"es": "Olvidar todos los dispositivos",
|
||||||
|
"it": "Dimentica tutti i dispositivi",
|
||||||
|
"de": "Alle Geräte entfernen",
|
||||||
|
"pt": "Esquecer todos os dispositivos",
|
||||||
|
"pl": "Zapomnij wszystkie urządzenia",
|
||||||
|
"nl": "Alle apparaten vergeten",
|
||||||
|
"ru": "Забыть все устройства"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"contacts_block": {
|
||||||
|
"context": "Device detail: button that blocks a device outright.",
|
||||||
|
"translations": {
|
||||||
|
"en": "Block device",
|
||||||
|
"fr": "Bloquer l’appareil",
|
||||||
|
"es": "Bloquear dispositivo",
|
||||||
|
"it": "Blocca dispositivo",
|
||||||
|
"de": "Gerät blockieren",
|
||||||
|
"pt": "Bloquear dispositivo",
|
||||||
|
"pl": "Zablokuj urządzenie",
|
||||||
|
"nl": "Apparaat blokkeren",
|
||||||
|
"ru": "Заблокировать устройство"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"contacts_blocked_title": {
|
||||||
|
"context": "Devices screen: section listing blocked devices.",
|
||||||
|
"translations": {
|
||||||
|
"en": "Blocked devices",
|
||||||
|
"fr": "Appareils bloqués",
|
||||||
|
"es": "Dispositivos bloqueados",
|
||||||
|
"it": "Dispositivi bloccati",
|
||||||
|
"de": "Blockierte Geräte",
|
||||||
|
"pt": "Dispositivos bloqueados",
|
||||||
|
"pl": "Zablokowane urządzenia",
|
||||||
|
"nl": "Geblokkeerde apparaten",
|
||||||
|
"ru": "Заблокированные устройства"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"contacts_unblock": {
|
||||||
|
"context": "Devices screen: button that removes a device from the block list.",
|
||||||
|
"translations": {
|
||||||
|
"en": "Unblock",
|
||||||
|
"fr": "Débloquer",
|
||||||
|
"es": "Desbloquear",
|
||||||
|
"it": "Sblocca",
|
||||||
|
"de": "Freigeben",
|
||||||
|
"pt": "Desbloquear",
|
||||||
|
"pl": "Odblokuj",
|
||||||
|
"nl": "Deblokkeren",
|
||||||
|
"ru": "Разблокировать"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"contacts_unblock_hint": {
|
||||||
|
"context": "Devices screen: note that unblocking does not restore the previous access.",
|
||||||
|
"translations": {
|
||||||
|
"en": "Unblocking does not restore access. The device has to be paired again.",
|
||||||
|
"fr": "Le déblocage ne rétablit pas l’accès. L’appareil doit être appairé à nouveau.",
|
||||||
|
"es": "Desbloquear no restaura el acceso. Hay que emparejar el dispositivo de nuevo.",
|
||||||
|
"it": "Sbloccare non ripristina l’accesso. Il dispositivo deve essere abbinato di nuovo.",
|
||||||
|
"de": "Die Freigabe stellt den Zugriff nicht wieder her. Das Gerät muss erneut gekoppelt werden.",
|
||||||
|
"pt": "Desbloquear não restaura o acesso. O dispositivo tem de ser emparelhado novamente.",
|
||||||
|
"pl": "Odblokowanie nie przywraca dostępu. Urządzenie trzeba sparować ponownie.",
|
||||||
|
"nl": "Deblokkeren herstelt de toegang niet. Het apparaat moet opnieuw worden gekoppeld.",
|
||||||
|
"ru": "Разблокировка не восстанавливает доступ. Устройство нужно сопрячь заново."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"contacts_send_to": {
|
||||||
|
"context": "Device detail: button that starts choosing files to send to this device.",
|
||||||
|
"translations": {
|
||||||
|
"en": "Send files",
|
||||||
|
"fr": "Envoyer des fichiers",
|
||||||
|
"es": "Enviar archivos",
|
||||||
|
"it": "Invia file",
|
||||||
|
"de": "Dateien senden",
|
||||||
|
"pt": "Enviar ficheiros",
|
||||||
|
"pl": "Wyślij pliki",
|
||||||
|
"nl": "Bestanden sturen",
|
||||||
|
"ru": "Отправить файлы"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"contacts_last_transfer": {
|
||||||
|
"context": "Devices screen: subtitle showing when the last transfer with a device happened. {date} = formatted date.",
|
||||||
|
"args": [
|
||||||
|
{
|
||||||
|
"name": "date",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"translations": {
|
||||||
|
"en": "Last transfer {date}",
|
||||||
|
"fr": "Dernier transfert {date}",
|
||||||
|
"es": "Última transferencia {date}",
|
||||||
|
"it": "Ultimo trasferimento {date}",
|
||||||
|
"de": "Letzte Übertragung {date}",
|
||||||
|
"pt": "Última transferência {date}",
|
||||||
|
"pl": "Ostatnie przesłanie {date}",
|
||||||
|
"nl": "Laatste overdracht {date}",
|
||||||
|
"ru": "Последняя передача {date}"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"pairing_request_title": {
|
||||||
|
"context": "Pairing prompt: title asking whether to remember a device that offered to be reachable.",
|
||||||
|
"translations": {
|
||||||
|
"en": "Remember this device?",
|
||||||
|
"fr": "Mémoriser cet appareil ?",
|
||||||
|
"es": "¿Recordar este dispositivo?",
|
||||||
|
"it": "Ricordare questo dispositivo?",
|
||||||
|
"de": "Dieses Gerät speichern?",
|
||||||
|
"pt": "Lembrar este dispositivo?",
|
||||||
|
"pl": "Zapamiętać to urządzenie?",
|
||||||
|
"nl": "Dit apparaat onthouden?",
|
||||||
|
"ru": "Запомнить это устройство?"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"pairing_request_body": {
|
||||||
|
"context": "Pairing prompt: explains what remembering a device allows. {device} = peer display name.",
|
||||||
|
"args": [
|
||||||
|
{
|
||||||
|
"name": "device",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"translations": {
|
||||||
|
"en": "{device} offered to let you send it files without a new invitation.",
|
||||||
|
"fr": "{device} propose de recevoir vos fichiers sans nouvelle invitation.",
|
||||||
|
"es": "{device} te ofrece enviarle archivos sin una nueva invitación.",
|
||||||
|
"it": "{device} ti consente di inviargli file senza un nuovo invito.",
|
||||||
|
"de": "{device} bietet an, Dateien ohne neue Einladung von Ihnen zu empfangen.",
|
||||||
|
"pt": "{device} ofereceu-se para receber ficheiros seus sem um novo convite.",
|
||||||
|
"pl": "{device} umożliwia wysyłanie plików bez nowego zaproszenia.",
|
||||||
|
"nl": "{device} biedt aan bestanden van je te ontvangen zonder nieuwe uitnodiging.",
|
||||||
|
"ru": "{device} разрешает отправлять файлы без нового приглашения."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"pairing_accept": {
|
||||||
|
"context": "Pairing prompt: button that remembers the device.",
|
||||||
|
"translations": {
|
||||||
|
"en": "Remember",
|
||||||
|
"fr": "Mémoriser",
|
||||||
|
"es": "Recordar",
|
||||||
|
"it": "Ricorda",
|
||||||
|
"de": "Speichern",
|
||||||
|
"pt": "Lembrar",
|
||||||
|
"pl": "Zapamiętaj",
|
||||||
|
"nl": "Onthouden",
|
||||||
|
"ru": "Запомнить"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"pairing_decline": {
|
||||||
|
"context": "Pairing prompt: button that declines to remember the device.",
|
||||||
|
"translations": {
|
||||||
|
"en": "Not now",
|
||||||
|
"fr": "Pas maintenant",
|
||||||
|
"es": "Ahora no",
|
||||||
|
"it": "Non ora",
|
||||||
|
"de": "Jetzt nicht",
|
||||||
|
"pt": "Agora não",
|
||||||
|
"pl": "Nie teraz",
|
||||||
|
"nl": "Niet nu",
|
||||||
|
"ru": "Не сейчас"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"pairing_allow_title": {
|
||||||
|
"context": "Post-transfer prompt: asks whether the other device may send files later without an invitation.",
|
||||||
|
"translations": {
|
||||||
|
"en": "Let this device send to you?",
|
||||||
|
"fr": "Autoriser cet appareil à vous envoyer des fichiers ?",
|
||||||
|
"es": "¿Permitir que este dispositivo te envíe archivos?",
|
||||||
|
"it": "Consentire a questo dispositivo di inviarti file?",
|
||||||
|
"de": "Diesem Gerät erlauben, Ihnen Dateien zu senden?",
|
||||||
|
"pt": "Permitir que este dispositivo lhe envie ficheiros?",
|
||||||
|
"pl": "Zezwolić temu urządzeniu na wysyłanie plików?",
|
||||||
|
"nl": "Dit apparaat toestaan je bestanden te sturen?",
|
||||||
|
"ru": "Разрешить этому устройству отправлять вам файлы?"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"pairing_allow_body": {
|
||||||
|
"context": "Post-transfer prompt: explains that the permission is revocable at any time.",
|
||||||
|
"translations": {
|
||||||
|
"en": "You will still confirm every transfer, and you can withdraw this at any time.",
|
||||||
|
"fr": "Vous confirmerez toujours chaque transfert et pourrez révoquer cette autorisation à tout moment.",
|
||||||
|
"es": "Seguirás confirmando cada transferencia y podrás retirar este permiso cuando quieras.",
|
||||||
|
"it": "Confermerai comunque ogni trasferimento e potrai revocare questa autorizzazione in qualsiasi momento.",
|
||||||
|
"de": "Sie bestätigen weiterhin jede Übertragung und können dies jederzeit widerrufen.",
|
||||||
|
"pt": "Continuará a confirmar cada transferência e pode retirar esta permissão a qualquer momento.",
|
||||||
|
"pl": "Nadal będziesz potwierdzać każde przesłanie i możesz w każdej chwili cofnąć zgodę.",
|
||||||
|
"nl": "Je bevestigt nog steeds elke overdracht en kunt dit altijd intrekken.",
|
||||||
|
"ru": "Вы по-прежнему будете подтверждать каждую передачу и сможете отозвать разрешение в любой момент."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"pairing_allow_confirm": {
|
||||||
|
"context": "Post-transfer prompt: button granting the other device permission to send later.",
|
||||||
|
"translations": {
|
||||||
|
"en": "Allow",
|
||||||
|
"fr": "Autoriser",
|
||||||
|
"es": "Permitir",
|
||||||
|
"it": "Consenti",
|
||||||
|
"de": "Erlauben",
|
||||||
|
"pt": "Permitir",
|
||||||
|
"pl": "Zezwól",
|
||||||
|
"nl": "Toestaan",
|
||||||
|
"ru": "Разрешить"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"offer_title": {
|
||||||
|
"context": "Offer prompt: title when a remembered device wants to send files.",
|
||||||
|
"translations": {
|
||||||
|
"en": "Incoming transfer",
|
||||||
|
"fr": "Transfert entrant",
|
||||||
|
"es": "Transferencia entrante",
|
||||||
|
"it": "Trasferimento in arrivo",
|
||||||
|
"de": "Eingehende Übertragung",
|
||||||
|
"pt": "Transferência recebida",
|
||||||
|
"pl": "Przychodzące przesłanie",
|
||||||
|
"nl": "Inkomende overdracht",
|
||||||
|
"ru": "Входящая передача"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"offer_body": {
|
||||||
|
"context": "Offer prompt: names the sender and what they want to send. {device} = sender, {transferName} = transfer title.",
|
||||||
|
"args": [
|
||||||
|
{
|
||||||
|
"name": "device",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "transferName",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"translations": {
|
||||||
|
"en": "{device} wants to send you “{transferName}”.",
|
||||||
|
"fr": "{device} souhaite vous envoyer « {transferName} ».",
|
||||||
|
"es": "{device} quiere enviarte «{transferName}».",
|
||||||
|
"it": "{device} vuole inviarti «{transferName}».",
|
||||||
|
"de": "{device} möchte Ihnen „{transferName}“ senden.",
|
||||||
|
"pt": "{device} quer enviar-lhe “{transferName}”.",
|
||||||
|
"pl": "{device} chce wysłać Ci „{transferName}”.",
|
||||||
|
"nl": "{device} wil je “{transferName}” sturen.",
|
||||||
|
"ru": "{device} хочет отправить вам «{transferName}»."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"offer_accept": {
|
||||||
|
"context": "Offer prompt: button that accepts the transfer and starts receiving.",
|
||||||
|
"translations": {
|
||||||
|
"en": "Receive",
|
||||||
|
"fr": "Recevoir",
|
||||||
|
"es": "Recibir",
|
||||||
|
"it": "Ricevi",
|
||||||
|
"de": "Empfangen",
|
||||||
|
"pt": "Receber",
|
||||||
|
"pl": "Odbierz",
|
||||||
|
"nl": "Ontvangen",
|
||||||
|
"ru": "Получить"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"offer_decline": {
|
||||||
|
"context": "Offer prompt: button that declines the incoming transfer.",
|
||||||
|
"translations": {
|
||||||
|
"en": "Decline",
|
||||||
|
"fr": "Refuser",
|
||||||
|
"es": "Rechazar",
|
||||||
|
"it": "Rifiuta",
|
||||||
|
"de": "Ablehnen",
|
||||||
|
"pt": "Recusar",
|
||||||
|
"pl": "Odrzuć",
|
||||||
|
"nl": "Weigeren",
|
||||||
|
"ru": "Отклонить"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"contacts_grant_lifetime_title": {
|
||||||
|
"context": "Devices settings: how long a remembered device stays reachable while unused.",
|
||||||
|
"translations": {
|
||||||
|
"en": "Forget unused devices after",
|
||||||
|
"fr": "Oublier les appareils inutilisés après",
|
||||||
|
"es": "Olvidar dispositivos sin usar tras",
|
||||||
|
"it": "Dimentica i dispositivi inutilizzati dopo",
|
||||||
|
"de": "Ungenutzte Geräte entfernen nach",
|
||||||
|
"pt": "Esquecer dispositivos não usados após",
|
||||||
|
"pl": "Zapomnij nieużywane urządzenia po",
|
||||||
|
"nl": "Ongebruikte apparaten vergeten na",
|
||||||
|
"ru": "Забывать неиспользуемые устройства через"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"contacts_grant_lifetime_hint": {
|
||||||
|
"context": "Devices settings: clarifies that the countdown restarts on each transfer.",
|
||||||
|
"translations": {
|
||||||
|
"en": "The countdown restarts every time you transfer with the device.",
|
||||||
|
"fr": "Le décompte redémarre à chaque transfert avec l’appareil.",
|
||||||
|
"es": "La cuenta atrás se reinicia cada vez que transfieres con el dispositivo.",
|
||||||
|
"it": "Il conteggio riparte a ogni trasferimento con il dispositivo.",
|
||||||
|
"de": "Die Frist beginnt bei jeder Übertragung mit dem Gerät neu.",
|
||||||
|
"pt": "A contagem reinicia sempre que transfere com o dispositivo.",
|
||||||
|
"pl": "Odliczanie zaczyna się od nowa przy każdym przesłaniu.",
|
||||||
|
"nl": "De teller start opnieuw bij elke overdracht met het apparaat.",
|
||||||
|
"ru": "Отсчёт начинается заново при каждой передаче с устройством."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"contacts_grant_lifetime_never": {
|
||||||
|
"context": "Devices settings: option to never forget an unused device.",
|
||||||
|
"translations": {
|
||||||
|
"en": "Never",
|
||||||
|
"fr": "Jamais",
|
||||||
|
"es": "Nunca",
|
||||||
|
"it": "Mai",
|
||||||
|
"de": "Nie",
|
||||||
|
"pt": "Nunca",
|
||||||
|
"pl": "Nigdy",
|
||||||
|
"nl": "Nooit",
|
||||||
|
"ru": "Никогда"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"contacts_grant_lifetime_days": {
|
||||||
|
"context": "Devices settings: option label for a number of days. {count} = days.",
|
||||||
|
"args": [
|
||||||
|
{
|
||||||
|
"name": "count",
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"plural": {
|
||||||
|
"en": {
|
||||||
|
"one": "{count} day",
|
||||||
|
"other": "{count} days"
|
||||||
|
},
|
||||||
|
"fr": {
|
||||||
|
"one": "{count} jour",
|
||||||
|
"other": "{count} jours"
|
||||||
|
},
|
||||||
|
"es": {
|
||||||
|
"one": "{count} día",
|
||||||
|
"other": "{count} días"
|
||||||
|
},
|
||||||
|
"it": {
|
||||||
|
"one": "{count} giorno",
|
||||||
|
"other": "{count} giorni"
|
||||||
|
},
|
||||||
|
"de": {
|
||||||
|
"one": "{count} Tag",
|
||||||
|
"other": "{count} Tage"
|
||||||
|
},
|
||||||
|
"pt": {
|
||||||
|
"one": "{count} dia",
|
||||||
|
"other": "{count} dias"
|
||||||
|
},
|
||||||
|
"pl": {
|
||||||
|
"one": "{count} dzień",
|
||||||
|
"few": "{count} dni",
|
||||||
|
"many": "{count} dni",
|
||||||
|
"other": "{count} dnia"
|
||||||
|
},
|
||||||
|
"nl": {
|
||||||
|
"one": "{count} dag",
|
||||||
|
"other": "{count} dagen"
|
||||||
|
},
|
||||||
|
"ru": {
|
||||||
|
"one": "{count} день",
|
||||||
|
"few": "{count} дня",
|
||||||
|
"many": "{count} дней",
|
||||||
|
"other": "{count} дня"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"contacts_check_on_open": {
|
||||||
|
"context": "Devices settings: toggle to check remembered devices for waiting transfers when the app opens.",
|
||||||
|
"translations": {
|
||||||
|
"en": "Check for waiting transfers",
|
||||||
|
"fr": "Rechercher les transferts en attente",
|
||||||
|
"es": "Buscar transferencias en espera",
|
||||||
|
"it": "Cerca trasferimenti in attesa",
|
||||||
|
"de": "Nach wartenden Übertragungen suchen",
|
||||||
|
"pt": "Procurar transferências em espera",
|
||||||
|
"pl": "Sprawdzaj oczekujące przesyłki",
|
||||||
|
"nl": "Controleren op wachtende overdrachten",
|
||||||
|
"ru": "Проверять ожидающие передачи"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"contacts_check_on_open_hint": {
|
||||||
|
"context": "Devices settings: warns that checking reveals to remembered devices when the app is opened.",
|
||||||
|
"translations": {
|
||||||
|
"en": "When you open VniDrop, your remembered devices are asked whether they have anything for you. This tells them when you opened the app.",
|
||||||
|
"fr": "À l’ouverture de VniDrop, vos appareils enregistrés sont interrogés pour savoir s’ils ont quelque chose pour vous. Cela leur indique quand vous ouvrez l’application.",
|
||||||
|
"es": "Al abrir VniDrop, se pregunta a tus dispositivos guardados si tienen algo para ti. Esto les indica cuándo abriste la aplicación.",
|
||||||
|
"it": "All’apertura di VniDrop, ai dispositivi memorizzati viene chiesto se hanno qualcosa per te. Questo rivela loro quando apri l’app.",
|
||||||
|
"de": "Beim Öffnen von VniDrop werden Ihre gespeicherten Geräte gefragt, ob sie etwas für Sie haben. Dadurch erfahren sie, wann Sie die App geöffnet haben.",
|
||||||
|
"pt": "Ao abrir o VniDrop, os dispositivos guardados são questionados se têm algo para si. Isto revela-lhes quando abriu a aplicação.",
|
||||||
|
"pl": "Po otwarciu VniDrop zapamiętane urządzenia są pytane, czy mają coś dla Ciebie. Dzięki temu wiedzą, kiedy otwierasz aplikację.",
|
||||||
|
"nl": "Bij het openen van VniDrop wordt aan je onthouden apparaten gevraagd of ze iets voor je hebben. Zij weten daardoor wanneer je de app opende.",
|
||||||
|
"ru": "При открытии VniDrop сохранённые устройства опрашиваются, есть ли у них что-то для вас. Так они узнают, когда вы открыли приложение."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"contacts_check_now": {
|
||||||
|
"context": "Devices screen: button that checks remembered devices for waiting transfers right now.",
|
||||||
|
"translations": {
|
||||||
|
"en": "Check now",
|
||||||
|
"fr": "Vérifier maintenant",
|
||||||
|
"es": "Comprobar ahora",
|
||||||
|
"it": "Controlla ora",
|
||||||
|
"de": "Jetzt prüfen",
|
||||||
|
"pt": "Verificar agora",
|
||||||
|
"pl": "Sprawdź teraz",
|
||||||
|
"nl": "Nu controleren",
|
||||||
|
"ru": "Проверить сейчас"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"contacts_check_none": {
|
||||||
|
"context": "Devices screen: result message when no device had anything waiting.",
|
||||||
|
"translations": {
|
||||||
|
"en": "Nothing waiting",
|
||||||
|
"fr": "Rien en attente",
|
||||||
|
"es": "Nada en espera",
|
||||||
|
"it": "Nulla in attesa",
|
||||||
|
"de": "Nichts wartet",
|
||||||
|
"pt": "Nada em espera",
|
||||||
|
"pl": "Nic nie czeka",
|
||||||
|
"nl": "Niets in de wacht",
|
||||||
|
"ru": "Ничего не ожидает"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"contacts_offer_held": {
|
||||||
|
"context": "Shown after sending to a device that was not running: the transfer waits for it to open the app.",
|
||||||
|
"translations": {
|
||||||
|
"en": "That device is not open. The transfer will be delivered the next time it opens VniDrop.",
|
||||||
|
"fr": "Cet appareil n’est pas ouvert. Le transfert sera remis à sa prochaine ouverture de VniDrop.",
|
||||||
|
"es": "Ese dispositivo no está abierto. La transferencia se entregará la próxima vez que abra VniDrop.",
|
||||||
|
"it": "Quel dispositivo non è aperto. Il trasferimento verrà consegnato alla prossima apertura di VniDrop.",
|
||||||
|
"de": "Dieses Gerät ist nicht geöffnet. Die Übertragung wird beim nächsten Öffnen von VniDrop zugestellt.",
|
||||||
|
"pt": "Esse dispositivo não está aberto. A transferência será entregue da próxima vez que abrir o VniDrop.",
|
||||||
|
"pl": "To urządzenie nie jest otwarte. Przesyłka zostanie dostarczona przy następnym uruchomieniu VniDrop.",
|
||||||
|
"nl": "Dat apparaat is niet geopend. De overdracht wordt bezorgd zodra het VniDrop weer opent.",
|
||||||
|
"ru": "Это устройство не открыто. Передача будет доставлена при следующем запуске VniDrop."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"contacts_waiting_title": {
|
||||||
|
"context": "Devices screen: section listing transfers waiting for their target device to come online.",
|
||||||
|
"translations": {
|
||||||
|
"en": "Waiting to be delivered",
|
||||||
|
"fr": "En attente de remise",
|
||||||
|
"es": "Pendientes de entrega",
|
||||||
|
"it": "In attesa di consegna",
|
||||||
|
"de": "Wartet auf Zustellung",
|
||||||
|
"pt": "A aguardar entrega",
|
||||||
|
"pl": "Oczekuje na dostarczenie",
|
||||||
|
"nl": "Wacht op bezorging",
|
||||||
|
"ru": "Ожидает доставки"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"contacts_waiting_hint": {
|
||||||
|
"context": "Devices screen: explains that a waiting transfer is withdrawn by cancelling it.",
|
||||||
|
"translations": {
|
||||||
|
"en": "Cancel the transfer to withdraw it.",
|
||||||
|
"fr": "Annulez le transfert pour le retirer.",
|
||||||
|
"es": "Cancela la transferencia para retirarla.",
|
||||||
|
"it": "Annulla il trasferimento per ritirarlo.",
|
||||||
|
"de": "Brechen Sie die Übertragung ab, um sie zurückzuziehen.",
|
||||||
|
"pt": "Cancele a transferência para a retirar.",
|
||||||
|
"pl": "Anuluj przesyłkę, aby ją wycofać.",
|
||||||
|
"nl": "Annuleer de overdracht om die in te trekken.",
|
||||||
|
"ru": "Отмените передачу, чтобы отозвать её."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"contacts_send_to_device": {
|
||||||
|
"context": "Transfer share panel: action that sends this transfer straight to a remembered device.",
|
||||||
|
"translations": {
|
||||||
|
"en": "Send to a device",
|
||||||
|
"fr": "Envoyer à un appareil",
|
||||||
|
"es": "Enviar a un dispositivo",
|
||||||
|
"it": "Invia a un dispositivo",
|
||||||
|
"de": "An ein Gerät senden",
|
||||||
|
"pt": "Enviar para um dispositivo",
|
||||||
|
"pl": "Wyślij do urządzenia",
|
||||||
|
"nl": "Naar een apparaat sturen",
|
||||||
|
"ru": "Отправить на устройство"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"contacts_pick_device_title": {
|
||||||
|
"context": "Device picker sheet: title when choosing which remembered device to send a transfer to.",
|
||||||
|
"translations": {
|
||||||
|
"en": "Choose a device",
|
||||||
|
"fr": "Choisir un appareil",
|
||||||
|
"es": "Elegir un dispositivo",
|
||||||
|
"it": "Scegli un dispositivo",
|
||||||
|
"de": "Gerät auswählen",
|
||||||
|
"pt": "Escolher um dispositivo",
|
||||||
|
"pl": "Wybierz urządzenie",
|
||||||
|
"nl": "Kies een apparaat",
|
||||||
|
"ru": "Выберите устройство"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"contacts_pick_device_empty": {
|
||||||
|
"context": "Device picker sheet: shown when no remembered device can currently be sent to.",
|
||||||
|
"translations": {
|
||||||
|
"en": "No device can be reached right now. Remembered devices appear here after a transfer.",
|
||||||
|
"fr": "Aucun appareil n’est joignable pour le moment. Les appareils enregistrés apparaissent ici après un transfert.",
|
||||||
|
"es": "Ningún dispositivo está disponible ahora. Los dispositivos guardados aparecen aquí tras una transferencia.",
|
||||||
|
"it": "Nessun dispositivo è raggiungibile ora. I dispositivi memorizzati compaiono qui dopo un trasferimento.",
|
||||||
|
"de": "Derzeit ist kein Gerät erreichbar. Gespeicherte Geräte erscheinen hier nach einer Übertragung.",
|
||||||
|
"pt": "Nenhum dispositivo está acessível agora. Os dispositivos guardados aparecem aqui após uma transferência.",
|
||||||
|
"pl": "Żadne urządzenie nie jest teraz dostępne. Zapamiętane urządzenia pojawią się tu po przesłaniu.",
|
||||||
|
"nl": "Er is nu geen apparaat bereikbaar. Onthouden apparaten verschijnen hier na een overdracht.",
|
||||||
|
"ru": "Сейчас ни одно устройство недоступно. Сохранённые устройства появятся здесь после передачи."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"contacts_sent_to_device": {
|
||||||
|
"context": "Confirmation after a transfer was accepted by the device it was sent to. {device} = device name.",
|
||||||
|
"args": [
|
||||||
|
{
|
||||||
|
"name": "device",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"translations": {
|
||||||
|
"en": "{device} accepted the transfer",
|
||||||
|
"fr": "{device} a accepté le transfert",
|
||||||
|
"es": "{device} aceptó la transferencia",
|
||||||
|
"it": "{device} ha accettato il trasferimento",
|
||||||
|
"de": "{device} hat die Übertragung angenommen",
|
||||||
|
"pt": "{device} aceitou a transferência",
|
||||||
|
"pl": "{device} zaakceptowało przesyłkę",
|
||||||
|
"nl": "{device} heeft de overdracht geaccepteerd",
|
||||||
|
"ru": "{device} принял передачу"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"contacts_declined_by_device": {
|
||||||
|
"context": "Shown when the person on the other device declined an offered transfer. {device} = device name.",
|
||||||
|
"args": [
|
||||||
|
{
|
||||||
|
"name": "device",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"translations": {
|
||||||
|
"en": "{device} declined the transfer",
|
||||||
|
"fr": "{device} a refusé le transfert",
|
||||||
|
"es": "{device} rechazó la transferencia",
|
||||||
|
"it": "{device} ha rifiutato il trasferimento",
|
||||||
|
"de": "{device} hat die Übertragung abgelehnt",
|
||||||
|
"pt": "{device} recusou a transferência",
|
||||||
|
"pl": "{device} odrzuciło przesyłkę",
|
||||||
|
"nl": "{device} heeft de overdracht geweigerd",
|
||||||
|
"ru": "{device} отклонил передачу"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"contacts_no_answer": {
|
||||||
|
"context": "Shown when an offered transfer got no answer on the other device before timing out. {device} = device name.",
|
||||||
|
"args": [
|
||||||
|
{
|
||||||
|
"name": "device",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"translations": {
|
||||||
|
"en": "{device} did not answer",
|
||||||
|
"fr": "{device} n’a pas répondu",
|
||||||
|
"es": "{device} no respondió",
|
||||||
|
"it": "{device} non ha risposto",
|
||||||
|
"de": "{device} hat nicht geantwortet",
|
||||||
|
"pt": "{device} não respondeu",
|
||||||
|
"pl": "{device} nie odpowiedziało",
|
||||||
|
"nl": "{device} heeft niet geantwoord",
|
||||||
|
"ru": "{device} не ответил"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
4
packaging/apple/studio/.gitignore
vendored
@@ -1,4 +0,0 @@
|
|||||||
generated/
|
|
||||||
out-*.png
|
|
||||||
.build/
|
|
||||||
.swiftpm/
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
// swift-tools-version:6.0
|
|
||||||
import PackageDescription
|
|
||||||
|
|
||||||
let package = Package(
|
|
||||||
name: "studio",
|
|
||||||
platforms: [.macOS(.v14)],
|
|
||||||
targets: [
|
|
||||||
.executableTarget(
|
|
||||||
name: "studio",
|
|
||||||
path: "Sources/studio"
|
|
||||||
)
|
|
||||||
]
|
|
||||||
)
|
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
# App Store screenshot studio
|
|
||||||
|
|
||||||
Code-driven, fully local App Store screenshots — composed natively with **SwiftUI +
|
|
||||||
`ImageRenderer`** and **SceneKit** (no Typst, no ImageMagick). Each marketing screen is a
|
|
||||||
SwiftUI view (gradient + 3D device + captions + generated artwork) rendered off-screen to
|
|
||||||
an exact-size PNG. Captions come from `strings.json` (9 locales); the app screenshots on
|
|
||||||
the device screens are captured from the real app.
|
|
||||||
|
|
||||||
## Requirements
|
|
||||||
- Xcode / Swift toolchain (macOS 26+). That's it.
|
|
||||||
|
|
||||||
## Run
|
|
||||||
```sh
|
|
||||||
swift run studio # all locales × screens -> generated/<Language>/
|
|
||||||
swift run studio --publish # -> ../<Language>/ (ships to App Store)
|
|
||||||
LOCALES="fr de" SCREENS="share-securely" swift run studio # subset
|
|
||||||
PLATFORM=ipad swift run studio # iPad set -> generated/<Language>/iPad/
|
|
||||||
PLATFORM=mac swift run studio # Mac set -> generated/<Language>/Mac/
|
|
||||||
```
|
|
||||||
`PLATFORM` must match between `capture.sh` and `swift run studio` (they read the same
|
|
||||||
`generated/shots/<platform>/` tree).
|
|
||||||
Run from this directory (it reads `strings.json` and `assets/` relative to cwd).
|
|
||||||
|
|
||||||
## Full pipeline
|
|
||||||
```sh
|
|
||||||
./capture.sh # real localized app screens -> generated/shots/<platform>/<locale>/
|
|
||||||
swift run studio # composite everything -> generated/<Language>/[iPad/]
|
|
||||||
./generate.sh # capture + composite in one shot (forwards PLATFORM / --publish)
|
|
||||||
```
|
|
||||||
`capture.sh` drives the app into each screen via the DEBUG `-VniScreenshot` launch
|
|
||||||
argument (see `apple/VniDrop/App/ScreenshotSupport.swift`), once per locale via
|
|
||||||
`-AppleLanguages`, in dark mode. Screenshots are transient (git-ignored, regenerated per
|
|
||||||
run) — during layout iteration, capture once then re-run `swift run studio` on its own.
|
|
||||||
- **iPhone / iPad** run in the simulator (9:41 status-bar override) via `simctl`.
|
|
||||||
- **Mac** has no simulator: `capture.sh` builds the native app, launches the binary
|
|
||||||
directly, sizes its window with `osascript`, and grabs it with `screencapture`. Grant
|
|
||||||
the terminal **Accessibility + Screen Recording** permission the first time.
|
|
||||||
|
|
||||||
## Platforms
|
|
||||||
`PLATFORM=iphone` (default), `ipad`, or `mac`. Each `Platform`
|
|
||||||
(`Sources/studio/Platform.swift`) carries its canvas size, capture target, output
|
|
||||||
subfolder, and a `DeviceModel` — the `.usdz`, its screen material, the orientation fix
|
|
||||||
(yaw so the screen faces the camera), optional front-glass mesh to hide, whether to tint
|
|
||||||
the body graphite, a `fillFraction` (how much of the frame the device fills; lower for
|
|
||||||
wide 3/4 poses that would otherwise clip), and a `screenPad` (black margin baked around
|
|
||||||
the shot so the window clears the display's rounded corners). Adding a device is a new
|
|
||||||
`DeviceModel` + `Platform` case + a layout set. (The studio is Apple-only today; the
|
|
||||||
same SwiftUI/SceneKit approach is intended to extend to Android and Windows later.)
|
|
||||||
|
|
||||||
## Device rendering
|
|
||||||
`SceneKitDeviceRenderer` textures the captured screenshot onto the model's screen mesh and
|
|
||||||
renders it off-screen. Curvature, bezel and body are the model's real geometry; poses
|
|
||||||
(pitch/yaw/roll) and a studio environment (IBL + bloom) are applied in the scene. It
|
|
||||||
auto-generates planar `[0,1]` UVs for screen meshes that ship without them, and mattes the
|
|
||||||
shot onto a slightly larger black canvas (`screenPad`) so the display edge stays black
|
|
||||||
instead of smearing the capture's corner pixels.
|
|
||||||
|
|
||||||
## Assets (`assets/`)
|
|
||||||
- `iphone-17-pro-max.usdz`, `ipad-pro.usdz`, `macbook-air.usdz` — the 3D devices
|
|
||||||
(committed; CC BY 4.0, see `ATTRIBUTION.md`).
|
|
||||||
- `globe.png` — for the send-anywhere hero (committed).
|
|
||||||
- `generated/shots/<platform>/<locale>/*.png` — captured app screens (transient).
|
|
||||||
|
|
||||||
## Layout & tuning
|
|
||||||
- `Sources/studio/ScreenSpec.swift` — per-platform layouts (`iphone` / `ipad` / `mac`):
|
|
||||||
gradient, device pose/position/size, globe + route (send-anywhere), the encryption flow
|
|
||||||
(stay-private: `beams` → `lock` → `stream` + `banners`), caption placement. `beams` and
|
|
||||||
`stream` take a `horizontal` flag for side-by-side devices (used by Mac stay-private).
|
|
||||||
- `Sources/studio/ScreenFrame.swift` — the composition (layer order, caption fitting,
|
|
||||||
generated artwork).
|
|
||||||
|
|
||||||
## Screens
|
|
||||||
`share-securely`, `choose-receivers`, `send-anywhere` (globe + Paris→LA route arc,
|
|
||||||
reuses the share screenshot), `stay-private` (encryption beams → padlock → binary
|
|
||||||
protection stream, with localized CHIFFREMENT/PROTECTION banners; two stacked phones on
|
|
||||||
iPhone/iPad, two side-by-side laptops with a horizontal flow on Mac).
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
import SwiftUI
|
|
||||||
import AppKit
|
|
||||||
|
|
||||||
// The device is a swappable layer behind this protocol; `SceneKitDeviceRenderer` is the
|
|
||||||
// current implementation (textures the app screenshot onto a real .usdz model).
|
|
||||||
protocol DeviceRenderer {
|
|
||||||
// Produces the device as a SwiftUI view sized to `spec.height` (width follows the
|
|
||||||
// device aspect), already framing `shot` and posed per `spec`.
|
|
||||||
@MainActor func view(shot: NSImage?, spec: DeviceSpec) -> AnyView
|
|
||||||
}
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
|
|
||||||
// A 3D device model + the per-model quirks the renderer needs (screen material name,
|
|
||||||
// orientation fix so the screen faces the camera, optional front-glass mesh to hide,
|
|
||||||
// whether to tint the body graphite).
|
|
||||||
struct DeviceModel {
|
|
||||||
var url: URL
|
|
||||||
var screenMaterial: String
|
|
||||||
var glassMaterial: String? // nil = no separate glass mesh
|
|
||||||
var bodyYaw: Double = 0 // deg about Y to face the screen toward the camera (-Z)
|
|
||||||
var recolorBody: Bool = false // graphite tint
|
|
||||||
// Fraction of the square render the device's upright height fills. Lower = more margin
|
|
||||||
// for wide 3/4 poses (a laptop's fanned base projects past a tight frame and clips).
|
|
||||||
var fillFraction: CGFloat = 0.66
|
|
||||||
// Overscan for auto-generated screen UVs: >0 shrinks the screenshot slightly so its
|
|
||||||
// edges aren't hidden when the screen mesh is a touch larger than the visible display.
|
|
||||||
var screenPad: CGFloat = 0
|
|
||||||
|
|
||||||
static func iphone(_ assets: URL) -> DeviceModel {
|
|
||||||
DeviceModel(url: assets.appendingPathComponent("iphone-17-pro-max.usdz"),
|
|
||||||
screenMaterial: "_7ProMax_Screen", glassMaterial: "glass",
|
|
||||||
bodyYaw: 0, recolorBody: true)
|
|
||||||
}
|
|
||||||
static func ipad(_ assets: URL) -> DeviceModel {
|
|
||||||
// Screen mesh "Material_002" (display only; the bezel is real geometry). Front
|
|
||||||
// faces +Z, so yaw 180° to face -Z. Standard UVs — no remap or bezel padding.
|
|
||||||
DeviceModel(url: assets.appendingPathComponent("ipad-pro.usdz"),
|
|
||||||
screenMaterial: "Material_002", glassMaterial: nil,
|
|
||||||
bodyYaw: 180, recolorBody: true)
|
|
||||||
}
|
|
||||||
static func macbook(_ assets: URL) -> DeviceModel {
|
|
||||||
// Open-laptop model; display mesh "screen_black" (no UVs — auto-generated). Front
|
|
||||||
// faces +Z, so yaw 180° to face -Z.
|
|
||||||
DeviceModel(url: assets.appendingPathComponent("macbook-air.usdz"),
|
|
||||||
screenMaterial: "screen_black", glassMaterial: nil,
|
|
||||||
bodyYaw: 180, recolorBody: false, fillFraction: 0.46, screenPad: 0.008)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// An App Store target: canvas size, device model, capture simulator, output subfolder.
|
|
||||||
enum Platform: String {
|
|
||||||
case iphone, ipad, mac
|
|
||||||
|
|
||||||
var canvas: CGSize {
|
|
||||||
switch self {
|
|
||||||
case .iphone: CGSize(width: 1284, height: 2778) // 6.5" iPhone
|
|
||||||
case .ipad: CGSize(width: 2064, height: 2752) // 13" iPad Pro (matches the M5 sim)
|
|
||||||
case .mac: CGSize(width: 2880, height: 1800) // Mac App Store (16:10 landscape)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func deviceModel(assets: URL) -> DeviceModel {
|
|
||||||
switch self {
|
|
||||||
case .iphone: DeviceModel.iphone(assets)
|
|
||||||
case .ipad: DeviceModel.ipad(assets)
|
|
||||||
case .mac: DeviceModel.macbook(assets)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Simulator used by capture.sh (informational here; capture reads its own env).
|
|
||||||
var simulator: String {
|
|
||||||
switch self {
|
|
||||||
case .iphone: "iPhone 17 Pro Max"
|
|
||||||
case .ipad: "iPad Pro 13-inch (M5)"
|
|
||||||
case .mac: "My Mac"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Output goes under <Language>/<subfolder> so the sets stay separate.
|
|
||||||
var outputSubfolder: String {
|
|
||||||
switch self {
|
|
||||||
case .iphone: "iPhone"
|
|
||||||
case .ipad: "iPad"
|
|
||||||
case .mac: "Mac"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,266 +0,0 @@
|
|||||||
import SwiftUI
|
|
||||||
import SceneKit
|
|
||||||
import AppKit
|
|
||||||
|
|
||||||
// Full 3D device layer: textures the app screenshot onto the real iPhone .usdz screen
|
|
||||||
// mesh and renders it with SceneKit off-screen. Slots behind the same DeviceRenderer
|
|
||||||
// protocol as the 2D compositor — ScreenFrame is unchanged. Tilt is baked into the
|
|
||||||
// scene (a real camera + perspective), not faked.
|
|
||||||
|
|
||||||
struct SceneKitDeviceRenderer: DeviceRenderer {
|
|
||||||
let model: DeviceModel
|
|
||||||
|
|
||||||
var supersample: CGFloat = 2 // render big, SwiftUI downscales for clean edges
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
func view(shot: NSImage?, spec: DeviceSpec) -> AnyView {
|
|
||||||
// Render into a square (so any pose fits without clipping); the upright phone
|
|
||||||
// occupies `heightFraction` of it, so displaySide maps that to spec.height.
|
|
||||||
let displaySide = spec.height / model.fillFraction
|
|
||||||
let renderSide = min(displaySide * supersample, 3200)
|
|
||||||
guard let img = render(shot: shot, spec: spec, side: renderSide) else {
|
|
||||||
return AnyView(Color.clear.frame(width: displaySide, height: displaySide))
|
|
||||||
}
|
|
||||||
return AnyView(Image(nsImage: img).resizable().frame(width: displaySide, height: displaySide))
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
private func render(shot: NSImage?, spec: DeviceSpec, side: CGFloat) -> NSImage? {
|
|
||||||
guard let scene = try? SCNScene(url: model.url),
|
|
||||||
let device = MTLCreateSystemDefaultDevice() else { return nil }
|
|
||||||
let root = scene.rootNode
|
|
||||||
let d2r = Double.pi / 180
|
|
||||||
|
|
||||||
// Orient the model so its screen faces the camera (-Z). Some models (iPad) have
|
|
||||||
// the screen on ±X, so a per-model yaw fix is applied first.
|
|
||||||
let oriented = SCNNode()
|
|
||||||
for child in root.childNodes { oriented.addChildNode(child) }
|
|
||||||
oriented.eulerAngles = SCNVector3(0, model.bodyYaw * d2r, 0)
|
|
||||||
|
|
||||||
// Re-parent under a pivot so the pose rotates it about its own centre. Compute the
|
|
||||||
// (oriented) bounding box before applying the pose. rootNode.boundingBox aggregates
|
|
||||||
// the big ancestor unit-scale correctly (flattenedClone collapses these models).
|
|
||||||
let pivot = SCNNode()
|
|
||||||
pivot.addChildNode(oriented)
|
|
||||||
root.addChildNode(pivot)
|
|
||||||
let (bmin, bmax) = pivot.boundingBox
|
|
||||||
let center = SCNVector3((bmin.x + bmax.x) / 2, (bmin.y + bmax.y) / 2, (bmin.z + bmax.z) / 2)
|
|
||||||
let height = CGFloat(bmax.y - bmin.y)
|
|
||||||
pivot.pivot = SCNMatrix4MakeTranslation(center.x, center.y, center.z)
|
|
||||||
pivot.position = center
|
|
||||||
pivot.eulerAngles = SCNVector3(spec.pose.pitch * d2r, spec.pose.yaw * d2r, spec.pose.roll * d2r)
|
|
||||||
|
|
||||||
// Texture the screenshot onto the screen mesh, shown flat and full-brightness.
|
|
||||||
// The model's screen carries a wavy normal map (a "screen protector" look) that
|
|
||||||
// ripples the image — clear it so the app content stays crisp, as Apple requires.
|
|
||||||
// Some screen meshes ship with no UV coordinates (an image can't map onto them —
|
|
||||||
// it renders as a flat colour). Generate planar UVs from the vertex positions.
|
|
||||||
addPlanarUVsToScreen(in: pivot, material: model.screenMaterial)
|
|
||||||
|
|
||||||
// Texture EVERY material with the screen name (some models split the display into
|
|
||||||
// several meshes that share the material name); setting only the first leaves the
|
|
||||||
// rest white.
|
|
||||||
// A thin black margin baked around the shot so the window's own corners clear the
|
|
||||||
// display's rounded edge — done in the image (not via UV overscan) so the clamped
|
|
||||||
// border samples black instead of smearing the capture's rounded-corner pixels.
|
|
||||||
let screenShot = (shot != nil && model.screenPad > 0) ? matted(shot!, pad: model.screenPad) : shot
|
|
||||||
for mat in materials(named: model.screenMaterial, in: pivot) where spec.blackScreen || shot != nil {
|
|
||||||
mat.diffuse.contents = spec.blackScreen ? NSColor.black : screenShot
|
|
||||||
mat.lightingModel = .constant
|
|
||||||
mat.normal.contents = nil
|
|
||||||
mat.emission.contents = nil
|
|
||||||
mat.metalness.contents = NSNumber(value: 0)
|
|
||||||
mat.roughness.contents = NSNumber(value: 1)
|
|
||||||
mat.isDoubleSided = false
|
|
||||||
mat.diffuse.wrapS = .clamp
|
|
||||||
mat.diffuse.wrapT = .clamp
|
|
||||||
}
|
|
||||||
|
|
||||||
// Hide the front glass mesh (if any) — it has its own normal-mapped waviness that
|
|
||||||
// reflects as swirls over the screen. We keep the crisp display instead.
|
|
||||||
if let glass = model.glassMaterial { hideMeshes(withMaterial: glass, in: pivot) }
|
|
||||||
|
|
||||||
// Recolor a silver body to graphite (iPhone); models already dark (iPad) skip it.
|
|
||||||
if model.recolorBody { recolorBody(in: pivot) }
|
|
||||||
|
|
||||||
// Camera on the screen side (front = -Z), oriented by hand: look(at:) renders
|
|
||||||
// nothing here, but a straight 180° yaw does. Framed so an upright phone height
|
|
||||||
// fills `heightFraction` of the square canvas (leaving margin for the pose).
|
|
||||||
let fovV = 18.0
|
|
||||||
let cam = SCNCamera()
|
|
||||||
cam.usesOrthographicProjection = false
|
|
||||||
cam.fieldOfView = fovV
|
|
||||||
cam.projectionDirection = .vertical
|
|
||||||
// zFar must comfortably exceed the camera distance; models differ hugely in unit
|
|
||||||
// scale (iPhone ~16 units tall, iPad ~300), so keep this large.
|
|
||||||
cam.zNear = 0.01; cam.zFar = 100_000
|
|
||||||
// HDR + subtle bloom so bright specular highlights on the metal/glass glow like a
|
|
||||||
// real product shot instead of clipping flat.
|
|
||||||
cam.wantsHDR = true
|
|
||||||
cam.wantsExposureAdaptation = false
|
|
||||||
cam.bloomThreshold = 0.9
|
|
||||||
cam.bloomIntensity = 0.25
|
|
||||||
cam.bloomBlurRadius = 6
|
|
||||||
let camNode = SCNNode(); camNode.camera = cam
|
|
||||||
let dist = Double(height) / (2 * Double(model.fillFraction) * tan(fovV / 2 * .pi / 180))
|
|
||||||
camNode.position = SCNVector3(center.x, center.y, center.z - SCNFloat(dist))
|
|
||||||
camNode.eulerAngles = SCNVector3(0, Double.pi, 0)
|
|
||||||
root.addChildNode(camNode)
|
|
||||||
|
|
||||||
lightRig(into: root, center: center)
|
|
||||||
// Image-based lighting: a studio softbox environment gives the metal frame and
|
|
||||||
// glass real reflections/highlights — this is what stops it looking plasticky.
|
|
||||||
scene.lightingEnvironment.contents = Self.studioEnvironment
|
|
||||||
scene.lightingEnvironment.intensity = 1.5
|
|
||||||
|
|
||||||
let renderer = SCNRenderer(device: device, options: nil)
|
|
||||||
renderer.scene = scene
|
|
||||||
renderer.pointOfView = camNode
|
|
||||||
renderer.autoenablesDefaultLighting = false
|
|
||||||
let px = CGSize(width: side, height: side)
|
|
||||||
// First snapshot primes the renderer and comes back empty; the second is real.
|
|
||||||
_ = renderer.snapshot(atTime: 0, with: CGSize(width: 16, height: 16), antialiasingMode: .none)
|
|
||||||
return renderer.snapshot(atTime: 0, with: px, antialiasingMode: .multisampling4X)
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
private func lightRig(into root: SCNNode, center: SCNVector3) {
|
|
||||||
func light(_ type: SCNLight.LightType, _ intensity: CGFloat, at pos: SCNVector3) {
|
|
||||||
let l = SCNLight(); l.type = type; l.intensity = intensity; l.temperature = 6500
|
|
||||||
l.castsShadow = false
|
|
||||||
let n = SCNNode(); n.light = l; n.position = pos; n.look(at: center)
|
|
||||||
root.addChildNode(n)
|
|
||||||
}
|
|
||||||
// Low ambient (the environment map does the fill); a crisp key for the highlight
|
|
||||||
// streak down the frame, a fill from the other side, and a top rim so the upper
|
|
||||||
// frame/bezel catches a highlight too instead of reading flat.
|
|
||||||
light(.ambient, 180, at: center)
|
|
||||||
light(.directional, 850, at: SCNVector3(center.x - 0.5, center.y + 0.6, center.z - 0.8))
|
|
||||||
light(.directional, 320, at: SCNVector3(center.x + 0.7, center.y - 0.2, center.z - 0.6))
|
|
||||||
light(.directional, 1100, at: SCNVector3(center.x - 0.15, center.y + 1.3, center.z - 0.5)) // top rim
|
|
||||||
light(.spot, 900, at: SCNVector3(center.x + 0.2, center.y + 1.1, center.z - 0.7)) // top glint
|
|
||||||
}
|
|
||||||
|
|
||||||
// A procedural equirectangular studio environment: bright "ceiling" softbox fading to
|
|
||||||
// a darker floor, so reflective surfaces show a gradient with a hot highlight band.
|
|
||||||
static let studioEnvironment: NSImage = {
|
|
||||||
let w = 1024, h = 512
|
|
||||||
let img = NSImage(size: NSSize(width: w, height: h))
|
|
||||||
img.lockFocus()
|
|
||||||
let grad = NSGradient(colorsAndLocations:
|
|
||||||
(NSColor(white: 1.0, alpha: 1), 0.0), // top = ceiling (bright)
|
|
||||||
(NSColor(white: 0.9, alpha: 1), 0.28),
|
|
||||||
(NSColor(white: 0.5, alpha: 1), 0.55),
|
|
||||||
(NSColor(white: 0.22, alpha: 1), 0.8),
|
|
||||||
(NSColor(white: 0.1, alpha: 1), 1.0)) // bottom = floor (dark)
|
|
||||||
grad?.draw(in: NSRect(x: 0, y: 0, width: w, height: h), angle: 90)
|
|
||||||
// Two softbox bands (upper "ceiling" + lower "floor bounce") so reflective
|
|
||||||
// surfaces catch a highlight at both the top and bottom of the frame.
|
|
||||||
NSColor.white.setFill()
|
|
||||||
NSBezierPath(roundedRect: NSRect(x: w / 4, y: Int(Double(h) * 0.66), width: w / 2, height: h / 8),
|
|
||||||
xRadius: 20, yRadius: 20).fill()
|
|
||||||
NSColor(white: 0.75, alpha: 1).setFill()
|
|
||||||
NSBezierPath(roundedRect: NSRect(x: w / 5, y: Int(Double(h) * 0.24), width: Int(Double(w) * 0.6), height: h / 10),
|
|
||||||
xRadius: 16, yRadius: 16).fill()
|
|
||||||
img.unlockFocus()
|
|
||||||
return img
|
|
||||||
}()
|
|
||||||
|
|
||||||
// Tint the phone body graphite. Skips the screen, hidden glass, and camera lenses.
|
|
||||||
// Uses `multiply` so the metallic reflections survive (just darkened), giving a
|
|
||||||
// space-black look with sharp rail highlights rather than bright silver.
|
|
||||||
private func recolorBody(in node: SCNNode) {
|
|
||||||
let skip = ["screen", "glass", "lens", "logo"]
|
|
||||||
let graphite = NSColor(calibratedRed: 0.19, green: 0.19, blue: 0.21, alpha: 1)
|
|
||||||
func walk(_ n: SCNNode) {
|
|
||||||
for m in n.geometry?.materials ?? [] {
|
|
||||||
let name = (m.name ?? "").lowercased()
|
|
||||||
// Never tint the screen — its material name may not contain "screen"
|
|
||||||
// (the iPad's is just "Material").
|
|
||||||
if m.name == model.screenMaterial { continue }
|
|
||||||
if skip.contains(where: { name.contains($0) }) { continue }
|
|
||||||
m.multiply.contents = graphite
|
|
||||||
// Sharper, brighter specular so the frame highlights "pop" like polished
|
|
||||||
// metal instead of a soft satin.
|
|
||||||
m.metalness.contents = NSNumber(value: 1.0)
|
|
||||||
m.roughness.contents = NSNumber(value: 0.22)
|
|
||||||
}
|
|
||||||
n.childNodes.forEach(walk)
|
|
||||||
}
|
|
||||||
walk(node)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func hideMeshes(withMaterial substring: String, in node: SCNNode) {
|
|
||||||
if node.geometry?.materials.contains(where: { ($0.name ?? "").localizedCaseInsensitiveContains(substring) }) == true {
|
|
||||||
node.isHidden = true
|
|
||||||
}
|
|
||||||
for c in node.childNodes { hideMeshes(withMaterial: substring, in: c) }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Give the screen mesh planar UVs (mapped over its two largest-extent axes) when it
|
|
||||||
// has none, so a screenshot texture maps across the display.
|
|
||||||
private func addPlanarUVsToScreen(in node: SCNNode, material name: String) {
|
|
||||||
guard let g = node.geometry,
|
|
||||||
g.materials.contains(where: { ($0.name ?? "").caseInsensitiveCompare(name) == .orderedSame }),
|
|
||||||
g.sources(for: .texcoord).isEmpty,
|
|
||||||
let vsrc = g.sources(for: .vertex).first else {
|
|
||||||
node.childNodes.forEach { addPlanarUVsToScreen(in: $0, material: name) }
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// Read vertex positions.
|
|
||||||
var pos: [(Float, Float, Float)] = []
|
|
||||||
let stride = vsrc.dataStride, off = vsrc.dataOffset
|
|
||||||
vsrc.data.withUnsafeBytes { (p: UnsafeRawBufferPointer) in
|
|
||||||
for i in 0..<vsrc.vectorCount {
|
|
||||||
let b = off + i * stride
|
|
||||||
pos.append((p.load(fromByteOffset: b, as: Float.self),
|
|
||||||
p.load(fromByteOffset: b + 4, as: Float.self),
|
|
||||||
p.load(fromByteOffset: b + 8, as: Float.self)))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let xs = pos.map(\.0), ys = pos.map(\.1), zs = pos.map(\.2)
|
|
||||||
let ext = [xs.max()! - xs.min()!, ys.max()! - ys.min()!, zs.max()! - zs.min()!]
|
|
||||||
// Screen plane = the two axes with the largest extent (drop the thin normal axis).
|
|
||||||
let normalAxis = ext.firstIndex(of: ext.min()!)!
|
|
||||||
let planeAxes = [0, 1, 2].filter { $0 != normalAxis } // [u-axis, v-axis]
|
|
||||||
func comp(_ p: (Float, Float, Float), _ a: Int) -> Float { a == 0 ? p.0 : (a == 1 ? p.1 : p.2) }
|
|
||||||
let ua = planeAxes[0], va = planeAxes[1]
|
|
||||||
let umin = [xs, ys, zs][ua].min()!, urange = max(1e-6, ext[ua])
|
|
||||||
let vmin = [xs, ys, zs][va].min()!, vrange = max(1e-6, ext[va])
|
|
||||||
// Plain [0,1] planar mapping — the screenshot fills the mesh exactly. Any margin the
|
|
||||||
// display needs is baked into the image (see `matted`), not added here, so the clamped
|
|
||||||
// edge stays black instead of smearing the capture's corner pixels.
|
|
||||||
let uvs: [CGPoint] = pos.map {
|
|
||||||
CGPoint(x: CGFloat((comp($0, ua) - umin) / urange),
|
|
||||||
y: CGFloat(1 - (comp($0, va) - vmin) / vrange)) // flip V for top-left origin
|
|
||||||
}
|
|
||||||
let uvSource = SCNGeometrySource(textureCoordinates: uvs)
|
|
||||||
let newGeo = SCNGeometry(sources: g.sources(for: .vertex) + g.sources(for: .normal) + [uvSource],
|
|
||||||
elements: g.elements)
|
|
||||||
newGeo.materials = g.materials
|
|
||||||
node.geometry = newGeo
|
|
||||||
node.childNodes.forEach { addPlanarUVsToScreen(in: $0, material: name) }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Return the shot centred on a black canvas `pad` larger on each side, so texturing it
|
|
||||||
// leaves a thin black border around the window inside the display's rounded corners.
|
|
||||||
private func matted(_ shot: NSImage, pad: CGFloat) -> NSImage {
|
|
||||||
let s = shot.size
|
|
||||||
let canvas = NSSize(width: s.width * (1 + 2 * pad), height: s.height * (1 + 2 * pad))
|
|
||||||
let out = NSImage(size: canvas)
|
|
||||||
out.lockFocus()
|
|
||||||
NSColor.black.setFill()
|
|
||||||
NSRect(origin: .zero, size: canvas).fill()
|
|
||||||
shot.draw(in: NSRect(x: s.width * pad, y: s.height * pad, width: s.width, height: s.height))
|
|
||||||
out.unlockFocus()
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
private func materials(named name: String, in node: SCNNode) -> [SCNMaterial] {
|
|
||||||
var out = (node.geometry?.materials ?? []).filter {
|
|
||||||
($0.name ?? "").caseInsensitiveCompare(name) == .orderedSame
|
|
||||||
}
|
|
||||||
for c in node.childNodes { out += materials(named: name, in: c) }
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,230 +0,0 @@
|
|||||||
import SwiftUI
|
|
||||||
|
|
||||||
// The full 1284x2778 marketing frame: gradient + globe + device + caption.
|
|
||||||
// Placement mirrors the old screens.typ: images are pinned top-left and pushed by
|
|
||||||
// (dx, dy); captions are centered and pinned to the top or bottom edge.
|
|
||||||
|
|
||||||
struct ScreenFrame: View {
|
|
||||||
let spec: ScreenSpec
|
|
||||||
let caption: Caption
|
|
||||||
let globe: Image?
|
|
||||||
let shot: NSImage?
|
|
||||||
let device: DeviceRenderer?
|
|
||||||
var canvas: CGSize = CGSize(width: 1284, height: 2778)
|
|
||||||
|
|
||||||
private var allDevices: [DeviceSpec] { spec.device.map { [$0] } ?? spec.devices }
|
|
||||||
|
|
||||||
private var titleColor: Color { spec.captionTheme == .light ? .white : Color(hex: "#1b1226") }
|
|
||||||
private var subColor: Color { spec.captionTheme == .light ? Color(hex: "#f3ecfb") : Color(hex: "#2c2138") }
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
let cw = canvas.width, ch = canvas.height
|
|
||||||
|
|
||||||
ZStack(alignment: .topLeading) {
|
|
||||||
spec.bg.gradient
|
|
||||||
|
|
||||||
if let g = spec.globe, let globe {
|
|
||||||
globe.resizable().scaledToFit()
|
|
||||||
.frame(width: g.width)
|
|
||||||
.saturation(0.82)
|
|
||||||
.brightness(-0.06)
|
|
||||||
.offset(x: g.dx, y: g.dy)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Route sits on the globe but behind the phone, so it appears to pass through.
|
|
||||||
if let rt = spec.route { routeLayer(rt) }
|
|
||||||
|
|
||||||
// Encryption flow (behind the phones so beams/stream tuck into them).
|
|
||||||
if let b = spec.beams { beamsLayer(b) }
|
|
||||||
if let s = spec.stream { streamLayer(s) }
|
|
||||||
|
|
||||||
if let device {
|
|
||||||
// The renderer bakes the pose into the 3D scene; we place each phone by
|
|
||||||
// its center on the canvas and add a soft contact shadow.
|
|
||||||
ForEach(Array(allDevices.enumerated()), id: \.offset) { _, d in
|
|
||||||
device.view(shot: shot, spec: d)
|
|
||||||
.shadow(color: d.shadow ? .black.opacity(0.28) : .clear,
|
|
||||||
radius: 60, x: 0, y: 34)
|
|
||||||
.position(x: d.cx, y: d.cy)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Padlock + banner labels sit on top of the flow.
|
|
||||||
if let l = spec.lock { lockLayer(l) }
|
|
||||||
ForEach(Array(spec.banners.enumerated()), id: \.offset) { _, b in
|
|
||||||
bannerLayer(b)
|
|
||||||
}
|
|
||||||
|
|
||||||
captionLayer
|
|
||||||
}
|
|
||||||
// Pin to top-leading: oversized layers (e.g. the globe frame is wider than the
|
|
||||||
// canvas) must be clipped from the origin, NOT re-centered — otherwise the whole
|
|
||||||
// composition, captions included, shifts left by (contentWidth - cw) / 2.
|
|
||||||
.frame(width: cw, height: ch, alignment: .topLeading)
|
|
||||||
.clipped()
|
|
||||||
}
|
|
||||||
|
|
||||||
static func bezier(_ t: Double, _ p0: CGPoint, _ c1: CGPoint, _ c2: CGPoint, _ p1: CGPoint) -> CGPoint {
|
|
||||||
let u = 1 - t
|
|
||||||
let a = u * u * u, b = 3 * u * u * t, c = 3 * u * t * t, d = t * t * t
|
|
||||||
return CGPoint(x: a * p0.x + b * c1.x + c * c2.x + d * p1.x,
|
|
||||||
y: a * p0.y + b * c1.y + c * c2.y + d * p1.y)
|
|
||||||
}
|
|
||||||
static func bezierTangent(_ t: Double, _ p0: CGPoint, _ c1: CGPoint, _ c2: CGPoint, _ p1: CGPoint) -> CGPoint {
|
|
||||||
let u = 1 - t
|
|
||||||
let a = 3 * u * u, b = 6 * u * t, c = 3 * t * t
|
|
||||||
return CGPoint(x: a * (c1.x - p0.x) + b * (c2.x - c1.x) + c * (p1.x - c2.x),
|
|
||||||
y: a * (c1.y - p0.y) + b * (c2.y - c1.y) + c * (p1.y - c2.y))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Transfer route: a glowing arc from the departure city to the arrival city, with a
|
|
||||||
// pulsing marker at each end. Drawn on the globe, behind the phone.
|
|
||||||
private func routeLayer(_ r: RouteSpec) -> some View {
|
|
||||||
let color = Color(hex: r.color)
|
|
||||||
let path = Path { p in
|
|
||||||
p.move(to: r.from)
|
|
||||||
p.addCurve(to: r.to, control1: r.c1, control2: r.c2)
|
|
||||||
}
|
|
||||||
return ZStack {
|
|
||||||
path.stroke(color.opacity(0.55), style: StrokeStyle(lineWidth: r.lineWidth * 2.6, lineCap: .round))
|
|
||||||
.blur(radius: 22)
|
|
||||||
path.stroke(
|
|
||||||
LinearGradient(colors: [Color(hex: "#c98bff"), Color(hex: "#7c3aed"), Color(hex: "#c98bff")],
|
|
||||||
startPoint: .topTrailing, endPoint: .bottomLeading),
|
|
||||||
style: StrokeStyle(lineWidth: r.lineWidth, lineCap: .round))
|
|
||||||
path.stroke(.white.opacity(0.85), style: StrokeStyle(lineWidth: r.lineWidth * 0.35, lineCap: .round))
|
|
||||||
.blur(radius: 1)
|
|
||||||
marker(at: r.from, color)
|
|
||||||
marker(at: r.to, color)
|
|
||||||
}
|
|
||||||
.frame(width: canvas.width, height: canvas.height)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func marker(at pt: CGPoint, _ color: Color) -> some View {
|
|
||||||
ZStack {
|
|
||||||
Circle().fill(color.opacity(0.5)).frame(width: 60, height: 60).blur(radius: 14)
|
|
||||||
Circle().fill(color).frame(width: 30, height: 30)
|
|
||||||
Circle().fill(.white).frame(width: 14, height: 14)
|
|
||||||
}
|
|
||||||
.position(pt)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Converging encryption beams: thin glowing lines fanning from the top phone's edge
|
|
||||||
// down to a single convergence point (above the lock).
|
|
||||||
private func beamsLayer(_ b: BeamsSpec) -> some View {
|
|
||||||
let color = Color(hex: b.color)
|
|
||||||
return Canvas { ctx, _ in
|
|
||||||
for i in 0..<b.count {
|
|
||||||
let f = b.count == 1 ? 0 : Double(i) / Double(b.count - 1) - 0.5 // -0.5…0.5
|
|
||||||
// Fan across the cross axis from the start line, converging on (cx, cy).
|
|
||||||
let start = b.horizontal
|
|
||||||
? CGPoint(x: b.y0, y: b.cy + CGFloat(f) * 2 * b.spread)
|
|
||||||
: CGPoint(x: b.cx + CGFloat(f) * 2 * b.spread, y: b.y0)
|
|
||||||
var path = Path()
|
|
||||||
path.move(to: start)
|
|
||||||
path.addLine(to: CGPoint(x: b.cx, y: b.cy))
|
|
||||||
let op = 0.25 + 0.35 * (1 - abs(f) * 2) // brighter toward the centre beam
|
|
||||||
ctx.stroke(path, with: .color(color.opacity(op)),
|
|
||||||
style: StrokeStyle(lineWidth: 2.2, lineCap: .round))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.frame(width: canvas.width, height: canvas.height)
|
|
||||||
.blur(radius: 0.6)
|
|
||||||
.shadow(color: color.opacity(0.7), radius: 10)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Vertical binary "protection" stream from the lock down to the receiving phone.
|
|
||||||
private func streamLayer(_ s: StreamSpec) -> some View {
|
|
||||||
let color = Color(hex: s.color)
|
|
||||||
return Canvas { ctx, _ in
|
|
||||||
let spacing = 44.0
|
|
||||||
let count = max(1, Int((s.y1 - s.y0) / spacing))
|
|
||||||
for i in 0...count {
|
|
||||||
let t = Double(i) / Double(count)
|
|
||||||
let along = s.y0 + (s.y1 - s.y0) * t
|
|
||||||
let at = s.horizontal ? CGPoint(x: along, y: s.cx) : CGPoint(x: s.cx, y: along)
|
|
||||||
let bit = (i % 3 == 0) ? "0" : "1"
|
|
||||||
// fade in near the lock and out near the receiving device
|
|
||||||
let op = 0.5 + 0.5 * sin(Double.pi * t)
|
|
||||||
var res = ctx.resolve(Text(bit)
|
|
||||||
.font(.system(size: 30, weight: .semibold, design: .monospaced)))
|
|
||||||
res.shading = .color(color.opacity(op))
|
|
||||||
ctx.draw(res, at: at)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.frame(width: canvas.width, height: canvas.height)
|
|
||||||
.shadow(color: color.opacity(0.6), radius: 8)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Glowing padlock (SF Symbol) — the encryption focal point.
|
|
||||||
private func lockLayer(_ l: LockSpec) -> some View {
|
|
||||||
let color = Color(hex: l.color)
|
|
||||||
return Image(systemName: "lock.fill")
|
|
||||||
.font(.system(size: l.size, weight: .regular))
|
|
||||||
.foregroundStyle(
|
|
||||||
LinearGradient(colors: [.white, color], startPoint: .top, endPoint: .bottom))
|
|
||||||
.shadow(color: color.opacity(0.9), radius: 30)
|
|
||||||
.shadow(color: color.opacity(0.6), radius: 60)
|
|
||||||
.position(x: l.cx, y: l.cy)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Localized banner label (CHIFFREMENT / PROTECTION) from strings.json.
|
|
||||||
private func bannerLayer(_ b: Banner) -> some View {
|
|
||||||
let text = (b.kind == .encryption ? caption.encryption : caption.protection) ?? ""
|
|
||||||
return Text(text)
|
|
||||||
.font(.system(size: 48, weight: .bold))
|
|
||||||
.tracking(3)
|
|
||||||
.foregroundStyle(Color(hex: "#e7dcf7"))
|
|
||||||
.position(x: b.cx, y: b.cy)
|
|
||||||
}
|
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
private func captionBlock(_ titleSize: CGFloat, _ subSize: CGFloat) -> some View {
|
|
||||||
let content = VStack(spacing: 10) {
|
|
||||||
Text(caption.title)
|
|
||||||
.font(.system(size: titleSize, weight: .heavy))
|
|
||||||
.foregroundStyle(titleColor)
|
|
||||||
Text(caption.subtitle)
|
|
||||||
.font(.system(size: subSize, weight: .semibold))
|
|
||||||
.foregroundStyle(subColor)
|
|
||||||
}
|
|
||||||
.multilineTextAlignment(.center)
|
|
||||||
.frame(width: canvas.width - (spec.headerBackdrop ? 300 : 160))
|
|
||||||
|
|
||||||
if spec.headerBackdrop {
|
|
||||||
// The panel is the caption's background, so it grows with the content — long
|
|
||||||
// translations (Russian, etc.) get a taller/wider backdrop automatically.
|
|
||||||
let shape = RoundedRectangle(cornerRadius: 48, style: .continuous)
|
|
||||||
content
|
|
||||||
.padding(.horizontal, 56)
|
|
||||||
.padding(.vertical, 44)
|
|
||||||
.background(shape.fill(Color(hex: "#180a30").opacity(0.82))
|
|
||||||
.overlay(shape.stroke(Color.white.opacity(0.16), lineWidth: 1.5)))
|
|
||||||
.shadow(color: .black.opacity(0.35), radius: 24, y: 10)
|
|
||||||
} else {
|
|
||||||
content
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var captionLayer: some View {
|
|
||||||
let top = spec.captionPlace == .top
|
|
||||||
// Keep the size for short captions, but shrink long translations (e.g. Russian /
|
|
||||||
// Polish / Portuguese wrap both lines) so the taller block still fits its band and
|
|
||||||
// never overlaps the phone. ViewThatFits picks the largest variant that fits.
|
|
||||||
// The top band is shorter than the bottom one because those screens' phones are
|
|
||||||
// large and start high, leaving less room above them.
|
|
||||||
let region: CGFloat = top ? (spec.headerBackdrop ? 560 : 290) : 360
|
|
||||||
let fitted = ViewThatFits(in: .vertical) {
|
|
||||||
captionBlock(104, 60)
|
|
||||||
captionBlock(92, 54)
|
|
||||||
captionBlock(82, 48)
|
|
||||||
captionBlock(72, 44)
|
|
||||||
}
|
|
||||||
.frame(width: canvas.width, height: region, alignment: top ? .top : .bottom)
|
|
||||||
|
|
||||||
return fitted
|
|
||||||
.padding(top ? .top : .bottom, top ? 96 : 110)
|
|
||||||
.frame(width: canvas.width, height: canvas.height,
|
|
||||||
alignment: top ? .top : .bottom)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,302 +0,0 @@
|
|||||||
import SwiftUI
|
|
||||||
|
|
||||||
// Layout data for each marketing screen. Numbers are in pixels (the canvas renders
|
|
||||||
// at scale 1, so a value of 104 == 104px). Ported from the old screens.typ `screens`
|
|
||||||
// dict — tune these against the originals.
|
|
||||||
|
|
||||||
extension Color {
|
|
||||||
// "#rrggbb"
|
|
||||||
init(hex: String) {
|
|
||||||
let s = hex.trimmingCharacters(in: CharacterSet(charactersIn: "#"))
|
|
||||||
let v = UInt64(s, radix: 16) ?? 0
|
|
||||||
self.init(
|
|
||||||
.sRGB,
|
|
||||||
red: Double((v >> 16) & 0xff) / 255,
|
|
||||||
green: Double((v >> 8) & 0xff) / 255,
|
|
||||||
blue: Double(v & 0xff) / 255,
|
|
||||||
opacity: 1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
enum CaptionPlace { case top, bottom }
|
|
||||||
enum CaptionTheme { case dark, light }
|
|
||||||
|
|
||||||
struct GradientSpec {
|
|
||||||
let stops: [String] // hex colors, top→bottom / start→end
|
|
||||||
let start: UnitPoint
|
|
||||||
let end: UnitPoint
|
|
||||||
|
|
||||||
var gradient: LinearGradient {
|
|
||||||
LinearGradient(colors: stops.map { Color(hex: $0) }, startPoint: start, endPoint: end)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// A 3D device layer. The screenshot is textured onto the model's screen and the phone
|
|
||||||
// is posed in real 3D (pitch/yaw/roll), positioned by its center on the canvas and
|
|
||||||
// sized by its upright height — matching how the originals were laid out.
|
|
||||||
struct Pose {
|
|
||||||
var pitch: CGFloat = 0 // deg, about X (top tips away/toward viewer)
|
|
||||||
var yaw: CGFloat = 0 // deg, about Y (turn left/right, reveals a side edge)
|
|
||||||
var roll: CGFloat = 0 // deg, about Z (in-plane spin)
|
|
||||||
}
|
|
||||||
|
|
||||||
struct DeviceSpec {
|
|
||||||
var height: CGFloat // upright phone height in canvas px (drives scale)
|
|
||||||
var cx: CGFloat // phone center X on the 1284-wide canvas
|
|
||||||
var cy: CGFloat // phone center Y on the 2778-tall canvas
|
|
||||||
var pose: Pose = Pose()
|
|
||||||
var shadow: Bool = true // soft contact shadow under the phone
|
|
||||||
var blackScreen: Bool = false // texture the screen solid black (ignore the shot)
|
|
||||||
}
|
|
||||||
|
|
||||||
struct GlobeSpec {
|
|
||||||
var width: CGFloat
|
|
||||||
var dx: CGFloat
|
|
||||||
var dy: CGFloat
|
|
||||||
}
|
|
||||||
|
|
||||||
// A transfer route: two city markers (departure + arrival) on the globe joined by a
|
|
||||||
// glowing arc that swoops down and passes behind the phone — "send from Paris to LA,
|
|
||||||
// through your phone". Generated natively.
|
|
||||||
struct RouteSpec {
|
|
||||||
var from: CGPoint // departure marker (on the globe)
|
|
||||||
var to: CGPoint // arrival marker (on the globe)
|
|
||||||
var c1: CGPoint // Bézier controls — pull down to bulge the arc through the phone
|
|
||||||
var c2: CGPoint
|
|
||||||
var lineWidth: CGFloat = 12
|
|
||||||
var color: String = "#a855f7"
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stay-private redesign: a vertical encryption flow between two stacked phones —
|
|
||||||
// converging light beams (encryption) → a glowing padlock → a binary stream (protection).
|
|
||||||
struct BeamsSpec {
|
|
||||||
var cx: CGFloat // convergence x
|
|
||||||
var y0: CGFloat // start-line coordinate: the y the beams fan from (x when horizontal)
|
|
||||||
var spread: CGFloat // half-extent the beams fan across at the start line
|
|
||||||
var cy: CGFloat // converge to this point
|
|
||||||
var count: Int = 22
|
|
||||||
var color: String = "#9ec3ff"
|
|
||||||
// Horizontal: beams fan across y from a vertical start line and converge left→right
|
|
||||||
// (for side-by-side devices) instead of fanning across x from a horizontal line.
|
|
||||||
var horizontal: Bool = false
|
|
||||||
}
|
|
||||||
|
|
||||||
struct LockSpec {
|
|
||||||
var cx: CGFloat
|
|
||||||
var cy: CGFloat
|
|
||||||
var size: CGFloat
|
|
||||||
var color: String = "#a9c9ff"
|
|
||||||
}
|
|
||||||
|
|
||||||
struct StreamSpec {
|
|
||||||
var cx: CGFloat // cross-axis centre (x for a vertical stream, y for a horizontal one)
|
|
||||||
var y0: CGFloat // along-axis start (below the lock; right of the lock when horizontal)
|
|
||||||
var y1: CGFloat // along-axis end (the receiving device)
|
|
||||||
var color: String = "#9ec3ff"
|
|
||||||
var horizontal: Bool = false // flow left→right instead of top→bottom
|
|
||||||
}
|
|
||||||
|
|
||||||
// Localized banner labels (CHIFFREMENT / PROTECTION), text taken from strings.json.
|
|
||||||
enum BannerKind { case encryption, protection }
|
|
||||||
struct Banner {
|
|
||||||
var kind: BannerKind
|
|
||||||
var cx: CGFloat
|
|
||||||
var cy: CGFloat
|
|
||||||
}
|
|
||||||
|
|
||||||
struct ScreenSpec {
|
|
||||||
let id: String
|
|
||||||
let bg: GradientSpec
|
|
||||||
let captionPlace: CaptionPlace
|
|
||||||
let captionTheme: CaptionTheme
|
|
||||||
var globe: GlobeSpec? = nil
|
|
||||||
var route: RouteSpec? = nil
|
|
||||||
var device: DeviceSpec? = nil
|
|
||||||
var devices: [DeviceSpec] = [] // multiple phones (e.g. stay-private)
|
|
||||||
var beams: BeamsSpec? = nil
|
|
||||||
var lock: LockSpec? = nil
|
|
||||||
var stream: StreamSpec? = nil
|
|
||||||
var banners: [Banner] = []
|
|
||||||
var headerBackdrop: Bool = false // dark scrim behind the top caption
|
|
||||||
// Which captured screenshot to texture (defaults to `id`). The hero reuses the
|
|
||||||
// approval modal shot, matching the original.
|
|
||||||
var shotId: String? = nil
|
|
||||||
|
|
||||||
static func all(for platform: Platform) -> [String: ScreenSpec] {
|
|
||||||
switch platform {
|
|
||||||
case .iphone: iphone
|
|
||||||
case .ipad: ipad
|
|
||||||
case .mac: mac
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static let iphone: [String: ScreenSpec] = [
|
|
||||||
// Straight-on hero, large and centered, showing the Share/QR sheet.
|
|
||||||
"share-securely": ScreenSpec(
|
|
||||||
id: "share-securely",
|
|
||||||
bg: GradientSpec(stops: ["#f3edfc", "#e7dbf7"], start: .top, end: .bottom),
|
|
||||||
captionPlace: .top, captionTheme: .dark,
|
|
||||||
device: DeviceSpec(
|
|
||||||
height: 2300, cx: 642, cy: 1560,
|
|
||||||
pose: Pose(pitch: 0, yaw: 0, roll: 0))
|
|
||||||
),
|
|
||||||
// Tilted hero: turned to reveal the right edge, top sloping down-right.
|
|
||||||
"choose-receivers": ScreenSpec(
|
|
||||||
id: "choose-receivers",
|
|
||||||
bg: GradientSpec(stops: ["#f2ecfb", "#e6d9f6"], start: .top, end: .bottom),
|
|
||||||
captionPlace: .top, captionTheme: .dark,
|
|
||||||
device: DeviceSpec(
|
|
||||||
height: 2160, cx: 620, cy: 1620,
|
|
||||||
pose: Pose(pitch: -9, yaw: -14, roll: 7))
|
|
||||||
),
|
|
||||||
// Strongly tilted, lying diagonally in the lower-left; globe + orbit above.
|
|
||||||
"send-anywhere": ScreenSpec(
|
|
||||||
id: "send-anywhere",
|
|
||||||
bg: GradientSpec(stops: ["#e9ddf9", "#e5d6f6"], start: .top, end: .bottom),
|
|
||||||
captionPlace: .bottom, captionTheme: .dark,
|
|
||||||
globe: GlobeSpec(width: 1520, dx: -10, dy: -200),
|
|
||||||
route: RouteSpec(
|
|
||||||
from: CGPoint(x: 1284 - 289, y: 226), // departure — Paris (Europe, right of globe)
|
|
||||||
to: CGPoint(x: 211, y: 296), // arrival — Los Angeles (upper-left of globe)
|
|
||||||
c1: CGPoint(x: 1500, y: 1980), // pull the arc down through the phone
|
|
||||||
c2: CGPoint(x: -300, y: 1980),
|
|
||||||
lineWidth: 12),
|
|
||||||
device: DeviceSpec(
|
|
||||||
height: 1550, cx: 581, cy: 1600,
|
|
||||||
pose: Pose(pitch: 35, yaw: 35, roll: 15)),
|
|
||||||
shotId: "share-securely"
|
|
||||||
),
|
|
||||||
// Two partial phones with black screens + a generated binary data ribbon.
|
|
||||||
// Vertical encryption flow: two stacked centred phones, converging beams into a
|
|
||||||
// glowing padlock, then a binary "protection" stream down to the receiver.
|
|
||||||
"stay-private": ScreenSpec(
|
|
||||||
id: "stay-private",
|
|
||||||
bg: GradientSpec(
|
|
||||||
stops: ["#241047", "#3a1e6b", "#7a5aa8", "#c9b6e6"],
|
|
||||||
start: .top, end: .bottom),
|
|
||||||
captionPlace: .top, captionTheme: .light,
|
|
||||||
devices: [
|
|
||||||
DeviceSpec(
|
|
||||||
height: 1500, cx: 642, cy: -20, // top phone, only its lower part shows
|
|
||||||
pose: Pose(roll: 180), shadow: false, blackScreen: true),
|
|
||||||
DeviceSpec(
|
|
||||||
height: 1500, cx: 642, cy: 2820, // bottom phone, only its upper part shows
|
|
||||||
pose: Pose(), shadow: false, blackScreen: true),
|
|
||||||
],
|
|
||||||
beams: BeamsSpec(cx: 642, y0: 700, spread: 150, cy: 1120, count: 22),
|
|
||||||
lock: LockSpec(cx: 642, cy: 1330, size: 300),
|
|
||||||
stream: StreamSpec(cx: 642, y0: 1520, y1: 2360),
|
|
||||||
banners: [
|
|
||||||
Banner(kind: .encryption, cx: 642, cy: 1120),
|
|
||||||
Banner(kind: .protection, cx: 642, cy: 1620),
|
|
||||||
],
|
|
||||||
headerBackdrop: true
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|
||||||
// iPad Pro layouts (canvas 2048x2732, centre x = 1024). First pass — same
|
|
||||||
// compositions as iPhone, retuned for the squarer, larger canvas.
|
|
||||||
static let ipad: [String: ScreenSpec] = [
|
|
||||||
"share-securely": ScreenSpec(
|
|
||||||
id: "share-securely",
|
|
||||||
bg: GradientSpec(stops: ["#f3edfc", "#e7dbf7"], start: .top, end: .bottom),
|
|
||||||
captionPlace: .top, captionTheme: .dark,
|
|
||||||
device: DeviceSpec(height: 1950, cx: 1024, cy: 1520, pose: Pose())
|
|
||||||
),
|
|
||||||
"choose-receivers": ScreenSpec(
|
|
||||||
id: "choose-receivers",
|
|
||||||
bg: GradientSpec(stops: ["#f2ecfb", "#e6d9f6"], start: .top, end: .bottom),
|
|
||||||
captionPlace: .top, captionTheme: .dark,
|
|
||||||
device: DeviceSpec(
|
|
||||||
height: 1950, cx: 1000, cy: 1460, pose: Pose(pitch: -9, yaw: -14, roll: 7))
|
|
||||||
),
|
|
||||||
"send-anywhere": ScreenSpec(
|
|
||||||
id: "send-anywhere",
|
|
||||||
bg: GradientSpec(stops: ["#e9ddf9", "#e5d6f6"], start: .top, end: .bottom),
|
|
||||||
captionPlace: .bottom, captionTheme: .dark,
|
|
||||||
globe: GlobeSpec(width: 2000, dx: 24, dy: -240),
|
|
||||||
route: RouteSpec(
|
|
||||||
from: CGPoint(x: 1342, y: 323), to: CGPoint(x: 315, y: 423),
|
|
||||||
c1: CGPoint(x: 2100, y: 2000), c2: CGPoint(x: -100, y: 2000), lineWidth: 14),
|
|
||||||
device: DeviceSpec(
|
|
||||||
height: 1180, cx: 1000, cy: 1720, pose: Pose(pitch: 12, yaw: 18, roll: -11)),
|
|
||||||
shotId: "share-securely"
|
|
||||||
),
|
|
||||||
"stay-private": ScreenSpec(
|
|
||||||
id: "stay-private",
|
|
||||||
bg: GradientSpec(
|
|
||||||
stops: ["#241047", "#3a1e6b", "#7a5aa8", "#c9b6e6"], start: .top, end: .bottom),
|
|
||||||
captionPlace: .top, captionTheme: .light,
|
|
||||||
devices: [
|
|
||||||
DeviceSpec(
|
|
||||||
height: 1300, cx: 1024, cy: 120, pose: Pose(roll: 180), shadow: false,
|
|
||||||
blackScreen: true),
|
|
||||||
DeviceSpec(
|
|
||||||
height: 1300, cx: 1024, cy: 2760, pose: Pose(), shadow: false, blackScreen: true
|
|
||||||
),
|
|
||||||
],
|
|
||||||
beams: BeamsSpec(cx: 1024, y0: 720, spread: 200, cy: 1220, count: 24),
|
|
||||||
lock: LockSpec(cx: 1024, cy: 1400, size: 320),
|
|
||||||
stream: StreamSpec(cx: 1024, y0: 1600, y1: 2360),
|
|
||||||
banners: [
|
|
||||||
Banner(kind: .encryption, cx: 1024, cy: 1200),
|
|
||||||
Banner(kind: .protection, cx: 1024, cy: 1680),
|
|
||||||
],
|
|
||||||
headerBackdrop: true
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|
||||||
// MacBook layouts (canvas 2880x1800, landscape, centre x = 1440). First pass.
|
|
||||||
static let mac: [String: ScreenSpec] = [
|
|
||||||
"share-securely": ScreenSpec(
|
|
||||||
id: "share-securely",
|
|
||||||
bg: GradientSpec(stops: ["#f3edfc", "#e7dbf7"], start: .top, end: .bottom),
|
|
||||||
captionPlace: .top, captionTheme: .dark,
|
|
||||||
device: DeviceSpec(height: 1500, cx: 1440, cy: 1110, pose: Pose())
|
|
||||||
),
|
|
||||||
"choose-receivers": ScreenSpec(
|
|
||||||
id: "choose-receivers",
|
|
||||||
bg: GradientSpec(stops: ["#f2ecfb", "#e6d9f6"], start: .top, end: .bottom),
|
|
||||||
captionPlace: .top, captionTheme: .dark,
|
|
||||||
device: DeviceSpec(height: 1500, cx: 1440, cy: 1110, pose: Pose())
|
|
||||||
),
|
|
||||||
// Globe backdrop in the upper half, route arcing through a centred laptop, caption
|
|
||||||
// at the bottom. Reuses the share capture (matches the other platforms).
|
|
||||||
"send-anywhere": ScreenSpec(
|
|
||||||
id: "send-anywhere",
|
|
||||||
bg: GradientSpec(stops: ["#e9ddf9", "#e5d6f6"], start: .top, end: .bottom),
|
|
||||||
captionPlace: .bottom, captionTheme: .light,
|
|
||||||
globe: GlobeSpec(width: 1320, dx: 780, dy: -150),
|
|
||||||
route: RouteSpec(
|
|
||||||
from: CGPoint(x: 1650, y: 222), to: CGPoint(x: 976, y: 274),
|
|
||||||
c1: CGPoint(x: 5200, y: 1150), c2: CGPoint(x: -2000, y: 1150), lineWidth: 13),
|
|
||||||
device: DeviceSpec(height: 1300, cx: 1440, cy: 960, pose: Pose()),
|
|
||||||
headerBackdrop: true,
|
|
||||||
shotId: "share-securely"
|
|
||||||
),
|
|
||||||
// Vertical encryption flow between two stacked laptops (same structure as the phone
|
|
||||||
// layouts, retuned for the landscape canvas).
|
|
||||||
"stay-private": ScreenSpec(
|
|
||||||
id: "stay-private",
|
|
||||||
bg: GradientSpec(
|
|
||||||
stops: ["#241047", "#3a1e6b", "#7a5aa8", "#c9b6e6"], start: .top, end: .bottom),
|
|
||||||
captionPlace: .top, captionTheme: .light,
|
|
||||||
// Two laptops on the left and right; encryption flows horizontally between them:
|
|
||||||
// beams converge left→lock, a binary stream runs lock→right.
|
|
||||||
devices: [
|
|
||||||
DeviceSpec(
|
|
||||||
height: 1040, cx: 120, cy: 1000, pose: Pose(), shadow: false, blackScreen: true),
|
|
||||||
DeviceSpec(
|
|
||||||
height: 1040, cx: 2760, cy: 1000, pose: Pose(), shadow: false, blackScreen: true
|
|
||||||
),
|
|
||||||
],
|
|
||||||
beams: BeamsSpec(cx: 1290, y0: 760, spread: 300, cy: 1000, count: 26, horizontal: true),
|
|
||||||
lock: LockSpec(cx: 1440, cy: 1000, size: 300),
|
|
||||||
stream: StreamSpec(cx: 1000, y0: 1600, y1: 2140, horizontal: true),
|
|
||||||
banners: [
|
|
||||||
Banner(kind: .encryption, cx: 940, cy: 1360),
|
|
||||||
Banner(kind: .protection, cx: 1960, cy: 1360),
|
|
||||||
]
|
|
||||||
),
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
|
|
||||||
// Mirrors strings.json. Captions live only here (not app strings).
|
|
||||||
struct Caption: Decodable {
|
|
||||||
let title: String
|
|
||||||
let subtitle: String
|
|
||||||
// Optional in-composition banner labels (stay-private): CHIFFREMENT / PROTECTION.
|
|
||||||
let encryption: String?
|
|
||||||
let protection: String?
|
|
||||||
}
|
|
||||||
|
|
||||||
struct LocaleStrings: Decodable {
|
|
||||||
let folder: String
|
|
||||||
private let screens: [String: Caption]
|
|
||||||
|
|
||||||
subscript(_ screen: String) -> Caption? { screens[screen] }
|
|
||||||
|
|
||||||
private enum CodingKeys: String, CodingKey { case folder = "_folder" }
|
|
||||||
|
|
||||||
init(from decoder: Decoder) throws {
|
|
||||||
// `_folder` is a reserved key; every other key is a screen id -> Caption.
|
|
||||||
let dyn = try decoder.container(keyedBy: DynamicKey.self)
|
|
||||||
var acc: [String: Caption] = [:]
|
|
||||||
var folderName = ""
|
|
||||||
for key in dyn.allKeys {
|
|
||||||
if key.stringValue == "_folder" {
|
|
||||||
folderName = try dyn.decode(String.self, forKey: key)
|
|
||||||
} else if let cap = try? dyn.decode(Caption.self, forKey: key) {
|
|
||||||
acc[key.stringValue] = cap
|
|
||||||
}
|
|
||||||
}
|
|
||||||
self.folder = folderName
|
|
||||||
self.screens = acc
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct DynamicKey: CodingKey {
|
|
||||||
var stringValue: String
|
|
||||||
var intValue: Int? { nil }
|
|
||||||
init?(stringValue: String) { self.stringValue = stringValue }
|
|
||||||
init?(intValue: Int) { nil }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct Strings: Decodable {
|
|
||||||
let screens: [String]
|
|
||||||
let locales: [String: LocaleStrings]
|
|
||||||
|
|
||||||
static func load(_ url: URL) throws -> Strings {
|
|
||||||
try JSONDecoder().decode(Strings.self, from: Data(contentsOf: url))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,111 +0,0 @@
|
|||||||
import AppKit
|
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
// Code-driven App Store screenshots, rendered natively with SwiftUI + ImageRenderer.
|
|
||||||
//
|
|
||||||
// swift run studio # -> generated/<Language>/<Name>.png
|
|
||||||
// swift run studio --publish # -> ../<Language>/<Name>.png (ships)
|
|
||||||
// LOCALES="fr de" SCREENS="share-securely" swift run studio
|
|
||||||
//
|
|
||||||
// Screenshots come from generated/shots/<platform>/<locale>/<screen>.png (from
|
|
||||||
// ./capture.sh) — transient build output, regenerated per run, never committed. The 3D
|
|
||||||
// device models + globe come from assets/. Run from the studio directory.
|
|
||||||
|
|
||||||
// Screen id -> output file basename (matches the existing App Store filenames).
|
|
||||||
let nameFor: [String: String] = [
|
|
||||||
"choose-receivers": "Choose Receivers", "send-anywhere": "Send Anywhere",
|
|
||||||
"share-securely": "Share Securely", "stay-private": "Stay private",
|
|
||||||
]
|
|
||||||
|
|
||||||
func env(_ key: String, _ fallback: String) -> String {
|
|
||||||
let v = ProcessInfo.processInfo.environment[key]
|
|
||||||
return (v?.isEmpty == false) ? v! : fallback
|
|
||||||
}
|
|
||||||
|
|
||||||
func loadNSImage(_ path: String) -> (NSImage, CGSize)? {
|
|
||||||
guard let ns = NSImage(contentsOfFile: path) else { return nil }
|
|
||||||
let size = ns.representations.first.map { CGSize(width: $0.pixelsWide, height: $0.pixelsHigh) } ?? ns.size
|
|
||||||
return (ns, size)
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
func writePNG(_ view: some View, to url: URL) throws {
|
|
||||||
let renderer = ImageRenderer(content: view)
|
|
||||||
renderer.scale = 1
|
|
||||||
guard let cg = renderer.cgImage else {
|
|
||||||
throw NSError(domain: "studio", code: 1, userInfo: [NSLocalizedDescriptionKey: "render failed"])
|
|
||||||
}
|
|
||||||
let rep = NSBitmapImageRep(cgImage: cg)
|
|
||||||
rep.size = NSSize(width: cg.width, height: cg.height)
|
|
||||||
guard let data = rep.representation(using: .png, properties: [:]) else {
|
|
||||||
throw NSError(domain: "studio", code: 2, userInfo: [NSLocalizedDescriptionKey: "png encode failed"])
|
|
||||||
}
|
|
||||||
try FileManager.default.createDirectory(at: url.deletingLastPathComponent(),
|
|
||||||
withIntermediateDirectories: true)
|
|
||||||
try data.write(to: url)
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
func run() throws {
|
|
||||||
let args = CommandLine.arguments
|
|
||||||
let publish = args.contains("--publish")
|
|
||||||
let cwd = FileManager.default.currentDirectoryPath
|
|
||||||
let base = URL(fileURLWithPath: cwd)
|
|
||||||
|
|
||||||
let strings = try Strings.load(base.appendingPathComponent("strings.json"))
|
|
||||||
|
|
||||||
let locales = env("LOCALES", "en fr de es it nl pl pt ru").split(separator: " ").map(String.init)
|
|
||||||
let screens = env("SCREENS", "choose-receivers send-anywhere share-securely stay-private")
|
|
||||||
.split(separator: " ").map(String.init)
|
|
||||||
|
|
||||||
let globe = loadNSImage(base.appendingPathComponent("assets/globe.png").path).map { Image(nsImage: $0.0) }
|
|
||||||
|
|
||||||
// Target platform: canvas size + 3D device model. PLATFORM=iphone|ipad (default iphone).
|
|
||||||
let platform = Platform(rawValue: env("PLATFORM", "iphone")) ?? .iphone
|
|
||||||
let canvas = platform.canvas
|
|
||||||
let model = platform.deviceModel(assets: base.appendingPathComponent("assets"))
|
|
||||||
let device: DeviceRenderer? = FileManager.default.fileExists(atPath: model.url.path)
|
|
||||||
? SceneKitDeviceRenderer(model: model) : nil
|
|
||||||
if device == nil {
|
|
||||||
FileHandle.standardError.write(Data("warning: missing model \(model.url.lastPathComponent) — devices will be blank\n".utf8))
|
|
||||||
}
|
|
||||||
let specs = ScreenSpec.all(for: platform)
|
|
||||||
|
|
||||||
var count = 0
|
|
||||||
for loc in locales {
|
|
||||||
guard let ls = strings.locales[loc] else {
|
|
||||||
FileHandle.standardError.write(Data("warning: no strings for locale \(loc)\n".utf8)); continue
|
|
||||||
}
|
|
||||||
// iPhone -> <Language>/, iPad -> <Language>/iPad/ so the sets stay separate.
|
|
||||||
var outDir = publish
|
|
||||||
? base.appendingPathComponent("../\(ls.folder)").standardized
|
|
||||||
: base.appendingPathComponent("generated/\(ls.folder)")
|
|
||||||
if !platform.outputSubfolder.isEmpty {
|
|
||||||
outDir = outDir.appendingPathComponent(platform.outputSubfolder)
|
|
||||||
}
|
|
||||||
|
|
||||||
for scr in screens {
|
|
||||||
guard let spec = specs[scr], let caption = ls[scr] else {
|
|
||||||
FileHandle.standardError.write(Data("warning: skipping \(loc)/\(scr)\n".utf8)); continue
|
|
||||||
}
|
|
||||||
|
|
||||||
let shotId = spec.shotId ?? scr
|
|
||||||
let shotsDir = env("SHOTS_DIR", "generated/shots/\(platform.rawValue)")
|
|
||||||
let shot = loadNSImage(base.appendingPathComponent("\(shotsDir)/\(loc)/\(shotId).png").path)?.0
|
|
||||||
let hasDevice = spec.device != nil || !spec.devices.isEmpty
|
|
||||||
let frame = ScreenFrame(spec: spec, caption: caption, globe: globe, shot: shot,
|
|
||||||
device: hasDevice ? device : nil, canvas: canvas)
|
|
||||||
let out = outDir.appendingPathComponent("\(nameFor[scr] ?? scr).png")
|
|
||||||
try writePNG(frame, to: out)
|
|
||||||
print(" ✅ \(out.path)")
|
|
||||||
count += 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
print("\nDone — \(count) screenshot(s)\(publish ? " (published)" : "").")
|
|
||||||
}
|
|
||||||
|
|
||||||
// ImageRenderer needs an AppKit context for text/font resolution.
|
|
||||||
let app = NSApplication.shared
|
|
||||||
app.setActivationPolicy(.prohibited)
|
|
||||||
do { try MainActor.assumeIsolated { try run() } }
|
|
||||||
catch { FileHandle.standardError.write(Data("error: \(error.localizedDescription)\n".utf8)); exit(1) }
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
# Third-party asset attributions
|
|
||||||
|
|
||||||
## iphone-17-pro-max.usdz
|
|
||||||
- **Title:** iphone 17 pro max silver
|
|
||||||
- **Author:** TechFreak (Sketchfab)
|
|
||||||
- **Source:** https://sketchfab.com/3d-models/iphone-17-pro-max-silver-9dbfe0d846f341bf9fc501a854f5de1a
|
|
||||||
- **License:** Creative Commons Attribution 4.0 (CC BY 4.0) — https://creativecommons.org/licenses/by/4.0/
|
|
||||||
|
|
||||||
## ipad-pro.usdz
|
|
||||||
- **Title:** 2018 Apple iPad Pro
|
|
||||||
- **Author:** lazercar (Sketchfab)
|
|
||||||
- **License:** Creative Commons Attribution 4.0 (CC BY 4.0) — https://creativecommons.org/licenses/by/4.0/
|
|
||||||
|
|
||||||
## macbook-air.usdz
|
|
||||||
- **Title:** MacBook Air
|
|
||||||
- **Author:** Jakob (Sketchfab — https://sketchfab.com/jakob3dlindblom)
|
|
||||||
- **License:** Creative Commons Attribution 4.0 (CC BY 4.0) — https://creativecommons.org/licenses/by/4.0/
|
|
||||||
|
|
||||||
CC BY 4.0 requires visible credit wherever these models (or renders derived from them)
|
|
||||||
are published. The App Store screenshots produced by this studio are derivatives, so
|
|
||||||
keep this attribution with the project. If a suitable place exists (e.g. the app's
|
|
||||||
acknowledgements / licenses screen), surface the credit there as well.
|
|
||||||
BIN
packaging/apple/studio/assets/mask-rotated.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
packaging/apple/studio/assets/mask-straight.png
Normal file
|
After Width: | Height: | Size: 6.1 KiB |
BIN
packaging/apple/studio/assets/mockup-rotated.png
Normal file
|
After Width: | Height: | Size: 2.7 MiB |
BIN
packaging/apple/studio/assets/mockup-straight.png
Normal file
|
After Width: | Height: | Size: 256 KiB |
|
After Width: | Height: | Size: 477 KiB |
BIN
packaging/apple/studio/assets/shots/en/choose-receivers.png
Normal file
|
After Width: | Height: | Size: 316 KiB |
BIN
packaging/apple/studio/assets/shots/en/send-anywhere.device.png
Normal file
|
After Width: | Height: | Size: 1.2 MiB |