summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorDavid Lönnhager <david.l@mullvad.net>2025-01-17 08:56:33 +0100
committerDavid Lönnhager <david.l@mullvad.net>2025-03-05 23:31:48 +0100
commit2b3ca70ff3265c9f9a99341229c2405d3139e57f (patch)
tree6eae2cdcf221e849ee7a87295703f7499ab2bbd6
parenta01d2467326f881e80dce1e29a79da068d38c56f (diff)
downloadmullvadvpn-2b3ca70ff3265c9f9a99341229c2405d3139e57f.tar.xz
mullvadvpn-2b3ca70ff3265c9f9a99341229c2405d3139e57f.zip
Add initial web installer and upgrades scaffold
-rw-r--r--Cargo.lock1385
-rw-r--r--Cargo.toml2
-rw-r--r--installer-downloader/Cargo.toml40
-rw-r--r--installer-downloader/assets/logo-icon.pngbin0 -> 944 bytes
-rw-r--r--installer-downloader/assets/logo-icon.svg37
-rw-r--r--installer-downloader/assets/logo-text.pngbin0 -> 1435 bytes
-rw-r--r--installer-downloader/assets/logo-text.svg19
-rw-r--r--installer-downloader/build.rs30
-rw-r--r--installer-downloader/convert-assets.py8
-rw-r--r--installer-downloader/loader.manifest37
-rw-r--r--installer-downloader/src/cacao_impl/delegate.rs119
-rw-r--r--installer-downloader/src/cacao_impl/mod.rs19
-rw-r--r--installer-downloader/src/cacao_impl/ui.rs335
-rw-r--r--installer-downloader/src/controller.rs210
-rw-r--r--installer-downloader/src/delegate.rs72
-rw-r--r--installer-downloader/src/lib.rs4
-rw-r--r--installer-downloader/src/main.rs39
-rw-r--r--installer-downloader/src/resource.rs47
-rw-r--r--installer-downloader/src/ui_downloader.rs142
-rw-r--r--installer-downloader/src/winapi_impl/delegate.rs147
-rw-r--r--installer-downloader/src/winapi_impl/mod.rs22
-rw-r--r--installer-downloader/src/winapi_impl/ui.rs373
-rw-r--r--installer-downloader/tests/controller.rs366
-rw-r--r--installer-downloader/tests/snapshots/controller__download-2.snap31
-rw-r--r--installer-downloader/tests/snapshots/controller__download-3.snap42
-rw-r--r--installer-downloader/tests/snapshots/controller__download.snap25
-rw-r--r--installer-downloader/tests/snapshots/controller__failed_verification.snap42
-rw-r--r--installer-downloader/tests/snapshots/controller__fetch_version-2.snap25
-rw-r--r--installer-downloader/tests/snapshots/controller__fetch_version.snap23
-rw-r--r--mullvad-update/Cargo.toml31
-rw-r--r--mullvad-update/mullvad-code-signing.gpgbin0 -> 3831 bytes
-rw-r--r--mullvad-update/src/api.rs58
-rw-r--r--mullvad-update/src/app.rs140
-rw-r--r--mullvad-update/src/deserializer.rs221
-rw-r--r--mullvad-update/src/fetch.rs496
-rw-r--r--mullvad-update/src/lib.rs7
-rw-r--r--mullvad-update/src/verify.rs72
-rw-r--r--mullvad-update/test-version-response.json95
38 files changed, 4658 insertions, 103 deletions
diff --git a/Cargo.lock b/Cargo.lock
index c02b5d23b3..9ba618673a 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -24,6 +24,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627"
[[package]]
+name = "adler32"
+version = "1.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "aae1277d39aeec15cb388266ecc24b11c80469deae6067e17a1a7aa9e5c1f234"
+
+[[package]]
name = "aead"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -59,6 +65,15 @@ dependencies = [
]
[[package]]
+name = "aes-kw"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "69fa2b352dcefb5f7f3a5fb840e02665d311d878955380515e4fd50095dd3d8c"
+dependencies = [
+ "aes",
+]
+
+[[package]]
name = "aho-corasick"
version = "1.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -161,6 +176,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69f7f8c3906b62b754cd5326047894316021dcfe5a194c8ea52bdd94934a3457"
[[package]]
+name = "argon2"
+version = "0.5.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072"
+dependencies = [
+ "base64ct",
+ "blake2",
+ "cpufeatures",
+ "password-hash",
+ "zeroize",
+]
+
+[[package]]
name = "arrayref"
version = "0.3.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -168,9 +196,9 @@ checksum = "6b4930d2cb77ce62f89ee5d5289b4ac049559b1c45539271f5ed4fdc7db34545"
[[package]]
name = "arrayvec"
-version = "0.7.4"
+version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "96d30a06541fbafbc7f82ed10c06164cfbd2c401138f6addd8404629c4b16711"
+checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
[[package]]
name = "assert-json-diff"
@@ -199,19 +227,28 @@ version = "0.3.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "16e62a023e7c117e27523144c5d2459f4397fcc3cab0085af8e2224f643a0193"
dependencies = [
- "proc-macro2",
- "quote",
+ "proc-macro2 1.0.92",
+ "quote 1.0.36",
"syn 2.0.89",
]
[[package]]
+name = "async-tempfile"
+version = "0.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "acb90d9834a8015109afc79f1f548223a0614edcbab62fb35b62d4b707e975e7"
+dependencies = [
+ "tokio",
+]
+
+[[package]]
name = "async-trait"
version = "0.1.80"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c6fa2087f2753a7da8cc1c0dbfcf89579dd57458e36769de5ac750b4671737ca"
dependencies = [
- "proc-macro2",
- "quote",
+ "proc-macro2 1.0.92",
+ "quote 1.0.36",
"syn 2.0.89",
]
@@ -332,6 +369,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb"
[[package]]
+name = "bitfield"
+version = "0.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2d7e60934ceec538daadb9d8432424ed043a904d8e0243f3c6446bce549a46ac"
+
+[[package]]
name = "bitflags"
version = "1.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -344,6 +387,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b048fb63fd8b5923fc5aa7b340d8e156aec7ec02f0c78fa8a6ddc2613f6f71de"
[[package]]
+name = "blake2"
+version = "0.10.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe"
+dependencies = [
+ "digest",
+]
+
+[[package]]
name = "blake3"
version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -357,6 +409,12 @@ dependencies = [
]
[[package]]
+name = "block"
+version = "0.1.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a"
+
+[[package]]
name = "block-buffer"
version = "0.10.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -366,6 +424,15 @@ dependencies = [
]
[[package]]
+name = "block-padding"
+version = "0.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93"
+dependencies = [
+ "generic-array",
+]
+
+[[package]]
name = "block2"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -375,6 +442,35 @@ dependencies = [
]
[[package]]
+name = "blowfish"
+version = "0.9.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e412e2cd0f2b2d93e02543ceae7917b3c70331573df19ee046bcbc35e45e87d7"
+dependencies = [
+ "byteorder",
+ "cipher",
+]
+
+[[package]]
+name = "bstr"
+version = "1.11.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "531a9155a481e2ee699d4f98f43c0ca4ff8ee1bfd55c31e9e98fb29d2b176fe0"
+dependencies = [
+ "memchr",
+ "serde",
+]
+
+[[package]]
+name = "buffer-redux"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4e8acf87c5b9f5897cd3ebb9a327f420e0cae9dd4e5c1d2e36f2c84c571a58f1"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
name = "bumpalo"
version = "3.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -399,6 +495,24 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f61dac84819c6588b558454b194026eb1f09c293b9036ae9b159e74e73ab6cf9"
[[package]]
+name = "cacao"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5952f0958672e4aa8fc706d01905c56af57759e078c53a6fddf4a13361943e7a"
+dependencies = [
+ "block",
+ "core-foundation",
+ "core-graphics",
+ "dispatch",
+ "lazy_static",
+ "libc",
+ "objc",
+ "objc_id",
+ "os_info",
+ "url",
+]
+
+[[package]]
name = "camellia"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -409,6 +523,15 @@ dependencies = [
]
[[package]]
+name = "cast5"
+version = "0.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "26b07d673db1ccf000e90f54b819db9e75a8348d6eb056e9b8ab53231b7a9911"
+dependencies = [
+ "cipher",
+]
+
+[[package]]
name = "cbindgen"
version = "0.24.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -417,8 +540,8 @@ dependencies = [
"heck 0.4.1",
"indexmap 1.9.3",
"log",
- "proc-macro2",
- "quote",
+ "proc-macro2 1.0.92",
+ "quote 1.0.36",
"serde",
"serde_json",
"syn 1.0.109",
@@ -435,8 +558,8 @@ dependencies = [
"heck 0.4.1",
"indexmap 2.2.6",
"log",
- "proc-macro2",
- "quote",
+ "proc-macro2 1.0.92",
+ "quote 1.0.36",
"serde",
"serde_json",
"syn 2.0.89",
@@ -460,6 +583,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c"
[[package]]
+name = "cfb-mode"
+version = "0.8.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "738b8d467867f80a71351933f70461f5b56f24d5c93e0cf216e59229c968d330"
+dependencies = [
+ "cipher",
+]
+
+[[package]]
name = "cfg-if"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -563,8 +695,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "528131438037fd55894f62d6e9f068b8f45ac57ffa77517819645d10aed04f64"
dependencies = [
"heck 0.5.0",
- "proc-macro2",
- "quote",
+ "proc-macro2 1.0.92",
+ "quote 1.0.36",
"syn 2.0.89",
]
@@ -586,6 +718,23 @@ dependencies = [
]
[[package]]
+name = "cmac"
+version = "0.7.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8543454e3c3f5126effff9cd44d562af4e31fb8ce1cc0d3dcd8f084515dbc1aa"
+dependencies = [
+ "cipher",
+ "dbl",
+ "digest",
+]
+
+[[package]]
+name = "color_quant"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b"
+
+[[package]]
name = "colorchoice"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -623,6 +772,18 @@ dependencies = [
]
[[package]]
+name = "console"
+version = "0.15.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ea3c6ecd8059b57859df5c69830340ed3c41d30e3da0c1cbed90a96ac853041b"
+dependencies = [
+ "encode_unicode",
+ "libc",
+ "once_cell",
+ "windows-sys 0.59.0",
+]
+
+[[package]]
name = "const-oid"
version = "0.9.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -651,6 +812,30 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06ea2b9bc92be3c2baa9334a323ebca2d6f074ff852cd1d7b11064035cd3868f"
[[package]]
+name = "core-graphics"
+version = "0.22.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2581bbab3b8ffc6fcbd550bf46c355135d16e9ff2a6ea032ad6b9bf1d7efe4fb"
+dependencies = [
+ "bitflags 1.3.2",
+ "core-foundation",
+ "core-graphics-types",
+ "foreign-types",
+ "libc",
+]
+
+[[package]]
+name = "core-graphics-types"
+version = "0.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf"
+dependencies = [
+ "bitflags 1.3.2",
+ "core-foundation",
+ "libc",
+]
+
+[[package]]
name = "cpufeatures"
version = "0.2.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -660,6 +845,12 @@ dependencies = [
]
[[package]]
+name = "crc24"
+version = "0.1.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fd121741cf3eb82c08dd3023eb55bf2665e5f60ec20f89760cf836ae4562e6a0"
+
+[[package]]
name = "crc32fast"
version = "1.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -678,6 +869,25 @@ dependencies = [
]
[[package]]
+name = "crossbeam-deque"
+version = "0.8.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51"
+dependencies = [
+ "crossbeam-epoch",
+ "crossbeam-utils",
+]
+
+[[package]]
+name = "crossbeam-epoch"
+version = "0.9.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e"
+dependencies = [
+ "crossbeam-utils",
+]
+
+[[package]]
name = "crossbeam-utils"
version = "0.8.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -734,7 +944,8 @@ dependencies = [
"cfg-if",
"cpufeatures",
"curve25519-dalek-derive",
- "fiat-crypto",
+ "digest",
+ "fiat-crypto 0.2.8",
"rustc_version",
"subtle",
"zeroize",
@@ -746,8 +957,8 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3"
dependencies = [
- "proc-macro2",
- "quote",
+ "proc-macro2 1.0.92",
+ "quote 1.0.36",
"syn 2.0.89",
]
@@ -769,8 +980,8 @@ checksum = "95133861a8032aaea082871032f5815eb9e98cef03fa916ab4500513994df9e5"
dependencies = [
"fnv",
"ident_case",
- "proc-macro2",
- "quote",
+ "proc-macro2 1.0.92",
+ "quote 1.0.36",
"strsim",
"syn 2.0.89",
]
@@ -782,7 +993,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d336a2a514f6ccccaa3e09b02d41d35330c07ddf03a62165fcec10bb561c7806"
dependencies = [
"darling_core",
- "quote",
+ "quote 1.0.36",
"syn 2.0.89",
]
@@ -806,6 +1017,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7e962a19be5cfc3f3bf6dd8f61eb50107f356ad6270fbb3ed41476571db78be5"
[[package]]
+name = "dbl"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bd2735a791158376708f9347fe8faba9667589d82427ef3aed6794a8981de3d9"
+dependencies = [
+ "generic-array",
+]
+
+[[package]]
name = "dbus"
version = "0.9.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -817,12 +1037,23 @@ dependencies = [
]
[[package]]
+name = "deflate"
+version = "0.7.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "707b6a7b384888a70c8d2e8650b3e60170dfc6a67bb4aa67b6dfca57af4bedb4"
+dependencies = [
+ "adler32",
+ "byteorder",
+]
+
+[[package]]
name = "der"
version = "0.7.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f55bf8e7b65898637379c1b74eb1551107c8294ed26d855ceb9fd1a09cfc9bc0"
dependencies = [
"const-oid",
+ "pem-rfc7468",
"zeroize",
]
@@ -841,8 +1072,8 @@ version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "302ccf094df1151173bb6f5a2282fcd2f45accd5eae1bdf82dcbfefbc501ad5c"
dependencies = [
- "proc-macro2",
- "quote",
+ "proc-macro2 1.0.92",
+ "quote 1.0.36",
"syn 1.0.109",
]
@@ -862,8 +1093,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d48cda787f839151732d396ac69e3473923d54312c070ee21e9effcaa8ca0b1d"
dependencies = [
"darling",
- "proc-macro2",
- "quote",
+ "proc-macro2 1.0.92",
+ "quote 1.0.36",
"syn 2.0.89",
]
@@ -878,12 +1109,43 @@ dependencies = [
]
[[package]]
+name = "derive_more"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4a9b99b9cbbe49445b21764dc0625032a89b145a2642e67603e1c936f5458d05"
+dependencies = [
+ "derive_more-impl",
+]
+
+[[package]]
+name = "derive_more-impl"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cb7330aeadfbe296029522e6c40f315320aba36fc43a5b3632f3795348f3bd22"
+dependencies = [
+ "proc-macro2 1.0.92",
+ "quote 1.0.36",
+ "syn 2.0.89",
+ "unicode-xid 0.2.6",
+]
+
+[[package]]
+name = "des"
+version = "0.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ffdd80ce8ce993de27e9f063a444a4d53ce8e8db4c1f00cc03af5ad5a9867a1e"
+dependencies = [
+ "cipher",
+]
+
+[[package]]
name = "digest"
version = "0.10.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [
"block-buffer",
+ "const-oid",
"crypto-common",
"subtle",
]
@@ -910,17 +1172,39 @@ dependencies = [
]
[[package]]
+name = "dispatch"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b"
+
+[[package]]
name = "displaydoc"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0"
dependencies = [
- "proc-macro2",
- "quote",
+ "proc-macro2 1.0.92",
+ "quote 1.0.36",
"syn 2.0.89",
]
[[package]]
+name = "dsa"
+version = "0.6.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "48bc224a9084ad760195584ce5abb3c2c34a225fa312a128ad245a6b412b7689"
+dependencies = [
+ "digest",
+ "num-bigint-dig",
+ "num-traits",
+ "pkcs8",
+ "rfc6979",
+ "sha2",
+ "signature",
+ "zeroize",
+]
+
+[[package]]
name = "duct"
version = "0.13.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -933,14 +1217,30 @@ dependencies = [
]
[[package]]
+name = "eax"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9954fabd903b82b9d7a68f65f97dc96dd9ad368e40ccc907a7c19d53e6bfac28"
+dependencies = [
+ "aead",
+ "cipher",
+ "cmac",
+ "ctr",
+ "subtle",
+]
+
+[[package]]
name = "ecdsa"
version = "0.16.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca"
dependencies = [
"der",
+ "digest",
"elliptic-curve",
+ "rfc6979",
"signature",
+ "spki",
]
[[package]]
@@ -954,6 +1254,31 @@ dependencies = [
]
[[package]]
+name = "ed25519-dalek"
+version = "2.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4a3daa8e81a3963a60642bcc1f90a670680bd4a77535faa384e9d1c79d620871"
+dependencies = [
+ "curve25519-dalek",
+ "ed25519",
+ "serde",
+ "sha2",
+ "subtle",
+ "zeroize",
+]
+
+[[package]]
+name = "ed448-goldilocks"
+version = "0.7.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "87b5fa9e9e3dd5fe1369f380acd3dcdfa766dbd0a1cd5b048fb40e38a6a78e79"
+dependencies = [
+ "fiat-crypto 0.1.20",
+ "hex",
+ "subtle",
+]
+
+[[package]]
name = "either"
version = "1.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -971,6 +1296,9 @@ dependencies = [
"ff",
"generic-array",
"group",
+ "hkdf",
+ "pem-rfc7468",
+ "pkcs8",
"rand_core 0.6.4",
"sec1",
"subtle",
@@ -978,14 +1306,29 @@ dependencies = [
]
[[package]]
+name = "encode_unicode"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0"
+
+[[package]]
+name = "encoding_rs"
+version = "0.8.35"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3"
+dependencies = [
+ "cfg-if",
+]
+
+[[package]]
name = "enum-as-inner"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5ffccbb6966c05b32ef8fbac435df276c4ae4d3dc55a8cd0eb9745e6c12f546a"
dependencies = [
"heck 0.4.1",
- "proc-macro2",
- "quote",
+ "proc-macro2 1.0.92",
+ "quote 1.0.36",
"syn 2.0.89",
]
@@ -1004,8 +1347,8 @@ version = "0.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f282cfdfe92516eb26c2af8589c274c7c17681f5ecc03c18255fe741c6aa64eb"
dependencies = [
- "proc-macro2",
- "quote",
+ "proc-macro2 1.0.92",
+ "quote 1.0.36",
"syn 2.0.89",
]
@@ -1103,6 +1446,12 @@ dependencies = [
[[package]]
name = "fiat-crypto"
+version = "0.1.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e825f6987101665dea6ec934c09ec6d721de7bc1bf92248e1d5810c8cd636b77"
+
+[[package]]
+name = "fiat-crypto"
version = "0.2.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "38793c55593b33412e3ae40c2c9781ffaa6f438f6f8c10f24e71846fbd7ae01e"
@@ -1142,6 +1491,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
[[package]]
+name = "foreign-types"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1"
+dependencies = [
+ "foreign-types-shared",
+]
+
+[[package]]
+name = "foreign-types-shared"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b"
+
+[[package]]
name = "form_urlencoded"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1213,8 +1577,8 @@ version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650"
dependencies = [
- "proc-macro2",
- "quote",
+ "proc-macro2 1.0.92",
+ "quote 1.0.36",
"syn 2.0.89",
]
@@ -1294,6 +1658,16 @@ dependencies = [
]
[[package]]
+name = "gif"
+version = "0.10.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "471d90201b3b223f3451cd4ad53e34295f16a1df17b1edf3736d47761c3981af"
+dependencies = [
+ "color_quant",
+ "lzw",
+]
+
+[[package]]
name = "gimli"
version = "0.28.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1642,6 +2016,22 @@ dependencies = [
]
[[package]]
+name = "hyper-tls"
+version = "0.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0"
+dependencies = [
+ "bytes",
+ "http-body-util",
+ "hyper",
+ "hyper-util",
+ "native-tls",
+ "tokio",
+ "tokio-native-tls",
+ "tower-service",
+]
+
+[[package]]
name = "hyper-util"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1796,12 +2186,21 @@ version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ec89e9337638ecdc08744df490b221a7399bf8d164eb52a665454e60e075ad6"
dependencies = [
- "proc-macro2",
- "quote",
+ "proc-macro2 1.0.92",
+ "quote 1.0.36",
"syn 2.0.89",
]
[[package]]
+name = "idea"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "075557004419d7f2031b8bb7f44bb43e55a83ca7b63076a8fb8fe75753836477"
+dependencies = [
+ "cipher",
+]
+
+[[package]]
name = "ident_case"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1829,6 +2228,24 @@ dependencies = [
]
[[package]]
+name = "image"
+version = "0.19.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ebdff791af04e30089bde8ad2a632b86af433b40c04db8d70ad4b21487db7a6a"
+dependencies = [
+ "byteorder",
+ "gif",
+ "jpeg-decoder",
+ "lzw",
+ "num-derive",
+ "num-iter",
+ "num-rational",
+ "num-traits",
+ "png",
+ "scoped_threadpool",
+]
+
+[[package]]
name = "indexmap"
version = "1.9.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1849,6 +2266,15 @@ dependencies = [
]
[[package]]
+name = "inflate"
+version = "0.4.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1cdb29978cc5797bd8dcc8e5bf7de604891df2a8dc576973d71a281e916db2ff"
+dependencies = [
+ "adler32",
+]
+
+[[package]]
name = "inotify"
version = "0.9.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1891,6 +2317,38 @@ dependencies = [
]
[[package]]
+name = "insta"
+version = "1.42.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "71c1b125e30d93896b365e156c33dadfffab45ee8400afcbba4752f59de08a86"
+dependencies = [
+ "console",
+ "linked-hash-map",
+ "once_cell",
+ "pin-project",
+ "serde",
+ "similar",
+]
+
+[[package]]
+name = "installer-downloader"
+version = "0.0.0"
+dependencies = [
+ "anyhow",
+ "async-trait",
+ "cacao",
+ "insta",
+ "mullvad-update",
+ "native-windows-gui",
+ "nsvg",
+ "objc_id",
+ "serde",
+ "tokio",
+ "windows-sys 0.52.0",
+ "winres",
+]
+
+[[package]]
name = "internet-checksum"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1900,8 +2358,8 @@ checksum = "fc6d6206008e25125b1f97fbe5d309eb7b85141cf9199d52dbd3729a1584dd16"
name = "intersection-derive"
version = "0.0.0"
dependencies = [
- "proc-macro2",
- "quote",
+ "proc-macro2 1.0.92",
+ "quote 1.0.36",
"syn 2.0.89",
]
@@ -1965,6 +2423,12 @@ dependencies = [
]
[[package]]
+name = "iter-read"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "071ed4cc1afd86650602c7b11aa2e1ce30762a1c27193201cb5cee9c6ebb1294"
+
+[[package]]
name = "itertools"
version = "0.10.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2027,12 +2491,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "002f4dfe6d97ae88c33f3489c0d31ffc6f81d9a492de98ff113b127d73bafff8"
dependencies = [
"heck 0.4.1",
- "proc-macro2",
- "quote",
+ "proc-macro2 1.0.92",
+ "quote 1.0.36",
"syn 1.0.109",
]
[[package]]
+name = "jpeg-decoder"
+version = "0.1.22"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "229d53d58899083193af11e15917b5640cd40b29ff475a1fe4ef725deb02d0f2"
+dependencies = [
+ "rayon",
+]
+
+[[package]]
name = "js-sys"
version = "0.3.69"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2042,6 +2515,17 @@ dependencies = [
]
[[package]]
+name = "json-canon"
+version = "0.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "447ae153a2bd47d61acc0d131295408e32ef87ed9785825a6f4ecef85afc0edb"
+dependencies = [
+ "ryu-js",
+ "serde",
+ "serde_json",
+]
+
+[[package]]
name = "json5"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2053,6 +2537,20 @@ dependencies = [
]
[[package]]
+name = "k256"
+version = "0.13.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f6e3919bbaa2945715f0bb6d3934a173d1e9a59ac23767fbaaef277265a7411b"
+dependencies = [
+ "cfg-if",
+ "ecdsa",
+ "elliptic-curve",
+ "once_cell",
+ "sha2",
+ "signature",
+]
+
+[[package]]
name = "keccak"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2096,6 +2594,9 @@ name = "lazy_static"
version = "1.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646"
+dependencies = [
+ "spin 0.5.2",
+]
[[package]]
name = "libc"
@@ -2124,6 +2625,12 @@ dependencies = [
[[package]]
name = "libm"
+version = "0.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7fc7aa29613bd6a620df431842069224d8bc9011086b1db4c0e0cd47fa03ec9a"
+
+[[package]]
+name = "libm"
version = "0.2.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ec2a862134d2a7d32d7983ddcdd1c4923530833c9f2ea1a44fc5fa473989058"
@@ -2168,9 +2675,9 @@ dependencies = [
[[package]]
name = "log"
-version = "0.4.21"
+version = "0.4.25"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "90ed8c1e510134f979dbc4f070f87d4313098b704861a105fe34231c70a3901c"
+checksum = "04cbf5b083de1c7e0222a7a51dbfdba1cbe1c6ab0b15e29fff3f6c077fd9cd9f"
[[package]]
name = "log-panics"
@@ -2197,6 +2704,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9106e1d747ffd48e6be5bb2d97fa706ed25b144fbee4d5c02eae110cd8d6badd"
[[package]]
+name = "lzw"
+version = "0.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7d947cbb889ed21c2a84be6ffbaebf5b4e0f4340638cba0444907e38b56be084"
+
+[[package]]
+name = "malloc_buf"
+version = "0.0.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb"
+dependencies = [
+ "libc",
+]
+
+[[package]]
name = "match_cfg"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2277,6 +2799,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
[[package]]
+name = "minimal-lexical"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
+
+[[package]]
name = "miniz_oxide"
version = "0.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2696,6 +3224,27 @@ dependencies = [
]
[[package]]
+name = "mullvad-update"
+version = "0.0.0"
+dependencies = [
+ "anyhow",
+ "async-tempfile",
+ "async-trait",
+ "chrono",
+ "ed25519-dalek",
+ "hex",
+ "json-canon",
+ "mockito",
+ "mullvad-version",
+ "pgp",
+ "rand 0.8.5",
+ "reqwest",
+ "serde",
+ "serde_json",
+ "tokio",
+]
+
+[[package]]
name = "mullvad-version"
version = "0.0.0"
dependencies = [
@@ -2710,6 +3259,39 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "defc4c55412d89136f966bbb339008b474350e5e6e78d2714439c386b3137a03"
[[package]]
+name = "native-tls"
+version = "0.2.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a8614eb2c83d59d1c8cc974dd3f920198647674a0a035e1af1fa58707e317466"
+dependencies = [
+ "libc",
+ "log",
+ "openssl",
+ "openssl-probe",
+ "openssl-sys",
+ "schannel",
+ "security-framework",
+ "security-framework-sys",
+ "tempfile",
+]
+
+[[package]]
+name = "native-windows-gui"
+version = "1.0.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4f7003a669f68deb6b7c57d74fff4f8e533c44a3f0b297492440ef4ff5a28454"
+dependencies = [
+ "bitflags 1.3.2",
+ "lazy_static",
+ "newline-converter",
+ "plotters",
+ "plotters-backend",
+ "stretch",
+ "winapi",
+ "winapi-build",
+]
+
+[[package]]
name = "natord"
version = "1.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2736,7 +3318,7 @@ version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c6813fde79b646e47e7ad75f480aa80ef76a5d9599e2717407961531169ee38b"
dependencies = [
- "quote",
+ "quote 1.0.36",
"syn 2.0.89",
"syn-mid",
]
@@ -2808,6 +3390,15 @@ dependencies = [
]
[[package]]
+name = "newline-converter"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1f71d09d5c87634207f894c6b31b6a2b2c64ea3bdcf71bd5599fdbbe1600c00f"
+dependencies = [
+ "unicode-segmentation",
+]
+
+[[package]]
name = "nftnl"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2886,6 +3477,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "43794a0ace135be66a25d3ae77d41b91615fb68ae937f904090203e81f755b65"
[[package]]
+name = "nom"
+version = "7.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a"
+dependencies = [
+ "memchr",
+ "minimal-lexical",
+]
+
+[[package]]
name = "notify"
version = "6.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2914,19 +3515,118 @@ dependencies = [
]
[[package]]
+name = "nsvg"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0bfa50149c05ca80b01c6a30452084a98d96279f911df8b6840bd18b068cc120"
+dependencies = [
+ "cc",
+ "image",
+]
+
+[[package]]
+name = "num-bigint-dig"
+version = "0.8.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dc84195820f291c7697304f3cbdadd1cb7199c0efc917ff5eafd71225c136151"
+dependencies = [
+ "byteorder",
+ "lazy_static",
+ "libm 0.2.8",
+ "num-integer",
+ "num-iter",
+ "num-traits",
+ "rand 0.8.5",
+ "serde",
+ "smallvec",
+ "zeroize",
+]
+
+[[package]]
name = "num-conv"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9"
[[package]]
+name = "num-derive"
+version = "0.2.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "eafd0b45c5537c3ba526f79d3e75120036502bebacbb3f3220914067ce39dbf2"
+dependencies = [
+ "proc-macro2 0.4.30",
+ "quote 0.6.13",
+ "syn 0.15.44",
+]
+
+[[package]]
+name = "num-integer"
+version = "0.1.46"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f"
+dependencies = [
+ "num-traits",
+]
+
+[[package]]
+name = "num-iter"
+version = "0.1.45"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf"
+dependencies = [
+ "autocfg",
+ "num-integer",
+ "num-traits",
+]
+
+[[package]]
+name = "num-rational"
+version = "0.1.42"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ee314c74bd753fc86b4780aa9475da469155f3848473a261d2d18e35245a784e"
+dependencies = [
+ "num-integer",
+ "num-traits",
+]
+
+[[package]]
name = "num-traits"
version = "0.2.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da0df0e5185db44f69b44f26786fe401b6c293d1907744beaa7fa62b2e5a517a"
dependencies = [
"autocfg",
- "libm",
+ "libm 0.2.8",
+]
+
+[[package]]
+name = "num_enum"
+version = "0.7.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4e613fc340b2220f734a8595782c551f1250e969d87d3be1ae0579e8d4065179"
+dependencies = [
+ "num_enum_derive",
+]
+
+[[package]]
+name = "num_enum_derive"
+version = "0.7.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "af1844ef2428cc3e1cb900be36181049ef3d3193c63e43026cfe202983b27a56"
+dependencies = [
+ "proc-macro-crate",
+ "proc-macro2 1.0.92",
+ "quote 1.0.36",
+ "syn 2.0.89",
+]
+
+[[package]]
+name = "objc"
+version = "0.2.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1"
+dependencies = [
+ "malloc_buf",
]
[[package]]
@@ -3032,6 +3732,15 @@ dependencies = [
]
[[package]]
+name = "objc_id"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c92d4ddb4bd7b50d730c215ff871754d0da6b2178849f8a2a2ab69712d0c073b"
+dependencies = [
+ "objc",
+]
+
+[[package]]
name = "object"
version = "0.32.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -3041,10 +3750,22 @@ dependencies = [
]
[[package]]
+name = "ocb3"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c196e0276c471c843dd5777e7543a36a298a4be942a2a688d8111cd43390dedb"
+dependencies = [
+ "aead",
+ "cipher",
+ "ctr",
+ "subtle",
+]
+
+[[package]]
name = "once_cell"
-version = "1.19.0"
+version = "1.20.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92"
+checksum = "945462a4b81e43c4e3ba96bd7b49d834c6f61198356aa858733bc4acf3cbe62e"
[[package]]
name = "opaque-debug"
@@ -3053,6 +3774,50 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
[[package]]
+name = "openssl"
+version = "0.10.68"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6174bc48f102d208783c2c84bf931bb75927a617866870de8a4ea85597f871f5"
+dependencies = [
+ "bitflags 2.6.0",
+ "cfg-if",
+ "foreign-types",
+ "libc",
+ "once_cell",
+ "openssl-macros",
+ "openssl-sys",
+]
+
+[[package]]
+name = "openssl-macros"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
+dependencies = [
+ "proc-macro2 1.0.92",
+ "quote 1.0.36",
+ "syn 2.0.89",
+]
+
+[[package]]
+name = "openssl-probe"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ff011a302c396a5197692431fc1948019154afc178baf7d8e37367442a4601cf"
+
+[[package]]
+name = "openssl-sys"
+version = "0.9.104"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "45abf306cbf99debc8195b66b7346498d7b10c210de50418b5ccd7ceba08c741"
+dependencies = [
+ "cc",
+ "libc",
+ "pkg-config",
+ "vcpkg",
+]
+
+[[package]]
name = "openvpn-plugin"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -3070,6 +3835,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
[[package]]
+name = "os_info"
+version = "3.9.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6e6520c8cc998c5741ee68ec1dc369fc47e5f0ea5320018ecf2a1ccd6328f48b"
+dependencies = [
+ "log",
+ "serde",
+ "windows-sys 0.52.0",
+]
+
+[[package]]
name = "os_pipe"
version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -3098,6 +3874,8 @@ checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b"
dependencies = [
"ecdsa",
"elliptic-curve",
+ "primeorder",
+ "sha2",
]
[[package]]
@@ -3109,6 +3887,21 @@ dependencies = [
"ecdsa",
"elliptic-curve",
"primeorder",
+ "sha2",
+]
+
+[[package]]
+name = "p521"
+version = "0.13.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0fc9e2161f1f215afdfce23677034ae137bbd45016a880c2eb3ba8eb95f085b2"
+dependencies = [
+ "base16ct",
+ "ecdsa",
+ "elliptic-curve",
+ "primeorder",
+ "rand_core 0.6.4",
+ "sha2",
]
[[package]]
@@ -3149,6 +3942,17 @@ dependencies = [
]
[[package]]
+name = "password-hash"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166"
+dependencies = [
+ "base64ct",
+ "rand_core 0.6.4",
+ "subtle",
+]
+
+[[package]]
name = "paste"
version = "1.0.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -3172,6 +3976,15 @@ dependencies = [
]
[[package]]
+name = "pem-rfc7468"
+version = "0.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412"
+dependencies = [
+ "base64ct",
+]
+
+[[package]]
name = "percent-encoding"
version = "2.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -3206,8 +4019,8 @@ checksum = "3ec22af7d3fb470a85dd2ca96b7c577a1eb4ef6f1683a9fe9a8c16e136c04687"
dependencies = [
"pest",
"pest_meta",
- "proc-macro2",
- "quote",
+ "proc-macro2 1.0.92",
+ "quote 1.0.36",
"syn 2.0.89",
]
@@ -3245,6 +4058,73 @@ dependencies = [
]
[[package]]
+name = "pgp"
+version = "0.14.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1877a97fd422433220ad272eb008ec55691944b1200e9eb204e3cb2cb69d34e9"
+dependencies = [
+ "aes",
+ "aes-gcm",
+ "aes-kw",
+ "argon2",
+ "base64 0.22.1",
+ "bitfield",
+ "block-padding",
+ "blowfish",
+ "bstr",
+ "buffer-redux",
+ "byteorder",
+ "camellia",
+ "cast5",
+ "cfb-mode",
+ "chrono",
+ "cipher",
+ "const-oid",
+ "crc24",
+ "curve25519-dalek",
+ "derive_builder",
+ "derive_more",
+ "des",
+ "digest",
+ "dsa",
+ "eax",
+ "ecdsa",
+ "ed25519-dalek",
+ "elliptic-curve",
+ "flate2",
+ "generic-array",
+ "hex",
+ "hkdf",
+ "idea",
+ "iter-read",
+ "k256",
+ "log",
+ "md-5",
+ "nom",
+ "num-bigint-dig",
+ "num-traits",
+ "num_enum",
+ "ocb3",
+ "p256",
+ "p384",
+ "p521",
+ "rand 0.8.5",
+ "ripemd",
+ "rsa",
+ "sha1",
+ "sha1-checked",
+ "sha2",
+ "sha3",
+ "signature",
+ "smallvec",
+ "thiserror 1.0.59",
+ "twofish",
+ "x25519-dalek",
+ "x448",
+ "zeroize",
+]
+
+[[package]]
name = "phf"
version = "0.11.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -3297,8 +4177,8 @@ version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2f38a4412a78282e09a2cf38d195ea5420d15ba0602cb375210efbc877243965"
dependencies = [
- "proc-macro2",
- "quote",
+ "proc-macro2 1.0.92",
+ "quote 1.0.36",
"syn 2.0.89",
]
@@ -3315,6 +4195,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
[[package]]
+name = "pkcs1"
+version = "0.7.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f"
+dependencies = [
+ "der",
+ "pkcs8",
+ "spki",
+]
+
+[[package]]
name = "pkcs8"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -3331,6 +4222,24 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d231b230927b5e4ad203db57bbcbee2802f6bce620b1e4a9024a07d94e2907ec"
[[package]]
+name = "plotters"
+version = "0.3.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5aeb6f403d7a4911efb1e33402027fc44f29b5bf6def3effcc22d7bb75f2b747"
+dependencies = [
+ "num-traits",
+ "plotters-backend",
+ "wasm-bindgen",
+ "web-sys",
+]
+
+[[package]]
+name = "plotters-backend"
+version = "0.3.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "df42e13c12958a16b3f7f4386b9ab1f3e7933914ecea48da7139435263a4172a"
+
+[[package]]
name = "pnet_base"
version = "0.34.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -3354,8 +4263,8 @@ version = "0.34.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "688b17499eee04a0408aca0aa5cba5fc86401d7216de8a63fdf7a4c227871804"
dependencies = [
- "proc-macro2",
- "quote",
+ "proc-macro2 1.0.92",
+ "quote 1.0.36",
"regex",
"syn 2.0.89",
]
@@ -3366,8 +4275,8 @@ version = "0.35.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13325ac86ee1a80a480b0bc8e3d30c25d133616112bb16e86f712dcf8a71c863"
dependencies = [
- "proc-macro2",
- "quote",
+ "proc-macro2 1.0.92",
+ "quote 1.0.36",
"regex",
"syn 2.0.89",
]
@@ -3415,6 +4324,18 @@ dependencies = [
]
[[package]]
+name = "png"
+version = "0.12.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f54b9600d584d3b8a739e1662a595fab051329eff43f20e7d8cc22872962145b"
+dependencies = [
+ "bitflags 1.3.2",
+ "deflate",
+ "inflate",
+ "num-iter",
+]
+
+[[package]]
name = "poly1305"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -3465,7 +4386,7 @@ version = "0.2.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5ac2cf0f2e4f42b49f5ffd07dae8d746508ef7526c13940e5f524012ae6c6550"
dependencies = [
- "proc-macro2",
+ "proc-macro2 1.0.92",
"syn 2.0.89",
]
@@ -3479,6 +4400,24 @@ dependencies = [
]
[[package]]
+name = "proc-macro-crate"
+version = "3.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8ecf48c7ca261d60b74ab1a7b20da18bede46776b2e55535cb958eb595c5fa7b"
+dependencies = [
+ "toml_edit",
+]
+
+[[package]]
+name = "proc-macro2"
+version = "0.4.30"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cf3d2011ab5c909338f7887f4fc896d35932e29146c12c8d01da6b22a80ba759"
+dependencies = [
+ "unicode-xid 0.1.0",
+]
+
+[[package]]
name = "proc-macro2"
version = "1.0.92"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -3556,8 +4495,8 @@ checksum = "19de2de2a00075bf566bee3bd4db014b11587e84184d3f7a791bc17f1a8e9e48"
dependencies = [
"anyhow",
"itertools 0.12.1",
- "proc-macro2",
- "quote",
+ "proc-macro2 1.0.92",
+ "quote 1.0.36",
"syn 2.0.89",
]
@@ -3569,8 +4508,8 @@ checksum = "e9552f850d5f0964a4e4d0bf306459ac29323ddfbae05e35a7c0d35cb0803cc5"
dependencies = [
"anyhow",
"itertools 0.12.1",
- "proc-macro2",
- "quote",
+ "proc-macro2 1.0.92",
+ "quote 1.0.36",
"syn 2.0.89",
]
@@ -3662,11 +4601,20 @@ dependencies = [
[[package]]
name = "quote"
+version = "0.6.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6ce23b6b870e8f94f81fb0a363d65d86675884b34a09043c81e5562f11c1f8e1"
+dependencies = [
+ "proc-macro2 0.4.30",
+]
+
+[[package]]
+name = "quote"
version = "1.0.36"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fa76aaf39101c457836aec0ce2316dbdc3ab723cdda1c6bd4e6ad4208acaca7"
dependencies = [
- "proc-macro2",
+ "proc-macro2 1.0.92",
]
[[package]]
@@ -3760,6 +4708,26 @@ dependencies = [
]
[[package]]
+name = "rayon"
+version = "1.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b418a60154510ca1a002a752ca9714984e21e4241e804d32555251faf8b78ffa"
+dependencies = [
+ "either",
+ "rayon-core",
+]
+
+[[package]]
+name = "rayon-core"
+version = "1.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1465873a3dfdaa8ae7cb14b4383657caab0b3e8a0aa9ae8e04b044854c8dfce2"
+dependencies = [
+ "crossbeam-deque",
+ "crossbeam-utils",
+]
+
+[[package]]
name = "redox_syscall"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -3822,18 +4790,23 @@ checksum = "a77c62af46e79de0a562e1a9849205ffcb7fc1238876e9bd743357570e04046f"
dependencies = [
"base64 0.22.1",
"bytes",
+ "encoding_rs",
+ "futures-channel",
"futures-core",
"futures-util",
+ "h2 0.4.4",
"http 1.1.0",
"http-body",
"http-body-util",
"hyper",
"hyper-rustls",
+ "hyper-tls",
"hyper-util",
"ipnet",
"js-sys",
"log",
"mime",
+ "native-tls",
"once_cell",
"percent-encoding",
"pin-project-lite",
@@ -3845,7 +4818,9 @@ dependencies = [
"serde_json",
"serde_urlencoded",
"sync_wrapper 1.0.1",
+ "system-configuration 0.6.1",
"tokio",
+ "tokio-native-tls",
"tokio-rustls 0.26.0",
"tower-service",
"url",
@@ -3867,6 +4842,16 @@ dependencies = [
]
[[package]]
+name = "rfc6979"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2"
+dependencies = [
+ "hmac",
+ "subtle",
+]
+
+[[package]]
name = "ring"
version = "0.17.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -3876,7 +4861,7 @@ dependencies = [
"cfg-if",
"getrandom 0.2.14",
"libc",
- "spin",
+ "spin 0.9.8",
"untrusted",
"windows-sys 0.52.0",
]
@@ -3901,12 +4886,41 @@ dependencies = [
]
[[package]]
+name = "ripemd"
+version = "0.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bd124222d17ad93a644ed9d011a40f4fb64aa54275c08cc216524a9ea82fb09f"
+dependencies = [
+ "digest",
+]
+
+[[package]]
name = "rs-release"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "21efba391745f92fc14a5cccb008e711a1a3708d8dacd2e69d88d5de513c117a"
[[package]]
+name = "rsa"
+version = "0.9.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "47c75d7c5c6b673e58bf54d8544a9f432e3a925b0e80f7cd3602ab5c50c55519"
+dependencies = [
+ "const-oid",
+ "digest",
+ "num-bigint-dig",
+ "num-integer",
+ "num-traits",
+ "pkcs1",
+ "pkcs8",
+ "rand_core 0.6.4",
+ "signature",
+ "spki",
+ "subtle",
+ "zeroize",
+]
+
+[[package]]
name = "rtnetlink"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -4056,6 +5070,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e86697c916019a8588c99b5fac3cead74ec0b4b819707a682fd4d23fa0ce1ba1"
[[package]]
+name = "ryu-js"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6518fc26bced4d53678a22d6e423e9d8716377def84545fe328236e3af070e7f"
+
+[[package]]
name = "same-file"
version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -4065,6 +5085,21 @@ dependencies = [
]
[[package]]
+name = "schannel"
+version = "0.1.27"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1f29ebaa345f945cec9fbbc532eb307f0fdad8161f281b6369539c8d84876b3d"
+dependencies = [
+ "windows-sys 0.59.0",
+]
+
+[[package]]
+name = "scoped_threadpool"
+version = "0.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1d51f5df5af43ab3f1360b429fa5e0152ac5ce8c0bd6485cae490332e96846a8"
+
+[[package]]
name = "scopeguard"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -4089,11 +5124,35 @@ dependencies = [
"base16ct",
"der",
"generic-array",
+ "pkcs8",
"subtle",
"zeroize",
]
[[package]]
+name = "security-framework"
+version = "2.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02"
+dependencies = [
+ "bitflags 2.6.0",
+ "core-foundation",
+ "core-foundation-sys",
+ "libc",
+ "security-framework-sys",
+]
+
+[[package]]
+name = "security-framework-sys"
+version = "2.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "49db231d56a190491cb4aeda9527f1ad45345af50b0851622a7adb8c03b01c32"
+dependencies = [
+ "core-foundation-sys",
+ "libc",
+]
+
+[[package]]
name = "semver"
version = "1.0.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -4130,8 +5189,8 @@ version = "1.0.217"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a9bf7cf98d04a2b28aead066b7496853d4779c9cc183c440dbac457641e19a0"
dependencies = [
- "proc-macro2",
- "quote",
+ "proc-macro2 1.0.92",
+ "quote 1.0.36",
"syn 2.0.89",
]
@@ -4180,6 +5239,17 @@ dependencies = [
]
[[package]]
+name = "sha1-checked"
+version = "0.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "89f599ac0c323ebb1c6082821a54962b839832b03984598375bff3975b804423"
+dependencies = [
+ "digest",
+ "sha1",
+ "zeroize",
+]
+
+[[package]]
name = "sha2"
version = "0.10.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -4239,7 +5309,7 @@ dependencies = [
"serde_urlencoded",
"shadowsocks-crypto",
"socket2",
- "spin",
+ "spin 0.9.8",
"thiserror 1.0.59",
"tokio",
"tokio-tfo",
@@ -4298,7 +5368,7 @@ dependencies = [
"serde",
"shadowsocks",
"socket2",
- "spin",
+ "spin 0.9.8",
"thiserror 1.0.59",
"tokio",
"windows-sys 0.59.0",
@@ -4341,6 +5411,7 @@ version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
dependencies = [
+ "digest",
"rand_core 0.6.4",
]
@@ -4393,6 +5464,12 @@ dependencies = [
[[package]]
name = "spin"
+version = "0.5.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6e63cff320ae2c57904679ba7cb63280a3dc4613885beafb148ee7bf9aa9042d"
+
+[[package]]
+name = "spin"
version = "0.9.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67"
@@ -4417,6 +5494,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3"
[[package]]
+name = "stretch"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7b0dc6d20ce137f302edf90f9cd3d278866fd7fb139efca6f246161222ad6d87"
+dependencies = [
+ "lazy_static",
+ "libm 0.1.4",
+]
+
+[[package]]
name = "strsim"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -4446,12 +5533,23 @@ dependencies = [
[[package]]
name = "syn"
+version = "0.15.44"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9ca4b3b69a77cbe1ffc9e198781b7acb0c7365a883670e8f1c1bc66fba79a5c5"
+dependencies = [
+ "proc-macro2 0.4.30",
+ "quote 0.6.13",
+ "unicode-xid 0.1.0",
+]
+
+[[package]]
+name = "syn"
version = "1.0.109"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237"
dependencies = [
- "proc-macro2",
- "quote",
+ "proc-macro2 1.0.92",
+ "quote 1.0.36",
"unicode-ident",
]
@@ -4461,8 +5559,8 @@ version = "2.0.89"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "44d46482f1c1c87acd84dea20c1bf5ebff4c757009ed6bf19cfd36fb10e92c4e"
dependencies = [
- "proc-macro2",
- "quote",
+ "proc-macro2 1.0.92",
+ "quote 1.0.36",
"unicode-ident",
]
@@ -4472,8 +5570,8 @@ version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b5dc35bb08dd1ca3dfb09dce91fd2d13294d6711c88897d9a9d60acf39bce049"
dependencies = [
- "proc-macro2",
- "quote",
+ "proc-macro2 1.0.92",
+ "quote 1.0.36",
"syn 2.0.89",
]
@@ -4498,8 +5596,8 @@ version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8af7666ab7b6390ab78131fb5b0fce11d6b7a6951602017c35fa82800708971"
dependencies = [
- "proc-macro2",
- "quote",
+ "proc-macro2 1.0.92",
+ "quote 1.0.36",
"syn 2.0.89",
]
@@ -4511,7 +5609,18 @@ checksum = "ba3a3adc5c275d719af8cb4272ea1c4a6d668a777f37e115f6d11ddbc1c8e0e7"
dependencies = [
"bitflags 1.3.2",
"core-foundation",
- "system-configuration-sys",
+ "system-configuration-sys 0.5.0",
+]
+
+[[package]]
+name = "system-configuration"
+version = "0.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b"
+dependencies = [
+ "bitflags 2.6.0",
+ "core-foundation",
+ "system-configuration-sys 0.6.0",
]
[[package]]
@@ -4525,6 +5634,16 @@ dependencies = [
]
[[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]]
name = "talpid-core"
version = "0.0.0"
dependencies = [
@@ -4554,7 +5673,7 @@ dependencies = [
"resolv-conf",
"serde",
"serde_json",
- "system-configuration",
+ "system-configuration 0.5.1",
"talpid-dbus",
"talpid-macos",
"talpid-net",
@@ -4695,7 +5814,7 @@ dependencies = [
"netlink-sys",
"nix 0.28.0",
"rtnetlink",
- "system-configuration",
+ "system-configuration 0.5.1",
"talpid-types",
"talpid-windows",
"thiserror 2.0.9",
@@ -4866,8 +5985,8 @@ version = "1.0.59"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d1cd413b5d558b4c5bf3680e324a6fa5014e7b7c067a51e69dbdf47eb7148b66"
dependencies = [
- "proc-macro2",
- "quote",
+ "proc-macro2 1.0.92",
+ "quote 1.0.36",
"syn 2.0.89",
]
@@ -4877,8 +5996,8 @@ version = "2.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7b50fa271071aae2e6ee85f842e2e28ba8cd2c5fb67f11fcb1fd70b276f9e7d4"
dependencies = [
- "proc-macro2",
- "quote",
+ "proc-macro2 1.0.92",
+ "quote 1.0.36",
"syn 2.0.89",
]
@@ -4950,12 +6069,22 @@ version = "2.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "693d596312e88961bc67d7f1f97af8a70227d9f90c31bba5806eec004978d752"
dependencies = [
- "proc-macro2",
- "quote",
+ "proc-macro2 1.0.92",
+ "quote 1.0.36",
"syn 2.0.89",
]
[[package]]
+name = "tokio-native-tls"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2"
+dependencies = [
+ "native-tls",
+ "tokio",
+]
+
+[[package]]
name = "tokio-rustls"
version = "0.24.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -5124,9 +6253,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d021fc044c18582b9a2408cd0dd05b1596e3ecdb5c4df822bb0183545683889"
dependencies = [
"prettyplease",
- "proc-macro2",
+ "proc-macro2 1.0.92",
"prost-build",
- "quote",
+ "quote 1.0.36",
"syn 2.0.89",
]
@@ -5193,8 +6322,8 @@ version = "0.1.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34704c8d6ebcbc939824180af020566b01a7c01f80641264eba0999f6c2b6be7"
dependencies = [
- "proc-macro2",
- "quote",
+ "proc-macro2 1.0.92",
+ "quote 1.0.36",
"syn 2.0.89",
]
@@ -5260,6 +6389,15 @@ dependencies = [
]
[[package]]
+name = "twofish"
+version = "0.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a78e83a30223c757c3947cd144a31014ff04298d8719ae10d03c31c0448c8013"
+dependencies = [
+ "cipher",
+]
+
+[[package]]
name = "typenum"
version = "1.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -5297,6 +6435,24 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b"
[[package]]
+name = "unicode-segmentation"
+version = "1.12.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493"
+
+[[package]]
+name = "unicode-xid"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fc72304796d0818e357ead4e000d19c9c174ab23dc11093ac919054d20a6a7fc"
+
+[[package]]
+name = "unicode-xid"
+version = "0.2.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
+
+[[package]]
name = "universal-hash"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -5353,6 +6509,12 @@ dependencies = [
]
[[package]]
+name = "vcpkg"
+version = "0.2.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
+
+[[package]]
name = "version_check"
version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -5417,8 +6579,8 @@ dependencies = [
"bumpalo",
"log",
"once_cell",
- "proc-macro2",
- "quote",
+ "proc-macro2 1.0.92",
+ "quote 1.0.36",
"syn 2.0.89",
"wasm-bindgen-shared",
]
@@ -5441,7 +6603,7 @@ version = "0.2.92"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a1f8823de937b71b9460c0c34e25f3da88250760bec0ebac694b49997550d726"
dependencies = [
- "quote",
+ "quote 1.0.36",
"wasm-bindgen-macro-support",
]
@@ -5451,8 +6613,8 @@ version = "0.2.92"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e94f17b526d0a461a191c78ea52bbce64071ed5c04c9ffe424dcb38f74171bb7"
dependencies = [
- "proc-macro2",
- "quote",
+ "proc-macro2 1.0.92",
+ "quote 1.0.36",
"syn 2.0.89",
"wasm-bindgen-backend",
"wasm-bindgen-shared",
@@ -5537,6 +6699,12 @@ dependencies = [
]
[[package]]
+name = "winapi-build"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2d315eee3b34aca4797b2da6b13ed88266e6d612562a0c46390af8299fc699bc"
+
+[[package]]
name = "winapi-i686-pc-windows-gnu"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -5618,8 +6786,8 @@ version = "0.58.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b"
dependencies = [
- "proc-macro2",
- "quote",
+ "proc-macro2 1.0.92",
+ "quote 1.0.36",
"syn 2.0.89",
]
@@ -5629,8 +6797,8 @@ version = "0.59.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "83577b051e2f49a058c308f17f273b570a6a758386fc291b5f6a934dd84e48c1"
dependencies = [
- "proc-macro2",
- "quote",
+ "proc-macro2 1.0.92",
+ "quote 1.0.36",
"syn 2.0.89",
]
@@ -5651,8 +6819,8 @@ version = "0.58.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515"
dependencies = [
- "proc-macro2",
- "quote",
+ "proc-macro2 1.0.92",
+ "quote 1.0.36",
"syn 2.0.89",
]
@@ -5662,8 +6830,8 @@ version = "0.59.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb26fd936d991781ea39e87c3a27285081e3c0da5ca0fcbc02d368cc6f52ff01"
dependencies = [
- "proc-macro2",
- "quote",
+ "proc-macro2 1.0.92",
+ "quote 1.0.36",
"syn 2.0.89",
]
@@ -6137,6 +7305,17 @@ dependencies = [
]
[[package]]
+name = "x448"
+version = "0.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c4cd07d4fae29e07089dbcacf7077cd52dce7760125ca9a4dd5a35ca603ffebb"
+dependencies = [
+ "ed448-goldilocks",
+ "hex",
+ "rand_core 0.5.1",
+]
+
+[[package]]
name = "yoke"
version = "0.7.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -6154,8 +7333,8 @@ version = "0.7.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "28cc31741b18cb6f1d5ff12f5b7523e3d6eb0852bbbad19d73905511d9849b95"
dependencies = [
- "proc-macro2",
- "quote",
+ "proc-macro2 1.0.92",
+ "quote 1.0.36",
"syn 2.0.89",
"synstructure",
]
@@ -6175,8 +7354,8 @@ version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ea7b4a3637ea8669cedf0f1fd5c286a17f3de97b8dd5a70a6c167a1730e63a5"
dependencies = [
- "proc-macro2",
- "quote",
+ "proc-macro2 1.0.92",
+ "quote 1.0.36",
"syn 2.0.89",
"synstructure",
]
@@ -6196,8 +7375,8 @@ version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce36e65b0d2999d2aafac989fb249189a141aee1f53c612c1f37d72631959f69"
dependencies = [
- "proc-macro2",
- "quote",
+ "proc-macro2 1.0.92",
+ "quote 1.0.36",
"syn 2.0.89",
]
@@ -6218,7 +7397,7 @@ version = "0.10.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6eafa6dfb17584ea3e2bd6e76e0cc15ad7af12b09abdd1ca55961bed9b1063c6"
dependencies = [
- "proc-macro2",
- "quote",
+ "proc-macro2 1.0.92",
+ "quote 1.0.36",
"syn 2.0.89",
]
diff --git a/Cargo.toml b/Cargo.toml
index 6214b68134..17e3920961 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -29,6 +29,7 @@ members = [
"mullvad-setup",
"mullvad-types",
"mullvad-types/intersection-derive",
+ "mullvad-update",
"mullvad-version",
"talpid-core",
"talpid-dbus",
@@ -47,6 +48,7 @@ members = [
"tunnel-obfuscation",
"wireguard-go-rs",
"windows-installer",
+ "installer-downloader",
]
# Default members dictate what is built when running `cargo build` in the root directory.
# This is set to a minimal set of packages to speed up the build process and avoid building
diff --git a/installer-downloader/Cargo.toml b/installer-downloader/Cargo.toml
new file mode 100644
index 0000000000..25799200d3
--- /dev/null
+++ b/installer-downloader/Cargo.toml
@@ -0,0 +1,40 @@
+[package]
+name = "installer-downloader"
+description = "A secure minimal web installer for the Mullvad app"
+authors.workspace = true
+repository.workspace = true
+license.workspace = true
+edition.workspace = true
+rust-version.workspace = true
+
+[lints]
+workspace = true
+
+[build-dependencies]
+anyhow = "1.0"
+winres = "0.1"
+windows-sys = { version = "0.52.0", features = ["Win32_System", "Win32_System_LibraryLoader", "Win32_System_SystemServices"] }
+
+[target.'cfg(any(target_os = "windows", target_os = "macos"))'.dependencies]
+anyhow = "1.0"
+tokio = { version = "1", features = ["full"] }
+async-trait = "0.1"
+
+mullvad-update = { path = "../mullvad-update" }
+
+[target.'cfg(target_os = "windows")'.dependencies]
+windows-sys = { workspace = true, features = ["Win32_UI", "Win32_UI_WindowsAndMessaging", "Win32_Graphics", "Win32_Graphics_Gdi"] }
+native-windows-gui = { version = "1.0.12", features = ["image-decoder"] }
+
+[target.'cfg(target_os = "macos")'.dependencies]
+cacao = "0.3.2"
+nsvg = "0.5.1"
+objc_id = "0.1"
+
+[target.'cfg(any(target_os = "windows", target_os = "macos"))'.dev-dependencies]
+serde = { workspace = true, features = ["derive"] }
+tokio = { workspace = true, features = ["test-util"] }
+insta = { version = "1.42", features = ["yaml"] }
+
+[package.metadata.winres]
+LegalCopyright = "(c) 2025 Mullvad VPN AB"
diff --git a/installer-downloader/assets/logo-icon.png b/installer-downloader/assets/logo-icon.png
new file mode 100644
index 0000000000..d80eedadc2
--- /dev/null
+++ b/installer-downloader/assets/logo-icon.png
Binary files differ
diff --git a/installer-downloader/assets/logo-icon.svg b/installer-downloader/assets/logo-icon.svg
new file mode 100644
index 0000000000..e488329ac1
--- /dev/null
+++ b/installer-downloader/assets/logo-icon.svg
@@ -0,0 +1,37 @@
+<?xml version="1.0" encoding="utf-8"?>
+<svg version="1.1" id="logo_00000183244726354177486890000003471613280854675596_"
+ xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 252 252"
+ style="enable-background:new 0 0 252 252;" xml:space="preserve">
+<style type="text/css">
+ .fur{fill-rule:evenodd;clip-rule:evenodd;fill:#D2943B;}
+ .nose{fill-rule:evenodd;clip-rule:evenodd;fill:#FFCD86;}
+ .helmet{fill-rule:evenodd;clip-rule:evenodd;fill:#FFD524;}
+ .lamp-light{fill:#FFFFFF;}
+ .lamp-ring{fill:#192E45;}
+</style>
+<g id="mole">
+ <g id="body">
+ <path id="fur" class="fur" d="M23.9,96.6L16.8,111l9.6-13.4c0,0.1-0.6,19.3-0.6,19.3l2.7-14.5c7.2,13.7,23.1,34.8,45.4,50.5
+ c1.7,1.2,3.9,3.3,4.6,4.1c0,0,21.3,10.8,54.1-14.2c0.3-0.2,0.7-0.4,1-0.6l0.5,0.3l7.2,4.8c-2.5-0.7-7.3-1.9-7.3-1.9
+ c-15.8,18.2-41.5,21.2-55.3,12c-0.6-0.4-4,1-4.6,3c-0.4,1.1,0,2.3,0.5,3.2c2.8,5.2,7,4.7,5,10.8c-1.4,3.3-3.4,6.5-5.6,9.5
+ c-4.6,6.2-11.8,11.7-11.1,15c32.6,40.2,106.1,34.6,134.1-1.3c-0.4-5.2-8.6-7.7-14.3-20.4c1.6,0.5,4,1.2,4,1.1
+ c0-0.1-6.8-11.1-7.1-12.2l4.4,0.3c0,0-5.8-7.2-6-7.9l5.9-0.8c0,0-7.4-8.5-7.5-9.2l7.5,1.2l-8.2-9.9h3.9l-4.6-6.7l-37.3-14.7
+ c-14.3-8.9-27-19.8-36.6-28.2l-19.3-9.4C63.3,79.4,46,79.9,35.4,82l6.8-11.6l-9.9,11.8C31.7,82,31,81.8,31,81.8l0.7-15l-3.3,14
+ L23.9,96.6z"/>
+ <path id="nose" class="nose" d="M28.4,80.8c-4.9-2.3-10.6,1-11.6,5.5c-1.2,4.3,1.8,9.6,7.1,10.3C28.9,93.5,33.2,85.5,28.4,80.8z"/>
+ </g>
+ <g id="helmet">
+ <path id="helmet" class="helmet" d="M101.2,69.9c-1.5-4.1-1.1-9.4,1-14.4c3-6.9,8.7-11.5,14.1-11.5c1.1,0,2.1,0.2,3.1,0.6
+ c3.1-2.8,6.7-5.1,10.7-6.7c22-8.8,54.3,6.9,62.6,28.5c4,10.5,2.8,21.9-0.6,32.4c-2.8,8.6-13,21-9.2,30.3
+ c-1.5-0.4-32.8-11-41.8-15.8c-14.1-8.8-26.7-19.6-36.2-27.9l-0.3-0.3l-32-15.2c-0.4-0.2-0.8-0.4-1.1-0.6
+ C75.9,69.5,93.4,71.6,101.2,69.9"/>
+ <g id="lamp">
+
+ <ellipse id="lamp-light" transform="matrix(0.4007 -0.9162 0.9162 0.4007 12.8553 140.4693)" class="lamp-light" cx="113.8" cy="60.4" rx="13.6" ry="8.1"/>
+ <path id="lamp-ring" class="lamp-ring" d="M120.1,46.1c-5.3-2.3-12.4,2.2-15.9,10.1s-1.9,16.1,3.4,18.5c5.3,2.3,12.4-2.2,15.9-10.1
+ S125.4,48.5,120.1,46.1z M120.6,63.4c-2.8,6.3-8.1,10.1-11.8,8.5c-3.8-1.7-4.6-8.1-1.8-14.5c2.8-6.3,8.1-10.1,11.9-8.5
+ C122.6,50.6,123.4,57.1,120.6,63.4z"/>
+ </g>
+ </g>
+</g>
+</svg>
diff --git a/installer-downloader/assets/logo-text.png b/installer-downloader/assets/logo-text.png
new file mode 100644
index 0000000000..993083c7b1
--- /dev/null
+++ b/installer-downloader/assets/logo-text.png
Binary files differ
diff --git a/installer-downloader/assets/logo-text.svg b/installer-downloader/assets/logo-text.svg
new file mode 100644
index 0000000000..c3296186c2
--- /dev/null
+++ b/installer-downloader/assets/logo-text.svg
@@ -0,0 +1,19 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!-- Generator: Adobe Illustrator 23.0.6, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
+<svg id="Mullvad_VPN_Logo_Positive" width="959.5" height="103.7" version="1.1" viewBox="0 0 959.5 103.7" xml:space="preserve" xmlns="http://www.w3.org/2000/svg">
+<style type="text/css">
+ .st4{fill:#FFFFFF;}
+</style>
+<g transform="translate(-381.4,-143.1)">
+ <path class="st4" d="m381.4 144.7c0-0.9 0.6-1.5 1.5-1.5h17.8c1.2 0 2 0.4 2.4 1.5l25.8 58.2h0.6l25.3-58.2c0.4-1 1.2-1.5 2.4-1.5h17.5c0.9 0 1.5 0.6 1.5 1.5v99c0 0.9-0.6 1.5-1.5 1.5h-17c-0.9 0-1.5-0.6-1.5-1.5v-57.4h-0.6l-18.9 43c-0.6 1.4-1.5 2-2.8 2h-10.3c-1.3 0-2.2-0.6-2.8-2l-18.9-43h-0.6v57.4c0 0.9-0.6 1.5-1.5 1.5h-16.9c-0.9 0-1.5-0.6-1.5-1.5z"/>
+ <path class="st4" d="m498.4 207.7v-63c0-0.9 0.6-1.5 1.5-1.5h19c0.9 0 1.5 0.6 1.5 1.5v63.6c0 12.1 6.8 19 17.1 19 10.2 0 16.9-6.9 16.9-19v-63.6c0-0.9 0.6-1.5 1.5-1.5h19c0.9 0 1.5 0.6 1.5 1.5v63c0 25.3-16.2 39.1-39 39.1s-39-13.8-39-39.1z"/>
+ <path class="st4" d="m598.6 144.7c0-0.9 0.6-1.5 1.5-1.5h19c0.9 0 1.5 0.6 1.5 1.5v79.9c0 0.6 0.3 0.9 0.9 0.9h45c0.9 0 1.5 0.6 1.5 1.5v16.6c0 0.9-0.6 1.5-1.5 1.5h-66.4c-0.9 0-1.5-0.6-1.5-1.5z"/>
+ <path class="st4" d="m684.1 144.7c0-0.9 0.6-1.5 1.5-1.5h19c0.9 0 1.5 0.6 1.5 1.5v79.9c0 0.6 0.3 0.9 0.9 0.9h45c0.9 0 1.5 0.6 1.5 1.5v16.6c0 0.9-0.6 1.5-1.5 1.5h-66.4c-0.9 0-1.5-0.6-1.5-1.5z"/>
+ <path class="st4" d="m785.1 245.1c-1 0-1.6-0.6-1.9-1.5l-32.4-98.8c-0.3-1.1 0.3-1.6 1.3-1.6h19.5c1 0 1.7 0.4 2 1.5l20.4 66.9h0.4l19.8-66.9c0.3-1 0.9-1.5 1.9-1.5h19.3c0.9 0 1.5 0.6 1.2 1.6l-32.4 98.8c-0.3 0.9-0.9 1.5-1.8 1.5z"/>
+ <path class="st4" d="m872.5 144.7c0.3-0.9 0.9-1.5 2-1.5h18.9c1 0 1.6 0.6 1.9 1.5l34.5 99c0.3 0.9 0 1.5-1 1.5h-19.5c-1 0-1.7-0.5-2-1.5l-5.8-17.8h-35.7l-5.7 17.8c-0.3 1-0.9 1.5-2 1.5h-19.6c-1 0-1.3-0.6-1-1.5zm23 62.4-11.5-35.7h-0.4l-11.5 35.7z"/>
+ <path class="st4" d="m944.7 144.7c0-0.9 0.6-1.5 1.5-1.5h37.9c17.8 0 30.3 7.6 35.2 22.9 1.8 5.7 2.7 11.4 2.7 28s-0.9 22.3-2.7 28c-4.9 15.3-17.4 22.9-35.2 22.9h-37.9c-0.9 0-1.5-0.6-1.5-1.5zm22.9 80.8h11.5c10.2 0 16.3-3 18.9-11.2 1-3 1.7-6.9 1.7-20.1s-0.6-17.1-1.7-20.1c-2.5-8.2-8.7-11.2-18.9-11.2h-11.5c-0.6 0-0.9 0.3-0.9 0.9v60.9c0 0.5 0.3 0.8 0.9 0.8z"/>
+ <path class="st4" d="m1102.5 245.1c-1.1 0-1.7-0.6-2-1.5l-32.4-98.8c-0.3-1.1 0.3-1.6 1.3-1.6h19.5c1 0 1.7 0.4 2 1.5l20.4 66.9h0.4l19.8-66.9c0.3-1 0.9-1.5 1.9-1.5h19.3c0.9 0 1.5 0.6 1.2 1.6l-32.4 98.8c-0.3 0.9-0.9 1.5-1.8 1.5z"/>
+ <path class="st4" d="m1170 245.1c-0.9 0-1.5-0.6-1.5-1.5v-99c0-0.9 0.6-1.5 1.5-1.5h39.6c22.2 0 35.5 13.3 35.5 32.8 0 19.2-13.5 32.7-35.5 32.7h-18.1c-0.6 0-0.9 0.3-0.9 0.9v34c0 0.9-0.6 1.5-1.5 1.5h-19.1zm53.1-69.1c0-8.2-5.5-13.8-14.8-13.8h-16.8c-0.6 0-0.9 0.3-0.9 0.9v25.6c0 0.6 0.3 0.9 0.9 0.9h16.8c9.2 0.1 14.8-5.3 14.8-13.6z"/>
+ <path class="st4" d="m1262.2 144.7c0-0.9 0.6-1.5 1.5-1.5h18c1 0 2 0.4 2.5 1.5l36 64.2h0.8v-64.2c0-0.9 0.6-1.5 1.5-1.5h16.9c0.9 0 1.5 0.6 1.5 1.5v99c0 0.9-0.6 1.5-1.5 1.5h-17.8c-1.2 0-2-0.5-2.5-1.5l-36.1-64h-0.8v64c0 0.9-0.6 1.5-1.5 1.5h-16.9c-0.9 0-1.5-0.6-1.5-1.5v-99z"/>
+</g>
+</svg>
diff --git a/installer-downloader/build.rs b/installer-downloader/build.rs
new file mode 100644
index 0000000000..475a864999
--- /dev/null
+++ b/installer-downloader/build.rs
@@ -0,0 +1,30 @@
+use anyhow::Context;
+use std::env;
+
+fn main() -> anyhow::Result<()> {
+ let target_os = env::var("CARGO_CFG_TARGET_OS").context("Missing 'CARGO_CFG_TARGET_OS")?;
+
+ match target_os.as_str() {
+ "windows" => win_main(),
+ _ => Ok(()),
+ }
+}
+
+fn win_main() -> anyhow::Result<()> {
+ let mut res = winres::WindowsResource::new();
+
+ res.set_language(make_lang_id(
+ windows_sys::Win32::System::SystemServices::LANG_ENGLISH as u16,
+ windows_sys::Win32::System::SystemServices::SUBLANG_ENGLISH_US as u16,
+ ));
+
+ println!("cargo:rerun-if-changed=loader.manifest");
+ res.set_manifest_file("loader.manifest");
+
+ res.compile().context("Failed to compile resources")
+}
+
+// Sourced from winnt.h: https://learn.microsoft.com/en-us/windows/win32/api/winnt/nf-winnt-makelangid
+fn make_lang_id(p: u16, s: u16) -> u16 {
+ (s << 10) | p
+}
diff --git a/installer-downloader/convert-assets.py b/installer-downloader/convert-assets.py
new file mode 100644
index 0000000000..ede39abe21
--- /dev/null
+++ b/installer-downloader/convert-assets.py
@@ -0,0 +1,8 @@
+#!/usr/bin/env python
+
+# Convert svg assets to png assets
+
+from cairosvg import svg2png
+
+svg2png(url="assets/logo-icon.svg", write_to="assets/logo-icon.png", output_width=32)
+svg2png(url="assets/logo-text.svg", write_to="assets/logo-text.png", output_width=122)
diff --git a/installer-downloader/loader.manifest b/installer-downloader/loader.manifest
new file mode 100644
index 0000000000..feedbf9bc0
--- /dev/null
+++ b/installer-downloader/loader.manifest
@@ -0,0 +1,37 @@
+<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
+<assembly xmlns="urn:schemas-microsoft-com:asm.v1" manifestVersion="1.0" xmlns:asmv3="urn:schemas-microsoft-com:asm.v3">
+<trustInfo xmlns="urn:schemas-microsoft-com:asm.v3">
+ <security>
+ <requestedPrivileges>
+ <requestedExecutionLevel
+ level="requireAdministrator"
+ uiAccess="false"/>
+ </requestedPrivileges>
+ </security>
+</trustInfo>
+<assemblyIdentity
+ version="1.0.0.0"
+ processorArchitecture="*"
+ name="Mullvad.MullvadVPN.Installer"
+ type="win32"
+/>
+<description>Web installer</description>
+<dependency>
+ <dependentAssembly>
+ <assemblyIdentity
+ type="win32"
+ name="Microsoft.Windows.Common-Controls"
+ version="6.0.0.0"
+ processorArchitecture="*"
+ publicKeyToken="6595b64144ccf1df"
+ language="*"
+ />
+ </dependentAssembly>
+</dependency>
+<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
+ <application>
+ <!--The ID below indicates app support for Windows 10 and Windows 11 -->
+ <supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}"/>
+ </application>
+</compatibility>
+</assembly>
diff --git a/installer-downloader/src/cacao_impl/delegate.rs b/installer-downloader/src/cacao_impl/delegate.rs
new file mode 100644
index 0000000000..80d080932b
--- /dev/null
+++ b/installer-downloader/src/cacao_impl/delegate.rs
@@ -0,0 +1,119 @@
+use std::sync::{Arc, Mutex};
+
+use cacao::{control::Control, layout::Layout};
+
+use super::ui::{Action, AppWindow};
+use crate::delegate::{AppDelegate, AppDelegateQueue};
+
+impl AppDelegate for AppWindow {
+ type Queue = Queue;
+
+ fn on_download<F>(&mut self, callback: F)
+ where
+ F: Fn() + Send + 'static,
+ {
+ let cb = Self::sync_callback(callback);
+ self.download_button.button.set_action(move || {
+ let cb = Action::DownloadClick(cb.clone());
+ cacao::appkit::App::<super::ui::AppImpl, _>::dispatch_main(cb);
+ });
+ }
+
+ fn on_cancel<F>(&mut self, callback: F)
+ where
+ F: Fn() + Send + 'static,
+ {
+ let cb = Self::sync_callback(callback);
+ self.cancel_button.button.set_action(move || {
+ let cb = Action::CancelClick(cb.clone());
+ cacao::appkit::App::<super::ui::AppImpl, _>::dispatch_main(cb);
+ });
+ }
+
+ fn set_status_text(&mut self, text: &str) {
+ self.status_text.set_text(text);
+ }
+
+ fn set_download_text(&mut self, text: &str) {
+ self.download_text.set_text(text);
+ }
+
+ fn show_download_progress(&mut self) {
+ self.progress.set_hidden(false);
+ }
+
+ fn hide_download_progress(&mut self) {
+ self.progress.set_hidden(true);
+ }
+
+ fn set_download_progress(&mut self, complete: u32) {
+ self.progress.set_value(complete as f64);
+ }
+
+ fn show_download_button(&mut self) {
+ self.download_button.button.set_hidden(false);
+ }
+
+ fn hide_download_button(&mut self) {
+ self.download_button.button.set_hidden(true);
+ }
+
+ fn enable_download_button(&mut self) {
+ self.download_button.button.set_enabled(true);
+ }
+
+ fn disable_download_button(&mut self) {
+ self.download_button.button.set_enabled(false);
+ }
+
+ fn show_cancel_button(&mut self) {
+ self.cancel_button.button.set_hidden(false);
+ }
+
+ fn hide_cancel_button(&mut self) {
+ self.cancel_button.button.set_hidden(true);
+ }
+
+ fn enable_cancel_button(&mut self) {
+ self.cancel_button.button.set_enabled(true);
+ }
+
+ fn disable_cancel_button(&mut self) {
+ self.cancel_button.button.set_enabled(false);
+ }
+
+ fn show_beta_text(&mut self) {
+ self.beta_link.set_hidden(false);
+ self.beta_link_preface.set_hidden(false);
+ }
+
+ fn hide_beta_text(&mut self) {
+ self.beta_link.set_hidden(true);
+ self.beta_link_preface.set_hidden(true);
+ }
+
+ fn queue(&self) -> Self::Queue {
+ Queue {}
+ }
+}
+
+impl AppWindow {
+ // NOTE: We need this horrible lock because Dispatcher demands Sync, but AppDelegate does not require Sync
+ fn sync_callback(
+ callback: impl Fn() + Send + 'static,
+ ) -> Arc<Mutex<Box<dyn Fn() + Send + 'static>>> {
+ Arc::new(Mutex::new(Box::new(move || callback())))
+ }
+}
+
+/// This simply mutates the UI on the main thread using the GCD
+pub struct Queue {}
+
+impl AppDelegateQueue<AppWindow> for Queue {
+ fn queue_main<F: FnOnce(&mut AppWindow) + 'static + Send>(&self, callback: F) {
+ // NOTE: We need this horrible lock because Dispatcher demands Sync
+ let cb: Mutex<Option<Box<dyn FnOnce(&mut AppWindow) + Send>>> =
+ Mutex::new(Some(Box::new(callback)));
+ cacao::appkit::App::<super::ui::AppImpl, _>::dispatch_main(Action::QueueMain(cb));
+ }
+}
diff --git a/installer-downloader/src/cacao_impl/mod.rs b/installer-downloader/src/cacao_impl/mod.rs
new file mode 100644
index 0000000000..89cd3b1402
--- /dev/null
+++ b/installer-downloader/src/cacao_impl/mod.rs
@@ -0,0 +1,19 @@
+use std::sync::Mutex;
+
+use cacao::appkit::App;
+use ui::{Action, AppImpl, AppWindow};
+
+mod delegate;
+mod ui;
+
+pub fn main() {
+ let app = App::new("net.mullvad.downloader", AppImpl::default());
+
+ let cb: Mutex<Option<Box<dyn FnOnce(&mut AppWindow) + Send + 'static>>> =
+ Mutex::new(Some(Box::new(|self_| {
+ crate::controller::initialize_controller(self_);
+ })));
+ cacao::appkit::App::<ui::AppImpl, _>::dispatch_main(Action::QueueMain(cb));
+
+ app.run();
+}
diff --git a/installer-downloader/src/cacao_impl/ui.rs b/installer-downloader/src/cacao_impl/ui.rs
new file mode 100644
index 0000000000..85f7554c05
--- /dev/null
+++ b/installer-downloader/src/cacao_impl/ui.rs
@@ -0,0 +1,335 @@
+use std::cell::RefCell;
+use std::sync::{Arc, LazyLock, Mutex, RwLock};
+
+use cacao::appkit::window::{Window, WindowConfig, WindowDelegate};
+use cacao::appkit::{App, AppDelegate};
+use cacao::button::Button;
+use cacao::color::Color;
+use cacao::image::{Image, ImageView};
+use cacao::layout::{Layout, LayoutConstraint};
+use cacao::notification_center::Dispatcher;
+use cacao::objc::{class, msg_send, sel, sel_impl};
+use cacao::progress::ProgressIndicator;
+use cacao::text::{AttributedString, Label};
+use cacao::view::View;
+use objc_id::Id;
+
+use crate::resource::{
+ BANNER_DESC, BETA_LINK_TEXT, BETA_PREFACE_DESC, CANCEL_BUTTON_TEXT, DOWNLOAD_BUTTON_TEXT,
+ WINDOW_HEIGHT, WINDOW_TITLE, WINDOW_WIDTH,
+};
+
+/// Logo render in the banner
+const LOGO_IMAGE_DATA: &[u8] = include_bytes!("../../assets/logo-icon.svg");
+
+/// Logo banner text
+const LOGO_TEXT_DATA: &[u8] = include_bytes!("../../assets/logo-text.svg");
+
+/// Banner background color: #192e45
+static BANNER_COLOR: LazyLock<Color> = LazyLock::new(|| {
+ let r = 0x19 as f64 / 255.;
+ let g = 0x2e as f64 / 255.;
+ let b = 0x45 as f64 / 255.;
+ let a = 1.;
+
+ // NOTE: colorWithCalibratedRed is used by cacao by default, but it renders a different color
+ // than it does for background color of the image. I believe this is because the
+ // calibrated uses the current color profile.
+ // Maybe using calibrated colors is more correct? Rendering different colors *definitely*
+ // is not.
+ let id =
+ unsafe { Id::from_retained_ptr(msg_send![class!(NSColor), colorWithRed:r green:g blue:b alpha:a]) };
+ Color::Custom(Arc::new(RwLock::new(id)))
+});
+
+static LOGO: LazyLock<Image> = LazyLock::new(|| Image::with_data(LOGO_IMAGE_DATA));
+static LOGO_TEXT: LazyLock<Image> = LazyLock::new(|| Image::with_data(LOGO_TEXT_DATA));
+
+pub struct AppImpl {
+ window: Window<AppWindowWrapper>,
+}
+
+impl Default for AppImpl {
+ fn default() -> Self {
+ Self {
+ window: Window::with(WindowConfig::default(), AppWindowWrapper::default()),
+ }
+ }
+}
+
+impl AppDelegate for AppImpl {
+ fn did_finish_launching(&self) {
+ App::activate();
+
+ self.window.show();
+
+ let delegate = self.window.delegate.as_ref().unwrap();
+ delegate.inner.borrow().layout();
+ }
+
+ fn should_terminate_after_last_window_closed(&self) -> bool {
+ true
+ }
+}
+
+/// Dispatcher actions
+pub enum Action {
+ /// User clicked the download button
+ DownloadClick(Arc<Mutex<Box<dyn Fn() + Send>>>),
+ /// User clicked the cancel button
+ CancelClick(Arc<Mutex<Box<dyn Fn() + Send>>>),
+ /// Run callback on main thread
+ QueueMain(Mutex<Option<Box<dyn for<'a> FnOnce(&'a mut AppWindow) + Send>>>),
+}
+
+impl Dispatcher for AppImpl {
+ type Message = Action;
+
+ fn on_ui_message(&self, message: Self::Message) {
+ let delegate = self.window.delegate.as_ref().unwrap();
+ match message {
+ Action::DownloadClick(cb) => {
+ let cb = cb.lock().unwrap();
+ cb();
+ }
+ Action::CancelClick(cb) => {
+ let cb = cb.lock().unwrap();
+ cb();
+ }
+ Action::QueueMain(cb) => {
+ // NOTE: We assume that this won't panic because they will never run simultaneously
+ let mut borrowed = delegate.inner.borrow_mut();
+ let cb = cb.lock().unwrap().take().unwrap();
+ cb(&mut borrowed);
+ }
+ }
+ }
+
+ fn on_background_message(&self, _message: Self::Message) {
+ // TODO
+ }
+}
+
+#[derive(Default)]
+pub struct AppWindowWrapper {
+ pub inner: RefCell<AppWindow>,
+}
+
+#[derive(Default)]
+pub struct AppWindow {
+ pub content: View,
+
+ pub banner: View,
+ pub banner_logo_view: ImageView,
+ pub banner_logo_text_view: ImageView,
+ pub banner_desc: Label,
+
+ pub main_view: View,
+
+ pub download_button: DownloadButton,
+ pub cancel_button: CancelButton,
+
+ pub progress: ProgressIndicator,
+
+ pub status_text: Label,
+ pub download_text: Label,
+
+ pub beta_link_preface: Label,
+ pub beta_link: Label,
+}
+
+pub struct DownloadButton {
+ pub button: Button,
+}
+
+impl Default for DownloadButton {
+ fn default() -> Self {
+ let button = Button::new(DOWNLOAD_BUTTON_TEXT);
+ Self { button }
+ }
+}
+
+pub struct CancelButton {
+ pub button: Button,
+}
+
+impl Default for CancelButton {
+ fn default() -> Self {
+ let button = Button::new(CANCEL_BUTTON_TEXT);
+ Self { button }
+ }
+}
+
+impl AppWindow {
+ pub fn layout(&self) {
+ self.banner_logo_view.set_image(&LOGO);
+ self.banner_logo_text_view.set_image(&LOGO_TEXT);
+ self.banner.set_background_color(&*BANNER_COLOR);
+
+ self.banner.add_subview(&self.banner_logo_view);
+ self.banner.add_subview(&self.banner_logo_text_view);
+
+ self.content.add_subview(&self.banner);
+ self.content.add_subview(&self.main_view);
+
+ self.main_view.add_subview(&self.progress);
+ self.progress.set_hidden(true);
+ self.progress.set_indeterminate(false);
+
+ self.banner_desc.set_text(BANNER_DESC);
+ self.banner_desc.set_text_color(Color::SystemWhite);
+ self.banner.add_subview(&self.banner_desc);
+ self.banner_desc
+ .set_line_break_mode(cacao::text::LineBreakMode::WrapWords);
+
+ LayoutConstraint::activate(&[
+ self.banner_logo_view
+ .bottom
+ .constraint_equal_to(&self.banner_desc.top)
+ .offset(-8.),
+ self.banner_logo_view
+ .left
+ .constraint_equal_to(&self.banner.left)
+ .offset(24.),
+ self.banner_logo_view
+ .width
+ .constraint_equal_to_constant(32.0f64),
+ self.banner_logo_view
+ .height
+ .constraint_equal_to_constant(32.0f64),
+ self.banner_desc
+ .left
+ .constraint_equal_to(&self.banner_logo_view.left),
+ self.banner_desc
+ .bottom
+ .constraint_equal_to(&self.banner.bottom)
+ .offset(-16.),
+ self.banner_desc
+ .right
+ .constraint_equal_to(&self.banner.right)
+ .offset(-24.),
+ ]);
+ LayoutConstraint::activate(&[
+ self.banner_logo_text_view
+ .top
+ .constraint_equal_to(&self.banner_logo_view.top)
+ .offset(9.4),
+ self.banner_logo_text_view
+ .left
+ .constraint_equal_to(&self.banner_logo_view.right)
+ .offset(12.),
+ self.banner_logo_text_view
+ .width
+ .constraint_equal_to_constant(122.),
+ self.banner_logo_text_view
+ .height
+ .constraint_equal_to_constant(13.),
+ ]);
+
+ LayoutConstraint::activate(&[
+ self.banner.left.constraint_equal_to(&self.content.left),
+ self.banner.right.constraint_equal_to(&self.content.right),
+ self.banner.top.constraint_equal_to(&self.content.top),
+ self.banner.height.constraint_equal_to_constant(122.),
+ ]);
+
+ LayoutConstraint::activate(&[
+ self.main_view.left.constraint_equal_to(&self.content.left),
+ self.main_view
+ .right
+ .constraint_equal_to(&self.content.right),
+ self.main_view.top.constraint_equal_to(&self.banner.bottom),
+ self.main_view
+ .bottom
+ .constraint_equal_to(&self.content.bottom),
+ ]);
+
+ self.main_view.add_subview(&self.status_text);
+ self.main_view.add_subview(&self.download_text);
+ self.main_view.add_subview(&self.download_button.button);
+ self.main_view.add_subview(&self.cancel_button.button);
+
+ self.beta_link_preface.set_text(BETA_PREFACE_DESC);
+ self.main_view.add_subview(&self.beta_link_preface);
+
+ let mut attr_text = AttributedString::new(&BETA_LINK_TEXT);
+ attr_text.set_text_color(Color::Link, 0..BETA_LINK_TEXT.len() as isize);
+
+ self.beta_link.set_attributed_text(attr_text);
+ self.main_view.add_subview(&self.beta_link);
+
+ LayoutConstraint::activate(&[
+ self.status_text
+ .top
+ .constraint_greater_than_or_equal_to(&self.main_view.top)
+ .offset(24.),
+ self.status_text
+ .center_x
+ .constraint_equal_to(&self.main_view.center_x),
+ self.download_text
+ .top
+ .constraint_equal_to(&self.status_text.bottom)
+ .offset(16.),
+ self.download_text
+ .center_x
+ .constraint_equal_to(&self.main_view.center_x),
+ self.download_button
+ .button
+ .center_x
+ .constraint_equal_to(&self.main_view.center_x),
+ self.download_button
+ .button
+ .top
+ .constraint_equal_to(&self.status_text.bottom)
+ .offset(16.),
+ self.progress
+ .top
+ .constraint_equal_to(&self.download_button.button.top)
+ .offset(32.),
+ self.progress
+ .left
+ .constraint_equal_to(&self.main_view.left)
+ .offset(30.),
+ self.progress
+ .right
+ .constraint_equal_to(&self.main_view.right)
+ .offset(-30.),
+ self.progress.height.constraint_equal_to_constant(16.0f64),
+ self.cancel_button
+ .button
+ .center_x
+ .constraint_equal_to(&self.main_view.center_x),
+ self.cancel_button
+ .button
+ .top
+ .constraint_equal_to(&self.progress.bottom)
+ .offset(16.),
+ self.beta_link_preface
+ .bottom
+ .constraint_equal_to(&self.main_view.bottom)
+ .offset(-24.),
+ self.beta_link_preface
+ .left
+ .constraint_equal_to(&self.main_view.left)
+ .offset(24.),
+ self.beta_link
+ .bottom
+ .constraint_equal_to(&self.beta_link_preface.bottom),
+ self.beta_link
+ .left
+ .constraint_equal_to(&self.beta_link_preface.right),
+ ]);
+ }
+}
+
+impl WindowDelegate for AppWindowWrapper {
+ const NAME: &'static str = "MullvadInstallerDelegate";
+
+ fn did_load(&mut self, window: Window) {
+ window.set_title(WINDOW_TITLE);
+ window.set_minimum_content_size(WINDOW_WIDTH as f64, WINDOW_HEIGHT as f64);
+ window.set_maximum_content_size(WINDOW_WIDTH as f64, WINDOW_HEIGHT as f64);
+ window.set_content_size(WINDOW_WIDTH as f64, WINDOW_HEIGHT as f64);
+ window.set_content_view(&self.inner.borrow().content);
+ }
+}
diff --git a/installer-downloader/src/controller.rs b/installer-downloader/src/controller.rs
new file mode 100644
index 0000000000..10c3519bd3
--- /dev/null
+++ b/installer-downloader/src/controller.rs
@@ -0,0 +1,210 @@
+//! This module implements the actual logic performed by different UI components.
+
+use crate::delegate::{AppDelegate, AppDelegateQueue};
+use crate::resource;
+use crate::ui_downloader::{UiAppDownloader, UiAppDownloaderParameters, UiProgressUpdater};
+
+use mullvad_update::api::Version;
+use mullvad_update::{
+ api::{self, VersionInfoProvider},
+ app::{self, AppDownloaderFactory},
+};
+
+use std::future::Future;
+use tokio::sync::{mpsc, oneshot};
+
+/// Actions handled by an async worker task in [handle_action_messages].
+enum TaskMessage {
+ SetVersionInfo(api::VersionInfo),
+ BeginDownload,
+ Cancel,
+}
+
+/// See the [module-level docs](self).
+pub struct AppController {}
+
+/// Public entry function for registering a [AppDelegate].
+pub fn initialize_controller<T: AppDelegate + 'static>(delegate: &mut T) {
+ use mullvad_update::api::LatestVersionInfoProvider;
+ use mullvad_update::app::HttpAppDownloader;
+
+ // App downloader (factory) to use
+ type DownloaderFactory<T> = HttpAppDownloader<UiProgressUpdater<T>, UiProgressUpdater<T>>;
+ // Version info provider to use
+ type VersionInfoProvider = LatestVersionInfoProvider;
+
+ AppController::initialize::<_, DownloaderFactory<T>, VersionInfoProvider>(delegate)
+}
+
+impl AppController {
+ /// Initialize [AppController] using the provided delegate.
+ ///
+ /// Providing the downloader and version info fetcher as type arguments, they're decoupled from
+ /// the logic of [AppController], allowing them to be mocked.
+ pub fn initialize<Delegate, DownloaderFactory, VersionProvider>(delegate: &mut Delegate)
+ where
+ Delegate: AppDelegate + 'static,
+ VersionProvider: VersionInfoProvider + 'static,
+ DownloaderFactory:
+ AppDownloaderFactory<Parameters = UiAppDownloaderParameters<Delegate>> + 'static,
+ {
+ delegate.hide_download_progress();
+ delegate.show_download_button();
+ delegate.disable_download_button();
+ delegate.hide_cancel_button();
+ delegate.hide_beta_text();
+
+ let (task_tx, task_rx) = mpsc::channel(1);
+ tokio::spawn(handle_action_messages::<Delegate, DownloaderFactory>(
+ delegate.queue(),
+ task_rx,
+ ));
+ delegate.set_status_text(resource::FETCH_VERSION_DESC);
+ tokio::spawn(fetch_app_version_info::<Delegate, VersionProvider>(
+ delegate,
+ task_tx.clone(),
+ ));
+ Self::register_user_action_callbacks(delegate, task_tx);
+ }
+
+ fn register_user_action_callbacks<T: AppDelegate + 'static>(
+ delegate: &mut T,
+ task_tx: mpsc::Sender<TaskMessage>,
+ ) {
+ let tx = task_tx.clone();
+ delegate.on_download(move || {
+ let _ = tx.try_send(TaskMessage::BeginDownload);
+ });
+ let tx = task_tx.clone();
+ delegate.on_cancel(move || {
+ let _ = tx.try_send(TaskMessage::Cancel);
+ });
+ }
+}
+
+/// Background task that fetches app version data.
+fn fetch_app_version_info<Delegate, VersionProvider>(
+ delegate: &mut Delegate,
+ download_tx: mpsc::Sender<TaskMessage>,
+) -> impl Future<Output = ()>
+where
+ Delegate: AppDelegate,
+ VersionProvider: VersionInfoProvider,
+{
+ let queue = delegate.queue();
+
+ async move {
+ // TODO: handle errors, retry
+ let Ok(version_info) = VersionProvider::get_version_info().await else {
+ queue.queue_main(move |self_| {
+ self_.set_status_text("Failed to fetch version info");
+ });
+ return;
+ };
+ let _ = download_tx.try_send(TaskMessage::SetVersionInfo(version_info));
+ }
+}
+
+/// Async worker that handles actions such as initiating a download, cancelling it, and updating
+/// labels.
+async fn handle_action_messages<Delegate, DownloaderFactory>(
+ queue: Delegate::Queue,
+ mut rx: mpsc::Receiver<TaskMessage>,
+) where
+ Delegate: AppDelegate + 'static,
+ DownloaderFactory:
+ AppDownloaderFactory<Parameters = UiAppDownloaderParameters<Delegate>> + 'static,
+{
+ let mut version_info = None;
+ let mut active_download = None;
+
+ while let Some(msg) = rx.recv().await {
+ match msg {
+ TaskMessage::SetVersionInfo(new_version_info) => {
+ let version_label = format_latest_version(&new_version_info.stable);
+ let has_beta = new_version_info.beta.is_some();
+ queue.queue_main(move |self_| {
+ self_.set_status_text(&version_label);
+ self_.enable_download_button();
+ if has_beta {
+ self_.show_beta_text();
+ }
+ });
+ version_info = Some(new_version_info);
+ }
+ TaskMessage::BeginDownload => {
+ if active_download.is_some() {
+ continue;
+ }
+ let Some(version_info) = version_info.clone() else {
+ continue;
+ };
+
+ let (tx, rx) = oneshot::channel();
+ queue.queue_main(move |self_| {
+ // TODO: Select appropriate URLs
+ let Some(app_url) = version_info.stable.urls.first() else {
+ return;
+ };
+ let Some(signature_url) = version_info.stable.signature_urls.first() else {
+ return;
+ };
+ let app_size = version_info.stable.size;
+
+ self_.set_download_text("");
+ self_.hide_download_button();
+ self_.hide_beta_text();
+ self_.show_cancel_button();
+ self_.enable_cancel_button();
+ self_.show_download_progress();
+
+ let downloader = DownloaderFactory::new_downloader(UiAppDownloaderParameters {
+ signature_url: signature_url.to_owned(),
+ app_url: app_url.to_owned(),
+ app_size,
+ sig_progress: UiProgressUpdater::new(self_.queue()),
+ app_progress: UiProgressUpdater::new(self_.queue()),
+ });
+
+ let ui_downloader = UiAppDownloader::new(self_, downloader);
+ let _ = tx.send(tokio::spawn(async move {
+ let _ = app::install_and_upgrade(ui_downloader).await;
+ }));
+ });
+ active_download = rx.await.ok();
+ }
+ TaskMessage::Cancel => {
+ let Some(active_download) = active_download.take() else {
+ continue;
+ };
+ active_download.abort();
+ let _ = active_download.await;
+
+ let (version_label, has_beta) = if let Some(version_info) = &version_info {
+ (
+ format_latest_version(&version_info.stable),
+ version_info.beta.is_some(),
+ )
+ } else {
+ ("".to_owned(), false)
+ };
+
+ queue.queue_main(move |self_| {
+ self_.set_status_text(&version_label);
+ self_.set_download_text("");
+ self_.show_download_button();
+ if has_beta {
+ self_.show_beta_text();
+ }
+ self_.hide_cancel_button();
+ self_.hide_download_progress();
+ self_.set_download_progress(0);
+ });
+ }
+ }
+ }
+}
+
+fn format_latest_version(version: &Version) -> String {
+ format!("{}: {}", resource::LATEST_VERSION_PREFIX, version.version)
+}
diff --git a/installer-downloader/src/delegate.rs b/installer-downloader/src/delegate.rs
new file mode 100644
index 0000000000..beca2e6282
--- /dev/null
+++ b/installer-downloader/src/delegate.rs
@@ -0,0 +1,72 @@
+//! Framework-agnostic module that hooks up a UI to actions
+
+pub use crate::ui_downloader::UiProgressUpdater;
+
+/// Trait implementing high-level UI actions
+pub trait AppDelegate {
+ /// Queue lets us perform actions from other threads
+ type Queue: AppDelegateQueue<Self>;
+
+ /// Register click handler for the download button
+ fn on_download<F>(&mut self, callback: F)
+ where
+ F: Fn() + Send + 'static;
+
+ /// Register click handler for the cancel button
+ fn on_cancel<F>(&mut self, callback: F)
+ where
+ F: Fn() + Send + 'static;
+
+ /// Set status text
+ fn set_status_text(&mut self, text: &str);
+
+ /// Set download text
+ fn set_download_text(&mut self, text: &str);
+
+ /// Show download progress bar
+ fn show_download_progress(&mut self);
+
+ /// Hide download progress bar
+ fn hide_download_progress(&mut self);
+
+ /// Update download progress bar
+ fn set_download_progress(&mut self, complete: u32);
+
+ /// Enable download button
+ fn enable_download_button(&mut self);
+
+ /// Disable download button
+ fn disable_download_button(&mut self);
+
+ /// Show download button
+ fn show_download_button(&mut self);
+
+ /// Hide download button
+ fn hide_download_button(&mut self);
+
+ /// Show cancel button
+ fn show_cancel_button(&mut self);
+
+ /// Hide cancel button
+ fn hide_cancel_button(&mut self);
+
+ /// Enable cancel button
+ fn enable_cancel_button(&mut self);
+
+ /// Disable cancel button
+ fn disable_cancel_button(&mut self);
+
+ /// Show beta text
+ fn show_beta_text(&mut self);
+
+ /// Hide beta text
+ fn hide_beta_text(&mut self);
+
+ /// Create queue for scheduling actions on UI thread
+ fn queue(&self) -> Self::Queue;
+}
+
+/// Schedules actions on the UI thread from other threads
+pub trait AppDelegateQueue<T: ?Sized>: Send {
+ fn queue_main<F: FnOnce(&mut T) + 'static + Send>(&self, callback: F);
+}
diff --git a/installer-downloader/src/lib.rs b/installer-downloader/src/lib.rs
new file mode 100644
index 0000000000..724b546998
--- /dev/null
+++ b/installer-downloader/src/lib.rs
@@ -0,0 +1,4 @@
+pub mod controller;
+pub mod delegate;
+pub mod resource;
+pub mod ui_downloader;
diff --git a/installer-downloader/src/main.rs b/installer-downloader/src/main.rs
new file mode 100644
index 0000000000..51f9e303e5
--- /dev/null
+++ b/installer-downloader/src/main.rs
@@ -0,0 +1,39 @@
+#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
+
+#[cfg(target_os = "windows")]
+mod winapi_impl;
+
+#[cfg(target_os = "macos")]
+mod cacao_impl;
+
+#[cfg(any(target_os = "windows", target_os = "macos"))]
+mod inner {
+ pub use installer_downloader::controller;
+ pub use installer_downloader::delegate;
+ pub use installer_downloader::resource;
+
+ pub fn run() {
+ let rt = tokio::runtime::Builder::new_multi_thread()
+ .enable_all()
+ .build()
+ .expect("failed to create tokio runtime");
+ let _guard = rt.enter();
+
+ #[cfg(target_os = "windows")]
+ super::winapi_impl::main();
+
+ #[cfg(target_os = "macos")]
+ super::cacao_impl::main();
+ }
+}
+
+#[cfg(not(any(target_os = "windows", target_os = "macos")))]
+mod inner {
+ pub fn run() {}
+}
+
+use inner::*;
+
+fn main() {
+ run()
+}
diff --git a/installer-downloader/src/resource.rs b/installer-downloader/src/resource.rs
new file mode 100644
index 0000000000..b5f5a0bf85
--- /dev/null
+++ b/installer-downloader/src/resource.rs
@@ -0,0 +1,47 @@
+//! Shared text and other resources
+
+/// Window title
+pub const WINDOW_TITLE: &str = "Mullvad VPN downloader";
+/// Window width
+pub const WINDOW_WIDTH: usize = 600;
+/// Window height
+pub const WINDOW_HEIGHT: usize = 334;
+
+/// Text description in the top banner
+pub const BANNER_DESC: &str =
+ "The Mullvad VPN app installer will be downloaded and verified for authenticity.";
+
+/// Beta preface text
+pub const BETA_PREFACE_DESC: &str = "Want to try the new Beta version? ";
+/// Beta link text
+pub const BETA_LINK_TEXT: &str = "Click here!";
+
+/// Dimensions of cancel button (including padding)
+pub const CANCEL_BUTTON_SIZE: (usize, usize) = (150, 40);
+
+/// Download button text
+pub const DOWNLOAD_BUTTON_TEXT: &str = "Download & install";
+
+/// Dimensions of download button (including padding)
+pub const DOWNLOAD_BUTTON_SIZE: (usize, usize) = (150, 40);
+
+/// Cancel button text
+pub const CANCEL_BUTTON_TEXT: &str = "Cancel";
+
+/// Displayed while fetching version info from the API
+pub const FETCH_VERSION_DESC: &str = "Loading version details...";
+
+/// The first part of "Version: 2025.1"
+pub const LATEST_VERSION_PREFIX: &str = "Version";
+
+/// Displayed while fetching version info from the API failed
+pub const FETCH_VERSION_ERROR_DESC: &str = "Couldn't load version details, please try again or make sure you have the latest installer downloader.";
+
+/// The first part of "Downloading from <some url>... (x%)", displayed during download
+pub const DOWNLOADING_DESC_PREFIX: &str = "Downloading from";
+
+/// Displayed after completed download
+pub const DOWNLOAD_COMPLETE_DESC: &str = "Download complete. Verifying...";
+
+/// Displayed after verification
+pub const VERIFICATION_SUCCEEDED_DESC: &str = "Verification successful. Starting install...";
diff --git a/installer-downloader/src/ui_downloader.rs b/installer-downloader/src/ui_downloader.rs
new file mode 100644
index 0000000000..41b6fa3efb
--- /dev/null
+++ b/installer-downloader/src/ui_downloader.rs
@@ -0,0 +1,142 @@
+//! This module hooks up [AppDelegate]s to arbitrary implementations of [AppDownloader] and
+//! [fetch::ProgressUpdater].
+
+use crate::{
+ delegate::{AppDelegate, AppDelegateQueue},
+ resource,
+};
+use mullvad_update::{
+ app::{self, AppDownloader, AppDownloaderParameters},
+ fetch,
+};
+
+/// [AppDownloader] that delegates the actual work to some underlying `downloader` and uses it to
+/// update a UI.
+pub struct UiAppDownloader<Delegate: AppDelegate, Downloader> {
+ downloader: Downloader,
+ /// Queue used to control the app UI
+ queue: Delegate::Queue,
+}
+
+/// Parameters for [UiAppDownloader]
+pub type UiAppDownloaderParameters<Delegate> =
+ AppDownloaderParameters<UiProgressUpdater<Delegate>, UiProgressUpdater<Delegate>>;
+
+impl<Delegate: AppDelegate, Downloader: AppDownloader + Send + 'static>
+ UiAppDownloader<Delegate, Downloader>
+{
+ /// Construct a [UiAppDownloader].
+ pub fn new(delegate: &Delegate, downloader: Downloader) -> Self {
+ Self {
+ downloader,
+ queue: delegate.queue(),
+ }
+ }
+}
+
+#[async_trait::async_trait]
+impl<Delegate: AppDelegate, Downloader: AppDownloader + Send + 'static> AppDownloader
+ for UiAppDownloader<Delegate, Downloader>
+{
+ async fn download_signature(&mut self) -> Result<(), app::DownloadError> {
+ if let Err(error) = self.downloader.download_signature().await {
+ self.queue.queue_main(move |self_| {
+ self_.set_download_text("ERROR: Failed to retrieve signature.");
+ self_.enable_download_button();
+ self_.hide_cancel_button();
+ });
+ Err(error)
+ } else {
+ Ok(())
+ }
+ }
+
+ async fn download_executable(&mut self) -> Result<(), app::DownloadError> {
+ match self.downloader.download_executable().await {
+ Ok(()) => {
+ self.queue.queue_main(move |self_| {
+ self_.set_download_text(resource::DOWNLOAD_COMPLETE_DESC);
+ self_.disable_cancel_button();
+ });
+
+ Ok(())
+ }
+ Err(err) => {
+ self.queue.queue_main(move |self_| {
+ self_.set_download_text("ERROR: Download failed. Please try again.");
+ self_.enable_download_button();
+ self_.hide_cancel_button();
+ });
+
+ Err(err)
+ }
+ }
+ }
+
+ async fn verify(&mut self) -> Result<(), app::DownloadError> {
+ match self.downloader.verify().await {
+ Ok(()) => {
+ self.queue.queue_main(move |self_| {
+ self_.set_download_text(resource::VERIFICATION_SUCCEEDED_DESC);
+ });
+
+ Ok(())
+ }
+ Err(error) => {
+ self.queue.queue_main(move |self_| {
+ self_.set_download_text("ERROR: Verification failed!");
+ });
+
+ Err(error)
+ }
+ }
+ }
+}
+
+/// Implementation of [fetch::ProgressUpdater] that updates some [AppDelegate].
+pub struct UiProgressUpdater<Delegate: AppDelegate> {
+ domain: String,
+ prev_progress: Option<u32>,
+ queue: Delegate::Queue,
+}
+
+impl<Delegate: AppDelegate> UiProgressUpdater<Delegate> {
+ pub fn new(queue: Delegate::Queue) -> Self {
+ Self {
+ domain: "unknown source".to_owned(),
+ prev_progress: None,
+ queue,
+ }
+ }
+}
+
+impl<Delegate: AppDelegate + 'static> fetch::ProgressUpdater for UiProgressUpdater<Delegate> {
+ fn set_progress(&mut self, fraction_complete: f32) {
+ let value = (100.0 * fraction_complete).min(100.0) as u32;
+
+ if self.prev_progress == Some(value) {
+ // Unconditionally updating causes flickering
+ return;
+ }
+
+ let status = format!(
+ "{} {}... ({value}%)",
+ resource::DOWNLOADING_DESC_PREFIX,
+ self.domain
+ );
+
+ self.queue.queue_main(move |self_| {
+ self_.set_download_progress(value);
+ self_.set_download_text(&status);
+ });
+
+ self.prev_progress = Some(value);
+ }
+
+ fn set_url(&mut self, url: &str) {
+ // Parse out domain name
+ let url = url.strip_prefix("https://").unwrap_or(url);
+ let (domain, _) = url.split_once('/').unwrap_or((url, ""));
+ self.domain = domain.to_owned();
+ }
+}
diff --git a/installer-downloader/src/winapi_impl/delegate.rs b/installer-downloader/src/winapi_impl/delegate.rs
new file mode 100644
index 0000000000..4414559e7c
--- /dev/null
+++ b/installer-downloader/src/winapi_impl/delegate.rs
@@ -0,0 +1,147 @@
+//! This module implements [AppDelegate] and [Queue], which allows the NWG UI to be hooked up to our
+//! generic controller.
+
+use native_windows_gui::{self as nwg, Event};
+use windows_sys::Win32::UI::WindowsAndMessaging::PostMessageW;
+
+use super::ui::{AppWindow, QUEUE_MESSAGE};
+use crate::delegate::{AppDelegate, AppDelegateQueue};
+
+impl AppDelegate for AppWindow {
+ type Queue = Queue;
+
+ fn on_download<F>(&mut self, callback: F)
+ where
+ F: Fn() + Send + 'static,
+ {
+ register_click_handler(self.window.handle, self.download_button.handle, callback);
+ }
+
+ fn on_cancel<F>(&mut self, callback: F)
+ where
+ F: Fn() + Send + 'static,
+ {
+ register_click_handler(self.window.handle, self.cancel_button.handle, callback);
+ }
+
+ fn set_status_text(&mut self, text: &str) {
+ self.status_text.set_text(text);
+ }
+
+ fn set_download_text(&mut self, text: &str) {
+ if !text.is_empty() {
+ self.download_text.set_visible(true);
+ self.download_text.set_text(text);
+ } else {
+ self.download_text.set_visible(false);
+ }
+ }
+
+ fn show_download_progress(&mut self) {
+ self.progress_bar.set_visible(true);
+ }
+
+ fn hide_download_progress(&mut self) {
+ self.progress_bar.set_visible(false);
+ }
+
+ fn set_download_progress(&mut self, complete: u32) {
+ self.progress_bar.set_pos(complete);
+ }
+
+ fn show_download_button(&mut self) {
+ self.download_button.set_visible(true);
+ }
+
+ fn hide_download_button(&mut self) {
+ self.download_button.set_visible(false);
+ }
+
+ fn enable_download_button(&mut self) {
+ self.download_button.set_enabled(true);
+ }
+
+ fn disable_download_button(&mut self) {
+ self.download_button.set_enabled(false);
+ }
+
+ fn show_cancel_button(&mut self) {
+ self.cancel_button.set_visible(true);
+ }
+
+ fn hide_cancel_button(&mut self) {
+ self.cancel_button.set_visible(false);
+ }
+
+ fn enable_cancel_button(&mut self) {
+ self.cancel_button.set_enabled(true);
+ }
+
+ fn disable_cancel_button(&mut self) {
+ self.cancel_button.set_enabled(false);
+ }
+
+ fn show_beta_text(&mut self) {
+ self.beta_prefix.set_visible(true);
+ self.beta_link.set_visible(true);
+ }
+
+ fn hide_beta_text(&mut self) {
+ self.beta_prefix.set_visible(false);
+ self.beta_link.set_visible(false);
+ }
+
+ fn queue(&self) -> Self::Queue {
+ Queue {
+ main_wnd: self.window.handle,
+ }
+ }
+}
+
+/// Register a window message for clicking this button that triggers `callback`.
+fn register_click_handler(
+ parent: nwg::ControlHandle,
+ button: nwg::ControlHandle,
+ callback: impl Fn() + 'static,
+) {
+ nwg::bind_event_handler(&button, &parent, move |evt, _, handle| {
+ if evt == Event::OnButtonClick && handle == button {
+ callback();
+ }
+ });
+}
+
+/// Queue sends a window message to the main window containing a [QueueContext], giving us mutable
+/// access to the [AppDelegate] on the main UI thread.
+///
+/// See [QueueContext] docs for more information.
+#[derive(Clone)]
+pub struct Queue {
+ main_wnd: nwg::ControlHandle,
+}
+
+// SAFETY: It is safe to post window messages across threads
+unsafe impl Send for Queue {}
+
+/// The context contains a callback function that is passed as a pointer to the main thread
+/// along with a custom window message `QUEUE_MESSAGE`.
+///
+/// It must be wrapped in a struct since we cannot pass a fat pointer
+/// `*mut dyn for<'a> FnOnce(&'a mut AppWindow) + Send` to `PostMessageW`.
+pub struct QueueContext {
+ pub callback: Box<dyn for<'a> FnOnce(&'a mut AppWindow) + Send>,
+}
+
+impl AppDelegateQueue<AppWindow> for Queue {
+ fn queue_main<F: FnOnce(&mut AppWindow) + 'static + Send>(&self, callback: F) {
+ let Some(hwnd) = self.main_wnd.hwnd() else {
+ return;
+ };
+ let context = QueueContext {
+ callback: Box::new(callback),
+ };
+ let context_ptr = Box::into_raw(Box::new(context));
+ // SAFETY: This is safe since `callback` is Send
+ unsafe { PostMessageW(hwnd as _, QUEUE_MESSAGE, 0, context_ptr as isize) };
+ }
+}
diff --git a/installer-downloader/src/winapi_impl/mod.rs b/installer-downloader/src/winapi_impl/mod.rs
new file mode 100644
index 0000000000..15a9957c00
--- /dev/null
+++ b/installer-downloader/src/winapi_impl/mod.rs
@@ -0,0 +1,22 @@
+use native_windows_gui as nwg;
+
+use crate::delegate::{AppDelegate, AppDelegateQueue};
+
+mod delegate;
+mod ui;
+
+pub fn main() {
+ nwg::init().expect("Failed to init Native Windows GUI");
+ nwg::Font::set_global_family("Segoe UI").expect("Failed to set default font");
+
+ let window = ui::AppWindow::default();
+ let window = window.layout().unwrap();
+
+ let queue = window.borrow().queue();
+
+ queue.queue_main(|window| {
+ crate::controller::initialize_controller(window);
+ });
+
+ nwg::dispatch_thread_events();
+}
diff --git a/installer-downloader/src/winapi_impl/ui.rs b/installer-downloader/src/winapi_impl/ui.rs
new file mode 100644
index 0000000000..1b4704ad3d
--- /dev/null
+++ b/installer-downloader/src/winapi_impl/ui.rs
@@ -0,0 +1,373 @@
+//! This module handles setting up and rendering changes to the UI
+
+//use std::borrow::Cow;
+use std::cell::RefCell;
+use std::rc::Rc;
+
+use native_windows_gui::{self as nwg, ControlHandle, ImageDecoder, WindowFlags};
+
+use windows_sys::Win32::Foundation::COLORREF;
+use windows_sys::Win32::Graphics::Gdi::{
+ CreateFontIndirectW, SetBkColor, SetBkMode, SetTextColor, COLOR_WINDOW, LOGFONTW, TRANSPARENT,
+};
+use windows_sys::Win32::UI::WindowsAndMessaging::WM_CTLCOLORSTATIC;
+
+use crate::resource::{
+ BANNER_DESC, BETA_LINK_TEXT, BETA_PREFACE_DESC, CANCEL_BUTTON_SIZE, CANCEL_BUTTON_TEXT,
+ DOWNLOAD_BUTTON_SIZE, DOWNLOAD_BUTTON_TEXT, WINDOW_HEIGHT, WINDOW_TITLE, WINDOW_WIDTH,
+};
+
+use super::delegate::QueueContext;
+
+static BANNER_IMAGE_DATA: &[u8] = include_bytes!("../../assets/logo-icon.png");
+static BANNER_TEXT_IMAGE_DATA: &[u8] = include_bytes!("../../assets/logo-text.png");
+
+const BACKGROUND_COLOR: [u8; 3] = [0x19, 0x2e, 0x45];
+/// Beta link color: #003E92
+const LINK_COLOR: [u8; 3] = [0x00, 0x3e, 0x92];
+
+/// Custom window message handler used to adjust the banner text color.
+pub const SET_LABEL_HANDLER_ID: usize = 0x10000;
+/// Unique ID of the handler used to handle our custom `QUEUE_MESSAGE`.
+pub const QUEUE_MESSAGE_HANDLER_ID: usize = 0x10001;
+/// Custom window message used to process requests from other threads.
+pub const QUEUE_MESSAGE: u32 = 0x10001;
+/// Unique ID of the handler for the beta link.
+pub const BETA_LINK_HANDLER_ID: usize = 0x10002;
+
+#[derive(Default)]
+pub struct AppWindow {
+ pub window: nwg::Window,
+
+ pub banner: nwg::ImageFrame,
+
+ pub banner_text: nwg::Label,
+ pub banner_text_image_bitmap: RefCell<Option<nwg::Bitmap>>,
+ pub banner_text_image: nwg::ImageFrame,
+ pub banner_image_bitmap: RefCell<Option<nwg::Bitmap>>,
+ pub banner_image: nwg::ImageFrame,
+
+ pub cancel_button: nwg::Button,
+ pub download_button: nwg::Button,
+
+ pub progress_bar: nwg::ProgressBar,
+
+ pub status_text: nwg::Label,
+ pub download_text: nwg::Label,
+
+ pub beta_prefix: nwg::Label,
+ pub beta_link: nwg::Label,
+}
+
+impl AppWindow {
+ /// Set up UI elements, position them, and register window message handlers
+ /// Note that some additional setup happens in [Self::on_init]
+ pub fn layout(mut self) -> Result<Rc<RefCell<AppWindow>>, nwg::NwgError> {
+ nwg::Window::builder()
+ .size((WINDOW_WIDTH as i32, WINDOW_HEIGHT as i32))
+ .center(true)
+ .title(WINDOW_TITLE)
+ .flags(WindowFlags::WINDOW)
+ .build(&mut self.window)?;
+
+ nwg::ImageFrame::builder()
+ .parent(&self.window)
+ .background_color(Some(BACKGROUND_COLOR))
+ .build(&mut self.banner)?;
+
+ nwg::Label::builder()
+ .parent(&self.banner)
+ .background_color(Some(BACKGROUND_COLOR))
+ .build(&mut self.banner_text)?;
+
+ nwg::ImageFrame::builder()
+ .parent(&self.banner)
+ .background_color(Some(BACKGROUND_COLOR))
+ .build(&mut self.banner_image)?;
+ nwg::ImageFrame::builder()
+ .parent(&self.banner)
+ .background_color(Some(BACKGROUND_COLOR))
+ .build(&mut self.banner_text_image)?;
+
+ nwg::Button::builder()
+ .parent(&self.window)
+ .size(try_pair_into(DOWNLOAD_BUTTON_SIZE).unwrap())
+ .text(&DOWNLOAD_BUTTON_TEXT.replace("&", "&&"))
+ .build(&mut self.download_button)?;
+
+ nwg::Button::builder()
+ .parent(&self.window)
+ .size(try_pair_into(CANCEL_BUTTON_SIZE).unwrap())
+ .text(CANCEL_BUTTON_TEXT)
+ .build(&mut self.cancel_button)?;
+
+ nwg::Label::builder()
+ .parent(&self.window)
+ .size((320, 32))
+ .text("")
+ .h_align(nwg::HTextAlign::Center)
+ .build(&mut self.status_text)?;
+
+ nwg::Label::builder()
+ .parent(&self.window)
+ .size((480, 32))
+ .text("")
+ .h_align(nwg::HTextAlign::Center)
+ .build(&mut self.download_text)?;
+
+ nwg::Label::builder()
+ .parent(&self.window)
+ .size((240, 24))
+ .text(BETA_PREFACE_DESC)
+ .h_align(nwg::HTextAlign::Left)
+ .build(&mut self.beta_prefix)?;
+ nwg::Label::builder()
+ .parent(&self.window)
+ .size((128, 24))
+ .text(BETA_LINK_TEXT)
+ .font(Some(&create_link_font()?))
+ .h_align(nwg::HTextAlign::Left)
+ .build(&mut self.beta_link)?;
+
+ const PROGRESS_BAR_MARGIN: i32 = 48;
+ nwg::ProgressBar::builder()
+ .parent(&self.window)
+ .size((WINDOW_WIDTH as i32 - 2 * PROGRESS_BAR_MARGIN, 16))
+ .build(&mut self.progress_bar)?;
+
+ const BANNER_HEIGHT: u32 = 102;
+
+ self.banner.set_size(self.window.size().0, BANNER_HEIGHT);
+
+ const LOWER_AREA_YMARGIN: i32 = 48;
+ const LOWER_AREA_YPADDING: i32 = 16;
+ const LABEL_YSPACING: i32 = 16;
+
+ self.download_text.set_visible(false);
+ self.status_text.set_position(
+ (self.window.size().0 / 2) as i32 - (self.status_text.size().0 / 2) as i32,
+ BANNER_HEIGHT as i32 + LOWER_AREA_YMARGIN,
+ );
+ self.download_button.set_position(
+ (self.window.size().0 / 2) as i32 - (self.download_button.size().0 / 2) as i32,
+ self.status_text.position().1 + 8 + LABEL_YSPACING + LOWER_AREA_YPADDING,
+ );
+ self.download_text.set_position(
+ (self.window.size().0 / 2) as i32 - (self.download_text.size().0 / 2) as i32,
+ self.status_text.position().1 + LABEL_YSPACING + LOWER_AREA_YPADDING,
+ );
+ self.progress_bar.set_position(
+ PROGRESS_BAR_MARGIN,
+ self.download_text.position().1 + LABEL_YSPACING + LOWER_AREA_YPADDING,
+ );
+ self.cancel_button.set_position(
+ (self.window.size().0 / 2) as i32 - (self.cancel_button.size().0 / 2) as i32,
+ self.progress_bar.position().1
+ + self.progress_bar.size().1 as i32
+ + LOWER_AREA_YPADDING,
+ );
+
+ self.beta_prefix.set_position(
+ 24,
+ self.window.size().1 as i32 - 24 - self.beta_prefix.size().1 as i32,
+ );
+ self.beta_link.set_position(
+ self.beta_prefix.position().0 + self.beta_prefix.size().0 as i32,
+ self.beta_prefix.position().1,
+ );
+ handle_beta_link_messages(&self.window, &self.beta_link, BETA_LINK_HANDLER_ID)?;
+
+ self.window.set_visible(true);
+
+ let event_handle = self.window.handle;
+ let app = Rc::new(RefCell::new(self));
+
+ handle_init_and_close_messages(event_handle, app.clone());
+ handle_queue_message(event_handle, app.clone())?;
+
+ Ok(app)
+ }
+
+ /// This function is called when the top-level window has been created
+ fn on_init(&self) {
+ if let Err(err) = self.load_banner_image() {
+ eprintln!("load_banner_image failed: {err}");
+ // not fatal, so continue
+ }
+ if let Err(err) = self.load_banner_text_image() {
+ eprintln!("load_banner_text_image failed: {err}");
+ // not fatal, so continue
+ }
+
+ if let Err(err) = handle_banner_label_colors(&self.banner.handle, SET_LABEL_HANDLER_ID) {
+ eprintln!("handle_banner_label_colors failed: {err}");
+ // not fatal, so continue
+ }
+
+ self.banner_text.set_text(BANNER_DESC);
+ self.banner_text
+ .set_position(24, self.banner_image.position().1 + 20);
+ self.banner_text.set_size(
+ WINDOW_WIDTH as u32 - self.banner_text.position().0 as u32 - 12,
+ 64,
+ );
+ }
+
+ /// This function is called when user clicks the "X"
+ fn on_close(&self) {
+ nwg::stop_thread_dispatch();
+ }
+
+ /// Load the embedded image and display it in `banner_image`
+ fn load_banner_image(&self) -> Result<(), nwg::NwgError> {
+ let src = ImageDecoder::new()?.from_stream(BANNER_IMAGE_DATA)?;
+ let frame = src.frame(0)?;
+ let size = frame.size();
+ let mut img = self.banner_image_bitmap.borrow_mut();
+ let bmp = frame.as_bitmap()?;
+ img.replace(bmp);
+
+ self.banner_image.set_bitmap(img.as_ref());
+ self.banner_image.set_position(24, 24);
+ self.banner_image.set_size(size.0, size.1);
+
+ Ok(())
+ }
+
+ /// Load the embedded image and display it in `banner_text_image`
+ fn load_banner_text_image(&self) -> Result<(), nwg::NwgError> {
+ let src = ImageDecoder::new()?.from_stream(BANNER_TEXT_IMAGE_DATA)?;
+ let frame = src.frame(0)?;
+ let size = frame.size();
+ let mut img = self.banner_text_image_bitmap.borrow_mut();
+ img.replace(frame.as_bitmap()?);
+
+ self.banner_text_image.set_bitmap(img.as_ref());
+ self.banner_text_image.set_position(
+ self.banner_image.position().0 + self.banner_image.size().0 as i32 + 8,
+ self.banner_image.position().1 + self.banner_image.size().1 as i32 / 2
+ - size.1 as i32 / 2,
+ );
+ self.banner_text_image.set_size(size.0, size.1);
+
+ Ok(())
+ }
+}
+
+/// Register a window message handler that ensures that the banner labels are rendered with the
+/// correct color
+fn handle_banner_label_colors(
+ banner: &ControlHandle,
+ handler_id: usize,
+) -> Result<nwg::RawEventHandler, nwg::NwgError> {
+ nwg::bind_raw_event_handler(banner, handler_id, move |_hwnd, msg, w, _p| {
+ /// This is the RGB() macro except it takes in a slice representing RGB values
+ pub fn rgb(color: [u8; 3]) -> COLORREF {
+ color[0] as COLORREF | ((color[1] as COLORREF) << 8) | ((color[2] as COLORREF) << 16)
+ }
+
+ if msg == WM_CTLCOLORSTATIC {
+ unsafe {
+ SetTextColor(w as _, rgb([255, 255, 255]));
+ SetBkColor(w as _, rgb(BACKGROUND_COLOR));
+ }
+ }
+ None
+ })
+}
+
+/// Register a window message handler for the beta link component
+fn handle_beta_link_messages(
+ parent: &nwg::Window,
+ link: &nwg::Label,
+ handler_id: usize,
+) -> Result<nwg::RawEventHandler, nwg::NwgError> {
+ let link_hwnd = link.handle.hwnd().map(|hwnd| hwnd as isize);
+ nwg::bind_raw_event_handler(&parent.handle, handler_id, move |_hwnd, msg, w, p| {
+ /// This is the RGB() macro except it takes in a slice representing RGB values
+ pub fn rgb(color: [u8; 3]) -> COLORREF {
+ color[0] as COLORREF | ((color[1] as COLORREF) << 8) | ((color[2] as COLORREF) << 16)
+ }
+
+ if msg == WM_CTLCOLORSTATIC && Some(p) == link_hwnd {
+ unsafe {
+ SetBkMode(w as _, TRANSPARENT as _);
+ SetTextColor(w as _, rgb(LINK_COLOR));
+ }
+ // Out of bounds background
+ return Some(COLOR_WINDOW as _);
+ }
+
+ None
+ })
+}
+
+/// Register events for [AppWindow::on_init] and [AppWindow::on_close].
+fn handle_init_and_close_messages(
+ window: impl Into<ControlHandle>,
+ app: Rc<RefCell<AppWindow>>,
+) -> nwg::EventHandler {
+ let window = window.into();
+ nwg::full_bind_event_handler(&window, move |event, _data, handle| match event {
+ nwg::Event::OnInit if handle == window => {
+ let app = app.borrow();
+ app.on_init();
+ }
+ nwg::Event::OnWindowClose if handle == window => {
+ let app = app.borrow();
+ app.on_close();
+ }
+ _ => (),
+ })
+}
+
+/// This handles `QUEUE_MESSAGE` messages, which contain callbacks reachable from
+/// pointers to a [super::delegate::QueueContext]. See [super::delegate::QueueContext]
+/// and [super::delegate::Queue] for details.
+fn handle_queue_message(
+ window: impl Into<ControlHandle>,
+ app: Rc<RefCell<AppWindow>>,
+) -> Result<nwg::RawEventHandler, nwg::NwgError> {
+ nwg::bind_raw_event_handler(
+ &window.into(),
+ QUEUE_MESSAGE_HANDLER_ID,
+ move |_hwnd, msg, _w, p| {
+ if msg == QUEUE_MESSAGE {
+ // SAFETY: This message is only sent with a boxed sendable function pointer, so we're
+ // good. See the implementation of `AppDelegateQueue` for `Queue`.
+ let context = unsafe { Box::from_raw(p as *mut QueueContext) };
+ let mut app = app.borrow_mut();
+ (context.callback)(&mut app);
+ }
+ None
+ },
+ )
+}
+
+fn try_pair_into<A: TryInto<B>, B>(a: (A, A)) -> Result<(B, B), A::Error> {
+ Ok((a.0.try_into()?, a.1.try_into()?))
+}
+
+/// Create a link font
+/// TODO: upstream to nwg
+fn create_link_font() -> Result<nwg::Font, nwg::NwgError> {
+ let face_name = "Segoe UI".encode_utf16();
+
+ let raw_font = unsafe {
+ let mut logfont: LOGFONTW = std::mem::zeroed();
+ logfont.lfUnderline = 1;
+
+ for (dest, src) in logfont.lfFaceName.iter_mut().zip(face_name) {
+ *dest = src;
+ }
+ CreateFontIndirectW(&logfont)
+ };
+
+ if raw_font == 0 {
+ return Err(nwg::NwgError::Unknown);
+ }
+
+ Ok(nwg::Font {
+ handle: raw_font as _,
+ })
+}
diff --git a/installer-downloader/tests/controller.rs b/installer-downloader/tests/controller.rs
new file mode 100644
index 0000000000..92e290273a
--- /dev/null
+++ b/installer-downloader/tests/controller.rs
@@ -0,0 +1,366 @@
+//! Tests for integrations between UI controller and other components
+//!
+//! The tests rely on `insta` for snapshot testing. If they fail due to snapshot assertions,
+//! then most likely the snapshots need to be updated. The most convenient way to review
+//! changes to, and update, snapshots are by running `cargo insta review`.
+
+use insta::assert_yaml_snapshot;
+use installer_downloader::controller::AppController;
+use installer_downloader::delegate::{AppDelegate, AppDelegateQueue};
+use installer_downloader::ui_downloader::UiAppDownloaderParameters;
+use mullvad_update::api::{Version, VersionInfo, VersionInfoProvider};
+use mullvad_update::app::{AppDownloader, AppDownloaderFactory, DownloadError};
+use mullvad_update::fetch::ProgressUpdater;
+use std::sync::{Arc, LazyLock, Mutex};
+use std::time::Duration;
+use std::vec::Vec;
+
+pub struct FakeVersionInfoProvider {}
+
+static FAKE_VERSION: LazyLock<VersionInfo> = LazyLock::new(|| VersionInfo {
+ stable: Version {
+ version: "2025.1".to_owned(),
+ urls: vec!["https://mullvad.net/fakeapp".to_owned()],
+ size: 1234,
+ signature_urls: vec!["https://mullvad.net/fakesig".to_owned()],
+ },
+ beta: None,
+});
+
+#[async_trait::async_trait]
+impl VersionInfoProvider for FakeVersionInfoProvider {
+ async fn get_version_info() -> anyhow::Result<VersionInfo> {
+ Ok(FAKE_VERSION.clone())
+ }
+}
+
+/// Downloader for which all steps immediately succeed
+pub type FakeAppDownloaderHappyPath = FakeAppDownloader<true, true, true>;
+
+/// Downloader for which the download step fails
+pub type FakeAppDownloaderDownloadFail = FakeAppDownloader<true, false, false>;
+
+/// Downloader for which all but the final verification step succeed
+pub type FakeAppDownloaderVerifyFail = FakeAppDownloader<true, true, false>;
+
+impl<const A: bool, const B: bool, const C: bool> AppDownloaderFactory
+ for FakeAppDownloader<A, B, C>
+{
+ type Parameters = UiAppDownloaderParameters<FakeAppDelegate>;
+
+ fn new_downloader(params: Self::Parameters) -> Self {
+ FakeAppDownloader { params }
+ }
+}
+
+/// Fake app downloader
+///
+/// Parameters:
+/// * SIG_SUCCEED - whether fetching the signature succeeds
+/// * EXE_SUCCEED - whether fetching the binary succeeds
+/// * VERIFY_SUCCEED - whether verifying the signature succeeds
+pub struct FakeAppDownloader<
+ const SIG_SUCCEED: bool,
+ const EXE_SUCCEED: bool,
+ const VERIFY_SUCCEED: bool,
+> {
+ params: UiAppDownloaderParameters<FakeAppDelegate>,
+}
+
+#[async_trait::async_trait]
+impl<const SIG_SUCCEED: bool, const EXE_SUCCEED: bool, const VERIFY_SUCCEED: bool> AppDownloader
+ for FakeAppDownloader<SIG_SUCCEED, EXE_SUCCEED, VERIFY_SUCCEED>
+{
+ async fn download_signature(&mut self) -> Result<(), DownloadError> {
+ self.params.sig_progress.set_url(&self.params.signature_url);
+ self.params.sig_progress.set_progress(0.);
+ if SIG_SUCCEED {
+ self.params.sig_progress.set_progress(1.);
+ Ok(())
+ } else {
+ Err(DownloadError::FetchSignature(anyhow::anyhow!(
+ "fetching signature failed"
+ )))
+ }
+ }
+
+ async fn download_executable(&mut self) -> Result<(), DownloadError> {
+ self.params.app_progress.set_url(&self.params.app_url);
+ self.params.app_progress.set_progress(0.);
+ if EXE_SUCCEED {
+ self.params.app_progress.set_progress(1.);
+ Ok(())
+ } else {
+ Err(DownloadError::FetchApp(anyhow::anyhow!(
+ "fetching app failed"
+ )))
+ }
+ }
+
+ async fn verify(&mut self) -> Result<(), DownloadError> {
+ if VERIFY_SUCCEED {
+ Ok(())
+ } else {
+ Err(DownloadError::Verification(anyhow::anyhow!(
+ "verification failed"
+ )))
+ }
+ }
+}
+
+/// A fake queue that stores callbacks so that tests can run them later.
+#[derive(Clone, Default)]
+pub struct FakeQueue {
+ callbacks: Arc<Mutex<Vec<Box<dyn FnOnce(&mut FakeAppDelegate) + Send>>>>,
+}
+
+impl FakeQueue {
+ /// Run all queued callbacks on the given delegate.
+ fn run_callbacks(&self, delegate: &mut FakeAppDelegate) {
+ let mut callbacks = self.callbacks.lock().unwrap();
+ for cb in callbacks.drain(..) {
+ cb(delegate);
+ }
+ }
+}
+
+impl AppDelegateQueue<FakeAppDelegate> for FakeQueue {
+ fn queue_main<F: FnOnce(&mut FakeAppDelegate) + 'static + Send>(&self, callback: F) {
+ self.callbacks.lock().unwrap().push(Box::new(callback));
+ }
+}
+
+/// A fake [AppDelegate]
+#[derive(Default)]
+pub struct FakeAppDelegate {
+ /// Callback registered by `on_download`
+ pub download_callback: Option<Box<dyn Fn() + Send>>,
+ /// Callback registered by `on_cancel`
+ pub cancel_callback: Option<Box<dyn Fn() + Send>>,
+ /// State of delegate
+ pub state: DelegateState,
+ /// Queue used to simulate the main thread
+ pub queue: FakeQueue,
+}
+
+/// A complete state of the UI, including its call history
+#[derive(Default, serde::Serialize)]
+pub struct DelegateState {
+ pub status_text: String,
+ pub download_text: String,
+ pub download_button_visible: bool,
+ pub cancel_button_visible: bool,
+ pub cancel_button_enabled: bool,
+ pub download_button_enabled: bool,
+ pub download_progress: u32,
+ pub download_progress_visible: bool,
+ pub beta_text_visible: bool,
+ /// Record of method calls.
+ pub call_log: Vec<String>,
+}
+
+impl AppDelegate for FakeAppDelegate {
+ type Queue = FakeQueue;
+
+ fn on_download<F>(&mut self, callback: F)
+ where
+ F: Fn() + Send + 'static,
+ {
+ self.state.call_log.push("on_download".into());
+ self.download_callback = Some(Box::new(callback));
+ }
+
+ fn on_cancel<F>(&mut self, callback: F)
+ where
+ F: Fn() + Send + 'static,
+ {
+ self.state.call_log.push("on_cancel".into());
+ self.cancel_callback = Some(Box::new(callback));
+ }
+
+ fn set_status_text(&mut self, text: &str) {
+ self.state
+ .call_log
+ .push(format!("set_status_text: {}", text));
+ self.state.status_text = text.to_owned();
+ }
+
+ fn set_download_text(&mut self, text: &str) {
+ self.state
+ .call_log
+ .push(format!("set_download_text: {}", text));
+ self.state.download_text = text.to_owned();
+ }
+
+ fn show_download_progress(&mut self) {
+ self.state.call_log.push("show_download_progress".into());
+ self.state.download_progress_visible = true;
+ }
+
+ fn hide_download_progress(&mut self) {
+ self.state.call_log.push("hide_download_progress".into());
+ self.state.download_progress_visible = false;
+ }
+
+ fn set_download_progress(&mut self, complete: u32) {
+ self.state
+ .call_log
+ .push(format!("set_download_progress: {}", complete));
+ self.state.download_progress = complete;
+ }
+
+ fn show_download_button(&mut self) {
+ self.state.call_log.push("show_download_button".into());
+ self.state.download_button_visible = true;
+ }
+
+ fn hide_download_button(&mut self) {
+ self.state.call_log.push("hide_download_button".into());
+ self.state.download_button_visible = false;
+ }
+
+ fn enable_download_button(&mut self) {
+ self.state.call_log.push("enable_download_button".into());
+ self.state.download_button_enabled = true;
+ }
+
+ fn disable_download_button(&mut self) {
+ self.state.call_log.push("disable_download_button".into());
+ self.state.download_button_enabled = false;
+ }
+
+ fn show_cancel_button(&mut self) {
+ self.state.call_log.push("show_cancel_button".into());
+ self.state.cancel_button_visible = true;
+ }
+
+ fn hide_cancel_button(&mut self) {
+ self.state.call_log.push("hide_cancel_button".into());
+ self.state.cancel_button_visible = false;
+ }
+
+ fn enable_cancel_button(&mut self) {
+ self.state.call_log.push("enable_cancel_button".into());
+ self.state.cancel_button_enabled = true;
+ }
+
+ fn disable_cancel_button(&mut self) {
+ self.state.call_log.push("disable_cancel_button".into());
+ self.state.cancel_button_enabled = false;
+ }
+
+ fn show_beta_text(&mut self) {
+ self.state.call_log.push("show_beta_text".into());
+ self.state.beta_text_visible = true;
+ }
+
+ fn hide_beta_text(&mut self) {
+ self.state.call_log.push("hide_beta_text".into());
+ self.state.beta_text_visible = false;
+ }
+
+ fn queue(&self) -> Self::Queue {
+ self.queue.clone()
+ }
+}
+
+/// Test that the flow starts by fetching app version data
+#[tokio::test(start_paused = true)]
+async fn test_fetch_version() {
+ let mut delegate = FakeAppDelegate::default();
+ AppController::initialize::<_, FakeAppDownloaderHappyPath, FakeVersionInfoProvider>(
+ &mut delegate,
+ );
+
+ // The app should start out by fetching the current app version
+ assert_yaml_snapshot!(delegate.state);
+
+ tokio::time::sleep(Duration::from_secs(1)).await;
+
+ // Run UI updates to display the fetched version
+ let queue = delegate.queue.clone();
+ queue.run_callbacks(&mut delegate);
+
+ // The download button and current version should be displayed
+ assert_yaml_snapshot!(delegate.state);
+}
+
+/// Test that the on_download callback gets registered and, when invoked,
+/// properly updates the UI.
+#[tokio::test(start_paused = true)]
+async fn test_download() {
+ let mut delegate = FakeAppDelegate::default();
+ AppController::initialize::<_, FakeAppDownloaderHappyPath, FakeVersionInfoProvider>(
+ &mut delegate,
+ );
+
+ // Wait for the version info
+ tokio::time::sleep(Duration::from_secs(1)).await;
+
+ let queue = delegate.queue.clone();
+ queue.run_callbacks(&mut delegate);
+
+ // The download button should be available
+ assert_yaml_snapshot!(delegate.state);
+
+ // Initiate download
+ let cb = delegate
+ .download_callback
+ .take()
+ .expect("no download callback registered");
+ cb();
+
+ tokio::time::sleep(Duration::from_secs(1)).await;
+
+ // Run queued actions
+ let queue = delegate.queue.clone();
+ queue.run_callbacks(&mut delegate);
+
+ // We should see download progress, and cancellation
+ assert_yaml_snapshot!(delegate.state);
+
+ // Wait for download
+ tokio::time::sleep(Duration::from_secs(1)).await;
+
+ let queue = delegate.queue.clone();
+ queue.run_callbacks(&mut delegate);
+
+ // Everything including verification should have succeeded
+ assert_yaml_snapshot!(delegate.state);
+}
+
+/// Test that the install aborts if verification fails
+#[tokio::test(start_paused = true)]
+async fn test_failed_verification() {
+ let mut delegate = FakeAppDelegate::default();
+ AppController::initialize::<_, FakeAppDownloaderVerifyFail, FakeVersionInfoProvider>(
+ &mut delegate,
+ );
+
+ // Wait for the version info
+ tokio::time::sleep(Duration::from_secs(1)).await;
+
+ let queue = delegate.queue.clone();
+ queue.run_callbacks(&mut delegate);
+
+ // Initiate download
+ let cb = delegate
+ .download_callback
+ .take()
+ .expect("no download callback registered");
+ cb();
+
+ tokio::time::sleep(Duration::from_secs(1)).await;
+
+ // Wait for queued actions to complete
+ let queue = delegate.queue.clone();
+ queue.run_callbacks(&mut delegate);
+
+ tokio::time::sleep(Duration::from_secs(1)).await;
+
+ let queue = delegate.queue.clone();
+ queue.run_callbacks(&mut delegate);
+
+ // Verification failed
+ assert_yaml_snapshot!(delegate.state);
+}
diff --git a/installer-downloader/tests/snapshots/controller__download-2.snap b/installer-downloader/tests/snapshots/controller__download-2.snap
new file mode 100644
index 0000000000..e86d9055d7
--- /dev/null
+++ b/installer-downloader/tests/snapshots/controller__download-2.snap
@@ -0,0 +1,31 @@
+---
+source: installer-downloader/tests/controller.rs
+expression: delegate.state
+snapshot_kind: text
+---
+status_text: "Version: 2025.1"
+download_text: ""
+download_button_visible: false
+cancel_button_visible: true
+cancel_button_enabled: true
+download_button_enabled: true
+download_progress: 0
+download_progress_visible: true
+beta_text_visible: false
+call_log:
+ - hide_download_progress
+ - show_download_button
+ - disable_download_button
+ - hide_cancel_button
+ - hide_beta_text
+ - "set_status_text: Loading version details..."
+ - on_download
+ - on_cancel
+ - "set_status_text: Version: 2025.1"
+ - enable_download_button
+ - "set_download_text: "
+ - hide_download_button
+ - hide_beta_text
+ - show_cancel_button
+ - enable_cancel_button
+ - show_download_progress
diff --git a/installer-downloader/tests/snapshots/controller__download-3.snap b/installer-downloader/tests/snapshots/controller__download-3.snap
new file mode 100644
index 0000000000..140e6d6320
--- /dev/null
+++ b/installer-downloader/tests/snapshots/controller__download-3.snap
@@ -0,0 +1,42 @@
+---
+source: installer-downloader/tests/controller.rs
+expression: delegate.state
+snapshot_kind: text
+---
+status_text: "Version: 2025.1"
+download_text: Verification successful. Starting install...
+download_button_visible: false
+cancel_button_visible: true
+cancel_button_enabled: false
+download_button_enabled: true
+download_progress: 100
+download_progress_visible: true
+beta_text_visible: false
+call_log:
+ - hide_download_progress
+ - show_download_button
+ - disable_download_button
+ - hide_cancel_button
+ - hide_beta_text
+ - "set_status_text: Loading version details..."
+ - on_download
+ - on_cancel
+ - "set_status_text: Version: 2025.1"
+ - enable_download_button
+ - "set_download_text: "
+ - hide_download_button
+ - hide_beta_text
+ - show_cancel_button
+ - enable_cancel_button
+ - show_download_progress
+ - "set_download_progress: 0"
+ - "set_download_text: Downloading from mullvad.net... (0%)"
+ - "set_download_progress: 100"
+ - "set_download_text: Downloading from mullvad.net... (100%)"
+ - "set_download_progress: 0"
+ - "set_download_text: Downloading from mullvad.net... (0%)"
+ - "set_download_progress: 100"
+ - "set_download_text: Downloading from mullvad.net... (100%)"
+ - "set_download_text: Download complete. Verifying..."
+ - disable_cancel_button
+ - "set_download_text: Verification successful. Starting install..."
diff --git a/installer-downloader/tests/snapshots/controller__download.snap b/installer-downloader/tests/snapshots/controller__download.snap
new file mode 100644
index 0000000000..45bd1796ad
--- /dev/null
+++ b/installer-downloader/tests/snapshots/controller__download.snap
@@ -0,0 +1,25 @@
+---
+source: installer-downloader/tests/controller.rs
+expression: delegate.state
+snapshot_kind: text
+---
+status_text: "Version: 2025.1"
+download_text: ""
+download_button_visible: true
+cancel_button_visible: false
+cancel_button_enabled: false
+download_button_enabled: true
+download_progress: 0
+download_progress_visible: false
+beta_text_visible: false
+call_log:
+ - hide_download_progress
+ - show_download_button
+ - disable_download_button
+ - hide_cancel_button
+ - hide_beta_text
+ - "set_status_text: Loading version details..."
+ - on_download
+ - on_cancel
+ - "set_status_text: Version: 2025.1"
+ - enable_download_button
diff --git a/installer-downloader/tests/snapshots/controller__failed_verification.snap b/installer-downloader/tests/snapshots/controller__failed_verification.snap
new file mode 100644
index 0000000000..bb92678de9
--- /dev/null
+++ b/installer-downloader/tests/snapshots/controller__failed_verification.snap
@@ -0,0 +1,42 @@
+---
+source: installer-downloader/tests/controller.rs
+expression: delegate.state
+snapshot_kind: text
+---
+status_text: "Version: 2025.1"
+download_text: "ERROR: Verification failed!"
+download_button_visible: false
+cancel_button_visible: true
+cancel_button_enabled: false
+download_button_enabled: true
+download_progress: 100
+download_progress_visible: true
+beta_text_visible: false
+call_log:
+ - hide_download_progress
+ - show_download_button
+ - disable_download_button
+ - hide_cancel_button
+ - hide_beta_text
+ - "set_status_text: Loading version details..."
+ - on_download
+ - on_cancel
+ - "set_status_text: Version: 2025.1"
+ - enable_download_button
+ - "set_download_text: "
+ - hide_download_button
+ - hide_beta_text
+ - show_cancel_button
+ - enable_cancel_button
+ - show_download_progress
+ - "set_download_progress: 0"
+ - "set_download_text: Downloading from mullvad.net... (0%)"
+ - "set_download_progress: 100"
+ - "set_download_text: Downloading from mullvad.net... (100%)"
+ - "set_download_progress: 0"
+ - "set_download_text: Downloading from mullvad.net... (0%)"
+ - "set_download_progress: 100"
+ - "set_download_text: Downloading from mullvad.net... (100%)"
+ - "set_download_text: Download complete. Verifying..."
+ - disable_cancel_button
+ - "set_download_text: ERROR: Verification failed!"
diff --git a/installer-downloader/tests/snapshots/controller__fetch_version-2.snap b/installer-downloader/tests/snapshots/controller__fetch_version-2.snap
new file mode 100644
index 0000000000..45bd1796ad
--- /dev/null
+++ b/installer-downloader/tests/snapshots/controller__fetch_version-2.snap
@@ -0,0 +1,25 @@
+---
+source: installer-downloader/tests/controller.rs
+expression: delegate.state
+snapshot_kind: text
+---
+status_text: "Version: 2025.1"
+download_text: ""
+download_button_visible: true
+cancel_button_visible: false
+cancel_button_enabled: false
+download_button_enabled: true
+download_progress: 0
+download_progress_visible: false
+beta_text_visible: false
+call_log:
+ - hide_download_progress
+ - show_download_button
+ - disable_download_button
+ - hide_cancel_button
+ - hide_beta_text
+ - "set_status_text: Loading version details..."
+ - on_download
+ - on_cancel
+ - "set_status_text: Version: 2025.1"
+ - enable_download_button
diff --git a/installer-downloader/tests/snapshots/controller__fetch_version.snap b/installer-downloader/tests/snapshots/controller__fetch_version.snap
new file mode 100644
index 0000000000..a8c1896189
--- /dev/null
+++ b/installer-downloader/tests/snapshots/controller__fetch_version.snap
@@ -0,0 +1,23 @@
+---
+source: installer-downloader/tests/controller.rs
+expression: delegate.state
+snapshot_kind: text
+---
+status_text: Loading version details...
+download_text: ""
+download_button_visible: true
+cancel_button_visible: false
+cancel_button_enabled: false
+download_button_enabled: false
+download_progress: 0
+download_progress_visible: false
+beta_text_visible: false
+call_log:
+ - hide_download_progress
+ - show_download_button
+ - disable_download_button
+ - hide_cancel_button
+ - hide_beta_text
+ - "set_status_text: Loading version details..."
+ - on_download
+ - on_cancel
diff --git a/mullvad-update/Cargo.toml b/mullvad-update/Cargo.toml
new file mode 100644
index 0000000000..f3a067c3af
--- /dev/null
+++ b/mullvad-update/Cargo.toml
@@ -0,0 +1,31 @@
+[package]
+name = "mullvad-update"
+description = "Support functions for securely installing or updating Mullvad VPN"
+authors.workspace = true
+repository.workspace = true
+license.workspace = true
+edition.workspace = true
+rust-version.workspace = true
+
+[lints]
+workspace = true
+
+[dependencies]
+anyhow = "1.0"
+json-canon = "0.1"
+chrono = { workspace = true, features = ["serde"] }
+ed25519-dalek = { version = "2.1", default-features = false }
+hex = { version = "0.4", default-features = false }
+pgp = "0.14.0"
+reqwest = { version = "0.12.9", features = ["blocking", "json"] }
+serde = { workspace = true, features = ["derive"] }
+serde_json = { workspace = true }
+tokio = { version = "1", features = ["full"] }
+async-trait = "0.1"
+
+mullvad-version = { path = "../mullvad-version", features = ["serde"] }
+
+[dev-dependencies]
+async-tempfile = "0.6"
+mockito = "1.6.1"
+rand = "0.8.5"
diff --git a/mullvad-update/mullvad-code-signing.gpg b/mullvad-update/mullvad-code-signing.gpg
new file mode 100644
index 0000000000..54fa40bc27
--- /dev/null
+++ b/mullvad-update/mullvad-code-signing.gpg
Binary files differ
diff --git a/mullvad-update/src/api.rs b/mullvad-update/src/api.rs
new file mode 100644
index 0000000000..16e578d485
--- /dev/null
+++ b/mullvad-update/src/api.rs
@@ -0,0 +1,58 @@
+//! Fetch information about app versions from the Mullvad API
+
+/// See [module-level](self) docs.
+#[async_trait::async_trait]
+pub trait VersionInfoProvider {
+ /// Return info about the stable version
+ async fn get_version_info() -> anyhow::Result<VersionInfo>;
+}
+
+/// Contains information about all versions
+#[derive(Debug, Clone)]
+pub struct VersionInfo {
+ /// Stable version info
+ pub stable: Version,
+ /// Beta version info
+ pub beta: Option<Version>,
+}
+
+/// Contains information about a version for the current target
+#[derive(Debug, Clone)]
+pub struct Version {
+ /// Version
+ pub version: String,
+ /// URLs to use for downloading the app installer
+ pub urls: Vec<String>,
+ /// Size of installer, in bytes
+ pub size: usize,
+ /// URLs pointing to app PGP signatures
+ pub signature_urls: Vec<String>,
+}
+
+/// Use hardcoded URL to fetch installer
+/// TODO: This is temporary
+pub struct LatestVersionInfoProvider;
+
+#[async_trait::async_trait]
+impl VersionInfoProvider for LatestVersionInfoProvider {
+ async fn get_version_info() -> anyhow::Result<VersionInfo> {
+ Ok(VersionInfo {
+ stable: Version {
+ version: "2025.3".to_string(),
+ urls: vec!["https://mullvad.net/en/download/app/exe/latest".to_owned()],
+ size: 200 * 1024 * 1024,
+ signature_urls: vec![
+ "https://mullvad.net/en/download/app/exe/latest/signature".to_owned()
+ ],
+ },
+ beta: Some(Version {
+ version: "2025.3-beta1".to_string(),
+ urls: vec!["https://mullvad.net/en/download/app/exe/latest-beta".to_owned()],
+ size: 200 * 1024 * 1024,
+ signature_urls: vec![
+ "https://mullvad.net/en/download/app/exe/latest-beta/signature".to_owned(),
+ ],
+ }),
+ })
+ }
+}
diff --git a/mullvad-update/src/app.rs b/mullvad-update/src/app.rs
new file mode 100644
index 0000000000..548fcf1681
--- /dev/null
+++ b/mullvad-update/src/app.rs
@@ -0,0 +1,140 @@
+//! This module implements the flow of downloading and verifying the app signature.
+
+use std::path::PathBuf;
+
+use crate::{
+ fetch::{self, ProgressUpdater},
+ verify::{AppVerifier, PgpVerifier},
+};
+
+#[derive(Debug)]
+pub enum DownloadError {
+ FetchSignature(anyhow::Error),
+ FetchApp(anyhow::Error),
+ Verification(anyhow::Error),
+}
+
+/// Parameters required to construct an [AppDownloader].
+pub struct AppDownloaderParameters<SigProgress, AppProgress> {
+ pub signature_url: String,
+ pub app_url: String,
+ pub app_size: usize,
+ pub sig_progress: SigProgress,
+ pub app_progress: AppProgress,
+}
+
+/// See the [module-level documentation](self).
+#[async_trait::async_trait]
+pub trait AppDownloader: Send {
+ /// Download the app signature.
+ async fn download_signature(&mut self) -> Result<(), DownloadError>;
+
+ /// Download the app binary.
+ async fn download_executable(&mut self) -> Result<(), DownloadError>;
+
+ /// Verify the app signature.
+ async fn verify(&mut self) -> Result<(), DownloadError>;
+}
+
+/// Trait for constructing some [AppDownloader].
+pub trait AppDownloaderFactory: AppDownloader {
+ type Parameters;
+
+ /// Instantiate a new [AppDownloader].
+ fn new_downloader(parameters: Self::Parameters) -> Self;
+}
+
+/// Download the app and signature, and verify the app's signature
+pub async fn install_and_upgrade(mut downloader: impl AppDownloader) -> Result<(), DownloadError> {
+ downloader.download_signature().await?;
+ downloader.download_executable().await?;
+ downloader.verify().await
+}
+
+#[derive(Clone)]
+pub struct HttpAppDownloader<SigProgress, AppProgress> {
+ signature_url: String,
+ app_url: String,
+ app_size: usize,
+ signature_progress_updater: SigProgress,
+ app_progress_updater: AppProgress,
+ // TODO: set permissions
+ tmp_dir: PathBuf,
+}
+
+impl<SigProgress, AppProgress> HttpAppDownloader<SigProgress, AppProgress> {
+ const MAX_SIGNATURE_SIZE: usize = 1024;
+
+ pub fn new(parameters: AppDownloaderParameters<SigProgress, AppProgress>) -> Self {
+ let tmp_dir = std::env::temp_dir();
+ Self {
+ signature_url: parameters.signature_url,
+ app_url: parameters.app_url,
+ app_size: parameters.app_size,
+ signature_progress_updater: parameters.sig_progress,
+ app_progress_updater: parameters.app_progress,
+ tmp_dir,
+ }
+ }
+}
+
+impl<SigProgress: ProgressUpdater, AppProgress: ProgressUpdater> AppDownloaderFactory
+ for HttpAppDownloader<SigProgress, AppProgress>
+{
+ type Parameters = AppDownloaderParameters<SigProgress, AppProgress>;
+
+ fn new_downloader(parameters: Self::Parameters) -> Self
+ where
+ Self: Sized,
+ {
+ HttpAppDownloader::new(parameters)
+ }
+}
+
+#[async_trait::async_trait]
+impl<SigProgress: ProgressUpdater, AppProgress: ProgressUpdater> AppDownloader
+ for HttpAppDownloader<SigProgress, AppProgress>
+{
+ async fn download_signature(&mut self) -> Result<(), DownloadError> {
+ fetch::get_to_file(
+ self.sig_path(),
+ &self.signature_url,
+ &mut self.signature_progress_updater,
+ fetch::SizeHint::Maximum(Self::MAX_SIGNATURE_SIZE),
+ )
+ .await
+ .map_err(DownloadError::FetchSignature)
+ }
+
+ async fn download_executable(&mut self) -> Result<(), DownloadError> {
+ fetch::get_to_file(
+ self.bin_path(),
+ &self.app_url,
+ &mut self.app_progress_updater,
+ // FIXME: use exact size hint
+ fetch::SizeHint::Maximum(self.app_size),
+ )
+ .await
+ .map_err(DownloadError::FetchApp)
+ }
+
+ async fn verify(&mut self) -> Result<(), DownloadError> {
+ let bin_path = self.bin_path();
+ let sig_path = self.sig_path();
+ tokio::task::spawn_blocking(move || {
+ PgpVerifier::verify(bin_path, sig_path).map_err(DownloadError::Verification)
+ })
+ .await
+ .expect("verifier panicked")
+ }
+}
+
+impl<SigProgress, AppProgress> HttpAppDownloader<SigProgress, AppProgress> {
+ fn bin_path(&self) -> PathBuf {
+ self.tmp_dir.join("temp.exe")
+ }
+
+ fn sig_path(&self) -> PathBuf {
+ self.tmp_dir.join("temp.exe.sig")
+ }
+}
diff --git a/mullvad-update/src/deserializer.rs b/mullvad-update/src/deserializer.rs
new file mode 100644
index 0000000000..5ddb4a45d8
--- /dev/null
+++ b/mullvad-update/src/deserializer.rs
@@ -0,0 +1,221 @@
+//! Deserializer for version API response format
+
+use anyhow::Context;
+use serde::Deserialize;
+
+/// JSON response including signature and signed content
+/// This type does not implement [serde::Deserialize] to prevent accidental deserialization without
+/// signature verification.
+pub struct SignedResponse {
+ /// Signature of the canonicalized JSON of `signed`
+ pub signature: ResponseSignature,
+ /// Content signed by `signature`
+ pub signed: Response,
+}
+
+/// Helper class that leaves the signed data untouched
+/// Note that deserializing doesn't verify anything
+#[derive(serde::Deserialize)]
+struct PartialSignedResponse {
+ /// Signature of the canonicalized JSON of `signed`
+ pub signature: ResponseSignature,
+ /// Content signed by `signature`
+ pub signed: serde_json::Value,
+}
+
+impl SignedResponse {
+ /// Deserialize some bytes to JSON, and verify them, including signature and expiry.
+ /// If successful, the deserialized data is returned.
+ pub fn deserialize_and_verify(key: VerifyingKey, bytes: &[u8]) -> Result<Self, anyhow::Error> {
+ Self::deserialize_and_verify_at_time(key, bytes, chrono::Utc::now())
+ }
+
+ /// Deserialize some bytes to JSON, and verify them, including signature and expiry.
+ /// If successful, the deserialized data is returned.
+ fn deserialize_and_verify_at_time(
+ key: VerifyingKey,
+ bytes: &[u8],
+ current_time: chrono::DateTime<chrono::Utc>,
+ ) -> Result<Self, anyhow::Error> {
+ let partial_data: PartialSignedResponse =
+ serde_json::from_slice(bytes).context("Invalid version JSON")?;
+
+ // Check if the key matches
+ if partial_data.signature.keyid.0 != key.0 {
+ anyhow::bail!("Unrecognized key");
+ }
+
+ // Serialize to canonical json format
+ let canon_data = json_canon::to_vec(&partial_data.signed)
+ .context("Failed to serialize to canonical JSON")?;
+
+ // Check if the data is signed by our key
+ partial_data
+ .signature
+ .keyid
+ .0
+ .verify_strict(&canon_data, &partial_data.signature.sig.0)
+ .context("Signature verification failed")?;
+
+ // Deserialize the canonical JSON to structured representation
+ let signed_response: Response =
+ serde_json::from_slice(&canon_data).context("Failed to deserialize response")?;
+
+ // Reject time if the data has expired
+ if current_time >= signed_response.expires {
+ anyhow::bail!(
+ "Version metadata has expired: valid until {}",
+ signed_response.expires
+ );
+ }
+
+ Ok(SignedResponse {
+ signature: partial_data.signature,
+ signed: signed_response,
+ })
+ }
+}
+
+/// JSON response signature
+#[derive(Deserialize)]
+pub struct ResponseSignature {
+ pub keyid: VerifyingKey,
+ pub sig: Signature,
+}
+
+/// ed25519 verifying key
+pub struct VerifyingKey(pub ed25519_dalek::VerifyingKey);
+
+impl<'de> Deserialize<'de> for VerifyingKey {
+ fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
+ where
+ D: serde::Deserializer<'de>,
+ {
+ let bytes = String::deserialize(deserializer).and_then(|string| {
+ bytes_from_hex::<D, { ed25519_dalek::PUBLIC_KEY_LENGTH }>(&string)
+ })?;
+ let key = ed25519_dalek::VerifyingKey::from_bytes(&bytes).map_err(|_err| {
+ serde::de::Error::invalid_value(
+ serde::de::Unexpected::Other("invalid verifying key"),
+ &"valid ed25519 key",
+ )
+ })?;
+ Ok(VerifyingKey(key))
+ }
+}
+
+/// ed25519 signature
+pub struct Signature(pub ed25519_dalek::Signature);
+
+impl<'de> Deserialize<'de> for Signature {
+ fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
+ where
+ D: serde::Deserializer<'de>,
+ {
+ let bytes = String::deserialize(deserializer)
+ .and_then(|string| bytes_from_hex::<D, { ed25519_dalek::SIGNATURE_LENGTH }>(&string))?;
+ Ok(Signature(ed25519_dalek::Signature::from_bytes(&bytes)))
+ }
+}
+
+/// Deserialize a hex-encoded string to a bytes array of an exact size
+fn bytes_from_hex<'de, D, const SIZE: usize>(key: &str) -> Result<[u8; SIZE], D::Error>
+where
+ D: serde::Deserializer<'de>,
+{
+ let bytes = hex::decode(key).map_err(|_err| {
+ serde::de::Error::invalid_value(
+ serde::de::Unexpected::Other("hex-encoded string"),
+ &"valid hex",
+ )
+ })?;
+ if bytes.len() != SIZE {
+ let expected = format!("hex-encoded string of {SIZE} bytes");
+ return Err(serde::de::Error::invalid_length(
+ bytes.len(),
+ &expected.as_str(),
+ ));
+ }
+ let mut key = [0u8; SIZE];
+ key.copy_from_slice(&bytes);
+ Ok(key)
+}
+
+/// Signed JSON response, not including the signature
+#[derive(Deserialize)]
+pub struct Response {
+ /// When the signature expires
+ pub expires: chrono::DateTime<chrono::Utc>,
+ /// Stable version response
+ pub stable: VersionResponse,
+ /// Beta version response
+ pub beta: Option<VersionResponse>,
+}
+
+#[derive(Deserialize)]
+pub struct VersionResponse {
+ /// The current version in this channel
+ pub current: SpecificVersionResponse,
+ /// The version being rolled out in this channel
+ pub next: Option<NextSpecificVersionResponse>,
+}
+
+#[derive(Deserialize)]
+pub struct NextSpecificVersionResponse {
+ /// The percentage of users that should receive the new version.
+ pub rollout: f32,
+ #[serde(flatten)]
+ pub version: SpecificVersionResponse,
+}
+
+#[derive(Deserialize)]
+pub struct SpecificVersionResponse {
+ /// Mullvad app version
+ pub version: mullvad_version::Version,
+ /// Changelog entries
+ pub changelog: String,
+ /// Installer details for different architectures
+ pub installers: SpecificVersionArchitectureResponses,
+}
+
+/// Version details for supported architectures
+#[derive(Deserialize)]
+pub struct SpecificVersionArchitectureResponses {
+ /// Details for x86 installer
+ pub x86: Option<SpecificVersionArchitectureResponse>,
+ /// Details for ARM64 installer
+ pub arm64: Option<SpecificVersionArchitectureResponse>,
+}
+
+#[derive(Deserialize)]
+pub struct SpecificVersionArchitectureResponse {
+ /// Mirrors that host the artifact
+ pub urls: Vec<String>,
+ /// Size of the installer, in bytes
+ pub size: usize,
+ /// TODO: hash of the installer, in bytes
+ pub sha256: String,
+}
+
+#[cfg(test)]
+mod test {
+ use super::*;
+
+ /// Test that a valid signed version response is successfully deserialized and verified
+ #[test]
+ fn test_response_deserialization_and_verification() {
+ const TEST_PUBKEY: &str =
+ "AEC24A08466F3D6A1EDCDB2AD3C234428AB9D991B6BEA7F53CB9F172E6CB40D8";
+ let pubkey = hex::decode(TEST_PUBKEY).unwrap();
+ let verifying_key =
+ ed25519_dalek::VerifyingKey::from_bytes(&pubkey.try_into().unwrap()).unwrap();
+
+ SignedResponse::deserialize_and_verify_at_time(
+ VerifyingKey(verifying_key),
+ include_bytes!("../test-version-response.json"),
+ // It's 1970 again
+ chrono::DateTime::UNIX_EPOCH,
+ )
+ .expect("expected valid signed version metadata");
+ }
+}
diff --git a/mullvad-update/src/fetch.rs b/mullvad-update/src/fetch.rs
new file mode 100644
index 0000000000..1093915a7a
--- /dev/null
+++ b/mullvad-update/src/fetch.rs
@@ -0,0 +1,496 @@
+//! A downloader that supports range requests and resuming downloads
+
+use std::{
+ path::Path,
+ pin::Pin,
+ task::{ready, Poll},
+};
+
+use reqwest::header::{HeaderValue, CONTENT_LENGTH, RANGE};
+use tokio::{
+ fs::{self, File},
+ io::{self, AsyncSeek, AsyncSeekExt, AsyncWrite, AsyncWriteExt, BufWriter},
+};
+
+use anyhow::Context;
+
+/// Receiver of the current progress so far
+pub trait ProgressUpdater: Send + 'static {
+ /// Progress so far
+ fn set_progress(&mut self, fraction_complete: f32);
+
+ /// URL that is being downloaded
+ fn set_url(&mut self, url: &str);
+}
+
+// TODO: save file to protected dir so it cannot be tampered with after verification
+
+/// This describes how to handle files that do not match an expected size
+#[derive(Debug, Clone, Copy)]
+pub enum SizeHint {
+ /// Fail if the resulting file does not exactly match the expected size.
+ Exact(usize),
+ /// Fail if the resulting file is larger than the specified limit.
+ Maximum(usize),
+}
+
+/// Download `url` to `file`. If the file already exists, this appends to it, as long
+/// as the file pointed to by `url` is larger than it.
+///
+/// # Arguments
+/// - `progress_updater` - This interface is notified of download progress.
+/// - `size_hint` - File size restrictions.
+pub async fn get_to_file(
+ file: impl AsRef<Path>,
+ url: &str,
+ progress_updater: &mut impl ProgressUpdater,
+ size_hint: SizeHint,
+) -> anyhow::Result<()> {
+ let file = create_or_append(file).await?;
+ let file = BufWriter::new(file);
+ get_to_writer(file, url, progress_updater, size_hint).await
+}
+
+/// Download `url` to `writer`.
+///
+/// # Arguments
+/// - `progress_updater` - This interface is notified of download progress.
+/// - `size_hint` - File size restrictions.
+pub async fn get_to_writer(
+ mut writer: impl AsyncWrite + AsyncSeek + Unpin,
+ url: &str,
+ progress_updater: &mut impl ProgressUpdater,
+ size_hint: SizeHint,
+) -> anyhow::Result<()> {
+ let client = reqwest::Client::new();
+
+ progress_updater.set_url(url);
+ progress_updater.set_progress(0.);
+
+ // Fetch content length first
+ let response = client.head(url).send().await.context("HEAD failed")?;
+ if !response.status().is_success() {
+ return response
+ .error_for_status()
+ .map(|_| ())
+ .context("Download failed");
+ }
+
+ let total_size = response
+ .headers()
+ .get(CONTENT_LENGTH)
+ .context("Missing file size")?;
+ let total_size: usize = total_size.to_str()?.parse().context("invalid size")?;
+ check_size_hint(size_hint, total_size)?;
+
+ let already_fetched_bytes = writer
+ .stream_position()
+ .await
+ .context("failed to get existing file size")?
+ .try_into()
+ .context("invalid size")?;
+
+ if total_size == already_fetched_bytes {
+ progress_updater.set_progress(1.);
+ return Ok(());
+ }
+ if already_fetched_bytes > total_size {
+ anyhow::bail!("Found existing file that was larger");
+ }
+
+ // Fetch content, one range at a time
+ let mut writer = WriterWithProgress {
+ writer,
+ progress_updater,
+ written_nbytes: already_fetched_bytes,
+ total_nbytes: total_size,
+ };
+
+ for range in RangeIter::new(already_fetched_bytes, total_size) {
+ let mut response = client
+ .get(url)
+ .header(RANGE, range)
+ .send()
+ .await
+ .context("Failed to retrieve range")?;
+ let status = response.status();
+ if !status.is_success() {
+ return response
+ .error_for_status()
+ .map(|_| ())
+ .context("Download failed");
+ }
+
+ let mut bytes_read = 0;
+
+ while let Some(chunk) = response.chunk().await.context("Failed to read chunk")? {
+ bytes_read += chunk.len();
+ if bytes_read > RangeIter::CHUNK_SIZE {
+ // Protect against servers responding with more data than expected
+ anyhow::bail!("Server returned more than chunk-sized bytes");
+ }
+
+ writer
+ .write_all(&chunk)
+ .await
+ .context("Failed to write chunk")?;
+ }
+ }
+
+ writer.shutdown().await.context("Failed to flush")?;
+
+ Ok(())
+}
+
+/// This function succeeds if `actual` is allowed according to the [SizeHint]. Otherwise, it
+/// returns an error.
+fn check_size_hint(hint: SizeHint, actual: usize) -> anyhow::Result<()> {
+ match hint {
+ SizeHint::Exact(expected) if actual != expected => {
+ anyhow::bail!("File size mismatch: expected {expected} bytes, served {actual}")
+ }
+ SizeHint::Maximum(limit) if actual > limit => {
+ anyhow::bail!(
+ "File size exceeds limit: expected at most {limit} bytes, served {actual}"
+ )
+ }
+ _ => Ok(()),
+ }
+}
+
+/// If a file exists, append to it. Otherwise, create a new file
+async fn create_or_append(path: impl AsRef<Path>) -> io::Result<File> {
+ match fs::File::create_new(&path).await {
+ // New file created
+ Ok(file) => Ok(file),
+ // Append to an existing file
+ Err(_err) => {
+ let mut file = fs::OpenOptions::new().append(true).open(path).await?;
+ // Seek to end, or else the seek position might be wrong
+ file.seek(io::SeekFrom::End(0)).await?;
+ Ok(file)
+ }
+ }
+}
+
+/// Used to download partial content
+struct RangeIter {
+ current: usize,
+ end: usize,
+}
+
+impl RangeIter {
+ /// Number of bytes to read per range request
+ pub const CHUNK_SIZE: usize = 512 * 1024;
+
+ fn new(current: usize, end: usize) -> Self {
+ Self { current, end }
+ }
+}
+
+impl Iterator for RangeIter {
+ type Item = HeaderValue;
+
+ fn next(&mut self) -> Option<Self::Item> {
+ if self.current > self.end {
+ return None;
+ }
+ let prev = self.current;
+
+ let read_n = self.end.saturating_sub(self.current).min(Self::CHUNK_SIZE);
+ if read_n == 0 {
+ return None;
+ }
+
+ self.current += read_n;
+
+ // NOTE: Subtracting 1 because range includes final byte
+ let end = self.current - 1;
+
+ Some(HeaderValue::from_str(&format!("bytes={prev}-{end}")).expect("valid range/str"))
+ }
+}
+
+struct WriterWithProgress<'a, PU: ProgressUpdater, Writer> {
+ writer: Writer,
+ progress_updater: &'a mut PU,
+ written_nbytes: usize,
+ /// Actual or estimated total number of bytes
+ total_nbytes: usize,
+}
+
+impl<PU: ProgressUpdater, Writer: AsyncWrite + Unpin> AsyncWrite
+ for WriterWithProgress<'_, PU, Writer>
+{
+ fn poll_write(
+ mut self: Pin<&mut Self>,
+ cx: &mut std::task::Context<'_>,
+ buf: &[u8],
+ ) -> Poll<Result<usize, io::Error>> {
+ let file = Pin::new(&mut self.writer);
+ let nbytes = ready!(file.poll_write(cx, buf))?;
+
+ let total_nbytes = self.total_nbytes;
+ let total_written = self.written_nbytes + nbytes;
+
+ self.written_nbytes = total_written;
+ self.progress_updater
+ .set_progress(total_written as f32 / total_nbytes as f32);
+
+ Poll::Ready(Ok(nbytes))
+ }
+
+ fn poll_flush(
+ mut self: Pin<&mut Self>,
+ cx: &mut std::task::Context<'_>,
+ ) -> Poll<Result<(), std::io::Error>> {
+ Pin::new(&mut self.writer).poll_flush(cx)
+ }
+
+ fn poll_shutdown(
+ mut self: Pin<&mut Self>,
+ cx: &mut std::task::Context<'_>,
+ ) -> Poll<Result<(), std::io::Error>> {
+ Pin::new(&mut self.writer).poll_shutdown(cx)
+ }
+}
+
+#[cfg(test)]
+mod test {
+ use std::io::Cursor;
+
+ use async_tempfile::TempDir;
+ use rand::RngCore;
+ use tokio::{fs, io::AsyncWriteExt};
+
+ use super::*;
+
+ #[tokio::test]
+ async fn test_create_or_append() -> anyhow::Result<()> {
+ let temp_dir = TempDir::new().await?;
+ let file_path = temp_dir.join("test");
+
+ // Write to a new file
+ const CONTENT: &[u8] = b"very important file";
+
+ let mut file = create_or_append(&file_path).await?;
+ file.write_all(CONTENT).await?;
+ file.flush().await?;
+ drop(file);
+
+ assert_eq!(fs::read(&file_path).await?, CONTENT);
+
+ // Verify that we can trust the stream position
+ let mut file = create_or_append(&file_path).await?;
+ let content_len: u64 = CONTENT.len().try_into()?;
+ assert_eq!(file.stream_position().await?, content_len);
+ drop(file);
+
+ // Append some more stuff
+ const EXTRA: &[u8] = b"my addition";
+
+ let mut file = create_or_append(&file_path).await?;
+ file.write_all(EXTRA).await?;
+ file.flush().await?;
+ drop(file);
+
+ // Append occurred correctly
+ const COMPLETE_STRING: &[u8] = b"very important filemy addition";
+ assert_eq!(fs::read(file_path).await?, COMPLETE_STRING);
+
+ Ok(())
+ }
+
+ #[derive(Default)]
+ struct FakeProgressUpdater {
+ complete: f32,
+ url: String,
+ }
+
+ impl ProgressUpdater for FakeProgressUpdater {
+ fn set_progress(&mut self, fraction_complete: f32) {
+ self.complete = fraction_complete;
+ }
+
+ fn set_url(&mut self, url: &str) {
+ self.url = url.to_owned();
+ }
+ }
+
+ /// Test that [get_to_writer] correctly downloads new files
+ #[tokio::test]
+ async fn test_fetch_complete() -> anyhow::Result<()> {
+ // Generate random data
+ let file_data = Box::leak(Box::new(vec![0u8; 1024 * 1024 + 1]));
+ rand::thread_rng().fill_bytes(file_data);
+
+ // Start server
+ let mut server = mockito::Server::new_async().await;
+ let file_url = format!("{}/my_file", server.url());
+ add_file_server_mock(&mut server, "/my_file", file_data);
+
+ // Download the file to `writer` and compare it to `file_data`
+ let mut writer = Cursor::new(vec![]);
+ let mut progress_updater = FakeProgressUpdater::default();
+
+ get_to_writer(
+ &mut writer,
+ &file_url,
+ &mut progress_updater,
+ SizeHint::Exact(file_data.len()),
+ )
+ .await
+ .context("Complete download failed")?;
+
+ assert_eq!(progress_updater.url, file_url);
+ assert_eq!(progress_updater.complete, 1.);
+ assert_eq!(&mut writer.into_inner(), file_data);
+
+ Ok(())
+ }
+
+ /// Test that [get_to_writer] correctly downloads partial files
+ #[tokio::test]
+ async fn test_fetch_interrupted() -> anyhow::Result<()> {
+ // Generate random data
+ let file_data = Box::leak(Box::new(vec![0u8; 1024 * 1024]));
+ rand::thread_rng().fill_bytes(file_data);
+
+ // Start server
+ let mut server = mockito::Server::new_async().await;
+ let file_url = format!("{}/my_file", server.url());
+ add_file_server_mock(&mut server, "/my_file", file_data);
+
+ // Interrupt after exactly half the file has been downloaded
+ let mut limited_buffer = vec![0u8; file_data.len() / 2].into_boxed_slice();
+ let mut writer = Cursor::new(&mut limited_buffer[..]);
+
+ let mut progress_updater = FakeProgressUpdater::default();
+
+ get_to_writer(
+ &mut writer,
+ &file_url,
+ &mut progress_updater,
+ SizeHint::Exact(file_data.len()),
+ )
+ .await
+ .expect_err("Expected interrupted download");
+
+ assert_eq!(progress_updater.url, file_url);
+
+ let completed = progress_updater.complete;
+ assert!(
+ (completed - 0.5).abs() < f32::EPSILON,
+ "expected half to be completed, got {completed}"
+ );
+
+ assert_eq!(
+ &*limited_buffer,
+ &file_data[..limited_buffer.len()],
+ "partial download incorrect"
+ );
+
+ // Download the remainder
+ let writer = limited_buffer.into_vec();
+ let partial_len = writer.len();
+ let mut writer = Cursor::new(writer);
+ writer.set_position(partial_len as u64);
+
+ let mut progress_updater = FakeProgressUpdater::default();
+
+ get_to_writer(
+ &mut writer,
+ &file_url,
+ &mut progress_updater,
+ SizeHint::Exact(file_data.len()),
+ )
+ .await
+ .context("Partial download failed")?;
+
+ assert_eq!(progress_updater.url, file_url);
+ assert_eq!(progress_updater.complete, 1.);
+ assert_eq!(&mut writer.into_inner(), file_data);
+
+ Ok(())
+ }
+
+ /// Create endpoints that serve a file at `url_path` using range requests
+ fn add_file_server_mock(server: &mut mockito::Server, url_path: &str, data: &'static [u8]) {
+ // Respond to head requests with file size
+ server
+ .mock("HEAD", url_path)
+ .with_header(CONTENT_LENGTH, &data.len().to_string())
+ .create();
+
+ // Respond to range requests with file
+ server
+ .mock("GET", url_path)
+ .with_body_from_request(|request| {
+ let range = request.header(RANGE);
+ let range = range[0].to_str().expect("expected str");
+ let (begin, end) = parse_http_range(range).expect("invalid range");
+
+ data[begin..=end].to_vec()
+ })
+ .create();
+ }
+
+ /// Parse a range header value, e.g. "bytes=0-31"
+ fn parse_http_range(val: &str) -> anyhow::Result<(usize, usize)> {
+ // parse: bytes=0-31
+ let (_, val) = val.split_once('=').context("invalid range header")?;
+ let (begin, end) = val.split_once('-').context("invalid range")?;
+
+ let begin: usize = begin.parse().context("invalid range begin")?;
+ let end: usize = end.parse().context("invalid range end")?;
+
+ Ok((begin, end))
+ }
+
+ /// Make sure unexpectedly large files are rejected
+ #[tokio::test]
+ async fn test_nefarious_sizes() -> anyhow::Result<()> {
+ // Head length is too large
+ let mut server = mockito::Server::new_async().await;
+ let file_url = format!("{}/my_file", server.url());
+ server
+ .mock("HEAD", "/my_file")
+ .with_header(CONTENT_LENGTH, "2")
+ .create();
+
+ get_to_writer(
+ Cursor::new(vec![]),
+ &file_url,
+ &mut FakeProgressUpdater::default(),
+ SizeHint::Exact(1),
+ )
+ .await
+ .expect_err("Reject unexpected content length");
+
+ // Malicious range response
+ // Serve the entire file rather than the requested range
+ let file_data = vec![0u8; 2 * RangeIter::CHUNK_SIZE];
+
+ let mut server = mockito::Server::new_async().await;
+ let file_url = format!("{}/my_file", server.url());
+ server
+ .mock("HEAD", "/my_file")
+ .with_header(CONTENT_LENGTH, &file_data.len().to_string())
+ .create();
+ server
+ .mock("GET", "/my_file")
+ .with_body(&file_data)
+ .create();
+
+ get_to_writer(
+ Cursor::new(vec![]),
+ &file_url,
+ &mut FakeProgressUpdater::default(),
+ SizeHint::Exact(file_data.len()),
+ )
+ .await
+ .expect_err("Reject unexpected chunk sizes");
+
+ Ok(())
+ }
+}
diff --git a/mullvad-update/src/lib.rs b/mullvad-update/src/lib.rs
new file mode 100644
index 0000000000..08069710e0
--- /dev/null
+++ b/mullvad-update/src/lib.rs
@@ -0,0 +1,7 @@
+//! Support functions for securely installing or updating Mullvad VPN
+
+pub mod api;
+pub mod app;
+mod deserializer;
+pub mod fetch;
+pub mod verify;
diff --git a/mullvad-update/src/verify.rs b/mullvad-update/src/verify.rs
new file mode 100644
index 0000000000..74d8790c4b
--- /dev/null
+++ b/mullvad-update/src/verify.rs
@@ -0,0 +1,72 @@
+use std::{fs::File, io::BufReader, path::Path};
+
+use anyhow::Context;
+use pgp::{
+ armor,
+ packet::{Packet, PacketParser},
+ types::PublicKeyTrait,
+ Deserializable, SignedPublicKey,
+};
+
+/// A verifier of digital file signatures
+pub trait AppVerifier: 'static + Clone {
+ /// Verify `bin_path` using the signature at `sig_path`, and return an error if this fails for
+ /// any reason.
+ fn verify(bin_path: impl AsRef<Path>, sig_path: impl AsRef<Path>) -> anyhow::Result<()>;
+}
+
+/// Verification using pgp
+#[derive(Clone)]
+pub struct PgpVerifier;
+
+impl PgpVerifier {
+ const SIGNING_PUBKEY: &[u8] = include_bytes!("../mullvad-code-signing.gpg");
+}
+
+impl AppVerifier for PgpVerifier {
+ fn verify(bin_path: impl AsRef<Path>, sig_path: impl AsRef<Path>) -> anyhow::Result<()> {
+ let pubkey = SignedPublicKey::from_bytes(Self::SIGNING_PUBKEY)?;
+
+ let sig_reader = BufReader::new(File::open(sig_path).context("Open signature file")?);
+ let signature = PacketParser::new(armor::Dearmor::new(sig_reader))
+ .find_map(|packet| {
+ if let Ok(Packet::Signature(sig)) = packet {
+ Some(sig)
+ } else {
+ None
+ }
+ })
+ .context("Missing signature")?;
+ let issuer = signature
+ .issuer()
+ .into_iter()
+ .next()
+ .context("Find issuer key ID")?;
+
+ // Find subkey used for signing
+ let subkey = pubkey
+ .public_subkeys
+ .iter()
+ .find(|subkey| &subkey.key_id() == issuer)
+ .context("Find signing subkey")?;
+ //subkey.verify(&pubkey)?;
+
+ let bin = BufReader::with_capacity(1024 * 1024, File::open(bin_path)?);
+
+ signature
+ .verify(subkey, bin)
+ .context("Verification failed")?;
+
+ Ok(())
+ }
+}
+
+#[cfg(test)]
+mod test {
+ use super::*;
+
+ #[test]
+ fn test_pgp_signing_pubkey() {
+ SignedPublicKey::from_bytes(PgpVerifier::SIGNING_PUBKEY).unwrap();
+ }
+}
diff --git a/mullvad-update/test-version-response.json b/mullvad-update/test-version-response.json
new file mode 100644
index 0000000000..1dfd397706
--- /dev/null
+++ b/mullvad-update/test-version-response.json
@@ -0,0 +1,95 @@
+{
+ "signature": {
+ "keyid": "AEC24A08466F3D6A1EDCDB2AD3C234428AB9D991B6BEA7F53CB9F172E6CB40D8",
+ "sig": "d68ba75006ea3ac249e56849022a7d93603effe26ec0385bac42cf6675fc6e31322cae018a60428d5c670baedd46b59fa2b35a412f1ed285256c64dbafbcb905"
+ },
+ "signed": {
+ "expires": "2025-07-02T15:33:00Z",
+ "stable": {
+ "current": {
+ "version": "2025.1",
+ "changelog": "[macos] Adding support for quicfuscator\n[windows] Less bugs",
+ "installers": {
+ "x86": {
+ "urls": [
+ "https://appcdn.mullvad.net/desktop/2025.1/MullvadVPN-2025.1-x64.exe"
+ ],
+ "size": 123456789,
+ "sha256": "3b2b1cdcfbab2c87e392f5b31e1fed63b1111027dae71bbfaf4bcef2998c18bc"
+ },
+ "arm64": {
+ "urls": [
+ "https://appcdn.mullvad.net/desktop/2025.1/MullvadVPN-2025.1-x64.exe"
+ ],
+ "size": 123456789,
+ "sha256": "3b2b1cdcfbab2c87e392f5b31e1fed63b1111027dae71bbfaf4bcef2998c18bc"
+ }
+ }
+ },
+ "next": {
+ "rollout": 0.3,
+ "version": "2025.1",
+ "changelog": "[macos] Adding support for quicfuscator\n[windows] Less bugs",
+ "installers": {
+ "x86": {
+ "urls": [
+ "https://appcdn.mullvad.net/desktop/2025.1/MullvadVPN-2025.1-x64.exe"
+ ],
+ "size": 123456789,
+ "sha256": "3b2b1cdcfbab2c87e392f5b31e1fed63b1111027dae71bbfaf4bcef2998c18bc"
+ },
+ "arm64": {
+ "urls": [
+ "https://appcdn.mullvad.net/desktop/2025.1/MullvadVPN-2025.1-x64.exe"
+ ],
+ "size": 123456789,
+ "sha256": "3b2b1cdcfbab2c87e392f5b31e1fed63b1111027dae71bbfaf4bcef2998c18bc"
+ }
+ }
+ }
+ },
+ "beta": {
+ "current": {
+ "version": "2025.1-beta1",
+ "changelog": "[macos] Adding support for quicfuscator\n[windows] Less bugs",
+ "installers": {
+ "x86": {
+ "urls": [
+ "https://appcdn.mullvad.net/desktop/2025.1-beta1/MullvadVPN-2025.1-beta1-x64.exe"
+ ],
+ "size": 123456789,
+ "sha256": "3b2b1cdcfbab2c87e392f5b31e1fed63b1111027dae71bbfaf4bcef2998c18bc"
+ },
+ "arm64": {
+ "urls": [
+ "https://appcdn.mullvad.net/desktop/2025.1-beta1/MullvadVPN-2025.1-beta1-x64.exe"
+ ],
+ "size": 123456789,
+ "sha256": "3b2b1cdcfbab2c87e392f5b31e1fed63b1111027dae71bbfaf4bcef2998c18bc"
+ }
+ }
+ },
+ "next": {
+ "rollout": 0.3,
+ "version": "2025.1-beta1",
+ "changelog": "[macos] Adding support for quicfuscator\n[windows] Less bugs",
+ "installers": {
+ "x86": {
+ "urls": [
+ "https://appcdn.mullvad.net/desktop/2025.1-beta1/MullvadVPN-2025.1-beta1-x64.exe"
+ ],
+ "size": 123456789,
+ "sha256": "3b2b1cdcfbab2c87e392f5b31e1fed63b1111027dae71bbfaf4bcef2998c18bc"
+ },
+ "arm64": {
+ "urls": [
+ "https://appcdn.mullvad.net/desktop/2025.1-beta1/MullvadVPN-2025.1-beta1-x64.exe"
+ ],
+ "size": 123456789,
+ "sha256": "3b2b1cdcfbab2c87e392f5b31e1fed63b1111027dae71bbfaf4bcef2998c18bc"
+ }
+ }
+ }
+ }
+ }
+} \ No newline at end of file