diff options
| author | David Lönnhager <david.l@mullvad.net> | 2025-03-05 23:34:34 +0100 |
|---|---|---|
| committer | David Lönnhager <david.l@mullvad.net> | 2025-03-05 23:34:34 +0100 |
| commit | 629780dd50805b3a44d9a11c9f51ca9caedce2c8 (patch) | |
| tree | 395213f84b04eb1425b6e8e14f2978a51bf913d2 | |
| parent | a01d2467326f881e80dce1e29a79da068d38c56f (diff) | |
| parent | 3c6af559cb6b85987c7f7eff853f40267396d81c (diff) | |
| download | mullvadvpn-629780dd50805b3a44d9a11c9f51ca9caedce2c8.tar.xz mullvadvpn-629780dd50805b3a44d9a11c9f51ca9caedce2c8.zip | |
Merge branch 'installer-downloader'
69 files changed, 6910 insertions, 31 deletions
diff --git a/.github/actions/check-file-size/action.yml b/.github/actions/check-file-size/action.yml new file mode 100644 index 0000000000..cc40e2709a --- /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 0000000000..33ce992fb7 --- /dev/null +++ b/.github/workflows/downloader.yml @@ -0,0 +1,81 @@ +--- +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: 2621440 + 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: "./dist/Install Mullvad VPN.exe" + max_size: ${{ env.MAX_BINARY_SIZE }} + + build-macos: + runs-on: macos-latest + 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: 3145728 + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Install Rust + run: rustup target add x86_64-apple-darwin + + - name: Build + run: ./installer-downloader/build.sh + + - name: Check file size + uses: ./.github/actions/check-file-size + with: + artifact: "./dist/Install Mullvad VPN.dmg" + max_size: ${{ env.MAX_BINARY_SIZE }} diff --git a/Cargo.lock b/Cargo.lock index c02b5d23b3..c8d0716510 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -168,9 +168,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" @@ -205,6 +205,15 @@ dependencies = [ ] [[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" @@ -357,6 +366,12 @@ dependencies = [ ] [[package]] +name = "block" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a" + +[[package]] name = "block-buffer" version = "0.10.4" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -399,6 +414,24 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f61dac84819c6588b558454b194026eb1f09c293b9036ae9b159e74e73ab6cf9" [[package]] +name = "cacao" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5952f0958672e4aa8fc706d01905c56af57759e078c53a6fddf4a13361943e7a" +dependencies = [ + "block", + "core-foundation", + "core-graphics", + "dispatch", + "lazy_static", + "libc", + "objc", + "objc_id", + "os_info", + "url", +] + +[[package]] name = "camellia" version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -623,6 +656,18 @@ dependencies = [ ] [[package]] +name = "console" +version = "0.15.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea3c6ecd8059b57859df5c69830340ed3c41d30e3da0c1cbed90a96ac853041b" +dependencies = [ + "encode_unicode", + "libc", + "once_cell", + "windows-sys 0.59.0", +] + +[[package]] name = "const-oid" version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -651,6 +696,30 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06ea2b9bc92be3c2baa9334a323ebca2d6f074ff852cd1d7b11064035cd3868f" [[package]] +name = "core-graphics" +version = "0.22.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2581bbab3b8ffc6fcbd550bf46c355135d16e9ff2a6ea032ad6b9bf1d7efe4fb" +dependencies = [ + "bitflags 1.3.2", + "core-foundation", + "core-graphics-types", + "foreign-types", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf" +dependencies = [ + "bitflags 1.3.2", + "core-foundation", + "libc", +] + +[[package]] name = "cpufeatures" version = "0.2.12" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -734,6 +803,7 @@ dependencies = [ "cfg-if", "cpufeatures", "curve25519-dalek-derive", + "digest", "fiat-crypto", "rustc_version", "subtle", @@ -910,6 +980,12 @@ dependencies = [ ] [[package]] +name = "dispatch" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b" + +[[package]] name = "displaydoc" version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -954,6 +1030,20 @@ dependencies = [ ] [[package]] +name = "ed25519-dalek" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a3daa8e81a3963a60642bcc1f90a670680bd4a77535faa384e9d1c79d620871" +dependencies = [ + "curve25519-dalek", + "ed25519", + "serde", + "sha2", + "subtle", + "zeroize", +] + +[[package]] name = "either" version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -978,6 +1068,12 @@ dependencies = [ ] [[package]] +name = "encode_unicode" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" + +[[package]] name = "enum-as-inner" version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1142,6 +1238,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" [[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + +[[package]] name = "form_urlencoded" version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1891,6 +2002,45 @@ dependencies = [ ] [[package]] +name = "insta" +version = "1.42.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71c1b125e30d93896b365e156c33dadfffab45ee8400afcbba4752f59de08a86" +dependencies = [ + "console", + "linked-hash-map", + "once_cell", + "pin-project", + "serde", + "similar", +] + +[[package]] +name = "installer-downloader" +version = "0.0.0" +dependencies = [ + "anyhow", + "async-trait", + "cacao", + "chrono", + "fern", + "hex", + "insta", + "log", + "mullvad-paths", + "mullvad-update", + "native-windows-gui", + "objc_id", + "rand 0.8.5", + "reqwest", + "serde", + "talpid-platform-metadata", + "tokio", + "windows-sys 0.52.0", + "winres", +] + +[[package]] name = "internet-checksum" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2042,6 +2192,17 @@ dependencies = [ ] [[package]] +name = "json-canon" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "447ae153a2bd47d61acc0d131295408e32ef87ed9785825a6f4ecef85afc0edb" +dependencies = [ + "ryu-js", + "serde", + "serde_json", +] + +[[package]] name = "json5" version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2168,9 +2329,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.21" +version = "0.4.25" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90ed8c1e510134f979dbc4f070f87d4313098b704861a105fe34231c70a3901c" +checksum = "04cbf5b083de1c7e0222a7a51dbfdba1cbe1c6ab0b15e29fff3f6c077fd9cd9f" [[package]] name = "log-panics" @@ -2197,6 +2358,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9106e1d747ffd48e6be5bb2d97fa706ed25b144fbee4d5c02eae110cd8d6badd" [[package]] +name = "malloc_buf" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb" +dependencies = [ + "libc", +] + +[[package]] name = "match_cfg" version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2696,6 +2866,30 @@ dependencies = [ ] [[package]] +name = "mullvad-update" +version = "0.0.0" +dependencies = [ + "anyhow", + "async-tempfile", + "async-trait", + "chrono", + "clap", + "ed25519-dalek", + "hex", + "insta", + "json-canon", + "mockito", + "mullvad-version", + "rand 0.8.5", + "reqwest", + "serde", + "serde_json", + "sha2", + "thiserror 2.0.9", + "tokio", +] + +[[package]] name = "mullvad-version" version = "0.0.0" dependencies = [ @@ -2710,6 +2904,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "defc4c55412d89136f966bbb339008b474350e5e6e78d2714439c386b3137a03" [[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", + "winapi", + "winapi-build", +] + +[[package]] name = "natord" version = "1.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2930,6 +3136,15 @@ dependencies = [ ] [[package]] +name = "objc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1" +dependencies = [ + "malloc_buf", +] + +[[package]] name = "objc-sys" version = "0.3.5" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -3032,6 +3247,15 @@ dependencies = [ ] [[package]] +name = "objc_id" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92d4ddb4bd7b50d730c215ff871754d0da6b2178849f8a2a2ab69712d0c073b" +dependencies = [ + "objc", +] + +[[package]] name = "object" version = "0.32.2" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -3042,9 +3266,9 @@ dependencies = [ [[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" @@ -3070,6 +3294,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" [[package]] +name = "os_info" +version = "3.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e6520c8cc998c5741ee68ec1dc369fc47e5f0ea5320018ecf2a1ccd6328f48b" +dependencies = [ + "log", + "serde", + "windows-sys 0.52.0", +] + +[[package]] name = "os_pipe" version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -4056,6 +4291,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e86697c916019a8588c99b5fac3cead74ec0b4b819707a682fd4d23fa0ce1ba1" [[package]] +name = "ryu-js" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6518fc26bced4d53678a22d6e423e9d8716377def84545fe328236e3af070e7f" + +[[package]] name = "same-file" version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -5537,6 +5778,12 @@ dependencies = [ ] [[package]] +name = "winapi-build" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d315eee3b34aca4797b2da6b13ed88266e6d612562a0c46390af8299fc699bc" + +[[package]] name = "winapi-i686-pc-windows-gnu" version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -5640,6 +5887,7 @@ version = "0.0.0" dependencies = [ "anyhow", "mullvad-version", + "talpid-platform-metadata", "tempfile", "windows-sys 0.52.0", "winres", diff --git a/Cargo.toml b/Cargo.toml index 6214b68134..16baf26854 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 @@ -101,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" @@ -120,6 +123,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/.gitignore b/installer-downloader/.gitignore new file mode 100644 index 0000000000..42afabfd2a --- /dev/null +++ b/installer-downloader/.gitignore @@ -0,0 +1 @@ +/build
\ No newline at end of file diff --git a/installer-downloader/Cargo.toml b/installer-downloader/Cargo.toml new file mode 100644 index 0000000000..1062853668 --- /dev/null +++ b/installer-downloader/Cargo.toml @@ -0,0 +1,49 @@ +[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 = { workspace = true } +winres = "0.1" +windows-sys = { workspace = true, features = ["Win32_System", "Win32_System_LibraryLoader", "Win32_System_SystemServices"] } + +[target.'cfg(any(target_os = "windows", target_os = "macos"))'.dependencies] +anyhow = { workspace = true } +async-trait = "0.1" +chrono = { workspace = true, features = ["clock"] } +fern = { workspace = true } +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] +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] +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"] } + +[package.metadata.winres] +LegalCopyright = "(c) 2025 Mullvad VPN AB" diff --git a/installer-downloader/assets/Info.plist b/installer-downloader/assets/Info.plist new file mode 100644 index 0000000000..e99e3277c4 --- /dev/null +++ b/installer-downloader/assets/Info.plist @@ -0,0 +1,34 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> +<plist version="1.0"> +<dict> + <key>CFBundleDevelopmentRegion</key> + <string>English</string> + <key>CFBundleExecutable</key> + <string>installer-downloader</string> + <key>CFBundleGetInfoString</key> + <string></string> + <key>CFBundleIconFile</key> + <string>icon.icns</string> + <key>CFBundleIdentifier</key> + <string>net.mullvad.MullvadVPNInstaller</string> + <key>CFBundleInfoDictionaryVersion</key> + <string>6.0</string> + <key>CFBundleLongVersionString</key> + <string></string> + <key>CFBundleName</key> + <string>net.mullvad.MullvadVPNInstaller</string> + <key>CFBundlePackageType</key> + <string>APPL</string> + <key>CFBundleShortVersionString</key> + <string>0.1</string> + <key>CFBundleVersion</key> + <string></string> + <key>CSResourcesFileMapped</key> + <true/> + <key>LSRequiresCarbon</key> + <true/> + <key>NSHighResolutionCapable</key> + <true/> +</dict> +</plist>
\ No newline at end of file diff --git a/installer-downloader/assets/alert-circle.png b/installer-downloader/assets/alert-circle.png Binary files differnew file mode 100644 index 0000000000..d53d283e33 --- /dev/null +++ b/installer-downloader/assets/alert-circle.png diff --git a/installer-downloader/assets/alert-circle.svg b/installer-downloader/assets/alert-circle.svg new file mode 100644 index 0000000000..abb561611f --- /dev/null +++ b/installer-downloader/assets/alert-circle.svg @@ -0,0 +1,8 @@ +<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg"> +<mask id="mask0_4714_1057" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="32" height="32"> +<path d="M32 0H0V32H32V0Z" fill="#D9D9D9"/> +</mask> +<g mask="url(#mask0_4714_1057)"> +<path fill-rule="evenodd" clip-rule="evenodd" d="M30 16C30 23.732 23.732 30 16 30C8.26801 30 2 23.732 2 16C2 8.26801 8.26801 2 16 2C23.732 2 30 8.26801 30 16ZM17.4 23C17.4 23.7732 16.7732 24.4 16 24.4C15.2268 24.4 14.6 23.7732 14.6 23C14.6 22.2268 15.2268 21.6 16 21.6C16.7732 21.6 17.4 22.2268 17.4 23ZM17.4 17.4V9C17.4 8.2268 16.7732 7.6 16 7.6C15.2268 7.6 14.6 8.2268 14.6 9V17.4C14.6 18.1732 15.2268 18.8 16 18.8C16.7732 18.8 17.4 18.1732 17.4 17.4Z" fill="#E34039"/> +</g> +</svg>
\ No newline at end of file diff --git a/installer-downloader/assets/logo-icon.png b/installer-downloader/assets/logo-icon.png Binary files differnew file mode 100644 index 0000000000..d80eedadc2 --- /dev/null +++ b/installer-downloader/assets/logo-icon.png diff --git a/installer-downloader/assets/logo-icon.svg b/installer-downloader/assets/logo-icon.svg new file mode 100644 index 0000000000..e488329ac1 --- /dev/null +++ b/installer-downloader/assets/logo-icon.svg @@ -0,0 +1,37 @@ +<?xml version="1.0" encoding="utf-8"?> +<svg version="1.1" id="logo_00000183244726354177486890000003471613280854675596_" + xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 252 252" + style="enable-background:new 0 0 252 252;" xml:space="preserve"> +<style type="text/css"> + .fur{fill-rule:evenodd;clip-rule:evenodd;fill:#D2943B;} + .nose{fill-rule:evenodd;clip-rule:evenodd;fill:#FFCD86;} + .helmet{fill-rule:evenodd;clip-rule:evenodd;fill:#FFD524;} + .lamp-light{fill:#FFFFFF;} + .lamp-ring{fill:#192E45;} +</style> +<g id="mole"> + <g id="body"> + <path id="fur" class="fur" d="M23.9,96.6L16.8,111l9.6-13.4c0,0.1-0.6,19.3-0.6,19.3l2.7-14.5c7.2,13.7,23.1,34.8,45.4,50.5 + c1.7,1.2,3.9,3.3,4.6,4.1c0,0,21.3,10.8,54.1-14.2c0.3-0.2,0.7-0.4,1-0.6l0.5,0.3l7.2,4.8c-2.5-0.7-7.3-1.9-7.3-1.9 + c-15.8,18.2-41.5,21.2-55.3,12c-0.6-0.4-4,1-4.6,3c-0.4,1.1,0,2.3,0.5,3.2c2.8,5.2,7,4.7,5,10.8c-1.4,3.3-3.4,6.5-5.6,9.5 + c-4.6,6.2-11.8,11.7-11.1,15c32.6,40.2,106.1,34.6,134.1-1.3c-0.4-5.2-8.6-7.7-14.3-20.4c1.6,0.5,4,1.2,4,1.1 + c0-0.1-6.8-11.1-7.1-12.2l4.4,0.3c0,0-5.8-7.2-6-7.9l5.9-0.8c0,0-7.4-8.5-7.5-9.2l7.5,1.2l-8.2-9.9h3.9l-4.6-6.7l-37.3-14.7 + c-14.3-8.9-27-19.8-36.6-28.2l-19.3-9.4C63.3,79.4,46,79.9,35.4,82l6.8-11.6l-9.9,11.8C31.7,82,31,81.8,31,81.8l0.7-15l-3.3,14 + L23.9,96.6z"/> + <path id="nose" class="nose" d="M28.4,80.8c-4.9-2.3-10.6,1-11.6,5.5c-1.2,4.3,1.8,9.6,7.1,10.3C28.9,93.5,33.2,85.5,28.4,80.8z"/> + </g> + <g id="helmet"> + <path id="helmet" class="helmet" d="M101.2,69.9c-1.5-4.1-1.1-9.4,1-14.4c3-6.9,8.7-11.5,14.1-11.5c1.1,0,2.1,0.2,3.1,0.6 + c3.1-2.8,6.7-5.1,10.7-6.7c22-8.8,54.3,6.9,62.6,28.5c4,10.5,2.8,21.9-0.6,32.4c-2.8,8.6-13,21-9.2,30.3 + c-1.5-0.4-32.8-11-41.8-15.8c-14.1-8.8-26.7-19.6-36.2-27.9l-0.3-0.3l-32-15.2c-0.4-0.2-0.8-0.4-1.1-0.6 + C75.9,69.5,93.4,71.6,101.2,69.9"/> + <g id="lamp"> + + <ellipse id="lamp-light" transform="matrix(0.4007 -0.9162 0.9162 0.4007 12.8553 140.4693)" class="lamp-light" cx="113.8" cy="60.4" rx="13.6" ry="8.1"/> + <path id="lamp-ring" class="lamp-ring" d="M120.1,46.1c-5.3-2.3-12.4,2.2-15.9,10.1s-1.9,16.1,3.4,18.5c5.3,2.3,12.4-2.2,15.9-10.1 + S125.4,48.5,120.1,46.1z M120.6,63.4c-2.8,6.3-8.1,10.1-11.8,8.5c-3.8-1.7-4.6-8.1-1.8-14.5c2.8-6.3,8.1-10.1,11.9-8.5 + C122.6,50.6,123.4,57.1,120.6,63.4z"/> + </g> + </g> +</g> +</svg> diff --git a/installer-downloader/assets/logo-text.png b/installer-downloader/assets/logo-text.png Binary files differnew file mode 100644 index 0000000000..993083c7b1 --- /dev/null +++ b/installer-downloader/assets/logo-text.png diff --git a/installer-downloader/assets/logo-text.svg b/installer-downloader/assets/logo-text.svg new file mode 100644 index 0000000000..c3296186c2 --- /dev/null +++ b/installer-downloader/assets/logo-text.svg @@ -0,0 +1,19 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- Generator: Adobe Illustrator 23.0.6, SVG Export Plug-In . SVG Version: 6.00 Build 0) --> +<svg id="Mullvad_VPN_Logo_Positive" width="959.5" height="103.7" version="1.1" viewBox="0 0 959.5 103.7" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"> +<style type="text/css"> + .st4{fill:#FFFFFF;} +</style> +<g transform="translate(-381.4,-143.1)"> + <path class="st4" d="m381.4 144.7c0-0.9 0.6-1.5 1.5-1.5h17.8c1.2 0 2 0.4 2.4 1.5l25.8 58.2h0.6l25.3-58.2c0.4-1 1.2-1.5 2.4-1.5h17.5c0.9 0 1.5 0.6 1.5 1.5v99c0 0.9-0.6 1.5-1.5 1.5h-17c-0.9 0-1.5-0.6-1.5-1.5v-57.4h-0.6l-18.9 43c-0.6 1.4-1.5 2-2.8 2h-10.3c-1.3 0-2.2-0.6-2.8-2l-18.9-43h-0.6v57.4c0 0.9-0.6 1.5-1.5 1.5h-16.9c-0.9 0-1.5-0.6-1.5-1.5z"/> + <path class="st4" d="m498.4 207.7v-63c0-0.9 0.6-1.5 1.5-1.5h19c0.9 0 1.5 0.6 1.5 1.5v63.6c0 12.1 6.8 19 17.1 19 10.2 0 16.9-6.9 16.9-19v-63.6c0-0.9 0.6-1.5 1.5-1.5h19c0.9 0 1.5 0.6 1.5 1.5v63c0 25.3-16.2 39.1-39 39.1s-39-13.8-39-39.1z"/> + <path class="st4" d="m598.6 144.7c0-0.9 0.6-1.5 1.5-1.5h19c0.9 0 1.5 0.6 1.5 1.5v79.9c0 0.6 0.3 0.9 0.9 0.9h45c0.9 0 1.5 0.6 1.5 1.5v16.6c0 0.9-0.6 1.5-1.5 1.5h-66.4c-0.9 0-1.5-0.6-1.5-1.5z"/> + <path class="st4" d="m684.1 144.7c0-0.9 0.6-1.5 1.5-1.5h19c0.9 0 1.5 0.6 1.5 1.5v79.9c0 0.6 0.3 0.9 0.9 0.9h45c0.9 0 1.5 0.6 1.5 1.5v16.6c0 0.9-0.6 1.5-1.5 1.5h-66.4c-0.9 0-1.5-0.6-1.5-1.5z"/> + <path class="st4" d="m785.1 245.1c-1 0-1.6-0.6-1.9-1.5l-32.4-98.8c-0.3-1.1 0.3-1.6 1.3-1.6h19.5c1 0 1.7 0.4 2 1.5l20.4 66.9h0.4l19.8-66.9c0.3-1 0.9-1.5 1.9-1.5h19.3c0.9 0 1.5 0.6 1.2 1.6l-32.4 98.8c-0.3 0.9-0.9 1.5-1.8 1.5z"/> + <path class="st4" d="m872.5 144.7c0.3-0.9 0.9-1.5 2-1.5h18.9c1 0 1.6 0.6 1.9 1.5l34.5 99c0.3 0.9 0 1.5-1 1.5h-19.5c-1 0-1.7-0.5-2-1.5l-5.8-17.8h-35.7l-5.7 17.8c-0.3 1-0.9 1.5-2 1.5h-19.6c-1 0-1.3-0.6-1-1.5zm23 62.4-11.5-35.7h-0.4l-11.5 35.7z"/> + <path class="st4" d="m944.7 144.7c0-0.9 0.6-1.5 1.5-1.5h37.9c17.8 0 30.3 7.6 35.2 22.9 1.8 5.7 2.7 11.4 2.7 28s-0.9 22.3-2.7 28c-4.9 15.3-17.4 22.9-35.2 22.9h-37.9c-0.9 0-1.5-0.6-1.5-1.5zm22.9 80.8h11.5c10.2 0 16.3-3 18.9-11.2 1-3 1.7-6.9 1.7-20.1s-0.6-17.1-1.7-20.1c-2.5-8.2-8.7-11.2-18.9-11.2h-11.5c-0.6 0-0.9 0.3-0.9 0.9v60.9c0 0.5 0.3 0.8 0.9 0.8z"/> + <path class="st4" d="m1102.5 245.1c-1.1 0-1.7-0.6-2-1.5l-32.4-98.8c-0.3-1.1 0.3-1.6 1.3-1.6h19.5c1 0 1.7 0.4 2 1.5l20.4 66.9h0.4l19.8-66.9c0.3-1 0.9-1.5 1.9-1.5h19.3c0.9 0 1.5 0.6 1.2 1.6l-32.4 98.8c-0.3 0.9-0.9 1.5-1.8 1.5z"/> + <path class="st4" d="m1170 245.1c-0.9 0-1.5-0.6-1.5-1.5v-99c0-0.9 0.6-1.5 1.5-1.5h39.6c22.2 0 35.5 13.3 35.5 32.8 0 19.2-13.5 32.7-35.5 32.7h-18.1c-0.6 0-0.9 0.3-0.9 0.9v34c0 0.9-0.6 1.5-1.5 1.5h-19.1zm53.1-69.1c0-8.2-5.5-13.8-14.8-13.8h-16.8c-0.6 0-0.9 0.3-0.9 0.9v25.6c0 0.6 0.3 0.9 0.9 0.9h16.8c9.2 0.1 14.8-5.3 14.8-13.6z"/> + <path class="st4" d="m1262.2 144.7c0-0.9 0.6-1.5 1.5-1.5h18c1 0 2 0.4 2.5 1.5l36 64.2h0.8v-64.2c0-0.9 0.6-1.5 1.5-1.5h16.9c0.9 0 1.5 0.6 1.5 1.5v99c0 0.9-0.6 1.5-1.5 1.5h-17.8c-1.2 0-2-0.5-2.5-1.5l-36.1-64h-0.8v64c0 0.9-0.6 1.5-1.5 1.5h-16.9c-0.9 0-1.5-0.6-1.5-1.5v-99z"/> +</g> +</svg> diff --git a/installer-downloader/build.rs b/installer-downloader/build.rs new file mode 100644 index 0000000000..64b0d66d00 --- /dev/null +++ b/installer-downloader/build.rs @@ -0,0 +1,36 @@ +use anyhow::Context; +use std::env; + +fn main() -> anyhow::Result<()> { + 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(()), + } +} + +fn win_main() -> anyhow::Result<()> { + use anyhow::Context; + + 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.set_icon("../dist-assets/icon.ico"); + + 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/build.sh b/installer-downloader/build.sh new file mode 100755 index 0000000000..5e69a1eb81 --- /dev/null +++ b/installer-downloader/build.sh @@ -0,0 +1,320 @@ +#!/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 + +CARGO_TARGET_DIR=${CARGO_TARGET_DIR:-"../target"} +export CARGO_TARGET_DIR + +# Temporary build directory +BUILD_DIR="./build" +# Successfully built (and signed) artifacts +DIST_DIR="../dist" + +BUNDLE_NAME="MullvadVPNInstaller" +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=() + + if [[ -n "${1-}" ]]; then + target_args+=(--target "$1") + fi + + # Old bash versions complain about empty array expansion when -u is set + set +u + + 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 +} + +# 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 "$BUILD_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 "$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="$BUILD_DIR/$FILENAME.app/" + local dmg_path="$BUILD_DIR/$FILENAME.dmg" + + # Build app bundle + log_info "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 "$BUILD_DIR/installer-downloader" "$app_path/Contents/MacOS/installer-downloader" + + # Sign app bundle + if [[ "$SIGN" != "false" ]]; then + setup_macos_keychain + fi + sign_macos "$app_path" + + # Pack in .dmg + log_info "Creating $dmg_path image..." + hdiutil create -volname "$FILENAME" -srcfolder "$app_path" -ov -format UDZO \ + "$dmg_path" + + # Sign .dmg + sign_macos "$dmg_path" + + # Notarize .dmg + if [[ "$SIGN" != "false" ]]; then + notarize_mac "$dmg_path" + fi + + # 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/" +} + +# Notarize and staple a file. +# Arguments: +# - file to sign +function notarize_mac { + local file="$1" + + 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 + + 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 +} + +main diff --git a/installer-downloader/convert-assets.py b/installer-downloader/convert-assets.py new file mode 100644 index 0000000000..d267154544 --- /dev/null +++ b/installer-downloader/convert-assets.py @@ -0,0 +1,10 @@ +#!/usr/bin/env python + +# Convert svg assets to png assets +# This must be done manually + +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) +svg2png(url="assets/alert-circle.svg", write_to="assets/alert-circle.png", output_width=32) diff --git a/installer-downloader/loader.manifest b/installer-downloader/loader.manifest new file mode 100644 index 0000000000..feedbf9bc0 --- /dev/null +++ b/installer-downloader/loader.manifest @@ -0,0 +1,37 @@ +<?xml version="1.0" encoding="UTF-8" standalone="yes"?> +<assembly xmlns="urn:schemas-microsoft-com:asm.v1" manifestVersion="1.0" xmlns:asmv3="urn:schemas-microsoft-com:asm.v3"> +<trustInfo xmlns="urn:schemas-microsoft-com:asm.v3"> + <security> + <requestedPrivileges> + <requestedExecutionLevel + level="requireAdministrator" + uiAccess="false"/> + </requestedPrivileges> + </security> +</trustInfo> +<assemblyIdentity + version="1.0.0.0" + processorArchitecture="*" + name="Mullvad.MullvadVPN.Installer" + type="win32" +/> +<description>Web installer</description> +<dependency> + <dependentAssembly> + <assemblyIdentity + type="win32" + name="Microsoft.Windows.Common-Controls" + version="6.0.0.0" + processorArchitecture="*" + publicKeyToken="6595b64144ccf1df" + language="*" + /> + </dependentAssembly> +</dependency> +<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1"> + <application> + <!--The ID below indicates app support for Windows 10 and Windows 11 --> + <supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}"/> + </application> +</compatibility> +</assembly> diff --git a/installer-downloader/src/cacao_impl/delegate.rs b/installer-downloader/src/cacao_impl/delegate.rs new file mode 100644 index 0000000000..f4378ae04e --- /dev/null +++ b/installer-downloader/src/cacao_impl/delegate.rs @@ -0,0 +1,194 @@ +use std::sync::{Arc, Mutex}; + +use cacao::{control::Control, layout::Layout}; + +use super::ui::{Action, AppWindow, ErrorView}; +use crate::delegate::{AppDelegate, AppDelegateQueue}; + +impl AppDelegate for AppWindow { + type Queue = Queue; + + fn on_download<F>(&mut self, callback: F) + where + F: Fn() + Send + 'static, + { + self.download_button.set_callback(callback); + } + + fn on_cancel<F>(&mut self, callback: F) + where + F: Fn() + Send + 'static, + { + self.cancel_button.set_callback(callback); + } + + fn on_beta_link<F>(&mut self, callback: F) + where + F: Fn() + Send + 'static, + { + self.beta_link.set_callback(callback); + } + + fn set_status_text(&mut self, text: &str) { + 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(); + } + + fn clear_download_text(&mut self) { + self.download_text.set_text(""); + self.readjust_status_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 clear_download_progress(&mut self) { + self.progress.set_value(0.); + } + + fn show_download_button(&mut self) { + self.download_button.set_hidden(false); + } + + fn hide_download_button(&mut self) { + self.download_button.set_hidden(true); + } + + 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_hidden(false); + } + + fn hide_cancel_button(&mut self) { + self.cancel_button.set_hidden(true); + } + + 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_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 {} + } + + fn quit(&mut self) { + cacao::appkit::App::<super::ui::AppImpl, _>::dispatch_main(Action::Quit); + } + + fn on_stable_link<F>(&mut self, callback: F) + where + F: Fn() + Send + 'static, + { + self.stable_link.set_callback(callback); + } + + fn show_stable_text(&mut self) { + self.stable_link.set_hidden(false); + } + + fn hide_stable_text(&mut self) { + self.stable_link.set_hidden(true); + } + + fn show_error_message(&mut self, message: installer_downloader::delegate::ErrorMessage) { + let on_cancel = self.error_cancel_callback.clone().map(|callback| { + move || { + let callback = callback.clone(); + let callback = Action::ButtonClick { callback }; + cacao::appkit::App::<super::ui::AppImpl, _>::dispatch_main(callback); + } + }); + + let on_retry = self.error_retry_callback.clone().map(|callback| { + move || { + let callback = callback.clone(); + let callback = Action::ButtonClick { callback }; + cacao::appkit::App::<super::ui::AppImpl, _>::dispatch_main(callback); + } + }); + + 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<F>(&mut self, callback: F) + where + F: Fn() + Send + 'static, + { + self.error_retry_callback = Some(Self::sync_callback(callback)); + } + + fn on_error_message_cancel<F>(&mut self, callback: F) + where + F: Fn() + Send + 'static, + { + self.error_cancel_callback = Some(Self::sync_callback(callback)); + } +} + +impl AppWindow { + // NOTE: We need this horrible lock because Dispatcher demands Sync, but AppDelegate does not require Sync + fn sync_callback( + callback: impl Fn() + Send + 'static, + ) -> Arc<Mutex<Box<dyn Fn() + Send + 'static>>> { + Arc::new(Mutex::new(Box::new(callback))) + } +} + +/// This simply mutates the UI on the main thread +#[derive(Clone)] +pub struct Queue {} + +impl AppDelegateQueue<AppWindow> for Queue { + fn queue_main<F: FnOnce(&mut AppWindow) + 'static + Send>(&self, callback: F) { + // NOTE: We need this horrible lock because Dispatcher demands Sync + let cb: Mutex<Option<super::ui::MainThreadCallback>> = Mutex::new(Some(Box::new(callback))); + cacao::appkit::App::<super::ui::AppImpl, _>::dispatch_main(Action::QueueMain(cb)); + } +} diff --git a/installer-downloader/src/cacao_impl/mod.rs b/installer-downloader/src/cacao_impl/mod.rs new file mode 100644 index 0000000000..9bd044d5fa --- /dev/null +++ b/installer-downloader/src/cacao_impl/mod.rs @@ -0,0 +1,27 @@ +use std::sync::Mutex; + +use cacao::appkit::App; +use installer_downloader::environment::{Environment, Error as EnvError}; +use ui::{Action, AppImpl}; + +mod delegate; +mod ui; + +pub fn main() { + let app = App::new("net.mullvad.MullvadVPNInstaller", 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<Option<ui::MainThreadCallback>> = Mutex::new(Some(Box::new(|self_| { + crate::controller::initialize_controller(self_, environment); + }))); + cacao::appkit::App::<ui::AppImpl, _>::dispatch_main(Action::QueueMain(cb)); + + app.run(); +} diff --git a/installer-downloader/src/cacao_impl/ui.rs b/installer-downloader/src/cacao_impl/ui.rs new file mode 100644 index 0000000000..745a725500 --- /dev/null +++ b/installer-downloader/src/cacao_impl/ui.rs @@ -0,0 +1,529 @@ +use std::cell::RefCell; +use std::ops::{Deref, DerefMut}; +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::Label; +use cacao::view::View; +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, + STABLE_LINK_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"); + +const ALERT_CIRCLE_IMAGE_DATA: &[u8] = include_bytes!("../../assets/alert-circle.svg"); + +/// Banner background color: #192e45 +static BANNER_COLOR: LazyLock<Color> = LazyLock::new(|| { + let r = 0x19 as f64 / 255.; + let g = 0x2e as f64 / 255.; + let b = 0x45 as f64 / 255.; + let a = 1.; + + // NOTE: colorWithCalibratedRed is used by cacao by default, but it renders a different color + // than it does for background color of the image. I believe this is because the + // calibrated uses the current color profile. + // Maybe using calibrated colors is more correct? Rendering different colors *definitely* + // is not. + let id = + // 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))) +}); + +static LOGO: LazyLock<Image> = LazyLock::new(|| Image::with_data(LOGO_IMAGE_DATA)); +static LOGO_TEXT: LazyLock<Image> = LazyLock::new(|| Image::with_data(LOGO_TEXT_DATA)); +static ALERT_CIRCLE: LazyLock<Image> = LazyLock::new(|| Image::with_data(ALERT_CIRCLE_IMAGE_DATA)); + +pub struct AppImpl { + window: Window<AppWindowWrapper>, +} + +impl Default for AppImpl { + fn default() -> Self { + Self { + window: Window::with(WindowConfig::default(), AppWindowWrapper::default()), + } + } +} + +impl AppDelegate for AppImpl { + fn did_finish_launching(&self) { + App::activate(); + + self.window.show(); + + let delegate = self.window.delegate.as_ref().unwrap(); + delegate.inner.borrow_mut().layout(); + } + + fn should_terminate_after_last_window_closed(&self) -> bool { + true + } +} + +/// Dispatcher actions +pub enum Action { + /// User clicked a button. + ButtonClick { + /// The callback to be invoked in the main thread. + callback: Arc<Mutex<Box<dyn Fn() + Send>>>, + }, + /// Run callback on main thread + QueueMain(Mutex<Option<MainThreadCallback>>), + /// Quit the application. + Quit, +} + +/// Callback used for `QueueMain` +pub type MainThreadCallback = Box<dyn for<'a> FnOnce(&'a mut AppWindow) + Send>; + +impl Dispatcher for AppImpl { + type Message = Action; + + fn on_ui_message(&self, message: Self::Message) { + let delegate = self.window.delegate.as_ref().unwrap(); + match message { + Action::ButtonClick { callback } => { + let callback = callback.lock().unwrap(); + callback(); + } + Action::QueueMain(cb) => { + let mut borrowed = delegate.inner.borrow_mut(); + let cb = cb.lock().unwrap().take().unwrap(); + cb(&mut borrowed); + } + Action::Quit => { + self.window.close(); + } + } + } + + fn on_background_message(&self, _message: Self::Message) {} +} + +#[derive(Default)] +pub struct AppWindowWrapper { + pub inner: RefCell<AppWindow>, +} + +#[derive(Default)] +pub struct AppWindow { + pub content: View, + + pub banner: View, + pub banner_logo_view: ImageView, + pub banner_logo_text_view: ImageView, + pub banner_desc: Label, + + pub main_view: View, + + pub download_button: DownloadButton, + pub cancel_button: CancelButton, + + pub progress: ProgressIndicator, + + pub status_text: Label, + /// 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<LayoutConstraint>, + + pub error_view: Option<ErrorView>, + pub error_retry_callback: Option<Arc<Mutex<ErrorViewClickCallback>>>, + pub error_cancel_callback: Option<Arc<Mutex<ErrorViewClickCallback>>>, + + pub download_text: Label, + + pub beta_link_preface: Label, + pub beta_link: LinkToBeta, + + pub stable_link: LinkToStable, +} + +pub struct ErrorView { + pub view: View, + pub text: Label, + pub circle: ImageView, + pub retry_button: Button, + pub cancel_button: Button, +} + +pub type ErrorViewClickCallback = Box<dyn Fn() + Send>; + +/// 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 $name { + fn default() -> Self { + Self { + button: Button::new(&$text), + } + } + } + + impl Deref for $name { + type Target = ::cacao::button::Button; + fn deref(&self) -> &Self::Target { + &self.button + } + } + + impl DerefMut for $name { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.button + } + } + + impl $name { + /// 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<Mutex> 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<dyn Fn() + Send>; + let callback = Arc::new(Mutex::new(callback)); + self.button.set_action(move || { + let callback = callback.clone(); + let callback = Action::ButtonClick { callback }; + cacao::appkit::App::<super::ui::AppImpl, _>::dispatch_main(callback); + }); + } + } + }; +} + +button_wrapper!(LinkToBeta, BETA_LINK_TEXT); +button_wrapper!(LinkToStable, format!("← {STABLE_LINK_TEXT}")); +button_wrapper!(DownloadButton, DOWNLOAD_BUTTON_TEXT); +button_wrapper!(CancelButton, CANCEL_BUTTON_TEXT); + +impl AppWindow { + 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); + + 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); + + self.beta_link.set_text_color(Color::Link); + self.beta_link.set_bordered(false); + 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 + .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(4.), + 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 + .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_text.bottom), + 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(36.), + 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), + 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) + .offset(-24.), + self.beta_link_preface + .left + .constraint_equal_to(&self.main_view.left) + .offset(24.), + self.beta_link + .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), + ]); + } + + // 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 { + 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); + } +} + +impl ErrorView { + pub fn new( + main_view: &View, + message: ErrorMessage, + on_retry: Option<impl Fn() + Send + Sync + 'static>, + on_cancel: Option<impl Fn() + Send + Sync + 'static>, + ) -> 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(); + } +} diff --git a/installer-downloader/src/controller.rs b/installer-downloader/src/controller.rs new file mode 100644 index 0000000000..c01222c9b3 --- /dev/null +++ b/installer-downloader/src/controller.rs @@ -0,0 +1,461 @@ +//! This module implements the actual logic performed by different UI components. + +use crate::{ + delegate::{AppDelegate, AppDelegateQueue}, + environment::Environment, + resource, + temp::DirectoryProvider, + ui_downloader::{UiAppDownloader, UiAppDownloaderParameters, UiProgressUpdater}, +}; + +use mullvad_update::{ + api::{HttpVersionInfoProvider, VersionInfoProvider}, + app::{self, AppDownloader, HttpAppDownloader}, + version::{Version, VersionInfo, VersionParameters}, +}; +use rand::seq::SliceRandom; +use std::path::PathBuf; +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"); + +/// 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 `<base +/// url>/<platform>.json` +const META_REPOSITORY_URL: &str = "https://api.stagemole.eu/app/releases/"; + +/// Actions handled by an async worker task in [ActionMessageHandler]. +enum TaskMessage { + BeginDownload, + Cancel, + TryBeta, + TryStable, +} + +/// See the [module-level docs](self). +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<T: AppDelegate + 'static>(delegate: &mut T, environment: Environment) { + // App downloader to use + type Downloader<T> = HttpAppDownloader<UiProgressUpdater<T>>; + // Directory provider to use + type DirProvider = crate::temp::TempDirProvider; + + // Version info provider to use + let verifying_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(), + pinned_certificate: Some(cert), + verifying_key, + }; + + AppController::initialize::<_, Downloader<T>, _, DirProvider>( + delegate, + version_provider, + environment, + ) +} + +/// JSON files should be stored at `<base url>/<platform>.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!("{META_REPOSITORY_URL}/{PLATFORM}.json") +} + +impl AppController { + /// Initialize [AppController] using the provided delegate. + /// + /// This function lets the caller provide a version information provider, download client, etc., + /// which is useful for testing. + pub fn initialize<D, A, V, DirProvider>( + delegate: &mut D, + version_provider: V, + environment: Environment, + ) where + D: AppDelegate + 'static, + V: VersionInfoProvider + Send + 'static, + A: From<UiAppDownloaderParameters<D>> + AppDownloader + 'static, + DirProvider: DirectoryProvider + 'static, + { + delegate.hide_download_progress(); + delegate.show_download_button(); + delegate.disable_download_button(); + delegate.hide_cancel_button(); + delegate.hide_beta_text(); + delegate.hide_stable_text(); + + let (task_tx, task_rx) = mpsc::channel(1); + let queue = delegate.queue(); + let task_tx_clone = task_tx.clone(); + tokio::spawn(async move { + let version_info = + fetch_app_version_info::<D, V>(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::<D, A>::run::<DirProvider>( + queue, + task_tx_clone, + task_rx, + version_info, + ) + .await; + }); + + Self::register_user_action_callbacks(delegate, task_tx); + } + + fn register_user_action_callbacks<T: AppDelegate + 'static>( + delegate: &mut T, + task_tx: mpsc::Sender<TaskMessage>, + ) { + let tx = task_tx.clone(); + delegate.on_download(move || { + let _ = tx.try_send(TaskMessage::BeginDownload); + }); + let tx = task_tx.clone(); + delegate.on_cancel(move || { + let _ = tx.try_send(TaskMessage::Cancel); + }); + 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); + }); + } +} + +/// Background task that fetches app version data. +async fn fetch_app_version_info<Delegate, VersionProvider>( + queue: Delegate::Queue, + version_provider: VersionProvider, + Environment { architecture }: Environment, +) -> VersionInfo +where + Delegate: AppDelegate, + 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 + rollout: mullvad_update::version::IGNORE, + // The downloader allows any version + 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) => { + return version_info; + } + Err(err) => err, + }; + + log::error!("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 (main) 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); + + self_.clear_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 action = action_rx.recv().await.expect("sender unexpectedly dropped"); + + 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(); + }); + } + } + } +} + +#[derive(Clone, Copy, PartialEq)] +enum TargetVersion { + Beta, + Stable, +} + +/// Async worker that handles actions such as initiating a download, cancelling it, and updating +/// labels. +struct ActionMessageHandler< + D: AppDelegate + 'static, + A: From<UiAppDownloaderParameters<D>> + AppDownloader + 'static, +> { + queue: D::Queue, + tx: mpsc::Sender<TaskMessage>, + version_info: VersionInfo, + active_download: Option<JoinHandle<()>>, + target_version: TargetVersion, + temp_dir: anyhow::Result<PathBuf>, + + _marker: std::marker::PhantomData<A>, +} + +impl<D: AppDelegate + 'static, A: From<UiAppDownloaderParameters<D>> + AppDownloader + 'static> + ActionMessageHandler<D, A> +{ + /// Run the [ActionMessageHandler] actor until the end of the program/execution + async fn run<DP: DirectoryProvider>( + queue: D::Queue, + tx: mpsc::Sender<TaskMessage>, + mut rx: mpsc::Receiver<TaskMessage>, + version_info: VersionInfo, + ) { + let temp_dir = DP::create_download_dir().await; + + let mut handler = Self { + queue, + tx, + version_info, + active_download: None, + target_version: TargetVersion::Stable, + temp_dir, + + _marker: std::marker::PhantomData, + }; + + while let Some(msg) = rx.recv().await { + handler.handle_message(&msg).await; + } + } + + async fn handle_message(&mut self, msg: &TaskMessage) { + match msg { + 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_try_beta(&mut self) { + log::error!("Attempted 'try beta' without beta version"); + let Some(beta_info) = self.version_info.beta.as_ref() else { + return; + }; + + 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); + }); + } + + fn handle_try_stable(&mut self) { + let stable_info = &self.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) { + self.cancel_download().await; + + 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_.clear_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(), + }); + }); + return; + } + }; + + log::debug!("Download directory: {}", download_dir.display()); + + // 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, + 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_.clear_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(); + } + + async fn cancel(&mut self) { + self.cancel_download().await; + + let selected_version = match self.target_version { + 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 = self.version_info.beta.is_some(); + let target_version = self.target_version; + + self.queue.queue_main(move |self_| { + self_.set_status_text(&version_label); + self_.clear_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_.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 +/// 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) +} diff --git a/installer-downloader/src/delegate.rs b/installer-downloader/src/delegate.rs new file mode 100644 index 0000000000..b33ea96b46 --- /dev/null +++ b/installer-downloader/src/delegate.rs @@ -0,0 +1,124 @@ +//! Framework-agnostic module that hooks up a UI to actions + +pub use crate::ui_downloader::UiProgressUpdater; + +/// Trait implementing high-level UI actions +pub trait AppDelegate { + /// Queue lets us perform actions from other threads + type Queue: AppDelegateQueue<Self>; + + /// Register click handler for the download button + fn on_download<F>(&mut self, callback: F) + where + F: Fn() + Send + 'static; + + /// Register click handler for the cancel button + fn on_cancel<F>(&mut self, callback: F) + where + F: Fn() + Send + 'static; + + /// Register click handler for the beta link + fn on_beta_link<F>(&mut self, callback: F) + where + F: Fn() + Send + 'static; + + /// Register click handler for the stable link + fn on_stable_link<F>(&mut self, callback: F) + where + F: Fn() + Send + 'static; + + /// 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); + + /// Hide download progress bar + fn hide_download_progress(&mut self); + + /// 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); + + /// 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); + + /// Show stable text + fn show_stable_text(&mut self); + + /// 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<F>(&mut self, callback: F) + where + F: Fn() + Send + 'static; + + /// Set error cancel callback + fn on_error_message_cancel<F>(&mut self, callback: F) + where + F: Fn() + Send + 'static; + + /// Exit the application + fn quit(&mut self); + + /// Create queue for scheduling actions on UI (main) thread + 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 (main) thread from other threads +pub trait AppDelegateQueue<T: ?Sized>: Send + Clone { + /// Schedule action on the UI (main) thread from other threads + fn queue_main<F: FnOnce(&mut T) + 'static + Send>(&self, callback: F); +} diff --git a/installer-downloader/src/environment.rs b/installer-downloader/src/environment.rs new file mode 100644 index 0000000000..f2bb691d5a --- /dev/null +++ b/installer-downloader/src/environment.rs @@ -0,0 +1,38 @@ +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: Architecture, +} + +pub type 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<Self, Error> { + 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<VersionArchitecture, Error> { + 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 new file mode 100644 index 0000000000..e787ccca78 --- /dev/null +++ b/installer-downloader/src/lib.rs @@ -0,0 +1,9 @@ +#![cfg(any(target_os = "windows", target_os = "macos"))] + +pub mod controller; +pub mod delegate; +pub mod environment; +pub mod log; +pub mod resource; +pub mod temp; +pub mod ui_downloader; diff --git a/installer-downloader/src/log.rs b/installer-downloader/src/log.rs new file mode 100644 index 0000000000..5e1cd8c861 --- /dev/null +++ b/installer-downloader/src/log.rs @@ -0,0 +1,28 @@ +use chrono::Local; +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| { + 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(LOG_FILENAME) +} diff --git a/installer-downloader/src/main.rs b/installer-downloader/src/main.rs new file mode 100644 index 0000000000..7be9b76b2c --- /dev/null +++ b/installer-downloader/src/main.rs @@ -0,0 +1,42 @@ +#![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::log; + pub use installer_downloader::resource; + + pub fn run() { + log::init().expect("failed to set up logger"); + + let rt = tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build() + .expect("failed to create tokio runtime"); + let _guard = rt.enter(); + + #[cfg(target_os = "windows")] + super::winapi_impl::main(); + + #[cfg(target_os = "macos")] + super::cacao_impl::main(); + } +} + +#[cfg(not(any(target_os = "windows", target_os = "macos")))] +mod inner { + pub fn run() {} +} + +use inner::*; + +fn main() { + run() +} diff --git a/installer-downloader/src/resource.rs b/installer-downloader/src/resource.rs new file mode 100644 index 0000000000..24c59995c3 --- /dev/null +++ b/installer-downloader/src/resource.rs @@ -0,0 +1,83 @@ +//! Shared text and other resources + +/// Window title +pub const WINDOW_TITLE: &str = "Mullvad VPN installer"; +/// 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!"; + +/// 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); + +/// 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 = "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"; + +/// 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 \<some url\>... (x%)", displayed during download +pub const DOWNLOADING_DESC_PREFIX: &str = "Downloading from"; + +/// Displayed after completed download +pub const DOWNLOAD_COMPLETE_DESC: &str = "Download complete. Verifying..."; + +/// Displayed when download fails +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 = "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 = "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 = "Try again"; + +/// 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 = "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"; + +/// Displayed when launch fails (cancel button) +pub const LAUNCH_FAILED_CANCEL_BUTTON_TEXT: &str = "Cancel"; diff --git a/installer-downloader/src/temp.rs b/installer-downloader/src/temp.rs new file mode 100644 index 0000000000..a40019df4f --- /dev/null +++ b/installer-downloader/src/temp.rs @@ -0,0 +1,93 @@ +//! 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. +//! +//! 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 +//! 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 async_trait::async_trait; +use std::path::PathBuf; + +/// Provide a directory to use for [mullvad_update::app::AppDownloader] +#[async_trait] +pub trait DirectoryProvider { + /// Provide a directory to use for [mullvad_update::app::AppDownloader] + async fn create_download_dir() -> anyhow::Result<PathBuf>; +} + +/// See [module-level](self) docs. +pub struct TempDirProvider; + +#[async_trait] +impl DirectoryProvider for TempDirProvider { + /// Create a locked-down directory to store downloads in + async fn create_download_dir() -> anyhow::Result<PathBuf> { + #[cfg(windows)] + { + admin_temp_dir().await + } + + #[cfg(target_os = "macos")] + { + temp_dir().await + } + } +} + +/// This returns a directory where only admins have write access. +/// +/// See [module-level](self) docs for more information. +#[cfg(windows)] +async fn admin_temp_dir() -> anyhow::Result<PathBuf> { + /// 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) +} + +/// 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<PathBuf> { + 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/installer-downloader/src/ui_downloader.rs b/installer-downloader/src/ui_downloader.rs new file mode 100644 index 0000000000..c69b1f9599 --- /dev/null +++ b/installer-downloader/src/ui_downloader.rs @@ -0,0 +1,210 @@ +//! This module hooks up [AppDelegate]s to arbitrary implementations of [AppDownloader] and +//! [fetch::ProgressUpdater]. + +use crate::{ + delegate::{AppDelegate, AppDelegateQueue}, + resource, +}; +use mullvad_update::{ + app::{self, AppDownloader, AppDownloaderParameters}, + fetch, +}; + +/// [AppDownloader] that delegates the actual work to some underlying `downloader` and uses it to +/// update a UI. +pub struct UiAppDownloader<Delegate: AppDelegate, Downloader> { + downloader: Downloader, + /// Queue used to control the app UI + queue: Delegate::Queue, +} + +/// Parameters for [UiAppDownloader] +pub type UiAppDownloaderParameters<Delegate> = AppDownloaderParameters<UiProgressUpdater<Delegate>>; + +impl<Delegate: AppDelegate, Downloader: AppDownloader + Send + 'static> + UiAppDownloader<Delegate, Downloader> +{ + /// Construct a [UiAppDownloader]. + pub fn new(delegate: &Delegate, downloader: Downloader) -> Self { + Self { + downloader, + queue: delegate.queue(), + } + } +} + +#[async_trait::async_trait] +impl<Delegate: AppDelegate, Downloader: AppDownloader + Send + 'static> AppDownloader + for UiAppDownloader<Delegate, Downloader> +{ + async fn download_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_.clear_status_text(); + self_.clear_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) + } + } + } + + 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_.clear_status_text(); + self_.clear_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) + } + } + } + + 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_.clear_status_text(); + self_.clear_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) + } + } + } +} + +/// Implementation of [fetch::ProgressUpdater] that updates some [AppDelegate]. +pub struct UiProgressUpdater<Delegate: AppDelegate> { + domain: Option<String>, + prev_progress: Option<u32>, + queue: Delegate::Queue, +} + +impl<Delegate: AppDelegate> UiProgressUpdater<Delegate> { + pub fn new(queue: Delegate::Queue) -> Self { + Self { + domain: None, + prev_progress: None, + 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() + ) + } + + fn domain(&self) -> &str { + self.domain.as_deref().unwrap_or("unknown source") + } +} + +impl<Delegate: AppDelegate + 'static> fetch::ProgressUpdater for UiProgressUpdater<Delegate> { + fn set_progress(&mut self, fraction_complete: f32) { + let value = Self::complete_from_percentage(fraction_complete); + + if !self.need_update(value) { + return; + } + + let status = self.status_text(value); + + self.queue.queue_main(move |self_| { + self_.set_download_progress(value); + self_.set_download_text(&status); + }); + } + + 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) { + // Parse out domain name + let url = url.strip_prefix("https://").unwrap_or(url); + let (domain, _) = url.split_once('/').unwrap_or((url, "")); + self.domain = Some(domain.to_owned()); + } +} diff --git a/installer-downloader/src/winapi_impl/delegate.rs b/installer-downloader/src/winapi_impl/delegate.rs new file mode 100644 index 0000000000..1734642771 --- /dev/null +++ b/installer-downloader/src/winapi_impl/delegate.rs @@ -0,0 +1,252 @@ +//! 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; + +use super::ui::{AppWindow, QUEUE_MESSAGE}; +use crate::delegate::{AppDelegate, AppDelegateQueue}; + +impl AppDelegate for AppWindow { + type Queue = Queue; + + fn on_download<F>(&mut self, callback: F) + where + F: Fn() + Send + 'static, + { + register_click_handler(self.window.handle, self.download_button.handle, callback); + } + + fn on_cancel<F>(&mut self, callback: F) + where + F: Fn() + Send + 'static, + { + register_click_handler(self.window.handle, self.cancel_button.handle, callback); + } + + fn on_beta_link<F>(&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<F>(&mut self, callback: F) + where + F: Fn() + Send + 'static, + { + register_frame_click_handler(self.stable_message_frame.handle, callback); + } + + fn set_status_text(&mut self, text: &str) { + 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) { + 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) { + 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 clear_download_progress(&mut self) { + self.progress_bar.set_pos(0); + } + + 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 show_stable_text(&mut self) { + self.stable_message_frame.set_visible(true); + } + + fn hide_stable_text(&mut self) { + 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<F>(&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<F>(&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(); + } + + 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, +) { + 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 == click_event && handle == button { + callback(); + } + }); +} + +/// 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 UI (main) thread. +/// +/// See [QueueContext] docs for more information. +#[derive(Clone)] +pub struct Queue { + main_wnd: nwg::ControlHandle, +} + +// 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 +/// along with a custom window message `QUEUE_MESSAGE`. +/// +/// It must be wrapped in a struct since we cannot pass a fat pointer +/// `*mut dyn for<'a> FnOnce(&'a mut AppWindow) + Send` to `PostMessageW`. +pub struct QueueContext { + pub callback: Box<dyn for<'a> FnOnce(&'a mut AppWindow) + Send>, +} + +impl AppDelegateQueue<AppWindow> for Queue { + fn queue_main<F: FnOnce(&mut AppWindow) + 'static + Send>(&self, callback: F) { + let Some(hwnd) = self.main_wnd.hwnd() else { + return; + }; + let context = QueueContext { + callback: Box::new(callback), + }; + let context_ptr = Box::into_raw(Box::new(context)); + // SAFETY: This is safe since `callback` is Send + unsafe { PostMessageW(hwnd as isize, QUEUE_MESSAGE, 0, context_ptr as isize) }; + } +} diff --git a/installer-downloader/src/winapi_impl/mod.rs b/installer-downloader/src/winapi_impl/mod.rs new file mode 100644 index 0000000000..0627c59569 --- /dev/null +++ b/installer-downloader/src/winapi_impl/mod.rs @@ -0,0 +1,42 @@ +use installer_downloader::environment::{Environment, Error as EnvError}; +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"); + 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() { + 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, 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) +} diff --git a/installer-downloader/src/winapi_impl/ui.rs b/installer-downloader/src/winapi_impl/ui.rs new file mode 100644 index 0000000000..b9502d0036 --- /dev/null +++ b/installer-downloader/src/winapi_impl/ui.rs @@ -0,0 +1,507 @@ +//! This module handles setting up and rendering changes to the UI + +use std::cell::RefCell; +use std::rc::Rc; +use std::sync::LazyLock; + +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, STABLE_LINK_TEXT, WINDOW_HEIGHT, WINDOW_TITLE, + WINDOW_WIDTH, +}; + +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"); + +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 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. +pub const BETA_LINK_HANDLER_ID: usize = 0x10002; + +#[derive(Default)] +pub struct AppWindow { + pub window: nwg::Window, + + pub banner: nwg::ImageFrame, + + pub banner_text: nwg::Label, + pub banner_text_image_bitmap: RefCell<Option<nwg::Bitmap>>, + pub banner_text_image: nwg::ImageFrame, + pub banner_image_bitmap: RefCell<Option<nwg::Bitmap>>, + pub banner_image: nwg::ImageFrame, + + pub cancel_button: nwg::Button, + pub download_button: nwg::Button, + + pub progress_bar: nwg::ProgressBar, + + pub status_text: nwg::Label, + pub download_text: nwg::Label, + + pub beta_prefix: nwg::Label, + pub beta_link: nwg::Label, + + pub arrow_font: nwg::Font, + + 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)?; + + 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_y)) + .size((232, 32)) + .build(&mut self.error_cancel_button)?; + + nwg::Button::builder() + .parent(&self.error_frame) + .position((64, button_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 { + /// Set up UI elements, position them, and register window message handlers + /// Note that some additional setup happens in [Self::on_init] + pub fn layout(mut self) -> Result<Rc<RefCell<AppWindow>>, nwg::NwgError> { + nwg::Window::builder() + .size((WINDOW_WIDTH as i32, WINDOW_HEIGHT as i32)) + .center(true) + .title(WINDOW_TITLE) + .flags(WindowFlags::WINDOW) + .build(&mut self.window)?; + + nwg::ImageFrame::builder() + .parent(&self.window) + .background_color(Some(BACKGROUND_COLOR)) + .build(&mut self.banner)?; + + nwg::Label::builder() + .parent(&self.banner) + .background_color(Some(BACKGROUND_COLOR)) + .build(&mut self.banner_text)?; + + nwg::ImageFrame::builder() + .parent(&self.banner) + .background_color(Some(BACKGROUND_COLOR)) + .build(&mut self.banner_image)?; + nwg::ImageFrame::builder() + .parent(&self.banner) + .background_color(Some(BACKGROUND_COLOR)) + .build(&mut self.banner_text_image)?; + + nwg::Button::builder() + .parent(&self.window) + .size(try_pair_into(DOWNLOAD_BUTTON_SIZE).unwrap()) + .text(&DOWNLOAD_BUTTON_TEXT.replace("&", "&&")) + .build(&mut self.download_button)?; + + nwg::Button::builder() + .parent(&self.window) + .size(try_pair_into(CANCEL_BUTTON_SIZE).unwrap()) + .text(CANCEL_BUTTON_TEXT) + .build(&mut self.cancel_button)?; + + nwg::Label::builder() + .parent(&self.window) + .size((320, 32)) + .text("") + .h_align(nwg::HTextAlign::Center) + .build(&mut self.status_text)?; + + nwg::Label::builder() + .parent(&self.window) + .size((480, 32)) + .text("") + .h_align(nwg::HTextAlign::Center) + .build(&mut self.download_text)?; + + nwg::Label::builder() + .parent(&self.window) + .size((240, 24)) + .text(BETA_PREFACE_DESC) + .h_align(nwg::HTextAlign::Left) + .build(&mut self.beta_prefix)?; + + let link_font = create_link_font()?; + + nwg::Label::builder() + .parent(&self.window) + .size((128, 24)) + .text(BETA_LINK_TEXT) + .font(Some(link_font)) + .h_align(nwg::HTextAlign::Left) + .build(&mut self.beta_link)?; + + nwg::ImageFrame::builder() + .parent(&self.window) + .size((240, 24)) + .build(&mut self.stable_message_frame)?; + + nwg::Font::builder() + .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)?; + + 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.stable_message_frame.set_position( + 24, + self.window.size().1 as i32 - 24 - self.stable_message_frame.size().1 as i32, + ); + 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, + ); + self.beta_link.set_position( + self.beta_prefix.position().0 + self.beta_prefix.size().0 as i32, + self.beta_prefix.position().1, + ); + handle_link_messages(&self.window.handle, &self.beta_link, BETA_LINK_HANDLER_ID)?; + + 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)); + + 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() { + log::error!("load_banner_image failed: {err}"); + // not fatal, so continue + } + if let Err(err) = self.load_banner_text_image() { + 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) { + log::error!("handle_banner_label_colors failed: {err}"); + // not fatal, so continue + } + + self.banner_text.set_text(BANNER_DESC); + self.banner_text + .set_position(24, self.banner_image.position().1 + 20); + self.banner_text.set_size( + WINDOW_WIDTH as u32 - self.banner_text.position().0 as u32 - 12, + 64, + ); + } + + /// This function is called when user clicks the "X" + fn on_close(&self) { + nwg::stop_thread_dispatch(); + } + + /// Load the embedded image and display it in `banner_image` + fn load_banner_image(&self) -> Result<(), nwg::NwgError> { + let src = ImageDecoder::new()?.from_stream(BANNER_IMAGE_DATA)?; + let frame = src.frame(0)?; + let size = frame.size(); + let mut img = self.banner_image_bitmap.borrow_mut(); + let bmp = frame.as_bitmap()?; + img.replace(bmp); + + self.banner_image.set_bitmap(img.as_ref()); + self.banner_image.set_position(24, 24); + self.banner_image.set_size(size.0, size.1); + + Ok(()) + } + + /// Load the embedded image and display it in `banner_text_image` + fn load_banner_text_image(&self) -> Result<(), nwg::NwgError> { + let src = ImageDecoder::new()?.from_stream(BANNER_TEXT_IMAGE_DATA)?; + let frame = src.frame(0)?; + let size = frame.size(); + let mut img = self.banner_text_image_bitmap.borrow_mut(); + img.replace(frame.as_bitmap()?); + + self.banner_text_image.set_bitmap(img.as_ref()); + self.banner_text_image.set_position( + self.banner_image.position().0 + self.banner_image.size().0 as i32 + 8, + self.banner_image.position().1 + self.banner_image.size().1 as i32 / 2 + - size.1 as i32 / 2, + ); + self.banner_text_image.set_size(size.0, size.1); + + Ok(()) + } +} + +/// Register a window message handler that ensures that the banner labels are rendered with the +/// correct color +fn handle_banner_label_colors( + banner: &ControlHandle, + handler_id: usize, +) -> Result<nwg::RawEventHandler, nwg::NwgError> { + nwg::bind_raw_event_handler(banner, handler_id, move |_hwnd, msg, w, _p| { + if msg == WM_CTLCOLORSTATIC { + // SAFETY: `w` is a valid device context for WM_CTLCOLORSTATIC + unsafe { + SetTextColor(w as isize, rgb([255, 255, 255])); + SetBkColor(w as isize, rgb(BACKGROUND_COLOR)); + } + } + None + }) +} + +/// Register a window message handler for the beta link component +fn handle_link_messages( + parent: &nwg::ControlHandle, + link: &nwg::Label, + handler_id: usize, +) -> Result<nwg::RawEventHandler, nwg::NwgError> { + let link_hwnd = link.handle.hwnd().map(|hwnd| hwnd as isize); + nwg::bind_raw_event_handler(parent, handler_id, move |_hwnd, msg, w, p| { + if msg == WM_CTLCOLORSTATIC && Some(p) == link_hwnd { + // SAFETY: `w` is a valid device context for WM_CTLCOLORSTATIC + unsafe { + SetBkMode(w as isize, TRANSPARENT as _); + SetTextColor(w as isize, rgb(LINK_COLOR)); + } + // Out of bounds background + return Some(COLOR_WINDOW as isize); + } + + None + }) +} + +/// Register events for [AppWindow::on_init] and [AppWindow::on_close]. +fn handle_init_and_close_messages( + window: impl Into<ControlHandle>, + app: Rc<RefCell<AppWindow>>, +) -> nwg::EventHandler { + let window = window.into(); + nwg::full_bind_event_handler(&window, move |event, _data, handle| match event { + nwg::Event::OnInit if handle == window => { + let app = app.borrow(); + app.on_init(); + } + nwg::Event::OnWindowClose if handle == window => { + let app = app.borrow(); + app.on_close(); + } + _ => (), + }) +} + +/// This handles `QUEUE_MESSAGE` messages, which contain callbacks reachable from +/// pointers to a [super::delegate::QueueContext]. See [super::delegate::QueueContext] +/// and [super::delegate::Queue] for details. +fn handle_queue_message( + window: impl Into<ControlHandle>, + app: Rc<RefCell<AppWindow>>, +) -> Result<nwg::RawEventHandler, nwg::NwgError> { + nwg::bind_raw_event_handler( + &window.into(), + QUEUE_MESSAGE_HANDLER_ID, + move |_hwnd, msg, _w, p| { + if msg == QUEUE_MESSAGE { + // SAFETY: This message is only sent with a boxed sendable function pointer, so we're + // good. See the implementation of `AppDelegateQueue` for `Queue`. + let context = unsafe { Box::from_raw(p as *mut QueueContext) }; + let mut app = app.borrow_mut(); + (context.callback)(&mut app); + } + None + }, + ) +} + +fn try_pair_into<A: TryInto<B>, B>(a: (A, A)) -> Result<(B, B), A::Error> { + Ok((a.0.try_into()?, a.1.try_into()?)) +} + +/// Create a link font +/// +/// 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<Result<nwg::Font, nwg::NwgError>> = 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; + logfont.lfHeight = -i32::try_from(FONT_HEIGHT).unwrap(); + + 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) }; + + if raw_font == 0 { + return Err(nwg::NwgError::Unknown); + } + + Ok(nwg::Font { + handle: raw_font as *mut _, + }) + }); + + match &*LINK_FONT { + Ok(font) => Ok(font), + 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) +} diff --git a/installer-downloader/tests/controller.rs b/installer-downloader/tests/controller.rs new file mode 100644 index 0000000000..192eab1b9d --- /dev/null +++ b/installer-downloader/tests/controller.rs @@ -0,0 +1,217 @@ +#![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, +//! then most likely the snapshots need to be updated. The most convenient way to review +//! changes to, and update, snapshots is by running `cargo insta review`. + +use insta::assert_yaml_snapshot; +use installer_downloader::controller::AppController; +use mock::{ + FakeAppDelegate, FakeAppDownloaderHappyPath, FakeAppDownloaderVerifyFail, + FakeDirectoryProvider, FakeVersionInfoProvider, FAKE_ENVIRONMENT, +}; +use std::{ + sync::{atomic::AtomicBool, Arc}, + time::Duration, +}; + +mod mock; + +/// 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, _, FakeDirectoryProvider<true>>( + &mut delegate, + FakeVersionInfoProvider::default(), + FAKE_ENVIRONMENT, + ); + + // 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); + + 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); +} + +/// 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, _, FakeDirectoryProvider<true>>( + &mut delegate, + FakeVersionInfoProvider::default(), + FAKE_ENVIRONMENT, + ); + + // 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 + // Downloader should have quit + 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<true>>( + &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); + + 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); + + 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); + 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); +} + +/// 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, _, FakeDirectoryProvider<true>>( + &mut delegate, + FakeVersionInfoProvider::default(), + FAKE_ENVIRONMENT, + ); + + // 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, _, FakeDirectoryProvider<false>>( + &mut delegate, + FakeVersionInfoProvider::default(), + FAKE_ENVIRONMENT, + ); + + // 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); + + // "Download failed" + assert_yaml_snapshot!(delegate.state); +} diff --git a/installer-downloader/tests/mock.rs b/installer-downloader/tests/mock.rs new file mode 100644 index 0000000000..51a20f347d --- /dev/null +++ b/installer-downloader/tests/mock.rs @@ -0,0 +1,373 @@ +#![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::atomic::AtomicBool; +use std::sync::{Arc, LazyLock, Mutex}; +use std::vec::Vec; + +/// Fake version info provider +#[derive(Default)] +pub struct FakeVersionInfoProvider { + pub fail_fetching: Arc<AtomicBool>, +} + +pub static FAKE_VERSION: LazyLock<VersionInfo> = 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<VersionInfo> { + if self.fail_fetching.load(std::sync::atomic::Ordering::SeqCst) { + anyhow::bail!("Failed to fetch version info"); + } + Ok(FAKE_VERSION.clone()) + } +} + +pub struct FakeDirectoryProvider<const SUCCEED: bool> {} + +#[async_trait::async_trait] +impl<const SUCCEEDED: bool> DirectoryProvider for FakeDirectoryProvider<SUCCEEDED> { + async fn create_download_dir() -> anyhow::Result<PathBuf> { + 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<true, true, true>; + +/// Downloader for which the verification step fails +pub type FakeAppDownloaderVerifyFail = FakeAppDownloader<true, false, false>; + +impl<const A: bool, const B: bool, const C: bool> From<UiAppDownloaderParameters<FakeAppDelegate>> + for FakeAppDownloader<A, B, C> +{ + fn from(params: UiAppDownloaderParameters<FakeAppDelegate>) -> 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<FakeAppDelegate>, +} + +#[async_trait::async_trait] +impl<const EXE_SUCCEED: bool, const VERIFY_SUCCEED: bool, const LAUNCH_SUCCEED: bool> AppDownloader + for FakeAppDownloader<EXE_SUCCEED, VERIFY_SUCCEED, LAUNCH_SUCCEED> +{ + 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<Mutex<Vec<MainThreadCallback>>>, +} + +pub type MainThreadCallback = Box<dyn FnOnce(&mut FakeAppDelegate) + Send>; + +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<FakeAppDelegate> for FakeQueue { + fn queue_main<F: FnOnce(&mut FakeAppDelegate) + 'static + Send>(&self, callback: F) { + self.callbacks.lock().unwrap().push(Box::new(callback)); + } +} + +/// A fake [AppDelegate] +#[derive(Default)] +pub struct FakeAppDelegate { + /// Callback registered by `on_download` + pub download_callback: Option<Box<dyn Fn() + Send>>, + /// Callback registered by `on_cancel` + pub cancel_callback: Option<Box<dyn Fn() + Send>>, + /// Callback registered by `on_beta_link` + pub beta_callback: Option<Box<dyn Fn() + Send>>, + /// Callback registered by `on_stable_link` + pub stable_callback: Option<Box<dyn Fn() + Send>>, + /// Callback registered by `on_error_cancel` + pub error_cancel_callback: Option<Box<dyn Fn() + Send>>, + /// Callback registered by `on_error_retry` + pub error_retry_callback: Option<Box<dyn Fn() + Send>>, + /// State of delegate + pub state: DelegateState, + /// Queue used to simulate the main thread + pub queue: FakeQueue, +} + +/// A complete state of the UI, including its call history +#[derive(Default, serde::Serialize)] +pub struct DelegateState { + pub status_text: String, + pub download_text: String, + pub download_button_visible: bool, + pub cancel_button_visible: bool, + pub cancel_button_enabled: bool, + pub download_button_enabled: bool, + pub download_progress: u32, + pub download_progress_visible: bool, + pub beta_text_visible: bool, + 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<String>, +} + +impl AppDelegate for FakeAppDelegate { + type Queue = FakeQueue; + + fn on_download<F>(&mut self, callback: F) + where + F: Fn() + Send + 'static, + { + self.state.call_log.push("on_download".into()); + self.download_callback = Some(Box::new(callback)); + } + + fn on_cancel<F>(&mut self, callback: F) + where + F: Fn() + Send + 'static, + { + self.state.call_log.push("on_cancel".into()); + self.cancel_callback = Some(Box::new(callback)); + } + + fn on_beta_link<F>(&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<F>(&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<F>(&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<F>(&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() + } +} diff --git a/installer-downloader/tests/snapshots/controller__download-2.snap b/installer-downloader/tests/snapshots/controller__download-2.snap new file mode 100644 index 0000000000..532866cc6c --- /dev/null +++ b/installer-downloader/tests/snapshots/controller__download-2.snap @@ -0,0 +1,46 @@ +--- +source: installer-downloader/tests/controller.rs +expression: delegate.state +--- +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 +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 + - show_download_button + - disable_download_button + - hide_cancel_button + - hide_beta_text + - hide_stable_text + - 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 + - on_error_message_retry + - on_error_message_cancel + - clear_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 new file mode 100644 index 0000000000..f4773be8e6 --- /dev/null +++ b/installer-downloader/tests/snapshots/controller__download-3.snap @@ -0,0 +1,54 @@ +--- +source: installer-downloader/tests/controller.rs +expression: delegate.state +--- +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 +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 + - show_download_button + - disable_download_button + - hide_cancel_button + - hide_beta_text + - hide_stable_text + - 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 + - on_error_message_retry + - on_error_message_cancel + - clear_download_text + - hide_download_button + - hide_beta_text + - hide_stable_text + - show_cancel_button + - enable_cancel_button + - show_download_progress + - clear_download_progress + - "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..." + - quit diff --git a/installer-downloader/tests/snapshots/controller__download.snap b/installer-downloader/tests/snapshots/controller__download.snap new file mode 100644 index 0000000000..2f1b3c46dd --- /dev/null +++ b/installer-downloader/tests/snapshots/controller__download.snap @@ -0,0 +1,34 @@ +--- +source: installer-downloader/tests/controller.rs +expression: delegate.state +--- +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 +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 + - show_download_button + - disable_download_button + - hide_cancel_button + - hide_beta_text + - hide_stable_text + - on_download + - on_cancel + - on_beta_link + - on_stable_link + - 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 new file mode 100644 index 0000000000..ae534f550e --- /dev/null +++ b/installer-downloader/tests/snapshots/controller__failed_directory_creation.snap @@ -0,0 +1,44 @@ +--- +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: true +download_progress: 0 +download_progress_visible: false +beta_text_visible: false +stable_text_visible: false +error_message_visible: true +error_message: + 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 +call_log: + - hide_download_progress + - show_download_button + - disable_download_button + - hide_cancel_button + - hide_beta_text + - hide_stable_text + - 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 + - on_error_message_retry + - on_error_message_cancel + - clear_status_text + - hide_download_button + - hide_beta_text + - hide_stable_text + - "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_fetch_version-2.snap b/installer-downloader/tests/snapshots/controller__failed_fetch_version-2.snap new file mode 100644 index 0000000000..d9cdcf7d43 --- /dev/null +++ b/installer-downloader/tests/snapshots/controller__failed_fetch_version-2.snap @@ -0,0 +1,44 @@ +--- +source: installer-downloader/tests/controller.rs +expression: delegate.state +--- +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 +stable_text_visible: false +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 + 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 + - 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 new file mode 100644 index 0000000000..8bb4a0ceea --- /dev/null +++ b/installer-downloader/tests/snapshots/controller__failed_fetch_version.snap @@ -0,0 +1,39 @@ +--- +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 + - 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" diff --git a/installer-downloader/tests/snapshots/controller__failed_verification.snap b/installer-downloader/tests/snapshots/controller__failed_verification.snap new file mode 100644 index 0000000000..a076e24110 --- /dev/null +++ b/installer-downloader/tests/snapshots/controller__failed_verification.snap @@ -0,0 +1,58 @@ +--- +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: true +download_progress: 100 +download_progress_visible: false +beta_text_visible: false +stable_text_visible: false +error_message_visible: true +error_message: + 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: Try again +quit: false +call_log: + - hide_download_progress + - show_download_button + - disable_download_button + - hide_cancel_button + - hide_beta_text + - hide_stable_text + - 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 + - on_error_message_retry + - on_error_message_cancel + - clear_download_text + - hide_download_button + - hide_beta_text + - hide_stable_text + - show_cancel_button + - enable_cancel_button + - show_download_progress + - clear_download_progress + - "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 + - clear_status_text + - clear_download_text + - hide_download_progress + - hide_download_button + - hide_cancel_button + - "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" diff --git a/installer-downloader/tests/snapshots/controller__fetch_version-2.snap b/installer-downloader/tests/snapshots/controller__fetch_version-2.snap new file mode 100644 index 0000000000..a817876875 --- /dev/null +++ b/installer-downloader/tests/snapshots/controller__fetch_version-2.snap @@ -0,0 +1,36 @@ +--- +source: installer-downloader/tests/controller.rs +expression: delegate.state +--- +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 +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 + - show_download_button + - disable_download_button + - hide_cancel_button + - hide_beta_text + - hide_stable_text + - 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 new file mode 100644 index 0000000000..d6c2cbef46 --- /dev/null +++ b/installer-downloader/tests/snapshots/controller__fetch_version.snap @@ -0,0 +1,31 @@ +--- +source: installer-downloader/tests/controller.rs +expression: delegate.state +--- +status_text: "" +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 +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 + - show_download_button + - disable_download_button + - hide_cancel_button + - hide_beta_text + - hide_stable_text + - on_download + - on_cancel + - on_beta_link + - on_stable_link diff --git a/mullvad-daemon/Cargo.toml b/mullvad-daemon/Cargo.toml index 499d83c98e..66edca63a2 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 } diff --git a/mullvad-update/Cargo.toml b/mullvad-update/Cargo.toml new file mode 100644 index 0000000000..cf2a396796 --- /dev/null +++ b/mullvad-update/Cargo.toml @@ -0,0 +1,48 @@ +[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 + +[features] +default = [] +sign = ["rand", "clap"] +client = ["async-trait", "reqwest", "sha2", "tokio", "thiserror"] + +[dependencies] +anyhow = { workspace = true } +json-canon = "0.1" +chrono = { workspace = true, features = ["serde", "now"] } +ed25519-dalek = { version = "2.1" } +hex = { version = "0.4" } +serde = { workspace = true, features = ["derive"] } +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", "macros"], 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 } + +[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" +required-features = ["sign"]
\ No newline at end of file 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 0000000000..b65eee837f --- /dev/null +++ b/mullvad-update/src/bin/mullvad-version-metadata.rs @@ -0,0 +1,78 @@ +//! 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()); + 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 + 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<Vec<u8>> { + 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/client/api.rs b/mullvad-update/src/client/api.rs new file mode 100644 index 0000000000..37f54c8c62 --- /dev/null +++ b/mullvad-update/src/client/api.rs @@ -0,0 +1,142 @@ +//! This module implements fetching of information about app versions + +use anyhow::Context; + +use crate::format; +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(&self, params: VersionParameters) -> anyhow::Result<VersionInfo>; +} + +/// 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<reqwest::Certificate>, + /// Key to use for verifying the response + pub verifying_key: format::key::VerifyingKey, +} + +#[async_trait::async_trait] +impl VersionInfoProvider for HttpVersionInfoProvider { + async fn get_version_info(&self, params: VersionParameters) -> anyhow::Result<VersionInfo> { + let raw_json = Self::get(&self.url, self.pinned_certificate.clone()).await?; + let response = format::SignedResponse::deserialize_and_verify( + &self.verifying_key, + &raw_json, + params.lowest_metadata_version, + )?; + + VersionInfo::try_from_response(¶ms, response.signed) + } +} + +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<reqwest::Certificate>, + ) -> anyhow::Result<Vec<u8>> { + 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 + .tls_built_in_root_certs(false) + .add_root_certificate(pinned_certificate); + } + + // 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); + } + + let mut read_n = 0; + let mut data = vec![]; + + while let Some(chunk) = req.chunk().await.context("Failed to retrieve chunk")? { + read_n += chunk.len(); + + // Fail if content length exceeds limit + if read_n > Self::SIZE_LIMIT { + anyhow::bail!("Version info exceeded limit: {} bytes", Self::SIZE_LIMIT); + } + + data.extend_from_slice(&chunk); + } + + Ok(data) + } +} + +#[cfg(test)] +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 is by running `cargo insta review`. + + /// 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(include_str!("../../test-pubkey")) + .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., + lowest_metadata_version: 0, + }; + let info_provider = HttpVersionInfoProvider { + url, + pinned_certificate: None, + verifying_key, + }; + + let info = info_provider + .get_version_info(params) + .await + .context("Expected valid version info")?; + + // Expect: Our query should yield some version response + assert_yaml_snapshot!(info); + + Ok(()) + } +} diff --git a/mullvad-update/src/client/app.rs b/mullvad-update/src/client/app.rs new file mode 100644 index 0000000000..decf8d932b --- /dev/null +++ b/mullvad-update/src/client/app.rs @@ -0,0 +1,176 @@ +//! This module implements the flow of downloading and verifying the app. + +use std::{ffi::OsString, path::PathBuf, time::Duration}; + +use tokio::{process::Command, time::timeout}; + +use crate::{ + fetch::{self, ProgressUpdater}, + verify::{AppVerifier, Sha256Verifier}, +}; + +#[derive(Debug, thiserror::Error)] +pub enum DownloadError { + #[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("Installer exited with error: {0}")] + InstallExited(std::process::ExitStatus), + #[error("Installer failed on child.wait(): {0}")] + InstallFailed(std::io::Error), +} + +/// Parameters required to construct an [AppDownloader]. +#[derive(Clone)] +pub struct AppDownloaderParameters<AppProgress> { + pub app_version: mullvad_version::Version, + pub app_url: String, + 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 { + /// Download the app binary. + async fn download_executable(&mut self) -> Result<(), DownloadError>; + + /// Verify the app signature. + async fn verify(&mut self) -> Result<(), DownloadError>; + + /// Execute installer. + 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.download_executable().await?; + downloader.verify().await?; + downloader.install().await +} + +#[derive(Clone)] +pub struct HttpAppDownloader<AppProgress> { + params: AppDownloaderParameters<AppProgress>, +} + +impl<AppProgress> HttpAppDownloader<AppProgress> { + pub fn new(params: AppDownloaderParameters<AppProgress>) -> Self { + Self { params } + } +} + +impl<AppProgress: ProgressUpdater> From<AppDownloaderParameters<AppProgress>> + for HttpAppDownloader<AppProgress> +{ + fn from(parameters: AppDownloaderParameters<AppProgress>) -> Self { + HttpAppDownloader::new(parameters) + } +} + +#[async_trait::async_trait] +impl<AppProgress: ProgressUpdater> AppDownloader for HttpAppDownloader<AppProgress> { + async fn download_executable(&mut self) -> Result<(), DownloadError> { + let bin_path = self.bin_path(); + fetch::get_to_file( + bin_path, + &self.params.app_url, + &mut self.params.app_progress, + fetch::SizeHint::Exact(self.params.app_size), + ) + .await + .map_err(DownloadError::FetchApp) + } + + async fn verify(&mut self) -> Result<(), DownloadError> { + let bin_path = self.bin_path(); + let hash = self.hash_sha256(); + + 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> { + let launch_path = self.launch_path(); + + // Launch process + 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 + 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 exited with error code + Ok(Ok(status)) => Err(DownloadError::InstallExited(status)), + // `child.wait()` returned an error + Ok(Err(err)) => Err(DownloadError::InstallFailed(err)), + } + } +} + +impl<AppProgress> HttpAppDownloader<AppProgress> { + fn bin_path(&self) -> PathBuf { + #[cfg(windows)] + let bin_filename = format!("mullvad-{}.exe", self.params.app_version); + + #[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<OsString> { + #[cfg(target_os = "windows")] + { + vec![] + } + + #[cfg(target_os = "macos")] + { + vec![self.bin_path().into()] + } + } + + fn hash_sha256(&self) -> &[u8; 32] { + &self.params.app_sha256 + } +} diff --git a/mullvad-update/src/client/fetch.rs b/mullvad-update/src/client/fetch.rs new file mode 100644 index 0000000000..706e3897f3 --- /dev/null +++ b/mullvad-update/src/client/fetch.rs @@ -0,0 +1,501 @@ +//! A downloader that supports HTTP 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); + + /// Clear progress so far + fn clear_progress(&mut self); + + /// URL that is being downloaded + fn set_url(&mut self, url: &str); +} + +/// 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), +} + +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. +/// +/// 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. +pub async fn get_to_file( + file: impl AsRef<Path>, + url: &str, + progress_updater: &mut impl ProgressUpdater, + size_hint: SizeHint, +) -> anyhow::Result<()> { + let file = create_or_append(file).await?; + let file = BufWriter::new(file); + get_to_writer(file, url, progress_updater, size_hint).await +} + +/// Download `url` to `writer`. +/// +/// # Arguments +/// - `progress_updater` - This interface is notified of download progress. +/// - `size_hint` - File size restrictions. +pub async fn get_to_writer( + mut writer: impl AsyncWrite + AsyncSeek + Unpin, + url: &str, + progress_updater: &mut impl ProgressUpdater, + size_hint: SizeHint, +) -> anyhow::Result<()> { + let client = reqwest::Client::new(); + + progress_updater.set_url(url); + progress_updater.set_progress(0.); + + // Fetch content length first + let response = client.head(url).send().await.context("HEAD failed")?; + if !response.status().is_success() { + return response + .error_for_status() + .map(|_| ()) + .context("Download failed"); + } + + let total_size = response + .headers() + .get(CONTENT_LENGTH) + .context("Missing file size")?; + let total_size: usize = total_size.to_str()?.parse().context("invalid size")?; + size_hint.check_size(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 > total_size - already_fetched_bytes { + // Protect against servers responding with more data than expected + anyhow::bail!("Server returned more than requested bytes"); + } + + writer + .write_all(&chunk) + .await + .context("Failed to write chunk")?; + } + } + + writer.shutdown().await.context("Failed to flush")?; + + Ok(()) +} + +/// If a file exists, append to it. Otherwise, create a new file +async fn create_or_append(path: impl AsRef<Path>) -> io::Result<File> { + match fs::File::create_new(&path).await { + // New file created + Ok(file) => Ok(file), + // Append to an existing file + Err(_err) => { + let mut file = fs::OpenOptions::new().append(true).open(path).await?; + // Seek to end, or else the seek position might be wrong + file.seek(io::SeekFrom::End(0)).await?; + Ok(file) + } + } +} + +/// Used to download partial content +struct RangeIter { + current: usize, + end: usize, +} + +impl RangeIter { + fn new(current: usize, end: usize) -> Self { + Self { current, end } + } +} + +impl Iterator for RangeIter { + type Item = HeaderValue; + + fn next(&mut self) -> Option<Self::Item> { + if self.current > self.end { + return None; + } + let prev = self.current; + + let read_n = self.end.saturating_sub(self.current); + if read_n == 0 { + return None; + } + + self.current += read_n; + + // NOTE: Subtracting 1 because range includes final byte + let end = self.current - 1; + + Some(HeaderValue::from_str(&format!("bytes={prev}-{end}")).expect("valid range/str")) + } +} + +struct WriterWithProgress<'a, PU: ProgressUpdater, Writer> { + writer: Writer, + progress_updater: &'a mut PU, + written_nbytes: usize, + /// Actual or estimated total number of bytes + total_nbytes: usize, +} + +impl<PU: ProgressUpdater, Writer: AsyncWrite + Unpin> AsyncWrite + for WriterWithProgress<'_, PU, Writer> +{ + fn poll_write( + mut self: Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + buf: &[u8], + ) -> Poll<Result<usize, io::Error>> { + let file = Pin::new(&mut self.writer); + let nbytes = ready!(file.poll_write(cx, buf))?; + + let total_nbytes = self.total_nbytes; + let total_written = self.written_nbytes + nbytes; + + self.written_nbytes = total_written; + self.progress_updater + .set_progress(total_written as f32 / total_nbytes as f32); + + Poll::Ready(Ok(nbytes)) + } + + fn poll_flush( + mut self: Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + ) -> Poll<Result<(), std::io::Error>> { + Pin::new(&mut self.writer).poll_flush(cx) + } + + fn poll_shutdown( + mut self: Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + ) -> Poll<Result<(), std::io::Error>> { + Pin::new(&mut self.writer).poll_shutdown(cx) + } +} + +#[cfg(test)] +mod test { + use std::io::Cursor; + + use async_tempfile::TempDir; + use rand::RngCore; + use tokio::{fs, io::AsyncWriteExt}; + + use super::*; + + #[tokio::test] + async fn test_create_or_append() -> anyhow::Result<()> { + let temp_dir = TempDir::new().await?; + let file_path = temp_dir.join("test"); + + // Write to a new file + const CONTENT: &[u8] = b"very important file"; + + let mut file = create_or_append(&file_path).await?; + file.write_all(CONTENT).await?; + file.flush().await?; + drop(file); + + assert_eq!(fs::read(&file_path).await?, CONTENT); + + // Verify that we can trust the stream position + let mut file = create_or_append(&file_path).await?; + let content_len: u64 = CONTENT.len().try_into()?; + assert_eq!(file.stream_position().await?, content_len); + drop(file); + + // Append some more stuff + const EXTRA: &[u8] = b"my addition"; + + let mut file = create_or_append(&file_path).await?; + file.write_all(EXTRA).await?; + file.flush().await?; + drop(file); + + // Append occurred correctly + const COMPLETE_STRING: &[u8] = b"very important filemy addition"; + assert_eq!(fs::read(file_path).await?, COMPLETE_STRING); + + Ok(()) + } + + #[derive(Default)] + struct FakeProgressUpdater { + complete: f32, + url: String, + } + + impl ProgressUpdater for FakeProgressUpdater { + fn set_progress(&mut self, fraction_complete: f32) { + self.complete = fraction_complete; + } + + fn clear_progress(&mut self) { + self.complete = 0.; + } + + 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 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 limited_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!( + &*buffer, + &file_data[..buffer.len()], + "partial download incorrect" + ); + + // Download the remainder + let partial_len = buffer.len(); + let mut writer = Cursor::new(buffer); + 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 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 + .mock("HEAD", url_path) + .with_header(CONTENT_LENGTH, &data.len().to_string()) + .create(); + + // Respond to HTTP 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"); + + // 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") + // Lie about size in header + .with_header(CONTENT_LENGTH, "1") + .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/client/mod.rs b/mullvad-update/src/client/mod.rs new file mode 100644 index 0000000000..4d8a4cc67a --- /dev/null +++ b/mullvad-update/src/client/mod.rs @@ -0,0 +1,4 @@ +pub mod api; +pub mod app; +pub mod fetch; +pub mod verify; diff --git a/mullvad-update/src/client/snapshots/mullvad_update__client__api__test__http_version_provider.snap b/mullvad-update/src/client/snapshots/mullvad_update__client__api__test__http_version_provider.snap new file mode 100644 index 0000000000..1cb23ff5e5 --- /dev/null +++ b/mullvad-update/src/client/snapshots/mullvad_update__client__api__test__http_version_provider.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/src/client/verify.rs b/mullvad-update/src/client/verify.rs new file mode 100644 index 0000000000..a6bc3c9bc0 --- /dev/null +++ b/mullvad-update/src/client/verify.rs @@ -0,0 +1,113 @@ +use anyhow::Context; +use sha2::Digest; +use tokio::{ + fs, + io::{AsyncRead, AsyncReadExt, BufReader}, +}; + +use std::{future::Future, path::Path}; + +/// A verifier of digital file signatures or hashes +pub trait AppVerifier: 'static + Clone { + type Parameters; + + /// Verify `bin_path` using `parameters`, and return an error if this fails for any reason. + fn verify( + bin_path: impl AsRef<Path>, + parameters: Self::Parameters, + ) -> impl Future<Output = anyhow::Result<()>>; +} + +/// Checksum verifier that uses SHA256 +#[derive(Clone)] +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<Path>, + expected_hash: Self::Parameters, + ) -> impl Future<Output = anyhow::Result<()>> { + 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); + + Self::verify_inner(file, expected_hash).await + } + } +} + +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(()) + } +} + +#[cfg(test)] +mod test { + use rand::RngCore; + use std::io::Cursor; + + use super::*; + + #[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"); + } +} diff --git a/mullvad-update/src/format/deserializer.rs b/mullvad-update/src/format/deserializer.rs new file mode 100644 index 0000000000..f79aab6537 --- /dev/null +++ b/mullvad-update/src/format/deserializer.rs @@ -0,0 +1,201 @@ +//! Deserializer and verifier of version metadata + +use anyhow::Context; + +use super::key::*; +use super::Response; +use super::{PartialSignedResponse, ResponseSignature, 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], + min_metadata_version: usize, + ) -> Result<Self, anyhow::Error> { + Self::deserialize_and_verify_at_time(key, bytes, chrono::Utc::now(), min_metadata_version) + } + + /// 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<Self, anyhow::Error> { + 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 { + signatures: partial_data.signatures, + 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<chrono::Utc>, + min_metadata_version: usize, + ) -> Result<Self, anyhow::Error> { + // 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.metadata_expiry { + anyhow::bail!( + "Version metadata has expired: valid until {}", + 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, + ); + } + + Ok(SignedResponse { + signatures: partial_data.signatures, + 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<PartialSignedResponse> { + let partial_data: PartialSignedResponse = + serde_json::from_slice(bytes).context("Invalid version JSON")?; + + // Check if the key matches + 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 + key.0 + .verify_strict(&canon_data, &sig.0) + .context("Signature verification failed")?; + + Ok(PartialSignedResponse { + signatures: partial_data.signatures, + signed: partial_data.signed, + }) +} + +#[cfg(test)] +mod test { + use std::str::FromStr; + + use super::*; + + /// Test that a valid signed version response is successfully deserialized and verified + #[test] + fn test_response_deserialization_and_verification() { + let pubkey = hex::decode(include_str!("../../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, + // Accept any version + 0, + ) + .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(), + // 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"); + } + + /// 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 new file mode 100644 index 0000000000..4add53fab4 --- /dev/null +++ b/mullvad-update/src/format/key.rs @@ -0,0 +1,201 @@ +//! Key and signature types for API version response format + +use std::{fmt, 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 fmt::Display for SecretKey { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", hex::encode(self.0)) + } +} + +impl<'de> Deserialize<'de> for SecretKey { + fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> + 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<Self, Self::Err> { + let bytes = bytes_from_hex::<{ ed25519_dalek::SECRET_KEY_LENGTH }>(s)?; + Ok(SecretKey(bytes)) + } +} + +impl Serialize for SecretKey { + fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> + 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 VerifyingKey { + pub fn from_hex(s: &str) -> anyhow::Result<Self> { + 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<D>(deserializer: D) -> Result<Self, D::Error> + 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<S>(&self, serializer: S) -> Result<S::Ok, S::Error> + where + S: serde::Serializer, + { + serializer.serialize_str(&hex::encode(self.0.as_bytes())) + } +} + +/// ed25519 signature +#[derive(Debug, PartialEq)] +pub struct Signature(pub ed25519_dalek::Signature); + +impl Signature { + pub fn from_hex(s: &str) -> anyhow::Result<Self> { + 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<D>(deserializer: D) -> Result<Self, D::Error> + 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<S>(&self, serializer: S) -> Result<S::Ok, S::Error> + 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<const SIZE: usize>(key: &str) -> anyhow::Result<[u8; SIZE]> { + let bytes = hex::decode(key).context("invalid hex")?; + if bytes.len() != SIZE { + bail!( + "expected 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 new file mode 100644 index 0000000000..6829374022 --- /dev/null +++ b/mullvad-update/src/format/mod.rs @@ -0,0 +1,160 @@ +//! 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 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. + +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(Debug, Serialize)] +pub struct SignedResponse { + /// Signatures of the canonicalized JSON of `signed` + pub signatures: Vec<ResponseSignature>, + /// Content signed by `signature` + pub signed: Response, +} + +/// Helper type that leaves the signed data untouched +/// Note that deserializing doesn't verify anything +#[derive(Deserialize, Serialize)] +struct PartialSignedResponse { + /// Signatures of the canonicalized JSON of `signed` + pub signatures: Vec<ResponseSignature>, + /// Content signed by `signature` + pub signed: serde_json::Value, +} + +/// Signed JSON response, not including the signature +#[derive(Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +#[cfg_attr(test, derive(Clone))] +pub struct Response { + /// Version counter + pub metadata_version: usize, + /// When the signature expires + pub metadata_expiry: chrono::DateTime<chrono::Utc>, + /// Available app releases + pub releases: Vec<Release>, +} + +/// App release +#[derive(Debug, Deserialize, Serialize)] +#[cfg_attr(test, derive(Clone))] +pub struct Release { + /// Mullvad app version + pub version: mullvad_version::Version, + /// Changelog entries + pub changelog: String, + /// Installer details for different architectures + pub installers: Vec<Installer>, + /// Fraction of users that should receive the new version + #[serde(default = "complete_rollout")] + #[serde(skip_serializing_if = "is_complete_rollout")] + pub rollout: f32, +} + +/// A full rollout includes all users +fn complete_rollout() -> f32 { + 1. +} + +fn is_complete_rollout(b: impl std::borrow::Borrow<f32>) -> bool { + (b.borrow() - complete_rollout()).abs() < f32::EPSILON +} + +/// App installer +#[derive(Debug, Deserialize, Serialize, Clone)] +pub struct Installer { + /// Installer architecture + pub architecture: Architecture, + /// Mirrors that host the artifact + pub urls: Vec<String>, + /// Size of the installer, in bytes + pub size: usize, + /// Hash of the installer, hexadecimal string + 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)] +#[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)] +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: complete_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, + }) + ); + } +} diff --git a/mullvad-update/src/format/serializer.rs b/mullvad-update/src/format/serializer.rs new file mode 100644 index 0000000000..46c4c8cb7a --- /dev/null +++ b/mullvad-update/src/format/serializer.rs @@ -0,0 +1,102 @@ +//! 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<SignedResponse> { + // Refuse to sign expired data + if response.metadata_expiry < 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 { + signatures: partial_signed.signatures, + signed: response, + }) + } +} + +/// Serialize JSON to bytes, with a signature attached, signed using `key` +fn sign<T: Serialize>( + key: &key::SecretKey, + unsigned_value: &T, +) -> anyhow::Result<PartialSignedResponse> { + // 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 { + signatures: vec![ResponseSignature::Ed25519 { + 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!( + matches!(&partial.signatures[0], ResponseSignature::Ed25519 { + keyid, + .. + } if 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 new file mode 100644 index 0000000000..2c78908089 --- /dev/null +++ b/mullvad-update/src/lib.rs @@ -0,0 +1,12 @@ +//! Support functions for securely installing or updating Mullvad VPN + +#[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 +pub mod format; diff --git a/mullvad-update/src/snapshots/mullvad_update__version__test__version_info_parser_arm64.snap b/mullvad-update/src/snapshots/mullvad_update__version__test__version_info_parser_arm64.snap new file mode 100644 index 0000000000..8b2f63d5c6 --- /dev/null +++ b/mullvad-update/src/snapshots/mullvad_update__version__test__version_info_parser_arm64.snap @@ -0,0 +1,45 @@ +--- +source: mullvad-update/src/version.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__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 0000000000..2a59903dbf --- /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 0000000000..686ff62def --- /dev/null +++ b/mullvad-update/src/version.rs @@ -0,0 +1,219 @@ +//! 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: 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; + +/// 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<Version>, +} + +/// 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<String>, + /// 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<Self> { + 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(Version::try_from).transpose()?, + }) + } + + /// Returns the first duplicated version found in `releases`. + /// `None` is returned if there are no duplicates. + /// NOTE: `releases` MUST be sorted on the version number + 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<IntermediateVersion> for Version { + type Error = anyhow::Error; + + fn try_from(version: IntermediateVersion) -> Result<Self, Self::Error> { + // 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 is 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., + lowest_metadata_version: 0, + }; + + // 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, + lowest_metadata_version: 0, + }; + + 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/stagemole-pubkey b/mullvad-update/stagemole-pubkey new file mode 100644 index 0000000000..256a77bafc --- /dev/null +++ b/mullvad-update/stagemole-pubkey @@ -0,0 +1 @@ +a0cd8f582e3147d57f7c01ec0fd306c8315290cea55725c7d5c76f835b78b363
\ No newline at end of file diff --git a/mullvad-update/test-pubkey b/mullvad-update/test-pubkey new file mode 100644 index 0000000000..f5b25b1f24 --- /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 new file mode 100644 index 0000000000..b6466e48c2 --- /dev/null +++ b/mullvad-update/test-version-response.json @@ -0,0 +1,104 @@ +{ + "signatures": [ + { + "keytype": "ed25519", + "keyid": "bb4ef63ffdcc6bd5a19c30cd23b9de03099407a04463418f17ae338b98aa09d4", + "sig": "253ec37846dcd909bfc5119c0e0d06535767e179eb8b4465015eaa95f4bed362c8c9186311192c987871722bf7d319d44e4f04eaf79c269820bc13ff1a901f0b" + } + ], + "signed": { + "metadata_version": 0, + "metadata_expiry": "2025-07-02T15:33:00Z", + "releases": [ + { + "version": "2025.2", + "changelog": "[macos] Adding support for quicfuscator\n[windows] Less bugs", + "installers": [ + { + "architecture": "x86", + "urls": [ + "https://releases.mullvad.net/desktop/releases/2025.2/MullvadVPN-2025.2.exe" + ], + "size": 101384672, + "sha256": "F4B25713D13F2819A300F2FFA94D967463AAEEA0D357FBD7479A281154BA0460" + }, + { + "architecture": "arm64", + "urls": [ + "https://releases.mullvad.net/desktop/releases/2025.2/MullvadVPN-2025.2_arm64.exe" + ], + "size": 104146312, + "sha256": "AFD8098A1FF89D69A243EC4E2E946CF5FBF8D1C10998230D6C8FC0A5C9C39541" + } + ] + }, + { + "version": "2025.3", + "changelog": "[macos] Adding support for quicfuscator\n[windows] Less bugs", + "installers": [ + { + "architecture": "x86", + "urls": [ + "https://releases.mullvad.net/desktop/releases/2025.2/MullvadVPN-2025.2.exe" + ], + "size": 101384672, + "sha256": "F4B25713D13F2819A300F2FFA94D967463AAEEA0D357FBD7479A281154BA0460" + }, + { + "architecture": "arm64", + "urls": [ + "https://releases.mullvad.net/desktop/releases/2025.2/MullvadVPN-2025.2_arm64.exe" + ], + "size": 104146312, + "sha256": "AFD8098A1FF89D69A243EC4E2E946CF5FBF8D1C10998230D6C8FC0A5C9C39541" + } + ], + "rollout": 0.5 + }, + { + "version": "2025.1-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" + } + ] + }, + { + "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" + } + ] + } + ] + } +} diff --git a/mullvad-update/update-testdata.sh b/mullvad-update/update-testdata.sh new file mode 100644 index 0000000000..71ab90d39d --- /dev/null +++ b/mullvad-update/update-testdata.sh @@ -0,0 +1,22 @@ +#!/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 )" +cd "$SCRIPT_DIR" + +# Update test-version-response from + +secret="a459c1ee4f128780592b61454786cb289b38034a3ac1c7860e6e62187ac6e9a9" +pubkey="BB4EF63FFDCC6BD5A19C30CD23B9DE03099407A04463418F17AE338B98AA09D4" + +echo "secret: $secret" +echo "pubkey: $pubkey" + +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 diff --git a/talpid-platform-metadata/Cargo.toml b/talpid-platform-metadata/Cargo.toml index d82479c0c4..9eb58f4a55 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 0000000000..d5343dcb50 --- /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<Option<Architecture>, 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<Option<Architecture>, std::io::Error> { + const TARGET_ARCH: Option<Architecture> = if cfg!(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 7a11c97f18..98640fabef 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; diff --git a/windows-installer/Cargo.toml b/windows-installer/Cargo.toml index 518fe2d82d..f50f032626 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 1b74b074b3..07e4587728 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<Architecture> { - 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), } } |
