update
This commit is contained in:
Generated
+298
-8
@@ -64,6 +64,15 @@ version = "1.0.104"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
|
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "arbitrary"
|
||||||
|
version = "1.4.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1"
|
||||||
|
dependencies = [
|
||||||
|
"derive_arbitrary",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "async-broadcast"
|
name = "async-broadcast"
|
||||||
version = "0.7.2"
|
version = "0.7.2"
|
||||||
@@ -546,6 +555,16 @@ dependencies = [
|
|||||||
"version_check",
|
"version_check",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "core-foundation"
|
||||||
|
version = "0.9.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f"
|
||||||
|
dependencies = [
|
||||||
|
"core-foundation-sys",
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "core-foundation"
|
name = "core-foundation"
|
||||||
version = "0.10.1"
|
version = "0.10.1"
|
||||||
@@ -569,7 +588,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97"
|
checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.13.2",
|
"bitflags 2.13.2",
|
||||||
"core-foundation",
|
"core-foundation 0.10.1",
|
||||||
"core-graphics-types",
|
"core-graphics-types",
|
||||||
"foreign-types",
|
"foreign-types",
|
||||||
"libc",
|
"libc",
|
||||||
@@ -582,7 +601,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb"
|
checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.13.2",
|
"bitflags 2.13.2",
|
||||||
"core-foundation",
|
"core-foundation 0.10.1",
|
||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -799,6 +818,17 @@ dependencies = [
|
|||||||
"serde_core",
|
"serde_core",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "derive_arbitrary"
|
||||||
|
version = "1.4.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.119",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "derive_more"
|
name = "derive_more"
|
||||||
version = "2.1.1"
|
version = "2.1.1"
|
||||||
@@ -1758,9 +1788,11 @@ dependencies = [
|
|||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"socket2",
|
"socket2",
|
||||||
|
"system-configuration",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tower-service",
|
"tower-service",
|
||||||
"tracing",
|
"tracing",
|
||||||
|
"windows-registry",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2063,6 +2095,36 @@ dependencies = [
|
|||||||
"windows-sys 0.45.0",
|
"windows-sys 0.45.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "jni"
|
||||||
|
version = "0.22.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"combine",
|
||||||
|
"jni-macros",
|
||||||
|
"jni-sys 0.4.1",
|
||||||
|
"log",
|
||||||
|
"simd_cesu8",
|
||||||
|
"thiserror 2.0.20",
|
||||||
|
"walkdir",
|
||||||
|
"windows-link 0.2.1",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "jni-macros"
|
||||||
|
version = "0.22.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"rustc_version",
|
||||||
|
"simd_cesu8",
|
||||||
|
"syn 2.0.119",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "jni-sys"
|
name = "jni-sys"
|
||||||
version = "0.3.1"
|
version = "0.3.1"
|
||||||
@@ -2291,6 +2353,12 @@ version = "0.3.17"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
|
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "minisign-verify"
|
||||||
|
version = "0.2.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "22f9645cb765ea72b8111f36c522475d2daa0d22c957a9826437e97534bc4e9e"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "miniz_oxide"
|
name = "miniz_oxide"
|
||||||
version = "0.8.9"
|
version = "0.8.9"
|
||||||
@@ -2558,6 +2626,18 @@ dependencies = [
|
|||||||
"objc2-core-foundation",
|
"objc2-core-foundation",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "objc2-osa-kit"
|
||||||
|
version = "0.3.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f112d1746737b0da274ef79a23aac283376f335f4095a083a267a082f21db0c0"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 2.13.2",
|
||||||
|
"objc2",
|
||||||
|
"objc2-app-kit",
|
||||||
|
"objc2-foundation",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "objc2-quartz-core"
|
name = "objc2-quartz-core"
|
||||||
version = "0.3.2"
|
version = "0.3.2"
|
||||||
@@ -2631,6 +2711,7 @@ dependencies = [
|
|||||||
"tauri-plugin-dialog",
|
"tauri-plugin-dialog",
|
||||||
"tauri-plugin-log",
|
"tauri-plugin-log",
|
||||||
"tauri-plugin-opener",
|
"tauri-plugin-opener",
|
||||||
|
"tauri-plugin-updater",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-util",
|
"tokio-util",
|
||||||
"url",
|
"url",
|
||||||
@@ -2675,6 +2756,12 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "openssl-probe"
|
||||||
|
version = "0.2.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "option-ext"
|
name = "option-ext"
|
||||||
version = "0.2.0"
|
version = "0.2.0"
|
||||||
@@ -2691,6 +2778,20 @@ dependencies = [
|
|||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "osakit"
|
||||||
|
version = "0.3.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "732c71caeaa72c065bb69d7ea08717bd3f4863a4f451402fc9513e29dbd5261b"
|
||||||
|
dependencies = [
|
||||||
|
"objc2",
|
||||||
|
"objc2-foundation",
|
||||||
|
"objc2-osa-kit",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"thiserror 2.0.20",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pango"
|
name = "pango"
|
||||||
version = "0.18.3"
|
version = "0.18.3"
|
||||||
@@ -3239,15 +3340,20 @@ dependencies = [
|
|||||||
"http-body",
|
"http-body",
|
||||||
"http-body-util",
|
"http-body-util",
|
||||||
"hyper",
|
"hyper",
|
||||||
|
"hyper-rustls",
|
||||||
"hyper-util",
|
"hyper-util",
|
||||||
"js-sys",
|
"js-sys",
|
||||||
"log",
|
"log",
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
|
"rustls",
|
||||||
|
"rustls-pki-types",
|
||||||
|
"rustls-platform-verifier",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sync_wrapper",
|
"sync_wrapper",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
"tokio-rustls",
|
||||||
"tokio-util",
|
"tokio-util",
|
||||||
"tower",
|
"tower",
|
||||||
"tower-http",
|
"tower-http",
|
||||||
@@ -3364,6 +3470,18 @@ dependencies = [
|
|||||||
"zeroize",
|
"zeroize",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustls-native-certs"
|
||||||
|
version = "0.8.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d"
|
||||||
|
dependencies = [
|
||||||
|
"openssl-probe",
|
||||||
|
"rustls-pki-types",
|
||||||
|
"schannel",
|
||||||
|
"security-framework",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustls-pki-types"
|
name = "rustls-pki-types"
|
||||||
version = "1.15.1"
|
version = "1.15.1"
|
||||||
@@ -3374,6 +3492,33 @@ dependencies = [
|
|||||||
"zeroize",
|
"zeroize",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustls-platform-verifier"
|
||||||
|
version = "0.7.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0"
|
||||||
|
dependencies = [
|
||||||
|
"core-foundation 0.10.1",
|
||||||
|
"core-foundation-sys",
|
||||||
|
"jni 0.22.4",
|
||||||
|
"log",
|
||||||
|
"once_cell",
|
||||||
|
"rustls",
|
||||||
|
"rustls-native-certs",
|
||||||
|
"rustls-platform-verifier-android",
|
||||||
|
"rustls-webpki",
|
||||||
|
"security-framework",
|
||||||
|
"security-framework-sys",
|
||||||
|
"webpki-root-certs",
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustls-platform-verifier-android"
|
||||||
|
version = "0.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustls-webpki"
|
name = "rustls-webpki"
|
||||||
version = "0.103.15"
|
version = "0.103.15"
|
||||||
@@ -3406,6 +3551,15 @@ dependencies = [
|
|||||||
"winapi-util",
|
"winapi-util",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "schannel"
|
||||||
|
version = "0.1.29"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939"
|
||||||
|
dependencies = [
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "schemars"
|
name = "schemars"
|
||||||
version = "0.8.22"
|
version = "0.8.22"
|
||||||
@@ -3463,6 +3617,29 @@ version = "1.2.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "security-framework"
|
||||||
|
version = "3.7.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 2.13.2",
|
||||||
|
"core-foundation 0.10.1",
|
||||||
|
"core-foundation-sys",
|
||||||
|
"libc",
|
||||||
|
"security-framework-sys",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "security-framework-sys"
|
||||||
|
version = "2.17.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3"
|
||||||
|
dependencies = [
|
||||||
|
"core-foundation-sys",
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "selectors"
|
name = "selectors"
|
||||||
version = "0.36.1"
|
version = "0.36.1"
|
||||||
@@ -3705,6 +3882,22 @@ version = "0.3.10"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
|
checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "simd_cesu8"
|
||||||
|
version = "1.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520"
|
||||||
|
dependencies = [
|
||||||
|
"rustc_version",
|
||||||
|
"simdutf8",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "simdutf8"
|
||||||
|
version = "0.1.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "siphasher"
|
name = "siphasher"
|
||||||
version = "1.0.3"
|
version = "1.0.3"
|
||||||
@@ -3908,6 +4101,27 @@ dependencies = [
|
|||||||
"syn 2.0.119",
|
"syn 2.0.119",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "system-configuration"
|
||||||
|
version = "0.7.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 2.13.2",
|
||||||
|
"core-foundation 0.9.4",
|
||||||
|
"system-configuration-sys",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "system-configuration-sys"
|
||||||
|
version = "0.6.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4"
|
||||||
|
dependencies = [
|
||||||
|
"core-foundation-sys",
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "system-deps"
|
name = "system-deps"
|
||||||
version = "6.2.2"
|
version = "6.2.2"
|
||||||
@@ -3929,7 +4143,7 @@ checksum = "d1c93047acf68669466a34690ac58cca7010bd1b201e1ec86f1fd0a75d3dd4a9"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.13.2",
|
"bitflags 2.13.2",
|
||||||
"block2",
|
"block2",
|
||||||
"core-foundation",
|
"core-foundation 0.10.1",
|
||||||
"core-graphics",
|
"core-graphics",
|
||||||
"crossbeam-channel",
|
"crossbeam-channel",
|
||||||
"dbus",
|
"dbus",
|
||||||
@@ -3939,7 +4153,7 @@ dependencies = [
|
|||||||
"gdkwayland-sys",
|
"gdkwayland-sys",
|
||||||
"gdkx11-sys",
|
"gdkx11-sys",
|
||||||
"gtk",
|
"gtk",
|
||||||
"jni",
|
"jni 0.21.1",
|
||||||
"libc",
|
"libc",
|
||||||
"log",
|
"log",
|
||||||
"ndk",
|
"ndk",
|
||||||
@@ -3980,6 +4194,7 @@ checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"filetime",
|
"filetime",
|
||||||
"libc",
|
"libc",
|
||||||
|
"xattr",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -4005,7 +4220,7 @@ dependencies = [
|
|||||||
"gtk",
|
"gtk",
|
||||||
"heck 0.5.0",
|
"heck 0.5.0",
|
||||||
"http",
|
"http",
|
||||||
"jni",
|
"jni 0.21.1",
|
||||||
"libc",
|
"libc",
|
||||||
"log",
|
"log",
|
||||||
"mime",
|
"mime",
|
||||||
@@ -4202,6 +4417,39 @@ dependencies = [
|
|||||||
"zbus",
|
"zbus",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tauri-plugin-updater"
|
||||||
|
version = "2.12.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7a5cad8ed5948d988e1018ecd31e27cacedbf72ce3fb972940c3e4bf51639e4b"
|
||||||
|
dependencies = [
|
||||||
|
"base64 0.22.1",
|
||||||
|
"dirs",
|
||||||
|
"flate2",
|
||||||
|
"futures-util",
|
||||||
|
"http",
|
||||||
|
"infer",
|
||||||
|
"log",
|
||||||
|
"minisign-verify",
|
||||||
|
"osakit",
|
||||||
|
"percent-encoding",
|
||||||
|
"reqwest 0.13.5",
|
||||||
|
"rustls",
|
||||||
|
"semver",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"tar",
|
||||||
|
"tauri",
|
||||||
|
"tauri-plugin",
|
||||||
|
"tempfile",
|
||||||
|
"thiserror 2.0.20",
|
||||||
|
"time",
|
||||||
|
"tokio",
|
||||||
|
"url",
|
||||||
|
"windows-sys 0.60.2",
|
||||||
|
"zip",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tauri-runtime"
|
name = "tauri-runtime"
|
||||||
version = "2.11.3"
|
version = "2.11.3"
|
||||||
@@ -4212,7 +4460,7 @@ dependencies = [
|
|||||||
"dpi",
|
"dpi",
|
||||||
"gtk",
|
"gtk",
|
||||||
"http",
|
"http",
|
||||||
"jni",
|
"jni 0.21.1",
|
||||||
"objc2",
|
"objc2",
|
||||||
"objc2-ui-kit",
|
"objc2-ui-kit",
|
||||||
"objc2-web-kit",
|
"objc2-web-kit",
|
||||||
@@ -4235,7 +4483,7 @@ checksum = "4e6fac707727b7a2f48e4ded90976324267371073edbb415ffb73bb0458d203f"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"gtk",
|
"gtk",
|
||||||
"http",
|
"http",
|
||||||
"jni",
|
"jni 0.21.1",
|
||||||
"log",
|
"log",
|
||||||
"objc2",
|
"objc2",
|
||||||
"objc2-app-kit",
|
"objc2-app-kit",
|
||||||
@@ -5039,6 +5287,15 @@ dependencies = [
|
|||||||
"system-deps",
|
"system-deps",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "webpki-root-certs"
|
||||||
|
version = "1.0.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b"
|
||||||
|
dependencies = [
|
||||||
|
"rustls-pki-types",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "webpki-roots"
|
name = "webpki-roots"
|
||||||
version = "1.0.9"
|
version = "1.0.9"
|
||||||
@@ -5233,6 +5490,17 @@ dependencies = [
|
|||||||
"windows-link 0.1.3",
|
"windows-link 0.1.3",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-registry"
|
||||||
|
version = "0.6.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720"
|
||||||
|
dependencies = [
|
||||||
|
"windows-link 0.2.1",
|
||||||
|
"windows-result 0.4.1",
|
||||||
|
"windows-strings 0.5.1",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-result"
|
name = "windows-result"
|
||||||
version = "0.3.4"
|
version = "0.3.4"
|
||||||
@@ -5582,7 +5850,7 @@ dependencies = [
|
|||||||
"gtk",
|
"gtk",
|
||||||
"http",
|
"http",
|
||||||
"javascriptcore-rs",
|
"javascriptcore-rs",
|
||||||
"jni",
|
"jni 0.21.1",
|
||||||
"libc",
|
"libc",
|
||||||
"ndk",
|
"ndk",
|
||||||
"objc2",
|
"objc2",
|
||||||
@@ -5629,6 +5897,16 @@ dependencies = [
|
|||||||
"pkg-config",
|
"pkg-config",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "xattr"
|
||||||
|
version = "1.6.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"rustix",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "yoke"
|
name = "yoke"
|
||||||
version = "0.8.3"
|
version = "0.8.3"
|
||||||
@@ -5782,6 +6060,18 @@ dependencies = [
|
|||||||
"syn 3.0.5",
|
"syn 3.0.5",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zip"
|
||||||
|
version = "4.6.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "caa8cd6af31c3b31c6631b8f483848b91589021b28fffe50adada48d4f4d2ed1"
|
||||||
|
dependencies = [
|
||||||
|
"arbitrary",
|
||||||
|
"crc32fast",
|
||||||
|
"indexmap 2.14.2",
|
||||||
|
"memchr",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zlib-rs"
|
name = "zlib-rs"
|
||||||
version = "0.6.7"
|
version = "0.6.7"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
//! The installer (fetch.py's `fetch`): verified manifest in, verified install out.
|
//! The installer (fetch.py's `fetch`): verified manifest in, verified install out.
|
||||||
|
|
||||||
use std::collections::BTreeSet;
|
use std::collections::{BTreeSet, HashMap};
|
||||||
use std::future::Future;
|
use std::future::Future;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
@@ -15,7 +15,7 @@ use tokio_util::sync::CancellationToken;
|
|||||||
use crate::copy::{atomic_write, copy_body, with_suffix};
|
use crate::copy::{atomic_write, copy_body, with_suffix};
|
||||||
use crate::error::{Error, IntegrityKind, Result};
|
use crate::error::{Error, IntegrityKind, Result};
|
||||||
use crate::hashing::{chunk_range, hash_file_prefix, hex, sha256_file};
|
use crate::hashing::{chunk_range, hash_file_prefix, hex, sha256_file};
|
||||||
use crate::manifest::{safe_relpath, AppFile, DbFile, SrcFile, SrcSection, TilesSection, VerifiedManifest};
|
use crate::manifest::{safe_relpath, AppFile, DbFile, SrcFile, SrcSection, TileSet, TilesSection, VerifiedManifest};
|
||||||
use crate::progress::{Progress, Reporter, Stage};
|
use crate::progress::{Progress, Reporter, Stage};
|
||||||
use crate::source::{blocking, Backend, Pack, Source};
|
use crate::source::{blocking, Backend, Pack, Source};
|
||||||
use crate::tileblocks::{copy_drive_block, fetch_site_block, DriveBlock};
|
use crate::tileblocks::{copy_drive_block, fetch_site_block, DriveBlock};
|
||||||
@@ -28,11 +28,19 @@ pub struct InstallOptions {
|
|||||||
pub workers: usize,
|
pub workers: usize,
|
||||||
/// Stages to run; `App`, `Db`, `Src` and `Tiles` are meaningful, others are ignored.
|
/// Stages to run; `App`, `Db`, `Src` and `Tiles` are meaningful, others are ignored.
|
||||||
pub only: Vec<Stage>,
|
pub only: Vec<Stage>,
|
||||||
|
/// The database files this PC needs, by manifest path; `None` is all of them. A PC that
|
||||||
|
/// keeps its own database needs only the dumps of tables it lacks, not the whole snapshot.
|
||||||
|
pub db_paths: Option<BTreeSet<String>>,
|
||||||
|
/// The tile sets to install, as `style/dataset`; `None` is all of them. The launcher
|
||||||
|
/// leaves out the marketplace sets the operator did not choose.
|
||||||
|
pub tile_sets: Option<BTreeSet<String>>,
|
||||||
|
/// The source files to install, by manifest path; `None` is all of them.
|
||||||
|
pub src_paths: Option<BTreeSet<String>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for InstallOptions {
|
impl Default for InstallOptions {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
InstallOptions { workers: 6, only: vec![Stage::App, Stage::Db, Stage::Src, Stage::Tiles] }
|
InstallOptions { workers: 6, only: vec![Stage::App, Stage::Db, Stage::Src, Stage::Tiles], db_paths: None, tile_sets: None, src_paths: None }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,6 +49,8 @@ pub struct AppSummary {
|
|||||||
pub files: usize,
|
pub files: usize,
|
||||||
pub downloaded: usize,
|
pub downloaded: usize,
|
||||||
pub skipped: usize,
|
pub skipped: usize,
|
||||||
|
/// Identical files taken from another installed version instead of downloaded.
|
||||||
|
pub reused: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||||
@@ -114,6 +124,7 @@ where
|
|||||||
let backend = Backend::new(source)?;
|
let backend = Backend::new(source)?;
|
||||||
let m = manifest.manifest();
|
let m = manifest.manifest();
|
||||||
let version = m.version.clone();
|
let version = m.version.clone();
|
||||||
|
backend.use_version(&version);
|
||||||
|
|
||||||
let wants = |stage: Stage| opts.only.contains(&stage);
|
let wants = |stage: Stage| opts.only.contains(&stage);
|
||||||
let do_app = wants(Stage::App);
|
let do_app = wants(Stage::App);
|
||||||
@@ -146,23 +157,29 @@ where
|
|||||||
if do_app {
|
if do_app {
|
||||||
check_cancel()?;
|
check_cancel()?;
|
||||||
stage_index += 1;
|
stage_index += 1;
|
||||||
summary.app = Some(install_app(&ctx, stage_index, &m.app.files, &version_dir).await?);
|
let (dest, current) = (dest.to_path_buf(), version.clone());
|
||||||
|
let reuse = blocking(move || Ok(installed_app_files(&dest, ¤t))).await?;
|
||||||
|
summary.app = Some(install_app(&ctx, stage_index, &m.app.files, &reuse, &version_dir).await?);
|
||||||
}
|
}
|
||||||
if let (true, Some(db)) = (do_db, &m.db) {
|
if let (true, Some(db)) = (do_db, &m.db) {
|
||||||
check_cancel()?;
|
check_cancel()?;
|
||||||
stage_index += 1;
|
stage_index += 1;
|
||||||
let (files, downloaded, skipped) = install_db(&ctx, stage_index, &db.files, &data_dir).await?;
|
let (files, downloaded, skipped) = install_db(&ctx, stage_index, &db.files, opts.db_paths.as_ref(), &data_dir).await?;
|
||||||
summary.db = Some(DbSummary { snapshot: db.snapshot.clone(), files, downloaded, skipped });
|
summary.db = Some(DbSummary { snapshot: db.snapshot.clone(), files, downloaded, skipped });
|
||||||
}
|
}
|
||||||
if let (true, Some(src)) = (do_src, &m.src) {
|
if let (true, Some(src)) = (do_src, &m.src) {
|
||||||
check_cancel()?;
|
check_cancel()?;
|
||||||
stage_index += 1;
|
stage_index += 1;
|
||||||
summary.src = Some(install_src(&ctx, stage_index, src, &data_dir).await?);
|
let keep = |f: &&SrcFile| opts.src_paths.as_ref().is_none_or(|paths| paths.contains(&f.path));
|
||||||
|
let src = SrcSection { files: src.files.iter().filter(keep).cloned().collect(), ..src.clone() };
|
||||||
|
summary.src = Some(install_src(&ctx, stage_index, &src, &data_dir).await?);
|
||||||
}
|
}
|
||||||
if let (true, Some(tiles)) = (do_tiles, &m.tiles) {
|
if let (true, Some(tiles)) = (do_tiles, &m.tiles) {
|
||||||
check_cancel()?;
|
check_cancel()?;
|
||||||
stage_index += 1;
|
stage_index += 1;
|
||||||
summary.tiles = Some(install_tiles(&ctx, stage_index, &version, tiles, &data_dir).await?);
|
let keep = |s: &&TileSet| opts.tile_sets.as_ref().is_none_or(|sets| sets.contains(&format!("{}/{}", s.style, s.dataset)));
|
||||||
|
let tiles = TilesSection { sets: tiles.sets.iter().filter(keep).cloned().collect(), ..tiles.clone() };
|
||||||
|
summary.tiles = Some(install_tiles(&ctx, stage_index, &version, &tiles, &data_dir).await?);
|
||||||
}
|
}
|
||||||
|
|
||||||
check_cancel()?;
|
check_cancel()?;
|
||||||
@@ -312,37 +329,103 @@ async fn download_file(ctx: &Ctx<'_>, pack: Pack<'_>, dest: &Path, size: u64, sh
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn install_app(ctx: &Ctx<'_>, stage_index: u32, files: &[AppFile], version_dir: &Path) -> Result<AppSummary> {
|
/// The app files other fully installed versions hold (their manifest.json is written last),
|
||||||
|
/// by content. An update takes an unchanged file from there instead of downloading it again.
|
||||||
|
fn installed_app_files(dest: &Path, except_version: &str) -> HashMap<String, PathBuf> {
|
||||||
|
let mut found = HashMap::new();
|
||||||
|
let Ok(entries) = std::fs::read_dir(dest.join("versions")) else { return found };
|
||||||
|
for entry in entries.flatten() {
|
||||||
|
if entry.file_name().to_string_lossy() == except_version {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let dir = entry.path();
|
||||||
|
let Ok(raw) = std::fs::read(dir.join("manifest.json")) else { continue };
|
||||||
|
let Ok(doc) = serde_json::from_slice::<Value>(&raw) else { continue };
|
||||||
|
for file in doc["app"]["files"].as_array().into_iter().flatten() {
|
||||||
|
let (Some(path), Some(sha256)) = (file["path"].as_str(), file["sha256"].as_str()) else { continue };
|
||||||
|
if let Ok(full) = join_rel(&dir, path) {
|
||||||
|
found.entry(sha256.to_string()).or_insert(full);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
found
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Put a copy of `from` at `target`: a hard link where the volume allows one (no bytes
|
||||||
|
/// written, and nothing left behind when the old version is removed), else a real copy.
|
||||||
|
/// Best effort: the caller verifies `target` like any file on disk, and downloads over it
|
||||||
|
/// when it is missing or wrong -- that write goes to `.part` and replaces the link, so it
|
||||||
|
/// never touches `from`.
|
||||||
|
fn link_or_copy(from: &Path, target: &Path) -> bool {
|
||||||
|
if target.exists() || !from.is_file() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if let Some(parent) = target.parent() {
|
||||||
|
if std::fs::create_dir_all(parent).is_err() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
std::fs::hard_link(from, target).is_ok() || std::fs::copy(from, target).is_ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn install_app(
|
||||||
|
ctx: &Ctx<'_>,
|
||||||
|
stage_index: u32,
|
||||||
|
files: &[AppFile],
|
||||||
|
reuse: &HashMap<String, PathBuf>,
|
||||||
|
version_dir: &Path,
|
||||||
|
) -> Result<AppSummary> {
|
||||||
let total: u64 = files.iter().map(|f| f.size).sum();
|
let total: u64 = files.iter().map(|f| f.size).sum();
|
||||||
ctx.reporter.begin_stage(Stage::App, stage_index, total, files.len() as u64);
|
ctx.reporter.begin_stage(Stage::App, stage_index, total, files.len() as u64);
|
||||||
ctx.reporter.begin_sub("app".into(), "files", files.len() as u64);
|
ctx.reporter.begin_sub("app".into(), "files", files.len() as u64);
|
||||||
let (mut downloaded, mut skipped) = (0, 0);
|
let (mut downloaded, mut skipped, mut reused) = (0, 0, 0);
|
||||||
run_pool(
|
run_pool(
|
||||||
files.iter().collect(),
|
files.iter().collect(),
|
||||||
ctx.workers,
|
ctx.workers,
|
||||||
&ctx.cancel,
|
&ctx.cancel,
|
||||||
|entry: &AppFile, stop| async move {
|
|entry: &AppFile, stop| async move {
|
||||||
let target = join_rel(version_dir, &entry.path)?;
|
let target = join_rel(version_dir, &entry.path)?;
|
||||||
download_file(ctx, Pack::App(&entry.sha256), &target, entry.size, &entry.sha256, &stop).await
|
let linked = match reuse.get(&entry.sha256) {
|
||||||
|
Some(from) => {
|
||||||
|
let (from, to) = (from.clone(), target.clone());
|
||||||
|
blocking(move || Ok(link_or_copy(&from, &to))).await?
|
||||||
|
}
|
||||||
|
None => false,
|
||||||
|
};
|
||||||
|
let pack = Pack::App { sha: &entry.sha256, path: &entry.path };
|
||||||
|
let outcome = download_file(ctx, pack, &target, entry.size, &entry.sha256, &stop).await?;
|
||||||
|
Ok((outcome, linked))
|
||||||
},
|
},
|
||||||
|outcome| {
|
|(outcome, linked)| {
|
||||||
if outcome == Outcome::Downloaded { downloaded += 1 } else { skipped += 1 }
|
match (outcome, linked) {
|
||||||
|
(Outcome::Downloaded, _) => downloaded += 1,
|
||||||
|
(Outcome::Skipped, true) => reused += 1,
|
||||||
|
(Outcome::Skipped, false) => skipped += 1,
|
||||||
|
}
|
||||||
ctx.reporter.items_done(1);
|
ctx.reporter.items_done(1);
|
||||||
Ok(())
|
Ok(())
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
ctx.reporter.finish_stage();
|
ctx.reporter.finish_stage();
|
||||||
Ok(AppSummary { files: files.len(), downloaded, skipped })
|
Ok(AppSummary { files: files.len(), downloaded, skipped, reused })
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn install_db(ctx: &Ctx<'_>, stage_index: u32, files: &[DbFile], data_dir: &Path) -> Result<(usize, usize, usize)> {
|
async fn install_db(
|
||||||
|
ctx: &Ctx<'_>,
|
||||||
|
stage_index: u32,
|
||||||
|
files: &[DbFile],
|
||||||
|
only: Option<&BTreeSet<String>>,
|
||||||
|
data_dir: &Path,
|
||||||
|
) -> Result<(usize, usize, usize)> {
|
||||||
|
let files: Vec<&DbFile> = files.iter().filter(|f| only.is_none_or(|keep| keep.contains(&f.path))).collect();
|
||||||
|
let count = files.len();
|
||||||
let total: u64 = files.iter().map(|f| f.size).sum();
|
let total: u64 = files.iter().map(|f| f.size).sum();
|
||||||
ctx.reporter.begin_stage(Stage::Db, stage_index, total, files.len() as u64);
|
ctx.reporter.begin_stage(Stage::Db, stage_index, total, count as u64);
|
||||||
ctx.reporter.begin_sub("db".into(), "files", files.len() as u64);
|
ctx.reporter.begin_sub("db".into(), "files", count as u64);
|
||||||
let (mut downloaded, mut skipped) = (0, 0);
|
let (mut downloaded, mut skipped) = (0, 0);
|
||||||
run_pool(
|
run_pool(
|
||||||
files.iter().collect(),
|
files,
|
||||||
ctx.workers.min(4),
|
ctx.workers.min(4),
|
||||||
&ctx.cancel,
|
&ctx.cancel,
|
||||||
|entry: &DbFile, stop| async move {
|
|entry: &DbFile, stop| async move {
|
||||||
@@ -357,7 +440,7 @@ async fn install_db(ctx: &Ctx<'_>, stage_index: u32, files: &[DbFile], data_dir:
|
|||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
ctx.reporter.finish_stage();
|
ctx.reporter.finish_stage();
|
||||||
Ok((files.len(), downloaded, skipped))
|
Ok((count, downloaded, skipped))
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- source files -----------------------------------------------------------------
|
// ---- source files -----------------------------------------------------------------
|
||||||
@@ -495,13 +578,186 @@ async fn fetch_chunk(ctx: &Ctx<'_>, entry: &SrcFile, chunk_bytes: u64, dest: &Pa
|
|||||||
|
|
||||||
// ---- tiles --------------------------------------------------------------------------
|
// ---- tiles --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// The installer's record of which blocks of a set a PC holds, beside the set's tiles.
|
||||||
|
const DONE_MARKER: &str = ".blocks-done.json";
|
||||||
|
|
||||||
/// `{"index_sha256": "...", "blocks": ["z/bx/by:digest", ...]}`, as Python's `json.dumps` writes it.
|
/// `{"index_sha256": "...", "blocks": ["z/bx/by:digest", ...]}`, as Python's `json.dumps` writes it.
|
||||||
fn blocks_done_json(index_sha256: &str, done: &BTreeSet<String>) -> String {
|
fn blocks_done_json(index_sha256: &str, done: &BTreeSet<String>) -> String {
|
||||||
let list: Vec<String> = done.iter().map(|k| format!("\"{k}\"")).collect();
|
let list: Vec<String> = done.iter().map(|k| format!("\"{k}\"")).collect();
|
||||||
format!("{{\"index_sha256\": \"{index_sha256}\", \"blocks\": [{}]}}", list.join(", "))
|
format!("{{\"index_sha256\": \"{index_sha256}\", \"blocks\": [{}]}}", list.join(", "))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/// Tile sets from another install, copied as they lie.
|
||||||
|
///
|
||||||
|
/// An install keeps no per-block index (only a site store has one), so the block-by-block
|
||||||
|
/// verification of a download has nothing to check against here. What makes this copy
|
||||||
|
/// trustworthy instead is every single file: its bytes are hashed at the source and read back
|
||||||
|
/// from the disk they landed on, and a mismatch fails the install rather than being installed.
|
||||||
|
/// The source's own record of which blocks it holds (`.blocks-done.json`) travels with the
|
||||||
|
/// tiles, so the PC knows what it has and an update fills in only the rest. Tiles the source
|
||||||
|
/// drew itself, beyond the blocks, come along: they are valid tiles of the same set.
|
||||||
|
async fn copy_install_tiles(
|
||||||
|
ctx: &Ctx<'_>,
|
||||||
|
stage_index: u32,
|
||||||
|
tiles: &TilesSection,
|
||||||
|
root: &Path,
|
||||||
|
data_dir: &Path,
|
||||||
|
) -> Result<Vec<TileSetSummary>> {
|
||||||
|
let total: u64 = tiles.sets.iter().map(|s| s.bytes).sum();
|
||||||
|
let files: u64 = tiles.sets.iter().map(|s| s.files).sum();
|
||||||
|
ctx.reporter.begin_stage(Stage::Tiles, stage_index, total, files);
|
||||||
|
let mut out = Vec::with_capacity(tiles.sets.len());
|
||||||
|
for set in &tiles.sets {
|
||||||
|
let (style, dataset) = (set.style.as_str(), set.dataset.as_str());
|
||||||
|
let from = root.join("data").join("tiles").join(style).join(dataset);
|
||||||
|
let to = data_dir.join("tiles").join(style).join(dataset);
|
||||||
|
// This PC already recorded the very same set as complete: `.blocks-done.json` names
|
||||||
|
// this index and all of its blocks, the record the marketplace itself reads to say
|
||||||
|
// "Installed". Hashing its hundreds of thousands of files to learn what the marker
|
||||||
|
// already states costs minutes a set and can only ever conclude "identical".
|
||||||
|
if set_recorded_complete(&to, set) {
|
||||||
|
ctx.reporter.begin_sub(format!("tiles {style}/{dataset}"), "files", set.files);
|
||||||
|
ctx.reporter.items_done(set.files);
|
||||||
|
ctx.reporter.add_done_bytes(set.bytes);
|
||||||
|
out.push(TileSetSummary {
|
||||||
|
set: format!("{style}/{dataset}"),
|
||||||
|
blocks: set.blocks as usize,
|
||||||
|
downloaded: 0,
|
||||||
|
files: set.files,
|
||||||
|
bytes: set.bytes,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if !from.is_dir() {
|
||||||
|
return Err(Error::NotFound { what: format!("{}: tiles {style}/{dataset}", from.display()) });
|
||||||
|
}
|
||||||
|
ctx.reporter.begin_sub(format!("tiles {style}/{dataset}"), "files", set.files);
|
||||||
|
let (mut copied, mut seen, mut bytes) = (0usize, 0usize, 0u64);
|
||||||
|
// The marker is the claim that the whole set is here, so it is written last, not
|
||||||
|
// copied somewhere in the middle of the walk: a copy stopped halfway must leave a
|
||||||
|
// set that still reads as partial, and resume rather than be trusted whole.
|
||||||
|
let marker = from.join(DONE_MARKER);
|
||||||
|
let mut folders = vec![from.clone()];
|
||||||
|
while let Some(folder) = folders.pop() {
|
||||||
|
if ctx.cancel.is_cancelled() {
|
||||||
|
return Err(Error::Cancelled);
|
||||||
|
}
|
||||||
|
let (batch, mut deeper) = {
|
||||||
|
let (folder, from, to) = (folder.clone(), from.clone(), to.clone());
|
||||||
|
blocking(move || list_folder(&folder, &from, &to)).await?
|
||||||
|
};
|
||||||
|
folders.append(&mut deeper);
|
||||||
|
let batch: Vec<(PathBuf, PathBuf)> = batch.into_iter().filter(|(file, _)| *file != marker).collect();
|
||||||
|
seen += batch.len();
|
||||||
|
let counted = ctx.reporter.clone();
|
||||||
|
run_pool(
|
||||||
|
batch,
|
||||||
|
ctx.workers,
|
||||||
|
&ctx.cancel,
|
||||||
|
|pair: (PathBuf, PathBuf), stop| {
|
||||||
|
let reporter = ctx.reporter.clone();
|
||||||
|
async move { blocking(move || copy_checked(&pair.0, &pair.1, &reporter, &stop)).await }
|
||||||
|
},
|
||||||
|
|(size, fresh)| {
|
||||||
|
bytes += size;
|
||||||
|
copied += usize::from(fresh);
|
||||||
|
counted.items_done(1);
|
||||||
|
Ok(())
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
// Every file of the set landed and was read back: what the source recorded about it
|
||||||
|
// is now true here too.
|
||||||
|
if marker.is_file() {
|
||||||
|
let raw = blocking({
|
||||||
|
let marker = marker.clone();
|
||||||
|
move || std::fs::read(&marker).map_err(Error::io(&marker))
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
atomic_write(&to.join(DONE_MARKER), &raw)?;
|
||||||
|
}
|
||||||
|
out.push(TileSetSummary {
|
||||||
|
set: format!("{style}/{dataset}"),
|
||||||
|
blocks: set.blocks as usize,
|
||||||
|
downloaded: copied,
|
||||||
|
files: seen as u64,
|
||||||
|
bytes,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
ctx.reporter.finish_stage();
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether `dir` holds the whole of `set` by the installer's own record: the marker names
|
||||||
|
/// this index and lists every block of it. Written by a download (each block verified against
|
||||||
|
/// the site's index) or by a copy (every file hashed in and read back), so either way the set
|
||||||
|
/// it describes is this set.
|
||||||
|
fn set_recorded_complete(dir: &Path, set: &TileSet) -> bool {
|
||||||
|
let Ok(raw) = std::fs::read(dir.join(DONE_MARKER)) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let Ok(saved) = serde_json::from_slice::<Value>(&raw) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
saved.get("index_sha256").and_then(Value::as_str) == Some(set.index_sha256.as_str())
|
||||||
|
&& saved.get("blocks").and_then(Value::as_array).is_some_and(|blocks| blocks.len() as u64 >= set.blocks)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One folder of a tile set: the files in it (source and destination), and the folders under it.
|
||||||
|
fn list_folder(folder: &Path, from: &Path, to: &Path) -> Result<(Vec<(PathBuf, PathBuf)>, Vec<PathBuf>)> {
|
||||||
|
let mut files = Vec::new();
|
||||||
|
let mut folders = Vec::new();
|
||||||
|
for entry in std::fs::read_dir(folder).map_err(Error::io(folder))? {
|
||||||
|
let entry = entry.map_err(Error::io(folder))?;
|
||||||
|
let path = entry.path();
|
||||||
|
let kind = entry.file_type().map_err(Error::io(&path))?;
|
||||||
|
if kind.is_dir() {
|
||||||
|
folders.push(path);
|
||||||
|
} else if kind.is_file() {
|
||||||
|
let rel = path.strip_prefix(from).map_err(|_| Error::io(&path)(std::io::Error::other("outside the set")))?;
|
||||||
|
files.push((path.clone(), to.join(rel)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok((files, folders))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Copy one file and prove it: the source's bytes are hashed, and so are the bytes read back
|
||||||
|
/// from where they landed. Returns its size and whether this run wrote it (a file already
|
||||||
|
/// there and identical is left alone, so a copy that stopped resumes without redoing it).
|
||||||
|
fn copy_checked(from: &Path, to: &Path, reporter: &Reporter, cancel: &CancellationToken) -> Result<(u64, bool)> {
|
||||||
|
let (size, want) = sha256_file(from, cancel)?;
|
||||||
|
let there = std::fs::metadata(to).is_ok_and(|md| md.is_file() && md.len() == size)
|
||||||
|
&& sha256_file(to, cancel).is_ok_and(|(_, got)| got == want);
|
||||||
|
if there {
|
||||||
|
reporter.add_done_bytes(size);
|
||||||
|
return Ok((size, false));
|
||||||
|
}
|
||||||
|
if let Some(parent) = to.parent() {
|
||||||
|
std::fs::create_dir_all(parent).map_err(Error::io(parent))?;
|
||||||
|
}
|
||||||
|
let part = with_suffix(to, ".part");
|
||||||
|
std::fs::copy(from, &part).map_err(Error::io(&part))?;
|
||||||
|
let (_, got) = sha256_file(&part, cancel)?;
|
||||||
|
if got != want {
|
||||||
|
let _ = std::fs::remove_file(&part);
|
||||||
|
return Err(Error::integrity(
|
||||||
|
IntegrityKind::FileHash,
|
||||||
|
from.display().to_string(),
|
||||||
|
format!("copied as {got}, expected {want}"),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
std::fs::rename(&part, to).map_err(Error::io(to))?;
|
||||||
|
reporter.add_transferred(size);
|
||||||
|
Ok((size, true))
|
||||||
|
}
|
||||||
|
|
||||||
async fn install_tiles(ctx: &Ctx<'_>, stage_index: u32, version: &str, tiles: &TilesSection, data_dir: &Path) -> Result<Vec<TileSetSummary>> {
|
async fn install_tiles(ctx: &Ctx<'_>, stage_index: u32, version: &str, tiles: &TilesSection, data_dir: &Path) -> Result<Vec<TileSetSummary>> {
|
||||||
|
if let Some(root) = ctx.backend.install_root() {
|
||||||
|
let root = root.to_path_buf();
|
||||||
|
return copy_install_tiles(ctx, stage_index, tiles, &root, data_dir).await;
|
||||||
|
}
|
||||||
let total: u64 = tiles.sets.iter().map(|s| s.bytes).sum();
|
let total: u64 = tiles.sets.iter().map(|s| s.bytes).sum();
|
||||||
let blocks: u64 = tiles.sets.iter().map(|s| s.blocks).sum();
|
let blocks: u64 = tiles.sets.iter().map(|s| s.blocks).sum();
|
||||||
ctx.reporter.begin_stage(Stage::Tiles, stage_index, total, blocks);
|
ctx.reporter.begin_stage(Stage::Tiles, stage_index, total, blocks);
|
||||||
@@ -523,7 +779,7 @@ async fn install_tiles(ctx: &Ctx<'_>, stage_index: u32, version: &str, tiles: &T
|
|||||||
let index = verify_index(&raw, &set.index_sha256, &what)?;
|
let index = verify_index(&raw, &set.index_sha256, &what)?;
|
||||||
|
|
||||||
let set_dir = data_dir.join("tiles").join(style).join(dataset);
|
let set_dir = data_dir.join("tiles").join(style).join(dataset);
|
||||||
let done_path = set_dir.join(".blocks-done.json");
|
let done_path = set_dir.join(DONE_MARKER);
|
||||||
let mut done: BTreeSet<String> = BTreeSet::new();
|
let mut done: BTreeSet<String> = BTreeSet::new();
|
||||||
if let Some(saved) = std::fs::read(&done_path).ok().and_then(|raw| serde_json::from_slice::<Value>(&raw).ok()) {
|
if let Some(saved) = std::fs::read(&done_path).ok().and_then(|raw| serde_json::from_slice::<Value>(&raw).ok()) {
|
||||||
if saved.get("index_sha256").and_then(Value::as_str) == Some(set.index_sha256.as_str()) {
|
if saved.get("index_sha256").and_then(Value::as_str) == Some(set.index_sha256.as_str()) {
|
||||||
|
|||||||
@@ -194,6 +194,24 @@ fn object<'a>(value: &'a Value, message: &str) -> Result<&'a Map<String, Value>>
|
|||||||
|
|
||||||
/// Error unless `manifest` is a well-formed schema-1 manifest. Structure only; the
|
/// Error unless `manifest` is a well-formed schema-1 manifest. Structure only; the
|
||||||
/// signature is checked by [`crate::signing::verify`].
|
/// signature is checked by [`crate::signing::verify`].
|
||||||
|
/// The marketplace fields a tile set or source file may carry (manifest.py `_check_catalog`).
|
||||||
|
/// An item without `"optional": true` is part of every install.
|
||||||
|
const CATALOG_TEXT_LIMITS: [(&str, usize); 2] = [("title", 80), ("description", 1000)];
|
||||||
|
|
||||||
|
fn check_catalog(entry: &Map<String, Value>, place: &str) -> Result<()> {
|
||||||
|
if let Some(optional) = entry.get("optional") {
|
||||||
|
require(optional.is_boolean(), || format!("{place}: optional must be true or false"))?;
|
||||||
|
}
|
||||||
|
for (key, limit) in CATALOG_TEXT_LIMITS {
|
||||||
|
if let Some(text) = entry.get(key) {
|
||||||
|
// Characters, as Python's len() counts them.
|
||||||
|
let ok = text.as_str().is_some_and(|s| !s.trim().is_empty() && s.chars().count() <= limit);
|
||||||
|
require(ok, || format!("{place}: {key} must be text of 1 to {limit} characters"))?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
pub fn validate(manifest: &Value) -> Result<()> {
|
pub fn validate(manifest: &Value) -> Result<()> {
|
||||||
let m = object(manifest, "manifest must be a JSON object")?;
|
let m = object(manifest, "manifest must be a JSON object")?;
|
||||||
require(m.get("schema").and_then(Value::as_u64) == Some(SCHEMA), || format!("schema must be {SCHEMA}"))?;
|
require(m.get("schema").and_then(Value::as_u64) == Some(SCHEMA), || format!("schema must be {SCHEMA}"))?;
|
||||||
@@ -280,6 +298,7 @@ pub fn validate(manifest: &Value) -> Result<()> {
|
|||||||
require(entry.get("index").and_then(Value::as_str) == Some(expected.as_str()), || {
|
require(entry.get("index").and_then(Value::as_str) == Some(expected.as_str()), || {
|
||||||
format!("{place}: index must be {expected:?}")
|
format!("{place}: index must be {expected:?}")
|
||||||
})?;
|
})?;
|
||||||
|
check_catalog(entry, &place)?;
|
||||||
sets_seen.insert((style, dataset));
|
sets_seen.insert((style, dataset));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -309,6 +328,7 @@ pub fn validate(manifest: &Value) -> Result<()> {
|
|||||||
require(entry.get("chunks_sha256").and_then(Value::as_str) == Some(chunks_digest(&hashes).as_str()), || {
|
require(entry.get("chunks_sha256").and_then(Value::as_str) == Some(chunks_digest(&hashes).as_str()), || {
|
||||||
format!("{place}: chunks_sha256 does not match chunks")
|
format!("{place}: chunks_sha256 does not match chunks")
|
||||||
})?;
|
})?;
|
||||||
|
check_catalog(entry, &place)?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -377,6 +397,13 @@ pub struct TileSet {
|
|||||||
pub bytes: u64,
|
pub bytes: u64,
|
||||||
pub index: String,
|
pub index: String,
|
||||||
pub index_sha256: String,
|
pub index_sha256: String,
|
||||||
|
/// Offered in the launcher's marketplace instead of installed with everything else.
|
||||||
|
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||||
|
pub optional: bool,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub title: Option<String>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub description: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
@@ -391,6 +418,13 @@ pub struct SrcFile {
|
|||||||
pub size: u64,
|
pub size: u64,
|
||||||
pub chunks: Vec<String>,
|
pub chunks: Vec<String>,
|
||||||
pub chunks_sha256: String,
|
pub chunks_sha256: String,
|
||||||
|
/// Offered in the launcher's marketplace instead of installed with everything else.
|
||||||
|
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||||
|
pub optional: bool,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub title: Option<String>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub description: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
@@ -481,6 +515,50 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn marketplace_fields_are_checked_like_python_and_default_to_part_of_the_install() {
|
||||||
|
let chunks = [hex(&Sha256::digest(b"c0"))];
|
||||||
|
let manifest = |set_extra: Value, src_extra: Value| {
|
||||||
|
let mut set = serde_json::json!({
|
||||||
|
"style": "dark_v2", "dataset": "ds", "journal_seq": 1, "blocks": 1, "files": 1, "bytes": 9,
|
||||||
|
"index": tile_index_name("dark_v2", "ds"), "index_sha256": "a".repeat(64),
|
||||||
|
});
|
||||||
|
let mut src = serde_json::json!({
|
||||||
|
"path": "x.pmtiles", "size": 5, "chunks": chunks, "chunks_sha256": chunks_digest(&[chunks[0].as_str()]),
|
||||||
|
});
|
||||||
|
set.as_object_mut().unwrap().extend(set_extra.as_object().unwrap().clone());
|
||||||
|
src.as_object_mut().unwrap().extend(src_extra.as_object().unwrap().clone());
|
||||||
|
serde_json::json!({
|
||||||
|
"schema": 1, "product": "odian", "version": "0.61.0", "released_at": "2026-09-22T12:00:00Z",
|
||||||
|
"app": {"files": []},
|
||||||
|
"tiles": {"block_shift": BLOCK_SHIFT, "sets": [set]},
|
||||||
|
"src": {"enabled": true, "chunk_bytes": 10, "files": [src]},
|
||||||
|
})
|
||||||
|
};
|
||||||
|
let good = manifest(
|
||||||
|
serde_json::json!({"optional": true, "title": "Dark V2 map", "description": "Buildings and forests."}),
|
||||||
|
serde_json::json!({"optional": true, "title": "Map source"}),
|
||||||
|
);
|
||||||
|
validate(&good).unwrap();
|
||||||
|
let typed: Manifest = serde_json::from_value(good).unwrap();
|
||||||
|
let (set, src) = (&typed.tiles.as_ref().unwrap().sets[0], &typed.src.as_ref().unwrap().files[0]);
|
||||||
|
assert!(set.optional && src.optional);
|
||||||
|
assert_eq!((set.title.as_deref(), src.description.as_deref()), (Some("Dark V2 map"), None));
|
||||||
|
let plain: Manifest = serde_json::from_value(manifest(serde_json::json!({}), serde_json::json!({}))).unwrap();
|
||||||
|
assert!(!plain.tiles.unwrap().sets[0].optional, "no field: part of every install");
|
||||||
|
|
||||||
|
// A 1000-character description passes however many bytes its letters take.
|
||||||
|
validate(&manifest(serde_json::json!({"description": "é".repeat(1000)}), serde_json::json!({}))).unwrap();
|
||||||
|
for (set_extra, src_extra) in [
|
||||||
|
(serde_json::json!({"optional": "yes"}), serde_json::json!({})),
|
||||||
|
(serde_json::json!({"title": " "}), serde_json::json!({})),
|
||||||
|
(serde_json::json!({}), serde_json::json!({"description": "x".repeat(1001)})),
|
||||||
|
(serde_json::json!({}), serde_json::json!({"title": 7})),
|
||||||
|
] {
|
||||||
|
assert!(validate(&manifest(set_extra.clone(), src_extra.clone())).is_err(), "{set_extra} {src_extra}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn versions() {
|
fn versions() {
|
||||||
assert_eq!(parse_version("0.51.0").unwrap(), (0, 51, 0));
|
assert_eq!(parse_version("0.51.0").unwrap(), (0, 51, 0));
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
//! Where a version comes from: a site's update service over HTTP, or an ODIAN drive that
|
//! Where a version comes from: a site's update service over HTTP, an ODIAN drive that holds
|
||||||
//! holds the site store layout (`packs/`, `tiles/`, `src/`).
|
//! the site store layout (`packs/`, `tiles/`, `src/`), or another ODIAN install, whose files
|
||||||
|
//! are copied out of it (a drive an operator carries holds one of those, not a store).
|
||||||
|
|
||||||
use std::io::{Read, Seek, SeekFrom};
|
use std::io::{Read, Seek, SeekFrom};
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
@@ -21,6 +22,9 @@ pub enum Source {
|
|||||||
Site { base_url: String, token: String },
|
Site { base_url: String, token: String },
|
||||||
/// An ODIAN drive (SSD) whose root holds `packs/`, `tiles/` (with `tiles.db`) and `src/`.
|
/// An ODIAN drive (SSD) whose root holds `packs/`, `tiles/` (with `tiles.db`) and `src/`.
|
||||||
Drive { root: PathBuf },
|
Drive { root: PathBuf },
|
||||||
|
/// Another ODIAN install (`versions\<v>\`, `data\`), e.g. the one a drive carries. Its
|
||||||
|
/// files are copied, and checked against the signed manifest of the version it holds.
|
||||||
|
Install { root: PathBuf },
|
||||||
}
|
}
|
||||||
|
|
||||||
const RETRIES: u32 = 4;
|
const RETRIES: u32 = 4;
|
||||||
@@ -97,7 +101,9 @@ pub(crate) enum Body {
|
|||||||
#[derive(Clone, Copy)]
|
#[derive(Clone, Copy)]
|
||||||
pub(crate) enum Pack<'a> {
|
pub(crate) enum Pack<'a> {
|
||||||
/// `app/<sha256>`.
|
/// `app/<sha256>`.
|
||||||
App(&'a str),
|
/// By content where the store is content-addressed (a site, a drive), by path inside
|
||||||
|
/// the version folder of another install.
|
||||||
|
App { sha: &'a str, path: &'a str },
|
||||||
/// A manifest `db.files[].path` (`db/<snapshot>/...`).
|
/// A manifest `db.files[].path` (`db/<snapshot>/...`).
|
||||||
Db(&'a str),
|
Db(&'a str),
|
||||||
}
|
}
|
||||||
@@ -112,9 +118,16 @@ pub(crate) struct Drive {
|
|||||||
root: PathBuf,
|
root: PathBuf,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) struct Install {
|
||||||
|
root: PathBuf,
|
||||||
|
/// The version whose manifest was read: where its app files are. Set when the manifest is.
|
||||||
|
version: Mutex<String>,
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) enum Backend {
|
pub(crate) enum Backend {
|
||||||
Site(Site),
|
Site(Site),
|
||||||
Drive(Drive),
|
Drive(Drive),
|
||||||
|
Install(Install),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Backend {
|
impl Backend {
|
||||||
@@ -130,6 +143,9 @@ impl Backend {
|
|||||||
Ok(Backend::Site(Site { base: base_url.trim_end_matches('/').to_string(), token: token.clone(), client }))
|
Ok(Backend::Site(Site { base: base_url.trim_end_matches('/').to_string(), token: token.clone(), client }))
|
||||||
}
|
}
|
||||||
Source::Drive { root } => Ok(Backend::Drive(Drive { root: root.clone() })),
|
Source::Drive { root } => Ok(Backend::Drive(Drive { root: root.clone() })),
|
||||||
|
Source::Install { root } => {
|
||||||
|
Ok(Backend::Install(Install { root: root.clone(), version: Mutex::new(String::new()) }))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -151,6 +167,12 @@ impl Backend {
|
|||||||
.map_err(|_| Error::manifest(format!("{} does not hold a valid version", pointer.display())))?;
|
.map_err(|_| Error::manifest(format!("{} does not hold a valid version", pointer.display())))?;
|
||||||
drive.read(join_rel(&drive.root, &format!("packs/versions/{text}/manifest.json"))).await
|
drive.read(join_rel(&drive.root, &format!("packs/versions/{text}/manifest.json"))).await
|
||||||
}
|
}
|
||||||
|
Backend::Install(install) => {
|
||||||
|
let root = install.root.clone();
|
||||||
|
let (version, raw) = blocking(move || newest_installed_version(&root)).await?;
|
||||||
|
install.set_version(&version);
|
||||||
|
Ok(raw)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -159,6 +181,11 @@ impl Backend {
|
|||||||
match self {
|
match self {
|
||||||
Backend::Site(site) => site.get_bytes(&format!("/v1/versions/{version}/manifest"), cancel).await,
|
Backend::Site(site) => site.get_bytes(&format!("/v1/versions/{version}/manifest"), cancel).await,
|
||||||
Backend::Drive(drive) => drive.read(join_rel(&drive.root, &format!("packs/versions/{version}/manifest.json"))).await,
|
Backend::Drive(drive) => drive.read(join_rel(&drive.root, &format!("packs/versions/{version}/manifest.json"))).await,
|
||||||
|
Backend::Install(install) => {
|
||||||
|
let raw = read_file(install.root.join("versions").join(version).join("manifest.json")).await?;
|
||||||
|
install.set_version(version);
|
||||||
|
Ok(raw)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -167,6 +194,8 @@ impl Backend {
|
|||||||
match self {
|
match self {
|
||||||
Backend::Site(site) => site.get_bytes(&format!("/v1/versions/{version}/notes"), cancel).await,
|
Backend::Site(site) => site.get_bytes(&format!("/v1/versions/{version}/notes"), cancel).await,
|
||||||
Backend::Drive(drive) => drive.read(join_rel(&drive.root, &format!("packs/versions/{version}/notes.md"))).await,
|
Backend::Drive(drive) => drive.read(join_rel(&drive.root, &format!("packs/versions/{version}/notes.md"))).await,
|
||||||
|
// An install keeps its notes with its own state, not with the version.
|
||||||
|
Backend::Install(_) => Err(Error::NotFound { what: format!("notes for {version}") }),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -180,13 +209,18 @@ impl Backend {
|
|||||||
let rel = format!("packs/versions/{version}/{}", tile_index_name(style, dataset));
|
let rel = format!("packs/versions/{version}/{}", tile_index_name(style, dataset));
|
||||||
drive.read(join_rel(&drive.root, &rel)).await
|
drive.read(join_rel(&drive.root, &rel)).await
|
||||||
}
|
}
|
||||||
|
// An install keeps no index: its tile sets are copied as they lie (see install_tiles).
|
||||||
|
Backend::Install(_) => {
|
||||||
|
Err(Error::NotFound { what: format!("tile index {style}/{dataset} of {version}") })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn pack_label(&self, pack: Pack<'_>) -> String {
|
pub fn pack_label(&self, pack: Pack<'_>) -> String {
|
||||||
match pack {
|
match (self, pack) {
|
||||||
Pack::App(sha) => format!("/packs/app/{sha}"),
|
(Backend::Install(_), Pack::App { path, .. }) => path.to_string(),
|
||||||
Pack::Db(path) => format!("/packs/{path}"),
|
(_, Pack::App { sha, .. }) => format!("/packs/app/{sha}"),
|
||||||
|
(_, Pack::Db(path)) => format!("/packs/{path}"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -196,7 +230,7 @@ impl Backend {
|
|||||||
match self {
|
match self {
|
||||||
Backend::Site(site) => {
|
Backend::Site(site) => {
|
||||||
let path = match pack {
|
let path = match pack {
|
||||||
Pack::App(sha) => format!("/packs/app/{}", encode_path(sha)),
|
Pack::App { sha, .. } => format!("/packs/app/{}", encode_path(sha)),
|
||||||
Pack::Db(rel) => format!("/packs/{}", encode_path(rel)),
|
Pack::Db(rel) => format!("/packs/{}", encode_path(rel)),
|
||||||
};
|
};
|
||||||
let range = (offset > 0).then(|| format!("bytes={offset}-"));
|
let range = (offset > 0).then(|| format!("bytes={offset}-"));
|
||||||
@@ -206,10 +240,18 @@ impl Backend {
|
|||||||
}
|
}
|
||||||
Backend::Drive(drive) => {
|
Backend::Drive(drive) => {
|
||||||
let path = match pack {
|
let path = match pack {
|
||||||
Pack::App(sha) => drive.root.join("packs").join("app").join(sha),
|
Pack::App { sha, .. } => drive.root.join("packs").join("app").join(sha),
|
||||||
Pack::Db(rel) => join_rel(&drive.root.join("packs"), rel),
|
Pack::Db(rel) => join_rel(&drive.root.join("packs"), rel),
|
||||||
};
|
};
|
||||||
let body = drive.open_range(path, offset, u64::MAX).await?;
|
let body = open_range(path, offset, u64::MAX).await?;
|
||||||
|
Ok((body, true))
|
||||||
|
}
|
||||||
|
Backend::Install(install) => {
|
||||||
|
let path = match pack {
|
||||||
|
Pack::App { path, .. } => join_rel(&install.version_dir(), path),
|
||||||
|
Pack::Db(rel) => join_rel(&install.root.join("data"), rel),
|
||||||
|
};
|
||||||
|
let body = open_range(path, offset, u64::MAX).await?;
|
||||||
Ok((body, true))
|
Ok((body, true))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -230,25 +272,95 @@ impl Backend {
|
|||||||
}
|
}
|
||||||
Ok(Body::Http { response, url })
|
Ok(Body::Http { response, url })
|
||||||
}
|
}
|
||||||
Backend::Drive(drive) => drive.open_range(join_rel(&drive.root.join("src"), rel), start, end - start + 1).await,
|
Backend::Drive(drive) => open_range(join_rel(&drive.root.join("src"), rel), start, end - start + 1).await,
|
||||||
|
Backend::Install(install) => {
|
||||||
|
open_range(join_rel(&install.root.join("data").join("src"), rel), start, end - start + 1).await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Which version's files to copy, when the source is another install: the manifest is
|
||||||
|
/// read through a backend of its own, so the one doing the work is told separately.
|
||||||
|
pub fn use_version(&self, version: &str) {
|
||||||
|
if let Backend::Install(install) = self {
|
||||||
|
install.set_version(version);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The root of another install this one is being copied from.
|
||||||
|
pub fn install_root(&self) -> Option<&Path> {
|
||||||
|
match self {
|
||||||
|
Backend::Install(install) => Some(&install.root),
|
||||||
|
_ => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn drive_root(&self) -> Option<&Path> {
|
pub fn drive_root(&self) -> Option<&Path> {
|
||||||
match self {
|
match self {
|
||||||
Backend::Drive(drive) => Some(&drive.root),
|
Backend::Drive(drive) => Some(&drive.root),
|
||||||
Backend::Site(_) => None,
|
_ => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn site(&self) -> Option<&Site> {
|
pub fn site(&self) -> Option<&Site> {
|
||||||
match self {
|
match self {
|
||||||
Backend::Site(site) => Some(site),
|
Backend::Site(site) => Some(site),
|
||||||
Backend::Drive(_) => None,
|
_ => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl Install {
|
||||||
|
fn set_version(&self, version: &str) {
|
||||||
|
*self.version.lock().unwrap_or_else(|e| e.into_inner()) = version.to_string();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn version_dir(&self) -> PathBuf {
|
||||||
|
let version = self.version.lock().unwrap_or_else(|e| e.into_inner()).clone();
|
||||||
|
self.root.join("versions").join(version)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The newest whole version another install carries. `versions\<v>\manifest.json` is written
|
||||||
|
/// last, so a folder without one is a download that stopped part way and is not offered.
|
||||||
|
fn newest_installed_version(root: &Path) -> Result<(String, Vec<u8>)> {
|
||||||
|
let versions = root.join("versions");
|
||||||
|
let entries = std::fs::read_dir(&versions).map_err(open_error(&versions))?;
|
||||||
|
let mut best: Option<((u64, u64, u64), String)> = None;
|
||||||
|
for entry in entries.flatten() {
|
||||||
|
let name = entry.file_name().to_string_lossy().into_owned();
|
||||||
|
let Ok(parsed) = parse_version(&name) else { continue };
|
||||||
|
if !entry.path().join("manifest.json").is_file() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if best.as_ref().is_none_or(|(best, _)| *best < parsed) {
|
||||||
|
best = Some((parsed, name));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let version = best
|
||||||
|
.ok_or_else(|| Error::NotFound { what: format!("{}: no ODIAN version installed here", versions.display()) })?
|
||||||
|
.1;
|
||||||
|
let path = versions.join(&version).join("manifest.json");
|
||||||
|
let raw = std::fs::read(&path).map_err(open_error(&path))?;
|
||||||
|
Ok((version, raw))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn read_file(path: PathBuf) -> Result<Vec<u8>> {
|
||||||
|
blocking(move || std::fs::read(&path).map_err(open_error(&path))).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `len` bytes from `start` of a local file.
|
||||||
|
pub(crate) async fn open_range(path: PathBuf, start: u64, len: u64) -> Result<Body> {
|
||||||
|
blocking(move || {
|
||||||
|
let mut file = std::fs::File::open(&path).map_err(open_error(&path))?;
|
||||||
|
if start > 0 {
|
||||||
|
file.seek(SeekFrom::Start(start)).map_err(Error::io(&path))?;
|
||||||
|
}
|
||||||
|
Ok(Body::File { reader: file.take(len), path })
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
impl Site {
|
impl Site {
|
||||||
/// GET with the Bearer token. Retries 502/503/504 (honouring Retry-After) and
|
/// GET with the Bearer token. Retries 502/503/504 (honouring Retry-After) and
|
||||||
/// connection errors with a doubling delay, like fetch.py's `Site.open`.
|
/// connection errors with a doubling delay, like fetch.py's `Site.open`.
|
||||||
@@ -326,18 +438,7 @@ impl Site {
|
|||||||
|
|
||||||
impl Drive {
|
impl Drive {
|
||||||
async fn read(&self, path: PathBuf) -> Result<Vec<u8>> {
|
async fn read(&self, path: PathBuf) -> Result<Vec<u8>> {
|
||||||
blocking(move || std::fs::read(&path).map_err(open_error(&path))).await
|
read_file(path).await
|
||||||
}
|
|
||||||
|
|
||||||
async fn open_range(&self, path: PathBuf, start: u64, len: u64) -> Result<Body> {
|
|
||||||
blocking(move || {
|
|
||||||
let mut file = std::fs::File::open(&path).map_err(open_error(&path))?;
|
|
||||||
if start > 0 {
|
|
||||||
file.seek(SeekFrom::Start(start)).map_err(Error::io(&path))?;
|
|
||||||
}
|
|
||||||
Ok(Body::File { reader: file.take(len), path })
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
//!
|
//!
|
||||||
//! `ODIAN_PACKS_PYTHON` picks the interpreter (default `python`).
|
//! `ODIAN_PACKS_PYTHON` picks the interpreter (default `python`).
|
||||||
|
|
||||||
use std::collections::BTreeMap;
|
use std::collections::{BTreeMap, BTreeSet};
|
||||||
use std::io::{BufRead, BufReader};
|
use std::io::{BufRead, BufReader};
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::process::{Child, ChildStdin, ChildStdout, Command, Stdio};
|
use std::process::{Child, ChildStdin, ChildStdout, Command, Stdio};
|
||||||
@@ -290,6 +290,141 @@ async fn site_install_verifies_everything_then_rerun_downloads_nothing() {
|
|||||||
assert!(matches!(version_manifest(&site, "9.9.9", &key).await, Err(Error::NotFound { .. })));
|
assert!(matches!(version_manifest(&site, "9.9.9", &key).await, Err(Error::NotFound { .. })));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||||
|
#[ignore = "spawns Python: set ODIAN_PACKS_PYTHONPATH and run with --ignored (see the file header)"]
|
||||||
|
async fn an_update_reuses_the_installed_files_and_fetches_only_the_db_files_asked_for() {
|
||||||
|
let stub = Stub::start(&[]);
|
||||||
|
let (site, key, dest) = (stub.site(), stub.key(), stub.dest("install"));
|
||||||
|
install_from(&site, &dest, &key, only(&[Stage::App])).await.unwrap();
|
||||||
|
// What is installed becomes an older version: installing the current one is an update.
|
||||||
|
let old = dest.join("versions").join("0.50.0");
|
||||||
|
std::fs::rename(dest.join("versions").join("0.51.0"), &old).unwrap();
|
||||||
|
// One old file went bad on disk: taken from there anyway, it must be caught.
|
||||||
|
let m = stub.store_manifest();
|
||||||
|
let bad = m["app"]["files"].as_array().unwrap().iter().find(|f| f["size"].as_u64().unwrap() > 0).unwrap();
|
||||||
|
let bad_path = bad["path"].as_str().unwrap().to_string();
|
||||||
|
flip(&old.join(&bad_path), 0);
|
||||||
|
let bad_old = std::fs::read(old.join(&bad_path)).unwrap();
|
||||||
|
|
||||||
|
stub.ctl("/_ctl/reset").await;
|
||||||
|
let first_db = m["db"]["files"][0]["path"].as_str().unwrap().to_string();
|
||||||
|
let opts = InstallOptions {
|
||||||
|
only: vec![Stage::App, Stage::Db],
|
||||||
|
db_paths: Some([first_db.clone()].into_iter().collect()),
|
||||||
|
..InstallOptions::default()
|
||||||
|
};
|
||||||
|
let summary = install_from(&site, &dest, &key, opts).await.unwrap();
|
||||||
|
|
||||||
|
let app = summary.app.unwrap();
|
||||||
|
assert_eq!((app.files, app.downloaded, app.reused, app.skipped), (5, 1, 4, 0), "{app:?}");
|
||||||
|
let app_requests: Vec<String> =
|
||||||
|
stub.requests().await.into_iter().map(|(p, _)| p).filter(|p| p.starts_with("/packs/app/")).collect();
|
||||||
|
assert_eq!(app_requests, vec![format!("/packs/app/{}", stub.app_sha(&bad_path))], "only the bad file is downloaded");
|
||||||
|
let new = dest.join("versions").join("0.51.0");
|
||||||
|
for f in m["app"]["files"].as_array().unwrap() {
|
||||||
|
let path = new.join(f["path"].as_str().unwrap());
|
||||||
|
assert_eq!(sha256_hex(&std::fs::read(&path).unwrap()), f["sha256"].as_str().unwrap(), "{}", path.display());
|
||||||
|
}
|
||||||
|
// The download replaced the link; it never wrote through it into the old version.
|
||||||
|
assert_eq!(std::fs::read(old.join(&bad_path)).unwrap(), bad_old);
|
||||||
|
|
||||||
|
let db = summary.db.unwrap();
|
||||||
|
assert_eq!((db.files, db.downloaded), (1, 1));
|
||||||
|
for f in m["db"]["files"].as_array().unwrap() {
|
||||||
|
let path = f["path"].as_str().unwrap();
|
||||||
|
assert_eq!(dest.join("data").join(path).is_file(), path == first_db, "{path}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||||
|
#[ignore = "spawns Python: set ODIAN_PACKS_PYTHONPATH and run with --ignored (see the file header)"]
|
||||||
|
async fn only_the_chosen_tile_sets_and_source_files_are_installed() {
|
||||||
|
let stub = Stub::start(&[]);
|
||||||
|
let (site, key, dest) = (stub.site(), stub.key(), stub.dest("install"));
|
||||||
|
let opts = InstallOptions {
|
||||||
|
only: vec![Stage::Src, Stage::Tiles],
|
||||||
|
tile_sets: Some(BTreeSet::new()),
|
||||||
|
src_paths: Some(["osm_source/small.bin".to_string()].into_iter().collect()),
|
||||||
|
..InstallOptions::default()
|
||||||
|
};
|
||||||
|
let summary = install_from(&site, &dest, &key, opts).await.unwrap();
|
||||||
|
let src: Vec<String> = summary.src.unwrap().into_iter().map(|s| s.path).collect();
|
||||||
|
assert_eq!(src, ["osm_source/small.bin"]);
|
||||||
|
assert!(summary.tiles.unwrap().is_empty());
|
||||||
|
assert!(dest.join("data/src/osm_source/small.bin").is_file());
|
||||||
|
assert!(!dest.join("data/src/osm_source/world_aeroway_v1.sqlite").exists());
|
||||||
|
assert!(!dest.join("data/tiles/dark/test_ds").exists(), "no tile set was chosen");
|
||||||
|
let log = stub.requests().await;
|
||||||
|
assert!(!log.iter().any(|(p, _)| p.contains("/block/")), "no tile block fetched: {log:?}");
|
||||||
|
|
||||||
|
// Choosing the tile set later adds it; what is installed stays as it is.
|
||||||
|
let opts = InstallOptions {
|
||||||
|
only: vec![Stage::Src, Stage::Tiles],
|
||||||
|
tile_sets: Some(["dark/test_ds".to_string()].into_iter().collect()),
|
||||||
|
src_paths: Some(["osm_source/small.bin".to_string()].into_iter().collect()),
|
||||||
|
..InstallOptions::default()
|
||||||
|
};
|
||||||
|
let summary = install_from(&site, &dest, &key, opts).await.unwrap();
|
||||||
|
assert_eq!(summary.tiles.unwrap()[0].downloaded, 4);
|
||||||
|
assert_eq!(summary.src.unwrap()[0].chunks, 0, "the source file was already there");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||||
|
#[ignore = "spawns Python: set ODIAN_PACKS_PYTHONPATH and run with --ignored (see the file header)"]
|
||||||
|
async fn a_pc_installs_by_copying_another_install_and_checks_every_file() {
|
||||||
|
let stub = Stub::start(&[]);
|
||||||
|
let (site, key) = (stub.site(), stub.key());
|
||||||
|
let first = stub.dest("first");
|
||||||
|
install_from(&site, &first, &key, InstallOptions::default()).await.unwrap();
|
||||||
|
|
||||||
|
// The drive an operator carries holds that install, not a store: the second PC copies it.
|
||||||
|
stub.ctl("/_ctl/reset").await;
|
||||||
|
let drive = Source::Install { root: first.clone() };
|
||||||
|
let second = stub.dest("second");
|
||||||
|
let summary = install_from(&drive, &second, &key, InstallOptions::default()).await.unwrap();
|
||||||
|
assert!(stub.requests().await.is_empty(), "nothing was asked of the site");
|
||||||
|
assert_eq!(summary.version, "0.51.0");
|
||||||
|
assert_eq!(files(&second.join("versions").join("0.51.0")), files(&first.join("versions").join("0.51.0")));
|
||||||
|
assert_eq!(files(&second.join("data")), files(&first.join("data")), "same data, tile by tile");
|
||||||
|
let done = second.join("data/tiles/dark/test_ds/.blocks-done.json");
|
||||||
|
assert!(done.is_file(), "the blocks the source holds travel with the tiles");
|
||||||
|
|
||||||
|
// Running it again copies nothing: what is already there is recognised.
|
||||||
|
let again = install_from(&drive, &second, &key, InstallOptions::default()).await.unwrap();
|
||||||
|
assert_eq!(again.app.unwrap().downloaded, 0);
|
||||||
|
assert_eq!(again.tiles.unwrap().iter().map(|t| t.downloaded).sum::<usize>(), 0);
|
||||||
|
|
||||||
|
// A set this PC already recorded as complete is not read again: the marker names this
|
||||||
|
// index and every block of it, so what lies here IS the set. Hundreds of thousands of
|
||||||
|
// files are not hashed twice to conclude "identical", and a tile that changes on the
|
||||||
|
// drive afterwards stays there, as a site update leaves a complete set alone too.
|
||||||
|
let on_drive = first.join("data/tiles/dark/test_ds/0/0/0.png");
|
||||||
|
let here = second.join("data/tiles/dark/test_ds/0/0/0.png");
|
||||||
|
let kept = std::fs::read(&here).unwrap();
|
||||||
|
flip(&on_drive, 3);
|
||||||
|
install_from(&drive, &second, &key, only(&[Stage::Tiles])).await.unwrap();
|
||||||
|
assert_eq!(std::fs::read(&here).unwrap(), kept, "the set was recorded complete: it was left alone");
|
||||||
|
flip(&on_drive, 3);
|
||||||
|
|
||||||
|
// An app file that went bad on the drive is caught: the manifest says what it should be.
|
||||||
|
let third = stub.dest("third");
|
||||||
|
flip(&first.join("versions").join("0.51.0").join("Odian").join("Odian.exe"), 3);
|
||||||
|
let what = integrity(install_from(&drive, &third, &key, InstallOptions::default()).await, IntegrityKind::FileHash);
|
||||||
|
assert!(what.contains("Odian.exe"), "{what}");
|
||||||
|
|
||||||
|
// A TILE cannot be checked that way: only a site's index carries per-tile hashes, and an
|
||||||
|
// install keeps none. What is checked is the copy - the bytes that land are the bytes read.
|
||||||
|
let fourth = stub.dest("fourth");
|
||||||
|
let tile = first.join("data/tiles/dark/test_ds/0/0/0.png");
|
||||||
|
flip(&tile, 3);
|
||||||
|
install_from(&drive, &fourth, &key, only(&[Stage::Tiles])).await.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
std::fs::read(fourth.join("data/tiles/dark/test_ds/0/0/0.png")).unwrap(),
|
||||||
|
std::fs::read(&tile).unwrap(),
|
||||||
|
"faithful to the drive, whatever the drive holds"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||||
#[ignore = "spawns Python: set ODIAN_PACKS_PYTHONPATH and run with --ignored (see the file header)"]
|
#[ignore = "spawns Python: set ODIAN_PACKS_PYTHONPATH and run with --ignored (see the file header)"]
|
||||||
async fn wrong_token_is_unauthorized_and_edited_manifest_is_a_signature_error() {
|
async fn wrong_token_is_unauthorized_and_edited_manifest_is_a_signature_error() {
|
||||||
@@ -480,6 +615,50 @@ async fn cancel_stops_and_keeps_verified_work() {
|
|||||||
assert!(dest.join("versions/0.51.0/manifest.json").exists());
|
assert!(dest.join("versions/0.51.0/manifest.json").exists());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||||
|
#[ignore = "spawns Python: set ODIAN_PACKS_PYTHONPATH and run with --ignored (see the file header)"]
|
||||||
|
async fn a_copy_stopped_halfway_leaves_the_set_partial() {
|
||||||
|
let stub = Stub::start(&[]);
|
||||||
|
let (site, key) = (stub.site(), stub.key());
|
||||||
|
let drive_install = stub.dest("drive");
|
||||||
|
install_from(&site, &drive_install, &key, InstallOptions::default()).await.unwrap();
|
||||||
|
let marker = ".blocks-done.json";
|
||||||
|
let carried = drive_install.join("data/tiles/dark/test_ds");
|
||||||
|
assert!(carried.join(marker).is_file(), "the drive holds the whole set");
|
||||||
|
// A set of a few tiles copies faster than the first progress report: give the drive enough
|
||||||
|
// of them (tiles drawn beyond the index, which travel too) to be stopped in the middle.
|
||||||
|
for x in 0..40 {
|
||||||
|
let dir = carried.join("9").join(x.to_string());
|
||||||
|
std::fs::create_dir_all(&dir).unwrap();
|
||||||
|
for y in 0..10 {
|
||||||
|
std::fs::write(dir.join(format!("{y}.png")), vec![b't'; 4096]).unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop the copy in the middle of the tiles.
|
||||||
|
let drive = Source::Install { root: drive_install.clone() };
|
||||||
|
let manifest = current_manifest(&drive, &key).await.unwrap();
|
||||||
|
let dest = stub.dest("half");
|
||||||
|
let cancel = CancellationToken::new();
|
||||||
|
let trigger = cancel.clone();
|
||||||
|
let result = install(&drive, &dest, &manifest, only(&[Stage::Tiles]), move |p| {
|
||||||
|
if p.stage == Stage::Tiles && p.done_items >= 1 {
|
||||||
|
trigger.cancel();
|
||||||
|
}
|
||||||
|
}, cancel)
|
||||||
|
.await;
|
||||||
|
assert!(matches!(result, Err(Error::Cancelled)), "{result:?}");
|
||||||
|
let set_dir = dest.join("data/tiles/dark/test_ds");
|
||||||
|
// Tiles landed, but nothing here claims the set is whole: the next run copies the rest
|
||||||
|
// instead of trusting a record written before the files it speaks for.
|
||||||
|
assert!(!set_dir.join(marker).exists(), "a half-copied set must not read as complete");
|
||||||
|
|
||||||
|
let summary = install(&drive, &dest, &manifest, only(&[Stage::Tiles]), |_| {}, CancellationToken::new()).await.unwrap();
|
||||||
|
assert!(set_dir.join(marker).is_file(), "finished, so it is recorded");
|
||||||
|
assert!(summary.tiles.unwrap().iter().map(|t| t.downloaded).sum::<usize>() > 0, "the rest was copied");
|
||||||
|
assert_eq!(files(&set_dir), files(&carried), "same set, tile by tile");
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||||
#[ignore = "spawns Python: set ODIAN_PACKS_PYTHONPATH and run with --ignored (see the file header)"]
|
#[ignore = "spawns Python: set ODIAN_PACKS_PYTHONPATH and run with --ignored (see the file header)"]
|
||||||
async fn drive_install_equals_site_install() {
|
async fn drive_install_equals_site_install() {
|
||||||
|
|||||||
+51
-3
@@ -35,6 +35,8 @@ interface Status {
|
|||||||
phase: Phase;
|
phase: Phase;
|
||||||
error: string | null; // set with phase "error"; cleared by the next command or `refresh`
|
error: string | null; // set with phase "error"; cleared by the next command or `refresh`
|
||||||
install_dir: string; // e.g. "D:\\ODIAN"
|
install_dir: string; // e.g. "D:\\ODIAN"
|
||||||
|
portable: boolean; // this launcher ships on an ODIAN drive: it runs that drive's
|
||||||
|
// install (any letter), the folder is fixed, no self-update
|
||||||
sync: SyncStatus | null; // counts from the last sync this session; null before one
|
sync: SyncStatus | null; // counts from the last sync this session; null before one
|
||||||
live: boolean | null; // while ODIAN runs: true = its data comes from the DHP,
|
live: boolean | null; // while ODIAN runs: true = its data comes from the DHP,
|
||||||
// false = from this PC. null when it is not running, or
|
// false = from this PC. null when it is not running, or
|
||||||
@@ -84,6 +86,25 @@ interface StackEvent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface LogEvent { level: "info" | "warn" | "error"; message: string; at: number }
|
interface LogEvent { level: "info" | "warn" | "error"; message: string; at: number }
|
||||||
|
|
||||||
|
interface MarketItem {
|
||||||
|
key: string; // stable across versions: "tiles:<style>/<dataset>" | "src:<path>"
|
||||||
|
kind: "tiles" | "src";
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
bytes: number; // what installing it downloads, in full
|
||||||
|
required: boolean; // part of every install; never offered for removal
|
||||||
|
presence: "installed" | "partial" | "absent"; // partial: a download that stopped
|
||||||
|
selected: boolean; // chosen: installed with the version, kept by updates
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Market {
|
||||||
|
version: string; // the installed version, else the DHP's current one
|
||||||
|
installed: boolean; // false: a choice waits for Install
|
||||||
|
core_bytes: number; // the app and database: what every install downloads besides the items
|
||||||
|
items: MarketItem[]; // required first
|
||||||
|
free_bytes: number | null; // free space where ODIAN is (or will be) installed
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
## Commands (`invoke`)
|
## Commands (`invoke`)
|
||||||
@@ -98,7 +119,7 @@ interface LogEvent { level: "info" | "warn" | "error"; message: string; at: numb
|
|||||||
| `get_notes` | `{ version?: string }` | `string` | Markdown. From the site when online; the cached copy of the installed version offline. **Untrusted: the UI must sanitize.** |
|
| `get_notes` | `{ version?: string }` | `string` | Markdown. From the site when online; the cached copy of the installed version offline. **Untrusted: the UI must sanitize.** |
|
||||||
| `open_external` | `{ url: string }` | `void` | opens an `http`/`https` link in the system browser (links in patch notes); anything else is refused |
|
| `open_external` | `{ url: string }` | `void` | opens an `http`/`https` link in the system browser (links in patch notes); anything else is refused |
|
||||||
| `start_install` | — | `void` | installs or updates to `available`; needs `signed_in` (the site checks the token); progress arrives as events |
|
| `start_install` | — | `void` | installs or updates to `available`; needs `signed_in` (the site checks the token); progress arrives as events |
|
||||||
| `start_install_from_drive` | `{ path: string }` | `void` | same, reading an ODIAN drive (SSD) instead of the site; no sign-in needed |
|
| `start_install_from_drive` | `{ path: string }` | `void` | same, copying from an ODIAN drive (SSD) instead of the site; no sign-in needed. `path` is the drive's root (a pack store) or a folder holding an install, and every file copied is checked against the manifest. A tile set the PC already records as complete for the same index (`.blocks-done.json`) is left as it stands instead of being re-read: that record is written last, so a copy stopped halfway still reads as partial and resumes. The copy lands in the install folder of Settings, so a drive can install onto the PC it is plugged into; an install folder that is the drive's own ODIAN (or inside it) is refused, since there is nothing to copy, and so is a drive carrying an older ODIAN than the one installed there |
|
||||||
| `cancel_install` | — | `void` | stops at the next file boundary; everything already verified is kept |
|
| `cancel_install` | — | `void` | stops at the next file boundary; everything already verified is kept |
|
||||||
| `launch` | — | `void` | starts the local stack; `stack` events follow |
|
| `launch` | — | `void` | starts the local stack; `stack` events follow |
|
||||||
| `stop` | — | `void` | stops Odian.exe's stack cleanly |
|
| `stop` | — | `void` | stops Odian.exe's stack cleanly |
|
||||||
@@ -106,14 +127,41 @@ interface LogEvent { level: "info" | "warn" | "error"; message: string; at: numb
|
|||||||
| `get_settings` | — | `Settings` | |
|
| `get_settings` | — | `Settings` | |
|
||||||
| `set_settings` | `{ settings: Settings }` | `Status` | |
|
| `set_settings` | `{ settings: Settings }` | `Status` | |
|
||||||
| `pick_folder` | — | `string \| null` | native folder picker |
|
| `pick_folder` | — | `string \| null` | native folder picker |
|
||||||
|
| `get_market` | `{ drive?: string }` | `Market` | the marketplace: every tile set and source file of the installed version (read from disk), or of the DHP's current one before the first install. With `drive`, what that drive carries instead (what it cannot install is left out), which is how the UI asks before installing from a drive |
|
||||||
|
| `apply_market` | `{ selected: string[] }` | `void` | saves the choice of optional items (keys), then applies it to the installed version: unchosen items are removed from the PC, chosen missing ones download (phase `installing`, `install-progress` events). Refused while busy or ODIAN runs, and while an update is required; downloading needs the DHP. Before the first install it only saves the choice |
|
||||||
|
| `set_market` | `{ selected: string[], drive?: string }` | `void` | the choice of optional items (keys) for an install about to run; changes nothing installed. Without `drive` it is this PC's standing choice and is saved. With `drive` it filters that one copy and is NOT saved: leaving out a tile set the PC already holds must not read as dropping it, or the next `apply_market` would delete it |
|
||||||
| `sync_now` | — | `void` | pushes and pulls operator data now; only while ODIAN is stopped (phase `syncing` meanwhile) |
|
| `sync_now` | — | `void` | pushes and pulls operator data now; only while ODIAN is stopped (phase `syncing` meanwhile) |
|
||||||
| `get_sync_conflicts` | — | `SyncConflict[]` | what waits for a decision, oldest first; starts the local Postgres just for the read |
|
| `get_sync_conflicts` | — | `SyncConflict[]` | what waits for a decision, oldest first; starts the local Postgres just for the read |
|
||||||
| `resolve_sync_conflict` | `{ tbl, key, choice: "theirs" \| "mine" \| "value" \| "dismiss", value?: unknown }` | `SyncConflict[]` | records one decision and returns what is left. `theirs` takes the DHP's version; `mine` keeps this PC's (the next sync sends it); `value` keeps a field-by-field pick; `dismiss` keeps a refused edit on this PC only. Links and folder placements held with a record follow its decision |
|
| `resolve_sync_conflict` | `{ tbl, key, choice: "theirs" \| "mine" \| "value" \| "dismiss", value?: unknown }` | `SyncConflict[]` | records one decision and returns what is left. `theirs` takes the DHP's version; `mine` keeps this PC's (the next sync sends it); `value` keeps a field-by-field pick; `dismiss` keeps a refused edit on this PC only. Links and folder placements held with a record follow its decision |
|
||||||
|
|
||||||
Errors reject the promise with a string message.
|
Errors reject the promise with a string message.
|
||||||
|
|
||||||
Planned, not yet available: the size of `available` for the Install button, and
|
Planned, not yet available: the size of `available` for the Install button.
|
||||||
inspecting a drive (its version and size) before installing from it.
|
|
||||||
|
## Where a drive's launcher installs
|
||||||
|
|
||||||
|
A launcher that ships on an ODIAN drive (`site.json` with `"portable": true`) keeps its
|
||||||
|
settings beside the exe, so the site and the install folder travel with the drive. The
|
||||||
|
folder may be on the drive itself, and ODIAN then runs from the drive on any PC: it is
|
||||||
|
stored without its letter, because the next PC hands the drive a different one. It may
|
||||||
|
also be a folder on a disk of the PC in front of the operator, and the drive then installs
|
||||||
|
ODIAN onto that PC and leaves; that folder is stored whole. A drive's root and a network
|
||||||
|
share are refused: Postgres and the tiles need a real disk.
|
||||||
|
|
||||||
|
## Marketplace
|
||||||
|
|
||||||
|
The DHP decides what is optional: a manifest tile set or source file with
|
||||||
|
`"optional": true` (plus `title` and `description`, from `catalog.json` at assemble) is
|
||||||
|
left out of Install and listed by `get_market`. Everything else is part of every install.
|
||||||
|
A manifest from before the marketplace flags nothing, so the launcher applies the same rule
|
||||||
|
to it: the app, the database and the `dark` tile set come with every install and the rest is
|
||||||
|
offered, which is what makes copying an older version from a drive a choice rather than all
|
||||||
|
228 GB or nothing. A manifest that flags anything at all is the DHP's to decide and is taken
|
||||||
|
exactly as it stands. The choice is kept in the
|
||||||
|
install's state (`extras`) and survives updates: an update refreshes the chosen items and
|
||||||
|
nothing else. Installing from a drive asks the same way: the marketplace opens on what the
|
||||||
|
drive carries, `set_market` records the answer, and `start_install_from_drive` copies it. A PC installed before the marketplace counts what it has on disk as chosen,
|
||||||
|
so nothing is removed or downloaded again behind the operator's back.
|
||||||
|
|
||||||
## Live mode
|
## Live mode
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ tauri = { version = "2.11.3", features = [] }
|
|||||||
tauri-plugin-log = "2"
|
tauri-plugin-log = "2"
|
||||||
tauri-plugin-opener = "2"
|
tauri-plugin-opener = "2"
|
||||||
tauri-plugin-dialog = "2"
|
tauri-plugin-dialog = "2"
|
||||||
|
tauri-plugin-updater = "2"
|
||||||
tokio = { version = "1", features = ["net", "io-util", "time", "sync", "macros", "rt-multi-thread", "fs"] }
|
tokio = { version = "1", features = ["net", "io-util", "time", "sync", "macros", "rt-multi-thread", "fs"] }
|
||||||
tokio-util = "0.7"
|
tokio-util = "0.7"
|
||||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "stream"] }
|
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "stream"] }
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ use tauri::{AppHandle, State};
|
|||||||
use tauri_plugin_dialog::DialogExt;
|
use tauri_plugin_dialog::DialogExt;
|
||||||
use tauri_plugin_opener::OpenerExt;
|
use tauri_plugin_opener::OpenerExt;
|
||||||
|
|
||||||
use crate::launcher::{Launcher, Status};
|
use crate::launcher::{Launcher, Market, Status};
|
||||||
use crate::settings::Settings;
|
use crate::settings::Settings;
|
||||||
use crate::stack::StackEvent;
|
use crate::stack::StackEvent;
|
||||||
use crate::sync::Conflict;
|
use crate::sync::Conflict;
|
||||||
@@ -21,6 +21,31 @@ pub async fn get_status(launcher: State<'_, Launcher>) -> CmdResult<Status> {
|
|||||||
Ok(launcher.status().await)
|
Ok(launcher.status().await)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The marketplace: every tile set and source file of the version, required ones first. With
|
||||||
|
/// `drive`, what that drive can install instead of the DHP.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_market(app: AppHandle, launcher: State<'_, Launcher>, drive: Option<String>) -> CmdResult<Market> {
|
||||||
|
launcher.market(&app, drive.map(std::path::PathBuf::from)).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Choose the optional items (their keys) for an install about to run, without touching what
|
||||||
|
/// is installed. `drive` names the drive whose items these are.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn set_market(
|
||||||
|
app: AppHandle,
|
||||||
|
launcher: State<'_, Launcher>,
|
||||||
|
selected: Vec<String>,
|
||||||
|
drive: Option<String>,
|
||||||
|
) -> CmdResult<()> {
|
||||||
|
launcher.choose_market(&app, selected, drive.map(std::path::PathBuf::from)).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Choose the optional items (their keys) and apply the choice; see `Launcher::apply_market`.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn apply_market(app: AppHandle, launcher: State<'_, Launcher>, selected: Vec<String>) -> CmdResult<()> {
|
||||||
|
launcher.apply_market(&app, selected).await
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn refresh(app: AppHandle, launcher: State<'_, Launcher>) -> CmdResult<Status> {
|
pub async fn refresh(app: AppHandle, launcher: State<'_, Launcher>) -> CmdResult<Status> {
|
||||||
Ok(launcher.refresh(&app).await)
|
Ok(launcher.refresh(&app).await)
|
||||||
|
|||||||
+654
-13
@@ -1,5 +1,6 @@
|
|||||||
//! The launcher's live state behind every command, and the `status` event.
|
//! The launcher's live state behind every command, and the `status` event.
|
||||||
|
|
||||||
|
use std::collections::BTreeSet;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
use std::sync::atomic::{AtomicU32, Ordering};
|
use std::sync::atomic::{AtomicU32, Ordering};
|
||||||
@@ -13,6 +14,8 @@ use tokio_util::sync::CancellationToken;
|
|||||||
|
|
||||||
use crate::install;
|
use crate::install;
|
||||||
use crate::localdb::{self, Pg};
|
use crate::localdb::{self, Pg};
|
||||||
|
use crate::market;
|
||||||
|
use crate::prune;
|
||||||
use crate::settings::{normalize_url, Settings};
|
use crate::settings::{normalize_url, Settings};
|
||||||
use crate::site::{self, SessionRefresh, TokenCheck};
|
use crate::site::{self, SessionRefresh, TokenCheck};
|
||||||
use crate::stack::{self, Layout, RunningStack, StackConfig, StackEvent};
|
use crate::stack::{self, Layout, RunningStack, StackConfig, StackEvent};
|
||||||
@@ -54,6 +57,9 @@ pub struct Status {
|
|||||||
pub phase: Phase,
|
pub phase: Phase,
|
||||||
pub error: Option<String>,
|
pub error: Option<String>,
|
||||||
pub install_dir: String,
|
pub install_dir: String,
|
||||||
|
/// This launcher ships on an ODIAN drive: it runs that drive's install, so the folder
|
||||||
|
/// cannot be changed and it never updates itself.
|
||||||
|
pub portable: bool,
|
||||||
/// Operator-data sync counts from the last sync this session; None before one.
|
/// Operator-data sync counts from the last sync this session; None before one.
|
||||||
pub sync: Option<SyncStatus>,
|
pub sync: Option<SyncStatus>,
|
||||||
/// While ODIAN runs: true when its data is coming from the DHP, false when from
|
/// While ODIAN runs: true when its data is coming from the DHP, false when from
|
||||||
@@ -61,6 +67,19 @@ pub struct Status {
|
|||||||
pub live: Option<bool>,
|
pub live: Option<bool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// What the marketplace shows (`get_market`).
|
||||||
|
#[derive(Clone, Serialize)]
|
||||||
|
pub struct Market {
|
||||||
|
/// The version the items belong to: the installed one, else the DHP's current one.
|
||||||
|
pub version: String,
|
||||||
|
pub installed: bool,
|
||||||
|
/// The app and database: what every install downloads besides the items.
|
||||||
|
pub core_bytes: u64,
|
||||||
|
pub items: Vec<market::Item>,
|
||||||
|
/// Free space where ODIAN is (or will be) installed; None when it cannot be read.
|
||||||
|
pub free_bytes: Option<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Serialize)]
|
#[derive(Clone, Serialize)]
|
||||||
struct LogEvent {
|
struct LogEvent {
|
||||||
level: &'static str,
|
level: &'static str,
|
||||||
@@ -104,6 +123,16 @@ struct Inner {
|
|||||||
/// during a blip still knows where live mode will forward.
|
/// during a blip still knows where live mode will forward.
|
||||||
engine_url: Option<String>,
|
engine_url: Option<String>,
|
||||||
live: Option<bool>,
|
live: Option<bool>,
|
||||||
|
/// Read once at start: this launcher ships on an ODIAN drive (`settings::portable_root`).
|
||||||
|
portable: bool,
|
||||||
|
/// A newer launcher is being checked for or downloaded (see `update_self`): nothing may
|
||||||
|
/// launch or install meanwhile, since installing it closes this launcher.
|
||||||
|
updating_self: bool,
|
||||||
|
/// What the marketplace picked for the copy about to run from a drive. It filters that one
|
||||||
|
/// copy and is never written down: a tile set left out because this PC already has it must
|
||||||
|
/// not read as "the operator dropped it", or the next Apply in the marketplace would delete
|
||||||
|
/// it. What this PC stands by is what it holds, which `present_extras` reads back.
|
||||||
|
drive_extras: Option<BTreeSet<String>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Inner {
|
impl Inner {
|
||||||
@@ -118,13 +147,18 @@ impl Inner {
|
|||||||
operator: self.state.operator.clone(),
|
operator: self.state.operator.clone(),
|
||||||
// Remembered until Sign out: a launcher session renews the Engine token on its own.
|
// Remembered until Sign out: a launcher session renews the Engine token on its own.
|
||||||
signed_in: self.token.as_ref().is_some_and(|t| t.launcher_token.is_some() || !t.expired()),
|
signed_in: self.token.as_ref().is_some_and(|t| t.launcher_token.is_some() || !t.expired()),
|
||||||
can_launch: installed.is_some() && !update_required && matches!(self.phase, Phase::Idle | Phase::Error),
|
can_launch: installed.is_some()
|
||||||
|
&& !update_required
|
||||||
|
&& !self.updating_self
|
||||||
|
&& matches!(self.phase, Phase::Idle | Phase::Error),
|
||||||
installed,
|
installed,
|
||||||
available: self.available.clone(),
|
available: self.available.clone(),
|
||||||
update_required,
|
update_required,
|
||||||
phase: self.phase,
|
// Updating itself reads as "checking": the window already shows that at every start.
|
||||||
|
phase: if self.updating_self && matches!(self.phase, Phase::Idle | Phase::Error) { Phase::Checking } else { self.phase },
|
||||||
error: self.error.clone(),
|
error: self.error.clone(),
|
||||||
install_dir: self.settings.install_dir.clone(),
|
install_dir: self.settings.install_dir.clone(),
|
||||||
|
portable: self.portable,
|
||||||
sync: self.sync.clone(),
|
sync: self.sync.clone(),
|
||||||
live: self.live,
|
live: self.live,
|
||||||
}
|
}
|
||||||
@@ -141,6 +175,98 @@ pub struct Launcher {
|
|||||||
install_cancel: std::sync::Mutex<Option<CancellationToken>>,
|
install_cancel: std::sync::Mutex<Option<CancellationToken>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// What an operator picked when installing from a drive: the site store an ODIAN drive
|
||||||
|
/// carries (`packs\versions\CURRENT`), or an ODIAN install to copy - the drive itself, or an
|
||||||
|
/// `ODIAN` folder on it, which is how a drive an operator runs from is laid out.
|
||||||
|
fn drive_source(picked: &Path) -> Result<Source, String> {
|
||||||
|
if picked.join("packs").join("versions").join("CURRENT").is_file() {
|
||||||
|
return Ok(Source::Drive { root: picked.to_path_buf() });
|
||||||
|
}
|
||||||
|
for root in [picked.to_path_buf(), picked.join("ODIAN")] {
|
||||||
|
if holds_a_version(&root) {
|
||||||
|
return Ok(Source::Install { root });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(format!(
|
||||||
|
"{} holds no ODIAN: a drive carries either a pack store (packs\\versions\\CURRENT) or an install (versions\\<version>).",
|
||||||
|
picked.display()
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Copying another install into itself copies nothing and would delete what it reads: the
|
||||||
|
/// operator has to say where it goes first. A drive's pack store is different: installing
|
||||||
|
/// from it onto its own drive is how ODIAN runs from an SSD.
|
||||||
|
fn self_copy_problem(source: &Source, into: &Path) -> Option<String> {
|
||||||
|
let Source::Install { root } = source else { return None };
|
||||||
|
let key = |p: &Path| p.to_string_lossy().replace('/', r"\").trim_end_matches('\\').to_lowercase();
|
||||||
|
let (from, target) = (key(root), key(into));
|
||||||
|
(target == from || target.starts_with(&format!(r"{from}\"))).then(|| {
|
||||||
|
format!(
|
||||||
|
"The install folder is {}, which is the ODIAN this drive carries. Set the install folder to where the copy goes \
|
||||||
|
(a folder on this PC, or another folder on the drive) in Settings, then install from the drive.",
|
||||||
|
into.display()
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn version_order(version: &str) -> Option<(u32, u32, u32)> {
|
||||||
|
let mut parts = version.split(['.', '-']).map(|part| part.parse::<u32>().ok());
|
||||||
|
Some((parts.next()??, parts.next()??, parts.next()??))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A drive carrying an older ODIAN than the one installed. Copying it would put the older
|
||||||
|
/// version (and its database schema) over the newer one, which the next prune then removes:
|
||||||
|
/// the operator updates from the DHP instead, or copies into another folder.
|
||||||
|
fn older_version_problem(carried: &str, installed: Option<&str>, into: &Path) -> Option<String> {
|
||||||
|
let installed = installed?;
|
||||||
|
(version_order(carried)? < version_order(installed)?).then(|| {
|
||||||
|
format!(
|
||||||
|
"This drive carries ODIAN {carried}, older than the {installed} installed in {}. Copying it would replace the \
|
||||||
|
newer version: update from your DHP instead, or set the install folder to somewhere else.",
|
||||||
|
into.display()
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A folder holding at least one whole version: `versions\<v>\manifest.json` is written last.
|
||||||
|
fn holds_a_version(root: &Path) -> bool {
|
||||||
|
let Ok(entries) = std::fs::read_dir(root.join("versions")) else { return false };
|
||||||
|
entries.flatten().any(|entry| entry.path().join("manifest.json").is_file())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The launcher's update feed on the download page (written by package_launcher.ps1).
|
||||||
|
const LAUNCHER_FEED: &str = "launcher-update.json";
|
||||||
|
|
||||||
|
/// Append one line to `<dir>/launcher.log`, keeping one older file past 2 MB. Best effort:
|
||||||
|
/// a log that cannot be written never stops the launcher.
|
||||||
|
fn append_log(dir: &Path, level: &str, message: &str) {
|
||||||
|
use std::io::Write;
|
||||||
|
let path = dir.join("launcher.log");
|
||||||
|
if std::fs::metadata(&path).is_ok_and(|md| md.len() > 2_000_000) {
|
||||||
|
let _ = std::fs::rename(&path, dir.join("launcher.log.1"));
|
||||||
|
}
|
||||||
|
let _ = std::fs::create_dir_all(dir);
|
||||||
|
if let Ok(mut file) = std::fs::OpenOptions::new().create(true).append(true).open(&path) {
|
||||||
|
let _ = writeln!(file, "{} {level:5} {message}", utc_text(now_secs()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `2026-09-22 13:05:09Z` from Unix seconds (days to a civil date, H. Hinnant's algorithm).
|
||||||
|
fn utc_text(secs: u64) -> String {
|
||||||
|
let days = (secs / 86_400) as i64;
|
||||||
|
let rest = secs % 86_400;
|
||||||
|
let z = days + 719_468;
|
||||||
|
let era = z.div_euclid(146_097);
|
||||||
|
let doe = z.rem_euclid(146_097);
|
||||||
|
let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
|
||||||
|
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||||
|
let mp = (5 * doy + 2) / 153;
|
||||||
|
let day = doy - (153 * mp + 2) / 5 + 1;
|
||||||
|
let month = if mp < 10 { mp + 3 } else { mp - 9 };
|
||||||
|
let year = yoe + era * 400 + i64::from(month <= 2);
|
||||||
|
format!("{year:04}-{month:02}-{day:02} {:02}:{:02}:{:02}Z", rest / 3_600, rest % 3_600 / 60, rest % 60)
|
||||||
|
}
|
||||||
|
|
||||||
fn now_secs() -> u64 {
|
fn now_secs() -> u64 {
|
||||||
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0)
|
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0)
|
||||||
}
|
}
|
||||||
@@ -156,7 +282,7 @@ impl Launcher {
|
|||||||
.map_err(|e| format!("cannot start the HTTP client: {e}"))?;
|
.map_err(|e| format!("cannot start the HTTP client: {e}"))?;
|
||||||
Ok(Launcher {
|
Ok(Launcher {
|
||||||
http,
|
http,
|
||||||
inner: Mutex::new(Inner { settings, state, online: false, available: None, token, phase: Phase::Checking, error: None, sync: None, engine_url: None, live: None }),
|
inner: Mutex::new(Inner { settings, state, online: false, available: None, token, phase: Phase::Checking, error: None, sync: None, engine_url: None, live: None, portable: crate::settings::portable_drive().is_some(), updating_self: false, drive_extras: None }),
|
||||||
stack: Mutex::new(None),
|
stack: Mutex::new(None),
|
||||||
stack_states: std::sync::Mutex::new(Vec::new()),
|
stack_states: std::sync::Mutex::new(Vec::new()),
|
||||||
sign_in_cancel: Notify::new(),
|
sign_in_cancel: Notify::new(),
|
||||||
@@ -175,7 +301,13 @@ impl Launcher {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn log(&self, app: &AppHandle, level: &'static str, message: impl Into<String>) {
|
fn log(&self, app: &AppHandle, level: &'static str, message: impl Into<String>) {
|
||||||
let _ = app.emit("log", &LogEvent { level, message: message.into(), at: now_secs() });
|
let message = message.into();
|
||||||
|
// Also on disk: the window shows only what arrives once it listens (the first lines of
|
||||||
|
// a start come before that), and an operator's launcher.log explains a problem later.
|
||||||
|
if let Ok(dir) = app.path().app_log_dir() {
|
||||||
|
append_log(&dir, level, &message);
|
||||||
|
}
|
||||||
|
let _ = app.emit("log", &LogEvent { level, message, at: now_secs() });
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn finish(&self, app: &AppHandle, error: Option<String>) {
|
async fn finish(&self, app: &AppHandle, error: Option<String>) {
|
||||||
@@ -383,6 +515,11 @@ impl Launcher {
|
|||||||
if next.install_dir.is_empty() {
|
if next.install_dir.is_empty() {
|
||||||
next.install_dir = inner.settings.install_dir.clone();
|
next.install_dir = inner.settings.install_dir.clone();
|
||||||
}
|
}
|
||||||
|
// A drive's own launcher installs on the drive, to run ODIAN from it, or on a disk of
|
||||||
|
// the PC it is plugged into, to hand ODIAN over. Never a root, never a share.
|
||||||
|
if let Some(problem) = crate::settings::portable_folder_problem(&next.install_path()) {
|
||||||
|
return Err(problem);
|
||||||
|
}
|
||||||
next.save(app)?;
|
next.save(app)?;
|
||||||
if next.install_dir != inner.settings.install_dir {
|
if next.install_dir != inner.settings.install_dir {
|
||||||
inner.state = LauncherState::load(&next.install_path())?;
|
inner.state = LauncherState::load(&next.install_path())?;
|
||||||
@@ -424,15 +561,25 @@ impl Launcher {
|
|||||||
/// Install or update from the DHP (`drive: None`) or from an ODIAN drive.
|
/// Install or update from the DHP (`drive: None`) or from an ODIAN drive.
|
||||||
/// Returns once installing has begun; `install-progress`, `log` and `status` events follow.
|
/// Returns once installing has begun; `install-progress`, `log` and `status` events follow.
|
||||||
pub async fn start_install(&self, app: &AppHandle, drive: Option<PathBuf>) -> Result<(), String> {
|
pub async fn start_install(&self, app: &AppHandle, drive: Option<PathBuf>) -> Result<(), String> {
|
||||||
|
self.start_run(app, drive, false).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `market`: remove the optional items no longer chosen and download the chosen ones for the
|
||||||
|
/// installed version, instead of installing the DHP's current version.
|
||||||
|
async fn start_run(&self, app: &AppHandle, drive: Option<PathBuf>, market: bool) -> Result<(), String> {
|
||||||
{
|
{
|
||||||
let inner = self.inner.lock().await;
|
let inner = self.inner.lock().await;
|
||||||
if !matches!(inner.phase, Phase::Idle | Phase::Error) {
|
if !matches!(inner.phase, Phase::Idle | Phase::Error) {
|
||||||
return Err("The launcher is busy.".to_string());
|
return Err("The launcher is busy.".to_string());
|
||||||
}
|
}
|
||||||
|
if inner.updating_self {
|
||||||
|
return Err("The launcher is updating itself: it restarts in a moment.".to_string());
|
||||||
|
}
|
||||||
match &drive {
|
match &drive {
|
||||||
Some(root) => {
|
Some(root) => {
|
||||||
if !root.join("packs").join("versions").join("CURRENT").is_file() {
|
let source = drive_source(root)?;
|
||||||
return Err(format!("{} is not an ODIAN drive: it has no packs\\versions\\CURRENT.", root.display()));
|
if let Some(problem) = self_copy_problem(&source, &inner.settings.install_path()) {
|
||||||
|
return Err(problem);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
None => {
|
None => {
|
||||||
@@ -448,7 +595,7 @@ impl Launcher {
|
|||||||
}
|
}
|
||||||
let install_dir = {
|
let install_dir = {
|
||||||
let mut inner = self.inner.lock().await;
|
let mut inner = self.inner.lock().await;
|
||||||
if !matches!(inner.phase, Phase::Idle | Phase::Error) {
|
if !matches!(inner.phase, Phase::Idle | Phase::Error) || inner.updating_self {
|
||||||
return Err("The launcher is busy.".to_string());
|
return Err("The launcher is busy.".to_string());
|
||||||
}
|
}
|
||||||
inner.phase = Phase::Installing;
|
inner.phase = Phase::Installing;
|
||||||
@@ -463,7 +610,7 @@ impl Launcher {
|
|||||||
|
|
||||||
let handle = app.clone();
|
let handle = app.clone();
|
||||||
tauri::async_runtime::spawn(async move {
|
tauri::async_runtime::spawn(async move {
|
||||||
handle.state::<Launcher>().run_install(&handle, drive, install_dir, cancel).await;
|
handle.state::<Launcher>().run_install(&handle, drive, install_dir, cancel, market).await;
|
||||||
});
|
});
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -529,7 +676,15 @@ impl Launcher {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn run_install(&self, app: &AppHandle, drive: Option<PathBuf>, install_dir: PathBuf, cancel: CancellationToken) {
|
async fn run_install(&self, app: &AppHandle, drive: Option<PathBuf>, install_dir: PathBuf, cancel: CancellationToken, market: bool) {
|
||||||
|
if market && !self.remove_unchosen(app, &install_dir).await {
|
||||||
|
// Nothing left to download: the removals were the whole change.
|
||||||
|
if let Ok(mut slot) = self.install_cancel.lock() {
|
||||||
|
*slot = None;
|
||||||
|
}
|
||||||
|
self.finish(app, None).await;
|
||||||
|
return;
|
||||||
|
}
|
||||||
// Downloading a whole version from the DHP takes a slot; a drive install takes none.
|
// Downloading a whole version from the DHP takes a slot; a drive install takes none.
|
||||||
let hold = CancellationToken::new();
|
let hold = CancellationToken::new();
|
||||||
if drive.is_none() && !self.wait_for_install_slot(app, &cancel, hold.clone()).await {
|
if drive.is_none() && !self.wait_for_install_slot(app, &cancel, hold.clone()).await {
|
||||||
@@ -541,13 +696,16 @@ impl Launcher {
|
|||||||
let mut renewed = false;
|
let mut renewed = false;
|
||||||
let result = loop {
|
let result = loop {
|
||||||
let source = match &drive {
|
let source = match &drive {
|
||||||
Some(root) => Source::Drive { root: root.clone() },
|
Some(root) => match drive_source(root) {
|
||||||
|
Ok(source) => source,
|
||||||
|
Err(message) => break Err(InstallFailure::Failed(message)),
|
||||||
|
},
|
||||||
None => match self.site_access_token(app).await {
|
None => match self.site_access_token(app).await {
|
||||||
Ok(token) => Source::Site { base_url: self.inner.lock().await.settings.site_url.clone(), token },
|
Ok(token) => Source::Site { base_url: self.inner.lock().await.settings.site_url.clone(), token },
|
||||||
Err(message) => break Err(InstallFailure::Failed(message)),
|
Err(message) => break Err(InstallFailure::Failed(message)),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
match self.install_steps(app, &source, &install_dir, &cancel).await {
|
match self.install_steps(app, &source, &install_dir, &cancel, market).await {
|
||||||
// The site's API restarted (or the token aged out) mid-way: renew once and resume.
|
// The site's API restarted (or the token aged out) mid-way: renew once and resume.
|
||||||
// Everything already verified is skipped on the second pass.
|
// Everything already verified is skipped on the second pass.
|
||||||
Err(InstallFailure::Unauthorized) if drive.is_none() && !renewed => {
|
Err(InstallFailure::Unauthorized) if drive.is_none() && !renewed => {
|
||||||
@@ -566,12 +724,18 @@ impl Launcher {
|
|||||||
*slot = None;
|
*slot = None;
|
||||||
}
|
}
|
||||||
match result {
|
match result {
|
||||||
|
Ok(_) if market => {
|
||||||
|
self.log(app, "info", "The marketplace items are installed.");
|
||||||
|
self.finish(app, None).await;
|
||||||
|
}
|
||||||
Ok(version) => {
|
Ok(version) => {
|
||||||
self.log(app, "info", format!("ODIAN {version} is installed in {}", install_dir.display()));
|
self.log(app, "info", format!("ODIAN {version} is installed in {}", install_dir.display()));
|
||||||
|
self.stop_replaced_db(app, &install_dir, &version).await;
|
||||||
self.finish(app, None).await;
|
self.finish(app, None).await;
|
||||||
if drive.is_none() {
|
if drive.is_none() {
|
||||||
self.refresh(app).await;
|
self.refresh(app).await;
|
||||||
}
|
}
|
||||||
|
self.prune(app).await;
|
||||||
}
|
}
|
||||||
Err(InstallFailure::Cancelled) => {
|
Err(InstallFailure::Cancelled) => {
|
||||||
self.log(app, "warn", "Install cancelled. Everything already verified was kept; installing again resumes from there.");
|
self.log(app, "warn", "Install cancelled. Everything already verified was kept; installing again resumes from there.");
|
||||||
@@ -595,18 +759,46 @@ impl Launcher {
|
|||||||
source: &Source,
|
source: &Source,
|
||||||
install_dir: &Path,
|
install_dir: &Path,
|
||||||
cancel: &CancellationToken,
|
cancel: &CancellationToken,
|
||||||
|
market: bool,
|
||||||
) -> Result<String, InstallFailure> {
|
) -> Result<String, InstallFailure> {
|
||||||
|
if market {
|
||||||
|
return self.install_market_items(app, source, install_dir, cancel).await;
|
||||||
|
}
|
||||||
match source {
|
match source {
|
||||||
Source::Site { base_url, .. } => self.log(app, "info", format!("Reading the current version from {base_url}")),
|
Source::Site { base_url, .. } => self.log(app, "info", format!("Reading the current version from {base_url}")),
|
||||||
Source::Drive { root } => self.log(app, "info", format!("Reading the ODIAN drive at {}", root.display())),
|
Source::Drive { root } => self.log(app, "info", format!("Reading the ODIAN drive at {}", root.display())),
|
||||||
|
Source::Install { root } => {
|
||||||
|
self.log(app, "info", format!("Copying ODIAN from the install on {}", root.display()))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
let verified = odian_packs::current_manifest(source, odian_packs::bundled_public_key()).await?;
|
let verified = odian_packs::current_manifest(source, odian_packs::bundled_public_key()).await?;
|
||||||
let manifest = verified.manifest();
|
let manifest = verified.manifest();
|
||||||
let version = verified.version().to_string();
|
let version = verified.version().to_string();
|
||||||
install::check_runnable(manifest)?;
|
install::check_runnable(manifest)?;
|
||||||
|
if !matches!(source, Source::Site { .. }) {
|
||||||
|
let here = self.inner.lock().await.state.installed_version.clone();
|
||||||
|
if let Some(problem) = older_version_problem(&version, here.as_deref(), install_dir) {
|
||||||
|
return Err(InstallFailure::Failed(problem));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// An ODIAN drive is carried between PCs: its install must not belong to this PC's account.
|
||||||
|
// An install onto the PC itself is that PC's own, and keeps the permissions it has.
|
||||||
|
let portable = self.inner.lock().await.portable;
|
||||||
|
if portable && crate::settings::installs_on_the_drive(install_dir) {
|
||||||
|
let _ = std::fs::create_dir_all(install_dir);
|
||||||
|
if let Err(e) = crate::settings::share_with_every_account(install_dir) {
|
||||||
|
self.log(app, "warn", format!("This drive's install may only be usable by you ({e})."));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The marketplace items the operator chose: for a copy, the popup's answer, used once.
|
||||||
|
let extras = match self.inner.lock().await.drive_extras.take() {
|
||||||
|
Some(chosen) if !matches!(source, Source::Site { .. }) => chosen,
|
||||||
|
_ => self.chosen_extras(install_dir, manifest).await?,
|
||||||
|
};
|
||||||
// A USB key cannot carry this: Postgres restores 16 GB into it, and unplugging the key
|
// A USB key cannot carry this: Postgres restores 16 GB into it, and unplugging the key
|
||||||
// would take the install with it. The default folder already avoids removable drives.
|
// would take the install with it. The default folder already avoids removable drives.
|
||||||
if crate::settings::is_removable_path(install_dir) {
|
// A drive's own launcher installs onto that drive on purpose: the warning is for a PC.
|
||||||
|
if crate::settings::is_removable_path(install_dir) && !portable {
|
||||||
self.log(
|
self.log(
|
||||||
app,
|
app,
|
||||||
"warn",
|
"warn",
|
||||||
@@ -636,7 +828,16 @@ impl Launcher {
|
|||||||
let _ = app.emit("install-progress", &report);
|
let _ = app.emit("install-progress", &report);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
odian_packs::install(source, install_dir, &verified, InstallOptions::default(), progress, cancel.clone()).await?;
|
// Keeping its database, this PC needs only the tables it lacks from a new snapshot: the
|
||||||
|
// rest of it would be gigabytes downloaded and never read.
|
||||||
|
let db_paths = match (&manifest.db, database_restored) {
|
||||||
|
(Some(db), true) => self.db_files_needed(app, install_dir, db).await,
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
let (tile_sets, src_paths) = market::install_filter(manifest, &extras);
|
||||||
|
let options =
|
||||||
|
InstallOptions { db_paths, tile_sets: Some(tile_sets), src_paths: Some(src_paths), ..InstallOptions::default() };
|
||||||
|
odian_packs::install(source, install_dir, &verified, options, progress, cancel.clone()).await?;
|
||||||
let base = match crate_stages.load(Ordering::Relaxed) {
|
let base = match crate_stages.load(Ordering::Relaxed) {
|
||||||
0 => 5,
|
0 => 5,
|
||||||
n => n,
|
n => n,
|
||||||
@@ -727,6 +928,397 @@ impl Launcher {
|
|||||||
Ok(version)
|
Ok(version)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The operator's marketplace choice for `manifest`, saving it when this is the first time:
|
||||||
|
/// whatever optional data is already on the disk counts as chosen, so an install from
|
||||||
|
/// before the marketplace keeps what it has, and a drive seeded from another PC keeps what
|
||||||
|
/// it was given. An empty folder starts with nothing optional.
|
||||||
|
async fn chosen_extras(&self, install_dir: &Path, manifest: &odian_packs::Manifest) -> Result<BTreeSet<String>, String> {
|
||||||
|
let mut inner = self.inner.lock().await;
|
||||||
|
if let Some(extras) = &inner.state.extras {
|
||||||
|
return Ok(extras.clone());
|
||||||
|
}
|
||||||
|
let extras = market::present_extras(manifest, &install_dir.join("data"));
|
||||||
|
inner.state.extras = Some(extras.clone());
|
||||||
|
inner.state.save(install_dir)?;
|
||||||
|
Ok(extras)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The manifest the marketplace lists: a drive's when one is being installed from, else the
|
||||||
|
/// installed version's (from disk), else the DHP's current one, so the optional items can be
|
||||||
|
/// chosen before the first install.
|
||||||
|
async fn market_manifest(&self, app: &AppHandle) -> Result<(odian_packs::Manifest, bool, PathBuf), String> {
|
||||||
|
let (install_dir, installed, site_url) = {
|
||||||
|
let inner = self.inner.lock().await;
|
||||||
|
(inner.settings.install_path(), inner.state.installed_version.clone(), inner.settings.site_url.clone())
|
||||||
|
};
|
||||||
|
if let Some(version) = installed {
|
||||||
|
let path = Layout::new(&install_dir, &version).version_dir().join("manifest.json");
|
||||||
|
let bytes = std::fs::read(&path).map_err(|e| format!("{} is missing ({e}); reinstall this version", path.display()))?;
|
||||||
|
let manifest =
|
||||||
|
serde_json::from_slice(&bytes).map_err(|e| format!("{} is not a valid manifest: {e}", path.display()))?;
|
||||||
|
return Ok((manifest, true, install_dir));
|
||||||
|
}
|
||||||
|
if !self.inner.lock().await.online {
|
||||||
|
return Err("Connect to your DHP to see what it offers.".to_string());
|
||||||
|
}
|
||||||
|
let token = self.site_access_token(app).await?;
|
||||||
|
let source = Source::Site { base_url: site_url, token };
|
||||||
|
let verified =
|
||||||
|
odian_packs::current_manifest(&source, odian_packs::bundled_public_key()).await.map_err(|e| e.to_string())?;
|
||||||
|
Ok((verified.manifest().clone(), false, install_dir))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What the marketplace shows: every tile set and source file, what this PC has, what is
|
||||||
|
/// chosen. With `drive`, it describes what that drive can install instead of the DHP, and
|
||||||
|
/// leaves out what the drive does not carry.
|
||||||
|
pub async fn market(&self, app: &AppHandle, drive: Option<PathBuf>) -> Result<Market, String> {
|
||||||
|
let (manifest, installed, install_dir, source_data) = match drive {
|
||||||
|
Some(picked) => {
|
||||||
|
let source = drive_source(&picked)?;
|
||||||
|
let install_dir = self.inner.lock().await.settings.install_path();
|
||||||
|
if let Some(problem) = self_copy_problem(&source, &install_dir) {
|
||||||
|
return Err(problem);
|
||||||
|
}
|
||||||
|
let verified = odian_packs::current_manifest(&source, odian_packs::bundled_public_key())
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
let here = self.inner.lock().await.state.installed_version.clone();
|
||||||
|
if let Some(problem) = older_version_problem(verified.version(), here.as_deref(), &install_dir) {
|
||||||
|
return Err(problem);
|
||||||
|
}
|
||||||
|
let installed = here.is_some();
|
||||||
|
// A drive's store keeps `tiles\` and `src\` at its root; an install keeps them in `data\`.
|
||||||
|
let data = match &source {
|
||||||
|
Source::Install { root } => root.join("data"),
|
||||||
|
Source::Drive { root } => root.clone(),
|
||||||
|
Source::Site { .. } => install_dir.join("data"),
|
||||||
|
};
|
||||||
|
(verified.manifest().clone(), installed, install_dir, Some(data))
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
let (manifest, installed, install_dir) = self.market_manifest(app).await?;
|
||||||
|
(manifest, installed, install_dir, None)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let data = install_dir.join("data");
|
||||||
|
let chosen = self.inner.lock().await.state.extras.clone();
|
||||||
|
let extras =
|
||||||
|
chosen.unwrap_or_else(|| if installed { market::present_extras(&manifest, &data) } else { BTreeSet::new() });
|
||||||
|
let free_bytes = install_dir.ancestors().find(|dir| dir.exists()).and_then(prune::free_bytes);
|
||||||
|
let core_bytes = manifest.app.files.iter().map(|f| f.size).sum::<u64>()
|
||||||
|
+ manifest.db.iter().flat_map(|db| &db.files).map(|f| f.size).sum::<u64>();
|
||||||
|
Ok(Market {
|
||||||
|
version: manifest.version.clone(),
|
||||||
|
installed,
|
||||||
|
core_bytes,
|
||||||
|
items: market::items_from(&manifest, &data, source_data.as_deref(), &extras),
|
||||||
|
free_bytes,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Save the operator's choice of optional items (`selected`, item keys) and nothing more:
|
||||||
|
/// what an install about to run will bring. `drive` names the source whose items these are.
|
||||||
|
pub async fn choose_market(&self, app: &AppHandle, selected: Vec<String>, drive: Option<PathBuf>) -> Result<(), String> {
|
||||||
|
let from_drive = drive.is_some();
|
||||||
|
let manifest = match drive {
|
||||||
|
Some(picked) => {
|
||||||
|
let source = drive_source(&picked)?;
|
||||||
|
odian_packs::current_manifest(&source, odian_packs::bundled_public_key())
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
.manifest()
|
||||||
|
.clone()
|
||||||
|
}
|
||||||
|
None => self.market_manifest(app).await?.0,
|
||||||
|
};
|
||||||
|
let extras = market::optional_keys(&manifest, selected);
|
||||||
|
{
|
||||||
|
let mut inner = self.inner.lock().await;
|
||||||
|
if from_drive {
|
||||||
|
inner.drive_extras = Some(extras);
|
||||||
|
} else {
|
||||||
|
inner.state.extras = Some(extras);
|
||||||
|
let dir = inner.settings.install_path();
|
||||||
|
inner.state.save(&dir)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.emit(app).await;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Save the operator's choice of optional items (`selected`, item keys), then apply it to
|
||||||
|
/// the installed version: the unchosen items leave this PC, the chosen missing ones are
|
||||||
|
/// downloaded. With nothing installed yet the choice only waits for Install.
|
||||||
|
pub async fn apply_market(&self, app: &AppHandle, selected: Vec<String>) -> Result<(), String> {
|
||||||
|
let (manifest, installed, install_dir) = self.market_manifest(app).await?;
|
||||||
|
let extras = market::optional_keys(&manifest, selected);
|
||||||
|
{
|
||||||
|
let mut inner = self.inner.lock().await;
|
||||||
|
if !matches!(inner.phase, Phase::Idle | Phase::Error) || inner.updating_self {
|
||||||
|
return Err("The launcher is busy: stop ODIAN or let it finish first.".to_string());
|
||||||
|
}
|
||||||
|
if installed && inner.status().update_required {
|
||||||
|
return Err("Update ODIAN first: the marketplace changes the installed version.".to_string());
|
||||||
|
}
|
||||||
|
inner.state.extras = Some(extras.clone());
|
||||||
|
inner.state.save(&install_dir)?;
|
||||||
|
}
|
||||||
|
let data = install_dir.join("data");
|
||||||
|
let adds = market::to_add(&manifest, &data, &extras);
|
||||||
|
if !installed || (adds.is_empty() && market::to_remove(&manifest, &data, &extras).is_empty()) {
|
||||||
|
self.emit(app).await;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
if !adds.is_empty() && !self.inner.lock().await.online {
|
||||||
|
return Err("Your DHP is unreachable: connect to download marketplace items.".to_string());
|
||||||
|
}
|
||||||
|
self.start_run(app, None, true).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove the installed version's optional items the operator no longer chose. Returns
|
||||||
|
/// whether chosen items still need downloading.
|
||||||
|
async fn remove_unchosen(&self, app: &AppHandle, install_dir: &Path) -> bool {
|
||||||
|
let manifest = match self.market_manifest(app).await {
|
||||||
|
Ok((manifest, _, _)) => manifest,
|
||||||
|
Err(message) => {
|
||||||
|
self.log(app, "error", message);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let extras = self.inner.lock().await.state.extras.clone().unwrap_or_default();
|
||||||
|
let data = install_dir.join("data");
|
||||||
|
for item in market::to_remove(&manifest, &data, &extras) {
|
||||||
|
let (m, d, key) = (manifest.clone(), data.clone(), item.key.clone());
|
||||||
|
match tauri::async_runtime::spawn_blocking(move || market::remove(&m, &d, &key)).await {
|
||||||
|
Ok(Ok(())) => self.log(app, "info", format!("Removed {} ({}).", item.title, prune::size_text(item.bytes))),
|
||||||
|
Ok(Err(e)) => self.log(app, "warn", format!("Could not remove {}: {e}", item.title)),
|
||||||
|
Err(e) => self.log(app, "warn", format!("Could not remove {}: {e}", item.title)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
!market::to_add(&manifest, &data, &extras).is_empty()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Download the chosen marketplace items of the installed version (tiles and source files
|
||||||
|
/// only: the app and database are already in place).
|
||||||
|
async fn install_market_items(
|
||||||
|
&self,
|
||||||
|
app: &AppHandle,
|
||||||
|
source: &Source,
|
||||||
|
install_dir: &Path,
|
||||||
|
cancel: &CancellationToken,
|
||||||
|
) -> Result<String, InstallFailure> {
|
||||||
|
let (installed, extras) = {
|
||||||
|
let inner = self.inner.lock().await;
|
||||||
|
(inner.state.installed_version.clone(), inner.state.extras.clone().unwrap_or_default())
|
||||||
|
};
|
||||||
|
let installed = installed.ok_or_else(|| "ODIAN is not installed.".to_string())?;
|
||||||
|
let verified = odian_packs::version_manifest(source, &installed, odian_packs::bundled_public_key()).await?;
|
||||||
|
let manifest = verified.manifest();
|
||||||
|
let titles: Vec<String> =
|
||||||
|
market::to_add(manifest, &install_dir.join("data"), &extras).into_iter().map(|item| item.title).collect();
|
||||||
|
self.log(app, "info", format!("Downloading {}", titles.join(", ")));
|
||||||
|
let (tile_sets, src_paths) = market::install_filter(manifest, &extras);
|
||||||
|
let options = InstallOptions {
|
||||||
|
only: vec![Stage::Src, Stage::Tiles],
|
||||||
|
tile_sets: Some(tile_sets),
|
||||||
|
src_paths: Some(src_paths),
|
||||||
|
..InstallOptions::default()
|
||||||
|
};
|
||||||
|
let progress = {
|
||||||
|
let app = app.clone();
|
||||||
|
move |report: Progress| {
|
||||||
|
let _ = app.emit("install-progress", &report);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
odian_packs::install(source, install_dir, &verified, options, progress, cancel.clone()).await?;
|
||||||
|
Ok(installed)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The files of snapshot `db` this PC's own database needs: the schema dumps and the tables
|
||||||
|
/// it lacks. Asks the database, starting it for the question when it is stopped. `None`
|
||||||
|
/// (every file) whenever it cannot be asked: downloading too much is safe, too little is not.
|
||||||
|
async fn db_files_needed(
|
||||||
|
&self,
|
||||||
|
app: &AppHandle,
|
||||||
|
install_dir: &Path,
|
||||||
|
db: &odian_packs::manifest::DbSection,
|
||||||
|
) -> Option<BTreeSet<String>> {
|
||||||
|
let (installed, password) = {
|
||||||
|
let inner = self.inner.lock().await;
|
||||||
|
(inner.state.installed_version.clone(), inner.state.pg_password.clone())
|
||||||
|
};
|
||||||
|
let installed = installed?;
|
||||||
|
let files: Vec<localdb::DbFile> = db
|
||||||
|
.files
|
||||||
|
.iter()
|
||||||
|
.map(|f| localdb::DbFile { section: f.section.clone(), table: f.table.clone(), path: f.path.clone(), size: f.size })
|
||||||
|
.collect();
|
||||||
|
let pg = Pg::new(&Layout::new(install_dir, &installed), &password);
|
||||||
|
let asked = tauri::async_runtime::spawn_blocking(move || -> Result<(BTreeSet<String>, usize, u64, u64), String> {
|
||||||
|
if !pg.is_initialized() {
|
||||||
|
return Err("it has not been created yet".to_string());
|
||||||
|
}
|
||||||
|
let started_here = !pg.running();
|
||||||
|
if started_here {
|
||||||
|
pg.start()?;
|
||||||
|
}
|
||||||
|
let missing = pg.missing_tables(&files);
|
||||||
|
if started_here {
|
||||||
|
let _ = pg.stop();
|
||||||
|
}
|
||||||
|
let missing = missing?;
|
||||||
|
let needed = localdb::files_needed(&files, &missing);
|
||||||
|
let needed_bytes = files.iter().filter(|f| needed.contains(&f.path)).map(|f| f.size).sum();
|
||||||
|
let all_bytes = files.iter().map(|f| f.size).sum();
|
||||||
|
Ok((needed, missing.len(), needed_bytes, all_bytes))
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
.and_then(|asked| asked);
|
||||||
|
match asked {
|
||||||
|
Ok((needed, missing, needed_bytes, all_bytes)) => {
|
||||||
|
self.log(
|
||||||
|
app,
|
||||||
|
"info",
|
||||||
|
format!(
|
||||||
|
"This PC's database lacks {missing} table(s) of snapshot {}: downloading {} of its {}.",
|
||||||
|
db.snapshot,
|
||||||
|
prune::size_text(needed_bytes),
|
||||||
|
prune::size_text(all_bytes)
|
||||||
|
),
|
||||||
|
);
|
||||||
|
Some(needed)
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
self.log(app, "warn", format!("Could not ask this PC's database which tables it lacks ({e}): downloading the whole snapshot."));
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Replace this launcher with a newer one the download page publishes: fetch it, check it
|
||||||
|
/// is signed with the ODIAN launcher key (tauri.conf.json `plugins.updater.pubkey`), and
|
||||||
|
/// run its installer, which restarts the launcher. Runs first at start; `updating_self`
|
||||||
|
/// keeps anything from launching or installing meanwhile (the window reads "checking").
|
||||||
|
/// Offline, with no page configured, or with anything wrong, this launcher carries on.
|
||||||
|
pub async fn update_self(&self, app: &AppHandle) {
|
||||||
|
use tauri_plugin_updater::UpdaterExt;
|
||||||
|
// A launcher that ships on an ODIAN drive is part of the drive: installing a newer one
|
||||||
|
// would put it on this PC instead, beside an install it does not carry.
|
||||||
|
if self.inner.lock().await.portable {
|
||||||
|
self.log(app, "info", "This launcher runs from its ODIAN drive: it is updated with the drive.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let mcp_url = self.inner.lock().await.settings.mcp_url.clone();
|
||||||
|
let Ok(feed) = url::Url::parse(&format!("{}/download/{LAUNCHER_FEED}", mcp_url.trim_end_matches('/'))) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
{
|
||||||
|
let mut inner = self.inner.lock().await;
|
||||||
|
// At start the phase is "checking" (the first look at the site), never busier.
|
||||||
|
if mcp_url.is_empty() || !matches!(inner.phase, Phase::Idle | Phase::Checking | Phase::Error) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
inner.updating_self = true;
|
||||||
|
}
|
||||||
|
self.emit(app).await;
|
||||||
|
let result = async {
|
||||||
|
let updater = app.updater_builder().endpoints(vec![feed])?.timeout(Duration::from_secs(20)).build()?;
|
||||||
|
let Some(update) = updater.check().await? else { return Ok(()) };
|
||||||
|
self.log(app, "info", format!("Updating the launcher to {}: it restarts by itself when done.", update.version));
|
||||||
|
// On Windows this starts the installer and exits the launcher: it does not return.
|
||||||
|
update.download_and_install(|_, _| {}, || {}).await
|
||||||
|
}
|
||||||
|
.await;
|
||||||
|
match result {
|
||||||
|
Ok(()) => {}
|
||||||
|
// Offline or the page unreachable: the normal case away from the office.
|
||||||
|
Err(tauri_plugin_updater::Error::Reqwest(e)) if e.is_connect() || e.is_timeout() => {}
|
||||||
|
Err(e) => self.log(app, "warn", format!("Could not update the launcher: {e}")),
|
||||||
|
}
|
||||||
|
self.inner.lock().await.updating_self = false;
|
||||||
|
self.emit(app).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stop this PC's database when it is still running the version an update has just
|
||||||
|
/// replaced. Postgres starts a fresh process per connection from the very path it was
|
||||||
|
/// started from, so once that version's folder goes it answers every new connection with
|
||||||
|
/// "could not fork new process for connection" while looking perfectly alive. The next
|
||||||
|
/// launch starts it from the version just installed.
|
||||||
|
async fn stop_replaced_db(&self, app: &AppHandle, install_dir: &Path, version: &str) {
|
||||||
|
let password = self.inner.lock().await.state.pg_password.clone();
|
||||||
|
let pg = Pg::new(&Layout::new(install_dir, version), &password);
|
||||||
|
let stopped = tauri::async_runtime::spawn_blocking(move || match pg.running() {
|
||||||
|
true => pg.stop().map(|()| true),
|
||||||
|
false => Ok(false),
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
match stopped {
|
||||||
|
Ok(Ok(true)) => {
|
||||||
|
self.log(app, "info", "Stopped this PC's database: it was still running the version this update replaced.")
|
||||||
|
}
|
||||||
|
Ok(Err(e)) => self.log(
|
||||||
|
app,
|
||||||
|
"warn",
|
||||||
|
format!("This PC's database is still running the version that was replaced and would not stop ({e}). Close ODIAN and start it again before launching."),
|
||||||
|
),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove what the installed version no longer uses: older versions, older database
|
||||||
|
/// snapshots, their notes (see `prune`). In the background; it never fails anything, and
|
||||||
|
/// a folder still in use is left for the next time.
|
||||||
|
pub async fn prune(&self, app: &AppHandle) {
|
||||||
|
let (install_dir, installed) = {
|
||||||
|
let inner = self.inner.lock().await;
|
||||||
|
(inner.settings.install_path(), inner.state.installed_version.clone())
|
||||||
|
};
|
||||||
|
let Some(installed) = installed else { return };
|
||||||
|
// A running database is running SOME version's postgres.exe, and needs that exact path
|
||||||
|
// for every connection it opens from now on. Whatever is old can wait for a start where
|
||||||
|
// nothing is running (`stop_replaced_db` makes that the normal case after an update).
|
||||||
|
let running = {
|
||||||
|
let password = self.inner.lock().await.state.pg_password.clone();
|
||||||
|
let pg = Pg::new(&Layout::new(&install_dir, &installed), &password);
|
||||||
|
tauri::async_runtime::spawn_blocking(move || pg.running()).await.unwrap_or(false)
|
||||||
|
};
|
||||||
|
if running {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let (dir, version) = (install_dir.clone(), installed.clone());
|
||||||
|
let Ok(outcome) = tauri::async_runtime::spawn_blocking(move || prune::run(&dir, &prune::plan(&dir, &version))).await else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let mut versions = Vec::new();
|
||||||
|
let mut snapshots = Vec::new();
|
||||||
|
for path in &outcome.removed {
|
||||||
|
let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
|
||||||
|
match path.parent().and_then(Path::file_name).map(|n| n.to_string_lossy().into_owned()).as_deref() {
|
||||||
|
Some("versions") if !name.starts_with('.') => versions.push(name),
|
||||||
|
Some("db") if !name.starts_with('.') => snapshots.push(name),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !versions.is_empty() || !snapshots.is_empty() {
|
||||||
|
let mut what = Vec::new();
|
||||||
|
if !versions.is_empty() {
|
||||||
|
what.push(format!("ODIAN {}", versions.join(", ")));
|
||||||
|
}
|
||||||
|
if !snapshots.is_empty() {
|
||||||
|
what.push(format!("database snapshot(s) {}", snapshots.join(", ")));
|
||||||
|
}
|
||||||
|
self.log(
|
||||||
|
app,
|
||||||
|
"info",
|
||||||
|
format!("Removed what ODIAN {installed} no longer uses: {}. {} freed.", what.join("; "), prune::size_text(outcome.freed_bytes)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for (path, why) in &outcome.kept {
|
||||||
|
self.log(app, "warn", format!("Kept {} for now: {why}.", path.display()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── local stack ───────────────────────────────────────────────────────────
|
// ── local stack ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
pub fn stack_states(&self) -> Vec<StackEvent> {
|
pub fn stack_states(&self) -> Vec<StackEvent> {
|
||||||
@@ -1232,3 +1824,52 @@ impl Launcher {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn log_times_read_as_utc_dates() {
|
||||||
|
assert_eq!(utc_text(0), "1970-01-01 00:00:00Z");
|
||||||
|
assert_eq!(utc_text(1_790_082_309), "2026-09-22 13:05:09Z");
|
||||||
|
assert_eq!(utc_text(951_782_400), "2000-02-29 00:00:00Z", "a leap day");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_log_keeps_one_older_file() {
|
||||||
|
let dir = std::env::temp_dir().join(format!("odian-log-test-{}", uuid::Uuid::new_v4().simple()));
|
||||||
|
append_log(&dir, "info", "first");
|
||||||
|
std::fs::write(dir.join("launcher.log"), vec![b'x'; 2_000_001]).unwrap();
|
||||||
|
append_log(&dir, "warn", "after the roll");
|
||||||
|
let current = std::fs::read_to_string(dir.join("launcher.log")).unwrap();
|
||||||
|
assert!(current.ends_with("warn after the roll
|
||||||
|
") && current.lines().count() == 1, "{current}");
|
||||||
|
assert_eq!(std::fs::metadata(dir.join("launcher.log.1")).unwrap().len(), 2_000_001);
|
||||||
|
std::fs::remove_dir_all(&dir).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_drives_install_will_not_be_copied_onto_itself() {
|
||||||
|
let carried = Source::Install { root: PathBuf::from(r"D:\ODIAN") };
|
||||||
|
// The install folder still is the drive's own ODIAN, or a folder inside it.
|
||||||
|
assert!(self_copy_problem(&carried, Path::new(r"D:\ODIAN")).is_some());
|
||||||
|
assert!(self_copy_problem(&carried, Path::new(r"d:\odian\data")).is_some(), "case and depth");
|
||||||
|
// Somewhere else on the PC, or elsewhere on the drive: a real copy.
|
||||||
|
assert!(self_copy_problem(&carried, Path::new(r"C:\ODIAN")).is_none());
|
||||||
|
assert!(self_copy_problem(&carried, Path::new(r"D:\ODIAN copy")).is_none());
|
||||||
|
// A drive's pack store installs onto its own drive: that is ODIAN running from an SSD.
|
||||||
|
let store = Source::Drive { root: PathBuf::from(r"D:\") };
|
||||||
|
assert!(self_copy_problem(&store, Path::new(r"D:\ODIAN")).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_drive_carrying_an_older_odian_will_not_replace_a_newer_install() {
|
||||||
|
let into = Path::new(r"D:\ODIAN");
|
||||||
|
assert!(older_version_problem("0.60.0", Some("0.62.1"), into).is_some());
|
||||||
|
assert!(older_version_problem("0.62.1", Some("0.62.1"), into).is_none(), "the same version reinstalls");
|
||||||
|
assert!(older_version_problem("0.62.1", Some("0.60.0"), into).is_none(), "newer is an update");
|
||||||
|
assert!(older_version_problem("0.9.0", Some("0.10.0"), into).is_some(), "numbers, not text");
|
||||||
|
assert!(older_version_problem("0.60.0", None, into).is_none(), "nothing installed yet");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+16
-2
@@ -6,6 +6,8 @@ mod fsutil;
|
|||||||
mod install;
|
mod install;
|
||||||
mod launcher;
|
mod launcher;
|
||||||
mod localdb;
|
mod localdb;
|
||||||
|
mod market;
|
||||||
|
mod prune;
|
||||||
mod settings;
|
mod settings;
|
||||||
mod site;
|
mod site;
|
||||||
mod stack;
|
mod stack;
|
||||||
@@ -20,6 +22,7 @@ pub fn run() {
|
|||||||
tauri::Builder::default()
|
tauri::Builder::default()
|
||||||
.plugin(tauri_plugin_opener::init())
|
.plugin(tauri_plugin_opener::init())
|
||||||
.plugin(tauri_plugin_dialog::init())
|
.plugin(tauri_plugin_dialog::init())
|
||||||
|
.plugin(tauri_plugin_updater::Builder::new().build())
|
||||||
.setup(|app| {
|
.setup(|app| {
|
||||||
if cfg!(debug_assertions) {
|
if cfg!(debug_assertions) {
|
||||||
app.handle().plugin(
|
app.handle().plugin(
|
||||||
@@ -30,15 +33,26 @@ pub fn run() {
|
|||||||
}
|
}
|
||||||
app.manage(launcher::Launcher::load(app.handle())?);
|
app.manage(launcher::Launcher::load(app.handle())?);
|
||||||
|
|
||||||
// First look at the site in the background; the window shows "checking" meanwhile.
|
// First a newer launcher, if the download page has one (it installs and restarts
|
||||||
|
// this one), then a look at the site; the window shows "checking" meanwhile.
|
||||||
let handle = app.handle().clone();
|
let handle = app.handle().clone();
|
||||||
tauri::async_runtime::spawn(async move {
|
tauri::async_runtime::spawn(async move {
|
||||||
handle.state::<launcher::Launcher>().refresh(&handle).await;
|
let launcher = handle.state::<launcher::Launcher>();
|
||||||
|
launcher.update_self(&handle).await;
|
||||||
|
launcher.refresh(&handle).await;
|
||||||
|
});
|
||||||
|
// And remove what the installed version no longer uses, left by earlier updates.
|
||||||
|
let handle = app.handle().clone();
|
||||||
|
tauri::async_runtime::spawn(async move {
|
||||||
|
handle.state::<launcher::Launcher>().prune(&handle).await;
|
||||||
});
|
});
|
||||||
Ok(())
|
Ok(())
|
||||||
})
|
})
|
||||||
.invoke_handler(tauri::generate_handler![
|
.invoke_handler(tauri::generate_handler![
|
||||||
commands::get_status,
|
commands::get_status,
|
||||||
|
commands::get_market,
|
||||||
|
commands::apply_market,
|
||||||
|
commands::set_market,
|
||||||
commands::refresh,
|
commands::refresh,
|
||||||
commands::sign_in,
|
commands::sign_in,
|
||||||
commands::cancel_sign_in,
|
commands::cancel_sign_in,
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
//! snapshot is restored in the order proven on the Data PC's dumps: pre-data,
|
//! snapshot is restored in the order proven on the Data PC's dumps: pre-data,
|
||||||
//! every table's data (in parallel), sequences, post-data.
|
//! every table's data (in parallel), sequences, post-data.
|
||||||
|
|
||||||
use std::collections::VecDeque;
|
use std::collections::{BTreeSet, VecDeque};
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::process::{Command, Stdio};
|
use std::process::{Command, Stdio};
|
||||||
use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
|
use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
|
||||||
@@ -420,6 +420,18 @@ const ADDING_MARKER: &str = "adding-tables.txt";
|
|||||||
/// copy would make this PC's Engine API skip migrations its schema never had.
|
/// copy would make this PC's Engine API skip migrations its schema never had.
|
||||||
const OWN_DATABASE_ONLY: [&str; 1] = ["public.odian_schema_migrations"];
|
const OWN_DATABASE_ONLY: [&str; 1] = ["public.odian_schema_migrations"];
|
||||||
|
|
||||||
|
/// The files of a snapshot a database lacking only `missing` needs: the schema and sequence
|
||||||
|
/// dumps (small, and adding tables reads them) and the data of the missing tables. The rest
|
||||||
|
/// is data the database already holds. A data dump with no plain table name is kept, the
|
||||||
|
/// safe side of not knowing.
|
||||||
|
pub fn files_needed(files: &[DbFile], missing: &[String]) -> BTreeSet<String> {
|
||||||
|
files
|
||||||
|
.iter()
|
||||||
|
.filter(|f| f.section != "data" || table_of(f).is_none_or(|table| missing.contains(&table)))
|
||||||
|
.map(|f| f.path.clone())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
/// The snapshot's data tables an existing database may lack and be given.
|
/// The snapshot's data tables an existing database may lack and be given.
|
||||||
fn candidate_tables(files: &[DbFile]) -> Vec<String> {
|
fn candidate_tables(files: &[DbFile]) -> Vec<String> {
|
||||||
files
|
files
|
||||||
@@ -565,6 +577,37 @@ CREATE FUNCTION public.hospital_count() RETURNS bigint AS $$ SELECT count(*) FRO
|
|||||||
("postgis_hospital", "TABLE", "public.postgis_hospital".to_string()));
|
("postgis_hospital", "TABLE", "public.postgis_hospital".to_string()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_database_keeping_its_data_needs_only_the_schema_and_the_tables_it_lacks() {
|
||||||
|
let file = |section: &str, table: Option<&str>, path: &str| DbFile {
|
||||||
|
section: section.to_string(),
|
||||||
|
table: table.map(str::to_string),
|
||||||
|
path: path.to_string(),
|
||||||
|
size: 1,
|
||||||
|
};
|
||||||
|
let files = [
|
||||||
|
file("pre-data", None, "db/S/pre-data.dump"),
|
||||||
|
file("data", Some("public.odb_note"), "db/S/data/public.odb_note.dump"),
|
||||||
|
file("data", Some("public.postgis_hospital"), "db/S/data/public.postgis_hospital.dump"),
|
||||||
|
file("data", None, "db/S/data/Weird Name.dump"),
|
||||||
|
file("data", Some("public.odian_schema_migrations"), "db/S/data/public.odian_schema_migrations.dump"),
|
||||||
|
file("sequences", None, "db/S/sequences.dump"),
|
||||||
|
file("post-data", None, "db/S/post-data.dump"),
|
||||||
|
];
|
||||||
|
let needed = files_needed(&files, &["public.postgis_hospital".to_string()]);
|
||||||
|
assert_eq!(
|
||||||
|
needed.into_iter().collect::<Vec<_>>(),
|
||||||
|
[
|
||||||
|
"db/S/data/Weird Name.dump",
|
||||||
|
"db/S/data/public.postgis_hospital.dump",
|
||||||
|
"db/S/post-data.dump",
|
||||||
|
"db/S/pre-data.dump",
|
||||||
|
"db/S/sequences.dump",
|
||||||
|
]
|
||||||
|
);
|
||||||
|
assert_eq!(files_needed(&files, &[]).len(), 4, "nothing missing: the schema dumps and the unnamed one");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_table_is_referenced_whole_never_as_a_prefix() {
|
fn a_table_is_referenced_whole_never_as_a_prefix() {
|
||||||
assert!(references("CREATE INDEX i ON public.postgis_hospital USING gist (geom);", "public.postgis_hospital"));
|
assert!(references("CREATE INDEX i ON public.postgis_hospital USING gist (geom);", "public.postgis_hospital"));
|
||||||
|
|||||||
@@ -0,0 +1,427 @@
|
|||||||
|
//! The marketplace: the tile sets and source files of a version an operator may choose.
|
||||||
|
//!
|
||||||
|
//! The DHP decides what is optional: a manifest item with `"optional": true` (and a title and
|
||||||
|
//! description) is left out of Install and offered here; everything else is part of every
|
||||||
|
//! install. A manifest from before the marketplace flags nothing, and the same rule is applied
|
||||||
|
//! to it here instead (see `pre_catalog`). What the operator chose is
|
||||||
|
//! `LauncherState::extras`, a set of item keys that outlive versions (`tiles:<style>/<dataset>`,
|
||||||
|
//! `src:<path>`), so an update refreshes exactly the chosen items.
|
||||||
|
|
||||||
|
use std::collections::BTreeSet;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use odian_packs::manifest::{Manifest, SrcFile, TileSet};
|
||||||
|
use serde::Serialize;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum Kind {
|
||||||
|
Tiles,
|
||||||
|
Src,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum Presence {
|
||||||
|
/// Every byte of this version's item is on the PC.
|
||||||
|
Installed,
|
||||||
|
/// Part of it is: a download that stopped, or an older version's copy. Installing resumes.
|
||||||
|
Partial,
|
||||||
|
Absent,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One item as the marketplace shows it.
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
|
||||||
|
pub struct Item {
|
||||||
|
pub key: String,
|
||||||
|
pub kind: Kind,
|
||||||
|
pub title: String,
|
||||||
|
pub description: String,
|
||||||
|
/// What installing it downloads, in full.
|
||||||
|
pub bytes: u64,
|
||||||
|
/// Part of every install: shown, never offered for removal.
|
||||||
|
pub required: bool,
|
||||||
|
pub presence: Presence,
|
||||||
|
/// Chosen: installed with the version and kept by updates (required items always are).
|
||||||
|
pub selected: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The map ODIAN opens on, kept required by `catalog.json`.
|
||||||
|
const ALWAYS_INSTALLED_STYLE: &str = "dark";
|
||||||
|
|
||||||
|
/// A manifest from before the marketplace (pack 0.61.0) flags nothing optional, so everything
|
||||||
|
/// in it would be part of every install: copying such a version from a drive would offer no
|
||||||
|
/// choice at all, and the operator would face the whole 228 GB or nothing. The catalog's own
|
||||||
|
/// rule is applied to it instead - the app, the database and the dark map come with every
|
||||||
|
/// install, the rest is offered - so an older version is chosen from like a current one. A
|
||||||
|
/// manifest that flags anything is the DHP's to decide and is taken exactly as it stands.
|
||||||
|
fn pre_catalog(manifest: &Manifest) -> bool {
|
||||||
|
!manifest.tiles.iter().flat_map(|tiles| &tiles.sets).any(|set| set.optional)
|
||||||
|
&& !manifest.src.iter().flat_map(|src| &src.files).any(|file| file.optional)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn tiles_optional(set: &TileSet, pre_catalog: bool) -> bool {
|
||||||
|
set.optional || (pre_catalog && set.style != ALWAYS_INSTALLED_STYLE)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn src_optional(file: &SrcFile, pre_catalog: bool) -> bool {
|
||||||
|
file.optional || pre_catalog
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn tiles_key(set: &TileSet) -> String {
|
||||||
|
format!("tiles:{}/{}", set.style, set.dataset)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn src_key(file: &SrcFile) -> String {
|
||||||
|
format!("src:{}", file.path)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn tile_set_dir(data_dir: &Path, set: &TileSet) -> PathBuf {
|
||||||
|
data_dir.join("tiles").join(&set.style).join(&set.dataset)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn src_path(data_dir: &Path, file: &SrcFile) -> PathBuf {
|
||||||
|
file.path.split('/').fold(data_dir.join("src"), |path, part| path.join(part))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn with_suffix(path: &Path, suffix: &str) -> PathBuf {
|
||||||
|
let mut name = path.as_os_str().to_owned();
|
||||||
|
name.push(suffix);
|
||||||
|
PathBuf::from(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A tile set is complete when the installer's `.blocks-done.json` names this index and lists
|
||||||
|
/// all its blocks; it is partial when its folder exists at all.
|
||||||
|
fn tile_set_presence(data_dir: &Path, set: &TileSet) -> Presence {
|
||||||
|
let dir = tile_set_dir(data_dir, set);
|
||||||
|
let done = std::fs::read(dir.join(".blocks-done.json"))
|
||||||
|
.ok()
|
||||||
|
.and_then(|raw| serde_json::from_slice::<serde_json::Value>(&raw).ok())
|
||||||
|
.filter(|done| done["index_sha256"].as_str() == Some(set.index_sha256.as_str()))
|
||||||
|
.and_then(|done| done["blocks"].as_array().map(Vec::len));
|
||||||
|
match done {
|
||||||
|
Some(blocks) if blocks as u64 >= set.blocks => Presence::Installed,
|
||||||
|
_ if dir.is_dir() => Presence::Partial,
|
||||||
|
_ => Presence::Absent,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A source file is complete when its `.ok` marker names its chunk list and the size is right
|
||||||
|
/// (the installer's own test); partial when the file exists.
|
||||||
|
fn src_presence(data_dir: &Path, file: &SrcFile) -> Presence {
|
||||||
|
let path = src_path(data_dir, file);
|
||||||
|
let marker_ok = std::fs::read_to_string(with_suffix(&path, ".ok")).is_ok_and(|text| text.trim() == file.chunks_sha256);
|
||||||
|
let size_ok = std::fs::metadata(&path).is_ok_and(|md| md.is_file() && md.len() == file.size);
|
||||||
|
match (marker_ok && size_ok, path.exists()) {
|
||||||
|
(true, _) => Presence::Installed,
|
||||||
|
(false, true) => Presence::Partial,
|
||||||
|
(false, false) => Presence::Absent,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every tile set and source file of `manifest`, required ones first, as the marketplace shows
|
||||||
|
/// them. `extras` is the operator's choice.
|
||||||
|
pub fn items(manifest: &Manifest, data_dir: &Path, extras: &BTreeSet<String>) -> Vec<Item> {
|
||||||
|
items_from(manifest, data_dir, None, extras)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The same, for an install that copies another one: an item the source does not carry cannot
|
||||||
|
/// be offered, whatever the version's manifest lists. `presence` stays what THIS PC has.
|
||||||
|
pub fn items_from(
|
||||||
|
manifest: &Manifest,
|
||||||
|
data_dir: &Path,
|
||||||
|
source_data: Option<&Path>,
|
||||||
|
extras: &BTreeSet<String>,
|
||||||
|
) -> Vec<Item> {
|
||||||
|
let mut out = all_items(manifest, data_dir, extras);
|
||||||
|
if let Some(source) = source_data {
|
||||||
|
let carried: BTreeSet<String> = all_items(manifest, source, extras)
|
||||||
|
.into_iter()
|
||||||
|
.filter(|item| item.presence != Presence::Absent)
|
||||||
|
.map(|item| item.key)
|
||||||
|
.collect();
|
||||||
|
out.retain(|item| carried.contains(&item.key));
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn all_items(manifest: &Manifest, data_dir: &Path, extras: &BTreeSet<String>) -> Vec<Item> {
|
||||||
|
let old = pre_catalog(manifest);
|
||||||
|
let mut out = Vec::new();
|
||||||
|
for set in manifest.tiles.iter().flat_map(|tiles| &tiles.sets) {
|
||||||
|
let key = tiles_key(set);
|
||||||
|
let optional = tiles_optional(set, old);
|
||||||
|
out.push(Item {
|
||||||
|
selected: !optional || extras.contains(&key),
|
||||||
|
title: set.title.clone().unwrap_or_else(|| format!("{} map ({})", set.style, set.dataset)),
|
||||||
|
description: set.description.clone().unwrap_or_default(),
|
||||||
|
bytes: set.bytes,
|
||||||
|
required: !optional,
|
||||||
|
presence: tile_set_presence(data_dir, set),
|
||||||
|
kind: Kind::Tiles,
|
||||||
|
key,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for file in manifest.src.iter().filter(|src| src.enabled).flat_map(|src| &src.files) {
|
||||||
|
let key = src_key(file);
|
||||||
|
let optional = src_optional(file, old);
|
||||||
|
out.push(Item {
|
||||||
|
selected: !optional || extras.contains(&key),
|
||||||
|
title: file.title.clone().unwrap_or_else(|| file.path.clone()),
|
||||||
|
description: file.description.clone().unwrap_or_default(),
|
||||||
|
bytes: file.size,
|
||||||
|
required: !optional,
|
||||||
|
presence: src_presence(data_dir, file),
|
||||||
|
kind: Kind::Src,
|
||||||
|
key,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
out.sort_by_key(|item| !item.required);
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What installing `manifest` fetches: every required item and the chosen optional ones, as
|
||||||
|
/// the installer's `tile_sets` (`style/dataset`) and `src_paths` filters.
|
||||||
|
pub fn install_filter(manifest: &Manifest, extras: &BTreeSet<String>) -> (BTreeSet<String>, BTreeSet<String>) {
|
||||||
|
let old = pre_catalog(manifest);
|
||||||
|
let tile_sets = manifest
|
||||||
|
.tiles
|
||||||
|
.iter()
|
||||||
|
.flat_map(|tiles| &tiles.sets)
|
||||||
|
.filter(|set| !tiles_optional(set, old) || extras.contains(&tiles_key(set)))
|
||||||
|
.map(|set| format!("{}/{}", set.style, set.dataset))
|
||||||
|
.collect();
|
||||||
|
let src_paths = manifest
|
||||||
|
.src
|
||||||
|
.iter()
|
||||||
|
.flat_map(|src| &src.files)
|
||||||
|
.filter(|file| !src_optional(file, old) || extras.contains(&src_key(file)))
|
||||||
|
.map(|file| file.path.clone())
|
||||||
|
.collect();
|
||||||
|
(tile_sets, src_paths)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The optional items this PC already has, complete or not. A PC installed before the
|
||||||
|
/// marketplace has all of them: its first choice starts from what is on disk, so nothing is
|
||||||
|
/// removed or downloaded again behind the operator's back.
|
||||||
|
pub fn present_extras(manifest: &Manifest, data_dir: &Path) -> BTreeSet<String> {
|
||||||
|
items(manifest, data_dir, &BTreeSet::new())
|
||||||
|
.into_iter()
|
||||||
|
.filter(|item| !item.required && item.presence != Presence::Absent)
|
||||||
|
.map(|item| item.key)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Keep only the keys of optional items `manifest` offers: a stale or required key means nothing.
|
||||||
|
pub fn optional_keys(manifest: &Manifest, keys: impl IntoIterator<Item = String>) -> BTreeSet<String> {
|
||||||
|
let offered: BTreeSet<String> =
|
||||||
|
items(manifest, Path::new(""), &BTreeSet::new()).into_iter().filter(|item| !item.required).map(|item| item.key).collect();
|
||||||
|
keys.into_iter().filter(|key| offered.contains(key)).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The optional items on this PC the operator no longer wants.
|
||||||
|
pub fn to_remove(manifest: &Manifest, data_dir: &Path, extras: &BTreeSet<String>) -> Vec<Item> {
|
||||||
|
items(manifest, data_dir, extras)
|
||||||
|
.into_iter()
|
||||||
|
.filter(|item| !item.required && !item.selected && item.presence != Presence::Absent)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the chosen optional items still need downloading.
|
||||||
|
pub fn to_add(manifest: &Manifest, data_dir: &Path, extras: &BTreeSet<String>) -> Vec<Item> {
|
||||||
|
items(manifest, data_dir, extras)
|
||||||
|
.into_iter()
|
||||||
|
.filter(|item| !item.required && item.selected && item.presence != Presence::Installed)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove an item's files from the PC: a tile set's folder, or a source file with its markers.
|
||||||
|
pub fn remove(manifest: &Manifest, data_dir: &Path, key: &str) -> Result<(), String> {
|
||||||
|
for set in manifest.tiles.iter().flat_map(|tiles| &tiles.sets) {
|
||||||
|
if tiles_key(set) == key {
|
||||||
|
let dir = tile_set_dir(data_dir, set);
|
||||||
|
return if dir.exists() { crate::prune::remove_folder(&dir) } else { Ok(()) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for file in manifest.src.iter().flat_map(|src| &src.files) {
|
||||||
|
if src_key(file) == key {
|
||||||
|
let path = src_path(data_dir, file);
|
||||||
|
for extra in ["", ".ok", ".chunks.json", ".part"] {
|
||||||
|
let target = with_suffix(&path, extra);
|
||||||
|
if target.exists() {
|
||||||
|
std::fs::remove_file(&target).map_err(|e| format!("cannot remove {}: {e}", target.display()))?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(format!("{key} is not in this version"))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn manifest() -> Manifest {
|
||||||
|
serde_json::from_value(serde_json::json!({
|
||||||
|
"schema": 1, "product": "odian", "version": "0.61.0", "released_at": "2026-09-22T12:00:00Z",
|
||||||
|
"app": {"files": []},
|
||||||
|
"tiles": {"block_shift": 6, "sets": [
|
||||||
|
{"style": "dark", "dataset": "ds", "journal_seq": 1, "blocks": 2, "files": 3, "bytes": 100,
|
||||||
|
"index": "tiles/dark.ds.index.json", "index_sha256": "a".repeat(64), "title": "Dark map"},
|
||||||
|
{"style": "dark_v2", "dataset": "ds", "journal_seq": 1, "blocks": 2, "files": 3, "bytes": 200,
|
||||||
|
"index": "tiles/dark_v2.ds.index.json", "index_sha256": "b".repeat(64), "optional": true,
|
||||||
|
"title": "Dark V2 map", "description": "Buildings."}
|
||||||
|
]},
|
||||||
|
"src": {"enabled": true, "chunk_bytes": 10, "files": [
|
||||||
|
{"path": "osm/x.pmtiles", "size": 4, "chunks": ["c".repeat(64)], "chunks_sha256": "d".repeat(64), "optional": true}
|
||||||
|
]}
|
||||||
|
}))
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A version from before the catalog: nothing in it is flagged.
|
||||||
|
fn old_manifest() -> Manifest {
|
||||||
|
let mut manifest = manifest();
|
||||||
|
manifest.version = "0.60.0".to_string();
|
||||||
|
for set in manifest.tiles.iter_mut().flat_map(|tiles| &mut tiles.sets) {
|
||||||
|
set.optional = false;
|
||||||
|
set.description = None;
|
||||||
|
}
|
||||||
|
for file in manifest.src.iter_mut().flat_map(|src| &mut src.files) {
|
||||||
|
file.optional = false;
|
||||||
|
}
|
||||||
|
manifest
|
||||||
|
}
|
||||||
|
|
||||||
|
fn data_dir() -> PathBuf {
|
||||||
|
std::env::temp_dir().join(format!("odian-market-test-{}", uuid::Uuid::new_v4().simple()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn complete_tile_set(data: &Path, style: &str, index: char, blocks: usize) {
|
||||||
|
let dir = data.join("tiles").join(style).join("ds");
|
||||||
|
std::fs::create_dir_all(&dir).unwrap();
|
||||||
|
let list: Vec<String> = (0..blocks).map(|i| format!("7/{i}/0:x")).collect();
|
||||||
|
let done = serde_json::json!({"index_sha256": index.to_string().repeat(64), "blocks": list});
|
||||||
|
std::fs::write(dir.join(".blocks-done.json"), done.to_string()).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn required_items_come_first_and_the_choice_marks_the_rest() {
|
||||||
|
let data = data_dir();
|
||||||
|
let listed = items(&manifest(), &data, &["src:osm/x.pmtiles".to_string()].into_iter().collect());
|
||||||
|
let summary: Vec<(&str, bool, bool)> = listed.iter().map(|i| (i.key.as_str(), i.required, i.selected)).collect();
|
||||||
|
assert_eq!(summary, [("tiles:dark/ds", true, true), ("tiles:dark_v2/ds", false, false), ("src:osm/x.pmtiles", false, true)]);
|
||||||
|
assert_eq!((listed[1].title.as_str(), listed[1].description.as_str(), listed[1].bytes), ("Dark V2 map", "Buildings.", 200));
|
||||||
|
assert_eq!(listed[2].title, "osm/x.pmtiles", "no title: the path");
|
||||||
|
assert!(listed.iter().all(|i| i.presence == Presence::Absent));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_install_fetches_the_required_items_and_the_chosen_ones() {
|
||||||
|
let (sets, paths) = install_filter(&manifest(), &BTreeSet::new());
|
||||||
|
assert_eq!((sets.into_iter().collect::<Vec<_>>(), paths.len()), (vec!["dark/ds".to_string()], 0));
|
||||||
|
let (sets, paths) = install_filter(&manifest(), &["tiles:dark_v2/ds".to_string()].into_iter().collect());
|
||||||
|
assert_eq!(sets.len(), 2);
|
||||||
|
assert!(paths.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn presence_follows_the_installers_own_markers() {
|
||||||
|
let data = data_dir();
|
||||||
|
let m = manifest();
|
||||||
|
complete_tile_set(&data, "dark_v2", 'b', 2);
|
||||||
|
complete_tile_set(&data, "dark", 'z', 2); // an older index: it resumes, it is not done
|
||||||
|
let src = data.join("src").join("osm").join("x.pmtiles");
|
||||||
|
std::fs::create_dir_all(src.parent().unwrap()).unwrap();
|
||||||
|
std::fs::write(&src, b"PMTi").unwrap();
|
||||||
|
let presence = |key: &str| items(&m, &data, &BTreeSet::new()).into_iter().find(|i| i.key == key).unwrap().presence;
|
||||||
|
assert_eq!(presence("tiles:dark_v2/ds"), Presence::Installed);
|
||||||
|
assert_eq!(presence("tiles:dark/ds"), Presence::Partial);
|
||||||
|
assert_eq!(presence("src:osm/x.pmtiles"), Presence::Partial, "no .ok marker yet");
|
||||||
|
std::fs::write(with_suffix(&src, ".ok"), "d".repeat(64)).unwrap();
|
||||||
|
assert_eq!(presence("src:osm/x.pmtiles"), Presence::Installed);
|
||||||
|
// A PC from before the marketplace keeps what it has.
|
||||||
|
assert_eq!(present_extras(&m, &data).into_iter().collect::<Vec<_>>(), ["src:osm/x.pmtiles", "tiles:dark_v2/ds"]);
|
||||||
|
std::fs::remove_dir_all(&data).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unchosen_items_are_removed_and_chosen_missing_ones_added() {
|
||||||
|
let data = data_dir();
|
||||||
|
let m = manifest();
|
||||||
|
complete_tile_set(&data, "dark_v2", 'b', 2);
|
||||||
|
let src = data.join("src").join("osm").join("x.pmtiles");
|
||||||
|
let chosen: BTreeSet<String> = ["src:osm/x.pmtiles".to_string()].into_iter().collect();
|
||||||
|
let keys = |items: Vec<Item>| items.into_iter().map(|i| i.key).collect::<Vec<_>>();
|
||||||
|
assert_eq!(keys(to_remove(&m, &data, &chosen)), ["tiles:dark_v2/ds"]);
|
||||||
|
assert_eq!(keys(to_add(&m, &data, &chosen)), ["src:osm/x.pmtiles"]);
|
||||||
|
|
||||||
|
remove(&m, &data, "tiles:dark_v2/ds").unwrap();
|
||||||
|
assert!(!data.join("tiles").join("dark_v2").join("ds").exists());
|
||||||
|
std::fs::create_dir_all(src.parent().unwrap()).unwrap();
|
||||||
|
std::fs::write(&src, b"PMTi").unwrap();
|
||||||
|
std::fs::write(with_suffix(&src, ".chunks.json"), b"{}").unwrap();
|
||||||
|
remove(&m, &data, "src:osm/x.pmtiles").unwrap();
|
||||||
|
assert!(!src.exists() && !with_suffix(&src, ".chunks.json").exists());
|
||||||
|
assert!(remove(&m, &data, "tiles:nope/ds").is_err());
|
||||||
|
std::fs::remove_dir_all(&data).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The marketplace a real published manifest gives a real install, after checking its
|
||||||
|
/// signature with the release key. Reads only. `ODIAN_MARKET_MANIFEST=<manifest.json>
|
||||||
|
/// ODIAN_MARKET_DATA=C:\ODIAN\data cargo test -p odian-launcher real_manifest -- --ignored --nocapture`
|
||||||
|
#[test]
|
||||||
|
#[ignore = "reads a real manifest named by ODIAN_MARKET_MANIFEST"]
|
||||||
|
fn show_the_marketplace_of_a_real_manifest() {
|
||||||
|
let raw = std::fs::read(std::env::var("ODIAN_MARKET_MANIFEST").unwrap()).unwrap();
|
||||||
|
let verified = odian_packs::VerifiedManifest::from_bytes(raw, odian_packs::bundled_public_key()).unwrap();
|
||||||
|
let data = std::env::var("ODIAN_MARKET_DATA").map(PathBuf::from).unwrap_or_default();
|
||||||
|
let manifest = verified.manifest();
|
||||||
|
for item in items(manifest, &data, &present_extras(manifest, &data)) {
|
||||||
|
println!(
|
||||||
|
"{:>7.1} GB {:<9} required={:<5} selected={:<5} {}",
|
||||||
|
item.bytes as f64 / 1e9,
|
||||||
|
format!("{:?}", item.presence),
|
||||||
|
item.required,
|
||||||
|
item.selected,
|
||||||
|
item.title
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let (sets, paths) = install_filter(manifest, &BTreeSet::new());
|
||||||
|
println!("a new PC installs: {sets:?} {paths:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn only_offered_optional_keys_are_kept() {
|
||||||
|
let keys = ["tiles:dark/ds", "tiles:dark_v2/ds", "src:gone.pmtiles"].map(str::to_string);
|
||||||
|
assert_eq!(optional_keys(&manifest(), keys).into_iter().collect::<Vec<_>>(), ["tiles:dark_v2/ds"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_version_from_before_the_catalog_is_still_chosen_from() {
|
||||||
|
let items = items(&old_manifest(), Path::new(""), &BTreeSet::new());
|
||||||
|
let required: Vec<&str> = items.iter().filter(|item| item.required).map(|item| item.key.as_str()).collect();
|
||||||
|
// Only the map ODIAN opens on comes with every install; the rest is the operator's to pick.
|
||||||
|
assert_eq!(required, vec!["tiles:dark/ds"]);
|
||||||
|
let offered: Vec<&str> = items.iter().filter(|item| !item.required).map(|item| item.key.as_str()).collect();
|
||||||
|
assert_eq!(offered, vec!["tiles:dark_v2/ds", "src:osm/x.pmtiles"]);
|
||||||
|
// Unchosen, an install of it brings the dark map alone.
|
||||||
|
let (tiles, src) = install_filter(&old_manifest(), &BTreeSet::new());
|
||||||
|
assert_eq!(tiles, BTreeSet::from(["dark/ds".to_string()]));
|
||||||
|
assert!(src.is_empty());
|
||||||
|
// Chosen, they come along.
|
||||||
|
let chosen = BTreeSet::from(["src:osm/x.pmtiles".to_string()]);
|
||||||
|
let (_, src) = install_filter(&old_manifest(), &chosen);
|
||||||
|
assert_eq!(src, BTreeSet::from(["osm/x.pmtiles".to_string()]));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_manifest_that_flags_anything_is_taken_as_it_stands() {
|
||||||
|
// The DHP decides for a catalog version: `dark_v2` is optional there, and nothing else moves.
|
||||||
|
let items = items(&manifest(), Path::new(""), &BTreeSet::new());
|
||||||
|
let required: Vec<&str> = items.iter().filter(|item| item.required).map(|item| item.key.as_str()).collect();
|
||||||
|
assert_eq!(required, vec!["tiles:dark/ds"]);
|
||||||
|
assert!(!pre_catalog(&manifest()) && pre_catalog(&old_manifest()));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,344 @@
|
|||||||
|
//! Removing what the installed version no longer uses.
|
||||||
|
//!
|
||||||
|
//! An update installs a version beside the old one and downloads a new database snapshot
|
||||||
|
//! beside the old one, and nothing ever removed either: every update left gigabytes behind.
|
||||||
|
//! This removes the versions OLDER than the installed one, the database snapshots older
|
||||||
|
//! than the one it names (adding tables later reads that one), and the cached notes of
|
||||||
|
//! those versions. Anything newer stays: it is an update being downloaded, which resumes
|
||||||
|
//! from what is on disk. Tiles and source files are shared by every version and updated in
|
||||||
|
//! place, so there is nothing old of theirs to remove.
|
||||||
|
//!
|
||||||
|
//! A folder is first renamed aside, then emptied. Either all of it goes or none of it does:
|
||||||
|
//! a folder something still uses (a process left running from that version) refuses the
|
||||||
|
//! rename and simply waits for the next time. A half-emptied folder keeps its `.removing-`
|
||||||
|
//! name, and the next run finishes it. Links are removed, never followed.
|
||||||
|
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
const TRASH_PREFIX: &str = ".removing-";
|
||||||
|
|
||||||
|
#[derive(Debug, Default, PartialEq, Eq)]
|
||||||
|
pub struct Plan {
|
||||||
|
/// Older versions, older snapshots, and what an earlier run did not finish removing.
|
||||||
|
pub folders: Vec<PathBuf>,
|
||||||
|
/// The cached release notes of older versions.
|
||||||
|
pub files: Vec<PathBuf>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
pub struct Outcome {
|
||||||
|
pub removed: Vec<PathBuf>,
|
||||||
|
/// Folders left in place this time, with the reason.
|
||||||
|
pub kept: Vec<(PathBuf, String)>,
|
||||||
|
/// Disk space the removal gave back (0 where it cannot be measured).
|
||||||
|
pub freed_bytes: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What removing everything the `installed` version no longer uses would remove.
|
||||||
|
pub fn plan(install_dir: &Path, installed: &str) -> Plan {
|
||||||
|
let mut plan = Plan::default();
|
||||||
|
let Some(current) = parse_version(installed) else { return plan };
|
||||||
|
let versions = install_dir.join("versions");
|
||||||
|
for (name, path) in entries(&versions, true) {
|
||||||
|
if name.starts_with(TRASH_PREFIX) || parse_version(&name).is_some_and(|v| v < current) {
|
||||||
|
plan.folders.push(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let db = install_dir.join("data").join("db");
|
||||||
|
let keep = installed_snapshot(&versions.join(installed).join("manifest.json"));
|
||||||
|
for (name, path) in entries(&db, true) {
|
||||||
|
let older = keep.as_deref().is_some_and(|keep| is_snapshot_id(&name) && name.as_str() < keep);
|
||||||
|
if name.starts_with(TRASH_PREFIX) || older {
|
||||||
|
plan.folders.push(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (name, path) in entries(&install_dir.join("state").join("notes"), false) {
|
||||||
|
if name.strip_suffix(".md").and_then(parse_version).is_some_and(|v| v < current) {
|
||||||
|
plan.files.push(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
plan
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove what `plan` lists. Never fails as a whole: what cannot go now is reported in
|
||||||
|
/// `kept` and planned again next time.
|
||||||
|
pub fn run(install_dir: &Path, plan: &Plan) -> Outcome {
|
||||||
|
let before = free_bytes(install_dir);
|
||||||
|
let mut outcome = Outcome::default();
|
||||||
|
for folder in &plan.folders {
|
||||||
|
match remove_folder(folder) {
|
||||||
|
Ok(()) => outcome.removed.push(folder.clone()),
|
||||||
|
Err(why) => outcome.kept.push((folder.clone(), why)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for file in &plan.files {
|
||||||
|
if std::fs::remove_file(file).is_ok() {
|
||||||
|
outcome.removed.push(file.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
outcome.freed_bytes = match (before, free_bytes(install_dir)) {
|
||||||
|
(Some(before), Some(after)) => after.saturating_sub(before),
|
||||||
|
_ => 0,
|
||||||
|
};
|
||||||
|
outcome
|
||||||
|
}
|
||||||
|
|
||||||
|
/// "1.8 GB", "640 MB".
|
||||||
|
pub fn size_text(bytes: u64) -> String {
|
||||||
|
if bytes >= 1_000_000_000 {
|
||||||
|
format!("{:.1} GB", bytes as f64 / 1e9)
|
||||||
|
} else {
|
||||||
|
format!("{} MB", bytes / 1_000_000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `a.b.c` as numbers, so 0.10.0 sorts after 0.9.0. Anything else is not a version, and
|
||||||
|
/// is never removed.
|
||||||
|
fn parse_version(name: &str) -> Option<(u64, u64, u64)> {
|
||||||
|
let mut parts = name.split('.').map(|part| part.parse::<u64>().ok());
|
||||||
|
let version = (parts.next()??, parts.next()??, parts.next()??);
|
||||||
|
parts.next().is_none().then_some(version)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `20260921T125618Z`: the snapshot ids sort by time as text.
|
||||||
|
fn is_snapshot_id(name: &str) -> bool {
|
||||||
|
let bytes = name.as_bytes();
|
||||||
|
bytes.len() == 16
|
||||||
|
&& bytes[8] == b'T'
|
||||||
|
&& bytes[15] == b'Z'
|
||||||
|
&& bytes[..8].iter().chain(&bytes[9..15]).all(u8::is_ascii_digit)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The snapshot a version's manifest names, if it names one.
|
||||||
|
fn installed_snapshot(manifest: &Path) -> Option<String> {
|
||||||
|
let doc: serde_json::Value = serde_json::from_slice(&std::fs::read(manifest).ok()?).ok()?;
|
||||||
|
doc["db"]["snapshot"].as_str().map(str::to_string)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Folders (or files) directly under `dir`, by name.
|
||||||
|
fn entries(dir: &Path, folders: bool) -> Vec<(String, PathBuf)> {
|
||||||
|
let Ok(read) = std::fs::read_dir(dir) else { return Vec::new() };
|
||||||
|
read
|
||||||
|
.flatten()
|
||||||
|
.filter(|entry| entry.file_type().is_ok_and(|kind| kind.is_dir() == folders && !kind.is_symlink()))
|
||||||
|
.map(|entry| (entry.file_name().to_string_lossy().into_owned(), entry.path()))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove a folder the safe way (renamed aside, then emptied without following links).
|
||||||
|
pub(crate) fn remove_folder(folder: &Path) -> Result<(), String> {
|
||||||
|
let name = folder.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
|
||||||
|
let aside = if name.starts_with(TRASH_PREFIX) {
|
||||||
|
folder.to_path_buf()
|
||||||
|
} else {
|
||||||
|
let nanos = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_nanos()).unwrap_or(0);
|
||||||
|
let aside = folder.with_file_name(format!("{TRASH_PREFIX}{name}-{nanos}"));
|
||||||
|
std::fs::rename(folder, &aside).map_err(|e| format!("still in use ({e})"))?;
|
||||||
|
aside
|
||||||
|
};
|
||||||
|
empty_and_remove(&aside).map_err(|e| format!("partly removed ({e}); the rest goes next time"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn empty_and_remove(dir: &Path) -> std::io::Result<()> {
|
||||||
|
for entry in std::fs::read_dir(dir)? {
|
||||||
|
let path = entry?.path();
|
||||||
|
let meta = std::fs::symlink_metadata(&path)?;
|
||||||
|
if is_link(&meta) {
|
||||||
|
// A junction or symlink: remove the link itself, never what it points at.
|
||||||
|
if is_dir_link(&meta) {
|
||||||
|
std::fs::remove_dir(&path)?;
|
||||||
|
} else {
|
||||||
|
std::fs::remove_file(&path)?;
|
||||||
|
}
|
||||||
|
} else if meta.is_dir() {
|
||||||
|
empty_and_remove(&path)?;
|
||||||
|
} else {
|
||||||
|
if meta.permissions().readonly() {
|
||||||
|
let mut writable = meta.permissions();
|
||||||
|
#[allow(clippy::permissions_set_readonly_false)]
|
||||||
|
writable.set_readonly(false);
|
||||||
|
std::fs::set_permissions(&path, writable)?;
|
||||||
|
}
|
||||||
|
std::fs::remove_file(&path)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
std::fs::remove_dir(dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
fn is_link(meta: &std::fs::Metadata) -> bool {
|
||||||
|
use std::os::windows::fs::MetadataExt;
|
||||||
|
const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x400;
|
||||||
|
meta.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
fn is_dir_link(meta: &std::fs::Metadata) -> bool {
|
||||||
|
use std::os::windows::fs::MetadataExt;
|
||||||
|
const FILE_ATTRIBUTE_DIRECTORY: u32 = 0x10;
|
||||||
|
meta.file_attributes() & FILE_ATTRIBUTE_DIRECTORY != 0
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
fn is_link(meta: &std::fs::Metadata) -> bool {
|
||||||
|
meta.file_type().is_symlink()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
fn is_dir_link(_meta: &std::fs::Metadata) -> bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
pub(crate) fn free_bytes(path: &Path) -> Option<u64> {
|
||||||
|
use std::os::windows::ffi::OsStrExt;
|
||||||
|
use windows_sys::Win32::Storage::FileSystem::GetDiskFreeSpaceExW;
|
||||||
|
let wide: Vec<u16> = path.as_os_str().encode_wide().chain(std::iter::once(0)).collect();
|
||||||
|
let mut free = 0u64;
|
||||||
|
// SAFETY: a NUL-terminated path and a valid out pointer; the other outputs are optional.
|
||||||
|
let ok = unsafe { GetDiskFreeSpaceExW(wide.as_ptr(), &mut free, std::ptr::null_mut(), std::ptr::null_mut()) };
|
||||||
|
(ok != 0).then_some(free)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
pub(crate) fn free_bytes(_path: &Path) -> Option<u64> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn install() -> PathBuf {
|
||||||
|
let root = std::env::temp_dir().join(format!("odian-prune-test-{}", uuid::Uuid::new_v4().simple()));
|
||||||
|
for version in ["0.9.0", "0.10.0", "0.11.0", "0.12.0"] {
|
||||||
|
let dir = root.join("versions").join(version);
|
||||||
|
std::fs::create_dir_all(dir.join("runtime")).unwrap();
|
||||||
|
std::fs::write(dir.join("runtime").join("python.exe"), b"MZ").unwrap();
|
||||||
|
if version != "0.12.0" {
|
||||||
|
std::fs::write(dir.join("manifest.json"), b"{}").unwrap(); // 0.12.0: an update half-downloaded
|
||||||
|
}
|
||||||
|
std::fs::create_dir_all(root.join("state").join("notes")).unwrap();
|
||||||
|
std::fs::write(root.join("state").join("notes").join(format!("{version}.md")), b"# notes").unwrap();
|
||||||
|
}
|
||||||
|
std::fs::write(
|
||||||
|
root.join("versions").join("0.11.0").join("manifest.json"),
|
||||||
|
br#"{"db": {"snapshot": "20260921T125618Z"}}"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
for snapshot in ["20260915T082529Z", "20260921T125618Z", "20260930T080000Z"] {
|
||||||
|
let dir = root.join("data").join("db").join(snapshot).join("data");
|
||||||
|
std::fs::create_dir_all(&dir).unwrap();
|
||||||
|
std::fs::write(dir.join("public.t.dump"), b"PGDMP").unwrap();
|
||||||
|
}
|
||||||
|
std::fs::create_dir_all(root.join("versions").join("tools")).unwrap(); // not a version: never touched
|
||||||
|
std::fs::create_dir_all(root.join("data").join("tiles").join("dark")).unwrap();
|
||||||
|
root
|
||||||
|
}
|
||||||
|
|
||||||
|
fn names(paths: &[PathBuf]) -> Vec<String> {
|
||||||
|
let mut names: Vec<String> =
|
||||||
|
paths.iter().map(|p| p.file_name().unwrap().to_string_lossy().into_owned()).collect();
|
||||||
|
names.sort();
|
||||||
|
names
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn only_what_is_older_than_the_installed_version_goes() {
|
||||||
|
let root = install();
|
||||||
|
let plan = plan(&root, "0.11.0");
|
||||||
|
// 0.10.0 sorts before 0.11.0 as numbers; as text it would sort after 0.9.0 only by luck.
|
||||||
|
assert_eq!(names(&plan.folders), ["0.10.0", "0.9.0", "20260915T082529Z"]);
|
||||||
|
assert_eq!(names(&plan.files), ["0.10.0.md", "0.9.0.md"]);
|
||||||
|
|
||||||
|
let outcome = run(&root, &plan);
|
||||||
|
assert!(outcome.kept.is_empty(), "{:?}", outcome.kept);
|
||||||
|
let versions = names(&entries(&root.join("versions"), true).into_iter().map(|(_, p)| p).collect::<Vec<_>>());
|
||||||
|
assert_eq!(versions, ["0.11.0", "0.12.0", "tools"], "installed, the update in progress, and a stranger stay");
|
||||||
|
let snapshots = names(&entries(&root.join("data").join("db"), true).into_iter().map(|(_, p)| p).collect::<Vec<_>>());
|
||||||
|
assert_eq!(snapshots, ["20260921T125618Z", "20260930T080000Z"]);
|
||||||
|
assert!(root.join("data").join("tiles").join("dark").is_dir());
|
||||||
|
assert_eq!(super::plan(&root, "0.11.0"), Plan::default(), "a second run has nothing left to do");
|
||||||
|
std::fs::remove_dir_all(&root).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_version_without_a_snapshot_leaves_every_snapshot_alone() {
|
||||||
|
let root = install();
|
||||||
|
std::fs::write(root.join("versions").join("0.11.0").join("manifest.json"), b"{}").unwrap();
|
||||||
|
assert_eq!(names(&plan(&root, "0.11.0").folders), ["0.10.0", "0.9.0"]);
|
||||||
|
assert_eq!(plan(&root, "not-a-version"), Plan::default());
|
||||||
|
std::fs::remove_dir_all(&root).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_unfinished_removal_is_finished() {
|
||||||
|
let root = install();
|
||||||
|
let trash = root.join("versions").join(".removing-0.8.0-1");
|
||||||
|
std::fs::create_dir_all(trash.join("deep")).unwrap();
|
||||||
|
std::fs::write(trash.join("deep").join("left.bin"), b"x").unwrap();
|
||||||
|
let plan = plan(&root, "0.9.0");
|
||||||
|
assert_eq!(names(&plan.folders), [".removing-0.8.0-1"]);
|
||||||
|
run(&root, &plan);
|
||||||
|
assert!(!trash.exists());
|
||||||
|
std::fs::remove_dir_all(&root).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
#[test]
|
||||||
|
fn a_link_inside_is_removed_without_touching_what_it_points_at() {
|
||||||
|
let root = install();
|
||||||
|
let shared = root.join("data").join("tiles");
|
||||||
|
std::fs::write(shared.join("dark").join("0.png"), b"PNG").unwrap();
|
||||||
|
junction::create(&shared, root.join("versions").join("0.9.0").join("tiles")).unwrap();
|
||||||
|
let plan = plan(&root, "0.11.0");
|
||||||
|
let outcome = run(&root, &plan);
|
||||||
|
assert!(outcome.kept.is_empty(), "{:?}", outcome.kept);
|
||||||
|
assert!(!root.join("versions").join("0.9.0").exists());
|
||||||
|
assert_eq!(std::fs::read(shared.join("dark").join("0.png")).unwrap(), b"PNG", "the link's target survives");
|
||||||
|
std::fs::remove_dir_all(&root).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
#[test]
|
||||||
|
fn a_folder_in_use_is_kept_whole_for_next_time() {
|
||||||
|
let root = install();
|
||||||
|
let busy = root.join("versions").join("0.9.0").join("runtime").join("python.exe");
|
||||||
|
let holder = std::fs::OpenOptions::new().read(true).open(&busy).unwrap(); // no delete sharing
|
||||||
|
let plan = plan(&root, "0.11.0");
|
||||||
|
let outcome = run(&root, &plan);
|
||||||
|
assert_eq!(names(&outcome.kept.iter().map(|(p, _)| p.clone()).collect::<Vec<_>>()), ["0.9.0"]);
|
||||||
|
assert!(busy.is_file(), "nothing of it was removed");
|
||||||
|
drop(holder);
|
||||||
|
run(&root, &super::plan(&root, "0.11.0"));
|
||||||
|
assert!(!root.join("versions").join("0.9.0").exists(), "and it goes the next time");
|
||||||
|
std::fs::remove_dir_all(&root).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sizes_read_like_the_launcher_window() {
|
||||||
|
assert_eq!(size_text(1_840_000_000), "1.8 GB");
|
||||||
|
assert_eq!(size_text(640_000_000), "640 MB");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn names_that_are_not_versions_or_snapshots_are_not_mistaken_for_them() {
|
||||||
|
assert_eq!(parse_version("0.10.0"), Some((0, 10, 0)));
|
||||||
|
assert!(parse_version("0.10").is_none() && parse_version("0.10.0.1").is_none() && parse_version("v0.1.0").is_none());
|
||||||
|
assert!(is_snapshot_id("20260921T125618Z"));
|
||||||
|
assert!(!is_snapshot_id("20260921T125618") && !is_snapshot_id("2026092aT125618Z"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What the launcher would remove from the real install on this PC; removes nothing.
|
||||||
|
/// `ODIAN_PRUNE_ROOT=C:\ODIAN ODIAN_PRUNE_VERSION=0.60.0 cargo test prune -- --ignored --nocapture`
|
||||||
|
#[test]
|
||||||
|
#[ignore = "reads a real install named by ODIAN_PRUNE_ROOT"]
|
||||||
|
fn show_the_plan_for_a_real_install() {
|
||||||
|
let root = PathBuf::from(std::env::var("ODIAN_PRUNE_ROOT").unwrap());
|
||||||
|
let version = std::env::var("ODIAN_PRUNE_VERSION").unwrap();
|
||||||
|
let plan = plan(&root, &version);
|
||||||
|
for path in plan.folders.iter().chain(&plan.files) {
|
||||||
|
println!("would remove {}", path.display());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+209
-15
@@ -34,13 +34,28 @@ struct SiteFile {
|
|||||||
site_url: String,
|
site_url: String,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
mcp_url: String,
|
mcp_url: String,
|
||||||
|
/// This launcher ships on an ODIAN drive: see `portable_root`.
|
||||||
|
#[serde(default)]
|
||||||
|
portable: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Where the settings are kept: beside a drive's own launcher, so the site and the install
|
||||||
|
/// folder travel with the drive; in this PC's config folder for a launcher installed on it.
|
||||||
fn settings_path(app: &AppHandle) -> Result<PathBuf, String> {
|
fn settings_path(app: &AppHandle) -> Result<PathBuf, String> {
|
||||||
|
if let Some(beside) = portable_settings_path() {
|
||||||
|
return Ok(beside);
|
||||||
|
}
|
||||||
let dir = app.path().app_config_dir().map_err(|e| format!("no config dir: {e}"))?;
|
let dir = app.path().app_config_dir().map_err(|e| format!("no config dir: {e}"))?;
|
||||||
Ok(dir.join("settings.json"))
|
Ok(dir.join("settings.json"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn portable_settings_path() -> Option<PathBuf> {
|
||||||
|
if !read_site_file().portable {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(std::env::current_exe().ok()?.parent()?.join("settings.json"))
|
||||||
|
}
|
||||||
|
|
||||||
/// Is this drive a disk built into the PC, rather than a USB key or a card?
|
/// Is this drive a disk built into the PC, rather than a USB key or a card?
|
||||||
///
|
///
|
||||||
/// A key plugged into a laptop takes D:, and ODIAN would install itself onto it: the
|
/// A key plugged into a laptop takes D:, and ODIAN would install itself onto it: the
|
||||||
@@ -114,6 +129,108 @@ fn read_site_file() -> SiteFile {
|
|||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The drive a launcher that ships on an ODIAN drive runs from, when it is one. The install
|
||||||
|
/// lives on THAT drive: the operator picks the folder, but the drive letter is whatever this
|
||||||
|
/// PC hands the drive, so the folder is re-read against it at every start.
|
||||||
|
/// None for a launcher installed on a PC, which keeps the folder the operator chose.
|
||||||
|
pub fn portable_drive() -> Option<PathBuf> {
|
||||||
|
portable_drive_of(&std::env::current_exe().ok()?, read_site_file().portable)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `<drive of the exe>\`, or None when this launcher is not a drive's own.
|
||||||
|
fn portable_drive_of(exe: &Path, portable: bool) -> Option<PathBuf> {
|
||||||
|
if !portable {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
drive_of(exe)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `C:\` for `C:\a\b`. None for a path with no drive letter (a network share, another OS):
|
||||||
|
/// an install needs a real disk for Postgres and its 200 GB of tiles.
|
||||||
|
fn drive_of(path: &Path) -> Option<PathBuf> {
|
||||||
|
match path.components().next()? {
|
||||||
|
std::path::Component::Prefix(prefix)
|
||||||
|
if matches!(prefix.kind(), std::path::Prefix::Disk(_) | std::path::Prefix::VerbatimDisk(_)) =>
|
||||||
|
{
|
||||||
|
Some(PathBuf::from(format!("{}\\", prefix.as_os_str().to_string_lossy())))
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The same folder on `drive`: `D:\ODIAN\x` on `E:\` is `E:\ODIAN\x`. A drive gets a
|
||||||
|
/// different letter on every PC, and the folder an operator chose has to follow it.
|
||||||
|
fn on_drive(folder: &Path, drive: &Path) -> PathBuf {
|
||||||
|
match drive_of(folder) {
|
||||||
|
Some(_) => drive.join(folder.components().skip(1).collect::<PathBuf>()),
|
||||||
|
None => drive.join(folder),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where a portable launcher may install: on its own drive (ODIAN runs from the drive), or
|
||||||
|
/// on a disk of the PC it is plugged into (the drive hands ODIAN over and leaves). Never a
|
||||||
|
/// drive's root, and never a network share: Postgres and 200 GB of tiles need a real disk.
|
||||||
|
pub fn portable_folder_problem(folder: &Path) -> Option<String> {
|
||||||
|
folder_problem_on(folder, portable_drive()?.as_path())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn folder_problem_on(folder: &Path, drive: &Path) -> Option<String> {
|
||||||
|
match drive_of(folder) {
|
||||||
|
Some(disk) if folder == disk => Some(format!(
|
||||||
|
"{} is a drive's root: install into a folder on it, like {}ODIAN.",
|
||||||
|
folder.display(),
|
||||||
|
disk.display()
|
||||||
|
)),
|
||||||
|
Some(_) => None,
|
||||||
|
None => Some(format!(
|
||||||
|
"The install folder must be a folder on a disk: on the ODIAN drive {} to run ODIAN from it, \
|
||||||
|
or on this PC's own disk to install onto the PC.",
|
||||||
|
drive.display()
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Is this folder on the drive the launcher runs from? Only then does the install travel,
|
||||||
|
/// and only then is it worth opening to every account of every PC the drive is plugged into.
|
||||||
|
pub fn installs_on_the_drive(folder: &Path) -> bool {
|
||||||
|
portable_drive().is_some_and(|drive| drive_of(folder) == Some(drive))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How the folder is written down beside a drive's own launcher. A folder on the drive itself
|
||||||
|
/// is stored without the letter (`ODIAN`, `field kit\ODIAN`): the next PC hands the drive a
|
||||||
|
/// different one. A folder on a PC's disk is stored whole, and names that PC's disk wherever
|
||||||
|
/// the drive lands.
|
||||||
|
fn stored_install_dir(folder: &str, drive: &Path) -> String {
|
||||||
|
let path = Path::new(folder);
|
||||||
|
match drive_of(path) {
|
||||||
|
Some(disk) if disk == drive => path.strip_prefix(&disk).unwrap_or(path).display().to_string(),
|
||||||
|
_ => folder.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Let every Windows account use an ODIAN drive's install: NTFS permissions are per account,
|
||||||
|
/// so a drive prepared on one PC is otherwise unreadable on the next. The grant is inheritable,
|
||||||
|
/// so everything installed into it afterwards carries it. Best effort: an install on a PC's own
|
||||||
|
/// disk keeps the permissions it had, and a drive that refuses the change still installs.
|
||||||
|
#[cfg(windows)]
|
||||||
|
pub fn share_with_every_account(dir: &Path) -> Result<(), String> {
|
||||||
|
use std::os::windows::process::CommandExt;
|
||||||
|
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
|
||||||
|
// BUILTIN\Users by SID: the group's name is localised (Utilisateurs, Benutzer, ...).
|
||||||
|
let status = std::process::Command::new("icacls")
|
||||||
|
.arg(dir)
|
||||||
|
.args(["/grant", "*S-1-5-32-545:(OI)(CI)F", "/Q"])
|
||||||
|
.creation_flags(CREATE_NO_WINDOW)
|
||||||
|
.status()
|
||||||
|
.map_err(|e| format!("cannot run icacls: {e}"))?;
|
||||||
|
status.success().then_some(()).ok_or_else(|| format!("icacls refused {}", dir.display()))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
pub fn share_with_every_account(_dir: &Path) -> Result<(), String> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
pub fn normalize_url(url: &str) -> String {
|
pub fn normalize_url(url: &str) -> String {
|
||||||
url.trim().trim_end_matches('/').to_string()
|
url.trim().trim_end_matches('/').to_string()
|
||||||
}
|
}
|
||||||
@@ -121,30 +238,107 @@ pub fn normalize_url(url: &str) -> String {
|
|||||||
impl Settings {
|
impl Settings {
|
||||||
pub fn load(app: &AppHandle) -> Result<Settings, String> {
|
pub fn load(app: &AppHandle) -> Result<Settings, String> {
|
||||||
let path = settings_path(app)?;
|
let path = settings_path(app)?;
|
||||||
if let Ok(bytes) = std::fs::read(&path) {
|
let mut settings = match std::fs::read(&path) {
|
||||||
let mut settings: Settings =
|
Ok(bytes) => {
|
||||||
serde_json::from_slice(&bytes).map_err(|e| format!("{} is not valid settings JSON: {e}", path.display()))?;
|
let mut settings: Settings =
|
||||||
if settings.install_dir.trim().is_empty() {
|
serde_json::from_slice(&bytes).map_err(|e| format!("{} is not valid settings JSON: {e}", path.display()))?;
|
||||||
settings.install_dir = default_install_dir();
|
if settings.install_dir.trim().is_empty() {
|
||||||
|
settings.install_dir = default_install_dir();
|
||||||
|
}
|
||||||
|
settings
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
let site = read_site_file();
|
||||||
|
let settings = Settings {
|
||||||
|
site_url: normalize_url(&site.site_url),
|
||||||
|
mcp_url: normalize_url(&site.mcp_url),
|
||||||
|
install_dir: default_install_dir(),
|
||||||
|
site_name: site.name,
|
||||||
|
};
|
||||||
|
settings.save(app)?;
|
||||||
|
settings
|
||||||
}
|
}
|
||||||
return Ok(settings);
|
|
||||||
}
|
|
||||||
let site = read_site_file();
|
|
||||||
let settings = Settings {
|
|
||||||
site_url: normalize_url(&site.site_url),
|
|
||||||
mcp_url: normalize_url(&site.mcp_url),
|
|
||||||
install_dir: default_install_dir(),
|
|
||||||
site_name: site.name,
|
|
||||||
};
|
};
|
||||||
settings.save(app)?;
|
// On an ODIAN drive a folder of the drive is stored without its letter and resolved afresh
|
||||||
|
// every start: the letter changes from PC to PC, and yesterday's would point at a stranger's
|
||||||
|
// disk. A folder with a letter names a disk of the PC in front of the operator.
|
||||||
|
if let Some(drive) = portable_drive() {
|
||||||
|
let raw = settings.install_dir.trim().to_string();
|
||||||
|
let folder = match raw.as_str() {
|
||||||
|
"" => drive.join("ODIAN"),
|
||||||
|
chosen if drive_of(Path::new(chosen)).is_some() => PathBuf::from(chosen),
|
||||||
|
chosen => on_drive(Path::new(chosen), &drive),
|
||||||
|
};
|
||||||
|
settings.install_dir = folder.display().to_string();
|
||||||
|
// Written by a launcher that stored the drive's own folder whole: store it the new way
|
||||||
|
// now, while the letter still is the one it was written with.
|
||||||
|
if raw != stored_install_dir(&settings.install_dir, &drive) {
|
||||||
|
settings.save(app)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
Ok(settings)
|
Ok(settings)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn save(&self, app: &AppHandle) -> Result<(), String> {
|
pub fn save(&self, app: &AppHandle) -> Result<(), String> {
|
||||||
write_json_atomic(&settings_path(app)?, self)
|
let mut stored = self.clone();
|
||||||
|
if let Some(drive) = portable_drive() {
|
||||||
|
stored.install_dir = stored_install_dir(&self.install_dir, &drive);
|
||||||
|
}
|
||||||
|
write_json_atomic(&settings_path(app)?, &stored)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn install_path(&self) -> PathBuf {
|
pub fn install_path(&self) -> PathBuf {
|
||||||
PathBuf::from(&self.install_dir)
|
PathBuf::from(&self.install_dir)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_drives_own_launcher_installs_on_that_drive_whatever_its_letter() {
|
||||||
|
for (exe, want) in [
|
||||||
|
(r"E:\ODIAN_Launcher\odian-launcher.exe", Some(r"E:\")),
|
||||||
|
(r"D:\tools\odian\odian-launcher.exe", Some(r"D:\")),
|
||||||
|
] {
|
||||||
|
assert_eq!(portable_drive_of(Path::new(exe), true).as_deref(), want.map(Path::new), "{exe}");
|
||||||
|
}
|
||||||
|
// A launcher installed on a PC keeps the folder the operator chose.
|
||||||
|
assert_eq!(portable_drive_of(Path::new(r"C:\Users\x\odian-launcher.exe"), false), None);
|
||||||
|
assert_eq!(portable_drive_of(Path::new(r"\\server\share\odian-launcher.exe"), true), None, "a share is not a drive");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_folder_the_operator_chose_follows_the_drive_to_the_next_pc() {
|
||||||
|
let drive = Path::new(r"E:\");
|
||||||
|
assert_eq!(on_drive(Path::new(r"D:\ODIAN"), drive), Path::new(r"E:\ODIAN"));
|
||||||
|
assert_eq!(on_drive(Path::new(r"D:\field kit\ODIAN"), drive), Path::new(r"E:\field kit\ODIAN"));
|
||||||
|
assert_eq!(on_drive(Path::new(r"E:\ODIAN"), drive), Path::new(r"E:\ODIAN"), "already there");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_portable_install_folder_is_a_folder_on_the_drive_or_on_this_pcs_disk() {
|
||||||
|
let drive = Path::new(r"E:\");
|
||||||
|
// On the drive: ODIAN runs from the drive itself.
|
||||||
|
assert_eq!(folder_problem_on(Path::new(r"E:\ODIAN"), drive), None);
|
||||||
|
assert_eq!(folder_problem_on(Path::new(r"E:\field kit\ODIAN"), drive), None);
|
||||||
|
// On the PC: the drive installs ODIAN onto the PC and leaves.
|
||||||
|
assert_eq!(folder_problem_on(Path::new(r"C:\ODIAN"), drive), None);
|
||||||
|
for bad in [r"E:\", r"C:\", r"\\server\share\ODIAN"] {
|
||||||
|
let problem = folder_problem_on(Path::new(bad), drive).expect(bad);
|
||||||
|
assert!(!problem.is_empty(), "{bad}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn only_the_drives_own_folder_is_stored_without_its_letter() {
|
||||||
|
let drive = Path::new(r"E:\");
|
||||||
|
assert_eq!(stored_install_dir(r"E:\ODIAN", drive), "ODIAN");
|
||||||
|
assert_eq!(stored_install_dir(r"E:\field kit\ODIAN", drive), r"field kit\ODIAN");
|
||||||
|
// A folder on a PC keeps its disk: it is not the drive's to move.
|
||||||
|
assert_eq!(stored_install_dir(r"C:\ODIAN", drive), r"C:\ODIAN");
|
||||||
|
// And it comes back as the same folder on whatever letter the drive gets.
|
||||||
|
assert_eq!(on_drive(Path::new("ODIAN"), Path::new(r"F:\")), Path::new(r"F:\ODIAN"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+11
-5
@@ -553,7 +553,10 @@ fn api_env(config: &StackConfig) -> Vec<(&'static str, String)> {
|
|||||||
/// The archive a dataset's basemap can be baked from, when this install ships it.
|
/// The archive a dataset's basemap can be baked from, when this install ships it.
|
||||||
fn pmtiles_of(config: &StackConfig, dataset: &str) -> Option<PathBuf> {
|
fn pmtiles_of(config: &StackConfig, dataset: &str) -> Option<PathBuf> {
|
||||||
let path = config.layout.data_dir().join("src").join(format!("{dataset}.pmtiles"));
|
let path = config.layout.data_dir().join("src").join(format!("{dataset}.pmtiles"));
|
||||||
path.is_file().then_some(path)
|
// The installer writes an archive chunk by chunk in place and marks it `.ok` once whole:
|
||||||
|
// a baker reading one mid-download draws garbage.
|
||||||
|
let complete = config.layout.data_dir().join("src").join(format!("{dataset}.pmtiles.ok"));
|
||||||
|
(path.is_file() && complete.is_file()).then_some(path)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The tile baker, when the version ships one AND some installed dataset has an archive
|
/// The tile baker, when the version ships one AND some installed dataset has an archive
|
||||||
@@ -651,6 +654,8 @@ pub fn installed_db(layout: &Layout) -> Option<odian_packs::manifest::DbSection>
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The tile sets the installed version's manifest names.
|
/// The tile sets the installed version's manifest names.
|
||||||
|
/// The version's tile sets this PC has (a marketplace set the operator did not choose has no
|
||||||
|
/// folder): the client is offered, and the tile server serves, only those.
|
||||||
pub fn installed_tile_sets(layout: &Layout) -> Result<Vec<TileSet>, String> {
|
pub fn installed_tile_sets(layout: &Layout) -> Result<Vec<TileSet>, String> {
|
||||||
let path = layout.version_dir().join("manifest.json");
|
let path = layout.version_dir().join("manifest.json");
|
||||||
let bytes = std::fs::read(&path).map_err(|e| format!("{} is missing ({e}); reinstall this version", path.display()))?;
|
let bytes = std::fs::read(&path).map_err(|e| format!("{} is missing ({e}); reinstall this version", path.display()))?;
|
||||||
@@ -668,6 +673,7 @@ pub fn installed_tile_sets(layout: &Layout) -> Result<Vec<TileSet>, String> {
|
|||||||
ext: set.get("tile_ext").and_then(|e| e.as_str()).unwrap_or_default().to_string(),
|
ext: set.get("tile_ext").and_then(|e| e.as_str()).unwrap_or_default().to_string(),
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
.filter(|set| layout.data_dir().join("tiles").join(&set.style).join(&set.dataset).is_dir())
|
||||||
.collect()
|
.collect()
|
||||||
})
|
})
|
||||||
.unwrap_or_default())
|
.unwrap_or_default())
|
||||||
@@ -839,6 +845,7 @@ mod tests {
|
|||||||
let src = cfg.layout.data_dir().join("src");
|
let src = cfg.layout.data_dir().join("src");
|
||||||
std::fs::create_dir_all(&src).unwrap();
|
std::fs::create_dir_all(&src).unwrap();
|
||||||
std::fs::write(src.join("protomaps_20260423.pmtiles"), b"PMTiles").unwrap();
|
std::fs::write(src.join("protomaps_20260423.pmtiles"), b"PMTiles").unwrap();
|
||||||
|
std::fs::write(src.join("protomaps_20260423.pmtiles.ok"), b"digest").unwrap();
|
||||||
let server = parent(&cfg.layout.app_file(TILE_SERVER_APP));
|
let server = parent(&cfg.layout.app_file(TILE_SERVER_APP));
|
||||||
std::fs::create_dir_all(&server).unwrap();
|
std::fs::create_dir_all(&server).unwrap();
|
||||||
if with_baker {
|
if with_baker {
|
||||||
@@ -884,14 +891,13 @@ mod tests {
|
|||||||
r#"{"tiles":{"block_shift":6,"sets":[{"style":"dark","dataset":"protomaps_20260423"},{"style":"heightmap","dataset":"planet_heightmap"}]}}"#,
|
r#"{"tiles":{"block_shift":6,"sets":[{"style":"dark","dataset":"protomaps_20260423"},{"style":"heightmap","dataset":"planet_heightmap"}]}}"#,
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
// The height map was not chosen in the marketplace: it has no folder, so it is not offered.
|
||||||
|
std::fs::create_dir_all(layout.data_dir().join("tiles").join("dark").join("protomaps_20260423")).unwrap();
|
||||||
let sets = installed_tile_sets(&layout).unwrap();
|
let sets = installed_tile_sets(&layout).unwrap();
|
||||||
std::fs::remove_dir_all(&root).unwrap();
|
std::fs::remove_dir_all(&root).unwrap();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
sets,
|
sets,
|
||||||
vec![
|
vec![TileSet { style: "dark".to_string(), dataset: "protomaps_20260423".to_string(), ext: String::new() }]
|
||||||
TileSet { style: "dark".to_string(), dataset: "protomaps_20260423".to_string(), ext: String::new() },
|
|
||||||
TileSet { style: "heightmap".to_string(), dataset: "planet_heightmap".to_string(), ext: String::new() },
|
|
||||||
]
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,6 +34,10 @@ pub struct LauncherState {
|
|||||||
/// from before sync: its first sync then starts from the DHP's state at that moment.
|
/// from before sync: its first sync then starts from the DHP's state at that moment.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub sync_origin: Option<SyncOrigin>,
|
pub sync_origin: Option<SyncOrigin>,
|
||||||
|
/// The marketplace items the operator chose (see `market`). None until the first choice:
|
||||||
|
/// an install from before the marketplace then counts what it has on disk as chosen.
|
||||||
|
#[serde(default)]
|
||||||
|
pub extras: Option<std::collections::BTreeSet<String>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
|
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
|
||||||
"productName": "ODIAN Launcher",
|
"productName": "ODIAN Launcher",
|
||||||
"version": "0.1.1",
|
"version": "0.1.3",
|
||||||
"identifier": "fr.odian.launcher",
|
"identifier": "fr.odian.launcher",
|
||||||
"build": {
|
"build": {
|
||||||
"frontendDist": "../dist",
|
"frontendDist": "../dist",
|
||||||
@@ -31,6 +31,7 @@
|
|||||||
"bundle": {
|
"bundle": {
|
||||||
"active": true,
|
"active": true,
|
||||||
"targets": ["nsis"],
|
"targets": ["nsis"],
|
||||||
|
"createUpdaterArtifacts": true,
|
||||||
"resources": [
|
"resources": [
|
||||||
"site.json"
|
"site.json"
|
||||||
],
|
],
|
||||||
@@ -41,5 +42,14 @@
|
|||||||
"icons/icon.icns",
|
"icons/icon.icns",
|
||||||
"icons/icon.ico"
|
"icons/icon.ico"
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
"plugins": {
|
||||||
|
"updater": {
|
||||||
|
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDdBODcwMzdCRUUxOEVBMTYKUldRVzZoanVld09IZXF0ZEl3dW1vNEpWcmdRZ0Izakl0MHlOOWQxTTd5ZjFwbks1QTVmL2tDWFgK",
|
||||||
|
"dangerousInsecureTransportProtocol": true,
|
||||||
|
"windows": {
|
||||||
|
"installMode": "passive"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-1
@@ -4,6 +4,7 @@
|
|||||||
import ActivityPanel from "./components/ActivityPanel.svelte";
|
import ActivityPanel from "./components/ActivityPanel.svelte";
|
||||||
import Hero from "./components/Hero.svelte";
|
import Hero from "./components/Hero.svelte";
|
||||||
import KeyArt from "./components/KeyArt.svelte";
|
import KeyArt from "./components/KeyArt.svelte";
|
||||||
|
import MarketplaceDrawer from "./components/MarketplaceDrawer.svelte";
|
||||||
import NewsPanel from "./components/NewsPanel.svelte";
|
import NewsPanel from "./components/NewsPanel.svelte";
|
||||||
import SettingsDrawer from "./components/SettingsDrawer.svelte";
|
import SettingsDrawer from "./components/SettingsDrawer.svelte";
|
||||||
import SyncPanel from "./components/SyncPanel.svelte";
|
import SyncPanel from "./components/SyncPanel.svelte";
|
||||||
@@ -18,7 +19,7 @@
|
|||||||
|
|
||||||
<div class="shell">
|
<div class="shell">
|
||||||
<KeyArt />
|
<KeyArt />
|
||||||
<TitleBar onOpenSettings={() => (settingsOpen = true)} />
|
<TitleBar onOpenSettings={() => (settingsOpen = true)} onOpenMarket={() => launcher.openMarket()} />
|
||||||
|
|
||||||
<main class="hub">
|
<main class="hub">
|
||||||
<div class="news-slot"><NewsPanel /></div>
|
<div class="news-slot"><NewsPanel /></div>
|
||||||
@@ -37,6 +38,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<SettingsDrawer bind:open={settingsOpen} />
|
<SettingsDrawer bind:open={settingsOpen} />
|
||||||
|
<MarketplaceDrawer />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
|||||||
@@ -98,7 +98,7 @@
|
|||||||
{#snippet driveLink(lead: string)}
|
{#snippet driveLink(lead: string)}
|
||||||
<p class="secondary">
|
<p class="secondary">
|
||||||
{lead}
|
{lead}
|
||||||
<button class="btn-link" disabled={blocked} onclick={() => launcher.installFromDrive()}>Install from a drive…</button>
|
<button class="btn-link" disabled={blocked} onclick={() => launcher.chooseDrive()}>Install from a drive…</button>
|
||||||
</p>
|
</p>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,8 @@
|
|||||||
| "check"
|
| "check"
|
||||||
| "signout"
|
| "signout"
|
||||||
| "key"
|
| "key"
|
||||||
| "globe";
|
| "globe"
|
||||||
|
| "market";
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
@@ -92,6 +93,11 @@
|
|||||||
<circle cx="8" cy="8" r="5.5" />
|
<circle cx="8" cy="8" r="5.5" />
|
||||||
<ellipse cx="8" cy="8" rx="2.4" ry="5.5" />
|
<ellipse cx="8" cy="8" rx="2.4" ry="5.5" />
|
||||||
<path d="M2.5 8h11" />
|
<path d="M2.5 8h11" />
|
||||||
|
{:else if name === "market"}
|
||||||
|
<rect x="2.5" y="2.5" width="4.5" height="4.5" />
|
||||||
|
<rect x="9" y="2.5" width="4.5" height="4.5" />
|
||||||
|
<rect x="2.5" y="9" width="4.5" height="4.5" />
|
||||||
|
<path d="M11.25 9v4.5M9 11.25h4.5" />
|
||||||
{/if}
|
{/if}
|
||||||
</svg>
|
</svg>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,588 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { untrack } from "svelte";
|
||||||
|
import Icon from "./Icon.svelte";
|
||||||
|
import { launcher } from "../lib/launcher.svelte";
|
||||||
|
import { errorText, formatBytes } from "../lib/format";
|
||||||
|
import type { Market, MarketItem } from "../lib/types";
|
||||||
|
|
||||||
|
let dialog: HTMLDialogElement | undefined = $state();
|
||||||
|
let market = $state<Market | null>(null);
|
||||||
|
let loading = $state(false);
|
||||||
|
let loadError = $state<string | null>(null);
|
||||||
|
/** The optional items ticked here, by key: the draft the operator applies. */
|
||||||
|
let chosen = $state<string[]>([]);
|
||||||
|
let saved = $state(false);
|
||||||
|
let applyError = $state<string | null>(null);
|
||||||
|
|
||||||
|
const s = $derived(launcher.status);
|
||||||
|
const locked = $derived(launcher.locked);
|
||||||
|
/** Set while the panel describes a drive to install from, instead of this PC and its DHP. */
|
||||||
|
const drive = $derived(launcher.marketDrive);
|
||||||
|
const applying = $derived(launcher.busy === "market");
|
||||||
|
const busy = $derived(launcher.busy !== null);
|
||||||
|
|
||||||
|
const required = $derived(market?.items.filter((item) => item.required) ?? []);
|
||||||
|
const optional = $derived(market?.items.filter((item) => !item.required) ?? []);
|
||||||
|
const picked = (item: MarketItem) => chosen.includes(item.key);
|
||||||
|
const dirty = $derived(optional.some((item) => picked(item) !== item.selected));
|
||||||
|
const adds = $derived(optional.filter((item) => picked(item) && item.presence !== "installed"));
|
||||||
|
const removes = $derived(optional.filter((item) => !picked(item) && item.presence !== "absent"));
|
||||||
|
const installed = $derived((market?.installed ?? false) && drive === null);
|
||||||
|
const addBytes = $derived(adds.reduce((sum, item) => sum + item.bytes, 0));
|
||||||
|
const removeBytes = $derived(removes.reduce((sum, item) => sum + item.bytes, 0));
|
||||||
|
/** Before the first install: everything Install will download with this choice. */
|
||||||
|
const installBytes = $derived(
|
||||||
|
(market?.core_bytes ?? 0) +
|
||||||
|
required.reduce((sum, item) => sum + item.bytes, 0) +
|
||||||
|
optional.filter(picked).reduce((sum, item) => sum + item.bytes, 0),
|
||||||
|
);
|
||||||
|
/** A copy skips what the target already holds, byte for byte: only the rest crosses. */
|
||||||
|
const copyBytes = $derived(
|
||||||
|
(market?.core_bytes ?? 0) +
|
||||||
|
(market?.items
|
||||||
|
.filter((item) => (item.required || picked(item)) && item.presence !== "installed")
|
||||||
|
.reduce((sum, item) => sum + item.bytes, 0) ?? 0),
|
||||||
|
);
|
||||||
|
const needBytes = $derived(drive !== null ? copyBytes : installed ? addBytes : installBytes);
|
||||||
|
const tooBig = $derived(market?.free_bytes != null && needBytes > market.free_bytes);
|
||||||
|
const offlineAdds = $derived(drive === null && installed && adds.length > 0 && !s?.online);
|
||||||
|
const updateFirst = $derived(drive === null && installed && (s?.update_required ?? false));
|
||||||
|
/** A stopped download is work to do even when the choice did not change. */
|
||||||
|
const work = $derived(installed ? adds.length + removes.length > 0 : dirty);
|
||||||
|
// A copy from a drive starts here, so a disk that cannot hold it stops the button too.
|
||||||
|
const blocked = $derived(locked || busy || offlineAdds || updateFirst || ((installed || drive !== null) && tooBig));
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
loading = true;
|
||||||
|
loadError = null;
|
||||||
|
try {
|
||||||
|
const next = await launcher.loadMarket(drive ?? undefined);
|
||||||
|
market = next;
|
||||||
|
chosen = next.items.filter((item) => !item.required && item.selected).map((item) => item.key);
|
||||||
|
} catch (e) {
|
||||||
|
loadError = errorText(e);
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function revert() {
|
||||||
|
chosen = optional.filter((item) => item.selected).map((item) => item.key);
|
||||||
|
saved = false;
|
||||||
|
applyError = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggle(item: MarketItem) {
|
||||||
|
chosen = picked(item) ? chosen.filter((key) => key !== item.key) : [...chosen, item.key];
|
||||||
|
saved = false;
|
||||||
|
applyError = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function apply() {
|
||||||
|
if (blocked) return;
|
||||||
|
// From a drive: save the choice, then copy. The progress shows in the main panel.
|
||||||
|
if (drive !== null) {
|
||||||
|
if (!(await launcher.setMarket([...chosen], drive))) {
|
||||||
|
applyError = launcher.notice;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
launcher.marketOpen = false;
|
||||||
|
void launcher.installFromDrive(drive);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!work) return;
|
||||||
|
const startsDownload = installed && work;
|
||||||
|
const ok = await launcher.applyMarket([...chosen]);
|
||||||
|
if (!ok) {
|
||||||
|
applyError = launcher.notice;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (startsDownload) {
|
||||||
|
launcher.marketOpen = false;
|
||||||
|
} else {
|
||||||
|
saved = true;
|
||||||
|
await load();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function presenceLabel(item: MarketItem): string {
|
||||||
|
if (item.presence === "installed") return "Installed";
|
||||||
|
if (item.presence === "partial") return "Partly downloaded";
|
||||||
|
return "Not installed";
|
||||||
|
}
|
||||||
|
|
||||||
|
function changeNote(item: MarketItem): string | null {
|
||||||
|
if (drive !== null) {
|
||||||
|
if (!picked(item)) return item.presence === "absent" ? null : "Left as it is";
|
||||||
|
if (item.presence === "installed") return "Already here";
|
||||||
|
return item.presence === "partial" ? "Copy resumes" : `Copies ${formatBytes(item.bytes)}`;
|
||||||
|
}
|
||||||
|
if (!installed) return picked(item) ? "Installed with ODIAN" : null;
|
||||||
|
if (picked(item) && item.presence === "partial") return "Download resumes";
|
||||||
|
if (picked(item) && item.presence === "absent") return `Downloads ${formatBytes(item.bytes)}`;
|
||||||
|
if (!picked(item) && item.presence !== "absent") return `Removed from this PC, frees ${formatBytes(item.bytes)}`;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep the native modal in sync with the store, returning focus to whatever opened it.
|
||||||
|
const open = $derived(launcher.marketOpen);
|
||||||
|
let opener: HTMLElement | null = null;
|
||||||
|
$effect(() => {
|
||||||
|
const el = dialog;
|
||||||
|
void drive;
|
||||||
|
if (!el) return;
|
||||||
|
if (open && !el.open) {
|
||||||
|
opener = document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
||||||
|
saved = false;
|
||||||
|
applyError = null;
|
||||||
|
untrack(() => void load());
|
||||||
|
el.showModal();
|
||||||
|
} else if (!open) {
|
||||||
|
if (el.open) el.close();
|
||||||
|
if (opener?.isConnected) opener.focus();
|
||||||
|
opener = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// A marketplace download that finishes while the drawer is open changes what is installed.
|
||||||
|
let lastPhase: string | undefined;
|
||||||
|
$effect(() => {
|
||||||
|
const phase = s?.phase;
|
||||||
|
if (open && lastPhase && lastPhase !== phase && phase === "idle") untrack(() => void load());
|
||||||
|
lastPhase = phase;
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const el = dialog;
|
||||||
|
if (!el) return;
|
||||||
|
const onClick = (e: MouseEvent) => {
|
||||||
|
if (e.target === el) launcher.marketOpen = false;
|
||||||
|
};
|
||||||
|
const onKeydown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
e.preventDefault();
|
||||||
|
launcher.marketOpen = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
el.addEventListener("click", onClick);
|
||||||
|
el.addEventListener("keydown", onKeydown);
|
||||||
|
return () => {
|
||||||
|
el.removeEventListener("click", onClick);
|
||||||
|
el.removeEventListener("keydown", onKeydown);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<dialog bind:this={dialog} class="drawer" aria-labelledby="market-title" onclose={() => (launcher.marketOpen = false)}>
|
||||||
|
<div class="panel">
|
||||||
|
<header class="head">
|
||||||
|
<div class="title">
|
||||||
|
<h2 id="market-title">{drive === null ? "Marketplace" : "Install from drive"}</h2>
|
||||||
|
{#if market}<span class="version">ODIAN {market.version}</span>{/if}
|
||||||
|
</div>
|
||||||
|
<button type="button" class="icon-btn" aria-label="Close marketplace" onclick={() => (launcher.marketOpen = false)}>
|
||||||
|
<Icon name="close" />
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="content">
|
||||||
|
{#if locked}
|
||||||
|
<p class="note warn" role="note">
|
||||||
|
<Icon name="alert" size={14} />
|
||||||
|
The marketplace is locked while ODIAN is signing in, installing or running.
|
||||||
|
</p>
|
||||||
|
{:else if updateFirst}
|
||||||
|
<p class="note warn" role="note">
|
||||||
|
<Icon name="alert" size={14} />
|
||||||
|
Update ODIAN first: the marketplace adds to the installed version.
|
||||||
|
</p>
|
||||||
|
{:else if offlineAdds}
|
||||||
|
<p class="note warn" role="note">
|
||||||
|
<Icon name="alert" size={14} />
|
||||||
|
Your DHP is unreachable: connect to download. Removing items works offline.
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if loading && !market}
|
||||||
|
<p class="empty">Reading what your DHP offers…</p>
|
||||||
|
{:else if loadError}
|
||||||
|
<div class="empty">
|
||||||
|
<p class="err-text">{loadError}</p>
|
||||||
|
<button type="button" class="btn" onclick={() => void load()}>
|
||||||
|
<Icon name="refresh" size={14} /> Try again
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{:else if market}
|
||||||
|
{#if drive !== null}
|
||||||
|
<p class="intro">
|
||||||
|
Copying from <strong>{drive}</strong> into <strong>{s?.install_dir}</strong>. Only what the drive
|
||||||
|
carries is listed, and every file is checked as it lands.
|
||||||
|
</p>
|
||||||
|
{:else if !installed}
|
||||||
|
<p class="intro">
|
||||||
|
ODIAN is not installed yet. Choose what to add: <strong>Install</strong> downloads it with the rest.
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<section class="group" aria-labelledby="market-optional">
|
||||||
|
<h3 id="market-optional" class="eyebrow">Optional</h3>
|
||||||
|
{#if optional.length === 0}
|
||||||
|
<p class="text">This version offers nothing optional: everything comes with the install.</p>
|
||||||
|
{/if}
|
||||||
|
{#each optional as item (item.key)}
|
||||||
|
{@const note = changeNote(item)}
|
||||||
|
<label class="card" class:picked={picked(item)}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
class="check"
|
||||||
|
checked={picked(item)}
|
||||||
|
disabled={locked || applying}
|
||||||
|
onchange={() => toggle(item)}
|
||||||
|
/>
|
||||||
|
<span class="body">
|
||||||
|
<span class="line">
|
||||||
|
<span class="name">{item.title}</span>
|
||||||
|
<span class="size">{formatBytes(item.bytes)}</span>
|
||||||
|
</span>
|
||||||
|
{#if item.description}<span class="desc">{item.description}</span>{/if}
|
||||||
|
<span class="meta">
|
||||||
|
<span class="pill {item.presence}">{presenceLabel(item)}</span>
|
||||||
|
{#if note}<span class="change" class:loss={!picked(item)}>{note}</span>{/if}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
{/each}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="group" aria-labelledby="market-included">
|
||||||
|
<h3 id="market-included" class="eyebrow">Included with every install</h3>
|
||||||
|
<ul class="included">
|
||||||
|
<li>
|
||||||
|
<span class="name">ODIAN app and database</span>
|
||||||
|
<span class="size">{formatBytes(market.core_bytes)}</span>
|
||||||
|
</li>
|
||||||
|
{#each required as item (item.key)}
|
||||||
|
<li>
|
||||||
|
<span class="name" title={item.description}>{item.title}</span>
|
||||||
|
<span class="size">{formatBytes(item.bytes)}</span>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer class="foot">
|
||||||
|
<span class="summary" role="status">
|
||||||
|
{#if applyError}
|
||||||
|
<span class="err">{applyError}</span>
|
||||||
|
{:else if saved}
|
||||||
|
<span class="ok"><Icon name="check" size={14} /> Saved: Install brings your choice.</span>
|
||||||
|
{:else if market}
|
||||||
|
{#if drive !== null}
|
||||||
|
Copies {formatBytes(copyBytes)}
|
||||||
|
{:else if installed}
|
||||||
|
{#if adds.length || removes.length}
|
||||||
|
{#if adds.length}Downloads {formatBytes(addBytes)}{/if}{#if adds.length && removes.length} · {/if}{#if removes.length}Frees {formatBytes(removeBytes)}{/if}
|
||||||
|
{:else}
|
||||||
|
Nothing to change
|
||||||
|
{/if}
|
||||||
|
{:else}
|
||||||
|
Install downloads {formatBytes(installBytes)}
|
||||||
|
{/if}
|
||||||
|
{#if market.free_bytes != null}
|
||||||
|
<span class:err={tooBig}> · {formatBytes(market.free_bytes)} free{tooBig ? ": not enough" : ""}</span>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
<button type="button" class="btn" disabled={!dirty || applying} onclick={revert}>Revert</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-accent"
|
||||||
|
disabled={blocked || (drive === null && !work) || (drive !== null && market === null)}
|
||||||
|
onclick={apply}
|
||||||
|
>
|
||||||
|
{#if applying}Applying…{:else if drive !== null}Install from drive{:else if installed}Apply changes{:else}Save choice{/if}
|
||||||
|
</button>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</dialog>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.drawer {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0 0 0 auto;
|
||||||
|
width: min(560px, 100vw);
|
||||||
|
max-width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
max-height: 100%;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
border-left: 1px solid var(--line-strong);
|
||||||
|
background: var(--glass-strong);
|
||||||
|
color: var(--ink);
|
||||||
|
backdrop-filter: blur(24px);
|
||||||
|
-webkit-backdrop-filter: blur(24px);
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(24px);
|
||||||
|
transition:
|
||||||
|
opacity var(--dur-slow) var(--ease),
|
||||||
|
transform var(--dur-slow) var(--ease),
|
||||||
|
overlay var(--dur-slow) allow-discrete,
|
||||||
|
display var(--dur-slow) allow-discrete;
|
||||||
|
}
|
||||||
|
.drawer[open] {
|
||||||
|
opacity: 1;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
.drawer::backdrop {
|
||||||
|
background: rgba(5, 5, 7, 0);
|
||||||
|
transition:
|
||||||
|
background-color var(--dur-slow) var(--ease),
|
||||||
|
overlay var(--dur-slow) allow-discrete,
|
||||||
|
display var(--dur-slow) allow-discrete;
|
||||||
|
}
|
||||||
|
.drawer[open]::backdrop {
|
||||||
|
background: rgba(5, 5, 7, 0.6);
|
||||||
|
}
|
||||||
|
@starting-style {
|
||||||
|
.drawer[open] {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(24px);
|
||||||
|
}
|
||||||
|
.drawer[open]::backdrop {
|
||||||
|
background: rgba(5, 5, 7, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
height: 56px;
|
||||||
|
padding: 0 12px 0 24px;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
.title {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
.head h2 {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
.version {
|
||||||
|
color: var(--muted);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
margin: 16px 24px 0;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
}
|
||||||
|
.note.warn {
|
||||||
|
border: 1px solid rgba(245, 180, 84, 0.3);
|
||||||
|
background: var(--warn-soft);
|
||||||
|
color: var(--warn);
|
||||||
|
}
|
||||||
|
|
||||||
|
.intro,
|
||||||
|
.empty {
|
||||||
|
margin: 20px 24px 0;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
.intro strong {
|
||||||
|
color: var(--ink);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.empty {
|
||||||
|
display: grid;
|
||||||
|
justify-items: start;
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
.err-text {
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.group {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 22px 24px 24px;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
.group .eyebrow {
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
.text {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-3);
|
||||||
|
padding: 14px 16px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: rgba(9, 9, 11, 0.45);
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
border-color var(--dur-fast) var(--ease),
|
||||||
|
background-color var(--dur-fast) var(--ease);
|
||||||
|
}
|
||||||
|
.card:hover {
|
||||||
|
border-color: var(--line-strong);
|
||||||
|
background: var(--surface-2);
|
||||||
|
}
|
||||||
|
.card.picked {
|
||||||
|
border-color: var(--accent-line);
|
||||||
|
background: var(--accent-soft);
|
||||||
|
}
|
||||||
|
.card:has(.check:disabled) {
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
.card:has(.check:focus-visible) {
|
||||||
|
outline: 1px solid var(--accent);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.check {
|
||||||
|
flex: none;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
margin-top: 2px;
|
||||||
|
accent-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.body {
|
||||||
|
display: grid;
|
||||||
|
flex: 1;
|
||||||
|
gap: 6px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.line {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
.name {
|
||||||
|
color: var(--ink);
|
||||||
|
font-size: var(--text-md);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.size {
|
||||||
|
flex: none;
|
||||||
|
color: var(--ink-2);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
}
|
||||||
|
.desc {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.meta {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
.pill {
|
||||||
|
padding: 1px 6px;
|
||||||
|
border: 1px solid var(--line-strong);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
color: var(--muted);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.pill.installed {
|
||||||
|
border-color: rgba(134, 239, 172, 0.28);
|
||||||
|
background: var(--ok-soft);
|
||||||
|
color: var(--ok);
|
||||||
|
}
|
||||||
|
.pill.partial {
|
||||||
|
border-color: rgba(245, 180, 84, 0.3);
|
||||||
|
background: var(--warn-soft);
|
||||||
|
color: var(--warn);
|
||||||
|
}
|
||||||
|
.change {
|
||||||
|
color: var(--accent-hover);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
}
|
||||||
|
.change.loss {
|
||||||
|
color: var(--warn);
|
||||||
|
}
|
||||||
|
|
||||||
|
.included {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
.included li {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
.included .name {
|
||||||
|
color: var(--ink-2);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.foot {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
padding: 12px 24px;
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
background: rgba(9, 9, 11, 0.6);
|
||||||
|
}
|
||||||
|
.summary {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
white-space: nowrap;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.ok {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
color: var(--ok);
|
||||||
|
}
|
||||||
|
.err {
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -23,6 +23,9 @@
|
|||||||
const current = $derived(launcher.settings);
|
const current = $derived(launcher.settings);
|
||||||
const locked = $derived(launcher.locked);
|
const locked = $derived(launcher.locked);
|
||||||
const saving = $derived(launcher.busy === "settings");
|
const saving = $derived(launcher.busy === "settings");
|
||||||
|
/** A launcher that ships on an ODIAN drive installs on that drive to run ODIAN from it,
|
||||||
|
* or on a disk of the PC it is plugged into to hand ODIAN over. */
|
||||||
|
const portable = $derived(s?.portable ?? false);
|
||||||
const busy = $derived(launcher.busy !== null);
|
const busy = $derived(launcher.busy !== null);
|
||||||
|
|
||||||
const trimmed = $derived<Settings>({
|
const trimmed = $derived<Settings>({
|
||||||
@@ -36,10 +39,12 @@
|
|||||||
trimmed.mcp_url !== current.mcp_url ||
|
trimmed.mcp_url !== current.mcp_url ||
|
||||||
trimmed.install_dir !== current.install_dir),
|
trimmed.install_dir !== current.install_dir),
|
||||||
);
|
);
|
||||||
|
/** A drive's launcher needs a folder on a disk: a root or a share holds no install. */
|
||||||
|
const offDrive = $derived(portable && trimmed.install_dir !== "" && !/^[A-Za-z]:\\.+/.test(trimmed.install_dir));
|
||||||
const siteError = $derived(urlProblem(trimmed.site_url));
|
const siteError = $derived(urlProblem(trimmed.site_url));
|
||||||
const mcpError = $derived(urlProblem(trimmed.mcp_url));
|
const mcpError = $derived(urlProblem(trimmed.mcp_url));
|
||||||
const dirError = $derived(trimmed.install_dir ? null : "Choose where ODIAN is installed.");
|
const dirError = $derived(trimmed.install_dir ? null : "Choose where ODIAN is installed.");
|
||||||
const invalid = $derived(siteError !== null || mcpError !== null || dirError !== null);
|
const invalid = $derived(siteError !== null || mcpError !== null || dirError !== null || offDrive);
|
||||||
|
|
||||||
function urlProblem(value: string): string | null {
|
function urlProblem(value: string): string | null {
|
||||||
if (!value) return "Required.";
|
if (!value) return "Required.";
|
||||||
@@ -134,7 +139,7 @@
|
|||||||
|
|
||||||
function installFromDrive() {
|
function installFromDrive() {
|
||||||
open = false;
|
open = false;
|
||||||
void launcher.installFromDrive();
|
void launcher.chooseDrive();
|
||||||
}
|
}
|
||||||
|
|
||||||
function signIn() {
|
function signIn() {
|
||||||
@@ -216,14 +221,24 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<span id="set-install-dir-help" class={dirty && dirError ? "field-error" : "field-help"}>
|
<span id="set-install-dir-help" class={dirty && dirError ? "field-error" : "field-help"}>
|
||||||
{dirty && dirError ? dirError : "Application, database snapshot and map tiles live here."}
|
{#if offDrive}
|
||||||
|
Give a folder on a disk, like D:\ODIAN — not a drive's root, and not a network share.
|
||||||
|
{:else if portable}
|
||||||
|
On the ODIAN drive this launcher runs from, ODIAN runs from the drive and follows it to the
|
||||||
|
next PC. A folder on this PC's own disk installs ODIAN onto the PC instead.
|
||||||
|
{:else}
|
||||||
|
{dirty && dirError ? dirError : "Application, database snapshot and map tiles live here."}
|
||||||
|
{/if}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
<section class="group" aria-labelledby="set-drive-title">
|
<section class="group" aria-labelledby="set-drive-title">
|
||||||
<h3 id="set-drive-title" class="eyebrow">Install from drive</h3>
|
<h3 id="set-drive-title" class="eyebrow">Install from drive</h3>
|
||||||
<p class="text">Install or update from an ODIAN SSD without reaching the DHP. Pick the drive's root folder.</p>
|
<p class="text">
|
||||||
|
Copy ODIAN from an ODIAN SSD into your install folder, without reaching the DHP. Pick the drive,
|
||||||
|
then choose what to copy.
|
||||||
|
</p>
|
||||||
<div>
|
<div>
|
||||||
<button type="button" class="btn" disabled={locked || busy} onclick={installFromDrive}>
|
<button type="button" class="btn" disabled={locked || busy} onclick={installFromDrive}>
|
||||||
<Icon name="drive" size={14} /> Install from drive…
|
<Icon name="drive" size={14} /> Install from drive…
|
||||||
|
|||||||
@@ -5,9 +5,10 @@
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
onOpenSettings: () => void;
|
onOpenSettings: () => void;
|
||||||
|
onOpenMarket: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { onOpenSettings }: Props = $props();
|
let { onOpenSettings, onOpenMarket }: Props = $props();
|
||||||
|
|
||||||
let maximized = $state(false);
|
let maximized = $state(false);
|
||||||
|
|
||||||
@@ -81,6 +82,10 @@
|
|||||||
|
|
||||||
<div class="drag" data-tauri-drag-region></div>
|
<div class="drag" data-tauri-drag-region></div>
|
||||||
|
|
||||||
|
<button class="icon-btn market" aria-label="Marketplace" title="Marketplace" aria-haspopup="dialog" onclick={onOpenMarket}>
|
||||||
|
<Icon name="market" />
|
||||||
|
</button>
|
||||||
|
|
||||||
<button class="icon-btn settings" aria-label="Settings" title="Settings" aria-haspopup="dialog" onclick={onOpenSettings}>
|
<button class="icon-btn settings" aria-label="Settings" title="Settings" aria-haspopup="dialog" onclick={onOpenSettings}>
|
||||||
<Icon name="settings" />
|
<Icon name="settings" />
|
||||||
</button>
|
</button>
|
||||||
@@ -223,6 +228,10 @@
|
|||||||
align-self: stretch;
|
align-self: stretch;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.market {
|
||||||
|
margin-right: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
.settings {
|
.settings {
|
||||||
margin-right: var(--space-2);
|
margin-right: var(--space-2);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import type {
|
|||||||
Backend,
|
Backend,
|
||||||
InstallProgress,
|
InstallProgress,
|
||||||
LogEvent,
|
LogEvent,
|
||||||
|
Market,
|
||||||
Settings,
|
Settings,
|
||||||
SignInResult,
|
SignInResult,
|
||||||
StackEvent,
|
StackEvent,
|
||||||
@@ -47,6 +48,9 @@ function createTauriBackend(): Backend {
|
|||||||
getStack: () => invoke<StackEvent[]>("get_stack"),
|
getStack: () => invoke<StackEvent[]>("get_stack"),
|
||||||
startInstall: () => invoke<void>("start_install"),
|
startInstall: () => invoke<void>("start_install"),
|
||||||
startInstallFromDrive: (path) => invoke<void>("start_install_from_drive", { path }),
|
startInstallFromDrive: (path) => invoke<void>("start_install_from_drive", { path }),
|
||||||
|
getMarket: (drive) => invoke<Market>("get_market", drive === undefined ? {} : { drive }),
|
||||||
|
applyMarket: (selected) => invoke<void>("apply_market", { selected }),
|
||||||
|
setMarket: (selected, drive) => invoke<void>("set_market", drive === undefined ? { selected } : { selected, drive }),
|
||||||
cancelInstall: () => invoke<void>("cancel_install"),
|
cancelInstall: () => invoke<void>("cancel_install"),
|
||||||
launch: () => invoke<void>("launch"),
|
launch: () => invoke<void>("launch"),
|
||||||
stop: () => invoke<void>("stop"),
|
stop: () => invoke<void>("stop"),
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import type {
|
|||||||
InstallProgress,
|
InstallProgress,
|
||||||
LogEvent,
|
LogEvent,
|
||||||
LogLevel,
|
LogLevel,
|
||||||
|
Market,
|
||||||
Phase,
|
Phase,
|
||||||
Settings,
|
Settings,
|
||||||
StackComponent,
|
StackComponent,
|
||||||
@@ -112,6 +113,9 @@ class Launcher {
|
|||||||
stopping = $state(false);
|
stopping = $state(false);
|
||||||
/** The sync review panel. */
|
/** The sync review panel. */
|
||||||
syncOpen = $state(false);
|
syncOpen = $state(false);
|
||||||
|
/** The marketplace panel, and the drive it describes (null: this PC and its DHP). */
|
||||||
|
marketOpen = $state(false);
|
||||||
|
marketDrive = $state<string | null>(null);
|
||||||
/** Decisions waiting in this PC's database; null until the panel loads them. */
|
/** Decisions waiting in this PC's database; null until the panel loads them. */
|
||||||
conflicts = $state<SyncConflict[] | null>(null);
|
conflicts = $state<SyncConflict[] | null>(null);
|
||||||
notes = $state<NotesState>({
|
notes = $state<NotesState>({
|
||||||
@@ -289,6 +293,38 @@ class Launcher {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The marketplace listing, of this PC's DHP or of a drive. Throws, so the drawer says why. */
|
||||||
|
loadMarket(drive?: string): Promise<Market> {
|
||||||
|
return backend.getMarket(drive);
|
||||||
|
}
|
||||||
|
|
||||||
|
openMarket() {
|
||||||
|
this.marketDrive = null;
|
||||||
|
this.marketOpen = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Installing from a drive starts by looking at what the drive carries. */
|
||||||
|
async chooseDrive() {
|
||||||
|
const path = await this.pickFolder();
|
||||||
|
if (!path) return;
|
||||||
|
this.marketDrive = path;
|
||||||
|
this.marketOpen = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Choose what an install about to run will bring; nothing installed is touched. */
|
||||||
|
setMarket(selected: string[], drive?: string) {
|
||||||
|
return this.run("market", () => backend.setMarket(selected, drive));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Save the choice of optional items and apply it (downloads show as an install). */
|
||||||
|
applyMarket(selected: string[]) {
|
||||||
|
return this.run("market", async () => {
|
||||||
|
this.lastOp = null;
|
||||||
|
await backend.applyMarket(selected);
|
||||||
|
this.applyStatus(await backend.getStatus());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
install() {
|
install() {
|
||||||
return this.run("install", async () => {
|
return this.run("install", async () => {
|
||||||
this.lastOp = { kind: "install" };
|
this.lastOp = { kind: "install" };
|
||||||
@@ -296,9 +332,11 @@ class Launcher {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async installFromDrive() {
|
/**
|
||||||
const path = await this.pickFolder();
|
* Installing from a drive is a copy the operator chooses first: the marketplace opens on what
|
||||||
if (!path) return false;
|
* the drive carries, and starts this once they have picked.
|
||||||
|
*/
|
||||||
|
async installFromDrive(path: string) {
|
||||||
return this.run("install", async () => {
|
return this.run("install", async () => {
|
||||||
this.lastOp = { kind: "drive", path };
|
this.lastOp = { kind: "drive", path };
|
||||||
await backend.startInstallFromDrive(path);
|
await backend.startInstallFromDrive(path);
|
||||||
|
|||||||
+100
@@ -18,6 +18,8 @@ import {
|
|||||||
type InstallStage,
|
type InstallStage,
|
||||||
type LogEvent,
|
type LogEvent,
|
||||||
type LogLevel,
|
type LogLevel,
|
||||||
|
type Market,
|
||||||
|
type MarketItem,
|
||||||
type MockControls,
|
type MockControls,
|
||||||
type MockScenario,
|
type MockScenario,
|
||||||
type Settings,
|
type Settings,
|
||||||
@@ -182,6 +184,25 @@ type Listeners = { [K in Channel]: Set<(payload: Payloads[K]) => void> };
|
|||||||
const now = () => Math.floor(Date.now() / 1000);
|
const now = () => Math.floor(Date.now() / 1000);
|
||||||
const busy = "Busy: wait for the current task to finish.";
|
const busy = "Busy: wait for the current task to finish.";
|
||||||
|
|
||||||
|
/** The 0.61.0 catalogue with real sizes: what the marketplace lists in the browser mock. */
|
||||||
|
const MOCK_MARKET: Omit<MarketItem, "presence" | "selected">[] = [
|
||||||
|
{ key: "tiles:dark/protomaps_20260423", kind: "tiles", title: "Dark map", bytes: 2_437_523_419, required: true,
|
||||||
|
description: "The standard ODIAN basemap: roads, water and borders in the dark style, as pre-drawn at the office. Every install carries it." },
|
||||||
|
{ key: "tiles:dark_v2/protomaps_20260423", kind: "tiles", title: "Dark V2 map", bytes: 1_649_512_829, required: false,
|
||||||
|
description: "The newer dark basemap, with buildings and forests. It is the Theatre of Operations' default ground: without it (or the Map source) the Theatre shows relief shading until Dark is picked." },
|
||||||
|
{ key: "tiles:satellite_hd/protomaps_20260423", kind: "tiles", title: "World satellite imagery", bytes: 5_660_999_147, required: false,
|
||||||
|
description: "Photographic basemap of the whole world, for the map and the Theatre of Operations. It covers what the office pre-cached: this PC cannot fetch more imagery offline." },
|
||||||
|
{ key: "tiles:orthophotos/protomaps_20260423", kind: "tiles", title: "France orthophotos", bytes: 6_713_284_879, required: false,
|
||||||
|
description: "High-resolution aerial photographs of France, much sharper than the world imagery over French territory." },
|
||||||
|
{ key: "tiles:heightmap/planet_heightmap", kind: "tiles", title: "Terrain elevation", bytes: 74_367_050_377, required: false,
|
||||||
|
description: "The world height map: real 3D relief for the globe and the Theatre of Operations, and ground elevation wherever ODIAN reads it. Without it the ground is flat." },
|
||||||
|
{ key: "src:osm_source/world_aeroway_v1.sqlite", kind: "src", title: "Aeroway source data (OSM)", bytes: 884_244_480, required: false,
|
||||||
|
description: "Raw OpenStreetMap airports and runways. ODIAN reads aeroways from its own database, so this is only needed to rebuild that layer." },
|
||||||
|
{ key: "src:protomaps_20260423.pmtiles", kind: "src", title: "Map source (world, vector)", bytes: 135_176_708_563, required: false,
|
||||||
|
description: "The vector map data the Dark and Dark V2 basemaps are drawn from. With it this PC draws any tile it lacks, anywhere and at any zoom: the detailed ground the Theatre of Operations needs offline. Without it only the pre-drawn tiles show." },
|
||||||
|
];
|
||||||
|
const MOCK_CORE_BYTES = 2_275_409_041 + 15_356_491_648;
|
||||||
|
|
||||||
function pick<T extends string>(value: string | null, allowed: readonly T[]): T | null {
|
function pick<T extends string>(value: string | null, allowed: readonly T[]): T | null {
|
||||||
return value !== null && (allowed as readonly string[]).includes(value) ? (value as T) : null;
|
return value !== null && (allowed as readonly string[]).includes(value) ? (value as T) : null;
|
||||||
}
|
}
|
||||||
@@ -212,6 +233,7 @@ function initialStatus(scenario: MockScenario): Status {
|
|||||||
phase: "idle",
|
phase: "idle",
|
||||||
error: null,
|
error: null,
|
||||||
install_dir: DEFAULT_DIR,
|
install_dir: DEFAULT_DIR,
|
||||||
|
portable: false,
|
||||||
sync: null,
|
sync: null,
|
||||||
live: null,
|
live: null,
|
||||||
};
|
};
|
||||||
@@ -276,6 +298,9 @@ class MockBackend implements Backend {
|
|||||||
private signInCancelled = false;
|
private signInCancelled = false;
|
||||||
/** What waits for a decision in the mock "local database". */
|
/** What waits for a decision in the mock "local database". */
|
||||||
private conflicts: SyncConflict[];
|
private conflicts: SyncConflict[];
|
||||||
|
/** Marketplace: the optional items chosen, and what is on the mock PC. */
|
||||||
|
private marketChosen = new Set<string>();
|
||||||
|
private readonly marketPresent = new Map<string, MarketItem["presence"]>();
|
||||||
/** Latest event per component, as `get_stack` reports it. */
|
/** Latest event per component, as `get_stack` reports it. */
|
||||||
private readonly stackStates = new Map<StackComponent, StackEvent>();
|
private readonly stackStates = new Map<StackComponent, StackEvent>();
|
||||||
private readonly listeners: Listeners = {
|
private readonly listeners: Listeners = {
|
||||||
@@ -295,6 +320,13 @@ class MockBackend implements Backend {
|
|||||||
this.conflicts = scenario === "sync-conflicts" ? structuredClone(MOCK_CONFLICTS) : [];
|
this.conflicts = scenario === "sync-conflicts" ? structuredClone(MOCK_CONFLICTS) : [];
|
||||||
this.network = this.status.online;
|
this.network = this.status.online;
|
||||||
this.settings = { site_url: SITE_URL, mcp_url: MCP_URL, install_dir: DEFAULT_DIR };
|
this.settings = { site_url: SITE_URL, mcp_url: MCP_URL, install_dir: DEFAULT_DIR };
|
||||||
|
if (this.status.installed) {
|
||||||
|
// An installed mock PC has the basemap, Dark V2 and a stopped satellite download.
|
||||||
|
for (const item of MOCK_MARKET) if (item.required) this.marketPresent.set(item.key, "installed");
|
||||||
|
this.marketPresent.set("tiles:dark_v2/protomaps_20260423", "installed");
|
||||||
|
this.marketPresent.set("tiles:satellite_hd/protomaps_20260423", "partial");
|
||||||
|
this.marketChosen = new Set(["tiles:dark_v2/protomaps_20260423", "tiles:satellite_hd/protomaps_20260423"]);
|
||||||
|
}
|
||||||
|
|
||||||
const self = this;
|
const self = this;
|
||||||
this.mock = {
|
this.mock = {
|
||||||
@@ -476,6 +508,74 @@ class MockBackend implements Backend {
|
|||||||
void this.runInstall(kind, "drive", path);
|
void this.runInstall(kind, "drive", path);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getMarket(drive?: string): Promise<Market> {
|
||||||
|
await this.wait(250);
|
||||||
|
if (drive !== undefined && !drive.trim()) throw "Pick the root folder of an ODIAN drive.";
|
||||||
|
if (drive === undefined && !this.status.installed && !this.status.online) {
|
||||||
|
throw "Connect to your DHP to see what it offers.";
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
version: this.status.installed ?? this.status.available ?? CURRENT,
|
||||||
|
installed: this.status.installed !== null,
|
||||||
|
core_bytes: MOCK_CORE_BYTES,
|
||||||
|
items: MOCK_MARKET.map((item) => ({
|
||||||
|
...item,
|
||||||
|
presence: this.marketPresent.get(item.key) ?? "absent",
|
||||||
|
selected: item.required || this.marketChosen.has(item.key),
|
||||||
|
})),
|
||||||
|
free_bytes: 297_100_000_000,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async setMarket(selected: string[], _drive?: string): Promise<void> {
|
||||||
|
const optional = new Set(MOCK_MARKET.filter((item) => !item.required).map((item) => item.key));
|
||||||
|
this.marketChosen = new Set(selected.filter((key) => optional.has(key)));
|
||||||
|
}
|
||||||
|
|
||||||
|
async applyMarket(selected: string[]): Promise<void> {
|
||||||
|
const s = this.status;
|
||||||
|
if (s.phase !== "idle" && s.phase !== "error") throw busy;
|
||||||
|
const optional = new Set(MOCK_MARKET.filter((item) => !item.required).map((item) => item.key));
|
||||||
|
this.marketChosen = new Set(selected.filter((key) => optional.has(key)));
|
||||||
|
if (!s.installed) return;
|
||||||
|
for (const item of MOCK_MARKET) {
|
||||||
|
if (!item.required && !this.marketChosen.has(item.key) && this.marketPresent.has(item.key)) {
|
||||||
|
this.marketPresent.delete(item.key);
|
||||||
|
this.log("info", `Removed ${item.title}.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const adds = MOCK_MARKET.filter((item) => this.marketChosen.has(item.key) && this.marketPresent.get(item.key) !== "installed");
|
||||||
|
if (adds.length === 0) return;
|
||||||
|
if (!s.online) throw "Your DHP is unreachable: connect to download marketplace items.";
|
||||||
|
void this.runMarketDownload(adds);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async runMarketDownload(adds: typeof MOCK_MARKET) {
|
||||||
|
const job = ++this.job;
|
||||||
|
this.commit({ phase: "installing", error: null });
|
||||||
|
this.log("info", `Downloading ${adds.map((item) => item.title).join(", ")}`);
|
||||||
|
const total = adds.reduce((sum, item) => sum + item.bytes, 0);
|
||||||
|
const ticks = 30;
|
||||||
|
for (let t = 0; t <= ticks; t++) {
|
||||||
|
if (t > 0) await this.wait(100);
|
||||||
|
if (job !== this.job) return;
|
||||||
|
this.emit("install-progress", {
|
||||||
|
stage: "tiles",
|
||||||
|
stage_index: 2,
|
||||||
|
stage_count: 2,
|
||||||
|
done_bytes: Math.round((total * t) / ticks),
|
||||||
|
total_bytes: total,
|
||||||
|
done_items: t,
|
||||||
|
total_items: ticks,
|
||||||
|
bytes_per_sec: t < ticks ? 180_000_000 : 0,
|
||||||
|
message: `tiles ${adds[0].title}: ${t}/${ticks} blocks`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const item of adds) this.marketPresent.set(item.key, "installed");
|
||||||
|
this.commit({ phase: "idle" });
|
||||||
|
this.log("info", "The marketplace items are installed.");
|
||||||
|
}
|
||||||
|
|
||||||
async cancelInstall(): Promise<void> {
|
async cancelInstall(): Promise<void> {
|
||||||
if (this.status.phase === "restoring") throw "The database restore cannot be cancelled once it has started.";
|
if (this.status.phase === "restoring") throw "The database restore cannot be cancelled once it has started.";
|
||||||
if (this.status.phase !== "installing") throw "Nothing is installing.";
|
if (this.status.phase !== "installing") throw "Nothing is installing.";
|
||||||
|
|||||||
@@ -38,6 +38,11 @@ export interface Status {
|
|||||||
error: string | null;
|
error: string | null;
|
||||||
/** e.g. "D:\\ODIAN". */
|
/** e.g. "D:\\ODIAN". */
|
||||||
install_dir: string;
|
install_dir: string;
|
||||||
|
/**
|
||||||
|
* This launcher ships on an ODIAN drive: it runs the install on the drive it was started
|
||||||
|
* from (whatever letter it gets), the folder cannot be changed, and it never self-updates.
|
||||||
|
*/
|
||||||
|
portable: boolean;
|
||||||
/** Operator-data sync counts from the last sync this session; null before one. */
|
/** Operator-data sync counts from the last sync this session; null before one. */
|
||||||
sync: SyncStatus | null;
|
sync: SyncStatus | null;
|
||||||
/**
|
/**
|
||||||
@@ -47,6 +52,36 @@ export interface Status {
|
|||||||
live: boolean | null;
|
live: boolean | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A marketplace item: one tile set or source file of the version. */
|
||||||
|
export interface MarketItem {
|
||||||
|
/** Stable across versions: `tiles:<style>/<dataset>` or `src:<path>`. */
|
||||||
|
key: string;
|
||||||
|
kind: "tiles" | "src";
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
/** What installing it downloads, in full. */
|
||||||
|
bytes: number;
|
||||||
|
/** Part of every install: shown, never offered for removal. */
|
||||||
|
required: boolean;
|
||||||
|
/** installed: all of it is on this PC; partial: a download that stopped (installing resumes). */
|
||||||
|
presence: "installed" | "partial" | "absent";
|
||||||
|
/** Chosen: installed with the version and kept by updates (required items always are). */
|
||||||
|
selected: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Market {
|
||||||
|
/** The version the items belong to: the installed one, else the DHP's current one. */
|
||||||
|
version: string;
|
||||||
|
/** False before the first install: a choice then waits for Install. */
|
||||||
|
installed: boolean;
|
||||||
|
/** The app and database: what every install downloads besides the items. */
|
||||||
|
core_bytes: number;
|
||||||
|
/** Required items first. */
|
||||||
|
items: MarketItem[];
|
||||||
|
/** Free space where ODIAN is (or will be) installed; null when unknown. */
|
||||||
|
free_bytes: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface SyncStatus {
|
export interface SyncStatus {
|
||||||
/** Local edits the DHP has not taken yet. */
|
/** Local edits the DHP has not taken yet. */
|
||||||
pending: number;
|
pending: number;
|
||||||
@@ -184,6 +219,15 @@ export interface Backend {
|
|||||||
openExternal(url: string): Promise<void>;
|
openExternal(url: string): Promise<void>;
|
||||||
startInstall(): Promise<void>;
|
startInstall(): Promise<void>;
|
||||||
startInstallFromDrive(path: string): Promise<void>;
|
startInstallFromDrive(path: string): Promise<void>;
|
||||||
|
/** The marketplace: every tile set and source file of the version, or of a drive. */
|
||||||
|
getMarket(drive?: string): Promise<Market>;
|
||||||
|
/**
|
||||||
|
* Choose the optional items (keys) and apply it: unchosen ones leave this PC, chosen missing
|
||||||
|
* ones download (install-progress follows). Before the first install it only saves the choice.
|
||||||
|
*/
|
||||||
|
applyMarket(selected: string[]): Promise<void>;
|
||||||
|
/** Choose the optional items an install about to run will bring; changes nothing installed. */
|
||||||
|
setMarket(selected: string[], drive?: string): Promise<void>;
|
||||||
cancelInstall(): Promise<void>;
|
cancelInstall(): Promise<void>;
|
||||||
launch(): Promise<void>;
|
launch(): Promise<void>;
|
||||||
stop(): Promise<void>;
|
stop(): Promise<void>;
|
||||||
|
|||||||
Reference in New Issue
Block a user