From 2b3ca70ff3265c9f9a99341229c2405d3139e57f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20L=C3=B6nnhager?= Date: Fri, 17 Jan 2025 08:56:33 +0100 Subject: [PATCH 001/112] Add initial web installer and upgrades scaffold --- Cargo.lock | 1391 +++++++++++++++-- Cargo.toml | 2 + installer-downloader/Cargo.toml | 40 + installer-downloader/assets/logo-icon.png | Bin 0 -> 944 bytes installer-downloader/assets/logo-icon.svg | 37 + installer-downloader/assets/logo-text.png | Bin 0 -> 1435 bytes installer-downloader/assets/logo-text.svg | 19 + installer-downloader/build.rs | 30 + installer-downloader/convert-assets.py | 8 + installer-downloader/loader.manifest | 37 + .../src/cacao_impl/delegate.rs | 119 ++ installer-downloader/src/cacao_impl/mod.rs | 19 + installer-downloader/src/cacao_impl/ui.rs | 335 ++++ installer-downloader/src/controller.rs | 210 +++ installer-downloader/src/delegate.rs | 72 + installer-downloader/src/lib.rs | 4 + installer-downloader/src/main.rs | 39 + installer-downloader/src/resource.rs | 47 + installer-downloader/src/ui_downloader.rs | 142 ++ .../src/winapi_impl/delegate.rs | 147 ++ installer-downloader/src/winapi_impl/mod.rs | 22 + installer-downloader/src/winapi_impl/ui.rs | 373 +++++ installer-downloader/tests/controller.rs | 366 +++++ .../snapshots/controller__download-2.snap | 31 + .../snapshots/controller__download-3.snap | 42 + .../tests/snapshots/controller__download.snap | 25 + .../controller__failed_verification.snap | 42 + .../controller__fetch_version-2.snap | 25 + .../snapshots/controller__fetch_version.snap | 23 + mullvad-update/Cargo.toml | 31 + mullvad-update/mullvad-code-signing.gpg | Bin 0 -> 3831 bytes mullvad-update/src/api.rs | 58 + mullvad-update/src/app.rs | 140 ++ mullvad-update/src/deserializer.rs | 221 +++ mullvad-update/src/fetch.rs | 496 ++++++ mullvad-update/src/lib.rs | 7 + mullvad-update/src/verify.rs | 72 + mullvad-update/test-version-response.json | 95 ++ 38 files changed, 4661 insertions(+), 106 deletions(-) create mode 100644 installer-downloader/Cargo.toml create mode 100644 installer-downloader/assets/logo-icon.png create mode 100644 installer-downloader/assets/logo-icon.svg create mode 100644 installer-downloader/assets/logo-text.png create mode 100644 installer-downloader/assets/logo-text.svg create mode 100644 installer-downloader/build.rs create mode 100644 installer-downloader/convert-assets.py create mode 100644 installer-downloader/loader.manifest create mode 100644 installer-downloader/src/cacao_impl/delegate.rs create mode 100644 installer-downloader/src/cacao_impl/mod.rs create mode 100644 installer-downloader/src/cacao_impl/ui.rs create mode 100644 installer-downloader/src/controller.rs create mode 100644 installer-downloader/src/delegate.rs create mode 100644 installer-downloader/src/lib.rs create mode 100644 installer-downloader/src/main.rs create mode 100644 installer-downloader/src/resource.rs create mode 100644 installer-downloader/src/ui_downloader.rs create mode 100644 installer-downloader/src/winapi_impl/delegate.rs create mode 100644 installer-downloader/src/winapi_impl/mod.rs create mode 100644 installer-downloader/src/winapi_impl/ui.rs create mode 100644 installer-downloader/tests/controller.rs create mode 100644 installer-downloader/tests/snapshots/controller__download-2.snap create mode 100644 installer-downloader/tests/snapshots/controller__download-3.snap create mode 100644 installer-downloader/tests/snapshots/controller__download.snap create mode 100644 installer-downloader/tests/snapshots/controller__failed_verification.snap create mode 100644 installer-downloader/tests/snapshots/controller__fetch_version-2.snap create mode 100644 installer-downloader/tests/snapshots/controller__fetch_version.snap create mode 100644 mullvad-update/Cargo.toml create mode 100644 mullvad-update/mullvad-code-signing.gpg create mode 100644 mullvad-update/src/api.rs create mode 100644 mullvad-update/src/app.rs create mode 100644 mullvad-update/src/deserializer.rs create mode 100644 mullvad-update/src/fetch.rs create mode 100644 mullvad-update/src/lib.rs create mode 100644 mullvad-update/src/verify.rs create mode 100644 mullvad-update/test-version-response.json diff --git a/Cargo.lock b/Cargo.lock index c02b5d23b37f..9ba618673aa0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -23,6 +23,12 @@ version = "2.0.0" 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" @@ -58,6 +64,15 @@ dependencies = [ "subtle", ] +[[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" @@ -160,6 +175,19 @@ version = "1.7.1" 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" @@ -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", ] @@ -331,6 +368,12 @@ version = "0.6.3" 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" @@ -343,6 +386,15 @@ version = "2.6.0" 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" @@ -356,6 +408,12 @@ dependencies = [ "constant_time_eq", ] +[[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" @@ -365,6 +423,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-padding" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" +dependencies = [ + "generic-array", +] + [[package]] name = "block2" version = "0.5.1" @@ -374,6 +441,35 @@ dependencies = [ "objc2", ] +[[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" @@ -398,6 +494,24 @@ version = "1.10.0" 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" @@ -408,6 +522,15 @@ dependencies = [ "cipher", ] +[[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" @@ -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", @@ -459,6 +582,15 @@ version = "1.1.0" 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" @@ -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", ] @@ -585,6 +717,23 @@ dependencies = [ "zeroize", ] +[[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" @@ -622,6 +771,18 @@ dependencies = [ "memchr", ] +[[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" @@ -650,6 +811,30 @@ version = "0.8.6" 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" @@ -659,6 +844,12 @@ dependencies = [ "libc", ] +[[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" @@ -677,6 +868,25 @@ dependencies = [ "crossbeam-utils", ] +[[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" @@ -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", ] @@ -805,6 +1016,15 @@ version = "2.5.0" 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" @@ -816,6 +1036,16 @@ dependencies = [ "winapi", ] +[[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" @@ -823,6 +1053,7 @@ 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", ] @@ -877,6 +1108,36 @@ dependencies = [ "syn 2.0.89", ] +[[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" @@ -884,6 +1145,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", + "const-oid", "crypto-common", "subtle", ] @@ -909,17 +1171,39 @@ dependencies = [ "windows-sys 0.48.0", ] +[[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" @@ -932,6 +1216,19 @@ dependencies = [ "shared_child", ] +[[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" @@ -939,8 +1236,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" dependencies = [ "der", + "digest", "elliptic-curve", + "rfc6979", "signature", + "spki", ] [[package]] @@ -953,6 +1253,31 @@ dependencies = [ "signature", ] +[[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" @@ -971,12 +1296,30 @@ dependencies = [ "ff", "generic-array", "group", + "hkdf", + "pem-rfc7468", + "pkcs8", "rand_core 0.6.4", "sec1", "subtle", "zeroize", ] +[[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" @@ -984,8 +1327,8 @@ 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", ] @@ -1101,6 +1444,12 @@ dependencies = [ "subtle", ] +[[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" @@ -1141,6 +1490,21 @@ version = "1.0.7" 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" @@ -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", ] @@ -1293,6 +1657,16 @@ dependencies = [ "polyval", ] +[[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" @@ -1641,6 +2015,22 @@ dependencies = [ "tower-service", ] +[[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" @@ -1796,11 +2186,20 @@ 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" @@ -1828,6 +2227,24 @@ dependencies = [ "icu_properties", ] +[[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" @@ -1842,10 +2259,19 @@ dependencies = [ name = "indexmap" version = "2.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "168fb715dda47215e360912c096649d23d58bf392ac62f73919e831745e40f26" +checksum = "168fb715dda47215e360912c096649d23d58bf392ac62f73919e831745e40f26" +dependencies = [ + "equivalent", + "hashbrown 0.14.3", +] + +[[package]] +name = "inflate" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1cdb29978cc5797bd8dcc8e5bf7de604891df2a8dc576973d71a281e916db2ff" dependencies = [ - "equivalent", - "hashbrown 0.14.3", + "adler32", ] [[package]] @@ -1890,6 +2316,38 @@ dependencies = [ "generic-array", ] +[[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" @@ -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", ] @@ -1964,6 +2422,12 @@ dependencies = [ "windows-sys 0.52.0", ] +[[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" @@ -2027,11 +2491,20 @@ 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" @@ -2041,6 +2514,17 @@ dependencies = [ "wasm-bindgen", ] +[[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" @@ -2052,6 +2536,20 @@ dependencies = [ "serde", ] +[[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" @@ -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" @@ -2122,6 +2623,12 @@ dependencies = [ "windows-targets 0.52.6", ] +[[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" @@ -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" @@ -2196,6 +2703,21 @@ version = "0.11.11" 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" @@ -2276,6 +2798,12 @@ version = "0.3.17" 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" @@ -2695,6 +3223,27 @@ dependencies = [ "uuid", ] +[[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" @@ -2709,6 +3258,39 @@ version = "0.10.0" 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" @@ -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", ] @@ -2807,6 +3389,15 @@ dependencies = [ "tokio", ] +[[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" @@ -2885,6 +3476,16 @@ version = "0.6.0" 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" @@ -2913,12 +3514,81 @@ dependencies = [ "objc2-app-kit", ] +[[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" @@ -2926,7 +3596,37 @@ 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]] @@ -3031,6 +3731,15 @@ dependencies = [ "objc2-metal", ] +[[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" @@ -3040,11 +3749,23 @@ dependencies = [ "memchr", ] +[[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" @@ -3052,6 +3773,50 @@ version = "0.3.1" 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" @@ -3069,6 +3834,17 @@ version = "0.2.0" 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" @@ -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]] @@ -3148,6 +3941,17 @@ dependencies = [ "windows-targets 0.48.5", ] +[[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" @@ -3171,6 +3975,15 @@ dependencies = [ "windows-sys 0.36.1", ] +[[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" @@ -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", ] @@ -3244,6 +4057,73 @@ dependencies = [ "libc", ] +[[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" @@ -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", ] @@ -3314,6 +4194,17 @@ version = "0.1.0" 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" @@ -3330,6 +4221,24 @@ version = "0.3.30" 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" @@ -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", ] @@ -3414,6 +4323,18 @@ dependencies = [ "pnet_macros_support 0.35.0", ] +[[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" @@ -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", ] @@ -3478,6 +4399,24 @@ dependencies = [ "elliptic-curve", ] +[[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" @@ -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", ] @@ -3660,13 +4599,22 @@ dependencies = [ "windows-sys 0.59.0", ] +[[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]] @@ -3759,6 +4707,26 @@ dependencies = [ "rand_core 0.6.4", ] +[[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" @@ -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", @@ -3866,6 +4841,16 @@ dependencies = [ "quick-error", ] +[[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" @@ -3876,7 +4861,7 @@ dependencies = [ "cfg-if", "getrandom 0.2.14", "libc", - "spin", + "spin 0.9.8", "untrusted", "windows-sys 0.52.0", ] @@ -3900,12 +4885,41 @@ dependencies = [ "signature", ] +[[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" @@ -4055,6 +5069,12 @@ version = "1.0.17" 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" @@ -4064,6 +5084,21 @@ dependencies = [ "winapi-util", ] +[[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" @@ -4089,10 +5124,34 @@ 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" @@ -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", ] @@ -4179,6 +5238,17 @@ dependencies = [ "digest", ] +[[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" @@ -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", ] @@ -4391,6 +5462,12 @@ dependencies = [ "windows-sys 0.52.0", ] +[[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" @@ -4416,6 +5493,16 @@ version = "1.2.0" 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" @@ -4444,14 +5531,25 @@ dependencies = [ "tracing", ] +[[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]] @@ -4524,6 +5633,16 @@ dependencies = [ "libc", ] +[[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" @@ -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,11 +6069,21 @@ 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" @@ -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", ] @@ -5259,6 +6388,15 @@ dependencies = [ "udp-over-tcp", ] +[[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" @@ -5296,6 +6434,24 @@ version = "1.0.12" 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" @@ -5352,6 +6508,12 @@ dependencies = [ "serde", ] +[[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" @@ -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", @@ -5536,6 +6698,12 @@ dependencies = [ "winapi-x86_64-pc-windows-gnu", ] +[[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" @@ -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", ] @@ -6136,6 +7304,17 @@ dependencies = [ "zeroize", ] +[[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" @@ -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 6214b68134d6..17e39209610c 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 000000000000..25799200d3c6 --- /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 0000000000000000000000000000000000000000..d80eedadc2880c5270ad916ebaf63b0936c15e3b GIT binary patch literal 944 zcmV;h15f;kP)K~z|U?Urw7Rb?E0B5gG%J=M zr0I)fM3GRwu&6;GgTQ*#3tO2+5Cl>blGJ=P2O+wWJFQ(B&TYCsQo3|5z1zGy_ddT~ zG%a`CIp=OhFY^29!1H~+-w)?J=XoBur~kWTt^wLrycAr7 z!QY|L2Up&80PQR-jtAGSuB+QjX6QaA>tZZli}sXcdj^Q?uL@{WDwO_GT%=uP1=?M@ z5TB{^2Ny9s{40LD(~WltadvnH@W(S&{G4A?aN_-%&~2_sa}Bi{NSH3*o5H|hP8`|J z^R?&P06=)$d3Z`1*|U2*Hd5CYZI5B;I4b!-#*{4%+8+!h_LZ&~c?3)&NP&AhfF6^I zzI8K={=I;N`eZU$gph|$|2Y(3ZG6JNE!vjfa{sc}(+dj64uDp>1IVD$=&esMG|<;D zc=lAxJx`w*I-B)v@5%5^;GhS9qn-dpFLsY7jr|rd<{qj`CdT}&(YCx+U^6f}gYgpC z6JIzb`EEY!YZ<)y`i8|95|%Xz)VV{Dfyl<;fh^|DpL}^=zCH!(B>49 zvJ}7ySOZ(O+f0FDAad;^jPk!0RWc2c>H?h<>Q+K_As>>Or>8yc}G>>RFxe- z>Y<~|cPod~lPpEjgo!p21Nv0x5Y>Kxq!H=`S>-S_cvk=b)RK=e`i3Z5Ku|ngBo<1- zgn_G0Ca=eFF5r0f-a#St$+K=ISAfX=ssP3uz~d{uP0H4uy-zvLa|Ot-gUul8(=Pu@ z)iGhyK8i>#kOTa#NIyb{Kc4)qVpsb}I=T1spXyKkp(r{X ShIL2)0000 + + + + + + + + + + + + + + + + + diff --git a/installer-downloader/assets/logo-text.png b/installer-downloader/assets/logo-text.png new file mode 100644 index 0000000000000000000000000000000000000000..993083c7b1c847f0beee0425b81d874ea6c6e60f GIT binary patch literal 1435 zcmV;M1!Ve(P)wT8c6$v z;hs6?J2U6b+&OdRBB@pxc1fxx0ZCsur%qCP*L9I}#`PaWd<**B6zC_az?9Tj0e5D= zy+2_$NeA72KvK)dewm~bZr_+_zc}dssS+P1=v>rE+AnFDq&q9T!-AmQg9-Z@x1Eu6 zmFs>>jCoqpUP-S@>KOHL$~pTaT^!kFxvjPe$S0^5Ff!rHKwzL(TX@0SSs=$~nXt7g zkyB_{A_HJ%0#YrC6bpb|{blM!S9;k8ote?t`pdnuU#?1Cb?Z*4FCeS@vVXReV zHp9%O0k;C%UEc%fmug^U^%T4M@`yJqkW&kN)XWxonH}-(EAg=nFgs<47aCw%AWtP7 zN}2`C>@@J9S9?gh!R5PwOI&U+iIO@4_j>g`V5`gHX(G(*6tE&tUE2u#oTwwofQMZ7 zt(omY|RnjL=N|7W4mP>#ksbEV!`@I zy7@fDJrv088sU9T5jk@IZX-MAQ~u?smekQpFW{}f_I-l?F0h+yP2cp7=`6y9UIP3< zHVIFYB`B8yM}ftSyvYBJPsz?RD9-6TLE36&dn5h?e;xof14{$jxbqnIR3KkeM)Ih$ z#;Z3S-7tzGuu#Mtm(iRvg_Dm zxw1hrW@dkqh437!%$n6Cx81{tWNmxJGN9Rht%Nq;4N+2vRl{v?nzxe;!TY0jNh zktv0D=P{A(`tZ#q%?oUUi6H5Xz&^xvPgEHHxDvl-Q;^hJ(vhI;tP)=STld!{_FM1# zIg$RA(9tTgYz^RaPBOEdW)`OKIj7!B&f5$0C(A0s9Z&s-!S z;BS|=nc0uIL0N(|)~g4A4T-*%kezLi?FPus-ouHq6L=pOU}o!^;3sBwlHylvJ+Q2V pddSD9DY3^mXZ;*=f&AF5{TFA + + + + + + + + + + + + + + + + diff --git a/installer-downloader/build.rs b/installer-downloader/build.rs new file mode 100644 index 000000000000..475a86499995 --- /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 000000000000..ede39abe21a6 --- /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 000000000000..feedbf9bc0a8 --- /dev/null +++ b/installer-downloader/loader.manifest @@ -0,0 +1,37 @@ + + + + + + + + + + +Web installer + + + + + + + + + + + + diff --git a/installer-downloader/src/cacao_impl/delegate.rs b/installer-downloader/src/cacao_impl/delegate.rs new file mode 100644 index 000000000000..80d080932bb1 --- /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(&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::::dispatch_main(cb); + }); + } + + fn on_cancel(&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::::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>> { + 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 for Queue { + fn queue_main(&self, callback: F) { + // NOTE: We need this horrible lock because Dispatcher demands Sync + let cb: Mutex>> = + Mutex::new(Some(Box::new(callback))); + cacao::appkit::App::::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 000000000000..89cd3b140265 --- /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>> = + Mutex::new(Some(Box::new(|self_| { + crate::controller::initialize_controller(self_); + }))); + cacao::appkit::App::::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 000000000000..85f7554c05f0 --- /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 = 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 = LazyLock::new(|| Image::with_data(LOGO_IMAGE_DATA)); +static LOGO_TEXT: LazyLock = LazyLock::new(|| Image::with_data(LOGO_TEXT_DATA)); + +pub struct AppImpl { + window: Window, +} + +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>>), + /// User clicked the cancel button + CancelClick(Arc>>), + /// Run callback on main thread + QueueMain(Mutex 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, +} + +#[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 000000000000..10c3519bd375 --- /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(delegate: &mut T) { + use mullvad_update::api::LatestVersionInfoProvider; + use mullvad_update::app::HttpAppDownloader; + + // App downloader (factory) to use + type DownloaderFactory = HttpAppDownloader, UiProgressUpdater>; + // Version info provider to use + type VersionInfoProvider = LatestVersionInfoProvider; + + AppController::initialize::<_, DownloaderFactory, 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: &mut Delegate) + where + Delegate: AppDelegate + 'static, + VersionProvider: VersionInfoProvider + 'static, + DownloaderFactory: + AppDownloaderFactory> + '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.queue(), + task_rx, + )); + delegate.set_status_text(resource::FETCH_VERSION_DESC); + tokio::spawn(fetch_app_version_info::( + delegate, + task_tx.clone(), + )); + Self::register_user_action_callbacks(delegate, task_tx); + } + + fn register_user_action_callbacks( + delegate: &mut T, + task_tx: mpsc::Sender, + ) { + 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: &mut Delegate, + download_tx: mpsc::Sender, +) -> impl Future +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( + queue: Delegate::Queue, + mut rx: mpsc::Receiver, +) where + Delegate: AppDelegate + 'static, + DownloaderFactory: + AppDownloaderFactory> + '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 000000000000..beca2e628280 --- /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; + + /// Register click handler for the download button + fn on_download(&mut self, callback: F) + where + F: Fn() + Send + 'static; + + /// Register click handler for the cancel button + fn on_cancel(&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: Send { + fn queue_main(&self, callback: F); +} diff --git a/installer-downloader/src/lib.rs b/installer-downloader/src/lib.rs new file mode 100644 index 000000000000..724b546998c6 --- /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 000000000000..51f9e303e5f8 --- /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 000000000000..b5f5a0bf852e --- /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 ... (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 000000000000..41b6fa3efbb4 --- /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 { + downloader: Downloader, + /// Queue used to control the app UI + queue: Delegate::Queue, +} + +/// Parameters for [UiAppDownloader] +pub type UiAppDownloaderParameters = + AppDownloaderParameters, UiProgressUpdater>; + +impl + UiAppDownloader +{ + /// Construct a [UiAppDownloader]. + pub fn new(delegate: &Delegate, downloader: Downloader) -> Self { + Self { + downloader, + queue: delegate.queue(), + } + } +} + +#[async_trait::async_trait] +impl AppDownloader + for UiAppDownloader +{ + 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 { + domain: String, + prev_progress: Option, + queue: Delegate::Queue, +} + +impl UiProgressUpdater { + pub fn new(queue: Delegate::Queue) -> Self { + Self { + domain: "unknown source".to_owned(), + prev_progress: None, + queue, + } + } +} + +impl fetch::ProgressUpdater for UiProgressUpdater { + 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 000000000000..4414559e7c3b --- /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(&mut self, callback: F) + where + F: Fn() + Send + 'static, + { + register_click_handler(self.window.handle, self.download_button.handle, callback); + } + + fn on_cancel(&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 FnOnce(&'a mut AppWindow) + Send>, +} + +impl AppDelegateQueue for Queue { + fn queue_main(&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 000000000000..15a9957c00fd --- /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 000000000000..1b4704ad3d6a --- /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>, + pub banner_text_image: nwg::ImageFrame, + pub banner_image_bitmap: RefCell>, + 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>, 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::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 { + 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, + app: Rc>, +) -> 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, + app: Rc>, +) -> Result { + 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, 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 { + 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 000000000000..92e290273aaa --- /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 = 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 { + Ok(FAKE_VERSION.clone()) + } +} + +/// Downloader for which all steps immediately succeed +pub type FakeAppDownloaderHappyPath = FakeAppDownloader; + +/// Downloader for which the download step fails +pub type FakeAppDownloaderDownloadFail = FakeAppDownloader; + +/// Downloader for which all but the final verification step succeed +pub type FakeAppDownloaderVerifyFail = FakeAppDownloader; + +impl AppDownloaderFactory + for FakeAppDownloader +{ + type Parameters = UiAppDownloaderParameters; + + 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, +} + +#[async_trait::async_trait] +impl AppDownloader + for FakeAppDownloader +{ + 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>>>, +} + +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 for FakeQueue { + fn queue_main(&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>, + /// Callback registered by `on_cancel` + pub cancel_callback: Option>, + /// 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, +} + +impl AppDelegate for FakeAppDelegate { + type Queue = FakeQueue; + + fn on_download(&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(&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 000000000000..e86d9055d719 --- /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 000000000000..140e6d6320a7 --- /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 000000000000..45bd1796addb --- /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 000000000000..bb92678de921 --- /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 000000000000..45bd1796addb --- /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 000000000000..a8c18961891a --- /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 000000000000..f3a067c3afef --- /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 0000000000000000000000000000000000000000..54fa40bc2721a7681e4f043eb28d4972979ab6cd GIT binary patch literal 3831 zcmVuRX#ifu#s>(d64+0IH_aFtuxDxngz3bURf zC0wWE0_P|b!gpchEmI+sC8F->kcV1_M&rT2KvGz2n%e`hDXstr@ zyXmWKt=4?4Cg@z(ZWINmRa8bJbGdf-k)2K+*1Wj<6?&hZd5G5PRa1`=??g+W&Fx4w zYC)s7IAEO9Im|Q>i2mY@HwQX8X}T!;+|1})i?-(~-5b#!ldXH7z5|BKP|Wtp?IE@8 zq=?SBms49R#!hd*y*{&b)5VP65;vwueuQQvK9M*cnNHZV{oS-wpF2SDA5{{oFo~JQ z*$K5NVxl%d{Eh!^L;C}YZ-AbhC*do>ZXAB6Hx9L$Wsig424|DKMFXI@tp{Wkx8#E* z3}aQEDSx~-^nQDq3mR$_Blq1N?Q_tX2`2F?Xal|arS_QT7Umz#M;r8C4tstxA)Lrs z?X|4%nfU?8Lp+vj)lRrtz4{j9uW{U5(BpqurT9d@$>i%v9`CjkACw1Q$+&=>IQ={P zp>a@}9piGaz!xnoLC!lp=G3AUa&^&{ub?#!@5E`o#t2RSF}s!n%exL!pdh3$xe&?Q z5j_;8n@9G}rqED5r_o)C80RRECDou54Y<6K}ASh#RWMv?8X=iR}Zf7YVJYi&QX>LGmb!=>Q zVPr0DWpqA?0yP8^0SEve0viJY9svRufCU0r5$PWU3ke7Z1r-Vj2nz%j0s{d63JDO^ zq15tb-i_ayL{7}xmTUgiRz#Gqqj_64-3*! zpX>ZJgN(2lfr5)KJH{LU5I4bu*6OU08o#{F2alG~9{itD6M6z|z6#`}giaWuah{Q* zO9_E+cb7nj9aH}QcGh{BL8UTHFtda+<*X9K-s=A0W$}9)ivqp%dNQ}oV~=qDBalQwSu=Is9&$`rrO#M_Rf(8 z5!+iNqqz%-#`GcFH;`p%C$G|kB`Ir>NNwVS2CigRztI#Akbl*XBZmWdd^I_4Zd3wv zvdgTJa$mNN41a(qwv}=%d!h?Q!vns=*mQ@7qccPLP&*3uYs{!R>L!i)Bhb#uZE9)~F2JKnhT|c;yWxE=rj$T~y8~VzF(o8VT+QLv z6TIIkH5|(9!v%m+;$BXM5nSQemz>RuaWoI2Ic7~_pzl{aPUs-K3GIfC;d_GJON}!H zk4p2XCl=?iM@OaU$WHd@d(h9%fyT|1n^M%F6gs=?z~R5!{^(Cv0X@i#F{C=xUNKx?WUzfo14#M#h0yc~-E}ZdZzwV6*~jKkS`-eME| z{WtqBCh!&sH#DMfy6MsVFt344sAt}5neS3J*~CD%lB%!hx$uWL^@>ES=FXDFHP@|uFUtyDOgUtLAR z$r?aEx+#fdT1!Y9Q`|1TpZy_U@3i7Cee$&fCIXoS%Dn`b==elddL&dm8Z zKQ6Z?=sjT0b)g^JkAONri*5(8d^P?GyMbFr7f0-!)xurCodd({rmq!u`A_dm7iCDDnhPZL%&oWeusgb zKlT-FfL7xJCwM_5C4J&9zsNIDkaYsTb-o|TUOhBnvr7LwOglWKQtT&#Q70@ZrGw88pdgT?qeW&)upu1_3LWWQpEClVv`ZbW zcTsJPu`u}NjS6XrpvtIvJFwDpjuq4tP|yM8VV-3I=sl*awW-#MzL#-8p`$PLqRXhf z1I;RxmQxjjb#J=bWqh+>E0Db5f?Wlewqd*FK`OKwHA0nIW9KmzMd|%Q+|MQCJ5$wM z-x9q*^y7NDoXR0G41t9|m$S za{VTFnsq5R<=;sW09MQ+-+$=@%at5&4DU%oatZjwcNI8d0sm~venoh-nOMtkWbK6- zcp>m{LCJ0-|Les4WV*WQtLaX{KIi%!aK^V`y>-Kg2Z1K3P;eODPen^}z7JsncS*Wl zUsi={8x;XDseyO*Nos!b^g^36Jqmo8TfkmvaBGtYnLtCHimA}h#hMTQ1+%4=ek`at zvRrA|lI(aY3H&=t2+~qCj{?7yuT78hbh-BLDwjl7v@$#hCEQ8PZQ|~Bmi57O?dK`4 zem&*7;E#Y&`k*A~pM_>@pox{%kg@q3*y>w_r?LAbPyzxi+1Aate#BunoE2yIV=8{} z82j`)?8Q`Nm$ULpm!NO&6A{tsOZYt<*LI;jmIuhXi+$IL!VQN{P*ec?zCbLwt{l>c zNZ!f{mCplHa1-adQ^kih(sW}l9Hhlx1G&WfDr$SmQQvbPrSWQhZ~&6~$rI}D^AZxJ z1&B|MeN^6ObHjGx>yor4(UKreUIdXtXYWQE&a>wbIC~Jm6E<)vjMX>hECeqUa>g|s zNun+n{$NS>I1F#x@@SN;fEr&ajJTDq7q*R^S<*m|*$O$||MoH|01*KI0f_`Y1Q-Db z00{*GSP|lA0viGXDG3nOq15tb-i_bET_6M*0SEvF1p-(R;%ER02@s-Xf$|y1F-*uD z5B?~4{*tnN1D$~VHz9Jgzt>?X>pnfifOmjG_yCoVJpMO>?u)_454xiIZl92HdG-+3r)0Cf zW)U?QC*0jors8Gkxa_dGhV6g6HnTt_mz~Y^=D!f%pBAbqJO-lKNnKhwZ~Ot(mFMp& z_s!=nT&vt0REbPrlE|-ok0_~2{VD+Jvv;9SY|$nEhoLC-`AIif%GC2W$y{#N(=k9g zsJfp2l<&8lC-We~-LWCADp5(Gwg!bm06a0HxdVkWx^rAYG{;8&*GDnwTg>C`#F$kw zLvt_ZQRQBc+$n7%YU2RfmaF~7?m#%9qAj6=n4TX=@vhJPxeOJ$#u15{V((81qS)lz z>S!XRHzH3LFD}tHL53`-1!bD8GkSfT6C0x_v~%M2ts+?W>~GmJp%PXh%fu$Hm5WRS z)9-(gcp`47d+AO|dQ7YtU)=@;+B{wt0Pv{=p&s_e!`Ss+P&QAwSE$|3A{g?uHyMd` z-DI`!oVi`hU@A2XXrH*?g1uIWn$Cz2Sj#*C5ScNdMJ01hW_u^W=nku;hEic464UIW zJ7nrj(ZNexhNMs1Clo2zm4KM}*?`{#qw5@A}l5C3^q z{NtoYlUWF_2ZW{!+CW?a!-D^;dA(g#AuA^(!pDV(TGb`JnKfqwILrIx=bP2AMviX7qgZ{1gm-sIlx9}pu4E(P4Q4Z)EPy?o$~CPiz1H literal 0 HcmV?d00001 diff --git a/mullvad-update/src/api.rs b/mullvad-update/src/api.rs new file mode 100644 index 000000000000..16e578d485b6 --- /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; +} + +/// Contains information about all versions +#[derive(Debug, Clone)] +pub struct VersionInfo { + /// Stable version info + pub stable: Version, + /// Beta version info + pub beta: Option, +} + +/// 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, + /// Size of installer, in bytes + pub size: usize, + /// URLs pointing to app PGP signatures + pub signature_urls: Vec, +} + +/// 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 { + 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 000000000000..548fcf1681c3 --- /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 { + 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 { + signature_url: String, + app_url: String, + app_size: usize, + signature_progress_updater: SigProgress, + app_progress_updater: AppProgress, + // TODO: set permissions + tmp_dir: PathBuf, +} + +impl HttpAppDownloader { + const MAX_SIGNATURE_SIZE: usize = 1024; + + pub fn new(parameters: AppDownloaderParameters) -> 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 AppDownloaderFactory + for HttpAppDownloader +{ + type Parameters = AppDownloaderParameters; + + fn new_downloader(parameters: Self::Parameters) -> Self + where + Self: Sized, + { + HttpAppDownloader::new(parameters) + } +} + +#[async_trait::async_trait] +impl AppDownloader + for HttpAppDownloader +{ + 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 HttpAppDownloader { + 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 000000000000..5ddb4a45d8e9 --- /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::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, + ) -> Result { + 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(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let bytes = String::deserialize(deserializer).and_then(|string| { + bytes_from_hex::(&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(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let bytes = String::deserialize(deserializer) + .and_then(|string| bytes_from_hex::(&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, + /// Stable version response + pub stable: VersionResponse, + /// Beta version response + pub beta: Option, +} + +#[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, +} + +#[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, + /// Details for ARM64 installer + pub arm64: Option, +} + +#[derive(Deserialize)] +pub struct SpecificVersionArchitectureResponse { + /// Mirrors that host the artifact + pub urls: Vec, + /// 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 000000000000..1093915a7afe --- /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, + 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) -> io::Result { + 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 { + 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 AsyncWrite + for WriterWithProgress<'_, PU, Writer> +{ + fn poll_write( + mut self: Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + buf: &[u8], + ) -> Poll> { + 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> { + Pin::new(&mut self.writer).poll_flush(cx) + } + + fn poll_shutdown( + mut self: Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + ) -> Poll> { + 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 000000000000..08069710e075 --- /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 000000000000..74d8790c4bbd --- /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, sig_path: impl AsRef) -> 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, sig_path: impl AsRef) -> 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 000000000000..1dfd39770604 --- /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 From 60d23ed40849680f351b9b1c0aeff1d0fdd769f0 Mon Sep 17 00:00:00 2001 From: Sebastian Holmin Date: Thu, 6 Feb 2025 09:56:59 +0100 Subject: [PATCH 002/112] Replace `AppDownloaderFactory` trait with `From` impl (#7606) Small refactoring that replaces a custom trait with a `From` implementation. --- installer-downloader/src/controller.rs | 40 +++++++++--------------- installer-downloader/tests/controller.rs | 8 ++--- mullvad-update/src/app.rs | 18 ++--------- 3 files changed, 20 insertions(+), 46 deletions(-) diff --git a/installer-downloader/src/controller.rs b/installer-downloader/src/controller.rs index 10c3519bd375..058b5049b913 100644 --- a/installer-downloader/src/controller.rs +++ b/installer-downloader/src/controller.rs @@ -4,10 +4,9 @@ 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}, + api::{self, Version, VersionInfoProvider}, + app::{self, AppDownloader}, }; use std::future::Future; @@ -25,8 +24,7 @@ pub struct AppController {} /// Public entry function for registering a [AppDelegate]. pub fn initialize_controller(delegate: &mut T) { - use mullvad_update::api::LatestVersionInfoProvider; - use mullvad_update::app::HttpAppDownloader; + use mullvad_update::{api::LatestVersionInfoProvider, app::HttpAppDownloader}; // App downloader (factory) to use type DownloaderFactory = HttpAppDownloader, UiProgressUpdater>; @@ -41,12 +39,11 @@ impl AppController { /// /// 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: &mut Delegate) + pub fn initialize(delegate: &mut D) where - Delegate: AppDelegate + 'static, - VersionProvider: VersionInfoProvider + 'static, - DownloaderFactory: - AppDownloaderFactory> + 'static, + D: AppDelegate + 'static, + V: VersionInfoProvider + 'static, + A: From> + AppDownloader + 'static, { delegate.hide_download_progress(); delegate.show_download_button(); @@ -55,15 +52,9 @@ impl AppController { delegate.hide_beta_text(); let (task_tx, task_rx) = mpsc::channel(1); - tokio::spawn(handle_action_messages::( - delegate.queue(), - task_rx, - )); + tokio::spawn(handle_action_messages::(delegate.queue(), task_rx)); delegate.set_status_text(resource::FETCH_VERSION_DESC); - tokio::spawn(fetch_app_version_info::( - delegate, - task_tx.clone(), - )); + tokio::spawn(fetch_app_version_info::(delegate, task_tx.clone())); Self::register_user_action_callbacks(delegate, task_tx); } @@ -107,13 +98,10 @@ where /// Async worker that handles actions such as initiating a download, cancelling it, and updating /// labels. -async fn handle_action_messages( - queue: Delegate::Queue, - mut rx: mpsc::Receiver, -) where - Delegate: AppDelegate + 'static, - DownloaderFactory: - AppDownloaderFactory> + 'static, +async fn handle_action_messages(queue: D::Queue, mut rx: mpsc::Receiver) +where + D: AppDelegate + 'static, + A: From> + AppDownloader + 'static, { let mut version_info = None; let mut active_download = None; @@ -158,7 +146,7 @@ async fn handle_action_messages( self_.enable_cancel_button(); self_.show_download_progress(); - let downloader = DownloaderFactory::new_downloader(UiAppDownloaderParameters { + let downloader = A::from(UiAppDownloaderParameters { signature_url: signature_url.to_owned(), app_url: app_url.to_owned(), app_size, diff --git a/installer-downloader/tests/controller.rs b/installer-downloader/tests/controller.rs index 92e290273aaa..83ed08edb8b9 100644 --- a/installer-downloader/tests/controller.rs +++ b/installer-downloader/tests/controller.rs @@ -9,7 +9,7 @@ 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::app::{AppDownloader, DownloadError}; use mullvad_update::fetch::ProgressUpdater; use std::sync::{Arc, LazyLock, Mutex}; use std::time::Duration; @@ -43,12 +43,10 @@ pub type FakeAppDownloaderDownloadFail = FakeAppDownloader; /// Downloader for which all but the final verification step succeed pub type FakeAppDownloaderVerifyFail = FakeAppDownloader; -impl AppDownloaderFactory +impl From> for FakeAppDownloader { - type Parameters = UiAppDownloaderParameters; - - fn new_downloader(params: Self::Parameters) -> Self { + fn from(params: UiAppDownloaderParameters) -> Self { FakeAppDownloader { params } } } diff --git a/mullvad-update/src/app.rs b/mullvad-update/src/app.rs index 548fcf1681c3..5aaeb1b7cc45 100644 --- a/mullvad-update/src/app.rs +++ b/mullvad-update/src/app.rs @@ -36,14 +36,6 @@ pub trait AppDownloader: Send { 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?; @@ -78,15 +70,11 @@ impl HttpAppDownloader { } } -impl AppDownloaderFactory +impl + From> for HttpAppDownloader { - type Parameters = AppDownloaderParameters; - - fn new_downloader(parameters: Self::Parameters) -> Self - where - Self: Sized, - { + fn from(parameters: AppDownloaderParameters) -> Self { HttpAppDownloader::new(parameters) } } From 5f24b9e9ce4161ac0cefb05bfc19388554acb357 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20L=C3=B6nnhager?= Date: Fri, 7 Feb 2025 10:24:06 +0100 Subject: [PATCH 003/112] Implement version provider for API responses This also replaces the PGP verifier with a SHA256 checksum verifier --- Cargo.lock | 524 +----------------- installer-downloader/src/controller.rs | 28 +- installer-downloader/src/ui_downloader.rs | 3 +- installer-downloader/tests/controller.rs | 45 +- .../snapshots/controller__download-3.snap | 4 - .../controller__failed_verification.snap | 4 - mullvad-update/Cargo.toml | 8 +- mullvad-update/src/api.rs | 157 +++++- mullvad-update/src/app.rs | 75 +-- mullvad-update/src/deserializer.rs | 16 +- mullvad-update/src/format/mod.rs | 91 +++ mullvad-update/src/verify.rs | 143 +++-- 12 files changed, 398 insertions(+), 700 deletions(-) create mode 100644 mullvad-update/src/format/mod.rs diff --git a/Cargo.lock b/Cargo.lock index 9ba618673aa0..2f59ea0fbe39 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -64,15 +64,6 @@ dependencies = [ "subtle", ] -[[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" @@ -175,19 +166,6 @@ version = "1.7.1" 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" @@ -368,12 +346,6 @@ version = "0.6.3" 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" @@ -386,15 +358,6 @@ version = "2.6.0" 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" @@ -423,15 +386,6 @@ dependencies = [ "generic-array", ] -[[package]] -name = "block-padding" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" -dependencies = [ - "generic-array", -] - [[package]] name = "block2" version = "0.5.1" @@ -441,35 +395,6 @@ dependencies = [ "objc2", ] -[[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" @@ -522,15 +447,6 @@ dependencies = [ "cipher", ] -[[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" @@ -582,15 +498,6 @@ version = "1.1.0" 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" @@ -717,17 +624,6 @@ dependencies = [ "zeroize", ] -[[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" @@ -844,12 +740,6 @@ dependencies = [ "libc", ] -[[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" @@ -945,7 +835,7 @@ dependencies = [ "cpufeatures", "curve25519-dalek-derive", "digest", - "fiat-crypto 0.2.8", + "fiat-crypto", "rustc_version", "subtle", "zeroize", @@ -1016,15 +906,6 @@ version = "2.5.0" 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" @@ -1053,7 +934,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f55bf8e7b65898637379c1b74eb1551107c8294ed26d855ceb9fd1a09cfc9bc0" dependencies = [ "const-oid", - "pem-rfc7468", "zeroize", ] @@ -1108,36 +988,6 @@ dependencies = [ "syn 2.0.89", ] -[[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" @@ -1145,7 +995,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", - "const-oid", "crypto-common", "subtle", ] @@ -1188,22 +1037,6 @@ dependencies = [ "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" @@ -1216,19 +1049,6 @@ dependencies = [ "shared_child", ] -[[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" @@ -1236,11 +1056,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" dependencies = [ "der", - "digest", "elliptic-curve", - "rfc6979", "signature", - "spki", ] [[package]] @@ -1267,17 +1084,6 @@ dependencies = [ "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" @@ -1296,9 +1102,6 @@ dependencies = [ "ff", "generic-array", "group", - "hkdf", - "pem-rfc7468", - "pkcs8", "rand_core 0.6.4", "sec1", "subtle", @@ -1444,12 +1247,6 @@ dependencies = [ "subtle", ] -[[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" @@ -2191,15 +1988,6 @@ dependencies = [ "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" @@ -2422,12 +2210,6 @@ dependencies = [ "windows-sys 0.52.0", ] -[[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" @@ -2536,20 +2318,6 @@ dependencies = [ "serde", ] -[[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" @@ -2594,9 +2362,6 @@ 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" @@ -2798,12 +2563,6 @@ version = "0.3.17" 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" @@ -3236,11 +2995,11 @@ dependencies = [ "json-canon", "mockito", "mullvad-version", - "pgp", "rand 0.8.5", "reqwest", "serde", "serde_json", + "sha2", "tokio", ] @@ -3476,16 +3235,6 @@ version = "0.6.0" 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" @@ -3524,24 +3273,6 @@ dependencies = [ "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" @@ -3599,27 +3330,6 @@ dependencies = [ "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" @@ -3749,18 +3459,6 @@ dependencies = [ "memchr", ] -[[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.20.3" @@ -3874,8 +3572,6 @@ checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" dependencies = [ "ecdsa", "elliptic-curve", - "primeorder", - "sha2", ] [[package]] @@ -3887,21 +3583,6 @@ 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]] @@ -3941,17 +3622,6 @@ dependencies = [ "windows-targets 0.48.5", ] -[[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" @@ -3975,15 +3645,6 @@ dependencies = [ "windows-sys 0.36.1", ] -[[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" @@ -4057,73 +3718,6 @@ dependencies = [ "libc", ] -[[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" @@ -4194,17 +3788,6 @@ version = "0.1.0" 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" @@ -4399,22 +3982,13 @@ dependencies = [ "elliptic-curve", ] -[[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", + "unicode-xid", ] [[package]] @@ -4841,16 +4415,6 @@ dependencies = [ "quick-error", ] -[[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" @@ -4861,7 +4425,7 @@ dependencies = [ "cfg-if", "getrandom 0.2.14", "libc", - "spin 0.9.8", + "spin", "untrusted", "windows-sys 0.52.0", ] @@ -4885,41 +4449,12 @@ dependencies = [ "signature", ] -[[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" @@ -5124,7 +4659,6 @@ dependencies = [ "base16ct", "der", "generic-array", - "pkcs8", "subtle", "zeroize", ] @@ -5238,17 +4772,6 @@ dependencies = [ "digest", ] -[[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" @@ -5309,7 +4832,7 @@ dependencies = [ "serde_urlencoded", "shadowsocks-crypto", "socket2", - "spin 0.9.8", + "spin", "thiserror 1.0.59", "tokio", "tokio-tfo", @@ -5368,7 +4891,7 @@ dependencies = [ "serde", "shadowsocks", "socket2", - "spin 0.9.8", + "spin", "thiserror 1.0.59", "tokio", "windows-sys 0.59.0", @@ -5411,7 +4934,6 @@ version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" dependencies = [ - "digest", "rand_core 0.6.4", ] @@ -5462,12 +4984,6 @@ dependencies = [ "windows-sys 0.52.0", ] -[[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" @@ -5539,7 +5055,7 @@ checksum = "9ca4b3b69a77cbe1ffc9e198781b7acb0c7365a883670e8f1c1bc66fba79a5c5" dependencies = [ "proc-macro2 0.4.30", "quote 0.6.13", - "unicode-xid 0.1.0", + "unicode-xid", ] [[package]] @@ -6388,15 +5904,6 @@ dependencies = [ "udp-over-tcp", ] -[[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" @@ -6446,12 +5953,6 @@ 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" @@ -7304,17 +6805,6 @@ dependencies = [ "zeroize", ] -[[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" diff --git a/installer-downloader/src/controller.rs b/installer-downloader/src/controller.rs index 058b5049b913..773ed1d3bd90 100644 --- a/installer-downloader/src/controller.rs +++ b/installer-downloader/src/controller.rs @@ -5,7 +5,7 @@ use crate::resource; use crate::ui_downloader::{UiAppDownloader, UiAppDownloaderParameters, UiProgressUpdater}; use mullvad_update::{ - api::{self, Version, VersionInfoProvider}, + api::{self, Version, VersionInfoProvider, VersionParameters}, app::{self, AppDownloader}, }; @@ -24,14 +24,14 @@ pub struct AppController {} /// Public entry function for registering a [AppDelegate]. pub fn initialize_controller(delegate: &mut T) { - use mullvad_update::{api::LatestVersionInfoProvider, app::HttpAppDownloader}; + use mullvad_update::{api::ApiVersionInfoProvider, app::HttpAppDownloader}; - // App downloader (factory) to use - type DownloaderFactory = HttpAppDownloader, UiProgressUpdater>; + // App downloader to use + type Downloader = HttpAppDownloader>; // Version info provider to use - type VersionInfoProvider = LatestVersionInfoProvider; + type VersionInfoProvider = ApiVersionInfoProvider; - AppController::initialize::<_, DownloaderFactory, VersionInfoProvider>(delegate) + AppController::initialize::<_, Downloader, VersionInfoProvider>(delegate) } impl AppController { @@ -85,8 +85,15 @@ where let queue = delegate.queue(); async move { + let version_params = VersionParameters { + // TODO: detect current architecture + architecture: api::VersionArchitecture::X86, + // For the downloader, the rollout version is always preferred + rollout: 1., + }; + // TODO: handle errors, retry - let Ok(version_info) = VersionProvider::get_version_info().await else { + let Ok(version_info) = VersionProvider::get_version_info(version_params).await else { queue.queue_main(move |self_| { self_.set_status_text("Failed to fetch version info"); }); @@ -134,9 +141,7 @@ where 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_sha256 = version_info.stable.sha256; let app_size = version_info.stable.size; self_.set_download_text(""); @@ -147,11 +152,10 @@ where self_.show_download_progress(); let downloader = A::from(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()), + app_sha256, }); let ui_downloader = UiAppDownloader::new(self_, downloader); diff --git a/installer-downloader/src/ui_downloader.rs b/installer-downloader/src/ui_downloader.rs index 41b6fa3efbb4..18eeb0074d6e 100644 --- a/installer-downloader/src/ui_downloader.rs +++ b/installer-downloader/src/ui_downloader.rs @@ -19,8 +19,7 @@ pub struct UiAppDownloader { } /// Parameters for [UiAppDownloader] -pub type UiAppDownloaderParameters = - AppDownloaderParameters, UiProgressUpdater>; +pub type UiAppDownloaderParameters = AppDownloaderParameters>; impl UiAppDownloader diff --git a/installer-downloader/tests/controller.rs b/installer-downloader/tests/controller.rs index 83ed08edb8b9..5f227bbdb279 100644 --- a/installer-downloader/tests/controller.rs +++ b/installer-downloader/tests/controller.rs @@ -8,7 +8,7 @@ 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::api::{Version, VersionInfo, VersionInfoProvider, VersionParameters}; use mullvad_update::app::{AppDownloader, DownloadError}; use mullvad_update::fetch::ProgressUpdater; use std::sync::{Arc, LazyLock, Mutex}; @@ -19,32 +19,33 @@ pub struct FakeVersionInfoProvider {} static FAKE_VERSION: LazyLock = LazyLock::new(|| VersionInfo { stable: Version { - version: "2025.1".to_owned(), + version: "2025.1".parse().unwrap(), urls: vec!["https://mullvad.net/fakeapp".to_owned()], size: 1234, - signature_urls: vec!["https://mullvad.net/fakesig".to_owned()], + changelog: "a changelog".to_owned(), + sha256: [0u8; 32], }, beta: None, }); #[async_trait::async_trait] impl VersionInfoProvider for FakeVersionInfoProvider { - async fn get_version_info() -> anyhow::Result { + async fn get_version_info(_params: VersionParameters) -> anyhow::Result { Ok(FAKE_VERSION.clone()) } } /// Downloader for which all steps immediately succeed -pub type FakeAppDownloaderHappyPath = FakeAppDownloader; +pub type FakeAppDownloaderHappyPath = FakeAppDownloader; /// Downloader for which the download step fails -pub type FakeAppDownloaderDownloadFail = FakeAppDownloader; +pub type FakeAppDownloaderDownloadFail = FakeAppDownloader; -/// Downloader for which all but the final verification step succeed -pub type FakeAppDownloaderVerifyFail = FakeAppDownloader; +/// Downloader for which the final verification step fails +pub type FakeAppDownloaderVerifyFail = FakeAppDownloader; -impl From> - for FakeAppDownloader +impl From> + for FakeAppDownloader { fn from(params: UiAppDownloaderParameters) -> Self { FakeAppDownloader { params } @@ -54,32 +55,18 @@ impl From { +/// * VERIFY_SUCCEED - whether verifying the binary succeeds +pub struct FakeAppDownloader { params: UiAppDownloaderParameters, } #[async_trait::async_trait] -impl AppDownloader - for FakeAppDownloader +impl AppDownloader + for FakeAppDownloader { 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" - ))) - } + Ok(()) } async fn download_executable(&mut self) -> Result<(), DownloadError> { diff --git a/installer-downloader/tests/snapshots/controller__download-3.snap b/installer-downloader/tests/snapshots/controller__download-3.snap index 140e6d6320a7..d7c86160de2a 100644 --- a/installer-downloader/tests/snapshots/controller__download-3.snap +++ b/installer-downloader/tests/snapshots/controller__download-3.snap @@ -33,10 +33,6 @@ call_log: - "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__failed_verification.snap b/installer-downloader/tests/snapshots/controller__failed_verification.snap index bb92678de921..d345795b7e7f 100644 --- a/installer-downloader/tests/snapshots/controller__failed_verification.snap +++ b/installer-downloader/tests/snapshots/controller__failed_verification.snap @@ -33,10 +33,6 @@ call_log: - "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/mullvad-update/Cargo.toml b/mullvad-update/Cargo.toml index f3a067c3afef..f858f0d917dc 100644 --- a/mullvad-update/Cargo.toml +++ b/mullvad-update/Cargo.toml @@ -13,13 +13,13 @@ 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" +chrono = { workspace = true, features = ["serde", "now"] } +ed25519-dalek = { version = "2.1" } +hex = { version = "0.4" } reqwest = { version = "0.12.9", features = ["blocking", "json"] } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } +sha2 = "0.10" tokio = { version = "1", features = ["full"] } async-trait = "0.1" diff --git a/mullvad-update/src/api.rs b/mullvad-update/src/api.rs index 16e578d485b6..d4fbd0e91c68 100644 --- a/mullvad-update/src/api.rs +++ b/mullvad-update/src/api.rs @@ -1,10 +1,32 @@ //! Fetch information about app versions from the Mullvad API +use anyhow::Context; + +use crate::deserializer; + +/// Parameters for [VersionInfoProvider] +#[derive(Debug)] +pub struct VersionParameters { + /// Architecture to retrieve data for + pub architecture: VersionArchitecture, + /// Rollout threshold. Any version in the response below this threshold will be ignored + pub rollout: f32, +} + +/// Architecture to retrieve data for +#[derive(Debug, Clone, Copy)] +pub enum VersionArchitecture { + /// x86-64 architecture + X86, + /// ARM64 architecture + Arm64, +} + /// 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; + async fn get_version_info(params: VersionParameters) -> anyhow::Result; } /// Contains information about all versions @@ -20,39 +42,122 @@ pub struct VersionInfo { #[derive(Debug, Clone)] pub struct Version { /// Version - pub version: String, + pub version: mullvad_version::Version, /// URLs to use for downloading the app installer pub urls: Vec, /// Size of installer, in bytes pub size: usize, - /// URLs pointing to app PGP signatures - pub signature_urls: Vec, + /// Version changelog + pub changelog: String, + /// App installer checksum + pub sha256: [u8; 32], } -/// Use hardcoded URL to fetch installer -/// TODO: This is temporary -pub struct LatestVersionInfoProvider; +/// Obtain version data from the Mullvad API +pub struct ApiVersionInfoProvider; #[async_trait::async_trait] -impl VersionInfoProvider for LatestVersionInfoProvider { - async fn get_version_info() -> anyhow::Result { - 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(), - ], - }), +impl VersionInfoProvider for ApiVersionInfoProvider { + async fn get_version_info(params: VersionParameters) -> anyhow::Result { + // FIXME: Replace with actual API response + use deserializer::*; + + 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(); + + let response = SignedResponse::deserialize_and_verify( + VerifyingKey(verifying_key), + include_bytes!("../test-version-response.json"), + )?; + + VersionInfo::try_from_signed_response(¶ms, response) + } +} + +impl VersionInfo { + /// Convert signed response data to public version type + /// NOTE: `response` is assumed to be verified and untampered. It is not verified. + fn try_from_signed_response( + params: &VersionParameters, + response: deserializer::SignedResponse, + ) -> anyhow::Result { + let stable = Version::try_from_signed_response(params, response.signed.stable)?; + let beta = response + .signed + .beta + .map(|response| Version::try_from_signed_response(params, response)) + .transpose() + .context("Failed to parse beta version")?; + + Ok(Self { stable, beta }) + } +} + +impl Version { + /// Convert response data to public version type + fn try_from_signed_response( + params: &VersionParameters, + response: deserializer::VersionResponse, + ) -> anyhow::Result { + // Check if the rollout version is acceptable according to threshold + if let Some(next) = response.next { + if next.rollout >= params.rollout { + // Use the version being rolled out + return Self::try_for_arch(params, next.version); + } + } + + // Return the version not being rolled out + Self::try_for_arch(params, response.current) + } + + /// Convert version response to the public version type for a given architecture + /// This may fail if the current architecture isn't included in the response + fn try_for_arch( + params: &VersionParameters, + response: deserializer::SpecificVersionResponse, + ) -> anyhow::Result { + let installer = match params.architecture { + VersionArchitecture::X86 => response.installers.x86, + VersionArchitecture::Arm64 => response.installers.arm64, + }; + let installer = installer.context("Installer missing for architecture")?; + let sha256 = hex::decode(installer.sha256) + .context("Invalid checksum hex")? + .try_into() + .map_err(|_| anyhow::anyhow!("Invalid checksum length"))?; + + Ok(Self { + changelog: response.changelog, + version: response.version, + urls: installer.urls, + size: installer.size, + sha256, }) } } + +#[cfg(test)] +mod test { + use super::*; + + /// Test API version responses can be parsed + #[test] + fn test_api_version_info_provider_parser() -> anyhow::Result<()> { + let response = deserializer::SignedResponse::deserialize_and_verify_insecure( + include_bytes!("../test-version-response.json"), + )?; + + let params = VersionParameters { + architecture: VersionArchitecture::X86, + rollout: 1., + }; + + VersionInfo::try_from_signed_response(¶ms, response)?; + + Ok(()) + } +} diff --git a/mullvad-update/src/app.rs b/mullvad-update/src/app.rs index 5aaeb1b7cc45..ef08971bb021 100644 --- a/mullvad-update/src/app.rs +++ b/mullvad-update/src/app.rs @@ -4,7 +4,7 @@ use std::path::PathBuf; use crate::{ fetch::{self, ProgressUpdater}, - verify::{AppVerifier, PgpVerifier}, + verify::{AppVerifier, Sha256Verifier}, }; #[derive(Debug)] @@ -15,12 +15,12 @@ pub enum DownloadError { } /// Parameters required to construct an [AppDownloader]. -pub struct AppDownloaderParameters { - pub signature_url: String, +#[derive(Clone)] +pub struct AppDownloaderParameters { pub app_url: String, pub app_size: usize, - pub sig_progress: SigProgress, pub app_progress: AppProgress, + pub app_sha256: [u8; 32], } /// See the [module-level documentation](self). @@ -44,63 +44,40 @@ pub async fn install_and_upgrade(mut downloader: impl AppDownloader) -> Result<( } #[derive(Clone)] -pub struct HttpAppDownloader { - signature_url: String, - app_url: String, - app_size: usize, - signature_progress_updater: SigProgress, - app_progress_updater: AppProgress, +pub struct HttpAppDownloader { + params: AppDownloaderParameters, // TODO: set permissions tmp_dir: PathBuf, } -impl HttpAppDownloader { - const MAX_SIGNATURE_SIZE: usize = 1024; - - pub fn new(parameters: AppDownloaderParameters) -> Self { +impl HttpAppDownloader { + pub fn new(params: AppDownloaderParameters) -> 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, - } + Self { params, tmp_dir } } } -impl - From> - for HttpAppDownloader +impl From> + for HttpAppDownloader { - fn from(parameters: AppDownloaderParameters) -> Self { + fn from(parameters: AppDownloaderParameters) -> Self { HttpAppDownloader::new(parameters) } } #[async_trait::async_trait] -impl AppDownloader - for HttpAppDownloader -{ +impl AppDownloader for HttpAppDownloader { 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) + // TODO: no-op, remove + Ok(()) } 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), + &self.params.app_url, + &mut self.params.app_progress, + fetch::SizeHint::Exact(self.params.app_size), ) .await .map_err(DownloadError::FetchApp) @@ -108,21 +85,19 @@ impl AppDownloader 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") + let hash = self.hash_sha256(); + Sha256Verifier::verify(bin_path, *hash) + .await + .map_err(DownloadError::Verification) } } -impl HttpAppDownloader { +impl HttpAppDownloader { fn bin_path(&self) -> PathBuf { self.tmp_dir.join("temp.exe") } - fn sig_path(&self) -> PathBuf { - self.tmp_dir.join("temp.exe.sig") + fn hash_sha256(&self) -> &[u8; 32] { + &self.params.app_sha256 } } diff --git a/mullvad-update/src/deserializer.rs b/mullvad-update/src/deserializer.rs index 5ddb4a45d8e9..5f1a0326b80c 100644 --- a/mullvad-update/src/deserializer.rs +++ b/mullvad-update/src/deserializer.rs @@ -30,6 +30,20 @@ impl SignedResponse { Self::deserialize_and_verify_at_time(key, bytes, chrono::Utc::now()) } + /// This method is used for testing, and skips all verification. + /// Own method to prevent accidental misuse. + #[cfg(test)] + pub fn deserialize_and_verify_insecure(bytes: &[u8]) -> Result { + let partial_data: PartialSignedResponse = + serde_json::from_slice(bytes).context("Invalid version JSON")?; + let signed = serde_json::from_value(partial_data.signed) + .context("Failed to deserialize response")?; + Ok(Self { + signature: partial_data.signature, + signed, + }) + } + /// 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( @@ -193,7 +207,7 @@ pub struct SpecificVersionArchitectureResponse { pub urls: Vec, /// Size of the installer, in bytes pub size: usize, - /// TODO: hash of the installer, in bytes + /// Hash of the installer, hexadecimal string pub sha256: String, } diff --git a/mullvad-update/src/format/mod.rs b/mullvad-update/src/format/mod.rs new file mode 100644 index 000000000000..98428babf436 --- /dev/null +++ b/mullvad-update/src/format/mod.rs @@ -0,0 +1,91 @@ +use serde::{Deserialize, Serialize}; + +pub mod deserializer; +pub mod key; +#[cfg(feature = "sign")] +pub mod serializer; + +/// JSON response including signature and signed content +/// This type does not implement [serde::Deserialize] to prevent accidental deserialization without +/// signature verification. +#[derive(Serialize)] +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(Deserialize, Serialize)] +struct PartialSignedResponse { + /// Signature of the canonicalized JSON of `signed` + pub signature: ResponseSignature, + /// Content signed by `signature` + pub signed: serde_json::Value, +} + +/// Signed JSON response, not including the signature +#[derive(Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct Response { + /// When the signature expires + pub expires: chrono::DateTime, + /// Stable version response + pub stable: VersionResponse, + /// Beta version response + pub beta: Option, +} + +#[derive(Deserialize, Serialize)] +pub struct VersionResponse { + /// The current version in this channel + pub current: SpecificVersionResponse, + /// The version being rolled out in this channel + pub next: Option, +} + +#[derive(Deserialize, Serialize)] +pub struct NextSpecificVersionResponse { + /// The percentage of users that should receive the new version. + pub rollout: f32, + #[serde(flatten)] + pub version: SpecificVersionResponse, +} + +#[derive(Deserialize, Serialize)] +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, Serialize)] +pub struct SpecificVersionArchitectureResponses { + /// Details for x86 installer + pub x86: Option, + /// Details for ARM64 installer + pub arm64: Option, +} + +#[derive(Deserialize, Serialize)] +pub struct SpecificVersionArchitectureResponse { + /// Mirrors that host the artifact + pub urls: Vec, + /// Size of the installer, in bytes + pub size: usize, + /// Hash of the installer, hexadecimal string + pub sha256: String, +} + +/// JSON response signature +#[derive(Deserialize, Serialize)] +pub struct ResponseSignature { + pub keyid: key::VerifyingKey, + pub sig: key::Signature, +} diff --git a/mullvad-update/src/verify.rs b/mullvad-update/src/verify.rs index 74d8790c4bbd..a6bc3c9bc0d5 100644 --- a/mullvad-update/src/verify.rs +++ b/mullvad-update/src/verify.rs @@ -1,61 +1,80 @@ -use std::{fs::File, io::BufReader, path::Path}; - use anyhow::Context; -use pgp::{ - armor, - packet::{Packet, PacketParser}, - types::PublicKeyTrait, - Deserializable, SignedPublicKey, +use sha2::Digest; +use tokio::{ + fs, + io::{AsyncRead, AsyncReadExt, BufReader}, }; -/// A verifier of digital file signatures +use std::{future::Future, path::Path}; + +/// A verifier of digital file signatures or hashes 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, sig_path: impl AsRef) -> anyhow::Result<()>; + type Parameters; + + /// Verify `bin_path` using `parameters`, and return an error if this fails for any reason. + fn verify( + bin_path: impl AsRef, + parameters: Self::Parameters, + ) -> impl Future>; } -/// Verification using pgp +/// Checksum verifier that uses SHA256 #[derive(Clone)] -pub struct PgpVerifier; +pub struct Sha256Verifier; + +impl Sha256Verifier { + /// Maximum number of bytes to read at a time + const BUF_SIZE: usize = 1024 * 1024; +} + +impl AppVerifier for Sha256Verifier { + /// The checksum + type Parameters = [u8; 32]; + + fn verify( + bin_path: impl AsRef, + expected_hash: Self::Parameters, + ) -> impl Future> { + let bin_path = bin_path.as_ref().to_owned(); + + async move { + let file = fs::File::open(&bin_path) + .await + .context(format!("Failed to open file at {}", bin_path.display()))?; + let file = BufReader::new(file); -impl PgpVerifier { - const SIGNING_PUBKEY: &[u8] = include_bytes!("../mullvad-code-signing.gpg"); + Self::verify_inner(file, expected_hash).await + } + } } -impl AppVerifier for PgpVerifier { - fn verify(bin_path: impl AsRef, sig_path: impl AsRef) -> 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")?; +impl Sha256Verifier { + async fn verify_inner( + mut reader: impl AsyncRead + Unpin, + expected_hash: [u8; 32], + ) -> anyhow::Result<()> { + let mut hasher = sha2::Sha256::new(); + + // Read data into hasher + let mut buffer = vec![0u8; Self::BUF_SIZE]; + loop { + let read_n = reader + .read(&mut buffer) + .await + .context("Error reading bin file")?; + if read_n == 0 { + // We're done + break; + } + hasher.update(&buffer[..read_n]); + } + + let actual_hash = hasher.finalize(); + + // Verify that hash is correct + if expected_hash != actual_hash[..] { + anyhow::bail!("Invalid checksum for bin file"); + } Ok(()) } @@ -63,10 +82,32 @@ impl AppVerifier for PgpVerifier { #[cfg(test)] mod test { + use rand::RngCore; + use std::io::Cursor; + use super::*; - #[test] - fn test_pgp_signing_pubkey() { - SignedPublicKey::from_bytes(PgpVerifier::SIGNING_PUBKEY).unwrap(); + #[tokio::test] + async fn test_sha256_checksum() { + // Generate some random data + let mut data = vec![0u8; 1024 * 1024]; + rand::thread_rng().fill_bytes(&mut data); + + // Hash it + let mut hasher = sha2::Sha256::new(); + hasher.update(&data); + let expected_hash = hasher.finalize(); + let expected_hash: [u8; 32] = expected_hash[..].try_into().unwrap(); + + // Same data should be accepted + Sha256Verifier::verify_inner(Cursor::new(&data), expected_hash) + .await + .expect("expected checksum match"); + + // Compare the hash against some random data, which should fail + rand::thread_rng().fill_bytes(&mut data); + Sha256Verifier::verify_inner(Cursor::new(&data), expected_hash) + .await + .expect_err("expected checksum mismatch"); } } From 1081a6d582a6390f41788ad2c88238f1f087e80b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20L=C3=B6nnhager?= Date: Fri, 7 Feb 2025 13:37:06 +0100 Subject: [PATCH 004/112] Add mullvad-version-metadata tool `format` was also updated to support signing --- Cargo.lock | 1 + mullvad-update/Cargo.toml | 12 + mullvad-update/src/api.rs | 18 +- .../src/bin/mullvad-version-metadata.rs | 79 ++++++ mullvad-update/src/deserializer.rs | 235 ------------------ mullvad-update/src/format/deserializer.rs | 116 +++++++++ mullvad-update/src/format/key.rs | 184 ++++++++++++++ mullvad-update/src/format/mod.rs | 2 +- mullvad-update/src/format/serializer.rs | 97 ++++++++ mullvad-update/src/lib.rs | 4 +- 10 files changed, 502 insertions(+), 246 deletions(-) create mode 100644 mullvad-update/src/bin/mullvad-version-metadata.rs delete mode 100644 mullvad-update/src/deserializer.rs create mode 100644 mullvad-update/src/format/deserializer.rs create mode 100644 mullvad-update/src/format/key.rs create mode 100644 mullvad-update/src/format/serializer.rs diff --git a/Cargo.lock b/Cargo.lock index 2f59ea0fbe39..e7416e77799d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2990,6 +2990,7 @@ dependencies = [ "async-tempfile", "async-trait", "chrono", + "clap", "ed25519-dalek", "hex", "json-canon", diff --git a/mullvad-update/Cargo.toml b/mullvad-update/Cargo.toml index f858f0d917dc..9ad66787d4b6 100644 --- a/mullvad-update/Cargo.toml +++ b/mullvad-update/Cargo.toml @@ -10,6 +10,10 @@ rust-version.workspace = true [lints] workspace = true +[features] +default = [] +sign = ["rand", "clap"] + [dependencies] anyhow = "1.0" json-canon = "0.1" @@ -25,7 +29,15 @@ async-trait = "0.1" mullvad-version = { path = "../mullvad-version", features = ["serde"] } +# features required by binaries +clap = { workspace = true, optional = true } +rand = { version = "0.8.5", optional = true } + [dev-dependencies] async-tempfile = "0.6" mockito = "1.6.1" rand = "0.8.5" + +[[bin]] +name = "mullvad-version-metadata" +required-features = ["sign"] \ No newline at end of file diff --git a/mullvad-update/src/api.rs b/mullvad-update/src/api.rs index d4fbd0e91c68..c17973900c93 100644 --- a/mullvad-update/src/api.rs +++ b/mullvad-update/src/api.rs @@ -2,7 +2,7 @@ use anyhow::Context; -use crate::deserializer; +use crate::format; /// Parameters for [VersionInfoProvider] #[derive(Debug)] @@ -60,7 +60,7 @@ pub struct ApiVersionInfoProvider; impl VersionInfoProvider for ApiVersionInfoProvider { async fn get_version_info(params: VersionParameters) -> anyhow::Result { // FIXME: Replace with actual API response - use deserializer::*; + use format::*; const TEST_PUBKEY: &str = "AEC24A08466F3D6A1EDCDB2AD3C234428AB9D991B6BEA7F53CB9F172E6CB40D8"; @@ -69,7 +69,7 @@ impl VersionInfoProvider for ApiVersionInfoProvider { ed25519_dalek::VerifyingKey::from_bytes(&pubkey.try_into().unwrap()).unwrap(); let response = SignedResponse::deserialize_and_verify( - VerifyingKey(verifying_key), + format::key::VerifyingKey(verifying_key), include_bytes!("../test-version-response.json"), )?; @@ -82,7 +82,7 @@ impl VersionInfo { /// NOTE: `response` is assumed to be verified and untampered. It is not verified. fn try_from_signed_response( params: &VersionParameters, - response: deserializer::SignedResponse, + response: format::SignedResponse, ) -> anyhow::Result { let stable = Version::try_from_signed_response(params, response.signed.stable)?; let beta = response @@ -100,7 +100,7 @@ impl Version { /// Convert response data to public version type fn try_from_signed_response( params: &VersionParameters, - response: deserializer::VersionResponse, + response: format::VersionResponse, ) -> anyhow::Result { // Check if the rollout version is acceptable according to threshold if let Some(next) = response.next { @@ -118,7 +118,7 @@ impl Version { /// This may fail if the current architecture isn't included in the response fn try_for_arch( params: &VersionParameters, - response: deserializer::SpecificVersionResponse, + response: format::SpecificVersionResponse, ) -> anyhow::Result { let installer = match params.architecture { VersionArchitecture::X86 => response.installers.x86, @@ -147,9 +147,9 @@ mod test { /// Test API version responses can be parsed #[test] fn test_api_version_info_provider_parser() -> anyhow::Result<()> { - let response = deserializer::SignedResponse::deserialize_and_verify_insecure( - include_bytes!("../test-version-response.json"), - )?; + let response = format::SignedResponse::deserialize_and_verify_insecure(include_bytes!( + "../test-version-response.json" + ))?; let params = VersionParameters { architecture: VersionArchitecture::X86, diff --git a/mullvad-update/src/bin/mullvad-version-metadata.rs b/mullvad-update/src/bin/mullvad-version-metadata.rs new file mode 100644 index 000000000000..fbfc0ed51b83 --- /dev/null +++ b/mullvad-update/src/bin/mullvad-version-metadata.rs @@ -0,0 +1,79 @@ +//! See [Opt]. + +use anyhow::Context; +use clap::Parser; +use std::io::Read; +use tokio::{fs, io}; + +use mullvad_update::format::{self, key}; + +#[allow(dead_code)] +const DEFAULT_EXPIRY_MONTHS: u32 = 6; + +/// A tool that generates signed Mullvad version metadata. +#[derive(Parser)] +pub enum Opt { + /// Generate an ed25519 secret key + GenerateKey, + + /// Sign a JSON payload using an ed25519 key and output the signed metadata + /// This data is typically generated by 'generate-unsigned-metadata' + Sign { + /// File to sign. Use "-" to read from stdin. + #[clap(short, long)] + file: String, + + /// Secret ed25519 key used for signing, as hexadecimal string + #[clap(short, long)] + secret: key::SecretKey, + }, +} + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + let opt = Opt::parse(); + + match opt { + Opt::GenerateKey => { + println!("{}", key::SecretKey::generate().to_string()); + Ok(()) + } + Opt::Sign { file, secret } => sign(file, secret).await, + } +} + +async fn sign(file: String, secret: key::SecretKey) -> anyhow::Result<()> { + // Read unsigned JSON data + let data = if file == "-" { + get_stdin().await? + } else { + fs::read(file).await? + }; + + // Deserialize version data + // TODO: Make sure this never ignores missing fields + let response: format::Response = + serde_json::from_slice(&data).context("Failed to deserialize version metadata")?; + + // Sign it + let signed_response = format::SignedResponse::sign(secret, response)?; + + // Print it + println!( + "{}", + serde_json::to_string_pretty(&signed_response) + .context("Failed to serialize signed version")? + ); + + Ok(()) +} + +async fn get_stdin() -> io::Result> { + tokio::task::spawn_blocking(|| { + let mut buf = vec![]; + std::io::stdin().read_to_end(&mut buf)?; + Ok(buf) + }) + .await + .unwrap() +} diff --git a/mullvad-update/src/deserializer.rs b/mullvad-update/src/deserializer.rs deleted file mode 100644 index 5f1a0326b80c..000000000000 --- a/mullvad-update/src/deserializer.rs +++ /dev/null @@ -1,235 +0,0 @@ -//! 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::deserialize_and_verify_at_time(key, bytes, chrono::Utc::now()) - } - - /// This method is used for testing, and skips all verification. - /// Own method to prevent accidental misuse. - #[cfg(test)] - pub fn deserialize_and_verify_insecure(bytes: &[u8]) -> Result { - let partial_data: PartialSignedResponse = - serde_json::from_slice(bytes).context("Invalid version JSON")?; - let signed = serde_json::from_value(partial_data.signed) - .context("Failed to deserialize response")?; - Ok(Self { - signature: partial_data.signature, - signed, - }) - } - - /// 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, - ) -> Result { - 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(deserializer: D) -> Result - where - D: serde::Deserializer<'de>, - { - let bytes = String::deserialize(deserializer).and_then(|string| { - bytes_from_hex::(&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(deserializer: D) -> Result - where - D: serde::Deserializer<'de>, - { - let bytes = String::deserialize(deserializer) - .and_then(|string| bytes_from_hex::(&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, - /// Stable version response - pub stable: VersionResponse, - /// Beta version response - pub beta: Option, -} - -#[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, -} - -#[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, - /// Details for ARM64 installer - pub arm64: Option, -} - -#[derive(Deserialize)] -pub struct SpecificVersionArchitectureResponse { - /// Mirrors that host the artifact - pub urls: Vec, - /// Size of the installer, in bytes - pub size: usize, - /// Hash of the installer, hexadecimal string - 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/format/deserializer.rs b/mullvad-update/src/format/deserializer.rs new file mode 100644 index 000000000000..35eeb09e6679 --- /dev/null +++ b/mullvad-update/src/format/deserializer.rs @@ -0,0 +1,116 @@ +//! Deserializer and verifier of version metadata + +use anyhow::Context; + +use super::key::*; +use super::Response; +use super::{PartialSignedResponse, SignedResponse}; + +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::deserialize_and_verify_at_time(key, bytes, chrono::Utc::now()) + } + + /// This method is used for testing, and skips all verification. + /// Own method to prevent accidental misuse. + #[cfg(test)] + pub fn deserialize_and_verify_insecure(bytes: &[u8]) -> Result { + let partial_data: PartialSignedResponse = + serde_json::from_slice(bytes).context("Invalid version JSON")?; + let signed = serde_json::from_value(partial_data.signed) + .context("Failed to deserialize response")?; + Ok(Self { + signature: partial_data.signature, + signed, + }) + } + + /// 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, + ) -> Result { + // Deserialize and verify signature + let partial_data = deserialize_and_verify(&key, bytes)?; + + // Deserialize the canonical JSON to structured representation + let signed_response: Response = serde_json::from_value(partial_data.signed) + .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, + }) + } +} + +/// Deserialize arbitrary JSON object with a signature attached. +/// WARNING: This only verifies the signature, not expiration. +/// +/// On success, this returns verified data and signature +pub(super) fn deserialize_and_verify( + key: &VerifyingKey, + bytes: &[u8], +) -> anyhow::Result { + 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")?; + + Ok(PartialSignedResponse { + signature: partial_data.signature, + // Serialize back in case something was lost during deserialization + signed: serde_json::from_slice(&canon_data) + .context("Failed to serialize canonical JSON")?, + }) +} + +#[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/format/key.rs b/mullvad-update/src/format/key.rs new file mode 100644 index 000000000000..3fe308f903f6 --- /dev/null +++ b/mullvad-update/src/format/key.rs @@ -0,0 +1,184 @@ +//! Key and signature types for API version response format + +use std::str::FromStr; + +use anyhow::{bail, Context}; +use ed25519_dalek::ed25519::signature::SignerMut; +#[cfg(feature = "sign")] +use rand::RngCore; +use serde::{Deserialize, Serialize}; + +/// ed25519 secret/signing key +#[derive(Debug, Clone, PartialEq)] +pub struct SecretKey(pub ed25519_dalek::SecretKey); + +impl SecretKey { + /// Generate a new secret ed25519 key + #[cfg(feature = "sign")] + pub fn generate() -> Self { + // Using OsRng is suggested by the docs + let mut bytes = ed25519_dalek::SecretKey::default(); + rand::rngs::OsRng.fill_bytes(&mut bytes); + SecretKey(bytes) + } + + pub fn pubkey(&self) -> VerifyingKey { + let sign_key = ed25519_dalek::SigningKey::from_bytes(&self.0); + VerifyingKey(sign_key.verifying_key()) + } + + /// Sign data using this key + pub fn sign(&self, msg: &[u8]) -> Signature { + let mut secret = ed25519_dalek::SigningKey::from_bytes(&self.0); + Signature(secret.sign(msg)) + } +} + +impl ToString for SecretKey { + fn to_string(&self) -> String { + hex::encode(self.0) + } +} + +impl<'de> Deserialize<'de> for SecretKey { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let key = String::deserialize(deserializer)?; + let key = bytes_from_hex::<{ ed25519_dalek::SECRET_KEY_LENGTH }>(&key) + .map_err(|err| serde::de::Error::custom(err.to_string()))?; + Ok(SecretKey(key)) + } +} + +impl FromStr for SecretKey { + type Err = anyhow::Error; + + fn from_str(s: &str) -> Result { + let bytes = bytes_from_hex::<{ ed25519_dalek::SECRET_KEY_LENGTH }>(&s)?; + Ok(SecretKey(bytes)) + } +} + +impl Serialize for SecretKey { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(&hex::encode(self.0)) + } +} + +/// ed25519 verifying key +#[derive(Debug, PartialEq, Eq)] +pub struct VerifyingKey(pub ed25519_dalek::VerifyingKey); + +impl<'de> Deserialize<'de> for VerifyingKey { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let bytes = String::deserialize(deserializer)?; + let bytes = bytes_from_hex::<{ ed25519_dalek::PUBLIC_KEY_LENGTH }>(&bytes) + .map_err(|err| serde::de::Error::custom(err.to_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)) + } +} + +impl Serialize for VerifyingKey { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(&hex::encode(self.0.as_bytes())) + } +} + +/// ed25519 signature +pub struct Signature(pub ed25519_dalek::Signature); + +impl<'de> Deserialize<'de> for Signature { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let bytes = String::deserialize(deserializer)?; + let bytes = bytes_from_hex::<{ ed25519_dalek::SIGNATURE_LENGTH }>(&bytes) + .map_err(|err| serde::de::Error::custom(err.to_string()))?; + Ok(Signature(ed25519_dalek::Signature::from_bytes(&bytes))) + } +} + +impl Serialize for Signature { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(&hex::encode(self.0.to_bytes())) + } +} + +/// Deserialize a hex-encoded string to a bytes array of an exact size +fn bytes_from_hex(key: &str) -> anyhow::Result<[u8; SIZE]> { + let bytes = hex::decode(key).context("invalid hex")?; + if bytes.len() != SIZE { + bail!( + "hex-encoded string of {SIZE} bytes, found {} bytes", + bytes.len() + ); + } + let mut key = [0u8; SIZE]; + key.copy_from_slice(&bytes); + Ok(key) +} + +#[cfg(test)] +mod test { + use rand::RngCore; + + use super::*; + + #[test] + fn test_serialization_and_deserialization() { + let mut secret = [0u8; 32]; + rand::thread_rng().fill_bytes(&mut secret); + + let secret_hex = hex::encode(secret); + let secret = SecretKey::from_str(&hex::encode(secret)).unwrap(); + + let pubkey = secret.pubkey(); + let pubkey_hex = hex::encode(pubkey.0); + + // Test serialization + let actual = serde_json::json!({ + "secret": secret, + "key": pubkey, + }); + let expected: serde_json::Value = serde_json::from_str(&format!( + r#"{{ + "secret": "{secret_hex}", + "key": "{pubkey_hex}" + }}"# + )) + .unwrap(); + + assert_eq!(actual, expected); + + // Test deserialization + let secret_obj = actual.as_object().unwrap().get("secret").unwrap().clone(); + let deserialized_secret: SecretKey = serde_json::from_value(secret_obj).unwrap(); + + let pubkey_obj = actual.as_object().unwrap().get("key").unwrap().clone(); + let deserialized_pubkey: VerifyingKey = serde_json::from_value(pubkey_obj).unwrap(); + + assert_eq!(deserialized_secret, secret); + assert_eq!(deserialized_pubkey, pubkey); + } +} diff --git a/mullvad-update/src/format/mod.rs b/mullvad-update/src/format/mod.rs index 98428babf436..1c7bdb111448 100644 --- a/mullvad-update/src/format/mod.rs +++ b/mullvad-update/src/format/mod.rs @@ -16,7 +16,7 @@ pub struct SignedResponse { pub signed: Response, } -/// Helper class that leaves the signed data untouched +/// Helper type that leaves the signed data untouched /// Note that deserializing doesn't verify anything #[derive(Deserialize, Serialize)] struct PartialSignedResponse { diff --git a/mullvad-update/src/format/serializer.rs b/mullvad-update/src/format/serializer.rs new file mode 100644 index 000000000000..4c40037e2d2e --- /dev/null +++ b/mullvad-update/src/format/serializer.rs @@ -0,0 +1,97 @@ +//! Serializer for signed version response data +//! +//! Signing attaches a signature, and leaves the original JSON data in the "signed" key: +//! +//! ```ignore +//! { +//! "signature": { +//! "keyid": "...", +//! "sig": "..." +//! } +//! "signed": { +//! ... +//! } +//! } +//! ``` + +use anyhow::Context; +use serde::Serialize; + +use super::{key, PartialSignedResponse, Response, ResponseSignature, SignedResponse}; + +impl SignedResponse { + pub fn sign(key: key::SecretKey, response: Response) -> anyhow::Result { + // Refuse to sign expired data + if response.expires < chrono::Utc::now() { + anyhow::bail!("Signing failed since the data has expired"); + } + + // Sign it + let partial_signed = sign(&key, &response)?; + + // Attempt to deserialize signed part as response + // Probably unnecessary; mostly in case canonical JSON lost something + let response: Response = serde_json::from_value(partial_signed.signed)?; + + Ok(SignedResponse { + signature: partial_signed.signature, + signed: response, + }) + } +} + +/// Serialize JSON to bytes, with a signature attached, signed using `key` +fn sign( + key: &key::SecretKey, + unsigned_value: &T, +) -> anyhow::Result { + // Serialize unsigned data to canonical JSON + let unsigned_canon = + json_canon::to_vec(&unsigned_value).context("Failed to canonicalize JSON")?; + + // Generate signature for the canonical JSON + let sig = key.sign(&unsigned_canon); + + // Deserialize in case something was lost during serialization + let signed = + serde_json::from_slice(&unsigned_canon).context("Failed to deserialize canonical JSON")?; + + // Attach signature + Ok(PartialSignedResponse { + signature: ResponseSignature { + keyid: key.pubkey(), + sig, + }, + // Attach now-signed data + signed, + }) +} + +#[cfg(test)] +mod test { + use super::*; + use crate::format::deserializer::deserialize_and_verify; + use serde_json::json; + + #[test] + fn test_sign() -> anyhow::Result<()> { + // Generate key and data + let key = key::SecretKey::generate(); + let pubkey = key.pubkey(); + + let data = json!({ + "stuff": "I can prove that I wrote this" + }); + + // Verify that we can deserialize and verify the data + let partial = sign(&key, &data).context("Signing failed")?; + + assert_eq!(partial.signature.keyid, pubkey); + + let bytes = serde_json::to_vec(&partial)?; + + deserialize_and_verify(&pubkey, &bytes)?; + + Ok(()) + } +} diff --git a/mullvad-update/src/lib.rs b/mullvad-update/src/lib.rs index 08069710e075..27184e59d56f 100644 --- a/mullvad-update/src/lib.rs +++ b/mullvad-update/src/lib.rs @@ -2,6 +2,8 @@ pub mod api; pub mod app; -mod deserializer; pub mod fetch; pub mod verify; + +/// Parser and serializer for version metadata +pub mod format; From e2d70cf29cd11273fbf15d50c494e5c38dc20b3c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20L=C3=B6nnhager?= Date: Fri, 7 Feb 2025 17:08:10 +0100 Subject: [PATCH 005/112] Update test data --- mullvad-update/src/api.rs | 2 +- mullvad-update/src/format/deserializer.rs | 2 +- mullvad-update/test-version-response.json | 162 ++++++++++------------ 3 files changed, 73 insertions(+), 93 deletions(-) diff --git a/mullvad-update/src/api.rs b/mullvad-update/src/api.rs index c17973900c93..c2f5fefecc61 100644 --- a/mullvad-update/src/api.rs +++ b/mullvad-update/src/api.rs @@ -63,7 +63,7 @@ impl VersionInfoProvider for ApiVersionInfoProvider { use format::*; const TEST_PUBKEY: &str = - "AEC24A08466F3D6A1EDCDB2AD3C234428AB9D991B6BEA7F53CB9F172E6CB40D8"; + "f4c262705b4ae8088bc8173889f779f77563edfd7de3b0ac4aa0e554a6896404"; let pubkey = hex::decode(TEST_PUBKEY).unwrap(); let verifying_key = ed25519_dalek::VerifyingKey::from_bytes(&pubkey.try_into().unwrap()).unwrap(); diff --git a/mullvad-update/src/format/deserializer.rs b/mullvad-update/src/format/deserializer.rs index 35eeb09e6679..26014911176c 100644 --- a/mullvad-update/src/format/deserializer.rs +++ b/mullvad-update/src/format/deserializer.rs @@ -100,7 +100,7 @@ mod test { #[test] fn test_response_deserialization_and_verification() { const TEST_PUBKEY: &str = - "AEC24A08466F3D6A1EDCDB2AD3C234428AB9D991B6BEA7F53CB9F172E6CB40D8"; + "f4c262705b4ae8088bc8173889f779f77563edfd7de3b0ac4aa0e554a6896404"; let pubkey = hex::decode(TEST_PUBKEY).unwrap(); let verifying_key = ed25519_dalek::VerifyingKey::from_bytes(&pubkey.try_into().unwrap()).unwrap(); diff --git a/mullvad-update/test-version-response.json b/mullvad-update/test-version-response.json index 1dfd39770604..bfa17dc08d39 100644 --- a/mullvad-update/test-version-response.json +++ b/mullvad-update/test-version-response.json @@ -1,95 +1,75 @@ { - "signature": { - "keyid": "AEC24A08466F3D6A1EDCDB2AD3C234428AB9D991B6BEA7F53CB9F172E6CB40D8", - "sig": "d68ba75006ea3ac249e56849022a7d93603effe26ec0385bac42cf6675fc6e31322cae018a60428d5c670baedd46b59fa2b35a412f1ed285256c64dbafbcb905" + "signature": { + "keyid": "f4c262705b4ae8088bc8173889f779f77563edfd7de3b0ac4aa0e554a6896404", + "sig": "692ff231a4e12698b86c91c5c3e43dd8781db8022e7910ff5a0f640e7516ae55b1cef216b7e40774d3eae4c1b29129ce78587900779bec51ca51db274af0eb0d" + }, + "signed": { + "expires": "2025-07-02T15:33:00Z", + "stable": { + "current": { + "version": "2025.2", + "changelog": "[macos] Adding support for quicfuscator\n[windows] Less bugs", + "installers": { + "x86": { + "urls": [ + "https://releases.mullvad.net/desktop/releases/2025.2/MullvadVPN-2025.2.exe" + ], + "size": 101384672, + "sha256": "F4B25713D13F2819A300F2FFA94D967463AAEEA0D357FBD7479A281154BA0460" + }, + "arm64": { + "urls": [ + "https://releases.mullvad.net/desktop/releases/2025.2/MullvadVPN-2025.2_arm64.exe" + ], + "size": 104146312, + "sha256": "AFD8098A1FF89D69A243EC4E2E946CF5FBF8D1C10998230D6C8FC0A5C9C39541" + } + } + }, + "next": { + "rollout": 0.3, + "version": "2025.1", + "changelog": "[macos] Adding support for quicfuscator\n[windows] Less bugs", + "installers": { + "x86": { + "urls": [ + "https://releases.mullvad.net/desktop/releases/2025.2/MullvadVPN-2025.2.exe" + ], + "size": 101384672, + "sha256": "F4B25713D13F2819A300F2FFA94D967463AAEEA0D357FBD7479A281154BA0460" + }, + "arm64": { + "urls": [ + "https://releases.mullvad.net/desktop/releases/2025.2/MullvadVPN-2025.2_arm64.exe" + ], + "size": 104146312, + "sha256": "AFD8098A1FF89D69A243EC4E2E946CF5FBF8D1C10998230D6C8FC0A5C9C39541" + } + } + } }, - "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" - } - } - } + "beta": { + "current": { + "version": "2025.1-beta1", + "changelog": "[macos] Adding support for quicfuscator\n[windows] Less bugs", + "installers": { + "x86": { + "urls": [ + "https://releases.mullvad.net/desktop/releases/2025.3-beta1/MullvadVPN-2025.3-beta1_x64.exe" + ], + "size": 106297504, + "sha256": "0c569aa0912eb93605a85073447d42ba0ca612361bef78ef04ef038e80b15403" + }, + "arm64": { + "urls": [ + "https://releases.mullvad.net/desktop/releases/2025.3-beta1/MullvadVPN-2025.3-beta1_arm64.exe" + ], + "size": 111488248, + "sha256": "82948D3DB5B869EE5F0D246DB557A81B72B68DFDDD2267872B7B8A5B19A05444" + } } + }, + "next": null } -} \ No newline at end of file + } +} From fc22b7cab4ff695c49eedc8e103a3ec822e9b9e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20L=C3=B6nnhager?= Date: Sat, 8 Feb 2025 00:24:02 +0100 Subject: [PATCH 006/112] Add build script for installer-downloader --- installer-downloader/build.sh | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 installer-downloader/build.sh diff --git a/installer-downloader/build.sh b/installer-downloader/build.sh new file mode 100644 index 000000000000..eb9b87e91f49 --- /dev/null +++ b/installer-downloader/build.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash + +# This script is used to build, and optionally sign the downloader, always in release mode. + +# This script performs the equivalent of the following profile: +# +# [profile.release] +# strip = true +# opt-level = 'z' +# codegen-units = 1 +# lto = true +# panic = 'abort' +# +# We cannot set all of the above directly in Cargo.toml since some must be set for the entire +# workspace. + +set -eu + +SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" +cd "$SCRIPT_DIR" + +# shellcheck disable=SC1091 +source ../scripts/utils/host +# shellcheck disable=SC1091 +source ../scripts/utils/log + +RUSTFLAGS="-C codegen-units=1 -C panic=abort -C strip=symbols -C opt-level=z" \ + cargo build --bin installer-downloader --release From 951d907dc72ba1ad4d37044b431cc833c773ad80 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20L=C3=B6nnhager?= Date: Sat, 8 Feb 2025 00:24:23 +0100 Subject: [PATCH 007/112] Add GHA workflow for regression testing installer-downloader size --- .github/actions/check-file-size/action.yml | 32 +++++++++ .github/workflows/downloader.yml | 80 ++++++++++++++++++++++ 2 files changed, 112 insertions(+) create mode 100644 .github/actions/check-file-size/action.yml create mode 100644 .github/workflows/downloader.yml diff --git a/.github/actions/check-file-size/action.yml b/.github/actions/check-file-size/action.yml new file mode 100644 index 000000000000..cc40e2709a19 --- /dev/null +++ b/.github/actions/check-file-size/action.yml @@ -0,0 +1,32 @@ +name: "Check file size" +description: "Fails a file exceeds a given size limit" +inputs: + artifact: + description: "Path to the file" + required: true + max_size: + description: "Maximum allowed size in bytes" + required: true +runs: + using: "composite" + steps: + - name: Check file size + shell: bash + run: | + if [ -f "${{ inputs.artifact }}" ]; then + if [ "$(uname)" = "Darwin" ]; then + SIZE=$(stat -f %z "${{ inputs.artifact }}") + else + SIZE=$(stat -c %s "${{ inputs.artifact }}") + fi + echo "File size: $SIZE bytes" + echo "Size limit: ${{ inputs.max_size }} bytes" + + if [ "$SIZE" -gt "${{ inputs.max_size }}" ]; then + echo "Error: Binary size exceeds limit." + exit 1 + fi + else + echo "Error: File not found!" + exit 1 + fi diff --git a/.github/workflows/downloader.yml b/.github/workflows/downloader.yml new file mode 100644 index 000000000000..ae8aabd4a8b9 --- /dev/null +++ b/.github/workflows/downloader.yml @@ -0,0 +1,80 @@ +--- +name: Installer downloader - Size test +on: + pull_request: + paths: + - '**' + - '!**/**.md' + - '!.github/workflows/**' + - '.github/workflows/downloader.yml' + - '!.github/CODEOWNERS' + - '!android/**' + - '!audits/**' + - '!build.sh' + - '!ci/**' + - '!clippy.toml' + - '!deny.toml' + - '!rustfmt.toml' + - '!.yamllint' + - '!docs/**' + - '!graphics/**' + - '!desktop/**' + - '!ios/**' + - '!scripts/**' + - '!.*ignore' + - '!prepare-release.sh' + - '!**/osv-scanner.toml' + +permissions: {} + +jobs: + build-windows: + strategy: + matrix: + config: + - os: windows-latest + arch: x64 + runs-on: ${{ matrix.config.os }} + env: + # If the file is larger than this, a regression has probably been introduced. + # You should think twice before increasing this limit. + MAX_BINARY_SIZE: 1572864 + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Build + shell: bash + env: + # On Windows, the checkout is on the D drive, which is very small. + # Moving the target directory to the C drive ensures that the runner + # doesn't run out of space on the D drive. + CARGO_TARGET_DIR: "C:/cargo-target" + run: ./installer-downloader/build.sh + + - name: Check file size + uses: ./.github/actions/check-file-size + with: + artifact: "C:/cargo-target/release/installer-downloader.exe" + max_size: ${{ env.MAX_BINARY_SIZE }} + + build-macos: + runs-on: macos-latest + env: + # TODO: Figure out a reasonable limit for macOS + # If the file is larger than this, a regression has probably been introduced. + # You should think twice before increasing this limit. + MAX_BINARY_SIZE: 2097152 + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Build + shell: bash + run: ./installer-downloader/build.sh + + - name: Check file size + uses: ./.github/actions/check-file-size + with: + artifact: "./target/release/installer-downloader" + max_size: ${{ env.MAX_BINARY_SIZE }} From 5542403cdb3687f4f7afd4ed569be208334c10b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20L=C3=B6nnhager?= Date: Sat, 8 Feb 2025 22:47:20 +0100 Subject: [PATCH 008/112] Remove signature download step --- installer-downloader/src/ui_downloader.rs | 13 ------------- installer-downloader/tests/controller.rs | 4 ---- mullvad-update/src/app.rs | 11 +---------- 3 files changed, 1 insertion(+), 27 deletions(-) diff --git a/installer-downloader/src/ui_downloader.rs b/installer-downloader/src/ui_downloader.rs index 18eeb0074d6e..67fa9322d25c 100644 --- a/installer-downloader/src/ui_downloader.rs +++ b/installer-downloader/src/ui_downloader.rs @@ -37,19 +37,6 @@ impl impl AppDownloader for UiAppDownloader { - 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(()) => { diff --git a/installer-downloader/tests/controller.rs b/installer-downloader/tests/controller.rs index 5f227bbdb279..65765123754c 100644 --- a/installer-downloader/tests/controller.rs +++ b/installer-downloader/tests/controller.rs @@ -65,10 +65,6 @@ pub struct FakeAppDownloader AppDownloader for FakeAppDownloader { - async fn download_signature(&mut self) -> Result<(), DownloadError> { - Ok(()) - } - 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.); diff --git a/mullvad-update/src/app.rs b/mullvad-update/src/app.rs index ef08971bb021..c6cfb1eb4558 100644 --- a/mullvad-update/src/app.rs +++ b/mullvad-update/src/app.rs @@ -1,4 +1,4 @@ -//! This module implements the flow of downloading and verifying the app signature. +//! This module implements the flow of downloading and verifying the app. use std::path::PathBuf; @@ -26,9 +26,6 @@ pub struct AppDownloaderParameters { /// 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>; @@ -38,7 +35,6 @@ pub trait AppDownloader: Send { /// 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 } @@ -67,11 +63,6 @@ impl From> #[async_trait::async_trait] impl AppDownloader for HttpAppDownloader { - async fn download_signature(&mut self) -> Result<(), DownloadError> { - // TODO: no-op, remove - Ok(()) - } - async fn download_executable(&mut self) -> Result<(), DownloadError> { fetch::get_to_file( self.bin_path(), From cb86b0374acaf6a196e5ae19980bf1311751da1d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20L=C3=B6nnhager?= Date: Sat, 8 Feb 2025 23:05:50 +0100 Subject: [PATCH 009/112] Add documentation to mullvad-update format module --- mullvad-update/src/format/mod.rs | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/mullvad-update/src/format/mod.rs b/mullvad-update/src/format/mod.rs index 1c7bdb111448..494bd4ec7430 100644 --- a/mullvad-update/src/format/mod.rs +++ b/mullvad-update/src/format/mod.rs @@ -1,3 +1,16 @@ +//! This module includes all that is needed for the (de)serialization of Mullvad version metadata. +//! This includes ensuring authenticity and integrity of version metadata, and rejecting expired +//! metadata. There are also tools for producing new versions. +//! +//! Fundamentally, a version object is a JSON object with a `signed` key and a `signature` key. +//! `signature` contains a public key and an ed25519 signature of `signed` in canonical JSON form. +//! `signed` also contains an `expires` field, which is a timestamp indicating when the object +//! expires. +//! +//! For [deserializer] to succeed in deserializing a file, it must verify that the canonicalized +//! form of `signed` is in fact signed by key/signature in `signature`. It also reads the `expires` +//! and rejects the file if it has expired. + use serde::{Deserialize, Serialize}; pub mod deserializer; From 504bb2a2c6ffbad4523a38990fc834541b0826ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20L=C3=B6nnhager?= Date: Sat, 8 Feb 2025 23:14:39 +0100 Subject: [PATCH 010/112] Test expired metadata --- mullvad-update/src/format/deserializer.rs | 11 +++++++++++ mullvad-update/src/format/key.rs | 1 + mullvad-update/src/format/mod.rs | 16 ++++++++-------- 3 files changed, 20 insertions(+), 8 deletions(-) diff --git a/mullvad-update/src/format/deserializer.rs b/mullvad-update/src/format/deserializer.rs index 26014911176c..2647bbe21299 100644 --- a/mullvad-update/src/format/deserializer.rs +++ b/mullvad-update/src/format/deserializer.rs @@ -94,6 +94,8 @@ pub(super) fn deserialize_and_verify( #[cfg(test)] mod test { + use std::str::FromStr; + use super::*; /// Test that a valid signed version response is successfully deserialized and verified @@ -112,5 +114,14 @@ mod test { chrono::DateTime::UNIX_EPOCH, ) .expect("expected valid signed version metadata"); + + // Reject expired data + SignedResponse::deserialize_and_verify_at_time( + VerifyingKey(verifying_key), + include_bytes!("../../test-version-response.json"), + // In the year 3000 + chrono::DateTime::from_str("3000-01-01T00:00:00Z").unwrap(), + ) + .expect_err("expected expired version metadata"); } } diff --git a/mullvad-update/src/format/key.rs b/mullvad-update/src/format/key.rs index 3fe308f903f6..b0eed5b4cb09 100644 --- a/mullvad-update/src/format/key.rs +++ b/mullvad-update/src/format/key.rs @@ -102,6 +102,7 @@ impl Serialize for VerifyingKey { } /// ed25519 signature +#[derive(Debug)] pub struct Signature(pub ed25519_dalek::Signature); impl<'de> Deserialize<'de> for Signature { diff --git a/mullvad-update/src/format/mod.rs b/mullvad-update/src/format/mod.rs index 494bd4ec7430..d1957f42cab4 100644 --- a/mullvad-update/src/format/mod.rs +++ b/mullvad-update/src/format/mod.rs @@ -21,7 +21,7 @@ pub mod serializer; /// JSON response including signature and signed content /// This type does not implement [serde::Deserialize] to prevent accidental deserialization without /// signature verification. -#[derive(Serialize)] +#[derive(Debug, Serialize)] pub struct SignedResponse { /// Signature of the canonicalized JSON of `signed` pub signature: ResponseSignature, @@ -40,7 +40,7 @@ struct PartialSignedResponse { } /// Signed JSON response, not including the signature -#[derive(Deserialize, Serialize)] +#[derive(Debug, Deserialize, Serialize)] #[serde(deny_unknown_fields)] pub struct Response { /// When the signature expires @@ -51,7 +51,7 @@ pub struct Response { pub beta: Option, } -#[derive(Deserialize, Serialize)] +#[derive(Debug, Deserialize, Serialize)] pub struct VersionResponse { /// The current version in this channel pub current: SpecificVersionResponse, @@ -59,7 +59,7 @@ pub struct VersionResponse { pub next: Option, } -#[derive(Deserialize, Serialize)] +#[derive(Debug, Deserialize, Serialize)] pub struct NextSpecificVersionResponse { /// The percentage of users that should receive the new version. pub rollout: f32, @@ -67,7 +67,7 @@ pub struct NextSpecificVersionResponse { pub version: SpecificVersionResponse, } -#[derive(Deserialize, Serialize)] +#[derive(Debug, Deserialize, Serialize)] pub struct SpecificVersionResponse { /// Mullvad app version pub version: mullvad_version::Version, @@ -78,7 +78,7 @@ pub struct SpecificVersionResponse { } /// Version details for supported architectures -#[derive(Deserialize, Serialize)] +#[derive(Debug, Deserialize, Serialize)] pub struct SpecificVersionArchitectureResponses { /// Details for x86 installer pub x86: Option, @@ -86,7 +86,7 @@ pub struct SpecificVersionArchitectureResponses { pub arm64: Option, } -#[derive(Deserialize, Serialize)] +#[derive(Debug, Deserialize, Serialize)] pub struct SpecificVersionArchitectureResponse { /// Mirrors that host the artifact pub urls: Vec, @@ -97,7 +97,7 @@ pub struct SpecificVersionArchitectureResponse { } /// JSON response signature -#[derive(Deserialize, Serialize)] +#[derive(Debug, Deserialize, Serialize)] pub struct ResponseSignature { pub keyid: key::VerifyingKey, pub sig: key::Signature, From b2f54826a9b2951ecf56c7794f341124b0220762 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20L=C3=B6nnhager?= Date: Wed, 12 Feb 2025 21:01:17 +0100 Subject: [PATCH 011/112] Update version response format --- mullvad-update/src/api.rs | 149 ++++++++++++++-------- mullvad-update/src/format/deserializer.rs | 2 +- mullvad-update/src/format/mod.rs | 57 ++++----- mullvad-update/test-version-response.json | 57 +++++---- 4 files changed, 151 insertions(+), 114 deletions(-) diff --git a/mullvad-update/src/api.rs b/mullvad-update/src/api.rs index c2f5fefecc61..253c4fc9226f 100644 --- a/mullvad-update/src/api.rs +++ b/mullvad-update/src/api.rs @@ -13,14 +13,8 @@ pub struct VersionParameters { pub rollout: f32, } -/// Architecture to retrieve data for -#[derive(Debug, Clone, Copy)] -pub enum VersionArchitecture { - /// x86-64 architecture - X86, - /// ARM64 architecture - Arm64, -} +/// Installer architecture +pub type VersionArchitecture = format::Architecture; /// See [module-level](self) docs. #[async_trait::async_trait] @@ -34,7 +28,8 @@ pub trait VersionInfoProvider { pub struct VersionInfo { /// Stable version info pub stable: Version, - /// Beta version info + /// Beta version info (if available and newer than `stable`). + /// If latest stable version is newer, this will be `None`. pub beta: Option, } @@ -63,7 +58,7 @@ impl VersionInfoProvider for ApiVersionInfoProvider { use format::*; const TEST_PUBKEY: &str = - "f4c262705b4ae8088bc8173889f779f77563edfd7de3b0ac4aa0e554a6896404"; + "4d35f5376f1f58c41b2a0ee4600ae7811eace354f100227e853994deef38942d"; let pubkey = hex::decode(TEST_PUBKEY).unwrap(); let verifying_key = ed25519_dalek::VerifyingKey::from_bytes(&pubkey.try_into().unwrap()).unwrap(); @@ -73,68 +68,110 @@ impl VersionInfoProvider for ApiVersionInfoProvider { include_bytes!("../test-version-response.json"), )?; - VersionInfo::try_from_signed_response(¶ms, response) + VersionInfo::try_from_response(¶ms, response.signed) } } +/// Helper used to lift the relevant installer out of the array in [format::Release] +#[derive(Clone)] +struct IntermediateVersion { + version: mullvad_version::Version, + changelog: String, + installer: format::Installer, +} + impl VersionInfo { /// Convert signed response data to public version type /// NOTE: `response` is assumed to be verified and untampered. It is not verified. - fn try_from_signed_response( + fn try_from_response( params: &VersionParameters, - response: format::SignedResponse, + response: format::Response, ) -> anyhow::Result { - let stable = Version::try_from_signed_response(params, response.signed.stable)?; - let beta = response - .signed - .beta - .map(|response| Version::try_from_signed_response(params, response)) - .transpose() - .context("Failed to parse beta version")?; - - Ok(Self { stable, beta }) - } -} - -impl Version { - /// Convert response data to public version type - fn try_from_signed_response( - params: &VersionParameters, - response: format::VersionResponse, - ) -> anyhow::Result { - // Check if the rollout version is acceptable according to threshold - if let Some(next) = response.next { - if next.rollout >= params.rollout { - // Use the version being rolled out - return Self::try_for_arch(params, next.version); - } + let mut releases: Vec<_> = response + .releases + .into_iter() + // Filter out releases that are not rolled out to us + .filter(|release| release.rollout >= params.rollout) + // Include only installers for the requested architecture + .flat_map(|release| { + release + .installers + .into_iter() + .filter(|installer| params.architecture == installer.architecture) + // Map each artifact to a [IntermediateVersion] + .map(move |installer| { + IntermediateVersion { + version: release.version.clone(), + changelog: release.changelog.clone(), + installer, + } + }) + }) + .collect(); + + // Sort releases by version + releases.sort_by(|a, b| mullvad_version::Version::version_ordering(&a.version, &b.version)); + + // Fail if there are duplicate versions + // Important! This must occur after sorting + if let Some(dup_version) = Self::find_duplicate_version(&releases) { + anyhow::bail!("API response contains at least one duplicated version: {dup_version}"); } - // Return the version not being rolled out - Self::try_for_arch(params, response.current) + // Find latest stable version + let stable = releases + .iter() + .rfind(|release| release.version.is_stable() && !release.version.is_dev()); + let Some(stable) = stable.cloned() else { + anyhow::bail!("No stable version found"); + }; + + // Find the latest beta version + let beta = releases + .iter() + // Find most recent beta version + .rfind(|release| release.version.beta().is_some() && !release.version.is_dev()) + // If the latest beta version is older than latest stable, dispose of it + .filter(|release| release.version.version_ordering(&stable.version).is_gt()) + .cloned(); + + Ok(Self { + stable: Version::try_from(stable)?, + beta: beta.map(|beta| Version::try_from(beta)).transpose()?, + }) } - /// Convert version response to the public version type for a given architecture - /// This may fail if the current architecture isn't included in the response - fn try_for_arch( - params: &VersionParameters, - response: format::SpecificVersionResponse, - ) -> anyhow::Result { - let installer = match params.architecture { - VersionArchitecture::X86 => response.installers.x86, - VersionArchitecture::Arm64 => response.installers.arm64, - }; - let installer = installer.context("Installer missing for architecture")?; - let sha256 = hex::decode(installer.sha256) + /// Returns the first duplicated version found in `releases`. + /// `None` is returned if there are no duplicates. + /// NOTE: `releases` MUST be sorted + fn find_duplicate_version( + releases: &[IntermediateVersion], + ) -> Option<&mullvad_version::Version> { + releases + .windows(2) + .find(|pair| { + mullvad_version::Version::version_ordering(&pair[0].version, &pair[1].version) + .is_eq() + }) + .map(|pair| &pair[0].version) + } +} + +impl TryFrom for Version { + type Error = anyhow::Error; + + fn try_from(version: IntermediateVersion) -> Result { + // Convert hex checksum to bytes + let sha256 = hex::decode(version.installer.sha256) .context("Invalid checksum hex")? .try_into() .map_err(|_| anyhow::anyhow!("Invalid checksum length"))?; - Ok(Self { - changelog: response.changelog, - version: response.version, - urls: installer.urls, - size: installer.size, + Ok(Version { + version: version.version, + size: version.installer.size, + urls: version.installer.urls, + changelog: version.changelog, sha256, }) } diff --git a/mullvad-update/src/format/deserializer.rs b/mullvad-update/src/format/deserializer.rs index 2647bbe21299..6c86f7c01b43 100644 --- a/mullvad-update/src/format/deserializer.rs +++ b/mullvad-update/src/format/deserializer.rs @@ -102,7 +102,7 @@ mod test { #[test] fn test_response_deserialization_and_verification() { const TEST_PUBKEY: &str = - "f4c262705b4ae8088bc8173889f779f77563edfd7de3b0ac4aa0e554a6896404"; + "4d35f5376f1f58c41b2a0ee4600ae7811eace354f100227e853994deef38942d"; let pubkey = hex::decode(TEST_PUBKEY).unwrap(); let verifying_key = ed25519_dalek::VerifyingKey::from_bytes(&pubkey.try_into().unwrap()).unwrap(); diff --git a/mullvad-update/src/format/mod.rs b/mullvad-update/src/format/mod.rs index d1957f42cab4..01e4618ddf52 100644 --- a/mullvad-update/src/format/mod.rs +++ b/mullvad-update/src/format/mod.rs @@ -45,49 +45,34 @@ struct PartialSignedResponse { pub struct Response { /// When the signature expires pub expires: chrono::DateTime, - /// Stable version response - pub stable: VersionResponse, - /// Beta version response - pub beta: Option, + /// Available app releases + pub releases: Vec, } +/// App release #[derive(Debug, Deserialize, Serialize)] -pub struct VersionResponse { - /// The current version in this channel - pub current: SpecificVersionResponse, - /// The version being rolled out in this channel - pub next: Option, -} - -#[derive(Debug, Deserialize, Serialize)] -pub struct NextSpecificVersionResponse { - /// The percentage of users that should receive the new version. - pub rollout: f32, - #[serde(flatten)] - pub version: SpecificVersionResponse, -} - -#[derive(Debug, Deserialize, Serialize)] -pub struct SpecificVersionResponse { +pub struct Release { /// Mullvad app version pub version: mullvad_version::Version, /// Changelog entries pub changelog: String, /// Installer details for different architectures - pub installers: SpecificVersionArchitectureResponses, + pub installers: Vec, + /// Fraction of users that should receive the new version + #[serde(default = "default_rollout")] + pub rollout: f32, } -/// Version details for supported architectures -#[derive(Debug, Deserialize, Serialize)] -pub struct SpecificVersionArchitectureResponses { - /// Details for x86 installer - pub x86: Option, - /// Details for ARM64 installer - pub arm64: Option, +/// By default, rollout includes all users +fn default_rollout() -> f32 { + 1. } -#[derive(Debug, Deserialize, Serialize)] -pub struct SpecificVersionArchitectureResponse { +/// App installer +#[derive(Debug, Deserialize, Serialize, Clone)] +pub struct Installer { + /// Installer architecture + pub architecture: Architecture, /// Mirrors that host the artifact pub urls: Vec, /// Size of the installer, in bytes @@ -96,6 +81,16 @@ pub struct SpecificVersionArchitectureResponse { pub sha256: String, } +/// Installer architecture +#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum Architecture { + /// x86-64 architecture + X86, + /// ARM64 architecture + Arm64, +} + /// JSON response signature #[derive(Debug, Deserialize, Serialize)] pub struct ResponseSignature { diff --git a/mullvad-update/test-version-response.json b/mullvad-update/test-version-response.json index bfa17dc08d39..bc5a89adc177 100644 --- a/mullvad-update/test-version-response.json +++ b/mullvad-update/test-version-response.json @@ -1,75 +1,80 @@ { "signature": { - "keyid": "f4c262705b4ae8088bc8173889f779f77563edfd7de3b0ac4aa0e554a6896404", - "sig": "692ff231a4e12698b86c91c5c3e43dd8781db8022e7910ff5a0f640e7516ae55b1cef216b7e40774d3eae4c1b29129ce78587900779bec51ca51db274af0eb0d" + "keyid": "4d35f5376f1f58c41b2a0ee4600ae7811eace354f100227e853994deef38942d", + "sig": "0537b9fc1f458592a3330e369ef521b2e29dacad2a422cbae37ff7ddd400a5381b063c5f3056e9e3db6235d128128d95b7c54bf305eb2f3bd250d722baa2a504" }, "signed": { "expires": "2025-07-02T15:33:00Z", - "stable": { - "current": { + "releases": [ + { "version": "2025.2", "changelog": "[macos] Adding support for quicfuscator\n[windows] Less bugs", - "installers": { - "x86": { + "installers": [ + { + "architecture": "x86", "urls": [ "https://releases.mullvad.net/desktop/releases/2025.2/MullvadVPN-2025.2.exe" ], "size": 101384672, "sha256": "F4B25713D13F2819A300F2FFA94D967463AAEEA0D357FBD7479A281154BA0460" }, - "arm64": { + { + "architecture": "arm64", "urls": [ "https://releases.mullvad.net/desktop/releases/2025.2/MullvadVPN-2025.2_arm64.exe" ], "size": 104146312, "sha256": "AFD8098A1FF89D69A243EC4E2E946CF5FBF8D1C10998230D6C8FC0A5C9C39541" } - } + ], + "rollout": 1.0 }, - "next": { - "rollout": 0.3, - "version": "2025.1", + { + "version": "2025.3", "changelog": "[macos] Adding support for quicfuscator\n[windows] Less bugs", - "installers": { - "x86": { + "installers": [ + { + "architecture": "x86", "urls": [ "https://releases.mullvad.net/desktop/releases/2025.2/MullvadVPN-2025.2.exe" ], "size": 101384672, "sha256": "F4B25713D13F2819A300F2FFA94D967463AAEEA0D357FBD7479A281154BA0460" }, - "arm64": { + { + "architecture": "arm64", "urls": [ "https://releases.mullvad.net/desktop/releases/2025.2/MullvadVPN-2025.2_arm64.exe" ], "size": 104146312, "sha256": "AFD8098A1FF89D69A243EC4E2E946CF5FBF8D1C10998230D6C8FC0A5C9C39541" } - } - } - }, - "beta": { - "current": { + ], + "rollout": 0.3 + }, + { "version": "2025.1-beta1", "changelog": "[macos] Adding support for quicfuscator\n[windows] Less bugs", - "installers": { - "x86": { + "installers": [ + { + "architecture": "x86", "urls": [ "https://releases.mullvad.net/desktop/releases/2025.3-beta1/MullvadVPN-2025.3-beta1_x64.exe" ], "size": 106297504, "sha256": "0c569aa0912eb93605a85073447d42ba0ca612361bef78ef04ef038e80b15403" }, - "arm64": { + { + "architecture": "arm64", "urls": [ "https://releases.mullvad.net/desktop/releases/2025.3-beta1/MullvadVPN-2025.3-beta1_arm64.exe" ], "size": 111488248, "sha256": "82948D3DB5B869EE5F0D246DB557A81B72B68DFDDD2267872B7B8A5B19A05444" } - } - }, - "next": null - } + ], + "rollout": 1.0 + } + ] } } From 0d789aa30a2f21217d5f0894c3056f2c9923b5c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20L=C3=B6nnhager?= Date: Wed, 12 Feb 2025 21:50:46 +0100 Subject: [PATCH 012/112] Add improved API response parse tests --- Cargo.lock | 1 + Cargo.toml | 1 + installer-downloader/Cargo.toml | 2 +- mullvad-update/Cargo.toml | 1 + mullvad-update/src/api.rs | 37 ++++++++- mullvad-update/src/format/mod.rs | 2 + ...pi_version_info_provider_parser_arm64.snap | 45 ++++++++++ ..._api_version_info_provider_parser_x86.snap | 83 +++++++++++++++++++ mullvad-update/test-version-response.json | 25 +++++- 9 files changed, 192 insertions(+), 5 deletions(-) create mode 100644 mullvad-update/src/snapshots/mullvad_update__api__test__api_version_info_provider_parser_arm64.snap create mode 100644 mullvad-update/src/snapshots/mullvad_update__api__test__api_version_info_provider_parser_x86.snap diff --git a/Cargo.lock b/Cargo.lock index e7416e77799d..81108146fbcb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2993,6 +2993,7 @@ dependencies = [ "clap", "ed25519-dalek", "hex", + "insta", "json-canon", "mockito", "mullvad-version", diff --git a/Cargo.toml b/Cargo.toml index 17e39209610c..b85092b02713 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -122,6 +122,7 @@ socket2 = "0.5.7" # Test dependencies proptest = "1.4" +insta = { version = "1.42", features = ["yaml"] } [profile.release] opt-level = "s" diff --git a/installer-downloader/Cargo.toml b/installer-downloader/Cargo.toml index 25799200d3c6..be2c830c2d26 100644 --- a/installer-downloader/Cargo.toml +++ b/installer-downloader/Cargo.toml @@ -34,7 +34,7 @@ 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"] } +insta = { workspace = true, features = ["yaml"] } [package.metadata.winres] LegalCopyright = "(c) 2025 Mullvad VPN AB" diff --git a/mullvad-update/Cargo.toml b/mullvad-update/Cargo.toml index 9ad66787d4b6..20b561776a74 100644 --- a/mullvad-update/Cargo.toml +++ b/mullvad-update/Cargo.toml @@ -35,6 +35,7 @@ rand = { version = "0.8.5", optional = true } [dev-dependencies] async-tempfile = "0.6" +insta = { workspace = true } mockito = "1.6.1" rand = "0.8.5" diff --git a/mullvad-update/src/api.rs b/mullvad-update/src/api.rs index 253c4fc9226f..bcb5dd951a60 100644 --- a/mullvad-update/src/api.rs +++ b/mullvad-update/src/api.rs @@ -25,6 +25,7 @@ pub trait VersionInfoProvider { /// Contains information about all versions #[derive(Debug, Clone)] +#[cfg_attr(test, derive(serde::Serialize))] pub struct VersionInfo { /// Stable version info pub stable: Version, @@ -35,6 +36,7 @@ pub struct VersionInfo { /// Contains information about a version for the current target #[derive(Debug, Clone)] +#[cfg_attr(test, derive(serde::Serialize))] pub struct Version { /// Version pub version: mullvad_version::Version, @@ -179,11 +181,17 @@ impl TryFrom for Version { #[cfg(test)] mod test { + use insta::assert_yaml_snapshot; + use super::*; - /// Test API version responses can be parsed + // These 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`. + + /// Test parsing of API responses (rollout 1, x86) #[test] - fn test_api_version_info_provider_parser() -> anyhow::Result<()> { + fn test_api_version_info_provider_parser_x86() -> anyhow::Result<()> { let response = format::SignedResponse::deserialize_and_verify_insecure(include_bytes!( "../test-version-response.json" ))?; @@ -193,7 +201,30 @@ mod test { rollout: 1., }; - VersionInfo::try_from_signed_response(¶ms, response)?; + // Expect: The available latest versions for X86, where the rollout is 1. + let info = VersionInfo::try_from_response(¶ms, response.signed.clone())?; + + assert_yaml_snapshot!(info); + + Ok(()) + } + + /// Test parsing of API responses (rollout 0.01, arm64) + #[test] + fn test_api_version_info_provider_parser_arm64() -> anyhow::Result<()> { + let response = format::SignedResponse::deserialize_and_verify_insecure(include_bytes!( + "../test-version-response.json" + ))?; + + let params = VersionParameters { + architecture: VersionArchitecture::Arm64, + rollout: 0.01, + }; + + let info = VersionInfo::try_from_response(¶ms, response.signed)?; + + // Expect: The available latest versions for arm64, where the rollout is .01. + assert_yaml_snapshot!(info); Ok(()) } diff --git a/mullvad-update/src/format/mod.rs b/mullvad-update/src/format/mod.rs index 01e4618ddf52..61fac6a8c257 100644 --- a/mullvad-update/src/format/mod.rs +++ b/mullvad-update/src/format/mod.rs @@ -42,6 +42,7 @@ struct PartialSignedResponse { /// Signed JSON response, not including the signature #[derive(Debug, Deserialize, Serialize)] #[serde(deny_unknown_fields)] +#[cfg_attr(test, derive(Clone))] pub struct Response { /// When the signature expires pub expires: chrono::DateTime, @@ -51,6 +52,7 @@ pub struct Response { /// App release #[derive(Debug, Deserialize, Serialize)] +#[cfg_attr(test, derive(Clone))] pub struct Release { /// Mullvad app version pub version: mullvad_version::Version, diff --git a/mullvad-update/src/snapshots/mullvad_update__api__test__api_version_info_provider_parser_arm64.snap b/mullvad-update/src/snapshots/mullvad_update__api__test__api_version_info_provider_parser_arm64.snap new file mode 100644 index 000000000000..91e6e9038930 --- /dev/null +++ b/mullvad-update/src/snapshots/mullvad_update__api__test__api_version_info_provider_parser_arm64.snap @@ -0,0 +1,45 @@ +--- +source: mullvad-update/src/api.rs +expression: info +snapshot_kind: text +--- +stable: + version: "2025.3" + urls: + - "https://releases.mullvad.net/desktop/releases/2025.2/MullvadVPN-2025.2_arm64.exe" + size: 104146312 + changelog: "[macos] Adding support for quicfuscator\n[windows] Less bugs" + sha256: + - 175 + - 216 + - 9 + - 138 + - 31 + - 248 + - 157 + - 105 + - 162 + - 67 + - 236 + - 78 + - 46 + - 148 + - 108 + - 245 + - 251 + - 248 + - 209 + - 193 + - 9 + - 152 + - 35 + - 13 + - 108 + - 143 + - 192 + - 165 + - 201 + - 195 + - 149 + - 65 +beta: ~ diff --git a/mullvad-update/src/snapshots/mullvad_update__api__test__api_version_info_provider_parser_x86.snap b/mullvad-update/src/snapshots/mullvad_update__api__test__api_version_info_provider_parser_x86.snap new file mode 100644 index 000000000000..1cb23ff5e50c --- /dev/null +++ b/mullvad-update/src/snapshots/mullvad_update__api__test__api_version_info_provider_parser_x86.snap @@ -0,0 +1,83 @@ +--- +source: mullvad-update/src/api.rs +expression: info +snapshot_kind: text +--- +stable: + version: "2025.2" + urls: + - "https://releases.mullvad.net/desktop/releases/2025.2/MullvadVPN-2025.2.exe" + size: 101384672 + changelog: "[macos] Adding support for quicfuscator\n[windows] Less bugs" + sha256: + - 244 + - 178 + - 87 + - 19 + - 209 + - 63 + - 40 + - 25 + - 163 + - 0 + - 242 + - 255 + - 169 + - 77 + - 150 + - 116 + - 99 + - 170 + - 238 + - 160 + - 211 + - 87 + - 251 + - 215 + - 71 + - 154 + - 40 + - 17 + - 84 + - 186 + - 4 + - 96 +beta: + version: 2025.3-beta1 + urls: + - "https://releases.mullvad.net/desktop/releases/2025.3-beta1/MullvadVPN-2025.3-beta1_x64.exe" + size: 106297504 + changelog: "[macos] Adding support for quicfuscator\n[windows] Less bugs" + sha256: + - 12 + - 86 + - 154 + - 160 + - 145 + - 46 + - 185 + - 54 + - 5 + - 168 + - 80 + - 115 + - 68 + - 125 + - 66 + - 186 + - 12 + - 166 + - 18 + - 54 + - 27 + - 239 + - 120 + - 239 + - 4 + - 239 + - 3 + - 142 + - 128 + - 177 + - 84 + - 3 diff --git a/mullvad-update/test-version-response.json b/mullvad-update/test-version-response.json index bc5a89adc177..0484159d5b45 100644 --- a/mullvad-update/test-version-response.json +++ b/mullvad-update/test-version-response.json @@ -1,7 +1,7 @@ { "signature": { "keyid": "4d35f5376f1f58c41b2a0ee4600ae7811eace354f100227e853994deef38942d", - "sig": "0537b9fc1f458592a3330e369ef521b2e29dacad2a422cbae37ff7ddd400a5381b063c5f3056e9e3db6235d128128d95b7c54bf305eb2f3bd250d722baa2a504" + "sig": "7dc4f2d491b972d98ead6a252022dd5cbe2d3829ae28f174129ee94bcd3d1329d19db90d46251c81d75e04e49db29ae950899bcb4e6cf7f64c3fedec3ee0ee08" }, "signed": { "expires": "2025-07-02T15:33:00Z", @@ -74,6 +74,29 @@ } ], "rollout": 1.0 + }, + { + "version": "2025.3-beta1", + "changelog": "[macos] Adding support for quicfuscator\n[windows] Less bugs", + "installers": [ + { + "architecture": "x86", + "urls": [ + "https://releases.mullvad.net/desktop/releases/2025.3-beta1/MullvadVPN-2025.3-beta1_x64.exe" + ], + "size": 106297504, + "sha256": "0c569aa0912eb93605a85073447d42ba0ca612361bef78ef04ef038e80b15403" + }, + { + "architecture": "arm64", + "urls": [ + "https://releases.mullvad.net/desktop/releases/2025.3-beta1/MullvadVPN-2025.3-beta1_arm64.exe" + ], + "size": 111488248, + "sha256": "82948D3DB5B869EE5F0D246DB557A81B72B68DFDDD2267872B7B8A5B19A05444" + } + ], + "rollout": 1.0 } ] } From 7e86e610ec9330c038dda59f70cc6cc8b7f099f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20L=C3=B6nnhager?= Date: Wed, 12 Feb 2025 22:10:34 +0100 Subject: [PATCH 013/112] Always fail if there are duplicate versions --- mullvad-update/src/api.rs | 29 +++++++++++++++-------------- 1 file changed, 15 insertions(+), 14 deletions(-) diff --git a/mullvad-update/src/api.rs b/mullvad-update/src/api.rs index bcb5dd951a60..debadb06822f 100644 --- a/mullvad-update/src/api.rs +++ b/mullvad-update/src/api.rs @@ -89,8 +89,20 @@ impl VersionInfo { params: &VersionParameters, response: format::Response, ) -> anyhow::Result { - let mut releases: Vec<_> = response - .releases + let mut releases = response.releases; + + // Sort releases by version + releases.sort_by(|a, b| mullvad_version::Version::version_ordering(&a.version, &b.version)); + + // Fail if there are duplicate versions. + // Check this before anything else so that it's rejected indepentently of `params`. + // Important! This must occur after sorting + if let Some(dup_version) = Self::find_duplicate_version(&releases) { + anyhow::bail!("API response contains at least one duplicated version: {dup_version}"); + } + + // Filter releases based on rollout and architecture + let releases: Vec<_> = releases .into_iter() // Filter out releases that are not rolled out to us .filter(|release| release.rollout >= params.rollout) @@ -111,15 +123,6 @@ impl VersionInfo { }) .collect(); - // Sort releases by version - releases.sort_by(|a, b| mullvad_version::Version::version_ordering(&a.version, &b.version)); - - // Fail if there are duplicate versions - // Important! This must occur after sorting - if let Some(dup_version) = Self::find_duplicate_version(&releases) { - anyhow::bail!("API response contains at least one duplicated version: {dup_version}"); - } - // Find latest stable version let stable = releases .iter() @@ -146,9 +149,7 @@ impl VersionInfo { /// Returns the first duplicated version found in `releases`. /// `None` is returned if there are no duplicates. /// NOTE: `releases` MUST be sorted - fn find_duplicate_version( - releases: &[IntermediateVersion], - ) -> Option<&mullvad_version::Version> { + fn find_duplicate_version(releases: &[format::Release]) -> Option<&mullvad_version::Version> { releases .windows(2) .find(|pair| { From 8183f74f6489d972ebf0bd8c907dfcba0884039b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20L=C3=B6nnhager?= Date: Thu, 13 Feb 2025 17:56:21 +0100 Subject: [PATCH 014/112] Don't hide cancel button if download fails --- installer-downloader/src/ui_downloader.rs | 2 -- 1 file changed, 2 deletions(-) diff --git a/installer-downloader/src/ui_downloader.rs b/installer-downloader/src/ui_downloader.rs index 67fa9322d25c..19dfb96b2a16 100644 --- a/installer-downloader/src/ui_downloader.rs +++ b/installer-downloader/src/ui_downloader.rs @@ -50,8 +50,6 @@ impl AppDownl 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) From e8984124a3c42fbef5c805be9cffe196c8c426bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20L=C3=B6nnhager?= Date: Thu, 13 Feb 2025 17:57:46 +0100 Subject: [PATCH 015/112] Set app download cache to a read-only directory Notably, this means that the loader must run as a privileged user --- Cargo.lock | 1 + installer-downloader/src/controller.rs | 2 + installer-downloader/src/ui_downloader.rs | 13 ++++++ installer-downloader/tests/controller.rs | 4 ++ mullvad-update/Cargo.toml | 1 + mullvad-update/src/app.rs | 33 +++++++++---- mullvad-update/src/dir.rs | 57 +++++++++++++++++++++++ mullvad-update/src/fetch.rs | 2 + mullvad-update/src/lib.rs | 1 + 9 files changed, 106 insertions(+), 8 deletions(-) create mode 100644 mullvad-update/src/dir.rs diff --git a/Cargo.lock b/Cargo.lock index 81108146fbcb..e9d1acf67a99 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2996,6 +2996,7 @@ dependencies = [ "insta", "json-canon", "mockito", + "mullvad-paths", "mullvad-version", "rand 0.8.5", "reqwest", diff --git a/installer-downloader/src/controller.rs b/installer-downloader/src/controller.rs index 773ed1d3bd90..c5aebaa37189 100644 --- a/installer-downloader/src/controller.rs +++ b/installer-downloader/src/controller.rs @@ -141,6 +141,7 @@ where let Some(app_url) = version_info.stable.urls.first() else { return; }; + let app_version = version_info.stable.version; let app_sha256 = version_info.stable.sha256; let app_size = version_info.stable.size; @@ -152,6 +153,7 @@ where self_.show_download_progress(); let downloader = A::from(UiAppDownloaderParameters { + app_version, app_url: app_url.to_owned(), app_size, app_progress: UiProgressUpdater::new(self_.queue()), diff --git a/installer-downloader/src/ui_downloader.rs b/installer-downloader/src/ui_downloader.rs index 19dfb96b2a16..5658297082c4 100644 --- a/installer-downloader/src/ui_downloader.rs +++ b/installer-downloader/src/ui_downloader.rs @@ -37,6 +37,19 @@ impl impl AppDownloader for UiAppDownloader { + async fn create_cache_dir(&mut self) -> Result<(), app::DownloadError> { + match self.downloader.create_cache_dir().await { + Ok(()) => Ok(()), + Err(err) => { + self.queue.queue_main(move |self_| { + self_.set_download_text("ERROR: Failed to create cache directory."); + }); + + Err(err) + } + } + } + async fn download_executable(&mut self) -> Result<(), app::DownloadError> { match self.downloader.download_executable().await { Ok(()) => { diff --git a/installer-downloader/tests/controller.rs b/installer-downloader/tests/controller.rs index 65765123754c..af46452e89f1 100644 --- a/installer-downloader/tests/controller.rs +++ b/installer-downloader/tests/controller.rs @@ -65,6 +65,10 @@ pub struct FakeAppDownloader AppDownloader for FakeAppDownloader { + async fn create_cache_dir(&mut self) -> Result<(), DownloadError> { + Ok(()) + } + 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.); diff --git a/mullvad-update/Cargo.toml b/mullvad-update/Cargo.toml index 20b561776a74..4c1cc1d3b90b 100644 --- a/mullvad-update/Cargo.toml +++ b/mullvad-update/Cargo.toml @@ -27,6 +27,7 @@ sha2 = "0.10" tokio = { version = "1", features = ["full"] } async-trait = "0.1" +mullvad-paths = { path = "../mullvad-paths" } mullvad-version = { path = "../mullvad-version", features = ["serde"] } # features required by binaries diff --git a/mullvad-update/src/app.rs b/mullvad-update/src/app.rs index c6cfb1eb4558..f8b0ec341404 100644 --- a/mullvad-update/src/app.rs +++ b/mullvad-update/src/app.rs @@ -9,6 +9,7 @@ use crate::{ #[derive(Debug)] pub enum DownloadError { + CreateDir(anyhow::Error), FetchSignature(anyhow::Error), FetchApp(anyhow::Error), Verification(anyhow::Error), @@ -17,6 +18,7 @@ pub enum DownloadError { /// Parameters required to construct an [AppDownloader]. #[derive(Clone)] pub struct AppDownloaderParameters { + pub app_version: mullvad_version::Version, pub app_url: String, pub app_size: usize, pub app_progress: AppProgress, @@ -26,6 +28,9 @@ pub struct AppDownloaderParameters { /// See the [module-level documentation](self). #[async_trait::async_trait] pub trait AppDownloader: Send { + /// Create download directory. + async fn create_cache_dir(&mut self) -> Result<(), DownloadError>; + /// Download the app binary. async fn download_executable(&mut self) -> Result<(), DownloadError>; @@ -35,6 +40,7 @@ pub trait AppDownloader: Send { /// Download the app and signature, and verify the app's signature pub async fn install_and_upgrade(mut downloader: impl AppDownloader) -> Result<(), DownloadError> { + downloader.create_cache_dir().await?; downloader.download_executable().await?; downloader.verify().await } @@ -42,14 +48,12 @@ pub async fn install_and_upgrade(mut downloader: impl AppDownloader) -> Result<( #[derive(Clone)] pub struct HttpAppDownloader { params: AppDownloaderParameters, - // TODO: set permissions - tmp_dir: PathBuf, + cache_dir: Option, } impl HttpAppDownloader { pub fn new(params: AppDownloaderParameters) -> Self { - let tmp_dir = std::env::temp_dir(); - Self { params, tmp_dir } + Self { params, cache_dir: None } } } @@ -63,9 +67,16 @@ impl From> #[async_trait::async_trait] impl AppDownloader for HttpAppDownloader { + async fn create_cache_dir(&mut self) -> Result<(), DownloadError> { + let dir = crate::dir::update_directory().await.map_err(DownloadError::CreateDir)?; + self.cache_dir = Some(dir); + Ok(()) + } + async fn download_executable(&mut self) -> Result<(), DownloadError> { + let bin_path = self.bin_path().expect("Performed after 'create_cache_dir'"); fetch::get_to_file( - self.bin_path(), + bin_path, &self.params.app_url, &mut self.params.app_progress, fetch::SizeHint::Exact(self.params.app_size), @@ -75,7 +86,7 @@ impl AppDownloader for HttpAppDownloader Result<(), DownloadError> { - let bin_path = self.bin_path(); + let bin_path = self.bin_path().expect("Performed after 'create_cache_dir'"); let hash = self.hash_sha256(); Sha256Verifier::verify(bin_path, *hash) .await @@ -84,8 +95,14 @@ impl AppDownloader for HttpAppDownloader HttpAppDownloader { - fn bin_path(&self) -> PathBuf { - self.tmp_dir.join("temp.exe") + fn bin_path(&self) -> Option { + #[cfg(windows)] + let bin_filename = format!("{}.exe", self.params.app_version); + + #[cfg(unix)] + let bin_filename = self.params.app_version.to_string(); + + self.cache_dir.as_ref().map(|dir| dir.join(bin_filename)) } fn hash_sha256(&self) -> &[u8; 32] { diff --git a/mullvad-update/src/dir.rs b/mullvad-update/src/dir.rs new file mode 100644 index 000000000000..a7e7b81e1110 --- /dev/null +++ b/mullvad-update/src/dir.rs @@ -0,0 +1,57 @@ +//! This provides a secure directory suitable for storing updates, with admin-only write access. + +use std::path::PathBuf; +use tokio::fs; + +use anyhow::Context; + +/// Name of subdirectory in the cache directory +const CACHE_DIRNAME: &str = "updates"; + +/// This returns a directory suitable for storing updates. Only admins have write access. +/// +/// This function is a bit racey, as the directory is created before being restricted. +/// This is acceptable as long as the checksum of each file is verified before being used. +pub async fn update_directory() -> anyhow::Result { + let dir = tokio::task::spawn_blocking(|| mullvad_paths::cache_dir()) + .await + .unwrap()? + .join(CACHE_DIRNAME); + + #[cfg(windows)] + { + let dir_clone = dir.clone(); + tokio::task::spawn_blocking(move || { + mullvad_paths::windows::create_privileged_directory(&dir_clone) + }) + .await + .unwrap() + .context("Failed to create cache directory")?; + } + + #[cfg(unix)] + { + use std::{fs::Permissions, os::unix::fs::PermissionsExt}; + use tokio::fs; + + fs::create_dir_all(&dir) + .await + .context("Failed to create cache directory")?; + fs::set_permissions(&dir, Permissions::from_mode(0o700)) + .await + .context("Failed to set cache directory permissions")?; + } + + Ok(dir) +} + +/// Remove all files from the update directory +pub async fn cleanup_update_directory() -> anyhow::Result<()> { + + let dir = update_directory().await?; + + // It's fine to remove the directory in its entirety, since `update_directory` recreates it. + fs::remove_dir_all(dir).await?; + + Ok(()) +} diff --git a/mullvad-update/src/fetch.rs b/mullvad-update/src/fetch.rs index 1093915a7afe..9b13fd9ed11c 100644 --- a/mullvad-update/src/fetch.rs +++ b/mullvad-update/src/fetch.rs @@ -37,6 +37,8 @@ pub enum SizeHint { /// 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. /// +/// Make sure that `file` is stored in a secure directory. +/// /// # Arguments /// - `progress_updater` - This interface is notified of download progress. /// - `size_hint` - File size restrictions. diff --git a/mullvad-update/src/lib.rs b/mullvad-update/src/lib.rs index 27184e59d56f..89fbdee2edd3 100644 --- a/mullvad-update/src/lib.rs +++ b/mullvad-update/src/lib.rs @@ -2,6 +2,7 @@ pub mod api; pub mod app; +pub mod dir; pub mod fetch; pub mod verify; From 25e5ee878516830c559dfea1314d1ad1f7bdef3f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20L=C3=B6nnhager?= Date: Thu, 13 Feb 2025 21:58:50 +0100 Subject: [PATCH 016/112] Add launch step to installer downloader --- installer-downloader/src/controller.rs | 4 +- installer-downloader/src/delegate.rs | 3 ++ installer-downloader/src/ui_downloader.rs | 19 +++++++++ .../src/winapi_impl/delegate.rs | 4 ++ installer-downloader/tests/controller.rs | 40 ++++++++++++++----- .../snapshots/controller__download-2.snap | 1 + .../snapshots/controller__download-3.snap | 2 + .../tests/snapshots/controller__download.snap | 1 + .../controller__failed_verification.snap | 1 + .../controller__fetch_version-2.snap | 1 + .../snapshots/controller__fetch_version.snap | 1 + mullvad-update/src/app.rs | 35 +++++++++++++++- 12 files changed, 98 insertions(+), 14 deletions(-) diff --git a/installer-downloader/src/controller.rs b/installer-downloader/src/controller.rs index c5aebaa37189..e7b8a6bb40c7 100644 --- a/installer-downloader/src/controller.rs +++ b/installer-downloader/src/controller.rs @@ -161,9 +161,7 @@ where }); let ui_downloader = UiAppDownloader::new(self_, downloader); - let _ = tx.send(tokio::spawn(async move { - let _ = app::install_and_upgrade(ui_downloader).await; - })); + let _ = tx.send(tokio::spawn(app::install_and_upgrade(ui_downloader))); }); active_download = rx.await.ok(); } diff --git a/installer-downloader/src/delegate.rs b/installer-downloader/src/delegate.rs index beca2e628280..3e82d2ef314b 100644 --- a/installer-downloader/src/delegate.rs +++ b/installer-downloader/src/delegate.rs @@ -62,6 +62,9 @@ pub trait AppDelegate { /// Hide beta text fn hide_beta_text(&mut self); + /// Exit the application + fn quit(&mut self); + /// Create queue for scheduling actions on UI thread fn queue(&self) -> Self::Queue; } diff --git a/installer-downloader/src/ui_downloader.rs b/installer-downloader/src/ui_downloader.rs index 5658297082c4..7ae37df3e516 100644 --- a/installer-downloader/src/ui_downloader.rs +++ b/installer-downloader/src/ui_downloader.rs @@ -88,6 +88,25 @@ impl AppDownl } } } + + async fn install(&mut self) -> Result<(), app::DownloadError> { + match self.downloader.install().await { + Ok(()) => { + self.queue.queue_main(move |self_| { + // Success! + self_.quit(); + }); + Ok(()) + } + Err(error) => { + self.queue.queue_main(move |self_| { + self_.set_download_text("ERROR: Failed to launch installer!"); + }); + + Err(error) + } + } + } } /// Implementation of [fetch::ProgressUpdater] that updates some [AppDelegate]. diff --git a/installer-downloader/src/winapi_impl/delegate.rs b/installer-downloader/src/winapi_impl/delegate.rs index 4414559e7c3b..353dff969189 100644 --- a/installer-downloader/src/winapi_impl/delegate.rs +++ b/installer-downloader/src/winapi_impl/delegate.rs @@ -91,6 +91,10 @@ impl AppDelegate for AppWindow { self.beta_link.set_visible(false); } + fn quit(&mut self) { + nwg::stop_thread_dispatch(); + } + fn queue(&self) -> Self::Queue { Queue { main_wnd: self.window.handle, diff --git a/installer-downloader/tests/controller.rs b/installer-downloader/tests/controller.rs index af46452e89f1..460e1c168a72 100644 --- a/installer-downloader/tests/controller.rs +++ b/installer-downloader/tests/controller.rs @@ -36,16 +36,16 @@ impl VersionInfoProvider for FakeVersionInfoProvider { } /// Downloader for which all steps immediately succeed -pub type FakeAppDownloaderHappyPath = FakeAppDownloader; +pub type FakeAppDownloaderHappyPath = FakeAppDownloader; /// Downloader for which the download step fails -pub type FakeAppDownloaderDownloadFail = FakeAppDownloader; +pub type FakeAppDownloaderDownloadFail = FakeAppDownloader; -/// Downloader for which the final verification step fails -pub type FakeAppDownloaderVerifyFail = FakeAppDownloader; +/// Downloader for which the verification step fails +pub type FakeAppDownloaderVerifyFail = FakeAppDownloader; -impl From> - for FakeAppDownloader +impl From> + for FakeAppDownloader { fn from(params: UiAppDownloaderParameters) -> Self { FakeAppDownloader { params } @@ -57,13 +57,18 @@ impl From { +/// * LAUNCH_SUCCEED - whether launching the binary succeeds +pub struct FakeAppDownloader< + const EXE_SUCCEED: bool, + const VERIFY_SUCCEED: bool, + const LAUNCH_SUCCEED: bool, +> { params: UiAppDownloaderParameters, } #[async_trait::async_trait] -impl AppDownloader - for FakeAppDownloader +impl AppDownloader + for FakeAppDownloader { async fn create_cache_dir(&mut self) -> Result<(), DownloadError> { Ok(()) @@ -91,6 +96,16 @@ impl AppDownloader ))) } } + + async fn install(&mut self) -> Result<(), DownloadError> { + if LAUNCH_SUCCEED { + Ok(()) + } else { + Err(DownloadError::InstallFailed(anyhow::anyhow!( + "install failed" + ))) + } + } } /// A fake queue that stores callbacks so that tests can run them later. @@ -140,6 +155,7 @@ pub struct DelegateState { pub download_progress: u32, pub download_progress_visible: bool, pub beta_text_visible: bool, + pub quit: bool, /// Record of method calls. pub call_log: Vec, } @@ -244,6 +260,11 @@ impl AppDelegate for FakeAppDelegate { self.state.beta_text_visible = false; } + fn quit(&mut self) { + self.state.call_log.push("quit".into()); + self.state.quit = true; + } + fn queue(&self) -> Self::Queue { self.queue.clone() } @@ -311,6 +332,7 @@ async fn test_download() { queue.run_callbacks(&mut delegate); // Everything including verification should have succeeded + // Downloader should have quit 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 index e86d9055d719..390e1727783d 100644 --- a/installer-downloader/tests/snapshots/controller__download-2.snap +++ b/installer-downloader/tests/snapshots/controller__download-2.snap @@ -12,6 +12,7 @@ download_button_enabled: true download_progress: 0 download_progress_visible: true beta_text_visible: false +quit: false call_log: - hide_download_progress - show_download_button diff --git a/installer-downloader/tests/snapshots/controller__download-3.snap b/installer-downloader/tests/snapshots/controller__download-3.snap index d7c86160de2a..6d918cf3414f 100644 --- a/installer-downloader/tests/snapshots/controller__download-3.snap +++ b/installer-downloader/tests/snapshots/controller__download-3.snap @@ -12,6 +12,7 @@ download_button_enabled: true download_progress: 100 download_progress_visible: true beta_text_visible: false +quit: true call_log: - hide_download_progress - show_download_button @@ -36,3 +37,4 @@ call_log: - "set_download_text: Download complete. Verifying..." - disable_cancel_button - "set_download_text: Verification successful. Starting install..." + - quit diff --git a/installer-downloader/tests/snapshots/controller__download.snap b/installer-downloader/tests/snapshots/controller__download.snap index 45bd1796addb..3d79a1922e71 100644 --- a/installer-downloader/tests/snapshots/controller__download.snap +++ b/installer-downloader/tests/snapshots/controller__download.snap @@ -12,6 +12,7 @@ download_button_enabled: true download_progress: 0 download_progress_visible: false beta_text_visible: false +quit: false call_log: - hide_download_progress - show_download_button diff --git a/installer-downloader/tests/snapshots/controller__failed_verification.snap b/installer-downloader/tests/snapshots/controller__failed_verification.snap index d345795b7e7f..b7825fb1d5ec 100644 --- a/installer-downloader/tests/snapshots/controller__failed_verification.snap +++ b/installer-downloader/tests/snapshots/controller__failed_verification.snap @@ -12,6 +12,7 @@ download_button_enabled: true download_progress: 100 download_progress_visible: true beta_text_visible: false +quit: false call_log: - hide_download_progress - show_download_button diff --git a/installer-downloader/tests/snapshots/controller__fetch_version-2.snap b/installer-downloader/tests/snapshots/controller__fetch_version-2.snap index 45bd1796addb..3d79a1922e71 100644 --- a/installer-downloader/tests/snapshots/controller__fetch_version-2.snap +++ b/installer-downloader/tests/snapshots/controller__fetch_version-2.snap @@ -12,6 +12,7 @@ download_button_enabled: true download_progress: 0 download_progress_visible: false beta_text_visible: false +quit: false call_log: - hide_download_progress - show_download_button diff --git a/installer-downloader/tests/snapshots/controller__fetch_version.snap b/installer-downloader/tests/snapshots/controller__fetch_version.snap index a8c18961891a..24c54ce95c89 100644 --- a/installer-downloader/tests/snapshots/controller__fetch_version.snap +++ b/installer-downloader/tests/snapshots/controller__fetch_version.snap @@ -12,6 +12,7 @@ download_button_enabled: false download_progress: 0 download_progress_visible: false beta_text_visible: false +quit: false call_log: - hide_download_progress - show_download_button diff --git a/mullvad-update/src/app.rs b/mullvad-update/src/app.rs index f8b0ec341404..b693520bf1a0 100644 --- a/mullvad-update/src/app.rs +++ b/mullvad-update/src/app.rs @@ -1,18 +1,24 @@ //! This module implements the flow of downloading and verifying the app. -use std::path::PathBuf; +use std::{path::PathBuf, time::Duration}; + +use tokio::{process::Command, time::timeout}; use crate::{ fetch::{self, ProgressUpdater}, verify::{AppVerifier, Sha256Verifier}, }; +const INSTALLER_STARTUP_TIMEOUT: Duration = Duration::from_millis(500); + #[derive(Debug)] pub enum DownloadError { CreateDir(anyhow::Error), FetchSignature(anyhow::Error), FetchApp(anyhow::Error), Verification(anyhow::Error), + Launch(std::io::Error), + InstallFailed(anyhow::Error), } /// Parameters required to construct an [AppDownloader]. @@ -36,13 +42,17 @@ pub trait AppDownloader: Send { /// Verify the app signature. async fn verify(&mut self) -> Result<(), DownloadError>; + + /// Execute installer. + async fn install(&mut self) -> Result<(), DownloadError>; } /// Download the app and signature, and verify the app's signature pub async fn install_and_upgrade(mut downloader: impl AppDownloader) -> Result<(), DownloadError> { downloader.create_cache_dir().await?; downloader.download_executable().await?; - downloader.verify().await + downloader.verify().await?; + downloader.install().await } #[derive(Clone)] @@ -92,6 +102,27 @@ impl AppDownloader for HttpAppDownloader Result<(), DownloadError> { + let bin_path = self.bin_path().expect("Performed after 'create_cache_dir'"); + + // Launch process + // TODO: move to launch.rs? + let mut cmd = Command::new(bin_path); + let mut child = cmd.spawn().map_err(DownloadError::Launch)?; + + // Wait to see if the installer fails + match timeout(INSTALLER_STARTUP_TIMEOUT, child.wait()).await { + // Timeout: Quit and let the installer take over + Err(_timeout) => Ok(()), + // No timeout: Incredibly quick but successful (or wrong exit code, probably) + Ok(Ok(status)) if status.success() => Ok(()), + // Installer failed + Ok(Ok(status)) => Err(DownloadError::InstallFailed(anyhow::anyhow!("Install failed with status: {status}"))), + // Installer failed + Ok(Err(err)) => Err(DownloadError::InstallFailed(anyhow::anyhow!("Install failed : {err}"))), + } + } } impl HttpAppDownloader { From 5519974065256f866c22102d44ef72d9c7e98030 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20L=C3=B6nnhager?= Date: Thu, 13 Feb 2025 22:19:07 +0100 Subject: [PATCH 017/112] Remove installer if verification fails --- mullvad-update/src/app.rs | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/mullvad-update/src/app.rs b/mullvad-update/src/app.rs index b693520bf1a0..6d1f6b81985f 100644 --- a/mullvad-update/src/app.rs +++ b/mullvad-update/src/app.rs @@ -98,9 +98,20 @@ impl AppDownloader for HttpAppDownloader Result<(), DownloadError> { let bin_path = self.bin_path().expect("Performed after 'create_cache_dir'"); let hash = self.hash_sha256(); - Sha256Verifier::verify(bin_path, *hash) + + match Sha256Verifier::verify(&bin_path, *hash) .await .map_err(DownloadError::Verification) + { + // Verification succeeded + Ok(()) => Ok(()), + // Verification failed + Err(err) => { + // Attempt to clean up + let _ = tokio::fs::remove_file(bin_path).await; + Err(err) + } + } } async fn install(&mut self) -> Result<(), DownloadError> { From 41672123ef0b5110cae22edc2c141dfbc05c3ad4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20L=C3=B6nnhager?= Date: Fri, 14 Feb 2025 12:38:00 +0100 Subject: [PATCH 018/112] Decouple download directory from mullvad-update --- installer-downloader/src/controller.rs | 48 +++++++++-- installer-downloader/src/ui_downloader.rs | 13 --- installer-downloader/tests/controller.rs | 85 ++++++++++++++++--- ...controller__failed_directory_creation.snap | 27 ++++++ mullvad-update/src/app.rs | 42 +++++---- mullvad-update/src/dir.rs | 35 +++----- mullvad-update/src/fetch.rs | 2 - 7 files changed, 170 insertions(+), 82 deletions(-) create mode 100644 installer-downloader/tests/snapshots/controller__failed_directory_creation.snap diff --git a/installer-downloader/src/controller.rs b/installer-downloader/src/controller.rs index e7b8a6bb40c7..6dbf9255f378 100644 --- a/installer-downloader/src/controller.rs +++ b/installer-downloader/src/controller.rs @@ -10,6 +10,7 @@ use mullvad_update::{ }; use std::future::Future; +use std::path::PathBuf; use tokio::sync::{mpsc, oneshot}; /// Actions handled by an async worker task in [handle_action_messages]. @@ -19,6 +20,21 @@ enum TaskMessage { Cancel, } +/// Provide a directory to use for [AppDownloader] +pub trait DirectoryProvider: 'static { + /// Provide a directory to use for [AppDownloader] + fn create_download_dir() -> impl Future> + Send; +} + +struct TempDirProvider; + +impl DirectoryProvider for TempDirProvider { + /// Create a locked-down directory to store downloads in + fn create_download_dir() -> impl Future> + Send { + mullvad_update::dir::admin_temp_dir() + } +} + /// See the [module-level docs](self). pub struct AppController {} @@ -30,8 +46,10 @@ pub fn initialize_controller(delegate: &mut T) { type Downloader = HttpAppDownloader>; // Version info provider to use type VersionInfoProvider = ApiVersionInfoProvider; + // Directory provider to use + type DirProvider = TempDirProvider; - AppController::initialize::<_, Downloader, VersionInfoProvider>(delegate) + AppController::initialize::<_, Downloader, VersionInfoProvider, DirProvider>(delegate) } impl AppController { @@ -39,11 +57,12 @@ impl AppController { /// /// 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: &mut D) + pub fn initialize(delegate: &mut D) where D: AppDelegate + 'static, V: VersionInfoProvider + 'static, A: From> + AppDownloader + 'static, + DirProvider: DirectoryProvider, { delegate.hide_download_progress(); delegate.show_download_button(); @@ -52,7 +71,10 @@ impl AppController { delegate.hide_beta_text(); let (task_tx, task_rx) = mpsc::channel(1); - tokio::spawn(handle_action_messages::(delegate.queue(), task_rx)); + tokio::spawn(handle_action_messages::( + delegate.queue(), + task_rx, + )); delegate.set_status_text(resource::FETCH_VERSION_DESC); tokio::spawn(fetch_app_version_info::(delegate, task_tx.clone())); Self::register_user_action_callbacks(delegate, task_tx); @@ -105,10 +127,13 @@ where /// Async worker that handles actions such as initiating a download, cancelling it, and updating /// labels. -async fn handle_action_messages(queue: D::Queue, mut rx: mpsc::Receiver) -where +async fn handle_action_messages( + queue: D::Queue, + mut rx: mpsc::Receiver, +) where D: AppDelegate + 'static, A: From> + AppDownloader + 'static, + DirProvider: DirectoryProvider, { let mut version_info = None; let mut active_download = None; @@ -135,6 +160,18 @@ where continue; }; + // Create temporary dir + let download_dir = match DirProvider::create_download_dir().await { + Ok(dir) => dir, + Err(_err) => { + queue.queue_main(move |self_| { + self_.set_status_text("Failed to create download directory"); + }); + continue; + } + }; + + // Begin download let (tx, rx) = oneshot::channel(); queue.queue_main(move |self_| { // TODO: Select appropriate URLs @@ -158,6 +195,7 @@ where app_size, app_progress: UiProgressUpdater::new(self_.queue()), app_sha256, + cache_dir: download_dir, }); let ui_downloader = UiAppDownloader::new(self_, downloader); diff --git a/installer-downloader/src/ui_downloader.rs b/installer-downloader/src/ui_downloader.rs index 7ae37df3e516..437320a537f6 100644 --- a/installer-downloader/src/ui_downloader.rs +++ b/installer-downloader/src/ui_downloader.rs @@ -37,19 +37,6 @@ impl impl AppDownloader for UiAppDownloader { - async fn create_cache_dir(&mut self) -> Result<(), app::DownloadError> { - match self.downloader.create_cache_dir().await { - Ok(()) => Ok(()), - Err(err) => { - self.queue.queue_main(move |self_| { - self_.set_download_text("ERROR: Failed to create cache directory."); - }); - - Err(err) - } - } - } - async fn download_executable(&mut self) -> Result<(), app::DownloadError> { match self.downloader.download_executable().await { Ok(()) => { diff --git a/installer-downloader/tests/controller.rs b/installer-downloader/tests/controller.rs index 460e1c168a72..2076b44a65e5 100644 --- a/installer-downloader/tests/controller.rs +++ b/installer-downloader/tests/controller.rs @@ -5,12 +5,13 @@ //! changes to, and update, snapshots are by running `cargo insta review`. use insta::assert_yaml_snapshot; -use installer_downloader::controller::AppController; +use installer_downloader::controller::{AppController, DirectoryProvider}; use installer_downloader::delegate::{AppDelegate, AppDelegateQueue}; use installer_downloader::ui_downloader::UiAppDownloaderParameters; use mullvad_update::api::{Version, VersionInfo, VersionInfoProvider, VersionParameters}; use mullvad_update::app::{AppDownloader, DownloadError}; use mullvad_update::fetch::ProgressUpdater; +use std::path::{Path, PathBuf}; use std::sync::{Arc, LazyLock, Mutex}; use std::time::Duration; use std::vec::Vec; @@ -35,6 +36,18 @@ impl VersionInfoProvider for FakeVersionInfoProvider { } } +pub struct FakeDirectoryProvider {} + +impl DirectoryProvider for FakeDirectoryProvider { + async fn create_download_dir() -> anyhow::Result { + if SUCCEEDED { + Ok(Path::new("/tmp/fake").to_owned()) + } else { + anyhow::bail!("Failed to create directory"); + } + } +} + /// Downloader for which all steps immediately succeed pub type FakeAppDownloaderHappyPath = FakeAppDownloader; @@ -70,10 +83,6 @@ pub struct FakeAppDownloader< impl AppDownloader for FakeAppDownloader { - async fn create_cache_dir(&mut self) -> Result<(), DownloadError> { - Ok(()) - } - 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.); @@ -274,9 +283,12 @@ impl AppDelegate for FakeAppDelegate { #[tokio::test(start_paused = true)] async fn test_fetch_version() { let mut delegate = FakeAppDelegate::default(); - AppController::initialize::<_, FakeAppDownloaderHappyPath, FakeVersionInfoProvider>( - &mut delegate, - ); + AppController::initialize::< + _, + FakeAppDownloaderHappyPath, + FakeVersionInfoProvider, + FakeDirectoryProvider, + >(&mut delegate); // The app should start out by fetching the current app version assert_yaml_snapshot!(delegate.state); @@ -296,9 +308,12 @@ async fn test_fetch_version() { #[tokio::test(start_paused = true)] async fn test_download() { let mut delegate = FakeAppDelegate::default(); - AppController::initialize::<_, FakeAppDownloaderHappyPath, FakeVersionInfoProvider>( - &mut delegate, - ); + AppController::initialize::< + _, + FakeAppDownloaderHappyPath, + FakeVersionInfoProvider, + FakeDirectoryProvider, + >(&mut delegate); // Wait for the version info tokio::time::sleep(Duration::from_secs(1)).await; @@ -340,9 +355,51 @@ async fn test_download() { #[tokio::test(start_paused = true)] async fn test_failed_verification() { let mut delegate = FakeAppDelegate::default(); - AppController::initialize::<_, FakeAppDownloaderVerifyFail, FakeVersionInfoProvider>( - &mut delegate, - ); + AppController::initialize::< + _, + FakeAppDownloaderVerifyFail, + FakeVersionInfoProvider, + FakeDirectoryProvider, + >(&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); +} + +/// Test failing to create the download directory +#[tokio::test(start_paused = true)] +async fn test_failed_directory_creation() { + let mut delegate = FakeAppDelegate::default(); + AppController::initialize::< + _, + FakeAppDownloaderHappyPath, + FakeVersionInfoProvider, + FakeDirectoryProvider, + >(&mut delegate); // Wait for the version info tokio::time::sleep(Duration::from_secs(1)).await; diff --git a/installer-downloader/tests/snapshots/controller__failed_directory_creation.snap b/installer-downloader/tests/snapshots/controller__failed_directory_creation.snap new file mode 100644 index 000000000000..9cb6d6fcb19c --- /dev/null +++ b/installer-downloader/tests/snapshots/controller__failed_directory_creation.snap @@ -0,0 +1,27 @@ +--- +source: installer-downloader/tests/controller.rs +expression: delegate.state +snapshot_kind: text +--- +status_text: Failed to create download directory +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 +quit: 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_status_text: Failed to create download directory" diff --git a/mullvad-update/src/app.rs b/mullvad-update/src/app.rs index 6d1f6b81985f..f543c1595529 100644 --- a/mullvad-update/src/app.rs +++ b/mullvad-update/src/app.rs @@ -9,8 +9,6 @@ use crate::{ verify::{AppVerifier, Sha256Verifier}, }; -const INSTALLER_STARTUP_TIMEOUT: Duration = Duration::from_millis(500); - #[derive(Debug)] pub enum DownloadError { CreateDir(anyhow::Error), @@ -29,14 +27,14 @@ pub struct AppDownloaderParameters { pub app_size: usize, pub app_progress: AppProgress, pub app_sha256: [u8; 32], + /// Directory to store the installer in. + /// Ensure that this has proper permissions set. + pub cache_dir: PathBuf, } /// See the [module-level documentation](self). #[async_trait::async_trait] pub trait AppDownloader: Send { - /// Create download directory. - async fn create_cache_dir(&mut self) -> Result<(), DownloadError>; - /// Download the app binary. async fn download_executable(&mut self) -> Result<(), DownloadError>; @@ -47,9 +45,11 @@ pub trait AppDownloader: Send { async fn install(&mut self) -> Result<(), DownloadError>; } +/// How long to wait for the installer to exit before returning +const INSTALLER_STARTUP_TIMEOUT: Duration = Duration::from_millis(500); + /// Download the app and signature, and verify the app's signature pub async fn install_and_upgrade(mut downloader: impl AppDownloader) -> Result<(), DownloadError> { - downloader.create_cache_dir().await?; downloader.download_executable().await?; downloader.verify().await?; downloader.install().await @@ -58,12 +58,11 @@ pub async fn install_and_upgrade(mut downloader: impl AppDownloader) -> Result<( #[derive(Clone)] pub struct HttpAppDownloader { params: AppDownloaderParameters, - cache_dir: Option, } impl HttpAppDownloader { pub fn new(params: AppDownloaderParameters) -> Self { - Self { params, cache_dir: None } + Self { params } } } @@ -77,14 +76,8 @@ impl From> #[async_trait::async_trait] impl AppDownloader for HttpAppDownloader { - async fn create_cache_dir(&mut self) -> Result<(), DownloadError> { - let dir = crate::dir::update_directory().await.map_err(DownloadError::CreateDir)?; - self.cache_dir = Some(dir); - Ok(()) - } - async fn download_executable(&mut self) -> Result<(), DownloadError> { - let bin_path = self.bin_path().expect("Performed after 'create_cache_dir'"); + let bin_path = self.bin_path(); fetch::get_to_file( bin_path, &self.params.app_url, @@ -96,7 +89,7 @@ impl AppDownloader for HttpAppDownloader Result<(), DownloadError> { - let bin_path = self.bin_path().expect("Performed after 'create_cache_dir'"); + let bin_path = self.bin_path(); let hash = self.hash_sha256(); match Sha256Verifier::verify(&bin_path, *hash) @@ -115,10 +108,9 @@ impl AppDownloader for HttpAppDownloader Result<(), DownloadError> { - let bin_path = self.bin_path().expect("Performed after 'create_cache_dir'"); + let bin_path = self.bin_path(); // Launch process - // TODO: move to launch.rs? let mut cmd = Command::new(bin_path); let mut child = cmd.spawn().map_err(DownloadError::Launch)?; @@ -129,22 +121,26 @@ impl AppDownloader for HttpAppDownloader Ok(()), // Installer failed - Ok(Ok(status)) => Err(DownloadError::InstallFailed(anyhow::anyhow!("Install failed with status: {status}"))), + Ok(Ok(status)) => Err(DownloadError::InstallFailed(anyhow::anyhow!( + "Install failed with status: {status}" + ))), // Installer failed - Ok(Err(err)) => Err(DownloadError::InstallFailed(anyhow::anyhow!("Install failed : {err}"))), + Ok(Err(err)) => Err(DownloadError::InstallFailed(anyhow::anyhow!( + "Install failed: {err}" + ))), } } } impl HttpAppDownloader { - fn bin_path(&self) -> Option { + fn bin_path(&self) -> PathBuf { #[cfg(windows)] - let bin_filename = format!("{}.exe", self.params.app_version); + let bin_filename = format!("mullvad-{}.exe", self.params.app_version); #[cfg(unix)] let bin_filename = self.params.app_version.to_string(); - self.cache_dir.as_ref().map(|dir| dir.join(bin_filename)) + self.params.cache_dir.join(bin_filename) } fn hash_sha256(&self) -> &[u8; 32] { diff --git a/mullvad-update/src/dir.rs b/mullvad-update/src/dir.rs index a7e7b81e1110..cf3a85eb24bd 100644 --- a/mullvad-update/src/dir.rs +++ b/mullvad-update/src/dir.rs @@ -1,26 +1,22 @@ -//! This provides a secure directory suitable for storing updates, with admin-only write access. +//! This provides a secure temp directory suitable for storing updates, with admin-only write access. use std::path::PathBuf; -use tokio::fs; use anyhow::Context; -/// Name of subdirectory in the cache directory -const CACHE_DIRNAME: &str = "updates"; +/// Name of subdirectory in the temp directory +const CACHE_DIRNAME: &str = "mullvad-updates"; -/// This returns a directory suitable for storing updates. Only admins have write access. +/// This returns a directory suitable for storing updates, where only admins have write access. /// /// This function is a bit racey, as the directory is created before being restricted. /// This is acceptable as long as the checksum of each file is verified before being used. -pub async fn update_directory() -> anyhow::Result { - let dir = tokio::task::spawn_blocking(|| mullvad_paths::cache_dir()) - .await - .unwrap()? - .join(CACHE_DIRNAME); +pub async fn admin_temp_dir() -> anyhow::Result { + let temp_dir = std::env::temp_dir().join(CACHE_DIRNAME); #[cfg(windows)] { - let dir_clone = dir.clone(); + let dir_clone = temp_dir.clone(); tokio::task::spawn_blocking(move || { mullvad_paths::windows::create_privileged_directory(&dir_clone) }) @@ -34,24 +30,13 @@ pub async fn update_directory() -> anyhow::Result { use std::{fs::Permissions, os::unix::fs::PermissionsExt}; use tokio::fs; - fs::create_dir_all(&dir) + fs::create_dir_all(&temp_dir) .await .context("Failed to create cache directory")?; - fs::set_permissions(&dir, Permissions::from_mode(0o700)) + fs::set_permissions(&temp_dir, Permissions::from_mode(0o700)) .await .context("Failed to set cache directory permissions")?; } - Ok(dir) -} - -/// Remove all files from the update directory -pub async fn cleanup_update_directory() -> anyhow::Result<()> { - - let dir = update_directory().await?; - - // It's fine to remove the directory in its entirety, since `update_directory` recreates it. - fs::remove_dir_all(dir).await?; - - Ok(()) + Ok(temp_dir) } diff --git a/mullvad-update/src/fetch.rs b/mullvad-update/src/fetch.rs index 9b13fd9ed11c..8ba821a5f400 100644 --- a/mullvad-update/src/fetch.rs +++ b/mullvad-update/src/fetch.rs @@ -23,8 +23,6 @@ pub trait ProgressUpdater: Send + 'static { 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 { From 2c8c3cae9ec86c592ccc4403ce2da899a328e65f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20L=C3=B6nnhager?= Date: Fri, 14 Feb 2025 20:38:24 +0100 Subject: [PATCH 019/112] Add API client for mullvad-update --- installer-downloader/src/controller.rs | 38 ++- installer-downloader/tests/controller.rs | 45 ++- mullvad-update/src/api.rs | 270 ++++++------------ mullvad-update/src/format/deserializer.rs | 8 +- mullvad-update/src/format/key.rs | 9 + mullvad-update/src/lib.rs | 1 + ...te__api__test__http_version_provider.snap} | 0 ...ion__test__version_info_parser_arm64.snap} | 2 +- ...ersion__test__version_info_parser_x86.snap | 83 ++++++ mullvad-update/src/version.rs | 205 +++++++++++++ 10 files changed, 436 insertions(+), 225 deletions(-) rename mullvad-update/src/snapshots/{mullvad_update__api__test__api_version_info_provider_parser_x86.snap => mullvad_update__api__test__http_version_provider.snap} (100%) rename mullvad-update/src/snapshots/{mullvad_update__api__test__api_version_info_provider_parser_arm64.snap => mullvad_update__version__test__version_info_parser_arm64.snap} (93%) create mode 100644 mullvad-update/src/snapshots/mullvad_update__version__test__version_info_parser_x86.snap create mode 100644 mullvad-update/src/version.rs diff --git a/installer-downloader/src/controller.rs b/installer-downloader/src/controller.rs index 6dbf9255f378..100fcf179617 100644 --- a/installer-downloader/src/controller.rs +++ b/installer-downloader/src/controller.rs @@ -5,8 +5,9 @@ use crate::resource; use crate::ui_downloader::{UiAppDownloader, UiAppDownloaderParameters, UiProgressUpdater}; use mullvad_update::{ - api::{self, Version, VersionInfoProvider, VersionParameters}, + api::VersionInfoProvider, app::{self, AppDownloader}, + version::{Version, VersionArchitecture, VersionInfo, VersionParameters}, }; use std::future::Future; @@ -15,7 +16,7 @@ use tokio::sync::{mpsc, oneshot}; /// Actions handled by an async worker task in [handle_action_messages]. enum TaskMessage { - SetVersionInfo(api::VersionInfo), + SetVersionInfo(VersionInfo), BeginDownload, Cancel, } @@ -40,16 +41,24 @@ pub struct AppController {} /// Public entry function for registering a [AppDelegate]. pub fn initialize_controller(delegate: &mut T) { - use mullvad_update::{api::ApiVersionInfoProvider, app::HttpAppDownloader}; + use mullvad_update::{api::HttpVersionInfoProvider, app::HttpAppDownloader}; // App downloader to use type Downloader = HttpAppDownloader>; - // Version info provider to use - type VersionInfoProvider = ApiVersionInfoProvider; // Directory provider to use type DirProvider = TempDirProvider; - AppController::initialize::<_, Downloader, VersionInfoProvider, DirProvider>(delegate) + // Version info provider to use + const TEST_PUBKEY: &str = "4d35f5376f1f58c41b2a0ee4600ae7811eace354f100227e853994deef38942d"; + let verifying_key = + mullvad_update::format::key::VerifyingKey::from_hex(TEST_PUBKEY).expect("valid key"); + let version_provider = HttpVersionInfoProvider { + url: "https://releases.mullvad.net/thing".to_owned(), + pinned_certificate: None, + verifying_key, + }; + + AppController::initialize::<_, Downloader, _, DirProvider>(delegate, version_provider) } impl AppController { @@ -57,10 +66,10 @@ impl AppController { /// /// 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: &mut D) + pub fn initialize(delegate: &mut D, version_provider: V) where D: AppDelegate + 'static, - V: VersionInfoProvider + 'static, + V: VersionInfoProvider + Send + 'static, A: From> + AppDownloader + 'static, DirProvider: DirectoryProvider, { @@ -76,7 +85,11 @@ impl AppController { task_rx, )); delegate.set_status_text(resource::FETCH_VERSION_DESC); - tokio::spawn(fetch_app_version_info::(delegate, task_tx.clone())); + tokio::spawn(fetch_app_version_info::( + delegate, + task_tx.clone(), + version_provider, + )); Self::register_user_action_callbacks(delegate, task_tx); } @@ -99,23 +112,24 @@ impl AppController { fn fetch_app_version_info( delegate: &mut Delegate, download_tx: mpsc::Sender, + version_provider: VersionProvider, ) -> impl Future where Delegate: AppDelegate, - VersionProvider: VersionInfoProvider, + VersionProvider: VersionInfoProvider + Send, { let queue = delegate.queue(); async move { let version_params = VersionParameters { // TODO: detect current architecture - architecture: api::VersionArchitecture::X86, + architecture: VersionArchitecture::X86, // For the downloader, the rollout version is always preferred rollout: 1., }; // TODO: handle errors, retry - let Ok(version_info) = VersionProvider::get_version_info(version_params).await else { + let Ok(version_info) = version_provider.get_version_info(version_params).await else { queue.queue_main(move |self_| { self_.set_status_text("Failed to fetch version info"); }); diff --git a/installer-downloader/tests/controller.rs b/installer-downloader/tests/controller.rs index 2076b44a65e5..03a4842ec73d 100644 --- a/installer-downloader/tests/controller.rs +++ b/installer-downloader/tests/controller.rs @@ -8,9 +8,10 @@ use insta::assert_yaml_snapshot; use installer_downloader::controller::{AppController, DirectoryProvider}; use installer_downloader::delegate::{AppDelegate, AppDelegateQueue}; use installer_downloader::ui_downloader::UiAppDownloaderParameters; -use mullvad_update::api::{Version, VersionInfo, VersionInfoProvider, VersionParameters}; +use mullvad_update::api::VersionInfoProvider; use mullvad_update::app::{AppDownloader, DownloadError}; use mullvad_update::fetch::ProgressUpdater; +use mullvad_update::version::{Version, VersionInfo, VersionParameters}; use std::path::{Path, PathBuf}; use std::sync::{Arc, LazyLock, Mutex}; use std::time::Duration; @@ -31,7 +32,7 @@ static FAKE_VERSION: LazyLock = LazyLock::new(|| VersionInfo { #[async_trait::async_trait] impl VersionInfoProvider for FakeVersionInfoProvider { - async fn get_version_info(_params: VersionParameters) -> anyhow::Result { + async fn get_version_info(&self, _params: VersionParameters) -> anyhow::Result { Ok(FAKE_VERSION.clone()) } } @@ -283,12 +284,10 @@ impl AppDelegate for FakeAppDelegate { #[tokio::test(start_paused = true)] async fn test_fetch_version() { let mut delegate = FakeAppDelegate::default(); - AppController::initialize::< - _, - FakeAppDownloaderHappyPath, - FakeVersionInfoProvider, - FakeDirectoryProvider, - >(&mut delegate); + AppController::initialize::<_, FakeAppDownloaderHappyPath, _, FakeDirectoryProvider>( + &mut delegate, + FakeVersionInfoProvider {}, + ); // The app should start out by fetching the current app version assert_yaml_snapshot!(delegate.state); @@ -308,12 +307,10 @@ async fn test_fetch_version() { #[tokio::test(start_paused = true)] async fn test_download() { let mut delegate = FakeAppDelegate::default(); - AppController::initialize::< - _, - FakeAppDownloaderHappyPath, - FakeVersionInfoProvider, - FakeDirectoryProvider, - >(&mut delegate); + AppController::initialize::<_, FakeAppDownloaderHappyPath, _, FakeDirectoryProvider>( + &mut delegate, + FakeVersionInfoProvider {}, + ); // Wait for the version info tokio::time::sleep(Duration::from_secs(1)).await; @@ -355,12 +352,10 @@ async fn test_download() { #[tokio::test(start_paused = true)] async fn test_failed_verification() { let mut delegate = FakeAppDelegate::default(); - AppController::initialize::< - _, - FakeAppDownloaderVerifyFail, - FakeVersionInfoProvider, - FakeDirectoryProvider, - >(&mut delegate); + AppController::initialize::<_, FakeAppDownloaderVerifyFail, _, FakeDirectoryProvider>( + &mut delegate, + FakeVersionInfoProvider {}, + ); // Wait for the version info tokio::time::sleep(Duration::from_secs(1)).await; @@ -394,12 +389,10 @@ async fn test_failed_verification() { #[tokio::test(start_paused = true)] async fn test_failed_directory_creation() { let mut delegate = FakeAppDelegate::default(); - AppController::initialize::< - _, - FakeAppDownloaderHappyPath, - FakeVersionInfoProvider, - FakeDirectoryProvider, - >(&mut delegate); + AppController::initialize::<_, FakeAppDownloaderHappyPath, _, FakeDirectoryProvider>( + &mut delegate, + FakeVersionInfoProvider {}, + ); // Wait for the version info tokio::time::sleep(Duration::from_secs(1)).await; diff --git a/mullvad-update/src/api.rs b/mullvad-update/src/api.rs index debadb06822f..864c0745a225 100644 --- a/mullvad-update/src/api.rs +++ b/mullvad-update/src/api.rs @@ -1,182 +1,84 @@ -//! Fetch information about app versions from the Mullvad API +//! This module implements fetching of information about app versions use anyhow::Context; use crate::format; - -/// Parameters for [VersionInfoProvider] -#[derive(Debug)] -pub struct VersionParameters { - /// Architecture to retrieve data for - pub architecture: VersionArchitecture, - /// Rollout threshold. Any version in the response below this threshold will be ignored - pub rollout: f32, -} - -/// Installer architecture -pub type VersionArchitecture = format::Architecture; +use crate::version::{VersionInfo, VersionParameters}; /// See [module-level](self) docs. #[async_trait::async_trait] pub trait VersionInfoProvider { /// Return info about the stable version - async fn get_version_info(params: VersionParameters) -> anyhow::Result; + async fn get_version_info(&self, params: VersionParameters) -> anyhow::Result; } -/// Contains information about all versions -#[derive(Debug, Clone)] -#[cfg_attr(test, derive(serde::Serialize))] -pub struct VersionInfo { - /// Stable version info - pub stable: Version, - /// Beta version info (if available and newer than `stable`). - /// If latest stable version is newer, this will be `None`. - pub beta: Option, +/// Obtain version data using a GET request +pub struct HttpVersionInfoProvider { + /// Endpoint for GET request + pub url: String, + /// Accepted root certificate. Defaults are used unless specified + pub pinned_certificate: Option, + /// Key to use for verifying the response + pub verifying_key: format::key::VerifyingKey, } -/// Contains information about a version for the current target -#[derive(Debug, Clone)] -#[cfg_attr(test, derive(serde::Serialize))] -pub struct Version { - /// Version - pub version: mullvad_version::Version, - /// URLs to use for downloading the app installer - pub urls: Vec, - /// Size of installer, in bytes - pub size: usize, - /// Version changelog - pub changelog: String, - /// App installer checksum - pub sha256: [u8; 32], -} - -/// Obtain version data from the Mullvad API -pub struct ApiVersionInfoProvider; - #[async_trait::async_trait] -impl VersionInfoProvider for ApiVersionInfoProvider { - async fn get_version_info(params: VersionParameters) -> anyhow::Result { - // FIXME: Replace with actual API response - use format::*; - - const TEST_PUBKEY: &str = - "4d35f5376f1f58c41b2a0ee4600ae7811eace354f100227e853994deef38942d"; - let pubkey = hex::decode(TEST_PUBKEY).unwrap(); - let verifying_key = - ed25519_dalek::VerifyingKey::from_bytes(&pubkey.try_into().unwrap()).unwrap(); - - let response = SignedResponse::deserialize_and_verify( - format::key::VerifyingKey(verifying_key), - include_bytes!("../test-version-response.json"), - )?; +impl VersionInfoProvider for HttpVersionInfoProvider { + async fn get_version_info(&self, params: VersionParameters) -> anyhow::Result { + let raw_json = Self::get(&self.url, self.pinned_certificate.clone()).await?; + let response = + format::SignedResponse::deserialize_and_verify(&self.verifying_key, &raw_json)?; VersionInfo::try_from_response(¶ms, response.signed) } } -/// Helper used to lift the relevant installer out of the array in [format::Release] -#[derive(Clone)] -struct IntermediateVersion { - version: mullvad_version::Version, - changelog: String, - installer: format::Installer, -} +impl HttpVersionInfoProvider { + /// Maximum size of the GET response, in bytes + const SIZE_LIMIT: usize = 1024 * 1024; + + /// Perform a simple GET request, with a size limit, and return it as bytes + async fn get( + url: &str, + pinned_certificate: Option, + ) -> anyhow::Result> { + let mut req_builder = reqwest::Client::builder(); + + if let Some(pinned_certificate) = pinned_certificate { + req_builder = req_builder + .tls_built_in_root_certs(false) + .add_root_certificate(pinned_certificate); + } -impl VersionInfo { - /// Convert signed response data to public version type - /// NOTE: `response` is assumed to be verified and untampered. It is not verified. - fn try_from_response( - params: &VersionParameters, - response: format::Response, - ) -> anyhow::Result { - let mut releases = response.releases; - - // Sort releases by version - releases.sort_by(|a, b| mullvad_version::Version::version_ordering(&a.version, &b.version)); - - // Fail if there are duplicate versions. - // Check this before anything else so that it's rejected indepentently of `params`. - // Important! This must occur after sorting - if let Some(dup_version) = Self::find_duplicate_version(&releases) { - anyhow::bail!("API response contains at least one duplicated version: {dup_version}"); + // Initiate GET request + let mut req = req_builder + .build()? + .get(url) + .send() + .await + .context("Failed to fetch version")?; + + // Fail if content length exceeds limit + let content_len_limit = Self::SIZE_LIMIT.try_into().expect("Invalid size limit"); + if req.content_length() > Some(content_len_limit) { + anyhow::bail!("Version info exceeded limit: {} bytes", Self::SIZE_LIMIT); } - // Filter releases based on rollout and architecture - let releases: Vec<_> = releases - .into_iter() - // Filter out releases that are not rolled out to us - .filter(|release| release.rollout >= params.rollout) - // Include only installers for the requested architecture - .flat_map(|release| { - release - .installers - .into_iter() - .filter(|installer| params.architecture == installer.architecture) - // Map each artifact to a [IntermediateVersion] - .map(move |installer| { - IntermediateVersion { - version: release.version.clone(), - changelog: release.changelog.clone(), - installer, - } - }) - }) - .collect(); - - // Find latest stable version - let stable = releases - .iter() - .rfind(|release| release.version.is_stable() && !release.version.is_dev()); - let Some(stable) = stable.cloned() else { - anyhow::bail!("No stable version found"); - }; + let mut read_n = 0; + let mut data = vec![]; - // Find the latest beta version - let beta = releases - .iter() - // Find most recent beta version - .rfind(|release| release.version.beta().is_some() && !release.version.is_dev()) - // If the latest beta version is older than latest stable, dispose of it - .filter(|release| release.version.version_ordering(&stable.version).is_gt()) - .cloned(); - - Ok(Self { - stable: Version::try_from(stable)?, - beta: beta.map(|beta| Version::try_from(beta)).transpose()?, - }) - } + while let Some(chunk) = req.chunk().await.context("Failed to retrieve chunk")? { + read_n += chunk.len(); - /// Returns the first duplicated version found in `releases`. - /// `None` is returned if there are no duplicates. - /// NOTE: `releases` MUST be sorted - fn find_duplicate_version(releases: &[format::Release]) -> Option<&mullvad_version::Version> { - releases - .windows(2) - .find(|pair| { - mullvad_version::Version::version_ordering(&pair[0].version, &pair[1].version) - .is_eq() - }) - .map(|pair| &pair[0].version) - } -} + // Fail if content length exceeds limit + if read_n > Self::SIZE_LIMIT { + anyhow::bail!("Version info exceeded limit: {} bytes", Self::SIZE_LIMIT); + } -impl TryFrom for Version { - type Error = anyhow::Error; - - fn try_from(version: IntermediateVersion) -> Result { - // Convert hex checksum to bytes - let sha256 = hex::decode(version.installer.sha256) - .context("Invalid checksum hex")? - .try_into() - .map_err(|_| anyhow::anyhow!("Invalid checksum length"))?; - - Ok(Version { - version: version.version, - size: version.installer.size, - urls: version.installer.urls, - changelog: version.changelog, - sha256, - }) + data.extend_from_slice(&chunk); + } + + Ok(data) } } @@ -184,47 +86,51 @@ impl TryFrom for Version { mod test { use insta::assert_yaml_snapshot; + use crate::version::VersionArchitecture; + use super::*; // These 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`. - /// Test parsing of API responses (rollout 1, x86) - #[test] - fn test_api_version_info_provider_parser_x86() -> anyhow::Result<()> { - let response = format::SignedResponse::deserialize_and_verify_insecure(include_bytes!( - "../test-version-response.json" - ))?; - + /// Test HTTP version info provider + /// + /// We're not testing the correctness of [version] here, only the HTTP client + #[tokio::test] + async fn test_http_version_provider() -> anyhow::Result<()> { + let verifying_key = crate::format::key::VerifyingKey::from_hex( + "4d35f5376f1f58c41b2a0ee4600ae7811eace354f100227e853994deef38942d", + ) + .expect("valid key"); + + // Start HTTP server + let mut server = mockito::Server::new_async().await; + let _mock = server + .mock("GET", "/version") + // Respond with some version response payload + .with_body(include_bytes!("../test-version-response.json")) + .create(); + + let url = format!("{}/version", server.url()); + + // Construct query and provider let params = VersionParameters { architecture: VersionArchitecture::X86, rollout: 1., }; - - // Expect: The available latest versions for X86, where the rollout is 1. - let info = VersionInfo::try_from_response(¶ms, response.signed.clone())?; - - assert_yaml_snapshot!(info); - - Ok(()) - } - - /// Test parsing of API responses (rollout 0.01, arm64) - #[test] - fn test_api_version_info_provider_parser_arm64() -> anyhow::Result<()> { - let response = format::SignedResponse::deserialize_and_verify_insecure(include_bytes!( - "../test-version-response.json" - ))?; - - let params = VersionParameters { - architecture: VersionArchitecture::Arm64, - rollout: 0.01, + let info_provider = HttpVersionInfoProvider { + url, + pinned_certificate: None, + verifying_key, }; - let info = VersionInfo::try_from_response(¶ms, response.signed)?; + let info = info_provider + .get_version_info(params) + .await + .context("Expected valid version info")?; - // Expect: The available latest versions for arm64, where the rollout is .01. + // Expect: Our query should yield some version response assert_yaml_snapshot!(info); Ok(()) diff --git a/mullvad-update/src/format/deserializer.rs b/mullvad-update/src/format/deserializer.rs index 6c86f7c01b43..422d90d5b391 100644 --- a/mullvad-update/src/format/deserializer.rs +++ b/mullvad-update/src/format/deserializer.rs @@ -9,7 +9,7 @@ use super::{PartialSignedResponse, SignedResponse}; 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 { + pub fn deserialize_and_verify(key: &VerifyingKey, bytes: &[u8]) -> Result { Self::deserialize_and_verify_at_time(key, bytes, chrono::Utc::now()) } @@ -30,7 +30,7 @@ impl SignedResponse { /// 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, + key: &VerifyingKey, bytes: &[u8], current_time: chrono::DateTime, ) -> Result { @@ -108,7 +108,7 @@ mod test { ed25519_dalek::VerifyingKey::from_bytes(&pubkey.try_into().unwrap()).unwrap(); SignedResponse::deserialize_and_verify_at_time( - VerifyingKey(verifying_key), + &VerifyingKey(verifying_key), include_bytes!("../../test-version-response.json"), // It's 1970 again chrono::DateTime::UNIX_EPOCH, @@ -117,7 +117,7 @@ mod test { // Reject expired data SignedResponse::deserialize_and_verify_at_time( - VerifyingKey(verifying_key), + &VerifyingKey(verifying_key), include_bytes!("../../test-version-response.json"), // In the year 3000 chrono::DateTime::from_str("3000-01-01T00:00:00Z").unwrap(), diff --git a/mullvad-update/src/format/key.rs b/mullvad-update/src/format/key.rs index b0eed5b4cb09..0033ec30a13a 100644 --- a/mullvad-update/src/format/key.rs +++ b/mullvad-update/src/format/key.rs @@ -74,6 +74,15 @@ impl Serialize for SecretKey { #[derive(Debug, PartialEq, Eq)] pub struct VerifyingKey(pub ed25519_dalek::VerifyingKey); +impl VerifyingKey { + pub fn from_hex(s: &str) -> anyhow::Result { + let bytes = bytes_from_hex::<{ ed25519_dalek::PUBLIC_KEY_LENGTH }>(s)?; + Ok(Self( + ed25519_dalek::VerifyingKey::from_bytes(&bytes).context("Invalid ed25519 key")?, + )) + } +} + impl<'de> Deserialize<'de> for VerifyingKey { fn deserialize(deserializer: D) -> Result where diff --git a/mullvad-update/src/lib.rs b/mullvad-update/src/lib.rs index 89fbdee2edd3..f6f0b74e10ab 100644 --- a/mullvad-update/src/lib.rs +++ b/mullvad-update/src/lib.rs @@ -5,6 +5,7 @@ pub mod app; pub mod dir; pub mod fetch; pub mod verify; +pub mod version; /// Parser and serializer for version metadata pub mod format; diff --git a/mullvad-update/src/snapshots/mullvad_update__api__test__api_version_info_provider_parser_x86.snap b/mullvad-update/src/snapshots/mullvad_update__api__test__http_version_provider.snap similarity index 100% rename from mullvad-update/src/snapshots/mullvad_update__api__test__api_version_info_provider_parser_x86.snap rename to mullvad-update/src/snapshots/mullvad_update__api__test__http_version_provider.snap diff --git a/mullvad-update/src/snapshots/mullvad_update__api__test__api_version_info_provider_parser_arm64.snap b/mullvad-update/src/snapshots/mullvad_update__version__test__version_info_parser_arm64.snap similarity index 93% rename from mullvad-update/src/snapshots/mullvad_update__api__test__api_version_info_provider_parser_arm64.snap rename to mullvad-update/src/snapshots/mullvad_update__version__test__version_info_parser_arm64.snap index 91e6e9038930..8b2f63d5c699 100644 --- a/mullvad-update/src/snapshots/mullvad_update__api__test__api_version_info_provider_parser_arm64.snap +++ b/mullvad-update/src/snapshots/mullvad_update__version__test__version_info_parser_arm64.snap @@ -1,5 +1,5 @@ --- -source: mullvad-update/src/api.rs +source: mullvad-update/src/version.rs expression: info snapshot_kind: text --- diff --git a/mullvad-update/src/snapshots/mullvad_update__version__test__version_info_parser_x86.snap b/mullvad-update/src/snapshots/mullvad_update__version__test__version_info_parser_x86.snap new file mode 100644 index 000000000000..2a59903dbf9c --- /dev/null +++ b/mullvad-update/src/snapshots/mullvad_update__version__test__version_info_parser_x86.snap @@ -0,0 +1,83 @@ +--- +source: mullvad-update/src/version.rs +expression: info +snapshot_kind: text +--- +stable: + version: "2025.2" + urls: + - "https://releases.mullvad.net/desktop/releases/2025.2/MullvadVPN-2025.2.exe" + size: 101384672 + changelog: "[macos] Adding support for quicfuscator\n[windows] Less bugs" + sha256: + - 244 + - 178 + - 87 + - 19 + - 209 + - 63 + - 40 + - 25 + - 163 + - 0 + - 242 + - 255 + - 169 + - 77 + - 150 + - 116 + - 99 + - 170 + - 238 + - 160 + - 211 + - 87 + - 251 + - 215 + - 71 + - 154 + - 40 + - 17 + - 84 + - 186 + - 4 + - 96 +beta: + version: 2025.3-beta1 + urls: + - "https://releases.mullvad.net/desktop/releases/2025.3-beta1/MullvadVPN-2025.3-beta1_x64.exe" + size: 106297504 + changelog: "[macos] Adding support for quicfuscator\n[windows] Less bugs" + sha256: + - 12 + - 86 + - 154 + - 160 + - 145 + - 46 + - 185 + - 54 + - 5 + - 168 + - 80 + - 115 + - 68 + - 125 + - 66 + - 186 + - 12 + - 166 + - 18 + - 54 + - 27 + - 239 + - 120 + - 239 + - 4 + - 239 + - 3 + - 142 + - 128 + - 177 + - 84 + - 3 diff --git a/mullvad-update/src/version.rs b/mullvad-update/src/version.rs new file mode 100644 index 000000000000..c62e0c8113cb --- /dev/null +++ b/mullvad-update/src/version.rs @@ -0,0 +1,205 @@ +//! This module is used to extract the latest versions out of a raw [format::Response] using a query +//! [VersionParameters]. It also contains additional logic for filtering and validating the raw +//! deserialized response. +//! +//! The main input here is [VersionParameters], and the main output is [VersionInfo]. + +use std::cmp::Ordering; + +use anyhow::Context; +use mullvad_version::PreStableType; + +use crate::format; + +/// Query type for [VersionInfo] +#[derive(Debug)] +pub struct VersionParameters { + /// Architecture to retrieve data for + pub architecture: VersionArchitecture, + /// Rollout threshold. Any version in the response below this threshold will be ignored + pub rollout: f32, +} + +/// Installer architecture +pub type VersionArchitecture = format::Architecture; + +/// Version information derived from querying a [format::Response] using [VersionParameters] +#[derive(Debug, Clone)] +#[cfg_attr(test, derive(serde::Serialize))] +pub struct VersionInfo { + /// Stable version info + pub stable: Version, + /// Beta version info (if available and newer than `stable`). + /// If latest stable version is newer, this will be `None`. + pub beta: Option, +} + +/// Contains information about a version for the current target +#[derive(Debug, Clone)] +#[cfg_attr(test, derive(serde::Serialize))] +pub struct Version { + /// Version + pub version: mullvad_version::Version, + /// URLs to use for downloading the app installer + pub urls: Vec, + /// Size of installer, in bytes + pub size: usize, + /// Version changelog + pub changelog: String, + /// App installer checksum + pub sha256: [u8; 32], +} + +/// Helper used to lift the relevant installer out of the array in [format::Release] +#[derive(Clone)] +struct IntermediateVersion { + version: mullvad_version::Version, + changelog: String, + installer: format::Installer, +} + +impl VersionInfo { + /// Convert signed response data to public version type + /// NOTE: `response` is assumed to be verified and untampered. It is not verified. + pub fn try_from_response( + params: &VersionParameters, + response: format::Response, + ) -> anyhow::Result { + let mut releases = response.releases; + + // Sort releases by version + releases.sort_by(|a, b| a.version.partial_cmp(&b.version).unwrap_or(Ordering::Equal)); + + // Fail if there are duplicate versions. + // Check this before anything else so that it's rejected indepentently of `params`. + // Important! This must occur after sorting + if let Some(dup_version) = Self::find_duplicate_version(&releases) { + anyhow::bail!("API response contains at least one duplicated version: {dup_version}"); + } + + // Filter releases based on rollout and architecture + let releases: Vec<_> = releases + .into_iter() + // Filter out releases that are not rolled out to us + .filter(|release| release.rollout >= params.rollout) + // Include only installers for the requested architecture + .flat_map(|release| { + release + .installers + .into_iter() + .filter(|installer| params.architecture == installer.architecture) + // Map each artifact to a [IntermediateVersion] + .map(move |installer| { + IntermediateVersion { + version: release.version.clone(), + changelog: release.changelog.clone(), + installer, + } + }) + }) + .collect(); + + // Find latest stable version + let stable = releases + .iter() + .rfind(|release| release.version.pre_stable.is_none() && !release.version.is_dev()); + let Some(stable) = stable.cloned() else { + anyhow::bail!("No stable version found"); + }; + + // Find the latest beta version + let beta = releases + .iter() + // Find most recent beta version + .rfind(|release| matches!(release.version.pre_stable, Some(PreStableType::Beta(_))) && !release.version.is_dev()) + // If the latest beta version is older than latest stable, dispose of it + .filter(|release| release.version > stable.version) + .cloned(); + + Ok(Self { + stable: Version::try_from(stable)?, + beta: beta.map(|beta| Version::try_from(beta)).transpose()?, + }) + } + + /// Returns the first duplicated version found in `releases`. + /// `None` is returned if there are no duplicates. + /// NOTE: `releases` MUST be sorted + fn find_duplicate_version(releases: &[format::Release]) -> Option<&mullvad_version::Version> { + releases + .windows(2) + .find(|pair| &pair[0].version == &pair[1].version) + .map(|pair| &pair[0].version) + } +} + +impl TryFrom for Version { + type Error = anyhow::Error; + + fn try_from(version: IntermediateVersion) -> Result { + // Convert hex checksum to bytes + let sha256 = hex::decode(version.installer.sha256) + .context("Invalid checksum hex")? + .try_into() + .map_err(|_| anyhow::anyhow!("Invalid checksum length"))?; + + Ok(Version { + version: version.version, + size: version.installer.size, + urls: version.installer.urls, + changelog: version.changelog, + sha256, + }) + } +} + +#[cfg(test)] +mod test { + use insta::assert_yaml_snapshot; + + use super::*; + + // These 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`. + + /// Test version info response handler (rollout 1, x86) + #[test] + fn test_version_info_parser_x86() -> anyhow::Result<()> { + let response = format::SignedResponse::deserialize_and_verify_insecure(include_bytes!( + "../test-version-response.json" + ))?; + + let params = VersionParameters { + architecture: VersionArchitecture::X86, + rollout: 1., + }; + + // Expect: The available latest versions for X86, where the rollout is 1. + let info = VersionInfo::try_from_response(¶ms, response.signed.clone())?; + + assert_yaml_snapshot!(info); + + Ok(()) + } + + /// Test version info response handler (rollout 0.01, arm64) + #[test] + fn test_version_info_parser_arm64() -> anyhow::Result<()> { + let response = format::SignedResponse::deserialize_and_verify_insecure(include_bytes!( + "../test-version-response.json" + ))?; + + let params = VersionParameters { + architecture: VersionArchitecture::Arm64, + rollout: 0.01, + }; + + let info = VersionInfo::try_from_response(¶ms, response.signed)?; + + // Expect: The available latest versions for arm64, where the rollout is .01. + assert_yaml_snapshot!(info); + + Ok(()) + } +} From 3a5f0a53c2f93f125417f9f1aa1ad9a68cffb05b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20L=C3=B6nnhager?= Date: Mon, 17 Feb 2025 09:44:00 +0100 Subject: [PATCH 020/112] Implement beta downloader --- installer-downloader/src/controller.rs | 96 ++++++++++++++++--- installer-downloader/src/delegate.rs | 16 ++++ installer-downloader/src/resource.rs | 3 + .../src/winapi_impl/delegate.rs | 43 ++++++++- installer-downloader/src/winapi_impl/ui.rs | 29 +++++- installer-downloader/tests/controller.rs | 31 ++++++ .../tests/snapshots/controller__download.snap | 4 + ...controller__failed_directory_creation.snap | 4 + .../controller__failed_verification.snap | 5 + .../snapshots/controller__fetch_version.snap | 4 + 10 files changed, 217 insertions(+), 18 deletions(-) diff --git a/installer-downloader/src/controller.rs b/installer-downloader/src/controller.rs index 100fcf179617..e241daf4d8b9 100644 --- a/installer-downloader/src/controller.rs +++ b/installer-downloader/src/controller.rs @@ -19,6 +19,8 @@ enum TaskMessage { SetVersionInfo(VersionInfo), BeginDownload, Cancel, + TryBeta, + TryStable, } /// Provide a directory to use for [AppDownloader] @@ -78,6 +80,7 @@ impl AppController { delegate.disable_download_button(); delegate.hide_cancel_button(); delegate.hide_beta_text(); + delegate.hide_stable_text(); let (task_tx, task_rx) = mpsc::channel(1); tokio::spawn(handle_action_messages::( @@ -105,6 +108,14 @@ impl AppController { delegate.on_cancel(move || { let _ = tx.try_send(TaskMessage::Cancel); }); + let tx = task_tx.clone(); + delegate.on_beta_link(move || { + let _ = tx.try_send(TaskMessage::TryBeta); + }); + let tx = task_tx.clone(); + delegate.on_stable_link(move || { + let _ = tx.try_send(TaskMessage::TryStable); + }); } } @@ -139,6 +150,12 @@ where } } +#[derive(Clone, Copy, PartialEq)] +enum TargetVersion { + Beta, + Stable, +} + /// Async worker that handles actions such as initiating a download, cancelling it, and updating /// labels. async fn handle_action_messages( @@ -152,6 +169,8 @@ async fn handle_action_messages( let mut version_info = None; let mut active_download = None; + let mut target_version = TargetVersion::Stable; + while let Some(msg) = rx.recv().await { match msg { TaskMessage::SetVersionInfo(new_version_info) => { @@ -166,6 +185,38 @@ async fn handle_action_messages( }); version_info = Some(new_version_info); } + TaskMessage::TryBeta => { + let Some(version_info) = version_info.as_ref() else { + continue; + }; + let Some(beta_info) = version_info.beta.as_ref() else { + continue; + }; + + target_version = TargetVersion::Beta; + let version_label = format_latest_version(beta_info); + + queue.queue_main(move |self_| { + self_.show_stable_text(); + self_.hide_beta_text(); + self_.set_status_text(&version_label); + }); + } + TaskMessage::TryStable => { + let Some(version_info) = version_info.as_ref() else { + continue; + }; + let stable_info = &version_info.stable; + + target_version = TargetVersion::Stable; + let version_label = format_latest_version(stable_info); + + queue.queue_main(move |self_| { + self_.hide_stable_text(); + self_.show_beta_text(); + self_.set_status_text(&version_label); + }); + } TaskMessage::BeginDownload => { if active_download.is_some() { continue; @@ -188,17 +239,25 @@ async fn handle_action_messages( // Begin download let (tx, rx) = oneshot::channel(); queue.queue_main(move |self_| { + let selected_version = match target_version { + TargetVersion::Stable => &version_info.stable, + TargetVersion::Beta => { + version_info.beta.as_ref().expect("selected version exists") + } + }; + // TODO: Select appropriate URLs - let Some(app_url) = version_info.stable.urls.first() else { + let Some(app_url) = selected_version.urls.first() else { return; }; - let app_version = version_info.stable.version; - let app_sha256 = version_info.stable.sha256; - let app_size = version_info.stable.size; + let app_version = selected_version.version.clone(); + let app_sha256 = selected_version.sha256; + let app_size = selected_version.size; self_.set_download_text(""); self_.hide_download_button(); self_.hide_beta_text(); + self_.hide_stable_text(); self_.show_cancel_button(); self_.enable_cancel_button(); self_.show_download_progress(); @@ -224,22 +283,33 @@ async fn handle_action_messages( 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) + let Some(version_info) = version_info.as_ref() else { + continue; }; + let selected_version = match target_version { + TargetVersion::Stable => &version_info.stable, + TargetVersion::Beta => { + version_info.beta.as_ref().expect("selected version exists") + } + }; + + let version_label = format_latest_version(&selected_version); + let has_beta = version_info.beta.is_some(); + 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(); + + if target_version == TargetVersion::Stable { + if has_beta { + self_.show_beta_text(); + } + } else { + self_.show_stable_text(); } + self_.hide_cancel_button(); self_.hide_download_progress(); self_.set_download_progress(0); diff --git a/installer-downloader/src/delegate.rs b/installer-downloader/src/delegate.rs index 3e82d2ef314b..de4313aeb2a7 100644 --- a/installer-downloader/src/delegate.rs +++ b/installer-downloader/src/delegate.rs @@ -17,6 +17,16 @@ pub trait AppDelegate { where F: Fn() + Send + 'static; + /// Register click handler for the beta link + fn on_beta_link(&mut self, callback: F) + where + F: Fn() + Send + 'static; + + /// Register click handler for the stable link + fn on_stable_link(&mut self, callback: F) + where + F: Fn() + Send + 'static; + /// Set status text fn set_status_text(&mut self, text: &str); @@ -62,6 +72,12 @@ pub trait AppDelegate { /// Hide beta text fn hide_beta_text(&mut self); + /// Show stable text + fn show_stable_text(&mut self); + + /// Hide stable text + fn hide_stable_text(&mut self); + /// Exit the application fn quit(&mut self); diff --git a/installer-downloader/src/resource.rs b/installer-downloader/src/resource.rs index b5f5a0bf852e..af54455872d4 100644 --- a/installer-downloader/src/resource.rs +++ b/installer-downloader/src/resource.rs @@ -16,6 +16,9 @@ pub const BETA_PREFACE_DESC: &str = "Want to try the new Beta version? "; /// Beta link text pub const BETA_LINK_TEXT: &str = "Click here!"; +/// Stable link text +pub const STABLE_LINK_TEXT: &str = "Back to stable version"; + /// Dimensions of cancel button (including padding) pub const CANCEL_BUTTON_SIZE: (usize, usize) = (150, 40); diff --git a/installer-downloader/src/winapi_impl/delegate.rs b/installer-downloader/src/winapi_impl/delegate.rs index 353dff969189..69176cf897ae 100644 --- a/installer-downloader/src/winapi_impl/delegate.rs +++ b/installer-downloader/src/winapi_impl/delegate.rs @@ -24,6 +24,20 @@ impl AppDelegate for AppWindow { register_click_handler(self.window.handle, self.cancel_button.handle, callback); } + fn on_beta_link(&mut self, callback: F) + where + F: Fn() + Send + 'static, + { + register_label_click_handler(self.window.handle, self.beta_link.handle, callback); + } + + fn on_stable_link(&mut self, callback: F) + where + F: Fn() + Send + 'static, + { + register_label_click_handler(self.window.handle, self.stable_link.handle, callback); + } + fn set_status_text(&mut self, text: &str) { self.status_text.set_text(text); } @@ -91,6 +105,14 @@ impl AppDelegate for AppWindow { self.beta_link.set_visible(false); } + fn show_stable_text(&mut self) { + self.stable_link.set_visible(true); + } + + fn hide_stable_text(&mut self) { + self.stable_link.set_visible(false); + } + fn quit(&mut self) { nwg::stop_thread_dispatch(); } @@ -107,9 +129,28 @@ fn register_click_handler( parent: nwg::ControlHandle, button: nwg::ControlHandle, callback: impl Fn() + 'static, +) { + register_click_handler_inner(parent, button, callback, Event::OnButtonClick); +} + +/// Register a window message for clicking this button that triggers `callback`. +fn register_label_click_handler( + parent: nwg::ControlHandle, + button: nwg::ControlHandle, + callback: impl Fn() + 'static, +) { + register_click_handler_inner(parent, button, callback, Event::OnLabelClick); +} + +/// Register a window message for clicking this button that triggers `callback`. +fn register_click_handler_inner( + parent: nwg::ControlHandle, + button: nwg::ControlHandle, + callback: impl Fn() + 'static, + click_event: Event, ) { nwg::bind_event_handler(&button, &parent, move |evt, _, handle| { - if evt == Event::OnButtonClick && handle == button { + if evt == click_event && handle == button { callback(); } }); diff --git a/installer-downloader/src/winapi_impl/ui.rs b/installer-downloader/src/winapi_impl/ui.rs index 1b4704ad3d6a..f53d00dccd89 100644 --- a/installer-downloader/src/winapi_impl/ui.rs +++ b/installer-downloader/src/winapi_impl/ui.rs @@ -14,7 +14,8 @@ 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, + DOWNLOAD_BUTTON_SIZE, DOWNLOAD_BUTTON_TEXT, STABLE_LINK_TEXT, WINDOW_HEIGHT, WINDOW_TITLE, + WINDOW_WIDTH, }; use super::delegate::QueueContext; @@ -32,6 +33,8 @@ pub const SET_LABEL_HANDLER_ID: usize = 0x10000; 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 stable link. +pub const STABLE_LINK_HANDLER_ID: usize = 0x10003; /// Unique ID of the handler for the beta link. pub const BETA_LINK_HANDLER_ID: usize = 0x10002; @@ -57,6 +60,7 @@ pub struct AppWindow { pub beta_prefix: nwg::Label, pub beta_link: nwg::Label, + pub stable_link: nwg::Label, } impl AppWindow { @@ -121,14 +125,26 @@ impl AppWindow { .text(BETA_PREFACE_DESC) .h_align(nwg::HTextAlign::Left) .build(&mut self.beta_prefix)?; + + let link_font = create_link_font()?; + nwg::Label::builder() .parent(&self.window) .size((128, 24)) .text(BETA_LINK_TEXT) - .font(Some(&create_link_font()?)) + .font(Some(&link_font)) .h_align(nwg::HTextAlign::Left) .build(&mut self.beta_link)?; + nwg::Label::builder() + .parent(&self.window) + .size((240, 24)) + .text(STABLE_LINK_TEXT) + .font(Some(&link_font)) + .h_align(nwg::HTextAlign::Left) + .build(&mut self.stable_link)?; + self.stable_link.set_visible(false); + const PROGRESS_BAR_MARGIN: i32 = 48; nwg::ProgressBar::builder() .parent(&self.window) @@ -167,6 +183,11 @@ impl AppWindow { + LOWER_AREA_YPADDING, ); + self.stable_link.set_position( + 24, + self.window.size().1 as i32 - 24 - self.stable_link.size().1 as i32, + ); + handle_link_messages(&self.window, &self.stable_link, STABLE_LINK_HANDLER_ID)?; self.beta_prefix.set_position( 24, self.window.size().1 as i32 - 24 - self.beta_prefix.size().1 as i32, @@ -175,7 +196,7 @@ impl AppWindow { 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)?; + handle_link_messages(&self.window, &self.beta_link, BETA_LINK_HANDLER_ID)?; self.window.set_visible(true); @@ -277,7 +298,7 @@ fn handle_banner_label_colors( } /// Register a window message handler for the beta link component -fn handle_beta_link_messages( +fn handle_link_messages( parent: &nwg::Window, link: &nwg::Label, handler_id: usize, diff --git a/installer-downloader/tests/controller.rs b/installer-downloader/tests/controller.rs index 03a4842ec73d..8ac544b98add 100644 --- a/installer-downloader/tests/controller.rs +++ b/installer-downloader/tests/controller.rs @@ -147,6 +147,10 @@ pub struct FakeAppDelegate { pub download_callback: Option>, /// Callback registered by `on_cancel` pub cancel_callback: Option>, + /// Callback registered by `on_beta_link` + pub beta_callback: Option>, + /// Callback registered by `on_stable_link` + pub stable_callback: Option>, /// State of delegate pub state: DelegateState, /// Queue used to simulate the main thread @@ -165,6 +169,7 @@ pub struct DelegateState { pub download_progress: u32, pub download_progress_visible: bool, pub beta_text_visible: bool, + pub stable_text_visible: bool, pub quit: bool, /// Record of method calls. pub call_log: Vec, @@ -189,6 +194,22 @@ impl AppDelegate for FakeAppDelegate { self.cancel_callback = Some(Box::new(callback)); } + fn on_beta_link(&mut self, callback: F) + where + F: Fn() + Send + 'static, + { + self.state.call_log.push("on_beta_link".into()); + self.beta_callback = Some(Box::new(callback)); + } + + fn on_stable_link(&mut self, callback: F) + where + F: Fn() + Send + 'static, + { + self.state.call_log.push("on_stable_link".into()); + self.stable_callback = Some(Box::new(callback)); + } + fn set_status_text(&mut self, text: &str) { self.state .call_log @@ -270,6 +291,16 @@ impl AppDelegate for FakeAppDelegate { self.state.beta_text_visible = false; } + fn show_stable_text(&mut self) { + self.state.call_log.push("show_stable_text".into()); + self.state.stable_text_visible = true; + } + + fn hide_stable_text(&mut self) { + self.state.call_log.push("hide_stable_text".into()); + self.state.stable_text_visible = false; + } + fn quit(&mut self) { self.state.call_log.push("quit".into()); self.state.quit = true; diff --git a/installer-downloader/tests/snapshots/controller__download.snap b/installer-downloader/tests/snapshots/controller__download.snap index 3d79a1922e71..ded40dd4a406 100644 --- a/installer-downloader/tests/snapshots/controller__download.snap +++ b/installer-downloader/tests/snapshots/controller__download.snap @@ -12,6 +12,7 @@ download_button_enabled: true download_progress: 0 download_progress_visible: false beta_text_visible: false +stable_text_visible: false quit: false call_log: - hide_download_progress @@ -19,8 +20,11 @@ call_log: - disable_download_button - hide_cancel_button - hide_beta_text + - hide_stable_text - "set_status_text: Loading version details..." - on_download - on_cancel + - on_beta_link + - on_stable_link - "set_status_text: Version: 2025.1" - enable_download_button diff --git a/installer-downloader/tests/snapshots/controller__failed_directory_creation.snap b/installer-downloader/tests/snapshots/controller__failed_directory_creation.snap index 9cb6d6fcb19c..2abc02673538 100644 --- a/installer-downloader/tests/snapshots/controller__failed_directory_creation.snap +++ b/installer-downloader/tests/snapshots/controller__failed_directory_creation.snap @@ -12,6 +12,7 @@ download_button_enabled: true download_progress: 0 download_progress_visible: false beta_text_visible: false +stable_text_visible: false quit: false call_log: - hide_download_progress @@ -19,9 +20,12 @@ call_log: - disable_download_button - hide_cancel_button - hide_beta_text + - hide_stable_text - "set_status_text: Loading version details..." - on_download - on_cancel + - on_beta_link + - on_stable_link - "set_status_text: Version: 2025.1" - enable_download_button - "set_status_text: Failed to create download directory" diff --git a/installer-downloader/tests/snapshots/controller__failed_verification.snap b/installer-downloader/tests/snapshots/controller__failed_verification.snap index b7825fb1d5ec..acf41ff257ca 100644 --- a/installer-downloader/tests/snapshots/controller__failed_verification.snap +++ b/installer-downloader/tests/snapshots/controller__failed_verification.snap @@ -12,6 +12,7 @@ download_button_enabled: true download_progress: 100 download_progress_visible: true beta_text_visible: false +stable_text_visible: false quit: false call_log: - hide_download_progress @@ -19,14 +20,18 @@ call_log: - disable_download_button - hide_cancel_button - hide_beta_text + - hide_stable_text - "set_status_text: Loading version details..." - on_download - on_cancel + - on_beta_link + - on_stable_link - "set_status_text: Version: 2025.1" - enable_download_button - "set_download_text: " - hide_download_button - hide_beta_text + - hide_stable_text - show_cancel_button - enable_cancel_button - show_download_progress diff --git a/installer-downloader/tests/snapshots/controller__fetch_version.snap b/installer-downloader/tests/snapshots/controller__fetch_version.snap index 24c54ce95c89..17f1b954cfa2 100644 --- a/installer-downloader/tests/snapshots/controller__fetch_version.snap +++ b/installer-downloader/tests/snapshots/controller__fetch_version.snap @@ -12,6 +12,7 @@ download_button_enabled: false download_progress: 0 download_progress_visible: false beta_text_visible: false +stable_text_visible: false quit: false call_log: - hide_download_progress @@ -19,6 +20,9 @@ call_log: - disable_download_button - hide_cancel_button - hide_beta_text + - hide_stable_text - "set_status_text: Loading version details..." - on_download - on_cancel + - on_beta_link + - on_stable_link From 7afe2c6ce0bf6cbb3c0e6c8526a1b694fed8acd0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20L=C3=B6nnhager?= Date: Mon, 17 Feb 2025 13:54:37 +0100 Subject: [PATCH 021/112] Add arrow to 'stable link' on Windows --- .../src/winapi_impl/delegate.rs | 15 ++++-- installer-downloader/src/winapi_impl/ui.rs | 51 ++++++++++++++++--- 2 files changed, 55 insertions(+), 11 deletions(-) diff --git a/installer-downloader/src/winapi_impl/delegate.rs b/installer-downloader/src/winapi_impl/delegate.rs index 69176cf897ae..da2e5ca3173a 100644 --- a/installer-downloader/src/winapi_impl/delegate.rs +++ b/installer-downloader/src/winapi_impl/delegate.rs @@ -35,7 +35,7 @@ impl AppDelegate for AppWindow { where F: Fn() + Send + 'static, { - register_label_click_handler(self.window.handle, self.stable_link.handle, callback); + register_frame_click_handler(self.stable_message_frame.handle, callback); } fn set_status_text(&mut self, text: &str) { @@ -106,11 +106,11 @@ impl AppDelegate for AppWindow { } fn show_stable_text(&mut self) { - self.stable_link.set_visible(true); + self.stable_message_frame.set_visible(true); } fn hide_stable_text(&mut self) { - self.stable_link.set_visible(false); + self.stable_message_frame.set_visible(false); } fn quit(&mut self) { @@ -156,6 +156,15 @@ fn register_click_handler_inner( }); } +/// Register a window message for clicking anything within a frame. +fn register_frame_click_handler(frame: nwg::ControlHandle, callback: impl Fn() + 'static) { + nwg::bind_event_handler(&frame, &frame, move |evt, _, _handle| { + if [Event::OnLabelClick, Event::OnImageFrameClick].contains(&evt) { + 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. /// diff --git a/installer-downloader/src/winapi_impl/ui.rs b/installer-downloader/src/winapi_impl/ui.rs index f53d00dccd89..bd5589becaab 100644 --- a/installer-downloader/src/winapi_impl/ui.rs +++ b/installer-downloader/src/winapi_impl/ui.rs @@ -33,6 +33,8 @@ pub const SET_LABEL_HANDLER_ID: usize = 0x10000; 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 stable link prefix. +pub const STABLE_LINK_PREFIX_HANDLER_ID: usize = 0x10004; /// Unique ID of the handler for the stable link. pub const STABLE_LINK_HANDLER_ID: usize = 0x10003; /// Unique ID of the handler for the beta link. @@ -60,6 +62,11 @@ pub struct AppWindow { pub beta_prefix: nwg::Label, pub beta_link: nwg::Label, + + pub arrow_font: nwg::Font, + + pub stable_message_frame: nwg::ImageFrame, + pub stable_prefix: nwg::Label, pub stable_link: nwg::Label, } @@ -136,14 +143,30 @@ impl AppWindow { .h_align(nwg::HTextAlign::Left) .build(&mut self.beta_link)?; - nwg::Label::builder() + nwg::ImageFrame::builder() .parent(&self.window) .size((240, 24)) + .build(&mut self.stable_message_frame)?; + + nwg::Font::builder() + // TODO: Ensure font always exists + .family("Segoe Fluent Icons") + .size(10) + .build(&mut self.arrow_font)?; + nwg::Label::builder() + .parent(&self.stable_message_frame) + .size((16, 24)) + .text("") + .font(Some(&self.arrow_font)) + .h_align(nwg::HTextAlign::Left) + .build(&mut self.stable_prefix)?; + nwg::Label::builder() + .parent(&self.stable_message_frame) + .size((240, 24)) .text(STABLE_LINK_TEXT) .font(Some(&link_font)) .h_align(nwg::HTextAlign::Left) .build(&mut self.stable_link)?; - self.stable_link.set_visible(false); const PROGRESS_BAR_MARGIN: i32 = 48; nwg::ProgressBar::builder() @@ -183,11 +206,23 @@ impl AppWindow { + LOWER_AREA_YPADDING, ); - self.stable_link.set_position( + self.stable_message_frame.set_position( 24, - self.window.size().1 as i32 - 24 - self.stable_link.size().1 as i32, + self.window.size().1 as i32 - 24 - self.stable_message_frame.size().1 as i32, ); - handle_link_messages(&self.window, &self.stable_link, STABLE_LINK_HANDLER_ID)?; + self.stable_link.set_position(16, 0); + self.stable_prefix.set_position(4, 12 - 4); + handle_link_messages( + &self.stable_message_frame.handle, + &self.stable_prefix, + STABLE_LINK_PREFIX_HANDLER_ID, + )?; + handle_link_messages( + &self.stable_message_frame.handle, + &self.stable_link, + STABLE_LINK_HANDLER_ID, + )?; + self.beta_prefix.set_position( 24, self.window.size().1 as i32 - 24 - self.beta_prefix.size().1 as i32, @@ -196,7 +231,7 @@ impl AppWindow { self.beta_prefix.position().0 + self.beta_prefix.size().0 as i32, self.beta_prefix.position().1, ); - handle_link_messages(&self.window, &self.beta_link, BETA_LINK_HANDLER_ID)?; + handle_link_messages(&self.window.handle, &self.beta_link, BETA_LINK_HANDLER_ID)?; self.window.set_visible(true); @@ -299,12 +334,12 @@ fn handle_banner_label_colors( /// Register a window message handler for the beta link component fn handle_link_messages( - parent: &nwg::Window, + parent: &nwg::ControlHandle, link: &nwg::Label, handler_id: usize, ) -> Result { 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| { + nwg::bind_raw_event_handler(&parent, 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) From ee2858eb52d8e3fb28f8e994c3c4fb9d6f8ea2ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20L=C3=B6nnhager?= Date: Mon, 17 Feb 2025 15:33:10 +0100 Subject: [PATCH 022/112] Add improved error messages Co-authored-by: Joakim Hulthe --- installer-downloader/Cargo.toml | 1 + installer-downloader/assets/alert-circle.png | Bin 0 -> 1115 bytes installer-downloader/assets/alert-circle.svg | 8 ++ installer-downloader/convert-assets.py | 1 + installer-downloader/src/controller.rs | 119 ++++++++++++++---- installer-downloader/src/delegate.rs | 23 ++++ installer-downloader/src/resource.rs | 33 +++++ installer-downloader/src/ui_downloader.rs | 38 +++++- .../src/winapi_impl/delegate.rs | 46 ++++++- installer-downloader/src/winapi_impl/ui.rs | 69 +++++++++- installer-downloader/tests/controller.rs | 35 +++++- .../snapshots/controller__download-2.snap | 13 ++ .../snapshots/controller__download-3.snap | 13 ++ .../tests/snapshots/controller__download.snap | 5 + ...controller__failed_directory_creation.snap | 18 ++- .../controller__failed_verification.snap | 23 +++- .../controller__fetch_version-2.snap | 9 ++ .../snapshots/controller__fetch_version.snap | 5 + 18 files changed, 419 insertions(+), 40 deletions(-) create mode 100644 installer-downloader/assets/alert-circle.png create mode 100644 installer-downloader/assets/alert-circle.svg diff --git a/installer-downloader/Cargo.toml b/installer-downloader/Cargo.toml index be2c830c2d26..79c706607417 100644 --- a/installer-downloader/Cargo.toml +++ b/installer-downloader/Cargo.toml @@ -19,6 +19,7 @@ windows-sys = { version = "0.52.0", features = ["Win32_System", "Win32_System_Li anyhow = "1.0" tokio = { version = "1", features = ["full"] } async-trait = "0.1" +serde = { workspace = true, features = ["derive"] } mullvad-update = { path = "../mullvad-update" } diff --git a/installer-downloader/assets/alert-circle.png b/installer-downloader/assets/alert-circle.png new file mode 100644 index 0000000000000000000000000000000000000000..d53d283e332559268e23b5d86fc7e4067aa4e773 GIT binary patch literal 1115 zcmV-h1f=_kP)EX>4Tx04R}tkv&MmKpe$iQ%gmvB6bjQ$WWc^q9Tr^ibb$c+6t{Ym|Xe=O&XFE z7e~Rh;NZt%)xpJCR|i)?5c~jf7n~Gbq{ROvg%&X$9QWhhy~o`NRA);4}N!R7N@7&q);3Pyx8`~I1tIO`ldWEd<*ogxxKabaryvcsjKB1;NTFL zC{gyh$GdyGd;9lHyT2bW_;QT!P6MC-000JJOGiWi{{a60|De66lK=n!32;bRa{vGh z*8l(w*8xH(n|J^K00(qQO+^Rk0uc@f5yuS#fB*mjSxH1eR9M69S3PgjP!v7)xiBC$ zDx@|^MO6tZwgO7ywE1v^5L=)U9r{}`0e_|43d#sW;-pGqIv}>HER=iz0{RC~?86e$ z5WDm_F zWB>?A&q_U^r=CH0Q`7IUKuR&Yxi0`G2GA~~SH$HW1j)$?Ovm-Sog4MT51|2k%&%`M zs(lEqPf7EsDA|rzniXvT-Q0#n@m>MA6iKHMKcMg+QEk;O1kf&|SH$BR0M|5J+k_W1YQoqI z6(|-2U}1(L7zlu~qX3>C0OfPIGV)5`yB-C@SaW9|y&fPT#!w!0EOTd&(c_R6==lLq z4j`~XxVZ{qcOnYZ+15Rzz%W>z-4GaMpBfU7Zr(fsa4Uqb5NbG#bF^f)Zu=@A00mMC z6Fi(64&n$)=c-}MZteqgqCU`$WZ#HaotJ-fa*|Ch8*1(l;Jk* z*5rw!(FgCvYmMqu7n81)-Wr@_R&_^Bgr!8)c@b$R_p;`ir=$on1Ht0R)vOER6NGhv hD#z{SJN!3I`w0(9(#NNgSc?Dv002ovPDHLkV1oOb>MsBQ literal 0 HcmV?d00001 diff --git a/installer-downloader/assets/alert-circle.svg b/installer-downloader/assets/alert-circle.svg new file mode 100644 index 000000000000..abb561611f11 --- /dev/null +++ b/installer-downloader/assets/alert-circle.svg @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/installer-downloader/convert-assets.py b/installer-downloader/convert-assets.py index ede39abe21a6..1dcdfa97a23d 100644 --- a/installer-downloader/convert-assets.py +++ b/installer-downloader/convert-assets.py @@ -6,3 +6,4 @@ 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) +svg2png(url="assets/alert-circle.svg", write_to="assets/alert-circle.png", output_width=32) diff --git a/installer-downloader/src/controller.rs b/installer-downloader/src/controller.rs index e241daf4d8b9..09860ec56c0a 100644 --- a/installer-downloader/src/controller.rs +++ b/installer-downloader/src/controller.rs @@ -85,11 +85,12 @@ impl AppController { let (task_tx, task_rx) = mpsc::channel(1); tokio::spawn(handle_action_messages::( delegate.queue(), + task_tx.clone(), task_rx, )); delegate.set_status_text(resource::FETCH_VERSION_DESC); tokio::spawn(fetch_app_version_info::( - delegate, + delegate.queue(), task_tx.clone(), version_provider, )); @@ -121,32 +122,77 @@ impl AppController { /// Background task that fetches app version data. fn fetch_app_version_info( - delegate: &mut Delegate, + queue: Delegate::Queue, download_tx: mpsc::Sender, version_provider: VersionProvider, ) -> impl Future where - Delegate: AppDelegate, + Delegate: AppDelegate + 'static, VersionProvider: VersionInfoProvider + Send, { - let queue = delegate.queue(); - async move { - let version_params = VersionParameters { - // TODO: detect current architecture - architecture: VersionArchitecture::X86, - // For the downloader, the rollout version is always preferred - rollout: 1., - }; - - // TODO: handle errors, retry - let Ok(version_info) = version_provider.get_version_info(version_params).await else { + loop { + let version_params = VersionParameters { + // TODO: detect current architecture + architecture: VersionArchitecture::X86, + // For the downloader, the rollout version is always preferred + rollout: 1., + }; + + let err = match version_provider.get_version_info(version_params).await { + Ok(version_info) => { + let _ = download_tx.try_send(TaskMessage::SetVersionInfo(version_info)); + return; + } + Err(err) => err, + }; + + eprintln!("Failed to get version info: {err}"); + + enum Action { + Retry, + Cancel, + } + + let (action_tx, mut action_rx) = mpsc::channel(1); + + // show error message (needs to happen on the UI thread) + // send Action when user presses a button to contin queue.queue_main(move |self_| { - self_.set_status_text("Failed to fetch version info"); + self_.hide_download_button(); + + let (retry_tx, cancel_tx) = (action_tx.clone(), action_tx); + + self_.set_status_text(""); + self_.on_error_message_retry(move || { + let _ = retry_tx.try_send(Action::Retry); + }); + self_.on_error_message_cancel(move || { + let _ = cancel_tx.try_send(Action::Cancel); + }); + self_.show_error_message(crate::delegate::ErrorMessage { + status_text: resource::FETCH_VERSION_ERROR_DESC.to_owned(), + cancel_button_text: resource::FETCH_VERSION_ERROR_CANCEL_BUTTON_TEXT.to_owned(), + retry_button_text: resource::FETCH_VERSION_ERROR_RETRY_BUTTON_TEXT.to_owned(), + }); }); - return; - }; - let _ = download_tx.try_send(TaskMessage::SetVersionInfo(version_info)); + + // wait for user to press either button + let Some(action) = action_rx.recv().await else { + panic!("channel was dropped? argh") + }; + + match action { + Action::Retry => { + continue; + } + Action::Cancel => { + queue.queue_main(|self_| { + self_.quit(); + }); + } + } + } } } @@ -160,6 +206,7 @@ enum TargetVersion { /// labels. async fn handle_action_messages( queue: D::Queue, + tx: mpsc::Sender, mut rx: mpsc::Receiver, ) where D: AppDelegate + 'static, @@ -218,19 +265,39 @@ async fn handle_action_messages( }); } TaskMessage::BeginDownload => { - if active_download.is_some() { - continue; + if let Some(_) = active_download.take() { + println!("Interrupting ongoing download"); } let Some(version_info) = version_info.clone() else { continue; }; + let (retry_tx, cancel_tx) = (tx.clone(), tx.clone()); + queue.queue_main(move |self_| { + self_.hide_error_message(); + self_.on_error_message_retry(move || { + let _ = retry_tx.try_send(TaskMessage::BeginDownload); + }); + self_.on_error_message_cancel(move || { + let _ = cancel_tx.try_send(TaskMessage::Cancel); + }); + }); + // Create temporary dir let download_dir = match DirProvider::create_download_dir().await { Ok(dir) => dir, Err(_err) => { queue.queue_main(move |self_| { - self_.set_status_text("Failed to create download directory"); + self_.set_status_text(""); + self_.hide_download_button(); + self_.hide_beta_text(); + self_.hide_stable_text(); + + self_.show_error_message(crate::delegate::ErrorMessage { + status_text: "Failed to create download directory".to_owned(), + cancel_button_text: "Cancel".to_owned(), + retry_button_text: "Try again".to_owned(), + }); }); continue; } @@ -277,11 +344,10 @@ async fn handle_action_messages( active_download = rx.await.ok(); } TaskMessage::Cancel => { - let Some(active_download) = active_download.take() else { - continue; - }; - active_download.abort(); - let _ = active_download.await; + if let Some(active_download) = active_download.take() { + active_download.abort(); + let _ = active_download.await; + } let Some(version_info) = version_info.as_ref() else { continue; @@ -301,6 +367,7 @@ async fn handle_action_messages( self_.set_status_text(&version_label); self_.set_download_text(""); self_.show_download_button(); + self_.hide_error_message(); if target_version == TargetVersion::Stable { if has_beta { diff --git a/installer-downloader/src/delegate.rs b/installer-downloader/src/delegate.rs index de4313aeb2a7..40d54efc3ff1 100644 --- a/installer-downloader/src/delegate.rs +++ b/installer-downloader/src/delegate.rs @@ -78,6 +78,22 @@ pub trait AppDelegate { /// Hide stable text fn hide_stable_text(&mut self); + /// Show error message + fn show_error_message(&mut self, message: ErrorMessage); + + /// Hide error message + fn hide_error_message(&mut self); + + /// Set error cancel callback + fn on_error_message_retry(&mut self, callback: F) + where + F: Fn() + Send + 'static; + + /// Set error cancel callback + fn on_error_message_cancel(&mut self, callback: F) + where + F: Fn() + Send + 'static; + /// Exit the application fn quit(&mut self); @@ -85,6 +101,13 @@ pub trait AppDelegate { fn queue(&self) -> Self::Queue; } +#[derive(Default, serde::Serialize)] +pub struct ErrorMessage { + pub status_text: String, + pub cancel_button_text: String, + pub retry_button_text: String, +} + /// Schedules actions on the UI thread from other threads pub trait AppDelegateQueue: Send { fn queue_main(&self, callback: F); diff --git a/installer-downloader/src/resource.rs b/installer-downloader/src/resource.rs index af54455872d4..a8aa106c82c5 100644 --- a/installer-downloader/src/resource.rs +++ b/installer-downloader/src/resource.rs @@ -40,11 +40,44 @@ 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."; +/// Displayed while fetching version info from the API failed (retry button) +pub const FETCH_VERSION_ERROR_RETRY_BUTTON_TEXT: &str = "Try again"; + +/// Displayed while fetching version info from the API failed (cancel button) +pub const FETCH_VERSION_ERROR_CANCEL_BUTTON_TEXT: &str = "Cancel"; + /// The first part of "Downloading from ... (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 when download fails +pub const DOWNLOAD_FAILED_DESC: &str = "Download failed"; + +/// Displayed when download fails (retry button) +pub const DOWNLOAD_FAILED_RETRY_BUTTON_TEXT: &str = "Redownload"; + +/// Displayed when download fails (cancel button) +pub const DOWNLOAD_FAILED_CANCEL_BUTTON_TEXT: &str = "Cancel"; + +/// Displayed when download fails +pub const VERIFICATION_FAILED_DESC: &str = "Couldn’t verify download, please try downloading again or contact our support by sending an email at support@mullvadvpn.net"; + +/// Displayed when download fails (retry button) +pub const VERIFICATION_FAILED_RETRY_BUTTON_TEXT: &str = "Redownload"; + +/// Displayed when download fails (cancel button) +pub const VERIFICATION_FAILED_CANCEL_BUTTON_TEXT: &str = "Cancel"; + /// Displayed after verification pub const VERIFICATION_SUCCEEDED_DESC: &str = "Verification successful. Starting install..."; + +/// Displayed when launch fails +pub const LAUNCH_FAILED_DESC: &str = "Couldn’t launch installer, please try again or contact our support by sending an email at support@mullvadvpn.net"; + +/// Displayed when launch fails (retry button) +pub const LAUNCH_FAILED_RETRY_BUTTON_TEXT: &str = "Try again"; + +/// Displayed when launch fails (cancel button) +pub const LAUNCH_FAILED_CANCEL_BUTTON_TEXT: &str = "Cancel"; diff --git a/installer-downloader/src/ui_downloader.rs b/installer-downloader/src/ui_downloader.rs index 437320a537f6..69e405797b0c 100644 --- a/installer-downloader/src/ui_downloader.rs +++ b/installer-downloader/src/ui_downloader.rs @@ -49,7 +49,17 @@ impl AppDownl } Err(err) => { self.queue.queue_main(move |self_| { - self_.set_download_text("ERROR: Download failed. Please try again."); + self_.set_status_text(""); + self_.set_download_text(""); + self_.hide_download_progress(); + self_.hide_download_button(); + self_.hide_cancel_button(); + + self_.show_error_message(crate::delegate::ErrorMessage { + status_text: resource::DOWNLOAD_FAILED_DESC.to_owned(), + cancel_button_text: resource::DOWNLOAD_FAILED_CANCEL_BUTTON_TEXT.to_owned(), + retry_button_text: resource::DOWNLOAD_FAILED_RETRY_BUTTON_TEXT.to_owned(), + }); }); Err(err) @@ -68,7 +78,19 @@ impl AppDownl } Err(error) => { self.queue.queue_main(move |self_| { - self_.set_download_text("ERROR: Verification failed!"); + self_.set_status_text(""); + self_.set_download_text(""); + self_.hide_download_progress(); + self_.hide_download_button(); + self_.hide_cancel_button(); + + self_.show_error_message(crate::delegate::ErrorMessage { + status_text: resource::VERIFICATION_FAILED_DESC.to_owned(), + cancel_button_text: resource::VERIFICATION_FAILED_CANCEL_BUTTON_TEXT + .to_owned(), + retry_button_text: resource::VERIFICATION_FAILED_RETRY_BUTTON_TEXT + .to_owned(), + }); }); Err(error) @@ -87,7 +109,17 @@ impl AppDownl } Err(error) => { self.queue.queue_main(move |self_| { - self_.set_download_text("ERROR: Failed to launch installer!"); + self_.set_status_text(""); + self_.set_download_text(""); + self_.hide_download_progress(); + self_.hide_download_button(); + self_.hide_cancel_button(); + + self_.show_error_message(crate::delegate::ErrorMessage { + status_text: resource::LAUNCH_FAILED_DESC.to_owned(), + cancel_button_text: resource::LAUNCH_FAILED_CANCEL_BUTTON_TEXT.to_owned(), + retry_button_text: resource::LAUNCH_FAILED_RETRY_BUTTON_TEXT.to_owned(), + }); }); Err(error) diff --git a/installer-downloader/src/winapi_impl/delegate.rs b/installer-downloader/src/winapi_impl/delegate.rs index da2e5ca3173a..b4b9793bddc6 100644 --- a/installer-downloader/src/winapi_impl/delegate.rs +++ b/installer-downloader/src/winapi_impl/delegate.rs @@ -1,6 +1,7 @@ //! This module implements [AppDelegate] and [Queue], which allows the NWG UI to be hooked up to our //! generic controller. +use installer_downloader::delegate::ErrorMessage; use native_windows_gui::{self as nwg, Event}; use windows_sys::Win32::UI::WindowsAndMessaging::PostMessageW; @@ -39,7 +40,12 @@ impl AppDelegate for AppWindow { } fn set_status_text(&mut self, text: &str) { - self.status_text.set_text(text); + if !text.is_empty() { + self.status_text.set_visible(true); + self.status_text.set_text(text); + } else { + self.status_text.set_visible(false); + } } fn set_download_text(&mut self, text: &str) { @@ -113,6 +119,44 @@ impl AppDelegate for AppWindow { self.stable_message_frame.set_visible(false); } + fn show_error_message(&mut self, error: ErrorMessage) { + self.error_view.error_text.set_text(&error.status_text); + self.error_view + .error_retry_button + .set_text(&error.retry_button_text); + self.error_view + .error_cancel_button + .set_text(&error.cancel_button_text); + + self.error_view.error_frame.set_visible(true); + } + + fn hide_error_message(&mut self) { + self.error_view.error_frame.set_visible(false); + } + + fn on_error_message_retry(&mut self, callback: F) + where + F: Fn() + Send + 'static, + { + register_click_handler( + self.error_view.error_frame.handle, + self.error_view.error_retry_button.handle, + callback, + ); + } + + fn on_error_message_cancel(&mut self, callback: F) + where + F: Fn() + Send + 'static, + { + register_click_handler( + self.error_view.error_frame.handle, + self.error_view.error_cancel_button.handle, + callback, + ); + } + fn quit(&mut self) { nwg::stop_thread_dispatch(); } diff --git a/installer-downloader/src/winapi_impl/ui.rs b/installer-downloader/src/winapi_impl/ui.rs index bd5589becaab..820972a9141d 100644 --- a/installer-downloader/src/winapi_impl/ui.rs +++ b/installer-downloader/src/winapi_impl/ui.rs @@ -1,6 +1,5 @@ //! This module handles setting up and rendering changes to the UI -//use std::borrow::Cow; use std::cell::RefCell; use std::rc::Rc; @@ -22,6 +21,7 @@ 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"); +static ERROR_IMAGE_DATA: &[u8] = include_bytes!("../../assets/alert-circle.png"); const BACKGROUND_COLOR: [u8; 3] = [0x19, 0x2e, 0x45]; /// Beta link color: #003E92 @@ -68,6 +68,71 @@ pub struct AppWindow { pub stable_message_frame: nwg::ImageFrame, pub stable_prefix: nwg::Label, pub stable_link: nwg::Label, + + pub error_view: ErrorView, +} + +#[derive(Default)] +pub struct ErrorView { + pub error_frame: nwg::Frame, + pub error_text: nwg::Label, + pub error_icon: nwg::ImageFrame, + pub error_icon_bmp: nwg::Bitmap, + pub error_cancel_button: nwg::Button, + pub error_retry_button: nwg::Button, +} + +impl ErrorView { + pub fn layout(&mut self, parent: &nwg::ControlHandle) -> Result<(), nwg::NwgError> { + nwg::Frame::builder() + .parent(parent) + .position((0, 102)) + .size((WINDOW_WIDTH as i32, 204)) + .flags(nwg::FrameFlags::empty()) + .build(&mut self.error_frame)?; + + nwg::Label::builder() + .parent(&self.error_frame) + .v_align(nwg::VTextAlign::Center) + .position((80, 45)) + .size((488, 64)) + .build(&mut self.error_text)?; + + nwg::ImageFrame::builder() + .parent(&self.error_frame) + .size((32, 32)) + .position((34, 49)) + .build(&mut self.error_icon)?; + + // TODO: put buttons 24px below bottom edge of text label + let text_bottom_y = 96; // TODO + let button_top_y = text_bottom_y + 24; + + nwg::Button::builder() + .parent(&self.error_frame) + .position((304, button_top_y)) + .size((232, 32)) + .build(&mut self.error_cancel_button)?; + + nwg::Button::builder() + .parent(&self.error_frame) + .position((64, button_top_y)) + .size((232, 32)) + .build(&mut self.error_retry_button)?; + + self.load_error_icon()?; + + Ok(()) + } + + /// Load the error icon and display it in `error_icon` + fn load_error_icon(&mut self) -> Result<(), nwg::NwgError> { + let src = ImageDecoder::new()?.from_stream(ERROR_IMAGE_DATA)?; + let frame = src.frame(0)?; + self.error_icon_bmp = frame.as_bitmap().unwrap(); + self.error_icon.set_bitmap(Some(&self.error_icon_bmp)); + Ok(()) + } } impl AppWindow { @@ -235,6 +300,8 @@ impl AppWindow { self.window.set_visible(true); + self.error_view.layout(&self.window.handle)?; + let event_handle = self.window.handle; let app = Rc::new(RefCell::new(self)); diff --git a/installer-downloader/tests/controller.rs b/installer-downloader/tests/controller.rs index 8ac544b98add..4c8f94813b41 100644 --- a/installer-downloader/tests/controller.rs +++ b/installer-downloader/tests/controller.rs @@ -6,7 +6,7 @@ use insta::assert_yaml_snapshot; use installer_downloader::controller::{AppController, DirectoryProvider}; -use installer_downloader::delegate::{AppDelegate, AppDelegateQueue}; +use installer_downloader::delegate::{AppDelegate, AppDelegateQueue, ErrorMessage}; use installer_downloader::ui_downloader::UiAppDownloaderParameters; use mullvad_update::api::VersionInfoProvider; use mullvad_update::app::{AppDownloader, DownloadError}; @@ -151,6 +151,10 @@ pub struct FakeAppDelegate { pub beta_callback: Option>, /// Callback registered by `on_stable_link` pub stable_callback: Option>, + /// Callback registered by `on_error_cancel` + pub error_cancel_callback: Option>, + /// Callback registered by `on_error_retry` + pub error_retry_callback: Option>, /// State of delegate pub state: DelegateState, /// Queue used to simulate the main thread @@ -170,6 +174,8 @@ pub struct DelegateState { pub download_progress_visible: bool, pub beta_text_visible: bool, pub stable_text_visible: bool, + pub error_message_visible: bool, + pub error_message: ErrorMessage, pub quit: bool, /// Record of method calls. pub call_log: Vec, @@ -301,6 +307,33 @@ impl AppDelegate for FakeAppDelegate { self.state.stable_text_visible = false; } + fn show_error_message(&mut self, message: ErrorMessage) { + self.state.call_log.push("show_error_message".into()); + self.state.error_message = message; + self.state.error_message_visible = true; + } + + fn hide_error_message(&mut self) { + self.state.call_log.push("hide_error_message".into()); + self.state.error_message_visible = false; + } + + fn on_error_message_cancel(&mut self, callback: F) + where + F: Fn() + Send + 'static, + { + self.state.call_log.push("on_error_message_cancel".into()); + self.error_cancel_callback = Some(Box::new(callback)); + } + + fn on_error_message_retry(&mut self, callback: F) + where + F: Fn() + Send + 'static, + { + self.state.call_log.push("on_error_message_retry".into()); + self.error_retry_callback = Some(Box::new(callback)); + } + fn quit(&mut self) { self.state.call_log.push("quit".into()); self.state.quit = true; diff --git a/installer-downloader/tests/snapshots/controller__download-2.snap b/installer-downloader/tests/snapshots/controller__download-2.snap index 390e1727783d..ee35e8f8eef7 100644 --- a/installer-downloader/tests/snapshots/controller__download-2.snap +++ b/installer-downloader/tests/snapshots/controller__download-2.snap @@ -12,6 +12,12 @@ download_button_enabled: true download_progress: 0 download_progress_visible: true beta_text_visible: false +stable_text_visible: false +error_message_visible: false +error_message: + status_text: "" + cancel_button_text: "" + retry_button_text: "" quit: false call_log: - hide_download_progress @@ -19,14 +25,21 @@ call_log: - disable_download_button - hide_cancel_button - hide_beta_text + - hide_stable_text - "set_status_text: Loading version details..." - on_download - on_cancel + - on_beta_link + - on_stable_link - "set_status_text: Version: 2025.1" - enable_download_button + - hide_error_message + - on_error_message_retry + - on_error_message_cancel - "set_download_text: " - hide_download_button - hide_beta_text + - hide_stable_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 index 6d918cf3414f..5b40b79b9d85 100644 --- a/installer-downloader/tests/snapshots/controller__download-3.snap +++ b/installer-downloader/tests/snapshots/controller__download-3.snap @@ -12,6 +12,12 @@ download_button_enabled: true download_progress: 100 download_progress_visible: true beta_text_visible: false +stable_text_visible: false +error_message_visible: false +error_message: + status_text: "" + cancel_button_text: "" + retry_button_text: "" quit: true call_log: - hide_download_progress @@ -19,14 +25,21 @@ call_log: - disable_download_button - hide_cancel_button - hide_beta_text + - hide_stable_text - "set_status_text: Loading version details..." - on_download - on_cancel + - on_beta_link + - on_stable_link - "set_status_text: Version: 2025.1" - enable_download_button + - hide_error_message + - on_error_message_retry + - on_error_message_cancel - "set_download_text: " - hide_download_button - hide_beta_text + - hide_stable_text - show_cancel_button - enable_cancel_button - show_download_progress diff --git a/installer-downloader/tests/snapshots/controller__download.snap b/installer-downloader/tests/snapshots/controller__download.snap index ded40dd4a406..12a2423d91ca 100644 --- a/installer-downloader/tests/snapshots/controller__download.snap +++ b/installer-downloader/tests/snapshots/controller__download.snap @@ -13,6 +13,11 @@ download_progress: 0 download_progress_visible: false beta_text_visible: false stable_text_visible: false +error_message_visible: false +error_message: + status_text: "" + cancel_button_text: "" + retry_button_text: "" quit: false call_log: - hide_download_progress diff --git a/installer-downloader/tests/snapshots/controller__failed_directory_creation.snap b/installer-downloader/tests/snapshots/controller__failed_directory_creation.snap index 2abc02673538..b3f970514902 100644 --- a/installer-downloader/tests/snapshots/controller__failed_directory_creation.snap +++ b/installer-downloader/tests/snapshots/controller__failed_directory_creation.snap @@ -3,9 +3,9 @@ source: installer-downloader/tests/controller.rs expression: delegate.state snapshot_kind: text --- -status_text: Failed to create download directory +status_text: "" download_text: "" -download_button_visible: true +download_button_visible: false cancel_button_visible: false cancel_button_enabled: false download_button_enabled: true @@ -13,6 +13,11 @@ download_progress: 0 download_progress_visible: false beta_text_visible: false stable_text_visible: false +error_message_visible: true +error_message: + status_text: Failed to create download directory + cancel_button_text: Cancel + retry_button_text: Try again quit: false call_log: - hide_download_progress @@ -28,4 +33,11 @@ call_log: - on_stable_link - "set_status_text: Version: 2025.1" - enable_download_button - - "set_status_text: Failed to create download directory" + - hide_error_message + - on_error_message_retry + - on_error_message_cancel + - "set_status_text: " + - hide_download_button + - hide_beta_text + - hide_stable_text + - show_error_message diff --git a/installer-downloader/tests/snapshots/controller__failed_verification.snap b/installer-downloader/tests/snapshots/controller__failed_verification.snap index acf41ff257ca..3bb0a7e130b8 100644 --- a/installer-downloader/tests/snapshots/controller__failed_verification.snap +++ b/installer-downloader/tests/snapshots/controller__failed_verification.snap @@ -3,16 +3,21 @@ source: installer-downloader/tests/controller.rs expression: delegate.state snapshot_kind: text --- -status_text: "Version: 2025.1" -download_text: "ERROR: Verification failed!" +status_text: "" +download_text: "" download_button_visible: false -cancel_button_visible: true +cancel_button_visible: false cancel_button_enabled: false download_button_enabled: true download_progress: 100 -download_progress_visible: true +download_progress_visible: false beta_text_visible: false stable_text_visible: false +error_message_visible: true +error_message: + status_text: "Couldn’t verify download, please try downloading again or contact our support by sending an email at support@mullvadvpn.net" + cancel_button_text: Cancel + retry_button_text: Redownload quit: false call_log: - hide_download_progress @@ -28,6 +33,9 @@ call_log: - on_stable_link - "set_status_text: Version: 2025.1" - enable_download_button + - hide_error_message + - on_error_message_retry + - on_error_message_cancel - "set_download_text: " - hide_download_button - hide_beta_text @@ -41,4 +49,9 @@ call_log: - "set_download_text: Downloading from mullvad.net... (100%)" - "set_download_text: Download complete. Verifying..." - disable_cancel_button - - "set_download_text: ERROR: Verification failed!" + - "set_status_text: " + - "set_download_text: " + - hide_download_progress + - hide_download_button + - hide_cancel_button + - show_error_message diff --git a/installer-downloader/tests/snapshots/controller__fetch_version-2.snap b/installer-downloader/tests/snapshots/controller__fetch_version-2.snap index 3d79a1922e71..12a2423d91ca 100644 --- a/installer-downloader/tests/snapshots/controller__fetch_version-2.snap +++ b/installer-downloader/tests/snapshots/controller__fetch_version-2.snap @@ -12,6 +12,12 @@ download_button_enabled: true download_progress: 0 download_progress_visible: false beta_text_visible: false +stable_text_visible: false +error_message_visible: false +error_message: + status_text: "" + cancel_button_text: "" + retry_button_text: "" quit: false call_log: - hide_download_progress @@ -19,8 +25,11 @@ call_log: - disable_download_button - hide_cancel_button - hide_beta_text + - hide_stable_text - "set_status_text: Loading version details..." - on_download - on_cancel + - on_beta_link + - on_stable_link - "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 index 17f1b954cfa2..eb1065929176 100644 --- a/installer-downloader/tests/snapshots/controller__fetch_version.snap +++ b/installer-downloader/tests/snapshots/controller__fetch_version.snap @@ -13,6 +13,11 @@ download_progress: 0 download_progress_visible: false beta_text_visible: false stable_text_visible: false +error_message_visible: false +error_message: + status_text: "" + cancel_button_text: "" + retry_button_text: "" quit: false call_log: - hide_download_progress From 1281dd790d376a0a04c38cf26c4ad5bc3bbc49db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20L=C3=B6nnhager?= Date: Mon, 17 Feb 2025 21:16:30 +0100 Subject: [PATCH 023/112] Select random mirror for downloading --- Cargo.lock | 1 + installer-downloader/Cargo.toml | 1 + installer-downloader/src/controller.rs | 10 ++++++++-- 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e9d1acf67a99..888450184fe3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2130,6 +2130,7 @@ dependencies = [ "native-windows-gui", "nsvg", "objc_id", + "rand 0.8.5", "serde", "tokio", "windows-sys 0.52.0", diff --git a/installer-downloader/Cargo.toml b/installer-downloader/Cargo.toml index 79c706607417..029a10dcbb9d 100644 --- a/installer-downloader/Cargo.toml +++ b/installer-downloader/Cargo.toml @@ -19,6 +19,7 @@ windows-sys = { version = "0.52.0", features = ["Win32_System", "Win32_System_Li anyhow = "1.0" tokio = { version = "1", features = ["full"] } async-trait = "0.1" +rand = { version = "0.8.5" } serde = { workspace = true, features = ["derive"] } mullvad-update = { path = "../mullvad-update" } diff --git a/installer-downloader/src/controller.rs b/installer-downloader/src/controller.rs index 09860ec56c0a..103f06a3f5f9 100644 --- a/installer-downloader/src/controller.rs +++ b/installer-downloader/src/controller.rs @@ -9,6 +9,7 @@ use mullvad_update::{ app::{self, AppDownloader}, version::{Version, VersionArchitecture, VersionInfo, VersionParameters}, }; +use rand::seq::SliceRandom; use std::future::Future; use std::path::PathBuf; @@ -313,8 +314,7 @@ async fn handle_action_messages( } }; - // TODO: Select appropriate URLs - let Some(app_url) = selected_version.urls.first() else { + let Some(app_url) = select_cdn_url(&selected_version.urls) else { return; }; let app_version = selected_version.version.clone(); @@ -386,6 +386,12 @@ async fn handle_action_messages( } } +/// Select a mirror to download from +/// Currently, the selection is random +fn select_cdn_url(urls: &[String]) -> Option<&str> { + urls.choose(&mut rand::thread_rng()).map(String::as_str) +} + fn format_latest_version(version: &Version) -> String { format!("{}: {}", resource::LATEST_VERSION_PREFIX, version.version) } From f769e2dedbedd9b5323178eb3d746305e0db2bf3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20L=C3=B6nnhager?= Date: Tue, 18 Feb 2025 11:58:21 +0100 Subject: [PATCH 024/112] Print reason for error --- Cargo.lock | 1 + installer-downloader/src/controller.rs | 12 +++++++++++- mullvad-update/Cargo.toml | 1 + mullvad-update/src/app.rs | 18 +++++++++++------- 4 files changed, 24 insertions(+), 8 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 888450184fe3..c65542b0dc58 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3004,6 +3004,7 @@ dependencies = [ "serde", "serde_json", "sha2", + "thiserror 2.0.9", "tokio", ] diff --git a/installer-downloader/src/controller.rs b/installer-downloader/src/controller.rs index 103f06a3f5f9..5021785fb51c 100644 --- a/installer-downloader/src/controller.rs +++ b/installer-downloader/src/controller.rs @@ -11,6 +11,7 @@ use mullvad_update::{ }; use rand::seq::SliceRandom; +use std::error::Error; use std::future::Future; use std::path::PathBuf; use tokio::sync::{mpsc, oneshot}; @@ -339,7 +340,16 @@ async fn handle_action_messages( }); let ui_downloader = UiAppDownloader::new(self_, downloader); - let _ = tx.send(tokio::spawn(app::install_and_upgrade(ui_downloader))); + let _ = tx.send(tokio::spawn(async move { + if let Err(err) = app::install_and_upgrade(ui_downloader).await { + eprintln!("install_and_upgrade failed: {err}"); + let mut source = err.source(); + while let Some(error) = source { + eprintln!("caused by: {error}"); + source = error.source(); + } + } + })); }); active_download = rx.await.ok(); } diff --git a/mullvad-update/Cargo.toml b/mullvad-update/Cargo.toml index 4c1cc1d3b90b..5fbf620adfb7 100644 --- a/mullvad-update/Cargo.toml +++ b/mullvad-update/Cargo.toml @@ -24,6 +24,7 @@ reqwest = { version = "0.12.9", features = ["blocking", "json"] } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } sha2 = "0.10" +thiserror = { workspace = true } tokio = { version = "1", features = ["full"] } async-trait = "0.1" diff --git a/mullvad-update/src/app.rs b/mullvad-update/src/app.rs index f543c1595529..f9de31c4996f 100644 --- a/mullvad-update/src/app.rs +++ b/mullvad-update/src/app.rs @@ -9,14 +9,18 @@ use crate::{ verify::{AppVerifier, Sha256Verifier}, }; -#[derive(Debug)] +#[derive(Debug, thiserror::Error)] pub enum DownloadError { - CreateDir(anyhow::Error), - FetchSignature(anyhow::Error), - FetchApp(anyhow::Error), - Verification(anyhow::Error), - Launch(std::io::Error), - InstallFailed(anyhow::Error), + #[error("Failed to create download directory")] + CreateDir(#[source] anyhow::Error), + #[error("Failed to download app")] + FetchApp(#[source] anyhow::Error), + #[error("Failed to verify app")] + Verification(#[source] anyhow::Error), + #[error("Failed to launch app")] + Launch(#[source] std::io::Error), + #[error("App installer failed")] + InstallFailed(#[source] anyhow::Error), } /// Parameters required to construct an [AppDownloader]. From 4f16a6c7b08762b7e14ce1278e5c02c3e24f4ef5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20L=C3=B6nnhager?= Date: Wed, 19 Feb 2025 14:13:31 +0100 Subject: [PATCH 025/112] Do not serialize rollout if 1 --- mullvad-update/src/format/mod.rs | 51 ++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/mullvad-update/src/format/mod.rs b/mullvad-update/src/format/mod.rs index 61fac6a8c257..eaa6a6de002d 100644 --- a/mullvad-update/src/format/mod.rs +++ b/mullvad-update/src/format/mod.rs @@ -62,6 +62,7 @@ pub struct Release { pub installers: Vec, /// Fraction of users that should receive the new version #[serde(default = "default_rollout")] + #[serde(skip_serializing_if = "is_default_rollout")] pub rollout: f32, } @@ -70,6 +71,10 @@ fn default_rollout() -> f32 { 1. } +fn is_default_rollout(b: impl std::borrow::Borrow) -> bool { + (b.borrow() - default_rollout()).abs() < f32::EPSILON +} + /// App installer #[derive(Debug, Deserialize, Serialize, Clone)] pub struct Installer { @@ -99,3 +104,49 @@ pub struct ResponseSignature { pub keyid: key::VerifyingKey, pub sig: key::Signature, } + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn test_default_rollout_serialize() { + // rollout should not be serialized if equal to default value + let serialized = serde_json::to_value(Release { + version: "2024.1".parse().unwrap(), + changelog: "".to_owned(), + installers: vec![], + rollout: default_rollout(), + }) + .unwrap(); + + assert_eq!( + serialized, + serde_json::json!({ + "version": "2024.1", + "changelog": "", + "installers": [], + }) + ); + + // rollout *should* be serialized if not equal to default value + let rollout = 0.99; + let serialized = serde_json::to_value(Release { + version: "2024.1".parse().unwrap(), + changelog: "".to_owned(), + installers: vec![], + rollout, + }) + .unwrap(); + + assert_eq!( + serialized, + serde_json::json!({ + "version": "2024.1", + "changelog": "", + "installers": [], + "rollout": rollout, + }) + ); + } +} From 147a28c9e0244c0c4ca24fdd30f720616f2fb3b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20L=C3=B6nnhager?= Date: Thu, 20 Feb 2025 11:51:06 +0100 Subject: [PATCH 026/112] Do not deserialize back canonical JSON --- mullvad-update/src/format/deserializer.rs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/mullvad-update/src/format/deserializer.rs b/mullvad-update/src/format/deserializer.rs index 422d90d5b391..3c19376ecb3a 100644 --- a/mullvad-update/src/format/deserializer.rs +++ b/mullvad-update/src/format/deserializer.rs @@ -86,9 +86,7 @@ pub(super) fn deserialize_and_verify( Ok(PartialSignedResponse { signature: partial_data.signature, - // Serialize back in case something was lost during deserialization - signed: serde_json::from_slice(&canon_data) - .context("Failed to serialize canonical JSON")?, + signed: partial_data.signed, }) } From 6b56ceaee3d03b6b186c9014a1ebc93b23197b2b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20L=C3=B6nnhager?= Date: Thu, 20 Feb 2025 13:04:09 +0100 Subject: [PATCH 027/112] Add version counter to metadata and rename expiry field --- installer-downloader/src/controller.rs | 4 ++- mullvad-update/src/api.rs | 15 +++++---- mullvad-update/src/format/deserializer.rs | 39 +++++++++++++++++++---- mullvad-update/src/format/mod.rs | 4 ++- mullvad-update/src/format/serializer.rs | 2 +- mullvad-update/src/version.rs | 5 +++ mullvad-update/test-pubkey | 1 + mullvad-update/test-version-response.json | 18 +++++------ mullvad-update/update-testdata.sh | 19 +++++++++++ 9 files changed, 81 insertions(+), 26 deletions(-) create mode 100644 mullvad-update/test-pubkey create mode 100644 mullvad-update/update-testdata.sh diff --git a/installer-downloader/src/controller.rs b/installer-downloader/src/controller.rs index 5021785fb51c..c4383dff7d8b 100644 --- a/installer-downloader/src/controller.rs +++ b/installer-downloader/src/controller.rs @@ -53,7 +53,7 @@ pub fn initialize_controller(delegate: &mut T) { type DirProvider = TempDirProvider; // Version info provider to use - const TEST_PUBKEY: &str = "4d35f5376f1f58c41b2a0ee4600ae7811eace354f100227e853994deef38942d"; + const TEST_PUBKEY: &str = include_str!("../../mullvad-update/test-pubkey"); let verifying_key = mullvad_update::format::key::VerifyingKey::from_hex(TEST_PUBKEY).expect("valid key"); let version_provider = HttpVersionInfoProvider { @@ -139,6 +139,8 @@ where architecture: VersionArchitecture::X86, // For the downloader, the rollout version is always preferred rollout: 1., + // The downloader allows any version + lowest_metadata_version: 0, }; let err = match version_provider.get_version_info(version_params).await { diff --git a/mullvad-update/src/api.rs b/mullvad-update/src/api.rs index 864c0745a225..8695daa2156f 100644 --- a/mullvad-update/src/api.rs +++ b/mullvad-update/src/api.rs @@ -26,8 +26,11 @@ pub struct HttpVersionInfoProvider { impl VersionInfoProvider for HttpVersionInfoProvider { async fn get_version_info(&self, params: VersionParameters) -> anyhow::Result { let raw_json = Self::get(&self.url, self.pinned_certificate.clone()).await?; - let response = - format::SignedResponse::deserialize_and_verify(&self.verifying_key, &raw_json)?; + let response = format::SignedResponse::deserialize_and_verify( + &self.verifying_key, + &raw_json, + params.lowest_metadata_version, + )?; VersionInfo::try_from_response(¶ms, response.signed) } @@ -99,10 +102,9 @@ mod test { /// We're not testing the correctness of [version] here, only the HTTP client #[tokio::test] async fn test_http_version_provider() -> anyhow::Result<()> { - let verifying_key = crate::format::key::VerifyingKey::from_hex( - "4d35f5376f1f58c41b2a0ee4600ae7811eace354f100227e853994deef38942d", - ) - .expect("valid key"); + let verifying_key = + crate::format::key::VerifyingKey::from_hex(include_str!("../test-pubkey")) + .expect("valid key"); // Start HTTP server let mut server = mockito::Server::new_async().await; @@ -118,6 +120,7 @@ mod test { let params = VersionParameters { architecture: VersionArchitecture::X86, rollout: 1., + lowest_metadata_version: 0, }; let info_provider = HttpVersionInfoProvider { url, diff --git a/mullvad-update/src/format/deserializer.rs b/mullvad-update/src/format/deserializer.rs index 3c19376ecb3a..0cde83bf1b32 100644 --- a/mullvad-update/src/format/deserializer.rs +++ b/mullvad-update/src/format/deserializer.rs @@ -9,8 +9,12 @@ use super::{PartialSignedResponse, SignedResponse}; 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::deserialize_and_verify_at_time(key, bytes, chrono::Utc::now()) + pub fn deserialize_and_verify( + key: &VerifyingKey, + bytes: &[u8], + min_metadata_version: usize, + ) -> Result { + Self::deserialize_and_verify_at_time(key, bytes, chrono::Utc::now(), min_metadata_version) } /// This method is used for testing, and skips all verification. @@ -33,6 +37,7 @@ impl SignedResponse { key: &VerifyingKey, bytes: &[u8], current_time: chrono::DateTime, + min_metadata_version: usize, ) -> Result { // Deserialize and verify signature let partial_data = deserialize_and_verify(&key, bytes)?; @@ -42,10 +47,19 @@ impl SignedResponse { .context("Failed to deserialize response")?; // Reject time if the data has expired - if current_time >= signed_response.expires { + if current_time >= signed_response.metadata_expiry { anyhow::bail!( "Version metadata has expired: valid until {}", - signed_response.expires + signed_response.metadata_expiry + ); + } + + // Reject data if the version counter is below `min_metadata_version` + if signed_response.metadata_version < min_metadata_version { + anyhow::bail!( + "Version metadata is too old: {}, must be at least {}", + signed_response.metadata_version, + min_metadata_version, ); } @@ -99,9 +113,7 @@ mod test { /// Test that a valid signed version response is successfully deserialized and verified #[test] fn test_response_deserialization_and_verification() { - const TEST_PUBKEY: &str = - "4d35f5376f1f58c41b2a0ee4600ae7811eace354f100227e853994deef38942d"; - let pubkey = hex::decode(TEST_PUBKEY).unwrap(); + let pubkey = hex::decode(include_str!("../../test-pubkey")).unwrap(); let verifying_key = ed25519_dalek::VerifyingKey::from_bytes(&pubkey.try_into().unwrap()).unwrap(); @@ -110,6 +122,8 @@ mod test { include_bytes!("../../test-version-response.json"), // It's 1970 again chrono::DateTime::UNIX_EPOCH, + // Accept any version + 0, ) .expect("expected valid signed version metadata"); @@ -119,7 +133,18 @@ mod test { include_bytes!("../../test-version-response.json"), // In the year 3000 chrono::DateTime::from_str("3000-01-01T00:00:00Z").unwrap(), + // Accept any version + 0, ) .expect_err("expected expired version metadata"); + + // Reject expired version number + SignedResponse::deserialize_and_verify_at_time( + &VerifyingKey(verifying_key), + include_bytes!("../../test-version-response.json"), + chrono::DateTime::UNIX_EPOCH, + usize::MAX, + ) + .expect_err("expected rejected version number"); } } diff --git a/mullvad-update/src/format/mod.rs b/mullvad-update/src/format/mod.rs index eaa6a6de002d..edbe24ab4fbd 100644 --- a/mullvad-update/src/format/mod.rs +++ b/mullvad-update/src/format/mod.rs @@ -44,8 +44,10 @@ struct PartialSignedResponse { #[serde(deny_unknown_fields)] #[cfg_attr(test, derive(Clone))] pub struct Response { + /// Version counter + pub metadata_version: usize, /// When the signature expires - pub expires: chrono::DateTime, + pub metadata_expiry: chrono::DateTime, /// Available app releases pub releases: Vec, } diff --git a/mullvad-update/src/format/serializer.rs b/mullvad-update/src/format/serializer.rs index 4c40037e2d2e..6698c9282f6b 100644 --- a/mullvad-update/src/format/serializer.rs +++ b/mullvad-update/src/format/serializer.rs @@ -22,7 +22,7 @@ use super::{key, PartialSignedResponse, Response, ResponseSignature, SignedRespo impl SignedResponse { pub fn sign(key: key::SecretKey, response: Response) -> anyhow::Result { // Refuse to sign expired data - if response.expires < chrono::Utc::now() { + if response.metadata_expiry < chrono::Utc::now() { anyhow::bail!("Signing failed since the data has expired"); } diff --git a/mullvad-update/src/version.rs b/mullvad-update/src/version.rs index c62e0c8113cb..5317cf8d49df 100644 --- a/mullvad-update/src/version.rs +++ b/mullvad-update/src/version.rs @@ -18,6 +18,9 @@ pub struct VersionParameters { pub architecture: VersionArchitecture, /// Rollout threshold. Any version in the response below this threshold will be ignored pub rollout: f32, + /// Lowest allowed `metadata_version` in the version data + /// Typically the current version plus 1 + pub lowest_metadata_version: usize, } /// Installer architecture @@ -173,6 +176,7 @@ mod test { let params = VersionParameters { architecture: VersionArchitecture::X86, rollout: 1., + lowest_metadata_version: 0, }; // Expect: The available latest versions for X86, where the rollout is 1. @@ -193,6 +197,7 @@ mod test { let params = VersionParameters { architecture: VersionArchitecture::Arm64, rollout: 0.01, + lowest_metadata_version: 0, }; let info = VersionInfo::try_from_response(¶ms, response.signed)?; diff --git a/mullvad-update/test-pubkey b/mullvad-update/test-pubkey new file mode 100644 index 000000000000..f5b25b1f244d --- /dev/null +++ b/mullvad-update/test-pubkey @@ -0,0 +1 @@ +BB4EF63FFDCC6BD5A19C30CD23B9DE03099407A04463418F17AE338B98AA09D4 \ No newline at end of file diff --git a/mullvad-update/test-version-response.json b/mullvad-update/test-version-response.json index 0484159d5b45..d4d72698e6b3 100644 --- a/mullvad-update/test-version-response.json +++ b/mullvad-update/test-version-response.json @@ -1,10 +1,11 @@ { "signature": { - "keyid": "4d35f5376f1f58c41b2a0ee4600ae7811eace354f100227e853994deef38942d", - "sig": "7dc4f2d491b972d98ead6a252022dd5cbe2d3829ae28f174129ee94bcd3d1329d19db90d46251c81d75e04e49db29ae950899bcb4e6cf7f64c3fedec3ee0ee08" + "keyid": "bb4ef63ffdcc6bd5a19c30cd23b9de03099407a04463418f17ae338b98aa09d4", + "sig": "253ec37846dcd909bfc5119c0e0d06535767e179eb8b4465015eaa95f4bed362c8c9186311192c987871722bf7d319d44e4f04eaf79c269820bc13ff1a901f0b" }, "signed": { - "expires": "2025-07-02T15:33:00Z", + "metadata_version": 0, + "metadata_expiry": "2025-07-02T15:33:00Z", "releases": [ { "version": "2025.2", @@ -26,8 +27,7 @@ "size": 104146312, "sha256": "AFD8098A1FF89D69A243EC4E2E946CF5FBF8D1C10998230D6C8FC0A5C9C39541" } - ], - "rollout": 1.0 + ] }, { "version": "2025.3", @@ -50,7 +50,7 @@ "sha256": "AFD8098A1FF89D69A243EC4E2E946CF5FBF8D1C10998230D6C8FC0A5C9C39541" } ], - "rollout": 0.3 + "rollout": 0.5 }, { "version": "2025.1-beta1", @@ -72,8 +72,7 @@ "size": 111488248, "sha256": "82948D3DB5B869EE5F0D246DB557A81B72B68DFDDD2267872B7B8A5B19A05444" } - ], - "rollout": 1.0 + ] }, { "version": "2025.3-beta1", @@ -95,8 +94,7 @@ "size": 111488248, "sha256": "82948D3DB5B869EE5F0D246DB557A81B72B68DFDDD2267872B7B8A5B19A05444" } - ], - "rollout": 1.0 + ] } ] } diff --git a/mullvad-update/update-testdata.sh b/mullvad-update/update-testdata.sh new file mode 100644 index 000000000000..17ac62d9ea50 --- /dev/null +++ b/mullvad-update/update-testdata.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash + +set -eu + +SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" +cd "$SCRIPT_DIR" + +# Update test-version-response from + +secret="a459c1ee4f128780592b61454786cb289b38034a3ac1c7860e6e62187ac6e9a9" +#secret=$(cargo r --bin mullvad-version-metadata --features sign generate-key) +pubkey="BB4EF63FFDCC6BD5A19C30CD23B9DE03099407A04463418F17AE338B98AA09D4" + +echo "secret: $secret" +echo "pubkey: $pubkey" + +cargo r --bin mullvad-version-metadata --features sign sign --file ./unsigned-response.json --secret $secret > test-version-response.json + +echo -n "$pubkey" > test-pubkey From 20aa2428753e87102073fb16ea21c30b13ead36a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20L=C3=B6nnhager?= Date: Thu, 20 Feb 2025 14:43:17 +0100 Subject: [PATCH 028/112] Support multiple signatures --- mullvad-update/src/format/deserializer.rs | 73 +++++++++++++++++++---- mullvad-update/src/format/key.rs | 9 ++- mullvad-update/src/format/mod.rs | 20 ++++--- mullvad-update/src/format/serializer.rs | 13 ++-- mullvad-update/test-version-response.json | 11 ++-- 5 files changed, 99 insertions(+), 27 deletions(-) diff --git a/mullvad-update/src/format/deserializer.rs b/mullvad-update/src/format/deserializer.rs index 0cde83bf1b32..28291dde003d 100644 --- a/mullvad-update/src/format/deserializer.rs +++ b/mullvad-update/src/format/deserializer.rs @@ -4,7 +4,7 @@ use anyhow::Context; use super::key::*; use super::Response; -use super::{PartialSignedResponse, SignedResponse}; +use super::{PartialSignedResponse, ResponseSignature, SignedResponse}; impl SignedResponse { /// Deserialize some bytes to JSON, and verify them, including signature and expiry. @@ -26,7 +26,7 @@ impl SignedResponse { let signed = serde_json::from_value(partial_data.signed) .context("Failed to deserialize response")?; Ok(Self { - signature: partial_data.signature, + signatures: partial_data.signatures, signed, }) } @@ -64,7 +64,7 @@ impl SignedResponse { } Ok(SignedResponse { - signature: partial_data.signature, + signatures: partial_data.signatures, signed: signed_response, }) } @@ -82,24 +82,26 @@ pub(super) fn deserialize_and_verify( serde_json::from_slice(bytes).context("Invalid version JSON")?; // Check if the key matches - if partial_data.signature.keyid.0 != key.0 { + let Some(sig) = partial_data.signatures.iter().find_map(|sig| match sig { + // Check if ed25519 key matches + ResponseSignature::Ed25519 { keyid, sig } if keyid.0 == key.0 => Some(sig), + // Ignore all non-matching key + _ => None, + }) else { 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) + key.0 + .verify_strict(&canon_data, &sig.0) .context("Signature verification failed")?; Ok(PartialSignedResponse { - signature: partial_data.signature, + signatures: partial_data.signatures, signed: partial_data.signed, }) } @@ -147,4 +149,53 @@ mod test { ) .expect_err("expected rejected version number"); } + + /// Test that invalid key types deserialized to "other" + #[test] + fn test_response_unknown_keytypes() { + //let secret = "F6631A59EBBF8AADEAC64CC30A08A83FC7283F39DE53B7F1BFBA6BE52663DC94"; + let pubkey = "8F735E412015D8976079E5FA0E090100A43A34937CCFC3A2341219E30291DD39"; + let fakesig = "08954286A9284718B83CAADA5DF8A9A9DF0CE569F8EFF669D8C2A2E5945C809C465C38168E2F6018461DD8801DBFC74126A2ED9102F99A49F6DD54722C9B3605"; + let value = serde_json::json!({ + "signatures": [ + { + "keytype": "ed25519", + "keyid": pubkey, + "sig": fakesig, + }, + { + "keytype": "new shiny key", + "keyid": "test 1", + "sig": "test 2", + } + ], + "signed": { + "metadata_expiry": "3000-01-01T00:00:00Z", + "metadata_version": 0, + "releases": [] + } + }); + + let bytes = serde_json::to_vec(&value).expect("serialize should succeed"); + + let response = SignedResponse::deserialize_and_verify_insecure(&bytes) + .expect("deserialization failed"); + + let expected_key = VerifyingKey::from_hex(pubkey).unwrap(); + let expected_sig = Signature::from_hex(&fakesig).unwrap(); + + // Ed25519 key + assert!( + matches!(&response.signatures[0], ResponseSignature::Ed25519 { keyid, sig } if keyid == &expected_key && sig == &expected_sig), + "unexpected response sig: {:?}", + response.signatures[0] + ); + + // Unrecognized key type + assert!( + matches!(&response.signatures[1], ResponseSignature::Other { keyid, sig } if keyid == "test 1" && sig == "test 2"), + "expected unrecognized key: {:?}", + response.signatures[1] + ); + } } diff --git a/mullvad-update/src/format/key.rs b/mullvad-update/src/format/key.rs index 0033ec30a13a..a951aff709fd 100644 --- a/mullvad-update/src/format/key.rs +++ b/mullvad-update/src/format/key.rs @@ -111,9 +111,16 @@ impl Serialize for VerifyingKey { } /// ed25519 signature -#[derive(Debug)] +#[derive(Debug, PartialEq)] pub struct Signature(pub ed25519_dalek::Signature); +impl Signature { + pub fn from_hex(s: &str) -> anyhow::Result { + let bytes = bytes_from_hex::<{ ed25519_dalek::SIGNATURE_LENGTH }>(s)?; + Ok(Self(ed25519_dalek::Signature::from_bytes(&bytes))) + } +} + impl<'de> Deserialize<'de> for Signature { fn deserialize(deserializer: D) -> Result where diff --git a/mullvad-update/src/format/mod.rs b/mullvad-update/src/format/mod.rs index edbe24ab4fbd..fc706f4357b1 100644 --- a/mullvad-update/src/format/mod.rs +++ b/mullvad-update/src/format/mod.rs @@ -23,8 +23,8 @@ pub mod serializer; /// signature verification. #[derive(Debug, Serialize)] pub struct SignedResponse { - /// Signature of the canonicalized JSON of `signed` - pub signature: ResponseSignature, + /// Signatures of the canonicalized JSON of `signed` + pub signatures: Vec, /// Content signed by `signature` pub signed: Response, } @@ -33,8 +33,8 @@ pub struct SignedResponse { /// Note that deserializing doesn't verify anything #[derive(Deserialize, Serialize)] struct PartialSignedResponse { - /// Signature of the canonicalized JSON of `signed` - pub signature: ResponseSignature, + /// Signatures of the canonicalized JSON of `signed` + pub signatures: Vec, /// Content signed by `signature` pub signed: serde_json::Value, } @@ -102,9 +102,15 @@ pub enum Architecture { /// JSON response signature #[derive(Debug, Deserialize, Serialize)] -pub struct ResponseSignature { - pub keyid: key::VerifyingKey, - pub sig: key::Signature, +#[serde(tag = "keytype")] +#[serde(rename_all = "lowercase")] +pub enum ResponseSignature { + Ed25519 { + keyid: key::VerifyingKey, + sig: key::Signature, + }, + #[serde(untagged)] + Other { keyid: String, sig: String }, } #[cfg(test)] diff --git a/mullvad-update/src/format/serializer.rs b/mullvad-update/src/format/serializer.rs index 6698c9282f6b..46c4c8cb7a33 100644 --- a/mullvad-update/src/format/serializer.rs +++ b/mullvad-update/src/format/serializer.rs @@ -34,7 +34,7 @@ impl SignedResponse { let response: Response = serde_json::from_value(partial_signed.signed)?; Ok(SignedResponse { - signature: partial_signed.signature, + signatures: partial_signed.signatures, signed: response, }) } @@ -58,10 +58,10 @@ fn sign( // Attach signature Ok(PartialSignedResponse { - signature: ResponseSignature { + signatures: vec![ResponseSignature::Ed25519 { keyid: key.pubkey(), sig, - }, + }], // Attach now-signed data signed, }) @@ -86,7 +86,12 @@ mod test { // Verify that we can deserialize and verify the data let partial = sign(&key, &data).context("Signing failed")?; - assert_eq!(partial.signature.keyid, pubkey); + assert!( + matches!(&partial.signatures[0], ResponseSignature::Ed25519 { + keyid, + .. + } if keyid == &pubkey) + ); let bytes = serde_json::to_vec(&partial)?; diff --git a/mullvad-update/test-version-response.json b/mullvad-update/test-version-response.json index d4d72698e6b3..b6466e48c22c 100644 --- a/mullvad-update/test-version-response.json +++ b/mullvad-update/test-version-response.json @@ -1,8 +1,11 @@ { - "signature": { - "keyid": "bb4ef63ffdcc6bd5a19c30cd23b9de03099407a04463418f17ae338b98aa09d4", - "sig": "253ec37846dcd909bfc5119c0e0d06535767e179eb8b4465015eaa95f4bed362c8c9186311192c987871722bf7d319d44e4f04eaf79c269820bc13ff1a901f0b" - }, + "signatures": [ + { + "keytype": "ed25519", + "keyid": "bb4ef63ffdcc6bd5a19c30cd23b9de03099407a04463418f17ae338b98aa09d4", + "sig": "253ec37846dcd909bfc5119c0e0d06535767e179eb8b4465015eaa95f4bed362c8c9186311192c987871722bf7d319d44e4f04eaf79c269820bc13ff1a901f0b" + } + ], "signed": { "metadata_version": 0, "metadata_expiry": "2025-07-02T15:33:00Z", From 0fb0b75a41e30016d5bbfc9acc99ee869a880299 Mon Sep 17 00:00:00 2001 From: Joakim Hulthe Date: Fri, 21 Feb 2025 14:22:12 +0100 Subject: [PATCH 029/112] Implement error view for macos --- .../src/cacao_impl/delegate.rs | 68 ++++++++- installer-downloader/src/cacao_impl/ui.rs | 132 +++++++++++++++++- 2 files changed, 192 insertions(+), 8 deletions(-) diff --git a/installer-downloader/src/cacao_impl/delegate.rs b/installer-downloader/src/cacao_impl/delegate.rs index 80d080932bb1..0a09b3324dd0 100644 --- a/installer-downloader/src/cacao_impl/delegate.rs +++ b/installer-downloader/src/cacao_impl/delegate.rs @@ -2,7 +2,7 @@ use std::sync::{Arc, Mutex}; use cacao::{control::Control, layout::Layout}; -use super::ui::{Action, AppWindow}; +use super::ui::{Action, AppWindow, ErrorView}; use crate::delegate::{AppDelegate, AppDelegateQueue}; impl AppDelegate for AppWindow { @@ -30,6 +30,12 @@ impl AppDelegate for AppWindow { }); } + fn on_beta_link(&mut self, callback: F) + where + F: Fn() + Send + 'static, + { + } + fn set_status_text(&mut self, text: &str) { self.status_text.set_text(text); } @@ -95,6 +101,66 @@ impl AppDelegate for AppWindow { fn queue(&self) -> Self::Queue { Queue {} } + + fn quit(&mut self) { + cacao::appkit::App::::dispatch_main(Action::Quit); + } + + fn on_stable_link(&mut self, callback: F) + where + F: Fn() + Send + 'static, + { + println!("todo. on stable link"); + } + + fn show_stable_text(&mut self) { + println!("todo. show stable text"); + } + + fn hide_stable_text(&mut self) { + println!("todo. hide stable text"); + } + + fn show_error_message(&mut self, message: installer_downloader::delegate::ErrorMessage) { + let on_cancel = self.error_cancel_callback.clone().map(|cb| { + move || { + let cb = Action::ErrorCancel(cb.clone()); + cacao::appkit::App::::dispatch_main(cb); + } + }); + + let on_retry = self.error_retry_callback.clone().map(|cb| { + move || { + let cb = Action::ErrorRetry(cb.clone()); + cacao::appkit::App::::dispatch_main(cb); + } + }); + + self.error_view = Some(ErrorView::new( + &self.main_view, + message, + on_retry, + on_cancel, + )); + } + + fn hide_error_message(&mut self) { + self.error_view.take(); + } + + fn on_error_message_retry(&mut self, callback: F) + where + F: Fn() + Send + 'static, + { + self.error_retry_callback = Some(Self::sync_callback(callback)); + } + + fn on_error_message_cancel(&mut self, callback: F) + where + F: Fn() + Send + 'static, + { + self.error_cancel_callback = Some(Self::sync_callback(callback)); + } } impl AppWindow { diff --git a/installer-downloader/src/cacao_impl/ui.rs b/installer-downloader/src/cacao_impl/ui.rs index 85f7554c05f0..5981b9465e24 100644 --- a/installer-downloader/src/cacao_impl/ui.rs +++ b/installer-downloader/src/cacao_impl/ui.rs @@ -12,6 +12,7 @@ use cacao::objc::{class, msg_send, sel, sel_impl}; use cacao::progress::ProgressIndicator; use cacao::text::{AttributedString, Label}; use cacao::view::View; +use installer_downloader::delegate::ErrorMessage; use objc_id::Id; use crate::resource::{ @@ -25,6 +26,8 @@ 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"); +const ALERT_CIRCLE_IMAGE_DATA: &[u8] = include_bytes!("../../assets/alert-circle.svg"); + /// Banner background color: #192e45 static BANNER_COLOR: LazyLock = LazyLock::new(|| { let r = 0x19 as f64 / 255.; @@ -44,6 +47,7 @@ static BANNER_COLOR: LazyLock = LazyLock::new(|| { static LOGO: LazyLock = LazyLock::new(|| Image::with_data(LOGO_IMAGE_DATA)); static LOGO_TEXT: LazyLock = LazyLock::new(|| Image::with_data(LOGO_TEXT_DATA)); +static ALERT_CIRCLE: LazyLock = LazyLock::new(|| Image::with_data(ALERT_CIRCLE_IMAGE_DATA)); pub struct AppImpl { window: Window, @@ -80,6 +84,12 @@ pub enum Action { CancelClick(Arc>>), /// Run callback on main thread QueueMain(Mutex FnOnce(&'a mut AppWindow) + Send>>>), + /// User clicked the retry button in the error view + ErrorRetry(Arc>>), + /// User clicked the cancel button in the error view + ErrorCancel(Arc>>), + /// Quit the application. + Quit, } impl Dispatcher for AppImpl { @@ -102,6 +112,17 @@ impl Dispatcher for AppImpl { let cb = cb.lock().unwrap().take().unwrap(); cb(&mut borrowed); } + Action::ErrorRetry(cb) => { + let cb = cb.lock().unwrap(); + cb(); + } + Action::ErrorCancel(cb) => { + let cb = cb.lock().unwrap(); + cb(); + } + Action::Quit => { + self.window.close(); + } } } @@ -132,12 +153,25 @@ pub struct AppWindow { pub progress: ProgressIndicator, pub status_text: Label, + + pub error_view: Option, + pub error_retry_callback: Option>>>, + pub error_cancel_callback: Option>>>, + pub download_text: Label, pub beta_link_preface: Label, pub beta_link: Label, } +pub struct ErrorView { + pub view: View, + pub text: Label, + pub circle: ImageView, + pub retry_button: Button, + pub cancel_button: Button, +} + pub struct DownloadButton { pub button: Button, } @@ -259,13 +293,6 @@ impl AppWindow { 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) @@ -333,3 +360,94 @@ impl WindowDelegate for AppWindowWrapper { window.set_content_view(&self.inner.borrow().content); } } + +impl ErrorView { + pub fn new( + main_view: &View, + message: ErrorMessage, + on_retry: Option, + on_cancel: Option, + ) -> Self { + let mut error_view = ErrorView { + view: Default::default(), + text: Default::default(), + circle: Default::default(), + retry_button: Button::new(&message.retry_button_text), + cancel_button: Button::new(&message.cancel_button_text), + }; + + let ErrorView { + view, + text, + circle, + retry_button, + cancel_button, + } = &mut error_view; + + text.set_text(message.status_text); + circle.set_image(&ALERT_CIRCLE); + + if let Some(on_cancel) = on_cancel { + cancel_button.set_action(on_cancel); + } + if let Some(on_retry) = on_retry { + retry_button.set_action(on_retry); + } + + view.add_subview(text); + view.add_subview(circle); + main_view.add_subview(view); + main_view.add_subview(retry_button); + main_view.add_subview(cancel_button); + + LayoutConstraint::activate(&[ + view.center_x.constraint_equal_to(&main_view.center_x), + view.center_y + .constraint_equal_to(&main_view.top) + .offset(74.), + view.width.constraint_equal_to_constant(536.), + text.center_y.constraint_equal_to(&view.center_y), + text.left.constraint_equal_to(&circle.right).offset(16.), + text.right.constraint_equal_to(&view.right), + circle.left.constraint_equal_to(&view.left), + circle.center_y.constraint_equal_to(&text.center_y), + retry_button + .top + .constraint_equal_to(&text.bottom) + .offset(24.), + cancel_button + .top + .constraint_equal_to(&text.bottom) + .offset(24.), + retry_button + .left + .constraint_equal_to(&view.center_x) + .offset(8.), + cancel_button + .right + .constraint_equal_to(&view.center_x) + .offset(-8.), + retry_button.width.constraint_equal_to_constant(213.), + cancel_button.width.constraint_equal_to_constant(213.), + ]); + + error_view + } +} + +impl Drop for ErrorView { + fn drop(&mut self) { + let ErrorView { + view, + text, + circle, + retry_button, + cancel_button, + } = self; + view.remove_from_superview(); + text.remove_from_superview(); + circle.remove_from_superview(); + retry_button.remove_from_superview(); + cancel_button.remove_from_superview(); + } +} From a343c50b33f254c50fc42dd874fcbca5a84f6d3f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20L=C3=B6nnhager?= Date: Sat, 22 Feb 2025 15:56:56 +0100 Subject: [PATCH 030/112] Fix launcher on macOS --- mullvad-update/src/app.rs | 37 ++++++++++++++++++++++++++++++++----- 1 file changed, 32 insertions(+), 5 deletions(-) diff --git a/mullvad-update/src/app.rs b/mullvad-update/src/app.rs index f9de31c4996f..8987cb73bdb5 100644 --- a/mullvad-update/src/app.rs +++ b/mullvad-update/src/app.rs @@ -1,6 +1,6 @@ //! This module implements the flow of downloading and verifying the app. -use std::{path::PathBuf, time::Duration}; +use std::{ffi::OsString, path::PathBuf, time::Duration}; use tokio::{process::Command, time::timeout}; @@ -112,10 +112,11 @@ impl AppDownloader for HttpAppDownloader Result<(), DownloadError> { - let bin_path = self.bin_path(); + let launch_path = self.launch_path(); // Launch process - let mut cmd = Command::new(bin_path); + let mut cmd = Command::new(launch_path); + cmd.args(self.launch_args()); let mut child = cmd.spawn().map_err(DownloadError::Launch)?; // Wait to see if the installer fails @@ -141,12 +142,38 @@ impl HttpAppDownloader { #[cfg(windows)] let bin_filename = format!("mullvad-{}.exe", self.params.app_version); - #[cfg(unix)] - let bin_filename = self.params.app_version.to_string(); + #[cfg(target_os = "macos")] + let bin_filename = format!("mullvad-{}.pkg", self.params.app_version); self.params.cache_dir.join(bin_filename) } + fn launch_path(&self) -> PathBuf { + #[cfg(target_os = "windows")] + { + self.bin_path() + } + + #[cfg(target_os = "macos")] + { + use std::path::Path; + + Path::new("/usr/bin/open").to_owned() + } + } + + fn launch_args(&self) -> Vec { + #[cfg(target_os = "windows")] + { + vec![] + } + + #[cfg(target_os = "macos")] + { + vec![self.bin_path().into()] + } + } + fn hash_sha256(&self) -> &[u8; 32] { &self.params.app_sha256 } From 5191c455b76fed7cfaac0e3a7593f422553d42a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20L=C3=B6nnhager?= Date: Sat, 22 Feb 2025 16:36:37 +0100 Subject: [PATCH 031/112] Add packaging of macOS app to build.sh --- .github/workflows/downloader.yml | 9 +-- installer-downloader/assets/Info.plist | 34 +++++++++ installer-downloader/build.sh | 99 +++++++++++++++++++++++++- 3 files changed, 136 insertions(+), 6 deletions(-) create mode 100644 installer-downloader/assets/Info.plist mode change 100644 => 100755 installer-downloader/build.sh diff --git a/.github/workflows/downloader.yml b/.github/workflows/downloader.yml index ae8aabd4a8b9..9b25aa783b75 100644 --- a/.github/workflows/downloader.yml +++ b/.github/workflows/downloader.yml @@ -55,13 +55,12 @@ jobs: - name: Check file size uses: ./.github/actions/check-file-size with: - artifact: "C:/cargo-target/release/installer-downloader.exe" + artifact: "./installer-downloader/dist/MullvadDownloader.exe" max_size: ${{ env.MAX_BINARY_SIZE }} build-macos: runs-on: macos-latest env: - # TODO: Figure out a reasonable limit for macOS # If the file is larger than this, a regression has probably been introduced. # You should think twice before increasing this limit. MAX_BINARY_SIZE: 2097152 @@ -69,12 +68,14 @@ jobs: - name: Checkout repository uses: actions/checkout@v4 + - name: Install Rust + run: rustup target add x86_64-apple-darwin + - name: Build - shell: bash run: ./installer-downloader/build.sh - name: Check file size uses: ./.github/actions/check-file-size with: - artifact: "./target/release/installer-downloader" + artifact: "./installer-downloader/dist/MullvadDownloader.dmg" max_size: ${{ env.MAX_BINARY_SIZE }} diff --git a/installer-downloader/assets/Info.plist b/installer-downloader/assets/Info.plist new file mode 100644 index 000000000000..be1ca1be385d --- /dev/null +++ b/installer-downloader/assets/Info.plist @@ -0,0 +1,34 @@ + + + + + CFBundleDevelopmentRegion + English + CFBundleExecutable + installer-downloader + CFBundleGetInfoString + + CFBundleIconFile + icon.icns + CFBundleIdentifier + net.mullvad.MullvadDownloader + CFBundleInfoDictionaryVersion + 6.0 + CFBundleLongVersionString + + CFBundleName + net.mullvad.MullvadDownloader + CFBundlePackageType + APPL + CFBundleShortVersionString + 0.1 + CFBundleVersion + + CSResourcesFileMapped + + LSRequiresCarbon + + NSHighResolutionCapable + + + \ No newline at end of file diff --git a/installer-downloader/build.sh b/installer-downloader/build.sh old mode 100644 new mode 100755 index eb9b87e91f49..a425c10ff67f --- a/installer-downloader/build.sh +++ b/installer-downloader/build.sh @@ -24,5 +24,100 @@ source ../scripts/utils/host # shellcheck disable=SC1091 source ../scripts/utils/log -RUSTFLAGS="-C codegen-units=1 -C panic=abort -C strip=symbols -C opt-level=z" \ - cargo build --bin installer-downloader --release +CARGO_TARGET_DIR=${CARGO_TARGET_DIR:-"../target"} +DIST_DIR="./dist" + +function build_executable { + local -a target_args=() + + if [[ -n "${1-}" ]]; then + target_args+=(--target "$1") + fi + + # Old bash versions complain about empty array expansion when -u is set + set +u + + RUSTFLAGS="-C codegen-units=1 -C panic=abort -C strip=symbols -C opt-level=z" \ + cargo build --bin installer-downloader --release "${target_args[@]}" + + set -u +} + +function dist_windows_app { + cp "$CARGO_TARGET_DIR/release/installer-downloader.exe" "$DIST_DIR/MullvadDownloader.exe" +} + +# Combine executables on macOS +function lipo_executables { + local target_exes + target_exes=() + + rm -rf "$DIST_DIR/installer-downloader" + + case $HOST in + x86_64-apple-darwin) target_exes=( + "$CARGO_TARGET_DIR/release/installer-downloader" + "$CARGO_TARGET_DIR/aarch64-apple-darwin/release/installer-downloader" + ) + ;; + aarch64-apple-darwin) target_exes=( + "$CARGO_TARGET_DIR/release/installer-downloader" + "$CARGO_TARGET_DIR/x86_64-apple-darwin/release/installer-downloader" + ) + ;; + esac + + lipo "${target_exes[@]}" -create -output "$DIST_DIR/installer-downloader" +} + +function dist_macos_app { + local app_path + bundle_name="MullvadDownloader" + bundle_id="net.mullvad.$bundle_name" + app_path="$DIST_DIR/$bundle_name.app/" + + # Build app bundle + echo "Building $app_path..." + + rm -rf "$app_path" + + mkdir -p "$app_path/Contents/Resources" + cp "../dist-assets/icon.icns" "$app_path/Contents/Resources/" + + mkdir -p "$app_path/Contents/MacOS" + + cp ./assets/Info.plist "$app_path/Contents/Info.plist" + cp "$DIST_DIR/installer-downloader" "$app_path/Contents/MacOS/installer-downloader" + + # Ad-hoc sign app bundle + codesign --force --deep --identifier "$bundle_id" --sign - \ + --timestamp=none --verbose=0 -o runtime \ + "$DIST_DIR/$bundle_name.app" + + # Pack in .dmg + echo "Creating .dmg image..." + + hdiutil create -volname "MullvadDownloader" -srcfolder "$app_path" -ov -format UDZO \ + "$DIST_DIR/MullvadDownloader.dmg" + + # TODO: sign image? +} + +mkdir -p "$DIST_DIR" + +if [[ "$(uname -s)" == "Darwin" ]]; then + case $HOST in + x86_64-apple-darwin) TARGETS=("" aarch64-apple-darwin);; + aarch64-apple-darwin) TARGETS=("" x86_64-apple-darwin);; + esac + + for t in "${TARGETS[@]:-"$HOST"}"; do + build_executable "$t" + done + + lipo_executables + dist_macos_app +elif [[ "$(uname -s)" == "MINGW"* ]]; then + build_executable + dist_windows_app +fi From 0e62c2f3f611f4a300fd481d6d6f073d53d8e220 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20L=C3=B6nnhager?= Date: Sat, 22 Feb 2025 17:07:47 +0100 Subject: [PATCH 032/112] Hide components that are not needed on Linux in mullvad-update --- Cargo.lock | 456 ++++-------------- installer-downloader/Cargo.toml | 13 +- installer-downloader/src/lib.rs | 4 + installer-downloader/tests/controller.rs | 2 + mullvad-update/Cargo.toml | 20 +- mullvad-update/src/{ => client}/api.rs | 4 +- mullvad-update/src/{ => client}/app.rs | 0 mullvad-update/src/{ => client}/dir.rs | 0 mullvad-update/src/{ => client}/fetch.rs | 0 mullvad-update/src/client/mod.rs | 5 + ...nt__api__test__http_version_provider.snap} | 0 mullvad-update/src/{ => client}/verify.rs | 0 mullvad-update/src/lib.rs | 11 +- 13 files changed, 140 insertions(+), 375 deletions(-) rename mullvad-update/src/{ => client}/api.rs (97%) rename mullvad-update/src/{ => client}/app.rs (100%) rename mullvad-update/src/{ => client}/dir.rs (100%) rename mullvad-update/src/{ => client}/fetch.rs (100%) create mode 100644 mullvad-update/src/client/mod.rs rename mullvad-update/src/{snapshots/mullvad_update__api__test__http_version_provider.snap => client/snapshots/mullvad_update__client__api__test__http_version_provider.snap} (100%) rename mullvad-update/src/{ => client}/verify.rs (100%) diff --git a/Cargo.lock b/Cargo.lock index c65542b0dc58..b7fac4207bc7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -23,12 +23,6 @@ version = "2.0.0" 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" @@ -205,8 +199,8 @@ version = "0.3.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "16e62a023e7c117e27523144c5d2459f4397fcc3cab0085af8e2224f643a0193" dependencies = [ - "proc-macro2 1.0.92", - "quote 1.0.36", + "proc-macro2", + "quote", "syn 2.0.89", ] @@ -225,8 +219,8 @@ version = "0.1.80" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c6fa2087f2753a7da8cc1c0dbfcf89579dd57458e36769de5ac750b4671737ca" dependencies = [ - "proc-macro2 1.0.92", - "quote 1.0.36", + "proc-macro2", + "quote", "syn 2.0.89", ] @@ -456,8 +450,8 @@ dependencies = [ "heck 0.4.1", "indexmap 1.9.3", "log", - "proc-macro2 1.0.92", - "quote 1.0.36", + "proc-macro2", + "quote", "serde", "serde_json", "syn 1.0.109", @@ -474,8 +468,8 @@ dependencies = [ "heck 0.4.1", "indexmap 2.2.6", "log", - "proc-macro2 1.0.92", - "quote 1.0.36", + "proc-macro2", + "quote", "serde", "serde_json", "syn 2.0.89", @@ -602,8 +596,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "528131438037fd55894f62d6e9f068b8f45ac57ffa77517819645d10aed04f64" dependencies = [ "heck 0.5.0", - "proc-macro2 1.0.92", - "quote 1.0.36", + "proc-macro2", + "quote", "syn 2.0.89", ] @@ -624,12 +618,6 @@ dependencies = [ "zeroize", ] -[[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" @@ -758,25 +746,6 @@ dependencies = [ "crossbeam-utils", ] -[[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" @@ -847,8 +816,8 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" dependencies = [ - "proc-macro2 1.0.92", - "quote 1.0.36", + "proc-macro2", + "quote", "syn 2.0.89", ] @@ -870,8 +839,8 @@ checksum = "95133861a8032aaea082871032f5815eb9e98cef03fa916ab4500513994df9e5" dependencies = [ "fnv", "ident_case", - "proc-macro2 1.0.92", - "quote 1.0.36", + "proc-macro2", + "quote", "strsim", "syn 2.0.89", ] @@ -883,7 +852,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d336a2a514f6ccccaa3e09b02d41d35330c07ddf03a62165fcec10bb561c7806" dependencies = [ "darling_core", - "quote 1.0.36", + "quote", "syn 2.0.89", ] @@ -917,16 +886,6 @@ dependencies = [ "winapi", ] -[[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" @@ -952,8 +911,8 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "302ccf094df1151173bb6f5a2282fcd2f45accd5eae1bdf82dcbfefbc501ad5c" dependencies = [ - "proc-macro2 1.0.92", - "quote 1.0.36", + "proc-macro2", + "quote", "syn 1.0.109", ] @@ -973,8 +932,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d48cda787f839151732d396ac69e3473923d54312c070ee21e9effcaa8ca0b1d" dependencies = [ "darling", - "proc-macro2 1.0.92", - "quote 1.0.36", + "proc-macro2", + "quote", "syn 2.0.89", ] @@ -1032,8 +991,8 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ - "proc-macro2 1.0.92", - "quote 1.0.36", + "proc-macro2", + "quote", "syn 2.0.89", ] @@ -1114,15 +1073,6 @@ 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" @@ -1130,8 +1080,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5ffccbb6966c05b32ef8fbac435df276c4ae4d3dc55a8cd0eb9745e6c12f546a" dependencies = [ "heck 0.4.1", - "proc-macro2 1.0.92", - "quote 1.0.36", + "proc-macro2", + "quote", "syn 2.0.89", ] @@ -1150,8 +1100,8 @@ version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f282cfdfe92516eb26c2af8589c274c7c17681f5ecc03c18255fe741c6aa64eb" dependencies = [ - "proc-macro2 1.0.92", - "quote 1.0.36", + "proc-macro2", + "quote", "syn 2.0.89", ] @@ -1374,8 +1324,8 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" dependencies = [ - "proc-macro2 1.0.92", - "quote 1.0.36", + "proc-macro2", + "quote", "syn 2.0.89", ] @@ -1454,16 +1404,6 @@ dependencies = [ "polyval", ] -[[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" @@ -1983,8 +1923,8 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ec89e9337638ecdc08744df490b221a7399bf8d164eb52a665454e60e075ad6" dependencies = [ - "proc-macro2 1.0.92", - "quote 1.0.36", + "proc-macro2", + "quote", "syn 2.0.89", ] @@ -2015,24 +1955,6 @@ dependencies = [ "icu_properties", ] -[[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" @@ -2053,15 +1975,6 @@ dependencies = [ "hashbrown 0.14.3", ] -[[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" @@ -2128,7 +2041,6 @@ dependencies = [ "insta", "mullvad-update", "native-windows-gui", - "nsvg", "objc_id", "rand 0.8.5", "serde", @@ -2147,8 +2059,8 @@ checksum = "fc6d6206008e25125b1f97fbe5d309eb7b85141cf9199d52dbd3729a1584dd16" name = "intersection-derive" version = "0.0.0" dependencies = [ - "proc-macro2 1.0.92", - "quote 1.0.36", + "proc-macro2", + "quote", "syn 2.0.89", ] @@ -2274,20 +2186,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "002f4dfe6d97ae88c33f3489c0d31ffc6f81d9a492de98ff113b127d73bafff8" dependencies = [ "heck 0.4.1", - "proc-macro2 1.0.92", - "quote 1.0.36", + "proc-macro2", + "quote", "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" @@ -2469,12 +2372,6 @@ version = "0.11.11" 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" @@ -3024,9 +2921,9 @@ checksum = "defc4c55412d89136f966bbb339008b474350e5e6e78d2714439c386b3137a03" [[package]] name = "native-tls" -version = "0.2.12" +version = "0.2.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8614eb2c83d59d1c8cc974dd3f920198647674a0a035e1af1fa58707e317466" +checksum = "0dab59f8e050d5df8e4dd87d9206fb6f65a483e20ac9fda365ade4fab353196c" dependencies = [ "libc", "log", @@ -3082,7 +2979,7 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c6813fde79b646e47e7ad75f480aa80ef76a5d9599e2717407961531169ee38b" dependencies = [ - "quote 1.0.36", + "quote", "syn 2.0.89", "syn-mid", ] @@ -3268,63 +3165,12 @@ dependencies = [ "objc2-app-kit", ] -[[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-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" @@ -3478,9 +3324,9 @@ checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" [[package]] name = "openssl" -version = "0.10.68" +version = "0.10.71" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6174bc48f102d208783c2c84bf931bb75927a617866870de8a4ea85597f871f5" +checksum = "5e14130c6a98cd258fdcb0fb6d744152343ff729cbfcb28c656a9d12b999fbcd" dependencies = [ "bitflags 2.6.0", "cfg-if", @@ -3497,22 +3343,22 @@ 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", + "proc-macro2", + "quote", "syn 2.0.89", ] [[package]] name = "openssl-probe" -version = "0.1.5" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff011a302c396a5197692431fc1948019154afc178baf7d8e37367442a4601cf" +checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" [[package]] name = "openssl-sys" -version = "0.9.104" +version = "0.9.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "45abf306cbf99debc8195b66b7346498d7b10c210de50418b5ccd7ceba08c741" +checksum = "8bb61ea9811cc39e3c2069f40b8b8e2e70d8569b361f879786cc7ed48b777cdd" dependencies = [ "cc", "libc", @@ -3685,8 +3531,8 @@ checksum = "3ec22af7d3fb470a85dd2ca96b7c577a1eb4ef6f1683a9fe9a8c16e136c04687" dependencies = [ "pest", "pest_meta", - "proc-macro2 1.0.92", - "quote 1.0.36", + "proc-macro2", + "quote", "syn 2.0.89", ] @@ -3776,8 +3622,8 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2f38a4412a78282e09a2cf38d195ea5420d15ba0602cb375210efbc877243965" dependencies = [ - "proc-macro2 1.0.92", - "quote 1.0.36", + "proc-macro2", + "quote", "syn 2.0.89", ] @@ -3851,8 +3697,8 @@ version = "0.34.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "688b17499eee04a0408aca0aa5cba5fc86401d7216de8a63fdf7a4c227871804" dependencies = [ - "proc-macro2 1.0.92", - "quote 1.0.36", + "proc-macro2", + "quote", "regex", "syn 2.0.89", ] @@ -3863,8 +3709,8 @@ version = "0.35.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13325ac86ee1a80a480b0bc8e3d30c25d133616112bb16e86f712dcf8a71c863" dependencies = [ - "proc-macro2 1.0.92", - "quote 1.0.36", + "proc-macro2", + "quote", "regex", "syn 2.0.89", ] @@ -3911,18 +3757,6 @@ dependencies = [ "pnet_macros_support 0.35.0", ] -[[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" @@ -3974,7 +3808,7 @@ version = "0.2.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5ac2cf0f2e4f42b49f5ffd07dae8d746508ef7526c13940e5f524012ae6c6550" dependencies = [ - "proc-macro2 1.0.92", + "proc-macro2", "syn 2.0.89", ] @@ -3987,15 +3821,6 @@ dependencies = [ "elliptic-curve", ] -[[package]] -name = "proc-macro2" -version = "0.4.30" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf3d2011ab5c909338f7887f4fc896d35932e29146c12c8d01da6b22a80ba759" -dependencies = [ - "unicode-xid", -] - [[package]] name = "proc-macro2" version = "1.0.92" @@ -4074,8 +3899,8 @@ checksum = "19de2de2a00075bf566bee3bd4db014b11587e84184d3f7a791bc17f1a8e9e48" dependencies = [ "anyhow", "itertools 0.12.1", - "proc-macro2 1.0.92", - "quote 1.0.36", + "proc-macro2", + "quote", "syn 2.0.89", ] @@ -4087,8 +3912,8 @@ checksum = "e9552f850d5f0964a4e4d0bf306459ac29323ddfbae05e35a7c0d35cb0803cc5" dependencies = [ "anyhow", "itertools 0.12.1", - "proc-macro2 1.0.92", - "quote 1.0.36", + "proc-macro2", + "quote", "syn 2.0.89", ] @@ -4178,22 +4003,13 @@ dependencies = [ "windows-sys 0.59.0", ] -[[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 1.0.92", + "proc-macro2", ] [[package]] @@ -4286,26 +4102,6 @@ dependencies = [ "rand_core 0.6.4", ] -[[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" @@ -4369,11 +4165,8 @@ 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", @@ -4397,7 +4190,6 @@ dependencies = [ "serde_json", "serde_urlencoded", "sync_wrapper 1.0.1", - "system-configuration 0.6.1", "tokio", "tokio-native-tls", "tokio-rustls 0.26.0", @@ -4633,12 +4425,6 @@ 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" @@ -4728,8 +4514,8 @@ version = "1.0.217" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a9bf7cf98d04a2b28aead066b7496853d4779c9cc183c440dbac457641e19a0" dependencies = [ - "proc-macro2 1.0.92", - "quote 1.0.36", + "proc-macro2", + "quote", "syn 2.0.89", ] @@ -5052,25 +4838,14 @@ dependencies = [ "tracing", ] -[[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", -] - [[package]] name = "syn" version = "1.0.109" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" dependencies = [ - "proc-macro2 1.0.92", - "quote 1.0.36", + "proc-macro2", + "quote", "unicode-ident", ] @@ -5080,8 +4855,8 @@ version = "2.0.89" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "44d46482f1c1c87acd84dea20c1bf5ebff4c757009ed6bf19cfd36fb10e92c4e" dependencies = [ - "proc-macro2 1.0.92", - "quote 1.0.36", + "proc-macro2", + "quote", "unicode-ident", ] @@ -5091,8 +4866,8 @@ version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b5dc35bb08dd1ca3dfb09dce91fd2d13294d6711c88897d9a9d60acf39bce049" dependencies = [ - "proc-macro2 1.0.92", - "quote 1.0.36", + "proc-macro2", + "quote", "syn 2.0.89", ] @@ -5117,8 +4892,8 @@ version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8af7666ab7b6390ab78131fb5b0fce11d6b7a6951602017c35fa82800708971" dependencies = [ - "proc-macro2 1.0.92", - "quote 1.0.36", + "proc-macro2", + "quote", "syn 2.0.89", ] @@ -5130,18 +4905,7 @@ checksum = "ba3a3adc5c275d719af8cb4272ea1c4a6d668a777f37e115f6d11ddbc1c8e0e7" dependencies = [ "bitflags 1.3.2", "core-foundation", - "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", + "system-configuration-sys", ] [[package]] @@ -5154,16 +4918,6 @@ dependencies = [ "libc", ] -[[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" @@ -5194,7 +4948,7 @@ dependencies = [ "resolv-conf", "serde", "serde_json", - "system-configuration 0.5.1", + "system-configuration", "talpid-dbus", "talpid-macos", "talpid-net", @@ -5335,7 +5089,7 @@ dependencies = [ "netlink-sys", "nix 0.28.0", "rtnetlink", - "system-configuration 0.5.1", + "system-configuration", "talpid-types", "talpid-windows", "thiserror 2.0.9", @@ -5506,8 +5260,8 @@ version = "1.0.59" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d1cd413b5d558b4c5bf3680e324a6fa5014e7b7c067a51e69dbdf47eb7148b66" dependencies = [ - "proc-macro2 1.0.92", - "quote 1.0.36", + "proc-macro2", + "quote", "syn 2.0.89", ] @@ -5517,8 +5271,8 @@ version = "2.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7b50fa271071aae2e6ee85f842e2e28ba8cd2c5fb67f11fcb1fd70b276f9e7d4" dependencies = [ - "proc-macro2 1.0.92", - "quote 1.0.36", + "proc-macro2", + "quote", "syn 2.0.89", ] @@ -5590,8 +5344,8 @@ version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "693d596312e88961bc67d7f1f97af8a70227d9f90c31bba5806eec004978d752" dependencies = [ - "proc-macro2 1.0.92", - "quote 1.0.36", + "proc-macro2", + "quote", "syn 2.0.89", ] @@ -5774,9 +5528,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d021fc044c18582b9a2408cd0dd05b1596e3ecdb5c4df822bb0183545683889" dependencies = [ "prettyplease", - "proc-macro2 1.0.92", + "proc-macro2", "prost-build", - "quote 1.0.36", + "quote", "syn 2.0.89", ] @@ -5843,8 +5597,8 @@ version = "0.1.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34704c8d6ebcbc939824180af020566b01a7c01f80641264eba0999f6c2b6be7" dependencies = [ - "proc-macro2 1.0.92", - "quote 1.0.36", + "proc-macro2", + "quote", "syn 2.0.89", ] @@ -5952,12 +5706,6 @@ 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 = "universal-hash" version = "0.5.1" @@ -6085,8 +5833,8 @@ dependencies = [ "bumpalo", "log", "once_cell", - "proc-macro2 1.0.92", - "quote 1.0.36", + "proc-macro2", + "quote", "syn 2.0.89", "wasm-bindgen-shared", ] @@ -6109,7 +5857,7 @@ version = "0.2.92" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a1f8823de937b71b9460c0c34e25f3da88250760bec0ebac694b49997550d726" dependencies = [ - "quote 1.0.36", + "quote", "wasm-bindgen-macro-support", ] @@ -6119,8 +5867,8 @@ version = "0.2.92" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e94f17b526d0a461a191c78ea52bbce64071ed5c04c9ffe424dcb38f74171bb7" dependencies = [ - "proc-macro2 1.0.92", - "quote 1.0.36", + "proc-macro2", + "quote", "syn 2.0.89", "wasm-bindgen-backend", "wasm-bindgen-shared", @@ -6292,8 +6040,8 @@ version = "0.58.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b" dependencies = [ - "proc-macro2 1.0.92", - "quote 1.0.36", + "proc-macro2", + "quote", "syn 2.0.89", ] @@ -6303,8 +6051,8 @@ version = "0.59.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "83577b051e2f49a058c308f17f273b570a6a758386fc291b5f6a934dd84e48c1" dependencies = [ - "proc-macro2 1.0.92", - "quote 1.0.36", + "proc-macro2", + "quote", "syn 2.0.89", ] @@ -6325,8 +6073,8 @@ version = "0.58.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515" dependencies = [ - "proc-macro2 1.0.92", - "quote 1.0.36", + "proc-macro2", + "quote", "syn 2.0.89", ] @@ -6336,8 +6084,8 @@ version = "0.59.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cb26fd936d991781ea39e87c3a27285081e3c0da5ca0fcbc02d368cc6f52ff01" dependencies = [ - "proc-macro2 1.0.92", - "quote 1.0.36", + "proc-macro2", + "quote", "syn 2.0.89", ] @@ -6828,8 +6576,8 @@ version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28cc31741b18cb6f1d5ff12f5b7523e3d6eb0852bbbad19d73905511d9849b95" dependencies = [ - "proc-macro2 1.0.92", - "quote 1.0.36", + "proc-macro2", + "quote", "syn 2.0.89", "synstructure", ] @@ -6849,8 +6597,8 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ea7b4a3637ea8669cedf0f1fd5c286a17f3de97b8dd5a70a6c167a1730e63a5" dependencies = [ - "proc-macro2 1.0.92", - "quote 1.0.36", + "proc-macro2", + "quote", "syn 2.0.89", "synstructure", ] @@ -6870,8 +6618,8 @@ version = "1.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ce36e65b0d2999d2aafac989fb249189a141aee1f53c612c1f37d72631959f69" dependencies = [ - "proc-macro2 1.0.92", - "quote 1.0.36", + "proc-macro2", + "quote", "syn 2.0.89", ] @@ -6892,7 +6640,7 @@ version = "0.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6eafa6dfb17584ea3e2bd6e76e0cc15ad7af12b09abdd1ca55961bed9b1063c6" dependencies = [ - "proc-macro2 1.0.92", - "quote 1.0.36", + "proc-macro2", + "quote", "syn 2.0.89", ] diff --git a/installer-downloader/Cargo.toml b/installer-downloader/Cargo.toml index 029a10dcbb9d..d15008254d4b 100644 --- a/installer-downloader/Cargo.toml +++ b/installer-downloader/Cargo.toml @@ -11,18 +11,18 @@ rust-version.workspace = true workspace = true [build-dependencies] -anyhow = "1.0" +anyhow = { workspace = true } winres = "0.1" -windows-sys = { version = "0.52.0", features = ["Win32_System", "Win32_System_LibraryLoader", "Win32_System_SystemServices"] } +windows-sys = { workspace = true, 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"] } +anyhow = { workspace = true } +tokio = { workspace = true, features = ["rt-multi-thread", "fs"] } async-trait = "0.1" rand = { version = "0.8.5" } serde = { workspace = true, features = ["derive"] } -mullvad-update = { path = "../mullvad-update" } +mullvad-update = { path = "../mullvad-update", features = ["client", "native-tls"] } [target.'cfg(target_os = "windows")'.dependencies] windows-sys = { workspace = true, features = ["Win32_UI", "Win32_UI_WindowsAndMessaging", "Win32_Graphics", "Win32_Graphics_Gdi"] } @@ -30,12 +30,11 @@ 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"] } +tokio = { workspace = true, features = ["test-util", "macros"] } insta = { workspace = true, features = ["yaml"] } [package.metadata.winres] diff --git a/installer-downloader/src/lib.rs b/installer-downloader/src/lib.rs index 724b546998c6..2a9896ab4c42 100644 --- a/installer-downloader/src/lib.rs +++ b/installer-downloader/src/lib.rs @@ -1,4 +1,8 @@ +#[cfg(any(target_os = "windows", target_os = "macos"))] pub mod controller; +#[cfg(any(target_os = "windows", target_os = "macos"))] pub mod delegate; +#[cfg(any(target_os = "windows", target_os = "macos"))] pub mod resource; +#[cfg(any(target_os = "windows", target_os = "macos"))] pub mod ui_downloader; diff --git a/installer-downloader/tests/controller.rs b/installer-downloader/tests/controller.rs index 4c8f94813b41..b1a05a0578bd 100644 --- a/installer-downloader/tests/controller.rs +++ b/installer-downloader/tests/controller.rs @@ -1,3 +1,5 @@ +#![cfg(any(target_os = "windows", target_os = "macos"))] + //! Tests for integrations between UI controller and other components //! //! The tests rely on `insta` for snapshot testing. If they fail due to snapshot assertions, diff --git a/mullvad-update/Cargo.toml b/mullvad-update/Cargo.toml index 5fbf620adfb7..f601f26ad38b 100644 --- a/mullvad-update/Cargo.toml +++ b/mullvad-update/Cargo.toml @@ -13,33 +13,39 @@ workspace = true [features] default = [] sign = ["rand", "clap"] +client = ["async-trait", "mullvad-paths", "reqwest", "sha2", "tokio", "thiserror"] +native-tls = ["reqwest/native-tls"] [dependencies] -anyhow = "1.0" +anyhow = { workspace = true } json-canon = "0.1" chrono = { workspace = true, features = ["serde", "now"] } ed25519-dalek = { version = "2.1" } hex = { version = "0.4" } -reqwest = { version = "0.12.9", features = ["blocking", "json"] } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } -sha2 = "0.10" -thiserror = { workspace = true } -tokio = { version = "1", features = ["full"] } -async-trait = "0.1" -mullvad-paths = { path = "../mullvad-paths" } +async-trait = { version = "0.1", optional = true } +reqwest = { version = "0.12.9", default-features = false, optional = true } +sha2 = { version = "0.10", optional = true } +tokio = { workspace = true, features = ["rt-multi-thread", "fs", "process"], optional = true } +thiserror = { workspace = true, optional = true } + mullvad-version = { path = "../mullvad-version", features = ["serde"] } # features required by binaries clap = { workspace = true, optional = true } rand = { version = "0.8.5", optional = true } +[target.'cfg(target_os = "windows")'.dependencies] +mullvad-paths = { path = "../mullvad-paths", optional = true } + [dev-dependencies] async-tempfile = "0.6" insta = { workspace = true } mockito = "1.6.1" rand = "0.8.5" +tokio = { workspace = true, features = ["test-util", "time", "macros"] } [[bin]] name = "mullvad-version-metadata" diff --git a/mullvad-update/src/api.rs b/mullvad-update/src/client/api.rs similarity index 97% rename from mullvad-update/src/api.rs rename to mullvad-update/src/client/api.rs index 8695daa2156f..0146f19d336f 100644 --- a/mullvad-update/src/api.rs +++ b/mullvad-update/src/client/api.rs @@ -103,7 +103,7 @@ mod test { #[tokio::test] async fn test_http_version_provider() -> anyhow::Result<()> { let verifying_key = - crate::format::key::VerifyingKey::from_hex(include_str!("../test-pubkey")) + crate::format::key::VerifyingKey::from_hex(include_str!("../../test-pubkey")) .expect("valid key"); // Start HTTP server @@ -111,7 +111,7 @@ mod test { let _mock = server .mock("GET", "/version") // Respond with some version response payload - .with_body(include_bytes!("../test-version-response.json")) + .with_body(include_bytes!("../../test-version-response.json")) .create(); let url = format!("{}/version", server.url()); diff --git a/mullvad-update/src/app.rs b/mullvad-update/src/client/app.rs similarity index 100% rename from mullvad-update/src/app.rs rename to mullvad-update/src/client/app.rs diff --git a/mullvad-update/src/dir.rs b/mullvad-update/src/client/dir.rs similarity index 100% rename from mullvad-update/src/dir.rs rename to mullvad-update/src/client/dir.rs diff --git a/mullvad-update/src/fetch.rs b/mullvad-update/src/client/fetch.rs similarity index 100% rename from mullvad-update/src/fetch.rs rename to mullvad-update/src/client/fetch.rs diff --git a/mullvad-update/src/client/mod.rs b/mullvad-update/src/client/mod.rs new file mode 100644 index 000000000000..ac9ca6641c91 --- /dev/null +++ b/mullvad-update/src/client/mod.rs @@ -0,0 +1,5 @@ +pub mod api; +pub mod app; +pub mod dir; +pub mod fetch; +pub mod verify; diff --git a/mullvad-update/src/snapshots/mullvad_update__api__test__http_version_provider.snap b/mullvad-update/src/client/snapshots/mullvad_update__client__api__test__http_version_provider.snap similarity index 100% rename from mullvad-update/src/snapshots/mullvad_update__api__test__http_version_provider.snap rename to mullvad-update/src/client/snapshots/mullvad_update__client__api__test__http_version_provider.snap diff --git a/mullvad-update/src/verify.rs b/mullvad-update/src/client/verify.rs similarity index 100% rename from mullvad-update/src/verify.rs rename to mullvad-update/src/client/verify.rs diff --git a/mullvad-update/src/lib.rs b/mullvad-update/src/lib.rs index f6f0b74e10ab..2c7890808973 100644 --- a/mullvad-update/src/lib.rs +++ b/mullvad-update/src/lib.rs @@ -1,10 +1,11 @@ //! Support functions for securely installing or updating Mullvad VPN -pub mod api; -pub mod app; -pub mod dir; -pub mod fetch; -pub mod verify; +#[cfg(all(feature = "client", any(target_os = "windows", target_os = "macos")))] +mod client; + +#[cfg(all(feature = "client", any(target_os = "windows", target_os = "macos")))] +pub use client::*; + pub mod version; /// Parser and serializer for version metadata From e07bbc0cfb3036310e2e297abb908041c5def540 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20L=C3=B6nnhager?= Date: Sat, 22 Feb 2025 19:23:54 +0100 Subject: [PATCH 033/112] Fix clippy lints --- .../src/cacao_impl/delegate.rs | 5 +- installer-downloader/src/cacao_impl/mod.rs | 9 +- installer-downloader/src/cacao_impl/ui.rs | 13 +- installer-downloader/src/controller.rs | 115 +++++++++--------- installer-downloader/src/winapi_impl/ui.rs | 2 +- installer-downloader/tests/controller.rs | 4 +- .../src/bin/mullvad-version-metadata.rs | 2 +- mullvad-update/src/format/deserializer.rs | 4 +- mullvad-update/src/format/key.rs | 10 +- mullvad-update/src/version.rs | 4 +- 10 files changed, 85 insertions(+), 83 deletions(-) diff --git a/installer-downloader/src/cacao_impl/delegate.rs b/installer-downloader/src/cacao_impl/delegate.rs index 0a09b3324dd0..84a80a5f20b3 100644 --- a/installer-downloader/src/cacao_impl/delegate.rs +++ b/installer-downloader/src/cacao_impl/delegate.rs @@ -168,7 +168,7 @@ impl AppWindow { fn sync_callback( callback: impl Fn() + Send + 'static, ) -> Arc>> { - Arc::new(Mutex::new(Box::new(move || callback()))) + Arc::new(Mutex::new(Box::new(callback))) } } @@ -178,8 +178,7 @@ pub struct Queue {} impl AppDelegateQueue for Queue { fn queue_main(&self, callback: F) { // NOTE: We need this horrible lock because Dispatcher demands Sync - let cb: Mutex>> = - Mutex::new(Some(Box::new(callback))); + let cb: Mutex> = Mutex::new(Some(Box::new(callback))); cacao::appkit::App::::dispatch_main(Action::QueueMain(cb)); } } diff --git a/installer-downloader/src/cacao_impl/mod.rs b/installer-downloader/src/cacao_impl/mod.rs index 89cd3b140265..e2974d370eeb 100644 --- a/installer-downloader/src/cacao_impl/mod.rs +++ b/installer-downloader/src/cacao_impl/mod.rs @@ -1,7 +1,7 @@ use std::sync::Mutex; use cacao::appkit::App; -use ui::{Action, AppImpl, AppWindow}; +use ui::{Action, AppImpl}; mod delegate; mod ui; @@ -9,10 +9,9 @@ mod ui; pub fn main() { let app = App::new("net.mullvad.downloader", AppImpl::default()); - let cb: Mutex>> = - Mutex::new(Some(Box::new(|self_| { - crate::controller::initialize_controller(self_); - }))); + let cb: Mutex> = Mutex::new(Some(Box::new(|self_| { + crate::controller::initialize_controller(self_); + }))); cacao::appkit::App::::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 index 5981b9465e24..b38dbd02d616 100644 --- a/installer-downloader/src/cacao_impl/ui.rs +++ b/installer-downloader/src/cacao_impl/ui.rs @@ -83,7 +83,7 @@ pub enum Action { /// User clicked the cancel button CancelClick(Arc>>), /// Run callback on main thread - QueueMain(Mutex FnOnce(&'a mut AppWindow) + Send>>>), + QueueMain(Mutex>), /// User clicked the retry button in the error view ErrorRetry(Arc>>), /// User clicked the cancel button in the error view @@ -92,6 +92,9 @@ pub enum Action { Quit, } +/// Callback used for `QueueMain` +pub type MainThreadCallback = Box FnOnce(&'a mut AppWindow) + Send>; + impl Dispatcher for AppImpl { type Message = Action; @@ -155,8 +158,8 @@ pub struct AppWindow { pub status_text: Label, pub error_view: Option, - pub error_retry_callback: Option>>>, - pub error_cancel_callback: Option>>>, + pub error_retry_callback: Option>>, + pub error_cancel_callback: Option>>, pub download_text: Label, @@ -172,6 +175,8 @@ pub struct ErrorView { pub cancel_button: Button, } +pub type ErrorViewClickCallback = Box; + pub struct DownloadButton { pub button: Button, } @@ -286,7 +291,7 @@ impl AppWindow { 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); + 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); diff --git a/installer-downloader/src/controller.rs b/installer-downloader/src/controller.rs index c4383dff7d8b..5bd42709e2e3 100644 --- a/installer-downloader/src/controller.rs +++ b/installer-downloader/src/controller.rs @@ -123,78 +123,75 @@ impl AppController { } /// Background task that fetches app version data. -fn fetch_app_version_info( +async fn fetch_app_version_info( queue: Delegate::Queue, download_tx: mpsc::Sender, version_provider: VersionProvider, -) -> impl Future -where +) where Delegate: AppDelegate + 'static, VersionProvider: VersionInfoProvider + Send, { - async move { - loop { - let version_params = VersionParameters { - // TODO: detect current architecture - architecture: VersionArchitecture::X86, - // For the downloader, the rollout version is always preferred - rollout: 1., - // The downloader allows any version - lowest_metadata_version: 0, - }; - - let err = match version_provider.get_version_info(version_params).await { - Ok(version_info) => { - let _ = download_tx.try_send(TaskMessage::SetVersionInfo(version_info)); - return; - } - Err(err) => err, - }; + loop { + let version_params = VersionParameters { + // TODO: detect current architecture + architecture: VersionArchitecture::X86, + // For the downloader, the rollout version is always preferred + rollout: 1., + // The downloader allows any version + lowest_metadata_version: 0, + }; + + let err = match version_provider.get_version_info(version_params).await { + Ok(version_info) => { + let _ = download_tx.try_send(TaskMessage::SetVersionInfo(version_info)); + return; + } + Err(err) => err, + }; - eprintln!("Failed to get version info: {err}"); + eprintln!("Failed to get version info: {err}"); - enum Action { - Retry, - Cancel, - } + enum Action { + Retry, + Cancel, + } - let (action_tx, mut action_rx) = mpsc::channel(1); + let (action_tx, mut action_rx) = mpsc::channel(1); - // show error message (needs to happen on the UI thread) - // send Action when user presses a button to contin - queue.queue_main(move |self_| { - self_.hide_download_button(); + // show error message (needs to happen on the UI thread) + // send Action when user presses a button to continue + queue.queue_main(move |self_| { + self_.hide_download_button(); - let (retry_tx, cancel_tx) = (action_tx.clone(), action_tx); + let (retry_tx, cancel_tx) = (action_tx.clone(), action_tx); - self_.set_status_text(""); - self_.on_error_message_retry(move || { - let _ = retry_tx.try_send(Action::Retry); - }); - self_.on_error_message_cancel(move || { - let _ = cancel_tx.try_send(Action::Cancel); - }); - self_.show_error_message(crate::delegate::ErrorMessage { - status_text: resource::FETCH_VERSION_ERROR_DESC.to_owned(), - cancel_button_text: resource::FETCH_VERSION_ERROR_CANCEL_BUTTON_TEXT.to_owned(), - retry_button_text: resource::FETCH_VERSION_ERROR_RETRY_BUTTON_TEXT.to_owned(), - }); + self_.set_status_text(""); + self_.on_error_message_retry(move || { + let _ = retry_tx.try_send(Action::Retry); + }); + self_.on_error_message_cancel(move || { + let _ = cancel_tx.try_send(Action::Cancel); }); + self_.show_error_message(crate::delegate::ErrorMessage { + status_text: resource::FETCH_VERSION_ERROR_DESC.to_owned(), + cancel_button_text: resource::FETCH_VERSION_ERROR_CANCEL_BUTTON_TEXT.to_owned(), + retry_button_text: resource::FETCH_VERSION_ERROR_RETRY_BUTTON_TEXT.to_owned(), + }); + }); - // wait for user to press either button - let Some(action) = action_rx.recv().await else { - panic!("channel was dropped? argh") - }; + // wait for user to press either button + let Some(action) = action_rx.recv().await else { + panic!("channel was dropped? argh") + }; - match action { - Action::Retry => { - continue; - } - Action::Cancel => { - queue.queue_main(|self_| { - self_.quit(); - }); - } + match action { + Action::Retry => { + continue; + } + Action::Cancel => { + queue.queue_main(|self_| { + self_.quit(); + }); } } } @@ -269,7 +266,7 @@ async fn handle_action_messages( }); } TaskMessage::BeginDownload => { - if let Some(_) = active_download.take() { + if active_download.take().is_some() { println!("Interrupting ongoing download"); } let Some(version_info) = version_info.clone() else { @@ -372,7 +369,7 @@ async fn handle_action_messages( } }; - let version_label = format_latest_version(&selected_version); + let version_label = format_latest_version(selected_version); let has_beta = version_info.beta.is_some(); queue.queue_main(move |self_| { diff --git a/installer-downloader/src/winapi_impl/ui.rs b/installer-downloader/src/winapi_impl/ui.rs index 820972a9141d..a24874a55a41 100644 --- a/installer-downloader/src/winapi_impl/ui.rs +++ b/installer-downloader/src/winapi_impl/ui.rs @@ -406,7 +406,7 @@ fn handle_link_messages( handler_id: usize, ) -> Result { let link_hwnd = link.handle.hwnd().map(|hwnd| hwnd as isize); - nwg::bind_raw_event_handler(&parent, handler_id, move |_hwnd, msg, w, p| { + nwg::bind_raw_event_handler(parent, 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) diff --git a/installer-downloader/tests/controller.rs b/installer-downloader/tests/controller.rs index b1a05a0578bd..704835e5a7f5 100644 --- a/installer-downloader/tests/controller.rs +++ b/installer-downloader/tests/controller.rs @@ -123,9 +123,11 @@ impl>>>, + callbacks: Arc>>, } +pub type MainThreadCallback = Box; + impl FakeQueue { /// Run all queued callbacks on the given delegate. fn run_callbacks(&self, delegate: &mut FakeAppDelegate) { diff --git a/mullvad-update/src/bin/mullvad-version-metadata.rs b/mullvad-update/src/bin/mullvad-version-metadata.rs index fbfc0ed51b83..c531fbe9ddcc 100644 --- a/mullvad-update/src/bin/mullvad-version-metadata.rs +++ b/mullvad-update/src/bin/mullvad-version-metadata.rs @@ -35,7 +35,7 @@ async fn main() -> anyhow::Result<()> { match opt { Opt::GenerateKey => { - println!("{}", key::SecretKey::generate().to_string()); + println!("{}", key::SecretKey::generate()); Ok(()) } Opt::Sign { file, secret } => sign(file, secret).await, diff --git a/mullvad-update/src/format/deserializer.rs b/mullvad-update/src/format/deserializer.rs index 28291dde003d..f79aab653770 100644 --- a/mullvad-update/src/format/deserializer.rs +++ b/mullvad-update/src/format/deserializer.rs @@ -40,7 +40,7 @@ impl SignedResponse { min_metadata_version: usize, ) -> Result { // Deserialize and verify signature - let partial_data = deserialize_and_verify(&key, bytes)?; + let partial_data = deserialize_and_verify(key, bytes)?; // Deserialize the canonical JSON to structured representation let signed_response: Response = serde_json::from_value(partial_data.signed) @@ -182,7 +182,7 @@ mod test { .expect("deserialization failed"); let expected_key = VerifyingKey::from_hex(pubkey).unwrap(); - let expected_sig = Signature::from_hex(&fakesig).unwrap(); + let expected_sig = Signature::from_hex(fakesig).unwrap(); // Ed25519 key assert!( diff --git a/mullvad-update/src/format/key.rs b/mullvad-update/src/format/key.rs index a951aff709fd..01cd51a6ea1a 100644 --- a/mullvad-update/src/format/key.rs +++ b/mullvad-update/src/format/key.rs @@ -1,6 +1,6 @@ //! Key and signature types for API version response format -use std::str::FromStr; +use std::{fmt, str::FromStr}; use anyhow::{bail, Context}; use ed25519_dalek::ed25519::signature::SignerMut; @@ -34,9 +34,9 @@ impl SecretKey { } } -impl ToString for SecretKey { - fn to_string(&self) -> String { - hex::encode(self.0) +impl fmt::Display for SecretKey { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", hex::encode(self.0)) } } @@ -56,7 +56,7 @@ impl FromStr for SecretKey { type Err = anyhow::Error; fn from_str(s: &str) -> Result { - let bytes = bytes_from_hex::<{ ed25519_dalek::SECRET_KEY_LENGTH }>(&s)?; + let bytes = bytes_from_hex::<{ ed25519_dalek::SECRET_KEY_LENGTH }>(s)?; Ok(SecretKey(bytes)) } } diff --git a/mullvad-update/src/version.rs b/mullvad-update/src/version.rs index 5317cf8d49df..80c28c7846a4 100644 --- a/mullvad-update/src/version.rs +++ b/mullvad-update/src/version.rs @@ -121,7 +121,7 @@ impl VersionInfo { Ok(Self { stable: Version::try_from(stable)?, - beta: beta.map(|beta| Version::try_from(beta)).transpose()?, + beta: beta.map(Version::try_from).transpose()?, }) } @@ -131,7 +131,7 @@ impl VersionInfo { fn find_duplicate_version(releases: &[format::Release]) -> Option<&mullvad_version::Version> { releases .windows(2) - .find(|pair| &pair[0].version == &pair[1].version) + .find(|pair| pair[0].version == pair[1].version) .map(|pair| &pair[0].version) } } From c0ca7e20c6271fa5d4206623c0b2d5155f8c5bbd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20L=C3=B6nnhager?= Date: Sat, 22 Feb 2025 20:55:44 +0100 Subject: [PATCH 034/112] Remove unused features from native-windows-gui --- Cargo.lock | 55 +-------------------------------- installer-downloader/Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 55 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b7fac4207bc7..06256333f451 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2292,12 +2292,6 @@ dependencies = [ "windows-targets 0.52.6", ] -[[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" @@ -2944,10 +2938,6 @@ checksum = "4f7003a669f68deb6b7c57d74fff4f8e533c44a3f0b297492440ef4ff5a28454" dependencies = [ "bitflags 1.3.2", "lazy_static", - "newline-converter", - "plotters", - "plotters-backend", - "stretch", "winapi", "winapi-build", ] @@ -3050,15 +3040,6 @@ dependencies = [ "tokio", ] -[[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" @@ -3178,7 +3159,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "da0df0e5185db44f69b44f26786fe401b6c293d1907744beaa7fa62b2e5a517a" dependencies = [ "autocfg", - "libm 0.2.8", + "libm", ] [[package]] @@ -3655,24 +3636,6 @@ version = "0.3.30" 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" @@ -4800,16 +4763,6 @@ version = "1.2.0" 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" @@ -5700,12 +5653,6 @@ version = "1.0.12" 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 = "universal-hash" version = "0.5.1" diff --git a/installer-downloader/Cargo.toml b/installer-downloader/Cargo.toml index d15008254d4b..d4895e3b9ffe 100644 --- a/installer-downloader/Cargo.toml +++ b/installer-downloader/Cargo.toml @@ -26,7 +26,7 @@ mullvad-update = { path = "../mullvad-update", features = ["client", "native-tls [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"] } +native-windows-gui = { version = "1.0.12", features = ["frame", "image-decoder", "progress-bar"], default-features = false } [target.'cfg(target_os = "macos")'.dependencies] cacao = "0.3.2" From 03491701d10b08190d71b300f6fe79f4ff981db8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20L=C3=B6nnhager?= Date: Sun, 23 Feb 2025 13:24:12 +0100 Subject: [PATCH 035/112] Remove unnecessary `into_boxed_slice` --- mullvad-update/src/client/fetch.rs | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/mullvad-update/src/client/fetch.rs b/mullvad-update/src/client/fetch.rs index 8ba821a5f400..db0a64c4cf26 100644 --- a/mullvad-update/src/client/fetch.rs +++ b/mullvad-update/src/client/fetch.rs @@ -362,13 +362,13 @@ mod test { 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 buffer = vec![0u8; file_data.len() / 2]; + let mut limited_writer = Cursor::new(&mut buffer[..]); let mut progress_updater = FakeProgressUpdater::default(); get_to_writer( - &mut writer, + &mut limited_writer, &file_url, &mut progress_updater, SizeHint::Exact(file_data.len()), @@ -385,15 +385,14 @@ mod test { ); assert_eq!( - &*limited_buffer, - &file_data[..limited_buffer.len()], + &*buffer, + &file_data[..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); + let partial_len = buffer.len(); + let mut writer = Cursor::new(buffer); writer.set_position(partial_len as u64); let mut progress_updater = FakeProgressUpdater::default(); From e09092b885778fb68a21c908dc412fdac12c7d98 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20L=C3=B6nnhager?= Date: Sun, 23 Feb 2025 13:50:55 +0100 Subject: [PATCH 036/112] Add logger to installer-downloader --- Cargo.lock | 2 ++ installer-downloader/Cargo.toml | 4 ++++ installer-downloader/src/controller.rs | 17 ++++++++--------- installer-downloader/src/main.rs | 2 ++ installer-downloader/src/winapi_impl/ui.rs | 6 +++--- installer-downloader/tests/controller.rs | 5 +++-- mullvad-update/src/client/app.rs | 20 ++++++++------------ 7 files changed, 30 insertions(+), 26 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 06256333f451..af13f48aa6cf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2038,7 +2038,9 @@ dependencies = [ "anyhow", "async-trait", "cacao", + "env_logger 0.10.2", "insta", + "log", "mullvad-update", "native-windows-gui", "objc_id", diff --git a/installer-downloader/Cargo.toml b/installer-downloader/Cargo.toml index d4895e3b9ffe..e84e2091beab 100644 --- a/installer-downloader/Cargo.toml +++ b/installer-downloader/Cargo.toml @@ -22,6 +22,10 @@ async-trait = "0.1" rand = { version = "0.8.5" } serde = { workspace = true, features = ["derive"] } +# Note: Not using workspace since we want fewer features +env_logger = { version = "0.10.0", default-features = false } +log = { workspace = true } + mullvad-update = { path = "../mullvad-update", features = ["client", "native-tls"] } [target.'cfg(target_os = "windows")'.dependencies] diff --git a/installer-downloader/src/controller.rs b/installer-downloader/src/controller.rs index 5bd42709e2e3..1485f7d2b5ab 100644 --- a/installer-downloader/src/controller.rs +++ b/installer-downloader/src/controller.rs @@ -11,7 +11,6 @@ use mullvad_update::{ }; use rand::seq::SliceRandom; -use std::error::Error; use std::future::Future; use std::path::PathBuf; use tokio::sync::{mpsc, oneshot}; @@ -149,7 +148,7 @@ async fn fetch_app_version_info( Err(err) => err, }; - eprintln!("Failed to get version info: {err}"); + log::error!("Failed to get version info: {err:?}"); enum Action { Retry, @@ -235,9 +234,11 @@ async fn handle_action_messages( } TaskMessage::TryBeta => { let Some(version_info) = version_info.as_ref() else { + log::error!("Attempted 'try beta' before having version info"); continue; }; let Some(beta_info) = version_info.beta.as_ref() else { + log::error!("Attempted 'try beta' without beta version"); continue; }; @@ -252,6 +253,7 @@ async fn handle_action_messages( } TaskMessage::TryStable => { let Some(version_info) = version_info.as_ref() else { + log::error!("Attempted 'try stable' before having version info"); continue; }; let stable_info = &version_info.stable; @@ -267,9 +269,10 @@ async fn handle_action_messages( } TaskMessage::BeginDownload => { if active_download.take().is_some() { - println!("Interrupting ongoing download"); + log::debug!("Interrupting ongoing download"); } let Some(version_info) = version_info.clone() else { + log::error!("Attempted 'begin download' before having version info"); continue; }; @@ -341,12 +344,7 @@ async fn handle_action_messages( let ui_downloader = UiAppDownloader::new(self_, downloader); let _ = tx.send(tokio::spawn(async move { if let Err(err) = app::install_and_upgrade(ui_downloader).await { - eprintln!("install_and_upgrade failed: {err}"); - let mut source = err.source(); - while let Some(error) = source { - eprintln!("caused by: {error}"); - source = error.source(); - } + log::error!("install_and_upgrade failed: {err:?}"); } })); }); @@ -359,6 +357,7 @@ async fn handle_action_messages( } let Some(version_info) = version_info.as_ref() else { + log::error!("Attempted 'cancel' before having version info"); continue; }; diff --git a/installer-downloader/src/main.rs b/installer-downloader/src/main.rs index 51f9e303e5f8..f190377d285b 100644 --- a/installer-downloader/src/main.rs +++ b/installer-downloader/src/main.rs @@ -13,6 +13,8 @@ mod inner { pub use installer_downloader::resource; pub fn run() { + env_logger::init(); + let rt = tokio::runtime::Builder::new_multi_thread() .enable_all() .build() diff --git a/installer-downloader/src/winapi_impl/ui.rs b/installer-downloader/src/winapi_impl/ui.rs index a24874a55a41..a83d2007bcee 100644 --- a/installer-downloader/src/winapi_impl/ui.rs +++ b/installer-downloader/src/winapi_impl/ui.rs @@ -314,16 +314,16 @@ impl AppWindow { /// 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}"); + log::error!("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}"); + log::error!("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}"); + log::error!("handle_banner_label_colors failed: {err}"); // not fatal, so continue } diff --git a/installer-downloader/tests/controller.rs b/installer-downloader/tests/controller.rs index 704835e5a7f5..0128746a4222 100644 --- a/installer-downloader/tests/controller.rs +++ b/installer-downloader/tests/controller.rs @@ -14,6 +14,7 @@ use mullvad_update::api::VersionInfoProvider; use mullvad_update::app::{AppDownloader, DownloadError}; use mullvad_update::fetch::ProgressUpdater; use mullvad_update::version::{Version, VersionInfo, VersionParameters}; +use std::io; use std::path::{Path, PathBuf}; use std::sync::{Arc, LazyLock, Mutex}; use std::time::Duration; @@ -113,8 +114,8 @@ impl AppDownloader for HttpAppDownloader Ok(()), // No timeout: Incredibly quick but successful (or wrong exit code, probably) Ok(Ok(status)) if status.success() => Ok(()), - // Installer failed - Ok(Ok(status)) => Err(DownloadError::InstallFailed(anyhow::anyhow!( - "Install failed with status: {status}" - ))), - // Installer failed - Ok(Err(err)) => Err(DownloadError::InstallFailed(anyhow::anyhow!( - "Install failed: {err}" - ))), + // Installer exited with error code + Ok(Ok(status)) => Err(DownloadError::InstallExited(status)), + // `child.wait()` returned an error + Ok(Err(err)) => Err(DownloadError::InstallFailed(err)), } } } From 47d023589dea6b6da81f5e3706f94cab48565ae8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20L=C3=B6nnhager?= Date: Mon, 24 Feb 2025 10:23:38 +0100 Subject: [PATCH 037/112] Use user-accessible random temp dir on macOS --- Cargo.lock | 2 +- installer-downloader/Cargo.toml | 1 + installer-downloader/src/controller.rs | 32 ++++------ installer-downloader/src/lib.rs | 2 + installer-downloader/src/temp.rs | 85 ++++++++++++++++++++++++++ mullvad-update/Cargo.toml | 5 +- mullvad-update/src/client/dir.rs | 42 ------------- mullvad-update/src/client/mod.rs | 1 - 8 files changed, 101 insertions(+), 69 deletions(-) create mode 100644 installer-downloader/src/temp.rs delete mode 100644 mullvad-update/src/client/dir.rs diff --git a/Cargo.lock b/Cargo.lock index af13f48aa6cf..73019fabbaa0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2041,6 +2041,7 @@ dependencies = [ "env_logger 0.10.2", "insta", "log", + "mullvad-paths", "mullvad-update", "native-windows-gui", "objc_id", @@ -2890,7 +2891,6 @@ dependencies = [ "insta", "json-canon", "mockito", - "mullvad-paths", "mullvad-version", "rand 0.8.5", "reqwest", diff --git a/installer-downloader/Cargo.toml b/installer-downloader/Cargo.toml index e84e2091beab..a8d54d2a14f9 100644 --- a/installer-downloader/Cargo.toml +++ b/installer-downloader/Cargo.toml @@ -31,6 +31,7 @@ mullvad-update = { path = "../mullvad-update", features = ["client", "native-tls [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 = ["frame", "image-decoder", "progress-bar"], default-features = false } +mullvad-paths = { path = "../mullvad-paths" } [target.'cfg(target_os = "macos")'.dependencies] cacao = "0.3.2" diff --git a/installer-downloader/src/controller.rs b/installer-downloader/src/controller.rs index 1485f7d2b5ab..bf492c09006a 100644 --- a/installer-downloader/src/controller.rs +++ b/installer-downloader/src/controller.rs @@ -2,6 +2,7 @@ use crate::delegate::{AppDelegate, AppDelegateQueue}; use crate::resource; +use crate::temp::DirectoryProvider; use crate::ui_downloader::{UiAppDownloader, UiAppDownloaderParameters, UiProgressUpdater}; use mullvad_update::{ @@ -11,8 +12,6 @@ use mullvad_update::{ }; use rand::seq::SliceRandom; -use std::future::Future; -use std::path::PathBuf; use tokio::sync::{mpsc, oneshot}; /// Actions handled by an async worker task in [handle_action_messages]. @@ -24,21 +23,6 @@ enum TaskMessage { TryStable, } -/// Provide a directory to use for [AppDownloader] -pub trait DirectoryProvider: 'static { - /// Provide a directory to use for [AppDownloader] - fn create_download_dir() -> impl Future> + Send; -} - -struct TempDirProvider; - -impl DirectoryProvider for TempDirProvider { - /// Create a locked-down directory to store downloads in - fn create_download_dir() -> impl Future> + Send { - mullvad_update::dir::admin_temp_dir() - } -} - /// See the [module-level docs](self). pub struct AppController {} @@ -49,7 +33,7 @@ pub fn initialize_controller(delegate: &mut T) { // App downloader to use type Downloader = HttpAppDownloader>; // Directory provider to use - type DirProvider = TempDirProvider; + type DirProvider = crate::temp::TempDirProvider; // Version info provider to use const TEST_PUBKEY: &str = include_str!("../../mullvad-update/test-pubkey"); @@ -218,6 +202,8 @@ async fn handle_action_messages( let mut target_version = TargetVersion::Stable; + let temp_dir = DirProvider::create_download_dir().await; + while let Some(msg) = rx.recv().await { match msg { TaskMessage::SetVersionInfo(new_version_info) => { @@ -288,9 +274,11 @@ async fn handle_action_messages( }); // Create temporary dir - let download_dir = match DirProvider::create_download_dir().await { - Ok(dir) => dir, - Err(_err) => { + let download_dir = match &temp_dir { + Ok(dir) => dir.clone(), + Err(error) => { + log::error!("Failed to create temporary directory: {error:?}"); + queue.queue_main(move |self_| { self_.set_status_text(""); self_.hide_download_button(); @@ -307,6 +295,8 @@ async fn handle_action_messages( } }; + log::debug!("Download directory: {}", download_dir.display()); + // Begin download let (tx, rx) = oneshot::channel(); queue.queue_main(move |self_| { diff --git a/installer-downloader/src/lib.rs b/installer-downloader/src/lib.rs index 2a9896ab4c42..13e47590869a 100644 --- a/installer-downloader/src/lib.rs +++ b/installer-downloader/src/lib.rs @@ -5,4 +5,6 @@ pub mod delegate; #[cfg(any(target_os = "windows", target_os = "macos"))] pub mod resource; #[cfg(any(target_os = "windows", target_os = "macos"))] +pub mod temp; +#[cfg(any(target_os = "windows", target_os = "macos"))] pub mod ui_downloader; diff --git a/installer-downloader/src/temp.rs b/installer-downloader/src/temp.rs new file mode 100644 index 000000000000..95487e10d114 --- /dev/null +++ b/installer-downloader/src/temp.rs @@ -0,0 +1,85 @@ +//! Creates a temporary directory for the installer. +//! +//! # Windows +//! +//! Since the Windows downloader runs as admin, we can use a persistent directory and prevent +//! non-admins from accessing it. +//! +//! # macOS +//! +//! The downloader does not run as a privileged user, so we store downloads in a temporary +//! directory. +//! +//! This is vulnerable to TOCTOU, ie replacing the file after its hash has been verified, but only +//! by the current user. Using a random directory name mitigates this issue. + +use anyhow::Context; +use std::{future::Future, path::PathBuf}; + +/// Provide a directory to use for [AppDownloader] +pub trait DirectoryProvider: 'static { + /// Provide a directory to use for [AppDownloader] + fn create_download_dir() -> impl Future> + Send; +} + +/// See [module-level](self) docs. +pub struct TempDirProvider; + +impl DirectoryProvider for TempDirProvider { + /// Create a locked-down directory to store downloads in + fn create_download_dir() -> impl Future> + Send { + #[cfg(windows)] + { + admin_temp_dir() + } + + #[cfg(target_os = "macos")] + { + temp_dir() + } + } +} + +/// This returns a directory where only admins have write access. +/// +/// This function is a bit racey, as the directory is created before being restricted. +/// This is acceptable as long as the checksum of each file is verified before being used. +#[cfg(windows)] +async fn admin_temp_dir() -> anyhow::Result { + /// Name of subdirectory in the temp directory + const CACHE_DIRNAME: &str = "mullvad-updates"; + + let temp_dir = std::env::temp_dir().join(CACHE_DIRNAME); + + let dir_clone = temp_dir.clone(); + tokio::task::spawn_blocking(move || { + mullvad_paths::windows::create_privileged_directory(&dir_clone) + }) + .await + .unwrap() + .context("Failed to create cache directory")?; + + Ok(temp_dir) +} + +#[cfg(target_os = "macos")] +async fn temp_dir() -> anyhow::Result { + use rand::{distributions::Alphanumeric, Rng}; + use std::{fs::Permissions, os::unix::fs::PermissionsExt}; + use tokio::fs; + + // Randomly generate a directory name + let dir_name: String = (0..10) + .map(|_| rand::thread_rng().sample(Alphanumeric) as char) + .collect(); + let temp_dir = std::env::temp_dir().join(dir_name); + + fs::create_dir_all(&temp_dir) + .await + .context("Failed to create cache directory")?; + fs::set_permissions(&temp_dir, Permissions::from_mode(0o700)) + .await + .context("Failed to set cache directory permissions")?; + + Ok(temp_dir) +} diff --git a/mullvad-update/Cargo.toml b/mullvad-update/Cargo.toml index f601f26ad38b..1a21d7aa1206 100644 --- a/mullvad-update/Cargo.toml +++ b/mullvad-update/Cargo.toml @@ -13,7 +13,7 @@ workspace = true [features] default = [] sign = ["rand", "clap"] -client = ["async-trait", "mullvad-paths", "reqwest", "sha2", "tokio", "thiserror"] +client = ["async-trait", "reqwest", "sha2", "tokio", "thiserror"] native-tls = ["reqwest/native-tls"] [dependencies] @@ -37,9 +37,6 @@ mullvad-version = { path = "../mullvad-version", features = ["serde"] } clap = { workspace = true, optional = true } rand = { version = "0.8.5", optional = true } -[target.'cfg(target_os = "windows")'.dependencies] -mullvad-paths = { path = "../mullvad-paths", optional = true } - [dev-dependencies] async-tempfile = "0.6" insta = { workspace = true } diff --git a/mullvad-update/src/client/dir.rs b/mullvad-update/src/client/dir.rs deleted file mode 100644 index cf3a85eb24bd..000000000000 --- a/mullvad-update/src/client/dir.rs +++ /dev/null @@ -1,42 +0,0 @@ -//! This provides a secure temp directory suitable for storing updates, with admin-only write access. - -use std::path::PathBuf; - -use anyhow::Context; - -/// Name of subdirectory in the temp directory -const CACHE_DIRNAME: &str = "mullvad-updates"; - -/// This returns a directory suitable for storing updates, where only admins have write access. -/// -/// This function is a bit racey, as the directory is created before being restricted. -/// This is acceptable as long as the checksum of each file is verified before being used. -pub async fn admin_temp_dir() -> anyhow::Result { - let temp_dir = std::env::temp_dir().join(CACHE_DIRNAME); - - #[cfg(windows)] - { - let dir_clone = temp_dir.clone(); - tokio::task::spawn_blocking(move || { - mullvad_paths::windows::create_privileged_directory(&dir_clone) - }) - .await - .unwrap() - .context("Failed to create cache directory")?; - } - - #[cfg(unix)] - { - use std::{fs::Permissions, os::unix::fs::PermissionsExt}; - use tokio::fs; - - fs::create_dir_all(&temp_dir) - .await - .context("Failed to create cache directory")?; - fs::set_permissions(&temp_dir, Permissions::from_mode(0o700)) - .await - .context("Failed to set cache directory permissions")?; - } - - Ok(temp_dir) -} diff --git a/mullvad-update/src/client/mod.rs b/mullvad-update/src/client/mod.rs index ac9ca6641c91..4d8a4cc67a13 100644 --- a/mullvad-update/src/client/mod.rs +++ b/mullvad-update/src/client/mod.rs @@ -1,5 +1,4 @@ pub mod api; pub mod app; -pub mod dir; pub mod fetch; pub mod verify; From 8cf0ca78ab4f23cabe255fe15b34f905a0c98e83 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20L=C3=B6nnhager?= Date: Mon, 24 Feb 2025 13:40:18 +0100 Subject: [PATCH 038/112] Update installer-downloader texts --- installer-downloader/src/controller.rs | 8 +++++--- installer-downloader/src/resource.rs | 12 ++++++------ 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/installer-downloader/src/controller.rs b/installer-downloader/src/controller.rs index bf492c09006a..b392ad0942fc 100644 --- a/installer-downloader/src/controller.rs +++ b/installer-downloader/src/controller.rs @@ -286,9 +286,11 @@ async fn handle_action_messages( self_.hide_stable_text(); self_.show_error_message(crate::delegate::ErrorMessage { - status_text: "Failed to create download directory".to_owned(), - cancel_button_text: "Cancel".to_owned(), - retry_button_text: "Try again".to_owned(), + status_text: resource::DOWNLOAD_FAILED_DESC.to_owned(), + cancel_button_text: resource::DOWNLOAD_FAILED_CANCEL_BUTTON_TEXT + .to_owned(), + retry_button_text: resource::DOWNLOAD_FAILED_RETRY_BUTTON_TEXT + .to_owned(), }); }); continue; diff --git a/installer-downloader/src/resource.rs b/installer-downloader/src/resource.rs index a8aa106c82c5..07bfe4161e46 100644 --- a/installer-downloader/src/resource.rs +++ b/installer-downloader/src/resource.rs @@ -38,7 +38,7 @@ pub const FETCH_VERSION_DESC: &str = "Loading version details..."; 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."; +pub const FETCH_VERSION_ERROR_DESC: &str = "Failed to load version details, please try again or make sure you have the latest installer downloader."; /// Displayed while fetching version info from the API failed (retry button) pub const FETCH_VERSION_ERROR_RETRY_BUTTON_TEXT: &str = "Try again"; @@ -53,19 +53,19 @@ pub const DOWNLOADING_DESC_PREFIX: &str = "Downloading from"; pub const DOWNLOAD_COMPLETE_DESC: &str = "Download complete. Verifying..."; /// Displayed when download fails -pub const DOWNLOAD_FAILED_DESC: &str = "Download failed"; +pub const DOWNLOAD_FAILED_DESC: &str = "Download failed, please check your internet connection or if you have enough space on your hard drive and try downloading again."; /// Displayed when download fails (retry button) -pub const DOWNLOAD_FAILED_RETRY_BUTTON_TEXT: &str = "Redownload"; +pub const DOWNLOAD_FAILED_RETRY_BUTTON_TEXT: &str = "Try again"; /// Displayed when download fails (cancel button) pub const DOWNLOAD_FAILED_CANCEL_BUTTON_TEXT: &str = "Cancel"; /// Displayed when download fails -pub const VERIFICATION_FAILED_DESC: &str = "Couldn’t verify download, please try downloading again or contact our support by sending an email at support@mullvadvpn.net"; +pub const VERIFICATION_FAILED_DESC: &str = "Failed to verify download, please try downloading again or contact our support by sending an email to support@mullvadvpn.net with a description of what happened."; /// Displayed when download fails (retry button) -pub const VERIFICATION_FAILED_RETRY_BUTTON_TEXT: &str = "Redownload"; +pub const VERIFICATION_FAILED_RETRY_BUTTON_TEXT: &str = "Try again"; /// Displayed when download fails (cancel button) pub const VERIFICATION_FAILED_CANCEL_BUTTON_TEXT: &str = "Cancel"; @@ -74,7 +74,7 @@ pub const VERIFICATION_FAILED_CANCEL_BUTTON_TEXT: &str = "Cancel"; pub const VERIFICATION_SUCCEEDED_DESC: &str = "Verification successful. Starting install..."; /// Displayed when launch fails -pub const LAUNCH_FAILED_DESC: &str = "Couldn’t launch installer, please try again or contact our support by sending an email at support@mullvadvpn.net"; +pub const LAUNCH_FAILED_DESC: &str = "Failed to start installation, please try downloading again or contact our support by sending an email to support@mullvadvpn.net with a description of what happened."; /// Displayed when launch fails (retry button) pub const LAUNCH_FAILED_RETRY_BUTTON_TEXT: &str = "Try again"; From 0b0204f19e3df35fa09ec295477260a1e3d52f61 Mon Sep 17 00:00:00 2001 From: Joakim Hulthe Date: Mon, 24 Feb 2025 18:16:56 +0100 Subject: [PATCH 039/112] Fix installer text positions on macos --- .../src/cacao_impl/delegate.rs | 21 +++++++- installer-downloader/src/cacao_impl/ui.rs | 49 ++++++++++++++----- 2 files changed, 58 insertions(+), 12 deletions(-) diff --git a/installer-downloader/src/cacao_impl/delegate.rs b/installer-downloader/src/cacao_impl/delegate.rs index 84a80a5f20b3..73ace8d4c65e 100644 --- a/installer-downloader/src/cacao_impl/delegate.rs +++ b/installer-downloader/src/cacao_impl/delegate.rs @@ -1,6 +1,9 @@ use std::sync::{Arc, Mutex}; -use cacao::{control::Control, layout::Layout}; +use cacao::{ + control::Control, + layout::{Layout, LayoutConstraint}, +}; use super::ui::{Action, AppWindow, ErrorView}; use crate::delegate::{AppDelegate, AppDelegateQueue}; @@ -42,6 +45,22 @@ impl AppDelegate for AppWindow { fn set_download_text(&mut self, text: &str) { self.download_text.set_text(text); + + // If there is a download_text, move status_text up to make room + + let offset = if text.is_empty() { 59.0 } else { 39.0 }; + + if let Some(previous_constraint) = self.status_text_position_y.take() { + LayoutConstraint::deactivate(&[previous_constraint]); + } + + let new_constraint = self + .status_text + .top + .constraint_equal_to(&self.main_view.top) + .offset(offset); + self.status_text_position_y = Some(new_constraint.clone()); + LayoutConstraint::activate(&[new_constraint]); } fn show_download_progress(&mut self) { diff --git a/installer-downloader/src/cacao_impl/ui.rs b/installer-downloader/src/cacao_impl/ui.rs index b38dbd02d616..fb2476fe7142 100644 --- a/installer-downloader/src/cacao_impl/ui.rs +++ b/installer-downloader/src/cacao_impl/ui.rs @@ -68,7 +68,7 @@ impl AppDelegate for AppImpl { self.window.show(); let delegate = self.window.delegate.as_ref().unwrap(); - delegate.inner.borrow().layout(); + delegate.inner.borrow_mut().layout(); } fn should_terminate_after_last_window_closed(&self) -> bool { @@ -156,6 +156,9 @@ pub struct AppWindow { pub progress: ProgressIndicator, pub status_text: Label, + /// The y position constraint of [Self::status_text]. + /// This exists because we need to shift it up when download_text is revealed. + pub status_text_position_y: Option, pub error_view: Option, pub error_retry_callback: Option>>, @@ -200,7 +203,7 @@ impl Default for CancelButton { } impl AppWindow { - pub fn layout(&self) { + pub fn layout(&mut self) { self.banner_logo_view.set_image(&LOGO); self.banner_logo_text_view.set_image(&LOGO_TEXT); self.banner.set_background_color(&*BANNER_COLOR); @@ -297,11 +300,22 @@ impl AppWindow { self.beta_link.set_attributed_text(attr_text); self.main_view.add_subview(&self.beta_link); + let status_text_position_y = self.status_text_position_y.get_or_insert_with(|| { + self.status_text + .top + .constraint_equal_to(&self.main_view.top) + .offset(59.) + }); + LayoutConstraint::activate(&[ + status_text_position_y.clone(), + 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.), + .offset(4.), self.download_text .center_x .constraint_equal_to(&self.main_view.center_x), @@ -311,13 +325,19 @@ impl AppWindow { .constraint_equal_to(&self.main_view.center_x), self.download_button .button - .top - .constraint_equal_to(&self.status_text.bottom) - .offset(16.), + .center_y + .constraint_equal_to(&self.main_view.center_y), + self.download_button + .button + .width + .constraint_equal_to_constant(213.), + self.download_button + .button + .height + .constraint_equal_to_constant(22.), self.progress .top - .constraint_equal_to(&self.download_button.button.top) - .offset(32.), + .constraint_equal_to(&self.download_text.bottom), self.progress .left .constraint_equal_to(&self.main_view.left) @@ -326,7 +346,7 @@ impl AppWindow { .right .constraint_equal_to(&self.main_view.right) .offset(-30.), - self.progress.height.constraint_equal_to_constant(16.0f64), + self.progress.height.constraint_equal_to_constant(36.), self.cancel_button .button .center_x @@ -334,8 +354,15 @@ impl AppWindow { self.cancel_button .button .top - .constraint_equal_to(&self.progress.bottom) - .offset(16.), + .constraint_equal_to(&self.progress.bottom), + self.cancel_button + .button + .width + .constraint_equal_to_constant(213.), + self.cancel_button + .button + .height + .constraint_equal_to_constant(22.), self.beta_link_preface .bottom .constraint_equal_to(&self.main_view.bottom) From 940913dee139c63b7d2a130316775556b7318d58 Mon Sep 17 00:00:00 2001 From: Joakim Hulthe Date: Tue, 25 Feb 2025 11:06:08 +0100 Subject: [PATCH 040/112] Add arrow to stable link in installer-downloader --- installer-downloader/src/resource.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/installer-downloader/src/resource.rs b/installer-downloader/src/resource.rs index 07bfe4161e46..1e3206ccbe8c 100644 --- a/installer-downloader/src/resource.rs +++ b/installer-downloader/src/resource.rs @@ -17,7 +17,7 @@ pub const BETA_PREFACE_DESC: &str = "Want to try the new Beta version? "; pub const BETA_LINK_TEXT: &str = "Click here!"; /// Stable link text -pub const STABLE_LINK_TEXT: &str = "Back to stable version"; +pub const STABLE_LINK_TEXT: &str = "← Back to stable version"; /// Dimensions of cancel button (including padding) pub const CANCEL_BUTTON_SIZE: (usize, usize) = (150, 40); From 7bfd655b73de6015ad79581df869b01a04f5b36f Mon Sep 17 00:00:00 2001 From: Joakim Hulthe Date: Tue, 25 Feb 2025 11:07:38 +0100 Subject: [PATCH 041/112] Make beta/stable links look the part (installer-downloader) --- .../src/cacao_impl/delegate.rs | 4 +- installer-downloader/src/cacao_impl/ui.rs | 72 ++++++++++++------- 2 files changed, 47 insertions(+), 29 deletions(-) diff --git a/installer-downloader/src/cacao_impl/delegate.rs b/installer-downloader/src/cacao_impl/delegate.rs index 73ace8d4c65e..a9ff24d10833 100644 --- a/installer-downloader/src/cacao_impl/delegate.rs +++ b/installer-downloader/src/cacao_impl/delegate.rs @@ -133,11 +133,11 @@ impl AppDelegate for AppWindow { } fn show_stable_text(&mut self) { - println!("todo. show stable text"); + self.stable_link.set_hidden(false); } fn hide_stable_text(&mut self) { - println!("todo. hide stable text"); + self.stable_link.set_hidden(true); } fn show_error_message(&mut self, message: installer_downloader::delegate::ErrorMessage) { diff --git a/installer-downloader/src/cacao_impl/ui.rs b/installer-downloader/src/cacao_impl/ui.rs index fb2476fe7142..aa58ff4177bd 100644 --- a/installer-downloader/src/cacao_impl/ui.rs +++ b/installer-downloader/src/cacao_impl/ui.rs @@ -1,4 +1,5 @@ use std::cell::RefCell; +use std::ops::Deref; use std::sync::{Arc, LazyLock, Mutex, RwLock}; use cacao::appkit::window::{Window, WindowConfig, WindowDelegate}; @@ -10,14 +11,14 @@ 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::text::Label; use cacao::view::View; -use installer_downloader::delegate::ErrorMessage; use objc_id::Id; +use crate::delegate::ErrorMessage; use crate::resource::{ BANNER_DESC, BETA_LINK_TEXT, BETA_PREFACE_DESC, CANCEL_BUTTON_TEXT, DOWNLOAD_BUTTON_TEXT, - WINDOW_HEIGHT, WINDOW_TITLE, WINDOW_WIDTH, + STABLE_LINK_TEXT, WINDOW_HEIGHT, WINDOW_TITLE, WINDOW_WIDTH, }; /// Logo render in the banner @@ -167,7 +168,9 @@ pub struct AppWindow { pub download_text: Label, pub beta_link_preface: Label, - pub beta_link: Label, + pub beta_link: LinkToBeta, + + pub stable_link: LinkToStable, } pub struct ErrorView { @@ -180,27 +183,34 @@ pub struct ErrorView { pub type ErrorViewClickCallback = Box; -pub struct DownloadButton { - pub button: Button, -} +/// Create a Button newtype that impls Default +macro_rules! button_wrapper { + ($name:ident, $text:expr) => { + pub struct $name { + pub button: ::cacao::button::Button, + } -impl Default for DownloadButton { - fn default() -> Self { - let button = Button::new(DOWNLOAD_BUTTON_TEXT); - Self { button } - } -} + impl Default for $name { + fn default() -> Self { + Self { + button: Button::new($text), + } + } + } -pub struct CancelButton { - pub button: Button, + impl Deref for $name { + type Target = ::cacao::button::Button; + fn deref(&self) -> &Self::Target { + &self.button + } + } + }; } -impl Default for CancelButton { - fn default() -> Self { - let button = Button::new(CANCEL_BUTTON_TEXT); - Self { button } - } -} +button_wrapper!(LinkToBeta, BETA_LINK_TEXT); +button_wrapper!(LinkToStable, STABLE_LINK_TEXT); +button_wrapper!(DownloadButton, DOWNLOAD_BUTTON_TEXT); +button_wrapper!(CancelButton, CANCEL_BUTTON_TEXT); impl AppWindow { pub fn layout(&mut self) { @@ -294,11 +304,13 @@ impl AppWindow { 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_text_color(Color::Link); + self.beta_link.set_bordered(false); + self.main_view.add_subview(&*self.beta_link); - self.beta_link.set_attributed_text(attr_text); - self.main_view.add_subview(&self.beta_link); + self.stable_link.set_text_color(Color::Link); + self.stable_link.set_bordered(false); + self.main_view.add_subview(&*self.stable_link); let status_text_position_y = self.status_text_position_y.get_or_insert_with(|| { self.status_text @@ -372,11 +384,17 @@ impl AppWindow { .constraint_equal_to(&self.main_view.left) .offset(24.), self.beta_link - .bottom - .constraint_equal_to(&self.beta_link_preface.bottom), + .center_y + .constraint_equal_to(&self.beta_link_preface.center_y), self.beta_link .left .constraint_equal_to(&self.beta_link_preface.right), + self.stable_link + .left + .constraint_equal_to(&self.beta_link_preface.left), + self.stable_link + .center_y + .constraint_equal_to(&self.beta_link_preface.center_y), ]); } } From f7dd7dfc4e69997f77f2448c44bb19be212a549a Mon Sep 17 00:00:00 2001 From: Joakim Hulthe Date: Tue, 25 Feb 2025 11:49:51 +0100 Subject: [PATCH 042/112] Refactor buttons slightly --- .../src/cacao_impl/delegate.rs | 45 +++++++-------- installer-downloader/src/cacao_impl/ui.rs | 55 +++++++++++-------- 2 files changed, 51 insertions(+), 49 deletions(-) diff --git a/installer-downloader/src/cacao_impl/delegate.rs b/installer-downloader/src/cacao_impl/delegate.rs index a9ff24d10833..cf9d20e1d19b 100644 --- a/installer-downloader/src/cacao_impl/delegate.rs +++ b/installer-downloader/src/cacao_impl/delegate.rs @@ -15,28 +15,21 @@ impl AppDelegate for AppWindow { 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::::dispatch_main(cb); - }); + self.download_button.set_callback(callback); } fn on_cancel(&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::::dispatch_main(cb); - }); + self.cancel_button.set_callback(callback); } fn on_beta_link(&mut self, callback: F) where F: Fn() + Send + 'static, { + self.beta_link.set_callback(callback); } fn set_status_text(&mut self, text: &str) { @@ -76,35 +69,35 @@ impl AppDelegate for AppWindow { } fn show_download_button(&mut self) { - self.download_button.button.set_hidden(false); + self.download_button.set_hidden(false); } fn hide_download_button(&mut self) { - self.download_button.button.set_hidden(true); + self.download_button.set_hidden(true); } fn enable_download_button(&mut self) { - self.download_button.button.set_enabled(true); + self.download_button.set_enabled(true); } fn disable_download_button(&mut self) { - self.download_button.button.set_enabled(false); + self.download_button.set_enabled(false); } fn show_cancel_button(&mut self) { - self.cancel_button.button.set_hidden(false); + self.cancel_button.set_hidden(false); } fn hide_cancel_button(&mut self) { - self.cancel_button.button.set_hidden(true); + self.cancel_button.set_hidden(true); } fn enable_cancel_button(&mut self) { - self.cancel_button.button.set_enabled(true); + self.cancel_button.set_enabled(true); } fn disable_cancel_button(&mut self) { - self.cancel_button.button.set_enabled(false); + self.cancel_button.set_enabled(false); } fn show_beta_text(&mut self) { @@ -129,7 +122,7 @@ impl AppDelegate for AppWindow { where F: Fn() + Send + 'static, { - println!("todo. on stable link"); + self.stable_link.set_callback(callback); } fn show_stable_text(&mut self) { @@ -141,17 +134,19 @@ impl AppDelegate for AppWindow { } fn show_error_message(&mut self, message: installer_downloader::delegate::ErrorMessage) { - let on_cancel = self.error_cancel_callback.clone().map(|cb| { + let on_cancel = self.error_cancel_callback.clone().map(|callback| { move || { - let cb = Action::ErrorCancel(cb.clone()); - cacao::appkit::App::::dispatch_main(cb); + let callback = callback.clone(); + let callback = Action::ButtonClick { callback }; + cacao::appkit::App::::dispatch_main(callback); } }); - let on_retry = self.error_retry_callback.clone().map(|cb| { + let on_retry = self.error_retry_callback.clone().map(|callback| { move || { - let cb = Action::ErrorRetry(cb.clone()); - cacao::appkit::App::::dispatch_main(cb); + let callback = callback.clone(); + let callback = Action::ButtonClick { callback }; + cacao::appkit::App::::dispatch_main(callback); } }); diff --git a/installer-downloader/src/cacao_impl/ui.rs b/installer-downloader/src/cacao_impl/ui.rs index aa58ff4177bd..5294eabd0ccb 100644 --- a/installer-downloader/src/cacao_impl/ui.rs +++ b/installer-downloader/src/cacao_impl/ui.rs @@ -1,5 +1,5 @@ use std::cell::RefCell; -use std::ops::Deref; +use std::ops::{Deref, DerefMut}; use std::sync::{Arc, LazyLock, Mutex, RwLock}; use cacao::appkit::window::{Window, WindowConfig, WindowDelegate}; @@ -79,16 +79,13 @@ impl AppDelegate for AppImpl { /// Dispatcher actions pub enum Action { - /// User clicked the download button - DownloadClick(Arc>>), - /// User clicked the cancel button - CancelClick(Arc>>), + /// User clicked a button. + ButtonClick { + /// The callback to be invoked in the main thread. + callback: Arc>>, + }, /// Run callback on main thread QueueMain(Mutex>), - /// User clicked the retry button in the error view - ErrorRetry(Arc>>), - /// User clicked the cancel button in the error view - ErrorCancel(Arc>>), /// Quit the application. Quit, } @@ -102,13 +99,9 @@ impl Dispatcher for AppImpl { 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::ButtonClick { callback } => { + let callback = callback.lock().unwrap(); + callback(); } Action::QueueMain(cb) => { // NOTE: We assume that this won't panic because they will never run simultaneously @@ -116,14 +109,6 @@ impl Dispatcher for AppImpl { let cb = cb.lock().unwrap().take().unwrap(); cb(&mut borrowed); } - Action::ErrorRetry(cb) => { - let cb = cb.lock().unwrap(); - cb(); - } - Action::ErrorCancel(cb) => { - let cb = cb.lock().unwrap(); - cb(); - } Action::Quit => { self.window.close(); } @@ -204,6 +189,28 @@ macro_rules! button_wrapper { &self.button } } + + impl DerefMut for $name { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.button + } + } + + impl $name { + /// Register a callback to be execued on the main thread when this button is pressed. + pub fn set_callback(&mut self, callback: impl Fn() + Send + 'static) { + // Wrap it in an Arc to make it Sync. + // We need this because Dispatcher demands sync, but the AppDelegate trait does not + // impose that requirement on the callback. + let callback = Box::new(callback) as Box; + let callback = Arc::new(Mutex::new(callback)); + self.button.set_action(move || { + let callback = callback.clone(); + let callback = Action::ButtonClick { callback }; + cacao::appkit::App::::dispatch_main(callback); + }); + } + } }; } From 46f2b1f15989d545c65f36871611be6122d1abe9 Mon Sep 17 00:00:00 2001 From: Joakim Hulthe Date: Tue, 25 Feb 2025 12:50:55 +0100 Subject: [PATCH 043/112] Fix stable link arrow --- installer-downloader/src/cacao_impl/ui.rs | 6 +++--- installer-downloader/src/resource.rs | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/installer-downloader/src/cacao_impl/ui.rs b/installer-downloader/src/cacao_impl/ui.rs index 5294eabd0ccb..b8f86e425f34 100644 --- a/installer-downloader/src/cacao_impl/ui.rs +++ b/installer-downloader/src/cacao_impl/ui.rs @@ -178,7 +178,7 @@ macro_rules! button_wrapper { impl Default for $name { fn default() -> Self { Self { - button: Button::new($text), + button: Button::new(&$text), } } } @@ -197,7 +197,7 @@ macro_rules! button_wrapper { } impl $name { - /// Register a callback to be execued on the main thread when this button is pressed. + /// Register a callback to be executed on the main thread when this button is pressed. pub fn set_callback(&mut self, callback: impl Fn() + Send + 'static) { // Wrap it in an Arc to make it Sync. // We need this because Dispatcher demands sync, but the AppDelegate trait does not @@ -215,7 +215,7 @@ macro_rules! button_wrapper { } button_wrapper!(LinkToBeta, BETA_LINK_TEXT); -button_wrapper!(LinkToStable, STABLE_LINK_TEXT); +button_wrapper!(LinkToStable, format!("← {STABLE_LINK_TEXT}")); button_wrapper!(DownloadButton, DOWNLOAD_BUTTON_TEXT); button_wrapper!(CancelButton, CANCEL_BUTTON_TEXT); diff --git a/installer-downloader/src/resource.rs b/installer-downloader/src/resource.rs index 1e3206ccbe8c..07bfe4161e46 100644 --- a/installer-downloader/src/resource.rs +++ b/installer-downloader/src/resource.rs @@ -17,7 +17,7 @@ pub const BETA_PREFACE_DESC: &str = "Want to try the new Beta version? "; pub const BETA_LINK_TEXT: &str = "Click here!"; /// Stable link text -pub const STABLE_LINK_TEXT: &str = "← Back to stable version"; +pub const STABLE_LINK_TEXT: &str = "Back to stable version"; /// Dimensions of cancel button (including padding) pub const CANCEL_BUTTON_SIZE: (usize, usize) = (150, 40); From 04065696aa21baedf4cb9db4410798d1ed8b6b15 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20L=C3=B6nnhager?= Date: Wed, 26 Feb 2025 15:22:02 +0100 Subject: [PATCH 044/112] Use stagemole to retrieve metadata in installer-downloader --- installer-downloader/src/controller.rs | 18 +++++++++++++++--- installer-downloader/tests/controller.rs | 3 ++- mullvad-update/stagemole-pubkey | 1 + 3 files changed, 18 insertions(+), 4 deletions(-) create mode 100644 mullvad-update/stagemole-pubkey diff --git a/installer-downloader/src/controller.rs b/installer-downloader/src/controller.rs index b392ad0942fc..dbbcbcf2aaee 100644 --- a/installer-downloader/src/controller.rs +++ b/installer-downloader/src/controller.rs @@ -36,11 +36,11 @@ pub fn initialize_controller(delegate: &mut T) { type DirProvider = crate::temp::TempDirProvider; // Version info provider to use - const TEST_PUBKEY: &str = include_str!("../../mullvad-update/test-pubkey"); + const STAGEMOLE_PUBKEY: &str = include_str!("../../mullvad-update/stagemole-pubkey"); let verifying_key = - mullvad_update::format::key::VerifyingKey::from_hex(TEST_PUBKEY).expect("valid key"); + mullvad_update::format::key::VerifyingKey::from_hex(STAGEMOLE_PUBKEY).expect("valid key"); let version_provider = HttpVersionInfoProvider { - url: "https://releases.mullvad.net/thing".to_owned(), + url: get_metadata_url(), pinned_certificate: None, verifying_key, }; @@ -48,6 +48,18 @@ pub fn initialize_controller(delegate: &mut T) { AppController::initialize::<_, Downloader, _, DirProvider>(delegate, version_provider) } +/// JSON files should be stored at `/updates-.json`. +fn get_metadata_url() -> String { + const PLATFORM: &str = if cfg!(target_os = "windows") { + "windows" + } else if cfg!(target_os = "macos") { + "macos" + } else { + panic!("Unsupported platform") + }; + format!("https://releases.stagemole.eu/desktop/metadata/updates-{PLATFORM}.json") +} + impl AppController { /// Initialize [AppController] using the provided delegate. /// diff --git a/installer-downloader/tests/controller.rs b/installer-downloader/tests/controller.rs index 0128746a4222..aa74c308b888 100644 --- a/installer-downloader/tests/controller.rs +++ b/installer-downloader/tests/controller.rs @@ -7,8 +7,9 @@ //! changes to, and update, snapshots are by running `cargo insta review`. use insta::assert_yaml_snapshot; -use installer_downloader::controller::{AppController, DirectoryProvider}; +use installer_downloader::controller::AppController; use installer_downloader::delegate::{AppDelegate, AppDelegateQueue, ErrorMessage}; +use installer_downloader::temp::DirectoryProvider; use installer_downloader::ui_downloader::UiAppDownloaderParameters; use mullvad_update::api::VersionInfoProvider; use mullvad_update::app::{AppDownloader, DownloadError}; diff --git a/mullvad-update/stagemole-pubkey b/mullvad-update/stagemole-pubkey new file mode 100644 index 000000000000..256a77bafc63 --- /dev/null +++ b/mullvad-update/stagemole-pubkey @@ -0,0 +1 @@ +a0cd8f582e3147d57f7c01ec0fd306c8315290cea55725c7d5c76f835b78b363 \ No newline at end of file From 1899613b3eae42c82469ff68b5e244186731fee5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20L=C3=B6nnhager?= Date: Wed, 26 Feb 2025 15:33:36 +0100 Subject: [PATCH 045/112] Remove updates- prefix in installer-downloader --- installer-downloader/src/controller.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/installer-downloader/src/controller.rs b/installer-downloader/src/controller.rs index dbbcbcf2aaee..dca4879d1846 100644 --- a/installer-downloader/src/controller.rs +++ b/installer-downloader/src/controller.rs @@ -48,7 +48,7 @@ pub fn initialize_controller(delegate: &mut T) { AppController::initialize::<_, Downloader, _, DirProvider>(delegate, version_provider) } -/// JSON files should be stored at `/updates-.json`. +/// JSON files should be stored at `/.json`. fn get_metadata_url() -> String { const PLATFORM: &str = if cfg!(target_os = "windows") { "windows" @@ -57,7 +57,7 @@ fn get_metadata_url() -> String { } else { panic!("Unsupported platform") }; - format!("https://releases.stagemole.eu/desktop/metadata/updates-{PLATFORM}.json") + format!("https://releases.stagemole.eu/desktop/metadata/{PLATFORM}.json") } impl AppController { From 03eec0ba6f3e15bd16ba70828f4bb28c96ec6b5c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20L=C3=B6nnhager?= Date: Thu, 27 Feb 2025 09:16:47 +0100 Subject: [PATCH 046/112] Update snapshots --- .../snapshots/controller__failed_directory_creation.snap | 3 +-- .../tests/snapshots/controller__failed_verification.snap | 5 ++--- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/installer-downloader/tests/snapshots/controller__failed_directory_creation.snap b/installer-downloader/tests/snapshots/controller__failed_directory_creation.snap index b3f970514902..575b9a4c5034 100644 --- a/installer-downloader/tests/snapshots/controller__failed_directory_creation.snap +++ b/installer-downloader/tests/snapshots/controller__failed_directory_creation.snap @@ -1,7 +1,6 @@ --- source: installer-downloader/tests/controller.rs expression: delegate.state -snapshot_kind: text --- status_text: "" download_text: "" @@ -15,7 +14,7 @@ beta_text_visible: false stable_text_visible: false error_message_visible: true error_message: - status_text: Failed to create download directory + status_text: "Download failed, please check your internet connection or if you have enough space on your hard drive and try downloading again." cancel_button_text: Cancel retry_button_text: Try again quit: false diff --git a/installer-downloader/tests/snapshots/controller__failed_verification.snap b/installer-downloader/tests/snapshots/controller__failed_verification.snap index 3bb0a7e130b8..ed6177dfea55 100644 --- a/installer-downloader/tests/snapshots/controller__failed_verification.snap +++ b/installer-downloader/tests/snapshots/controller__failed_verification.snap @@ -1,7 +1,6 @@ --- source: installer-downloader/tests/controller.rs expression: delegate.state -snapshot_kind: text --- status_text: "" download_text: "" @@ -15,9 +14,9 @@ beta_text_visible: false stable_text_visible: false error_message_visible: true error_message: - status_text: "Couldn’t verify download, please try downloading again or contact our support by sending an email at support@mullvadvpn.net" + status_text: "Failed to verify download, please try downloading again or contact our support by sending an email to support@mullvadvpn.net with a description of what happened." cancel_button_text: Cancel - retry_button_text: Redownload + retry_button_text: Try again quit: false call_log: - hide_download_progress From 81939bb9b2ae030c33a37590ec23a5e3f5c3dcee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20L=C3=B6nnhager?= Date: Thu, 27 Feb 2025 09:53:22 +0100 Subject: [PATCH 047/112] Add file logging --- Cargo.lock | 3 ++- installer-downloader/Cargo.toml | 5 +++-- installer-downloader/src/lib.rs | 2 ++ installer-downloader/src/log.rs | 26 ++++++++++++++++++++++++++ installer-downloader/src/main.rs | 3 ++- 5 files changed, 35 insertions(+), 4 deletions(-) create mode 100644 installer-downloader/src/log.rs diff --git a/Cargo.lock b/Cargo.lock index 73019fabbaa0..f12769e2f109 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2038,7 +2038,8 @@ dependencies = [ "anyhow", "async-trait", "cacao", - "env_logger 0.10.2", + "chrono", + "fern", "insta", "log", "mullvad-paths", diff --git a/installer-downloader/Cargo.toml b/installer-downloader/Cargo.toml index a8d54d2a14f9..7b04f3447400 100644 --- a/installer-downloader/Cargo.toml +++ b/installer-downloader/Cargo.toml @@ -22,8 +22,9 @@ async-trait = "0.1" rand = { version = "0.8.5" } serde = { workspace = true, features = ["derive"] } -# Note: Not using workspace since we want fewer features -env_logger = { version = "0.10.0", default-features = false } + +chrono = { workspace = true, features = ["clock"] } +fern = { version = "0.6", default-features = false } log = { workspace = true } mullvad-update = { path = "../mullvad-update", features = ["client", "native-tls"] } diff --git a/installer-downloader/src/lib.rs b/installer-downloader/src/lib.rs index 13e47590869a..bde7683cdcc6 100644 --- a/installer-downloader/src/lib.rs +++ b/installer-downloader/src/lib.rs @@ -3,6 +3,8 @@ pub mod controller; #[cfg(any(target_os = "windows", target_os = "macos"))] pub mod delegate; #[cfg(any(target_os = "windows", target_os = "macos"))] +pub mod log; +#[cfg(any(target_os = "windows", target_os = "macos"))] pub mod resource; #[cfg(any(target_os = "windows", target_os = "macos"))] pub mod temp; diff --git a/installer-downloader/src/log.rs b/installer-downloader/src/log.rs new file mode 100644 index 000000000000..93ff53870066 --- /dev/null +++ b/installer-downloader/src/log.rs @@ -0,0 +1,26 @@ +use chrono::Local; +use fern::Dispatch; +use log::LevelFilter; +use std::{io, path::PathBuf}; + +pub fn init() -> Result<(), fern::InitError> { + Dispatch::new() + .format(|out, message, record| { + out.finish(format_args!( + "{} [{}] {}", + Local::now().format("%Y-%m-%d %H:%M:%S"), + record.level(), + message + )) + }) + .level(LevelFilter::Debug) + .chain(io::stdout()) + .chain(fern::log_file(log_path())?) + .apply()?; + + Ok(()) +} + +fn log_path() -> PathBuf { + std::env::temp_dir().join("mullvad-downloader.log") +} diff --git a/installer-downloader/src/main.rs b/installer-downloader/src/main.rs index f190377d285b..7be9b76b2cd9 100644 --- a/installer-downloader/src/main.rs +++ b/installer-downloader/src/main.rs @@ -10,10 +10,11 @@ mod cacao_impl; mod inner { pub use installer_downloader::controller; pub use installer_downloader::delegate; + pub use installer_downloader::log; pub use installer_downloader::resource; pub fn run() { - env_logger::init(); + log::init().expect("failed to set up logger"); let rt = tokio::runtime::Builder::new_multi_thread() .enable_all() From e90ea8ffe6a5a247f396f0418683a66d1c7abbc4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20L=C3=B6nnhager?= Date: Thu, 27 Feb 2025 10:13:13 +0100 Subject: [PATCH 048/112] Switch to rustls for installer-downloader This also increases the file size limit with around 1M --- .github/workflows/downloader.yml | 4 +- Cargo.lock | 128 ------------------------------- installer-downloader/Cargo.toml | 2 +- mullvad-update/Cargo.toml | 3 +- 4 files changed, 4 insertions(+), 133 deletions(-) diff --git a/.github/workflows/downloader.yml b/.github/workflows/downloader.yml index 9b25aa783b75..c8c90716aefe 100644 --- a/.github/workflows/downloader.yml +++ b/.github/workflows/downloader.yml @@ -38,7 +38,7 @@ jobs: env: # If the file is larger than this, a regression has probably been introduced. # You should think twice before increasing this limit. - MAX_BINARY_SIZE: 1572864 + MAX_BINARY_SIZE: 2621440 steps: - name: Checkout repository uses: actions/checkout@v4 @@ -63,7 +63,7 @@ jobs: env: # If the file is larger than this, a regression has probably been introduced. # You should think twice before increasing this limit. - MAX_BINARY_SIZE: 2097152 + MAX_BINARY_SIZE: 3145728 steps: - name: Checkout repository uses: actions/checkout@v4 diff --git a/Cargo.lock b/Cargo.lock index f12769e2f109..0df261a8e7f8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1752,22 +1752,6 @@ dependencies = [ "tower-service", ] -[[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" @@ -2916,23 +2900,6 @@ version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "defc4c55412d89136f966bbb339008b474350e5e6e78d2714439c386b3137a03" -[[package]] -name = "native-tls" -version = "0.2.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0dab59f8e050d5df8e4dd87d9206fb6f65a483e20ac9fda365ade4fab353196c" -dependencies = [ - "libc", - "log", - "openssl", - "openssl-probe", - "openssl-sys", - "schannel", - "security-framework", - "security-framework-sys", - "tempfile", -] - [[package]] name = "native-windows-gui" version = "1.0.13" @@ -3306,50 +3273,6 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" -[[package]] -name = "openssl" -version = "0.10.71" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e14130c6a98cd258fdcb0fb6d744152343ff729cbfcb28c656a9d12b999fbcd" -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", - "quote", - "syn 2.0.89", -] - -[[package]] -name = "openssl-probe" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" - -[[package]] -name = "openssl-sys" -version = "0.9.106" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8bb61ea9811cc39e3c2069f40b8b8e2e70d8569b361f879786cc7ed48b777cdd" -dependencies = [ - "cc", - "libc", - "pkg-config", - "vcpkg", -] - [[package]] name = "openvpn-plugin" version = "0.4.2" @@ -4138,13 +4061,11 @@ dependencies = [ "http-body-util", "hyper", "hyper-rustls", - "hyper-tls", "hyper-util", "ipnet", "js-sys", "log", "mime", - "native-tls", "once_cell", "percent-encoding", "pin-project-lite", @@ -4157,7 +4078,6 @@ dependencies = [ "serde_urlencoded", "sync_wrapper 1.0.1", "tokio", - "tokio-native-tls", "tokio-rustls 0.26.0", "tower-service", "url", @@ -4382,15 +4302,6 @@ dependencies = [ "winapi-util", ] -[[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 = "scopeguard" version = "1.2.0" @@ -4420,29 +4331,6 @@ dependencies = [ "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" @@ -5305,16 +5193,6 @@ dependencies = [ "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" @@ -5712,12 +5590,6 @@ dependencies = [ "serde", ] -[[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" diff --git a/installer-downloader/Cargo.toml b/installer-downloader/Cargo.toml index 7b04f3447400..7eaed2808263 100644 --- a/installer-downloader/Cargo.toml +++ b/installer-downloader/Cargo.toml @@ -27,7 +27,7 @@ chrono = { workspace = true, features = ["clock"] } fern = { version = "0.6", default-features = false } log = { workspace = true } -mullvad-update = { path = "../mullvad-update", features = ["client", "native-tls"] } +mullvad-update = { path = "../mullvad-update", features = ["client"] } [target.'cfg(target_os = "windows")'.dependencies] windows-sys = { workspace = true, features = ["Win32_UI", "Win32_UI_WindowsAndMessaging", "Win32_Graphics", "Win32_Graphics_Gdi"] } diff --git a/mullvad-update/Cargo.toml b/mullvad-update/Cargo.toml index 1a21d7aa1206..12434e3545bc 100644 --- a/mullvad-update/Cargo.toml +++ b/mullvad-update/Cargo.toml @@ -14,7 +14,6 @@ workspace = true default = [] sign = ["rand", "clap"] client = ["async-trait", "reqwest", "sha2", "tokio", "thiserror"] -native-tls = ["reqwest/native-tls"] [dependencies] anyhow = { workspace = true } @@ -26,7 +25,7 @@ serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } async-trait = { version = "0.1", optional = true } -reqwest = { version = "0.12.9", default-features = false, optional = true } +reqwest = { version = "0.12.9", default-features = false, features = ["rustls-tls"], optional = true } sha2 = { version = "0.10", optional = true } tokio = { workspace = true, features = ["rt-multi-thread", "fs", "process"], optional = true } thiserror = { workspace = true, optional = true } From 364ff7b776e3029e99fc1eee8f64429913fd276c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20L=C3=B6nnhager?= Date: Thu, 27 Feb 2025 10:19:39 +0100 Subject: [PATCH 049/112] Force TLS 1.3 in mullvad-update --- mullvad-update/src/client/api.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/mullvad-update/src/client/api.rs b/mullvad-update/src/client/api.rs index 0146f19d336f..90bb3e57459b 100644 --- a/mullvad-update/src/client/api.rs +++ b/mullvad-update/src/client/api.rs @@ -46,6 +46,7 @@ impl HttpVersionInfoProvider { pinned_certificate: Option, ) -> anyhow::Result> { let mut req_builder = reqwest::Client::builder(); + req_builder = req_builder.min_tls_version(reqwest::tls::Version::TLS_1_3); if let Some(pinned_certificate) = pinned_certificate { req_builder = req_builder From 751bcc6de02207ba80d47500b022061faf545cc0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20L=C3=B6nnhager?= Date: Thu, 27 Feb 2025 10:20:02 +0100 Subject: [PATCH 050/112] Pin installer-downloader to LE root for version metadata --- Cargo.lock | 1 + installer-downloader/Cargo.toml | 1 + installer-downloader/src/controller.rs | 5 ++++- 3 files changed, 6 insertions(+), 1 deletion(-) diff --git a/Cargo.lock b/Cargo.lock index 0df261a8e7f8..d2eb110f8f37 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2031,6 +2031,7 @@ dependencies = [ "native-windows-gui", "objc_id", "rand 0.8.5", + "reqwest", "serde", "tokio", "windows-sys 0.52.0", diff --git a/installer-downloader/Cargo.toml b/installer-downloader/Cargo.toml index 7eaed2808263..80b7450b7c46 100644 --- a/installer-downloader/Cargo.toml +++ b/installer-downloader/Cargo.toml @@ -20,6 +20,7 @@ anyhow = { workspace = true } tokio = { workspace = true, features = ["rt-multi-thread", "fs"] } async-trait = "0.1" rand = { version = "0.8.5" } +reqwest = { version = "0.12.9", default-features = false, features = ["rustls-tls"] } serde = { workspace = true, features = ["derive"] } diff --git a/installer-downloader/src/controller.rs b/installer-downloader/src/controller.rs index dca4879d1846..2f8f228d831a 100644 --- a/installer-downloader/src/controller.rs +++ b/installer-downloader/src/controller.rs @@ -14,6 +14,8 @@ use rand::seq::SliceRandom; use tokio::sync::{mpsc, oneshot}; +const PINNED_CERTIFICATE: &[u8] = include_bytes!("../../mullvad-api/le_root_cert.pem"); + /// Actions handled by an async worker task in [handle_action_messages]. enum TaskMessage { SetVersionInfo(VersionInfo), @@ -39,9 +41,10 @@ pub fn initialize_controller(delegate: &mut T) { const STAGEMOLE_PUBKEY: &str = include_str!("../../mullvad-update/stagemole-pubkey"); let verifying_key = mullvad_update::format::key::VerifyingKey::from_hex(STAGEMOLE_PUBKEY).expect("valid key"); + let cert = reqwest::Certificate::from_pem(PINNED_CERTIFICATE).expect("invalid cert"); let version_provider = HttpVersionInfoProvider { url: get_metadata_url(), - pinned_certificate: None, + pinned_certificate: Some(cert), verifying_key, }; From c3c0a0625b90783b96b769de916232bcdba3cf14 Mon Sep 17 00:00:00 2001 From: Markus Pettersson Date: Fri, 21 Feb 2025 11:58:46 +0100 Subject: [PATCH 051/112] Detect native CPU arch --- Cargo.lock | 1 + installer-downloader/Cargo.toml | 1 + installer-downloader/src/controller.rs | 15 ++++++- talpid-platform-metadata/Cargo.toml | 1 + talpid-platform-metadata/src/arch.rs | 58 ++++++++++++++++++++++++++ talpid-platform-metadata/src/lib.rs | 4 ++ 6 files changed, 78 insertions(+), 2 deletions(-) create mode 100644 talpid-platform-metadata/src/arch.rs diff --git a/Cargo.lock b/Cargo.lock index d2eb110f8f37..4205eb9adc10 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2033,6 +2033,7 @@ dependencies = [ "rand 0.8.5", "reqwest", "serde", + "talpid-platform-metadata", "tokio", "windows-sys 0.52.0", "winres", diff --git a/installer-downloader/Cargo.toml b/installer-downloader/Cargo.toml index 80b7450b7c46..a468abb8cbc4 100644 --- a/installer-downloader/Cargo.toml +++ b/installer-downloader/Cargo.toml @@ -28,6 +28,7 @@ chrono = { workspace = true, features = ["clock"] } fern = { version = "0.6", default-features = false } log = { workspace = true } +talpid-platform-metadata = { path = "../talpid-platform-metadata" } mullvad-update = { path = "../mullvad-update", features = ["client"] } [target.'cfg(target_os = "windows")'.dependencies] diff --git a/installer-downloader/src/controller.rs b/installer-downloader/src/controller.rs index 2f8f228d831a..ff02b3cd0cad 100644 --- a/installer-downloader/src/controller.rs +++ b/installer-downloader/src/controller.rs @@ -129,10 +129,12 @@ async fn fetch_app_version_info( Delegate: AppDelegate + 'static, VersionProvider: VersionInfoProvider + Send, { + // TODO: Do not unwrap + // TODO: Construct a proper error instead + let architecture = get_arch().unwrap().unwrap(); loop { let version_params = VersionParameters { - // TODO: detect current architecture - architecture: VersionArchitecture::X86, + architecture, // For the downloader, the rollout version is always preferred rollout: 1., // The downloader allows any version @@ -410,3 +412,12 @@ fn select_cdn_url(urls: &[String]) -> Option<&str> { fn format_latest_version(version: &Version) -> String { format!("{}: {}", resource::LATEST_VERSION_PREFIX, version.version) } + +/// Try to map the host's CPU architecture to one of the CPU architectures the Mullvad VPN app +/// supports. +fn get_arch() -> Result, std::io::Error> { + match talpid_platform_metadata::get_native_arch()?? { + talpid_platform_metadata::Architecture::X86 => VersionArchitecture::X86, + talpid_platform_metadata::Architecture::Arm64 => VersionArchitecture::Arm64, + } +} diff --git a/talpid-platform-metadata/Cargo.toml b/talpid-platform-metadata/Cargo.toml index d82479c0c4ca..9eb58f4a5557 100644 --- a/talpid-platform-metadata/Cargo.toml +++ b/talpid-platform-metadata/Cargo.toml @@ -25,4 +25,5 @@ features = [ "Win32_System_LibraryLoader", "Win32_System_SystemInformation", "Win32_System_SystemServices", + "Win32_System_Threading", ] diff --git a/talpid-platform-metadata/src/arch.rs b/talpid-platform-metadata/src/arch.rs new file mode 100644 index 000000000000..1f6de901cdaa --- /dev/null +++ b/talpid-platform-metadata/src/arch.rs @@ -0,0 +1,58 @@ +//! Detect the running platform's CPU architecture. + +/// CPU architectures supported by the talpid family of crates. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Architecture { + /// x86-64 architecture + X86, + /// ARM64 architecture + Arm64, +} + +/// Return native architecture (ignoring WOW64). If the native architecture can not be detected, +/// [`None`] is returned. This should never be the case on working X86_64 or Arm64 systems. +#[cfg(target_os = "windows")] +pub fn get_native_arch() -> Result, std::io::Error> { + use core::ffi::c_ushort; + use windows_sys::Win32::System::SystemInformation::{ + IMAGE_FILE_MACHINE_AMD64, IMAGE_FILE_MACHINE_ARM64, + }; + use windows_sys::Win32::System::Threading::{GetCurrentProcess, IsWow64Process2}; + + let native_arch = { + let mut running_arch: c_ushort = 0; + let mut native_arch: c_ushort = 0; + + // SAFETY: Trivially safe. The current process handle is a glorified constant. + let current_process = unsafe { GetCurrentProcess() }; + + // IsWow64Process2: + // Determines whether the specified process is running under WOW64; also returns additional machine process and architecture information. + // + // SAFETY: Trivially safe, since we provide the required arguments. + if 0 == unsafe { IsWow64Process2(current_process, &mut running_arch, &mut native_arch) } { + return Err(std::io::Error::last_os_error()); + } + + native_arch + }; + + match native_arch { + IMAGE_FILE_MACHINE_AMD64 => Ok(Some(Architecture::X86)), + IMAGE_FILE_MACHINE_ARM64 => Ok(Some(Architecture::Arm64)), + _other => Ok(None), + } +} + +/// Return native architecture. +#[cfg(not(target_os = "windows"))] +pub fn get_native_arch() -> Result, std::io::Error> { + const TARGET_ARCH: Option = if cfg!(any(target_arch = "x86_64",)) { + Some(Architecture::X86) + } else if cfg!(target_arch = "aarch64") { + Some(Architecture::Arm64) + } else { + None + }; + Ok(TARGET_ARCH) +} diff --git a/talpid-platform-metadata/src/lib.rs b/talpid-platform-metadata/src/lib.rs index 7a11c97f1849..98640fabef75 100644 --- a/talpid-platform-metadata/src/lib.rs +++ b/talpid-platform-metadata/src/lib.rs @@ -1,3 +1,4 @@ +mod arch; #[cfg(target_os = "linux")] #[path = "linux.rs"] mod imp; @@ -19,3 +20,6 @@ pub use self::imp::MacosVersion; #[cfg(windows)] pub use self::imp::WindowsVersion; pub use self::imp::{extra_metadata, short_version, version}; + +pub use arch::get_native_arch; +pub use arch::Architecture; From aefca5230e86aaf03fc3b1daa1c72f90f32fb693 Mon Sep 17 00:00:00 2001 From: Markus Pettersson Date: Fri, 21 Feb 2025 14:52:47 +0100 Subject: [PATCH 052/112] Remove explicit `unsafe` from `windows-installer` --- Cargo.lock | 1 + windows-installer/Cargo.toml | 5 +++-- windows-installer/src/windows.rs | 32 ++++++++++---------------------- 3 files changed, 14 insertions(+), 24 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 4205eb9adc10..645c02fac46c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5886,6 +5886,7 @@ version = "0.0.0" dependencies = [ "anyhow", "mullvad-version", + "talpid-platform-metadata", "tempfile", "windows-sys 0.52.0", "winres", diff --git a/windows-installer/Cargo.toml b/windows-installer/Cargo.toml index 518fe2d82d53..f50f0326261a 100644 --- a/windows-installer/Cargo.toml +++ b/windows-installer/Cargo.toml @@ -11,9 +11,10 @@ rust-version.workspace = true workspace = true [target.'cfg(all(target_os = "windows", target_arch = "x86_64"))'.dependencies] -windows-sys = { version = "0.52.0", features = ["Win32_System", "Win32_System_LibraryLoader", "Win32_System_SystemInformation", "Win32_System_Threading"] } -tempfile = "3.10" anyhow.workspace = true +talpid-platform-metadata = { path = "../talpid-platform-metadata" } +tempfile = "3.10" +windows-sys = { version = "0.52.0", features = ["Win32_System", "Win32_System_LibraryLoader", "Win32_System_SystemInformation", "Win32_System_Threading"] } [build-dependencies] winres = "0.1" diff --git a/windows-installer/src/windows.rs b/windows-installer/src/windows.rs index 1b74b074b3c9..07e4587728a2 100644 --- a/windows-installer/src/windows.rs +++ b/windows-installer/src/windows.rs @@ -7,7 +7,7 @@ //! * `WIN_ARM64_INSTALLER` - a path to the ARM64 Windows installer use anyhow::{bail, Context}; use std::{ - ffi::{c_ushort, OsStr}, + ffi::OsStr, io::{self, Write}, num::NonZero, process::{Command, ExitStatus}, @@ -16,11 +16,7 @@ use std::{ use tempfile::TempPath; use windows_sys::{ w, - Win32::System::{ - LibraryLoader::{FindResourceW, LoadResource, LockResource, SizeofResource}, - SystemInformation::{IMAGE_FILE_MACHINE_AMD64, IMAGE_FILE_MACHINE_ARM64}, - Threading::IsWow64Process2, - }, + Win32::System::LibraryLoader::{FindResourceW, LoadResource, LockResource, SizeofResource}, }; /// Import resource constants from `resource.rs`. This is automatically generated by the build @@ -124,22 +120,14 @@ enum Architecture { /// Return native architecture (ignoring WOW64) fn get_native_arch() -> anyhow::Result { - let mut running_arch: c_ushort = 0; - let mut native_arch: c_ushort = 0; - - // SAFETY: Trivially safe, since we provide the required arguments. `hprocess == 0` is - // undocumented but refers to the current process. - let result = unsafe { IsWow64Process2(0, &mut running_arch, &mut native_arch) }; - if result == 0 { - bail!( - "Failed to get native architecture: {}", - io::Error::last_os_error() - ); - } + let Some(arch) = + talpid_platform_metadata::get_native_arch().context("Failed to get native architecture")? + else { + bail!("Unable to detect native architecture (most likely unsupported)"); + }; - match native_arch { - IMAGE_FILE_MACHINE_AMD64 => Ok(Architecture::X64), - IMAGE_FILE_MACHINE_ARM64 => Ok(Architecture::Arm64), - other => bail!("unsupported architecture: {other}"), + match arch { + talpid_platform_metadata::Architecture::X86 => Ok(Architecture::X64), + talpid_platform_metadata::Architecture::Arm64 => Ok(Architecture::Arm64), } } From 5605ba9037597aeb8577c8a068e19ca1854bb997 Mon Sep 17 00:00:00 2001 From: Markus Pettersson Date: Mon, 24 Feb 2025 10:22:34 +0100 Subject: [PATCH 053/112] Push error handling of `get_arch` outwards --- installer-downloader/src/cacao_impl/mod.rs | 11 ++++++- installer-downloader/src/controller.rs | 32 +++++++++--------- installer-downloader/src/environment.rs | 36 +++++++++++++++++++++ installer-downloader/src/lib.rs | 2 ++ installer-downloader/src/winapi_impl/mod.rs | 16 ++++++++- 5 files changed, 78 insertions(+), 19 deletions(-) create mode 100644 installer-downloader/src/environment.rs diff --git a/installer-downloader/src/cacao_impl/mod.rs b/installer-downloader/src/cacao_impl/mod.rs index e2974d370eeb..3b607c3295db 100644 --- a/installer-downloader/src/cacao_impl/mod.rs +++ b/installer-downloader/src/cacao_impl/mod.rs @@ -1,6 +1,7 @@ use std::sync::Mutex; use cacao::appkit::App; +use installer_downloader::environment::{Environment, Error as EnvError}; use ui::{Action, AppImpl}; mod delegate; @@ -9,8 +10,16 @@ mod ui; pub fn main() { let app = App::new("net.mullvad.downloader", AppImpl::default()); + // Load "global" values and resources + let environment = match Environment::load() { + Ok(env) => env, + Err(EnvError::Arch) => { + unreachable!("The CPU architecture will always be retrievable on macOS") + } + }; + let cb: Mutex> = Mutex::new(Some(Box::new(|self_| { - crate::controller::initialize_controller(self_); + crate::controller::initialize_controller(self_, environment); }))); cacao::appkit::App::::dispatch_main(Action::QueueMain(cb)); diff --git a/installer-downloader/src/controller.rs b/installer-downloader/src/controller.rs index ff02b3cd0cad..ebed872755ac 100644 --- a/installer-downloader/src/controller.rs +++ b/installer-downloader/src/controller.rs @@ -1,6 +1,7 @@ //! This module implements the actual logic performed by different UI components. use crate::delegate::{AppDelegate, AppDelegateQueue}; +use crate::environment::Environment; use crate::resource; use crate::temp::DirectoryProvider; use crate::ui_downloader::{UiAppDownloader, UiAppDownloaderParameters, UiProgressUpdater}; @@ -8,7 +9,7 @@ use crate::ui_downloader::{UiAppDownloader, UiAppDownloaderParameters, UiProgres use mullvad_update::{ api::VersionInfoProvider, app::{self, AppDownloader}, - version::{Version, VersionArchitecture, VersionInfo, VersionParameters}, + version::{Version, VersionInfo, VersionParameters}, }; use rand::seq::SliceRandom; @@ -29,7 +30,7 @@ enum TaskMessage { pub struct AppController {} /// Public entry function for registering a [AppDelegate]. -pub fn initialize_controller(delegate: &mut T) { +pub fn initialize_controller(delegate: &mut T, environment: Environment) { use mullvad_update::{api::HttpVersionInfoProvider, app::HttpAppDownloader}; // App downloader to use @@ -48,7 +49,11 @@ pub fn initialize_controller(delegate: &mut T) { verifying_key, }; - AppController::initialize::<_, Downloader, _, DirProvider>(delegate, version_provider) + AppController::initialize::<_, Downloader, _, DirProvider>( + delegate, + version_provider, + environment, + ) } /// JSON files should be stored at `/.json`. @@ -68,8 +73,11 @@ impl AppController { /// /// 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: &mut D, version_provider: V) - where + pub fn initialize( + delegate: &mut D, + version_provider: V, + environment: Environment, + ) where D: AppDelegate + 'static, V: VersionInfoProvider + Send + 'static, A: From> + AppDownloader + 'static, @@ -93,6 +101,7 @@ impl AppController { delegate.queue(), task_tx.clone(), version_provider, + environment, )); Self::register_user_action_callbacks(delegate, task_tx); } @@ -125,13 +134,11 @@ async fn fetch_app_version_info( queue: Delegate::Queue, download_tx: mpsc::Sender, version_provider: VersionProvider, + Environment { architecture }: Environment, ) where Delegate: AppDelegate + 'static, VersionProvider: VersionInfoProvider + Send, { - // TODO: Do not unwrap - // TODO: Construct a proper error instead - let architecture = get_arch().unwrap().unwrap(); loop { let version_params = VersionParameters { architecture, @@ -412,12 +419,3 @@ fn select_cdn_url(urls: &[String]) -> Option<&str> { fn format_latest_version(version: &Version) -> String { format!("{}: {}", resource::LATEST_VERSION_PREFIX, version.version) } - -/// Try to map the host's CPU architecture to one of the CPU architectures the Mullvad VPN app -/// supports. -fn get_arch() -> Result, std::io::Error> { - match talpid_platform_metadata::get_native_arch()?? { - talpid_platform_metadata::Architecture::X86 => VersionArchitecture::X86, - talpid_platform_metadata::Architecture::Arm64 => VersionArchitecture::Arm64, - } -} diff --git a/installer-downloader/src/environment.rs b/installer-downloader/src/environment.rs new file mode 100644 index 000000000000..4b1757591289 --- /dev/null +++ b/installer-downloader/src/environment.rs @@ -0,0 +1,36 @@ +use mullvad_update::version::VersionArchitecture; + +/// The environment consists of globals and/or constants which need to be computed at runtime. +pub struct Environment { + pub architecture: mullvad_update::format::Architecture, +} + +pub enum Error { + /// Failed to get the host's CPU architecture. + Arch, +} + +impl Environment { + /// Try to load the environment. + pub fn load() -> Result { + let architecture = Self::get_arch()?; + + Ok(Environment { architecture }) + } + + /// Try to map the host's CPU architecture to one of the CPU architectures the Mullvad VPN app + /// supports. + fn get_arch() -> Result { + let arch = talpid_platform_metadata::get_native_arch() + .inspect_err(|err| log::debug!("{err}")) + .map_err(|_| Error::Arch)? + .ok_or(Error::Arch)?; + + let arch = match arch { + talpid_platform_metadata::Architecture::X86 => VersionArchitecture::X86, + talpid_platform_metadata::Architecture::Arm64 => VersionArchitecture::Arm64, + }; + + Ok(arch) + } +} diff --git a/installer-downloader/src/lib.rs b/installer-downloader/src/lib.rs index bde7683cdcc6..a07d557d670d 100644 --- a/installer-downloader/src/lib.rs +++ b/installer-downloader/src/lib.rs @@ -3,6 +3,8 @@ pub mod controller; #[cfg(any(target_os = "windows", target_os = "macos"))] pub mod delegate; #[cfg(any(target_os = "windows", target_os = "macos"))] +pub mod environment; +#[cfg(any(target_os = "windows", target_os = "macos"))] pub mod log; #[cfg(any(target_os = "windows", target_os = "macos"))] pub mod resource; diff --git a/installer-downloader/src/winapi_impl/mod.rs b/installer-downloader/src/winapi_impl/mod.rs index 15a9957c00fd..fe755009da0e 100644 --- a/installer-downloader/src/winapi_impl/mod.rs +++ b/installer-downloader/src/winapi_impl/mod.rs @@ -1,3 +1,4 @@ +use installer_downloader::environment::{Environment, Error as EnvError}; use native_windows_gui as nwg; use crate::delegate::{AppDelegate, AppDelegateQueue}; @@ -9,14 +10,27 @@ 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"); + // Load "global" values and resources + let environment = match Environment::load() { + Ok(env) => env, + Err(error) => fatal_environment_error(error), + }; + let window = ui::AppWindow::default(); let window = window.layout().unwrap(); let queue = window.borrow().queue(); queue.queue_main(|window| { - crate::controller::initialize_controller(window); + crate::controller::initialize_controller(window, environment); }); nwg::dispatch_thread_events(); } + +fn fatal_environment_error(error: EnvError) -> ! { + let content = match error { + EnvError::Arch => "Failed to detect CPU architecture", + }; + nwg::fatal_message(installer_downloader::resource::WINDOW_TITLE, content) +} From f891e9b9e9f0df27c237d7577e85c4e18fb3b19f Mon Sep 17 00:00:00 2001 From: Markus Pettersson Date: Thu, 27 Feb 2025 12:57:03 +0100 Subject: [PATCH 054/112] Fix tests --- installer-downloader/src/environment.rs | 4 +++- installer-downloader/tests/controller.rs | 9 +++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/installer-downloader/src/environment.rs b/installer-downloader/src/environment.rs index 4b1757591289..f2bb691d5a0f 100644 --- a/installer-downloader/src/environment.rs +++ b/installer-downloader/src/environment.rs @@ -2,9 +2,11 @@ use mullvad_update::version::VersionArchitecture; /// The environment consists of globals and/or constants which need to be computed at runtime. pub struct Environment { - pub architecture: mullvad_update::format::Architecture, + pub architecture: Architecture, } +pub type Architecture = mullvad_update::format::Architecture; + pub enum Error { /// Failed to get the host's CPU architecture. Arch, diff --git a/installer-downloader/tests/controller.rs b/installer-downloader/tests/controller.rs index aa74c308b888..3c7c56a3c1e9 100644 --- a/installer-downloader/tests/controller.rs +++ b/installer-downloader/tests/controller.rs @@ -9,6 +9,7 @@ use insta::assert_yaml_snapshot; use installer_downloader::controller::AppController; use installer_downloader::delegate::{AppDelegate, AppDelegateQueue, ErrorMessage}; +use installer_downloader::environment::{Architecture, Environment}; use installer_downloader::temp::DirectoryProvider; use installer_downloader::ui_downloader::UiAppDownloaderParameters; use mullvad_update::api::VersionInfoProvider; @@ -34,6 +35,10 @@ static FAKE_VERSION: LazyLock = LazyLock::new(|| VersionInfo { beta: None, }); +const FAKE_ENVIRONMENT: Environment = Environment { + architecture: Architecture::X86, +}; + #[async_trait::async_trait] impl VersionInfoProvider for FakeVersionInfoProvider { async fn get_version_info(&self, _params: VersionParameters) -> anyhow::Result { @@ -357,6 +362,7 @@ async fn test_fetch_version() { AppController::initialize::<_, FakeAppDownloaderHappyPath, _, FakeDirectoryProvider>( &mut delegate, FakeVersionInfoProvider {}, + FAKE_ENVIRONMENT, ); // The app should start out by fetching the current app version @@ -380,6 +386,7 @@ async fn test_download() { AppController::initialize::<_, FakeAppDownloaderHappyPath, _, FakeDirectoryProvider>( &mut delegate, FakeVersionInfoProvider {}, + FAKE_ENVIRONMENT, ); // Wait for the version info @@ -425,6 +432,7 @@ async fn test_failed_verification() { AppController::initialize::<_, FakeAppDownloaderVerifyFail, _, FakeDirectoryProvider>( &mut delegate, FakeVersionInfoProvider {}, + FAKE_ENVIRONMENT, ); // Wait for the version info @@ -462,6 +470,7 @@ async fn test_failed_directory_creation() { AppController::initialize::<_, FakeAppDownloaderHappyPath, _, FakeDirectoryProvider>( &mut delegate, FakeVersionInfoProvider {}, + FAKE_ENVIRONMENT, ); // Wait for the version info From 3e3ec7ff11daa692fb3e8fafd64c9726d0e63d45 Mon Sep 17 00:00:00 2001 From: Markus Pettersson Date: Thu, 27 Feb 2025 13:22:59 +0100 Subject: [PATCH 055/112] Clean up `Cargo.toml` --- Cargo.lock | 1 + installer-downloader/Cargo.toml | 16 ++++++++-------- installer-downloader/build.rs | 2 ++ mullvad-update/Cargo.toml | 2 +- 4 files changed, 12 insertions(+), 9 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 645c02fac46c..c8d07165101b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2024,6 +2024,7 @@ dependencies = [ "cacao", "chrono", "fern", + "hex", "insta", "log", "mullvad-paths", diff --git a/installer-downloader/Cargo.toml b/installer-downloader/Cargo.toml index a468abb8cbc4..4dd336050c01 100644 --- a/installer-downloader/Cargo.toml +++ b/installer-downloader/Cargo.toml @@ -17,23 +17,23 @@ windows-sys = { workspace = true, features = ["Win32_System", "Win32_System_Libr [target.'cfg(any(target_os = "windows", target_os = "macos"))'.dependencies] anyhow = { workspace = true } -tokio = { workspace = true, features = ["rt-multi-thread", "fs"] } async-trait = "0.1" -rand = { version = "0.8.5" } -reqwest = { version = "0.12.9", default-features = false, features = ["rustls-tls"] } -serde = { workspace = true, features = ["derive"] } - - chrono = { workspace = true, features = ["clock"] } fern = { version = "0.6", default-features = false } +hex = "0.4" log = { workspace = true } +rand = { version = "0.8.5" } +reqwest = { version = "0.12.9", default-features = false, features = ["rustls-tls"] } +serde = { workspace = true, features = ["derive"] } +tokio = { workspace = true, features = ["rt-multi-thread", "fs"] } talpid-platform-metadata = { path = "../talpid-platform-metadata" } mullvad-update = { path = "../mullvad-update", features = ["client"] } [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 = ["frame", "image-decoder", "progress-bar"], default-features = false } +windows-sys = { workspace = true, features = ["Win32_UI", "Win32_UI_WindowsAndMessaging", "Win32_Graphics", "Win32_Graphics_Gdi"] } + mullvad-paths = { path = "../mullvad-paths" } [target.'cfg(target_os = "macos")'.dependencies] @@ -41,9 +41,9 @@ cacao = "0.3.2" objc_id = "0.1" [target.'cfg(any(target_os = "windows", target_os = "macos"))'.dev-dependencies] +insta = { workspace = true, features = ["yaml"] } serde = { workspace = true, features = ["derive"] } tokio = { workspace = true, features = ["test-util", "macros"] } -insta = { workspace = true, features = ["yaml"] } [package.metadata.winres] LegalCopyright = "(c) 2025 Mullvad VPN AB" diff --git a/installer-downloader/build.rs b/installer-downloader/build.rs index 475a86499995..9bb62b77ee3d 100644 --- a/installer-downloader/build.rs +++ b/installer-downloader/build.rs @@ -11,6 +11,8 @@ fn main() -> anyhow::Result<()> { } fn win_main() -> anyhow::Result<()> { + use anyhow::Context; + let mut res = winres::WindowsResource::new(); res.set_language(make_lang_id( diff --git a/mullvad-update/Cargo.toml b/mullvad-update/Cargo.toml index 12434e3545bc..cf2a39679688 100644 --- a/mullvad-update/Cargo.toml +++ b/mullvad-update/Cargo.toml @@ -27,7 +27,7 @@ serde_json = { workspace = true } async-trait = { version = "0.1", optional = true } reqwest = { version = "0.12.9", default-features = false, features = ["rustls-tls"], optional = true } sha2 = { version = "0.10", optional = true } -tokio = { workspace = true, features = ["rt-multi-thread", "fs", "process"], optional = true } +tokio = { workspace = true, features = ["rt-multi-thread", "fs", "process", "macros"], optional = true } thiserror = { workspace = true, optional = true } mullvad-version = { path = "../mullvad-version", features = ["serde"] } From 3d4d95f9235576a5977880a338a0de3b8ae4b8a2 Mon Sep 17 00:00:00 2001 From: Markus Pettersson Date: Thu, 27 Feb 2025 13:27:26 +0100 Subject: [PATCH 056/112] Convert `fern` to workspace dependency --- Cargo.toml | 1 + installer-downloader/Cargo.toml | 2 +- mullvad-daemon/Cargo.toml | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index b85092b02713..16baf268541c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -103,6 +103,7 @@ env_logger = "0.10.0" thiserror = "2.0" anyhow = "1.0" log = "0.4" +fern = { version = "0.6", default-features = false } shadowsocks = "1.20.3" shadowsocks-service = "1.20.3" diff --git a/installer-downloader/Cargo.toml b/installer-downloader/Cargo.toml index 4dd336050c01..10628536688c 100644 --- a/installer-downloader/Cargo.toml +++ b/installer-downloader/Cargo.toml @@ -19,7 +19,7 @@ windows-sys = { workspace = true, features = ["Win32_System", "Win32_System_Libr anyhow = { workspace = true } async-trait = "0.1" chrono = { workspace = true, features = ["clock"] } -fern = { version = "0.6", default-features = false } +fern = { workspace = true } hex = "0.4" log = { workspace = true } rand = { version = "0.8.5" } diff --git a/mullvad-daemon/Cargo.toml b/mullvad-daemon/Cargo.toml index 499d83c98e10..66edca63a24b 100644 --- a/mullvad-daemon/Cargo.toml +++ b/mullvad-daemon/Cargo.toml @@ -19,7 +19,7 @@ anyhow = { workspace = true } chrono = { workspace = true } thiserror = { workspace = true } either = "1.11" -fern = { version = "0.6", features = ["colored"] } +fern = { workspace = true, features = ["colored"] } futures = { workspace = true } libc = "0.2" log = { workspace = true } From 73ea790988fad37e33056ff3feff649422da025d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20L=C3=B6nnhager?= Date: Thu, 27 Feb 2025 14:08:22 +0100 Subject: [PATCH 057/112] Change rollout to 0 --- installer-downloader/src/controller.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/installer-downloader/src/controller.rs b/installer-downloader/src/controller.rs index ebed872755ac..829e51b5119d 100644 --- a/installer-downloader/src/controller.rs +++ b/installer-downloader/src/controller.rs @@ -143,7 +143,7 @@ async fn fetch_app_version_info( let version_params = VersionParameters { architecture, // For the downloader, the rollout version is always preferred - rollout: 1., + rollout: 0., // The downloader allows any version lowest_metadata_version: 0, }; From 99a8a4aa327e16d6db118eed2a2377acfb24394f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20L=C3=B6nnhager?= Date: Thu, 27 Feb 2025 14:16:44 +0100 Subject: [PATCH 058/112] Move constants to top in installer-downloader --- installer-downloader/src/controller.rs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/installer-downloader/src/controller.rs b/installer-downloader/src/controller.rs index 829e51b5119d..425d89d11351 100644 --- a/installer-downloader/src/controller.rs +++ b/installer-downloader/src/controller.rs @@ -15,6 +15,10 @@ use rand::seq::SliceRandom; use tokio::sync::{mpsc, oneshot}; +/// ed25519 pubkey used to verify metadata from the Mullvad (stagemole) API +const VERSION_PROVIDER_PUBKEY: &str = include_str!("../../mullvad-update/stagemole-pubkey"); + +/// Pinned root certificate used when fetching version metadata const PINNED_CERTIFICATE: &[u8] = include_bytes!("../../mullvad-api/le_root_cert.pem"); /// Actions handled by an async worker task in [handle_action_messages]. @@ -39,9 +43,8 @@ pub fn initialize_controller(delegate: &mut T, environ type DirProvider = crate::temp::TempDirProvider; // Version info provider to use - const STAGEMOLE_PUBKEY: &str = include_str!("../../mullvad-update/stagemole-pubkey"); let verifying_key = - mullvad_update::format::key::VerifyingKey::from_hex(STAGEMOLE_PUBKEY).expect("valid key"); + mullvad_update::format::key::VerifyingKey::from_hex(VERSION_PROVIDER_PUBKEY).expect("valid key"); let cert = reqwest::Certificate::from_pem(PINNED_CERTIFICATE).expect("invalid cert"); let version_provider = HttpVersionInfoProvider { url: get_metadata_url(), From da20f02e2d85da5b4362730e26f406c8a481fcf9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20L=C3=B6nnhager?= Date: Thu, 27 Feb 2025 14:50:17 +0100 Subject: [PATCH 059/112] Move action handlers to functions in installer-downloader --- installer-downloader/src/controller.rs | 388 ++++++++++++++----------- 1 file changed, 211 insertions(+), 177 deletions(-) diff --git a/installer-downloader/src/controller.rs b/installer-downloader/src/controller.rs index 425d89d11351..ed539547be7d 100644 --- a/installer-downloader/src/controller.rs +++ b/installer-downloader/src/controller.rs @@ -12,8 +12,9 @@ use mullvad_update::{ version::{Version, VersionInfo, VersionParameters}, }; use rand::seq::SliceRandom; - +use std::path::PathBuf; use tokio::sync::{mpsc, oneshot}; +use tokio::task::JoinHandle; /// ed25519 pubkey used to verify metadata from the Mullvad (stagemole) API const VERSION_PROVIDER_PUBKEY: &str = include_str!("../../mullvad-update/stagemole-pubkey"); @@ -21,7 +22,7 @@ const VERSION_PROVIDER_PUBKEY: &str = include_str!("../../mullvad-update/stagemo /// Pinned root certificate used when fetching version metadata const PINNED_CERTIFICATE: &[u8] = include_bytes!("../../mullvad-api/le_root_cert.pem"); -/// Actions handled by an async worker task in [handle_action_messages]. +/// Actions handled by an async worker task in [ActionMessageHandler]. enum TaskMessage { SetVersionInfo(VersionInfo), BeginDownload, @@ -44,7 +45,8 @@ pub fn initialize_controller(delegate: &mut T, environ // Version info provider to use let verifying_key = - mullvad_update::format::key::VerifyingKey::from_hex(VERSION_PROVIDER_PUBKEY).expect("valid key"); + mullvad_update::format::key::VerifyingKey::from_hex(VERSION_PROVIDER_PUBKEY) + .expect("valid key"); let cert = reqwest::Certificate::from_pem(PINNED_CERTIFICATE).expect("invalid cert"); let version_provider = HttpVersionInfoProvider { url: get_metadata_url(), @@ -94,7 +96,7 @@ impl AppController { delegate.hide_stable_text(); let (task_tx, task_rx) = mpsc::channel(1); - tokio::spawn(handle_action_messages::( + tokio::spawn(ActionMessageHandler::::run::( delegate.queue(), task_tx.clone(), task_rx, @@ -215,201 +217,233 @@ enum TargetVersion { /// Async worker that handles actions such as initiating a download, cancelling it, and updating /// labels. -async fn handle_action_messages( - queue: D::Queue, - tx: mpsc::Sender, - mut rx: mpsc::Receiver, -) where +struct ActionMessageHandler< D: AppDelegate + 'static, A: From> + AppDownloader + 'static, - DirProvider: DirectoryProvider, +> { + queue: D::Queue, + tx: mpsc::Sender, + version_info: Option, + active_download: Option>, + target_version: TargetVersion, + temp_dir: anyhow::Result, + + _marker: std::marker::PhantomData, +} + +impl> + AppDownloader + 'static> + ActionMessageHandler { - let mut version_info = None; - let mut active_download = None; + async fn run( + queue: D::Queue, + tx: mpsc::Sender, + mut rx: mpsc::Receiver, + ) { + let temp_dir = DP::create_download_dir().await; - let mut target_version = TargetVersion::Stable; + let mut handler = Self { + queue, + tx, + version_info: None, + active_download: None, + target_version: TargetVersion::Stable, + temp_dir, - let temp_dir = DirProvider::create_download_dir().await; + _marker: std::marker::PhantomData, + }; - while let Some(msg) = rx.recv().await { + while let Some(msg) = rx.recv().await { + handler.handle_message(&msg).await; + } + } + + async fn handle_message(&mut self, msg: &TaskMessage) { 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); + self.handle_set_version_info(new_version_info); } - TaskMessage::TryBeta => { - let Some(version_info) = version_info.as_ref() else { - log::error!("Attempted 'try beta' before having version info"); - continue; - }; - let Some(beta_info) = version_info.beta.as_ref() else { - log::error!("Attempted 'try beta' without beta version"); - continue; - }; - - target_version = TargetVersion::Beta; - let version_label = format_latest_version(beta_info); - - queue.queue_main(move |self_| { - self_.show_stable_text(); - self_.hide_beta_text(); - self_.set_status_text(&version_label); - }); + TaskMessage::TryBeta => self.handle_try_beta(), + TaskMessage::TryStable => self.handle_try_stable(), + TaskMessage::BeginDownload => self.begin_download().await, + TaskMessage::Cancel => self.cancel().await, + } + } + + fn handle_set_version_info(&mut self, new_version_info: &VersionInfo) { + let version_label = format_latest_version(&new_version_info.stable); + let has_beta = new_version_info.beta.is_some(); + self.queue.queue_main(move |self_| { + self_.set_status_text(&version_label); + self_.enable_download_button(); + if has_beta { + self_.show_beta_text(); } - TaskMessage::TryStable => { - let Some(version_info) = version_info.as_ref() else { - log::error!("Attempted 'try stable' before having version info"); - continue; - }; - let stable_info = &version_info.stable; + }); + self.version_info = Some(new_version_info.to_owned()); + } - target_version = TargetVersion::Stable; - let version_label = format_latest_version(stable_info); + fn handle_try_beta(&mut self) { + let Some(version_info) = self.version_info.as_ref() else { + log::error!("Attempted 'try beta' before having version info"); + return; + }; + let Some(beta_info) = version_info.beta.as_ref() else { + log::error!("Attempted 'try beta' without beta version"); + return; + }; - queue.queue_main(move |self_| { - self_.hide_stable_text(); - self_.show_beta_text(); - self_.set_status_text(&version_label); - }); - } - TaskMessage::BeginDownload => { - if active_download.take().is_some() { - log::debug!("Interrupting ongoing download"); - } - let Some(version_info) = version_info.clone() else { - log::error!("Attempted 'begin download' before having version info"); - continue; - }; - - let (retry_tx, cancel_tx) = (tx.clone(), tx.clone()); - queue.queue_main(move |self_| { - self_.hide_error_message(); - self_.on_error_message_retry(move || { - let _ = retry_tx.try_send(TaskMessage::BeginDownload); - }); - self_.on_error_message_cancel(move || { - let _ = cancel_tx.try_send(TaskMessage::Cancel); - }); - }); + self.target_version = TargetVersion::Beta; + let version_label = format_latest_version(beta_info); + + self.queue.queue_main(move |self_| { + self_.show_stable_text(); + self_.hide_beta_text(); + self_.set_status_text(&version_label); + }); + } - // Create temporary dir - let download_dir = match &temp_dir { - Ok(dir) => dir.clone(), - Err(error) => { - log::error!("Failed to create temporary directory: {error:?}"); - - queue.queue_main(move |self_| { - self_.set_status_text(""); - self_.hide_download_button(); - self_.hide_beta_text(); - self_.hide_stable_text(); - - self_.show_error_message(crate::delegate::ErrorMessage { - status_text: resource::DOWNLOAD_FAILED_DESC.to_owned(), - cancel_button_text: resource::DOWNLOAD_FAILED_CANCEL_BUTTON_TEXT - .to_owned(), - retry_button_text: resource::DOWNLOAD_FAILED_RETRY_BUTTON_TEXT - .to_owned(), - }); - }); - continue; - } - }; - - log::debug!("Download directory: {}", download_dir.display()); - - // Begin download - let (tx, rx) = oneshot::channel(); - queue.queue_main(move |self_| { - let selected_version = match target_version { - TargetVersion::Stable => &version_info.stable, - TargetVersion::Beta => { - version_info.beta.as_ref().expect("selected version exists") - } - }; - - let Some(app_url) = select_cdn_url(&selected_version.urls) else { - return; - }; - let app_version = selected_version.version.clone(); - let app_sha256 = selected_version.sha256; - let app_size = selected_version.size; - - self_.set_download_text(""); + fn handle_try_stable(&mut self) { + let Some(version_info) = self.version_info.as_ref() else { + log::error!("Attempted 'try stable' before having version info"); + return; + }; + let stable_info = &version_info.stable; + + self.target_version = TargetVersion::Stable; + let version_label = format_latest_version(stable_info); + + self.queue.queue_main(move |self_| { + self_.hide_stable_text(); + self_.show_beta_text(); + self_.set_status_text(&version_label); + }); + } + + async fn begin_download(&mut self) { + if self.active_download.take().is_some() { + log::debug!("Interrupting ongoing download"); + } + let Some(version_info) = self.version_info.clone() else { + log::error!("Attempted 'begin download' before having version info"); + return; + }; + + let (retry_tx, cancel_tx) = (self.tx.clone(), self.tx.clone()); + self.queue.queue_main(move |self_| { + self_.hide_error_message(); + self_.on_error_message_retry(move || { + let _ = retry_tx.try_send(TaskMessage::BeginDownload); + }); + self_.on_error_message_cancel(move || { + let _ = cancel_tx.try_send(TaskMessage::Cancel); + }); + }); + + // Create temporary dir + let download_dir = match &self.temp_dir { + Ok(dir) => dir.clone(), + Err(error) => { + log::error!("Failed to create temporary directory: {error:?}"); + + self.queue.queue_main(move |self_| { + self_.set_status_text(""); self_.hide_download_button(); self_.hide_beta_text(); self_.hide_stable_text(); - self_.show_cancel_button(); - self_.enable_cancel_button(); - self_.show_download_progress(); - - let downloader = A::from(UiAppDownloaderParameters { - app_version, - app_url: app_url.to_owned(), - app_size, - app_progress: UiProgressUpdater::new(self_.queue()), - app_sha256, - cache_dir: download_dir, - }); - let ui_downloader = UiAppDownloader::new(self_, downloader); - let _ = tx.send(tokio::spawn(async move { - if let Err(err) = app::install_and_upgrade(ui_downloader).await { - log::error!("install_and_upgrade failed: {err:?}"); - } - })); + self_.show_error_message(crate::delegate::ErrorMessage { + status_text: resource::DOWNLOAD_FAILED_DESC.to_owned(), + cancel_button_text: resource::DOWNLOAD_FAILED_CANCEL_BUTTON_TEXT.to_owned(), + retry_button_text: resource::DOWNLOAD_FAILED_RETRY_BUTTON_TEXT.to_owned(), + }); }); - active_download = rx.await.ok(); + return; } - TaskMessage::Cancel => { - if let Some(active_download) = active_download.take() { - active_download.abort(); - let _ = active_download.await; + }; + + log::debug!("Download directory: {}", download_dir.display()); + + // Begin download + let (tx, rx) = oneshot::channel(); + let target_version = self.target_version; + self.queue.queue_main(move |self_| { + let selected_version = match target_version { + TargetVersion::Stable => &version_info.stable, + TargetVersion::Beta => version_info.beta.as_ref().expect("selected version exists"), + }; + + let Some(app_url) = select_cdn_url(&selected_version.urls) else { + return; + }; + let app_version = selected_version.version.clone(); + let app_sha256 = selected_version.sha256; + let app_size = selected_version.size; + + self_.set_download_text(""); + self_.hide_download_button(); + self_.hide_beta_text(); + self_.hide_stable_text(); + self_.show_cancel_button(); + self_.enable_cancel_button(); + self_.show_download_progress(); + + let downloader = A::from(UiAppDownloaderParameters { + app_version, + app_url: app_url.to_owned(), + app_size, + app_progress: UiProgressUpdater::new(self_.queue()), + app_sha256, + cache_dir: download_dir, + }); + + let ui_downloader = UiAppDownloader::new(self_, downloader); + let _ = tx.send(tokio::spawn(async move { + if let Err(err) = app::install_and_upgrade(ui_downloader).await { + log::error!("install_and_upgrade failed: {err:?}"); } + })); + }); + self.active_download = rx.await.ok(); + } - let Some(version_info) = version_info.as_ref() else { - log::error!("Attempted 'cancel' before having version info"); - continue; - }; - - let selected_version = match target_version { - TargetVersion::Stable => &version_info.stable, - TargetVersion::Beta => { - version_info.beta.as_ref().expect("selected version exists") - } - }; - - let version_label = format_latest_version(selected_version); - let has_beta = version_info.beta.is_some(); - - queue.queue_main(move |self_| { - self_.set_status_text(&version_label); - self_.set_download_text(""); - self_.show_download_button(); - self_.hide_error_message(); - - if target_version == TargetVersion::Stable { - if has_beta { - self_.show_beta_text(); - } - } else { - self_.show_stable_text(); - } - - self_.hide_cancel_button(); - self_.hide_download_progress(); - self_.set_download_progress(0); - }); - } + async fn cancel(&mut self) { + if let Some(active_download) = self.active_download.take() { + active_download.abort(); + let _ = active_download.await; } + + let Some(version_info) = self.version_info.as_ref() else { + log::error!("Attempted 'cancel' before having version info"); + return; + }; + + let selected_version = match self.target_version { + TargetVersion::Stable => &version_info.stable, + TargetVersion::Beta => version_info.beta.as_ref().expect("selected version exists"), + }; + + let version_label = format_latest_version(selected_version); + let has_beta = version_info.beta.is_some(); + let target_version = self.target_version; + + self.queue.queue_main(move |self_| { + self_.set_status_text(&version_label); + self_.set_download_text(""); + self_.show_download_button(); + self_.hide_error_message(); + + if target_version == TargetVersion::Stable { + if has_beta { + self_.show_beta_text(); + } + } else { + self_.show_stable_text(); + } + + self_.hide_cancel_button(); + self_.hide_download_progress(); + self_.set_download_progress(0); + }); } } From ab009561e5a1f8ad56841bb5f1914c5f255d6201 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20L=C3=B6nnhager?= Date: Mon, 3 Mar 2025 09:27:25 +0100 Subject: [PATCH 060/112] Do not add manifest to installer-downloader when building tests --- installer-downloader/build.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/installer-downloader/build.rs b/installer-downloader/build.rs index 9bb62b77ee3d..c44a94c8cfaa 100644 --- a/installer-downloader/build.rs +++ b/installer-downloader/build.rs @@ -3,6 +3,11 @@ use std::env; fn main() -> anyhow::Result<()> { let target_os = env::var("CARGO_CFG_TARGET_OS").context("Missing 'CARGO_CFG_TARGET_OS")?; + let building_tests = env::var("CARGO_TARGET_TMPDIR").is_ok(); + + if building_tests { + return Ok(()); + } match target_os.as_str() { "windows" => win_main(), From b5964d5b9f64089d99e29925fc5f578e7cf75b24 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20L=C3=B6nnhager?= Date: Mon, 3 Mar 2025 09:55:53 +0100 Subject: [PATCH 061/112] Run as admin only in release mode --- installer-downloader/build.rs | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/installer-downloader/build.rs b/installer-downloader/build.rs index c44a94c8cfaa..93692ee6cd8e 100644 --- a/installer-downloader/build.rs +++ b/installer-downloader/build.rs @@ -2,13 +2,11 @@ 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")?; - let building_tests = env::var("CARGO_TARGET_TMPDIR").is_ok(); - - if building_tests { + if cfg!(debug_assertions) { return Ok(()); } + let target_os = env::var("CARGO_CFG_TARGET_OS").context("Missing 'CARGO_CFG_TARGET_OS")?; match target_os.as_str() { "windows" => win_main(), _ => Ok(()), From a193f5bf70500bd1b10edf2904c986419c18748f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20L=C3=B6nnhager?= Date: Mon, 24 Feb 2025 19:30:54 +0100 Subject: [PATCH 062/112] Sign and notarize installer downloader --- .github/workflows/downloader.yml | 4 +- installer-downloader/build.sh | 266 ++++++++++++++++++--- installer-downloader/src/cacao_impl/mod.rs | 2 +- installer-downloader/src/resource.rs | 2 +- 4 files changed, 233 insertions(+), 41 deletions(-) diff --git a/.github/workflows/downloader.yml b/.github/workflows/downloader.yml index c8c90716aefe..33ce992fb7e6 100644 --- a/.github/workflows/downloader.yml +++ b/.github/workflows/downloader.yml @@ -55,7 +55,7 @@ jobs: - name: Check file size uses: ./.github/actions/check-file-size with: - artifact: "./installer-downloader/dist/MullvadDownloader.exe" + artifact: "./dist/Install Mullvad VPN.exe" max_size: ${{ env.MAX_BINARY_SIZE }} build-macos: @@ -77,5 +77,5 @@ jobs: - name: Check file size uses: ./.github/actions/check-file-size with: - artifact: "./installer-downloader/dist/MullvadDownloader.dmg" + artifact: "./dist/Install Mullvad VPN.dmg" max_size: ${{ env.MAX_BINARY_SIZE }} diff --git a/installer-downloader/build.sh b/installer-downloader/build.sh index a425c10ff67f..56adbd6be3e9 100755 --- a/installer-downloader/build.sh +++ b/installer-downloader/build.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash -# This script is used to build, and optionally sign the downloader, always in release mode. +# This script is used to build, and optionally sign, the downloader, always in release mode. # This script performs the equivalent of the following profile: # @@ -25,8 +25,71 @@ source ../scripts/utils/host source ../scripts/utils/log CARGO_TARGET_DIR=${CARGO_TARGET_DIR:-"../target"} -DIST_DIR="./dist" +export CARGO_TARGET_DIR +# Temporary build directory +BUILD_DIR="./build" +# Successfully built (and signed) artifacts +DIST_DIR="../dist" + +BUNDLE_NAME="MullvadDownloader" +BUNDLE_ID="net.mullvad.$BUNDLE_NAME" + +FILENAME="Install Mullvad VPN" + +rm -rf "$BUILD_DIR" +mkdir -p "$BUILD_DIR" + +mkdir -p "$DIST_DIR" + +# Whether to sign and notarized produced binaries +SIGN="false" + +# Temporary keychain to store the .p12 in. +# This is automatically created/replaced when signing on macOS. +SIGN_KEYCHAIN_PATH="$HOME/Library/Keychains/mv-metadata-keychain-db" + +# Parse arguments +while [[ "$#" -gt 0 ]]; do + case $1 in + --sign) + SIGN="true" + ;; + *) + log_error "Unknown parameter: $1" + exit 1 + ;; + esac + shift +done + +# Check that we have the correct environment set for signing +function assert_can_sign { + if [[ "$(uname -s)" == "Darwin" ]]; then + if [[ -z ${CSC_LINK-} ]]; then + log_error "The variable CSC_LINK is not set. It needs to point to a file containing the private key used for signing of binaries." + exit 1 + fi + if [[ -z ${CSC_KEY_PASSWORD-} ]]; then + read -rsp "CSC_KEY_PASSWORD = " CSC_KEY_PASSWORD + echo "" + export CSC_KEY_PASSWORD + fi + if [[ -z ${NOTARIZE_KEYCHAIN-} || -z ${NOTARIZE_KEYCHAIN_PROFILE-} ]]; then + log_error "The variables NOTARIZE_KEYCHAIN and NOTARIZE_KEYCHAIN_PROFILE must be set." + exit 1 + fi + elif [[ "$(uname -s)" == "MINGW"* ]]; then + if [[ -z ${CERT_HASH-} ]]; then + log_error "The variable CERT_HASH is not set. It needs to be set to the thumbprint of the signing certificate." + exit 1 + fi + fi +} + +# Run cargo with all appropriate flags and options +# Arguments: +# - (optional) target function build_executable { local -a target_args=() @@ -43,16 +106,12 @@ function build_executable { set -u } -function dist_windows_app { - cp "$CARGO_TARGET_DIR/release/installer-downloader.exe" "$DIST_DIR/MullvadDownloader.exe" -} - -# Combine executables on macOS +# Combine executables on macOS. This must be run after build_executable for both x86 and arm64. function lipo_executables { local target_exes target_exes=() - rm -rf "$DIST_DIR/installer-downloader" + rm -rf "$BUILD_DIR/installer-downloader" case $HOST in x86_64-apple-darwin) target_exes=( @@ -67,17 +126,79 @@ function lipo_executables { ;; esac - lipo "${target_exes[@]}" -create -output "$DIST_DIR/installer-downloader" + lipo "${target_exes[@]}" -create -output "$BUILD_DIR/installer-downloader" +} + +# Create temporary keychain for importing $CSC_LINK +function setup_macos_keychain { + log_info "Creating a temporary keychain \"$SIGN_KEYCHAIN_PATH\" for $CSC_LINK" + + SIGN_KEYCHAIN_PASS=$(openssl rand -base64 64) + export SIGN_KEYCHAIN_PASS + + delete_macos_keychain + trap "delete_macos_keychain" EXIT + + /usr/bin/security create-keychain -p "$SIGN_KEYCHAIN_PASS" "$SIGN_KEYCHAIN_PATH" + /usr/bin/security unlock-keychain -p "$SIGN_KEYCHAIN_PASS" "$SIGN_KEYCHAIN_PATH" + /usr/bin/security set-keychain-settings "$SIGN_KEYCHAIN_PATH" + + # Include keychain in the search list, or codesign won't find it + /usr/bin/security list-keychains -d user -s "$SIGN_KEYCHAIN_PATH" + + log_info "Importing PKCS #12 to keychain" + + /usr/bin/security import "$CSC_LINK" -k "$SIGN_KEYCHAIN_PATH" -P "$CSC_KEY_PASSWORD" -T /usr/bin/codesign + + # Prevent password prompt when signing + /usr/bin/security set-key-partition-list -S "apple-tool:,apple:" -s -k "$SIGN_KEYCHAIN_PASS" "$SIGN_KEYCHAIN_PATH" + + log_info "Done." + + # Find identity + log_info "Find the identity to use" + + /usr/bin/security find-identity -p codesigning + read -rp "Enter identity: " SIGN_KEYCHAIN_IDENTITY + export SIGN_KEYCHAIN_IDENTITY + + # TODO: auto-detect identity } +function delete_macos_keychain { + /usr/bin/security delete-keychain "$SIGN_KEYCHAIN_PATH" || true + rm -f "$SIGN_KEYCHAIN_PATH" +} + +# Sign an artifact. +# - setup_macos_keychain must be called first +# Arguments: +# - file to sign +function sign_macos { + local file="$1" + if [[ "$SIGN" == "false" ]]; then + # Ad-hoc sign app bundle + /usr/bin/codesign --identifier "$BUNDLE_ID" \ + --sign - \ + --timestamp=none --verbose=0 -o runtime \ + "$file" + else + /usr/bin/codesign --identifier "$BUNDLE_ID" \ + --sign "$SIGN_KEYCHAIN_IDENTITY" \ + --keychain "$SIGN_KEYCHAIN_PATH" \ + --verbose=0 -o runtime \ + "$file" + fi +} + +# Build app bundle and dmg, and optionally sign it. +# If `$SIGN` is false, the app bundle is only ad-hoc signed. function dist_macos_app { - local app_path - bundle_name="MullvadDownloader" - bundle_id="net.mullvad.$bundle_name" - app_path="$DIST_DIR/$bundle_name.app/" + local app_path="$BUILD_DIR/$FILENAME.app/" + local dmg_path="$BUILD_DIR/$FILENAME.dmg" # Build app bundle - echo "Building $app_path..." + log_info "Building $app_path..." rm -rf "$app_path" @@ -87,37 +208,108 @@ function dist_macos_app { mkdir -p "$app_path/Contents/MacOS" cp ./assets/Info.plist "$app_path/Contents/Info.plist" - cp "$DIST_DIR/installer-downloader" "$app_path/Contents/MacOS/installer-downloader" + cp "$BUILD_DIR/installer-downloader" "$app_path/Contents/MacOS/installer-downloader" - # Ad-hoc sign app bundle - codesign --force --deep --identifier "$bundle_id" --sign - \ - --timestamp=none --verbose=0 -o runtime \ - "$DIST_DIR/$bundle_name.app" + # Sign app bundle + if [[ "$SIGN" != "false" ]]; then + setup_macos_keychain + fi + sign_macos "$app_path" # Pack in .dmg - echo "Creating .dmg image..." + log_info "Creating $dmg_path image..." + hdiutil create -volname "$FILENAME" -srcfolder "$app_path" -ov -format UDZO \ + "$dmg_path" - hdiutil create -volname "MullvadDownloader" -srcfolder "$app_path" -ov -format UDZO \ - "$DIST_DIR/MullvadDownloader.dmg" + # Sign .dmg + sign_macos "$dmg_path" + + # Notarize .dmg + if [[ "$SIGN" != "false" ]]; then + notarize_mac "$dmg_path" + fi - # TODO: sign image? + # Move to dist dir + log_info "Moving final artifacts to $DIST_DIR" + rm -rf "$DIST_DIR/$FILENAME.app/" + rm -rf "$DIST_DIR/$FILENAME.dmg" + mv "$app_path" "$DIST_DIR/" + mv "$dmg_path" "$DIST_DIR/" } -mkdir -p "$DIST_DIR" +# Notarize and staple a file. +# Arguments: +# - file to sign +function notarize_mac { + local file="$1" -if [[ "$(uname -s)" == "Darwin" ]]; then - case $HOST in - x86_64-apple-darwin) TARGETS=("" aarch64-apple-darwin);; - aarch64-apple-darwin) TARGETS=("" x86_64-apple-darwin);; - esac + log_info "Notarizing $file" + xcrun notarytool submit "$file" \ + --keychain "$NOTARIZE_KEYCHAIN" \ + --keychain-profile "$NOTARIZE_KEYCHAIN_PROFILE" \ + --wait + + log_info "Stapling $file" + xcrun stapler staple "$file" +} + +# Sign a file. +# Arguments: +# - file to sign +function sign_win { + local binary=$1 + local num_retries=3 + + for i in $(seq 0 ${num_retries}); do + log_info "Signing $binary..." + if signtool sign \ + -tr http://timestamp.digicert.com -td sha256 \ + -fd sha256 -d "Mullvad VPN installer" \ + -du "https://github.com/mullvad/mullvadvpn-app#readme" \ + -sha1 "$CERT_HASH" "$binary" + then + break + fi - for t in "${TARGETS[@]:-"$HOST"}"; do - build_executable "$t" + if [ "$i" -eq "${num_retries}" ]; then + return 1 + fi + + sleep 1 done +} + +# Copy executable and optionally sign it. +function dist_windows_app { + cp "$CARGO_TARGET_DIR/release/installer-downloader.exe" "$BUILD_DIR/$FILENAME.exe" + if [[ "$SIGN" != "false" ]]; then + sign_win "$BUILD_DIR/$FILENAME.exe" + fi + mv "$BUILD_DIR/$FILENAME.exe" "$DIST_DIR/" +} + +function main { + if [[ "$SIGN" != "false" ]]; then + assert_can_sign + fi + + if [[ "$(uname -s)" == "Darwin" ]]; then + case $HOST in + x86_64-apple-darwin) TARGETS=("" aarch64-apple-darwin);; + aarch64-apple-darwin) TARGETS=("" x86_64-apple-darwin);; + esac + + for t in "${TARGETS[@]:-"$HOST"}"; do + build_executable "$t" + done + + lipo_executables + dist_macos_app + + elif [[ "$(uname -s)" == "MINGW"* ]]; then + build_executable + dist_windows_app + fi +} - lipo_executables - dist_macos_app -elif [[ "$(uname -s)" == "MINGW"* ]]; then - build_executable - dist_windows_app -fi +main diff --git a/installer-downloader/src/cacao_impl/mod.rs b/installer-downloader/src/cacao_impl/mod.rs index 3b607c3295db..717223893e98 100644 --- a/installer-downloader/src/cacao_impl/mod.rs +++ b/installer-downloader/src/cacao_impl/mod.rs @@ -8,7 +8,7 @@ mod delegate; mod ui; pub fn main() { - let app = App::new("net.mullvad.downloader", AppImpl::default()); + let app = App::new("net.mullvad.MullvadDownloader", AppImpl::default()); // Load "global" values and resources let environment = match Environment::load() { diff --git a/installer-downloader/src/resource.rs b/installer-downloader/src/resource.rs index 07bfe4161e46..f23533384569 100644 --- a/installer-downloader/src/resource.rs +++ b/installer-downloader/src/resource.rs @@ -1,7 +1,7 @@ //! Shared text and other resources /// Window title -pub const WINDOW_TITLE: &str = "Mullvad VPN downloader"; +pub const WINDOW_TITLE: &str = "Mullvad VPN installer"; /// Window width pub const WINDOW_WIDTH: usize = 600; /// Window height From 9ab1e1012c644203663710b1c00106a63ddfe10d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20L=C3=B6nnhager?= Date: Fri, 28 Feb 2025 09:45:12 +0100 Subject: [PATCH 063/112] Add build/ to .gitignore --- installer-downloader/.gitignore | 1 + 1 file changed, 1 insertion(+) create mode 100644 installer-downloader/.gitignore diff --git a/installer-downloader/.gitignore b/installer-downloader/.gitignore new file mode 100644 index 000000000000..42afabfd2abe --- /dev/null +++ b/installer-downloader/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file From cc4c66dc386cbeabc270f9d26b939e757175f2cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20L=C3=B6nnhager?= Date: Mon, 3 Mar 2025 11:07:51 +0100 Subject: [PATCH 064/112] Add missing safety comments --- installer-downloader/src/winapi_impl/ui.rs | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/installer-downloader/src/winapi_impl/ui.rs b/installer-downloader/src/winapi_impl/ui.rs index a83d2007bcee..f788ea4bd22f 100644 --- a/installer-downloader/src/winapi_impl/ui.rs +++ b/installer-downloader/src/winapi_impl/ui.rs @@ -390,6 +390,7 @@ fn handle_banner_label_colors( } if msg == WM_CTLCOLORSTATIC { + // SAFETY: `w` is a valid device context for WM_CTLCOLORSTATIC unsafe { SetTextColor(w as _, rgb([255, 255, 255])); SetBkColor(w as _, rgb(BACKGROUND_COLOR)); @@ -413,6 +414,7 @@ fn handle_link_messages( } if msg == WM_CTLCOLORSTATIC && Some(p) == link_hwnd { + // SAFETY: `w` is a valid device context for WM_CTLCOLORSTATIC unsafe { SetBkMode(w as _, TRANSPARENT as _); SetTextColor(w as _, rgb(LINK_COLOR)); @@ -476,15 +478,15 @@ fn try_pair_into, B>(a: (A, A)) -> Result<(B, B), A::Error> { fn create_link_font() -> Result { let face_name = "Segoe UI".encode_utf16(); - let raw_font = unsafe { - let mut logfont: LOGFONTW = std::mem::zeroed(); - logfont.lfUnderline = 1; + // SAFETY: Trivially safe. `LOGFONTW` is a C struct + let mut logfont: LOGFONTW = unsafe { std::mem::zeroed() }; + logfont.lfUnderline = 1; + for (dest, src) in logfont.lfFaceName.iter_mut().zip(face_name) { + *dest = src; + } - for (dest, src) in logfont.lfFaceName.iter_mut().zip(face_name) { - *dest = src; - } - CreateFontIndirectW(&logfont) - }; + // SAFETY: `logfont` is a valid font + let raw_font = unsafe { CreateFontIndirectW(&logfont) }; if raw_font == 0 { return Err(nwg::NwgError::Unknown); From 14d86ef6dfa225c759d486048082f79e3cfbf882 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20L=C3=B6nnhager?= Date: Mon, 3 Mar 2025 11:27:26 +0100 Subject: [PATCH 065/112] Document font lifetime for windows UI --- installer-downloader/src/winapi_impl/ui.rs | 50 +++++++++++++--------- 1 file changed, 30 insertions(+), 20 deletions(-) diff --git a/installer-downloader/src/winapi_impl/ui.rs b/installer-downloader/src/winapi_impl/ui.rs index f788ea4bd22f..1f542fd161b1 100644 --- a/installer-downloader/src/winapi_impl/ui.rs +++ b/installer-downloader/src/winapi_impl/ui.rs @@ -2,6 +2,7 @@ use std::cell::RefCell; use std::rc::Rc; +use std::sync::LazyLock; use native_windows_gui::{self as nwg, ControlHandle, ImageDecoder, WindowFlags}; @@ -204,7 +205,7 @@ impl AppWindow { .parent(&self.window) .size((128, 24)) .text(BETA_LINK_TEXT) - .font(Some(&link_font)) + .font(Some(link_font)) .h_align(nwg::HTextAlign::Left) .build(&mut self.beta_link)?; @@ -229,7 +230,7 @@ impl AppWindow { .parent(&self.stable_message_frame) .size((240, 24)) .text(STABLE_LINK_TEXT) - .font(Some(&link_font)) + .font(Some(link_font)) .h_align(nwg::HTextAlign::Left) .build(&mut self.stable_link)?; @@ -474,25 +475,34 @@ fn try_pair_into, B>(a: (A, A)) -> Result<(B, B), A::Error> { } /// Create a link font -/// TODO: upstream to nwg -fn create_link_font() -> Result { - let face_name = "Segoe UI".encode_utf16(); - - // SAFETY: Trivially safe. `LOGFONTW` is a C struct - let mut logfont: LOGFONTW = unsafe { std::mem::zeroed() }; - logfont.lfUnderline = 1; - for (dest, src) in logfont.lfFaceName.iter_mut().zip(face_name) { - *dest = src; - } +/// +/// NOTE: The font is never freed using DeleteObject. This is acceptable since it exists for the +/// lifetime of the program. +fn create_link_font() -> Result<&'static nwg::Font, nwg::NwgError> { + static LINK_FONT: LazyLock> = LazyLock::new(|| { + let face_name = "Segoe UI".encode_utf16(); + + // SAFETY: Trivially safe. `LOGFONTW` is a C struct + let mut logfont: LOGFONTW = unsafe { std::mem::zeroed() }; + logfont.lfUnderline = 1; + for (dest, src) in logfont.lfFaceName.iter_mut().zip(face_name) { + *dest = src; + } - // SAFETY: `logfont` is a valid font - let raw_font = unsafe { CreateFontIndirectW(&logfont) }; + // SAFETY: `logfont` is a valid font + let raw_font = unsafe { CreateFontIndirectW(&logfont) }; - if raw_font == 0 { - return Err(nwg::NwgError::Unknown); - } + if raw_font == 0 { + return Err(nwg::NwgError::Unknown); + } - Ok(nwg::Font { - handle: raw_font as _, - }) + Ok(nwg::Font { + handle: raw_font as _, + }) + }); + + match &*LINK_FONT { + Ok(font) => Ok(font), + Err(err) => Err(err.to_owned()), + } } From e58632ed6184677e1d9c1243cd2e0efd4d68c51a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20L=C3=B6nnhager?= Date: Mon, 3 Mar 2025 11:29:02 +0100 Subject: [PATCH 066/112] Simplify cfg conditions in installer-downloader --- installer-downloader/src/lib.rs | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/installer-downloader/src/lib.rs b/installer-downloader/src/lib.rs index a07d557d670d..e787ccca7875 100644 --- a/installer-downloader/src/lib.rs +++ b/installer-downloader/src/lib.rs @@ -1,14 +1,9 @@ -#[cfg(any(target_os = "windows", target_os = "macos"))] +#![cfg(any(target_os = "windows", target_os = "macos"))] + pub mod controller; -#[cfg(any(target_os = "windows", target_os = "macos"))] pub mod delegate; -#[cfg(any(target_os = "windows", target_os = "macos"))] pub mod environment; -#[cfg(any(target_os = "windows", target_os = "macos"))] pub mod log; -#[cfg(any(target_os = "windows", target_os = "macos"))] pub mod resource; -#[cfg(any(target_os = "windows", target_os = "macos"))] pub mod temp; -#[cfg(any(target_os = "windows", target_os = "macos"))] pub mod ui_downloader; From 2094a65117b8ab1aeb5a82bba62bc10e8f181e2a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20L=C3=B6nnhager?= Date: Mon, 3 Mar 2025 11:31:18 +0100 Subject: [PATCH 067/112] Update bundle identifier in installer-downloader --- installer-downloader/assets/Info.plist | 4 ++-- installer-downloader/build.sh | 2 +- installer-downloader/src/cacao_impl/mod.rs | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/installer-downloader/assets/Info.plist b/installer-downloader/assets/Info.plist index be1ca1be385d..e99e3277c46e 100644 --- a/installer-downloader/assets/Info.plist +++ b/installer-downloader/assets/Info.plist @@ -11,13 +11,13 @@ CFBundleIconFile icon.icns CFBundleIdentifier - net.mullvad.MullvadDownloader + net.mullvad.MullvadVPNInstaller CFBundleInfoDictionaryVersion 6.0 CFBundleLongVersionString CFBundleName - net.mullvad.MullvadDownloader + net.mullvad.MullvadVPNInstaller CFBundlePackageType APPL CFBundleShortVersionString diff --git a/installer-downloader/build.sh b/installer-downloader/build.sh index 56adbd6be3e9..ea0f75d6c2eb 100755 --- a/installer-downloader/build.sh +++ b/installer-downloader/build.sh @@ -32,7 +32,7 @@ BUILD_DIR="./build" # Successfully built (and signed) artifacts DIST_DIR="../dist" -BUNDLE_NAME="MullvadDownloader" +BUNDLE_NAME="MullvadVPNInstaller" BUNDLE_ID="net.mullvad.$BUNDLE_NAME" FILENAME="Install Mullvad VPN" diff --git a/installer-downloader/src/cacao_impl/mod.rs b/installer-downloader/src/cacao_impl/mod.rs index 717223893e98..9bd044d5fa4d 100644 --- a/installer-downloader/src/cacao_impl/mod.rs +++ b/installer-downloader/src/cacao_impl/mod.rs @@ -8,7 +8,7 @@ mod delegate; mod ui; pub fn main() { - let app = App::new("net.mullvad.MullvadDownloader", AppImpl::default()); + let app = App::new("net.mullvad.MullvadVPNInstaller", AppImpl::default()); // Load "global" values and resources let environment = match Environment::load() { From a0c84cbebb2973fd3a24d79d991a4101c1341ef6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20L=C3=B6nnhager?= Date: Mon, 3 Mar 2025 11:36:30 +0100 Subject: [PATCH 068/112] Add constants for rollout in mullvad-update --- installer-downloader/src/controller.rs | 4 ++-- mullvad-update/src/version.rs | 7 +++++++ 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/installer-downloader/src/controller.rs b/installer-downloader/src/controller.rs index ed539547be7d..d2eeb377564a 100644 --- a/installer-downloader/src/controller.rs +++ b/installer-downloader/src/controller.rs @@ -9,7 +9,7 @@ use crate::ui_downloader::{UiAppDownloader, UiAppDownloaderParameters, UiProgres use mullvad_update::{ api::VersionInfoProvider, app::{self, AppDownloader}, - version::{Version, VersionInfo, VersionParameters}, + version::{Version, VersionInfo, VersionParameters, ROLLOUT_ANY_VERSION}, }; use rand::seq::SliceRandom; use std::path::PathBuf; @@ -148,7 +148,7 @@ async fn fetch_app_version_info( let version_params = VersionParameters { architecture, // For the downloader, the rollout version is always preferred - rollout: 0., + rollout: ROLLOUT_ANY_VERSION, // The downloader allows any version lowest_metadata_version: 0, }; diff --git a/mullvad-update/src/version.rs b/mullvad-update/src/version.rs index 80c28c7846a4..8dff8beb59f9 100644 --- a/mullvad-update/src/version.rs +++ b/mullvad-update/src/version.rs @@ -11,6 +11,13 @@ use mullvad_version::PreStableType; use crate::format; +/// Rollout threshold in [VersionParameters] that will accept *any* version (rollout >= 0) +pub const ROLLOUT_ANY_VERSION: f32 = 0.; + +/// Rollout threshold in [VersionParameters] that will accept only fully rolled out versions +/// (rollout = 1) +pub const ROLLOUT_FULLY_ROLLED_OUT_ONLY: f32 = 1.; + /// Query type for [VersionInfo] #[derive(Debug)] pub struct VersionParameters { From 5d6eacfbee0491c0525f5bf400426c2505e6dd9a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20L=C3=B6nnhager?= Date: Mon, 3 Mar 2025 11:40:52 +0100 Subject: [PATCH 069/112] Add docs for ActionMessageHandler --- installer-downloader/src/controller.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/installer-downloader/src/controller.rs b/installer-downloader/src/controller.rs index d2eeb377564a..aa5c93fa8ae8 100644 --- a/installer-downloader/src/controller.rs +++ b/installer-downloader/src/controller.rs @@ -234,6 +234,7 @@ struct ActionMessageHandler< impl> + AppDownloader + 'static> ActionMessageHandler { + /// Run the [ActionMessageHandler] actor until the end of the program/execution async fn run( queue: D::Queue, tx: mpsc::Sender, From 78a1f9c09b78e9e3cb5d77eb893f2d05000a119a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20L=C3=B6nnhager?= Date: Mon, 3 Mar 2025 11:43:22 +0100 Subject: [PATCH 070/112] Add docs for queue_main --- installer-downloader/src/delegate.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/installer-downloader/src/delegate.rs b/installer-downloader/src/delegate.rs index 40d54efc3ff1..bc3e4bf84c54 100644 --- a/installer-downloader/src/delegate.rs +++ b/installer-downloader/src/delegate.rs @@ -110,5 +110,6 @@ pub struct ErrorMessage { /// Schedules actions on the UI thread from other threads pub trait AppDelegateQueue: Send { + /// Schedule action on the UI thread from other threads fn queue_main(&self, callback: F); } From d537aa60ac37ce88910fae68b8906fbd193588e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20L=C3=B6nnhager?= Date: Mon, 3 Mar 2025 11:45:00 +0100 Subject: [PATCH 071/112] Rename log to mullvad-installer.log --- installer-downloader/src/log.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/installer-downloader/src/log.rs b/installer-downloader/src/log.rs index 93ff53870066..5e1cd8c8610e 100644 --- a/installer-downloader/src/log.rs +++ b/installer-downloader/src/log.rs @@ -3,6 +3,8 @@ use fern::Dispatch; use log::LevelFilter; use std::{io, path::PathBuf}; +const LOG_FILENAME: &str = "mullvad-installer.log"; + pub fn init() -> Result<(), fern::InitError> { Dispatch::new() .format(|out, message, record| { @@ -22,5 +24,5 @@ pub fn init() -> Result<(), fern::InitError> { } fn log_path() -> PathBuf { - std::env::temp_dir().join("mullvad-downloader.log") + std::env::temp_dir().join(LOG_FILENAME) } From 6b83bd97f13ac802adfa57a160a92dd34c24aa4f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20L=C3=B6nnhager?= Date: Mon, 3 Mar 2025 11:48:30 +0100 Subject: [PATCH 072/112] Fix grammatical error --- installer-downloader/tests/controller.rs | 2 +- mullvad-update/src/client/api.rs | 2 +- mullvad-update/src/version.rs | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/installer-downloader/tests/controller.rs b/installer-downloader/tests/controller.rs index 3c7c56a3c1e9..0dce2e68e5fb 100644 --- a/installer-downloader/tests/controller.rs +++ b/installer-downloader/tests/controller.rs @@ -4,7 +4,7 @@ //! //! 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`. +//! changes to, and update, snapshots is by running `cargo insta review`. use insta::assert_yaml_snapshot; use installer_downloader::controller::AppController; diff --git a/mullvad-update/src/client/api.rs b/mullvad-update/src/client/api.rs index 90bb3e57459b..37f54c8c62db 100644 --- a/mullvad-update/src/client/api.rs +++ b/mullvad-update/src/client/api.rs @@ -96,7 +96,7 @@ mod test { // These 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`. + // changes to, and update, snapshots is by running `cargo insta review`. /// Test HTTP version info provider /// diff --git a/mullvad-update/src/version.rs b/mullvad-update/src/version.rs index 8dff8beb59f9..7c627d57c285 100644 --- a/mullvad-update/src/version.rs +++ b/mullvad-update/src/version.rs @@ -171,7 +171,7 @@ mod test { // These 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`. + // changes to, and update, snapshots is by running `cargo insta review`. /// Test version info response handler (rollout 1, x86) #[test] From 10ec4379a554eb6153aacb93b0eecb9b74100b42 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20L=C3=B6nnhager?= Date: Mon, 3 Mar 2025 15:05:15 +0100 Subject: [PATCH 073/112] Attempt to explain difference between initialize_controller and AppController::initialize --- installer-downloader/src/controller.rs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/installer-downloader/src/controller.rs b/installer-downloader/src/controller.rs index aa5c93fa8ae8..6cca66e2be49 100644 --- a/installer-downloader/src/controller.rs +++ b/installer-downloader/src/controller.rs @@ -35,6 +35,10 @@ enum TaskMessage { pub struct AppController {} /// Public entry function for registering a [AppDelegate]. +/// +/// This function uses the Mullvad API to fetch the current releases, a hardcoded public key to +/// verify the metadata, and the default HTTP client from `mullvad-update` and stores the files +/// in a temporary directory. pub fn initialize_controller(delegate: &mut T, environment: Environment) { use mullvad_update::{api::HttpVersionInfoProvider, app::HttpAppDownloader}; @@ -76,8 +80,8 @@ fn get_metadata_url() -> String { 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. + /// This function lets the caller provide a version information provider, download client, etc., + /// which is useful for testing. pub fn initialize( delegate: &mut D, version_provider: V, From 234360d9a20919c625aad841321e39267cd5cc31 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20L=C3=B6nnhager?= Date: Mon, 3 Mar 2025 15:07:45 +0100 Subject: [PATCH 074/112] Move imports out of function --- installer-downloader/src/controller.rs | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/installer-downloader/src/controller.rs b/installer-downloader/src/controller.rs index 6cca66e2be49..eaaccb994301 100644 --- a/installer-downloader/src/controller.rs +++ b/installer-downloader/src/controller.rs @@ -7,8 +7,8 @@ use crate::temp::DirectoryProvider; use crate::ui_downloader::{UiAppDownloader, UiAppDownloaderParameters, UiProgressUpdater}; use mullvad_update::{ - api::VersionInfoProvider, - app::{self, AppDownloader}, + api::{HttpVersionInfoProvider, VersionInfoProvider}, + app::{self, AppDownloader, HttpAppDownloader}, version::{Version, VersionInfo, VersionParameters, ROLLOUT_ANY_VERSION}, }; use rand::seq::SliceRandom; @@ -40,8 +40,6 @@ pub struct AppController {} /// verify the metadata, and the default HTTP client from `mullvad-update` and stores the files /// in a temporary directory. pub fn initialize_controller(delegate: &mut T, environment: Environment) { - use mullvad_update::{api::HttpVersionInfoProvider, app::HttpAppDownloader}; - // App downloader to use type Downloader = HttpAppDownloader>; // Directory provider to use From ec33f4aa7085e424032d8220254b8cec9eb585ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20L=C3=B6nnhager?= Date: Mon, 3 Mar 2025 15:12:15 +0100 Subject: [PATCH 075/112] Add new rollout constants --- installer-downloader/src/controller.rs | 4 ++-- mullvad-update/src/version.rs | 18 ++++++++++-------- 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/installer-downloader/src/controller.rs b/installer-downloader/src/controller.rs index eaaccb994301..8bdbacfd5158 100644 --- a/installer-downloader/src/controller.rs +++ b/installer-downloader/src/controller.rs @@ -9,7 +9,7 @@ use crate::ui_downloader::{UiAppDownloader, UiAppDownloaderParameters, UiProgres use mullvad_update::{ api::{HttpVersionInfoProvider, VersionInfoProvider}, app::{self, AppDownloader, HttpAppDownloader}, - version::{Version, VersionInfo, VersionParameters, ROLLOUT_ANY_VERSION}, + version::{Version, VersionInfo, VersionParameters}, }; use rand::seq::SliceRandom; use std::path::PathBuf; @@ -150,7 +150,7 @@ async fn fetch_app_version_info( let version_params = VersionParameters { architecture, // For the downloader, the rollout version is always preferred - rollout: ROLLOUT_ANY_VERSION, + rollout: mullvad_update::version::IGNORE, // The downloader allows any version lowest_metadata_version: 0, }; diff --git a/mullvad-update/src/version.rs b/mullvad-update/src/version.rs index 7c627d57c285..cc5a708397a1 100644 --- a/mullvad-update/src/version.rs +++ b/mullvad-update/src/version.rs @@ -11,25 +11,27 @@ use mullvad_version::PreStableType; use crate::format; -/// Rollout threshold in [VersionParameters] that will accept *any* version (rollout >= 0) -pub const ROLLOUT_ANY_VERSION: f32 = 0.; - -/// Rollout threshold in [VersionParameters] that will accept only fully rolled out versions -/// (rollout = 1) -pub const ROLLOUT_FULLY_ROLLED_OUT_ONLY: f32 = 1.; - /// Query type for [VersionInfo] #[derive(Debug)] pub struct VersionParameters { /// Architecture to retrieve data for pub architecture: VersionArchitecture, /// Rollout threshold. Any version in the response below this threshold will be ignored - pub rollout: f32, + pub rollout: Rollout, /// Lowest allowed `metadata_version` in the version data /// Typically the current version plus 1 pub lowest_metadata_version: usize, } +/// Rollout threshold. Any version in the response below this threshold will be ignored +pub type Rollout = f32; + +/// Accept *any* version (rollout >= 0) when querying for app info. +pub const IGNORE: Rollout = 0.; + +/// Accept only fully rolled out versions (rollout >= 1) when querying for app info. +pub const FULLY_ROLLED_OUT: Rollout = 1.; + /// Installer architecture pub type VersionArchitecture = format::Architecture; From aba4b7a0d9b8ffb96e11eddd8b13b85737e01391 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20L=C3=B6nnhager?= Date: Mon, 3 Mar 2025 15:26:42 +0100 Subject: [PATCH 076/112] Add explicit clear methods to Delegate trait --- .../src/cacao_impl/delegate.rs | 29 +++++++------------ installer-downloader/src/cacao_impl/ui.rs | 19 ++++++++++++ installer-downloader/src/controller.rs | 8 ++--- installer-downloader/src/delegate.rs | 6 ++++ installer-downloader/src/ui_downloader.rs | 12 ++++---- .../src/winapi_impl/delegate.rs | 26 +++++++++-------- installer-downloader/tests/controller.rs | 10 +++++++ .../snapshots/controller__download-2.snap | 2 +- .../snapshots/controller__download-3.snap | 2 +- ...controller__failed_directory_creation.snap | 3 +- .../controller__failed_verification.snap | 7 +++-- 11 files changed, 77 insertions(+), 47 deletions(-) diff --git a/installer-downloader/src/cacao_impl/delegate.rs b/installer-downloader/src/cacao_impl/delegate.rs index cf9d20e1d19b..73c17e7e53b6 100644 --- a/installer-downloader/src/cacao_impl/delegate.rs +++ b/installer-downloader/src/cacao_impl/delegate.rs @@ -1,9 +1,6 @@ use std::sync::{Arc, Mutex}; -use cacao::{ - control::Control, - layout::{Layout, LayoutConstraint}, -}; +use cacao::{control::Control, layout::Layout}; use super::ui::{Action, AppWindow, ErrorView}; use crate::delegate::{AppDelegate, AppDelegateQueue}; @@ -36,24 +33,18 @@ impl AppDelegate for AppWindow { self.status_text.set_text(text); } + fn clear_status_text(&mut self) { + self.status_text.set_text(""); + } + fn set_download_text(&mut self, text: &str) { self.download_text.set_text(text); + self.readjust_status_text(); + } - // If there is a download_text, move status_text up to make room - - let offset = if text.is_empty() { 59.0 } else { 39.0 }; - - if let Some(previous_constraint) = self.status_text_position_y.take() { - LayoutConstraint::deactivate(&[previous_constraint]); - } - - let new_constraint = self - .status_text - .top - .constraint_equal_to(&self.main_view.top) - .offset(offset); - self.status_text_position_y = Some(new_constraint.clone()); - LayoutConstraint::activate(&[new_constraint]); + fn clear_download_text(&mut self) { + self.download_text.set_text(""); + self.readjust_status_text(); } fn show_download_progress(&mut self) { diff --git a/installer-downloader/src/cacao_impl/ui.rs b/installer-downloader/src/cacao_impl/ui.rs index b8f86e425f34..f65dc6ae2a13 100644 --- a/installer-downloader/src/cacao_impl/ui.rs +++ b/installer-downloader/src/cacao_impl/ui.rs @@ -404,6 +404,25 @@ impl AppWindow { .constraint_equal_to(&self.beta_link_preface.center_y), ]); } + + // If there is a download_text, move status_text up to make room + pub fn readjust_status_text(&mut self) { + let text = self.download_text.get_text(); + + let offset = if text.is_empty() { 59.0 } else { 39.0 }; + + if let Some(previous_constraint) = self.status_text_position_y.take() { + LayoutConstraint::deactivate(&[previous_constraint]); + } + + let new_constraint = self + .status_text + .top + .constraint_equal_to(&self.main_view.top) + .offset(offset); + self.status_text_position_y = Some(new_constraint.clone()); + LayoutConstraint::activate(&[new_constraint]); + } } impl WindowDelegate for AppWindowWrapper { diff --git a/installer-downloader/src/controller.rs b/installer-downloader/src/controller.rs index 8bdbacfd5158..a8bbbafa263e 100644 --- a/installer-downloader/src/controller.rs +++ b/installer-downloader/src/controller.rs @@ -179,7 +179,7 @@ async fn fetch_app_version_info( let (retry_tx, cancel_tx) = (action_tx.clone(), action_tx); - self_.set_status_text(""); + self_.clear_status_text(); self_.on_error_message_retry(move || { let _ = retry_tx.try_send(Action::Retry); }); @@ -349,7 +349,7 @@ impl> + AppDownlo log::error!("Failed to create temporary directory: {error:?}"); self.queue.queue_main(move |self_| { - self_.set_status_text(""); + self_.clear_status_text(); self_.hide_download_button(); self_.hide_beta_text(); self_.hide_stable_text(); @@ -382,7 +382,7 @@ impl> + AppDownlo let app_sha256 = selected_version.sha256; let app_size = selected_version.size; - self_.set_download_text(""); + self_.clear_download_text(); self_.hide_download_button(); self_.hide_beta_text(); self_.hide_stable_text(); @@ -431,7 +431,7 @@ impl> + AppDownlo self.queue.queue_main(move |self_| { self_.set_status_text(&version_label); - self_.set_download_text(""); + self_.clear_download_text(); self_.show_download_button(); self_.hide_error_message(); diff --git a/installer-downloader/src/delegate.rs b/installer-downloader/src/delegate.rs index bc3e4bf84c54..a00e33bd13fe 100644 --- a/installer-downloader/src/delegate.rs +++ b/installer-downloader/src/delegate.rs @@ -30,9 +30,15 @@ pub trait AppDelegate { /// Set status text fn set_status_text(&mut self, text: &str); + /// Clear status text + fn clear_status_text(&mut self); + /// Set download text fn set_download_text(&mut self, text: &str); + /// Clear download text + fn clear_download_text(&mut self); + /// Show download progress bar fn show_download_progress(&mut self); diff --git a/installer-downloader/src/ui_downloader.rs b/installer-downloader/src/ui_downloader.rs index 69e405797b0c..acb6c528e595 100644 --- a/installer-downloader/src/ui_downloader.rs +++ b/installer-downloader/src/ui_downloader.rs @@ -49,8 +49,8 @@ impl AppDownl } Err(err) => { self.queue.queue_main(move |self_| { - self_.set_status_text(""); - self_.set_download_text(""); + self_.clear_status_text(); + self_.clear_download_text(); self_.hide_download_progress(); self_.hide_download_button(); self_.hide_cancel_button(); @@ -78,8 +78,8 @@ impl AppDownl } Err(error) => { self.queue.queue_main(move |self_| { - self_.set_status_text(""); - self_.set_download_text(""); + self_.clear_status_text(); + self_.clear_download_text(); self_.hide_download_progress(); self_.hide_download_button(); self_.hide_cancel_button(); @@ -109,8 +109,8 @@ impl AppDownl } Err(error) => { self.queue.queue_main(move |self_| { - self_.set_status_text(""); - self_.set_download_text(""); + self_.clear_status_text(); + self_.clear_download_text(); self_.hide_download_progress(); self_.hide_download_button(); self_.hide_cancel_button(); diff --git a/installer-downloader/src/winapi_impl/delegate.rs b/installer-downloader/src/winapi_impl/delegate.rs index b4b9793bddc6..ce69e818b9cd 100644 --- a/installer-downloader/src/winapi_impl/delegate.rs +++ b/installer-downloader/src/winapi_impl/delegate.rs @@ -40,21 +40,23 @@ impl AppDelegate for AppWindow { } fn set_status_text(&mut self, text: &str) { - if !text.is_empty() { - self.status_text.set_visible(true); - self.status_text.set_text(text); - } else { - self.status_text.set_visible(false); - } + self.status_text.set_visible(true); + self.status_text.set_text(text); + } + + fn clear_status_text(&mut self) { + self.status_text.set_visible(false); + self.status_text.set_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); - } + self.download_text.set_visible(true); + self.download_text.set_text(text); + } + + fn clear_download_text(&mut self) { + self.download_text.set_visible(false); + self.download_text.set_text(""); } fn show_download_progress(&mut self) { diff --git a/installer-downloader/tests/controller.rs b/installer-downloader/tests/controller.rs index 0dce2e68e5fb..f8b26a60e6b7 100644 --- a/installer-downloader/tests/controller.rs +++ b/installer-downloader/tests/controller.rs @@ -234,6 +234,11 @@ impl AppDelegate for FakeAppDelegate { self.state.status_text = text.to_owned(); } + fn clear_status_text(&mut self) { + self.state.call_log.push(format!("clear_status_text")); + self.state.status_text = "".to_owned(); + } + fn set_download_text(&mut self, text: &str) { self.state .call_log @@ -241,6 +246,11 @@ impl AppDelegate for FakeAppDelegate { self.state.download_text = text.to_owned(); } + fn clear_download_text(&mut self) { + self.state.call_log.push(format!("clear_download_text")); + self.state.download_text = "".to_owned(); + } + fn show_download_progress(&mut self) { self.state.call_log.push("show_download_progress".into()); self.state.download_progress_visible = true; diff --git a/installer-downloader/tests/snapshots/controller__download-2.snap b/installer-downloader/tests/snapshots/controller__download-2.snap index ee35e8f8eef7..44bbde015260 100644 --- a/installer-downloader/tests/snapshots/controller__download-2.snap +++ b/installer-downloader/tests/snapshots/controller__download-2.snap @@ -36,7 +36,7 @@ call_log: - hide_error_message - on_error_message_retry - on_error_message_cancel - - "set_download_text: " + - clear_download_text - hide_download_button - hide_beta_text - hide_stable_text diff --git a/installer-downloader/tests/snapshots/controller__download-3.snap b/installer-downloader/tests/snapshots/controller__download-3.snap index 5b40b79b9d85..c995f67c9282 100644 --- a/installer-downloader/tests/snapshots/controller__download-3.snap +++ b/installer-downloader/tests/snapshots/controller__download-3.snap @@ -36,7 +36,7 @@ call_log: - hide_error_message - on_error_message_retry - on_error_message_cancel - - "set_download_text: " + - clear_download_text - hide_download_button - hide_beta_text - hide_stable_text diff --git a/installer-downloader/tests/snapshots/controller__failed_directory_creation.snap b/installer-downloader/tests/snapshots/controller__failed_directory_creation.snap index 575b9a4c5034..505142a91ca0 100644 --- a/installer-downloader/tests/snapshots/controller__failed_directory_creation.snap +++ b/installer-downloader/tests/snapshots/controller__failed_directory_creation.snap @@ -1,6 +1,7 @@ --- source: installer-downloader/tests/controller.rs expression: delegate.state +snapshot_kind: text --- status_text: "" download_text: "" @@ -35,7 +36,7 @@ call_log: - hide_error_message - on_error_message_retry - on_error_message_cancel - - "set_status_text: " + - clear_status_text - hide_download_button - hide_beta_text - hide_stable_text diff --git a/installer-downloader/tests/snapshots/controller__failed_verification.snap b/installer-downloader/tests/snapshots/controller__failed_verification.snap index ed6177dfea55..cd41099d5af3 100644 --- a/installer-downloader/tests/snapshots/controller__failed_verification.snap +++ b/installer-downloader/tests/snapshots/controller__failed_verification.snap @@ -1,6 +1,7 @@ --- source: installer-downloader/tests/controller.rs expression: delegate.state +snapshot_kind: text --- status_text: "" download_text: "" @@ -35,7 +36,7 @@ call_log: - hide_error_message - on_error_message_retry - on_error_message_cancel - - "set_download_text: " + - clear_download_text - hide_download_button - hide_beta_text - hide_stable_text @@ -48,8 +49,8 @@ call_log: - "set_download_text: Downloading from mullvad.net... (100%)" - "set_download_text: Download complete. Verifying..." - disable_cancel_button - - "set_status_text: " - - "set_download_text: " + - clear_status_text + - clear_download_text - hide_download_progress - hide_download_button - hide_cancel_button From 0a5f4da6c53caed17c2b6e2d0ed02587b0a59a7f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20L=C3=B6nnhager?= Date: Mon, 3 Mar 2025 15:28:46 +0100 Subject: [PATCH 077/112] Clarify that UI thread is the main thread --- installer-downloader/src/cacao_impl/delegate.rs | 2 +- installer-downloader/src/controller.rs | 2 +- installer-downloader/src/delegate.rs | 6 +++--- installer-downloader/src/winapi_impl/delegate.rs | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/installer-downloader/src/cacao_impl/delegate.rs b/installer-downloader/src/cacao_impl/delegate.rs index 73c17e7e53b6..645fc1aa63bb 100644 --- a/installer-downloader/src/cacao_impl/delegate.rs +++ b/installer-downloader/src/cacao_impl/delegate.rs @@ -177,7 +177,7 @@ impl AppWindow { } } -/// This simply mutates the UI on the main thread using the GCD +/// This simply mutates the UI on the main thread pub struct Queue {} impl AppDelegateQueue for Queue { diff --git a/installer-downloader/src/controller.rs b/installer-downloader/src/controller.rs index a8bbbafa263e..21931af231c9 100644 --- a/installer-downloader/src/controller.rs +++ b/installer-downloader/src/controller.rs @@ -172,7 +172,7 @@ async fn fetch_app_version_info( let (action_tx, mut action_rx) = mpsc::channel(1); - // show error message (needs to happen on the UI thread) + // show error message (needs to happen on the UI (main) thread) // send Action when user presses a button to continue queue.queue_main(move |self_| { self_.hide_download_button(); diff --git a/installer-downloader/src/delegate.rs b/installer-downloader/src/delegate.rs index a00e33bd13fe..6eff3a4a3745 100644 --- a/installer-downloader/src/delegate.rs +++ b/installer-downloader/src/delegate.rs @@ -103,7 +103,7 @@ pub trait AppDelegate { /// Exit the application fn quit(&mut self); - /// Create queue for scheduling actions on UI thread + /// Create queue for scheduling actions on UI (main) thread fn queue(&self) -> Self::Queue; } @@ -114,8 +114,8 @@ pub struct ErrorMessage { pub retry_button_text: String, } -/// Schedules actions on the UI thread from other threads +/// Schedules actions on the UI (main) thread from other threads pub trait AppDelegateQueue: Send { - /// Schedule action on the UI thread from other threads + /// Schedule action on the UI (main) thread from other threads fn queue_main(&self, callback: F); } diff --git a/installer-downloader/src/winapi_impl/delegate.rs b/installer-downloader/src/winapi_impl/delegate.rs index ce69e818b9cd..3df758b07799 100644 --- a/installer-downloader/src/winapi_impl/delegate.rs +++ b/installer-downloader/src/winapi_impl/delegate.rs @@ -212,7 +212,7 @@ fn register_frame_click_handler(frame: nwg::ControlHandle, callback: impl Fn() + } /// Queue sends a window message to the main window containing a [QueueContext], giving us mutable -/// access to the [AppDelegate] on the main UI thread. +/// access to the [AppDelegate] on the UI (main) thread. /// /// See [QueueContext] docs for more information. #[derive(Clone)] From 587d01d41c8c56cbc8dcb22216379ed5c425cfdc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20L=C3=B6nnhager?= Date: Mon, 3 Mar 2025 15:32:50 +0100 Subject: [PATCH 078/112] Use async_trait for DirectoryProvider --- installer-downloader/src/controller.rs | 2 +- installer-downloader/src/temp.rs | 15 +++++++++------ installer-downloader/tests/controller.rs | 1 + 3 files changed, 11 insertions(+), 7 deletions(-) diff --git a/installer-downloader/src/controller.rs b/installer-downloader/src/controller.rs index 21931af231c9..c679a338ea0e 100644 --- a/installer-downloader/src/controller.rs +++ b/installer-downloader/src/controller.rs @@ -88,7 +88,7 @@ impl AppController { D: AppDelegate + 'static, V: VersionInfoProvider + Send + 'static, A: From> + AppDownloader + 'static, - DirProvider: DirectoryProvider, + DirProvider: DirectoryProvider + 'static, { delegate.hide_download_progress(); delegate.show_download_button(); diff --git a/installer-downloader/src/temp.rs b/installer-downloader/src/temp.rs index 95487e10d114..84bf506dc420 100644 --- a/installer-downloader/src/temp.rs +++ b/installer-downloader/src/temp.rs @@ -14,28 +14,31 @@ //! by the current user. Using a random directory name mitigates this issue. use anyhow::Context; -use std::{future::Future, path::PathBuf}; +use async_trait::async_trait; +use std::path::PathBuf; /// Provide a directory to use for [AppDownloader] -pub trait DirectoryProvider: 'static { +#[async_trait] +pub trait DirectoryProvider { /// Provide a directory to use for [AppDownloader] - fn create_download_dir() -> impl Future> + Send; + async fn create_download_dir() -> anyhow::Result; } /// See [module-level](self) docs. pub struct TempDirProvider; +#[async_trait] impl DirectoryProvider for TempDirProvider { /// Create a locked-down directory to store downloads in - fn create_download_dir() -> impl Future> + Send { + async fn create_download_dir() -> anyhow::Result { #[cfg(windows)] { - admin_temp_dir() + admin_temp_dir().await } #[cfg(target_os = "macos")] { - temp_dir() + temp_dir().await } } } diff --git a/installer-downloader/tests/controller.rs b/installer-downloader/tests/controller.rs index f8b26a60e6b7..031d5c153d9c 100644 --- a/installer-downloader/tests/controller.rs +++ b/installer-downloader/tests/controller.rs @@ -48,6 +48,7 @@ impl VersionInfoProvider for FakeVersionInfoProvider { pub struct FakeDirectoryProvider {} +#[async_trait::async_trait] impl DirectoryProvider for FakeDirectoryProvider { async fn create_download_dir() -> anyhow::Result { if SUCCEEDED { From ab3251ffabafc5ce965d5cafb4dd6c2bcef951e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20L=C3=B6nnhager?= Date: Mon, 3 Mar 2025 15:35:55 +0100 Subject: [PATCH 079/112] Improve docs for temp module --- installer-downloader/src/temp.rs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/installer-downloader/src/temp.rs b/installer-downloader/src/temp.rs index 84bf506dc420..257465d5d09f 100644 --- a/installer-downloader/src/temp.rs +++ b/installer-downloader/src/temp.rs @@ -5,6 +5,9 @@ //! Since the Windows downloader runs as admin, we can use a persistent directory and prevent //! non-admins from accessing it. //! +//! The directory is created before being restricted, but this is fine as long as the checksum is +//! verified before launching the app. +//! //! # macOS //! //! The downloader does not run as a privileged user, so we store downloads in a temporary @@ -45,8 +48,7 @@ impl DirectoryProvider for TempDirProvider { /// This returns a directory where only admins have write access. /// -/// This function is a bit racey, as the directory is created before being restricted. -/// This is acceptable as long as the checksum of each file is verified before being used. +/// See [module-level](self) docs for more information. #[cfg(windows)] async fn admin_temp_dir() -> anyhow::Result { /// Name of subdirectory in the temp directory @@ -65,6 +67,9 @@ async fn admin_temp_dir() -> anyhow::Result { Ok(temp_dir) } +/// This returns a temporary directory for storing the downloaded app. +/// +/// See [module-level](self) docs for more information. #[cfg(target_os = "macos")] async fn temp_dir() -> anyhow::Result { use rand::{distributions::Alphanumeric, Rng}; From a0e7b804164a464c94fb0c7390575a021fdffe7d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20L=C3=B6nnhager?= Date: Mon, 3 Mar 2025 15:42:05 +0100 Subject: [PATCH 080/112] Include error message text in snapshots --- installer-downloader/tests/controller.rs | 7 +++++-- .../snapshots/controller__failed_directory_creation.snap | 2 +- .../tests/snapshots/controller__failed_verification.snap | 2 +- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/installer-downloader/tests/controller.rs b/installer-downloader/tests/controller.rs index 031d5c153d9c..e2325adcb9cd 100644 --- a/installer-downloader/tests/controller.rs +++ b/installer-downloader/tests/controller.rs @@ -330,7 +330,10 @@ impl AppDelegate for FakeAppDelegate { } fn show_error_message(&mut self, message: ErrorMessage) { - self.state.call_log.push("show_error_message".into()); + self.state.call_log.push(format!( + "show_error_message: {}. retry: {}. cancel: {}", + message.status_text, message.retry_button_text, message.cancel_button_text + )); self.state.error_message = message; self.state.error_message_visible = true; } @@ -508,6 +511,6 @@ async fn test_failed_directory_creation() { let queue = delegate.queue.clone(); queue.run_callbacks(&mut delegate); - // Verification failed + // "Download failed" assert_yaml_snapshot!(delegate.state); } diff --git a/installer-downloader/tests/snapshots/controller__failed_directory_creation.snap b/installer-downloader/tests/snapshots/controller__failed_directory_creation.snap index 505142a91ca0..e38c8bc3f23c 100644 --- a/installer-downloader/tests/snapshots/controller__failed_directory_creation.snap +++ b/installer-downloader/tests/snapshots/controller__failed_directory_creation.snap @@ -40,4 +40,4 @@ call_log: - hide_download_button - hide_beta_text - hide_stable_text - - show_error_message + - "show_error_message: Download failed, please check your internet connection or if you have enough space on your hard drive and try downloading again.. retry: Try again. cancel: Cancel" diff --git a/installer-downloader/tests/snapshots/controller__failed_verification.snap b/installer-downloader/tests/snapshots/controller__failed_verification.snap index cd41099d5af3..3269280f9484 100644 --- a/installer-downloader/tests/snapshots/controller__failed_verification.snap +++ b/installer-downloader/tests/snapshots/controller__failed_verification.snap @@ -54,4 +54,4 @@ call_log: - hide_download_progress - hide_download_button - hide_cancel_button - - show_error_message + - "show_error_message: Failed to verify download, please try downloading again or contact our support by sending an email to support@mullvadvpn.net with a description of what happened.. retry: Try again. cancel: Cancel" From 47ef9dea7087f8e89a840dbf70faf5b9877fe42e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20L=C3=B6nnhager?= Date: Mon, 3 Mar 2025 15:56:34 +0100 Subject: [PATCH 081/112] Add clear_progress --- .../src/cacao_impl/delegate.rs | 4 ++ installer-downloader/src/controller.rs | 2 +- installer-downloader/src/delegate.rs | 3 ++ installer-downloader/src/ui_downloader.rs | 47 +++++++++++++++---- .../src/winapi_impl/delegate.rs | 4 ++ installer-downloader/tests/controller.rs | 7 ++- .../snapshots/controller__download-3.snap | 2 +- .../controller__failed_verification.snap | 2 +- mullvad-update/src/client/fetch.rs | 7 +++ 9 files changed, 65 insertions(+), 13 deletions(-) diff --git a/installer-downloader/src/cacao_impl/delegate.rs b/installer-downloader/src/cacao_impl/delegate.rs index 645fc1aa63bb..c7dc9d3476c8 100644 --- a/installer-downloader/src/cacao_impl/delegate.rs +++ b/installer-downloader/src/cacao_impl/delegate.rs @@ -59,6 +59,10 @@ impl AppDelegate for AppWindow { self.progress.set_value(complete as f64); } + fn clear_download_progress(&mut self) { + self.progress.set_value(0.); + } + fn show_download_button(&mut self) { self.download_button.set_hidden(false); } diff --git a/installer-downloader/src/controller.rs b/installer-downloader/src/controller.rs index c679a338ea0e..94c15de36d65 100644 --- a/installer-downloader/src/controller.rs +++ b/installer-downloader/src/controller.rs @@ -445,7 +445,7 @@ impl> + AppDownlo self_.hide_cancel_button(); self_.hide_download_progress(); - self_.set_download_progress(0); + self_.clear_download_progress(); }); } } diff --git a/installer-downloader/src/delegate.rs b/installer-downloader/src/delegate.rs index 6eff3a4a3745..17182bd0d137 100644 --- a/installer-downloader/src/delegate.rs +++ b/installer-downloader/src/delegate.rs @@ -48,6 +48,9 @@ pub trait AppDelegate { /// Update download progress bar fn set_download_progress(&mut self, complete: u32); + /// Clear download progress + fn clear_download_progress(&mut self); + /// Enable download button fn enable_download_button(&mut self); diff --git a/installer-downloader/src/ui_downloader.rs b/installer-downloader/src/ui_downloader.rs index acb6c528e595..9c97b17b8ac1 100644 --- a/installer-downloader/src/ui_downloader.rs +++ b/installer-downloader/src/ui_downloader.rs @@ -143,29 +143,58 @@ impl UiProgressUpdater { queue, } } + + fn need_update(&mut self, complete: u32) -> bool { + if self.prev_progress == Some(complete) { + // Unconditionally updating causes flickering + return false; + } + self.prev_progress = Some(complete); + true + } + + fn complete_from_percentage(fraction_complete: f32) -> u32 { + (100.0 * fraction_complete).min(100.0) as u32 + } + + fn status_text(&self, complete_percentage: u32) -> String { + format!( + "{} {}... ({complete_percentage}%)", + resource::DOWNLOADING_DESC_PREFIX, + self.domain + ) + } } impl fetch::ProgressUpdater for UiProgressUpdater { fn set_progress(&mut self, fraction_complete: f32) { - let value = (100.0 * fraction_complete).min(100.0) as u32; + let value = Self::complete_from_percentage(fraction_complete); - if self.prev_progress == Some(value) { - // Unconditionally updating causes flickering + if !self.need_update(value) { return; } - let status = format!( - "{} {}... ({value}%)", - resource::DOWNLOADING_DESC_PREFIX, - self.domain - ); + let status = self.status_text(value); self.queue.queue_main(move |self_| { self_.set_download_progress(value); self_.set_download_text(&status); }); + } - self.prev_progress = Some(value); + fn clear_progress(&mut self) { + let value = 0; + + if !self.need_update(value) { + return; + } + + let status = self.status_text(value); + + self.queue.queue_main(move |self_| { + self_.clear_download_progress(); + self_.set_download_text(&status); + }); } fn set_url(&mut self, url: &str) { diff --git a/installer-downloader/src/winapi_impl/delegate.rs b/installer-downloader/src/winapi_impl/delegate.rs index 3df758b07799..1312f5aec901 100644 --- a/installer-downloader/src/winapi_impl/delegate.rs +++ b/installer-downloader/src/winapi_impl/delegate.rs @@ -71,6 +71,10 @@ impl AppDelegate for AppWindow { self.progress_bar.set_pos(complete); } + fn clear_download_progress(&mut self) { + self.progress_bar.set_pos(0); + } + fn show_download_button(&mut self) { self.download_button.set_visible(true); } diff --git a/installer-downloader/tests/controller.rs b/installer-downloader/tests/controller.rs index e2325adcb9cd..6eb9e727d061 100644 --- a/installer-downloader/tests/controller.rs +++ b/installer-downloader/tests/controller.rs @@ -96,7 +96,7 @@ impl Result<(), DownloadError> { self.params.app_progress.set_url(&self.params.app_url); - self.params.app_progress.set_progress(0.); + self.params.app_progress.clear_progress(); if EXE_SUCCEED { self.params.app_progress.set_progress(1.); Ok(()) @@ -269,6 +269,11 @@ impl AppDelegate for FakeAppDelegate { self.state.download_progress = complete; } + fn clear_download_progress(&mut self) { + self.state.call_log.push(format!("clear_download_progress")); + self.state.download_progress = 0; + } + fn show_download_button(&mut self) { self.state.call_log.push("show_download_button".into()); self.state.download_button_visible = true; diff --git a/installer-downloader/tests/snapshots/controller__download-3.snap b/installer-downloader/tests/snapshots/controller__download-3.snap index c995f67c9282..f7c952b191fa 100644 --- a/installer-downloader/tests/snapshots/controller__download-3.snap +++ b/installer-downloader/tests/snapshots/controller__download-3.snap @@ -43,7 +43,7 @@ call_log: - show_cancel_button - enable_cancel_button - show_download_progress - - "set_download_progress: 0" + - clear_download_progress - "set_download_text: Downloading from mullvad.net... (0%)" - "set_download_progress: 100" - "set_download_text: Downloading from mullvad.net... (100%)" diff --git a/installer-downloader/tests/snapshots/controller__failed_verification.snap b/installer-downloader/tests/snapshots/controller__failed_verification.snap index 3269280f9484..0f672c029b07 100644 --- a/installer-downloader/tests/snapshots/controller__failed_verification.snap +++ b/installer-downloader/tests/snapshots/controller__failed_verification.snap @@ -43,7 +43,7 @@ call_log: - show_cancel_button - enable_cancel_button - show_download_progress - - "set_download_progress: 0" + - clear_download_progress - "set_download_text: Downloading from mullvad.net... (0%)" - "set_download_progress: 100" - "set_download_text: Downloading from mullvad.net... (100%)" diff --git a/mullvad-update/src/client/fetch.rs b/mullvad-update/src/client/fetch.rs index db0a64c4cf26..2a66fc68bc70 100644 --- a/mullvad-update/src/client/fetch.rs +++ b/mullvad-update/src/client/fetch.rs @@ -19,6 +19,9 @@ pub trait ProgressUpdater: Send + 'static { /// Progress so far fn set_progress(&mut self, fraction_complete: f32); + /// Clear progress so far + fn clear_progress(&mut self); + /// URL that is being downloaded fn set_url(&mut self, url: &str); } @@ -312,6 +315,10 @@ mod test { self.complete = fraction_complete; } + fn clear_progress(&mut self) { + self.complete = 0.; + } + fn set_url(&mut self, url: &str) { self.url = url.to_owned(); } From 838566717d3a923102a45fa9d7b581034769825b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20L=C3=B6nnhager?= Date: Mon, 3 Mar 2025 16:06:37 +0100 Subject: [PATCH 082/112] Add safety comment for NSColor instantiation --- installer-downloader/src/cacao_impl/ui.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/installer-downloader/src/cacao_impl/ui.rs b/installer-downloader/src/cacao_impl/ui.rs index f65dc6ae2a13..1e031761e8d8 100644 --- a/installer-downloader/src/cacao_impl/ui.rs +++ b/installer-downloader/src/cacao_impl/ui.rs @@ -42,6 +42,9 @@ static BANNER_COLOR: LazyLock = LazyLock::new(|| { // Maybe using calibrated colors is more correct? Rendering different colors *definitely* // is not. let id = + // SAFETY: This function returns a pointer to a refcounted NSColor instance, and panics if + // a null pointer is passed. + // See https://developer.apple.com/documentation/appkit/nscolor/init(red:green:blue:alpha:)?language=objc unsafe { Id::from_retained_ptr(msg_send![class!(NSColor), colorWithRed:r green:g blue:b alpha:a]) }; Color::Custom(Arc::new(RwLock::new(id))) }); From b9ce64d1e7d27e4119eafb15b17a1b4bfdc7404e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20L=C3=B6nnhager?= Date: Mon, 3 Mar 2025 16:09:08 +0100 Subject: [PATCH 083/112] Remove TODO comment --- installer-downloader/src/cacao_impl/ui.rs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/installer-downloader/src/cacao_impl/ui.rs b/installer-downloader/src/cacao_impl/ui.rs index 1e031761e8d8..b848125d02f8 100644 --- a/installer-downloader/src/cacao_impl/ui.rs +++ b/installer-downloader/src/cacao_impl/ui.rs @@ -118,9 +118,7 @@ impl Dispatcher for AppImpl { } } - fn on_background_message(&self, _message: Self::Message) { - // TODO - } + fn on_background_message(&self, _message: Self::Message) {} } #[derive(Default)] From df0dd166e5aceb29ecb1ae7460972f715db0d8c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20L=C3=B6nnhager?= Date: Mon, 3 Mar 2025 16:35:04 +0100 Subject: [PATCH 084/112] Move fake implementations of updater/downloader traits to own module --- installer-downloader/tests/controller.rs | 368 +---------------------- installer-downloader/tests/mock.rs | 365 ++++++++++++++++++++++ 2 files changed, 370 insertions(+), 363 deletions(-) create mode 100644 installer-downloader/tests/mock.rs diff --git a/installer-downloader/tests/controller.rs b/installer-downloader/tests/controller.rs index 6eb9e727d061..dcc571ea72ba 100644 --- a/installer-downloader/tests/controller.rs +++ b/installer-downloader/tests/controller.rs @@ -8,371 +8,13 @@ use insta::assert_yaml_snapshot; use installer_downloader::controller::AppController; -use installer_downloader::delegate::{AppDelegate, AppDelegateQueue, ErrorMessage}; -use installer_downloader::environment::{Architecture, Environment}; -use installer_downloader::temp::DirectoryProvider; -use installer_downloader::ui_downloader::UiAppDownloaderParameters; -use mullvad_update::api::VersionInfoProvider; -use mullvad_update::app::{AppDownloader, DownloadError}; -use mullvad_update::fetch::ProgressUpdater; -use mullvad_update::version::{Version, VersionInfo, VersionParameters}; -use std::io; -use std::path::{Path, PathBuf}; -use std::sync::{Arc, LazyLock, Mutex}; -use std::time::Duration; -use std::vec::Vec; - -pub struct FakeVersionInfoProvider {} - -static FAKE_VERSION: LazyLock = LazyLock::new(|| VersionInfo { - stable: Version { - version: "2025.1".parse().unwrap(), - urls: vec!["https://mullvad.net/fakeapp".to_owned()], - size: 1234, - changelog: "a changelog".to_owned(), - sha256: [0u8; 32], - }, - beta: None, -}); - -const FAKE_ENVIRONMENT: Environment = Environment { - architecture: Architecture::X86, +use mock::{ + FakeAppDelegate, FakeAppDownloaderHappyPath, FakeAppDownloaderVerifyFail, + FakeDirectoryProvider, FakeVersionInfoProvider, FAKE_ENVIRONMENT, }; +use std::time::Duration; -#[async_trait::async_trait] -impl VersionInfoProvider for FakeVersionInfoProvider { - async fn get_version_info(&self, _params: VersionParameters) -> anyhow::Result { - Ok(FAKE_VERSION.clone()) - } -} - -pub struct FakeDirectoryProvider {} - -#[async_trait::async_trait] -impl DirectoryProvider for FakeDirectoryProvider { - async fn create_download_dir() -> anyhow::Result { - if SUCCEEDED { - Ok(Path::new("/tmp/fake").to_owned()) - } else { - anyhow::bail!("Failed to create directory"); - } - } -} - -/// Downloader for which all steps immediately succeed -pub type FakeAppDownloaderHappyPath = FakeAppDownloader; - -/// Downloader for which the download step fails -pub type FakeAppDownloaderDownloadFail = FakeAppDownloader; - -/// Downloader for which the verification step fails -pub type FakeAppDownloaderVerifyFail = FakeAppDownloader; - -impl From> - for FakeAppDownloader -{ - fn from(params: UiAppDownloaderParameters) -> Self { - FakeAppDownloader { params } - } -} - -/// Fake app downloader -/// -/// Parameters: -/// * EXE_SUCCEED - whether fetching the binary succeeds -/// * VERIFY_SUCCEED - whether verifying the binary succeeds -/// * LAUNCH_SUCCEED - whether launching the binary succeeds -pub struct FakeAppDownloader< - const EXE_SUCCEED: bool, - const VERIFY_SUCCEED: bool, - const LAUNCH_SUCCEED: bool, -> { - params: UiAppDownloaderParameters, -} - -#[async_trait::async_trait] -impl AppDownloader - for FakeAppDownloader -{ - async fn download_executable(&mut self) -> Result<(), DownloadError> { - self.params.app_progress.set_url(&self.params.app_url); - self.params.app_progress.clear_progress(); - 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" - ))) - } - } - - async fn install(&mut self) -> Result<(), DownloadError> { - if LAUNCH_SUCCEED { - Ok(()) - } else { - Err(DownloadError::InstallFailed(io::Error::other( - "install failed", - ))) - } - } -} - -/// A fake queue that stores callbacks so that tests can run them later. -#[derive(Clone, Default)] -pub struct FakeQueue { - callbacks: Arc>>, -} - -pub type MainThreadCallback = Box; - -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 for FakeQueue { - fn queue_main(&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>, - /// Callback registered by `on_cancel` - pub cancel_callback: Option>, - /// Callback registered by `on_beta_link` - pub beta_callback: Option>, - /// Callback registered by `on_stable_link` - pub stable_callback: Option>, - /// Callback registered by `on_error_cancel` - pub error_cancel_callback: Option>, - /// Callback registered by `on_error_retry` - pub error_retry_callback: Option>, - /// 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, - pub stable_text_visible: bool, - pub error_message_visible: bool, - pub error_message: ErrorMessage, - pub quit: bool, - /// Record of method calls. - pub call_log: Vec, -} - -impl AppDelegate for FakeAppDelegate { - type Queue = FakeQueue; - - fn on_download(&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(&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 on_beta_link(&mut self, callback: F) - where - F: Fn() + Send + 'static, - { - self.state.call_log.push("on_beta_link".into()); - self.beta_callback = Some(Box::new(callback)); - } - - fn on_stable_link(&mut self, callback: F) - where - F: Fn() + Send + 'static, - { - self.state.call_log.push("on_stable_link".into()); - self.stable_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 clear_status_text(&mut self) { - self.state.call_log.push(format!("clear_status_text")); - self.state.status_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 clear_download_text(&mut self) { - self.state.call_log.push(format!("clear_download_text")); - self.state.download_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 clear_download_progress(&mut self) { - self.state.call_log.push(format!("clear_download_progress")); - self.state.download_progress = 0; - } - - 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 show_stable_text(&mut self) { - self.state.call_log.push("show_stable_text".into()); - self.state.stable_text_visible = true; - } - - fn hide_stable_text(&mut self) { - self.state.call_log.push("hide_stable_text".into()); - self.state.stable_text_visible = false; - } - - fn show_error_message(&mut self, message: ErrorMessage) { - self.state.call_log.push(format!( - "show_error_message: {}. retry: {}. cancel: {}", - message.status_text, message.retry_button_text, message.cancel_button_text - )); - self.state.error_message = message; - self.state.error_message_visible = true; - } - - fn hide_error_message(&mut self) { - self.state.call_log.push("hide_error_message".into()); - self.state.error_message_visible = false; - } - - fn on_error_message_cancel(&mut self, callback: F) - where - F: Fn() + Send + 'static, - { - self.state.call_log.push("on_error_message_cancel".into()); - self.error_cancel_callback = Some(Box::new(callback)); - } - - fn on_error_message_retry(&mut self, callback: F) - where - F: Fn() + Send + 'static, - { - self.state.call_log.push("on_error_message_retry".into()); - self.error_retry_callback = Some(Box::new(callback)); - } - - fn quit(&mut self) { - self.state.call_log.push("quit".into()); - self.state.quit = true; - } - - fn queue(&self) -> Self::Queue { - self.queue.clone() - } -} +mod mock; /// Test that the flow starts by fetching app version data #[tokio::test(start_paused = true)] diff --git a/installer-downloader/tests/mock.rs b/installer-downloader/tests/mock.rs new file mode 100644 index 000000000000..5e4a08d44bb3 --- /dev/null +++ b/installer-downloader/tests/mock.rs @@ -0,0 +1,365 @@ +#![cfg(any(target_os = "windows", target_os = "macos"))] + +//! This module contains fake/mock implementations of different updater/installer traits + +use installer_downloader::delegate::{AppDelegate, AppDelegateQueue, ErrorMessage}; +use installer_downloader::environment::{Architecture, Environment}; +use installer_downloader::temp::DirectoryProvider; +use installer_downloader::ui_downloader::UiAppDownloaderParameters; +use mullvad_update::api::VersionInfoProvider; +use mullvad_update::app::{AppDownloader, DownloadError}; +use mullvad_update::fetch::ProgressUpdater; +use mullvad_update::version::{Version, VersionInfo, VersionParameters}; +use std::io; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, LazyLock, Mutex}; +use std::vec::Vec; + +pub struct FakeVersionInfoProvider {} + +pub static FAKE_VERSION: LazyLock = LazyLock::new(|| VersionInfo { + stable: Version { + version: "2025.1".parse().unwrap(), + urls: vec!["https://mullvad.net/fakeapp".to_owned()], + size: 1234, + changelog: "a changelog".to_owned(), + sha256: [0u8; 32], + }, + beta: None, +}); + +pub const FAKE_ENVIRONMENT: Environment = Environment { + architecture: Architecture::X86, +}; + +#[async_trait::async_trait] +impl VersionInfoProvider for FakeVersionInfoProvider { + async fn get_version_info(&self, _params: VersionParameters) -> anyhow::Result { + Ok(FAKE_VERSION.clone()) + } +} + +pub struct FakeDirectoryProvider {} + +#[async_trait::async_trait] +impl DirectoryProvider for FakeDirectoryProvider { + async fn create_download_dir() -> anyhow::Result { + if SUCCEEDED { + Ok(Path::new("/tmp/fake").to_owned()) + } else { + anyhow::bail!("Failed to create directory"); + } + } +} + +/// Downloader for which all steps immediately succeed +pub type FakeAppDownloaderHappyPath = FakeAppDownloader; + +/// Downloader for which the verification step fails +pub type FakeAppDownloaderVerifyFail = FakeAppDownloader; + +impl From> + for FakeAppDownloader +{ + fn from(params: UiAppDownloaderParameters) -> Self { + FakeAppDownloader { params } + } +} + +/// Fake app downloader +/// +/// Parameters: +/// * EXE_SUCCEED - whether fetching the binary succeeds +/// * VERIFY_SUCCEED - whether verifying the binary succeeds +/// * LAUNCH_SUCCEED - whether launching the binary succeeds +pub struct FakeAppDownloader< + const EXE_SUCCEED: bool, + const VERIFY_SUCCEED: bool, + const LAUNCH_SUCCEED: bool, +> { + params: UiAppDownloaderParameters, +} + +#[async_trait::async_trait] +impl AppDownloader + for FakeAppDownloader +{ + async fn download_executable(&mut self) -> Result<(), DownloadError> { + self.params.app_progress.set_url(&self.params.app_url); + self.params.app_progress.clear_progress(); + 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" + ))) + } + } + + async fn install(&mut self) -> Result<(), DownloadError> { + if LAUNCH_SUCCEED { + Ok(()) + } else { + Err(DownloadError::InstallFailed(io::Error::other( + "install failed", + ))) + } + } +} + +/// A fake queue that stores callbacks so that tests can run them later. +#[derive(Clone, Default)] +pub struct FakeQueue { + callbacks: Arc>>, +} + +pub type MainThreadCallback = Box; + +impl FakeQueue { + /// Run all queued callbacks on the given delegate. + pub fn run_callbacks(&self, delegate: &mut FakeAppDelegate) { + let mut callbacks = self.callbacks.lock().unwrap(); + for cb in callbacks.drain(..) { + cb(delegate); + } + } +} + +impl AppDelegateQueue for FakeQueue { + fn queue_main(&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>, + /// Callback registered by `on_cancel` + pub cancel_callback: Option>, + /// Callback registered by `on_beta_link` + pub beta_callback: Option>, + /// Callback registered by `on_stable_link` + pub stable_callback: Option>, + /// Callback registered by `on_error_cancel` + pub error_cancel_callback: Option>, + /// Callback registered by `on_error_retry` + pub error_retry_callback: Option>, + /// 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, + pub stable_text_visible: bool, + pub error_message_visible: bool, + pub error_message: ErrorMessage, + pub quit: bool, + /// Record of method calls. + pub call_log: Vec, +} + +impl AppDelegate for FakeAppDelegate { + type Queue = FakeQueue; + + fn on_download(&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(&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 on_beta_link(&mut self, callback: F) + where + F: Fn() + Send + 'static, + { + self.state.call_log.push("on_beta_link".into()); + self.beta_callback = Some(Box::new(callback)); + } + + fn on_stable_link(&mut self, callback: F) + where + F: Fn() + Send + 'static, + { + self.state.call_log.push("on_stable_link".into()); + self.stable_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 clear_status_text(&mut self) { + self.state.call_log.push("clear_status_text".into()); + self.state.status_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 clear_download_text(&mut self) { + self.state.call_log.push("clear_download_text".into()); + self.state.download_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 clear_download_progress(&mut self) { + self.state.call_log.push("clear_download_progress".into()); + self.state.download_progress = 0; + } + + 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 show_stable_text(&mut self) { + self.state.call_log.push("show_stable_text".into()); + self.state.stable_text_visible = true; + } + + fn hide_stable_text(&mut self) { + self.state.call_log.push("hide_stable_text".into()); + self.state.stable_text_visible = false; + } + + fn show_error_message(&mut self, message: ErrorMessage) { + self.state.call_log.push(format!( + "show_error_message: {}. retry: {}. cancel: {}", + message.status_text, message.retry_button_text, message.cancel_button_text + )); + self.state.error_message = message; + self.state.error_message_visible = true; + } + + fn hide_error_message(&mut self) { + self.state.call_log.push("hide_error_message".into()); + self.state.error_message_visible = false; + } + + fn on_error_message_cancel(&mut self, callback: F) + where + F: Fn() + Send + 'static, + { + self.state.call_log.push("on_error_message_cancel".into()); + self.error_cancel_callback = Some(Box::new(callback)); + } + + fn on_error_message_retry(&mut self, callback: F) + where + F: Fn() + Send + 'static, + { + self.state.call_log.push("on_error_message_retry".into()); + self.error_retry_callback = Some(Box::new(callback)); + } + + fn quit(&mut self) { + self.state.call_log.push("quit".into()); + self.state.quit = true; + } + + fn queue(&self) -> Self::Queue { + self.queue.clone() + } +} From 58575478f331b4082adfaeb0731245b9a89ecead Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20L=C3=B6nnhager?= Date: Mon, 3 Mar 2025 16:37:21 +0100 Subject: [PATCH 085/112] Note that releases must be sorted by version number --- mullvad-update/src/version.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mullvad-update/src/version.rs b/mullvad-update/src/version.rs index cc5a708397a1..686ff62def03 100644 --- a/mullvad-update/src/version.rs +++ b/mullvad-update/src/version.rs @@ -136,7 +136,7 @@ impl VersionInfo { /// Returns the first duplicated version found in `releases`. /// `None` is returned if there are no duplicates. - /// NOTE: `releases` MUST be sorted + /// NOTE: `releases` MUST be sorted on the version number fn find_duplicate_version(releases: &[format::Release]) -> Option<&mullvad_version::Version> { releases .windows(2) From 8ae3b64a9b09aa762bc9cecc950949f1e7a423de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20L=C3=B6nnhager?= Date: Mon, 3 Mar 2025 16:47:17 +0100 Subject: [PATCH 086/112] Move size hint check to method --- mullvad-update/src/client/fetch.rs | 36 ++++++++++++++++-------------- 1 file changed, 19 insertions(+), 17 deletions(-) diff --git a/mullvad-update/src/client/fetch.rs b/mullvad-update/src/client/fetch.rs index 2a66fc68bc70..d9de50a55d56 100644 --- a/mullvad-update/src/client/fetch.rs +++ b/mullvad-update/src/client/fetch.rs @@ -35,6 +35,24 @@ pub enum SizeHint { Maximum(usize), } +impl SizeHint { + /// This function succeeds if `actual` is allowed according to the [SizeHint]. Otherwise, it + /// returns an error. + fn check_size(&self, actual: usize) -> anyhow::Result<()> { + match *self { + 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(()), + } + } +} + /// 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. /// @@ -84,7 +102,7 @@ pub async fn get_to_writer( .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)?; + size_hint.check_size(total_size)?; let already_fetched_bytes = writer .stream_position() @@ -145,22 +163,6 @@ pub async fn get_to_writer( 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) -> io::Result { match fs::File::create_new(&path).await { From bb47a0e6bad55ad70c17c2f841f0c617ea8c430a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20L=C3=B6nnhager?= Date: Tue, 4 Mar 2025 09:14:41 +0100 Subject: [PATCH 087/112] Compute position of error view button --- installer-downloader/src/winapi_impl/ui.rs | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/installer-downloader/src/winapi_impl/ui.rs b/installer-downloader/src/winapi_impl/ui.rs index 1f542fd161b1..a59d499fb25e 100644 --- a/installer-downloader/src/winapi_impl/ui.rs +++ b/installer-downloader/src/winapi_impl/ui.rs @@ -105,19 +105,18 @@ impl ErrorView { .position((34, 49)) .build(&mut self.error_icon)?; - // TODO: put buttons 24px below bottom edge of text label - let text_bottom_y = 96; // TODO - let button_top_y = text_bottom_y + 24; + let button_y = + self.error_text.position().1 + i32::try_from(self.error_text.size().1).unwrap() + 11; nwg::Button::builder() .parent(&self.error_frame) - .position((304, button_top_y)) + .position((304, button_y)) .size((232, 32)) .build(&mut self.error_cancel_button)?; nwg::Button::builder() .parent(&self.error_frame) - .position((64, button_top_y)) + .position((64, button_y)) .size((232, 32)) .build(&mut self.error_retry_button)?; From 60cace55d7aa7605ba48c41b0741415f73f0a8d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20L=C3=B6nnhager?= Date: Tue, 4 Mar 2025 09:14:52 +0100 Subject: [PATCH 088/112] Remove TODO about Segoe font --- installer-downloader/src/winapi_impl/ui.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/installer-downloader/src/winapi_impl/ui.rs b/installer-downloader/src/winapi_impl/ui.rs index a59d499fb25e..72ba3d52e8a9 100644 --- a/installer-downloader/src/winapi_impl/ui.rs +++ b/installer-downloader/src/winapi_impl/ui.rs @@ -214,7 +214,6 @@ impl AppWindow { .build(&mut self.stable_message_frame)?; nwg::Font::builder() - // TODO: Ensure font always exists .family("Segoe Fluent Icons") .size(10) .build(&mut self.arrow_font)?; From fe255f09ac6c5f9599b6c4447a722f062f2773cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20L=C3=B6nnhager?= Date: Tue, 4 Mar 2025 09:16:27 +0100 Subject: [PATCH 089/112] Improve safety documentation --- installer-downloader/src/winapi_impl/delegate.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/installer-downloader/src/winapi_impl/delegate.rs b/installer-downloader/src/winapi_impl/delegate.rs index 1312f5aec901..08f5292cade8 100644 --- a/installer-downloader/src/winapi_impl/delegate.rs +++ b/installer-downloader/src/winapi_impl/delegate.rs @@ -224,7 +224,8 @@ pub struct Queue { main_wnd: nwg::ControlHandle, } -// SAFETY: It is safe to post window messages across threads +// SAFETY: It is safe to send HWND and HMENU handles across threads, particularly since we're always +// using them on the main UI thread. unsafe impl Send for Queue {} /// The context contains a callback function that is passed as a pointer to the main thread From 11044086755045a3f05a6edbba082b162a45c543 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20L=C3=B6nnhager?= Date: Tue, 4 Mar 2025 09:18:01 +0100 Subject: [PATCH 090/112] Remove PGP code signing key --- mullvad-update/mullvad-code-signing.gpg | Bin 3831 -> 0 bytes 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 mullvad-update/mullvad-code-signing.gpg diff --git a/mullvad-update/mullvad-code-signing.gpg b/mullvad-update/mullvad-code-signing.gpg deleted file mode 100644 index 54fa40bc2721a7681e4f043eb28d4972979ab6cd..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 3831 zcmVuRX#ifu#s>(d64+0IH_aFtuxDxngz3bURf zC0wWE0_P|b!gpchEmI+sC8F->kcV1_M&rT2KvGz2n%e`hDXstr@ zyXmWKt=4?4Cg@z(ZWINmRa8bJbGdf-k)2K+*1Wj<6?&hZd5G5PRa1`=??g+W&Fx4w zYC)s7IAEO9Im|Q>i2mY@HwQX8X}T!;+|1})i?-(~-5b#!ldXH7z5|BKP|Wtp?IE@8 zq=?SBms49R#!hd*y*{&b)5VP65;vwueuQQvK9M*cnNHZV{oS-wpF2SDA5{{oFo~JQ z*$K5NVxl%d{Eh!^L;C}YZ-AbhC*do>ZXAB6Hx9L$Wsig424|DKMFXI@tp{Wkx8#E* z3}aQEDSx~-^nQDq3mR$_Blq1N?Q_tX2`2F?Xal|arS_QT7Umz#M;r8C4tstxA)Lrs z?X|4%nfU?8Lp+vj)lRrtz4{j9uW{U5(BpqurT9d@$>i%v9`CjkACw1Q$+&=>IQ={P zp>a@}9piGaz!xnoLC!lp=G3AUa&^&{ub?#!@5E`o#t2RSF}s!n%exL!pdh3$xe&?Q z5j_;8n@9G}rqED5r_o)C80RRECDou54Y<6K}ASh#RWMv?8X=iR}Zf7YVJYi&QX>LGmb!=>Q zVPr0DWpqA?0yP8^0SEve0viJY9svRufCU0r5$PWU3ke7Z1r-Vj2nz%j0s{d63JDO^ zq15tb-i_ayL{7}xmTUgiRz#Gqqj_64-3*! zpX>ZJgN(2lfr5)KJH{LU5I4bu*6OU08o#{F2alG~9{itD6M6z|z6#`}giaWuah{Q* zO9_E+cb7nj9aH}QcGh{BL8UTHFtda+<*X9K-s=A0W$}9)ivqp%dNQ}oV~=qDBalQwSu=Is9&$`rrO#M_Rf(8 z5!+iNqqz%-#`GcFH;`p%C$G|kB`Ir>NNwVS2CigRztI#Akbl*XBZmWdd^I_4Zd3wv zvdgTJa$mNN41a(qwv}=%d!h?Q!vns=*mQ@7qccPLP&*3uYs{!R>L!i)Bhb#uZE9)~F2JKnhT|c;yWxE=rj$T~y8~VzF(o8VT+QLv z6TIIkH5|(9!v%m+;$BXM5nSQemz>RuaWoI2Ic7~_pzl{aPUs-K3GIfC;d_GJON}!H zk4p2XCl=?iM@OaU$WHd@d(h9%fyT|1n^M%F6gs=?z~R5!{^(Cv0X@i#F{C=xUNKx?WUzfo14#M#h0yc~-E}ZdZzwV6*~jKkS`-eME| z{WtqBCh!&sH#DMfy6MsVFt344sAt}5neS3J*~CD%lB%!hx$uWL^@>ES=FXDFHP@|uFUtyDOgUtLAR z$r?aEx+#fdT1!Y9Q`|1TpZy_U@3i7Cee$&fCIXoS%Dn`b==elddL&dm8Z zKQ6Z?=sjT0b)g^JkAONri*5(8d^P?GyMbFr7f0-!)xurCodd({rmq!u`A_dm7iCDDnhPZL%&oWeusgb zKlT-FfL7xJCwM_5C4J&9zsNIDkaYsTb-o|TUOhBnvr7LwOglWKQtT&#Q70@ZrGw88pdgT?qeW&)upu1_3LWWQpEClVv`ZbW zcTsJPu`u}NjS6XrpvtIvJFwDpjuq4tP|yM8VV-3I=sl*awW-#MzL#-8p`$PLqRXhf z1I;RxmQxjjb#J=bWqh+>E0Db5f?Wlewqd*FK`OKwHA0nIW9KmzMd|%Q+|MQCJ5$wM z-x9q*^y7NDoXR0G41t9|m$S za{VTFnsq5R<=;sW09MQ+-+$=@%at5&4DU%oatZjwcNI8d0sm~venoh-nOMtkWbK6- zcp>m{LCJ0-|Les4WV*WQtLaX{KIi%!aK^V`y>-Kg2Z1K3P;eODPen^}z7JsncS*Wl zUsi={8x;XDseyO*Nos!b^g^36Jqmo8TfkmvaBGtYnLtCHimA}h#hMTQ1+%4=ek`at zvRrA|lI(aY3H&=t2+~qCj{?7yuT78hbh-BLDwjl7v@$#hCEQ8PZQ|~Bmi57O?dK`4 zem&*7;E#Y&`k*A~pM_>@pox{%kg@q3*y>w_r?LAbPyzxi+1Aate#BunoE2yIV=8{} z82j`)?8Q`Nm$ULpm!NO&6A{tsOZYt<*LI;jmIuhXi+$IL!VQN{P*ec?zCbLwt{l>c zNZ!f{mCplHa1-adQ^kih(sW}l9Hhlx1G&WfDr$SmQQvbPrSWQhZ~&6~$rI}D^AZxJ z1&B|MeN^6ObHjGx>yor4(UKreUIdXtXYWQE&a>wbIC~Jm6E<)vjMX>hECeqUa>g|s zNun+n{$NS>I1F#x@@SN;fEr&ajJTDq7q*R^S<*m|*$O$||MoH|01*KI0f_`Y1Q-Db z00{*GSP|lA0viGXDG3nOq15tb-i_bET_6M*0SEvF1p-(R;%ER02@s-Xf$|y1F-*uD z5B?~4{*tnN1D$~VHz9Jgzt>?X>pnfifOmjG_yCoVJpMO>?u)_454xiIZl92HdG-+3r)0Cf zW)U?QC*0jors8Gkxa_dGhV6g6HnTt_mz~Y^=D!f%pBAbqJO-lKNnKhwZ~Ot(mFMp& z_s!=nT&vt0REbPrlE|-ok0_~2{VD+Jvv;9SY|$nEhoLC-`AIif%GC2W$y{#N(=k9g zsJfp2l<&8lC-We~-LWCADp5(Gwg!bm06a0HxdVkWx^rAYG{;8&*GDnwTg>C`#F$kw zLvt_ZQRQBc+$n7%YU2RfmaF~7?m#%9qAj6=n4TX=@vhJPxeOJ$#u15{V((81qS)lz z>S!XRHzH3LFD}tHL53`-1!bD8GkSfT6C0x_v~%M2ts+?W>~GmJp%PXh%fu$Hm5WRS z)9-(gcp`47d+AO|dQ7YtU)=@;+B{wt0Pv{=p&s_e!`Ss+P&QAwSE$|3A{g?uHyMd` z-DI`!oVi`hU@A2XXrH*?g1uIWn$Cz2Sj#*C5ScNdMJ01hW_u^W=nku;hEic464UIW zJ7nrj(ZNexhNMs1Clo2zm4KM}*?`{#qw5@A}l5C3^q z{NtoYlUWF_2ZW{!+CW?a!-D^;dA(g#AuA^(!pDV(TGb`JnKfqwILrIx=bP2AMviX7qgZ{1gm-sIlx9}pu4E(P4Q4Z)EPy?o$~CPiz1H From c42a706d0b6c49e32cfd2bd7e790939e01c8777f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20L=C3=B6nnhager?= Date: Tue, 4 Mar 2025 09:21:43 +0100 Subject: [PATCH 091/112] Update test generation script --- mullvad-update/update-testdata.sh | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/mullvad-update/update-testdata.sh b/mullvad-update/update-testdata.sh index 17ac62d9ea50..71ab90d39d08 100644 --- a/mullvad-update/update-testdata.sh +++ b/mullvad-update/update-testdata.sh @@ -1,5 +1,8 @@ #!/usr/bin/env bash +# This script updates ./test-version-response.json by signing ./unsigned-response.json. +# The JSON data is used by several unit tests. + set -eu SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" @@ -8,12 +11,12 @@ cd "$SCRIPT_DIR" # Update test-version-response from secret="a459c1ee4f128780592b61454786cb289b38034a3ac1c7860e6e62187ac6e9a9" -#secret=$(cargo r --bin mullvad-version-metadata --features sign generate-key) pubkey="BB4EF63FFDCC6BD5A19C30CD23B9DE03099407A04463418F17AE338B98AA09D4" echo "secret: $secret" echo "pubkey: $pubkey" -cargo r --bin mullvad-version-metadata --features sign sign --file ./unsigned-response.json --secret $secret > test-version-response.json +cargo r --bin mullvad-version-metadata --features sign --features client \ + sign --file ./unsigned-response.json --secret $secret > test-version-response.json echo -n "$pubkey" > test-pubkey From 2aa5f05a5451417d27308e1b25bcc78517365d28 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20L=C3=B6nnhager?= Date: Tue, 4 Mar 2025 09:31:14 +0100 Subject: [PATCH 092/112] Change domain name to option --- installer-downloader/src/ui_downloader.rs | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/installer-downloader/src/ui_downloader.rs b/installer-downloader/src/ui_downloader.rs index 9c97b17b8ac1..c69b1f959947 100644 --- a/installer-downloader/src/ui_downloader.rs +++ b/installer-downloader/src/ui_downloader.rs @@ -130,7 +130,7 @@ impl AppDownl /// Implementation of [fetch::ProgressUpdater] that updates some [AppDelegate]. pub struct UiProgressUpdater { - domain: String, + domain: Option, prev_progress: Option, queue: Delegate::Queue, } @@ -138,7 +138,7 @@ pub struct UiProgressUpdater { impl UiProgressUpdater { pub fn new(queue: Delegate::Queue) -> Self { Self { - domain: "unknown source".to_owned(), + domain: None, prev_progress: None, queue, } @@ -161,9 +161,13 @@ impl UiProgressUpdater { format!( "{} {}... ({complete_percentage}%)", resource::DOWNLOADING_DESC_PREFIX, - self.domain + self.domain() ) } + + fn domain(&self) -> &str { + self.domain.as_deref().unwrap_or("unknown source") + } } impl fetch::ProgressUpdater for UiProgressUpdater { @@ -201,6 +205,6 @@ impl fetch::ProgressUpdater for UiProgressUpdat // 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(); + self.domain = Some(domain.to_owned()); } } From e2d7bdd51e667d37246e3c4ccdb0a73018545ce9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20L=C3=B6nnhager?= Date: Tue, 4 Mar 2025 09:47:46 +0100 Subject: [PATCH 093/112] Add icon to installer-downloader on Windows --- installer-downloader/build.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/installer-downloader/build.rs b/installer-downloader/build.rs index 93692ee6cd8e..64b0d66d00a8 100644 --- a/installer-downloader/build.rs +++ b/installer-downloader/build.rs @@ -25,6 +25,7 @@ fn win_main() -> anyhow::Result<()> { println!("cargo:rerun-if-changed=loader.manifest"); res.set_manifest_file("loader.manifest"); + res.set_icon("../dist-assets/icon.ico"); res.compile().context("Failed to compile resources") } From 9c5ed68434cd10c75fdbb3788accf86f54376b22 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20L=C3=B6nnhager?= Date: Tue, 4 Mar 2025 09:51:38 +0100 Subject: [PATCH 094/112] Force static linking against libc --- installer-downloader/build.sh | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/installer-downloader/build.sh b/installer-downloader/build.sh index ea0f75d6c2eb..5e69a1eb8181 100755 --- a/installer-downloader/build.sh +++ b/installer-downloader/build.sh @@ -100,8 +100,13 @@ function build_executable { # Old bash versions complain about empty array expansion when -u is set set +u - RUSTFLAGS="-C codegen-units=1 -C panic=abort -C strip=symbols -C opt-level=z" \ - cargo build --bin installer-downloader --release "${target_args[@]}" + local rustflags="-C codegen-units=1 -C panic=abort -C strip=symbols -C opt-level=z" + + if [[ -z "$1" && "$(uname -s)" == "MINGW"* ]] || [[ $1 == *"windows"* ]]; then + rustflags+=" -Ctarget-feature=+crt-static" + fi + + RUSTFLAGS="$rustflags" cargo build --bin installer-downloader --release "${target_args[@]}" set -u } From 753f73c7aa6e65f87fed312c1cf687c43d19a6c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20L=C3=B6nnhager?= Date: Tue, 4 Mar 2025 10:10:43 +0100 Subject: [PATCH 095/112] Abort download task when begin_download is incorrectly called twice --- installer-downloader/src/controller.rs | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/installer-downloader/src/controller.rs b/installer-downloader/src/controller.rs index 94c15de36d65..7b6abce60cc0 100644 --- a/installer-downloader/src/controller.rs +++ b/installer-downloader/src/controller.rs @@ -323,9 +323,7 @@ impl> + AppDownlo } async fn begin_download(&mut self) { - if self.active_download.take().is_some() { - log::debug!("Interrupting ongoing download"); - } + self.cancel_download().await; let Some(version_info) = self.version_info.clone() else { log::error!("Attempted 'begin download' before having version info"); return; @@ -410,10 +408,7 @@ impl> + AppDownlo } async fn cancel(&mut self) { - if let Some(active_download) = self.active_download.take() { - active_download.abort(); - let _ = active_download.await; - } + self.cancel_download().await; let Some(version_info) = self.version_info.as_ref() else { log::error!("Attempted 'cancel' before having version info"); @@ -448,6 +443,14 @@ impl> + AppDownlo self_.clear_download_progress(); }); } + + async fn cancel_download(&mut self) { + if let Some(active_download) = self.active_download.take() { + log::debug!("Interrupting ongoing download"); + active_download.abort(); + let _ = active_download.await; + } + } } /// Select a mirror to download from From d7782f858a16dc4a5ec9c5c38400f0ed592ee723 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20L=C3=B6nnhager?= Date: Tue, 4 Mar 2025 10:21:01 +0100 Subject: [PATCH 096/112] Clarify panic message --- installer-downloader/src/controller.rs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/installer-downloader/src/controller.rs b/installer-downloader/src/controller.rs index 7b6abce60cc0..25d1d182f817 100644 --- a/installer-downloader/src/controller.rs +++ b/installer-downloader/src/controller.rs @@ -194,9 +194,7 @@ async fn fetch_app_version_info( }); // wait for user to press either button - let Some(action) = action_rx.recv().await else { - panic!("channel was dropped? argh") - }; + let action = action_rx.recv().await.expect("sender unexpectedly dropped"); match action { Action::Retry => { From 3de6aaaa162e866f5d11aba325ea5bf4b561d58e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20L=C3=B6nnhager?= Date: Tue, 4 Mar 2025 10:29:10 +0100 Subject: [PATCH 097/112] Update version metadata URL --- installer-downloader/src/controller.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/installer-downloader/src/controller.rs b/installer-downloader/src/controller.rs index 25d1d182f817..595e4cb24788 100644 --- a/installer-downloader/src/controller.rs +++ b/installer-downloader/src/controller.rs @@ -22,6 +22,9 @@ const VERSION_PROVIDER_PUBKEY: &str = include_str!("../../mullvad-update/stagemo /// Pinned root certificate used when fetching version metadata const PINNED_CERTIFICATE: &[u8] = include_bytes!("../../mullvad-api/le_root_cert.pem"); +/// Base URL for pulling metadata. Actual JSON files should be stored at `/.json` +const META_REPOSITORY_URL: &str = "https://api.stagemole.eu/app/releases/"; + /// Actions handled by an async worker task in [ActionMessageHandler]. enum TaskMessage { SetVersionInfo(VersionInfo), @@ -72,7 +75,7 @@ fn get_metadata_url() -> String { } else { panic!("Unsupported platform") }; - format!("https://releases.stagemole.eu/desktop/metadata/{PLATFORM}.json") + format!("{META_REPOSITORY_URL}/{PLATFORM}.json") } impl AppController { From 3da2c2d4a92391cfa3f12cf0d995d5e5dcf3e5ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20L=C3=B6nnhager?= Date: Tue, 4 Mar 2025 10:45:58 +0100 Subject: [PATCH 098/112] Fix documentation warnings --- installer-downloader/src/resource.rs | 2 +- installer-downloader/src/temp.rs | 4 ++-- mullvad-update/src/format/mod.rs | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/installer-downloader/src/resource.rs b/installer-downloader/src/resource.rs index f23533384569..24c59995c3eb 100644 --- a/installer-downloader/src/resource.rs +++ b/installer-downloader/src/resource.rs @@ -46,7 +46,7 @@ pub const FETCH_VERSION_ERROR_RETRY_BUTTON_TEXT: &str = "Try again"; /// Displayed while fetching version info from the API failed (cancel button) pub const FETCH_VERSION_ERROR_CANCEL_BUTTON_TEXT: &str = "Cancel"; -/// The first part of "Downloading from ... (x%)", displayed during download +/// The first part of "Downloading from \... (x%)", displayed during download pub const DOWNLOADING_DESC_PREFIX: &str = "Downloading from"; /// Displayed after completed download diff --git a/installer-downloader/src/temp.rs b/installer-downloader/src/temp.rs index 257465d5d09f..a40019df4f5e 100644 --- a/installer-downloader/src/temp.rs +++ b/installer-downloader/src/temp.rs @@ -20,10 +20,10 @@ use anyhow::Context; use async_trait::async_trait; use std::path::PathBuf; -/// Provide a directory to use for [AppDownloader] +/// Provide a directory to use for [mullvad_update::app::AppDownloader] #[async_trait] pub trait DirectoryProvider { - /// Provide a directory to use for [AppDownloader] + /// Provide a directory to use for [mullvad_update::app::AppDownloader] async fn create_download_dir() -> anyhow::Result; } diff --git a/mullvad-update/src/format/mod.rs b/mullvad-update/src/format/mod.rs index fc706f4357b1..82b4f16ea0f1 100644 --- a/mullvad-update/src/format/mod.rs +++ b/mullvad-update/src/format/mod.rs @@ -7,7 +7,7 @@ //! `signed` also contains an `expires` field, which is a timestamp indicating when the object //! expires. //! -//! For [deserializer] to succeed in deserializing a file, it must verify that the canonicalized +//! For the deserializer to succeed in deserializing a file, it must verify that the canonicalized //! form of `signed` is in fact signed by key/signature in `signature`. It also reads the `expires` //! and rejects the file if it has expired. From d9e4dd0f358a145e2522bcc221f3cba436510252 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20L=C3=B6nnhager?= Date: Tue, 4 Mar 2025 10:56:03 +0100 Subject: [PATCH 099/112] Change from "as _" to "as isize" --- installer-downloader/src/winapi_impl/delegate.rs | 2 +- installer-downloader/src/winapi_impl/ui.rs | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/installer-downloader/src/winapi_impl/delegate.rs b/installer-downloader/src/winapi_impl/delegate.rs index 08f5292cade8..173464277172 100644 --- a/installer-downloader/src/winapi_impl/delegate.rs +++ b/installer-downloader/src/winapi_impl/delegate.rs @@ -247,6 +247,6 @@ impl AppDelegateQueue for Queue { }; 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) }; + unsafe { PostMessageW(hwnd as isize, QUEUE_MESSAGE, 0, context_ptr as isize) }; } } diff --git a/installer-downloader/src/winapi_impl/ui.rs b/installer-downloader/src/winapi_impl/ui.rs index 72ba3d52e8a9..af5e39bf21ed 100644 --- a/installer-downloader/src/winapi_impl/ui.rs +++ b/installer-downloader/src/winapi_impl/ui.rs @@ -391,8 +391,8 @@ fn handle_banner_label_colors( if msg == WM_CTLCOLORSTATIC { // SAFETY: `w` is a valid device context for WM_CTLCOLORSTATIC unsafe { - SetTextColor(w as _, rgb([255, 255, 255])); - SetBkColor(w as _, rgb(BACKGROUND_COLOR)); + SetTextColor(w as isize, rgb([255, 255, 255])); + SetBkColor(w as isize, rgb(BACKGROUND_COLOR)); } } None @@ -415,11 +415,11 @@ fn handle_link_messages( if msg == WM_CTLCOLORSTATIC && Some(p) == link_hwnd { // SAFETY: `w` is a valid device context for WM_CTLCOLORSTATIC unsafe { - SetBkMode(w as _, TRANSPARENT as _); - SetTextColor(w as _, rgb(LINK_COLOR)); + SetBkMode(w as isize, TRANSPARENT as _); + SetTextColor(w as isize, rgb(LINK_COLOR)); } // Out of bounds background - return Some(COLOR_WINDOW as _); + return Some(COLOR_WINDOW as isize); } None @@ -495,7 +495,7 @@ fn create_link_font() -> Result<&'static nwg::Font, nwg::NwgError> { } Ok(nwg::Font { - handle: raw_font as _, + handle: raw_font as *mut _, }) }); From aa2176e734c2b7b05d49d8ee1a4f543dc6763235 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20L=C3=B6nnhager?= Date: Tue, 4 Mar 2025 10:57:02 +0100 Subject: [PATCH 100/112] Add links to RGB macro --- installer-downloader/src/winapi_impl/ui.rs | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/installer-downloader/src/winapi_impl/ui.rs b/installer-downloader/src/winapi_impl/ui.rs index af5e39bf21ed..daf04ce5b799 100644 --- a/installer-downloader/src/winapi_impl/ui.rs +++ b/installer-downloader/src/winapi_impl/ui.rs @@ -383,11 +383,6 @@ fn handle_banner_label_colors( handler_id: usize, ) -> Result { 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 { // SAFETY: `w` is a valid device context for WM_CTLCOLORSTATIC unsafe { @@ -407,11 +402,6 @@ fn handle_link_messages( ) -> Result { let link_hwnd = link.handle.hwnd().map(|hwnd| hwnd as isize); nwg::bind_raw_event_handler(parent, 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 { // SAFETY: `w` is a valid device context for WM_CTLCOLORSTATIC unsafe { @@ -504,3 +494,9 @@ fn create_link_font() -> Result<&'static nwg::Font, nwg::NwgError> { Err(err) => Err(err.to_owned()), } } + +/// This is the RGB() macro except it takes in a slice representing RGB values +/// RGB macro: https://learn.microsoft.com/en-us/windows/win32/api/wingdi/nf-wingdi-rgb +fn rgb(color: [u8; 3]) -> COLORREF { + color[0] as COLORREF | ((color[1] as COLORREF) << 8) | ((color[2] as COLORREF) << 16) +} From cae49e5a18c14c1ef7bd80d60e56ad4f109e3fae Mon Sep 17 00:00:00 2001 From: Sebastian Holmin Date: Tue, 4 Mar 2025 14:40:22 +0100 Subject: [PATCH 101/112] Fetch app version before starting `ActionMessageHandler` --- .../src/cacao_impl/delegate.rs | 1 + installer-downloader/src/controller.rs | 120 ++++++++---------- installer-downloader/src/delegate.rs | 2 +- 3 files changed, 56 insertions(+), 67 deletions(-) diff --git a/installer-downloader/src/cacao_impl/delegate.rs b/installer-downloader/src/cacao_impl/delegate.rs index c7dc9d3476c8..f4378ae04ea0 100644 --- a/installer-downloader/src/cacao_impl/delegate.rs +++ b/installer-downloader/src/cacao_impl/delegate.rs @@ -182,6 +182,7 @@ impl AppWindow { } /// This simply mutates the UI on the main thread +#[derive(Clone)] pub struct Queue {} impl AppDelegateQueue for Queue { diff --git a/installer-downloader/src/controller.rs b/installer-downloader/src/controller.rs index 595e4cb24788..3c04aa120510 100644 --- a/installer-downloader/src/controller.rs +++ b/installer-downloader/src/controller.rs @@ -1,10 +1,12 @@ //! This module implements the actual logic performed by different UI components. -use crate::delegate::{AppDelegate, AppDelegateQueue}; -use crate::environment::Environment; -use crate::resource; -use crate::temp::DirectoryProvider; -use crate::ui_downloader::{UiAppDownloader, UiAppDownloaderParameters, UiProgressUpdater}; +use crate::{ + delegate::{AppDelegate, AppDelegateQueue}, + environment::Environment, + resource, + temp::DirectoryProvider, + ui_downloader::{UiAppDownloader, UiAppDownloaderParameters, UiProgressUpdater}, +}; use mullvad_update::{ api::{HttpVersionInfoProvider, VersionInfoProvider}, @@ -13,8 +15,10 @@ use mullvad_update::{ }; use rand::seq::SliceRandom; use std::path::PathBuf; -use tokio::sync::{mpsc, oneshot}; -use tokio::task::JoinHandle; +use tokio::{ + sync::{mpsc, oneshot}, + task::JoinHandle, +}; /// ed25519 pubkey used to verify metadata from the Mullvad (stagemole) API const VERSION_PROVIDER_PUBKEY: &str = include_str!("../../mullvad-update/stagemole-pubkey"); @@ -22,12 +26,12 @@ const VERSION_PROVIDER_PUBKEY: &str = include_str!("../../mullvad-update/stagemo /// Pinned root certificate used when fetching version metadata const PINNED_CERTIFICATE: &[u8] = include_bytes!("../../mullvad-api/le_root_cert.pem"); -/// Base URL for pulling metadata. Actual JSON files should be stored at `/.json` +/// Base URL for pulling metadata. Actual JSON files should be stored at `/.json` const META_REPOSITORY_URL: &str = "https://api.stagemole.eu/app/releases/"; /// Actions handled by an async worker task in [ActionMessageHandler]. enum TaskMessage { - SetVersionInfo(VersionInfo), BeginDownload, Cancel, TryBeta, @@ -101,18 +105,21 @@ impl AppController { delegate.hide_stable_text(); let (task_tx, task_rx) = mpsc::channel(1); - tokio::spawn(ActionMessageHandler::::run::( - delegate.queue(), - task_tx.clone(), - task_rx, - )); + let queue = delegate.queue(); + let task_tx_clone = task_tx.clone(); + tokio::spawn(async move { + let version_info = + fetch_app_version_info::(queue.clone(), version_provider, environment).await; + ActionMessageHandler::::run::( + queue, + task_tx_clone, + task_rx, + version_info, + ) + .await; + }); delegate.set_status_text(resource::FETCH_VERSION_DESC); - tokio::spawn(fetch_app_version_info::( - delegate.queue(), - task_tx.clone(), - version_provider, - environment, - )); + Self::register_user_action_callbacks(delegate, task_tx); } @@ -142,11 +149,11 @@ impl AppController { /// Background task that fetches app version data. async fn fetch_app_version_info( queue: Delegate::Queue, - download_tx: mpsc::Sender, version_provider: VersionProvider, Environment { architecture }: Environment, -) where - Delegate: AppDelegate + 'static, +) -> VersionInfo +where + Delegate: AppDelegate, VersionProvider: VersionInfoProvider + Send, { loop { @@ -160,8 +167,16 @@ async fn fetch_app_version_info( let err = match version_provider.get_version_info(version_params).await { Ok(version_info) => { - let _ = download_tx.try_send(TaskMessage::SetVersionInfo(version_info)); - return; + let version_label = format_latest_version(&version_info.stable); + let has_beta = 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(); + } + }); + return version_info; } Err(err) => err, }; @@ -226,7 +241,7 @@ struct ActionMessageHandler< > { queue: D::Queue, tx: mpsc::Sender, - version_info: Option, + version_info: VersionInfo, active_download: Option>, target_version: TargetVersion, temp_dir: anyhow::Result, @@ -242,13 +257,14 @@ impl> + AppDownlo queue: D::Queue, tx: mpsc::Sender, mut rx: mpsc::Receiver, + version_info: VersionInfo, ) { let temp_dir = DP::create_download_dir().await; let mut handler = Self { queue, tx, - version_info: None, + version_info, active_download: None, target_version: TargetVersion::Stable, temp_dir, @@ -263,9 +279,6 @@ impl> + AppDownlo async fn handle_message(&mut self, msg: &TaskMessage) { match msg { - TaskMessage::SetVersionInfo(new_version_info) => { - self.handle_set_version_info(new_version_info); - } TaskMessage::TryBeta => self.handle_try_beta(), TaskMessage::TryStable => self.handle_try_stable(), TaskMessage::BeginDownload => self.begin_download().await, @@ -273,26 +286,9 @@ impl> + AppDownlo } } - fn handle_set_version_info(&mut self, new_version_info: &VersionInfo) { - let version_label = format_latest_version(&new_version_info.stable); - let has_beta = new_version_info.beta.is_some(); - self.queue.queue_main(move |self_| { - self_.set_status_text(&version_label); - self_.enable_download_button(); - if has_beta { - self_.show_beta_text(); - } - }); - self.version_info = Some(new_version_info.to_owned()); - } - fn handle_try_beta(&mut self) { - let Some(version_info) = self.version_info.as_ref() else { - log::error!("Attempted 'try beta' before having version info"); - return; - }; - let Some(beta_info) = version_info.beta.as_ref() else { - log::error!("Attempted 'try beta' without beta version"); + log::error!("Attempted 'try beta' without beta version"); + let Some(beta_info) = self.version_info.beta.as_ref() else { return; }; @@ -307,11 +303,7 @@ impl> + AppDownlo } fn handle_try_stable(&mut self) { - let Some(version_info) = self.version_info.as_ref() else { - log::error!("Attempted 'try stable' before having version info"); - return; - }; - let stable_info = &version_info.stable; + let stable_info = &self.version_info.stable; self.target_version = TargetVersion::Stable; let version_label = format_latest_version(stable_info); @@ -325,10 +317,6 @@ impl> + AppDownlo async fn begin_download(&mut self) { self.cancel_download().await; - let Some(version_info) = self.version_info.clone() else { - log::error!("Attempted 'begin download' before having version info"); - return; - }; let (retry_tx, cancel_tx) = (self.tx.clone(), self.tx.clone()); self.queue.queue_main(move |self_| { @@ -368,6 +356,7 @@ impl> + AppDownlo // Begin download let (tx, rx) = oneshot::channel(); let target_version = self.target_version; + let version_info = self.version_info.clone(); self.queue.queue_main(move |self_| { let selected_version = match target_version { TargetVersion::Stable => &version_info.stable, @@ -411,18 +400,17 @@ impl> + AppDownlo async fn cancel(&mut self) { self.cancel_download().await; - let Some(version_info) = self.version_info.as_ref() else { - log::error!("Attempted 'cancel' before having version info"); - return; - }; - let selected_version = match self.target_version { - TargetVersion::Stable => &version_info.stable, - TargetVersion::Beta => version_info.beta.as_ref().expect("selected version exists"), + TargetVersion::Stable => &self.version_info.stable, + TargetVersion::Beta => self + .version_info + .beta + .as_ref() + .expect("selected version exists"), }; let version_label = format_latest_version(selected_version); - let has_beta = version_info.beta.is_some(); + let has_beta = self.version_info.beta.is_some(); let target_version = self.target_version; self.queue.queue_main(move |self_| { diff --git a/installer-downloader/src/delegate.rs b/installer-downloader/src/delegate.rs index 17182bd0d137..b33ea96b461a 100644 --- a/installer-downloader/src/delegate.rs +++ b/installer-downloader/src/delegate.rs @@ -118,7 +118,7 @@ pub struct ErrorMessage { } /// Schedules actions on the UI (main) thread from other threads -pub trait AppDelegateQueue: Send { +pub trait AppDelegateQueue: Send + Clone { /// Schedule action on the UI (main) thread from other threads fn queue_main(&self, callback: F); } From 1f5b3a94a2b3f32b2299dd4bce544897b7a972e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20L=C3=B6nnhager?= Date: Tue, 4 Mar 2025 17:00:28 +0100 Subject: [PATCH 102/112] Request all remaining bytes in HTTP range request --- mullvad-update/src/client/fetch.rs | 23 ++++++++++------------- 1 file changed, 10 insertions(+), 13 deletions(-) diff --git a/mullvad-update/src/client/fetch.rs b/mullvad-update/src/client/fetch.rs index d9de50a55d56..706e3897f3d9 100644 --- a/mullvad-update/src/client/fetch.rs +++ b/mullvad-update/src/client/fetch.rs @@ -1,4 +1,4 @@ -//! A downloader that supports range requests and resuming downloads +//! A downloader that supports HTTP range requests and resuming downloads use std::{ path::Path, @@ -146,9 +146,9 @@ pub async fn get_to_writer( while let Some(chunk) = response.chunk().await.context("Failed to read chunk")? { bytes_read += chunk.len(); - if bytes_read > RangeIter::CHUNK_SIZE { + if bytes_read > total_size - already_fetched_bytes { // Protect against servers responding with more data than expected - anyhow::bail!("Server returned more than chunk-sized bytes"); + anyhow::bail!("Server returned more than requested bytes"); } writer @@ -185,9 +185,6 @@ struct RangeIter { } 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 } } @@ -202,7 +199,7 @@ impl Iterator for RangeIter { } let prev = self.current; - let read_n = self.end.saturating_sub(self.current).min(Self::CHUNK_SIZE); + let read_n = self.end.saturating_sub(self.current); if read_n == 0 { return None; } @@ -422,7 +419,7 @@ mod test { Ok(()) } - /// Create endpoints that serve a file at `url_path` using range requests + /// Create endpoints that serve a file at `url_path` using HTTP 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 @@ -430,7 +427,7 @@ mod test { .with_header(CONTENT_LENGTH, &data.len().to_string()) .create(); - // Respond to range requests with file + // Respond to HTTP range requests with file server .mock("GET", url_path) .with_body_from_request(|request| { @@ -475,15 +472,15 @@ mod test { .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]; + // Reject larger than expected files + let file_data = vec![0u8; 2]; 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()) + // Lie about size in header + .with_header(CONTENT_LENGTH, "1") .create(); server .mock("GET", "/my_file") From 7c443a2a379fa5ea59400ec11a187d65865116e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20L=C3=B6nnhager?= Date: Tue, 4 Mar 2025 17:12:19 +0100 Subject: [PATCH 103/112] Remove TODO comment about missing fields --- mullvad-update/src/bin/mullvad-version-metadata.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/mullvad-update/src/bin/mullvad-version-metadata.rs b/mullvad-update/src/bin/mullvad-version-metadata.rs index c531fbe9ddcc..b65eee837f88 100644 --- a/mullvad-update/src/bin/mullvad-version-metadata.rs +++ b/mullvad-update/src/bin/mullvad-version-metadata.rs @@ -51,7 +51,6 @@ async fn sign(file: String, secret: key::SecretKey) -> anyhow::Result<()> { }; // Deserialize version data - // TODO: Make sure this never ignores missing fields let response: format::Response = serde_json::from_slice(&data).context("Failed to deserialize version metadata")?; From 50af33f45455d5bdf3b48a5f1a050686ed87791a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20L=C3=B6nnhager?= Date: Tue, 4 Mar 2025 17:14:19 +0100 Subject: [PATCH 104/112] Fix error string about hex length --- mullvad-update/src/format/key.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mullvad-update/src/format/key.rs b/mullvad-update/src/format/key.rs index 01cd51a6ea1a..4add53fab45b 100644 --- a/mullvad-update/src/format/key.rs +++ b/mullvad-update/src/format/key.rs @@ -147,7 +147,7 @@ fn bytes_from_hex(key: &str) -> anyhow::Result<[u8; SIZE]> { let bytes = hex::decode(key).context("invalid hex")?; if bytes.len() != SIZE { bail!( - "hex-encoded string of {SIZE} bytes, found {} bytes", + "expected hex-encoded string of {SIZE} bytes, found {} bytes", bytes.len() ); } From f29f1266a9482a3ac7d1bb2620d0633d61272601 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20L=C3=B6nnhager?= Date: Tue, 4 Mar 2025 17:20:08 +0100 Subject: [PATCH 105/112] Fix target arch check --- talpid-platform-metadata/src/arch.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/talpid-platform-metadata/src/arch.rs b/talpid-platform-metadata/src/arch.rs index 1f6de901cdaa..d5343dcb501e 100644 --- a/talpid-platform-metadata/src/arch.rs +++ b/talpid-platform-metadata/src/arch.rs @@ -47,7 +47,7 @@ pub fn get_native_arch() -> Result, std::io::Error> { /// Return native architecture. #[cfg(not(target_os = "windows"))] pub fn get_native_arch() -> Result, std::io::Error> { - const TARGET_ARCH: Option = if cfg!(any(target_arch = "x86_64",)) { + const TARGET_ARCH: Option = if cfg!(target_arch = "x86_64") { Some(Architecture::X86) } else if cfg!(target_arch = "aarch64") { Some(Architecture::Arm64) From e5063f68cdef8cb19a27296631b064d1a96fac07 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20L=C3=B6nnhager?= Date: Wed, 5 Mar 2025 11:48:22 +0100 Subject: [PATCH 106/112] Remove comment about borrow --- installer-downloader/src/cacao_impl/ui.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/installer-downloader/src/cacao_impl/ui.rs b/installer-downloader/src/cacao_impl/ui.rs index b848125d02f8..745a72550080 100644 --- a/installer-downloader/src/cacao_impl/ui.rs +++ b/installer-downloader/src/cacao_impl/ui.rs @@ -107,7 +107,6 @@ impl Dispatcher for AppImpl { callback(); } 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); From 111ee4282a641245e2b5877aecda18d5937eef28 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20L=C3=B6nnhager?= Date: Wed, 5 Mar 2025 11:51:50 +0100 Subject: [PATCH 107/112] Clarify meaning of default rollout --- mullvad-update/src/format/mod.rs | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/mullvad-update/src/format/mod.rs b/mullvad-update/src/format/mod.rs index 82b4f16ea0f1..6829374022f2 100644 --- a/mullvad-update/src/format/mod.rs +++ b/mullvad-update/src/format/mod.rs @@ -63,18 +63,18 @@ pub struct Release { /// Installer details for different architectures pub installers: Vec, /// Fraction of users that should receive the new version - #[serde(default = "default_rollout")] - #[serde(skip_serializing_if = "is_default_rollout")] + #[serde(default = "complete_rollout")] + #[serde(skip_serializing_if = "is_complete_rollout")] pub rollout: f32, } -/// By default, rollout includes all users -fn default_rollout() -> f32 { +/// A full rollout includes all users +fn complete_rollout() -> f32 { 1. } -fn is_default_rollout(b: impl std::borrow::Borrow) -> bool { - (b.borrow() - default_rollout()).abs() < f32::EPSILON +fn is_complete_rollout(b: impl std::borrow::Borrow) -> bool { + (b.borrow() - complete_rollout()).abs() < f32::EPSILON } /// App installer @@ -124,7 +124,7 @@ mod test { version: "2024.1".parse().unwrap(), changelog: "".to_owned(), installers: vec![], - rollout: default_rollout(), + rollout: complete_rollout(), }) .unwrap(); From f4d55eceefdf20e50064b0486e75a2e33ef04b9c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20L=C3=B6nnhager?= Date: Wed, 5 Mar 2025 11:53:24 +0100 Subject: [PATCH 108/112] Note that svg conversion is manual --- installer-downloader/convert-assets.py | 1 + 1 file changed, 1 insertion(+) diff --git a/installer-downloader/convert-assets.py b/installer-downloader/convert-assets.py index 1dcdfa97a23d..d267154544fc 100644 --- a/installer-downloader/convert-assets.py +++ b/installer-downloader/convert-assets.py @@ -1,6 +1,7 @@ #!/usr/bin/env python # Convert svg assets to png assets +# This must be done manually from cairosvg import svg2png From b0792ce4400412ffbc212df0f14c16707174dde4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20L=C3=B6nnhager?= Date: Wed, 5 Mar 2025 15:45:32 +0100 Subject: [PATCH 109/112] Set font size --- installer-downloader/src/winapi_impl/mod.rs | 8 +++++++- installer-downloader/src/winapi_impl/ui.rs | 5 +++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/installer-downloader/src/winapi_impl/mod.rs b/installer-downloader/src/winapi_impl/mod.rs index fe755009da0e..0627c59569ee 100644 --- a/installer-downloader/src/winapi_impl/mod.rs +++ b/installer-downloader/src/winapi_impl/mod.rs @@ -8,7 +8,13 @@ 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 mut global_font = nwg::Font::default(); + nwg::FontBuilder::new() + .family("Segoe UI") + .size_absolute(ui::FONT_HEIGHT) + .build(&mut global_font) + .unwrap(); + nwg::Font::set_global_default(Some(global_font)); // Load "global" values and resources let environment = match Environment::load() { diff --git a/installer-downloader/src/winapi_impl/ui.rs b/installer-downloader/src/winapi_impl/ui.rs index daf04ce5b799..b9502d003671 100644 --- a/installer-downloader/src/winapi_impl/ui.rs +++ b/installer-downloader/src/winapi_impl/ui.rs @@ -20,6 +20,9 @@ use crate::resource::{ use super::delegate::QueueContext; +/// Font height +pub const FONT_HEIGHT: u32 = 16; + static BANNER_IMAGE_DATA: &[u8] = include_bytes!("../../assets/logo-icon.png"); static BANNER_TEXT_IMAGE_DATA: &[u8] = include_bytes!("../../assets/logo-text.png"); static ERROR_IMAGE_DATA: &[u8] = include_bytes!("../../assets/alert-circle.png"); @@ -473,6 +476,8 @@ fn create_link_font() -> Result<&'static nwg::Font, nwg::NwgError> { // SAFETY: Trivially safe. `LOGFONTW` is a C struct let mut logfont: LOGFONTW = unsafe { std::mem::zeroed() }; logfont.lfUnderline = 1; + logfont.lfHeight = -i32::try_from(FONT_HEIGHT).unwrap(); + for (dest, src) in logfont.lfFaceName.iter_mut().zip(face_name) { *dest = src; } From fce594ecca0037b4dff79b6366ee9d4804f40625 Mon Sep 17 00:00:00 2001 From: Sebastian Holmin Date: Wed, 5 Mar 2025 15:25:13 +0100 Subject: [PATCH 110/112] Add `fail_fetching` to `FakeVersionInfoProvider` --- installer-downloader/tests/controller.rs | 8 ++++---- installer-downloader/tests/mock.rs | 10 +++++++++- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/installer-downloader/tests/controller.rs b/installer-downloader/tests/controller.rs index dcc571ea72ba..0d1b0deeb7bf 100644 --- a/installer-downloader/tests/controller.rs +++ b/installer-downloader/tests/controller.rs @@ -22,7 +22,7 @@ async fn test_fetch_version() { let mut delegate = FakeAppDelegate::default(); AppController::initialize::<_, FakeAppDownloaderHappyPath, _, FakeDirectoryProvider>( &mut delegate, - FakeVersionInfoProvider {}, + FakeVersionInfoProvider::default(), FAKE_ENVIRONMENT, ); @@ -46,7 +46,7 @@ async fn test_download() { let mut delegate = FakeAppDelegate::default(); AppController::initialize::<_, FakeAppDownloaderHappyPath, _, FakeDirectoryProvider>( &mut delegate, - FakeVersionInfoProvider {}, + FakeVersionInfoProvider::default(), FAKE_ENVIRONMENT, ); @@ -92,7 +92,7 @@ async fn test_failed_verification() { let mut delegate = FakeAppDelegate::default(); AppController::initialize::<_, FakeAppDownloaderVerifyFail, _, FakeDirectoryProvider>( &mut delegate, - FakeVersionInfoProvider {}, + FakeVersionInfoProvider::default(), FAKE_ENVIRONMENT, ); @@ -130,7 +130,7 @@ async fn test_failed_directory_creation() { let mut delegate = FakeAppDelegate::default(); AppController::initialize::<_, FakeAppDownloaderHappyPath, _, FakeDirectoryProvider>( &mut delegate, - FakeVersionInfoProvider {}, + FakeVersionInfoProvider::default(), FAKE_ENVIRONMENT, ); diff --git a/installer-downloader/tests/mock.rs b/installer-downloader/tests/mock.rs index 5e4a08d44bb3..51a20f347d9f 100644 --- a/installer-downloader/tests/mock.rs +++ b/installer-downloader/tests/mock.rs @@ -12,10 +12,15 @@ use mullvad_update::fetch::ProgressUpdater; use mullvad_update::version::{Version, VersionInfo, VersionParameters}; use std::io; use std::path::{Path, PathBuf}; +use std::sync::atomic::AtomicBool; use std::sync::{Arc, LazyLock, Mutex}; use std::vec::Vec; -pub struct FakeVersionInfoProvider {} +/// Fake version info provider +#[derive(Default)] +pub struct FakeVersionInfoProvider { + pub fail_fetching: Arc, +} pub static FAKE_VERSION: LazyLock = LazyLock::new(|| VersionInfo { stable: Version { @@ -35,6 +40,9 @@ pub const FAKE_ENVIRONMENT: Environment = Environment { #[async_trait::async_trait] impl VersionInfoProvider for FakeVersionInfoProvider { async fn get_version_info(&self, _params: VersionParameters) -> anyhow::Result { + if self.fail_fetching.load(std::sync::atomic::Ordering::SeqCst) { + anyhow::bail!("Failed to fetch version info"); + } Ok(FAKE_VERSION.clone()) } } From 1f5459e00b1f59cf012ce8bf57d21303188b1e20 Mon Sep 17 00:00:00 2001 From: Sebastian Holmin Date: Wed, 5 Mar 2025 15:25:29 +0100 Subject: [PATCH 111/112] Add test for failed version fetching --- installer-downloader/tests/controller.rs | 46 ++++++++++++++++++- .../controller__failed_fetch_version-2.snap | 39 ++++++++++++++++ .../controller__failed_fetch_version.snap | 37 +++++++++++++++ 3 files changed, 121 insertions(+), 1 deletion(-) create mode 100644 installer-downloader/tests/snapshots/controller__failed_fetch_version-2.snap create mode 100644 installer-downloader/tests/snapshots/controller__failed_fetch_version.snap diff --git a/installer-downloader/tests/controller.rs b/installer-downloader/tests/controller.rs index 0d1b0deeb7bf..463a0cd526eb 100644 --- a/installer-downloader/tests/controller.rs +++ b/installer-downloader/tests/controller.rs @@ -12,7 +12,10 @@ use mock::{ FakeAppDelegate, FakeAppDownloaderHappyPath, FakeAppDownloaderVerifyFail, FakeDirectoryProvider, FakeVersionInfoProvider, FAKE_ENVIRONMENT, }; -use std::time::Duration; +use std::{ + sync::{atomic::AtomicBool, Arc}, + time::Duration, +}; mod mock; @@ -86,6 +89,47 @@ async fn test_download() { assert_yaml_snapshot!(delegate.state); } +/// Test that the flow of retrying the version fetch after a failure +#[tokio::test(start_paused = true)] +async fn test_failed_fetch_version() { + let mut delegate = FakeAppDelegate::default(); + let fail_fetching = Arc::new(AtomicBool::new(true)); + AppController::initialize::<_, FakeAppDownloaderHappyPath, _, FakeDirectoryProvider>( + &mut delegate, + FakeVersionInfoProvider { + fail_fetching: fail_fetching.clone(), + }, + FAKE_ENVIRONMENT, + ); + + 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 fetch version failure screen with a retry and cancel button should be displayed + assert_yaml_snapshot!(delegate.state); + + fail_fetching.store(false, std::sync::atomic::Ordering::SeqCst); + + // Retry fetching the version + let cb = delegate + .error_retry_callback + .take() + .expect("no retry callback registered"); + cb(); + + 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 install aborts if verification fails #[tokio::test(start_paused = true)] async fn test_failed_verification() { diff --git a/installer-downloader/tests/snapshots/controller__failed_fetch_version-2.snap b/installer-downloader/tests/snapshots/controller__failed_fetch_version-2.snap new file mode 100644 index 000000000000..a44774df842b --- /dev/null +++ b/installer-downloader/tests/snapshots/controller__failed_fetch_version-2.snap @@ -0,0 +1,39 @@ +--- +source: installer-downloader/tests/controller.rs +expression: delegate.state +--- +status_text: "Version: 2025.1" +download_text: "" +download_button_visible: false +cancel_button_visible: false +cancel_button_enabled: false +download_button_enabled: true +download_progress: 0 +download_progress_visible: false +beta_text_visible: false +stable_text_visible: false +error_message_visible: true +error_message: + status_text: "Failed to load version details, please try again or make sure you have the latest installer downloader." + cancel_button_text: Cancel + retry_button_text: Try again +quit: false +call_log: + - hide_download_progress + - show_download_button + - disable_download_button + - hide_cancel_button + - hide_beta_text + - hide_stable_text + - "set_status_text: Loading version details..." + - on_download + - on_cancel + - on_beta_link + - on_stable_link + - hide_download_button + - clear_status_text + - on_error_message_retry + - on_error_message_cancel + - "show_error_message: Failed to load version details, please try again or make sure you have the latest installer downloader.. retry: Try again. cancel: Cancel" + - "set_status_text: Version: 2025.1" + - enable_download_button diff --git a/installer-downloader/tests/snapshots/controller__failed_fetch_version.snap b/installer-downloader/tests/snapshots/controller__failed_fetch_version.snap new file mode 100644 index 000000000000..f741b3db1a46 --- /dev/null +++ b/installer-downloader/tests/snapshots/controller__failed_fetch_version.snap @@ -0,0 +1,37 @@ +--- +source: installer-downloader/tests/controller.rs +expression: delegate.state +--- +status_text: "" +download_text: "" +download_button_visible: false +cancel_button_visible: false +cancel_button_enabled: false +download_button_enabled: false +download_progress: 0 +download_progress_visible: false +beta_text_visible: false +stable_text_visible: false +error_message_visible: true +error_message: + status_text: "Failed to load version details, please try again or make sure you have the latest installer downloader." + cancel_button_text: Cancel + retry_button_text: Try again +quit: false +call_log: + - hide_download_progress + - show_download_button + - disable_download_button + - hide_cancel_button + - hide_beta_text + - hide_stable_text + - "set_status_text: Loading version details..." + - on_download + - on_cancel + - on_beta_link + - on_stable_link + - hide_download_button + - clear_status_text + - on_error_message_retry + - on_error_message_cancel + - "show_error_message: Failed to load version details, please try again or make sure you have the latest installer downloader.. retry: Try again. cancel: Cancel" From 3c6af559cb6b85987c7f7eff853f40267396d81c Mon Sep 17 00:00:00 2001 From: Sebastian Holmin Date: Wed, 5 Mar 2025 16:15:58 +0100 Subject: [PATCH 112/112] Fix retry version check Hide the error message when retrying the version check fetch succeeds. --- installer-downloader/src/controller.rs | 28 ++++++++++++------- installer-downloader/tests/controller.rs | 10 +++++++ .../snapshots/controller__download-2.snap | 5 ++-- .../snapshots/controller__download-3.snap | 5 ++-- .../tests/snapshots/controller__download.snap | 11 ++++---- ...controller__failed_directory_creation.snap | 5 ++-- .../controller__failed_fetch_version-2.snap | 11 ++++++-- .../controller__failed_fetch_version.snap | 4 ++- .../controller__failed_verification.snap | 5 ++-- .../controller__fetch_version-2.snap | 5 ++-- .../snapshots/controller__fetch_version.snap | 4 +-- 11 files changed, 60 insertions(+), 33 deletions(-) diff --git a/installer-downloader/src/controller.rs b/installer-downloader/src/controller.rs index 3c04aa120510..c01222c9b360 100644 --- a/installer-downloader/src/controller.rs +++ b/installer-downloader/src/controller.rs @@ -110,6 +110,16 @@ impl AppController { tokio::spawn(async move { let version_info = fetch_app_version_info::(queue.clone(), version_provider, environment).await; + let version_label = format_latest_version(&version_info.stable); + let has_beta = 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(); + } + }); + ActionMessageHandler::::run::( queue, task_tx_clone, @@ -118,7 +128,6 @@ impl AppController { ) .await; }); - delegate.set_status_text(resource::FETCH_VERSION_DESC); Self::register_user_action_callbacks(delegate, task_tx); } @@ -157,6 +166,11 @@ where VersionProvider: VersionInfoProvider + Send, { loop { + queue.queue_main(|self_| { + self_.show_download_button(); + self_.set_status_text(resource::FETCH_VERSION_DESC); + self_.hide_error_message(); + }); let version_params = VersionParameters { architecture, // For the downloader, the rollout version is always preferred @@ -165,17 +179,9 @@ where lowest_metadata_version: 0, }; + tokio::time::sleep(std::time::Duration::from_secs(1)).await; let err = match version_provider.get_version_info(version_params).await { Ok(version_info) => { - let version_label = format_latest_version(&version_info.stable); - let has_beta = 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(); - } - }); return version_info; } Err(err) => err, @@ -216,9 +222,11 @@ where match action { Action::Retry => { + log::debug!("Retrying to fetch version info"); continue; } Action::Cancel => { + log::debug!("Cancelling fetching version info"); queue.queue_main(|self_| { self_.quit(); }); diff --git a/installer-downloader/tests/controller.rs b/installer-downloader/tests/controller.rs index 463a0cd526eb..192eab1b9d19 100644 --- a/installer-downloader/tests/controller.rs +++ b/installer-downloader/tests/controller.rs @@ -38,6 +38,11 @@ async fn test_fetch_version() { 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); + // The download button and current version should be displayed assert_yaml_snapshot!(delegate.state); } @@ -108,6 +113,9 @@ async fn test_failed_fetch_version() { let queue = delegate.queue.clone(); queue.run_callbacks(&mut delegate); + tokio::time::sleep(Duration::from_secs(1)).await; + queue.run_callbacks(&mut delegate); + // The fetch version failure screen with a retry and cancel button should be displayed assert_yaml_snapshot!(delegate.state); @@ -125,6 +133,8 @@ async fn test_failed_fetch_version() { // Run UI updates to display the fetched version let queue = delegate.queue.clone(); queue.run_callbacks(&mut delegate); + tokio::time::sleep(Duration::from_secs(1)).await; + queue.run_callbacks(&mut delegate); // The download button and current version should be displayed 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 index 44bbde015260..532866cc6c8a 100644 --- a/installer-downloader/tests/snapshots/controller__download-2.snap +++ b/installer-downloader/tests/snapshots/controller__download-2.snap @@ -1,7 +1,6 @@ --- source: installer-downloader/tests/controller.rs expression: delegate.state -snapshot_kind: text --- status_text: "Version: 2025.1" download_text: "" @@ -26,11 +25,13 @@ call_log: - hide_cancel_button - hide_beta_text - hide_stable_text - - "set_status_text: Loading version details..." - on_download - on_cancel - on_beta_link - on_stable_link + - show_download_button + - "set_status_text: Loading version details..." + - hide_error_message - "set_status_text: Version: 2025.1" - enable_download_button - hide_error_message diff --git a/installer-downloader/tests/snapshots/controller__download-3.snap b/installer-downloader/tests/snapshots/controller__download-3.snap index f7c952b191fa..f4773be8e61a 100644 --- a/installer-downloader/tests/snapshots/controller__download-3.snap +++ b/installer-downloader/tests/snapshots/controller__download-3.snap @@ -1,7 +1,6 @@ --- source: installer-downloader/tests/controller.rs expression: delegate.state -snapshot_kind: text --- status_text: "Version: 2025.1" download_text: Verification successful. Starting install... @@ -26,11 +25,13 @@ call_log: - hide_cancel_button - hide_beta_text - hide_stable_text - - "set_status_text: Loading version details..." - on_download - on_cancel - on_beta_link - on_stable_link + - show_download_button + - "set_status_text: Loading version details..." + - hide_error_message - "set_status_text: Version: 2025.1" - enable_download_button - hide_error_message diff --git a/installer-downloader/tests/snapshots/controller__download.snap b/installer-downloader/tests/snapshots/controller__download.snap index 12a2423d91ca..2f1b3c46dd34 100644 --- a/installer-downloader/tests/snapshots/controller__download.snap +++ b/installer-downloader/tests/snapshots/controller__download.snap @@ -1,14 +1,13 @@ --- source: installer-downloader/tests/controller.rs expression: delegate.state -snapshot_kind: text --- -status_text: "Version: 2025.1" +status_text: Loading version details... download_text: "" download_button_visible: true cancel_button_visible: false cancel_button_enabled: false -download_button_enabled: true +download_button_enabled: false download_progress: 0 download_progress_visible: false beta_text_visible: false @@ -26,10 +25,10 @@ call_log: - hide_cancel_button - hide_beta_text - hide_stable_text - - "set_status_text: Loading version details..." - on_download - on_cancel - on_beta_link - on_stable_link - - "set_status_text: Version: 2025.1" - - enable_download_button + - show_download_button + - "set_status_text: Loading version details..." + - hide_error_message diff --git a/installer-downloader/tests/snapshots/controller__failed_directory_creation.snap b/installer-downloader/tests/snapshots/controller__failed_directory_creation.snap index e38c8bc3f23c..ae534f550ef3 100644 --- a/installer-downloader/tests/snapshots/controller__failed_directory_creation.snap +++ b/installer-downloader/tests/snapshots/controller__failed_directory_creation.snap @@ -1,7 +1,6 @@ --- source: installer-downloader/tests/controller.rs expression: delegate.state -snapshot_kind: text --- status_text: "" download_text: "" @@ -26,11 +25,13 @@ call_log: - hide_cancel_button - hide_beta_text - hide_stable_text - - "set_status_text: Loading version details..." - on_download - on_cancel - on_beta_link - on_stable_link + - show_download_button + - "set_status_text: Loading version details..." + - hide_error_message - "set_status_text: Version: 2025.1" - enable_download_button - hide_error_message diff --git a/installer-downloader/tests/snapshots/controller__failed_fetch_version-2.snap b/installer-downloader/tests/snapshots/controller__failed_fetch_version-2.snap index a44774df842b..d9cdcf7d43c0 100644 --- a/installer-downloader/tests/snapshots/controller__failed_fetch_version-2.snap +++ b/installer-downloader/tests/snapshots/controller__failed_fetch_version-2.snap @@ -4,7 +4,7 @@ expression: delegate.state --- status_text: "Version: 2025.1" download_text: "" -download_button_visible: false +download_button_visible: true cancel_button_visible: false cancel_button_enabled: false download_button_enabled: true @@ -12,7 +12,7 @@ download_progress: 0 download_progress_visible: false beta_text_visible: false stable_text_visible: false -error_message_visible: true +error_message_visible: false error_message: status_text: "Failed to load version details, please try again or make sure you have the latest installer downloader." cancel_button_text: Cancel @@ -25,15 +25,20 @@ call_log: - hide_cancel_button - hide_beta_text - hide_stable_text - - "set_status_text: Loading version details..." - on_download - on_cancel - on_beta_link - on_stable_link + - show_download_button + - "set_status_text: Loading version details..." + - hide_error_message - hide_download_button - clear_status_text - on_error_message_retry - on_error_message_cancel - "show_error_message: Failed to load version details, please try again or make sure you have the latest installer downloader.. retry: Try again. cancel: Cancel" + - show_download_button + - "set_status_text: Loading version details..." + - hide_error_message - "set_status_text: Version: 2025.1" - enable_download_button diff --git a/installer-downloader/tests/snapshots/controller__failed_fetch_version.snap b/installer-downloader/tests/snapshots/controller__failed_fetch_version.snap index f741b3db1a46..8bb4a0ceea6e 100644 --- a/installer-downloader/tests/snapshots/controller__failed_fetch_version.snap +++ b/installer-downloader/tests/snapshots/controller__failed_fetch_version.snap @@ -25,11 +25,13 @@ call_log: - hide_cancel_button - hide_beta_text - hide_stable_text - - "set_status_text: Loading version details..." - on_download - on_cancel - on_beta_link - on_stable_link + - show_download_button + - "set_status_text: Loading version details..." + - hide_error_message - hide_download_button - clear_status_text - on_error_message_retry diff --git a/installer-downloader/tests/snapshots/controller__failed_verification.snap b/installer-downloader/tests/snapshots/controller__failed_verification.snap index 0f672c029b07..a076e241106a 100644 --- a/installer-downloader/tests/snapshots/controller__failed_verification.snap +++ b/installer-downloader/tests/snapshots/controller__failed_verification.snap @@ -1,7 +1,6 @@ --- source: installer-downloader/tests/controller.rs expression: delegate.state -snapshot_kind: text --- status_text: "" download_text: "" @@ -26,11 +25,13 @@ call_log: - hide_cancel_button - hide_beta_text - hide_stable_text - - "set_status_text: Loading version details..." - on_download - on_cancel - on_beta_link - on_stable_link + - show_download_button + - "set_status_text: Loading version details..." + - hide_error_message - "set_status_text: Version: 2025.1" - enable_download_button - hide_error_message diff --git a/installer-downloader/tests/snapshots/controller__fetch_version-2.snap b/installer-downloader/tests/snapshots/controller__fetch_version-2.snap index 12a2423d91ca..a8178768756b 100644 --- a/installer-downloader/tests/snapshots/controller__fetch_version-2.snap +++ b/installer-downloader/tests/snapshots/controller__fetch_version-2.snap @@ -1,7 +1,6 @@ --- source: installer-downloader/tests/controller.rs expression: delegate.state -snapshot_kind: text --- status_text: "Version: 2025.1" download_text: "" @@ -26,10 +25,12 @@ call_log: - hide_cancel_button - hide_beta_text - hide_stable_text - - "set_status_text: Loading version details..." - on_download - on_cancel - on_beta_link - on_stable_link + - show_download_button + - "set_status_text: Loading version details..." + - hide_error_message - "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 index eb1065929176..d6c2cbef4664 100644 --- a/installer-downloader/tests/snapshots/controller__fetch_version.snap +++ b/installer-downloader/tests/snapshots/controller__fetch_version.snap @@ -1,9 +1,8 @@ --- source: installer-downloader/tests/controller.rs expression: delegate.state -snapshot_kind: text --- -status_text: Loading version details... +status_text: "" download_text: "" download_button_visible: true cancel_button_visible: false @@ -26,7 +25,6 @@ call_log: - hide_cancel_button - hide_beta_text - hide_stable_text - - "set_status_text: Loading version details..." - on_download - on_cancel - on_beta_link