summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorSebastian Holmin <sebastian.holmin@mullvad.net>2025-05-28 13:25:55 +0200
committerSebastian Holmin <sebastian.holmin@mullvad.net>2025-05-28 13:25:55 +0200
commite2cde7704ac1c424f29aae295afbc7fe9d8bcd60 (patch)
treebfc9bed040ed7cb3da1ef84966813b6cc7d5f0a4
parent6bb379b0d18d13aa198229dad51068d84b317192 (diff)
parent3f12961fbce8a4a32bfa7ee0ad5b989f7cce9226 (diff)
downloadmullvadvpn-e2cde7704ac1c424f29aae295afbc7fe9d8bcd60.tar.xz
mullvadvpn-e2cde7704ac1c424f29aae295afbc7fe9d8bcd60.zip
Merge branch 'in-app-upgrades-on-windows-and-macos'
-rw-r--r--CHANGELOG.md2
-rw-r--r--Cargo.lock5
-rw-r--r--android/lib/daemon-grpc/src/main/kotlin/net/mullvad/mullvadvpn/lib/daemon/grpc/mapper/ToDomain.kt2
-rw-r--r--desktop/packages/mullvad-vpn/assets/icons/icon-settings-partial.svg3
-rw-r--r--desktop/packages/mullvad-vpn/eslint.config.mjs2
-rw-r--r--desktop/packages/mullvad-vpn/locales/messages.pot371
-rwxr-xr-xdesktop/packages/mullvad-vpn/scripts/build-test-executable.sh2
-rw-r--r--desktop/packages/mullvad-vpn/src/main/app-upgrade.ts155
-rw-r--r--desktop/packages/mullvad-vpn/src/main/daemon-rpc.ts75
-rw-r--r--desktop/packages/mullvad-vpn/src/main/grpc-type-convertions.ts82
-rw-r--r--desktop/packages/mullvad-vpn/src/main/gui-settings.ts12
-rw-r--r--desktop/packages/mullvad-vpn/src/main/index.ts43
-rw-r--r--desktop/packages/mullvad-vpn/src/main/notification-controller.ts19
-rw-r--r--desktop/packages/mullvad-vpn/src/main/settings.ts4
-rw-r--r--desktop/packages/mullvad-vpn/src/main/version.ts2
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/app.tsx116
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/ApiAccessMethods.tsx2
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/AppRouter.tsx8
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/ExpiredAccountAddTime.tsx2
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/ExpiredAccountErrorView.tsx2
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/ExternalLink.tsx11
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/InternalLink.tsx2
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/KeyboardNavigation.tsx2
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/Launch.tsx2
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/NavigationListItem.tsx24
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/NotificationArea.tsx69
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/NotificationSubtitle.tsx18
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/ProblemReport.tsx34
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/Settings.tsx248
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/SettingsImport.tsx2
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/Support.tsx49
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/TooManyDevices.tsx2
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/UserInterfaceSettings.tsx2
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/VpnSettings.tsx2
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/WireguardSettings.tsx2
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/app-main-header/components/AppMainHeaderAccountButton.tsx2
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/app-main-header/components/AppMainHeaderSettingsButton.tsx25
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/cell/Selector.tsx2
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/changelog-list/ChangelogList.tsx29
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/changelog-list/index.ts1
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/main-view/SelectLocationButton.tsx2
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/select-location/SelectLocation.tsx2
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/select-location/SpecialLocationList.tsx2
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/app-info/AppInfoView.tsx44
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/app-info/components/AppVersionListItem.tsx64
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/app-info/components/ChangelogListItem.tsx17
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/app-info/components/beta-list-item/BetaListItem.tsx51
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/app-info/components/beta-list-item/index.ts1
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/app-info/components/changelog-list-item/ChangelogListItem.tsx26
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/app-info/components/changelog-list-item/index.ts1
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/app-info/components/index.ts5
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/app-info/components/update-available-list-item/UpdateAvailableListItem.tsx44
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/app-info/components/update-available-list-item/hooks/index.ts2
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/app-info/components/update-available-list-item/hooks/useHandleClick.tsx10
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/app-info/components/update-available-list-item/hooks/useOpenDownloadUrl.tsx14
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/app-info/components/update-available-list-item/index.ts1
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/app-info/components/version-list-item/VersionListItem.tsx40
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/app-info/components/version-list-item/hooks/index.ts2
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/app-info/components/version-list-item/hooks/useShowAlert.tsx6
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/app-info/components/version-list-item/hooks/useShowFooter.tsx6
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/app-info/components/version-list-item/index.ts1
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/app-info/hooks/index.ts1
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/app-info/hooks/useShowUpdateAvailable.tsx6
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/AppUpgradeView.tsx29
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/connection-blocked-label/ConnectionBlockedLabel.tsx20
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/connection-blocked-label/index.ts1
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/download-progress/DownloadProgress.tsx21
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/download-progress/hooks/index.ts2
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/download-progress/hooks/useDisabled.ts22
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/download-progress/hooks/useMessage/constants.ts44
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/download-progress/hooks/useMessage/hooks/index.ts2
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/download-progress/hooks/useMessage/hooks/useGetMessageError.ts29
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/download-progress/hooks/useMessage/hooks/useGetMessageTimeLeft.ts32
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/download-progress/hooks/useMessage/index.ts1
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/download-progress/hooks/useMessage/useMessage.ts51
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/download-progress/index.ts1
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/Footer.tsx45
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/download-footer/DownloadFooter.tsx22
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/download-footer/components/download-label/DownloadLabel.tsx8
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/download-footer/components/download-label/hooks/index.ts2
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/download-footer/components/download-label/hooks/useGetDownloadProgressMessage.ts28
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/download-footer/components/download-label/hooks/useMessage.ts19
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/download-footer/components/download-label/index.ts1
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/download-footer/components/index.ts3
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/download-footer/components/pause-download-button/PauseDownloadButton.tsx13
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/download-footer/components/pause-download-button/index.ts1
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/download-footer/components/resume-download-button/ResumeDownloadButton.tsx16
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/download-footer/components/resume-download-button/index.ts1
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/download-footer/hooks/index.ts2
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/download-footer/hooks/useShowConnectionBlockedLabel.ts9
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/download-footer/hooks/useShowResumeDownloadButton.ts12
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/download-footer/index.ts1
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/error-footer/ErrorFooter.tsx30
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/error-footer/components/index.ts3
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/error-footer/components/manual-download-link/ManualDownloadLink.tsx26
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/error-footer/components/manual-download-link/hooks/index.ts1
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/error-footer/components/manual-download-link/hooks/useDownloadUrl.ts10
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/error-footer/components/manual-download-link/index.ts1
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/error-footer/components/report-problem-button/ReportProblemButton.tsx22
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/error-footer/components/report-problem-button/index.ts1
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/error-footer/components/retry-button/RetryButton.tsx18
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/error-footer/components/retry-button/components/index.ts2
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/error-footer/components/retry-button/components/retry-launch-installer-button/RetryLaunchInstallerButton.tsx16
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/error-footer/components/retry-button/components/retry-launch-installer-button/hooks/index.ts1
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/error-footer/components/retry-button/components/retry-launch-installer-button/hooks/useDisabled.ts9
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/error-footer/components/retry-button/components/retry-launch-installer-button/index.ts1
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/error-footer/components/retry-button/components/retry-upgrade-button/RetryUpgradeButton.tsx8
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/error-footer/components/retry-button/components/retry-upgrade-button/hooks/index.ts1
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/error-footer/components/retry-button/components/retry-upgrade-button/hooks/useMessage.ts14
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/error-footer/components/retry-button/components/retry-upgrade-button/index.ts1
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/error-footer/components/retry-button/index.ts1
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/error-footer/hooks/index.ts2
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/error-footer/hooks/useMessage.ts40
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/error-footer/hooks/useShowManualDownloadLink.ts9
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/error-footer/index.ts1
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/index.ts6
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/initial-footer/InitialFooter.tsx17
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/initial-footer/components/connection-blocked/ConnectionBlocked.tsx22
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/initial-footer/components/connection-blocked/index.ts1
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/initial-footer/components/index.ts3
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/initial-footer/components/installer-ready/InstallerReady.tsx31
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/initial-footer/components/installer-ready/index.ts1
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/initial-footer/components/start-upgrade/StartUpgrade.tsx18
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/initial-footer/components/start-upgrade/index.ts1
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/initial-footer/hooks/index.ts2
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/initial-footer/hooks/useShowConnectionBlocked.ts9
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/initial-footer/hooks/useShowInstallerReady.ts9
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/initial-footer/index.ts1
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/launch-footer/LaunchFooter.tsx30
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/launch-footer/hooks/index.ts2
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/launch-footer/hooks/useDisabled.ts12
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/launch-footer/hooks/useMessage.ts14
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/launch-footer/index.ts1
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/pause-footer/PauseFooter.tsx12
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/pause-footer/components/connection-blocked/ConnectionBlocked.tsx24
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/pause-footer/components/connection-blocked/index.ts1
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/pause-footer/components/index.ts2
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/pause-footer/components/resume-upgrade/ResumeUpgrade.tsx28
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/pause-footer/components/resume-upgrade/index.ts1
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/pause-footer/hooks/index.ts2
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/pause-footer/hooks/useShowConnectionBlocked.ts9
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/pause-footer/hooks/useShowInstallerReady.ts9
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/pause-footer/index.ts1
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/verify-footer/VerifyFooter.tsx31
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/verify-footer/components/index.ts1
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/verify-footer/components/pause-button/PauseButton.tsx18
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/verify-footer/components/pause-button/index.ts1
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/verify-footer/index.ts1
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/hooks/index.ts1
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/hooks/useStep.ts16
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/index.ts1
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/index.ts8
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/launch-installer-button/LaunchInstallerButton.tsx22
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/launch-installer-button/index.ts1
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/pause-button/PauseButton.tsx17
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/pause-button/index.ts1
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/resume-button/ResumeButton.tsx17
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/resume-button/index.ts1
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/upgrade-button/UpgradeButton.tsx17
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/upgrade-button/index.ts1
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/upgrade-details/UpgradeDetails.tsx52
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/upgrade-details/components/index.ts1
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/upgrade-details/components/no-changelog-updates/NoChangelogUpdates.tsx16
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/upgrade-details/components/no-changelog-updates/index.ts1
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/upgrade-details/hooks/index.ts3
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/upgrade-details/hooks/useChangelog.ts17
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/upgrade-details/hooks/useShowChangelogList.ts9
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/upgrade-details/hooks/useTitle.ts20
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/upgrade-details/index.ts1
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/hooks/index.ts1
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/hooks/useErrorCountExceeded.ts9
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/index.ts1
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/changelog/ChangelogView.tsx57
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/changelog/components/index.ts1
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/changelog/components/no-changelog-updates/NoChangelogUpdates.tsx16
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/changelog/components/no-changelog-updates/index.ts1
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/changelog/hooks/index.ts3
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/changelog/hooks/useChangelog.ts7
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/changelog/hooks/useHasChangelog.ts9
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/changelog/hooks/useShowChangelogList.ts9
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/index.ts2
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/settings/SettingsView.tsx92
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/settings/components/api-access-methods-list-item/ApiAccessMethodsListItem.tsx19
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/settings/components/api-access-methods-list-item/index.ts1
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/settings/components/app-info-list-item/AppInfoListItem.tsx44
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/settings/components/app-info-list-item/index.ts1
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/settings/components/daita-list-item/DaitaListItem.tsx21
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/settings/components/daita-list-item/hooks/index.ts1
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/settings/components/daita-list-item/hooks/useIsOn.tsx9
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/settings/components/daita-list-item/index.ts1
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/settings/components/debug-list-item/DebugListItem.tsx13
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/settings/components/debug-list-item/index.ts1
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/settings/components/index.ts10
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/settings/components/multihop-list-item/MultihopListItem.tsx20
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/settings/components/multihop-list-item/hooks/index.ts1
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/settings/components/multihop-list-item/hooks/useIsOn.tsx10
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/settings/components/multihop-list-item/index.ts1
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/settings/components/quit-button/QuitButton.tsx17
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/settings/components/quit-button/hooks/index.ts1
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/settings/components/quit-button/hooks/useIsConnected.tsx6
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/settings/components/quit-button/index.ts1
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/settings/components/split-tunneling-list-item/SplitTunnelingListItem.tsx14
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/settings/components/split-tunneling-list-item/index.ts1
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/settings/components/support-list-item/SupportListItem.tsx19
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/settings/components/support-list-item/index.ts1
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/settings/components/user-interface-settings-list-item/UserInterfaceSettingsListItem.tsx19
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/settings/components/user-interface-settings-list-item/index.ts1
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/settings/components/vpn-settings-list-item/VpnSettingsListItem.tsx19
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/settings/components/vpn-settings-list-item/index.ts1
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/settings/hooks/index.ts3
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/settings/hooks/useShowDebug.tsx3
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/settings/hooks/useShowSplitTunneling.tsx6
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/settings/hooks/useShowSubSettings.tsx7
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/views/settings/index.ts1
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/history/hooks/index.ts4
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/history/hooks/usePop.tsx8
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/history/hooks/usePushAppUpgrade.tsx10
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/history/hooks/usePushChangelog.tsx9
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/history/hooks/usePushProblemReport.ts24
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/hooks/index.ts8
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/hooks/useAppUpgradeDownloadProgressValue/constants.ts1
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/hooks/useAppUpgradeDownloadProgressValue/hooks/index.ts2
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/hooks/useAppUpgradeDownloadProgressValue/hooks/useGetValueDownloadProgress.ts20
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/hooks/useAppUpgradeDownloadProgressValue/hooks/useGetValueError.ts29
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/hooks/useAppUpgradeDownloadProgressValue/index.ts1
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/hooks/useAppUpgradeDownloadProgressValue/useAppUpgradeDownloadProgressValue.ts40
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/hooks/useAppUpgradeEventType.ts9
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/hooks/useHasAppUpgradeError.ts9
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/hooks/useHasAppUpgradeEvent.ts9
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/hooks/useHasAppUpgradeInitiated.ts9
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/hooks/useHasAppUpgradeVerifiedInstallerPath.ts11
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/hooks/useIsPlatformLinux.ts5
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/hooks/useMeasure.ts37
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/lib/components/animate/Animate.tsx112
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/lib/components/animate/AnimateContext.tsx55
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/lib/components/animate/animations/animations.ts13
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/lib/components/animate/animations/fade.ts31
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/lib/components/animate/animations/index.ts1
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/lib/components/animate/animations/wipe.ts31
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/lib/components/animate/components/AnimatePresentVertical.tsx34
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/lib/components/animate/components/index.ts1
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/lib/components/animate/hooks/index.ts4
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/lib/components/animate/hooks/useAnimate.ts18
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/lib/components/animate/hooks/useAnimations.ts31
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/lib/components/animate/hooks/useHandleAnimationEnd.ts23
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/lib/components/animate/hooks/usePreviousValue.ts11
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/lib/components/animate/types.ts10
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/lib/components/animate/utils/create-animation-declaration.ts10
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/lib/components/animate/utils/create-animation.ts10
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/lib/components/animate/utils/index.ts2
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/lib/components/dot/Dot.tsx4
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/lib/components/icon/types.ts1
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/lib/components/list-item/ListItem.tsx2
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/lib/components/list-item/components/ListItemContent.tsx47
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/lib/components/list-item/components/ListItemItem.tsx19
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/lib/components/list-item/components/ListItemText.tsx9
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/lib/components/list-item/components/ListItemTrigger.tsx38
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/lib/components/list-item/components/index.ts14
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/lib/components/list-item/components/list-item-content/ListItemContent.tsx47
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/lib/components/list-item/components/list-item-content/index.ts1
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/lib/components/list-item/components/list-item-footer/ListItemFooter.tsx (renamed from desktop/packages/mullvad-vpn/src/renderer/lib/components/list-item/components/ListItemFooter.tsx)2
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/lib/components/list-item/components/list-item-footer/index.ts1
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/lib/components/list-item/components/list-item-group/ListItemGroup.tsx (renamed from desktop/packages/mullvad-vpn/src/renderer/lib/components/list-item/components/ListItemGroup.tsx)2
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/lib/components/list-item/components/list-item-group/index.ts1
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/lib/components/list-item/components/list-item-item/ListItemItem.tsx29
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/lib/components/list-item/components/list-item-item/hooks/index.ts1
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/lib/components/list-item/components/list-item-item/hooks/useBackgroundColor.tsx7
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/lib/components/list-item/components/list-item-item/index.ts1
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/lib/components/list-item/components/list-item-label/ListItemLabel.tsx (renamed from desktop/packages/mullvad-vpn/src/renderer/lib/components/list-item/components/ListItemLabel.tsx)4
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/lib/components/list-item/components/list-item-label/index.ts1
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/lib/components/list-item/components/list-item-text/ListItemText.tsx9
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/lib/components/list-item/components/list-item-text/index.ts1
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/lib/components/list-item/components/list-item-trigger/ListItemTrigger.tsx46
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/lib/components/list-item/components/list-item-trigger/index.ts1
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/lib/components/progress/Progress.tsx2
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/lib/history.tsx3
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/lib/notifications/app-upgrade-available.ts96
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/lib/notifications/app-upgrade-error.ts157
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/lib/notifications/app-upgrade-progress.ts89
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/lib/notifications/app-upgrade-ready.ts61
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/lib/notifications/index.ts3
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/lib/notifications/new-version.ts2
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/lib/notifications/no-open-vpn-server-available.ts2
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/lib/notifications/unsupported-wireguard-port.ts2
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/lib/routeHelpers.ts2
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/redux/account/hooks/index.ts1
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/redux/account/hooks/useAccountStatus.tsx7
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/redux/app-upgrade/actions.ts62
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/redux/app-upgrade/helpers/convertEventTypeToStep.ts25
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/redux/app-upgrade/helpers/index.ts1
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/redux/app-upgrade/hooks/index.ts4
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/redux/app-upgrade/hooks/useAppUpgradeError.ts9
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/redux/app-upgrade/hooks/useAppUpgradeErrorCount.ts7
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/redux/app-upgrade/hooks/useAppUpgradeEvent.ts9
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/redux/app-upgrade/hooks/useAppUpgradeLastProgress.ts9
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/redux/app-upgrade/reducers.ts51
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/redux/connection/hooks/index.ts2
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/redux/connection/hooks/useConnectionIsBlocked.tsx5
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/redux/connection/hooks/useConnectionStatus.tsx5
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/redux/hooks/index.ts6
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/redux/settings/hooks/index.ts3
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/redux/settings/hooks/useSettingsDaitaEnabled.tsx7
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/redux/settings/hooks/useSettingsRelaySettings.tsx7
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/redux/settings/hooks/useSettingsShowBetaReleases.tsx10
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/redux/settings/reducers.ts1
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/redux/store.ts6
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/redux/userinterface/hooks/index.ts3
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/redux/userinterface/hooks/useUserInterfaceChangelog.ts7
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/redux/userinterface/hooks/useUserInterfaceConnectedToDaemon.ts7
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/redux/userinterface/hooks/useUserInterfaceIsMacOs13OrNewer.ts7
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/redux/version/hooks/index.ts5
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/redux/version/hooks/useVersionConsistent.tsx5
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/redux/version/hooks/useVersionCurrent.tsx5
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/redux/version/hooks/useVersionIsBeta.tsx5
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/redux/version/hooks/useVersionSuggestedIsBeta.tsx5
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/redux/version/hooks/useVersionSuggestedUpgrade.tsx5
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/redux/version/reducers.ts3
-rw-r--r--desktop/packages/mullvad-vpn/src/shared/app-upgrade.ts39
-rw-r--r--desktop/packages/mullvad-vpn/src/shared/daemon-rpc-types.ts49
-rw-r--r--desktop/packages/mullvad-vpn/src/shared/gui-settings-state.ts4
-rw-r--r--desktop/packages/mullvad-vpn/src/shared/ipc-schema.ts9
-rw-r--r--desktop/packages/mullvad-vpn/src/shared/ipc-types.ts7
-rw-r--r--desktop/packages/mullvad-vpn/src/shared/localization-contexts.ts3
-rw-r--r--desktop/packages/mullvad-vpn/src/shared/notifications/account-expired.ts10
-rw-r--r--desktop/packages/mullvad-vpn/src/shared/notifications/close-to-account-expiry.ts18
-rw-r--r--desktop/packages/mullvad-vpn/src/shared/notifications/notification.ts33
-rw-r--r--desktop/packages/mullvad-vpn/src/shared/notifications/unsupported-version.ts59
-rw-r--r--desktop/packages/mullvad-vpn/src/shared/notifications/update-available.ts56
-rw-r--r--desktop/packages/mullvad-vpn/src/shared/routes.ts (renamed from desktop/packages/mullvad-vpn/src/renderer/lib/routes.ts)1
-rw-r--r--desktop/packages/mullvad-vpn/src/shared/utils.ts4
-rw-r--r--desktop/packages/mullvad-vpn/test/e2e/installed/state-dependent/api-access-methods.spec.ts2
-rw-r--r--desktop/packages/mullvad-vpn/test/e2e/installed/state-dependent/custom-bridge.spec.ts2
-rw-r--r--desktop/packages/mullvad-vpn/test/e2e/installed/state-dependent/device-revoked.spec.ts2
-rw-r--r--desktop/packages/mullvad-vpn/test/e2e/installed/state-dependent/disconnected.spec.ts2
-rw-r--r--desktop/packages/mullvad-vpn/test/e2e/installed/state-dependent/login.spec.ts2
-rw-r--r--desktop/packages/mullvad-vpn/test/e2e/installed/state-dependent/macos-split-tunneling.spec.ts2
-rw-r--r--desktop/packages/mullvad-vpn/test/e2e/installed/state-dependent/obfuscation.spec.ts2
-rw-r--r--desktop/packages/mullvad-vpn/test/e2e/installed/state-dependent/openvpn-tunnel-state.spec.ts2
-rw-r--r--desktop/packages/mullvad-vpn/test/e2e/installed/state-dependent/settings-import.spec.ts2
-rw-r--r--desktop/packages/mullvad-vpn/test/e2e/installed/state-dependent/too-many-devices.spec.ts2
-rw-r--r--desktop/packages/mullvad-vpn/test/e2e/installed/state-dependent/tunnel-state.spec.ts2
-rw-r--r--desktop/packages/mullvad-vpn/test/e2e/mocked/app-upgrade/app-upgrade.spec.ts261
-rw-r--r--desktop/packages/mullvad-vpn/test/e2e/mocked/app-upgrade/helpers.ts147
-rw-r--r--desktop/packages/mullvad-vpn/test/e2e/mocked/expired-account-error-view.spec.ts2
-rw-r--r--desktop/packages/mullvad-vpn/test/e2e/mocked/feature-indicators.spec.ts2
-rw-r--r--desktop/packages/mullvad-vpn/test/e2e/mocked/main.spec.ts2
-rw-r--r--desktop/packages/mullvad-vpn/test/e2e/mocked/notifications.spec.ts2
-rw-r--r--desktop/packages/mullvad-vpn/test/e2e/mocked/select-location.spec.ts2
-rw-r--r--desktop/packages/mullvad-vpn/test/e2e/mocked/settings.spec.ts2
-rw-r--r--desktop/packages/mullvad-vpn/test/e2e/mocked/tunnel-state.spec.ts2
-rw-r--r--desktop/packages/mullvad-vpn/test/e2e/route-object-models/main/main-route-object-model.ts2
-rw-r--r--desktop/packages/mullvad-vpn/test/e2e/route-object-models/settings/settings-route-object-model.ts2
-rw-r--r--desktop/packages/mullvad-vpn/test/e2e/route-object-models/user-interface-settings/user-interface-settings-route-object-model.ts2
-rw-r--r--desktop/packages/mullvad-vpn/test/e2e/setup/main.ts1
-rw-r--r--desktop/packages/mullvad-vpn/test/unit/history.spec.ts2
-rw-r--r--desktop/packages/mullvad-vpn/test/unit/notification-evaluation.spec.ts12
-rw-r--r--dist-assets/windows/installer.nsh23
-rw-r--r--installer-downloader/CHANGELOG.md2
-rw-r--r--installer-downloader/src/controller.rs4
-rw-r--r--mullvad-api/src/version.rs26
-rw-r--r--mullvad-cli/src/cmds/version.rs22
-rw-r--r--mullvad-daemon/Cargo.toml6
-rw-r--r--mullvad-daemon/build.rs25
-rw-r--r--mullvad-daemon/src/lib.rs100
-rw-r--r--mullvad-daemon/src/management_interface.rs72
-rw-r--r--mullvad-daemon/src/version.rs19
-rw-r--r--mullvad-daemon/src/version/check.rs (renamed from mullvad-daemon/src/version_check.rs)700
-rw-r--r--mullvad-daemon/src/version/downloader.rs241
-rw-r--r--mullvad-daemon/src/version/mod.rs61
-rw-r--r--mullvad-daemon/src/version/router.rs1081
-rw-r--r--mullvad-management-interface/proto/management_interface.proto46
-rw-r--r--mullvad-management-interface/src/client.rs8
-rw-r--r--mullvad-management-interface/src/types/conversions/version.rs191
-rw-r--r--mullvad-paths/src/cache.rs9
-rw-r--r--mullvad-paths/src/lib.rs17
-rw-r--r--mullvad-paths/src/logs.rs16
-rw-r--r--mullvad-paths/src/settings.rs10
-rw-r--r--mullvad-paths/src/unix.rs30
-rw-r--r--mullvad-paths/src/windows.rs46
-rw-r--r--mullvad-types/Cargo.toml2
-rw-r--r--mullvad-types/src/version.rs64
-rw-r--r--mullvad-update/Cargo.toml2
-rw-r--r--mullvad-update/mullvad-release/src/main.rs3
-rw-r--r--mullvad-update/mullvad-release/src/platform.rs9
-rw-r--r--mullvad-update/src/client/app.rs33
-rw-r--r--mullvad-update/src/client/fetch.rs202
-rw-r--r--mullvad-update/src/version.rs11
-rw-r--r--mullvad-version/build.rs25
-rw-r--r--mullvad-version/src/lib.rs1
-rw-r--r--mullvad-version/src/main.rs235
-rw-r--r--test/Cargo.lock2
391 files changed, 7715 insertions, 1495 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 195235f446..8b2b588f22 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -25,6 +25,8 @@ Line wrap the file at 100 chars. Th
### Added
- Add notification that shows when the user is connected to WireGuard with a port that is not
supported.
+- Add in-app updates to Windows and macOS. This new feature lets you download, verify, and install
+ new versions from within the app.
#### Linux
- The deb package repositores now have static codenames on top of the existing distro version
diff --git a/Cargo.lock b/Cargo.lock
index 796002dbab..2878ae49ec 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -2937,10 +2937,12 @@ dependencies = [
"mullvad-paths",
"mullvad-relay-selector",
"mullvad-types",
+ "mullvad-update",
"mullvad-version",
"nix 0.23.2",
"notify 8.0.0",
"objc2",
+ "rand 0.8.5",
"regex",
"serde",
"serde_json",
@@ -3216,6 +3218,7 @@ dependencies = [
"intersection-derive",
"ipnetwork",
"log",
+ "mullvad-version",
"regex",
"serde",
"talpid-types",
@@ -3235,6 +3238,7 @@ dependencies = [
"hex",
"insta",
"json-canon",
+ "log",
"mockito",
"mullvad-version",
"rand 0.8.5",
@@ -5806,6 +5810,7 @@ dependencies = [
"futures-core",
"pin-project-lite",
"tokio",
+ "tokio-util 0.7.10",
]
[[package]]
diff --git a/android/lib/daemon-grpc/src/main/kotlin/net/mullvad/mullvadvpn/lib/daemon/grpc/mapper/ToDomain.kt b/android/lib/daemon-grpc/src/main/kotlin/net/mullvad/mullvadvpn/lib/daemon/grpc/mapper/ToDomain.kt
index db6e21a586..feb181f01d 100644
--- a/android/lib/daemon-grpc/src/main/kotlin/net/mullvad/mullvadvpn/lib/daemon/grpc/mapper/ToDomain.kt
+++ b/android/lib/daemon-grpc/src/main/kotlin/net/mullvad/mullvadvpn/lib/daemon/grpc/mapper/ToDomain.kt
@@ -517,7 +517,7 @@ internal fun QuantumResistantState.toDomain(): ManagementInterface.QuantumResist
internal fun ManagementInterface.AppVersionInfo.toDomain(): AppVersionInfo =
AppVersionInfo(
supported = supported,
- suggestedUpgrade = if (hasSuggestedUpgrade()) suggestedUpgrade else null,
+ suggestedUpgrade = if (hasSuggestedUpgrade()) suggestedUpgrade.version else null,
)
internal fun ConnectivityState.toDomain(): GrpcConnectivityState =
diff --git a/desktop/packages/mullvad-vpn/assets/icons/icon-settings-partial.svg b/desktop/packages/mullvad-vpn/assets/icons/icon-settings-partial.svg
new file mode 100644
index 0000000000..8e86c1c392
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/assets/icons/icon-settings-partial.svg
@@ -0,0 +1,3 @@
+<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
+ <path fill-rule="evenodd" clip-rule="evenodd" d="M21.1188 9.89587C20.7563 9.96424 20.3824 10 20 10C16.6863 10 14 7.31371 14 4C14 3.32438 14.1117 2.67485 14.3176 2.06884C14.2392 2.02283 14.1493 1.99876 14.0574 2.00005H9.94154C9.82218 1.99838 9.70617 2.03947 9.61446 2.11589C9.52276 2.19231 9.46142 2.29901 9.44154 2.41672L9.05071 5.07005C8.43252 5.32247 7.84873 5.65203 7.31321 6.05088L4.75321 5.05088C4.69336 5.02788 4.62982 5.01602 4.56571 5.01588C4.47649 5.01521 4.38871 5.03842 4.31149 5.08312C4.23426 5.12781 4.17041 5.19235 4.12654 5.27005L2.07154 8.73005C2.0082 8.833 1.98582 8.95598 2.00881 9.07465C2.0318 9.19332 2.0985 9.29903 2.19571 9.37088L4.36237 11.0209C4.31737 11.3454 4.29343 11.6725 4.29071 12C4.29321 12.3279 4.31687 12.6552 4.36154 12.98L2.19487 14.63C2.10003 14.7035 2.03513 14.8089 2.01228 14.9267C1.98944 15.0444 2.0102 15.1665 2.07071 15.27L4.12654 18.7309C4.1723 18.8085 4.23784 18.8725 4.31647 18.9164C4.3951 18.9603 4.48399 18.9826 4.57404 18.9809C4.63508 18.9809 4.69568 18.9705 4.75321 18.9501L7.31321 17.95C7.84779 18.3526 8.43311 18.683 9.05404 18.9326L9.44487 21.5834C9.46476 21.7011 9.52609 21.8078 9.6178 21.8842C9.7095 21.9606 9.82552 22.0017 9.94487 22.0001H14.0574C14.1773 22.0025 14.2941 21.9618 14.3865 21.8853C14.4789 21.8088 14.5407 21.7017 14.5607 21.5834L14.949 18.9292C15.5673 18.6777 16.1512 18.3487 16.6865 17.95L19.2465 18.9501C19.3064 18.9731 19.3699 18.985 19.434 18.9851C19.5234 18.9859 19.6114 18.9627 19.6887 18.918C19.7661 18.8733 19.8301 18.8087 19.874 18.7309L21.9307 15.27C21.9912 15.1665 22.012 15.0444 21.9891 14.9267C21.9663 14.8089 21.9014 14.7035 21.8065 14.63L19.6399 12.98C19.6847 12.6552 19.7086 12.3279 19.7115 12C19.7091 11.6725 19.6857 11.3454 19.6415 11.0209L21.1188 9.89587ZM9.68416 8.53559C10.3694 8.07775 11.175 7.83338 11.999 7.83338C13.1038 7.83449 14.1629 8.27383 14.9441 9.05499C15.7253 9.83615 16.1646 10.8953 16.1657 12C16.1657 12.8241 15.9213 13.6297 15.4635 14.3149C15.0057 15.0001 14.3549 15.5342 13.5936 15.8495C12.8322 16.1649 11.9944 16.2474 11.1862 16.0867C10.3779 15.9259 9.63548 15.529 9.05276 14.9463C8.47004 14.3636 8.07321 13.6212 7.91243 12.8129C7.75166 12.0047 7.83418 11.1669 8.14954 10.4055C8.46491 9.64418 8.99896 8.99343 9.68416 8.53559Z" fill="white" />
+</svg> \ No newline at end of file
diff --git a/desktop/packages/mullvad-vpn/eslint.config.mjs b/desktop/packages/mullvad-vpn/eslint.config.mjs
index 4026cbcdc1..2686d8751a 100644
--- a/desktop/packages/mullvad-vpn/eslint.config.mjs
+++ b/desktop/packages/mullvad-vpn/eslint.config.mjs
@@ -8,7 +8,7 @@ import workspaceConfig from '../../eslint.config.mjs';
export default [
...workspaceConfig,
react.configs.flat.recommended,
- { ignores: ['build/'] },
+ { ignores: ['build/', 'build-standalone/'] },
{
files: ['**/*'],
ignores: ['src/renderer/'],
diff --git a/desktop/packages/mullvad-vpn/locales/messages.pot b/desktop/packages/mullvad-vpn/locales/messages.pot
index 53adb35f5a..3c11128ccd 100644
--- a/desktop/packages/mullvad-vpn/locales/messages.pot
+++ b/desktop/packages/mullvad-vpn/locales/messages.pot
@@ -332,6 +332,11 @@ msgctxt "accessibility"
msgid "Go to %(wireGuard)s settings."
msgstr ""
+#. Accessibility label for link to app upgrade view.
+msgctxt "accessibility"
+msgid "Go to app update page"
+msgstr ""
+
#. Accessibility label for link to blog post about OpenVPN support ending.
msgctxt "accessibility"
msgid "Go to blog post to read more, opens externally"
@@ -369,6 +374,15 @@ msgctxt "accessibility"
msgid "More information"
msgstr ""
+#. Accessbility label for link to go to download page.
+msgctxt "accessibility"
+msgid "New version available, click here to go to download page, opens externally"
+msgstr ""
+
+msgctxt "accessibility"
+msgid "New version available, click here to go to update view"
+msgstr ""
+
msgctxt "accessibility"
msgid "New version installed, click here to see the changelog"
msgstr ""
@@ -611,20 +625,201 @@ msgctxt "api-access-methods-view"
msgid "With the “Mullvad bridges” method, the app communicates with a Mullvad API server via a Mullvad bridge server. It does this by sending the traffic obfuscated by Shadowsocks."
msgstr ""
+#. Title of the app info view.
msgctxt "app-info-view"
msgid "App info"
msgstr ""
+#. Description for version list item when app is out of sync.
msgctxt "app-info-view"
msgid "App is out of sync. Please quit and restart."
msgstr ""
+#. Label for switch to toggle beta program.
+msgctxt "app-info-view"
+msgid "Beta program"
+msgstr ""
+
msgctxt "app-info-view"
-msgid "App version"
+msgid "Enable to get notified when new beta versions of the app are released."
+msgstr ""
+
+msgctxt "app-info-view"
+msgid "This option is unavailable while using a beta version."
+msgstr ""
+
+#. Label for update available list item.
+msgctxt "app-info-view"
+msgid "Update available"
+msgstr ""
+
+#. Label for version list item.
+msgctxt "app-info-view"
+msgid "Version"
msgstr ""
+#. Label for changelog list item.
msgctxt "app-info-view"
-msgid "Update available. Install the latest app version to stay up to date."
+msgid "What’s new"
+msgstr ""
+
+#. Status text displayed below a progress bar when the update is being downloaded
+#. with the estimated time of completion is within a few seconds.
+msgctxt "app-upgrade-view"
+msgid "A few seconds remaining..."
+msgstr ""
+
+#. Status text displayed below a progress bar when the update is being downloaded
+#. with the estimated time of completion represented in minutes.
+#. Available placeholders:
+#. %(minutes)s - Will be replaced with remaining minutes until download is complete
+msgctxt "app-upgrade-view"
+msgid "About %(minutes)s minutes remaining..."
+msgstr ""
+
+#. Status text displayed below a progress bar when the update is being downloaded
+#. with the estimated time of completion represented in seconds.
+#. Available placeholders:
+#. %(second)s - Will be replaced with remaining seconds until download is complete
+msgctxt "app-upgrade-view"
+msgid "About %(seconds)s seconds remaining..."
+msgstr ""
+
+#. Label displayed when an error occurred due to the connection being blocked
+msgctxt "app-upgrade-view"
+msgid "Connection blocked. Try changing server or other settings."
+msgstr ""
+
+#. Label displayed when an error occurred due to the installer failing to start
+#. and the suggested resolution is to download the update again.
+msgctxt "app-upgrade-view"
+msgid "Could not open installer, please try again or send a problem report."
+msgstr ""
+
+#. Button text to download and install an update
+msgctxt "app-upgrade-view"
+msgid "Download & install"
+msgstr ""
+
+#. Status text displayed below a progress bar when the download of an update is complete
+msgctxt "app-upgrade-view"
+msgid "Download complete!"
+msgstr ""
+
+#. Status text displayed below a progress bar when the download of an update fails
+msgctxt "app-upgrade-view"
+msgid "Download failed"
+msgstr ""
+
+#. Label displayed when an error occurred due to the download failing
+msgctxt "app-upgrade-view"
+msgid "Download failed, please check your connection/firewall and try again, or send a problem report."
+msgstr ""
+
+#. Status text displayed below a progress bar when the download of an update has been paused
+#. Label displayed above a progress bar when the update is verified successfully
+msgctxt "app-upgrade-view"
+msgid "Download paused"
+msgstr ""
+
+#. Label displayed above a progress bar informing the user which server
+#. the update is downloading from
+msgctxt "app-upgrade-view"
+msgid "Downloading from: %(server)s"
+msgstr ""
+
+#. Label displayed above a progress bar when a download is in progress
+msgctxt "app-upgrade-view"
+msgid "Downloading..."
+msgstr ""
+
+#. Link shown to optionally manually download the update
+#. due to repeated errors in the upgrade process.
+msgctxt "app-upgrade-view"
+msgid "Having problems? Try downloading the app from our website"
+msgstr ""
+
+#. Button text to install an update
+msgctxt "app-upgrade-view"
+msgid "Install update"
+msgstr ""
+
+#. Label displayed when an error occurred within the installer
+msgctxt "app-upgrade-view"
+msgid "Installer quit unexpectedly, please try again or send a problem report."
+msgstr ""
+
+#. Text displayed when there are no updates for this platform in the next app version
+msgctxt "app-upgrade-view"
+msgid "No updates or changes were made in this release for this platform."
+msgstr ""
+
+#. Button text to pause the download of an update
+msgctxt "app-upgrade-view"
+msgid "Pause"
+msgstr ""
+
+#. Button text to report a problem
+msgctxt "app-upgrade-view"
+msgid "Report a problem"
+msgstr ""
+
+#. Button text to resume updating
+msgctxt "app-upgrade-view"
+msgid "Resume"
+msgstr ""
+
+#. Button text to try again
+msgctxt "app-upgrade-view"
+msgid "Retry"
+msgstr ""
+
+#. Button text to try download again
+msgctxt "app-upgrade-view"
+msgid "Retry download"
+msgstr ""
+
+#. Status text displayed below a progress bar when the download of an update is starting
+msgctxt "app-upgrade-view"
+msgid "Starting download..."
+msgstr ""
+
+#. Button text to when starting the installer for an update
+msgctxt "app-upgrade-view"
+msgid "Starting installer..."
+msgstr ""
+
+#. Label displayed when an unknown error occurred
+msgctxt "app-upgrade-view"
+msgid "Unknown error occurred. Please try again or send a problem report."
+msgstr ""
+
+#. Title in navigation bar
+#. Main title for the update available view
+msgctxt "app-upgrade-view"
+msgid "Update available"
+msgstr ""
+
+#. Label displayed when an error occurred due to the installer failed verification
+msgctxt "app-upgrade-view"
+msgid "Verification failed, please try again or send a problem report."
+msgstr ""
+
+#. Label displayed above a progress bar when the update is verified successfully
+msgctxt "app-upgrade-view"
+msgid "Verification successful!"
+msgstr ""
+
+#. Label displayed above a progress bar when the update is being verified
+msgctxt "app-upgrade-view"
+msgid "Verifying installer..."
+msgstr ""
+
+#. Heading which shows the version of the app which can be upgraded to.
+#. Available placeholders:
+#. %(version)s - The new version of the app.
+msgctxt "app-upgrade-view"
+msgid "Version %(version)s"
msgstr ""
msgctxt "auth-failure"
@@ -643,10 +838,13 @@ msgctxt "auth-failure"
msgid "You are logged in with an invalid account number. Please log out and try another one."
msgstr ""
+#. Text displayed when there are no updates for this platform in the app version
msgctxt "changelog-view"
msgid "No updates or changes were made in this release for this platform."
msgstr ""
+#. Heading for the view of the changes and updates in the
+#. current version compared to the old version.
msgctxt "changelog-view"
msgid "What’s new"
msgstr ""
@@ -957,6 +1155,13 @@ msgctxt "in-app-notifications"
msgid "%(openVpn)s support is ending. Switch location or"
msgstr ""
+#. Notification subtitle when the app upgrade is ready to install.
+#. Available placeholders:
+#. - %(suggestedUpgradeVersion)s: Upgrade version to be installed.
+msgctxt "in-app-notifications"
+msgid "%(suggestedUpgradeVersion)s is ready to be installed."
+msgstr ""
+
#. Link in notication to go to WireGuard settings.
#. Available placeholders:
#. %(wireGuard)s - Will be replaced with WireGuard
@@ -988,15 +1193,73 @@ msgctxt "in-app-notifications"
msgid "change tunnel protocol to %(wireGuard)s."
msgstr ""
+#. Notification subtitle when the app upgrade is ready to install.
+msgctxt "in-app-notifications"
+msgid "Click here to install update."
+msgstr ""
+
+#. Notification subtitle when the installer failed.
+msgctxt "in-app-notifications"
+msgid "Click here to retry"
+msgstr ""
+
+#. Notification subtitle when the download of the installer failed
+#. and the user can try downloading again.
+msgctxt "in-app-notifications"
+msgid "Click here to retry download"
+msgstr ""
+
msgctxt "in-app-notifications"
msgid "Click here to see what’s new."
msgstr ""
+#. Link text to go to the app upgrade view
+msgctxt "in-app-notifications"
+msgid "Click here to update"
+msgstr ""
+
+#. Notification subtitle when the installer download failed.
+msgctxt "in-app-notifications"
+msgid "Could not download installer."
+msgstr ""
+
+#. Generic notification subtitle when the app upgrade failed.
+msgctxt "in-app-notifications"
+msgid "Could not upgrade the app."
+msgstr ""
+
+#. Notification title when app upgrade is verifying the installer.
+msgctxt "in-app-notifications"
+msgid "DOWNLOAD COMPLETE! VERIFYING..."
+msgstr ""
+
+#. Notification title when the installer download failed.
+msgctxt "in-app-notifications"
+msgid "DOWNLOAD FAILED"
+msgstr ""
+
+#. Generic notification title when app upgrade is downloading the installer.
+msgctxt "in-app-notifications"
+msgid "DOWNLOADING UPDATE..."
+msgstr ""
+
+#. Notification title when the app upgrade is in progress.
+#. Available placeholders:
+#. - %(appUpgradeDownloadProgressValue)s: The download progress value.
+msgctxt "in-app-notifications"
+msgid "DOWNLOADING UPDATE... %(appUpgradeDownloadProgressValue)s%%"
+msgstr ""
+
#. The in-app banner displayed to the user when the app update is available.
msgctxt "in-app-notifications"
msgid "Install the latest app version to stay up to date."
msgstr ""
+#. Notification title when the installer failed.
+msgctxt "in-app-notifications"
+msgid "INSTALLER FAILED"
+msgstr ""
+
msgctxt "in-app-notifications"
msgid "NETWORK TRAFFIC MIGHT BE LEAKING"
msgstr ""
@@ -1040,11 +1303,41 @@ msgctxt "in-app-notifications"
msgid "Read more"
msgstr ""
+#. Notification title when the app upgrade is ready to install.
+msgctxt "in-app-notifications"
+msgid "READY TO INSTALL UPDATE"
+msgstr ""
+
+#. Accessibility label for the button to retry download of the installer.
+msgctxt "in-app-notifications"
+msgid "Retry download of the installer"
+msgstr ""
+
+#. Accessibility label for the button to retry the installation.
+msgctxt "in-app-notifications"
+msgid "Retry installation"
+msgstr ""
+
#. Button label to send a problem report.
msgctxt "in-app-notifications"
msgid "Send problem report"
msgstr ""
+#. Notification subtitle when the installer verification failed.
+msgctxt "in-app-notifications"
+msgid "The installer could not be verified."
+msgstr ""
+
+#. Notification subtitle when the installer failed.
+msgctxt "in-app-notifications"
+msgid "The installer did not complete successfully."
+msgstr ""
+
+#. Notification subtitle when the installer failed.
+msgctxt "in-app-notifications"
+msgid "The installer did not start successfully."
+msgstr ""
+
#. Notification subtitle indicating the user is using an unsupported port for WireGuard.
#. Available placeholders:
#. %(wireGuard)s - Will be replaced with WireGuard
@@ -1068,6 +1361,26 @@ msgctxt "in-app-notifications"
msgid "UPDATE AVAILABLE"
msgstr ""
+#. Generic notification title when the app upgrade failed.
+msgctxt "in-app-notifications"
+msgid "UPDATE FAILED"
+msgstr ""
+
+#. Notification title when app upgrade is ready for the user to launch the installer.
+msgctxt "in-app-notifications"
+msgid "VERIFICATION COMPLETE! INSTALLER READY!"
+msgstr ""
+
+#. Notification title when app upgrade is launching the installer.
+msgctxt "in-app-notifications"
+msgid "VERIFICATION COMPLETE! LAUNCHING INSTALLER..."
+msgstr ""
+
+#. Notification title when the installer verification failed.
+msgctxt "in-app-notifications"
+msgid "VERIFICATION FAILED"
+msgstr ""
+
msgctxt "in-app-notifications"
msgid "Welcome, this device is now called <b>%(deviceName)s</b>. For more details see the info button in Account."
msgstr ""
@@ -1214,11 +1527,6 @@ msgctxt "navigation-bar"
msgid "API access"
msgstr ""
-#. Title label in navigation bar
-msgctxt "navigation-bar"
-msgid "Settings"
-msgstr ""
-
#. The system notification displayed to the user when the account credit is close to expiry.
#. Available placeholder:
#. %(duration)s - remaining time, e.g. "2 days"
@@ -1306,6 +1614,12 @@ msgctxt "notifications"
msgid "No servers match your settings, try changing server or other settings."
msgstr ""
+#. A link in the in-app banner to encourage the user to update the app.
+#. The in-app banner is is displayed to the user when the running app becomes unsupported.
+msgctxt "notifications"
+msgid "Please click here to update now"
+msgstr ""
+
msgctxt "notifications"
msgid "Reconnecting"
msgstr ""
@@ -1376,7 +1690,12 @@ msgctxt "notifications"
msgid "Your device is offline. The tunnel will automatically connect once your device is back online."
msgstr ""
-#. The in-app banner and system notification which are displayed to the user when the running app becomes unsupported.
+#. The in-app banner which is displayed to the user when the running app becomes unsupported.
+msgctxt "notifications"
+msgid "Your privacy might be at risk with this unsupported app version."
+msgstr ""
+
+#. The system notification which is displayed to the user when the running app becomes unsupported.
msgctxt "notifications"
msgid "Your privacy might be at risk with this unsupported app version. Please update now."
msgstr ""
@@ -1683,6 +2002,7 @@ msgctxt "settings-view"
msgid "API access"
msgstr ""
+#. Navigation button to the 'App info' view
msgctxt "settings-view"
msgid "App info"
msgstr ""
@@ -1691,10 +2011,22 @@ msgctxt "settings-view"
msgid "Multihop"
msgstr ""
+#. Title label in navigation bar
+#. Main title for settings view
+msgctxt "settings-view"
+msgid "Settings"
+msgstr ""
+
+#. Navigation button to the 'Support' view
msgctxt "settings-view"
msgid "Support"
msgstr ""
+#. Label for the app info list item indicating that an update is available and can be downloaded
+msgctxt "settings-view"
+msgid "Update available"
+msgstr ""
+
#. Navigation button to the 'User interface settings' view
msgctxt "settings-view"
msgid "User interface settings"
@@ -1705,10 +2037,6 @@ msgctxt "settings-view"
msgid "VPN settings"
msgstr ""
-msgctxt "settings-view"
-msgid "What’s new"
-msgstr ""
-
msgctxt "split-tunneling-view"
msgid "%(applicationName)s is problematic and can’t be excluded from the VPN tunnel."
msgstr ""
@@ -1781,10 +2109,6 @@ msgctxt "split-tunneling-view"
msgid "Unable to launch selection. %(detailedErrorMessage)s"
msgstr ""
-msgctxt "support-view"
-msgid "Beta program"
-msgstr ""
-
#. Button label for continuing problem report submission with an outdated app version.
msgctxt "support-view"
msgid "Continue anyway"
@@ -1796,10 +2120,6 @@ msgid "Edit message"
msgstr ""
msgctxt "support-view"
-msgid "Enable to get notified when new beta versions of the app are released."
-msgstr ""
-
-msgctxt "support-view"
msgid "Failed to send"
msgstr ""
@@ -1853,10 +2173,6 @@ msgid "Thanks!"
msgstr ""
msgctxt "support-view"
-msgid "This option is unavailable while using a beta version."
-msgstr ""
-
-msgctxt "support-view"
msgid "To assist you better, please write in English or Swedish and include which country you are connecting from."
msgstr ""
@@ -1869,9 +2185,9 @@ msgctxt "support-view"
msgid "Try again"
msgstr ""
-#. Button label for upgrading the app to the latest version.
+#. Button label for updating the app to the latest version.
msgctxt "support-view"
-msgid "Upgrade app"
+msgid "Update app"
msgstr ""
#. Button label for opening app logs.
@@ -2954,9 +3270,6 @@ msgstr ""
msgid "Verifying voucher…"
msgstr ""
-msgid "Version"
-msgstr ""
-
msgid "View and manage all your logged in devices. You can have up to 5 devices on one account at a time. Each device gets a name when logged in to help you tell them apart easily."
msgstr ""
diff --git a/desktop/packages/mullvad-vpn/scripts/build-test-executable.sh b/desktop/packages/mullvad-vpn/scripts/build-test-executable.sh
index 9dd6f26b03..8bc64c06f1 100755
--- a/desktop/packages/mullvad-vpn/scripts/build-test-executable.sh
+++ b/desktop/packages/mullvad-vpn/scripts/build-test-executable.sh
@@ -9,10 +9,10 @@ TARGET=${1:-$(rustc -vV | sed -n 's|host: ||p')}
PRODUCT_VERSION=$(cargo run -q --bin mullvad-version)
ASSETS=(
- "build-standalone/src/renderer/lib/routes.js"
"build-standalone/src/renderer/lib/foundations/*.js"
"build-standalone/src/renderer/lib/foundations/**/*.js"
"build-standalone/src/shared/constants/*.js"
+ "build-standalone/src/shared/routes.js"
"build-standalone/test/e2e/utils.js"
"build-standalone/test/e2e/shared/*.js"
"build-standalone/test/e2e/installed/*.js"
diff --git a/desktop/packages/mullvad-vpn/src/main/app-upgrade.ts b/desktop/packages/mullvad-vpn/src/main/app-upgrade.ts
new file mode 100644
index 0000000000..ff50218d5e
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/main/app-upgrade.ts
@@ -0,0 +1,155 @@
+import { spawn } from 'child_process';
+import fs from 'fs/promises';
+import { tmpdir } from 'os';
+
+import { DaemonAppUpgradeEvent } from '../shared/daemon-rpc-types';
+import log from '../shared/logging';
+import { DaemonRpc, SubscriptionListener } from './daemon-rpc';
+import { IpcMainEventChannel } from './ipc-event-channel';
+
+export default class AppUpgrade {
+ public constructor(private daemonRpc: DaemonRpc) {}
+
+ public registerIpcListeners() {
+ IpcMainEventChannel.app.handleUpgrade(() => {
+ this.daemonRpc.appUpgrade();
+ });
+
+ IpcMainEventChannel.app.handleUpgradeAbort(() => {
+ this.daemonRpc.appUpgradeAbort();
+ });
+
+ IpcMainEventChannel.app.handleUpgradeInstallerStart(async (verifiedInstallerPath: string) => {
+ try {
+ await this.checkInstallerPath(verifiedInstallerPath);
+ this.startInstaller(verifiedInstallerPath);
+ } catch (e) {
+ const error = e as Error;
+ log.error(
+ `An error occurred when trying to start the installer: ${verifiedInstallerPath}. Error: ${error.message}`,
+ );
+ }
+ });
+ }
+
+ public subscribeEvents() {
+ const daemonAppUpgradeEventListener = new SubscriptionListener(
+ (appUpgradeEvent: DaemonAppUpgradeEvent) => {
+ if (appUpgradeEvent.type === 'APP_UPGRADE_ERROR') {
+ IpcMainEventChannel.app.notifyUpgradeError?.(appUpgradeEvent.error);
+ } else {
+ IpcMainEventChannel.app.notifyUpgradeEvent?.(appUpgradeEvent);
+ }
+ },
+ (error: Error) => {
+ log.error(`Cannot deserialize the app upgrade event: ${error.message}`);
+ },
+ );
+
+ this.daemonRpc.subscribeAppUpgradeEventListener(daemonAppUpgradeEventListener);
+
+ return daemonAppUpgradeEventListener;
+ }
+
+ private async checkInstallerPath(verifiedInstallerPath: string) {
+ try {
+ // fs.stat throws if the path does not exist
+ const stat = await fs.stat(verifiedInstallerPath);
+ // If the path exists, verify that its a file.
+ if (!stat.isFile()) {
+ throw new Error('Verified installer path is not a file.');
+ }
+ } catch (e) {
+ // Let the render process know we encountered an error
+ IpcMainEventChannel.app.notifyUpgradeError?.('GENERAL_ERROR');
+ // If the daemon for some reason doesn't reply with an aborted event we should
+ // let the render process know which event step to re-start from.
+ IpcMainEventChannel.app.notifyUpgradeEvent?.({
+ type: 'APP_UPGRADE_STATUS_MANUAL_START_INSTALLER',
+ });
+
+ // Let the daemon know the we are aborting the upgrade
+ this.daemonRpc.appUpgradeAbort();
+
+ const error = e as Error;
+ throw new Error(
+ `An error occurred when checking installer at path: ${verifiedInstallerPath}. Error: ${error.message}`,
+ );
+ }
+ }
+
+ private spawnChildMac(verifiedInstallerPath: string) {
+ const child = spawn('open', [verifiedInstallerPath, '--wait-apps'], {
+ detached: true,
+ });
+
+ return child;
+ }
+
+ private spawnChildWindows(verifiedInstallerPath: string) {
+ const SYSTEM_ROOT_PATH = process.env.SYSTEMROOT || process.env.windir || 'C:\\Windows';
+ const CMD_PATH = `${SYSTEM_ROOT_PATH}\\System32\\cmd.exe`;
+ const quotedVerifiedInstallerPath = `"${verifiedInstallerPath}"`;
+ const updaterFlag = '/inapp';
+
+ const cwd = tmpdir();
+ const child = spawn(CMD_PATH, ['/C', 'start', '""', quotedVerifiedInstallerPath, updaterFlag], {
+ cwd,
+ detached: true,
+ stdio: 'ignore',
+ windowsVerbatimArguments: true,
+ });
+
+ return child;
+ }
+
+ private spawnChild(verifiedInstallerPath: string) {
+ if (process.platform === 'darwin') {
+ return this.spawnChildMac(verifiedInstallerPath);
+ }
+
+ if (process.platform === 'win32') {
+ return this.spawnChildWindows(verifiedInstallerPath);
+ }
+
+ throw new Error(`Unsupported platform: ${process.platform}`);
+ }
+
+ private startInstaller(verifiedInstallerPath: string) {
+ try {
+ const child = this.spawnChild(verifiedInstallerPath);
+ IpcMainEventChannel.app.notifyUpgradeEvent?.({
+ type: 'APP_UPGRADE_STATUS_STARTED_INSTALLER',
+ });
+
+ child.once('error', (error) => {
+ log.error(`An error occurred with the installer: ${error.message}`);
+ IpcMainEventChannel.app.notifyUpgradeError?.('INSTALLER_FAILED');
+ IpcMainEventChannel.app.notifyUpgradeEvent?.({
+ type: 'APP_UPGRADE_STATUS_EXITED_INSTALLER',
+ });
+ });
+
+ child.once('exit', (code) => {
+ if (code !== 0) {
+ log.error(`The installer exited unexpectedly with exit code: ${code}`);
+ IpcMainEventChannel.app.notifyUpgradeError?.('INSTALLER_FAILED');
+ }
+
+ IpcMainEventChannel.app.notifyUpgradeEvent?.({
+ type: 'APP_UPGRADE_STATUS_EXITED_INSTALLER',
+ });
+ });
+ } catch (e) {
+ IpcMainEventChannel.app.notifyUpgradeError?.('START_INSTALLER_FAILED');
+ IpcMainEventChannel.app.notifyUpgradeEvent?.({
+ type: 'APP_UPGRADE_STATUS_EXITED_INSTALLER',
+ });
+
+ const error = e as Error;
+ log.error(
+ `An error occurred when starting installer at path: ${verifiedInstallerPath}. Error: ${error.message}`,
+ );
+ }
+ }
+}
diff --git a/desktop/packages/mullvad-vpn/src/main/daemon-rpc.ts b/desktop/packages/mullvad-vpn/src/main/daemon-rpc.ts
index d5a8ab7f51..d0d4244186 100644
--- a/desktop/packages/mullvad-vpn/src/main/daemon-rpc.ts
+++ b/desktop/packages/mullvad-vpn/src/main/daemon-rpc.ts
@@ -1,4 +1,5 @@
import * as grpc from '@grpc/grpc-js';
+import fs from 'fs';
import { Empty } from 'google-protobuf/google/protobuf/empty_pb.js';
import { BoolValue, StringValue } from 'google-protobuf/google/protobuf/wrappers_pb.js';
import { types as grpcTypes } from 'management-interface';
@@ -12,6 +13,7 @@ import {
BridgeState,
CustomListError,
CustomProxy,
+ DaemonAppUpgradeEvent,
DaemonEvent,
DeviceState,
IAppVersionInfo,
@@ -31,6 +33,8 @@ import {
import { ConnectionObserver, GrpcClient, noConnectionError } from './grpc-client';
import {
convertFromApiAccessMethodSetting,
+ convertFromAppUpgradeEvent,
+ convertFromAppVersionInfo,
convertFromDaemonEvent,
convertFromDevice,
convertFromDeviceState,
@@ -47,7 +51,9 @@ import {
} from './grpc-type-convertions';
const DAEMON_RPC_PATH =
- process.platform === 'win32' ? 'unix:////./pipe/Mullvad VPN' : 'unix:///var/run/mullvad-vpn';
+ process.platform === 'win32' ? '//./pipe/Mullvad VPN' : '/var/run/mullvad-vpn';
+const DAEMON_RPC_PATH_PREFIX = 'unix://';
+const DAEMON_RPC_PATH_PREFIXED = `${DAEMON_RPC_PATH_PREFIX}${DAEMON_RPC_PATH}`;
export class SubscriptionListener<T> {
// Only meant to be used by DaemonRpc
@@ -74,10 +80,13 @@ export class SubscriptionListener<T> {
export class DaemonRpc extends GrpcClient {
private nextSubscriptionId = 0;
- private subscriptions: Map<number, grpc.ClientReadableStream<grpcTypes.DaemonEvent>> = new Map();
+ private subscriptions: Map<
+ number,
+ grpc.ClientReadableStream<grpcTypes.DaemonEvent | grpcTypes.AppUpgradeEvent>
+ > = new Map();
public constructor(connectionObserver?: ConnectionObserver) {
- super(DAEMON_RPC_PATH, connectionObserver);
+ super(DAEMON_RPC_PATH_PREFIXED, connectionObserver);
}
public disconnect() {
@@ -88,6 +97,62 @@ export class DaemonRpc extends GrpcClient {
super.disconnect();
}
+ public async verifyDaemonOwnership() {
+ if (process.platform === 'win32') {
+ try {
+ const { pipeIsAdminOwned } = await import('windows-utils');
+ pipeIsAdminOwned(DAEMON_RPC_PATH);
+ } catch {
+ throw new Error('Failed to verify admin ownership of named pipe');
+ }
+ } else {
+ const stat = fs.statSync(DAEMON_RPC_PATH);
+ if (stat.uid !== 0) {
+ throw new Error('Failed to verify root ownership of socket');
+ }
+ }
+ }
+
+ public subscribeAppUpgradeEventListener(listener: SubscriptionListener<DaemonAppUpgradeEvent>) {
+ const call = this.isConnected && this.client.appUpgradeEventsListen(new Empty());
+ if (!call) {
+ throw noConnectionError;
+ }
+ const subscriptionId = this.subscriptionId();
+ listener.subscriptionId = subscriptionId;
+ this.subscriptions.set(subscriptionId, call);
+
+ call.on('data', (data: grpcTypes.AppUpgradeEvent) => {
+ try {
+ const appUpgradeEvent = convertFromAppUpgradeEvent(data);
+ listener.onEvent(appUpgradeEvent);
+ } catch (e) {
+ const error = e as Error;
+ listener.onError(error);
+ }
+ });
+
+ call.on('error', (error) => {
+ listener.onError(error);
+ this.removeSubscription(subscriptionId);
+ });
+ }
+
+ public appUpgrade() {
+ void this.callEmpty(this.client.appUpgrade);
+ }
+
+ public appUpgradeAbort() {
+ void this.callEmpty(this.client.appUpgradeAbort);
+ }
+
+ public unsubscribeAppUpgradeEventListener(listener: SubscriptionListener<DaemonAppUpgradeEvent>) {
+ const id = listener.subscriptionId;
+ if (id !== undefined) {
+ this.removeSubscription(id);
+ }
+ }
+
public subscribeDaemonEventListener(listener: SubscriptionListener<DaemonEvent>) {
const call = this.isConnected && this.client.eventsListen(new Empty());
if (!call) {
@@ -428,7 +493,9 @@ export class DaemonRpc extends GrpcClient {
public async getVersionInfo(): Promise<IAppVersionInfo> {
const response = await this.callEmpty<grpcTypes.AppVersionInfo>(this.client.getVersionInfo);
- return response.toObject();
+ const versionInfo = convertFromAppVersionInfo(response);
+
+ return versionInfo;
}
public async addSplitTunnelingApplication(path: string): Promise<void> {
diff --git a/desktop/packages/mullvad-vpn/src/main/grpc-type-convertions.ts b/desktop/packages/mullvad-vpn/src/main/grpc-type-convertions.ts
index 96334cc435..64c805c3b9 100644
--- a/desktop/packages/mullvad-vpn/src/main/grpc-type-convertions.ts
+++ b/desktop/packages/mullvad-vpn/src/main/grpc-type-convertions.ts
@@ -14,6 +14,8 @@ import {
Constraint,
CustomLists,
CustomProxy,
+ DaemonAppUpgradeError,
+ DaemonAppUpgradeEvent,
DaemonEvent,
DeviceEvent,
DeviceState,
@@ -25,6 +27,7 @@ import {
FeatureIndicator,
FirewallPolicyError,
FirewallPolicyErrorType,
+ IAppVersionInfo,
IBridgeConstraints,
ICustomList,
IDevice,
@@ -60,6 +63,7 @@ import {
TunnelType,
wrapConstraint,
} from '../shared/daemon-rpc-types';
+import { parseChangelog } from './changelog';
export class ResponseParseError extends Error {
constructor(message: string) {
@@ -713,6 +717,82 @@ function convertFromObfuscationSettings(
};
}
+function convertFromAppUpgradeError(error: grpcTypes.AppUpgradeError.Error): DaemonAppUpgradeError {
+ switch (error) {
+ case grpcTypes.AppUpgradeError.Error.DOWNLOAD_FAILED:
+ return 'DOWNLOAD_FAILED';
+ case grpcTypes.AppUpgradeError.Error.VERIFICATION_FAILED:
+ return 'VERIFICATION_FAILED';
+ default:
+ return 'GENERAL_ERROR';
+ }
+}
+
+export function convertFromAppUpgradeEvent(data: grpcTypes.AppUpgradeEvent): DaemonAppUpgradeEvent {
+ const downloadStartingData = data.getDownloadStarting();
+ if (downloadStartingData !== undefined) {
+ return { type: 'APP_UPGRADE_STATUS_DOWNLOAD_STARTED' };
+ }
+
+ const downloadProgressData = data.getDownloadProgress();
+ if (downloadProgressData !== undefined) {
+ const [server, progress, timeLeftDuration] = [
+ downloadProgressData.getServer(),
+ downloadProgressData.getProgress(),
+ downloadProgressData.getTimeLeft(),
+ ];
+
+ const timeLeft = timeLeftDuration?.getSeconds();
+
+ return { type: 'APP_UPGRADE_STATUS_DOWNLOAD_PROGRESS', server, progress, timeLeft };
+ }
+
+ if (data.hasUpgradeAborted()) {
+ return { type: 'APP_UPGRADE_STATUS_ABORTED' };
+ }
+
+ if (data.hasVerifyingInstaller()) {
+ return { type: 'APP_UPGRADE_STATUS_VERIFYING_INSTALLER' };
+ }
+
+ if (data.hasVerifiedInstaller()) {
+ return { type: 'APP_UPGRADE_STATUS_VERIFIED_INSTALLER' };
+ }
+
+ const errorData = data.getError();
+ if (errorData !== undefined) {
+ const error = errorData.getError();
+
+ return {
+ type: 'APP_UPGRADE_ERROR',
+ error: convertFromAppUpgradeError(error),
+ };
+ }
+
+ // Handle unknown AppUpgradeEvent messages
+ const keys = Object.entries(data.toObject())
+ .filter(([, value]) => value !== undefined)
+ .map(([key]) => key);
+ throw new Error(`Unknown app upgrade event received containing ${keys}`);
+}
+
+export function convertFromAppVersionInfo(data: grpcTypes.AppVersionInfo): IAppVersionInfo {
+ const { suggestedUpgrade, ...appVersionInfo } = data.toObject();
+ const changelog = suggestedUpgrade?.changelog ? parseChangelog(suggestedUpgrade?.changelog) : [];
+
+ if (suggestedUpgrade) {
+ return {
+ ...appVersionInfo,
+ suggestedUpgrade: {
+ ...suggestedUpgrade,
+ changelog,
+ },
+ };
+ }
+
+ return appVersionInfo;
+}
+
export function convertFromDaemonEvent(data: grpcTypes.DaemonEvent): DaemonEvent {
const tunnelState = data.getTunnelState();
if (tunnelState !== undefined) {
@@ -741,7 +821,7 @@ export function convertFromDaemonEvent(data: grpcTypes.DaemonEvent): DaemonEvent
const versionInfo = data.getVersionInfo();
if (versionInfo !== undefined) {
- return { appVersionInfo: versionInfo.toObject() };
+ return { appVersionInfo: convertFromAppVersionInfo(versionInfo) };
}
const newAccessMethod = data.getNewAccessMethod();
diff --git a/desktop/packages/mullvad-vpn/src/main/gui-settings.ts b/desktop/packages/mullvad-vpn/src/main/gui-settings.ts
index ae0cff0cab..85dec09c10 100644
--- a/desktop/packages/mullvad-vpn/src/main/gui-settings.ts
+++ b/desktop/packages/mullvad-vpn/src/main/gui-settings.ts
@@ -14,6 +14,7 @@ const settingsSchema: Record<keyof IGuiSettingsState, string> = {
unpinnedWindow: 'boolean',
browsedForSplitTunnelingApplications: 'Array<string>',
changelogDisplayedForVersion: 'string',
+ updateDismissedForVersion: 'string',
animateMap: 'boolean',
};
@@ -26,6 +27,7 @@ const defaultSettings: IGuiSettingsState = {
unpinnedWindow: process.platform !== 'win32' && process.platform !== 'darwin',
browsedForSplitTunnelingApplications: [],
changelogDisplayedForVersion: '',
+ updateDismissedForVersion: '',
animateMap: true,
};
@@ -116,6 +118,16 @@ export default class GuiSettings {
: this.stateValue.changelogDisplayedForVersion;
}
+ set updateDismissedForVersion(newValue: string | undefined) {
+ this.changeStateAndNotify({ ...this.stateValue, updateDismissedForVersion: newValue ?? '' });
+ }
+
+ get updateDismissedForVersion(): string | undefined {
+ return this.stateValue.updateDismissedForVersion === ''
+ ? undefined
+ : this.stateValue.updateDismissedForVersion;
+ }
+
set animateMap(newValue: boolean) {
this.changeStateAndNotify({ ...this.stateValue, animateMap: newValue });
}
diff --git a/desktop/packages/mullvad-vpn/src/main/index.ts b/desktop/packages/mullvad-vpn/src/main/index.ts
index 57a0bf3dc9..eb5ca1ef70 100644
--- a/desktop/packages/mullvad-vpn/src/main/index.ts
+++ b/desktop/packages/mullvad-vpn/src/main/index.ts
@@ -12,6 +12,7 @@ import {
import { urls } from '../shared/constants';
import {
AccessMethodSetting,
+ DaemonAppUpgradeEvent,
DaemonEvent,
DeviceEvent,
ErrorStateCause,
@@ -29,7 +30,9 @@ import {
SystemNotification,
SystemNotificationCategory,
} from '../shared/notifications/notification';
+import { RoutePath } from '../shared/routes';
import Account, { AccountDelegate, LocaleProvider } from './account';
+import AppUpgrade from './app-upgrade';
import { getOpenAtLogin } from './autostart';
import { readChangelog } from './changelog';
import {
@@ -96,10 +99,12 @@ class ApplicationMain
private version: Version;
private settings: Settings;
private account: Account;
+ private appUpgrade: AppUpgrade;
private userInterface?: UserInterface;
private tunnelState = new TunnelStateHandler(this);
private daemonEventListener?: SubscriptionListener<DaemonEvent>;
+ private daemonAppUpgradeEventListener?: SubscriptionListener<DaemonAppUpgradeEvent>;
private reconnectBackoff = new ReconnectionBackoff();
private beforeFirstDaemonConnection = true;
private isPerformingPostUpgrade = false;
@@ -141,6 +146,7 @@ class ApplicationMain
this.version = new Version(this, this.daemonRpc, UPDATE_NOTIFICATION_DISABLED);
this.settings = new Settings(this, this.daemonRpc, this.version.currentVersion);
this.account = new Account(this, this.daemonRpc);
+ this.appUpgrade = new AppUpgrade(this.daemonRpc);
}
public run() {
@@ -513,6 +519,17 @@ class ApplicationMain
log.info('Connected to the daemon');
+ // verify daemon ownership
+ try {
+ await this.daemonRpc.verifyDaemonOwnership();
+ log.info('Verified daemon ownership');
+ } catch (e) {
+ const error = e as Error;
+ log.error(`Failed to verify daemon ownership: ${error.message}`);
+
+ return;
+ }
+
this.notificationController.closeNotificationsInCategory(
SystemNotificationCategory.tunnelState,
);
@@ -527,6 +544,16 @@ class ApplicationMain
return this.handleBootstrapError(error);
}
+ // subscribe to app upgrade events
+ try {
+ this.daemonAppUpgradeEventListener = this.appUpgrade.subscribeEvents();
+ } catch (e) {
+ const error = e as Error;
+ log.error(`Failed to subscribe to app upgrade events: ${error.message}`);
+
+ return this.handleBootstrapError(error);
+ }
+
if (firstDaemonConnection) {
// check if daemon is performing post upgrade tasks the first time it's connected to
try {
@@ -650,8 +677,12 @@ class ApplicationMain
if (this.daemonEventListener) {
this.daemonRpc.unsubscribeDaemonEventListener(this.daemonEventListener);
}
- // Reset the daemon event listener since it's going to be invalidated on disconnect
+ if (this.daemonAppUpgradeEventListener) {
+ this.daemonRpc.unsubscribeAppUpgradeEventListener(this.daemonAppUpgradeEventListener);
+ }
+ // Reset the daemon and app upgrade event listeners since they're going to be invalidated on disconnect
this.daemonEventListener = undefined;
+ this.daemonAppUpgradeEventListener = undefined;
this.notificationController.closeNotificationsInCategory(
SystemNotificationCategory.tunnelState,
@@ -699,10 +730,14 @@ class ApplicationMain
}
private handleBootstrapError(_error?: Error) {
- // Unsubscribe from daemon events when encountering errors during initial data retrieval.
+ // Unsubscribe from daemon and app upgrade events when encountering errors during initial data retrieval.
if (this.daemonEventListener) {
this.daemonRpc.unsubscribeDaemonEventListener(this.daemonEventListener);
}
+
+ if (this.daemonAppUpgradeEventListener) {
+ this.daemonRpc.unsubscribeAppUpgradeEventListener(this.daemonAppUpgradeEventListener);
+ }
}
private subscribeEvents(): SubscriptionListener<DaemonEvent> {
@@ -897,6 +932,7 @@ class ApplicationMain
this.userInterface!.registerIpcListeners();
this.settings.registerIpcListeners();
this.account.registerIpcListeners();
+ this.appUpgrade.registerIpcListeners();
if (this.splitTunneling) {
this.settings.gui.browsedForSplitTunnelingApplications.forEach((application) => {
@@ -1098,6 +1134,9 @@ class ApplicationMain
return shell.openExternal(url);
}
};
+ public openRoute = (route: RoutePath) => {
+ void IpcMainEventChannel.app.notifyOpenRoute?.(route);
+ };
public showNotificationIcon = (value: boolean, reason?: string) =>
this.userInterface?.showNotificationIcon(value, reason);
diff --git a/desktop/packages/mullvad-vpn/src/main/notification-controller.ts b/desktop/packages/mullvad-vpn/src/main/notification-controller.ts
index b822911306..9f49d3ca07 100644
--- a/desktop/packages/mullvad-vpn/src/main/notification-controller.ts
+++ b/desktop/packages/mullvad-vpn/src/main/notification-controller.ts
@@ -10,13 +10,14 @@ import {
DaemonDisconnectedNotificationProvider,
DisconnectedNotificationProvider,
ErrorNotificationProvider,
- NotificationAction,
ReconnectingNotificationProvider,
SystemNotification,
+ SystemNotificationAction,
SystemNotificationCategory,
SystemNotificationProvider,
SystemNotificationSeverityType,
} from '../shared/notifications';
+import { RoutePath } from '../shared/routes';
import { Scheduler } from '../shared/scheduler';
const THROTTLE_DELAY = 500;
@@ -34,6 +35,7 @@ export interface NotificationSender {
export interface NotificationControllerDelegate {
openApp(): void;
openLink(url: string, withAuth?: boolean): Promise<void>;
+ openRoute(url: RoutePath): void;
/**
* We have experienced issues where the
* notification dot wasn't removed and logging the reason for it to be showing we can narrow the
@@ -237,7 +239,7 @@ export default class NotificationController {
// Action buttons are only available on macOS.
if (process.platform === 'darwin') {
if (systemNotification.action) {
- notification.actions = [{ type: 'button', text: systemNotification.action.text }];
+ notification.actions = [{ type: 'button', text: systemNotification.action.link.text }];
notification.on('action', () => this.performAction(systemNotification.action));
}
notification.on('click', () => this.notificationControllerDelegate.openApp());
@@ -268,9 +270,16 @@ export default class NotificationController {
});
}
- private performAction(action?: NotificationAction) {
- if (action && action.type === 'open-url') {
- void this.notificationControllerDelegate.openLink(action.url, action.withAuth);
+ private performAction(action?: SystemNotificationAction) {
+ if (action) {
+ if (action.type === 'navigate-external') {
+ void this.notificationControllerDelegate.openLink(action.link.to, action.link.withAuth);
+ }
+
+ if (action.type === 'navigate-internal') {
+ void this.notificationControllerDelegate.openRoute(action.link.to);
+ this.notificationControllerDelegate.openApp();
+ }
}
}
diff --git a/desktop/packages/mullvad-vpn/src/main/settings.ts b/desktop/packages/mullvad-vpn/src/main/settings.ts
index 99fb4f2a5a..bfe2bc140d 100644
--- a/desktop/packages/mullvad-vpn/src/main/settings.ts
+++ b/desktop/packages/mullvad-vpn/src/main/settings.ts
@@ -142,6 +142,10 @@ export default class Settings implements Readonly<ISettings> {
IpcMainEventChannel.currentVersion.handleDisplayedChangelog(() => {
this.guiSettings.changelogDisplayedForVersion = this.currentVersion.gui;
});
+
+ IpcMainEventChannel.upgradeVersion.handleDismissedUpgrade((version: string) => {
+ this.guiSettings.updateDismissedForVersion = version;
+ });
}
public get all() {
diff --git a/desktop/packages/mullvad-vpn/src/main/version.ts b/desktop/packages/mullvad-vpn/src/main/version.ts
index 676391e150..f6ea72cc83 100644
--- a/desktop/packages/mullvad-vpn/src/main/version.ts
+++ b/desktop/packages/mullvad-vpn/src/main/version.ts
@@ -81,7 +81,7 @@ export default class Version {
const suggestedIsBeta =
latestVersionInfo.suggestedUpgrade !== undefined &&
- IS_BETA.test(latestVersionInfo.suggestedUpgrade);
+ IS_BETA.test(latestVersionInfo.suggestedUpgrade.version);
const upgradeVersion = {
...latestVersionInfo,
diff --git a/desktop/packages/mullvad-vpn/src/renderer/app.tsx b/desktop/packages/mullvad-vpn/src/renderer/app.tsx
index 5511fff941..7ceab22c2f 100644
--- a/desktop/packages/mullvad-vpn/src/renderer/app.tsx
+++ b/desktop/packages/mullvad-vpn/src/renderer/app.tsx
@@ -38,6 +38,7 @@ import { IGuiSettingsState, SYSTEM_PREFERRED_LOCALE_KEY } from '../shared/gui-se
import { IChangelog, ICurrentAppVersionInfo, IHistoryObject } from '../shared/ipc-types';
import log, { ConsoleOutput } from '../shared/logging';
import { LogLevel } from '../shared/logging-types';
+import { RoutePath } from '../shared/routes';
import { Scheduler } from '../shared/scheduler';
import AppRouter from './components/AppRouter';
import ErrorBoundary from './components/ErrorBoundary';
@@ -50,8 +51,8 @@ import { Theme } from './lib/components';
import History, { TransitionType } from './lib/history';
import { loadTranslations } from './lib/load-translations';
import IpcOutput from './lib/logging';
-import { RoutePath } from './lib/routes';
import accountActions from './redux/account/actions';
+import { appUpgradeActions } from './redux/app-upgrade/actions';
import connectionActions from './redux/connection/actions';
import settingsActions from './redux/settings/actions';
import configureStore from './redux/store';
@@ -95,6 +96,7 @@ export default class AppRenderer {
private reduxStore = configureStore();
private reduxActions = {
account: bindActionCreators(accountActions, this.reduxStore.dispatch),
+ appUpgrade: bindActionCreators(appUpgradeActions, this.reduxStore.dispatch),
connection: bindActionCreators(connectionActions, this.reduxStore.dispatch),
settings: bindActionCreators(settingsActions, this.reduxStore.dispatch),
version: bindActionCreators(versionActions, this.reduxStore.dispatch),
@@ -173,12 +175,49 @@ export default class AppRenderer {
this.setRelayListPair(relayListPair);
});
+ IpcRendererEventChannel.app.listenUpgradeEvent((appUpgradeEvent) => {
+ this.reduxActions.appUpgrade.setAppUpgradeEvent(appUpgradeEvent);
+
+ if (appUpgradeEvent.type === 'APP_UPGRADE_STATUS_DOWNLOAD_PROGRESS') {
+ this.reduxActions.appUpgrade.setLastProgress(appUpgradeEvent.progress);
+ }
+
+ // Ensure progress is updated to 100%, since the daemon doesn't send the last event
+ if (
+ appUpgradeEvent.type === 'APP_UPGRADE_STATUS_VERIFYING_INSTALLER' ||
+ appUpgradeEvent.type === 'APP_UPGRADE_STATUS_VERIFIED_INSTALLER'
+ ) {
+ this.reduxActions.appUpgrade.setLastProgress(100);
+ }
+
+ // Check if the installer should be started automatically
+ this.appUpgradeMaybeStartInstaller();
+ });
+
+ IpcRendererEventChannel.app.listenUpgradeError((appUpgradeError) => {
+ this.reduxActions.appUpgrade.setAppUpgradeError(appUpgradeError);
+ });
+
IpcRendererEventChannel.currentVersion.listen((currentVersion: ICurrentAppVersionInfo) => {
this.setCurrentVersion(currentVersion);
});
IpcRendererEventChannel.upgradeVersion.listen((upgradeVersion: IAppVersionInfo) => {
+ const reduxStore = this.reduxStore.getState();
+
+ const currentSuggestedUpgradeVersion = reduxStore.version.suggestedUpgrade?.version;
+ const newSuggestedUpgradeVersion = upgradeVersion.suggestedUpgrade?.version;
+ if (
+ currentSuggestedUpgradeVersion &&
+ currentSuggestedUpgradeVersion !== newSuggestedUpgradeVersion
+ ) {
+ this.reduxActions.appUpgrade.resetAppUpgrade();
+ }
+
this.setUpgradeVersion(upgradeVersion);
+
+ // Check if the installer should be started automatically
+ this.appUpgradeMaybeStartInstaller();
});
IpcRendererEventChannel.guiSettings.listen((guiSettings: IGuiSettingsState) => {
@@ -203,6 +242,12 @@ export default class AppRenderer {
IpcRendererEventChannel.navigation.listenReset(() => this.history.pop(true));
+ IpcRendererEventChannel.app.listenOpenRoute((route: RoutePath) => {
+ this.history.push({
+ routePath: route,
+ });
+ });
+
// Request the initial state from the main process
const initialState = IpcRendererEventChannel.state.get();
@@ -390,6 +435,38 @@ export default class AppRenderer {
public daemonPrepareRestart = (shutdown: boolean): void => {
IpcRendererEventChannel.daemon.prepareRestart(shutdown);
};
+ public appUpgrade = () => {
+ const reduxState = this.reduxStore.getState();
+ const appUpgradeError = reduxState.appUpgrade.error;
+
+ if (appUpgradeError) {
+ this.reduxActions.appUpgrade.resetAppUpgradeError();
+ }
+
+ this.reduxActions.appUpgrade.setAppUpgradeEvent({
+ type: 'APP_UPGRADE_STATUS_DOWNLOAD_INITIATED',
+ });
+
+ IpcRendererEventChannel.app.upgrade();
+ };
+ public appUpgradeAbort = () => IpcRendererEventChannel.app.upgradeAbort();
+ public appUpgradeInstallerStart = () => {
+ const reduxState = this.reduxStore.getState();
+ const verifiedInstallerPath = reduxState.version.suggestedUpgrade?.verifiedInstallerPath;
+ const hasVerifiedInstallerPath =
+ typeof verifiedInstallerPath === 'string' && verifiedInstallerPath.length > 0;
+
+ // Ensure we have a the path to the verified installer and that we are not already trying
+ // to start the installer.
+ if (hasVerifiedInstallerPath) {
+ this.reduxActions.appUpgrade.setAppUpgradeEvent({
+ type: 'APP_UPGRADE_STATUS_MANUAL_STARTING_INSTALLER',
+ });
+ this.reduxActions.appUpgrade.resetAppUpgradeError();
+
+ IpcRendererEventChannel.app.upgradeInstallerStart(verifiedInstallerPath);
+ }
+ };
public login = async (accountNumber: AccountNumber) => {
const actions = this.reduxActions;
@@ -586,6 +663,12 @@ export default class AppRenderer {
IpcRendererEventChannel.currentVersion.displayedChangelog();
};
+ public setDismissedUpgrade = (): void => {
+ IpcRendererEventChannel.upgradeVersion.dismissedUpgrade(
+ this.reduxStore.getState().version.suggestedUpgrade?.version ?? '',
+ );
+ };
+
public setNavigationHistory(history: IHistoryObject) {
IpcRendererEventChannel.navigation.setHistory(history);
@@ -594,6 +677,37 @@ export default class AppRenderer {
}
}
+ // If the installer has just been downloaded and verified we want to automatically
+ // start the installer if the window is focused.
+ private appUpgradeMaybeStartInstaller() {
+ const reduxState = this.reduxStore.getState();
+
+ const appUpgradeEvent = reduxState.appUpgrade.event;
+ const verifiedInstallerPath = reduxState.version.suggestedUpgrade?.verifiedInstallerPath;
+ const windowFocused = reduxState.userInterface.windowFocused;
+
+ const hasVerifiedInstallerPath =
+ typeof verifiedInstallerPath === 'string' && verifiedInstallerPath.length > 0;
+
+ if (
+ hasVerifiedInstallerPath &&
+ appUpgradeEvent?.type === 'APP_UPGRADE_STATUS_VERIFIED_INSTALLER'
+ ) {
+ // Only trigger the installer if the window is focused
+ if (windowFocused) {
+ this.reduxActions.appUpgrade.setAppUpgradeEvent({
+ type: 'APP_UPGRADE_STATUS_AUTOMATIC_STARTING_INSTALLER',
+ });
+ IpcRendererEventChannel.app.upgradeInstallerStart(verifiedInstallerPath);
+ } else {
+ // Otherwise, flag this as requiring manual start
+ this.reduxActions.appUpgrade.setAppUpgradeEvent({
+ type: 'APP_UPGRADE_STATUS_MANUAL_START_INSTALLER',
+ });
+ }
+ }
+ }
+
private isLoggedIn(): boolean {
return this.deviceState?.type === 'logged in';
}
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/ApiAccessMethods.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/ApiAccessMethods.tsx
index 0d5842cdeb..1018f11259 100644
--- a/desktop/packages/mullvad-vpn/src/renderer/components/ApiAccessMethods.tsx
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/ApiAccessMethods.tsx
@@ -4,13 +4,13 @@ import styled from 'styled-components';
import { AccessMethodSetting } from '../../shared/daemon-rpc-types';
import { messages } from '../../shared/gettext';
+import { RoutePath } from '../../shared/routes';
import { useAppContext } from '../context';
import { useApiAccessMethodTest } from '../lib/api-access-methods';
import { Button, Container, Flex, Spinner } from '../lib/components';
import { colors, spacings } from '../lib/foundations';
import { useHistory } from '../lib/history';
import { generateRoutePath } from '../lib/routeHelpers';
-import { RoutePath } from '../lib/routes';
import { useBoolean } from '../lib/utility-hooks';
import { useSelector } from '../redux/store';
import { AppNavigationHeader } from './';
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/AppRouter.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/AppRouter.tsx
index fc027738d1..aba4887f2e 100644
--- a/desktop/packages/mullvad-vpn/src/renderer/components/AppRouter.tsx
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/AppRouter.tsx
@@ -1,9 +1,9 @@
import { useCallback, useRef } from 'react';
import { Route, Switch } from 'react-router';
+import { RoutePath } from '../../shared/routes';
import LoginPage from '../components/Login';
import SelectLocation from '../components/select-location/SelectLocationContainer';
-import { RoutePath } from '../lib/routes';
import { useViewTransitions } from '../lib/transition-hooks';
import Account from './Account';
import ApiAccessMethods from './ApiAccessMethods';
@@ -27,7 +27,6 @@ import MultihopSettings from './MultihopSettings';
import OpenVpnSettings from './OpenVpnSettings';
import ProblemReport from './ProblemReport';
import SelectLanguage from './SelectLanguage';
-import Settings from './Settings';
import SettingsImport from './SettingsImport';
import SettingsTextImport from './SettingsTextImport';
import Shadowsocks from './Shadowsocks';
@@ -36,7 +35,7 @@ import Support from './Support';
import TooManyDevices from './TooManyDevices';
import UdpOverTcp from './UdpOverTcp';
import UserInterfaceSettings from './UserInterfaceSettings';
-import { AppInfoView, ChangelogView } from './views';
+import { AppInfoView, AppUpgradeView, ChangelogView, SettingsView } from './views';
import VpnSettings from './VpnSettings';
import WireguardSettings from './WireguardSettings';
@@ -62,7 +61,7 @@ export default function AppRouter() {
<Route exact path={RoutePath.timeAdded} component={TimeAdded} />
<Route exact path={RoutePath.setupFinished} component={SetupFinished} />
<Route exact path={RoutePath.account} component={Account} />
- <Route exact path={RoutePath.settings} component={Settings} />
+ <Route exact path={RoutePath.settings} component={SettingsView} />
<Route exact path={RoutePath.selectLanguage} component={SelectLanguage} />
<Route exact path={RoutePath.userInterfaceSettings} component={UserInterfaceSettings} />
<Route exact path={RoutePath.multihopSettings} component={MultihopSettings} />
@@ -85,6 +84,7 @@ export default function AppRouter() {
<Route exact path={RoutePath.filter} component={Filter} />
<Route exact path={RoutePath.appInfo} component={AppInfoView} />
<Route exact path={RoutePath.changelog} component={ChangelogView} />
+ <Route exact path={RoutePath.appUpgrade} component={AppUpgradeView} />
</Switch>
</Focus>
);
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/ExpiredAccountAddTime.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/ExpiredAccountAddTime.tsx
index e4267d76dc..d1fd5c7153 100644
--- a/desktop/packages/mullvad-vpn/src/renderer/components/ExpiredAccountAddTime.tsx
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/ExpiredAccountAddTime.tsx
@@ -7,6 +7,7 @@ import { formatDate } from '../../shared/account-expiry';
import { urls } from '../../shared/constants';
import { formatRelativeDate } from '../../shared/date-helper';
import { messages } from '../../shared/gettext';
+import { RoutePath } from '../../shared/routes';
import { useAppContext } from '../context';
import useActions from '../lib/actionsHook';
import { Button, Flex } from '../lib/components';
@@ -15,7 +16,6 @@ import { colors } from '../lib/foundations';
import { TransitionType, useHistory } from '../lib/history';
import { IconBadge } from '../lib/icon-badge';
import { generateRoutePath } from '../lib/routeHelpers';
-import { RoutePath } from '../lib/routes';
import account from '../redux/account/actions';
import { useSelector } from '../redux/store';
import { AppMainHeader } from './app-main-header';
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/ExpiredAccountErrorView.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/ExpiredAccountErrorView.tsx
index 7b96834c1c..28e00e4151 100644
--- a/desktop/packages/mullvad-vpn/src/renderer/components/ExpiredAccountErrorView.tsx
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/ExpiredAccountErrorView.tsx
@@ -4,6 +4,7 @@ import { sprintf } from 'sprintf-js';
import { urls } from '../../shared/constants';
import { messages } from '../../shared/gettext';
import log from '../../shared/logging';
+import { RoutePath } from '../../shared/routes';
import { capitalizeEveryWord } from '../../shared/string-helpers';
import { useAppContext } from '../context';
import { Button, Flex } from '../lib/components';
@@ -11,7 +12,6 @@ import { FlexColumn } from '../lib/components/flex-column';
import { useHistory } from '../lib/history';
import { useExclusiveTask } from '../lib/hooks/use-exclusive-task';
import { IconBadge } from '../lib/icon-badge';
-import { RoutePath } from '../lib/routes';
import { useSelector } from '../redux/store';
import { AppMainHeader } from './app-main-header';
import * as Cell from './cell';
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/ExternalLink.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/ExternalLink.tsx
index 4f160ad64d..16238b7c3c 100644
--- a/desktop/packages/mullvad-vpn/src/renderer/components/ExternalLink.tsx
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/ExternalLink.tsx
@@ -6,19 +6,24 @@ import { Link, LinkProps } from '../lib/components';
export type ExternalLinkProps = Omit<LinkProps, 'href' | 'as'> & {
to: Url;
+ withAuth?: boolean;
};
-function ExternalLink({ to, onClick, ...props }: ExternalLinkProps) {
- const { openUrl } = useAppContext();
+function ExternalLink({ to, onClick, withAuth, ...props }: ExternalLinkProps) {
+ const { openUrl, openUrlWithAuth } = useAppContext();
const navigate = useCallback(
(e: React.MouseEvent<HTMLAnchorElement>) => {
e.preventDefault();
if (onClick) {
onClick(e);
}
+
+ if (withAuth) {
+ return openUrlWithAuth(to);
+ }
return openUrl(to);
},
- [onClick, openUrl, to],
+ [onClick, openUrl, openUrlWithAuth, to, withAuth],
);
return <Link href="" onClick={navigate} {...props} />;
}
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/InternalLink.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/InternalLink.tsx
index ba6b32280e..609238a237 100644
--- a/desktop/packages/mullvad-vpn/src/renderer/components/InternalLink.tsx
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/InternalLink.tsx
@@ -1,8 +1,8 @@
import { useCallback } from 'react';
+import { RoutePath } from '../../shared/routes';
import { Link, LinkProps } from '../lib/components';
import { useHistory } from '../lib/history';
-import { RoutePath } from '../lib/routes';
export type InternalLinkProps = Omit<LinkProps, 'href' | 'as'> & {
to: RoutePath;
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/KeyboardNavigation.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/KeyboardNavigation.tsx
index cbde4297bd..2dff95ec5d 100644
--- a/desktop/packages/mullvad-vpn/src/renderer/components/KeyboardNavigation.tsx
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/KeyboardNavigation.tsx
@@ -1,9 +1,9 @@
import React, { useCallback, useContext, useEffect, useMemo, useState } from 'react';
import { useLocation } from 'react-router';
+import { RoutePath } from '../../shared/routes';
import { useHistory } from '../lib/history';
import { disableDismissForRoutes } from '../lib/routeHelpers';
-import { RoutePath } from '../lib/routes';
import { useEffectEvent } from '../lib/utility-hooks';
interface IKeyboardNavigationProps {
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/Launch.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/Launch.tsx
index 43e1434314..2f68f9395b 100644
--- a/desktop/packages/mullvad-vpn/src/renderer/components/Launch.tsx
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/Launch.tsx
@@ -2,11 +2,11 @@ import { useCallback } from 'react';
import styled from 'styled-components';
import { messages } from '../../shared/gettext';
+import { RoutePath } from '../../shared/routes';
import { useAppContext } from '../context';
import { Button } from '../lib/components';
import { colors } from '../lib/foundations';
import { TransitionType, useHistory } from '../lib/history';
-import { RoutePath } from '../lib/routes';
import { useBoolean } from '../lib/utility-hooks';
import { useSelector } from '../redux/store';
import { measurements, tinyText } from './common-styles';
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/NavigationListItem.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/NavigationListItem.tsx
new file mode 100644
index 0000000000..f17b9772d1
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/NavigationListItem.tsx
@@ -0,0 +1,24 @@
+import React from 'react';
+
+import { RoutePath } from '../../shared/routes';
+import { ListItem, ListItemProps } from '../lib/components/list-item';
+import { useHistory } from '../lib/history';
+
+export type NavigationListItemProps = ListItemProps & {
+ to: RoutePath;
+};
+
+export function NavigationListItem({ to, children, ...props }: NavigationListItemProps) {
+ const history = useHistory();
+ const navigate = React.useCallback(() => history.push(to), [history, to]);
+
+ return (
+ <ListItem {...props}>
+ <ListItem.Item>
+ <ListItem.Trigger onClick={navigate}>
+ <ListItem.Content>{children}</ListItem.Content>
+ </ListItem.Trigger>
+ </ListItem.Item>
+ </ListItem>
+ );
+}
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/NotificationArea.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/NotificationArea.tsx
index 4004d0cea9..64c82197eb 100644
--- a/desktop/packages/mullvad-vpn/src/renderer/components/NotificationArea.tsx
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/NotificationArea.tsx
@@ -12,22 +12,32 @@ import {
InconsistentVersionNotificationProvider,
ReconnectingNotificationProvider,
UnsupportedVersionNotificationProvider,
- UpdateAvailableNotificationProvider,
} from '../../shared/notifications';
+import { RoutePath } from '../../shared/routes';
import { useAppContext } from '../context';
+import {
+ useAppUpgradeDownloadProgressValue,
+ useAppUpgradeEventType,
+ useHasAppUpgradeError,
+} from '../hooks';
import useActions from '../lib/actionsHook';
import { Button } from '../lib/components';
import { TransitionType, useHistory } from '../lib/history';
import {
+ AppUpgradeErrorNotificationProvider,
+ AppUpgradeProgressNotificationProvider,
+ AppUpgradeReadyNotificationProvider,
NewDeviceNotificationProvider,
NewVersionNotificationProvider,
NoOpenVpnServerAvailableNotificationProvider,
OpenVpnSupportEndingNotificationProvider,
UnsupportedWireGuardPortNotificationProvider,
} from '../lib/notifications';
+import { AppUpgradeAvailableNotificationProvider } from '../lib/notifications/app-upgrade-available';
import { useTunnelProtocol } from '../lib/relay-settings-hooks';
-import { RoutePath } from '../lib/routes';
import accountActions from '../redux/account/actions';
+import { convertEventTypeToStep } from '../redux/app-upgrade/helpers';
+import { useAppUpgradeError, useVersionSuggestedUpgrade } from '../redux/hooks';
import { IReduxState, useSelector } from '../redux/store';
import { ModalAlert, ModalAlertType, ModalMessage, ModalMessageList } from './Modal';
import {
@@ -69,7 +79,8 @@ export default function NotificationArea(props: IProps) {
const { hideNewDeviceBanner } = useActions(accountActions);
- const { setDisplayedChangelog } = useAppContext();
+ const { setDisplayedChangelog, setDismissedUpgrade, appUpgrade, appUpgradeInstallerStart } =
+ useAppContext();
const currentVersion = useSelector((state) => state.version.current);
const displayedForVersion = useSelector(
@@ -89,6 +100,25 @@ export default function NotificationArea(props: IProps) {
await setSplitTunnelingState(false);
}, [setSplitTunnelingState]);
+ const updateDismissedForVersion = useSelector(
+ (state) => state.settings.guiSettings.updateDismissedForVersion,
+ );
+ const hasAppUpgradeError = useHasAppUpgradeError();
+ const { error } = useAppUpgradeError();
+
+ const restartAppUpgrade = useCallback(() => {
+ appUpgrade();
+ }, [appUpgrade]);
+ const restartAppUpgradeInstaller = useCallback(() => {
+ appUpgradeInstallerStart();
+ }, [appUpgradeInstallerStart]);
+
+ const { suggestedUpgrade } = useVersionSuggestedUpgrade();
+
+ const appUpgradeDownloadProgressValue = useAppUpgradeDownloadProgressValue();
+ const appUpgradeEventType = useAppUpgradeEventType();
+ const appUpgradeStep = convertEventTypeToStep(appUpgradeEventType); // TODO: Remove and read value from redux
+
const notificationProviders: InAppNotificationProvider[] = [
new ConnectingNotificationProvider({ tunnelState }),
new ReconnectingNotificationProvider(tunnelState),
@@ -97,6 +127,21 @@ export default function NotificationArea(props: IProps) {
blockWhenDisconnectedSetting,
hasExcludedApps,
}),
+ new AppUpgradeErrorNotificationProvider({
+ hasAppUpgradeError,
+ appUpgradeError: error,
+ restartAppUpgrade,
+ restartAppUpgradeInstaller,
+ }),
+ new AppUpgradeReadyNotificationProvider({
+ appUpgradeEventType,
+ suggestedUpgradeVersion: suggestedUpgrade?.version,
+ }),
+ new AppUpgradeProgressNotificationProvider({
+ appUpgradeStep,
+ appUpgradeEventType,
+ appUpgradeDownloadProgressValue,
+ }),
new NoOpenVpnServerAvailableNotificationProvider({
connection,
tunnelProtocol,
@@ -136,7 +181,13 @@ export default function NotificationArea(props: IProps) {
changelog,
close,
}),
- new UpdateAvailableNotificationProvider(version),
+ new AppUpgradeAvailableNotificationProvider({
+ platform: window.env.platform,
+ suggestedUpgradeVersion: suggestedUpgrade?.version,
+ suggestedIsBeta: version.suggestedIsBeta,
+ updateDismissedForVersion,
+ close: setDismissedUpgrade,
+ }),
new OpenVpnSupportEndingNotificationProvider({ tunnelProtocol }),
);
@@ -201,11 +252,11 @@ function NotificationActionWrapper({
const handleClick = useCallback(() => {
if (action) {
switch (action.type) {
- case 'open-url':
- if (action.withAuth) {
- return openUrlWithAuth(action.url);
+ case 'navigate-external':
+ if (action.link.withAuth) {
+ return openUrlWithAuth(action.link.to);
} else {
- return openUrl(action.url);
+ return openUrl(action.link.to);
}
case 'troubleshoot-dialog':
setIsModalOpen(true);
@@ -227,7 +278,7 @@ function NotificationActionWrapper({
let actionComponent: React.ReactElement | undefined;
if (action) {
switch (action.type) {
- case 'open-url':
+ case 'navigate-external':
actionComponent = <NotificationOpenLinkAction onClick={handleClick} />;
break;
case 'troubleshoot-dialog':
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/NotificationSubtitle.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/NotificationSubtitle.tsx
index 71045a2bfd..736c3d6ad3 100644
--- a/desktop/packages/mullvad-vpn/src/renderer/components/NotificationSubtitle.tsx
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/NotificationSubtitle.tsx
@@ -1,8 +1,10 @@
import React from 'react';
+import styled from 'styled-components';
import { InAppNotificationSubtitle } from '../../shared/notifications';
-import { LabelTiny } from '../lib/components';
+import { LabelTiny, Link } from '../lib/components';
import { formatHtml } from '../lib/html-formatter';
+import { buttonReset } from '../lib/styles';
import { ExternalLink } from './ExternalLink';
import { InternalLink } from './InternalLink';
@@ -10,6 +12,13 @@ export type NotificationSubtitleProps = {
subtitle?: string | InAppNotificationSubtitle[];
};
+const StyledLink = styled(Link)(() => {
+ const { color: _, ...reset } = buttonReset;
+ return {
+ ...reset,
+ };
+});
+
const formatSubtitle = (subtitle: InAppNotificationSubtitle) => {
const content = formatHtml(subtitle.content);
if (subtitle.action) {
@@ -27,6 +36,13 @@ const formatSubtitle = (subtitle: InAppNotificationSubtitle) => {
<ExternalLink.Icon icon="external" />
</ExternalLink>
);
+ case 'run-function':
+ return (
+ <StyledLink color="white" forwardedAs="button" {...subtitle.action.button}>
+ <StyledLink.Text>{content}</StyledLink.Text>
+ </StyledLink>
+ );
+
default:
break;
}
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/ProblemReport.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/ProblemReport.tsx
index 5c808fe8dc..bf7837ff0b 100644
--- a/desktop/packages/mullvad-vpn/src/renderer/components/ProblemReport.tsx
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/ProblemReport.tsx
@@ -15,6 +15,8 @@ import {
import { messages } from '../../shared/gettext';
import { getDownloadUrl } from '../../shared/version';
import { useAppContext } from '../context';
+import { usePushAppUpgrade } from '../history/hooks';
+import { useIsPlatformLinux } from '../hooks';
import useActions from '../lib/actionsHook';
import { Button, Flex, Spinner } from '../lib/components';
import { FlexColumn } from '../lib/components/flex-column';
@@ -336,8 +338,18 @@ function OutdatedVersionWarningDialog() {
const isOffline = useSelector((state) => state.connection.isBlocked);
const suggestedIsBeta = useSelector((state) => state.version.suggestedIsBeta ?? false);
const outdatedVersion = useSelector((state) => !!state.version.suggestedUpgrade);
+ const pushAppUpgrade = usePushAppUpgrade();
- const [showOutdatedVersionWarning, setShowOutdatedVersionWarning] = useState(outdatedVersion);
+ const { location } = useHistory();
+ const { state } = location;
+ const hasSuppressOutdatedVersionWarning = state?.options?.some(
+ (option) => option.type === 'suppress-outdated-version-warning',
+ );
+ const showOutdatedVersionWarningInitial = outdatedVersion && !hasSuppressOutdatedVersionWarning;
+
+ const [showOutdatedVersionWarning, setShowOutdatedVersionWarning] = useState(
+ showOutdatedVersionWarningInitial,
+ );
const acknowledgeOutdatedVersion = useCallback(() => {
setShowOutdatedVersionWarning(false);
@@ -347,6 +359,16 @@ function OutdatedVersionWarningDialog() {
await openUrl(getDownloadUrl(suggestedIsBeta));
}, [openUrl, suggestedIsBeta]);
+ const isLinux = useIsPlatformLinux();
+ const upgradeAction = useCallback(async () => {
+ if (isLinux) {
+ await openDownloadLink();
+ } else {
+ acknowledgeOutdatedVersion();
+ pushAppUpgrade();
+ }
+ }, [isLinux, openDownloadLink, pushAppUpgrade, acknowledgeOutdatedVersion]);
+
const outdatedVersionCancel = useCallback(() => {
acknowledgeOutdatedVersion();
pop();
@@ -357,6 +379,8 @@ function OutdatedVersionWarningDialog() {
'You are using an old version of the app. Please upgrade and see if the problem still exists before sending a report.',
);
+ const disabled = isLinux && isOffline;
+
return (
<ModalAlert
isOpen={showOutdatedVersionWarning}
@@ -366,13 +390,13 @@ function OutdatedVersionWarningDialog() {
<Button
key="upgrade"
variant="success"
- disabled={isOffline}
- onClick={openDownloadLink}
+ disabled={disabled}
+ onClick={upgradeAction}
aria-description={messages.pgettext('accessibility', 'Opens externally')}>
<Button.Text>
{
- // TRANSLATORS: Button label for upgrading the app to the latest version.
- messages.pgettext('support-view', 'Upgrade app')
+ // TRANSLATORS: Button label for updating the app to the latest version.
+ messages.pgettext('support-view', 'Update app')
}
</Button.Text>
<Button.Icon icon="external" />
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/Settings.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/Settings.tsx
deleted file mode 100644
index 53dded0411..0000000000
--- a/desktop/packages/mullvad-vpn/src/renderer/components/Settings.tsx
+++ /dev/null
@@ -1,248 +0,0 @@
-import { useCallback } from 'react';
-
-import { strings } from '../../shared/constants';
-import { messages } from '../../shared/gettext';
-import { useAppContext } from '../context';
-import { Button, TitleBig } from '../lib/components';
-import { useHistory } from '../lib/history';
-import { RoutePath } from '../lib/routes';
-import { useSelector } from '../redux/store';
-import { AppNavigationHeader } from './';
-import * as Cell from './cell';
-import { BackAction } from './KeyboardNavigation';
-import {
- ButtonStack,
- Footer,
- Layout,
- SettingsContainer,
- SettingsContent,
- SettingsGroup,
- SettingsNavigationScrollbars,
- SettingsStack,
-} from './Layout';
-import { NavigationContainer } from './NavigationContainer';
-import SettingsHeader from './SettingsHeader';
-
-export default function Support() {
- const history = useHistory();
-
- const loginState = useSelector((state) => state.account.status);
- const connectedToDaemon = useSelector((state) => state.userInterface.connectedToDaemon);
- const isMacOs13OrNewer = useSelector((state) => state.userInterface.isMacOs13OrNewer);
-
- const showSubSettings = loginState.type === 'ok' && connectedToDaemon;
- const showSplitTunneling = window.env.platform !== 'darwin' || isMacOs13OrNewer;
-
- return (
- <BackAction action={history.pop}>
- <Layout>
- <SettingsContainer>
- <NavigationContainer>
- <AppNavigationHeader
- title={
- // TRANSLATORS: Title label in navigation bar
- messages.pgettext('navigation-bar', 'Settings')
- }
- />
-
- <SettingsNavigationScrollbars fillContainer>
- <SettingsContent>
- <SettingsHeader>
- <TitleBig>{messages.pgettext('navigation-bar', 'Settings')}</TitleBig>
- </SettingsHeader>
-
- <SettingsStack>
- {showSubSettings ? (
- <>
- <SettingsGroup>
- <DaitaButton />
- <MultihopButton />
- <VpnSettingsButton />
- <UserInterfaceSettingsButton />
- </SettingsGroup>
-
- {showSplitTunneling && (
- <SettingsGroup>
- <SplitTunnelingButton />
- </SettingsGroup>
- )}
- </>
- ) : (
- <SettingsGroup>
- <UserInterfaceSettingsButton />
- </SettingsGroup>
- )}
-
- <SettingsGroup>
- <ApiAccessMethodsButton />
- </SettingsGroup>
-
- <SettingsGroup>
- <SupportButton />
- <AppInfoButton />
- </SettingsGroup>
-
- {window.env.development && (
- <SettingsGroup>
- <DebugButton />
- </SettingsGroup>
- )}
- </SettingsStack>
- <Footer>
- <ButtonStack>
- <QuitButton />
- </ButtonStack>
- </Footer>
- </SettingsContent>
- </SettingsNavigationScrollbars>
- </NavigationContainer>
- </SettingsContainer>
- </Layout>
- </BackAction>
- );
-}
-
-function UserInterfaceSettingsButton() {
- const history = useHistory();
- const navigate = useCallback(() => history.push(RoutePath.userInterfaceSettings), [history]);
-
- return (
- <Cell.CellNavigationButton onClick={navigate}>
- <Cell.Label>
- {
- // TRANSLATORS: Navigation button to the 'User interface settings' view
- messages.pgettext('settings-view', 'User interface settings')
- }
- </Cell.Label>
- </Cell.CellNavigationButton>
- );
-}
-
-function MultihopButton() {
- const history = useHistory();
- const navigate = useCallback(() => history.push(RoutePath.multihopSettings), [history]);
- const relaySettings = useSelector((state) => state.settings.relaySettings);
- const multihop = 'normal' in relaySettings ? relaySettings.normal.wireguard.useMultihop : false;
- const unavailable =
- 'normal' in relaySettings ? relaySettings.normal.tunnelProtocol === 'openvpn' : true;
-
- return (
- <Cell.CellNavigationButton onClick={navigate}>
- <Cell.Label>{messages.pgettext('settings-view', 'Multihop')}</Cell.Label>
- <Cell.SubText>
- {multihop && !unavailable ? messages.gettext('On') : messages.gettext('Off')}
- </Cell.SubText>
- </Cell.CellNavigationButton>
- );
-}
-
-function DaitaButton() {
- const history = useHistory();
- const navigate = useCallback(() => history.push(RoutePath.daitaSettings), [history]);
- const daita = useSelector((state) => state.settings.wireguard.daita?.enabled ?? false);
- const relaySettings = useSelector((state) => state.settings.relaySettings);
- const unavailable =
- 'normal' in relaySettings ? relaySettings.normal.tunnelProtocol === 'openvpn' : true;
-
- return (
- <Cell.CellNavigationButton onClick={navigate}>
- <Cell.Label>{strings.daita}</Cell.Label>
- <Cell.SubText>
- {daita && !unavailable ? messages.gettext('On') : messages.gettext('Off')}
- </Cell.SubText>
- </Cell.CellNavigationButton>
- );
-}
-
-function VpnSettingsButton() {
- const history = useHistory();
- const navigate = useCallback(() => history.push(RoutePath.vpnSettings), [history]);
-
- return (
- <Cell.CellNavigationButton onClick={navigate}>
- <Cell.Label>
- {
- // TRANSLATORS: Navigation button to the 'VPN settings' view
- messages.pgettext('settings-view', 'VPN settings')
- }
- </Cell.Label>
- </Cell.CellNavigationButton>
- );
-}
-
-function SplitTunnelingButton() {
- const history = useHistory();
- const navigate = useCallback(() => history.push(RoutePath.splitTunneling), [history]);
-
- return (
- <Cell.CellNavigationButton onClick={navigate}>
- <Cell.Label>{strings.splitTunneling}</Cell.Label>
- </Cell.CellNavigationButton>
- );
-}
-
-function ApiAccessMethodsButton() {
- const history = useHistory();
- const navigate = useCallback(() => history.push(RoutePath.apiAccessMethods), [history]);
-
- return (
- <Cell.CellNavigationButton onClick={navigate}>
- <Cell.Label>
- {
- // TRANSLATORS: Navigation button to the 'API access methods' view
- messages.pgettext('settings-view', 'API access')
- }
- </Cell.Label>
- </Cell.CellNavigationButton>
- );
-}
-
-function AppInfoButton() {
- const history = useHistory();
- const navigate = useCallback(() => history.push(RoutePath.appInfo), [history]);
- const appVersion = useSelector((state) => state.version.current);
-
- return (
- <Cell.CellNavigationButton onClick={navigate}>
- <Cell.Label>{messages.pgettext('settings-view', 'App info')}</Cell.Label>
- <Cell.SubText>{appVersion}</Cell.SubText>
- </Cell.CellNavigationButton>
- );
-}
-
-function SupportButton() {
- const history = useHistory();
- const navigate = useCallback(() => history.push(RoutePath.support), [history]);
-
- return (
- <Cell.CellNavigationButton onClick={navigate}>
- <Cell.Label>{messages.pgettext('settings-view', 'Support')}</Cell.Label>
- </Cell.CellNavigationButton>
- );
-}
-
-function DebugButton() {
- const history = useHistory();
- const navigate = useCallback(() => history.push(RoutePath.debug), [history]);
-
- return (
- <Cell.CellNavigationButton onClick={navigate}>
- <Cell.Label>Developer tools</Cell.Label>
- </Cell.CellNavigationButton>
- );
-}
-
-function QuitButton() {
- const { quit } = useAppContext();
- const tunnelState = useSelector((state) => state.connection.status);
-
- return (
- <Button variant="destructive" onClick={quit}>
- <Button.Text>
- {tunnelState.state === 'disconnected'
- ? messages.gettext('Quit')
- : messages.gettext('Disconnect & quit')}
- </Button.Text>
- </Button>
- );
-}
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/SettingsImport.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/SettingsImport.tsx
index 0ca1dbddd3..541e5cab44 100644
--- a/desktop/packages/mullvad-vpn/src/renderer/components/SettingsImport.tsx
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/SettingsImport.tsx
@@ -3,13 +3,13 @@ import { sprintf } from 'sprintf-js';
import styled from 'styled-components';
import { messages } from '../../shared/gettext';
+import { RoutePath } from '../../shared/routes';
import { useScheduler } from '../../shared/scheduler';
import { useAppContext } from '../context';
import useActions from '../lib/actionsHook';
import { Button, Flex, Icon, IconProps, LabelTiny } from '../lib/components';
import { colors, spacings } from '../lib/foundations';
import { TransitionType, useHistory } from '../lib/history';
-import { RoutePath } from '../lib/routes';
import { useBoolean, useEffectEvent } from '../lib/utility-hooks';
import settingsImportActions from '../redux/settings-import/actions';
import { useSelector } from '../redux/store';
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/Support.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/Support.tsx
index 7eb86d974b..7ee58275fb 100644
--- a/desktop/packages/mullvad-vpn/src/renderer/components/Support.tsx
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/Support.tsx
@@ -3,19 +3,12 @@ import styled from 'styled-components';
import { urls } from '../../shared/constants';
import { messages } from '../../shared/gettext';
+import { RoutePath } from '../../shared/routes';
import { useAppContext } from '../context';
import { useHistory } from '../lib/history';
-import { RoutePath } from '../lib/routes';
import { useSelector } from '../redux/store';
import { AppNavigationHeader } from './';
-import {
- AriaDescribed,
- AriaDescription,
- AriaDescriptionGroup,
- AriaInput,
- AriaInputGroup,
- AriaLabel,
-} from './AriaGroup';
+import { AriaDescribed, AriaDescription, AriaDescriptionGroup } from './AriaGroup';
import * as Cell from './cell';
import { BackAction } from './KeyboardNavigation';
import { Layout, SettingsContainer } from './Layout';
@@ -55,10 +48,6 @@ export default function Support() {
<ProblemReportButton />
<FaqButton />
</Cell.Group>
-
- <Cell.Group>
- <BetaProgramSetting />
- </Cell.Group>
</StyledContent>
</NavigationScrollbars>
</NavigationContainer>
@@ -109,37 +98,3 @@ function FaqButton() {
</AriaDescriptionGroup>
);
}
-
-function BetaProgramSetting() {
- const isBeta = useSelector((state) => state.version.isBeta);
- const showBetaReleases = useSelector((state) => state.settings.showBetaReleases);
- const { setShowBetaReleases } = useAppContext();
-
- return (
- <AriaInputGroup>
- <Cell.Container disabled={isBeta}>
- <AriaLabel>
- <Cell.InputLabel>{messages.pgettext('support-view', 'Beta program')}</Cell.InputLabel>
- </AriaLabel>
- <AriaInput>
- <Cell.Switch isOn={showBetaReleases} onChange={setShowBetaReleases} />
- </AriaInput>
- </Cell.Container>
- <Cell.CellFooter>
- <AriaDescription>
- <Cell.CellFooterText>
- {isBeta
- ? messages.pgettext(
- 'support-view',
- 'This option is unavailable while using a beta version.',
- )
- : messages.pgettext(
- 'support-view',
- 'Enable to get notified when new beta versions of the app are released.',
- )}
- </Cell.CellFooterText>
- </AriaDescription>
- </Cell.CellFooter>
- </AriaInputGroup>
- );
-}
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/TooManyDevices.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/TooManyDevices.tsx
index 5ffdfc0f55..baa57853f4 100644
--- a/desktop/packages/mullvad-vpn/src/renderer/components/TooManyDevices.tsx
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/TooManyDevices.tsx
@@ -5,6 +5,7 @@ import styled from 'styled-components';
import { IDevice } from '../../shared/daemon-rpc-types';
import { messages } from '../../shared/gettext';
import log from '../../shared/logging';
+import { RoutePath } from '../../shared/routes';
import { capitalizeEveryWord } from '../../shared/string-helpers';
import { useAppContext } from '../context';
import { Button, Flex, IconButton, Spinner } from '../lib/components';
@@ -13,7 +14,6 @@ import { colors } from '../lib/foundations';
import { TransitionType, useHistory } from '../lib/history';
import { formatHtml } from '../lib/html-formatter';
import { IconBadge, IconBadgeProps } from '../lib/icon-badge';
-import { RoutePath } from '../lib/routes';
import { useBoolean } from '../lib/utility-hooks';
import { useSelector } from '../redux/store';
import { AppMainHeader } from './app-main-header';
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/UserInterfaceSettings.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/UserInterfaceSettings.tsx
index b92c1a38f0..786013168b 100644
--- a/desktop/packages/mullvad-vpn/src/renderer/components/UserInterfaceSettings.tsx
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/UserInterfaceSettings.tsx
@@ -2,9 +2,9 @@ import { useCallback } from 'react';
import styled from 'styled-components';
import { messages } from '../../shared/gettext';
+import { RoutePath } from '../../shared/routes';
import { useAppContext } from '../context';
import { useHistory } from '../lib/history';
-import { RoutePath } from '../lib/routes';
import { useSelector } from '../redux/store';
import { AppNavigationHeader } from './';
import { AriaDescription, AriaInput, AriaInputGroup, AriaLabel } from './AriaGroup';
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/VpnSettings.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/VpnSettings.tsx
index 9e3acf985e..cca769e238 100644
--- a/desktop/packages/mullvad-vpn/src/renderer/components/VpnSettings.tsx
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/VpnSettings.tsx
@@ -6,6 +6,7 @@ import { strings, urls } from '../../shared/constants';
import { IDnsOptions, TunnelProtocol } from '../../shared/daemon-rpc-types';
import { messages } from '../../shared/gettext';
import log from '../../shared/logging';
+import { RoutePath } from '../../shared/routes';
import { useAppContext } from '../context';
import { Button } from '../lib/components';
import { useRelaySettingsUpdater } from '../lib/constraint-updater';
@@ -13,7 +14,6 @@ import { colors, spacings } from '../lib/foundations';
import { useHistory } from '../lib/history';
import { formatHtml } from '../lib/html-formatter';
import { useTunnelProtocol } from '../lib/relay-settings-hooks';
-import { RoutePath } from '../lib/routes';
import { useBoolean } from '../lib/utility-hooks';
import { RelaySettingsRedux } from '../redux/settings/reducers';
import { useSelector } from '../redux/store';
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/WireguardSettings.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/WireguardSettings.tsx
index 0193ed4676..cd2f2baab4 100644
--- a/desktop/packages/mullvad-vpn/src/renderer/components/WireguardSettings.tsx
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/WireguardSettings.tsx
@@ -11,12 +11,12 @@ import {
} from '../../shared/daemon-rpc-types';
import { messages } from '../../shared/gettext';
import log from '../../shared/logging';
+import { RoutePath } from '../../shared/routes';
import { removeNonNumericCharacters } from '../../shared/string-helpers';
import { isInRanges } from '../../shared/utils';
import { useAppContext } from '../context';
import { useRelaySettingsUpdater } from '../lib/constraint-updater';
import { useHistory } from '../lib/history';
-import { RoutePath } from '../lib/routes';
import { useSelector } from '../redux/store';
import { AppNavigationHeader } from './';
import { AriaDescription, AriaInput, AriaInputGroup, AriaLabel } from './AriaGroup';
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/app-main-header/components/AppMainHeaderAccountButton.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/app-main-header/components/AppMainHeaderAccountButton.tsx
index f558f253ee..23059a1f25 100644
--- a/desktop/packages/mullvad-vpn/src/renderer/components/app-main-header/components/AppMainHeaderAccountButton.tsx
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/app-main-header/components/AppMainHeaderAccountButton.tsx
@@ -1,9 +1,9 @@
import { useCallback } from 'react';
import { messages } from '../../../../shared/gettext';
+import { RoutePath } from '../../../../shared/routes';
import { IconButton, IconButtonProps, MainHeader } from '../../../lib/components';
import { TransitionType, useHistory } from '../../../lib/history';
-import { RoutePath } from '../../../lib/routes';
import { useSelector } from '../../../redux/store';
export type MainHeaderBarAccountButtonProps = Omit<IconButtonProps, 'icon'>;
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/app-main-header/components/AppMainHeaderSettingsButton.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/app-main-header/components/AppMainHeaderSettingsButton.tsx
index 755350f18d..1478ca4747 100644
--- a/desktop/packages/mullvad-vpn/src/renderer/components/app-main-header/components/AppMainHeaderSettingsButton.tsx
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/app-main-header/components/AppMainHeaderSettingsButton.tsx
@@ -1,14 +1,28 @@
import { useCallback } from 'react';
+import styled from 'styled-components';
import { messages } from '../../../../shared/gettext';
+import { RoutePath } from '../../../../shared/routes';
import { IconButton, IconButtonProps, MainHeader } from '../../../lib/components';
+import { Dot } from '../../../lib/components/dot';
import { TransitionType, useHistory } from '../../../lib/history';
-import { RoutePath } from '../../../lib/routes';
+import { useSelector } from '../../../redux/store';
export type MainHeaderSettingsButtonProps = Omit<IconButtonProps, 'icon'>;
+const StyledDot = styled(Dot)`
+ position: absolute;
+ top: 0;
+ right: 0;
+`;
+
+const StyledDiv = styled.div`
+ position: relative;
+`;
+
export function AppMainHeaderSettingsButton(props: MainHeaderSettingsButtonProps) {
const history = useHistory();
+ const suggestedUpgrade = useSelector((state) => state.version.suggestedUpgrade);
const openSettings = useCallback(() => {
if (!props.disabled) {
@@ -18,7 +32,14 @@ export function AppMainHeaderSettingsButton(props: MainHeaderSettingsButtonProps
return (
<MainHeader.IconButton onClick={openSettings} aria-label={messages.gettext('Settings')}>
- <IconButton.Icon icon="settings-filled" />{' '}
+ {suggestedUpgrade ? (
+ <StyledDiv>
+ <IconButton.Icon icon="settings-partial" />
+ <StyledDot variant="warning" size="tiny" />
+ </StyledDiv>
+ ) : (
+ <IconButton.Icon icon="settings-filled" />
+ )}
</MainHeader.IconButton>
);
}
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/cell/Selector.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/cell/Selector.tsx
index 4dfa25d731..9de9c49c3b 100644
--- a/desktop/packages/mullvad-vpn/src/renderer/components/cell/Selector.tsx
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/cell/Selector.tsx
@@ -2,10 +2,10 @@ import { useCallback, useRef, useState } from 'react';
import styled from 'styled-components';
import { messages } from '../../../shared/gettext';
+import { RoutePath } from '../../../shared/routes';
import { Icon } from '../../lib/components';
import { colors, spacings } from '../../lib/foundations';
import { useHistory } from '../../lib/history';
-import { RoutePath } from '../../lib/routes';
import { useStyledRef } from '../../lib/utility-hooks';
import { AriaDetails, AriaInput, AriaLabel } from '../AriaGroup';
import InfoButton from '../InfoButton';
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/changelog-list/ChangelogList.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/changelog-list/ChangelogList.tsx
new file mode 100644
index 0000000000..6c46aaaf97
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/changelog-list/ChangelogList.tsx
@@ -0,0 +1,29 @@
+import styled from 'styled-components';
+
+import { IChangelog } from '../../../shared/ipc-types';
+import { BodySmall } from '../../lib/components';
+import { Flex } from '../../lib/components';
+
+const StyledList = styled(Flex)`
+ list-style-type: disc;
+ padding-left: 0;
+ li {
+ margin-left: 1.5em;
+ }
+`;
+
+export type ChangelogListProps = {
+ changelog: IChangelog;
+};
+
+export function ChangelogList({ changelog }: ChangelogListProps) {
+ return (
+ <StyledList as="ul" $flexDirection="column" $gap="medium">
+ {changelog.map((item, i) => (
+ <BodySmall as="li" key={`${item}${i}`} color="whiteAlpha60">
+ {item}
+ </BodySmall>
+ ))}
+ </StyledList>
+ );
+}
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/changelog-list/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/changelog-list/index.ts
new file mode 100644
index 0000000000..f560f0ebd6
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/changelog-list/index.ts
@@ -0,0 +1 @@
+export * from './ChangelogList';
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/main-view/SelectLocationButton.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/main-view/SelectLocationButton.tsx
index 3d78cbe6dd..663df7a082 100644
--- a/desktop/packages/mullvad-vpn/src/renderer/components/main-view/SelectLocationButton.tsx
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/main-view/SelectLocationButton.tsx
@@ -5,10 +5,10 @@ import styled from 'styled-components';
import { ICustomList } from '../../../shared/daemon-rpc-types';
import { messages, relayLocations } from '../../../shared/gettext';
import log from '../../../shared/logging';
+import { RoutePath } from '../../../shared/routes';
import { useAppContext } from '../../context';
import { Button, ButtonProps, Icon } from '../../lib/components';
import { TransitionType, useHistory } from '../../lib/history';
-import { RoutePath } from '../../lib/routes';
import { IRelayLocationCountryRedux, RelaySettingsRedux } from '../../redux/settings/reducers';
import { useSelector } from '../../redux/store';
import { MultiButton } from '../MultiButton';
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/select-location/SelectLocation.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/select-location/SelectLocation.tsx
index 381ba6495f..d5be26cbf4 100644
--- a/desktop/packages/mullvad-vpn/src/renderer/components/select-location/SelectLocation.tsx
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/select-location/SelectLocation.tsx
@@ -4,6 +4,7 @@ import { sprintf } from 'sprintf-js';
import { strings } from '../../../shared/constants';
import { Ownership } from '../../../shared/daemon-rpc-types';
import { messages } from '../../../shared/gettext';
+import { RoutePath } from '../../../shared/routes';
import { Button, FilterChip, Flex, IconButton, LabelTiny } from '../../lib/components';
import { FlexColumn } from '../../lib/components/flex-column';
import { useRelaySettingsUpdater } from '../../lib/constraint-updater';
@@ -11,7 +12,6 @@ import { daitaFilterActive, filterSpecialLocations } from '../../lib/filter-loca
import { useHistory } from '../../lib/history';
import { formatHtml } from '../../lib/html-formatter';
import { useNormalRelaySettings, useTunnelProtocol } from '../../lib/relay-settings-hooks';
-import { RoutePath } from '../../lib/routes';
import { useSelector } from '../../redux/store';
import { AppNavigationHeader } from '../';
import * as Cell from '../cell';
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/select-location/SpecialLocationList.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/select-location/SpecialLocationList.tsx
index e53e3d41b2..17cd833992 100644
--- a/desktop/packages/mullvad-vpn/src/renderer/components/select-location/SpecialLocationList.tsx
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/select-location/SpecialLocationList.tsx
@@ -2,9 +2,9 @@ import React, { useCallback } from 'react';
import styled from 'styled-components';
import { messages } from '../../../shared/gettext';
+import { RoutePath } from '../../../shared/routes';
import { Icon } from '../../lib/components';
import { useHistory } from '../../lib/history';
-import { RoutePath } from '../../lib/routes';
import { useSelector } from '../../redux/store';
import * as Cell from '../cell';
import InfoButton from '../InfoButton';
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/app-info/AppInfoView.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-info/AppInfoView.tsx
index dedf6b72bb..ef24719981 100644
--- a/desktop/packages/mullvad-vpn/src/renderer/components/views/app-info/AppInfoView.tsx
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-info/AppInfoView.tsx
@@ -1,40 +1,54 @@
import { messages } from '../../../../shared/gettext';
+import { Flex } from '../../../lib/components';
+import { Animate } from '../../../lib/components/animate';
import { useHistory } from '../../../lib/history';
import { AppNavigationHeader } from '../../';
import { BackAction } from '../../KeyboardNavigation';
-import { Layout, SettingsContainer, SettingsGroup, SettingsStack } from '../../Layout';
+import { Layout, SettingsContainer } from '../../Layout';
import { NavigationContainer } from '../../NavigationContainer';
import { NavigationScrollbars } from '../../NavigationScrollbars';
import SettingsHeader, { HeaderTitle } from '../../SettingsHeader';
-import { AppVersionListItem, ChangelogListItem } from './components';
+import { ChangelogListItem, UpdateAvailableListItem, VersionListItem } from './components';
+import { BetaListItem } from './components/beta-list-item';
+import { useShowUpdateAvailable } from './hooks';
-export const AppInfoView = () => {
+export function AppInfoView() {
const { pop } = useHistory();
+ const showUpdateAvailable = useShowUpdateAvailable();
return (
<BackAction action={pop}>
<Layout>
<SettingsContainer>
<NavigationContainer>
- <AppNavigationHeader title={messages.pgettext('app-info-view', 'App info')} />
+ <AppNavigationHeader
+ title={
+ // TRANSLATORS: Title of the app info view.
+ messages.pgettext('app-info-view', 'App info')
+ }
+ />
<NavigationScrollbars>
<SettingsHeader>
<HeaderTitle>{messages.pgettext('app-info-view', 'App info')}</HeaderTitle>
</SettingsHeader>
- <SettingsContainer>
- <SettingsStack>
- <SettingsGroup>
- <AppVersionListItem />
- </SettingsGroup>
- <SettingsGroup>
- <ChangelogListItem />
- </SettingsGroup>
- </SettingsStack>
- </SettingsContainer>
+ <Animate
+ present={showUpdateAvailable}
+ animations={[{ type: 'fade' }, { type: 'wipe', direction: 'vertical' }]}>
+ <Flex $margin={{ bottom: 'medium' }}>
+ <UpdateAvailableListItem />
+ </Flex>
+ </Animate>
+ <Flex $flexDirection="column" $gap="medium">
+ <Flex $flexDirection="column">
+ <VersionListItem />
+ <ChangelogListItem />
+ </Flex>
+ <BetaListItem />
+ </Flex>
</NavigationScrollbars>
</NavigationContainer>
</SettingsContainer>
</Layout>
</BackAction>
);
-};
+}
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/app-info/components/AppVersionListItem.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-info/components/AppVersionListItem.tsx
deleted file mode 100644
index e77d98c9ea..0000000000
--- a/desktop/packages/mullvad-vpn/src/renderer/components/views/app-info/components/AppVersionListItem.tsx
+++ /dev/null
@@ -1,64 +0,0 @@
-import { useCallback } from 'react';
-
-import { messages } from '../../../../../shared/gettext';
-import { getDownloadUrl } from '../../../../../shared/version';
-import { useAppContext } from '../../../../context';
-import { useSelector } from '../../../../redux/store';
-import * as Cell from '../../../cell';
-import { LabelStack } from '../../../Layout';
-
-export function AppVersionListItem() {
- const appVersion = useSelector((state) => state.version.current);
- const consistentVersion = useSelector((state) => state.version.consistent);
- const upToDateVersion = useSelector((state) => (state.version.suggestedUpgrade ? false : true));
- const suggestedIsBeta = useSelector((state) => state.version.suggestedIsBeta ?? false);
- const isOffline = useSelector((state) => state.connection.isBlocked);
-
- const { openUrl } = useAppContext();
- const openDownloadLink = useCallback(
- () => openUrl(getDownloadUrl(suggestedIsBeta)),
- [openUrl, suggestedIsBeta],
- );
-
- let alertIcon;
- let footer;
- if (!consistentVersion || !upToDateVersion) {
- const inconsistentVersionMessage = messages.pgettext(
- 'app-info-view',
- 'App is out of sync. Please quit and restart.',
- );
-
- const updateAvailableMessage = messages.pgettext(
- 'app-info-view',
- 'Update available. Install the latest app version to stay up to date.',
- );
-
- const message = !consistentVersion ? inconsistentVersionMessage : updateAvailableMessage;
-
- alertIcon = <Cell.CellIcon icon="alert-circle" color="red" />;
- footer = (
- <Cell.CellFooter>
- <Cell.CellFooterText>{message}</Cell.CellFooterText>
- </Cell.CellFooter>
- );
- }
-
- return (
- <>
- <Cell.CellNavigationButton
- disabled={isOffline}
- onClick={openDownloadLink}
- icon={{
- icon: 'external',
- 'aria-label': messages.pgettext('accessibility', 'Opens externally'),
- }}>
- <LabelStack>
- {alertIcon}
- <Cell.Label>{messages.pgettext('app-info-view', 'App version')}</Cell.Label>
- </LabelStack>
- <Cell.SubText>{appVersion}</Cell.SubText>
- </Cell.CellNavigationButton>
- {footer}
- </>
- );
-}
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/app-info/components/ChangelogListItem.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-info/components/ChangelogListItem.tsx
deleted file mode 100644
index 9c861008d1..0000000000
--- a/desktop/packages/mullvad-vpn/src/renderer/components/views/app-info/components/ChangelogListItem.tsx
+++ /dev/null
@@ -1,17 +0,0 @@
-import { useCallback } from 'react';
-
-import { messages } from '../../../../../shared/gettext';
-import { useHistory } from '../../../../lib/history';
-import { RoutePath } from '../../../../lib/routes';
-import * as Cell from '../../../cell';
-
-export function ChangelogListItem() {
- const history = useHistory();
- const navigate = useCallback(() => history.push(RoutePath.changelog), [history]);
-
- return (
- <Cell.CellNavigationButton onClick={navigate}>
- <Cell.Label>{messages.pgettext('settings-view', 'What’s new')}</Cell.Label>
- </Cell.CellNavigationButton>
- );
-}
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/app-info/components/beta-list-item/BetaListItem.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-info/components/beta-list-item/BetaListItem.tsx
new file mode 100644
index 0000000000..9dbbb0d566
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-info/components/beta-list-item/BetaListItem.tsx
@@ -0,0 +1,51 @@
+import React from 'react';
+
+import { messages } from '../../../../../../shared/gettext';
+import { ListItem } from '../../../../../lib/components/list-item';
+import { useSettingsShowBetaReleases, useVersionIsBeta } from '../../../../../redux/hooks';
+import Switch from '../../../../Switch';
+
+export function BetaListItem() {
+ const { isBeta } = useVersionIsBeta();
+ const { showBetaReleases, setShowBetaReleases } = useSettingsShowBetaReleases();
+ const switchId = React.useId();
+ const labelId = React.useId();
+ const descriptionId = React.useId();
+
+ return (
+ <ListItem disabled={isBeta}>
+ <ListItem.Item>
+ <ListItem.Content>
+ <ListItem.Label id={labelId} as="label" htmlFor={switchId}>
+ {
+ // TRANSLATORS: Label for switch to toggle beta program.
+ messages.pgettext('app-info-view', 'Beta program')
+ }
+ </ListItem.Label>
+ <Switch
+ id={switchId}
+ aria-labelledby={labelId}
+ aria-describedby={descriptionId}
+ isOn={showBetaReleases}
+ onChange={setShowBetaReleases}
+ />
+ </ListItem.Content>
+ </ListItem.Item>
+ <ListItem.Footer>
+ <ListItem.Text id={descriptionId}>
+ {isBeta
+ ? // TRANSLATORS: Description for beta program switch when using a beta version.
+ messages.pgettext(
+ 'app-info-view',
+ 'This option is unavailable while using a beta version.',
+ )
+ : // TRANSLATORS: Description for beta program switch.
+ messages.pgettext(
+ 'app-info-view',
+ 'Enable to get notified when new beta versions of the app are released.',
+ )}
+ </ListItem.Text>
+ </ListItem.Footer>
+ </ListItem>
+ );
+}
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/app-info/components/beta-list-item/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-info/components/beta-list-item/index.ts
new file mode 100644
index 0000000000..133d5c3afe
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-info/components/beta-list-item/index.ts
@@ -0,0 +1 @@
+export * from './BetaListItem';
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/app-info/components/changelog-list-item/ChangelogListItem.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-info/components/changelog-list-item/ChangelogListItem.tsx
new file mode 100644
index 0000000000..437cae4d37
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-info/components/changelog-list-item/ChangelogListItem.tsx
@@ -0,0 +1,26 @@
+import { messages } from '../../../../../../shared/gettext';
+import { usePushChangelog } from '../../../../../history/hooks';
+import { Icon } from '../../../../../lib/components';
+import { ListItem } from '../../../../../lib/components/list-item';
+
+export function ChangelogListItem() {
+ const pushChangelog = usePushChangelog();
+
+ return (
+ <ListItem>
+ <ListItem.Item>
+ <ListItem.Trigger onClick={pushChangelog}>
+ <ListItem.Content>
+ <ListItem.Label>
+ {
+ // TRANSLATORS: Label for changelog list item.
+ messages.pgettext('app-info-view', 'What’s new')
+ }
+ </ListItem.Label>
+ <Icon icon="chevron-right" />
+ </ListItem.Content>
+ </ListItem.Trigger>
+ </ListItem.Item>
+ </ListItem>
+ );
+}
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/app-info/components/changelog-list-item/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-info/components/changelog-list-item/index.ts
new file mode 100644
index 0000000000..8a8329bd2c
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-info/components/changelog-list-item/index.ts
@@ -0,0 +1 @@
+export * from './ChangelogListItem';
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/app-info/components/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-info/components/index.ts
index 905bc45397..daaca08082 100644
--- a/desktop/packages/mullvad-vpn/src/renderer/components/views/app-info/components/index.ts
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-info/components/index.ts
@@ -1,2 +1,3 @@
-export * from './AppVersionListItem';
-export * from './ChangelogListItem';
+export * from './version-list-item';
+export * from './changelog-list-item';
+export * from './update-available-list-item';
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/app-info/components/update-available-list-item/UpdateAvailableListItem.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-info/components/update-available-list-item/UpdateAvailableListItem.tsx
new file mode 100644
index 0000000000..0e0ee8fba4
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-info/components/update-available-list-item/UpdateAvailableListItem.tsx
@@ -0,0 +1,44 @@
+import styled from 'styled-components';
+
+import { messages } from '../../../../../../shared/gettext';
+import { useIsPlatformLinux } from '../../../../../hooks';
+import { Flex, Icon } from '../../../../../lib/components';
+import { Dot } from '../../../../../lib/components/dot';
+import { ListItem } from '../../../../../lib/components/list-item';
+import { useVersionSuggestedUpgrade } from '../../../../../redux/hooks';
+import { useHandleClick } from './hooks';
+
+const StyledText = styled(ListItem.Text)`
+ margin-top: -4px;
+`;
+
+export function UpdateAvailableListItem() {
+ const { suggestedUpgrade } = useVersionSuggestedUpgrade();
+
+ const isLinux = useIsPlatformLinux();
+ const handleClick = useHandleClick();
+
+ return (
+ <ListItem>
+ <ListItem.Item>
+ <ListItem.Trigger onClick={handleClick}>
+ <ListItem.Content>
+ <Flex $flexDirection="column">
+ <ListItem.Label>
+ {
+ // TRANSLATORS: Label for update available list item.
+ messages.pgettext('app-info-view', 'Update available')
+ }
+ </ListItem.Label>
+ <StyledText variant="footnoteMini">{suggestedUpgrade?.version}</StyledText>
+ </Flex>
+ <ListItem.Group>
+ <Dot variant="warning" size="small" />
+ <Icon icon={isLinux ? 'external' : 'chevron-right'} />
+ </ListItem.Group>
+ </ListItem.Content>
+ </ListItem.Trigger>
+ </ListItem.Item>
+ </ListItem>
+ );
+}
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/app-info/components/update-available-list-item/hooks/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-info/components/update-available-list-item/hooks/index.ts
new file mode 100644
index 0000000000..e1b349a0e9
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-info/components/update-available-list-item/hooks/index.ts
@@ -0,0 +1,2 @@
+export * from './useOpenDownloadUrl';
+export * from './useHandleClick';
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/app-info/components/update-available-list-item/hooks/useHandleClick.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-info/components/update-available-list-item/hooks/useHandleClick.tsx
new file mode 100644
index 0000000000..dc3cd2d7a8
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-info/components/update-available-list-item/hooks/useHandleClick.tsx
@@ -0,0 +1,10 @@
+import { usePushAppUpgrade } from '../../../../../../history/hooks';
+import { useIsPlatformLinux } from '../../../../../../hooks';
+import { useOpenDownloadUrl } from './useOpenDownloadUrl';
+
+export const useHandleClick = () => {
+ const openDownloadUrl = useOpenDownloadUrl();
+ const pushAppUpgrade = usePushAppUpgrade();
+ const isLinux = useIsPlatformLinux();
+ return isLinux ? openDownloadUrl : pushAppUpgrade;
+};
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/app-info/components/update-available-list-item/hooks/useOpenDownloadUrl.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-info/components/update-available-list-item/hooks/useOpenDownloadUrl.tsx
new file mode 100644
index 0000000000..babef27fb6
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-info/components/update-available-list-item/hooks/useOpenDownloadUrl.tsx
@@ -0,0 +1,14 @@
+import React from 'react';
+
+import { getDownloadUrl } from '../../../../../../../shared/version';
+import { useAppContext } from '../../../../../../context';
+import { useVersionSuggestedIsBeta } from '../../../../../../redux/hooks';
+
+export const useOpenDownloadUrl = () => {
+ const { suggestedIsBeta } = useVersionSuggestedIsBeta();
+ const { openUrl } = useAppContext();
+ const openDownloadLink = React.useCallback(async () => {
+ await openUrl(getDownloadUrl(suggestedIsBeta));
+ }, [openUrl, suggestedIsBeta]);
+ return openDownloadLink;
+};
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/app-info/components/update-available-list-item/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-info/components/update-available-list-item/index.ts
new file mode 100644
index 0000000000..658fb4f345
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-info/components/update-available-list-item/index.ts
@@ -0,0 +1 @@
+export * from './UpdateAvailableListItem';
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/app-info/components/version-list-item/VersionListItem.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-info/components/version-list-item/VersionListItem.tsx
new file mode 100644
index 0000000000..abc3654384
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-info/components/version-list-item/VersionListItem.tsx
@@ -0,0 +1,40 @@
+import { messages } from '../../../../../../shared/gettext';
+import { Icon } from '../../../../../lib/components';
+import { ListItem } from '../../../../../lib/components/list-item';
+import { useVersionCurrent } from '../../../../../redux/hooks';
+import { useShowAlert, useShowFooter } from './hooks';
+
+export function VersionListItem() {
+ const { current } = useVersionCurrent();
+ const showAlert = useShowAlert();
+ const showFooter = useShowFooter();
+
+ return (
+ <ListItem>
+ <ListItem.Item>
+ <ListItem.Content>
+ <ListItem.Group>
+ {showAlert && <Icon icon="alert-circle" color="red" />}
+ <ListItem.Label>
+ {
+ // TRANSLATORS: Label for version list item.
+ messages.pgettext('app-info-view', 'Version')
+ }
+ </ListItem.Label>
+ </ListItem.Group>
+ <ListItem.Text>{current}</ListItem.Text>
+ </ListItem.Content>
+ </ListItem.Item>
+ {showFooter && (
+ <ListItem.Footer>
+ <ListItem.Text>
+ {
+ // TRANSLATORS: Description for version list item when app is out of sync.
+ messages.pgettext('app-info-view', 'App is out of sync. Please quit and restart.')
+ }
+ </ListItem.Text>
+ </ListItem.Footer>
+ )}
+ </ListItem>
+ );
+}
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/app-info/components/version-list-item/hooks/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-info/components/version-list-item/hooks/index.ts
new file mode 100644
index 0000000000..80ad6f4868
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-info/components/version-list-item/hooks/index.ts
@@ -0,0 +1,2 @@
+export * from './useShowAlert';
+export * from './useShowFooter';
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/app-info/components/version-list-item/hooks/useShowAlert.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-info/components/version-list-item/hooks/useShowAlert.tsx
new file mode 100644
index 0000000000..36c9e32382
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-info/components/version-list-item/hooks/useShowAlert.tsx
@@ -0,0 +1,6 @@
+import { useVersionConsistent } from '../../../../../../redux/hooks';
+
+export const useShowAlert = () => {
+ const { consistent } = useVersionConsistent();
+ return !consistent;
+};
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/app-info/components/version-list-item/hooks/useShowFooter.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-info/components/version-list-item/hooks/useShowFooter.tsx
new file mode 100644
index 0000000000..077648d705
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-info/components/version-list-item/hooks/useShowFooter.tsx
@@ -0,0 +1,6 @@
+import { useVersionConsistent } from '../../../../../../redux/hooks';
+
+export const useShowFooter = () => {
+ const { consistent } = useVersionConsistent();
+ return !consistent;
+};
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/app-info/components/version-list-item/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-info/components/version-list-item/index.ts
new file mode 100644
index 0000000000..f21721307f
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-info/components/version-list-item/index.ts
@@ -0,0 +1 @@
+export * from './VersionListItem';
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/app-info/hooks/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-info/hooks/index.ts
new file mode 100644
index 0000000000..48285aaafb
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-info/hooks/index.ts
@@ -0,0 +1 @@
+export * from './useShowUpdateAvailable';
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/app-info/hooks/useShowUpdateAvailable.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-info/hooks/useShowUpdateAvailable.tsx
new file mode 100644
index 0000000000..5cb4507a39
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-info/hooks/useShowUpdateAvailable.tsx
@@ -0,0 +1,6 @@
+import { useVersionSuggestedUpgrade } from '../../../../redux/version/hooks/useVersionSuggestedUpgrade';
+
+export const useShowUpdateAvailable = () => {
+ const { suggestedUpgrade } = useVersionSuggestedUpgrade();
+ return !!suggestedUpgrade;
+};
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/AppUpgradeView.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/AppUpgradeView.tsx
new file mode 100644
index 0000000000..5d56347a50
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/AppUpgradeView.tsx
@@ -0,0 +1,29 @@
+import styled from 'styled-components';
+
+import { useHistory } from '../../../lib/history';
+import { BackAction } from '../../KeyboardNavigation';
+import { Layout } from '../../Layout';
+import { Footer, UpgradeDetails } from './components';
+
+const StyledFooter = styled.div`
+ // TODO: Use color from Colors
+ background-color: rgba(21, 39, 58, 1);
+ position: sticky;
+ bottom: 0;
+ width: 100%;
+`;
+
+export const AppUpgradeView = () => {
+ const { pop } = useHistory();
+
+ return (
+ <BackAction action={pop}>
+ <Layout>
+ <UpgradeDetails />
+ <StyledFooter>
+ <Footer />
+ </StyledFooter>
+ </Layout>
+ </BackAction>
+ );
+};
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/connection-blocked-label/ConnectionBlockedLabel.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/connection-blocked-label/ConnectionBlockedLabel.tsx
new file mode 100644
index 0000000000..627ffc1503
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/connection-blocked-label/ConnectionBlockedLabel.tsx
@@ -0,0 +1,20 @@
+import { messages } from '../../../../../../shared/gettext';
+import { Flex, LabelTiny } from '../../../../../lib/components';
+import { Dot } from '../../../../../lib/components/dot';
+
+export function ConnectionBlockedLabel() {
+ return (
+ <Flex $gap="small" $alignItems="baseline">
+ <Dot size="small" variant="error" />
+ <LabelTiny>
+ {
+ // TRANSLATORS: Label displayed when an error occurred due to the connection being blocked
+ messages.pgettext(
+ 'app-upgrade-view',
+ 'Connection blocked. Try changing server or other settings.',
+ )
+ }
+ </LabelTiny>
+ </Flex>
+ );
+}
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/connection-blocked-label/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/connection-blocked-label/index.ts
new file mode 100644
index 0000000000..8363d2451f
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/connection-blocked-label/index.ts
@@ -0,0 +1 @@
+export * from './ConnectionBlockedLabel';
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/download-progress/DownloadProgress.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/download-progress/DownloadProgress.tsx
new file mode 100644
index 0000000000..0bc31459c4
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/download-progress/DownloadProgress.tsx
@@ -0,0 +1,21 @@
+import { useAppUpgradeDownloadProgressValue } from '../../../../../hooks';
+import { Progress } from '../../../../../lib/components/progress';
+import { useDisabled, useMessage } from './hooks';
+
+export function DownloadProgress() {
+ const disabled = useDisabled();
+ const message = useMessage();
+ const value = useAppUpgradeDownloadProgressValue();
+
+ return (
+ <Progress value={value} disabled={disabled}>
+ <Progress.Track>
+ <Progress.Range />
+ </Progress.Track>
+ <Progress.Footer>
+ <Progress.Percent />
+ <Progress.Text>{message}</Progress.Text>
+ </Progress.Footer>
+ </Progress>
+ );
+}
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/download-progress/hooks/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/download-progress/hooks/index.ts
new file mode 100644
index 0000000000..a1469aafac
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/download-progress/hooks/index.ts
@@ -0,0 +1,2 @@
+export * from './useMessage';
+export * from './useDisabled';
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/download-progress/hooks/useDisabled.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/download-progress/hooks/useDisabled.ts
new file mode 100644
index 0000000000..9623de9a6e
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/download-progress/hooks/useDisabled.ts
@@ -0,0 +1,22 @@
+import {
+ useAppUpgradeEventType,
+ useHasAppUpgradeError,
+ useHasAppUpgradeVerifiedInstallerPath,
+} from '../../../../../../hooks';
+import { useConnectionIsBlocked } from '../../../../../../redux/hooks';
+
+export const useDisabled = () => {
+ const { isBlocked } = useConnectionIsBlocked();
+ const appUpgradeEventType = useAppUpgradeEventType();
+ const hasAppUpgradeError = useHasAppUpgradeError();
+ const hasAppUpgradeVerifiedInstallerPath = useHasAppUpgradeVerifiedInstallerPath();
+
+ if (hasAppUpgradeVerifiedInstallerPath) {
+ return false;
+ }
+
+ const disabled =
+ hasAppUpgradeError || isBlocked || appUpgradeEventType === 'APP_UPGRADE_STATUS_ABORTED';
+
+ return disabled;
+};
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/download-progress/hooks/useMessage/constants.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/download-progress/hooks/useMessage/constants.ts
new file mode 100644
index 0000000000..2e6b598186
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/download-progress/hooks/useMessage/constants.ts
@@ -0,0 +1,44 @@
+import { sprintf } from 'sprintf-js';
+
+import { messages } from '../../../../../../../../shared/gettext';
+
+export const translations = {
+ downloadComplete:
+ // TRANSLATORS: Status text displayed below a progress bar when the download of an update is complete
+ messages.pgettext('app-upgrade-view', 'Download complete!'),
+ downloadFailed:
+ // TRANSLATORS: Status text displayed below a progress bar when the download of an update fails
+ messages.pgettext('app-upgrade-view', 'Download failed'),
+ downloadFewSecondsRemaining:
+ // TRANSLATORS: Status text displayed below a progress bar when the update is being downloaded
+ // TRANSLATORS: with the estimated time of completion is within a few seconds.
+ messages.pgettext('app-upgrade-view', 'A few seconds remaining...'),
+ downloadPaused:
+ // TRANSLATORS: Status text displayed below a progress bar when the download of an update has been paused
+ messages.pgettext('app-upgrade-view', 'Download paused'),
+ downloadStarting:
+ // TRANSLATORS: Status text displayed below a progress bar when the download of an update is starting
+ messages.pgettext('app-upgrade-view', 'Starting download...'),
+ getDownloadMinutesRemaining: (minutes: number) =>
+ sprintf(
+ // TRANSLATORS: Status text displayed below a progress bar when the update is being downloaded
+ // TRANSLATORS: with the estimated time of completion represented in minutes.
+ // TRANSLATORS: Available placeholders:
+ // TRANSLATORS: %(minutes)s - Will be replaced with remaining minutes until download is complete
+ messages.pgettext('app-upgrade-view', 'About %(minutes)s minutes remaining...'),
+ {
+ minutes,
+ },
+ ),
+ getDownloadSecondsRemaining: (seconds: number) =>
+ sprintf(
+ // TRANSLATORS: Status text displayed below a progress bar when the update is being downloaded
+ // TRANSLATORS: with the estimated time of completion represented in seconds.
+ // TRANSLATORS: Available placeholders:
+ // TRANSLATORS: %(second)s - Will be replaced with remaining seconds until download is complete
+ messages.pgettext('app-upgrade-view', 'About %(seconds)s seconds remaining...'),
+ {
+ seconds,
+ },
+ ),
+};
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/download-progress/hooks/useMessage/hooks/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/download-progress/hooks/useMessage/hooks/index.ts
new file mode 100644
index 0000000000..22e7b37790
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/download-progress/hooks/useMessage/hooks/index.ts
@@ -0,0 +1,2 @@
+export * from './useGetMessageError';
+export * from './useGetMessageTimeLeft';
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/download-progress/hooks/useMessage/hooks/useGetMessageError.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/download-progress/hooks/useMessage/hooks/useGetMessageError.ts
new file mode 100644
index 0000000000..34fbebbb30
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/download-progress/hooks/useMessage/hooks/useGetMessageError.ts
@@ -0,0 +1,29 @@
+import { useHasAppUpgradeVerifiedInstallerPath } from '../../../../../../../../hooks';
+import { useAppUpgradeError } from '../../../../../../../../redux/hooks';
+import { translations } from '../constants';
+
+export const useGetMessageError = () => {
+ const { error } = useAppUpgradeError();
+ const hasAppUpgradeVerifiedInstallerPath = useHasAppUpgradeVerifiedInstallerPath();
+
+ const getMessageError = () => {
+ if (error) {
+ switch (error) {
+ case 'DOWNLOAD_FAILED':
+ return translations.downloadFailed;
+ case 'INSTALLER_FAILED':
+ case 'START_INSTALLER_FAILED':
+ case 'VERIFICATION_FAILED':
+ return translations.downloadComplete;
+ case 'GENERAL_ERROR':
+ return hasAppUpgradeVerifiedInstallerPath ? translations.downloadComplete : null;
+ default:
+ return error satisfies never;
+ }
+ }
+
+ return null;
+ };
+
+ return getMessageError;
+};
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/download-progress/hooks/useMessage/hooks/useGetMessageTimeLeft.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/download-progress/hooks/useMessage/hooks/useGetMessageTimeLeft.ts
new file mode 100644
index 0000000000..e826105522
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/download-progress/hooks/useMessage/hooks/useGetMessageTimeLeft.ts
@@ -0,0 +1,32 @@
+import { isNumber } from '../../../../../../../../../shared/utils';
+import { useAppUpgradeEvent } from '../../../../../../../../redux/hooks';
+import { translations } from '../constants';
+
+export const useGetMessageTimeLeft = () => {
+ const { event } = useAppUpgradeEvent();
+
+ const getMessageTimeLeft = () => {
+ if (event?.type === 'APP_UPGRADE_STATUS_DOWNLOAD_PROGRESS') {
+ const { timeLeft } = event;
+ const isTimeLeftNumeric = isNumber(timeLeft);
+
+ if (isTimeLeftNumeric) {
+ if (timeLeft > 90) {
+ const minutes = Math.round(timeLeft / 60);
+
+ return translations.getDownloadMinutesRemaining(minutes);
+ }
+
+ if (timeLeft > 3) {
+ return translations.getDownloadSecondsRemaining(timeLeft);
+ }
+
+ return translations.downloadFewSecondsRemaining;
+ }
+ }
+
+ return null;
+ };
+
+ return getMessageTimeLeft;
+};
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/download-progress/hooks/useMessage/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/download-progress/hooks/useMessage/index.ts
new file mode 100644
index 0000000000..107ba71afc
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/download-progress/hooks/useMessage/index.ts
@@ -0,0 +1 @@
+export * from './useMessage';
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/download-progress/hooks/useMessage/useMessage.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/download-progress/hooks/useMessage/useMessage.ts
new file mode 100644
index 0000000000..74d884f4de
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/download-progress/hooks/useMessage/useMessage.ts
@@ -0,0 +1,51 @@
+import {
+ useAppUpgradeEventType,
+ useHasAppUpgradeError,
+ useHasAppUpgradeVerifiedInstallerPath,
+} from '../../../../../../../hooks';
+import { convertEventTypeToStep } from '../../../../../../../redux/app-upgrade/helpers';
+import { useConnectionIsBlocked } from '../../../../../../../redux/hooks';
+import { translations } from './constants';
+import { useGetMessageError, useGetMessageTimeLeft } from './hooks';
+
+export const useMessage = () => {
+ const { isBlocked } = useConnectionIsBlocked();
+ const appUpgradeEventType = useAppUpgradeEventType();
+ const getMessageError = useGetMessageError();
+ const getMessageTimeLeft = useGetMessageTimeLeft();
+ const hasAppUpgradeVerifiedInstallerPath = useHasAppUpgradeVerifiedInstallerPath();
+ const hasAppUpgradeError = useHasAppUpgradeError();
+ const step = convertEventTypeToStep(appUpgradeEventType);
+
+ if (
+ (step === 'initial' && hasAppUpgradeVerifiedInstallerPath) ||
+ step === 'launch' ||
+ step === 'verify'
+ ) {
+ return translations.downloadComplete;
+ }
+
+ if (isBlocked) {
+ return translations.downloadPaused;
+ }
+
+ if (hasAppUpgradeError) {
+ return getMessageError();
+ }
+
+ if (step === 'pause') {
+ return translations.downloadPaused;
+ }
+
+ if (step === 'download') {
+ if (appUpgradeEventType === 'APP_UPGRADE_STATUS_DOWNLOAD_PROGRESS') {
+ const messageTimeLeft = getMessageTimeLeft();
+
+ return messageTimeLeft;
+ }
+
+ return translations.downloadStarting;
+ }
+
+ return null;
+};
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/download-progress/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/download-progress/index.ts
new file mode 100644
index 0000000000..213bb26f82
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/download-progress/index.ts
@@ -0,0 +1 @@
+export * from './DownloadProgress';
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/Footer.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/Footer.tsx
new file mode 100644
index 0000000000..6381079b6b
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/Footer.tsx
@@ -0,0 +1,45 @@
+import styled from 'styled-components';
+
+import { useMeasure } from '../../../../../hooks';
+import {
+ DownloadFooter,
+ ErrorFooter,
+ InitialFooter,
+ LaunchFooter,
+ PauseFooter,
+ VerifyFooter,
+} from './components';
+import { useStep } from './hooks';
+
+const TransitionHeight = styled.div<{ $height: string }>`
+ display: flex;
+ flex-direction: column;
+ justify-content: flex-end;
+ overflow: hidden;
+ height: ${({ $height }) => $height};
+ @media (prefers-reduced-motion: no-preference) {
+ transition: height 200ms cubic-bezier(0.16, 1, 0.3, 1);
+ }
+`;
+
+const footers = {
+ download: <DownloadFooter />,
+ error: <ErrorFooter />,
+ initial: <InitialFooter />,
+ launch: <LaunchFooter />,
+ pause: <PauseFooter />,
+ verify: <VerifyFooter />,
+};
+
+export function Footer() {
+ const step = useStep();
+ const footer = footers[step];
+
+ const [ref, { height }] = useMeasure<HTMLDivElement>();
+
+ return (
+ <TransitionHeight $height={`${height}px`}>
+ <div ref={ref}>{footer}</div>
+ </TransitionHeight>
+ );
+}
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/download-footer/DownloadFooter.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/download-footer/DownloadFooter.tsx
new file mode 100644
index 0000000000..213110d2a0
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/download-footer/DownloadFooter.tsx
@@ -0,0 +1,22 @@
+import { Flex } from '../../../../../../../lib/components';
+import { ConnectionBlockedLabel } from '../../../connection-blocked-label';
+import { DownloadProgress } from '../../../download-progress';
+import { DownloadLabel, PauseDownloadButton, ResumeDownloadButton } from './components';
+import { useShowConnectionBlockedLabel, useShowResumeDownloadButton } from './hooks';
+
+export function DownloadFooter() {
+ const showConnectionBlockedLabel = useShowConnectionBlockedLabel();
+ const showResumeDownloadButton = useShowResumeDownloadButton();
+
+ return (
+ <Flex $padding="large" $flexDirection="column">
+ <Flex $gap="medium" $flexDirection="column" $margin={{ bottom: 'medium' }}>
+ {showConnectionBlockedLabel ? <ConnectionBlockedLabel /> : <DownloadLabel />}
+ <DownloadProgress />
+ </Flex>
+ <Flex $flexDirection="column">
+ {showResumeDownloadButton ? <ResumeDownloadButton /> : <PauseDownloadButton />}
+ </Flex>
+ </Flex>
+ );
+}
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/download-footer/components/download-label/DownloadLabel.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/download-footer/components/download-label/DownloadLabel.tsx
new file mode 100644
index 0000000000..7bdc5a18bc
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/download-footer/components/download-label/DownloadLabel.tsx
@@ -0,0 +1,8 @@
+import { LabelTiny } from '../../../../../../../../../lib/components';
+import { useMessage } from './hooks';
+
+export function DownloadLabel() {
+ const message = useMessage();
+
+ return <LabelTiny>{message}</LabelTiny>;
+}
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/download-footer/components/download-label/hooks/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/download-footer/components/download-label/hooks/index.ts
new file mode 100644
index 0000000000..5f0470ee5b
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/download-footer/components/download-label/hooks/index.ts
@@ -0,0 +1,2 @@
+export * from './useGetDownloadProgressMessage';
+export * from './useMessage';
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/download-footer/components/download-label/hooks/useGetDownloadProgressMessage.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/download-footer/components/download-label/hooks/useGetDownloadProgressMessage.ts
new file mode 100644
index 0000000000..17c98f849b
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/download-footer/components/download-label/hooks/useGetDownloadProgressMessage.ts
@@ -0,0 +1,28 @@
+import { useCallback } from 'react';
+import { sprintf } from 'sprintf-js';
+
+import { messages } from '../../../../../../../../../../../shared/gettext';
+import { useAppUpgradeEvent } from '../../../../../../../../../../redux/hooks';
+
+export const useGetDownloadProgressMessage = () => {
+ const { event } = useAppUpgradeEvent();
+
+ const getDownloadProgressMessage = useCallback(() => {
+ if (event?.type === 'APP_UPGRADE_STATUS_DOWNLOAD_PROGRESS') {
+ const { server } = event;
+
+ return sprintf(
+ // TRANSLATORS: Label displayed above a progress bar informing the user which server
+ // TRANSLATORS: the update is downloading from
+ messages.pgettext('app-upgrade-view', 'Downloading from: %(server)s'),
+ {
+ server,
+ },
+ );
+ }
+
+ return null;
+ }, [event]);
+
+ return getDownloadProgressMessage;
+};
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/download-footer/components/download-label/hooks/useMessage.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/download-footer/components/download-label/hooks/useMessage.ts
new file mode 100644
index 0000000000..02c1e50a8d
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/download-footer/components/download-label/hooks/useMessage.ts
@@ -0,0 +1,19 @@
+import { messages } from '../../../../../../../../../../../shared/gettext';
+import { useAppUpgradeEventType } from '../../../../../../../../../../hooks';
+import { useGetDownloadProgressMessage } from './useGetDownloadProgressMessage';
+
+export const useMessage = () => {
+ const appUpgradeEventType = useAppUpgradeEventType();
+ const getDownloadProgressMessage = useGetDownloadProgressMessage();
+
+ switch (appUpgradeEventType) {
+ case 'APP_UPGRADE_STATUS_DOWNLOAD_INITIATED':
+ case 'APP_UPGRADE_STATUS_DOWNLOAD_STARTED':
+ // TRANSLATORS: Label displayed above a progress bar when a download is in progress
+ return messages.pgettext('app-upgrade-view', 'Downloading...');
+ case 'APP_UPGRADE_STATUS_DOWNLOAD_PROGRESS':
+ return getDownloadProgressMessage();
+ default:
+ return null;
+ }
+};
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/download-footer/components/download-label/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/download-footer/components/download-label/index.ts
new file mode 100644
index 0000000000..3349460ac0
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/download-footer/components/download-label/index.ts
@@ -0,0 +1 @@
+export * from './DownloadLabel';
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/download-footer/components/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/download-footer/components/index.ts
new file mode 100644
index 0000000000..554101504a
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/download-footer/components/index.ts
@@ -0,0 +1,3 @@
+export * from './download-label';
+export * from './pause-download-button';
+export * from './resume-download-button';
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/download-footer/components/pause-download-button/PauseDownloadButton.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/download-footer/components/pause-download-button/PauseDownloadButton.tsx
new file mode 100644
index 0000000000..fd664b9879
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/download-footer/components/pause-download-button/PauseDownloadButton.tsx
@@ -0,0 +1,13 @@
+import { messages } from '../../../../../../../../../../shared/gettext';
+import { PauseButton } from '../../../../../pause-button';
+
+export function PauseDownloadButton() {
+ return (
+ <PauseButton>
+ {
+ // TRANSLATORS: Button text to pause the download of an update
+ messages.pgettext('app-upgrade-view', 'Pause')
+ }
+ </PauseButton>
+ );
+}
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/download-footer/components/pause-download-button/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/download-footer/components/pause-download-button/index.ts
new file mode 100644
index 0000000000..251db80243
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/download-footer/components/pause-download-button/index.ts
@@ -0,0 +1 @@
+export * from './PauseDownloadButton';
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/download-footer/components/resume-download-button/ResumeDownloadButton.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/download-footer/components/resume-download-button/ResumeDownloadButton.tsx
new file mode 100644
index 0000000000..2fbf5f9d8b
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/download-footer/components/resume-download-button/ResumeDownloadButton.tsx
@@ -0,0 +1,16 @@
+import { messages } from '../../../../../../../../../../shared/gettext';
+import { useConnectionIsBlocked } from '../../../../../../../../../redux/hooks';
+import { ResumeButton } from '../../../../../resume-button';
+
+export function ResumeDownloadButton() {
+ const { isBlocked } = useConnectionIsBlocked();
+
+ return (
+ <ResumeButton disabled={isBlocked}>
+ {
+ // TRANSLATORS: Button text to resume updating
+ messages.pgettext('app-upgrade-view', 'Resume')
+ }
+ </ResumeButton>
+ );
+}
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/download-footer/components/resume-download-button/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/download-footer/components/resume-download-button/index.ts
new file mode 100644
index 0000000000..6526c66726
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/download-footer/components/resume-download-button/index.ts
@@ -0,0 +1 @@
+export * from './ResumeDownloadButton';
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/download-footer/hooks/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/download-footer/hooks/index.ts
new file mode 100644
index 0000000000..b22696f48d
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/download-footer/hooks/index.ts
@@ -0,0 +1,2 @@
+export * from './useShowResumeDownloadButton';
+export * from './useShowConnectionBlockedLabel';
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/download-footer/hooks/useShowConnectionBlockedLabel.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/download-footer/hooks/useShowConnectionBlockedLabel.ts
new file mode 100644
index 0000000000..fc2ebff1a1
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/download-footer/hooks/useShowConnectionBlockedLabel.ts
@@ -0,0 +1,9 @@
+import { useConnectionIsBlocked } from '../../../../../../../../redux/hooks';
+
+export const useShowConnectionBlockedLabel = () => {
+ const { isBlocked } = useConnectionIsBlocked();
+
+ const showConnectionBlocked = isBlocked;
+
+ return showConnectionBlocked;
+};
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/download-footer/hooks/useShowResumeDownloadButton.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/download-footer/hooks/useShowResumeDownloadButton.ts
new file mode 100644
index 0000000000..3009d2235c
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/download-footer/hooks/useShowResumeDownloadButton.ts
@@ -0,0 +1,12 @@
+import { useAppUpgradeEventType } from '../../../../../../../../hooks';
+import { useConnectionIsBlocked } from '../../../../../../../../redux/hooks';
+
+export const useShowResumeDownloadButton = () => {
+ const { isBlocked } = useConnectionIsBlocked();
+ const appUpgradeEventType = useAppUpgradeEventType();
+
+ const showResumeDownloadButton =
+ appUpgradeEventType === 'APP_UPGRADE_STATUS_ABORTED' || isBlocked;
+
+ return showResumeDownloadButton;
+};
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/download-footer/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/download-footer/index.ts
new file mode 100644
index 0000000000..90939788f8
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/download-footer/index.ts
@@ -0,0 +1 @@
+export * from './DownloadFooter';
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/error-footer/ErrorFooter.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/error-footer/ErrorFooter.tsx
new file mode 100644
index 0000000000..60c0056b44
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/error-footer/ErrorFooter.tsx
@@ -0,0 +1,30 @@
+import { Flex, Icon, LabelTiny } from '../../../../../../../lib/components';
+import { DownloadProgress } from '../../../download-progress';
+import { ManualDownloadLink, ReportProblemButton, RetryButton } from './components';
+import { useMessage, useShowManualDownloadLink } from './hooks';
+
+export function ErrorFooter() {
+ const message = useMessage();
+ const showManualDownloadLink = useShowManualDownloadLink();
+
+ return (
+ <Flex $padding="large" $flexDirection="column">
+ <Flex $gap="medium" $flexDirection="column" $margin={{ bottom: 'medium' }}>
+ <Flex $gap="tiny" $flexDirection="row">
+ <div>
+ <Icon size="small" icon="alert-circle" color="red" />
+ </div>
+ <Flex $flexDirection="column">
+ <LabelTiny>{message}</LabelTiny>
+ </Flex>
+ </Flex>
+ <DownloadProgress />
+ </Flex>
+ <Flex $gap="medium" $flexDirection="column">
+ {showManualDownloadLink && <ManualDownloadLink />}
+ <ReportProblemButton />
+ <RetryButton />
+ </Flex>
+ </Flex>
+ );
+}
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/error-footer/components/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/error-footer/components/index.ts
new file mode 100644
index 0000000000..2df959e6ee
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/error-footer/components/index.ts
@@ -0,0 +1,3 @@
+export * from './manual-download-link';
+export * from './report-problem-button';
+export * from './retry-button';
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/error-footer/components/manual-download-link/ManualDownloadLink.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/error-footer/components/manual-download-link/ManualDownloadLink.tsx
new file mode 100644
index 0000000000..fba67726d6
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/error-footer/components/manual-download-link/ManualDownloadLink.tsx
@@ -0,0 +1,26 @@
+import { messages } from '../../../../../../../../../../shared/gettext';
+import { ExternalLink } from '../../../../../../../../../components/ExternalLink';
+import { useDownloadUrl } from './hooks';
+
+export function ManualDownloadLink() {
+ const downloadUrl = useDownloadUrl();
+
+ return (
+ <ExternalLink variant="labelTiny" to={downloadUrl}>
+ <ExternalLink.Text>
+ {
+ // TRANSLATORS: Link shown to optionally manually download the update
+ // TRANSLATORS: due to repeated errors in the upgrade process.
+ messages.pgettext(
+ 'app-upgrade-view',
+ 'Having problems? Try downloading the app from our website',
+ )
+ }
+ </ExternalLink.Text>
+ <ExternalLink.Icon
+ aria-description={messages.pgettext('accessibility', 'Opens externally')}
+ icon="external"
+ />
+ </ExternalLink>
+ );
+}
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/error-footer/components/manual-download-link/hooks/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/error-footer/components/manual-download-link/hooks/index.ts
new file mode 100644
index 0000000000..0da77deb69
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/error-footer/components/manual-download-link/hooks/index.ts
@@ -0,0 +1 @@
+export * from './useDownloadUrl';
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/error-footer/components/manual-download-link/hooks/useDownloadUrl.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/error-footer/components/manual-download-link/hooks/useDownloadUrl.ts
new file mode 100644
index 0000000000..899cec66f4
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/error-footer/components/manual-download-link/hooks/useDownloadUrl.ts
@@ -0,0 +1,10 @@
+import { getDownloadUrl } from '../../../../../../../../../../../shared/version';
+import { useVersionSuggestedIsBeta } from '../../../../../../../../../../redux/hooks';
+
+export const useDownloadUrl = () => {
+ const { suggestedIsBeta } = useVersionSuggestedIsBeta();
+
+ const downloadUrl = getDownloadUrl(suggestedIsBeta);
+
+ return downloadUrl;
+};
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/error-footer/components/manual-download-link/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/error-footer/components/manual-download-link/index.ts
new file mode 100644
index 0000000000..347f2e4283
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/error-footer/components/manual-download-link/index.ts
@@ -0,0 +1 @@
+export * from './ManualDownloadLink';
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/error-footer/components/report-problem-button/ReportProblemButton.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/error-footer/components/report-problem-button/ReportProblemButton.tsx
new file mode 100644
index 0000000000..dcc7daf905
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/error-footer/components/report-problem-button/ReportProblemButton.tsx
@@ -0,0 +1,22 @@
+import { messages } from '../../../../../../../../../../shared/gettext';
+import { usePushProblemReport } from '../../../../../../../../../history/hooks';
+import { Button } from '../../../../../../../../../lib/components';
+
+export function ReportProblemButton() {
+ const pushProblemReport = usePushProblemReport({
+ state: {
+ options: [{ type: 'suppress-outdated-version-warning' }],
+ },
+ });
+
+ return (
+ <Button onClick={pushProblemReport}>
+ <Button.Text>
+ {
+ // TRANSLATORS: Button text to report a problem
+ messages.pgettext('app-upgrade-view', 'Report a problem')
+ }
+ </Button.Text>
+ </Button>
+ );
+}
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/error-footer/components/report-problem-button/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/error-footer/components/report-problem-button/index.ts
new file mode 100644
index 0000000000..808f304f69
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/error-footer/components/report-problem-button/index.ts
@@ -0,0 +1 @@
+export * from './ReportProblemButton';
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/error-footer/components/retry-button/RetryButton.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/error-footer/components/retry-button/RetryButton.tsx
new file mode 100644
index 0000000000..1362742e3c
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/error-footer/components/retry-button/RetryButton.tsx
@@ -0,0 +1,18 @@
+import { useAppUpgradeError } from '../../../../../../../../../redux/hooks';
+import { RetryLaunchInstallerButton, RetryUpgradeButton } from './components';
+
+export function RetryButton() {
+ const { error } = useAppUpgradeError();
+
+ switch (error) {
+ case 'INSTALLER_FAILED':
+ case 'START_INSTALLER_FAILED':
+ return <RetryLaunchInstallerButton />;
+ case 'DOWNLOAD_FAILED':
+ case 'GENERAL_ERROR':
+ case 'VERIFICATION_FAILED':
+ return <RetryUpgradeButton />;
+ default:
+ return null;
+ }
+}
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/error-footer/components/retry-button/components/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/error-footer/components/retry-button/components/index.ts
new file mode 100644
index 0000000000..c44ee15a58
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/error-footer/components/retry-button/components/index.ts
@@ -0,0 +1,2 @@
+export * from './retry-launch-installer-button';
+export * from './retry-upgrade-button';
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/error-footer/components/retry-button/components/retry-launch-installer-button/RetryLaunchInstallerButton.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/error-footer/components/retry-button/components/retry-launch-installer-button/RetryLaunchInstallerButton.tsx
new file mode 100644
index 0000000000..13e236b52a
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/error-footer/components/retry-button/components/retry-launch-installer-button/RetryLaunchInstallerButton.tsx
@@ -0,0 +1,16 @@
+import { messages } from '../../../../../../../../../../../../shared/gettext';
+import { LaunchInstallerButton } from '../../../../../../../launch-installer-button';
+import { useDisabled } from './hooks';
+
+export function RetryLaunchInstallerButton() {
+ const disabled = useDisabled();
+
+ return (
+ <LaunchInstallerButton disabled={disabled}>
+ {
+ // TRANSLATORS: Button text to try again
+ messages.pgettext('app-upgrade-view', 'Retry')
+ }
+ </LaunchInstallerButton>
+ );
+}
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/error-footer/components/retry-button/components/retry-launch-installer-button/hooks/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/error-footer/components/retry-button/components/retry-launch-installer-button/hooks/index.ts
new file mode 100644
index 0000000000..73e963a519
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/error-footer/components/retry-button/components/retry-launch-installer-button/hooks/index.ts
@@ -0,0 +1 @@
+export * from './useDisabled';
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/error-footer/components/retry-button/components/retry-launch-installer-button/hooks/useDisabled.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/error-footer/components/retry-button/components/retry-launch-installer-button/hooks/useDisabled.ts
new file mode 100644
index 0000000000..8b31f7f3b2
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/error-footer/components/retry-button/components/retry-launch-installer-button/hooks/useDisabled.ts
@@ -0,0 +1,9 @@
+import { useAppUpgradeEventType } from '../../../../../../../../../../../../hooks';
+
+export const useDisabled = () => {
+ const appUpgradeEventType = useAppUpgradeEventType();
+
+ const disabled = appUpgradeEventType === 'APP_UPGRADE_STATUS_MANUAL_STARTING_INSTALLER';
+
+ return disabled;
+};
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/error-footer/components/retry-button/components/retry-launch-installer-button/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/error-footer/components/retry-button/components/retry-launch-installer-button/index.ts
new file mode 100644
index 0000000000..21a96c63bc
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/error-footer/components/retry-button/components/retry-launch-installer-button/index.ts
@@ -0,0 +1 @@
+export * from './RetryLaunchInstallerButton';
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/error-footer/components/retry-button/components/retry-upgrade-button/RetryUpgradeButton.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/error-footer/components/retry-button/components/retry-upgrade-button/RetryUpgradeButton.tsx
new file mode 100644
index 0000000000..a609fd94e6
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/error-footer/components/retry-button/components/retry-upgrade-button/RetryUpgradeButton.tsx
@@ -0,0 +1,8 @@
+import { UpgradeButton } from '../../../../../../../upgrade-button';
+import { useMessage } from './hooks';
+
+export function RetryUpgradeButton() {
+ const message = useMessage();
+
+ return <UpgradeButton>{message}</UpgradeButton>;
+}
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/error-footer/components/retry-button/components/retry-upgrade-button/hooks/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/error-footer/components/retry-button/components/retry-upgrade-button/hooks/index.ts
new file mode 100644
index 0000000000..107ba71afc
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/error-footer/components/retry-button/components/retry-upgrade-button/hooks/index.ts
@@ -0,0 +1 @@
+export * from './useMessage';
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/error-footer/components/retry-button/components/retry-upgrade-button/hooks/useMessage.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/error-footer/components/retry-button/components/retry-upgrade-button/hooks/useMessage.ts
new file mode 100644
index 0000000000..faefc8ab79
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/error-footer/components/retry-button/components/retry-upgrade-button/hooks/useMessage.ts
@@ -0,0 +1,14 @@
+import { messages } from '../../../../../../../../../../../../../shared/gettext';
+import { useAppUpgradeError } from '../../../../../../../../../../../../redux/hooks';
+
+export const useMessage = () => {
+ const { error } = useAppUpgradeError();
+
+ if (error === 'DOWNLOAD_FAILED') {
+ // TRANSLATORS: Button text to try download again
+ return messages.pgettext('app-upgrade-view', 'Retry download');
+ }
+
+ // TRANSLATORS: Button text to try again
+ return messages.pgettext('app-upgrade-view', 'Retry');
+};
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/error-footer/components/retry-button/components/retry-upgrade-button/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/error-footer/components/retry-button/components/retry-upgrade-button/index.ts
new file mode 100644
index 0000000000..4aebbc2d17
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/error-footer/components/retry-button/components/retry-upgrade-button/index.ts
@@ -0,0 +1 @@
+export * from './RetryUpgradeButton';
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/error-footer/components/retry-button/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/error-footer/components/retry-button/index.ts
new file mode 100644
index 0000000000..ab7c597651
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/error-footer/components/retry-button/index.ts
@@ -0,0 +1 @@
+export * from './RetryButton';
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/error-footer/hooks/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/error-footer/hooks/index.ts
new file mode 100644
index 0000000000..347c6f1b98
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/error-footer/hooks/index.ts
@@ -0,0 +1,2 @@
+export * from './useMessage';
+export * from './useShowManualDownloadLink';
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/error-footer/hooks/useMessage.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/error-footer/hooks/useMessage.ts
new file mode 100644
index 0000000000..f8380b6240
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/error-footer/hooks/useMessage.ts
@@ -0,0 +1,40 @@
+import { messages } from '../../../../../../../../../shared/gettext';
+import { useAppUpgradeError } from '../../../../../../../../redux/hooks';
+
+export const useMessage = () => {
+ const { error } = useAppUpgradeError();
+
+ switch (error) {
+ case 'DOWNLOAD_FAILED':
+ // TRANSLATORS: Label displayed when an error occurred due to the download failing
+ return messages.pgettext(
+ 'app-upgrade-view',
+ 'Download failed, please check your connection/firewall and try again, or send a problem report.',
+ );
+ case 'INSTALLER_FAILED':
+ // TRANSLATORS: Label displayed when an error occurred within the installer
+ return messages.pgettext(
+ 'app-upgrade-view',
+ 'Installer quit unexpectedly, please try again or send a problem report.',
+ );
+ case 'START_INSTALLER_FAILED':
+ // TRANSLATORS: Label displayed when an error occurred due to the installer failing to start
+ // TRANSLATORS: and the suggested resolution is to download the update again.
+ return messages.pgettext(
+ 'app-upgrade-view',
+ 'Could not open installer, please try again or send a problem report.',
+ );
+ case 'VERIFICATION_FAILED':
+ // TRANSLATORS: Label displayed when an error occurred due to the installer failed verification
+ return messages.pgettext(
+ 'app-upgrade-view',
+ 'Verification failed, please try again or send a problem report.',
+ );
+ default:
+ // TRANSLATORS: Label displayed when an unknown error occurred
+ return messages.pgettext(
+ 'app-upgrade-view',
+ 'Unknown error occurred. Please try again or send a problem report.',
+ );
+ }
+};
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/error-footer/hooks/useShowManualDownloadLink.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/error-footer/hooks/useShowManualDownloadLink.ts
new file mode 100644
index 0000000000..13e1463dfc
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/error-footer/hooks/useShowManualDownloadLink.ts
@@ -0,0 +1,9 @@
+import { useErrorCountExceeded } from '../../../../../hooks';
+
+export const useShowManualDownloadLink = () => {
+ const errorCountExceeded = useErrorCountExceeded();
+
+ const showManualDownloadLink = errorCountExceeded;
+
+ return showManualDownloadLink;
+};
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/error-footer/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/error-footer/index.ts
new file mode 100644
index 0000000000..a91203d9e2
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/error-footer/index.ts
@@ -0,0 +1 @@
+export * from './ErrorFooter';
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/index.ts
new file mode 100644
index 0000000000..bb40d3f2fa
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/index.ts
@@ -0,0 +1,6 @@
+export * from './download-footer';
+export * from './error-footer';
+export * from './initial-footer';
+export * from './launch-footer';
+export * from './pause-footer';
+export * from './verify-footer';
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/initial-footer/InitialFooter.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/initial-footer/InitialFooter.tsx
new file mode 100644
index 0000000000..5860dc5d7f
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/initial-footer/InitialFooter.tsx
@@ -0,0 +1,17 @@
+import { ConnectionBlocked, InstallerReady, StartUpgrade } from './components';
+import { useShowConnectionBlocked, useShowInstallerReady } from './hooks';
+
+export function InitialFooter() {
+ const showConnectionBlocked = useShowConnectionBlocked();
+ const showInstallerReady = useShowInstallerReady();
+
+ if (showInstallerReady) {
+ return <InstallerReady />;
+ }
+
+ if (showConnectionBlocked) {
+ return <ConnectionBlocked />;
+ }
+
+ return <StartUpgrade />;
+}
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/initial-footer/components/connection-blocked/ConnectionBlocked.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/initial-footer/components/connection-blocked/ConnectionBlocked.tsx
new file mode 100644
index 0000000000..36d1ae56f8
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/initial-footer/components/connection-blocked/ConnectionBlocked.tsx
@@ -0,0 +1,22 @@
+import { messages } from '../../../../../../../../../../shared/gettext';
+import { Flex } from '../../../../../../../../../lib/components';
+import { ConnectionBlockedLabel } from '../../../../../connection-blocked-label';
+import { UpgradeButton } from '../../../../../upgrade-button';
+
+export function ConnectionBlocked() {
+ return (
+ <Flex $padding="large" $flexDirection="column">
+ <Flex $gap="medium" $flexDirection="column" $margin={{ bottom: 'medium' }}>
+ <ConnectionBlockedLabel />
+ </Flex>
+ <Flex $flexDirection="column">
+ <UpgradeButton disabled>
+ {
+ // TRANSLATORS: Button text to download and install an update
+ messages.pgettext('app-upgrade-view', 'Download & install')
+ }
+ </UpgradeButton>
+ </Flex>
+ </Flex>
+ );
+}
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/initial-footer/components/connection-blocked/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/initial-footer/components/connection-blocked/index.ts
new file mode 100644
index 0000000000..c24311f6b3
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/initial-footer/components/connection-blocked/index.ts
@@ -0,0 +1 @@
+export * from './ConnectionBlocked';
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/initial-footer/components/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/initial-footer/components/index.ts
new file mode 100644
index 0000000000..32f6ebddb2
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/initial-footer/components/index.ts
@@ -0,0 +1,3 @@
+export * from './connection-blocked';
+export * from './installer-ready';
+export * from './start-upgrade';
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/initial-footer/components/installer-ready/InstallerReady.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/initial-footer/components/installer-ready/InstallerReady.tsx
new file mode 100644
index 0000000000..11a10354dd
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/initial-footer/components/installer-ready/InstallerReady.tsx
@@ -0,0 +1,31 @@
+import { messages } from '../../../../../../../../../../shared/gettext';
+import { Flex, Icon, LabelTiny } from '../../../../../../../../../lib/components';
+import { DownloadProgress } from '../../../../../download-progress';
+import { LaunchInstallerButton } from '../../../../../launch-installer-button';
+
+export function InstallerReady() {
+ return (
+ <Flex $padding="large" $flexDirection="column">
+ <Flex $gap="medium" $flexDirection="column" $margin={{ bottom: 'medium' }}>
+ <Flex $gap="tiny" $alignItems="center">
+ <Icon icon="checkmark" color="green" size="small" />
+ <LabelTiny>
+ {
+ // TRANSLATORS: Label displayed above a progress bar when the update is verified successfully
+ messages.pgettext('app-upgrade-view', 'Verification successful!')
+ }
+ </LabelTiny>
+ </Flex>
+ <DownloadProgress />
+ </Flex>
+ <Flex $flexDirection="column">
+ <LaunchInstallerButton>
+ {
+ // TRANSLATORS: Button text to install an update
+ messages.pgettext('app-upgrade-view', 'Install update')
+ }
+ </LaunchInstallerButton>
+ </Flex>
+ </Flex>
+ );
+}
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/initial-footer/components/installer-ready/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/initial-footer/components/installer-ready/index.ts
new file mode 100644
index 0000000000..ab9fed2358
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/initial-footer/components/installer-ready/index.ts
@@ -0,0 +1 @@
+export * from './InstallerReady';
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/initial-footer/components/start-upgrade/StartUpgrade.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/initial-footer/components/start-upgrade/StartUpgrade.tsx
new file mode 100644
index 0000000000..cf6f6562af
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/initial-footer/components/start-upgrade/StartUpgrade.tsx
@@ -0,0 +1,18 @@
+import { messages } from '../../../../../../../../../../shared/gettext';
+import { Flex } from '../../../../../../../../../lib/components';
+import { UpgradeButton } from '../../../../../upgrade-button';
+
+export function StartUpgrade() {
+ return (
+ <Flex $padding="large" $flexDirection="column">
+ <Flex $flexDirection="column">
+ <UpgradeButton>
+ {
+ // TRANSLATORS: Button text to download and install an update
+ messages.pgettext('app-upgrade-view', 'Download & install')
+ }
+ </UpgradeButton>
+ </Flex>
+ </Flex>
+ );
+}
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/initial-footer/components/start-upgrade/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/initial-footer/components/start-upgrade/index.ts
new file mode 100644
index 0000000000..a68d15c4fa
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/initial-footer/components/start-upgrade/index.ts
@@ -0,0 +1 @@
+export * from './StartUpgrade';
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/initial-footer/hooks/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/initial-footer/hooks/index.ts
new file mode 100644
index 0000000000..6e318620d7
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/initial-footer/hooks/index.ts
@@ -0,0 +1,2 @@
+export * from './useShowConnectionBlocked';
+export * from './useShowInstallerReady';
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/initial-footer/hooks/useShowConnectionBlocked.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/initial-footer/hooks/useShowConnectionBlocked.ts
new file mode 100644
index 0000000000..8384c9cd9a
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/initial-footer/hooks/useShowConnectionBlocked.ts
@@ -0,0 +1,9 @@
+import { useConnectionIsBlocked } from '../../../../../../../../redux/hooks';
+
+export const useShowConnectionBlocked = () => {
+ const { isBlocked } = useConnectionIsBlocked();
+
+ const showConnectionBlocked = isBlocked;
+
+ return showConnectionBlocked;
+};
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/initial-footer/hooks/useShowInstallerReady.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/initial-footer/hooks/useShowInstallerReady.ts
new file mode 100644
index 0000000000..fce1837766
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/initial-footer/hooks/useShowInstallerReady.ts
@@ -0,0 +1,9 @@
+import { useHasAppUpgradeVerifiedInstallerPath } from '../../../../../../../../hooks';
+
+export const useShowInstallerReady = () => {
+ const hasAppUpgradeVerifiedInstallerPath = useHasAppUpgradeVerifiedInstallerPath();
+
+ const showInstallerReady = hasAppUpgradeVerifiedInstallerPath;
+
+ return showInstallerReady;
+};
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/initial-footer/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/initial-footer/index.ts
new file mode 100644
index 0000000000..a116a490b3
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/initial-footer/index.ts
@@ -0,0 +1 @@
+export * from './InitialFooter';
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/launch-footer/LaunchFooter.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/launch-footer/LaunchFooter.tsx
new file mode 100644
index 0000000000..a05afe356e
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/launch-footer/LaunchFooter.tsx
@@ -0,0 +1,30 @@
+import { messages } from '../../../../../../../../shared/gettext';
+import { Flex, Icon, LabelTiny } from '../../../../../../../lib/components';
+import { DownloadProgress } from '../../../download-progress';
+import { LaunchInstallerButton } from '../../../launch-installer-button';
+import { useDisabled, useMessage } from './hooks';
+
+export function LaunchFooter() {
+ const disabled = useDisabled();
+ const message = useMessage();
+
+ return (
+ <Flex $padding="large" $flexDirection="column">
+ <Flex $gap="medium" $flexDirection="column" $margin={{ bottom: 'medium' }}>
+ <Flex $gap="tiny" $alignItems="center">
+ <Icon icon="checkmark" color="green" size="small" />
+ <LabelTiny>
+ {
+ // TRANSLATORS: Label displayed above a progress bar when the update is verified successfully
+ messages.pgettext('app-upgrade-view', 'Verification successful!')
+ }
+ </LabelTiny>
+ </Flex>
+ <DownloadProgress />
+ </Flex>
+ <Flex $flexDirection="column">
+ <LaunchInstallerButton disabled={disabled}>{message}</LaunchInstallerButton>
+ </Flex>
+ </Flex>
+ );
+}
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/launch-footer/hooks/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/launch-footer/hooks/index.ts
new file mode 100644
index 0000000000..081bb03213
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/launch-footer/hooks/index.ts
@@ -0,0 +1,2 @@
+export * from './useDisabled';
+export * from './useMessage';
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/launch-footer/hooks/useDisabled.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/launch-footer/hooks/useDisabled.ts
new file mode 100644
index 0000000000..53c1385ff8
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/launch-footer/hooks/useDisabled.ts
@@ -0,0 +1,12 @@
+import { useAppUpgradeEventType } from '../../../../../../../../hooks';
+
+export const useDisabled = () => {
+ const appUpgradeEventType = useAppUpgradeEventType();
+
+ const disabled =
+ appUpgradeEventType === 'APP_UPGRADE_STATUS_AUTOMATIC_STARTING_INSTALLER' ||
+ appUpgradeEventType === 'APP_UPGRADE_STATUS_MANUAL_STARTING_INSTALLER' ||
+ appUpgradeEventType === 'APP_UPGRADE_STATUS_STARTED_INSTALLER';
+
+ return disabled;
+};
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/launch-footer/hooks/useMessage.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/launch-footer/hooks/useMessage.ts
new file mode 100644
index 0000000000..ce07d0c1bf
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/launch-footer/hooks/useMessage.ts
@@ -0,0 +1,14 @@
+import { messages } from '../../../../../../../../../shared/gettext';
+import { useDisabled } from './useDisabled';
+
+export const useMessage = () => {
+ const disabled = useDisabled();
+
+ if (disabled) {
+ // TRANSLATORS: Button text to when starting the installer for an update
+ return messages.pgettext('app-upgrade-view', 'Starting installer...');
+ }
+
+ // TRANSLATORS: Button text to install an update
+ return messages.pgettext('app-upgrade-view', 'Install update');
+};
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/launch-footer/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/launch-footer/index.ts
new file mode 100644
index 0000000000..2316de43eb
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/launch-footer/index.ts
@@ -0,0 +1 @@
+export * from './LaunchFooter';
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/pause-footer/PauseFooter.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/pause-footer/PauseFooter.tsx
new file mode 100644
index 0000000000..d55dea497f
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/pause-footer/PauseFooter.tsx
@@ -0,0 +1,12 @@
+import { ConnectionBlocked, ResumeUpgrade } from './components';
+import { useShowConnectionBlocked } from './hooks';
+
+export function PauseFooter() {
+ const showConnectionBlocked = useShowConnectionBlocked();
+
+ if (showConnectionBlocked) {
+ return <ConnectionBlocked />;
+ }
+
+ return <ResumeUpgrade />;
+}
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/pause-footer/components/connection-blocked/ConnectionBlocked.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/pause-footer/components/connection-blocked/ConnectionBlocked.tsx
new file mode 100644
index 0000000000..96909c2a15
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/pause-footer/components/connection-blocked/ConnectionBlocked.tsx
@@ -0,0 +1,24 @@
+import { messages } from '../../../../../../../../../../shared/gettext';
+import { Flex } from '../../../../../../../../../lib/components';
+import { ConnectionBlockedLabel } from '../../../../../connection-blocked-label';
+import { DownloadProgress } from '../../../../../download-progress';
+import { ResumeButton } from '../../../../../resume-button';
+
+export function ConnectionBlocked() {
+ return (
+ <Flex $padding="large" $flexDirection="column">
+ <Flex $gap="medium" $flexDirection="column" $margin={{ bottom: 'medium' }}>
+ <ConnectionBlockedLabel />
+ <DownloadProgress />
+ </Flex>
+ <Flex $flexDirection="column">
+ <ResumeButton disabled>
+ {
+ // TRANSLATORS: Button text to resume updating
+ messages.pgettext('app-upgrade-view', 'Resume')
+ }
+ </ResumeButton>
+ </Flex>
+ </Flex>
+ );
+}
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/pause-footer/components/connection-blocked/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/pause-footer/components/connection-blocked/index.ts
new file mode 100644
index 0000000000..c24311f6b3
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/pause-footer/components/connection-blocked/index.ts
@@ -0,0 +1 @@
+export * from './ConnectionBlocked';
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/pause-footer/components/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/pause-footer/components/index.ts
new file mode 100644
index 0000000000..32d7f6aa0b
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/pause-footer/components/index.ts
@@ -0,0 +1,2 @@
+export * from './connection-blocked';
+export * from './resume-upgrade';
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/pause-footer/components/resume-upgrade/ResumeUpgrade.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/pause-footer/components/resume-upgrade/ResumeUpgrade.tsx
new file mode 100644
index 0000000000..8f2fc26879
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/pause-footer/components/resume-upgrade/ResumeUpgrade.tsx
@@ -0,0 +1,28 @@
+import { messages } from '../../../../../../../../../../shared/gettext';
+import { Flex, LabelTiny } from '../../../../../../../../../lib/components';
+import { DownloadProgress } from '../../../../../download-progress';
+import { ResumeButton } from '../../../../../resume-button';
+
+export function ResumeUpgrade() {
+ return (
+ <Flex $padding="large" $flexDirection="column">
+ <Flex $gap="medium" $flexDirection="column" $margin={{ bottom: 'medium' }}>
+ <LabelTiny>
+ {
+ // TRANSLATORS: Label displayed above a progress bar when the update is verified successfully
+ messages.pgettext('app-upgrade-view', 'Download paused')
+ }
+ </LabelTiny>
+ <DownloadProgress />
+ </Flex>
+ <Flex $flexDirection="column">
+ <ResumeButton>
+ {
+ // TRANSLATORS: Button text to resume updating
+ messages.pgettext('app-upgrade-view', 'Resume')
+ }
+ </ResumeButton>
+ </Flex>
+ </Flex>
+ );
+}
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/pause-footer/components/resume-upgrade/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/pause-footer/components/resume-upgrade/index.ts
new file mode 100644
index 0000000000..66ce331576
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/pause-footer/components/resume-upgrade/index.ts
@@ -0,0 +1 @@
+export * from './ResumeUpgrade';
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/pause-footer/hooks/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/pause-footer/hooks/index.ts
new file mode 100644
index 0000000000..6e318620d7
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/pause-footer/hooks/index.ts
@@ -0,0 +1,2 @@
+export * from './useShowConnectionBlocked';
+export * from './useShowInstallerReady';
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/pause-footer/hooks/useShowConnectionBlocked.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/pause-footer/hooks/useShowConnectionBlocked.ts
new file mode 100644
index 0000000000..8384c9cd9a
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/pause-footer/hooks/useShowConnectionBlocked.ts
@@ -0,0 +1,9 @@
+import { useConnectionIsBlocked } from '../../../../../../../../redux/hooks';
+
+export const useShowConnectionBlocked = () => {
+ const { isBlocked } = useConnectionIsBlocked();
+
+ const showConnectionBlocked = isBlocked;
+
+ return showConnectionBlocked;
+};
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/pause-footer/hooks/useShowInstallerReady.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/pause-footer/hooks/useShowInstallerReady.ts
new file mode 100644
index 0000000000..fce1837766
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/pause-footer/hooks/useShowInstallerReady.ts
@@ -0,0 +1,9 @@
+import { useHasAppUpgradeVerifiedInstallerPath } from '../../../../../../../../hooks';
+
+export const useShowInstallerReady = () => {
+ const hasAppUpgradeVerifiedInstallerPath = useHasAppUpgradeVerifiedInstallerPath();
+
+ const showInstallerReady = hasAppUpgradeVerifiedInstallerPath;
+
+ return showInstallerReady;
+};
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/pause-footer/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/pause-footer/index.ts
new file mode 100644
index 0000000000..d44a21614c
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/pause-footer/index.ts
@@ -0,0 +1 @@
+export * from './PauseFooter';
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/verify-footer/VerifyFooter.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/verify-footer/VerifyFooter.tsx
new file mode 100644
index 0000000000..a44548e642
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/verify-footer/VerifyFooter.tsx
@@ -0,0 +1,31 @@
+import { messages } from '../../../../../../../../shared/gettext';
+import { Flex, LabelTiny, Spinner } from '../../../../../../../lib/components';
+import { DownloadProgress } from '../../../download-progress';
+import { PauseButton } from '../../../pause-button';
+
+export function VerifyFooter() {
+ return (
+ <Flex $padding="large" $flexDirection="column">
+ <Flex $gap="medium" $flexDirection="column" $margin={{ bottom: 'medium' }}>
+ <Flex $gap="tiny" $alignItems="center">
+ <Spinner size="small" />
+ <LabelTiny>
+ {
+ // TRANSLATORS: Label displayed above a progress bar when the update is being verified
+ messages.pgettext('app-upgrade-view', 'Verifying installer...')
+ }
+ </LabelTiny>
+ </Flex>
+ <DownloadProgress />
+ </Flex>
+ <Flex $flexDirection="column">
+ <PauseButton disabled>
+ {
+ // TRANSLATORS: Button text to pause the download of an update
+ messages.pgettext('app-upgrade-view', 'Pause')
+ }
+ </PauseButton>
+ </Flex>
+ </Flex>
+ );
+}
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/verify-footer/components/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/verify-footer/components/index.ts
new file mode 100644
index 0000000000..4dc62c15da
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/verify-footer/components/index.ts
@@ -0,0 +1 @@
+export * from './pause-button';
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/verify-footer/components/pause-button/PauseButton.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/verify-footer/components/pause-button/PauseButton.tsx
new file mode 100644
index 0000000000..7b029a7368
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/verify-footer/components/pause-button/PauseButton.tsx
@@ -0,0 +1,18 @@
+import { messages } from '../../../../../../../../../../shared/gettext';
+import { useAppContext } from '../../../../../../../../../context';
+import { Button } from '../../../../../../../../../lib/components';
+
+export function PauseButton() {
+ const { appUpgradeAbort } = useAppContext();
+
+ return (
+ <Button disabled onClick={appUpgradeAbort}>
+ <Button.Text>
+ {
+ // TRANSLATORS: Button text to pause the download of an update
+ messages.pgettext('app-upgrade-view', 'Pause')
+ }
+ </Button.Text>
+ </Button>
+ );
+}
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/verify-footer/components/pause-button/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/verify-footer/components/pause-button/index.ts
new file mode 100644
index 0000000000..dccc1184a8
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/verify-footer/components/pause-button/index.ts
@@ -0,0 +1 @@
+export * from './PauseButton';
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/verify-footer/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/verify-footer/index.ts
new file mode 100644
index 0000000000..9a0b44ab0c
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/components/verify-footer/index.ts
@@ -0,0 +1 @@
+export * from './VerifyFooter';
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/hooks/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/hooks/index.ts
new file mode 100644
index 0000000000..4d2feba819
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/hooks/index.ts
@@ -0,0 +1 @@
+export * from './useStep';
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/hooks/useStep.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/hooks/useStep.ts
new file mode 100644
index 0000000000..55c6659969
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/hooks/useStep.ts
@@ -0,0 +1,16 @@
+import { AppUpgradeStep } from '../../../../../../../shared/app-upgrade';
+import { useAppUpgradeEventType, useHasAppUpgradeError } from '../../../../../../hooks';
+import { convertEventTypeToStep } from '../../../../../../redux/app-upgrade/helpers';
+import { useConnectionIsBlocked } from '../../../../../../redux/hooks';
+
+export const useStep = (): AppUpgradeStep => {
+ const { isBlocked } = useConnectionIsBlocked();
+ const appUpgradeEventType = useAppUpgradeEventType();
+ const hasAppUpgradeError = useHasAppUpgradeError();
+
+ if (hasAppUpgradeError && !isBlocked) {
+ return 'error';
+ }
+
+ return convertEventTypeToStep(appUpgradeEventType);
+};
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/index.ts
new file mode 100644
index 0000000000..ddcc5a9cd1
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/footer/index.ts
@@ -0,0 +1 @@
+export * from './Footer';
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/index.ts
new file mode 100644
index 0000000000..f90ad28315
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/index.ts
@@ -0,0 +1,8 @@
+export * from './connection-blocked-label';
+export * from './download-progress';
+export * from './footer';
+export * from './launch-installer-button';
+export * from './pause-button';
+export * from './resume-button';
+export * from './upgrade-button';
+export * from './upgrade-details';
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/launch-installer-button/LaunchInstallerButton.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/launch-installer-button/LaunchInstallerButton.tsx
new file mode 100644
index 0000000000..b27be2327d
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/launch-installer-button/LaunchInstallerButton.tsx
@@ -0,0 +1,22 @@
+import { messages } from '../../../../../../shared/gettext';
+import { useAppContext } from '../../../../../context';
+import { Button } from '../../../../../lib/components';
+
+export type LaunchInstallerButtonProps = {
+ children?: React.ReactNode;
+ disabled?: boolean;
+};
+
+export function LaunchInstallerButton({ children, disabled }: LaunchInstallerButtonProps) {
+ const { appUpgradeInstallerStart } = useAppContext();
+
+ return (
+ <Button disabled={disabled} onClick={appUpgradeInstallerStart}>
+ <Button.Text>
+ {children ||
+ // TRANSLATORS: Button text to install an update
+ messages.pgettext('app-upgrade-view', 'Install update')}
+ </Button.Text>
+ </Button>
+ );
+}
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/launch-installer-button/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/launch-installer-button/index.ts
new file mode 100644
index 0000000000..3b631732be
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/launch-installer-button/index.ts
@@ -0,0 +1 @@
+export * from './LaunchInstallerButton';
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/pause-button/PauseButton.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/pause-button/PauseButton.tsx
new file mode 100644
index 0000000000..37c820ca3e
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/pause-button/PauseButton.tsx
@@ -0,0 +1,17 @@
+import { useAppContext } from '../../../../../context';
+import { Button } from '../../../../../lib/components';
+
+export type PauseButtonProps = {
+ children?: React.ReactNode;
+ disabled?: boolean;
+};
+
+export function PauseButton({ children, disabled }: PauseButtonProps) {
+ const { appUpgradeAbort } = useAppContext();
+
+ return (
+ <Button disabled={disabled} onClick={appUpgradeAbort}>
+ <Button.Text>{children}</Button.Text>
+ </Button>
+ );
+}
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/pause-button/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/pause-button/index.ts
new file mode 100644
index 0000000000..dccc1184a8
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/pause-button/index.ts
@@ -0,0 +1 @@
+export * from './PauseButton';
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/resume-button/ResumeButton.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/resume-button/ResumeButton.tsx
new file mode 100644
index 0000000000..cf367d80b6
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/resume-button/ResumeButton.tsx
@@ -0,0 +1,17 @@
+import { useAppContext } from '../../../../../context';
+import { Button } from '../../../../../lib/components';
+
+export type ResumeButtonProps = {
+ children?: React.ReactNode;
+ disabled?: boolean;
+};
+
+export function ResumeButton({ children, disabled }: ResumeButtonProps) {
+ const { appUpgrade } = useAppContext();
+
+ return (
+ <Button disabled={disabled} onClick={appUpgrade}>
+ <Button.Text>{children}</Button.Text>
+ </Button>
+ );
+}
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/resume-button/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/resume-button/index.ts
new file mode 100644
index 0000000000..9167f9ef68
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/resume-button/index.ts
@@ -0,0 +1 @@
+export * from './ResumeButton';
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/upgrade-button/UpgradeButton.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/upgrade-button/UpgradeButton.tsx
new file mode 100644
index 0000000000..625332b858
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/upgrade-button/UpgradeButton.tsx
@@ -0,0 +1,17 @@
+import { useAppContext } from '../../../../../context';
+import { Button } from '../../../../../lib/components';
+
+export type UpgradeButtonProps = {
+ children?: React.ReactNode;
+ disabled?: boolean;
+};
+
+export function UpgradeButton({ children, disabled }: UpgradeButtonProps) {
+ const { appUpgrade } = useAppContext();
+
+ return (
+ <Button disabled={disabled} onClick={appUpgrade}>
+ <Button.Text>{children}</Button.Text>
+ </Button>
+ );
+}
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/upgrade-button/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/upgrade-button/index.ts
new file mode 100644
index 0000000000..472b50ea34
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/upgrade-button/index.ts
@@ -0,0 +1 @@
+export * from './UpgradeButton';
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/upgrade-details/UpgradeDetails.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/upgrade-details/UpgradeDetails.tsx
new file mode 100644
index 0000000000..cac6f59b9c
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/upgrade-details/UpgradeDetails.tsx
@@ -0,0 +1,52 @@
+import { messages } from '../../../../../../shared/gettext';
+import { Container, Flex, TitleBig, TitleLarge } from '../../../../../lib/components';
+import { AppNavigationHeader } from '../../../../app-navigation-header';
+import { ChangelogList } from '../../../../changelog-list';
+import { SettingsContainer } from '../../../../Layout';
+import { NavigationContainer } from '../../../../NavigationContainer';
+import { NavigationScrollbars } from '../../../../NavigationScrollbars';
+import { NoChangelogUpdates } from './components';
+import { useChangelog, useShowChangelogList, useTitle } from './hooks';
+
+export function UpgradeDetails() {
+ const changelog = useChangelog();
+ const showChangelogList = useShowChangelogList();
+ const title = useTitle();
+
+ return (
+ <SettingsContainer>
+ <NavigationContainer>
+ <AppNavigationHeader
+ title={
+ // TRANSLATORS: Title in navigation bar
+ messages.pgettext('app-upgrade-view', 'Update available')
+ }
+ />
+ <NavigationScrollbars>
+ <Flex $flexDirection="column" $gap="large" $padding={{ bottom: 'medium' }}>
+ <Container size="4">
+ <TitleBig as="h2">
+ {
+ // TRANSLATORS: Main title for the update available view
+ messages.pgettext('app-upgrade-view', 'Update available')
+ }
+ </TitleBig>
+ </Container>
+ <Flex $flexDirection="column" $gap="small">
+ <Container size="4">
+ <TitleLarge as="h2">{title}</TitleLarge>
+ </Container>
+ <Container size="3" $flexDirection="column">
+ {showChangelogList ? (
+ <ChangelogList changelog={changelog} />
+ ) : (
+ <NoChangelogUpdates />
+ )}
+ </Container>
+ </Flex>
+ </Flex>
+ </NavigationScrollbars>
+ </NavigationContainer>
+ </SettingsContainer>
+ );
+}
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/upgrade-details/components/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/upgrade-details/components/index.ts
new file mode 100644
index 0000000000..e838cd8521
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/upgrade-details/components/index.ts
@@ -0,0 +1 @@
+export * from './no-changelog-updates';
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/upgrade-details/components/no-changelog-updates/NoChangelogUpdates.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/upgrade-details/components/no-changelog-updates/NoChangelogUpdates.tsx
new file mode 100644
index 0000000000..6621c8d4fe
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/upgrade-details/components/no-changelog-updates/NoChangelogUpdates.tsx
@@ -0,0 +1,16 @@
+import { messages } from '../../../../../../../../shared/gettext';
+import { BodySmall } from '../../../../../../../lib/components';
+
+export function NoChangelogUpdates() {
+ return (
+ <BodySmall color="whiteAlpha60">
+ {
+ // TRANSLATORS: Text displayed when there are no updates for this platform in the next app version
+ messages.pgettext(
+ 'app-upgrade-view',
+ 'No updates or changes were made in this release for this platform.',
+ )
+ }
+ </BodySmall>
+ );
+}
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/upgrade-details/components/no-changelog-updates/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/upgrade-details/components/no-changelog-updates/index.ts
new file mode 100644
index 0000000000..095239a335
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/upgrade-details/components/no-changelog-updates/index.ts
@@ -0,0 +1 @@
+export * from './NoChangelogUpdates';
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/upgrade-details/hooks/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/upgrade-details/hooks/index.ts
new file mode 100644
index 0000000000..41cfba9f0a
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/upgrade-details/hooks/index.ts
@@ -0,0 +1,3 @@
+export * from './useChangelog';
+export * from './useShowChangelogList';
+export * from './useTitle';
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/upgrade-details/hooks/useChangelog.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/upgrade-details/hooks/useChangelog.ts
new file mode 100644
index 0000000000..25bcb761e7
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/upgrade-details/hooks/useChangelog.ts
@@ -0,0 +1,17 @@
+import { useMemo } from 'react';
+
+import { useVersionSuggestedUpgrade } from '../../../../../../redux/hooks';
+
+export const useChangelog = () => {
+ const { suggestedUpgrade } = useVersionSuggestedUpgrade();
+
+ const changelogMemo = useMemo(() => {
+ if (suggestedUpgrade) {
+ return suggestedUpgrade.changelog;
+ }
+
+ return [];
+ }, [suggestedUpgrade]);
+
+ return changelogMemo;
+};
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/upgrade-details/hooks/useShowChangelogList.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/upgrade-details/hooks/useShowChangelogList.ts
new file mode 100644
index 0000000000..d71289c21d
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/upgrade-details/hooks/useShowChangelogList.ts
@@ -0,0 +1,9 @@
+import { useChangelog } from './useChangelog';
+
+export const useShowChangelogList = () => {
+ const changeLog = useChangelog();
+
+ const showChangelogList = changeLog.length > 0;
+
+ return showChangelogList;
+};
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/upgrade-details/hooks/useTitle.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/upgrade-details/hooks/useTitle.ts
new file mode 100644
index 0000000000..0741d54ea3
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/upgrade-details/hooks/useTitle.ts
@@ -0,0 +1,20 @@
+import { sprintf } from 'sprintf-js';
+
+import { messages } from '../../../../../../../shared/gettext';
+import { useVersionSuggestedUpgrade } from '../../../../../../redux/hooks';
+
+export const useTitle = () => {
+ const { suggestedUpgrade } = useVersionSuggestedUpgrade();
+
+ const title = sprintf(
+ // TRANSLATORS: Heading which shows the version of the app which can be upgraded to.
+ // TRANSLATORS: Available placeholders:
+ // TRANSLATORS: %(version)s - The new version of the app.
+ messages.pgettext('app-upgrade-view', 'Version %(version)s'),
+ {
+ version: suggestedUpgrade?.version,
+ },
+ );
+
+ return title;
+};
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/upgrade-details/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/upgrade-details/index.ts
new file mode 100644
index 0000000000..307c9ef208
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/components/upgrade-details/index.ts
@@ -0,0 +1 @@
+export * from './UpgradeDetails';
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/hooks/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/hooks/index.ts
new file mode 100644
index 0000000000..edb9a49686
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/hooks/index.ts
@@ -0,0 +1 @@
+export * from './useErrorCountExceeded';
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/hooks/useErrorCountExceeded.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/hooks/useErrorCountExceeded.ts
new file mode 100644
index 0000000000..cd3ff561a6
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/hooks/useErrorCountExceeded.ts
@@ -0,0 +1,9 @@
+import { useAppUpgradeErrorCount } from '../../../../redux/hooks';
+
+export const useErrorCountExceeded = () => {
+ const { errorCount } = useAppUpgradeErrorCount();
+
+ const errorCountExceeded = errorCount >= 3;
+
+ return errorCountExceeded;
+};
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/index.ts
new file mode 100644
index 0000000000..481653229f
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/app-upgrade/index.ts
@@ -0,0 +1 @@
+export * from './AppUpgradeView';
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/changelog/ChangelogView.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/views/changelog/ChangelogView.tsx
index 32bf4e7a9e..8ebc20a262 100644
--- a/desktop/packages/mullvad-vpn/src/renderer/components/views/changelog/ChangelogView.tsx
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/changelog/ChangelogView.tsx
@@ -1,60 +1,55 @@
-import styled from 'styled-components';
-
import { messages } from '../../../../shared/gettext';
-import { BodySmall, Container, Flex, TitleBig, TitleLarge } from '../../../lib/components';
+import { Container, Flex, TitleBig, TitleLarge } from '../../../lib/components';
import { useHistory } from '../../../lib/history';
-import { useSelector } from '../../../redux/store';
+import { useVersionCurrent } from '../../../redux/hooks';
import { AppNavigationHeader } from '../../';
+import { ChangelogList } from '../../changelog-list';
import { BackAction } from '../../KeyboardNavigation';
import { Layout, SettingsContainer } from '../../Layout';
import { NavigationContainer } from '../../NavigationContainer';
import { NavigationScrollbars } from '../../NavigationScrollbars';
-
-const StyledList = styled(Flex)({
- listStyleType: 'disc',
- paddingLeft: 0,
- li: {
- marginLeft: '1.5em',
- },
-});
+import { NoChangelogUpdates } from './components';
+import { useChangelog, useShowChangelogList } from './hooks';
export const ChangelogView = () => {
const { pop } = useHistory();
- const changelog = useSelector((state) => state.userInterface.changelog);
- const version = useSelector((state) => state.version.current);
+ const { current } = useVersionCurrent();
+ const changelog = useChangelog();
+ const showChangelogList = useShowChangelogList();
return (
<BackAction action={pop}>
<Layout>
<SettingsContainer>
<NavigationContainer>
- <AppNavigationHeader title={messages.pgettext('changelog-view', 'What’s new')} />
+ <AppNavigationHeader
+ title={
+ // TRANSLATORS: Heading for the view of the changes and updates in the
+ // TRANSLATORS: current version compared to the old version.
+ messages.pgettext('changelog-view', 'What’s new')
+ }
+ />
<NavigationScrollbars>
<Flex $flexDirection="column" $gap="large">
<Container size="4">
- <TitleBig as={'h1'}>{messages.pgettext('changelog-view', 'What’s new')}</TitleBig>
+ <TitleBig as="h1">
+ {
+ // TRANSLATORS: Heading for the view of the changes and updates in the
+ // TRANSLATORS: current version compared to the old version.
+ messages.pgettext('changelog-view', 'What’s new')
+ }
+ </TitleBig>
</Container>
<Flex $flexDirection="column" $gap="small">
<Container size="4">
- <TitleLarge as="h2">{version}</TitleLarge>
+ <TitleLarge as="h2">{current}</TitleLarge>
</Container>
<Container size="3" $flexDirection="column">
- {changelog.length ? (
- <StyledList as="ul" $flexDirection="column" $gap="medium">
- {changelog.map((item, i) => (
- <BodySmall as="li" key={i} color="whiteAlpha60">
- {item}
- </BodySmall>
- ))}
- </StyledList>
+ {showChangelogList ? (
+ <ChangelogList changelog={changelog} />
) : (
- <BodySmall color="whiteAlpha60">
- {messages.pgettext(
- 'changelog-view',
- 'No updates or changes were made in this release for this platform.',
- )}
- </BodySmall>
+ <NoChangelogUpdates />
)}
</Container>
</Flex>
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/changelog/components/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/changelog/components/index.ts
new file mode 100644
index 0000000000..e838cd8521
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/changelog/components/index.ts
@@ -0,0 +1 @@
+export * from './no-changelog-updates';
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/changelog/components/no-changelog-updates/NoChangelogUpdates.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/views/changelog/components/no-changelog-updates/NoChangelogUpdates.tsx
new file mode 100644
index 0000000000..1cc425bd30
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/changelog/components/no-changelog-updates/NoChangelogUpdates.tsx
@@ -0,0 +1,16 @@
+import { messages } from '../../../../../../shared/gettext';
+import { BodySmall } from '../../../../../lib/components';
+
+export function NoChangelogUpdates() {
+ return (
+ <BodySmall color="whiteAlpha60">
+ {
+ // TRANSLATORS: Text displayed when there are no updates for this platform in the app version
+ messages.pgettext(
+ 'changelog-view',
+ 'No updates or changes were made in this release for this platform.',
+ )
+ }
+ </BodySmall>
+ );
+}
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/changelog/components/no-changelog-updates/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/changelog/components/no-changelog-updates/index.ts
new file mode 100644
index 0000000000..095239a335
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/changelog/components/no-changelog-updates/index.ts
@@ -0,0 +1 @@
+export * from './NoChangelogUpdates';
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/changelog/hooks/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/changelog/hooks/index.ts
new file mode 100644
index 0000000000..fe20474c5d
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/changelog/hooks/index.ts
@@ -0,0 +1,3 @@
+export * from './useChangelog';
+export * from './useHasChangelog';
+export * from './useShowChangelogList';
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/changelog/hooks/useChangelog.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/changelog/hooks/useChangelog.ts
new file mode 100644
index 0000000000..2b3236d5ee
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/changelog/hooks/useChangelog.ts
@@ -0,0 +1,7 @@
+import { useUserInterfaceChangelog } from '../../../../redux/hooks';
+
+export const useChangelog = () => {
+ const { changelog } = useUserInterfaceChangelog();
+
+ return changelog;
+};
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/changelog/hooks/useHasChangelog.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/changelog/hooks/useHasChangelog.ts
new file mode 100644
index 0000000000..b1e232122a
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/changelog/hooks/useHasChangelog.ts
@@ -0,0 +1,9 @@
+import { useChangelog } from './useChangelog';
+
+export const useHasChangelog = () => {
+ const changelog = useChangelog();
+
+ const hasChangeLog = changelog.length > 0;
+
+ return hasChangeLog;
+};
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/changelog/hooks/useShowChangelogList.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/changelog/hooks/useShowChangelogList.ts
new file mode 100644
index 0000000000..c4a11ac7cb
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/changelog/hooks/useShowChangelogList.ts
@@ -0,0 +1,9 @@
+import { useHasChangelog } from './useHasChangelog';
+
+export const useShowChangelogList = () => {
+ const hasChangeLog = useHasChangelog();
+
+ const showChangelog = hasChangeLog;
+
+ return showChangelog;
+};
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/index.ts
index dff5aa7a90..686a4bbbd3 100644
--- a/desktop/packages/mullvad-vpn/src/renderer/components/views/index.ts
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/index.ts
@@ -1,2 +1,4 @@
export * from './app-info';
+export * from './app-upgrade';
export * from './changelog';
+export * from './settings';
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/settings/SettingsView.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/views/settings/SettingsView.tsx
new file mode 100644
index 0000000000..19d67f7b0e
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/settings/SettingsView.tsx
@@ -0,0 +1,92 @@
+import styled from 'styled-components';
+
+import { messages } from '../../../../shared/gettext';
+import { usePop } from '../../../history/hooks';
+import { Flex, TitleBig } from '../../../lib/components';
+import { spacings } from '../../../lib/foundations';
+import { AppNavigationHeader } from '../../';
+import { measurements } from '../../common-styles';
+import { BackAction } from '../../KeyboardNavigation';
+import { SettingsContainer, SettingsNavigationScrollbars } from '../../Layout';
+import { NavigationContainer } from '../../NavigationContainer';
+import {
+ ApiAccessMethodsListItem,
+ AppInfoListItem,
+ DaitaListItem,
+ DebugListItem,
+ MultihopListItem,
+ QuitButton,
+ SplitTunnelingListItem,
+ SupportListItem,
+ UserInterfaceSettingsListItem,
+ VpnSettingsListItem,
+} from './components';
+import { useShowDebug, useShowSplitTunneling, useShowSubSettings } from './hooks';
+
+export const Title = styled(TitleBig)`
+ margin: 0 ${spacings.medium} ${spacings.medium};
+`;
+
+export const Footer = styled(Flex)`
+ margin: ${spacings.large} ${measurements.horizontalViewMargin} ${measurements.verticalViewMargin};
+`;
+
+export function SettingsView() {
+ const pop = usePop();
+
+ const showSubSettings = useShowSubSettings();
+ const showSplitTunneling = useShowSplitTunneling();
+ const showDebug = useShowDebug();
+
+ return (
+ <BackAction action={pop}>
+ <SettingsContainer>
+ <NavigationContainer>
+ <AppNavigationHeader
+ title={
+ // TRANSLATORS: Title label in navigation bar
+ messages.pgettext('settings-view', 'Settings')
+ }
+ />
+
+ <SettingsNavigationScrollbars fillContainer>
+ <Title>
+ {
+ // TRANSLATORS: Main title for settings view
+ messages.pgettext('settings-view', 'Settings')
+ }
+ </Title>
+
+ <Flex $flexDirection="column" $gap="medium">
+ {showSubSettings ? (
+ <>
+ <Flex $flexDirection="column">
+ <DaitaListItem />
+ <MultihopListItem />
+ <VpnSettingsListItem />
+ <UserInterfaceSettingsListItem />
+ </Flex>
+ {showSplitTunneling && <SplitTunnelingListItem />}
+ </>
+ ) : (
+ <UserInterfaceSettingsListItem />
+ )}
+
+ <ApiAccessMethodsListItem />
+
+ <Flex $flexDirection="column">
+ <SupportListItem />
+ <AppInfoListItem />
+ </Flex>
+
+ {showDebug && <DebugListItem />}
+ </Flex>
+ <Footer>
+ <QuitButton />
+ </Footer>
+ </SettingsNavigationScrollbars>
+ </NavigationContainer>
+ </SettingsContainer>
+ </BackAction>
+ );
+}
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/settings/components/api-access-methods-list-item/ApiAccessMethodsListItem.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/views/settings/components/api-access-methods-list-item/ApiAccessMethodsListItem.tsx
new file mode 100644
index 0000000000..56cd42b14b
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/settings/components/api-access-methods-list-item/ApiAccessMethodsListItem.tsx
@@ -0,0 +1,19 @@
+import { messages } from '../../../../../../shared/gettext';
+import { RoutePath } from '../../../../../../shared/routes';
+import { Icon } from '../../../../../lib/components';
+import { ListItem } from '../../../../../lib/components/list-item';
+import { NavigationListItem } from '../../../../NavigationListItem';
+
+export function ApiAccessMethodsListItem() {
+ return (
+ <NavigationListItem to={RoutePath.apiAccessMethods}>
+ <ListItem.Label>
+ {
+ // TRANSLATORS: Navigation button to the 'API access methods' view
+ messages.pgettext('settings-view', 'API access')
+ }
+ </ListItem.Label>
+ <Icon icon="chevron-right" />
+ </NavigationListItem>
+ );
+}
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/settings/components/api-access-methods-list-item/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/settings/components/api-access-methods-list-item/index.ts
new file mode 100644
index 0000000000..2665f013bd
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/settings/components/api-access-methods-list-item/index.ts
@@ -0,0 +1 @@
+export * from './ApiAccessMethodsListItem';
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/settings/components/app-info-list-item/AppInfoListItem.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/views/settings/components/app-info-list-item/AppInfoListItem.tsx
new file mode 100644
index 0000000000..d5e1d03ca2
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/settings/components/app-info-list-item/AppInfoListItem.tsx
@@ -0,0 +1,44 @@
+import styled from 'styled-components';
+
+import { messages } from '../../../../../../shared/gettext';
+import { RoutePath } from '../../../../../../shared/routes';
+import { Flex, Icon } from '../../../../../lib/components';
+import { Dot } from '../../../../../lib/components/dot';
+import { ListItem } from '../../../../../lib/components/list-item';
+import { useVersionCurrent, useVersionSuggestedUpgrade } from '../../../../../redux/hooks';
+import { NavigationListItem } from '../../../../NavigationListItem';
+
+const StyledText = styled(ListItem.Text)`
+ margin-top: -4px;
+`;
+
+export function AppInfoListItem() {
+ const { current } = useVersionCurrent();
+ const { suggestedUpgrade } = useVersionSuggestedUpgrade();
+
+ return (
+ <NavigationListItem to={RoutePath.appInfo}>
+ <Flex $flexDirection="column">
+ <ListItem.Label>
+ {
+ // TRANSLATORS: Navigation button to the 'App info' view
+ messages.pgettext('settings-view', 'App info')
+ }
+ </ListItem.Label>
+ {suggestedUpgrade && (
+ <StyledText variant="footnoteMini">
+ {
+ // TRANSLATORS: Label for the app info list item indicating that an update is available and can be downloaded
+ messages.pgettext('settings-view', 'Update available')
+ }
+ </StyledText>
+ )}
+ </Flex>
+ <ListItem.Group>
+ <ListItem.Text>{current}</ListItem.Text>
+ {suggestedUpgrade && <Dot variant="warning" size="small" />}
+ <Icon icon="chevron-right" />
+ </ListItem.Group>
+ </NavigationListItem>
+ );
+}
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/settings/components/app-info-list-item/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/settings/components/app-info-list-item/index.ts
new file mode 100644
index 0000000000..00f4bab0ac
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/settings/components/app-info-list-item/index.ts
@@ -0,0 +1 @@
+export * from './AppInfoListItem';
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/settings/components/daita-list-item/DaitaListItem.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/views/settings/components/daita-list-item/DaitaListItem.tsx
new file mode 100644
index 0000000000..9766bdbba8
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/settings/components/daita-list-item/DaitaListItem.tsx
@@ -0,0 +1,21 @@
+import { strings } from '../../../../../../shared/constants';
+import { messages } from '../../../../../../shared/gettext';
+import { RoutePath } from '../../../../../../shared/routes';
+import { Icon } from '../../../../../lib/components';
+import { ListItem } from '../../../../../lib/components/list-item';
+import { NavigationListItem } from '../../../../NavigationListItem';
+import { useIsOn } from './hooks';
+
+export function DaitaListItem() {
+ const isOn = useIsOn();
+
+ return (
+ <NavigationListItem to={RoutePath.daitaSettings}>
+ <ListItem.Label>{strings.daita}</ListItem.Label>
+ <ListItem.Group>
+ <ListItem.Text>{isOn ? messages.gettext('On') : messages.gettext('Off')}</ListItem.Text>
+ <Icon icon="chevron-right" />
+ </ListItem.Group>
+ </NavigationListItem>
+ );
+}
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/settings/components/daita-list-item/hooks/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/settings/components/daita-list-item/hooks/index.ts
new file mode 100644
index 0000000000..a96f5ea44e
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/settings/components/daita-list-item/hooks/index.ts
@@ -0,0 +1 @@
+export * from './useIsOn';
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/settings/components/daita-list-item/hooks/useIsOn.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/views/settings/components/daita-list-item/hooks/useIsOn.tsx
new file mode 100644
index 0000000000..eb519439c4
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/settings/components/daita-list-item/hooks/useIsOn.tsx
@@ -0,0 +1,9 @@
+import { useSettingsDaitaEnabled, useSettingsRelaySettings } from '../../../../../../redux/hooks';
+
+export const useIsOn = () => {
+ const { daitaEnabled } = useSettingsDaitaEnabled();
+ const { relaySettings } = useSettingsRelaySettings();
+ const unavailable =
+ 'normal' in relaySettings ? relaySettings.normal.tunnelProtocol === 'openvpn' : true;
+ return daitaEnabled && !unavailable;
+};
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/settings/components/daita-list-item/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/settings/components/daita-list-item/index.ts
new file mode 100644
index 0000000000..849e42f7e2
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/settings/components/daita-list-item/index.ts
@@ -0,0 +1 @@
+export * from './DaitaListItem';
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/settings/components/debug-list-item/DebugListItem.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/views/settings/components/debug-list-item/DebugListItem.tsx
new file mode 100644
index 0000000000..2388b659ba
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/settings/components/debug-list-item/DebugListItem.tsx
@@ -0,0 +1,13 @@
+import { RoutePath } from '../../../../../../shared/routes';
+import { Icon } from '../../../../../lib/components';
+import { ListItem } from '../../../../../lib/components/list-item';
+import { NavigationListItem } from '../../../../NavigationListItem';
+
+export function DebugListItem() {
+ return (
+ <NavigationListItem to={RoutePath.debug}>
+ <ListItem.Label>Developer tools</ListItem.Label>
+ <Icon icon="chevron-right" />
+ </NavigationListItem>
+ );
+}
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/settings/components/debug-list-item/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/settings/components/debug-list-item/index.ts
new file mode 100644
index 0000000000..1b6e1dd98c
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/settings/components/debug-list-item/index.ts
@@ -0,0 +1 @@
+export * from './DebugListItem';
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/settings/components/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/settings/components/index.ts
new file mode 100644
index 0000000000..ed410c10ca
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/settings/components/index.ts
@@ -0,0 +1,10 @@
+export * from './api-access-methods-list-item';
+export * from './app-info-list-item';
+export * from './daita-list-item';
+export * from './debug-list-item';
+export * from './multihop-list-item';
+export * from './quit-button';
+export * from './split-tunneling-list-item';
+export * from './support-list-item';
+export * from './user-interface-settings-list-item';
+export * from './vpn-settings-list-item';
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/settings/components/multihop-list-item/MultihopListItem.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/views/settings/components/multihop-list-item/MultihopListItem.tsx
new file mode 100644
index 0000000000..e64996cb78
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/settings/components/multihop-list-item/MultihopListItem.tsx
@@ -0,0 +1,20 @@
+import { messages } from '../../../../../../shared/gettext';
+import { RoutePath } from '../../../../../../shared/routes';
+import { Icon } from '../../../../../lib/components';
+import { ListItem } from '../../../../../lib/components/list-item';
+import { NavigationListItem } from '../../../../NavigationListItem';
+import { useIsOn } from './hooks';
+
+export function MultihopListItem() {
+ const isOn = useIsOn();
+
+ return (
+ <NavigationListItem to={RoutePath.multihopSettings}>
+ <ListItem.Label>{messages.pgettext('settings-view', 'Multihop')}</ListItem.Label>
+ <ListItem.Group>
+ <ListItem.Text>{isOn ? messages.gettext('On') : messages.gettext('Off')}</ListItem.Text>
+ <Icon icon="chevron-right" />
+ </ListItem.Group>
+ </NavigationListItem>
+ );
+}
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/settings/components/multihop-list-item/hooks/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/settings/components/multihop-list-item/hooks/index.ts
new file mode 100644
index 0000000000..a96f5ea44e
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/settings/components/multihop-list-item/hooks/index.ts
@@ -0,0 +1 @@
+export * from './useIsOn';
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/settings/components/multihop-list-item/hooks/useIsOn.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/views/settings/components/multihop-list-item/hooks/useIsOn.tsx
new file mode 100644
index 0000000000..499be9373e
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/settings/components/multihop-list-item/hooks/useIsOn.tsx
@@ -0,0 +1,10 @@
+import { useSettingsRelaySettings } from '../../../../../../redux/hooks';
+
+export const useIsOn = () => {
+ const { relaySettings } = useSettingsRelaySettings();
+ const multihopEnabled =
+ 'normal' in relaySettings ? relaySettings.normal.wireguard.useMultihop : false;
+ const unavailable =
+ 'normal' in relaySettings ? relaySettings.normal.tunnelProtocol === 'openvpn' : true;
+ return multihopEnabled && !unavailable;
+};
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/settings/components/multihop-list-item/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/settings/components/multihop-list-item/index.ts
new file mode 100644
index 0000000000..685db63327
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/settings/components/multihop-list-item/index.ts
@@ -0,0 +1 @@
+export * from './MultihopListItem';
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/settings/components/quit-button/QuitButton.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/views/settings/components/quit-button/QuitButton.tsx
new file mode 100644
index 0000000000..99c7d555f0
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/settings/components/quit-button/QuitButton.tsx
@@ -0,0 +1,17 @@
+import { messages } from '../../../../../../shared/gettext';
+import { useAppContext } from '../../../../../context';
+import { Button } from '../../../../../lib/components';
+import { useIsConnected } from './hooks';
+
+export function QuitButton() {
+ const { quit } = useAppContext();
+ const isConnected = useIsConnected();
+
+ return (
+ <Button variant="destructive" onClick={quit}>
+ <Button.Text>
+ {isConnected ? messages.gettext('Disconnect & quit') : messages.gettext('Quit')}
+ </Button.Text>
+ </Button>
+ );
+}
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/settings/components/quit-button/hooks/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/settings/components/quit-button/hooks/index.ts
new file mode 100644
index 0000000000..d25f83300d
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/settings/components/quit-button/hooks/index.ts
@@ -0,0 +1 @@
+export * from './useIsConnected';
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/settings/components/quit-button/hooks/useIsConnected.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/views/settings/components/quit-button/hooks/useIsConnected.tsx
new file mode 100644
index 0000000000..18bc40946b
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/settings/components/quit-button/hooks/useIsConnected.tsx
@@ -0,0 +1,6 @@
+import { useConnectionStatus } from '../../../../../../redux/hooks';
+
+export const useIsConnected = () => {
+ const { status } = useConnectionStatus();
+ return status.state === 'connected';
+};
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/settings/components/quit-button/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/settings/components/quit-button/index.ts
new file mode 100644
index 0000000000..776a8dc589
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/settings/components/quit-button/index.ts
@@ -0,0 +1 @@
+export * from './QuitButton';
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/settings/components/split-tunneling-list-item/SplitTunnelingListItem.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/views/settings/components/split-tunneling-list-item/SplitTunnelingListItem.tsx
new file mode 100644
index 0000000000..5f0201c984
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/settings/components/split-tunneling-list-item/SplitTunnelingListItem.tsx
@@ -0,0 +1,14 @@
+import { strings } from '../../../../../../shared/constants';
+import { RoutePath } from '../../../../../../shared/routes';
+import { Icon } from '../../../../../lib/components';
+import { ListItem } from '../../../../../lib/components/list-item';
+import { NavigationListItem } from '../../../../NavigationListItem';
+
+export function SplitTunnelingListItem() {
+ return (
+ <NavigationListItem to={RoutePath.splitTunneling}>
+ <ListItem.Label>{strings.splitTunneling}</ListItem.Label>
+ <Icon icon="chevron-right" />
+ </NavigationListItem>
+ );
+}
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/settings/components/split-tunneling-list-item/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/settings/components/split-tunneling-list-item/index.ts
new file mode 100644
index 0000000000..e531a0b5f6
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/settings/components/split-tunneling-list-item/index.ts
@@ -0,0 +1 @@
+export * from './SplitTunnelingListItem';
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/settings/components/support-list-item/SupportListItem.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/views/settings/components/support-list-item/SupportListItem.tsx
new file mode 100644
index 0000000000..8fbfd81681
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/settings/components/support-list-item/SupportListItem.tsx
@@ -0,0 +1,19 @@
+import { messages } from '../../../../../../shared/gettext';
+import { RoutePath } from '../../../../../../shared/routes';
+import { Icon } from '../../../../../lib/components';
+import { ListItem } from '../../../../../lib/components/list-item';
+import { NavigationListItem } from '../../../../NavigationListItem';
+
+export function SupportListItem() {
+ return (
+ <NavigationListItem to={RoutePath.support}>
+ <ListItem.Label>
+ {
+ // TRANSLATORS: Navigation button to the 'Support' view
+ messages.pgettext('settings-view', 'Support')
+ }
+ </ListItem.Label>
+ <Icon icon="chevron-right" />
+ </NavigationListItem>
+ );
+}
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/settings/components/support-list-item/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/settings/components/support-list-item/index.ts
new file mode 100644
index 0000000000..f7f980b220
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/settings/components/support-list-item/index.ts
@@ -0,0 +1 @@
+export * from './SupportListItem';
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/settings/components/user-interface-settings-list-item/UserInterfaceSettingsListItem.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/views/settings/components/user-interface-settings-list-item/UserInterfaceSettingsListItem.tsx
new file mode 100644
index 0000000000..f13d40cb5b
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/settings/components/user-interface-settings-list-item/UserInterfaceSettingsListItem.tsx
@@ -0,0 +1,19 @@
+import { messages } from '../../../../../../shared/gettext';
+import { RoutePath } from '../../../../../../shared/routes';
+import { Icon } from '../../../../../lib/components';
+import { ListItem } from '../../../../../lib/components/list-item';
+import { NavigationListItem } from '../../../../NavigationListItem';
+
+export function UserInterfaceSettingsListItem() {
+ return (
+ <NavigationListItem to={RoutePath.userInterfaceSettings}>
+ <ListItem.Label>
+ {
+ // TRANSLATORS: Navigation button to the 'User interface settings' view
+ messages.pgettext('settings-view', 'User interface settings')
+ }
+ </ListItem.Label>
+ <Icon icon="chevron-right" />
+ </NavigationListItem>
+ );
+}
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/settings/components/user-interface-settings-list-item/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/settings/components/user-interface-settings-list-item/index.ts
new file mode 100644
index 0000000000..f5168d5f9e
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/settings/components/user-interface-settings-list-item/index.ts
@@ -0,0 +1 @@
+export * from './UserInterfaceSettingsListItem';
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/settings/components/vpn-settings-list-item/VpnSettingsListItem.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/views/settings/components/vpn-settings-list-item/VpnSettingsListItem.tsx
new file mode 100644
index 0000000000..0c39da6536
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/settings/components/vpn-settings-list-item/VpnSettingsListItem.tsx
@@ -0,0 +1,19 @@
+import { messages } from '../../../../../../shared/gettext';
+import { RoutePath } from '../../../../../../shared/routes';
+import { Icon } from '../../../../../lib/components';
+import { ListItem } from '../../../../../lib/components/list-item';
+import { NavigationListItem } from '../../../../NavigationListItem';
+
+export function VpnSettingsListItem() {
+ return (
+ <NavigationListItem to={RoutePath.vpnSettings}>
+ <ListItem.Label>
+ {
+ // TRANSLATORS: Navigation button to the 'VPN settings' view
+ messages.pgettext('settings-view', 'VPN settings')
+ }
+ </ListItem.Label>
+ <Icon icon="chevron-right" />
+ </NavigationListItem>
+ );
+}
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/settings/components/vpn-settings-list-item/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/settings/components/vpn-settings-list-item/index.ts
new file mode 100644
index 0000000000..737115424d
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/settings/components/vpn-settings-list-item/index.ts
@@ -0,0 +1 @@
+export * from './VpnSettingsListItem';
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/settings/hooks/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/settings/hooks/index.ts
new file mode 100644
index 0000000000..eddd5a9766
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/settings/hooks/index.ts
@@ -0,0 +1,3 @@
+export * from './useShowDebug';
+export * from './useShowSubSettings';
+export * from './useShowSplitTunneling';
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/settings/hooks/useShowDebug.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/views/settings/hooks/useShowDebug.tsx
new file mode 100644
index 0000000000..0f907df671
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/settings/hooks/useShowDebug.tsx
@@ -0,0 +1,3 @@
+export const useShowDebug = () => {
+ return window.env.development;
+};
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/settings/hooks/useShowSplitTunneling.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/views/settings/hooks/useShowSplitTunneling.tsx
new file mode 100644
index 0000000000..a699aee42e
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/settings/hooks/useShowSplitTunneling.tsx
@@ -0,0 +1,6 @@
+import { useUserInterfaceIsMacOs13OrNewer } from '../../../../redux/hooks';
+
+export const useShowSplitTunneling = () => {
+ const { isMacOs13OrNewer } = useUserInterfaceIsMacOs13OrNewer();
+ return window.env.platform !== 'darwin' || isMacOs13OrNewer;
+};
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/settings/hooks/useShowSubSettings.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/views/settings/hooks/useShowSubSettings.tsx
new file mode 100644
index 0000000000..774c597395
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/settings/hooks/useShowSubSettings.tsx
@@ -0,0 +1,7 @@
+import { useAccountStatus, useUserInterfaceConnectedToDaemon } from '../../../../redux/hooks';
+
+export const useShowSubSettings = () => {
+ const { status } = useAccountStatus();
+ const connectedToDaemon = useUserInterfaceConnectedToDaemon();
+ return status.type === 'ok' && connectedToDaemon;
+};
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/views/settings/index.ts b/desktop/packages/mullvad-vpn/src/renderer/components/views/settings/index.ts
new file mode 100644
index 0000000000..4877aa46c4
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/views/settings/index.ts
@@ -0,0 +1 @@
+export * from './SettingsView';
diff --git a/desktop/packages/mullvad-vpn/src/renderer/history/hooks/index.ts b/desktop/packages/mullvad-vpn/src/renderer/history/hooks/index.ts
new file mode 100644
index 0000000000..f22b1d9062
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/history/hooks/index.ts
@@ -0,0 +1,4 @@
+export * from './usePop';
+export * from './usePushAppUpgrade';
+export * from './usePushChangelog';
+export * from './usePushProblemReport';
diff --git a/desktop/packages/mullvad-vpn/src/renderer/history/hooks/usePop.tsx b/desktop/packages/mullvad-vpn/src/renderer/history/hooks/usePop.tsx
new file mode 100644
index 0000000000..2685a7880f
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/history/hooks/usePop.tsx
@@ -0,0 +1,8 @@
+import React from 'react';
+
+import { useHistory } from '../../lib/history';
+
+export const usePop = () => {
+ const history = useHistory();
+ return React.useCallback(() => history.pop(), [history]);
+};
diff --git a/desktop/packages/mullvad-vpn/src/renderer/history/hooks/usePushAppUpgrade.tsx b/desktop/packages/mullvad-vpn/src/renderer/history/hooks/usePushAppUpgrade.tsx
new file mode 100644
index 0000000000..b8a5de1ea1
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/history/hooks/usePushAppUpgrade.tsx
@@ -0,0 +1,10 @@
+import React from 'react';
+
+import { RoutePath } from '../../../shared/routes';
+import { useHistory } from '../../lib/history';
+
+export const usePushAppUpgrade = () => {
+ const history = useHistory();
+
+ return React.useCallback(() => history.push(RoutePath.appUpgrade), [history]);
+};
diff --git a/desktop/packages/mullvad-vpn/src/renderer/history/hooks/usePushChangelog.tsx b/desktop/packages/mullvad-vpn/src/renderer/history/hooks/usePushChangelog.tsx
new file mode 100644
index 0000000000..340cc2b223
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/history/hooks/usePushChangelog.tsx
@@ -0,0 +1,9 @@
+import { useCallback } from 'react';
+
+import { RoutePath } from '../../../shared/routes';
+import { useHistory } from '../../lib/history';
+
+export const usePushChangelog = () => {
+ const history = useHistory();
+ return useCallback(() => history.push(RoutePath.changelog), [history]);
+};
diff --git a/desktop/packages/mullvad-vpn/src/renderer/history/hooks/usePushProblemReport.ts b/desktop/packages/mullvad-vpn/src/renderer/history/hooks/usePushProblemReport.ts
new file mode 100644
index 0000000000..9bb7cba201
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/history/hooks/usePushProblemReport.ts
@@ -0,0 +1,24 @@
+import { useCallback } from 'react';
+import { useHistory } from 'react-router';
+
+import { LocationState } from '../../../shared/ipc-types';
+import { RoutePath } from '../../../shared/routes';
+
+export type PushProblemReportProps = {
+ state?: Partial<LocationState>;
+};
+
+export const usePushProblemReport = ({ state }: PushProblemReportProps = {}) => {
+ const history = useHistory();
+
+ const pushProblemReport = useCallback(() => {
+ history.push(
+ {
+ pathname: RoutePath.problemReport,
+ },
+ state,
+ );
+ }, [history, state]);
+
+ return pushProblemReport;
+};
diff --git a/desktop/packages/mullvad-vpn/src/renderer/hooks/index.ts b/desktop/packages/mullvad-vpn/src/renderer/hooks/index.ts
new file mode 100644
index 0000000000..51e9b187a6
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/hooks/index.ts
@@ -0,0 +1,8 @@
+export * from './useAppUpgradeDownloadProgressValue';
+export * from './useAppUpgradeEventType';
+export * from './useHasAppUpgradeError';
+export * from './useHasAppUpgradeEvent';
+export * from './useHasAppUpgradeInitiated';
+export * from './useHasAppUpgradeVerifiedInstallerPath';
+export * from './useIsPlatformLinux';
+export * from './useMeasure';
diff --git a/desktop/packages/mullvad-vpn/src/renderer/hooks/useAppUpgradeDownloadProgressValue/constants.ts b/desktop/packages/mullvad-vpn/src/renderer/hooks/useAppUpgradeDownloadProgressValue/constants.ts
new file mode 100644
index 0000000000..cd027d4d3f
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/hooks/useAppUpgradeDownloadProgressValue/constants.ts
@@ -0,0 +1 @@
+export const DOWNLOAD_COMPLETE_VALUE = 100;
diff --git a/desktop/packages/mullvad-vpn/src/renderer/hooks/useAppUpgradeDownloadProgressValue/hooks/index.ts b/desktop/packages/mullvad-vpn/src/renderer/hooks/useAppUpgradeDownloadProgressValue/hooks/index.ts
new file mode 100644
index 0000000000..a5529f5c7d
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/hooks/useAppUpgradeDownloadProgressValue/hooks/index.ts
@@ -0,0 +1,2 @@
+export * from './useGetValueDownloadProgress';
+export * from './useGetValueError';
diff --git a/desktop/packages/mullvad-vpn/src/renderer/hooks/useAppUpgradeDownloadProgressValue/hooks/useGetValueDownloadProgress.ts b/desktop/packages/mullvad-vpn/src/renderer/hooks/useAppUpgradeDownloadProgressValue/hooks/useGetValueDownloadProgress.ts
new file mode 100644
index 0000000000..79da7afb9f
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/hooks/useAppUpgradeDownloadProgressValue/hooks/useGetValueDownloadProgress.ts
@@ -0,0 +1,20 @@
+import { useCallback } from 'react';
+
+import { useAppUpgradeEvent, useAppUpgradeLastProgress } from '../../../redux/hooks';
+
+export const useGetValueDownloadProgress = () => {
+ const { event } = useAppUpgradeEvent();
+ const { lastProgress } = useAppUpgradeLastProgress();
+
+ const getValueDownloadProgress = useCallback(() => {
+ if (event?.type === 'APP_UPGRADE_STATUS_DOWNLOAD_PROGRESS') {
+ const { progress } = event;
+
+ return progress;
+ }
+
+ return lastProgress;
+ }, [event, lastProgress]);
+
+ return getValueDownloadProgress;
+};
diff --git a/desktop/packages/mullvad-vpn/src/renderer/hooks/useAppUpgradeDownloadProgressValue/hooks/useGetValueError.ts b/desktop/packages/mullvad-vpn/src/renderer/hooks/useAppUpgradeDownloadProgressValue/hooks/useGetValueError.ts
new file mode 100644
index 0000000000..fe3076b0d7
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/hooks/useAppUpgradeDownloadProgressValue/hooks/useGetValueError.ts
@@ -0,0 +1,29 @@
+import { useCallback } from 'react';
+
+import { useAppUpgradeError, useAppUpgradeLastProgress } from '../../../redux/hooks';
+import { DOWNLOAD_COMPLETE_VALUE } from '../constants';
+import { useGetValueDownloadProgress } from './useGetValueDownloadProgress';
+
+export const useGetValueError = () => {
+ const { error } = useAppUpgradeError();
+ const getValueDownloadProgress = useGetValueDownloadProgress();
+ const { lastProgress } = useAppUpgradeLastProgress();
+
+ const getValueError = useCallback(() => {
+ if (error === 'DOWNLOAD_FAILED' || error === 'GENERAL_ERROR') {
+ return getValueDownloadProgress();
+ }
+
+ if (
+ error === 'INSTALLER_FAILED' ||
+ error === 'START_INSTALLER_FAILED' ||
+ error === 'VERIFICATION_FAILED'
+ ) {
+ return DOWNLOAD_COMPLETE_VALUE;
+ }
+
+ return lastProgress;
+ }, [error, getValueDownloadProgress, lastProgress]);
+
+ return getValueError;
+};
diff --git a/desktop/packages/mullvad-vpn/src/renderer/hooks/useAppUpgradeDownloadProgressValue/index.ts b/desktop/packages/mullvad-vpn/src/renderer/hooks/useAppUpgradeDownloadProgressValue/index.ts
new file mode 100644
index 0000000000..df5a58ca14
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/hooks/useAppUpgradeDownloadProgressValue/index.ts
@@ -0,0 +1 @@
+export * from './useAppUpgradeDownloadProgressValue';
diff --git a/desktop/packages/mullvad-vpn/src/renderer/hooks/useAppUpgradeDownloadProgressValue/useAppUpgradeDownloadProgressValue.ts b/desktop/packages/mullvad-vpn/src/renderer/hooks/useAppUpgradeDownloadProgressValue/useAppUpgradeDownloadProgressValue.ts
new file mode 100644
index 0000000000..43e52c4491
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/hooks/useAppUpgradeDownloadProgressValue/useAppUpgradeDownloadProgressValue.ts
@@ -0,0 +1,40 @@
+import { useAppUpgradeLastProgress } from '../../redux/hooks';
+import { useAppUpgradeEventType } from '../useAppUpgradeEventType';
+import { useHasAppUpgradeError } from '../useHasAppUpgradeError';
+import { useHasAppUpgradeVerifiedInstallerPath } from '../useHasAppUpgradeVerifiedInstallerPath';
+import { DOWNLOAD_COMPLETE_VALUE } from './constants';
+import { useGetValueDownloadProgress, useGetValueError } from './hooks';
+
+export const useAppUpgradeDownloadProgressValue = () => {
+ const appUpgradeEventType = useAppUpgradeEventType();
+ const getValueDownloadProgress = useGetValueDownloadProgress();
+ const getValueError = useGetValueError();
+ const hasAppUpgradeError = useHasAppUpgradeError();
+ const hasAppUpgradeVerifiedInstallerPath = useHasAppUpgradeVerifiedInstallerPath();
+ const { lastProgress } = useAppUpgradeLastProgress();
+
+ if (hasAppUpgradeError) {
+ return getValueError();
+ }
+
+ if (hasAppUpgradeVerifiedInstallerPath && !appUpgradeEventType) {
+ return DOWNLOAD_COMPLETE_VALUE;
+ }
+
+ switch (appUpgradeEventType) {
+ case 'APP_UPGRADE_STATUS_DOWNLOAD_PROGRESS':
+ return getValueDownloadProgress();
+ case 'APP_UPGRADE_STATUS_AUTOMATIC_STARTING_INSTALLER':
+ case 'APP_UPGRADE_STATUS_EXITED_INSTALLER':
+ case 'APP_UPGRADE_STATUS_MANUAL_START_INSTALLER':
+ case 'APP_UPGRADE_STATUS_MANUAL_STARTING_INSTALLER':
+ case 'APP_UPGRADE_STATUS_STARTED_INSTALLER':
+ case 'APP_UPGRADE_STATUS_VERIFIED_INSTALLER':
+ case 'APP_UPGRADE_STATUS_VERIFYING_INSTALLER':
+ return DOWNLOAD_COMPLETE_VALUE;
+ default:
+ break;
+ }
+
+ return lastProgress;
+};
diff --git a/desktop/packages/mullvad-vpn/src/renderer/hooks/useAppUpgradeEventType.ts b/desktop/packages/mullvad-vpn/src/renderer/hooks/useAppUpgradeEventType.ts
new file mode 100644
index 0000000000..238a38e9b1
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/hooks/useAppUpgradeEventType.ts
@@ -0,0 +1,9 @@
+import { useAppUpgradeEvent } from '../redux/hooks';
+
+export const useAppUpgradeEventType = () => {
+ const { event } = useAppUpgradeEvent();
+
+ const appUpgradeEventType = event?.type;
+
+ return appUpgradeEventType;
+};
diff --git a/desktop/packages/mullvad-vpn/src/renderer/hooks/useHasAppUpgradeError.ts b/desktop/packages/mullvad-vpn/src/renderer/hooks/useHasAppUpgradeError.ts
new file mode 100644
index 0000000000..f829ff3657
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/hooks/useHasAppUpgradeError.ts
@@ -0,0 +1,9 @@
+import { useAppUpgradeError } from '../redux/hooks';
+
+export const useHasAppUpgradeError = () => {
+ const { error } = useAppUpgradeError();
+
+ const hasAppUpgradeError = error !== undefined;
+
+ return hasAppUpgradeError;
+};
diff --git a/desktop/packages/mullvad-vpn/src/renderer/hooks/useHasAppUpgradeEvent.ts b/desktop/packages/mullvad-vpn/src/renderer/hooks/useHasAppUpgradeEvent.ts
new file mode 100644
index 0000000000..2ed8aeb556
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/hooks/useHasAppUpgradeEvent.ts
@@ -0,0 +1,9 @@
+import { useAppUpgradeEvent } from '../redux/hooks';
+
+export const useHasAppUpgradeEvent = () => {
+ const { event } = useAppUpgradeEvent();
+
+ const hasAppUpgradeEvent = event !== undefined;
+
+ return hasAppUpgradeEvent;
+};
diff --git a/desktop/packages/mullvad-vpn/src/renderer/hooks/useHasAppUpgradeInitiated.ts b/desktop/packages/mullvad-vpn/src/renderer/hooks/useHasAppUpgradeInitiated.ts
new file mode 100644
index 0000000000..67861f4c4d
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/hooks/useHasAppUpgradeInitiated.ts
@@ -0,0 +1,9 @@
+import { useAppUpgradeEventType } from './useAppUpgradeEventType';
+
+export const useHasAppUpgradeInitiated = () => {
+ const appUpgradeEventType = useAppUpgradeEventType();
+
+ const hasAppUpgradeInitiated = appUpgradeEventType !== undefined;
+
+ return hasAppUpgradeInitiated;
+};
diff --git a/desktop/packages/mullvad-vpn/src/renderer/hooks/useHasAppUpgradeVerifiedInstallerPath.ts b/desktop/packages/mullvad-vpn/src/renderer/hooks/useHasAppUpgradeVerifiedInstallerPath.ts
new file mode 100644
index 0000000000..1f734bd1b4
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/hooks/useHasAppUpgradeVerifiedInstallerPath.ts
@@ -0,0 +1,11 @@
+import { useVersionSuggestedUpgrade } from '../redux/hooks';
+
+export const useHasAppUpgradeVerifiedInstallerPath = () => {
+ const { suggestedUpgrade } = useVersionSuggestedUpgrade();
+
+ const hasVerifiedInstallerPath =
+ typeof suggestedUpgrade?.verifiedInstallerPath === 'string' &&
+ suggestedUpgrade.verifiedInstallerPath.length > 0;
+
+ return hasVerifiedInstallerPath;
+};
diff --git a/desktop/packages/mullvad-vpn/src/renderer/hooks/useIsPlatformLinux.ts b/desktop/packages/mullvad-vpn/src/renderer/hooks/useIsPlatformLinux.ts
new file mode 100644
index 0000000000..3527363e7c
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/hooks/useIsPlatformLinux.ts
@@ -0,0 +1,5 @@
+export const useIsPlatformLinux = () => {
+ const isPlatformLinux = window.env.platform === 'linux';
+
+ return isPlatformLinux;
+};
diff --git a/desktop/packages/mullvad-vpn/src/renderer/hooks/useMeasure.ts b/desktop/packages/mullvad-vpn/src/renderer/hooks/useMeasure.ts
new file mode 100644
index 0000000000..fafb1066c1
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/hooks/useMeasure.ts
@@ -0,0 +1,37 @@
+import { RefCallback, useCallback, useLayoutEffect, useState } from 'react';
+
+export interface MeasureSize {
+ width: number;
+ height: number;
+}
+
+export function useMeasure<T extends Element = HTMLElement>(): [RefCallback<T>, MeasureSize] {
+ const [size, setSize] = useState<MeasureSize>({ width: 0, height: 0 });
+ const [node, setNode] = useState<T | null>(null);
+
+ const ref: RefCallback<T> = useCallback((instance: T | null) => {
+ setNode(instance);
+ }, []);
+
+ useLayoutEffect(() => {
+ if (!node) return;
+
+ const measure = () => {
+ window.requestAnimationFrame(() => {
+ const rect = node.getBoundingClientRect();
+ setSize({ width: rect.width, height: rect.height });
+ });
+ };
+
+ measure();
+
+ const resizeObserver = new ResizeObserver(measure);
+ resizeObserver.observe(node);
+
+ return () => {
+ resizeObserver.disconnect();
+ };
+ }, [node]);
+
+ return [ref, size];
+}
diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/components/animate/Animate.tsx b/desktop/packages/mullvad-vpn/src/renderer/lib/components/animate/Animate.tsx
index d0edee746e..1c806b9a8d 100644
--- a/desktop/packages/mullvad-vpn/src/renderer/lib/components/animate/Animate.tsx
+++ b/desktop/packages/mullvad-vpn/src/renderer/lib/components/animate/Animate.tsx
@@ -1,14 +1,106 @@
-import { AnimatePresentVertical, AnimatePresentVerticalProps } from './components';
+import React from 'react';
+import styled, { css, RuleSet } from 'styled-components';
-export type AnimateProps = {
- type: 'present-vertical';
-} & AnimatePresentVerticalProps;
+import { TransientProps } from '../../types';
+import { AnimateProvider, useAnimateContext } from './AnimateContext';
+import { useAnimate, useAnimations, useHandleAnimationEnd } from './hooks';
+import { Animation } from './types';
-export function Animate({ type, ...props }: AnimateProps) {
- switch (type) {
- case 'present-vertical':
- return <AnimatePresentVertical {...props} />;
- default:
- return type satisfies never;
+type AnimateBaseProps = {
+ initial?: boolean;
+ present?: boolean;
+ duration?: React.CSSProperties['animationDuration'];
+ timingFunction?: React.CSSProperties['animationTimingFunction'];
+ direction?: React.CSSProperties['animationDirection'];
+ iterationCount?: React.CSSProperties['animationIterationCount'];
+
+ animations: Animation[];
+ children?: React.ReactNode;
+};
+
+export type AnimateProps = AnimateBaseProps &
+ Omit<React.HTMLAttributes<HTMLDivElement>, keyof AnimateBaseProps>;
+
+const StyledDiv = styled.div<
+ TransientProps<Omit<AnimateBaseProps, 'animations' | 'present'>> & {
+ $animations: RuleSet;
}
+>`
+ ${({
+ $animations,
+ $duration = '0.25s',
+ $timingFunction = 'ease',
+ $direction = 'normal',
+ $iterationCount = '1',
+ }) => {
+ return css`
+ // If the user prefers reduced motion, visibility still needs
+ // to be toggled, otherwise this is handled by animations
+ display: none;
+
+ &&[data-present='true'] {
+ display: block;
+ }
+
+ @media (prefers-reduced-motion: no-preference) {
+ &&[data-animate='true'] {
+ --duration: ${$duration};
+ --timing-function: ${$timingFunction};
+ --direction: ${$direction};
+ --iteration-count: ${$iterationCount};
+
+ interpolate-size: allow-keywords;
+ transition-behavior: allow-discrete;
+
+ overflow: clip;
+ ${$animations}
+ }
+ }
+ `;
+ }}
+`;
+
+/**
+ * Animate that applies animation to a wrapper around it's children.
+ *
+ * @param initial - Whether animation should trigger on mount.
+ * @param present - Whether element is present, i.e rendered or not.
+ * @param animations - List of animations to apply.
+ */
+export function Animate({ animations, initial, present = true, children, ...props }: AnimateProps) {
+ return (
+ <AnimateProvider animations={animations} initial={initial} present={present}>
+ <AnimateImpl {...props}>{children}</AnimateImpl>
+ </AnimateProvider>
+ );
+}
+
+export type AnimateImplProps = Omit<AnimateProps, 'animations' | 'present'>;
+
+function AnimateImpl({
+ duration,
+ timingFunction,
+ direction,
+ iterationCount,
+ onAnimationEnd,
+ ...props
+}: AnimateImplProps) {
+ const { animatePresent } = useAnimateContext();
+ const animations = useAnimations();
+ const animate = useAnimate();
+ const handleAnimationEnd = useHandleAnimationEnd();
+
+ return (
+ <StyledDiv
+ data-animate={animate}
+ data-present={animatePresent}
+ onAnimationEnd={handleAnimationEnd}
+ $animations={animations}
+ $duration={duration}
+ $timingFunction={timingFunction}
+ $direction={direction}
+ $iterationCount={iterationCount}
+ {...props}
+ />
+ );
}
diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/components/animate/AnimateContext.tsx b/desktop/packages/mullvad-vpn/src/renderer/lib/components/animate/AnimateContext.tsx
new file mode 100644
index 0000000000..d168f07a2e
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/lib/components/animate/AnimateContext.tsx
@@ -0,0 +1,55 @@
+import React, { useState } from 'react';
+
+import { Animation } from './types';
+
+interface AnimateContextProps {
+ animations: Animation[];
+ animate: boolean;
+ animatePresent: boolean;
+ present: boolean;
+ initial?: boolean;
+ setAnimate: React.Dispatch<React.SetStateAction<boolean>>;
+ setAnimatePresent: React.Dispatch<React.SetStateAction<boolean>>;
+}
+
+const AnimateContext = React.createContext<AnimateContextProps | undefined>(undefined);
+
+export const useAnimateContext = (): AnimateContextProps => {
+ const context = React.useContext(AnimateContext);
+ if (!context) {
+ throw new Error('useButtonContext must be used within a ButtonProvider');
+ }
+ return context;
+};
+
+interface AnimateProviderProps {
+ animations: Animation[];
+ present: boolean;
+ initial?: boolean;
+ children: React.ReactNode;
+}
+
+export const AnimateProvider = ({
+ animations,
+ present,
+ initial,
+ children,
+}: AnimateProviderProps) => {
+ const [animate, setAnimate] = React.useState<boolean>((initial && present) || false);
+ const [animatePresent, setAnimatePresent] = useState<boolean>(present);
+
+ return (
+ <AnimateContext.Provider
+ value={{
+ animate,
+ animatePresent,
+ animations,
+ initial,
+ present,
+ setAnimate,
+ setAnimatePresent,
+ }}>
+ {children}
+ </AnimateContext.Provider>
+ );
+};
diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/components/animate/animations/animations.ts b/desktop/packages/mullvad-vpn/src/renderer/lib/components/animate/animations/animations.ts
new file mode 100644
index 0000000000..bec40a1adf
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/lib/components/animate/animations/animations.ts
@@ -0,0 +1,13 @@
+import { fadeIn, fadeOut } from './fade';
+import { wipeDownIn, wipeVerticalOut } from './wipe';
+
+export const animations = {
+ fade: {
+ in: fadeIn,
+ out: fadeOut,
+ },
+ wipeDown: {
+ in: wipeDownIn,
+ out: wipeVerticalOut,
+ },
+} as const;
diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/components/animate/animations/fade.ts b/desktop/packages/mullvad-vpn/src/renderer/lib/components/animate/animations/fade.ts
new file mode 100644
index 0000000000..f67b6e1dd5
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/lib/components/animate/animations/fade.ts
@@ -0,0 +1,31 @@
+import { css } from 'styled-components';
+
+import { createAnimation } from '../utils';
+
+export const fadeIn = createAnimation(
+ 'animation-fade-in',
+ css`
+ from {
+ display: none;
+ opacity: 0;
+ }
+ to {
+ display: block;
+ opacity: 1;
+ }
+ `,
+);
+
+export const fadeOut = createAnimation(
+ 'animation-fade-out',
+ css`
+ from {
+ display: block;
+ opacity: 1;
+ }
+ to {
+ display: none;
+ opacity: 0;
+ }
+ `,
+);
diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/components/animate/animations/index.ts b/desktop/packages/mullvad-vpn/src/renderer/lib/components/animate/animations/index.ts
new file mode 100644
index 0000000000..9616d4a908
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/lib/components/animate/animations/index.ts
@@ -0,0 +1 @@
+export * from './animations';
diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/components/animate/animations/wipe.ts b/desktop/packages/mullvad-vpn/src/renderer/lib/components/animate/animations/wipe.ts
new file mode 100644
index 0000000000..bc0fff3807
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/lib/components/animate/animations/wipe.ts
@@ -0,0 +1,31 @@
+import { css } from 'styled-components';
+
+import { createAnimation } from '../utils';
+
+export const wipeDownIn = createAnimation(
+ 'animation-wipe-down-in',
+ css`
+ from {
+ display: none;
+ max-height: 0;
+ }
+ to {
+ display: block;
+ max-height: min-content;
+ }
+ `,
+);
+
+export const wipeVerticalOut = createAnimation(
+ 'animation-wipe-vertical-out',
+ css`
+ from {
+ display: block;
+ max-height: min-content;
+ }
+ to {
+ display: none;
+ max-height: 0;
+ }
+ `,
+);
diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/components/animate/components/AnimatePresentVertical.tsx b/desktop/packages/mullvad-vpn/src/renderer/lib/components/animate/components/AnimatePresentVertical.tsx
deleted file mode 100644
index 31536c93f9..0000000000
--- a/desktop/packages/mullvad-vpn/src/renderer/lib/components/animate/components/AnimatePresentVertical.tsx
+++ /dev/null
@@ -1,34 +0,0 @@
-import React from 'react';
-import styled from 'styled-components';
-
-export interface AnimatePresentVerticalProps extends React.HTMLAttributes<HTMLDivElement> {
- present?: boolean;
- children?: React.ReactNode;
-}
-
-const StyledDiv = styled.div`
- --display-start: none;
- --height-start: 0;
- --display-end: block;
- --height-end: min-content;
-
- overflow: clip;
- transition-property: display, height;
- transition-duration: 0.25s;
- transition-timing-function: ease;
- interpolate-size: allow-keywords;
- transition-behavior: allow-discrete;
- display: var(--display-start);
- height: var(--height-start);
- &&[data-present='true'] {
- display: var(--display-end);
- height: var(--height-end);
- @starting-style {
- height: var(--height-start);
- }
- }
-`;
-
-export function AnimatePresentVertical({ present, ...props }: AnimatePresentVerticalProps) {
- return <StyledDiv data-present={present} {...props} />;
-}
diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/components/animate/components/index.ts b/desktop/packages/mullvad-vpn/src/renderer/lib/components/animate/components/index.ts
deleted file mode 100644
index 6bea1c015a..0000000000
--- a/desktop/packages/mullvad-vpn/src/renderer/lib/components/animate/components/index.ts
+++ /dev/null
@@ -1 +0,0 @@
-export * from './AnimatePresentVertical';
diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/components/animate/hooks/index.ts b/desktop/packages/mullvad-vpn/src/renderer/lib/components/animate/hooks/index.ts
new file mode 100644
index 0000000000..8d6128d192
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/lib/components/animate/hooks/index.ts
@@ -0,0 +1,4 @@
+export * from './useAnimate';
+export * from './useAnimations';
+export * from './useHandleAnimationEnd';
+export * from './usePreviousValue';
diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/components/animate/hooks/useAnimate.ts b/desktop/packages/mullvad-vpn/src/renderer/lib/components/animate/hooks/useAnimate.ts
new file mode 100644
index 0000000000..f67405c72d
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/lib/components/animate/hooks/useAnimate.ts
@@ -0,0 +1,18 @@
+import { useEffect } from 'react';
+
+import { useAnimateContext } from '../AnimateContext';
+import { usePreviousValue } from './usePreviousValue';
+
+export const useAnimate = () => {
+ const { animate, present, setAnimate, setAnimatePresent } = useAnimateContext();
+ const previousPresent = usePreviousValue(present);
+
+ useEffect(() => {
+ if (present !== previousPresent) {
+ setAnimate(true);
+ setAnimatePresent(present);
+ }
+ }, [present, previousPresent, setAnimate, setAnimatePresent]);
+
+ return animate;
+};
diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/components/animate/hooks/useAnimations.ts b/desktop/packages/mullvad-vpn/src/renderer/lib/components/animate/hooks/useAnimations.ts
new file mode 100644
index 0000000000..232bcd99b5
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/lib/components/animate/hooks/useAnimations.ts
@@ -0,0 +1,31 @@
+import { css, RuleSet } from 'styled-components';
+
+import { useAnimateContext } from '../AnimateContext';
+import { animations } from '../animations';
+import { createAnimationDeclaration } from '../utils';
+
+export const useAnimations = () => {
+ const { animations: values } = useAnimateContext();
+
+ const inAnimations: Array<{ name: string; rule: RuleSet }> = [];
+ const outAnimations: Array<{ name: string; rule: RuleSet }> = [];
+
+ values.forEach((animation) => {
+ if (animation.type === 'fade') {
+ inAnimations.push(animations.fade.in);
+ outAnimations.push(animations.fade.out);
+ } else if (animation.type === 'wipe' && animation.direction === 'vertical') {
+ inAnimations.push(animations.wipeDown.in);
+ outAnimations.push(animations.wipeDown.out);
+ }
+ });
+
+ return css`
+ ${inAnimations.map((animation) => animation.rule)}
+ ${outAnimations.map((animation) => animation.rule)}
+ ${createAnimationDeclaration(outAnimations)}
+ &&[data-present='true'] {
+ ${createAnimationDeclaration(inAnimations)}
+ }
+ `;
+};
diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/components/animate/hooks/useHandleAnimationEnd.ts b/desktop/packages/mullvad-vpn/src/renderer/lib/components/animate/hooks/useHandleAnimationEnd.ts
new file mode 100644
index 0000000000..53389437bc
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/lib/components/animate/hooks/useHandleAnimationEnd.ts
@@ -0,0 +1,23 @@
+import { useCallback, useRef } from 'react';
+
+import { useAnimateContext } from '../AnimateContext';
+
+export const useHandleAnimationEnd = () => {
+ const { animations, present, setAnimate, setAnimatePresent } = useAnimateContext();
+ const animationsCount = animations.length;
+ const animationsFinishedCount = useRef(0);
+
+ const handleAnimationEnd = useCallback(() => {
+ const nextAnimationsFinishedCount = animationsFinishedCount.current + 1;
+
+ if (nextAnimationsFinishedCount === animationsCount) {
+ animationsFinishedCount.current = 0;
+ setAnimate(false);
+ setAnimatePresent(present);
+ } else {
+ animationsFinishedCount.current = nextAnimationsFinishedCount;
+ }
+ }, [animationsCount, animationsFinishedCount, present, setAnimate, setAnimatePresent]);
+
+ return handleAnimationEnd;
+};
diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/components/animate/hooks/usePreviousValue.ts b/desktop/packages/mullvad-vpn/src/renderer/lib/components/animate/hooks/usePreviousValue.ts
new file mode 100644
index 0000000000..1b835a7823
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/lib/components/animate/hooks/usePreviousValue.ts
@@ -0,0 +1,11 @@
+import { useEffect, useState } from 'react';
+
+export const usePreviousValue = <T>(value: T) => {
+ const [previousValue, setPreviousValue] = useState(value);
+
+ useEffect(() => {
+ setPreviousValue(value);
+ }, [setPreviousValue, value]);
+
+ return previousValue;
+};
diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/components/animate/types.ts b/desktop/packages/mullvad-vpn/src/renderer/lib/components/animate/types.ts
new file mode 100644
index 0000000000..6b51da2889
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/lib/components/animate/types.ts
@@ -0,0 +1,10 @@
+export type Animation = FadeAnimation | WipeAnimation;
+
+export type FadeAnimation = {
+ type: 'fade';
+};
+
+export type WipeAnimation = {
+ type: 'wipe';
+ direction: 'vertical';
+};
diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/components/animate/utils/create-animation-declaration.ts b/desktop/packages/mullvad-vpn/src/renderer/lib/components/animate/utils/create-animation-declaration.ts
new file mode 100644
index 0000000000..e1268e6b76
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/lib/components/animate/utils/create-animation-declaration.ts
@@ -0,0 +1,10 @@
+import { css } from 'styled-components';
+
+export const createAnimationDeclaration = (animations: Array<{ name: string }>) => css`
+ animation: ${animations
+ .map(
+ ({ name }) =>
+ `${name} var(--duration) var(--timing-function) var(--direction) var(--iteration-count)`,
+ )
+ .join(', ')};
+`;
diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/components/animate/utils/create-animation.ts b/desktop/packages/mullvad-vpn/src/renderer/lib/components/animate/utils/create-animation.ts
new file mode 100644
index 0000000000..6f3905f5cd
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/lib/components/animate/utils/create-animation.ts
@@ -0,0 +1,10 @@
+import { css, RuleSet } from 'styled-components';
+
+export const createAnimation = (name: string, frames: RuleSet) => ({
+ name,
+ rule: css`
+ @keyframes ${name} {
+ ${frames}
+ }
+ `,
+});
diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/components/animate/utils/index.ts b/desktop/packages/mullvad-vpn/src/renderer/lib/components/animate/utils/index.ts
new file mode 100644
index 0000000000..d3cf17a616
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/lib/components/animate/utils/index.ts
@@ -0,0 +1,2 @@
+export * from './create-animation';
+export * from './create-animation-declaration';
diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/components/dot/Dot.tsx b/desktop/packages/mullvad-vpn/src/renderer/lib/components/dot/Dot.tsx
index 3b1abf87cd..b5fceb055e 100644
--- a/desktop/packages/mullvad-vpn/src/renderer/lib/components/dot/Dot.tsx
+++ b/desktop/packages/mullvad-vpn/src/renderer/lib/components/dot/Dot.tsx
@@ -8,8 +8,8 @@ export interface DotProps {
}
const StyledDiv = styled.div<{ $size: string; $color: string }>`
- width: ${({ $size }) => $size};
- height: ${({ $size }) => $size};
+ min-width: ${({ $size }) => $size};
+ min-height: ${({ $size }) => $size};
border-radius: 50%;
background-color: ${({ $color }) => $color};
`;
diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/components/icon/types.ts b/desktop/packages/mullvad-vpn/src/renderer/lib/components/icon/types.ts
index 19440d2207..dbafdadb3c 100644
--- a/desktop/packages/mullvad-vpn/src/renderer/lib/components/icon/types.ts
+++ b/desktop/packages/mullvad-vpn/src/renderer/lib/components/icon/types.ts
@@ -30,5 +30,6 @@ export const icons = {
'search-circle': 'icon-search-circle',
search: 'icon-search',
'settings-filled': 'icon-settings-filled',
+ 'settings-partial': 'icon-settings-partial',
show: 'icon-show',
};
diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/components/list-item/ListItem.tsx b/desktop/packages/mullvad-vpn/src/renderer/lib/components/list-item/ListItem.tsx
index 7a099fe04d..3d969f3cb3 100644
--- a/desktop/packages/mullvad-vpn/src/renderer/lib/components/list-item/ListItem.tsx
+++ b/desktop/packages/mullvad-vpn/src/renderer/lib/components/list-item/ListItem.tsx
@@ -26,7 +26,7 @@ const StyledFlex = styled(Flex)`
const ListItem = ({ level = 0, disabled, children }: ListItemProps) => {
return (
<ListItemProvider level={level} disabled={disabled}>
- <StyledFlex $flexDirection="column" $gap="tiny">
+ <StyledFlex $flexDirection="column" $gap="tiny" $flex={1}>
{children}
</StyledFlex>
</ListItemProvider>
diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/components/list-item/components/ListItemContent.tsx b/desktop/packages/mullvad-vpn/src/renderer/lib/components/list-item/components/ListItemContent.tsx
deleted file mode 100644
index 612b7ce6bc..0000000000
--- a/desktop/packages/mullvad-vpn/src/renderer/lib/components/list-item/components/ListItemContent.tsx
+++ /dev/null
@@ -1,47 +0,0 @@
-import styled from 'styled-components';
-
-import { Flex, FlexProps } from '../../flex';
-import { levels } from '../levels';
-import { useListItem } from '../ListItemContext';
-
-const sizes = {
- full: '100%',
- small: '44px',
-};
-
-export const StyledFlex = styled(Flex)<{
- $level: keyof typeof levels;
- $disabled?: boolean;
- $size: 'full' | 'small';
-}>`
- width: ${({ $size }) => sizes[$size]};
- height: 100%;
- background-color: ${({ $disabled, $level }) =>
- $disabled ? levels[$level].disabled : levels[$level].enabled};
- &&:has(> :last-child:nth-child(1)) {
- &&:has(img) {
- justify-content: center;
- }
- }
-`;
-
-export interface ListItemContainerProps extends FlexProps {
- size?: 'full' | 'small';
-}
-
-export const ListItemContent = ({ size = 'full', ...props }: ListItemContainerProps) => {
- const { level } = useListItem();
- return (
- <StyledFlex
- $size={size}
- $level={level}
- $alignItems="center"
- $justifyContent="space-between"
- $gap="small"
- $padding={{
- horizontal: 'medium',
- }}
- {...props}
- />
- );
-};
diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/components/list-item/components/ListItemItem.tsx b/desktop/packages/mullvad-vpn/src/renderer/lib/components/list-item/components/ListItemItem.tsx
deleted file mode 100644
index 9bee51b0e9..0000000000
--- a/desktop/packages/mullvad-vpn/src/renderer/lib/components/list-item/components/ListItemItem.tsx
+++ /dev/null
@@ -1,19 +0,0 @@
-import styled from 'styled-components';
-
-export interface ListItemItemProps {
- children: React.ReactNode;
-}
-
-const StyledDiv = styled.div`
- min-height: 44px;
- width: 100%;
- display: grid;
- grid-template-columns: 1fr;
- &&:has(> :last-child:nth-child(2)) {
- grid-template-columns: 1fr 44px;
- }
-`;
-
-export const ListItemItem = ({ children }: ListItemItemProps) => {
- return <StyledDiv>{children}</StyledDiv>;
-};
diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/components/list-item/components/ListItemText.tsx b/desktop/packages/mullvad-vpn/src/renderer/lib/components/list-item/components/ListItemText.tsx
deleted file mode 100644
index cec3d51aef..0000000000
--- a/desktop/packages/mullvad-vpn/src/renderer/lib/components/list-item/components/ListItemText.tsx
+++ /dev/null
@@ -1,9 +0,0 @@
-import { Text, TextProps } from '../../typography';
-import { useListItem } from '../ListItemContext';
-
-export type ListItemProps<E extends React.ElementType = 'span'> = TextProps<E>;
-
-export const ListItemText = <E extends React.ElementType = 'span'>(props: ListItemProps<E>) => {
- const { disabled } = useListItem();
- return <Text variant="labelTiny" color={disabled ? 'whiteAlpha40' : 'whiteAlpha60'} {...props} />;
-};
diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/components/list-item/components/ListItemTrigger.tsx b/desktop/packages/mullvad-vpn/src/renderer/lib/components/list-item/components/ListItemTrigger.tsx
deleted file mode 100644
index a46c5a9139..0000000000
--- a/desktop/packages/mullvad-vpn/src/renderer/lib/components/list-item/components/ListItemTrigger.tsx
+++ /dev/null
@@ -1,38 +0,0 @@
-import styled, { css } from 'styled-components';
-
-import { colors } from '../../../foundations';
-import { ButtonBase } from '../../button';
-import { useListItem } from '../ListItemContext';
-import { StyledFlex } from './ListItemContent';
-
-const StyledButton = styled(ButtonBase)<{ $disabled?: boolean }>`
- display: flex;
- width: 100%;
- ${({ $disabled }) =>
- !$disabled &&
- css`
- &:hover ${StyledFlex} {
- background-color: ${colors.whiteOnBlue5};
- }
- &:active ${StyledFlex} {
- background-color: ${colors.whiteOnBlue10};
- }
- `}
-
- &&:focus-visible {
- outline: 2px solid ${colors.white};
- outline-offset: -1px;
- z-index: 10;
- }
-`;
-
-export type ListItemTriggerProps = React.HtmlHTMLAttributes<HTMLButtonElement>;
-
-export const ListItemTrigger = ({ children, ...props }: ListItemTriggerProps) => {
- const { disabled } = useListItem();
- return (
- <StyledButton $disabled={disabled} {...props}>
- {children}
- </StyledButton>
- );
-};
diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/components/list-item/components/index.ts b/desktop/packages/mullvad-vpn/src/renderer/lib/components/list-item/components/index.ts
index 69139b76ad..23a355f17d 100644
--- a/desktop/packages/mullvad-vpn/src/renderer/lib/components/list-item/components/index.ts
+++ b/desktop/packages/mullvad-vpn/src/renderer/lib/components/list-item/components/index.ts
@@ -1,7 +1,7 @@
-export * from './ListItemContent';
-export * from './ListItemItem';
-export * from './ListItemGroup';
-export * from './ListItemLabel';
-export * from './ListItemText';
-export * from './ListItemTrigger';
-export * from './ListItemFooter';
+export * from './list-item-content';
+export * from './list-item-item';
+export * from './list-item-group';
+export * from './list-item-label';
+export * from './list-item-text';
+export * from './list-item-trigger';
+export * from './list-item-footer';
diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/components/list-item/components/list-item-content/ListItemContent.tsx b/desktop/packages/mullvad-vpn/src/renderer/lib/components/list-item/components/list-item-content/ListItemContent.tsx
new file mode 100644
index 0000000000..6065e56871
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/lib/components/list-item/components/list-item-content/ListItemContent.tsx
@@ -0,0 +1,47 @@
+import styled, { css } from 'styled-components';
+
+import { Flex, FlexProps } from '../../../flex';
+
+const sizes = {
+ full: '100%',
+ small: '56px',
+};
+
+type Size = keyof typeof sizes;
+
+const StyledFlex = styled(Flex)<{
+ $size: Size;
+}>`
+ ${({ $size }) => {
+ const size = sizes[$size];
+ return css`
+ --size: ${size};
+ width: var(--size);
+ height: 100%;
+ &&:has(> :last-child:nth-child(1)) {
+ &&:has(img) {
+ justify-content: center;
+ }
+ }
+ `;
+ }}
+`;
+
+export interface ListItemContentProps extends FlexProps {
+ size?: Size;
+}
+
+export function ListItemContent({ size = 'full', ...props }: ListItemContentProps) {
+ return (
+ <StyledFlex
+ $size={size}
+ $alignItems="center"
+ $justifyContent="space-between"
+ $gap="small"
+ $padding={{
+ horizontal: 'medium',
+ }}
+ {...props}
+ />
+ );
+}
diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/components/list-item/components/list-item-content/index.ts b/desktop/packages/mullvad-vpn/src/renderer/lib/components/list-item/components/list-item-content/index.ts
new file mode 100644
index 0000000000..77fb91c041
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/lib/components/list-item/components/list-item-content/index.ts
@@ -0,0 +1 @@
+export * from './ListItemContent';
diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/components/list-item/components/ListItemFooter.tsx b/desktop/packages/mullvad-vpn/src/renderer/lib/components/list-item/components/list-item-footer/ListItemFooter.tsx
index dc83058377..c5b0de9355 100644
--- a/desktop/packages/mullvad-vpn/src/renderer/lib/components/list-item/components/ListItemFooter.tsx
+++ b/desktop/packages/mullvad-vpn/src/renderer/lib/components/list-item/components/list-item-footer/ListItemFooter.tsx
@@ -1,4 +1,4 @@
-import { Flex, FlexProps } from '../../flex';
+import { Flex, FlexProps } from '../../../flex';
export type ListItemFooterProps = FlexProps;
diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/components/list-item/components/list-item-footer/index.ts b/desktop/packages/mullvad-vpn/src/renderer/lib/components/list-item/components/list-item-footer/index.ts
new file mode 100644
index 0000000000..e47938de21
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/lib/components/list-item/components/list-item-footer/index.ts
@@ -0,0 +1 @@
+export * from './ListItemFooter';
diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/components/list-item/components/ListItemGroup.tsx b/desktop/packages/mullvad-vpn/src/renderer/lib/components/list-item/components/list-item-group/ListItemGroup.tsx
index 6e7b52ef49..b8f4c01ce6 100644
--- a/desktop/packages/mullvad-vpn/src/renderer/lib/components/list-item/components/ListItemGroup.tsx
+++ b/desktop/packages/mullvad-vpn/src/renderer/lib/components/list-item/components/list-item-group/ListItemGroup.tsx
@@ -1,4 +1,4 @@
-import { Flex, FlexProps } from '../../flex';
+import { Flex, FlexProps } from '../../../flex';
export type ListItemGroupProps = FlexProps;
diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/components/list-item/components/list-item-group/index.ts b/desktop/packages/mullvad-vpn/src/renderer/lib/components/list-item/components/list-item-group/index.ts
new file mode 100644
index 0000000000..df2d980e64
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/lib/components/list-item/components/list-item-group/index.ts
@@ -0,0 +1 @@
+export * from './ListItemGroup';
diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/components/list-item/components/list-item-item/ListItemItem.tsx b/desktop/packages/mullvad-vpn/src/renderer/lib/components/list-item/components/list-item-item/ListItemItem.tsx
new file mode 100644
index 0000000000..268297200d
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/lib/components/list-item/components/list-item-item/ListItemItem.tsx
@@ -0,0 +1,29 @@
+import styled, { css } from 'styled-components';
+
+import { useBackgroundColor } from './hooks';
+
+export interface ListItemItemProps {
+ children: React.ReactNode;
+}
+
+const StyledDiv = styled.div<{ $backgroundColor: string }>`
+ ${({ $backgroundColor }) => {
+ return css`
+ --background-color: ${$backgroundColor};
+ background-color: var(--background-color);
+ min-height: 44px;
+ width: 100%;
+ display: grid;
+ grid-template-columns: 1fr;
+ background-color: var(--background-color);
+ &&:has(> :last-child:nth-child(2)) {
+ grid-template-columns: 1fr 56px;
+ }
+ `;
+ }}
+`;
+
+export function ListItemItem({ children }: ListItemItemProps) {
+ const backgroundColor = useBackgroundColor();
+ return <StyledDiv $backgroundColor={backgroundColor}>{children}</StyledDiv>;
+}
diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/components/list-item/components/list-item-item/hooks/index.ts b/desktop/packages/mullvad-vpn/src/renderer/lib/components/list-item/components/list-item-item/hooks/index.ts
new file mode 100644
index 0000000000..9cc5be2e57
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/lib/components/list-item/components/list-item-item/hooks/index.ts
@@ -0,0 +1 @@
+export * from './useBackgroundColor';
diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/components/list-item/components/list-item-item/hooks/useBackgroundColor.tsx b/desktop/packages/mullvad-vpn/src/renderer/lib/components/list-item/components/list-item-item/hooks/useBackgroundColor.tsx
new file mode 100644
index 0000000000..68bc841f48
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/lib/components/list-item/components/list-item-item/hooks/useBackgroundColor.tsx
@@ -0,0 +1,7 @@
+import { levels } from '../../../levels';
+import { useListItem } from '../../../ListItemContext';
+
+export const useBackgroundColor = () => {
+ const { level, disabled } = useListItem();
+ return disabled ? levels[level].disabled : levels[level].enabled;
+};
diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/components/list-item/components/list-item-item/index.ts b/desktop/packages/mullvad-vpn/src/renderer/lib/components/list-item/components/list-item-item/index.ts
new file mode 100644
index 0000000000..5d90636a73
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/lib/components/list-item/components/list-item-item/index.ts
@@ -0,0 +1 @@
+export * from './ListItemItem';
diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/components/list-item/components/ListItemLabel.tsx b/desktop/packages/mullvad-vpn/src/renderer/lib/components/list-item/components/list-item-label/ListItemLabel.tsx
index 85fff79648..c5231e9fe1 100644
--- a/desktop/packages/mullvad-vpn/src/renderer/lib/components/list-item/components/ListItemLabel.tsx
+++ b/desktop/packages/mullvad-vpn/src/renderer/lib/components/list-item/components/list-item-label/ListItemLabel.tsx
@@ -1,5 +1,5 @@
-import { LabelTinyProps, TitleMedium } from '../../typography';
-import { useListItem } from '../ListItemContext';
+import { LabelTinyProps, TitleMedium } from '../../../typography';
+import { useListItem } from '../../ListItemContext';
export type ListItemLabelProps<E extends React.ElementType = 'span'> = LabelTinyProps<E>;
diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/components/list-item/components/list-item-label/index.ts b/desktop/packages/mullvad-vpn/src/renderer/lib/components/list-item/components/list-item-label/index.ts
new file mode 100644
index 0000000000..c2e29a2454
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/lib/components/list-item/components/list-item-label/index.ts
@@ -0,0 +1 @@
+export * from './ListItemLabel';
diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/components/list-item/components/list-item-text/ListItemText.tsx b/desktop/packages/mullvad-vpn/src/renderer/lib/components/list-item/components/list-item-text/ListItemText.tsx
new file mode 100644
index 0000000000..423c9c24c4
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/lib/components/list-item/components/list-item-text/ListItemText.tsx
@@ -0,0 +1,9 @@
+import { Text, TextProps } from '../../../typography';
+import { useListItem } from '../../ListItemContext';
+
+export type ListItemTextProps<E extends React.ElementType = 'span'> = TextProps<E>;
+
+export const ListItemText = <E extends React.ElementType = 'span'>(props: ListItemTextProps<E>) => {
+ const { disabled } = useListItem();
+ return <Text variant="labelTiny" color={disabled ? 'whiteAlpha40' : 'whiteAlpha60'} {...props} />;
+};
diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/components/list-item/components/list-item-text/index.ts b/desktop/packages/mullvad-vpn/src/renderer/lib/components/list-item/components/list-item-text/index.ts
new file mode 100644
index 0000000000..d093b5eec9
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/lib/components/list-item/components/list-item-text/index.ts
@@ -0,0 +1 @@
+export * from './ListItemText';
diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/components/list-item/components/list-item-trigger/ListItemTrigger.tsx b/desktop/packages/mullvad-vpn/src/renderer/lib/components/list-item/components/list-item-trigger/ListItemTrigger.tsx
new file mode 100644
index 0000000000..13aaa74a8f
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/lib/components/list-item/components/list-item-trigger/ListItemTrigger.tsx
@@ -0,0 +1,46 @@
+import styled, { css } from 'styled-components';
+
+import { colors } from '../../../../foundations';
+import { ButtonBase } from '../../../button';
+import { ListItemProps } from '../../ListItem';
+import { useListItem } from '../../ListItemContext';
+
+const StyledButton = styled(ButtonBase)<Pick<ListItemProps, 'disabled'>>`
+ display: flex;
+ width: 100%;
+ --background: transparent;
+ background-color: var(--background);
+
+ &&:focus-visible {
+ outline: 2px solid ${colors.white};
+ outline-offset: -2px;
+ z-index: 10;
+ }
+
+ ${({ disabled }) => {
+ if (!disabled) {
+ return css`
+ --background: ${colors.blue};
+
+ &:hover {
+ --background: ${colors.whiteOnBlue10};
+ background-color: var(--background);
+ }
+
+ &:active {
+ --background: ${colors.whiteOnBlue20};
+ background-color: var(--background);
+ }
+ `;
+ }
+
+ return null;
+ }}
+`;
+
+export type ListItemTriggerProps = React.HtmlHTMLAttributes<HTMLButtonElement>;
+
+export function ListItemTrigger(props: ListItemTriggerProps) {
+ const { disabled } = useListItem();
+ return <StyledButton disabled={disabled} {...props} />;
+}
diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/components/list-item/components/list-item-trigger/index.ts b/desktop/packages/mullvad-vpn/src/renderer/lib/components/list-item/components/list-item-trigger/index.ts
new file mode 100644
index 0000000000..a6091831b4
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/lib/components/list-item/components/list-item-trigger/index.ts
@@ -0,0 +1 @@
+export * from './ListItemTrigger';
diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/components/progress/Progress.tsx b/desktop/packages/mullvad-vpn/src/renderer/lib/components/progress/Progress.tsx
index ba062f0898..d16fb8e6fd 100644
--- a/desktop/packages/mullvad-vpn/src/renderer/lib/components/progress/Progress.tsx
+++ b/desktop/packages/mullvad-vpn/src/renderer/lib/components/progress/Progress.tsx
@@ -23,7 +23,7 @@ const Progress = React.forwardRef<HTMLDivElement, ProgressProps>(
const percent = ((normalizedValue - min) / (max - min)) * 100;
return (
<ProgressProvider value={value} min={min} max={max} percent={percent} disabled={disabled}>
- <Flex $flexDirection="column" $gap="small" ref={ref} {...props}>
+ <Flex $flexDirection="column" $gap="small" ref={ref} $flex={1} {...props}>
{children}
</Flex>
</ProgressProvider>
diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/history.tsx b/desktop/packages/mullvad-vpn/src/renderer/lib/history.tsx
index afea9582ab..f209dc8acf 100644
--- a/desktop/packages/mullvad-vpn/src/renderer/lib/history.tsx
+++ b/desktop/packages/mullvad-vpn/src/renderer/lib/history.tsx
@@ -2,8 +2,8 @@ import { Action, History as OriginalHistory, Location, LocationDescriptorObject
import { useHistory as useReactRouterHistory } from 'react-router';
import { IHistoryObject, LocationState } from '../../shared/ipc-types';
+import { RoutePath } from '../../shared/routes';
import { GeneratedRoutePath } from './routeHelpers';
-import { RoutePath } from './routes';
export enum TransitionType {
show,
@@ -216,6 +216,7 @@ export default class History {
scrollPosition: state?.scrollPosition ?? [0, 0],
expandedSections: state?.expandedSections ?? {},
transition: state?.transition ?? TransitionType.none,
+ options: state?.options,
};
}
diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/notifications/app-upgrade-available.ts b/desktop/packages/mullvad-vpn/src/renderer/lib/notifications/app-upgrade-available.ts
new file mode 100644
index 0000000000..a87d260d1a
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/lib/notifications/app-upgrade-available.ts
@@ -0,0 +1,96 @@
+import { sprintf } from 'sprintf-js';
+
+import { messages } from '../../../shared/gettext';
+import { InAppNotification, InAppNotificationProvider } from '../../../shared/notifications';
+import { RoutePath } from '../../../shared/routes';
+import { getDownloadUrl } from '../../../shared/version';
+
+interface AppUpgradeAvailableNotificationContext {
+ suggestedUpgradeVersion?: string;
+ suggestedIsBeta?: boolean;
+ updateDismissedForVersion?: string;
+ platform: NodeJS.Platform;
+ close: () => void;
+}
+
+export class AppUpgradeAvailableNotificationProvider implements InAppNotificationProvider {
+ public constructor(private context: AppUpgradeAvailableNotificationContext) {}
+
+ public mayDisplay(): boolean {
+ const { suggestedUpgradeVersion, suggestedIsBeta, updateDismissedForVersion } = this.context;
+ if (!suggestedUpgradeVersion) {
+ return false;
+ }
+ if (suggestedIsBeta && suggestedUpgradeVersion === updateDismissedForVersion) {
+ return false;
+ }
+ return true;
+ }
+
+ public getInAppNotification(): InAppNotification {
+ const { close, platform, suggestedIsBeta } = this.context;
+ const isLinux = platform === 'linux';
+
+ return {
+ indicator: 'warning',
+ title: suggestedIsBeta
+ ? messages.pgettext('in-app-notifications', 'BETA UPDATE AVAILABLE')
+ : messages.pgettext('in-app-notifications', 'UPDATE AVAILABLE'),
+ subtitle: [
+ {
+ content: this.inAppMessage(),
+ },
+ {
+ content:
+ // TRANSLATORS: Link text to go to the app upgrade view
+ messages.pgettext('in-app-notifications', 'Click here to update'),
+ action: isLinux
+ ? {
+ type: 'navigate-external',
+ link: {
+ to: getDownloadUrl(suggestedIsBeta ?? false),
+ 'aria-label':
+ // TRANSLATORS: Accessbility label for link to go to download page.
+ messages.pgettext(
+ 'accessibility',
+ 'New version available, click here to go to download page, opens externally',
+ ),
+ },
+ }
+ : {
+ type: 'navigate-internal',
+ link: {
+ to: RoutePath.appUpgrade,
+ // TRANSLATORS: Accessbility label for link to go to upgrade view.
+ 'aria-label': messages.pgettext(
+ 'accessibility',
+ 'New version available, click here to go to update view',
+ ),
+ },
+ },
+ },
+ ],
+ action: suggestedIsBeta ? { type: 'close', close } : undefined,
+ };
+ }
+
+ private inAppMessage(): string {
+ const { suggestedIsBeta, suggestedUpgradeVersion } = this.context;
+ if (suggestedIsBeta) {
+ return sprintf(
+ // TRANSLATORS: The in-app banner displayed to the user when the app beta update is
+ // TRANSLATORS: available.
+ // TRANSLATORS: Available placeholders:
+ // TRANSLATORS: %(version)s - The version number of the new beta version.
+ messages.pgettext('in-app-notifications', 'Try out the newest beta version (%(version)s).'),
+ { version: suggestedUpgradeVersion },
+ );
+ } else {
+ // TRANSLATORS: The in-app banner displayed to the user when the app update is available.
+ return messages.pgettext(
+ 'in-app-notifications',
+ 'Install the latest app version to stay up to date.',
+ );
+ }
+ }
+}
diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/notifications/app-upgrade-error.ts b/desktop/packages/mullvad-vpn/src/renderer/lib/notifications/app-upgrade-error.ts
new file mode 100644
index 0000000000..706669bd4e
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/lib/notifications/app-upgrade-error.ts
@@ -0,0 +1,157 @@
+import { AppUpgradeError } from '../../../shared/app-upgrade';
+import { messages } from '../../../shared/gettext';
+import {
+ InAppNotification,
+ InAppNotificationProvider,
+ InAppNotificationSubtitle,
+} from '../../../shared/notifications';
+
+interface AppUpgradeErrorNotificationContext {
+ hasAppUpgradeError: boolean;
+ appUpgradeError?: AppUpgradeError;
+ restartAppUpgrade: () => void;
+ restartAppUpgradeInstaller: () => void;
+}
+
+export class AppUpgradeErrorNotificationProvider implements InAppNotificationProvider {
+ public constructor(private context: AppUpgradeErrorNotificationContext) {}
+
+ public mayDisplay = () => {
+ return this.context.hasAppUpgradeError;
+ };
+
+ public getInAppNotification(): InAppNotification {
+ const { appUpgradeError } = this.context;
+ const retrySubtitle: InAppNotificationSubtitle = {
+ content:
+ // TRANSLATORS: Notification subtitle when the download of the installer failed
+ // TRANSLATORS: and the user can try downloading again.
+ messages.pgettext('in-app-notifications', 'Click here to retry download'),
+ action: {
+ type: 'run-function',
+ button: {
+ onClick: () => this.context.restartAppUpgrade(),
+ 'aria-label':
+ // TRANSLATORS: Accessibility label for the button to retry download of the installer.
+ messages.pgettext('in-app-notifications', 'Retry download of the installer'),
+ },
+ },
+ };
+
+ if (appUpgradeError) {
+ if (appUpgradeError === 'VERIFICATION_FAILED') {
+ return {
+ indicator: 'error',
+ title:
+ // TRANSLATORS: Notification title when the installer verification failed.
+ messages.pgettext('in-app-notifications', 'VERIFICATION FAILED'),
+ subtitle: [
+ {
+ content:
+ // TRANSLATORS: Notification subtitle when the installer verification failed.
+ messages.pgettext('in-app-notifications', 'The installer could not be verified.'),
+ },
+ retrySubtitle,
+ ],
+ };
+ }
+ if (appUpgradeError === 'DOWNLOAD_FAILED') {
+ return {
+ indicator: 'error',
+ title:
+ // TRANSLATORS: Notification title when the installer download failed.
+ messages.pgettext('in-app-notifications', 'DOWNLOAD FAILED'),
+ subtitle: [
+ {
+ content:
+ // TRANSLATORS: Notification subtitle when the installer download failed.
+ messages.pgettext('in-app-notifications', 'Could not download installer.'),
+ },
+ retrySubtitle,
+ ],
+ };
+ }
+
+ if (appUpgradeError === 'START_INSTALLER_FAILED') {
+ return {
+ indicator: 'error',
+ title:
+ // TRANSLATORS: Notification title when the installer failed.
+ messages.pgettext('in-app-notifications', 'INSTALLER FAILED'),
+ subtitle: [
+ {
+ content:
+ // TRANSLATORS: Notification subtitle when the installer failed.
+ messages.pgettext(
+ 'in-app-notifications',
+ 'The installer did not start successfully.',
+ ),
+ },
+ {
+ content:
+ // TRANSLATORS: Notification subtitle when the installer failed.
+ messages.pgettext('in-app-notifications', 'Click here to retry'),
+ action: {
+ type: 'run-function',
+ button: {
+ onClick: () => this.context.restartAppUpgradeInstaller(),
+ 'aria-label':
+ // TRANSLATORS: Accessibility label for the button to retry the installation.
+ messages.pgettext('in-app-notifications', 'Retry installation'),
+ },
+ },
+ },
+ ],
+ };
+ }
+
+ if (appUpgradeError === 'INSTALLER_FAILED') {
+ return {
+ indicator: 'error',
+ title:
+ // TRANSLATORS: Notification title when the installer failed.
+ messages.pgettext('in-app-notifications', 'INSTALLER FAILED'),
+ subtitle: [
+ {
+ content:
+ // TRANSLATORS: Notification subtitle when the installer failed.
+ messages.pgettext(
+ 'in-app-notifications',
+ 'The installer did not complete successfully.',
+ ),
+ },
+ {
+ content:
+ // TRANSLATORS: Notification subtitle when the installer failed.
+ messages.pgettext('in-app-notifications', 'Click here to retry'),
+ action: {
+ type: 'run-function',
+ button: {
+ onClick: () => this.context.restartAppUpgradeInstaller(),
+ 'aria-label':
+ // TRANSLATORS: Accessibility label for the button to retry the installation.
+ messages.pgettext('in-app-notifications', 'Retry installation'),
+ },
+ },
+ },
+ ],
+ };
+ }
+ }
+
+ return {
+ indicator: 'error',
+ title:
+ // TRANSLATORS: Generic notification title when the app upgrade failed.
+ messages.pgettext('in-app-notifications', 'UPDATE FAILED'),
+ subtitle: [
+ {
+ content:
+ // TRANSLATORS: Generic notification subtitle when the app upgrade failed.
+ messages.pgettext('in-app-notifications', 'Could not upgrade the app.'),
+ },
+ retrySubtitle,
+ ],
+ };
+ }
+}
diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/notifications/app-upgrade-progress.ts b/desktop/packages/mullvad-vpn/src/renderer/lib/notifications/app-upgrade-progress.ts
new file mode 100644
index 0000000000..a4810e2b15
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/lib/notifications/app-upgrade-progress.ts
@@ -0,0 +1,89 @@
+import { sprintf } from 'sprintf-js';
+
+import { AppUpgradeEvent, AppUpgradeStep } from '../../../shared/app-upgrade';
+import { messages } from '../../../shared/gettext';
+import { InAppNotification, InAppNotificationProvider } from '../../../shared/notifications';
+
+interface AppUpgradeProgressNotificationContext {
+ appUpgradeStep: AppUpgradeStep;
+ appUpgradeDownloadProgressValue: number;
+ appUpgradeEventType?: AppUpgradeEvent['type'];
+}
+
+export class AppUpgradeProgressNotificationProvider implements InAppNotificationProvider {
+ public constructor(private context: AppUpgradeProgressNotificationContext) {}
+
+ public mayDisplay = () => {
+ return (
+ this.context.appUpgradeStep === 'download' ||
+ this.context.appUpgradeStep === 'launch' ||
+ this.context.appUpgradeStep === 'verify'
+ );
+ };
+
+ public getInAppNotification(): InAppNotification {
+ const { appUpgradeDownloadProgressValue, appUpgradeEventType } = this.context;
+ if (appUpgradeEventType === 'APP_UPGRADE_STATUS_DOWNLOAD_PROGRESS') {
+ return {
+ indicator: 'warning',
+ title: sprintf(
+ // TRANSLATORS: Notification title when the app upgrade is in progress.
+ // TRANSLATORS: Available placeholders:
+ // TRANSLATORS: - %(appUpgradeDownloadProgressValue)s: The download progress value.
+ messages.pgettext(
+ 'in-app-notifications',
+ 'DOWNLOADING UPDATE... %(appUpgradeDownloadProgressValue)s%%',
+ ),
+ {
+ appUpgradeDownloadProgressValue,
+ },
+ ),
+ };
+ }
+
+ if (appUpgradeEventType === 'APP_UPGRADE_STATUS_VERIFYING_INSTALLER') {
+ return {
+ indicator: 'warning',
+ title:
+ // TRANSLATORS: Notification title when app upgrade is verifying the installer.
+ messages.pgettext('in-app-notifications', 'DOWNLOAD COMPLETE! VERIFYING...'),
+ };
+ }
+
+ if (
+ appUpgradeEventType === 'APP_UPGRADE_STATUS_MANUAL_START_INSTALLER' ||
+ appUpgradeEventType === 'APP_UPGRADE_STATUS_VERIFIED_INSTALLER' ||
+ appUpgradeEventType === 'APP_UPGRADE_STATUS_EXITED_INSTALLER'
+ ) {
+ return {
+ indicator: 'warning',
+ title:
+ // TRANSLATORS: Notification title when app upgrade is ready for the user to launch the installer.
+ messages.pgettext('in-app-notifications', 'VERIFICATION COMPLETE! INSTALLER READY!'),
+ };
+ }
+
+ if (
+ appUpgradeEventType === 'APP_UPGRADE_STATUS_AUTOMATIC_STARTING_INSTALLER' ||
+ appUpgradeEventType === 'APP_UPGRADE_STATUS_MANUAL_STARTING_INSTALLER' ||
+ appUpgradeEventType === 'APP_UPGRADE_STATUS_STARTED_INSTALLER'
+ ) {
+ return {
+ indicator: 'warning',
+ title:
+ // TRANSLATORS: Notification title when app upgrade is launching the installer.
+ messages.pgettext(
+ 'in-app-notifications',
+ 'VERIFICATION COMPLETE! LAUNCHING INSTALLER...',
+ ),
+ };
+ }
+
+ return {
+ indicator: 'warning',
+ title:
+ // TRANSLATORS: Generic notification title when app upgrade is downloading the installer.
+ messages.pgettext('in-app-notifications', 'DOWNLOADING UPDATE...'),
+ };
+ }
+}
diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/notifications/app-upgrade-ready.ts b/desktop/packages/mullvad-vpn/src/renderer/lib/notifications/app-upgrade-ready.ts
new file mode 100644
index 0000000000..ab32b1a200
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/lib/notifications/app-upgrade-ready.ts
@@ -0,0 +1,61 @@
+import { sprintf } from 'sprintf-js';
+
+import { AppUpgradeEvent } from '../../../shared/app-upgrade';
+import { messages } from '../../../shared/gettext';
+import { InAppNotification, InAppNotificationProvider } from '../../../shared/notifications';
+import { RoutePath } from '../../../shared/routes';
+
+interface AppUpgradeReadyNotificationContext {
+ appUpgradeEventType?: AppUpgradeEvent['type'];
+ suggestedUpgradeVersion?: string;
+}
+
+export class AppUpgradeReadyNotificationProvider implements InAppNotificationProvider {
+ public constructor(private context: AppUpgradeReadyNotificationContext) {}
+
+ public mayDisplay = () => {
+ return (
+ this.context.appUpgradeEventType === 'APP_UPGRADE_STATUS_EXITED_INSTALLER' ||
+ this.context.appUpgradeEventType === 'APP_UPGRADE_STATUS_MANUAL_START_INSTALLER'
+ );
+ };
+
+ public getInAppNotification(): InAppNotification {
+ return {
+ indicator: 'warning',
+ title:
+ // TRANSLATORS: Notification title when the app upgrade is ready to install.
+ messages.pgettext('in-app-notifications', 'READY TO INSTALL UPDATE'),
+ subtitle: [
+ {
+ content: sprintf(
+ // TRANSLATORS: Notification subtitle when the app upgrade is ready to install.
+ // TRANSLATORS: Available placeholders:
+ // TRANSLATORS: - %(suggestedUpgradeVersion)s: Upgrade version to be installed.
+ messages.pgettext(
+ 'in-app-notifications',
+ '%(suggestedUpgradeVersion)s is ready to be installed.',
+ ),
+ {
+ suggestedUpgradeVersion: this.context.suggestedUpgradeVersion,
+ },
+ ),
+ },
+ {
+ content:
+ // TRANSLATORS: Notification subtitle when the app upgrade is ready to install.
+ messages.pgettext('in-app-notifications', 'Click here to install update.'),
+ action: {
+ type: 'navigate-internal',
+ link: {
+ to: RoutePath.appUpgrade,
+ 'aria-label':
+ // TRANSLATORS: Accessibility label for link to app upgrade view.
+ messages.pgettext('accessibility', 'Go to app update page'),
+ },
+ },
+ },
+ ],
+ };
+ }
+}
diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/notifications/index.ts b/desktop/packages/mullvad-vpn/src/renderer/lib/notifications/index.ts
index 1e5d95339f..2a90aaae4f 100644
--- a/desktop/packages/mullvad-vpn/src/renderer/lib/notifications/index.ts
+++ b/desktop/packages/mullvad-vpn/src/renderer/lib/notifications/index.ts
@@ -3,3 +3,6 @@ export * from './new-version';
export * from './open-vpn-support-ending';
export * from './no-open-vpn-server-available';
export * from './unsupported-wireguard-port';
+export * from './app-upgrade-progress';
+export * from './app-upgrade-error';
+export * from './app-upgrade-ready';
diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/notifications/new-version.ts b/desktop/packages/mullvad-vpn/src/renderer/lib/notifications/new-version.ts
index 0bccec27a0..728f458426 100644
--- a/desktop/packages/mullvad-vpn/src/renderer/lib/notifications/new-version.ts
+++ b/desktop/packages/mullvad-vpn/src/renderer/lib/notifications/new-version.ts
@@ -1,7 +1,7 @@
import { messages } from '../../../shared/gettext';
import { IChangelog } from '../../../shared/ipc-types';
import { InAppNotification, InAppNotificationProvider } from '../../../shared/notifications';
-import { RoutePath } from '../routes';
+import { RoutePath } from '../../../shared/routes';
interface NewVersionNotificationContext {
currentVersion: string;
diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/notifications/no-open-vpn-server-available.ts b/desktop/packages/mullvad-vpn/src/renderer/lib/notifications/no-open-vpn-server-available.ts
index 566a61c80b..df1e439205 100644
--- a/desktop/packages/mullvad-vpn/src/renderer/lib/notifications/no-open-vpn-server-available.ts
+++ b/desktop/packages/mullvad-vpn/src/renderer/lib/notifications/no-open-vpn-server-available.ts
@@ -12,12 +12,12 @@ import {
InAppNotificationProvider,
InAppNotificationSubtitle,
} from '../../../shared/notifications';
+import { RoutePath } from '../../../shared/routes';
import { IConnectionReduxState } from '../../redux/connection/reducers';
import {
IRelayLocationCountryRedux,
IRelayLocationRelayRedux,
} from '../../redux/settings/reducers';
-import { RoutePath } from '../routes';
interface NoOpenVpnServerAvailableNotificationContext {
connection: IConnectionReduxState;
diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/notifications/unsupported-wireguard-port.ts b/desktop/packages/mullvad-vpn/src/renderer/lib/notifications/unsupported-wireguard-port.ts
index 6f3ebf327b..e55956be30 100644
--- a/desktop/packages/mullvad-vpn/src/renderer/lib/notifications/unsupported-wireguard-port.ts
+++ b/desktop/packages/mullvad-vpn/src/renderer/lib/notifications/unsupported-wireguard-port.ts
@@ -4,10 +4,10 @@ import { strings } from '../../../shared/constants';
import { TunnelProtocol } from '../../../shared/daemon-rpc-types';
import { messages } from '../../../shared/gettext';
import { InAppNotification, InAppNotificationProvider } from '../../../shared/notifications';
+import { RoutePath } from '../../../shared/routes';
import { isInRanges } from '../../../shared/utils';
import { IConnectionReduxState } from '../../redux/connection/reducers';
import { RelaySettingsRedux } from '../../redux/settings/reducers';
-import { RoutePath } from '../routes';
interface UnsupportedWireGuardPortNotificationContext {
connection: IConnectionReduxState;
diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/routeHelpers.ts b/desktop/packages/mullvad-vpn/src/renderer/lib/routeHelpers.ts
index 50c5867768..e610a2ac77 100644
--- a/desktop/packages/mullvad-vpn/src/renderer/lib/routeHelpers.ts
+++ b/desktop/packages/mullvad-vpn/src/renderer/lib/routeHelpers.ts
@@ -1,6 +1,6 @@
import { generatePath } from 'react-router';
-import { RoutePath } from './routes';
+import { RoutePath } from '../../shared/routes';
export type GeneratedRoutePath = { routePath: string };
diff --git a/desktop/packages/mullvad-vpn/src/renderer/redux/account/hooks/index.ts b/desktop/packages/mullvad-vpn/src/renderer/redux/account/hooks/index.ts
new file mode 100644
index 0000000000..223bde8b64
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/redux/account/hooks/index.ts
@@ -0,0 +1 @@
+export * from './useAccountStatus';
diff --git a/desktop/packages/mullvad-vpn/src/renderer/redux/account/hooks/useAccountStatus.tsx b/desktop/packages/mullvad-vpn/src/renderer/redux/account/hooks/useAccountStatus.tsx
new file mode 100644
index 0000000000..9702dbfc56
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/redux/account/hooks/useAccountStatus.tsx
@@ -0,0 +1,7 @@
+import { useSelector } from '../../store';
+
+export const useAccountStatus = () => {
+ return {
+ status: useSelector((state) => state.account.status),
+ };
+};
diff --git a/desktop/packages/mullvad-vpn/src/renderer/redux/app-upgrade/actions.ts b/desktop/packages/mullvad-vpn/src/renderer/redux/app-upgrade/actions.ts
new file mode 100644
index 0000000000..8ae5cebdfe
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/redux/app-upgrade/actions.ts
@@ -0,0 +1,62 @@
+import { AppUpgradeError, AppUpgradeEvent } from '../../../shared/app-upgrade';
+
+export type AppUpgradeActionReset = {
+ type: 'APP_UPGRADE_RESET';
+};
+
+export const resetAppUpgrade = (): AppUpgradeActionReset => ({
+ type: 'APP_UPGRADE_RESET',
+});
+
+export type AppUpgradeActionResetError = {
+ type: 'APP_UPGRADE_RESET_ERROR';
+};
+
+export const resetAppUpgradeError = (): AppUpgradeActionResetError => ({
+ type: 'APP_UPGRADE_RESET_ERROR',
+});
+
+export type AppUpgradeActionSetError = {
+ type: 'APP_UPGRADE_SET_ERROR';
+ error: AppUpgradeError;
+};
+
+export type AppUpgradeActionSetLastProgress = {
+ type: 'APP_UPGRADE_SET_LAST_PROGRESS';
+ lastProgress: number;
+};
+
+export const setAppUpgradeError = (error: AppUpgradeError): AppUpgradeActionSetError => ({
+ type: 'APP_UPGRADE_SET_ERROR',
+ error,
+});
+
+export type AppUpgradeActionSetEvent = {
+ type: 'APP_UPGRADE_SET_EVENT';
+ event: AppUpgradeEvent;
+};
+
+export const setAppUpgradeEvent = (event: AppUpgradeEvent): AppUpgradeActionSetEvent => ({
+ type: 'APP_UPGRADE_SET_EVENT',
+ event,
+});
+
+export const setLastProgress = (lastProgress: number): AppUpgradeActionSetLastProgress => ({
+ type: 'APP_UPGRADE_SET_LAST_PROGRESS',
+ lastProgress,
+});
+
+export const appUpgradeActions = {
+ resetAppUpgrade,
+ resetAppUpgradeError,
+ setAppUpgradeError,
+ setAppUpgradeEvent,
+ setLastProgress,
+};
+
+export type AppUpgradeAction =
+ | AppUpgradeActionReset
+ | AppUpgradeActionResetError
+ | AppUpgradeActionSetError
+ | AppUpgradeActionSetEvent
+ | AppUpgradeActionSetLastProgress;
diff --git a/desktop/packages/mullvad-vpn/src/renderer/redux/app-upgrade/helpers/convertEventTypeToStep.ts b/desktop/packages/mullvad-vpn/src/renderer/redux/app-upgrade/helpers/convertEventTypeToStep.ts
new file mode 100644
index 0000000000..81fd03b78b
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/redux/app-upgrade/helpers/convertEventTypeToStep.ts
@@ -0,0 +1,25 @@
+import { AppUpgradeEvent, AppUpgradeStep } from '../../../../shared/app-upgrade';
+
+export const convertEventTypeToStep = (
+ appUpgradeEventType: AppUpgradeEvent['type'] | undefined,
+): AppUpgradeStep => {
+ switch (appUpgradeEventType) {
+ case 'APP_UPGRADE_STATUS_DOWNLOAD_INITIATED':
+ case 'APP_UPGRADE_STATUS_DOWNLOAD_PROGRESS':
+ case 'APP_UPGRADE_STATUS_DOWNLOAD_STARTED':
+ return 'download';
+ case 'APP_UPGRADE_STATUS_AUTOMATIC_STARTING_INSTALLER':
+ case 'APP_UPGRADE_STATUS_EXITED_INSTALLER':
+ case 'APP_UPGRADE_STATUS_MANUAL_START_INSTALLER':
+ case 'APP_UPGRADE_STATUS_MANUAL_STARTING_INSTALLER':
+ case 'APP_UPGRADE_STATUS_STARTED_INSTALLER':
+ case 'APP_UPGRADE_STATUS_VERIFIED_INSTALLER':
+ return 'launch';
+ case 'APP_UPGRADE_STATUS_ABORTED':
+ return 'pause';
+ case 'APP_UPGRADE_STATUS_VERIFYING_INSTALLER':
+ return 'verify';
+ default:
+ return 'initial';
+ }
+};
diff --git a/desktop/packages/mullvad-vpn/src/renderer/redux/app-upgrade/helpers/index.ts b/desktop/packages/mullvad-vpn/src/renderer/redux/app-upgrade/helpers/index.ts
new file mode 100644
index 0000000000..7c59828176
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/redux/app-upgrade/helpers/index.ts
@@ -0,0 +1 @@
+export * from './convertEventTypeToStep';
diff --git a/desktop/packages/mullvad-vpn/src/renderer/redux/app-upgrade/hooks/index.ts b/desktop/packages/mullvad-vpn/src/renderer/redux/app-upgrade/hooks/index.ts
new file mode 100644
index 0000000000..255790f0d6
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/redux/app-upgrade/hooks/index.ts
@@ -0,0 +1,4 @@
+export * from './useAppUpgradeError';
+export * from './useAppUpgradeErrorCount';
+export * from './useAppUpgradeEvent';
+export * from './useAppUpgradeLastProgress';
diff --git a/desktop/packages/mullvad-vpn/src/renderer/redux/app-upgrade/hooks/useAppUpgradeError.ts b/desktop/packages/mullvad-vpn/src/renderer/redux/app-upgrade/hooks/useAppUpgradeError.ts
new file mode 100644
index 0000000000..dcc7ffbc32
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/redux/app-upgrade/hooks/useAppUpgradeError.ts
@@ -0,0 +1,9 @@
+import { useSelector } from '../../store';
+import { setAppUpgradeError } from '../actions';
+
+export const useAppUpgradeError = () => {
+ return {
+ error: useSelector((state) => state.appUpgrade.error),
+ setAppUpgradeError,
+ };
+};
diff --git a/desktop/packages/mullvad-vpn/src/renderer/redux/app-upgrade/hooks/useAppUpgradeErrorCount.ts b/desktop/packages/mullvad-vpn/src/renderer/redux/app-upgrade/hooks/useAppUpgradeErrorCount.ts
new file mode 100644
index 0000000000..6946f32180
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/redux/app-upgrade/hooks/useAppUpgradeErrorCount.ts
@@ -0,0 +1,7 @@
+import { useSelector } from '../../store';
+
+export const useAppUpgradeErrorCount = () => {
+ return {
+ errorCount: useSelector((state) => state.appUpgrade.errorCount),
+ };
+};
diff --git a/desktop/packages/mullvad-vpn/src/renderer/redux/app-upgrade/hooks/useAppUpgradeEvent.ts b/desktop/packages/mullvad-vpn/src/renderer/redux/app-upgrade/hooks/useAppUpgradeEvent.ts
new file mode 100644
index 0000000000..62e8ea940d
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/redux/app-upgrade/hooks/useAppUpgradeEvent.ts
@@ -0,0 +1,9 @@
+import { useSelector } from '../../store';
+import { setAppUpgradeEvent } from '../actions';
+
+export const useAppUpgradeEvent = () => {
+ return {
+ event: useSelector((state) => state.appUpgrade.event),
+ setAppUpgradeEvent,
+ };
+};
diff --git a/desktop/packages/mullvad-vpn/src/renderer/redux/app-upgrade/hooks/useAppUpgradeLastProgress.ts b/desktop/packages/mullvad-vpn/src/renderer/redux/app-upgrade/hooks/useAppUpgradeLastProgress.ts
new file mode 100644
index 0000000000..59d6099c69
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/redux/app-upgrade/hooks/useAppUpgradeLastProgress.ts
@@ -0,0 +1,9 @@
+import { useSelector } from '../../store';
+import { setLastProgress } from '../actions';
+
+export const useAppUpgradeLastProgress = () => {
+ return {
+ lastProgress: useSelector((state) => state.appUpgrade.lastProgress),
+ setLastProgress,
+ };
+};
diff --git a/desktop/packages/mullvad-vpn/src/renderer/redux/app-upgrade/reducers.ts b/desktop/packages/mullvad-vpn/src/renderer/redux/app-upgrade/reducers.ts
new file mode 100644
index 0000000000..adaac0f299
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/redux/app-upgrade/reducers.ts
@@ -0,0 +1,51 @@
+import { AppUpgradeError, AppUpgradeEvent } from '../../../shared/app-upgrade';
+import { ReduxAction } from '../store';
+
+export interface AppUpgradeReduxState {
+ error?: AppUpgradeError;
+ errorCount: number;
+ event?: AppUpgradeEvent;
+ lastProgress: number;
+}
+
+const initialState: AppUpgradeReduxState = {
+ error: undefined,
+ errorCount: 0,
+ event: undefined,
+ lastProgress: 0,
+};
+
+export function appUpgradeReducer(
+ state: AppUpgradeReduxState = initialState,
+ action: ReduxAction,
+): AppUpgradeReduxState {
+ switch (action.type) {
+ case 'APP_UPGRADE_SET_EVENT':
+ return {
+ ...state,
+ event: action.event,
+ };
+ case 'APP_UPGRADE_SET_LAST_PROGRESS':
+ return {
+ ...state,
+ lastProgress: action.lastProgress,
+ };
+ case 'APP_UPGRADE_SET_ERROR':
+ return {
+ ...state,
+ error: action.error,
+ errorCount: state.errorCount + 1,
+ };
+ case 'APP_UPGRADE_RESET_ERROR':
+ return {
+ ...state,
+ error: initialState.error,
+ };
+ case 'APP_UPGRADE_RESET':
+ return {
+ ...initialState,
+ };
+ default:
+ return state;
+ }
+}
diff --git a/desktop/packages/mullvad-vpn/src/renderer/redux/connection/hooks/index.ts b/desktop/packages/mullvad-vpn/src/renderer/redux/connection/hooks/index.ts
new file mode 100644
index 0000000000..8d23ba1971
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/redux/connection/hooks/index.ts
@@ -0,0 +1,2 @@
+export * from './useConnectionIsBlocked';
+export * from './useConnectionStatus';
diff --git a/desktop/packages/mullvad-vpn/src/renderer/redux/connection/hooks/useConnectionIsBlocked.tsx b/desktop/packages/mullvad-vpn/src/renderer/redux/connection/hooks/useConnectionIsBlocked.tsx
new file mode 100644
index 0000000000..42345f9b70
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/redux/connection/hooks/useConnectionIsBlocked.tsx
@@ -0,0 +1,5 @@
+import { useSelector } from '../../store';
+
+export const useConnectionIsBlocked = () => {
+ return { isBlocked: useSelector((state) => state.connection.isBlocked) };
+};
diff --git a/desktop/packages/mullvad-vpn/src/renderer/redux/connection/hooks/useConnectionStatus.tsx b/desktop/packages/mullvad-vpn/src/renderer/redux/connection/hooks/useConnectionStatus.tsx
new file mode 100644
index 0000000000..06ebe49615
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/redux/connection/hooks/useConnectionStatus.tsx
@@ -0,0 +1,5 @@
+import { useSelector } from '../../store';
+
+export const useConnectionStatus = () => {
+ return { status: useSelector((state) => state.connection.status) };
+};
diff --git a/desktop/packages/mullvad-vpn/src/renderer/redux/hooks/index.ts b/desktop/packages/mullvad-vpn/src/renderer/redux/hooks/index.ts
new file mode 100644
index 0000000000..50370d4207
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/redux/hooks/index.ts
@@ -0,0 +1,6 @@
+export * from '../account/hooks';
+export * from '../app-upgrade/hooks';
+export * from '../connection/hooks';
+export * from '../settings/hooks';
+export * from '../userinterface/hooks';
+export * from '../version/hooks';
diff --git a/desktop/packages/mullvad-vpn/src/renderer/redux/settings/hooks/index.ts b/desktop/packages/mullvad-vpn/src/renderer/redux/settings/hooks/index.ts
new file mode 100644
index 0000000000..e5781542ee
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/redux/settings/hooks/index.ts
@@ -0,0 +1,3 @@
+export * from './useSettingsShowBetaReleases';
+export * from './useSettingsRelaySettings';
+export * from './useSettingsDaitaEnabled';
diff --git a/desktop/packages/mullvad-vpn/src/renderer/redux/settings/hooks/useSettingsDaitaEnabled.tsx b/desktop/packages/mullvad-vpn/src/renderer/redux/settings/hooks/useSettingsDaitaEnabled.tsx
new file mode 100644
index 0000000000..7d66346ded
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/redux/settings/hooks/useSettingsDaitaEnabled.tsx
@@ -0,0 +1,7 @@
+import { useSelector } from '../../store';
+
+export const useSettingsDaitaEnabled = () => {
+ return {
+ daitaEnabled: useSelector((state) => state.settings.wireguard.daita?.enabled ?? false),
+ };
+};
diff --git a/desktop/packages/mullvad-vpn/src/renderer/redux/settings/hooks/useSettingsRelaySettings.tsx b/desktop/packages/mullvad-vpn/src/renderer/redux/settings/hooks/useSettingsRelaySettings.tsx
new file mode 100644
index 0000000000..ceed55869f
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/redux/settings/hooks/useSettingsRelaySettings.tsx
@@ -0,0 +1,7 @@
+import { useSelector } from '../../store';
+
+export const useSettingsRelaySettings = () => {
+ return {
+ relaySettings: useSelector((state) => state.settings.relaySettings),
+ };
+};
diff --git a/desktop/packages/mullvad-vpn/src/renderer/redux/settings/hooks/useSettingsShowBetaReleases.tsx b/desktop/packages/mullvad-vpn/src/renderer/redux/settings/hooks/useSettingsShowBetaReleases.tsx
new file mode 100644
index 0000000000..b7d83bd528
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/redux/settings/hooks/useSettingsShowBetaReleases.tsx
@@ -0,0 +1,10 @@
+import { useAppContext } from '../../../context';
+import { useSelector } from '../../store';
+
+export const useSettingsShowBetaReleases = () => {
+ const { setShowBetaReleases } = useAppContext();
+ return {
+ showBetaReleases: useSelector((state) => state.settings.showBetaReleases),
+ setShowBetaReleases,
+ };
+};
diff --git a/desktop/packages/mullvad-vpn/src/renderer/redux/settings/reducers.ts b/desktop/packages/mullvad-vpn/src/renderer/redux/settings/reducers.ts
index 1540179289..909004faf6 100644
--- a/desktop/packages/mullvad-vpn/src/renderer/redux/settings/reducers.ts
+++ b/desktop/packages/mullvad-vpn/src/renderer/redux/settings/reducers.ts
@@ -134,6 +134,7 @@ const initialState: ISettingsReduxState = {
unpinnedWindow: window.env.platform !== 'win32' && window.env.platform !== 'darwin',
browsedForSplitTunnelingApplications: [],
changelogDisplayedForVersion: '',
+ updateDismissedForVersion: '',
animateMap: true,
},
relaySettings: {
diff --git a/desktop/packages/mullvad-vpn/src/renderer/redux/store.ts b/desktop/packages/mullvad-vpn/src/renderer/redux/store.ts
index a691c7d01f..0f8b120aa2 100644
--- a/desktop/packages/mullvad-vpn/src/renderer/redux/store.ts
+++ b/desktop/packages/mullvad-vpn/src/renderer/redux/store.ts
@@ -3,6 +3,8 @@ import { combineReducers, compose, createStore, Dispatch, StoreEnhancer } from '
import accountActions, { AccountAction } from './account/actions';
import accountReducer, { IAccountReduxState } from './account/reducers';
+import { AppUpgradeAction, appUpgradeActions } from './app-upgrade/actions';
+import { appUpgradeReducer, AppUpgradeReduxState } from './app-upgrade/reducers';
import connectionActions, { ConnectionAction } from './connection/actions';
import connectionReducer, { IConnectionReduxState } from './connection/reducers';
import settingsActions, { SettingsAction } from './settings/actions';
@@ -18,6 +20,7 @@ import versionReducer, { IVersionReduxState } from './version/reducers';
export interface IReduxState {
account: IAccountReduxState;
+ appUpgrade: AppUpgradeReduxState;
connection: IConnectionReduxState;
settings: ISettingsReduxState;
support: ISupportReduxState;
@@ -27,6 +30,7 @@ export interface IReduxState {
}
export type ReduxAction =
+ | AppUpgradeAction
| AccountAction
| ConnectionAction
| SettingsAction
@@ -40,6 +44,7 @@ export type ReduxDispatch = Dispatch<ReduxAction>;
export default function configureStore() {
const reducers = {
account: accountReducer,
+ appUpgrade: appUpgradeReducer,
connection: connectionReducer,
settings: settingsReducer,
support: supportReducer,
@@ -56,6 +61,7 @@ export default function configureStore() {
function composeEnhancers(): StoreEnhancer {
const actionCreators = {
...accountActions,
+ ...appUpgradeActions,
...connectionActions,
...settingsActions,
...supportActions,
diff --git a/desktop/packages/mullvad-vpn/src/renderer/redux/userinterface/hooks/index.ts b/desktop/packages/mullvad-vpn/src/renderer/redux/userinterface/hooks/index.ts
new file mode 100644
index 0000000000..0b7ee7ff2a
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/redux/userinterface/hooks/index.ts
@@ -0,0 +1,3 @@
+export * from './useUserInterfaceChangelog';
+export * from './useUserInterfaceConnectedToDaemon';
+export * from './useUserInterfaceIsMacOs13OrNewer';
diff --git a/desktop/packages/mullvad-vpn/src/renderer/redux/userinterface/hooks/useUserInterfaceChangelog.ts b/desktop/packages/mullvad-vpn/src/renderer/redux/userinterface/hooks/useUserInterfaceChangelog.ts
new file mode 100644
index 0000000000..da868383aa
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/redux/userinterface/hooks/useUserInterfaceChangelog.ts
@@ -0,0 +1,7 @@
+import { useSelector } from '../../store';
+
+export const useUserInterfaceChangelog = () => {
+ return {
+ changelog: useSelector((state) => state.userInterface.changelog),
+ };
+};
diff --git a/desktop/packages/mullvad-vpn/src/renderer/redux/userinterface/hooks/useUserInterfaceConnectedToDaemon.ts b/desktop/packages/mullvad-vpn/src/renderer/redux/userinterface/hooks/useUserInterfaceConnectedToDaemon.ts
new file mode 100644
index 0000000000..40acdd520b
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/redux/userinterface/hooks/useUserInterfaceConnectedToDaemon.ts
@@ -0,0 +1,7 @@
+import { useSelector } from '../../store';
+
+export const useUserInterfaceConnectedToDaemon = () => {
+ return {
+ connectedToDaemon: useSelector((state) => state.userInterface.connectedToDaemon),
+ };
+};
diff --git a/desktop/packages/mullvad-vpn/src/renderer/redux/userinterface/hooks/useUserInterfaceIsMacOs13OrNewer.ts b/desktop/packages/mullvad-vpn/src/renderer/redux/userinterface/hooks/useUserInterfaceIsMacOs13OrNewer.ts
new file mode 100644
index 0000000000..bf0fa4722c
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/redux/userinterface/hooks/useUserInterfaceIsMacOs13OrNewer.ts
@@ -0,0 +1,7 @@
+import { useSelector } from '../../store';
+
+export const useUserInterfaceIsMacOs13OrNewer = () => {
+ return {
+ isMacOs13OrNewer: useSelector((state) => state.userInterface.isMacOs13OrNewer),
+ };
+};
diff --git a/desktop/packages/mullvad-vpn/src/renderer/redux/version/hooks/index.ts b/desktop/packages/mullvad-vpn/src/renderer/redux/version/hooks/index.ts
new file mode 100644
index 0000000000..46db4e4c72
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/redux/version/hooks/index.ts
@@ -0,0 +1,5 @@
+export * from './useVersionConsistent';
+export * from './useVersionCurrent';
+export * from './useVersionIsBeta';
+export * from './useVersionSuggestedUpgrade';
+export * from './useVersionSuggestedIsBeta';
diff --git a/desktop/packages/mullvad-vpn/src/renderer/redux/version/hooks/useVersionConsistent.tsx b/desktop/packages/mullvad-vpn/src/renderer/redux/version/hooks/useVersionConsistent.tsx
new file mode 100644
index 0000000000..64bd2972ef
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/redux/version/hooks/useVersionConsistent.tsx
@@ -0,0 +1,5 @@
+import { useSelector } from '../../store';
+
+export const useVersionConsistent = () => {
+ return { consistent: useSelector((state) => state.version.consistent) };
+};
diff --git a/desktop/packages/mullvad-vpn/src/renderer/redux/version/hooks/useVersionCurrent.tsx b/desktop/packages/mullvad-vpn/src/renderer/redux/version/hooks/useVersionCurrent.tsx
new file mode 100644
index 0000000000..9b2d8a9430
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/redux/version/hooks/useVersionCurrent.tsx
@@ -0,0 +1,5 @@
+import { useSelector } from '../../store';
+
+export const useVersionCurrent = () => {
+ return { current: useSelector((state) => state.version.current) };
+};
diff --git a/desktop/packages/mullvad-vpn/src/renderer/redux/version/hooks/useVersionIsBeta.tsx b/desktop/packages/mullvad-vpn/src/renderer/redux/version/hooks/useVersionIsBeta.tsx
new file mode 100644
index 0000000000..a2aab53ffe
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/redux/version/hooks/useVersionIsBeta.tsx
@@ -0,0 +1,5 @@
+import { useSelector } from '../../store';
+
+export const useVersionIsBeta = () => {
+ return { isBeta: useSelector((state) => state.version.isBeta) };
+};
diff --git a/desktop/packages/mullvad-vpn/src/renderer/redux/version/hooks/useVersionSuggestedIsBeta.tsx b/desktop/packages/mullvad-vpn/src/renderer/redux/version/hooks/useVersionSuggestedIsBeta.tsx
new file mode 100644
index 0000000000..f7b62a7c36
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/redux/version/hooks/useVersionSuggestedIsBeta.tsx
@@ -0,0 +1,5 @@
+import { useSelector } from '../../store';
+
+export const useVersionSuggestedIsBeta = () => {
+ return { suggestedIsBeta: useSelector((state) => state.version.suggestedIsBeta ?? false) };
+};
diff --git a/desktop/packages/mullvad-vpn/src/renderer/redux/version/hooks/useVersionSuggestedUpgrade.tsx b/desktop/packages/mullvad-vpn/src/renderer/redux/version/hooks/useVersionSuggestedUpgrade.tsx
new file mode 100644
index 0000000000..1da590ea8f
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/redux/version/hooks/useVersionSuggestedUpgrade.tsx
@@ -0,0 +1,5 @@
+import { useSelector } from '../../store';
+
+export const useVersionSuggestedUpgrade = () => {
+ return { suggestedUpgrade: useSelector((state) => state.version.suggestedUpgrade) };
+};
diff --git a/desktop/packages/mullvad-vpn/src/renderer/redux/version/reducers.ts b/desktop/packages/mullvad-vpn/src/renderer/redux/version/reducers.ts
index 1128c82bac..f20d4a29e1 100644
--- a/desktop/packages/mullvad-vpn/src/renderer/redux/version/reducers.ts
+++ b/desktop/packages/mullvad-vpn/src/renderer/redux/version/reducers.ts
@@ -1,10 +1,11 @@
+import { AppVersionInfoSuggestedUpgrade } from '../../../shared/daemon-rpc-types';
import { ReduxAction } from '../store';
export interface IVersionReduxState {
current: string;
supported: boolean;
isBeta: boolean;
- suggestedUpgrade?: string;
+ suggestedUpgrade?: AppVersionInfoSuggestedUpgrade;
suggestedIsBeta?: boolean;
consistent: boolean;
}
diff --git a/desktop/packages/mullvad-vpn/src/shared/app-upgrade.ts b/desktop/packages/mullvad-vpn/src/shared/app-upgrade.ts
new file mode 100644
index 0000000000..2d6809d2d0
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/shared/app-upgrade.ts
@@ -0,0 +1,39 @@
+import { DaemonAppUpgradeError, DaemonAppUpgradeEventStatus } from './daemon-rpc-types';
+
+export type AppUpgradeEventStatusAutomaticStartingInstaller = {
+ type: 'APP_UPGRADE_STATUS_AUTOMATIC_STARTING_INSTALLER';
+};
+
+export type AppUpgradeEventStatusStartedInstaller = {
+ type: 'APP_UPGRADE_STATUS_STARTED_INSTALLER';
+};
+
+export type AppUpgradeEventStatusDownloadInitiated = {
+ type: 'APP_UPGRADE_STATUS_DOWNLOAD_INITIATED';
+};
+
+export type AppUpgradeEventStatusManualStartInstaller = {
+ type: 'APP_UPGRADE_STATUS_MANUAL_START_INSTALLER';
+};
+
+export type AppUpgradeEventStatusManualStartingInstaller = {
+ type: 'APP_UPGRADE_STATUS_MANUAL_STARTING_INSTALLER';
+};
+
+export type AppUpgradeEventStatusExitedInstaller = {
+ type: 'APP_UPGRADE_STATUS_EXITED_INSTALLER';
+};
+
+export type AppUpgradeEventStatus =
+ | AppUpgradeEventStatusStartedInstaller
+ | AppUpgradeEventStatusAutomaticStartingInstaller
+ | AppUpgradeEventStatusDownloadInitiated
+ | AppUpgradeEventStatusExitedInstaller
+ | AppUpgradeEventStatusManualStartingInstaller
+ | AppUpgradeEventStatusManualStartInstaller;
+
+export type AppUpgradeEvent = DaemonAppUpgradeEventStatus | AppUpgradeEventStatus;
+
+export type AppUpgradeStep = 'download' | 'error' | 'initial' | 'launch' | 'pause' | 'verify';
+
+export type AppUpgradeError = DaemonAppUpgradeError | 'START_INSTALLER_FAILED' | 'INSTALLER_FAILED';
diff --git a/desktop/packages/mullvad-vpn/src/shared/daemon-rpc-types.ts b/desktop/packages/mullvad-vpn/src/shared/daemon-rpc-types.ts
index 0d73280c4d..b00fe4587a 100644
--- a/desktop/packages/mullvad-vpn/src/shared/daemon-rpc-types.ts
+++ b/desktop/packages/mullvad-vpn/src/shared/daemon-rpc-types.ts
@@ -1,3 +1,5 @@
+import { IChangelog } from './ipc-types';
+
export interface IAccountData {
expiry: string;
}
@@ -175,6 +177,45 @@ export type DaemonEvent =
| { deviceRemoval: Array<IDevice> }
| { accessMethodSetting: AccessMethodSetting };
+export type DaemonAppUpgradeEventStatusDownloadStarted = {
+ type: 'APP_UPGRADE_STATUS_DOWNLOAD_STARTED';
+};
+
+export type DaemonAppUpgradeEventStatusDownloadProgress = {
+ type: 'APP_UPGRADE_STATUS_DOWNLOAD_PROGRESS';
+ progress: number;
+ server: string;
+ timeLeft?: number;
+};
+
+export type DaemonAppUpgradeEventStatusAborted = {
+ type: 'APP_UPGRADE_STATUS_ABORTED';
+};
+
+export type DaemonAppUpgradeEventStatusVerifyingInstaller = {
+ type: 'APP_UPGRADE_STATUS_VERIFYING_INSTALLER';
+};
+
+export type DaemonAppUpgradeEventStatusVerifiedInstaller = {
+ type: 'APP_UPGRADE_STATUS_VERIFIED_INSTALLER';
+};
+
+export type DaemonAppUpgradeError = 'DOWNLOAD_FAILED' | 'GENERAL_ERROR' | 'VERIFICATION_FAILED';
+
+export type DaemonAppUpgradeEventError = {
+ type: 'APP_UPGRADE_ERROR';
+ error: DaemonAppUpgradeError;
+};
+
+export type DaemonAppUpgradeEventStatus =
+ | DaemonAppUpgradeEventStatusDownloadStarted
+ | DaemonAppUpgradeEventStatusDownloadProgress
+ | DaemonAppUpgradeEventStatusAborted
+ | DaemonAppUpgradeEventStatusVerifyingInstaller
+ | DaemonAppUpgradeEventStatusVerifiedInstaller;
+
+export type DaemonAppUpgradeEvent = DaemonAppUpgradeEventStatus | DaemonAppUpgradeEventError;
+
export interface ITunnelStateRelayInfo {
endpoint: ITunnelEndpoint;
location?: ILocation;
@@ -388,9 +429,15 @@ export interface IDnsOptions {
};
}
+export type AppVersionInfoSuggestedUpgrade = {
+ changelog: IChangelog;
+ verifiedInstallerPath?: string;
+ version: string;
+};
+
export interface IAppVersionInfo {
supported: boolean;
- suggestedUpgrade?: string;
+ suggestedUpgrade?: AppVersionInfoSuggestedUpgrade;
suggestedIsBeta?: boolean;
}
diff --git a/desktop/packages/mullvad-vpn/src/shared/gui-settings-state.ts b/desktop/packages/mullvad-vpn/src/shared/gui-settings-state.ts
index 68e958324a..9339541042 100644
--- a/desktop/packages/mullvad-vpn/src/shared/gui-settings-state.ts
+++ b/desktop/packages/mullvad-vpn/src/shared/gui-settings-state.ts
@@ -32,6 +32,10 @@ export interface IGuiSettingsState {
// changelog after upgrade.
changelogDisplayedForVersion: string;
+ // The last version that the update dialog was dismissed for. This is used to determine
+ // whether to show the update notification.
+ updateDismissedForVersion: string;
+
// Tells the app whether or not to show the map in the main view.
animateMap: boolean;
}
diff --git a/desktop/packages/mullvad-vpn/src/shared/ipc-schema.ts b/desktop/packages/mullvad-vpn/src/shared/ipc-schema.ts
index 3580c679f3..a85f55d18a 100644
--- a/desktop/packages/mullvad-vpn/src/shared/ipc-schema.ts
+++ b/desktop/packages/mullvad-vpn/src/shared/ipc-schema.ts
@@ -33,6 +33,7 @@ interface ILogEntry {
message: string;
}
import { MapData } from '../renderer/lib/3dmap';
+import { AppUpgradeError, AppUpgradeEvent } from './app-upgrade';
import { invoke, invokeSync, notifyRenderer, send } from './ipc-helpers';
import {
IChangelog,
@@ -40,6 +41,7 @@ import {
IHistoryObject,
IWindowShapeParameters,
} from './ipc-types';
+import { RoutePath } from './routes';
export interface ITranslations {
locale: string;
@@ -155,14 +157,21 @@ export const ipcSchema = {
},
upgradeVersion: {
'': notifyRenderer<IAppVersionInfo>(),
+ dismissedUpgrade: send<string>(),
},
app: {
quit: send<void>(),
openUrl: invoke<string, void>(),
+ openRoute: notifyRenderer<RoutePath>(),
showOpenDialog: invoke<Electron.OpenDialogOptions, Electron.OpenDialogReturnValue>(),
showLaunchDaemonSettings: invoke<void, void>(),
showFullDiskAccessSettings: invoke<void, void>(),
getPathBaseName: invoke<string, string>(),
+ upgrade: send<void>(),
+ upgradeAbort: send<void>(),
+ upgradeEvent: notifyRenderer<AppUpgradeEvent>(),
+ upgradeError: notifyRenderer<AppUpgradeError>(),
+ upgradeInstallerStart: send<string>(),
},
tunnel: {
'': notifyRenderer<TunnelState>(),
diff --git a/desktop/packages/mullvad-vpn/src/shared/ipc-types.ts b/desktop/packages/mullvad-vpn/src/shared/ipc-types.ts
index 67d1539b36..40a32e42fa 100644
--- a/desktop/packages/mullvad-vpn/src/shared/ipc-types.ts
+++ b/desktop/packages/mullvad-vpn/src/shared/ipc-types.ts
@@ -13,12 +13,19 @@ export interface IWindowShapeParameters {
arrowPosition?: number;
}
+export type SuppressOutdatedVersionOption = {
+ type: 'suppress-outdated-version-warning';
+};
+
+export type LocationStateOptions = SuppressOutdatedVersionOption;
+
export type IChangelog = Array<string>;
export interface LocationState {
scrollPosition: [number, number];
expandedSections: Record<string, boolean>;
transition: TransitionType;
+ options?: LocationStateOptions[];
}
export interface IHistoryObject {
diff --git a/desktop/packages/mullvad-vpn/src/shared/localization-contexts.ts b/desktop/packages/mullvad-vpn/src/shared/localization-contexts.ts
index c4d47b1afa..88c032c057 100644
--- a/desktop/packages/mullvad-vpn/src/shared/localization-contexts.ts
+++ b/desktop/packages/mullvad-vpn/src/shared/localization-contexts.ts
@@ -39,4 +39,5 @@ export type LocalizationContexts =
| 'tray-icon-tooltip'
| 'troubleshoot'
| 'app-info-view'
- | 'changelog-view';
+ | 'changelog-view'
+ | 'app-upgrade-view';
diff --git a/desktop/packages/mullvad-vpn/src/shared/notifications/account-expired.ts b/desktop/packages/mullvad-vpn/src/shared/notifications/account-expired.ts
index 8eff5379db..48c8edc1f3 100644
--- a/desktop/packages/mullvad-vpn/src/shared/notifications/account-expired.ts
+++ b/desktop/packages/mullvad-vpn/src/shared/notifications/account-expired.ts
@@ -32,10 +32,12 @@ export class AccountExpiredNotificationProvider implements SystemNotificationPro
severity: SystemNotificationSeverityType.high,
presentOnce: { value: true, name: this.constructor.name },
action: {
- type: 'open-url',
- url: urls.purchase,
- withAuth: true,
- text: messages.pgettext('notifications', 'Buy more'),
+ type: 'navigate-external',
+ link: {
+ text: messages.pgettext('notifications', 'Buy more'),
+ to: urls.purchase,
+ withAuth: true,
+ },
},
};
}
diff --git a/desktop/packages/mullvad-vpn/src/shared/notifications/close-to-account-expiry.ts b/desktop/packages/mullvad-vpn/src/shared/notifications/close-to-account-expiry.ts
index 2e72938fae..9300e414d6 100644
--- a/desktop/packages/mullvad-vpn/src/shared/notifications/close-to-account-expiry.ts
+++ b/desktop/packages/mullvad-vpn/src/shared/notifications/close-to-account-expiry.ts
@@ -43,10 +43,12 @@ export class CloseToAccountExpiryNotificationProvider
category: SystemNotificationCategory.expiry,
severity: SystemNotificationSeverityType.medium,
action: {
- type: 'open-url',
- url: urls.purchase,
- withAuth: true,
- text: messages.pgettext('notifications', 'Buy more'),
+ type: 'navigate-external',
+ link: {
+ text: messages.pgettext('notifications', 'Buy more'),
+ to: urls.purchase,
+ withAuth: true,
+ },
},
};
}
@@ -66,7 +68,13 @@ export class CloseToAccountExpiryNotificationProvider
indicator: 'warning',
title: messages.pgettext('in-app-notifications', 'ACCOUNT CREDIT EXPIRES SOON'),
subtitle,
- action: { type: 'open-url', url: urls.purchase, withAuth: true },
+ action: {
+ type: 'navigate-external',
+ link: {
+ to: urls.purchase,
+ withAuth: true,
+ },
+ },
};
}
}
diff --git a/desktop/packages/mullvad-vpn/src/shared/notifications/notification.ts b/desktop/packages/mullvad-vpn/src/shared/notifications/notification.ts
index fa31004d6a..b3a3435dd4 100644
--- a/desktop/packages/mullvad-vpn/src/shared/notifications/notification.ts
+++ b/desktop/packages/mullvad-vpn/src/shared/notifications/notification.ts
@@ -1,13 +1,25 @@
import { ExternalLinkProps } from '../../renderer/components/ExternalLink';
import { InternalLinkProps } from '../../renderer/components/InternalLink';
+import { ButtonProps } from '../../renderer/lib/components';
+import { RoutePath } from '../../shared/routes';
import { Url } from '../constants';
-export type NotificationAction = {
- type: 'open-url';
- url: Url;
- text?: string;
- withAuth?: boolean;
-};
+export type SystemNotificationAction =
+ | {
+ type: 'navigate-internal';
+ link: {
+ to: RoutePath;
+ text?: string;
+ };
+ }
+ | {
+ type: 'navigate-external';
+ link: {
+ to: Url;
+ text?: string;
+ withAuth?: boolean;
+ };
+ };
export interface InAppNotificationTroubleshootInfo {
details: string;
@@ -22,7 +34,6 @@ export interface InAppNotificationTroubleshootButton {
}
export type InAppNotificationAction =
- | NotificationAction
| {
type: 'troubleshoot-dialog';
troubleshoot: InAppNotificationTroubleshootInfo;
@@ -37,7 +48,11 @@ export type InAppNotificationAction =
}
| {
type: 'navigate-external';
- link: Pick<ExternalLinkProps, 'to' | 'onClick' | 'aria-label'>;
+ link: Pick<ExternalLinkProps, 'to' | 'onClick' | 'aria-label' | 'withAuth'>;
+ }
+ | {
+ type: 'run-function';
+ button: Pick<ButtonProps, 'onClick' | 'aria-label'>;
};
export type InAppNotificationIndicatorType = 'success' | 'warning' | 'error';
@@ -67,7 +82,7 @@ export interface SystemNotification {
throttle?: boolean;
presentOnce?: { value: boolean; name: string };
suppressInDevelopment?: boolean;
- action?: NotificationAction;
+ action?: SystemNotificationAction;
}
export interface InAppNotification {
diff --git a/desktop/packages/mullvad-vpn/src/shared/notifications/unsupported-version.ts b/desktop/packages/mullvad-vpn/src/shared/notifications/unsupported-version.ts
index 15c622703c..6559e02a21 100644
--- a/desktop/packages/mullvad-vpn/src/shared/notifications/unsupported-version.ts
+++ b/desktop/packages/mullvad-vpn/src/shared/notifications/unsupported-version.ts
@@ -1,4 +1,6 @@
import { messages } from '../../shared/gettext';
+import { RoutePath } from '../../shared/routes';
+import { AppVersionInfoSuggestedUpgrade } from '../daemon-rpc-types';
import { getDownloadUrl } from '../version';
import {
InAppNotification,
@@ -12,7 +14,7 @@ import {
interface UnsupportedVersionNotificationContext {
supported: boolean;
consistent: boolean;
- suggestedUpgrade?: string;
+ suggestedUpgrade?: AppVersionInfoSuggestedUpgrade;
suggestedIsBeta?: boolean;
}
@@ -30,11 +32,20 @@ export class UnsupportedVersionNotificationProvider
message: this.getMessage(),
category: SystemNotificationCategory.newVersion,
severity: SystemNotificationSeverityType.high,
- action: {
- type: 'open-url',
- url: getDownloadUrl(this.context.suggestedIsBeta ?? false),
- text: messages.pgettext('notifications', 'Upgrade'),
- },
+ action: this.context.suggestedUpgrade
+ ? {
+ type: 'navigate-internal',
+ link: {
+ to: RoutePath.appUpgrade,
+ },
+ }
+ : {
+ type: 'navigate-external',
+ link: {
+ text: messages.pgettext('notifications', 'Upgrade'),
+ to: getDownloadUrl(this.context.suggestedIsBeta ?? false),
+ },
+ },
presentOnce: { value: true, name: this.constructor.name },
suppressInDevelopment: true,
};
@@ -44,16 +55,40 @@ export class UnsupportedVersionNotificationProvider
return {
indicator: 'error',
title: messages.pgettext('in-app-notifications', 'UNSUPPORTED VERSION'),
- subtitle: this.getMessage(),
- action: {
- type: 'open-url',
- url: getDownloadUrl(this.context.suggestedIsBeta ?? false),
- },
+ subtitle: [
+ {
+ content:
+ // TRANSLATORS: The in-app banner which is displayed to the user when the running app becomes unsupported.
+ messages.pgettext(
+ 'notifications',
+ 'Your privacy might be at risk with this unsupported app version.',
+ ),
+ },
+ {
+ content:
+ // TRANSLATORS: A link in the in-app banner to encourage the user to update the app.
+ // TRANSLATORS: The in-app banner is is displayed to the user when the running app becomes unsupported.
+ messages.pgettext('notifications', 'Please click here to update now'),
+ action: this.context.suggestedUpgrade
+ ? {
+ type: 'navigate-internal',
+ link: {
+ to: RoutePath.appUpgrade,
+ },
+ }
+ : {
+ type: 'navigate-external',
+ link: {
+ to: getDownloadUrl(this.context.suggestedIsBeta ?? false),
+ },
+ },
+ },
+ ],
};
}
private getMessage(): string {
- // TRANSLATORS: The in-app banner and system notification which are displayed to the user when the running app becomes unsupported.
+ // TRANSLATORS: The system notification which is displayed to the user when the running app becomes unsupported.
return messages.pgettext(
'notifications',
'Your privacy might be at risk with this unsupported app version. Please update now.',
diff --git a/desktop/packages/mullvad-vpn/src/shared/notifications/update-available.ts b/desktop/packages/mullvad-vpn/src/shared/notifications/update-available.ts
index 732e7bb9a8..a199d077f6 100644
--- a/desktop/packages/mullvad-vpn/src/shared/notifications/update-available.ts
+++ b/desktop/packages/mullvad-vpn/src/shared/notifications/update-available.ts
@@ -1,10 +1,9 @@
import { sprintf } from 'sprintf-js';
import { messages } from '../../shared/gettext';
-import { getDownloadUrl } from '../version';
+import { RoutePath } from '../../shared/routes';
+import { AppVersionInfoSuggestedUpgrade } from '../daemon-rpc-types';
import {
- InAppNotification,
- InAppNotificationProvider,
SystemNotification,
SystemNotificationCategory,
SystemNotificationProvider,
@@ -12,31 +11,15 @@ import {
} from './notification';
interface UpdateAvailableNotificationContext {
- suggestedUpgrade?: string;
+ suggestedUpgrade?: AppVersionInfoSuggestedUpgrade;
suggestedIsBeta?: boolean;
}
-export class UpdateAvailableNotificationProvider
- implements InAppNotificationProvider, SystemNotificationProvider
-{
+export class UpdateAvailableNotificationProvider implements SystemNotificationProvider {
public constructor(private context: UpdateAvailableNotificationContext) {}
public mayDisplay() {
- return this.context.suggestedUpgrade ? true : false;
- }
-
- public getInAppNotification(): InAppNotification {
- return {
- indicator: 'warning',
- title: this.context.suggestedIsBeta
- ? messages.pgettext('in-app-notifications', 'BETA UPDATE AVAILABLE')
- : messages.pgettext('in-app-notifications', 'UPDATE AVAILABLE'),
- subtitle: this.inAppMessage(),
- action: {
- type: 'open-url',
- url: getDownloadUrl(this.context.suggestedIsBeta ?? false),
- },
- };
+ return this.context.suggestedUpgrade?.version ? true : false;
}
public getSystemNotification(): SystemNotification {
@@ -45,34 +28,17 @@ export class UpdateAvailableNotificationProvider
category: SystemNotificationCategory.newVersion,
severity: SystemNotificationSeverityType.medium,
action: {
- type: 'open-url',
- url: getDownloadUrl(this.context.suggestedIsBeta ?? false),
- text: messages.pgettext('notifications', 'Upgrade'),
+ type: 'navigate-internal',
+ link: {
+ text: messages.pgettext('notifications', 'Upgrade'),
+ to: RoutePath.appUpgrade,
+ },
},
presentOnce: { value: true, name: this.constructor.name },
suppressInDevelopment: true,
};
}
- private inAppMessage(): string {
- if (this.context.suggestedIsBeta) {
- return sprintf(
- // TRANSLATORS: The in-app banner displayed to the user when the app beta update is
- // TRANSLATORS: available.
- // TRANSLATORS: Available placeholders:
- // TRANSLATORS: %(version)s - The version number of the new beta version.
- messages.pgettext('in-app-notifications', 'Try out the newest beta version (%(version)s).'),
- { version: this.context.suggestedUpgrade },
- );
- } else {
- // TRANSLATORS: The in-app banner displayed to the user when the app update is available.
- return messages.pgettext(
- 'in-app-notifications',
- 'Install the latest app version to stay up to date.',
- );
- }
- }
-
private systemMessage(): string {
if (this.context.suggestedIsBeta) {
return sprintf(
@@ -84,7 +50,7 @@ export class UpdateAvailableNotificationProvider
'notifications',
'Beta update available. Try out the newest beta version (%(version)s).',
),
- { version: this.context.suggestedUpgrade },
+ { version: this.context.suggestedUpgrade?.version },
);
} else {
return messages.pgettext(
diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/routes.ts b/desktop/packages/mullvad-vpn/src/shared/routes.ts
index 73f510ea3a..1868b2639a 100644
--- a/desktop/packages/mullvad-vpn/src/renderer/lib/routes.ts
+++ b/desktop/packages/mullvad-vpn/src/shared/routes.ts
@@ -33,4 +33,5 @@ export enum RoutePath {
filter = '/select-location/filter',
appInfo = '/settings/app-info',
changelog = '/settings/changelog',
+ appUpgrade = '/settings/app-upgrade',
}
diff --git a/desktop/packages/mullvad-vpn/src/shared/utils.ts b/desktop/packages/mullvad-vpn/src/shared/utils.ts
index 52f9c49e76..65bd822009 100644
--- a/desktop/packages/mullvad-vpn/src/shared/utils.ts
+++ b/desktop/packages/mullvad-vpn/src/shared/utils.ts
@@ -7,3 +7,7 @@ export function hasValue<T>(value: T): value is NonNullable<T> {
export function isInRanges(value: number, ranges: [number, number][]): boolean {
return ranges.some(([min, max]) => value >= min && value <= max);
}
+
+export function isNumber(number: unknown): number is number {
+ return !Number.isNaN(number);
+}
diff --git a/desktop/packages/mullvad-vpn/test/e2e/installed/state-dependent/api-access-methods.spec.ts b/desktop/packages/mullvad-vpn/test/e2e/installed/state-dependent/api-access-methods.spec.ts
index 78f749e0d2..c9d38988d8 100644
--- a/desktop/packages/mullvad-vpn/test/e2e/installed/state-dependent/api-access-methods.spec.ts
+++ b/desktop/packages/mullvad-vpn/test/e2e/installed/state-dependent/api-access-methods.spec.ts
@@ -1,7 +1,7 @@
import { expect, test } from '@playwright/test';
import { Page } from 'playwright';
-import { RoutePath } from '../../../../src/renderer/lib/routes';
+import { RoutePath } from '../../../../src/shared/routes';
import { TestUtils } from '../../utils';
import { startInstalledApp } from '../installed-utils';
diff --git a/desktop/packages/mullvad-vpn/test/e2e/installed/state-dependent/custom-bridge.spec.ts b/desktop/packages/mullvad-vpn/test/e2e/installed/state-dependent/custom-bridge.spec.ts
index 01526139de..9be24d838b 100644
--- a/desktop/packages/mullvad-vpn/test/e2e/installed/state-dependent/custom-bridge.spec.ts
+++ b/desktop/packages/mullvad-vpn/test/e2e/installed/state-dependent/custom-bridge.spec.ts
@@ -2,7 +2,7 @@ import { expect, test } from '@playwright/test';
import { Page } from 'playwright';
import { colorTokens } from '../../../../src/renderer/lib/foundations';
-import { RoutePath } from '../../../../src/renderer/lib/routes';
+import { RoutePath } from '../../../../src/shared/routes';
import { TestUtils } from '../../utils';
import { startInstalledApp } from '../installed-utils';
diff --git a/desktop/packages/mullvad-vpn/test/e2e/installed/state-dependent/device-revoked.spec.ts b/desktop/packages/mullvad-vpn/test/e2e/installed/state-dependent/device-revoked.spec.ts
index c74b578a59..deaf746d6b 100644
--- a/desktop/packages/mullvad-vpn/test/e2e/installed/state-dependent/device-revoked.spec.ts
+++ b/desktop/packages/mullvad-vpn/test/e2e/installed/state-dependent/device-revoked.spec.ts
@@ -1,7 +1,7 @@
import { expect, test } from '@playwright/test';
import { Page } from 'playwright';
-import { RoutePath } from '../../../../src/renderer/lib/routes';
+import { RoutePath } from '../../../../src/shared/routes';
import { TestUtils } from '../../utils';
import { startInstalledApp } from '../installed-utils';
diff --git a/desktop/packages/mullvad-vpn/test/e2e/installed/state-dependent/disconnected.spec.ts b/desktop/packages/mullvad-vpn/test/e2e/installed/state-dependent/disconnected.spec.ts
index b510620516..51864efa04 100644
--- a/desktop/packages/mullvad-vpn/test/e2e/installed/state-dependent/disconnected.spec.ts
+++ b/desktop/packages/mullvad-vpn/test/e2e/installed/state-dependent/disconnected.spec.ts
@@ -1,7 +1,7 @@
import { test } from '@playwright/test';
import { Page } from 'playwright';
-import { RoutePath } from '../../../../src/renderer/lib/routes';
+import { RoutePath } from '../../../../src/shared/routes';
import { expectDisconnected } from '../../shared/tunnel-state';
import { TestUtils } from '../../utils';
import { startInstalledApp } from '../installed-utils';
diff --git a/desktop/packages/mullvad-vpn/test/e2e/installed/state-dependent/login.spec.ts b/desktop/packages/mullvad-vpn/test/e2e/installed/state-dependent/login.spec.ts
index bd6dbcbb2e..f02a440361 100644
--- a/desktop/packages/mullvad-vpn/test/e2e/installed/state-dependent/login.spec.ts
+++ b/desktop/packages/mullvad-vpn/test/e2e/installed/state-dependent/login.spec.ts
@@ -2,7 +2,7 @@ import { expect, test } from '@playwright/test';
import { exec, execSync } from 'child_process';
import { Locator, Page } from 'playwright';
-import { RoutePath } from '../../../../src/renderer/lib/routes';
+import { RoutePath } from '../../../../src/shared/routes';
import { expectDisconnected } from '../../shared/tunnel-state';
import { TestUtils } from '../../utils';
import { startInstalledApp } from '../installed-utils';
diff --git a/desktop/packages/mullvad-vpn/test/e2e/installed/state-dependent/macos-split-tunneling.spec.ts b/desktop/packages/mullvad-vpn/test/e2e/installed/state-dependent/macos-split-tunneling.spec.ts
index 0ddfdd868b..f7ab5369c3 100644
--- a/desktop/packages/mullvad-vpn/test/e2e/installed/state-dependent/macos-split-tunneling.spec.ts
+++ b/desktop/packages/mullvad-vpn/test/e2e/installed/state-dependent/macos-split-tunneling.spec.ts
@@ -2,7 +2,7 @@ import { expect, Locator, test } from '@playwright/test';
import { execSync } from 'child_process';
import { Page } from 'playwright';
-import { RoutePath } from '../../../../src/renderer/lib/routes';
+import { RoutePath } from '../../../../src/shared/routes';
import { TestUtils } from '../../utils';
import { startInstalledApp } from '../installed-utils';
diff --git a/desktop/packages/mullvad-vpn/test/e2e/installed/state-dependent/obfuscation.spec.ts b/desktop/packages/mullvad-vpn/test/e2e/installed/state-dependent/obfuscation.spec.ts
index 9fb2474f86..d1d3cb2e01 100644
--- a/desktop/packages/mullvad-vpn/test/e2e/installed/state-dependent/obfuscation.spec.ts
+++ b/desktop/packages/mullvad-vpn/test/e2e/installed/state-dependent/obfuscation.spec.ts
@@ -3,7 +3,7 @@ import { execSync } from 'child_process';
import { Page } from 'playwright';
import { colorTokens } from '../../../../src/renderer/lib/foundations';
-import { RoutePath } from '../../../../src/renderer/lib/routes';
+import { RoutePath } from '../../../../src/shared/routes';
import { TestUtils } from '../../utils';
import { startInstalledApp } from '../installed-utils';
diff --git a/desktop/packages/mullvad-vpn/test/e2e/installed/state-dependent/openvpn-tunnel-state.spec.ts b/desktop/packages/mullvad-vpn/test/e2e/installed/state-dependent/openvpn-tunnel-state.spec.ts
index 3adb1827cc..e3d583d21a 100644
--- a/desktop/packages/mullvad-vpn/test/e2e/installed/state-dependent/openvpn-tunnel-state.spec.ts
+++ b/desktop/packages/mullvad-vpn/test/e2e/installed/state-dependent/openvpn-tunnel-state.spec.ts
@@ -3,7 +3,7 @@ import { exec as execAsync } from 'child_process';
import { Page } from 'playwright';
import { promisify } from 'util';
-import { RoutePath } from '../../../../src/renderer/lib/routes';
+import { RoutePath } from '../../../../src/shared/routes';
import { expectConnected } from '../../shared/tunnel-state';
import { TestUtils } from '../../utils';
import { startInstalledApp } from '../installed-utils';
diff --git a/desktop/packages/mullvad-vpn/test/e2e/installed/state-dependent/settings-import.spec.ts b/desktop/packages/mullvad-vpn/test/e2e/installed/state-dependent/settings-import.spec.ts
index 23ca4e4b51..e2ed4ae1ac 100644
--- a/desktop/packages/mullvad-vpn/test/e2e/installed/state-dependent/settings-import.spec.ts
+++ b/desktop/packages/mullvad-vpn/test/e2e/installed/state-dependent/settings-import.spec.ts
@@ -1,7 +1,7 @@
import { expect, test } from '@playwright/test';
import { Page } from 'playwright';
-import { RoutePath } from '../../../../src/renderer/lib/routes';
+import { RoutePath } from '../../../../src/shared/routes';
import { TestUtils } from '../../utils';
import { startInstalledApp } from '../installed-utils';
diff --git a/desktop/packages/mullvad-vpn/test/e2e/installed/state-dependent/too-many-devices.spec.ts b/desktop/packages/mullvad-vpn/test/e2e/installed/state-dependent/too-many-devices.spec.ts
index d9346d358e..72ad0839c8 100644
--- a/desktop/packages/mullvad-vpn/test/e2e/installed/state-dependent/too-many-devices.spec.ts
+++ b/desktop/packages/mullvad-vpn/test/e2e/installed/state-dependent/too-many-devices.spec.ts
@@ -1,7 +1,7 @@
import { expect, test } from '@playwright/test';
import { Locator, Page } from 'playwright';
-import { RoutePath } from '../../../../src/renderer/lib/routes';
+import { RoutePath } from '../../../../src/shared/routes';
import { TestUtils } from '../../utils';
import { startInstalledApp } from '../installed-utils';
diff --git a/desktop/packages/mullvad-vpn/test/e2e/installed/state-dependent/tunnel-state.spec.ts b/desktop/packages/mullvad-vpn/test/e2e/installed/state-dependent/tunnel-state.spec.ts
index edeabf9c62..44ca808734 100644
--- a/desktop/packages/mullvad-vpn/test/e2e/installed/state-dependent/tunnel-state.spec.ts
+++ b/desktop/packages/mullvad-vpn/test/e2e/installed/state-dependent/tunnel-state.spec.ts
@@ -3,7 +3,7 @@ import { exec as execAsync } from 'child_process';
import { Page } from 'playwright';
import { promisify } from 'util';
-import { RoutePath } from '../../../../src/renderer/lib/routes';
+import { RoutePath } from '../../../../src/shared/routes';
import { expectConnected, expectDisconnected, expectError } from '../../shared/tunnel-state';
import { escapeRegExp, TestUtils } from '../../utils';
import { startInstalledApp } from '../installed-utils';
diff --git a/desktop/packages/mullvad-vpn/test/e2e/mocked/app-upgrade/app-upgrade.spec.ts b/desktop/packages/mullvad-vpn/test/e2e/mocked/app-upgrade/app-upgrade.spec.ts
new file mode 100644
index 0000000000..6015f1b4fe
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/test/e2e/mocked/app-upgrade/app-upgrade.spec.ts
@@ -0,0 +1,261 @@
+import { expect, test } from '@playwright/test';
+import { Page } from 'playwright';
+
+import { RoutePath } from '../../../../src/shared/routes';
+import { MockedTestUtils, startMockedApp } from '../mocked-utils';
+import { createHelpers, createIpc, createSelectors, mockData, resolveIpcHandle } from './helpers';
+
+let page: Page;
+let util: MockedTestUtils;
+let helpers: ReturnType<typeof createHelpers>;
+let ipc: ReturnType<typeof createIpc>;
+let selectors: ReturnType<typeof createSelectors>;
+
+test.describe('App upgrade', () => {
+ if (process.platform === 'linux') {
+ test.skip();
+ }
+
+ const startup = async () => {
+ ({ page, util } = await startMockedApp());
+
+ helpers = createHelpers(page, util);
+ ipc = createIpc(util);
+ selectors = createSelectors(page);
+
+ await util.waitForRoute(RoutePath.main);
+
+ await ipc.send.upgradeVersion({
+ supported: true,
+ suggestedIsBeta: false,
+ suggestedUpgrade: {
+ changelog: mockData.changelog,
+ version: mockData.version,
+ },
+ });
+
+ await page.click('button[aria-label="Settings"]');
+ await util.waitForRoute(RoutePath.settings);
+ await page.getByRole('button', { name: 'App info' }).click();
+ await util.waitForRoute(RoutePath.appInfo);
+ await page.getByRole('button', { name: 'Update available' }).click();
+ await util.waitForRoute(RoutePath.appUpgrade);
+ };
+
+ const restart = async () => {
+ await page.close();
+ await startup();
+ };
+
+ test.beforeAll(async () => {
+ await startup();
+ });
+
+ test.afterAll(async () => {
+ await page.close();
+ });
+
+ test.describe('Should display changelog', () => {
+ test.afterAll(() => restart());
+
+ test('Should display new version number as heading', async () => {
+ const headingText = await page
+ .getByRole('heading', {
+ name: 'Version',
+ })
+ .textContent();
+ expect(headingText).toBe(`Version ${mockData.version}`);
+ });
+
+ test('Should display new version changelog', async () => {
+ const changelogList = page.getByRole('list');
+ const changelogListText = await changelogList.textContent();
+ expect(changelogListText).toEqual(mockData.changelog.join(''));
+
+ const changelogListItems = page.getByRole('listitem');
+ const changelogListItemsCount = await changelogListItems.count();
+ expect(changelogListItemsCount).toBe(mockData.changelog.length);
+ });
+ });
+
+ test.describe('Should download upgrade', () => {
+ test.afterAll(() => restart());
+
+ test('Should start upgrade when clicking Download & install button', async () => {
+ await helpers.startAppUpgrade();
+ const downloadAndLaunchInstallerButton = selectors.downloadAndLaunchInstallerButton();
+ await expect(downloadAndLaunchInstallerButton).toBeHidden();
+ });
+
+ test('Should show indeterminate download progress after upgrade started', async () => {
+ await expect(page.getByText('Downloading...')).toBeVisible();
+ await expect(page.getByText('Starting download...')).toBeVisible();
+
+ await helpers.expectProgress(0, true);
+ });
+
+ test('Should show download progress after receiving event', async () => {
+ const mockedProgress = 90;
+ await ipc.send.appUpgradeEventDownloadProgress({
+ progress: mockedProgress,
+ server: 'cdn.mullvad.net',
+ timeLeft: 120,
+ });
+
+ await expect(page.getByText('Downloading from: cdn.mullvad.net')).toBeVisible();
+ await expect(page.getByText('About 2 minutes remaining...')).toBeVisible();
+
+ await helpers.expectProgress(mockedProgress, true);
+ });
+
+ test('Should verify installer when download is complete', async () => {
+ await ipc.send.appUpgradeEventVerifyingInstaller();
+
+ await expect(page.getByText('Verifying installer')).toBeVisible();
+ await expect(page.getByText('Download complete')).toBeVisible();
+
+ await helpers.expectProgress(100);
+ });
+
+ test('Should show that it has verified the installer when verification is complete', async () => {
+ await ipc.send.appUpgradeEventVerifiedInstaller();
+
+ await expect(page.getByText('Verification successful!')).toBeVisible();
+ await expect(page.getByText('Download complete')).toBeVisible();
+
+ await helpers.expectProgress(100);
+ });
+ });
+
+ test.describe('Should handle failing to download upgrade', () => {
+ test.afterAll(() => restart());
+
+ test('Should handle failing to download upgrade', async () => {
+ await ipc.send.appUpgradeError('DOWNLOAD_FAILED');
+
+ await expect(
+ page.getByText(
+ 'Download failed, please check your connection/firewall and try again, or send a problem report.',
+ ),
+ ).toBeVisible();
+
+ const retryButton = selectors.retryButton();
+ await expect(retryButton).toBeVisible();
+
+ const reportProblemButton = selectors.reportProblemButton();
+ await expect(reportProblemButton).toBeVisible();
+ });
+
+ test('Should handle retrying download of upgrade', async () => {
+ const retryButton = selectors.retryButton();
+
+ await resolveIpcHandle(ipc.handle.appUpgrade(), retryButton.click());
+
+ await expect(page.getByText('Downloading...')).toBeVisible();
+ await expect(page.getByText('Starting download...')).toBeVisible();
+
+ await helpers.expectProgress(0, true);
+ });
+ });
+
+ test.describe('Should handle installer failing to start', () => {
+ test.afterAll(() => restart());
+
+ // This test should fail due to the window not being focused,
+ // which is a pre-requisite for launching the installer automatically.
+ test('Should handle installer failing to start automatically', async () => {
+ await ipc.send.windowFocus(false);
+
+ await ipc.send.upgradeVersion({
+ supported: true,
+ suggestedIsBeta: false,
+ suggestedUpgrade: {
+ changelog: mockData.changelog,
+ verifiedInstallerPath: mockData.verifiedInstallerPath,
+ version: mockData.version,
+ },
+ });
+
+ await ipc.send.appUpgradeEventVerifiedInstaller();
+
+ const installUpdateButton = selectors.installButton();
+
+ await expect(page.getByText('Verification successful!')).toBeVisible();
+ await expect(installUpdateButton).toBeVisible();
+ await expect(installUpdateButton).toBeEnabled();
+ });
+
+ test('Should handle installer failing to start manually', async () => {
+ const installUpdateButton = selectors.installButton();
+
+ await resolveIpcHandle(ipc.handle.appUpgradeInstallerStart(), installUpdateButton.click());
+
+ await ipc.send.appUpgradeEventExitedInstaller();
+ await ipc.send.appUpgradeError('START_INSTALLER_FAILED');
+
+ await expect(installUpdateButton).not.toBeVisible();
+
+ await expect(
+ page.getByText('Could not open installer, please try again or send a problem report.'),
+ ).toBeVisible();
+ const retryButton = selectors.retryButton();
+ await expect(retryButton).toBeVisible();
+ await expect(retryButton).toBeEnabled();
+
+ const reportProblemButton = selectors.reportProblemButton();
+ await expect(reportProblemButton).toBeVisible();
+ await expect(reportProblemButton).toBeEnabled();
+ });
+
+ test('Should handle installer repeatedly failing to start', async () => {
+ const retryButton = selectors.retryButton();
+
+ // Call the retry button 2 additional times, to increase the total
+ // errorCount to 3 in order for the ManualDownloadLink to be shown.
+ await resolveIpcHandle(ipc.handle.appUpgradeInstallerStart(), retryButton.click());
+ await ipc.send.appUpgradeEventExitedInstaller();
+ await ipc.send.appUpgradeError('START_INSTALLER_FAILED');
+
+ await resolveIpcHandle(ipc.handle.appUpgradeInstallerStart(), retryButton.click());
+ await ipc.send.appUpgradeEventExitedInstaller();
+ await ipc.send.appUpgradeError('START_INSTALLER_FAILED');
+
+ const manualDownloadLink = selectors.manualDownloadLink();
+ await expect(manualDownloadLink).toBeVisible();
+ });
+ });
+
+ test.describe('Should pause download', () => {
+ test('Should show Pause button after upgrade started', async () => {
+ await helpers.startAppUpgrade();
+ const pauseButton = selectors.pauseButton();
+
+ await expect(pauseButton).toBeVisible();
+ await expect(pauseButton).toBeEnabled();
+ });
+
+ test('Should pause upgrade when clicking the Pause button', async () => {
+ const pauseButton = selectors.pauseButton();
+
+ await resolveIpcHandle(ipc.handle.appUpgradeAbort(), pauseButton.click());
+
+ // After the app upgrade abort RPC is sent we expect to receive an aborted
+ // event.
+ await ipc.send.appUpgradeEventAborted();
+
+ await expect(pauseButton).toBeHidden();
+
+ const resumeButton = selectors.resumeButton();
+ await expect(resumeButton).toBeVisible();
+ await expect(resumeButton).toBeEnabled();
+ });
+
+ test('Should start upgrade again when clicking Resume button', async () => {
+ const resumeButton = selectors.resumeButton();
+
+ await resolveIpcHandle(ipc.handle.appUpgrade(), resumeButton.click());
+
+ await expect(resumeButton).toBeHidden();
+ });
+ });
+});
diff --git a/desktop/packages/mullvad-vpn/test/e2e/mocked/app-upgrade/helpers.ts b/desktop/packages/mullvad-vpn/test/e2e/mocked/app-upgrade/helpers.ts
new file mode 100644
index 0000000000..9c2570f04b
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/test/e2e/mocked/app-upgrade/helpers.ts
@@ -0,0 +1,147 @@
+import { expect } from '@playwright/test';
+import { Page } from 'playwright';
+
+import { AppUpgradeError, AppUpgradeEvent } from '../../../../src/shared/app-upgrade';
+import {
+ DaemonAppUpgradeEventStatusDownloadProgress,
+ IAppVersionInfo,
+} from '../../../../src/shared/daemon-rpc-types';
+import { MockedTestUtils } from '../mocked-utils';
+
+export const createIpc = (util: MockedTestUtils) => {
+ const createMockHandle = <T>(channel: string, response?: T) =>
+ util.mockIpcHandle<T | undefined>({ channel, response });
+
+ const createMockResponse = <T>(channel: string, response: T) =>
+ util.sendMockIpcResponse<T>({
+ channel,
+ response,
+ });
+
+ const createMockResponseAppUpgradeEvent = (event: AppUpgradeEvent) =>
+ createMockResponse<AppUpgradeEvent>('app-upgradeEvent', event);
+
+ return {
+ handle: {
+ appUpgrade: () => createMockHandle('appUpgrade'),
+ appUpgradeAbort: () => createMockHandle('appUpgradeAbort'),
+ appUpgradeInstallerStart: () => createMockHandle('appUpgradeInstallerStart'),
+ },
+ send: {
+ appUpgradeEventAborted: () =>
+ createMockResponseAppUpgradeEvent({
+ type: 'APP_UPGRADE_STATUS_ABORTED',
+ }),
+ appUpgradeEventDownloadStarted: () =>
+ createMockResponseAppUpgradeEvent({
+ type: 'APP_UPGRADE_STATUS_DOWNLOAD_STARTED',
+ }),
+ appUpgradeEventDownloadProgress: (
+ data: Omit<DaemonAppUpgradeEventStatusDownloadProgress, 'type'>,
+ ) =>
+ createMockResponseAppUpgradeEvent({
+ type: 'APP_UPGRADE_STATUS_DOWNLOAD_PROGRESS',
+ ...data,
+ }),
+ appUpgradeEventVerifyingInstaller: () =>
+ createMockResponseAppUpgradeEvent({
+ type: 'APP_UPGRADE_STATUS_VERIFYING_INSTALLER',
+ }),
+ appUpgradeEventVerifiedInstaller: () =>
+ createMockResponseAppUpgradeEvent({
+ type: 'APP_UPGRADE_STATUS_VERIFIED_INSTALLER',
+ }),
+ appUpgradeEventStartedInstaller: () =>
+ createMockResponseAppUpgradeEvent({
+ type: 'APP_UPGRADE_STATUS_STARTED_INSTALLER',
+ }),
+ appUpgradeEventExitedInstaller: () =>
+ createMockResponseAppUpgradeEvent({
+ type: 'APP_UPGRADE_STATUS_EXITED_INSTALLER',
+ }),
+ appUpgradeError: (error: AppUpgradeError) =>
+ createMockResponse<AppUpgradeError>('app-upgradeError', error),
+ upgradeVersion: (data: IAppVersionInfo) =>
+ createMockResponse<IAppVersionInfo>('upgradeVersion-', data),
+ windowFocus: (value: boolean) => createMockResponse<boolean>('window-focus', value),
+ },
+ };
+};
+
+export const createSelectors = (page: Page) => ({
+ downloadAndLaunchInstallerButton: () =>
+ page.getByRole('button', {
+ name: 'Download & install',
+ }),
+ downloadProgressBar: () => page.getByRole('progressbar'),
+ installButton: () =>
+ page.getByRole('button', {
+ name: 'Install update',
+ }),
+ manualDownloadLink: () =>
+ page.getByRole('link', {
+ name: 'Having problems? Try downloading the app from our website',
+ }),
+ pauseButton: () =>
+ page.getByRole('button', {
+ name: 'Pause',
+ }),
+ resumeButton: () =>
+ page.getByRole('button', {
+ name: 'Resume',
+ }),
+ retryButton: () =>
+ page.getByRole('button', {
+ name: 'Retry',
+ }),
+ reportProblemButton: () =>
+ page.getByRole('button', {
+ name: 'Report a problem',
+ }),
+ startingInstallerButton: () =>
+ page.getByRole('button', {
+ name: ' Starting installer...',
+ }),
+});
+
+export const mockData = {
+ changelog: ['This is a changelog.', 'Each item is on a separate line.', 'There are three items.'],
+ verifiedInstallerPath: '/tmp/dummy-path',
+ version: '2100.1',
+};
+
+export const resolveIpcHandle = async (test: Promise<void>, trigger: Promise<void>) => {
+ // The promise is resolved when its handle has been called.
+ // The handle should be called when the trigger is called.
+ const promise = await Promise.all([test, trigger]);
+ expect(promise).toBeTruthy();
+};
+
+export const createHelpers = (page: Page, util: MockedTestUtils) => {
+ const selectors = createSelectors(page);
+ const ipc = createIpc(util);
+
+ const startAppUpgrade = async () => {
+ const downloadAndLaunchInstallerButton = selectors.downloadAndLaunchInstallerButton();
+
+ await resolveIpcHandle(ipc.handle.appUpgrade(), downloadAndLaunchInstallerButton.click());
+
+ await ipc.send.appUpgradeEventDownloadStarted();
+ };
+
+ const expectProgress = async (progress: number, expectLabel?: boolean) => {
+ if (expectLabel) {
+ await expect(page.getByText(`${progress}%`)).toBeVisible();
+ }
+
+ const downloadProgressBarValue = await selectors
+ .downloadProgressBar()
+ .getAttribute('aria-valuenow');
+ expect(downloadProgressBarValue).toEqual(progress.toString());
+ };
+
+ return {
+ expectProgress,
+ startAppUpgrade,
+ };
+};
diff --git a/desktop/packages/mullvad-vpn/test/e2e/mocked/expired-account-error-view.spec.ts b/desktop/packages/mullvad-vpn/test/e2e/mocked/expired-account-error-view.spec.ts
index d03baafde8..fc1c6d99e7 100644
--- a/desktop/packages/mullvad-vpn/test/e2e/mocked/expired-account-error-view.spec.ts
+++ b/desktop/packages/mullvad-vpn/test/e2e/mocked/expired-account-error-view.spec.ts
@@ -2,8 +2,8 @@ import { expect, test } from '@playwright/test';
import { Page } from 'playwright';
import { colorTokens } from '../../../src/renderer/lib/foundations';
-import { RoutePath } from '../../../src/renderer/lib/routes';
import { IAccountData } from '../../../src/shared/daemon-rpc-types';
+import { RoutePath } from '../../../src/shared/routes';
import { getBackgroundColor } from '../utils';
import { MockedTestUtils, startMockedApp } from './mocked-utils';
diff --git a/desktop/packages/mullvad-vpn/test/e2e/mocked/feature-indicators.spec.ts b/desktop/packages/mullvad-vpn/test/e2e/mocked/feature-indicators.spec.ts
index 9e985fbf63..26509664b3 100644
--- a/desktop/packages/mullvad-vpn/test/e2e/mocked/feature-indicators.spec.ts
+++ b/desktop/packages/mullvad-vpn/test/e2e/mocked/feature-indicators.spec.ts
@@ -1,13 +1,13 @@
import { expect, test } from '@playwright/test';
import { Page } from 'playwright';
-import { RoutePath } from '../../../src/renderer/lib/routes';
import {
FeatureIndicator,
ILocation,
ITunnelEndpoint,
TunnelState,
} from '../../../src/shared/daemon-rpc-types';
+import { RoutePath } from '../../../src/shared/routes';
import { expectConnected } from '../shared/tunnel-state';
import { MockedTestUtils, startMockedApp } from './mocked-utils';
diff --git a/desktop/packages/mullvad-vpn/test/e2e/mocked/main.spec.ts b/desktop/packages/mullvad-vpn/test/e2e/mocked/main.spec.ts
index 1162866ff4..7aefa1fa7d 100644
--- a/desktop/packages/mullvad-vpn/test/e2e/mocked/main.spec.ts
+++ b/desktop/packages/mullvad-vpn/test/e2e/mocked/main.spec.ts
@@ -1,7 +1,7 @@
import { expect, test } from '@playwright/test';
import { Page } from 'playwright';
-import { RoutePath } from '../../../src/renderer/lib/routes';
+import { RoutePath } from '../../../src/shared/routes';
import { TestUtils } from '../utils';
import { startMockedApp } from './mocked-utils';
diff --git a/desktop/packages/mullvad-vpn/test/e2e/mocked/notifications.spec.ts b/desktop/packages/mullvad-vpn/test/e2e/mocked/notifications.spec.ts
index c783b8a868..d432e0df4c 100644
--- a/desktop/packages/mullvad-vpn/test/e2e/mocked/notifications.spec.ts
+++ b/desktop/packages/mullvad-vpn/test/e2e/mocked/notifications.spec.ts
@@ -3,7 +3,6 @@ import { Page } from 'playwright';
import { getDefaultSettings } from '../../../src/main/default-settings';
import { colorTokens } from '../../../src/renderer/lib/foundations';
-import { RoutePath } from '../../../src/renderer/lib/routes';
import {
Constraint,
ErrorStateCause,
@@ -12,6 +11,7 @@ import {
ISettings,
TunnelState,
} from '../../../src/shared/daemon-rpc-types';
+import { RoutePath } from '../../../src/shared/routes';
import { getBackgroundColor } from '../utils';
import { MockedTestUtils, startMockedApp } from './mocked-utils';
diff --git a/desktop/packages/mullvad-vpn/test/e2e/mocked/select-location.spec.ts b/desktop/packages/mullvad-vpn/test/e2e/mocked/select-location.spec.ts
index ffcf9d42e4..624142fbf9 100644
--- a/desktop/packages/mullvad-vpn/test/e2e/mocked/select-location.spec.ts
+++ b/desktop/packages/mullvad-vpn/test/e2e/mocked/select-location.spec.ts
@@ -3,13 +3,13 @@ import { Page } from 'playwright';
import { getDefaultSettings } from '../../../src/main/default-settings';
import { colorTokens } from '../../../src/renderer/lib/foundations';
-import { RoutePath } from '../../../src/renderer/lib/routes';
import {
IRelayList,
IRelayListWithEndpointData,
ISettings,
IWireguardEndpointData,
} from '../../../src/shared/daemon-rpc-types';
+import { RoutePath } from '../../../src/shared/routes';
import { MockedTestUtils, startMockedApp } from './mocked-utils';
const relayList: IRelayList = {
diff --git a/desktop/packages/mullvad-vpn/test/e2e/mocked/settings.spec.ts b/desktop/packages/mullvad-vpn/test/e2e/mocked/settings.spec.ts
index 8330e74b0a..472893a5c0 100644
--- a/desktop/packages/mullvad-vpn/test/e2e/mocked/settings.spec.ts
+++ b/desktop/packages/mullvad-vpn/test/e2e/mocked/settings.spec.ts
@@ -1,8 +1,8 @@
import { expect, test } from '@playwright/test';
import { Page } from 'playwright';
-import { RoutePath } from '../../../src/renderer/lib/routes';
import { IAccountData } from '../../../src/shared/daemon-rpc-types';
+import { RoutePath } from '../../../src/shared/routes';
import { MockedTestUtils, startMockedApp } from './mocked-utils';
let page: Page;
diff --git a/desktop/packages/mullvad-vpn/test/e2e/mocked/tunnel-state.spec.ts b/desktop/packages/mullvad-vpn/test/e2e/mocked/tunnel-state.spec.ts
index f46ab3ce62..e8a706471b 100644
--- a/desktop/packages/mullvad-vpn/test/e2e/mocked/tunnel-state.spec.ts
+++ b/desktop/packages/mullvad-vpn/test/e2e/mocked/tunnel-state.spec.ts
@@ -1,13 +1,13 @@
import { test } from '@playwright/test';
import { Page } from 'playwright';
-import { RoutePath } from '../../../src/renderer/lib/routes';
import {
ErrorStateCause,
ILocation,
ITunnelEndpoint,
TunnelState,
} from '../../../src/shared/daemon-rpc-types';
+import { RoutePath } from '../../../src/shared/routes';
import {
expectConnected,
expectConnecting,
diff --git a/desktop/packages/mullvad-vpn/test/e2e/route-object-models/main/main-route-object-model.ts b/desktop/packages/mullvad-vpn/test/e2e/route-object-models/main/main-route-object-model.ts
index cee2e8b43b..6e1c2bb099 100644
--- a/desktop/packages/mullvad-vpn/test/e2e/route-object-models/main/main-route-object-model.ts
+++ b/desktop/packages/mullvad-vpn/test/e2e/route-object-models/main/main-route-object-model.ts
@@ -1,6 +1,6 @@
import { Page } from 'playwright';
-import { RoutePath } from '../../../../src/renderer/lib/routes';
+import { RoutePath } from '../../../../src/shared/routes';
import { TestUtils } from '../../utils';
import { createSelectors } from './selectors';
diff --git a/desktop/packages/mullvad-vpn/test/e2e/route-object-models/settings/settings-route-object-model.ts b/desktop/packages/mullvad-vpn/test/e2e/route-object-models/settings/settings-route-object-model.ts
index eab86a5f75..e85af07bd2 100644
--- a/desktop/packages/mullvad-vpn/test/e2e/route-object-models/settings/settings-route-object-model.ts
+++ b/desktop/packages/mullvad-vpn/test/e2e/route-object-models/settings/settings-route-object-model.ts
@@ -1,6 +1,6 @@
import { Page } from 'playwright';
-import { RoutePath } from '../../../../src/renderer/lib/routes';
+import { RoutePath } from '../../../../src/shared/routes';
import { TestUtils } from '../../utils';
import { createSelectors } from './selectors';
diff --git a/desktop/packages/mullvad-vpn/test/e2e/route-object-models/user-interface-settings/user-interface-settings-route-object-model.ts b/desktop/packages/mullvad-vpn/test/e2e/route-object-models/user-interface-settings/user-interface-settings-route-object-model.ts
index 58595d5fc3..47c434cf62 100644
--- a/desktop/packages/mullvad-vpn/test/e2e/route-object-models/user-interface-settings/user-interface-settings-route-object-model.ts
+++ b/desktop/packages/mullvad-vpn/test/e2e/route-object-models/user-interface-settings/user-interface-settings-route-object-model.ts
@@ -1,6 +1,6 @@
import { Page } from 'playwright';
-import { RoutePath } from '../../../../src/renderer/lib/routes';
+import { RoutePath } from '../../../../src/shared/routes';
import { TestUtils } from '../../utils';
import { createSelectors } from './selectors';
diff --git a/desktop/packages/mullvad-vpn/test/e2e/setup/main.ts b/desktop/packages/mullvad-vpn/test/e2e/setup/main.ts
index 700c71c5a5..d23d85cfa6 100644
--- a/desktop/packages/mullvad-vpn/test/e2e/setup/main.ts
+++ b/desktop/packages/mullvad-vpn/test/e2e/setup/main.ts
@@ -29,6 +29,7 @@ class ApplicationMain {
unpinnedWindow: process.platform !== 'win32' && process.platform !== 'darwin',
browsedForSplitTunnelingApplications: [],
changelogDisplayedForVersion: '',
+ updateDismissedForVersion: '',
animateMap: true,
};
diff --git a/desktop/packages/mullvad-vpn/test/unit/history.spec.ts b/desktop/packages/mullvad-vpn/test/unit/history.spec.ts
index 739c65c5ca..7d76d33295 100644
--- a/desktop/packages/mullvad-vpn/test/unit/history.spec.ts
+++ b/desktop/packages/mullvad-vpn/test/unit/history.spec.ts
@@ -2,7 +2,7 @@ import { expect, spy } from 'chai';
import { beforeEach, describe, it } from 'mocha';
import History from '../../src/renderer/lib/history';
-import { RoutePath } from '../../src/renderer/lib/routes';
+import { RoutePath } from '../../src/shared/routes';
const BASE_PATH = RoutePath.launch;
const FIRST_PATH = RoutePath.main;
diff --git a/desktop/packages/mullvad-vpn/test/unit/notification-evaluation.spec.ts b/desktop/packages/mullvad-vpn/test/unit/notification-evaluation.spec.ts
index 2a1924e52d..5ec99d83b6 100644
--- a/desktop/packages/mullvad-vpn/test/unit/notification-evaluation.spec.ts
+++ b/desktop/packages/mullvad-vpn/test/unit/notification-evaluation.spec.ts
@@ -10,6 +10,7 @@ import {
UnsupportedVersionNotificationProvider,
UpdateAvailableNotificationProvider,
} from '../../src/shared/notifications';
+import { RoutePath } from '../../src/shared/routes';
function createController() {
return new NotificationController({
@@ -17,6 +18,9 @@ function createController() {
/* no-op */
},
openLink: (_url: string, _withAuth?: boolean) => Promise.resolve(),
+ openRoute: (_url: RoutePath) => {
+ /* no-op */
+ },
showNotificationIcon: (_value: boolean) => {
/* no-op */
},
@@ -51,7 +55,6 @@ describe('System notifications', () => {
const notification = new UnsupportedVersionNotificationProvider({
supported: false,
consistent: true,
- suggestedUpgrade: '2100.1',
suggestedIsBeta: false,
});
@@ -69,7 +72,10 @@ describe('System notifications', () => {
const controller1 = createController();
const controller2 = createController();
const notification = new UpdateAvailableNotificationProvider({
- suggestedUpgrade: '2100.1',
+ suggestedUpgrade: {
+ changelog: [],
+ version: '2100.1',
+ },
suggestedIsBeta: false,
});
@@ -88,7 +94,6 @@ describe('System notifications', () => {
const notification = new UnsupportedVersionNotificationProvider({
supported: false,
consistent: true,
- suggestedUpgrade: '2100.1',
suggestedIsBeta: false,
});
@@ -105,7 +110,6 @@ describe('System notifications', () => {
const notification = new UnsupportedVersionNotificationProvider({
supported: false,
consistent: true,
- suggestedUpgrade: '2100.1',
suggestedIsBeta: false,
});
diff --git a/dist-assets/windows/installer.nsh b/dist-assets/windows/installer.nsh
index 9e0a2dfaf7..d9e9fc63af 100644
--- a/dist-assets/windows/installer.nsh
+++ b/dist-assets/windows/installer.nsh
@@ -890,7 +890,28 @@
${ExtractMullvadSetup}
${ClearFirewallRules}
- MessageBox MB_OK "Failed to uninstall a previous version. Please try restarting your computer and try again. If you still have this issue, please contact support."
+ # Give the user some helpful instructions about how to proceed
+ Push $0
+
+ ${GetParameters} $0
+ ${GetOptions} $0 "/inapp" $1
+ ${If} ${Errors}
+ Push 0
+ ${Else}
+ Push 1
+ ${EndIf}
+
+ Pop $0
+
+ # Show appropriate message about failed update depending on whether /inapp is specified
+ ${If} $0 == 1
+ MessageBox MB_OK "The update could not be installed and the previous version is missing. Please download the app again from mullvad.net/download and reinstall. If that fails, please try restarting your computer and try again."
+ ${Else}
+ MessageBox MB_OK "Failed to uninstall a previous version. Please try restarting your computer and try again. If you still have this issue, please contact support."
+ ${EndIf}
+
+ Pop $0
+
SetErrorLevel 5
Abort
diff --git a/installer-downloader/CHANGELOG.md b/installer-downloader/CHANGELOG.md
index aceeb14b41..9629809539 100644
--- a/installer-downloader/CHANGELOG.md
+++ b/installer-downloader/CHANGELOG.md
@@ -21,10 +21,10 @@ Line wrap the file at 100 chars. Th
## [Unreleased]
### Fix
+- Fix downloads hanging indefinitely on switching networks
#### macOS
- Fix rendering issues on old (unsupported) macOS versions.
-
## [1.0.0] - 2025-05-13
### Fixed
#### Windows
diff --git a/installer-downloader/src/controller.rs b/installer-downloader/src/controller.rs
index cf63154719..a5e2d6aabe 100644
--- a/installer-downloader/src/controller.rs
+++ b/installer-downloader/src/controller.rs
@@ -146,7 +146,7 @@ where
// For the downloader, the rollout version is always preferred
rollout: mullvad_update::version::IGNORE,
// The downloader allows any version
- lowest_metadata_version: 0,
+ lowest_metadata_version: mullvad_update::version::MIN_VERIFY_METADATA_VERSION,
};
let err = match version_provider.get_version_info(version_params).await {
@@ -328,7 +328,7 @@ impl<D: AppDelegate + 'static, A: From<UiAppDownloaderParameters<D>> + AppDownlo
}
};
- log::debug!("Download directory: {}", download_dir.display());
+ log::trace!("Download directory: {}", download_dir.display());
// Begin download
let (tx, rx) = oneshot::channel();
diff --git a/mullvad-api/src/version.rs b/mullvad-api/src/version.rs
index ef68525802..0a53631351 100644
--- a/mullvad-api/src/version.rs
+++ b/mullvad-api/src/version.rs
@@ -2,9 +2,10 @@ use std::future::Future;
use std::sync::Arc;
use http::StatusCode;
-use mullvad_types::version::AppVersion;
use mullvad_update::version::{VersionInfo, VersionParameters};
+type AppVersion = String;
+
use super::rest;
use super::APP_URL_PREFIX;
@@ -18,7 +19,16 @@ pub struct AppVersionResponse {
pub supported: bool,
pub latest: AppVersion,
pub latest_stable: Option<AppVersion>,
- pub latest_beta: AppVersion,
+ pub latest_beta: Option<AppVersion>,
+}
+
+/// Reply from `/app/releases/<platform>.json` endpoint
+pub struct AppVersionResponse2 {
+ /// Information about available versions for the current target
+ pub version_info: VersionInfo,
+ /// Index of the metadata version used to sign the response.
+ /// Used to prevent replay/downgrade attacks.
+ pub metadata_version: usize,
}
impl AppVersionProxy {
@@ -56,7 +66,7 @@ impl AppVersionProxy {
architecture: mullvad_update::format::Architecture,
rollout: f32,
lowest_metadata_version: usize,
- ) -> impl Future<Output = Result<VersionInfo, rest::Error>> + use<> {
+ ) -> impl Future<Output = Result<AppVersionResponse2, rest::Error>> + use<> {
let service = self.handle.service.clone();
let path = format!("app/releases/{platform}.json");
let request = self.handle.factory.get(&path);
@@ -78,9 +88,13 @@ impl AppVersionProxy {
lowest_metadata_version,
};
- VersionInfo::try_from_response(&params, response.signed)
- .map_err(Arc::new)
- .map_err(rest::Error::FetchVersions)
+ let metadata_version = response.signed.metadata_version;
+ Ok(AppVersionResponse2 {
+ version_info: VersionInfo::try_from_response(&params, response.signed)
+ .map_err(Arc::new)
+ .map_err(rest::Error::FetchVersions)?,
+ metadata_version,
+ })
}
}
}
diff --git a/mullvad-cli/src/cmds/version.rs b/mullvad-cli/src/cmds/version.rs
index d1a5ff162a..18f2f49f11 100644
--- a/mullvad-cli/src/cmds/version.rs
+++ b/mullvad-cli/src/cmds/version.rs
@@ -21,28 +21,16 @@ pub async fn print() -> Result<()> {
.get_version_info()
.await
.context("Failed to get version info")?;
- println!("{:22}: {}", "Is supported", version_info.supported);
+ println!(
+ "{:22}: {}",
+ "Is supported", version_info.current_version_supported
+ );
if let Some(suggested_upgrade) = version_info.suggested_upgrade {
- println!("{:22}: {}", "Suggested upgrade", suggested_upgrade);
+ println!("{:22}: {}", "Suggested upgrade", suggested_upgrade.version);
} else {
println!("{:22}: none", "Suggested upgrade");
}
- if !version_info.latest_stable.is_empty() {
- println!(
- "{:22}: {}",
- "Latest stable version", version_info.latest_stable
- );
- }
-
- let settings = rpc
- .get_settings()
- .await
- .context("Failed to obtain settings")?;
- if settings.show_beta_releases {
- println!("{:22}: {}", "Latest beta version", version_info.latest_beta);
- };
-
Ok(())
}
diff --git a/mullvad-daemon/Cargo.toml b/mullvad-daemon/Cargo.toml
index bc22a2d1c8..07a2a50a4d 100644
--- a/mullvad-daemon/Cargo.toml
+++ b/mullvad-daemon/Cargo.toml
@@ -29,7 +29,7 @@ regex = "1.0"
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
tokio = { workspace = true, features = ["fs", "io-util", "rt-multi-thread", "sync", "time"] }
-tokio-stream = "0.1"
+tokio-stream = { version = "0.1", features = ["sync"]}
socket2 = { workspace = true }
mullvad-relay-selector = { path = "../mullvad-relay-selector" }
@@ -39,6 +39,7 @@ mullvad-encrypted-dns-proxy = { path = "../mullvad-encrypted-dns-proxy" }
mullvad-fs = { path = "../mullvad-fs" }
mullvad-paths = { path = "../mullvad-paths" }
mullvad-version = { path = "../mullvad-version" }
+mullvad-update = { path = "../mullvad-update", features = ["client"] }
mullvad-leak-checker = { path = "../mullvad-leak-checker", default-features = false }
talpid-core = { path = "../talpid-core" }
talpid-future = { path = "../talpid-future" }
@@ -46,6 +47,7 @@ talpid-platform-metadata = { path = "../talpid-platform-metadata" }
talpid-time = { path = "../talpid-time" }
talpid-types = { path = "../talpid-types" }
talpid-routing = { path = "../talpid-routing" }
+rand = "0.8.5"
clap = { workspace = true }
log-panics = "2.0.0"
@@ -56,8 +58,8 @@ talpid-time = { path = "../talpid-time", features = ["test"] }
tokio = { workspace = true, features = ["test-util"] }
[target.'cfg(target_os="android")'.dependencies]
-android_logger = "0.8"
async-trait = "0.1"
+android_logger = "0.8"
hickory-resolver = { workspace = true }
[target.'cfg(unix)'.dependencies]
diff --git a/mullvad-daemon/build.rs b/mullvad-daemon/build.rs
index 1310450aa9..f0790dddca 100644
--- a/mullvad-daemon/build.rs
+++ b/mullvad-daemon/build.rs
@@ -33,6 +33,12 @@ fn main() {
// Enable DAITA by default on desktop and android
println!("cargo::rustc-check-cfg=cfg(daita)");
println!(r#"cargo::rustc-cfg=daita"#);
+
+ // Enable in-app upgrades on macOS and Windows
+ println!("cargo::rustc-check-cfg=cfg(in_app_upgrade)");
+ if matches!(target_os(), Os::Windows | Os::Macos) {
+ println!(r#"cargo::rustc-cfg=in_app_upgrade"#);
+ }
}
fn commit_date() -> String {
@@ -45,3 +51,22 @@ fn commit_date() -> String {
.trim()
.to_owned()
}
+
+#[derive(PartialEq, Eq, Clone, Copy)]
+enum Os {
+ Windows,
+ Macos,
+ Linux,
+ Android,
+}
+
+fn target_os() -> Os {
+ let target_os = env::var("CARGO_CFG_TARGET_OS").unwrap();
+ match target_os.as_str() {
+ "windows" => Os::Windows,
+ "macos" => Os::Macos,
+ "linux" => Os::Linux,
+ "android" => Os::Android,
+ _ => panic!("Unsupported target os: {target_os}"),
+ }
+}
diff --git a/mullvad-daemon/src/lib.rs b/mullvad-daemon/src/lib.rs
index e165f7e6eb..49ebb74ff8 100644
--- a/mullvad-daemon/src/lib.rs
+++ b/mullvad-daemon/src/lib.rs
@@ -28,7 +28,6 @@ pub mod shutdown;
mod target_state;
mod tunnel;
pub mod version;
-mod version_check;
use crate::target_state::PersistentTargetState;
use api::DaemonAccessMethodResolver;
@@ -64,7 +63,7 @@ use mullvad_types::{
relay_list::RelayList,
settings::{DnsOptions, Settings},
states::{Secured, TargetState, TargetStateStrict, TunnelState},
- version::{AppVersion, AppVersionInfo},
+ version::AppVersionInfo,
wireguard::{PublicKey, QuantumResistantState, RotationInterval},
};
use relay_list::{RelayListUpdater, RelayListUpdaterHandle, RELAYS_FILENAME};
@@ -126,7 +125,7 @@ pub enum Error {
ApiCheckError(#[source] mullvad_api::availability::Error),
#[error("Version check failed")]
- VersionCheckError(#[source] version_check::Error),
+ VersionCheckError(#[source] version::Error),
#[error("Unable to load account history")]
LoadAccountHistory(#[source] account_history::Error),
@@ -337,7 +336,7 @@ pub enum DaemonCommand {
/// Return whether the daemon is performing post-upgrade tasks
IsPerformingPostUpgrade(oneshot::Sender<bool>),
/// Get current version of the app
- GetCurrentVersion(oneshot::Sender<AppVersion>),
+ GetCurrentVersion(oneshot::Sender<mullvad_version::Version>),
/// Remove settings and clear the cache
#[cfg(not(target_os = "android"))]
FactoryReset(ResponseTx<(), Error>),
@@ -402,6 +401,13 @@ pub enum DaemonCommand {
relay: String,
tx: oneshot::Sender<()>,
},
+ // App upgrade
+ /// Prompt the daemon to start an app version upgrade.
+ ///
+ /// If an upgrade had previously been started but not completed the daemon should continue the upgrade process at the appropriate step. The client need not be notified about this detail.
+ AppUpgrade(ResponseTx<(), version::Error>),
+ /// Prompt the daemon to abort the current upgrade.
+ AppUpgradeAbort(ResponseTx<(), version::Error>),
}
/// All events that can happen in the daemon. Sent from various threads and exposed interfaces.
@@ -621,7 +627,7 @@ pub struct Daemon {
access_mode_handler: mullvad_api::access_mode::AccessModeSelectorHandle,
api_runtime: mullvad_api::Runtime,
api_handle: mullvad_api::rest::MullvadRestHandle,
- version_updater_handle: version_check::VersionUpdaterHandle,
+ version_handle: version::router::VersionRouterHandle,
relay_selector: RelaySelector,
relay_list_updater: RelayListUpdaterHandle,
parameters_generator: tunnel::ParametersGenerator,
@@ -653,9 +659,13 @@ impl Daemon {
macos::bump_filehandle_limit();
let command_sender = daemon_command_channel.sender();
- let management_interface =
- ManagementInterfaceServer::start(command_sender, config.rpc_socket_path)
- .map_err(Error::ManagementInterfaceError)?;
+ let app_upgrade_broadcast = tokio::sync::broadcast::channel(32).0;
+ let management_interface = ManagementInterfaceServer::start(
+ command_sender,
+ config.rpc_socket_path,
+ app_upgrade_broadcast.clone(),
+ )
+ .map_err(Error::ManagementInterfaceError)?;
let (internal_event_tx, internal_event_rx) = daemon_command_channel.destructure();
@@ -890,14 +900,14 @@ impl Daemon {
on_relay_list_update,
);
- let version_updater_handle = version_check::VersionUpdater::spawn(
+ let version_handle = version::router::spawn_version_router(
api_handle.clone(),
- api_availability.clone(),
+ api_handle.availability.clone(),
config.cache_dir.clone(),
internal_event_tx.to_specialized_sender(),
settings.show_beta_releases,
- )
- .await;
+ app_upgrade_broadcast,
+ );
// Attempt to download a fresh relay list
relay_list_updater.update().await;
@@ -944,7 +954,7 @@ impl Daemon {
access_mode_handler,
api_runtime,
api_handle,
- version_updater_handle,
+ version_handle,
relay_selector,
relay_list_updater,
parameters_generator,
@@ -1473,6 +1483,8 @@ impl Daemon {
GetFeatureIndicators(tx) => self.on_get_feature_indicators(tx),
DisableRelay { relay, tx } => self.on_toggle_relay(relay, false, tx),
EnableRelay { relay, tx } => self.on_toggle_relay(relay, true, tx),
+ AppUpgrade(tx) => self.on_app_upgrade(tx).await,
+ AppUpgradeAbort(tx) => self.on_app_upgrade_abort(tx).await,
}
}
@@ -1939,12 +1951,12 @@ impl Daemon {
}
fn on_get_version_info(&mut self, tx: oneshot::Sender<Result<AppVersionInfo, Error>>) {
- let mut handle = self.version_updater_handle.clone();
+ let handle = self.version_handle.clone();
tokio::spawn(async move {
Self::oneshot_send(
tx,
handle
- .get_version_info()
+ .get_latest_version()
.await
.inspect_err(|error| {
log::error!(
@@ -1958,10 +1970,12 @@ impl Daemon {
});
}
- fn on_get_current_version(&mut self, tx: oneshot::Sender<AppVersion>) {
+ fn on_get_current_version(&mut self, tx: oneshot::Sender<mullvad_version::Version>) {
Self::oneshot_send(
tx,
- mullvad_version::VERSION.to_owned(),
+ mullvad_version::VERSION
+ .parse::<mullvad_version::Version>()
+ .expect("Failed to parse version"),
"get_current_version response",
);
}
@@ -2307,8 +2321,12 @@ impl Daemon {
Ok(settings_changed) => {
Self::oneshot_send(tx, Ok(()), "set_show_beta_releases response");
if settings_changed {
- let mut handle = self.version_updater_handle.clone();
- handle.set_show_beta_releases(enabled).await;
+ let version_handle = self.version_handle.clone();
+ tokio::spawn(async move {
+ if let Err(error) = version_handle.set_show_beta_releases(enabled).await {
+ log::error!("Failed to reset beta releases state: {error}");
+ }
+ });
}
}
Err(e) => {
@@ -3013,9 +3031,16 @@ impl Daemon {
let dns = dns::addresses_from_options(&self.settings.tunnel_options.dns_options);
self.send_tunnel_command(TunnelCommand::Dns(dns, tx));
- self.version_updater_handle
- .set_show_beta_releases(self.settings.show_beta_releases)
- .await;
+ let version_handle = self.version_handle.clone();
+ let show_beta_releases = self.settings.show_beta_releases;
+ tokio::spawn(async move {
+ if let Err(error) = version_handle
+ .set_show_beta_releases(show_beta_releases)
+ .await
+ {
+ log::error!("Failed to reset beta releases state: {error}");
+ }
+ });
let access_mode_handler = self.access_mode_handler.clone();
tokio::spawn(async move {
if let Err(error) = access_mode_handler.rotate().await {
@@ -3205,6 +3230,37 @@ impl Daemon {
Self::oneshot_send(tx, (), "on_toggle_relay response");
}
+ #[cfg_attr(not(in_app_upgrade), allow(clippy::unused_async))]
+ async fn on_app_upgrade(&self, tx: ResponseTx<(), version::Error>) {
+ #[cfg(in_app_upgrade)]
+ {
+ let result = self.version_handle.update_application().await;
+ Self::oneshot_send(tx, result, "on_app_upgrade response");
+ }
+ #[cfg(not(in_app_upgrade))]
+ {
+ log::warn!("Ignoring app upgrade command as in-app upgrades are disabled on this OS");
+ Self::oneshot_send(tx, Ok(()), "on_app_upgrade response")
+ };
+ }
+
+ #[cfg_attr(not(in_app_upgrade), allow(clippy::unused_async))]
+ async fn on_app_upgrade_abort(&self, tx: ResponseTx<(), version::Error>) {
+ #[cfg(in_app_upgrade)]
+ {
+ let result = self.version_handle.cancel_update().await;
+ Self::oneshot_send(tx, result, "on_app_upgrade_abort response");
+ }
+ #[cfg(not(in_app_upgrade))]
+ {
+ log::warn!(
+ "Ignoring cancel app upgrade command as in-app upgrades are disabled on this OS"
+ );
+
+ Self::oneshot_send(tx, Ok(()), "on_app_upgrade_abort response")
+ };
+ }
+
/// Set the target state of the client. If it changed trigger the operations needed to
/// progress towards that state.
/// Returns a bool representing whether a state change was initiated.
diff --git a/mullvad-daemon/src/management_interface.rs b/mullvad-daemon/src/management_interface.rs
index 3efe41e288..6d95547137 100644
--- a/mullvad-daemon/src/management_interface.rs
+++ b/mullvad-daemon/src/management_interface.rs
@@ -1,4 +1,4 @@
-use crate::{account_history, device, version_check, DaemonCommand, DaemonCommandSender};
+use crate::{account_history, device, DaemonCommand, DaemonCommandSender};
use futures::{
channel::{mpsc, oneshot},
StreamExt,
@@ -38,15 +38,21 @@ pub enum Error {
SetupError(#[source] mullvad_management_interface::Error),
}
+pub type AppUpgradeBroadcast = tokio::sync::broadcast::Sender<version::AppUpgradeEvent>;
+
struct ManagementServiceImpl {
daemon_tx: DaemonCommandSender,
subscriptions: Arc<Mutex<Vec<EventsListenerSender>>>,
+ pub app_upgrade_broadcast: AppUpgradeBroadcast,
}
pub type ServiceResult<T> = std::result::Result<Response<T>, Status>;
type EventsListenerReceiver = UnboundedReceiverStream<Result<types::DaemonEvent, Status>>;
type EventsListenerSender = tokio::sync::mpsc::UnboundedSender<Result<types::DaemonEvent, Status>>;
+type AppUpgradeEventListenerReceiver =
+ Box<dyn futures::Stream<Item = Result<types::AppUpgradeEvent, Status>> + Send + Unpin>;
+
const INVALID_VOUCHER_MESSAGE: &str = "This voucher code is invalid";
const USED_VOUCHER_MESSAGE: &str = "This voucher code has already been used";
@@ -54,6 +60,7 @@ const USED_VOUCHER_MESSAGE: &str = "This voucher code has already been used";
impl ManagementService for ManagementServiceImpl {
type GetSplitTunnelProcessesStream = UnboundedReceiverStream<Result<i32, Status>>;
type EventsListenStream = EventsListenerReceiver;
+ type AppUpgradeEventsListenStream = AppUpgradeEventListenerReceiver;
// Control and get the tunnel state
//
@@ -139,7 +146,7 @@ impl ManagementService for ManagementServiceImpl {
log::debug!("get_current_version");
let (tx, rx) = oneshot::channel();
self.send_command_to_daemon(DaemonCommand::GetCurrentVersion(tx))?;
- let version = self.wait_for_result(rx).await?;
+ let version = self.wait_for_result(rx).await?.to_string();
Ok(Response::new(version))
}
@@ -1115,6 +1122,53 @@ impl ManagementService for ManagementServiceImpl {
self.wait_for_result(rx).await?;
Ok(Response::new(()))
}
+
+ // App upgrade
+
+ async fn app_upgrade(&self, _: Request<()>) -> ServiceResult<()> {
+ log::debug!("app_upgrade");
+
+ let (tx, rx) = oneshot::channel();
+ self.send_command_to_daemon(DaemonCommand::AppUpgrade(tx))?;
+
+ self.wait_for_result(rx)
+ .await?
+ .map_err(map_version_check_error)?;
+
+ Ok(Response::new(()))
+ }
+
+ async fn app_upgrade_abort(&self, _: Request<()>) -> ServiceResult<()> {
+ log::debug!("app_upgrade_abort");
+
+ let (tx, rx) = oneshot::channel();
+ self.send_command_to_daemon(DaemonCommand::AppUpgradeAbort(tx))?;
+
+ self.wait_for_result(rx)
+ .await?
+ .map_err(map_version_check_error)?;
+
+ Ok(Response::new(()))
+ }
+
+ async fn app_upgrade_events_listen(
+ &self,
+ _: Request<()>,
+ ) -> ServiceResult<Self::AppUpgradeEventsListenStream> {
+ log::debug!("app_upgrade_events_listen");
+ let rx = self.app_upgrade_broadcast.subscribe();
+ let upgrade_event_stream =
+ tokio_stream::wrappers::BroadcastStream::new(rx).map(|result| match result {
+ Ok(event) => Ok(event.into()),
+ Err(error) => Err(Status::internal(format!(
+ "Failed to receive app upgrade event: {error}"
+ ))),
+ });
+
+ Ok(Response::new(
+ Box::new(upgrade_event_stream) as Self::AppUpgradeEventsListenStream
+ ))
+ }
}
impl ManagementServiceImpl {
@@ -1147,15 +1201,19 @@ impl ManagementInterfaceServer {
pub fn start(
daemon_tx: DaemonCommandSender,
rpc_socket_path: impl AsRef<Path>,
+ app_upgrade_broadcast: tokio::sync::broadcast::Sender<version::AppUpgradeEvent>,
) -> Result<ManagementInterfaceServer, Error> {
let subscriptions = Arc::<Mutex<Vec<EventsListenerSender>>>::default();
+
// NOTE: It is important that the channel buffer size is kept at 0. When sending a signal
// to abort the gRPC server, the sender can be awaited to know when the gRPC server has
// received and started processing the shutdown signal.
let (server_abort_tx, server_abort_rx) = mpsc::channel(0);
+
let server = ManagementServiceImpl {
daemon_tx,
subscriptions: subscriptions.clone(),
+ app_upgrade_broadcast,
};
let rpc_server_join_handle = mullvad_management_interface::spawn_rpc_server(
server,
@@ -1257,7 +1315,7 @@ impl ManagementInterfaceEventBroadcaster {
/// Notify that info about the latest available app version changed.
/// Or some flag about the currently running version is changed.
pub(crate) fn notify_app_version(&self, app_version_info: version::AppVersionInfo) {
- log::debug!("Broadcasting new app version info");
+ log::debug!("Broadcasting app version info:\n{app_version_info}");
self.notify(types::DaemonEvent {
event: Some(daemon_event::Event::VersionInfo(
types::AppVersionInfo::from(app_version_info),
@@ -1393,11 +1451,11 @@ fn map_account_history_error(error: account_history::Error) -> Status {
}
}
-fn map_version_check_error(error: version_check::Error) -> Status {
+fn map_version_check_error(error: crate::version::Error) -> Status {
match error {
- version_check::Error::Download(..)
- | version_check::Error::ReadVersionCache(..)
- | version_check::Error::ApiCheck(..) => Status::unavailable(error.to_string()),
+ crate::version::Error::Download(..)
+ | crate::version::Error::ReadVersionCache(..)
+ | crate::version::Error::ApiCheck(..) => Status::unavailable(error.to_string()),
_ => Status::unknown(error.to_string()),
}
}
diff --git a/mullvad-daemon/src/version.rs b/mullvad-daemon/src/version.rs
deleted file mode 100644
index db3d9db5d3..0000000000
--- a/mullvad-daemon/src/version.rs
+++ /dev/null
@@ -1,19 +0,0 @@
-/// Contains the date of the git commit this was built from
-pub const COMMIT_DATE: &str = include_str!(concat!(env!("OUT_DIR"), "/git-commit-date.txt"));
-
-pub fn is_beta_version() -> bool {
- mullvad_version::VERSION.contains("beta")
-}
-
-pub fn is_dev_version() -> bool {
- mullvad_version::VERSION.contains("dev")
-}
-
-pub fn log_version() {
- log::info!(
- "Starting {} - {} {}",
- env!("CARGO_PKG_NAME"),
- mullvad_version::VERSION,
- COMMIT_DATE,
- )
-}
diff --git a/mullvad-daemon/src/version_check.rs b/mullvad-daemon/src/version/check.rs
index a66dbc6903..de2bbb7117 100644
--- a/mullvad-daemon/src/version_check.rs
+++ b/mullvad-daemon/src/version/check.rs
@@ -1,20 +1,17 @@
-use crate::{version::is_beta_version, DaemonEventSender};
use futures::{
channel::{mpsc, oneshot},
future::{BoxFuture, FusedFuture},
- FutureExt, SinkExt, StreamExt, TryFutureExt,
+ FutureExt, StreamExt, TryFutureExt,
};
use mullvad_api::{
- availability::ApiAvailability,
- rest::MullvadRestHandle,
- version::{AppVersionProxy, AppVersionResponse},
+ availability::ApiAvailability, rest::MullvadRestHandle, version::AppVersionProxy,
};
-use mullvad_types::version::AppVersionInfo;
+
+use mullvad_update::version::VersionInfo;
use mullvad_version::Version;
use serde::{Deserialize, Serialize};
use std::{
future::Future,
- io,
path::{Path, PathBuf},
pin::Pin,
str::FromStr,
@@ -26,6 +23,8 @@ use talpid_future::retry::{retry_future, ConstantInterval};
use talpid_types::ErrorExt;
use tokio::{fs::File, io::AsyncReadExt};
+use super::Error;
+
const VERSION_INFO_FILENAME: &str = "version-info.json";
static APP_VERSION: LazyLock<Version> =
@@ -51,118 +50,36 @@ const PLATFORM: &str = "windows";
const PLATFORM: &str = "android";
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
-struct CachedAppVersionInfo {
- #[serde(flatten)]
- pub version_info: AppVersionInfo,
- pub cached_from_version: String,
-}
-
-impl From<AppVersionInfo> for CachedAppVersionInfo {
- fn from(version_info: AppVersionInfo) -> CachedAppVersionInfo {
- CachedAppVersionInfo {
- version_info,
- cached_from_version: mullvad_version::VERSION.to_owned(),
- }
- }
-}
-
-#[derive(thiserror::Error, Debug)]
-pub enum Error {
- #[error("Failed to open app version cache file for reading")]
- ReadVersionCache(#[source] io::Error),
-
- #[error("Failed to open app version cache file for writing")]
- WriteVersionCache(#[source] io::Error),
-
- #[error("Failure in serialization of the version info")]
- Serialize(#[source] serde_json::Error),
-
- #[error("Failure in deserialization of the version info")]
- Deserialize(#[source] serde_json::Error),
-
- #[error("Failed to check the latest app version")]
- Download(#[source] mullvad_api::rest::Error),
-
- #[error("API availability check failed")]
- ApiCheck(#[source] mullvad_api::availability::Error),
-
- #[error("Clearing version check cache due to a version mismatch")]
- CacheVersionMismatch,
-
- #[error("Version updater is down")]
- VersionUpdaterDown,
-
- #[error("Version cache update was aborted")]
- UpdateAborted,
+pub(super) struct VersionCache {
+ /// Whether the current (installed) version is supported or an upgrade is required
+ pub current_version_supported: bool,
+ /// The latest available versions
+ pub version_info: mullvad_update::version::VersionInfo,
+ #[cfg(in_app_upgrade)]
+ pub metadata_version: usize,
}
-pub(crate) struct VersionUpdater;
+pub(crate) struct VersionUpdater(());
#[derive(Default)]
struct VersionUpdaterInner {
/// The last known [AppVersionInfo], along with the time it was determined.
- last_app_version_info: Option<(AppVersionInfo, SystemTime)>,
- show_beta_releases: bool,
+ last_app_version_info: Option<(VersionCache, SystemTime)>,
/// Oneshot channels for responding to [VersionUpdaterCommand::GetVersionInfo].
- get_version_info_responders: Vec<oneshot::Sender<AppVersionInfo>>,
-}
-
-#[derive(Clone)]
-pub(crate) struct VersionUpdaterHandle {
- tx: mpsc::Sender<VersionUpdaterCommand>,
-}
-
-enum VersionUpdaterCommand {
- SetShowBetaReleases(bool),
- GetVersionInfo(oneshot::Sender<AppVersionInfo>),
-}
-
-impl VersionUpdaterHandle {
- pub async fn set_show_beta_releases(&mut self, show_beta_releases: bool) {
- if self
- .tx
- .send(VersionUpdaterCommand::SetShowBetaReleases(
- show_beta_releases,
- ))
- .await
- .is_err()
- {
- log::error!("Version updater already down, can't send new `show_beta_releases` state");
- }
- }
-
- /// Get the latest cached [AppVersionInfo].
- ///
- /// If the cache is stale or missing, this will immediately query the API for the latest
- /// version. This may take a few seconds.
- pub async fn get_version_info(&mut self) -> Result<AppVersionInfo, Error> {
- let (done_tx, done_rx) = oneshot::channel();
- if self
- .tx
- .send(VersionUpdaterCommand::GetVersionInfo(done_tx))
- .await
- .is_err()
- {
- Err(Error::VersionUpdaterDown)
- } else {
- done_rx.await.map_err(|_| Error::UpdateAborted)
- }
- }
+ get_version_info_responders: Vec<oneshot::Sender<VersionCache>>,
}
impl VersionUpdater {
- pub async fn spawn(
+ pub(super) async fn spawn(
mut api_handle: MullvadRestHandle,
availability_handle: ApiAvailability,
cache_dir: PathBuf,
- update_sender: DaemonEventSender<AppVersionInfo>,
- show_beta_releases: bool,
- ) -> VersionUpdaterHandle {
+ update_sender: mpsc::UnboundedSender<VersionCache>,
+ refresh_rx: mpsc::UnboundedReceiver<()>,
+ ) {
// load the last known AppVersionInfo from cache
let last_app_version_info = load_cache(&cache_dir).await;
- let (tx, rx) = mpsc::channel(1);
-
api_handle.factory = api_handle.factory.default_timeout(DOWNLOAD_TIMEOUT);
let version_proxy = AppVersionProxy::new(api_handle);
let cache_path = cache_dir.join(VERSION_INFO_FILENAME);
@@ -171,11 +88,10 @@ impl VersionUpdater {
tokio::spawn(
VersionUpdaterInner {
last_app_version_info,
- show_beta_releases,
get_version_info_responders: vec![],
}
.run(
- rx,
+ refresh_rx,
UpdateContext {
cache_path,
update_sender,
@@ -187,41 +103,47 @@ impl VersionUpdater {
},
),
);
-
- VersionUpdaterHandle { tx }
}
}
impl VersionUpdaterInner {
/// Get the last known [AppVersionInfo]. May be stale.
- pub fn last_app_version_info(&self) -> Option<&AppVersionInfo> {
+ pub fn last_app_version_info(&self) -> Option<&VersionCache> {
self.last_app_version_info.as_ref().map(|(info, _)| info)
}
- /// Convert a [AppVersionResponse] to an [AppVersionInfo].
- fn response_to_version_info(&self, response: AppVersionResponse) -> AppVersionInfo {
- let suggested_upgrade = suggested_upgrade(
- &APP_VERSION,
- &response.latest_stable,
- &response.latest_beta,
- self.show_beta_releases || is_beta_version(),
- );
+ #[cfg(in_app_upgrade)]
+ pub fn get_min_metadata_version(&self) -> usize {
+ self.last_app_version_info
+ .as_ref()
+ // Reject version responses with a lower metadata version
+ // than the newest version we know about. This is
+ // important to prevent downgrade attacks.
+ .map(|(info, _)| info.metadata_version)
+ .unwrap_or(mullvad_update::version::MIN_VERIFY_METADATA_VERSION)
+ }
- AppVersionInfo {
- supported: response.supported,
- latest_stable: response.latest_stable.unwrap_or_else(|| "".to_owned()),
- latest_beta: response.latest_beta,
- suggested_upgrade,
- }
+ #[cfg(not(in_app_upgrade))]
+ pub fn get_min_metadata_version(&self) -> usize {
+ mullvad_update::version::MIN_VERIFY_METADATA_VERSION
}
/// Update [Self::last_app_version_info] and write it to disk cache, and notify the `update`
/// callback.
+ #[allow(unused_mut)]
async fn update_version_info(
&mut self,
- update: &impl Fn(AppVersionInfo) -> BoxFuture<'static, Result<(), Error>>,
- new_version_info: AppVersionInfo,
+ update: &impl Fn(VersionCache) -> BoxFuture<'static, Result<(), Error>>,
+ mut new_version_info: VersionCache,
) {
+ #[cfg(in_app_upgrade)]
+ if let Some((current_cache, _)) = self.last_app_version_info.as_ref() {
+ if current_cache.metadata_version == new_version_info.metadata_version {
+ log::trace!("Ignoring version info with same metadata version");
+ new_version_info = current_cache.clone();
+ }
+ }
+
if let Err(err) = update(new_version_info.clone()).await {
log::error!("Failed to save version cache to disk: {}", err);
}
@@ -234,10 +156,7 @@ impl VersionUpdaterInner {
/// This happens [UPDATE_INTERVAL] after the last version check.
fn time_until_version_is_stale(&self) -> Duration {
let now = SystemTime::now();
- self
- .last_app_version_info
- .as_ref()
- .map(|(_, last_update_time)| last_update_time)
+ self.last_update_time()
.and_then(|&last_update_time| now.duration_since(last_update_time).ok())
.map(|time_since_last_update| UPDATE_INTERVAL.saturating_sub(time_since_last_update))
// if there is no last_app_version_info, or if clocks are being weird,
@@ -245,6 +164,12 @@ impl VersionUpdaterInner {
.unwrap_or(Duration::ZERO)
}
+ fn last_update_time(&self) -> Option<&SystemTime> {
+ self.last_app_version_info
+ .as_ref()
+ .map(|(_, last_update_time)| last_update_time)
+ }
+
/// Is [Self::last_app_version_info] stale?
fn version_is_stale(&self) -> bool {
self.time_until_version_is_stale().is_zero()
@@ -268,80 +193,66 @@ impl VersionUpdaterInner {
async fn run(
self,
- mut rx: mpsc::Receiver<VersionUpdaterCommand>,
+ mut refresh_rx: mpsc::UnboundedReceiver<()>,
update: UpdateContext,
api: ApiContext,
) {
// If this is a dev build, there's no need to pester the API for version checks.
if *IS_DEV_BUILD {
log::warn!("Not checking for updates because this is a development build");
- while let Some(cmd) = rx.next().await {
- if let VersionUpdaterCommand::GetVersionInfo(done_tx) = cmd {
- log::info!("Version check is disabled in dev builds");
- let _ = done_tx.send(dev_version_cache());
- }
+ while let Some(()) = refresh_rx.next().await {
+ log::info!("Version check is disabled in dev builds");
}
return;
}
let update = |info| Box::pin(update.update(info)) as BoxFuture<'static, _>;
- let do_version_check = || do_version_check(api.clone());
- let do_version_check_in_background = || do_version_check_in_background(api.clone());
+ let do_version_check =
+ |min_metadata_version| do_version_check(api.clone(), min_metadata_version);
+ let do_version_check_in_background = |min_metadata_version| {
+ do_version_check_in_background(api.clone(), min_metadata_version)
+ };
- self.run_inner(rx, update, do_version_check, do_version_check_in_background)
- .await
+ self.run_inner(
+ refresh_rx,
+ update,
+ do_version_check,
+ do_version_check_in_background,
+ )
+ .await
}
async fn run_inner(
mut self,
- mut rx: mpsc::Receiver<VersionUpdaterCommand>,
- update: impl Fn(AppVersionInfo) -> BoxFuture<'static, Result<(), Error>>,
- do_version_check: impl Fn() -> BoxFuture<'static, Result<AppVersionResponse, Error>>,
- do_version_check_in_background: impl Fn()
- -> BoxFuture<'static, Result<AppVersionResponse, Error>>,
+ mut refresh_rx: mpsc::UnboundedReceiver<()>,
+ update: impl Fn(VersionCache) -> BoxFuture<'static, Result<(), Error>>,
+ do_version_check: impl Fn(usize) -> BoxFuture<'static, Result<VersionCache, Error>>,
+ do_version_check_in_background: impl Fn(
+ usize,
+ )
+ -> BoxFuture<'static, Result<VersionCache, Error>>,
) {
let mut version_is_stale = self.wait_until_version_is_stale();
let mut version_check = futures::future::Fuse::terminated();
loop {
futures::select! {
- command = rx.next() => match command {
- Some(VersionUpdaterCommand::SetShowBetaReleases(show_beta_releases)) => {
- self.show_beta_releases = show_beta_releases;
-
- if let Some(last_app_version_info) = self
- .last_app_version_info()
- .cloned()
- {
- let suggested_upgrade = suggested_upgrade(
- &APP_VERSION,
- &Some(last_app_version_info.latest_stable.clone()),
- &last_app_version_info.latest_beta,
- self.show_beta_releases || is_beta_version(),
- );
+ command = refresh_rx.next() => match command {
- self.update_version_info(&update, AppVersionInfo {
- supported: last_app_version_info.supported,
- latest_stable: last_app_version_info.latest_stable,
- latest_beta: last_app_version_info.latest_beta,
- suggested_upgrade,
- }).await;
- }
- }
-
- Some(VersionUpdaterCommand::GetVersionInfo(done_tx)) => {
+ Some(()) => {
match (self.version_is_stale(), self.last_app_version_info()) {
- (false, Some(version_info)) => {
+ (false, Some(version_cache)) => {
// if the version_info isn't stale, return it immediately.
- let _ = done_tx.send(version_info.clone());
+ if let Err(err) = update(version_cache.clone()).await {
+ log::error!("Failed to save version cache to disk: {}", err);
+ }
}
_ => {
// otherwise, start a foreground query to get the latest version_info.
if !self.is_running_version_check() {
- version_check = do_version_check().fuse();
+ version_check = do_version_check(self.get_min_metadata_version()).fuse();
}
- self.get_version_info_responders.retain(|r| !r.is_canceled());
- self.get_version_info_responders.push(done_tx);
+
}
}
}
@@ -356,26 +267,17 @@ impl VersionUpdaterInner {
if self.is_running_version_check() {
continue;
}
- version_check = do_version_check_in_background().fuse();
+ version_check = do_version_check_in_background(self.get_min_metadata_version()).fuse();
},
response = version_check => {
match response {
- Ok(version_info_response) => {
- let new_version_info =
- self.response_to_version_info(version_info_response);
-
- // Respond to all pending GetVersionInfo commands
- for done_tx in self.get_version_info_responders.drain(..) {
- let _ = done_tx.send(new_version_info.clone());
- }
-
- self.update_version_info(&update, new_version_info).await;
+ Ok(version_info) => {
+ self.update_version_info(&update, version_info).await;
}
Err(err) => {
log::error!("Failed to fetch version info: {err:#}");
- self.get_version_info_responders.clear();
}
}
@@ -388,7 +290,7 @@ impl VersionUpdaterInner {
struct UpdateContext {
cache_path: PathBuf,
- update_sender: DaemonEventSender<AppVersionInfo>,
+ update_sender: mpsc::UnboundedSender<VersionCache>,
}
impl UpdateContext {
@@ -396,15 +298,14 @@ impl UpdateContext {
/// ([VERSION_INFO_FILENAME]). Also, notify `self.update_sender`
fn update(
&self,
- last_app_version: AppVersionInfo,
+ last_app_version: VersionCache,
) -> impl Future<Output = Result<(), Error>> + use<> {
let _ = self.update_sender.send(last_app_version.clone());
let cache_path = self.cache_path.clone();
async move {
log::debug!("Writing version check cache to {}", cache_path.display());
- let cached_app_version = CachedAppVersionInfo::from(last_app_version);
- let buf = serde_json::to_vec_pretty(&cached_app_version).map_err(Error::Serialize)?;
+ let buf = serde_json::to_vec_pretty(&last_app_version).map_err(Error::Serialize)?;
tokio::fs::write(cache_path, buf)
.await
.map_err(Error::WriteVersionCache)
@@ -420,24 +321,18 @@ struct ApiContext {
}
/// Immediately query the API for the latest [AppVersionInfo].
-fn do_version_check(api: ApiContext) -> BoxFuture<'static, Result<AppVersionResponse, Error>> {
- let download_future_factory = move || {
- api.version_proxy
- .version_check(
- mullvad_version::VERSION.to_owned(),
- PLATFORM,
- api.platform_version.clone(),
- )
- .map_err(Error::Download)
- };
+fn do_version_check(
+ api: ApiContext,
+ min_metadata_version: usize,
+) -> BoxFuture<'static, Result<VersionCache, Error>> {
+ let api_handle = api.api_handle.clone();
+
+ let download_future_factory = move || version_check_inner(&api, min_metadata_version);
// retry immediately on network errors (unless we're offline)
let should_retry_immediate = move |result: &Result<_, Error>| {
- if let Err(Error::Download(error)) = result {
- error.is_network_error() && !api.api_handle.is_offline()
- } else {
- false
- }
+ !api_handle.is_offline()
+ && matches!(result, Err(Error::Download(error)) if error.is_network_error())
};
Box::pin(retry_future(
@@ -455,44 +350,141 @@ fn do_version_check(api: ApiContext) -> BoxFuture<'static, Result<AppVersionResp
/// On any error, this function retries repeatedly every [UPDATE_INTERVAL_ERROR] until success.
fn do_version_check_in_background(
api: ApiContext,
-) -> BoxFuture<'static, Result<AppVersionResponse, Error>> {
+ min_metadata_version: usize,
+) -> BoxFuture<'static, Result<VersionCache, Error>> {
let download_future_factory = move || {
let when_available = api.api_handle.wait_background();
- let request = api.version_proxy.version_check(
- mullvad_version::VERSION.to_owned(),
- PLATFORM,
- api.platform_version.clone(),
- );
+ let version_cache = version_check_inner(&api, min_metadata_version);
async move {
when_available.await.map_err(Error::ApiCheck)?;
- request.await.map_err(Error::Download)
+ version_cache.await
}
};
Box::pin(retry_future(
download_future_factory,
|result| result.is_err(),
- std::iter::repeat(UPDATE_INTERVAL_ERROR),
+ ConstantInterval::new(UPDATE_INTERVAL_ERROR, None),
))
}
+/// Combine the old version and new version endpoint
+#[cfg(in_app_upgrade)]
+fn version_check_inner(
+ api: &ApiContext,
+ min_metadata_version: usize,
+) -> impl Future<Output = Result<VersionCache, Error>> {
+ use mullvad_api::version::{AppVersionResponse, AppVersionResponse2};
+
+ let v1_endpoint = api.version_proxy.version_check(
+ mullvad_version::VERSION.to_owned(),
+ PLATFORM,
+ api.platform_version.clone(),
+ );
+
+ let architecture = match talpid_platform_metadata::get_native_arch()
+ .expect("IO error while getting native architecture")
+ .expect("Failed to get native architecture")
+ {
+ talpid_platform_metadata::Architecture::X86 => mullvad_update::format::Architecture::X86,
+ talpid_platform_metadata::Architecture::Arm64 => {
+ mullvad_update::format::Architecture::Arm64
+ }
+ };
+ let v2_endpoint = api.version_proxy.version_check_2(
+ PLATFORM,
+ architecture,
+ mullvad_update::version::IGNORE,
+ min_metadata_version,
+ );
+ async move {
+ let (
+ AppVersionResponse {
+ supported: current_version_supported,
+ ..
+ },
+ AppVersionResponse2 {
+ version_info,
+ metadata_version,
+ },
+ ) = tokio::try_join!(v1_endpoint, v2_endpoint).map_err(Error::Download)?;
+
+ Ok(VersionCache {
+ current_version_supported,
+ version_info,
+ metadata_version,
+ })
+ }
+}
+
+#[cfg(not(in_app_upgrade))]
+fn version_check_inner(
+ api: &ApiContext,
+ // NOTE: This is unused when `update` is disabled
+ _min_metadata_version: usize,
+) -> impl Future<Output = Result<VersionCache, Error>> {
+ let v1_endpoint = api.version_proxy.version_check(
+ mullvad_version::VERSION.to_owned(),
+ PLATFORM,
+ api.platform_version.clone(),
+ );
+ async move {
+ let response = v1_endpoint.await.map_err(Error::Download)?;
+ let latest_stable = response.latest_stable
+ .and_then(|version| version.parse().ok())
+ // Suggested stable must actually be stable
+ .filter(|version: &mullvad_version::Version| version.pre_stable.is_none())
+ .ok_or_else(|| Error::MissingStable)?;
+ let latest_beta = response.latest_beta
+ .and_then(|version| version.parse().ok())
+ // Suggested beta must actually be non-stable
+ .filter(|version: &mullvad_version::Version| version.pre_stable.is_some());
+
+ Ok(VersionCache {
+ current_version_supported: response.supported,
+ // Note: We're pretending that this is complete information,
+ // but on Android and Linux, most of the information is missing
+ version_info: VersionInfo {
+ stable: mullvad_update::version::Version {
+ version: latest_stable,
+ changelog: "".to_owned(),
+ urls: vec![],
+ sha256: [0u8; 32],
+ size: 0,
+ },
+ beta: latest_beta.map(|version| mullvad_update::version::Version {
+ version,
+ changelog: "".to_owned(),
+ urls: vec![],
+ sha256: [0u8; 32],
+ size: 0,
+ }),
+ },
+ })
+ }
+}
+
/// Read the app version cache from the provided directory.
///
/// Returns the [AppVersionInfo] along with the modification time of the cache file,
/// or `None` on any error.
-async fn load_cache(cache_dir: &Path) -> Option<(AppVersionInfo, SystemTime)> {
+async fn load_cache(cache_dir: &Path) -> Option<(VersionCache, SystemTime)> {
try_load_cache(cache_dir)
.await
.inspect_err(|error| {
- log::warn!(
- "{}",
- error.display_chain_with_msg("Unable to load cached version info")
- )
+ if matches!(error, Error::OutdatedVersion) {
+ log::trace!("Ignoring outdated version cache");
+ } else {
+ log::warn!(
+ "{}",
+ error.display_chain_with_msg("Unable to load cached version info")
+ );
+ }
})
.ok()
}
-async fn try_load_cache(cache_dir: &Path) -> Result<(AppVersionInfo, SystemTime), Error> {
+async fn try_load_cache(cache_dir: &Path) -> Result<(VersionCache, SystemTime), Error> {
if *IS_DEV_BUILD {
return Ok((dev_version_cache(), SystemTime::now()));
}
@@ -511,61 +503,49 @@ async fn try_load_cache(cache_dir: &Path) -> Result<(AppVersionInfo, SystemTime)
.map_err(Error::ReadVersionCache)
.await?;
- let version_info: CachedAppVersionInfo =
- serde_json::from_str(&content).map_err(Error::Deserialize)?;
+ let cache: VersionCache = serde_json::from_str(&content).map_err(Error::Deserialize)?;
- if version_info.cached_from_version == mullvad_version::VERSION {
- Ok((version_info.version_info, mtime))
- } else {
- Err(Error::CacheVersionMismatch)
+ if cache_is_old(&cache.version_info, &APP_VERSION) {
+ return Err(Error::OutdatedVersion);
}
-}
-
-fn dev_version_cache() -> AppVersionInfo {
- assert!(*IS_DEV_BUILD);
- AppVersionInfo {
- supported: false,
- latest_stable: mullvad_version::VERSION.to_owned(),
- latest_beta: mullvad_version::VERSION.to_owned(),
- suggested_upgrade: None,
- }
+ Ok((cache, mtime))
}
-/// If current_version is not the latest, return a string containing the latest version.
-fn suggested_upgrade(
- current_version: &Version,
- latest_stable: &Option<String>,
- latest_beta: &str,
- show_beta: bool,
-) -> Option<String> {
- let stable_version = latest_stable
- .as_ref()
- .and_then(|stable| Version::from_str(stable).ok());
-
- let beta_version = if show_beta {
- Version::from_str(latest_beta).ok()
+/// Check if the cached version is older than the current version. If so, assume the cache is stale.
+/// It could in principle mean that a version has been yanked, but we do not really support this,
+/// and it should not cause any real issue to delete the cache anyway.
+fn cache_is_old(cached_version: &VersionInfo, current_version: &mullvad_version::Version) -> bool {
+ let last_version = if current_version.pre_stable.is_some() {
+ // Discard suggested version if current beta is newer
+ cached_version
+ .beta
+ .as_ref()
+ .unwrap_or(&cached_version.stable)
} else {
- None
+ // Discard suggested version if current stable is newer
+ &cached_version.stable
};
+ current_version > &last_version.version
+}
- let latest_version = match (&stable_version, &beta_version) {
- (Some(_), None) => stable_version,
- (None, Some(_)) => beta_version,
- (Some(stable), Some(beta)) => {
- if beta > stable {
- beta_version
- } else {
- stable_version
- }
- }
- (None, None) => None,
- }?;
+fn dev_version_cache() -> VersionCache {
+ assert!(*IS_DEV_BUILD);
- if &latest_version > current_version {
- Some(latest_version.to_string())
- } else {
- None
+ VersionCache {
+ current_version_supported: false,
+ version_info: VersionInfo {
+ stable: mullvad_update::version::Version {
+ version: mullvad_version::VERSION.parse().unwrap(),
+ changelog: "".to_owned(),
+ urls: vec![],
+ sha256: [0u8; 32],
+ size: 0,
+ },
+ beta: None,
+ },
+ #[cfg(in_app_upgrade)]
+ metadata_version: 0,
}
}
@@ -576,8 +556,60 @@ mod test {
Arc,
};
+ use futures::SinkExt;
+ use mullvad_update::version::Version;
+
use super::*;
+ /// Test whether outdated version caches are ignored correctly.
+ /// This prevents old versions from being suggested as updates.
+ #[test]
+ fn test_old_cache() {
+ assert!(cache_is_old(
+ &version_info("2025.5", None),
+ &"2025.6".parse().unwrap()
+ ));
+ assert!(!cache_is_old(
+ &version_info("2025.5", None),
+ &"2025.5".parse().unwrap()
+ ));
+ assert!(!cache_is_old(
+ &version_info("2025.5", Some("2025.5-beta1")),
+ &"2025.5-beta1".parse().unwrap()
+ ));
+ assert!(cache_is_old(
+ &version_info("2025.5", Some("2025.5-beta1")),
+ &"2025.5-beta2".parse().unwrap()
+ ));
+ assert!(!cache_is_old(
+ &version_info("2025.5", None),
+ &"2025.5-beta2".parse().unwrap()
+ ));
+ assert!(cache_is_old(
+ &version_info("2025.5", None),
+ &"2025.6-beta2".parse().unwrap()
+ ));
+ }
+
+ fn version_info(stable: &str, beta: Option<&str>) -> VersionInfo {
+ VersionInfo {
+ stable: Version {
+ version: stable.parse().unwrap(),
+ urls: vec![],
+ size: 0,
+ changelog: "".to_owned(),
+ sha256: [0u8; 32],
+ },
+ beta: beta.map(|beta| Version {
+ version: beta.parse().unwrap(),
+ urls: vec![],
+ size: 0,
+ changelog: "".to_owned(),
+ sha256: [0u8; 32],
+ }),
+ }
+ }
+
/// If there's no cached version, it should count as stale
#[test]
fn test_version_unknown_is_stale() {
@@ -630,7 +662,7 @@ mod test {
let updated = Arc::new(AtomicBool::new(false));
let update = fake_updater(updated.clone());
- let (_tx, rx) = mpsc::channel(1);
+ let (_tx, rx) = mpsc::unbounded();
tokio::spawn(checker.run_inner(rx, update, fake_version_check, fake_version_check));
talpid_time::sleep(Duration::from_secs(10)).await;
@@ -648,7 +680,7 @@ mod test {
let updated = Arc::new(AtomicBool::new(false));
let update = fake_updater(updated.clone());
- let (_tx, rx) = mpsc::channel(1);
+ let (_tx, rx) = mpsc::unbounded();
tokio::spawn(checker.run_inner(rx, update, fake_version_check, fake_version_check));
assert!(!updated.load(Ordering::SeqCst));
@@ -677,7 +709,7 @@ mod test {
let updated = Arc::new(AtomicBool::new(false));
let update = fake_updater(updated.clone());
- let (mut tx, rx) = mpsc::channel(1);
+ let (mut tx, rx) = mpsc::unbounded();
tokio::spawn(checker.run_inner(rx, update, fake_version_check, fake_version_check_err));
// Fail automatic update
@@ -694,34 +726,37 @@ mod test {
updated.store(false, Ordering::SeqCst);
- // The next request should do nothing
+ // The next request should trigger an update, even if the version has not changed
send_version_request(&mut tx).await.unwrap();
talpid_time::sleep(Duration::from_secs(1)).await;
- assert!(!updated.load(Ordering::SeqCst), "expected cached version");
+ assert!(updated.load(Ordering::SeqCst), "expected cached version");
}
async fn send_version_request(
- tx: &mut mpsc::Sender<VersionUpdaterCommand>,
+ tx: &mut mpsc::UnboundedSender<()>,
) -> Result<(), futures::channel::mpsc::SendError> {
- let (done_tx, _done_rx) = oneshot::channel();
- tx.send(VersionUpdaterCommand::GetVersionInfo(done_tx))
- .await
+ tx.send(()).await?;
+ Ok(())
}
fn fake_updater(
updated: Arc<AtomicBool>,
- ) -> impl Fn(AppVersionInfo) -> BoxFuture<'static, Result<(), Error>> {
+ ) -> impl Fn(VersionCache) -> BoxFuture<'static, Result<(), Error>> {
move |_new_version| {
updated.store(true, Ordering::SeqCst);
Box::pin(async { Ok(()) })
}
}
- fn fake_version_check() -> BoxFuture<'static, Result<AppVersionResponse, Error>> {
+ fn fake_version_check(
+ _min_metadata_version: usize,
+ ) -> BoxFuture<'static, Result<VersionCache, Error>> {
Box::pin(async { Ok(fake_version_response()) })
}
- fn fake_version_check_err() -> BoxFuture<'static, Result<AppVersionResponse, Error>> {
+ fn fake_version_check_err(
+ _min_metadata_version: usize,
+ ) -> BoxFuture<'static, Result<VersionCache, Error>> {
Box::pin(retry_future(
|| async { Err(Error::Download(mullvad_api::rest::Error::TimeoutError)) },
|_| true,
@@ -729,105 +764,22 @@ mod test {
))
}
- fn fake_version_response() -> AppVersionResponse {
- AppVersionResponse {
- supported: true,
- latest: "2024.1".to_owned(),
- latest_stable: None,
- latest_beta: "2024.1-beta1".to_owned(),
+ fn fake_version_response() -> VersionCache {
+ // TODO: The tests pass, but check that this is a sane fake version cache anyway
+ VersionCache {
+ current_version_supported: true,
+ version_info: VersionInfo {
+ stable: Version {
+ version: "2025.5".parse::<mullvad_version::Version>().unwrap(),
+ urls: vec![],
+ size: 0,
+ changelog: "".to_owned(),
+ sha256: [0u8; 32],
+ },
+ beta: None,
+ },
+ #[cfg(in_app_upgrade)]
+ metadata_version: 0,
}
}
-
- #[test]
- fn test_version_upgrade_suggestions() {
- let latest_stable = Some("2020.4".to_string());
- let latest_beta = "2020.5-beta3";
-
- let older_stable = Version::from_str("2020.3").unwrap();
- let current_stable = Version::from_str("2020.4").unwrap();
- let newer_stable = Version::from_str("2021.5").unwrap();
-
- let older_beta = Version::from_str("2020.3-beta3").unwrap();
- let current_beta = Version::from_str("2020.5-beta3").unwrap();
- let newer_beta = Version::from_str("2021.5-beta3").unwrap();
-
- let older_alpha = Version::from_str("2020.3-alpha3").unwrap();
- let current_alpha = Version::from_str("2020.5-alpha3").unwrap();
- let newer_alpha = Version::from_str("2021.5-alpha3").unwrap();
-
- assert_eq!(
- suggested_upgrade(&older_stable, &latest_stable, latest_beta, false),
- Some("2020.4".to_owned())
- );
- assert_eq!(
- suggested_upgrade(&older_stable, &latest_stable, latest_beta, true),
- Some("2020.5-beta3".to_owned())
- );
- assert_eq!(
- suggested_upgrade(&current_stable, &latest_stable, latest_beta, false),
- None
- );
- assert_eq!(
- suggested_upgrade(&current_stable, &latest_stable, latest_beta, true),
- Some("2020.5-beta3".to_owned())
- );
- assert_eq!(
- suggested_upgrade(&newer_stable, &latest_stable, latest_beta, false),
- None
- );
- assert_eq!(
- suggested_upgrade(&newer_stable, &latest_stable, latest_beta, true),
- None
- );
-
- assert_eq!(
- suggested_upgrade(&older_beta, &latest_stable, latest_beta, false),
- Some("2020.4".to_owned())
- );
- assert_eq!(
- suggested_upgrade(&older_beta, &latest_stable, latest_beta, true),
- Some("2020.5-beta3".to_owned())
- );
- assert_eq!(
- suggested_upgrade(&current_beta, &latest_stable, latest_beta, false),
- None
- );
- assert_eq!(
- suggested_upgrade(&current_beta, &latest_stable, latest_beta, true),
- None
- );
- assert_eq!(
- suggested_upgrade(&newer_beta, &latest_stable, latest_beta, false),
- None
- );
- assert_eq!(
- suggested_upgrade(&newer_beta, &latest_stable, latest_beta, true),
- None
- );
-
- assert_eq!(
- suggested_upgrade(&older_alpha, &latest_stable, latest_beta, false),
- Some("2020.4".to_owned())
- );
- assert_eq!(
- suggested_upgrade(&older_alpha, &latest_stable, latest_beta, true),
- Some("2020.5-beta3".to_owned())
- );
- assert_eq!(
- suggested_upgrade(&current_alpha, &latest_stable, latest_beta, false),
- None,
- );
- assert_eq!(
- suggested_upgrade(&current_alpha, &latest_stable, latest_beta, true),
- Some("2020.5-beta3".to_owned())
- );
- assert_eq!(
- suggested_upgrade(&newer_alpha, &latest_stable, latest_beta, false),
- None
- );
- assert_eq!(
- suggested_upgrade(&newer_alpha, &latest_stable, latest_beta, true),
- None
- );
- }
}
diff --git a/mullvad-daemon/src/version/downloader.rs b/mullvad-daemon/src/version/downloader.rs
new file mode 100644
index 0000000000..58bef56af5
--- /dev/null
+++ b/mullvad-daemon/src/version/downloader.rs
@@ -0,0 +1,241 @@
+#![cfg(in_app_upgrade)]
+
+use mullvad_types::version::{AppUpgradeDownloadProgress, AppUpgradeError, AppUpgradeEvent};
+use mullvad_update::app::{bin_path, AppDownloader, AppDownloaderParameters, DownloadError};
+use rand::seq::SliceRandom;
+use std::io;
+use std::path::PathBuf;
+use std::time::{Duration, Instant};
+use talpid_types::ErrorExt;
+use tokio::fs;
+use tokio::sync::broadcast;
+
+#[derive(thiserror::Error, Debug)]
+pub enum Error {
+ #[error("Failed to get download directory")]
+ GetDownloadDir(#[from] mullvad_paths::Error),
+
+ #[error("Failed to create download directory")]
+ CreateDownloadDir(#[source] std::io::Error),
+
+ #[error("Failed to clean up download directory")]
+ RemoveDownloadDir(#[source] std::io::Error),
+
+ #[error("Failed to download app")]
+ Download(#[from] DownloadError),
+
+ #[error("Download was cancelled or panicked")]
+ JoinError(#[from] tokio::task::JoinError),
+
+ #[error("Could not select URL for app update")]
+ NoUrlFound,
+}
+
+pub type Result<T> = std::result::Result<T, Error>;
+
+#[derive(Debug)]
+pub struct DownloaderHandle {
+ /// Handle to the downloader task
+ task: tokio::task::JoinHandle<std::result::Result<PathBuf, Error>>,
+ /// Handle to send `AppUpgradeEvent::Aborted` when the downloader is dropped
+ dropped_tx: Option<broadcast::Sender<AppUpgradeEvent>>,
+}
+
+impl Drop for DownloaderHandle {
+ fn drop(&mut self) {
+ self.task.abort();
+ if let Some(dropped_tx) = self.dropped_tx.take() {
+ // If the downloader is dropped, send an event to notify that it was aborted
+ let _ = dropped_tx.send(AppUpgradeEvent::Aborted);
+ }
+ }
+}
+
+impl std::future::Future for DownloaderHandle {
+ type Output = Result<PathBuf>;
+
+ fn poll(
+ mut self: std::pin::Pin<&mut Self>,
+ cx: &mut std::task::Context<'_>,
+ ) -> std::task::Poll<Self::Output> {
+ let task = std::pin::Pin::new(&mut self.task);
+ let ready = futures::ready!(task.poll(cx))?;
+ self.dropped_tx = None; // Prevent sending the aborted event after successful download
+ std::task::Poll::Ready(ready)
+ }
+}
+
+pub fn spawn_downloader<D>(
+ version: mullvad_update::version::Version,
+ event_tx: broadcast::Sender<AppUpgradeEvent>,
+) -> DownloaderHandle
+where
+ D: AppDownloader + Send + 'static,
+ D: From<AppDownloaderParameters<ProgressUpdater>>,
+{
+ DownloaderHandle {
+ task: tokio::spawn(start::<D>(version, event_tx.clone())),
+ dropped_tx: Some(event_tx),
+ }
+}
+
+/// Begin or resume download of `version`
+async fn start<D>(
+ version: mullvad_update::version::Version,
+ event_tx: broadcast::Sender<AppUpgradeEvent>,
+) -> Result<PathBuf>
+where
+ D: AppDownloader + Send + 'static,
+ D: From<AppDownloaderParameters<ProgressUpdater>>,
+{
+ let url = select_cdn_url(&version.urls)
+ .ok_or(Error::NoUrlFound)?
+ .to_owned();
+
+ log::info!("Downloading app version '{}' from {url}", version.version);
+
+ let download_dir = if cfg!(test) {
+ PathBuf::new()
+ } else {
+ create_download_dir().await.inspect_err(|err| {
+ log::error!("Failed to get download directory: {}", err.display_chain());
+ let _ = event_tx.send(AppUpgradeEvent::Error(AppUpgradeError::GeneralError));
+ })?
+ };
+ let bin_path = bin_path(&version.version, &download_dir);
+
+ let params = AppDownloaderParameters {
+ app_version: version.version,
+ app_url: url.clone(),
+ app_size: version.size,
+ app_progress: ProgressUpdater::new(server_from_url(&url), event_tx.clone()),
+ app_sha256: version.sha256,
+ cache_dir: download_dir,
+ };
+ let mut downloader = D::from(params);
+
+ let _ = event_tx.send(AppUpgradeEvent::DownloadStarting);
+ if let Err(err) = downloader.download_executable().await {
+ let _ = event_tx.send(AppUpgradeEvent::Error(AppUpgradeError::DownloadFailed));
+ return Err(err.into());
+ };
+ let _ = event_tx.send(AppUpgradeEvent::VerifyingInstaller);
+ if let Err(err) = downloader.verify().await {
+ let _ = event_tx.send(AppUpgradeEvent::Error(AppUpgradeError::VerificationFailed));
+ return Err(err.into());
+ };
+ let _ = event_tx.send(AppUpgradeEvent::VerifiedInstaller);
+
+ // Note that we cannot call `downloader.install()` here, as it must be done by the user process.
+ // Instead, the GUI is responsible for launching the installer.
+
+ Ok(bin_path)
+}
+
+async fn create_download_dir() -> Result<PathBuf> {
+ let download_dir = mullvad_paths::cache_dir()?.join("mullvad-update");
+ log::trace!("Download directory: {download_dir:?}");
+ fs::create_dir_all(&download_dir)
+ .await
+ .map_err(Error::CreateDownloadDir)?;
+ Ok(download_dir)
+}
+
+/// Remove the download directory
+pub async fn clear_download_dir() -> Result<PathBuf> {
+ let download_dir = mullvad_paths::get_cache_dir()?.join("mullvad-update");
+ log::info!("Cleaning up download directory: {}", download_dir.display());
+ match fs::remove_dir_all(&download_dir).await {
+ Ok(()) => Ok(download_dir),
+ Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(download_dir),
+ Err(err) => Err(Error::CreateDownloadDir(err)),
+ }
+}
+
+pub struct ProgressUpdater {
+ server: String,
+ event_tx: broadcast::Sender<AppUpgradeEvent>,
+ complete_frac: f32,
+ start_time: Instant,
+ complete_frac_at_start: Option<f32>,
+}
+
+impl ProgressUpdater {
+ fn new(server: String, event_tx: broadcast::Sender<AppUpgradeEvent>) -> Self {
+ Self {
+ server,
+ event_tx,
+ complete_frac: 0.,
+ start_time: Instant::now(),
+ complete_frac_at_start: None,
+ }
+ }
+}
+
+impl mullvad_update::fetch::ProgressUpdater for ProgressUpdater {
+ fn set_url(&mut self, _url: &str) {
+ // ignored since we already know the URL
+ }
+
+ fn set_progress(&mut self, fraction_complete: f32) {
+ if (self.complete_frac - fraction_complete).abs() < 0.01 {
+ return;
+ }
+ let complete_frac_at_start = self.complete_frac_at_start.get_or_insert(fraction_complete);
+
+ self.complete_frac = fraction_complete;
+
+ let _ = self.event_tx.send(AppUpgradeEvent::DownloadProgress(
+ AppUpgradeDownloadProgress {
+ server: self.server.clone(),
+ progress: (fraction_complete * 100.0) as u32,
+ time_left: estimate_time_left(
+ self.start_time,
+ fraction_complete,
+ *complete_frac_at_start,
+ ),
+ },
+ ));
+ }
+
+ fn clear_progress(&mut self) {
+ self.complete_frac = 0.;
+
+ let _ = self.event_tx.send(AppUpgradeEvent::DownloadProgress(
+ AppUpgradeDownloadProgress {
+ server: self.server.clone(),
+ progress: 0,
+ time_left: None,
+ },
+ ));
+ }
+}
+
+fn estimate_time_left(
+ start_time: Instant,
+ fraction_complete: f32,
+ complete_frac_at_start: f32,
+) -> Option<Duration> {
+ let completed_frac_since_start = fraction_complete - complete_frac_at_start;
+ // Don't estimate time left if the progress is less than 1%, to avoid division numerical instability
+ if completed_frac_since_start <= 0.01 {
+ return None;
+ }
+ let remaining_frac = 1.0 - fraction_complete;
+
+ let elapsed = start_time.elapsed();
+ Some(elapsed.mul_f32(remaining_frac / completed_frac_since_start))
+}
+
+/// 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)
+}
+
+/// Extract domain name from a URL
+fn server_from_url(url: &str) -> String {
+ let url = url.strip_prefix("https://").unwrap_or(url);
+ let (server, _) = url.split_once('/').unwrap_or((url, ""));
+ server.to_owned()
+}
diff --git a/mullvad-daemon/src/version/mod.rs b/mullvad-daemon/src/version/mod.rs
new file mode 100644
index 0000000000..e83082ae64
--- /dev/null
+++ b/mullvad-daemon/src/version/mod.rs
@@ -0,0 +1,61 @@
+use std::io;
+
+pub mod check;
+pub mod downloader;
+pub mod router;
+
+#[derive(thiserror::Error, Debug)]
+pub enum Error {
+ #[error("Failed to open app version cache file for reading")]
+ ReadVersionCache(#[source] io::Error),
+
+ #[error("Failed to open app version cache file for writing")]
+ WriteVersionCache(#[source] io::Error),
+
+ #[error("Failure in serialization of the version info")]
+ Serialize(#[source] serde_json::Error),
+
+ #[error("Failure in deserialization of the version info")]
+ Deserialize(#[source] serde_json::Error),
+
+ #[error("Failed to check the latest app version")]
+ Download(#[source] mullvad_api::rest::Error),
+
+ #[error("API availability check failed")]
+ ApiCheck(#[source] mullvad_api::availability::Error),
+
+ #[error("Response is missing a valid stable version")]
+ MissingStable,
+
+ #[error("Clearing version check cache due to old version")]
+ OutdatedVersion,
+
+ #[error("Version updater is down")]
+ VersionUpdaterDown,
+
+ #[error("Version router is down")]
+ VersionRouterClosed,
+
+ #[error("Version cache update was aborted")]
+ UpdateAborted,
+}
+
+/// Contains the date of the git commit this was built from
+pub const COMMIT_DATE: &str = include_str!(concat!(env!("OUT_DIR"), "/git-commit-date.txt"));
+
+pub fn is_beta_version() -> bool {
+ mullvad_version::VERSION.contains("beta")
+}
+
+pub fn is_dev_version() -> bool {
+ mullvad_version::VERSION.contains("dev")
+}
+
+pub fn log_version() {
+ log::info!(
+ "Starting {} - {} {}",
+ env!("CARGO_PKG_NAME"),
+ mullvad_version::VERSION,
+ COMMIT_DATE,
+ )
+}
diff --git a/mullvad-daemon/src/version/router.rs b/mullvad-daemon/src/version/router.rs
new file mode 100644
index 0000000000..127f8ca791
--- /dev/null
+++ b/mullvad-daemon/src/version/router.rs
@@ -0,0 +1,1081 @@
+use std::ops::ControlFlow;
+use std::path::PathBuf;
+
+use futures::channel::{mpsc, oneshot};
+use futures::stream::StreamExt;
+use mullvad_api::{availability::ApiAvailability, rest::MullvadRestHandle};
+use mullvad_types::version::{AppVersionInfo, SuggestedUpgrade};
+#[cfg(in_app_upgrade)]
+use mullvad_update::app::{AppDownloader, AppDownloaderParameters, HttpAppDownloader};
+use mullvad_update::version::VersionInfo;
+use talpid_core::mpsc::Sender;
+#[cfg(in_app_upgrade)]
+use talpid_types::ErrorExt;
+
+use crate::management_interface::AppUpgradeBroadcast;
+use crate::DaemonEventSender;
+
+#[cfg(in_app_upgrade)]
+use super::downloader::ProgressUpdater;
+use super::{
+ check::{VersionCache, VersionUpdater},
+ Error,
+};
+
+#[cfg(in_app_upgrade)]
+use super::downloader;
+use std::mem;
+
+pub type Result<T> = std::result::Result<T, Error>;
+
+#[derive(Clone)]
+pub struct VersionRouterHandle {
+ tx: mpsc::UnboundedSender<Message>,
+}
+
+impl VersionRouterHandle {
+ pub async fn set_show_beta_releases(&self, state: bool) -> Result<()> {
+ let (result_tx, result_rx) = oneshot::channel();
+ self.tx
+ .send(Message::SetBetaProgram { state, result_tx })
+ .map_err(|_| Error::VersionRouterClosed)?;
+ result_rx.await.map_err(|_| Error::VersionRouterClosed)
+ }
+
+ pub async fn get_latest_version(&self) -> Result<AppVersionInfo> {
+ let (result_tx, result_rx) = oneshot::channel();
+ self.tx
+ .send(Message::GetLatestVersion(result_tx))
+ .map_err(|_| Error::VersionRouterClosed)?;
+ result_rx.await.map_err(|_| Error::VersionRouterClosed)?
+ }
+
+ #[cfg(in_app_upgrade)]
+ pub async fn update_application(&self) -> Result<()> {
+ let (result_tx, result_rx) = oneshot::channel();
+ self.tx
+ .send(Message::UpdateApplication { result_tx })
+ .map_err(|_| Error::VersionRouterClosed)?;
+ result_rx.await.map_err(|_| Error::VersionRouterClosed)
+ }
+
+ #[cfg(in_app_upgrade)]
+ pub async fn cancel_update(&self) -> Result<()> {
+ let (result_tx, result_rx) = oneshot::channel();
+ self.tx
+ .send(Message::CancelUpdate { result_tx })
+ .map_err(|_| Error::VersionRouterClosed)?;
+ result_rx.await.map_err(|_| Error::VersionRouterClosed)
+ }
+}
+
+// These wrapper traits and type aliases exist to help feature gate the module
+#[cfg(in_app_upgrade)]
+trait Downloader:
+ AppDownloader + Send + 'static + From<AppDownloaderParameters<ProgressUpdater>>
+{
+}
+#[cfg(not(in_app_upgrade))]
+trait Downloader {}
+
+#[cfg(in_app_upgrade)]
+type DefaultDownloader = HttpAppDownloader<ProgressUpdater>;
+#[cfg(not(in_app_upgrade))]
+type DefaultDownloader = ();
+
+impl Downloader for DefaultDownloader {}
+
+/// Router of version updates and update requests.
+///
+/// New available app version events are forwarded from the [`VersionUpdater`].
+/// If an update is in progress, these events are paused until the update is completed or canceled.
+/// This is done to prevent frontends from confusing which version is currently being installed,
+/// in case new version info is received while the update is in progress.
+struct VersionRouter<S = DaemonEventSender<AppVersionInfo>, D = DefaultDownloader> {
+ daemon_rx: mpsc::UnboundedReceiver<Message>,
+ state: State,
+ beta_program: bool,
+ version_event_sender: S,
+ /// Channel used to trigger a version check. The result will always be sent to the
+ /// `new_version_rx` channel.
+ refresh_version_check_tx: mpsc::UnboundedSender<()>,
+ /// Channel used to receive updates from `version_check`
+ new_version_rx: mpsc::UnboundedReceiver<VersionCache>,
+ /// Channels that receive responses to `get_latest_version`
+ version_request_channels: Vec<oneshot::Sender<Result<AppVersionInfo>>>,
+ /// Broadcast channel for app upgrade events
+ #[cfg(in_app_upgrade)]
+ app_upgrade_broadcast: AppUpgradeBroadcast,
+ /// Type used to spawn the downloader task, replaced when testing
+ _phantom: std::marker::PhantomData<D>,
+}
+
+enum Message {
+ /// Enable or disable beta program
+ SetBetaProgram {
+ state: bool,
+ result_tx: oneshot::Sender<()>,
+ },
+ /// Check for updates
+ GetLatestVersion(oneshot::Sender<Result<AppVersionInfo>>),
+ /// Update the application
+ #[cfg(in_app_upgrade)]
+ UpdateApplication { result_tx: oneshot::Sender<()> },
+ /// Cancel the ongoing update
+ #[cfg(in_app_upgrade)]
+ CancelUpdate { result_tx: oneshot::Sender<()> },
+}
+
+#[derive(Debug)]
+enum State {
+ /// There is no version available yet
+ NoVersion,
+ /// Running version checker, no upgrade in progress
+ HasVersion { version_cache: VersionCache },
+ /// Download is in progress, so we don't forward version checks
+ #[cfg(in_app_upgrade)]
+ Downloading {
+ /// Version info received from `HasVersion`
+ version_cache: VersionCache,
+ /// The version being upgraded to, derived from `version_info` and beta program state
+ upgrading_to_version: mullvad_update::version::Version,
+ /// Tokio task for the downloader handle
+ downloader_handle: downloader::DownloaderHandle,
+ },
+ /// Download is complete. We have a verified binary
+ #[cfg(in_app_upgrade)]
+ Downloaded {
+ /// Version info received from `HasVersion`
+ version_cache: VersionCache,
+ /// Path to verified installer
+ verified_installer_path: PathBuf,
+ },
+}
+
+struct AppVersionInfoEvent {
+ app_version_info: AppVersionInfo,
+ is_new: bool,
+}
+
+impl std::fmt::Display for State {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ match self {
+ State::NoVersion => write!(f, "NoVersion"),
+ State::HasVersion { .. } => write!(f, "HasVersion"),
+ #[cfg(in_app_upgrade)]
+ State::Downloading {
+ upgrading_to_version,
+ ..
+ } => write!(f, "Downloading '{}'", upgrading_to_version.version),
+ #[cfg(in_app_upgrade)]
+ State::Downloaded {
+ verified_installer_path,
+ ..
+ } => write!(f, "Downloaded '{}'", verified_installer_path.display()),
+ }
+ }
+}
+
+impl State {
+ fn get_version_cache(&self) -> Option<&VersionCache> {
+ match self {
+ State::NoVersion => None,
+ State::HasVersion { version_cache, .. } => Some(version_cache),
+ #[cfg(in_app_upgrade)]
+ State::Downloading { version_cache, .. } | State::Downloaded { version_cache, .. } => {
+ Some(version_cache)
+ }
+ }
+ }
+}
+
+#[cfg_attr(not(in_app_upgrade), allow(unused_variables))]
+pub(crate) fn spawn_version_router(
+ api_handle: MullvadRestHandle,
+ availability_handle: ApiAvailability,
+ cache_dir: PathBuf,
+ version_event_sender: DaemonEventSender<AppVersionInfo>,
+ beta_program: bool,
+ app_upgrade_broadcast: AppUpgradeBroadcast,
+) -> VersionRouterHandle {
+ let (tx, rx) = mpsc::unbounded();
+
+ tokio::spawn(async move {
+ let (new_version_tx, new_version_rx) = mpsc::unbounded();
+ let (refresh_version_check_tx, refresh_version_check_rx) = mpsc::unbounded();
+
+ #[cfg(in_app_upgrade)]
+ let _ = downloader::clear_download_dir().await.inspect_err(|err| {
+ log::error!(
+ "{}",
+ err.display_chain_with_msg("Failed to clean up download directory")
+ )
+ });
+
+ VersionUpdater::spawn(
+ api_handle,
+ availability_handle,
+ cache_dir,
+ new_version_tx,
+ refresh_version_check_rx,
+ )
+ .await;
+
+ VersionRouter {
+ daemon_rx: rx,
+ state: State::NoVersion,
+ beta_program,
+ version_event_sender,
+ new_version_rx,
+ version_request_channels: vec![],
+ #[cfg(in_app_upgrade)]
+ app_upgrade_broadcast,
+ refresh_version_check_tx,
+ _phantom: std::marker::PhantomData::<DefaultDownloader>,
+ }
+ .run()
+ .await;
+ });
+ VersionRouterHandle { tx }
+}
+
+impl<S, D> VersionRouter<S, D>
+where
+ S: Sender<AppVersionInfo> + Send + 'static,
+ D: Downloader,
+{
+ async fn run(mut self) {
+ log::debug!("Version router started");
+ // Loop until the router is closed
+ while self.run_step().await.is_continue() {}
+ log::debug!("Version router closed");
+ }
+
+ /// Run a single step of the router, handling messages from the daemon and version events
+ async fn run_step(&mut self) -> ControlFlow<()> {
+ tokio::select! {
+ // Received version event from `check`
+ Some(new_version) = self.new_version_rx.next() => {
+ let AppVersionInfoEvent { app_version_info, is_new } = self.on_new_version(new_version);
+ self.notify_version_requesters(app_version_info.clone());
+ if is_new {
+ // Notify the daemon about new version
+ let _ = self.version_event_sender.send(app_version_info);
+ }
+ }
+ res = wait_for_update(&mut self.state) => {
+ // If the download was successful, we send the new version, which contains the
+ // verified installer path
+ if let Some(app_update_info) = res {
+ let _ = self.version_event_sender.send(app_update_info);
+ }
+ },
+ Some(message) = self.daemon_rx.next() => self.handle_message(message),
+ else => return ControlFlow::Break(()),
+ }
+ ControlFlow::Continue(())
+ }
+
+ /// Handle [Message] sent by user
+ fn handle_message(&mut self, message: Message) {
+ match message {
+ Message::SetBetaProgram { state, result_tx } => {
+ self.set_beta_program(state);
+ // We're happy as soon as the internal state has changed; no need to wait for
+ // version update
+ let _ = result_tx.send(());
+ }
+ Message::GetLatestVersion(result_tx) => {
+ self.get_latest_version(result_tx);
+ }
+ #[cfg(in_app_upgrade)]
+ Message::UpdateApplication { result_tx } => {
+ self.update_application();
+ let _ = result_tx.send(());
+ }
+ #[cfg(in_app_upgrade)]
+ Message::CancelUpdate { result_tx } => {
+ self.cancel_upgrade();
+ let _ = result_tx.send(());
+ }
+ }
+ }
+
+ /// Handle new version info
+ ///
+ /// If the router is in the process of upgrading, it will not propagate versions, but only
+ /// remember it for when it transitions back into the "idle" (version check) state.
+ fn on_new_version(&mut self, version_cache: VersionCache) -> AppVersionInfoEvent {
+ let new_app_version_info = match &mut self.state {
+ State::NoVersion => {
+ // Receive first version
+ let app_version_info = to_app_version_info(&version_cache, self.beta_program, None);
+
+ AppVersionInfoEvent {
+ app_version_info,
+ is_new: true,
+ }
+ }
+ // Already have version info, just update it
+ State::HasVersion {
+ version_cache: prev_cache,
+ } => {
+ let prev_app_version = to_app_version_info(prev_cache, self.beta_program, None);
+ let new_app_version = to_app_version_info(&version_cache, self.beta_program, None);
+
+ AppVersionInfoEvent {
+ is_new: new_app_version != prev_app_version,
+ app_version_info: new_app_version,
+ }
+ }
+ #[cfg(in_app_upgrade)]
+ State::Downloaded { .. } | State::Downloading { .. } => {
+ let app_version_info = to_app_version_info(&version_cache, self.beta_program, None);
+
+ log::warn!("Received new version while upgrading: {app_version_info:?}");
+ AppVersionInfoEvent {
+ app_version_info,
+ is_new: true,
+ }
+ }
+ };
+ self.state = State::HasVersion { version_cache };
+ new_app_version_info
+ }
+
+ fn notify_version_requesters(&mut self, new_app_version_info: AppVersionInfo) {
+ // Notify all requesters
+ for tx in self.version_request_channels.drain(..) {
+ let _ = tx.send(Ok(new_app_version_info.clone()));
+ }
+ }
+
+ fn set_beta_program(&mut self, new_state: bool) {
+ if new_state == self.beta_program {
+ return;
+ }
+ let previous_state = self.beta_program;
+ self.beta_program = new_state;
+ let Some(version_cache) = self.state.get_version_cache() else {
+ return;
+ };
+ let prev_app_version = to_app_version_info(version_cache, previous_state, None);
+ let new_app_version = to_app_version_info(version_cache, new_state, None);
+ if new_app_version == prev_app_version {
+ return;
+ };
+
+ // Always cancel download if the suggested upgrade changes
+ let version_cache = match mem::replace(&mut self.state, State::NoVersion) {
+ #[cfg(in_app_upgrade)]
+ State::Downloaded { version_cache, .. } | State::Downloading { version_cache, .. } => {
+ log::warn!("Switching beta after updating resulted in new suggested upgrade: {:?}, aborting", new_app_version.suggested_upgrade);
+ version_cache
+ }
+ State::HasVersion { version_cache } => version_cache,
+ State::NoVersion => {
+ unreachable!("Can't get recommended upgrade on beta change without version")
+ }
+ };
+
+ self.state = State::HasVersion { version_cache };
+ let _ = self.version_event_sender.send(new_app_version.clone());
+
+ self.notify_version_requesters(new_app_version);
+ }
+
+ fn get_latest_version(
+ &mut self,
+ result_tx: oneshot::Sender<std::result::Result<AppVersionInfo, Error>>,
+ ) {
+ // Start a version request unless already in progress
+ match self
+ .refresh_version_check_tx
+ .unbounded_send(())
+ .map_err(|_e| Error::VersionRouterClosed)
+ {
+ // Append to response channels
+ Ok(()) => self.version_request_channels.push(result_tx),
+ Err(err) => result_tx
+ .send(Err(err))
+ .unwrap_or_else(|e| log::warn!("Failed to send version request result: {e:?}")),
+ }
+ }
+
+ #[cfg(in_app_upgrade)]
+ fn update_application(&mut self) {
+ use crate::version::downloader::spawn_downloader;
+
+ match mem::replace(&mut self.state, State::NoVersion) {
+ State::HasVersion { version_cache } => {
+ let Some(upgrading_to_version) =
+ recommended_version_upgrade(&version_cache.version_info, self.beta_program)
+ else {
+ // If there's no suggested upgrade, do nothing
+ log::debug!("Received update request without suggested upgrade");
+ self.state = State::HasVersion { version_cache };
+ return;
+ };
+ log::info!(
+ "Starting upgrade to version {}",
+ upgrading_to_version.version
+ );
+
+ let downloader_handle = spawn_downloader::<D>(
+ upgrading_to_version.clone(),
+ self.app_upgrade_broadcast.clone(),
+ );
+
+ self.state = State::Downloading {
+ version_cache,
+ upgrading_to_version,
+ downloader_handle,
+ };
+ }
+ state => {
+ log::debug!("Ignoring update request while in state {:?}", state);
+ self.state = state;
+ }
+ }
+ }
+
+ #[cfg(in_app_upgrade)]
+ fn cancel_upgrade(&mut self) {
+ use mullvad_types::version::AppUpgradeEvent;
+
+ match mem::replace(&mut self.state, State::NoVersion) {
+ // If we're upgrading, emit an event if a version was received during the upgrade
+ // Otherwise, just reset upgrade info to last known state
+ State::Downloading { version_cache, .. } => {
+ self.state = State::HasVersion { version_cache };
+ }
+ State::Downloaded { version_cache, .. } => {
+ let app_version_info = to_app_version_info(&version_cache, self.beta_program, None);
+ self.state = State::HasVersion { version_cache };
+
+ // Send "Aborted" here, since there's no "Downloader" to do it for us
+ let _ = self.app_upgrade_broadcast.send(AppUpgradeEvent::Aborted);
+
+ // Notify the daemon and version requesters about new version
+ self.notify_version_requesters(app_version_info.clone());
+ let _ = self.version_event_sender.send(app_version_info);
+ }
+ // No-op unless we're downloading something right now
+ // In the `Downloaded` state, we also do nothing
+ state => self.state = state,
+ };
+
+ debug_assert!(matches!(
+ self.state,
+ State::HasVersion { .. } | State::NoVersion
+ ));
+ }
+}
+
+/// Wait for the update to finish. In case no update is in progress (or the platform does not
+/// support in-app upgrades), then the future will never resolve as to not escape the select statement.
+#[allow(clippy::unused_async, unused_variables)]
+async fn wait_for_update(state: &mut State) -> Option<AppVersionInfo> {
+ #[cfg(in_app_upgrade)]
+ match state {
+ State::Downloading {
+ version_cache,
+ ref mut downloader_handle,
+ upgrading_to_version,
+ ..
+ } => match downloader_handle.await {
+ Ok(verified_installer_path) => {
+ let app_update_info = AppVersionInfo {
+ current_version_supported: version_cache.current_version_supported,
+ suggested_upgrade: Some({
+ SuggestedUpgrade {
+ version: upgrading_to_version.version.clone(),
+ changelog: upgrading_to_version.changelog.clone(),
+ verified_installer_path: Some(verified_installer_path.clone()),
+ }
+ }),
+ };
+ *state = State::Downloaded {
+ version_cache: version_cache.clone(),
+ verified_installer_path,
+ };
+ Some(app_update_info)
+ }
+ Err(err) => {
+ log::warn!("Downloader task ended: {err}");
+ *state = State::HasVersion {
+ version_cache: version_cache.clone(),
+ };
+ None
+ }
+ },
+ _ => {
+ let () = std::future::pending().await;
+ unreachable!()
+ }
+ }
+ #[cfg(not(in_app_upgrade))]
+ {
+ let () = std::future::pending().await;
+ unreachable!()
+ }
+}
+
+/// Extract [`AppVersionInfo`], containing upgrade version and `current_version_supported`
+/// from [VersionCache] and beta program state.
+fn to_app_version_info(
+ cache: &VersionCache,
+ beta_program: bool,
+ verified_installer_path: Option<PathBuf>,
+) -> AppVersionInfo {
+ let current_version_supported = cache.current_version_supported;
+ let suggested_upgrade =
+ recommended_version_upgrade(&cache.version_info, beta_program).map(|version| {
+ SuggestedUpgrade {
+ version: version.version,
+ changelog: version.changelog,
+ verified_installer_path,
+ }
+ });
+ AppVersionInfo {
+ current_version_supported,
+ suggested_upgrade,
+ }
+}
+
+/// Extract upgrade version from [VersionCache] based on `beta_program`
+fn recommended_version_upgrade(
+ version_info: &VersionInfo,
+ beta_program: bool,
+) -> Option<mullvad_update::version::Version> {
+ let version_details = if beta_program {
+ version_info.beta.as_ref().unwrap_or(&version_info.stable)
+ } else {
+ &version_info.stable
+ };
+
+ // Set suggested upgrade if the received version is newer than the current version
+ let current_version = mullvad_version::VERSION.parse().unwrap();
+ if version_details.version > current_version {
+ Some(version_details.to_owned())
+ } else {
+ None
+ }
+}
+
+#[cfg(all(test, in_app_upgrade))]
+mod test {
+ use super::downloader::ProgressUpdater;
+ use futures::channel::mpsc::unbounded;
+ use mullvad_types::version::{AppUpgradeDownloadProgress, AppUpgradeEvent};
+ use mullvad_update::{app::DownloadError, fetch::ProgressUpdater as _};
+ use tokio::sync::broadcast::error::TryRecvError;
+
+ use super::*;
+
+ /// To be able to test events occurring during the download process, we need to
+ /// call `tokio::time::sleep` in the downloader. This will not affect the runtime
+ /// of the tests, as set `start_paused = true`.
+ const DOWNLOAD_DURATION: std::time::Duration = std::time::Duration::from_millis(1000);
+
+ /// Mock downloader that simulates a successful download
+ struct SuccessfulAppDownloader(AppDownloaderParameters<ProgressUpdater>);
+
+ impl AppDownloader for SuccessfulAppDownloader {
+ async fn download_executable(&mut self) -> std::result::Result<(), DownloadError> {
+ tokio::time::sleep(DOWNLOAD_DURATION).await;
+ self.0.app_progress.set_progress(1.0);
+ Ok(())
+ }
+
+ async fn verify(&mut self) -> std::result::Result<(), DownloadError> {
+ Ok(())
+ }
+
+ async fn install(&mut self) -> std::result::Result<(), DownloadError> {
+ Ok(())
+ }
+ }
+
+ impl From<AppDownloaderParameters<ProgressUpdater>> for SuccessfulAppDownloader {
+ fn from(parameters: AppDownloaderParameters<ProgressUpdater>) -> Self {
+ Self(parameters)
+ }
+ }
+
+ impl Downloader for SuccessfulAppDownloader {}
+
+ /// Mock downloader that simulates a failed download
+ struct FailingAppDownloader;
+
+ impl AppDownloader for FailingAppDownloader {
+ async fn download_executable(&mut self) -> std::result::Result<(), DownloadError> {
+ Err(DownloadError::FetchApp(anyhow::anyhow!("Download failed")))
+ }
+
+ async fn verify(&mut self) -> std::result::Result<(), DownloadError> {
+ Ok(())
+ }
+
+ async fn install(&mut self) -> std::result::Result<(), DownloadError> {
+ Ok(())
+ }
+ }
+
+ impl From<AppDownloaderParameters<ProgressUpdater>> for FailingAppDownloader {
+ fn from(_parameters: AppDownloaderParameters<ProgressUpdater>) -> Self {
+ Self
+ }
+ }
+
+ impl Downloader for FailingAppDownloader {}
+
+ /// Mock downloader that simulates a failed verification, but a successful download
+ struct FailingAppVerifier;
+
+ impl AppDownloader for FailingAppVerifier {
+ async fn download_executable(&mut self) -> std::result::Result<(), DownloadError> {
+ Ok(())
+ }
+
+ async fn verify(&mut self) -> std::result::Result<(), DownloadError> {
+ Err(DownloadError::Verification(anyhow::anyhow!(
+ "Verification failed"
+ )))
+ }
+
+ async fn install(&mut self) -> std::result::Result<(), DownloadError> {
+ Ok(())
+ }
+ }
+
+ impl From<AppDownloaderParameters<ProgressUpdater>> for FailingAppVerifier {
+ fn from(_parameters: AppDownloaderParameters<ProgressUpdater>) -> Self {
+ Self
+ }
+ }
+
+ impl Downloader for FailingAppVerifier {}
+
+ /// Channels used to communicate with the version router and receive version events.
+ /// This is used in the tests to simulate the daemon and `VersionUpdater`.
+ struct VersionRouterChannels {
+ daemon_tx: futures::channel::mpsc::UnboundedSender<Message>,
+ new_version_tx: futures::channel::mpsc::UnboundedSender<VersionCache>,
+ refresh_version_check_rx: futures::channel::mpsc::UnboundedReceiver<()>,
+ version_event_receiver: futures::channel::mpsc::UnboundedReceiver<AppVersionInfo>,
+ }
+
+ fn make_version_router<D>() -> (
+ VersionRouter<futures::channel::mpsc::UnboundedSender<AppVersionInfo>, D>,
+ VersionRouterChannels,
+ ) {
+ let (version_event_sender, version_event_receiver) = unbounded();
+ let (daemon_tx, daemon_rx) = unbounded();
+ let (app_upgrade_broadcast, _) = tokio::sync::broadcast::channel(10);
+ let (refresh_version_check_tx, refresh_version_check_rx) = unbounded();
+ let (new_version_tx, new_version_rx) = unbounded();
+ (
+ VersionRouter {
+ daemon_rx,
+ state: State::NoVersion,
+ beta_program: false,
+ version_event_sender,
+ new_version_rx,
+ version_request_channels: vec![],
+ app_upgrade_broadcast,
+ refresh_version_check_tx,
+ _phantom: std::marker::PhantomData::<D>,
+ },
+ VersionRouterChannels {
+ daemon_tx,
+ new_version_tx,
+ refresh_version_check_rx,
+ version_event_receiver,
+ },
+ )
+ }
+
+ /// Create a version cache with a stable version that is newer than the current version
+ fn get_new_stable_version_cache() -> VersionCache {
+ let mut version: mullvad_version::Version = mullvad_version::VERSION.parse().unwrap();
+ version.incremental += 1;
+ VersionCache {
+ current_version_supported: true,
+ version_info: VersionInfo {
+ beta: None,
+ stable: mullvad_update::version::Version {
+ version,
+ urls: vec!["https://example.com".to_string()],
+ size: 123456,
+ changelog: "Changelog".to_string(),
+ sha256: [0; 32],
+ },
+ },
+ metadata_version: 0,
+ }
+ }
+
+ /// Create a version cache with a beta version that is newer than the current version
+ fn get_new_beta_version_cache() -> VersionCache {
+ let stable = mullvad_update::version::Version {
+ version: mullvad_version::VERSION.parse().unwrap(),
+ urls: vec!["https://example.com".to_string()],
+ size: 123456,
+ changelog: "Changelog".to_string(),
+ sha256: [0; 32],
+ };
+ let mut beta = stable.clone();
+ beta.version.pre_stable = Some(mullvad_version::PreStableType::Beta(1));
+ beta.version.incremental += 1;
+ VersionCache {
+ current_version_supported: true,
+ version_info: VersionInfo {
+ beta: Some(beta),
+ stable,
+ },
+ metadata_version: 0,
+ }
+ }
+
+ #[tokio::test(start_paused = true)]
+ async fn test_upgrade_with_no_version() {
+ let (mut version_router, _channels) = make_version_router::<SuccessfulAppDownloader>();
+ let upgrade_events = version_router.app_upgrade_broadcast.subscribe();
+ version_router.update_application();
+ assert!(
+ matches!(version_router.state, State::NoVersion),
+ "State should stay as NoVersion after calling update_application"
+ );
+ assert!(
+ upgrade_events.is_empty(),
+ "No upgrade events should be sent"
+ );
+ }
+
+ #[tokio::test(start_paused = true)]
+ async fn test_new_beta() {
+ let (mut version_router, mut channels) = make_version_router::<SuccessfulAppDownloader>();
+ let version_cache = get_new_beta_version_cache();
+
+ // Test that new beta version is ignored if beta program is off
+ version_router.set_beta_program(false); // This is default value, but set it for clarity
+ assert!(
+ matches!(version_router.state, State::NoVersion),
+ "State should not transition"
+ );
+ version_router.on_new_version(version_cache);
+ assert!(matches!(version_router.state, State::HasVersion { .. }));
+ assert!(
+ channels.version_event_receiver.try_next().is_err(),
+ "No version event should be sent on beta program change"
+ );
+ version_router.update_application();
+ assert!(
+ matches!(version_router.state, State::HasVersion { .. }),
+ "State should not transition to Downloading as the beta version is ignored"
+ );
+
+ // Test that switching to beta program sends version event for the previously received beta
+ // version and allows upgrades.
+ version_router.set_beta_program(true);
+ assert!(
+ channels.version_event_receiver.try_next().is_ok(),
+ "Version event should be sent on beta program change"
+ );
+ version_router.update_application();
+ assert!(
+ matches!(version_router.state, State::Downloading { .. }),
+ "State should transition to Downloading as the beta version is accepted"
+ );
+ }
+
+ /// Test that when the daemon calls `get_latest_version`, it will trigger a version check
+ /// and send the result back to the daemon, both on the response channel and in the
+ /// version event stream.
+ #[tokio::test(start_paused = true)]
+ async fn test_get_latest_version() {
+ let (mut version_router, mut channels) = make_version_router::<SuccessfulAppDownloader>();
+ let version_cache_test = get_new_stable_version_cache();
+
+ // Make a request to the router to get the latest version
+ // Note that we could as well call `version_router.get_latest_version()`,
+ // but this way we test the actual message passing between the router and
+ // the daemon.
+ let (tx, mut get_latest_version_rx) = oneshot::channel();
+ channels
+ .daemon_tx
+ .unbounded_send(Message::GetLatestVersion(tx))
+ .unwrap();
+ version_router.run_step().await;
+
+ // Here, we play the role of `VersionUpdater`.
+ // It should receive a version check request and send a version in response
+ assert!(
+ matches!(channels.refresh_version_check_rx.try_next(), Ok(Some(()))),
+ "Version check should be triggered"
+ );
+ channels
+ .new_version_tx
+ .unbounded_send(version_cache_test.clone())
+ .unwrap();
+
+ // On the next step, the router should receive the version info
+ // and send it to as a response to the oneshot from `GetLatestVersion`
+ // and to the daemon in the `version_event_receiver` channel.
+ version_router.run_step().await;
+ let version_info = get_latest_version_rx
+ .try_recv()
+ .expect("Sender should not be dropped")
+ .expect("Version info should have been sent")
+ .expect("Version request should be successful");
+ match &version_router.state {
+ State::HasVersion { version_cache } => assert_eq!(version_cache, &version_cache_test),
+ other => panic!("State should be HasVersion, was {other:?}"),
+ }
+ assert_eq!(
+ version_info,
+ channels
+ .version_event_receiver
+ .try_next()
+ .expect("Version event sender should not be closed")
+ .expect("Version event should be sent"),
+ "Version event sent to the daemon should be the same as the one sent to the requester"
+ );
+ }
+
+ #[tokio::test(start_paused = true)]
+ async fn test_upgrade() {
+ let (mut version_router, mut channels) = make_version_router::<SuccessfulAppDownloader>();
+ let version_cache_test = get_new_stable_version_cache();
+
+ version_router.on_new_version(version_cache_test.clone());
+ match &version_router.state {
+ State::HasVersion { version_cache } => assert_eq!(version_cache, &version_cache_test),
+ other => panic!("State should be HasVersion, was {other:?}"),
+ }
+
+ // Start upgrading
+ let mut app_upgrade_listener = version_router.app_upgrade_broadcast.subscribe();
+ version_router.update_application();
+ // Check that the state is now downloading
+ match &version_router.state {
+ State::Downloading {
+ version_cache,
+ upgrading_to_version,
+ ..
+ } => {
+ assert_eq!(version_cache, &version_cache_test);
+ assert_eq!(
+ upgrading_to_version.version,
+ version_cache_test.version_info.stable.version
+ );
+ }
+ other => panic!("State should be Downloading, was {other:?}"),
+ }
+
+ version_router.update_application();
+ assert!(
+ matches!(version_router.state, State::Downloading { .. }),
+ "Triggering an update while in the downloading shout be ignored"
+ );
+
+ // Drive the download to completion, and get the verified installer path
+ version_router.run_step().await;
+ let verified_installer_path = match &version_router.state {
+ State::Downloaded {
+ version_cache,
+ verified_installer_path,
+ ..
+ } => {
+ assert_eq!(version_cache, &version_cache_test);
+ verified_installer_path
+ }
+ other => panic!("State should be Downloaded, was {other:?}"),
+ };
+
+ // Check that the app upgrade events were sent
+ let events = [
+ Ok(AppUpgradeEvent::DownloadStarting),
+ Ok(AppUpgradeEvent::DownloadProgress(
+ AppUpgradeDownloadProgress {
+ progress: 100,
+ server: "example.com".to_string(),
+ time_left: None,
+ },
+ )),
+ Ok(AppUpgradeEvent::VerifyingInstaller),
+ Ok(AppUpgradeEvent::VerifiedInstaller),
+ Err(TryRecvError::Empty), // No more events should be sent
+ ];
+ for event in events {
+ assert_eq!(app_upgrade_listener.try_recv(), event);
+ }
+
+ // Check that the version event was sent with the verified installer path
+ let version_info = channels
+ .version_event_receiver
+ .try_next()
+ .expect("Version event channel should contain message")
+ .expect("Version event should be sent");
+ assert_eq!(
+ version_info
+ .suggested_upgrade
+ .as_ref()
+ .unwrap()
+ .verified_installer_path,
+ Some(verified_installer_path.clone())
+ );
+ channels
+ .version_event_receiver
+ .try_next()
+ .expect_err("Channel should not have any messages");
+
+ version_router.update_application();
+ assert!(
+ matches!(version_router.state, State::Downloaded { .. }),
+ "Triggering an update while in the downloaded shout be ignored"
+ );
+
+ version_router.cancel_upgrade();
+ assert!(
+ matches!(version_router.state, State::HasVersion { .. }),
+ "State should be HasVersion after cancelling the upgrade"
+ );
+
+ assert_eq!(
+ app_upgrade_listener.try_recv(),
+ Ok(AppUpgradeEvent::Aborted),
+ "The `AppUpgradeEvent::Aborted` should be sent when cancelling a finished download"
+ );
+ assert_eq!(
+ app_upgrade_listener.try_recv(),
+ Err(TryRecvError::Empty),
+ "No more events should be sent",
+ );
+
+ let version_info = channels
+ .version_event_receiver
+ .try_next()
+ .expect("Version event channel should contain message")
+ .expect("Version event should be sent");
+ assert_eq!(
+ version_info
+ .suggested_upgrade
+ .as_ref()
+ .unwrap()
+ .verified_installer_path,
+ None,
+ "Aborting should send a new `AppVersionInfo` without a verified installer path"
+ );
+ }
+
+ /// Test that the update is aborted if a new version is received while downloading
+ #[tokio::test(start_paused = true)]
+ async fn test_abort_on_new_version() {
+ let (mut version_router, _channels) = make_version_router::<SuccessfulAppDownloader>();
+ let upgrade_version = get_new_stable_version_cache();
+ let mut upgrade_version_newer = upgrade_version.clone();
+ upgrade_version_newer
+ .version_info
+ .stable
+ .version
+ .incremental += 1;
+
+ version_router.on_new_version(upgrade_version.clone());
+
+ // Start upgrading
+ let mut app_upgrade_listener = version_router.app_upgrade_broadcast.subscribe();
+ version_router.update_application();
+ // Check that the state is now downloading
+ assert!(matches!(version_router.state, State::Downloading { .. }),);
+
+ // Advance the download to the point where we have started downloading
+ tokio::time::sleep(DOWNLOAD_DURATION / 2).await;
+ assert_eq!(
+ app_upgrade_listener.try_recv().unwrap(),
+ AppUpgradeEvent::DownloadStarting
+ );
+ assert_eq!(app_upgrade_listener.try_recv(), Err(TryRecvError::Empty));
+
+ // Now, send a new version while the download is in progress
+ version_router.on_new_version(upgrade_version_newer);
+ assert_eq!(
+ app_upgrade_listener.try_recv().unwrap(),
+ AppUpgradeEvent::Aborted
+ );
+ assert_eq!(app_upgrade_listener.try_recv(), Err(TryRecvError::Empty));
+ }
+
+ #[tokio::test]
+ async fn test_failed_download() {
+ let (mut version_router, _channels) = make_version_router::<FailingAppDownloader>();
+ let version_cache_test = get_new_stable_version_cache();
+
+ version_router.on_new_version(version_cache_test.clone());
+
+ // Start upgrading
+ let mut app_upgrade_listener = version_router.app_upgrade_broadcast.subscribe();
+ version_router.update_application();
+ // Check that the state is now downloading
+ assert!(matches!(version_router.state, State::Downloading { .. }),);
+
+ // Drive the download to completion
+ version_router.run_step().await;
+ assert_eq!(
+ app_upgrade_listener.try_recv().unwrap(),
+ AppUpgradeEvent::DownloadStarting
+ );
+ assert_eq!(
+ app_upgrade_listener.try_recv().unwrap(),
+ AppUpgradeEvent::Error(mullvad_types::version::AppUpgradeError::DownloadFailed)
+ );
+ assert_eq!(app_upgrade_listener.try_recv(), Err(TryRecvError::Empty));
+ version_router.update_application();
+
+ // Verify that we can restart the download again
+ version_router.run_step().await;
+ assert_eq!(
+ app_upgrade_listener.try_recv().unwrap(),
+ AppUpgradeEvent::DownloadStarting
+ );
+ }
+
+ #[tokio::test]
+ async fn test_failed_verification() {
+ let (mut version_router, _channels) = make_version_router::<FailingAppVerifier>();
+ let version_cache_test = get_new_stable_version_cache();
+
+ version_router.on_new_version(version_cache_test.clone());
+
+ // Start upgrading
+ let mut app_upgrade_listener = version_router.app_upgrade_broadcast.subscribe();
+ version_router.update_application();
+ // Check that the state is now downloading
+ assert!(matches!(version_router.state, State::Downloading { .. }),);
+
+ // Drive the download to completion
+ version_router.run_step().await;
+ assert_eq!(
+ app_upgrade_listener.try_recv().unwrap(),
+ AppUpgradeEvent::DownloadStarting
+ );
+ assert_eq!(
+ app_upgrade_listener.try_recv().unwrap(),
+ AppUpgradeEvent::VerifyingInstaller
+ );
+ assert_eq!(
+ app_upgrade_listener.try_recv().unwrap(),
+ AppUpgradeEvent::Error(mullvad_types::version::AppUpgradeError::VerificationFailed)
+ );
+ assert_eq!(app_upgrade_listener.try_recv(), Err(TryRecvError::Empty));
+ version_router.update_application();
+
+ // Verify that we can restart the download again
+ version_router.run_step().await;
+ assert_eq!(
+ app_upgrade_listener.try_recv().unwrap(),
+ AppUpgradeEvent::DownloadStarting
+ );
+ }
+}
diff --git a/mullvad-management-interface/proto/management_interface.proto b/mullvad-management-interface/proto/management_interface.proto
index 2b7e02f9d2..4accf8f5bc 100644
--- a/mullvad-management-interface/proto/management_interface.proto
+++ b/mullvad-management-interface/proto/management_interface.proto
@@ -25,6 +25,8 @@ service ManagementService {
rpc FactoryReset(google.protobuf.Empty) returns (google.protobuf.Empty) {}
rpc GetCurrentVersion(google.protobuf.Empty) returns (google.protobuf.StringValue) {}
+ // Get information about the latest available version of the app.
+ // Note that calling this during an in-app upgrade will cancel the upgrade.
rpc GetVersionInfo(google.protobuf.Empty) returns (AppVersionInfo) {}
rpc IsPerformingPostUpgrade(google.protobuf.Empty) returns (google.protobuf.BoolValue) {}
@@ -131,6 +133,40 @@ service ManagementService {
// Debug features
rpc DisableRelay(google.protobuf.StringValue) returns (google.protobuf.Empty) {}
rpc EnableRelay(google.protobuf.StringValue) returns (google.protobuf.Empty) {}
+
+ // App upgrade
+ rpc AppUpgrade(google.protobuf.Empty) returns (google.protobuf.Empty) {}
+ rpc AppUpgradeAbort(google.protobuf.Empty) returns (google.protobuf.Empty) {}
+ rpc AppUpgradeEventsListen(google.protobuf.Empty) returns (stream AppUpgradeEvent) {}
+}
+
+message AppUpgradeEvent {
+ oneof event {
+ AppUpgradeDownloadStarting download_starting = 1;
+ AppUpgradeDownloadProgress download_progress = 2;
+ AppUpgradeAborted upgrade_aborted = 3;
+ AppUpgradeVerifyingInstaller verifying_installer = 4;
+ AppUpgradeVerifiedInstaller verified_installer = 5;
+ AppUpgradeError error = 6;
+ }
+}
+
+message AppUpgradeDownloadStarting {}
+message AppUpgradeDownloadProgress {
+ string server = 1;
+ uint32 progress = 2;
+ optional google.protobuf.Duration time_left = 3;
+}
+message AppUpgradeAborted {}
+message AppUpgradeVerifyingInstaller {}
+message AppUpgradeVerifiedInstaller {}
+message AppUpgradeError {
+ enum Error {
+ GENERAL_ERROR = 0;
+ DOWNLOAD_FAILED = 1;
+ VERIFICATION_FAILED = 2;
+ }
+ Error error = 1;
}
message UUID { string value = 1; }
@@ -629,11 +665,15 @@ message ExcludedProcess {
message ExcludedProcessList { repeated ExcludedProcess processes = 1; }
+message SuggestedUpgrade {
+ string version = 1;
+ string changelog = 2;
+ optional string verified_installer_path = 3;
+}
+
message AppVersionInfo {
bool supported = 1;
- string latest_stable = 2;
- string latest_beta = 3;
- optional string suggested_upgrade = 4;
+ SuggestedUpgrade suggested_upgrade = 2;
}
message RelayListCountry {
diff --git a/mullvad-management-interface/src/client.rs b/mullvad-management-interface/src/client.rs
index 6d17e923f6..302e8f3173 100644
--- a/mullvad-management-interface/src/client.rs
+++ b/mullvad-management-interface/src/client.rs
@@ -67,9 +67,9 @@ impl TryFrom<types::daemon_event::Event> for DaemonEvent {
types::daemon_event::Event::RelayList(list) => RelayList::try_from(list)
.map(DaemonEvent::RelayList)
.map_err(Error::InvalidResponse),
- types::daemon_event::Event::VersionInfo(info) => {
- Ok(DaemonEvent::AppVersionInfo(AppVersionInfo::from(info)))
- }
+ types::daemon_event::Event::VersionInfo(info) => AppVersionInfo::try_from(info)
+ .map(DaemonEvent::AppVersionInfo)
+ .map_err(Error::InvalidResponse),
types::daemon_event::Event::Device(event) => DeviceEvent::try_from(event)
.map(DaemonEvent::Device)
.map_err(Error::InvalidResponse),
@@ -192,7 +192,7 @@ impl MullvadProxyClient {
.await
.map_err(Error::Rpc)?
.into_inner();
- Ok(AppVersionInfo::from(version_info))
+ AppVersionInfo::try_from(version_info).map_err(Error::InvalidResponse)
}
pub async fn get_relay_locations(&mut self) -> Result<RelayList> {
diff --git a/mullvad-management-interface/src/types/conversions/version.rs b/mullvad-management-interface/src/types/conversions/version.rs
index b2ab054206..381dbbc0df 100644
--- a/mullvad-management-interface/src/types/conversions/version.rs
+++ b/mullvad-management-interface/src/types/conversions/version.rs
@@ -1,23 +1,190 @@
+use std::path::PathBuf;
+
use crate::types::proto;
+use mullvad_types::version::*;
+
+use super::FromProtobufTypeError;
-impl From<mullvad_types::version::AppVersionInfo> for proto::AppVersionInfo {
- fn from(version_info: mullvad_types::version::AppVersionInfo) -> Self {
+impl From<AppVersionInfo> for proto::AppVersionInfo {
+ fn from(version_info: AppVersionInfo) -> Self {
Self {
- supported: version_info.supported,
- latest_stable: version_info.latest_stable,
- latest_beta: version_info.latest_beta,
- suggested_upgrade: version_info.suggested_upgrade,
+ supported: version_info.current_version_supported,
+ suggested_upgrade: version_info
+ .suggested_upgrade
+ .map(proto::SuggestedUpgrade::from),
}
}
}
-impl From<proto::AppVersionInfo> for mullvad_types::version::AppVersionInfo {
- fn from(version_info: proto::AppVersionInfo) -> Self {
+impl TryFrom<proto::AppVersionInfo> for AppVersionInfo {
+ type Error = FromProtobufTypeError;
+
+ fn try_from(version_info: proto::AppVersionInfo) -> Result<Self, Self::Error> {
+ Ok(Self {
+ current_version_supported: version_info.supported,
+ suggested_upgrade: version_info
+ .suggested_upgrade
+ .map(SuggestedUpgrade::try_from)
+ .transpose()?,
+ })
+ }
+}
+
+impl From<SuggestedUpgrade> for proto::SuggestedUpgrade {
+ fn from(suggested_upgrade: SuggestedUpgrade) -> Self {
Self {
- supported: version_info.supported,
- latest_stable: version_info.latest_stable,
- latest_beta: version_info.latest_beta,
- suggested_upgrade: version_info.suggested_upgrade,
+ version: suggested_upgrade.version.to_string(),
+ changelog: suggested_upgrade.changelog,
+ verified_installer_path: suggested_upgrade
+ .verified_installer_path
+ .and_then(|path| path.to_str().map(str::to_owned)),
+ }
+ }
+}
+
+impl TryFrom<proto::SuggestedUpgrade> for SuggestedUpgrade {
+ type Error = FromProtobufTypeError;
+
+ fn try_from(suggested_upgrade: proto::SuggestedUpgrade) -> Result<Self, Self::Error> {
+ // TODO: we probably don't need to convert in this direction
+ let version = suggested_upgrade.version.parse().map_err(|_err| {
+ FromProtobufTypeError::InvalidArgument("invalid Mullvad app version")
+ })?;
+ let verified_installer_path = suggested_upgrade
+ .verified_installer_path
+ .map(|path| PathBuf::from(&path));
+
+ Ok(Self {
+ version,
+ changelog: suggested_upgrade.changelog,
+ verified_installer_path,
+ })
+ }
+}
+
+impl From<AppUpgradeEvent> for proto::AppUpgradeEvent {
+ fn from(upgrade_event: AppUpgradeEvent) -> Self {
+ type ProtoEvent = proto::app_upgrade_event::Event;
+
+ let event = match upgrade_event {
+ AppUpgradeEvent::DownloadStarting => {
+ ProtoEvent::DownloadStarting(proto::AppUpgradeDownloadStarting {})
+ }
+ AppUpgradeEvent::DownloadProgress(progress) => {
+ ProtoEvent::DownloadProgress(progress.into())
+ }
+ AppUpgradeEvent::VerifyingInstaller => {
+ ProtoEvent::VerifyingInstaller(proto::AppUpgradeVerifyingInstaller {})
+ }
+ AppUpgradeEvent::VerifiedInstaller => {
+ ProtoEvent::VerifiedInstaller(proto::AppUpgradeVerifiedInstaller {})
+ }
+ AppUpgradeEvent::Aborted => ProtoEvent::UpgradeAborted(proto::AppUpgradeAborted {}),
+ AppUpgradeEvent::Error(app_upgrade_error) => {
+ ProtoEvent::Error(app_upgrade_error.into())
+ }
+ };
+ Self { event: Some(event) }
+ }
+}
+
+impl TryFrom<proto::AppUpgradeEvent> for AppUpgradeEvent {
+ type Error = FromProtobufTypeError;
+
+ fn try_from(upgrade_event: proto::AppUpgradeEvent) -> Result<Self, FromProtobufTypeError> {
+ type ProtoEvent = proto::app_upgrade_event::Event;
+
+ let event = upgrade_event
+ .event
+ .ok_or(FromProtobufTypeError::InvalidArgument(
+ "Non-existent AppUpgradeEvent",
+ ))?;
+
+ let event = match event {
+ ProtoEvent::DownloadStarting(_starting) => AppUpgradeEvent::DownloadStarting,
+ ProtoEvent::DownloadProgress(progress) => {
+ let progress = AppUpgradeDownloadProgress::try_from(progress)?;
+ AppUpgradeEvent::DownloadProgress(progress)
+ }
+ ProtoEvent::VerifyingInstaller(_verifying) => AppUpgradeEvent::VerifyingInstaller,
+ ProtoEvent::VerifiedInstaller(_verified) => AppUpgradeEvent::VerifiedInstaller,
+ ProtoEvent::UpgradeAborted(_aborted) => AppUpgradeEvent::Aborted,
+ ProtoEvent::Error(error) => {
+ let error = AppUpgradeError::try_from(error)?;
+ AppUpgradeEvent::Error(error)
+ }
+ };
+ Ok(event)
+ }
+}
+
+impl From<AppUpgradeDownloadProgress> for proto::AppUpgradeDownloadProgress {
+ fn from(value: AppUpgradeDownloadProgress) -> Self {
+ // From the docs: Converts a std::time::Duration to a Duration, failing if the duration is too large.
+ let time_left = value
+ .time_left
+ .map(prost_types::Duration::try_from)
+ .transpose()
+ .expect("Failed to convert duration to protobuf, duration is too large");
+ proto::AppUpgradeDownloadProgress {
+ server: value.server,
+ progress: value.progress,
+ time_left,
+ }
+ }
+}
+
+impl TryFrom<proto::AppUpgradeDownloadProgress> for AppUpgradeDownloadProgress {
+ type Error = FromProtobufTypeError;
+
+ fn try_from(value: proto::AppUpgradeDownloadProgress) -> Result<Self, Self::Error> {
+ // From the docs: Converts a Duration to a std::time::Duration, failing if the duration is negative.
+ let time_left = value
+ .time_left
+ .map(std::time::Duration::try_from)
+ .transpose()
+ .expect("Failed to convert duration to std::time::Duration");
+
+ let progress = AppUpgradeDownloadProgress {
+ server: value.server,
+ progress: value.progress,
+ time_left,
+ };
+ Ok(progress)
+ }
+}
+
+impl From<AppUpgradeError> for proto::AppUpgradeError {
+ fn from(value: AppUpgradeError) -> Self {
+ type ProtoError = proto::app_upgrade_error::Error;
+ match value {
+ AppUpgradeError::GeneralError => proto::AppUpgradeError {
+ error: ProtoError::GeneralError as i32,
+ },
+ AppUpgradeError::DownloadFailed => proto::AppUpgradeError {
+ error: ProtoError::DownloadFailed as i32,
+ },
+ AppUpgradeError::VerificationFailed => proto::AppUpgradeError {
+ error: ProtoError::VerificationFailed as i32,
+ },
+ }
+ }
+}
+
+impl TryFrom<proto::AppUpgradeError> for AppUpgradeError {
+ type Error = FromProtobufTypeError;
+
+ fn try_from(value: proto::AppUpgradeError) -> Result<Self, Self::Error> {
+ type ProtoError = proto::app_upgrade_error::Error;
+ let Ok(error) = ProtoError::try_from(value.error) else {
+ return Err(FromProtobufTypeError::InvalidArgument(
+ "invalid AppUpgradeError",
+ ));
+ };
+ match error {
+ ProtoError::GeneralError => Ok(AppUpgradeError::GeneralError),
+ ProtoError::DownloadFailed => Ok(AppUpgradeError::DownloadFailed),
+ ProtoError::VerificationFailed => Ok(AppUpgradeError::VerificationFailed),
}
}
}
diff --git a/mullvad-paths/src/cache.rs b/mullvad-paths/src/cache.rs
index 3b31a9460b..26f36a4629 100644
--- a/mullvad-paths/src/cache.rs
+++ b/mullvad-paths/src/cache.rs
@@ -4,10 +4,11 @@ use std::{env, path::PathBuf};
/// Creates and returns the cache directory pointed to by `MULLVAD_CACHE_DIR`, or the default
/// one if that variable is unset.
pub fn cache_dir() -> Result<PathBuf> {
- #[cfg(unix)]
- let permissions = crate::unix::Permissions::ReadExecOnly;
- #[cfg(target_os = "windows")]
- let permissions = true;
+ let permissions = Some(crate::UserPermissions {
+ read: true,
+ write: false,
+ execute: true,
+ });
crate::create_dir(get_cache_dir()?, permissions)
}
diff --git a/mullvad-paths/src/lib.rs b/mullvad-paths/src/lib.rs
index c5eb5b9a52..508a3e8956 100644
--- a/mullvad-paths/src/lib.rs
+++ b/mullvad-paths/src/lib.rs
@@ -46,6 +46,23 @@ pub enum Error {
NoDataDir,
}
+#[derive(Clone, Copy)]
+pub struct UserPermissions {
+ pub read: bool,
+ pub write: bool,
+ pub execute: bool,
+}
+
+impl UserPermissions {
+ pub fn read_only() -> Self {
+ UserPermissions {
+ read: true,
+ write: false,
+ execute: false,
+ }
+ }
+}
+
#[cfg(unix)]
use unix::create_dir;
diff --git a/mullvad-paths/src/logs.rs b/mullvad-paths/src/logs.rs
index 03ab990632..5a1fb2034d 100644
--- a/mullvad-paths/src/logs.rs
+++ b/mullvad-paths/src/logs.rs
@@ -4,14 +4,14 @@ use std::{env, path::PathBuf};
/// Creates and returns the logging directory pointed to by `MULLVAD_LOG_DIR`, or the default
/// one if that variable is unset.
pub fn log_dir() -> Result<PathBuf> {
- #[cfg(unix)]
- {
- crate::create_dir(get_log_dir()?, crate::unix::Permissions::ReadExecOnly)
- }
- #[cfg(target_os = "windows")]
- {
- crate::create_dir(get_log_dir()?, true)
- }
+ let permissions = Some(crate::UserPermissions {
+ read: true,
+ write: false,
+ // Unix: Make directory contents readable
+ execute: cfg!(unix),
+ });
+
+ crate::create_dir(get_log_dir()?, permissions)
}
/// Get the logging directory, but don't try to create it.
diff --git a/mullvad-paths/src/settings.rs b/mullvad-paths/src/settings.rs
index 016e158bee..3852c56ddd 100644
--- a/mullvad-paths/src/settings.rs
+++ b/mullvad-paths/src/settings.rs
@@ -4,15 +4,7 @@ use std::{env, path::PathBuf};
/// Creates and returns the settings directory pointed to by `MULLVAD_SETTINGS_DIR`, or the default
/// one if that variable is unset.
pub fn settings_dir() -> Result<PathBuf> {
- #[cfg(unix)]
- {
- crate::create_dir(get_settings_dir()?, crate::unix::Permissions::Any)
- }
-
- #[cfg(target_os = "windows")]
- {
- crate::create_dir(get_settings_dir()?, false)
- }
+ crate::create_dir(get_settings_dir()?, None)
}
fn get_settings_dir() -> Result<PathBuf> {
diff --git a/mullvad-paths/src/unix.rs b/mullvad-paths/src/unix.rs
index 90a5d2fc5c..5d5b3b03a1 100644
--- a/mullvad-paths/src/unix.rs
+++ b/mullvad-paths/src/unix.rs
@@ -4,35 +4,29 @@ use std::{
path::{Path, PathBuf},
};
-use crate::{Error, Result};
+use crate::{Error, Result, UserPermissions};
pub const PRODUCT_NAME: &str = "mullvad-vpn";
-#[derive(Clone, Copy, PartialEq)]
-pub enum Permissions {
- /// Do not set any particular permissions. They will be inherited instead.
- Any,
- /// Only root should have write access. Other users will have
- /// read and execute permissions (0o755).
- ReadExecOnly,
-}
+impl UserPermissions {
+ fn fs_permissions(self) -> fs::Permissions {
+ const OWNER_BITS: u32 = 0o700;
-impl Permissions {
- fn fs_permissions(self) -> Option<fs::Permissions> {
- match self {
- Permissions::Any => None,
- Permissions::ReadExecOnly => Some(std::os::unix::fs::PermissionsExt::from_mode(0o755)),
- }
+ let rbits = if self.read { 0o044 } else { 0 };
+ let wbits = if self.write { 0o022 } else { 0 };
+ let ebits = if self.execute { 0o011 } else { 0 };
+
+ std::os::unix::fs::PermissionsExt::from_mode(OWNER_BITS | rbits | wbits | ebits)
}
}
/// Create a directory at `dir`, setting the permissions given by `permissions`, unless it exists.
/// If the directory already exists, but the permissions are not at least as strict as expected,
/// then it will be deleted and recreated.
-pub fn create_dir(dir: PathBuf, permissions: Permissions) -> Result<PathBuf> {
+pub fn create_dir(dir: PathBuf, permissions: Option<UserPermissions>) -> Result<PathBuf> {
let mut dir_builder = fs::DirBuilder::new();
- let fs_perms = permissions.fs_permissions();
- if let Some(fs_perms) = fs_perms.as_ref() {
+ let fs_perms = permissions.as_ref().map(|perms| perms.fs_permissions());
+ if let Some(fs_perms) = &fs_perms {
dir_builder.mode(fs_perms.mode());
}
match dir_builder.create(&dir) {
diff --git a/mullvad-paths/src/windows.rs b/mullvad-paths/src/windows.rs
index 102fa53f5f..5bab1c68f3 100644
--- a/mullvad-paths/src/windows.rs
+++ b/mullvad-paths/src/windows.rs
@@ -1,6 +1,6 @@
#![allow(clippy::undocumented_unsafe_blocks)] // Remove me if you dare.
-use crate::{Error, Result};
+use crate::{Error, Result, UserPermissions};
use once_cell::sync::OnceCell;
use std::{
ffi::OsStr,
@@ -15,7 +15,7 @@ use windows_sys::{
Win32::{
Foundation::{
CloseHandle, LocalFree, ERROR_INSUFFICIENT_BUFFER, ERROR_SUCCESS, GENERIC_ALL,
- GENERIC_READ, HANDLE, INVALID_HANDLE_VALUE, LUID, S_OK,
+ GENERIC_EXECUTE, GENERIC_READ, GENERIC_WRITE, HANDLE, INVALID_HANDLE_VALUE, LUID, S_OK,
},
Security::{
self, AdjustTokenPrivileges,
@@ -58,9 +58,10 @@ pub fn get_allusersprofile_dir() -> Result<PathBuf> {
/// file permissions corresponding to Authenticated Users - Read Only and Administrators - Full
/// Access. Only directories that do not already exist and the leaf directory will have their
/// permissions set.
-pub fn create_dir(path: PathBuf, set_security_permissions: bool) -> Result<PathBuf> {
- if set_security_permissions {
- create_dir_with_permissions_recursive(&path)?;
+#[cfg(windows)]
+pub fn create_dir(path: PathBuf, user_permissions: Option<UserPermissions>) -> Result<PathBuf> {
+ if let Some(user_permissions) = user_permissions {
+ create_dir_recursive_with_permissions(&path, user_permissions)?;
} else {
std::fs::create_dir_all(&path).map_err(|e| {
Error::CreateDirFailed(
@@ -93,12 +94,31 @@ fn get_wide_str<S: AsRef<OsStr>>(string: S) -> Vec<u16> {
wide_string
}
+impl UserPermissions {
+ fn flags(self) -> u32 {
+ let mut flags = 0;
+ if self.read {
+ flags |= GENERIC_READ;
+ }
+ if self.write {
+ flags |= GENERIC_WRITE;
+ }
+ if self.execute {
+ flags |= GENERIC_EXECUTE;
+ }
+ flags
+ }
+}
+
/// If directory at path already exists, set permissions for it.
/// If directory at path don't exist but parent does, create directory and set permissions.
/// If parent directory at path does not exist then recurse and create parent directory and set
/// permissions for it, then create child directory and set permissions.
/// This does not set permissions for parent directories that already exists.
-fn create_dir_with_permissions_recursive(path: &Path) -> Result<()> {
+fn create_dir_recursive_with_permissions(
+ path: &Path,
+ user_permissions: UserPermissions,
+) -> Result<()> {
// No directory to create
if path == Path::new("") {
return Ok(());
@@ -106,13 +126,13 @@ fn create_dir_with_permissions_recursive(path: &Path) -> Result<()> {
match std::fs::create_dir(path) {
Ok(()) => {
- return set_security_permissions(path);
+ return set_security_permissions(path, user_permissions);
}
// Could not find parent directory, try creating parent
Err(e) if e.kind() == io::ErrorKind::NotFound => (),
// Directory already exists, set permissions
Err(e) if e.kind() == io::ErrorKind::AlreadyExists && path.is_dir() => {
- return set_security_permissions(path);
+ return set_security_permissions(path, user_permissions);
}
Err(e) => {
return Err(Error::CreateDirFailed(
@@ -124,7 +144,7 @@ fn create_dir_with_permissions_recursive(path: &Path) -> Result<()> {
match path.parent() {
// Create parent directory
- Some(parent) => create_dir_with_permissions_recursive(parent)?,
+ Some(parent) => create_dir_recursive_with_permissions(parent, user_permissions)?,
None => {
// Reached the top of the tree but when creating directories only got NotFound for some
// reason
@@ -139,19 +159,19 @@ fn create_dir_with_permissions_recursive(path: &Path) -> Result<()> {
}
std::fs::create_dir(path).map_err(|e| Error::CreateDirFailed(path.display().to_string(), e))?;
- set_security_permissions(path)
+ set_security_permissions(path, user_permissions)
}
/// Recursively creates directories for the given path with permissions that give full access to
/// admins and read only access to authenticated users. If any of the directories already exist this
/// will not return an error, instead it will apply the permissions and if successful return Ok(()).
pub fn create_privileged_directory(path: &Path) -> Result<()> {
- create_dir_with_permissions_recursive(path)
+ create_dir_recursive_with_permissions(path, UserPermissions::read_only())
}
/// Sets security permissions for path such that admin has full ownership and access while
/// authenticated users only have read access.
-fn set_security_permissions(path: &Path) -> Result<()> {
+fn set_security_permissions(path: &Path, user_permissions: UserPermissions) -> Result<()> {
let wide_path = get_wide_str(path);
let security_information = Security::DACL_SECURITY_INFORMATION
| Security::PROTECTED_DACL_SECURITY_INFORMATION
@@ -216,7 +236,7 @@ fn set_security_permissions(path: &Path) -> Result<()> {
};
let authenticated_users_ea = EXPLICIT_ACCESS_W {
- grfAccessPermissions: GENERIC_READ,
+ grfAccessPermissions: user_permissions.flags(),
grfAccessMode: SET_ACCESS,
grfInheritance: NO_INHERITANCE | SUB_CONTAINERS_AND_OBJECTS_INHERIT,
Trustee: trustee,
diff --git a/mullvad-types/Cargo.toml b/mullvad-types/Cargo.toml
index adddfef0f5..5878767458 100644
--- a/mullvad-types/Cargo.toml
+++ b/mullvad-types/Cargo.toml
@@ -23,6 +23,6 @@ uuid = { version = "1.4.1", features = ["v4", "serde" ] }
talpid-types = { path = "../talpid-types" }
intersection-derive = { path = "intersection-derive" }
-
clap = { workspace = true , optional = true }
+mullvad-version = { path = "../mullvad-version", features = ["serde"] }
diff --git a/mullvad-types/src/version.rs b/mullvad-types/src/version.rs
index 6338909feb..e2e73b4ea7 100644
--- a/mullvad-types/src/version.rs
+++ b/mullvad-types/src/version.rs
@@ -1,3 +1,5 @@
+use std::{fmt::Display, path::PathBuf};
+
use serde::{Deserialize, Serialize};
/// AppVersionInfo represents the current stable and the current latest release versions of the
@@ -11,14 +13,58 @@ pub struct AppVersionInfo {
/// issues, so using it is no longer recommended.
///
/// The user should really upgrade when this is false.
- pub supported: bool,
- /// Latest stable version
- pub latest_stable: AppVersion,
- /// Equal to `latest_stable` when the newest release is a stable release. But will contain
- /// beta versions when those are out for testing.
- pub latest_beta: AppVersion,
- /// Whether should update to newer version
- pub suggested_upgrade: Option<AppVersion>,
+ pub current_version_supported: bool,
+ /// A newer version that may be upgraded to
+ pub suggested_upgrade: Option<SuggestedUpgrade>,
+}
+
+impl Display for AppVersionInfo {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ if let Some(suggested_upgrade) = &self.suggested_upgrade {
+ writeln!(f, "Suggested upgrade: {}", suggested_upgrade.version)?;
+ if let Some(path) = &suggested_upgrade.verified_installer_path {
+ writeln!(f, "verified installer path: '{}'", path.display())?;
+ }
+ }
+ if self.current_version_supported {
+ write!(f, "Current version supported")?;
+ } else {
+ write!(f, "Current version not supported")?;
+ }
+ Ok(())
+ }
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
+pub struct SuggestedUpgrade {
+ /// Version available for update
+ pub version: mullvad_version::Version,
+ /// Changelog
+ pub changelog: String,
+ /// Path to the available installer, iff it has been verified
+ pub verified_installer_path: Option<PathBuf>,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
+pub struct AppUpgradeDownloadProgress {
+ pub server: String,
+ pub progress: u32,
+ pub time_left: Option<std::time::Duration>,
}
-pub type AppVersion = String;
+#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
+pub enum AppUpgradeEvent {
+ DownloadStarting,
+ DownloadProgress(AppUpgradeDownloadProgress),
+ Aborted,
+ VerifyingInstaller,
+ VerifiedInstaller,
+ Error(AppUpgradeError),
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
+pub enum AppUpgradeError {
+ GeneralError,
+ DownloadFailed,
+ VerificationFailed,
+}
diff --git a/mullvad-update/Cargo.toml b/mullvad-update/Cargo.toml
index 020391b635..d2f2bbae37 100644
--- a/mullvad-update/Cargo.toml
+++ b/mullvad-update/Cargo.toml
@@ -24,6 +24,7 @@ hex = { version = "0.4" }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
zeroize = { version = "1.8", features = ["zeroize_derive"] }
+log = { workspace = true }
reqwest = { version = "0.12.9", default-features = false, features = ["rustls-tls"], optional = true }
sha2 = { workspace = true, optional = true }
@@ -36,7 +37,6 @@ mullvad-version = { path = "../mullvad-version", features = ["serde"] }
clap = { workspace = true, optional = true }
rand = { version = "0.8.5", optional = true }
-[target.'cfg(any(target_os = "macos", target_os = "windows"))'.dependencies]
thiserror = { workspace = true, optional = true }
[dev-dependencies]
diff --git a/mullvad-update/mullvad-release/src/main.rs b/mullvad-update/mullvad-release/src/main.rs
index 7ba8469cd1..900caf481b 100644
--- a/mullvad-update/mullvad-release/src/main.rs
+++ b/mullvad-update/mullvad-release/src/main.rs
@@ -25,9 +25,6 @@ const DEFAULT_EXPIRY_MONTHS: usize = 6;
/// Rollout to use when not specified
const DEFAULT_ROLLOUT: f32 = 1.;
-/// Lowest version to accept using 'verify'
-const MIN_VERIFY_METADATA_VERSION: usize = 0;
-
/// A tool that generates signed Mullvad version metadata.
///
/// Unsigned work is stored in `work/`, and signed work is stored in `signed/`
diff --git a/mullvad-update/mullvad-release/src/platform.rs b/mullvad-update/mullvad-release/src/platform.rs
index 06ad8a7185..5ab5b8ba39 100644
--- a/mullvad-update/mullvad-release/src/platform.rs
+++ b/mullvad-update/mullvad-release/src/platform.rs
@@ -105,7 +105,7 @@ impl Platform {
let response = HttpVersionInfoProvider::get_versions_for_platform(
platform,
- crate::MIN_VERIFY_METADATA_VERSION,
+ mullvad_update::version::MIN_VERIFY_METADATA_VERSION,
)
.await
.context("Failed to retrieve versions")?;
@@ -204,8 +204,11 @@ impl Platform {
println!("Verifying signature of {}...", signed_path.display());
let bytes = fs::read(signed_path).await.context("Failed to read file")?;
- format::SignedResponse::deserialize_and_verify(&bytes, crate::MIN_VERIFY_METADATA_VERSION)
- .context("Failed to verify metadata for {platform}: {error}")?;
+ format::SignedResponse::deserialize_and_verify(
+ &bytes,
+ mullvad_update::version::MIN_VERIFY_METADATA_VERSION,
+ )
+ .context("Failed to verify metadata for {platform}: {error}")?;
Ok(())
}
diff --git a/mullvad-update/src/client/app.rs b/mullvad-update/src/client/app.rs
index 90dc4314e2..473f8daf2e 100644
--- a/mullvad-update/src/client/app.rs
+++ b/mullvad-update/src/client/app.rs
@@ -2,7 +2,12 @@
//! This module implements the flow of downloading and verifying the app.
-use std::{ffi::OsString, future::Future, path::PathBuf, time::Duration};
+use std::{
+ ffi::OsString,
+ future::Future,
+ path::{Path, PathBuf},
+ time::Duration,
+};
use tokio::{process::Command, time::timeout};
@@ -14,7 +19,7 @@ use crate::{
#[derive(Debug, thiserror::Error)]
pub enum DownloadError {
#[error("Failed to download app")]
- FetchApp(#[source] anyhow::Error),
+ FetchApp(#[from] anyhow::Error),
#[error("Failed to verify app")]
Verification(#[source] anyhow::Error),
#[error("Failed to launch app")]
@@ -81,7 +86,7 @@ impl<AppProgress: ProgressUpdater> From<AppDownloaderParameters<AppProgress>>
impl<AppProgress: ProgressUpdater> AppDownloader for HttpAppDownloader<AppProgress> {
async fn download_executable(&mut self) -> Result<(), DownloadError> {
- let bin_path = self.bin_path();
+ let bin_path = bin_path(&self.params.app_version, &self.params.cache_dir);
fetch::get_to_file(
bin_path,
&self.params.app_url,
@@ -93,7 +98,7 @@ impl<AppProgress: ProgressUpdater> AppDownloader for HttpAppDownloader<AppProgre
}
async fn verify(&mut self) -> Result<(), DownloadError> {
- let bin_path = self.bin_path();
+ let bin_path = bin_path(&self.params.app_version, &self.params.cache_dir);
let hash = self.hash_sha256();
match Sha256Verifier::verify(&bin_path, *hash)
@@ -133,21 +138,21 @@ impl<AppProgress: ProgressUpdater> AppDownloader for HttpAppDownloader<AppProgre
}
}
-impl<AppProgress> HttpAppDownloader<AppProgress> {
- fn bin_path(&self) -> PathBuf {
- #[cfg(windows)]
- let bin_filename = format!("mullvad-{}.exe", self.params.app_version);
+pub fn bin_path(app_version: &mullvad_version::Version, cache_dir: &Path) -> PathBuf {
+ #[cfg(windows)]
+ let bin_filename = format!("mullvad-{}.exe", app_version);
- #[cfg(target_os = "macos")]
- let bin_filename = format!("mullvad-{}.pkg", self.params.app_version);
+ #[cfg(target_os = "macos")]
+ let bin_filename = format!("mullvad-{}.pkg", app_version);
- self.params.cache_dir.join(bin_filename)
- }
+ cache_dir.join(bin_filename)
+}
+impl<AppProgress> HttpAppDownloader<AppProgress> {
fn launch_path(&self) -> PathBuf {
#[cfg(target_os = "windows")]
{
- self.bin_path()
+ bin_path(&self.params.app_version, &self.params.cache_dir)
}
#[cfg(target_os = "macos")]
@@ -166,7 +171,7 @@ impl<AppProgress> HttpAppDownloader<AppProgress> {
#[cfg(target_os = "macos")]
{
- vec![self.bin_path().into()]
+ vec![bin_path(&self.params.app_version, &self.params.cache_dir).into()]
}
}
diff --git a/mullvad-update/src/client/fetch.rs b/mullvad-update/src/client/fetch.rs
index 706e3897f3..4a5a8c8d56 100644
--- a/mullvad-update/src/client/fetch.rs
+++ b/mullvad-update/src/client/fetch.rs
@@ -1,9 +1,11 @@
//! A downloader that supports HTTP range requests and resuming downloads
use std::{
+ error::Error,
path::Path,
pin::Pin,
task::{ready, Poll},
+ time::Duration,
};
use reqwest::header::{HeaderValue, CONTENT_LENGTH, RANGE};
@@ -12,7 +14,110 @@ use tokio::{
io::{self, AsyncSeek, AsyncSeekExt, AsyncWrite, AsyncWriteExt, BufWriter},
};
-use anyhow::Context;
+use thiserror::Error;
+
+/// Start value of the read timeout. This is doubled on each retry.
+const READ_TIMEOUT: Duration = Duration::from_secs(1);
+const CONNECT_TIMEOUT: Duration = Duration::from_secs(30);
+// Maximum number of retry attempts for timeouts
+const MAX_RETRY_ATTEMPTS: u32 = 4;
+
+/// Custom error type for download operations
+#[derive(Error, Debug)]
+pub enum DownloadError {
+ /// Failed to initialize client
+ #[error("Failed to initialize HTTP client")]
+ ClientInitialization(#[source] reqwest::Error),
+
+ /// Failed to get content length
+ #[error("Failed to request download")]
+ HeadRequest(#[source] reqwest::Error),
+
+ /// Server returned error status
+ #[error("Download failed: {0}")]
+ HttpStatus(reqwest::StatusCode),
+
+ /// Invalid content length header
+ #[error("Invalid content length header: {0}")]
+ InvalidContentLength(&'static str),
+
+ /// Failed to make range request
+ #[error("Failed to retrieve range")]
+ RangeRequest(#[source] reqwest::Error),
+
+ /// Failed to read chunk
+ #[error("Failed to read chunk")]
+ ChunkRead(#[source] reqwest::Error),
+
+ /// Failed to write chunk
+ #[error("Failed to write chunk")]
+ ChunkWrite(#[source] io::Error),
+
+ /// Failed to get stream position
+ #[error("Failed to get existing file size")]
+ StreamPosition(#[source] io::Error),
+
+ /// Failed to flush writer
+ #[error("Failed to flush writer")]
+ Flush(#[source] io::Error),
+
+ /// Size validation error
+ #[error("Size validation failed: {0}")]
+ SizeValidation(String),
+
+ /// File operation error
+ #[error("File operation failed: {0}")]
+ FileOperation(#[source] io::Error),
+
+ /// Other error
+ #[error("{0}")]
+ Other(&'static str),
+}
+
+impl DownloadError {
+ /// Checks if the error is caused by a timeout or network issue that can be retried
+ pub fn should_retry(&self) -> bool {
+ match self {
+ DownloadError::HeadRequest(e)
+ | DownloadError::RangeRequest(e)
+ | DownloadError::ChunkRead(e)
+ | DownloadError::ClientInitialization(e) => is_network_error(e),
+ DownloadError::HttpStatus(status) => {
+ // Retry server errors and timeout status
+ status.is_server_error() || *status == reqwest::StatusCode::REQUEST_TIMEOUT
+ }
+ // Don't retry other types of errors
+ _ => false,
+ }
+ }
+}
+
+/// Checks if the error is a network-related error that can be retried
+fn is_network_error(error: &reqwest::Error) -> bool {
+ // Retry on timeout errors
+ // Retry on connection errors (which often happen when switching networks)
+ // Retry on request errors (like "connection reset")
+ if error.is_timeout() || error.is_connect() || error.is_request() {
+ return true;
+ }
+
+ let mut error = error as &dyn Error;
+ loop {
+ if let Some(io_err) = error.downcast_ref::<std::io::Error>() {
+ // Check if the error is a timeout or connection error
+ if io_err.kind() == io::ErrorKind::TimedOut
+ || io_err.kind() == io::ErrorKind::ConnectionReset
+ {
+ return true;
+ }
+ }
+ if let Some(source) = error.source() {
+ error = source;
+ } else {
+ break false;
+ }
+ }
+}
/// Receiver of the current progress so far
pub trait ProgressUpdater: Send + 'static {
@@ -67,9 +172,26 @@ pub async fn get_to_file(
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
+ let file = create_or_append(file)
+ .await
+ .map_err(DownloadError::FileOperation)?;
+ let mut file = BufWriter::new(file);
+ let mut attempts = 0;
+ let mut read_timeout = READ_TIMEOUT;
+ while let Err(err) =
+ get_to_writer(&mut file, url, progress_updater, size_hint, read_timeout).await
+ {
+ if !err.should_retry() {
+ anyhow::bail!(err);
+ }
+ attempts += 1;
+ read_timeout *= 2;
+ if attempts >= MAX_RETRY_ATTEMPTS {
+ anyhow::bail!("Max retry attempts reached: {err}");
+ }
+ log::warn!("Download failed: {err}. Retrying in with timeout: {read_timeout:?}");
+ }
+ Ok(())
}
/// Download `url` to `writer`.
@@ -82,41 +204,59 @@ pub async fn get_to_writer(
url: &str,
progress_updater: &mut impl ProgressUpdater,
size_hint: SizeHint,
-) -> anyhow::Result<()> {
- let client = reqwest::Client::new();
+ read_timeout: Duration,
+) -> Result<(), DownloadError> {
+ // Create a new client for each download attempt to prevent stale connections
+ let client = reqwest::Client::builder()
+ .read_timeout(read_timeout)
+ .connect_timeout(CONNECT_TIMEOUT)
+ .build()
+ .map_err(DownloadError::ClientInitialization)?;
progress_updater.set_url(url);
- progress_updater.set_progress(0.);
// Fetch content length first
- let response = client.head(url).send().await.context("HEAD failed")?;
+ let response = client
+ .head(url)
+ .send()
+ .await
+ .map_err(DownloadError::HeadRequest)?;
+
if !response.status().is_success() {
- return response
- .error_for_status()
- .map(|_| ())
- .context("Download failed");
+ return Err(DownloadError::HttpStatus(response.status()));
}
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)?;
+ .ok_or_else(|| DownloadError::InvalidContentLength("Missing file size"))?;
+
+ let total_size: usize = total_size
+ .to_str()
+ .map_err(|_| DownloadError::InvalidContentLength("Invalid content length header"))?
+ .parse()
+ .map_err(|_| DownloadError::InvalidContentLength("Invalid size format"))?;
+
+ match size_hint.check_size(total_size) {
+ Ok(_) => {}
+ Err(e) => return Err(DownloadError::SizeValidation(e.to_string())),
+ }
let already_fetched_bytes = writer
.stream_position()
.await
- .context("failed to get existing file size")?
+ .map_err(DownloadError::StreamPosition)?
.try_into()
- .context("invalid size")?;
+ .map_err(|_| DownloadError::Other("Invalid file position"))?;
+ progress_updater.set_progress(already_fetched_bytes as f32 / total_size as f32);
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");
+ return Err(DownloadError::SizeValidation(
+ "Found existing file that was larger".to_string(),
+ ));
}
// Fetch content, one range at a time
@@ -133,32 +273,32 @@ pub async fn get_to_writer(
.header(RANGE, range)
.send()
.await
- .context("Failed to retrieve range")?;
+ .map_err(DownloadError::RangeRequest)?;
+
let status = response.status();
if !status.is_success() {
- return response
- .error_for_status()
- .map(|_| ())
- .context("Download failed");
+ return Err(DownloadError::HttpStatus(status));
}
let mut bytes_read = 0;
- while let Some(chunk) = response.chunk().await.context("Failed to read chunk")? {
+ while let Some(chunk) = response.chunk().await.map_err(DownloadError::ChunkRead)? {
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");
+ return Err(DownloadError::SizeValidation(
+ "Server returned more than requested bytes".to_string(),
+ ));
}
writer
.write_all(&chunk)
.await
- .context("Failed to write chunk")?;
+ .map_err(DownloadError::ChunkWrite)?;
}
}
- writer.shutdown().await.context("Failed to flush")?;
+ writer.shutdown().await.map_err(DownloadError::Flush)?;
Ok(())
}
@@ -261,6 +401,7 @@ impl<PU: ProgressUpdater, Writer: AsyncWrite + Unpin> AsyncWrite
mod test {
use std::io::Cursor;
+ use anyhow::Context;
use async_tempfile::TempDir;
use rand::RngCore;
use tokio::{fs, io::AsyncWriteExt};
@@ -344,6 +485,7 @@ mod test {
&file_url,
&mut progress_updater,
SizeHint::Exact(file_data.len()),
+ READ_TIMEOUT,
)
.await
.context("Complete download failed")?;
@@ -378,6 +520,7 @@ mod test {
&file_url,
&mut progress_updater,
SizeHint::Exact(file_data.len()),
+ READ_TIMEOUT,
)
.await
.expect_err("Expected interrupted download");
@@ -408,6 +551,7 @@ mod test {
&file_url,
&mut progress_updater,
SizeHint::Exact(file_data.len()),
+ READ_TIMEOUT,
)
.await
.context("Partial download failed")?;
@@ -468,6 +612,7 @@ mod test {
&file_url,
&mut FakeProgressUpdater::default(),
SizeHint::Exact(1),
+ READ_TIMEOUT,
)
.await
.expect_err("Reject unexpected content length");
@@ -492,6 +637,7 @@ mod test {
&file_url,
&mut FakeProgressUpdater::default(),
SizeHint::Exact(file_data.len()),
+ READ_TIMEOUT,
)
.await
.expect_err("Reject unexpected chunk sizes");
diff --git a/mullvad-update/src/version.rs b/mullvad-update/src/version.rs
index 4b94e66b42..a6bad44b22 100644
--- a/mullvad-update/src/version.rs
+++ b/mullvad-update/src/version.rs
@@ -11,6 +11,9 @@ use mullvad_version::PreStableType;
use crate::format;
+/// Lowest version to accept using 'verify'
+pub const MIN_VERIFY_METADATA_VERSION: usize = 0;
+
/// Query type for [VersionInfo]
#[derive(Debug)]
pub struct VersionParameters {
@@ -35,9 +38,8 @@ 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))]
+/// Version update information derived from querying a [format::Response] and filtering with [VersionParameters]
+#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq)]
pub struct VersionInfo {
/// Stable version info
pub stable: Version,
@@ -47,8 +49,7 @@ pub struct VersionInfo {
}
/// Contains information about a version for the current target
-#[derive(Debug, Clone)]
-#[cfg_attr(test, derive(serde::Serialize))]
+#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq)]
pub struct Version {
/// Version
pub version: mullvad_version::Version,
diff --git a/mullvad-version/build.rs b/mullvad-version/build.rs
index b2cd98db77..4fb75f81c8 100644
--- a/mullvad-version/build.rs
+++ b/mullvad-version/build.rs
@@ -11,28 +11,41 @@ const GIT_HASH_DEV_SUFFIX_LEN: usize = 6;
const ANDROID_VERSION_FILE_PATH: &str = "../dist-assets/android-version-name.txt";
const DESKTOP_VERSION_FILE_PATH: &str = "../dist-assets/desktop-product-version.txt";
-#[derive(Debug, Copy, Clone)]
+#[derive(Debug, Copy, Clone, PartialEq)]
enum Target {
Android,
Desktop,
}
impl Target {
- pub fn current_target() -> Self {
+ fn current_target() -> Result<Self, String> {
println!("cargo:rerun-if-env-changed=CARGO_CFG_TARGET_OS");
match env::var("CARGO_CFG_TARGET_OS")
.expect("CARGO_CFG_TARGET_OS should be set")
.as_str()
{
- "android" => Self::Android,
- "linux" | "windows" | "macos" => Self::Desktop,
- target_os => panic!("Unsupported target OS: {target_os}"),
+ "android" => Ok(Self::Android),
+ "linux" | "windows" | "macos" => Ok(Self::Desktop),
+ other => Err(other.to_owned()),
}
}
}
fn main() {
- let product_version = get_product_version(Target::current_target());
+ // Mark "has_version" as a conditional configuration flag
+ println!("cargo::rustc-check-cfg=cfg(has_version)");
+
+ let target = match Target::current_target() {
+ Ok(target) => target,
+ Err(other) => {
+ eprintln!("No version available for target {other}");
+ return;
+ }
+ };
+
+ println!(r#"cargo::rustc-cfg=has_version"#);
+
+ let product_version = get_product_version(target);
let android_product_version = get_product_version(Target::Android);
let out_dir = PathBuf::from(env::var_os("OUT_DIR").unwrap());
diff --git a/mullvad-version/src/lib.rs b/mullvad-version/src/lib.rs
index 6dd411f1be..e88cf490e1 100644
--- a/mullvad-version/src/lib.rs
+++ b/mullvad-version/src/lib.rs
@@ -6,6 +6,7 @@ use std::sync::LazyLock;
use regex_lite::Regex;
/// The Mullvad VPN app product version
+#[cfg(has_version)]
pub const VERSION: &str = include_str!(concat!(env!("OUT_DIR"), "/product-version.txt"));
#[derive(Debug, Clone, PartialEq)]
diff --git a/mullvad-version/src/main.rs b/mullvad-version/src/main.rs
index 9d5cf6d152..68906e4360 100644
--- a/mullvad-version/src/main.rs
+++ b/mullvad-version/src/main.rs
@@ -1,134 +1,147 @@
-use mullvad_version::{PreStableType, Version};
-use std::env::VarError;
-use std::{env, process::exit};
+#[cfg(has_version)]
+mod inner {
+ use mullvad_version::{PreStableType, Version};
+ use std::env::VarError;
+ use std::{env, process::exit};
-const ANDROID_VERSION: &str = include_str!(concat!(env!("OUT_DIR"), "/android-version-name.txt"));
+ const ANDROID_VERSION: &str =
+ include_str!(concat!(env!("OUT_DIR"), "/android-version-name.txt"));
-fn main() {
- let android_version_env = env::var("ANDROID_VERSION");
- if matches!(android_version_env, Err(VarError::NotUnicode(_))) {
- eprintln!("ANDROID_VERSION is not valid unicode.");
- exit(1);
- }
- let android_version = android_version_env.unwrap_or(ANDROID_VERSION.to_string());
-
- let command = env::args().nth(1);
- match command.as_deref() {
- None => println!("{}", mullvad_version::VERSION),
- Some("semver") => println!("{}", to_semver(mullvad_version::VERSION)),
- Some("version.h") => println!("{}", to_windows_h_format(mullvad_version::VERSION)),
- Some("versionName") => println!("{android_version}"),
- Some("versionCode") => println!("{}", to_android_version_code(&android_version)),
- Some(command) => {
- eprintln!("Unknown command: {command}");
+ pub fn main() {
+ let android_version_env = env::var("ANDROID_VERSION");
+ if matches!(android_version_env, Err(VarError::NotUnicode(_))) {
+ eprintln!("ANDROID_VERSION is not valid unicode.");
exit(1);
}
+ let android_version = android_version_env.unwrap_or(ANDROID_VERSION.to_string());
+
+ let command = env::args().nth(1);
+ match command.as_deref() {
+ None => println!("{}", mullvad_version::VERSION),
+ Some("semver") => println!("{}", to_semver(mullvad_version::VERSION)),
+ Some("version.h") => println!("{}", to_windows_h_format(mullvad_version::VERSION)),
+ Some("versionName") => println!("{android_version}"),
+ Some("versionCode") => println!("{}", to_android_version_code(&android_version)),
+ Some(command) => {
+ eprintln!("Unknown command: {command}");
+ exit(1);
+ }
+ }
}
-}
-/// Takes a version without a patch number and adds the patch (set to zero).
-///
-/// Converts `x.y[-z]` into `x.y.0[-z]` to make the version semver compatible.
-fn to_semver(version: &str) -> String {
- let mut parts = version.splitn(2, '-');
+ /// Takes a version without a patch number and adds the patch (set to zero).
+ ///
+ /// Converts `x.y[-z]` into `x.y.0[-z]` to make the version semver compatible.
+ fn to_semver(version: &str) -> String {
+ let mut parts = version.splitn(2, '-');
- let version = parts.next().expect("Year component");
- let remainder = parts.next().map(|s| format!("-{s}")).unwrap_or_default();
- assert_eq!(parts.next(), None);
+ let version = parts.next().expect("Year component");
+ let remainder = parts.next().map(|s| format!("-{s}")).unwrap_or_default();
+ assert_eq!(parts.next(), None);
- format!("{version}.0{remainder}")
-}
+ format!("{version}.0{remainder}")
+ }
-/// Takes a version in the normal Mullvad VPN app version format and returns the Android
-/// `versionCode` formatted version.
-///
-/// The format of the code is: YYVVXZZZ
-/// Last two digits of the year (major)---------^^
-/// Incrementing version (minor)------------------^^
-/// Build type (0=alpha, 1=beta, 9=stable/dev)------^
-/// Build number (000 if stable/dev)-----------------^^^
-///
-/// # Examples
-///
-/// Version: 2021.1-alpha1
-/// versionCode: 21010001
-///
-/// Version: 2021.34-beta5
-/// versionCode: 21341005
-///
-/// Version: 2021.34
-/// versionCode: 21349000
-///
-/// Version: 2021.34-dev
-/// versionCode: 21349000
-fn to_android_version_code(version: &str) -> String {
- let version: Version = version.parse().unwrap();
+ /// Takes a version in the normal Mullvad VPN app version format and returns the Android
+ /// `versionCode` formatted version.
+ ///
+ /// The format of the code is: YYVVXZZZ
+ /// Last two digits of the year (major)---------^^
+ /// Incrementing version (minor)------------------^^
+ /// Build type (0=alpha, 1=beta, 9=stable/dev)------^
+ /// Build number (000 if stable/dev)-----------------^^^
+ ///
+ /// # Examples
+ ///
+ /// Version: 2021.1-alpha1
+ /// versionCode: 21010001
+ ///
+ /// Version: 2021.34-beta5
+ /// versionCode: 21341005
+ ///
+ /// Version: 2021.34
+ /// versionCode: 21349000
+ ///
+ /// Version: 2021.34-dev
+ /// versionCode: 21349000
+ fn to_android_version_code(version: &str) -> String {
+ let version: Version = version.parse().unwrap();
- let (build_type, build_number) = if version.dev.is_some() {
- ("9", "000".to_string())
- } else {
- match &version.pre_stable {
- Some(PreStableType::Alpha(v)) => ("0", v.to_string()),
- Some(PreStableType::Beta(v)) => ("1", v.to_string()),
- // Stable version
- None => ("9", "000".to_string()),
- }
- };
+ let (build_type, build_number) = if version.dev.is_some() {
+ ("9", "000".to_string())
+ } else {
+ match &version.pre_stable {
+ Some(PreStableType::Alpha(v)) => ("0", v.to_string()),
+ Some(PreStableType::Beta(v)) => ("1", v.to_string()),
+ // Stable version
+ None => ("9", "000".to_string()),
+ }
+ };
- let year_last_two_digits = version.year % 100;
+ let year_last_two_digits = version.year % 100;
- format!(
- "{}{:0>2}{}{:0>3}",
- year_last_two_digits, version.incremental, build_type, build_number,
- )
-}
+ format!(
+ "{}{:0>2}{}{:0>3}",
+ year_last_two_digits, version.incremental, build_type, build_number,
+ )
+ }
-fn to_windows_h_format(version_str: &str) -> String {
- let version = version_str.parse().unwrap();
+ fn to_windows_h_format(version_str: &str) -> String {
+ let version = version_str.parse().unwrap();
- let Version {
- year, incremental, ..
- } = version;
+ let Version {
+ year, incremental, ..
+ } = version;
- format!(
- "#define MAJOR_VERSION {year}
-#define MINOR_VERSION {incremental}
-#define PATCH_VERSION 0
-#define PRODUCT_VERSION \"{version_str}\""
- )
-}
+ format!(
+ "#define MAJOR_VERSION {year}
+ #define MINOR_VERSION {incremental}
+ #define PATCH_VERSION 0
+ #define PRODUCT_VERSION \"{version_str}\""
+ )
+ }
-#[cfg(test)]
-mod tests {
- use super::*;
+ #[cfg(test)]
+ mod tests {
+ use super::*;
- #[test]
- fn test_version_code() {
- assert_eq!("21349000", to_android_version_code("2021.34"));
- }
+ #[test]
+ fn test_version_code() {
+ assert_eq!("21349000", to_android_version_code("2021.34"));
+ }
- #[test]
- fn test_version_code_alpha() {
- assert_eq!("21010001", to_android_version_code("2021.1-alpha1"));
- }
+ #[test]
+ fn test_version_code_alpha() {
+ assert_eq!("21010001", to_android_version_code("2021.1-alpha1"));
+ }
- #[test]
- fn test_version_code_beta() {
- assert_eq!("21341005", to_android_version_code("2021.34-beta5"));
- }
+ #[test]
+ fn test_version_code_beta() {
+ assert_eq!("21341005", to_android_version_code("2021.34-beta5"));
+ }
- #[test]
- fn test_version_code_dev() {
- assert_eq!("21349000", to_android_version_code("2021.34-dev-be846a5f0"));
- }
+ #[test]
+ fn test_version_code_dev() {
+ assert_eq!("21349000", to_android_version_code("2021.34-dev-be846a5f0"));
+ }
- #[test]
- fn test_windows_version_h() {
- let version_h = to_windows_h_format("2025.4-beta2-dev-abcdef");
- let expected_version_h = "#define MAJOR_VERSION 2025
-#define MINOR_VERSION 4
-#define PATCH_VERSION 0
-#define PRODUCT_VERSION \"2025.4-beta2-dev-abcdef\"";
- assert_eq!(expected_version_h, version_h);
+ #[test]
+ fn test_windows_version_h() {
+ let version_h = to_windows_h_format("2025.4-beta2-dev-abcdef");
+ let expected_version_h = "#define MAJOR_VERSION 2025
+ #define MINOR_VERSION 4
+ #define PATCH_VERSION 0
+ #define PRODUCT_VERSION \"2025.4-beta2-dev-abcdef\"";
+ assert_eq!(expected_version_h, version_h);
+ }
}
}
+
+#[cfg(not(has_version))]
+mod inner {
+ pub fn main() {}
+}
+
+fn main() {
+ inner::main();
+}
diff --git a/test/Cargo.lock b/test/Cargo.lock
index cdc8297aeb..1ccf04e2e7 100644
--- a/test/Cargo.lock
+++ b/test/Cargo.lock
@@ -2200,6 +2200,7 @@ dependencies = [
"intersection-derive",
"ipnetwork",
"log",
+ "mullvad-version",
"regex",
"serde",
"talpid-types",
@@ -2216,6 +2217,7 @@ dependencies = [
"ed25519-dalek",
"hex",
"json-canon",
+ "log",
"mullvad-version",
"reqwest",
"serde",