summaryrefslogtreecommitdiffhomepage
path: root/gui/src
diff options
context:
space:
mode:
authorOskar <oskar@mullvad.net>2024-11-05 07:57:08 +0100
committerOskar <oskar@mullvad.net>2024-11-14 16:43:18 +0100
commit84f14d79c4f0dde73337820ec94ba8ff928a3797 (patch)
treece468658e5ba7b0a74950c7ad1b09b3a4d00520b /gui/src
parente3ce0eb5cd0610dbff6ec98cb8cb388415c74bf6 (diff)
downloadmullvadvpn-84f14d79c4f0dde73337820ec94ba8ff928a3797.tar.xz
mullvadvpn-84f14d79c4f0dde73337820ec94ba8ff928a3797.zip
Move gui directory to desktop/packages/mullvad-vpn
Diffstat (limited to 'gui/src')
-rw-r--r--gui/src/config.json48
-rw-r--r--gui/src/main/account-data-cache.ts190
-rw-r--r--gui/src/main/account.ts239
-rw-r--r--gui/src/main/autostart.ts82
-rw-r--r--gui/src/main/changelog.ts66
-rw-r--r--gui/src/main/command-line-options.ts61
-rw-r--r--gui/src/main/daemon-rpc.ts614
-rw-r--r--gui/src/main/default-settings.ts115
-rw-r--r--gui/src/main/expectation.ts23
-rw-r--r--gui/src/main/grpc-client.ts246
-rw-r--r--gui/src/main/grpc-type-convertions.ts1281
-rw-r--r--gui/src/main/gui-settings.ts203
-rw-r--r--gui/src/main/index.ts1154
-rw-r--r--gui/src/main/ipc-event-channel.ts16
-rw-r--r--gui/src/main/keyframe-animation.ts144
-rw-r--r--gui/src/main/linux-desktop-entry.ts335
-rw-r--r--gui/src/main/linux-split-tunneling.ts166
-rw-r--r--gui/src/main/load-translations.ts75
-rw-r--r--gui/src/main/logging.ts119
-rw-r--r--gui/src/main/macos-split-tunneling.ts339
-rw-r--r--gui/src/main/notification-controller.ts337
-rw-r--r--gui/src/main/platform-version.ts26
-rw-r--r--gui/src/main/problem-report.ts72
-rw-r--r--gui/src/main/proc.ts25
-rw-r--r--gui/src/main/reconnection-backoff.ts22
-rw-r--r--gui/src/main/settings.ts235
-rw-r--r--gui/src/main/tray-icon-controller.ts207
-rw-r--r--gui/src/main/tunnel-state.ts116
-rw-r--r--gui/src/main/user-interface.ts702
-rw-r--r--gui/src/main/version.ts126
-rw-r--r--gui/src/main/window-controller.ts333
-rw-r--r--gui/src/main/windows-pe-parser.ts479
-rw-r--r--gui/src/main/windows-split-tunneling.ts680
-rw-r--r--gui/src/renderer/app.tsx1090
-rw-r--r--gui/src/renderer/components/Accordion.tsx124
-rw-r--r--gui/src/renderer/components/Account.tsx160
-rw-r--r--gui/src/renderer/components/AccountNumberLabel.tsx20
-rw-r--r--gui/src/renderer/components/AccountStyles.tsx50
-rw-r--r--gui/src/renderer/components/ApiAccessMethods.tsx369
-rw-r--r--gui/src/renderer/components/AppButton.tsx215
-rw-r--r--gui/src/renderer/components/AppButtonStyles.tsx42
-rw-r--r--gui/src/renderer/components/AppRouter.tsx111
-rw-r--r--gui/src/renderer/components/AriaGroup.tsx165
-rw-r--r--gui/src/renderer/components/Changelog.tsx78
-rw-r--r--gui/src/renderer/components/ChevronButton.tsx36
-rw-r--r--gui/src/renderer/components/ClipboardLabel.tsx105
-rw-r--r--gui/src/renderer/components/ContextMenu.tsx223
-rw-r--r--gui/src/renderer/components/CustomDnsSettings.tsx450
-rw-r--r--gui/src/renderer/components/CustomDnsSettingsStyles.tsx62
-rw-r--r--gui/src/renderer/components/CustomScrollbars.tsx564
-rw-r--r--gui/src/renderer/components/DaitaSettings.tsx270
-rw-r--r--gui/src/renderer/components/Debug.tsx90
-rw-r--r--gui/src/renderer/components/DeviceInfoButton.tsx57
-rw-r--r--gui/src/renderer/components/DeviceRevokedView.tsx96
-rw-r--r--gui/src/renderer/components/EditApiAccessMethod.tsx249
-rw-r--r--gui/src/renderer/components/EditCustomBridge.tsx120
-rw-r--r--gui/src/renderer/components/ErrorBoundary.tsx50
-rw-r--r--gui/src/renderer/components/ErrorView.tsx70
-rw-r--r--gui/src/renderer/components/ExpiredAccountAddTime.tsx290
-rw-r--r--gui/src/renderer/components/ExpiredAccountErrorView.tsx341
-rw-r--r--gui/src/renderer/components/ExpiredAccountErrorViewStyles.tsx79
-rw-r--r--gui/src/renderer/components/Filter.tsx382
-rw-r--r--gui/src/renderer/components/Focus.tsx76
-rw-r--r--gui/src/renderer/components/FormattableTextInput.tsx135
-rw-r--r--gui/src/renderer/components/HeaderBar.tsx253
-rw-r--r--gui/src/renderer/components/ImageView.tsx70
-rw-r--r--gui/src/renderer/components/InfoButton.tsx73
-rw-r--r--gui/src/renderer/components/KeyboardNavigation.tsx139
-rw-r--r--gui/src/renderer/components/Lang.tsx14
-rw-r--r--gui/src/renderer/components/Launch.tsx131
-rw-r--r--gui/src/renderer/components/Layout.tsx38
-rw-r--r--gui/src/renderer/components/List.tsx190
-rw-r--r--gui/src/renderer/components/Login.tsx530
-rw-r--r--gui/src/renderer/components/LoginStyles.tsx193
-rw-r--r--gui/src/renderer/components/MacOsScrollbarDetection.tsx45
-rw-r--r--gui/src/renderer/components/Map.tsx205
-rw-r--r--gui/src/renderer/components/Marquee.tsx99
-rw-r--r--gui/src/renderer/components/Modal.tsx376
-rw-r--r--gui/src/renderer/components/MultiButton.tsx43
-rw-r--r--gui/src/renderer/components/MultihopSettings.tsx133
-rw-r--r--gui/src/renderer/components/NavigationBar.tsx230
-rw-r--r--gui/src/renderer/components/NavigationBarStyles.tsx57
-rw-r--r--gui/src/renderer/components/NotificationArea.tsx230
-rw-r--r--gui/src/renderer/components/NotificationBanner.tsx180
-rw-r--r--gui/src/renderer/components/OpenVpnSettings.tsx511
-rw-r--r--gui/src/renderer/components/PageSlider.tsx243
-rw-r--r--gui/src/renderer/components/ProblemReport.tsx483
-rw-r--r--gui/src/renderer/components/ProblemReportStyles.tsx77
-rw-r--r--gui/src/renderer/components/ProxyForm.tsx559
-rw-r--r--gui/src/renderer/components/RedeemVoucher.tsx285
-rw-r--r--gui/src/renderer/components/RedeemVoucherStyles.tsx70
-rw-r--r--gui/src/renderer/components/RelayStatusIndicator.tsx40
-rw-r--r--gui/src/renderer/components/SearchBar.tsx118
-rw-r--r--gui/src/renderer/components/SecuredLabel.tsx80
-rw-r--r--gui/src/renderer/components/SelectLanguage.tsx103
-rw-r--r--gui/src/renderer/components/Settings.tsx290
-rw-r--r--gui/src/renderer/components/SettingsHeader.tsx44
-rw-r--r--gui/src/renderer/components/SettingsImport.tsx308
-rw-r--r--gui/src/renderer/components/SettingsStyles.tsx31
-rw-r--r--gui/src/renderer/components/SettingsTextImport.tsx82
-rw-r--r--gui/src/renderer/components/Shadowsocks.tsx137
-rw-r--r--gui/src/renderer/components/SimpleInput.tsx81
-rw-r--r--gui/src/renderer/components/SmallButton.tsx124
-rw-r--r--gui/src/renderer/components/SplitTunnelingSettings.tsx660
-rw-r--r--gui/src/renderer/components/SplitTunnelingSettingsStyles.tsx130
-rw-r--r--gui/src/renderer/components/Support.tsx155
-rw-r--r--gui/src/renderer/components/Switch.tsx72
-rw-r--r--gui/src/renderer/components/TooManyDevices.tsx364
-rw-r--r--gui/src/renderer/components/TransitionContainer.tsx381
-rw-r--r--gui/src/renderer/components/UdpOverTcp.tsx126
-rw-r--r--gui/src/renderer/components/UserInterfaceSettings.tsx272
-rw-r--r--gui/src/renderer/components/VpnSettings.tsx829
-rw-r--r--gui/src/renderer/components/WireguardSettings.tsx490
-rw-r--r--gui/src/renderer/components/YellowLabel.tsx18
-rw-r--r--gui/src/renderer/components/cell/CellButton.tsx67
-rw-r--r--gui/src/renderer/components/cell/Container.tsx26
-rw-r--r--gui/src/renderer/components/cell/Footer.tsx16
-rw-r--r--gui/src/renderer/components/cell/Group.tsx14
-rw-r--r--gui/src/renderer/components/cell/Input.tsx409
-rw-r--r--gui/src/renderer/components/cell/Label.tsx109
-rw-r--r--gui/src/renderer/components/cell/Row.tsx25
-rw-r--r--gui/src/renderer/components/cell/Section.tsx95
-rw-r--r--gui/src/renderer/components/cell/Selector.tsx387
-rw-r--r--gui/src/renderer/components/cell/SettingsForm.tsx77
-rw-r--r--gui/src/renderer/components/cell/SettingsGroup.tsx99
-rw-r--r--gui/src/renderer/components/cell/SettingsRadioGroup.tsx126
-rw-r--r--gui/src/renderer/components/cell/SettingsRow.tsx139
-rw-r--r--gui/src/renderer/components/cell/SettingsSelect.tsx255
-rw-r--r--gui/src/renderer/components/cell/SettingsTextInput.tsx129
-rw-r--r--gui/src/renderer/components/cell/SideButton.tsx25
-rw-r--r--gui/src/renderer/components/cell/index.ts9
-rw-r--r--gui/src/renderer/components/cell/styles.ts13
-rw-r--r--gui/src/renderer/components/common-styles.ts71
-rw-r--r--gui/src/renderer/components/main-view/ConnectionActionButton.tsx63
-rw-r--r--gui/src/renderer/components/main-view/ConnectionDetails.tsx202
-rw-r--r--gui/src/renderer/components/main-view/ConnectionPanel.tsx122
-rw-r--r--gui/src/renderer/components/main-view/ConnectionPanelChevron.tsx40
-rw-r--r--gui/src/renderer/components/main-view/ConnectionStatus.tsx58
-rw-r--r--gui/src/renderer/components/main-view/FeatureIndicators.tsx288
-rw-r--r--gui/src/renderer/components/main-view/Hostname.tsx69
-rw-r--r--gui/src/renderer/components/main-view/Location.tsx41
-rw-r--r--gui/src/renderer/components/main-view/MainView.tsx67
-rw-r--r--gui/src/renderer/components/main-view/SelectLocationButton.tsx143
-rw-r--r--gui/src/renderer/components/main-view/styles.ts7
-rw-r--r--gui/src/renderer/components/select-location/CombinedLocationList.tsx39
-rw-r--r--gui/src/renderer/components/select-location/CustomListDialogs.tsx260
-rw-r--r--gui/src/renderer/components/select-location/CustomLists.tsx251
-rw-r--r--gui/src/renderer/components/select-location/LocationRow.tsx296
-rw-r--r--gui/src/renderer/components/select-location/LocationRowStyles.tsx117
-rw-r--r--gui/src/renderer/components/select-location/RelayListContext.tsx383
-rw-r--r--gui/src/renderer/components/select-location/RelayLocationList.tsx65
-rw-r--r--gui/src/renderer/components/select-location/ScopeBar.tsx73
-rw-r--r--gui/src/renderer/components/select-location/ScrollPositionContext.tsx118
-rw-r--r--gui/src/renderer/components/select-location/SelectLocation.tsx450
-rw-r--r--gui/src/renderer/components/select-location/SelectLocationContainer.tsx44
-rw-r--r--gui/src/renderer/components/select-location/SelectLocationStyles.tsx73
-rw-r--r--gui/src/renderer/components/select-location/SpacePreAllocationView.tsx30
-rw-r--r--gui/src/renderer/components/select-location/SpecialLocationList.tsx157
-rw-r--r--gui/src/renderer/components/select-location/custom-list-helpers.ts188
-rw-r--r--gui/src/renderer/components/select-location/select-location-helpers.ts221
-rw-r--r--gui/src/renderer/components/select-location/select-location-hooks.ts148
-rw-r--r--gui/src/renderer/components/select-location/select-location-types.ts119
-rw-r--r--gui/src/renderer/context.tsx25
-rw-r--r--gui/src/renderer/index.html21
-rw-r--r--gui/src/renderer/index.ts8
-rw-r--r--gui/src/renderer/lib/3dmap.ts835
-rw-r--r--gui/src/renderer/lib/account.ts5
-rw-r--r--gui/src/renderer/lib/actionsHook.ts12
-rw-r--r--gui/src/renderer/lib/api-access-methods.ts93
-rw-r--r--gui/src/renderer/lib/constraint-updater.ts158
-rw-r--r--gui/src/renderer/lib/filter-locations.ts216
-rw-r--r--gui/src/renderer/lib/history.tsx264
-rw-r--r--gui/src/renderer/lib/html-formatter.tsx18
-rw-r--r--gui/src/renderer/lib/ip.ts281
-rw-r--r--gui/src/renderer/lib/ipc-event-channel.ts6
-rw-r--r--gui/src/renderer/lib/load-translations.ts23
-rw-r--r--gui/src/renderer/lib/logging.ts9
-rw-r--r--gui/src/renderer/lib/relay-settings-hooks.ts25
-rw-r--r--gui/src/renderer/lib/routeHelpers.ts24
-rw-r--r--gui/src/renderer/lib/routes.ts34
-rw-r--r--gui/src/renderer/lib/styles.ts9
-rw-r--r--gui/src/renderer/lib/utility-hooks.ts76
-rw-r--r--gui/src/renderer/lib/will-exit.tsx13
-rw-r--r--gui/src/renderer/preload.ts15
-rw-r--r--gui/src/renderer/redux/account/actions.ts225
-rw-r--r--gui/src/renderer/redux/account/reducers.ts154
-rw-r--r--gui/src/renderer/redux/connection/actions.ts118
-rw-r--r--gui/src/renderer/redux/connection/reducers.ts98
-rw-r--r--gui/src/renderer/redux/settings-import/actions.ts40
-rw-r--r--gui/src/renderer/redux/settings-import/reducers.ts41
-rw-r--r--gui/src/renderer/redux/settings/actions.ts353
-rw-r--r--gui/src/renderer/redux/settings/reducers.ts358
-rw-r--r--gui/src/renderer/redux/store.ts93
-rw-r--r--gui/src/renderer/redux/support/actions.ts30
-rw-r--r--gui/src/renderer/redux/support/reducers.ts35
-rw-r--r--gui/src/renderer/redux/userinterface/actions.ts176
-rw-r--r--gui/src/renderer/redux/userinterface/reducers.ts94
-rw-r--r--gui/src/renderer/redux/version/actions.ts37
-rw-r--r--gui/src/renderer/redux/version/reducers.ts45
-rw-r--r--gui/src/shared/account-expiry.ts28
-rw-r--r--gui/src/shared/application-types.ts60
-rw-r--r--gui/src/shared/connect-helper.ts34
-rw-r--r--gui/src/shared/daemon-rpc-types.ts626
-rw-r--r--gui/src/shared/date-helper.ts125
-rw-r--r--gui/src/shared/gettext.ts32
-rw-r--r--gui/src/shared/gui-settings-state.ts37
-rw-r--r--gui/src/shared/ipc-helpers.ts199
-rw-r--r--gui/src/shared/ipc-schema.ts257
-rw-r--r--gui/src/shared/ipc-types.ts30
-rw-r--r--gui/src/shared/localization-contexts.ts40
-rw-r--r--gui/src/shared/logging-types.ts17
-rw-r--r--gui/src/shared/logging.ts108
-rw-r--r--gui/src/shared/notifications/account-expired.ts42
-rw-r--r--gui/src/shared/notifications/block-when-disconnected.ts66
-rw-r--r--gui/src/shared/notifications/close-to-account-expiry.ts72
-rw-r--r--gui/src/shared/notifications/connected.ts42
-rw-r--r--gui/src/shared/notifications/connecting.ts60
-rw-r--r--gui/src/shared/notifications/daemon-disconnected.ts22
-rw-r--r--gui/src/shared/notifications/disconnected.ts28
-rw-r--r--gui/src/shared/notifications/error.ts338
-rw-r--r--gui/src/shared/notifications/inconsistent-version.ts39
-rw-r--r--gui/src/shared/notifications/new-device.ts32
-rw-r--r--gui/src/shared/notifications/notification.ts86
-rw-r--r--gui/src/shared/notifications/reconnecting.ts35
-rw-r--r--gui/src/shared/notifications/unsupported-version.ts62
-rw-r--r--gui/src/shared/notifications/update-available.ts96
-rw-r--r--gui/src/shared/scheduler.ts37
-rw-r--r--gui/src/shared/string-helpers.ts11
-rw-r--r--gui/src/shared/utils.ts5
-rw-r--r--gui/src/shared/version.ts22
230 files changed, 0 insertions, 38415 deletions
diff --git a/gui/src/config.json b/gui/src/config.json
deleted file mode 100644
index a77c47de45..0000000000
--- a/gui/src/config.json
+++ /dev/null
@@ -1,48 +0,0 @@
-{
- "supportEmail": "support@mullvadvpn.net",
- "links": {
- "purchase": "https://mullvad.net/account/",
- "faq": "https://mullvad.net/help/tag/mullvad-app/",
- "privacyGuide": "https://mullvad.net/help/first-steps-towards-online-privacy/",
- "download": "https://mullvad.net/download/vpn/"
- },
- "colors": {
- "darkerBlue": "rgba(25, 38, 56, 0.95)",
- "darkBlue": "rgb(25, 46, 69)",
- "blue": "rgb(41, 77, 115)",
- "darkGreen": "rgb(32, 84, 37)",
- "green": "rgb(68, 173, 77)",
- "red": "rgb(227, 64, 57)",
- "darkYellow": "rgb(142, 78, 19)",
- "yellow": "rgb(255, 213, 36)",
- "black": "rgb(0, 0, 0)",
- "white": "rgb(255, 255, 255)",
- "white90": "rgba(255, 255, 255, 0.9)",
- "white80": "rgba(255, 255, 255, 0.8)",
- "white60": "rgba(255, 255, 255, 0.6)",
- "white50": "rgba(255, 255, 255, 0.5)",
- "white40": "rgba(255, 255, 255, 0.4)",
- "white20": "rgba(255, 255, 255, 0.2)",
- "white10": "rgba(255, 255, 255, 0.1)",
- "blue10": "rgba(41, 77, 115, 0.1)",
- "blue20": "rgba(41, 77, 115, 0.2)",
- "blue40": "rgba(41, 77, 115, 0.4)",
- "blue50": "rgba(41, 77, 115, 0.5)",
- "blue60": "rgba(41, 77, 115, 0.6)",
- "blue80": "rgba(41, 77, 115, 0.8)",
- "red95": "rgba(227, 64, 57, 0.95)",
- "red80": "rgba(227, 64, 57, 0.8)",
- "red60": "rgba(227, 64, 57, 0.6)",
- "red40": "rgba(227, 64, 57, 0.4)",
- "red45": "rgba(227, 64, 57, 0.45)",
- "green90": "rgba(68, 173, 77, 0.9)",
- "green40": "rgba(68, 173, 77, 0.4)"
- },
- "strings": {
- "wireguard": "WireGuard",
- "openvpn": "OpenVPN",
- "splitTunneling": "Split tunneling",
- "daita": "DAITA",
- "daitaFull": "Defence against AI-guided Traffic Analysis"
- }
-}
diff --git a/gui/src/main/account-data-cache.ts b/gui/src/main/account-data-cache.ts
deleted file mode 100644
index a36d48b2ce..0000000000
--- a/gui/src/main/account-data-cache.ts
+++ /dev/null
@@ -1,190 +0,0 @@
-import { closeToExpiry, hasExpired } from '../shared/account-expiry';
-import {
- AccountDataError,
- AccountDataResponse,
- AccountNumber,
- IAccountData,
- VoucherResponse,
-} from '../shared/daemon-rpc-types';
-import { dateByAddingComponent, DateComponent } from '../shared/date-helper';
-import log from '../shared/logging';
-import { Scheduler } from '../shared/scheduler';
-
-export type AccountFetchError = AccountDataError['error'] | 'cancelled';
-
-interface IAccountFetchWatcher {
- onFinish: () => void;
- onError: (error: AccountFetchError) => void;
-}
-
-// Account data is valid for 1 minute unless the account has expired.
-const ACCOUNT_DATA_VALIDITY_SECONDS = 60_000;
-// Account data is valid for 10 seconds if the account has expired.
-const ACCOUNT_DATA_EXPIRED_VALIDITY_SECONDS = 10_000;
-
-// An account data cache that helps to throttle RPC requests to get_account_data and retain the
-// cached value for 1 minute.
-export default class AccountDataCache {
- private currentAccount?: AccountNumber;
- private validUntil?: Date;
- private performingFetch = false;
- private waitStrategy = new WaitStrategy();
- private fetchRetryScheduler = new Scheduler();
- private watchers: IAccountFetchWatcher[] = [];
-
- constructor(
- private fetchHandler: (number: AccountNumber) => Promise<AccountDataResponse>,
- private updateHandler: (data?: IAccountData) => void,
- ) {}
-
- public fetch(accountNumber: AccountNumber, watcher?: IAccountFetchWatcher) {
- // invalidate cache if account number has changed
- if (accountNumber !== this.currentAccount) {
- this.invalidate();
- this.currentAccount = accountNumber;
- }
-
- // Only fetch if value has expired
- if (!this.isValid()) {
- if (watcher) {
- this.watchers.push(watcher);
- }
-
- this.fetchRetryScheduler.cancel();
- // If a scheduled retry is cancelled the fetchAttempt shouldn't be increased.
- this.waitStrategy.decrease();
-
- // Only fetch if there's no fetch for this account number in progress.
- if (!this.performingFetch) {
- void this.performFetch(accountNumber);
- }
- } else if (watcher) {
- watcher.onFinish();
- }
- }
-
- public invalidate() {
- this.fetchRetryScheduler.cancel();
- this.waitStrategy.reset();
-
- this.performingFetch = false;
- this.validUntil = undefined;
- this.updateHandler();
- this.notifyWatchers((watcher) => {
- watcher.onError('cancelled');
- });
- }
-
- public handleVoucherResponse(accountNumber: AccountNumber, voucherResponse: VoucherResponse) {
- if (accountNumber === this.currentAccount && voucherResponse.type === 'success') {
- this.setValue({ expiry: voucherResponse.newExpiry });
- }
- }
-
- private setValue(accountData: IAccountData) {
- this.validUntil = this.getValidUntil(accountData);
- this.updateHandler(accountData);
- this.notifyWatchers((watcher) => watcher.onFinish());
- }
-
- private isValid() {
- return this.validUntil && this.validUntil > new Date();
- }
-
- private getValidUntil(accountData: IAccountData): Date {
- if (hasExpired(accountData.expiry)) {
- return new Date(Date.now() + ACCOUNT_DATA_EXPIRED_VALIDITY_SECONDS);
- } else {
- return new Date(Date.now() + ACCOUNT_DATA_VALIDITY_SECONDS);
- }
- }
-
- private async performFetch(accountNumber: AccountNumber) {
- this.performingFetch = true;
- // it's possible for invalidate() to be called or for a fetch for a different account number
- // to start before this fetch completes, so checking if the current account number is the one
- // used is necessary below.
- const response = await this.fetchHandler(accountNumber);
- if ('error' in response) {
- if (this.currentAccount === accountNumber) {
- this.handleFetchError(accountNumber, response.error);
- this.performingFetch = false;
- }
- } else {
- if (this.currentAccount === accountNumber) {
- this.setValue(response);
-
- const refetchDelay = this.calculateRefetchDelay(response.expiry);
- if (refetchDelay) {
- this.scheduleFetch(accountNumber, refetchDelay);
- }
-
- this.waitStrategy.reset();
- this.performingFetch = false;
- }
- }
- }
-
- private calculateRefetchDelay(accountExpiry: string) {
- const currentDate = new Date();
- const oneMinuteBeforeExpiry = dateByAddingComponent(accountExpiry, DateComponent.minute, -1);
-
- if (oneMinuteBeforeExpiry >= currentDate && closeToExpiry(accountExpiry)) {
- return oneMinuteBeforeExpiry.getTime() - currentDate.getTime();
- } else {
- return undefined;
- }
- }
-
- private handleFetchError(accountNumber: AccountNumber, error: AccountDataError['error']) {
- this.notifyWatchers((w) => w.onError(error));
- if (error !== 'invalid-account') {
- this.scheduleRetry(accountNumber);
- }
- }
-
- private scheduleRetry(accountNumber: AccountNumber) {
- this.waitStrategy.increase();
- const delay = this.waitStrategy.delay();
-
- log.warn(`Failed to fetch account data. Retrying in ${delay} ms`);
-
- this.scheduleFetch(accountNumber, delay);
- }
-
- private scheduleFetch(accountNumber: AccountNumber, delay: number) {
- this.fetchRetryScheduler.schedule(() => {
- void this.performFetch(accountNumber);
- }, delay);
- }
-
- private notifyWatchers(notify: (watcher: IAccountFetchWatcher) => void) {
- this.watchers.splice(0).forEach(notify);
- }
-}
-
-const MAX_ATTEMPT = 9;
-
-class WaitStrategy {
- private counter = 0;
-
- public increase() {
- if (this.counter < MAX_ATTEMPT) {
- this.counter += 1;
- }
- }
- public decrease() {
- if (this.counter > 0) {
- this.counter -= 1;
- }
- }
-
- public reset() {
- this.counter = 0;
- }
-
- public delay(): number {
- // Max delay: 2^11 = 2048
- return Math.pow(2, this.counter + 2) * 1000;
- }
-}
diff --git a/gui/src/main/account.ts b/gui/src/main/account.ts
deleted file mode 100644
index 024e4a7208..0000000000
--- a/gui/src/main/account.ts
+++ /dev/null
@@ -1,239 +0,0 @@
-import { closeToExpiry } from '../shared/account-expiry';
-import {
- AccountDataError,
- AccountNumber,
- DeviceEvent,
- DeviceState,
- IAccountData,
- IDeviceRemoval,
- TunnelState,
-} from '../shared/daemon-rpc-types';
-import log from '../shared/logging';
-import {
- AccountExpiredNotificationProvider,
- CloseToAccountExpiryNotificationProvider,
- SystemNotificationCategory,
-} from '../shared/notifications/notification';
-import { Scheduler } from '../shared/scheduler';
-import AccountDataCache from './account-data-cache';
-import { DaemonRpc } from './daemon-rpc';
-import { IpcMainEventChannel } from './ipc-event-channel';
-import { NotificationSender } from './notification-controller';
-import { TunnelStateProvider } from './tunnel-state';
-
-export interface LocaleProvider {
- getLocale(): string;
-}
-
-export interface AccountDelegate {
- onDeviceEvent(): void;
-}
-
-export default class Account {
- private accountDataValue?: IAccountData = undefined;
- private accountHistoryValue?: AccountNumber = undefined;
- private expiryNotificationFrequencyScheduler = new Scheduler();
- private firstExpiryNotificationScheduler = new Scheduler();
-
- private accountDataCache = new AccountDataCache(
- (accountNumber) => {
- return this.daemonRpc.getAccountData(accountNumber);
- },
- (accountData) => {
- this.accountDataValue = accountData;
-
- IpcMainEventChannel.account.notify?.(this.accountData);
-
- this.handleAccountExpiry();
- },
- );
-
- private deviceStateValue?: DeviceState;
-
- public constructor(
- private delegate: AccountDelegate & TunnelStateProvider & LocaleProvider & NotificationSender,
- private daemonRpc: DaemonRpc,
- ) {}
-
- public get accountData() {
- return this.accountDataValue;
- }
-
- public get accountHistory() {
- return this.accountHistoryValue;
- }
-
- public get deviceState() {
- return this.deviceStateValue;
- }
-
- public registerIpcListeners() {
- IpcMainEventChannel.account.handleCreate(() => this.createNewAccount());
- IpcMainEventChannel.account.handleLogin(
- async (number: AccountNumber) => (await this.login(number)) ?? undefined,
- );
- IpcMainEventChannel.account.handleLogout(() => this.logout());
- IpcMainEventChannel.account.handleGetWwwAuthToken(() => this.daemonRpc.getWwwAuthToken());
- IpcMainEventChannel.account.handleSubmitVoucher(async (voucherCode: string) => {
- const currentAccountNumber = this.getAccountNumber();
- const response = await this.daemonRpc.submitVoucher(voucherCode);
-
- if (currentAccountNumber) {
- this.accountDataCache.handleVoucherResponse(currentAccountNumber, response);
- }
-
- return response;
- });
- IpcMainEventChannel.account.handleUpdateData(() => this.updateAccountData());
-
- IpcMainEventChannel.accountHistory.handleClear(async () => {
- await this.daemonRpc.clearAccountHistory();
- void this.updateAccountHistory();
- });
-
- IpcMainEventChannel.account.handleListDevices((accountNumber: AccountNumber) => {
- return this.daemonRpc.listDevices(accountNumber);
- });
- IpcMainEventChannel.account.handleRemoveDevice((deviceRemoval: IDeviceRemoval) => {
- return this.daemonRpc.removeDevice(deviceRemoval);
- });
- }
-
- public isLoggedIn(): boolean {
- return this.deviceState?.type === 'logged in';
- }
-
- public updateAccountData = () => {
- if (this.daemonRpc.isConnected && this.isLoggedIn()) {
- this.accountDataCache.fetch(this.getAccountNumber()!);
- }
- };
-
- public detectStaleAccountExpiry(tunnelState: TunnelState) {
- const hasExpired = !this.accountData || new Date() >= new Date(this.accountData.expiry);
-
- // It's likely that the account expiry is stale if the daemon managed to establish the tunnel.
- if (tunnelState.state === 'connected' && hasExpired) {
- log.info('Detected the stale account expiry.');
- this.accountDataCache.invalidate();
- }
- }
-
- public handleDeviceEvent(deviceEvent: DeviceEvent) {
- this.delegate.closeNotificationsInCategory(SystemNotificationCategory.expiry);
-
- this.deviceStateValue = deviceEvent.deviceState;
-
- switch (deviceEvent.deviceState.type) {
- case 'logged in':
- this.accountDataCache.fetch(deviceEvent.deviceState.accountAndDevice.accountNumber);
- break;
- case 'logged out':
- case 'revoked':
- this.accountDataCache.invalidate();
- break;
- }
-
- void this.updateAccountHistory();
- this.delegate.onDeviceEvent();
-
- IpcMainEventChannel.account.notifyDevice?.(deviceEvent);
- }
-
- public setAccountHistory(accountHistory?: AccountNumber) {
- this.accountHistoryValue = accountHistory;
-
- IpcMainEventChannel.accountHistory.notify?.(accountHistory);
- }
-
- private async createNewAccount(): Promise<string> {
- try {
- return await this.daemonRpc.createNewAccount();
- } catch (e) {
- const error = e as Error;
- log.error(`Failed to create account: ${error.message}`);
- throw error;
- }
- }
-
- private async login(accountNumber: AccountNumber): Promise<AccountDataError | void> {
- const error = await this.daemonRpc.loginAccount(accountNumber);
-
- if (error) {
- log.error(`Failed to login: ${error.error}`);
- return error;
- }
- }
-
- private async logout(): Promise<void> {
- try {
- await this.daemonRpc.logoutAccount();
-
- this.delegate.closeNotificationsInCategory(SystemNotificationCategory.expiry);
- this.expiryNotificationFrequencyScheduler.cancel();
- this.firstExpiryNotificationScheduler.cancel();
- } catch (e) {
- const error = e as Error;
- log.info(`Failed to logout: ${error.message}`);
-
- throw error;
- }
- }
-
- private handleAccountExpiry() {
- if (this.accountData) {
- const expiredNotification = new AccountExpiredNotificationProvider({
- accountExpiry: this.accountData.expiry,
- tunnelState: this.delegate.getTunnelState(),
- });
- const closeToExpiryNotification = new CloseToAccountExpiryNotificationProvider({
- accountExpiry: this.accountData.expiry,
- locale: this.delegate.getLocale(),
- });
-
- if (expiredNotification.mayDisplay()) {
- this.expiryNotificationFrequencyScheduler.cancel();
- this.firstExpiryNotificationScheduler.cancel();
- this.delegate.notify(expiredNotification.getSystemNotification());
- } else if (
- !this.expiryNotificationFrequencyScheduler.isRunning &&
- closeToExpiryNotification.mayDisplay()
- ) {
- this.firstExpiryNotificationScheduler.cancel();
- this.delegate.notify(closeToExpiryNotification.getSystemNotification());
-
- const twelveHours = 12 * 60 * 60 * 1000;
- const remainingMilliseconds = new Date(this.accountData.expiry).getTime() - Date.now();
- const delay = Math.min(twelveHours, remainingMilliseconds);
- this.expiryNotificationFrequencyScheduler.schedule(() => this.handleAccountExpiry(), delay);
- } else if (!closeToExpiry(this.accountData.expiry)) {
- this.expiryNotificationFrequencyScheduler.cancel();
- // If no longer close to expiry, all previous notifications should be closed
- this.delegate.closeNotificationsInCategory(SystemNotificationCategory.expiry);
-
- const expiry = new Date(this.accountData.expiry).getTime();
- const now = new Date().getTime();
- const threeDays = 3 * 24 * 60 * 60 * 1000;
- // Add 10 seconds to be on the safe side. Never make it longer than a 24 days since
- // the timeout needs to fit into a signed 32-bit integer.
- const timeout = Math.min(expiry - now - threeDays + 10_000, 24 * 24 * 60 * 60 * 1000);
- this.firstExpiryNotificationScheduler.schedule(() => this.handleAccountExpiry(), timeout);
- }
- }
- }
-
- private async updateAccountHistory(): Promise<void> {
- try {
- this.setAccountHistory(await this.daemonRpc.getAccountHistory());
- } catch (e) {
- const error = e as Error;
- log.error(`Failed to fetch the account history: ${error.message}`);
- }
- }
-
- private getAccountNumber(): AccountNumber | undefined {
- return this.deviceState?.type === 'logged in'
- ? this.deviceState.accountAndDevice.accountNumber
- : undefined;
- }
-}
diff --git a/gui/src/main/autostart.ts b/gui/src/main/autostart.ts
deleted file mode 100644
index 763862178e..0000000000
--- a/gui/src/main/autostart.ts
+++ /dev/null
@@ -1,82 +0,0 @@
-import { app } from 'electron';
-import fs from 'fs';
-import path from 'path';
-
-import log from '../shared/logging';
-import { getDesktopEntries } from './linux-desktop-entry';
-
-const DESKTOP_FILE_NAME = 'mullvad-vpn.desktop';
-
-export function getOpenAtLogin() {
- if (process.platform === 'linux') {
- try {
- const autostartDir = path.join(app.getPath('appData'), 'autostart');
- const autostartFilePath = path.join(autostartDir, DESKTOP_FILE_NAME);
-
- fs.accessSync(autostartFilePath);
-
- return true;
- } catch (e) {
- const error = e as Error;
- log.error(`Failed to check autostart file: ${error.message}`);
- return false;
- }
- } else {
- return app.getLoginItemSettings().openAtLogin;
- }
-}
-
-export async function setOpenAtLogin(openAtLogin: boolean) {
- if (process.platform === 'linux') {
- try {
- const desktopFilePath = await getDesktopEntryPath();
- const autostartDir = path.join(app.getPath('appData'), 'autostart');
- const autostartFilePath = path.join(autostartDir, DESKTOP_FILE_NAME);
-
- if (openAtLogin) {
- await createDirIfNecessary(autostartDir);
- await fs.promises.symlink(desktopFilePath, autostartFilePath);
- } else {
- await fs.promises.unlink(autostartFilePath);
- }
- } catch (e) {
- const error = e as Error;
- log.error(`Failed to set auto-start: ${error.message}`);
- }
- } else {
- app.setLoginItemSettings({ openAtLogin });
- }
-}
-
-async function getDesktopEntryPath(): Promise<string> {
- const entries = await getDesktopEntries();
- const entry = entries.find((entry) => path.parse(entry).base === DESKTOP_FILE_NAME);
- if (entry) {
- return entry;
- } else {
- throw new Error(`Couldn't find ${DESKTOP_FILE_NAME}`);
- }
-}
-
-const createDirIfNecessary = async (directory: string) => {
- let stat;
- try {
- stat = await fs.promises.stat(directory);
- } catch {
- // Path doesn't exist, so it has to be created
- return fs.promises.mkdir(directory);
- }
-
- // Is there a file instead of a directory?
- if (!stat.isDirectory()) {
- // Try to remove existing file and replace it with a new directory
- try {
- await fs.promises.unlink(directory);
- } catch (e) {
- const error = e as Error;
- log.error(`Failed to remove path before creating a directory for it: ${error.message}`);
- }
-
- return fs.promises.mkdir(directory);
- }
-};
diff --git a/gui/src/main/changelog.ts b/gui/src/main/changelog.ts
deleted file mode 100644
index 978b0b28e6..0000000000
--- a/gui/src/main/changelog.ts
+++ /dev/null
@@ -1,66 +0,0 @@
-import fs from 'fs';
-import path from 'path';
-
-import { IChangelog } from '../shared/ipc-types';
-import log from '../shared/logging';
-
-// Reads and parses the changelog file.
-export function readChangelog(): IChangelog {
- try {
- const changelogPath = path.join(__dirname, '..', '..', '..', 'changes.txt');
- const contents = fs.readFileSync(changelogPath).toString();
- return parseChangelog(contents);
- } catch (e) {
- const error = e as Error;
- log.error('Failed to read changelog.txt', error.message);
- return [];
- }
-}
-
-// Parses the contents of the changelog file and returns all relevant items.
-export function parseChangelog(changelog: string): IChangelog {
- const items = changelog
- .split('\n')
- .map((item) => item.trim())
- .filter((item) => item !== '');
- return filterForPlatform(items);
-}
-
-// Filters the changelog items based on platform
-function filterForPlatform(items: Array<string>): IChangelog {
- return items
- .map((item) => {
- // Extracts the platforms from from the string if there are any specified. Platforms are
- // specified within brackets with separated with a comma.
- const platforms = item
- .match(/^\[.*?\]/)
- ?.flatMap((match) => match.slice(1, -1).split(','))
- .map((platform) => platform.trim());
- if (!platforms || isPlatform(platforms)) {
- // If there are no platforms specified or if the current platform matches one of the
- // specified, then the item is included.
- return item.replace(/^\[.*?\]/, '').trim();
- } else {
- return undefined;
- }
- })
- .filter((item): item is string => item !== undefined);
-}
-
-// Checks if an OS name corresponds to the current platform.
-function isPlatform(platformNames: Array<string>): boolean {
- const platforms = platformNames.map((platformName) => {
- switch (platformName.toLowerCase()) {
- case 'windows':
- return 'win32';
- case 'macos':
- return 'darwin';
- case 'linux':
- return 'linux';
- default:
- return platformName;
- }
- });
-
- return platforms.includes(process.platform);
-}
diff --git a/gui/src/main/command-line-options.ts b/gui/src/main/command-line-options.ts
deleted file mode 100644
index 5a7ca57762..0000000000
--- a/gui/src/main/command-line-options.ts
+++ /dev/null
@@ -1,61 +0,0 @@
-class CommandLineOption {
- private flags: string[];
-
- public constructor(
- private description: string,
- ...flags: string[]
- ) {
- this.flags = flags;
- }
-
- public get match(): boolean {
- return this.flags.some((flag) => process.argv.includes(flag));
- }
-
- public format(): string {
- return formatOption(this.description, ...this.flags);
- }
-}
-
-class DevelopmentCommandLineOption extends CommandLineOption {
- public constructor(...flags: string[]) {
- super('', ...flags);
- }
-
- public get match(): boolean {
- return process.env.NODE_ENV === 'development' && super.match;
- }
-}
-
-export const CommandLineOptions = {
- help: new CommandLineOption('Print this help text', '--help', '-h'),
- version: new CommandLineOption('Print the app version', '--version'),
- showChanges: new CommandLineOption('Show changes dialog', '--show-changes'),
- disableResetNavigation: new DevelopmentCommandLineOption('--disable-reset-navigation'),
- disableDevtoolsOpen: new DevelopmentCommandLineOption('--disable-devtools-open'),
- forwardRendererLog: new DevelopmentCommandLineOption('--forward-renderer-log'),
-} as const;
-
-export function printCommandLineOptions() {
- Object.values(CommandLineOptions).forEach((option) => {
- if (!(option instanceof DevelopmentCommandLineOption)) {
- console.log(option.format());
- }
- });
-}
-
-export function printElectronOptions() {
- console.log(formatOption('Run without renderer process sandboxed', '--no-sandbox'));
- console.log(formatOption('Run without hardware acceleration for graphics', '--disable-gpu'));
-}
-
-// This functions format options into one line, e.g.
-// --help Print this help text
-// The line starts with 4 spaces and the flags and description are separated with spaces to align
-// the descriptions
-function formatOption(description: string, ...flags: string[]) {
- const joinedFlags = flags.join(', ');
- const padding = ' ';
- const paddedFlags = (joinedFlags + padding).slice(0, -joinedFlags.length);
- return ' ' + paddedFlags + description;
-}
diff --git a/gui/src/main/daemon-rpc.ts b/gui/src/main/daemon-rpc.ts
deleted file mode 100644
index 8cea0d4008..0000000000
--- a/gui/src/main/daemon-rpc.ts
+++ /dev/null
@@ -1,614 +0,0 @@
-import * as grpc from '@grpc/grpc-js';
-import { Empty } from 'google-protobuf/google/protobuf/empty_pb.js';
-import { BoolValue, StringValue } from 'google-protobuf/google/protobuf/wrappers_pb.js';
-
-import {
- AccessMethodSetting,
- AccountDataError,
- AccountDataResponse,
- AccountNumber,
- BridgeSettings,
- BridgeState,
- CustomListError,
- CustomProxy,
- DaemonEvent,
- DeviceState,
- IAppVersionInfo,
- ICustomList,
- IDevice,
- IDeviceRemoval,
- IDnsOptions,
- IRelayListWithEndpointData,
- ISettings,
- NewAccessMethodSetting,
- ObfuscationSettings,
- ObfuscationType,
- RelaySettings,
- TunnelState,
- VoucherResponse,
-} from '../shared/daemon-rpc-types';
-import { ConnectionObserver, GrpcClient, noConnectionError } from './grpc-client';
-import {
- convertFromApiAccessMethodSetting,
- convertFromDaemonEvent,
- convertFromDevice,
- convertFromDeviceState,
- convertFromRelayList,
- convertFromSettings,
- convertFromTunnelState,
- convertToApiAccessMethodSetting,
- convertToCustomList,
- convertToCustomProxy,
- convertToNewApiAccessMethodSetting,
- convertToNormalBridgeSettings,
- convertToRelayConstraints,
- ensureExists,
-} from './grpc-type-convertions';
-import * as grpcTypes from './management_interface/management_interface_pb';
-
-const DAEMON_RPC_PATH =
- process.platform === 'win32' ? 'unix:////./pipe/Mullvad VPN' : 'unix:///var/run/mullvad-vpn';
-
-export class SubscriptionListener<T> {
- // Only meant to be used by DaemonRpc
- // @internal
- public subscriptionId?: number;
-
- constructor(
- private eventHandler: (payload: T) => void,
- private errorHandler: (error: Error) => void,
- ) {}
-
- // Only meant to be called by DaemonRpc
- // @internal
- public onEvent(payload: T) {
- this.eventHandler(payload);
- }
-
- // Only meant to be called by DaemonRpc
- // @internal
- public onError(error: Error) {
- this.errorHandler(error);
- }
-}
-
-export class DaemonRpc extends GrpcClient {
- private nextSubscriptionId = 0;
- private subscriptions: Map<number, grpc.ClientReadableStream<grpcTypes.DaemonEvent>> = new Map();
-
- public constructor(connectionObserver?: ConnectionObserver) {
- super(DAEMON_RPC_PATH, connectionObserver);
- }
-
- public disconnect() {
- for (const subscriptionId of this.subscriptions.keys()) {
- this.removeSubscription(subscriptionId);
- }
-
- super.disconnect();
- }
-
- public subscribeDaemonEventListener(listener: SubscriptionListener<DaemonEvent>) {
- const call = this.isConnected && this.client.eventsListen(new Empty());
- if (!call) {
- throw noConnectionError;
- }
- const subscriptionId = this.subscriptionId();
- listener.subscriptionId = subscriptionId;
- this.subscriptions.set(subscriptionId, call);
-
- call.on('data', (data: grpcTypes.DaemonEvent) => {
- try {
- const daemonEvent = convertFromDaemonEvent(data);
- listener.onEvent(daemonEvent);
- } catch (e) {
- const error = e as Error;
- listener.onError(error);
- }
- });
-
- call.on('error', (error) => {
- listener.onError(error);
- this.removeSubscription(subscriptionId);
- });
- }
-
- public unsubscribeDaemonEventListener(listener: SubscriptionListener<DaemonEvent>) {
- const id = listener.subscriptionId;
- if (id !== undefined) {
- this.removeSubscription(id);
- }
- }
-
- public async getAccountData(accountNumber: AccountNumber): Promise<AccountDataResponse> {
- try {
- const response = await this.callString<grpcTypes.AccountData>(
- this.client.getAccountData,
- accountNumber,
- );
- const expiry = response.getExpiry()!.toDate().toISOString();
- return { type: 'success', expiry };
- } catch (e) {
- const error = e as grpc.ServiceError;
- if (error.code) {
- switch (error.code) {
- case grpc.status.UNAUTHENTICATED:
- return { type: 'error', error: 'invalid-account' };
- default:
- return { type: 'error', error: 'communication' };
- }
- }
- throw error;
- }
- }
-
- public async getWwwAuthToken(): Promise<string> {
- const response = await this.callEmpty<StringValue>(this.client.getWwwAuthToken);
- return response.getValue();
- }
-
- public async submitVoucher(voucherCode: string): Promise<VoucherResponse> {
- try {
- const response = await this.callString<grpcTypes.VoucherSubmission>(
- this.client.submitVoucher,
- voucherCode,
- );
-
- const secondsAdded = ensureExists(
- response.getSecondsAdded(),
- "no 'secondsAdded' field in voucher response",
- );
- const newExpiry = ensureExists(
- response.getNewExpiry(),
- "no 'newExpiry' field in voucher response",
- )
- .toDate()
- .toISOString();
- return {
- type: 'success',
- secondsAdded,
- newExpiry,
- };
- } catch (e) {
- const error = e as grpc.ServiceError;
- if (error.code) {
- switch (error.code) {
- case grpc.status.NOT_FOUND:
- return { type: 'invalid' };
- case grpc.status.RESOURCE_EXHAUSTED:
- return { type: 'already_used' };
- }
- }
- return { type: 'error' };
- }
- }
-
- public async getRelayLocations(): Promise<IRelayListWithEndpointData> {
- if (this.isConnected) {
- const response = await this.callEmpty<grpcTypes.RelayList>(this.client.getRelayLocations);
- return convertFromRelayList(response);
- } else {
- throw noConnectionError;
- }
- }
-
- public async createNewAccount(): Promise<string> {
- const response = await this.callEmpty<StringValue>(this.client.createNewAccount);
- return response.getValue();
- }
-
- public async loginAccount(accountNumber: AccountNumber): Promise<AccountDataError | void> {
- try {
- await this.callString(this.client.loginAccount, accountNumber);
- } catch (e) {
- const error = e as grpc.ServiceError;
- switch (error.code) {
- case grpc.status.RESOURCE_EXHAUSTED:
- return { type: 'error', error: 'too-many-devices' };
- case grpc.status.UNAUTHENTICATED:
- return { type: 'error', error: 'invalid-account' };
- default:
- return { type: 'error', error: 'communication' };
- }
- }
- }
-
- public async logoutAccount(): Promise<void> {
- await this.callEmpty(this.client.logoutAccount);
- }
-
- // TODO: Custom tunnel configurations are not supported by the GUI.
- public async setRelaySettings(relaySettings: RelaySettings): Promise<void> {
- if ('normal' in relaySettings) {
- const normalSettings = relaySettings.normal;
- const grpcRelaySettings = new grpcTypes.RelaySettings();
- grpcRelaySettings.setNormal(convertToRelayConstraints(normalSettings));
-
- await this.call<grpcTypes.RelaySettings, Empty>(
- this.client.setRelaySettings,
- grpcRelaySettings,
- );
- }
- }
-
- public async setAllowLan(allowLan: boolean): Promise<void> {
- await this.callBool(this.client.setAllowLan, allowLan);
- }
-
- public async setShowBetaReleases(showBetaReleases: boolean): Promise<void> {
- await this.callBool(this.client.setShowBetaReleases, showBetaReleases);
- }
-
- public async setEnableIpv6(enableIpv6: boolean): Promise<void> {
- await this.callBool(this.client.setEnableIpv6, enableIpv6);
- }
-
- public async setBlockWhenDisconnected(blockWhenDisconnected: boolean): Promise<void> {
- await this.callBool(this.client.setBlockWhenDisconnected, blockWhenDisconnected);
- }
-
- public async setBridgeState(bridgeState: BridgeState): Promise<void> {
- const bridgeStateMap = {
- auto: grpcTypes.BridgeState.State.AUTO,
- on: grpcTypes.BridgeState.State.ON,
- off: grpcTypes.BridgeState.State.OFF,
- };
-
- const grpcBridgeState = new grpcTypes.BridgeState();
- grpcBridgeState.setState(bridgeStateMap[bridgeState]);
- await this.call<grpcTypes.BridgeState, Empty>(this.client.setBridgeState, grpcBridgeState);
- }
-
- public async setBridgeSettings(bridgeSettings: BridgeSettings): Promise<void> {
- const grpcBridgeSettings = new grpcTypes.BridgeSettings();
-
- grpcBridgeSettings.setBridgeType(
- bridgeSettings.type === 'normal'
- ? grpcTypes.BridgeSettings.BridgeType.NORMAL
- : grpcTypes.BridgeSettings.BridgeType.CUSTOM,
- );
-
- const normalSettings = convertToNormalBridgeSettings(bridgeSettings.normal);
- grpcBridgeSettings.setNormal(normalSettings);
-
- if (bridgeSettings.custom) {
- const customProxy = convertToCustomProxy(bridgeSettings.custom);
- grpcBridgeSettings.setCustom(customProxy);
- }
-
- await this.call<grpcTypes.BridgeSettings, Empty>(
- this.client.setBridgeSettings,
- grpcBridgeSettings,
- );
- }
-
- public async setObfuscationSettings(obfuscationSettings: ObfuscationSettings): Promise<void> {
- const grpcObfuscationSettings = new grpcTypes.ObfuscationSettings();
- switch (obfuscationSettings.selectedObfuscation) {
- case ObfuscationType.auto:
- grpcObfuscationSettings.setSelectedObfuscation(
- grpcTypes.ObfuscationSettings.SelectedObfuscation.AUTO,
- );
- break;
- case ObfuscationType.off:
- grpcObfuscationSettings.setSelectedObfuscation(
- grpcTypes.ObfuscationSettings.SelectedObfuscation.OFF,
- );
- break;
- case ObfuscationType.shadowsocks:
- grpcObfuscationSettings.setSelectedObfuscation(
- grpcTypes.ObfuscationSettings.SelectedObfuscation.SHADOWSOCKS,
- );
- break;
- case ObfuscationType.udp2tcp:
- grpcObfuscationSettings.setSelectedObfuscation(
- grpcTypes.ObfuscationSettings.SelectedObfuscation.UDP2TCP,
- );
- break;
- }
-
- if (obfuscationSettings.udp2tcpSettings) {
- const grpcUdp2tcpSettings = new grpcTypes.Udp2TcpObfuscationSettings();
- if (obfuscationSettings.udp2tcpSettings.port !== 'any') {
- grpcUdp2tcpSettings.setPort(obfuscationSettings.udp2tcpSettings.port.only);
- }
- grpcObfuscationSettings.setUdp2tcp(grpcUdp2tcpSettings);
- }
-
- if (obfuscationSettings.shadowsocksSettings) {
- const shadowsocksSettings = new grpcTypes.ShadowsocksSettings();
- if (obfuscationSettings.shadowsocksSettings.port !== 'any') {
- shadowsocksSettings.setPort(obfuscationSettings.shadowsocksSettings.port.only);
- }
- grpcObfuscationSettings.setShadowsocks(shadowsocksSettings);
- }
-
- await this.call<grpcTypes.ObfuscationSettings, Empty>(
- this.client.setObfuscationSettings,
- grpcObfuscationSettings,
- );
- }
-
- public async setOpenVpnMssfix(mssfix?: number): Promise<void> {
- await this.callNumber(this.client.setOpenvpnMssfix, mssfix);
- }
-
- public async setWireguardMtu(mtu?: number): Promise<void> {
- await this.callNumber(this.client.setWireguardMtu, mtu);
- }
-
- public async setWireguardQuantumResistant(quantumResistant?: boolean): Promise<void> {
- const quantumResistantState = new grpcTypes.QuantumResistantState();
- switch (quantumResistant) {
- case true:
- quantumResistantState.setState(grpcTypes.QuantumResistantState.State.ON);
- break;
- case false:
- quantumResistantState.setState(grpcTypes.QuantumResistantState.State.OFF);
- break;
- case undefined:
- quantumResistantState.setState(grpcTypes.QuantumResistantState.State.AUTO);
- break;
- }
- await this.call<grpcTypes.QuantumResistantState, Empty>(
- this.client.setQuantumResistantTunnel,
- quantumResistantState,
- );
- }
-
- public async setAutoConnect(autoConnect: boolean): Promise<void> {
- await this.callBool(this.client.setAutoConnect, autoConnect);
- }
-
- public async connectTunnel(): Promise<void> {
- await this.callEmpty(this.client.connectTunnel);
- }
-
- public async disconnectTunnel(): Promise<void> {
- await this.callEmpty(this.client.disconnectTunnel);
- }
-
- public async reconnectTunnel(): Promise<void> {
- await this.callEmpty(this.client.reconnectTunnel);
- }
-
- public async getState(): Promise<TunnelState> {
- const response = await this.callEmpty<grpcTypes.TunnelState>(this.client.getTunnelState);
- return convertFromTunnelState(response)!;
- }
-
- public async getSettings(): Promise<ISettings> {
- const response = await this.callEmpty<grpcTypes.Settings>(this.client.getSettings);
- return convertFromSettings(response)!;
- }
-
- public async getAccountHistory(): Promise<AccountNumber | undefined> {
- const response = await this.callEmpty<grpcTypes.AccountHistory>(this.client.getAccountHistory);
- return response.getNumber()?.getValue();
- }
-
- public async clearAccountHistory(): Promise<void> {
- await this.callEmpty(this.client.clearAccountHistory);
- }
-
- public async getCurrentVersion(): Promise<string> {
- const response = await this.callEmpty<StringValue>(this.client.getCurrentVersion);
- return response.getValue();
- }
-
- public async setDnsOptions(dns: IDnsOptions): Promise<void> {
- const dnsOptions = new grpcTypes.DnsOptions();
-
- const defaultOptions = new grpcTypes.DefaultDnsOptions();
- defaultOptions.setBlockAds(dns.defaultOptions.blockAds);
- defaultOptions.setBlockTrackers(dns.defaultOptions.blockTrackers);
- defaultOptions.setBlockMalware(dns.defaultOptions.blockMalware);
- defaultOptions.setBlockAdultContent(dns.defaultOptions.blockAdultContent);
- defaultOptions.setBlockGambling(dns.defaultOptions.blockGambling);
- defaultOptions.setBlockSocialMedia(dns.defaultOptions.blockSocialMedia);
- dnsOptions.setDefaultOptions(defaultOptions);
-
- const customOptions = new grpcTypes.CustomDnsOptions();
- customOptions.setAddressesList(dns.customOptions.addresses);
- dnsOptions.setCustomOptions(customOptions);
-
- if (dns.state === 'custom') {
- dnsOptions.setState(grpcTypes.DnsOptions.DnsState.CUSTOM);
- } else {
- dnsOptions.setState(grpcTypes.DnsOptions.DnsState.DEFAULT);
- }
-
- await this.call<grpcTypes.DnsOptions, Empty>(this.client.setDnsOptions, dnsOptions);
- }
-
- public async getVersionInfo(): Promise<IAppVersionInfo> {
- const response = await this.callEmpty<grpcTypes.AppVersionInfo>(this.client.getVersionInfo);
- return response.toObject();
- }
-
- public async addSplitTunnelingApplication(path: string): Promise<void> {
- await this.callString(this.client.addSplitTunnelApp, path);
- }
-
- public async removeSplitTunnelingApplication(path: string): Promise<void> {
- await this.callString(this.client.removeSplitTunnelApp, path);
- }
-
- public async setSplitTunnelingState(enabled: boolean): Promise<void> {
- await this.callBool(this.client.setSplitTunnelState, enabled);
- }
-
- public async needFullDiskPermissions(): Promise<boolean> {
- const needFullDiskPermissions = await this.callEmpty<BoolValue>(
- this.client.needFullDiskPermissions,
- );
- return needFullDiskPermissions.getValue();
- }
-
- public async checkVolumes(): Promise<void> {
- await this.callEmpty(this.client.checkVolumes);
- }
-
- public async isPerformingPostUpgrade(): Promise<boolean> {
- const response = await this.callEmpty<BoolValue>(this.client.isPerformingPostUpgrade);
- return response.getValue();
- }
-
- public async getDevice(): Promise<DeviceState> {
- const response = await this.callEmpty<grpcTypes.DeviceState>(this.client.getDevice);
- return convertFromDeviceState(response);
- }
-
- public async updateDevice(): Promise<void> {
- await this.callEmpty(this.client.updateDevice);
- }
-
- public async prepareRestart(quit: boolean) {
- await this.callBool(this.client.prepareRestartV2, quit);
- }
-
- public async setEnableDaita(value: boolean): Promise<void> {
- await this.callBool(this.client.setEnableDaita, value);
- }
-
- public async setDaitaDirectOnly(value: boolean): Promise<void> {
- await this.callBool(this.client.setDaitaDirectOnly, value);
- }
-
- public async listDevices(accountNumber: AccountNumber): Promise<Array<IDevice>> {
- try {
- const response = await this.callString<grpcTypes.DeviceList>(
- this.client.listDevices,
- accountNumber,
- );
-
- return response.getDevicesList().map(convertFromDevice);
- } catch {
- throw new Error('Failed to list devices');
- }
- }
-
- public async removeDevice(deviceRemoval: IDeviceRemoval): Promise<void> {
- const grpcDeviceRemoval = new grpcTypes.DeviceRemoval();
- grpcDeviceRemoval.setAccountNumber(deviceRemoval.accountNumber);
- grpcDeviceRemoval.setDeviceId(deviceRemoval.deviceId);
-
- await this.call<grpcTypes.DeviceRemoval, Empty>(this.client.removeDevice, grpcDeviceRemoval);
- }
-
- public async createCustomList(name: string): Promise<void | CustomListError> {
- try {
- await this.callString<Empty>(this.client.createCustomList, name);
- } catch (e) {
- const error = e as grpc.ServiceError;
- if (error.code === 6) {
- return { type: 'name already exists' };
- } else {
- throw error;
- }
- }
- }
-
- public async deleteCustomList(id: string): Promise<void> {
- await this.callString<Empty>(this.client.deleteCustomList, id);
- }
-
- public async updateCustomList(customList: ICustomList): Promise<void | CustomListError> {
- try {
- await this.call<grpcTypes.CustomList, Empty>(
- this.client.updateCustomList,
- convertToCustomList(customList),
- );
- } catch (e) {
- const error = e as grpc.ServiceError;
- if (error.code === 6) {
- return { type: 'name already exists' };
- } else {
- throw error;
- }
- }
- }
-
- public async addApiAccessMethod(method: NewAccessMethodSetting): Promise<string> {
- const result = await this.call<grpcTypes.NewAccessMethodSetting, grpcTypes.UUID>(
- this.client.addApiAccessMethod,
- convertToNewApiAccessMethodSetting(method),
- );
- return result.getValue();
- }
-
- public async updateApiAccessMethod(method: AccessMethodSetting) {
- await this.call(this.client.updateApiAccessMethod, convertToApiAccessMethodSetting(method));
- }
-
- public async getCurrentApiAccessMethod() {
- const response = await this.callEmpty<grpcTypes.AccessMethodSetting>(
- this.client.getCurrentApiAccessMethod,
- );
- return convertFromApiAccessMethodSetting(response);
- }
-
- public async removeApiAccessMethod(id: string) {
- const uuid = new grpcTypes.UUID();
- uuid.setValue(id);
- await this.call(this.client.removeApiAccessMethod, uuid);
- }
-
- public async setApiAccessMethod(id: string) {
- const uuid = new grpcTypes.UUID();
- uuid.setValue(id);
- await this.call(this.client.setApiAccessMethod, uuid);
- }
-
- public async testApiAccessMethodById(id: string): Promise<boolean> {
- const uuid = new grpcTypes.UUID();
- uuid.setValue(id);
- const result = await this.call<grpcTypes.UUID, BoolValue>(
- this.client.testApiAccessMethodById,
- uuid,
- );
- return result.getValue();
- }
-
- public async testCustomApiAccessMethod(method: CustomProxy): Promise<boolean> {
- const result = await this.call<grpcTypes.CustomProxy, BoolValue>(
- this.client.testCustomApiAccessMethod,
- convertToCustomProxy(method),
- );
- return result.getValue();
- }
-
- public async applyJsonSettings(settings: string): Promise<void> {
- await this.callString(this.client.applyJsonSettings, settings);
- }
-
- public async clearAllRelayOverrides(): Promise<void> {
- await this.callEmpty(this.client.clearAllRelayOverrides);
- }
-
- private subscriptionId(): number {
- const current = this.nextSubscriptionId;
- this.nextSubscriptionId += 1;
- return current;
- }
-
- private removeSubscription(id: number) {
- const subscription = this.subscriptions.get(id);
- if (subscription !== undefined) {
- this.subscriptions.delete(id);
- subscription.removeAllListeners('data');
- subscription.removeAllListeners('error');
-
- subscription.on('error', (e) => {
- const error = e as grpc.ServiceError;
- if (error.code !== grpc.status.CANCELLED) {
- throw error;
- }
- });
- // setImmediate is required due to https://github.com/grpc/grpc-node/issues/1464. Should be
- // possible to remove it again after upgrading to Electron 16 which is using a node version
- // where this is fixed.
- setImmediate(() => subscription.cancel());
- }
- }
-}
diff --git a/gui/src/main/default-settings.ts b/gui/src/main/default-settings.ts
deleted file mode 100644
index bebc5b9a4e..0000000000
--- a/gui/src/main/default-settings.ts
+++ /dev/null
@@ -1,115 +0,0 @@
-import {
- ApiAccessMethodSettings,
- ISettings,
- ObfuscationType,
- Ownership,
-} from '../shared/daemon-rpc-types';
-
-export function getDefaultSettings(): ISettings {
- return {
- allowLan: false,
- autoConnect: false,
- blockWhenDisconnected: false,
- showBetaReleases: false,
- splitTunnel: {
- enableExclusions: false,
- appsList: [],
- },
- relaySettings: {
- normal: {
- location: 'any',
- tunnelProtocol: 'any',
- providers: [],
- ownership: Ownership.any,
- openvpnConstraints: {
- port: 'any',
- protocol: 'any',
- },
- wireguardConstraints: {
- port: 'any',
- ipVersion: 'any',
- useMultihop: false,
- entryLocation: 'any',
- },
- },
- },
- bridgeSettings: {
- type: 'normal',
- normal: {
- location: 'any',
- providers: [],
- ownership: Ownership.any,
- },
- custom: undefined,
- },
- bridgeState: 'auto',
- tunnelOptions: {
- generic: {
- enableIpv6: false,
- },
- openvpn: {
- mssfix: undefined,
- },
- wireguard: {
- mtu: undefined,
- quantumResistant: undefined,
- daita: {
- enabled: false,
- directOnly: false,
- },
- },
- dns: {
- state: 'default',
- defaultOptions: {
- blockAds: false,
- blockTrackers: false,
- blockMalware: false,
- blockAdultContent: false,
- blockGambling: false,
- blockSocialMedia: false,
- },
- customOptions: {
- addresses: [],
- },
- },
- },
- obfuscationSettings: {
- selectedObfuscation: ObfuscationType.auto,
- udp2tcpSettings: {
- port: 'any',
- },
- shadowsocksSettings: {
- port: 'any',
- },
- },
- customLists: [],
- apiAccessMethods: getDefaultApiAccessMethods(),
- relayOverrides: [],
- };
-}
-
-export function getDefaultApiAccessMethods(): ApiAccessMethodSettings {
- // 'id's are UUIDs generated by the daemon when an access method is created,
- // and as such we can't provide a good default value for them.
- return {
- direct: {
- id: '',
- name: 'Direct',
- enabled: true,
- type: 'direct',
- },
- mullvadBridges: {
- id: '',
- name: 'Mullvad Bridges',
- enabled: false,
- type: 'bridges',
- },
- encryptedDnsProxy: {
- id: '',
- name: 'Encrypted DNS Proxy',
- enabled: false,
- type: 'encrypted-dns-proxy',
- },
- custom: [],
- };
-}
diff --git a/gui/src/main/expectation.ts b/gui/src/main/expectation.ts
deleted file mode 100644
index 4141b46ffb..0000000000
--- a/gui/src/main/expectation.ts
+++ /dev/null
@@ -1,23 +0,0 @@
-export default class Expectation {
- private fulfilled = false;
- private timeout: NodeJS.Timeout;
-
- constructor(
- private handler: () => void,
- timeout = 2000,
- ) {
- this.timeout = global.setTimeout(() => {
- this.fulfill();
- }, timeout);
- }
-
- public fulfill() {
- if (this.fulfilled) {
- return;
- }
-
- this.fulfilled = true;
- global.clearTimeout(this.timeout);
- this.handler();
- }
-}
diff --git a/gui/src/main/grpc-client.ts b/gui/src/main/grpc-client.ts
deleted file mode 100644
index fa04cdf855..0000000000
--- a/gui/src/main/grpc-client.ts
+++ /dev/null
@@ -1,246 +0,0 @@
-import * as grpc from '@grpc/grpc-js';
-import { Empty } from 'google-protobuf/google/protobuf/empty_pb.js';
-import {
- BoolValue,
- StringValue,
- UInt32Value,
-} from 'google-protobuf/google/protobuf/wrappers_pb.js';
-import { promisify } from 'util';
-
-import log from '../shared/logging';
-import { ManagementServiceClient } from './management_interface/management_interface_grpc_pb';
-
-const NETWORK_CALL_TIMEOUT = 10000;
-const CHANNEL_STATE_TIMEOUT = 1000 * 60 * 60;
-
-type CallFunctionArgument<T, R> =
- | ((arg: T, callback: (error: Error | null, result: R) => void) => void)
- | undefined;
-
-export const noConnectionError = new Error('No connection established to daemon');
-
-export class ConnectionObserver {
- constructor(
- private openHandler: () => void,
- private closeHandler: (wasConnected: boolean, error?: Error) => void,
- ) {}
-
- // Only meant to be called by DaemonRpc
- // @internal
- public onOpen = () => {
- this.openHandler();
- };
-
- // Only meant to be called by DaemonRpc
- // @internal
- public onClose = (wasConnected: boolean, error?: Error) => {
- this.closeHandler(wasConnected, error);
- };
-}
-
-export class GrpcClient {
- protected client: ManagementServiceClient;
- private isConnectedValue = false;
- private isClosed = false;
- private reconnectionTimeout?: NodeJS.Timeout;
-
- constructor(
- private rpcPath: string,
- private connectionObserver?: ConnectionObserver,
- ) {
- this.client = new ManagementServiceClient(
- rpcPath,
- grpc.credentials.createInsecure(),
- this.channelOptions(),
- );
- }
-
- public get isConnected() {
- return this.isConnectedValue;
- }
-
- public reopen(connectionObserver?: ConnectionObserver) {
- if (this.isClosed) {
- this.isClosed = false;
- this.client = new ManagementServiceClient(
- this.rpcPath,
- grpc.credentials.createInsecure(),
- this.channelOptions(),
- );
-
- this.connectionObserver = connectionObserver;
- }
- }
-
- public connect(): Promise<void> {
- return new Promise((resolve, reject) => {
- const usedClient = this.client;
- this.client.waitForReady(this.deadlineFromNow(), (error) => {
- if (this.client !== usedClient) {
- reject(new Error('Stale connection attempt'));
- return;
- }
-
- if (error) {
- this.onClose(error);
- this.ensureConnectivity();
- reject(error);
- } else {
- this.reconnectionTimeout = undefined;
- this.isConnectedValue = true;
- this.connectionObserver?.onOpen();
- this.setChannelCallback();
- resolve();
- }
- });
- });
- }
-
- public disconnect() {
- this.isConnectedValue = false;
-
- this.isClosed = true;
- this.client.close();
- this.connectionObserver = undefined;
- if (this.reconnectionTimeout) {
- clearTimeout(this.reconnectionTimeout);
- }
- }
-
- protected callEmpty<R = Empty>(fn: CallFunctionArgument<Empty, R>): Promise<R> {
- return this.call<Empty, R>(fn, new Empty());
- }
-
- protected callString<R = Empty>(
- fn: CallFunctionArgument<StringValue, R>,
- value?: string,
- ): Promise<R> {
- const googleString = new StringValue();
-
- if (value !== undefined) {
- googleString.setValue(value);
- }
-
- return this.call<StringValue, R>(fn, googleString);
- }
-
- protected callBool<R>(fn: CallFunctionArgument<BoolValue, R>, value?: boolean): Promise<R> {
- const googleBool = new BoolValue();
-
- if (value !== undefined) {
- googleBool.setValue(value);
- }
-
- return this.call<BoolValue, R>(fn, googleBool);
- }
-
- protected callNumber<R>(fn: CallFunctionArgument<UInt32Value, R>, value?: number): Promise<R> {
- const googleNumber = new UInt32Value();
-
- if (value !== undefined) {
- googleNumber.setValue(value);
- }
-
- return this.call<UInt32Value, R>(fn, googleNumber);
- }
-
- protected call<T, R>(fn: CallFunctionArgument<T, R>, arg: T): Promise<R> {
- if (fn && this.isConnected) {
- return promisify<T, R>(fn.bind(this.client))(arg);
- } else {
- throw noConnectionError;
- }
- }
-
- private deadlineFromNow() {
- return Date.now() + NETWORK_CALL_TIMEOUT;
- }
-
- private channelStateTimeout(): number {
- return Date.now() + CHANNEL_STATE_TIMEOUT;
- }
-
- private onClose(error?: Error) {
- const wasConnected = this.isConnectedValue;
- this.isConnectedValue = false;
-
- this.connectionObserver?.onClose(wasConnected, error);
- }
-
- private channelOptions(): grpc.ClientOptions {
- return {
- 'grpc.max_reconnect_backoff_ms': 3000,
- 'grpc.initial_reconnect_backoff_ms': 3000,
- 'grpc.keepalive_time_ms': Math.pow(2, 30),
- 'grpc.keepalive_timeout_ms': Math.pow(2, 30),
- 'grpc.client_idle_timeout_ms': Math.pow(2, 30),
- };
- }
-
- private connectivityChangeCallback(timeoutErr?: Error) {
- const channel = this.client.getChannel();
- const currentState = channel?.getConnectivityState(true);
- log.verbose(`GRPC Channel connectivity state changed to ${currentState}`);
- if (channel) {
- if (timeoutErr) {
- this.setChannelCallback(currentState);
- return;
- }
- const wasConnected = this.isConnected;
- if (this.channelDisconnected(currentState)) {
- this.onClose();
- // Try and reconnect in case
- void this.connect().catch((error) => {
- log.error(`Failed to reconnect - ${error}`);
- });
- this.setChannelCallback(currentState);
- } else if (!wasConnected && currentState === grpc.connectivityState.READY) {
- this.isConnectedValue = true;
- this.connectionObserver?.onOpen();
- this.setChannelCallback(currentState);
- }
- }
- }
-
- private channelDisconnected(state: grpc.connectivityState): boolean {
- return (
- (state === grpc.connectivityState.SHUTDOWN ||
- state === grpc.connectivityState.TRANSIENT_FAILURE ||
- state === grpc.connectivityState.IDLE) &&
- this.isConnected
- );
- }
-
- private setChannelCallback(currentState?: grpc.connectivityState) {
- const channel = this.client.getChannel();
- if (currentState === undefined && channel) {
- currentState = channel?.getConnectivityState(false);
- }
- if (currentState) {
- channel.watchConnectivityState(currentState, this.channelStateTimeout(), (error) =>
- this.connectivityChangeCallback(error),
- );
- }
- }
-
- // Since grpc.Channel.watchConnectivityState() isn't always running as intended, whenever the
- // client fails to connect at first, `ensureConnectivity()` should be called so that it tries to
- // check the connectivity state and nudge the client into connecting.
- // `grpc.Channel.getConnectivityState(true)` should make it attempt to connect.
- private ensureConnectivity() {
- if (this.reconnectionTimeout) {
- clearTimeout(this.reconnectionTimeout);
- }
- this.reconnectionTimeout = setTimeout(() => {
- const lastState = this.client.getChannel().getConnectivityState(true);
- if (this.channelDisconnected(lastState)) {
- this.onClose();
- }
- if (!this.isConnected) {
- void this.connect().catch((error) => {
- log.error(`Failed to reconnect - ${error}`);
- });
- }
- }, 3000);
- }
-}
diff --git a/gui/src/main/grpc-type-convertions.ts b/gui/src/main/grpc-type-convertions.ts
deleted file mode 100644
index 37e5c7fcf5..0000000000
--- a/gui/src/main/grpc-type-convertions.ts
+++ /dev/null
@@ -1,1281 +0,0 @@
-import {
- AccessMethod,
- AccessMethodSetting,
- AfterDisconnect,
- ApiAccessMethodSettings,
- AuthFailedError,
- BridgeSettings,
- BridgesMethod,
- BridgeState,
- BridgeType,
- ConnectionConfig,
- Constraint,
- CustomLists,
- CustomProxy,
- DaemonEvent,
- DeviceEvent,
- DeviceState,
- DirectMethod,
- EncryptedDnsProxy,
- EndpointObfuscationType,
- ErrorStateCause,
- ErrorStateDetails,
- FeatureIndicator,
- FirewallPolicyError,
- FirewallPolicyErrorType,
- IBridgeConstraints,
- ICustomList,
- IDevice,
- IObfuscationEndpoint,
- IOpenVpnConstraints,
- IProxyEndpoint,
- IRelayListCity,
- IRelayListCountry,
- IRelayListHostname,
- IRelayListWithEndpointData,
- IRelaySettingsNormal,
- ISettings,
- ITunnelOptions,
- ITunnelStateRelayInfo,
- IWireguardConstraints,
- IWireguardEndpointData,
- LoggedInDeviceState,
- LoggedOutDeviceState,
- NewAccessMethodSetting,
- ObfuscationSettings,
- ObfuscationType,
- Ownership,
- ProxyType,
- RelayEndpointType,
- RelayLocation,
- RelayLocationGeographical,
- RelayProtocol,
- RelaySettings,
- SocksAuth,
- TunnelParameterError,
- TunnelProtocol,
- TunnelState,
- TunnelType,
- wrapConstraint,
-} from '../shared/daemon-rpc-types';
-import * as grpcTypes from './management_interface/management_interface_pb';
-
-const invalidErrorStateCause = new Error(
- 'VPN_PERMISSION_DENIED is not a valid error state cause on desktop',
-);
-
-export class ResponseParseError extends Error {
- constructor(message: string) {
- super(message);
- }
-}
-
-function unwrapConstraint<T>(constraint: Constraint<T> | undefined): T | undefined {
- if (constraint !== undefined && constraint !== 'any') {
- return constraint.only;
- }
- return undefined;
-}
-
-export function convertFromRelayList(relayList: grpcTypes.RelayList): IRelayListWithEndpointData {
- return {
- relayList: {
- countries: relayList
- .getCountriesList()
- .map((country: grpcTypes.RelayListCountry) => convertFromRelayListCountry(country)),
- },
- wireguardEndpointData: convertWireguardEndpointData(relayList.getWireguard()!),
- };
-}
-
-function convertWireguardEndpointData(
- data: grpcTypes.WireguardEndpointData,
-): IWireguardEndpointData {
- return {
- portRanges: data.getPortRangesList().map((range) => [range.getFirst(), range.getLast()]),
- udp2tcpPorts: data.getUdp2tcpPortsList(),
- };
-}
-
-function convertFromRelayListCountry(country: grpcTypes.RelayListCountry): IRelayListCountry {
- const countryObject = country.toObject();
- return {
- ...countryObject,
- cities: country.getCitiesList().map(convertFromRelayListCity),
- };
-}
-
-function convertFromRelayListCity(city: grpcTypes.RelayListCity): IRelayListCity {
- const cityObject = city.toObject();
- return {
- ...cityObject,
- relays: city.getRelaysList().map(convertFromRelayListRelay),
- };
-}
-
-function convertFromRelayListRelay(relay: grpcTypes.Relay): IRelayListHostname {
- const relayObject = relay.toObject();
-
- let daita = false;
- if (relayObject.endpointType === grpcTypes.Relay.RelayType.WIREGUARD) {
- const endpointDataU8 = relay.getEndpointData()?.getValue_asU8();
- if (endpointDataU8) {
- daita = grpcTypes.WireguardRelayEndpointData.deserializeBinary(endpointDataU8).getDaita();
- }
- }
-
- return {
- ...relayObject,
- endpointType: convertFromRelayType(relayObject.endpointType),
- daita,
- };
-}
-
-function convertFromRelayType(relayType: grpcTypes.Relay.RelayType): RelayEndpointType {
- const protocolMap: Record<grpcTypes.Relay.RelayType, RelayEndpointType> = {
- [grpcTypes.Relay.RelayType.OPENVPN]: 'openvpn',
- [grpcTypes.Relay.RelayType.BRIDGE]: 'bridge',
- [grpcTypes.Relay.RelayType.WIREGUARD]: 'wireguard',
- };
- return protocolMap[relayType];
-}
-
-function convertFromWireguardKey(publicKey: Uint8Array | string): string {
- if (typeof publicKey === 'string') {
- return publicKey;
- }
- return Buffer.from(publicKey).toString('base64');
-}
-
-function convertFromTransportProtocol(protocol: grpcTypes.TransportProtocol): RelayProtocol {
- const protocolMap: Record<grpcTypes.TransportProtocol, RelayProtocol> = {
- [grpcTypes.TransportProtocol.TCP]: 'tcp',
- [grpcTypes.TransportProtocol.UDP]: 'udp',
- };
- return protocolMap[protocol];
-}
-
-export function convertFromTunnelState(
- tunnelState: grpcTypes.TunnelState,
-): TunnelState | undefined {
- const tunnelStateObject = tunnelState.toObject();
- switch (tunnelState.getStateCase()) {
- case grpcTypes.TunnelState.StateCase.STATE_NOT_SET:
- return undefined;
- case grpcTypes.TunnelState.StateCase.DISCONNECTED:
- return {
- state: 'disconnected',
- location: tunnelStateObject.disconnected!.disconnectedLocation,
- };
- case grpcTypes.TunnelState.StateCase.DISCONNECTING: {
- const detailsMap: Record<grpcTypes.AfterDisconnect, AfterDisconnect> = {
- [grpcTypes.AfterDisconnect.NOTHING]: 'nothing',
- [grpcTypes.AfterDisconnect.BLOCK]: 'block',
- [grpcTypes.AfterDisconnect.RECONNECT]: 'reconnect',
- };
- return (
- tunnelStateObject.disconnecting && {
- state: 'disconnecting',
- details: detailsMap[tunnelStateObject.disconnecting.afterDisconnect],
- }
- );
- }
- case grpcTypes.TunnelState.StateCase.ERROR:
- return (
- tunnelStateObject.error?.errorState && {
- state: 'error',
- details: convertFromTunnelStateError(tunnelStateObject.error.errorState),
- }
- );
- case grpcTypes.TunnelState.StateCase.CONNECTING:
- return {
- state: 'connecting',
- details:
- tunnelStateObject.connecting?.relayInfo &&
- convertFromTunnelStateRelayInfo(tunnelStateObject.connecting.relayInfo),
- featureIndicators: convertFromFeatureIndicators(
- tunnelStateObject.connecting?.featureIndicators?.activeFeaturesList,
- ),
- };
- case grpcTypes.TunnelState.StateCase.CONNECTED: {
- const relayInfo =
- tunnelStateObject.connected?.relayInfo &&
- convertFromTunnelStateRelayInfo(tunnelStateObject.connected.relayInfo);
- return (
- relayInfo && {
- state: 'connected',
- details: relayInfo,
- featureIndicators: convertFromFeatureIndicators(
- tunnelStateObject.connected?.featureIndicators?.activeFeaturesList,
- ),
- }
- );
- }
- }
-}
-
-function convertFromTunnelStateError(state: grpcTypes.ErrorState.AsObject): ErrorStateDetails {
- const baseError = {
- blockingError: state.blockingError && convertFromBlockingError(state.blockingError),
- };
-
- switch (state.cause) {
- case grpcTypes.ErrorState.Cause.AUTH_FAILED:
- return {
- ...baseError,
- cause: ErrorStateCause.authFailed,
- authFailedError: convertFromAuthFailedError(state.authFailedError),
- };
- case grpcTypes.ErrorState.Cause.TUNNEL_PARAMETER_ERROR:
- return {
- ...baseError,
- cause: ErrorStateCause.tunnelParameterError,
- parameterError: convertFromParameterError(state.parameterError),
- };
- case grpcTypes.ErrorState.Cause.SET_FIREWALL_POLICY_ERROR:
- return {
- ...baseError,
- cause: ErrorStateCause.setFirewallPolicyError,
- policyError: convertFromBlockingError(state.policyError!),
- };
-
- case grpcTypes.ErrorState.Cause.IS_OFFLINE:
- return {
- ...baseError,
- cause: ErrorStateCause.isOffline,
- };
- case grpcTypes.ErrorState.Cause.SET_DNS_ERROR:
- return {
- ...baseError,
- cause: ErrorStateCause.setDnsError,
- };
- case grpcTypes.ErrorState.Cause.IPV6_UNAVAILABLE:
- return {
- ...baseError,
- cause: ErrorStateCause.ipv6Unavailable,
- };
- case grpcTypes.ErrorState.Cause.START_TUNNEL_ERROR:
- return {
- ...baseError,
- cause: ErrorStateCause.startTunnelError,
- };
- case grpcTypes.ErrorState.Cause.CREATE_TUNNEL_DEVICE:
- return {
- ...baseError,
- cause: ErrorStateCause.createTunnelDeviceError,
- osError: state.createTunnelError,
- };
- case grpcTypes.ErrorState.Cause.SPLIT_TUNNEL_ERROR:
- return {
- ...baseError,
- cause: ErrorStateCause.splitTunnelError,
- };
- case grpcTypes.ErrorState.Cause.NEED_FULL_DISK_PERMISSIONS:
- return {
- ...baseError,
- cause: ErrorStateCause.needFullDiskPermissions,
- };
- case grpcTypes.ErrorState.Cause.VPN_PERMISSION_DENIED:
- // VPN_PERMISSION_DENIED is only ever created on Android
- throw invalidErrorStateCause;
- }
-}
-
-function convertFromBlockingError(
- error: grpcTypes.ErrorState.FirewallPolicyError.AsObject,
-): FirewallPolicyError {
- switch (error.type) {
- case grpcTypes.ErrorState.FirewallPolicyError.ErrorType.GENERIC:
- return { type: FirewallPolicyErrorType.generic };
- case grpcTypes.ErrorState.FirewallPolicyError.ErrorType.LOCKED: {
- const pid = error.lockPid;
- const name = error.lockName!;
- return { type: FirewallPolicyErrorType.locked, pid, name };
- }
- }
-}
-
-function convertFromAuthFailedError(error: grpcTypes.ErrorState.AuthFailedError): AuthFailedError {
- switch (error) {
- case grpcTypes.ErrorState.AuthFailedError.UNKNOWN:
- return AuthFailedError.unknown;
- case grpcTypes.ErrorState.AuthFailedError.INVALID_ACCOUNT:
- return AuthFailedError.invalidAccount;
- case grpcTypes.ErrorState.AuthFailedError.EXPIRED_ACCOUNT:
- return AuthFailedError.expiredAccount;
- case grpcTypes.ErrorState.AuthFailedError.TOO_MANY_CONNECTIONS:
- return AuthFailedError.tooManyConnections;
- }
-}
-
-function convertFromParameterError(
- error: grpcTypes.ErrorState.GenerationError,
-): TunnelParameterError {
- switch (error) {
- case grpcTypes.ErrorState.GenerationError.NO_MATCHING_RELAY:
- return TunnelParameterError.noMatchingRelay;
- case grpcTypes.ErrorState.GenerationError.NO_MATCHING_BRIDGE_RELAY:
- return TunnelParameterError.noMatchingBridgeRelay;
- case grpcTypes.ErrorState.GenerationError.NO_WIREGUARD_KEY:
- return TunnelParameterError.noWireguardKey;
- case grpcTypes.ErrorState.GenerationError.CUSTOM_TUNNEL_HOST_RESOLUTION_ERROR:
- return TunnelParameterError.customTunnelHostResolutionError;
- }
-}
-
-function convertFromTunnelStateRelayInfo(
- state: grpcTypes.TunnelStateRelayInfo.AsObject,
-): ITunnelStateRelayInfo | undefined {
- if (state.tunnelEndpoint) {
- return {
- ...state,
- endpoint: {
- ...state.tunnelEndpoint,
- tunnelType: convertFromTunnelType(state.tunnelEndpoint.tunnelType),
- protocol: convertFromTransportProtocol(state.tunnelEndpoint.protocol),
- proxy: state.tunnelEndpoint.proxy && convertFromProxyEndpoint(state.tunnelEndpoint.proxy),
- obfuscationEndpoint:
- state.tunnelEndpoint.obfuscation &&
- convertFromObfuscationEndpoint(state.tunnelEndpoint.obfuscation),
- entryEndpoint:
- state.tunnelEndpoint.entryEndpoint &&
- convertFromEntryEndpoint(state.tunnelEndpoint.entryEndpoint),
- },
- };
- }
- return undefined;
-}
-
-function convertFromFeatureIndicators(
- featureIndicators?: Array<grpcTypes.FeatureIndicator>,
-): Array<FeatureIndicator> | undefined {
- return featureIndicators?.map(convertFromFeatureIndicator);
-}
-
-function convertFromFeatureIndicator(
- featureIndicator: grpcTypes.FeatureIndicator,
-): FeatureIndicator {
- switch (featureIndicator) {
- case grpcTypes.FeatureIndicator.QUANTUM_RESISTANCE:
- return FeatureIndicator.quantumResistance;
- case grpcTypes.FeatureIndicator.MULTIHOP:
- return FeatureIndicator.multihop;
- case grpcTypes.FeatureIndicator.BRIDGE_MODE:
- return FeatureIndicator.bridgeMode;
- case grpcTypes.FeatureIndicator.SPLIT_TUNNELING:
- return FeatureIndicator.splitTunneling;
- case grpcTypes.FeatureIndicator.LOCKDOWN_MODE:
- return FeatureIndicator.lockdownMode;
- case grpcTypes.FeatureIndicator.UDP_2_TCP:
- return FeatureIndicator.udp2tcp;
- case grpcTypes.FeatureIndicator.LAN_SHARING:
- return FeatureIndicator.lanSharing;
- case grpcTypes.FeatureIndicator.DNS_CONTENT_BLOCKERS:
- return FeatureIndicator.dnsContentBlockers;
- case grpcTypes.FeatureIndicator.CUSTOM_DNS:
- return FeatureIndicator.customDns;
- case grpcTypes.FeatureIndicator.SERVER_IP_OVERRIDE:
- return FeatureIndicator.serverIpOverride;
- case grpcTypes.FeatureIndicator.CUSTOM_MTU:
- return FeatureIndicator.customMtu;
- case grpcTypes.FeatureIndicator.CUSTOM_MSS_FIX:
- return FeatureIndicator.customMssFix;
- case grpcTypes.FeatureIndicator.DAITA:
- return FeatureIndicator.daita;
- case grpcTypes.FeatureIndicator.SHADOWSOCKS:
- return FeatureIndicator.shadowsocks;
- }
-}
-
-function convertFromTunnelType(tunnelType: grpcTypes.TunnelType): TunnelType {
- const tunnelTypeMap: Record<grpcTypes.TunnelType, TunnelType> = {
- [grpcTypes.TunnelType.WIREGUARD]: 'wireguard',
- [grpcTypes.TunnelType.OPENVPN]: 'openvpn',
- };
-
- return tunnelTypeMap[tunnelType];
-}
-
-function convertFromProxyEndpoint(proxyEndpoint: grpcTypes.ProxyEndpoint.AsObject): IProxyEndpoint {
- const proxyTypeMap: Record<grpcTypes.ProxyEndpoint.ProxyType, ProxyType> = {
- [grpcTypes.ProxyEndpoint.ProxyType.CUSTOM]: 'custom',
- [grpcTypes.ProxyEndpoint.ProxyType.SHADOWSOCKS]: 'shadowsocks',
- };
-
- return {
- ...proxyEndpoint,
- protocol: convertFromTransportProtocol(proxyEndpoint.protocol),
- proxyType: proxyTypeMap[proxyEndpoint.proxyType],
- };
-}
-
-function convertFromObfuscationEndpoint(
- obfuscationEndpoint: grpcTypes.ObfuscationEndpoint.AsObject,
-): IObfuscationEndpoint {
- let obfuscationType: EndpointObfuscationType;
- switch (obfuscationEndpoint.obfuscationType) {
- case grpcTypes.ObfuscationEndpoint.ObfuscationType.UDP2TCP:
- obfuscationType = 'udp2tcp';
- break;
- case grpcTypes.ObfuscationEndpoint.ObfuscationType.SHADOWSOCKS:
- obfuscationType = 'shadowsocks';
- break;
- default:
- throw new Error('unsupported obfuscation protocol');
- }
-
- return {
- ...obfuscationEndpoint,
- protocol: convertFromTransportProtocol(obfuscationEndpoint.protocol),
- obfuscationType: obfuscationType,
- };
-}
-
-function convertFromEntryEndpoint(entryEndpoint: grpcTypes.Endpoint.AsObject) {
- return {
- address: entryEndpoint.address,
- transportProtocol: convertFromTransportProtocol(entryEndpoint.protocol),
- };
-}
-
-export function convertFromSettings(settings: grpcTypes.Settings): ISettings | undefined {
- const settingsObject = settings.toObject();
- const bridgeState = convertFromBridgeState(settingsObject.bridgeState!.state!);
- const relaySettings = convertFromRelaySettings(settings.getRelaySettings())!;
- const bridgeSettings = convertFromBridgeSettings(settings.getBridgeSettings()!);
- const tunnelOptions = convertFromTunnelOptions(settingsObject.tunnelOptions!);
- const splitTunnel = settingsObject.splitTunnel ?? { enableExclusions: false, appsList: [] };
- const obfuscationSettings = convertFromObfuscationSettings(settingsObject.obfuscationSettings);
- const customLists = convertFromCustomListSettings(settings.getCustomLists());
- const apiAccessMethods = convertFromApiAccessMethodSettings(settings.getApiAccessMethods()!);
- const relayOverrides = settingsObject.relayOverridesList;
- return {
- ...settings.toObject(),
- bridgeState,
- relaySettings,
- bridgeSettings,
- tunnelOptions,
- splitTunnel,
- obfuscationSettings,
- customLists,
- apiAccessMethods,
- relayOverrides,
- };
-}
-
-function convertFromBridgeState(bridgeState: grpcTypes.BridgeState.State): BridgeState {
- const bridgeStateMap: Record<grpcTypes.BridgeState.State, BridgeState> = {
- [grpcTypes.BridgeState.State.AUTO]: 'auto',
- [grpcTypes.BridgeState.State.ON]: 'on',
- [grpcTypes.BridgeState.State.OFF]: 'off',
- };
-
- return bridgeStateMap[bridgeState];
-}
-
-function convertFromRelaySettings(
- relaySettings?: grpcTypes.RelaySettings,
-): RelaySettings | undefined {
- if (relaySettings) {
- switch (relaySettings.getEndpointCase()) {
- case grpcTypes.RelaySettings.EndpointCase.ENDPOINT_NOT_SET:
- return undefined;
- case grpcTypes.RelaySettings.EndpointCase.CUSTOM: {
- const custom = relaySettings.getCustom()?.toObject();
- const config = relaySettings.getCustom()?.getConfig();
- const connectionConfig = config && convertFromConnectionConfig(config);
- return (
- custom &&
- connectionConfig && {
- customTunnelEndpoint: {
- ...custom,
- config: connectionConfig,
- },
- }
- );
- }
- case grpcTypes.RelaySettings.EndpointCase.NORMAL: {
- const normal = relaySettings.getNormal()!;
- const locationConstraint = convertFromLocationConstraint(normal.getLocation());
- const location = wrapConstraint(locationConstraint);
- // `getTunnelType()` is not falsy if type is 'any'
- const tunnelProtocol = convertFromTunnelTypeConstraint(
- normal.hasTunnelType() ? normal.getTunnelType() : undefined,
- );
- const providers = normal.getProvidersList();
- const ownership = convertFromOwnership(normal.getOwnership());
- const openvpnConstraints = convertFromOpenVpnConstraints(normal.getOpenvpnConstraints()!);
- const wireguardConstraints = convertFromWireguardConstraints(
- normal.getWireguardConstraints()!,
- );
-
- return {
- normal: {
- location,
- tunnelProtocol,
- providers,
- ownership,
- wireguardConstraints,
- openvpnConstraints,
- },
- };
- }
- }
- } else {
- return undefined;
- }
-}
-
-function convertFromBridgeSettings(bridgeSettings: grpcTypes.BridgeSettings): BridgeSettings {
- const bridgeSettingsObject = bridgeSettings.toObject();
-
- const detailsMap: Record<grpcTypes.BridgeSettings.BridgeType, BridgeType> = {
- [grpcTypes.BridgeSettings.BridgeType.NORMAL]: 'normal',
- [grpcTypes.BridgeSettings.BridgeType.CUSTOM]: 'custom',
- };
- const type = detailsMap[bridgeSettingsObject.bridgeType];
-
- const normalSettings = bridgeSettingsObject.normal;
- const locationConstraint = convertFromLocationConstraint(
- bridgeSettings.getNormal()?.getLocation(),
- );
- const location = wrapConstraint(locationConstraint);
- const providers = normalSettings!.providersList;
- const ownership = convertFromOwnership(normalSettings!.ownership);
-
- const normal = {
- location,
- providers,
- ownership,
- };
-
- const grpcCustom = bridgeSettings.getCustom();
- const custom = grpcCustom ? convertFromCustomProxy(grpcCustom) : undefined;
-
- return { type, normal, custom };
-}
-
-function convertFromConnectionConfig(
- connectionConfig: grpcTypes.ConnectionConfig,
-): ConnectionConfig | undefined {
- const connectionConfigObject = connectionConfig.toObject();
- switch (connectionConfig.getConfigCase()) {
- case grpcTypes.ConnectionConfig.ConfigCase.CONFIG_NOT_SET:
- return undefined;
- case grpcTypes.ConnectionConfig.ConfigCase.WIREGUARD:
- return (
- connectionConfigObject.wireguard &&
- connectionConfigObject.wireguard.tunnel &&
- connectionConfigObject.wireguard.peer && {
- wireguard: {
- ...connectionConfigObject.wireguard,
- tunnel: {
- privateKey: convertFromWireguardKey(
- connectionConfigObject.wireguard.tunnel.privateKey,
- ),
- addresses: connectionConfigObject.wireguard.tunnel.addressesList,
- },
- peer: {
- ...connectionConfigObject.wireguard.peer,
- addresses: connectionConfigObject.wireguard.peer.allowedIpsList,
- publicKey: convertFromWireguardKey(connectionConfigObject.wireguard.peer.publicKey),
- },
- },
- }
- );
- case grpcTypes.ConnectionConfig.ConfigCase.OPENVPN: {
- const [ip, port] = connectionConfigObject.openvpn!.address.split(':');
- return {
- openvpn: {
- ...connectionConfigObject.openvpn!,
- endpoint: {
- ip,
- port: parseInt(port, 10),
- protocol: convertFromTransportProtocol(connectionConfigObject.openvpn!.protocol),
- },
- },
- };
- }
- }
-}
-
-function convertFromLocationConstraint(
- location?: grpcTypes.LocationConstraint,
-): RelayLocation | undefined {
- if (location === undefined) {
- return undefined;
- } else if (location.getTypeCase() === grpcTypes.LocationConstraint.TypeCase.CUSTOM_LIST) {
- return { customList: location.getCustomList() };
- } else {
- const innerLocation = location.getLocation()?.toObject();
- return innerLocation && convertFromGeographicConstraint(innerLocation);
- }
-}
-
-function convertFromGeographicConstraint(
- location: grpcTypes.GeographicLocationConstraint.AsObject,
-): RelayLocation {
- if (location.hostname) {
- return location;
- } else if (location.city) {
- return {
- country: location.country,
- city: location.city,
- };
- } else {
- return {
- country: location.country,
- };
- }
-}
-
-function convertFromTunnelOptions(tunnelOptions: grpcTypes.TunnelOptions.AsObject): ITunnelOptions {
- return {
- openvpn: {
- mssfix: tunnelOptions.openvpn!.mssfix,
- },
- wireguard: {
- mtu: tunnelOptions.wireguard!.mtu,
- quantumResistant: convertFromQuantumResistantState(
- tunnelOptions.wireguard?.quantumResistant?.state,
- ),
- daita: tunnelOptions.wireguard!.daita,
- },
- generic: {
- enableIpv6: tunnelOptions.generic!.enableIpv6,
- },
- dns: {
- state:
- tunnelOptions.dnsOptions?.state === grpcTypes.DnsOptions.DnsState.CUSTOM
- ? 'custom'
- : 'default',
- defaultOptions: {
- blockAds: tunnelOptions.dnsOptions?.defaultOptions?.blockAds ?? false,
- blockTrackers: tunnelOptions.dnsOptions?.defaultOptions?.blockTrackers ?? false,
- blockMalware: tunnelOptions.dnsOptions?.defaultOptions?.blockMalware ?? false,
- blockAdultContent: tunnelOptions.dnsOptions?.defaultOptions?.blockAdultContent ?? false,
- blockGambling: tunnelOptions.dnsOptions?.defaultOptions?.blockGambling ?? false,
- blockSocialMedia: tunnelOptions.dnsOptions?.defaultOptions?.blockSocialMedia ?? false,
- },
- customOptions: {
- addresses: tunnelOptions.dnsOptions?.customOptions?.addressesList ?? [],
- },
- },
- };
-}
-
-function convertFromQuantumResistantState(
- state?: grpcTypes.QuantumResistantState.State,
-): boolean | undefined {
- return state === undefined
- ? undefined
- : {
- [grpcTypes.QuantumResistantState.State.ON]: true,
- [grpcTypes.QuantumResistantState.State.OFF]: false,
- [grpcTypes.QuantumResistantState.State.AUTO]: undefined,
- }[state];
-}
-
-function convertFromObfuscationSettings(
- obfuscationSettings?: grpcTypes.ObfuscationSettings.AsObject,
-): ObfuscationSettings {
- let selectedObfuscationType = ObfuscationType.auto;
- switch (obfuscationSettings?.selectedObfuscation) {
- case grpcTypes.ObfuscationSettings.SelectedObfuscation.OFF:
- selectedObfuscationType = ObfuscationType.off;
- break;
- case grpcTypes.ObfuscationSettings.SelectedObfuscation.UDP2TCP:
- selectedObfuscationType = ObfuscationType.udp2tcp;
- break;
- case grpcTypes.ObfuscationSettings.SelectedObfuscation.SHADOWSOCKS:
- selectedObfuscationType = ObfuscationType.shadowsocks;
- break;
- }
-
- return {
- selectedObfuscation: selectedObfuscationType,
- udp2tcpSettings: obfuscationSettings?.udp2tcp
- ? { port: convertFromConstraint(obfuscationSettings.udp2tcp.port) }
- : { port: 'any' },
- shadowsocksSettings: obfuscationSettings?.shadowsocks
- ? { port: convertFromConstraint(obfuscationSettings.shadowsocks.port) }
- : { port: 'any' },
- };
-}
-
-export function convertFromDaemonEvent(data: grpcTypes.DaemonEvent): DaemonEvent {
- const tunnelState = data.getTunnelState();
- if (tunnelState !== undefined) {
- return { tunnelState: convertFromTunnelState(tunnelState)! };
- }
-
- const settings = data.getSettings();
- if (settings !== undefined) {
- return { settings: convertFromSettings(settings)! };
- }
-
- const relayList = data.getRelayList();
- if (relayList !== undefined) {
- return { relayList: convertFromRelayList(relayList) };
- }
-
- const deviceConfig = data.getDevice();
- if (deviceConfig !== undefined) {
- return { device: convertFromDeviceEvent(deviceConfig) };
- }
-
- const deviceRemoval = data.getRemoveDevice();
- if (deviceRemoval !== undefined) {
- return { deviceRemoval: convertFromDeviceRemoval(deviceRemoval) };
- }
-
- const versionInfo = data.getVersionInfo();
- if (versionInfo !== undefined) {
- return { appVersionInfo: versionInfo.toObject() };
- }
-
- const newAccessMethod = data.getNewAccessMethod();
- if (newAccessMethod !== undefined) {
- return { accessMethodSetting: convertFromApiAccessMethodSetting(newAccessMethod) };
- }
-
- // Handle unknown daemon events
- const keys = Object.entries(data.toObject())
- .filter(([, value]) => value !== undefined)
- .map(([key]) => key);
- throw new Error(`Unknown daemon event received containing ${keys}`);
-}
-
-function convertFromOwnership(ownership: grpcTypes.Ownership): Ownership {
- switch (ownership) {
- case grpcTypes.Ownership.ANY:
- return Ownership.any;
- case grpcTypes.Ownership.MULLVAD_OWNED:
- return Ownership.mullvadOwned;
- case grpcTypes.Ownership.RENTED:
- return Ownership.rented;
- }
-}
-
-function convertToOwnership(ownership: Ownership): grpcTypes.Ownership {
- switch (ownership) {
- case Ownership.any:
- return grpcTypes.Ownership.ANY;
- case Ownership.mullvadOwned:
- return grpcTypes.Ownership.MULLVAD_OWNED;
- case Ownership.rented:
- return grpcTypes.Ownership.RENTED;
- }
-}
-
-function convertFromOpenVpnConstraints(
- constraints: grpcTypes.OpenvpnConstraints,
-): IOpenVpnConstraints {
- const transportPort = convertFromConstraint(constraints.getPort());
- if (transportPort !== 'any' && 'only' in transportPort) {
- const port = convertFromConstraint(transportPort.only.getPort());
- let protocol: Constraint<RelayProtocol> = 'any';
- switch (transportPort.only.getProtocol()) {
- case grpcTypes.TransportProtocol.TCP:
- protocol = { only: 'tcp' };
- break;
- case grpcTypes.TransportProtocol.UDP:
- protocol = { only: 'udp' };
- break;
- }
- return { port, protocol };
- }
- return { port: 'any', protocol: 'any' };
-}
-
-function convertFromWireguardConstraints(
- constraints: grpcTypes.WireguardConstraints,
-): IWireguardConstraints {
- const result: IWireguardConstraints = {
- port: 'any',
- ipVersion: 'any',
- useMultihop: constraints.getUseMultihop(),
- entryLocation: 'any',
- };
-
- const port = constraints.getPort();
- if (port) {
- result.port = { only: port };
- }
-
- // `getIpVersion()` is not falsy if type is 'any'
- if (constraints.hasIpVersion()) {
- switch (constraints.getIpVersion()) {
- case grpcTypes.IpVersion.V4:
- result.ipVersion = { only: 'ipv4' };
- break;
- case grpcTypes.IpVersion.V6:
- result.ipVersion = { only: 'ipv6' };
- break;
- }
- }
-
- const entryLocation = constraints.getEntryLocation();
- if (entryLocation) {
- const location = convertFromLocationConstraint(entryLocation);
- result.entryLocation = wrapConstraint(location);
- }
-
- return result;
-}
-
-function convertFromTunnelTypeConstraint(
- constraint: grpcTypes.TunnelType | undefined,
-): Constraint<TunnelProtocol> {
- switch (constraint) {
- case grpcTypes.TunnelType.WIREGUARD: {
- return { only: 'wireguard' };
- }
- case grpcTypes.TunnelType.OPENVPN: {
- return { only: 'openvpn' };
- }
- default: {
- return 'any';
- }
- }
-}
-
-function convertFromConstraint<T>(value: T | undefined): Constraint<T> {
- if (value) {
- return { only: value };
- } else {
- return 'any';
- }
-}
-
-export function convertToRelayConstraints(
- constraints: IRelaySettingsNormal<IOpenVpnConstraints, IWireguardConstraints>,
-): grpcTypes.NormalRelaySettings {
- const relayConstraints = new grpcTypes.NormalRelaySettings();
-
- if (constraints.tunnelProtocol !== 'any') {
- relayConstraints.setTunnelType(convertToTunnelType(constraints.tunnelProtocol.only));
- }
- relayConstraints.setLocation(convertToLocation(unwrapConstraint(constraints.location)));
- relayConstraints.setWireguardConstraints(
- convertToWireguardConstraints(constraints.wireguardConstraints),
- );
- relayConstraints.setOpenvpnConstraints(
- convertToOpenVpnConstraints(constraints.openvpnConstraints),
- );
- relayConstraints.setProvidersList(constraints.providers);
- relayConstraints.setOwnership(convertToOwnership(constraints.ownership));
-
- return relayConstraints;
-}
-
-export function convertToNormalBridgeSettings(
- constraints: IBridgeConstraints,
-): grpcTypes.BridgeSettings.BridgeConstraints {
- const normalBridgeSettings = new grpcTypes.BridgeSettings.BridgeConstraints();
- normalBridgeSettings.setLocation(convertToLocation(unwrapConstraint(constraints.location)));
- normalBridgeSettings.setProvidersList(constraints.providers);
-
- return normalBridgeSettings;
-}
-
-function convertToLocation(
- constraint: RelayLocation | undefined,
-): grpcTypes.LocationConstraint | undefined {
- const locationConstraint = new grpcTypes.LocationConstraint();
- if (constraint && 'customList' in constraint && constraint.customList) {
- locationConstraint.setCustomList(constraint.customList);
- } else {
- const location = constraint && convertToGeographicConstraint(constraint);
- locationConstraint.setLocation(location);
- }
-
- return locationConstraint;
-}
-
-function convertToGeographicConstraint(
- location: RelayLocation,
-): grpcTypes.GeographicLocationConstraint {
- const relayLocation = new grpcTypes.GeographicLocationConstraint();
- if ('hostname' in location) {
- relayLocation.setCountry(location.country);
- relayLocation.setCity(location.city);
- relayLocation.setHostname(location.hostname);
- } else if ('city' in location) {
- relayLocation.setCountry(location.country);
- relayLocation.setCity(location.city);
- } else if ('country' in location) {
- relayLocation.setCountry(location.country);
- }
-
- return relayLocation;
-}
-
-function convertToTunnelType(tunnelProtocol: TunnelProtocol): grpcTypes.TunnelType {
- switch (tunnelProtocol) {
- case 'wireguard':
- return grpcTypes.TunnelType.WIREGUARD;
- case 'openvpn':
- return grpcTypes.TunnelType.OPENVPN;
- }
-}
-
-function convertToOpenVpnConstraints(
- constraints: Partial<IOpenVpnConstraints> | undefined,
-): grpcTypes.OpenvpnConstraints | undefined {
- const openvpnConstraints = new grpcTypes.OpenvpnConstraints();
- if (constraints) {
- const protocol = unwrapConstraint(constraints.protocol);
- if (protocol) {
- const portConstraints = new grpcTypes.TransportPort();
- const port = unwrapConstraint(constraints.port);
- if (port) {
- portConstraints.setPort(port);
- }
- portConstraints.setProtocol(convertToTransportProtocol(protocol));
- openvpnConstraints.setPort(portConstraints);
- }
- return openvpnConstraints;
- }
-
- return undefined;
-}
-
-function convertToWireguardConstraints(
- constraint: Partial<IWireguardConstraints> | undefined,
-): grpcTypes.WireguardConstraints | undefined {
- if (constraint) {
- const wireguardConstraints = new grpcTypes.WireguardConstraints();
-
- const port = unwrapConstraint(constraint.port);
- if (port) {
- wireguardConstraints.setPort(port);
- }
-
- const ipVersion = unwrapConstraint(constraint.ipVersion);
- if (ipVersion) {
- const ipVersionProtocol =
- ipVersion === 'ipv4' ? grpcTypes.IpVersion.V4 : grpcTypes.IpVersion.V6;
- wireguardConstraints.setIpVersion(ipVersionProtocol);
- }
-
- if (constraint.useMultihop) {
- wireguardConstraints.setUseMultihop(constraint.useMultihop);
- }
-
- const entryLocation = unwrapConstraint(constraint.entryLocation);
- if (entryLocation) {
- const entryLocationConstraint = convertToLocation(entryLocation);
- wireguardConstraints.setEntryLocation(entryLocationConstraint);
- }
-
- return wireguardConstraints;
- }
- return undefined;
-}
-
-function convertToTransportProtocol(protocol: RelayProtocol): grpcTypes.TransportProtocol {
- switch (protocol) {
- case 'udp':
- return grpcTypes.TransportProtocol.UDP;
- case 'tcp':
- return grpcTypes.TransportProtocol.TCP;
- }
-}
-
-function convertFromDeviceEvent(deviceEvent: grpcTypes.DeviceEvent): DeviceEvent {
- const deviceState = convertFromDeviceState(deviceEvent.getNewState()!);
- switch (deviceEvent.getCause()) {
- case grpcTypes.DeviceEvent.Cause.LOGGED_IN:
- return { type: 'logged in', deviceState: deviceState as LoggedInDeviceState };
- case grpcTypes.DeviceEvent.Cause.LOGGED_OUT:
- return { type: 'logged out', deviceState: deviceState as LoggedOutDeviceState };
- case grpcTypes.DeviceEvent.Cause.REVOKED:
- return { type: 'revoked', deviceState: deviceState as LoggedOutDeviceState };
- case grpcTypes.DeviceEvent.Cause.UPDATED:
- return { type: 'updated', deviceState: deviceState as LoggedInDeviceState };
- case grpcTypes.DeviceEvent.Cause.ROTATED_KEY:
- return { type: 'rotated_key', deviceState: deviceState as LoggedInDeviceState };
- }
-}
-
-export function convertFromDeviceState(deviceState: grpcTypes.DeviceState): DeviceState {
- switch (deviceState.getState()) {
- case grpcTypes.DeviceState.State.LOGGED_IN: {
- const accountAndDevice = deviceState.getDevice()!;
- const device = accountAndDevice.getDevice();
- return {
- type: 'logged in',
- accountAndDevice: {
- accountNumber: accountAndDevice.getAccountNumber(),
- device: device && convertFromDevice(device),
- },
- };
- }
- case grpcTypes.DeviceState.State.LOGGED_OUT:
- return { type: 'logged out' };
- case grpcTypes.DeviceState.State.REVOKED:
- return { type: 'revoked' };
- }
-}
-
-function convertFromDeviceRemoval(deviceRemoval: grpcTypes.RemoveDeviceEvent): Array<IDevice> {
- return deviceRemoval.getNewDeviceListList().map(convertFromDevice);
-}
-
-export function convertFromDevice(device: grpcTypes.Device): IDevice {
- const created = ensureExists(device.getCreated(), "no 'created' field for device").toDate();
- const asObject = device.toObject();
-
- return {
- ...asObject,
- created: created,
- };
-}
-
-function convertFromCustomListSettings(
- customListSettings?: grpcTypes.CustomListSettings,
-): CustomLists {
- return customListSettings ? convertFromCustomLists(customListSettings.getCustomListsList()) : [];
-}
-
-function convertFromCustomLists(customLists: Array<grpcTypes.CustomList>): CustomLists {
- return customLists.map((list) => ({
- id: list.getId(),
- name: list.getName(),
- locations: list
- .getLocationsList()
- .map((location) =>
- convertFromGeographicConstraint(location.toObject()),
- ) as Array<RelayLocationGeographical>,
- }));
-}
-
-export function convertToCustomList(customList: ICustomList): grpcTypes.CustomList {
- const grpcCustomList = new grpcTypes.CustomList();
- grpcCustomList.setId(customList.id);
- grpcCustomList.setName(customList.name);
-
- const locations = customList.locations.map(convertToGeographicConstraint);
- grpcCustomList.setLocationsList(locations);
-
- return grpcCustomList;
-}
-
-export function convertToApiAccessMethodSetting(
- method: AccessMethodSetting,
-): grpcTypes.AccessMethodSetting {
- const updatedMethod = new grpcTypes.AccessMethodSetting();
- const uuid = new grpcTypes.UUID();
- uuid.setValue(method.id);
- updatedMethod.setId(uuid);
- return fillApiAccessMethodSetting(updatedMethod, method);
-}
-
-export function convertToNewApiAccessMethodSetting(
- method: NewAccessMethodSetting,
-): grpcTypes.NewAccessMethodSetting {
- const newMethod = new grpcTypes.NewAccessMethodSetting();
- return fillApiAccessMethodSetting(newMethod, method);
-}
-
-function fillApiAccessMethodSetting<T extends grpcTypes.NewAccessMethodSetting>(
- newMethod: T,
- method: NewAccessMethodSetting,
-): T {
- newMethod.setName(method.name);
- newMethod.setEnabled(method.enabled);
-
- const accessMethod = new grpcTypes.AccessMethod();
- switch (method.type) {
- case 'direct': {
- const direct = new grpcTypes.AccessMethod.Direct();
- accessMethod.setDirect(direct);
- break;
- }
- case 'bridges': {
- const bridges = new grpcTypes.AccessMethod.Bridges();
- accessMethod.setBridges(bridges);
- break;
- }
- case 'encrypted-dns-proxy': {
- const encryptedDnsProxy = new grpcTypes.AccessMethod.EncryptedDnsProxy();
- accessMethod.setEncryptedDnsProxy(encryptedDnsProxy);
- break;
- }
- default:
- accessMethod.setCustom(convertToCustomProxy(method));
- }
-
- newMethod.setAccessMethod(accessMethod);
- return newMethod;
-}
-
-export function convertToCustomProxy(proxy: CustomProxy): grpcTypes.CustomProxy {
- const customProxy = new grpcTypes.CustomProxy();
-
- switch (proxy.type) {
- case 'socks5-local': {
- const socks5Local = new grpcTypes.Socks5Local();
- socks5Local.setRemoteIp(proxy.remoteIp);
- socks5Local.setRemotePort(proxy.remotePort);
- socks5Local.setRemoteTransportProtocol(
- convertToTransportProtocol(proxy.remoteTransportProtocol),
- );
- socks5Local.setLocalPort(proxy.localPort);
- customProxy.setSocks5local(socks5Local);
- break;
- }
- case 'socks5-remote': {
- const socks5Remote = new grpcTypes.Socks5Remote();
- socks5Remote.setIp(proxy.ip);
- socks5Remote.setPort(proxy.port);
- if (proxy.authentication !== undefined) {
- socks5Remote.setAuth(convertToSocksAuth(proxy.authentication));
- }
- customProxy.setSocks5remote(socks5Remote);
- break;
- }
- case 'shadowsocks': {
- const shadowsocks = new grpcTypes.Shadowsocks();
- shadowsocks.setIp(proxy.ip);
- shadowsocks.setPort(proxy.port);
- shadowsocks.setPassword(proxy.password);
- shadowsocks.setCipher(proxy.cipher);
- customProxy.setShadowsocks(shadowsocks);
- break;
- }
- }
-
- return customProxy;
-}
-
-function convertToSocksAuth(authentication: SocksAuth): grpcTypes.SocksAuth {
- const auth = new grpcTypes.SocksAuth();
- auth.setUsername(authentication.username);
- auth.setPassword(authentication.password);
- return auth;
-}
-
-function convertFromApiAccessMethodSettings(
- accessMethods: grpcTypes.ApiAccessMethodSettings,
-): ApiAccessMethodSettings {
- const direct = convertFromApiAccessMethodSetting(
- ensureExists(accessMethods.getDirect(), "no 'Direct' access method was found"),
- ) as AccessMethodSetting<DirectMethod>;
- const bridges = convertFromApiAccessMethodSetting(
- ensureExists(accessMethods.getMullvadBridges(), "no 'Mullvad Bridges' access method was found"),
- ) as AccessMethodSetting<BridgesMethod>;
- const encryptedDnsProxy = convertFromApiAccessMethodSetting(
- ensureExists(
- accessMethods.getEncryptedDnsProxy(),
- "no 'Encrypted DNS proxy' access method was found",
- ),
- ) as AccessMethodSetting<EncryptedDnsProxy>;
- const custom = accessMethods
- .getCustomList()
- .filter((setting) => setting.hasId() && setting.hasAccessMethod())
- .map(convertFromApiAccessMethodSetting)
- // The last filter helps TypeScript infer the custom proxy type.
- .filter(isCustomProxy);
-
- return {
- direct,
- mullvadBridges: bridges,
- encryptedDnsProxy,
- custom,
- };
-}
-
-function isCustomProxy(
- accessMethod: AccessMethodSetting,
-): accessMethod is AccessMethodSetting<CustomProxy> {
- return (
- accessMethod.type !== 'direct' &&
- accessMethod.type !== 'bridges' &&
- accessMethod.type !== 'encrypted-dns-proxy'
- );
-}
-
-export function convertFromApiAccessMethodSetting(
- setting: grpcTypes.AccessMethodSetting,
-): AccessMethodSetting {
- const id = setting.getId()!;
- const accessMethod = setting.getAccessMethod()!;
-
- return {
- id: id.getValue(),
- name: setting.getName(),
- enabled: setting.getEnabled(),
- ...convertFromAccessMethod(accessMethod),
- };
-}
-
-function convertFromAccessMethod(method: grpcTypes.AccessMethod): AccessMethod {
- switch (method.getAccessMethodCase()) {
- case grpcTypes.AccessMethod.AccessMethodCase.DIRECT:
- return { type: 'direct' };
- case grpcTypes.AccessMethod.AccessMethodCase.BRIDGES:
- return { type: 'bridges' };
- case grpcTypes.AccessMethod.AccessMethodCase.ENCRYPTED_DNS_PROXY:
- return { type: 'encrypted-dns-proxy' };
- case grpcTypes.AccessMethod.AccessMethodCase.CUSTOM: {
- return convertFromCustomProxy(method.getCustom()!);
- }
- case grpcTypes.AccessMethod.AccessMethodCase.ACCESS_METHOD_NOT_SET:
- throw new Error('Access method not set, which should always be set');
- }
-}
-
-function convertFromCustomProxy(proxy: grpcTypes.CustomProxy): CustomProxy {
- switch (proxy.getProxyMethodCase()) {
- case grpcTypes.CustomProxy.ProxyMethodCase.SOCKS5LOCAL: {
- const socks5Local = proxy.getSocks5local()!;
- return {
- type: 'socks5-local',
- remoteIp: socks5Local.getRemoteIp(),
- remotePort: socks5Local.getRemotePort(),
- remoteTransportProtocol: convertFromTransportProtocol(
- socks5Local.getRemoteTransportProtocol(),
- ),
- localPort: socks5Local.getLocalPort(),
- };
- }
- case grpcTypes.CustomProxy.ProxyMethodCase.SOCKS5REMOTE: {
- const socks5Remote = proxy.getSocks5remote()!;
- const auth = socks5Remote.getAuth();
- return {
- type: 'socks5-remote',
- ip: socks5Remote.getIp(),
- port: socks5Remote.getPort(),
- authentication: auth === undefined ? undefined : convertFromSocksAuth(auth),
- };
- }
- case grpcTypes.CustomProxy.ProxyMethodCase.SHADOWSOCKS: {
- const shadowsocks = proxy.getShadowsocks()!;
- return {
- type: 'shadowsocks',
- ip: shadowsocks.getIp(),
- port: shadowsocks.getPort(),
- password: shadowsocks.getPassword(),
- cipher: shadowsocks.getCipher(),
- };
- }
- case grpcTypes.CustomProxy.ProxyMethodCase.PROXY_METHOD_NOT_SET:
- throw new Error('Custom method not set, which should always be set');
- }
-}
-
-function convertFromSocksAuth(auth: grpcTypes.SocksAuth): SocksAuth {
- return {
- username: auth.getUsername(),
- password: auth.getPassword(),
- };
-}
-
-export function ensureExists<T>(value: T | undefined, errorMessage: string): T {
- if (value) {
- return value;
- }
- throw new ResponseParseError(errorMessage);
-}
diff --git a/gui/src/main/gui-settings.ts b/gui/src/main/gui-settings.ts
deleted file mode 100644
index ae0cff0cab..0000000000
--- a/gui/src/main/gui-settings.ts
+++ /dev/null
@@ -1,203 +0,0 @@
-import { app } from 'electron';
-import * as fs from 'fs';
-import * as path from 'path';
-
-import { IGuiSettingsState, SYSTEM_PREFERRED_LOCALE_KEY } from '../shared/gui-settings-state';
-import log from '../shared/logging';
-
-const settingsSchema: Record<keyof IGuiSettingsState, string> = {
- preferredLocale: 'string',
- autoConnect: 'boolean',
- enableSystemNotifications: 'boolean',
- monochromaticIcon: 'boolean',
- startMinimized: 'boolean',
- unpinnedWindow: 'boolean',
- browsedForSplitTunnelingApplications: 'Array<string>',
- changelogDisplayedForVersion: 'string',
- animateMap: 'boolean',
-};
-
-const defaultSettings: IGuiSettingsState = {
- preferredLocale: SYSTEM_PREFERRED_LOCALE_KEY,
- autoConnect: false,
- enableSystemNotifications: true,
- monochromaticIcon: false,
- startMinimized: false,
- unpinnedWindow: process.platform !== 'win32' && process.platform !== 'darwin',
- browsedForSplitTunnelingApplications: [],
- changelogDisplayedForVersion: '',
- animateMap: true,
-};
-
-export default class GuiSettings {
- public onChange?: (newState: IGuiSettingsState, oldState: IGuiSettingsState) => void;
-
- private stateValue: IGuiSettingsState = { ...defaultSettings };
-
- get state(): IGuiSettingsState {
- return this.stateValue;
- }
-
- set preferredLocale(newValue: string) {
- this.changeStateAndNotify({ ...this.stateValue, preferredLocale: newValue });
- }
-
- get preferredLocale(): string {
- return this.stateValue.preferredLocale;
- }
-
- set enableSystemNotifications(newValue: boolean) {
- this.changeStateAndNotify({ ...this.stateValue, enableSystemNotifications: newValue });
- }
-
- get enableSystemNotifications(): boolean {
- return this.stateValue.enableSystemNotifications;
- }
-
- set autoConnect(newValue: boolean) {
- this.changeStateAndNotify({ ...this.stateValue, autoConnect: newValue });
- }
-
- get autoConnect(): boolean {
- return this.stateValue.autoConnect;
- }
-
- set monochromaticIcon(newValue: boolean) {
- this.changeStateAndNotify({ ...this.stateValue, monochromaticIcon: newValue });
- }
-
- get monochromaticIcon(): boolean {
- return this.stateValue.monochromaticIcon;
- }
-
- set startMinimized(newValue: boolean) {
- this.changeStateAndNotify({ ...this.stateValue, startMinimized: newValue });
- }
-
- get startMinimized(): boolean {
- return this.stateValue.startMinimized;
- }
-
- set unpinnedWindow(newValue: boolean) {
- this.changeStateAndNotify({ ...this.stateValue, unpinnedWindow: newValue });
- }
-
- get unpinnedWindow(): boolean {
- return this.stateValue.unpinnedWindow;
- }
-
- public addBrowsedForSplitTunnelingApplications(newApp: string) {
- this.changeStateAndNotify({
- ...this.stateValue,
- browsedForSplitTunnelingApplications: [...this.browsedForSplitTunnelingApplications, newApp],
- });
- }
-
- public deleteBrowsedForSplitTunnelingApplications(path: string) {
- this.changeStateAndNotify({
- ...this.stateValue,
- browsedForSplitTunnelingApplications: this.browsedForSplitTunnelingApplications.filter(
- (application) => application !== path,
- ),
- });
- }
-
- get browsedForSplitTunnelingApplications(): Array<string> {
- return this.stateValue.browsedForSplitTunnelingApplications;
- }
-
- set changelogDisplayedForVersion(newValue: string | undefined) {
- this.changeStateAndNotify({ ...this.stateValue, changelogDisplayedForVersion: newValue ?? '' });
- }
-
- get changelogDisplayedForVersion(): string | undefined {
- return this.stateValue.changelogDisplayedForVersion === ''
- ? undefined
- : this.stateValue.changelogDisplayedForVersion;
- }
-
- set animateMap(newValue: boolean) {
- this.changeStateAndNotify({ ...this.stateValue, animateMap: newValue });
- }
-
- get animateMap(): boolean {
- return this.stateValue.animateMap;
- }
-
- public load() {
- try {
- const settingsFile = this.filePath();
- const contents = fs.readFileSync(settingsFile, 'utf8');
- const rawJson = JSON.parse(contents);
-
- this.stateValue = {
- ...defaultSettings,
- ...this.validateSettings(rawJson),
- };
- } catch (e) {
- const error = e as Error & { code?: string };
- // Read settings if the file exists, otherwise write the default settings to it.
- if (error.code === 'ENOENT') {
- log.verbose('Creating gui-settings file and writing the default settings to it');
- this.store();
- } else {
- log.error(`Failed to read GUI settings file: ${error}`);
- }
- }
- }
-
- public store() {
- try {
- const settingsFile = this.filePath();
-
- fs.writeFileSync(settingsFile, JSON.stringify(this.stateValue));
- } catch (error) {
- log.error(`Failed to write GUI settings file: ${error}`);
- }
- }
-
- private validateSettings(settings: Record<string, unknown>) {
- Object.entries(settingsSchema).forEach(([key, expectedType]) => {
- if (key in settings) {
- if (/^Array<.*>/.test(expectedType)) {
- const value = settings[key];
- if (!Array.isArray(value)) {
- throw new Error(`Expected ${key} to be array but wasn't`);
- } else {
- const expectedInnerType = expectedType.replace(/^Array</, '').replace(/>$/, '');
- const innerTypes: string[] = value.map((value) => typeof value);
- if (
- innerTypes.length > 0 &&
- (innerTypes.some((value) => value !== innerTypes[0]) ||
- innerTypes[0] !== expectedInnerType)
- ) {
- throw new Error(`Expected ${key} to contain ${expectedInnerType}s`);
- }
- }
- } else {
- const actualType = typeof settings[key];
- if (actualType !== expectedType) {
- throw new Error(`Expected ${key} to be of type ${expectedType} but was ${actualType}`);
- }
- }
- }
- });
-
- return settings as Partial<IGuiSettingsState>;
- }
-
- private filePath() {
- return path.join(app.getPath('userData'), 'gui_settings.json');
- }
-
- private changeStateAndNotify(newState: IGuiSettingsState) {
- const oldState = this.stateValue;
- this.stateValue = newState;
-
- this.store();
-
- if (this.onChange) {
- this.onChange({ ...newState }, oldState);
- }
- }
-}
diff --git a/gui/src/main/index.ts b/gui/src/main/index.ts
deleted file mode 100644
index c9067c78e2..0000000000
--- a/gui/src/main/index.ts
+++ /dev/null
@@ -1,1154 +0,0 @@
-import { exec, execFile } from 'child_process';
-import { app, nativeTheme, powerMonitor, session, shell, systemPreferences } from 'electron';
-import fs from 'fs';
-import * as path from 'path';
-import util from 'util';
-
-import config from '../config.json';
-import { hasExpired } from '../shared/account-expiry';
-import {
- ISplitTunnelingApplication,
- ISplitTunnelingAppListRetriever,
-} from '../shared/application-types';
-import {
- AccessMethodSetting,
- DaemonEvent,
- DeviceEvent,
- IRelayListWithEndpointData,
- ISettings,
- TunnelState,
-} from '../shared/daemon-rpc-types';
-import { messages, relayLocations } from '../shared/gettext';
-import { SYSTEM_PREFERRED_LOCALE_KEY } from '../shared/gui-settings-state';
-import { ITranslations, MacOsScrollbarVisibility } from '../shared/ipc-schema';
-import { IChangelog, IHistoryObject } from '../shared/ipc-types';
-import log, { ConsoleOutput, Logger } from '../shared/logging';
-import { LogLevel } from '../shared/logging-types';
-import {
- SystemNotification,
- SystemNotificationCategory,
-} from '../shared/notifications/notification';
-import Account, { AccountDelegate, LocaleProvider } from './account';
-import { getOpenAtLogin } from './autostart';
-import { readChangelog } from './changelog';
-import {
- CommandLineOptions,
- printCommandLineOptions,
- printElectronOptions,
-} from './command-line-options';
-import { DaemonRpc, SubscriptionListener } from './daemon-rpc';
-import Expectation from './expectation';
-import { ConnectionObserver } from './grpc-client';
-import { IpcMainEventChannel } from './ipc-event-channel';
-import { findIconPath } from './linux-desktop-entry';
-import { loadTranslations } from './load-translations';
-import {
- backupLogFile,
- cleanUpLogDirectory,
- createLoggingDirectory,
- FileOutput,
- getMainLogPath,
- getRendererLogPath,
- IpcInput,
- OLD_LOG_FILES,
-} from './logging';
-import NotificationController, {
- NotificationControllerDelegate,
- NotificationSender,
-} from './notification-controller';
-import { isMacOs13OrNewer } from './platform-version';
-import * as problemReport from './problem-report';
-import { resolveBin } from './proc';
-import ReconnectionBackoff from './reconnection-backoff';
-import Settings, { SettingsDelegate } from './settings';
-import TunnelStateHandler, {
- TunnelStateHandlerDelegate,
- TunnelStateProvider,
-} from './tunnel-state';
-import UserInterface, { UserInterfaceDelegate } from './user-interface';
-import Version, { GUI_VERSION } from './version';
-
-const execAsync = util.promisify(exec);
-
-// Only import split tunneling library on correct OS.
-// eslint-disable-next-line @typescript-eslint/no-require-imports
-const linuxSplitTunneling = process.platform === 'linux' && require('./linux-split-tunneling');
-// This is used on Windows and macOS and will be undefined on Linux.
-const splitTunneling: ISplitTunnelingAppListRetriever | undefined = importSplitTunneling();
-
-const ALLOWED_PERMISSIONS = ['clipboard-sanitized-write'];
-
-const SANDBOX_DISABLED = app.commandLine.hasSwitch('no-sandbox');
-const UPDATE_NOTIFICATION_DISABLED = process.env.MULLVAD_DISABLE_UPDATE_NOTIFICATION === '1';
-
-const GEO_DIR = path.resolve(__dirname, '../../assets/geo');
-
-class ApplicationMain
- implements
- NotificationSender,
- TunnelStateProvider,
- LocaleProvider,
- NotificationControllerDelegate,
- UserInterfaceDelegate,
- TunnelStateHandlerDelegate,
- SettingsDelegate,
- AccountDelegate
-{
- private daemonRpc: DaemonRpc;
-
- private notificationController = new NotificationController(this);
- private version: Version;
- private settings: Settings;
- private account: Account;
- private userInterface?: UserInterface;
- private tunnelState = new TunnelStateHandler(this);
-
- private daemonEventListener?: SubscriptionListener<DaemonEvent>;
- private reconnectBackoff = new ReconnectionBackoff();
- private beforeFirstDaemonConnection = true;
- private isPerformingPostUpgrade = false;
- private daemonAllowed?: boolean;
- private quitInitiated = false;
-
- private tunnelStateExpectation?: Expectation;
-
- // The UI locale which is set once from onReady handler
- private locale = 'en';
-
- private rendererLog?: Logger;
- private translations: ITranslations = { locale: this.locale };
-
- private splitTunnelingApplications?: ISplitTunnelingApplication[];
-
- private macOsScrollbarVisibility?: MacOsScrollbarVisibility;
-
- private changelog?: IChangelog;
-
- private navigationHistory?: IHistoryObject;
-
- private relayList?: IRelayListWithEndpointData;
-
- private currentApiAccessMethod?: AccessMethodSetting;
-
- public constructor() {
- this.daemonRpc = new DaemonRpc(
- new ConnectionObserver(this.onDaemonConnected, this.onDaemonDisconnected),
- );
-
- 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);
- }
-
- public run() {
- // Remove window animations to combat window flickering when opening window. Can be removed when
- // this issue has been resolved: https://github.com/electron/electron/issues/12130
- if (process.platform === 'win32') {
- app.commandLine.appendSwitch('wm-window-animations-disabled');
- }
-
- // Display correct colors regardless of monitor color profile.
- app.commandLine.appendSwitch('force-color-profile', 'srgb');
-
- this.overrideAppPaths();
-
- // This ensures that only a single instance is running at the same time.
- if (!app.requestSingleInstanceLock()) {
- app.quit();
- return;
- }
-
- this.addSecondInstanceEventHandler();
-
- this.initLogging();
-
- log.verbose(`Chromium sandbox is ${SANDBOX_DISABLED ? 'disabled' : 'enabled'}`);
- if (!SANDBOX_DISABLED) {
- app.enableSandbox();
- }
-
- log.info(`Running version ${this.version.currentVersion.gui}`);
-
- if (process.platform === 'win32') {
- app.setAppUserModelId('net.mullvad.vpn');
- }
-
- // While running in development the watch script triggers a reload of the renderer by sending
- // the signal `SIGUSR2`.
- if (process.env.NODE_ENV === 'development') {
- process.on('SIGUSR2', () => {
- this.userInterface?.reloadWindow();
- });
- }
-
- this.settings.gui.load();
- this.changelog = readChangelog();
-
- app.on('render-process-gone', (_event, _webContents, details) => {
- log.error(
- `Render process exited with exit code ${details.exitCode} due to ${details.reason}`,
- );
- app.quit();
- });
- app.on('child-process-gone', (_event, details) => {
- log.error(
- `Child process of type ${details.type} exited with exit code ${details.exitCode} due to ${details.reason}`,
- );
- });
-
- app.on('ready', this.onReady);
-
- app.on('before-quit', this.onBeforeQuit);
- app.on('will-quit', () => {
- log.info('will-quit received');
- this.onQuit();
- });
- app.on('quit', () => {
- log.info('quit received');
- this.onQuit();
- });
- }
-
- public async performPostUpgradeCheck(): Promise<void> {
- const oldValue = this.isPerformingPostUpgrade;
- this.isPerformingPostUpgrade = await this.daemonRpc.isPerformingPostUpgrade();
- if (this.isPerformingPostUpgrade !== oldValue) {
- IpcMainEventChannel.daemon.notifyIsPerformingPostUpgrade?.(this.isPerformingPostUpgrade);
- }
- }
-
- public connectTunnel = async (): Promise<void> => {
- if (this.tunnelState.allowConnect(this.daemonRpc.isConnected, this.account.isLoggedIn())) {
- this.tunnelState.expectNextTunnelState('connecting');
- await this.daemonRpc.connectTunnel();
- }
- };
-
- public reconnectTunnel = async (): Promise<void> => {
- if (this.tunnelState.allowReconnect(this.daemonRpc.isConnected, this.account.isLoggedIn())) {
- this.tunnelState.expectNextTunnelState('connecting');
- await this.daemonRpc.reconnectTunnel();
- }
- };
-
- public disconnectTunnel = async (): Promise<void> => {
- if (this.tunnelState.allowDisconnect(this.daemonRpc.isConnected)) {
- this.tunnelState.expectNextTunnelState('disconnecting');
- await this.daemonRpc.disconnectTunnel();
- }
- };
-
- public isLoggedIn = () => this.account.isLoggedIn();
-
- public disconnectAndQuit = async () => {
- if (this.daemonRpc.isConnected) {
- try {
- await this.daemonRpc.disconnectTunnel();
- log.info('Disconnected the tunnel');
- } catch (e) {
- const error = e as Error;
- log.error(`Failed to disconnect the tunnel: ${error.message}`);
- }
- } else {
- log.info('Cannot close the tunnel because there is no active connection to daemon.');
- }
-
- app.quit();
- };
-
- private addSecondInstanceEventHandler() {
- app.on('second-instance', (_event, _argv, _workingDirectory) => {
- this.userInterface?.showWindow();
- });
- }
-
- private overrideAppPaths() {
- // This ensures that on Windows the %LOCALAPPDATA% directory is used instead of the %ADDDATA%
- // directory that has roaming contents
- if (process.platform === 'win32') {
- const appDataDir = process.env.LOCALAPPDATA;
- if (appDataDir) {
- const userDataDir = path.join(appDataDir, app.name);
- const logDir = path.join(userDataDir, 'logs');
- // In Electron 16, the `appData` directory must be created explicitly or an error is
- // thrown when creating the singleton lock file.
- fs.mkdirSync(logDir, { recursive: true });
- app.setPath('appData', appDataDir);
- app.setPath('userData', userDataDir);
- app.setPath('logs', logDir);
- } else {
- throw new Error('Missing %LOCALAPPDATA% environment variable');
- }
- } else if (process.platform === 'linux') {
- const userDataDir = app.getPath('userData');
- const logDir = path.join(userDataDir, 'logs');
- fs.mkdirSync(logDir, { recursive: true });
- app.setPath('logs', logDir);
- }
- }
-
- private initLogging() {
- const mainLogPath = getMainLogPath();
- const rendererLogPath = getRendererLogPath();
-
- if (process.env.NODE_ENV === 'production') {
- this.rendererLog = new Logger();
- this.rendererLog.addInput(new IpcInput());
-
- try {
- createLoggingDirectory();
- cleanUpLogDirectory(OLD_LOG_FILES);
-
- backupLogFile(mainLogPath);
- backupLogFile(rendererLogPath);
-
- log.addOutput(new FileOutput(LogLevel.verbose, mainLogPath));
- this.rendererLog.addOutput(new FileOutput(LogLevel.verbose, rendererLogPath));
- } catch (e) {
- const error = e as Error;
- console.error('Failed to initialize logging:', error);
- }
- }
-
- log.addOutput(new ConsoleOutput(LogLevel.debug));
- }
-
- private onActivate = () => this.userInterface?.showWindow();
-
- // This is a last try to disconnect and quit gracefully if the app quits without having received
- // the before-quit event.
- private onQuit = () => {
- if (!this.quitInitiated) {
- this.prepareToQuit();
- }
- };
-
- private onBeforeQuit = async (event: Electron.Event) => {
- if (this.tunnelState.hasReceivedFullDiskAccessError) {
- await this.daemonRpc.prepareRestart(true);
- }
-
- log.info('before-quit received');
- if (this.quitInitiated) {
- event.preventDefault();
- } else {
- this.prepareToQuit();
- }
- };
-
- private prepareToQuit() {
- this.quitInitiated = true;
- log.info('Quit initiated');
-
- this.userInterface?.dispose();
- this.notificationController.dispose();
-
- // Unsubscribe the event handler
- try {
- if (this.daemonEventListener) {
- this.daemonRpc.unsubscribeDaemonEventListener(this.daemonEventListener);
- log.info('Unsubscribed from the daemon events');
- }
- } catch (e) {
- const error = e as Error;
- log.error(`Failed to unsubscribe from daemon events: ${error.message}`);
- }
-
- if (this.daemonRpc.isConnected) {
- this.daemonRpc.disconnect();
- }
-
- for (const logger of [log, this.rendererLog]) {
- try {
- logger?.disposeDisposableOutputs();
- } catch (e) {
- const error = e as Error;
- log.error('Failed to dispose logger:', error);
- }
- }
-
- log.info('Disposable logging outputs disposed');
- log.info('Quit preparations finished');
- }
-
- private detectLocale(): string {
- const preferredLocale = this.settings.gui.preferredLocale;
- if (preferredLocale === SYSTEM_PREFERRED_LOCALE_KEY) {
- return app.getLocale();
- } else {
- return preferredLocale;
- }
- }
-
- private onReady = async () => {
- app.on('activate', this.onActivate);
- powerMonitor.on('suspend', this.onSuspend);
- powerMonitor.on('resume', this.onResume);
-
- // Disable built-in DNS resolver.
- app.configureHostResolver({
- enableBuiltInResolver: false,
- secureDnsMode: 'off',
- secureDnsServers: [],
- });
-
- // There's no option that prevents Electron from fetching spellcheck dictionaries from
- // Chromium's CDN and passing a non-resolving URL is the only known way to prevent it from
- // fetching. https://github.com/electron/electron/issues/22995
- session.defaultSession.setSpellCheckerDictionaryDownloadURL('https://00.00/');
-
- // Blocks scripts in the renderer process from asking for any permission.
- this.blockPermissionRequests();
- // Blocks any http(s) and file requests that aren't supposed to happen.
- this.blockRequests();
- // Blocks navigation and window.open since it's not needed.
- this.blockNavigationAndWindowOpen();
-
- this.updateCurrentLocale();
-
- this.connectToDaemon();
-
- if (process.platform === 'darwin') {
- await this.updateMacOsScrollbarVisibility();
- systemPreferences.subscribeNotification('AppleShowScrollBarsSettingChanged', async () => {
- await this.updateMacOsScrollbarVisibility();
- });
-
- await this.checkMacOsLaunchDaemon();
- }
-
- this.userInterface = new UserInterface(
- this,
- this.daemonRpc,
- SANDBOX_DISABLED,
- CommandLineOptions.disableResetNavigation.match,
- );
-
- this.tunnelStateExpectation = new Expectation(async () => {
- this.userInterface?.createTrayIconController(
- this.tunnelState.tunnelState,
- this.settings.blockWhenDisconnected,
- this.settings.gui.monochromaticIcon,
- );
- await this.userInterface?.updateTrayTheme();
-
- this.userInterface?.updateTray(
- this.account.isLoggedIn(),
- this.tunnelState.tunnelState,
- this.settings.blockWhenDisconnected,
- );
-
- if (process.platform === 'win32') {
- nativeTheme.on('updated', async () => {
- if (this.settings.gui.monochromaticIcon) {
- await this.userInterface?.updateTrayTheme();
- }
- });
- }
- });
-
- this.registerIpcListeners();
-
- if (this.shouldShowWindowOnStart() || process.env.NODE_ENV === 'development') {
- this.userInterface.showWindow();
- }
-
- // For some reason playwright hangs on Linux if we call `window.setIcon`. Since the icon isn't
- // needed for the tests this block has been disabled when running e2e tests.
- if (process.platform === 'linux' && process.env.CI !== 'e2e') {
- try {
- const icon = await findIconPath('mullvad-vpn', ['png']);
- if (icon) {
- this.userInterface.setWindowIcon(icon);
- }
- } catch (e) {
- const error = e as Error;
- log.error('Failed to set window icon:', error.message);
- }
- }
-
- await this.userInterface.initializeWindow(
- this.account.isLoggedIn(),
- this.tunnelState.tunnelState,
- );
- };
-
- private onSuspend = () => {
- log.info('Suspend event received, disconnecting from daemon');
- if (this.daemonEventListener) {
- this.daemonRpc.unsubscribeDaemonEventListener(this.daemonEventListener);
- }
-
- const wasConnected = this.daemonRpc.isConnected;
- IpcMainEventChannel.navigation.notifyReset?.();
- this.daemonRpc.disconnect();
- this.onDaemonDisconnected(wasConnected, undefined, true);
- };
-
- private onResume = () => {
- log.info('Resume event received, connecting to daemon');
- this.daemonRpc.reopen(
- new ConnectionObserver(this.onDaemonConnected, this.onDaemonDisconnected),
- );
- this.connectToDaemon();
- };
-
- private onDaemonConnected = async () => {
- const firstDaemonConnection = this.beforeFirstDaemonConnection;
- this.beforeFirstDaemonConnection = false;
-
- log.info('Connected to the daemon');
-
- this.notificationController.closeNotificationsInCategory(
- SystemNotificationCategory.tunnelState,
- );
-
- // subscribe to events
- try {
- this.daemonEventListener = this.subscribeEvents();
- } catch (e) {
- const error = e as Error;
- log.error(`Failed to subscribe: ${error.message}`);
-
- return this.handleBootstrapError(error);
- }
-
- if (firstDaemonConnection) {
- // check if daemon is performing post upgrade tasks the first time it's connected to
- try {
- await this.performPostUpgradeCheck();
- } catch (e) {
- const error = e as Error;
- log.error(`Failed to check if daemon is performing post upgrade tasks: ${error.message}`);
-
- return this.handleBootstrapError(error);
- }
- }
-
- // fetch account history
- try {
- this.account.setAccountHistory(await this.daemonRpc.getAccountHistory());
- } catch (e) {
- const error = e as Error;
- log.error(`Failed to fetch the account history: ${error.message}`);
-
- return this.handleBootstrapError(error);
- }
-
- // fetch the tunnel state
- try {
- this.tunnelState.handleNewTunnelState(await this.daemonRpc.getState());
- } catch (e) {
- const error = e as Error;
- log.error(`Failed to fetch the tunnel state: ${error.message}`);
-
- return this.handleBootstrapError(error);
- }
-
- // fetch device
- try {
- const deviceState = await this.daemonRpc.getDevice();
- this.account.handleDeviceEvent({ type: deviceState.type, deviceState } as DeviceEvent);
- if (deviceState.type === 'logged in') {
- void this.daemonRpc
- .updateDevice()
- .catch((error: Error) => log.warn(`Failed to update device info: ${error.message}`));
- }
- } catch (e) {
- const error = e as Error;
- log.error(`Failed to fetch device: ${error.message}`);
-
- return this.handleBootstrapError(error);
- }
-
- // fetch settings
- try {
- this.setSettings(await this.daemonRpc.getSettings());
- } catch (e) {
- const error = e as Error;
- log.error(`Failed to fetch settings: ${error.message}`);
-
- return this.handleBootstrapError(error);
- }
-
- // fetch current api access method
- try {
- this.currentApiAccessMethod = await this.daemonRpc.getCurrentApiAccessMethod();
- IpcMainEventChannel.settings.notifyApiAccessMethodSettingChange?.(
- this.currentApiAccessMethod,
- );
- } catch (e) {
- const error = e as Error;
- log.error(`Failed to fetch settings: ${error.message}`);
-
- return this.handleBootstrapError(error);
- }
-
- if (this.tunnelStateExpectation) {
- this.tunnelStateExpectation.fulfill();
- }
-
- // fetch relays
- try {
- this.setRelayList(await this.daemonRpc.getRelayLocations());
- } catch (e) {
- const error = e as Error;
- log.error(`Failed to fetch relay locations: ${error.message}`);
-
- return this.handleBootstrapError(error);
- }
-
- // fetch the daemon's version
- try {
- this.version.setDaemonVersion(await this.daemonRpc.getCurrentVersion());
- } catch (e) {
- const error = e as Error;
- log.error(`Failed to fetch the daemon's version: ${error.message}`);
-
- return this.handleBootstrapError(error);
- }
-
- // fetch the latest version info in background
- if (!UPDATE_NOTIFICATION_DISABLED) {
- void this.version.fetchLatestVersion();
- }
-
- // reset the reconnect backoff when connection established.
- this.reconnectBackoff.reset();
-
- // notify renderer, this.daemonRpc.isConnected could have changed if the daemon disconnected
- // again before this if-statement is reached.
- if (this.daemonRpc.isConnected) {
- IpcMainEventChannel.daemon.notifyConnected?.();
- }
-
- if (firstDaemonConnection) {
- void this.autoConnect();
- }
-
- // show window when account is not set
- if (!this.account.isLoggedIn()) {
- this.userInterface?.showWindow();
- }
- };
-
- private onDaemonDisconnected = (wasConnected: boolean, error?: Error, planned?: boolean) => {
- if (this.daemonEventListener) {
- this.daemonRpc.unsubscribeDaemonEventListener(this.daemonEventListener);
- }
- // Reset the daemon event listener since it's going to be invalidated on disconnect
- this.daemonEventListener = undefined;
-
- this.notificationController.closeNotificationsInCategory(
- SystemNotificationCategory.tunnelState,
- );
-
- if (this.tunnelState.tunnelState.state !== 'disconnected' && !planned) {
- this.notificationController.notifyDaemonDisconnected(
- this.userInterface?.isWindowVisible() ?? false,
- this.settings.gui.enableSystemNotifications,
- );
- }
-
- this.tunnelState.resetFallback();
-
- if (wasConnected) {
- // update the tray icon to indicate that the computer is not secure anymore
- this.userInterface?.updateTray(false, { state: 'disconnected' }, false);
-
- // notify renderer process
- IpcMainEventChannel.daemon.notifyDisconnected?.();
- }
-
- // recover connection on error
- if (error) {
- if (wasConnected) {
- log.error(`Lost connection to daemon: ${error.message}`);
- } else {
- log.error(`Failed to connect to daemon: ${error.message}`);
- }
- } else {
- log.info('Disconnected from the daemon');
- }
- if (process.platform === 'darwin') {
- void this.checkMacOsLaunchDaemon();
- }
- };
-
- private connectToDaemon() {
- void this.daemonRpc
- .connect()
- .catch((error) => log.error(`Unable to connect to daemon: ${error.message}`));
- }
-
- private handleBootstrapError(_error?: Error) {
- // Unsubscribe from daemon events when encountering errors during initial data retrieval.
- if (this.daemonEventListener) {
- this.daemonRpc.unsubscribeDaemonEventListener(this.daemonEventListener);
- }
- }
-
- private subscribeEvents(): SubscriptionListener<DaemonEvent> {
- const daemonEventListener = new SubscriptionListener(
- (daemonEvent: DaemonEvent) => {
- if ('tunnelState' in daemonEvent) {
- this.tunnelState.handleNewTunnelState(daemonEvent.tunnelState);
- } else if ('settings' in daemonEvent) {
- this.setSettings(daemonEvent.settings);
- } else if ('relayList' in daemonEvent) {
- IpcMainEventChannel.relays.notify?.(daemonEvent.relayList);
- } else if ('appVersionInfo' in daemonEvent) {
- this.version.setLatestVersion(daemonEvent.appVersionInfo);
- } else if ('device' in daemonEvent) {
- this.account.handleDeviceEvent(daemonEvent.device);
- } else if ('deviceRemoval' in daemonEvent) {
- IpcMainEventChannel.account.notifyDevices?.(daemonEvent.deviceRemoval);
- } else if ('accessMethodSetting' in daemonEvent) {
- IpcMainEventChannel.settings.notifyApiAccessMethodSettingChange?.(
- daemonEvent.accessMethodSetting,
- );
- }
- },
- (error: Error) => {
- log.error(`Cannot deserialize the daemon event: ${error.message}`);
- },
- );
-
- this.daemonRpc.subscribeDaemonEventListener(daemonEventListener);
-
- return daemonEventListener;
- }
-
- private setSettings(newSettings: ISettings) {
- const oldSettings = this.settings;
- this.settings.handleNewSettings(newSettings);
-
- this.userInterface?.updateTray(
- this.account.isLoggedIn(),
- this.tunnelState.tunnelState,
- newSettings.blockWhenDisconnected,
- );
-
- if (oldSettings.showBetaReleases !== newSettings.showBetaReleases) {
- this.version.setLatestVersion(this.version.upgradeVersion);
- }
-
- IpcMainEventChannel.settings.notify?.(newSettings);
-
- void this.updateSplitTunnelingApplications(newSettings.splitTunnel.appsList);
- }
-
- private setRelayList(relayList: IRelayListWithEndpointData) {
- this.relayList = relayList;
- IpcMainEventChannel.relays.notify?.(relayList);
- }
-
- private async updateSplitTunnelingApplications(appList: string[]): Promise<void> {
- if (splitTunneling) {
- const { applications } = await splitTunneling.getMetadataForApplications(appList);
- this.splitTunnelingApplications = applications;
-
- IpcMainEventChannel.splitTunneling.notify?.(applications);
- }
- }
-
- private registerIpcListeners() {
- IpcMainEventChannel.state.handleGet(() => ({
- isConnected: this.daemonRpc.isConnected,
- autoStart: getOpenAtLogin(),
- accountData: this.account.accountData,
- accountHistory: this.account.accountHistory,
- tunnelState: this.tunnelState.tunnelState,
- settings: this.settings.all,
- isPerformingPostUpgrade: this.isPerformingPostUpgrade,
- daemonAllowed: this.daemonAllowed,
- deviceState: this.account.deviceState,
- relayList: this.relayList,
- currentVersion: this.version.currentVersion,
- upgradeVersion: this.version.upgradeVersion,
- guiSettings: this.settings.gui.state,
- translations: this.translations,
- splitTunnelingApplications: this.splitTunnelingApplications,
- macOsScrollbarVisibility: this.macOsScrollbarVisibility,
- changelog: this.changelog ?? [],
- forceShowChanges: CommandLineOptions.showChanges.match,
- navigationHistory: this.navigationHistory,
- currentApiAccessMethod: this.currentApiAccessMethod,
- isMacOs13OrNewer: isMacOs13OrNewer(),
- }));
-
- IpcMainEventChannel.map.handleGetData(async () => ({
- landContourIndices: await fs.promises.readFile(path.join(GEO_DIR, 'land_contour_indices.gl')),
- landPositions: await fs.promises.readFile(path.join(GEO_DIR, 'land_positions.gl')),
- landTriangleIndices: await fs.promises.readFile(
- path.join(GEO_DIR, 'land_triangle_indices.gl'),
- ),
- oceanIndices: await fs.promises.readFile(path.join(GEO_DIR, 'ocean_indices.gl')),
- oceanPositions: await fs.promises.readFile(path.join(GEO_DIR, 'ocean_positions.gl')),
- }));
-
- IpcMainEventChannel.tunnel.handleConnect(this.connectTunnel);
- IpcMainEventChannel.tunnel.handleReconnect(this.reconnectTunnel);
- IpcMainEventChannel.tunnel.handleDisconnect(this.disconnectTunnel);
-
- IpcMainEventChannel.guiSettings.handleSetPreferredLocale((locale: string) => {
- this.settings.gui.preferredLocale = locale;
- this.updateCurrentLocale();
- return Promise.resolve(this.translations);
- });
-
- IpcMainEventChannel.linuxSplitTunneling.handleGetApplications(() => {
- return linuxSplitTunneling.getApplications(this.locale);
- });
- IpcMainEventChannel.splitTunneling.handleGetApplications((updateCaches: boolean) => {
- return splitTunneling!.getApplications(updateCaches);
- });
- IpcMainEventChannel.linuxSplitTunneling.handleLaunchApplication((application) => {
- return linuxSplitTunneling.launchApplication(application);
- });
-
- IpcMainEventChannel.splitTunneling.handleSetState((enabled) => {
- return this.daemonRpc.setSplitTunnelingState(enabled);
- });
- IpcMainEventChannel.splitTunneling.handleAddApplication(async (application) => {
- // If the applications is a string (path) it's an application picked with the file picker
- // that we want to add to the list of additional applications.
- if (typeof application === 'string') {
- const executablePath = await splitTunneling!.resolveExecutablePath(application);
- this.settings.gui.addBrowsedForSplitTunnelingApplications(executablePath);
- await splitTunneling!.addApplicationPathToCache(application);
- await this.daemonRpc.addSplitTunnelingApplication(executablePath);
- } else {
- await this.daemonRpc.addSplitTunnelingApplication(application.absolutepath);
- }
- });
- IpcMainEventChannel.splitTunneling.handleRemoveApplication((application) => {
- return this.daemonRpc.removeSplitTunnelingApplication(
- typeof application === 'string' ? application : application.absolutepath,
- );
- });
- IpcMainEventChannel.splitTunneling.handleForgetManuallyAddedApplication((application) => {
- this.settings.gui.deleteBrowsedForSplitTunnelingApplications(application.absolutepath);
- splitTunneling!.removeApplicationFromCache(application);
- return Promise.resolve();
- });
- IpcMainEventChannel.macOsSplitTunneling.handleNeedFullDiskPermissions(() => {
- return this.daemonRpc.needFullDiskPermissions();
- });
-
- IpcMainEventChannel.app.handleQuit(() => this.disconnectAndQuit());
- IpcMainEventChannel.app.handleOpenUrl(async (url) => {
- if (Object.values(config.links).find((link) => url.startsWith(link))) {
- await shell.openExternal(url);
- }
- });
- IpcMainEventChannel.app.handleGetPathBaseName((filePath) =>
- Promise.resolve(path.basename(filePath)),
- );
-
- IpcMainEventChannel.navigation.handleSetHistory((history) => {
- this.navigationHistory = history;
- });
-
- IpcMainEventChannel.customLists.handleCreateCustomList((name) => {
- return this.daemonRpc.createCustomList(name);
- });
- IpcMainEventChannel.customLists.handleDeleteCustomList((id) => {
- return this.daemonRpc.deleteCustomList(id);
- });
- IpcMainEventChannel.customLists.handleUpdateCustomList((customList) => {
- return this.daemonRpc.updateCustomList(customList);
- });
-
- problemReport.registerIpcListeners();
- this.userInterface!.registerIpcListeners();
- this.settings.registerIpcListeners();
- this.account.registerIpcListeners();
-
- if (splitTunneling) {
- this.settings.gui.browsedForSplitTunnelingApplications.forEach((application) => {
- void splitTunneling.addApplicationPathToCache(application);
- });
- }
- }
-
- private async autoConnect() {
- if (process.env.NODE_ENV === 'development') {
- log.info('Skip autoconnect in development');
- } else if (
- this.account.isLoggedIn() &&
- (!this.account.accountData || !hasExpired(this.account.accountData.expiry))
- ) {
- if (this.settings.gui.autoConnect) {
- try {
- log.info('Autoconnect the tunnel');
-
- await this.daemonRpc.connectTunnel();
- } catch (e) {
- const error = e as Error;
- log.error(`Failed to autoconnect the tunnel: ${error.message}`);
- }
- } else {
- log.info('Skip autoconnect because GUI setting is disabled');
- }
- } else {
- log.info('Skip autoconnect because account number is not set');
- }
- }
-
- private updateCurrentLocale() {
- this.locale = this.detectLocale();
-
- log.info(`Detected locale: ${this.locale}`);
-
- const messagesTranslations = loadTranslations(this.locale, messages);
- const relayLocationsTranslations = loadTranslations(this.locale, relayLocations);
-
- this.translations = {
- locale: this.locale,
- messages: messagesTranslations,
- relayLocations: relayLocationsTranslations,
- };
-
- this.userInterface?.updateTray(
- this.account.isLoggedIn(),
- this.tunnelState.tunnelState,
- this.settings.blockWhenDisconnected,
- );
- }
-
- private blockPermissionRequests() {
- session.defaultSession.setPermissionRequestHandler((_webContents, permission, callback) => {
- callback(ALLOWED_PERMISSIONS.includes(permission));
- });
- session.defaultSession.setPermissionCheckHandler((_webContents, permission) =>
- ALLOWED_PERMISSIONS.includes(permission),
- );
- }
-
- // Since the app frontend never performs any network requests, all requests originating from the
- // renderer process are blocked to protect against the potential threat of malicious third party
- // dependencies. There are a few exceptions which are described further down.
- private blockRequests() {
- session.defaultSession.webRequest.onBeforeRequest((details, callback) => {
- if (this.allowFileAccess(details.url) || this.allowDevelopmentRequest(details.url)) {
- callback({});
- } else {
- log.error(`${details.method} request blocked: ${details.url}`);
- callback({ cancel: true });
-
- // Throw error in development to notify since this should never happen.
- if (process.env.NODE_ENV === 'development') {
- throw new Error('Web request blocked');
- }
- }
- });
- }
-
- private allowFileAccess(url: string): boolean {
- const buildDir = path.normalize(path.join(path.resolve(__dirname), '..', '..'));
-
- if (url.startsWith('file:')) {
- // Extract the path from the URL
- let filePath = decodeURI(new URL(url).pathname);
- if (process.platform === 'win32') {
- // Windows paths shouldn't start with a '/'
- filePath = filePath.replace(/^\//, '');
- }
- filePath = path.resolve(filePath);
-
- return !path.relative(buildDir, filePath).includes('..');
- } else {
- return false;
- }
- }
-
- private allowDevelopmentRequest(url: string): boolean {
- return (
- process.env.NODE_ENV === 'development' &&
- // Downloading of React and Redux developer tools.
- (url.startsWith('devtools://') ||
- url.startsWith('chrome-extension://') ||
- url.startsWith('https://clients2.google.com') ||
- url.startsWith('https://clients2.googleusercontent.com'))
- );
- }
-
- // Blocks navigation and window.open since it's not needed.
- private blockNavigationAndWindowOpen() {
- app.on('web-contents-created', (_event, contents) => {
- contents.on('will-navigate', (event) => event.preventDefault());
- contents.setWindowOpenHandler(() => ({ action: 'deny' }));
- });
- }
-
- private shouldShowWindowOnStart(): boolean {
- return this.settings.gui.unpinnedWindow && !this.settings.gui.startMinimized;
- }
-
- private checkMacOsLaunchDaemon(): Promise<void> {
- const daemonBin = resolveBin('mullvad-daemon');
- const args = ['--launch-daemon-status'];
- return new Promise((resolve, _reject) => {
- execFile(daemonBin, args, { windowsHide: true }, (error, stdout, stderr) => {
- if (error) {
- if (error.code === 2) {
- IpcMainEventChannel.daemon.notifyDaemonAllowed?.(false);
- this.daemonAllowed = false;
- } else {
- log.error(
- `Error while checking launch daemon authorization status.
- Stdout: ${stdout.toString()}
- Stderr: ${stderr.toString()}`,
- );
- }
- } else {
- IpcMainEventChannel.daemon.notifyDaemonAllowed?.(true);
- this.daemonAllowed = true;
- }
- resolve();
- });
- });
- }
-
- private async updateMacOsScrollbarVisibility(): Promise<void> {
- const command =
- 'defaults read kCFPreferencesAnyApplication AppleShowScrollBars || echo Automatic';
- const { stdout } = await execAsync(command);
- switch (stdout.trim()) {
- case 'WhenScrolling':
- this.macOsScrollbarVisibility = MacOsScrollbarVisibility.whenScrolling;
- break;
- case 'Always':
- this.macOsScrollbarVisibility = MacOsScrollbarVisibility.always;
- break;
- case 'Automatic':
- default:
- this.macOsScrollbarVisibility = MacOsScrollbarVisibility.automatic;
- break;
- }
-
- IpcMainEventChannel.window.notifyMacOsScrollbarVisibility?.(this.macOsScrollbarVisibility);
- }
-
- /* eslint-disable @typescript-eslint/member-ordering */
- // NotificationControllerDelagate
- public openApp = () => this.userInterface?.showWindow();
- public openLink = async (url: string, withAuth?: boolean) => {
- if (withAuth) {
- let token = '';
- try {
- token = await this.daemonRpc.getWwwAuthToken();
- } catch (e) {
- const error = e as Error;
- log.error(`Failed to get the WWW auth token: ${error.message}`);
- }
- return shell.openExternal(`${url}?token=${token}`);
- } else {
- return shell.openExternal(url);
- }
- };
- public showNotificationIcon = (value: boolean, reason?: string) =>
- this.userInterface?.showNotificationIcon(value, reason);
-
- // NotificationSender
- public notify = (notification: SystemNotification) => {
- this.notificationController.notify(
- notification,
- this.userInterface?.isWindowVisible() ?? false,
- this.settings.gui.enableSystemNotifications,
- );
- };
- public closeNotificationsInCategory = (category: SystemNotificationCategory) =>
- this.notificationController.closeNotificationsInCategory(category);
-
- // UserInterfaceDelegate
- public dismissActiveNotifications = () =>
- this.notificationController.dismissActiveNotifications();
- public isUnpinnedWindow = () => this.settings.gui.unpinnedWindow;
- public updateAccountData = () => this.account.updateAccountData();
- public getAccountData = () => this.account.accountData;
-
- // TunnelStateHandlerDelegate
- public handleTunnelStateUpdate = (tunnelState: TunnelState) => {
- this.userInterface?.updateTray(
- this.account.isLoggedIn(),
- tunnelState,
- this.settings.blockWhenDisconnected,
- );
-
- this.notificationController.notifyTunnelState(
- tunnelState,
- this.settings.blockWhenDisconnected,
- this.settings.splitTunnel.enableExclusions && this.settings.splitTunnel.appsList.length > 0,
- this.userInterface?.isWindowVisible() ?? false,
- this.settings.gui.enableSystemNotifications,
- );
-
- IpcMainEventChannel.tunnel.notify?.(tunnelState);
-
- if (this.account.accountData) {
- this.account.detectStaleAccountExpiry(tunnelState);
- }
- };
-
- // SettingsDelegate
- public handleMonochromaticIconChange = (value: boolean) =>
- this.userInterface?.setMonochromaticIcon(value) ?? Promise.resolve();
- public handleUnpinnedWindowChange = () =>
- void this.userInterface?.recreateWindow(
- this.account.isLoggedIn(),
- this.tunnelState.tunnelState,
- );
-
- // AccountDelegate
- public getLocale = () => this.locale;
- public getTunnelState = () => this.tunnelState.tunnelState;
- public onDeviceEvent = () => {
- this.userInterface?.updateTray(
- this.account.isLoggedIn(),
- this.tunnelState.tunnelState,
- this.settings.blockWhenDisconnected,
- );
-
- if (this.isPerformingPostUpgrade) {
- void this.performPostUpgradeCheck();
- }
- };
- /* eslint-enable @typescript-eslint/member-ordering */
-}
-
-function importSplitTunneling() {
- if (process.platform === 'win32') {
- // eslint-disable-next-line @typescript-eslint/no-require-imports
- const { WindowsSplitTunnelingAppListRetriever } = require('./windows-split-tunneling');
- return new WindowsSplitTunnelingAppListRetriever();
- } else if (process.platform === 'darwin') {
- // eslint-disable-next-line @typescript-eslint/no-require-imports
- const { MacOsSplitTunnelingAppListRetriever } = require('./macos-split-tunneling');
- return new MacOsSplitTunnelingAppListRetriever();
- }
-}
-
-if (CommandLineOptions.help.match) {
- console.log('Mullvad VPN');
- console.log('Graphical interface for managing the Mullvad VPN daemon');
-
- console.log('');
- console.log('OPTIONS:');
- printCommandLineOptions();
-
- console.log('');
- console.log('USEFUL ELECTRON/CHROMIUM OPTIONS:');
- printElectronOptions();
-
- process.exit(0);
-} else if (CommandLineOptions.version.match) {
- console.log(GUI_VERSION);
- console.log('Electron version:', process.versions.electron);
-
- process.exit(0);
-} else {
- const applicationMain = new ApplicationMain();
- applicationMain.run();
-}
diff --git a/gui/src/main/ipc-event-channel.ts b/gui/src/main/ipc-event-channel.ts
deleted file mode 100644
index a42b2bc1f1..0000000000
--- a/gui/src/main/ipc-event-channel.ts
+++ /dev/null
@@ -1,16 +0,0 @@
-import { ipcMain, WebContents } from 'electron';
-
-import { createIpcMain } from '../shared/ipc-helpers';
-import { ipcSchema } from '../shared/ipc-schema';
-
-// eslint-disable-next-line @typescript-eslint/naming-convention
-export let IpcMainEventChannel = createIpcMain(ipcSchema, ipcMain, undefined);
-
-// Change the `IpcMainEventChannel` for a new one with a new `WebContents`.
-export function changeIpcWebContents(webContents: WebContents | undefined) {
- IpcMainEventChannel = createIpcMain(ipcSchema, ipcMain, webContents);
-}
-
-export function unsetIpcWebContents() {
- changeIpcWebContents(undefined);
-}
diff --git a/gui/src/main/keyframe-animation.ts b/gui/src/main/keyframe-animation.ts
deleted file mode 100644
index f4ecb34743..0000000000
--- a/gui/src/main/keyframe-animation.ts
+++ /dev/null
@@ -1,144 +0,0 @@
-export type OnFrameFn = (frame: number) => void;
-export type OnFinishFn = () => void;
-
-export interface IKeyframeAnimationOptions {
- start?: number;
- end: number;
-}
-export type KeyframeAnimationRange = [number, number];
-
-export default class KeyframeAnimation {
- private speedValue = 200; // ms
-
- private onFrameValue?: OnFrameFn;
- private onFinishValue?: OnFinishFn;
-
- private currentFrameValue = 0;
- private targetFrame = 0;
-
- private isRunningValue = false;
- private isFinishedValue = false;
-
- private timeout?: NodeJS.Timeout;
-
- get currentFrame(): number {
- return this.currentFrameValue;
- }
-
- // This setter is only meant to be used when running tests
- // @internal
- set currentFrame(newValue: number) {
- if (process.env.NODE_ENV === 'test') {
- this.currentFrameValue = newValue;
- } else {
- throw new Error('The setter for currentFrame is only available in test environment.');
- }
- }
-
- set onFrame(newValue: OnFrameFn | undefined) {
- this.onFrameValue = newValue;
- }
- get onFrame(): OnFrameFn | undefined {
- return this.onFrameValue;
- }
-
- // called when animation finished
- set onFinish(newValue: OnFinishFn | undefined) {
- this.onFinishValue = newValue;
- }
- get onFinish(): OnFinishFn | undefined {
- return this.onFinishValue;
- }
-
- // pace per frame in ms
- set speed(newValue: number) {
- this.speedValue = newValue;
- }
- get speed(): number {
- return this.speedValue;
- }
-
- get isRunning(): boolean {
- return this.isRunningValue;
- }
-
- get isFinished(): boolean {
- return this.isFinishedValue;
- }
-
- public play(options: IKeyframeAnimationOptions) {
- const { start, end } = options;
-
- if (start !== undefined) {
- this.currentFrameValue = start;
- }
-
- this.targetFrame = end;
-
- this.isRunningValue = true;
- this.isFinishedValue = false;
-
- this.unscheduleUpdate();
-
- this.render();
- this.scheduleUpdate();
- }
-
- public stop() {
- this.isRunningValue = false;
- this.unscheduleUpdate();
- }
-
- private unscheduleUpdate() {
- if (this.timeout) {
- clearTimeout(this.timeout);
- this.timeout = undefined;
- }
- }
-
- private scheduleUpdate() {
- this.timeout = global.setTimeout(() => this.onUpdateFrame(), this.speedValue);
- }
-
- private render() {
- if (this.onFrameValue) {
- this.onFrameValue(this.currentFrameValue);
- }
- }
-
- private didFinish() {
- this.isFinishedValue = true;
- this.isRunningValue = false;
-
- if (this.onFinishValue) {
- this.onFinishValue();
- }
- }
-
- private onUpdateFrame() {
- this.advanceFrame();
-
- if (!this.isFinishedValue) {
- this.render();
-
- // check once again since onFrame() may stop animation
- if (this.isRunningValue) {
- this.scheduleUpdate();
- }
- }
- }
-
- private advanceFrame() {
- if (this.isFinishedValue) {
- return;
- }
-
- if (this.currentFrameValue === this.targetFrame) {
- this.didFinish();
- } else if (this.currentFrameValue < this.targetFrame) {
- this.currentFrameValue += 1;
- } else {
- this.currentFrameValue -= 1;
- }
- }
-}
diff --git a/gui/src/main/linux-desktop-entry.ts b/gui/src/main/linux-desktop-entry.ts
deleted file mode 100644
index 7cc46862a5..0000000000
--- a/gui/src/main/linux-desktop-entry.ts
+++ /dev/null
@@ -1,335 +0,0 @@
-import child_process from 'child_process';
-import { nativeImage } from 'electron';
-import fs from 'fs';
-import path from 'path';
-
-import { ILinuxApplication } from '../shared/application-types';
-import log from '../shared/logging';
-
-type DirectoryDescription = string | RegExp;
-
-export interface DesktopEntry {
- absolutepath: string;
- name: string;
- type: string;
- icon?: string;
- exec?: string;
- terminal?: string;
- noDisplay?: string;
- hidden?: string;
- onlyShowIn?: string[];
- notShowIn?: string[];
- tryExec?: string;
-}
-
-const DESKTOP_ENTRY_KEYS = [
- 'name',
- 'type',
- 'icon',
- 'exec',
- 'terminal',
- 'noDisplay',
- 'hidden',
- 'onlyShowIn',
- 'notShowIn',
- 'tryExec',
-];
-
-const LIST_KEYS = ['onlyShowIn', 'notShowIn'];
-
-// Parses a desktop entry at a specific path. Implemented in accordance with the freedesktop.org's
-// Desktop Entry Specification:
-// https://specifications.freedesktop.org/desktop-entry-spec/desktop-entry-spec-latest.html
-export async function readDesktopEntry(entryPath: string, locale?: string): Promise<DesktopEntry> {
- // First the lines corresponding to desktop entry group is extracted from the file
- const contents = (await fs.promises.readFile(entryPath)).toString().split('\n');
- // The group start is indicated by `[Desktop Entry]`
- const startIndex = contents.indexOf('[Desktop Entry]') + 1;
- const contentsFromDesktopEntry = contents.slice(startIndex);
- // The group ens when the next group start
- const endIndex = contentsFromDesktopEntry.findIndex((line) => /^\[.*\]$/.test(line));
- const desktopEntry = contentsFromDesktopEntry.slice(0, endIndex);
-
- return parseDesktopEntry(entryPath, desktopEntry, locale);
-}
-
-// Parses the values within the desktop entry group in a desktop entry file
-function parseDesktopEntry(
- absolutepath: string,
- desktopEntry: string[],
- locale?: string,
-): DesktopEntry {
- const parsed: Partial<DesktopEntry> = desktopEntry.reduce(
- (entry, line) => parseDesktopEntryLine(entry, line, locale),
- { absolutepath } as Partial<DesktopEntry>,
- );
-
- // If the desktop entry is lacking some of the required keys it's invalid
- if (isDesktopEntry(parsed)) {
- return parsed;
- } else {
- throw new Error('Not a correctly formatted desktop entry');
- }
-}
-
-// Parses a line in a desktop entry
-function parseDesktopEntryLine(
- entry: Partial<DesktopEntry>,
- line: string,
- locale?: string,
-): Partial<DesktopEntry> {
- // Comments start with `#` and keys and values are separated by a `=`
- if (!line.startsWith('#') && line.includes('=')) {
- const firstEqualSign = line.indexOf('=');
- const keyWithLocale = line.slice(0, firstEqualSign).replace(' ', '');
- const value = line.slice(firstEqualSign + 1).trim();
-
- // Key values can be suffixed by a locale enclosed in `[]`
- const pascalCaseKey = keyWithLocale.replace(/\[.*\]/, '');
- const key = pascalCaseKey[0].toLowerCase() + pascalCaseKey.slice(1);
- const keyLocale = keyWithLocale.match(/\[.*\]/)?.[0].replace(/(\[|\])/g, '');
-
- // If the key locale match the provided locale the value is used, otherwise it's only used if
- // there isn't a value already
- if (isDesktopEntryKey(key) && (keyLocale ? keyLocale === locale : entry[key] === undefined)) {
- // Some values are lists of values and they have to be split on `;` and ofter contain a
- // trailing `;`
- if (LIST_KEYS.includes(key)) {
- const arrayValue = value.replace(/;$/, '').split(';');
- return { ...entry, [key]: arrayValue };
- } else {
- return { ...entry, [key]: value };
- }
- }
- }
-
- return entry;
-}
-
-function isDesktopEntryKey(key: string): key is keyof DesktopEntry {
- return DESKTOP_ENTRY_KEYS.includes(key);
-}
-
-function isDesktopEntry(entry: Partial<DesktopEntry>): entry is DesktopEntry {
- return entry.absolutepath !== undefined && entry.name !== undefined && entry.type !== undefined;
-}
-
-// Scans for desktop entries in accordance with the Desktop Entry Specification
-export async function getDesktopEntries(): Promise<string[]> {
- const directories = getDesktopEntryDirectories();
-
- const entries = await directories.reduce(
- async (entries, directory) => getDesktopEntriesInDirectory(directory, await entries),
- Promise.resolve({}),
- );
-
- return Object.values(entries);
-}
-
-async function getDesktopEntriesInDirectory(
- directory: string,
- previousEntries: { [id: string]: string },
- prefix = '',
-): Promise<{ [id: string]: string }> {
- let currentEntries = { ...previousEntries };
- try {
- const contents = await fs.promises.readdir(directory);
-
- for (const item of contents) {
- const id = prefix + item;
- if (path.extname(item) === '.desktop') {
- if (currentEntries[id] === undefined) {
- currentEntries[id] = path.join(directory, item);
- }
- } else {
- const nextDirectory = path.join(directory, item);
- currentEntries = await getDesktopEntriesInDirectory(
- nextDirectory,
- currentEntries,
- `${prefix}${item}-`,
- );
- }
- }
- } catch {
- // no-op
- }
-
- return currentEntries;
-}
-
-function getDesktopEntryDirectories() {
- const directories: string[] = [];
-
- if (process.env.HOME) {
- directories.push(path.join(process.env.HOME, '.local', 'share', 'applications'));
- }
-
- const xdgDataDirs = getXdgDataDirs().map((dir) => path.join(dir, 'applications'));
- directories.push(...xdgDataDirs);
-
- return directories;
-}
-
-// Implemented according to freedesktop specification
-// https://specifications.freedesktop.org/desktop-entry-spec/desktop-entry-spec-latest.html
-// TODO: Respect "TryExec"
-export function shouldShowApplication(application: DesktopEntry): application is ILinuxApplication {
- const originalXdgCurrentDesktop = process.env.ORIGINAL_XDG_CURRENT_DESKTOP?.split(':') ?? [];
- const xdgCurrentDesktop = process.env.XDG_CURRENT_DESKTOP?.split(':') ?? [];
- const desktopEnvironments = originalXdgCurrentDesktop.concat(xdgCurrentDesktop);
-
- const notShowIn =
- typeof application.notShowIn === 'string' ? [application.notShowIn] : application.notShowIn;
- const onlyShowIn =
- typeof application.onlyShowIn === 'string' ? [application.onlyShowIn] : application.onlyShowIn;
-
- const notShowInMatch = notShowIn?.some((desktopEnvironment) =>
- desktopEnvironments?.includes(desktopEnvironment),
- );
- const onlyShowInMatch =
- onlyShowIn?.some((desktopEnvironment) => desktopEnvironments?.includes(desktopEnvironment)) ??
- false;
-
- return (
- application.type === 'Application' &&
- application.name !== 'Mullvad VPN' &&
- application.exec !== undefined &&
- application.noDisplay !== 'true' &&
- application.terminal !== 'true' &&
- application.hidden !== 'true' &&
- !notShowInMatch &&
- (!application.onlyShowIn || onlyShowInMatch)
- );
-}
-
-export async function getImageDataUrl(imagePath: string): Promise<string> {
- if (imagePath && path.extname(imagePath) === '.svg') {
- const contents = await fs.promises.readFile(imagePath);
- return `data:image/svg+xml;base64,${contents.toString('base64')}`;
- } else {
- const image = nativeImage.createFromPath(imagePath);
-
- if (image.isEmpty()) {
- log.error(`Failed to load nativeImage: ${imagePath}`);
- throw new Error(`Failed to load nativeImage: ${imagePath}`);
- } else {
- return image.toDataURL();
- }
- }
-}
-
-// Returns the path of the icon with the specified name. If none is found it returns undefined.
-export async function findIconPath(
- name: string,
- allowedExtensions = ['svg', 'png'],
-): Promise<string | undefined> {
- // Chromium doesn't support .xpm files
- return findIcon(name, allowedExtensions, [
- getIconDirectories(),
- await getGtkThemeDirectories(),
- // Begin with preferred sized but if nothing matches other sizes should be considered as well.
- ['scalable', '256x256', '512x512', '256x256@2x', '128x128@2x', '128x128', /^\d+x\d+(@2x)?$/],
- // Search in all categories of icons.
- [/.*/],
- ]);
-}
-
-// Implemented according to freedesktop specification.
-// https://specifications.freedesktop.org/icon-theme-spec/icon-theme-spec-latest.html
-function getIconDirectories() {
- const directories: string[] = [];
-
- if (process.env.HOME) {
- directories.push(path.join(process.env.HOME, '.icons'));
- directories.push(path.join(process.env.HOME, '.local', 'share', 'icons')); // For KDE Plasma
- }
-
- const xdgDataDirs = getXdgDataDirs().map((dir) => path.join(dir, 'icons'));
- directories.push(...xdgDataDirs);
- directories.push('/usr/share/pixmaps');
-
- return directories;
-}
-
-function getXdgDataDirs(): string[] {
- return process.env.XDG_DATA_DIRS?.split(':') ?? ['/usr/local/share/', '/usr/share/'];
-}
-
-function getGtkThemeDirectories(): Promise<DirectoryDescription[]> {
- // "hicolor" is fallback theme and should always be checked. If no icon is found search is
- // continued in other themes.
- const themes = ['hicolor', /.*/];
- return new Promise((resolve, _reject) => {
- // Electron modifies XDG_CURRENT_DESKTOP and saves the old value in ORIGINAL_XDG_CURRENT_DESKTOP
- const xdgCurrentDesktop =
- process.env.ORIGINAL_XDG_CURRENT_DESKTOP ?? process.env.XDG_CURRENT_DESKTOP ?? '';
- child_process.exec(
- 'gsettings get org.gnome.desktop.interface icon-theme',
- { env: { XDG_CURRENT_DESKTOP: xdgCurrentDesktop } },
- (error, stdout) => {
- if (error) {
- log.error('Error while retrieving theme', error);
- resolve(themes);
- } else {
- const theme = stdout.trim().replace(new RegExp("^'|'$", 'g'), '');
- resolve(theme === '' ? themes : [theme, ...themes]);
- }
- },
- );
- });
-}
-
-// Searches through a directory tree according to the directory lists supplied. E.g. The arguments
-// ('mullvad', ['svg', 'png'], [['a', 'b'], ['c', 'd']]) will search for mullvad.svg and mullvad.png
-// in the directories a, a/c, a/d, b, b/c and b/d.
-async function findIcon(
- name: string,
- extensions: string[],
- [directories, ...restDirectories]: [string[], ...DirectoryDescription[][]],
-): Promise<string | undefined> {
- for (const directory of directories) {
- let contents: string[] | undefined;
- try {
- contents = await fs.promises.readdir(directory);
- } catch (e) {
- const error = e as NodeJS.ErrnoException;
- // Non-existent directories and files (not a directory) are expected.
- if (error.code !== 'ENOENT' && error.code !== 'ENOTDIR') {
- log.error(`Failed to open directory while searching for ${name} icon`, error);
- }
- }
-
- if (contents) {
- const iconPath = contents.find((item) =>
- extensions.some((extension) => item === `${name}.${extension}`),
- );
-
- if (iconPath) {
- return path.join(directory, iconPath);
- } else if (restDirectories.length > 0) {
- const nextDirectories = matchDirectories(restDirectories[0], contents);
- const iconPath = await findIcon(name, extensions, [
- nextDirectories.map((nextDirectory) => path.join(directory, nextDirectory)),
- ...restDirectories.slice(1),
- ]);
-
- if (iconPath) {
- return iconPath;
- }
- }
- }
- }
-
- return undefined;
-}
-
-function matchDirectories(directories: DirectoryDescription[], contents: string[]) {
- const matches = directories
- .map((directory) =>
- directory instanceof RegExp ? contents.filter((item) => directory.test(item)) : directory,
- )
- .flat();
-
- // Remove duplicates
- return [...new Set(matches)];
-}
diff --git a/gui/src/main/linux-split-tunneling.ts b/gui/src/main/linux-split-tunneling.ts
deleted file mode 100644
index 6d690aa453..0000000000
--- a/gui/src/main/linux-split-tunneling.ts
+++ /dev/null
@@ -1,166 +0,0 @@
-import argvSplit from 'argv-split';
-import child_process from 'child_process';
-import path from 'path';
-
-import { ILinuxSplitTunnelingApplication } from '../shared/application-types';
-import { messages } from '../shared/gettext';
-import { LaunchApplicationResult } from '../shared/ipc-schema';
-import { Scheduler } from '../shared/scheduler';
-import {
- DesktopEntry,
- findIconPath,
- getDesktopEntries,
- getImageDataUrl,
- readDesktopEntry,
- shouldShowApplication,
-} from './linux-desktop-entry';
-
-const PROBLEMATIC_APPLICATIONS = {
- launchingInExistingProcess: [
- 'brave-browser-stable',
- 'chromium-browser',
- 'firefox',
- 'firefox-esr',
- 'google-chrome-stable',
- 'mate-terminal',
- 'opera',
- 'xfce4-terminal',
- ],
- launchingElsewhere: ['gnome-terminal'],
-};
-
-// Launches an application. The application parameter could be a path the an executable or .desktop
-// file or an object representing an application
-export async function launchApplication(
- app: ILinuxSplitTunnelingApplication | string,
-): Promise<LaunchApplicationResult> {
- let excludeArguments: string[];
- try {
- excludeArguments = await getLaunchCommand(app);
- } catch (e) {
- const error = e as Error;
- return { error: error.message };
- }
-
- return new Promise((resolve, _reject) => {
- const scheduler = new Scheduler();
- const proc = child_process.spawn('mullvad-exclude', excludeArguments, { detached: true });
-
- // If the process exits within 200 milliseconds the user is notified that it failed to launch.
- scheduler.schedule(() => {
- proc.removeAllListeners();
- resolve({ success: true });
- }, 200);
-
- proc.stderr.on('data', (data) => {
- if (data.includes('Failed to launch the process') && data.includes('ENOENT')) {
- scheduler.cancel();
- proc.removeAllListeners();
- resolve({
- error:
- // TRANSLATORS: This error message is shown if the user tries to launch an app that
- // TRANSLATORS: doesn't exist.
- messages.pgettext('split-tunneling-view', 'Please try again or send a problem report.'),
- });
- }
- });
- proc.once('exit', (code) => {
- scheduler.cancel();
- proc.removeAllListeners();
-
- if (code === 1) {
- resolve({
- error:
- // TRANSLATORS: This error message is shown if an application fails during startup.
- messages.pgettext('split-tunneling-view', 'Please try again or send a problem report.'),
- });
- } else {
- resolve({ success: true });
- }
- });
- });
-}
-
-// Takes the same argument as launchApplication and returns the command to run
-async function getLaunchCommand(app: ILinuxSplitTunnelingApplication | string): Promise<string[]> {
- if (typeof app === 'object') {
- return formatExec(app.exec);
- } else if (path.extname(app) === '.desktop') {
- const entry = await readDesktopEntry(app);
- if (entry.exec !== undefined) {
- return formatExec(entry.exec);
- } else {
- throw new Error(
- // TRANSLATORS: This error message is shown if the user tries to launch a Linux desktop
- // TRANSLATORS: entry file that doesn't contain the required 'Exec' value.
- messages.pgettext('split-tunneling-view', 'Please send a problem report.'),
- );
- }
- } else {
- return [app];
- }
-}
-
-// Removes placeholder arguments and separates command into list of strings
-function formatExec(exec: string) {
- return argvSplit(exec).filter((argument: string) => !/%[cdDfFikmnNuUv]/.test(argument));
-}
-
-export async function getApplications(locale: string): Promise<ILinuxSplitTunnelingApplication[]> {
- const desktopEntryPaths = await getDesktopEntries();
- const desktopEntries: DesktopEntry[] = [];
-
- for (const entryPath of desktopEntryPaths) {
- try {
- desktopEntries.push(await readDesktopEntry(entryPath, locale));
- } catch {
- // no-op
- }
- }
-
- const applications = desktopEntries
- .filter(shouldShowApplication)
- .map(addApplicationWarnings)
- .map(replaceIconNameWithDataUrl);
-
- return Promise.all(applications);
-}
-
-async function replaceIconNameWithDataUrl(
- app: ILinuxSplitTunnelingApplication,
-): Promise<ILinuxSplitTunnelingApplication> {
- try {
- // Either the app has no icon or it's already an absolute path.
- if (app.icon === undefined) {
- return app;
- }
-
- const iconPath = path.isAbsolute(app.icon) ? app.icon : await findIconPath(app.icon);
- if (iconPath === undefined) {
- return app;
- }
-
- return { ...app, icon: await getImageDataUrl(iconPath) };
- } catch {
- return app;
- }
-}
-
-function addApplicationWarnings(
- application: ILinuxSplitTunnelingApplication,
-): ILinuxSplitTunnelingApplication {
- const binaryBasename = path.basename(application.exec!.split(' ')[0]);
- if (PROBLEMATIC_APPLICATIONS.launchingInExistingProcess.includes(binaryBasename)) {
- return {
- ...application,
- warning: 'launches-in-existing-process',
- };
- } else if (PROBLEMATIC_APPLICATIONS.launchingElsewhere.includes(binaryBasename)) {
- return {
- ...application,
- warning: 'launches-elsewhere',
- };
- } else {
- return application;
- }
-}
diff --git a/gui/src/main/load-translations.ts b/gui/src/main/load-translations.ts
deleted file mode 100644
index a3e892ce0b..0000000000
--- a/gui/src/main/load-translations.ts
+++ /dev/null
@@ -1,75 +0,0 @@
-import fs from 'fs';
-import { GetTextTranslations, po } from 'gettext-parser';
-import Gettext from 'node-gettext';
-import path from 'path';
-
-import log from '../shared/logging';
-
-const SOURCE_LANGUAGE = 'en';
-const LOCALES_DIR = path.resolve(__dirname, '../../locales');
-
-export function loadTranslations(
- currentLocale: string,
- catalogue: Gettext,
-): GetTextTranslations | undefined {
- // First look for exact match of the current locale
- const preferredLocales = [];
-
- if (currentLocale !== SOURCE_LANGUAGE) {
- preferredLocales.push(currentLocale);
- }
-
- // In case of region bound locale like en-US, fallback to en.
- const language = Gettext.getLanguageCode(currentLocale);
- if (currentLocale !== language) {
- preferredLocales.push(language);
- }
-
- const domain = catalogue.domain;
- for (const locale of preferredLocales) {
- const parsedTranslations = parseTranslation(locale, domain, catalogue);
- if (parsedTranslations) {
- log.info(`Loaded translations ${locale}/${domain}`);
- catalogue.setLocale(locale);
- return parsedTranslations;
- }
- }
-
- // Reset the locale to source language if we couldn't load the catalogue for the requested locale
- // Add empty translations to suppress some of the warnings produces by node-gettext
- catalogue.addTranslations(SOURCE_LANGUAGE, domain, {});
- catalogue.setLocale(SOURCE_LANGUAGE);
- return;
-}
-
-function parseTranslation(
- locale: string,
- domain: string,
- catalogue: Gettext,
-): GetTextTranslations | undefined {
- const filename = path.join(LOCALES_DIR, locale, `${domain}.po`);
- let contents: string;
-
- try {
- contents = fs.readFileSync(filename, { encoding: 'utf8' });
- } catch (e) {
- const error = e as NodeJS.ErrnoException;
- if (error.code !== 'ENOENT') {
- log.error(`Cannot read the gettext file "${filename}": ${error.message}`);
- }
- return undefined;
- }
-
- let translations: GetTextTranslations;
- try {
- translations = po.parse(contents);
- } catch (e) {
- const error = e as Error;
- log.error(`Cannot parse the gettext file "${filename}": ${error.message}`);
- return undefined;
- }
-
- catalogue.addTranslations(locale, domain, translations);
-
- return translations;
-}
diff --git a/gui/src/main/logging.ts b/gui/src/main/logging.ts
deleted file mode 100644
index a1942d5007..0000000000
--- a/gui/src/main/logging.ts
+++ /dev/null
@@ -1,119 +0,0 @@
-import { app, WebContents } from 'electron';
-import fs from 'fs';
-import path from 'path';
-
-import { ILogInput, ILogOutput, LogLevel } from '../shared/logging-types';
-import { IpcMainEventChannel } from './ipc-event-channel';
-
-export const OLD_LOG_FILES = ['main.log', 'renderer.log', 'frontend.log'];
-
-export class FileOutput implements ILogOutput {
- private fileDescriptor: number;
-
- constructor(
- public level: LogLevel,
- filePath: string,
- ) {
- this.fileDescriptor = fs.openSync(filePath, fs.constants.O_CREAT | fs.constants.O_WRONLY);
- }
-
- public dispose() {
- fs.closeSync(this.fileDescriptor);
- }
-
- public write(_level: LogLevel, message: string): Promise<void> {
- return new Promise((resolve, reject) => {
- fs.write(this.fileDescriptor, `${message}\n`, (err) => {
- if (err) {
- reject(err);
- } else {
- resolve();
- }
- });
- });
- }
-}
-
-export class IpcInput implements ILogInput {
- public on(handler: (level: LogLevel, message: string) => void) {
- IpcMainEventChannel.logging.handleLog(({ level, message }) => handler(level, message));
- }
-}
-
-export class WebContentsConsoleInput implements ILogInput {
- public constructor(private webContents: WebContents) {}
-
- public on(handler: (level: LogLevel, message: string) => void) {
- const levelMap = [LogLevel.verbose, LogLevel.info, LogLevel.warning, LogLevel.error];
-
- this.webContents.on('console-message', (_event, consoleLevel, message) => {
- const level = levelMap[consoleLevel];
- handler(level, WebContentsConsoleInput.formatMessage(level, message));
- });
-
- this.webContents.on('preload-error', (_event, _path, error) =>
- handler(LogLevel.error, WebContentsConsoleInput.formatMessage(LogLevel.error, error.message)),
- );
- }
-
- private static formatMessage(level: LogLevel, message: string) {
- // Prefix all messages from renderer with [Renderer] in blue or red depending on level.
- const color = level === LogLevel.error ? '\x1b[31m' : '\x1b[34m';
- return `${color}[Renderer]\x1b[0m ${message}`;
- }
-}
-
-export function getMainLogPath() {
- return path.join(getLogDirectoryDir(), 'frontend-main.log');
-}
-
-export function getRendererLogPath() {
- return path.join(getLogDirectoryDir(), 'frontend-renderer.log');
-}
-
-export function createLoggingDirectory(): void {
- fs.mkdirSync(getLogDirectoryDir(), { recursive: true });
-}
-
-// When cleaning up old log files they are first backed up and the next time removed.
-export function cleanUpLogDirectory(fileNames: string[]): void {
- fileNames.forEach((fileName) => {
- const filePath = path.join(getLogDirectoryDir(), fileName);
- rotateOrDeleteFile(filePath);
- });
-}
-
-export function backupLogFile(filePath: string) {
- const backupFilePath = getBackupFilePath(filePath);
- if (fileExists(filePath)) {
- fs.renameSync(filePath, backupFilePath);
- }
-}
-
-export function rotateOrDeleteFile(filePath: string): void {
- const backupFilePath = getBackupFilePath(filePath);
- if (fileExists(filePath)) {
- backupLogFile(filePath);
- } else if (fileExists(backupFilePath)) {
- fs.unlinkSync(backupFilePath);
- }
-}
-
-function getBackupFilePath(filePath: string): string {
- const parsedPath = path.parse(filePath);
- parsedPath.base = parsedPath.name + '.old' + parsedPath.ext;
- return path.normalize(path.format(parsedPath));
-}
-
-function getLogDirectoryDir() {
- return app.getPath('logs');
-}
-
-function fileExists(filePath: string): boolean {
- try {
- fs.accessSync(filePath);
- return true;
- } catch {
- return false;
- }
-}
diff --git a/gui/src/main/macos-split-tunneling.ts b/gui/src/main/macos-split-tunneling.ts
deleted file mode 100644
index df144c1278..0000000000
--- a/gui/src/main/macos-split-tunneling.ts
+++ /dev/null
@@ -1,339 +0,0 @@
-import { NativeImage, nativeImage } from 'electron';
-import fs from 'fs/promises';
-import { userInfo } from 'os';
-import path from 'path';
-import plist from 'simple-plist';
-import { promisify } from 'util';
-
-import {
- ISplitTunnelingApplication,
- ISplitTunnelingAppListRetriever,
-} from '../shared/application-types';
-import log from '../shared/logging';
-
-const readPlist = promisify(plist.readFile);
-
-type Plist = Record<string, unknown>;
-
-export class MacOsSplitTunnelingAppListRetriever implements ISplitTunnelingAppListRetriever {
- /**
- * Cache of all previously scanned applications.
- */
- private applicationCache = new ApplicationCache();
- /**
- * List of apps that have been added manually by the user.
- */
- private additionalApplications = new AdditionalApplications();
-
- public async getApplications(updateCaches = false): Promise<{
- fromCache: boolean;
- applications: ISplitTunnelingApplication[];
- }> {
- const fromCache = !updateCaches && !this.applicationCache.isEmpty();
-
- // Update cache if requested or if cache is empty.
- if (!fromCache) {
- const applicationBundlePaths = await this.findApplicationBundlePaths();
- const executablePaths = this.additionalApplications.values();
- await Promise.all([
- // `getApplication updates the cache so no need to use the result.`
- ...applicationBundlePaths.map((applicationBundlePath) =>
- this.getApplication(applicationBundlePath, false),
- ),
- ...executablePaths.map((executablePath) => this.getApplication(executablePath, true)),
- ]);
- }
-
- // Return applications from cache.
- return { fromCache, applications: this.applicationCache.values() };
- }
-
- public async getMetadataForApplications(
- applicationPaths: string[],
- ): Promise<{ fromCache: boolean; applications: ISplitTunnelingApplication[] }> {
- await Promise.all(
- applicationPaths
- .filter((applicationPath) => !this.applicationCache.includes(applicationPath))
- .map((applicationPath) => this.addApplicationPathToCache(applicationPath)),
- );
-
- const applications = await this.getApplications();
-
- applications.applications = applications.applications.filter((application) =>
- applicationPaths.some(
- (applicationPath) =>
- applicationPath.toLowerCase() === application.absolutepath.toLowerCase(),
- ),
- );
-
- return applications;
- }
-
- public removeApplicationFromCache(application: ISplitTunnelingApplication): void {
- this.applicationCache.remove(application);
- this.additionalApplications.remove(application);
- }
-
- public async addApplicationPathToCache(applicationPath: string): Promise<void> {
- const application = await this.getApplication(applicationPath, true);
- if (application?.deletable) {
- this.additionalApplications.add(application);
- }
- }
-
- public async resolveExecutablePath(applicationPath: string): Promise<string> {
- if (path.extname(applicationPath) === '.app') {
- const macOsApplication = await this.createMacOsApplication(applicationPath, false);
- return macOsApplication?.absolutepath ?? applicationPath;
- } else {
- return applicationPath;
- }
- }
-
- /**
- * Creates an `ISplitTunnelingApplication` and adds it to the cache.
- */
- private async getApplication(
- applicationPath: string,
- deletable: boolean,
- ): Promise<ISplitTunnelingApplication | undefined> {
- const application = await this.createApplication(applicationPath, deletable);
-
- if (application !== undefined) {
- this.applicationCache.add(application);
- }
-
- return application;
- }
-
- private async findApplicationBundlePaths() {
- const readdirPromises = this.getAppDirectories().map((directory) =>
- this.readDirectory(directory),
- );
- const applicationBundlePaths = (await Promise.all(readdirPromises)).flat();
- return applicationBundlePaths.filter((filePath) => {
- const parsedFilePath = path.parse(filePath);
- return (
- parsedFilePath.ext === '.app' &&
- !parsedFilePath.name.startsWith('.') &&
- parsedFilePath.name !== 'Mullvad VPN'
- );
- });
- }
-
- /**
- * Returns contents of directory with results as absolute paths.
- */
- private async readDirectory(applicationDir: string) {
- try {
- const basenames = await fs.readdir(applicationDir);
- return basenames.map((basename) => path.join(applicationDir, basename));
- } catch (err) {
- const e = err as NodeJS.ErrnoException;
- if (e.code !== 'ENOENT' && e.code !== 'ENOTDIR') {
- log.error(`Failed to read directory contents: ${applicationDir}`, err);
- }
- return [];
- }
- }
-
- private async readApplicationBundlePlist(applicationBundlePath: string): Promise<Plist> {
- const plistPath = path.join(applicationBundlePath, 'Contents', 'Info.plist');
- return (await readPlist(plistPath)) ?? {};
- }
-
- /**
- * Creates an `ISplitTunnelingApplication` for any type of application.
- */
- private async createApplication(
- applicationPath: string,
- deletable: boolean,
- ): Promise<ISplitTunnelingApplication | undefined> {
- if (path.extname(applicationPath) === '.app') {
- return this.createMacOsApplication(applicationPath, deletable);
- }
-
- const applicationDirectory = this.getApplicationDirectoryForExecutable(applicationPath);
- if (applicationDirectory) {
- const additionalApplication = await this.createMacOsApplication(
- applicationDirectory,
- deletable,
- );
- if (additionalApplication?.absolutepath === applicationPath) {
- return additionalApplication;
- }
- }
-
- return this.createExecutableApplication(applicationPath, deletable);
- }
-
- /**
- * Creates an `ISplitTunnelingApplication` for the provided executable.
- */
- private async createExecutableApplication(
- executablePath: string,
- deletable: boolean,
- ): Promise<ISplitTunnelingApplication> {
- return {
- absolutepath: executablePath,
- name: path.basename(executablePath),
- icon: (await this.getApplicationIcon(executablePath)).toDataURL(),
- deletable,
- };
- }
-
- /**
- * Creates an `ISplitTunnelingApplication` for the provided application bundle.
- */
- private async createMacOsApplication(
- applicationBundlePath: string,
- deletable: boolean,
- ): Promise<ISplitTunnelingApplication | undefined> {
- const appInfo = await this.readApplicationBundlePlist(applicationBundlePath);
-
- if (!('CFBundleExecutable' in appInfo) || typeof appInfo.CFBundleExecutable !== 'string') {
- return undefined;
- }
-
- const name = this.getApplicationName(appInfo);
- if (!name) {
- return undefined;
- }
-
- const icon = await this.getApplicationIcon(applicationBundlePath);
- const executablePath = path.join(
- applicationBundlePath,
- 'Contents',
- 'MacOS',
- appInfo.CFBundleExecutable,
- );
-
- return {
- absolutepath: executablePath,
- name,
- icon: icon.toDataURL(),
- deletable,
- };
- }
-
- private getApplicationName(appInfo: Plist): string | void {
- if ('CFBundleDisplayName' in appInfo && typeof appInfo.CFBundleDisplayName === 'string') {
- return appInfo.CFBundleDisplayName;
- }
-
- if ('CFBundleName' in appInfo && typeof appInfo.CFBundleName === 'string') {
- return appInfo.CFBundleName;
- }
- }
-
- private async getApplicationIcon(applicationPath: string): Promise<NativeImage> {
- const applicationDirectory =
- this.getApplicationDirectoryForExecutable(applicationPath) ?? applicationPath;
-
- try {
- // 70x70 is the size at which the icon will be rendered in the split tunneling view accounting
- // for HiDPI displays.
- return await nativeImage.createThumbnailFromPath(applicationDirectory, {
- height: 70,
- width: 70,
- });
- } catch {
- log.info('Failed to fetch icon for split tunneling application:', applicationPath);
- return nativeImage.createEmpty();
- }
- }
-
- /**
- * Returns path to the application bundle if the provided path is or is part of an application
- * bundle.
- */
- private getApplicationDirectoryForExecutable(currentPath: string): string | undefined {
- const parsedPath = path.parse(currentPath);
- if (parsedPath.ext === '.app') {
- return currentPath;
- } else if (parsedPath.dir === '/') {
- return undefined;
- } else {
- return this.getApplicationDirectoryForExecutable(parsedPath.dir);
- }
- }
-
- /**
- * Returns the directories to be scanned for application bundles.
- */
- private getAppDirectories() {
- return [
- '/Applications',
- '/Applications/Utilities',
- '/System/Applications',
- path.join('/', 'Users', userInfo().username, 'Applications'),
- ];
- }
-}
-
-/**
- * Cache of all previously scanned applications.
- */
-class ApplicationCache {
- private cache: Record<string, ISplitTunnelingApplication> = {};
-
- public add(application: ISplitTunnelingApplication) {
- const cacheKey = application.absolutepath.toLowerCase();
- this.cache[cacheKey] = this.merge(application, this.cache[cacheKey]);
- }
-
- public remove(application: ISplitTunnelingApplication) {
- delete this.cache[application.absolutepath.toLowerCase()];
- }
-
- public values(): Array<ISplitTunnelingApplication> {
- return Object.values(this.cache);
- }
-
- public isEmpty(): boolean {
- return Object.keys(this.cache).length === 0;
- }
-
- public includes(application: ISplitTunnelingApplication | string) {
- const cacheKey = typeof application === 'string' ? application : application.absolutepath;
- return this.cache[cacheKey.toLowerCase()] !== undefined;
- }
-
- /**
- * Merges two applications by using the values from the new one but respects the `deletable`
- * property on the old one.
- */
- private merge(
- newApplication: ISplitTunnelingApplication,
- oldApplication?: ISplitTunnelingApplication,
- ): ISplitTunnelingApplication {
- if (oldApplication === undefined) {
- return newApplication;
- }
-
- newApplication.deletable =
- newApplication.deletable === true && oldApplication.deletable === true;
- return newApplication;
- }
-}
-
-/**
- * List of apps that have been added manually by the user.
- */
-class AdditionalApplications {
- private executablePaths: Record<string, string> = {};
-
- public add(application: ISplitTunnelingApplication | string) {
- const executablePath = typeof application === 'string' ? application : application.absolutepath;
- this.executablePaths[executablePath.toLowerCase()] = executablePath;
- }
-
- public remove(application: ISplitTunnelingApplication | string) {
- const executablePath = typeof application === 'string' ? application : application.absolutepath;
- delete this.executablePaths[executablePath.toLowerCase()];
- }
-
- public values(): Array<string> {
- return Object.values(this.executablePaths);
- }
-}
diff --git a/gui/src/main/notification-controller.ts b/gui/src/main/notification-controller.ts
deleted file mode 100644
index 925bff0812..0000000000
--- a/gui/src/main/notification-controller.ts
+++ /dev/null
@@ -1,337 +0,0 @@
-import { app, NativeImage, nativeImage, Notification as ElectronNotification } from 'electron';
-import os from 'os';
-import path from 'path';
-
-import { TunnelState } from '../shared/daemon-rpc-types';
-import log from '../shared/logging';
-import {
- ConnectedNotificationProvider,
- ConnectingNotificationProvider,
- DaemonDisconnectedNotificationProvider,
- DisconnectedNotificationProvider,
- ErrorNotificationProvider,
- NotificationAction,
- ReconnectingNotificationProvider,
- SystemNotification,
- SystemNotificationCategory,
- SystemNotificationProvider,
- SystemNotificationSeverityType,
-} from '../shared/notifications/notification';
-import { Scheduler } from '../shared/scheduler';
-
-const THROTTLE_DELAY = 500;
-
-export interface Notification {
- specification: SystemNotification;
- notification: ElectronNotification;
-}
-
-export interface NotificationSender {
- notify(notification: SystemNotification): void;
- closeNotificationsInCategory(category: SystemNotificationCategory): void;
-}
-
-export interface NotificationControllerDelegate {
- openApp(): void;
- openLink(url: string, withAuth?: boolean): Promise<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
- * causes down.
- *
- * @param reason Used for debug purposes, it is currently all relevant notification messages..
- */
- showNotificationIcon(value: boolean, reason?: string): void;
-}
-
-enum NotificationSuppressReason {
- development,
- windowVisible,
- preference,
- alreadyPresented,
-}
-
-export default class NotificationController {
- private reconnecting = false;
-
- private presentedNotifications: { [key: string]: boolean } = {};
- private activeNotifications: Set<Notification> = new Set();
- private dismissedNotifications: Set<SystemNotification> = new Set();
- private throttledNotifications: Map<SystemNotification, Scheduler> = new Map();
-
- private notificationTitle =
- process.platform === 'linux' && process.env.NODE_ENV !== 'test' ? app.name : '';
- private notificationIcon?: NativeImage;
-
- constructor(private notificationControllerDelegate: NotificationControllerDelegate) {
- let usePngIcon;
- if (process.platform === 'linux') {
- usePngIcon = true;
- } else if (process.platform === 'win32') {
- usePngIcon = parseInt(os.release().split('.')[0], 10) >= 10;
- } else {
- usePngIcon = false;
- }
-
- if (usePngIcon) {
- const basePath = path.resolve(path.join(__dirname, '../../assets/images'));
- // `nativeImage` is undefined when running tests
- this.notificationIcon = nativeImage?.createFromPath(
- path.join(basePath, 'icon-notification.png'),
- );
- }
- }
-
- public dispose() {
- this.throttledNotifications.forEach((scheduler) => scheduler.cancel());
-
- this.activeNotifications.forEach((notification) => notification.notification.close());
- this.activeNotifications.clear();
- }
-
- public notifyTunnelState(
- tunnelState: TunnelState,
- blockWhenDisconnected: boolean,
- hasExcludedApps: boolean,
- isWindowVisible: boolean,
- areSystemNotificationsEnabled: boolean,
- ): boolean {
- const notificationProviders: SystemNotificationProvider[] = [
- new ConnectingNotificationProvider({ tunnelState, reconnecting: this.reconnecting }),
- new ConnectedNotificationProvider(tunnelState),
- new ReconnectingNotificationProvider(tunnelState),
- new DisconnectedNotificationProvider({ tunnelState, blockWhenDisconnected }),
- new ErrorNotificationProvider({ tunnelState, hasExcludedApps }),
- ];
-
- const notificationProvider = notificationProviders.find((notification) =>
- notification.mayDisplay(),
- );
-
- this.reconnecting =
- tunnelState.state === 'disconnecting' && tunnelState.details === 'reconnect';
-
- if (notificationProvider) {
- const notification = notificationProvider.getSystemNotification();
-
- if (notification) {
- return this.notify(notification, isWindowVisible, areSystemNotificationsEnabled);
- } else {
- log.error(
- `Notification providers mayDisplay() returned true but getSystemNotification() returned undefined for ${notificationProvider.constructor.name}`,
- );
- }
- } else {
- this.closeNotificationsInCategory(SystemNotificationCategory.tunnelState);
- }
-
- return false;
- }
-
- public notifyDaemonDisconnected(windowVisible: boolean, infoNotificationsEnabled: boolean) {
- this.notify(
- new DaemonDisconnectedNotificationProvider().getSystemNotification(),
- windowVisible,
- infoNotificationsEnabled,
- );
- }
-
- // Closes still relevant notifications but still lets them affect notification dot in tray icon.
- public dismissActiveNotifications() {
- this.activeNotifications.forEach((notification) => {
- notification.notification.close();
- });
- this.updateNotificationIcon();
- }
-
- public closeNotificationsInCategory(
- category: SystemNotificationCategory,
- severity?: SystemNotificationSeverityType,
- ) {
- this.activeNotifications.forEach((notification) => {
- if (notification.specification.category === category) {
- notification.notification.removeAllListeners('close');
- notification.notification.close();
- this.activeNotifications.delete(notification);
- }
- });
- this.dismissedNotifications.forEach((notification) => {
- if (
- notification.category === category &&
- (severity === undefined || severity >= notification.severity)
- ) {
- this.dismissedNotifications.delete(notification);
- }
- });
- this.updateNotificationIcon();
- }
-
- public notify(
- systemNotification: SystemNotification,
- windowVisible: boolean,
- infoNotificationsEnabled: boolean,
- ): boolean {
- const notificationSuppressReason = this.evaluateNotification(
- systemNotification,
- windowVisible,
- infoNotificationsEnabled,
- );
- if (notificationSuppressReason !== undefined) {
- if (
- notificationSuppressReason === NotificationSuppressReason.preference ||
- notificationSuppressReason === NotificationSuppressReason.windowVisible
- ) {
- this.dismissedNotifications.add(systemNotification);
- this.updateNotificationIcon();
- }
-
- return false;
- }
-
- // Cancel throttled notifications within the same category
- if (systemNotification.category !== undefined) {
- this.throttledNotifications.forEach((scheduler, specification) => {
- if (specification.category === systemNotification.category) {
- scheduler.cancel();
- this.throttledNotifications.delete(specification);
- }
- });
- }
-
- if (systemNotification.throttle) {
- const scheduler = new Scheduler();
- scheduler.schedule(() => {
- this.throttledNotifications.delete(systemNotification);
- this.notifyImpl(systemNotification);
- }, THROTTLE_DELAY);
-
- this.throttledNotifications.set(systemNotification, scheduler);
- return true;
- } else {
- this.notifyImpl(systemNotification);
- return true;
- }
- }
-
- private notifyImpl(systemNotification: SystemNotification): Notification {
- // Remove notifications in the same category if specified
- if (systemNotification.category !== undefined) {
- this.closeNotificationsInCategory(systemNotification.category, systemNotification.severity);
- }
-
- const notification = this.createNotification(systemNotification);
- this.addActiveNotification(notification);
- notification.notification.show();
-
- // Close notification of low severity automatically
- if (systemNotification.severity === SystemNotificationSeverityType.info) {
- setTimeout(() => notification.notification.close(), 4000);
- }
-
- return notification;
- }
-
- private createNotification(systemNotification: SystemNotification): Notification {
- const notification = this.createElectronNotification(systemNotification);
-
- // Action buttons are only available on macOS.
- if (process.platform === 'darwin') {
- if (systemNotification.action) {
- notification.actions = [{ type: 'button', text: systemNotification.action.text }];
- notification.on('action', () => this.performAction(systemNotification.action));
- }
- notification.on('click', () => this.notificationControllerDelegate.openApp());
- } else if (
- !(
- process.platform === 'win32' &&
- systemNotification.severity === SystemNotificationSeverityType.high
- )
- ) {
- if (systemNotification.action) {
- notification.on('click', () => this.performAction(systemNotification.action));
- } else {
- notification.on('click', () => this.notificationControllerDelegate.openApp());
- }
- }
-
- return { specification: systemNotification, notification };
- }
-
- private createElectronNotification(systemNotification: SystemNotification): ElectronNotification {
- return new ElectronNotification({
- title: this.notificationTitle,
- body: systemNotification.message,
- silent: true,
- icon: this.notificationIcon,
- timeoutType:
- systemNotification.severity == SystemNotificationSeverityType.high ? 'never' : 'default',
- });
- }
-
- private performAction(action?: NotificationAction) {
- if (action && action.type === 'open-url') {
- void this.notificationControllerDelegate.openLink(action.url, action.withAuth);
- }
- }
-
- private addActiveNotification(notification: Notification) {
- notification.notification.on('close', () => {
- this.dismissedNotifications.add(notification.specification);
- this.activeNotifications.delete(notification);
- this.updateNotificationIcon();
- });
- this.activeNotifications.add(notification);
- this.updateNotificationIcon();
- }
-
- private updateNotificationIcon() {
- const activeNotifications = [...this.activeNotifications].map(
- (notification) => notification.specification,
- );
- const notifications = [...activeNotifications, ...this.dismissedNotifications].filter(
- (notification) => notification.severity >= SystemNotificationSeverityType.medium,
- );
-
- if (notifications.length > 0) {
- const reason = notifications.map((notification) => `"${notification.message}"`).join(',');
- this.notificationControllerDelegate.showNotificationIcon(true, reason);
- } else {
- this.notificationControllerDelegate.showNotificationIcon(false);
- }
- }
-
- private evaluateNotification(
- notification: SystemNotification,
- isWindowVisible: boolean,
- areSystemNotificationsEnabled: boolean,
- ): NotificationSuppressReason | undefined {
- if (notification.suppressInDevelopment && process.env.NODE_ENV === 'development') {
- return NotificationSuppressReason.development;
- } else if (isWindowVisible) {
- return NotificationSuppressReason.windowVisible;
- } else if (
- !areSystemNotificationsEnabled &&
- notification.severity <= SystemNotificationSeverityType.low
- ) {
- return NotificationSuppressReason.preference;
- } else if (this.suppressDueToAlreadyPresented(notification)) {
- return NotificationSuppressReason.alreadyPresented;
- }
-
- return undefined;
- }
-
- private suppressDueToAlreadyPresented(notification: SystemNotification) {
- const presented = this.presentedNotifications;
- if (notification.presentOnce?.value) {
- if (presented[notification.presentOnce.name]) {
- return true;
- } else {
- presented[notification.presentOnce.name] = true;
- return false;
- }
- } else {
- return false;
- }
- }
-}
diff --git a/gui/src/main/platform-version.ts b/gui/src/main/platform-version.ts
deleted file mode 100644
index 027434d8d9..0000000000
--- a/gui/src/main/platform-version.ts
+++ /dev/null
@@ -1,26 +0,0 @@
-import os from 'os';
-
-export function isMacOs11OrNewer() {
- const [major] = parseVersion();
- return process.platform === 'darwin' && major >= 20;
-}
-
-export function isMacOs13OrNewer() {
- const [major] = parseVersion();
- return process.platform === 'darwin' && major >= 22;
-}
-
-// Windows 11 has the internal version 10.0.22000+.
-export function isWindows11OrNewer() {
- const [major, minor, patch] = parseVersion();
- return (
- process.platform === 'win32' && (major > 10 || (major === 10 && (minor > 0 || patch >= 22000)))
- );
-}
-
-function parseVersion() {
- return os
- .release()
- .split('.')
- .map((value) => parseInt(value, 10));
-}
diff --git a/gui/src/main/problem-report.ts b/gui/src/main/problem-report.ts
deleted file mode 100644
index be84814d72..0000000000
--- a/gui/src/main/problem-report.ts
+++ /dev/null
@@ -1,72 +0,0 @@
-import { execFile } from 'child_process';
-import { randomUUID } from 'crypto';
-import { app, shell } from 'electron';
-import * as path from 'path';
-
-import log from '../shared/logging';
-import { IpcMainEventChannel } from './ipc-event-channel';
-import { resolveBin } from './proc';
-
-export function registerIpcListeners() {
- IpcMainEventChannel.problemReport.handleCollectLogs(collectLogs);
-
- IpcMainEventChannel.problemReport.handleSendReport(({ email, message, savedReportId }) => {
- return send(email, message, savedReportId);
- });
-
- IpcMainEventChannel.problemReport.handleViewLog((savedReportId) =>
- shell.openPath(getProblemReportPath(savedReportId)),
- );
-}
-
-function collectLogs(toRedact?: string): Promise<string> {
- const id = randomUUID();
- const reportPath = getProblemReportPath(id);
- const executable = resolveBin('mullvad-problem-report');
- const args = ['collect', '--output', reportPath];
- if (toRedact) {
- args.push('--redact', toRedact);
- }
-
- return new Promise((resolve, reject) => {
- execFile(executable, args, { windowsHide: true }, (error, stdout, stderr) => {
- if (error) {
- log.error(
- `Failed to collect a problem report.
- Stdout: ${stdout.toString()}
- Stderr: ${stderr.toString()}`,
- );
- reject(error.message);
- } else {
- log.verbose(`Problem report was written to ${reportPath}`);
- resolve(id);
- }
- });
- });
-}
-
-function send(email: string, message: string, savedReportId: string): Promise<void> {
- const executable = resolveBin('mullvad-problem-report');
- const reportPath = getProblemReportPath(savedReportId);
- const args = ['send', '--email', email, '--message', message, '--report', reportPath];
-
- return new Promise((resolve, reject) => {
- execFile(executable, args, { windowsHide: true }, (error, stdout, stderr) => {
- if (error) {
- log.error(
- `Failed to send a problem report.
- Stdout: ${stdout.toString()}
- Stderr: ${stderr.toString()}`,
- );
- reject(error.message);
- } else {
- log.info('Problem report was sent.');
- resolve();
- }
- });
- });
-}
-
-function getProblemReportPath(id: string): string {
- return path.join(app.getPath('temp'), `${id}.log`);
-}
diff --git a/gui/src/main/proc.ts b/gui/src/main/proc.ts
deleted file mode 100644
index 1e8118268d..0000000000
--- a/gui/src/main/proc.ts
+++ /dev/null
@@ -1,25 +0,0 @@
-import path from 'path';
-
-export function resolveBin(binaryName: string) {
- return path.resolve(getBasePath(), binaryName + getExtension());
-}
-
-function getBasePath(): string {
- if (process.env.NODE_ENV === 'development') {
- return (
- process.env.MULLVAD_PATH || path.resolve(path.join(__dirname, '../../../../target/debug'))
- );
- } else {
- return process.resourcesPath;
- }
-}
-
-function getExtension() {
- switch (process.platform) {
- case 'win32':
- return '.exe';
-
- default:
- return '';
- }
-}
diff --git a/gui/src/main/reconnection-backoff.ts b/gui/src/main/reconnection-backoff.ts
deleted file mode 100644
index 5709f053d7..0000000000
--- a/gui/src/main/reconnection-backoff.ts
+++ /dev/null
@@ -1,22 +0,0 @@
-/*
- * Used to calculate the time to wait before reconnecting to the daemon.
- * It uses a linear backoff function that goes from 500ms to 3000ms.
- */
-export default class ReconnectionBackoff {
- private attemptValue = 0;
-
- public attempt(handler: () => void) {
- setTimeout(handler, this.getIncreasedBackoff());
- }
-
- public reset() {
- this.attemptValue = 0;
- }
-
- private getIncreasedBackoff() {
- if (this.attemptValue < 6) {
- this.attemptValue++;
- }
- return this.attemptValue * 500;
- }
-}
diff --git a/gui/src/main/settings.ts b/gui/src/main/settings.ts
deleted file mode 100644
index 99fb4f2a5a..0000000000
--- a/gui/src/main/settings.ts
+++ /dev/null
@@ -1,235 +0,0 @@
-import fs from 'fs/promises';
-
-import { ISettings } from '../shared/daemon-rpc-types';
-import { ICurrentAppVersionInfo } from '../shared/ipc-types';
-import log from '../shared/logging';
-import { getOpenAtLogin, setOpenAtLogin } from './autostart';
-import { DaemonRpc } from './daemon-rpc';
-import { getDefaultSettings } from './default-settings';
-import GuiSettings from './gui-settings';
-import { IpcMainEventChannel } from './ipc-event-channel';
-
-export interface SettingsDelegate {
- handleMonochromaticIconChange(value: boolean): void;
- handleUnpinnedWindowChange(): void;
-}
-
-export default class Settings implements Readonly<ISettings> {
- private guiSettings = new GuiSettings();
-
- private settingsValue = getDefaultSettings();
-
- public constructor(
- private delegate: SettingsDelegate,
- private daemonRpc: DaemonRpc,
- private currentVersion: ICurrentAppVersionInfo,
- ) {}
-
- public registerIpcListeners() {
- this.registerGuiSettingsListener();
-
- IpcMainEventChannel.settings.handleSetAllowLan((allowLan) =>
- this.daemonRpc.setAllowLan(allowLan),
- );
- IpcMainEventChannel.settings.handleSetShowBetaReleases((showBetaReleases) =>
- this.daemonRpc.setShowBetaReleases(showBetaReleases),
- );
- IpcMainEventChannel.settings.handleSetEnableIpv6((enableIpv6) =>
- this.daemonRpc.setEnableIpv6(enableIpv6),
- );
- IpcMainEventChannel.settings.handleSetBlockWhenDisconnected((blockWhenDisconnected) =>
- this.daemonRpc.setBlockWhenDisconnected(blockWhenDisconnected),
- );
- IpcMainEventChannel.settings.handleSetBridgeState(async (bridgeState) => {
- await this.daemonRpc.setBridgeState(bridgeState);
-
- // Reset bridge constraints to `any` when the state is set to auto or off if not custom
- if (
- (bridgeState === 'auto' || bridgeState === 'off') &&
- this.bridgeSettings.type === 'normal'
- ) {
- await this.daemonRpc.setBridgeSettings({
- ...this.bridgeSettings,
- normal: { ...this.bridgeSettings.normal, location: 'any' },
- });
- }
- });
- IpcMainEventChannel.settings.handleSetOpenVpnMssfix((mssfix?: number) =>
- this.daemonRpc.setOpenVpnMssfix(mssfix),
- );
- IpcMainEventChannel.settings.handleSetWireguardMtu((mtu?: number) =>
- this.daemonRpc.setWireguardMtu(mtu),
- );
- IpcMainEventChannel.settings.handleSetWireguardQuantumResistant((quantumResistant?: boolean) =>
- this.daemonRpc.setWireguardQuantumResistant(quantumResistant),
- );
- IpcMainEventChannel.settings.handleSetRelaySettings((relaySettings) =>
- this.daemonRpc.setRelaySettings(relaySettings),
- );
- IpcMainEventChannel.settings.handleUpdateBridgeSettings((bridgeSettings) => {
- return this.daemonRpc.setBridgeSettings(bridgeSettings);
- });
- IpcMainEventChannel.settings.handleSetDnsOptions((dns) => {
- return this.daemonRpc.setDnsOptions(dns);
- });
- IpcMainEventChannel.autoStart.handleSet((autoStart: boolean) => {
- return this.setAutoStart(autoStart);
- });
- IpcMainEventChannel.settings.handleSetObfuscationSettings((obfuscationSettings) => {
- return this.daemonRpc.setObfuscationSettings(obfuscationSettings);
- });
- IpcMainEventChannel.settings.handleAddApiAccessMethod((method) => {
- return this.daemonRpc.addApiAccessMethod(method);
- });
- IpcMainEventChannel.settings.handleUpdateApiAccessMethod((method) => {
- return this.daemonRpc.updateApiAccessMethod(method);
- });
- IpcMainEventChannel.settings.handleRemoveApiAccessMethod((id) => {
- return this.daemonRpc.removeApiAccessMethod(id);
- });
- IpcMainEventChannel.settings.handleSetApiAccessMethod((id) => {
- return this.daemonRpc.setApiAccessMethod(id);
- });
- IpcMainEventChannel.settings.handleTestApiAccessMethodById((id) => {
- return this.daemonRpc.testApiAccessMethodById(id);
- });
- IpcMainEventChannel.settings.handleTestCustomApiAccessMethod((method) => {
- return this.daemonRpc.testCustomApiAccessMethod(method);
- });
-
- IpcMainEventChannel.settings.handleClearAllRelayOverrides(() => {
- return this.daemonRpc.clearAllRelayOverrides();
- });
- IpcMainEventChannel.settings.handleImportText((text) => {
- return this.daemonRpc.applyJsonSettings(text);
- });
- IpcMainEventChannel.settings.handleImportFile(async (path) => {
- const settings = await fs.readFile(path);
- return this.daemonRpc.applyJsonSettings(settings.toString());
- });
- IpcMainEventChannel.settings.handleSetEnableDaita((value) => {
- return this.daemonRpc.setEnableDaita(value);
- });
- IpcMainEventChannel.settings.handleSetDaitaDirectOnly((value) => {
- return this.daemonRpc.setDaitaDirectOnly(value);
- });
-
- IpcMainEventChannel.guiSettings.handleSetEnableSystemNotifications((flag: boolean) => {
- this.guiSettings.enableSystemNotifications = flag;
- });
-
- IpcMainEventChannel.guiSettings.handleSetAutoConnect((autoConnect: boolean) => {
- this.guiSettings.autoConnect = autoConnect;
- });
-
- IpcMainEventChannel.guiSettings.handleSetStartMinimized((startMinimized: boolean) => {
- this.guiSettings.startMinimized = startMinimized;
- });
-
- IpcMainEventChannel.guiSettings.handleSetMonochromaticIcon((monochromaticIcon: boolean) => {
- this.guiSettings.monochromaticIcon = monochromaticIcon;
- });
-
- IpcMainEventChannel.guiSettings.handleSetUnpinnedWindow((unpinnedWindow: boolean) => {
- this.guiSettings.unpinnedWindow = unpinnedWindow;
- this.delegate.handleUnpinnedWindowChange();
- });
-
- IpcMainEventChannel.guiSettings.handleSetAnimateMap((animateMap: boolean) => {
- this.guiSettings.animateMap = animateMap;
- });
-
- IpcMainEventChannel.currentVersion.handleDisplayedChangelog(() => {
- this.guiSettings.changelogDisplayedForVersion = this.currentVersion.gui;
- });
- }
-
- public get all() {
- return this.settingsValue;
- }
-
- public get allowLan() {
- return this.settingsValue.allowLan;
- }
- public get autoConnect() {
- return this.settingsValue.autoConnect;
- }
- public get blockWhenDisconnected() {
- return this.settingsValue.blockWhenDisconnected;
- }
- public get showBetaReleases() {
- return this.settingsValue.showBetaReleases;
- }
- public get relaySettings() {
- return this.settingsValue.relaySettings;
- }
- public get tunnelOptions() {
- return this.settingsValue.tunnelOptions;
- }
- public get bridgeSettings() {
- return this.settingsValue.bridgeSettings;
- }
- public get bridgeState() {
- return this.settingsValue.bridgeState;
- }
- public get splitTunnel() {
- return this.settingsValue.splitTunnel;
- }
- public get obfuscationSettings() {
- return this.settingsValue.obfuscationSettings;
- }
- public get customLists() {
- return this.settingsValue.customLists;
- }
- public get apiAccessMethods() {
- return this.settingsValue.apiAccessMethods;
- }
- public get relayOverrides() {
- return this.settingsValue.relayOverrides;
- }
-
- public get gui() {
- return this.guiSettings;
- }
-
- public handleNewSettings(newSettings: ISettings) {
- this.settingsValue = newSettings;
- }
-
- private registerGuiSettingsListener() {
- this.guiSettings.onChange = (newState, oldState) => {
- if (oldState.monochromaticIcon !== newState.monochromaticIcon) {
- this.delegate.handleMonochromaticIconChange(newState.monochromaticIcon);
- }
-
- if (newState.autoConnect !== oldState.autoConnect) {
- this.updateDaemonsAutoConnect();
- }
-
- IpcMainEventChannel.guiSettings.notify?.(newState);
- };
- }
-
- private async setAutoStart(autoStart: boolean): Promise<void> {
- try {
- await setOpenAtLogin(autoStart);
-
- IpcMainEventChannel.autoStart.notify?.(autoStart);
-
- this.updateDaemonsAutoConnect();
- } catch (e) {
- const error = e as Error;
- log.error(
- `Failed to update the autostart to ${autoStart.toString()}. ${error.message.toString()}`,
- );
- }
- return Promise.resolve();
- }
-
- private updateDaemonsAutoConnect() {
- const daemonAutoConnect = this.guiSettings.autoConnect && getOpenAtLogin();
- if (daemonAutoConnect !== this.settingsValue.autoConnect) {
- void this.daemonRpc.setAutoConnect(daemonAutoConnect);
- }
- }
-}
diff --git a/gui/src/main/tray-icon-controller.ts b/gui/src/main/tray-icon-controller.ts
deleted file mode 100644
index ff7300b1e6..0000000000
--- a/gui/src/main/tray-icon-controller.ts
+++ /dev/null
@@ -1,207 +0,0 @@
-import { exec as execAsync } from 'child_process';
-import { NativeImage, nativeImage, Tray } from 'electron';
-import path from 'path';
-import { promisify } from 'util';
-
-import log from '../shared/logging';
-import KeyframeAnimation from './keyframe-animation';
-
-const exec = promisify(execAsync);
-
-export type TrayIconType = 'unsecured' | 'securing' | 'secured';
-
-type IconParameters = { monochromatic: boolean; notification: boolean };
-
-export default class TrayIconController {
- private animation?: KeyframeAnimation;
- private iconSet: NativeImage[] = [];
- private iconParameters: IconParameters;
-
- private updateThrottlePromise?: Promise<void>;
-
- private previousNotificationIconReason?: string;
-
- constructor(
- private tray: Tray,
- private iconTypeValue: TrayIconType,
- monochromaticIcon: boolean,
- notificationIcon: boolean,
- ) {
- this.iconParameters = { monochromatic: monochromaticIcon, notification: notificationIcon };
- void this.updateTheme();
- }
-
- public dispose() {
- if (this.animation) {
- this.animation.stop();
- this.animation = undefined;
- }
- }
-
- get iconType(): TrayIconType {
- return this.iconTypeValue;
- }
-
- public updateTheme(): Promise<void> {
- // For some reason the icon doesn't update if the iconSet is changed to quickly. Adding a
- // throttle fixes this issue.
- this.updateThrottlePromise ??= new Promise((resolve) => {
- setTimeout(() => {
- this.updateThrottlePromise = undefined;
- void this.updateThemeImpl().then(resolve);
- }, 200);
- });
-
- return this.updateThrottlePromise;
- }
-
- public setMonochromaticIcon(monochromaticIcon: boolean) {
- void this.updateIconParameters({ monochromatic: monochromaticIcon });
- }
-
- public showNotificationIcon(notificationIcon: boolean, reason?: string) {
- if (reason !== this.previousNotificationIconReason) {
- this.previousNotificationIconReason = reason;
- if (notificationIcon) {
- log.info('Showing notification icon:', reason);
- } else {
- log.info('Hiding notification icon');
- }
- }
-
- void this.updateIconParameters({ notification: notificationIcon });
- }
-
- public animateToIcon(type: TrayIconType) {
- if (this.iconTypeValue === type || !this.animation) {
- return;
- }
-
- this.iconTypeValue = type;
-
- const animation = this.animation;
- const frame = this.targetFrame();
-
- animation.play({ end: frame });
- }
-
- private async updateThemeImpl() {
- const systemUsesLightTheme = await this.getSystemUsesLightTheme();
- this.iconSet = this.loadImages(systemUsesLightTheme);
-
- if (this.animation === undefined) {
- this.initAnimation();
- } else if (!this.animation.isRunning) {
- this.animation.play({ end: this.targetFrame() });
- }
- }
-
- // This function uses a promise as a lock to prevent multiple simultaneous updates
- private updateIconParameters(parameters: Partial<IconParameters>) {
- if (
- (parameters.monochromatic !== undefined &&
- parameters.monochromatic !== this.iconParameters.monochromatic) ||
- (parameters.notification !== undefined &&
- parameters.notification !== this.iconParameters.notification)
- ) {
- this.iconParameters = { ...this.iconParameters, ...parameters };
- void this.updateTheme();
- }
- }
-
- private initAnimation() {
- const initialFrame = this.targetFrame();
- const animation = new KeyframeAnimation();
- animation.speed = 100;
- animation.onFrame = this.onFrame;
- animation.play({ start: initialFrame, end: initialFrame });
-
- this.animation = animation;
- }
-
- private onFrame = (frameNumber: number) => {
- const frame = this.iconSet[frameNumber];
- if (frame === undefined) {
- log.error('Failed to show tray icon due to the icon being undefined');
- } else {
- this.tray.setImage(frame);
- }
- };
-
- private loadImages(systemUsesLightTheme?: boolean): NativeImage[] {
- const notificationIcon = this.iconParameters.notification ? '_notification' : '';
- if (this.iconParameters.monochromatic) {
- switch (process.platform) {
- case 'darwin':
- return this.loadImageSet(`${notificationIcon}Template`);
- case 'win32':
- return systemUsesLightTheme
- ? this.loadImageSet(`_black${notificationIcon}`)
- : this.loadImageSet(`_white${notificationIcon}`);
- case 'linux':
- default:
- return this.loadImageSet(`_white${notificationIcon}`);
- }
- } else {
- return this.loadImageSet(notificationIcon);
- }
- }
-
- private loadImageSet(suffix: string): NativeImage[] {
- const frames = Array.from({ length: 10 }, (_, i) => i + 1);
- return frames.map((frame) => nativeImage.createFromPath(this.getImagePath(frame, suffix)));
- }
-
- private getImagePath(frame: number, suffix?: string) {
- const basePath = path.resolve(path.join(__dirname, '../../assets/images/menubar-icons'));
- const extension = process.platform === 'win32' ? 'ico' : 'png';
- return path.join(basePath, process.platform, `lock-${frame}${suffix}.${extension}`);
- }
-
- private async getSystemUsesLightTheme(): Promise<boolean | undefined> {
- if (process.platform !== 'win32') {
- return undefined;
- }
-
- try {
- // This registry entry contains information about the tray background color. This is
- // needed to decide between white and black icons.
- const { stdout, stderr } = await exec(
- 'reg query HKEY_CURRENT_USER\\Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize\\ /v SystemUsesLightTheme',
- );
-
- if (!stderr && stdout) {
- // Split the output into rows
- const rows = stdout.split('\n');
- // Select the row that contains the registry entry result
- const resultRow = rows.find((row) => row.includes('SystemUsesLightTheme'))?.trim();
- // Split the row into words
- const resultRowWords = resultRow?.split(' ').filter((word) => word !== '');
- // Grab value which is last word on the result row
- const value = resultRowWords && resultRowWords[resultRowWords.length - 1];
-
- if (value) {
- const parsedValue = parseInt(value);
- return parsedValue === 1 ? true : false;
- }
- }
-
- return undefined;
- } catch (e) {
- const error = e as Error;
- log.error('Failed to read SystemUsesLightTheme,', error.message);
- return undefined;
- }
- }
-
- private targetFrame(): number {
- switch (this.iconTypeValue) {
- case 'unsecured':
- return 0;
- case 'securing':
- return 9;
- case 'secured':
- return 8;
- }
- }
-}
diff --git a/gui/src/main/tunnel-state.ts b/gui/src/main/tunnel-state.ts
deleted file mode 100644
index 43ebe97ad6..0000000000
--- a/gui/src/main/tunnel-state.ts
+++ /dev/null
@@ -1,116 +0,0 @@
-import { connectEnabled, disconnectEnabled, reconnectEnabled } from '../shared/connect-helper';
-import { ErrorStateCause, ILocation, TunnelState } from '../shared/daemon-rpc-types';
-import { Scheduler } from '../shared/scheduler';
-
-export interface TunnelStateProvider {
- getTunnelState(): TunnelState;
-}
-
-export interface TunnelStateHandlerDelegate {
- handleTunnelStateUpdate(tunnelState: TunnelState): void;
-}
-
-export default class TunnelStateHandler {
- // The current tunnel state
- private tunnelStateValue: TunnelState = { state: 'disconnected' };
- // When pressing connect/disconnect/reconnect the app assumes what the next state will be before
- // it get's the new state from the daemon. The latest state from the daemon is saved as fallback
- // if the assumed state isn't reached.
- private tunnelStateFallback?: TunnelState;
- // Scheduler for discarding the assumed next state.
- private tunnelStateFallbackScheduler = new Scheduler();
-
- private receivedFullDiskAccessError = false;
-
- private lastKnownDisconnectedLocation: Partial<ILocation> | undefined;
-
- public constructor(private delegate: TunnelStateHandlerDelegate) {}
-
- public get hasReceivedFullDiskAccessError() {
- return this.receivedFullDiskAccessError;
- }
- public get tunnelState() {
- return this.tunnelStateValue;
- }
-
- public resetFallback() {
- this.tunnelStateFallbackScheduler.cancel();
- this.tunnelStateFallback = undefined;
- }
-
- // This function sets a new tunnel state as an assumed next state and saves the current state as
- // fallback. The fallback is used if the assumed next state isn't reached.
- public expectNextTunnelState(state: 'connecting' | 'disconnecting') {
- this.tunnelStateFallback = this.tunnelState;
-
- this.setTunnelState(
- state === 'disconnecting'
- ? { state, details: 'nothing' as const, location: this.lastKnownDisconnectedLocation }
- : { state, featureIndicators: undefined },
- );
-
- this.tunnelStateFallbackScheduler.schedule(() => {
- if (this.tunnelStateFallback) {
- this.setTunnelState(this.tunnelStateFallback);
- this.tunnelStateFallback = undefined;
- }
- }, 3000);
- }
-
- public handleNewTunnelState(newState: TunnelState) {
- if (newState.state === 'error' && newState.details) {
- if (newState.details.cause === ErrorStateCause.needFullDiskPermissions) {
- this.receivedFullDiskAccessError = true;
- }
- }
-
- // If there's a fallback state set then the app is in an assumed next state and need to check
- // if it's now reached or if the current state should be ignored and set as the fallback state.
- if (this.tunnelStateFallback) {
- if (this.tunnelState.state === newState.state || newState.state === 'error') {
- this.tunnelStateFallbackScheduler.cancel();
- this.tunnelStateFallback = undefined;
- } else {
- this.tunnelStateFallback = newState;
- return;
- }
- }
-
- if (newState.state === 'disconnecting' && newState.details === 'reconnect') {
- // When reconnecting there's no need of showing the disconnecting state. This switches to the
- // connecting state immediately.
- this.expectNextTunnelState('connecting');
- this.tunnelStateFallback = newState;
- } else {
- if (newState.state === 'disconnected' && newState.location !== undefined) {
- this.lastKnownDisconnectedLocation = newState.location;
- }
-
- if (
- newState.state === 'disconnecting' ||
- (newState.state === 'disconnected' && newState.location === undefined)
- ) {
- newState.location = this.lastKnownDisconnectedLocation;
- }
-
- this.setTunnelState(newState);
- }
- }
-
- public allowConnect(connectToDaemon: boolean, isLoggedIn: boolean) {
- return connectEnabled(connectToDaemon, isLoggedIn, this.tunnelState.state);
- }
-
- public allowReconnect(connectToDaemon: boolean, isLoggedIn: boolean) {
- return reconnectEnabled(connectToDaemon, isLoggedIn, this.tunnelState.state);
- }
-
- public allowDisconnect(connectToDaemon: boolean) {
- return disconnectEnabled(connectToDaemon, this.tunnelState.state);
- }
-
- private setTunnelState(newState: TunnelState) {
- this.tunnelStateValue = newState;
- this.delegate.handleTunnelStateUpdate(newState);
- }
-}
diff --git a/gui/src/main/user-interface.ts b/gui/src/main/user-interface.ts
deleted file mode 100644
index f7b81247ff..0000000000
--- a/gui/src/main/user-interface.ts
+++ /dev/null
@@ -1,702 +0,0 @@
-import { exec } from 'child_process';
-import { app, BrowserWindow, dialog, Menu, nativeImage, screen, Tray } from 'electron';
-import path from 'path';
-import { sprintf } from 'sprintf-js';
-import { promisify } from 'util';
-
-import { connectEnabled, disconnectEnabled, reconnectEnabled } from '../shared/connect-helper';
-import { IAccountData, ILocation, TunnelState } from '../shared/daemon-rpc-types';
-import { messages, relayLocations } from '../shared/gettext';
-import log from '../shared/logging';
-import { Scheduler } from '../shared/scheduler';
-import { CommandLineOptions } from './command-line-options';
-import { DaemonRpc } from './daemon-rpc';
-import {
- changeIpcWebContents,
- IpcMainEventChannel,
- unsetIpcWebContents,
-} from './ipc-event-channel';
-import { WebContentsConsoleInput } from './logging';
-import { isMacOs11OrNewer } from './platform-version';
-import TrayIconController, { TrayIconType } from './tray-icon-controller';
-import WindowController, { WindowControllerDelegate } from './window-controller';
-
-const execAsync = promisify(exec);
-
-export interface UserInterfaceDelegate {
- dismissActiveNotifications(): void;
- updateAccountData(): void;
- connectTunnel(): void;
- reconnectTunnel(): void;
- disconnectTunnel(): void;
- disconnectAndQuit(): void;
- isUnpinnedWindow(): boolean;
- isLoggedIn(): boolean;
- getAccountData(): IAccountData | undefined;
- getTunnelState(): TunnelState;
-}
-
-export default class UserInterface implements WindowControllerDelegate {
- private windowController: WindowController;
-
- private tray: Tray;
- private trayIconController?: TrayIconController;
-
- // True while file pickers are displayed which is used to decide if the Browser window should be
- // hidden when losing focus.
- private browsingFiles = false;
-
- private blurNavigationResetScheduler = new Scheduler();
- private backgroundThrottleScheduler = new Scheduler();
-
- public constructor(
- private delegate: UserInterfaceDelegate,
- private daemonRpc: DaemonRpc,
- private sandboxDisabled: boolean,
- private navigationResetDisabled: boolean,
- ) {
- const window = this.createWindow();
-
- this.windowController = this.createWindowController(window);
- this.tray = this.createTray();
- }
-
- public registerIpcListeners() {
- IpcMainEventChannel.app.handleShowOpenDialog(async (options) => {
- this.browsingFiles = true;
- const response = await dialog.showOpenDialog({
- defaultPath: app.getPath('home'),
- ...options,
- });
- this.browsingFiles = false;
- this.showWindow();
- return response;
- });
-
- IpcMainEventChannel.app.handleShowLaunchDaemonSettings(async () => {
- try {
- await execAsync('open x-apple.systempreferences:com.apple.LoginItems-Settings.extension');
- } catch (error) {
- log.error(`Failed to open launch daemon settings: ${error}`);
- }
- });
-
- IpcMainEventChannel.app.handleShowFullDiskAccessSettings(async () => {
- try {
- await execAsync(
- 'open "x-apple.systempreferences:com.apple.preference.security?Privacy_AllFiles"',
- );
- } catch (error) {
- log.error(`Failed to open Full Disk Access settings: ${error}`);
- }
- });
- }
-
- public createTrayIconController(
- tunnelState: TunnelState,
- blockWhenDisconnected: boolean,
- monochromaticIcon: boolean,
- ) {
- const iconType = this.trayIconType(tunnelState, blockWhenDisconnected);
- this.trayIconController = new TrayIconController(this.tray, iconType, monochromaticIcon, false);
- }
-
- public async initializeWindow(isLoggedIn: boolean, tunnelState: TunnelState) {
- if (!this.windowController.window) {
- throw new Error('No window available in initializeWindow');
- }
-
- const window = this.windowController.window;
-
- // Make sure the IPC wrapper always has the latest webcontents if any
- window.webContents.on('destroyed', unsetIpcWebContents);
- changeIpcWebContents(window.webContents);
-
- this.registerWindowListener();
- this.addContextMenu();
-
- if (process.env.NODE_ENV === 'development') {
- await this.installDevTools();
-
- if (!CommandLineOptions.disableDevtoolsOpen.match) {
- // The devtools doesn't open on Windows if openDevTools is called without a delay here.
- window.once('ready-to-show', () => window.webContents.openDevTools({ mode: 'detach' }));
- }
-
- if (CommandLineOptions.forwardRendererLog.match) {
- log.addInput(new WebContentsConsoleInput(window.webContents));
- }
- }
-
- switch (process.platform) {
- case 'win32':
- this.installWindowsMenubarAppWindowHandlers();
- break;
- case 'darwin':
- this.installMacOsMenubarAppWindowHandlers();
- this.setMacOsAppMenu();
- break;
- case 'linux':
- this.setTrayContextMenu(isLoggedIn, tunnelState);
- this.setLinuxAppMenu();
- window.setMenuBarVisibility(false);
- break;
- }
-
- this.installWindowCloseHandler();
- this.installTrayClickHandlers();
-
- const filePath = path.resolve(path.join(__dirname, '../renderer/index.html'));
- try {
- await window.loadFile(filePath);
- } catch (e) {
- const error = e as Error;
- log.error(`Failed to load index file: ${error.message}`);
- }
-
- // disable pinch to zoom
- if (this.windowController.webContents) {
- void this.windowController.webContents.setVisualZoomLevelLimits(1, 1);
- }
- }
-
- public updateTray = (
- isLoggedIn: boolean,
- tunnelState: TunnelState,
- blockWhenDisconnected: boolean,
- ) => {
- this.updateTrayIcon(tunnelState, blockWhenDisconnected);
- this.setTrayContextMenu(isLoggedIn, tunnelState);
- this.setTrayTooltip(tunnelState);
- };
-
- public async recreateWindow(isLoggedIn: boolean, tunnelState: TunnelState): Promise<void> {
- if (this.tray) {
- this.tray.removeAllListeners();
- // Prevent the IPC webcontents reference to be reset when replacing window. Resetting wouldn't
- // work since the old webContents is destroyed after the IPC wrapper has been updated with the
- // new one.
- this.windowController.webContents?.removeListener('destroyed', unsetIpcWebContents);
- // Remove window close handler that calls `preventDefault` when closed.
- this.windowController.window?.removeListener('close', this.windowCloseHandler);
-
- const window = this.createWindow();
- changeIpcWebContents(window.webContents);
-
- this.windowController.close();
- this.windowController = new WindowController(this, window);
-
- await this.initializeWindow(isLoggedIn, tunnelState);
- this.windowController.show();
- }
- }
-
- public reloadWindow = () => this.windowController.window?.reload();
- public isWindowVisible = () => this.windowController.isVisible();
- public showWindow = () => this.windowController.show();
- public updateTrayTheme = () => this.trayIconController?.updateTheme() ?? Promise.resolve();
- public setMonochromaticIcon = (value: boolean) =>
- this.trayIconController?.setMonochromaticIcon(value);
- public showNotificationIcon = (value: boolean, reason?: string) =>
- this.trayIconController?.showNotificationIcon(value, reason);
- public setWindowIcon = (icon: string) => this.windowController.window?.setIcon(icon);
-
- public updateTrayIcon(tunnelState: TunnelState, blockWhenDisconnected: boolean) {
- const type = this.trayIconType(tunnelState, blockWhenDisconnected);
- this.trayIconController?.animateToIcon(type);
- }
-
- public dispose = () => {
- this.tray.removeAllListeners();
- this.windowController.window?.removeAllListeners();
-
- // The window is not closable on macOS to be able to hide the titlebar and workaround
- // a shadow bug rendered above the invisible title bar. This also prevents the window from
- // closing normally, even programmatically. Therefore re-enable the close button just before
- // quitting the app.
- // Github issue: https://github.com/electron/electron/issues/15008
- if (process.platform === 'darwin' && this.windowController.window) {
- this.windowController.window.closable = true;
- }
-
- this.windowController.close();
- this.trayIconController?.dispose();
- };
-
- private createTray(): Tray {
- const tray = new Tray(nativeImage.createEmpty());
- tray.setToolTip('Mullvad VPN');
-
- // disable double click on tray icon since it causes weird delay
- tray.setIgnoreDoubleClickEvents(true);
-
- return tray;
- }
-
- private createWindow(): BrowserWindow {
- const unpinnedWindow = this.delegate.isUnpinnedWindow();
- const { width, height } = WindowController.getContentSize(unpinnedWindow);
-
- const options: Electron.BrowserWindowConstructorOptions = {
- useContentSize: true,
- width,
- height,
- resizable: false,
- maximizable: false,
- fullscreenable: false,
- show: false,
- frame: unpinnedWindow,
- webPreferences: {
- preload: path.join(__dirname, '../renderer/preloadBundle.js'),
- nodeIntegration: false,
- nodeIntegrationInWorker: false,
- nodeIntegrationInSubFrames: false,
- sandbox: !this.sandboxDisabled,
- contextIsolation: true,
- spellcheck: false,
- devTools: process.env.NODE_ENV === 'development',
- },
- };
-
- switch (process.platform) {
- case 'darwin': {
- // setup window flags to mimic popover on macOS
- const appWindow = new BrowserWindow({
- ...options,
- titleBarStyle: unpinnedWindow ? 'default' : 'customButtonsOnHover',
- minimizable: unpinnedWindow,
- closable: unpinnedWindow,
- transparent: !unpinnedWindow,
- hiddenInMissionControl: !unpinnedWindow,
- });
-
- // make the window visible on all workspaces and prevent the icon from showing in the dock
- // and app switcher.
- if (unpinnedWindow) {
- void app.dock.show();
- } else {
- appWindow.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true });
- app.dock.hide();
- }
-
- return appWindow;
- }
-
- case 'win32': {
- // setup window flags to mimic an overlay window
- const appWindow = new BrowserWindow({
- ...options,
- // Due to a bug in Electron the app is sometimes placed behind other apps when opened.
- // Setting alwaysOnTop to true ensures that the app is placed on top. Electron issue:
- // https://github.com/electron/electron/issues/25915
- alwaysOnTop: !unpinnedWindow,
- skipTaskbar: !unpinnedWindow,
- // Workaround for sub-pixel anti-aliasing
- // https://github.com/electron/electron/blob/main/docs/faq.md#the-font-looks-blurry-what-is-this-and-what-can-i-do
- backgroundColor: '#fff',
- });
- const WM_DEVICECHANGE = 0x0219;
- const DBT_DEVICEARRIVAL = 0x8000n;
- const DBT_DEVICEREMOVECOMPLETE = 0x8004n;
- appWindow.hookWindowMessage(WM_DEVICECHANGE, (wParam) => {
- const wParamL = wParam.readBigInt64LE(0);
- if (wParamL != DBT_DEVICEARRIVAL && wParamL != DBT_DEVICEREMOVECOMPLETE) {
- return;
- }
- this.daemonRpc
- .checkVolumes()
- .catch((error) =>
- log.error(`Unable to notify daemon of device event: ${error.message}`),
- );
- });
-
- appWindow.removeMenu();
-
- return appWindow;
- }
-
- default:
- return new BrowserWindow(options);
- }
- }
-
- private createWindowController(window: BrowserWindow) {
- return new WindowController(this, window);
- }
-
- private registerWindowListener() {
- this.windowController.window?.on('focus', () => {
- IpcMainEventChannel.window.notifyFocus?.(true);
-
- this.blurNavigationResetScheduler.cancel();
-
- // cancel notifications when window appears
- this.delegate.dismissActiveNotifications();
-
- this.delegate.updateAccountData();
- });
-
- this.windowController.window?.on('blur', () => {
- IpcMainEventChannel.window.notifyFocus?.(false);
- });
-
- // Use hide instead of blur to prevent the navigation reset from happening when bluring an
- // unpinned window.
- this.windowController.window?.on('hide', () => {
- if (process.env.NODE_ENV !== 'development' || !this.navigationResetDisabled) {
- this.blurNavigationResetScheduler.schedule(() => {
- this.windowController.webContents?.setBackgroundThrottling(false);
- IpcMainEventChannel.navigation.notifyReset?.();
-
- this.backgroundThrottleScheduler.schedule(() => {
- this.windowController.webContents?.setBackgroundThrottling(true);
- }, 1_000);
- }, 120_000);
- }
- });
- }
-
- private setTrayContextMenu(isLoggedIn: boolean, tunnelState: TunnelState) {
- if (process.platform === 'linux') {
- this.tray.setContextMenu(
- this.createContextMenu(this.daemonRpc.isConnected, isLoggedIn, tunnelState),
- );
- }
- }
-
- private setTrayTooltip(tunnelState: TunnelState) {
- const tooltip = this.createTooltipText(this.daemonRpc.isConnected, tunnelState);
- this.tray?.setToolTip(tooltip);
- }
-
- private addContextMenu() {
- const menuTemplate: Electron.MenuItemConstructorOptions[] = [
- { role: 'cut' },
- { role: 'copy' },
- { role: 'paste' },
- { type: 'separator' },
- { role: 'selectAll' },
- ];
-
- // add inspect element on right click menu
- this.windowController.window?.webContents.on(
- 'context-menu',
- (_e: Electron.Event, props: { x: number; y: number; isEditable: boolean }) => {
- const inspectTemplate = [
- {
- label: 'Inspect element',
- click: () => {
- this.windowController.window?.webContents.openDevTools({ mode: 'detach' });
- this.windowController.window?.webContents.inspectElement(props.x, props.y);
- },
- },
- ];
-
- if (props.isEditable) {
- // mixin 'inspect element' into standard menu when in development mode
- if (process.env.NODE_ENV === 'development') {
- const inputMenu: Electron.MenuItemConstructorOptions[] = [
- { type: 'separator' },
- ...inspectTemplate,
- ];
-
- Menu.buildFromTemplate(inputMenu).popup({ window: this.windowController.window });
- } else {
- Menu.buildFromTemplate(menuTemplate).popup({ window: this.windowController.window });
- }
- } else if (process.env.NODE_ENV === 'development') {
- // display inspect element for all non-editable
- // elements when in development mode
- Menu.buildFromTemplate(inspectTemplate).popup({ window: this.windowController.window });
- }
- },
- );
- }
-
- private async installDevTools() {
- const {
- default: installer,
- REACT_DEVELOPER_TOOLS,
- REDUX_DEVTOOLS,
- } = await import('electron-devtools-installer');
- const forceDownload = !!process.env.UPGRADE_EXTENSIONS;
- const options = { forceDownload, loadExtensionOptions: { allowFileAccess: true } };
- try {
- await installer(REACT_DEVELOPER_TOOLS, options);
- await installer(REDUX_DEVTOOLS, options);
- } catch (e) {
- const error = e as Error;
- log.info(`Error installing extension: ${error.message}`);
- }
- }
-
- // On macOS, hotkeys are bound to the app menu and won't work if it's not set,
- // even though the app menu itself is not visible because the app does not appear in the dock.
- private setMacOsAppMenu() {
- const mullvadVpnSubmenu: Electron.MenuItemConstructorOptions[] = [];
- if (process.env.NODE_ENV === 'development') {
- mullvadVpnSubmenu.unshift({ role: 'quit' }, { role: 'reload' }, { role: 'forceReload' });
- }
-
- const template: Electron.MenuItemConstructorOptions[] = [
- {
- label: 'Mullvad VPN',
- submenu: mullvadVpnSubmenu,
- },
- {
- label: 'Edit',
- submenu: [
- { role: 'cut' },
- { role: 'copy' },
- { role: 'paste' },
- { type: 'separator' },
- { role: 'selectAll' },
- ],
- },
- ];
- Menu.setApplicationMenu(Menu.buildFromTemplate(template));
- }
-
- private setLinuxAppMenu() {
- const template: Electron.MenuItemConstructorOptions[] = [
- {
- label: 'Mullvad VPN',
- submenu: [{ role: 'quit' }],
- },
- ];
- Menu.setApplicationMenu(Menu.buildFromTemplate(template));
- }
-
- private installWindowsMenubarAppWindowHandlers() {
- if (this.delegate.isUnpinnedWindow()) {
- return;
- }
-
- this.windowController.window?.on('blur', () => {
- // Detect if blur happened when user had a cursor above the tray icon.
- const trayBounds = this.tray.getBounds();
- const cursorPos = screen.getCursorScreenPoint();
- const isCursorInside =
- cursorPos.x >= trayBounds.x &&
- cursorPos.y >= trayBounds.y &&
- cursorPos.x <= trayBounds.x + trayBounds.width &&
- cursorPos.y <= trayBounds.y + trayBounds.height;
- if (!isCursorInside && !this.browsingFiles) {
- this.windowController.hide();
- }
- });
- }
-
- // setup NSEvent monitor to fix inconsistent window.blur on macOS
- // see https://github.com/electron/electron/issues/8689
- private installMacOsMenubarAppWindowHandlers() {
- if (this.delegate.isUnpinnedWindow()) {
- return;
- }
-
- // eslint-disable-next-line @typescript-eslint/no-require-imports
- const { NSEventMonitor, NSEventMask } = require('nseventmonitor');
- const macEventMonitor = new NSEventMonitor();
- const eventMask = NSEventMask.leftMouseDown | NSEventMask.rightMouseDown;
-
- this.windowController.window?.on('show', () =>
- macEventMonitor.start(eventMask, () => this.windowController.hide()),
- );
- this.windowController.window?.on('hide', () => macEventMonitor.stop());
- this.windowController.window?.on('blur', () => {
- // Make sure to hide the menubar window when other program captures the focus.
- // But avoid doing that when dev tools capture the focus to make it possible to inspect the UI
- if (
- this.windowController.window?.isVisible() &&
- !this.windowController.window?.webContents.isDevToolsFocused()
- ) {
- this.windowController.hide();
- }
- });
- }
-
- private installWindowCloseHandler() {
- if (!this.delegate.isUnpinnedWindow()) {
- return;
- }
-
- this.windowController.window?.on('close', this.windowCloseHandler);
- }
-
- private windowCloseHandler = (closeEvent: Electron.Event) => {
- closeEvent.preventDefault();
- this.windowController.hide();
- };
-
- private installTrayClickHandlers() {
- switch (process.platform) {
- case 'win32':
- if (this.delegate.isUnpinnedWindow()) {
- // This needs to be executed on click since if it is added to the tray icon it will be
- // displayed on left click as well.
- this.tray?.on('right-click', () =>
- this.popUpContextMenu(this.delegate.isLoggedIn(), this.delegate.getTunnelState()),
- );
- this.tray?.on('click', () => this.windowController.show());
- } else {
- this.tray?.on('right-click', () => this.windowController.hide());
- this.tray?.on('click', () => this.windowController.toggle());
- }
- break;
- case 'darwin':
- this.tray?.on('right-click', () => this.windowController.hide());
- this.tray?.on('click', (event) => {
- if (event.metaKey) {
- setImmediate(() => this.windowController.updatePosition());
- } else {
- if (isMacOs11OrNewer() && !this.windowController.isVisible()) {
- // This is a workaround for this Electron issue, when it's resolved
- // `this.windowController.toggle()` should do the trick on all platforms:
- // https://github.com/electron/electron/issues/28776
- const contextMenu = Menu.buildFromTemplate([]);
- contextMenu.on('menu-will-show', () => this.windowController.show());
- this.tray?.popUpContextMenu(contextMenu);
- } else {
- this.windowController.toggle();
- }
- }
- });
- break;
- case 'linux':
- this.tray?.on('click', () => this.windowController.show());
- break;
- }
- }
-
- private popUpContextMenu(isLoggedIn: boolean, tunnelState: TunnelState) {
- this.tray.popUpContextMenu(
- this.createContextMenu(this.daemonRpc.isConnected, isLoggedIn, tunnelState),
- );
- }
-
- private createTooltipText(connectedToDaemon: boolean, tunnelState: TunnelState): string {
- if (!connectedToDaemon) {
- return messages.pgettext('tray-icon-context-menu', 'Disconnected from system service');
- }
-
- switch (tunnelState.state) {
- case 'disconnected':
- return messages.gettext('Disconnected');
- case 'disconnecting':
- return messages.gettext('Disconnecting');
- case 'connecting': {
- const location = this.createLocationString(tunnelState.details?.location);
- return location
- ? sprintf(messages.pgettext('tray-icon-tooltip', 'Connecting. %(location)s'), {
- location,
- })
- : messages.gettext('Connecting');
- }
- case 'connected': {
- const location = this.createLocationString(tunnelState.details.location);
- return location
- ? sprintf(messages.pgettext('tray-icon-tooltip', 'Connected. %(location)s'), {
- location,
- })
- : messages.gettext('Connected');
- }
- }
-
- return 'Mullvad VPN';
- }
-
- private createLocationString(location?: ILocation): string | undefined {
- if (location === undefined) {
- return undefined;
- }
-
- const country = relayLocations.gettext(location.country);
- return location.city
- ? sprintf(messages.pgettext('tray-icon-tooltip', '%(city)s, %(country)s'), {
- city: relayLocations.gettext(location.city),
- country,
- })
- : country;
- }
-
- private createContextMenu(
- connectedToDaemon: boolean,
- loggedIn: boolean,
- tunnelState: TunnelState,
- ) {
- const template: Electron.MenuItemConstructorOptions[] = [
- {
- label: sprintf(messages.pgettext('tray-icon-context-menu', 'Open %(mullvadVpn)s'), {
- mullvadVpn: 'Mullvad VPN',
- }),
- click: () => this.windowController.show(),
- },
- { type: 'separator' },
- {
- id: 'connect',
- label: messages.gettext('Connect'),
- enabled: connectEnabled(connectedToDaemon, loggedIn, tunnelState.state),
- click: this.delegate.connectTunnel,
- },
- {
- id: 'reconnect',
- label: messages.gettext('Reconnect'),
- enabled: reconnectEnabled(connectedToDaemon, loggedIn, tunnelState.state),
- click: this.delegate.reconnectTunnel,
- },
- {
- id: 'disconnect',
- label: messages.gettext('Disconnect'),
- enabled: disconnectEnabled(connectedToDaemon, tunnelState.state),
- click: this.delegate.disconnectTunnel,
- },
- { type: 'separator' },
- {
- id: 'disconnect',
- label:
- tunnelState.state === 'disconnected'
- ? messages.gettext('Quit')
- : this.escapeContextMenuLabel(messages.gettext('Disconnect & quit')),
- click: this.delegate.disconnectAndQuit,
- },
- ];
-
- return Menu.buildFromTemplate(template);
- }
-
- private escapeContextMenuLabel(label: string): string {
- return label.replace('&', '&&');
- }
-
- private trayIconType(tunnelState: TunnelState, blockWhenDisconnected: boolean): TrayIconType {
- switch (tunnelState.state) {
- case 'connected':
- return 'secured';
-
- case 'connecting':
- return 'securing';
-
- case 'error':
- if (!tunnelState.details.blockingError) {
- return 'securing';
- } else {
- return 'unsecured';
- }
- case 'disconnecting':
- return 'securing';
-
- case 'disconnected':
- if (blockWhenDisconnected) {
- return 'securing';
- } else {
- return 'unsecured';
- }
- }
- }
-
- /* eslint-disable @typescript-eslint/member-ordering */
- // WindowControllerDelegate
- public getTrayBounds = () => this.tray.getBounds();
- public isUnpinnedWindow = () => this.delegate.isUnpinnedWindow();
- /* eslint-enable @typescript-eslint/member-ordering */
-}
diff --git a/gui/src/main/version.ts b/gui/src/main/version.ts
deleted file mode 100644
index 1bab7728b4..0000000000
--- a/gui/src/main/version.ts
+++ /dev/null
@@ -1,126 +0,0 @@
-import { app } from 'electron';
-
-import { IAppVersionInfo } from '../shared/daemon-rpc-types';
-import { ICurrentAppVersionInfo } from '../shared/ipc-types';
-import log from '../shared/logging';
-import {
- InconsistentVersionNotificationProvider,
- SystemNotificationCategory,
- UnsupportedVersionNotificationProvider,
- UpdateAvailableNotificationProvider,
-} from '../shared/notifications/notification';
-import { DaemonRpc } from './daemon-rpc';
-import { IpcMainEventChannel } from './ipc-event-channel';
-import { NotificationSender } from './notification-controller';
-
-export const GUI_VERSION = app.getVersion().replace('.0', '');
-/// Mirrors the beta check regex in the daemon. Matches only well formed beta versions
-const IS_BETA = /^(\d{4})\.(\d+)-beta(\d+)$/;
-
-export default class Version {
- private currentVersionData: ICurrentAppVersionInfo = {
- daemon: undefined,
- gui: GUI_VERSION,
- isConsistent: true,
- isBeta: IS_BETA.test(GUI_VERSION),
- };
-
- private upgradeVersionData: IAppVersionInfo = {
- supported: true,
- suggestedUpgrade: undefined,
- };
-
- public constructor(
- private delegate: NotificationSender,
- private daemonRpc: DaemonRpc,
- private updateNotificationDisabled: boolean,
- ) {}
-
- public get currentVersion() {
- return this.currentVersionData;
- }
-
- public get upgradeVersion() {
- return this.upgradeVersionData;
- }
-
- public setDaemonVersion(daemonVersion: string) {
- const versionInfo = {
- ...this.currentVersionData,
- daemon: daemonVersion,
- isConsistent: daemonVersion === this.currentVersionData.gui,
- };
-
- this.currentVersionData = versionInfo;
-
- if (!versionInfo.isConsistent) {
- log.info('Inconsistent version', {
- guiVersion: versionInfo.gui,
- daemonVersion: versionInfo.daemon,
- });
- }
-
- // notify user about inconsistent version
- const notificationProvider = new InconsistentVersionNotificationProvider({
- consistent: versionInfo.isConsistent,
- });
- if (notificationProvider.mayDisplay()) {
- this.delegate.notify(notificationProvider.getSystemNotification());
- } else {
- this.delegate.closeNotificationsInCategory(SystemNotificationCategory.inconsistentVersion);
- }
-
- // notify renderer
- IpcMainEventChannel.currentVersion.notify?.(versionInfo);
- }
-
- public setLatestVersion(latestVersionInfo: IAppVersionInfo) {
- if (this.updateNotificationDisabled) {
- return;
- }
-
- const suggestedIsBeta =
- latestVersionInfo.suggestedUpgrade !== undefined &&
- IS_BETA.test(latestVersionInfo.suggestedUpgrade);
-
- const upgradeVersion = {
- ...latestVersionInfo,
- suggestedIsBeta,
- };
-
- this.upgradeVersionData = upgradeVersion;
-
- // notify user to update the app if it became unsupported
- const notificationProviders = [
- new UnsupportedVersionNotificationProvider({
- supported: latestVersionInfo.supported,
- consistent: this.currentVersionData.isConsistent,
- suggestedUpgrade: latestVersionInfo.suggestedUpgrade,
- suggestedIsBeta,
- }),
- new UpdateAvailableNotificationProvider({
- suggestedUpgrade: latestVersionInfo.suggestedUpgrade,
- suggestedIsBeta,
- }),
- ];
- const notificationProvider = notificationProviders.find((notificationProvider) =>
- notificationProvider.mayDisplay(),
- );
- if (notificationProvider) {
- this.delegate.notify(notificationProvider.getSystemNotification());
- } else {
- this.delegate.closeNotificationsInCategory(SystemNotificationCategory.newVersion);
- }
-
- IpcMainEventChannel.upgradeVersion.notify?.(upgradeVersion);
- }
-
- public async fetchLatestVersion() {
- try {
- this.setLatestVersion(await this.daemonRpc.getVersionInfo());
- } catch (e) {
- const error = e as Error;
- log.error(`Failed to request the version info: ${error.message}`);
- }
- }
-}
diff --git a/gui/src/main/window-controller.ts b/gui/src/main/window-controller.ts
deleted file mode 100644
index 553c798334..0000000000
--- a/gui/src/main/window-controller.ts
+++ /dev/null
@@ -1,333 +0,0 @@
-import { BrowserWindow, Display, screen, Tray, WebContents } from 'electron';
-
-import { IWindowShapeParameters } from '../shared/ipc-types';
-import { Scheduler } from '../shared/scheduler';
-import { IpcMainEventChannel } from './ipc-event-channel';
-import { isWindows11OrNewer } from './platform-version';
-
-interface IPosition {
- x: number;
- y: number;
-}
-
-interface IWindowPositioning {
- getPosition(window: BrowserWindow): IPosition;
- getWindowShapeParameters(window: BrowserWindow): IWindowShapeParameters;
-}
-
-export interface WindowControllerDelegate {
- getTrayBounds: Tray['getBounds'];
- isUnpinnedWindow(): boolean;
-}
-
-class StandaloneWindowPositioning implements IWindowPositioning {
- public getPosition(window: BrowserWindow): IPosition {
- const windowBounds = window.getBounds();
-
- const primaryDisplay = screen.getPrimaryDisplay();
- const workArea = primaryDisplay.workArea;
- const maxX = workArea.x + workArea.width - windowBounds.width;
- const maxY = workArea.y + workArea.height - windowBounds.height;
-
- const x = Math.min(Math.max(windowBounds.x, workArea.x), maxX);
- const y = Math.min(Math.max(windowBounds.y, workArea.y), maxY);
-
- return { x, y };
- }
-
- public getWindowShapeParameters(_window: BrowserWindow): IWindowShapeParameters {
- return {};
- }
-}
-
-class AttachedToTrayWindowPositioning implements IWindowPositioning {
- constructor(private delegate: WindowControllerDelegate) {}
-
- public getPosition(window: BrowserWindow): IPosition {
- const windowBounds = window.getBounds();
- const trayBounds = this.delegate.getTrayBounds();
-
- const activeDisplay = screen.getDisplayNearestPoint({
- x: trayBounds.x,
- y: trayBounds.y,
- });
- const workArea = activeDisplay.workArea;
- const placement = this.getTrayPlacement();
- const maxX = workArea.x + workArea.width - windowBounds.width;
- const maxY = workArea.y + workArea.height - windowBounds.height;
-
- const margin = this.getWindowMargin();
-
- let x = 0;
- let y = 0;
-
- switch (placement) {
- case 'top':
- x = trayBounds.x + (trayBounds.width - windowBounds.width) * 0.5;
- y = workArea.y + margin;
- break;
-
- case 'bottom':
- x = trayBounds.x + (trayBounds.width - windowBounds.width) * 0.5;
- y = workArea.y + workArea.height - windowBounds.height - margin;
- break;
-
- case 'left':
- x = workArea.x + margin;
- y = trayBounds.y + (trayBounds.height - windowBounds.height) * 0.5;
- break;
-
- case 'right':
- x = workArea.width - windowBounds.width - margin;
- y = trayBounds.y + (trayBounds.height - windowBounds.height) * 0.5;
- break;
-
- case 'none':
- x = workArea.x + (workArea.width - windowBounds.width) * 0.5;
- y = workArea.y + (workArea.height - windowBounds.height) * 0.5;
- break;
- }
-
- x = Math.min(Math.max(x, workArea.x), maxX);
- y = Math.min(Math.max(y, workArea.y), maxY);
-
- return {
- x: Math.round(x),
- y: Math.round(y),
- };
- }
-
- public getWindowShapeParameters(window: BrowserWindow): IWindowShapeParameters {
- const trayBounds = this.delegate.getTrayBounds();
- const windowBounds = window.getBounds();
- const arrowPosition = trayBounds.x - windowBounds.x + trayBounds.width * 0.5;
- return {
- arrowPosition,
- };
- }
-
- private getTrayPlacement() {
- switch (process.platform) {
- case 'darwin':
- // macOS has menubar always placed at the top
- return 'top';
-
- case 'win32': {
- // taskbar occupies some part of the screen excluded from work area
- const primaryDisplay = screen.getPrimaryDisplay();
- const displaySize = primaryDisplay.size;
- const workArea = primaryDisplay.workArea;
-
- if (workArea.width < displaySize.width) {
- return workArea.x > 0 ? 'left' : 'right';
- } else if (workArea.height < displaySize.height) {
- return workArea.y > 0 ? 'top' : 'bottom';
- } else {
- return 'none';
- }
- }
-
- default:
- return 'none';
- }
- }
-
- private getWindowMargin() {
- if (isWindows11OrNewer()) {
- // Tray applications are positioned approximately 10px from the tray in Windows 11.
- return 10;
- } else if (process.platform === 'darwin') {
- return 5;
- } else {
- return 0;
- }
- }
-}
-
-export default class WindowController {
- private windowValue: BrowserWindow;
- private webContentsValue: WebContents;
- private windowPositioning: IWindowPositioning;
-
- private windowPositioningScheduler = new Scheduler();
-
- get window(): BrowserWindow | undefined {
- return this.windowValue.isDestroyed() ? undefined : this.windowValue;
- }
-
- get webContents(): WebContents | undefined {
- return this.webContentsValue.isDestroyed() ? undefined : this.webContentsValue;
- }
-
- constructor(
- private delegate: WindowControllerDelegate,
- windowValue: BrowserWindow,
- ) {
- this.windowValue = windowValue;
- this.webContentsValue = windowValue.webContents;
- this.windowPositioning = delegate.isUnpinnedWindow()
- ? new StandaloneWindowPositioning()
- : new AttachedToTrayWindowPositioning(delegate);
-
- this.installDisplayMetricsHandler();
- this.installHideHandler();
- }
-
- public show(whenReady = true) {
- if (whenReady) {
- this.executeWhenWindowIsReady(() => this.showImmediately());
- } else {
- this.showImmediately();
- }
- }
-
- public hide() {
- this.window?.hide();
- }
-
- public toggle() {
- if (this.window?.isVisible()) {
- this.hide();
- } else {
- this.show();
- }
- }
-
- public isVisible(): boolean {
- return this.window !== undefined && this.window.isVisible() && !this.window.isMinimized();
- }
-
- public updatePosition() {
- if (this.window) {
- const position = this.windowPositioning.getPosition(this.window);
- const size = WindowController.getContentSize(this.delegate.isUnpinnedWindow());
- this.window.setBounds({ ...position, ...size }, false);
- }
-
- this.notifyUpdateWindowShape();
- }
-
- public close() {
- if (this.window && !this.window.isDestroyed()) {
- this.window.webContents.closeDevTools();
- this.window.closable = true;
- this.window.close();
- }
- }
-
- public static getContentSize(unpinnedWindow: boolean): { width: number; height: number } {
- return {
- width: 320,
- height: WindowController.getContentHeight(unpinnedWindow),
- };
- }
-
- private installHideHandler() {
- this.window?.addListener('hide', () => this.windowPositioningScheduler.cancel());
- this.window?.addListener('closed', () => this.windowPositioningScheduler.cancel());
- }
-
- private showImmediately() {
- const window = this.window;
-
- // When running with unpinned window on Windows there's a bug that causes the app to become
- // wider if opened from minimized if the updated position is set before the window is opened.
- // Unfortunately the order can't always be changed since this would cause the Window to "jump"
- // in other scenarios.
- if (
- process.platform === 'win32' &&
- this.windowPositioning instanceof StandaloneWindowPositioning
- ) {
- window?.show();
- window?.focus();
-
- this.updatePosition();
- } else {
- this.updatePosition();
-
- window?.show();
- window?.focus();
- }
- }
-
- private notifyUpdateWindowShape() {
- if (this.window) {
- const shapeParameters = this.windowPositioning.getWindowShapeParameters(this.window);
-
- IpcMainEventChannel.window.notifyShape?.(shapeParameters);
- }
- }
-
- // Installs display event handlers to update the window position on any changes in the display or
- // workarea dimensions.
- private installDisplayMetricsHandler() {
- if (this.window) {
- screen.addListener('display-metrics-changed', this.onDisplayMetricsChanged);
- this.window.once('closed', () => {
- screen.removeListener('display-metrics-changed', this.onDisplayMetricsChanged);
- });
- }
- }
-
- private onDisplayMetricsChanged = (
- _event: Electron.Event,
- _display: Display,
- changedMetrics: string[],
- ) => {
- if (changedMetrics.includes('scaleFactor')) {
- IpcMainEventChannel.window.notifyScaleFactorChange?.();
- }
-
- if (changedMetrics.includes('workArea') && this.window?.isVisible()) {
- this.onWorkAreaSizeChange();
- if (process.platform === 'win32') {
- this.windowPositioningScheduler.schedule(() => this.onWorkAreaSizeChange(), 500);
- }
- }
-
- // On Linux and Windows, the window won't be properly rescaled back to it's original
- // size if the DPI scaling factor is changed.
- // https://github.com/electron/electron/issues/11050
- if (
- changedMetrics.includes('scaleFactor') &&
- (process.platform === 'win32' || process.platform === 'linux')
- ) {
- this.forceResizeWindow();
- }
- };
-
- private onWorkAreaSizeChange() {
- this.updatePosition();
- }
-
- private forceResizeWindow() {
- const { width, height } = WindowController.getContentSize(this.delegate.isUnpinnedWindow());
- this.window?.setContentSize(width, height);
- }
-
- private executeWhenWindowIsReady(closure: () => void) {
- if (this.webContents?.isLoading() === false && this.webContents?.getURL() !== '') {
- closure();
- } else {
- this.webContents?.once('did-stop-loading', () => {
- closure();
- });
- }
- }
-
- // On both Linux and Windows the app height is applied incorrectly:
- // https://github.com/electron/electron/issues/28777
- private static getContentHeight(unpinnedWindow: boolean): number {
- // The height we want to achieve.
- const contentHeight = 568;
-
- switch (process.platform) {
- case 'win32':
- // On Windows the app height ends up slightly lower than we set it to if running in unpinned
- // mode and the app becomes a tiny bit taller when pinned to task bar.
- return unpinnedWindow ? contentHeight + 25 : contentHeight;
- default:
- return contentHeight;
- }
- }
-}
diff --git a/gui/src/main/windows-pe-parser.ts b/gui/src/main/windows-pe-parser.ts
deleted file mode 100644
index f4d6ebc852..0000000000
--- a/gui/src/main/windows-pe-parser.ts
+++ /dev/null
@@ -1,479 +0,0 @@
-import { promises as fs } from 'fs';
-
-// eslint-disable-next-line @typescript-eslint/no-explicit-any
-export type Primitive = { size: number; reader: (buffer: Buffer) => any };
-export type PrimitiveWrapper = { primitive: Primitive };
-
-export type StructItem = { name: string; datatype: Datatype };
-export type Struct = Array<StructItem>;
-export type StructWrapper = { struct: Struct };
-
-export type ArrayWrapper = { array: Array<Datatype> };
-
-export type Datatype = PrimitiveWrapper | StructWrapper | ArrayWrapper;
-
-// Type that represent the correct value-type for a given Datatype.
-type ValueType<T extends Datatype> = T extends PrimitiveWrapper
- ? PrimitiveValue<T>
- : T extends ArrayWrapper
- ? ArrayValue<T>
- : T extends StructWrapper
- ? StructValue<T>
- : never;
-
-// Represents any kind of parseable value within the PE headers. Value is extended by
-// PrimitiveValue, ArrayValue and StructValue.
-export class Value<T extends Datatype> {
- public constructor(
- protected fileHandle: fs.FileHandle,
- public readonly buffer: Buffer,
- public readonly datatype: T,
- public readonly offset: number,
- ) {}
-
- public get size(): number {
- return Value.sizeOf(this.datatype);
- }
-
- public get endOffset(): number {
- return this.offset + this.size;
- }
-
- public static sizeOf(datatype: Datatype): number {
- if (Value.isPrimitive(datatype)) {
- return datatype.primitive.size;
- } else if (Value.isArray(datatype)) {
- return datatype.array.reduce((sum, current) => sum + Value.sizeOf(current), 0);
- } else if (Value.isStruct(datatype)) {
- return datatype.struct.reduce((sum, current) => sum + Value.sizeOf(current.datatype), 0);
- } else {
- throw new Error('Not possible');
- }
- }
-
- // Reads a datatype from a file handle and returns the correct subclass of Value.
- public static async fromFile<T extends Datatype>(
- fileHandle: fs.FileHandle,
- offset: number,
- datatype: T,
- ): Promise<ValueType<T>> {
- const buffer = Buffer.alloc(Value.sizeOf(datatype));
- const { bytesRead } = await fileHandle.read(buffer, 0, buffer.length, offset);
-
- if (bytesRead < buffer.length) {
- throw new Error('Failed to read datatype');
- }
-
- return Value.createNew(fileHandle, buffer, datatype, offset);
- }
-
- protected static isPrimitive(datatype: Datatype): datatype is PrimitiveWrapper {
- return 'primitive' in datatype;
- }
- protected static isArray(datatype: Datatype): datatype is ArrayWrapper {
- return 'array' in datatype;
- }
- protected static isStruct(datatype: Datatype): datatype is StructWrapper {
- return 'struct' in datatype;
- }
-
- protected createNew<T extends Datatype>(
- buffer: Buffer,
- datatype: T,
- offset: number,
- ): ValueType<T> {
- return Value.createNew(this.fileHandle, buffer, datatype, offset);
- }
-
- private static createNew<T extends Datatype>(
- fileHandle: fs.FileHandle,
- buffer: Buffer,
- datatype: T,
- offset: number,
- ): ValueType<T> {
- if (Value.isPrimitive(datatype)) {
- return new PrimitiveValue(fileHandle, buffer, datatype, offset) as ValueType<T>;
- } else if (Value.isArray(datatype)) {
- return new ArrayValue(fileHandle, buffer, datatype, offset) as ValueType<T>;
- } else if (Value.isStruct(datatype)) {
- return new StructValue(fileHandle, buffer, datatype, offset) as ValueType<T>;
- } else {
- // This will never happen since the value can't be anything else than the above types.
- throw new Error('No matching value type.');
- }
- }
-}
-
-// Calculates the byteoffset from a relative virtual address.
-export async function rvaToOffset(
- fileHandle: fs.FileHandle,
- rva: number,
- numberOfSections: number,
- firstSectionHeaderOffset: number,
-): Promise<number> {
- for (let i = 0; i < numberOfSections; i++) {
- const sectionHeaderOffset = firstSectionHeaderOffset + i * Value.sizeOf(IMAGE_SECTION_HEADER);
- const sectionHeader = await Value.fromFile(
- fileHandle,
- sectionHeaderOffset,
- IMAGE_SECTION_HEADER,
- );
- const sectionRva = sectionHeader.get('VirtualAddress').value<number>();
- const sectionSize = sectionHeader.get('SizeOfRawData').value<number>();
-
- if (rva >= sectionRva && rva < sectionRva + sectionSize) {
- const pointerToRawData = sectionHeader.get('PointerToRawData').value();
- return pointerToRawData + (rva - sectionRva);
- }
- }
-
- throw new Error('Failed to map RVA to offset');
-}
-
-export class PrimitiveValue<T extends PrimitiveWrapper = PrimitiveWrapper> extends Value<T> {
- // Parses and returns the value.
- public value<U extends ReturnType<T['primitive']['reader']>>(): ReturnType<
- T['primitive']['reader']
- > {
- const result = this.datatype.primitive.reader(this.buffer);
- if (result === undefined) {
- throw new Error('Failed to read value from buffer');
- } else {
- return result as U;
- }
- }
-}
-
-export class ArrayValue<T extends ArrayWrapper = ArrayWrapper> extends Value<T> {
- // Parses and returns the value at the specified index.
- public nth<U extends ValueType<T['array'][number]>>(index: number): U {
- const datatype = this.datatype.array[0];
- const itemSize = Value.sizeOf(datatype);
- const offset = index * itemSize;
- const buffer = this.buffer.slice(offset, offset + itemSize);
-
- return this.createNew(buffer, datatype, offset) as U;
- }
-}
-
-export class StructValue<T extends StructWrapper = StructWrapper> extends Value<T> {
- // Parses and returns the value for the specified key.
- public get<
- U extends ValueType<T['struct'][number]['datatype']>,
- V extends StructItem = T['struct'][number],
- >(name: V['name']): U {
- const index = this.datatype.struct.findIndex((entry) => entry.name === name);
- if (index === -1) {
- throw new Error('No such field');
- }
-
- const datatype = this.datatype.struct[index].datatype;
-
- const slicedType = { struct: this.datatype.struct.slice(0, index) };
- const offset = Value.sizeOf(slicedType);
- const size = Value.sizeOf(datatype);
- const buffer = this.buffer.slice(offset, offset + size);
-
- return this.createNew(buffer, datatype, offset) as U;
- }
-}
-
-// Datatype specifications
-export const ARRAY = <T>(length: number, innerType: T) => ({
- array: Array<T>(length).fill(innerType),
-});
-export const USHORT = {
- primitive: { size: 2, reader: (buffer: Buffer) => buffer.readUInt16LE(0) },
-};
-export const LONG = {
- primitive: { size: 4, reader: (buffer: Buffer) => buffer.readUInt32LE(0) },
-};
-export const ULONGLONG = {
- primitive: {
- size: 8,
- reader: (_buffer: Buffer) => {
- throw new Error('Not implemented');
- },
- },
-};
-export const WCHAR = {
- primitive: { size: 2, reader: (buffer: Buffer) => buffer.readUInt16LE(0) },
-};
-export const WORD = {
- primitive: { size: 2, reader: (buffer: Buffer) => buffer.readUInt16LE(0) },
-};
-export const DWORD = {
- primitive: { size: 4, reader: (buffer: Buffer) => buffer.readUInt32LE(0) },
-};
-export const BYTE = {
- primitive: { size: 1, reader: (buffer: Buffer) => buffer.readInt8(0) },
-};
-export const UTF8_STRING = (length: number) => ({
- primitive: {
- size: length,
- reader: (_buffer: Buffer) => {
- throw new Error('Not implemented');
- },
- },
-});
-
-export const IMAGE_FILE_HEADER = {
- struct: [
- { name: 'Machine', datatype: WORD },
- { name: 'NumberOfSections', datatype: WORD },
- { name: 'TimeDateStamp', datatype: DWORD },
- { name: 'PointerToSymbolTable', datatype: DWORD },
- { name: 'NumberOfSymbols', datatype: DWORD },
- { name: 'SizeOfOptionalHeader', datatype: WORD },
- { name: 'Characteristics', datatype: WORD },
- ],
-};
-
-export const IMAGE_DATA_DIRECTORY_ENTRY = {
- struct: [
- { name: 'VirtualAddress', datatype: DWORD },
- { name: 'Size', datatype: DWORD },
- ],
-};
-
-export const IMAGE_DATA_DIRECTORY = ARRAY(16, IMAGE_DATA_DIRECTORY_ENTRY);
-
-export const IMAGE_OPTIONAL_HEADER32 = {
- struct: [
- { name: 'Magic', datatype: WORD },
- { name: 'MajorLinkerVersion', datatype: BYTE },
- { name: 'MinorLinkerVersion', datatype: BYTE },
- { name: 'SizeOfCode', datatype: DWORD },
- { name: 'SizeOfInitializedData', datatype: DWORD },
- { name: 'SizeOfUninitializedData', datatype: DWORD },
- { name: 'AddressOfEntryPoint', datatype: DWORD },
- { name: 'BaseOfCode', datatype: DWORD },
- { name: 'BaseOfData', datatype: DWORD },
- { name: 'ImageBase', datatype: DWORD },
- { name: 'SectionAlignment', datatype: DWORD },
- { name: 'FileAlignment', datatype: DWORD },
- { name: 'MajorOperatingSystemVersion', datatype: WORD },
- { name: 'MinorOperatingSystemVersion', datatype: WORD },
- { name: 'MajorImageVersion', datatype: WORD },
- { name: 'MinorImageVersion', datatype: WORD },
- { name: 'MajorSubsystemVersion', datatype: WORD },
- { name: 'MinorSubsystemVersion', datatype: WORD },
- { name: 'Win32VersionValue', datatype: DWORD },
- { name: 'SizeOfImage', datatype: DWORD },
- { name: 'SizeOfHeaders', datatype: DWORD },
- { name: 'CheckSum', datatype: DWORD },
- { name: 'Subsystem', datatype: WORD },
- { name: 'DllCharacteristics', datatype: WORD },
- { name: 'SizeOfStackReserve', datatype: DWORD },
- { name: 'SizeOfStackCommit', datatype: DWORD },
- { name: 'SizeOfHeapReserve', datatype: DWORD },
- { name: 'SizeOfHeapCommit', datatype: DWORD },
- { name: 'LoaderFlags', datatype: DWORD },
- { name: 'NumberOfRvaAndSizes', datatype: DWORD },
- { name: 'DataDirectory', datatype: IMAGE_DATA_DIRECTORY },
- ],
-};
-
-export const IMAGE_OPTIONAL_HEADER64 = {
- struct: [
- { name: 'Magic', datatype: WORD },
- { name: 'MajorLinkerVersion', datatype: BYTE },
- { name: 'MinorLinkerVersion', datatype: BYTE },
- { name: 'SizeOfCode', datatype: DWORD },
- { name: 'SizeOfInitializedData', datatype: DWORD },
- { name: 'SizeOfUninitializedData', datatype: DWORD },
- { name: 'AddressOfEntryPoint', datatype: DWORD },
- { name: 'BaseOfCode', datatype: DWORD },
- { name: 'ImageBase', datatype: ULONGLONG },
- { name: 'SectionAlignment', datatype: DWORD },
- { name: 'FileAlignment', datatype: DWORD },
- { name: 'MajorOperatingSystemVersion', datatype: WORD },
- { name: 'MinorOperatingSystemVersion', datatype: WORD },
- { name: 'MajorImageVersion', datatype: WORD },
- { name: 'MinorImageVersion', datatype: WORD },
- { name: 'MajorSubsystemVersion', datatype: WORD },
- { name: 'MinorSubsystemVersion', datatype: WORD },
- { name: 'Win32VersionValue', datatype: DWORD },
- { name: 'SizeOfImage', datatype: DWORD },
- { name: 'SizeOfHeaders', datatype: DWORD },
- { name: 'CheckSum', datatype: DWORD },
- { name: 'Subsystem', datatype: WORD },
- { name: 'DllCharacteristics', datatype: WORD },
- { name: 'SizeOfStackReserve', datatype: ULONGLONG },
- { name: 'SizeOfStackCommit', datatype: ULONGLONG },
- { name: 'SizeOfHeapReserve', datatype: ULONGLONG },
- { name: 'SizeOfHeapCommit', datatype: ULONGLONG },
- { name: 'LoaderFlags', datatype: DWORD },
- { name: 'NumberOfRvaAndSizes', datatype: DWORD },
- { name: 'DataDirectory', datatype: IMAGE_DATA_DIRECTORY },
- ],
-};
-
-export const IMAGE_NT_HEADERS = {
- struct: [
- { name: 'Signature', datatype: DWORD },
- { name: 'FileHeader', datatype: IMAGE_FILE_HEADER },
- { name: 'OptionalHeader', datatype: IMAGE_OPTIONAL_HEADER32 },
- ],
-};
-
-export const IMAGE_NT_HEADERS64 = {
- struct: [
- { name: 'Signature', datatype: DWORD },
- { name: 'FileHeader', datatype: IMAGE_FILE_HEADER },
- { name: 'OptionalHeader', datatype: IMAGE_OPTIONAL_HEADER64 },
- ],
-};
-
-export const DOS_HEADER = {
- struct: [
- { name: 'e_magic', datatype: USHORT },
- { name: 'e_cblp', datatype: USHORT },
- { name: 'e_cp', datatype: USHORT },
- { name: 'e_crlc', datatype: USHORT },
- { name: 'e_cparhdr', datatype: USHORT },
- { name: 'e_minalloc', datatype: USHORT },
- { name: 'e_maxalloc', datatype: USHORT },
- { name: 'e_ss', datatype: USHORT },
- { name: 'e_sp', datatype: USHORT },
- { name: 'e_csum', datatype: USHORT },
- { name: 'e_ip', datatype: USHORT },
- { name: 'e_cs', datatype: USHORT },
- { name: 'e_lfarlc', datatype: USHORT },
- { name: 'e_ovno', datatype: USHORT },
- { name: 'e_res', datatype: ARRAY(4, USHORT) },
- { name: 'e_oemid', datatype: USHORT },
- { name: 'e_oeminfo', datatype: USHORT },
- { name: 'e_res2', datatype: ARRAY(10, USHORT) },
- { name: 'e_lfanew', datatype: LONG },
- ],
-};
-
-export const IMAGE_SECTION_HEADER = {
- struct: [
- { name: 'Name', datatype: UTF8_STRING(8) },
- { name: 'PhysicalAddressVirtualSizeUnion', datatype: DWORD }, // TODO? Support unions?
- { name: 'VirtualAddress', datatype: DWORD },
- { name: 'SizeOfRawData', datatype: DWORD },
- { name: 'PointerToRawData', datatype: DWORD },
- { name: 'PointerToRelocations', datatype: DWORD },
- { name: 'PointerToLinenumbers', datatype: DWORD },
- { name: 'NumberOfRelocations', datatype: WORD },
- { name: 'NumberOfLinenumbers', datatype: WORD },
- { name: 'Characteristics', datatype: DWORD },
- ],
-};
-
-export const IMAGE_IMPORT_MODULE_DIRECTORY = {
- struct: [
- { name: 'ImportLookupTable', datatype: DWORD },
- { name: 'TimeDateStamp', datatype: DWORD },
- { name: 'ForwarderChain', datatype: DWORD },
- { name: 'ModuleName', datatype: DWORD },
- { name: 'ImportAddressTable', datatype: DWORD },
- ],
-};
-
-export const IMAGE_RESOURCE_DIRECTORY = {
- struct: [
- { name: 'Characteristics', datatype: DWORD },
- { name: 'TimeDateStamp', datatype: DWORD },
- { name: 'MajorVersion', datatype: WORD },
- { name: 'MinorVersion', datatype: WORD },
- { name: 'NumberOfNameEntries', datatype: WORD },
- { name: 'NumberOfIdEntries', datatype: WORD },
- ],
-};
-
-export const IMAGE_RESOURCE_DIRECTORY_ID_ENTRY = {
- struct: [
- { name: 'Id', datatype: DWORD },
- { name: 'OffsetToData', datatype: DWORD },
- ],
-};
-
-export const IMAGE_RESOURCE_DIRECTORY_DATA_ENTRY = {
- struct: [
- { name: 'DataRVA', datatype: DWORD },
- { name: 'Size', datatype: DWORD },
- { name: 'CodePage', datatype: DWORD },
- { name: '_Reserved', datatype: DWORD },
- ],
-};
-
-export const VS_FIXEDFILEINFO = {
- struct: [
- { name: 'dwSignature', datatype: DWORD },
- { name: 'dwStrucVersion', datatype: DWORD },
- { name: 'dwFileVersionMS', datatype: DWORD },
- { name: 'dwFileVersionLS', datatype: DWORD },
- { name: 'dwProductVersionMS', datatype: DWORD },
- { name: 'dwProductVersionLS', datatype: DWORD },
- { name: 'dwFileFlagsMask', datatype: DWORD },
- { name: 'dwFileFlags', datatype: DWORD },
- { name: 'dwFileOS', datatype: DWORD },
- { name: 'dwFileType', datatype: DWORD },
- { name: 'dwFileSubtype', datatype: DWORD },
- { name: 'dwFileDateMS', datatype: DWORD },
- { name: 'dwFileDateLS', datatype: DWORD },
- ],
-};
-
-// This structure can't be expressed with this format since Padding1 and Padding2 has a variable
-// size.
-export const VS_VERSIONINFO = {
- struct: [
- { name: 'wLength', datatype: WORD },
- { name: 'wValueLength', datatype: WORD },
- { name: 'wType', datatype: WORD },
- // { name: 'szKey', datatype: ARRAY(?, WCHAR) },
- // { name: 'Padding1', datatype: WORD },
- // { name: 'Value', datatype: VS_FIXEDFILEINFO },
- // { name: 'Padding2', datatype: WORD },
- // { name: 'Children', datatype: WORD },
- ],
-};
-
-// This structure can't be expressed with this format since Padding has a variable size.
-export const STRING_FILE_INFO = {
- struct: [
- { name: 'wLength', datatype: WORD },
- { name: 'wValueLength', datatype: WORD },
- { name: 'wType', datatype: WORD },
- // { name: 'szKey', datatype: ARRAY(?, WCHAR) },
- // { name: 'Padding', datatype: WORD },
- // { name: 'Children', datatype: WORD },
- ],
-};
-
-// This structure can't be expressed with this format since Padding has a variable size.
-export const STRING_TABLE = {
- struct: [
- { name: 'wLength', datatype: WORD },
- { name: 'wValueLength', datatype: WORD },
- { name: 'wType', datatype: WORD },
- // { name: 'szKey', datatype: ARRAY(?, WCHAR) },
- // { name: 'Padding', datatype: WORD },
- // { name: 'Children', datatype: WORD },
- ],
-};
-
-// This structure can't be expressed with this format since Padding has a variable size.
-export const STRING_TABLE_STRING = {
- struct: [
- { name: 'wLength', datatype: WORD },
- { name: 'wValueLength', datatype: WORD },
- { name: 'wType', datatype: WORD },
- // { name: 'szKey', datatype: ARRAY(?, WCHAR) },
- // { name: 'Padding', datatype: WORD },
- // { name: 'Value', datatype: WORD },
- ],
-};
-
-export const IMAGE_DIRECTORY_ENTRY_IMPORT = 1;
-export const IMAGE_DIRECTORY_ENTRY_RESOURCE = 2;
-
-export type ImageNtHeadersUnion = typeof IMAGE_NT_HEADERS | typeof IMAGE_NT_HEADERS64;
-export type ImageOptionalHeaderUnion =
- | typeof IMAGE_OPTIONAL_HEADER32
- | typeof IMAGE_OPTIONAL_HEADER64;
diff --git a/gui/src/main/windows-split-tunneling.ts b/gui/src/main/windows-split-tunneling.ts
deleted file mode 100644
index 7ae73827d0..0000000000
--- a/gui/src/main/windows-split-tunneling.ts
+++ /dev/null
@@ -1,680 +0,0 @@
-import { app, shell } from 'electron';
-import fs from 'fs';
-import path from 'path';
-
-import {
- ISplitTunnelingApplication,
- ISplitTunnelingAppListRetriever,
-} from '../shared/application-types';
-import log from '../shared/logging';
-import {
- ArrayValue,
- DOS_HEADER,
- DWORD,
- IMAGE_DATA_DIRECTORY,
- IMAGE_DIRECTORY_ENTRY_IMPORT,
- IMAGE_DIRECTORY_ENTRY_RESOURCE,
- IMAGE_FILE_HEADER,
- IMAGE_IMPORT_MODULE_DIRECTORY,
- IMAGE_NT_HEADERS,
- IMAGE_NT_HEADERS64,
- IMAGE_OPTIONAL_HEADER32,
- IMAGE_RESOURCE_DIRECTORY,
- IMAGE_RESOURCE_DIRECTORY_DATA_ENTRY,
- IMAGE_RESOURCE_DIRECTORY_ID_ENTRY,
- ImageNtHeadersUnion,
- ImageOptionalHeaderUnion,
- PrimitiveValue,
- rvaToOffset as rvaToOffsetImpl,
- STRING_FILE_INFO,
- STRING_TABLE,
- STRING_TABLE_STRING,
- StructValue,
- StructWrapper,
- Value,
- VS_VERSIONINFO,
-} from './windows-pe-parser';
-
-interface ShortcutDetails {
- target: string;
- name: string;
- args?: string;
- deletable: boolean;
-}
-
-type RvaToOffset = (rva: number) => Promise<number>;
-
-// Applications are found by scanning the start menu directories
-const APPLICATION_PATHS = [
- `${process.env.ProgramData}/Microsoft/Windows/Start Menu/Programs`,
- `${process.env.AppData}/Microsoft/Windows/Start Menu/Programs`,
-];
-
-// Some applications might be falsely filtered from the application list. This allow-list specifies
-// apps that are falsely filtered but should be included.
-const APPLICATION_ALLOW_LIST = [
- 'firefox.exe',
- 'chrome.exe',
- 'msedge.exe',
- 'brave.exe',
- 'iexplore.exe',
-];
-
-export class WindowsSplitTunnelingAppListRetriever implements ISplitTunnelingAppListRetriever {
- // Cache of all previously scanned shortcuts.
- private shortcutCache: Record<string, ShortcutDetails> = {};
- // Cache of all previously scanned applications.
- private applicationCache: Record<string, ISplitTunnelingApplication> = {};
- // List of shortcuts that have been added manually by the user.
- private additionalShortcuts: ShortcutDetails[] = [];
-
- // Finds applications by searching through the startmenu for shortcuts with and exe-file as
- // target.
- public async getApplications(
- updateCaches = false,
- ): Promise<{ fromCache: boolean; applications: ISplitTunnelingApplication[] }> {
- const cacheIsEmpty = Object.keys(this.shortcutCache).length === 0;
-
- const fromCache = !updateCaches && !cacheIsEmpty;
- if (!fromCache) {
- await this.updateShortcutCache();
- }
-
- await this.updateApplicationCache();
-
- return {
- fromCache,
- applications: Object.values(this.applicationCache),
- };
- }
-
- public async getMetadataForApplications(
- applicationPaths: string[],
- ): Promise<{ fromCache: boolean; applications: ISplitTunnelingApplication[] }> {
- // Add excluded apps that are missing from the shortcut cache to it
- await Promise.all(
- applicationPaths.map((applicationPath) =>
- this.addApplicationToAdditionalShortcuts(applicationPath),
- ),
- );
-
- const applications = await this.getApplications();
- // If applicationPaths is supplied the returnvalue should only contain the applications
- // corresponding to those paths.
- applications.applications = applications.applications.filter(
- (application) =>
- applicationPaths.find(
- (applicationPath) =>
- applicationPath.toLowerCase() === application.absolutepath.toLowerCase(),
- ) !== undefined,
- );
-
- return applications;
- }
-
- public resolveExecutablePath(providedPath: string): Promise<string> {
- if (path.extname(providedPath) === '.lnk') {
- return Promise.resolve(shell.readShortcutLink(path.resolve(providedPath)).target);
- }
-
- return Promise.resolve(providedPath);
- }
-
- // Adds either a shortcut or an executable to the additionalShortcuts list
- public async addApplicationPathToCache(applicationPath: string): Promise<void> {
- const parsedPath = path.parse(applicationPath);
- if (parsedPath.ext === '.lnk') {
- const shortcutDetiails = shell.readShortcutLink(path.resolve(applicationPath));
- this.additionalShortcuts.push({
- ...shortcutDetiails,
- name: path.parse(applicationPath).name,
- deletable: true,
- });
- } else {
- await this.addApplicationToAdditionalShortcuts(applicationPath);
- }
- }
-
- public removeApplicationFromCache(application: ISplitTunnelingApplication): void {
- this.additionalShortcuts = this.additionalShortcuts.filter(
- (shortcut) => shortcut.target !== application.absolutepath,
- );
- delete this.applicationCache[application.absolutepath.toLowerCase()];
- }
-
- // Reads the start-menu directories and adds all shortcuts, targeting applications using networking,
- // to the shortcuts cache. Whether or not an application use networking is determined by checking for
- // "WS2_32.dll" in it's imports.
- private async updateShortcutCache(): Promise<void> {
- const links = await Promise.all(
- APPLICATION_PATHS.map((applicationPath) => this.findAllLinks(applicationPath)),
- );
- const resolvedLinks = this.removeDuplicates(this.resolveLinks(links.flat()));
-
- const shortcuts: ShortcutDetails[] = [];
- for (const shortcut of resolvedLinks) {
- if (
- APPLICATION_ALLOW_LIST.includes(path.basename(shortcut.target.toLowerCase())) ||
- (await this.importsDll(shortcut.target, 'WS2_32.dll'))
- ) {
- shortcuts.push(shortcut);
- this.shortcutCache[shortcut.target.toLowerCase()] = shortcut;
- }
- }
- }
-
- private async updateApplicationCache(): Promise<void> {
- const shortcuts = Object.values(this.shortcutCache).concat(this.additionalShortcuts);
-
- await Promise.all(
- shortcuts.map(async (shortcut) => {
- const lowercaseTarget = shortcut.target.toLowerCase();
- if (this.applicationCache[lowercaseTarget] === undefined) {
- this.applicationCache[lowercaseTarget] =
- await this.convertToSplitTunnelingApplication(shortcut);
- }
-
- return this.applicationCache[lowercaseTarget];
- }),
- );
- }
-
- // Add excluded apps that are missing from the shortcut cache to it
- private async addApplicationToAdditionalShortcuts(applicationPath: string): Promise<void> {
- if (
- this.shortcutCache[applicationPath.toLowerCase()] === undefined &&
- !this.additionalShortcuts.some(
- (shortcut) => shortcut.target.toLowerCase() === applicationPath.toLowerCase(),
- )
- ) {
- this.additionalShortcuts.push({
- target: applicationPath,
- name: (await this.getProgramName(applicationPath)) ?? path.parse(applicationPath).name,
- deletable: true,
- });
- }
- }
-
- // Fins all links in a directory.
- private async findAllLinks(path: string): Promise<string[]> {
- if (path.endsWith('.lnk')) {
- return [path];
- } else {
- const stat = await fs.promises.stat(path);
- if (stat.isDirectory()) {
- const contents = await fs.promises.readdir(path);
- const result = await Promise.all(
- contents.map((item) => this.findAllLinks(`${path}/${item}`)),
- );
- return result.flat();
- } else {
- return [];
- }
- }
- }
-
- private resolveLinks(linkPaths: string[]): ShortcutDetails[] {
- return linkPaths
- .map((link) => {
- try {
- return {
- ...shell.readShortcutLink(path.resolve(link)),
- name: path.parse(link).name,
- };
- } catch {
- return null;
- }
- })
- .filter(
- (shortcut): shortcut is ShortcutDetails =>
- shortcut !== null &&
- !shortcut.target.endsWith('Mullvad VPN.exe') &&
- shortcut.target.endsWith('.exe') &&
- !shortcut.target.toLowerCase().includes('install') && // Covers "uninstall" as well.
- !shortcut.name.toLowerCase().includes('install'),
- );
- }
-
- private async getProgramName(exePath: string): Promise<string | undefined> {
- try {
- return await this.getProductName(exePath);
- } catch {
- return undefined;
- }
- }
-
- // Removes all duplicate shortcuts.
- private removeDuplicates(shortcuts: ShortcutDetails[]): ShortcutDetails[] {
- const unique = shortcuts.reduce(
- (shortcuts, shortcut) => {
- const lowercaseTarget = shortcut.target.toLowerCase();
- if (shortcuts[lowercaseTarget]) {
- if (
- shortcuts[lowercaseTarget].args &&
- shortcuts[lowercaseTarget].args !== '' &&
- (!shortcut.args || shortcut.args === '')
- ) {
- shortcuts[lowercaseTarget] = shortcut;
- }
- } else {
- shortcuts[lowercaseTarget] = shortcut;
- }
- return shortcuts;
- },
- {} as Record<string, ShortcutDetails>,
- );
-
- return Object.values(unique);
- }
-
- private async convertToSplitTunnelingApplication(
- shortcut: ShortcutDetails,
- ): Promise<ISplitTunnelingApplication> {
- return {
- absolutepath: shortcut.target,
- name: shortcut.name,
- icon: await this.retrieveIcon(shortcut.target),
- deletable: shortcut.deletable,
- };
- }
-
- private async retrieveIcon(exe: string) {
- const icon = await app.getFileIcon(exe, { size: 'large' });
- return icon.toDataURL();
- }
-
- // Checks if the application at the supplied path imports a specific dll.
- private async importsDll(path: string, dllName: string): Promise<boolean> {
- let fileHandle: fs.promises.FileHandle;
- try {
- fileHandle = await fs.promises.open(path, fs.constants.O_RDONLY);
- } catch {
- return false;
- }
-
- const imports = await this.getExeImports(fileHandle, path);
- await fileHandle.close();
- return imports.map((name) => name.toLowerCase()).includes(dllName.toLowerCase());
- }
-
- private async getExeImports(fileHandle: fs.promises.FileHandle, path: string): Promise<string[]> {
- try {
- const tableOffsetResult = await this.getTableOffset(fileHandle, IMAGE_DIRECTORY_ENTRY_IMPORT);
- if (tableOffsetResult) {
- const { offset: importTableOffset, rvaToOffset } = tableOffsetResult;
- const moduleNames = await this.getImportModuleNames(
- fileHandle,
- importTableOffset,
- rvaToOffset,
- );
- return moduleNames;
- } else {
- return [];
- }
- } catch (e) {
- log.error(`Failed to read .exe import table for ${path}.`, e);
- return [];
- }
- }
-
- private async readString(
- fileHandle: fs.promises.FileHandle,
- offset: number,
- encoding: 'ascii' | 'ucs2',
- ): Promise<{ value: string; endOffset: number }> {
- const characterSize = this.getCharacterSize(encoding);
- const buffer = Buffer.alloc(characterSize);
- await fileHandle.read(buffer, 0, characterSize, offset);
-
- const nextOffset = offset + characterSize;
- if (buffer.every((value) => value === 0)) {
- return { value: '', endOffset: nextOffset };
- } else {
- const { value: nextValue, endOffset } = await this.readString(
- fileHandle,
- nextOffset,
- encoding,
- );
- const value = buffer.toString(encoding) + nextValue;
- return { value, endOffset };
- }
- }
-
- private getCharacterSize(encoding: 'ascii' | 'ucs2'): number {
- switch (encoding) {
- case 'ascii':
- return 1;
- case 'ucs2':
- return 2;
- }
- }
-
- // Finds and returns the NT header.
- private async getNtHeader(
- fileHandle: fs.promises.FileHandle,
- ): Promise<StructValue<ImageNtHeadersUnion>> {
- // Check whether or not the file follows the PE format.
- const dosHeader = await Value.fromFile(fileHandle, 0, DOS_HEADER);
- const eMagic = dosHeader.get<PrimitiveValue>('e_magic').value();
- if (eMagic !== 0x5a4d) {
- throw new Error('Not a PE file');
- }
-
- const ntHeaderOffset = dosHeader.get<PrimitiveValue>('e_lfanew').value();
-
- // Check if this is a 32- or 64-bit exe-file and return the correct datatype.
- const ntHeader32 = await Value.fromFile(fileHandle, ntHeaderOffset, IMAGE_NT_HEADERS);
- const signature = ntHeader32.get<PrimitiveValue>('Signature').buffer.toString('ascii');
- if (signature !== 'PE\0\0') {
- throw new Error('Not a PE file');
- }
-
- const magic = ntHeader32
- .get<StructValue<typeof IMAGE_OPTIONAL_HEADER32>>('OptionalHeader')
- .get<PrimitiveValue>('Magic')
- .value();
-
- // magic is 0x20b for 64-bit executables.
- return magic === 0x20b
- ? Value.fromFile(fileHandle, ntHeaderOffset, IMAGE_NT_HEADERS64)
- : ntHeader32;
- }
-
- // Reads the import table and returns a list of the imported DLLs.
- private async getImportModuleNames(
- fileHandle: fs.promises.FileHandle,
- importTableOffset: number,
- rvaToOffset: RvaToOffset,
- ): Promise<string[]> {
- const moduleNames: string[] = [];
- const entrySize = Value.sizeOf(IMAGE_IMPORT_MODULE_DIRECTORY);
-
- // eslint-disable-next-line no-constant-condition
- for (let i = 0; true; i++) {
- const importEntry = await Value.fromFile(
- fileHandle,
- importTableOffset + i * entrySize,
- IMAGE_IMPORT_MODULE_DIRECTORY,
- );
- const nameRva = importEntry.get('ModuleName').value();
-
- if (nameRva !== 0x0) {
- const offset = await rvaToOffset(nameRva);
-
- const { value: name } = await this.readString(fileHandle, offset, 'ascii');
- moduleNames.push(name);
- } else {
- return moduleNames;
- }
- }
- }
-
- private async getProductName(path: string): Promise<string | undefined> {
- let fileHandle: fs.promises.FileHandle;
- try {
- fileHandle = await fs.promises.open(path, fs.constants.O_RDONLY);
- } catch {
- return undefined;
- }
-
- try {
- const getTableOffsetResult = await this.getTableOffset(
- fileHandle,
- IMAGE_DIRECTORY_ENTRY_RESOURCE,
- );
-
- if (getTableOffsetResult) {
- const { offset: resourceTableOffset, rvaToOffset } = getTableOffsetResult;
- const leafOffsets = await this.getResourceTreeLeafOffsets(
- fileHandle,
- resourceTableOffset,
- resourceTableOffset,
- rvaToOffset,
- [[16], [1], [0, 1033]],
- );
-
- const productName = await leafOffsets.reduce(
- async (alreadyFoundValue, leafOffset) => {
- const value = await alreadyFoundValue;
- if (value) {
- return value;
- } else {
- const strings = await this.getVsVersionInfoStrings(fileHandle, leafOffset);
- return strings.get('FileDescription') ?? strings.get('ProductName');
- }
- },
- Promise.resolve() as Promise<string | undefined>,
- );
-
- return productName;
- } else {
- return undefined;
- }
- } catch {
- return undefined;
- } finally {
- await fileHandle.close();
- }
- }
-
- private async getTableOffset(
- fileHandle: fs.promises.FileHandle,
- tableIndex: number,
- ): Promise<{ offset: number; rvaToOffset: RvaToOffset } | undefined> {
- const ntHeader = await this.getNtHeader(fileHandle);
- const fileHeader = ntHeader.get<StructValue<typeof IMAGE_FILE_HEADER>>('FileHeader');
- const optionalHeader = ntHeader.get<StructValue<ImageOptionalHeaderUnion>>('OptionalHeader');
-
- const tableRva = optionalHeader
- .get<ArrayValue<typeof IMAGE_DATA_DIRECTORY>>('DataDirectory')
- .nth(tableIndex)
- .get('VirtualAddress')
- .value();
-
- if (tableRva === 0x0) {
- return undefined;
- }
-
- const numberOfSections = fileHeader.get<PrimitiveValue>('NumberOfSections').value();
- const ntHeaderEndOffset =
- ntHeader.offset +
- ntHeader.get<PrimitiveValue<typeof DWORD>>('Signature').size +
- fileHeader.size +
- fileHeader.get<PrimitiveValue>('SizeOfOptionalHeader').value();
-
- const rvaToOffset = (rva: number) =>
- rvaToOffsetImpl(fileHandle, rva, numberOfSections, ntHeaderEndOffset);
-
- const tableOffset = await rvaToOffset(tableRva);
-
- return { offset: tableOffset, rvaToOffset };
- }
-
- // Searches the resource tree for the supplied paths and returns the leaves at the end of those
- // paths.
- private async getResourceTreeLeafOffsets(
- fileHandle: fs.promises.FileHandle,
- sectionOffset: number,
- tableOffset: number,
- rvaToOffset: (rva: number) => Promise<number>,
- [ids, ...path]: number[][],
- ): Promise<number[]> {
- const table = await Value.fromFile(fileHandle, tableOffset, IMAGE_RESOURCE_DIRECTORY);
-
- const numberOfNameEntries = table.get('NumberOfNameEntries').value();
- const numberOfIdEntries = table.get('NumberOfIdEntries').value();
-
- const leaves: number[] = [];
-
- for (let i = numberOfNameEntries; i < numberOfNameEntries + numberOfIdEntries; i++) {
- const offset =
- tableOffset +
- Value.sizeOf(IMAGE_RESOURCE_DIRECTORY) +
- i * Value.sizeOf(IMAGE_RESOURCE_DIRECTORY_ID_ENTRY);
- const entry = await Value.fromFile(fileHandle, offset, IMAGE_RESOURCE_DIRECTORY_ID_ENTRY);
-
- const id = entry.get('Id').value();
- if (!ids.includes(id)) {
- continue;
- }
-
- let offsetToData = entry.get('OffsetToData').value();
- // If the first bit is 1 then the offset points to another node, otherwise it point to a leaf.
- const isLeaf = (offsetToData & 0x80000000) === 0;
-
- if (isLeaf && path.length === 0) {
- const leafDataOffset = await this.getResourceTreeLeafValueOffset(
- fileHandle,
- sectionOffset + offsetToData,
- rvaToOffset,
- );
-
- leaves.push(leafDataOffset);
- } else if (!isLeaf) {
- offsetToData &= 0x7fffffff;
-
- const subTreeLeaves = await this.getResourceTreeLeafOffsets(
- fileHandle,
- sectionOffset,
- sectionOffset + offsetToData,
- rvaToOffset,
- path,
- );
-
- leaves.push(...subTreeLeaves);
- } else {
- continue;
- }
- }
-
- return leaves;
- }
-
- // Finds the Strings structures within the VS_VERSIONINFO structure and returns the contents.
- private async getVsVersionInfoStrings(
- fileHandle: fs.promises.FileHandle,
- offset: number,
- ): Promise<Map<string, string>> {
- try {
- const stringFileInfoOffset = await this.getVsVersionInfoChildrenOffset(fileHandle, offset);
-
- const stringTableOffset = await this.getChildrenOffset(
- fileHandle,
- stringFileInfoOffset,
- STRING_FILE_INFO,
- (szKey) => szKey === 'StringFileInfo',
- );
- const stringTable = await Value.fromFile(fileHandle, stringTableOffset, STRING_TABLE);
- const stringTableLength = stringTable.get<PrimitiveValue>('wLength').value();
-
- const stringsOffset = await this.getChildrenOffset(
- fileHandle,
- stringTableOffset,
- STRING_TABLE,
- (szKey) => szKey.substring(4).toLowerCase() === '04b0',
- );
-
- const strings = await this.parseStrings(
- fileHandle,
- stringsOffset,
- stringTableOffset + stringTableLength,
- );
-
- return strings;
- } catch {
- return new Map();
- }
- }
-
- // Loops through the list of strings and returns a map with the contents.
- private async parseStrings(
- fileHandle: fs.promises.FileHandle,
- stringsOffset: number,
- stringTableEnd: number,
- ): Promise<Map<string, string>> {
- const strings = new Map<string, string>();
-
- let currentStringOffset = stringsOffset;
- while (currentStringOffset < stringTableEnd) {
- const stringValue = await Value.fromFile(
- fileHandle,
- currentStringOffset,
- STRING_TABLE_STRING,
- );
- const structSize = stringValue.get('wLength').value();
- const valueSize = (stringValue.get('wValueLength').value() - 1) * 2;
-
- const szKeyOffset = currentStringOffset + stringValue.size;
- const { value: szKey, endOffset } = await this.readString(fileHandle, szKeyOffset, 'ucs2');
-
- const valueOffset = this.alignDword(endOffset);
- // Some programs specify the value size in bytes instead of words resulting in reading double
- // the length. To make sure we don't read beyond the end offset we calculate the max size to
- // read. The last value is the null termination character.
- const calculatedValueMaxSize = structSize - (valueOffset - currentStringOffset) - 2;
- const valueReadSize = Math.min(valueSize, calculatedValueMaxSize);
-
- const { buffer } = await fileHandle.read(
- Buffer.alloc(valueReadSize),
- 0,
- valueReadSize,
- valueOffset,
- );
- const value = buffer.toString('ucs2');
-
- strings.set(szKey, value);
- currentStringOffset += this.alignDword(stringValue.get<PrimitiveValue>('wLength').value());
- }
-
- return strings;
- }
-
- private async getResourceTreeLeafValueOffset(
- fileHandle: fs.promises.FileHandle,
- offset: number,
- rvaToOffset: (rva: number) => Promise<number>,
- ): Promise<number> {
- const leaf = await Value.fromFile(fileHandle, offset, IMAGE_RESOURCE_DIRECTORY_DATA_ENTRY);
- const valueRva = leaf.get<PrimitiveValue>('DataRVA').value();
- const valueOffset = await rvaToOffset(valueRva);
-
- return valueOffset;
- }
-
- // Finds the offset to the Children field in the VS_VERSIONINFO structure.
- private async getVsVersionInfoChildrenOffset(fileHandle: fs.promises.FileHandle, offset: number) {
- const valueValueOffset = await this.getChildrenOffset(
- fileHandle,
- offset,
- VS_VERSIONINFO,
- (szKey) => szKey === 'VS_VERSION_INFO',
- );
- const versionInfo = await Value.fromFile(fileHandle, offset, VS_VERSIONINFO);
- const versionInfoValueLength = versionInfo.get<PrimitiveValue>('wValueLength').value();
- const valuePadding2Offset = valueValueOffset + versionInfoValueLength;
- const valueChildrenOffset = this.alignDword(valuePadding2Offset);
-
- return valueChildrenOffset;
- }
-
- // Finds the offset to the Children field in any of the STRING_FILE_INFO, STRING_TABLE and
- // STRING_TABLE_STRING structures.
- private async getChildrenOffset(
- fileHandle: fs.promises.FileHandle,
- offset: number,
- datatype: StructWrapper,
- validateSzKey?: (szKey: string) => boolean,
- ) {
- const szKeyOffset = offset + Value.sizeOf(datatype);
- const { value, endOffset } = await this.readString(fileHandle, szKeyOffset, 'ucs2');
- if (validateSzKey && !validateSzKey(value)) {
- throw new Error(`Invalid szKey "${value}"`);
- }
-
- return this.alignDword(endOffset);
- }
-
- private alignDword(offset: number): number {
- return Math.ceil(offset / 4) * 4;
- }
-}
diff --git a/gui/src/renderer/app.tsx b/gui/src/renderer/app.tsx
deleted file mode 100644
index ddbb43aab7..0000000000
--- a/gui/src/renderer/app.tsx
+++ /dev/null
@@ -1,1090 +0,0 @@
-import { StrictMode } from 'react';
-import { batch, Provider } from 'react-redux';
-import { Router } from 'react-router';
-import { bindActionCreators } from 'redux';
-import { StyleSheetManager } from 'styled-components';
-
-import { closeToExpiry, hasExpired } from '../shared/account-expiry';
-import {
- ILinuxSplitTunnelingApplication,
- ISplitTunnelingApplication,
-} from '../shared/application-types';
-import {
- AccessMethodSetting,
- AccountNumber,
- BridgeSettings,
- BridgeState,
- CustomProxy,
- DeviceEvent,
- DeviceState,
- IAccountData,
- IAppVersionInfo,
- ICustomList,
- IDevice,
- IDeviceRemoval,
- IDnsOptions,
- ILocation,
- IRelayListWithEndpointData,
- ISettings,
- liftConstraint,
- NewAccessMethodSetting,
- ObfuscationSettings,
- RelaySettings,
- TunnelState,
-} from '../shared/daemon-rpc-types';
-import { messages, relayLocations } from '../shared/gettext';
-import { IGuiSettingsState, SYSTEM_PREFERRED_LOCALE_KEY } from '../shared/gui-settings-state';
-import { IChangelog, ICurrentAppVersionInfo, IHistoryObject } from '../shared/ipc-types';
-import log, { ConsoleOutput } from '../shared/logging';
-import { LogLevel } from '../shared/logging-types';
-import { Scheduler } from '../shared/scheduler';
-import AppRouter from './components/AppRouter';
-import { Changelog } from './components/Changelog';
-import ErrorBoundary from './components/ErrorBoundary';
-import KeyboardNavigation from './components/KeyboardNavigation';
-import Lang from './components/Lang';
-import MacOsScrollbarDetection from './components/MacOsScrollbarDetection';
-import { ModalContainer } from './components/Modal';
-import { AppContext } from './context';
-import History, { ITransitionSpecification, transitions } 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 connectionActions from './redux/connection/actions';
-import settingsActions from './redux/settings/actions';
-import configureStore from './redux/store';
-import userInterfaceActions from './redux/userinterface/actions';
-import versionActions from './redux/version/actions';
-
-const IpcRendererEventChannel = window.ipc;
-
-interface IPreferredLocaleDescriptor {
- name: string;
- code: string;
-}
-
-type LoginState = 'none' | 'logging in' | 'creating account' | 'too many devices';
-
-const SUPPORTED_LOCALE_LIST = [
- { name: 'Dansk', code: 'da' },
- { name: 'Deutsch', code: 'de' },
- { name: 'English', code: 'en' },
- { name: 'Español', code: 'es' },
- { name: 'Suomi', code: 'fi' },
- { name: 'Français', code: 'fr' },
- { name: 'Italiano', code: 'it' },
- { name: '日本語', code: 'ja' },
- { name: '한국어', code: 'ko' },
- { name: 'မြန်မာဘာသာ', code: 'my' },
- { name: 'Nederlands', code: 'nl' },
- { name: 'Norsk', code: 'nb' },
- { name: 'Polski', code: 'pl' },
- { name: 'Português', code: 'pt' },
- { name: 'Русский', code: 'ru' },
- { name: 'Svenska', code: 'sv' },
- { name: 'ภาษาไทย', code: 'th' },
- { name: 'Türkçe', code: 'tr' },
- { name: '简体中文', code: 'zh-CN' },
- { name: '繁體中文', code: 'zh-TW' },
-];
-
-export default class AppRenderer {
- private history: History;
- private reduxStore = configureStore();
- private reduxActions = {
- account: bindActionCreators(accountActions, this.reduxStore.dispatch),
- connection: bindActionCreators(connectionActions, this.reduxStore.dispatch),
- settings: bindActionCreators(settingsActions, this.reduxStore.dispatch),
- version: bindActionCreators(versionActions, this.reduxStore.dispatch),
- userInterface: bindActionCreators(userInterfaceActions, this.reduxStore.dispatch),
- };
-
- private location?: Partial<ILocation>;
- private relayList?: IRelayListWithEndpointData;
- private tunnelState!: TunnelState;
- private settings!: ISettings;
- private deviceState?: DeviceState;
- private loginState: LoginState = 'none';
- private previousLoginState: LoginState = 'none';
- private connectedToDaemon = false;
-
- private loginScheduler = new Scheduler();
- private expiryScheduler = new Scheduler();
-
- constructor() {
- log.addOutput(new ConsoleOutput(LogLevel.debug));
- log.addOutput(new IpcOutput(LogLevel.debug));
-
- IpcRendererEventChannel.window.listenShape((windowShapeParams) => {
- if (typeof windowShapeParams.arrowPosition === 'number') {
- this.reduxActions.userInterface.updateWindowArrowPosition(windowShapeParams.arrowPosition);
- }
- });
-
- IpcRendererEventChannel.daemon.listenConnected(() => {
- void this.onDaemonConnected();
- });
-
- IpcRendererEventChannel.daemon.listenDisconnected(() => {
- this.onDaemonDisconnected();
- });
-
- IpcRendererEventChannel.daemon.listenIsPerformingPostUpgrade((isPerformingPostUpgrade) => {
- this.setIsPerformingPostUpgrade(isPerformingPostUpgrade);
- });
-
- IpcRendererEventChannel.daemon.listenDaemonAllowed((daemonAllowed) => {
- this.reduxActions.userInterface.setDaemonAllowed(daemonAllowed);
- });
-
- IpcRendererEventChannel.account.listen((newAccountData?: IAccountData) => {
- this.setAccountExpiry(newAccountData?.expiry);
- });
-
- IpcRendererEventChannel.account.listenDevice((deviceEvent) => {
- this.handleDeviceEvent(deviceEvent);
- });
-
- IpcRendererEventChannel.account.listenDevices((devices) => {
- this.reduxActions.account.updateDevices(devices);
- });
-
- IpcRendererEventChannel.accountHistory.listen((newAccountHistory?: AccountNumber) => {
- this.setAccountHistory(newAccountHistory);
- });
-
- IpcRendererEventChannel.tunnel.listen((newState: TunnelState) => {
- this.setTunnelState(newState);
- this.updateBlockedState(newState, this.settings.blockWhenDisconnected);
- });
-
- IpcRendererEventChannel.settings.listen((newSettings: ISettings) => {
- this.setSettings(newSettings);
- this.updateBlockedState(this.tunnelState, newSettings.blockWhenDisconnected);
- });
-
- IpcRendererEventChannel.settings.listenApiAccessMethodSettingChange((setting) => {
- this.setCurrentApiAccessMethod(setting);
- });
-
- IpcRendererEventChannel.relays.listen((relayListPair: IRelayListWithEndpointData) => {
- this.setRelayListPair(relayListPair);
- });
-
- IpcRendererEventChannel.currentVersion.listen((currentVersion: ICurrentAppVersionInfo) => {
- this.setCurrentVersion(currentVersion);
- });
-
- IpcRendererEventChannel.upgradeVersion.listen((upgradeVersion: IAppVersionInfo) => {
- this.setUpgradeVersion(upgradeVersion);
- });
-
- IpcRendererEventChannel.guiSettings.listen((guiSettings: IGuiSettingsState) => {
- this.setGuiSettings(guiSettings);
- });
-
- IpcRendererEventChannel.autoStart.listen((autoStart: boolean) => {
- this.storeAutoStart(autoStart);
- });
-
- IpcRendererEventChannel.splitTunneling.listen((applications: ISplitTunnelingApplication[]) => {
- this.reduxActions.settings.setSplitTunnelingApplications(applications);
- });
-
- IpcRendererEventChannel.window.listenFocus((focus: boolean) => {
- this.reduxActions.userInterface.setWindowFocused(focus);
- });
-
- IpcRendererEventChannel.window.listenMacOsScrollbarVisibility((visibility) => {
- this.reduxActions.userInterface.setMacOsScrollbarVisibility(visibility);
- });
-
- IpcRendererEventChannel.navigation.listenReset(() => this.history.pop(true));
-
- // Request the initial state from the main process
- const initialState = IpcRendererEventChannel.state.get();
-
- this.setLocale(initialState.translations.locale);
- loadTranslations(
- messages,
- initialState.translations.locale,
- initialState.translations.messages,
- );
- loadTranslations(
- relayLocations,
- initialState.translations.locale,
- initialState.translations.relayLocations,
- );
-
- this.setSettings(initialState.settings);
- this.setIsPerformingPostUpgrade(initialState.isPerformingPostUpgrade);
-
- if (initialState.daemonAllowed !== undefined) {
- this.reduxActions.userInterface.setDaemonAllowed(initialState.daemonAllowed);
- }
-
- if (initialState.deviceState) {
- const deviceState = initialState.deviceState;
- this.handleDeviceEvent(
- { type: deviceState.type, deviceState } as DeviceEvent,
- initialState.navigationHistory !== undefined,
- );
- }
- // Login state and account needs to be set before expiry.
- this.setAccountExpiry(initialState.accountData?.expiry);
-
- this.setAccountHistory(initialState.accountHistory);
- this.setTunnelState(initialState.tunnelState);
- this.updateBlockedState(initialState.tunnelState, initialState.settings.blockWhenDisconnected);
-
- this.setRelayListPair(initialState.relayList);
- this.setCurrentVersion(initialState.currentVersion);
- this.setUpgradeVersion(initialState.upgradeVersion);
- this.setGuiSettings(initialState.guiSettings);
- this.storeAutoStart(initialState.autoStart);
- this.setChangelog(initialState.changelog, initialState.forceShowChanges);
- this.setCurrentApiAccessMethod(initialState.currentApiAccessMethod);
- this.reduxActions.userInterface.setIsMacOs13OrNewer(initialState.isMacOs13OrNewer);
-
- if (initialState.macOsScrollbarVisibility !== undefined) {
- this.reduxActions.userInterface.setMacOsScrollbarVisibility(
- initialState.macOsScrollbarVisibility,
- );
- }
-
- if (initialState.isConnected) {
- void this.onDaemonConnected();
- }
-
- this.checkContentHeight(false);
- window.addEventListener('resize', () => {
- this.checkContentHeight(true);
- });
-
- if (initialState.splitTunnelingApplications) {
- this.reduxActions.settings.setSplitTunnelingApplications(
- initialState.splitTunnelingApplications,
- );
- }
-
- this.updateLocation();
-
- if (initialState.navigationHistory) {
- // Set last action to POP to trigger automatic scrolling to saved coordinates.
- initialState.navigationHistory.lastAction = 'POP';
- this.history = History.fromSavedHistory(initialState.navigationHistory);
- } else {
- const navigationBase = this.getNavigationBase();
- this.history = new History(navigationBase);
- }
-
- if (window.env.e2e) {
- // Make the current location available to the tests if running e2e tests
- window.e2e = { location: this.history.location.pathname };
- }
- }
-
- public renderView() {
- return (
- <StrictMode>
- <AppContext.Provider value={{ app: this }}>
- <Provider store={this.reduxStore}>
- <StyleSheetManager enableVendorPrefixes>
- <Lang>
- <Router history={this.history.asHistory}>
- <ErrorBoundary>
- <ModalContainer>
- <KeyboardNavigation>
- <AppRouter />
- <Changelog />
- </KeyboardNavigation>
- {window.env.platform === 'darwin' && <MacOsScrollbarDetection />}
- </ModalContainer>
- </ErrorBoundary>
- </Router>
- </Lang>
- </StyleSheetManager>
- </Provider>
- </AppContext.Provider>
- </StrictMode>
- );
- }
-
- public submitVoucher = (code: string) => IpcRendererEventChannel.account.submitVoucher(code);
- public updateAccountData = () => IpcRendererEventChannel.account.updateData();
- public removeDevice = (device: IDeviceRemoval) =>
- IpcRendererEventChannel.account.removeDevice(device);
- public connectTunnel = () => IpcRendererEventChannel.tunnel.connect();
- public disconnectTunnel = () => IpcRendererEventChannel.tunnel.disconnect();
- public reconnectTunnel = () => IpcRendererEventChannel.tunnel.reconnect();
- public setRelaySettings = (relaySettings: RelaySettings) =>
- IpcRendererEventChannel.settings.setRelaySettings(relaySettings);
- public updateBridgeSettings = (bridgeSettings: BridgeSettings) =>
- IpcRendererEventChannel.settings.updateBridgeSettings(bridgeSettings);
- public setDnsOptions = (dnsOptions: IDnsOptions) =>
- IpcRendererEventChannel.settings.setDnsOptions(dnsOptions);
- public clearAccountHistory = () => IpcRendererEventChannel.accountHistory.clear();
- public setAutoConnect = (value: boolean) =>
- IpcRendererEventChannel.guiSettings.setAutoConnect(value);
- public setEnableSystemNotifications = (value: boolean) =>
- IpcRendererEventChannel.guiSettings.setEnableSystemNotifications(value);
- public setStartMinimized = (value: boolean) =>
- IpcRendererEventChannel.guiSettings.setStartMinimized(value);
- public setMonochromaticIcon = (value: boolean) =>
- IpcRendererEventChannel.guiSettings.setMonochromaticIcon(value);
- public setUnpinnedWindow = (value: boolean) =>
- IpcRendererEventChannel.guiSettings.setUnpinnedWindow(value);
- public getLinuxSplitTunnelingApplications = () =>
- IpcRendererEventChannel.linuxSplitTunneling.getApplications();
- public launchExcludedApplication = (application: ILinuxSplitTunnelingApplication | string) =>
- IpcRendererEventChannel.linuxSplitTunneling.launchApplication(application);
- public setSplitTunnelingState = (state: boolean) =>
- IpcRendererEventChannel.splitTunneling.setState(state);
- public addSplitTunnelingApplication = (application: string | ISplitTunnelingApplication) =>
- IpcRendererEventChannel.splitTunneling.addApplication(application);
- public forgetManuallyAddedSplitTunnelingApplication = (application: ISplitTunnelingApplication) =>
- IpcRendererEventChannel.splitTunneling.forgetManuallyAddedApplication(application);
- public needFullDiskPermissions = () =>
- IpcRendererEventChannel.macOsSplitTunneling.needFullDiskPermissions();
- public setObfuscationSettings = (obfuscationSettings: ObfuscationSettings) =>
- IpcRendererEventChannel.settings.setObfuscationSettings(obfuscationSettings);
- public setEnableDaita = (value: boolean) =>
- IpcRendererEventChannel.settings.setEnableDaita(value);
- public setDaitaDirectOnly = (value: boolean) =>
- IpcRendererEventChannel.settings.setDaitaDirectOnly(value);
- public collectProblemReport = (toRedact: string | undefined) =>
- IpcRendererEventChannel.problemReport.collectLogs(toRedact);
- public viewLog = (path: string) => IpcRendererEventChannel.problemReport.viewLog(path);
- public quit = () => IpcRendererEventChannel.app.quit();
- public openUrl = (url: string) => IpcRendererEventChannel.app.openUrl(url);
- public getPathBaseName = (path: string) => IpcRendererEventChannel.app.getPathBaseName(path);
- public showOpenDialog = (options: Electron.OpenDialogOptions) =>
- IpcRendererEventChannel.app.showOpenDialog(options);
- public createCustomList = (name: string) =>
- IpcRendererEventChannel.customLists.createCustomList(name);
- public deleteCustomList = (id: string) =>
- IpcRendererEventChannel.customLists.deleteCustomList(id);
- public updateCustomList = (customList: ICustomList) =>
- IpcRendererEventChannel.customLists.updateCustomList(customList);
- public addApiAccessMethod = (method: NewAccessMethodSetting) =>
- IpcRendererEventChannel.settings.addApiAccessMethod(method);
- public updateApiAccessMethod = (method: AccessMethodSetting) =>
- IpcRendererEventChannel.settings.updateApiAccessMethod(method);
- public removeApiAccessMethod = (id: string) =>
- IpcRendererEventChannel.settings.removeApiAccessMethod(id);
- public setApiAccessMethod = (id: string) =>
- IpcRendererEventChannel.settings.setApiAccessMethod(id);
- public testApiAccessMethodById = (id: string) =>
- IpcRendererEventChannel.settings.testApiAccessMethodById(id);
- public testCustomApiAccessMethod = (method: CustomProxy) =>
- IpcRendererEventChannel.settings.testCustomApiAccessMethod(method);
- public importSettingsFile = (path: string) => IpcRendererEventChannel.settings.importFile(path);
- public importSettingsText = (text: string) => IpcRendererEventChannel.settings.importText(text);
- public clearAllRelayOverrides = () => IpcRendererEventChannel.settings.clearAllRelayOverrides();
- public getMapData = () => IpcRendererEventChannel.map.getData();
- public setAnimateMap = (displayMap: boolean): void =>
- IpcRendererEventChannel.guiSettings.setAnimateMap(displayMap);
-
- public login = async (accountNumber: AccountNumber) => {
- const actions = this.reduxActions;
- actions.account.startLogin(accountNumber);
-
- log.info('Logging in');
-
- this.previousLoginState = this.loginState;
- this.loginState = 'logging in';
-
- const response = await IpcRendererEventChannel.account.login(accountNumber);
- if (response?.type === 'error') {
- if (response.error === 'too-many-devices') {
- try {
- await this.fetchDevices(accountNumber);
-
- actions.account.loginTooManyDevices();
- this.loginState = 'too many devices';
-
- this.history.reset(RoutePath.tooManyDevices, { transition: transitions.push });
- } catch {
- log.error('Failed to fetch device list');
- actions.account.loginFailed('list-devices');
- }
- } else {
- actions.account.loginFailed(response.error);
- }
- }
- };
-
- public cancelLogin = (): void => {
- const reduxAccount = this.reduxActions.account;
- reduxAccount.loggedOut();
- this.loginState = 'none';
- };
-
- public logout = async (transition = transitions.dismiss) => {
- try {
- this.history.reset(RoutePath.login, { transition });
- await IpcRendererEventChannel.account.logout();
- } catch (e) {
- const error = e as Error;
- log.info('Failed to logout: ', error.message);
- }
- };
-
- public leaveRevokedDevice = async () => {
- await this.logout(transitions.pop);
- await this.disconnectTunnel();
- };
-
- public createNewAccount = async () => {
- log.info('Creating account');
-
- const actions = this.reduxActions;
- actions.account.startCreateAccount();
- this.loginState = 'creating account';
-
- try {
- await IpcRendererEventChannel.account.create();
- this.redirectToConnect();
- } catch (e) {
- const error = e as Error;
- actions.account.createAccountFailed(error);
- }
- };
-
- public fetchDevices = async (accountNumber: AccountNumber): Promise<Array<IDevice>> => {
- const devices = await IpcRendererEventChannel.account.listDevices(accountNumber);
- this.reduxActions.account.updateDevices(devices);
- return devices;
- };
-
- public openLinkWithAuth = async (link: string): Promise<void> => {
- let token = '';
- try {
- token = await IpcRendererEventChannel.account.getWwwAuthToken();
- } catch (e) {
- const error = e as Error;
- log.error(`Failed to get the WWW auth token: ${error.message}`);
- }
- void this.openUrl(`${link}?token=${token}`);
- };
-
- public setAllowLan = async (allowLan: boolean) => {
- const actions = this.reduxActions;
- await IpcRendererEventChannel.settings.setAllowLan(allowLan);
- actions.settings.updateAllowLan(allowLan);
- };
-
- public setShowBetaReleases = async (showBetaReleases: boolean) => {
- const actions = this.reduxActions;
- await IpcRendererEventChannel.settings.setShowBetaReleases(showBetaReleases);
- actions.settings.updateShowBetaReleases(showBetaReleases);
- };
-
- public setEnableIpv6 = async (enableIpv6: boolean) => {
- const actions = this.reduxActions;
- await IpcRendererEventChannel.settings.setEnableIpv6(enableIpv6);
- actions.settings.updateEnableIpv6(enableIpv6);
- };
-
- public setBridgeState = async (bridgeState: BridgeState) => {
- const actions = this.reduxActions;
- await IpcRendererEventChannel.settings.setBridgeState(bridgeState);
- actions.settings.updateBridgeState(bridgeState);
- };
-
- public setBlockWhenDisconnected = async (blockWhenDisconnected: boolean) => {
- const actions = this.reduxActions;
- await IpcRendererEventChannel.settings.setBlockWhenDisconnected(blockWhenDisconnected);
- actions.settings.updateBlockWhenDisconnected(blockWhenDisconnected);
- };
-
- public setOpenVpnMssfix = async (mssfix?: number) => {
- const actions = this.reduxActions;
- actions.settings.updateOpenVpnMssfix(mssfix);
- await IpcRendererEventChannel.settings.setOpenVpnMssfix(mssfix);
- };
-
- public setWireguardMtu = async (mtu?: number) => {
- const actions = this.reduxActions;
- actions.settings.updateWireguardMtu(mtu);
- await IpcRendererEventChannel.settings.setWireguardMtu(mtu);
- };
-
- public setWireguardQuantumResistant = async (quantumResistant?: boolean) => {
- const actions = this.reduxActions;
- actions.settings.updateWireguardQuantumResistant(quantumResistant);
- await IpcRendererEventChannel.settings.setWireguardQuantumResistant(quantumResistant);
- };
-
- public setAutoStart = (autoStart: boolean): Promise<void> => {
- this.storeAutoStart(autoStart);
-
- return IpcRendererEventChannel.autoStart.set(autoStart);
- };
-
- public getSplitTunnelingApplications(updateCache = false) {
- return IpcRendererEventChannel.splitTunneling.getApplications(updateCache);
- }
-
- public removeSplitTunnelingApplication(application: ISplitTunnelingApplication) {
- void IpcRendererEventChannel.splitTunneling.removeApplication(application);
- }
-
- public async showLaunchDaemonSettings() {
- await IpcRendererEventChannel.app.showLaunchDaemonSettings();
- }
-
- public showFullDiskAccessSettings = async () => {
- await IpcRendererEventChannel.app.showFullDiskAccessSettings();
- };
-
- public async sendProblemReport(
- email: string,
- message: string,
- savedReportId: string,
- ): Promise<void> {
- await IpcRendererEventChannel.problemReport.sendReport({ email, message, savedReportId });
- }
-
- public getPreferredLocaleList(): IPreferredLocaleDescriptor[] {
- return [
- {
- // TRANSLATORS: The option that represents the active operating system language in the
- // TRANSLATORS: user interface language selection list.
- name: messages.gettext('System default'),
- code: SYSTEM_PREFERRED_LOCALE_KEY,
- },
- ...SUPPORTED_LOCALE_LIST.sort((a, b) => a.name.localeCompare(b.name)),
- ];
- }
-
- public setPreferredLocale = async (preferredLocale: string): Promise<void> => {
- const translations =
- await IpcRendererEventChannel.guiSettings.setPreferredLocale(preferredLocale);
-
- // set current locale
- this.setLocale(translations.locale);
-
- // load translations for new locale
- loadTranslations(messages, translations.locale, translations.messages);
- loadTranslations(relayLocations, translations.locale, translations.relayLocations);
- };
-
- public getPreferredLocaleDisplayName = (localeCode: string): string => {
- const preferredLocale = this.getPreferredLocaleList().find((item) => item.code === localeCode);
-
- return preferredLocale ? preferredLocale.name : '';
- };
-
- public setDisplayedChangelog = (): void => {
- IpcRendererEventChannel.currentVersion.displayedChangelog();
- };
-
- public setNavigationHistory(history: IHistoryObject) {
- IpcRendererEventChannel.navigation.setHistory(history);
-
- if (window.env.e2e) {
- window.e2e.location = history.entries[history.index].pathname;
- }
- }
-
- private isLoggedIn(): boolean {
- return this.deviceState?.type === 'logged in';
- }
-
- // Make sure that the content height is correct and log if it isn't. This is mostly for debugging
- // purposes since there's a bug in Electron that causes the app height to be another value than
- // the one we have set.
- // https://github.com/electron/electron/issues/28777
- private checkContentHeight(resize: boolean): void {
- const expectedContentHeight = 568;
- const contentHeight = window.innerHeight;
- if (contentHeight !== expectedContentHeight) {
- log.verbose(
- resize ? 'Resize:' : 'Initial:',
- `Wrong content height: ${contentHeight}, expected ${expectedContentHeight}`,
- );
- }
- }
-
- private redirectToConnect() {
- // Redirect the user after some time to allow for the 'Logged in' screen to be visible
- this.loginScheduler.schedule(() => this.resetNavigation(), 1000);
- }
-
- private setLocale(locale: string) {
- this.reduxActions.userInterface.updateLocale(locale);
- }
-
- private setReduxRelaySettings(relaySettings: RelaySettings) {
- const actions = this.reduxActions;
-
- if ('normal' in relaySettings) {
- const {
- location,
- openvpnConstraints,
- wireguardConstraints,
- tunnelProtocol,
- providers,
- ownership,
- } = relaySettings.normal;
-
- actions.settings.updateRelay({
- normal: {
- location: liftConstraint(location),
- providers,
- ownership,
- openvpn: {
- port: liftConstraint(openvpnConstraints.port),
- protocol: liftConstraint(openvpnConstraints.protocol),
- },
- wireguard: {
- port: liftConstraint(wireguardConstraints.port),
- ipVersion: liftConstraint(wireguardConstraints.ipVersion),
- useMultihop: wireguardConstraints.useMultihop,
- entryLocation: liftConstraint(wireguardConstraints.entryLocation),
- },
- tunnelProtocol: liftConstraint(tunnelProtocol),
- },
- });
- } else if ('customTunnelEndpoint' in relaySettings) {
- const customTunnelEndpoint = relaySettings.customTunnelEndpoint;
- const config = customTunnelEndpoint.config;
-
- if ('openvpn' in config) {
- actions.settings.updateRelay({
- customTunnelEndpoint: {
- host: customTunnelEndpoint.host,
- port: config.openvpn.endpoint.port,
- protocol: config.openvpn.endpoint.protocol,
- },
- });
- } else if ('wireguard' in config) {
- // TODO: handle wireguard
- }
- }
- }
-
- private setBridgeSettings(bridgeSettings: BridgeSettings) {
- const actions = this.reduxActions;
-
- actions.settings.updateBridgeSettings({
- type: bridgeSettings.type,
- normal: {
- location: liftConstraint(bridgeSettings.normal.location),
- providers: bridgeSettings.normal.providers,
- ownership: bridgeSettings.normal.ownership,
- },
- custom: bridgeSettings.custom,
- });
- }
-
- private onDaemonConnected() {
- this.connectedToDaemon = true;
- this.reduxActions.userInterface.setConnectedToDaemon(true);
- this.reduxActions.userInterface.setDaemonAllowed(true);
- this.resetNavigation();
- }
-
- private onDaemonDisconnected() {
- this.connectedToDaemon = false;
- this.reduxActions.userInterface.setConnectedToDaemon(false);
- this.resetNavigation();
- }
-
- private resetNavigation(replaceRoot?: boolean) {
- if (this.history) {
- const pathname = this.history.location.pathname as RoutePath;
- const nextPath = this.getNavigationBase() as RoutePath;
-
- if (pathname !== nextPath) {
- const transition = this.getNavigationTransition(pathname, nextPath);
- if (replaceRoot) {
- this.history.replaceRoot(nextPath, { transition });
- } else {
- this.history.reset(nextPath, { transition });
- }
- }
- }
- }
-
- private getNavigationTransition(prevPath: RoutePath, nextPath: RoutePath) {
- // First level contains the possible next locations and the second level contains the
- // possible current locations.
- const navigationTransitions: Partial<
- Record<RoutePath, Partial<Record<RoutePath | '*', ITransitionSpecification>>>
- > = {
- [RoutePath.launch]: {
- [RoutePath.login]: transitions.pop,
- [RoutePath.main]: transitions.pop,
- '*': transitions.dismiss,
- },
- [RoutePath.login]: {
- [RoutePath.launch]: transitions.push,
- [RoutePath.main]: transitions.pop,
- [RoutePath.deviceRevoked]: transitions.pop,
- '*': transitions.dismiss,
- },
- [RoutePath.main]: {
- [RoutePath.launch]: transitions.push,
- [RoutePath.login]: transitions.push,
- [RoutePath.tooManyDevices]: transitions.push,
- '*': transitions.dismiss,
- },
- [RoutePath.expired]: {
- [RoutePath.launch]: transitions.push,
- [RoutePath.login]: transitions.push,
- [RoutePath.tooManyDevices]: transitions.push,
- '*': transitions.dismiss,
- },
- [RoutePath.timeAdded]: {
- [RoutePath.expired]: transitions.push,
- [RoutePath.redeemVoucher]: transitions.push,
- '*': transitions.dismiss,
- },
- [RoutePath.deviceRevoked]: {
- '*': transitions.pop,
- },
- };
-
- return navigationTransitions[nextPath]?.[prevPath] ?? navigationTransitions[nextPath]?.['*'];
- }
-
- private getNavigationBase(): RoutePath {
- if (this.connectedToDaemon && this.deviceState !== undefined) {
- const loginState = this.reduxStore.getState().account.status;
- const deviceRevoked = loginState.type === 'none' && loginState.deviceRevoked;
-
- if (deviceRevoked) {
- return RoutePath.deviceRevoked;
- } else if (!this.isLoggedIn()) {
- return RoutePath.login;
- } else if (
- loginState.type === 'ok' &&
- (loginState.expiredState === 'expired' || loginState.method === 'new_account')
- ) {
- return RoutePath.expired;
- } else if (loginState.type === 'ok' && loginState.expiredState === 'time_added') {
- return RoutePath.timeAdded;
- } else {
- return RoutePath.main;
- }
- } else {
- return RoutePath.launch;
- }
- }
-
- private setAccountHistory(accountHistory?: AccountNumber) {
- this.reduxActions.account.updateAccountHistory(accountHistory);
- }
-
- private setTunnelState(tunnelState: TunnelState) {
- const actions = this.reduxActions;
-
- log.verbose(`Tunnel state: ${tunnelState.state}`);
-
- this.tunnelState = tunnelState;
-
- batch(() => {
- switch (tunnelState.state) {
- case 'connecting':
- actions.connection.connecting(tunnelState.details, tunnelState.featureIndicators);
- break;
-
- case 'connected':
- actions.connection.connected(tunnelState.details, tunnelState.featureIndicators);
- break;
-
- case 'disconnecting':
- actions.connection.disconnecting(tunnelState.details);
- break;
-
- case 'disconnected':
- actions.connection.disconnected();
- break;
-
- case 'error':
- actions.connection.blocked(tunnelState.details);
- break;
- }
-
- // Update the location when entering a new tunnel state since it's likely changed.
- this.updateLocation();
- });
- }
-
- private setSettings(newSettings: ISettings) {
- this.settings = newSettings;
-
- const reduxSettings = this.reduxActions.settings;
-
- reduxSettings.updateAllowLan(newSettings.allowLan);
- reduxSettings.updateEnableIpv6(newSettings.tunnelOptions.generic.enableIpv6);
- reduxSettings.updateBlockWhenDisconnected(newSettings.blockWhenDisconnected);
- reduxSettings.updateShowBetaReleases(newSettings.showBetaReleases);
- reduxSettings.updateOpenVpnMssfix(newSettings.tunnelOptions.openvpn.mssfix);
- reduxSettings.updateWireguardMtu(newSettings.tunnelOptions.wireguard.mtu);
- reduxSettings.updateWireguardQuantumResistant(
- newSettings.tunnelOptions.wireguard.quantumResistant,
- );
- reduxSettings.updateWireguardDaita(newSettings.tunnelOptions.wireguard.daita);
- reduxSettings.updateBridgeState(newSettings.bridgeState);
- reduxSettings.updateDnsOptions(newSettings.tunnelOptions.dns);
- reduxSettings.updateSplitTunnelingState(newSettings.splitTunnel.enableExclusions);
- reduxSettings.updateObfuscationSettings(newSettings.obfuscationSettings);
- reduxSettings.updateCustomLists(newSettings.customLists);
- reduxSettings.updateApiAccessMethods(newSettings.apiAccessMethods);
- reduxSettings.updateRelayOverrides(newSettings.relayOverrides);
-
- this.setReduxRelaySettings(newSettings.relaySettings);
- this.setBridgeSettings(newSettings.bridgeSettings);
- }
-
- private setIsPerformingPostUpgrade(isPerformingPostUpgrade: boolean) {
- this.reduxActions.userInterface.setIsPerformingPostUpgrade(isPerformingPostUpgrade);
- }
-
- private updateBlockedState(tunnelState: TunnelState, blockWhenDisconnected: boolean) {
- const actions = this.reduxActions.connection;
- switch (tunnelState.state) {
- case 'connecting':
- actions.updateBlockState(true);
- break;
-
- case 'connected':
- actions.updateBlockState(false);
- break;
-
- case 'disconnected':
- actions.updateBlockState(blockWhenDisconnected);
- break;
-
- case 'disconnecting':
- actions.updateBlockState(true);
- break;
-
- case 'error':
- actions.updateBlockState(!tunnelState.details.blockingError);
- break;
- }
- }
-
- private handleDeviceEvent(deviceEvent: DeviceEvent, preventRedirectToConnect?: boolean) {
- const reduxAccount = this.reduxActions.account;
-
- this.deviceState = deviceEvent.deviceState;
-
- switch (deviceEvent.type) {
- case 'logged in': {
- const accountNumber = deviceEvent.deviceState.accountAndDevice.accountNumber;
- const device = deviceEvent.deviceState.accountAndDevice.device;
-
- switch (this.loginState) {
- case 'none':
- reduxAccount.loggedIn(accountNumber, device);
- this.resetNavigation();
- break;
- case 'logging in':
- reduxAccount.loggedIn(accountNumber, device);
-
- if (this.previousLoginState === 'too many devices') {
- this.resetNavigation();
- } else if (!preventRedirectToConnect) {
- this.redirectToConnect();
- }
- break;
- case 'creating account':
- reduxAccount.accountCreated(accountNumber, device, new Date().toISOString());
- break;
- }
- break;
- }
- case 'logged out':
- this.loginScheduler.cancel();
- reduxAccount.loggedOut();
- this.resetNavigation();
- break;
- case 'revoked': {
- this.loginScheduler.cancel();
- reduxAccount.deviceRevoked();
- this.resetNavigation();
- break;
- }
- }
-
- this.previousLoginState = this.loginState;
- this.loginState = 'none';
- }
-
- private setLocation(location: Partial<ILocation>) {
- this.location = location;
- this.propagateLocationToRedux();
- }
-
- private propagateLocationToRedux() {
- if (this.location) {
- this.reduxActions.connection.newLocation(this.location);
- }
- }
-
- private setRelayListPair(relayListPair?: IRelayListWithEndpointData) {
- this.relayList = relayListPair;
- this.propagateRelayListPairToRedux();
- }
-
- private propagateRelayListPairToRedux() {
- if (this.relayList) {
- this.reduxActions.settings.updateRelayLocations(this.relayList.relayList.countries);
- this.reduxActions.settings.updateWireguardEndpointData(this.relayList.wireguardEndpointData);
- }
- }
-
- private setCurrentVersion(versionInfo: ICurrentAppVersionInfo) {
- this.reduxActions.version.updateVersion(
- versionInfo.gui,
- versionInfo.isConsistent,
- versionInfo.isBeta,
- );
- }
-
- private setUpgradeVersion(upgradeVersion: IAppVersionInfo) {
- this.reduxActions.version.updateLatest(upgradeVersion);
- }
-
- private setGuiSettings(guiSettings: IGuiSettingsState) {
- this.reduxActions.settings.updateGuiSettings(guiSettings);
- }
-
- private setAccountExpiry(expiry?: string) {
- const state = this.reduxStore.getState();
- const previousExpiry = state.account.expiry;
-
- this.expiryScheduler.cancel();
-
- if (expiry !== undefined) {
- const expired = hasExpired(expiry);
-
- // Set state to expired when expiry date passes.
- if (!expired && closeToExpiry(expiry)) {
- const delay = new Date(expiry).getTime() - Date.now() + 1;
- this.expiryScheduler.schedule(() => this.handleExpiry(expiry, true), delay);
- }
-
- if (expiry !== previousExpiry) {
- this.handleExpiry(expiry, expired);
- }
- } else {
- this.handleExpiry(expiry);
- }
- }
-
- private handleExpiry(expiry?: string, expired?: boolean) {
- const state = this.reduxStore.getState();
- this.reduxActions.account.updateAccountExpiry(expiry);
-
- if (
- expiry !== undefined &&
- state.account.status.type === 'ok' &&
- ((state.account.status.expiredState === undefined && expired) ||
- (state.account.status.expiredState === 'expired' && !expired)) &&
- // If the login navigation is already scheduled no navigation is needed
- !this.loginScheduler.isRunning
- ) {
- this.resetNavigation(true);
- }
- }
-
- private storeAutoStart(autoStart: boolean) {
- this.reduxActions.settings.updateAutoStart(autoStart);
- }
-
- private setChangelog(changelog: IChangelog, forceShowChanges: boolean) {
- this.reduxActions.userInterface.setChangelog(changelog);
- this.reduxActions.userInterface.setForceShowChanges(forceShowChanges);
- }
-
- private updateLocation() {
- switch (this.tunnelState.state) {
- case 'disconnected':
- if (this.tunnelState.location) {
- this.setLocation(this.tunnelState.location);
- }
- break;
- case 'disconnecting':
- if (this.tunnelState.location) {
- this.setLocation(this.tunnelState.location);
- } else {
- // If there's no previous location while disconnecting we remove the location. We keep the
- // coordinates to prevent the map from jumping around.
- const { longitude, latitude } = this.reduxStore.getState().connection;
- this.setLocation({ longitude, latitude });
- }
- break;
- case 'connecting':
- case 'connected': {
- this.setLocation(this.tunnelState.details?.location ?? this.getLocationFromConstraints());
- break;
- }
- }
- }
-
- private setCurrentApiAccessMethod(method?: AccessMethodSetting) {
- if (method) {
- this.reduxActions.settings.updateCurrentApiAccessMethod(method);
- }
- }
-
- private getLocationFromConstraints(): Partial<ILocation> {
- const state = this.reduxStore.getState();
- const coordinates = {
- longitude: state.connection.longitude,
- latitude: state.connection.latitude,
- };
-
- const relaySettings = this.settings.relaySettings;
- if ('normal' in relaySettings) {
- const location = relaySettings.normal.location;
- if (location !== 'any' && 'only' in location) {
- const constraint = location.only;
- const relayLocations = state.settings.relayLocations;
-
- if ('hostname' in constraint) {
- const country = relayLocations.find(({ code }) => constraint.country === code);
- const city = country?.cities.find(({ code }) => constraint.city === code);
-
- let entryHostname: string | undefined;
- const multihopConstraint = relaySettings.normal.wireguardConstraints.useMultihop;
- const entryLocationConstraint = relaySettings.normal.wireguardConstraints.entryLocation;
- if (
- multihopConstraint &&
- entryLocationConstraint !== 'any' &&
- 'hostname' in entryLocationConstraint.only &&
- entryLocationConstraint.only.hostname.length === 3
- ) {
- entryHostname = entryLocationConstraint.only.hostname;
- }
-
- return {
- country: country?.name,
- city: city?.name,
- hostname: constraint.hostname,
- entryHostname,
- ...coordinates,
- };
- } else if ('city' in constraint) {
- const country = relayLocations.find(({ code }) => constraint.country === code);
- const city = country?.cities.find(({ code }) => constraint.city === code);
-
- return { country: country?.name, city: city?.name, ...coordinates };
- } else if ('country' in constraint) {
- const country = relayLocations.find(({ code }) => constraint.country === code);
-
- return { country: country?.name, ...coordinates };
- }
- }
- }
-
- return coordinates;
- }
-}
diff --git a/gui/src/renderer/components/Accordion.tsx b/gui/src/renderer/components/Accordion.tsx
deleted file mode 100644
index 27a5be53bc..0000000000
--- a/gui/src/renderer/components/Accordion.tsx
+++ /dev/null
@@ -1,124 +0,0 @@
-import * as React from 'react';
-import styled from 'styled-components';
-
-interface IProps {
- expanded: boolean;
- animationDuration: number;
- children?: React.ReactNode;
- onWillExpand?: (contentHeight: number) => void;
- onTransitionEnd?: () => void;
- className?: string;
-}
-
-interface IState {
- mountChildren: boolean;
- containerHeight: string;
-}
-
-const Container = styled.div<{ $height: string; $animationDuration: number }>((props) => ({
- display: 'flex',
- height: props.$height,
- overflow: 'hidden',
- transition: `height ${props.$animationDuration}ms ease-in-out`,
-}));
-
-const Content = styled.div({
- display: 'flex',
- flexDirection: 'column',
- flex: 1,
- height: 'fit-content',
- width: '100%',
-});
-
-export default class Accordion extends React.Component<IProps, IState> {
- public static defaultProps = {
- expanded: true,
- animationDuration: 350,
- };
-
- public state: IState = {
- mountChildren: this.props.expanded,
- containerHeight: this.props.expanded ? 'auto' : '0',
- };
-
- private containerRef = React.createRef<HTMLDivElement>();
- private contentRef = React.createRef<HTMLDivElement>();
-
- public componentDidUpdate(oldProps: IProps) {
- if (this.props.expanded && !oldProps.expanded) {
- this.expand();
- } else if (!this.props.expanded && oldProps.expanded) {
- this.collapse();
- }
- }
-
- public render() {
- return (
- <Container
- ref={this.containerRef}
- className={this.props.className}
- $height={this.state.containerHeight}
- $animationDuration={this.props.animationDuration}
- onTransitionEnd={this.onTransitionEnd}>
- <Content ref={this.contentRef}>{this.state.mountChildren && this.props.children}</Content>
- </Container>
- );
- }
-
- private expand() {
- // Make sure the children are mounted first before expanding the accordion
- this.mountChildren(() => {
- this.onWillExpand();
-
- const contentHeight = this.getContentHeight();
- const containerHeight = this.containerRef.current?.offsetHeight;
- if (containerHeight === contentHeight) {
- // If the height new height is the same as the current then we want to change the height to
- // auto immediately since no transition is needed.
- this.setState({ containerHeight: 'auto' });
- } else {
- this.setState({ containerHeight: contentHeight + 'px' });
- }
- });
- }
-
- private mountChildren(childrenDidMount: () => void) {
- if (!this.state.mountChildren) {
- this.setState({ mountChildren: true }, childrenDidMount);
- } else {
- childrenDidMount();
- }
- }
-
- private collapse() {
- // First change height to height in px since it's not possible to transition to/from auto
- this.setState({ containerHeight: this.getContentHeight() + 'px' }, () => {
- // Make sure new height has been applied. By reading offsetHeight we force the browser to
- // apply the height before returning.
- // eslint-disable-next-line @typescript-eslint/no-unused-expressions
- this.containerRef.current?.offsetHeight;
- this.setState({ containerHeight: '0' });
- });
- }
-
- private getContentHeight(): number {
- return this.contentRef.current?.offsetHeight ?? 0;
- }
-
- private onWillExpand() {
- const contentHeight = this.getContentHeight();
- if (contentHeight) {
- this.props.onWillExpand?.(contentHeight);
- }
- }
-
- private onTransitionEnd = (event: React.TransitionEvent<HTMLDivElement>) => {
- if (event.target === this.containerRef.current) {
- this.props.onTransitionEnd?.();
- if (this.props.expanded) {
- // Height auto enables the container to grow if the content changes size
- this.setState({ containerHeight: 'auto' });
- }
- }
- };
-}
diff --git a/gui/src/renderer/components/Account.tsx b/gui/src/renderer/components/Account.tsx
deleted file mode 100644
index 1cc666910e..0000000000
--- a/gui/src/renderer/components/Account.tsx
+++ /dev/null
@@ -1,160 +0,0 @@
-import { useCallback, useEffect } from 'react';
-
-import { links } from '../../config.json';
-import { formatDate, hasExpired } from '../../shared/account-expiry';
-import { messages } from '../../shared/gettext';
-import { useAppContext } from '../context';
-import { useHistory } from '../lib/history';
-import { useEffectEvent } from '../lib/utility-hooks';
-import { useSelector } from '../redux/store';
-import AccountNumberLabel from './AccountNumberLabel';
-import {
- AccountContainer,
- AccountOutOfTime,
- AccountRow,
- AccountRowLabel,
- AccountRows,
- AccountRowValue,
- DeviceRowValue,
- StyledDeviceNameRow,
-} from './AccountStyles';
-import * as AppButton from './AppButton';
-import { AriaDescribed, AriaDescription, AriaDescriptionGroup } from './AriaGroup';
-import DeviceInfoButton from './DeviceInfoButton';
-import { BackAction } from './KeyboardNavigation';
-import { Footer, Layout, SettingsContainer } from './Layout';
-import { NavigationBar, NavigationItems, TitleBarItem } from './NavigationBar';
-import { RedeemVoucherButton } from './RedeemVoucher';
-import SettingsHeader, { HeaderTitle } from './SettingsHeader';
-
-export default function Account() {
- const history = useHistory();
- const isOffline = useSelector((state) => state.connection.isBlocked);
- const { updateAccountData, openLinkWithAuth, logout } = useAppContext();
-
- const onBuyMore = useCallback(async () => {
- await openLinkWithAuth(links.purchase);
- }, [openLinkWithAuth]);
-
- const onMount = useEffectEvent(() => updateAccountData());
- useEffect(() => onMount(), []);
-
- // Hack needed because if we just call `logout` directly in `onClick`
- // then it is run with the wrong `this`.
- const doLogout = useCallback(async () => {
- await logout();
- }, [logout]);
-
- return (
- <BackAction action={history.pop}>
- <Layout>
- <SettingsContainer>
- <NavigationBar>
- <NavigationItems>
- <TitleBarItem>
- {
- // TRANSLATORS: Title label in navigation bar
- messages.pgettext('account-view', 'Account')
- }
- </TitleBarItem>
- </NavigationItems>
- </NavigationBar>
-
- <AccountContainer>
- <SettingsHeader>
- <HeaderTitle>{messages.pgettext('account-view', 'Account')}</HeaderTitle>
- </SettingsHeader>
-
- <AccountRows>
- <AccountRow>
- <AccountRowLabel>
- {messages.pgettext('device-management', 'Device name')}
- </AccountRowLabel>
- <DeviceNameRow />
- </AccountRow>
-
- <AccountRow>
- <AccountRowLabel>
- {messages.pgettext('account-view', 'Account number')}
- </AccountRowLabel>
- <AccountNumberRow />
- </AccountRow>
-
- <AccountRow>
- <AccountRowLabel>{messages.pgettext('account-view', 'Paid until')}</AccountRowLabel>
- <AccountExpiryRow />
- </AccountRow>
- </AccountRows>
-
- <Footer>
- <AppButton.ButtonGroup>
- <AppButton.BlockingButton disabled={isOffline} onClick={onBuyMore}>
- <AriaDescriptionGroup>
- <AriaDescribed>
- <AppButton.GreenButton>
- <AppButton.Label>{messages.gettext('Buy more credit')}</AppButton.Label>
- <AriaDescription>
- <AppButton.Icon
- source="icon-extLink"
- height={16}
- width={16}
- aria-label={messages.pgettext('accessibility', 'Opens externally')}
- />
- </AriaDescription>
- </AppButton.GreenButton>
- </AriaDescribed>
- </AriaDescriptionGroup>
- </AppButton.BlockingButton>
-
- <RedeemVoucherButton />
-
- <AppButton.RedButton onClick={doLogout}>
- {messages.pgettext('account-view', 'Log out')}
- </AppButton.RedButton>
- </AppButton.ButtonGroup>
- </Footer>
- </AccountContainer>
- </SettingsContainer>
- </Layout>
- </BackAction>
- );
-}
-
-function DeviceNameRow() {
- const deviceName = useSelector((state) => state.account.deviceName);
- return (
- <StyledDeviceNameRow>
- <DeviceRowValue>{deviceName}</DeviceRowValue>
- <DeviceInfoButton />
- </StyledDeviceNameRow>
- );
-}
-
-function AccountNumberRow() {
- const accountNumber = useSelector((state) => state.account.accountNumber);
- return <AccountRowValue as={AccountNumberLabel} accountNumber={accountNumber || ''} />;
-}
-
-function AccountExpiryRow() {
- const accountExpiry = useSelector((state) => state.account.expiry);
- const expiryLocale = useSelector((state) => state.userInterface.locale);
- return <FormattedAccountExpiry expiry={accountExpiry} locale={expiryLocale} />;
-}
-
-function FormattedAccountExpiry(props: { expiry?: string; locale: string }) {
- if (props.expiry) {
- if (hasExpired(props.expiry)) {
- return (
- <AccountOutOfTime>{messages.pgettext('account-view', 'OUT OF TIME')}</AccountOutOfTime>
- );
- } else {
- return <AccountRowValue>{formatDate(props.expiry, props.locale)}</AccountRowValue>;
- }
- } else {
- return (
- <AccountRowValue>
- {messages.pgettext('account-view', 'Currently unavailable')}
- </AccountRowValue>
- );
- }
-}
diff --git a/gui/src/renderer/components/AccountNumberLabel.tsx b/gui/src/renderer/components/AccountNumberLabel.tsx
deleted file mode 100644
index c41a6248aa..0000000000
--- a/gui/src/renderer/components/AccountNumberLabel.tsx
+++ /dev/null
@@ -1,20 +0,0 @@
-import { formatAccountNumber } from '../lib/account';
-import ClipboardLabel from './ClipboardLabel';
-
-interface IAccountNumberLabelProps {
- accountNumber: string;
- obscureValue?: boolean;
- className?: string;
-}
-
-export default function AccountNumberLabel(props: IAccountNumberLabelProps) {
- return (
- <ClipboardLabel
- value={props.accountNumber}
- displayValue={formatAccountNumber(props.accountNumber)}
- obscureValue={props.obscureValue}
- className={props.className}
- data-testid="account-number"
- />
- );
-}
diff --git a/gui/src/renderer/components/AccountStyles.tsx b/gui/src/renderer/components/AccountStyles.tsx
deleted file mode 100644
index f3ba25c86a..0000000000
--- a/gui/src/renderer/components/AccountStyles.tsx
+++ /dev/null
@@ -1,50 +0,0 @@
-import styled from 'styled-components';
-
-import { colors } from '../../config.json';
-import { measurements, normalText, tinyText } from './common-styles';
-
-export const AccountContainer = styled.div({
- display: 'flex',
- flexDirection: 'column',
- flex: 1,
-});
-
-export const AccountRows = styled.div({
- display: 'flex',
- flexDirection: 'column',
- flex: 1,
-});
-
-export const AccountRow = styled.div({
- padding: `0 ${measurements.viewMargin}`,
- marginBottom: measurements.rowVerticalMargin,
-});
-
-const AccountRowText = styled.span({
- display: 'block',
- fontFamily: 'Open Sans',
-});
-
-export const AccountRowLabel = styled(AccountRowText)(tinyText, {
- lineHeight: '20px',
- marginBottom: '5px',
- color: colors.white60,
-});
-
-export const AccountRowValue = styled(AccountRowText)(normalText, {
- fontWeight: 600,
- color: colors.white,
-});
-
-export const DeviceRowValue = styled(AccountRowValue)({
- textTransform: 'capitalize',
-});
-
-export const AccountOutOfTime = styled(AccountRowValue)({
- color: colors.red,
-});
-
-export const StyledDeviceNameRow = styled.div({
- display: 'flex',
- flexDirection: 'row',
-});
diff --git a/gui/src/renderer/components/ApiAccessMethods.tsx b/gui/src/renderer/components/ApiAccessMethods.tsx
deleted file mode 100644
index 68b873bd64..0000000000
--- a/gui/src/renderer/components/ApiAccessMethods.tsx
+++ /dev/null
@@ -1,369 +0,0 @@
-import { useCallback, useMemo } from 'react';
-import { sprintf } from 'sprintf-js';
-import styled from 'styled-components';
-
-import { colors } from '../../config.json';
-import { AccessMethodSetting } from '../../shared/daemon-rpc-types';
-import { messages } from '../../shared/gettext';
-import { useAppContext } from '../context';
-import { useApiAccessMethodTest } from '../lib/api-access-methods';
-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 * as Cell from './cell';
-import {
- ContextMenu,
- ContextMenuContainer,
- ContextMenuItem,
- ContextMenuTrigger,
-} from './ContextMenu';
-import ImageView from './ImageView';
-import InfoButton from './InfoButton';
-import { BackAction } from './KeyboardNavigation';
-import { Layout, SettingsContainer } from './Layout';
-import { ModalAlert, ModalAlertType } from './Modal';
-import {
- NavigationBar,
- NavigationContainer,
- NavigationInfoButton,
- NavigationItems,
- TitleBarItem,
-} from './NavigationBar';
-import SettingsHeader, { HeaderSubTitle, HeaderTitle } from './SettingsHeader';
-import { StyledContent, StyledNavigationScrollbars, StyledSettingsContent } from './SettingsStyles';
-import { SmallButton, SmallButtonColor, SmallButtonGroup } from './SmallButton';
-
-const StyledContextMenuButton = styled(Cell.Icon)({
- alignItems: 'center',
- justifyContent: 'center',
- marginRight: '8px',
-});
-
-const StyledMethodInfoButton = styled(InfoButton)({
- marginRight: '11px',
-});
-
-const StyledSpinner = styled(ImageView)({
- height: '10px',
- width: '10px',
- marginRight: '6px',
-});
-
-const StyledNameLabel = styled(Cell.Label)({
- display: 'block',
- overflow: 'hidden',
- textOverflow: 'ellipsis',
- whiteSpace: 'nowrap',
-});
-
-const StyledTestResultCircle = styled.div<{ $result: boolean }>((props) => ({
- width: '10px',
- height: '10px',
- borderRadius: '50%',
- backgroundColor: props.$result ? colors.green : colors.red,
- marginRight: '6px',
-}));
-
-// This component is the topmost component in the API access methods view.
-export default function ApiAccessMethods() {
- const history = useHistory();
- const methods = useSelector((state) => state.settings.apiAccessMethods);
- const currentMethod = useSelector((state) => state.settings.currentApiAccessMethod);
-
- const navigateToEdit = useCallback(
- (id?: string) => {
- const path = generateRoutePath(RoutePath.editApiAccessMethods, { id });
- history.push(path);
- },
- [history],
- );
-
- const navigateToNew = useCallback(() => navigateToEdit(), [navigateToEdit]);
-
- return (
- <BackAction action={history.pop}>
- <Layout>
- <SettingsContainer>
- <NavigationContainer>
- <NavigationBar>
- <NavigationItems>
- <TitleBarItem>
- {
- // TRANSLATORS: Title label in navigation bar
- messages.pgettext('navigation-bar', 'API access')
- }
- </TitleBarItem>
- <NavigationInfoButton
- message={[
- messages.pgettext(
- 'api-access-methods-view',
- 'The app needs to communicate with a Mullvad API server to log you in, fetch server lists, and other critical operations.',
- ),
- messages.pgettext(
- 'api-access-methods-view',
- 'On some networks, where various types of censorship are being used, the API servers might not be directly reachable.',
- ),
- messages.pgettext(
- 'api-access-methods-view',
- 'This feature allows you to circumvent that censorship by adding custom ways to access the API via proxies and similar methods.',
- ),
- ]}
- />
- </NavigationItems>
- </NavigationBar>
-
- <StyledNavigationScrollbars fillContainer>
- <StyledContent>
- <SettingsHeader>
- <HeaderTitle>{messages.pgettext('navigation-bar', 'API access')}</HeaderTitle>
- <HeaderSubTitle>
- {messages.pgettext(
- 'api-access-methods-view',
- 'Manage and add custom methods to access the Mullvad API.',
- )}
- </HeaderSubTitle>
- </SettingsHeader>
-
- <StyledSettingsContent>
- <Cell.Group>
- <ApiAccessMethod
- method={methods.direct}
- inUse={methods.direct.id === currentMethod?.id}
- />
- <ApiAccessMethod
- method={methods.mullvadBridges}
- inUse={methods.mullvadBridges.id === currentMethod?.id}
- />
- <ApiAccessMethod
- method={methods.encryptedDnsProxy}
- inUse={methods.encryptedDnsProxy.id === currentMethod?.id}
- />
- {methods.custom.map((method) => (
- <ApiAccessMethod
- key={method.id}
- method={method}
- inUse={method.id === currentMethod?.id}
- custom
- />
- ))}
- </Cell.Group>
-
- <SmallButtonGroup $noMarginTop>
- <SmallButton onClick={navigateToNew}>{messages.gettext('Add')}</SmallButton>
- </SmallButtonGroup>
- </StyledSettingsContent>
- </StyledContent>
- </StyledNavigationScrollbars>
- </NavigationContainer>
- </SettingsContainer>
- </Layout>
- </BackAction>
- );
-}
-
-interface ApiAccessMethodProps {
- method: AccessMethodSetting;
- inUse: boolean;
- custom?: boolean;
-}
-
-function ApiAccessMethod(props: ApiAccessMethodProps) {
- const {
- setApiAccessMethod: setApiAccessMethodImpl,
- updateApiAccessMethod,
- removeApiAccessMethod,
- } = useAppContext();
- const { push } = useHistory();
-
- const [testing, testResult, testApiAccessMethod] = useApiAccessMethodTest();
-
- // State for delete confirmation dialog.
- const [removeConfirmationVisible, showRemoveConfirmation, hideRemoveConfirmation] = useBoolean();
- const confirmRemove = useCallback(() => {
- void removeApiAccessMethod(props.method.id);
- hideRemoveConfirmation();
- }, [hideRemoveConfirmation, props.method.id, removeApiAccessMethod]);
-
- // Toggle on/off on an access method.
- const toggle = useCallback(
- async (value: boolean) => {
- const updatedMethod = cloneMethod(props.method);
- updatedMethod.enabled = value;
- await updateApiAccessMethod(updatedMethod);
- },
- [props.method, updateApiAccessMethod],
- );
-
- const setApiAccessMethod = useCallback(async () => {
- const reachable = await testApiAccessMethod(props.method.id);
- if (reachable) {
- await setApiAccessMethodImpl(props.method.id);
- }
- }, [testApiAccessMethod, props.method.id, setApiAccessMethodImpl]);
-
- const menuItems = useMemo<Array<ContextMenuItem>>(() => {
- const items: Array<ContextMenuItem> = [
- {
- type: 'item' as const,
- label: messages.gettext('Use'),
- disabled: props.inUse,
- onClick: setApiAccessMethod,
- },
- {
- type: 'item' as const,
- label: messages.gettext('Test'),
- onClick: () => testApiAccessMethod(props.method.id),
- },
- ];
-
- // Edit and Delete shouldn't be available for direct, bridges or encrypted DNS proxy.
- if (props.custom) {
- items.push(
- { type: 'separator' as const },
- {
- type: 'item' as const,
- label: messages.gettext('Edit'),
- onClick: () =>
- push(generateRoutePath(RoutePath.editApiAccessMethods, { id: props.method.id })),
- },
- {
- type: 'item' as const,
- label: messages.gettext('Delete'),
- onClick: showRemoveConfirmation,
- },
- );
- }
-
- return items;
- }, [
- props.inUse,
- props.custom,
- props.method.id,
- setApiAccessMethod,
- testApiAccessMethod,
- showRemoveConfirmation,
- push,
- ]);
-
- return (
- <Cell.Row data-testid="access-method">
- <Cell.LabelContainer>
- <StyledNameLabel>{props.method.name}</StyledNameLabel>
- {testing && (
- <Cell.SubLabel>
- <StyledSpinner source="icon-spinner" />
- {messages.pgettext('api-access-methods-view', 'Testing...')}
- </Cell.SubLabel>
- )}
- {!testing && testResult !== undefined && (
- <Cell.SubLabel>
- <StyledTestResultCircle $result={testResult} />
- {testResult
- ? messages.pgettext('api-access-methods-view', 'API reachable')
- : messages.pgettext('api-access-methods-view', 'API unreachable')}
- </Cell.SubLabel>
- )}
- {!testing && testResult === undefined && props.inUse && (
- <Cell.SubLabel>{messages.pgettext('api-access-methods-view', 'In use')}</Cell.SubLabel>
- )}
- </Cell.LabelContainer>
- {props.method.type === 'direct' && (
- <StyledMethodInfoButton
- message={[
- messages.pgettext(
- 'api-access-methods-view',
- 'With the “Direct” method, the app communicates with a Mullvad API server directly without any intermediate proxies.',
- ),
- messages.pgettext(
- 'api-access-methods-view',
- 'This can be useful when you are not affected by censorship.',
- ),
- ]}
- />
- )}
- {props.method.type === 'bridges' && (
- <StyledMethodInfoButton
- message={[
- messages.pgettext(
- 'api-access-methods-view',
- '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.',
- ),
- messages.pgettext(
- 'api-access-methods-view',
- 'This can be useful if the API is censored but Mullvad’s bridge servers are not.',
- ),
- ]}
- />
- )}
- {props.method.type === 'encrypted-dns-proxy' && (
- <StyledMethodInfoButton
- message={[
- messages.pgettext(
- 'api-access-methods-view',
- 'With the “Encrypted DNS proxy” method, the app will communicate with our Mullvad API through a proxy address. It does this by retrieving an address from a DNS over HTTPS (DoH) server and then using that to reach our API servers.',
- ),
- messages.pgettext(
- 'api-access-methods-view',
- 'If you are not connected to our VPN, then the Encrypted DNS proxy will use your own non-VPN IP when connecting. The DoH servers are hosted by one of the following providers: Quad 9, CloudFlare, or Google.',
- ),
- ]}
- />
- )}
- <ContextMenuContainer>
- <ContextMenuTrigger>
- <StyledContextMenuButton
- source="icon-more"
- tintColor={colors.white}
- tintHoverColor={colors.white80}
- />
- </ContextMenuTrigger>
- <ContextMenu items={menuItems} align="right" />
- </ContextMenuContainer>
- <Cell.Switch isOn={props.method.enabled} onChange={toggle} />
-
- {/* Confirmation dialog for method removal */}
- <ModalAlert
- isOpen={removeConfirmationVisible}
- type={ModalAlertType.warning}
- gridButtons={[
- <SmallButton key="cancel" onClick={hideRemoveConfirmation}>
- {messages.gettext('Cancel')}
- </SmallButton>,
- <SmallButton key="confirm" onClick={confirmRemove} color={SmallButtonColor.red}>
- {messages.gettext('Delete')}
- </SmallButton>,
- ]}
- close={hideRemoveConfirmation}
- title={sprintf(messages.pgettext('api-access-methods-view', 'Delete %(name)s?'), {
- name: props.method.name,
- })}
- message={
- props.inUse
- ? messages.pgettext(
- 'api-access-methods-view',
- 'The in use API access method will change.',
- )
- : undefined
- }
- />
- </Cell.Row>
- );
-}
-
-function cloneMethod<T extends AccessMethodSetting>(method: T): T {
- const clonedMethod = {
- ...method,
- };
-
- if (
- method.type === 'socks5-remote' &&
- clonedMethod.type === 'socks5-remote' &&
- method.authentication !== undefined
- ) {
- clonedMethod.authentication = { ...method.authentication };
- }
-
- return clonedMethod;
-}
diff --git a/gui/src/renderer/components/AppButton.tsx b/gui/src/renderer/components/AppButton.tsx
deleted file mode 100644
index f82b997cc4..0000000000
--- a/gui/src/renderer/components/AppButton.tsx
+++ /dev/null
@@ -1,215 +0,0 @@
-import React, { useCallback, useContext, useMemo, useState } from 'react';
-import styled from 'styled-components';
-
-import { colors } from '../../config.json';
-import log from '../../shared/logging';
-import { useMounted } from '../lib/utility-hooks';
-import {
- StyledButtonContent,
- StyledHiddenSide,
- StyledLabel,
- StyledLeft,
- StyledRight,
- StyledVisibleSide,
- transparentButton,
-} from './AppButtonStyles';
-import { measurements } from './common-styles';
-import ImageView from './ImageView';
-
-interface ILabelProps {
- textOffset?: number;
- children?: React.ReactNode;
-}
-
-export function Label(props: ILabelProps) {
- return <StyledLabel $textOffset={props.textOffset ?? 0}>{props.children}</StyledLabel>;
-}
-
-interface IIconProps {
- source: string;
- width?: number;
- height?: number;
-}
-
-export function Icon(props: IIconProps) {
- return <ImageView {...props} tintColor={colors.white} />;
-}
-
-export interface IProps extends React.HTMLAttributes<HTMLButtonElement> {
- children?: React.ReactNode;
- className?: string;
- disabled?: boolean;
- onClick?: () => void;
- textOffset?: number;
-}
-
-type ChildrenGroups = { left: React.ReactNode[]; label: React.ReactNode; right: React.ReactNode[] };
-
-const BaseButton = React.memo(function BaseButtonT(props: IProps) {
- const { children, textOffset, ...otherProps } = props;
-
- const groupedChildren = useMemo(() => {
- return React.Children.toArray(children).reduce(
- (groups: ChildrenGroups, child) => {
- if (groups.label === undefined && typeof child === 'string') {
- return { ...groups, label: <Label textOffset={textOffset}>{child}</Label> };
- } else if (React.isValidElement(child) && child.type === Label) {
- return {
- ...groups,
- label: React.cloneElement(child as React.ReactElement<ILabelProps>, { textOffset }),
- };
- } else if (groups.label === undefined) {
- return { ...groups, left: [...groups.left, child] };
- } else {
- return { ...groups, right: [...groups.right, child] };
- }
- },
- { left: [], label: undefined, right: [] },
- );
- }, [children, textOffset]);
-
- return (
- <StyledSimpleButton {...otherProps}>
- <StyledButtonContent>
- <StyledLeft>
- <StyledVisibleSide>{groupedChildren.left}</StyledVisibleSide>
- <StyledHiddenSide>{groupedChildren.right}</StyledHiddenSide>
- </StyledLeft>
-
- {groupedChildren.label ?? <Label />}
-
- <StyledRight>
- <StyledVisibleSide>{groupedChildren.right}</StyledVisibleSide>
- <StyledHiddenSide>{groupedChildren.left}</StyledHiddenSide>
- </StyledRight>
- </StyledButtonContent>
- </StyledSimpleButton>
- );
-});
-
-function SimpleButtonT(props: React.ButtonHTMLAttributes<HTMLButtonElement>) {
- const blockingContext = useContext(BlockingContext);
-
- return (
- <button
- {...props}
- disabled={props.disabled || blockingContext.disabled}
- onClick={blockingContext.onClick ?? props.onClick}>
- {props.children}
- </button>
- );
-}
-
-export const SimpleButton = React.memo(SimpleButtonT);
-
-const StyledSimpleButton = styled(SimpleButton)({
- display: 'flex',
- cursor: 'default',
- borderRadius: 4,
- border: 'none',
- padding: 0,
- '&&:disabled': {
- opacity: 0.5,
- },
-});
-
-interface IBlockingContext {
- disabled?: boolean;
- onClick?: () => Promise<void>;
-}
-
-const BlockingContext = React.createContext<IBlockingContext>({});
-
-interface IBlockingProps {
- children?: React.ReactNode;
- onClick: () => Promise<void>;
- disabled?: boolean;
-}
-
-export function BlockingButton(props: IBlockingProps) {
- const { onClick: propsOnClick } = props;
-
- const isMounted = useMounted();
- const [isBlocked, setIsBlocked] = useState(false);
-
- const onClick = useCallback(async () => {
- setIsBlocked(true);
- try {
- await propsOnClick();
- } catch (error) {
- log.error(`onClick() failed - ${error}`);
- }
-
- if (isMounted()) {
- setIsBlocked(false);
- }
- }, [isMounted, propsOnClick]);
-
- const contextValue = useMemo(
- () => ({
- disabled: isBlocked || props.disabled,
- onClick,
- }),
- [isBlocked, props.disabled, onClick],
- );
-
- return <BlockingContext.Provider value={contextValue}>{props.children}</BlockingContext.Provider>;
-}
-
-export const RedButton = styled(BaseButton)({
- backgroundColor: colors.red,
- '&&:not(:disabled):hover': {
- backgroundColor: colors.red95,
- },
-});
-
-export const GreenButton = styled(BaseButton)({
- backgroundColor: colors.green,
- '&&:not(:disabled):hover': {
- backgroundColor: colors.green90,
- },
-});
-
-export const BlueButton = styled(BaseButton)({
- backgroundColor: colors.blue80,
- '&&:not(:disabled):hover': {
- backgroundColor: colors.blue60,
- },
-});
-
-export const TransparentButton = styled(BaseButton)(transparentButton, {
- backgroundColor: colors.white20,
- '&&:not(:disabled):hover': {
- backgroundColor: colors.white40,
- },
-});
-
-export const RedTransparentButton = styled(BaseButton)(transparentButton, {
- backgroundColor: colors.red60,
- '&&:not(:disabled):hover': {
- backgroundColor: colors.red80,
- },
-});
-
-const StyledButtonWrapper = styled.div({
- display: 'flex',
- flexDirection: 'column',
- flex: 0,
- '&&:not(:last-child)': {
- marginBottom: measurements.buttonVerticalMargin,
- },
-});
-
-interface IButtonGroupProps {
- children: React.ReactNode | React.ReactNode[];
-}
-
-export function ButtonGroup(props: IButtonGroupProps) {
- return (
- <>
- {React.Children.map(props.children, (button, index) => (
- <StyledButtonWrapper key={index}>{button}</StyledButtonWrapper>
- ))}
- </>
- );
-}
diff --git a/gui/src/renderer/components/AppButtonStyles.tsx b/gui/src/renderer/components/AppButtonStyles.tsx
deleted file mode 100644
index ac65f951f6..0000000000
--- a/gui/src/renderer/components/AppButtonStyles.tsx
+++ /dev/null
@@ -1,42 +0,0 @@
-import styled from 'styled-components';
-
-import { buttonText } from './common-styles';
-
-export const StyledLabel = styled.span<{ $textOffset: number }>(buttonText, (props) => ({
- paddingLeft: props.$textOffset > 0 ? `${props.$textOffset}px` : 0,
- paddingRight: props.$textOffset < 0 ? `${-props.$textOffset}px` : 0,
- textAlign: 'center',
- wordBreak: 'break-word',
-}));
-
-export const StyledButtonContent = styled.div({
- flex: 1,
- display: 'grid',
- gridTemplateColumns: '1fr auto 1fr',
- alignItems: 'center',
- padding: '9px',
-});
-
-export const transparentButton = {
- backdropFilter: 'blur(4px)',
-};
-
-export const StyledLeft = styled.div({
- justifySelf: 'start',
- display: 'flex',
- flexDirection: 'column',
-});
-
-export const StyledRight = styled(StyledLeft)({
- justifySelf: 'end',
-});
-
-export const StyledVisibleSide = styled.div({
- display: 'flex',
- flexDirection: 'row',
-});
-
-export const StyledHiddenSide = styled(StyledVisibleSide).attrs({ 'aria-hidden': true })({
- height: 0,
- visibility: 'hidden',
-});
diff --git a/gui/src/renderer/components/AppRouter.tsx b/gui/src/renderer/components/AppRouter.tsx
deleted file mode 100644
index 3541c095d6..0000000000
--- a/gui/src/renderer/components/AppRouter.tsx
+++ /dev/null
@@ -1,111 +0,0 @@
-import { createRef, useCallback, useEffect, useState } from 'react';
-import { Route, Switch } from 'react-router';
-
-import LoginPage from '../components/Login';
-import SelectLocation from '../components/select-location/SelectLocationContainer';
-import { useAppContext } from '../context';
-import { ITransitionSpecification, transitions, useHistory } from '../lib/history';
-import { RoutePath } from '../lib/routes';
-import Account from './Account';
-import ApiAccessMethods from './ApiAccessMethods';
-import DaitaSettings from './DaitaSettings';
-import Debug from './Debug';
-import { DeviceRevokedView } from './DeviceRevokedView';
-import { EditApiAccessMethod } from './EditApiAccessMethod';
-import { EditCustomBridge } from './EditCustomBridge';
-import {
- SetupFinished,
- TimeAdded,
- VoucherInput,
- VoucherVerificationSuccess,
-} from './ExpiredAccountAddTime';
-import ExpiredAccountErrorView from './ExpiredAccountErrorView';
-import Filter from './Filter';
-import Focus, { IFocusHandle } from './Focus';
-import Launch from './Launch';
-import MainView from './main-view/MainView';
-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';
-import SplitTunnelingSettings from './SplitTunnelingSettings';
-import Support from './Support';
-import TooManyDevices from './TooManyDevices';
-import TransitionContainer, { TransitionView } from './TransitionContainer';
-import UdpOverTcp from './UdpOverTcp';
-import UserInterfaceSettings from './UserInterfaceSettings';
-import VpnSettings from './VpnSettings';
-import WireguardSettings from './WireguardSettings';
-
-export default function AppRouter() {
- const history = useHistory();
- const [currentLocation, setCurrentLocation] = useState(history.location);
- const [transition, setTransition] = useState<ITransitionSpecification>(transitions.none);
- const { setNavigationHistory } = useAppContext();
- const focusRef = createRef<IFocusHandle>();
-
- useEffect(() => {
- // React throttles updates, so it's impossible to capture the intermediate navigation without
- // listening to the history directly.
- const unobserveHistory = history.listen((location, _, transition) => {
- setNavigationHistory(history.asObject);
- setCurrentLocation(location);
- setTransition(transition);
- });
-
- return () => {
- unobserveHistory?.();
- };
- }, [history, setNavigationHistory]);
-
- const onNavigation = useCallback(() => {
- focusRef.current?.resetFocus();
- }, [focusRef]);
-
- return (
- <Focus ref={focusRef}>
- <TransitionContainer onTransitionEnd={onNavigation} {...transition}>
- <TransitionView routePath={history.location.pathname}>
- <Switch key={currentLocation.key} location={currentLocation}>
- <Route exact path={RoutePath.launch} component={Launch} />
- <Route exact path={RoutePath.login} component={LoginPage} />
- <Route exact path={RoutePath.tooManyDevices} component={TooManyDevices} />
- <Route exact path={RoutePath.deviceRevoked} component={DeviceRevokedView} />
- <Route exact path={RoutePath.main} component={MainView} />
- <Route exact path={RoutePath.expired} component={ExpiredAccountErrorView} />
- <Route exact path={RoutePath.redeemVoucher} component={VoucherInput} />
- <Route exact path={RoutePath.voucherSuccess} component={VoucherVerificationSuccess} />
- <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.selectLanguage} component={SelectLanguage} />
- <Route exact path={RoutePath.userInterfaceSettings} component={UserInterfaceSettings} />
- <Route exact path={RoutePath.multihopSettings} component={MultihopSettings} />
- <Route exact path={RoutePath.vpnSettings} component={VpnSettings} />
- <Route exact path={RoutePath.wireguardSettings} component={WireguardSettings} />
- <Route exact path={RoutePath.daitaSettings} component={DaitaSettings} />
- <Route exact path={RoutePath.udpOverTcp} component={UdpOverTcp} />
- <Route exact path={RoutePath.shadowsocks} component={Shadowsocks} />
- <Route exact path={RoutePath.openVpnSettings} component={OpenVpnSettings} />
- <Route exact path={RoutePath.splitTunneling} component={SplitTunnelingSettings} />
- <Route exact path={RoutePath.apiAccessMethods} component={ApiAccessMethods} />
- <Route exact path={RoutePath.settingsImport} component={SettingsImport} />
- <Route exact path={RoutePath.settingsTextImport} component={SettingsTextImport} />
- <Route exact path={RoutePath.editApiAccessMethods} component={EditApiAccessMethod} />
- <Route exact path={RoutePath.support} component={Support} />
- <Route exact path={RoutePath.problemReport} component={ProblemReport} />
- <Route exact path={RoutePath.debug} component={Debug} />
- <Route exact path={RoutePath.selectLocation} component={SelectLocation} />
- <Route exact path={RoutePath.editCustomBridge} component={EditCustomBridge} />
- <Route exact path={RoutePath.filter} component={Filter} />
- </Switch>
- </TransitionView>
- </TransitionContainer>
- </Focus>
- );
-}
diff --git a/gui/src/renderer/components/AriaGroup.tsx b/gui/src/renderer/components/AriaGroup.tsx
deleted file mode 100644
index 9e58283933..0000000000
--- a/gui/src/renderer/components/AriaGroup.tsx
+++ /dev/null
@@ -1,165 +0,0 @@
-import React, { useContext, useEffect, useId, useMemo, useState } from 'react';
-
-interface IAriaControlContext {
- controlledId: string;
-}
-
-const AriaControlContext = React.createContext<IAriaControlContext>({
- get controlledId(): string {
- throw new Error('Missing AriaControlContext.Provider');
- },
-});
-
-interface IAriaGroupProps {
- describedId?: string;
- children: React.ReactNode;
-}
-
-export function AriaControlGroup(props: IAriaGroupProps) {
- const id = useId();
- const contextValue = useMemo(() => ({ controlledId: `${id}-controlled` }), [id]);
-
- return (
- <AriaControlContext.Provider value={contextValue}>{props.children}</AriaControlContext.Provider>
- );
-}
-
-interface IAriaDescriptionContext {
- describedId: string;
- descriptionId?: string;
- setHasDescription: (value: boolean) => void;
-}
-
-const AriaDescriptionContext = React.createContext<IAriaDescriptionContext>({
- get describedId(): string {
- throw new Error('Missing AriaDescriptionContext.Provider');
- },
- setHasDescription(_value) {
- throw new Error('Missing AriaDescriptionContext.Provider');
- },
-});
-
-export function AriaDescriptionGroup(props: IAriaGroupProps) {
- const id = useId();
- const [hasDescription, setHasDescription] = useState(false);
-
- const contextValue = useMemo(
- () => ({
- describedId: props.describedId ?? `${id}-described`,
- descriptionId: hasDescription ? `${id}-description` : undefined,
- setHasDescription,
- }),
- [hasDescription, id, props.describedId],
- );
-
- return (
- <AriaDescriptionContext.Provider value={contextValue}>
- {props.children}
- </AriaDescriptionContext.Provider>
- );
-}
-
-interface IAriaInputContext {
- inputId: string;
- labelId?: string;
- setHasLabel: (value: boolean) => void;
-}
-
-const missingAriaInputContextError = new Error('Missing AriaInputContext.Provider');
-const AriaInputContext = React.createContext<IAriaInputContext>({
- get inputId(): string {
- throw missingAriaInputContextError;
- },
- setHasLabel() {
- throw missingAriaInputContextError;
- },
-});
-
-export function AriaInputGroup(props: IAriaGroupProps) {
- const id = useId();
-
- const [hasLabel, setHasLabel] = useState(false);
-
- const contextValue = useMemo(
- () => ({
- inputId: `${id}-input`,
- labelId: hasLabel ? `${id}-label` : undefined,
- setHasLabel,
- }),
- [hasLabel, id],
- );
-
- return (
- <AriaDescriptionGroup describedId={contextValue.inputId}>
- <AriaInputContext.Provider value={contextValue}>{props.children}</AriaInputContext.Provider>
- </AriaDescriptionGroup>
- );
-}
-
-interface IAriaElementProps {
- children: React.ReactElement;
-}
-
-export function AriaControlled(props: IAriaElementProps) {
- const { controlledId } = useContext(AriaControlContext);
- return React.cloneElement(props.children, { id: controlledId });
-}
-
-export function AriaControls(props: IAriaElementProps) {
- const { controlledId } = useContext(AriaControlContext);
- return React.cloneElement(props.children, { 'aria-controls': controlledId });
-}
-
-export function AriaInput(props: IAriaElementProps) {
- const { inputId, labelId } = useContext(AriaInputContext);
-
- return (
- <AriaDescribed>
- {React.cloneElement(props.children, {
- id: inputId,
- 'aria-labelledby': labelId,
- })}
- </AriaDescribed>
- );
-}
-
-export function AriaLabel(props: IAriaElementProps) {
- const { inputId, labelId, setHasLabel } = useContext(AriaInputContext);
-
- useEffect(() => {
- setHasLabel(true);
- return () => setHasLabel(false);
- }, [setHasLabel]);
-
- return React.cloneElement(props.children, {
- id: labelId,
- htmlFor: inputId,
- });
-}
-
-export function AriaDescribed(props: IAriaElementProps) {
- const { describedId, descriptionId } = useContext(AriaDescriptionContext);
-
- return React.cloneElement(props.children, {
- id: describedId,
- 'aria-describedby': descriptionId,
- });
-}
-
-export function AriaDescription(props: IAriaElementProps) {
- const { descriptionId, setHasDescription } = useContext(AriaDescriptionContext);
-
- useEffect(() => {
- setHasDescription(true);
- return () => setHasDescription(false);
- }, [setHasDescription]);
-
- return React.cloneElement(props.children, {
- id: descriptionId,
- });
-}
-
-export function AriaDetails(props: IAriaElementProps) {
- const { describedId } = useContext(AriaDescriptionContext);
- return React.cloneElement(props.children, { 'aria-details': describedId });
-}
diff --git a/gui/src/renderer/components/Changelog.tsx b/gui/src/renderer/components/Changelog.tsx
deleted file mode 100644
index ea1b42f5c2..0000000000
--- a/gui/src/renderer/components/Changelog.tsx
+++ /dev/null
@@ -1,78 +0,0 @@
-import { useCallback } from 'react';
-import styled from 'styled-components';
-
-import { messages } from '../../shared/gettext';
-import { useAppContext } from '../context';
-import { useBoolean } from '../lib/utility-hooks';
-import { useSelector } from '../redux/store';
-import * as AppButton from './AppButton';
-import { hugeText, smallText } from './common-styles';
-import { ModalAlert, ModalMessage } from './Modal';
-
-const StyledTitle = styled.h1(hugeText, {
- textAlign: 'center',
- margin: '7px 0 4px',
-});
-
-const StyledSubTitle = styled.span(smallText, {
- marginTop: '10px',
- fontWeight: 700,
-});
-
-const StyledList = styled.ul({
- listStyle: 'disc outside',
- marginLeft: '20px',
-});
-
-const StyledMessage = styled(ModalMessage)({
- fontSize: '12px',
- marginTop: '6px',
-});
-
-export function Changelog() {
- const currentVersion = useSelector((state) => state.version.current);
- const changelogDisplayedForVersion = useSelector(
- (state) => state.settings.guiSettings.changelogDisplayedForVersion,
- );
- const changelog = useSelector((state) => state.userInterface.changelog);
- const initialForceShowChanges = useSelector((state) => state.userInterface.forceShowChanges);
-
- const { setDisplayedChangelog } = useAppContext();
-
- const [forceShowChanges, , stopForceShowChanges] = useBoolean(initialForceShowChanges);
-
- const close = useCallback(() => {
- setDisplayedChangelog();
- stopForceShowChanges();
- }, [setDisplayedChangelog, stopForceShowChanges]);
-
- const visible =
- forceShowChanges ||
- (changelogDisplayedForVersion !== currentVersion &&
- changelog.length > 0 &&
- !window.env.development &&
- !/-dev-[0-9a-f]{6}$/.test(currentVersion));
-
- return (
- <ModalAlert
- isOpen={visible}
- buttons={[
- <AppButton.BlueButton key="close" onClick={close}>
- {
- // TRANSLATORS: This is a button which closes a dialog.
- messages.gettext('Got it!')
- }
- </AppButton.BlueButton>,
- ]}>
- <StyledTitle>{currentVersion}</StyledTitle>
- <StyledSubTitle>{messages.pgettext('changelog', 'Changes in this version:')}</StyledSubTitle>
- <StyledMessage>
- <StyledList>
- {changelog.map((item, i) => (
- <li key={i}>{item}</li>
- ))}
- </StyledList>
- </StyledMessage>
- </ModalAlert>
- );
-}
diff --git a/gui/src/renderer/components/ChevronButton.tsx b/gui/src/renderer/components/ChevronButton.tsx
deleted file mode 100644
index 6e16fdf6db..0000000000
--- a/gui/src/renderer/components/ChevronButton.tsx
+++ /dev/null
@@ -1,36 +0,0 @@
-import * as React from 'react';
-import styled from 'styled-components';
-
-import { colors } from '../../config.json';
-import { Icon } from './cell/Label';
-
-interface IProps extends React.HTMLAttributes<HTMLButtonElement> {
- up: boolean;
-}
-
-const Button = styled.button({
- border: 'none',
- background: 'none',
-});
-
-const StyledIcon = styled(Icon)({
- flex: 0,
- alignSelf: 'stretch',
- justifyContent: 'center',
-});
-
-export default function ChevronButton(props: IProps) {
- const { up, ...otherProps } = props;
-
- return (
- <Button {...otherProps}>
- <StyledIcon
- tintColor={colors.white80}
- tintHoverColor={colors.white}
- source={up ? 'icon-chevron-up' : 'icon-chevron-down'}
- height={24}
- width={24}
- />
- </Button>
- );
-}
diff --git a/gui/src/renderer/components/ClipboardLabel.tsx b/gui/src/renderer/components/ClipboardLabel.tsx
deleted file mode 100644
index 7f1970ebfc..0000000000
--- a/gui/src/renderer/components/ClipboardLabel.tsx
+++ /dev/null
@@ -1,105 +0,0 @@
-import { useCallback } from 'react';
-import styled from 'styled-components';
-
-import { colors } from '../../config.json';
-import { messages } from '../../shared/gettext';
-import log from '../../shared/logging';
-import { useScheduler } from '../../shared/scheduler';
-import { useBoolean } from '../lib/utility-hooks';
-import ImageView from './ImageView';
-
-const COPIED_ICON_DURATION = 2000;
-
-interface IProps extends React.HTMLAttributes<HTMLElement> {
- value: string;
- displayValue?: string;
- obscureValue?: boolean;
- message?: string;
-}
-
-const StyledLabelContainer = styled.div({
- display: 'flex',
- flex: 1,
- height: '19px',
- alignItems: 'center',
-});
-
-const StyledLabel = styled.span({
- flex: 1,
-});
-
-const StyledButton = styled.button({
- cursor: 'default',
- padding: 0,
- marginLeft: '20px',
- backgroundColor: 'transparent',
- border: 'none',
-});
-
-const StyledCopyButton = styled(StyledButton)({
- width: '24px',
-});
-
-export default function ClipboardLabel(props: IProps) {
- const { value, obscureValue, displayValue, message, ...otherProps } = props;
-
- const [obscured, , , toggleObscured] = useBoolean(obscureValue ?? true);
- const [justCopied, setJustCopied, resetJustCopied] = useBoolean(false);
-
- const copiedScheduler = useScheduler();
-
- const onCopy = useCallback(async () => {
- try {
- await navigator.clipboard.writeText(value);
- copiedScheduler.schedule(resetJustCopied, COPIED_ICON_DURATION);
- setJustCopied();
- } catch (e) {
- const error = e as Error;
- log.error(`Failed to copy to clipboard: ${error.message}`);
- }
- }, [value, copiedScheduler, setJustCopied, resetJustCopied]);
-
- return (
- <StyledLabelContainer>
- <StyledLabel aria-hidden={obscured} {...otherProps}>
- {obscured ? '●●●● ●●●● ●●●● ●●●●' : (displayValue ?? value)}
- </StyledLabel>
- {obscureValue !== false && (
- <StyledButton
- onClick={toggleObscured}
- aria-label={
- obscured
- ? // This line is here to prevent the following one to be moved up here by prettier
- // TRANSLATORS: Provided to accessibility tools such as screenreaders to describe
- // TRANSLATORS: the button which unobscures the account number.
- messages.pgettext('accessibility', 'Show account number')
- : // This line is here to prevent the following one to be moved up here by prettier
- // TRANSLATORS: Provided to accessibility tools such as screenreaders to describe
- // TRANSLATORS: the button which obscures the account number.
- messages.pgettext('accessibility', 'Hide account number')
- }>
- <ImageView
- source={obscured ? 'icon-unobscure' : 'icon-obscure'}
- tintColor={colors.white}
- tintHoverColor={colors.white80}
- width={24}
- />
- </StyledButton>
- )}
- <StyledCopyButton
- onClick={onCopy}
- aria-label={
- // TRANSLATORS: Provided to accessibility tools such as screenreaders to describe a button
- // TRANSLATORS: which copies the account number to the clipboard.
- messages.pgettext('accessibility', 'Copy account number')
- }>
- <ImageView
- source={justCopied ? 'icon-tick' : 'icon-copy'}
- tintColor={justCopied ? colors.green : colors.white}
- tintHoverColor={justCopied ? colors.green : colors.white80}
- width={justCopied ? 22 : 24}
- />
- </StyledCopyButton>
- </StyledLabelContainer>
- );
-}
diff --git a/gui/src/renderer/components/ContextMenu.tsx b/gui/src/renderer/components/ContextMenu.tsx
deleted file mode 100644
index 2e01c9375f..0000000000
--- a/gui/src/renderer/components/ContextMenu.tsx
+++ /dev/null
@@ -1,223 +0,0 @@
-import React, { useCallback, useContext, useEffect, useMemo } from 'react';
-import styled from 'styled-components';
-
-import { colors } from '../../config.json';
-import { useBoolean, useStyledRef } from '../lib/utility-hooks';
-import { smallText } from './common-styles';
-import { BackAction } from './KeyboardNavigation';
-
-const BORDER_WIDTH = 1;
-const PADDING_VERTICAL = 10;
-const ITEM_HEIGHT = 22;
-
-type Alignment = 'left' | 'right';
-type Direction = 'up' | 'down';
-
-interface MenuContext {
- getTriggerBounds: () => DOMRect;
- toggleVisibility: () => void;
- hide: () => void;
- visible: boolean;
-}
-
-const menuContext = React.createContext<MenuContext>({
- getTriggerBounds: () => {
- throw new Error('No trigger bounds available');
- },
- toggleVisibility: () => {
- throw new Error('toggleVisibility not defined');
- },
- hide: () => {
- throw new Error('hide not defined');
- },
- visible: false,
-});
-
-const StyledMenuContainer = styled.div({
- position: 'relative',
- padding: '8px 4px',
- display: 'flex',
- justifyContent: 'center',
-});
-
-export function ContextMenuContainer(props: React.PropsWithChildren) {
- const ref = useStyledRef<HTMLDivElement>();
- const [visible, , hide, toggleVisibility] = useBoolean(false);
-
- const getTriggerBounds = useCallback(() => {
- if (ref.current === null) {
- throw new Error('No trigger bounds available');
- }
- return ref.current.getBoundingClientRect();
- }, [ref]);
-
- const contextValue = useMemo(
- () => ({
- getTriggerBounds,
- toggleVisibility,
- visible,
- hide,
- }),
- [getTriggerBounds, hide, toggleVisibility, visible],
- );
-
- const clickOutsideListener = useCallback(
- (event: MouseEvent) => {
- if (
- visible &&
- event.target !== null &&
- ref.current?.contains(event.target as HTMLElement) === false
- ) {
- hide();
- }
- },
- [hide, ref, visible],
- );
-
- useEffect(() => {
- document.addEventListener('click', clickOutsideListener, true);
- return () => document.removeEventListener('click', clickOutsideListener, true);
- }, [clickOutsideListener]);
-
- return (
- <StyledMenuContainer ref={ref}>
- <menuContext.Provider value={contextValue}>{props.children}</menuContext.Provider>
- </StyledMenuContainer>
- );
-}
-
-const StyledTrigger = styled.button({
- borderWidth: 0,
- padding: 0,
- margin: 0,
- cursor: 'default',
- backgroundColor: 'transparent',
-});
-
-export function ContextMenuTrigger(props: React.PropsWithChildren) {
- const { toggleVisibility } = useContext(menuContext);
-
- return <StyledTrigger onClick={toggleVisibility}>{props.children}</StyledTrigger>;
-}
-
-interface StyledMenuProps {
- $direction: Direction;
- $align: Alignment;
-}
-
-const StyledMenu = styled.div<StyledMenuProps>((props) => {
- const oppositeSide = 'calc(100% - 8px)';
- const iconMargin = '12px';
-
- return {
- position: 'absolute',
- top: props.$direction === 'up' ? 'auto' : oppositeSide,
- bottom: props.$direction === 'up' ? oppositeSide : 'auto',
- left: props.$align === 'left' ? iconMargin : 'auto',
- right: props.$align === 'left' ? 'auto' : iconMargin,
- padding: '7px 4px',
- background: 'rgb(36, 53, 78)',
- border: `1px solid ${colors.darkBlue}`,
- borderRadius: '8px',
- zIndex: 1,
- };
-});
-
-const StyledMenuItem = styled.button(smallText, (props) => ({
- minWidth: '110px',
- padding: '1px 10px 2px',
- lineHeight: `${ITEM_HEIGHT}px`,
- background: 'transparent',
- border: 'none',
- textAlign: 'left',
- color: props.disabled ? colors.white50 : colors.white,
-
- '&&:hover': {
- background: props.disabled ? 'transparent' : colors.blue,
- },
-}));
-
-const StyledSeparator = styled.hr({
- height: '1px',
- border: 'none',
- backgroundColor: colors.darkBlue,
- margin: '4px 9px',
-});
-
-type ContextMenuItemItem = {
- type: 'item';
- label: string;
- disabled?: boolean;
- onClick: () => void;
-};
-
-type ContextMenuSeparator = { type: 'separator' };
-
-export type ContextMenuItem = ContextMenuItemItem | ContextMenuSeparator;
-
-interface MenuProps {
- items: Array<ContextMenuItem>;
- align: Alignment;
-}
-
-export function ContextMenu(props: MenuProps) {
- const { getTriggerBounds, visible, hide } = useContext(menuContext);
-
- if (!visible) {
- return null;
- }
-
- const triggerBounds = getTriggerBounds();
- const direction = calculateDirection(visible, triggerBounds, props.items.length);
-
- return (
- <BackAction action={hide}>
- <StyledMenu $direction={direction} $align={props.align}>
- {props.items.map((item, i) =>
- item.type === 'separator' ? (
- <StyledSeparator key={`separator-${i}`} />
- ) : (
- <ContextMenuItemRow key={item.label} item={item} closeMenu={hide} />
- ),
- )}
- </StyledMenu>
- </BackAction>
- );
-}
-
-function calculateDirection(
- visible: boolean,
- triggerBounds: DOMRect,
- itemsLength: number,
-): Direction {
- if (visible) {
- const extraSpace = 2 * (BORDER_WIDTH + PADDING_VERTICAL);
- const downwardsStartPosition = triggerBounds.y + triggerBounds.height;
- const downwardsEndPosition = downwardsStartPosition + itemsLength * ITEM_HEIGHT + extraSpace;
- return downwardsEndPosition < window.innerHeight ? 'down' : 'up';
- } else {
- return 'down';
- }
-}
-
-interface ContextMenuItemRowProps {
- item: ContextMenuItemItem;
- closeMenu: () => void;
-}
-
-function ContextMenuItemRow(props: ContextMenuItemRowProps) {
- const { closeMenu } = props;
-
- const onClick = useCallback(() => {
- if (!props.item.disabled) {
- closeMenu();
- props.item.onClick();
- }
- }, [closeMenu, props.item]);
-
- return (
- <StyledMenuItem onClick={onClick} disabled={props.item.disabled}>
- {props.item.label}
- </StyledMenuItem>
- );
-}
diff --git a/gui/src/renderer/components/CustomDnsSettings.tsx b/gui/src/renderer/components/CustomDnsSettings.tsx
deleted file mode 100644
index 914f7b8406..0000000000
--- a/gui/src/renderer/components/CustomDnsSettings.tsx
+++ /dev/null
@@ -1,450 +0,0 @@
-import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
-import { sprintf } from 'sprintf-js';
-
-import { colors, strings } from '../../config.json';
-import { messages } from '../../shared/gettext';
-import { useAppContext } from '../context';
-import { formatHtml } from '../lib/html-formatter';
-import { IpAddress } from '../lib/ip';
-import { useBoolean, useMounted, useStyledRef } from '../lib/utility-hooks';
-import { useSelector } from '../redux/store';
-import Accordion from './Accordion';
-import * as AppButton from './AppButton';
-import {
- AriaDescribed,
- AriaDescription,
- AriaDescriptionGroup,
- AriaInput,
- AriaInputGroup,
- AriaLabel,
-} from './AriaGroup';
-import * as Cell from './cell';
-import {
- StyledAddCustomDnsButton,
- StyledAddCustomDnsLabel,
- StyledButton,
- StyledContainer,
- StyledCustomDnsFooter,
- StyledLabel,
- StyledRemoveButton,
- StyledRemoveIcon,
-} from './CustomDnsSettingsStyles';
-import List, { stringValueAsKey } from './List';
-import { ModalAlert, ModalAlertType } from './Modal';
-
-const manualLocal = window.env.platform === 'win32' || window.env.platform === 'linux';
-
-export default function CustomDnsSettings() {
- const { setDnsOptions } = useAppContext();
- const dns = useSelector((state) => state.settings.dns);
-
- const [inputVisible, showInput, hideInput] = useBoolean(false);
- const [invalid, setInvalid, setValid] = useBoolean(false);
- const [confirmAction, setConfirmAction] = useState<() => Promise<void>>();
- const [savingAdd, setSavingAdd] = useState(false);
- const [savingEdit, setSavingEdit] = useState(false);
- const willShowConfirmationDialog = useRef(false);
- const addingLocalIp = useRef(false);
-
- const featureAvailable = useMemo(
- () =>
- dns.state === 'custom' ||
- (!dns.defaultOptions.blockAds &&
- !dns.defaultOptions.blockTrackers &&
- !dns.defaultOptions.blockMalware &&
- !dns.defaultOptions.blockAdultContent &&
- !dns.defaultOptions.blockGambling &&
- !dns.defaultOptions.blockSocialMedia),
- [dns],
- );
-
- const switchRef = useStyledRef<HTMLDivElement>();
- const addButtonRef = useStyledRef<HTMLButtonElement>();
- const inputContainerRef = useStyledRef<HTMLDivElement>();
-
- const confirm = useCallback(() => {
- void confirmAction?.();
- setConfirmAction(undefined);
- }, [confirmAction]);
- const abortConfirmation = useCallback(() => {
- setConfirmAction(undefined);
- }, []);
-
- const setCustomDnsEnabled = useCallback(
- async (enabled: boolean) => {
- if (dns.customOptions.addresses.length > 0) {
- await setDnsOptions({ ...dns, state: enabled ? 'custom' : 'default' });
- }
- if (enabled && dns.customOptions.addresses.length === 0) {
- showInput();
- }
- if (!enabled) {
- hideInput();
- }
- },
- [dns, hideInput, setDnsOptions, showInput],
- );
-
- // The input field should be hidden when it loses focus unless something on the same row or the
- // add-button is the new focused element.
- const onInputBlur = useCallback(
- (event?: React.FocusEvent<HTMLTextAreaElement>) => {
- const relatedTarget = event?.relatedTarget as Node | undefined;
- if (
- relatedTarget &&
- (switchRef.current?.contains(relatedTarget) ||
- addButtonRef.current?.contains(relatedTarget) ||
- inputContainerRef.current?.contains(relatedTarget))
- ) {
- event?.target.focus();
- } else if (!willShowConfirmationDialog.current) {
- hideInput();
- }
- },
- [addButtonRef, hideInput, inputContainerRef, switchRef],
- );
-
- const onAdd = useCallback(
- async (address: string) => {
- if (dns.customOptions.addresses.includes(address)) {
- setInvalid();
- } else {
- const add = async () => {
- await setDnsOptions({
- ...dns,
- state: dns.state === 'custom' || inputVisible ? 'custom' : 'default',
- customOptions: {
- addresses: [...dns.customOptions.addresses, address],
- },
- });
-
- setSavingAdd(true);
- hideInput();
- };
-
- try {
- const ipAddress = IpAddress.fromString(address);
- addingLocalIp.current = ipAddress.isLocal();
- if (addingLocalIp.current) {
- if (manualLocal) {
- willShowConfirmationDialog.current = true;
- setConfirmAction(() => async () => {
- willShowConfirmationDialog.current = false;
- await add();
- });
- } else {
- await add();
- }
- } else {
- willShowConfirmationDialog.current = true;
- setConfirmAction(() => async () => {
- willShowConfirmationDialog.current = false;
- await add();
- });
- }
- } catch {
- setInvalid();
- }
- }
- },
- [dns, setInvalid, setDnsOptions, inputVisible, hideInput],
- );
-
- const onEdit = useCallback(
- (oldAddress: string, newAddress: string) => {
- if (oldAddress !== newAddress && dns.customOptions.addresses.includes(newAddress)) {
- throw new Error('Duplicate address');
- }
-
- const edit = async () => {
- setSavingEdit(true);
-
- const addresses = dns.customOptions.addresses.map((address) =>
- oldAddress === address ? newAddress : address,
- );
- await setDnsOptions({
- ...dns,
- customOptions: {
- addresses,
- },
- });
- };
-
- const ipAddress = IpAddress.fromString(newAddress);
- return new Promise<void>((resolve) => {
- addingLocalIp.current = ipAddress.isLocal();
- if (addingLocalIp.current) {
- if (manualLocal) {
- willShowConfirmationDialog.current = true;
- setConfirmAction(() => async () => {
- willShowConfirmationDialog.current = false;
- await edit();
- resolve();
- });
- } else {
- void edit().then(resolve);
- }
- } else {
- willShowConfirmationDialog.current = true;
- setConfirmAction(() => async () => {
- willShowConfirmationDialog.current = false;
- await edit();
- resolve();
- });
- }
- });
- },
- [dns, setDnsOptions],
- );
-
- const onRemove = useCallback(
- (address: string) => {
- const addresses = dns.customOptions.addresses.filter((item) => item !== address);
- void setDnsOptions({
- ...dns,
- state: addresses.length > 0 && dns.state === 'custom' ? 'custom' : 'default',
- customOptions: {
- addresses,
- },
- });
- },
- [dns, setDnsOptions],
- );
-
- useEffect(() => setSavingEdit(false), [dns.customOptions.addresses]);
- useEffect(() => setSavingAdd(false), [dns.customOptions.addresses]);
-
- const listExpanded = featureAvailable && (dns.state === 'custom' || inputVisible || savingAdd);
-
- return (
- <>
- <Cell.Container disabled={!featureAvailable}>
- <AriaInputGroup>
- <AriaLabel>
- <Cell.InputLabel>
- {messages.pgettext('vpn-settings-view', 'Use custom DNS server')}
- </Cell.InputLabel>
- </AriaLabel>
- <AriaInput>
- <Cell.Switch
- innerRef={switchRef}
- isOn={dns.state === 'custom' || inputVisible}
- onChange={setCustomDnsEnabled}
- />
- </AriaInput>
- </AriaInputGroup>
- </Cell.Container>
- <Accordion expanded={listExpanded}>
- <Cell.Section role="listbox">
- <List
- items={dns.customOptions.addresses}
- getKey={stringValueAsKey}
- skipAddTransition={true}
- skipRemoveTransition={savingEdit}>
- {(item) => (
- <CellListItem
- onRemove={onRemove}
- onChange={onEdit}
- willShowConfirmationDialog={willShowConfirmationDialog}>
- {item}
- </CellListItem>
- )}
- </List>
- </Cell.Section>
-
- {inputVisible && (
- <div ref={inputContainerRef}>
- <Cell.RowInput
- placeholder={messages.pgettext('vpn-settings-view', 'Enter IP')}
- onSubmit={onAdd}
- onChange={setValid}
- invalid={invalid}
- paddingLeft={32}
- onBlur={onInputBlur}
- autofocus
- />
- </div>
- )}
-
- <StyledAddCustomDnsButton
- ref={addButtonRef}
- onClick={showInput}
- disabled={inputVisible}
- tabIndex={-1}>
- <StyledAddCustomDnsLabel tabIndex={-1}>
- {messages.pgettext('vpn-settings-view', 'Add a server')}
- </StyledAddCustomDnsLabel>
- <Cell.Icon
- source="icon-add"
- width={18}
- height={18}
- tintColor={colors.white40}
- tintHoverColor={colors.white60}
- tabIndex={-1}
- />
- </StyledAddCustomDnsButton>
- </Accordion>
-
- <StyledCustomDnsFooter>
- <Cell.CellFooterText>
- {featureAvailable
- ? messages.pgettext('vpn-settings-view', 'Enable to add at least one DNS server.')
- : formatHtml(
- // TRANSLATORS: This is displayed when either or both of the block ads/trackers settings are
- // TRANSLATORS: turned on which makes the custom DNS setting disabled.
- // TRANSLATORS: Available placeholders:
- // TRANSLATORS: %(preferencesPageName)s - The page title showed on top in the preferences page.
- messages.pgettext(
- 'vpn-settings-view',
- 'Disable all <b>DNS content blockers</b> above to activate this setting.',
- ),
- )}
- </Cell.CellFooterText>
- </StyledCustomDnsFooter>
-
- <ConfirmationDialog
- isOpen={confirmAction !== undefined}
- isLocal={addingLocalIp}
- confirm={confirm}
- abort={abortConfirmation}
- />
- </>
- );
-}
-
-interface ICellListItemProps {
- willShowConfirmationDialog: React.RefObject<boolean>;
- onRemove: (application: string) => void;
- onChange: (value: string, newValue: string) => Promise<void>;
- children: string;
-}
-
-function CellListItem(props: ICellListItemProps) {
- const { onRemove: propsOnRemove, onChange } = props;
-
- const [editing, startEditing, stopEditing] = useBoolean(false);
- const [invalid, setInvalid, setValid] = useBoolean(false);
- const isMounted = useMounted();
-
- const inputContainerRef = useStyledRef<HTMLDivElement>();
-
- const onRemove = useCallback(
- () => propsOnRemove(props.children),
- [propsOnRemove, props.children],
- );
-
- const onSubmit = useCallback(
- async (value: string) => {
- if (value === props.children) {
- stopEditing();
- } else {
- try {
- await onChange(props.children, value);
- if (isMounted()) {
- stopEditing();
- }
- } catch {
- setInvalid();
- }
- }
- },
- [props.children, stopEditing, onChange, isMounted, setInvalid],
- );
-
- const onBlur = useCallback(
- (event?: React.FocusEvent<HTMLTextAreaElement>) => {
- const relatedTarget = event?.relatedTarget as Node | undefined;
- if (relatedTarget && inputContainerRef.current?.contains(relatedTarget)) {
- event?.target.focus();
- } else if (!props.willShowConfirmationDialog.current) {
- stopEditing();
- }
- },
- [inputContainerRef, props.willShowConfirmationDialog, stopEditing],
- );
-
- return (
- <AriaDescriptionGroup>
- {editing ? (
- <div ref={inputContainerRef}>
- <Cell.RowInput
- initialValue={props.children}
- placeholder={messages.pgettext('vpn-settings-view', 'Enter IP')}
- onSubmit={onSubmit}
- onChange={setValid}
- invalid={invalid}
- paddingLeft={32}
- onBlur={onBlur}
- autofocus
- />
- </div>
- ) : (
- <StyledContainer>
- <StyledButton onClick={startEditing}>
- <AriaDescription>
- <StyledLabel>{props.children}</StyledLabel>
- </AriaDescription>
- </StyledButton>
- <AriaDescribed>
- <StyledRemoveButton
- onClick={onRemove}
- aria-label={messages.pgettext('accessibility', 'Remove item')}>
- <StyledRemoveIcon
- source="icon-close"
- width={18}
- height={18}
- tintColor={editing ? colors.black : colors.white40}
- />
- </StyledRemoveButton>
- </AriaDescribed>
- </StyledContainer>
- )}
- </AriaDescriptionGroup>
- );
-}
-
-interface IConfirmationDialogProps {
- isOpen: boolean;
- isLocal: React.RefObject<boolean>;
- confirm: () => void;
- abort: () => void;
-}
-
-function ConfirmationDialog(props: IConfirmationDialogProps) {
- let message;
- if (props.isLocal.current) {
- message = messages.pgettext(
- 'vpn-settings-view',
- 'The DNS server you want to add is a private IP. You must ensure that your network interfaces are configured to use it.',
- );
- } else {
- message = sprintf(
- // TRANSLATORS: Available placeholders:
- // TRANSLATORS: %(tunnelProtocol)s - the name of the tunnel protocol setting
- // TRANSLATORS: %(wireguard)s - will be replaced with "WireGuard"
- messages.pgettext(
- 'vpn-settings-view',
- 'The DNS server you want to add is public and will only work with %(wireguard)s. To ensure that it always works, set the "%(tunnelProtocol)s" (in Advanced settings) to %(wireguard)s.',
- ),
- {
- wireguard: strings.wireguard,
- tunnelProtocol: messages.pgettext('vpn-settings-view', 'Tunnel protocol'),
- },
- );
- }
- return (
- <ModalAlert
- isOpen={props.isOpen}
- type={ModalAlertType.caution}
- buttons={[
- <AppButton.RedButton key="confirm" onClick={props.confirm}>
- {messages.pgettext('vpn-settings-view', 'Add anyway')}
- </AppButton.RedButton>,
- <AppButton.BlueButton key="back" onClick={props.abort}>
- {messages.gettext('Back')}
- </AppButton.BlueButton>,
- ]}
- close={props.abort}
- message={message}></ModalAlert>
- );
-}
diff --git a/gui/src/renderer/components/CustomDnsSettingsStyles.tsx b/gui/src/renderer/components/CustomDnsSettingsStyles.tsx
deleted file mode 100644
index b8fdc7fd38..0000000000
--- a/gui/src/renderer/components/CustomDnsSettingsStyles.tsx
+++ /dev/null
@@ -1,62 +0,0 @@
-import styled from 'styled-components';
-
-import { colors } from '../../config.json';
-import * as Cell from './cell';
-import ImageView from './ImageView';
-
-export const StyledCustomDnsFooter = styled(Cell.CellFooter)({
- marginBottom: '2px',
-});
-
-export const StyledAddCustomDnsButton = styled(Cell.CellButton)({
- backgroundColor: colors.blue40,
-});
-
-export const StyledAddCustomDnsLabel = styled(Cell.Label)<{ $paddingLeft?: number }>((props) => ({
- fontFamily: 'Open Sans',
- fontWeight: 400,
- fontSize: '16px',
- paddingLeft: (props.$paddingLeft ?? 32) + 'px',
- whiteSpace: 'pre-wrap',
- overflowWrap: 'break-word',
- width: '171px',
- marginRight: '25px',
-}));
-
-export const StyledContainer = styled(Cell.Container)({
- display: 'flex',
- backgroundColor: colors.blue40,
-});
-
-export const StyledButton = styled.button({
- display: 'flex',
- alignItems: 'center',
- flex: 1,
- border: 'none',
- background: 'transparent',
- padding: 0,
- margin: 0,
-});
-
-export const StyledLabel = styled(Cell.Label)({
- fontFamily: 'Open Sans',
- fontWeight: 400,
- fontSize: '16px',
- paddingLeft: '32px',
- whiteSpace: 'pre-wrap',
- overflowWrap: 'break-word',
- width: '171px',
- marginRight: '25px',
-});
-
-export const StyledRemoveButton = styled.button({
- background: 'transparent',
- border: 'none',
- padding: 0,
-});
-
-export const StyledRemoveIcon = styled(ImageView)({
- [StyledRemoveButton + ':hover &&']: {
- backgroundColor: colors.white80,
- },
-});
diff --git a/gui/src/renderer/components/CustomScrollbars.tsx b/gui/src/renderer/components/CustomScrollbars.tsx
deleted file mode 100644
index 45e7ad521d..0000000000
--- a/gui/src/renderer/components/CustomScrollbars.tsx
+++ /dev/null
@@ -1,564 +0,0 @@
-import * as React from 'react';
-import styled from 'styled-components';
-
-import { MacOsScrollbarVisibility } from '../../shared/ipc-schema';
-import { Scheduler } from '../../shared/scheduler';
-import { useSelector } from '../redux/store';
-
-const StyledScrollableContent = styled.div({
- display: 'flex',
- flexDirection: 'column',
- minHeight: '100%',
- height: 'max-content',
-});
-
-const StyledCustomScrollbars = styled.div({
- display: 'flex',
- flexDirection: 'column',
- position: 'relative',
- overflow: 'hidden',
-});
-
-const StyledScrollable = styled.div<{ $fillContainer?: boolean }>((props) => ({
- flex: props.$fillContainer ? '1' : undefined,
- width: '100%',
- overflow: 'auto',
- '&&::-webkit-scrollbar': {
- display: 'none',
- },
-}));
-
-const StyledTrack = styled.div<{ $canScroll: boolean; $show: boolean }>((props) => ({
- position: 'absolute',
- top: 0,
- right: 0,
- bottom: 0,
- width: '16px',
- backgroundColor: props.$show ? 'rgba(0, 0, 0, 0.2)' : 'rgba(0, 0, 0, 0)',
- borderRadius: '8px',
- transition: 'width 0.1s ease-in-out, background-color 0.25s ease-in-out',
- zIndex: 99,
- pointerEvents: props.$canScroll ? 'auto' : 'none',
-}));
-
-const StyledThumb = styled.div<{ $show: boolean; $isDragging: boolean; $wide: boolean }>(
- (props) => ({
- position: 'absolute',
- top: 0,
- right: 0,
- borderRadius: props.$wide ? '6px' : '4px',
- width: props.$wide ? '12px' : '8px',
- transition:
- 'width 0.25s ease-in-out, border-radius 0.25s ease-in-out, height 0.25s ease-in-out, opacity 0.25s ease-in-out, background-color 0.1s ease-in-out',
- opacity: props.$show ? 1 : 0,
- backgroundColor: props.$isDragging ? 'rgba(255, 255, 255, 0.65)' : 'rgba(255, 255, 255, 0.4)',
-
- // Thumb should be less transparent when track is hovered.
- [`${StyledTrack}:hover &&`]: {
- backgroundColor: 'rgba(255, 255, 255, 0.65)',
- },
- }),
-);
-
-const AUTOHIDE_TIMEOUT = 1000;
-
-interface IProps {
- autoHide?: boolean;
- trackPadding?: { x: number; y: number };
- onScroll?: (value: IScrollEvent) => void;
- className?: string;
- fillContainer?: boolean;
- children?: React.ReactNode;
-}
-
-interface IState {
- canScroll: boolean;
- showScrollIndicators: boolean;
- active: boolean;
- isDragging: boolean;
- dragStart: {
- x: number;
- y: number;
- };
-}
-
-export interface IScrollEvent {
- scrollLeft: number;
- scrollTop: number;
-}
-export type ScrollPosition = 'top' | 'bottom' | 'middle';
-
-interface IScrollbarUpdateContext {
- size: boolean;
- position: boolean;
-}
-
-export default React.forwardRef(function CustomScrollbarsContainer(
- props: IProps,
- forwardRef: React.Ref<CustomScrollbars>,
-) {
- const macOsScrollbarVisibility = useSelector(
- (state) => state.userInterface.macOsScrollbarVisibility,
- );
- const autoHide =
- props.autoHide ??
- (window.env.platform === 'darwin' &&
- (macOsScrollbarVisibility === undefined ||
- macOsScrollbarVisibility === MacOsScrollbarVisibility.whenScrolling));
-
- return <CustomScrollbars {...props} autoHide={autoHide} ref={forwardRef} />;
-});
-
-export type CustomScrollbarsRef = CustomScrollbars;
-
-class CustomScrollbars extends React.Component<IProps, IState> {
- public static defaultProps: Partial<IProps> = {
- trackPadding: { x: 2, y: 2 },
- };
-
- public state = {
- canScroll: false,
- showScrollIndicators: true,
- active: false,
- isDragging: false,
- dragStart: { x: 0, y: 0 },
- };
-
- private scrollableRef = React.createRef<HTMLDivElement>();
- private scrollableContentRef = React.createRef<HTMLDivElement>();
- private trackRef = React.createRef<HTMLDivElement>();
- private thumbRef = React.createRef<HTMLDivElement>();
- private autoHideScheduler = new Scheduler();
-
- // Update scrollbar when content grows/shrinks.
- private contentResizeObserver = new ResizeObserver(() => {
- this.updateScrollbarsHelper({ size: true });
- });
-
- public scrollToTop(smooth = false) {
- const scrollable = this.scrollableRef.current;
- scrollable?.scrollTo({ top: 0, behavior: smooth ? 'smooth' : 'auto' });
- }
-
- public scrollTo(x: number, y: number, smooth = false) {
- const scrollable = this.scrollableRef.current;
- scrollable?.scrollTo({ top: y, left: x, behavior: smooth ? 'smooth' : 'auto' });
- }
-
- public scrollToElement(child: HTMLElement, scrollPosition: ScrollPosition) {
- const scrollable = this.scrollableRef.current;
- if (scrollable) {
- // throw if child is not a descendant of scroll view
- if (!scrollable.contains(child)) {
- throw new Error(
- 'Cannot scroll to an element which is not a descendant of CustomScrollbars.',
- );
- }
-
- const scrollTop = this.computeScrollTop(scrollable, child, scrollPosition);
- this.scrollTo(0, scrollTop);
- }
- }
-
- public scrollIntoView(elementRect: DOMRect) {
- const scrollable = this.scrollableRef.current;
- if (scrollable) {
- const scrollableRect = scrollable.getBoundingClientRect();
- // The element position needs to be relative to the parent, not the document
- const elementTop = elementRect.top - scrollableRect.top;
- const bottomOverflow = elementTop + elementRect.height - scrollableRect.height;
-
- let scrollDistance = 0;
- if (elementTop < 0) {
- scrollDistance = elementTop;
- } else if (bottomOverflow > 0) {
- // Prevent the elements top from being scrolled out of the visible area
- scrollDistance = Math.min(bottomOverflow, elementTop);
- }
-
- scrollable.scrollBy({
- top: scrollDistance,
- behavior: 'smooth',
- });
- }
- }
-
- public getScrollPosition(): [number, number] {
- const scroll = this.scrollableRef.current;
- if (scroll) {
- return [scroll.scrollLeft, scroll.scrollTop];
- } else {
- return [0, 0];
- }
- }
-
- public componentDidMount() {
- this.updateScrollbarsHelper({
- position: true,
- size: true,
- });
-
- document.addEventListener('mousemove', this.handleMouseMove);
- document.addEventListener('mouseup', this.handleMouseUp);
-
- // show scroll indicators briefly when mounted
- if (this.props.autoHide) {
- this.startAutoHide();
- }
-
- if (this.scrollableContentRef.current) {
- this.contentResizeObserver.observe(this.scrollableContentRef.current);
- }
- }
-
- public shouldComponentUpdate(nextProps: IProps, nextState: IState) {
- const prevProps = this.props;
- const prevState = this.state;
-
- return (
- prevProps.children !== nextProps.children ||
- prevProps.autoHide !== nextProps.autoHide ||
- prevProps.trackPadding?.x !== nextProps.trackPadding?.x ||
- prevProps.trackPadding?.y !== nextProps.trackPadding?.y ||
- prevState.canScroll !== nextState.canScroll ||
- prevState.showScrollIndicators !== nextState.showScrollIndicators ||
- prevState.isDragging !== nextState.isDragging ||
- prevState.active !== nextState.active
- );
- }
-
- public componentWillUnmount() {
- this.autoHideScheduler.cancel();
-
- document.removeEventListener('mousemove', this.handleMouseMove);
- document.removeEventListener('mouseup', this.handleMouseUp);
-
- if (this.scrollableContentRef.current) {
- this.contentResizeObserver.unobserve(this.scrollableContentRef.current);
- }
- }
-
- public componentDidUpdate() {
- this.updateScrollbarsHelper({
- position: true,
- size: true,
- });
- }
-
- public render() {
- const {
- autoHide: _autoHide,
- trackPadding: _trackPadding,
- onScroll: _onScroll,
- fillContainer,
- children,
- ...otherProps
- } = this.props;
- const showScrollbars = this.state.canScroll && this.state.showScrollIndicators;
-
- return (
- <StyledCustomScrollbars {...otherProps}>
- <StyledTrack
- ref={this.trackRef}
- $show={showScrollbars && this.state.active}
- $canScroll={this.state.canScroll}
- onMouseEnter={this.handleMouseEnter}
- onMouseLeave={this.handleMouseLeave}>
- <StyledThumb
- ref={this.thumbRef}
- $show={showScrollbars}
- $isDragging={this.state.isDragging}
- $wide={this.state.active}
- onMouseDown={this.handleMouseDown}
- />
- </StyledTrack>
- <StyledScrollable
- $fillContainer={fillContainer}
- onScroll={this.onScroll}
- ref={this.scrollableRef}>
- <StyledScrollableContent ref={this.scrollableContentRef}>
- {children}
- </StyledScrollableContent>
- </StyledScrollable>
- </StyledCustomScrollbars>
- );
- }
-
- private onScroll = () => {
- this.updateScrollbarsHelper({ position: true });
-
- if (this.props.autoHide) {
- this.ensureScrollbarsVisible();
-
- // only auto-hide when scrolling with mousewheel
- if (!this.state.isDragging) {
- this.startAutoHide();
- }
- } else {
- // only auto-shrink when scrolling with mousewheel
- if (!this.state.isDragging) {
- this.startAutoShrink();
- }
- }
-
- const scrollView = this.scrollableRef.current;
- if (scrollView && this.props.onScroll) {
- this.props.onScroll({
- scrollLeft: scrollView.scrollLeft,
- scrollTop: scrollView.scrollTop,
- });
- }
- };
-
- private handleMouseEnter = () => {
- this.autoHideScheduler.cancel();
- this.setState({
- showScrollIndicators: true,
- active: true,
- });
- };
-
- private handleMouseLeave = () => {
- // do not hide the scrollbar if user is dragging a thumb but left the track area.
- if (!this.state.isDragging) {
- this.mouseLeaveAction();
- }
- };
-
- private mouseLeaveAction = () => {
- if (this.props.autoHide) {
- this.startAutoHide();
- } else {
- this.startAutoShrink();
- }
- };
-
- private handleMouseDown = (event: React.MouseEvent<HTMLDivElement>) => {
- // initiate dragging when user clicked inside of thumb
- const thumb = this.thumbRef.current;
- if (thumb === event.target || thumb?.contains(event.target as Node)) {
- const cursorPosition = {
- x: event.clientX,
- y: event.clientY,
- };
-
- this.setState({
- isDragging: true,
- dragStart: this.getPointRelativeToElement(thumb, cursorPosition),
- });
- }
- };
-
- private handleMouseUp = (event: MouseEvent) => {
- if (!this.state.isDragging) {
- return;
- }
-
- this.setState({
- isDragging: false,
- });
-
- const track = this.trackRef.current;
- if (track) {
- // Make sure to auto-hide the scrollbar if cursor ended up outside of scroll track
- const cursorPosition = {
- x: event.clientX,
- y: event.clientY,
- };
-
- if (!this.isPointInsideOfElement(track, cursorPosition)) {
- this.mouseLeaveAction();
- }
- }
- };
-
- private handleMouseMove = (event: MouseEvent) => {
- const scrollable = this.scrollableRef.current;
- const thumb = this.thumbRef.current;
-
- const cursorPosition = {
- x: event.clientX,
- y: event.clientY,
- };
-
- if (this.state.isDragging && scrollable && thumb) {
- // the content height of the scroll view
- const scrollHeight = scrollable.scrollHeight;
-
- // the visible height of the scroll view
- const visibleHeight = scrollable.offsetHeight;
-
- // lowest point of scrollTop
- const maxScrollTop = scrollHeight - visibleHeight;
-
- // Map absolute cursor coordinate to point in scroll container
- const pointInScrollContainer = this.getPointRelativeToElement(scrollable, cursorPosition);
-
- // calculate the thumb boundary to make sure that the visual appearance of
- // a thumb at the lowest point matches the bottom of scrollable view
- const thumbBoundary = this.computeTrackLength(scrollable) - thumb.clientHeight;
- const thumbTop =
- pointInScrollContainer.y - this.state.dragStart.y - (this.props.trackPadding?.y ?? 0);
- const newScrollTop = (thumbTop / thumbBoundary) * maxScrollTop;
-
- scrollable.scrollTop = newScrollTop;
- }
- };
-
- private ensureScrollbarsVisible() {
- if (!this.state.showScrollIndicators) {
- this.setState({
- showScrollIndicators: true,
- });
- }
- }
-
- private startAutoHide() {
- this.autoHideScheduler.schedule(() => {
- this.setState({
- showScrollIndicators: false,
- active: false,
- });
- }, AUTOHIDE_TIMEOUT);
- }
-
- private startAutoShrink() {
- this.autoHideScheduler.schedule(() => {
- this.setState({
- active: false,
- });
- }, AUTOHIDE_TIMEOUT);
- }
-
- private isPointInsideOfElement(element: HTMLElement, point: { x: number; y: number }) {
- const rect = element.getBoundingClientRect();
- return (
- point.x >= rect.left && point.x <= rect.right && point.y >= rect.top && point.y <= rect.bottom
- );
- }
-
- private getPointRelativeToElement(element: HTMLElement, point: { x: number; y: number }) {
- const rect = element.getBoundingClientRect();
- return {
- x: point.x - rect.left,
- y: point.y - rect.top,
- };
- }
-
- private computeTrackLength(scrollable: HTMLElement) {
- return scrollable.offsetHeight - (this.props.trackPadding?.y ?? 0) * 2;
- }
-
- // Computes the position of child element within scrollable container
- private computeOffsetTop(scrollable: HTMLElement, child: HTMLElement) {
- let offsetTop = 0;
- let node = child;
-
- while (scrollable.contains(node)) {
- offsetTop += node.offsetTop;
- if (node.offsetParent) {
- node = node.offsetParent as HTMLElement;
- } else {
- break;
- }
- }
-
- return offsetTop;
- }
-
- private computeScrollTop(
- scrollable: HTMLElement,
- child: HTMLElement,
- scrollPosition: ScrollPosition,
- ) {
- const offsetTop = this.computeOffsetTop(scrollable, child);
-
- switch (scrollPosition) {
- case 'top':
- return offsetTop;
-
- case 'bottom':
- return offsetTop - (scrollable.offsetHeight - child.clientHeight);
-
- case 'middle':
- return offsetTop - (scrollable.offsetHeight - child.clientHeight) * 0.5;
- }
- }
-
- private computeThumbPosition(scrollable: HTMLElement, thumb: HTMLElement) {
- // the content height of the scroll view
- const scrollHeight = scrollable.scrollHeight;
-
- // the visible height of the scroll view
- const visibleHeight = scrollable.offsetHeight;
-
- // scroll offset
- const scrollTop = scrollable.scrollTop;
-
- // lowest point of scrollTop
- const maxScrollTop = scrollHeight - visibleHeight;
-
- // calculate scroll position within 0..1 range
- const scrollPosition = scrollHeight > 0 ? scrollTop / maxScrollTop : 0;
-
- // calculate the thumb boundary to make sure that the visual appearance of
- // a thumb at the lowest point matches the bottom of scrollable view
- const thumbBoundary = this.computeTrackLength(scrollable) - thumb.clientHeight;
-
- // calculate thumb position based on scroll progress and thumb boundary
- // adding vertical inset to adjust the thumb's appearance
- const thumbPosition = thumbBoundary * scrollPosition + (this.props.trackPadding?.y ?? 0);
-
- return {
- x: -(this.props.trackPadding?.x ?? 0),
- y: thumbPosition,
- };
- }
-
- private computeThumbHeight(scrollable: HTMLElement) {
- const scrollHeight = scrollable.scrollHeight;
- const visibleHeight = scrollable.offsetHeight;
-
- const thumbHeight = (visibleHeight / scrollHeight) * visibleHeight;
-
- // ensure that the scroll thumb doesn't shrink to nano size
- return Math.max(thumbHeight, 8);
- }
-
- private updateScrollbarsHelper(updateFlags: Partial<IScrollbarUpdateContext>) {
- const scrollable = this.scrollableRef.current;
- const thumb = this.thumbRef.current;
- if (scrollable && thumb) {
- this.updateScrollbars(scrollable, thumb, updateFlags);
- }
- }
-
- private updateScrollbars(
- scrollable: HTMLElement,
- thumb: HTMLElement,
- context: Partial<IScrollbarUpdateContext>,
- ) {
- if (context.size) {
- const thumbHeight = this.computeThumbHeight(scrollable);
- thumb.style.setProperty('height', thumbHeight + 'px');
-
- // hide thumb when there is nothing to scroll. We've had issues with scrollHeight being
- // off-by-one, to ensure this doesn't happen we subtract 1 here.
- const canScroll = thumbHeight < scrollable.offsetHeight - 1;
- if (this.state.canScroll !== canScroll) {
- this.setState({ canScroll });
-
- // flash the scroll indicators when the view becomes scrollable
- if (this.props.autoHide && canScroll) {
- this.startAutoHide();
- this.ensureScrollbarsVisible();
- }
- }
- }
-
- if (context.position) {
- const { x, y } = this.computeThumbPosition(scrollable, thumb);
- thumb.style.setProperty('transform', `translate(${x}px, ${y}px)`);
- }
- }
-}
diff --git a/gui/src/renderer/components/DaitaSettings.tsx b/gui/src/renderer/components/DaitaSettings.tsx
deleted file mode 100644
index bcfca03316..0000000000
--- a/gui/src/renderer/components/DaitaSettings.tsx
+++ /dev/null
@@ -1,270 +0,0 @@
-import React, { useCallback } from 'react';
-import { sprintf } from 'sprintf-js';
-import styled from 'styled-components';
-
-import { strings } from '../../config.json';
-import { messages } from '../../shared/gettext';
-import { useAppContext } from '../context';
-import { useHistory } from '../lib/history';
-import { useBoolean } from '../lib/utility-hooks';
-import { useSelector } from '../redux/store';
-import { AriaDescription, AriaInput, AriaInputGroup, AriaLabel } from './AriaGroup';
-import * as Cell from './cell';
-import InfoButton from './InfoButton';
-import { BackAction } from './KeyboardNavigation';
-import { Layout, SettingsContainer } from './Layout';
-import { ModalAlert, ModalAlertType, ModalMessage } from './Modal';
-import {
- NavigationBar,
- NavigationContainer,
- NavigationItems,
- NavigationScrollbars,
- TitleBarItem,
-} from './NavigationBar';
-import PageSlider from './PageSlider';
-import SettingsHeader, { HeaderSubTitle, HeaderTitle } from './SettingsHeader';
-import { SmallButton, SmallButtonColor } from './SmallButton';
-
-const StyledContent = styled.div({
- display: 'flex',
- flexDirection: 'column',
- flex: 1,
- marginBottom: '2px',
-});
-
-const StyledHeaderSubTitle = styled(HeaderSubTitle)({
- display: 'inline-block',
- fontWeight: 400,
-
- '&&:not(:last-child)': {
- paddingBottom: '18px',
- },
-});
-
-export const StyledIllustration = styled.img({
- width: '100%',
- padding: '8px 0 8px',
-});
-
-export default function DaitaSettings() {
- const { pop } = useHistory();
-
- return (
- <BackAction action={pop}>
- <Layout>
- <SettingsContainer>
- <NavigationContainer>
- <NavigationBar>
- <NavigationItems>
- <TitleBarItem>{strings.daita}</TitleBarItem>
- </NavigationItems>
- </NavigationBar>
-
- <NavigationScrollbars>
- <SettingsHeader>
- <HeaderTitle>{strings.daita}</HeaderTitle>
- <PageSlider
- content={[
- <React.Fragment key="without-daita">
- <StyledIllustration src="../../assets/images/daita-off-illustration.svg" />
- <StyledHeaderSubTitle>
- {sprintf(
- messages.pgettext(
- 'wireguard-settings-view',
- '%(daita)s (%(daitaFull)s) hides patterns in your encrypted VPN traffic.',
- ),
- { daita: strings.daita, daitaFull: strings.daitaFull },
- )}
- </StyledHeaderSubTitle>
- <StyledHeaderSubTitle>
- {messages.pgettext(
- 'wireguard-settings-view',
- 'By using sophisticated AI it’s possible to analyze the traffic of data packets going in and out of your device (even if the traffic is encrypted).',
- )}
- </StyledHeaderSubTitle>
- <StyledHeaderSubTitle>
- {sprintf(
- messages.pgettext(
- 'wireguard-settings-view',
- 'If an observer monitors these data packets, %(daita)s makes it significantly harder for them to identify which websites you are visiting or with whom you are communicating.',
- ),
- { daita: strings.daita },
- )}
- </StyledHeaderSubTitle>
- </React.Fragment>,
- <React.Fragment key="with-daita">
- <StyledIllustration src="../../assets/images/daita-on-illustration.svg" />
- <StyledHeaderSubTitle>
- {sprintf(
- messages.pgettext(
- 'wireguard-settings-view',
- '%(daita)s does this by carefully adding network noise and making all network packets the same size.',
- ),
- { daita: strings.daita },
- )}
- </StyledHeaderSubTitle>
- <StyledHeaderSubTitle>
- {sprintf(
- messages.pgettext(
- 'wireguard-settings-view',
- 'Not all our servers are %(daita)s-enabled. Therefore, we use multihop automatically to enable %(daita)s with any server.',
- ),
- { daita: strings.daita },
- )}
- </StyledHeaderSubTitle>
- <StyledHeaderSubTitle>
- {sprintf(
- messages.pgettext(
- 'wireguard-settings-view',
- 'Attention: Be cautious if you have a limited data plan as this feature will increase your network traffic. This feature can only be used with %(wireguard)s.',
- ),
- { wireguard: strings.wireguard },
- )}
- </StyledHeaderSubTitle>
- </React.Fragment>,
- ]}
- />
- </SettingsHeader>
-
- <StyledContent>
- <Cell.Group>
- <DaitaToggle />
- </Cell.Group>
- </StyledContent>
- </NavigationScrollbars>
- </NavigationContainer>
- </SettingsContainer>
- </Layout>
- </BackAction>
- );
-}
-
-function DaitaToggle() {
- const { setEnableDaita, setDaitaDirectOnly } = useAppContext();
- const relaySettings = useSelector((state) => state.settings.relaySettings);
- const daita = useSelector((state) => state.settings.wireguard.daita?.enabled ?? false);
- const directOnly = useSelector((state) => state.settings.wireguard.daita?.directOnly ?? false);
-
- const [confirmationDialogVisible, showConfirmationDialog, hideConfirmationDialog] = useBoolean();
-
- const unavailable =
- 'normal' in relaySettings ? relaySettings.normal.tunnelProtocol === 'openvpn' : true;
-
- const setDaita = useCallback(
- (value: boolean) => {
- void setEnableDaita(value);
- },
- [setEnableDaita],
- );
-
- const setDirectOnly = useCallback(
- (value: boolean) => {
- if (value) {
- showConfirmationDialog();
- } else {
- void setDaitaDirectOnly(value);
- }
- },
- [setDaitaDirectOnly, showConfirmationDialog],
- );
-
- const confirmEnableDirectOnly = useCallback(() => {
- void setDaitaDirectOnly(true);
- hideConfirmationDialog();
- }, [hideConfirmationDialog, setDaitaDirectOnly]);
-
- const directOnlyString = messages.gettext('Direct only');
-
- return (
- <>
- <AriaInputGroup>
- <Cell.Container disabled={unavailable}>
- <AriaLabel>
- <Cell.InputLabel>{messages.gettext('Enable')}</Cell.InputLabel>
- </AriaLabel>
- <AriaInput>
- <Cell.Switch isOn={daita && !unavailable} onChange={setDaita} />
- </AriaInput>
- </Cell.Container>
- </AriaInputGroup>
- <AriaInputGroup>
- <Cell.Container disabled={!daita || unavailable}>
- <AriaLabel>
- <Cell.InputLabel>{directOnlyString}</Cell.InputLabel>
- </AriaLabel>
- <InfoButton>
- <DirectOnlyModalMessage />
- </InfoButton>
- <AriaInput>
- <Cell.Switch isOn={directOnly && !unavailable} onChange={setDirectOnly} />
- </AriaInput>
- </Cell.Container>
- {unavailable ? (
- <Cell.CellFooter>
- <AriaDescription>
- <Cell.CellFooterText>{featureUnavailableMessage()}</Cell.CellFooterText>
- </AriaDescription>
- </Cell.CellFooter>
- ) : null}
- </AriaInputGroup>
- <ModalAlert
- isOpen={confirmationDialogVisible}
- type={ModalAlertType.caution}
- gridButtons={[
- <SmallButton
- key="confirm"
- onClick={confirmEnableDirectOnly}
- color={SmallButtonColor.blue}>
- {sprintf(messages.gettext('Enable "%(directOnly)s"'), { directOnly: directOnlyString })}
- </SmallButton>,
- <SmallButton key="cancel" onClick={hideConfirmationDialog} color={SmallButtonColor.blue}>
- {messages.pgettext('wireguard-settings-view', 'Cancel')}
- </SmallButton>,
- ]}
- close={hideConfirmationDialog}>
- <ModalMessage>
- {sprintf(
- // TRANSLATORS: Warning text in a dialog that is displayed after a setting is toggled.
- messages.pgettext(
- 'wireguard-settings-view',
- 'Not all our servers are %(daita)s-enabled. In order to use the internet, you might have to select a new location after enabling.',
- ),
- { daita: strings.daita },
- )}
- </ModalMessage>
- </ModalAlert>
- </>
- );
-}
-
-function DirectOnlyModalMessage() {
- const directOnlyString = messages.gettext('Direct only');
-
- return (
- <ModalMessage>
- {sprintf(
- messages.pgettext(
- 'wireguard-settings-view',
- 'By enabling “%(directOnly)s” you will have to manually select a server that is %(daita)s-enabled. This can cause you to end up in a blocked state until you have selected a compatible server in the “Select location” view.',
- ),
- {
- daita: strings.daita,
- directOnly: directOnlyString,
- },
- )}
- </ModalMessage>
- );
-}
-
-function featureUnavailableMessage() {
- const automatic = messages.gettext('Automatic');
- const tunnelProtocol = messages.pgettext('vpn-settings-view', 'Tunnel protocol');
-
- return sprintf(
- messages.pgettext(
- 'wireguard-settings-view',
- 'Switch to “%(wireguard)s” or “%(automatic)s” in Settings > %(tunnelProtocol)s to make %(setting)s available.',
- ),
- { wireguard: strings.wireguard, automatic, tunnelProtocol, setting: strings.daita },
- );
-}
diff --git a/gui/src/renderer/components/Debug.tsx b/gui/src/renderer/components/Debug.tsx
deleted file mode 100644
index 58e446be47..0000000000
--- a/gui/src/renderer/components/Debug.tsx
+++ /dev/null
@@ -1,90 +0,0 @@
-import { useCallback } from 'react';
-import styled from 'styled-components';
-
-import { useHistory } from '../lib/history';
-import { useBoolean } from '../lib/utility-hooks';
-import * as AppButton from './AppButton';
-import { measurements } from './common-styles';
-import { BackAction } from './KeyboardNavigation';
-import { Layout, SettingsContainer } from './Layout';
-import {
- NavigationBar,
- NavigationContainer,
- NavigationItems,
- NavigationScrollbars,
- TitleBarItem,
-} from './NavigationBar';
-import SettingsHeader, { HeaderTitle } from './SettingsHeader';
-
-const StyledContent = styled.div({
- display: 'flex',
- flexDirection: 'column',
- flex: 1,
- marginBottom: '2px',
-});
-
-const StyledButtonGroup = styled.div({
- margin: measurements.viewMargin,
-});
-
-export default function Debug() {
- const { pop } = useHistory();
-
- return (
- <BackAction action={pop}>
- <Layout>
- <SettingsContainer>
- <NavigationContainer>
- <NavigationBar>
- <NavigationItems>
- <TitleBarItem>Developer tools</TitleBarItem>
- </NavigationItems>
- </NavigationBar>
-
- <NavigationScrollbars>
- <SettingsHeader>
- <HeaderTitle>Developer tools</HeaderTitle>
- </SettingsHeader>
-
- <StyledContent>
- <StyledButtonGroup>
- <AppButton.ButtonGroup>
- <ThrowErrorButton />
- <UnhandledRejectionButton />
- <ErrorDuringRender />
- </AppButton.ButtonGroup>
- </StyledButtonGroup>
- </StyledContent>
- </NavigationScrollbars>
- </NavigationContainer>
- </SettingsContainer>
- </Layout>
- </BackAction>
- );
-}
-
-function ThrowErrorButton() {
- const handleClick = useCallback(() => {
- throw new Error('This is a test error');
- }, []);
-
- return <AppButton.RedButton onClick={handleClick}>Throw error</AppButton.RedButton>;
-}
-
-function UnhandledRejectionButton() {
- const handleClick = useCallback(() => {
- return new Promise((_resolve, reject) => setTimeout(reject, 100));
- }, []);
-
- return <AppButton.RedButton onClick={handleClick}>Unhandled rejection</AppButton.RedButton>;
-}
-
-function ErrorDuringRender() {
- const [error, setError] = useBoolean(false);
-
- if (error) {
- throw new Error('This is a test error during render');
- }
-
- return <AppButton.RedButton onClick={setError}>Error next render</AppButton.RedButton>;
-}
diff --git a/gui/src/renderer/components/DeviceInfoButton.tsx b/gui/src/renderer/components/DeviceInfoButton.tsx
deleted file mode 100644
index 090bb9c8df..0000000000
--- a/gui/src/renderer/components/DeviceInfoButton.tsx
+++ /dev/null
@@ -1,57 +0,0 @@
-import styled from 'styled-components';
-
-import { messages } from '../../shared/gettext';
-import { useBoolean } from '../lib/utility-hooks';
-import * as AppButton from './AppButton';
-import { InfoIcon } from './InfoButton';
-import { ModalAlert, ModalAlertType, ModalMessage } from './Modal';
-
-const StyledInfoButton = styled.button({
- margin: '0 0 0 10px',
- borderWidth: 0,
- padding: 0,
- cursor: 'default',
- backgroundColor: 'transparent',
-});
-
-export default function DeviceInfoButton() {
- const [deviceHelpVisible, showDeviceHelp, hideDeviceHelp] = useBoolean();
-
- return (
- <>
- <StyledInfoButton
- onClick={showDeviceHelp}
- aria-label={messages.pgettext('accessibility', 'More information')}>
- <InfoIcon size={16} />
- </StyledInfoButton>
- <ModalAlert
- isOpen={deviceHelpVisible}
- type={ModalAlertType.info}
- buttons={[
- <AppButton.BlueButton key="back" onClick={hideDeviceHelp}>
- {messages.gettext('Got it!')}
- </AppButton.BlueButton>,
- ]}
- close={hideDeviceHelp}>
- <ModalMessage>
- {messages.pgettext(
- 'device-management',
- 'This is the name assigned to the device. Each device logged in on a Mullvad account gets a unique name that helps you identify it when you manage your devices in the app or on the website.',
- )}
- </ModalMessage>
- <ModalMessage>
- {messages.pgettext(
- 'device-management',
- 'You can have up to 5 devices logged in on one Mullvad account.',
- )}
- </ModalMessage>
- <ModalMessage>
- {messages.pgettext(
- 'device-management',
- 'If you log out, the device and the device name is removed. When you log back in again, the device will get a new name.',
- )}
- </ModalMessage>
- </ModalAlert>
- </>
- );
-}
diff --git a/gui/src/renderer/components/DeviceRevokedView.tsx b/gui/src/renderer/components/DeviceRevokedView.tsx
deleted file mode 100644
index dd6abbf376..0000000000
--- a/gui/src/renderer/components/DeviceRevokedView.tsx
+++ /dev/null
@@ -1,96 +0,0 @@
-import styled from 'styled-components';
-
-import { colors } from '../../config.json';
-import { messages } from '../../shared/gettext';
-import { useAppContext } from '../context';
-import { useSelector } from '../redux/store';
-import * as AppButton from './AppButton';
-import { bigText, measurements, smallText } from './common-styles';
-import CustomScrollbars from './CustomScrollbars';
-import { calculateHeaderBarStyle, DefaultHeaderBar } from './HeaderBar';
-import ImageView from './ImageView';
-import { Container, Footer } from './Layout';
-import { Layout } from './Layout';
-
-export const StyledHeader = styled(DefaultHeaderBar)({
- flex: 0,
-});
-
-export const StyledCustomScrollbars = styled(CustomScrollbars)({
- flex: 1,
-});
-
-export const StyledContainer = styled(Container)({
- paddingTop: '22px',
- minHeight: '100%',
- backgroundColor: colors.darkBlue,
-});
-
-export const StyledBody = styled.div({
- display: 'flex',
- flexDirection: 'column',
- flex: 1,
- padding: `0 ${measurements.viewMargin}`,
-});
-
-export const StyledStatusIcon = styled.div({
- alignSelf: 'center',
- width: '60px',
- height: '60px',
- marginBottom: '18px',
-});
-
-export const StyledTitle = styled.span(bigText, {
- lineHeight: '38px',
- marginBottom: '8px',
- color: colors.white,
-});
-
-export const StyledMessage = styled.span(smallText, {
- marginBottom: measurements.rowVerticalMargin,
- color: colors.white,
-});
-
-export function DeviceRevokedView() {
- const { leaveRevokedDevice } = useAppContext();
- const tunnelState = useSelector((state) => state.connection.status);
-
- const Button = tunnelState.state === 'disconnected' ? AppButton.BlueButton : AppButton.RedButton;
-
- return (
- <Layout>
- <StyledHeader barStyle={calculateHeaderBarStyle(tunnelState)} />
- <StyledCustomScrollbars fillContainer>
- <StyledContainer>
- <StyledBody>
- <StyledStatusIcon>
- <ImageView source="icon-fail" height={60} width={60} />
- </StyledStatusIcon>
- <StyledTitle data-testid="title">
- {messages.pgettext('device-management', 'Device is inactive')}
- </StyledTitle>
- <StyledMessage>
- {messages.pgettext(
- 'device-management',
- 'You have removed this device. To connect again, you will need to log back in.',
- )}
- </StyledMessage>
- <StyledMessage>
- {tunnelState.state !== 'disconnected' &&
- messages.pgettext(
- 'device-management',
- 'Going to login will unblock the Internet on this device.',
- )}
- </StyledMessage>
- </StyledBody>
-
- <Footer>
- <Button onClick={leaveRevokedDevice}>
- {messages.pgettext('device-management', 'Go to login')}
- </Button>
- </Footer>
- </StyledContainer>
- </StyledCustomScrollbars>
- </Layout>
- );
-}
diff --git a/gui/src/renderer/components/EditApiAccessMethod.tsx b/gui/src/renderer/components/EditApiAccessMethod.tsx
deleted file mode 100644
index 8a11404b1c..0000000000
--- a/gui/src/renderer/components/EditApiAccessMethod.tsx
+++ /dev/null
@@ -1,249 +0,0 @@
-import { useCallback, useState } from 'react';
-import { useParams } from 'react-router';
-import { sprintf } from 'sprintf-js';
-
-import {
- CustomProxy,
- NamedCustomProxy,
- NewAccessMethodSetting,
-} from '../../shared/daemon-rpc-types';
-import { messages } from '../../shared/gettext';
-import { useScheduler } from '../../shared/scheduler';
-import { useAppContext } from '../context';
-import { useApiAccessMethodTest } from '../lib/api-access-methods';
-import { useHistory } from '../lib/history';
-import { useLastDefinedValue } from '../lib/utility-hooks';
-import { useSelector } from '../redux/store';
-import { SettingsForm } from './cell/SettingsForm';
-import { BackAction } from './KeyboardNavigation';
-import { Layout, SettingsContainer } from './Layout';
-import { ModalAlert, ModalAlertType } from './Modal';
-import { NavigationBar, NavigationContainer, NavigationItems, TitleBarItem } from './NavigationBar';
-import { NamedProxyForm } from './ProxyForm';
-import SettingsHeader, { HeaderSubTitle, HeaderTitle } from './SettingsHeader';
-import { StyledContent, StyledNavigationScrollbars, StyledSettingsContent } from './SettingsStyles';
-import { SmallButton } from './SmallButton';
-
-export function EditApiAccessMethod() {
- return (
- <SettingsForm>
- <AccessMethodForm></AccessMethodForm>
- </SettingsForm>
- );
-}
-
-function AccessMethodForm() {
- const { pop } = useHistory();
- const { addApiAccessMethod, updateApiAccessMethod } = useAppContext();
- const methods = useSelector((state) => state.settings.apiAccessMethods.custom);
-
- const [testing, testResult, testApiAccessMethod, resetTestResult] = useApiAccessMethodTest(
- false,
- 500,
- );
- const saveScheduler = useScheduler();
-
- // Use id in url to figure out which method is to be edited. undefined means this is a new method.
- const { id } = useParams<{ id: string | undefined }>();
- const method = methods.find((method) => method.id === id);
-
- const [updatedMethod, setUpdatedMethod] = useState<
- NewAccessMethodSetting<CustomProxy> | undefined
- >(method);
-
- const save = useCallback(
- (method: NewAccessMethodSetting<CustomProxy>) => {
- if (method !== undefined) {
- resetTestResult();
- if (id === undefined) {
- void addApiAccessMethod(method);
- } else {
- void updateApiAccessMethod({ ...method, id });
- }
- pop();
- }
- },
- [resetTestResult, id, pop, addApiAccessMethod, updateApiAccessMethod],
- );
-
- const onSave = useCallback(
- async (newMethod: NamedCustomProxy) => {
- const enabled = id === undefined ? true : (method?.enabled ?? true);
- const updatedMethod = { ...newMethod, enabled };
- setUpdatedMethod(updatedMethod);
- if (
- updatedMethod !== undefined &&
- (await testApiAccessMethod(updatedMethod as CustomProxy))
- ) {
- // Hide the save dialog after 1.5 seconds.
- saveScheduler.schedule(() => save(updatedMethod), 1500);
- }
- },
- [id, method?.enabled, testApiAccessMethod, saveScheduler, save],
- );
-
- const handleDialogSave = useCallback(() => {
- if (updatedMethod !== undefined) {
- save(updatedMethod);
- }
- }, [save, updatedMethod]);
-
- const title = getTitle(id === undefined);
- const subtitle = getSubtitle(id === undefined);
-
- return (
- <BackAction action={pop}>
- <Layout>
- <SettingsContainer>
- <NavigationContainer>
- <NavigationBar>
- <NavigationItems>
- <TitleBarItem>{title}</TitleBarItem>
- </NavigationItems>
- </NavigationBar>
-
- <StyledNavigationScrollbars fillContainer>
- <StyledContent>
- <SettingsHeader>
- <HeaderTitle>{title}</HeaderTitle>
- <HeaderSubTitle>{subtitle}</HeaderSubTitle>
- </SettingsHeader>
-
- <StyledSettingsContent>
- {id !== undefined && method === undefined ? (
- <span>Failed to open method</span>
- ) : (
- <NamedProxyForm proxy={method} onSave={onSave} onCancel={pop} />
- )}
- </StyledSettingsContent>
-
- <TestingDialog
- name={updatedMethod?.name ?? ''}
- newMethod={id === undefined}
- testing={testing}
- testResult={testResult}
- cancel={resetTestResult}
- save={handleDialogSave}
- />
- </StyledContent>
- </StyledNavigationScrollbars>
- </NavigationContainer>
- </SettingsContainer>
- </Layout>
- </BackAction>
- );
-}
-
-function getTitle(isNewMethod: boolean) {
- return isNewMethod
- ? messages.pgettext('api-access-methods-view', 'Add method')
- : messages.pgettext('api-access-methods-view', 'Edit method');
-}
-
-function getSubtitle(isNewMethod: boolean) {
- return isNewMethod
- ? messages.pgettext('api-access-methods-view', 'Adding a new API access method also tests it.')
- : messages.pgettext('api-access-methods-view', 'Editing an API access method also tests it.');
-}
-
-interface TestingDialogProps {
- name: string;
- newMethod: boolean;
- testing: boolean;
- testResult?: boolean;
- cancel: () => void;
- save: () => void;
-}
-
-function TestingDialog(props: TestingDialogProps) {
- let currentType: ModalAlertType | undefined;
- if (props.testing) {
- currentType = ModalAlertType.loading;
- } else if (props.testResult) {
- currentType = ModalAlertType.success;
- } else if (props.testResult === false) {
- currentType = ModalAlertType.failure;
- }
-
- const type = useLastDefinedValue(currentType);
- const displayType = type ?? ModalAlertType.failure;
-
- return (
- <ModalAlert
- isOpen={!!currentType}
- type={type}
- gridButtons={getTestingDialogButtons(displayType, props.save, props.cancel)}
- close={props.cancel}
- title={getTestingDialogTitle(displayType, props.newMethod)}
- message={getTestingDialogSubTitle(displayType, props.newMethod, props.name)}
- />
- );
-}
-
-function getTestingDialogTitle(type: ModalAlertType, newMethod: boolean) {
- switch (type) {
- case ModalAlertType.success:
- return newMethod
- ? messages.pgettext('api-access-methods-view', 'API reachable, adding method…')
- : messages.pgettext('api-access-methods-view', 'API reachable, saving method…');
- case ModalAlertType.failure:
- return newMethod
- ? messages.pgettext('api-access-methods-view', 'API unreachable, add anyway?')
- : messages.pgettext('api-access-methods-view', 'API unreachable, save anyway?');
- default:
- case ModalAlertType.loading:
- return messages.pgettext('api-access-methods-view', 'Testing method...');
- }
-}
-
-function getTestingDialogSubTitle(type: ModalAlertType, newMethod: boolean, name: string) {
- switch (type) {
- case ModalAlertType.failure:
- return newMethod
- ? sprintf(
- messages.pgettext(
- 'api-access-methods-view',
- 'The API could not be reached using the %(name)s method.',
- ),
- { name },
- )
- : sprintf(
- // TRANSLATORS: %(save)s - Will be replaced with the translation for the word "Save".
- messages.pgettext(
- 'api-access-methods-view',
- 'Clicking “%(save)s” changes the in use method.',
- ),
- { save: messages.gettext('Save') },
- );
- default:
- return undefined;
- }
-}
-
-function getTestingDialogButtons(type: ModalAlertType, save: () => void, cancel: () => void) {
- const saveButton = (
- <SmallButton key="confirm" onClick={save}>
- {messages.gettext('Save')}
- </SmallButton>
- );
- const cancelButton = (
- <SmallButton key="cancel" onClick={cancel}>
- {messages.gettext('Cancel')}
- </SmallButton>
- );
- const disabledCancelButton = (
- <SmallButton key="cancel" onClick={cancel} disabled>
- {messages.gettext('Cancel')}
- </SmallButton>
- );
-
- switch (type) {
- case ModalAlertType.success:
- return [disabledCancelButton];
- case ModalAlertType.failure:
- return [cancelButton, saveButton];
- case ModalAlertType.loading:
- default:
- return [cancelButton];
- }
-}
diff --git a/gui/src/renderer/components/EditCustomBridge.tsx b/gui/src/renderer/components/EditCustomBridge.tsx
deleted file mode 100644
index 7a0ad6f8d8..0000000000
--- a/gui/src/renderer/components/EditCustomBridge.tsx
+++ /dev/null
@@ -1,120 +0,0 @@
-import { useCallback } from 'react';
-
-import { CustomProxy } from '../../shared/daemon-rpc-types';
-import { messages } from '../../shared/gettext';
-import { useBridgeSettingsUpdater } from '../lib/constraint-updater';
-import { useHistory } from '../lib/history';
-import { useBoolean } from '../lib/utility-hooks';
-import { useSelector } from '../redux/store';
-import { SettingsForm } from './cell/SettingsForm';
-import { BackAction } from './KeyboardNavigation';
-import { Layout, SettingsContainer } from './Layout';
-import { ModalAlert, ModalAlertType } from './Modal';
-import { NavigationBar, NavigationContainer, NavigationItems, TitleBarItem } from './NavigationBar';
-import { ProxyForm } from './ProxyForm';
-import SettingsHeader, { HeaderTitle } from './SettingsHeader';
-import { StyledContent, StyledNavigationScrollbars, StyledSettingsContent } from './SettingsStyles';
-import { SmallButton, SmallButtonColor } from './SmallButton';
-
-export function EditCustomBridge() {
- return (
- <SettingsForm>
- <CustomBridgeForm />
- </SettingsForm>
- );
-}
-
-function CustomBridgeForm() {
- const { pop } = useHistory();
- const bridgeSettingsUpdater = useBridgeSettingsUpdater();
- const bridgeSettings = useSelector((state) => state.settings.bridgeSettings);
-
- const [deleteDialogVisible, showDeleteDialog, hideDeleteDialog] = useBoolean();
-
- // If there are no custom bridge settings, we should prompt the user to add a custom bridge.
- // Otherwise, we should prompt them to edit the existing custom bridge settings.
- const title =
- bridgeSettings.custom === undefined
- ? messages.pgettext('custom-bridge', 'Add custom bridge')
- : messages.pgettext('custom-bridge', 'Edit custom bridge');
-
- const onSave = useCallback(
- (newBridge: CustomProxy) => {
- void bridgeSettingsUpdater((bridgeSettings) => {
- bridgeSettings.type = 'custom';
- bridgeSettings.custom = newBridge;
- return bridgeSettings;
- });
- pop();
- },
- [bridgeSettingsUpdater, pop],
- );
-
- const onDelete = useCallback(() => {
- if (bridgeSettings.custom !== undefined) {
- hideDeleteDialog();
- void bridgeSettingsUpdater((bridgeSettings) => {
- bridgeSettings.type = 'normal';
- delete bridgeSettings.custom;
- return bridgeSettings;
- });
- pop();
- }
- }, [bridgeSettings.custom, bridgeSettingsUpdater, hideDeleteDialog, pop]);
-
- return (
- <BackAction action={pop}>
- <Layout>
- <SettingsContainer>
- <NavigationContainer>
- <NavigationBar>
- <NavigationItems>
- <TitleBarItem>{title}</TitleBarItem>
- </NavigationItems>
- </NavigationBar>
-
- <StyledNavigationScrollbars fillContainer>
- <StyledContent>
- <SettingsHeader>
- <HeaderTitle>{title}</HeaderTitle>
- </SettingsHeader>
-
- <StyledSettingsContent>
- <ProxyForm
- proxy={bridgeSettings.custom}
- onSave={onSave}
- onCancel={pop}
- onDelete={bridgeSettings.custom === undefined ? undefined : showDeleteDialog}
- />
- </StyledSettingsContent>
-
- <ModalAlert
- isOpen={deleteDialogVisible}
- type={ModalAlertType.warning}
- gridButtons={[
- <SmallButton key="cancel" onClick={hideDeleteDialog}>
- {messages.gettext('Cancel')}
- </SmallButton>,
- <SmallButton
- key="delete"
- color={SmallButtonColor.red}
- onClick={onDelete}
- data-testid="delete-confirm">
- {messages.gettext('Delete')}
- </SmallButton>,
- ]}
- close={hideDeleteDialog}
- title={messages.pgettext('custom-bridge', 'Delete custom bridge?')}
- message={messages.pgettext(
- 'custom-bridge',
- 'Deleting the custom bridge will take you back to the select location view and the Automatic option will be selected instead.',
- )}
- />
- </StyledContent>
- </StyledNavigationScrollbars>
- </NavigationContainer>
- </SettingsContainer>
- </Layout>
- </BackAction>
- );
-}
diff --git a/gui/src/renderer/components/ErrorBoundary.tsx b/gui/src/renderer/components/ErrorBoundary.tsx
deleted file mode 100644
index 40615e44f4..0000000000
--- a/gui/src/renderer/components/ErrorBoundary.tsx
+++ /dev/null
@@ -1,50 +0,0 @@
-import React from 'react';
-import styled from 'styled-components';
-
-import { supportEmail } from '../../config.json';
-import { messages } from '../../shared/gettext';
-import log from '../../shared/logging';
-import ErrorView from './ErrorView';
-
-interface IProps {
- children?: React.ReactNode;
-}
-
-interface IState {
- hasError: boolean;
-}
-
-const Email = styled.span({
- fontWeight: 900,
-});
-
-export default class ErrorBoundary extends React.Component<IProps, IState> {
- public state = { hasError: false };
-
- public componentDidCatch(error: Error, info: React.ErrorInfo) {
- this.setState({ hasError: true });
-
- log.error(
- `The error boundary caught an error: ${error.message}\nError stack: ${
- error.stack || 'Not available'
- }\nComponent stack: ${info.componentStack}`,
- );
- }
-
- public render() {
- if (this.state.hasError) {
- const reachBackMessage: React.ReactNode[] =
- // TRANSLATORS: The message displayed to the user in case of critical error in the GUI
- // TRANSLATORS: Available placeholders:
- // TRANSLATORS: %(email)s - support email
- messages
- .pgettext('error-boundary-view', 'Something went wrong. Please contact us at %(email)s')
- .split('%(email)s', 2);
- reachBackMessage.splice(1, 0, <Email>{supportEmail}</Email>);
-
- return <ErrorView settingsUnavailable>{reachBackMessage}</ErrorView>;
- } else {
- return this.props.children;
- }
- }
-}
diff --git a/gui/src/renderer/components/ErrorView.tsx b/gui/src/renderer/components/ErrorView.tsx
deleted file mode 100644
index fead788c24..0000000000
--- a/gui/src/renderer/components/ErrorView.tsx
+++ /dev/null
@@ -1,70 +0,0 @@
-import React from 'react';
-import styled from 'styled-components';
-
-import { colors } from '../../config.json';
-import { measurements } from './common-styles';
-import { HeaderBarSettingsButton } from './HeaderBar';
-import ImageView from './ImageView';
-import { Container, Header, Layout } from './Layout';
-
-const StyledContainer = styled(Container)({
- flex: 1,
- flexDirection: 'column',
- alignItems: 'center',
- justifyContent: 'end',
-});
-
-const StyledContent = styled.div({
- display: 'flex',
- flex: 1,
- flexDirection: 'column',
- alignItems: 'center',
- justifyContent: 'end',
-});
-
-const Logo = styled(ImageView)({
- marginBottom: '12px',
-});
-
-const Title = styled(ImageView)({
- opacity: 0.6,
- marginBottom: '9px',
-});
-
-const Subtitle = styled.span({
- fontFamily: 'Open Sans',
- fontSize: '14px',
- lineHeight: '20px',
- margin: `0 ${measurements.viewMargin}`,
- color: colors.white40,
- textAlign: 'center',
-});
-
-const StyledFooterContainer = styled.div({
- display: 'flex',
- flexDirection: 'column',
- justifyContent: 'end',
- minHeight: '241px',
-});
-
-interface ErrorViewProps {
- settingsUnavailable?: boolean;
- footer?: React.ReactNode | React.ReactNode[];
- children: React.ReactNode | React.ReactNode[];
-}
-
-export default function ErrorView(props: ErrorViewProps) {
- return (
- <Layout>
- <Header>{!props.settingsUnavailable && <HeaderBarSettingsButton />}</Header>
- <StyledContainer>
- <StyledContent>
- <Logo height={106} width={106} source="logo-icon" />
- <Title height={18} source="logo-text" />
- <Subtitle role="alert">{props.children}</Subtitle>
- </StyledContent>
- <StyledFooterContainer>{props.footer}</StyledFooterContainer>
- </StyledContainer>
- </Layout>
- );
-}
diff --git a/gui/src/renderer/components/ExpiredAccountAddTime.tsx b/gui/src/renderer/components/ExpiredAccountAddTime.tsx
deleted file mode 100644
index 8dd48d44d0..0000000000
--- a/gui/src/renderer/components/ExpiredAccountAddTime.tsx
+++ /dev/null
@@ -1,290 +0,0 @@
-import { useCallback } from 'react';
-import { useParams } from 'react-router';
-import { sprintf } from 'sprintf-js';
-import styled from 'styled-components';
-
-import { colors, links } from '../../config.json';
-import { formatDate } from '../../shared/account-expiry';
-import { formatRelativeDate } from '../../shared/date-helper';
-import { messages } from '../../shared/gettext';
-import { useAppContext } from '../context';
-import useActions from '../lib/actionsHook';
-import { transitions, useHistory } from '../lib/history';
-import { generateRoutePath } from '../lib/routeHelpers';
-import { RoutePath } from '../lib/routes';
-import account from '../redux/account/actions';
-import { useSelector } from '../redux/store';
-import * as AppButton from './AppButton';
-import { AriaDescribed, AriaDescription, AriaDescriptionGroup } from './AriaGroup';
-import { hugeText, measurements, tinyText } from './common-styles';
-import CustomScrollbars from './CustomScrollbars';
-import { calculateHeaderBarStyle, DefaultHeaderBar, HeaderBarStyle } from './HeaderBar';
-import ImageView from './ImageView';
-import { Container, Footer, Layout } from './Layout';
-import {
- RedeemVoucherContainer,
- RedeemVoucherInput,
- RedeemVoucherResponse,
- RedeemVoucherSubmitButton,
-} from './RedeemVoucher';
-
-export const StyledHeader = styled(DefaultHeaderBar)({
- flex: 0,
-});
-
-export const StyledCustomScrollbars = styled(CustomScrollbars)({
- flex: 1,
-});
-
-export const StyledContainer = styled(Container)({
- paddingTop: '22px',
- minHeight: '100%',
- backgroundColor: colors.darkBlue,
-});
-
-export const StyledBody = styled.div({
- display: 'flex',
- flexDirection: 'column',
- flex: 1,
- padding: `0 ${measurements.viewMargin}`,
- paddingBottom: 'auto',
-});
-
-export const StyledTitle = styled.span(hugeText, {
- lineHeight: '38px',
- marginBottom: '8px',
-});
-
-export const StyledLabel = styled.span(tinyText, {
- lineHeight: '20px',
- color: colors.white,
- marginBottom: '9px',
-});
-
-export const StyledRedeemVoucherInput = styled(RedeemVoucherInput)({
- flex: 0,
-});
-
-export const StyledStatusIcon = styled.div({
- alignSelf: 'center',
- width: '60px',
- height: '60px',
- marginBottom: '18px',
-});
-
-export function VoucherInput() {
- const history = useHistory();
-
- const onSuccess = useCallback(
- (newExpiry: string, secondsAdded: number) => {
- const path = generateRoutePath(RoutePath.voucherSuccess, { newExpiry, secondsAdded });
- history.push(path);
- },
- [history],
- );
-
- const navigateBack = useCallback(() => {
- history.pop();
- }, [history]);
-
- return (
- <Layout>
- <HeaderBar />
- <StyledCustomScrollbars fillContainer>
- <StyledContainer>
- <RedeemVoucherContainer onSuccess={onSuccess}>
- <StyledBody>
- <StyledTitle>{messages.pgettext('connect-view', 'Redeem voucher')}</StyledTitle>
- <StyledLabel>{messages.pgettext('connect-view', 'Enter voucher code')}</StyledLabel>
- <StyledRedeemVoucherInput />
- <RedeemVoucherResponse />
- </StyledBody>
-
- <Footer>
- <AppButton.ButtonGroup>
- <RedeemVoucherSubmitButton />
- <AppButton.BlueButton onClick={navigateBack}>
- {messages.gettext('Cancel')}
- </AppButton.BlueButton>
- </AppButton.ButtonGroup>
- </Footer>
- </RedeemVoucherContainer>
- </StyledContainer>
- </StyledCustomScrollbars>
- </Layout>
- );
-}
-
-export function VoucherVerificationSuccess() {
- const { newExpiry, secondsAdded } = useParams<{ newExpiry: string; secondsAdded: string }>();
-
- return (
- <TimeAdded
- newExpiry={newExpiry}
- secondsAdded={parseInt(secondsAdded)}
- title={messages.pgettext('connect-view', 'Voucher was successfully redeemed')}
- />
- );
-}
-
-interface ITimeAddedProps {
- title?: string;
- newExpiry?: string;
- secondsAdded?: number;
-}
-
-export function TimeAdded(props: ITimeAddedProps) {
- const { push } = useHistory();
- const finish = useFinishedCallback();
- const expiry = useSelector((state) => state.account.expiry);
- const isNewAccount = useSelector(
- (state) => state.account.status.type === 'ok' && state.account.status.method === 'new_account',
- );
- const locale = useSelector((state) => state.userInterface.locale);
-
- const navigateToSetupFinished = useCallback(() => {
- if (isNewAccount) {
- push(RoutePath.setupFinished);
- } else {
- finish();
- }
- }, [isNewAccount, push, finish]);
-
- const duration =
- props.secondsAdded !== undefined
- ? formatRelativeDate(0, props.secondsAdded * 1000, { capitalize: true, displayMonths: true })
- : undefined;
-
- let newExpiry = '';
- if (props.newExpiry !== undefined) {
- newExpiry = formatDate(props.newExpiry, locale);
- } else if (expiry !== undefined) {
- newExpiry = formatDate(expiry, locale);
- }
-
- return (
- <Layout>
- <HeaderBar />
- <StyledCustomScrollbars fillContainer>
- <StyledContainer>
- <StyledBody>
- <StyledStatusIcon>
- <ImageView source="icon-success" height={60} width={60} />
- </StyledStatusIcon>
- <StyledTitle>
- {props.title ?? messages.pgettext('connect-view', 'Time was successfully added')}
- </StyledTitle>
- <StyledLabel>
- {duration
- ? sprintf(
- messages.gettext('%(duration)s was added, account paid until %(expiry)s.'),
- {
- duration,
- expiry: newExpiry,
- },
- )
- : sprintf(messages.gettext('Account paid until %(expiry)s.'), {
- expiry: newExpiry,
- })}
- </StyledLabel>
- </StyledBody>
-
- <Footer>
- <AppButton.BlueButton onClick={navigateToSetupFinished}>
- {messages.gettext('Next')}
- </AppButton.BlueButton>
- </Footer>
- </StyledContainer>
- </StyledCustomScrollbars>
- </Layout>
- );
-}
-
-export function SetupFinished() {
- const finish = useFinishedCallback();
- const { openUrl } = useAppContext();
-
- const openPrivacyLink = useCallback(() => openUrl(links.privacyGuide), [openUrl]);
-
- return (
- <Layout>
- <HeaderBar />
- <StyledCustomScrollbars fillContainer>
- <StyledContainer>
- <StyledBody>
- <StyledTitle>{messages.pgettext('connect-view', 'You’re all set!')}</StyledTitle>
- <StyledLabel>
- {messages.pgettext(
- 'connect-view',
- 'Go ahead and start using the app to begin reclaiming your online privacy.',
- )}
- </StyledLabel>
- <StyledLabel>
- {messages.pgettext(
- 'connect-view',
- 'To continue your journey as a privacy ninja, visit our website to pick up other privacy-friendly habits and tools.',
- )}
- </StyledLabel>
- </StyledBody>
-
- <Footer>
- <AppButton.ButtonGroup>
- <AriaDescriptionGroup>
- <AriaDescribed>
- <AppButton.BlueButton onClick={openPrivacyLink}>
- <AppButton.Label>
- {messages.pgettext('connect-view', 'Learn about privacy')}
- </AppButton.Label>
- <AriaDescription>
- <AppButton.Icon
- height={16}
- width={16}
- source="icon-extLink"
- aria-label={messages.pgettext('accessibility', 'Opens externally')}
- />
- </AriaDescription>
- </AppButton.BlueButton>
- </AriaDescribed>
- </AriaDescriptionGroup>
- <AppButton.GreenButton onClick={finish}>
- {messages.pgettext('connect-view', 'Start using the app')}
- </AppButton.GreenButton>
- </AppButton.ButtonGroup>
- </Footer>
- </StyledContainer>
- </StyledCustomScrollbars>
- </Layout>
- );
-}
-
-function HeaderBar() {
- const isNewAccount = useSelector(
- (state) => state.account.status.type === 'ok' && state.account.status.method === 'new_account',
- );
- const tunnelState = useSelector((state) => state.connection.status);
- const headerBarStyle = isNewAccount
- ? HeaderBarStyle.default
- : calculateHeaderBarStyle(tunnelState);
-
- return <StyledHeader barStyle={headerBarStyle} />;
-}
-
-function useFinishedCallback() {
- const { accountSetupFinished } = useActions(account);
-
- const history = useHistory();
- const isNewAccount = useSelector(
- (state) => state.account.status.type === 'ok' && state.account.status.method === 'new_account',
- );
-
- const callback = useCallback(() => {
- // Changes login method from "new_account" to "existing_account"
- if (isNewAccount) {
- accountSetupFinished();
- }
-
- history.reset(RoutePath.main, { transition: transitions.push });
- }, [isNewAccount, accountSetupFinished, history]);
-
- return callback;
-}
diff --git a/gui/src/renderer/components/ExpiredAccountErrorView.tsx b/gui/src/renderer/components/ExpiredAccountErrorView.tsx
deleted file mode 100644
index a98d3e441c..0000000000
--- a/gui/src/renderer/components/ExpiredAccountErrorView.tsx
+++ /dev/null
@@ -1,341 +0,0 @@
-import { createContext, ReactNode, useCallback, useContext, useMemo, useState } from 'react';
-import { sprintf } from 'sprintf-js';
-
-import { links } from '../../config.json';
-import { messages } from '../../shared/gettext';
-import log from '../../shared/logging';
-import { capitalizeEveryWord } from '../../shared/string-helpers';
-import { useAppContext } from '../context';
-import { useHistory } from '../lib/history';
-import { RoutePath } from '../lib/routes';
-import { useSelector } from '../redux/store';
-import * as AppButton from './AppButton';
-import { AriaDescribed, AriaDescription, AriaDescriptionGroup } from './AriaGroup';
-import * as Cell from './cell';
-import DeviceInfoButton from './DeviceInfoButton';
-import {
- StyledAccountNumberContainer,
- StyledAccountNumberLabel,
- StyledAccountNumberMessage,
- StyledBody,
- StyledContainer,
- StyledCustomScrollbars,
- StyledDeviceLabel,
- StyledHeader,
- StyledMessage,
- StyledModalCellContainer,
- StyledStatusIcon,
- StyledTitle,
-} from './ExpiredAccountErrorViewStyles';
-import { calculateHeaderBarStyle, HeaderBarStyle } from './HeaderBar';
-import ImageView from './ImageView';
-import { Footer, Layout } from './Layout';
-import { ModalAlert, ModalAlertType, ModalMessage } from './Modal';
-
-enum RecoveryAction {
- openBrowser,
- disconnect,
- disableBlockedWhenDisconnected,
-}
-
-export default function ExpiredAccountErrorView() {
- return (
- <ExpiredAccountContextProvider>
- <ExpiredAccountErrorViewComponent />
- </ExpiredAccountContextProvider>
- );
-}
-
-function ExpiredAccountErrorViewComponent() {
- const { push } = useHistory();
- const { disconnectTunnel } = useAppContext();
-
- const connection = useSelector((state) => state.connection);
-
- const { recoveryAction } = useRecoveryAction();
- const isNewAccount = useIsNewAccount();
-
- const headerBarStyle = isNewAccount
- ? HeaderBarStyle.default
- : calculateHeaderBarStyle(connection.status);
-
- const onDisconnect = useCallback(async () => {
- try {
- await disconnectTunnel();
- } catch (e) {
- const error = e as Error;
- log.error(`Failed to disconnect the tunnel: ${error.message}`);
- }
- }, [disconnectTunnel]);
-
- const navigateToRedeemVoucher = useCallback(() => {
- push(RoutePath.redeemVoucher);
- }, [push]);
-
- return (
- <Layout>
- <StyledHeader barStyle={headerBarStyle} />
- <StyledCustomScrollbars fillContainer>
- <StyledContainer>
- <StyledBody>{isNewAccount ? <WelcomeView /> : <Content />}</StyledBody>
-
- <Footer>
- <AppButton.ButtonGroup>
- {recoveryAction === RecoveryAction.disconnect && (
- <AppButton.BlockingButton onClick={onDisconnect}>
- <AppButton.RedButton>
- {messages.pgettext('connect-view', 'Disconnect')}
- </AppButton.RedButton>
- </AppButton.BlockingButton>
- )}
-
- <ExternalPaymentButton />
-
- <AppButton.GreenButton onClick={navigateToRedeemVoucher}>
- {messages.pgettext('connect-view', 'Redeem voucher')}
- </AppButton.GreenButton>
- </AppButton.ButtonGroup>
- </Footer>
-
- <BlockWhenDisconnectedAlert />
- </StyledContainer>
- </StyledCustomScrollbars>
- </Layout>
- );
-}
-
-function WelcomeView() {
- const account = useSelector((state) => state.account);
- const { recoveryMessage } = useRecoveryAction();
-
- return (
- <>
- <StyledTitle data-testid="title">
- {messages.pgettext('connect-view', 'Congrats!')}
- </StyledTitle>
- <StyledAccountNumberMessage>
- {messages.pgettext('connect-view', 'Here’s your account number. Save it!')}
- <StyledAccountNumberContainer>
- <StyledAccountNumberLabel
- accountNumber={account.accountNumber || ''}
- obscureValue={false}
- />
- </StyledAccountNumberContainer>
- </StyledAccountNumberMessage>
-
- <StyledDeviceLabel>
- <span>
- {sprintf(
- // TRANSLATORS: A label that will display the newly created device name to inform the user
- // TRANSLATORS: about it.
- // TRANSLATORS: Available placeholders:
- // TRANSLATORS: %(deviceName)s - The name of the current device
- messages.pgettext('device-management', 'Device name: %(deviceName)s'),
- {
- deviceName: capitalizeEveryWord(account.deviceName ?? ''),
- },
- )}
- </span>
- <DeviceInfoButton />
- </StyledDeviceLabel>
-
- <StyledMessage>
- {sprintf('%(introduction)s %(recoveryMessage)s', {
- introduction: messages.pgettext(
- 'connect-view',
- 'To start using the app, you first need to add time to your account.',
- ),
- recoveryMessage,
- })}
- </StyledMessage>
- </>
- );
-}
-
-function Content() {
- const { recoveryMessage } = useRecoveryAction();
-
- return (
- <>
- <StyledStatusIcon>
- <ImageView source="icon-fail" height={60} width={60} />
- </StyledStatusIcon>
- <StyledTitle data-testid="title">
- {messages.pgettext('connect-view', 'Out of time')}
- </StyledTitle>
- <StyledMessage>
- {sprintf('%(introduction)s %(recoveryMessage)s', {
- introduction: messages.pgettext(
- 'connect-view',
- 'You have no more VPN time left on this account.',
- ),
- recoveryMessage,
- })}
- </StyledMessage>
- </>
- );
-}
-
-function ExternalPaymentButton() {
- const { setShowBlockWhenDisconnectedAlert } = useExpiredAccountContext();
- const { recoveryAction } = useRecoveryAction();
- const { openLinkWithAuth } = useAppContext();
- const isNewAccount = useIsNewAccount();
-
- const buttonText = isNewAccount
- ? messages.gettext('Buy credit')
- : messages.gettext('Buy more credit');
-
- const onOpenExternalPayment = useCallback(async () => {
- if (recoveryAction === RecoveryAction.disableBlockedWhenDisconnected) {
- setShowBlockWhenDisconnectedAlert(true);
- } else {
- await openLinkWithAuth(links.purchase);
- }
- }, [openLinkWithAuth, recoveryAction, setShowBlockWhenDisconnectedAlert]);
-
- return (
- <AppButton.BlockingButton
- disabled={recoveryAction === RecoveryAction.disconnect}
- onClick={onOpenExternalPayment}>
- <AriaDescriptionGroup>
- <AriaDescribed>
- <AppButton.GreenButton>
- <AppButton.Label>{buttonText}</AppButton.Label>
- <AriaDescription>
- <AppButton.Icon
- source="icon-extLink"
- height={16}
- width={16}
- aria-label={messages.pgettext('accessibility', 'Opens externally')}
- />
- </AriaDescription>
- </AppButton.GreenButton>
- </AriaDescribed>
- </AriaDescriptionGroup>
- </AppButton.BlockingButton>
- );
-}
-
-function BlockWhenDisconnectedAlert() {
- const { showBlockWhenDisconnectedAlert, setShowBlockWhenDisconnectedAlert } =
- useExpiredAccountContext();
- const { setBlockWhenDisconnected } = useAppContext();
- const blockWhenDisconnected = useSelector((state) => state.settings.blockWhenDisconnected);
-
- const onCloseBlockWhenDisconnectedInstructions = useCallback(() => {
- setShowBlockWhenDisconnectedAlert(false);
- }, [setShowBlockWhenDisconnectedAlert]);
-
- const onChange = useCallback(
- async (blockWhenDisconnected: boolean) => {
- try {
- await setBlockWhenDisconnected(blockWhenDisconnected);
- } catch (e) {
- const error = e as Error;
- log.error('Failed to update block when disconnected', error.message);
- }
- },
- [setBlockWhenDisconnected],
- );
-
- return (
- <ModalAlert
- isOpen={showBlockWhenDisconnectedAlert}
- type={ModalAlertType.caution}
- buttons={[
- <AppButton.BlueButton key="cancel" onClick={onCloseBlockWhenDisconnectedInstructions}>
- {messages.gettext('Close')}
- </AppButton.BlueButton>,
- ]}
- close={onCloseBlockWhenDisconnectedInstructions}>
- <ModalMessage>
- {messages.pgettext(
- 'connect-view',
- 'You need to disable "Lockdown mode" in order to access the Internet to add time.',
- )}
- </ModalMessage>
- <ModalMessage>
- {messages.pgettext(
- 'connect-view',
- 'Remember, turning it off will allow network traffic while the VPN is disconnected until you turn it back on under Advanced settings.',
- )}
- </ModalMessage>
- <StyledModalCellContainer>
- <Cell.Label>{messages.pgettext('vpn-settings-view', 'Lockdown mode')}</Cell.Label>
- <Cell.Switch isOn={blockWhenDisconnected} onChange={onChange} />
- </StyledModalCellContainer>
- </ModalAlert>
- );
-}
-
-type ExpiredAccountContextType = {
- setShowBlockWhenDisconnectedAlert: (val: boolean) => void;
- showBlockWhenDisconnectedAlert: boolean;
-};
-
-const ExpiredAccountContext = createContext<ExpiredAccountContextType | undefined>(undefined);
-
-const ExpiredAccountContextProvider = ({ children }: { children: ReactNode }) => {
- const [showBlockWhenDisconnectedAlert, setShowBlockWhenDisconnectedAlert] = useState(false);
-
- const value: ExpiredAccountContextType = useMemo(
- () => ({
- setShowBlockWhenDisconnectedAlert,
- showBlockWhenDisconnectedAlert,
- }),
- [setShowBlockWhenDisconnectedAlert, showBlockWhenDisconnectedAlert],
- );
- return <ExpiredAccountContext.Provider value={value}>{children}</ExpiredAccountContext.Provider>;
-};
-
-const useExpiredAccountContext = () => {
- const context = useContext(ExpiredAccountContext);
- if (!context) {
- throw new Error(
- 'useExpiredAccountContext must be used within an ExpiredAccountContextProvider',
- );
- }
-
- return context;
-};
-
-const useRecoveryAction = () => {
- const isBlocked = useSelector((state) => state.connection.isBlocked);
- const blockWhenDisconnected = useSelector((state) => state.settings.blockWhenDisconnected);
-
- let recoveryAction: RecoveryAction;
-
- if (blockWhenDisconnected && isBlocked) {
- recoveryAction = RecoveryAction.disableBlockedWhenDisconnected;
- } else if (!blockWhenDisconnected && isBlocked) {
- recoveryAction = RecoveryAction.disconnect;
- } else {
- recoveryAction = RecoveryAction.openBrowser;
- }
-
- let recoveryMessage: string;
-
- switch (recoveryAction) {
- case RecoveryAction.openBrowser:
- case RecoveryAction.disableBlockedWhenDisconnected:
- recoveryMessage = messages.pgettext(
- 'connect-view',
- 'Either buy credit on our website or redeem a voucher.',
- );
- break;
- case RecoveryAction.disconnect:
- recoveryMessage = messages.pgettext(
- 'connect-view',
- 'To add more, you will need to disconnect and access the Internet with an unsecure connection.',
- );
- break;
- }
-
- return { recoveryAction, recoveryMessage };
-};
-
-const useIsNewAccount = () => {
- const account = useSelector((state) => state.account);
- return account.status.type === 'ok' && account.status.method === 'new_account';
-};
diff --git a/gui/src/renderer/components/ExpiredAccountErrorViewStyles.tsx b/gui/src/renderer/components/ExpiredAccountErrorViewStyles.tsx
deleted file mode 100644
index 53450a41ee..0000000000
--- a/gui/src/renderer/components/ExpiredAccountErrorViewStyles.tsx
+++ /dev/null
@@ -1,79 +0,0 @@
-import styled from 'styled-components';
-
-import { colors } from '../../config.json';
-import AccountNumberLabel from './AccountNumberLabel';
-import * as Cell from './cell';
-import { hugeText, measurements, tinyText } from './common-styles';
-import CustomScrollbars from './CustomScrollbars';
-import { DefaultHeaderBar } from './HeaderBar';
-import { Container } from './Layout';
-
-export const StyledHeader = styled(DefaultHeaderBar)({
- flex: 0,
-});
-
-export const StyledAccountNumberLabel = styled(AccountNumberLabel)({
- fontFamily: 'Open Sans',
- lineHeight: '20px',
- fontSize: '20px',
- fontWeight: 700,
- color: colors.white,
-});
-
-export const StyledModalCellContainer = styled(Cell.Container)({
- marginTop: '18px',
- paddingLeft: '12px',
- paddingRight: '12px',
-});
-
-export const StyledCustomScrollbars = styled(CustomScrollbars)({
- flex: 1,
-});
-
-export const StyledContainer = styled(Container)({
- paddingTop: '22px',
- minHeight: '100%',
- backgroundColor: colors.darkBlue,
-});
-
-export const StyledBody = styled.div({
- display: 'flex',
- flexDirection: 'column',
- flex: 1,
- padding: `0 ${measurements.viewMargin}`,
-});
-
-export const StyledTitle = styled.span(hugeText, {
- lineHeight: '38px',
- marginBottom: '8px',
-});
-
-export const StyledMessage = styled.span(tinyText, {
- marginBottom: '20px',
- color: colors.white,
-});
-
-export const StyledAccountNumberMessage = styled.span(tinyText, {
- color: colors.white,
-});
-
-export const StyledStatusIcon = styled.div({
- alignSelf: 'center',
- width: '60px',
- height: '60px',
- marginBottom: '18px',
-});
-
-export const StyledAccountNumberContainer = styled.div({
- display: 'flex',
- height: '50px',
- alignItems: 'center',
-});
-
-export const StyledDeviceLabel = styled.span(tinyText, {
- display: 'flex',
- alignItems: 'middle',
- lineHeight: '20px',
- marginBottom: '18px',
- color: colors.white,
-});
diff --git a/gui/src/renderer/components/Filter.tsx b/gui/src/renderer/components/Filter.tsx
deleted file mode 100644
index a0ed1725b1..0000000000
--- a/gui/src/renderer/components/Filter.tsx
+++ /dev/null
@@ -1,382 +0,0 @@
-import { useCallback, useMemo, useState } from 'react';
-import styled from 'styled-components';
-
-import { colors } from '../../config.json';
-import { Ownership } from '../../shared/daemon-rpc-types';
-import { messages } from '../../shared/gettext';
-import { useRelaySettingsUpdater } from '../lib/constraint-updater';
-import {
- EndpointType,
- filterLocations,
- filterLocationsByEndPointType,
-} from '../lib/filter-locations';
-import { useHistory } from '../lib/history';
-import { useNormalRelaySettings, useTunnelProtocol } from '../lib/relay-settings-hooks';
-import { useBoolean } from '../lib/utility-hooks';
-import { IRelayLocationCountryRedux } from '../redux/settings/reducers';
-import { useSelector } from '../redux/store';
-import Accordion from './Accordion';
-import * as AppButton from './AppButton';
-import { AriaInputGroup, AriaLabel } from './AriaGroup';
-import * as Cell from './cell';
-import Selector from './cell/Selector';
-import { normalText } from './common-styles';
-import ImageView from './ImageView';
-import { BackAction } from './KeyboardNavigation';
-import { Footer, Layout, SettingsContainer } from './Layout';
-import {
- NavigationBar,
- NavigationContainer,
- NavigationItems,
- NavigationScrollbars,
- TitleBarItem,
-} from './NavigationBar';
-
-const StyledNavigationScrollbars = styled(NavigationScrollbars)({
- backgroundColor: colors.darkBlue,
- flex: 1,
-});
-
-export default function Filter() {
- const history = useHistory();
- const relaySettingsUpdater = useRelaySettingsUpdater();
-
- const initialProviders = useProviders();
- const [providers, setProviders] = useState<Record<string, boolean>>(initialProviders);
-
- // The daemon expects the value to be an empty list if all are selected.
- const formattedProviderList = useMemo(() => {
- // If all providers are selected it's represented as an empty array.
- return Object.values(providers).every((provider) => provider)
- ? []
- : Object.entries(providers)
- .filter(([, selected]) => selected)
- .map(([name]) => name);
- }, [providers]);
-
- const initialOwnership = useSelector((state) =>
- 'normal' in state.settings.relaySettings
- ? state.settings.relaySettings.normal.ownership
- : Ownership.any,
- );
- const [ownership, setOwnership] = useState<Ownership>(initialOwnership);
-
- // Available providers are used to only show compatible options after activating a filter.
- const availableProviders = useFilteredProviders([], ownership);
- const availableOwnershipOptions = useFilteredOwnershipOptions(
- formattedProviderList,
- Ownership.any,
- );
-
- // Applies the changes by sending them to the daemon.
- const onApply = useCallback(async () => {
- await relaySettingsUpdater((settings) => {
- settings.providers = formattedProviderList;
- settings.ownership = ownership;
- return settings;
- });
- history.pop();
- }, [formattedProviderList, ownership, history, relaySettingsUpdater]);
-
- return (
- <BackAction action={history.pop}>
- <Layout>
- <SettingsContainer>
- <NavigationContainer>
- <NavigationBar alwaysDisplayBarTitle={true}>
- <NavigationItems>
- <TitleBarItem>
- {
- // TRANSLATORS: Title label in navigation bar
- messages.pgettext('filter-nav', 'Filter')
- }
- </TitleBarItem>
- </NavigationItems>
- </NavigationBar>
- <StyledNavigationScrollbars>
- <FilterByOwnership
- ownership={ownership}
- availableOptions={availableOwnershipOptions}
- setOwnership={setOwnership}
- />
- <FilterByProvider
- providers={providers}
- availableOptions={availableProviders}
- setProviders={setProviders}
- />
- </StyledNavigationScrollbars>
- <Footer>
- <AppButton.GreenButton
- disabled={Object.values(providers).every((provider) => !provider)}
- onClick={onApply}>
- {messages.gettext('Apply')}
- </AppButton.GreenButton>
- </Footer>
- </NavigationContainer>
- </SettingsContainer>
- </Layout>
- </BackAction>
- );
-}
-
-// Returns only the ownership options that are compatible with the other filters
-function useFilteredOwnershipOptions(providers: string[], ownership: Ownership): Ownership[] {
- const relaySettings = useNormalRelaySettings();
- const tunnelProtocol = useTunnelProtocol();
- const bridgeState = useSelector((state) => state.settings.bridgeState);
- const locations = useSelector((state) => state.settings.relayLocations);
-
- const endpointType = bridgeState === 'on' ? EndpointType.any : EndpointType.exit;
-
- const availableOwnershipOptions = useMemo(() => {
- const relayListForEndpointType = filterLocationsByEndPointType(
- locations,
- endpointType,
- tunnelProtocol,
- relaySettings,
- );
- const relaylistForFilters = filterLocations(relayListForEndpointType, ownership, providers);
-
- const filteredRelayOwnership = relaylistForFilters.flatMap((country) =>
- country.cities.flatMap((city) => city.relays.map((relay) => relay.owned)),
- );
-
- const ownershipOptions = [Ownership.any];
- if (filteredRelayOwnership.includes(true)) {
- ownershipOptions.push(Ownership.mullvadOwned);
- }
- if (filteredRelayOwnership.includes(false)) {
- ownershipOptions.push(Ownership.rented);
- }
-
- return ownershipOptions;
- }, [locations, endpointType, tunnelProtocol, relaySettings, ownership, providers]);
-
- return availableOwnershipOptions;
-}
-
-// Returns only the providers that are compatible with the other filters
-export function useFilteredProviders(providers: string[], ownership: Ownership): string[] {
- const relaySettings = useNormalRelaySettings();
- const tunnelProtocol = useTunnelProtocol();
- const bridgeState = useSelector((state) => state.settings.bridgeState);
- const locations = useSelector((state) => state.settings.relayLocations);
-
- const endpointType = bridgeState === 'on' ? EndpointType.any : EndpointType.exit;
-
- const availableProviders = useMemo(() => {
- const relayListForEndpointType = filterLocationsByEndPointType(
- locations,
- endpointType,
- tunnelProtocol,
- relaySettings,
- );
- const relaylistForFilters = filterLocations(relayListForEndpointType, ownership, providers);
- return providersFromRelays(relaylistForFilters);
- }, [endpointType, locations, ownership, providers, relaySettings, tunnelProtocol]);
-
- return availableProviders;
-}
-
-// Returns all available providers in the provided relay list.
-function providersFromRelays(relays: IRelayLocationCountryRedux[]) {
- const providers = relays.flatMap((country) =>
- country.cities.flatMap((city) => city.relays.map((relay) => relay.provider)),
- );
- return removeDuplicates(providers).sort((a, b) => a.localeCompare(b));
-}
-
-function useProviders(): Record<string, boolean> {
- const tunnelProtocol = useTunnelProtocol();
- const relaySettings = useNormalRelaySettings();
- const relayLocations = useSelector((state) => state.settings.relayLocations);
- const bridgeState = useSelector((state) => state.settings.bridgeState);
- const providerConstraint = relaySettings?.providers ?? [];
-
- const endpointType =
- tunnelProtocol === 'openvpn' && bridgeState === 'on' ? EndpointType.any : EndpointType.exit;
- const relays = filterLocationsByEndPointType(
- relayLocations,
- endpointType,
- tunnelProtocol,
- relaySettings,
- );
- const providers = providersFromRelays(relays);
-
- // Empty containt array means that all providers are selected. No selection isn't possible.
- return Object.fromEntries(
- providers.map((provider) => [
- provider,
- providerConstraint.length === 0 || providerConstraint.includes(provider),
- ]),
- );
-}
-
-const StyledSelector = styled(Selector)({
- marginBottom: 0,
-});
-
-interface IFilterByOwnershipProps {
- ownership: Ownership;
- availableOptions: Ownership[];
- setOwnership: (ownership: Ownership) => void;
-}
-
-function FilterByOwnership(props: IFilterByOwnershipProps) {
- const [expanded, , , toggleExpanded] = useBoolean(false);
-
- const values = useMemo(
- () =>
- [
- {
- label: messages.pgettext('filter-view', 'Mullvad owned only'),
- value: Ownership.mullvadOwned,
- },
- {
- label: messages.pgettext('filter-view', 'Rented only'),
- value: Ownership.rented,
- },
- ].filter((option) => props.availableOptions.includes(option.value)),
- [props.availableOptions],
- );
-
- return (
- <AriaInputGroup>
- <Cell.CellButton onClick={toggleExpanded}>
- <AriaLabel>
- <Cell.Label>{messages.pgettext('filter-view', 'Ownership')}</Cell.Label>
- </AriaLabel>
- <ImageView
- tintColor={colors.white80}
- source={expanded ? 'icon-chevron-up' : 'icon-chevron-down'}
- height={24}
- />
- </Cell.CellButton>
-
- <Accordion expanded={expanded}>
- <StyledSelector
- items={values}
- value={props.ownership}
- onSelect={props.setOwnership}
- automaticLabel={messages.gettext('Any')}
- automaticValue={Ownership.any}
- />
- </Accordion>
- </AriaInputGroup>
- );
-}
-
-interface IFilterByProviderProps {
- providers: Record<string, boolean>;
- availableOptions: string[];
- setProviders: (providers: (previous: Record<string, boolean>) => Record<string, boolean>) => void;
-}
-
-function FilterByProvider(props: IFilterByProviderProps) {
- const { setProviders } = props;
-
- const [expanded, , , toggleExpanded] = useBoolean(false);
-
- const onToggle = useCallback(
- (provider: string) =>
- setProviders((providers) => {
- const newProviders = { ...providers, [provider]: !providers[provider] };
- return props.availableOptions.every((provider) => newProviders[provider])
- ? toggleAllProviders(providers, true)
- : newProviders;
- }),
- [props.availableOptions, setProviders],
- );
-
- const toggleAll = useCallback(() => {
- setProviders((providers) => toggleAllProviders(providers));
- }, [setProviders]);
-
- return (
- <>
- <Cell.CellButton onClick={toggleExpanded}>
- <Cell.Label>{messages.pgettext('filter-view', 'Providers')}</Cell.Label>
- <ImageView
- tintColor={colors.white80}
- source={expanded ? 'icon-chevron-up' : 'icon-chevron-down'}
- height={24}
- />
- </Cell.CellButton>
- <Accordion expanded={expanded}>
- <CheckboxRow
- label={messages.pgettext('filter-view', 'All providers')}
- $bold
- checked={Object.values(props.providers).every((value) => value)}
- onChange={toggleAll}
- />
- {Object.entries(props.providers)
- .filter(([provider]) => props.availableOptions.includes(provider))
- .map(([provider, checked]) => (
- <CheckboxRow key={provider} label={provider} checked={checked} onChange={onToggle} />
- ))}
- </Accordion>
- </>
- );
-}
-
-function toggleAllProviders(providers: Record<string, boolean>, value?: boolean) {
- const shouldSelect = value ?? !Object.values(providers).every((value) => value);
- return Object.fromEntries(Object.keys(providers).map((provider) => [provider, shouldSelect]));
-}
-
-interface IStyledRowTitleProps {
- $bold?: boolean;
-}
-
-const StyledCheckbox = styled.div({
- width: '24px',
- height: '24px',
- display: 'flex',
- alignItems: 'center',
- justifyContent: 'center',
- backgroundColor: colors.white,
- borderRadius: '4px',
-});
-
-const StyledRow = styled(Cell.Row)({
- backgroundColor: colors.blue40,
- '&&:hover': {
- backgroundColor: colors.blue80,
- },
-});
-
-const StyledRowTitle = styled.label<IStyledRowTitleProps>(normalText, (props) => ({
- fontWeight: props.$bold ? 600 : 400,
- color: colors.white,
- marginLeft: '22px',
-}));
-
-interface ICheckboxRowProps extends IStyledRowTitleProps {
- label: string;
- checked: boolean;
- onChange: (provider: string) => void;
-}
-
-function CheckboxRow(props: ICheckboxRowProps) {
- const { onChange } = props;
-
- const onToggle = useCallback(() => onChange(props.label), [onChange, props.label]);
-
- return (
- <StyledRow onClick={onToggle}>
- <StyledCheckbox role="checkbox" aria-label={props.label} aria-checked={props.checked}>
- {props.checked && <ImageView source="icon-tick" width={18} tintColor={colors.green} />}
- </StyledCheckbox>
- <StyledRowTitle aria-hidden $bold={props.$bold}>
- {props.label}
- </StyledRowTitle>
- </StyledRow>
- );
-}
-
-function removeDuplicates(list: string[]): string[] {
- return list.reduce(
- (result, current) => (result.includes(current) ? result : [...result, current]),
- [] as string[],
- );
-}
diff --git a/gui/src/renderer/components/Focus.tsx b/gui/src/renderer/components/Focus.tsx
deleted file mode 100644
index c29cbe653e..0000000000
--- a/gui/src/renderer/components/Focus.tsx
+++ /dev/null
@@ -1,76 +0,0 @@
-import React, { useImperativeHandle, useState } from 'react';
-import { useLocation } from 'react-router';
-import { sprintf } from 'sprintf-js';
-import styled from 'styled-components';
-
-import { messages } from '../../shared/gettext';
-
-const FOCUS_FALLBACK_CLASS = 'focus-fallback';
-
-const PageChangeAnnouncer = styled.div({
- width: 0,
- height: 0,
- overflow: 'hidden',
-});
-
-export interface IFocusHandle {
- resetFocus(): void;
-}
-
-interface IFocusProps {
- children?: React.ReactElement;
-}
-
-function Focus(props: IFocusProps, ref: React.Ref<IFocusHandle>) {
- const location = useLocation();
- const [title, setTitle] = useState<string>();
-
- useImperativeHandle(
- ref,
- () => ({
- resetFocus: () => {
- const pageName = location.pathname.slice(location.pathname.lastIndexOf('/') + 1);
- const titleElement = document.getElementsByTagName('h1')[0];
- const titleContent = titleElement?.textContent ?? pageName;
- setTitle(titleContent);
-
- const focusElement =
- titleElement ?? document.getElementsByClassName(FOCUS_FALLBACK_CLASS)[0];
- if (focusElement) {
- focusElement.setAttribute('tabindex', '-1');
- focusElement.focus();
- }
- },
- }),
- [location.pathname],
- );
-
- return (
- <>
- {title && (
- <PageChangeAnnouncer aria-live="polite">
- {
- // TRANSLATORS: This string is used to notify users of screenreaders that the view has
- // TRANSLATORS: changed, usually as a result of pressing a navigation button.
- // TRANSLATORS: Available placeholders:
- // TRANSLATORS: %(title)s - page title
- sprintf(messages.pgettext('accessibility', '%(title)s, View loaded'), { title })
- }
- </PageChangeAnnouncer>
- )}
- {props.children}
- </>
- );
-}
-
-export default React.memo(React.forwardRef(Focus));
-
-interface IFocusFallbackProps {
- children: React.ReactElement;
-}
-
-export function FocusFallback(props: IFocusFallbackProps) {
- return React.cloneElement(props.children, {
- className: `${props.children.props.className} ${FOCUS_FALLBACK_CLASS}`,
- });
-}
diff --git a/gui/src/renderer/components/FormattableTextInput.tsx b/gui/src/renderer/components/FormattableTextInput.tsx
deleted file mode 100644
index aae614ddd7..0000000000
--- a/gui/src/renderer/components/FormattableTextInput.tsx
+++ /dev/null
@@ -1,135 +0,0 @@
-import React, { useCallback, useEffect } from 'react';
-
-import { useCombinedRefs, useStyledRef } from '../lib/utility-hooks';
-
-interface IFormattableTextInputProps extends React.InputHTMLAttributes<HTMLInputElement> {
- allowedCharacters: string;
- separator: string;
- uppercaseOnly?: boolean;
- maxLength?: number;
- groupLength: number;
- addTrailingSeparator?: boolean;
- handleChange: (value: string) => void;
-}
-
-function FormattableTextInput(
- props: IFormattableTextInputProps,
- forwardedRef: React.Ref<HTMLInputElement>,
-) {
- const {
- addTrailingSeparator,
- allowedCharacters,
- groupLength,
- handleChange,
- maxLength,
- separator,
- uppercaseOnly,
- value,
- ...otherProps
- } = props;
-
- const ref = useStyledRef<HTMLInputElement>();
- const combinedRef = useCombinedRefs(ref, forwardedRef);
-
- const unformat = useCallback(
- (value: string) => {
- const correctCaseValue = uppercaseOnly ? value.toUpperCase() : value;
- return correctCaseValue.match(new RegExp(allowedCharacters, 'g'))?.join('') ?? '';
- },
- [uppercaseOnly, allowedCharacters],
- );
-
- const format = useCallback(
- (value: string, addTrailingSeparator?: boolean) => {
- let formatted = value.match(new RegExp(`.{1,${groupLength}}`, 'g'))?.join(separator) ?? '';
-
- if (
- addTrailingSeparator &&
- value.length > 0 &&
- value.length % groupLength === 0 &&
- (!maxLength || maxLength > value.length)
- ) {
- formatted += separator;
- }
-
- return formatted;
- },
- [groupLength, separator, maxLength],
- );
-
- const onBeforeInput = useCallback(
- (event: Event) => {
- const { inputType, data, target } = event as InputEvent;
-
- if (ref.current) {
- const inputElement = target as HTMLInputElement;
- const oldValue = inputElement.value;
-
- const selectionStart = inputElement.selectionStart ?? oldValue.length;
- const selectionEnd = inputElement.selectionEnd ?? selectionStart;
- const emptySelection = selectionStart === selectionEnd;
- const beforeSelection = unformat(oldValue.slice(0, selectionStart));
- const afterSelection = unformat(oldValue.slice(selectionEnd));
-
- let unformattedData = unformat(data ?? '');
- // Only allow adding data that fits into the max length.
- if (maxLength) {
- const charactersLeft = maxLength - beforeSelection.length - afterSelection.length;
- unformattedData = unformattedData.slice(0, charactersLeft);
- }
-
- let newValue: string;
- let caretPosition: number;
- if (inputType === 'deleteContentBackward' && emptySelection && beforeSelection.length > 0) {
- // This is triggered when pressing backspace without a selection
- newValue = beforeSelection.slice(0, -1) + afterSelection;
- caretPosition = format(beforeSelection + unformattedData, false).length - 1;
- } else if (inputType === 'deleteContentForward' && emptySelection) {
- // This is triggered when pressing delete without a selection
- newValue = beforeSelection + afterSelection.slice(1);
- caretPosition = format(beforeSelection + unformattedData, true).length;
- } else {
- newValue = beforeSelection + unformattedData + afterSelection;
- caretPosition = format(beforeSelection + unformattedData, true).length;
- }
-
- const formattedValue = format(newValue, addTrailingSeparator);
- caretPosition = Math.min(caretPosition, formattedValue.length);
-
- // The new value can't be set before the browser has changed the content of the input
- // element since that would result in the change being made twice. Another alternative would
- // be to call `event.preventDefault()` but that prevents other side effects such as the
- // scrolling of the input content when overflowing.
- ref.current.addEventListener(
- 'input',
- () => {
- inputElement.value = formattedValue;
- inputElement.selectionStart = inputElement.selectionEnd = caretPosition;
- handleChange(newValue);
- },
- { once: true },
- );
- }
- },
- [ref, unformat, maxLength, format, addTrailingSeparator, handleChange],
- );
-
- // React doesn't fully support onBeforeInput currently and it's therefore set here.
- useEffect(() => {
- const input = ref.current;
- input?.addEventListener('beforeinput', onBeforeInput);
- return () => input?.removeEventListener('beforeinput', onBeforeInput);
- }, [onBeforeInput, ref]);
-
- // Use value provided in props if it differs from current input value.
- useEffect(() => {
- if (typeof value === 'string' && ref.current && unformat(ref.current.value) !== value) {
- // eslint-disable-next-line react-compiler/react-compiler
- ref.current.value = format(value, addTrailingSeparator);
- }
- }, [format, value, addTrailingSeparator, ref, unformat]);
-
- return <input ref={combinedRef} type="text" {...otherProps} />;
-}
-
-export default React.memo(React.forwardRef(FormattableTextInput));
diff --git a/gui/src/renderer/components/HeaderBar.tsx b/gui/src/renderer/components/HeaderBar.tsx
deleted file mode 100644
index d0f2bde6ef..0000000000
--- a/gui/src/renderer/components/HeaderBar.tsx
+++ /dev/null
@@ -1,253 +0,0 @@
-import React, { useCallback } from 'react';
-import { sprintf } from 'sprintf-js';
-import styled from 'styled-components';
-
-import { colors } from '../../config.json';
-import { closeToExpiry, formatRemainingTime, hasExpired } from '../../shared/account-expiry';
-import { TunnelState } from '../../shared/daemon-rpc-types';
-import { messages } from '../../shared/gettext';
-import { capitalizeEveryWord } from '../../shared/string-helpers';
-import { transitions, useHistory } from '../lib/history';
-import { RoutePath } from '../lib/routes';
-import { useSelector } from '../redux/store';
-import { tinyText } from './common-styles';
-import { FocusFallback } from './Focus';
-import ImageView from './ImageView';
-
-export enum HeaderBarStyle {
- default = 'default',
- defaultDark = 'defaultDark',
- error = 'error',
- success = 'success',
-}
-
-const headerBarStyleColorMap = {
- [HeaderBarStyle.default]: colors.blue,
- [HeaderBarStyle.defaultDark]: colors.darkBlue,
- [HeaderBarStyle.error]: colors.red,
- [HeaderBarStyle.success]: colors.green,
-};
-
-interface IHeaderBarContainerProps {
- $barStyle?: HeaderBarStyle;
- $accountInfoVisible: boolean;
- $unpinnedWindow: boolean;
-}
-
-const HeaderBarContainer = styled.header<IHeaderBarContainerProps>((props) => ({
- padding: '15px 11px 0px 16px',
- minHeight: props.$accountInfoVisible ? '80px' : '68px',
- height: props.$accountInfoVisible ? '80px' : '68px',
- backgroundColor: headerBarStyleColorMap[props.$barStyle ?? HeaderBarStyle.default],
- transitionProperty: 'height, min-height',
- transitionDuration: '250ms',
- transitionTimingFunction: 'ease-in-out',
-}));
-
-const HeaderBarContent = styled.div({
- display: 'flex',
- alignItems: 'center',
- justifyContent: 'flex-end',
- height: '38px',
-});
-
-interface IHeaderBarProps {
- barStyle?: HeaderBarStyle;
- className?: string;
- children?: React.ReactNode;
- showAccountInfo?: boolean;
-}
-
-export default function HeaderBar(props: IHeaderBarProps) {
- const unpinnedWindow = useSelector((state) => state.settings.guiSettings.unpinnedWindow);
-
- return (
- <HeaderBarContainer
- $barStyle={props.barStyle}
- className={props.className}
- $accountInfoVisible={props.showAccountInfo ?? false}
- $unpinnedWindow={unpinnedWindow}>
- <HeaderBarContent>{props.children}</HeaderBarContent>
- {props.showAccountInfo && <HeaderBarDeviceInfo />}
- </HeaderBarContainer>
- );
-}
-
-const BrandContainer = styled.div({
- display: 'flex',
- flex: 1,
- alignItems: 'center',
-});
-
-const Title = styled(ImageView)({
- opacity: 0.8,
- marginLeft: '9px',
-});
-
-export function Brand(props: React.HTMLAttributes<HTMLDivElement>) {
- return (
- <BrandContainer {...props}>
- <ImageView width={38} height={38} source="logo-icon" />
- <Title height={15.4} source="logo-text" />
- </BrandContainer>
- );
-}
-
-const StyledAccountInfo = styled.div({
- display: 'flex',
- marginTop: '2px',
- maxWidth: '100%',
-});
-
-const StyledDeviceLabel = styled.div(tinyText, {
- fontSize: '10px',
- color: colors.white80,
- whiteSpace: 'nowrap',
- overflow: 'hidden',
- textOverflow: 'ellipsis',
-});
-
-const StyledTimeLeftLabel = styled.div(tinyText, {
- fontSize: '10px',
- color: colors.white80,
- marginLeft: '16px',
- whiteSpace: 'nowrap',
-});
-
-function HeaderBarDeviceInfo() {
- const deviceName = useSelector((state) => state.account.deviceName);
- const accountExpiry = useSelector((state) => state.account.expiry);
- const isOutOfTime = accountExpiry ? hasExpired(accountExpiry) : false;
- const formattedExpiry = isOutOfTime
- ? sprintf(messages.ngettext('1 day', '%d days', 0), 0)
- : accountExpiry
- ? formatRemainingTime(accountExpiry)
- : '';
-
- return (
- <StyledAccountInfo>
- <StyledDeviceLabel>
- {sprintf(
- // TRANSLATORS: A label that will display the newly created device name to inform the user
- // TRANSLATORS: about it.
- // TRANSLATORS: Available placeholders:
- // TRANSLATORS: %(deviceName)s - The name of the current device
- messages.pgettext('device-management', 'Device name: %(deviceName)s'),
- {
- deviceName: capitalizeEveryWord(deviceName ?? ''),
- },
- )}
- </StyledDeviceLabel>
- {accountExpiry && !closeToExpiry(accountExpiry) && !isOutOfTime && (
- <StyledTimeLeftLabel>
- {sprintf(messages.pgettext('device-management', 'Time left: %(timeLeft)s'), {
- timeLeft: formattedExpiry,
- })}
- </StyledTimeLeftLabel>
- )}
- </StyledAccountInfo>
- );
-}
-
-const HeaderBarSettingsButtonContainer = styled.button({
- cursor: 'default',
- padding: '5px',
- marginLeft: '3px',
- backgroundColor: 'transparent',
- border: 'none',
-});
-
-const HeaderBarAccountButtonContainer = styled(HeaderBarSettingsButtonContainer)({
- marginRight: '11px',
-});
-
-const StyledHeaderBarImageView = styled(ImageView)((props) => ({
- [`${HeaderBarSettingsButtonContainer}:hover &&`]: {
- backgroundColor: props.tintHoverColor,
- },
-}));
-
-interface IHeaderBarSettingsButtonProps {
- disabled?: boolean;
-}
-
-export function HeaderBarSettingsButton(props: IHeaderBarSettingsButtonProps) {
- const history = useHistory();
-
- const openSettings = useCallback(() => {
- if (!props.disabled) {
- history.push(RoutePath.settings, { transition: transitions.show });
- }
- }, [history, props.disabled]);
-
- return (
- <HeaderBarSettingsButtonContainer
- onClick={openSettings}
- aria-label={messages.gettext('Settings')}>
- <StyledHeaderBarImageView
- height={24}
- width={24}
- source="icon-settings"
- tintColor={props.disabled ? colors.white40 : colors.white60}
- tintHoverColor={props.disabled ? colors.white40 : colors.white80}
- />
- </HeaderBarSettingsButtonContainer>
- );
-}
-
-export function HeaderBarAccountButton() {
- const history = useHistory();
- const openAccount = useCallback(
- () => history.push(RoutePath.account, { transition: transitions.show }),
- [history],
- );
-
- return (
- <HeaderBarAccountButtonContainer
- onClick={openAccount}
- data-testid="account-button"
- aria-label={messages.gettext('Account settings')}>
- <StyledHeaderBarImageView
- height={24}
- width={24}
- source="icon-account"
- tintColor={colors.white60}
- tintHoverColor={colors.white80}
- />
- </HeaderBarAccountButtonContainer>
- );
-}
-
-export function DefaultHeaderBar(props: IHeaderBarProps) {
- const loggedIn = useSelector((state) => state.account.status.type === 'ok');
-
- return (
- <HeaderBar showAccountInfo={loggedIn} {...props}>
- <FocusFallback>
- <Brand />
- </FocusFallback>
- {loggedIn && <HeaderBarAccountButton />}
- <HeaderBarSettingsButton />
- </HeaderBar>
- );
-}
-
-export function calculateHeaderBarStyle(tunnelState: TunnelState): HeaderBarStyle {
- switch (tunnelState.state) {
- case 'disconnected':
- return HeaderBarStyle.error;
- case 'connecting':
- case 'connected':
- return HeaderBarStyle.success;
- case 'error':
- return !tunnelState.details.blockingError ? HeaderBarStyle.success : HeaderBarStyle.error;
- case 'disconnecting':
- switch (tunnelState.details) {
- case 'block':
- case 'reconnect':
- return HeaderBarStyle.success;
- case 'nothing':
- return HeaderBarStyle.error;
- }
- }
-}
diff --git a/gui/src/renderer/components/ImageView.tsx b/gui/src/renderer/components/ImageView.tsx
deleted file mode 100644
index f40a93fbbc..0000000000
--- a/gui/src/renderer/components/ImageView.tsx
+++ /dev/null
@@ -1,70 +0,0 @@
-import React, { useMemo } from 'react';
-import styled from 'styled-components';
-
-import { NonTransientProps } from '../lib/styles';
-
-export interface IImageViewProps
- extends NonTransientProps<IImageMaskProps, 'tintColor' | 'tintHoverColor'> {
- source: string;
- onClick?: (event: React.MouseEvent) => void;
- className?: string;
-}
-
-interface IImageMaskProps extends React.HTMLAttributes<HTMLElement> {
- width?: number;
- height?: number;
- disabled?: boolean;
- $tintColor?: string;
- $tintHoverColor?: string;
-}
-
-const Wrapper = styled.div({
- display: 'flex',
- flexDirection: 'column',
- justifyContent: 'center',
-});
-
-const ImageMask = styled.div<IImageMaskProps>((props) => {
- const maskWidth = props.width ? `${props.width}px` : 'auto';
- const maskHeight = props.height ? `${props.height}px` : 'auto';
- return {
- maskRepeat: 'no-repeat',
- maskSize: `${maskWidth} ${maskHeight}`,
- maskPosition: 'center',
- lineHeight: 0,
- backgroundColor: props.$tintColor,
- '&&:hover': {
- backgroundColor: (!props.disabled && props.$tintHoverColor) || props.$tintColor,
- },
- };
-});
-
-const HiddenImage = styled.img({ visibility: 'hidden' });
-
-export default function ImageView(props: IImageViewProps) {
- const url = props.source.startsWith('data:')
- ? props.source
- : `../../assets/images/${props.source}.svg`;
-
- const style = useMemo(() => ({ WebkitMaskImage: `url('${url}')` }), [url]);
-
- if (props.tintColor) {
- const { source: _source, tintColor, tintHoverColor, ...otherProps } = props;
- return (
- <ImageMask
- style={style}
- $tintColor={tintColor}
- $tintHoverColor={tintHoverColor}
- {...otherProps}>
- <HiddenImage src={url} width={props.width} height={props.height} />
- </ImageMask>
- );
- } else {
- const { source: _source, width, height, ...otherProps } = props;
- return (
- <Wrapper {...otherProps}>
- <img src={url} width={width} height={height} aria-hidden={true} />
- </Wrapper>
- );
- }
-}
diff --git a/gui/src/renderer/components/InfoButton.tsx b/gui/src/renderer/components/InfoButton.tsx
deleted file mode 100644
index 7f77f115e7..0000000000
--- a/gui/src/renderer/components/InfoButton.tsx
+++ /dev/null
@@ -1,73 +0,0 @@
-import styled from 'styled-components';
-
-import { colors } from '../../config.json';
-import { messages } from '../../shared/gettext';
-import { useBoolean } from '../lib/utility-hooks';
-import * as AppButton from './AppButton';
-import ImageView from './ImageView';
-import { ModalAlert, ModalAlertType } from './Modal';
-
-const StyledInfoButton = styled.button({
- margin: '0 16px 0 8px',
- borderWidth: 0,
- padding: 0,
- cursor: 'default',
- backgroundColor: 'transparent',
-});
-
-interface IInfoIconProps {
- className?: string;
- size?: number;
- tintColor?: string;
- tintHoverColor?: string;
-}
-
-export function InfoIcon(props: IInfoIconProps) {
- return (
- <ImageView
- source="icon-info"
- width={props.size ?? 18}
- tintColor={props.tintColor ?? colors.white}
- tintHoverColor={props.tintHoverColor ?? colors.white80}
- className={props.className}
- />
- );
-}
-
-export interface IInfoButtonProps extends React.HTMLAttributes<HTMLButtonElement> {
- message?: string | Array<string>;
- children?: React.ReactNode;
- title?: string;
- size?: number;
- tintColor?: string;
- tintHoverColor?: string;
-}
-
-export default function InfoButton(props: IInfoButtonProps) {
- const { message, children, size, tintColor, tintHoverColor, ...otherProps } = props;
- const [isOpen, show, hide] = useBoolean(false);
-
- return (
- <>
- <StyledInfoButton
- onClick={show}
- aria-label={messages.pgettext('accessibility', 'More information')}
- {...otherProps}>
- <InfoIcon size={size} tintColor={tintColor} tintHoverColor={tintHoverColor} />
- </StyledInfoButton>
- <ModalAlert
- isOpen={isOpen}
- title={props.title}
- message={props.message}
- type={ModalAlertType.info}
- buttons={[
- <AppButton.BlueButton key="back" onClick={hide}>
- {messages.gettext('Got it!')}
- </AppButton.BlueButton>,
- ]}
- close={hide}>
- {props.children}
- </ModalAlert>
- </>
- );
-}
diff --git a/gui/src/renderer/components/KeyboardNavigation.tsx b/gui/src/renderer/components/KeyboardNavigation.tsx
deleted file mode 100644
index cbde4297bd..0000000000
--- a/gui/src/renderer/components/KeyboardNavigation.tsx
+++ /dev/null
@@ -1,139 +0,0 @@
-import React, { useCallback, useContext, useEffect, useMemo, useState } from 'react';
-import { useLocation } from 'react-router';
-
-import { useHistory } from '../lib/history';
-import { disableDismissForRoutes } from '../lib/routeHelpers';
-import { RoutePath } from '../lib/routes';
-import { useEffectEvent } from '../lib/utility-hooks';
-
-interface IKeyboardNavigationProps {
- children: React.ReactElement | Array<React.ReactElement>;
-}
-
-// Listens for and handles keyboard shortcuts
-export default function KeyboardNavigation(props: IKeyboardNavigationProps) {
- const { pop } = useHistory();
- const [backAction, setBackActionImpl] = useState<BackActionFn>();
- const location = useLocation();
-
- // Since the backaction is now a function we need to make sure it's not called when setting the
- // state.
- const setBackAction = useCallback((backAction: BackActionFn | undefined) => {
- setBackActionImpl(() => backAction);
- }, []);
-
- const handleKeyDown = useCallback(
- (event: KeyboardEvent) => {
- if (event.key === 'Escape') {
- const path = location.pathname as RoutePath;
- if (event.shiftKey && !disableDismissForRoutes.includes(path)) {
- pop(true);
- } else {
- backAction?.();
- }
- }
- },
- [pop, backAction, location.pathname],
- );
-
- useEffect(() => {
- document.addEventListener('keydown', handleKeyDown);
- return () => document.removeEventListener('keydown', handleKeyDown);
- }, [handleKeyDown]);
-
- return <BackActionTracker registerBackAction={setBackAction}>{props.children}</BackActionTracker>;
-}
-
-type BackActionFn = () => void;
-
-interface IBackActionContext {
- parentBackAction?: BackActionFn;
- registerBackAction: (backAction: BackActionFn) => void;
- removeBackAction: (backAction: BackActionFn) => void;
-}
-
-export const BackActionContext = React.createContext<IBackActionContext>({
- registerBackAction(_backAction) {
- throw new Error('Missing BackActionContext');
- },
- removeBackAction(_backAction) {
- throw new Error('Missing BackActionContext');
- },
-});
-
-interface IBackActionProps {
- disabled?: boolean;
- action: BackActionFn;
- children: React.ReactNode;
-}
-
-// Component for registering back actions, e.g. navigate back or close modal. These are called
-// either by pressing the back button in the navigation bar or by pressing escape.
-export function BackAction(props: IBackActionProps) {
- const { registerBackAction, removeBackAction } = useContext(BackActionContext);
- const [childrenBackAction, setChildrenBackActionImpl] = useState<BackActionFn>();
-
- // Since the backaction is now a function we need to make sure it's not called when setting the
- // state.
- const setChildrenBackAction = useCallback((backAction: BackActionFn | undefined) => {
- setChildrenBackActionImpl(() => backAction);
- }, []);
-
- // Each back action needs to be unique to make `removeBackAction` work. This is accomplished by
- // wrapping it in a callback. This was an issue since `history.pop`, which is commonly used as a
- // back action, is the same function for every component.
- const backAction = useCallback(() => {
- (childrenBackAction ?? props.action)();
- }, [props.action, childrenBackAction]);
-
- // Every time the action or the disabled property changes the action needs to be reregistered.
- useEffect((): (() => void) | void => {
- if (!props.disabled && backAction) {
- registerBackAction(backAction);
- return () => removeBackAction(backAction);
- }
- }, [props.disabled, backAction, registerBackAction, removeBackAction]);
-
- // Every back action keeps track of the back actions in its subtree. This makes it possible to
- // always use the action furthest down in the tree.
- return (
- <BackActionTracker registerBackAction={setChildrenBackAction} parentBackAction={props.action}>
- {props.children}
- </BackActionTracker>
- );
-}
-
-interface IBackActionTracker {
- parentBackAction?: BackActionFn;
- registerBackAction: (backAction: BackActionFn | undefined) => void;
- children: React.ReactNode;
-}
-
-// This component keeps track of all registered back actions in it's subtree and reports one of them
-// to it's parent.
-function BackActionTracker(props: IBackActionTracker) {
- const [backActions, setBackActions] = useState<Array<BackActionFn>>([]);
-
- const registerBackAction = useCallback((backAction: BackActionFn) => {
- setBackActions((backActions) => [...backActions, backAction]);
- }, []);
- const removeBackAction = useCallback((backAction: BackActionFn) => {
- setBackActions((backActions) => backActions.filter((action) => action !== backAction));
- }, []);
- const backActionContext = useMemo(
- () => ({ parentBackAction: props.parentBackAction, registerBackAction, removeBackAction }),
- [props.parentBackAction, registerBackAction, removeBackAction],
- );
-
- const registerBackActionEvent = useEffectEvent((backActions: Array<BackActionFn>) => {
- props.registerBackAction(backActions.at(0));
- });
-
- useEffect(() => registerBackActionEvent(backActions), [backActions]);
-
- return (
- <BackActionContext.Provider value={backActionContext}>
- {props.children}
- </BackActionContext.Provider>
- );
-}
diff --git a/gui/src/renderer/components/Lang.tsx b/gui/src/renderer/components/Lang.tsx
deleted file mode 100644
index 2145e12fbb..0000000000
--- a/gui/src/renderer/components/Lang.tsx
+++ /dev/null
@@ -1,14 +0,0 @@
-import { PropsWithChildren } from 'react';
-import styled from 'styled-components';
-
-import { useSelector } from '../redux/store';
-
-const StyledLang = styled.div({
- display: 'flex',
- flex: '1',
-});
-
-export default function Lang(props: PropsWithChildren) {
- const locale = useSelector((state) => state.userInterface.locale);
- return <StyledLang lang={locale}>{props.children}</StyledLang>;
-}
diff --git a/gui/src/renderer/components/Launch.tsx b/gui/src/renderer/components/Launch.tsx
deleted file mode 100644
index 7a8f75bc9c..0000000000
--- a/gui/src/renderer/components/Launch.tsx
+++ /dev/null
@@ -1,131 +0,0 @@
-import { useCallback } from 'react';
-import styled from 'styled-components';
-
-import { colors } from '../../config.json';
-import { messages } from '../../shared/gettext';
-import { useAppContext } from '../context';
-import { transitions, useHistory } from '../lib/history';
-import { RoutePath } from '../lib/routes';
-import { useBoolean } from '../lib/utility-hooks';
-import { useSelector } from '../redux/store';
-import * as AppButton from './AppButton';
-import { measurements, tinyText } from './common-styles';
-import ErrorView from './ErrorView';
-import { Footer } from './Layout';
-import { ModalAlert, ModalMessage, ModalMessageList } from './Modal';
-import { ModalAlertType } from './Modal';
-
-export default function Launch() {
- const daemonAllowed = useSelector((state) => state.userInterface.daemonAllowed);
- const footer = daemonAllowed === false ? <MacOsPermissionFooter /> : <DefaultFooter />;
-
- return (
- <ErrorView footer={footer}>
- {messages.pgettext('launch-view', 'Connecting to Mullvad system service...')}
- </ErrorView>
- );
-}
-
-const StyledFooter = styled(Footer)({
- backgroundColor: colors.blue,
- padding: `0 14px ${measurements.viewMargin}`,
- transition: 'opacity 250ms ease-in-out',
-});
-
-const StyledFooterInner = styled.div({
- display: 'flex',
- flexDirection: 'column',
- flex: 1,
- backgroundColor: colors.darkBlue,
- borderRadius: '8px',
- margin: 0,
- padding: '16px',
-});
-
-const StyledFooterMessage = styled.span(tinyText, {
- color: colors.white,
- margin: `8px 0 ${measurements.buttonVerticalMargin} 0`,
-});
-
-function MacOsPermissionFooter() {
- const { showLaunchDaemonSettings } = useAppContext();
-
- const openSettings = useCallback(async () => {
- await showLaunchDaemonSettings();
- }, [showLaunchDaemonSettings]);
-
- return (
- <StyledFooter>
- <StyledFooterInner>
- <StyledFooterMessage>
- {messages.pgettext(
- 'launch-view',
- 'Permission for the Mullvad VPN service has been revoked. Please go to System Settings and allow Mullvad VPN under the “Allow in the Background” setting.',
- )}
- </StyledFooterMessage>
- <AppButton.BlueButton onClick={openSettings}>
- {messages.gettext('Go to System Settings')}
- </AppButton.BlueButton>
- </StyledFooterInner>
- </StyledFooter>
- );
-}
-
-function DefaultFooter() {
- const { push } = useHistory();
- const [dialogVisible, showDialog, hideDialog] = useBoolean();
-
- const openSendProblemReport = useCallback(() => {
- hideDialog();
- push(RoutePath.problemReport, { transition: transitions.show });
- }, [hideDialog, push]);
-
- return (
- <>
- <StyledFooter>
- <StyledFooterInner>
- <StyledFooterMessage>
- {messages.pgettext(
- 'launch-view',
- 'Unable to contact the Mullvad system service, your connection might be unsecure. Please troubleshoot or send a problem report by clicking the Learn more button.',
- )}
- </StyledFooterMessage>
- <AppButton.BlueButton onClick={showDialog}>
- {messages.gettext('Learn more')}
- </AppButton.BlueButton>
- </StyledFooterInner>
- </StyledFooter>
- <ModalAlert
- isOpen={dialogVisible}
- type={ModalAlertType.info}
- close={hideDialog}
- buttons={[
- <AppButton.GreenButton key="problem-report" onClick={openSendProblemReport}>
- {messages.pgettext('launch-view', 'Send problem report')}
- </AppButton.GreenButton>,
- <AppButton.BlueButton key="back" onClick={hideDialog}>
- {messages.gettext('Back')}
- </AppButton.BlueButton>,
- ]}>
- <ModalMessage>
- {messages.pgettext(
- 'launch-view',
- 'The system service component of the app hasn’t started or can’t be contacted. The system service is responsible for the security, kill switch, and the VPN tunnel. To troubleshoot please try:',
- )}
- </ModalMessage>
- <ModalMessage>
- <ModalMessageList>
- <li>{messages.pgettext('launch-view', 'Restarting your computer.')}</li>
- <li>{messages.pgettext('launch-view', 'Reinstalling the app.')}</li>
- </ModalMessageList>
- </ModalMessage>
- <ModalMessage>
- {messages.pgettext(
- 'launch-view',
- 'If these steps do not work please send a problem report.',
- )}
- </ModalMessage>
- </ModalAlert>
- </>
- );
-}
diff --git a/gui/src/renderer/components/Layout.tsx b/gui/src/renderer/components/Layout.tsx
deleted file mode 100644
index b74cfd929f..0000000000
--- a/gui/src/renderer/components/Layout.tsx
+++ /dev/null
@@ -1,38 +0,0 @@
-import styled from 'styled-components';
-
-import { colors } from '../../config.json';
-import { measurements } from './common-styles';
-import HeaderBar from './HeaderBar';
-
-export const Header = styled(HeaderBar)({
- flex: 0,
-});
-
-export const Container = styled.div({
- display: 'flex',
- flexDirection: 'column',
- flex: 1,
- backgroundColor: colors.blue,
- overflow: 'hidden',
-});
-
-export const SettingsContainer = styled(Container)({
- backgroundColor: colors.darkBlue,
-});
-
-export const Layout = styled.div({
- display: 'flex',
- flexDirection: 'column',
- flex: 1,
- height: '100vh',
-});
-
-export const Footer = styled.div({
- display: 'flex',
- flexDirection: 'column',
- flex: 0,
- paddingTop: '18px',
- paddingLeft: measurements.viewMargin,
- paddingRight: measurements.viewMargin,
- paddingBottom: measurements.viewMargin,
-});
diff --git a/gui/src/renderer/components/List.tsx b/gui/src/renderer/components/List.tsx
deleted file mode 100644
index 4961855fba..0000000000
--- a/gui/src/renderer/components/List.tsx
+++ /dev/null
@@ -1,190 +0,0 @@
-import { useCallback, useEffect, useRef, useState } from 'react';
-import styled from 'styled-components';
-
-import { Scheduler } from '../../shared/scheduler';
-import { useEffectEvent } from '../lib/utility-hooks';
-import Accordion from './Accordion';
-
-export const stringValueAsKey = (value: string): string => value;
-
-const StyledListItem = styled.div({
- display: 'flex',
- flex: 1,
- flexDirection: 'column',
-});
-
-interface ListProps<T> {
- items: Array<T>;
- getKey: (data: T) => string;
- children: (data: T) => React.ReactNode;
- skipAddTransition?: boolean;
- skipInitialAddTransition?: boolean;
- skipRemoveTransition?: boolean;
-}
-
-export interface RowData<T> {
- key: string;
- data: T;
-}
-
-export interface RowDisplayData<T> extends RowData<T> {
- removing: boolean;
-}
-
-export default function List<T>(props: ListProps<T>) {
- const [displayItems, setDisplayItems] = useState(() =>
- convertToRowDisplayData(props.items, props.getKey),
- );
- // Skip add transition on first render when initial items are added.
- const [skipAddTransition, setSkipAddTransition] = useState(
- props.skipInitialAddTransition ?? false,
- );
-
- const removeFallbackSchedulers = useRef<Record<string, Scheduler>>({});
-
- const itemChangeEvent = useEffectEvent((items: Array<T>) => {
- setDisplayItems((prevItems) => {
- if (props.skipRemoveTransition) {
- return convertToRowDisplayData(items, props.getKey);
- } else {
- const nextItems = convertToRowData(items, props.getKey);
- return calculateItemList(prevItems, nextItems);
- }
- });
- });
-
- useEffect(() => itemChangeEvent(props.items), [props.items]);
-
- useEffect(() => {
- // Set to animate accordion for added items after first render unless
- // props.skipAddTransition === true.
- setSkipAddTransition(props.skipAddTransition ?? false);
- }, [props.skipAddTransition]);
-
- const onRemoved = useCallback((key: string) => {
- removeFallbackSchedulers.current[key].cancel();
- delete removeFallbackSchedulers.current[key];
-
- setDisplayItems((items) => items.filter((item) => item.key !== key));
- }, []);
-
- const handleDisplayItemsChange = useEffectEvent((displayItems: Array<RowDisplayData<T>>) => {
- // Add scheduled item removal if `onTransitionEnd` doesn't trigger for some reason.
- displayItems
- .filter((item) => item.removing && removeFallbackSchedulers.current[item.key] === undefined)
- .forEach((item) => {
- const scheduler = new Scheduler();
- scheduler.schedule(() => onRemoved(item.key), 400);
- removeFallbackSchedulers.current[item.key] = scheduler;
- });
- });
-
- useEffect(() => handleDisplayItemsChange(displayItems), [displayItems]);
-
- useEffect(
- () => () => {
- // Cancel all schedulers on unmount
- Object.values(removeFallbackSchedulers.current).forEach((scheduler) => scheduler.cancel());
- },
- [],
- );
-
- return (
- <>
- {displayItems.map((displayItem) => (
- <ListItem
- key={displayItem.key}
- data={displayItem}
- onRemoved={onRemoved}
- render={props.children}
- skipAddTransition={skipAddTransition}
- />
- ))}
- </>
- );
-}
-
-interface ListItemProps<T> {
- data: RowDisplayData<T>;
- onRemoved: (key: string) => void;
- render: (data: T) => React.ReactNode;
- skipAddTransition: boolean;
-}
-
-function ListItem<T>(props: ListItemProps<T>) {
- const { onRemoved } = props;
-
- // If skipAddTransition is true then the item is expanded from the beginning.
- const [expanded, setExpanded] = useState(props.skipAddTransition);
-
- const onTransitionEnd = useCallback(() => {
- if (props.data.removing) {
- onRemoved(props.data.key);
- }
- }, [onRemoved, props.data.key, props.data.removing]);
-
- // Expands after initial render and collapses when item is set to being removed.
- useEffect(() => setExpanded(!props.data.removing), [props.data.removing]);
-
- return (
- <Accordion expanded={expanded} onTransitionEnd={onTransitionEnd}>
- <StyledListItem>{props.render(props.data.data)}</StyledListItem>
- </Accordion>
- );
-}
-
-function convertToRowData<T>(items: Array<T>, getKey: (data: T) => string): Array<RowData<T>> {
- return items.map((item) => ({ key: getKey(item), data: item }));
-}
-
-function convertToRowDisplayData<T>(
- items: Array<T>,
- getKey: (data: T) => string,
- removing = false,
-): Array<RowDisplayData<T>> {
- return convertToRowData(items, getKey).map((item) => ({ ...item, removing }));
-}
-
-export function calculateItemList<T>(
- prevItemsList: Array<RowDisplayData<T>>,
- nextItemsList: Array<RowData<T>>,
-): Array<RowDisplayData<T>> {
- const prevItems = [...prevItemsList];
- const nextItems = [...nextItemsList];
-
- if (
- prevItems.length !== nextItems.length ||
- !prevItems.every((prevItem, i) => prevItem.key === nextItems[i].key)
- ) {
- // If the nextItems contains changes from prevItems we want to calculate the next state.
- const combinedItems: Array<RowDisplayData<T>> = [];
-
- while (prevItems.length > 0 || nextItems.length > 0) {
- const prevItem = prevItems[0];
- const nextItem = nextItems[0];
-
- // Either prevItem or nextItem must have a value since at least one of the lists isn't
- // empty.
- if (prevItem?.key === nextItem?.key) {
- combinedItems.push({ ...prevItem, removing: false });
- prevItems.shift();
- nextItems.shift();
- } else if (
- prevItem === undefined ||
- nextItems.find((item) => item.key === prevItem.key) !== undefined
- ) {
- // An item has been added if there are no more previous items or if the current prevItem
- // exists later in nextItems.
- combinedItems.push({ ...nextItem, removing: false });
- nextItems.shift();
- } else {
- combinedItems.push({ ...prevItem, removing: true });
- prevItems.shift();
- }
- }
-
- return combinedItems;
- } else {
- return prevItemsList;
- }
-}
diff --git a/gui/src/renderer/components/Login.tsx b/gui/src/renderer/components/Login.tsx
deleted file mode 100644
index 0d234bd66b..0000000000
--- a/gui/src/renderer/components/Login.tsx
+++ /dev/null
@@ -1,530 +0,0 @@
-import React, { useCallback } from 'react';
-import { sprintf } from 'sprintf-js';
-
-import { colors } from '../../config.json';
-import { AccountDataError, AccountNumber } from '../../shared/daemon-rpc-types';
-import { messages } from '../../shared/gettext';
-import { useAppContext } from '../context';
-import { formatAccountNumber } from '../lib/account';
-import useActions from '../lib/actionsHook';
-import { formatHtml } from '../lib/html-formatter';
-import accountActions from '../redux/account/actions';
-import { LoginState } from '../redux/account/reducers';
-import { useSelector } from '../redux/store';
-import Accordion from './Accordion';
-import * as AppButton from './AppButton';
-import { AriaControlGroup, AriaControlled, AriaControls } from './AriaGroup';
-import { Brand, HeaderBarSettingsButton } from './HeaderBar';
-import ImageView from './ImageView';
-import { Container, Header, Layout } from './Layout';
-import {
- StyledAccountDropdownContainer,
- StyledAccountDropdownItem,
- StyledAccountDropdownItemButton,
- StyledAccountDropdownItemButtonLabel,
- StyledAccountDropdownRemoveButton,
- StyledAccountDropdownRemoveIcon,
- StyledAccountInputBackdrop,
- StyledAccountInputGroup,
- StyledBlockMessage,
- StyledBlockMessageContainer,
- StyledBlockTitle,
- StyledDropdownSpacer,
- StyledFooter,
- StyledInput,
- StyledInputButton,
- StyledInputSubmitIcon,
- StyledLoginFooterPrompt,
- StyledLoginForm,
- StyledStatusIcon,
- StyledSubtitle,
- StyledTitle,
- StyledTopInfo,
-} from './LoginStyles';
-
-export default function LoginContainer() {
- const { openUrl, login, clearAccountHistory, createNewAccount } = useAppContext();
- const { resetLoginError, updateAccountNumber } = useActions(accountActions);
-
- const { accountNumber, accountHistory, status } = useSelector((state) => state.account);
-
- const tunnelState = useSelector((state) => state.connection.status);
- const blockWhenDisconnected = useSelector((state) => state.settings.blockWhenDisconnected);
- const showBlockMessage = tunnelState.state === 'error' || blockWhenDisconnected;
-
- const isPerformingPostUpgrade = useSelector(
- (state) => state.userInterface.isPerformingPostUpgrade,
- );
-
- return (
- <Login
- accountNumber={accountNumber}
- accountHistory={accountHistory}
- loginState={status}
- showBlockMessage={showBlockMessage}
- openExternalLink={openUrl}
- login={login}
- resetLoginError={resetLoginError}
- updateAccountNumber={updateAccountNumber}
- clearAccountHistory={clearAccountHistory}
- createNewAccount={createNewAccount}
- isPerformingPostUpgrade={isPerformingPostUpgrade}
- />
- );
-}
-
-interface IProps {
- accountNumber?: AccountNumber;
- accountHistory?: AccountNumber;
- loginState: LoginState;
- showBlockMessage: boolean;
- openExternalLink: (type: string) => void;
- login: (accountNumber: AccountNumber) => void;
- resetLoginError: () => void;
- updateAccountNumber: (accountNumber: AccountNumber) => void;
- clearAccountHistory: () => Promise<void>;
- createNewAccount: () => void;
- isPerformingPostUpgrade?: boolean;
-}
-
-interface IState {
- isActive: boolean;
-}
-
-const MIN_ACCOUNT_NUMBER_LENGTH = 10;
-
-class Login extends React.Component<IProps, IState> {
- public state: IState = {
- isActive: true,
- };
-
- private accountInput = React.createRef<HTMLInputElement>();
- private shouldResetLoginError = false;
-
- constructor(props: IProps) {
- super(props);
-
- if (props.loginState.type === 'failed') {
- this.shouldResetLoginError = true;
- }
- }
-
- public componentDidUpdate(prevProps: IProps, _prevState: IState) {
- if (
- this.props.loginState.type !== prevProps.loginState.type &&
- this.props.loginState.type === 'failed'
- ) {
- this.shouldResetLoginError = true;
-
- // focus on login field when failed to log in
- this.accountInput.current?.focus();
- }
- }
-
- public render() {
- const allowInteraction = this.allowInteraction();
-
- return (
- <Layout>
- <Header>
- <Brand />
- <HeaderBarSettingsButton disabled={!allowInteraction} />
- </Header>
- <Container>
- <StyledTopInfo>
- {this.props.showBlockMessage ? <BlockMessage /> : this.getStatusIcon()}
- </StyledTopInfo>
-
- <StyledLoginForm>
- <StyledTitle aria-live="polite">{this.formTitle()}</StyledTitle>
-
- {this.createLoginForm()}
- </StyledLoginForm>
-
- <StyledFooter $show={allowInteraction}>{this.createFooter()}</StyledFooter>
- </Container>
- </Layout>
- );
- }
-
- private onFocus = () => {
- this.setState({ isActive: true });
- };
-
- private onBlur = (e: React.FocusEvent<HTMLInputElement>) => {
- // restore focus if click happened within dropdown
- if (e.relatedTarget) {
- if (this.accountInput.current) {
- this.accountInput.current.focus();
- }
- return;
- }
-
- this.setState({ isActive: false });
- };
-
- private onSubmit = (event?: React.FormEvent) => {
- event?.preventDefault();
-
- if (this.accountNumberValid()) {
- this.props.login(this.props.accountNumber!);
- }
- };
-
- private onInputChange = (accountNumber: string) => {
- // reset error when user types in the new account number
- if (this.shouldResetLoginError) {
- this.shouldResetLoginError = false;
- this.props.resetLoginError();
- }
-
- this.props.updateAccountNumber(accountNumber);
- };
-
- private formTitle() {
- if (this.props.isPerformingPostUpgrade) {
- return messages.pgettext('login-view', 'Upgrading...');
- }
-
- switch (this.props.loginState.type) {
- case 'logging in':
- case 'too many devices':
- return this.props.loginState.method === 'existing_account'
- ? messages.pgettext('login-view', 'Logging in...')
- : messages.pgettext('login-view', 'Creating account...');
- case 'failed':
- return this.props.loginState.method === 'existing_account'
- ? messages.pgettext('login-view', 'Login failed')
- : messages.pgettext('login-view', 'Error');
- case 'ok':
- return this.props.loginState.method === 'existing_account'
- ? messages.pgettext('login-view', 'Logged in')
- : messages.pgettext('login-view', 'Account created');
- default:
- return messages.pgettext('login-view', 'Login');
- }
- }
-
- private formSubtitle() {
- if (this.props.isPerformingPostUpgrade) {
- return messages.pgettext('login-view', 'Finishing upgrade.');
- }
-
- switch (this.props.loginState.type) {
- case 'failed':
- return this.props.loginState.method === 'existing_account'
- ? this.errorString(this.props.loginState.error)
- : messages.pgettext('login-view', 'Failed to create account');
- case 'too many devices':
- return messages.pgettext('login-view', 'Too many devices');
- case 'logging in':
- return this.props.loginState.method === 'existing_account'
- ? messages.pgettext('login-view', 'Checking account number')
- : messages.pgettext('login-view', 'Please wait');
- case 'ok':
- return this.props.loginState.method === 'existing_account'
- ? messages.pgettext('login-view', 'Valid account number')
- : messages.pgettext('login-view', 'Logged in');
- default:
- return messages.pgettext('login-view', 'Enter your account number');
- }
- }
-
- private errorString(error: AccountDataError['error']): string {
- switch (error) {
- case 'invalid-account':
- // TRANSLATORS: Error message shown above login input when trying to login with a
- // TRANSLATORS: non-existent account number.
- return messages.pgettext('login-view', 'Invalid account number');
- case 'too-many-devices':
- // TRANSLATORS: Error message shown above login input when trying to login to an account
- // TRANSLATORS: with too many registered devices.
- return messages.pgettext('login-view', 'Too many devices');
- case 'list-devices':
- // TRANSLATORS: Error message shown above login input when trying to login but the app fails
- // TRANSLATORS: to fetch the list of registered devices.
- return messages.pgettext('login-view', 'Failed to fetch list of devices');
- case 'communication':
- return 'api.mullvad.net is blocked, please check your firewall';
- default:
- return messages.pgettext('login-view', 'Unknown error');
- }
- }
-
- private getStatusIcon() {
- const statusIconPath = this.getStatusIconPath();
- return (
- <StyledStatusIcon>
- {statusIconPath ? <ImageView source={statusIconPath} height={48} width={48} /> : null}
- </StyledStatusIcon>
- );
- }
-
- private getStatusIconPath(): string | undefined {
- if (this.props.isPerformingPostUpgrade) {
- return 'icon-spinner';
- }
-
- switch (this.props.loginState.type) {
- case 'logging in':
- return 'icon-spinner';
- case 'failed':
- return 'icon-fail';
- case 'ok':
- return 'icon-success';
- default:
- return undefined;
- }
- }
-
- private allowInteraction() {
- return (
- !this.props.isPerformingPostUpgrade &&
- this.props.loginState.type !== 'logging in' &&
- this.props.loginState.type !== 'ok' &&
- this.props.loginState.type !== 'too many devices'
- );
- }
-
- private allowCreateAccount() {
- const { accountNumber } = this.props;
- return this.allowInteraction() && (accountNumber === undefined || accountNumber.length === 0);
- }
-
- private accountNumberValid(): boolean {
- const { accountNumber } = this.props;
- return accountNumber !== undefined && accountNumber.length >= MIN_ACCOUNT_NUMBER_LENGTH;
- }
-
- private shouldShowAccountHistory() {
- return this.allowInteraction() && this.props.accountHistory !== undefined;
- }
-
- private onSelectAccountFromHistory = (accountNumber: string) => {
- this.props.updateAccountNumber(accountNumber);
- this.props.login(accountNumber);
- };
-
- private onClearAccountHistory = () => {
- void this.clearAccountHistory();
- };
-
- private async clearAccountHistory() {
- try {
- await this.props.clearAccountHistory();
-
- // TODO: Remove account from memory
- } catch {
- // TODO: Show error
- }
- }
-
- private createLoginForm() {
- const allowInteraction = this.allowInteraction();
- const allowLogin = allowInteraction && this.accountNumberValid();
- const hasError =
- this.props.loginState.type === 'failed' &&
- this.props.loginState.method === 'existing_account';
-
- return (
- <>
- <StyledSubtitle data-testid="subtitle">{this.formSubtitle()}</StyledSubtitle>
- <StyledAccountInputGroup
- $active={allowInteraction && this.state.isActive}
- $editable={allowInteraction}
- $error={hasError}
- onSubmit={this.onSubmit}>
- <StyledAccountInputBackdrop>
- <StyledInput
- allowedCharacters="[0-9]"
- separator=" "
- groupLength={4}
- placeholder="0000 0000 0000 0000"
- value={this.props.accountNumber || ''}
- disabled={!allowInteraction}
- onFocus={this.onFocus}
- onBlur={this.onBlur}
- handleChange={this.onInputChange}
- autoFocus={true}
- ref={this.accountInput}
- aria-autocomplete="list"
- />
- <StyledInputButton
- type="submit"
- $visible={allowLogin}
- disabled={!allowLogin}
- aria-label={
- // TRANSLATORS: This is used by screenreaders to communicate the login button.
- messages.pgettext('accessibility', 'Login')
- }>
- <StyledInputSubmitIcon
- $visible={
- this.props.loginState.type !== 'logging in' && !this.props.isPerformingPostUpgrade
- }
- source="icon-arrow"
- height={16}
- width={24}
- tintColor="rgb(255, 255, 255)"
- />
- </StyledInputButton>
- </StyledAccountInputBackdrop>
- <Accordion expanded={this.shouldShowAccountHistory()}>
- <StyledAccountDropdownContainer>
- <AccountDropdown
- item={this.props.accountHistory}
- onSelect={this.onSelectAccountFromHistory}
- onRemove={this.onClearAccountHistory}
- />
- </StyledAccountDropdownContainer>
- </Accordion>
- </StyledAccountInputGroup>
- </>
- );
- }
-
- private createFooter() {
- return (
- <>
- <StyledLoginFooterPrompt>
- {messages.pgettext('login-view', 'Don’t have an account number?')}
- </StyledLoginFooterPrompt>
- <AppButton.BlueButton
- onClick={this.props.createNewAccount}
- disabled={!this.allowCreateAccount()}>
- {messages.pgettext('login-view', 'Create account')}
- </AppButton.BlueButton>
- </>
- );
- }
-}
-
-interface IAccountDropdownProps {
- item?: AccountNumber;
- onSelect: (value: AccountNumber) => void;
- onRemove: (value: AccountNumber) => void;
-}
-
-function AccountDropdown(props: IAccountDropdownProps) {
- const accountNumber = props.item;
- if (!accountNumber) {
- return null;
- }
- const label = formatAccountNumber(accountNumber);
- return (
- <AccountDropdownItem
- value={accountNumber}
- label={label}
- onSelect={props.onSelect}
- onRemove={props.onRemove}
- />
- );
-}
-
-interface IAccountDropdownItemProps {
- label: string;
- value: AccountNumber;
- onRemove: (value: AccountNumber) => void;
- onSelect: (value: AccountNumber) => void;
-}
-
-function AccountDropdownItem(props: IAccountDropdownItemProps) {
- const { onSelect, onRemove } = props;
-
- const handleSelect = useCallback(() => {
- onSelect(props.value);
- }, [onSelect, props.value]);
-
- const handleRemove = useCallback(
- (event: React.MouseEvent<HTMLButtonElement>) => {
- // Prevent login form from submitting
- event.preventDefault();
- onRemove(props.value);
- },
- [onRemove, props.value],
- );
-
- return (
- <>
- <StyledDropdownSpacer />
- <StyledAccountDropdownItem>
- <AriaControlGroup>
- <AriaControlled>
- <StyledAccountDropdownItemButton id={props.label} onClick={handleSelect} type="button">
- <StyledAccountDropdownItemButtonLabel>
- {props.label}
- </StyledAccountDropdownItemButtonLabel>
- </StyledAccountDropdownItemButton>
- </AriaControlled>
- <AriaControls>
- <StyledAccountDropdownRemoveButton
- onClick={handleRemove}
- aria-controls={props.label}
- aria-label={
- // TRANSLATORS: This is used by screenreaders to communicate the "x" button next to a saved account number.
- // TRANSLATORS: Available placeholders:
- // TRANSLATORS: %(accountNumber)s - the account number to the left of the button
- sprintf(messages.pgettext('accessibility', 'Forget %(accountNumber)s'), {
- accountNumber: props.label,
- })
- }>
- <StyledAccountDropdownRemoveIcon
- tintColor={colors.blue40}
- tintHoverColor={colors.blue}
- source="icon-close-sml"
- height={16}
- width={16}
- />
- </StyledAccountDropdownRemoveButton>
- </AriaControls>
- </AriaControlGroup>
- </StyledAccountDropdownItem>
- </>
- );
-}
-
-function BlockMessage() {
- const { setBlockWhenDisconnected, disconnectTunnel } = useAppContext();
- const tunnelState = useSelector((state) => state.connection.status);
- const blockWhenDisconnected = useSelector((state) => state.settings.blockWhenDisconnected);
-
- const unlock = useCallback(() => {
- if (blockWhenDisconnected) {
- void setBlockWhenDisconnected(false);
- }
-
- if (tunnelState.state === 'error') {
- void disconnectTunnel();
- }
- }, [blockWhenDisconnected, tunnelState, setBlockWhenDisconnected, disconnectTunnel]);
-
- const lockdownModeSettingName = messages.pgettext('vpn-settings-view', 'Lockdown mode');
- const message = formatHtml(
- blockWhenDisconnected
- ? sprintf(
- // TRANSLATORS: This is a warning message shown when the app is blocking the users
- // TRANSLATORS: internet connection while logged out.
- // TRANSLATORS: Available placeholder:
- // TRANSLATORS: %(lockdownModeSettingName)s - The translation of "Lockdown mode"
- messages.pgettext(
- 'login-view',
- '<b>%(lockdownModeSettingName)s</b> is enabled. Disable it to unblock your connection.',
- ),
- { lockdownModeSettingName },
- )
- : // This makes the translator comment appear on it's own line.
- // TRANSLATORS: This is a warning message shown when the app is blocking the users
- // TRANSLATORS: internet connection while logged out.
- messages.pgettext('login-view', 'Our kill switch is currently blocking your connection.'),
- );
- const buttonText = blockWhenDisconnected
- ? messages.gettext('Disable')
- : messages.gettext('Unblock');
-
- return (
- <StyledBlockMessageContainer>
- <StyledBlockTitle>{messages.gettext('Blocking internet')}</StyledBlockTitle>
- <StyledBlockMessage>{message}</StyledBlockMessage>
- <AppButton.RedButton onClick={unlock}>{buttonText}</AppButton.RedButton>
- </StyledBlockMessageContainer>
- );
-}
diff --git a/gui/src/renderer/components/LoginStyles.tsx b/gui/src/renderer/components/LoginStyles.tsx
deleted file mode 100644
index 4a597b07b3..0000000000
--- a/gui/src/renderer/components/LoginStyles.tsx
+++ /dev/null
@@ -1,193 +0,0 @@
-import styled from 'styled-components';
-
-import { colors } from '../../config.json';
-import * as Cell from './cell';
-import { hugeText, largeText, measurements, smallText, tinyText } from './common-styles';
-import FormattableTextInput from './FormattableTextInput';
-import ImageView from './ImageView';
-import { Footer } from './Layout';
-
-export const StyledAccountDropdownContainer = styled.ul({
- display: 'flex',
- flexDirection: 'column',
-});
-
-export const StyledAccountDropdownRemoveButton = styled.button({
- border: 'none',
- background: 'none',
-});
-
-export const StyledAccountDropdownRemoveIcon = styled(ImageView)({
- justifyContent: 'center',
- paddingTop: '10px',
- paddingRight: '12px',
- paddingBottom: '12px',
- paddingLeft: '12px',
- marginLeft: '0px',
-});
-
-export const StyledInputSubmitIcon = styled(ImageView)<{ $visible: boolean }>((props) => ({
- flex: 0,
- borderWidth: '0px',
- width: '48px',
- alignItems: 'center',
- justifyContent: 'center',
- opacity: props.$visible ? 1 : 0,
-}));
-
-export const StyledAccountDropdownItem = styled.li({
- display: 'flex',
- flex: 1,
- backgroundColor: colors.white60,
- cursor: 'default',
- '&&:hover': {
- backgroundColor: colors.white40,
- },
-});
-
-export const StyledAccountDropdownItemButton = styled(Cell.CellButton)({
- padding: '0px',
- marginBottom: '0px',
- flexDirection: 'row',
- alignItems: 'stretch',
- backgroundColor: 'transparent',
- '&&:not(:disabled):hover': {
- backgroundColor: 'transparent',
- },
-});
-
-export const StyledAccountDropdownItemButtonLabel = styled(Cell.Label)(largeText, {
- padding: '11px 0px 11px 12px',
- margin: '0',
- color: colors.blue80,
- borderWidth: 0,
- textAlign: 'left',
- marginLeft: 0,
- cursor: 'default',
- [StyledAccountDropdownItemButton + ':hover']: {
- color: colors.blue,
- },
-});
-
-export const StyledTopInfo = styled.div({
- display: 'flex',
- justifyContent: 'center',
- flex: 1,
-});
-
-export const StyledFooter = styled(Footer)<{ $show: boolean }>((props) => ({
- position: 'relative',
- width: '100%',
- bottom: 0,
- transform: `translateY(${props.$show ? 0 : 100}%)`,
- backgroundColor: colors.darkBlue,
- transition: 'transform 250ms ease-in-out',
-}));
-
-export const StyledStatusIcon = styled.div({
- display: 'flex',
- alignSelf: 'end',
- flex: 0,
- marginBottom: '30px',
- justifyContent: 'center',
- height: '48px',
- minHeight: '48px',
-});
-
-export const StyledLoginForm = styled.div({
- display: 'flex',
- flex: '0 1 225px',
- flexDirection: 'column',
- overflow: 'visible',
- padding: `0 ${measurements.viewMargin}`,
-});
-
-interface IStyledAccountInputGroupProps {
- $editable: boolean;
- $active: boolean;
- $error: boolean;
-}
-
-export const StyledAccountInputGroup = styled.form<IStyledAccountInputGroupProps>((props) => ({
- borderWidth: '2px',
- borderStyle: 'solid',
- borderRadius: '8px',
- overflow: 'hidden',
- borderColor: props.$error ? colors.red40 : props.$active ? colors.darkBlue : 'transparent',
- opacity: props.$editable ? 1 : 0.6,
-}));
-
-export const StyledAccountInputBackdrop = styled.div({
- display: 'flex',
- backgroundColor: colors.white,
- borderColor: colors.darkBlue,
-});
-
-export const StyledInputButton = styled.button<{ $visible: boolean }>((props) => ({
- display: 'flex',
- flex: 0,
- borderWidth: 0,
- width: '48px',
- alignItems: 'center',
- justifyContent: 'center',
- opacity: props.$visible ? 1 : 0,
- transition: 'opacity 250ms ease-in-out',
- backgroundColor: colors.green,
-}));
-
-export const StyledDropdownSpacer = styled.div({
- height: 1,
- backgroundColor: colors.darkBlue,
-});
-
-export const StyledLoginFooterPrompt = styled.span(tinyText, {
- color: colors.white60,
- marginBottom: '8px',
-});
-
-export const StyledTitle = styled.h1(hugeText, {
- lineHeight: '40px',
- marginBottom: '7px',
- flex: 0,
-});
-
-export const StyledSubtitle = styled.span(tinyText, {
- lineHeight: '15px',
- marginBottom: '8px',
- color: colors.white60,
-});
-
-export const StyledInput = styled(FormattableTextInput)(largeText, {
- fontWeight: 700,
- minWidth: 0,
- borderWidth: 0,
- padding: '12px 12px 12px',
- color: colors.blue,
- backgroundColor: 'transparent',
- flex: 1,
- '&&::placeholder': {
- color: colors.blue40,
- },
-});
-
-export const StyledBlockMessageContainer = styled.div({
- display: 'flex',
- flexDirection: 'column',
- flex: 1,
- alignSelf: 'start',
- backgroundColor: colors.darkBlue,
- borderRadius: '8px',
- margin: '5px 16px 10px',
- padding: '16px',
-});
-
-export const StyledBlockTitle = styled.div(smallText, {
- color: colors.white,
- marginBottom: '5px',
- fontWeight: 700,
-});
-
-export const StyledBlockMessage = styled.div(tinyText, {
- color: colors.white,
- marginBottom: '10px',
-});
diff --git a/gui/src/renderer/components/MacOsScrollbarDetection.tsx b/gui/src/renderer/components/MacOsScrollbarDetection.tsx
deleted file mode 100644
index 520b6f3f3b..0000000000
--- a/gui/src/renderer/components/MacOsScrollbarDetection.tsx
+++ /dev/null
@@ -1,45 +0,0 @@
-import { useEffect } from 'react';
-import styled from 'styled-components';
-
-import { MacOsScrollbarVisibility } from '../../shared/ipc-schema';
-import useActions from '../lib/actionsHook';
-import { useEffectEvent, useStyledRef } from '../lib/utility-hooks';
-import { useSelector } from '../redux/store';
-import userInterface from '../redux/userinterface/actions';
-
-const StyledContainer = styled.div({
- position: 'absolute',
- visibility: 'hidden',
- overflowY: 'scroll',
- overflowX: 'hidden',
- width: '1px',
- height: '0px',
-});
-
-// This component is used to determine whether scrollbars should be always visible or only visible
-// while scrolling when the system setting for this is set to "Automatic". This is detected by
-// testing if any space is taken by a scrollbar.
-export default function MacOsScrollbarDetection() {
- const visibility = useSelector((state) => state.userInterface.macOsScrollbarVisibility);
- const { setMacOsScrollbarVisibility } = useActions(userInterface);
- const ref = useStyledRef<HTMLDivElement>();
-
- const detectVisibility = useEffectEvent((visibility?: MacOsScrollbarVisibility) => {
- if (visibility === MacOsScrollbarVisibility.automatic) {
- // If the width is 0 then the 1 px width of the parent has been used by the scrollbar.
- const newVisibility =
- ref.current?.offsetWidth === 0
- ? MacOsScrollbarVisibility.always
- : MacOsScrollbarVisibility.whenScrolling;
- setMacOsScrollbarVisibility(newVisibility);
- }
- });
-
- useEffect(() => detectVisibility(visibility), [visibility]);
-
- return (
- <StyledContainer>
- <div ref={ref} />
- </StyledContainer>
- );
-}
diff --git a/gui/src/renderer/components/Map.tsx b/gui/src/renderer/components/Map.tsx
deleted file mode 100644
index 9ea69251d9..0000000000
--- a/gui/src/renderer/components/Map.tsx
+++ /dev/null
@@ -1,205 +0,0 @@
-import { useCallback, useEffect, useMemo, useRef } from 'react';
-import styled from 'styled-components';
-
-import { TunnelState } from '../../shared/daemon-rpc-types';
-import log from '../../shared/logging';
-import { useAppContext } from '../context';
-import GlMap, { ConnectionState, Coordinate } from '../lib/3dmap';
-import {
- useCombinedRefs,
- useEffectEvent,
- useRefCallback,
- useRerenderer,
-} from '../lib/utility-hooks';
-import { useSelector } from '../redux/store';
-
-// Default to Gothenburg when we don't know the actual location.
-const defaultLocation: Coordinate = { latitude: 57.70887, longitude: 11.97456 };
-
-const StyledCanvas = styled.canvas({
- position: 'absolute',
- width: '100%',
- height: '100%',
-});
-
-interface MapParams {
- location: Coordinate;
- connectionState: ConnectionState;
-}
-
-export default function Map() {
- const connection = useSelector((state) => state.connection);
- const animateMap = useSelector((state) => state.settings.guiSettings.animateMap);
-
- const hasLocationValue = hasLocation(connection);
- const location = useMemo<Coordinate | undefined>(() => {
- return hasLocationValue ? connection : defaultLocation;
- // eslint-disable-next-line react-compiler/react-compiler
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [hasLocationValue, connection.longitude, connection.latitude]);
-
- if (window.env.e2e) {
- return null;
- }
-
- const connectionState = getConnectionState(hasLocationValue, connection.status.state);
-
- const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
- const animate = !reduceMotion && animateMap;
-
- return (
- <MapInner
- location={location ?? defaultLocation}
- connectionState={connectionState}
- animate={animate}
- />
- );
-}
-
-function hasLocation(location: Partial<Coordinate>): location is Coordinate {
- return typeof location.latitude === 'number' && typeof location.longitude === 'number';
-}
-
-function getConnectionState(hasLocation: boolean, connectionState: TunnelState['state']) {
- if (!hasLocation) {
- return ConnectionState.noMarker;
- }
-
- switch (connectionState) {
- case 'connected':
- return ConnectionState.connected;
- case 'disconnected':
- return ConnectionState.disconnected;
- default:
- return ConnectionState.noMarker;
- }
-}
-
-interface MapInnerProps extends MapParams {
- animate: boolean;
-}
-
-function MapInner(props: MapInnerProps) {
- const { getMapData } = useAppContext();
-
- // When location or connection state changes it's stored here until passed to 3dmap
- const newParams = useRef<MapParams>();
-
- // This is set to true when rendering should be paused
- const pause = useRef<boolean>(false);
-
- const mapRef = useRef<GlMap>();
- const canvasRef = useRef<HTMLCanvasElement>();
-
- // eslint-disable-next-line react-compiler/react-compiler
- const width = applyPixelRatio(canvasRef.current?.clientWidth ?? window.innerWidth);
-
- // This constant is used for the height the first frame that is rendered only.
- // eslint-disable-next-line react-compiler/react-compiler
- const height = applyPixelRatio(canvasRef.current?.clientHeight ?? 493);
-
- // Hack to rerender when window size changes or when ref is set.
- const [onSizeChangeImpl, sizeChangeCounter] = useRerenderer();
- const onSizeChange = useEffectEvent(onSizeChangeImpl);
-
- const animationFrameCallback = useEffectEvent((now: number) => {
- now *= 0.001; // convert to seconds
-
- // Propagate location change to the map
- if (newParams.current) {
- mapRef.current?.setLocation(
- newParams.current.location,
- newParams.current.connectionState,
- now,
- props.animate,
- );
- newParams.current = undefined;
- }
-
- mapRef.current?.draw(now);
-
- // Stops rendering if pause is true. This happens when there is no ongoing movements
- if (!pause.current) {
- render();
- }
- });
-
- const render = useCallback(() => requestAnimationFrame(animationFrameCallback), []);
-
- // This is called when the canvas has been rendered the first time and initializes the gl context
- // and the map.
- const canvasCallback = useRefCallback(async (canvas: HTMLCanvasElement | null) => {
- if (!canvas) {
- return;
- }
-
- onSizeChange();
-
- const gl = canvas.getContext('webgl2', { antialias: true })!;
-
- mapRef.current = new GlMap(
- gl,
- await getMapData(),
- props.location,
- props.connectionState,
- () => (pause.current = true),
- );
-
- render();
- });
-
- // Set new params when the location or connection state has changed, and unpause if paused
- useEffect(() => {
- newParams.current = {
- location: props.location,
- connectionState: props.connectionState,
- };
-
- if (pause.current) {
- pause.current = false;
- render();
- }
- }, [props.location, props.connectionState, render]);
-
- useEffect(() => {
- mapRef.current?.updateViewport();
- render();
- }, [width, height, sizeChangeCounter, render]);
-
- // Resize canvas if window size changes
- useEffect(() => {
- addEventListener('resize', onSizeChange);
- return () => removeEventListener('resize', onSizeChange);
- }, []);
-
- useEffect(() => {
- const unsubscribe = window.ipc.window.listenScaleFactorChange(onSizeChange);
- return () => unsubscribe();
- }, []);
-
- const devicePixelRatio = window.devicePixelRatio;
-
- // Log new scale factor if it changes
- useEffect(() => {
- log.verbose(`Map canvas scale factor: ${devicePixelRatio}, using: ${getPixelRatio()}`);
- }, [devicePixelRatio]);
-
- const combinedCanvasRef = useCombinedRefs(canvasRef, canvasCallback);
-
- return <StyledCanvas ref={combinedCanvasRef} width={width} height={height} />;
-}
-
-function getPixelRatio(): number {
- let pixelRatio = window.devicePixelRatio;
-
- // Wayland renders non-integer values as the next integer and then scales it back down.
- if (window.env.platform === 'linux') {
- pixelRatio = Math.ceil(pixelRatio);
- }
-
- return pixelRatio;
-}
-
-function applyPixelRatio(dimension: number): number {
- return Math.floor(dimension * getPixelRatio());
-}
diff --git a/gui/src/renderer/components/Marquee.tsx b/gui/src/renderer/components/Marquee.tsx
deleted file mode 100644
index c654e473d2..0000000000
--- a/gui/src/renderer/components/Marquee.tsx
+++ /dev/null
@@ -1,99 +0,0 @@
-import React from 'react';
-import styled from 'styled-components';
-
-import { Scheduler } from '../../shared/scheduler';
-
-const Container = styled.div({
- overflow: 'hidden',
-});
-
-const Text = styled.span<{ $overflow: number; $alignRight: boolean }>((props) => ({
- display: 'inline-block',
- // Prevents Container from adding 2px below the text.
- verticalAlign: 'middle',
- whiteSpace: 'nowrap',
- willChange: props.$overflow > 0 ? 'transform' : 'auto',
- transform: props.$alignRight
- ? `translate3d(${-props.$overflow}px, 0, 0)`
- : 'translate3d(0, 0, 0)',
- transition: `transform linear ${props.$overflow * 80}ms`,
-}));
-
-interface IMarqueeProps {
- className?: string;
- children?: React.ReactNode;
-}
-
-interface IMarqueeState extends React.HTMLAttributes<HTMLSpanElement> {
- alignRight: boolean;
- // uniqueKey is used to force the Text component to remount to achieve the initial position of the
- // text without using a transition.
- uniqueKey: number;
-}
-
-export default class Marquee extends React.Component<IMarqueeProps, IMarqueeState> {
- public state = {
- alignRight: false,
- uniqueKey: 0,
- };
-
- private textRef = React.createRef<HTMLSpanElement>();
- private scheduler = new Scheduler();
-
- public componentDidMount() {
- this.startAnimationIfOverflow();
- }
-
- public componentDidUpdate(prevProps: IMarqueeProps) {
- if (this.props.children !== prevProps.children) {
- this.scheduler.cancel();
- this.setState(
- (state) => ({
- alignRight: false,
- uniqueKey: state.uniqueKey + 1,
- }),
- this.startAnimationIfOverflow,
- );
- }
- }
-
- public componentWillUnmount() {
- this.scheduler.cancel();
- }
-
- public render() {
- const { children, ...otherProps } = this.props;
-
- return (
- <Container>
- <Text
- key={this.state.uniqueKey}
- ref={this.textRef}
- $overflow={this.calculateOverflow()}
- $alignRight={this.state.alignRight}
- onTransitionEnd={this.scheduleToggleAlignRight}
- {...otherProps}>
- {children}
- </Text>
- </Container>
- );
- }
-
- private startAnimationIfOverflow = () => {
- if (this.calculateOverflow() > 0) {
- this.scheduleToggleAlignRight();
- }
- };
-
- private scheduleToggleAlignRight = () => {
- this.scheduler.schedule(() => {
- this.setState((state) => ({ alignRight: !state.alignRight }));
- }, 2000);
- };
-
- private calculateOverflow() {
- const textWidth = this.textRef.current?.offsetWidth ?? 0;
- const parentWidth = this.textRef.current?.parentElement?.offsetWidth ?? 0;
- return textWidth - parentWidth;
- }
-}
diff --git a/gui/src/renderer/components/Modal.tsx b/gui/src/renderer/components/Modal.tsx
deleted file mode 100644
index e339566c24..0000000000
--- a/gui/src/renderer/components/Modal.tsx
+++ /dev/null
@@ -1,376 +0,0 @@
-import React, { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
-import ReactDOM from 'react-dom';
-import styled from 'styled-components';
-
-import { colors } from '../../config.json';
-import log from '../../shared/logging';
-import { useEffectEvent } from '../lib/utility-hooks';
-import { useWillExit } from '../lib/will-exit';
-import * as AppButton from './AppButton';
-import { measurements, normalText, tinyText } from './common-styles';
-import CustomScrollbars from './CustomScrollbars';
-import ImageView from './ImageView';
-import { BackAction } from './KeyboardNavigation';
-import { SmallButtonGrid } from './SmallButton';
-
-const MODAL_CONTAINER_ID = 'modal-container';
-
-const ModalContent = styled.div({
- position: 'absolute',
- display: 'flex',
- flexDirection: 'column',
- flex: 1,
- top: 0,
- left: 0,
- right: 0,
- bottom: 0,
- overflow: 'hidden',
-});
-
-const ModalBackground = styled.div<{ $visible: boolean }>((props) => ({
- backgroundColor: props.$visible ? 'rgba(0,0,0,0.5)' : 'rgba(0,0,0,0)',
- backdropFilter: props.$visible ? 'blur(1.5px)' : '',
- position: 'absolute',
- display: 'flex',
- flexDirection: 'column',
- flex: 1,
- top: 0,
- left: 0,
- right: 0,
- bottom: 0,
- transition: 'background-color 150ms ease-out',
- pointerEvents: props.$visible ? 'auto' : 'none',
- zIndex: 2,
-}));
-
-export const StyledModalContainer = styled.div({
- position: 'relative',
- flex: 1,
-});
-
-interface IModalContainerProps {
- children?: React.ReactNode;
-}
-
-interface IModalContext {
- activeModal: boolean;
- setActiveModal: (value: boolean) => void;
- previousActiveElement: React.MutableRefObject<HTMLElement | undefined>;
-}
-
-const noActiveModalContextError = new Error('ActiveModalContext.Provider missing');
-const ActiveModalContext = React.createContext<IModalContext>({
- get activeModal(): boolean {
- throw noActiveModalContextError;
- },
- setActiveModal(_value) {
- throw noActiveModalContextError;
- },
- get previousActiveElement(): React.MutableRefObject<HTMLElement | undefined> {
- throw noActiveModalContextError;
- },
-});
-
-export function ModalContainer(props: IModalContainerProps) {
- const [activeModal, setActiveModal] = useState(false);
- const previousActiveElement = useRef<HTMLElement>();
-
- const contextValue = useMemo(
- () => ({
- activeModal,
- setActiveModal,
- previousActiveElement,
- }),
- [activeModal],
- );
-
- useEffect(() => {
- if (!activeModal) {
- previousActiveElement.current?.focus();
- }
- }, [activeModal]);
-
- return (
- <ActiveModalContext.Provider value={contextValue}>
- <StyledModalContainer id={MODAL_CONTAINER_ID}>
- <ModalContent aria-hidden={activeModal}>{props.children}</ModalContent>
- </StyledModalContainer>
- </ActiveModalContext.Provider>
- );
-}
-
-export enum ModalAlertType {
- info = 1,
- caution,
- warning,
-
- loading,
- success,
- failure,
-}
-
-const ModalAlertContainer = styled.div({
- display: 'flex',
- flexDirection: 'column',
- flex: 1,
- justifyContent: 'center',
- padding: '14px',
-});
-
-const StyledModalAlert = styled.div<{ $visible: boolean; $closing: boolean }>((props) => {
- let transform = '';
- if (props.$visible && props.$closing) {
- transform = 'scale(80%)';
- } else if (!props.$visible) {
- transform = 'translateY(10px) scale(98%)';
- }
-
- return {
- display: 'flex',
- flexDirection: 'column',
- backgroundColor: colors.darkBlue,
- borderRadius: '11px',
- padding: '16px 0 16px 16px',
- maxHeight: '80vh',
- opacity: props.$visible && !props.$closing ? 1 : 0,
- transform,
- boxShadow: ' 0px 15px 35px 5px rgba(0,0,0,0.5)',
- transition: 'all 150ms ease-out',
- };
-});
-
-const StyledCustomScrollbars = styled(CustomScrollbars)({
- paddingRight: '16px',
-});
-
-const ModalAlertIcon = styled.div({
- display: 'flex',
- justifyContent: 'center',
- marginTop: '8px',
-});
-
-const ModalAlertButtonGroupContainer = styled.div({
- marginTop: measurements.buttonVerticalMargin,
-});
-
-const StyledSmallButtonGrid = styled(SmallButtonGrid)({
- marginRight: '16px',
-});
-
-const ModalAlertButtonContainer = styled.div({
- display: 'flex',
- flexDirection: 'column',
- marginRight: '16px',
-});
-
-interface IModalAlertProps {
- type?: ModalAlertType;
- iconColor?: string;
- title?: string;
- message?: string | Array<string>;
- buttons?: React.ReactNode[];
- gridButtons?: React.ReactNode[];
- children?: React.ReactNode;
- close?: () => void;
-}
-
-interface OpenState {
- isClosing: boolean;
- wasOpen: boolean;
-}
-
-export function ModalAlert(props: IModalAlertProps & { isOpen: boolean }) {
- const { isOpen, ...otherProps } = props;
- const activeModalContext = useContext(ActiveModalContext);
- const [openState, setOpenState] = useState<OpenState>({ isClosing: false, wasOpen: isOpen });
-
- const willExit = useWillExit();
-
- // Modal shouldn't prepare for being opened again while view is disappearing.
- const onTransitionEnd = useCallback(() => {
- if (!willExit) {
- setOpenState({ isClosing: false, wasOpen: isOpen });
- }
- }, [willExit, isOpen]);
-
- const onOpenStateChange = useEffectEvent((isOpen: boolean) => {
- setOpenState(({ isClosing, wasOpen }) => ({
- isClosing: isClosing || (wasOpen && !isOpen),
- // Unmounting the Modal during view transitions result in a visual glitch.
- wasOpen: willExit ? wasOpen : isOpen,
- }));
- });
-
- useEffect(() => onOpenStateChange(isOpen), [isOpen]);
-
- if (!openState.wasOpen && !isOpen && !openState.isClosing) {
- return null;
- }
-
- return (
- <ModalAlertImpl
- {...activeModalContext}
- {...otherProps}
- closing={openState.isClosing}
- onTransitionEnd={onTransitionEnd}
- />
- );
-}
-
-interface IModalAlertState {
- visible: boolean;
-}
-
-interface IModalAlertImplProps extends IModalAlertProps, IModalContext {
- closing: boolean;
- onTransitionEnd: () => void;
-}
-
-class ModalAlertImpl extends React.Component<IModalAlertImplProps, IModalAlertState> {
- public state = { visible: false };
-
- private element = document.createElement('div');
- private modalRef = React.createRef<HTMLDivElement>();
-
- constructor(props: IModalAlertImplProps) {
- super(props);
-
- if (document.activeElement) {
- props.previousActiveElement.current = document.activeElement as HTMLElement;
- }
- }
-
- public componentDidMount() {
- this.props.setActiveModal(true);
-
- const modalContainer = document.getElementById(MODAL_CONTAINER_ID);
- if (modalContainer) {
- modalContainer.appendChild(this.element);
- this.modalRef.current?.focus();
-
- this.setState({ visible: true });
- } else {
- log.error('Modal container not found when mounting modal');
- }
- }
-
- public componentWillUnmount() {
- this.props.setActiveModal(false);
-
- const modalContainer = document.getElementById(MODAL_CONTAINER_ID);
- modalContainer?.removeChild(this.element);
- }
-
- public render() {
- return ReactDOM.createPortal(this.renderModal(), this.element);
- }
-
- private renderModal() {
- const messages =
- typeof this.props.message === 'string' ? [this.props.message] : this.props.message;
-
- return (
- <BackAction action={this.close}>
- <ModalBackground $visible={this.state.visible && !this.props.closing}>
- <ModalAlertContainer>
- <StyledModalAlert
- ref={this.modalRef}
- tabIndex={-1}
- role="dialog"
- aria-modal
- $visible={this.state.visible}
- $closing={this.props.closing}
- onTransitionEnd={this.onTransitionEnd}>
- <StyledCustomScrollbars>
- {this.props.type && (
- <ModalAlertIcon>{this.renderTypeIcon(this.props.type)}</ModalAlertIcon>
- )}
- {this.props.title && <ModalTitle>{this.props.title}</ModalTitle>}
- {messages &&
- messages.map((message) => <ModalMessage key={message}>{message}</ModalMessage>)}
- {this.props.children}
- </StyledCustomScrollbars>
-
- <ModalAlertButtonGroupContainer>
- {this.props.gridButtons && (
- <StyledSmallButtonGrid>{this.props.gridButtons}</StyledSmallButtonGrid>
- )}
- {this.props.buttons && (
- <AppButton.ButtonGroup>
- {this.props.buttons.map((button, index) => (
- <ModalAlertButtonContainer key={index}>{button}</ModalAlertButtonContainer>
- ))}
- </AppButton.ButtonGroup>
- )}
- </ModalAlertButtonGroupContainer>
- </StyledModalAlert>
- </ModalAlertContainer>
- </ModalBackground>
- </BackAction>
- );
- }
-
- private close = () => {
- this.props.close?.();
- };
-
- private renderTypeIcon(type: ModalAlertType) {
- let source = '';
- let color = undefined;
- switch (type) {
- case ModalAlertType.info:
- source = 'icon-info';
- color = colors.white;
- break;
- case ModalAlertType.caution:
- source = 'icon-alert';
- color = colors.white;
- break;
- case ModalAlertType.warning:
- source = 'icon-alert';
- color = colors.red;
- break;
-
- case ModalAlertType.loading:
- source = 'icon-spinner';
- break;
- case ModalAlertType.success:
- source = 'icon-success';
- break;
- case ModalAlertType.failure:
- source = 'icon-fail';
- break;
- }
-
- return (
- <ImageView height={44} width={44} source={source} tintColor={this.props.iconColor ?? color} />
- );
- }
-
- private onTransitionEnd = (event: React.TransitionEvent<HTMLDivElement>) => {
- if (event.target === this.modalRef.current) {
- this.props.onTransitionEnd();
- }
- };
-}
-
-const ModalTitle = styled.h1(normalText, {
- color: colors.white,
- fontWeight: 600,
- margin: '18px 0 0 0',
-});
-
-export const ModalMessage = styled.span(tinyText, {
- color: colors.white80,
- marginTop: '16px',
-
- [`${ModalTitle} ~ &&`]: {
- marginTop: '6px',
- },
-});
-
-export const ModalMessageList = styled.ul({
- listStyle: 'disc outside',
- paddingLeft: '20px',
- color: colors.white80,
-});
diff --git a/gui/src/renderer/components/MultiButton.tsx b/gui/src/renderer/components/MultiButton.tsx
deleted file mode 100644
index 3129abcbb2..0000000000
--- a/gui/src/renderer/components/MultiButton.tsx
+++ /dev/null
@@ -1,43 +0,0 @@
-import React from 'react';
-import styled from 'styled-components';
-
-const SIDE_BUTTON_WIDTH = 44;
-
-const ButtonRow = styled.div({
- display: 'flex',
- flexDirection: 'row',
-});
-
-const MainButton = styled.button({
- display: 'flex',
- flex: 1,
- borderTopRightRadius: 0,
- borderBottomRightRadius: 0,
-});
-
-const SideButton = styled.button({
- display: 'flex',
- borderTopLeftRadius: 0,
- borderBottomLeftRadius: 0,
- width: SIDE_BUTTON_WIDTH,
- marginLeft: '1px !important',
-});
-
-export interface MultiButtonCompatibleProps {
- className?: string;
- textOffset?: number;
-}
-
-interface IMultiButtonProps {
- mainButton: React.ComponentType<MultiButtonCompatibleProps>;
- sideButton: React.ComponentType<MultiButtonCompatibleProps>;
-}
-
-export function MultiButton(props: IMultiButtonProps) {
- return (
- <ButtonRow>
- <MainButton as={props.mainButton} textOffset={SIDE_BUTTON_WIDTH + 1} />
- <SideButton as={props.sideButton} />
- </ButtonRow>
- );
-}
diff --git a/gui/src/renderer/components/MultihopSettings.tsx b/gui/src/renderer/components/MultihopSettings.tsx
deleted file mode 100644
index 3075e1d4ed..0000000000
--- a/gui/src/renderer/components/MultihopSettings.tsx
+++ /dev/null
@@ -1,133 +0,0 @@
-import { useCallback } from 'react';
-import { sprintf } from 'sprintf-js';
-import styled from 'styled-components';
-
-import { strings } from '../../config.json';
-import { messages } from '../../shared/gettext';
-import log from '../../shared/logging';
-import { useRelaySettingsUpdater } from '../lib/constraint-updater';
-import { useHistory } from '../lib/history';
-import { useSelector } from '../redux/store';
-import { AriaDescription, AriaInput, AriaInputGroup, AriaLabel } from './AriaGroup';
-import * as Cell from './cell';
-import { StyledIllustration } from './DaitaSettings';
-import { BackAction } from './KeyboardNavigation';
-import { Layout, SettingsContainer } from './Layout';
-import {
- NavigationBar,
- NavigationContainer,
- NavigationItems,
- NavigationScrollbars,
- TitleBarItem,
-} from './NavigationBar';
-import SettingsHeader, { HeaderSubTitle, HeaderTitle } from './SettingsHeader';
-
-const StyledContent = styled.div({
- display: 'flex',
- flexDirection: 'column',
- flex: 1,
- marginBottom: '2px',
-});
-
-export default function MultihopSettings() {
- const { pop } = useHistory();
-
- return (
- <BackAction action={pop}>
- <Layout>
- <SettingsContainer>
- <NavigationContainer>
- <NavigationBar>
- <NavigationItems>
- <TitleBarItem>
- {messages.pgettext('wireguard-settings-view', 'Multihop')}
- </TitleBarItem>
- </NavigationItems>
- </NavigationBar>
-
- <NavigationScrollbars>
- <SettingsHeader>
- <HeaderTitle>
- {messages.pgettext('wireguard-settings-view', 'Multihop')}
- </HeaderTitle>
- <HeaderSubTitle>
- <StyledIllustration src="../../assets/images/multihop-illustration.svg" />
- {messages.pgettext(
- 'wireguard-settings-view',
- 'Multihop routes your traffic into one WireGuard server and out another, making it harder to trace. This results in increased latency but increases anonymity online.',
- )}
- </HeaderSubTitle>
- </SettingsHeader>
-
- <StyledContent>
- <Cell.Group>
- <MultihopSetting />
- </Cell.Group>
- </StyledContent>
- </NavigationScrollbars>
- </NavigationContainer>
- </SettingsContainer>
- </Layout>
- </BackAction>
- );
-}
-
-function MultihopSetting() {
- const relaySettings = useSelector((state) => state.settings.relaySettings);
- const relaySettingsUpdater = useRelaySettingsUpdater();
-
- const multihop = 'normal' in relaySettings ? relaySettings.normal.wireguard.useMultihop : false;
- const unavailable =
- 'normal' in relaySettings ? relaySettings.normal.tunnelProtocol === 'openvpn' : true;
-
- const setMultihop = useCallback(
- async (enabled: boolean) => {
- try {
- await relaySettingsUpdater((settings) => {
- settings.wireguardConstraints.useMultihop = enabled;
- return settings;
- });
- } catch (e) {
- const error = e as Error;
- log.error('Failed to update WireGuard multihop settings', error.message);
- }
- },
- [relaySettingsUpdater],
- );
-
- return (
- <>
- <AriaInputGroup>
- <Cell.Container disabled={unavailable}>
- <AriaLabel>
- <Cell.InputLabel>{messages.gettext('Enable')}</Cell.InputLabel>
- </AriaLabel>
- <AriaInput>
- <Cell.Switch isOn={multihop && !unavailable} onChange={setMultihop} />
- </AriaInput>
- </Cell.Container>
- {unavailable ? (
- <Cell.CellFooter>
- <AriaDescription>
- <Cell.CellFooterText>{featureUnavailableMessage()}</Cell.CellFooterText>
- </AriaDescription>
- </Cell.CellFooter>
- ) : null}
- </AriaInputGroup>
- </>
- );
-}
-
-function featureUnavailableMessage() {
- const automatic = messages.gettext('Automatic');
- const tunnelProtocol = messages.pgettext('vpn-settings-view', 'Tunnel protocol');
- const multihop = messages.pgettext('wireguard-settings-view', 'Multihop');
-
- return sprintf(
- messages.pgettext(
- 'wireguard-settings-view',
- 'Switch to “%(wireguard)s” or “%(automatic)s” in Settings > %(tunnelProtocol)s to make %(setting)s available.',
- ),
- { wireguard: strings.wireguard, automatic, tunnelProtocol, setting: multihop },
- );
-}
diff --git a/gui/src/renderer/components/NavigationBar.tsx b/gui/src/renderer/components/NavigationBar.tsx
deleted file mode 100644
index 680393a6c7..0000000000
--- a/gui/src/renderer/components/NavigationBar.tsx
+++ /dev/null
@@ -1,230 +0,0 @@
-import React, { useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef } from 'react';
-import styled from 'styled-components';
-
-import { colors } from '../../config.json';
-import { messages } from '../../shared/gettext';
-import { useAppContext } from '../context';
-import { transitions, useHistory } from '../lib/history';
-import { useCombinedRefs, useEffectEvent } from '../lib/utility-hooks';
-import CustomScrollbars, { CustomScrollbarsRef, IScrollEvent } from './CustomScrollbars';
-import InfoButton from './InfoButton';
-import { BackActionContext } from './KeyboardNavigation';
-import {
- StyledBackBarItemButton,
- StyledBackBarItemIcon,
- StyledNavigationBar,
- StyledNavigationBarSeparator,
- StyledNavigationItems,
- StyledTitleBarItemLabel,
-} from './NavigationBarStyles';
-
-interface INavigationContainerProps {
- children?: React.ReactNode;
-}
-
-interface INavigationContainerState {
- showsBarTitle: boolean;
- showsBarSeparator: boolean;
-}
-
-const NavigationScrollContext = React.createContext({
- showsBarTitle: false,
- showsBarSeparator: false,
- onScroll(_event: IScrollEvent): void {
- throw Error('NavigationScrollContext provider missing');
- },
-});
-
-export class NavigationContainer extends React.Component<
- INavigationContainerProps,
- INavigationContainerState
-> {
- public state = {
- showsBarTitle: false,
- showsBarSeparator: false,
- };
-
- public componentDidMount() {
- this.updateBarAppearance({ scrollLeft: 0, scrollTop: 0 });
- }
-
- public render() {
- return (
- <NavigationScrollContext.Provider
- value={{
- ...this.state,
- onScroll: this.onScroll,
- }}>
- {this.props.children}
- </NavigationScrollContext.Provider>
- );
- }
-
- public onScroll = (event: IScrollEvent) => {
- this.updateBarAppearance(event);
- };
-
- private updateBarAppearance(event: IScrollEvent) {
- // that's where SettingsHeader.HeaderTitle intersects the navigation bar
- const showsBarSeparator = event.scrollTop > 11;
-
- // that's when SettingsHeader.HeaderTitle goes behind the navigation bar
- const showsBarTitle = event.scrollTop > 20;
-
- if (
- this.state.showsBarSeparator !== showsBarSeparator ||
- this.state.showsBarTitle !== showsBarTitle
- ) {
- this.setState({ showsBarSeparator, showsBarTitle });
- }
- }
-}
-
-interface INavigationScrollbarsProps {
- className?: string;
- fillContainer?: boolean;
- children?: React.ReactNode;
-}
-
-export const NavigationScrollbars = React.forwardRef(function NavigationScrollbarsT(
- props: INavigationScrollbarsProps,
- forwardedRef?: React.Ref<CustomScrollbarsRef>,
-) {
- const history = useHistory();
- const { setNavigationHistory } = useAppContext();
- const { onScroll } = useContext(NavigationScrollContext);
-
- const ref = useRef<CustomScrollbarsRef>();
- const combinedRefs = useCombinedRefs(forwardedRef, ref);
-
- const beforeunload = useEffectEvent(() => {
- if (ref.current) {
- history.recordScrollPosition(ref.current.getScrollPosition());
- setNavigationHistory(history.asObject);
- }
- });
-
- useEffect(() => {
- window.addEventListener('beforeunload', beforeunload);
- return () => window.removeEventListener('beforeunload', beforeunload);
- }, []);
-
- const onMount = useEffectEvent(() => {
- const location = history.location;
- if (history.action === 'POP') {
- ref.current?.scrollTo(...location.state.scrollPosition);
- }
- });
-
- const onUnmount = useEffectEvent(() => {
- if (history.action === 'PUSH' && ref.current) {
- history.recordScrollPosition(ref.current.getScrollPosition());
- setNavigationHistory(history.asObject);
- }
- });
-
- useLayoutEffect(() => {
- onMount();
- return () => onUnmount();
- }, []);
-
- const handleScroll = useCallback(
- (event: IScrollEvent) => {
- onScroll(event);
- },
- [onScroll],
- );
-
- return (
- <CustomScrollbars
- ref={combinedRefs}
- className={props.className}
- fillContainer={props.fillContainer}
- onScroll={handleScroll}>
- {props.children}
- </CustomScrollbars>
- );
-});
-
-const TitleBarItemContext = React.createContext({
- visible: false,
-});
-
-interface INavigationBarProps {
- children?: React.ReactNode;
- alwaysDisplayBarTitle?: boolean;
-}
-
-export const NavigationBar = function NavigationBarT(props: INavigationBarProps) {
- const { showsBarSeparator, showsBarTitle } = useContext(NavigationScrollContext);
-
- return (
- <StyledNavigationBar>
- <TitleBarItemContext.Provider
- value={{ visible: props.alwaysDisplayBarTitle || showsBarTitle }}>
- {props.children}
- </TitleBarItemContext.Provider>
- {showsBarSeparator && <StyledNavigationBarSeparator />}
- </StyledNavigationBar>
- );
-};
-
-interface INavigationItemsProps {
- children: React.ReactNode;
-}
-
-export function NavigationItems(props: INavigationItemsProps) {
- const { parentBackAction } = useContext(BackActionContext);
- return (
- <StyledNavigationItems>
- {parentBackAction && <BackBarItem />}
- {props.children}
- </StyledNavigationItems>
- );
-}
-
-interface ITitleBarItemProps {
- children?: React.ReactText;
-}
-
-export const TitleBarItem = React.memo(function TitleBarItemT(props: ITitleBarItemProps) {
- const { visible } = useContext(TitleBarItemContext);
- return <StyledTitleBarItemLabel $visible={visible}>{props.children}</StyledTitleBarItemLabel>;
-});
-
-export function BackBarItem() {
- const history = useHistory();
- // Compare the transition name with dismiss to infer wheter or not the view will slide
- // horizontally or vertically and then use matching button.
- const backIcon = useMemo(
- () => history.getPopTransition().name !== transitions.dismiss.name,
- [history],
- );
- const { parentBackAction } = useContext(BackActionContext);
- const iconSource = backIcon ? 'icon-back' : 'icon-close-down';
- const ariaLabel = backIcon ? messages.gettext('Back') : messages.gettext('Close');
-
- return (
- <StyledBackBarItemButton aria-label={ariaLabel} onClick={parentBackAction}>
- <StyledBackBarItemIcon source={iconSource} tintColor={colors.white40} width={24} />
- </StyledBackBarItemButton>
- );
-}
-
-const navigationRightHandSideButton: React.CSSProperties = {
- justifySelf: 'end',
- borderWidth: 0,
- padding: 0,
- margin: 0,
- cursor: 'default',
- backgroundColor: 'transparent',
-};
-
-export const NavigationBarButton = styled.button({ ...navigationRightHandSideButton });
-export const NavigationInfoButton = styled(InfoButton).attrs({
- size: 24,
- tintColor: colors.white40,
- tintHoverColor: colors.white60,
-})({
- ...navigationRightHandSideButton,
-});
diff --git a/gui/src/renderer/components/NavigationBarStyles.tsx b/gui/src/renderer/components/NavigationBarStyles.tsx
deleted file mode 100644
index eb4472c900..0000000000
--- a/gui/src/renderer/components/NavigationBarStyles.tsx
+++ /dev/null
@@ -1,57 +0,0 @@
-import styled from 'styled-components';
-
-import { colors } from '../../config.json';
-import { normalText } from './common-styles';
-import ImageView from './ImageView';
-
-export const StyledNavigationBarSeparator = styled.div({
- backgroundColor: 'rgba(0, 0, 0, 0.2)',
- position: 'absolute',
- bottom: 0,
- left: 0,
- right: 0,
- height: '1px',
-});
-
-export const StyledNavigationItems = styled.div({
- flex: 1,
- display: 'grid',
- gridTemplateColumns: '1fr auto 1fr',
- alignItems: 'center',
-});
-
-export const StyledNavigationBar = styled.nav({
- flex: 0,
- padding: '12px',
-});
-
-export const StyledTitleBarItemLabel = styled.h1<{ $visible?: boolean }>(normalText, (props) => ({
- fontWeight: 400,
- lineHeight: '22px',
- color: colors.white,
- padding: '0 5px',
- overflow: 'hidden',
- textOverflow: 'ellipsis',
- whiteSpace: 'nowrap',
- opacity: props.$visible ? 1 : 0,
- transition: 'opacity 250ms ease-in-out',
-}));
-
-export const StyledBackBarItemButton = styled.button({
- justifySelf: 'start',
- borderWidth: 0,
- padding: 0,
- margin: 0,
- cursor: 'default',
- display: 'flex',
- flexDirection: 'row',
- alignItems: 'center',
- backgroundColor: 'transparent',
-});
-
-export const StyledBackBarItemIcon = styled(ImageView)({
- marginRight: '8px',
- [StyledBackBarItemButton + ':hover &&']: {
- backgroundColor: colors.white60,
- },
-});
diff --git a/gui/src/renderer/components/NotificationArea.tsx b/gui/src/renderer/components/NotificationArea.tsx
deleted file mode 100644
index dac62db192..0000000000
--- a/gui/src/renderer/components/NotificationArea.tsx
+++ /dev/null
@@ -1,230 +0,0 @@
-import { useCallback, useState } from 'react';
-import { useSelector } from 'react-redux';
-
-import { messages } from '../../shared/gettext';
-import log from '../../shared/logging';
-import { NewDeviceNotificationProvider } from '../../shared/notifications/new-device';
-import {
- BlockWhenDisconnectedNotificationProvider,
- CloseToAccountExpiryNotificationProvider,
- ConnectingNotificationProvider,
- ErrorNotificationProvider,
- InAppNotificationAction,
- InAppNotificationProvider,
- InAppNotificationTroubleshootInfo,
- InconsistentVersionNotificationProvider,
- ReconnectingNotificationProvider,
- UnsupportedVersionNotificationProvider,
- UpdateAvailableNotificationProvider,
-} from '../../shared/notifications/notification';
-import { useAppContext } from '../context';
-import useActions from '../lib/actionsHook';
-import { transitions, useHistory } from '../lib/history';
-import { formatHtml } from '../lib/html-formatter';
-import { RoutePath } from '../lib/routes';
-import accountActions from '../redux/account/actions';
-import { IReduxState } from '../redux/store';
-import * as AppButton from './AppButton';
-import { ModalAlert, ModalAlertType, ModalMessage, ModalMessageList } from './Modal';
-import {
- NotificationActions,
- NotificationBanner,
- NotificationCloseAction,
- NotificationContent,
- NotificationIndicator,
- NotificationOpenLinkAction,
- NotificationSubtitle,
- NotificationTitle,
- NotificationTroubleshootDialogAction,
-} from './NotificationBanner';
-
-interface IProps {
- className?: string;
-}
-
-export default function NotificationArea(props: IProps) {
- const { showFullDiskAccessSettings } = useAppContext();
-
- const account = useSelector((state: IReduxState) => state.account);
- const locale = useSelector((state: IReduxState) => state.userInterface.locale);
- const tunnelState = useSelector((state: IReduxState) => state.connection.status);
- const version = useSelector((state: IReduxState) => state.version);
- const blockWhenDisconnected = useSelector(
- (state: IReduxState) => state.settings.blockWhenDisconnected,
- );
- const hasExcludedApps = useSelector(
- (state: IReduxState) =>
- state.settings.splitTunneling && state.settings.splitTunnelingApplications.length > 0,
- );
-
- const { hideNewDeviceBanner } = useActions(accountActions);
-
- const notificationProviders: InAppNotificationProvider[] = [
- new ConnectingNotificationProvider({ tunnelState }),
- new ReconnectingNotificationProvider(tunnelState),
- new BlockWhenDisconnectedNotificationProvider({
- tunnelState,
- blockWhenDisconnected,
- hasExcludedApps,
- }),
- new ErrorNotificationProvider({ tunnelState, hasExcludedApps, showFullDiskAccessSettings }),
- new InconsistentVersionNotificationProvider({ consistent: version.consistent }),
- new UnsupportedVersionNotificationProvider(version),
- ];
-
- if (account.expiry) {
- notificationProviders.push(
- new CloseToAccountExpiryNotificationProvider({ accountExpiry: account.expiry, locale }),
- );
- }
-
- notificationProviders.push(
- new NewDeviceNotificationProvider({
- shouldDisplay: account.status.type === 'ok' && account.status.newDeviceBanner,
- deviceName: account.deviceName ?? '',
- close: hideNewDeviceBanner,
- }),
- new UpdateAvailableNotificationProvider(version),
- );
-
- const notificationProvider = notificationProviders.find((notification) =>
- notification.mayDisplay(),
- );
-
- if (notificationProvider) {
- const notification = notificationProvider.getInAppNotification();
-
- if (notification) {
- return (
- <NotificationBanner className={props.className} data-testid="notificationBanner">
- <NotificationIndicator
- $type={notification.indicator}
- data-testid="notificationIndicator"
- />
- <NotificationContent role="status" aria-live="polite">
- <NotificationTitle data-testid="notificationTitle">
- {notification.title}
- </NotificationTitle>
- <NotificationSubtitle data-testid="notificationSubTitle">
- {formatHtml(notification.subtitle ?? '')}
- </NotificationSubtitle>
- </NotificationContent>
- {notification.action && <NotificationActionWrapper action={notification.action} />}
- </NotificationBanner>
- );
- } else {
- log.error(
- `Notification providers mayDisplay() returned true but getInAppNotification() returned undefined for ${notificationProvider.constructor.name}`,
- );
- }
- }
-
- return <NotificationBanner className={props.className} aria-hidden={true} />;
-}
-
-interface INotificationActionWrapperProps {
- action: InAppNotificationAction;
-}
-
-function NotificationActionWrapper(props: INotificationActionWrapperProps) {
- const { push } = useHistory();
- const { openLinkWithAuth, openUrl } = useAppContext();
- const [troubleshootInfo, setTroubleshootInfo] = useState<InAppNotificationTroubleshootInfo>();
-
- const handleClick = useCallback(() => {
- if (props.action) {
- switch (props.action.type) {
- case 'open-url':
- if (props.action.withAuth) {
- return openLinkWithAuth(props.action.url);
- } else {
- return openUrl(props.action.url);
- }
- case 'troubleshoot-dialog':
- setTroubleshootInfo(props.action.troubleshoot);
- break;
- case 'close':
- props.action.close();
- break;
- }
- }
-
- return Promise.resolve();
- }, [openLinkWithAuth, openUrl, props.action]);
-
- const goToProblemReport = useCallback(() => {
- setTroubleshootInfo(undefined);
- push(RoutePath.problemReport, { transition: transitions.show });
- }, [push]);
-
- const closeTroubleshootInfo = useCallback(() => setTroubleshootInfo(undefined), []);
-
- let actionComponent: React.ReactElement | undefined;
- if (props.action) {
- switch (props.action.type) {
- case 'open-url':
- actionComponent = <NotificationOpenLinkAction onClick={handleClick} />;
- break;
- case 'troubleshoot-dialog':
- actionComponent = (
- <>
- <NotificationTroubleshootDialogAction onClick={handleClick} />
- </>
- );
- break;
- case 'close':
- actionComponent = <NotificationCloseAction onClick={handleClick} />;
- }
- }
-
- const problemReportButton = troubleshootInfo?.buttons ? (
- <AppButton.BlueButton key="problem-report" onClick={goToProblemReport}>
- {messages.pgettext('in-app-notifications', 'Send problem report')}
- </AppButton.BlueButton>
- ) : (
- <AppButton.GreenButton key="problem-report" onClick={goToProblemReport}>
- {messages.pgettext('in-app-notifications', 'Send problem report')}
- </AppButton.GreenButton>
- );
-
- let buttons = [
- problemReportButton,
- <AppButton.BlueButton key="back" onClick={closeTroubleshootInfo}>
- {messages.gettext('Back')}
- </AppButton.BlueButton>,
- ];
-
- if (troubleshootInfo?.buttons) {
- const actionButtons = troubleshootInfo.buttons.map((button) => (
- <AppButton.GreenButton key={button.label} onClick={button.action}>
- {button.label}
- </AppButton.GreenButton>
- ));
-
- buttons = actionButtons.concat(buttons);
- }
-
- return (
- <>
- <NotificationActions>{actionComponent}</NotificationActions>
- <ModalAlert
- isOpen={troubleshootInfo !== undefined}
- type={ModalAlertType.info}
- buttons={buttons}
- close={closeTroubleshootInfo}>
- <ModalMessage>{troubleshootInfo?.details}</ModalMessage>
- <ModalMessage>
- <ModalMessageList>
- {troubleshootInfo?.steps.map((step) => <li key={step}>{step}</li>)}
- </ModalMessageList>
- </ModalMessage>
- <ModalMessage>
- {messages.pgettext(
- 'troubleshoot',
- 'If these steps do not work please send a problem report.',
- )}
- </ModalMessage>
- </ModalAlert>
- </>
- );
-}
diff --git a/gui/src/renderer/components/NotificationBanner.tsx b/gui/src/renderer/components/NotificationBanner.tsx
deleted file mode 100644
index 924f65ff99..0000000000
--- a/gui/src/renderer/components/NotificationBanner.tsx
+++ /dev/null
@@ -1,180 +0,0 @@
-import React, { useEffect, useState } from 'react';
-import styled from 'styled-components';
-
-import { colors } from '../../config.json';
-import { messages } from '../../shared/gettext';
-import { InAppNotificationIndicatorType } from '../../shared/notifications/notification';
-import { useEffectEvent, useLastDefinedValue, useStyledRef } from '../lib/utility-hooks';
-import * as AppButton from './AppButton';
-import { tinyText } from './common-styles';
-import ImageView from './ImageView';
-
-const NOTIFICATION_AREA_ID = 'notification-area';
-
-export const NotificationTitle = styled.span(tinyText, {
- color: colors.white,
-});
-
-export const NotificationSubtitleText = styled.span(tinyText, {
- color: colors.white60,
-});
-
-interface INotificationSubtitleProps {
- children?: React.ReactNode;
-}
-
-export function NotificationSubtitle(props: INotificationSubtitleProps) {
- return React.Children.count(props.children) > 0 ? <NotificationSubtitleText {...props} /> : null;
-}
-
-export const NotificationActionButton = styled(AppButton.SimpleButton)({
- flex: 1,
- justifyContent: 'center',
- cursor: 'default',
- padding: '4px',
- background: 'transparent',
- border: 'none',
-});
-
-export const NotificationActionButtonInner = styled(ImageView)({
- [NotificationActionButton + ':hover &&']: {
- backgroundColor: colors.white80,
- },
-});
-
-interface NotificationActionProps {
- onClick: () => Promise<void>;
-}
-
-export function NotificationOpenLinkAction(props: NotificationActionProps) {
- return (
- <AppButton.BlockingButton onClick={props.onClick}>
- <NotificationActionButton
- aria-describedby={NOTIFICATION_AREA_ID}
- aria-label={messages.gettext('Open URL')}>
- <NotificationActionButtonInner
- height={12}
- width={12}
- tintColor={colors.white60}
- source="icon-extLink"
- />
- </NotificationActionButton>
- </AppButton.BlockingButton>
- );
-}
-
-export function NotificationTroubleshootDialogAction(props: NotificationActionProps) {
- return (
- <NotificationActionButton
- aria-describedby={NOTIFICATION_AREA_ID}
- aria-label={messages.gettext('Troubleshoot')}
- onClick={props.onClick}>
- <NotificationActionButtonInner
- height={12}
- width={12}
- tintColor={colors.white60}
- source="icon-info"
- />
- </NotificationActionButton>
- );
-}
-
-export function NotificationCloseAction(props: NotificationActionProps) {
- return (
- <NotificationActionButton
- aria-describedby={NOTIFICATION_AREA_ID}
- aria-label={messages.pgettext('accessibility', 'Close notification')}
- onClick={props.onClick}>
- <NotificationActionButtonInner source="icon-close" width={16} tintColor={colors.white60} />
- </NotificationActionButton>
- );
-}
-
-export const NotificationContent = styled.div.attrs({ id: NOTIFICATION_AREA_ID })({
- display: 'flex',
- flexDirection: 'column',
- flex: 1,
- paddingRight: '4px',
-});
-
-export const NotificationActions = styled.div({
- display: 'flex',
- flex: 0,
- flexDirection: 'column',
- justifyContent: 'center',
-});
-
-interface INotificationIndicatorProps {
- $type?: InAppNotificationIndicatorType;
-}
-
-const notificationIndicatorTypeColorMap = {
- success: colors.green,
- warning: colors.yellow,
- error: colors.red,
-};
-
-export const NotificationIndicator = styled.div<INotificationIndicatorProps>((props) => ({
- width: '10px',
- height: '10px',
- borderRadius: '5px',
- marginTop: '4px',
- marginRight: '8px',
- backgroundColor: props.$type ? notificationIndicatorTypeColorMap[props.$type] : 'transparent',
-}));
-
-interface ICollapsibleProps {
- $alignBottom: boolean;
- $height?: number;
-}
-
-const Collapsible = styled.div<ICollapsibleProps>((props) => {
- return {
- display: 'flex',
- flexDirection: 'column',
- justifyContent: props.$alignBottom ? 'flex-end' : 'flex-start',
- backgroundColor: colors.darkerBlue,
- overflow: 'hidden',
- // Using auto as the initial value prevents transition if a notification is visible on mount.
- height: props.$height === undefined ? 'auto' : `${props.$height}px`,
- transition: 'height 250ms ease-in-out',
- };
-});
-
-const Content = styled.section({
- display: 'flex',
- flexDirection: 'row',
- padding: '8px 12px 8px 16px',
- height: 'fit-content',
-});
-
-interface INotificationBannerProps {
- children?: React.ReactNode; // Array<NotificationContent | NotificationActions>,
- className?: string;
-}
-
-export function NotificationBanner(props: INotificationBannerProps) {
- const [contentHeight, setContentHeight] = useState<number>();
- const [alignBottom, setAlignBottom] = useState(false);
-
- const contentRef = useStyledRef<HTMLDivElement>();
-
- const children = useLastDefinedValue(props.children);
-
- const updateHeightEvent = useEffectEvent(() => {
- const newHeight =
- props.children !== undefined ? (contentRef.current?.getBoundingClientRect().height ?? 0) : 0;
- if (newHeight !== contentHeight) {
- setContentHeight(newHeight);
- setAlignBottom((alignBottom) => alignBottom || contentHeight === 0 || newHeight === 0);
- }
- });
-
- useEffect(() => updateHeightEvent());
-
- return (
- <Collapsible $height={contentHeight} className={props.className} $alignBottom={alignBottom}>
- <Content ref={contentRef}>{children}</Content>
- </Collapsible>
- );
-}
diff --git a/gui/src/renderer/components/OpenVpnSettings.tsx b/gui/src/renderer/components/OpenVpnSettings.tsx
deleted file mode 100644
index 571e8e3571..0000000000
--- a/gui/src/renderer/components/OpenVpnSettings.tsx
+++ /dev/null
@@ -1,511 +0,0 @@
-import { useCallback, useMemo } from 'react';
-import { sprintf } from 'sprintf-js';
-import styled from 'styled-components';
-
-import { strings } from '../../config.json';
-import {
- BridgeState,
- RelayProtocol,
- TunnelProtocol,
- wrapConstraint,
-} from '../../shared/daemon-rpc-types';
-import { messages } from '../../shared/gettext';
-import log from '../../shared/logging';
-import { removeNonNumericCharacters } from '../../shared/string-helpers';
-import { useAppContext } from '../context';
-import { useRelaySettingsUpdater } from '../lib/constraint-updater';
-import { useHistory } from '../lib/history';
-import { formatHtml } from '../lib/html-formatter';
-import { useBoolean } from '../lib/utility-hooks';
-import { useSelector } from '../redux/store';
-import { AriaDescription, AriaInput, AriaInputGroup, AriaLabel } from './AriaGroup';
-import * as Cell from './cell';
-import Selector, { SelectorItem } from './cell/Selector';
-import { BackAction } from './KeyboardNavigation';
-import { Layout, SettingsContainer } from './Layout';
-import { ModalAlert, ModalAlertType, ModalMessage } from './Modal';
-import {
- NavigationBar,
- NavigationContainer,
- NavigationItems,
- NavigationScrollbars,
- TitleBarItem,
-} from './NavigationBar';
-import SettingsHeader, { HeaderTitle } from './SettingsHeader';
-import { SmallButton } from './SmallButton';
-
-const MIN_MSSFIX_VALUE = 1000;
-const MAX_MSSFIX_VALUE = 1450;
-const UDP_PORTS = [1194, 1195, 1196, 1197, 1300, 1301, 1302];
-const TCP_PORTS = [80, 443];
-
-export enum BridgeModeAvailability {
- available,
- blockedDueToTunnelProtocol,
- blockedDueToTransportProtocol,
-}
-
-function mapPortToSelectorItem(value: number): SelectorItem<number> {
- return { label: value.toString(), value };
-}
-
-export const StyledNavigationScrollbars = styled(NavigationScrollbars)({
- flex: 1,
-});
-
-export const StyledSelectorContainer = styled.div({
- flex: 0,
-});
-
-export default function OpenVpnSettings() {
- const { pop } = useHistory();
-
- const relaySettings = useSelector((state) => state.settings.relaySettings);
-
- const protocol = useMemo(() => {
- const protocol = 'normal' in relaySettings ? relaySettings.normal.openvpn.protocol : undefined;
- return protocol === 'any' ? undefined : protocol;
- }, [relaySettings]);
-
- return (
- <BackAction action={pop}>
- <Layout>
- <SettingsContainer>
- <NavigationContainer>
- <NavigationBar>
- <NavigationItems>
- <TitleBarItem>
- {sprintf(
- // TRANSLATORS: Title label in navigation bar
- // TRANSLATORS: Available placeholders:
- // TRANSLATORS: %(openvpn)s - Will be replaced with "OpenVPN"
- messages.pgettext('openvpn-settings-nav', '%(openvpn)s settings'),
- { openvpn: strings.openvpn },
- )}
- </TitleBarItem>
- </NavigationItems>
- </NavigationBar>
-
- <NavigationScrollbars>
- <SettingsHeader>
- <HeaderTitle>
- {sprintf(
- // TRANSLATORS: %(openvpn)s will be replaced with "OpenVPN"
- messages.pgettext('openvpn-settings-view', '%(openvpn)s settings'),
- {
- openvpn: strings.openvpn,
- },
- )}
- </HeaderTitle>
- </SettingsHeader>
-
- <Cell.Group>
- <TransportProtocolSelector />
- </Cell.Group>
-
- {protocol ? (
- <Cell.Group>
- <PortSelector />
- </Cell.Group>
- ) : undefined}
-
- <Cell.Group>
- <BridgeModeSelector />
- </Cell.Group>
-
- <Cell.Group>
- <MssFixSetting />
- </Cell.Group>
- </NavigationScrollbars>
- </NavigationContainer>
- </SettingsContainer>
- </Layout>
- </BackAction>
- );
-}
-
-function TransportProtocolSelector() {
- const relaySettingsUpdater = useRelaySettingsUpdater();
- const relaySettings = useSelector((state) => state.settings.relaySettings);
- const bridgeState = useSelector((state) => state.settings.bridgeState);
-
- const protocol = useMemo(() => {
- const protocol = 'normal' in relaySettings ? relaySettings.normal.openvpn.protocol : 'any';
- return protocol === 'any' ? null : protocol;
- }, [relaySettings]);
-
- const onSelect = useCallback(
- async (protocol: RelayProtocol | null) => {
- await relaySettingsUpdater((settings) => {
- settings.openvpnConstraints.protocol = wrapConstraint(protocol);
- settings.openvpnConstraints.port = wrapConstraint<number>(undefined);
- return settings;
- });
- },
- [relaySettingsUpdater],
- );
-
- const items: SelectorItem<RelayProtocol>[] = useMemo(
- () => [
- {
- label: messages.gettext('TCP'),
- value: 'tcp',
- },
- {
- label: messages.gettext('UDP'),
- value: 'udp',
- disabled: bridgeState === 'on',
- },
- ],
- [bridgeState],
- );
-
- return (
- <StyledSelectorContainer>
- <AriaInputGroup>
- <Selector
- title={messages.pgettext('openvpn-settings-view', 'Transport protocol')}
- items={items}
- value={protocol}
- onSelect={onSelect}
- automaticValue={null}
- />
- {bridgeState === 'on' && (
- <Cell.CellFooter>
- <AriaDescription>
- <Cell.CellFooterText>
- {formatHtml(
- // TRANSLATORS: This is used to instruct users how to make UDP mode
- // TRANSLATORS: available.
- messages.pgettext(
- 'openvpn-settings-view',
- 'To activate UDP, change <b>Bridge mode</b> to <b>Automatic</b> or <b>Off</b>.',
- ),
- )}
- </Cell.CellFooterText>
- </AriaDescription>
- </Cell.CellFooter>
- )}
- </AriaInputGroup>
- </StyledSelectorContainer>
- );
-}
-
-function PortSelector() {
- const relaySettingsUpdater = useRelaySettingsUpdater();
- const relaySettings = useSelector((state) => state.settings.relaySettings);
-
- const protocol = useMemo(() => {
- const protocol = 'normal' in relaySettings ? relaySettings.normal.openvpn.protocol : 'any';
- return protocol === 'any' ? null : protocol;
- }, [relaySettings]);
-
- const port = useMemo(() => {
- const port = 'normal' in relaySettings ? relaySettings.normal.openvpn.port : 'any';
- return port === 'any' ? null : port;
- }, [relaySettings]);
-
- const onSelect = useCallback(
- async (port: number | null) => {
- await relaySettingsUpdater((settings) => {
- settings.openvpnConstraints.port = wrapConstraint(port);
- return settings;
- });
- },
- [relaySettingsUpdater],
- );
-
- const portItems = {
- udp: UDP_PORTS.map(mapPortToSelectorItem),
- tcp: TCP_PORTS.map(mapPortToSelectorItem),
- };
-
- if (protocol === null) {
- return null;
- }
-
- return (
- <StyledSelectorContainer>
- <AriaInputGroup>
- <Selector
- title={sprintf(
- // TRANSLATORS: The title for the port selector section.
- // TRANSLATORS: Available placeholders:
- // TRANSLATORS: %(portType)s - a selected protocol (either TCP or UDP)
- messages.pgettext('openvpn-settings-view', '%(portType)s port'),
- {
- portType: protocol.toUpperCase(),
- },
- )}
- items={portItems[protocol]}
- value={port}
- onSelect={onSelect}
- automaticValue={null}
- />
- </AriaInputGroup>
- </StyledSelectorContainer>
- );
-}
-
-function BridgeModeSelector() {
- const { setBridgeState: setBridgeStateImpl } = useAppContext();
- const relaySettings = useSelector((state) => state.settings.relaySettings);
-
- const bridgeState = useSelector((state) => state.settings.bridgeState);
-
- const tunnelProtocol = useMemo(() => {
- const protocol = 'normal' in relaySettings ? relaySettings.normal.tunnelProtocol : 'any';
- return protocol === 'any' ? null : protocol;
- }, [relaySettings]);
-
- const transportProtocol = useMemo(() => {
- const protocol = 'normal' in relaySettings ? relaySettings.normal.openvpn.protocol : 'any';
- return protocol === 'any' ? null : protocol;
- }, [relaySettings]);
-
- const options: SelectorItem<BridgeState>[] = useMemo(
- () => [
- {
- label: messages.gettext('On'),
- value: 'on',
- disabled: tunnelProtocol !== 'openvpn' || transportProtocol === 'udp',
- 'data-testid': 'bridge-mode-on',
- },
- {
- label: messages.gettext('Off'),
- value: 'off',
- },
- ],
- [tunnelProtocol, transportProtocol],
- );
-
- const [confirmationDialogVisible, showConfirmationDialog, hideConfirmationDialog] = useBoolean();
-
- const setBridgeState = useCallback(
- async (bridgeState: BridgeState) => {
- try {
- await setBridgeStateImpl(bridgeState);
- } catch (e) {
- const error = e as Error;
- log.error(`Failed to update bridge state: ${error.message}`);
- }
- },
- [setBridgeStateImpl],
- );
-
- const onSelectBridgeState = useCallback(
- async (newValue: BridgeState) => {
- if (newValue === 'on') {
- showConfirmationDialog();
- } else {
- await setBridgeState(newValue);
- }
- },
- [showConfirmationDialog, setBridgeState],
- );
-
- const confirmBridgeState = useCallback(async () => {
- hideConfirmationDialog();
- await setBridgeState('on');
- }, [hideConfirmationDialog, setBridgeState]);
-
- const footerText = bridgeModeFooterText(bridgeState === 'on', tunnelProtocol, transportProtocol);
-
- return (
- <>
- <AriaInputGroup>
- <StyledSelectorContainer>
- <Selector
- title={
- // TRANSLATORS: The title for the shadowsocks bridge selector section.
- messages.pgettext('openvpn-settings-view', 'Bridge mode')
- }
- infoTitle={messages.pgettext('openvpn-settings-view', 'Bridge mode')}
- details={
- <>
- <ModalMessage>
- {sprintf(
- // TRANSLATORS: This is used as a description for the bridge mode
- // TRANSLATORS: setting.
- // TRANSLATORS: Available placeholders:
- // TRANSLATORS: %(openvpn)s - will be replaced with OpenVPN
- messages.pgettext(
- 'openvpn-settings-view',
- 'Helps circumvent censorship, by routing your traffic through a bridge server before reaching an %(openvpn)s server. Obfuscation is added to make fingerprinting harder.',
- ),
- { openvpn: strings.openvpn },
- )}
- </ModalMessage>
- <ModalMessage>
- {messages.gettext('This setting increases latency. Use only if needed.')}
- </ModalMessage>
- </>
- }
- items={options}
- value={bridgeState}
- onSelect={onSelectBridgeState}
- automaticValue={'auto' as const}
- />
- </StyledSelectorContainer>
- {footerText !== undefined && (
- <Cell.CellFooter>
- <AriaDescription>
- <Cell.CellFooterText>{footerText}</Cell.CellFooterText>
- </AriaDescription>
- </Cell.CellFooter>
- )}
- </AriaInputGroup>
- <ModalAlert
- isOpen={confirmationDialogVisible}
- type={ModalAlertType.caution}
- title={messages.pgettext('openvpn-settings-view', 'Enable bridge mode?')}
- message={
- // TRANSLATORS: Warning shown in dialog to users when they enable setting that increases
- // TRANSLATORS: network latency (decreases performance).
- messages.gettext('This setting increases latency. Use only if needed.')
- }
- gridButtons={[
- <SmallButton key="cancel" onClick={hideConfirmationDialog}>
- {messages.gettext('Cancel')}
- </SmallButton>,
- <SmallButton key="confirm" onClick={confirmBridgeState} data-testid="enable-confirm">
- {messages.gettext('Enable')}
- </SmallButton>,
- ]}
- close={hideConfirmationDialog}
- />
- </>
- );
-}
-
-function bridgeModeFooterText(
- bridgeModeOn: boolean,
- tunnelProtocol: TunnelProtocol | null,
- transportProtocol: RelayProtocol | null,
-): React.ReactNode | void {
- if (bridgeModeOn) {
- // TRANSLATORS: This text is shown beneath the bridge mode setting to instruct users how to
- // TRANSLATORS: configure the feature further.
- return messages.pgettext(
- 'openvpn-settings-view',
- 'To select a specific bridge server, go to the Select location view.',
- );
- } else if (tunnelProtocol !== 'openvpn') {
- return formatHtml(
- sprintf(
- // TRANSLATORS: This is used to instruct users how to make the bridge mode setting
- // TRANSLATORS: available.
- // TRANSLATORS: Available placeholders:
- // TRANSLATORS: %(tunnelProtocol)s - the name of the tunnel protocol setting
- // TRANSLATORS: %(openvpn)s - will be replaced with OpenVPN
- messages.pgettext(
- 'openvpn-settings-view',
- 'To activate Bridge mode, go back and change <b>%(tunnelProtocol)s</b> to <b>%(openvpn)s</b>.',
- ),
- {
- tunnelProtocol: messages.pgettext('vpn-settings-view', 'Tunnel protocol'),
- openvpn: strings.openvpn,
- },
- ),
- );
- } else if (transportProtocol === 'udp') {
- return formatHtml(
- sprintf(
- // TRANSLATORS: This is used to instruct users how to make the bridge mode setting
- // TRANSLATORS: available.
- // TRANSLATORS: Available placeholders:
- // TRANSLATORS: %(transportProtocol)s - the name of the transport protocol setting
- // TRANSLATORS: %(automat)s - the translation of "Automatic"
- // TRANSLATORS: %(openvpn)s - will be replaced with OpenVPN
- messages.pgettext(
- 'openvpn-settings-view',
- 'To activate Bridge mode, change <b>%(transportProtocol)s</b> to <b>%(automatic)s</b> or <b>%(tcp)s</b>.',
- ),
- {
- transportProtocol: messages.pgettext('openvpn-settings-view', 'Transport protocol'),
- automatic: messages.gettext('Automatic'),
- tcp: messages.gettext('TCP'),
- },
- ),
- );
- }
-}
-
-function mssfixIsValid(mssfix: string): boolean {
- const parsedMssFix = mssfix ? parseInt(mssfix) : undefined;
- return (
- parsedMssFix === undefined ||
- (parsedMssFix >= MIN_MSSFIX_VALUE && parsedMssFix <= MAX_MSSFIX_VALUE)
- );
-}
-
-function MssFixSetting() {
- const { setOpenVpnMssfix: setOpenVpnMssfixImpl } = useAppContext();
- const mssfix = useSelector((state) => state.settings.openVpn.mssfix);
-
- const setOpenVpnMssfix = useCallback(
- async (mssfix?: number) => {
- try {
- await setOpenVpnMssfixImpl(mssfix);
- } catch (e) {
- const error = e as Error;
- log.error('Failed to update mssfix value', error.message);
- }
- },
- [setOpenVpnMssfixImpl],
- );
-
- const onMssfixSubmit = useCallback(
- async (value: string) => {
- const parsedValue = value === '' ? undefined : parseInt(value, 10);
- if (mssfixIsValid(value)) {
- await setOpenVpnMssfix(parsedValue);
- }
- },
- [setOpenVpnMssfix],
- );
-
- return (
- <AriaInputGroup>
- <Cell.Container>
- <AriaLabel>
- <Cell.InputLabel>{messages.pgettext('openvpn-settings-view', 'Mssfix')}</Cell.InputLabel>
- </AriaLabel>
- <AriaInput>
- <Cell.AutoSizingTextInput
- initialValue={mssfix ? mssfix.toString() : ''}
- inputMode={'numeric'}
- maxLength={4}
- placeholder={messages.gettext('Default')}
- onSubmitValue={onMssfixSubmit}
- validateValue={mssfixIsValid}
- submitOnBlur={true}
- modifyValue={removeNonNumericCharacters}
- />
- </AriaInput>
- </Cell.Container>
- <Cell.CellFooter>
- <AriaDescription>
- <Cell.CellFooterText>
- {sprintf(
- // TRANSLATORS: The hint displayed below the Mssfix input field.
- // TRANSLATORS: Available placeholders:
- // TRANSLATORS: %(openvpn)s - will be replaced with "OpenVPN"
- // TRANSLATORS: %(max)d - the maximum possible mssfix value
- // TRANSLATORS: %(min)d - the minimum possible mssfix value
- messages.pgettext(
- 'openvpn-settings-view',
- 'Set %(openvpn)s MSS value. Valid range: %(min)d - %(max)d.',
- ),
- {
- openvpn: strings.openvpn,
- min: MIN_MSSFIX_VALUE,
- max: MAX_MSSFIX_VALUE,
- },
- )}
- </Cell.CellFooterText>
- </AriaDescription>
- </Cell.CellFooter>
- </AriaInputGroup>
- );
-}
diff --git a/gui/src/renderer/components/PageSlider.tsx b/gui/src/renderer/components/PageSlider.tsx
deleted file mode 100644
index d03c90a0e2..0000000000
--- a/gui/src/renderer/components/PageSlider.tsx
+++ /dev/null
@@ -1,243 +0,0 @@
-import { useCallback, useEffect, useState } from 'react';
-import styled from 'styled-components';
-
-import { colors } from '../../config.json';
-import { NonEmptyArray } from '../../shared/utils';
-import { useStyledRef } from '../lib/utility-hooks';
-import { Icon } from './cell';
-
-const PAGE_GAP = 16;
-
-const StyledPageSliderContainer = styled.div({
- display: 'flex',
- flexDirection: 'column',
-});
-
-const StyledPageSlider = styled.div({
- whiteSpace: 'nowrap',
- overflow: 'scroll hidden',
- scrollSnapType: 'x mandatory',
- scrollBehavior: 'smooth',
-
- '&&::-webkit-scrollbar': {
- display: 'none',
- },
-});
-
-const StyledPage = styled.div({
- display: 'inline-block',
- width: '100%',
- whiteSpace: 'normal',
- verticalAlign: 'top',
- scrollSnapAlign: 'start',
-
- '&&:not(:last-child)': {
- marginRight: `${PAGE_GAP}px`,
- },
-});
-
-interface PageSliderProps {
- content: NonEmptyArray<React.ReactNode>;
-}
-
-export default function PageSlider(props: PageSliderProps) {
- // A state is needed to trigger a rerender. This is needed to update the "disabled" and "$current"
- // props of the arrows and page indicators.
- const [, setPageNumberState] = useState(0);
- const pageContainerRef = useStyledRef<HTMLDivElement>();
-
- // Calculate the page number based on the scroll position.
- const getPageNumber = useCallback(() => {
- if (pageContainerRef.current) {
- const scrollLeft = pageContainerRef.current.scrollLeft;
- const pageWidth = pageContainerRef.current.offsetWidth + PAGE_GAP;
- // Clamp it between 0 and props.content.length-1 to make sure it will correspond to a page.
- return Math.max(0, Math.min(Math.round(scrollLeft / pageWidth), props.content.length - 1));
- } else {
- return 0;
- }
- }, [pageContainerRef, props.content.length]);
-
- // These values are only intended to be used for display purposes. Using them when calculating
- // next or prev page would increase the risk of race conditions.
- const pageNumber = getPageNumber();
- const hasNext = pageNumber < props.content.length - 1;
- const hasPrev = pageNumber > 0;
-
- // Scroll to a specific page.
- const goToPage = useCallback(
- (page: number) => {
- if (pageContainerRef.current) {
- const width = pageContainerRef.current.offsetWidth;
- pageContainerRef.current.scrollTo({ left: width * page });
- }
- },
- [pageContainerRef],
- );
-
- const next = useCallback(() => goToPage(getPageNumber() + 1), [goToPage, getPageNumber]);
- const prev = useCallback(() => goToPage(getPageNumber() - 1), [goToPage, getPageNumber]);
-
- // Callback that navigates when left and right arrows are pressed.
- const handleKeyDown = useCallback(
- (event: KeyboardEvent) => {
- if (event.key === 'ArrowLeft') {
- prev();
- } else if (event.key === 'ArrowRight') {
- next();
- }
- },
- [next, prev],
- );
-
- // Trigger a rerender when the page number has changed. This needs to be done to update the
- // states of the arrows and page indicators.
- const handleScroll = useCallback(() => setPageNumberState(getPageNumber()), [getPageNumber]);
-
- useEffect(() => {
- document.addEventListener('keydown', handleKeyDown);
- return () => document.removeEventListener('keydown', handleKeyDown);
- }, [handleKeyDown]);
-
- return (
- <StyledPageSliderContainer>
- <StyledPageSlider ref={pageContainerRef} onScroll={handleScroll}>
- {props.content.map((page, i) => (
- <StyledPage key={`page-${i}`}>{page}</StyledPage>
- ))}
- </StyledPageSlider>
- <Controls
- goToPage={goToPage}
- hasNext={hasNext}
- hasPrev={hasPrev}
- next={next}
- prev={prev}
- pageNumber={pageNumber}
- numberOfPages={props.content.length}
- />
- </StyledPageSliderContainer>
- );
-}
-
-const StyledControlsContainer = styled.div({
- display: 'flex',
- marginTop: '12px',
- alignItems: 'center',
-});
-
-const StyledControlElement = styled.div({
- flex: '1 0 60px',
- display: 'flex',
-});
-
-const StyledArrows = styled(StyledControlElement)({
- display: 'flex',
- justifyContent: 'right',
- gap: '12px',
-});
-
-const StyledPageIndicators = styled(StyledControlElement)({
- display: 'flex',
- flexGrow: 2,
- justifyContent: 'center',
-});
-
-const StyledTransparentButton = styled.button({
- border: 'none',
- background: 'transparent',
- padding: '4px',
- margin: 0,
-});
-
-const StyledPageIndicator = styled.div<{ $current: boolean }>((props) => ({
- width: '8px',
- height: '8px',
- borderRadius: '50%',
- backgroundColor: props.$current ? colors.white80 : colors.white40,
-
- [`${StyledTransparentButton}:hover &&`]: {
- backgroundColor: colors.white80,
- },
-}));
-
-const StyledArrow = styled(Icon)((props) => ({
- backgroundColor: props.disabled ? colors.white20 : props.tintColor,
-
- [`${StyledTransparentButton}:hover &&`]: {
- backgroundColor: props.disabled ? colors.white20 : props.tintHoverColor,
- },
-}));
-
-const StyledLeftArrow = styled(StyledArrow)({
- transform: 'scaleX(-100%)',
-});
-
-interface ControlsProps {
- pageNumber: number;
- numberOfPages: number;
- hasNext: boolean;
- hasPrev: boolean;
- next: () => void;
- prev: () => void;
- goToPage: (page: number) => void;
-}
-
-function Controls(props: ControlsProps) {
- return (
- <StyledControlsContainer>
- <StyledControlElement>{/* spacer to make page indicators centered */}</StyledControlElement>
- <StyledPageIndicators>
- {[...Array(props.numberOfPages)].map((_, i) => (
- <PageIndicator
- key={i}
- current={i === props.pageNumber}
- pageNumber={i}
- goToPage={props.goToPage}
- />
- ))}
- </StyledPageIndicators>
- <StyledArrows>
- <StyledTransparentButton onClick={props.prev}>
- <StyledLeftArrow
- disabled={!props.hasPrev}
- height={12}
- width={7}
- source="icon-chevron"
- tintColor={colors.white}
- tintHoverColor={colors.white60}
- />
- </StyledTransparentButton>
- <StyledTransparentButton onClick={props.next}>
- <StyledArrow
- disabled={!props.hasNext}
- height={12}
- width={7}
- source="icon-chevron"
- tintColor={colors.white}
- tintHoverColor={colors.white60}
- />
- </StyledTransparentButton>
- </StyledArrows>
- </StyledControlsContainer>
- );
-}
-
-interface PageIndicatorProps {
- pageNumber: number;
- goToPage: (page: number) => void;
- current: boolean;
-}
-
-function PageIndicator(props: PageIndicatorProps) {
- const { goToPage } = props;
-
- const onClick = useCallback(() => {
- goToPage(props.pageNumber);
- }, [goToPage, props.pageNumber]);
-
- return (
- <StyledTransparentButton onClick={onClick}>
- <StyledPageIndicator $current={props.current} />
- </StyledTransparentButton>
- );
-}
diff --git a/gui/src/renderer/components/ProblemReport.tsx b/gui/src/renderer/components/ProblemReport.tsx
deleted file mode 100644
index 36ddc09c45..0000000000
--- a/gui/src/renderer/components/ProblemReport.tsx
+++ /dev/null
@@ -1,483 +0,0 @@
-import {
- ChangeEvent,
- createContext,
- Dispatch,
- ReactNode,
- SetStateAction,
- useCallback,
- useContext,
- useEffect,
- useMemo,
- useRef,
- useState,
-} from 'react';
-
-import { messages } from '../../shared/gettext';
-import { getDownloadUrl } from '../../shared/version';
-import { useAppContext } from '../context';
-import useActions from '../lib/actionsHook';
-import { useHistory } from '../lib/history';
-import { useEffectEvent } from '../lib/utility-hooks';
-import { useSelector } from '../redux/store';
-import support from '../redux/support/actions';
-import * as AppButton from './AppButton';
-import { AriaDescribed, AriaDescription, AriaDescriptionGroup } from './AriaGroup';
-import ImageView from './ImageView';
-import { BackAction } from './KeyboardNavigation';
-import { Footer, Layout, SettingsContainer } from './Layout';
-import { ModalAlert, ModalAlertType } from './Modal';
-import { NavigationBar, NavigationItems, TitleBarItem } from './NavigationBar';
-import {
- StyledContent,
- StyledContentContainer,
- StyledEmail,
- StyledEmailInput,
- StyledForm,
- StyledFormEmailRow,
- StyledFormMessageRow,
- StyledMessageInput,
- StyledSendStatus,
- StyledSentMessage,
- StyledStatusIcon,
- StyledThanks,
-} from './ProblemReportStyles';
-import SettingsHeader, { HeaderSubTitle, HeaderTitle } from './SettingsHeader';
-
-enum SendState {
- initial,
- confirm,
- sending,
- success,
- failed,
-}
-
-export default function ProblemReport() {
- return (
- <ProblemReportContextProvider>
- <ProblemReportComponent />
- </ProblemReportContextProvider>
- );
-}
-
-function ProblemReportComponent() {
- const history = useHistory();
-
- return (
- <BackAction action={history.pop}>
- <Layout>
- <SettingsContainer>
- <NavigationBar>
- <NavigationItems>
- <TitleBarItem>
- {
- // TRANSLATORS: Title label in navigation bar
- messages.pgettext('support-view', 'Report a problem')
- }
- </TitleBarItem>
- </NavigationItems>
- </NavigationBar>
- <StyledContentContainer>
- <Header />
- <Content />
- </StyledContentContainer>
-
- <NoEmailDialog />
- <OutdatedVersionWarningDialog />
- </SettingsContainer>
- </Layout>
- </BackAction>
- );
-}
-
-function Header() {
- const { sendState } = useProblemReportContext();
-
- return (
- <SettingsHeader>
- <HeaderTitle>{messages.pgettext('support-view', 'Report a problem')}</HeaderTitle>
- {(sendState === SendState.initial || sendState === SendState.confirm) && (
- <HeaderSubTitle>
- {messages.pgettext(
- 'support-view',
- 'To help you more effectively, your app’s log file will be attached to this message. Your data will remain secure and private, as it is anonymised before being sent over an encrypted channel.',
- )}
- </HeaderSubTitle>
- )}
- </SettingsHeader>
- );
-}
-
-function Content() {
- const { sendState } = useProblemReportContext();
-
- switch (sendState) {
- case SendState.initial:
- case SendState.confirm:
- return <Form />;
- case SendState.sending:
- return <Sending />;
- case SendState.success:
- return <Sent />;
- case SendState.failed:
- return <Failed />;
- default:
- return null;
- }
-}
-
-function Form() {
- const { viewLog } = useAppContext();
- const { email, setEmail, message, setMessage, onSend } = useProblemReportContext();
- const { collectLog } = useCollectLog();
-
- const [disableActions, setDisableActions] = useState(false);
-
- const onViewLog = useCallback(async () => {
- setDisableActions(true);
-
- try {
- const reportId = await collectLog();
- await viewLog(reportId);
- } catch {
- // TODO: handle error
- } finally {
- setDisableActions(false);
- }
- }, [collectLog, viewLog]);
-
- const onChangeEmail = useCallback(
- (event: ChangeEvent<HTMLInputElement>) => {
- setEmail(event.target.value);
- },
- [setEmail],
- );
-
- const onChangeDescription = useCallback(
- (event: ChangeEvent<HTMLTextAreaElement>) => {
- setMessage(event.target.value);
- },
- [setMessage],
- );
-
- const validate = () => message.trim().length > 0;
-
- return (
- <StyledContent>
- <StyledForm>
- <StyledFormEmailRow>
- <StyledEmailInput
- placeholder={messages.pgettext('support-view', 'Your email (optional)')}
- defaultValue={email}
- onChange={onChangeEmail}
- />
- </StyledFormEmailRow>
- <StyledFormMessageRow>
- <StyledMessageInput
- placeholder={messages.pgettext(
- 'support-view',
- 'To assist you better, please write in English or Swedish and include which country you are connecting from.',
- )}
- defaultValue={message}
- onChange={onChangeDescription}
- />
- </StyledFormMessageRow>
- </StyledForm>
- <Footer>
- <AriaDescriptionGroup>
- <AriaDescribed>
- <AppButton.ButtonGroup>
- <AppButton.BlueButton onClick={onViewLog} disabled={disableActions}>
- <AppButton.Label>
- {messages.pgettext('support-view', 'View app logs')}
- </AppButton.Label>
- <AriaDescription>
- <AppButton.Icon
- source="icon-extLink"
- height={16}
- width={16}
- aria-label={messages.pgettext('accessibility', 'Opens externally')}
- />
- </AriaDescription>
- </AppButton.BlueButton>
- </AppButton.ButtonGroup>
- </AriaDescribed>
- </AriaDescriptionGroup>
- <AppButton.GreenButton disabled={!validate() || disableActions} onClick={onSend}>
- {messages.pgettext('support-view', 'Send')}
- </AppButton.GreenButton>
- </Footer>
- </StyledContent>
- );
-}
-
-function Sending() {
- return (
- <StyledContent>
- <StyledForm>
- <StyledStatusIcon>
- <ImageView source="icon-spinner" height={60} width={60} />
- </StyledStatusIcon>
- <StyledSendStatus>{messages.pgettext('support-view', 'Sending...')}</StyledSendStatus>
- </StyledForm>
- </StyledContent>
- );
-}
-
-function Sent() {
- const { email } = useProblemReportContext();
-
- const reachBackMessage: ReactNode[] =
- // TRANSLATORS: The message displayed to the user after submitting the problem report, given that the user left his or her email for us to reach back.
- // TRANSLATORS: Available placeholders:
- // TRANSLATORS: %(email)s
- messages
- .pgettext('support-view', 'If needed we will contact you at %(email)s')
- .split('%(email)s', 2);
- reachBackMessage.splice(1, 0, <StyledEmail key="email">{email}</StyledEmail>);
-
- return (
- <StyledContent>
- <StyledForm>
- <StyledStatusIcon>
- <ImageView source="icon-success" height={60} width={60} />
- </StyledStatusIcon>
- <StyledSendStatus>{messages.pgettext('support-view', 'Sent')}</StyledSendStatus>
-
- <StyledSentMessage>
- <StyledThanks>{messages.pgettext('support-view', 'Thanks!')} </StyledThanks>
- {messages.pgettext('support-view', 'We will look into this.')}
- </StyledSentMessage>
- {email.trim().length > 0 ? <StyledSentMessage>{reachBackMessage}</StyledSentMessage> : null}
- </StyledForm>
- </StyledContent>
- );
-}
-
-function Failed() {
- const { setSendState, onSend } = useProblemReportContext();
-
- const handleEditMessage = useCallback(() => {
- setSendState(SendState.initial);
- }, [setSendState]);
-
- return (
- <StyledContent>
- <StyledForm>
- <StyledStatusIcon>
- <ImageView source="icon-fail" height={60} width={60} />
- </StyledStatusIcon>
- <StyledSendStatus>{messages.pgettext('support-view', 'Failed to send')}</StyledSendStatus>
- <StyledSentMessage>
- {messages.pgettext(
- 'support-view',
- 'If you exit the form and try again later, the information you already entered will still be here.',
- )}
- </StyledSentMessage>
- </StyledForm>
- <Footer>
- <AppButton.ButtonGroup>
- <AppButton.BlueButton onClick={handleEditMessage}>
- {messages.pgettext('support-view', 'Edit message')}
- </AppButton.BlueButton>
- <AppButton.GreenButton onClick={onSend}>
- {messages.pgettext('support-view', 'Try again')}
- </AppButton.GreenButton>
- </AppButton.ButtonGroup>
- </Footer>
- </StyledContent>
- );
-}
-
-function NoEmailDialog() {
- const { sendState, setSendState, onSend } = useProblemReportContext();
-
- const message = messages.pgettext(
- 'support-view',
- 'You are about to send the problem report without a way for us to get back to you. If you want an answer to your report you will have to enter an email address.',
- );
-
- const onCancelNoEmailDialog = useCallback(() => {
- setSendState(SendState.initial);
- }, [setSendState]);
-
- return (
- <ModalAlert
- isOpen={sendState === SendState.confirm}
- type={ModalAlertType.warning}
- message={message}
- buttons={[
- <AppButton.RedButton key="proceed" onClick={onSend}>
- {messages.pgettext('support-view', 'Send anyway')}
- </AppButton.RedButton>,
- <AppButton.BlueButton key="cancel" onClick={onCancelNoEmailDialog}>
- {messages.gettext('Back')}
- </AppButton.BlueButton>,
- ]}
- close={onCancelNoEmailDialog}
- />
- );
-}
-
-function OutdatedVersionWarningDialog() {
- const { pop } = useHistory();
- const { openUrl } = useAppContext();
-
- const isOffline = useSelector((state) => state.connection.isBlocked);
- const suggestedIsBeta = useSelector((state) => state.version.suggestedIsBeta ?? false);
- const outdatedVersion = useSelector((state) => !!state.version.suggestedUpgrade);
-
- const [showOutdatedVersionWarning, setShowOutdatedVersionWarning] = useState(outdatedVersion);
-
- const acknowledgeOutdatedVersion = useCallback(() => {
- setShowOutdatedVersionWarning(false);
- }, []);
-
- const openDownloadLink = useCallback(async () => {
- await openUrl(getDownloadUrl(suggestedIsBeta));
- }, [openUrl, suggestedIsBeta]);
-
- const outdatedVersionCancel = useCallback(() => {
- acknowledgeOutdatedVersion();
- pop();
- }, [acknowledgeOutdatedVersion, pop]);
-
- const message = messages.pgettext(
- 'support-view',
- 'You are using an old version of the app. Please upgrade and see if the problem still exists before sending a report.',
- );
-
- return (
- <ModalAlert
- isOpen={showOutdatedVersionWarning}
- type={ModalAlertType.warning}
- message={message}
- buttons={[
- <AriaDescriptionGroup key="upgrade">
- <AriaDescribed>
- <AppButton.GreenButton disabled={isOffline} onClick={openDownloadLink}>
- <AppButton.Label>{messages.pgettext('support-view', 'Upgrade app')}</AppButton.Label>
- <AriaDescription>
- <AppButton.Icon
- height={16}
- width={16}
- source="icon-extLink"
- aria-label={messages.pgettext('accessibility', 'Opens externally')}
- />
- </AriaDescription>
- </AppButton.GreenButton>
- </AriaDescribed>
- </AriaDescriptionGroup>,
- <AppButton.RedButton key="proceed" onClick={acknowledgeOutdatedVersion}>
- {messages.pgettext('support-view', 'Continue anyway')}
- </AppButton.RedButton>,
- <AppButton.BlueButton key="cancel" onClick={outdatedVersionCancel}>
- {messages.gettext('Cancel')}
- </AppButton.BlueButton>,
- ]}
- close={pop}
- />
- );
-}
-
-const useCollectLog = () => {
- const { collectProblemReport } = useAppContext();
- const accountHistory = useSelector((state) => state.account.accountHistory);
-
- const collectLogPromise = useRef<Promise<string>>();
-
- const collectLog = useCallback(async (): Promise<string> => {
- if (collectLogPromise.current) {
- return collectLogPromise.current;
- } else {
- const collectPromise = collectProblemReport(accountHistory);
- // save promise to prevent subsequent requests
- collectLogPromise.current = collectPromise;
-
- try {
- const reportId = await collectPromise;
- return reportId;
- } catch (error) {
- collectLogPromise.current = undefined;
- throw error;
- }
- }
- }, [accountHistory, collectProblemReport]);
-
- return { collectLog };
-};
-
-type ProblemReportContextType = {
- sendState: SendState;
- setSendState: Dispatch<SetStateAction<SendState>>;
- email: string;
- setEmail: Dispatch<SetStateAction<string>>;
- message: string;
- setMessage: Dispatch<SetStateAction<string>>;
- onSend: () => Promise<void>;
-};
-
-const ProblemReportContext = createContext<ProblemReportContextType | undefined>(undefined);
-
-const ProblemReportContextProvider = ({ children }: { children: ReactNode }) => {
- const { sendProblemReport } = useAppContext();
- const { clearReportForm, saveReportForm } = useActions(support);
-
- const { email: defaultEmail, message: defaultMessage } = useSelector((state) => state.support);
-
- const { collectLog } = useCollectLog();
-
- const [sendState, setSendState] = useState(SendState.initial);
- const [email, setEmail] = useState(defaultEmail);
- const [message, setMessage] = useState(defaultMessage);
-
- const sendReport = useCallback(async () => {
- try {
- const reportId = await collectLog();
- await sendProblemReport(email, message, reportId);
- clearReportForm();
- setSendState(SendState.success);
- } catch {
- setSendState(SendState.failed);
- }
- }, [clearReportForm, collectLog, email, message, sendProblemReport]);
-
- const onSend = useCallback(async () => {
- if (sendState === SendState.initial && email.length === 0) {
- setSendState(SendState.confirm);
- } else if (
- sendState === SendState.initial ||
- sendState === SendState.confirm ||
- sendState === SendState.failed
- ) {
- try {
- setSendState(SendState.sending);
- await sendReport();
- } catch {
- // No-op
- }
- }
- }, [email, sendReport, sendState]);
-
- const onMount = useEffectEvent((email: string, message: string) => {
- saveReportForm({ email, message });
- });
-
- /**
- * Save the form whenever email or message gets updated
- */
- useEffect(() => onMount(email, message), [email, message]);
-
- const value: ProblemReportContextType = useMemo(
- () => ({ sendState, setSendState, email, setEmail, message, setMessage, onSend }),
- [sendState, setSendState, email, setEmail, message, setMessage, onSend],
- );
- return <ProblemReportContext.Provider value={value}>{children}</ProblemReportContext.Provider>;
-};
-
-const useProblemReportContext = () => {
- const context = useContext(ProblemReportContext);
- if (!context) {
- throw new Error('useProblemReportContext must be used within a ProblemReportContextProvider');
- }
- return context;
-};
diff --git a/gui/src/renderer/components/ProblemReportStyles.tsx b/gui/src/renderer/components/ProblemReportStyles.tsx
deleted file mode 100644
index 4b32c3fd53..0000000000
--- a/gui/src/renderer/components/ProblemReportStyles.tsx
+++ /dev/null
@@ -1,77 +0,0 @@
-import styled from 'styled-components';
-
-import { colors } from '../../config.json';
-import { hugeText, measurements, smallText } from './common-styles';
-
-export const StyledContentContainer = styled.div({
- display: 'flex',
- flexDirection: 'column',
- flex: 1,
-});
-
-export const StyledContent = styled.div({
- display: 'flex',
- flex: 1,
- flexDirection: 'column',
- justifyContent: 'space-between',
-});
-
-export const StyledForm = styled.div({
- display: 'flex',
- flex: 1,
- flexDirection: 'column',
- margin: `0 ${measurements.viewMargin}`,
-});
-
-export const StyledFormEmailRow = styled.div({
- marginBottom: '12px',
- display: 'flex',
-});
-
-export const StyledFormMessageRow = styled.div({
- display: 'flex',
- flex: 1,
-});
-
-const input = {
- flex: 1,
- borderRadius: '4px',
- padding: '14px',
- color: colors.blue,
- backgroundColor: colors.white,
- border: 'none',
-};
-
-export const StyledEmailInput = styled.input.attrs({ type: 'email' })(smallText, input, {
- lineHeight: '26px',
- fontWeight: 400,
-});
-
-export const StyledMessageInput = styled.textarea(smallText, input, {
- resize: 'none',
- fontWeight: 400,
-});
-
-export const StyledStatusIcon = styled.div({
- display: 'flex',
- justifyContent: 'center',
- marginBottom: '32px',
-});
-
-export const StyledSentMessage = styled.span(smallText, {
- overflow: 'visible',
- color: colors.white60,
-});
-
-export const StyledThanks = styled.span({
- color: colors.green,
-});
-
-export const StyledEmail = styled.span({
- fontWeight: 900,
- color: colors.white,
-});
-
-export const StyledSendStatus = styled.span(hugeText, {
- marginBottom: '4px',
-});
diff --git a/gui/src/renderer/components/ProxyForm.tsx b/gui/src/renderer/components/ProxyForm.tsx
deleted file mode 100644
index 9a163ceebf..0000000000
--- a/gui/src/renderer/components/ProxyForm.tsx
+++ /dev/null
@@ -1,559 +0,0 @@
-import { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
-import React from 'react';
-
-import {
- CustomProxy,
- NamedCustomProxy,
- RelayProtocol,
- ShadowsocksCustomProxy,
- Socks5LocalCustomProxy,
- Socks5RemoteCustomProxy,
-} from '../../shared/daemon-rpc-types';
-import { messages } from '../../shared/gettext';
-import { IpAddress } from '../lib/ip';
-import { useEffectEvent } from '../lib/utility-hooks';
-import * as Cell from './cell';
-import { SettingsForm, useSettingsFormSubmittable } from './cell/SettingsForm';
-import { SettingsGroup } from './cell/SettingsGroup';
-import { SettingsRadioGroup } from './cell/SettingsRadioGroup';
-import { SettingsRow } from './cell/SettingsRow';
-import { SettingsSelect, SettingsSelectItem } from './cell/SettingsSelect';
-import { SettingsNumberInput, SettingsTextInput } from './cell/SettingsTextInput';
-import {
- SmallButton,
- SmallButtonColor,
- SmallButtonGroup,
- SmallButtonGroupStart,
-} from './SmallButton';
-
-interface ProxyFormContext {
- proxy?: CustomProxy;
- setProxy: (proxy: CustomProxy) => void;
- onSave: () => void;
- onCancel: () => void;
- onDelete?: () => void;
-}
-
-const proxyFormContext = React.createContext<ProxyFormContext>({
- get proxy(): CustomProxy {
- throw new Error('Missing ProxyFromContext provider');
- },
- setProxy(): void {
- throw new Error('Missing ProxyFromContext provider');
- },
- onSave(): void {
- throw new Error('Missing ProxyFromContext provider');
- },
- onCancel(): void {
- throw new Error('Missing ProxyFromContext provider');
- },
- onDelete(): void {
- throw new Error('Missing ProxyFromContext provider');
- },
-});
-
-interface ProxyFormContextProviderProps {
- proxy?: CustomProxy;
- onSave: (proxy: CustomProxy) => void;
- onCancel: () => void;
- onDelete?: () => void;
-}
-
-function ProxyFormContextProvider(props: React.PropsWithChildren<ProxyFormContextProviderProps>) {
- const { onSave: propsOnSave } = props;
-
- const [proxy, setProxy] = useState<CustomProxy | undefined>(props.proxy);
-
- const onSave = useCallback(() => {
- if (proxy !== undefined) {
- propsOnSave(proxy);
- }
- }, [proxy, propsOnSave]);
-
- const value = useMemo(
- () => ({ proxy, setProxy, onSave, onCancel: props.onCancel, onDelete: props.onDelete }),
- [proxy, onSave, props.onCancel, props.onDelete],
- );
-
- return <proxyFormContext.Provider value={value}>{props.children}</proxyFormContext.Provider>;
-}
-
-export function ProxyForm(props: ProxyFormContextProviderProps) {
- return (
- <ProxyFormContextProvider {...props}>
- <SettingsForm>
- <ProxyFormInner />
- <ProxyFormButtons new={props.proxy === undefined} />
- </SettingsForm>
- </ProxyFormContextProvider>
- );
-}
-
-interface NamedProxyFormContext {
- name?: string;
- setName: (name: string) => void;
-}
-
-const namedProxyFormContext = React.createContext<NamedProxyFormContext>({
- get name(): string {
- throw new Error('Missing NamedProxyFromContext provider');
- },
- setName(): void {
- throw new Error('Missing NamedProxyFromContext provider');
- },
-});
-
-interface NamedProxyFormContainerProps
- extends Omit<ProxyFormContextProviderProps, 'proxy' | 'onSave'> {
- proxy?: NamedCustomProxy;
- onSave: (proxy: NamedCustomProxy) => void;
-}
-
-export function NamedProxyForm(props: NamedProxyFormContainerProps) {
- const { onSave, ...otherProps } = props;
-
- const [name, setName] = useState<string>(props.proxy?.name ?? '');
-
- const save = useCallback(
- (proxy: CustomProxy) => {
- if (name !== '') {
- onSave({ ...proxy, name });
- }
- },
- [name, onSave],
- );
-
- const nameContextValue = useMemo(() => ({ name, setName }), [name]);
-
- return (
- <namedProxyFormContext.Provider value={nameContextValue}>
- <ProxyFormContextProvider {...otherProps} onSave={save}>
- <SettingsForm>
- <ProxyFormNameField />
- <ProxyFormInner />
- <ProxyFormButtons new={props.proxy === undefined} />
- </SettingsForm>
- </ProxyFormContextProvider>
- </namedProxyFormContext.Provider>
- );
-}
-
-function ProxyFormNameField() {
- const { name, setName } = useContext(namedProxyFormContext);
-
- return (
- <SettingsRow label={messages.gettext('Name')}>
- <SettingsTextInput
- defaultValue={name}
- placeholder={messages.pgettext('api-access-methods-view', 'Enter name')}
- onUpdate={setName}
- />
- </SettingsRow>
- );
-}
-
-interface ProxyFormButtonsProps {
- new: boolean;
-}
-
-export function ProxyFormButtons(props: ProxyFormButtonsProps) {
- const { onSave, onCancel, onDelete } = useContext(proxyFormContext);
-
- // Contains form submittability to know whether or not to enable the Add/Save button.
- const formSubmittable = useSettingsFormSubmittable();
-
- return (
- <SmallButtonGroup>
- {onDelete !== undefined && (
- <SmallButtonGroupStart>
- <SmallButton color={SmallButtonColor.red} onClick={onDelete}>
- {messages.gettext('Delete')}
- </SmallButton>
- </SmallButtonGroupStart>
- )}
- <SmallButton onClick={onCancel}>{messages.gettext('Cancel')}</SmallButton>
- <SmallButton onClick={onSave} disabled={!formSubmittable}>
- {props.new ? messages.gettext('Add') : messages.gettext('Save')}
- </SmallButton>
- </SmallButtonGroup>
- );
-}
-
-function ProxyFormInner() {
- const { proxy, setProxy } = useContext(proxyFormContext);
-
- // Available custom proxies
- const types = useMemo<Array<SettingsSelectItem<CustomProxy['type']>>>(
- () => [
- { value: 'shadowsocks', label: 'Shadowsocks' },
- {
- value: 'socks5-remote',
- label: messages.pgettext('api-access-methods-view', 'SOCKS5 remote'),
- },
- {
- value: 'socks5-local',
- label: messages.pgettext('api-access-methods-view', 'SOCKS5 local'),
- },
- ],
- [],
- );
- const [type, setType] = useState(proxy?.type ?? 'shadowsocks');
- const proxyRef = useRef<CustomProxy | undefined>(proxy);
-
- const updateProxy = useCallback(
- (value: CustomProxy) => {
- proxyRef.current = value;
-
- // When the form makes up a valid proxy the parent is updated.
- if (proxyRef.current !== undefined) {
- setProxy(proxyRef.current);
- }
- },
- [setProxy],
- );
-
- return (
- <>
- <SettingsRow label={messages.gettext('Type')}>
- <SettingsSelect defaultValue={type} onUpdate={setType} items={types} />
- </SettingsRow>
-
- {type === 'shadowsocks' && (
- <EditShadowsocks
- onUpdate={updateProxy}
- proxy={proxy?.type === 'shadowsocks' ? proxy : undefined}
- />
- )}
- {type === 'socks5-remote' && (
- <EditSocks5Remote
- onUpdate={updateProxy}
- proxy={proxy?.type === 'socks5-remote' ? proxy : undefined}
- />
- )}
- {type === 'socks5-local' && (
- <EditSocks5Local
- onUpdate={updateProxy}
- proxy={proxy?.type === 'socks5-local' ? proxy : undefined}
- />
- )}
- </>
- );
-}
-
-interface EditProxyProps<T> {
- proxy?: T;
- onUpdate: (proxy: CustomProxy) => void;
-}
-
-function EditShadowsocks(props: EditProxyProps<ShadowsocksCustomProxy>) {
- const [ip, setIp] = useState(props.proxy?.ip ?? '');
- const [port, setPort] = useState(props.proxy?.port);
- const [password, setPassword] = useState(props.proxy?.password ?? '');
- const [cipher, setCipher] = useState(props.proxy?.cipher);
-
- const ciphers = useMemo(
- () =>
- [
- { value: 'aes-128-cfb', label: 'aes-128-cfb' },
- { value: 'aes-128-cfb1', label: 'aes-128-cfb1' },
- { value: 'aes-128-cfb8', label: 'aes-128-cfb8' },
- { value: 'aes-128-cfb128', label: 'aes-128-cfb128' },
- { value: 'aes-256-cfb', label: 'aes-256-cfb' },
- { value: 'aes-256-cfb1', label: 'aes-256-cfb1' },
- { value: 'aes-256-cfb8', label: 'aes-256-cfb8' },
- { value: 'aes-256-cfb128', label: 'aes-256-cfb128' },
- { value: 'rc4', label: 'rc4' },
- { value: 'rc4-md5', label: 'rc4-md5' },
- { value: 'chacha20', label: 'chacha20' },
- { value: 'salsa20', label: 'salsa20' },
- { value: 'chacha20-ietf', label: 'chacha20-ietf' },
- { value: 'aes-128-gcm', label: 'aes-128-gcm' },
- { value: 'aes-256-gcm', label: 'aes-256-gcm' },
- { value: 'chacha20-ietf-poly1305', label: 'chacha20-ietf-poly1305' },
- { value: 'xchacha20-ietf-poly1305', label: 'xchacha20-ietf-poly1305' },
- { value: 'aes-128-pmac-siv', label: 'aes-128-pmac-siv' },
- { value: 'aes-256-pmac-siv', label: 'aes-256-pmac-siv' },
- ].sort((a, b) => a.label.localeCompare(b.label)),
- [],
- );
-
- const onUpdate = useEffectEvent(
- (ip: string, port: number | undefined, password: string, cipher: string | undefined) => {
- if (ip !== '' && port !== undefined && cipher !== undefined) {
- props.onUpdate({
- type: 'shadowsocks',
- ip,
- port,
- password,
- cipher,
- });
- }
- },
- );
-
- // Report back to form component with the proxy values when all required values are set.
- useEffect(() => onUpdate(ip, port, password, cipher), [ip, port, password, cipher]);
-
- return (
- <SettingsGroup title={messages.pgettext('api-access-methods-view', 'Server details')}>
- <SettingsRow
- label={messages.pgettext('api-access-methods-view', 'Server')}
- errorMessage={messages.pgettext(
- 'api-access-methods-view',
- 'Please enter a valid IPv4 or IPv6 address.',
- )}>
- <SettingsTextInput
- value={ip}
- placeholder={messages.pgettext('api-access-methods-view', 'Enter IP')}
- onUpdate={setIp}
- validate={validateIp}
- />
- </SettingsRow>
-
- <SettingsRow
- label={messages.gettext('Port')}
- errorMessage={messages.pgettext(
- 'api-access-methods-view',
- 'Please enter a valid remote server port.',
- )}>
- <SettingsNumberInput
- value={port ?? ''}
- placeholder={messages.pgettext('api-access-methods-view', 'Enter port')}
- onUpdate={setPort}
- validate={validatePort}
- />
- </SettingsRow>
-
- <SettingsRow label={messages.gettext('Password')}>
- <SettingsTextInput
- value={password}
- placeholder={messages.gettext('Optional')}
- onUpdate={setPassword}
- optionalInForm
- />
- </SettingsRow>
-
- <SettingsRow label={messages.gettext('Cipher')}>
- <SettingsSelect
- data-testid="ciphers"
- direction="up"
- defaultValue={cipher}
- onUpdate={setCipher}
- items={ciphers}
- />
- </SettingsRow>
- </SettingsGroup>
- );
-}
-
-function EditSocks5Remote(props: EditProxyProps<Socks5RemoteCustomProxy>) {
- const [ip, setIp] = useState(props.proxy?.ip ?? '');
- const [port, setPort] = useState(props.proxy?.port);
- const [authentication, setAuthentication] = useState(props.proxy?.authentication !== undefined);
- const [username, setUsername] = useState(props.proxy?.authentication?.username ?? '');
- const [password, setPassword] = useState(props.proxy?.authentication?.password ?? '');
-
- const onUpdate = useEffectEvent(
- (ip: string, port: number | undefined, username: string, password: string) => {
- if (
- ip !== '' &&
- port !== undefined &&
- (!authentication || (username !== '' && password !== ''))
- ) {
- props.onUpdate({
- type: 'socks5-remote',
- ip,
- port,
- authentication: authentication ? { username, password } : undefined,
- });
- }
- },
- );
-
- // Report back to form component with the proxy values when all required values are set.
- useEffect(() => onUpdate(ip, port, username, password), [ip, port, username, password]);
-
- return (
- <SettingsGroup title={messages.pgettext('api-access-methods-view', 'Remote Server')}>
- <SettingsRow
- label={messages.pgettext('api-access-methods-view', 'Server')}
- errorMessage={messages.pgettext(
- 'api-access-methods-view',
- 'Please enter a valid IPv4 or IPv6 address.',
- )}>
- <SettingsTextInput
- value={ip}
- placeholder={messages.pgettext('api-access-methods-view', 'Enter IP')}
- onUpdate={setIp}
- validate={validateIp}
- />
- </SettingsRow>
-
- <SettingsRow
- label={messages.gettext('Port')}
- errorMessage={messages.pgettext(
- 'api-access-methods-view',
- 'Please enter a valid remote server port.',
- )}>
- <SettingsNumberInput
- value={port ?? ''}
- placeholder={messages.pgettext('api-access-methods-view', 'Enter port')}
- onUpdate={setPort}
- validate={validatePort}
- />
- </SettingsRow>
-
- <SettingsRow label={messages.pgettext('api-access-methods-view', 'Authentication')}>
- <Cell.Switch isOn={authentication} onChange={setAuthentication} />
- </SettingsRow>
-
- {authentication && (
- <>
- <SettingsRow label={messages.gettext('Username')}>
- <SettingsTextInput
- value={username}
- placeholder={messages.gettext('Required')}
- onUpdate={setUsername}
- />
- </SettingsRow>
-
- <SettingsRow label={messages.gettext('Password')}>
- <SettingsTextInput
- value={password}
- placeholder={messages.gettext('Required')}
- onUpdate={setPassword}
- />
- </SettingsRow>
- </>
- )}
- </SettingsGroup>
- );
-}
-
-function EditSocks5Local(props: EditProxyProps<Socks5LocalCustomProxy>) {
- const [remoteIp, setRemoteIp] = useState(props.proxy?.remoteIp ?? '');
- const [remotePort, setRemotePort] = useState(props.proxy?.remotePort);
- const [remoteTransportProtocol, setRemoteTransportProtocol] = useState<RelayProtocol>(
- props.proxy?.remoteTransportProtocol ?? 'tcp',
- );
- const [localPort, setLocalPort] = useState(props.proxy?.localPort);
-
- const remoteTransportProtocols = useMemo<Array<SettingsSelectItem<RelayProtocol>>>(
- () => [
- { value: 'tcp', label: 'TCP' },
- { value: 'udp', label: 'UDP' },
- ],
- [],
- );
-
- const onUpdate = useEffectEvent(
- (
- remoteIp: string,
- remotePort: number | undefined,
- localPort: number | undefined,
- remoteTransportProtocol: RelayProtocol,
- ) => {
- if (remoteIp !== '' && remotePort !== undefined && localPort !== undefined) {
- props.onUpdate({
- type: 'socks5-local',
- remoteIp,
- remotePort,
- remoteTransportProtocol,
- localPort,
- });
- }
- },
- );
-
- useEffect(
- () => onUpdate(remoteIp, remotePort, localPort, remoteTransportProtocol),
- [remoteIp, remotePort, localPort, remoteTransportProtocol],
- );
-
- return (
- <>
- <SettingsGroup
- title={messages.pgettext('api-access-methods-view', 'Local SOCKS5 server')}
- infoMessage={messages.pgettext(
- 'api-access-methods-view',
- 'The TCP port where your local SOCKS5 server is listening.',
- )}>
- <SettingsRow
- label={messages.gettext('Port')}
- errorMessage={messages.pgettext(
- 'api-access-methods-view',
- 'Please enter a valid localhost port.',
- )}>
- <SettingsNumberInput
- value={localPort}
- placeholder={messages.pgettext('api-access-methods-view', 'Enter port')}
- onUpdate={setLocalPort}
- validate={validatePort}
- />
- </SettingsRow>
- </SettingsGroup>
-
- <SettingsGroup
- title={messages.pgettext('api-access-methods-view', 'Remote Server')}
- infoMessage={[
- messages.pgettext(
- 'api-access-methods-view',
- 'The app needs the remote server details, where your local SOCKS5 server will forward your traffic.',
- ),
- messages.pgettext(
- 'api-access-methods-view',
- 'This is needed so our app can allow that traffic in the firewall.',
- ),
- ]}>
- <SettingsRow
- label={messages.pgettext('api-access-methods-view', 'Server')}
- errorMessage={messages.pgettext(
- 'api-access-methods-view',
- 'Please enter a valid IPv4 or IPv6 address.',
- )}>
- <SettingsTextInput
- value={remoteIp}
- placeholder={messages.pgettext('api-access-methods-view', 'Enter IP')}
- onUpdate={setRemoteIp}
- validate={validateIp}
- />
- </SettingsRow>
-
- <SettingsRow
- label={messages.gettext('Port')}
- errorMessage={messages.pgettext(
- 'api-access-methods-view',
- 'Please enter a valid remote server port.',
- )}>
- <SettingsNumberInput
- value={remotePort ?? ''}
- placeholder={messages.pgettext('api-access-methods-view', 'Enter port')}
- onUpdate={setRemotePort}
- validate={validatePort}
- />
- </SettingsRow>
-
- <SettingsRow label={messages.pgettext('api-access-methods-view', 'Transport protocol')}>
- <SettingsRadioGroup<'tcp' | 'udp'>
- defaultValue={remoteTransportProtocol}
- onUpdate={setRemoteTransportProtocol}
- items={remoteTransportProtocols}
- />
- </SettingsRow>
- </SettingsGroup>
- </>
- );
-}
-
-function validateIp(ip: string): boolean {
- try {
- void IpAddress.fromString(ip);
- return true;
- } catch {
- return false;
- }
-}
-
-function validatePort(port: number): boolean {
- return port > 0 && port <= 65535;
-}
diff --git a/gui/src/renderer/components/RedeemVoucher.tsx b/gui/src/renderer/components/RedeemVoucher.tsx
deleted file mode 100644
index c88d68a087..0000000000
--- a/gui/src/renderer/components/RedeemVoucher.tsx
+++ /dev/null
@@ -1,285 +0,0 @@
-import React, { useCallback, useContext, useState } from 'react';
-import { sprintf } from 'sprintf-js';
-
-import { formatDate } from '../../shared/account-expiry';
-import { VoucherResponse } from '../../shared/daemon-rpc-types';
-import { formatRelativeDate } from '../../shared/date-helper';
-import { messages } from '../../shared/gettext';
-import { useAppContext } from '../context';
-import { useSelector } from '../redux/store';
-import * as AppButton from './AppButton';
-import ImageView from './ImageView';
-import { ModalAlert } from './Modal';
-import {
- StyledEmptyResponse,
- StyledErrorResponse,
- StyledInput,
- StyledLabel,
- StyledProgressResponse,
- StyledProgressWrapper,
- StyledSpinner,
- StyledStatusIcon,
- StyledTitle,
-} from './RedeemVoucherStyles';
-
-const MIN_VOUCHER_LENGTH = 16;
-
-interface IRedeemVoucherContextValue {
- onSubmit: () => void;
- value: string;
- setValue: (value: string) => void;
- valueValid: boolean;
- submitting: boolean;
- response?: VoucherResponse;
-}
-
-const contextProviderMissingError = new Error('<RedeemVoucherContext.Provider> is missing');
-
-const RedeemVoucherContext = React.createContext<IRedeemVoucherContextValue>({
- onSubmit() {
- throw contextProviderMissingError;
- },
- get value(): string {
- throw contextProviderMissingError;
- },
- setValue(_) {
- throw contextProviderMissingError;
- },
- get valueValid(): boolean {
- throw contextProviderMissingError;
- },
- get submitting(): boolean {
- throw contextProviderMissingError;
- },
- get response(): VoucherResponse {
- throw contextProviderMissingError;
- },
-});
-
-interface IRedeemVoucherProps {
- onSubmit?: () => void;
- onSuccess?: (newExpiry: string, secondsAdded: number) => void;
- onFailure?: () => void;
- children?: React.ReactNode;
-}
-
-export function RedeemVoucherContainer(props: IRedeemVoucherProps) {
- const { onSubmit, onSuccess, onFailure } = props;
-
- const { submitVoucher } = useAppContext();
-
- const [value, setValue] = useState('');
- const [submitting, setSubmitting] = useState(false);
- const [response, setResponse] = useState<VoucherResponse>();
-
- const valueValid = value.length >= MIN_VOUCHER_LENGTH;
-
- const onSubmitWrapper = useCallback(async () => {
- if (!valueValid) {
- return;
- }
-
- const submitTimestamp = Date.now();
- setSubmitting(true);
- onSubmit?.();
- const response = await submitVoucher(value);
-
- // Show the spinner for at least half a second if it isn't successful.
- const submitDuration = Date.now() - submitTimestamp;
- if (response.type !== 'success' && submitDuration < 500) {
- await new Promise((resolve) => setTimeout(resolve, 500 - submitDuration));
- }
-
- setSubmitting(false);
- setResponse(response);
- if (response.type === 'success') {
- onSuccess?.(response.newExpiry, response.secondsAdded);
- } else {
- onFailure?.();
- }
- }, [value, valueValid, onSubmit, submitVoucher, onSuccess, onFailure]);
-
- return (
- <RedeemVoucherContext.Provider
- value={{ onSubmit: onSubmitWrapper, value, setValue, valueValid, submitting, response }}>
- {props.children}
- </RedeemVoucherContext.Provider>
- );
-}
-
-interface IRedeemVoucherInputProps {
- className?: string;
-}
-
-export function RedeemVoucherInput(props: IRedeemVoucherInputProps) {
- const { value, setValue, onSubmit, submitting, response } = useContext(RedeemVoucherContext);
- const disabled = submitting || response?.type === 'success';
-
- const handleChange = useCallback(
- (value: string) => {
- setValue(value);
- },
- [setValue],
- );
-
- const onKeyPress = useCallback(
- (event: React.KeyboardEvent<HTMLInputElement>) => {
- if (event.key === 'Enter') {
- onSubmit();
- }
- },
- [onSubmit],
- );
-
- return (
- <StyledInput
- className={props.className}
- allowedCharacters="[A-Z0-9]"
- separator="-"
- uppercaseOnly
- groupLength={4}
- maxLength={16}
- addTrailingSeparator
- disabled={disabled}
- value={value}
- placeholder={'XXXX-XXXX-XXXX-XXXX'}
- handleChange={handleChange}
- onKeyPress={onKeyPress}
- />
- );
-}
-
-export function RedeemVoucherResponse() {
- const { response, submitting } = useContext(RedeemVoucherContext);
-
- if (submitting) {
- return (
- <>
- <StyledProgressWrapper>
- <StyledSpinner source="icon-spinner" height={20} width={20} />
- <StyledProgressResponse>
- {messages.pgettext('redeem-voucher-view', 'Verifying voucher...')}
- </StyledProgressResponse>
- </StyledProgressWrapper>
- </>
- );
- }
-
- if (response) {
- switch (response.type) {
- case 'success':
- return <StyledEmptyResponse />;
- case 'invalid':
- return (
- <StyledErrorResponse>
- {messages.pgettext('redeem-voucher-view', 'Voucher code is invalid.')}
- </StyledErrorResponse>
- );
- case 'already_used':
- return (
- <StyledErrorResponse>
- {messages.pgettext('redeem-voucher-view', 'Voucher code has already been used.')}
- </StyledErrorResponse>
- );
- case 'error':
- return (
- <StyledErrorResponse>
- {messages.pgettext('redeem-voucher-view', 'An error occurred.')}
- </StyledErrorResponse>
- );
- }
- }
-
- return <StyledEmptyResponse />;
-}
-
-export function RedeemVoucherSubmitButton() {
- const { valueValid, onSubmit, submitting, response } = useContext(RedeemVoucherContext);
- const disabled = submitting || response?.type === 'success';
-
- return (
- <AppButton.GreenButton disabled={!valueValid || disabled} onClick={onSubmit}>
- {messages.pgettext('redeem-voucher-view', 'Redeem')}
- </AppButton.GreenButton>
- );
-}
-
-interface IRedeemVoucherAlertProps {
- show: boolean;
- onClose?: () => void;
-}
-
-export function RedeemVoucherAlert(props: IRedeemVoucherAlertProps) {
- const { submitting, response } = useContext(RedeemVoucherContext);
- const locale = useSelector((state) => state.userInterface.locale);
-
- if (response?.type === 'success') {
- const duration = formatRelativeDate(0, response.secondsAdded * 1000, {
- capitalize: true,
- displayMonths: true,
- });
- const expiry = formatDate(response.newExpiry, locale);
-
- return (
- <ModalAlert
- isOpen={props.show}
- buttons={[
- <AppButton.BlueButton key="gotit" onClick={props.onClose}>
- {messages.gettext('Got it!')}
- </AppButton.BlueButton>,
- ]}
- close={props.onClose}>
- <StyledStatusIcon>
- <ImageView source="icon-success" height={60} width={60} />
- </StyledStatusIcon>
- <StyledTitle>
- {messages.pgettext('redeem-voucher-view', 'Voucher was successfully redeemed.')}
- </StyledTitle>
- <StyledLabel>
- {sprintf(messages.gettext('%(duration)s was added, account paid until %(expiry)s.'), {
- duration,
- expiry,
- })}
- </StyledLabel>
- </ModalAlert>
- );
- } else {
- return (
- <ModalAlert
- isOpen={props.show}
- buttons={[
- <RedeemVoucherSubmitButton key="submit" />,
- <AppButton.BlueButton key="cancel" disabled={submitting} onClick={props.onClose}>
- {messages.pgettext('redeem-voucher-alert', 'Cancel')}
- </AppButton.BlueButton>,
- ]}
- close={props.onClose}>
- <StyledLabel>{messages.pgettext('redeem-voucher-alert', 'Enter voucher code')}</StyledLabel>
- <RedeemVoucherInput />
- <RedeemVoucherResponse />
- </ModalAlert>
- );
- }
-}
-
-interface IRedeemVoucherButtonProps {
- className?: string;
-}
-
-export function RedeemVoucherButton(props: IRedeemVoucherButtonProps) {
- const [showAlert, setShowAlert] = useState(false);
-
- const onClick = useCallback(() => setShowAlert(true), []);
- const onClose = useCallback(() => setShowAlert(false), []);
-
- return (
- <>
- <AppButton.GreenButton onClick={onClick} className={props.className}>
- {messages.pgettext('redeem-voucher-alert', 'Redeem voucher')}
- </AppButton.GreenButton>
- <RedeemVoucherContainer>
- <RedeemVoucherAlert show={showAlert} onClose={onClose} />
- </RedeemVoucherContainer>
- </>
- );
-}
diff --git a/gui/src/renderer/components/RedeemVoucherStyles.tsx b/gui/src/renderer/components/RedeemVoucherStyles.tsx
deleted file mode 100644
index 37f51ee5e4..0000000000
--- a/gui/src/renderer/components/RedeemVoucherStyles.tsx
+++ /dev/null
@@ -1,70 +0,0 @@
-import styled from 'styled-components';
-
-import { colors } from '../../config.json';
-import { normalText, smallText, tinyText } from './common-styles';
-import FormattableTextInput from './FormattableTextInput';
-import ImageView from './ImageView';
-
-export const StyledLabel = styled.span(smallText, {
- color: colors.white,
- marginBottom: '9px',
-});
-
-export const StyledInput = styled(FormattableTextInput)(normalText, {
- flex: 1,
- overflow: 'hidden',
- padding: '14px',
- fontWeight: 600,
- lineHeight: '26px',
- color: colors.blue,
- backgroundColor: colors.white,
- border: 'none',
- borderRadius: '4px',
- '&&::placeholder': {
- color: colors.blue40,
- },
-});
-
-export const StyledResponse = styled.span(tinyText, {
- lineHeight: '20px',
- marginTop: '8px',
- color: colors.white,
-});
-
-export const StyledProgressWrapper = styled.div({
- display: 'flex',
- alignItems: 'center',
- marginTop: '8px',
-});
-
-export const StyledProgressResponse = styled(StyledResponse)({
- marginTop: 0,
-});
-
-export const StyledErrorResponse = styled(StyledResponse)({
- color: colors.red,
-});
-
-export const StyledEmptyResponse = styled.span({
- height: '20px',
- marginTop: '8px',
-});
-
-export const StyledSpinner = styled(ImageView)({
- marginRight: '8px',
-});
-
-export const StyledStatusIcon = styled.div({
- alignSelf: 'center',
- width: '60px',
- height: '60px',
- marginBottom: '18px',
- marginTop: '25px',
-});
-
-export const StyledTitle = styled.span(smallText, {
- lineHeight: '22px',
- fontWeight: 400,
- color: colors.white,
- marginBottom: '5px',
-});
diff --git a/gui/src/renderer/components/RelayStatusIndicator.tsx b/gui/src/renderer/components/RelayStatusIndicator.tsx
deleted file mode 100644
index 1060121b9e..0000000000
--- a/gui/src/renderer/components/RelayStatusIndicator.tsx
+++ /dev/null
@@ -1,40 +0,0 @@
-import styled from 'styled-components';
-import { Styles } from 'styled-components/dist/types';
-
-import { colors } from '../../config.json';
-import * as Cell from './cell';
-
-const indicatorStyles: Styles<
- React.DetailedHTMLProps<React.HTMLAttributes<HTMLDivElement>, HTMLDivElement>
-> = {
- width: '16px',
- height: '16px',
- borderRadius: '8px',
- margin: '0 12px 0 4px',
-};
-
-const StyledRelayStatus = styled.div<{ $active: boolean }>(indicatorStyles, (props) => ({
- backgroundColor: props.$active ? colors.green90 : colors.red95,
-}));
-
-const TickIcon = styled(Cell.Icon)({
- marginLeft: '3px',
- marginRight: '8px',
-});
-
-interface IProps {
- active: boolean;
- selected: boolean;
-}
-
-export default function RelayStatusIndicator(props: IProps) {
- return props.selected ? (
- <TickIcon tintColor={colors.white} source="icon-tick" width={18} />
- ) : (
- <StyledRelayStatus $active={props.active} />
- );
-}
-
-export const SpecialLocationIndicator = styled.div(indicatorStyles, {
- backgroundColor: colors.white90,
-});
diff --git a/gui/src/renderer/components/SearchBar.tsx b/gui/src/renderer/components/SearchBar.tsx
deleted file mode 100644
index fe1d1936aa..0000000000
--- a/gui/src/renderer/components/SearchBar.tsx
+++ /dev/null
@@ -1,118 +0,0 @@
-import { useCallback, useEffect } from 'react';
-import styled from 'styled-components';
-
-import { colors } from '../../config.json';
-import { messages } from '../../shared/gettext';
-import { useEffectEvent, useStyledRef } from '../lib/utility-hooks';
-import { normalText } from './common-styles';
-import ImageView from './ImageView';
-
-export const StyledSearchContainer = styled.div({
- position: 'relative',
- display: 'flex',
-});
-
-export const StyledSearchInput = styled.input.attrs({ type: 'text' })({
- ...normalText,
- flex: 1,
- border: 'none',
- borderRadius: '4px',
- padding: '9px 38px',
- margin: 0,
- lineHeight: '24px',
- color: colors.white60,
- backgroundColor: colors.white10,
- '&&::placeholder': {
- color: colors.white60,
- },
- '&&:focus': {
- color: colors.blue,
- backgroundColor: colors.white,
- },
- '&&:focus::placeholder': {
- color: colors.blue40,
- },
-});
-
-export const StyledClearButton = styled.button({
- position: 'absolute',
- top: '50%',
- transform: 'translateY(-50%)',
- right: '9px',
- border: 'none',
- background: 'none',
- padding: 0,
-});
-
-export const StyledSearchIcon = styled(ImageView)({
- position: 'absolute',
- top: '50%',
- transform: 'translateY(-50%)',
- left: '9px',
- [`${StyledSearchInput}:focus ~ &&`]: {
- backgroundColor: colors.blue,
- },
-});
-
-export const StyledClearIcon = styled(ImageView)({
- '&&:hover': {
- backgroundColor: colors.white60,
- },
- [`${StyledSearchInput}:focus ~ ${StyledClearButton} &&`]: {
- backgroundColor: colors.blue40,
- },
- [`${StyledSearchInput}:focus ~ ${StyledClearButton} &&:hover`]: {
- backgroundColor: colors.blue,
- },
-});
-
-interface ISearchBarProps {
- searchTerm: string;
- onSearch: (searchTerm: string) => void;
- className?: string;
- disableAutoFocus?: boolean;
-}
-
-export default function SearchBar(props: ISearchBarProps) {
- const { onSearch } = props;
-
- const inputRef = useStyledRef<HTMLInputElement>();
-
- const onInput = useCallback(
- (event: React.FormEvent) => {
- const element = event.target as HTMLInputElement;
- onSearch(element.value);
- },
- [onSearch],
- );
-
- const onClear = useCallback(() => {
- onSearch('');
- inputRef.current?.blur();
- }, [inputRef, onSearch]);
-
- const focusInput = useEffectEvent(() => {
- if (!props.disableAutoFocus) {
- inputRef.current?.focus({ preventScroll: true });
- }
- });
-
- useEffect(() => focusInput(), []);
-
- return (
- <StyledSearchContainer className={props.className}>
- <StyledSearchInput
- ref={inputRef}
- value={props.searchTerm}
- onInput={onInput}
- placeholder={messages.gettext('Search for...')}
- />
- <StyledSearchIcon source="icon-search" width={24} tintColor={colors.white60} />
- {props.searchTerm.length > 0 && (
- <StyledClearButton onClick={onClear}>
- <StyledClearIcon source="icon-close" width={18} tintColor={colors.white40} />
- </StyledClearButton>
- )}
- </StyledSearchContainer>
- );
-}
diff --git a/gui/src/renderer/components/SecuredLabel.tsx b/gui/src/renderer/components/SecuredLabel.tsx
deleted file mode 100644
index 534a01a58c..0000000000
--- a/gui/src/renderer/components/SecuredLabel.tsx
+++ /dev/null
@@ -1,80 +0,0 @@
-import styled from 'styled-components';
-
-import { colors } from '../../config.json';
-import { messages } from '../../shared/gettext';
-
-export enum SecuredDisplayStyle {
- secured,
- securedPq,
- blocked,
- securing,
- securingPq,
- unsecured,
- unsecuring,
- failedToSecure,
-}
-
-const securedDisplayStyleColorMap = {
- [SecuredDisplayStyle.securing]: colors.white,
- [SecuredDisplayStyle.securingPq]: colors.white,
- [SecuredDisplayStyle.unsecuring]: colors.white,
- [SecuredDisplayStyle.secured]: colors.green,
- [SecuredDisplayStyle.securedPq]: colors.green,
- [SecuredDisplayStyle.blocked]: colors.white,
- [SecuredDisplayStyle.unsecured]: colors.red,
- [SecuredDisplayStyle.failedToSecure]: colors.red,
-};
-
-const StyledSecuredLabel = styled.span<{ $displayStyle: SecuredDisplayStyle }>((props) => ({
- display: 'inline-block',
- minHeight: '22px',
- color: securedDisplayStyleColorMap[props.$displayStyle],
-}));
-
-interface ISecuredLabelProps {
- displayStyle: SecuredDisplayStyle;
- className?: string;
-}
-
-export default function SecuredLabel(props: ISecuredLabelProps) {
- const { displayStyle, ...otherProps } = props;
- return (
- <StyledSecuredLabel
- $displayStyle={displayStyle}
- {...otherProps}
- role="status"
- aria-live="polite">
- {getLabelText(props.displayStyle)}
- </StyledSecuredLabel>
- );
-}
-
-function getLabelText(displayStyle: SecuredDisplayStyle) {
- switch (displayStyle) {
- case SecuredDisplayStyle.secured:
- return messages.gettext('SECURE CONNECTION');
-
- case SecuredDisplayStyle.securedPq:
- // TRANSLATORS: The connection is secure and isn't breakable by quantum computers.
- return messages.gettext('QUANTUM SECURE CONNECTION');
-
- case SecuredDisplayStyle.blocked:
- return messages.gettext('BLOCKED CONNECTION');
-
- case SecuredDisplayStyle.securing:
- return messages.gettext('CREATING SECURE CONNECTION');
-
- case SecuredDisplayStyle.securingPq:
- // TRANSLATORS: Creating a secure connection that isn't breakable by quantum computers.
- return messages.gettext('CREATING QUANTUM SECURE CONNECTION');
-
- case SecuredDisplayStyle.unsecured:
- return messages.gettext('UNSECURED CONNECTION');
-
- case SecuredDisplayStyle.unsecuring:
- return '';
-
- case SecuredDisplayStyle.failedToSecure:
- return messages.gettext('FAILED TO SECURE CONNECTION');
- }
-}
diff --git a/gui/src/renderer/components/SelectLanguage.tsx b/gui/src/renderer/components/SelectLanguage.tsx
deleted file mode 100644
index 9bbc110fc1..0000000000
--- a/gui/src/renderer/components/SelectLanguage.tsx
+++ /dev/null
@@ -1,103 +0,0 @@
-import { useCallback, useEffect, useMemo, useRef } from 'react';
-import styled from 'styled-components';
-
-import { useAppContext } from '../../renderer/context';
-import { messages } from '../../shared/gettext';
-import { useHistory } from '../lib/history';
-import { useSelector } from '../redux/store';
-import { AriaInputGroup } from './AriaGroup';
-import Selector, { SelectorItem } from './cell/Selector';
-import { CustomScrollbarsRef } from './CustomScrollbars';
-import { BackAction } from './KeyboardNavigation';
-import { Layout, SettingsContainer } from './Layout';
-import {
- NavigationBar,
- NavigationContainer,
- NavigationItems,
- NavigationScrollbars,
- TitleBarItem,
-} from './NavigationBar';
-import SettingsHeader, { HeaderTitle } from './SettingsHeader';
-
-const StyledSelector = styled(Selector)({
- marginBottom: 0,
-});
-
-export default function SelectLanguage() {
- const { pop } = useHistory();
- const { preferredLocale, preferredLocalesList, setPreferredLocale } = usePreferredLocale();
- const scrollView = useRef<CustomScrollbarsRef>(null);
- const selectedCellRef = useRef<HTMLButtonElement>(null);
-
- const selectLocale = useCallback(
- async (locale: string) => {
- await setPreferredLocale(locale);
- pop();
- },
- [pop, setPreferredLocale],
- );
-
- const scrollToSelectedCell = () => {
- const ref = selectedCellRef.current;
- const view = scrollView.current;
- if (view && ref) {
- if (ref instanceof HTMLElement) {
- view.scrollToElement(ref, 'middle');
- }
- }
- };
-
- useEffect(() => {
- scrollToSelectedCell();
- }, []);
-
- return (
- <BackAction action={pop}>
- <Layout>
- <SettingsContainer>
- <NavigationContainer>
- <NavigationBar>
- <NavigationItems>
- <TitleBarItem>
- {
- // TRANSLATORS: Title label in navigation bar
- messages.pgettext('select-language-nav', 'Select language')
- }
- </TitleBarItem>
- </NavigationItems>
- </NavigationBar>
-
- <NavigationScrollbars ref={scrollView}>
- <SettingsHeader>
- <HeaderTitle>
- {messages.pgettext('select-language-nav', 'Select language')}
- </HeaderTitle>
- </SettingsHeader>
- <AriaInputGroup>
- <StyledSelector
- title=""
- value={preferredLocale}
- items={preferredLocalesList}
- onSelect={selectLocale}
- selectedCellRef={selectedCellRef}
- />
- </AriaInputGroup>
- </NavigationScrollbars>
- </NavigationContainer>
- </SettingsContainer>
- </Layout>
- </BackAction>
- );
-}
-
-function usePreferredLocale() {
- const preferredLocale = useSelector((state) => state.settings.guiSettings.preferredLocale);
-
- const { getPreferredLocaleList, setPreferredLocale } = useAppContext();
-
- const preferredLocalesList: SelectorItem<string>[] = useMemo(() => {
- return [...getPreferredLocaleList().map(({ name, code }) => ({ label: name, value: code }))];
- }, [getPreferredLocaleList]);
-
- return { preferredLocale, preferredLocalesList, setPreferredLocale };
-}
diff --git a/gui/src/renderer/components/Settings.tsx b/gui/src/renderer/components/Settings.tsx
deleted file mode 100644
index 0a691d340f..0000000000
--- a/gui/src/renderer/components/Settings.tsx
+++ /dev/null
@@ -1,290 +0,0 @@
-import { useCallback } from 'react';
-
-import { colors, strings } from '../../config.json';
-import { messages } from '../../shared/gettext';
-import { getDownloadUrl } from '../../shared/version';
-import { useAppContext } from '../context';
-import { useHistory } from '../lib/history';
-import { RoutePath } from '../lib/routes';
-import { useSelector } from '../redux/store';
-import { AriaDescribed, AriaDescription, AriaDescriptionGroup } from './AriaGroup';
-import * as Cell from './cell';
-import { BackAction } from './KeyboardNavigation';
-import { Layout, SettingsContainer } from './Layout';
-import { NavigationBar, NavigationContainer, NavigationItems, TitleBarItem } from './NavigationBar';
-import SettingsHeader, { HeaderTitle } from './SettingsHeader';
-import {
- StyledCellIcon,
- StyledContent,
- StyledNavigationScrollbars,
- StyledQuitButton,
- StyledSettingsContent,
-} from './SettingsStyles';
-
-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>
- <NavigationBar>
- <NavigationItems>
- <TitleBarItem>
- {
- // TRANSLATORS: Title label in navigation bar
- messages.pgettext('navigation-bar', 'Settings')
- }
- </TitleBarItem>
- </NavigationItems>
- </NavigationBar>
-
- <StyledNavigationScrollbars fillContainer>
- <StyledContent>
- <SettingsHeader>
- <HeaderTitle>{messages.pgettext('navigation-bar', 'Settings')}</HeaderTitle>
- </SettingsHeader>
-
- <StyledSettingsContent>
- {showSubSettings ? (
- <>
- <Cell.Group>
- <UserInterfaceSettingsButton />
- <MultihopButton />
- <DaitaButton />
- <VpnSettingsButton />
- </Cell.Group>
-
- {showSplitTunneling && (
- <Cell.Group>
- <SplitTunnelingButton />
- </Cell.Group>
- )}
- </>
- ) : (
- <Cell.Group>
- <UserInterfaceSettingsButton />
- </Cell.Group>
- )}
-
- <Cell.Group>
- <ApiAccessMethodsButton />
- </Cell.Group>
-
- <Cell.Group>
- <SupportButton />
- <AppVersionButton />
- </Cell.Group>
-
- {window.env.development && (
- <Cell.Group>
- <DebugButton />
- </Cell.Group>
- )}
- </StyledSettingsContent>
- </StyledContent>
-
- <QuitButton />
- </StyledNavigationScrollbars>
- </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 AppVersionButton() {
- 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 icon;
- let footer;
- if (!consistentVersion || !upToDateVersion) {
- const inconsistentVersionMessage = messages.pgettext(
- 'settings-view',
- 'App is out of sync. Please quit and restart.',
- );
-
- const updateAvailableMessage = messages.pgettext(
- 'settings-view',
- 'Update available. Install the latest app version to stay up to date.',
- );
-
- const message = !consistentVersion ? inconsistentVersionMessage : updateAvailableMessage;
-
- icon = <StyledCellIcon source="icon-alert" width={18} tintColor={colors.red} />;
- footer = (
- <Cell.CellFooter>
- <Cell.CellFooterText>{message}</Cell.CellFooterText>
- </Cell.CellFooter>
- );
- }
-
- return (
- <AriaDescriptionGroup>
- <AriaDescribed>
- <Cell.CellButton disabled={isOffline} onClick={openDownloadLink}>
- {icon}
- <Cell.Label>{messages.pgettext('settings-view', 'App version')}</Cell.Label>
- <Cell.SubText>{appVersion}</Cell.SubText>
- <AriaDescription>
- <Cell.Icon
- height={16}
- width={16}
- source="icon-extLink"
- aria-label={messages.pgettext('accessibility', 'Opens externally')}
- />
- </AriaDescription>
- </Cell.CellButton>
- </AriaDescribed>
- {footer}
- </AriaDescriptionGroup>
- );
-}
-
-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 (
- <StyledQuitButton onClick={quit}>
- {tunnelState.state === 'disconnected'
- ? messages.gettext('Quit')
- : messages.gettext('Disconnect & quit')}
- </StyledQuitButton>
- );
-}
diff --git a/gui/src/renderer/components/SettingsHeader.tsx b/gui/src/renderer/components/SettingsHeader.tsx
deleted file mode 100644
index 47e1f47f7b..0000000000
--- a/gui/src/renderer/components/SettingsHeader.tsx
+++ /dev/null
@@ -1,44 +0,0 @@
-import * as React from 'react';
-import styled from 'styled-components';
-
-import { colors } from '../../config.json';
-import { hugeText, measurements, tinyText } from './common-styles';
-
-export const Container = styled.div({
- flex: 0,
- paddingTop: '2px',
- paddingLeft: measurements.viewMargin,
- paddingRight: measurements.viewMargin,
- paddingBottom: measurements.rowVerticalMargin,
-});
-
-export const ContentWrapper = styled.div({
- '&&:not(:first-child)': {
- paddingTop: '8px',
- },
-});
-
-export const HeaderTitle = styled.span(hugeText, {
- wordWrap: 'break-word',
- hyphens: 'auto',
-});
-export const HeaderSubTitle = styled.span(tinyText, {
- color: colors.white60,
-});
-
-interface ISettingsHeaderProps {
- children?: React.ReactNode;
- className?: string;
-}
-
-function SettingsHeader(props: ISettingsHeaderProps, forwardRef: React.Ref<HTMLDivElement>) {
- return (
- <Container ref={forwardRef} className={props.className}>
- {React.Children.map(props.children, (child) => {
- return React.isValidElement(child) ? <ContentWrapper>{child}</ContentWrapper> : undefined;
- })}
- </Container>
- );
-}
-
-export default React.forwardRef(SettingsHeader);
diff --git a/gui/src/renderer/components/SettingsImport.tsx b/gui/src/renderer/components/SettingsImport.tsx
deleted file mode 100644
index 8fefbedf20..0000000000
--- a/gui/src/renderer/components/SettingsImport.tsx
+++ /dev/null
@@ -1,308 +0,0 @@
-import { useCallback, useEffect, useState } from 'react';
-import { sprintf } from 'sprintf-js';
-import styled from 'styled-components';
-
-import { colors } from '../../config.json';
-import { messages } from '../../shared/gettext';
-import { useScheduler } from '../../shared/scheduler';
-import { useAppContext } from '../context';
-import useActions from '../lib/actionsHook';
-import { transitions, 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';
-import { measurements, normalText } from './common-styles';
-import { tinyText } from './common-styles';
-import ImageView from './ImageView';
-import { BackAction } from './KeyboardNavigation';
-import { Footer, Layout, SettingsContainer } from './Layout';
-import { ModalAlert, ModalAlertType } from './Modal';
-import {
- NavigationBar,
- NavigationInfoButton,
- NavigationItems,
- TitleBarItem,
-} from './NavigationBar';
-import SettingsHeader, { HeaderSubTitle, HeaderTitle } from './SettingsHeader';
-import { SmallButton, SmallButtonGrid } from './SmallButton';
-import { SmallButtonColor } from './SmallButton';
-
-const ContentContainer = styled.div({
- display: 'flex',
- flexDirection: 'column',
- flex: 1,
-});
-
-const Content = styled.div({
- display: 'flex',
- flexDirection: 'column',
- flex: 1,
-});
-
-const StyledSmallButtonGrid = styled(SmallButtonGrid)({
- margin: `0 ${measurements.viewMargin}`,
-});
-
-type ImportStatus = { successful: boolean } & ({ type: 'file'; name: string } | { type: 'text' });
-
-export default function SettingsImport() {
- const history = useHistory();
- const {
- clearAllRelayOverrides,
- importSettingsFile,
- importSettingsText,
- showOpenDialog,
- getPathBaseName,
- } = useAppContext();
- const { clearSettingsImportForm, unsetSubmitSettingsImportForm } =
- useActions(settingsImportActions);
-
- // Status of the text form which is used to for example submit it.
- const textForm = useSelector((state) => state.settingsImport);
-
- // "Clear" button will be disabled if there are no imported overrides.
- const activeOverrides = useSelector((state) => state.settings.relayOverrides.length > 0);
-
- const [clearDialogVisible, showClearDialog, hideClearDialog] = useBoolean();
-
- // Keeps the status of the last import and is cleared 10 seconds after being set.
- const [importStatus, setImportStatusImpl] = useState<ImportStatus>();
- const importStatusResetScheduler = useScheduler();
-
- const setImportStatus = useCallback(
- (status?: ImportStatus) => {
- // Cancel scheduled status clearing.
- importStatusResetScheduler.cancel();
- setImportStatusImpl(status);
-
- // The status text should be cleared after 10 seconds.
- if (status !== undefined) {
- importStatusResetScheduler.schedule(() => setImportStatusImpl(undefined), 10_000);
- }
- },
- [importStatusResetScheduler],
- );
-
- const confirmClear = useCallback(() => {
- hideClearDialog();
- void clearAllRelayOverrides();
- setImportStatus(undefined);
- }, [clearAllRelayOverrides, hideClearDialog, setImportStatus]);
-
- const navigateTextImport = useCallback(() => {
- history.push(RoutePath.settingsTextImport, { transition: transitions.show });
- }, [history]);
-
- const importFile = useCallback(async () => {
- const file = await showOpenDialog({
- properties: ['openFile'],
- buttonLabel: messages.gettext('Import'),
- filters: [{ name: 'Mullvad settings file', extensions: ['json'] }],
- });
- const path = file.filePaths[0];
- const name = await getPathBaseName(path);
- try {
- await importSettingsFile(path);
- setImportStatus({ successful: true, type: 'file', name });
- } catch {
- setImportStatus({ successful: false, type: 'file', name });
- }
- }, [getPathBaseName, importSettingsFile, setImportStatus, showOpenDialog]);
-
- const onMount = useEffectEvent(async () => {
- if (history.action === 'POP' && textForm.submit && textForm.value !== '') {
- try {
- await importSettingsText(textForm.value);
- setImportStatus({ successful: true, type: 'text' });
- clearSettingsImportForm();
- } catch {
- setImportStatus({ successful: false, type: 'text' });
- unsetSubmitSettingsImportForm();
- }
- }
- });
-
- useEffect(() => void onMount(), []);
-
- return (
- <BackAction action={history.pop}>
- <Layout>
- <SettingsContainer>
- <NavigationBar>
- <NavigationItems>
- <TitleBarItem>
- {
- // TRANSLATORS: Title label in navigation bar. This is for a feature that lets
- // TRANSLATORS: users import server IP settings.
- messages.pgettext('settings-import', 'Server IP override')
- }
- </TitleBarItem>
- <NavigationInfoButton
- title={messages.pgettext('settings-import', 'Server IP override')}
- message={[
- messages.pgettext(
- 'settings-import',
- 'On some networks, where various types of censorship are being used, our server IP addresses are sometimes blocked.',
- ),
- messages.pgettext(
- 'settings-import',
- 'To circumvent this you can import a file or a text, provided by our support team, with new IP addresses that override the default addresses of the servers in the Select location view.',
- ),
- messages.pgettext(
- 'settings-import',
- 'If you are having issues connecting to VPN servers, please contact support.',
- ),
- ]}
- />
- </NavigationItems>
- </NavigationBar>
-
- <ContentContainer>
- <SettingsHeader>
- <HeaderTitle>
- {messages.pgettext('settings-import', 'Server IP override')}
- </HeaderTitle>
- <HeaderSubTitle>
- {messages.pgettext(
- 'settings-import',
- 'Import files or text with new IP addresses for the servers in the Select location view.',
- )}
- </HeaderSubTitle>
- </SettingsHeader>
-
- <Content>
- <StyledSmallButtonGrid>
- <SmallButton onClick={navigateTextImport}>
- {messages.pgettext('settings-import', 'Import via text')}
- </SmallButton>
- <SmallButton onClick={importFile}>
- {messages.pgettext('settings-import', 'Import file')}
- </SmallButton>
- </StyledSmallButtonGrid>
-
- <SettingsImportStatus status={importStatus} />
- </Content>
-
- <Footer>
- <SmallButton
- onClick={showClearDialog}
- color={SmallButtonColor.red}
- disabled={!activeOverrides}>
- {messages.pgettext('settings-import', 'Clear all overrides')}
- </SmallButton>
- </Footer>
-
- <ModalAlert
- isOpen={clearDialogVisible}
- type={ModalAlertType.warning}
- gridButtons={[
- <SmallButton key="cancel" onClick={hideClearDialog}>
- {messages.gettext('Cancel')}
- </SmallButton>,
- <SmallButton key="confirm" onClick={confirmClear} color={SmallButtonColor.red}>
- {messages.gettext('Clear')}
- </SmallButton>,
- ]}
- close={hideClearDialog}
- title={messages.pgettext('settings-import', 'Clear all overrides?')}
- message={messages.pgettext(
- 'settings-import',
- 'Clearing the imported overrides changes the server IPs, in the Select location view, back to default.',
- )}
- />
- </ContentContainer>
- </SettingsContainer>
- </Layout>
- </BackAction>
- );
-}
-
-const StyledStatusContainer = styled.div({
- display: 'flex',
- flexDirection: 'column',
- margin: `18px ${measurements.viewMargin}`,
-});
-
-const StyledStatusTitle = styled.div(normalText, {
- display: 'flex',
- alignItems: 'center',
- fontWeight: 'bold',
- lineHeight: '20px',
- color: colors.white,
-});
-
-const StyledStatusImage = styled(ImageView)({
- margin: '5px',
-});
-
-const StyledStatusSubTitle = styled.div(tinyText, {
- color: colors.white60,
-});
-
-interface ImportStatusProps {
- status?: ImportStatus;
-}
-
-// This component renders the status title, subtitle and icon depending on active overrides and
-// import result.
-function SettingsImportStatus(props: ImportStatusProps) {
- const activeOverrides = useSelector((state) => state.settings.relayOverrides.length > 0);
-
- let title;
- if (props.status?.successful) {
- title = messages.pgettext('settings-import', 'IMPORT SUCCESSFUL');
- } else if (activeOverrides && props.status?.successful !== false) {
- title = messages.pgettext('settings-import', 'OVERRIDES ACTIVE');
- } else {
- title = messages.pgettext('settings-import', 'NO OVERRIDES IMPORTED');
- }
-
- let icon = undefined;
- let subtitle;
- if (props.status !== undefined) {
- icon = props.status.successful ? 'icon-checkmark' : 'icon-cross';
-
- if (props.status.successful) {
- subtitle =
- props.status.type === 'file'
- ? sprintf(
- messages.pgettext(
- 'settings-import',
- 'Import of file %(fileName)s was successful, overrides are now active.',
- ),
- { fileName: props.status.name },
- )
- : messages.pgettext(
- 'settings-import',
- 'Import of text was successful, overrides are now active.',
- );
- } else {
- subtitle =
- props.status.type === 'file'
- ? sprintf(
- messages.pgettext(
- 'settings-import',
- 'Import of file %(fileName)s was unsuccessful, please try again.',
- ),
- { fileName: props.status.name },
- )
- : messages.pgettext(
- 'settings-import',
- 'Import of text was unsuccessful, please try again.',
- );
- }
- }
-
- return (
- <StyledStatusContainer>
- <StyledStatusTitle data-testid="status-title">
- {title}
- {icon !== undefined && <StyledStatusImage source={icon} width={13} />}
- </StyledStatusTitle>
- {subtitle !== undefined && (
- <StyledStatusSubTitle data-testid="status-subtitle">{subtitle}</StyledStatusSubTitle>
- )}
- </StyledStatusContainer>
- );
-}
diff --git a/gui/src/renderer/components/SettingsStyles.tsx b/gui/src/renderer/components/SettingsStyles.tsx
deleted file mode 100644
index 7a3d2c04ae..0000000000
--- a/gui/src/renderer/components/SettingsStyles.tsx
+++ /dev/null
@@ -1,31 +0,0 @@
-import styled from 'styled-components';
-
-import * as AppButton from './AppButton';
-import * as Cell from './cell';
-import { measurements } from './common-styles';
-import { NavigationScrollbars } from './NavigationBar';
-
-export const StyledCellIcon = styled(Cell.UntintedIcon)({
- marginRight: '8px',
-});
-
-export const StyledNavigationScrollbars = styled(NavigationScrollbars)({
- flex: 1,
-});
-
-export const StyledContent = styled.div({
- display: 'flex',
- flexDirection: 'column',
- flex: 1,
- overflow: 'visible',
-});
-
-export const StyledSettingsContent = styled.div({
- display: 'flex',
- flexDirection: 'column',
-});
-
-export const StyledQuitButton = styled(AppButton.RedButton)({
- margin: measurements.viewMargin,
- marginTop: measurements.rowVerticalMargin,
-});
diff --git a/gui/src/renderer/components/SettingsTextImport.tsx b/gui/src/renderer/components/SettingsTextImport.tsx
deleted file mode 100644
index c6cb325b0c..0000000000
--- a/gui/src/renderer/components/SettingsTextImport.tsx
+++ /dev/null
@@ -1,82 +0,0 @@
-import { useCallback } from 'react';
-import styled from 'styled-components';
-
-import { colors } from '../../config.json';
-import { messages } from '../../shared/gettext';
-import useActions from '../lib/actionsHook';
-import { useHistory } from '../lib/history';
-import { useCombinedRefs, useRefCallback, useStyledRef } from '../lib/utility-hooks';
-import settingsImportActions from '../redux/settings-import/actions';
-import { useSelector } from '../redux/store';
-import ImageView from './ImageView';
-import { BackAction } from './KeyboardNavigation';
-import { Layout, SettingsContainer } from './Layout';
-import { NavigationBar, NavigationBarButton, NavigationItems, TitleBarItem } from './NavigationBar';
-
-const StyledTextArea = styled.textarea({
- width: '100%',
- flex: 1,
- padding: '13px',
- color: colors.blue,
-});
-
-export default function SettingsTextImport() {
- const { pop } = useHistory();
-
- const { saveSettingsImportForm } = useActions(settingsImportActions);
- // The textarea value is saved in redux to make it persistent when leaving the view.
- const initialValue = useSelector((state) => state.settingsImport.value);
-
- const textareaRef = useStyledRef<HTMLTextAreaElement>();
- const onTextareaLoad = useRefCallback((element?: HTMLTextAreaElement) => {
- if (element) {
- element.value = initialValue;
- }
- });
-
- const combinedTextAreaRef = useCombinedRefs(textareaRef, onTextareaLoad);
-
- const save = useCallback(() => {
- if (textareaRef.current?.value) {
- saveSettingsImportForm(textareaRef.current.value, true);
- }
- pop();
- }, [pop, saveSettingsImportForm, textareaRef]);
-
- const back = useCallback(() => {
- if (textareaRef.current) {
- saveSettingsImportForm(textareaRef.current.value, false);
- }
- pop();
- }, [pop, saveSettingsImportForm, textareaRef]);
-
- return (
- <BackAction action={back}>
- <Layout>
- <SettingsContainer>
- <NavigationBar alwaysDisplayBarTitle>
- <NavigationItems>
- <TitleBarItem>
- {
- // TRANSLATORS: Title label in navigation bar
- messages.pgettext('settings-import', 'Import via text')
- }
- </TitleBarItem>
- <NavigationBarButton onClick={save} aria-label={messages.gettext('Save')}>
- <ImageView
- source="icon-check"
- tintColor={colors.white40}
- tintHoverColor={colors.white60}
- height={24}
- width={24}
- />
- </NavigationBarButton>
- </NavigationItems>
- </NavigationBar>
-
- <StyledTextArea ref={combinedTextAreaRef} />
- </SettingsContainer>
- </Layout>
- </BackAction>
- );
-}
diff --git a/gui/src/renderer/components/Shadowsocks.tsx b/gui/src/renderer/components/Shadowsocks.tsx
deleted file mode 100644
index 0614bc44cf..0000000000
--- a/gui/src/renderer/components/Shadowsocks.tsx
+++ /dev/null
@@ -1,137 +0,0 @@
-import { useCallback } from 'react';
-import { sprintf } from 'sprintf-js';
-import styled from 'styled-components';
-
-import { wrapConstraint } from '../../shared/daemon-rpc-types';
-import { messages } from '../../shared/gettext';
-import { removeNonNumericCharacters } from '../../shared/string-helpers';
-import { useAppContext } from '../context';
-import { useHistory } from '../lib/history';
-import { useSelector } from '../redux/store';
-import { AriaDescription, AriaInputGroup } from './AriaGroup';
-import * as Cell from './cell';
-import { SelectorItem, SelectorWithCustomItem } from './cell/Selector';
-import { BackAction } from './KeyboardNavigation';
-import { Layout, SettingsContainer } from './Layout';
-import {
- NavigationBar,
- NavigationContainer,
- NavigationItems,
- NavigationScrollbars,
- TitleBarItem,
-} from './NavigationBar';
-import SettingsHeader, { HeaderTitle } from './SettingsHeader';
-
-const PORTS: Array<SelectorItem<number>> = [];
-const ALLOWED_RANGE = [1, 65000];
-
-const StyledContent = styled.div({
- display: 'flex',
- flexDirection: 'column',
- flex: 1,
- marginBottom: '2px',
-});
-
-const StyledSelectorContainer = styled.div({
- flex: 0,
-});
-
-export default function Shadowsocks() {
- const { pop } = useHistory();
-
- return (
- <BackAction action={pop}>
- <Layout>
- <SettingsContainer>
- <NavigationContainer>
- <NavigationBar>
- <NavigationItems>
- <TitleBarItem>
- {
- // TRANSLATORS: Title label in navigation bar
- messages.pgettext('wireguard-settings-nav', 'Shadowsocks')
- }
- </TitleBarItem>
- </NavigationItems>
- </NavigationBar>
-
- <NavigationScrollbars>
- <SettingsHeader>
- <HeaderTitle>
- {messages.pgettext('wireguard-settings-view', 'Shadowsocks')}
- </HeaderTitle>
- </SettingsHeader>
-
- <StyledContent>
- <Cell.Group>
- <ShadowsocksPortSelector />
- </Cell.Group>
- </StyledContent>
- </NavigationScrollbars>
- </NavigationContainer>
- </SettingsContainer>
- </Layout>
- </BackAction>
- );
-}
-
-function ShadowsocksPortSelector() {
- const { setObfuscationSettings } = useAppContext();
- const obfuscationSettings = useSelector((state) => state.settings.obfuscationSettings);
-
- const port =
- obfuscationSettings.shadowsocksSettings.port === 'any'
- ? null
- : obfuscationSettings.shadowsocksSettings.port.only;
-
- const setShadowsocksPort = useCallback(
- async (port: number | null) => {
- await setObfuscationSettings({
- ...obfuscationSettings,
- shadowsocksSettings: {
- ...obfuscationSettings.shadowsocksSettings,
- port: wrapConstraint(port),
- },
- });
- },
- [setObfuscationSettings, obfuscationSettings],
- );
-
- const parseValue = useCallback((port: string) => parseInt(port), []);
-
- const validateValue = useCallback(
- (value: number) => value >= ALLOWED_RANGE[0] && value <= ALLOWED_RANGE[1],
- [],
- );
-
- return (
- <AriaInputGroup>
- <StyledSelectorContainer>
- <SelectorWithCustomItem
- // TRANSLATORS: The title for the WireGuard port selector.
- title={messages.pgettext('wireguard-settings-view', 'Port')}
- items={PORTS}
- value={port}
- onSelect={setShadowsocksPort}
- inputPlaceholder={messages.pgettext('wireguard-settings-view', 'Port')}
- automaticValue={null}
- parseValue={parseValue}
- modifyValue={removeNonNumericCharacters}
- validateValue={validateValue}
- maxLength={`${ALLOWED_RANGE[1]}`.length}
- />
- </StyledSelectorContainer>
- <Cell.CellFooter>
- <AriaDescription>
- <Cell.CellFooterText>
- {sprintf(
- // TRANSLATORS: Text describing the valid port range for a port selector.
- messages.pgettext('wireguard-settings-view', 'Valid range: %(min)s - %(max)s'),
- { min: ALLOWED_RANGE[0], max: ALLOWED_RANGE[1] },
- )}
- </Cell.CellFooterText>
- </AriaDescription>
- </Cell.CellFooter>
- </AriaInputGroup>
- );
-}
diff --git a/gui/src/renderer/components/SimpleInput.tsx b/gui/src/renderer/components/SimpleInput.tsx
deleted file mode 100644
index 8d4a51d8e9..0000000000
--- a/gui/src/renderer/components/SimpleInput.tsx
+++ /dev/null
@@ -1,81 +0,0 @@
-import { useCallback, useState } from 'react';
-import React from 'react';
-import styled from 'styled-components';
-
-import { useCombinedRefs } from '../lib/utility-hooks';
-import { normalText } from './common-styles';
-
-const StyledInput = styled.input.attrs({ type: 'text' })(normalText, {
- padding: '6px 8px',
- borderRadius: '4px',
- outline: 0,
- border: 0,
- lineHeight: '21px',
-});
-
-interface SimpleInputProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'type'> {
- onChangeValue?: (value: string) => void;
- onSubmitValue?: (value: string) => void;
-}
-
-function SimpleInput(props: SimpleInputProps, ref: React.Ref<HTMLInputElement>) {
- const {
- onChangeValue,
- onSubmitValue,
- onChange: propsOnChange,
- onSubmit: propsOnSubmit,
- onKeyPress: propsOnKeyPress,
- ...otherProps
- } = props;
- const [value, setValue] = useState((props.value as string) ?? '');
-
- const onChange = useCallback(
- (event: React.ChangeEvent<HTMLInputElement>) => {
- setValue(event.target.value);
- propsOnChange?.(event);
- onChangeValue?.(event.target.value);
- },
- [propsOnChange, onChangeValue],
- );
-
- const onSubmit = useCallback(
- (event: React.FormEvent<HTMLInputElement>) => {
- propsOnSubmit?.(event);
- onSubmitValue?.(value);
- },
- [propsOnSubmit, onSubmitValue, value],
- );
-
- const onKeyPress = useCallback(
- (event: React.KeyboardEvent<HTMLInputElement>) => {
- propsOnKeyPress?.(event);
- if (event.key === 'Enter') {
- onSubmitValue?.(value);
- }
- },
- [propsOnKeyPress, onSubmitValue, value],
- );
-
- const refCallback = useCallback(
- (element: HTMLInputElement | null) => {
- if (element && otherProps.autoFocus) {
- setTimeout(() => element.focus());
- }
- },
- [otherProps.autoFocus],
- );
-
- const combinedRef = useCombinedRefs(refCallback, ref);
-
- return (
- <StyledInput
- {...otherProps}
- ref={combinedRef}
- onChange={onChange}
- onSubmit={onSubmit}
- onKeyPress={onKeyPress}
- />
- );
-}
-
-export default React.forwardRef(SimpleInput);
diff --git a/gui/src/renderer/components/SmallButton.tsx b/gui/src/renderer/components/SmallButton.tsx
deleted file mode 100644
index c91fbbdb20..0000000000
--- a/gui/src/renderer/components/SmallButton.tsx
+++ /dev/null
@@ -1,124 +0,0 @@
-import React from 'react';
-import styled from 'styled-components';
-
-import { colors } from '../../config.json';
-import { smallText } from './common-styles';
-import { MultiButtonCompatibleProps } from './MultiButton';
-
-export enum SmallButtonColor {
- blue,
- red,
- green,
-}
-
-function getButtonColors(color?: SmallButtonColor, disabled?: boolean) {
- switch (color) {
- case SmallButtonColor.red:
- return {
- background: disabled ? colors.red60 : colors.red,
- backgroundHover: disabled ? colors.red60 : colors.red80,
- };
- case SmallButtonColor.green:
- return {
- background: disabled ? colors.green40 : colors.green,
- backgroundHover: disabled ? colors.green40 : colors.green90,
- };
- default:
- return {
- background: disabled ? colors.blue50 : colors.blue,
- backgroundHover: disabled ? colors.blue50 : colors.blue60,
- };
- }
-}
-
-const BUTTON_GROUP_GAP = 12;
-
-interface StyledSmallButtonProps {
- $color?: SmallButtonColor;
- disabled?: boolean;
-}
-
-const StyledSmallButton = styled.button<StyledSmallButtonProps>(smallText, (props) => {
- const buttonColors = getButtonColors(props.$color, props.disabled);
-
- return {
- display: 'flex',
- minHeight: '32px',
- padding: '5px 16px',
- border: 'none',
- background: buttonColors.background,
- color: props.disabled ? colors.white50 : colors.white,
- borderRadius: '4px',
- marginLeft: `${BUTTON_GROUP_GAP}px`,
- alignItems: 'center',
- justifyContent: 'center',
-
- '&&:not(& + &&)': {
- marginLeft: '0px',
- },
-
- [`${SmallButtonGroupStart} &&`]: {
- marginLeft: 0,
- marginRight: `${BUTTON_GROUP_GAP}px`,
- },
-
- [`${SmallButtonGrid} &&`]: {
- flex: '1 0 auto',
- marginLeft: 0,
- minWidth: `calc(50% - ${BUTTON_GROUP_GAP / 2}px)`,
- maxWidth: '100%',
- },
-
- '&&:hover': {
- background: buttonColors.backgroundHover,
- },
- };
-});
-
-const StyledContent = styled.span({
- flex: '1 0 fit-content',
-});
-
-const StyledTextOffset = styled.span<{ $width: number }>((props) => ({
- display: 'flex',
- flex: `0 1 ${props.$width}px`,
-}));
-
-interface SmallButtonProps
- extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, 'onClick' | 'color'>,
- MultiButtonCompatibleProps {
- onClick: () => void;
- children: React.ReactNode;
- color?: SmallButtonColor;
-}
-
-export function SmallButton(props: SmallButtonProps) {
- const { color, textOffset, children, ...otherProps } = props;
- return (
- <StyledSmallButton $color={props.color} {...otherProps}>
- {textOffset && textOffset > 0 ? <StyledTextOffset $width={Math.abs(textOffset)} /> : null}
- <StyledContent>{children}</StyledContent>
- {textOffset && textOffset < 0 ? <StyledTextOffset $width={Math.abs(textOffset)} /> : null}
- </StyledSmallButton>
- );
-}
-
-export const SmallButtonGroup = styled.div<{ $noMarginTop?: boolean }>((props) => ({
- display: 'flex',
- justifyContent: 'end',
- margin: '0 23px',
- marginTop: props.$noMarginTop ? 0 : '30px',
-}));
-
-export const SmallButtonGroupStart = styled(SmallButtonGroup)({
- flex: 1,
- justifyContent: 'start',
- margin: 0,
-});
-
-export const SmallButtonGrid = styled.div({
- display: 'flex',
- flexWrap: 'wrap',
- columnGap: `${BUTTON_GROUP_GAP}px`,
- rowGap: `${BUTTON_GROUP_GAP}px`,
-});
diff --git a/gui/src/renderer/components/SplitTunnelingSettings.tsx b/gui/src/renderer/components/SplitTunnelingSettings.tsx
deleted file mode 100644
index 7e8830e6f8..0000000000
--- a/gui/src/renderer/components/SplitTunnelingSettings.tsx
+++ /dev/null
@@ -1,660 +0,0 @@
-import React, { useCallback, useEffect, useMemo, useState } from 'react';
-import { useSelector } from 'react-redux';
-import { sprintf } from 'sprintf-js';
-
-import { colors, strings } from '../../config.json';
-import {
- IApplication,
- ILinuxSplitTunnelingApplication,
- ISplitTunnelingApplication,
-} from '../../shared/application-types';
-import { messages } from '../../shared/gettext';
-import { useAppContext } from '../context';
-import { useHistory } from '../lib/history';
-import { formatHtml } from '../lib/html-formatter';
-import { useEffectEvent, useStyledRef } from '../lib/utility-hooks';
-import { IReduxState } from '../redux/store';
-import Accordion from './Accordion';
-import * as AppButton from './AppButton';
-import * as Cell from './cell';
-import { CustomScrollbarsRef } from './CustomScrollbars';
-import ImageView from './ImageView';
-import { BackAction } from './KeyboardNavigation';
-import { Layout, SettingsContainer } from './Layout';
-import List from './List';
-import { ModalAlert, ModalAlertType } from './Modal';
-import { NavigationBar, NavigationContainer, NavigationItems, TitleBarItem } from './NavigationBar';
-import SettingsHeader, { HeaderSubTitle, HeaderTitle } from './SettingsHeader';
-import {
- StyledActionIcon,
- StyledBrowseButton,
- StyledCellButton,
- StyledCellLabel,
- StyledCellWarningIcon,
- StyledContent,
- StyledHeaderTitle,
- StyledHeaderTitleContainer,
- StyledIcon,
- StyledIconPlaceholder,
- StyledListContainer,
- StyledNavigationScrollbars,
- StyledNoResult,
- StyledNoResultText,
- StyledPageCover,
- StyledSearchBar,
- StyledSpinnerRow,
- StyledSystemSettingsButton,
-} from './SplitTunnelingSettingsStyles';
-import Switch from './Switch';
-
-export default function SplitTunneling() {
- const { pop } = useHistory();
- const [browsing, setBrowsing] = useState(false);
- const scrollbarsRef = useStyledRef<CustomScrollbarsRef>();
-
- const scrollToTop = useCallback(() => scrollbarsRef.current?.scrollToTop(true), [scrollbarsRef]);
-
- return (
- <>
- <StyledPageCover $show={browsing} />
- <BackAction action={pop}>
- <Layout>
- <SettingsContainer>
- <NavigationContainer>
- <NavigationBar>
- <NavigationItems>
- <TitleBarItem>{strings.splitTunneling}</TitleBarItem>
- </NavigationItems>
- </NavigationBar>
-
- <StyledNavigationScrollbars ref={scrollbarsRef}>
- <StyledContent>
- <PlatformSpecificSplitTunnelingSettings
- setBrowsing={setBrowsing}
- scrollToTop={scrollToTop}
- />
- </StyledContent>
- </StyledNavigationScrollbars>
- </NavigationContainer>
- </SettingsContainer>
- </Layout>
- </BackAction>
- </>
- );
-}
-
-interface IPlatformSplitTunnelingSettingsProps {
- setBrowsing: (value: boolean) => void;
- scrollToTop: () => void;
-}
-
-function PlatformSpecificSplitTunnelingSettings(props: IPlatformSplitTunnelingSettingsProps) {
- switch (window.env.platform) {
- case 'linux':
- return <LinuxSplitTunnelingSettings {...props} />;
- default:
- return <SplitTunnelingSettings {...props} />;
- }
-}
-
-function useFilePicker(
- buttonLabel: string,
- setOpen: (value: boolean) => void,
- select: (path: string) => void,
- filter?: { name: string; extensions: string[] },
-) {
- const { showOpenDialog } = useAppContext();
-
- return useCallback(async () => {
- setOpen(true);
- const file = await showOpenDialog({
- properties: ['openFile'],
- buttonLabel,
- filters: filter ? [filter] : undefined,
- });
- setOpen(false);
-
- if (file.filePaths[0]) {
- select(file.filePaths[0]);
- }
- }, [setOpen, showOpenDialog, buttonLabel, filter, select]);
-}
-
-function LinuxSplitTunnelingSettings(props: IPlatformSplitTunnelingSettingsProps) {
- const { getLinuxSplitTunnelingApplications, launchExcludedApplication } = useAppContext();
-
- const [searchTerm, setSearchTerm] = useState('');
- const [applications, setApplications] = useState<ILinuxSplitTunnelingApplication[]>();
- const [browseError, setBrowseError] = useState<string>();
-
- const updateApplications = useEffectEvent(async () => {
- const applications = await getLinuxSplitTunnelingApplications();
- setApplications(applications);
- });
-
- useEffect(() => void updateApplications(), []);
-
- const launchApplication = useCallback(
- async (application: ILinuxSplitTunnelingApplication | string) => {
- const result = await launchExcludedApplication(application);
- if ('error' in result) {
- setBrowseError(result.error);
- }
- },
- [launchExcludedApplication],
- );
-
- const launchWithFilePicker = useFilePicker(
- messages.pgettext('split-tunneling-view', 'Launch'),
- props.setBrowsing,
- launchApplication,
- );
-
- const filteredApplications = useMemo(
- () => applications?.filter((application) => includesSearchTerm(application, searchTerm)),
- [applications, searchTerm],
- );
-
- const hideBrowseFailureDialog = useCallback(() => setBrowseError(undefined), []);
-
- const rowRenderer = useCallback(
- (application: ILinuxSplitTunnelingApplication) => (
- <LinuxApplicationRow application={application} onSelect={launchApplication} />
- ),
- [launchApplication],
- );
-
- return (
- <>
- <SettingsHeader>
- <HeaderTitle>{strings.splitTunneling}</HeaderTitle>
- <HeaderSubTitle>
- {messages.pgettext(
- 'split-tunneling-view',
- 'Click on an app to launch it. Its traffic will bypass the VPN tunnel until you close it.',
- )}
- </HeaderSubTitle>
- </SettingsHeader>
-
- <StyledSearchBar searchTerm={searchTerm} onSearch={setSearchTerm} />
- {filteredApplications !== undefined && filteredApplications.length > 0 && (
- <ApplicationList applications={filteredApplications} rowRenderer={rowRenderer} />
- )}
-
- {searchTerm !== '' &&
- (filteredApplications === undefined || filteredApplications.length === 0) && (
- <StyledNoResult>
- <StyledNoResultText>
- {formatHtml(
- sprintf(messages.gettext('No result for <b>%(searchTerm)s</b>.'), { searchTerm }),
- )}
- </StyledNoResultText>
- <StyledNoResultText>{messages.gettext('Try a different search.')}</StyledNoResultText>
- </StyledNoResult>
- )}
-
- <StyledBrowseButton onClick={launchWithFilePicker}>
- {messages.pgettext('split-tunneling-view', 'Find another app')}
- </StyledBrowseButton>
-
- <ModalAlert
- isOpen={browseError !== undefined}
- type={ModalAlertType.warning}
- iconColor={colors.red}
- message={sprintf(
- // TRANSLATORS: Error message showed in a dialog when an application fails to launch.
- messages.pgettext(
- 'split-tunneling-view',
- 'Unable to launch selection. %(detailedErrorMessage)s',
- ),
- { detailedErrorMessage: browseError },
- )}
- buttons={[
- <AppButton.BlueButton key="close" onClick={hideBrowseFailureDialog}>
- {messages.gettext('Close')}
- </AppButton.BlueButton>,
- ]}
- close={hideBrowseFailureDialog}
- />
- </>
- );
-}
-
-interface ILinuxApplicationRowProps {
- application: ILinuxSplitTunnelingApplication;
- onSelect?: (application: ILinuxSplitTunnelingApplication) => void;
-}
-
-function LinuxApplicationRow(props: ILinuxApplicationRowProps) {
- const { onSelect } = props;
-
- const [showWarning, setShowWarning] = useState(false);
-
- const launch = useCallback(() => {
- setShowWarning(false);
- onSelect?.(props.application);
- }, [onSelect, props.application]);
-
- const showWarningDialog = useCallback(() => setShowWarning(true), []);
- const hideWarningDialog = useCallback(() => setShowWarning(false), []);
-
- const disabled = props.application.warning === 'launches-elsewhere';
- const warningColor = disabled ? colors.red : colors.yellow;
- const warningMessage = disabled
- ? sprintf(
- messages.pgettext(
- 'split-tunneling-view',
- '%(applicationName)s is problematic and can’t be excluded from the VPN tunnel.',
- ),
- {
- applicationName: props.application.name,
- },
- )
- : sprintf(
- messages.pgettext(
- 'split-tunneling-view',
- 'If it’s already running, close %(applicationName)s before launching it from here. Otherwise it might not be excluded from the VPN tunnel.',
- ),
- {
- applicationName: props.application.name,
- },
- );
- const warningDialogButtons = disabled
- ? [
- <AppButton.BlueButton key="cancel" onClick={hideWarningDialog}>
- {messages.gettext('Back')}
- </AppButton.BlueButton>,
- ]
- : [
- <AppButton.BlueButton key="launch" onClick={launch}>
- {messages.pgettext('split-tunneling-view', 'Launch')}
- </AppButton.BlueButton>,
- <AppButton.BlueButton key="cancel" onClick={hideWarningDialog}>
- {messages.gettext('Cancel')}
- </AppButton.BlueButton>,
- ];
-
- return (
- <>
- <StyledCellButton
- onClick={props.application.warning ? showWarningDialog : launch}
- $lookDisabled={disabled}>
- {props.application.icon ? (
- <StyledIcon
- source={props.application.icon}
- width={35}
- height={35}
- $lookDisabled={disabled}
- />
- ) : (
- <StyledIconPlaceholder />
- )}
- <StyledCellLabel $lookDisabled={disabled}>{props.application.name}</StyledCellLabel>
- {props.application.warning && (
- <StyledCellWarningIcon source="icon-alert" tintColor={warningColor} width={18} />
- )}
- </StyledCellButton>
- <ModalAlert
- isOpen={showWarning}
- type={ModalAlertType.warning}
- iconColor={warningColor}
- message={warningMessage}
- buttons={warningDialogButtons}
- close={hideWarningDialog}
- />
- </>
- );
-}
-
-export function SplitTunnelingSettings(props: IPlatformSplitTunnelingSettingsProps) {
- const { scrollToTop } = props;
-
- const {
- addSplitTunnelingApplication,
- removeSplitTunnelingApplication,
- forgetManuallyAddedSplitTunnelingApplication,
- getSplitTunnelingApplications,
- needFullDiskPermissions,
- setSplitTunnelingState,
- } = useAppContext();
- const splitTunnelingEnabledValue = useSelector(
- (state: IReduxState) => state.settings.splitTunneling,
- );
- const splitTunnelingApplications = useSelector(
- (state: IReduxState) => state.settings.splitTunnelingApplications,
- );
-
- const [searchTerm, setSearchTerm] = useState('');
- const [applications, setApplications] = useState<ISplitTunnelingApplication[]>();
-
- const [splitTunnelingAvailable, setSplitTunnelingAvailable] = useState(
- window.env.platform === 'darwin' ? undefined : true,
- );
-
- const splitTunnelingEnabled = splitTunnelingEnabledValue && (splitTunnelingAvailable ?? false);
-
- const fetchNeedFullDiskPermissions = useCallback(async () => {
- const needPermissions = await needFullDiskPermissions();
- setSplitTunnelingAvailable(!needPermissions);
- }, [needFullDiskPermissions]);
-
- useEffect((): void | (() => void) => {
- if (window.env.platform === 'darwin') {
- void fetchNeedFullDiskPermissions();
- }
- }, [fetchNeedFullDiskPermissions]);
-
- const onMount = useEffectEvent(async () => {
- const { fromCache, applications } = await getSplitTunnelingApplications();
- setApplications(applications);
-
- if (fromCache) {
- const { applications } = await getSplitTunnelingApplications(true);
- setApplications(applications);
- }
- });
-
- useEffect(() => void onMount(), []);
-
- const filteredSplitApplications = useMemo(
- () =>
- splitTunnelingApplications.filter((application) =>
- includesSearchTerm(application, searchTerm),
- ),
- [splitTunnelingApplications, searchTerm],
- );
-
- const filteredNonSplitApplications = useMemo(() => {
- return applications?.filter(
- (application) =>
- includesSearchTerm(application, searchTerm) &&
- !splitTunnelingApplications.some(
- (splitTunnelingApplication) =>
- application.absolutepath === splitTunnelingApplication.absolutepath,
- ),
- );
- }, [applications, splitTunnelingApplications, searchTerm]);
-
- const addApplication = useCallback(
- async (application: ISplitTunnelingApplication | string) => {
- if (!splitTunnelingEnabled) {
- await setSplitTunnelingState(true);
- }
- await addSplitTunnelingApplication(application);
- },
- [addSplitTunnelingApplication, splitTunnelingEnabled, setSplitTunnelingState],
- );
-
- const addBrowsedForApplication = useCallback(
- async (application: string) => {
- await addApplication(application);
- const { applications } = await getSplitTunnelingApplications();
- setApplications(applications);
- },
- [addApplication, getSplitTunnelingApplications],
- );
-
- const forgetManuallyAddedApplicationAndUpdate = useCallback(
- async (application: ISplitTunnelingApplication) => {
- await forgetManuallyAddedSplitTunnelingApplication(application);
- const { applications } = await getSplitTunnelingApplications();
- setApplications(applications);
- },
- [forgetManuallyAddedSplitTunnelingApplication, getSplitTunnelingApplications],
- );
-
- const removeApplication = useCallback(
- async (application: ISplitTunnelingApplication) => {
- if (!splitTunnelingEnabled) {
- await setSplitTunnelingState(true);
- }
- removeSplitTunnelingApplication(application);
- },
- [removeSplitTunnelingApplication, setSplitTunnelingState, splitTunnelingEnabled],
- );
-
- const filePickerCallback = useFilePicker(
- messages.pgettext('split-tunneling-view', 'Add'),
- props.setBrowsing,
- addBrowsedForApplication,
- getFilePickerOptionsForPlatform(),
- );
-
- const addWithFilePicker = useCallback(async () => {
- scrollToTop();
- await filePickerCallback();
- }, [filePickerCallback, scrollToTop]);
-
- const excludedRowRenderer = useCallback(
- (application: ISplitTunnelingApplication) => (
- <ApplicationRow application={application} onRemove={removeApplication} />
- ),
- [removeApplication],
- );
-
- const includedRowRenderer = useCallback(
- (application: ISplitTunnelingApplication) => {
- const onForget = application.deletable ? forgetManuallyAddedApplicationAndUpdate : undefined;
- return (
- <ApplicationRow application={application} onAdd={addApplication} onDelete={onForget} />
- );
- },
- [addApplication, forgetManuallyAddedApplicationAndUpdate],
- );
-
- const showSplitSection = splitTunnelingEnabled && filteredSplitApplications.length > 0;
- const showNonSplitSection =
- splitTunnelingEnabled &&
- (!filteredNonSplitApplications || filteredNonSplitApplications.length > 0);
-
- const excludedTitle = (
- <Cell.SectionTitle>
- {messages.pgettext('split-tunneling-view', 'Excluded apps')}
- </Cell.SectionTitle>
- );
-
- const allTitle = (
- <Cell.SectionTitle>{messages.pgettext('split-tunneling-view', 'All apps')}</Cell.SectionTitle>
- );
-
- return (
- <>
- <SettingsHeader>
- <StyledHeaderTitleContainer>
- <StyledHeaderTitle>{strings.splitTunneling}</StyledHeaderTitle>
- <Switch
- isOn={splitTunnelingEnabled}
- disabled={!splitTunnelingAvailable}
- onChange={setSplitTunnelingState}
- />
- </StyledHeaderTitleContainer>
- <MacOsSplitTunnelingAvailability
- needFullDiskPermissions={
- window.env.platform === 'darwin' && splitTunnelingAvailable === false
- }
- />
- {splitTunnelingAvailable ? (
- <HeaderSubTitle>
- {messages.pgettext(
- 'split-tunneling-view',
- 'Choose the apps you want to exclude from the VPN tunnel.',
- )}
- </HeaderSubTitle>
- ) : null}
- </SettingsHeader>
-
- {splitTunnelingEnabled && (
- <StyledSearchBar searchTerm={searchTerm} onSearch={setSearchTerm} />
- )}
-
- <Accordion expanded={showSplitSection}>
- <Cell.Section sectionTitle={excludedTitle}>
- <ApplicationList
- data-testid="split-applications"
- applications={filteredSplitApplications}
- rowRenderer={excludedRowRenderer}
- />
- </Cell.Section>
- </Accordion>
-
- <Accordion expanded={showNonSplitSection}>
- <Cell.Section sectionTitle={allTitle}>
- <ApplicationList
- data-testid="non-split-applications"
- applications={filteredNonSplitApplications}
- rowRenderer={includedRowRenderer}
- />
- </Cell.Section>
- </Accordion>
-
- {splitTunnelingEnabled && searchTerm !== '' && !showSplitSection && !showNonSplitSection && (
- <StyledNoResult>
- <StyledNoResultText>
- {formatHtml(
- sprintf(messages.gettext('No result for <b>%(searchTerm)s</b>.'), { searchTerm }),
- )}
- </StyledNoResultText>
- <StyledNoResultText>{messages.gettext('Try a different search.')}</StyledNoResultText>
- </StyledNoResult>
- )}
-
- {splitTunnelingEnabled && (
- <StyledBrowseButton onClick={addWithFilePicker}>
- {messages.pgettext('split-tunneling-view', 'Find another app')}
- </StyledBrowseButton>
- )}
- </>
- );
-}
-
-interface MacOsSplitTunnelingAvailabilityProps {
- needFullDiskPermissions: boolean;
-}
-
-function MacOsSplitTunnelingAvailability({
- needFullDiskPermissions,
-}: MacOsSplitTunnelingAvailabilityProps) {
- const { showFullDiskAccessSettings } = useAppContext();
-
- return (
- <>
- {needFullDiskPermissions === true ? (
- <>
- <HeaderSubTitle>
- {messages.pgettext(
- 'split-tunneling-view',
- 'To use split tunneling please enable “Full disk access” for “Mullvad VPN” in the macOS system settings.',
- )}
- </HeaderSubTitle>
- <StyledSystemSettingsButton onClick={showFullDiskAccessSettings}>
- Open System Settings
- </StyledSystemSettingsButton>
- </>
- ) : null}
- </>
- );
-}
-
-interface IApplicationListProps<T extends IApplication> {
- applications: T[] | undefined;
- rowRenderer: (application: T) => React.ReactElement;
- 'data-testid'?: string;
-}
-
-function ApplicationList<T extends IApplication>(props: IApplicationListProps<T>) {
- if (props.applications === undefined) {
- return (
- <StyledSpinnerRow>
- <ImageView source="icon-spinner" height={60} width={60} />
- </StyledSpinnerRow>
- );
- } else {
- return (
- <StyledListContainer data-testid={props['data-testid']}>
- <List
- data-testid={props['data-testid']}
- items={props.applications.sort((a, b) => a.name.localeCompare(b.name))}
- getKey={applicationGetKey}>
- {props.rowRenderer}
- </List>
- </StyledListContainer>
- );
- }
-}
-
-function applicationGetKey<T extends IApplication>(application: T): string {
- return application.absolutepath;
-}
-
-interface IApplicationRowProps {
- application: ISplitTunnelingApplication;
- onAdd?: (application: ISplitTunnelingApplication) => void;
- onRemove?: (application: ISplitTunnelingApplication) => void;
- onDelete?: (application: ISplitTunnelingApplication) => void;
-}
-
-function ApplicationRow(props: IApplicationRowProps) {
- const { onAdd: propsOnAdd, onRemove: propsOnRemove, onDelete: propsOnDelete } = props;
-
- const onAdd = useCallback(() => {
- propsOnAdd?.(props.application);
- }, [propsOnAdd, props.application]);
-
- const onRemove = useCallback(() => {
- propsOnRemove?.(props.application);
- }, [propsOnRemove, props.application]);
-
- const onDelete = useCallback(() => {
- propsOnDelete?.(props.application);
- }, [propsOnDelete, props.application]);
-
- return (
- <Cell.CellButton>
- {props.application.icon ? (
- <StyledIcon source={props.application.icon} width={35} height={35} />
- ) : (
- <StyledIconPlaceholder />
- )}
- <StyledCellLabel>{props.application.name}</StyledCellLabel>
- {props.onDelete && (
- <StyledActionIcon
- source="icon-close"
- width={18}
- onClick={onDelete}
- tintColor={colors.white40}
- tintHoverColor={colors.white60}
- />
- )}
- {props.onAdd && (
- <StyledActionIcon
- source="icon-add"
- width={18}
- onClick={onAdd}
- tintColor={colors.white40}
- tintHoverColor={colors.white60}
- />
- )}
- {props.onRemove && (
- <StyledActionIcon
- source="icon-remove"
- width={18}
- onClick={onRemove}
- tintColor={colors.white40}
- tintHoverColor={colors.white60}
- />
- )}
- </Cell.CellButton>
- );
-}
-
-function includesSearchTerm(application: IApplication, searchTerm: string) {
- return application.name.toLowerCase().includes(searchTerm.toLowerCase());
-}
-
-function getFilePickerOptionsForPlatform():
- | { name: string; extensions: Array<string> }
- | undefined {
- return window.env.platform === 'win32'
- ? { name: 'Executables', extensions: ['exe', 'lnk'] }
- : undefined;
-}
diff --git a/gui/src/renderer/components/SplitTunnelingSettingsStyles.tsx b/gui/src/renderer/components/SplitTunnelingSettingsStyles.tsx
deleted file mode 100644
index a2019fba8d..0000000000
--- a/gui/src/renderer/components/SplitTunnelingSettingsStyles.tsx
+++ /dev/null
@@ -1,130 +0,0 @@
-import styled from 'styled-components';
-
-import { colors } from '../../config.json';
-import * as AppButton from './AppButton';
-import * as Cell from './cell';
-import { measurements, normalText } from './common-styles';
-import ImageView from './ImageView';
-import { NavigationScrollbars } from './NavigationBar';
-import SearchBar from './SearchBar';
-import { HeaderTitle } from './SettingsHeader';
-import { SmallButton } from './SmallButton';
-
-export const StyledPageCover = styled.div<{ $show: boolean }>((props) => ({
- position: 'absolute',
- zIndex: 2,
- top: 0,
- left: 0,
- right: 0,
- bottom: 0,
- backgroundColor: colors.black,
- opacity: 0.5,
- display: props.$show ? 'block' : 'none',
-}));
-
-export const StyledNavigationScrollbars = styled(NavigationScrollbars)({
- flex: 1,
-});
-
-export const StyledContent = styled.div({
- display: 'flex',
- flexDirection: 'column',
- flex: 1,
-});
-
-export const StyledCellButton = styled(Cell.CellButton)<{ $lookDisabled?: boolean }>((props) => ({
- '&&:not(:disabled):hover': {
- backgroundColor: props.$lookDisabled ? colors.blue : undefined,
- },
-}));
-
-interface DisabledApplicationProps {
- $lookDisabled?: boolean;
-}
-
-const disabledApplication = (props: DisabledApplicationProps) => ({
- opacity: props.$lookDisabled ? 0.6 : undefined,
-});
-
-export const StyledIcon = styled(Cell.UntintedIcon)<DisabledApplicationProps>(disabledApplication, {
- marginRight: '12px',
-});
-
-export const StyledActionIcon = styled(ImageView)({
- marginLeft: '8px',
-});
-
-export const StyledCellWarningIcon = styled(Cell.Icon)({
- marginLeft: '9px',
- marginRight: '3px',
-});
-
-export const StyledCellLabel = styled(Cell.Label)<DisabledApplicationProps>(
- disabledApplication,
- normalText,
- {
- fontWeight: 400,
- wordWrap: 'break-word',
- overflow: 'hidden',
- },
-);
-
-export const StyledIconPlaceholder = styled.div({
- width: '35px',
- marginRight: '12px',
-});
-
-export const StyledSpinnerRow = styled(Cell.CellButton)({
- display: 'flex',
- alignItems: 'center',
- justifyContent: 'center',
- padding: '8px 0',
- marginBottom: measurements.rowVerticalMargin,
- background: colors.blue40,
-});
-
-export const StyledListContainer = styled.div({
- display: 'flex',
- flexDirection: 'column',
- marginBottom: measurements.rowVerticalMargin,
-});
-
-export const StyledBrowseButton = styled(AppButton.BlueButton)({
- margin: `0 ${measurements.viewMargin} ${measurements.viewMargin}`,
-});
-
-export const StyledCellContainer = styled(Cell.Container)({
- marginBottom: measurements.rowVerticalMargin,
-});
-
-export const StyledNoResult = styled(Cell.CellFooter)({
- display: 'flex',
- flexDirection: 'column',
- paddingTop: 0,
- marginTop: 0,
- marginBottom: '69px',
-});
-
-export const StyledNoResultText = styled(Cell.CellFooterText)({
- textAlign: 'center',
-});
-
-export const StyledHeaderTitleContainer = styled.div({
- display: 'flex',
- alignItems: 'center',
-});
-
-export const StyledHeaderTitle = styled(HeaderTitle)({
- flex: 1,
-});
-
-export const StyledSearchBar = styled(SearchBar)({
- marginLeft: measurements.viewMargin,
- marginRight: measurements.viewMargin,
- marginBottom: measurements.buttonVerticalMargin,
-});
-
-export const StyledSystemSettingsButton = styled(SmallButton)({
- width: '100%',
- marginTop: '24px',
-});
diff --git a/gui/src/renderer/components/Support.tsx b/gui/src/renderer/components/Support.tsx
deleted file mode 100644
index 2785883ecf..0000000000
--- a/gui/src/renderer/components/Support.tsx
+++ /dev/null
@@ -1,155 +0,0 @@
-import { useCallback } from 'react';
-import styled from 'styled-components';
-
-import { links } from '../../config.json';
-import { messages } from '../../shared/gettext';
-import { useAppContext } from '../context';
-import { useHistory } from '../lib/history';
-import { RoutePath } from '../lib/routes';
-import { useSelector } from '../redux/store';
-import {
- AriaDescribed,
- AriaDescription,
- AriaDescriptionGroup,
- AriaInput,
- AriaInputGroup,
- AriaLabel,
-} from './AriaGroup';
-import * as Cell from './cell';
-import { BackAction } from './KeyboardNavigation';
-import { Layout, SettingsContainer } from './Layout';
-import {
- NavigationBar,
- NavigationContainer,
- NavigationItems,
- NavigationScrollbars,
- TitleBarItem,
-} from './NavigationBar';
-import SettingsHeader, { HeaderTitle } from './SettingsHeader';
-
-const StyledContent = styled.div({
- display: 'flex',
- flexDirection: 'column',
- flex: 1,
- marginBottom: '2px',
-});
-
-export default function Support() {
- const { pop } = useHistory();
-
- return (
- <BackAction action={pop}>
- <Layout>
- <SettingsContainer>
- <NavigationContainer>
- <NavigationBar>
- <NavigationItems>
- <TitleBarItem>
- {
- // TRANSLATORS: Title label in navigation bar
- messages.pgettext('support-view', 'Support')
- }
- </TitleBarItem>
- </NavigationItems>
- </NavigationBar>
-
- <NavigationScrollbars>
- <SettingsHeader>
- <HeaderTitle>{messages.pgettext('support-view', 'Support')}</HeaderTitle>
- </SettingsHeader>
-
- <StyledContent>
- <Cell.Group>
- <ProblemReportButton />
- <FaqButton />
- </Cell.Group>
-
- <Cell.Group>
- <BetaProgramSetting />
- </Cell.Group>
- </StyledContent>
- </NavigationScrollbars>
- </NavigationContainer>
- </SettingsContainer>
- </Layout>
- </BackAction>
- );
-}
-
-function ProblemReportButton() {
- const history = useHistory();
- const clickHandler = useCallback(() => history.push(RoutePath.problemReport), [history]);
-
- // TRANSLATORS: Navigation button to the 'Report a problem' help view
- const label = messages.pgettext('support-view', 'Report a problem');
-
- return (
- <Cell.CellNavigationButton onClick={clickHandler}>
- <Cell.Label>{label}</Cell.Label>
- </Cell.CellNavigationButton>
- );
-}
-
-function FaqButton() {
- const isOffline = useSelector((state) => state.connection.isBlocked);
- const { openUrl } = useAppContext();
-
- const openFaq = useCallback(() => openUrl(links.faq), [openUrl]);
-
- return (
- <AriaDescriptionGroup>
- <AriaDescribed>
- <Cell.CellButton disabled={isOffline} onClick={openFaq}>
- <Cell.Label>
- {
- // TRANSLATORS: Link to the webpage
- messages.pgettext('support-view', 'FAQs & Guides')
- }
- </Cell.Label>
- <AriaDescription>
- <Cell.Icon
- height={16}
- width={16}
- source="icon-extLink"
- aria-label={messages.pgettext('accessibility', 'Opens externally')}
- />
- </AriaDescription>
- </Cell.CellButton>
- </AriaDescribed>
- </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/gui/src/renderer/components/Switch.tsx b/gui/src/renderer/components/Switch.tsx
deleted file mode 100644
index 595bc422a2..0000000000
--- a/gui/src/renderer/components/Switch.tsx
+++ /dev/null
@@ -1,72 +0,0 @@
-import React from 'react';
-import styled from 'styled-components';
-
-import { colors } from '../../config.json';
-
-interface IProps {
- id?: string;
- 'aria-labelledby'?: string;
- 'aria-describedby'?: string;
- isOn: boolean;
- onChange?: (isOn: boolean) => void;
- className?: string;
- disabled?: boolean;
- innerRef?: React.Ref<HTMLDivElement>;
-}
-
-const SwitchContainer = styled.div<{ disabled: boolean }>((props) => ({
- position: 'relative',
- width: '34px',
- height: '22px',
- borderColor: props.disabled ? colors.white20 : colors.white80,
- borderWidth: '2px',
- borderStyle: 'solid',
- borderRadius: '11px',
- padding: '1px',
-}));
-
-const Knob = styled.div<{ $isOn: boolean; disabled: boolean }>((props) => {
- let backgroundColor = props.$isOn ? colors.green : colors.red;
- if (props.disabled) {
- backgroundColor = props.$isOn ? colors.green40 : colors.red40;
- }
-
- return {
- position: 'absolute',
- height: '16px',
- borderRadius: '8px',
- transition: 'all 200ms linear',
- width: '16px',
- backgroundColor,
- // When enabled the button should be placed all the way to the right (100%) minus padding (1px)
- // minus it's own width (16px).
- left: props.$isOn ? 'calc(100% - 1px - 16px)' : '1px',
- };
-});
-
-export default class Switch extends React.PureComponent<IProps> {
- public render() {
- return (
- <SwitchContainer
- ref={this.props.innerRef}
- id={this.props.id}
- role="checkbox"
- aria-labelledby={this.props['aria-labelledby']}
- aria-describedby={this.props['aria-describedby']}
- aria-checked={this.props.isOn}
- onClick={this.handleClick}
- disabled={this.props.disabled ?? false}
- aria-disabled={this.props.disabled ?? false}
- tabIndex={-1}
- className={this.props.className}>
- <Knob disabled={this.props.disabled ?? false} $isOn={this.props.isOn} />
- </SwitchContainer>
- );
- }
-
- private handleClick = () => {
- if (!this.props.disabled) {
- this.props.onChange?.(!this.props.isOn);
- }
- };
-}
diff --git a/gui/src/renderer/components/TooManyDevices.tsx b/gui/src/renderer/components/TooManyDevices.tsx
deleted file mode 100644
index 3e636bcb44..0000000000
--- a/gui/src/renderer/components/TooManyDevices.tsx
+++ /dev/null
@@ -1,364 +0,0 @@
-import { useCallback } from 'react';
-import { sprintf } from 'sprintf-js';
-import styled from 'styled-components';
-
-import { colors } from '../../config.json';
-import { IDevice } from '../../shared/daemon-rpc-types';
-import { messages } from '../../shared/gettext';
-import log from '../../shared/logging';
-import { capitalizeEveryWord } from '../../shared/string-helpers';
-import { useAppContext } from '../context';
-import { transitions, useHistory } from '../lib/history';
-import { formatHtml } from '../lib/html-formatter';
-import { RoutePath } from '../lib/routes';
-import { useBoolean } from '../lib/utility-hooks';
-import { useSelector } from '../redux/store';
-import * as AppButton from './AppButton';
-import * as Cell from './cell';
-import { bigText, measurements, normalText, tinyText } from './common-styles';
-import CustomScrollbars from './CustomScrollbars';
-import { Brand, HeaderBarSettingsButton } from './HeaderBar';
-import ImageView from './ImageView';
-import { Footer, Header, Layout, SettingsContainer } from './Layout';
-import List from './List';
-import { ModalAlert, ModalAlertType, ModalContainer, ModalMessage } from './Modal';
-
-const StyledCustomScrollbars = styled(CustomScrollbars)({
- flex: 1,
-});
-
-const StyledContainer = styled(SettingsContainer)({
- paddingTop: '14px',
- minHeight: '100%',
-});
-
-const StyledBody = styled.div({
- display: 'flex',
- flexDirection: 'column',
- flex: 1,
- paddingBottom: 'auto',
-});
-
-const StyledStatusIcon = styled.div({
- alignSelf: 'center',
- width: '60px',
- height: '60px',
- marginBottom: '18px',
-});
-
-const StyledTitle = styled.span(bigText, {
- lineHeight: '38px',
- margin: `0 ${measurements.viewMargin} 8px`,
- color: colors.white,
-});
-
-const StyledLabel = styled.span({
- fontFamily: 'Open Sans',
- fontSize: '12px',
- fontWeight: 600,
- lineHeight: '20px',
- color: colors.white,
- margin: `0 ${measurements.viewMargin} 18px`,
-});
-
-const StyledSpacer = styled.div({
- flex: '1',
-});
-
-const StyledDeviceInfo = styled(Cell.Label)({
- display: 'flex',
- flexDirection: 'column',
- marginTop: '9px',
- marginBottom: '9px',
-});
-
-const StyledDeviceName = styled.span(normalText, {
- fontWeight: 'normal',
- lineHeight: '20px',
- textTransform: 'capitalize',
-});
-
-const StyledDeviceDate = styled.span(tinyText, {
- fontSize: '10px',
- lineHeight: '10px',
- color: colors.white60,
-});
-
-const StyledRemoveDeviceButton = styled.button({
- cursor: 'default',
- padding: 0,
- marginLeft: 8,
- backgroundColor: 'transparent',
- border: 'none',
-});
-
-export default function TooManyDevices() {
- const { reset } = useHistory();
- const { removeDevice, login, cancelLogin } = useAppContext();
- const accountNumber = useSelector((state) => state.account.accountNumber)!;
- const devices = useSelector((state) => state.account.devices);
- const loginState = useSelector((state) => state.account.status);
-
- const onRemoveDevice = useCallback(
- async (deviceId: string) => {
- await removeDevice({ accountNumber, deviceId });
- },
- [removeDevice, accountNumber],
- );
-
- const continueLogin = useCallback(() => {
- void login(accountNumber);
- reset(RoutePath.login, { transition: transitions.pop });
- }, [reset, login, accountNumber]);
- const cancel = useCallback(() => {
- cancelLogin();
- reset(RoutePath.login, { transition: transitions.pop });
- }, [reset, cancelLogin]);
-
- const iconSource = getIconSource(devices);
- const title = getTitle(devices);
- const subtitle = getSubtitle(devices);
-
- const continueButtonDisabled = devices.length === 5 || loginState.type !== 'too many devices';
-
- return (
- <ModalContainer>
- <Layout>
- <Header>
- <Brand />
- <HeaderBarSettingsButton />
- </Header>
- <StyledCustomScrollbars fillContainer>
- <StyledContainer>
- <StyledBody>
- <StyledStatusIcon>
- <ImageView key={iconSource} source={iconSource} height={60} width={60} />
- </StyledStatusIcon>
- {devices !== undefined && (
- <>
- <StyledTitle data-testid="title">{title}</StyledTitle>
- <StyledLabel>{subtitle}</StyledLabel>
- <DeviceList devices={devices} onRemoveDevice={onRemoveDevice} />
- </>
- )}
- </StyledBody>
-
- {devices !== undefined && (
- <Footer>
- <AppButton.ButtonGroup>
- <AppButton.GreenButton onClick={continueLogin} disabled={continueButtonDisabled}>
- {
- // TRANSLATORS: Button for continuing login process.
- messages.pgettext('device-management', 'Continue with login')
- }
- </AppButton.GreenButton>
- <AppButton.BlueButton onClick={cancel}>
- {messages.gettext('Back')}
- </AppButton.BlueButton>
- </AppButton.ButtonGroup>
- </Footer>
- )}
- </StyledContainer>
- </StyledCustomScrollbars>
- </Layout>
- </ModalContainer>
- );
-}
-
-interface IDeviceListProps {
- devices: Array<IDevice>;
- onRemoveDevice: (deviceId: string) => Promise<void>;
-}
-
-function DeviceList(props: IDeviceListProps) {
- return (
- <StyledSpacer>
- <List items={props.devices} getKey={getDeviceKey}>
- {(device) => <Device device={device} onRemove={props.onRemoveDevice} />}
- </List>
- </StyledSpacer>
- );
-}
-
-const getDeviceKey = (device: IDevice): string => device.id;
-
-interface IDeviceProps {
- device: IDevice;
- onRemove: (deviceId: string) => Promise<void>;
-}
-
-function Device(props: IDeviceProps) {
- const { onRemove: propsOnRemove } = props;
-
- const { fetchDevices } = useAppContext();
- const accountNumber = useSelector((state) => state.account.accountNumber)!;
- const [confirmationVisible, showConfirmation, hideConfirmation] = useBoolean(false);
- const [deleting, setDeleting, unsetDeleting] = useBoolean(false);
- const [error, setError, resetError] = useBoolean(false);
-
- const handleError = useCallback(
- async (error: Error) => {
- log.error(`Failede to remove device: ${error.message}`);
-
- let devices: Array<IDevice> | undefined = undefined;
- try {
- devices = await fetchDevices(accountNumber);
- } catch {
- /* no-op */
- }
-
- if (devices === undefined || devices.find((device) => device.id === props.device.id)) {
- hideConfirmation();
- unsetDeleting();
- setError();
- }
- },
- [fetchDevices, accountNumber, props.device.id, hideConfirmation, unsetDeleting, setError],
- );
-
- const onRemove = useCallback(async () => {
- setDeleting();
- hideConfirmation();
- try {
- await propsOnRemove(props.device.id);
- } catch (e) {
- await handleError(e as Error);
- }
- }, [propsOnRemove, props.device.id, hideConfirmation, setDeleting, handleError]);
-
- const capitalizedDeviceName = capitalizeEveryWord(props.device.name);
- const createdDate = props.device.created.toISOString().split('T')[0];
-
- return (
- <>
- <Cell.Container>
- <StyledDeviceInfo>
- <StyledDeviceName aria-hidden>{props.device.name}</StyledDeviceName>
- <StyledDeviceDate>
- {sprintf(
- // TRANSLATORS: Label informing the user when a device was created.
- // TRANSLATORS: Available placeholders:
- // TRANSLATORS: %(createdDate)s - The creation date of the device.
- messages.pgettext('device-management', 'Created: %(createdDate)s'),
- {
- createdDate,
- },
- )}
- </StyledDeviceDate>
- </StyledDeviceInfo>
- {deleting ? (
- <ImageView source="icon-spinner" width={24} />
- ) : (
- <StyledRemoveDeviceButton
- onClick={showConfirmation}
- aria-label={sprintf(
- // TRANSLATORS: Button action description provided to accessibility tools such as screen
- // TRANSLATORS: readers.
- // TRANSLATORS: Available placeholders:
- // TRANSLATORS: %(deviceName)s - The device name to remove.
- messages.pgettext('accessibility', 'Remove device named %(deviceName)s'),
- { deviceName: props.device.name },
- )}>
- <ImageView
- source="icon-close"
- width={18}
- height={18}
- tintColor={colors.white40}
- tintHoverColor={colors.white60}
- />
- </StyledRemoveDeviceButton>
- )}
- </Cell.Container>
- <ModalAlert
- isOpen={confirmationVisible}
- type={ModalAlertType.warning}
- iconColor={colors.red}
- buttons={[
- <AppButton.RedButton key="remove" onClick={onRemove} disabled={deleting}>
- {
- // TRANSLATORS: Confirmation button when logging out other device.
- messages.pgettext('device-management', 'Yes, log out device')
- }
- </AppButton.RedButton>,
- <AppButton.BlueButton key="back" onClick={hideConfirmation} disabled={deleting}>
- {messages.gettext('Back')}
- </AppButton.BlueButton>,
- ]}
- close={hideConfirmation}>
- <ModalMessage>
- {formatHtml(
- sprintf(
- // TRANSLATORS: Text displayed above button which logs out another device.
- // TRANSLATORS: The text enclosed in "<b></b>" will appear bold.
- // TRANSLATORS: Available placeholders:
- // TRANSLATORS: %(deviceName)s - The name of the device to log out.
- messages.pgettext(
- 'device-management',
- 'Are you sure you want to log <b>%(deviceName)s</b> out?',
- ),
- { deviceName: capitalizedDeviceName },
- ),
- )}
- </ModalMessage>
- </ModalAlert>
- <ModalAlert
- isOpen={error}
- type={ModalAlertType.warning}
- iconColor={colors.red}
- buttons={[
- <AppButton.BlueButton key="close" onClick={resetError}>
- {messages.gettext('Close')}
- </AppButton.BlueButton>,
- ]}
- close={resetError}
- message={messages.pgettext('device-management', 'Failed to remove device')}
- />
- </>
- );
-}
-
-function getIconSource(devices?: Array<IDevice>): string {
- if (devices) {
- if (devices.length === 5) {
- return 'icon-fail';
- } else {
- return 'icon-success';
- }
- } else {
- return 'icon-spinner';
- }
-}
-
-function getTitle(devices?: Array<IDevice>): string | undefined {
- if (devices) {
- if (devices.length === 5) {
- // TRANSLATORS: Page title informing user that the login failed due to too many registered
- // TRANSLATORS: devices on account.
- return messages.pgettext('device-management', 'Too many devices');
- } else {
- // TRANSLATORS: Page title informing user that enough devices has been removed to continue
- // TRANSLATORS: login process.
- return messages.pgettext('device-management', 'Super!');
- }
- } else {
- return undefined;
- }
-}
-
-function getSubtitle(devices?: Array<IDevice>): string | undefined {
- if (devices) {
- if (devices.length === 5) {
- return messages.pgettext(
- 'device-management',
- 'Please log out of at least one by removing it from the list below. You can find the corresponding device name under the device’s Account settings.',
- );
- } else {
- return messages.pgettext(
- 'device-management',
- 'You can now continue logging in on this device.',
- );
- }
- } else {
- return undefined;
- }
-}
diff --git a/gui/src/renderer/components/TransitionContainer.tsx b/gui/src/renderer/components/TransitionContainer.tsx
deleted file mode 100644
index 1df9b097c5..0000000000
--- a/gui/src/renderer/components/TransitionContainer.tsx
+++ /dev/null
@@ -1,381 +0,0 @@
-import * as React from 'react';
-import styled from 'styled-components';
-
-import { ITransitionSpecification } from '../lib/history';
-import { WillExit } from '../lib/will-exit';
-
-interface ITransitioningViewProps {
- routePath: string;
- children?: React.ReactNode;
-}
-
-type TransitioningView = React.ReactElement<ITransitioningViewProps>;
-
-interface ITransitionQueueItem {
- view: TransitioningView;
- transition: ITransitionSpecification;
-}
-
-interface IProps extends ITransitionSpecification {
- children: TransitioningView;
- onTransitionEnd: () => void;
-}
-
-interface IItemStyle {
- // x and y are percentages
- x: number;
- y: number;
- inFront: boolean;
- duration?: number;
-}
-
-interface IState {
- currentItem?: ITransitionQueueItem;
- nextItem?: ITransitionQueueItem;
- queuedItem?: ITransitionQueueItem;
- currentItemStyle?: IItemStyle;
- nextItemStyle?: IItemStyle;
- currentItemTransition?: Partial<IItemStyle>;
- nextItemTransition?: Partial<IItemStyle>;
-}
-
-export const StyledTransitionContainer = styled.div({ flex: 1 });
-
-interface StyledTransitionContentProps {
- $transition?: IItemStyle;
- $disableUserInteraction?: boolean;
-}
-
-export const StyledTransitionContent = styled.div.attrs<
- StyledTransitionContentProps,
- { 'data-testid': string }
->({
- 'data-testid': 'transition-content',
-})((props) => {
- const x = `${props.$transition?.x ?? 0}%`;
- const y = `${props.$transition?.y ?? 0}%`;
- const duration = props.$transition?.duration ?? 450;
-
- return {
- display: 'flex',
- flexDirection: 'column',
- position: 'absolute',
- left: 0,
- right: 0,
- top: 0,
- bottom: 0,
- zIndex: props.$transition?.inFront ? 1 : 0,
- willChange: 'transform',
- transform: `translate3d(${x}, ${y}, 0)`,
- transition: `transform ${duration}ms ease-in-out`,
- pointerEvents: props.$disableUserInteraction ? 'none' : undefined,
- };
-});
-
-export const StyledTransitionView = styled.div({
- display: 'flex',
- flex: 1,
- flexDirection: 'column',
- height: '100%',
- width: '100%',
-});
-
-export class TransitionView extends React.Component<ITransitioningViewProps> {
- public render() {
- return (
- <StyledTransitionView data-testid={this.props.routePath}>
- {this.props.children}
- </StyledTransitionView>
- );
- }
-}
-
-export default class TransitionContainer extends React.Component<IProps, IState> {
- public state: IState = {
- currentItem: TransitionContainer.makeItem(this.props),
- };
-
- private isCycling = false;
- private isTransitioning = false;
-
- private currentContentRef: React.MutableRefObject<HTMLDivElement | null> =
- React.createRef<HTMLDivElement>();
- private nextContentRef: React.MutableRefObject<HTMLDivElement | null> =
- React.createRef<HTMLDivElement>();
- // The item that should trigger the cycle to finish in onTransitionEnd
- private transitioningItemRef?: React.RefObject<HTMLDivElement>;
-
- public componentDidUpdate(prevProps: IProps) {
- if (this.props.children !== prevProps.children) {
- this.updateStateFromProps();
- }
-
- if (
- this.state.currentItemStyle &&
- this.state.currentItemTransition &&
- this.state.nextItemStyle &&
- this.state.nextItemTransition
- ) {
- // Force browser reflow before starting transition. Without this animations won't run since
- // the next view content hasn't been painted yet. It will just appear without a transition.
- void this.nextContentRef.current?.offsetHeight;
-
- // Start transition
- this.setState((state) => ({
- currentItemStyle: Object.assign({}, state.currentItemStyle, state.currentItemTransition),
- nextItemStyle: Object.assign({}, state.nextItemStyle, state.nextItemTransition),
- currentItemTransition: undefined,
- nextItemTransition: undefined,
- }));
- } else {
- this.cycle();
- }
- }
-
- public render() {
- const willExit = this.state.queuedItem !== undefined || this.state.nextItem !== undefined;
-
- return (
- <StyledTransitionContainer>
- {this.state.currentItem && (
- <WillExit key={this.state.currentItem.view.props.routePath} value={willExit}>
- <StyledTransitionContent
- ref={this.setCurrentContentRef}
- $transition={this.state.currentItemStyle}
- onTransitionEnd={this.onTransitionEnd}
- $disableUserInteraction={willExit}>
- {this.state.currentItem.view}
- </StyledTransitionContent>
- </WillExit>
- )}
-
- {this.state.nextItem && (
- <WillExit key={this.state.nextItem.view.props.routePath} value={false}>
- <StyledTransitionContent
- ref={this.setNextContentRef}
- $transition={this.state.nextItemStyle}
- onTransitionEnd={this.onTransitionEnd}>
- {this.state.nextItem.view}
- </StyledTransitionContent>
- </WillExit>
- )}
- </StyledTransitionContainer>
- );
- }
-
- private setCurrentContentRef = (element: HTMLDivElement) => {
- this.currentContentRef.current?.removeEventListener('transitionstart', this.onTransitionStart);
- this.currentContentRef.current = element;
- this.currentContentRef.current?.addEventListener('transitionstart', this.onTransitionStart);
- };
-
- private setNextContentRef = (element: HTMLDivElement) => {
- this.nextContentRef.current?.removeEventListener('transitionstart', this.onTransitionStart);
- this.nextContentRef.current = element;
- this.nextContentRef.current?.addEventListener('transitionstart', this.onTransitionStart);
- };
-
- private updateStateFromProps() {
- const candidate = this.props.children;
-
- if (candidate && this.state.currentItem) {
- // Update currentItem, nextItem, queuedItem depending on which the candidate matches.
- if (
- !this.isCycling &&
- this.state.currentItem.view.props.routePath === candidate.props.routePath
- ) {
- // There's no transition in progress and the newest candidate has the same path as the
- // current. In this situation the app should just remain in the same view.
- this.setState(
- {
- currentItem: TransitionContainer.makeItem(this.props),
- nextItem: undefined,
- queuedItem: undefined,
- currentItemStyle: undefined,
- nextItemStyle: undefined,
- currentItemTransition: undefined,
- nextItemTransition: undefined,
- },
- () => (this.isCycling = false),
- );
- } else if (!this.isCycling && this.state.nextItem) {
- // There's no transition in progress but there is a next item. Abort the transition and add
- // the candidate to the queue. The app shouldn't start a transition if there is another view
- // to queue.
- this.setState(
- {
- nextItem: undefined,
- queuedItem: TransitionContainer.makeItem(this.props),
- currentItemStyle: undefined,
- nextItemStyle: undefined,
- currentItemTransition: undefined,
- nextItemTransition: undefined,
- },
- () => (this.isCycling = false),
- );
- } else if (this.state.nextItem?.view.props.routePath === candidate.props.routePath) {
- // There's an update to the item that is currently being transitioned to. Update that item
- // and continue the transition.
- this.setState({
- nextItem: TransitionContainer.makeItem(this.props),
- queuedItem: undefined,
- });
- } else {
- // If none of the above, initiate a transition to the new item.
- this.setState({ queuedItem: TransitionContainer.makeItem(this.props) });
- }
- } else if (candidate) {
- // Child is set as current item if there's no item already.
- this.setState({ currentItem: TransitionContainer.makeItem(this.props) });
- }
- }
-
- private onTransitionStart = (event: TransitionEvent) => {
- if (
- this.isCycling &&
- !this.isTransitioning &&
- event.target === this.transitioningItemRef?.current
- ) {
- this.isTransitioning = true;
- }
- };
-
- private onTransitionEnd = (event: React.TransitionEvent<HTMLDivElement>) => {
- if (this.isCycling && event.target === this.transitioningItemRef?.current) {
- this.isTransitioning = false;
- this.transitioningItemRef = undefined;
- this.makeNextItemCurrent(() => {
- this.onFinishCycle();
- });
- }
- };
-
- private cycle() {
- if (!this.isCycling) {
- this.isCycling = true;
- this.cycleUnguarded();
- }
- }
-
- private onFinishCycle() {
- this.props.onTransitionEnd();
- this.cycleUnguarded();
- }
-
- private cycleUnguarded = () => {
- if (this.state.queuedItem) {
- const transition = this.state.queuedItem.transition;
-
- switch (transition.name) {
- case 'slide-up':
- this.slideUp(transition.duration);
- break;
-
- case 'slide-down':
- this.slideDown(transition.duration);
- break;
-
- case 'push':
- this.push(transition.duration);
- break;
-
- case 'pop':
- this.pop(transition.duration);
- break;
-
- default:
- this.replace(() => this.onFinishCycle);
- break;
- }
- } else {
- this.isCycling = false;
- }
- };
-
- private static makeItem(props: IProps): ITransitionQueueItem {
- return {
- transition: {
- name: props.name,
- duration: props.duration,
- },
- view: React.cloneElement(props.children),
- };
- }
-
- private makeNextItemCurrent(completion: () => void) {
- this.setState(
- (state) => ({
- currentItem: state.nextItem,
- nextItem: undefined,
- currentItemStyle: undefined,
- nextItemStyle: undefined,
- currentItemTransition: undefined,
- nextItemTransition: undefined,
- }),
- completion,
- );
- }
-
- private slideUp(duration: number) {
- this.transitioningItemRef = this.nextContentRef;
- this.setState((state) => ({
- nextItem: state.queuedItem,
- queuedItem: undefined,
- currentItemStyle: { x: 0, y: 0, inFront: false },
- nextItemStyle: { x: 0, y: 100, inFront: true },
- currentItemTransition: { duration },
- nextItemTransition: { y: 0, duration },
- }));
- }
-
- private slideDown(duration: number) {
- this.transitioningItemRef = this.currentContentRef;
- this.setState((state) => ({
- nextItem: state.queuedItem,
- queuedItem: undefined,
- currentItemStyle: { x: 0, y: 0, inFront: true },
- nextItemStyle: { x: 0, y: 0, inFront: false },
- currentItemTransition: { y: 100, duration },
- nextItemTransition: { duration },
- }));
- }
-
- private push(duration: number) {
- this.transitioningItemRef = this.nextContentRef;
- this.setState((state) => ({
- nextItem: state.queuedItem,
- queuedItem: undefined,
- currentItemStyle: { x: 0, y: 0, inFront: false },
- nextItemStyle: { x: 100, y: 0, inFront: true },
- currentItemTransition: { x: -50, duration },
- nextItemTransition: { x: 0, duration },
- }));
- }
-
- private pop(duration: number) {
- this.transitioningItemRef = this.currentContentRef;
- this.setState((state) => ({
- nextItem: state.queuedItem,
- queuedItem: undefined,
- currentItemStyle: { x: 0, y: 0, inFront: true },
- nextItemStyle: { x: -50, y: 0, inFront: false },
- currentItemTransition: { x: 100, duration },
- nextItemTransition: { x: 0, duration },
- }));
- }
-
- private replace(completion: () => void) {
- this.setState(
- (state) => ({
- currentItem: state.queuedItem,
- nextItem: undefined,
- queuedItem: undefined,
- currentItemStyle: { x: 0, y: 0, inFront: false, duration: 0 },
- nextItemStyle: { x: 0, y: 0, inFront: true, duration: 0 },
- currentItemTransition: undefined,
- nextItemTransition: undefined,
- }),
- completion,
- );
- }
-}
diff --git a/gui/src/renderer/components/UdpOverTcp.tsx b/gui/src/renderer/components/UdpOverTcp.tsx
deleted file mode 100644
index 7179daca77..0000000000
--- a/gui/src/renderer/components/UdpOverTcp.tsx
+++ /dev/null
@@ -1,126 +0,0 @@
-import { useCallback, useMemo } from 'react';
-import styled from 'styled-components';
-
-import { liftConstraint, LiftedConstraint, wrapConstraint } from '../../shared/daemon-rpc-types';
-import { messages } from '../../shared/gettext';
-import { useAppContext } from '../context';
-import { useHistory } from '../lib/history';
-import { useSelector } from '../redux/store';
-import { AriaInputGroup } from './AriaGroup';
-import * as Cell from './cell';
-import Selector, { SelectorItem } from './cell/Selector';
-import { BackAction } from './KeyboardNavigation';
-import { Layout, SettingsContainer } from './Layout';
-import { ModalMessage } from './Modal';
-import {
- NavigationBar,
- NavigationContainer,
- NavigationItems,
- NavigationScrollbars,
- TitleBarItem,
-} from './NavigationBar';
-import SettingsHeader, { HeaderTitle } from './SettingsHeader';
-
-const UDP2TCP_PORTS = [80, 5001];
-
-function mapPortToSelectorItem(value: number): SelectorItem<number> {
- return { label: value.toString(), value };
-}
-
-const StyledContent = styled.div({
- display: 'flex',
- flexDirection: 'column',
- flex: 1,
- marginBottom: '2px',
-});
-
-const StyledSelectorContainer = styled.div({
- flex: 0,
-});
-
-export default function UdpOverTcp() {
- const { pop } = useHistory();
-
- return (
- <BackAction action={pop}>
- <Layout>
- <SettingsContainer>
- <NavigationContainer>
- <NavigationBar>
- <NavigationItems>
- <TitleBarItem>
- {
- // TRANSLATORS: Title label in navigation bar
- messages.pgettext('wireguard-settings-nav', 'UDP-over-TCP')
- }
- </TitleBarItem>
- </NavigationItems>
- </NavigationBar>
-
- <NavigationScrollbars>
- <SettingsHeader>
- <HeaderTitle>
- {messages.pgettext('wireguard-settings-view', 'UDP-over-TCP')}
- </HeaderTitle>
- </SettingsHeader>
-
- <StyledContent>
- <Cell.Group>
- <Udp2tcpPortSetting />
- </Cell.Group>
- </StyledContent>
- </NavigationScrollbars>
- </NavigationContainer>
- </SettingsContainer>
- </Layout>
- </BackAction>
- );
-}
-
-function Udp2tcpPortSetting() {
- const { setObfuscationSettings } = useAppContext();
- const obfuscationSettings = useSelector((state) => state.settings.obfuscationSettings);
-
- const port = liftConstraint(obfuscationSettings.udp2tcpSettings.port);
- const portItems: SelectorItem<number>[] = useMemo(
- () => UDP2TCP_PORTS.map(mapPortToSelectorItem),
- [],
- );
-
- const selectPort = useCallback(
- async (port: LiftedConstraint<number>) => {
- await setObfuscationSettings({
- ...obfuscationSettings,
- udp2tcpSettings: {
- ...obfuscationSettings.udp2tcpSettings,
- port: wrapConstraint(port),
- },
- });
- },
- [setObfuscationSettings, obfuscationSettings],
- );
-
- return (
- <AriaInputGroup>
- <StyledSelectorContainer>
- <Selector
- // TRANSLATORS: The title for the UDP-over-TCP port selector.
- title={messages.pgettext('wireguard-settings-view', 'UDP-over-TCP port')}
- details={
- <ModalMessage>
- {messages.pgettext(
- 'wireguard-settings-view',
- 'Which TCP port the UDP-over-TCP obfuscation protocol should connect to on the VPN server.',
- )}
- </ModalMessage>
- }
- items={portItems}
- value={port}
- onSelect={selectPort}
- thinTitle
- automaticValue={'any' as const}
- />
- </StyledSelectorContainer>
- </AriaInputGroup>
- );
-}
diff --git a/gui/src/renderer/components/UserInterfaceSettings.tsx b/gui/src/renderer/components/UserInterfaceSettings.tsx
deleted file mode 100644
index 9c3415d90d..0000000000
--- a/gui/src/renderer/components/UserInterfaceSettings.tsx
+++ /dev/null
@@ -1,272 +0,0 @@
-import { useCallback } from 'react';
-import styled from 'styled-components';
-
-import { messages } from '../../shared/gettext';
-import { useAppContext } from '../context';
-import { useHistory } from '../lib/history';
-import { RoutePath } from '../lib/routes';
-import { useSelector } from '../redux/store';
-import { AriaDescription, AriaInput, AriaInputGroup, AriaLabel } from './AriaGroup';
-import * as Cell from './cell';
-import { BackAction } from './KeyboardNavigation';
-import { Layout, SettingsContainer } from './Layout';
-import {
- NavigationBar,
- NavigationContainer,
- NavigationItems,
- NavigationScrollbars,
- TitleBarItem,
-} from './NavigationBar';
-import SettingsHeader, { HeaderTitle } from './SettingsHeader';
-
-const StyledContent = styled.div({
- display: 'flex',
- flexDirection: 'column',
- flex: 1,
- marginBottom: '2px',
-});
-
-const StyledCellIcon = styled(Cell.UntintedIcon)({
- marginRight: '8px',
-});
-
-const StyledAnimateMapSettingsGroup = styled(Cell.Group)({
- '@media (prefers-reduced-motion: reduce)': {
- display: 'none',
- },
-});
-
-export default function UserInterfaceSettings() {
- const { pop } = useHistory();
- const unpinnedWindow = useSelector((state) => state.settings.guiSettings.unpinnedWindow);
-
- return (
- <BackAction action={pop}>
- <Layout>
- <SettingsContainer>
- <NavigationContainer>
- <NavigationBar>
- <NavigationItems>
- <TitleBarItem>
- {
- // TRANSLATORS: Title label in navigation bar
- messages.pgettext('user-interface-settings-view', 'User interface settings')
- }
- </TitleBarItem>
- </NavigationItems>
- </NavigationBar>
-
- <NavigationScrollbars>
- <SettingsHeader>
- <HeaderTitle>
- {messages.pgettext('user-interface-settings-view', 'User interface settings')}
- </HeaderTitle>
- </SettingsHeader>
-
- <StyledContent>
- <Cell.Group>
- <NotificationsSetting />
- </Cell.Group>
- <Cell.Group>
- <MonochromaticTrayIconSetting />
- </Cell.Group>
-
- <Cell.Group>
- <LanguageButton />
- </Cell.Group>
-
- {(window.env.platform === 'win32' ||
- (window.env.platform === 'darwin' && window.env.development)) && (
- <Cell.Group>
- <UnpinnedWindowSetting />
- </Cell.Group>
- )}
-
- {unpinnedWindow && (
- <Cell.Group>
- <StartMinimizedSetting />
- </Cell.Group>
- )}
-
- <StyledAnimateMapSettingsGroup>
- <AnimateMapSetting />
- </StyledAnimateMapSettingsGroup>
- </StyledContent>
- </NavigationScrollbars>
- </NavigationContainer>
- </SettingsContainer>
- </Layout>
- </BackAction>
- );
-}
-
-function NotificationsSetting() {
- const enableSystemNotifications = useSelector(
- (state) => state.settings.guiSettings.enableSystemNotifications,
- );
- const { setEnableSystemNotifications } = useAppContext();
-
- return (
- <AriaInputGroup>
- <Cell.Container>
- <AriaLabel>
- <Cell.InputLabel>
- {messages.pgettext('user-interface-settings-view', 'Notifications')}
- </Cell.InputLabel>
- </AriaLabel>
- <AriaInput>
- <Cell.Switch isOn={enableSystemNotifications} onChange={setEnableSystemNotifications} />
- </AriaInput>
- </Cell.Container>
- <Cell.CellFooter>
- <AriaDescription>
- <Cell.CellFooterText>
- {messages.pgettext(
- 'user-interface-settings-view',
- 'Enable or disable system notifications. The critical notifications will always be displayed.',
- )}
- </Cell.CellFooterText>
- </AriaDescription>
- </Cell.CellFooter>
- </AriaInputGroup>
- );
-}
-
-function MonochromaticTrayIconSetting() {
- const monochromaticIcon = useSelector((state) => state.settings.guiSettings.monochromaticIcon);
- const { setMonochromaticIcon } = useAppContext();
-
- return (
- <AriaInputGroup>
- <Cell.Container>
- <AriaLabel>
- <Cell.InputLabel>
- {messages.pgettext('user-interface-settings-view', 'Monochromatic tray icon')}
- </Cell.InputLabel>
- </AriaLabel>
- <AriaInput>
- <Cell.Switch isOn={monochromaticIcon} onChange={setMonochromaticIcon} />
- </AriaInput>
- </Cell.Container>
- <Cell.CellFooter>
- <AriaDescription>
- <Cell.CellFooterText>
- {messages.pgettext(
- 'user-interface-settings-view',
- 'Use a monochromatic tray icon instead of a colored one.',
- )}
- </Cell.CellFooterText>
- </AriaDescription>
- </Cell.CellFooter>
- </AriaInputGroup>
- );
-}
-
-function UnpinnedWindowSetting() {
- const unpinnedWindow = useSelector((state) => state.settings.guiSettings.unpinnedWindow);
- const { setUnpinnedWindow } = useAppContext();
-
- return (
- <AriaInputGroup>
- <Cell.Container>
- <AriaLabel>
- <Cell.InputLabel>
- {messages.pgettext('user-interface-settings-view', 'Unpin app from taskbar')}
- </Cell.InputLabel>
- </AriaLabel>
- <AriaInput>
- <Cell.Switch isOn={unpinnedWindow} onChange={setUnpinnedWindow} />
- </AriaInput>
- </Cell.Container>
- <Cell.CellFooter>
- <AriaDescription>
- <Cell.CellFooterText>
- {messages.pgettext(
- 'user-interface-settings-view',
- 'Enable to move the app around as a free-standing window.',
- )}
- </Cell.CellFooterText>
- </AriaDescription>
- </Cell.CellFooter>
- </AriaInputGroup>
- );
-}
-
-function StartMinimizedSetting() {
- const startMinimized = useSelector((state) => state.settings.guiSettings.startMinimized);
- const { setStartMinimized } = useAppContext();
-
- return (
- <AriaInputGroup>
- <Cell.Container>
- <AriaLabel>
- <Cell.InputLabel>
- {messages.pgettext('user-interface-settings-view', 'Start minimized')}
- </Cell.InputLabel>
- </AriaLabel>
- <AriaInput>
- <Cell.Switch isOn={startMinimized} onChange={setStartMinimized} />
- </AriaInput>
- </Cell.Container>
- <Cell.CellFooter>
- <AriaDescription>
- <Cell.CellFooterText>
- {messages.pgettext(
- 'user-interface-settings-view',
- 'Show only the tray icon when the app starts.',
- )}
- </Cell.CellFooterText>
- </AriaDescription>
- </Cell.CellFooter>
- </AriaInputGroup>
- );
-}
-
-function AnimateMapSetting() {
- const animateMap = useSelector((state) => state.settings.guiSettings.animateMap);
- const { setAnimateMap } = useAppContext();
-
- return (
- <AriaInputGroup>
- <Cell.Container>
- <AriaLabel>
- <Cell.InputLabel>
- {messages.pgettext('user-interface-settings-view', 'Animate map')}
- </Cell.InputLabel>
- </AriaLabel>
- <AriaInput>
- <Cell.Switch isOn={animateMap} onChange={setAnimateMap} />
- </AriaInput>
- </Cell.Container>
- <Cell.CellFooter>
- <AriaDescription>
- <Cell.CellFooterText>
- {messages.pgettext('user-interface-settings-view', 'Animate map movements.')}
- </Cell.CellFooterText>
- </AriaDescription>
- </Cell.CellFooter>
- </AriaInputGroup>
- );
-}
-
-function LanguageButton() {
- const history = useHistory();
- const { getPreferredLocaleDisplayName } = useAppContext();
- const preferredLocale = useSelector((state) => state.settings.guiSettings.preferredLocale);
- const localeDisplayName = getPreferredLocaleDisplayName(preferredLocale);
-
- const navigate = useCallback(() => history.push(RoutePath.selectLanguage), [history]);
-
- return (
- <Cell.CellNavigationButton onClick={navigate}>
- <StyledCellIcon width={24} height={24} source="icon-language" />
- <Cell.Label>
- {
- // TRANSLATORS: Navigation button to the 'Language' settings view
- messages.pgettext('user-interface-settings-view', 'Language')
- }
- </Cell.Label>
- <Cell.SubText>{localeDisplayName}</Cell.SubText>
- </Cell.CellNavigationButton>
- );
-}
diff --git a/gui/src/renderer/components/VpnSettings.tsx b/gui/src/renderer/components/VpnSettings.tsx
deleted file mode 100644
index c23ecdbf47..0000000000
--- a/gui/src/renderer/components/VpnSettings.tsx
+++ /dev/null
@@ -1,829 +0,0 @@
-import { useCallback, useMemo } from 'react';
-import { sprintf } from 'sprintf-js';
-import styled from 'styled-components';
-
-import { colors, strings } from '../../config.json';
-import { IDnsOptions, TunnelProtocol, wrapConstraint } from '../../shared/daemon-rpc-types';
-import { messages } from '../../shared/gettext';
-import log from '../../shared/logging';
-import { useAppContext } from '../context';
-import { useRelaySettingsUpdater } from '../lib/constraint-updater';
-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';
-import * as AppButton from './AppButton';
-import { AriaDescription, AriaDetails, AriaInput, AriaInputGroup, AriaLabel } from './AriaGroup';
-import * as Cell from './cell';
-import Selector, { SelectorItem } from './cell/Selector';
-import CustomDnsSettings from './CustomDnsSettings';
-import InfoButton, { InfoIcon } from './InfoButton';
-import { BackAction } from './KeyboardNavigation';
-import { Layout, SettingsContainer } from './Layout';
-import { ModalAlert, ModalAlertType, ModalMessage } from './Modal';
-import {
- NavigationBar,
- NavigationContainer,
- NavigationItems,
- NavigationScrollbars,
- TitleBarItem,
-} from './NavigationBar';
-import SettingsHeader, { HeaderTitle } from './SettingsHeader';
-
-const StyledContent = styled.div({
- display: 'flex',
- flexDirection: 'column',
- flex: 1,
- marginBottom: '2px',
-});
-
-const StyledInfoIcon = styled(InfoIcon)({
- marginRight: '16px',
-});
-
-const StyledSelectorContainer = styled.div({
- flex: 0,
-});
-
-const StyledTitleLabel = styled(Cell.SectionTitle)({
- flex: 1,
-});
-
-const StyledSectionItem = styled(Cell.Container)({
- backgroundColor: colors.blue40,
-});
-
-const LanIpRanges = styled.ul({
- listStyle: 'disc outside',
- marginLeft: '20px',
-});
-
-const IndentedValueLabel = styled(Cell.ValueLabel)({
- marginLeft: '16px',
-});
-
-export default function VpnSettings() {
- const { pop } = useHistory();
-
- return (
- <BackAction action={pop}>
- <Layout>
- <SettingsContainer>
- <NavigationContainer>
- <NavigationBar>
- <NavigationItems>
- <TitleBarItem>
- {
- // TRANSLATORS: Title label in navigation bar
- messages.pgettext('vpn-settings-view', 'VPN settings')
- }
- </TitleBarItem>
- </NavigationItems>
- </NavigationBar>
-
- <NavigationScrollbars>
- <SettingsHeader>
- <HeaderTitle>{messages.pgettext('vpn-settings-view', 'VPN settings')}</HeaderTitle>
- </SettingsHeader>
-
- <StyledContent>
- <Cell.Group>
- <AutoStart />
- <AutoConnect />
- </Cell.Group>
-
- <Cell.Group>
- <AllowLan />
- </Cell.Group>
-
- <Cell.Group>
- <DnsBlockers />
- </Cell.Group>
-
- <Cell.Group>
- <EnableIpv6 />
- </Cell.Group>
-
- <Cell.Group>
- <KillSwitchInfo />
- <LockdownMode />
- </Cell.Group>
-
- <Cell.Group>
- <TunnelProtocolSetting />
- </Cell.Group>
-
- <Cell.Group>
- <WireguardSettingsButton />
- <OpenVpnSettingsButton />
- </Cell.Group>
-
- <Cell.Group>
- <CustomDnsSettings />
- </Cell.Group>
-
- <Cell.Group>
- <IpOverrideButton />
- </Cell.Group>
- </StyledContent>
- </NavigationScrollbars>
- </NavigationContainer>
- </SettingsContainer>
- </Layout>
- </BackAction>
- );
-}
-
-function AutoStart() {
- const autoStart = useSelector((state) => state.settings.autoStart);
- const { setAutoStart: setAutoStartImpl } = useAppContext();
-
- const setAutoStart = useCallback(
- async (autoStart: boolean) => {
- try {
- await setAutoStartImpl(autoStart);
- } catch (e) {
- const error = e as Error;
- log.error(`Cannot set auto-start: ${error.message}`);
- }
- },
- [setAutoStartImpl],
- );
-
- return (
- <AriaInputGroup>
- <Cell.Container>
- <AriaLabel>
- <Cell.InputLabel>
- {messages.pgettext('vpn-settings-view', 'Launch app on start-up')}
- </Cell.InputLabel>
- </AriaLabel>
- <AriaInput>
- <Cell.Switch isOn={autoStart} onChange={setAutoStart} />
- </AriaInput>
- </Cell.Container>
- </AriaInputGroup>
- );
-}
-
-function AutoConnect() {
- const autoConnect = useSelector((state) => state.settings.guiSettings.autoConnect);
- const { setAutoConnect } = useAppContext();
-
- return (
- <AriaInputGroup>
- <Cell.Container>
- <AriaLabel>
- <Cell.InputLabel>
- {messages.pgettext('vpn-settings-view', 'Auto-connect')}
- </Cell.InputLabel>
- </AriaLabel>
- <AriaInput>
- <Cell.Switch isOn={autoConnect} onChange={setAutoConnect} />
- </AriaInput>
- </Cell.Container>
- <Cell.CellFooter>
- <AriaDescription>
- <Cell.CellFooterText>
- {messages.pgettext(
- 'vpn-settings-view',
- 'Automatically connect to a server when the app launches.',
- )}
- </Cell.CellFooterText>
- </AriaDescription>
- </Cell.CellFooter>
- </AriaInputGroup>
- );
-}
-
-function AllowLan() {
- const allowLan = useSelector((state) => state.settings.allowLan);
- const { setAllowLan } = useAppContext();
-
- return (
- <AriaInputGroup>
- <Cell.Container>
- <AriaLabel>
- <Cell.InputLabel>
- {messages.pgettext('vpn-settings-view', 'Local network sharing')}
- </Cell.InputLabel>
- </AriaLabel>
- <AriaDetails>
- <InfoButton>
- <ModalMessage>
- {messages.pgettext(
- 'vpn-settings-view',
- 'This feature allows access to other devices on the local network, such as for sharing, printing, streaming, etc.',
- )}
- </ModalMessage>
- <ModalMessage>
- {messages.pgettext(
- 'vpn-settings-view',
- 'It does this by allowing network communication outside the tunnel to local multicast and broadcast ranges as well as to and from these private IP ranges:',
- )}
- <LanIpRanges>
- <li>10.0.0.0/8</li>
- <li>172.16.0.0/12</li>
- <li>192.168.0.0/16</li>
- <li>169.254.0.0/16</li>
- <li>fe80::/10</li>
- <li>fc00::/7</li>
- </LanIpRanges>
- </ModalMessage>
- </InfoButton>
- </AriaDetails>
- <AriaInput>
- <Cell.Switch isOn={allowLan} onChange={setAllowLan} />
- </AriaInput>
- </Cell.Container>
- </AriaInputGroup>
- );
-}
-
-function useDns(setting: keyof IDnsOptions['defaultOptions']) {
- const dns = useSelector((state) => state.settings.dns);
- const { setDnsOptions } = useAppContext();
-
- const updateBlockSetting = useCallback(
- (enabled: boolean) =>
- setDnsOptions({
- ...dns,
- defaultOptions: {
- ...dns.defaultOptions,
- [setting]: enabled,
- },
- }),
- [setting, dns, setDnsOptions],
- );
-
- return [dns, updateBlockSetting] as const;
-}
-
-function DnsBlockers() {
- const dns = useSelector((state) => state.settings.dns);
- const customDnsFeatureName = messages.pgettext('vpn-settings-view', 'Use custom DNS server');
-
- const title = (
- <>
- <StyledTitleLabel as="label" disabled={dns.state === 'custom'}>
- {messages.pgettext('vpn-settings-view', 'DNS content blockers')}
- </StyledTitleLabel>
- <InfoButton>
- <ModalMessage>
- {messages.pgettext(
- 'vpn-settings-view',
- 'When this feature is enabled it stops the device from contacting certain domains or websites known for distributing ads, malware, trackers and more.',
- )}
- </ModalMessage>
- <ModalMessage>
- {messages.pgettext(
- 'vpn-settings-view',
- 'This might cause issues on certain websites, services, and apps.',
- )}
- </ModalMessage>
- <ModalMessage>
- {formatHtml(
- sprintf(
- messages.pgettext(
- 'vpn-settings-view',
- 'Attention: this setting cannot be used in combination with <b>%(customDnsFeatureName)s</b>',
- ),
- { customDnsFeatureName },
- ),
- )}
- </ModalMessage>
- </InfoButton>
- </>
- );
-
- return (
- <Cell.ExpandableSection sectionTitle={title} expandableId="dns-blockers">
- <BlockAds />
- <BlockTrackers />
- <BlockMalware />
- <BlockGambling />
- <BlockAdultContent />
- <BlockSocialMedia />
- </Cell.ExpandableSection>
- );
-}
-
-function BlockAds() {
- const [dns, setBlockAds] = useDns('blockAds');
-
- return (
- <AriaInputGroup>
- <StyledSectionItem disabled={dns.state === 'custom'}>
- <AriaLabel>
- <IndentedValueLabel>
- {
- // TRANSLATORS: Label for settings that enables ad blocking.
- messages.pgettext('vpn-settings-view', 'Ads')
- }
- </IndentedValueLabel>
- </AriaLabel>
- <AriaInput>
- <Cell.Switch
- isOn={dns.state === 'default' && dns.defaultOptions.blockAds}
- onChange={setBlockAds}
- />
- </AriaInput>
- </StyledSectionItem>
- </AriaInputGroup>
- );
-}
-
-function BlockTrackers() {
- const [dns, setBlockTrackers] = useDns('blockTrackers');
-
- return (
- <AriaInputGroup>
- <StyledSectionItem disabled={dns.state === 'custom'}>
- <AriaLabel>
- <IndentedValueLabel>
- {
- // TRANSLATORS: Label for settings that enables tracker blocking.
- messages.pgettext('vpn-settings-view', 'Trackers')
- }
- </IndentedValueLabel>
- </AriaLabel>
- <AriaInput>
- <Cell.Switch
- isOn={dns.state === 'default' && dns.defaultOptions.blockTrackers}
- onChange={setBlockTrackers}
- />
- </AriaInput>
- </StyledSectionItem>
- </AriaInputGroup>
- );
-}
-
-function BlockMalware() {
- const [dns, setBlockMalware] = useDns('blockMalware');
-
- return (
- <AriaInputGroup>
- <StyledSectionItem disabled={dns.state === 'custom'}>
- <AriaLabel>
- <IndentedValueLabel>
- {
- // TRANSLATORS: Label for settings that enables malware blocking.
- messages.pgettext('vpn-settings-view', 'Malware')
- }
- </IndentedValueLabel>
- </AriaLabel>
- <AriaDetails>
- <InfoButton>
- <ModalMessage>
- {messages.pgettext(
- 'vpn-settings-view',
- 'Warning: The malware blocker is not an anti-virus and should not be treated as such, this is just an extra layer of protection.',
- )}
- </ModalMessage>
- </InfoButton>
- </AriaDetails>
- <AriaInput>
- <Cell.Switch
- isOn={dns.state === 'default' && dns.defaultOptions.blockMalware}
- onChange={setBlockMalware}
- />
- </AriaInput>
- </StyledSectionItem>
- </AriaInputGroup>
- );
-}
-
-function BlockGambling() {
- const [dns, setBlockGambling] = useDns('blockGambling');
-
- return (
- <AriaInputGroup>
- <StyledSectionItem disabled={dns.state === 'custom'}>
- <AriaLabel>
- <IndentedValueLabel>
- {
- // TRANSLATORS: Label for settings that enables block of gamling related websites.
- messages.pgettext('vpn-settings-view', 'Gambling')
- }
- </IndentedValueLabel>
- </AriaLabel>
- <AriaInput>
- <Cell.Switch
- isOn={dns.state === 'default' && dns.defaultOptions.blockGambling}
- onChange={setBlockGambling}
- />
- </AriaInput>
- </StyledSectionItem>
- </AriaInputGroup>
- );
-}
-
-function BlockAdultContent() {
- const [dns, setBlockAdultContent] = useDns('blockAdultContent');
-
- return (
- <AriaInputGroup>
- <StyledSectionItem disabled={dns.state === 'custom'}>
- <AriaLabel>
- <IndentedValueLabel>
- {
- // TRANSLATORS: Label for settings that enables block of adult content.
- messages.pgettext('vpn-settings-view', 'Adult content')
- }
- </IndentedValueLabel>
- </AriaLabel>
- <AriaInput>
- <Cell.Switch
- isOn={dns.state === 'default' && dns.defaultOptions.blockAdultContent}
- onChange={setBlockAdultContent}
- />
- </AriaInput>
- </StyledSectionItem>
- </AriaInputGroup>
- );
-}
-
-function BlockSocialMedia() {
- const [dns, setBlockSocialMedia] = useDns('blockSocialMedia');
-
- return (
- <AriaInputGroup>
- <StyledSectionItem disabled={dns.state === 'custom'}>
- <AriaLabel>
- <IndentedValueLabel>
- {
- // TRANSLATORS: Label for settings that enables block of social media.
- messages.pgettext('vpn-settings-view', 'Social media')
- }
- </IndentedValueLabel>
- </AriaLabel>
- <AriaInput>
- <Cell.Switch
- isOn={dns.state === 'default' && dns.defaultOptions.blockSocialMedia}
- onChange={setBlockSocialMedia}
- />
- </AriaInput>
- </StyledSectionItem>
- {dns.state === 'custom' && <CustomDnsEnabledFooter />}
- </AriaInputGroup>
- );
-}
-
-function CustomDnsEnabledFooter() {
- const customDnsFeatureName = messages.pgettext('vpn-settings-view', 'Use custom DNS server');
-
- // TRANSLATORS: This is displayed when the custom DNS setting is turned on which makes the block
- // TRANSLATORS: ads/trackers settings disabled. The text enclosed in "<b></b>" will appear bold.
- // TRANSLATORS: Available placeholders:
- // TRANSLATORS: %(customDnsFeatureName)s - The name displayed next to the custom DNS toggle.
- const blockingDisabledText = messages.pgettext(
- 'vpn-settings-view',
- 'Disable <b>%(customDnsFeatureName)s</b> below to activate these settings.',
- );
-
- return (
- <Cell.CellFooter>
- <AriaDescription>
- <Cell.CellFooterText>
- {formatHtml(sprintf(blockingDisabledText, { customDnsFeatureName }))}
- </Cell.CellFooterText>
- </AriaDescription>
- </Cell.CellFooter>
- );
-}
-
-function EnableIpv6() {
- const enableIpv6 = useSelector((state) => state.settings.enableIpv6);
- const { setEnableIpv6: setEnableIpv6Impl } = useAppContext();
-
- const setEnableIpv6 = useCallback(
- async (enableIpv6: boolean) => {
- try {
- await setEnableIpv6Impl(enableIpv6);
- } catch (e) {
- const error = e as Error;
- log.error('Failed to update enable IPv6', error.message);
- }
- },
- [setEnableIpv6Impl],
- );
-
- return (
- <AriaInputGroup>
- <Cell.Container>
- <AriaLabel>
- <Cell.InputLabel>{messages.pgettext('vpn-settings-view', 'Enable IPv6')}</Cell.InputLabel>
- </AriaLabel>
- <AriaDetails>
- <InfoButton>
- <ModalMessage>
- {messages.pgettext(
- 'vpn-settings-view',
- 'When this feature is enabled, IPv6 can be used alongside IPv4 in the VPN tunnel to communicate with internet services.',
- )}
- </ModalMessage>
- <ModalMessage>
- {messages.pgettext(
- 'vpn-settings-view',
- 'IPv4 is always enabled and the majority of websites and applications use this protocol. We do not recommend enabling IPv6 unless you know you need it.',
- )}
- </ModalMessage>
- </InfoButton>
- </AriaDetails>
- <AriaInput>
- <Cell.Switch isOn={enableIpv6} onChange={setEnableIpv6} />
- </AriaInput>
- </Cell.Container>
- </AriaInputGroup>
- );
-}
-
-function KillSwitchInfo() {
- const [killSwitchInfoVisible, showKillSwitchInfo, hideKillSwitchInfo] = useBoolean(false);
-
- return (
- <>
- <Cell.CellButton onClick={showKillSwitchInfo}>
- <AriaInputGroup>
- <AriaLabel>
- <Cell.InputLabel>
- {messages.pgettext('vpn-settings-view', 'Kill switch')}
- </Cell.InputLabel>
- </AriaLabel>
- <StyledInfoIcon />
- <AriaInput>
- <Cell.Switch isOn disabled />
- </AriaInput>
- </AriaInputGroup>
- </Cell.CellButton>
- <ModalAlert
- isOpen={killSwitchInfoVisible}
- type={ModalAlertType.info}
- buttons={[
- <AppButton.BlueButton key="back" onClick={hideKillSwitchInfo}>
- {messages.gettext('Got it!')}
- </AppButton.BlueButton>,
- ]}
- close={hideKillSwitchInfo}>
- <ModalMessage>
- {messages.pgettext(
- 'vpn-settings-view',
- 'This built-in feature prevents your traffic from leaking outside of the VPN tunnel if your network suddenly stops working or if the tunnel fails, it does this by blocking your traffic until your connection is reestablished.',
- )}
- </ModalMessage>
- <ModalMessage>
- {messages.pgettext(
- 'vpn-settings-view',
- 'The difference between the Kill Switch and Lockdown Mode is that the Kill Switch will prevent any leaks from happening during automatic tunnel reconnects, software crashes and similar accidents. With Lockdown Mode enabled, you must be connected to a Mullvad VPN server to be able to reach the internet. Manually disconnecting or quitting the app will block your connection.',
- )}
- </ModalMessage>
- </ModalAlert>
- </>
- );
-}
-
-function LockdownMode() {
- const blockWhenDisconnected = useSelector((state) => state.settings.blockWhenDisconnected);
- const { setBlockWhenDisconnected: setBlockWhenDisconnectedImpl } = useAppContext();
-
- const [confirmationDialogVisible, showConfirmationDialog, hideConfirmationDialog] =
- useBoolean(false);
-
- const setBlockWhenDisconnected = useCallback(
- async (blockWhenDisconnected: boolean) => {
- try {
- await setBlockWhenDisconnectedImpl(blockWhenDisconnected);
- } catch (e) {
- const error = e as Error;
- log.error('Failed to update block when disconnected', error.message);
- }
- },
- [setBlockWhenDisconnectedImpl],
- );
-
- const setLockDownMode = useCallback(
- async (newValue: boolean) => {
- if (newValue) {
- showConfirmationDialog();
- } else {
- await setBlockWhenDisconnected(false);
- }
- },
- [setBlockWhenDisconnected, showConfirmationDialog],
- );
-
- const confirmLockdownMode = useCallback(async () => {
- hideConfirmationDialog();
- await setBlockWhenDisconnected(true);
- }, [hideConfirmationDialog, setBlockWhenDisconnected]);
-
- return (
- <>
- <AriaInputGroup>
- <Cell.Container>
- <AriaLabel>
- <Cell.InputLabel>
- {messages.pgettext('vpn-settings-view', 'Lockdown mode')}
- </Cell.InputLabel>
- </AriaLabel>
- <AriaDetails>
- <InfoButton>
- <ModalMessage>
- {messages.pgettext(
- 'vpn-settings-view',
- 'The difference between the Kill Switch and Lockdown Mode is that the Kill Switch will prevent any leaks from happening during automatic tunnel reconnects, software crashes and similar accidents.',
- )}
- </ModalMessage>
- <ModalMessage>
- {messages.pgettext(
- 'vpn-settings-view',
- 'With Lockdown Mode enabled, you must be connected to a Mullvad VPN server to be able to reach the internet. Manually disconnecting or quitting the app will block your connection.',
- )}
- </ModalMessage>
- </InfoButton>
- </AriaDetails>
- <AriaInput>
- <Cell.Switch isOn={blockWhenDisconnected} onChange={setLockDownMode} />
- </AriaInput>
- </Cell.Container>
- </AriaInputGroup>
- <ModalAlert
- isOpen={confirmationDialogVisible}
- type={ModalAlertType.caution}
- buttons={[
- <AppButton.RedButton key="confirm" onClick={confirmLockdownMode}>
- {messages.gettext('Enable anyway')}
- </AppButton.RedButton>,
- <AppButton.BlueButton key="back" onClick={hideConfirmationDialog}>
- {messages.gettext('Back')}
- </AppButton.BlueButton>,
- ]}
- close={hideConfirmationDialog}>
- <ModalMessage>
- {messages.pgettext(
- 'vpn-settings-view',
- 'Attention: enabling this will always require a Mullvad VPN connection in order to reach the internet.',
- )}
- </ModalMessage>
- <ModalMessage>
- {messages.pgettext(
- 'vpn-settings-view',
- 'The app’s built-in kill switch is always on. This setting will additionally block the internet if clicking Disconnect or Quit.',
- )}
- </ModalMessage>
- </ModalAlert>
- </>
- );
-}
-
-function TunnelProtocolSetting() {
- const tunnelProtocol = useSelector((state) =>
- mapRelaySettingsToProtocol(state.settings.relaySettings),
- );
- const relaySettingsUpdater = useRelaySettingsUpdater();
-
- const relaySettings = useSelector((state) => state.settings.relaySettings);
- const multihop = 'normal' in relaySettings ? relaySettings.normal.wireguard.useMultihop : false;
- const daita = useSelector((state) => state.settings.wireguard.daita?.enabled ?? false);
- const quantumResistant = useSelector((state) => state.settings.wireguard.quantumResistant);
- const openVpnDisabled = daita || multihop || quantumResistant;
-
- const featuresToDisableForOpenVpn = [];
- if (daita) {
- featuresToDisableForOpenVpn.push(strings.daita);
- }
- if (multihop) {
- featuresToDisableForOpenVpn.push(messages.pgettext('wireguard-settings-view', 'Multihop'));
- }
- if (quantumResistant) {
- featuresToDisableForOpenVpn.push(
- messages.pgettext('wireguard-settings-view', 'Quantum-resistant tunnel'),
- );
- }
-
- const setTunnelProtocol = useCallback(
- async (tunnelProtocol: TunnelProtocol | null) => {
- try {
- await relaySettingsUpdater((settings) => ({
- ...settings,
- tunnelProtocol: wrapConstraint(tunnelProtocol),
- }));
- } catch (e) {
- const error = e as Error;
- log.error('Failed to update tunnel protocol constraints', error.message);
- }
- },
- [relaySettingsUpdater],
- );
-
- const tunnelProtocolItems: Array<SelectorItem<TunnelProtocol>> = useMemo(
- () => [
- {
- label: strings.wireguard,
- value: 'wireguard',
- },
- {
- label: strings.openvpn,
- value: 'openvpn',
- disabled: openVpnDisabled,
- },
- ],
- [openVpnDisabled],
- );
-
- return (
- <AriaInputGroup>
- <StyledSelectorContainer>
- <Selector
- title={messages.pgettext('vpn-settings-view', 'Tunnel protocol')}
- items={tunnelProtocolItems}
- value={tunnelProtocol ?? null}
- onSelect={setTunnelProtocol}
- automaticValue={null}
- />
- </StyledSelectorContainer>
- {openVpnDisabled ? (
- <Cell.CellFooter>
- <AriaDescription>
- <Cell.CellFooterText>
- {sprintf(
- messages.pgettext(
- 'vpn-settings-view',
- 'To select %(openvpn)s, please disable these settings: %(featureList)s.',
- ),
- { openvpn: strings.openvpn, featureList: featuresToDisableForOpenVpn.join(', ') },
- )}
- </Cell.CellFooterText>
- </AriaDescription>
- </Cell.CellFooter>
- ) : null}
- </AriaInputGroup>
- );
-}
-
-function mapRelaySettingsToProtocol(relaySettings: RelaySettingsRedux) {
- if ('normal' in relaySettings) {
- const { tunnelProtocol } = relaySettings.normal;
- return tunnelProtocol === 'any' ? undefined : tunnelProtocol;
- // since the GUI doesn't display custom settings, just display the default ones.
- // If the user sets any settings, then those will be applied.
- } else if ('customTunnelEndpoint' in relaySettings) {
- return undefined;
- } else {
- throw new Error('Unknown type of relay settings.');
- }
-}
-
-function WireguardSettingsButton() {
- const history = useHistory();
- const tunnelProtocol = useSelector((state) =>
- mapRelaySettingsToProtocol(state.settings.relaySettings),
- );
-
- const navigate = useCallback(() => history.push(RoutePath.wireguardSettings), [history]);
-
- return (
- <Cell.CellNavigationButton onClick={navigate} disabled={tunnelProtocol === 'openvpn'}>
- <Cell.Label>
- {sprintf(
- // TRANSLATORS: %(wireguard)s will be replaced with the string "WireGuard"
- messages.pgettext('vpn-settings-view', '%(wireguard)s settings'),
- { wireguard: strings.wireguard },
- )}
- </Cell.Label>
- </Cell.CellNavigationButton>
- );
-}
-
-function OpenVpnSettingsButton() {
- const history = useHistory();
- const tunnelProtocol = useTunnelProtocol();
-
- const navigate = useCallback(() => history.push(RoutePath.openVpnSettings), [history]);
-
- return (
- <Cell.CellNavigationButton onClick={navigate} disabled={tunnelProtocol === 'wireguard'}>
- <Cell.Label>
- {sprintf(
- // TRANSLATORS: %(openvpn)s will be replaced with the string "OpenVPN"
- messages.pgettext('vpn-settings-view', '%(openvpn)s settings'),
- { openvpn: strings.openvpn },
- )}
- </Cell.Label>
- </Cell.CellNavigationButton>
- );
-}
-
-function IpOverrideButton() {
- const history = useHistory();
- const navigate = useCallback(() => history.push(RoutePath.settingsImport), [history]);
-
- return (
- <Cell.CellNavigationButton onClick={navigate}>
- <Cell.Label>{messages.pgettext('vpn-settings-view', 'Server IP override')}</Cell.Label>
- </Cell.CellNavigationButton>
- );
-}
diff --git a/gui/src/renderer/components/WireguardSettings.tsx b/gui/src/renderer/components/WireguardSettings.tsx
deleted file mode 100644
index b199bd5ee5..0000000000
--- a/gui/src/renderer/components/WireguardSettings.tsx
+++ /dev/null
@@ -1,490 +0,0 @@
-import { useCallback, useMemo } from 'react';
-import { sprintf } from 'sprintf-js';
-import styled from 'styled-components';
-
-import { strings } from '../../config.json';
-import {
- Constraint,
- IpVersion,
- ObfuscationType,
- wrapConstraint,
-} from '../../shared/daemon-rpc-types';
-import { messages } from '../../shared/gettext';
-import log from '../../shared/logging';
-import { removeNonNumericCharacters } from '../../shared/string-helpers';
-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 { AriaDescription, AriaInput, AriaInputGroup, AriaLabel } from './AriaGroup';
-import * as Cell from './cell';
-import Selector, { SelectorItem, SelectorWithCustomItem } from './cell/Selector';
-import { BackAction } from './KeyboardNavigation';
-import { Layout, SettingsContainer } from './Layout';
-import { ModalMessage } from './Modal';
-import {
- NavigationBar,
- NavigationContainer,
- NavigationItems,
- NavigationScrollbars,
- TitleBarItem,
-} from './NavigationBar';
-import SettingsHeader, { HeaderTitle } from './SettingsHeader';
-
-const MIN_WIREGUARD_MTU_VALUE = 1280;
-const MAX_WIREGUARD_MTU_VALUE = 1420;
-const WIREUGARD_UDP_PORTS = [51820, 53];
-
-function mapPortToSelectorItem(value: number): SelectorItem<number> {
- return { label: value.toString(), value };
-}
-
-const StyledContent = styled.div({
- display: 'flex',
- flexDirection: 'column',
- flex: 1,
- marginBottom: '2px',
-});
-
-const StyledSelectorContainer = styled.div({
- flex: 0,
-});
-
-export default function WireguardSettings() {
- const { pop } = useHistory();
-
- return (
- <BackAction action={pop}>
- <Layout>
- <SettingsContainer>
- <NavigationContainer>
- <NavigationBar>
- <NavigationItems>
- <TitleBarItem>
- {sprintf(
- // TRANSLATORS: Title label in navigation bar
- // TRANSLATORS: Available placeholders:
- // TRANSLATORS: %(wireguard)s - Will be replaced with the string "WireGuard"
- messages.pgettext('wireguard-settings-nav', '%(wireguard)s settings'),
- { wireguard: strings.wireguard },
- )}
- </TitleBarItem>
- </NavigationItems>
- </NavigationBar>
-
- <NavigationScrollbars>
- <SettingsHeader>
- <HeaderTitle>
- {sprintf(
- // TRANSLATORS: Available placeholders:
- // TRANSLATORS: %(wireguard)s - Will be replaced with the string "WireGuard"
- messages.pgettext('wireguard-settings-view', '%(wireguard)s settings'),
- { wireguard: strings.wireguard },
- )}
- </HeaderTitle>
- </SettingsHeader>
-
- <StyledContent>
- <Cell.Group>
- <PortSelector />
- </Cell.Group>
-
- <Cell.Group>
- <ObfuscationSettings />
- </Cell.Group>
-
- <Cell.Group>
- <QuantumResistantSetting />
- </Cell.Group>
-
- <Cell.Group>
- <IpVersionSetting />
- </Cell.Group>
-
- <Cell.Group>
- <MtuSetting />
- </Cell.Group>
- </StyledContent>
- </NavigationScrollbars>
- </NavigationContainer>
- </SettingsContainer>
- </Layout>
- </BackAction>
- );
-}
-
-function PortSelector() {
- const relaySettings = useSelector((state) => state.settings.relaySettings);
- const relaySettingsUpdater = useRelaySettingsUpdater();
- const allowedPortRanges = useSelector((state) => state.settings.wireguardEndpointData.portRanges);
-
- const wireguardPortItems = useMemo<Array<SelectorItem<number>>>(
- () => WIREUGARD_UDP_PORTS.map(mapPortToSelectorItem),
- [],
- );
-
- const port = useMemo(() => {
- const port = 'normal' in relaySettings ? relaySettings.normal.wireguard.port : 'any';
- return port === 'any' ? null : port;
- }, [relaySettings]);
-
- const setWireguardPort = useCallback(
- async (port: number | null) => {
- try {
- await relaySettingsUpdater((settings) => {
- settings.wireguardConstraints.port = wrapConstraint(port);
- return settings;
- });
- } catch (e) {
- const error = e as Error;
- log.error('Failed to update relay settings', error.message);
- }
- },
- [relaySettingsUpdater],
- );
-
- const parseValue = useCallback((port: string) => parseInt(port), []);
-
- const validateValue = useCallback(
- (value: number) => allowedPortRanges.some(([start, end]) => value >= start && value <= end),
- [allowedPortRanges],
- );
-
- const portRangesText = allowedPortRanges
- .map(([start, end]) => (start === end ? start : `${start}-${end}`))
- .join(', ');
-
- return (
- <AriaInputGroup>
- <StyledSelectorContainer>
- <SelectorWithCustomItem
- // TRANSLATORS: The title for the WireGuard port selector.
- title={messages.pgettext('wireguard-settings-view', 'Port')}
- items={wireguardPortItems}
- value={port}
- onSelect={setWireguardPort}
- inputPlaceholder={messages.pgettext('wireguard-settings-view', 'Port')}
- automaticValue={null}
- parseValue={parseValue}
- modifyValue={removeNonNumericCharacters}
- validateValue={validateValue}
- maxLength={5}
- details={
- <>
- <ModalMessage>
- {messages.pgettext(
- 'wireguard-settings-view',
- 'The automatic setting will randomly choose from the valid port ranges shown below.',
- )}
- </ModalMessage>
- <ModalMessage>
- {sprintf(
- messages.pgettext(
- 'wireguard-settings-view',
- 'The custom port can be any value inside the valid ranges: %(portRanges)s.',
- ),
- { portRanges: portRangesText },
- )}
- </ModalMessage>
- </>
- }
- />
- </StyledSelectorContainer>
- </AriaInputGroup>
- );
-}
-
-function ObfuscationSettings() {
- const { setObfuscationSettings } = useAppContext();
- const obfuscationSettings = useSelector((state) => state.settings.obfuscationSettings);
-
- // TRANSLATORS: Text showing currently selected port.
- // TRANSLATORS: Available placeholders:
- // TRANSLATORS: %(port)s - Can be either a number between 1 and 65000 or the text "Automatic".
- const subLabelTemplate = messages.pgettext('wireguard-settings-view', 'Port: %(port)s');
-
- const obfuscationType = obfuscationSettings.selectedObfuscation;
- const obfuscationTypeItems: SelectorItem<ObfuscationType>[] = useMemo(
- () => [
- {
- label: messages.pgettext('wireguard-settings-view', 'Shadowsocks'),
- subLabel: sprintf(subLabelTemplate, {
- port: formatPortForSubLabel(obfuscationSettings.shadowsocksSettings.port),
- }),
- value: ObfuscationType.shadowsocks,
- details: {
- path: RoutePath.shadowsocks,
- ariaLabel: messages.pgettext('accessibility', 'Shadowsocks settings'),
- },
- },
- {
- label: messages.pgettext('wireguard-settings-view', 'UDP-over-TCP'),
- subLabel: sprintf(subLabelTemplate, {
- port: formatPortForSubLabel(obfuscationSettings.udp2tcpSettings.port),
- }),
- value: ObfuscationType.udp2tcp,
- details: {
- path: RoutePath.udpOverTcp,
- ariaLabel: messages.pgettext('accessibility', 'UDP-over-TCP settings'),
- },
- },
- {
- label: messages.gettext('Off'),
- value: ObfuscationType.off,
- },
- ],
- [
- obfuscationSettings.shadowsocksSettings.port,
- obfuscationSettings.udp2tcpSettings.port,
- subLabelTemplate,
- ],
- );
-
- const selectObfuscationType = useCallback(
- async (value: ObfuscationType) => {
- await setObfuscationSettings({
- ...obfuscationSettings,
- selectedObfuscation: value,
- });
- },
- [setObfuscationSettings, obfuscationSettings],
- );
-
- return (
- <AriaInputGroup>
- <StyledSelectorContainer>
- <Selector
- // TRANSLATORS: The title for the WireGuard obfuscation selector.
- title={messages.pgettext('wireguard-settings-view', 'Obfuscation')}
- details={
- <ModalMessage>
- {messages.pgettext(
- 'wireguard-settings-view',
- 'Obfuscation hides the WireGuard traffic inside another protocol. It can be used to help circumvent censorship and other types of filtering, where a plain WireGuard connect would be blocked.',
- )}
- </ModalMessage>
- }
- items={obfuscationTypeItems}
- value={obfuscationType}
- onSelect={selectObfuscationType}
- automaticValue={ObfuscationType.auto}
- automaticTestId="automatic-obfuscation"
- />
- </StyledSelectorContainer>
- </AriaInputGroup>
- );
-}
-
-function formatPortForSubLabel(port: Constraint<number>): string {
- return port === 'any' ? messages.gettext('Automatic') : `${port.only}`;
-}
-
-function IpVersionSetting() {
- const relaySettingsUpdater = useRelaySettingsUpdater();
- const relaySettings = useSelector((state) => state.settings.relaySettings);
- const ipVersion = useMemo(() => {
- const ipVersion = 'normal' in relaySettings ? relaySettings.normal.wireguard.ipVersion : 'any';
- return ipVersion === 'any' ? null : ipVersion;
- }, [relaySettings]);
-
- const ipVersionItems: SelectorItem<IpVersion>[] = useMemo(
- () => [
- {
- label: messages.gettext('IPv4'),
- value: 'ipv4',
- },
- {
- label: messages.gettext('IPv6'),
- value: 'ipv6',
- },
- ],
- [],
- );
-
- const setIpVersion = useCallback(
- async (ipVersion: IpVersion | null) => {
- try {
- await relaySettingsUpdater((settings) => {
- settings.wireguardConstraints.ipVersion = wrapConstraint(ipVersion);
- return settings;
- });
- } catch (e) {
- const error = e as Error;
- log.error('Failed to update relay settings', error.message);
- }
- },
- [relaySettingsUpdater],
- );
-
- return (
- <AriaInputGroup>
- <StyledSelectorContainer>
- <Selector
- // TRANSLATORS: The title for the WireGuard IP version selector.
- title={messages.pgettext('wireguard-settings-view', 'IP version')}
- items={ipVersionItems}
- value={ipVersion}
- onSelect={setIpVersion}
- automaticValue={null}
- />
- </StyledSelectorContainer>
- <Cell.CellFooter>
- <AriaDescription>
- <Cell.CellFooterText>
- {sprintf(
- // TRANSLATORS: The hint displayed below the WireGuard IP version selector.
- // TRANSLATORS: Available placeholders:
- // TRANSLATORS: %(wireguard)s - Will be replaced with the string "WireGuard"
- messages.pgettext(
- 'wireguard-settings-view',
- 'This allows access to %(wireguard)s for devices that only support IPv6.',
- ),
- { wireguard: strings.wireguard },
- )}
- </Cell.CellFooterText>
- </AriaDescription>
- </Cell.CellFooter>
- </AriaInputGroup>
- );
-}
-
-function mtuIsValid(mtu: string): boolean {
- const parsedMtu = mtu ? parseInt(mtu) : undefined;
- return (
- parsedMtu === undefined ||
- (parsedMtu >= MIN_WIREGUARD_MTU_VALUE && parsedMtu <= MAX_WIREGUARD_MTU_VALUE)
- );
-}
-
-function MtuSetting() {
- const { setWireguardMtu: setWireguardMtuImpl } = useAppContext();
- const mtu = useSelector((state) => state.settings.wireguard.mtu);
-
- const setMtu = useCallback(
- async (mtu?: number) => {
- try {
- await setWireguardMtuImpl(mtu);
- } catch (e) {
- const error = e as Error;
- log.error('Failed to update mtu value', error.message);
- }
- },
- [setWireguardMtuImpl],
- );
-
- const onSubmit = useCallback(
- async (value: string) => {
- const parsedValue = value === '' ? undefined : parseInt(value, 10);
- if (mtuIsValid(value)) {
- await setMtu(parsedValue);
- }
- },
- [setMtu],
- );
-
- return (
- <AriaInputGroup>
- <Cell.Container>
- <AriaLabel>
- <Cell.InputLabel>{messages.pgettext('wireguard-settings-view', 'MTU')}</Cell.InputLabel>
- </AriaLabel>
- <AriaInput>
- <Cell.AutoSizingTextInput
- initialValue={mtu ? mtu.toString() : ''}
- inputMode={'numeric'}
- maxLength={4}
- placeholder={messages.gettext('Default')}
- onSubmitValue={onSubmit}
- validateValue={mtuIsValid}
- submitOnBlur={true}
- modifyValue={removeNonNumericCharacters}
- />
- </AriaInput>
- </Cell.Container>
- <Cell.CellFooter>
- <AriaDescription>
- <Cell.CellFooterText>
- {sprintf(
- // TRANSLATORS: The hint displayed below the WireGuard MTU input field.
- // TRANSLATORS: Available placeholders:
- // TRANSLATORS: %(wireguard)s - Will be replaced with the string "WireGuard"
- // TRANSLATORS: %(max)d - the maximum possible wireguard mtu value
- // TRANSLATORS: %(min)d - the minimum possible wireguard mtu value
- messages.pgettext(
- 'wireguard-settings-view',
- 'Set %(wireguard)s MTU value. Valid range: %(min)d - %(max)d.',
- ),
- {
- wireguard: strings.wireguard,
- min: MIN_WIREGUARD_MTU_VALUE,
- max: MAX_WIREGUARD_MTU_VALUE,
- },
- )}
- </Cell.CellFooterText>
- </AriaDescription>
- </Cell.CellFooter>
- </AriaInputGroup>
- );
-}
-
-function QuantumResistantSetting() {
- const { setWireguardQuantumResistant } = useAppContext();
- const quantumResistant = useSelector((state) => state.settings.wireguard.quantumResistant);
-
- const items: SelectorItem<boolean>[] = useMemo(
- () => [
- {
- label: messages.gettext('On'),
- value: true,
- },
- {
- label: messages.gettext('Off'),
- value: false,
- },
- ],
- [],
- );
-
- const selectQuantumResistant = useCallback(
- async (quantumResistant: boolean | null) => {
- await setWireguardQuantumResistant(quantumResistant ?? undefined);
- },
- [setWireguardQuantumResistant],
- );
-
- return (
- <AriaInputGroup>
- <StyledSelectorContainer>
- <Selector
- title={
- // TRANSLATORS: The title for the WireGuard quantum resistance selector. This setting
- // TRANSLATORS: makes the cryptography resistant to the future abilities of quantum
- // TRANSLATORS: computers.
- messages.pgettext('wireguard-settings-view', 'Quantum-resistant tunnel')
- }
- details={
- <>
- <ModalMessage>
- {messages.pgettext(
- 'wireguard-settings-view',
- 'This feature makes the WireGuard tunnel resistant to potential attacks from quantum computers.',
- )}
- </ModalMessage>
- <ModalMessage>
- {messages.pgettext(
- 'wireguard-settings-view',
- 'It does this by performing an extra key exchange using a quantum safe algorithm and mixing the result into WireGuard’s regular encryption. This extra step uses approximately 500 kiB of traffic every time a new tunnel is established.',
- )}
- </ModalMessage>
- </>
- }
- items={items}
- value={quantumResistant ?? null}
- onSelect={selectQuantumResistant}
- automaticValue={null}
- />
- </StyledSelectorContainer>
- </AriaInputGroup>
- );
-}
diff --git a/gui/src/renderer/components/YellowLabel.tsx b/gui/src/renderer/components/YellowLabel.tsx
deleted file mode 100644
index 55059e223f..0000000000
--- a/gui/src/renderer/components/YellowLabel.tsx
+++ /dev/null
@@ -1,18 +0,0 @@
-import styled from 'styled-components';
-
-import { colors } from '../../config.json';
-
-export default styled.span({
- display: 'inline-block',
- fontFamily: 'Open Sans',
- color: colors.blue,
- fontSize: '12px',
- fontWeight: 800,
- lineHeight: '20px',
- padding: '1px 8px',
- marginLeft: '8px',
- background: colors.yellow,
- borderRadius: '5px',
- textAlign: 'center',
- verticalAlign: 'middle',
-});
diff --git a/gui/src/renderer/components/cell/CellButton.tsx b/gui/src/renderer/components/cell/CellButton.tsx
deleted file mode 100644
index cc7a6e1015..0000000000
--- a/gui/src/renderer/components/cell/CellButton.tsx
+++ /dev/null
@@ -1,67 +0,0 @@
-import React, { useContext } from 'react';
-import styled from 'styled-components';
-
-import { colors } from '../../../config.json';
-import { CellDisabledContext } from './Container';
-import { Icon } from './Label';
-import { Row } from './Row';
-import { CellSectionContext } from './Section';
-
-interface IStyledCellButtonProps extends React.HTMLAttributes<HTMLButtonElement> {
- $selected?: boolean;
- $containedInSection: boolean;
-}
-
-const StyledCellButton = styled(Row)<IStyledCellButtonProps>((props) => {
- const backgroundColor = props.$selected
- ? colors.green
- : props.$containedInSection
- ? colors.blue40
- : colors.blue;
- const backgroundColorHover = props.$selected ? colors.green : colors.blue80;
-
- return {
- paddingRight: '16px',
- flex: 1,
- alignContent: 'center',
- cursor: 'default',
- border: 'none',
- backgroundColor,
- '&&:not(:disabled):hover': {
- backgroundColor: props.onClick ? backgroundColorHover : backgroundColor,
- },
- };
-});
-
-interface ICellButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
- selected?: boolean;
-}
-
-export const CellButton = styled(
- React.forwardRef(function Button(props: ICellButtonProps, ref: React.Ref<HTMLButtonElement>) {
- const { selected, ...otherProps } = props;
- const containedInSection = useContext(CellSectionContext);
- return (
- <CellDisabledContext.Provider value={props.disabled ?? false}>
- <StyledCellButton
- as="button"
- ref={ref}
- $selected={selected}
- $containedInSection={containedInSection}
- {...otherProps}
- />
- </CellDisabledContext.Provider>
- );
- }),
-)({});
-
-export function CellNavigationButton(props: ICellButtonProps) {
- const { children, ...otherProps } = props;
-
- return (
- <CellButton {...otherProps}>
- {children}
- <Icon height={12} width={7} source="icon-chevron" />
- </CellButton>
- );
-}
diff --git a/gui/src/renderer/components/cell/Container.tsx b/gui/src/renderer/components/cell/Container.tsx
deleted file mode 100644
index e35babf790..0000000000
--- a/gui/src/renderer/components/cell/Container.tsx
+++ /dev/null
@@ -1,26 +0,0 @@
-import React from 'react';
-import styled from 'styled-components';
-
-import { Row } from './Row';
-
-const StyledContainer = styled(Row)({
- paddingRight: '16px',
-});
-
-export const CellDisabledContext = React.createContext<boolean>(false);
-
-interface IContainerProps extends React.HTMLAttributes<HTMLDivElement> {
- disabled?: boolean;
-}
-
-export const Container = React.forwardRef(function ContainerT(
- props: IContainerProps,
- ref: React.Ref<HTMLDivElement>,
-) {
- const { disabled, ...otherProps } = props;
- return (
- <CellDisabledContext.Provider value={disabled ?? false}>
- <StyledContainer ref={ref} {...otherProps} />
- </CellDisabledContext.Provider>
- );
-});
diff --git a/gui/src/renderer/components/cell/Footer.tsx b/gui/src/renderer/components/cell/Footer.tsx
deleted file mode 100644
index 86487c676c..0000000000
--- a/gui/src/renderer/components/cell/Footer.tsx
+++ /dev/null
@@ -1,16 +0,0 @@
-import styled from 'styled-components';
-
-import { colors } from '../../../config.json';
-import { measurements, tinyText } from '../common-styles';
-
-export const CellFooter = styled.div({
- padding: `6px ${measurements.viewMargin} 0px`,
-});
-
-export const CellFooterText = styled.span(tinyText, {
- color: colors.white60,
-});
-
-export const CellFooterBoldText = styled(CellFooterText)({
- fontWeight: 900,
-});
diff --git a/gui/src/renderer/components/cell/Group.tsx b/gui/src/renderer/components/cell/Group.tsx
deleted file mode 100644
index 86d95f0a33..0000000000
--- a/gui/src/renderer/components/cell/Group.tsx
+++ /dev/null
@@ -1,14 +0,0 @@
-import styled from 'styled-components';
-
-import { measurements } from '../common-styles';
-
-interface IStyledGroupProps {
- $noMarginBottom?: boolean;
-}
-
-export const Group = styled.div<IStyledGroupProps>((props) => ({
- display: 'flex',
- flexDirection: 'column',
- flex: 1,
- marginBottom: props.$noMarginBottom ? '0px' : measurements.rowVerticalMargin,
-}));
diff --git a/gui/src/renderer/components/cell/Input.tsx b/gui/src/renderer/components/cell/Input.tsx
deleted file mode 100644
index de2649e3c6..0000000000
--- a/gui/src/renderer/components/cell/Input.tsx
+++ /dev/null
@@ -1,409 +0,0 @@
-import React, { useCallback, useContext, useEffect, useState } from 'react';
-import styled from 'styled-components';
-
-import { colors } from '../../../config.json';
-import { useBoolean, useCombinedRefs, useEffectEvent, useStyledRef } from '../../lib/utility-hooks';
-import { normalText } from '../common-styles';
-import ImageView from '../ImageView';
-import { BackAction } from '../KeyboardNavigation';
-import StandaloneSwitch from '../Switch';
-import { CellDisabledContext, Container } from './Container';
-
-export const Switch = React.forwardRef(function SwitchT(
- props: StandaloneSwitch['props'],
- ref: React.Ref<StandaloneSwitch>,
-) {
- const disabled = useContext(CellDisabledContext);
- return <StandaloneSwitch ref={ref} disabled={disabled} {...props} />;
-});
-
-const inputTextStyles: React.CSSProperties = {
- ...normalText,
- height: '18px',
- textAlign: 'right',
- padding: '0px',
-};
-
-const StyledInput = styled.input<{ $focused: boolean; $valid?: boolean }>((props) => ({
- ...inputTextStyles,
- backgroundColor: 'transparent',
- border: 'none',
- width: '100%',
- height: '100%',
- color: props.$valid === false ? colors.red : props.$focused ? colors.blue : colors.white,
- '&&::placeholder': {
- color: props.$focused ? colors.blue60 : colors.white60,
- },
-}));
-
-interface IInputProps extends React.InputHTMLAttributes<HTMLInputElement> {
- value?: string;
- initialValue?: string;
- validateValue?: (value: string) => boolean;
- modifyValue?: (value: string) => string;
- submitOnBlur?: boolean;
- onSubmitValue?: (value: string) => void;
- onInvalidValue?: (value: string) => void;
- onChangeValue?: (value: string) => void;
-}
-
-// If value is provided this component behaves like a controlled component.
-// If value isn't provided, then initialValue will be used for the initial value, but updates to
-// initialValue will also cause the internal value to update.
-function InputWithRef(props: IInputProps, forwardedRef: React.Ref<HTMLInputElement>) {
- const {
- initialValue,
- validateValue,
- modifyValue,
- submitOnBlur,
- onSubmitValue,
- onInvalidValue,
- onChangeValue,
- onFocus: propsOnFocus,
- onBlur: propsOnBlur,
- onChange: propsOnChange,
- onKeyPress: propsOnKeyPress,
- ...otherProps
- } = props;
-
- const [isFocused, setFocused, setBlurred] = useBoolean(false);
-
- // internalValue will be used when the component is uncontrolled.
- const [internalValue, setInternalValue] = useState(props.value ?? props.initialValue ?? '');
- const value = props.value ?? internalValue;
-
- const inputRef = useStyledRef<HTMLInputElement>();
- const combinedRef = useCombinedRefs(inputRef, forwardedRef);
-
- const onSubmit = useCallback(
- (value: string) => {
- if (validateValue?.(value) !== false) {
- onSubmitValue?.(value);
- } else {
- onInvalidValue?.(value);
- }
- },
- [validateValue, onSubmitValue, onInvalidValue],
- );
-
- const onFocus = useCallback(
- (event: React.FocusEvent<HTMLInputElement>) => {
- setFocused();
- propsOnFocus?.(event);
- },
- [propsOnFocus, setFocused],
- );
-
- const onBlur = useCallback(
- (event: React.FocusEvent<HTMLInputElement>) => {
- setBlurred();
- propsOnBlur?.(event);
- if (submitOnBlur) {
- onSubmit(value);
- }
- },
- [setBlurred, propsOnBlur, submitOnBlur, onSubmit, value],
- );
-
- const onChange = useCallback(
- (event: React.ChangeEvent<HTMLInputElement>) => {
- const value = modifyValue?.(event.target.value) ?? event.target.value;
- if (props.value === undefined) {
- // Only update the internal value when in uncontrolled mode to not cause unnecessary render
- // cycles.
- setInternalValue(value);
- }
-
- propsOnChange?.(event);
- onChangeValue?.(value);
- },
- [modifyValue, onChangeValue, props.value, propsOnChange],
- );
-
- const onKeyPress = useCallback(
- (event: React.KeyboardEvent<HTMLInputElement>) => {
- if (event.key === 'Enter') {
- onSubmit(value);
- inputRef.current?.blur();
- }
- propsOnKeyPress?.(event);
- },
- [value, onSubmit, inputRef, propsOnKeyPress],
- );
-
- const handleInitialValueChange = useEffectEvent((initialValue?: string) => {
- if (
- !isFocused &&
- props.value === undefined &&
- initialValue !== undefined &&
- internalValue !== initialValue
- ) {
- setInternalValue(initialValue);
- onChangeValue?.(initialValue);
- }
- });
-
- // If the the initialValue changes in the uncontrolled mode when the user isn't currently writing,
- // then we want to update the value.
- useEffect(() => {
- handleInitialValueChange(props.initialValue);
- }, [props.initialValue]);
-
- const valid = validateValue?.(value);
-
- return (
- <CellDisabledContext.Consumer>
- {(disabled) => (
- <StyledInput
- {...otherProps}
- ref={combinedRef}
- type="text"
- $valid={valid}
- $focused={isFocused}
- aria-invalid={!valid}
- onChange={onChange}
- onFocus={onFocus}
- onBlur={onBlur}
- onKeyPress={onKeyPress}
- value={value}
- disabled={disabled}
- />
- )}
- </CellDisabledContext.Consumer>
- );
-}
-
-export const Input = React.memo(React.forwardRef(InputWithRef));
-
-const InputFrame = styled.div<{ $focused: boolean }>((props) => ({
- display: 'flex',
- flexGrow: 0,
- backgroundColor: props.$focused ? colors.white : 'rgba(255,255,255,0.1)',
- borderRadius: '4px',
- padding: '6px 8px',
-}));
-
-const StyledAutoSizingTextInputContainer = styled.div({
- position: 'relative',
-});
-
-const StyledAutoSizingTextInputFiller = styled.pre({
- ...inputTextStyles,
- minWidth: '80px',
- color: 'transparent',
-});
-
-const StyledAutoSizingTextInputWrapper = styled.div({
- position: 'absolute',
- top: '0px',
- left: '0px',
- width: '100%',
- height: '100%',
-});
-
-function AutoSizingTextInputWithRef(props: IInputProps, forwardedRef: React.Ref<HTMLInputElement>) {
- const { onFocus, onBlur, ...otherProps } = props;
-
- const [focused, setFocused, setBlurred] = useBoolean(false);
- const inputRef = useStyledRef<HTMLInputElement>();
- const combinedRef = useCombinedRefs(inputRef, forwardedRef);
-
- const onBlurWrapper = useCallback(
- (event: React.FocusEvent<HTMLInputElement>) => {
- setBlurred();
- onBlur?.(event);
- },
- [onBlur, setBlurred],
- );
-
- const onFocusWrapper = useCallback(
- (event: React.FocusEvent<HTMLInputElement>) => {
- setFocused();
- onFocus?.(event);
- },
- [onFocus, setFocused],
- );
-
- const blur = useCallback(() => inputRef.current?.blur(), [inputRef]);
-
- const value = inputRef.current?.value;
-
- return (
- <BackAction disabled={!focused} action={blur}>
- <InputFrame $focused={focused}>
- <StyledAutoSizingTextInputContainer>
- <StyledAutoSizingTextInputWrapper>
- <Input
- ref={combinedRef}
- onBlur={onBlurWrapper}
- onFocus={onFocusWrapper}
- {...otherProps}
- />
- </StyledAutoSizingTextInputWrapper>
- <StyledAutoSizingTextInputFiller className={otherProps.className} aria-hidden={true}>
- {value === '' ? otherProps.placeholder : value}
- </StyledAutoSizingTextInputFiller>
- </StyledAutoSizingTextInputContainer>
- </InputFrame>
- </BackAction>
- );
-}
-
-export const AutoSizingTextInput = React.memo(React.forwardRef(AutoSizingTextInputWithRef));
-
-const StyledCellInputRowContainer = styled(Container)({
- backgroundColor: 'white',
- marginBottom: '1px',
-});
-
-const StyledSubmitButton = styled.button({
- border: 'none',
- backgroundColor: 'transparent',
- padding: '10px 0',
-});
-
-const StyledInputWrapper = styled.div<{ $marginLeft: number }>(normalText, (props) => ({
- position: 'relative',
- flex: 1,
- width: '171px',
- marginLeft: props.$marginLeft + 'px',
- lineHeight: '24px',
- minHeight: '24px',
- fontWeight: 400,
- padding: '10px 0',
- maxWidth: '100%',
-}));
-
-const StyledTextArea = styled.textarea<{ $invalid?: boolean }>(normalText, (props) => ({
- position: 'absolute',
- top: 0,
- left: 0,
- width: '100%',
- height: '100%',
- backgroundColor: 'transparent',
- border: 'none',
- flex: 1,
- lineHeight: '24px',
- fontWeight: 400,
- resize: 'none',
- padding: '10px 25px 10px 0',
- color: props.$invalid ? colors.red : 'auto',
-}));
-
-const StyledInputFiller = styled.div({
- whiteSpace: 'pre-wrap',
- overflowWrap: 'break-word',
- minHeight: '24px',
- color: 'transparent',
- marginRight: '25px',
-});
-
-interface IRowInputProps {
- initialValue?: string;
- onChange?: (value: string) => void;
- onSubmit: (value: string) => void;
- onFocus?: (event: React.FocusEvent<HTMLTextAreaElement>) => void;
- onBlur?: (event?: React.FocusEvent<HTMLTextAreaElement>) => void;
- paddingLeft?: number;
- invalid?: boolean;
- autofocus?: boolean;
- placeholder?: string;
-}
-
-export function RowInput(props: IRowInputProps) {
- const { onSubmit, onChange: propsOnChange, onFocus: propsOnFocus, onBlur: propsOnBlur } = props;
-
- const [value, setValue] = useState(props.initialValue ?? '');
- const textAreaRef = useStyledRef<HTMLTextAreaElement>();
- const [focused, setFocused, setBlurred] = useBoolean(false);
-
- const submit = useCallback(() => onSubmit(value), [onSubmit, value]);
- const onChange = useCallback(
- (event: React.ChangeEvent<HTMLTextAreaElement>) => {
- const value = event.target.value;
- setValue(value);
- propsOnChange?.(value);
- },
- [propsOnChange],
- );
- const onKeyDown = useCallback(
- (event: React.KeyboardEvent<HTMLTextAreaElement>) => {
- if (event.key === 'Enter') {
- event.preventDefault();
- submit();
- }
- },
- [submit],
- );
-
- const onFocus = useCallback(
- (event: React.FocusEvent<HTMLTextAreaElement>) => {
- setFocused();
- propsOnFocus?.(event);
- },
- [propsOnFocus, setFocused],
- );
- const onBlur = useCallback(
- (event: React.FocusEvent<HTMLTextAreaElement>) => {
- setBlurred();
- propsOnBlur?.(event);
- },
- [propsOnBlur, setBlurred],
- );
-
- const focus = useCallback(() => {
- const input = textAreaRef.current;
- if (input) {
- input.focus();
- // eslint-disable-next-line react-compiler/react-compiler
- input.selectionStart = input.selectionEnd = value.length;
- }
- }, [textAreaRef, value.length]);
-
- const blur = useCallback(() => textAreaRef.current?.blur(), [textAreaRef]);
-
- const focusOnMount = useEffectEvent(() => {
- if (props.autofocus) {
- focus();
- }
- });
-
- useEffect(() => {
- focusOnMount();
- }, []);
-
- useEffect(() => {
- if (props.invalid) {
- focus();
- }
- }, [props.invalid, focus]);
-
- return (
- <BackAction disabled={!focused} action={blur}>
- <StyledCellInputRowContainer>
- <StyledInputWrapper $marginLeft={props.paddingLeft ?? 0}>
- <StyledInputFiller>{value}</StyledInputFiller>
- <StyledTextArea
- ref={textAreaRef}
- onChange={onChange}
- onKeyDown={onKeyDown}
- rows={1}
- value={value}
- $invalid={props.invalid}
- onFocus={onFocus}
- onBlur={onBlur}
- placeholder={props.placeholder}
- />
- </StyledInputWrapper>
- <StyledSubmitButton onClick={submit}>
- <ImageView
- source="icon-check"
- height={18}
- tintColor={value === '' ? colors.blue60 : colors.blue}
- tintHoverColor={value === '' ? colors.blue60 : colors.blue80}
- />
- </StyledSubmitButton>
- </StyledCellInputRowContainer>
- </BackAction>
- );
-}
diff --git a/gui/src/renderer/components/cell/Label.tsx b/gui/src/renderer/components/cell/Label.tsx
deleted file mode 100644
index b2b37c1e4c..0000000000
--- a/gui/src/renderer/components/cell/Label.tsx
+++ /dev/null
@@ -1,109 +0,0 @@
-import React, { useContext } from 'react';
-import styled from 'styled-components';
-
-import { colors } from '../../../config.json';
-import { buttonText, normalText, tinyText } from '../common-styles';
-import ImageView, { IImageViewProps } from '../ImageView';
-import { CellButton } from './CellButton';
-import { CellDisabledContext } from './Container';
-
-const StyledLabel = styled.div<{ disabled: boolean }>(buttonText, (props) => ({
- display: 'flex',
- margin: '10px 0',
- flex: 1,
- color: props.disabled ? colors.white40 : colors.white,
- textAlign: 'left',
-
- [`${LabelContainer} &&`]: {
- marginTop: '0px',
- marginBottom: 0,
- height: '20px',
- lineHeight: '20px',
- },
-
- [`${LabelContainer}:has(${StyledSubLabel}) &&`]: {
- marginTop: '5px',
- },
-}));
-
-const StyledSubText = styled.span<{ disabled: boolean }>(tinyText, (props) => ({
- color: props.disabled ? colors.white20 : colors.white60,
- flex: -1,
- textAlign: 'right',
- marginLeft: '8px',
- marginRight: '8px',
-}));
-
-const StyledIconContainer = styled.div<{ disabled: boolean }>((props) => ({
- opacity: props.disabled ? 0.4 : 1,
-}));
-
-const StyledTintedIcon = styled(ImageView).attrs((props: IImageViewProps) => ({
- tintColor: props.tintColor ?? colors.white60,
- tintHoverColor: props.tintHoverColor ?? props.tintColor ?? colors.white60,
-}))((props: IImageViewProps) => ({
- '&&:hover': {
- backgroundColor: props.tintHoverColor,
- },
- [`${CellButton}:not(:disabled):hover &&`]: {
- backgroundColor: props.tintHoverColor,
- },
-}));
-
-const StyledSubLabel = styled.div<{ disabled: boolean }>(tinyText, {
- display: 'flex',
- alignItems: 'center',
- color: colors.white60,
- marginBottom: '5px',
- lineHeight: '14px',
- height: '14px',
-});
-
-export const LabelContainer = styled.div({
- display: 'flex',
- flexDirection: 'column',
- flex: 1,
- minWidth: 0,
-});
-
-export function Label(props: React.HTMLAttributes<HTMLDivElement>) {
- const disabled = useContext(CellDisabledContext);
- return <StyledLabel disabled={disabled} {...props} />;
-}
-
-export function InputLabel(props: React.LabelHTMLAttributes<HTMLLabelElement>) {
- const disabled = useContext(CellDisabledContext);
- return <StyledLabel as="label" disabled={disabled} {...props} />;
-}
-
-export const ValueLabel = styled(Label)(normalText, {
- fontWeight: 400,
-});
-
-export function SubText(props: React.HTMLAttributes<HTMLDivElement>) {
- const disabled = useContext(CellDisabledContext);
- return <StyledSubText disabled={disabled} {...props} />;
-}
-
-export function UntintedIcon(props: IImageViewProps) {
- const disabled = useContext(CellDisabledContext);
- return (
- <StyledIconContainer disabled={disabled}>
- <ImageView {...props} />
- </StyledIconContainer>
- );
-}
-
-export function Icon(props: IImageViewProps) {
- const disabled = useContext(CellDisabledContext);
- return (
- <StyledIconContainer disabled={disabled}>
- <StyledTintedIcon {...props} />
- </StyledIconContainer>
- );
-}
-
-export function SubLabel(props: React.HTMLAttributes<HTMLDivElement>) {
- const disabled = useContext(CellDisabledContext);
- return <StyledSubLabel disabled={disabled} {...props} />;
-}
diff --git a/gui/src/renderer/components/cell/Row.tsx b/gui/src/renderer/components/cell/Row.tsx
deleted file mode 100644
index 9aca25d3a0..0000000000
--- a/gui/src/renderer/components/cell/Row.tsx
+++ /dev/null
@@ -1,25 +0,0 @@
-import React from 'react';
-import styled from 'styled-components';
-
-import { colors } from '../../../config.json';
-import { measurements } from '../common-styles';
-import { Group } from './Group';
-
-interface RowProps extends React.HTMLAttributes<HTMLDivElement> {
- includeMarginBottomOnLast?: boolean;
-}
-
-export const Row = styled.div.withConfig({
- shouldForwardProp: (prop) => prop !== 'includeMarginBottomOnLast',
-})<RowProps>((props) => ({
- display: 'flex',
- alignItems: 'center',
- backgroundColor: colors.blue,
- minHeight: measurements.rowMinHeight,
- paddingLeft: measurements.viewMargin,
- paddingRight: measurements.viewMargin,
- marginBottom: '1px',
- [`${Group} > &&:last-child`]: {
- marginBottom: props.includeMarginBottomOnLast ? '1px' : '0px',
- },
-}));
diff --git a/gui/src/renderer/components/cell/Section.tsx b/gui/src/renderer/components/cell/Section.tsx
deleted file mode 100644
index b7465ecb93..0000000000
--- a/gui/src/renderer/components/cell/Section.tsx
+++ /dev/null
@@ -1,95 +0,0 @@
-import React, { useEffect } from 'react';
-import styled from 'styled-components';
-
-import { colors } from '../../../config.json';
-import { useAppContext } from '../../context';
-import { useHistory } from '../../lib/history';
-import { useBoolean, useEffectEvent } from '../../lib/utility-hooks';
-import Accordion from '../Accordion';
-import ChevronButton from '../ChevronButton';
-import { buttonText, openSans, sourceSansPro } from '../common-styles';
-import { Container } from './Container';
-import { Row } from './Row';
-
-const StyledSection = styled.div({
- display: 'flex',
- flexDirection: 'column',
-});
-
-interface SectionTitleProps {
- disabled?: boolean;
- $thin?: boolean;
-}
-
-export const SectionTitle = styled(Row)<SectionTitleProps>(buttonText, (props) => ({
- paddingRight: '16px',
- color: props.disabled ? colors.white20 : colors.white,
- fontWeight: props.$thin ? 400 : 600,
- fontSize: props.$thin ? '15px' : '18px',
- ...(props.$thin ? openSans : sourceSansPro),
-}));
-
-export const CellSectionContext = React.createContext<boolean>(false);
-
-interface SectionProps extends React.HTMLAttributes<HTMLDivElement> {
- sectionTitle?: React.ReactElement;
-}
-
-export function Section(props: SectionProps) {
- const { children, sectionTitle, ...otherProps } = props;
- return (
- <StyledSection {...otherProps}>
- <CellSectionContext.Provider value={true}>
- {sectionTitle && <StyledTitleContainer>{sectionTitle}</StyledTitleContainer>}
- {children}
- </CellSectionContext.Provider>
- </StyledSection>
- );
-}
-
-const StyledChevronButton = styled(ChevronButton)({
- padding: 0,
- marginRight: '16px',
-});
-
-const StyledTitleContainer = styled(Container)({
- display: 'flex',
- padding: 0,
-});
-
-interface ExpandableSectionProps extends SectionProps {
- expandableId: string;
- expandedInitially?: boolean;
-}
-
-export function ExpandableSection(props: ExpandableSectionProps) {
- const { expandableId, expandedInitially, sectionTitle, ...otherProps } = props;
-
- const history = useHistory();
- const { setNavigationHistory } = useAppContext();
- const expandedValue =
- history.location.state.expandedSections[props.expandableId] ?? !!expandedInitially;
- const [expanded, , , toggleExpanded] = useBoolean(expandedValue);
-
- const updateHistory = useEffectEvent((expanded: boolean) => {
- history.recordSectionExpandedState(props.expandableId, expanded);
- setNavigationHistory(history.asObject);
- });
-
- useEffect(() => {
- updateHistory(expanded);
- }, [expanded]);
-
- const title = (
- <>
- {sectionTitle}
- <StyledChevronButton up={expanded} onClick={toggleExpanded} />
- </>
- );
-
- return (
- <Section className={props.className} sectionTitle={title} {...otherProps}>
- <Accordion expanded={expanded}>{props.children}</Accordion>
- </Section>
- );
-}
diff --git a/gui/src/renderer/components/cell/Selector.tsx b/gui/src/renderer/components/cell/Selector.tsx
deleted file mode 100644
index b1e20a0c41..0000000000
--- a/gui/src/renderer/components/cell/Selector.tsx
+++ /dev/null
@@ -1,387 +0,0 @@
-import { useCallback, useRef, useState } from 'react';
-import styled from 'styled-components';
-
-import { colors } from '../../../config.json';
-import { messages } from '../../../shared/gettext';
-import { useHistory } from '../../lib/history';
-import { RoutePath } from '../../lib/routes';
-import { useStyledRef } from '../../lib/utility-hooks';
-import { AriaDetails, AriaInput, AriaLabel } from '../AriaGroup';
-import ImageView from '../ImageView';
-import InfoButton from '../InfoButton';
-import * as Cell from '.';
-
-const StyledTitleLabel = styled(Cell.SectionTitle)({
- flex: 1,
-});
-
-export interface SelectorItem<T> {
- label: string;
- value: T;
- disabled?: boolean;
- 'data-testid'?: string;
- details?: { path: RoutePath; ariaLabel: string };
- subLabel?: string;
-}
-
-// T represents the available values and U represent the value of "Automatic"/"Any" if there is one.
-interface CommonSelectorProps<T, U> {
- title?: string;
- items: Array<SelectorItem<T>>;
- value: T | U;
- selectedCellRef?: React.Ref<HTMLElement>;
- className?: string;
- infoTitle?: string;
- details?: React.ReactElement;
- expandable?: { expandable: boolean; id: string };
- disabled?: boolean;
- thinTitle?: boolean;
- automaticLabel?: string;
- automaticValue?: U;
- automaticTestId?: string;
- children?: React.ReactNode | Array<React.ReactNode>;
-}
-
-interface SelectorProps<T, U> extends CommonSelectorProps<T, U> {
- onSelect: (value: T | U) => void;
-}
-
-export default function Selector<T, U>(props: SelectorProps<T, U>) {
- const items = props.items.map((item) => {
- const selected = props.value === item.value;
- const ref = selected ? (props.selectedCellRef as React.Ref<HTMLButtonElement>) : undefined;
-
- return (
- <SelectorCell
- key={`value-${item.value}`}
- value={item.value}
- isSelected={selected}
- disabled={props.disabled || item.disabled}
- forwardedRef={ref}
- onSelect={props.onSelect}
- subLabel={item.subLabel}
- details={item.details}
- data-testid={item['data-testid']}>
- {item.label}
- </SelectorCell>
- );
- });
-
- if (props.automaticValue !== undefined) {
- const selected = props.value === props.automaticValue;
- const ref = selected ? (props.selectedCellRef as React.Ref<HTMLButtonElement>) : undefined;
-
- items.unshift(
- <SelectorCell
- key={'automatic'}
- data-testid={props.automaticTestId}
- value={props.automaticValue}
- isSelected={selected}
- disabled={props.disabled}
- forwardedRef={ref}
- onSelect={props.onSelect}>
- {props.automaticLabel ?? messages.gettext('Automatic')}
- </SelectorCell>,
- );
- }
-
- const title = props.title ? (
- <>
- <AriaLabel>
- <StyledTitleLabel as="label" disabled={props.disabled} $thin={props.thinTitle}>
- {props.title}
- </StyledTitleLabel>
- </AriaLabel>
- {props.details && (
- <AriaDetails>
- <InfoButton title={props.infoTitle}>{props.details}</InfoButton>
- </AriaDetails>
- )}
- </>
- ) : undefined;
-
- // Add potential additional items to the list. Used for custom entry.
- const children = (
- <Cell.Group $noMarginBottom>
- {items}
- {props.children}
- </Cell.Group>
- );
-
- if (props.expandable?.expandable) {
- return (
- <AriaInput>
- <Cell.ExpandableSection
- role="listbox"
- expandedInitially={false}
- className={props.className}
- sectionTitle={title}
- expandableId={props.expandable.id}>
- {children}
- </Cell.ExpandableSection>
- </AriaInput>
- );
- } else {
- return (
- <AriaInput>
- <Cell.Section role="listbox" className={props.className} sectionTitle={title}>
- {children}
- </Cell.Section>
- </AriaInput>
- );
- }
-}
-
-const StyledCellIcon = styled(Cell.Icon)<{ $visible: boolean }>((props) => ({
- opacity: props.$visible ? 1 : 0,
- marginRight: '8px',
-}));
-
-interface SelectorCellProps<T> {
- value: T;
- isSelected: boolean;
- disabled?: boolean;
- onSelect: (value: T) => void;
- children: string;
- subLabel?: string;
- forwardedRef?: React.Ref<HTMLButtonElement>;
- 'data-testid'?: string;
- details?: SelectorItem<unknown>['details'];
-}
-
-const StyledSelectorCell = styled.div({
- display: 'flex',
-});
-
-const StyledSideButtonImage = styled(ImageView)({
- padding: '0 3px',
-});
-
-const StyledSideButton = styled(Cell.SideButton)({
- marginBottom: '1px',
-});
-
-function SelectorCell<T>(props: SelectorCellProps<T>) {
- const { onSelect } = props;
-
- const { push } = useHistory();
-
- const handleClick = useCallback(() => {
- if (!props.isSelected) {
- onSelect(props.value);
- }
- }, [props.isSelected, onSelect, props.value]);
-
- const navigate = useCallback(() => {
- if (props.details) {
- push(props.details.path);
- }
- }, [props.details, push]);
-
- return (
- <StyledSelectorCell>
- <Cell.CellButton
- ref={props.forwardedRef}
- onClick={handleClick}
- selected={props.isSelected}
- disabled={props.disabled}
- role="option"
- aria-selected={props.isSelected}
- aria-disabled={props.disabled}
- data-testid={props['data-testid']}>
- <StyledCellIcon
- $visible={props.isSelected}
- source="icon-tick"
- width={18}
- tintColor={colors.white}
- />
- <SelectorCellLabel subLabel={props.subLabel}>{props.children}</SelectorCellLabel>
- </Cell.CellButton>
- {props.details && (
- <StyledSideButton
- $backgroundColor={colors.blue40}
- $backgroundColorHover={colors.blue80}
- aria-label={props.details.ariaLabel}
- onClick={navigate}>
- <StyledSideButtonImage
- source="icon-chevron"
- width={7}
- tintColor={colors.white}
- tintHoverColor={colors.white80}
- />
- </StyledSideButton>
- )}
- </StyledSelectorCell>
- );
-}
-
-interface SelectorCellLabelProps {
- children: string;
- subLabel?: string;
-}
-
-function SelectorCellLabel(props: SelectorCellLabelProps) {
- if (props.subLabel) {
- return (
- <Cell.LabelContainer>
- <Cell.ValueLabel>{props.children}</Cell.ValueLabel>
- {props.subLabel && <Cell.SubLabel>{props.subLabel}</Cell.SubLabel>}
- </Cell.LabelContainer>
- );
- } else {
- return <Cell.ValueLabel>{props.children}</Cell.ValueLabel>;
- }
-}
-
-interface StyledCustomContainerProps {
- selected: boolean;
-}
-
-const StyledCustomContainer = styled(Cell.Container)<StyledCustomContainerProps>((props) => ({
- backgroundColor: props.selected ? colors.green : colors.blue40,
- '&&:hover': {
- backgroundColor: props.selected ? colors.green : colors.blue,
- },
-}));
-
-// Adding undefined as possible value of the selector to be able to select nothing.
-interface SelectorWithCustomItemProps<T, U> extends CommonSelectorProps<T | undefined, U> {
- inputPlaceholder: string;
- onSelect: (value: T | U) => void;
- parseValue: (value: string) => T;
- validateValue?: (value: T) => boolean;
- maxLength?: number;
- selectedCellRef?: React.Ref<HTMLDivElement>;
- modifyValue?: (value: string) => string;
-}
-
-export function SelectorWithCustomItem<T, U>(props: SelectorWithCustomItemProps<T, U>) {
- const {
- value: _value,
- inputPlaceholder,
- onSelect,
- maxLength,
- selectedCellRef,
- validateValue,
- parseValue,
- modifyValue,
- ...otherProps
- } = props;
-
- const [value, setValue] = useState(props.value);
- // Disables submitting of custom input when another item has been pressed.
- const allowSubmitCustom = useRef(false);
-
- const isNonCustomItem = useCallback(
- (value: T | U | undefined) =>
- props.items.some((item) => item.value === value) || props.automaticValue === value,
- [props.automaticValue, props.items],
- );
-
- const itemIsSelected = isNonCustomItem(value);
- // Value of custom input. The value is undefined when custom isn't picked.
- const [customValue, setCustomValue] = useState(itemIsSelected ? undefined : `${value}`);
- const customIsSelected = customValue !== undefined;
-
- const inputRef = useStyledRef<HTMLInputElement>();
-
- const handleClickCustom = useCallback(() => {
- inputRef.current?.focus();
- // After focusing the input it should be allowed to submit custom values.
- allowSubmitCustom.current = true;
- setCustomValue((customValue) => customValue ?? '');
- }, [inputRef]);
-
- const handleSelectItem = useCallback(
- (newValue: T | U | undefined) => {
- setCustomValue(undefined);
- setValue(newValue);
- // When pressing an item the blur shouldn't be triggered since that would cause the input
- // value to be propagated as the new value.
- allowSubmitCustom.current = false;
- inputRef.current?.blur();
-
- onSelect(newValue!);
- },
- [inputRef, onSelect],
- );
-
- const validateCustomValue = useCallback(
- (value: string) => validateValue?.(parseValue(value)) ?? true,
- [parseValue, validateValue],
- );
-
- const handleSubmitCustom = useCallback(
- (newStringValue: string) => {
- if (allowSubmitCustom.current) {
- const newValue = parseValue(newStringValue);
-
- if (isNonCustomItem(newValue)) {
- handleSelectItem(newValue);
- } else {
- setValue(newValue);
- onSelect(newValue);
- }
- }
- },
- [parseValue, isNonCustomItem, handleSelectItem, onSelect],
- );
-
- const handleInvalidCustom = useCallback(
- () => setCustomValue(itemIsSelected ? undefined : `${value}`),
- [itemIsSelected, value],
- );
-
- // Delay blur event until onMouseUp resulting in handleSelectItem being called before
- // handleSubmitCustomValue and handleInvalidCustom. Clicking on the input should still move the
- // cursor and therefore needs to be an exception to this.
- const handleMouseDown = useCallback(
- (event: React.MouseEvent) => {
- if (event.target !== inputRef.current) {
- event.preventDefault();
- }
- },
- [inputRef],
- );
-
- return (
- <div onMouseDown={handleMouseDown}>
- <Selector<T | undefined, U>
- {...otherProps}
- onSelect={handleSelectItem}
- value={customIsSelected ? undefined : value}>
- <StyledCustomContainer
- ref={customIsSelected ? props.selectedCellRef : undefined}
- onClick={handleClickCustom}
- selected={customIsSelected}
- disabled={props.disabled}
- role="option"
- aria-selected={customIsSelected}
- aria-disabled={props.disabled}>
- <StyledCellIcon
- $visible={customIsSelected}
- source="icon-tick"
- width={18}
- tintColor={colors.white}
- />
- <Cell.ValueLabel>{messages.gettext('Custom')}</Cell.ValueLabel>
- <AriaInput>
- <Cell.AutoSizingTextInput
- ref={inputRef}
- value={customValue ?? ''}
- placeholder={inputPlaceholder}
- inputMode={'numeric'}
- maxLength={maxLength ?? 4}
- onChangeValue={setCustomValue}
- onSubmitValue={handleSubmitCustom}
- onInvalidValue={handleInvalidCustom}
- submitOnBlur={true}
- validateValue={validateCustomValue}
- modifyValue={modifyValue}
- />
- </AriaInput>
- </StyledCustomContainer>
- </Selector>
- </div>
- );
-}
diff --git a/gui/src/renderer/components/cell/SettingsForm.tsx b/gui/src/renderer/components/cell/SettingsForm.tsx
deleted file mode 100644
index 9a901d88d8..0000000000
--- a/gui/src/renderer/components/cell/SettingsForm.tsx
+++ /dev/null
@@ -1,77 +0,0 @@
-import React, { useCallback, useContext, useEffect, useId, useMemo, useState } from 'react';
-
-import { useEffectEvent } from '../../lib/utility-hooks';
-
-interface SettingsFormContext {
- formSubmittable: boolean;
- reportInputSubmittable: (key: string, submittable: boolean) => void;
- removeInput: (key: string) => void;
-}
-
-// Keep track of all submittable and non submittable inputs in a form to enable e.g. buttons to
-// become enabled/disabled based on input states.
-const settingsFormContext = React.createContext<SettingsFormContext | undefined>(undefined);
-
-function useSettingsFormContext() {
- return useContext(settingsFormContext);
-}
-
-// Hook that returns whether or not the form is submittable for use in form container.
-export function useSettingsFormSubmittable() {
- const context = useSettingsFormContext();
- return context?.formSubmittable ?? true;
-}
-
-// Hook that returns function that input can use to report if it's submittable or not.
-export function useSettingsFormSubmittableReporter() {
- const context = useSettingsFormContext();
-
- // Each form needs an unique ID, this key is part of that ID.
- const key = useId();
-
- const reportInputSubmittable = useCallback(
- (submittable: boolean) => {
- context?.reportInputSubmittable(key, submittable);
- },
- [context, key],
- );
-
- const clearRequiredFields = useEffectEvent(() => {
- context?.removeInput(key);
- });
-
- useEffect(() => {
- // Remove from required fields if unmounted.
- return () => clearRequiredFields();
- }, []);
-
- return reportInputSubmittable;
-}
-
-export function SettingsForm(props: React.PropsWithChildren) {
- const [inputStatuses, setInputStatuses] = useState<Record<string, boolean>>({});
-
- const reportInputSubmittable = useCallback((key: string, submittable: boolean) => {
- setInputStatuses((prevInputStatuses) => ({ ...prevInputStatuses, [key]: submittable }));
- }, []);
-
- const removeInput = useCallback((key: string) => {
- setInputStatuses((prevInputStatuses) => {
- const { [key]: _, ...inputStatuses } = prevInputStatuses;
- return inputStatuses;
- });
- }, []);
-
- const value = useMemo(
- () => ({
- formSubmittable: Object.values(inputStatuses).every((item) => item === true),
- reportInputSubmittable,
- removeInput,
- }),
- [inputStatuses, removeInput, reportInputSubmittable],
- );
-
- return (
- <settingsFormContext.Provider value={value}>{props.children}</settingsFormContext.Provider>
- );
-}
diff --git a/gui/src/renderer/components/cell/SettingsGroup.tsx b/gui/src/renderer/components/cell/SettingsGroup.tsx
deleted file mode 100644
index 7ca8d0dff1..0000000000
--- a/gui/src/renderer/components/cell/SettingsGroup.tsx
+++ /dev/null
@@ -1,99 +0,0 @@
-import React, { useCallback, useContext, useEffect, useId, useMemo, useState } from 'react';
-import styled from 'styled-components';
-
-import { colors } from '../../../config.json';
-import { measurements, tinyText } from '../common-styles';
-import InfoButton from '../InfoButton';
-import { SettingsRowErrorMessage } from './SettingsRow';
-
-const StyledContainer = styled.div({
- '& ~ &&': {
- marginTop: '20px',
- },
-});
-
-const StyledTitle = styled.h2(tinyText, {
- display: 'flex',
- alignItems: 'center',
- color: colors.white80,
- margin: `0 ${measurements.viewMargin} 8px`,
- lineHeight: '17px',
-});
-
-const StyledInfoButton = styled(InfoButton)({
- marginLeft: '6px',
-});
-
-export const StyledSettingsGroup = styled.div({});
-
-interface SettingsGroupContext {
- setError?: (key: string, errorMessage: string) => void;
- unsetError?: (key: string) => void;
-}
-
-const settingsGroupContext = React.createContext<SettingsGroupContext>({});
-
-export function useSettingsGroupContext() {
- const { setError, unsetError } = useContext(settingsGroupContext);
- const key = useId();
-
- const reportError = useCallback(
- (errorMessage: string) => {
- setError?.(key, errorMessage);
- },
- [setError, key],
- );
-
- const unsetErrorImpl = useCallback(() => unsetError?.(key), [key, unsetError]);
-
- useEffect(() => () => unsetErrorImpl(), [unsetErrorImpl]);
-
- return { reportError, unsetError: unsetErrorImpl };
-}
-
-interface SettingsGroupProps {
- title?: string;
- infoMessage?: string | Array<string>;
-}
-
-export function SettingsGroup(props: React.PropsWithChildren<SettingsGroupProps>) {
- const [errors, setErrors] = useState<Record<string, string>>({});
-
- const setError = useCallback((key: string, errorMessage: string) => {
- setErrors((prevErrors) => ({ ...prevErrors, [key]: errorMessage }));
- }, []);
-
- const unsetError = useCallback((key: string) => {
- setErrors((prevErrors) => {
- const { [key]: _, ...errors } = prevErrors;
- return errors;
- });
- }, []);
-
- const contextValue = useMemo(
- () => ({
- setError,
- unsetError,
- }),
- [setError, unsetError],
- );
-
- return (
- <settingsGroupContext.Provider value={contextValue}>
- <StyledContainer>
- {props.title !== undefined && (
- <StyledTitle>
- {props.title}
- {props.infoMessage !== undefined && (
- <StyledInfoButton size={12} message={props.infoMessage} />
- )}
- </StyledTitle>
- )}
- <StyledSettingsGroup>{props.children}</StyledSettingsGroup>
- {Object.values(errors).map((error) => (
- <SettingsRowErrorMessage key={error}>{error}</SettingsRowErrorMessage>
- ))}
- </StyledContainer>
- </settingsGroupContext.Provider>
- );
-}
diff --git a/gui/src/renderer/components/cell/SettingsRadioGroup.tsx b/gui/src/renderer/components/cell/SettingsRadioGroup.tsx
deleted file mode 100644
index 6f4f1a0d90..0000000000
--- a/gui/src/renderer/components/cell/SettingsRadioGroup.tsx
+++ /dev/null
@@ -1,126 +0,0 @@
-import { useCallback, useId, useState } from 'react';
-import { styled } from 'styled-components';
-
-import { colors } from '../../../config.json';
-import { AriaInput, AriaInputGroup, AriaLabel } from '../AriaGroup';
-import { smallNormalText } from '../common-styles';
-import { SettingsSelectItem } from './SettingsSelect';
-
-const StyledRadioGroup = styled.div({
- display: 'flex',
-});
-
-interface SettingsSelectProps<T extends string> {
- defaultValue?: T;
- items: Array<SettingsSelectItem<T>>;
- onUpdate: (value: T) => void;
-}
-
-export function SettingsRadioGroup<T extends string>(props: SettingsSelectProps<T>) {
- const { onUpdate } = props;
-
- const [value, setValue] = useState<T>(props.defaultValue ?? props.items[0]?.value ?? '');
- const key = useId();
-
- const onSelect = useCallback(
- (value: T) => {
- setValue(value);
- onUpdate(value);
- },
- [onUpdate],
- );
-
- return (
- <StyledRadioGroup>
- {props.items.map((item) => (
- <RadioButton
- key={item.value}
- group={key}
- item={item}
- selected={item.value === value}
- onSelect={onSelect}
- />
- ))}
- </StyledRadioGroup>
- );
-}
-
-const StyledRadioButton = styled.input.attrs({ type: 'radio' })({
- position: 'relative',
- margin: 0,
- appearance: 'none',
- backgroundColor: 'transparent',
- width: '12px',
- height: '12px',
-
- '&&::before': {
- position: 'absolute',
- content: '""',
- width: '12px',
- height: '12px',
- borderRadius: '50%',
- backgroundColor: 'transparent',
- border: `1px ${colors.white} solid`,
- top: 0,
- left: 0,
- },
-
- '&&:checked::after': {
- position: 'absolute',
- content: '""',
- width: '8px',
- height: '8px',
- borderRadius: '50%',
- backgroundColor: colors.white,
- top: '3px',
- left: '3px',
- },
-});
-
-const StyledRadioButtonContainer = styled.div({
- display: 'flex',
- alignItems: 'center',
- flexWrap: 'nowrap',
- marginLeft: '16px',
-});
-
-const StyledRadioButtonLabel = styled.label(smallNormalText, {
- color: colors.white,
- marginLeft: '8px',
-});
-
-interface RadioButtonProps<T extends string> {
- group: string;
- item: SettingsSelectItem<T>;
- selected: boolean;
- onSelect: (value: T) => void;
-}
-
-function RadioButton<T extends string>(props: RadioButtonProps<T>) {
- const { onSelect } = props;
-
- const onChange = useCallback(
- (event: React.ChangeEvent<HTMLInputElement>) => {
- onSelect(event.target.value as T);
- },
- [onSelect],
- );
-
- return (
- <StyledRadioButtonContainer>
- <AriaInputGroup>
- <AriaInput>
- <StyledRadioButton
- name={props.group}
- value={props.item.value}
- onChange={onChange}
- checked={props.selected}
- />
- </AriaInput>
- <AriaLabel>
- <StyledRadioButtonLabel>{props.item.label}</StyledRadioButtonLabel>
- </AriaLabel>
- </AriaInputGroup>
- </StyledRadioButtonContainer>
- );
-}
diff --git a/gui/src/renderer/components/cell/SettingsRow.tsx b/gui/src/renderer/components/cell/SettingsRow.tsx
deleted file mode 100644
index f242106c92..0000000000
--- a/gui/src/renderer/components/cell/SettingsRow.tsx
+++ /dev/null
@@ -1,139 +0,0 @@
-import React, { useCallback, useContext, useMemo, useState } from 'react';
-import styled from 'styled-components';
-
-import { colors } from '../../../config.json';
-import { AriaInputGroup, AriaLabel } from '../AriaGroup';
-import { measurements, smallNormalText, tinyText } from '../common-styles';
-import ImageView from '../ImageView';
-import { StyledSettingsGroup, useSettingsGroupContext } from './SettingsGroup';
-
-const StyledSettingsRow = styled.label<{ $invalid: boolean }>((props) => ({
- display: 'flex',
- alignItems: 'center',
-
- margin: `0 ${measurements.viewMargin} ${measurements.rowVerticalMargin}`,
- padding: '0 8px',
- minHeight: '36px',
- backgroundColor: colors.blue60,
- borderRadius: '4px',
-
- [`${StyledSettingsGroup} &&`]: {
- marginBottom: 0,
- },
-
- [`${StyledSettingsGroup} &&:not(:last-child)`]: {
- marginBottom: '1px',
- borderBottomLeftRadius: 0,
- borderBottomRightRadius: 0,
- },
-
- [`${StyledSettingsGroup} &&:not(:first-child)`]: {
- borderTopLeftRadius: 0,
- borderTopRightRadius: 0,
- },
-
- borderWidth: '1px',
- outlineWidth: '1px',
- borderStyle: 'solid',
- outlineStyle: 'solid',
- borderColor: props.$invalid ? colors.red : 'transparent',
- outlineColor: props.$invalid ? colors.red : 'transparent',
- '&&:focus-within': {
- borderColor: props.$invalid ? colors.red : colors.white,
- outlineColor: props.$invalid ? colors.red : colors.white,
- },
-}));
-
-const StyledLabel = styled.div(smallNormalText, {
- display: 'flex',
- flex: 1,
- margin: '4px 0',
-});
-
-const StyledInputContainer = styled.div({
- display: 'flex',
- flex: 1,
- justifyContent: 'end',
-});
-
-const StyledSettingsRowErrorMessage = styled.div(tinyText, {
- display: 'flex',
- alignItems: 'center',
- marginLeft: measurements.viewMargin,
- marginTop: '5px',
- color: colors.white60,
-});
-
-const StyledErrorMessageAlertIcon = styled(ImageView)({
- marginRight: '5px',
-});
-
-interface SettingsRowContext {
- invalid: boolean;
- setInvalid: (invalid: boolean) => void;
-}
-
-// Keeps track of input validity to show red border if an invalid value is provided.
-const settingsRowContext = React.createContext<SettingsRowContext>({
- invalid: false,
- setInvalid: (_invalid: boolean) => {
- throw new Error('setInvalid not defined');
- },
-});
-
-export function useSettingsRowContext() {
- return useContext(settingsRowContext);
-}
-
-interface IndentedRowProps {
- label: string;
- infoMessage?: string | Array<string>;
- errorMessage?: string;
-}
-
-export function SettingsRow(props: React.PropsWithChildren<IndentedRowProps>) {
- const { reportError, unsetError } = useSettingsGroupContext();
- const [invalid, setInvalid] = useState(false);
-
- const setInvalidImpl = useCallback(
- (invalid: boolean) => {
- setInvalid(invalid);
- if (reportError !== undefined && props.errorMessage !== undefined && invalid) {
- reportError(props.errorMessage);
- } else if (unsetError !== undefined && !invalid) {
- unsetError?.();
- }
- },
- [props.errorMessage, reportError, unsetError],
- );
-
- const contextValue = useMemo(
- () => ({ invalid, setInvalid: setInvalidImpl }),
- [invalid, setInvalidImpl],
- );
-
- return (
- <settingsRowContext.Provider value={contextValue}>
- <AriaInputGroup>
- <AriaLabel>
- <StyledSettingsRow $invalid={invalid}>
- <StyledLabel>{props.label}</StyledLabel>
- <StyledInputContainer>{props.children}</StyledInputContainer>
- </StyledSettingsRow>
- </AriaLabel>
- {reportError === undefined && invalid && props.errorMessage && (
- <SettingsRowErrorMessage>{props.errorMessage}</SettingsRowErrorMessage>
- )}
- </AriaInputGroup>
- </settingsRowContext.Provider>
- );
-}
-
-export function SettingsRowErrorMessage(props: React.PropsWithChildren) {
- return (
- <StyledSettingsRowErrorMessage>
- <StyledErrorMessageAlertIcon source="icon-alert" tintColor={colors.red} width={12} />
- {props.children}
- </StyledSettingsRowErrorMessage>
- );
-}
diff --git a/gui/src/renderer/components/cell/SettingsSelect.tsx b/gui/src/renderer/components/cell/SettingsSelect.tsx
deleted file mode 100644
index 7b5e1d7ab4..0000000000
--- a/gui/src/renderer/components/cell/SettingsSelect.tsx
+++ /dev/null
@@ -1,255 +0,0 @@
-import { useCallback, useEffect, useRef, useState } from 'react';
-import styled from 'styled-components';
-
-import { colors } from '../../../config.json';
-import { useScheduler } from '../../../shared/scheduler';
-import { useBoolean, useEffectEvent } from '../../lib/utility-hooks';
-import { AriaInput } from '../AriaGroup';
-import { smallNormalText } from '../common-styles';
-import CustomScrollbars from '../CustomScrollbars';
-import ImageView from '../ImageView';
-
-export interface SettingsSelectItem<T extends string> {
- value: T;
- label: string;
-}
-
-const StyledSelect = styled.div.attrs({ tabIndex: 0 })(smallNormalText, {
- display: 'flex',
- flex: 1,
- position: 'relative',
- background: 'transparent',
- border: 'none',
- color: colors.white,
- borderRadius: '4px',
- height: '26px',
-
- '&&:focus': {
- outline: `1px ${colors.darkBlue} solid`,
- backgroundColor: colors.blue,
- },
-});
-
-const StyledItems = styled.div<{ $direction: 'down' | 'up' }>((props) => ({
- display: 'flex',
- flexDirection: 'column',
- position: 'absolute',
- top: props.$direction === 'down' ? 'calc(100% + 4px)' : 'auto',
- bottom: props.$direction === 'up' ? 'calc(100% + 4px)' : 'auto',
- right: '-1px',
- backgroundColor: colors.darkBlue,
- border: `1px ${colors.darkerBlue} solid`,
- borderRadius: '4px',
- padding: '4px 8px',
- maxHeight: '250px',
- overflowY: 'hidden',
- zIndex: 2,
-}));
-
-const StyledSelectedContainer = styled.div({
- overflow: 'hidden',
- width: 'fit-content',
- maxWidth: '170px',
-});
-
-const StyledSelectedContainerInner = styled.div({
- display: 'flex',
- alignItems: 'center',
- justifyContent: 'end',
- height: '100%',
-});
-
-const StyledSelectedText = styled.span({
- display: 'inline-block',
- maxWidth: 'calc(100% - 30px)',
- marginLeft: '12px',
- whiteSpace: 'nowrap',
- textOverflow: 'ellipsis',
- overflow: 'hidden',
-});
-
-const StyledInvisibleItems = styled.div({
- padding: '0 29px 31px',
- visibility: 'hidden',
-});
-
-const StyledInvisibleItemsInner = styled.div({
- whiteSpace: 'nowrap',
-});
-
-const StyledChevron = styled(ImageView)({
- marginLeft: '6px',
- marginRight: '5px',
-});
-
-interface SettingsSelectProps<T extends string> {
- defaultValue?: T;
- items: Array<SettingsSelectItem<T>>;
- onUpdate: (value: T) => void;
- direction?: 'down' | 'up';
- 'data-testid'?: string;
-}
-
-export function SettingsSelect<T extends string>(props: SettingsSelectProps<T>) {
- const [value, setValue] = useState<T>(props.defaultValue ?? props.items[0]?.value ?? '');
- const [dropdownVisible, , closeDropdown, toggleDropdown] = useBoolean();
-
- // When typing to search the current search value is stored here.
- const searchRef = useRef<string>('');
- // Scheduler for clearing the search string after the user has stopped typing.
- const searchClearScheduler = useScheduler();
-
- const onSelect = useCallback(
- (value: T) => {
- setValue(value);
- closeDropdown();
- },
- [closeDropdown],
- );
-
- // Handle keyboard shortcuts and type search
- const onKeyDown = useCallback(
- (event: React.KeyboardEvent<HTMLDivElement>) => {
- switch (event.key) {
- case 'ArrowUp':
- setValue((prevValue) => findPreviousValue(props.items, prevValue));
- break;
- case 'ArrowDown':
- setValue((prevValue) => findNextValue(props.items, prevValue));
- break;
- case 'Home':
- setValue(props.items[0]?.value ?? '');
- break;
- case 'End':
- setValue(props.items[props.items.length - 1]?.value ?? '');
- break;
- default:
- // Only accept printable characters for text search.
- if (event.key.length === 1) {
- searchClearScheduler.cancel();
- searchRef.current += event.key.toLowerCase();
- searchClearScheduler.schedule(() => (searchRef.current = ''), 500);
-
- setValue((prevValue) => findSearchedValue(props.items, prevValue, searchRef.current));
- }
- break;
- }
- },
- [props.items, searchClearScheduler],
- );
-
- const updateEvent = useEffectEvent((value: T) => {
- props.onUpdate(value);
- });
-
- // Update the parent when the value changes.
- useEffect(() => {
- updateEvent(value);
- }, [value]);
-
- return (
- <AriaInput>
- <StyledSelect onBlur={closeDropdown} onKeyDown={onKeyDown} role="listbox">
- <StyledSelectedContainer data-testid={props['data-testid']} onClick={toggleDropdown}>
- <StyledSelectedContainerInner>
- <StyledSelectedText>
- {props.items.find((item) => item.value === value)?.label ?? ''}
- </StyledSelectedText>
- <StyledChevron tintColor={colors.white60} source="icon-chevron-down" width={22} />
- </StyledSelectedContainerInner>
- <StyledInvisibleItems>
- {props.items.map((item) => (
- <StyledInvisibleItemsInner key={item.label}>{item.label}</StyledInvisibleItemsInner>
- ))}
- </StyledInvisibleItems>
- </StyledSelectedContainer>
- {dropdownVisible && (
- <StyledItems $direction={props.direction ?? 'down'}>
- <CustomScrollbars>
- {props.items.map((item) => (
- <Item
- key={item.value}
- item={item}
- selected={item.value === value}
- onSelect={onSelect}
- />
- ))}
- </CustomScrollbars>
- </StyledItems>
- )}
- </StyledSelect>
- </AriaInput>
- );
-}
-
-function findPreviousValue<T extends string>(
- items: Array<SettingsSelectItem<T>>,
- currentValue: T,
-): T {
- const currentIndex = items.findIndex((item) => item.value === currentValue) ?? 0;
- const newIndex = Math.max(currentIndex - 1, 0);
- return items[newIndex]?.value ?? '';
-}
-
-function findNextValue<T extends string>(items: Array<SettingsSelectItem<T>>, currentValue: T): T {
- const currentIndex = items.findIndex((item) => item.value === currentValue) ?? 0;
- const newIndex = Math.min(currentIndex + 1, items.length - 1);
- return items[newIndex]?.value ?? '';
-}
-
-function findSearchedValue<T extends string>(
- items: Array<SettingsSelectItem<T>>,
- currentValue: T,
- searchValue: string,
-): T {
- const currentIndex = items.findIndex((item) => item.value === currentValue) ?? 0;
- const itemsFromCurrent = [...items.slice(currentIndex + 1), ...items.slice(0, currentIndex)];
- const searchedValue = itemsFromCurrent.find((item) =>
- item.label.toLowerCase().startsWith(searchValue),
- );
-
- return searchedValue?.value ?? currentValue;
-}
-
-const StyledItem = styled.div<{ $selected: boolean }>((props) => ({
- display: 'flex',
- alignItems: 'center',
- borderRadius: '4px',
- lineHeight: '22px',
- paddingLeft: props.$selected ? '0px' : '23px',
- paddingRight: '18px',
- whiteSpace: 'nowrap',
- '&&:hover': {
- backgroundColor: colors.blue,
- },
-}));
-
-const TickIcon = styled(ImageView)({
- marginLeft: '5px',
- marginRight: '6px',
-});
-
-interface ItemProps<T extends string> {
- item: SettingsSelectItem<T>;
- selected: boolean;
- onSelect: (key: T) => void;
-}
-
-function Item<T extends string>(props: ItemProps<T>) {
- const { onSelect } = props;
-
- const onClick = useCallback(() => {
- onSelect(props.item.value);
- }, [onSelect, props.item.value]);
-
- return (
- <StyledItem
- onClick={onClick}
- role="option"
- $selected={props.selected}
- aria-selected={props.selected}>
- {props.selected && <TickIcon tintColor={colors.white} source="icon-tick" width={12} />}
- {props.item.label}
- </StyledItem>
- );
-}
diff --git a/gui/src/renderer/components/cell/SettingsTextInput.tsx b/gui/src/renderer/components/cell/SettingsTextInput.tsx
deleted file mode 100644
index 44381bc681..0000000000
--- a/gui/src/renderer/components/cell/SettingsTextInput.tsx
+++ /dev/null
@@ -1,129 +0,0 @@
-import { useCallback, useEffect } from 'react';
-import styled from 'styled-components';
-
-import { colors } from '../../../config.json';
-import { useEffectEvent } from '../../lib/utility-hooks';
-import { AriaInput } from '../AriaGroup';
-import { smallNormalText } from '../common-styles';
-import { useSettingsFormSubmittableReporter } from './SettingsForm';
-import { useSettingsRowContext } from './SettingsRow';
-
-const StyledInput = styled.input(smallNormalText, {
- flex: 1,
- textAlign: 'right',
- background: 'transparent',
- border: 'none',
- color: colors.white,
- width: '100px',
-
- '&&::placeholder': {
- color: colors.white50,
- },
-});
-
-interface SettingsTextInputProps extends InputProps<'text'> {
- defaultValue?: string;
-}
-
-export function SettingsTextInput(props: SettingsTextInputProps) {
- return <Input type="text" {...props} />;
-}
-
-interface SettingsNumberInputProps
- extends Omit<InputProps<'number'>, 'onUpdate' | 'validate' | 'value'> {
- defaultValue?: number;
- value?: number | '';
- onUpdate: (value: number | undefined) => void;
- validate?: (value: number) => boolean;
-}
-
-// NumberInput is basically a text input but it parses all values as numbers.
-export function SettingsNumberInput(props: SettingsNumberInputProps) {
- const { onUpdate, validate, value, ...otherProps } = props;
-
- const parse = useCallback((value: string) => {
- const parsedValue = parseInt(value);
- return isNaN(parsedValue) ? undefined : parsedValue;
- }, []);
-
- const onNumberUpdate = useCallback(
- (value: string) => {
- onUpdate(parse(value));
- },
- [onUpdate, parse],
- );
-
- const validateNumber = useCallback(
- (value: string) => {
- const parsedValue = parse(value);
- return (parsedValue === undefined || validate?.(parsedValue)) ?? true;
- },
- [parse, validate],
- );
-
- return (
- <Input
- {...otherProps}
- value={value ?? ''}
- onUpdate={onNumberUpdate}
- validate={validateNumber}
- />
- );
-}
-
-type ValueTypes = 'text' | 'number';
-type ValueType<T extends ValueTypes> = T extends 'number' ? number | '' : string;
-
-interface InputProps<T extends ValueTypes> extends React.InputHTMLAttributes<HTMLInputElement> {
- type?: T;
- value?: ValueType<T>;
- defaultValue?: ValueType<T>;
- onUpdate: (value: string) => void;
- validate?: (value: string) => boolean;
- optionalInForm?: boolean;
-}
-
-function Input<T extends ValueTypes>(props: InputProps<T>) {
- const { onUpdate, onChange: propsOnChange, validate, optionalInForm, ...otherProps } = props;
- const reportSubmittable = useSettingsFormSubmittableReporter();
-
- const { setInvalid } = useSettingsRowContext();
-
- const onChange = useCallback(
- (event: React.ChangeEvent<HTMLInputElement>) => {
- const value = event.target.value;
-
- // Report change to parent
- propsOnChange?.(event);
- onUpdate(value);
-
- if (validate?.(value) === false && value !== '') {
- // Report validity and submittability to settings row context and form context.
- setInvalid(true);
- reportSubmittable(false);
- } else {
- setInvalid(false);
- reportSubmittable(value !== '' || optionalInForm === true);
- }
- },
- [propsOnChange, onUpdate, validate, setInvalid, reportSubmittable, optionalInForm],
- );
-
- const updateReportSubmittable = useEffectEvent(() => {
- const value = props.value ?? props.defaultValue ?? '';
- reportSubmittable(
- (value !== '' || optionalInForm === true) && validate?.(`${value}`) !== false,
- );
- });
-
- // Report submittability to form context on load.
- useEffect(() => {
- updateReportSubmittable();
- }, []);
-
- return (
- <AriaInput>
- <StyledInput {...otherProps} onChange={onChange} />
- </AriaInput>
- );
-}
diff --git a/gui/src/renderer/components/cell/SideButton.tsx b/gui/src/renderer/components/cell/SideButton.tsx
deleted file mode 100644
index 6c30922b5f..0000000000
--- a/gui/src/renderer/components/cell/SideButton.tsx
+++ /dev/null
@@ -1,25 +0,0 @@
-import styled from 'styled-components';
-
-import { colors } from '../../../config.json';
-import { measurements } from '../common-styles';
-import { buttonColor, ButtonColors } from './styles';
-
-export const SideButton = styled.button<ButtonColors>(buttonColor, {
- position: 'relative',
- alignSelf: 'stretch',
- paddingLeft: measurements.viewMargin,
- paddingRight: measurements.viewMargin,
- border: 0,
-
- '&&::before': {
- content: '""',
- position: 'absolute',
- margin: 'auto',
- top: 0,
- left: 0,
- bottom: 0,
- height: '50%',
- width: '1px',
- backgroundColor: colors.darkBlue,
- },
-});
diff --git a/gui/src/renderer/components/cell/index.ts b/gui/src/renderer/components/cell/index.ts
deleted file mode 100644
index 2bbd56167a..0000000000
--- a/gui/src/renderer/components/cell/index.ts
+++ /dev/null
@@ -1,9 +0,0 @@
-export * from './CellButton';
-export * from './Container';
-export * from './Footer';
-export * from './Input';
-export * from './Label';
-export * from './Section';
-export * from './Group';
-export * from './Row';
-export * from './SideButton';
diff --git a/gui/src/renderer/components/cell/styles.ts b/gui/src/renderer/components/cell/styles.ts
deleted file mode 100644
index a2d20b03f6..0000000000
--- a/gui/src/renderer/components/cell/styles.ts
+++ /dev/null
@@ -1,13 +0,0 @@
-export interface ButtonColors {
- $backgroundColor: string;
- $backgroundColorHover: string;
-}
-
-export const buttonColor = (props: ButtonColors) => {
- return {
- backgroundColor: props.$backgroundColor,
- '&&:not(:disabled):hover': {
- backgroundColor: props.$backgroundColorHover,
- },
- };
-};
diff --git a/gui/src/renderer/components/common-styles.ts b/gui/src/renderer/components/common-styles.ts
deleted file mode 100644
index dbfb720e44..0000000000
--- a/gui/src/renderer/components/common-styles.ts
+++ /dev/null
@@ -1,71 +0,0 @@
-import React from 'react';
-
-import { colors } from '../../config.json';
-
-export const openSans: React.CSSProperties = {
- fontFamily: 'Open Sans',
-};
-
-export const sourceSansPro: React.CSSProperties = {
- fontFamily: '"Source Sans Pro", "Noto Sans Myanmar", "Noto Sans Thai", sans-serif',
-};
-
-export const tinyText = {
- ...openSans,
- fontSize: '12px',
- fontWeight: 600,
- lineHeight: '18px',
-};
-
-export const smallText = {
- ...openSans,
- fontSize: '14px',
- fontWeight: 600,
- lineHeight: '20px',
- color: colors.white80,
-};
-
-export const smallNormalText = {
- ...smallText,
- fontWeight: 'normal',
-};
-
-export const normalText = {
- ...openSans,
- fontSize: '15px',
- lineHeight: '18px',
-};
-
-export const largeText = {
- ...sourceSansPro,
- fontWeight: 600,
- fontSize: '18px',
- lineHeight: '24px',
-};
-
-export const buttonText = {
- ...largeText,
- color: colors.white,
-};
-
-export const bigText = {
- ...sourceSansPro,
- fontSize: '24px',
- fontWeight: 700,
- lineHeight: '28px',
-};
-
-export const hugeText = {
- ...sourceSansPro,
- fontSize: '32px',
- fontWeight: 700,
- lineHeight: '34px',
- color: colors.white,
-};
-
-export const measurements = {
- rowMinHeight: '44px',
- viewMargin: '22px',
- rowVerticalMargin: '20px',
- buttonVerticalMargin: '18px',
-};
diff --git a/gui/src/renderer/components/main-view/ConnectionActionButton.tsx b/gui/src/renderer/components/main-view/ConnectionActionButton.tsx
deleted file mode 100644
index 437fa93321..0000000000
--- a/gui/src/renderer/components/main-view/ConnectionActionButton.tsx
+++ /dev/null
@@ -1,63 +0,0 @@
-import { useCallback } from 'react';
-import styled from 'styled-components';
-
-import { messages } from '../../../shared/gettext';
-import log from '../../../shared/logging';
-import { useAppContext } from '../../context';
-import { useSelector } from '../../redux/store';
-import { SmallButton, SmallButtonColor } from '../SmallButton';
-
-const StyledConnectionButton = styled(SmallButton)({
- margin: 0,
-});
-
-export default function ConnectionActionButton() {
- const tunnelState = useSelector((state) => state.connection.status.state);
-
- if (tunnelState === 'disconnected' || tunnelState === 'disconnecting') {
- return <ConnectButton disabled={tunnelState === 'disconnecting'} />;
- } else {
- return <DisconnectButton />;
- }
-}
-
-function ConnectButton(props: Partial<Parameters<typeof SmallButton>[0]>) {
- const { connectTunnel } = useAppContext();
-
- const onConnect = useCallback(async () => {
- try {
- await connectTunnel();
- } catch (e) {
- const error = e as Error;
- log.error(`Failed to connect the tunnel: ${error.message}`);
- }
- }, [connectTunnel]);
-
- return (
- <StyledConnectionButton color={SmallButtonColor.green} onClick={onConnect} {...props}>
- {messages.pgettext('tunnel-control', 'Connect')}
- </StyledConnectionButton>
- );
-}
-
-function DisconnectButton() {
- const { disconnectTunnel } = useAppContext();
- const tunnelState = useSelector((state) => state.connection.status.state);
-
- const onDisconnect = useCallback(async () => {
- try {
- await disconnectTunnel();
- } catch (e) {
- const error = e as Error;
- log.error(`Failed to disconnect the tunnel: ${error.message}`);
- }
- }, [disconnectTunnel]);
-
- const displayAsCancel = tunnelState !== 'connected';
-
- return (
- <StyledConnectionButton color={SmallButtonColor.red} onClick={onDisconnect}>
- {displayAsCancel ? messages.gettext('Cancel') : messages.gettext('Disconnect')}
- </StyledConnectionButton>
- );
-}
diff --git a/gui/src/renderer/components/main-view/ConnectionDetails.tsx b/gui/src/renderer/components/main-view/ConnectionDetails.tsx
deleted file mode 100644
index c6bff32ef3..0000000000
--- a/gui/src/renderer/components/main-view/ConnectionDetails.tsx
+++ /dev/null
@@ -1,202 +0,0 @@
-import { useEffect, useState } from 'react';
-import styled from 'styled-components';
-
-import { colors } from '../../../config.json';
-import {
- EndpointObfuscationType,
- ITunnelEndpoint,
- parseSocketAddress,
- ProxyType,
- RelayProtocol,
- TunnelState,
- TunnelType,
- tunnelTypeToString,
-} from '../../../shared/daemon-rpc-types';
-import { messages } from '../../../shared/gettext';
-import { useSelector } from '../../redux/store';
-import { tinyText } from '../common-styles';
-
-interface Endpoint {
- ip: string;
- port: number;
- protocol: RelayProtocol;
-}
-
-interface InAddress extends Endpoint {
- tunnelType: TunnelType;
-}
-
-interface BridgeData extends Endpoint {
- bridgeType: ProxyType;
-}
-
-interface ObfuscationData extends Endpoint {
- obfuscationType: EndpointObfuscationType;
-}
-
-const StyledConnectionDetailsHeading = styled.h2(tinyText, {
- margin: '0 0 4px',
- fontSize: '10px',
- lineHeight: '15px',
- color: colors.white60,
-});
-
-const StyledConnectionDetailsContainer = styled.div({
- marginTop: '16px',
- marginBottom: '16px',
-});
-
-const StyledIpTable = styled.div({
- display: 'grid',
- gridTemplateColumns: 'minmax(48px, min-content) auto',
-});
-
-const StyledIpLabelContainer = styled.div({
- display: 'flex',
- flexDirection: 'column',
-});
-
-const StyledConnectionDetailsLabel = styled.span(tinyText, {
- display: 'block',
- color: colors.white,
- fontWeight: '400',
- minHeight: '1em',
-});
-
-const StyledConnectionDetailsTitle = styled(StyledConnectionDetailsLabel)({
- color: colors.white60,
- whiteSpace: 'nowrap',
-});
-
-export default function ConnectionDetails() {
- const reduxConnection = useSelector((state) => state.connection);
- const [connection, setConnection] = useState(reduxConnection);
-
- const tunnelState = connection.status;
-
- useEffect(() => {
- if (
- reduxConnection.status.state === 'connected' ||
- reduxConnection.status.state === 'connecting'
- ) {
- setConnection(reduxConnection);
- }
- }, [reduxConnection, tunnelState.state]);
-
- const entry = getEntryPoint(tunnelState);
-
- const showDetails = tunnelState.state === 'connected' || tunnelState.state === 'connecting';
- const hasEntry = showDetails && entry !== undefined;
-
- return (
- <StyledConnectionDetailsContainer>
- <StyledConnectionDetailsHeading>
- {messages.pgettext('connect-view', 'Connection details')}
- </StyledConnectionDetailsHeading>
- <StyledConnectionDetailsLabel data-testid="tunnel-protocol">
- {showDetails &&
- tunnelState.details !== undefined &&
- tunnelTypeToString(tunnelState.details.endpoint.tunnelType)}
- </StyledConnectionDetailsLabel>
- <StyledIpTable>
- <StyledConnectionDetailsTitle>
- {messages.pgettext('connection-info', 'In')}
- </StyledConnectionDetailsTitle>
- <StyledConnectionDetailsLabel data-testid="in-ip">
- {hasEntry ? `${entry.ip}:${entry.port} ${entry.protocol.toUpperCase()}` : ''}
- </StyledConnectionDetailsLabel>
- <StyledConnectionDetailsTitle>
- {messages.pgettext('connection-info', 'Out')}
- </StyledConnectionDetailsTitle>
- <StyledIpLabelContainer>
- {connection.ipv4 && (
- <StyledConnectionDetailsLabel>{connection.ipv4}</StyledConnectionDetailsLabel>
- )}
- {connection.ipv6 && (
- <StyledConnectionDetailsLabel>{connection.ipv6}</StyledConnectionDetailsLabel>
- )}
- </StyledIpLabelContainer>
- </StyledIpTable>
- </StyledConnectionDetailsContainer>
- );
-}
-
-function getEntryPoint(tunnelState: TunnelState): Endpoint | undefined {
- if (
- (tunnelState.state !== 'connected' && tunnelState.state !== 'connecting') ||
- tunnelState.details === undefined
- ) {
- return undefined;
- }
-
- const endpoint = tunnelState.details.endpoint;
- const inAddress = tunnelEndpointToRelayInAddress(endpoint);
- const entryLocationInAddress = tunnelEndpointToEntryLocationInAddress(endpoint);
- const bridgeInfo = tunnelEndpointToBridgeData(endpoint);
- const obfuscationEndpoint = tunnelEndpointToObfuscationEndpoint(endpoint);
-
- if (obfuscationEndpoint) {
- return obfuscationEndpoint;
- } else if (entryLocationInAddress && inAddress) {
- return entryLocationInAddress;
- } else if (bridgeInfo && inAddress) {
- return bridgeInfo;
- } else {
- return inAddress;
- }
-}
-
-function tunnelEndpointToRelayInAddress(tunnelEndpoint: ITunnelEndpoint): InAddress {
- const socketAddr = parseSocketAddress(tunnelEndpoint.address);
- return {
- ip: socketAddr.host,
- port: socketAddr.port,
- protocol: tunnelEndpoint.protocol,
- tunnelType: tunnelEndpoint.tunnelType,
- };
-}
-
-function tunnelEndpointToEntryLocationInAddress(
- tunnelEndpoint: ITunnelEndpoint,
-): InAddress | undefined {
- if (!tunnelEndpoint.entryEndpoint) {
- return undefined;
- }
-
- const socketAddr = parseSocketAddress(tunnelEndpoint.entryEndpoint.address);
- return {
- ip: socketAddr.host,
- port: socketAddr.port,
- protocol: tunnelEndpoint.entryEndpoint.transportProtocol,
- tunnelType: tunnelEndpoint.tunnelType,
- };
-}
-
-function tunnelEndpointToBridgeData(endpoint: ITunnelEndpoint): BridgeData | undefined {
- if (!endpoint.proxy) {
- return undefined;
- }
-
- const socketAddr = parseSocketAddress(endpoint.proxy.address);
- return {
- ip: socketAddr.host,
- port: socketAddr.port,
- protocol: endpoint.proxy.protocol,
- bridgeType: endpoint.proxy.proxyType,
- };
-}
-
-function tunnelEndpointToObfuscationEndpoint(
- endpoint: ITunnelEndpoint,
-): ObfuscationData | undefined {
- if (!endpoint.obfuscationEndpoint) {
- return undefined;
- }
-
- return {
- ip: endpoint.obfuscationEndpoint.address,
- port: endpoint.obfuscationEndpoint.port,
- protocol: endpoint.obfuscationEndpoint.protocol,
- obfuscationType: endpoint.obfuscationEndpoint.obfuscationType,
- };
-}
diff --git a/gui/src/renderer/components/main-view/ConnectionPanel.tsx b/gui/src/renderer/components/main-view/ConnectionPanel.tsx
deleted file mode 100644
index 34e98abeea..0000000000
--- a/gui/src/renderer/components/main-view/ConnectionPanel.tsx
+++ /dev/null
@@ -1,122 +0,0 @@
-import { useCallback, useEffect } from 'react';
-import styled from 'styled-components';
-
-import { useBoolean } from '../../lib/utility-hooks';
-import { useSelector } from '../../redux/store';
-import CustomScrollbars from '../CustomScrollbars';
-import { BackAction } from '../KeyboardNavigation';
-import ConnectionActionButton from './ConnectionActionButton';
-import ConnectionDetails from './ConnectionDetails';
-import ConnectionPanelChevron from './ConnectionPanelChevron';
-import ConnectionStatus from './ConnectionStatus';
-import FeatureIndicators from './FeatureIndicators';
-import Hostname from './Hostname';
-import Location from './Location';
-import SelectLocationButton from './SelectLocationButton';
-import { ConnectionPanelAccordion } from './styles';
-
-const PANEL_MARGIN = '16px';
-
-const StyledAccordion = styled(ConnectionPanelAccordion)({
- flexShrink: 0,
-});
-
-const StyledConnectionPanel = styled.div<{ $expanded: boolean }>((props) => ({
- position: 'relative',
- display: 'flex',
- flexDirection: 'column',
- maxHeight: `calc(100% - 2 * ${PANEL_MARGIN})`,
- margin: `auto ${PANEL_MARGIN} ${PANEL_MARGIN}`,
- padding: '16px',
- justifySelf: 'flex-end',
- borderRadius: '12px',
- backgroundColor: `rgba(16, 24, 35, ${props.$expanded ? 0.8 : 0.4})`,
- backdropFilter: 'blur(6px)',
-
- transition: 'background-color 300ms ease-out',
-}));
-
-const StyledConnectionButtonContainer = styled.div({
- transition: 'margin-top 300ms ease-out',
- display: 'flex',
- flexDirection: 'column',
- gap: '16px',
- marginTop: '16px',
-});
-
-const StyledCustomScrollbars = styled(CustomScrollbars)({
- flexShrink: 1,
-});
-
-const StyledConnectionPanelChevron = styled(ConnectionPanelChevron)({
- position: 'absolute',
- top: '16px',
- right: '16px',
- width: 'fit-content',
-});
-
-const StyledConnectionStatusContainer = styled.div<{
- $expanded: boolean;
- $hasFeatureIndicators: boolean;
-}>((props) => ({
- paddingBottom: props.$hasFeatureIndicators || props.$expanded ? '16px' : 0,
- marginBottom: props.$expanded && props.$hasFeatureIndicators ? '16px' : 0,
- borderBottom: props.$expanded ? '1px rgba(255, 255, 255, 0.2) solid' : 'none',
- transitionProperty: 'margin-bottom, padding-bottom',
- transitionDuration: '300ms',
- transitionTimingFunction: 'ease-out',
-}));
-
-export default function ConnectionPanel() {
- const [expanded, expandImpl, collapse, toggleExpandedImpl] = useBoolean();
- const tunnelState = useSelector((state) => state.connection.status);
-
- const allowExpand = tunnelState.state === 'connected' || tunnelState.state === 'connecting';
-
- const expand = useCallback(() => {
- if (allowExpand) {
- expandImpl();
- }
- }, [allowExpand, expandImpl]);
-
- const toggleExpanded = useCallback(() => {
- if (allowExpand) {
- toggleExpandedImpl();
- }
- }, [allowExpand, toggleExpandedImpl]);
-
- const hasFeatureIndicators =
- allowExpand &&
- tunnelState.featureIndicators !== undefined &&
- tunnelState.featureIndicators.length > 0;
-
- useEffect(collapse, [tunnelState.state, collapse]);
-
- return (
- <BackAction disabled={!expanded} action={collapse}>
- <StyledConnectionPanel $expanded={expanded}>
- {allowExpand && (
- <StyledConnectionPanelChevron pointsUp={!expanded} onToggle={toggleExpanded} />
- )}
- <StyledConnectionStatusContainer
- $expanded={expanded}
- $hasFeatureIndicators={hasFeatureIndicators}
- onClick={toggleExpanded}>
- <ConnectionStatus />
- <Location />
- <Hostname />
- </StyledConnectionStatusContainer>
- <StyledCustomScrollbars>
- <FeatureIndicators expanded={expanded} expandIsland={expand} />
- <StyledAccordion expanded={expanded}>
- <ConnectionDetails />
- </StyledAccordion>
- </StyledCustomScrollbars>
- <StyledConnectionButtonContainer>
- <SelectLocationButton />
- <ConnectionActionButton />
- </StyledConnectionButtonContainer>
- </StyledConnectionPanel>
- </BackAction>
- );
-}
diff --git a/gui/src/renderer/components/main-view/ConnectionPanelChevron.tsx b/gui/src/renderer/components/main-view/ConnectionPanelChevron.tsx
deleted file mode 100644
index a50fada589..0000000000
--- a/gui/src/renderer/components/main-view/ConnectionPanelChevron.tsx
+++ /dev/null
@@ -1,40 +0,0 @@
-import styled from 'styled-components';
-
-import { colors } from '../../../config.json';
-import ImageView from '../ImageView';
-
-const Container = styled.button({
- display: 'flex',
- alignItems: 'center',
- width: '100%',
- background: 'none',
- border: 'none',
-});
-
-const Chevron = styled(ImageView)({
- [Container + ':hover &&']: {
- backgroundColor: colors.white80,
- },
-});
-
-interface IProps {
- pointsUp: boolean;
- onToggle?: () => void;
- className?: string;
-}
-
-export default function ConnectionPanelChevron(props: IProps) {
- return (
- <Container
- data-testid="connection-panel-chevron"
- className={props.className}
- onClick={props.onToggle}>
- <Chevron
- source={props.pointsUp ? 'icon-chevron-up' : 'icon-chevron-down'}
- width={24}
- height={24}
- tintColor={colors.white}
- />
- </Container>
- );
-}
diff --git a/gui/src/renderer/components/main-view/ConnectionStatus.tsx b/gui/src/renderer/components/main-view/ConnectionStatus.tsx
deleted file mode 100644
index 1745fdaf11..0000000000
--- a/gui/src/renderer/components/main-view/ConnectionStatus.tsx
+++ /dev/null
@@ -1,58 +0,0 @@
-import styled from 'styled-components';
-
-import { colors } from '../../../config.json';
-import { TunnelState } from '../../../shared/daemon-rpc-types';
-import { messages } from '../../../shared/gettext';
-import { useSelector } from '../../redux/store';
-import { largeText } from '../common-styles';
-
-const StyledConnectionStatus = styled.span<{ $color: string }>(largeText, (props) => ({
- minHeight: '24px',
- color: props.$color,
- marginBottom: '4px',
-}));
-
-export default function ConnectionStatus() {
- const tunnelState = useSelector((state) => state.connection.status);
- const lockdownMode = useSelector((state) => state.settings.blockWhenDisconnected);
-
- const color = getConnectionSTatusLabelColor(tunnelState, lockdownMode);
- const text = getConnectionStatusLabelText(tunnelState);
-
- return (
- <StyledConnectionStatus role="status" $color={color}>
- {text}
- </StyledConnectionStatus>
- );
-}
-
-function getConnectionSTatusLabelColor(tunnelState: TunnelState, lockdownMode: boolean) {
- switch (tunnelState.state) {
- case 'connected':
- return colors.green;
- case 'connecting':
- case 'disconnecting':
- return colors.white;
- case 'disconnected':
- return lockdownMode ? colors.white : colors.red;
- case 'error':
- return tunnelState.details.blockingError ? colors.red : colors.white;
- }
-}
-
-function getConnectionStatusLabelText(tunnelState: TunnelState) {
- switch (tunnelState.state) {
- case 'connected':
- return messages.gettext('CONNECTED');
- case 'connecting':
- return messages.gettext('CONNECTING...');
- case 'disconnecting':
- return messages.gettext('DISCONNECTING...');
- case 'disconnected':
- return messages.gettext('DISCONNECTED');
- case 'error':
- return tunnelState.details.blockingError
- ? messages.gettext('FAILED TO SECURE CONNECTION')
- : messages.gettext('BLOCKED CONNECTION');
- }
-}
diff --git a/gui/src/renderer/components/main-view/FeatureIndicators.tsx b/gui/src/renderer/components/main-view/FeatureIndicators.tsx
deleted file mode 100644
index 5b7e60ade0..0000000000
--- a/gui/src/renderer/components/main-view/FeatureIndicators.tsx
+++ /dev/null
@@ -1,288 +0,0 @@
-import { useEffect, useRef } from 'react';
-import { sprintf } from 'sprintf-js';
-import styled from 'styled-components';
-
-import { colors, strings } from '../../../config.json';
-import { FeatureIndicator } from '../../../shared/daemon-rpc-types';
-import { messages } from '../../../shared/gettext';
-import { useStyledRef } from '../../lib/utility-hooks';
-import { useSelector } from '../../redux/store';
-import { tinyText } from '../common-styles';
-import { InfoIcon } from '../InfoButton';
-import { ConnectionPanelAccordion } from './styles';
-
-const LINE_HEIGHT = 22;
-const GAP = 8;
-
-const StyledAccordion = styled(ConnectionPanelAccordion)({
- flexShrink: 0,
-});
-
-const StyledFeatureIndicatorsContainer = styled.div<{ $expanded: boolean }>((props) => ({
- marginTop: '0px',
- marginBottom: props.$expanded ? '8px' : 0,
- transition: 'margin-bottom 300ms ease-out',
-}));
-
-const StyledTitle = styled.h2(tinyText, {
- margin: '0 0 2px',
- fontSize: '10px',
- lineHeight: '15px',
- color: colors.white60,
-});
-
-const StyledFeatureIndicators = styled.div({
- position: 'relative',
-});
-
-const StyledFeatureIndicatorsWrapper = styled.div<{ $expanded: boolean }>((props) => ({
- display: 'flex',
- flexWrap: 'wrap',
- gap: `${GAP}px`,
- maxHeight: props.$expanded ? 'fit-content' : '52px',
- overflow: 'hidden',
-}));
-
-const StyledFeatureIndicatorLabel = styled.span(tinyText, (props) => ({
- display: 'flex',
- gap: '4px',
- padding: '1px 7px',
- justifyContent: 'center',
- alignItems: 'center',
- borderRadius: '4px',
- background: colors.darkerBlue,
- color: colors.white,
- fontWeight: 400,
- whiteSpace: 'nowrap',
- visibility: 'hidden',
-
- // Style clickable feature indicators with a border and on-hover effect
- boxSizing: 'border-box', // make border act as padding rather than margin
- border: 'solid 1px',
- borderColor: props.onClick ? colors.blue : colors.darkerBlue,
- transition: 'background ease-in-out 300ms',
- '&&:hover': {
- background: props.onClick ? colors.blue60 : undefined,
- },
-}));
-
-const StyledBaseEllipsis = styled.span<{ $display: boolean }>(tinyText, (props) => ({
- position: 'absolute',
- top: `${LINE_HEIGHT + GAP}px`,
- color: colors.white,
- padding: '2px 8px 2px 16px',
- display: props.$display ? 'inline' : 'none',
-}));
-
-const StyledEllipsisSpacer = styled(StyledBaseEllipsis)({
- right: 0,
- opacity: 0,
-});
-
-const StyledEllipsis = styled(StyledBaseEllipsis)({
- visibility: 'hidden',
-});
-
-interface FeatureIndicatorsProps {
- expanded: boolean;
- expandIsland: () => void;
-}
-
-// This component needs to render a maximum of two lines of feature indicators and then ellipsis
-// with the text "N more...". This poses two challenges:
-// 1. We can't know the size of the content beforehand or how many indicators should be hidden
-// 2. The ellipsis string doesn't have a fixed width, the amount can change.
-//
-// To solve this the indicators are first rendered hidden along with a invisible "placeholder"
-// ellipsis at the end of the second row. Then after render, all indicators that either is placed
-// after the second row or overlaps with the invisible ellipsis text will be set to invisible. Then
-// we can count those and add another ellipsis element which is visible and place it after the last
-// visible indicator.
-export default function FeatureIndicators(props: FeatureIndicatorsProps) {
- const tunnelState = useSelector((state) => state.connection.status);
- const ellipsisRef = useStyledRef<HTMLSpanElement>();
- const ellipsisSpacerRef = useStyledRef<HTMLSpanElement>();
- const featureIndicatorsContainerRef = useStyledRef<HTMLDivElement>();
-
- const featureIndicatorsVisible =
- tunnelState.state === 'connected' || tunnelState.state === 'connecting';
-
- const featureIndicators = useRef(
- featureIndicatorsVisible ? (tunnelState.featureIndicators ?? []) : [],
- );
-
- if (featureIndicatorsVisible && tunnelState.featureIndicators) {
- featureIndicators.current = tunnelState.featureIndicators;
- }
-
- const ellipsis = messages.gettext('%(amount)d more...');
-
- // Returns an optional callback for clickable feature indicators, or undefined.
- const getFeatureIndicatorOnClick = (indicator: FeatureIndicator) => {
- // NOTE: With the "smart routing" feature indicator removed, this function now does nothing, should it be removed?
- switch (indicator) {
- default:
- return undefined;
- }
- };
-
- useEffect(() => {
- // We need to defer the visibility logic one painting cycle to make sure the elements are
- // rendered and available.
- setTimeout(() => {
- if (
- featureIndicatorsContainerRef.current &&
- ellipsisSpacerRef.current &&
- ellipsisRef.current
- ) {
- // Get all feature indicator elements.
- const indicatorElements = Array.from(
- featureIndicatorsContainerRef.current.getElementsByTagName('span'),
- );
-
- let lastVisibleIndex = 0;
- let hasHidden = false;
- indicatorElements.forEach((indicatorElement, i) => {
- if (
- indicatorShouldBeVisible(
- props.expanded,
- featureIndicatorsContainerRef.current!,
- indicatorElement,
- ellipsisSpacerRef.current!,
- )
- ) {
- // If an indicator should be visible we set its visibility and increment the variable
- // containing the last visible index.
- indicatorElement.style.visibility = 'visible';
- lastVisibleIndex = i;
- } else {
- indicatorElement.style.visibility = 'hidden';
- // If it should be visible we store that there exists hidden indicators.
- hasHidden = true;
- }
- });
-
- if (hasHidden) {
- const lastVisibleIndicatorRect =
- indicatorElements[lastVisibleIndex].getBoundingClientRect();
- const containerRect = featureIndicatorsContainerRef.current.getBoundingClientRect();
-
- // Place the ellipsis at the end of the last visible indicator.
- const left = lastVisibleIndicatorRect.right - containerRect.left;
- // eslint-disable-next-line react-compiler/react-compiler
- ellipsisRef.current.style.left = `${left}px`;
- ellipsisRef.current.style.visibility = 'visible';
-
- // Add the ellipsis text to the ellipsis.
- ellipsisRef.current.textContent = sprintf(ellipsis, {
- amount: indicatorElements.length - (lastVisibleIndex + 1),
- });
- } else {
- ellipsisRef.current.style.visibility = 'hidden';
- }
- }
- }, 0);
- });
-
- const sortedIndicators = [...featureIndicators.current].sort((a, b) => a - b);
-
- return (
- <StyledAccordion expanded={featureIndicatorsVisible && featureIndicators.current.length > 0}>
- <StyledFeatureIndicatorsContainer $expanded={props.expanded}>
- <StyledAccordion expanded={props.expanded}>
- <StyledTitle>{messages.pgettext('connect-view', 'Active features')}</StyledTitle>
- </StyledAccordion>
- <StyledFeatureIndicators>
- <StyledFeatureIndicatorsWrapper
- ref={featureIndicatorsContainerRef}
- $expanded={props.expanded}>
- {sortedIndicators.map((indicator) => {
- const onClick = getFeatureIndicatorOnClick(indicator);
- return (
- <StyledFeatureIndicatorLabel
- key={indicator.toString()}
- data-testid="feature-indicator"
- onClick={onClick}>
- {getFeatureIndicatorLabel(indicator)}
- {onClick ? <InfoIcon size={10} /> : null}
- </StyledFeatureIndicatorLabel>
- );
- })}
- </StyledFeatureIndicatorsWrapper>
- <StyledEllipsisSpacer $display={!props.expanded} ref={ellipsisSpacerRef}>
- {
- // Mock amount for the spacer ellipsis. This needs to be wider than the real
- // ellipsis will ever be.
- sprintf(ellipsis, { amount: 222 })
- }
- </StyledEllipsisSpacer>
- <StyledEllipsis
- onClick={props.expandIsland}
- $display={!props.expanded}
- ref={ellipsisRef}
- />
- </StyledFeatureIndicators>
- </StyledFeatureIndicatorsContainer>
- </StyledAccordion>
- );
-}
-
-function indicatorShouldBeVisible(
- expanded: boolean,
- container: HTMLElement,
- indicator: HTMLElement,
- ellipsisSpacer: HTMLElement,
-): boolean {
- if (expanded) {
- return true;
- }
-
- const indicatorRect = indicator.getBoundingClientRect();
- const ellipsisSpacerRect = ellipsisSpacer.getBoundingClientRect();
- const containerRect = container.getBoundingClientRect();
-
- // Calculate which line the indicator is positioned on.
- const lineIndex = Math.round((indicatorRect.top - containerRect.top) / (LINE_HEIGHT + GAP));
-
- // An indicator should be visible if it's on the first line or if it is on the second line and
- // doesn't overlap with the ellipsis.
- return lineIndex === 0 || (lineIndex === 1 && indicatorRect.right < ellipsisSpacerRect.left);
-}
-
-function getFeatureIndicatorLabel(indicator: FeatureIndicator) {
- switch (indicator) {
- case FeatureIndicator.daita:
- return strings.daita;
- case FeatureIndicator.udp2tcp:
- case FeatureIndicator.shadowsocks:
- return messages.pgettext('wireguard-settings-view', 'Obfuscation');
- case FeatureIndicator.multihop:
- // TRANSLATORS: This refers to the multihop setting in the VPN settings view. This is
- // TRANSLATORS: displayed when the feature is on.
- return messages.gettext('Multihop');
- case FeatureIndicator.customDns:
- // TRANSLATORS: This refers to the Custom DNS setting in the VPN settings view. This is
- // TRANSLATORS: displayed when the feature is on.
- return messages.gettext('Custom DNS');
- case FeatureIndicator.customMtu:
- return messages.pgettext('wireguard-settings-view', 'MTU');
- case FeatureIndicator.bridgeMode:
- return messages.pgettext('openvpn-settings-view', 'Bridge mode');
- case FeatureIndicator.lanSharing:
- return messages.pgettext('vpn-settings-view', 'Local network sharing');
- case FeatureIndicator.customMssFix:
- return messages.pgettext('openvpn-settings-view', 'Mssfix');
- case FeatureIndicator.lockdownMode:
- return messages.pgettext('vpn-settings-view', 'Lockdown mode');
- case FeatureIndicator.splitTunneling:
- return strings.splitTunneling;
- case FeatureIndicator.serverIpOverride:
- return messages.pgettext('settings-import', 'Server IP override');
- case FeatureIndicator.quantumResistance:
- // TRANSLATORS: This refers to the quantum resistance setting in the WireGuard settings view.
- // TRANSLATORS: This is displayed when the feature is on.
- return messages.gettext('Quantum resistance');
- case FeatureIndicator.dnsContentBlockers:
- return messages.pgettext('vpn-settings-view', 'DNS content blockers');
- }
-}
diff --git a/gui/src/renderer/components/main-view/Hostname.tsx b/gui/src/renderer/components/main-view/Hostname.tsx
deleted file mode 100644
index 097e3b291b..0000000000
--- a/gui/src/renderer/components/main-view/Hostname.tsx
+++ /dev/null
@@ -1,69 +0,0 @@
-import { sprintf } from 'sprintf-js';
-import styled from 'styled-components';
-
-import { colors } from '../../../config.json';
-import { messages } from '../../../shared/gettext';
-import { IConnectionReduxState } from '../../redux/connection/reducers';
-import { useSelector } from '../../redux/store';
-import { smallText } from '../common-styles';
-import Marquee from '../Marquee';
-import { ConnectionPanelAccordion } from './styles';
-
-const StyledAccordion = styled(ConnectionPanelAccordion)({
- flexShrink: 0,
-});
-
-const StyledHostname = styled.span(smallText, {
- color: colors.white60,
- fontWeight: '400',
- flexShrink: 0,
- minHeight: '1em',
-});
-
-export default function Hostname() {
- const tunnelState = useSelector((state) => state.connection.status.state);
- const connection = useSelector((state) => state.connection);
- const text = getHostnameText(connection);
-
- return (
- <StyledAccordion expanded={tunnelState === 'connecting' || tunnelState === 'connected'}>
- <StyledHostname data-testid="hostname-line">
- <Marquee>{text}</Marquee>
- </StyledHostname>
- </StyledAccordion>
- );
-}
-
-function getHostnameText(connection: IConnectionReduxState) {
- let hostname = '';
-
- if (connection.hostname && connection.bridgeHostname) {
- hostname = sprintf(messages.pgettext('connection-info', '%(relay)s via %(entry)s'), {
- relay: connection.hostname,
- entry: connection.bridgeHostname,
- });
- } else if (connection.hostname && connection.entryHostname) {
- hostname = sprintf(
- // TRANSLATORS: The hostname line displayed below the country on the main screen
- // TRANSLATORS: Available placeholders:
- // TRANSLATORS: %(relay)s - the relay hostname
- // TRANSLATORS: %(entry)s - the entry relay hostname
- messages.pgettext('connection-info', '%(relay)s via %(entry)s'),
- {
- relay: connection.hostname,
- entry: connection.entryHostname,
- },
- );
- } else if (
- (connection.status.state === 'connecting' || connection.status.state === 'connected') &&
- connection.status.details?.endpoint.proxy !== undefined
- ) {
- hostname = sprintf(messages.pgettext('connection-info', '%(relay)s via Custom bridge'), {
- relay: connection.hostname,
- });
- } else if (connection.hostname) {
- hostname = connection.hostname;
- }
-
- return hostname;
-}
diff --git a/gui/src/renderer/components/main-view/Location.tsx b/gui/src/renderer/components/main-view/Location.tsx
deleted file mode 100644
index 2685394158..0000000000
--- a/gui/src/renderer/components/main-view/Location.tsx
+++ /dev/null
@@ -1,41 +0,0 @@
-import styled from 'styled-components';
-
-import { colors } from '../../../config.json';
-import { TunnelState } from '../../../shared/daemon-rpc-types';
-import { useSelector } from '../../redux/store';
-import { largeText } from '../common-styles';
-import Marquee from '../Marquee';
-import { ConnectionPanelAccordion } from './styles';
-
-const StyledLocation = styled.span(largeText, {
- color: colors.white,
- flexShrink: 0,
-});
-
-export default function Location() {
- const connection = useSelector((state) => state.connection);
- const text = getLocationText(connection.status, connection.country, connection.city);
-
- return (
- <ConnectionPanelAccordion expanded={connection.status.state !== 'error'}>
- <StyledLocation>
- <Marquee>{text}</Marquee>
- </StyledLocation>
- </ConnectionPanelAccordion>
- );
-}
-
-function getLocationText(tunnelState: TunnelState, country?: string, city?: string): string {
- country = country ?? '';
-
- switch (tunnelState.state) {
- case 'connected':
- case 'connecting':
- return city ? `${country}, ${city}` : country;
- case 'disconnecting':
- case 'disconnected':
- return country;
- case 'error':
- return '';
- }
-}
diff --git a/gui/src/renderer/components/main-view/MainView.tsx b/gui/src/renderer/components/main-view/MainView.tsx
deleted file mode 100644
index 9327094a51..0000000000
--- a/gui/src/renderer/components/main-view/MainView.tsx
+++ /dev/null
@@ -1,67 +0,0 @@
-import styled from 'styled-components';
-
-import { useSelector } from '../../redux/store';
-import { calculateHeaderBarStyle, DefaultHeaderBar } from '../HeaderBar';
-import ImageView from '../ImageView';
-import { Container, Layout } from '../Layout';
-import Map from '../Map';
-import NotificationArea from '../NotificationArea';
-import ConnectionPanel from './ConnectionPanel';
-
-const StyledContainer = styled(Container)({
- position: 'relative',
-});
-
-const Content = styled.div({
- display: 'flex',
- flex: 1,
- flexDirection: 'column',
- position: 'relative', // need this for z-index to work to cover the map
- zIndex: 1,
- maxHeight: '100%',
-});
-
-const StatusIcon = styled(ImageView)({
- position: 'absolute',
- alignSelf: 'center',
- marginTop: 94,
-});
-
-const StyledNotificationArea = styled(NotificationArea)({
- position: 'absolute',
- left: 0,
- top: 0,
- right: 0,
-});
-
-const StyledMain = styled.main({
- display: 'flex',
- flexDirection: 'column',
- flex: 1,
- maxHeight: '100%',
-});
-
-export default function MainView() {
- const connection = useSelector((state) => state.connection);
-
- const showSpinner =
- connection.status.state === 'connecting' || connection.status.state === 'disconnecting';
-
- return (
- <Layout>
- <DefaultHeaderBar barStyle={calculateHeaderBarStyle(connection.status)} />
- <StyledContainer>
- <Map />
- <Content>
- <StyledNotificationArea />
-
- <StyledMain>
- {showSpinner ? <StatusIcon source="icon-spinner" height={60} width={60} /> : null}
-
- <ConnectionPanel />
- </StyledMain>
- </Content>
- </StyledContainer>
- </Layout>
- );
-}
diff --git a/gui/src/renderer/components/main-view/SelectLocationButton.tsx b/gui/src/renderer/components/main-view/SelectLocationButton.tsx
deleted file mode 100644
index 50508a3192..0000000000
--- a/gui/src/renderer/components/main-view/SelectLocationButton.tsx
+++ /dev/null
@@ -1,143 +0,0 @@
-import { useCallback, useMemo } from 'react';
-import { sprintf } from 'sprintf-js';
-import styled from 'styled-components';
-
-import { ICustomList } from '../../../shared/daemon-rpc-types';
-import { messages, relayLocations } from '../../../shared/gettext';
-import log from '../../../shared/logging';
-import { useAppContext } from '../../context';
-import { transitions, useHistory } from '../../lib/history';
-import { RoutePath } from '../../lib/routes';
-import { IRelayLocationCountryRedux, RelaySettingsRedux } from '../../redux/settings/reducers';
-import { useSelector } from '../../redux/store';
-import ImageView from '../ImageView';
-import { MultiButton, MultiButtonCompatibleProps } from '../MultiButton';
-import { SmallButton, SmallButtonColor } from '../SmallButton';
-
-const StyledSmallButton = styled(SmallButton)({
- margin: 0,
-});
-
-const StyledReconnectButton = styled(StyledSmallButton)({
- padding: '4px 8px 4px 8px',
-});
-
-export default function SelectLocationButtons() {
- const tunnelState = useSelector((state) => state.connection.status.state);
-
- if (tunnelState === 'connecting' || tunnelState === 'connected') {
- return <MultiButton mainButton={SelectLocationButton} sideButton={ReconnectButton} />;
- } else {
- return <SelectLocationButton />;
- }
-}
-
-function SelectLocationButton(props: MultiButtonCompatibleProps) {
- const { push } = useHistory();
-
- const tunnelState = useSelector((state) => state.connection.status.state);
- const relaySettings = useSelector((state) => state.settings.relaySettings);
- const relayLocations = useSelector((state) => state.settings.relayLocations);
- const customLists = useSelector((state) => state.settings.customLists);
-
- const selectedRelayName = useMemo(
- () => getRelayName(relaySettings, customLists, relayLocations),
- [relaySettings, customLists, relayLocations],
- );
-
- const onSelectLocation = useCallback(() => {
- push(RoutePath.selectLocation, { transition: transitions.show });
- }, [push]);
-
- return (
- <StyledSmallButton
- color={SmallButtonColor.blue}
- onClick={onSelectLocation}
- aria-label={sprintf(
- messages.pgettext('accessibility', 'Select location. Current location is %(location)s'),
- { location: selectedRelayName },
- )}
- {...props}>
- {tunnelState === 'disconnected'
- ? selectedRelayName
- : messages.pgettext('tunnel-control', 'Switch location')}
- </StyledSmallButton>
- );
-}
-
-function getRelayName(
- relaySettings: RelaySettingsRedux,
- customLists: Array<ICustomList>,
- locations: IRelayLocationCountryRedux[],
-): string {
- if ('normal' in relaySettings) {
- const location = relaySettings.normal.location;
-
- if (location === 'any') {
- return 'Automatic';
- } else if ('customList' in location) {
- return customLists.find((list) => list.id === location.customList)?.name ?? 'Unknown';
- } else if ('hostname' in location) {
- const country = locations.find(({ code }) => code === location.country);
- if (country) {
- const city = country.cities.find(({ code }) => code === location.city);
- if (city) {
- return sprintf(
- // TRANSLATORS: The selected location label displayed on the main view, when a user selected a specific host to connect to.
- // TRANSLATORS: Example: Malmö (se-mma-001)
- // TRANSLATORS: Available placeholders:
- // TRANSLATORS: %(city)s - a city name
- // TRANSLATORS: %(hostname)s - a hostname
- messages.pgettext('connect-container', '%(city)s (%(hostname)s)'),
- {
- city: relayLocations.gettext(city.name),
- hostname: location.hostname,
- },
- );
- }
- }
- } else if ('city' in location) {
- const country = locations.find(({ code }) => code === location.country);
- if (country) {
- const city = country.cities.find(({ code }) => code === location.city);
- if (city) {
- return relayLocations.gettext(city.name);
- }
- }
- } else if ('country' in location) {
- const country = locations.find(({ code }) => code === location.country);
- if (country) {
- return relayLocations.gettext(country.name);
- }
- }
-
- return 'Unknown';
- } else if (relaySettings.customTunnelEndpoint) {
- return 'Custom';
- } else {
- throw new Error('Unsupported relay settings.');
- }
-}
-
-function ReconnectButton(props: MultiButtonCompatibleProps) {
- const { reconnectTunnel } = useAppContext();
-
- const onReconnect = useCallback(async () => {
- try {
- await reconnectTunnel();
- } catch (e) {
- const error = e as Error;
- log.error(`Failed to reconnect the tunnel: ${error.message}`);
- }
- }, [reconnectTunnel]);
-
- return (
- <StyledReconnectButton
- color={SmallButtonColor.blue}
- onClick={onReconnect}
- aria-label={messages.gettext('Reconnect')}
- {...props}>
- <ImageView height={24} width={24} source="icon-reload" tintColor="white" />
- </StyledReconnectButton>
- );
-}
diff --git a/gui/src/renderer/components/main-view/styles.ts b/gui/src/renderer/components/main-view/styles.ts
deleted file mode 100644
index 58c6e85eb2..0000000000
--- a/gui/src/renderer/components/main-view/styles.ts
+++ /dev/null
@@ -1,7 +0,0 @@
-import styled from 'styled-components';
-
-import Accordion from '../Accordion';
-
-export const ConnectionPanelAccordion = styled(Accordion)({
- transition: 'height 300ms ease-out',
-});
diff --git a/gui/src/renderer/components/select-location/CombinedLocationList.tsx b/gui/src/renderer/components/select-location/CombinedLocationList.tsx
deleted file mode 100644
index 5ef9918b8f..0000000000
--- a/gui/src/renderer/components/select-location/CombinedLocationList.tsx
+++ /dev/null
@@ -1,39 +0,0 @@
-import React from 'react';
-
-import { RelayLocation } from '../../../shared/daemon-rpc-types';
-import RelayLocationList from './RelayLocationList';
-import { RelayList, SpecialLocation } from './select-location-types';
-import SpecialLocationList from './SpecialLocationList';
-
-export interface CombinedLocationListProps<T> {
- relayLocations: RelayList;
- specialLocations?: Array<SpecialLocation<T>>;
- allowAddToCustomList: boolean;
- selectedElementRef: React.Ref<HTMLDivElement>;
- onSelectRelay: (value: RelayLocation) => void;
- onSelectSpecial: (value: T) => void;
- onExpand: (location: RelayLocation) => void;
- onCollapse: (location: RelayLocation) => void;
- onWillExpand: (
- locationRect: DOMRect,
- expandedContentHeight: number,
- invokedByUser: boolean,
- ) => void;
- onTransitionEnd: () => void;
-}
-
-// Renders the special locations and the regular locations as separate lists
-export default function CombinedLocationList<T>(props: CombinedLocationListProps<T>) {
- return (
- <>
- {props.specialLocations !== undefined && props.specialLocations.length > 0 && (
- <SpecialLocationList
- {...props}
- source={props.specialLocations}
- onSelect={props.onSelectSpecial}
- />
- )}
- <RelayLocationList {...props} source={props.relayLocations} onSelect={props.onSelectRelay} />
- </>
- );
-}
diff --git a/gui/src/renderer/components/select-location/CustomListDialogs.tsx b/gui/src/renderer/components/select-location/CustomListDialogs.tsx
deleted file mode 100644
index 4cc42c0623..0000000000
--- a/gui/src/renderer/components/select-location/CustomListDialogs.tsx
+++ /dev/null
@@ -1,260 +0,0 @@
-import { useCallback, useState } from 'react';
-import { sprintf } from 'sprintf-js';
-import styled from 'styled-components';
-
-import { colors } from '../../../config.json';
-import {
- compareRelayLocationGeographical,
- ICustomList,
- RelayLocation,
- RelayLocationGeographical,
-} from '../../../shared/daemon-rpc-types';
-import { messages } from '../../../shared/gettext';
-import log from '../../../shared/logging';
-import { useAppContext } from '../../context';
-import { formatHtml } from '../../lib/html-formatter';
-import { useBoolean } from '../../lib/utility-hooks';
-import { useSelector } from '../../redux/store';
-import * as AppButton from '../AppButton';
-import * as Cell from '../cell';
-import { normalText, tinyText } from '../common-styles';
-import { ModalAlert, ModalAlertType, ModalMessage } from '../Modal';
-import SimpleInput from '../SimpleInput';
-
-const StyledModalMessage = styled(ModalMessage)({
- marginTop: '8px',
- marginBottom: '8px',
-});
-
-interface AddToListDialogProps {
- location: RelayLocationGeographical;
- isOpen: boolean;
- hide: () => void;
-}
-
-// Dialog that displays list of custom lists when adding location to custom list.
-export function AddToListDialog(props: AddToListDialogProps) {
- const { hide } = props;
-
- const { updateCustomList } = useAppContext();
- const customLists = useSelector((state) => state.settings.customLists);
-
- const add = useCallback(
- async (list: ICustomList) => {
- // Update the list with the new location.
- const updatedList = {
- ...list,
- locations: [...list.locations, props.location],
- };
- try {
- await updateCustomList(updatedList);
- } catch (e) {
- const error = e as Error;
- log.error(`Failed to edit custom list ${list.id}: ${error.message}`);
- }
-
- hide();
- },
- [hide, props.location, updateCustomList],
- );
-
- let locationType: string;
- if ('hostname' in props.location) {
- // TRANSLATORS: This refers to our VPN relays/servers
- locationType = messages.pgettext('select-location-view', 'Relay');
- } else if ('city' in props.location) {
- locationType = messages.pgettext('select-location-view', 'City');
- } else {
- locationType = messages.pgettext('select-location-view', 'Country');
- }
-
- const lists = customLists.map((list) => (
- <SelectList key={list.id} list={list} location={props.location} add={add} />
- ));
-
- return (
- <ModalAlert
- isOpen={props.isOpen}
- buttons={[
- <AppButton.BlueButton key="cancel" onClick={props.hide}>
- {messages.gettext('Cancel')}
- </AppButton.BlueButton>,
- ]}
- close={props.hide}>
- <StyledModalMessage>
- {formatHtml(
- sprintf(
- // TRANSLATORS: This is a label shown above a list of options.
- // TRANSLATORS: Available placeholder:
- // TRANSLATORS: %(locationType) - Could be either "Country", "City" and "Relay"
- messages.pgettext('select-location-view', 'Add <b>%(locationType)s</b> to list'),
- {
- locationType,
- },
- ),
- )}
- </StyledModalMessage>
- {lists}
- </ModalAlert>
- );
-}
-
-const StyledSelectListItemLabel = styled(Cell.Label)(normalText, {
- fontWeight: 'normal',
-});
-
-const StyledSelectListItemIcon = styled(Cell.Icon)({
- [`${Cell.CellButton}:not(:disabled):hover &&`]: {
- backgroundColor: colors.white80,
- },
-});
-
-interface SelectListProps {
- list: ICustomList;
- location: RelayLocation;
- add: (list: ICustomList) => void;
-}
-
-function SelectList(props: SelectListProps) {
- const { add } = props;
-
- const onAdd = useCallback(() => add(props.list), [add, props.list]);
-
- // List should be disabled if location already is in list.
- const disabled = props.list.locations.some((location) =>
- compareRelayLocationGeographical(location, props.location),
- );
-
- return (
- <Cell.CellButton onClick={onAdd} disabled={disabled}>
- <StyledSelectListItemLabel>
- {props.list.name} {disabled && messages.pgettext('select-location-view', '(Added)')}
- </StyledSelectListItemLabel>
- <StyledSelectListItemIcon source="icon-add" width={18} />
- </Cell.CellButton>
- );
-}
-
-const StyledInputErrorText = styled.span(tinyText, {
- marginTop: '6px',
- color: colors.red,
-});
-
-interface EditListProps {
- list: ICustomList;
- isOpen: boolean;
- hide: () => void;
-}
-
-// Dialog for changing the name of a custom list.
-export function EditListDialog(props: EditListProps) {
- const { hide } = props;
-
- const { updateCustomList } = useAppContext();
-
- const [newName, setNewName] = useState(props.list.name);
- const newNameTrimmed = newName.trim();
- const newNameValid = newNameTrimmed !== '';
- const [error, setError, unsetError] = useBoolean();
-
- // Update name in list and save it.
- const save = useCallback(async () => {
- if (newNameValid) {
- try {
- const updatedList = { ...props.list, name: newNameTrimmed };
- const result = await updateCustomList(updatedList);
- if (result && result.type === 'name already exists') {
- setError();
- } else {
- hide();
- }
- } catch (e) {
- const error = e as Error;
- log.error(`Failed to edit custom list ${props.list.id}: ${error.message}`);
- }
- }
- }, [newNameValid, props.list, newNameTrimmed, updateCustomList, setError, hide]);
-
- // Errors should be reset when editing the value
- const onChange = useCallback(
- (value: string) => {
- setNewName(value);
- unsetError();
- },
- [unsetError],
- );
-
- return (
- <ModalAlert
- isOpen={props.isOpen}
- buttons={[
- <AppButton.BlueButton key="save" disabled={!newNameValid} onClick={save}>
- {messages.gettext('Save')}
- </AppButton.BlueButton>,
- <AppButton.BlueButton key="cancel" onClick={props.hide}>
- {messages.gettext('Cancel')}
- </AppButton.BlueButton>,
- ]}
- close={props.hide}>
- <StyledModalMessage>
- {messages.pgettext('select-location-view', 'Edit list name')}
- </StyledModalMessage>
- <SimpleInput
- value={newName}
- onChangeValue={onChange}
- onSubmitValue={save}
- maxLength={30}
- autoFocus
- />
- {error && (
- <StyledInputErrorText>
- {messages.pgettext('select-location-view', 'Name is already taken.')}
- </StyledInputErrorText>
- )}
- </ModalAlert>
- );
-}
-
-interface DeleteConfirmDialogProps {
- list: ICustomList;
- isOpen: boolean;
- hide: () => void;
- confirm: () => void;
-}
-
-// Dialog for changing the name of a custom list.
-export function DeleteConfirmDialog(props: DeleteConfirmDialogProps) {
- const { confirm: propsConfirm, hide } = props;
-
- const confirm = useCallback(() => {
- propsConfirm();
- hide();
- }, [hide, propsConfirm]);
-
- return (
- <ModalAlert
- type={ModalAlertType.warning}
- isOpen={props.isOpen}
- buttons={[
- <AppButton.RedButton key="save" onClick={confirm}>
- {messages.gettext('Delete list')}
- </AppButton.RedButton>,
- <AppButton.BlueButton key="cancel" onClick={props.hide}>
- {messages.gettext('Cancel')}
- </AppButton.BlueButton>,
- ]}
- close={props.hide}>
- <ModalMessage>
- {formatHtml(
- sprintf(
- messages.pgettext(
- 'select-location-view',
- 'Do you want to delete the list <b>%(list)s</b>?',
- ),
- { list: props.list.name },
- ),
- )}
- </ModalMessage>
- </ModalAlert>
- );
-}
diff --git a/gui/src/renderer/components/select-location/CustomLists.tsx b/gui/src/renderer/components/select-location/CustomLists.tsx
deleted file mode 100644
index 1f4c77ed6b..0000000000
--- a/gui/src/renderer/components/select-location/CustomLists.tsx
+++ /dev/null
@@ -1,251 +0,0 @@
-import { useCallback, useEffect, useState } from 'react';
-import styled from 'styled-components';
-
-import { colors } from '../../../config.json';
-import { CustomListError, CustomLists, RelayLocation } from '../../../shared/daemon-rpc-types';
-import { messages } from '../../../shared/gettext';
-import log from '../../../shared/logging';
-import { useAppContext } from '../../context';
-import { useBoolean, useStyledRef } from '../../lib/utility-hooks';
-import Accordion from '../Accordion';
-import * as Cell from '../cell';
-import { measurements } from '../common-styles';
-import { BackAction } from '../KeyboardNavigation';
-import SimpleInput from '../SimpleInput';
-import { useRelayListContext } from './RelayListContext';
-import RelayLocationList from './RelayLocationList';
-import { useScrollPositionContext } from './ScrollPositionContext';
-import { useSelectLocationContext } from './SelectLocationContainer';
-
-const StyledCellContainer = styled(Cell.Container)({
- padding: 0,
- background: 'none',
-});
-
-const StyledInputContainer = styled.div({
- display: 'flex',
- alignItems: 'center',
- flex: 1,
- backgroundColor: colors.blue,
- paddingLeft: measurements.viewMargin,
- height: measurements.rowMinHeight,
-});
-
-const StyledHeaderLabel = styled(Cell.Label)({
- display: 'block',
- flex: 1,
- backgroundColor: colors.blue,
- paddingLeft: measurements.viewMargin,
- margin: 0,
- height: measurements.rowMinHeight,
- lineHeight: measurements.rowMinHeight,
-});
-
-const StyledCellButton = styled(Cell.SideButton)({
- border: 'none',
-});
-
-const StyledAddListCellButton = styled(StyledCellButton)({
- marginLeft: 'auto',
-});
-
-const StyledSideButtonIcon = styled(Cell.Icon)({
- padding: '3px',
-
- [`${StyledCellButton}:disabled &&, ${StyledAddListCellButton}:disabled &&`]: {
- backgroundColor: colors.white40,
- },
-
- [`${StyledCellButton}:not(:disabled):hover &&, ${StyledAddListCellButton}:not(:disabled):hover &&`]:
- {
- backgroundColor: colors.white,
- },
-});
-
-const StyledInput = styled(SimpleInput)<{ $error: boolean }>((props) => ({
- color: props.$error ? colors.red : 'auto',
-}));
-
-interface CustomListsProps {
- selectedElementRef: React.Ref<HTMLDivElement>;
- onSelect: (value: RelayLocation) => void;
-}
-
-export default function CustomLists(props: CustomListsProps) {
- const [addListVisible, showAddList, hideAddList] = useBoolean();
- const { createCustomList } = useAppContext();
- const { searchTerm } = useSelectLocationContext();
- const { customLists } = useRelayListContext();
-
- const createList = useCallback(
- async (name: string): Promise<void | CustomListError> => {
- const result = await createCustomList(name);
- // If an error is returned it should be passed as the return value.
- if (result) {
- return result;
- }
-
- hideAddList();
- },
- [createCustomList, hideAddList],
- );
-
- if (searchTerm !== '' && !customLists.some((list) => list.visible)) {
- return null;
- }
-
- return (
- <Cell.Group>
- <StyledCellContainer>
- <StyledHeaderLabel>
- {messages.pgettext('select-location-view', 'Custom lists')}
- </StyledHeaderLabel>
- <StyledCellButton
- $backgroundColor={colors.blue}
- $backgroundColorHover={colors.blue80}
- onClick={showAddList}>
- <StyledSideButtonIcon source="icon-add" tintColor={colors.white60} width={18} />
- </StyledCellButton>
- </StyledCellContainer>
-
- <Accordion expanded>
- <CustomListsImpl selectedElementRef={props.selectedElementRef} onSelect={props.onSelect} />
- </Accordion>
-
- <AddListForm visible={addListVisible} onCreateList={createList} cancel={hideAddList} />
- </Cell.Group>
- );
-}
-
-interface AddListFormProps {
- visible: boolean;
- onCreateList: (list: string) => Promise<void | CustomListError>;
- cancel: () => void;
-}
-
-function AddListForm(props: AddListFormProps) {
- const { onCreateList, cancel } = props;
-
- const [name, setName] = useState('');
- const nameTrimmed = name.trim();
- const nameValid = nameTrimmed !== '';
- const [error, setError, unsetError] = useBoolean();
- const containerRef = useStyledRef<HTMLDivElement>();
- const inputRef = useStyledRef<HTMLInputElement>();
-
- // Errors should be reset when editing the value
- const onChange = useCallback(
- (value: string) => {
- setName(value);
- unsetError();
- },
- [unsetError],
- );
-
- const createList = useCallback(async () => {
- if (nameValid) {
- try {
- const result = await onCreateList(nameTrimmed);
- if (result) {
- setError();
- }
- } catch (e) {
- const error = e as Error;
- log.error('Failed to create list:', error.message);
- }
- }
- }, [nameValid, onCreateList, nameTrimmed, setError]);
-
- const onBlur = useCallback(
- (event: React.FocusEvent<HTMLInputElement>) => {
- // Only cancel if losing focus to something else than the contents of the row container.
- if (!event.relatedTarget || !containerRef.current?.contains(event.relatedTarget)) {
- cancel();
- }
- },
- [containerRef, cancel],
- );
-
- const onTransitionEnd = useCallback(() => {
- if (!props.visible) {
- setName('');
- }
- }, [props.visible]);
-
- useEffect(() => {
- if (props.visible) {
- inputRef.current?.focus();
- }
- }, [inputRef, props.visible]);
-
- return (
- <BackAction disabled={!props.visible} action={props.cancel}>
- <Accordion expanded={props.visible} onTransitionEnd={onTransitionEnd}>
- <StyledCellContainer ref={containerRef}>
- <StyledInputContainer>
- <StyledInput
- ref={inputRef}
- value={name}
- onChangeValue={onChange}
- onSubmitValue={createList}
- onBlur={onBlur}
- maxLength={30}
- $error={error}
- autoFocus
- />
- </StyledInputContainer>
-
- <StyledAddListCellButton
- $backgroundColor={colors.blue}
- $backgroundColorHover={colors.blue80}
- disabled={!nameValid}
- onClick={createList}>
- <StyledSideButtonIcon source="icon-check" tintColor={colors.white60} width={18} />
- </StyledAddListCellButton>
- </StyledCellContainer>
- <Cell.CellFooter>
- <Cell.CellFooterText>
- {messages.pgettext('select-location-view', 'List names must be unique.')}
- </Cell.CellFooterText>
- </Cell.CellFooter>
- </Accordion>
- </BackAction>
- );
-}
-
-interface CustomListsImplProps {
- selectedElementRef: React.Ref<HTMLDivElement>;
- onSelect: (value: RelayLocation) => void;
-}
-
-function CustomListsImpl(props: CustomListsImplProps) {
- const { onSelect: propsOnSelect } = props;
-
- const { customLists, expandLocation, collapseLocation, onBeforeExpand } = useRelayListContext();
- const { resetHeight } = useScrollPositionContext();
-
- const onSelect = useCallback(
- (value: RelayLocation) => {
- const location = { ...value };
- if ('country' in location) {
- // Only the geographical part should be sent to the daemon when setting a location.
- delete location.customList;
- }
- propsOnSelect(location);
- },
- [propsOnSelect],
- );
-
- return (
- <RelayLocationList
- source={customLists}
- onExpand={expandLocation}
- onCollapse={collapseLocation}
- onWillExpand={onBeforeExpand}
- selectedElementRef={props.selectedElementRef}
- onSelect={onSelect}
- onTransitionEnd={resetHeight}
- allowAddToCustomList={false}
- />
- );
-}
diff --git a/gui/src/renderer/components/select-location/LocationRow.tsx b/gui/src/renderer/components/select-location/LocationRow.tsx
deleted file mode 100644
index a47c1dd646..0000000000
--- a/gui/src/renderer/components/select-location/LocationRow.tsx
+++ /dev/null
@@ -1,296 +0,0 @@
-import React, { useCallback, useRef } from 'react';
-import { sprintf } from 'sprintf-js';
-
-import {
- compareRelayLocation,
- compareRelayLocationGeographical,
- RelayLocation,
-} from '../../../shared/daemon-rpc-types';
-import { messages } from '../../../shared/gettext';
-import log from '../../../shared/logging';
-import { useAppContext } from '../../context';
-import { useBoolean, useStyledRef } from '../../lib/utility-hooks';
-import { useSelector } from '../../redux/store';
-import Accordion from '../Accordion';
-import * as Cell from '../cell';
-import ChevronButton from '../ChevronButton';
-import RelayStatusIndicator from '../RelayStatusIndicator';
-import { AddToListDialog, DeleteConfirmDialog, EditListDialog } from './CustomListDialogs';
-import {
- getButtonColor,
- StyledHoverIcon,
- StyledHoverIconButton,
- StyledLocationRowButton,
- StyledLocationRowContainer,
- StyledLocationRowLabel,
-} from './LocationRowStyles';
-import {
- CitySpecification,
- CountrySpecification,
- getLocationChildren,
- LocationSpecification,
- RelaySpecification,
-} from './select-location-types';
-
-interface IProps<C extends LocationSpecification> {
- source: C;
- level: number;
- selectedElementRef: React.Ref<HTMLDivElement>;
- onSelect: (value: RelayLocation) => void;
- onExpand: (location: RelayLocation) => void;
- onCollapse: (location: RelayLocation) => void;
- allowAddToCustomList: boolean;
- onWillExpand: (
- locationRect: DOMRect,
- expandedContentHeight: number,
- invokedByUser: boolean,
- ) => void;
- onTransitionEnd: () => void;
- children?: C extends RelaySpecification
- ? never
- : React.ReactElement<
- IProps<C extends CountrySpecification ? CitySpecification : RelaySpecification>
- >[];
-}
-
-// Renders the rows and its children for countries, cities and relays
-function LocationRow<C extends LocationSpecification>(props: IProps<C>) {
- const { onSelect, onWillExpand: propsOnWillExpand } = props;
-
- const hasChildren = getLocationChildren(props.source).some((child) => child.visible);
- const buttonRef = useStyledRef<HTMLButtonElement>();
- const userInvokedExpand = useRef(false);
-
- const { updateCustomList, deleteCustomList } = useAppContext();
- const [addToListDialogVisible, showAddToListDialog, hideAddToListDialog] = useBoolean();
- const [editDialogVisible, showEditDialog, hideEditDialog] = useBoolean();
- const [deleteDialogVisible, showDeleteDialog, hideDeleteDialog] = useBoolean();
- const background = getButtonColor(props.source.selected, props.level, props.source.disabled);
-
- const customLists = useSelector((state) => state.settings.customLists);
-
- // Expand/collapse should only be available if the expanded property is provided in the source
- const expanded = 'expanded' in props.source ? props.source.expanded : undefined;
- const toggleCollapse = useCallback(() => {
- if (expanded !== undefined && hasChildren) {
- userInvokedExpand.current = true;
- const callback = expanded ? props.onCollapse : props.onExpand;
- callback(props.source.location);
- }
- }, [props.onExpand, props.onCollapse, props.source.location, expanded, hasChildren]);
-
- const handleClick = useCallback(() => {
- if (!props.source.selected) {
- onSelect(props.source.location);
- }
- }, [onSelect, props.source.location, props.source.selected]);
-
- const onWillExpand = useCallback(
- (nextHeight: number) => {
- const buttonRect = buttonRef.current?.getBoundingClientRect();
- if (expanded !== undefined && buttonRect) {
- propsOnWillExpand(buttonRect, nextHeight, userInvokedExpand.current);
- userInvokedExpand.current = false;
- }
- },
- [buttonRef, expanded, propsOnWillExpand],
- );
-
- const onRemoveFromList = useCallback(async () => {
- if (props.source.location.customList) {
- // Find the list and remove the location from it.
- const list = customLists.find((list) => list.id === props.source.location.customList);
- if (list !== undefined) {
- const updatedList = {
- ...list,
- locations: list.locations.filter((location) => {
- return !compareRelayLocationGeographical(location, props.source.location);
- }),
- };
-
- try {
- await updateCustomList(updatedList);
- } catch (e) {
- const error = e as Error;
- log.error(
- `Failed to edit custom list ${props.source.location.customList}: ${error.message}`,
- );
- }
- }
- }
- }, [customLists, props.source.location, updateCustomList]);
-
- // Remove an entire custom list.
- const confirmRemoveCustomList = useCallback(async () => {
- if (props.source.location.customList) {
- try {
- await deleteCustomList(props.source.location.customList);
- } catch (e) {
- const error = e as Error;
- log.error(
- `Failed to delete custom list ${props.source.location.customList}: ${error.message}`,
- );
- }
- }
- }, [deleteCustomList, props.source.location.customList]);
-
- if (!props.source.visible) {
- return null;
- }
-
- // The selectedRef should only be used if the element is selected
- const selectedRef = props.source.selected ? props.selectedElementRef : undefined;
- return (
- <>
- <StyledLocationRowContainer ref={selectedRef} disabled={props.source.disabled}>
- <StyledLocationRowButton
- as="button"
- ref={buttonRef}
- onClick={handleClick}
- $level={props.level}
- disabled={props.source.disabled}
- includeMarginBottomOnLast
- {...background}>
- <RelayStatusIndicator active={props.source.active} selected={props.source.selected} />
- <StyledLocationRowLabel>{props.source.label}</StyledLocationRowLabel>
- </StyledLocationRowButton>
-
- {props.allowAddToCustomList ? (
- <StyledHoverIconButton onClick={showAddToListDialog} $isLast {...background}>
- <StyledHoverIcon source="icon-add" />
- </StyledHoverIconButton>
- ) : null}
-
- {/* Show remove from custom list button if location is top level item in a custom list. */}
- {'customList' in props.source.location &&
- 'country' in props.source.location &&
- props.level === 1 ? (
- <StyledHoverIconButton onClick={onRemoveFromList} $isLast {...background}>
- <StyledHoverIcon source="icon-remove" />
- </StyledHoverIconButton>
- ) : null}
-
- {/* Show buttons for editing and removing a custom list */}
- {'customList' in props.source.location && !('country' in props.source.location) ? (
- <>
- <StyledHoverIconButton onClick={showEditDialog} {...background}>
- <StyledHoverIcon source="icon-edit" />
- </StyledHoverIconButton>
- <StyledHoverIconButton onClick={showDeleteDialog} $isLast {...background}>
- <StyledHoverIcon source="icon-close" />
- </StyledHoverIconButton>
- </>
- ) : null}
-
- {hasChildren ||
- ('customList' in props.source.location && !('country' in props.source.location)) ? (
- <Cell.SideButton
- as={ChevronButton}
- onClick={toggleCollapse}
- disabled={!hasChildren}
- up={expanded ?? false}
- aria-label={sprintf(
- expanded === true
- ? messages.pgettext('accessibility', 'Collapse %(location)s')
- : messages.pgettext('accessibility', 'Expand %(location)s'),
- { location: props.source.label },
- )}
- {...background}
- />
- ) : null}
- </StyledLocationRowContainer>
-
- {hasChildren && (
- <Accordion
- expanded={expanded}
- onWillExpand={onWillExpand}
- onTransitionEnd={props.onTransitionEnd}
- animationDuration={150}>
- <Cell.Group $noMarginBottom>{props.children}</Cell.Group>
- </Accordion>
- )}
-
- {'country' in props.source.location && (
- <AddToListDialog
- isOpen={addToListDialogVisible}
- hide={hideAddToListDialog}
- location={props.source.location}
- />
- )}
-
- {'list' in props.source && (
- <EditListDialog list={props.source.list} isOpen={editDialogVisible} hide={hideEditDialog} />
- )}
-
- {'list' in props.source && (
- <DeleteConfirmDialog
- list={props.source.list}
- isOpen={deleteDialogVisible}
- hide={hideDeleteDialog}
- confirm={confirmRemoveCustomList}
- />
- )}
- </>
- );
-}
-
-// This is to avoid unnecessary rerenders since most of the subtree is hidden and would result in
-// a lot more work than necessary
-export default React.memo(LocationRow, compareProps);
-
-function compareProps<C extends LocationSpecification>(
- oldProps: IProps<C>,
- nextProps: IProps<C>,
-): boolean {
- return (
- oldProps.onSelect === nextProps.onSelect &&
- oldProps.onExpand === nextProps.onExpand &&
- oldProps.onCollapse === nextProps.onCollapse &&
- oldProps.onWillExpand === nextProps.onWillExpand &&
- oldProps.onTransitionEnd === nextProps.onTransitionEnd &&
- oldProps.allowAddToCustomList === nextProps.allowAddToCustomList &&
- compareLocation(oldProps.source, nextProps.source)
- );
-}
-
-function compareLocation(
- oldLocation: LocationSpecification,
- nextLocation: LocationSpecification,
-): boolean {
- return (
- oldLocation.visible === nextLocation.visible &&
- oldLocation.label === nextLocation.label &&
- oldLocation.active === nextLocation.active &&
- oldLocation.disabled === nextLocation.disabled &&
- oldLocation.selected === nextLocation.selected &&
- compareRelayLocation(oldLocation.location, nextLocation.location) &&
- compareExpanded(oldLocation, nextLocation) &&
- compareChildren(oldLocation, nextLocation)
- );
-}
-
-function compareChildren(
- oldLocation: LocationSpecification,
- nextLocation: LocationSpecification,
-): boolean {
- const oldVisibleChildren = getLocationChildren(oldLocation).filter((child) => child.visible);
- const nextVisibleChildren = getLocationChildren(nextLocation).filter((child) => child.visible);
-
- // Children shouldn't be checked if the row is collapsed
- const nextExpanded = 'expanded' in nextLocation && nextLocation.expanded;
-
- return (
- (!nextExpanded && oldVisibleChildren.length > 0 && nextVisibleChildren.length > 0) ||
- (oldVisibleChildren.length === nextVisibleChildren.length &&
- oldVisibleChildren.every((oldChild, i) => compareLocation(oldChild, nextVisibleChildren[i])))
- );
-}
-
-function compareExpanded(
- oldLocation: LocationSpecification,
- nextLocation: LocationSpecification,
-): boolean {
- const oldExpanded = 'expanded' in oldLocation && oldLocation.expanded;
- const nextExpanded = 'expanded' in nextLocation && nextLocation.expanded;
- return oldExpanded === nextExpanded;
-}
diff --git a/gui/src/renderer/components/select-location/LocationRowStyles.tsx b/gui/src/renderer/components/select-location/LocationRowStyles.tsx
deleted file mode 100644
index 58e9ba9606..0000000000
--- a/gui/src/renderer/components/select-location/LocationRowStyles.tsx
+++ /dev/null
@@ -1,117 +0,0 @@
-import styled from 'styled-components';
-import { Styles } from 'styled-components/dist/types';
-
-import { colors } from '../../../config.json';
-import * as Cell from '../cell';
-import { buttonColor, ButtonColors } from '../cell/styles';
-import { measurements, normalText } from '../common-styles';
-import ImageView from '../ImageView';
-import InfoButton from '../InfoButton';
-
-export const StyledLocationRowContainer = styled(Cell.Container)({
- display: 'flex',
- padding: 0,
- background: 'none',
-});
-
-export const StyledLocationRowContainerWithMargin = styled(StyledLocationRowContainer)({
- marginBottom: 1,
-});
-
-export const StyledLocationRowLabel = styled(Cell.Label)(normalText, {
- flex: 1,
- minWidth: 0,
- fontWeight: 400,
- lineHeight: '24px',
- overflow: 'hidden',
- textOverflow: 'ellipsis',
- whiteSpace: 'nowrap',
-});
-
-export const StyledLocationRowButton = styled(Cell.Row)<ButtonColors & { $level: number }>(
- buttonColor,
- (props) => {
- const paddingLeft = (props.$level + 1) * 16 + 2;
-
- return {
- display: 'flex',
- flex: 1,
- overflow: 'hidden',
- border: 'none',
- padding: `0 10px 0 ${paddingLeft}px`,
- margin: 0,
- };
- },
-);
-
-interface HoverButtonProps {
- $isLast?: boolean;
-}
-
-const hoverButton = (
- props: ButtonColors & HoverButtonProps,
-): Styles<
- React.DetailedHTMLProps<React.ButtonHTMLAttributes<HTMLButtonElement>, HTMLButtonElement>
-> => ({
- flex: 0,
- display: 'none',
- padding: '0 10px',
- paddingRight: props.$isLast ? '17px' : '10px',
- margin: 0,
- border: 0,
- height: measurements.rowMinHeight,
- appearance: 'none',
-
- '&&:last-child': {
- paddingRight: '25px',
- },
-
- '&&:not(:disabled):hover': {
- backgroundColor: props.$backgroundColor,
- },
- [`${StyledLocationRowContainer}:hover &&`]: {
- display: 'block',
- },
- [`${StyledLocationRowButton}:hover ~ &&`]: {
- backgroundColor: props.$backgroundColorHover,
- },
-});
-
-export const StyledHoverIconButton = styled.button<ButtonColors & HoverButtonProps>(
- buttonColor,
- hoverButton,
-);
-
-export const StyledHoverIcon = styled(ImageView).attrs({
- width: 18,
- height: 18,
- tintColor: colors.white60,
- tintHoverColor: colors.white,
-})({
- [`${StyledHoverIconButton}:hover &&`]: {
- backgroundColor: colors.white,
- },
-});
-
-export const StyledHoverInfoButton = styled(InfoButton)<ButtonColors & HoverButtonProps>(
- buttonColor,
- hoverButton,
-);
-
-export function getButtonColor(selected: boolean, level: number, disabled?: boolean) {
- let backgroundColor = colors.blue60;
- if (selected) {
- backgroundColor = colors.green;
- } else if (level === 1) {
- backgroundColor = colors.blue40;
- } else if (level === 2) {
- backgroundColor = colors.blue20;
- } else if (level === 3) {
- backgroundColor = colors.blue10;
- }
-
- return {
- $backgroundColor: backgroundColor,
- $backgroundColorHover: selected || disabled ? backgroundColor : colors.blue80,
- };
-}
diff --git a/gui/src/renderer/components/select-location/RelayListContext.tsx b/gui/src/renderer/components/select-location/RelayListContext.tsx
deleted file mode 100644
index 3165a95824..0000000000
--- a/gui/src/renderer/components/select-location/RelayListContext.tsx
+++ /dev/null
@@ -1,383 +0,0 @@
-import React, { useCallback, useContext, useEffect, useMemo, useState } from 'react';
-
-import { compareRelayLocation, RelayLocation } from '../../../shared/daemon-rpc-types';
-import {
- EndpointType,
- filterLocations,
- filterLocationsByDaita,
- filterLocationsByEndPointType,
- getLocationsExpandedBySearch,
- searchForLocations,
-} from '../../lib/filter-locations';
-import {
- useNormalBridgeSettings,
- useNormalRelaySettings,
- useTunnelProtocol,
-} from '../../lib/relay-settings-hooks';
-import { useEffectEvent } from '../../lib/utility-hooks';
-import { IRelayLocationCountryRedux } from '../../redux/settings/reducers';
-import { useSelector } from '../../redux/store';
-import { useCustomListsRelayList } from './custom-list-helpers';
-import { useScrollPositionContext } from './ScrollPositionContext';
-import {
- defaultExpandedLocations,
- formatRowName,
- isCityDisabled,
- isCountryDisabled,
- isExpanded,
- isRelayDisabled,
- isSelected,
-} from './select-location-helpers';
-import {
- CustomListSpecification,
- DisabledReason,
- GeographicalRelayList,
- LocationType,
- RelayLocationCountryWithVisibility,
-} from './select-location-types';
-import { useSelectLocationContext } from './SelectLocationContainer';
-
-// Context containing the relay list and related data and callbacks
-interface RelayListContext {
- relayList: GeographicalRelayList;
- customLists: Array<CustomListSpecification>;
- expandedLocations?: Array<RelayLocation>;
- expandLocation: (location: RelayLocation) => void;
- collapseLocation: (location: RelayLocation) => void;
- onBeforeExpand: (
- locationRect: DOMRect,
- expandedContentHeight: number,
- invokedByUser: boolean,
- ) => void;
- expandSearchResults: (searchTerm: string) => void;
-}
-
-type ExpandedLocations = Partial<Record<LocationType, Array<RelayLocation>>>;
-
-export const relayListContext = React.createContext<RelayListContext | undefined>(undefined);
-
-export function useRelayListContext() {
- return useContext(relayListContext)!;
-}
-
-interface RelayListContextProviderProps {
- children: React.ReactNode;
-}
-
-export function RelayListContextProvider(props: RelayListContextProviderProps) {
- const { locationType, searchTerm } = useSelectLocationContext();
- const daita = useSelector((state) => state.settings.wireguard.daita?.enabled ?? false);
- const directOnly = useSelector((state) => state.settings.wireguard.daita?.directOnly ?? false);
-
- const fullRelayList = useSelector((state) => state.settings.relayLocations);
- const relaySettings = useNormalRelaySettings();
- const tunnelProtocol = useTunnelProtocol();
-
- // Filters the relays to only keep the ones of the desired endpoint type, e.g. "wireguard",
- // "openvpn" or "bridge"
- const relayListForEndpointType = useMemo(() => {
- const endpointType =
- locationType === LocationType.entry ? EndpointType.entry : EndpointType.exit;
- return filterLocationsByEndPointType(
- fullRelayList,
- endpointType,
- tunnelProtocol,
- relaySettings,
- );
- }, [fullRelayList, locationType, relaySettings, tunnelProtocol]);
-
- const relayListForDaita = useMemo(() => {
- return filterLocationsByDaita(
- relayListForEndpointType,
- daita,
- directOnly,
- locationType,
- relaySettings?.tunnelProtocol ?? 'any',
- relaySettings?.wireguard.useMultihop ?? false,
- );
- }, [
- daita,
- directOnly,
- locationType,
- relayListForEndpointType,
- relaySettings?.tunnelProtocol,
- relaySettings?.wireguard.useMultihop,
- ]);
-
- // Filters the relays to only keep the relays matching the currently selected filters, e.g.
- // ownership and providers
- const relayListForFilters = useMemo(() => {
- return filterLocations(relayListForDaita, relaySettings?.ownership, relaySettings?.providers);
- }, [relaySettings?.ownership, relaySettings?.providers, relayListForDaita]);
-
- // Filters the relays based on the provided search term
- const relayListForSearch = useMemo(() => {
- return searchForLocations(relayListForFilters, searchTerm);
- }, [relayListForFilters, searchTerm]);
-
- const {
- expandedLocations,
- expandLocation,
- collapseLocation,
- onBeforeExpand,
- expandSearchResults,
- } = useExpandedLocations(relayListForFilters);
-
- // Prepares all relays and combines the data needed for rendering them
- const relayList = useRelayList(relayListForSearch, expandedLocations);
-
- const customLists = useCustomListsRelayList(relayList, expandedLocations);
-
- const contextValue = useMemo(
- () => ({
- relayList,
- customLists,
- expandedLocations,
- expandLocation,
- collapseLocation,
- onBeforeExpand,
- expandSearchResults,
- }),
- [
- relayList,
- customLists,
- expandedLocations,
- expandLocation,
- collapseLocation,
- onBeforeExpand,
- expandSearchResults,
- ],
- );
-
- return (
- <relayListContext.Provider value={contextValue}>{props.children}</relayListContext.Provider>
- );
-}
-
-// Return the final filtered and formatted relay list. This should be the only place in the app
-// where processing of the relay list is performed.
-function useRelayList(
- relayList: Array<RelayLocationCountryWithVisibility>,
- expandedLocations?: Array<RelayLocation>,
-): GeographicalRelayList {
- const locale = useSelector((state) => state.userInterface.locale);
- const selectedLocation = useSelectedLocation();
- const disabledLocation = useDisabledLocation();
-
- const preventDueToCustomBridgeSelected = usePreventDueToCustomBridgeSelected();
-
- const isLocationSelected = useCallback(
- (location: RelayLocation) => {
- return preventDueToCustomBridgeSelected ? false : isSelected(location, selectedLocation);
- },
- [preventDueToCustomBridgeSelected, selectedLocation],
- );
-
- return useMemo(() => {
- return relayList
- .map((country) => {
- const countryLocation = { country: country.code };
- const countryDisabledReason = isCountryDisabled(country, countryLocation, disabledLocation);
-
- return {
- ...country,
- label: formatRowName(country.name, countryLocation, countryDisabledReason),
- location: countryLocation,
- active: countryDisabledReason !== DisabledReason.inactive,
- disabled: countryDisabledReason !== undefined,
- disabledReason: countryDisabledReason,
- expanded: isExpanded(countryLocation, expandedLocations),
- selected: isLocationSelected(countryLocation),
- cities: country.cities
- .map((city) => {
- const cityLocation: RelayLocation = { country: country.code, city: city.code };
- const cityDisabledReason =
- countryDisabledReason ?? isCityDisabled(city, cityLocation, disabledLocation);
-
- return {
- ...city,
- label: formatRowName(city.name, cityLocation, cityDisabledReason),
- location: cityLocation,
- active: cityDisabledReason !== DisabledReason.inactive,
- disabled: cityDisabledReason !== undefined,
- disabledReason: cityDisabledReason,
- expanded: isExpanded(cityLocation, expandedLocations),
- selected: isLocationSelected(cityLocation),
- relays: city.relays
- .map((relay) => {
- const relayLocation: RelayLocation = {
- country: country.code,
- city: city.code,
- hostname: relay.hostname,
- };
- const relayDisabledReason =
- countryDisabledReason ??
- cityDisabledReason ??
- isRelayDisabled(relay, relayLocation, disabledLocation);
-
- return {
- ...relay,
- label: formatRowName(relay.hostname, relayLocation, relayDisabledReason),
- location: relayLocation,
- disabled: relayDisabledReason !== undefined,
- disabledReason: relayDisabledReason,
- selected: isLocationSelected(relayLocation),
- };
- })
- .sort((a, b) => a.hostname.localeCompare(b.hostname, locale, { numeric: true })),
- };
- })
- .sort((a, b) => a.label.localeCompare(b.label, locale)),
- };
- })
- .sort((a, b) => a.label.localeCompare(b.label, locale));
- }, [locale, expandedLocations, relayList, disabledLocation, isLocationSelected]);
-}
-
-export function usePreventDueToCustomBridgeSelected(): boolean {
- const relaySettings = useNormalRelaySettings();
- const { locationType } = useSelectLocationContext();
- const bridgeSettings = useSelector((state) => state.settings.bridgeSettings);
- const isBridgeSelection =
- relaySettings?.tunnelProtocol === 'openvpn' && locationType === LocationType.entry;
-
- return isBridgeSelection && bridgeSettings.type === 'custom';
-}
-
-// Return all RelayLocations that should be expanded
-function useExpandedLocations(filteredLocations: Array<IRelayLocationCountryRedux>) {
- const { locationType, searchTerm } = useSelectLocationContext();
- const { spacePreAllocationViewRef, scrollIntoView } = useScrollPositionContext();
- const relaySettings = useNormalRelaySettings();
- const bridgeSettings = useNormalBridgeSettings();
-
- // Keeps the state of which locations are expanded for which locationType. This is used to restore
- // the state when switching back and forth between entry and exit.
- const [expandedLocationsMap, setExpandedLocations] = useState<ExpandedLocations>(() =>
- defaultExpandedLocations(relaySettings, bridgeSettings),
- );
-
- const expandLocation = useCallback(
- (location: RelayLocation) => {
- setExpandedLocations((expandedLocations) => ({
- ...expandedLocations,
- [locationType]: [...(expandedLocations[locationType] ?? []), location],
- }));
- },
- [locationType],
- );
-
- const collapseLocation = useCallback(
- (location: RelayLocation) => {
- setExpandedLocations((expandedLocations) => ({
- ...expandedLocations,
- [locationType]: expandedLocations[locationType]!.filter(
- (item) => !compareRelayLocation(location, item),
- ),
- }));
- },
- [locationType],
- );
-
- // Called before expansion to make room for expansion and to scroll to fit the element
- const onBeforeExpand = useCallback(
- (locationRect: DOMRect, expandedContentHeight: number, invokedByUser: boolean) => {
- if (invokedByUser) {
- locationRect.height += expandedContentHeight;
- spacePreAllocationViewRef.current?.allocate(expandedContentHeight);
- scrollIntoView(locationRect);
- }
- },
- [scrollIntoView, spacePreAllocationViewRef],
- );
-
- // Expand search results when searching
- const expandSearchResults = useCallback(
- (searchTerm: string) => {
- if (searchTerm === '') {
- setExpandedLocations(defaultExpandedLocations(relaySettings, bridgeSettings));
- } else {
- setExpandedLocations((expandedLocations) => ({
- ...expandedLocations,
- [locationType]: getLocationsExpandedBySearch(filteredLocations, searchTerm),
- }));
- }
- },
- [relaySettings, bridgeSettings, locationType, filteredLocations],
- );
-
- const expandLocationsForSearch = useEffectEvent(
- (filteredLocations: Array<IRelayLocationCountryRedux>) => {
- if (searchTerm !== '') {
- setExpandedLocations((expandedLocations) => ({
- ...expandedLocations,
- [locationType]: getLocationsExpandedBySearch(filteredLocations, searchTerm),
- }));
- }
- },
- );
-
- // Expand locations when filters are changed
- useEffect(() => expandLocationsForSearch(filteredLocations), [filteredLocations]);
-
- return {
- expandedLocations: expandedLocationsMap[locationType],
- expandLocation,
- collapseLocation,
- onBeforeExpand,
- expandSearchResults,
- };
-}
-
-// Returns the location (if any) that should be disabled. This is currently used for disabling the
-// entry location when selecting exit location etc.
-export function useDisabledLocation() {
- const { locationType } = useSelectLocationContext();
- const relaySettings = useNormalRelaySettings();
-
- return useMemo(() => {
- if (relaySettings?.tunnelProtocol !== 'openvpn' && relaySettings?.wireguard.useMultihop) {
- if (locationType === LocationType.exit && relaySettings?.wireguard.entryLocation !== 'any') {
- return {
- location: relaySettings?.wireguard.entryLocation,
- reason: DisabledReason.entry,
- };
- } else if (locationType === LocationType.entry && relaySettings?.location !== 'any') {
- return { location: relaySettings?.location, reason: DisabledReason.exit };
- }
- }
-
- return undefined;
- }, [
- locationType,
- relaySettings?.tunnelProtocol,
- relaySettings?.wireguard.useMultihop,
- relaySettings?.wireguard.entryLocation,
- relaySettings?.location,
- ]);
-}
-
-// Returns the selected location for the current tunnel protocol and location type
-export function useSelectedLocation(): RelayLocation | undefined {
- const { locationType } = useSelectLocationContext();
- const relaySettings = useNormalRelaySettings();
- const bridgeSettings = useNormalBridgeSettings();
-
- return useMemo(() => {
- if (locationType === LocationType.exit) {
- return relaySettings?.location === 'any' ? undefined : relaySettings?.location;
- } else if (relaySettings?.tunnelProtocol !== 'openvpn') {
- return relaySettings?.wireguard.entryLocation === 'any'
- ? undefined
- : relaySettings?.wireguard.entryLocation;
- } else {
- return bridgeSettings?.location === 'any' ? undefined : bridgeSettings?.location;
- }
- }, [
- locationType,
- relaySettings?.location,
- relaySettings?.tunnelProtocol,
- relaySettings?.wireguard.entryLocation,
- bridgeSettings?.location,
- ]);
-}
diff --git a/gui/src/renderer/components/select-location/RelayLocationList.tsx b/gui/src/renderer/components/select-location/RelayLocationList.tsx
deleted file mode 100644
index 49e1f2a38c..0000000000
--- a/gui/src/renderer/components/select-location/RelayLocationList.tsx
+++ /dev/null
@@ -1,65 +0,0 @@
-import React from 'react';
-
-import { RelayLocation } from '../../../shared/daemon-rpc-types';
-import * as Cell from '../cell';
-import LocationRow from './LocationRow';
-import { getLocationChildren, LocationSpecification, RelayList } from './select-location-types';
-
-interface CommonProps {
- selectedElementRef: React.Ref<HTMLDivElement>;
- allowAddToCustomList: boolean;
- onSelect: (value: RelayLocation) => void;
- onExpand: (location: RelayLocation) => void;
- onCollapse: (location: RelayLocation) => void;
- onWillExpand: (
- locationRect: DOMRect,
- expandedContentHeight: number,
- invokedByUser: boolean,
- ) => void;
- onTransitionEnd: () => void;
-}
-
-interface RelayLocationsProps extends CommonProps {
- source: RelayList;
-}
-
-export default function RelayLocationList({ source, ...props }: RelayLocationsProps) {
- return (
- <Cell.Group $noMarginBottom>
- {source.map((country) => (
- <RelayLocation
- key={getLocationKey(country.location)}
- source={country}
- level={0}
- {...props}
- />
- ))}
- </Cell.Group>
- );
-}
-
-interface RelayLocationProps extends CommonProps {
- source: LocationSpecification;
- level: number;
-}
-
-function RelayLocation(props: RelayLocationProps) {
- const children = getLocationChildren(props.source);
-
- return (
- <LocationRow {...props}>
- {children.map((child) => (
- <RelayLocation
- key={getLocationKey(child.location)}
- {...props}
- source={child}
- level={props.level + 1}
- />
- ))}
- </LocationRow>
- );
-}
-
-function getLocationKey(location: RelayLocation): string {
- return Object.values(location).join('-');
-}
diff --git a/gui/src/renderer/components/select-location/ScopeBar.tsx b/gui/src/renderer/components/select-location/ScopeBar.tsx
deleted file mode 100644
index 28312da19e..0000000000
--- a/gui/src/renderer/components/select-location/ScopeBar.tsx
+++ /dev/null
@@ -1,73 +0,0 @@
-import React, { useCallback } from 'react';
-import styled from 'styled-components';
-
-import { colors } from '../../../config.json';
-import { smallText } from '../common-styles';
-
-const StyledScopeBar = styled.div({
- display: 'flex',
- flexDirection: 'row',
- backgroundColor: colors.blue40,
- borderRadius: '13px',
- overflow: 'hidden',
-});
-
-interface IScopeBarProps {
- selectedIndex: number;
- onChange?: (selectedIndex: number) => void;
- className?: string;
- children: React.ReactElement<IScopeBarItemProps>[];
-}
-
-export function ScopeBar(props: IScopeBarProps) {
- const children = React.Children.map(props.children, (child, index) => {
- if (React.isValidElement(child)) {
- return React.cloneElement(child, {
- selected: index === props.selectedIndex,
- onClick: props.onChange,
- index,
- });
- } else {
- return undefined;
- }
- });
-
- return <StyledScopeBar className={props.className}>{children}</StyledScopeBar>;
-}
-
-const StyledScopeBarItem = styled.button<{ selected?: boolean }>(smallText, (props) => ({
- cursor: 'default',
- flex: 1,
- flexBasis: 0,
- padding: '4px 8px',
- color: colors.white,
- textAlign: 'center',
- border: 'none',
- backgroundColor: props.selected ? colors.green : 'transparent',
- '&&:hover': {
- backgroundColor: props.selected ? colors.green : colors.blue40,
- },
-}));
-
-interface IScopeBarItemProps {
- index?: number;
- selected?: boolean;
- onClick?: (index: number) => void;
- children?: React.ReactNode;
-}
-
-export function ScopeBarItem(props: IScopeBarItemProps) {
- const { onClick: propOnClick } = props;
-
- const onClick = useCallback(() => {
- if (props.index !== undefined) {
- propOnClick?.(props.index);
- }
- }, [propOnClick, props.index]);
-
- return props.index !== undefined ? (
- <StyledScopeBarItem selected={props.selected} onClick={onClick}>
- {props.children}
- </StyledScopeBarItem>
- ) : null;
-}
diff --git a/gui/src/renderer/components/select-location/ScrollPositionContext.tsx b/gui/src/renderer/components/select-location/ScrollPositionContext.tsx
deleted file mode 100644
index 55ee8dae97..0000000000
--- a/gui/src/renderer/components/select-location/ScrollPositionContext.tsx
+++ /dev/null
@@ -1,118 +0,0 @@
-import { Action } from 'history';
-import React, { useCallback, useContext, useEffect, useMemo, useRef } from 'react';
-
-import { useHistory } from '../../lib/history';
-import { useNormalRelaySettings } from '../../lib/relay-settings-hooks';
-import { useStyledRef } from '../../lib/utility-hooks';
-import { CustomScrollbarsRef } from '../CustomScrollbars';
-import { LocationType } from './select-location-types';
-import { useSelectLocationContext } from './SelectLocationContainer';
-import { SpacePreAllocationView } from './SpacePreAllocationView';
-
-// Context containing the scroll position for each location type and methods to interact with it.
-interface ScrollPositionContext {
- scrollPositions: React.RefObject<Partial<Record<LocationType, ScrollPosition>>>;
- // The selected location element is used to scroll to it when opening the view
- selectedLocationRef: React.RefObject<HTMLDivElement>;
- // The scroll view container is used to get the current scroll position and to restore an old one
- scrollViewRef: React.RefObject<CustomScrollbarsRef>;
- // The space pre allocation view is used to enable smooth scrolling when opening locations
- spacePreAllocationViewRef: React.RefObject<SpacePreAllocationView>;
- saveScrollPosition: () => void;
- resetScrollPositions: () => void;
- scrollIntoView: (rect: DOMRect) => void;
- resetHeight: () => void;
-}
-
-type ScrollPosition = [number, number];
-
-const scrollPositionContext = React.createContext<ScrollPositionContext | undefined>(undefined);
-
-export function useScrollPositionContext() {
- return useContext(scrollPositionContext)!;
-}
-
-interface ScrollPositionContextProps {
- children: React.ReactNode;
-}
-
-export function ScrollPositionContextProvider(props: ScrollPositionContextProps) {
- const { locationType, searchTerm } = useSelectLocationContext();
- const relaySettings = useNormalRelaySettings();
-
- const { action } = useHistory();
- const recentNavigationAction = useRef<Action | null>(action);
-
- const scrollPositions = useRef<Partial<Record<LocationType, ScrollPosition>>>({});
- const scrollViewRef = useRef<CustomScrollbarsRef>(null);
- const spacePreAllocationViewRef = useStyledRef<SpacePreAllocationView>();
- const selectedLocationRef = useRef<HTMLDivElement>(null);
-
- const saveScrollPosition = useCallback(() => {
- const scrollPosition = scrollViewRef.current?.getScrollPosition();
- if (scrollPositions.current && scrollPosition) {
- scrollPositions.current[locationType] = scrollPosition;
- }
- }, [locationType]);
-
- const resetScrollPositions = useCallback(() => {
- for (const locationTypeVariant of [LocationType.entry, LocationType.exit]) {
- if (
- scrollPositions.current &&
- (scrollPositions.current[locationTypeVariant] || locationTypeVariant === locationType)
- ) {
- scrollPositions.current[locationTypeVariant] = [0, 0];
- }
- }
- }, [locationType]);
-
- const scrollIntoView = useCallback((rect: DOMRect) => {
- scrollViewRef.current?.scrollIntoView(rect);
- }, []);
-
- const resetHeight = useCallback(
- () => spacePreAllocationViewRef.current?.reset(),
- [spacePreAllocationViewRef],
- );
-
- const value = useMemo(
- () => ({
- scrollPositions,
- selectedLocationRef,
- scrollViewRef,
- spacePreAllocationViewRef,
- saveScrollPosition,
- resetScrollPositions,
- scrollIntoView,
- resetHeight,
- }),
- [
- spacePreAllocationViewRef,
- saveScrollPosition,
- resetScrollPositions,
- scrollIntoView,
- resetHeight,
- ],
- );
-
- // Restore the scroll position when parameters change
- useEffect(() => {
- if (recentNavigationAction.current === 'POP') {
- recentNavigationAction.current = null;
- return;
- }
-
- const scrollPosition = scrollPositions.current?.[locationType];
- if (scrollPosition) {
- scrollViewRef.current?.scrollTo(...scrollPosition);
- } else if (selectedLocationRef.current) {
- scrollViewRef.current?.scrollToElement(selectedLocationRef.current, 'middle');
- } else {
- scrollViewRef.current?.scrollToTop();
- }
- }, [locationType, searchTerm, relaySettings?.ownership, relaySettings?.providers.length]);
-
- return (
- <scrollPositionContext.Provider value={value}>{props.children}</scrollPositionContext.Provider>
- );
-}
diff --git a/gui/src/renderer/components/select-location/SelectLocation.tsx b/gui/src/renderer/components/select-location/SelectLocation.tsx
deleted file mode 100644
index 506c836548..0000000000
--- a/gui/src/renderer/components/select-location/SelectLocation.tsx
+++ /dev/null
@@ -1,450 +0,0 @@
-import { useCallback, useState } from 'react';
-import { sprintf } from 'sprintf-js';
-
-import { colors, strings } from '../../../config.json';
-import { Ownership } from '../../../shared/daemon-rpc-types';
-import { messages } from '../../../shared/gettext';
-import { useRelaySettingsUpdater } from '../../lib/constraint-updater';
-import { daitaFilterActive, filterSpecialLocations } from '../../lib/filter-locations';
-import { useHistory } from '../../lib/history';
-import { formatHtml } from '../../lib/html-formatter';
-import { useNormalRelaySettings } from '../../lib/relay-settings-hooks';
-import { RoutePath } from '../../lib/routes';
-import { useSelector } from '../../redux/store';
-import * as Cell from '../cell';
-import { useFilteredProviders } from '../Filter';
-import ImageView from '../ImageView';
-import { BackAction } from '../KeyboardNavigation';
-import { Layout, SettingsContainer } from '../Layout';
-import {
- NavigationBar,
- NavigationBarButton,
- NavigationContainer,
- NavigationItems,
- NavigationScrollbars,
- TitleBarItem,
-} from '../NavigationBar';
-import CombinedLocationList, { CombinedLocationListProps } from './CombinedLocationList';
-import CustomLists from './CustomLists';
-import { useRelayListContext } from './RelayListContext';
-import { ScopeBarItem } from './ScopeBar';
-import { useScrollPositionContext } from './ScrollPositionContext';
-import {
- useOnSelectBridgeLocation,
- useOnSelectEntryLocation,
- useOnSelectExitLocation,
-} from './select-location-hooks';
-import { LocationType, SpecialBridgeLocationType, SpecialLocation } from './select-location-types';
-import { useSelectLocationContext } from './SelectLocationContainer';
-import {
- StyledClearFilterButton,
- StyledContent,
- StyledDaitaSettingsButton,
- StyledFilter,
- StyledFilterRow,
- StyledNavigationBarAttachment,
- StyledScopeBar,
- StyledSearchBar,
- StyledSelectionUnavailable,
- StyledSelectionUnavailableText,
-} from './SelectLocationStyles';
-import { SpacePreAllocationView } from './SpacePreAllocationView';
-import {
- AutomaticLocationRow,
- CustomBridgeLocationRow,
- CustomExitLocationRow,
-} from './SpecialLocationList';
-
-export default function SelectLocation() {
- const history = useHistory();
- const relaySettingsUpdater = useRelaySettingsUpdater();
- const { saveScrollPosition, resetScrollPositions, scrollViewRef, spacePreAllocationViewRef } =
- useScrollPositionContext();
- const { locationType, setLocationType, setSearchTerm } = useSelectLocationContext();
- const { expandSearchResults } = useRelayListContext();
-
- const relaySettings = useNormalRelaySettings();
- const ownership = relaySettings?.ownership ?? Ownership.any;
- const providers = relaySettings?.providers ?? [];
- const filteredProviders = useFilteredProviders(providers, ownership);
- const daita = useSelector((state) => state.settings.wireguard.daita?.enabled ?? false);
- const directOnly = useSelector((state) => state.settings.wireguard.daita?.directOnly ?? false);
- const showDaitaFilter = daitaFilterActive(
- daita,
- directOnly,
- locationType,
- relaySettings?.tunnelProtocol ?? 'any',
- relaySettings?.wireguard.useMultihop ?? false,
- );
-
- const [searchValue, setSearchValue] = useState('');
-
- const onClose = useCallback(() => history.pop(), [history]);
- const onViewFilter = useCallback(() => history.push(RoutePath.filter), [history]);
-
- const tunnelProtocol = relaySettings?.tunnelProtocol ?? 'any';
- const bridgeState = useSelector((state) => state.settings.bridgeState);
- const allowEntrySelection =
- (tunnelProtocol === 'openvpn' && bridgeState === 'on') ||
- (tunnelProtocol !== 'openvpn' && relaySettings?.wireguard.useMultihop);
-
- const onClearProviders = useCallback(async () => {
- resetScrollPositions();
- if (relaySettings) {
- await relaySettingsUpdater((settings) => ({ ...settings, providers: [] }));
- }
- }, [relaySettingsUpdater, resetScrollPositions, relaySettings]);
-
- const onClearOwnership = useCallback(async () => {
- resetScrollPositions();
- if (relaySettings) {
- await relaySettingsUpdater((settings) => ({ ...settings, ownership: Ownership.any }));
- }
- }, [relaySettingsUpdater, resetScrollPositions, relaySettings]);
-
- const changeLocationType = useCallback(
- (locationType: LocationType) => {
- saveScrollPosition();
- setLocationType(locationType);
- },
- [saveScrollPosition, setLocationType],
- );
-
- const updateSearchTerm = useCallback(
- (value: string) => {
- setSearchValue(value);
- if (value.length === 1) {
- expandSearchResults('');
- setSearchTerm('');
- } else {
- resetScrollPositions();
- expandSearchResults(value);
- setSearchTerm(value);
- }
- },
- [expandSearchResults, setSearchTerm, resetScrollPositions],
- );
-
- const showOwnershipFilter = ownership !== Ownership.any;
- const showProvidersFilter = providers.length > 0;
- const showFilters = showOwnershipFilter || showProvidersFilter || showDaitaFilter;
- return (
- <BackAction action={onClose}>
- <Layout>
- <SettingsContainer>
- <NavigationContainer>
- <NavigationBar alwaysDisplayBarTitle>
- <NavigationItems>
- <TitleBarItem>
- {
- // TRANSLATORS: Title label in navigation bar
- messages.pgettext('select-location-nav', 'Select location')
- }
- </TitleBarItem>
-
- <NavigationBarButton onClick={onViewFilter} aria-label={messages.gettext('Filter')}>
- <ImageView
- source="icon-filter-round"
- tintColor={colors.white40}
- tintHoverColor={colors.white60}
- height={24}
- width={24}
- />
- </NavigationBarButton>
- </NavigationItems>
- </NavigationBar>
-
- <StyledNavigationBarAttachment>
- {allowEntrySelection && (
- <>
- <StyledScopeBar selectedIndex={locationType} onChange={changeLocationType}>
- <ScopeBarItem>
- {messages.pgettext('select-location-view', 'Entry')}
- </ScopeBarItem>
- <ScopeBarItem>{messages.pgettext('select-location-view', 'Exit')}</ScopeBarItem>
- </StyledScopeBar>
- </>
- )}
-
- {locationType === LocationType.entry && daita && !directOnly ? null : (
- <>
- {showFilters && (
- <StyledFilterRow>
- {messages.pgettext('select-location-view', 'Filtered:')}
-
- {showOwnershipFilter && (
- <StyledFilter>
- {ownershipFilterLabel(ownership)}
- <StyledClearFilterButton
- aria-label={messages.gettext('Clear')}
- onClick={onClearOwnership}>
- <ImageView
- height={16}
- width={16}
- source="icon-close"
- tintColor={colors.white60}
- tintHoverColor={colors.white80}
- />
- </StyledClearFilterButton>
- </StyledFilter>
- )}
-
- {showProvidersFilter && (
- <StyledFilter>
- {sprintf(
- messages.pgettext(
- 'select-location-view',
- 'Providers: %(numberOfProviders)d',
- ),
- { numberOfProviders: filteredProviders.length },
- )}
- <StyledClearFilterButton
- aria-label={messages.gettext('Clear')}
- onClick={onClearProviders}>
- <ImageView
- height={16}
- width={16}
- source="icon-close"
- tintColor={colors.white60}
- tintHoverColor={colors.white80}
- />
- </StyledClearFilterButton>
- </StyledFilter>
- )}
-
- {showDaitaFilter && (
- <StyledFilter>
- {sprintf(
- messages.pgettext('select-location-view', 'Setting: %(settingName)s'),
- { settingName: 'DAITA' },
- )}
- </StyledFilter>
- )}
- </StyledFilterRow>
- )}
-
- <StyledSearchBar searchTerm={searchValue} onSearch={updateSearchTerm} />
- </>
- )}
- </StyledNavigationBarAttachment>
-
- <NavigationScrollbars ref={scrollViewRef}>
- <SpacePreAllocationView ref={spacePreAllocationViewRef}>
- <StyledContent>
- <SelectLocationContent />
- </StyledContent>
- </SpacePreAllocationView>
- </NavigationScrollbars>
- </NavigationContainer>
- </SettingsContainer>
- </Layout>
- </BackAction>
- );
-}
-
-function ownershipFilterLabel(ownership: Ownership): string {
- switch (ownership) {
- case Ownership.mullvadOwned:
- return messages.pgettext('filter-view', 'Owned');
- case Ownership.rented:
- return messages.pgettext('filter-view', 'Rented');
- default:
- throw new Error('Only owned and rented should make label visible');
- }
-}
-
-function SelectLocationContent() {
- const { locationType, searchTerm } = useSelectLocationContext();
- const { selectedLocationRef, resetHeight } = useScrollPositionContext();
- const { relayList, expandLocation, collapseLocation, onBeforeExpand } = useRelayListContext();
- const [onSelectExitRelay, onSelectExitSpecial] = useOnSelectExitLocation();
- const [onSelectEntryRelay, onSelectEntrySpecial] = useOnSelectEntryLocation();
- const [onSelectBridgeRelay, onSelectBridgeSpecial] = useOnSelectBridgeLocation();
-
- const daita = useSelector((state) => state.settings.wireguard.daita?.enabled ?? false);
- const directOnly = useSelector((state) => state.settings.wireguard.daita?.directOnly ?? false);
-
- const relaySettings = useNormalRelaySettings();
- const bridgeSettings = useSelector((state) => state.settings.bridgeSettings);
-
- const allowAddToCustomList = useSelector((state) => state.settings.customLists.length > 0);
-
- if (locationType === LocationType.exit) {
- // Add "Custom" item if a custom relay is selected
- const specialList: Array<SpecialLocation<undefined>> = [];
- if (relaySettings === undefined) {
- specialList.push({
- label: messages.gettext('Custom'),
- value: undefined,
- selected: true,
- component: CustomExitLocationRow,
- });
- }
-
- const specialLocations = filterSpecialLocations(searchTerm, specialList);
- return (
- <>
- <CustomLists selectedElementRef={selectedLocationRef} onSelect={onSelectExitRelay} />
- <LocationList
- key={locationType}
- relayLocations={relayList}
- specialLocations={specialLocations}
- selectedElementRef={selectedLocationRef}
- onSelectRelay={onSelectExitRelay}
- onSelectSpecial={onSelectExitSpecial}
- onExpand={expandLocation}
- onCollapse={collapseLocation}
- onWillExpand={onBeforeExpand}
- onTransitionEnd={resetHeight}
- allowAddToCustomList={allowAddToCustomList}
- />
- <NoSearchResult specialLocationsLength={specialLocations.length} />
- </>
- );
- } else if (relaySettings?.tunnelProtocol !== 'openvpn') {
- if (daita && !directOnly) {
- return <DisabledEntrySelection />;
- }
-
- return (
- <>
- <CustomLists selectedElementRef={selectedLocationRef} onSelect={onSelectEntryRelay} />
- <LocationList
- key={locationType}
- relayLocations={relayList}
- selectedElementRef={selectedLocationRef}
- onSelectRelay={onSelectEntryRelay}
- onSelectSpecial={onSelectEntrySpecial}
- onExpand={expandLocation}
- onCollapse={collapseLocation}
- onWillExpand={onBeforeExpand}
- onTransitionEnd={resetHeight}
- allowAddToCustomList={allowAddToCustomList}
- />
- <NoSearchResult specialLocationsLength={0} />
- </>
- );
- } else {
- // Add the "Automatic" item
- const specialList: Array<SpecialLocation<SpecialBridgeLocationType>> = [
- {
- label: messages.pgettext('select-location-view', 'Custom bridge'),
- value: SpecialBridgeLocationType.custom,
- selected: bridgeSettings?.type === 'custom',
- disabled: bridgeSettings?.custom === undefined,
- component: CustomBridgeLocationRow,
- },
- {
- label: messages.gettext('Automatic'),
- value: SpecialBridgeLocationType.closestToExit,
- selected: bridgeSettings?.type === 'normal' && bridgeSettings.normal?.location === 'any',
- component: AutomaticLocationRow,
- },
- ];
-
- const specialLocations = filterSpecialLocations(searchTerm, specialList);
- return (
- <>
- <CustomLists selectedElementRef={selectedLocationRef} onSelect={onSelectBridgeRelay} />
- <LocationList
- key={locationType}
- relayLocations={relayList}
- specialLocations={specialLocations}
- selectedElementRef={selectedLocationRef}
- onSelectRelay={onSelectBridgeRelay}
- onSelectSpecial={onSelectBridgeSpecial}
- onExpand={expandLocation}
- onCollapse={collapseLocation}
- onWillExpand={onBeforeExpand}
- onTransitionEnd={resetHeight}
- allowAddToCustomList={allowAddToCustomList}
- />
- <NoSearchResult specialLocationsLength={specialLocations.length} />
- </>
- );
- }
-}
-
-function LocationList<T>(props: CombinedLocationListProps<T>) {
- const { searchTerm } = useSelectLocationContext();
-
- if (
- searchTerm !== '' &&
- !props.relayLocations.some((country) => country.visible) &&
- (props.specialLocations === undefined || props.specialLocations.length === 0)
- ) {
- return null;
- } else {
- return (
- <>
- <Cell.Row>
- <Cell.Label>{messages.pgettext('select-location-view', 'All locations')}</Cell.Label>
- </Cell.Row>
- <CombinedLocationList {...props} />
- </>
- );
- }
-}
-
-interface NoSearchResultProps {
- specialLocationsLength: number;
-}
-
-function NoSearchResult(props: NoSearchResultProps) {
- const { relayList, customLists } = useRelayListContext();
- const { searchTerm } = useSelectLocationContext();
-
- if (
- searchTerm === '' ||
- relayList.some((country) => country.visible) ||
- customLists.some((list) => list.visible) ||
- props.specialLocationsLength > 0
- ) {
- return null;
- }
-
- return (
- <StyledSelectionUnavailable>
- <StyledSelectionUnavailableText>
- {formatHtml(
- sprintf(messages.gettext('No result for <b>%(searchTerm)s</b>.'), {
- searchTerm,
- }),
- )}
- </StyledSelectionUnavailableText>
- <StyledSelectionUnavailableText>
- {messages.gettext('Try a different search.')}
- </StyledSelectionUnavailableText>
- </StyledSelectionUnavailable>
- );
-}
-
-function DisabledEntrySelection() {
- const { push } = useHistory();
-
- const multihop = messages.pgettext('settings-view', 'Multihop');
- const directOnly = messages.gettext('Direct only');
-
- const navigateToDaitaSettings = useCallback(() => {
- push(RoutePath.daitaSettings);
- }, [push]);
-
- return (
- <StyledSelectionUnavailable>
- <StyledSelectionUnavailableText>
- {sprintf(
- messages.pgettext(
- 'select-location-view',
- '%(daita)s overrides %(multihop)s. To use %(multihop)s, please enable “%(directOnly)s” or disable %(daita)s in the %(daita)s settings.',
- ),
- { daita: strings.daita, multihop, directOnly },
- )}
- </StyledSelectionUnavailableText>
- <StyledDaitaSettingsButton onClick={navigateToDaitaSettings}>
- {sprintf(messages.pgettext('select-location-view', 'Go to %(daita)s settings'), {
- daita: strings.daita,
- })}
- </StyledDaitaSettingsButton>
- </StyledSelectionUnavailable>
- );
-}
diff --git a/gui/src/renderer/components/select-location/SelectLocationContainer.tsx b/gui/src/renderer/components/select-location/SelectLocationContainer.tsx
deleted file mode 100644
index 66bebdf1b0..0000000000
--- a/gui/src/renderer/components/select-location/SelectLocationContainer.tsx
+++ /dev/null
@@ -1,44 +0,0 @@
-import React, { useContext, useMemo, useState } from 'react';
-
-import useActions from '../../lib/actionsHook';
-import { useSelector } from '../../redux/store';
-import userInterface from '../../redux/userinterface/actions';
-import { RelayListContextProvider } from './RelayListContext';
-import { ScrollPositionContextProvider } from './ScrollPositionContext';
-import { LocationType } from './select-location-types';
-import SelectLocation from './SelectLocation';
-
-// Context containing data required by different components in the sub tree
-interface SelectLocationContext {
- locationType: LocationType;
- setLocationType: (locationType: LocationType) => void;
- searchTerm: string;
- setSearchTerm: (value: string) => void;
-}
-
-const selectLocationContext = React.createContext<SelectLocationContext | undefined>(undefined);
-
-export function useSelectLocationContext() {
- return useContext(selectLocationContext)!;
-}
-
-export default function SelectLocationContainer() {
- const locationType = useSelector((state) => state.userInterface.selectLocationView);
- const { setSelectLocationView } = useActions(userInterface);
- const [searchTerm, setSearchTerm] = useState('');
-
- const value = useMemo(
- () => ({ locationType, setLocationType: setSelectLocationView, searchTerm, setSearchTerm }),
- [locationType, searchTerm, setSelectLocationView],
- );
-
- return (
- <selectLocationContext.Provider value={value}>
- <ScrollPositionContextProvider>
- <RelayListContextProvider>
- <SelectLocation />
- </RelayListContextProvider>
- </ScrollPositionContextProvider>
- </selectLocationContext.Provider>
- );
-}
diff --git a/gui/src/renderer/components/select-location/SelectLocationStyles.tsx b/gui/src/renderer/components/select-location/SelectLocationStyles.tsx
deleted file mode 100644
index ff83a6fc8d..0000000000
--- a/gui/src/renderer/components/select-location/SelectLocationStyles.tsx
+++ /dev/null
@@ -1,73 +0,0 @@
-import styled from 'styled-components';
-
-import { colors } from '../../../config.json';
-import * as Cell from '../cell';
-import { normalText, tinyText } from '../common-styles';
-import SearchBar from '../SearchBar';
-import { SmallButton } from '../SmallButton';
-import { ScopeBar } from './ScopeBar';
-
-export const StyledContent = styled.div({
- display: 'flex',
- flexDirection: 'column',
- flex: 1,
- overflow: 'visible',
-});
-
-export const StyledScopeBar = styled(ScopeBar)({
- marginBottom: '16px',
-});
-
-export const StyledNavigationBarAttachment = styled.div({
- padding: '0 16px 16px',
-});
-
-export const StyledFilterRow = styled.div({
- ...tinyText,
- color: colors.white,
- margin: '0 6px 16px',
-});
-
-export const StyledFilter = styled.div({
- ...tinyText,
- display: 'inline-flex',
- alignItems: 'center',
- backgroundColor: colors.blue,
- borderRadius: '4px',
- padding: '3px 8px',
- marginLeft: '6px',
- color: colors.white,
-});
-
-export const StyledClearFilterButton = styled.div({
- display: 'inline-block',
- borderWidth: 0,
- padding: 0,
- margin: '0 0 0 6px',
- cursor: 'default',
- backgroundColor: 'transparent',
-});
-
-export const StyledSearchBar = styled(SearchBar)({
- margin: '0 6px',
-});
-
-export const StyledSelectionUnavailable = styled(Cell.CellFooter)({
- display: 'flex',
- flexDirection: 'column',
- paddingTop: 0,
- marginTop: 0,
-});
-
-export const StyledSelectionUnavailableText = styled(Cell.CellFooterText)({
- textAlign: 'center',
-});
-
-export const StyledAllLocationsTitle = styled(Cell.Label)(normalText, {
- fontWeight: 'normal',
-});
-
-export const StyledDaitaSettingsButton = styled(SmallButton)({
- marginLeft: 0,
- marginTop: '24px',
-});
diff --git a/gui/src/renderer/components/select-location/SpacePreAllocationView.tsx b/gui/src/renderer/components/select-location/SpacePreAllocationView.tsx
deleted file mode 100644
index 4b493aeed1..0000000000
--- a/gui/src/renderer/components/select-location/SpacePreAllocationView.tsx
+++ /dev/null
@@ -1,30 +0,0 @@
-import React from 'react';
-
-interface ISpacePreAllocationView {
- children?: React.ReactNode;
-}
-
-export class SpacePreAllocationView extends React.Component<ISpacePreAllocationView> {
- private ref = React.createRef<HTMLDivElement>();
-
- public allocate(height: number) {
- if (this.ref.current) {
- this.minHeight = this.ref.current.offsetHeight + height + 'px';
- }
- }
-
- public reset = () => {
- this.minHeight = 'auto';
- };
-
- public render() {
- return <div ref={this.ref}>{this.props.children}</div>;
- }
-
- private set minHeight(value: string) {
- const element = this.ref.current;
- if (element) {
- element.style.minHeight = value;
- }
- }
-}
diff --git a/gui/src/renderer/components/select-location/SpecialLocationList.tsx b/gui/src/renderer/components/select-location/SpecialLocationList.tsx
deleted file mode 100644
index e4d105d635..0000000000
--- a/gui/src/renderer/components/select-location/SpecialLocationList.tsx
+++ /dev/null
@@ -1,157 +0,0 @@
-import React, { useCallback } from 'react';
-import styled from 'styled-components';
-
-import { colors } from '../../../config.json';
-import { messages } from '../../../shared/gettext';
-import { useHistory } from '../../lib/history';
-import { RoutePath } from '../../lib/routes';
-import { useSelector } from '../../redux/store';
-import * as Cell from '../cell';
-import ImageView from '../ImageView';
-import InfoButton from '../InfoButton';
-import { SpecialLocationIndicator } from '../RelayStatusIndicator';
-import {
- getButtonColor,
- StyledHoverInfoButton,
- StyledLocationRowButton,
- StyledLocationRowContainerWithMargin,
- StyledLocationRowLabel,
-} from './LocationRowStyles';
-import { SpecialBridgeLocationType, SpecialLocation } from './select-location-types';
-
-interface SpecialLocationsProps<T> {
- source: Array<SpecialLocation<T>>;
- selectedElementRef: React.Ref<HTMLDivElement>;
- onSelect: (value: T) => void;
-}
-
-export default function SpecialLocationList<T>({ source, ...props }: SpecialLocationsProps<T>) {
- return (
- <>
- {source.map((location) => (
- <SpecialLocationRow key={location.label} source={location} {...props} />
- ))}
- </>
- );
-}
-
-const StyledSpecialLocationInfoButton = styled(InfoButton)({ padding: '0 25px', margin: 0 });
-const StyledSpecialLocationSideButton = styled(ImageView)({ padding: '0 3px' });
-
-interface SpecialLocationRowProps<T> {
- source: SpecialLocation<T>;
- selectedElementRef: React.Ref<HTMLDivElement>;
- onSelect: (value: T) => void;
-}
-
-function SpecialLocationRow<T>(props: SpecialLocationRowProps<T>) {
- const { onSelect: propsOnSelect } = props;
- const onSelect = useCallback(() => {
- if (!props.source.selected) {
- propsOnSelect(props.source.value);
- }
- }, [props.source, propsOnSelect]);
-
- const innerProps: SpecialLocationRowInnerProps<T> = {
- ...props,
- onSelect,
- };
- return <props.source.component {...innerProps} />;
-}
-
-export interface SpecialLocationRowInnerProps<T>
- extends Omit<SpecialLocationRowProps<T>, 'onSelect'> {
- onSelect: () => void;
-}
-
-export function AutomaticLocationRow(
- props: SpecialLocationRowInnerProps<SpecialBridgeLocationType>,
-) {
- const selectedRef = props.source.selected ? props.selectedElementRef : undefined;
- const background = getButtonColor(props.source.selected, 0, props.source.disabled);
- return (
- <StyledLocationRowContainerWithMargin ref={selectedRef}>
- <StyledLocationRowButton onClick={props.onSelect} $level={0} {...background}>
- <SpecialLocationIndicator />
- <StyledLocationRowLabel>{props.source.label}</StyledLocationRowLabel>
- </StyledLocationRowButton>
- <Cell.SideButton
- as={StyledSpecialLocationInfoButton}
- title={messages.gettext('Automatic')}
- message={messages.pgettext(
- 'select-location-view',
- 'The app selects a random bridge server, but servers have a higher probability the closer they are to you.',
- )}
- aria-label={messages.pgettext('accessibility', 'info')}
- {...background}
- />
- </StyledLocationRowContainerWithMargin>
- );
-}
-
-export function CustomExitLocationRow(props: SpecialLocationRowInnerProps<undefined>) {
- const selectedRef = props.source.selected ? props.selectedElementRef : undefined;
- const background = getButtonColor(props.source.selected, 0, props.source.disabled);
- return (
- <StyledLocationRowContainerWithMargin ref={selectedRef}>
- <StyledLocationRowButton $level={0} {...background}>
- <StyledLocationRowLabel>{props.source.label}</StyledLocationRowLabel>
- </StyledLocationRowButton>
- </StyledLocationRowContainerWithMargin>
- );
-}
-
-const StyledInfoButton = styled(StyledHoverInfoButton)({ display: 'block' });
-
-export function CustomBridgeLocationRow(
- props: SpecialLocationRowInnerProps<SpecialBridgeLocationType>,
-) {
- const { push } = useHistory();
-
- const bridgeSettings = useSelector((state) => state.settings.bridgeSettings);
- const bridgeConfigured = bridgeSettings.custom !== undefined;
- const icon = bridgeConfigured ? 'icon-edit' : 'icon-add';
-
- const selectedRef = props.source.selected ? props.selectedElementRef : undefined;
- const background = getButtonColor(props.source.selected, 0, props.source.disabled);
-
- const navigate = useCallback(() => push(RoutePath.editCustomBridge), [push]);
-
- return (
- <StyledLocationRowContainerWithMargin ref={selectedRef} disabled={props.source.disabled}>
- <StyledLocationRowButton
- as="button"
- onClick={props.onSelect}
- $level={0}
- disabled={props.source.disabled}
- {...background}>
- <SpecialLocationIndicator />
- <StyledLocationRowLabel>{props.source.label}</StyledLocationRowLabel>
- </StyledLocationRowButton>
- <StyledInfoButton
- {...background}
- $isLast
- title={messages.pgettext('select-location-view', 'Custom bridge')}
- message={messages.pgettext(
- 'select-location-view',
- 'A custom bridge server can be used to circumvent censorship when regular Mullvad bridge servers don’t work.',
- )}
- />
- <Cell.SideButton
- {...background}
- aria-label={
- bridgeConfigured
- ? messages.pgettext('accessibility', 'Edit custom bridge')
- : messages.pgettext('accessibility', 'Add new custom bridge')
- }
- onClick={navigate}>
- <StyledSpecialLocationSideButton
- source={icon}
- width={18}
- tintColor={colors.white}
- tintHoverColor={colors.white80}
- />
- </Cell.SideButton>
- </StyledLocationRowContainerWithMargin>
- );
-}
diff --git a/gui/src/renderer/components/select-location/custom-list-helpers.ts b/gui/src/renderer/components/select-location/custom-list-helpers.ts
deleted file mode 100644
index 3f6149cd7f..0000000000
--- a/gui/src/renderer/components/select-location/custom-list-helpers.ts
+++ /dev/null
@@ -1,188 +0,0 @@
-import { useMemo } from 'react';
-
-import { ICustomList, RelayLocation } from '../../../shared/daemon-rpc-types';
-import { hasValue } from '../../../shared/utils';
-import { searchMatch } from '../../lib/filter-locations';
-import { useSelector } from '../../redux/store';
-import {
- useDisabledLocation,
- usePreventDueToCustomBridgeSelected,
- useSelectedLocation,
-} from './RelayListContext';
-import { isCustomListDisabled, isExpanded, isSelected } from './select-location-helpers';
-import {
- CitySpecification,
- CountrySpecification,
- CustomListSpecification,
- DisabledReason,
- GeographicalRelayList,
- RelaySpecification,
-} from './select-location-types';
-import { useSelectLocationContext } from './SelectLocationContainer';
-
-// Hook that generates the custom lists relay list.
-export function useCustomListsRelayList(
- relayList: GeographicalRelayList,
- expandedLocations?: Array<RelayLocation>,
-) {
- const disabledLocation = useDisabledLocation();
- const selectedLocation = useSelectedLocation();
- const { searchTerm } = useSelectLocationContext();
- const customLists = useSelector((state) => state.settings.customLists);
-
- const preventDueToCustomBridgeSelected = usePreventDueToCustomBridgeSelected();
-
- // Populate all custom lists with the real location trees for the list locations.
- return useMemo(
- () =>
- customLists.map((list) =>
- prepareCustomList(
- list,
- relayList,
- searchTerm,
- preventDueToCustomBridgeSelected,
- selectedLocation,
- disabledLocation,
- expandedLocations,
- ),
- ),
- [
- customLists,
- relayList,
- searchTerm,
- preventDueToCustomBridgeSelected,
- selectedLocation,
- disabledLocation,
- expandedLocations,
- ],
- );
-}
-
-// Creates a CustomListSpecification from a ICustomList.
-function prepareCustomList(
- list: ICustomList,
- fullRelayList: GeographicalRelayList,
- searchTerm: string,
- preventDueToCustomBridgeSelected: boolean,
- selectedLocation?: RelayLocation,
- disabledLocation?: { location: RelayLocation; reason: DisabledReason },
- expandedLocations?: Array<RelayLocation>,
-): CustomListSpecification {
- const location = { customList: list.id };
- const locations = prepareLocations(list, fullRelayList, expandedLocations);
-
- const disabledReason = isCustomListDisabled(location, locations, disabledLocation);
- return {
- label: list.name,
- list,
- location,
- active: disabledReason !== DisabledReason.inactive,
- disabled: disabledReason !== undefined,
- disabledReason,
- expanded: isExpanded(location, expandedLocations),
- selected: preventDueToCustomBridgeSelected ? false : isSelected(location, selectedLocation),
- visible: searchMatch(searchTerm, list.name),
- locations,
- };
-}
-
-// Returns a list of CountrySpecification, CitySpecification and RelaySpecification matching the
-// contents of the custom list.
-function prepareLocations(
- list: ICustomList,
- fullRelayList: GeographicalRelayList,
- expandedLocations?: Array<RelayLocation>,
-) {
- const locationCounter = {};
-
- return list.locations
- .map((location) => {
- if ('hostname' in location) {
- // Search through all relays in all cities in all countries to find the matching relay.
- const relay = fullRelayList
- .find((country) => country.location.country === location.country)
- ?.cities.find((city) => city.location.city === location.city)
- ?.relays.find((relay) => relay.location.hostname === location.hostname);
-
- return relay && updateRelay(relay, list.id);
- } else if ('city' in location) {
- // Search through all cities in all countries to find the matching city.
- const city = fullRelayList
- .find((country) => country.location.country === location.country)
- ?.cities.find((city) => city.location.city === location.city);
-
- return city && updateCity(city, list.id, locationCounter, expandedLocations);
- } else {
- // Search through all countries to find the matching country.
- const country = fullRelayList.find(
- (country) => country.location.country === location.country,
- );
-
- return country && updateCountry(country, list.id, locationCounter, expandedLocations);
- }
- })
- .filter(hasValue);
-}
-
-// Update the CountrySpecification from the original relay list to contain the correct properties
-// for the custom list list.
-function updateCountry(
- country: CountrySpecification,
- customList: string,
- locationCounter: Record<string, number>,
- expandedLocations?: Array<RelayLocation>,
-): CountrySpecification {
- // Since there can be multiple instances of a location in a custom list, every instance needs to
- // be unique to avoid expanding all instances when expanding one.
- const counterKey = `${country.location.country}`;
- const count = locationCounter[counterKey] ?? 0;
- locationCounter[counterKey] = count + 1;
-
- const location = { ...country.location, customList, count };
- return {
- ...country,
- location,
- expanded: isExpanded(location, expandedLocations),
- selected: false,
- visible: true,
- cities: country.cities.map((city) =>
- updateCity(city, customList, locationCounter, expandedLocations),
- ),
- };
-}
-
-// Update the CitySpecification from the original relay list to contain the correct properties
-// for the custom list list.
-function updateCity(
- city: CitySpecification,
- customList: string,
- locationCounter: Record<string, number>,
- expandedLocations?: Array<RelayLocation>,
-): CitySpecification {
- // Since there can be multiple instances of a location in a custom list, every instance needs to
- // be unique to avoid expanding all instances when expanding one.
- const counterKey = `${city.location.country}_${city.location.city}`;
- const count = locationCounter[counterKey] ?? 0;
- locationCounter[counterKey] = count + 1;
-
- const location = { ...city.location, customList, count };
- return {
- ...city,
- location,
- expanded: isExpanded(location, expandedLocations),
- selected: false,
- visible: true,
- relays: city.relays.map((relay) => updateRelay(relay, customList)),
- };
-}
-
-// Update the RelaySpecification from the original relay list to contain the correct properties
-// for the custom list list.
-function updateRelay(relay: RelaySpecification, customList: string): RelaySpecification {
- return {
- ...relay,
- location: { ...relay.location, customList },
- selected: false,
- visible: true,
- };
-}
diff --git a/gui/src/renderer/components/select-location/select-location-helpers.ts b/gui/src/renderer/components/select-location/select-location-helpers.ts
deleted file mode 100644
index 3d099adb7b..0000000000
--- a/gui/src/renderer/components/select-location/select-location-helpers.ts
+++ /dev/null
@@ -1,221 +0,0 @@
-import { sprintf } from 'sprintf-js';
-
-import {
- compareRelayLocation,
- compareRelayLocationCount,
- compareRelayLocationLoose,
- LiftedConstraint,
- RelayLocation,
- RelayLocationCity,
- RelayLocationCountry,
- RelayLocationCustomList,
- RelayLocationRelay,
-} from '../../../shared/daemon-rpc-types';
-import { messages, relayLocations } from '../../../shared/gettext';
-import {
- IRelayLocationCityRedux,
- IRelayLocationCountryRedux,
- IRelayLocationRelayRedux,
- NormalBridgeSettingsRedux,
- NormalRelaySettingsRedux,
-} from '../../redux/settings/reducers';
-import { DisabledReason, LocationSpecification, LocationType } from './select-location-types';
-
-export function isSelected(
- relayLocation: RelayLocation,
- selected?: LiftedConstraint<RelayLocation>,
-) {
- return selected !== 'any' && compareRelayLocationLoose(selected, relayLocation);
-}
-
-export function isExpanded(
- relayLocation: RelayLocation & { count?: number },
- expandedLocations?: Array<RelayLocation>,
-) {
- return (
- expandedLocations?.some((location) => compareRelayLocationCount(location, relayLocation)) ??
- false
- );
-}
-
-// Calculates which locations should be expanded based on selected location
-export function defaultExpandedLocations(
- relaySettings?: NormalRelaySettingsRedux,
- bridgeSettings?: NormalBridgeSettingsRedux,
-) {
- const expandedLocations: Partial<Record<LocationType, Array<RelayLocation>>> = {};
-
- const exitLocation = relaySettings?.location;
- if (exitLocation && exitLocation !== 'any') {
- expandedLocations[LocationType.exit] = expandRelayLocation(exitLocation);
- }
-
- if (relaySettings?.tunnelProtocol === 'openvpn') {
- const bridgeLocation = bridgeSettings?.location;
- if (bridgeLocation && bridgeLocation !== 'any') {
- expandedLocations[LocationType.entry] = expandRelayLocation(bridgeLocation);
- }
- } else if (relaySettings?.wireguard.useMultihop) {
- const entryLocation = relaySettings?.wireguard.entryLocation;
- if (entryLocation && entryLocation !== 'any') {
- expandedLocations[LocationType.entry] = expandRelayLocation(entryLocation);
- }
- }
-
- return expandedLocations;
-}
-
-// Expands a relay location and its parents
-function expandRelayLocation(location: RelayLocation): RelayLocation[] {
- if ('hostname' in location) {
- return [{ country: location.country }, { country: location.country, city: location.city }];
- } else if ('city' in location) {
- return [{ country: location.country }];
- } else {
- return [];
- }
-}
-
-// Formats the label that is discplayed for a country, city or relay
-export function formatRowName(
- name: string,
- location: RelayLocation,
- disabledReason?: DisabledReason,
-): string {
- const translatedName = 'hostname' in location ? name : relayLocations.gettext(name);
-
- // In some situations the exit/entry server should be marked on a location
- let info: string | undefined;
- if (disabledReason === DisabledReason.entry) {
- info = messages.pgettext('select-location-view', 'Entry');
- } else if (disabledReason === DisabledReason.exit) {
- info = messages.pgettext('select-location-view', 'Exit');
- }
-
- return info !== undefined
- ? sprintf(
- // TRANSLATORS: This is used for appending information about a location.
- // TRANSLATORS: E.g. "Gothenburg (Entry)" if Gothenburg has been selected as the entrypoint.
- // TRANSLATORS: Available placeholders:
- // TRANSLATORS: %(location)s - Translated location name
- // TRANSLATORS: %(info)s - Information about the location
- messages.pgettext('select-location-view', '%(location)s (%(info)s)'),
- {
- location: translatedName,
- info,
- },
- )
- : translatedName;
-}
-
-export function isRelayDisabled(
- relay: IRelayLocationRelayRedux,
- location: RelayLocationRelay,
- disabledLocation?: { location: RelayLocation; reason: DisabledReason },
-): DisabledReason | undefined {
- if (!relay.active) {
- return DisabledReason.inactive;
- } else if (disabledLocation && compareRelayLocation(location, disabledLocation.location)) {
- return disabledLocation.reason;
- } else {
- return undefined;
- }
-}
-
-export function isCityDisabled(
- city: IRelayLocationCityRedux,
- location: RelayLocationCity,
- disabledLocation?: { location: RelayLocation; reason: DisabledReason },
-): DisabledReason | undefined {
- const relaysDisabled = city.relays.map((relay) =>
- isRelayDisabled(relay, { ...location, hostname: relay.hostname }),
- );
- if (relaysDisabled.every((status) => status === DisabledReason.inactive)) {
- return DisabledReason.inactive;
- }
-
- const disabledDueToSelection = relaysDisabled.find(
- (status) => status === DisabledReason.entry || status === DisabledReason.exit,
- );
-
- if (
- relaysDisabled.every((status) => status !== undefined) &&
- disabledDueToSelection !== undefined
- ) {
- return disabledDueToSelection;
- }
-
- if (
- disabledLocation &&
- compareRelayLocation(location, disabledLocation.location) &&
- city.relays.filter((relay) => relay.active).length <= 1
- ) {
- return disabledLocation.reason;
- }
-
- return undefined;
-}
-
-export function isCountryDisabled(
- country: IRelayLocationCountryRedux,
- location: RelayLocationCountry,
- disabledLocation?: { location: RelayLocation; reason: DisabledReason },
-): DisabledReason | undefined {
- const citiesDisabled = country.cities.map((city) =>
- isCityDisabled(city, { ...location, city: city.code }),
- );
- if (citiesDisabled.every((status) => status === DisabledReason.inactive)) {
- return DisabledReason.inactive;
- }
-
- const disabledDueToSelection = citiesDisabled.find(
- (status) => status === DisabledReason.entry || status === DisabledReason.exit,
- );
- if (
- citiesDisabled.every((status) => status !== undefined) &&
- disabledDueToSelection !== undefined
- ) {
- return disabledDueToSelection;
- }
-
- if (
- disabledLocation &&
- compareRelayLocation(location, disabledLocation.location) &&
- country.cities.flatMap((city) => city.relays).filter((relay) => relay.active).length <= 1
- ) {
- return disabledLocation.reason;
- }
-
- return undefined;
-}
-
-export function isCustomListDisabled(
- location: RelayLocationCustomList,
- locations: Array<LocationSpecification>,
- disabledLocation?: { location: RelayLocation; reason: DisabledReason },
-) {
- const locationsDisabled = locations.map((location) => location.disabledReason);
- if (locationsDisabled.every((status) => status === DisabledReason.inactive)) {
- return DisabledReason.inactive;
- }
-
- const disabledDueToSelection = locationsDisabled.find(
- (status) => status === DisabledReason.entry || status === DisabledReason.exit,
- );
- if (
- locationsDisabled.every((status) => status !== undefined) &&
- disabledDueToSelection !== undefined
- ) {
- return disabledDueToSelection;
- }
-
- if (
- disabledLocation &&
- compareRelayLocation(location, disabledLocation.location) &&
- locations.filter((location) => location.active).length <= 1
- ) {
- return disabledLocation.reason;
- }
-
- return undefined;
-}
diff --git a/gui/src/renderer/components/select-location/select-location-hooks.ts b/gui/src/renderer/components/select-location/select-location-hooks.ts
deleted file mode 100644
index fbecc4db5d..0000000000
--- a/gui/src/renderer/components/select-location/select-location-hooks.ts
+++ /dev/null
@@ -1,148 +0,0 @@
-import { useCallback } from 'react';
-
-import {
- BridgeSettings,
- RelayLocation,
- RelaySettings,
- wrapConstraint,
-} from '../../../shared/daemon-rpc-types';
-import log from '../../../shared/logging';
-import { useAppContext } from '../../context';
-import { useRelaySettingsModifier } from '../../lib/constraint-updater';
-import { useBridgeSettingsModifier } from '../../lib/constraint-updater';
-import { useHistory } from '../../lib/history';
-import { LocationType, SpecialBridgeLocationType } from './select-location-types';
-import { useSelectLocationContext } from './SelectLocationContainer';
-
-export function useOnSelectExitLocation() {
- const onSelectLocation = useOnSelectLocation();
- const history = useHistory();
- const relaySettingsModifier = useRelaySettingsModifier();
- const { connectTunnel } = useAppContext();
-
- const onSelectRelay = useCallback(
- async (relayLocation: RelayLocation) => {
- const settings = relaySettingsModifier((settings) => ({
- ...settings,
- location: wrapConstraint(relayLocation),
- }));
- history.pop();
- await onSelectLocation({ normal: settings });
- await connectTunnel();
- },
- [connectTunnel, history, onSelectLocation, relaySettingsModifier],
- );
-
- const onSelectSpecial = useCallback((_location: undefined) => {
- throw new Error('relayLocation should never be undefined');
- }, []);
-
- return [onSelectRelay, onSelectSpecial] as const;
-}
-
-export function useOnSelectEntryLocation() {
- const onSelectLocation = useOnSelectLocation();
- const { setLocationType } = useSelectLocationContext();
- const relaySettingsModifier = useRelaySettingsModifier();
-
- const onSelectRelay = useCallback(
- async (entryLocation: RelayLocation) => {
- setLocationType(LocationType.exit);
- const settings = relaySettingsModifier((settings) => {
- settings.wireguardConstraints.entryLocation = wrapConstraint(entryLocation);
- return settings;
- });
- await onSelectLocation({ normal: settings });
- },
- [onSelectLocation, relaySettingsModifier, setLocationType],
- );
-
- const onSelectSpecial = useCallback(
- async (_location: 'any') => {
- setLocationType(LocationType.exit);
- const settings = relaySettingsModifier((settings) => {
- settings.wireguardConstraints.entryLocation = 'any';
- return settings;
- });
- await onSelectLocation({ normal: settings });
- },
- [onSelectLocation, relaySettingsModifier, setLocationType],
- );
-
- return [onSelectRelay, onSelectSpecial] as const;
-}
-
-function useOnSelectLocation() {
- const { setRelaySettings } = useAppContext();
-
- return useCallback(
- async (relaySettings: RelaySettings) => {
- try {
- await setRelaySettings(relaySettings);
- } catch (e) {
- const error = e as Error;
- log.error(`Failed to select the location: ${error.message}`);
- }
- },
- [setRelaySettings],
- );
-}
-
-export function useOnSelectBridgeLocation() {
- const { updateBridgeSettings } = useAppContext();
- const { setLocationType } = useSelectLocationContext();
- const bridgeSettingsModifier = useBridgeSettingsModifier();
-
- const setLocation = useCallback(
- async (bridgeUpdate: BridgeSettings) => {
- if (bridgeUpdate) {
- setLocationType(LocationType.exit);
- try {
- await updateBridgeSettings(bridgeUpdate);
- } catch (e) {
- const error = e as Error;
- log.error(`Failed to select the bridge location: ${error.message}`);
- }
- }
- },
- [setLocationType, updateBridgeSettings],
- );
-
- const onSelectRelay = useCallback(
- (location: RelayLocation) => {
- return setLocation(
- bridgeSettingsModifier((bridgeSettings) => {
- bridgeSettings.type = 'normal';
- bridgeSettings.normal.location = wrapConstraint(location);
- return bridgeSettings;
- }),
- );
- },
- [bridgeSettingsModifier, setLocation],
- );
-
- const onSelectSpecial = useCallback(
- (location: SpecialBridgeLocationType) => {
- switch (location) {
- case SpecialBridgeLocationType.closestToExit:
- return setLocation(
- bridgeSettingsModifier((bridgeSettings) => {
- bridgeSettings.type = 'normal';
- bridgeSettings.normal.location = 'any';
- return bridgeSettings;
- }),
- );
- case SpecialBridgeLocationType.custom:
- return setLocation(
- bridgeSettingsModifier((bridgeSettings) => {
- bridgeSettings.type = 'custom';
- return bridgeSettings;
- }),
- );
- }
- },
- [bridgeSettingsModifier, setLocation],
- );
-
- return [onSelectRelay, onSelectSpecial] as const;
-}
diff --git a/gui/src/renderer/components/select-location/select-location-types.ts b/gui/src/renderer/components/select-location/select-location-types.ts
deleted file mode 100644
index ca6afecf74..0000000000
--- a/gui/src/renderer/components/select-location/select-location-types.ts
+++ /dev/null
@@ -1,119 +0,0 @@
-import {
- ICustomList,
- RelayLocation,
- RelayLocationCity,
- RelayLocationCountry,
- RelayLocationCustomList,
- RelayLocationRelay,
-} from '../../../shared/daemon-rpc-types';
-import {
- IRelayLocationCityRedux,
- IRelayLocationCountryRedux,
- IRelayLocationRelayRedux,
-} from '../../redux/settings/reducers';
-import { SpecialLocationRowInnerProps } from './SpecialLocationList';
-
-export enum LocationType {
- entry = 0,
- exit,
-}
-
-export type RelayList = GeographicalRelayList | Array<CustomListSpecification>;
-export type GeographicalRelayList = Array<CountrySpecification>;
-
-export enum SpecialBridgeLocationType {
- closestToExit,
- custom,
-}
-
-export interface LocationVisibility {
- visible: boolean;
-}
-
-interface CommonLocationSpecification {
- label: string;
- selected: boolean;
- disabled?: boolean;
- disabledReason?: DisabledReason;
-}
-
-export interface SpecialLocation<T> extends CommonLocationSpecification {
- value: T;
- component: React.ComponentType<SpecialLocationRowInnerProps<T>>;
-}
-
-type GeographicalLocationSpecification =
- | CountrySpecification
- | CitySpecification
- | RelaySpecification;
-
-export type LocationSpecification = GeographicalLocationSpecification | CustomListSpecification;
-
-export interface RelayLocationCountryWithVisibility
- extends IRelayLocationCountryRedux,
- LocationVisibility {
- cities: Array<RelayLocationCityWithVisibility>;
-}
-
-export interface RelayLocationCityWithVisibility
- extends IRelayLocationCityRedux,
- LocationVisibility {
- relays: Array<RelayLocationRelayWithVisibility>;
-}
-
-export type RelayLocationRelayWithVisibility = IRelayLocationRelayRedux & LocationVisibility;
-
-interface CommonNormalLocationSpecification
- extends CommonLocationSpecification,
- LocationVisibility {
- location: RelayLocation;
- disabled: boolean;
- active: boolean;
-}
-
-export interface CustomListSpecification extends CommonNormalLocationSpecification {
- location: RelayLocationCustomList;
- list: ICustomList;
- expanded: boolean;
- locations: Array<GeographicalLocationSpecification>;
-}
-
-export interface CountrySpecification
- extends Pick<IRelayLocationCountryRedux, 'name' | 'code'>,
- CommonNormalLocationSpecification {
- location: RelayLocationCountry;
- expanded: boolean;
- cities: Array<CitySpecification>;
-}
-
-export interface CitySpecification
- extends Pick<IRelayLocationCityRedux, 'name' | 'code'>,
- CommonNormalLocationSpecification {
- location: RelayLocationCity;
- expanded: boolean;
- relays: Array<RelaySpecification>;
-}
-
-export interface RelaySpecification
- extends Omit<IRelayLocationRelayRedux, 'ipv4AddrIn' | 'includeInCountry' | 'weight'>,
- CommonNormalLocationSpecification {
- location: RelayLocationRelay;
-}
-
-export enum DisabledReason {
- entry,
- exit,
- inactive,
-}
-
-export function getLocationChildren(location: LocationSpecification): Array<LocationSpecification> {
- if ('locations' in location) {
- return location.locations;
- } else if ('cities' in location) {
- return location.cities;
- } else if ('relays' in location) {
- return location.relays;
- } else {
- return [];
- }
-}
diff --git a/gui/src/renderer/context.tsx b/gui/src/renderer/context.tsx
deleted file mode 100644
index eb1be7a556..0000000000
--- a/gui/src/renderer/context.tsx
+++ /dev/null
@@ -1,25 +0,0 @@
-import React, { useContext } from 'react';
-
-import App from './app';
-
-export interface IAppContext {
- app: App;
-}
-
-export const AppContext = React.createContext<IAppContext | undefined>(undefined);
-if (window.env.development) {
- AppContext.displayName = 'AppContext';
-}
-
-const missingContextError = new Error(
- 'The context value is empty. Make sure to wrap the component in AppContext.Provider.',
-);
-
-export function useAppContext(): App {
- const appContext = useContext(AppContext);
- if (appContext) {
- return appContext.app;
- } else {
- throw missingContextError;
- }
-}
diff --git a/gui/src/renderer/index.html b/gui/src/renderer/index.html
deleted file mode 100644
index e262187014..0000000000
--- a/gui/src/renderer/index.html
+++ /dev/null
@@ -1,21 +0,0 @@
-<!DOCTYPE html>
-<html>
- <head>
- <title>Mullvad VPN</title>
- <link rel="preload" href="../../assets/fonts/SourceSansPro-Bold.ttf" as="font" />
- <link rel="preload" href="../../assets/fonts/NotoSansMyanmar-Bold.ttf" as="font" />
- <link rel="preload" href="../../assets/fonts/NotoSansThai-Bold.ttf" as="font" />
- <link rel="preload" href="../../assets/fonts/SourceSansPro-SemiBold.ttf" as="font" />
- <link rel="preload" href="../../assets/fonts/OpenSans-Bold.ttf" as="font" />
- <link rel="preload" href="../../assets/fonts/OpenSans-Semibold.ttf" as="font" />
- <link rel="preload" href="../../assets/fonts/OpenSans-Regular.ttf" as="font" />
- <link rel="stylesheet" href="../../assets/css/style.css" />
- <meta http-equiv="Content-Security-Policy" content="script-src 'self' 'unsafe-inline'">
- <meta charset="UTF-8">
- </head>
- <body>
- <div id="app"></div>
- <script>var exports = {};</script>
- <script src="./bundle.js"></script>
- </body>
-</html>
diff --git a/gui/src/renderer/index.ts b/gui/src/renderer/index.ts
deleted file mode 100644
index fbf8ebcdba..0000000000
--- a/gui/src/renderer/index.ts
+++ /dev/null
@@ -1,8 +0,0 @@
-import { createRoot } from 'react-dom/client';
-
-import App from './app';
-
-const app = new App();
-const container = document.getElementById('app');
-const root = createRoot(container!);
-root.render(app.renderView());
diff --git a/gui/src/renderer/lib/3dmap.ts b/gui/src/renderer/lib/3dmap.ts
deleted file mode 100644
index d0e130e4c8..0000000000
--- a/gui/src/renderer/lib/3dmap.ts
+++ /dev/null
@@ -1,835 +0,0 @@
-import { mat4 } from 'gl-matrix';
-
-type ColorRgba = [number, number, number, number];
-type ColorRgb = [number, number, number];
-
-export interface MapData {
- landContourIndices: ArrayBuffer;
- landPositions: ArrayBuffer;
- landTriangleIndices: ArrayBuffer;
- oceanIndices: ArrayBuffer;
- oceanPositions: ArrayBuffer;
-}
-
-interface IndexBuffer {
- indexBuffer: WebGLBuffer;
- length: number;
-}
-
-interface ProgramInfo {
- program: WebGLProgram;
- attribLocations: {
- vertexPosition: GLint;
- vertexColor?: GLint;
- };
- uniformLocations: {
- color?: WebGLUniformLocation;
- projectionMatrix: WebGLUniformLocation;
- modelViewMatrix: WebGLUniformLocation;
- };
-}
-
-interface ZoomAnimation {
- endTime: number;
- compute(now: number): [number, number];
-}
-
-export enum ConnectionState {
- disconnected,
- connected,
- noMarker,
-}
-
-// Color of "space" as seen in the corners when zooming out
-const spaceColor: ColorRgba = [10 / 255, 25 / 255, 35 / 255, 1];
-// Color values for various components of the map.
-const landColor: ColorRgba = [0.16, 0.302, 0.45, 1.0];
-const oceanColor: ColorRgba = [0.098, 0.18, 0.271, 1.0];
-// The color of borders between geographical entities
-const contourColor: ColorRgba = oceanColor;
-
-// The green color of the location marker when in the secured state
-const locationMarkerSecureColor: ColorRgb = [0.267, 0.678, 0.302];
-// The red color of the location marken when in the unsecured state
-const locationMarkerUnsecureColor: ColorRgb = [0.89, 0.251, 0.224];
-
-// The angle in degrees that the camera sees in
-const angleOfView = 70;
-
-// Zoom is distance from earths center. 1.0 is at the surface.
-// These constants define the zoom levels for the connected and disconnected states.
-const disconnectedZoom = 1.35;
-const connectedZoom = 1.25;
-
-// Animations longer than this time will use the out-in zoom animation.
-// Shorter animations will use the direct animation.
-const zoomAnimationStyleTimeBreakpoint = 1.7;
-// When animating with the out-in zoom animation, set the middle
-// zoom point to this times the max start or end zoom levels.
-const animationZoomoutFactor = 1.5;
-// Never zoom out further than this.
-const maxZoomout = Math.max(disconnectedZoom, connectedZoom) * animationZoomoutFactor;
-
-// The min and max time an animation to a new location can take.
-const animationMinTime = 1.3;
-const animationMaxTime = 2.5;
-
-// A geographical latitude, longitude coordinate in *degrees*.
-// This class is also being abused as a 2D vector in some parts of the code.
-export interface Coordinate {
- latitude: number;
- longitude: number;
-}
-
-class Vector {
- public constructor(
- public x: number,
- public y: number,
- ) {}
-
- public static fromCoordinate(coordinate: Coordinate): Vector {
- return new Vector(coordinate.latitude, coordinate.longitude);
- }
-
- public toCoordinate() {
- return { latitude: this.x, longitude: this.y };
- }
-
- public length() {
- return Math.sqrt(this.x * this.x + this.y * this.y);
- }
-
- public scale(r: number) {
- return new Vector(this.x * r, this.y * r);
- }
-
- public add(other: Vector) {
- return new Vector(this.x + other.x, this.y + other.y);
- }
-}
-
-// Class for drawing earth.
-class Globe {
- private static vsSource = `
- attribute vec3 aVertexPosition;
-
- uniform vec4 uColor;
- uniform mat4 uModelViewMatrix;
- uniform mat4 uProjectionMatrix;
-
- varying lowp vec4 vColor;
-
- void main(void) {
- gl_Position = uProjectionMatrix * uModelViewMatrix * vec4(aVertexPosition, 1.0);
- vColor = uColor;
- }
- `;
-
- private static fsSource = `
- varying lowp vec4 vColor;
-
- void main(void) {
- gl_FragColor = vColor;
- }
- `;
-
- private landVertexBuffer: WebGLBuffer;
- private landContourIndexBuffer: IndexBuffer;
- private landTriangleIndexBuffer: IndexBuffer;
- private oceanVertexBuffer: WebGLBuffer;
- private oceanIndexBuffer: IndexBuffer;
-
- private programInfo: ProgramInfo;
-
- public constructor(
- private gl: WebGL2RenderingContext,
- data: MapData,
- ) {
- this.landVertexBuffer = initArrayBuffer(gl, data.landPositions);
- this.oceanVertexBuffer = initArrayBuffer(gl, data.oceanPositions);
-
- this.landContourIndexBuffer = initIndexBuffer(gl, data.landContourIndices);
- this.landTriangleIndexBuffer = initIndexBuffer(gl, data.landTriangleIndices);
- this.oceanIndexBuffer = initIndexBuffer(gl, data.oceanIndices);
-
- const shaderProgram = initShaderProgram(gl, Globe.vsSource, Globe.fsSource);
- this.programInfo = {
- program: shaderProgram,
- attribLocations: {
- vertexPosition: gl.getAttribLocation(shaderProgram, 'aVertexPosition'),
- },
- uniformLocations: {
- color: gl.getUniformLocation(shaderProgram, 'uColor')!,
- projectionMatrix: gl.getUniformLocation(shaderProgram, 'uProjectionMatrix')!,
- modelViewMatrix: gl.getUniformLocation(shaderProgram, 'uModelViewMatrix')!,
- },
- };
- }
-
- public draw(projectionMatrix: mat4, viewMatrix: mat4) {
- const globeViewMatrix = mat4.clone(viewMatrix);
-
- this.gl.useProgram(this.programInfo.program);
-
- // Draw country contour lines
- drawBufferElements(
- this.gl,
- this.programInfo,
- projectionMatrix,
- globeViewMatrix,
- this.landVertexBuffer,
- this.landContourIndexBuffer,
- contourColor,
- this.gl.LINE_STRIP,
- );
-
- // We scale down to render the land triangles behind/under the country contour lines.
- mat4.scale(
- globeViewMatrix, // destination matrix
- globeViewMatrix, // matrix to scale
- [0.99999, 0.99999, 0.99999], // amount to scale
- );
-
- // Draw land triangles.
- drawBufferElements(
- this.gl,
- this.programInfo,
- projectionMatrix,
- globeViewMatrix,
- this.landVertexBuffer,
- this.landTriangleIndexBuffer,
- landColor,
- this.gl.TRIANGLES,
- );
-
- // Draw the ocean as a sphere just beneath the land.
- drawBufferElements(
- this.gl,
- this.programInfo,
- projectionMatrix,
- globeViewMatrix,
- this.oceanVertexBuffer,
- this.oceanIndexBuffer,
- oceanColor,
- this.gl.TRIANGLES,
- );
- }
-}
-
-// Class for rendering a location marker on a given coordinate on the globe.
-class LocationMarker {
- private static vsSource = `
- attribute vec3 aVertexPosition;
- attribute vec4 aVertexColor;
-
- uniform mat4 uModelViewMatrix;
- uniform mat4 uProjectionMatrix;
-
- varying lowp vec4 vColor;
-
- void main(void) {
- gl_Position = uProjectionMatrix * uModelViewMatrix * vec4(aVertexPosition, 1.0);
- vColor = aVertexColor;
- }
- `;
-
- private static fsSource = `
- varying lowp vec4 vColor;
-
- void main(void) {
- gl_FragColor = vColor;
- }
- `;
-
- private programInfo: ProgramInfo;
- private ringPositionCount: Array<number>;
- private positionBuffer: WebGLBuffer;
- private colorBuffer: WebGLBuffer;
-
- public constructor(
- private gl: WebGL2RenderingContext,
- color: ColorRgb,
- ) {
- const white: ColorRgb = [1.0, 1.0, 1.0];
- const black: ColorRgb = [0.0, 0.0, 0.0];
- const rings = [
- circleFanVertices(32, 0.5, [0.0, 0.0, 0.0], [...color, 0.4], [...color, 0.4]), // Semi-transparent outer
- circleFanVertices(16, 0.28, [0.0, -0.05, 0.00001], [...black, 0.55], [...black, 0.0]), // shadow
- circleFanVertices(32, 0.185, [0.0, 0.0, 0.00002], [...white, 1.0], [...white, 1.0]), // white ring
- circleFanVertices(32, 0.15, [0.0, 0.0, 0.00003], [...color, 1.0], [...color, 1.0]), // Center colored circle
- ];
-
- const positionArrayBuffer = new Float32Array(rings.map((r) => r.positions).flat());
- const colorArrayBuffer = new Float32Array(rings.map((r) => r.colors).flat());
- this.ringPositionCount = rings.map((r) => r.positions.length);
- this.positionBuffer = initArrayBuffer(gl, positionArrayBuffer);
- this.colorBuffer = initArrayBuffer(gl, colorArrayBuffer);
-
- const shaderProgram = initShaderProgram(gl, LocationMarker.vsSource, LocationMarker.fsSource);
- this.programInfo = {
- program: shaderProgram,
- attribLocations: {
- vertexPosition: gl.getAttribLocation(shaderProgram, 'aVertexPosition'),
- vertexColor: gl.getAttribLocation(shaderProgram, 'aVertexColor'),
- },
- uniformLocations: {
- projectionMatrix: gl.getUniformLocation(shaderProgram, 'uProjectionMatrix')!,
- modelViewMatrix: gl.getUniformLocation(shaderProgram, 'uModelViewMatrix')!,
- },
- };
- }
-
- public draw(projectionMatrix: mat4, viewMatrix: mat4, coordinate: Coordinate, size: number) {
- const modelViewMatrix = mat4.clone(viewMatrix);
-
- this.gl.useProgram(this.programInfo.program);
-
- const [theta, phi] = coordinates2thetaphi(coordinate);
- mat4.rotateY(modelViewMatrix, modelViewMatrix, theta);
- mat4.rotateX(modelViewMatrix, modelViewMatrix, -phi);
-
- mat4.scale(modelViewMatrix, modelViewMatrix, [size, size, 1.0]);
- mat4.translate(modelViewMatrix, modelViewMatrix, [0.0, 0.0, 1.0001]);
-
- {
- this.gl.bindBuffer(this.gl.ARRAY_BUFFER, this.positionBuffer);
- this.gl.vertexAttribPointer(
- this.programInfo.attribLocations.vertexPosition,
- 3, // num components
- this.gl.FLOAT, // type
- false, // normalize
- 0, // stride
- 0, // offset
- );
- this.gl.enableVertexAttribArray(this.programInfo.attribLocations.vertexPosition);
- }
- {
- this.gl.bindBuffer(this.gl.ARRAY_BUFFER, this.colorBuffer);
- this.gl.vertexAttribPointer(
- this.programInfo.attribLocations.vertexColor!,
- 4, // num components
- this.gl.FLOAT, // type
- false, // normalize
- 0, // stride
- 0, // offset
- );
- this.gl.enableVertexAttribArray(this.programInfo.attribLocations.vertexColor!);
- }
-
- // Set the shader uniforms
- this.gl.uniformMatrix4fv(
- this.programInfo.uniformLocations.projectionMatrix,
- false,
- projectionMatrix,
- );
- this.gl.uniformMatrix4fv(
- this.programInfo.uniformLocations.modelViewMatrix,
- false,
- modelViewMatrix,
- );
-
- let offset = 0;
- for (let i = 0; i < this.ringPositionCount.length; i++) {
- const numVertices = this.ringPositionCount[i] / 3;
- this.gl.drawArrays(this.gl.TRIANGLE_FAN, offset, numVertices);
- offset += numVertices;
- }
- }
-}
-
-// Class for computing a smooth linear interpolation from `start` along `path`.
-// Starting at time `startTime` (usually now() at the time of creating an instance),
-// and animating for `duration` seconds
-class SmoothLerp {
- public constructor(
- private start: Vector,
- private path: Vector,
- private startTime: number,
- private duration: number,
- ) {}
-
- // Computes and returns the position as well as the smoothened transition
- // ratio of this lerp operation.
- public compute(now: number): [Vector, number] {
- const animationRatio = Math.min(Math.max((now - this.startTime) / this.duration, 0.0), 1.0);
- const smoothAnimationRatio = smoothTransition(animationRatio);
- const position = this.start.add(this.path.scale(smoothAnimationRatio));
- return [position, smoothAnimationRatio];
- }
-}
-
-// Zooms from startZoom to endZoom via a midpoint that is `animationZoomoutFactor` times higer up
-// than max(startZoom, endZoom).
-class SmoothZoomOutIn implements ZoomAnimation {
- private middleZoom: number;
-
- public constructor(
- private startZoom: number,
- private endZoom: number,
- private startTime: number,
- private duration: number,
- ) {
- this.middleZoom = Math.min(Math.max(startZoom, endZoom) * animationZoomoutFactor, maxZoomout);
- }
-
- get endTime(): number {
- return this.startTime + this.duration;
- }
-
- public compute(now: number): [number, number] {
- const animationRatio = Math.min(Math.max((now - this.startTime) / this.duration, 0.0), 1.0);
- // Linear animation ratio 0-1. 0.0-0.5 means zooming out and 0.5-1.0 means zooming in
- if (animationRatio <= 0.5) {
- const smoothAnimationRatio = smoothTransition(animationRatio * 2);
- return [
- this.startZoom + smoothAnimationRatio * (this.middleZoom - this.startZoom),
- animationRatio,
- ];
- } else {
- const smoothAnimationRatio = smoothTransition((animationRatio - 0.5) * 2);
- return [
- this.middleZoom - smoothAnimationRatio * (this.middleZoom - this.endZoom),
- animationRatio,
- ];
- }
- }
-}
-
-// Zooms from startZoom to endZoom directly in a smooth manner.
-class SmoothZoomDirect implements ZoomAnimation {
- public constructor(
- private startZoom: number,
- private endZoom: number,
- private startTime: number,
- private duration: number,
- ) {}
-
- get endTime(): number {
- return this.startTime + this.duration;
- }
-
- public compute(now: number): [number, number] {
- const animationRatio = Math.min(Math.max((now - this.startTime) / this.duration, 0.0), 1.0);
- const smoothAnimationRatio = smoothTransition(animationRatio);
- return [
- this.startZoom + smoothAnimationRatio * (this.endZoom - this.startZoom),
- animationRatio,
- ];
- }
-}
-
-export default class GlMap {
- private projectionMatrix: mat4;
- private globe: Globe;
- private locationMarkerSecure: LocationMarker;
- private locationMarkerUnsecure: LocationMarker;
-
- // Current state of the map positioning
- private coordinate: Coordinate;
- private zoom: number;
- private connectionState: ConnectionState;
-
- // `targetCoordinate` is the same as `coordinate` when no animation is in progress.
- // This is where the location marker is drawn.
- private targetCoordinate: Coordinate;
-
- // Current ongoing animations. Empty arrays when no animation in progress.
- private animations: Array<SmoothLerp>;
- private zoomAnimations: Array<ZoomAnimation>;
-
- public constructor(
- private gl: WebGL2RenderingContext,
- data: MapData,
- startCoordinate: Coordinate,
- connectionState: ConnectionState,
- private animationEndListener?: () => void,
- ) {
- initGlOptions(gl);
- this.projectionMatrix = getProjectionMatrix(gl);
- this.globe = new Globe(gl, data);
- this.locationMarkerSecure = new LocationMarker(gl, locationMarkerSecureColor);
- this.locationMarkerUnsecure = new LocationMarker(gl, locationMarkerUnsecureColor);
-
- this.coordinate = startCoordinate;
- this.zoom = connectionState === ConnectionState.connected ? connectedZoom : disconnectedZoom;
- this.connectionState = connectionState;
-
- this.targetCoordinate = startCoordinate;
-
- this.animations = [];
- this.zoomAnimations = [];
- }
-
- public updateViewport() {
- this.gl.viewport(0, 0, this.gl.drawingBufferWidth, this.gl.drawingBufferHeight);
- }
-
- // Move the location marker to `newCoordinate` (with state `connectionState`).
- // Queues an animation to `newCoordinate` if `animate` is true. Otherwise it moves
- // directly to that location.
- public setLocation(
- newCoordinate: Coordinate,
- connectionState: ConnectionState,
- now: number,
- animate: boolean,
- ) {
- const endZoom = connectionState == ConnectionState.connected ? connectedZoom : disconnectedZoom;
-
- // Only perform a coordinate animation if the new coordinate is
- // different from the current position/latest ongoing animation.
- // If the new coordinate is the same as the current target, we just
- // queue a zoom animation.
- if (animate) {
- if (newCoordinate !== this.targetCoordinate) {
- const path = shortestPath(
- Vector.fromCoordinate(this.coordinate),
- Vector.fromCoordinate(newCoordinate),
- );
-
- // Compute animation time as a function of movement distance. Clamp the
- // duration range between animationMinTime and animationMaxTime
- const duration = Math.min(Math.max(path.length() / 20, animationMinTime), animationMaxTime);
-
- this.animations.push(
- new SmoothLerp(Vector.fromCoordinate(this.coordinate), path, now, duration),
- );
- if (duration > zoomAnimationStyleTimeBreakpoint) {
- this.zoomAnimations.push(new SmoothZoomOutIn(this.zoom, endZoom, now, duration));
- } else {
- this.zoomAnimations.push(new SmoothZoomDirect(this.zoom, endZoom, now, duration));
- }
- } else {
- let duration = animationMinTime;
- // If an animation is in progress, make sure our zoom animation ends at the same time.
- // Just makes a smooth transition from one zoom end state to the other.
- if (this.zoomAnimations.length > 0) {
- const lastZoomAnimation = this.zoomAnimations[this.zoomAnimations.length - 1];
- duration = Math.max(lastZoomAnimation.endTime - now, animationMinTime);
- }
- this.zoomAnimations.push(new SmoothZoomDirect(this.zoom, endZoom, now, duration));
- }
- } else {
- this.animations = [];
- this.zoomAnimations = [];
- this.coordinate = newCoordinate;
- this.zoom = endZoom;
- }
-
- this.connectionState = connectionState;
- this.targetCoordinate = newCoordinate;
- }
-
- // Render the map for the time `now`.
- public draw(now: number) {
- this.clearCanvas();
- this.updatePosition(now);
- this.updateZoom(now);
-
- if (this.animations.length === 0 && this.zoomAnimations.length === 0) {
- this.animationEndListener?.();
- }
-
- const viewMatrix = mat4.create();
-
- // Offset Y for placing the marker at the same area as the spinner. The zoom calculation is
- // required for the unsecured and secured markers to be placed in the same spot.
- // The constants look arbitrary. They are found by just trying stuff until it looks good.
- const offsetY = 0.088 + (this.zoom - connectedZoom) * 0.3;
-
- // Move the camera back `this.zoom` away from the center of the globe.
- mat4.translate(
- viewMatrix, // destination matrix
- viewMatrix, // matrix to translate
- [0.0, offsetY, -this.zoom],
- );
-
- // Rotate the globe so the camera ends up looking down on `this.coordinate`.
- const [theta, phi] = coordinates2thetaphi(this.coordinate);
- mat4.rotateX(viewMatrix, viewMatrix, phi);
- mat4.rotateY(viewMatrix, viewMatrix, -theta);
-
- this.globe.draw(this.projectionMatrix, viewMatrix);
-
- // Draw the appropriate location marker depending on our connection state.
- switch (this.connectionState) {
- case ConnectionState.disconnected:
- this.locationMarkerUnsecure.draw(
- this.projectionMatrix,
- viewMatrix,
- this.targetCoordinate,
- 0.03 * this.zoom,
- );
- break;
- case ConnectionState.connected:
- this.locationMarkerSecure.draw(
- this.projectionMatrix,
- viewMatrix,
- this.targetCoordinate,
- 0.03 * this.zoom,
- );
- break;
- }
- }
-
- private clearCanvas() {
- this.gl.clearColor(...spaceColor); // Set the clear color to space color
- this.gl.clearDepth(1.0);
- this.gl.enable(this.gl.DEPTH_TEST); // Enable depth testing
- this.gl.depthFunc(this.gl.LEQUAL); // Near things obscure far things
-
- // Clear the canvas before we start drawing on it.
- this.gl.clear(this.gl.COLOR_BUFFER_BIT | this.gl.DEPTH_BUFFER_BIT);
- }
-
- // Private function that just updates internal animation state to match with time `now`.
- private updatePosition(now: number) {
- if (this.animations.length === 0) {
- return;
- }
-
- // Compute lerp position and ratio of the newest animation
- const lastAnimation = this.animations[this.animations.length - 1];
- let [coordinate, ratio] = lastAnimation.compute(now);
- if (ratio >= 1.0) {
- // Animation is done. We can empty the animations array
- this.animations = [];
- }
-
- // Loop through all previous animations (that are still in progress) backwards and
- // lerp between them to compute our actual location.
- for (let i = this.animations.length - 2; i >= 0; i--) {
- const [previousPoint, animationRatio] = this.animations[i].compute(now);
- coordinate = lerpVector(previousPoint, coordinate, ratio);
- // If this animation is finished, none of the animations [0, i) will have any effect,
- // so they can be pruned
- if (animationRatio >= 1.0 && i > 0) {
- this.animations = this.animations.slice(i, this.animations.length);
-
- break;
- }
- ratio = animationRatio;
- }
-
- // Set our coordinate and zoom to the values interpolated from all ongoing animations.
- this.coordinate = coordinate.toCoordinate();
- }
-
- // Private function that updates the current zoom level according to ongoing animations.
- private updateZoom(now: number) {
- if (this.zoomAnimations.length === 0) {
- return;
- }
-
- const lastZoomAnimation = this.zoomAnimations[this.zoomAnimations.length - 1];
- let [zoom, ratio] = lastZoomAnimation.compute(now);
-
- if (ratio >= 1.0) {
- // Animation is done. We can empty the animations array
- this.zoomAnimations = [];
- }
-
- // Loop through all previous animations (that are still in progress) backwards and
- // lerp between them to compute our actual location.
- for (let i = this.zoomAnimations.length - 2; i >= 0; i--) {
- const [previousZoom, animationRatio] = this.zoomAnimations[i].compute(now);
- zoom = lerp(previousZoom, zoom, ratio);
- // If this animation is finished, none of the animations [0, i) will have any effect,
- // so they can be pruned
- if (animationRatio >= 1.0 && i > 0) {
- this.zoomAnimations = this.zoomAnimations.slice(i, this.zoomAnimations.length);
- break;
- }
- ratio = animationRatio;
- }
-
- // Set our coordinate and zoom to the values interpolated from all ongoing animations.
- this.zoom = zoom;
- }
-}
-
-function initGlOptions(gl: WebGL2RenderingContext) {
- // Hide triangles not facing the camera
- gl.enable(gl.CULL_FACE);
- gl.cullFace(gl.BACK);
-
- // Enable transparency (alpha < 1.0)
- gl.enable(gl.BLEND);
- gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
-}
-
-function getProjectionMatrix(gl: WebGL2RenderingContext): mat4 {
- // Enables using gl.UNSIGNED_INT for indexes. Allows 32 bit integer
- // indexes. Needed to have more than 2^16 vertices in one buffer.
- // Not needed on WebGL2 canvases where it's enabled by default
- // const ext = gl.getExtension('OES_element_index_uint');
-
- // Create a perspective matrix, a special matrix that is
- // used to simulate the distortion of perspective in a camera.
- const fieldOfView = (angleOfView / 180) * Math.PI; // in radians
- const canvas = gl.canvas as HTMLCanvasElement;
- const aspect = canvas.clientWidth / canvas.clientHeight;
- const zNear = 0.1;
- const zFar = 10;
- const projectionMatrix = mat4.create();
- mat4.perspective(projectionMatrix, fieldOfView, aspect, zNear, zFar);
-
- return projectionMatrix;
-}
-
-// Draws primitives of type `mode` (TRIANGLES, LINES etc) using vertex positions from
-// `positionBuffer` at indices in `indices` with the color `color` and using the shaders in
-// `programInfo`.
-function drawBufferElements(
- gl: WebGL2RenderingContext,
- programInfo: ProgramInfo,
- projectionMatrix: mat4,
- modelViewMatrix: mat4,
- positionBuffer: WebGLBuffer,
- indices: IndexBuffer,
- color: ColorRgba,
- mode: GLenum,
-) {
- {
- gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);
- gl.vertexAttribPointer(
- programInfo.attribLocations.vertexPosition,
- 3, // num components
- gl.FLOAT, // type
- false, // normalize
- 0, // stride
- 0, // offset
- );
- gl.enableVertexAttribArray(programInfo.attribLocations.vertexPosition);
- }
-
- // Tell WebGL which indices to use to index the vertices
- gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, indices.indexBuffer);
-
- // Set the shader uniforms
- gl.uniform4fv(programInfo.uniformLocations.color!, color);
- gl.uniformMatrix4fv(programInfo.uniformLocations.projectionMatrix, false, projectionMatrix);
- gl.uniformMatrix4fv(programInfo.uniformLocations.modelViewMatrix, false, modelViewMatrix);
-
- gl.drawElements(mode, indices.length, gl.UNSIGNED_INT, 0);
-}
-
-// Allocates and returns an ELEMENT_ARRAY_BUFFER filled with the Uint32 indices in `indices`.
-// On a WebGL1 canvas the `OES_element_index_uint` extension must be loaded.
-function initIndexBuffer(gl: WebGL2RenderingContext, indices: ArrayBuffer): IndexBuffer {
- const indexBuffer = gl.createBuffer()!;
- gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, indexBuffer);
- gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, indices, gl.STATIC_DRAW);
- return {
- indexBuffer: indexBuffer,
- // Values are 32 bit, i.e. 4 bytes per value
- length: indices.byteLength / 4,
- };
-}
-
-// Allocates and returns an ARRAY_BUFFER filled with the Float32 data in `data`.
-// This type of buffer is used for vertex coordinate data and color values.
-function initArrayBuffer(gl: WebGL2RenderingContext, data: ArrayBuffer) {
- const arrayBuffer = gl.createBuffer()!;
- gl.bindBuffer(gl.ARRAY_BUFFER, arrayBuffer);
- gl.bufferData(gl.ARRAY_BUFFER, data, gl.STATIC_DRAW);
- return arrayBuffer;
-}
-
-// Initialize a shader program, so WebGL knows how to draw our data
-function initShaderProgram(gl: WebGL2RenderingContext, vsSource: string, fsSource: string) {
- const vertexShader = loadShader(gl, gl.VERTEX_SHADER, vsSource)!;
- const fragmentShader = loadShader(gl, gl.FRAGMENT_SHADER, fsSource)!;
-
- const shaderProgram = gl.createProgram()!;
- gl.attachShader(shaderProgram, vertexShader);
- gl.attachShader(shaderProgram, fragmentShader);
- gl.linkProgram(shaderProgram);
-
- // See if creating the shader program was successful
- if (!gl.getProgramParameter(shaderProgram, gl.LINK_STATUS)) {
- throw new Error('Failed to create shader program');
- }
-
- return shaderProgram;
-}
-
-// creates a shader of the given type, uploads the source and compiles it.
-function loadShader(gl: WebGL2RenderingContext, type: GLenum, source: string) {
- const shader = gl.createShader(type)!;
- gl.shaderSource(shader, source);
- gl.compileShader(shader);
-
- // See if the shader compiled successfully
- if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
- alert('An error occurred compiling the shaders: ' + gl.getShaderInfoLog(shader));
- gl.deleteShader(shader);
- return null;
- }
-
- return shader;
-}
-
-// Takes coordinates in degrees and outputs [theta, phi]
-function coordinates2thetaphi(coordinate: Coordinate) {
- const phi = coordinate.latitude * (Math.PI / 180);
- const theta = coordinate.longitude * (Math.PI / 180);
- return [theta, phi];
-}
-
-// Returns a `Vector` between c1 and c2.
-// ratio=0.0 returns c1. ratio=1.0 returns c2.
-function lerpVector(c1: Vector, c2: Vector, ratio: number) {
- const x = lerp(c1.x, c2.x, ratio);
- const y = lerp(c1.y, c2.y, ratio);
- return new Vector(x, y);
-}
-
-// Performs linear interpolation between two floats, `x` and `y`.
-function lerp(x: number, y: number, ratio: number) {
- return x + (y - x) * ratio;
-}
-
-// The shortest coordinate change from c1 to c2.
-// Returns a vector representing the movement needed to go from c1 to c2 (as a `Vector`)
-// The input vectors are expected to be lat/long coordinates *in degrees*
-function shortestPath(c1: Vector, c2: Vector) {
- let longDiff = c2.y - c1.y;
- if (longDiff > 180) {
- longDiff -= 360;
- } else if (longDiff < -180) {
- longDiff += 360;
- }
- return new Vector(c2.x - c1.x, longDiff);
-}
-
-// smooths out a linear 0-1 transition into an accelerating and decelerating transition
-function smoothTransition(x: number) {
- return 0.5 - 0.5 * Math.cos(x * Math.PI);
-}
-
-// Returns vertex positions and color values for a circle.
-// `offset` is a vector of x, y and z values determining how much to offset the circle
-// position from origo
-function circleFanVertices(
- numEdges: number,
- radius: number,
- offset: [number, number, number],
- centerColor: ColorRgba,
- ringColor: ColorRgba,
-) {
- const positions = [...offset];
- const colors = [...centerColor];
- for (let i = 0; i <= numEdges; i++) {
- const angle = (i / numEdges) * 2 * Math.PI;
- const x = offset[0] + radius * Math.cos(angle);
- const y = offset[1] + radius * Math.sin(angle);
- const z = offset[2];
- positions.push(x, y, z);
- colors.push(...ringColor);
- }
- return { positions: positions, colors: colors };
-}
-
-// Good resources:
-// https://www.youtube.com/watch?v=aVwxzDHniEw - The Beauty of Bézier Curves
-// https://splines.readthedocs.io/en/latest/rotation/slerp.html - slerp - spherical lerp
diff --git a/gui/src/renderer/lib/account.ts b/gui/src/renderer/lib/account.ts
deleted file mode 100644
index e15097a7fd..0000000000
--- a/gui/src/renderer/lib/account.ts
+++ /dev/null
@@ -1,5 +0,0 @@
-export function formatAccountNumber(accountNumber: string) {
- const parts =
- accountNumber.replace(/\s+| /g, '').substring(0, 16).match(new RegExp('.{1,4}', 'g')) || [];
- return parts.join(' ');
-}
diff --git a/gui/src/renderer/lib/actionsHook.ts b/gui/src/renderer/lib/actionsHook.ts
deleted file mode 100644
index fc046d0a66..0000000000
--- a/gui/src/renderer/lib/actionsHook.ts
+++ /dev/null
@@ -1,12 +0,0 @@
-import { useMemo } from 'react';
-import { useDispatch } from 'react-redux';
-import { ActionCreatorsMapObject, bindActionCreators } from 'redux';
-
-export default function useActions<A, M extends ActionCreatorsMapObject<A>>(actionCreator: M) {
- const dispatch = useDispatch();
- const actions = useMemo(
- () => bindActionCreators(actionCreator, dispatch),
- [actionCreator, dispatch],
- );
- return actions;
-}
diff --git a/gui/src/renderer/lib/api-access-methods.ts b/gui/src/renderer/lib/api-access-methods.ts
deleted file mode 100644
index 90d406cc3d..0000000000
--- a/gui/src/renderer/lib/api-access-methods.ts
+++ /dev/null
@@ -1,93 +0,0 @@
-import { useCallback, useRef, useState } from 'react';
-
-import { CustomProxy } from '../../shared/daemon-rpc-types';
-import { useScheduler } from '../../shared/scheduler';
-import { useAppContext } from '../context';
-import { useBoolean } from './utility-hooks';
-
-export function useApiAccessMethodTest(
- autoReset = true,
- minDuration = 0,
-): [
- boolean,
- boolean | undefined,
- (method: CustomProxy | string) => Promise<boolean | void>,
- () => void,
-] {
- const { testApiAccessMethodById, testCustomApiAccessMethod } = useAppContext();
- const delayScheduler = useScheduler();
-
- // Whether or not the method is currently being tested.
- const [testing, setTesting, unsetTesting] = useBoolean();
- const [testResult, setTestResult] = useState<boolean>();
- // We keep the promise for the most recent test to compare it when we receive the results to know
- // if it's canceled or not.
- const lastTestPromise = useRef<Promise<boolean>>();
-
- // A few seconds after the test has finished the result should not be displayed anymore. This
- // scheduler is used to clear it.
- const testResultResetScheduler = useScheduler();
-
- const testApiAccessMethod = useCallback(
- async (method: CustomProxy | string) => {
- testResultResetScheduler.cancel();
- setTestResult(undefined);
-
- setTesting();
- let reachable;
- let testPromise;
-
- const submitTimestamp = Date.now();
- try {
- testPromise =
- typeof method === 'string'
- ? testApiAccessMethodById(method)
- : testCustomApiAccessMethod(method);
-
- lastTestPromise.current = testPromise;
- reachable = await testPromise;
- } catch {
- reachable = false;
- }
-
- // Make sure the loading text is displayed for at least `minDuration` milliseconds.
- const submitDuration = Date.now() - submitTimestamp;
- if (submitDuration < minDuration) {
- await new Promise<void>((resolve) =>
- delayScheduler.schedule(resolve, minDuration - submitDuration),
- );
- }
-
- if (testPromise !== lastTestPromise.current) {
- return;
- }
-
- setTestResult(reachable);
- unsetTesting();
-
- if (autoReset) {
- testResultResetScheduler.schedule(() => setTestResult(undefined), 5000);
- }
-
- return reachable;
- },
- [
- autoReset,
- delayScheduler,
- minDuration,
- setTesting,
- testApiAccessMethodById,
- testCustomApiAccessMethod,
- testResultResetScheduler,
- unsetTesting,
- ],
- );
-
- const resetTestResult = useCallback(() => {
- lastTestPromise.current = undefined;
- unsetTesting();
- setTestResult(undefined);
- }, [unsetTesting]);
-
- return [testing, testResult, testApiAccessMethod, resetTestResult];
-}
diff --git a/gui/src/renderer/lib/constraint-updater.ts b/gui/src/renderer/lib/constraint-updater.ts
deleted file mode 100644
index 6ea021ece9..0000000000
--- a/gui/src/renderer/lib/constraint-updater.ts
+++ /dev/null
@@ -1,158 +0,0 @@
-import { useCallback } from 'react';
-
-import {
- BridgeSettings,
- IBridgeConstraints,
- IOpenVpnConstraints,
- IRelaySettingsNormal,
- IWireguardConstraints,
- Ownership,
- wrapConstraint,
-} from '../../shared/daemon-rpc-types';
-import { useAppContext } from '../context';
-import {
- BridgeSettingsRedux,
- NormalBridgeSettingsRedux,
- NormalRelaySettingsRedux,
-} from '../redux/settings/reducers';
-import { useSelector } from '../redux/store';
-import { useNormalRelaySettings } from './relay-settings-hooks';
-
-export function wrapRelaySettingsOrDefault(
- relaySettings?: NormalRelaySettingsRedux,
-): IRelaySettingsNormal<IOpenVpnConstraints, IWireguardConstraints> {
- if (relaySettings) {
- const openvpnPort = wrapConstraint(relaySettings.openvpn.port);
- const openvpnProtocol = wrapConstraint(relaySettings.openvpn.protocol);
- const wgPort = wrapConstraint(relaySettings.wireguard.port);
- const wgIpVersion = wrapConstraint(relaySettings.wireguard.ipVersion);
- const wgEntryLocation = wrapConstraint(relaySettings.wireguard.entryLocation);
- const location = wrapConstraint(relaySettings.location);
- const tunnelProtocol = wrapConstraint(relaySettings.tunnelProtocol);
-
- return {
- providers: [...relaySettings.providers],
- ownership: relaySettings.ownership,
- tunnelProtocol,
- openvpnConstraints: {
- port: openvpnPort,
- protocol: openvpnProtocol,
- },
- wireguardConstraints: {
- port: wgPort,
- ipVersion: wgIpVersion,
- useMultihop: relaySettings.wireguard.useMultihop,
- entryLocation: wgEntryLocation,
- },
- location,
- };
- }
-
- return {
- location: 'any',
- tunnelProtocol: 'any',
- providers: [],
- ownership: Ownership.any,
- openvpnConstraints: {
- port: 'any',
- protocol: 'any',
- },
- wireguardConstraints: {
- port: 'any',
- ipVersion: 'any',
- useMultihop: false,
- entryLocation: 'any',
- },
- };
-}
-
-type RelaySettingsUpdateFunction = (
- settings: IRelaySettingsNormal<IOpenVpnConstraints, IWireguardConstraints>,
-) => IRelaySettingsNormal<IOpenVpnConstraints, IWireguardConstraints>;
-
-export function useRelaySettingsModifier() {
- const relaySettings = useNormalRelaySettings();
-
- return useCallback(
- (fn: RelaySettingsUpdateFunction) => {
- const settings = wrapRelaySettingsOrDefault(relaySettings);
- return fn(settings);
- },
- [relaySettings],
- );
-}
-
-export function useRelaySettingsUpdater() {
- const { setRelaySettings } = useAppContext();
- const modifyRelaySettings = useRelaySettingsModifier();
-
- return useCallback(
- async (fn: RelaySettingsUpdateFunction) => {
- const modifiedSettings = modifyRelaySettings(fn);
- await setRelaySettings({ normal: modifiedSettings });
- },
- [setRelaySettings, modifyRelaySettings],
- );
-}
-
-export function wrapBridgeSettingsOrDefault(bridgeSettings?: BridgeSettingsRedux): BridgeSettings {
- if (bridgeSettings) {
- return {
- type: bridgeSettings.type,
- normal: wrapNormalBridgeSettingsOrDefault(bridgeSettings.normal),
- custom: bridgeSettings.custom,
- };
- }
-
- return {
- type: 'normal',
- normal: wrapNormalBridgeSettingsOrDefault(),
- };
-}
-
-function wrapNormalBridgeSettingsOrDefault(
- bridgeSettings?: NormalBridgeSettingsRedux,
-): IBridgeConstraints {
- if (bridgeSettings) {
- const location = wrapConstraint(bridgeSettings.location);
-
- return {
- location,
- providers: [...bridgeSettings.providers],
- ownership: bridgeSettings.ownership,
- };
- }
-
- return {
- location: 'any',
- providers: [],
- ownership: Ownership.any,
- };
-}
-
-type BridgeSettingsUpdateFunction = (settings: BridgeSettings) => BridgeSettings;
-
-export function useBridgeSettingsModifier() {
- const bridgeSettings = useSelector((state) => state.settings.bridgeSettings);
-
- return useCallback(
- (fn: BridgeSettingsUpdateFunction) => {
- const settings = wrapBridgeSettingsOrDefault(bridgeSettings);
- return fn(settings);
- },
- [bridgeSettings],
- );
-}
-
-export function useBridgeSettingsUpdater() {
- const { updateBridgeSettings } = useAppContext();
- const modifyBridgeSettings = useBridgeSettingsModifier();
-
- return useCallback(
- async (fn: BridgeSettingsUpdateFunction) => {
- const modifiedSettings = modifyBridgeSettings(fn);
- await updateBridgeSettings(modifiedSettings);
- },
- [updateBridgeSettings, modifyBridgeSettings],
- );
-}
diff --git a/gui/src/renderer/lib/filter-locations.ts b/gui/src/renderer/lib/filter-locations.ts
deleted file mode 100644
index c3a0f7feb9..0000000000
--- a/gui/src/renderer/lib/filter-locations.ts
+++ /dev/null
@@ -1,216 +0,0 @@
-import {
- LiftedConstraint,
- Ownership,
- RelayEndpointType,
- RelayLocation,
- TunnelProtocol,
-} from '../../shared/daemon-rpc-types';
-import { relayLocations } from '../../shared/gettext';
-import {
- LocationType,
- RelayLocationCityWithVisibility,
- RelayLocationCountryWithVisibility,
- RelayLocationRelayWithVisibility,
- SpecialLocation,
-} from '../components/select-location/select-location-types';
-import {
- IRelayLocationCityRedux,
- IRelayLocationCountryRedux,
- IRelayLocationRelayRedux,
- NormalRelaySettingsRedux,
-} from '../redux/settings/reducers';
-
-export enum EndpointType {
- any,
- entry,
- exit,
-}
-
-export function filterLocationsByEndPointType(
- locations: IRelayLocationCountryRedux[],
- endpointType: EndpointType,
- tunnelProtocol: LiftedConstraint<TunnelProtocol>,
- relaySettings?: NormalRelaySettingsRedux,
-): IRelayLocationCountryRedux[] {
- return filterLocationsImpl(
- locations,
- getTunnelProtocolFilter(endpointType, tunnelProtocol, relaySettings),
- );
-}
-
-export function filterLocationsByDaita(
- locations: IRelayLocationCountryRedux[],
- daita: boolean,
- directOnly: boolean,
- locationType: LocationType,
- tunnelProtocol: LiftedConstraint<TunnelProtocol>,
- multihop: boolean,
-): IRelayLocationCountryRedux[] {
- return daitaFilterActive(daita, directOnly, locationType, tunnelProtocol, multihop)
- ? filterLocationsImpl(locations, (relay: IRelayLocationRelayRedux) => relay.daita)
- : locations;
-}
-
-export function daitaFilterActive(
- daita: boolean,
- directOnly: boolean,
- locationType: LocationType,
- tunnelProtocol: LiftedConstraint<TunnelProtocol>,
- multihop: boolean,
-) {
- const isEntry = multihop
- ? locationType === LocationType.entry
- : locationType === LocationType.exit;
- return daita && (directOnly || multihop) && isEntry && tunnelProtocol !== 'openvpn';
-}
-
-export function filterLocations(
- locations: IRelayLocationCountryRedux[],
- ownership?: Ownership,
- providers?: Array<string>,
-): IRelayLocationCountryRedux[] {
- const filters = [getOwnershipFilter(ownership), getProviderFilter(providers)];
-
- return filters.some((filter) => filter !== undefined)
- ? filterLocationsImpl(locations, (relay) => filters.every((filter) => filter?.(relay) ?? true))
- : locations;
-}
-
-function getTunnelProtocolFilter(
- endpointType: EndpointType,
- tunnelProtocol: LiftedConstraint<TunnelProtocol>,
- relaySettings?: NormalRelaySettingsRedux,
-): (relay: IRelayLocationRelayRedux) => boolean {
- const endpointTypes: Array<RelayEndpointType> = [];
- if (endpointType !== EndpointType.exit && tunnelProtocol === 'openvpn') {
- endpointTypes.push('bridge');
- } else if (tunnelProtocol === 'any') {
- endpointTypes.push('wireguard');
- if (!relaySettings?.wireguard.useMultihop) {
- endpointTypes.push('openvpn');
- }
- } else {
- endpointTypes.push(tunnelProtocol);
- }
-
- return (relay) => endpointTypes.includes(relay.endpointType);
-}
-
-function getOwnershipFilter(
- ownership?: Ownership,
-): ((relay: IRelayLocationRelayRedux) => boolean) | undefined {
- if (ownership === undefined || ownership === Ownership.any) {
- return undefined;
- }
-
- const expectOwned = ownership === Ownership.mullvadOwned;
- return (relay) => relay.owned === expectOwned;
-}
-
-function getProviderFilter(
- providers?: string[],
-): ((relay: IRelayLocationRelayRedux) => boolean) | undefined {
- return providers === undefined || providers.length === 0
- ? undefined
- : (relay) => providers.includes(relay.provider);
-}
-
-function filterLocationsImpl(
- locations: Array<IRelayLocationCountryRedux>,
- filter: (relay: IRelayLocationRelayRedux) => boolean,
-): Array<IRelayLocationCountryRedux> {
- return locations
- .map((country) => ({
- ...country,
- cities: country.cities
- .map((city) => ({ ...city, relays: city.relays.filter(filter) }))
- .filter((city) => city.relays.length > 0),
- }))
- .filter((country) => country.cities.length > 0);
-}
-
-export function searchForLocations(
- countries: Array<IRelayLocationCountryRedux>,
- searchTerm: string,
-): Array<RelayLocationCountryWithVisibility> {
- return countries.map((country) => {
- const match =
- searchTerm === '' ||
- searchMatch(searchTerm, country.code) ||
- searchMatch(searchTerm, relayLocations.gettext(country.name));
- const cities = searchCities(country.cities, searchTerm, match);
- const expanded = cities.some((city) => city.visible);
- return { ...country, cities: cities, visible: expanded || match };
- });
-}
-
-function searchCities(
- cities: Array<IRelayLocationCityRedux>,
- searchTerm: string,
- countryMatch: boolean,
-): Array<RelayLocationCityWithVisibility> {
- return cities.map((city) => {
- const match =
- searchTerm === '' ||
- countryMatch ||
- searchMatch(searchTerm, city.code) ||
- searchMatch(searchTerm, relayLocations.gettext(city.name));
- const relays = searchRelays(city.relays, searchTerm, match);
- const expanded = match || relays.some((relay) => relay.visible);
- return { ...city, relays: relays, visible: expanded };
- });
-}
-
-function searchRelays(
- relays: Array<IRelayLocationRelayRedux>,
- searchTerm: string,
- cityMatch: boolean,
-): Array<RelayLocationRelayWithVisibility> {
- return relays.map((relay) => ({
- ...relay,
- visible: searchTerm === '' || cityMatch || searchMatch(searchTerm, relay.hostname),
- }));
-}
-
-export function getLocationsExpandedBySearch(
- countries: Array<IRelayLocationCountryRedux>,
- searchTerm: string,
-): Array<RelayLocation> {
- return countries.reduce((locations, country) => {
- const cityLocations = getCityLocationsExpandecBySearch(
- country.cities,
- country.code,
- searchTerm,
- );
- const cityMatches = country.cities.some(
- (city) => searchMatch(searchTerm, city.code) || searchMatch(searchTerm, city.name),
- );
- const location = { country: country.code };
- const expanded = cityMatches || cityLocations.length > 0;
- return expanded ? [...locations, ...cityLocations, location] : locations;
- }, [] as Array<RelayLocation>);
-}
-
-function getCityLocationsExpandecBySearch(
- cities: Array<IRelayLocationCityRedux>,
- countryCode: string,
- searchTerm: string,
-): Array<RelayLocation> {
- return cities.reduce((locations, city) => {
- const expanded =
- city.relays.filter((relay) => searchMatch(searchTerm, relay.hostname)).length > 0;
- const location: RelayLocation = { country: countryCode, city: city.code };
- return expanded ? [...locations, location] : locations;
- }, [] as Array<RelayLocation>);
-}
-
-export function searchMatch(searchTerm: string, value: string): boolean {
- return value.toLowerCase().includes(searchTerm.toLowerCase());
-}
-
-export function filterSpecialLocations<T>(
- searchTerm: string,
- locations: Array<SpecialLocation<T>>,
-): Array<SpecialLocation<T>> {
- return locations.filter((location) => searchMatch(searchTerm, location.label));
-}
diff --git a/gui/src/renderer/lib/history.tsx b/gui/src/renderer/lib/history.tsx
deleted file mode 100644
index 6d92a0e88c..0000000000
--- a/gui/src/renderer/lib/history.tsx
+++ /dev/null
@@ -1,264 +0,0 @@
-import { Action, History as OriginalHistory, Location, LocationDescriptorObject } from 'history';
-import { useHistory as useReactRouterHistory } from 'react-router';
-
-import { IHistoryObject, LocationState } from '../../shared/ipc-types';
-import { GeneratedRoutePath } from './routeHelpers';
-import { RoutePath } from './routes';
-
-export interface ITransitionSpecification {
- name: string;
- duration: number;
-}
-
-interface ITransitionMap {
- [name: string]: ITransitionSpecification;
-}
-
-/**
- * Transition descriptors
- */
-export const transitions: ITransitionMap = {
- show: {
- name: 'slide-up',
- duration: 450,
- },
- dismiss: {
- name: 'slide-down',
- duration: 450,
- },
- push: {
- name: 'push',
- duration: 450,
- },
- pop: {
- name: 'pop',
- duration: 450,
- },
- none: {
- name: '',
- duration: 0,
- },
-};
-
-const transitionOpposites: Record<string, string> = {
- 'slide-up': 'slide-down',
- 'slide-down': 'slide-up',
- push: 'pop',
- pop: 'push',
- '': '',
-};
-
-function oppositeTransition(transition: ITransitionSpecification): ITransitionSpecification {
- return {
- ...transition,
- name: transitionOpposites[transition.name],
- };
-}
-
-type LocationDescriptor = RoutePath | GeneratedRoutePath | LocationDescriptorObject<LocationState>;
-
-type LocationListener = (
- location: Location<LocationState>,
- action: Action,
- transition: ITransitionSpecification,
-) => void;
-
-export default class History {
- private listeners: LocationListener[] = [];
- private entries: Location<LocationState>[];
- private index = 0;
- private lastAction: Action = 'POP';
-
- public constructor(location: LocationDescriptor, state?: LocationState) {
- this.entries = [this.createLocation(location, state)];
- }
-
- public static fromSavedHistory(savedHistory: IHistoryObject): History {
- const history = new History(RoutePath.launch);
- history.entries = savedHistory.entries;
- history.index = savedHistory.index;
- history.lastAction = savedHistory.lastAction;
-
- return history;
- }
-
- public recordScrollPosition(position: [number, number]) {
- this.location.state.scrollPosition = position;
- }
-
- public recordSectionExpandedState(id: string, expanded: boolean) {
- this.location.state.expandedSections[id] = expanded;
- }
-
- public get location(): Location<LocationState> {
- return this.entries[this.index];
- }
-
- public get length(): number {
- return this.entries.length;
- }
-
- public get action(): Action {
- return this.lastAction;
- }
-
- public push = (nextLocation: LocationDescriptor, nextState?: Partial<LocationState>) => {
- const state = { transition: transitions.push, ...nextState };
- this.pushImpl(nextLocation, state);
- this.notify(state.transition);
- };
-
- public pop = (all?: boolean) => {
- const transition = this.popImpl(all === true ? this.index : 1);
- if (transition !== undefined) {
- this.notify(transition);
- }
- };
-
- public reset = (nextLocation: LocationDescriptor, nextState?: Partial<LocationState>) => {
- const location = this.createLocation(nextLocation, nextState);
- this.lastAction = 'REPLACE';
- this.index = 0;
- this.entries = [location];
-
- this.notify(nextState?.transition ?? transitions.none);
- };
-
- public replaceRoot = (
- replacementLocation: LocationDescriptor,
- replacementState?: Partial<LocationState>,
- ) => {
- const location = this.createLocation(replacementLocation, replacementState);
- this.lastAction = 'REPLACE';
- this.entries.splice(0, 1, location);
-
- if (this.index === 0) {
- this.notify(replacementState?.transition ?? transitions.none);
- }
- };
-
- public listen(callback: LocationListener) {
- this.listeners.push(callback);
- return () => (this.listeners = this.listeners.filter((listener) => listener !== callback));
- }
-
- public canGo(n: number) {
- const nextIndex = this.index + n;
- return nextIndex >= 0 && nextIndex < this.entries.length;
- }
-
- public getPopTransition(steps = 1) {
- // The back transition should be based on the last view to be popped, i.e. the one with the
- // lowest index.
- const transition = this.entries[this.index - steps + 1].state.transition;
- return oppositeTransition(transition);
- }
-
- // This returns this object casted as History from the History module. The difference between this
- // one and the one in the history module is that this one has stricter types for the paths.
- // Instead of accepting any string it's limited to the paths we actually support. But this history
- // implementation would handle any string as expected.
- public get asHistory(): OriginalHistory {
- return this as OriginalHistory;
- }
-
- public get asObject(): IHistoryObject {
- return {
- entries: this.entries,
- index: this.index,
- lastAction: this.lastAction,
- };
- }
-
- public block(): never {
- throw Error('Not implemented');
- }
- public replace(): never {
- throw Error('Not implemented');
- }
- public go(): never {
- throw Error('Not implemented');
- }
- public goBack(): never {
- throw Error('Not implemented');
- }
- public goForward(): never {
- throw Error('Not implemented');
- }
- public createHref(): never {
- throw Error('Not implemented');
- }
-
- private pushImpl(nextLocation: LocationDescriptor, nextState?: Partial<LocationState>) {
- const location = this.createLocation(nextLocation, nextState);
- this.lastAction = 'PUSH';
- this.index += 1;
- this.entries.splice(this.index, this.entries.length - this.index, location);
- }
-
- private popImpl(n = 1): ITransitionSpecification | undefined {
- if (this.canGo(-n)) {
- const transition = this.getPopTransition(n);
-
- this.lastAction = 'POP';
- this.index -= n;
- this.entries = this.entries.slice(0, this.index + 1);
-
- return transition;
- } else {
- return undefined;
- }
- }
-
- private notify(transition: ITransitionSpecification) {
- this.listeners.forEach((listener) => listener(this.location, this.action, transition));
- }
-
- private createLocation(
- location: LocationDescriptor,
- state?: Partial<LocationState>,
- ): Location<LocationState> {
- if (typeof location === 'string') {
- return this.createLocationFromString(location, state);
- } else if ('routePath' in location) {
- return this.createLocationFromString(location.routePath, state);
- } else {
- return {
- pathname: location.pathname ?? this.location.pathname,
- search: location.search ?? '',
- hash: location.hash ?? '',
- state: this.createState(state),
- key: location.key ?? this.getRandomKey(),
- };
- }
- }
-
- private createLocationFromString(
- path: string,
- state?: Partial<LocationState>,
- ): Location<LocationState> {
- return {
- pathname: path,
- search: '',
- hash: '',
- state: this.createState(state),
- key: this.getRandomKey(),
- };
- }
-
- private createState(state?: Partial<LocationState>): LocationState {
- return {
- scrollPosition: state?.scrollPosition ?? [0, 0],
- expandedSections: state?.expandedSections ?? {},
- transition: state?.transition ?? transitions.none,
- };
- }
-
- private getRandomKey() {
- return Math.random().toString(36).substr(8);
- }
-}
-
-export function useHistory(): History {
- return useReactRouterHistory<LocationState>() as History;
-}
diff --git a/gui/src/renderer/lib/html-formatter.tsx b/gui/src/renderer/lib/html-formatter.tsx
deleted file mode 100644
index 452b7fb1e8..0000000000
--- a/gui/src/renderer/lib/html-formatter.tsx
+++ /dev/null
@@ -1,18 +0,0 @@
-import React from 'react';
-import styled from 'styled-components';
-
-const boldSyntax = /(<b>.*?<\/b>)/g;
-const Bold = styled.span({ fontWeight: 700 });
-
-export function formatHtml(inputString: string): React.ReactElement {
- const formattedString = inputString.split(boldSyntax).map((value, index) => {
- if (boldSyntax.test(value)) {
- const valueWithoutTags = value.replaceAll(/<b>|<\/b>/g, '');
- return <Bold key={index}>{valueWithoutTags}</Bold>;
- } else {
- return <React.Fragment key={index}>{value}</React.Fragment>;
- }
- });
-
- return <>{formattedString}</>;
-}
diff --git a/gui/src/renderer/lib/ip.ts b/gui/src/renderer/lib/ip.ts
deleted file mode 100644
index db458aa256..0000000000
--- a/gui/src/renderer/lib/ip.ts
+++ /dev/null
@@ -1,281 +0,0 @@
-// Number of groups for each IP format
-type IPv4Octets = [number, number, number, number];
-type IPv6Groups = [number, number, number, number, number, number, number, number];
-
-// Number of bits in each group for each IP format
-const IPv4OctetSize = 8;
-const IPv6GroupSize = 16;
-
-// Abstract class representing an IP address
-export abstract class IpAddress<G extends number[]> {
- public constructor(public readonly groups: G) {}
-
- public abstract isLocal(): boolean;
-
- public static fromString(ip: string): IPv4Address | IPv6Address {
- try {
- return IPv4Address.fromString(ip);
- } catch {
- return IPv6Address.fromString(ip);
- }
- }
-}
-
-// Abstract class representing an IP range or subnet
-export abstract class IpRange<G extends number[]> {
- public constructor(
- public readonly groups: G,
- public readonly prefixSize: number,
- ) {}
-
- // Returns whether or not this subnet includes the provided IP
- protected includes<T extends IpAddress<G>>(ip: T, groupSize: number): boolean {
- return IpRange.match(groupSize, ip.groups, [this.groups, this.prefixSize]);
- }
-
- // Matches each group of the ip/subnet from left to right to determine if they match
- private static match(
- groupSize: number,
- [ipGroup, ...ipGroups]: number[],
- [[subnetGroup, ...subnetGroups], prefixSize]: [number[], number],
- ): boolean {
- if (prefixSize >= groupSize) {
- // If the current group is part of the prefix the only needed check is if they are equal
- return (
- ipGroup === subnetGroup &&
- IPv4Range.match(groupSize, ipGroups, [subnetGroups, prefixSize - groupSize])
- );
- } else {
- // If the group (or parts of the group) isn't part of the prefix the non-prefix part needs to
- // be compared
- // variableBits contains the maximum value that the non-prefix bits can have
- const variableBits = getBitsMax(groupSize - prefixSize);
- // Calculate smallest IP in the subnet
- const subnetMin = subnetGroup & (getBitsMax(groupSize) - variableBits);
- // Calculate greatest IP in the subnet
- const subnetMax = subnetGroup | variableBits;
- // Check if the provided ip is between subnetMin/-Max
- return ipGroup >= subnetMin && ipGroup <= subnetMax;
- }
- }
-}
-
-export class IPv4Address extends IpAddress<IPv4Octets> {
- public constructor(octets: IPv4Octets) {
- super(octets);
-
- // Ensure that each octets is the correct number of bits
- if (octets.some((octets) => !isNumberOfBits(octets, IPv4OctetSize))) {
- throw new Error(`Invalid ip: ${octets.join('.')}`);
- }
- }
-
- public isLocal(): boolean {
- const localSubnets = [...IPV4_LAN_SUBNETS, IPV4_LOOPBACK_SUBNET];
- return localSubnets.some((subnet) => subnet.includes(this));
- }
-
- // Parses an ip address from a string of the quad-dotted format, e.g. 127.0.0.1
- public static fromString(ip: string): IPv4Address {
- try {
- const octets = IPv4Address.octetsFromString(ip);
- return new IPv4Address(octets);
- } catch {
- throw new Error(`Invalid ip: ${ip}`);
- }
- }
-
- public static octetsFromString(ip: string): IPv4Octets {
- try {
- const octets = ip.split('.');
- if (octets.every((octet) => /^\d{1,3}$/.test(octet))) {
- const parsedOctets = octets.map((octet) => parseInt(octet, 10));
- if (IPv4Address.isIPv4Octets(parsedOctets)) {
- return parsedOctets;
- }
- }
- } catch {
- // no-op
- }
-
- throw new Error(`Invalid ip: ${ip}`);
- }
-
- public static isValid(ip: string): boolean {
- try {
- IPv4Address.fromString(ip);
- return true;
- } catch {
- return false;
- }
- }
-
- // Makes sure that the number of octets is correct and values where parsed correctly
- private static isIPv4Octets(octets: number[]): octets is IPv4Octets {
- return octets.length === 4 && octets.every((octet) => !isNaN(octet));
- }
-}
-
-export class IPv4Range extends IpRange<IPv4Octets> {
- public constructor(octets: IPv4Octets, prefixSize: number) {
- super(octets, prefixSize);
-
- // Makes sure that the prefix is within the correct range
- if (prefixSize < 0 || prefixSize > 32) {
- throw new Error(`Invalid ip: ${octets.join('.')}/${prefixSize}`);
- }
- }
-
- public static fromString(subnet: string): IPv4Range {
- try {
- // In addition to parsing the ip the subnet-mask also needs to be parsed
- const parts = subnet.split('/');
- if (/^\d{1,2}$/.test(parts[1])) {
- const octets = IPv4Address.octetsFromString(parts[0]);
- const prefixSize = parseInt(parts[1]);
- return new IPv4Range(octets, prefixSize);
- }
- } catch {
- // no-op
- }
-
- throw new Error(`Invalid ip: ${subnet}`);
- }
-
- public includes(ip: IPv4Address): boolean {
- return super.includes(ip, IPv4OctetSize);
- }
-}
-
-export class IPv6Address extends IpAddress<IPv6Groups> {
- public constructor(groups: IPv6Groups) {
- super(groups);
-
- // Ensure that each group is the correct number of bits
- if (groups.some((group) => !isNumberOfBits(group, 16))) {
- throw new Error(`Invalid ip: ${groups.join(':')}`);
- }
- }
-
- public isLocal(): boolean {
- const localSubnets = [...IPV6_LAN_SUBNETS, IPV6_LOOPBACK_SUBNET];
- return localSubnets.some((subnet) => subnet.includes(this));
- }
-
- // Parses IPv6 addresses where the groups are separated by ':' and supports shortened addresses.
- public static fromString(ip: string): IPv6Address {
- try {
- const groups = IPv6Address.groupsFromString(ip);
- return new IPv6Address(groups);
- } catch {
- throw new Error(`Invalid ip: ${ip}`);
- }
- }
-
- public static groupsFromString(ip: string): IPv6Groups {
- try {
- // Split on shortening separator and make sure there's only one separator
- const shortened = ip.split('::');
- if (shortened.length <= 2) {
- // Split each part of the shortened address into groups and remove any empty groups, such as
- // the one before the separator in ::1
- const parts = shortened.map((groups) => groups.split(':').filter((group) => group !== ''));
-
- let groups: string[];
- if (parts.length === 2) {
- // If the address contained the shortening separator the parts are concatenated with empty
- // groups in between
- const shortened = Array(8 - parts[0].length - parts[1].length).fill(0x0);
- groups = [...parts[0], ...shortened, ...parts[1]];
- } else {
- // If it wasn't shortened all groups are used as is
- groups = parts.flat();
- }
-
- if (groups.every((group) => /^[0-9a-fA-F]{1,4}$/.test(group))) {
- const parsedGroups = groups.map((group) => parseInt(group, 16));
-
- if (IPv6Address.isIPv6Groups(parsedGroups)) {
- return parsedGroups;
- }
- }
- }
- } catch {
- // no-op
- }
-
- throw new Error(`Invalid ip: ${ip}`);
- }
-
- public static isValid(ip: string): boolean {
- try {
- IPv6Address.fromString(ip);
- return true;
- } catch {
- return false;
- }
- }
-
- // Makes sure that the number of groups is correct and values where parsed correctly
- private static isIPv6Groups(groups: number[]): groups is IPv6Groups {
- return groups.length === 8 && groups.every((group) => !isNaN(group));
- }
-}
-
-export class IPv6Range extends IpRange<IPv6Groups> {
- public constructor(groups: IPv6Groups, prefixSize: number) {
- super(groups, prefixSize);
-
- // Makes sure that the prefix is within the correct range
- if (prefixSize < 0 || prefixSize > 128) {
- throw new Error(`Invalid subnet: ${groups.join(':')}/${prefixSize}`);
- }
- }
-
- public static fromString(subnet: string): IPv6Range {
- try {
- // In addition to parsing the ip the subnet-mask also needs to be parsed
- const parts = subnet.split('/');
- if (/^\d{1,3}$/.test(parts[1])) {
- const groups = IPv6Address.groupsFromString(parts[0]);
- const prefixSize = parseInt(parts[1], 10);
- return new IPv6Range(groups, prefixSize);
- }
- } catch {
- // no-op
- }
-
- throw new Error(`Invalid subnet: ${subnet}`);
- }
-
- public includes(ip: IPv6Address): boolean {
- return super.includes(ip, IPv6GroupSize);
- }
-}
-
-// Returns the maximum value possible with the provided size
-function getBitsMax(bits: number): number {
- return Math.pow(2, bits) - 1;
-}
-
-// Returns whether or not a number is possible to represent as an unsigned in of the provided size
-function isNumberOfBits(value: number, bits: number): boolean {
- return value >= 0 && value < Math.pow(2, bits);
-}
-
-// IPv4 addresses reserved for local networks
-const IPV4_LAN_SUBNETS = [
- new IPv4Range([10, 0, 0, 0], 8),
- new IPv4Range([172, 16, 0, 0], 12),
- new IPv4Range([192, 168, 0, 0], 16),
- new IPv4Range([169, 254, 0, 0], 16),
-];
-
-// IPv6 addresses reserved for local networks
-const IPV6_LAN_SUBNETS = [
- new IPv6Range([0xfe80, 0, 0, 0, 0, 0, 0, 0], 10),
- new IPv6Range([0xfc00, 0, 0, 0, 0, 0, 0, 0], 7),
-];
-
-const IPV4_LOOPBACK_SUBNET = new IPv4Range([127, 0, 0, 0], 8);
-const IPV6_LOOPBACK_SUBNET = new IPv6Range([0, 0, 0, 0, 0, 0, 0, 1], 128);
diff --git a/gui/src/renderer/lib/ipc-event-channel.ts b/gui/src/renderer/lib/ipc-event-channel.ts
deleted file mode 100644
index 03b5471cb7..0000000000
--- a/gui/src/renderer/lib/ipc-event-channel.ts
+++ /dev/null
@@ -1,6 +0,0 @@
-import { ipcRenderer } from 'electron';
-
-import { createIpcRenderer } from '../../shared/ipc-helpers';
-import { ipcSchema } from '../../shared/ipc-schema';
-
-export const IpcRendererEventChannel = createIpcRenderer(ipcSchema, ipcRenderer);
diff --git a/gui/src/renderer/lib/load-translations.ts b/gui/src/renderer/lib/load-translations.ts
deleted file mode 100644
index d418f54b4f..0000000000
--- a/gui/src/renderer/lib/load-translations.ts
+++ /dev/null
@@ -1,23 +0,0 @@
-import { GetTextTranslations } from 'gettext-parser';
-import Gettext from 'node-gettext';
-
-import log from '../../shared/logging';
-
-const SOURCE_LANGUAGE = 'en';
-
-export function loadTranslations(
- catalogue: Gettext,
- locale: string,
- translations?: GetTextTranslations,
-) {
- if (translations) {
- catalogue.addTranslations(locale, catalogue.domain, translations);
- catalogue.setLocale(locale);
- log.info(`Loaded translations ${locale}/${catalogue.domain}`);
- } else {
- // Reset the locale to source language if we couldn't load the catalogue for the requested locale
- // Add empty translations to suppress some of the warnings produces by node-gettext
- catalogue.addTranslations(SOURCE_LANGUAGE, catalogue.domain, {});
- catalogue.setLocale(SOURCE_LANGUAGE);
- }
-}
diff --git a/gui/src/renderer/lib/logging.ts b/gui/src/renderer/lib/logging.ts
deleted file mode 100644
index 7c8dc9e542..0000000000
--- a/gui/src/renderer/lib/logging.ts
+++ /dev/null
@@ -1,9 +0,0 @@
-import { ILogOutput, LogLevel } from '../../shared/logging-types';
-
-export default class IpcOutput implements ILogOutput {
- constructor(public level: LogLevel) {}
-
- public write(level: LogLevel, message: string) {
- window.ipc.logging.log({ level: level, message });
- }
-}
diff --git a/gui/src/renderer/lib/relay-settings-hooks.ts b/gui/src/renderer/lib/relay-settings-hooks.ts
deleted file mode 100644
index 14fe99849d..0000000000
--- a/gui/src/renderer/lib/relay-settings-hooks.ts
+++ /dev/null
@@ -1,25 +0,0 @@
-import { LiftedConstraint, TunnelProtocol } from '../../shared/daemon-rpc-types';
-import { useSelector } from '../redux/store';
-
-export function useNormalRelaySettings() {
- const relaySettings = useSelector((state) => state.settings.relaySettings);
- return 'normal' in relaySettings ? relaySettings.normal : undefined;
-}
-
-// Some features are considered core privacy features and when enabled prevent OpenVPN from being
-// used. This hook returns the tunnelprotocol with the exception that it always returns WireGuard
-// when any of those features are enabled.
-export function useTunnelProtocol(): LiftedConstraint<TunnelProtocol> {
- const relaySettings = useNormalRelaySettings();
- const multihop = relaySettings?.wireguard.useMultihop ?? false;
- const daita = useSelector((state) => state.settings.wireguard.daita?.enabled ?? false);
- const quantumResistant = useSelector((state) => state.settings.wireguard.quantumResistant);
- const openVpnDisabled = daita || multihop || quantumResistant;
-
- return openVpnDisabled ? 'wireguard' : (relaySettings?.tunnelProtocol ?? 'any');
-}
-
-export function useNormalBridgeSettings() {
- const bridgeSettings = useSelector((state) => state.settings.bridgeSettings);
- return bridgeSettings.normal;
-}
diff --git a/gui/src/renderer/lib/routeHelpers.ts b/gui/src/renderer/lib/routeHelpers.ts
deleted file mode 100644
index 50c5867768..0000000000
--- a/gui/src/renderer/lib/routeHelpers.ts
+++ /dev/null
@@ -1,24 +0,0 @@
-import { generatePath } from 'react-router';
-
-import { RoutePath } from './routes';
-
-export type GeneratedRoutePath = { routePath: string };
-
-export const disableDismissForRoutes = [
- RoutePath.launch,
- RoutePath.login,
- RoutePath.tooManyDevices,
- RoutePath.deviceRevoked,
- RoutePath.main,
- RoutePath.redeemVoucher,
- RoutePath.voucherSuccess,
- RoutePath.timeAdded,
- RoutePath.setupFinished,
-];
-
-export function generateRoutePath(
- routePath: RoutePath,
- parameters: Parameters<typeof generatePath>[1],
-): GeneratedRoutePath {
- return { routePath: generatePath(routePath, parameters) };
-}
diff --git a/gui/src/renderer/lib/routes.ts b/gui/src/renderer/lib/routes.ts
deleted file mode 100644
index 89b50c1fb0..0000000000
--- a/gui/src/renderer/lib/routes.ts
+++ /dev/null
@@ -1,34 +0,0 @@
-export enum RoutePath {
- launch = '/',
- login = '/login',
- tooManyDevices = '/login/too-many-devices',
- deviceRevoked = '/login/device-revoked',
- main = '/main',
- redeemVoucher = '/main/voucher/redeem',
- voucherSuccess = '/main/voucher/success/:newExpiry/:secondsAdded',
- expired = '/main/expired',
- timeAdded = '/main/time-added',
- setupFinished = '/main/setup-finished',
- settings = '/settings',
- selectLanguage = '/settings/language',
- account = '/account',
- userInterfaceSettings = '/settings/interface',
- multihopSettings = '/settings/multihop',
- vpnSettings = '/settings/vpn',
- wireguardSettings = '/settings/advanced/wireguard',
- daitaSettings = '/settings/daita',
- udpOverTcp = '/settings/advanced/wireguard/udp-over-tcp',
- shadowsocks = '/settings/advanced/shadowsocks',
- openVpnSettings = '/settings/advanced/openvpn',
- splitTunneling = '/settings/split-tunneling',
- apiAccessMethods = '/settings/api-access-methods',
- settingsImport = '/settings/settings-import',
- settingsTextImport = '/settings/settings-import/text-import',
- editApiAccessMethods = '/settings/api-access-methods/edit/:id?',
- support = '/settings/support',
- problemReport = '/settings/support/problem-report',
- debug = '/settings/debug',
- selectLocation = '/select-location',
- editCustomBridge = '/select-location/edit-custom-bridge',
- filter = '/select-location/filter',
-}
diff --git a/gui/src/renderer/lib/styles.ts b/gui/src/renderer/lib/styles.ts
deleted file mode 100644
index 554e669373..0000000000
--- a/gui/src/renderer/lib/styles.ts
+++ /dev/null
@@ -1,9 +0,0 @@
-type NonTransientPropKey<K> = K extends `$${infer L}` ? L : K;
-
-export type NonTransientProps<T, K extends NonTransientPropKey<keyof T>> = {
- [P in keyof T as NonTransientPropKey<P> extends K ? NonTransientPropKey<P> : P]: T[P];
-};
-
-export type TransientProps<T, K extends keyof T> = {
- [P in keyof T as P extends K ? `$${P & string}` : P]: T[P];
-};
diff --git a/gui/src/renderer/lib/utility-hooks.ts b/gui/src/renderer/lib/utility-hooks.ts
deleted file mode 100644
index 1efc49c804..0000000000
--- a/gui/src/renderer/lib/utility-hooks.ts
+++ /dev/null
@@ -1,76 +0,0 @@
-import React, { useCallback, useEffect, useInsertionEffect, useRef, useState } from 'react';
-
-export function useMounted() {
- const mountedRef = useRef(false);
- const isMounted = useCallback(() => mountedRef.current, []);
-
- useEffect(() => {
- mountedRef.current = true;
- return () => {
- mountedRef.current = false;
- };
- }, []);
-
- return isMounted;
-}
-
-export function useStyledRef<T>(): React.MutableRefObject<T> {
- return useRef() as React.MutableRefObject<T>;
-}
-
-export function useCombinedRefs<T>(...refs: (React.Ref<T> | undefined)[]): React.RefCallback<T> {
- return useRefCallback((element: T | null) => refs.forEach((ref) => assignToRef(element, ref)));
-}
-
-export function assignToRef<T>(element: T | null, ref?: React.Ref<T>) {
- if (typeof ref === 'function') {
- ref(element);
- } else if (ref && element) {
- (ref as React.MutableRefObject<T>).current = element;
- }
-}
-
-export function useBoolean(initialValue = false) {
- const [value, setValue] = useState(initialValue);
-
- const setTrue = useCallback(() => setValue(true), []);
- const setFalse = useCallback(() => setValue(false), []);
- const toggle = useCallback(() => setValue((value) => !value), []);
-
- return [value, setTrue, setFalse, toggle] as const;
-}
-
-// This hook returns a function that can be used to force a rerender of a component, and
-// additionally also returns a variable that can be used to trigger effects as a result. This is a
-// hack and should be avoided unless there are no better ways.
-export function useRerenderer(): [() => void, number] {
- const [count, setCount] = useState(0);
- const rerender = useCallback(() => setCount((count) => count + 1), []);
- return [rerender, count];
-}
-
-type Fn<T extends unknown[], R> = (...args: T) => R;
-
-export function useEffectEvent<Args extends unknown[]>(
- fn: Fn<Args, void | undefined | Promise<void | undefined>>,
-): Fn<Args, void> {
- const ref = useRef<Fn<Args, void>>(fn);
-
- useInsertionEffect(() => {
- ref.current = fn;
- }, [fn]);
-
- return useCallback((...args: Args) => ref.current(...args), []);
-}
-
-// Alias for useEffectEvent, but with another name since the effect event is named after a very
-// specific usecase.
-export const useRefCallback = useEffectEvent;
-
-export function useLastDefinedValue<T>(value: T): T {
- const [definedValue, setDefinedValue] = useState(value);
-
- useEffect(() => setDefinedValue((prev) => value ?? prev), [value]);
-
- return value ?? definedValue;
-}
diff --git a/gui/src/renderer/lib/will-exit.tsx b/gui/src/renderer/lib/will-exit.tsx
deleted file mode 100644
index 67ce4c5549..0000000000
--- a/gui/src/renderer/lib/will-exit.tsx
+++ /dev/null
@@ -1,13 +0,0 @@
-import React, { useContext } from 'react';
-
-// This context tells its subtree if it should stop rendering or not. This is useful during
-// transitions, e.g. on log out, since data might be updated which makes the disappearing view
-// update a lot during the transition. There's currently no support for unpausing, which can be
-// added later if needed.
-const willExitContext = React.createContext<boolean>(false);
-
-export const WillExit = willExitContext.Provider;
-
-export function useWillExit() {
- return useContext(willExitContext);
-}
diff --git a/gui/src/renderer/preload.ts b/gui/src/renderer/preload.ts
deleted file mode 100644
index 864d1dc4d0..0000000000
--- a/gui/src/renderer/preload.ts
+++ /dev/null
@@ -1,15 +0,0 @@
-import { contextBridge } from 'electron';
-
-import { IpcRendererEventChannel } from './lib/ipc-event-channel';
-
-contextBridge.exposeInMainWorld('ipc', IpcRendererEventChannel);
-
-contextBridge.exposeInMainWorld('env', {
- e2e: process.env.CI,
- development: process.env.NODE_ENV === 'development',
- platform: process.platform,
-});
-
-if (process.env.CI) {
- contextBridge.exposeInMainWorld('__REACT_DEVTOOLS_GLOBAL_HOOK__', { isDisabled: true });
-}
diff --git a/gui/src/renderer/redux/account/actions.ts b/gui/src/renderer/redux/account/actions.ts
deleted file mode 100644
index 98a689cd76..0000000000
--- a/gui/src/renderer/redux/account/actions.ts
+++ /dev/null
@@ -1,225 +0,0 @@
-import { hasExpired } from '../../../shared/account-expiry';
-import { AccountDataError, AccountNumber, IDevice } from '../../../shared/daemon-rpc-types';
-
-interface IStartLoginAction {
- type: 'START_LOGIN';
- accountNumber: AccountNumber;
-}
-
-interface ILoggedInAction {
- type: 'LOGGED_IN';
- accountNumber: AccountNumber;
- deviceName?: string;
-}
-
-interface ILoginFailedAction {
- type: 'LOGIN_FAILED';
- error: AccountDataError['error'];
-}
-
-interface ILoginTooManyDevicesAction {
- type: 'TOO_MANY_DEVICES';
-}
-
-interface ILoggedOutAction {
- type: 'LOGGED_OUT';
-}
-
-interface IResetLoginErrorAction {
- type: 'RESET_LOGIN_ERROR';
-}
-
-interface IDeviceRevokedAction {
- type: 'DEVICE_REVOKED';
-}
-
-interface IStartCreateAccount {
- type: 'START_CREATE_ACCOUNT';
-}
-
-interface ICreateAccountFailed {
- type: 'CREATE_ACCOUNT_FAILED';
- error: Error;
-}
-
-interface IAccountCreated {
- type: 'ACCOUNT_CREATED';
- accountNumber: AccountNumber;
- deviceName?: string;
- expiry: string;
-}
-
-interface IAccountSetupFinished {
- type: 'ACCOUNT_SETUP_FINISHED';
-}
-
-interface IHideNewDeviceBanner {
- type: 'HIDE_NEW_DEVICE_BANNER';
-}
-
-interface IUpdateAccountNumberAction {
- type: 'UPDATE_ACCOUNT_NUMBER';
- accountNumber: AccountNumber;
-}
-
-interface IUpdateAccountHistoryAction {
- type: 'UPDATE_ACCOUNT_HISTORY';
- accountHistory?: AccountNumber;
-}
-
-interface IUpdateAccountExpiryAction {
- type: 'UPDATE_ACCOUNT_EXPIRY';
- expiry?: string;
- expired: boolean;
-}
-
-interface IUpdateDevicesAction {
- type: 'UPDATE_DEVICES';
- devices: Array<IDevice>;
-}
-
-export type AccountAction =
- | IStartLoginAction
- | ILoggedInAction
- | ILoginFailedAction
- | ILoginTooManyDevicesAction
- | ILoggedOutAction
- | IResetLoginErrorAction
- | IDeviceRevokedAction
- | IStartCreateAccount
- | ICreateAccountFailed
- | IAccountCreated
- | IAccountSetupFinished
- | IHideNewDeviceBanner
- | IUpdateAccountNumberAction
- | IUpdateAccountHistoryAction
- | IUpdateAccountExpiryAction
- | IUpdateDevicesAction;
-
-function startLogin(accountNumber: AccountNumber): IStartLoginAction {
- return {
- type: 'START_LOGIN',
- accountNumber,
- };
-}
-
-function loggedIn(accountNumber: AccountNumber, device?: IDevice): ILoggedInAction {
- return {
- type: 'LOGGED_IN',
- accountNumber,
- deviceName: device?.name,
- };
-}
-
-function loginFailed(error: AccountDataError['error']): ILoginFailedAction {
- return {
- type: 'LOGIN_FAILED',
- error,
- };
-}
-
-function loginTooManyDevices(): ILoginTooManyDevicesAction {
- return {
- type: 'TOO_MANY_DEVICES',
- };
-}
-
-function loggedOut(): ILoggedOutAction {
- return {
- type: 'LOGGED_OUT',
- };
-}
-
-function resetLoginError(): IResetLoginErrorAction {
- return {
- type: 'RESET_LOGIN_ERROR',
- };
-}
-
-function deviceRevoked(): IDeviceRevokedAction {
- return {
- type: 'DEVICE_REVOKED',
- };
-}
-
-function startCreateAccount(): IStartCreateAccount {
- return {
- type: 'START_CREATE_ACCOUNT',
- };
-}
-
-function createAccountFailed(error: Error): ICreateAccountFailed {
- return {
- type: 'CREATE_ACCOUNT_FAILED',
- error,
- };
-}
-
-function accountCreated(
- accountNumber: AccountNumber,
- device: IDevice | undefined,
- expiry: string,
-): IAccountCreated {
- return {
- type: 'ACCOUNT_CREATED',
- accountNumber: accountNumber,
- deviceName: device?.name,
- expiry,
- };
-}
-
-function accountSetupFinished(): IAccountSetupFinished {
- return { type: 'ACCOUNT_SETUP_FINISHED' };
-}
-
-function hideNewDeviceBanner(): IHideNewDeviceBanner {
- return { type: 'HIDE_NEW_DEVICE_BANNER' };
-}
-
-function updateAccountNumber(accountNumber: AccountNumber): IUpdateAccountNumberAction {
- return {
- type: 'UPDATE_ACCOUNT_NUMBER',
- accountNumber,
- };
-}
-
-function updateAccountHistory(accountHistory?: AccountNumber): IUpdateAccountHistoryAction {
- return {
- type: 'UPDATE_ACCOUNT_HISTORY',
- accountHistory,
- };
-}
-
-function updateAccountExpiry(expiry?: string): IUpdateAccountExpiryAction {
- return {
- type: 'UPDATE_ACCOUNT_EXPIRY',
- expiry,
- expired: expiry !== undefined && hasExpired(expiry),
- };
-}
-
-function updateDevices(devices: Array<IDevice>): IUpdateDevicesAction {
- return {
- type: 'UPDATE_DEVICES',
- devices: devices.sort((a, b) => a.created.getTime() - b.created.getTime()),
- };
-}
-
-export default {
- startLogin,
- loggedIn,
- loginFailed,
- loginTooManyDevices,
- loggedOut,
- resetLoginError,
- deviceRevoked,
- startCreateAccount,
- createAccountFailed,
- accountCreated,
- accountSetupFinished,
- hideNewDeviceBanner,
- updateAccountNumber,
- updateAccountHistory,
- updateAccountExpiry,
- updateDevices,
-};
diff --git a/gui/src/renderer/redux/account/reducers.ts b/gui/src/renderer/redux/account/reducers.ts
deleted file mode 100644
index a5cf1611ac..0000000000
--- a/gui/src/renderer/redux/account/reducers.ts
+++ /dev/null
@@ -1,154 +0,0 @@
-import { AccountDataError, AccountNumber, IDevice } from '../../../shared/daemon-rpc-types';
-import { ReduxAction } from '../store';
-
-type LoginMethod = 'existing_account' | 'new_account';
-type ExpiredState = 'expired' | 'time_added';
-
-export type LoginState =
- | { type: 'none'; deviceRevoked: boolean }
- | { type: 'logging in'; method: LoginMethod }
- | { type: 'ok'; method: LoginMethod; newDeviceBanner: boolean; expiredState?: ExpiredState }
- | { type: 'too many devices'; method: LoginMethod }
- | { type: 'failed'; method: 'existing_account'; error: AccountDataError['error'] }
- | { type: 'failed'; method: 'new_account'; error: Error };
-export interface IAccountReduxState {
- accountNumber?: AccountNumber;
- deviceName?: string;
- devices: Array<IDevice>;
- accountHistory?: AccountNumber;
- expiry?: string; // ISO8601
- status: LoginState;
-}
-
-const initialState: IAccountReduxState = {
- accountNumber: undefined,
- deviceName: undefined,
- devices: [],
- accountHistory: undefined,
- expiry: undefined,
- status: { type: 'none', deviceRevoked: false },
-};
-
-export default function (
- state: IAccountReduxState = initialState,
- action: ReduxAction,
-): IAccountReduxState {
- switch (action.type) {
- case 'START_LOGIN':
- return {
- ...state,
- status: { type: 'logging in', method: 'existing_account' },
- accountNumber: action.accountNumber,
- };
- case 'LOGGED_IN':
- return {
- ...state,
- status: {
- type: 'ok',
- method: 'existing_account',
- newDeviceBanner: state.status.type === 'logging in',
- },
- accountNumber: action.accountNumber,
- deviceName: action.deviceName,
- };
- case 'LOGIN_FAILED':
- return {
- ...state,
- status: { type: 'failed', method: 'existing_account', error: action.error },
- };
- case 'TOO_MANY_DEVICES':
- return {
- ...state,
- status: { type: 'too many devices', method: 'existing_account' },
- };
- case 'LOGGED_OUT':
- return {
- ...state,
- status: { type: 'none', deviceRevoked: false },
- accountNumber: undefined,
- expiry: undefined,
- };
- case 'RESET_LOGIN_ERROR':
- return {
- ...state,
- status: { type: 'none', deviceRevoked: false },
- };
- case 'DEVICE_REVOKED':
- return {
- ...state,
- status: { type: 'none', deviceRevoked: true },
- };
- case 'START_CREATE_ACCOUNT':
- return {
- ...state,
- status: { type: 'logging in', method: 'new_account' },
- };
- case 'CREATE_ACCOUNT_FAILED':
- return {
- ...state,
- status: { type: 'failed', method: 'new_account', error: action.error },
- };
- case 'ACCOUNT_CREATED':
- return {
- ...state,
- status: {
- type: 'ok',
- method: 'new_account',
- newDeviceBanner: true,
- expiredState: 'expired',
- },
- accountNumber: action.accountNumber,
- deviceName: action.deviceName,
- expiry: action.expiry,
- };
- case 'ACCOUNT_SETUP_FINISHED':
- return {
- ...state,
- status: { type: 'ok', method: 'existing_account', newDeviceBanner: true },
- };
- case 'HIDE_NEW_DEVICE_BANNER':
- if (state.status.type !== 'ok') {
- return state;
- }
-
- return {
- ...state,
- status: { ...state.status, newDeviceBanner: false },
- };
- case 'UPDATE_ACCOUNT_NUMBER':
- return {
- ...state,
- accountNumber: action.accountNumber,
- };
- case 'UPDATE_ACCOUNT_HISTORY':
- return {
- ...state,
- accountHistory: action.accountHistory,
- };
- case 'UPDATE_ACCOUNT_EXPIRY': {
- const status = { ...state.status };
- if (status.type === 'ok') {
- if (action.expired) {
- status.expiredState = 'expired';
- } else if (status.expiredState === 'expired' && !action.expired) {
- status.expiredState = 'time_added';
- } else {
- status.expiredState = undefined;
- }
- }
-
- return {
- ...state,
- expiry: action.expiry,
- status,
- };
- }
- case 'UPDATE_DEVICES':
- return {
- ...state,
- devices: action.devices,
- };
- }
-
- return state;
-}
diff --git a/gui/src/renderer/redux/connection/actions.ts b/gui/src/renderer/redux/connection/actions.ts
deleted file mode 100644
index 8a3f98efe5..0000000000
--- a/gui/src/renderer/redux/connection/actions.ts
+++ /dev/null
@@ -1,118 +0,0 @@
-import {
- AfterDisconnect,
- ErrorStateDetails,
- FeatureIndicator,
- ILocation,
- ITunnelStateRelayInfo,
-} from '../../../shared/daemon-rpc-types';
-
-interface IConnectingAction {
- type: 'CONNECTING';
- details?: ITunnelStateRelayInfo;
- featureIndicators?: Array<FeatureIndicator>;
-}
-
-interface IConnectedAction {
- type: 'CONNECTED';
- details: ITunnelStateRelayInfo;
- featureIndicators?: Array<FeatureIndicator>;
-}
-
-interface IDisconnectedAction {
- type: 'DISCONNECTED';
-}
-
-interface IDisconnectingAction {
- type: 'DISCONNECTING';
- afterDisconnect: AfterDisconnect;
-}
-
-interface IBlockedAction {
- type: 'TUNNEL_ERROR';
- errorState: ErrorStateDetails;
-}
-
-interface INewLocationAction {
- type: 'NEW_LOCATION';
- newLocation: Partial<ILocation>;
-}
-
-interface IUpdateBlockStateAction {
- type: 'UPDATE_BLOCK_STATE';
- isBlocked: boolean;
-}
-
-export type ConnectionAction =
- | INewLocationAction
- | IConnectingAction
- | IConnectedAction
- | IDisconnectedAction
- | IDisconnectingAction
- | IBlockedAction
- | IUpdateBlockStateAction;
-
-function connecting(
- details?: ITunnelStateRelayInfo,
- featureIndicators?: Array<FeatureIndicator>,
-): IConnectingAction {
- return {
- type: 'CONNECTING',
- details,
- featureIndicators,
- };
-}
-
-function connected(
- details: ITunnelStateRelayInfo,
- featureIndicators?: Array<FeatureIndicator>,
-): IConnectedAction {
- return {
- type: 'CONNECTED',
- details,
- featureIndicators,
- };
-}
-
-function disconnected(): IDisconnectedAction {
- return {
- type: 'DISCONNECTED',
- };
-}
-
-function disconnecting(afterDisconnect: AfterDisconnect): IDisconnectingAction {
- return {
- type: 'DISCONNECTING',
- afterDisconnect,
- };
-}
-
-function blocked(errorState: ErrorStateDetails): IBlockedAction {
- return {
- type: 'TUNNEL_ERROR',
- errorState,
- };
-}
-
-function newLocation(location: Partial<ILocation>): INewLocationAction {
- return {
- type: 'NEW_LOCATION',
- newLocation: location,
- };
-}
-
-function updateBlockState(isBlocked: boolean): IUpdateBlockStateAction {
- return {
- type: 'UPDATE_BLOCK_STATE',
- isBlocked,
- };
-}
-
-export default {
- newLocation,
- updateBlockState,
- connecting,
- connected,
- disconnected,
- disconnecting,
- blocked,
-};
diff --git a/gui/src/renderer/redux/connection/reducers.ts b/gui/src/renderer/redux/connection/reducers.ts
deleted file mode 100644
index b597d29a64..0000000000
--- a/gui/src/renderer/redux/connection/reducers.ts
+++ /dev/null
@@ -1,98 +0,0 @@
-import { Ip, TunnelState } from '../../../shared/daemon-rpc-types';
-import { ReduxAction } from '../store';
-
-export interface IConnectionReduxState {
- status: TunnelState;
- isBlocked: boolean;
- ipv4?: Ip;
- ipv6?: Ip;
- hostname?: string;
- bridgeHostname?: string;
- entryHostname?: string;
- latitude?: number;
- longitude?: number;
- country?: string;
- city?: string;
-}
-
-const initialState: IConnectionReduxState = {
- status: { state: 'disconnected' },
- isBlocked: false,
- ipv4: undefined,
- ipv6: undefined,
- hostname: undefined,
- bridgeHostname: undefined,
- entryHostname: undefined,
- latitude: undefined,
- longitude: undefined,
- country: undefined,
- city: undefined,
-};
-
-export default function (
- state: IConnectionReduxState = initialState,
- action: ReduxAction,
-): IConnectionReduxState {
- switch (action.type) {
- case 'NEW_LOCATION':
- return {
- ...state,
- ipv4: action.newLocation.ipv4,
- ipv6: action.newLocation.ipv6,
- country: action.newLocation.country,
- city: action.newLocation.city,
- latitude: action.newLocation.latitude,
- longitude: action.newLocation.longitude,
- hostname: action.newLocation.hostname,
- bridgeHostname: action.newLocation.bridgeHostname,
- entryHostname: action.newLocation.entryHostname,
- };
-
- case 'UPDATE_BLOCK_STATE':
- return { ...state, isBlocked: action.isBlocked };
-
- case 'CONNECTING':
- return {
- ...state,
- status: {
- state: 'connecting',
- details: action.details,
- featureIndicators: action.featureIndicators,
- },
- };
-
- case 'CONNECTED':
- return {
- ...state,
- status: {
- state: 'connected',
- details: action.details,
- featureIndicators: action.featureIndicators,
- },
- };
-
- case 'DISCONNECTED':
- return {
- ...state,
- status: { state: 'disconnected' },
- };
-
- case 'DISCONNECTING':
- return {
- ...state,
- status: { state: 'disconnecting', details: action.afterDisconnect },
- };
-
- case 'TUNNEL_ERROR':
- return {
- ...state,
- status: {
- state: 'error',
- details: action.errorState,
- },
- };
-
- default:
- return state;
- }
-}
diff --git a/gui/src/renderer/redux/settings-import/actions.ts b/gui/src/renderer/redux/settings-import/actions.ts
deleted file mode 100644
index f31af1c1c6..0000000000
--- a/gui/src/renderer/redux/settings-import/actions.ts
+++ /dev/null
@@ -1,40 +0,0 @@
-export interface SaveSettingsImportFormAction {
- type: 'SAVE_SETTINGS_IMPORT_FORM';
- value: string;
- submit: boolean;
-}
-
-export interface ClearSettingsImportFormAction {
- type: 'CLEAR_SETTINGS_IMPORT_FORM';
-}
-
-export interface UnsetSubmitSettingsImportFormAction {
- type: 'UNSET_SUBMIT_SETTINGS_IMPORT_FORM';
-}
-
-export type SettingsImportAction =
- | SaveSettingsImportFormAction
- | ClearSettingsImportFormAction
- | UnsetSubmitSettingsImportFormAction;
-
-function saveSettingsImportForm(value: string, submit: boolean): SaveSettingsImportFormAction {
- return {
- type: 'SAVE_SETTINGS_IMPORT_FORM',
- value,
- submit,
- };
-}
-
-function clearSettingsImportForm(): ClearSettingsImportFormAction {
- return {
- type: 'CLEAR_SETTINGS_IMPORT_FORM',
- };
-}
-
-function unsetSubmitSettingsImportForm(): UnsetSubmitSettingsImportFormAction {
- return {
- type: 'UNSET_SUBMIT_SETTINGS_IMPORT_FORM',
- };
-}
-
-export default { saveSettingsImportForm, clearSettingsImportForm, unsetSubmitSettingsImportForm };
diff --git a/gui/src/renderer/redux/settings-import/reducers.ts b/gui/src/renderer/redux/settings-import/reducers.ts
deleted file mode 100644
index 76908bc67a..0000000000
--- a/gui/src/renderer/redux/settings-import/reducers.ts
+++ /dev/null
@@ -1,41 +0,0 @@
-import { ReduxAction } from '../store';
-
-export interface SettingsImportReduxState {
- value: string;
- submit: boolean;
-}
-
-const initialState: SettingsImportReduxState = {
- value: '',
- submit: false,
-};
-
-export default function (
- state: SettingsImportReduxState = initialState,
- action: ReduxAction,
-): SettingsImportReduxState {
- switch (action.type) {
- case 'SAVE_SETTINGS_IMPORT_FORM':
- return {
- ...state,
- value: action.value,
- submit: action.submit,
- };
-
- case 'CLEAR_SETTINGS_IMPORT_FORM':
- return {
- ...state,
- value: '',
- submit: false,
- };
-
- case 'UNSET_SUBMIT_SETTINGS_IMPORT_FORM':
- return {
- ...state,
- submit: false,
- };
-
- default:
- return state;
- }
-}
diff --git a/gui/src/renderer/redux/settings/actions.ts b/gui/src/renderer/redux/settings/actions.ts
deleted file mode 100644
index d2a3fb1c4a..0000000000
--- a/gui/src/renderer/redux/settings/actions.ts
+++ /dev/null
@@ -1,353 +0,0 @@
-import { ISplitTunnelingApplication } from '../../../shared/application-types';
-import {
- AccessMethodSetting,
- ApiAccessMethodSettings,
- BridgeState,
- CustomLists,
- IDaitaSettings,
- IDnsOptions,
- IWireguardEndpointData,
- ObfuscationSettings,
- RelayOverride,
-} from '../../../shared/daemon-rpc-types';
-import { IGuiSettingsState } from '../../../shared/gui-settings-state';
-import { BridgeSettingsRedux, IRelayLocationCountryRedux, RelaySettingsRedux } from './reducers';
-
-export interface IUpdateGuiSettingsAction {
- type: 'UPDATE_GUI_SETTINGS';
- guiSettings: IGuiSettingsState;
-}
-
-export interface IUpdateRelayAction {
- type: 'UPDATE_RELAY';
- relay: RelaySettingsRedux;
-}
-
-export interface IUpdateRelayLocationsAction {
- type: 'UPDATE_RELAY_LOCATIONS';
- relayLocations: IRelayLocationCountryRedux[];
-}
-
-export interface IUpdateWireguardEndpointData {
- type: 'UPDATE_WIREGUARD_ENDPOINT_DATA';
- wireguardEndpointData: IWireguardEndpointData;
-}
-
-export interface IUpdateAllowLanAction {
- type: 'UPDATE_ALLOW_LAN';
- allowLan: boolean;
-}
-
-export interface IUpdateEnableIpv6Action {
- type: 'UPDATE_ENABLE_IPV6';
- enableIpv6: boolean;
-}
-
-export interface IUpdateBlockWhenDisconnectedAction {
- type: 'UPDATE_BLOCK_WHEN_DISCONNECTED';
- blockWhenDisconnected: boolean;
-}
-
-export interface IUpdateShowBetaReleasesAction {
- type: 'UPDATE_SHOW_BETA_NOTIFICATIONS';
- showBetaReleases: boolean;
-}
-
-export interface IUpdateBridgeSettingsAction {
- type: 'UPDATE_BRIDGE_SETTINGS';
- bridgeSettings: BridgeSettingsRedux;
-}
-
-export interface IUpdateBridgeStateAction {
- type: 'UPDATE_BRIDGE_STATE';
- bridgeState: BridgeState;
-}
-
-export interface IUpdateOpenVpnMssfixAction {
- type: 'UPDATE_OPENVPN_MSSFIX';
- mssfix?: number;
-}
-
-export interface IUpdateWireguardMtuAction {
- type: 'UPDATE_WIREGUARD_MTU';
- mtu?: number;
-}
-
-export interface IUpdateWireguardQuantumResistantAction {
- type: 'UPDATE_WIREGUARD_QUANTUM_RESISTANT';
- quantumResistant?: boolean;
-}
-
-export interface IUpdateWireguardDaitaAction {
- type: 'UPDATE_WIREGUARD_DAITA';
- daita?: IDaitaSettings;
-}
-
-export interface IUpdateAutoStartAction {
- type: 'UPDATE_AUTO_START';
- autoStart: boolean;
-}
-
-export interface IUpdateDnsOptionsAction {
- type: 'UPDATE_DNS_OPTIONS';
- dns: IDnsOptions;
-}
-
-export interface IUpdateSplitTunnelingStateAction {
- type: 'UPDATE_SPLIT_TUNNELING_STATE';
- enabled: boolean;
-}
-
-export interface ISetSplitTunnelingApplicationsAction {
- type: 'SET_SPLIT_TUNNELING_APPLICATIONS';
- applications: ISplitTunnelingApplication[];
-}
-
-export interface ISetObfuscationSettings {
- type: 'SET_OBFUSCATION_SETTINGS';
- obfuscationSettings: ObfuscationSettings;
-}
-
-export interface ISetCustomLists {
- type: 'SET_CUSTOM_LISTS';
- customLists: CustomLists;
-}
-
-export interface ISetApiAccessMethods {
- type: 'SET_API_ACCESS_METHODS';
- accessMethods: ApiAccessMethodSettings;
-}
-
-export interface ISetCurrentApiAccessMethod {
- type: 'SET_CURRENT_API_ACCESS_METHOD';
- accessMethod: AccessMethodSetting;
-}
-
-export interface ISetRelayOverrides {
- type: 'SET_RELAY_OVERRIDES';
- relayOverrides: Array<RelayOverride>;
-}
-
-export type SettingsAction =
- | IUpdateGuiSettingsAction
- | IUpdateRelayAction
- | IUpdateRelayLocationsAction
- | IUpdateWireguardEndpointData
- | IUpdateAllowLanAction
- | IUpdateEnableIpv6Action
- | IUpdateBlockWhenDisconnectedAction
- | IUpdateShowBetaReleasesAction
- | IUpdateBridgeSettingsAction
- | IUpdateBridgeStateAction
- | IUpdateOpenVpnMssfixAction
- | IUpdateWireguardMtuAction
- | IUpdateWireguardQuantumResistantAction
- | IUpdateWireguardDaitaAction
- | IUpdateAutoStartAction
- | IUpdateDnsOptionsAction
- | IUpdateSplitTunnelingStateAction
- | ISetSplitTunnelingApplicationsAction
- | ISetObfuscationSettings
- | ISetCustomLists
- | ISetApiAccessMethods
- | ISetCurrentApiAccessMethod
- | ISetRelayOverrides;
-
-function updateGuiSettings(guiSettings: IGuiSettingsState): IUpdateGuiSettingsAction {
- return {
- type: 'UPDATE_GUI_SETTINGS',
- guiSettings,
- };
-}
-
-function updateRelay(relay: RelaySettingsRedux): IUpdateRelayAction {
- return {
- type: 'UPDATE_RELAY',
- relay,
- };
-}
-
-function updateRelayLocations(
- relayLocations: IRelayLocationCountryRedux[],
-): IUpdateRelayLocationsAction {
- return {
- type: 'UPDATE_RELAY_LOCATIONS',
- relayLocations,
- };
-}
-
-function updateWireguardEndpointData(
- wireguardEndpointData: IWireguardEndpointData,
-): IUpdateWireguardEndpointData {
- return {
- type: 'UPDATE_WIREGUARD_ENDPOINT_DATA',
- wireguardEndpointData,
- };
-}
-
-function updateAllowLan(allowLan: boolean): IUpdateAllowLanAction {
- return {
- type: 'UPDATE_ALLOW_LAN',
- allowLan,
- };
-}
-
-function updateEnableIpv6(enableIpv6: boolean): IUpdateEnableIpv6Action {
- return {
- type: 'UPDATE_ENABLE_IPV6',
- enableIpv6,
- };
-}
-
-function updateBlockWhenDisconnected(
- blockWhenDisconnected: boolean,
-): IUpdateBlockWhenDisconnectedAction {
- return {
- type: 'UPDATE_BLOCK_WHEN_DISCONNECTED',
- blockWhenDisconnected,
- };
-}
-
-function updateShowBetaReleases(showBetaReleases: boolean): IUpdateShowBetaReleasesAction {
- return {
- type: 'UPDATE_SHOW_BETA_NOTIFICATIONS',
- showBetaReleases,
- };
-}
-
-function updateBridgeSettings(bridgeSettings: BridgeSettingsRedux): IUpdateBridgeSettingsAction {
- return {
- type: 'UPDATE_BRIDGE_SETTINGS',
- bridgeSettings,
- };
-}
-
-function updateBridgeState(bridgeState: BridgeState): IUpdateBridgeStateAction {
- return {
- type: 'UPDATE_BRIDGE_STATE',
- bridgeState,
- };
-}
-
-function updateOpenVpnMssfix(mssfix?: number): IUpdateOpenVpnMssfixAction {
- return {
- type: 'UPDATE_OPENVPN_MSSFIX',
- mssfix,
- };
-}
-
-function updateWireguardMtu(mtu?: number): IUpdateWireguardMtuAction {
- return {
- type: 'UPDATE_WIREGUARD_MTU',
- mtu,
- };
-}
-
-function updateWireguardQuantumResistant(
- quantumResistant?: boolean,
-): IUpdateWireguardQuantumResistantAction {
- return {
- type: 'UPDATE_WIREGUARD_QUANTUM_RESISTANT',
- quantumResistant,
- };
-}
-
-function updateWireguardDaita(daita?: IDaitaSettings): IUpdateWireguardDaitaAction {
- return {
- type: 'UPDATE_WIREGUARD_DAITA',
- daita,
- };
-}
-
-function updateAutoStart(autoStart: boolean): IUpdateAutoStartAction {
- return {
- type: 'UPDATE_AUTO_START',
- autoStart,
- };
-}
-
-function updateDnsOptions(dns: IDnsOptions): IUpdateDnsOptionsAction {
- return {
- type: 'UPDATE_DNS_OPTIONS',
- dns,
- };
-}
-
-function updateSplitTunnelingState(enabled: boolean): IUpdateSplitTunnelingStateAction {
- return {
- type: 'UPDATE_SPLIT_TUNNELING_STATE',
- enabled,
- };
-}
-
-function setSplitTunnelingApplications(
- applications: ISplitTunnelingApplication[],
-): ISetSplitTunnelingApplicationsAction {
- return {
- type: 'SET_SPLIT_TUNNELING_APPLICATIONS',
- applications,
- };
-}
-
-function updateObfuscationSettings(
- obfuscationSettings: ObfuscationSettings,
-): ISetObfuscationSettings {
- return {
- type: 'SET_OBFUSCATION_SETTINGS',
- obfuscationSettings,
- };
-}
-
-function updateCustomLists(customLists: CustomLists): ISetCustomLists {
- return {
- type: 'SET_CUSTOM_LISTS',
- customLists,
- };
-}
-
-function updateApiAccessMethods(methods: ApiAccessMethodSettings): ISetApiAccessMethods {
- return {
- type: 'SET_API_ACCESS_METHODS',
- accessMethods: methods,
- };
-}
-
-function updateCurrentApiAccessMethod(setting: AccessMethodSetting): ISetCurrentApiAccessMethod {
- return {
- type: 'SET_CURRENT_API_ACCESS_METHOD',
- accessMethod: setting,
- };
-}
-
-function updateRelayOverrides(relayOverrides: Array<RelayOverride>): ISetRelayOverrides {
- return {
- type: 'SET_RELAY_OVERRIDES',
- relayOverrides,
- };
-}
-
-export default {
- updateGuiSettings,
- updateRelay,
- updateRelayLocations,
- updateWireguardEndpointData,
- updateAllowLan,
- updateEnableIpv6,
- updateBlockWhenDisconnected,
- updateShowBetaReleases,
- updateBridgeSettings,
- updateBridgeState,
- updateOpenVpnMssfix,
- updateWireguardMtu,
- updateWireguardQuantumResistant,
- updateWireguardDaita,
- updateAutoStart,
- updateDnsOptions,
- updateSplitTunnelingState,
- setSplitTunnelingApplications,
- updateObfuscationSettings,
- updateCustomLists,
- updateApiAccessMethods,
- updateCurrentApiAccessMethod,
- updateRelayOverrides,
-};
diff --git a/gui/src/renderer/redux/settings/reducers.ts b/gui/src/renderer/redux/settings/reducers.ts
deleted file mode 100644
index 6eb595467b..0000000000
--- a/gui/src/renderer/redux/settings/reducers.ts
+++ /dev/null
@@ -1,358 +0,0 @@
-import { getDefaultApiAccessMethods } from '../../../main/default-settings';
-import { ISplitTunnelingApplication } from '../../../shared/application-types';
-import {
- AccessMethodSetting,
- ApiAccessMethodSettings,
- BridgeState,
- BridgeType,
- CustomLists,
- CustomProxy,
- IDaitaSettings,
- IDnsOptions,
- IpVersion,
- IWireguardEndpointData,
- LiftedConstraint,
- ObfuscationSettings,
- ObfuscationType,
- Ownership,
- RelayEndpointType,
- RelayLocation,
- RelayOverride,
- RelayProtocol,
- TunnelProtocol,
-} from '../../../shared/daemon-rpc-types';
-import { IGuiSettingsState } from '../../../shared/gui-settings-state';
-import { ReduxAction } from '../store';
-
-export type NormalRelaySettingsRedux = {
- tunnelProtocol: LiftedConstraint<TunnelProtocol>;
- location: LiftedConstraint<RelayLocation>;
- providers: string[];
- ownership: Ownership;
- openvpn: {
- port: LiftedConstraint<number>;
- protocol: LiftedConstraint<RelayProtocol>;
- };
- wireguard: {
- port: LiftedConstraint<number>;
- ipVersion: LiftedConstraint<IpVersion>;
- useMultihop: boolean;
- entryLocation: LiftedConstraint<RelayLocation>;
- };
-};
-
-export type NormalBridgeSettingsRedux = {
- location: LiftedConstraint<RelayLocation>;
- /** Providers are used to filter bridges and as bridge constraints for the daemon. */
- providers: string[];
- /** Ownership is used to filter bridges and as bridge constraints for the daemon. */
- ownership: Ownership;
-};
-
-export type RelaySettingsRedux =
- | {
- normal: NormalRelaySettingsRedux;
- }
- | {
- customTunnelEndpoint: {
- host: string;
- port: number;
- protocol: RelayProtocol;
- };
- };
-
-export type BridgeSettingsRedux = {
- type: BridgeType;
- normal: NormalBridgeSettingsRedux;
- custom?: CustomProxy;
-};
-
-export interface IRelayLocationRelayRedux {
- hostname: string;
- provider: string;
- ipv4AddrIn: string;
- includeInCountry: boolean;
- active: boolean;
- owned: boolean;
- weight: number;
- endpointType: RelayEndpointType;
- daita: boolean;
-}
-
-export interface IRelayLocationCityRedux {
- name: string;
- code: string;
- latitude: number;
- longitude: number;
- relays: IRelayLocationRelayRedux[];
-}
-
-export interface IRelayLocationCountryRedux {
- name: string;
- code: string;
- cities: IRelayLocationCityRedux[];
-}
-
-export interface ISettingsReduxState {
- autoStart: boolean;
- guiSettings: IGuiSettingsState;
- relaySettings: RelaySettingsRedux;
- relayLocations: IRelayLocationCountryRedux[];
- wireguardEndpointData: IWireguardEndpointData;
- allowLan: boolean;
- enableIpv6: boolean;
- bridgeSettings: BridgeSettingsRedux;
- bridgeState: BridgeState;
- blockWhenDisconnected: boolean;
- showBetaReleases: boolean;
- openVpn: {
- mssfix?: number;
- };
- wireguard: {
- mtu?: number;
- quantumResistant?: boolean;
- daita?: IDaitaSettings;
- };
- dns: IDnsOptions;
- splitTunneling: boolean;
- splitTunnelingApplications: ISplitTunnelingApplication[];
- obfuscationSettings: ObfuscationSettings;
- customLists: CustomLists;
- apiAccessMethods: ApiAccessMethodSettings;
- currentApiAccessMethod?: AccessMethodSetting;
- relayOverrides: Array<RelayOverride>;
-}
-
-const initialState: ISettingsReduxState = {
- autoStart: false,
- guiSettings: {
- preferredLocale: 'system',
- enableSystemNotifications: true,
- autoConnect: true,
- monochromaticIcon: false,
- startMinimized: false,
- unpinnedWindow: window.env.platform !== 'win32' && window.env.platform !== 'darwin',
- browsedForSplitTunnelingApplications: [],
- changelogDisplayedForVersion: '',
- animateMap: true,
- },
- relaySettings: {
- normal: {
- location: 'any',
- tunnelProtocol: 'any',
- providers: [],
- ownership: Ownership.any,
- wireguard: { port: 'any', ipVersion: 'any', useMultihop: false, entryLocation: 'any' },
- openvpn: {
- port: 'any',
- protocol: 'any',
- },
- },
- },
- relayLocations: [],
- wireguardEndpointData: { portRanges: [], udp2tcpPorts: [] },
- allowLan: false,
- enableIpv6: true,
- bridgeSettings: {
- type: 'normal',
- normal: {
- location: 'any',
- providers: [],
- ownership: Ownership.any,
- },
- custom: undefined,
- },
- bridgeState: 'auto',
- blockWhenDisconnected: false,
- showBetaReleases: false,
- openVpn: {},
- wireguard: {},
- dns: {
- state: 'default',
- defaultOptions: {
- blockAds: false,
- blockTrackers: false,
- blockMalware: false,
- blockAdultContent: false,
- blockGambling: false,
- blockSocialMedia: false,
- },
- customOptions: {
- addresses: [],
- },
- },
- splitTunneling: false,
- splitTunnelingApplications: [],
- obfuscationSettings: {
- selectedObfuscation: ObfuscationType.auto,
- udp2tcpSettings: {
- port: 'any',
- },
- shadowsocksSettings: {
- port: 'any',
- },
- },
- customLists: [],
- apiAccessMethods: getDefaultApiAccessMethods(),
- currentApiAccessMethod: undefined,
- relayOverrides: [],
-};
-
-export default function (
- state: ISettingsReduxState = initialState,
- action: ReduxAction,
-): ISettingsReduxState {
- switch (action.type) {
- case 'UPDATE_GUI_SETTINGS':
- return {
- ...state,
- guiSettings: action.guiSettings,
- };
-
- case 'UPDATE_RELAY':
- return {
- ...state,
- relaySettings: action.relay,
- };
-
- case 'UPDATE_RELAY_LOCATIONS':
- return {
- ...state,
- relayLocations: action.relayLocations,
- };
-
- case 'UPDATE_WIREGUARD_ENDPOINT_DATA':
- return {
- ...state,
- wireguardEndpointData: action.wireguardEndpointData,
- };
-
- case 'UPDATE_ALLOW_LAN':
- return {
- ...state,
- allowLan: action.allowLan,
- };
-
- case 'UPDATE_ENABLE_IPV6':
- return {
- ...state,
- enableIpv6: action.enableIpv6,
- };
-
- case 'UPDATE_BLOCK_WHEN_DISCONNECTED':
- return {
- ...state,
- blockWhenDisconnected: action.blockWhenDisconnected,
- };
-
- case 'UPDATE_SHOW_BETA_NOTIFICATIONS':
- return {
- ...state,
- showBetaReleases: action.showBetaReleases,
- };
-
- case 'UPDATE_OPENVPN_MSSFIX':
- return {
- ...state,
- openVpn: {
- ...state.openVpn,
- mssfix: action.mssfix,
- },
- };
-
- case 'UPDATE_WIREGUARD_MTU':
- return {
- ...state,
- wireguard: {
- ...state.wireguard,
- mtu: action.mtu,
- },
- };
-
- case 'UPDATE_WIREGUARD_QUANTUM_RESISTANT':
- return {
- ...state,
- wireguard: {
- ...state.wireguard,
- quantumResistant: action.quantumResistant,
- },
- };
- case 'UPDATE_WIREGUARD_DAITA':
- return {
- ...state,
- wireguard: {
- ...state.wireguard,
- daita: action.daita,
- },
- };
-
- case 'UPDATE_AUTO_START':
- return {
- ...state,
- autoStart: action.autoStart,
- };
-
- case 'UPDATE_BRIDGE_SETTINGS':
- return {
- ...state,
- bridgeSettings: action.bridgeSettings,
- };
-
- case 'UPDATE_BRIDGE_STATE':
- return {
- ...state,
- bridgeState: action.bridgeState,
- };
-
- case 'UPDATE_DNS_OPTIONS':
- return {
- ...state,
- dns: action.dns,
- };
-
- case 'UPDATE_SPLIT_TUNNELING_STATE':
- return {
- ...state,
- splitTunneling: action.enabled,
- };
-
- case 'SET_SPLIT_TUNNELING_APPLICATIONS':
- return {
- ...state,
- splitTunnelingApplications: action.applications,
- };
-
- case 'SET_OBFUSCATION_SETTINGS':
- return {
- ...state,
- obfuscationSettings: action.obfuscationSettings,
- };
-
- case 'SET_CUSTOM_LISTS':
- return {
- ...state,
- customLists: action.customLists,
- };
-
- case 'SET_API_ACCESS_METHODS':
- return {
- ...state,
- apiAccessMethods: action.accessMethods,
- };
-
- case 'SET_CURRENT_API_ACCESS_METHOD':
- return {
- ...state,
- currentApiAccessMethod: action.accessMethod,
- };
-
- case 'SET_RELAY_OVERRIDES':
- return {
- ...state,
- relayOverrides: action.relayOverrides,
- };
-
- default:
- return state;
- }
-}
diff --git a/gui/src/renderer/redux/store.ts b/gui/src/renderer/redux/store.ts
deleted file mode 100644
index 073fe67d30..0000000000
--- a/gui/src/renderer/redux/store.ts
+++ /dev/null
@@ -1,93 +0,0 @@
-import { useRef } from 'react';
-import { useSelector as useReduxSelector } from 'react-redux';
-import { combineReducers, compose, createStore, Dispatch, StoreEnhancer } from 'redux';
-
-import { useWillExit } from '../lib/will-exit';
-import accountActions, { AccountAction } from './account/actions';
-import accountReducer, { IAccountReduxState } from './account/reducers';
-import connectionActions, { ConnectionAction } from './connection/actions';
-import connectionReducer, { IConnectionReduxState } from './connection/reducers';
-import settingsActions, { SettingsAction } from './settings/actions';
-import settingsReducer, { ISettingsReduxState } from './settings/reducers';
-import { SettingsImportAction } from './settings-import/actions';
-import settingsImportReducer, { SettingsImportReduxState } from './settings-import/reducers';
-import supportActions, { SupportAction } from './support/actions';
-import supportReducer, { ISupportReduxState } from './support/reducers';
-import userInterfaceActions, { UserInterfaceAction } from './userinterface/actions';
-import userInterfaceReducer, { IUserInterfaceReduxState } from './userinterface/reducers';
-import versionActions, { VersionAction } from './version/actions';
-import versionReducer, { IVersionReduxState } from './version/reducers';
-
-export interface IReduxState {
- account: IAccountReduxState;
- connection: IConnectionReduxState;
- settings: ISettingsReduxState;
- support: ISupportReduxState;
- version: IVersionReduxState;
- userInterface: IUserInterfaceReduxState;
- settingsImport: SettingsImportReduxState;
-}
-
-export type ReduxAction =
- | AccountAction
- | ConnectionAction
- | SettingsAction
- | SupportAction
- | VersionAction
- | UserInterfaceAction
- | SettingsImportAction;
-export type ReduxStore = ReturnType<typeof configureStore>;
-export type ReduxDispatch = Dispatch<ReduxAction>;
-
-export default function configureStore() {
- const reducers = {
- account: accountReducer,
- connection: connectionReducer,
- settings: settingsReducer,
- support: supportReducer,
- version: versionReducer,
- userInterface: userInterfaceReducer,
- settingsImport: settingsImportReducer,
- };
-
- const rootReducer = combineReducers(reducers);
-
- return createStore(rootReducer, composeEnhancers());
-}
-
-function composeEnhancers(): StoreEnhancer {
- const actionCreators = {
- ...accountActions,
- ...connectionActions,
- ...settingsActions,
- ...supportActions,
- ...versionActions,
- ...userInterfaceActions,
- };
-
- if (window.env.development) {
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- const devtoolsCompose = (window as any).__REDUX_DEVTOOLS_EXTENSION_COMPOSE__?.({
- actionCreators,
- });
- return devtoolsCompose ? devtoolsCompose() : compose();
- }
-
- return compose();
-}
-
-// This hook adds type to state to make use simpler. It also prevents the state from update if the
-// WillExit context value is true.
-export function useSelector<R>(fn: (state: IReduxState) => R): R {
- const value = useReduxSelector(fn);
- const valueBeforeExit = useRef(value);
- const willExit = useWillExit();
-
- if (!willExit) {
- // eslint-disable-next-line react-compiler/react-compiler
- valueBeforeExit.current = value;
- }
-
- // eslint-disable-next-line react-compiler/react-compiler
- return valueBeforeExit.current;
-}
diff --git a/gui/src/renderer/redux/support/actions.ts b/gui/src/renderer/redux/support/actions.ts
deleted file mode 100644
index de26a17908..0000000000
--- a/gui/src/renderer/redux/support/actions.ts
+++ /dev/null
@@ -1,30 +0,0 @@
-export interface IProblemReportForm {
- email: string;
- message: string;
-}
-
-export interface IKeepReportFormAction {
- type: 'SAVE_REPORT_FORM';
- form: IProblemReportForm;
-}
-
-export interface IClearReportFormAction {
- type: 'CLEAR_REPORT_FORM';
-}
-
-export type SupportAction = IKeepReportFormAction | IClearReportFormAction;
-
-function saveReportForm(form: IProblemReportForm): IKeepReportFormAction {
- return {
- type: 'SAVE_REPORT_FORM',
- form,
- };
-}
-
-function clearReportForm(): IClearReportFormAction {
- return {
- type: 'CLEAR_REPORT_FORM',
- };
-}
-
-export default { saveReportForm, clearReportForm };
diff --git a/gui/src/renderer/redux/support/reducers.ts b/gui/src/renderer/redux/support/reducers.ts
deleted file mode 100644
index 94ed2a9123..0000000000
--- a/gui/src/renderer/redux/support/reducers.ts
+++ /dev/null
@@ -1,35 +0,0 @@
-import { ReduxAction } from '../store';
-
-export interface ISupportReduxState {
- email: string;
- message: string;
-}
-
-const initialState: ISupportReduxState = {
- email: '',
- message: '',
-};
-
-export default function (
- state: ISupportReduxState = initialState,
- action: ReduxAction,
-): ISupportReduxState {
- switch (action.type) {
- case 'SAVE_REPORT_FORM':
- return {
- ...state,
- email: action.form.email,
- message: action.form.message,
- };
-
- case 'CLEAR_REPORT_FORM':
- return {
- ...state,
- email: '',
- message: '',
- };
-
- default:
- return state;
- }
-}
diff --git a/gui/src/renderer/redux/userinterface/actions.ts b/gui/src/renderer/redux/userinterface/actions.ts
deleted file mode 100644
index 238835318e..0000000000
--- a/gui/src/renderer/redux/userinterface/actions.ts
+++ /dev/null
@@ -1,176 +0,0 @@
-import { MacOsScrollbarVisibility } from '../../../shared/ipc-schema';
-import { IChangelog } from '../../../shared/ipc-types';
-import { LocationType } from '../../components/select-location/select-location-types';
-
-export interface IUpdateLocaleAction {
- type: 'UPDATE_LOCALE';
- locale: string;
-}
-
-export interface IUpdateWindowArrowPositionAction {
- type: 'UPDATE_WINDOW_ARROW_POSITION';
- arrowPosition: number;
-}
-
-export interface IUpdateConnectionInfoOpenAction {
- type: 'TOGGLE_CONNECTION_PANEL';
-}
-
-export interface ISetWindowFocusedAction {
- type: 'SET_WINDOW_FOCUSED';
- focused: boolean;
-}
-
-export interface ISetMacOsScrollbarVisibility {
- type: 'SET_MACOS_SCROLLBAR_VISIBILITY';
- visibility: MacOsScrollbarVisibility;
-}
-
-export interface ISetConnectedToDaemon {
- type: 'SET_CONNECTED_TO_DAEMON';
- connectedToDaemon: boolean;
-}
-
-export interface ISetDaemonAllowed {
- type: 'SET_DAEMON_ALLOWED';
- daemonAllowed: boolean;
-}
-
-export interface ISetChangelog {
- type: 'SET_CHANGELOG';
- changelog: IChangelog;
-}
-
-export interface ISetForceShowChanges {
- type: 'SET_FORCE_SHOW_CHANGES';
- forceShowChanges: boolean;
-}
-
-export interface ISetIsPerformingPostUpgrade {
- type: 'SET_IS_PERFORMING_POST_UPGRADE';
- isPerformingPostUpgrade: boolean;
-}
-
-export interface ISetSelectLocationView {
- type: 'SET_SELECT_LOCATION_VIEW';
- selectLocationView: LocationType;
-}
-
-export interface ISetIsMacOs13OrNewer {
- type: 'SET_IS_MACOS13_OR_NEWER';
- isMacOs13OrNewer: boolean;
-}
-
-export type UserInterfaceAction =
- | IUpdateLocaleAction
- | IUpdateWindowArrowPositionAction
- | IUpdateConnectionInfoOpenAction
- | ISetWindowFocusedAction
- | ISetMacOsScrollbarVisibility
- | ISetConnectedToDaemon
- | ISetDaemonAllowed
- | ISetChangelog
- | ISetForceShowChanges
- | ISetIsPerformingPostUpgrade
- | ISetSelectLocationView
- | ISetIsMacOs13OrNewer;
-
-function updateLocale(locale: string): IUpdateLocaleAction {
- return {
- type: 'UPDATE_LOCALE',
- locale,
- };
-}
-
-function updateWindowArrowPosition(arrowPosition: number): IUpdateWindowArrowPositionAction {
- return {
- type: 'UPDATE_WINDOW_ARROW_POSITION',
- arrowPosition,
- };
-}
-
-function toggleConnectionPanel(): IUpdateConnectionInfoOpenAction {
- return {
- type: 'TOGGLE_CONNECTION_PANEL',
- };
-}
-
-function setWindowFocused(focused: boolean): ISetWindowFocusedAction {
- return {
- type: 'SET_WINDOW_FOCUSED',
- focused,
- };
-}
-
-function setMacOsScrollbarVisibility(
- visibility: MacOsScrollbarVisibility,
-): ISetMacOsScrollbarVisibility {
- return {
- type: 'SET_MACOS_SCROLLBAR_VISIBILITY',
- visibility,
- };
-}
-
-function setConnectedToDaemon(connectedToDaemon: boolean): ISetConnectedToDaemon {
- return {
- type: 'SET_CONNECTED_TO_DAEMON',
- connectedToDaemon,
- };
-}
-
-function setDaemonAllowed(daemonAllowed: boolean): ISetDaemonAllowed {
- return {
- type: 'SET_DAEMON_ALLOWED',
- daemonAllowed,
- };
-}
-
-function setChangelog(changelog: IChangelog): ISetChangelog {
- return {
- type: 'SET_CHANGELOG',
- changelog,
- };
-}
-
-function setForceShowChanges(forceShowChanges: boolean): ISetForceShowChanges {
- return {
- type: 'SET_FORCE_SHOW_CHANGES',
- forceShowChanges,
- };
-}
-
-function setIsPerformingPostUpgrade(isPerformingPostUpgrade: boolean): ISetIsPerformingPostUpgrade {
- return {
- type: 'SET_IS_PERFORMING_POST_UPGRADE',
- isPerformingPostUpgrade,
- };
-}
-
-function setSelectLocationView(selectLocationView: LocationType): ISetSelectLocationView {
- return {
- type: 'SET_SELECT_LOCATION_VIEW',
- selectLocationView,
- };
-}
-
-function setIsMacOs13OrNewer(isMacOs13OrNewer: boolean): ISetIsMacOs13OrNewer {
- return {
- type: 'SET_IS_MACOS13_OR_NEWER',
- isMacOs13OrNewer,
- };
-}
-
-export default {
- updateLocale,
- updateWindowArrowPosition,
- toggleConnectionPanel,
- setWindowFocused,
- setMacOsScrollbarVisibility,
- setConnectedToDaemon,
- setDaemonAllowed,
- setChangelog,
- setForceShowChanges,
- setIsPerformingPostUpgrade,
- setSelectLocationView,
- setIsMacOs13OrNewer,
-};
diff --git a/gui/src/renderer/redux/userinterface/reducers.ts b/gui/src/renderer/redux/userinterface/reducers.ts
deleted file mode 100644
index 89427e9b06..0000000000
--- a/gui/src/renderer/redux/userinterface/reducers.ts
+++ /dev/null
@@ -1,94 +0,0 @@
-import { MacOsScrollbarVisibility } from '../../../shared/ipc-schema';
-import { IChangelog } from '../../../shared/ipc-types';
-import { LocationType } from '../../components/select-location/select-location-types';
-import { ReduxAction } from '../store';
-
-export interface IUserInterfaceReduxState {
- locale: string;
- arrowPosition?: number;
- connectionPanelVisible: boolean;
- windowFocused: boolean;
- macOsScrollbarVisibility?: MacOsScrollbarVisibility;
- connectedToDaemon: boolean;
- daemonAllowed?: boolean;
- changelog: IChangelog;
- forceShowChanges: boolean;
- isPerformingPostUpgrade: boolean;
- selectLocationView: LocationType;
- isMacOs13OrNewer: boolean;
-}
-
-const initialState: IUserInterfaceReduxState = {
- locale: 'en',
- connectionPanelVisible: false,
- windowFocused: false,
- macOsScrollbarVisibility: undefined,
- connectedToDaemon: false,
- daemonAllowed: undefined,
- changelog: [],
- forceShowChanges: false,
- isPerformingPostUpgrade: false,
- selectLocationView: LocationType.exit,
- isMacOs13OrNewer: true,
-};
-
-export default function (
- state: IUserInterfaceReduxState = initialState,
- action: ReduxAction,
-): IUserInterfaceReduxState {
- switch (action.type) {
- case 'UPDATE_LOCALE':
- return { ...state, locale: action.locale };
-
- case 'UPDATE_WINDOW_ARROW_POSITION':
- return { ...state, arrowPosition: action.arrowPosition };
-
- case 'TOGGLE_CONNECTION_PANEL':
- return { ...state, connectionPanelVisible: !state.connectionPanelVisible };
-
- case 'SET_WINDOW_FOCUSED':
- return { ...state, windowFocused: action.focused };
-
- case 'SET_MACOS_SCROLLBAR_VISIBILITY':
- return { ...state, macOsScrollbarVisibility: action.visibility };
-
- case 'SET_CONNECTED_TO_DAEMON':
- return { ...state, connectedToDaemon: action.connectedToDaemon };
-
- case 'SET_DAEMON_ALLOWED':
- return { ...state, daemonAllowed: action.daemonAllowed };
-
- case 'SET_CHANGELOG':
- return {
- ...state,
- changelog: action.changelog,
- };
-
- case 'SET_FORCE_SHOW_CHANGES':
- return {
- ...state,
- forceShowChanges: action.forceShowChanges,
- };
-
- case 'SET_IS_PERFORMING_POST_UPGRADE':
- return {
- ...state,
- isPerformingPostUpgrade: action.isPerformingPostUpgrade,
- };
-
- case 'SET_SELECT_LOCATION_VIEW':
- return {
- ...state,
- selectLocationView: action.selectLocationView,
- };
-
- case 'SET_IS_MACOS13_OR_NEWER':
- return {
- ...state,
- isMacOs13OrNewer: action.isMacOs13OrNewer,
- };
-
- default:
- return state;
- }
-}
diff --git a/gui/src/renderer/redux/version/actions.ts b/gui/src/renderer/redux/version/actions.ts
deleted file mode 100644
index 8b3f1461f4..0000000000
--- a/gui/src/renderer/redux/version/actions.ts
+++ /dev/null
@@ -1,37 +0,0 @@
-import { IAppVersionInfo } from '../../../shared/daemon-rpc-types';
-
-export interface IUpdateLatestAction {
- type: 'UPDATE_LATEST';
- latestInfo: IAppVersionInfo;
-}
-
-export interface IUpdateVersionAction {
- type: 'UPDATE_VERSION';
- version: string;
- consistent: boolean;
- isBeta: boolean;
-}
-
-export type VersionAction = IUpdateLatestAction | IUpdateVersionAction;
-
-function updateLatest(latestInfo: IAppVersionInfo): IUpdateLatestAction {
- return {
- type: 'UPDATE_LATEST',
- latestInfo,
- };
-}
-
-function updateVersion(
- version: string,
- consistent: boolean,
- isBeta: boolean,
-): IUpdateVersionAction {
- return {
- type: 'UPDATE_VERSION',
- version,
- consistent,
- isBeta,
- };
-}
-
-export default { updateLatest, updateVersion };
diff --git a/gui/src/renderer/redux/version/reducers.ts b/gui/src/renderer/redux/version/reducers.ts
deleted file mode 100644
index 1128c82bac..0000000000
--- a/gui/src/renderer/redux/version/reducers.ts
+++ /dev/null
@@ -1,45 +0,0 @@
-import { ReduxAction } from '../store';
-
-export interface IVersionReduxState {
- current: string;
- supported: boolean;
- isBeta: boolean;
- suggestedUpgrade?: string;
- suggestedIsBeta?: boolean;
- consistent: boolean;
-}
-
-const initialState: IVersionReduxState = {
- current: '',
- supported: true,
- isBeta: false,
- suggestedUpgrade: undefined,
- suggestedIsBeta: false,
- consistent: true,
-};
-
-export default function (
- state: IVersionReduxState = initialState,
- action: ReduxAction,
-): IVersionReduxState {
- switch (action.type) {
- case 'UPDATE_LATEST':
- return {
- ...state,
- supported: action.latestInfo.supported,
- suggestedUpgrade: action.latestInfo.suggestedUpgrade,
- suggestedIsBeta: action.latestInfo.suggestedIsBeta,
- };
-
- case 'UPDATE_VERSION':
- return {
- ...state,
- current: action.version,
- consistent: action.consistent,
- isBeta: action.isBeta,
- };
-
- default:
- return state;
- }
-}
diff --git a/gui/src/shared/account-expiry.ts b/gui/src/shared/account-expiry.ts
deleted file mode 100644
index 1b40848220..0000000000
--- a/gui/src/shared/account-expiry.ts
+++ /dev/null
@@ -1,28 +0,0 @@
-import {
- dateByAddingComponent,
- DateComponent,
- DateType,
- FormatDateOptions,
- formatRelativeDate,
-} from './date-helper';
-
-export function hasExpired(expiry: DateType): boolean {
- return new Date(expiry).getTime() < Date.now();
-}
-
-export function closeToExpiry(expiry: DateType, days = 3): boolean {
- return (
- !hasExpired(expiry) &&
- new Date(expiry) <= dateByAddingComponent(new Date(), DateComponent.day, days)
- );
-}
-
-export function formatDate(date: DateType, locale: string): string {
- return new Intl.DateTimeFormat(locale, { dateStyle: 'medium', timeStyle: 'short' }).format(
- new Date(date),
- );
-}
-
-export function formatRemainingTime(expiry: DateType, options?: FormatDateOptions): string {
- return formatRelativeDate(new Date(), expiry, options);
-}
diff --git a/gui/src/shared/application-types.ts b/gui/src/shared/application-types.ts
deleted file mode 100644
index 526d994d7b..0000000000
--- a/gui/src/shared/application-types.ts
+++ /dev/null
@@ -1,60 +0,0 @@
-type Warning = 'launches-in-existing-process' | 'launches-elsewhere';
-
-export interface IApplication {
- absolutepath: string;
- name: string;
- icon?: string;
-}
-
-export interface ISplitTunnelingApplication extends IApplication {
- deletable: boolean;
-}
-
-export interface ILinuxApplication extends IApplication {
- exec: string;
- type: string;
- terminal?: string;
- noDisplay?: string;
- hidden?: string;
- onlyShowIn?: string[];
- notShowIn?: string[];
- tryExec?: string;
-}
-
-export interface ILinuxSplitTunnelingApplication extends ILinuxApplication {
- warning?: Warning;
-}
-
-export interface ISplitTunnelingAppListRetriever {
- /**
- * Returns a list of all applications known to the app.
- * @param updateCaches Specifies if the application list should be fetched again and merged into the existing cache.
- */
- getApplications(
- updateCaches?: boolean,
- ): Promise<{ fromCache: boolean; applications: ISplitTunnelingApplication[] }>;
-
- /**
- * Returns an object containing information about whether or not it was fetched from the cache,
- * and a list of ISplitTunnelingApplication corresponding to the provided paths.
- */
- getMetadataForApplications(
- applicationPaths: string[],
- ): Promise<{ fromCache: boolean; applications: ISplitTunnelingApplication[] }>;
-
- /**
- * Resolves the actual executable path when an app is provided. On Windows this resolves links and
- * on macOS this finds the executable when an application bundle is provided.
- */
- resolveExecutablePath(providedPath: string): Promise<string>;
-
- /**
- * Adds an application to the internal cache.
- */
- addApplicationPathToCache(applicationPath: string): Promise<void>;
-
- /**
- * Removes an application from the internal cache.
- */
- removeApplicationFromCache(application: ISplitTunnelingApplication): void;
-}
diff --git a/gui/src/shared/connect-helper.ts b/gui/src/shared/connect-helper.ts
deleted file mode 100644
index 26128513ed..0000000000
--- a/gui/src/shared/connect-helper.ts
+++ /dev/null
@@ -1,34 +0,0 @@
-import { TunnelState } from './daemon-rpc-types';
-
-export function connectEnabled(
- connectedToDaemon: boolean,
- loggedIn: boolean,
- tunnelState: TunnelState['state'],
-) {
- return (
- connectedToDaemon &&
- loggedIn &&
- (tunnelState === 'disconnected' || tunnelState === 'disconnecting' || tunnelState === 'error')
- );
-}
-
-export function reconnectEnabled(
- connectedToDaemon: boolean,
- loggedIn: boolean,
- tunnelState: TunnelState['state'],
-) {
- return (
- connectedToDaemon &&
- loggedIn &&
- (tunnelState === 'connected' || tunnelState === 'connecting' || tunnelState === 'error')
- );
-}
-
-// Disconnecting while logged out is allowed since it's possible to "connect" and end up in the
-// blocked state with the CLI.
-export function disconnectEnabled(connectedToDaemon: boolean, tunnelState: TunnelState['state']) {
- return (
- connectedToDaemon &&
- (tunnelState === 'connected' || tunnelState === 'connecting' || tunnelState === 'error')
- );
-}
diff --git a/gui/src/shared/daemon-rpc-types.ts b/gui/src/shared/daemon-rpc-types.ts
deleted file mode 100644
index 1522d43b39..0000000000
--- a/gui/src/shared/daemon-rpc-types.ts
+++ /dev/null
@@ -1,626 +0,0 @@
-export interface IAccountData {
- expiry: string;
-}
-
-export type AccountDataError = {
- type: 'error';
- error: 'invalid-account' | 'too-many-devices' | 'list-devices' | 'communication';
-};
-
-export type AccountDataResponse = ({ type: 'success' } & IAccountData) | AccountDataError;
-
-export type AccountNumber = string;
-export type Ip = string;
-export interface ILocation {
- ipv4?: string;
- ipv6?: string;
- country: string;
- city?: string;
- latitude: number;
- longitude: number;
- mullvadExitIp: boolean;
- hostname?: string;
- bridgeHostname?: string;
- entryHostname?: string;
- provider?: string;
-}
-
-export enum FirewallPolicyErrorType {
- generic,
- locked,
-}
-
-export type FirewallPolicyError =
- | { type: FirewallPolicyErrorType.generic }
- | {
- type: FirewallPolicyErrorType.locked;
- name: string;
- pid: number;
- };
-
-export enum ErrorStateCause {
- authFailed,
- ipv6Unavailable,
- setFirewallPolicyError,
- setDnsError,
- startTunnelError,
- createTunnelDeviceError,
- tunnelParameterError,
- isOffline,
- splitTunnelError,
- needFullDiskPermissions,
-}
-
-export enum AuthFailedError {
- unknown,
- invalidAccount,
- expiredAccount,
- tooManyConnections,
-}
-
-export enum TunnelParameterError {
- noMatchingRelay,
- noMatchingBridgeRelay,
- noWireguardKey,
- customTunnelHostResolutionError,
-}
-
-export type ErrorStateDetails =
- | {
- cause:
- | ErrorStateCause.ipv6Unavailable
- | ErrorStateCause.setDnsError
- | ErrorStateCause.startTunnelError
- | ErrorStateCause.isOffline
- | ErrorStateCause.splitTunnelError
- | ErrorStateCause.needFullDiskPermissions;
- blockingError?: FirewallPolicyError;
- }
- | {
- cause: ErrorStateCause.authFailed;
- blockingError?: FirewallPolicyError;
- authFailedError: AuthFailedError;
- }
- | {
- cause: ErrorStateCause.createTunnelDeviceError;
- blockingError?: FirewallPolicyError;
- osError?: number;
- }
- | {
- cause: ErrorStateCause.tunnelParameterError;
- blockingError?: FirewallPolicyError;
- parameterError: TunnelParameterError;
- }
- | {
- cause: ErrorStateCause.setFirewallPolicyError;
- blockingError?: FirewallPolicyError;
- policyError: FirewallPolicyError;
- };
-
-export type AfterDisconnect = 'nothing' | 'block' | 'reconnect';
-
-export type TunnelType = 'any' | 'wireguard' | 'openvpn';
-export function tunnelTypeToString(tunnel: TunnelType): string {
- switch (tunnel) {
- case 'wireguard':
- return 'WireGuard';
- case 'openvpn':
- return 'OpenVPN';
- case 'any':
- return '';
- }
-}
-
-export type RelayProtocol = 'tcp' | 'udp';
-export type EndpointObfuscationType = 'udp2tcp' | 'shadowsocks';
-
-export type Constraint<T> = 'any' | { only: T };
-export type LiftedConstraint<T> = 'any' | T;
-
-export function liftConstraint<T>(constraint: Constraint<T>): LiftedConstraint<T> {
- return constraint === 'any' ? constraint : constraint.only;
-}
-export function wrapConstraint<T>(
- constraint: LiftedConstraint<T> | undefined | null,
-): Constraint<T> {
- if (constraint) {
- return constraint === 'any' ? 'any' : { only: constraint };
- }
- return 'any';
-}
-
-export type ProxyType = 'shadowsocks' | 'custom';
-
-export enum Ownership {
- any,
- mullvadOwned,
- rented,
-}
-
-export interface ITunnelEndpoint {
- address: string;
- protocol: RelayProtocol;
- tunnelType: TunnelType;
- quantumResistant: boolean;
- proxy?: IProxyEndpoint;
- obfuscationEndpoint?: IObfuscationEndpoint;
- entryEndpoint?: IEndpoint;
- daita: boolean;
-}
-
-export interface IEndpoint {
- address: string;
- transportProtocol: RelayProtocol;
-}
-
-export interface IObfuscationEndpoint {
- address: string;
- port: number;
- protocol: RelayProtocol;
- obfuscationType: EndpointObfuscationType;
-}
-
-export interface IProxyEndpoint {
- address: string;
- protocol: RelayProtocol;
- proxyType: ProxyType;
-}
-
-export type DaemonEvent =
- | { tunnelState: TunnelState }
- | { settings: ISettings }
- | { relayList: IRelayListWithEndpointData }
- | { appVersionInfo: IAppVersionInfo }
- | { device: DeviceEvent }
- | { deviceRemoval: Array<IDevice> }
- | { accessMethodSetting: AccessMethodSetting };
-
-export interface ITunnelStateRelayInfo {
- endpoint: ITunnelEndpoint;
- location?: ILocation;
-}
-
-// The order of the variants match the priority order and can be sorted on.
-export enum FeatureIndicator {
- daita,
- quantumResistance,
- multihop,
- bridgeMode,
- splitTunneling,
- lockdownMode,
- udp2tcp,
- shadowsocks,
- lanSharing,
- dnsContentBlockers,
- customDns,
- serverIpOverride,
- customMtu,
- customMssFix,
-}
-
-export type DisconnectedState = { state: 'disconnected'; location?: Partial<ILocation> };
-export type ConnectingState = {
- state: 'connecting';
- details?: ITunnelStateRelayInfo;
- featureIndicators?: Array<FeatureIndicator>;
-};
-export type ConnectedState = {
- state: 'connected';
- details: ITunnelStateRelayInfo;
- featureIndicators?: Array<FeatureIndicator>;
-};
-export type DisconnectingState = {
- state: 'disconnecting';
- details: AfterDisconnect;
- location?: Partial<ILocation>;
-};
-export type ErrorState = { state: 'error'; details: ErrorStateDetails };
-
-export type TunnelState =
- | DisconnectedState
- | ConnectingState
- | ConnectedState
- | DisconnectingState
- | ErrorState;
-
-export interface RelayLocationCountry extends Partial<RelayLocationCustomList> {
- country: string;
-}
-
-export interface RelayLocationCity extends RelayLocationCountry {
- city: string;
-}
-
-export interface RelayLocationRelay extends RelayLocationCity {
- hostname: string;
-}
-
-export interface RelayLocationCustomList {
- customList: string;
-}
-
-export type RelayLocationGeographical =
- | RelayLocationRelay
- | RelayLocationCountry
- | RelayLocationCity;
-
-export type RelayLocation = RelayLocationGeographical | RelayLocationCustomList;
-
-export interface IOpenVpnConstraints {
- port: Constraint<number>;
- protocol: Constraint<RelayProtocol>;
-}
-
-export interface IWireguardConstraints {
- port: Constraint<number>;
- ipVersion: Constraint<IpVersion>;
- useMultihop: boolean;
- entryLocation: Constraint<RelayLocation>;
-}
-
-export type TunnelProtocol = 'wireguard' | 'openvpn';
-
-export type IpVersion = 'ipv4' | 'ipv6';
-
-export interface IRelaySettingsNormal<OpenVpn, Wireguard> {
- location: Constraint<RelayLocation>;
- tunnelProtocol: Constraint<TunnelProtocol>;
- providers: string[];
- ownership: Ownership;
- openvpnConstraints: OpenVpn;
- wireguardConstraints: Wireguard;
-}
-
-export type ConnectionConfig =
- | {
- openvpn: {
- endpoint: {
- ip: string;
- port: number;
- protocol: RelayProtocol;
- };
- username: string;
- };
- }
- | {
- wireguard: {
- tunnel: {
- privateKey: string;
- addresses: string[];
- };
- peer: {
- publicKey: string;
- addresses: string[];
- endpoint: string;
- };
- ipv4Gateway: string;
- ipv6Gateway?: string;
- };
- };
-
-// types describing the structure of RelaySettings
-export interface IRelaySettingsCustom {
- host: string;
- config: ConnectionConfig;
-}
-export type RelaySettings =
- | {
- normal: IRelaySettingsNormal<IOpenVpnConstraints, IWireguardConstraints>;
- }
- | {
- customTunnelEndpoint: IRelaySettingsCustom;
- };
-
-export interface IRelayListWithEndpointData {
- relayList: IRelayList;
- wireguardEndpointData: IWireguardEndpointData;
-}
-
-export interface IRelayList {
- countries: IRelayListCountry[];
-}
-
-export interface IWireguardEndpointData {
- portRanges: [number, number][];
- udp2tcpPorts: number[];
-}
-
-export interface IRelayListCountry {
- name: string;
- code: string;
- cities: IRelayListCity[];
-}
-
-export interface IRelayListCity {
- name: string;
- code: string;
- latitude: number;
- longitude: number;
- relays: IRelayListHostname[];
-}
-
-export interface IRelayListHostname {
- hostname: string;
- provider: string;
- ipv4AddrIn: string;
- includeInCountry: boolean;
- active: boolean;
- weight: number;
- owned: boolean;
- endpointType: RelayEndpointType;
- daita: boolean;
-}
-
-export type RelayEndpointType = 'wireguard' | 'openvpn' | 'bridge';
-
-export interface ITunnelOptions {
- openvpn: {
- mssfix?: number;
- };
- wireguard: {
- mtu?: number;
- quantumResistant?: boolean;
- daita?: IDaitaSettings;
- };
- generic: {
- enableIpv6: boolean;
- };
- dns: IDnsOptions;
-}
-
-export interface IDnsOptions {
- state: 'custom' | 'default';
- customOptions: {
- addresses: string[];
- };
- defaultOptions: {
- blockAds: boolean;
- blockTrackers: boolean;
- blockMalware: boolean;
- blockAdultContent: boolean;
- blockGambling: boolean;
- blockSocialMedia: boolean;
- };
-}
-
-export interface IAppVersionInfo {
- supported: boolean;
- suggestedUpgrade?: string;
- suggestedIsBeta?: boolean;
-}
-
-export interface IAccountAndDevice {
- accountNumber: AccountNumber;
- device?: IDevice;
-}
-
-export type LoggedInDeviceState = { type: 'logged in'; accountAndDevice: IAccountAndDevice };
-export type LoggedOutDeviceState = { type: 'logged out' | 'revoked' };
-
-export type DeviceState = LoggedInDeviceState | LoggedOutDeviceState;
-
-export type DeviceEvent =
- | { type: 'logged in' | 'updated' | 'rotated_key'; deviceState: LoggedInDeviceState }
- | { type: 'logged out' | 'revoked'; deviceState: LoggedOutDeviceState };
-
-export interface IDevice {
- id: string;
- name: string;
- created: Date;
-}
-
-export interface IDeviceRemoval {
- accountNumber: string;
- deviceId: string;
-}
-
-export type CustomLists = Array<ICustomList>;
-
-export interface ICustomList {
- id: string;
- name: string;
- locations: Array<RelayLocationGeographical>;
-}
-
-export type CustomListError = { type: 'name already exists' };
-
-export interface ISettings {
- allowLan: boolean;
- autoConnect: boolean;
- blockWhenDisconnected: boolean;
- showBetaReleases: boolean;
- relaySettings: RelaySettings;
- tunnelOptions: ITunnelOptions;
- bridgeSettings: BridgeSettings;
- bridgeState: BridgeState;
- splitTunnel: SplitTunnelSettings;
- obfuscationSettings: ObfuscationSettings;
- customLists: CustomLists;
- apiAccessMethods: ApiAccessMethodSettings;
- relayOverrides: Array<RelayOverride>;
-}
-
-export type BridgeState = 'auto' | 'on' | 'off';
-
-export type SplitTunnelSettings = {
- enableExclusions: boolean;
- appsList: string[];
-};
-
-export type Udp2TcpObfuscationSettings = {
- port: Constraint<number>;
-};
-
-export type ShadowsocksSettings = {
- port: Constraint<number>;
-};
-
-export enum ObfuscationType {
- auto,
- off,
- udp2tcp,
- shadowsocks,
-}
-
-export type ObfuscationSettings = {
- selectedObfuscation: ObfuscationType;
- udp2tcpSettings: Udp2TcpObfuscationSettings;
- shadowsocksSettings: ShadowsocksSettings;
-};
-
-export interface IBridgeConstraints {
- location: Constraint<RelayLocation>;
- providers: string[];
- ownership: Ownership;
-}
-
-export type BridgeType = 'normal' | 'custom';
-
-export interface BridgeSettings {
- type: BridgeType;
- normal: IBridgeConstraints;
- custom?: CustomProxy;
-}
-
-export interface ISocketAddress {
- host: string;
- port: number;
-}
-
-export type VoucherResponse =
- | { type: 'success'; newExpiry: string; secondsAdded: number }
- | { type: 'invalid' | 'already_used' | 'error' };
-
-export interface SocksAuth {
- username: string;
- password: string;
-}
-
-export type Socks5LocalCustomProxy = {
- type: 'socks5-local';
- remoteIp: string;
- remotePort: number;
- remoteTransportProtocol: RelayProtocol;
- localPort: number;
-};
-
-export type Socks5RemoteCustomProxy = {
- type: 'socks5-remote';
- ip: string;
- port: number;
- authentication?: SocksAuth;
-};
-
-export type ShadowsocksCustomProxy = {
- type: 'shadowsocks';
- ip: string;
- port: number;
- password: string;
- cipher: string;
-};
-
-export type CustomProxy = Socks5LocalCustomProxy | Socks5RemoteCustomProxy | ShadowsocksCustomProxy;
-export type NamedCustomProxy = CustomProxy & { name: string };
-
-export type DirectMethod = { type: 'direct' };
-export type BridgesMethod = { type: 'bridges' };
-export type EncryptedDnsProxy = { type: 'encrypted-dns-proxy' };
-export type AccessMethod = DirectMethod | BridgesMethod | EncryptedDnsProxy | CustomProxy;
-
-export type NamedAccessMethod<T extends AccessMethod> = T & { name: string };
-
-export type NewAccessMethodSetting<T extends AccessMethod = AccessMethod> = NamedAccessMethod<T> & {
- enabled: boolean;
-};
-
-export type AccessMethodSetting<T extends AccessMethod = AccessMethod> =
- NewAccessMethodSetting<T> & {
- id: string;
- };
-
-export type ApiAccessMethodSettings = {
- direct: AccessMethodSetting<DirectMethod>;
- mullvadBridges: AccessMethodSetting<BridgesMethod>;
- encryptedDnsProxy: AccessMethodSetting<EncryptedDnsProxy>;
- custom: Array<AccessMethodSetting<CustomProxy>>;
-};
-
-export interface RelayOverride {
- hostname: string;
- ipv4AddrIn?: string;
- ipv6AddrIn?: string;
-}
-
-export interface IDaitaSettings {
- enabled: boolean;
- directOnly: boolean;
-}
-
-export function parseSocketAddress(socketAddrStr: string): ISocketAddress {
- const re = new RegExp(/(.+):(\d+)$/);
- const matches = socketAddrStr.match(re);
-
- if (!matches || matches.length < 3) {
- throw new Error(`Failed to parse socket address from address string '${socketAddrStr}'`);
- }
- const socketAddress: ISocketAddress = {
- host: matches[1],
- port: Number(matches[2]),
- };
- return socketAddress;
-}
-
-export function compareRelayLocationCount(lhs: RelayLocation, rhs: RelayLocation): boolean {
- if (
- ('count' in lhs || 'count' in rhs) &&
- !('count' in lhs && 'count' in rhs && lhs.count === rhs.count)
- ) {
- return false;
- }
-
- return compareRelayLocation(lhs, rhs);
-}
-
-export function compareRelayLocation(lhs: RelayLocation, rhs: RelayLocation): boolean {
- if (
- ('customList' in lhs || 'customList' in rhs) &&
- !('customList' in lhs && 'customList' in rhs && lhs.customList === rhs.customList)
- ) {
- return false;
- }
-
- return compareRelayLocationGeographical(lhs, rhs);
-}
-
-export function compareRelayLocationGeographical(lhs: RelayLocation, rhs: RelayLocation): boolean {
- if (
- ('country' in lhs || 'country' in rhs) &&
- !('country' in lhs && 'country' in rhs && lhs.country === rhs.country)
- ) {
- return false;
- }
-
- if (
- ('city' in lhs || 'city' in rhs) &&
- !('city' in lhs && 'city' in rhs && lhs.city === rhs.city)
- ) {
- return false;
- }
-
- if (
- ('hostname' in lhs || 'hostname' in rhs) &&
- !('hostname' in lhs && 'hostname' in rhs && lhs.hostname === rhs.hostname)
- ) {
- return false;
- }
-
- return true;
-}
-
-export function compareRelayLocationLoose(lhs?: RelayLocation, rhs?: RelayLocation) {
- if (lhs && rhs) {
- return compareRelayLocation(lhs, rhs);
- } else {
- return lhs === rhs;
- }
-}
diff --git a/gui/src/shared/date-helper.ts b/gui/src/shared/date-helper.ts
deleted file mode 100644
index be83473cfa..0000000000
--- a/gui/src/shared/date-helper.ts
+++ /dev/null
@@ -1,125 +0,0 @@
-import { sprintf } from 'sprintf-js';
-
-import { messages } from './gettext';
-import { capitalize } from './string-helpers';
-
-export type DateType = Date | string | number;
-
-export enum DateComponent {
- day,
- hour,
- minute,
-}
-
-export function dateByAddingComponent(date: DateType, component: DateComponent, value: number) {
- const modifiedDate = new Date(date);
- switch (component) {
- case DateComponent.day:
- modifiedDate.setDate(modifiedDate.getDate() + value);
- break;
- case DateComponent.hour:
- modifiedDate.setHours(modifiedDate.getHours() + value);
- break;
- case DateComponent.minute:
- modifiedDate.setMinutes(modifiedDate.getMinutes() + value);
- break;
- }
-
- return modifiedDate;
-}
-
-export class DateDiff {
- private readonly fromDate: Date;
- private readonly toDate: Date;
-
- public constructor(fromDate: DateType, toDate: DateType) {
- this.fromDate = new Date(fromDate);
- this.toDate = new Date(toDate);
- }
-
- get milliseconds(): number {
- return this.toDate.getTime() - this.fromDate.getTime();
- }
-
- get seconds(): number {
- return this.floor(this.milliseconds / 1000);
- }
-
- get minutes(): number {
- return this.floor(this.seconds / 60);
- }
-
- get hours(): number {
- return this.floor(this.minutes / 60);
- }
-
- get days(): number {
- return this.floor(this.hours / 24);
- }
-
- get months(): number {
- const months = new Date(Math.abs(this.milliseconds)).getUTCMonth();
- const monthsWithSign = this.milliseconds >= 0 ? months : -months;
- return this.years * 12 + monthsWithSign;
- }
-
- get years(): number {
- const years = new Date(Math.abs(this.milliseconds)).getUTCFullYear() - 1970;
- return this.milliseconds >= 0 ? years : -years;
- }
-
- private floor(n: number): number {
- return n >= 0 ? Math.floor(n) : Math.ceil(n);
- }
-}
-
-export interface FormatDateOptions {
- suffix?: boolean;
- displayMonths?: boolean;
- capitalize?: boolean;
-}
-
-// If withSuffix is true then "left" will be added at the end of the remaining time.
-// If noMonths is true then the following applies:
-// If a user has more than 2 years (730 days) left of time it should be displayed in whole years
-// rounded down If a user has less than 2 years left (e.g. 729 days) then this should be displayed
-// in days.
-export function formatRelativeDate(
- fromDate: DateType,
- toDate: DateType,
- options?: FormatDateOptions,
-): string {
- const diff = new DateDiff(fromDate, toDate);
- const years = Math.abs(diff.years);
- const months = Math.abs(diff.months);
- const days = Math.abs(diff.days);
-
- if (isNaN(years) || isNaN(months) || isNaN(days)) {
- return '';
- }
-
- let result = '';
- if (!options?.suffix) {
- if (options?.displayMonths ? years > 0 : days >= 730) {
- result = sprintf(messages.ngettext('1 year', '%d years', years), years);
- } else if (options?.displayMonths && months >= 3) {
- result = sprintf(messages.ngettext('1 month', '%d months', months), months);
- } else if (days > 0) {
- result = sprintf(messages.ngettext('1 day', '%d days', days), days);
- } else {
- result = messages.gettext('less than a day');
- }
- } else if (diff.milliseconds > 0) {
- if (options?.displayMonths ? years > 0 : days >= 730) {
- result = sprintf(messages.ngettext('1 year left', '%d years left', years), years);
- } else if (options?.displayMonths && months >= 3) {
- result = sprintf(messages.ngettext('1 month left', '%d months left', months), months);
- } else if (days > 0) {
- result = sprintf(messages.ngettext('1 day left', '%d days left', days), days);
- } else {
- result = messages.gettext('less than a day left');
- }
- }
-
- return options?.capitalize ? capitalize(result) : result;
-}
diff --git a/gui/src/shared/gettext.ts b/gui/src/shared/gettext.ts
deleted file mode 100644
index 90391107e0..0000000000
--- a/gui/src/shared/gettext.ts
+++ /dev/null
@@ -1,32 +0,0 @@
-import Gettext from 'node-gettext';
-
-import { LocalizationContexts } from './localization-contexts';
-import log from './logging';
-
-const SOURCE_LANGUAGE = 'en';
-
-function setErrorHandler(catalogue: Gettext) {
- catalogue.on('error', (error) => {
- log.debug(`Gettext error: ${error.message}`);
- });
-}
-
-const gettextOptions = { sourceLocale: SOURCE_LANGUAGE };
-
-declare class GettextWithAppContexts extends Gettext {
- pgettext(msgctxt: LocalizationContexts, msgid: string): string;
- npgettext(
- msgctxt: LocalizationContexts,
- msgid: string,
- msgidPlural: string,
- count: number,
- ): string;
-}
-
-export const messages = new Gettext(gettextOptions) as GettextWithAppContexts;
-messages.setTextDomain('messages');
-setErrorHandler(messages);
-
-export const relayLocations = new Gettext(gettextOptions);
-relayLocations.setTextDomain('relay-locations');
-setErrorHandler(relayLocations);
diff --git a/gui/src/shared/gui-settings-state.ts b/gui/src/shared/gui-settings-state.ts
deleted file mode 100644
index 68e958324a..0000000000
--- a/gui/src/shared/gui-settings-state.ts
+++ /dev/null
@@ -1,37 +0,0 @@
-// This is a special value which is when contained within IGuiSettingsState.preferredLocale
-// indicates that app should use the active operating system locale to determine the UI language.
-export const SYSTEM_PREFERRED_LOCALE_KEY = 'system';
-
-export interface IGuiSettingsState {
- // A user interface locale.
- // Use 'system' to opt-in for active locale set in the operating system
- // (see SYSTEM_PREFERRED_LOCALE_KEY)
- preferredLocale: string;
-
- // Enable or disable system notifications on tunnel state etc.
- enableSystemNotifications: boolean;
-
- // Tells the app to activate auto-connect feature in the mullvad-daemon, but only if the app is
- // set to auto-start with the system.
- autoConnect: boolean;
-
- // Tells the app to use monochromatic set of icons for tray.
- monochromaticIcon: boolean;
-
- // Tells the app to hide the main window on start.
- startMinimized: boolean;
-
- // Tells the app whether or not it should act as a window or a context menu.
- unpinnedWindow: boolean;
-
- // Contains a list of filepaths to applications added to the list of applications, in the split
- // tunneling view, by the user.
- browsedForSplitTunnelingApplications: Array<string>;
-
- // The last version that the changelog dialog was shown for. This is used to only show the
- // changelog after upgrade.
- changelogDisplayedForVersion: string;
-
- // Tells the app whether or not to show the map in the main view.
- animateMap: boolean;
-}
diff --git a/gui/src/shared/ipc-helpers.ts b/gui/src/shared/ipc-helpers.ts
deleted file mode 100644
index c49b7df9b8..0000000000
--- a/gui/src/shared/ipc-helpers.ts
+++ /dev/null
@@ -1,199 +0,0 @@
-import { IpcMain as EIpcMain, IpcRenderer as EIpcRenderer, WebContents } from 'electron';
-
-import log from './logging';
-import { capitalize } from './string-helpers';
-
-type Handler<T, R> = (callback: (arg: T) => R) => void;
-type Sender<T, R> = (arg: T) => R;
-type Notifier<T> = ((arg: T) => void) | undefined;
-type Listener<T> = (callback: (arg: T) => void) => () => void;
-
-interface MainToRenderer<T> {
- direction: 'main-to-renderer';
- send: (event: string, webContents: WebContents) => Notifier<T>;
- receive: (event: string, ipcRenderer: EIpcRenderer) => Listener<T>;
-}
-
-interface RendererToMain<T, R> {
- direction: 'renderer-to-main';
- send: (event: string, ipcRenderer: EIpcRenderer) => Sender<T, R>;
- receive: (event: string, ipcMain: EIpcMain) => Handler<T, R>;
-}
-
-// eslint-disable-next-line @typescript-eslint/no-explicit-any
-type AnyIpcCall = MainToRenderer<any> | RendererToMain<any, any>;
-
-export type Schema = Record<string, Record<string, AnyIpcCall>>;
-
-// Renames all IPC calls, e.g. `callName` to either `notifyCallName` or `handleCallName` depending
-// on direction.
-type IpcMainKey<N extends string, I extends AnyIpcCall> = I['direction'] extends 'main-to-renderer'
- ? `notify${Capitalize<N>}`
- : `handle${Capitalize<N>}`;
-
-// Selects either the send or receive function depending on direction.
-type IpcMainFn<I extends AnyIpcCall> = I['direction'] extends 'main-to-renderer'
- ? ReturnType<I['send']>
- : ReturnType<I['receive']>;
-
-// Renames all receiving IPC calls, e.g. `callName` to `listenCallName`.
-type IpcRendererKey<
- N extends string,
- I extends AnyIpcCall,
-> = I['direction'] extends 'main-to-renderer' ? `listen${Capitalize<N>}` : N;
-
-// Selects either the send or receive function depending on direction.
-type IpcRendererFn<I extends AnyIpcCall> = I['direction'] extends 'main-to-renderer'
- ? ReturnType<I['receive']>
- : ReturnType<I['send']>;
-
-// Transforms the provided schema to the correct type for the main event channel.
-export type IpcMain<S extends Schema> = {
- [G in keyof S]: {
- [K in keyof S[G] as IpcMainKey<string & K, S[G][K]>]: IpcMainFn<S[G][K]>;
- };
-};
-
-// Transforms the provided schema to the correct type for the renderer event channel.
-export type IpcRenderer<S extends Schema> = {
- [G in keyof S]: {
- [K in keyof S[G] as IpcRendererKey<string & K, S[G][K]>]: IpcRendererFn<S[G][K]>;
- };
-};
-
-// Preforms the transformation of the main event channel in accordance with the above types.
-export function createIpcMain<S extends Schema>(
- schema: S,
- ipcMain: EIpcMain,
- webContents: WebContents | undefined,
-): IpcMain<S> {
- return createIpc(schema, (event, key, spec) => {
- const capitalizedKey = capitalize(key);
- const newKey =
- spec.direction === 'main-to-renderer' ? `notify${capitalizedKey}` : `handle${capitalizedKey}`;
-
- let newValue;
- if (spec.direction === 'main-to-renderer') {
- newValue = webContents ? spec.send(event, webContents) : undefined;
- } else {
- newValue = spec.receive(event, ipcMain);
- }
-
- return [newKey, newValue];
- });
-}
-
-// Preforms the transformation of the renderer event channel in accordance with the above types.
-export function createIpcRenderer<S extends Schema>(
- schema: S,
- ipcRenderer: EIpcRenderer,
-): IpcRenderer<S> {
- return createIpc(schema, (event, key, spec) => {
- const newKey = spec.direction === 'main-to-renderer' ? `listen${capitalize(key)}` : key;
- const newValue =
- spec.direction === 'main-to-renderer'
- ? spec.receive(event, ipcRenderer)
- : spec.send(event, ipcRenderer);
-
- return [newKey, newValue];
- });
-}
-
-function createIpc<S extends Schema, T, R extends IpcMain<S> | IpcRenderer<S>>(
- ipc: S,
- fn: (event: string, key: string, spec: AnyIpcCall) => [newKey: string, newValue: T],
-): R {
- return Object.fromEntries(
- Object.entries(ipc).map(([groupKey, group]) => {
- const newGroup = Object.fromEntries(
- Object.entries(group).map(([key, spec]) => fn(`${groupKey}-${key}`, key, spec)),
- );
- return [groupKey, newGroup];
- }),
- ) as R;
-}
-
-// Sends a request from the renderer process to the main process without any possibility to respond.
-export function send<T>(): RendererToMain<T, void> {
- return {
- direction: 'renderer-to-main',
- send: (event, ipcRenderer) => (newValue: T) => ipcRenderer.send(event, newValue),
- receive: (event, ipcMain) => (handlerFn: (value: T) => void) => {
- ipcMain.on(event, (_event, newValue: T) => {
- handlerFn(newValue);
- });
- },
- };
-}
-
-// Sends a synchronous request from the renderer process to the main process.
-export function invokeSync<T, R>(): RendererToMain<T, R> {
- return {
- direction: 'renderer-to-main',
- send: (event, ipcRenderer) => (newValue: T) => ipcRenderer.sendSync(event, newValue),
- receive: (event, ipcMain) => (handlerFn: (value: T) => R) => {
- ipcMain.on(event, (ipcEvent, newValue: T) => {
- ipcEvent.returnValue = handlerFn(newValue);
- });
- },
- };
-}
-
-// Sends an asynchronous request from the renderer process to the main process.
-export function invoke<T, R>(): RendererToMain<T, Promise<R>> {
- return {
- direction: 'renderer-to-main',
- send: invokeImpl,
- receive: handle,
- };
-}
-
-// Sends a request from the main process to the renderer process without any possibility to respond.
-export function notifyRenderer<T>(): MainToRenderer<T> {
- return {
- direction: 'main-to-renderer',
- send: notifyRendererImpl,
- receive: (event, ipcRenderer) => (fn: (value: T) => void) => {
- const listener = (_event: unknown, newState: T) => fn(newState);
- ipcRenderer.on(event, listener);
- return () => ipcRenderer.off(event, listener);
- },
- };
-}
-
-function notifyRendererImpl<T>(event: string, webContents: WebContents): Notifier<T> {
- return (value) => {
- if (webContents === undefined || webContents.isDestroyed() || webContents.isCrashed()) {
- log.error(`sender(${event}): webContents is already destroyed!`);
- } else {
- webContents.send(event, value);
- }
- };
-}
-
-type RequestResult<T> = { type: 'success'; value: T } | { type: 'error'; message: string };
-
-function invokeImpl<T, R>(event: string, ipcRenderer: EIpcRenderer): Sender<T, Promise<R>> {
- return async (arg: T): Promise<R> => {
- const result: RequestResult<R> = await ipcRenderer.invoke(event, arg);
- switch (result.type) {
- case 'error':
- throw new Error(result.message);
- case 'success':
- return result.value;
- }
- };
-}
-
-function handle<T, R>(event: string, ipcMain: EIpcMain): Handler<T, Promise<R>> {
- return (fn: (arg: T) => Promise<R>) => {
- ipcMain.handle(event, async (_ipcEvent, arg: T) => {
- try {
- return { type: 'success', value: await fn(arg) };
- } catch (e) {
- const error = e as Error;
- return { type: 'error', message: error.message || '' };
- }
- });
- };
-}
diff --git a/gui/src/shared/ipc-schema.ts b/gui/src/shared/ipc-schema.ts
deleted file mode 100644
index a2282e2849..0000000000
--- a/gui/src/shared/ipc-schema.ts
+++ /dev/null
@@ -1,257 +0,0 @@
-import { GetTextTranslations } from 'gettext-parser';
-
-import { ILinuxSplitTunnelingApplication, ISplitTunnelingApplication } from './application-types';
-import {
- AccessMethodSetting,
- AccountDataError,
- AccountNumber,
- BridgeSettings,
- BridgeState,
- CustomListError,
- CustomProxy,
- DeviceEvent,
- DeviceState,
- IAccountData,
- IAppVersionInfo,
- ICustomList,
- IDevice,
- IDeviceRemoval,
- IDnsOptions,
- IRelayListWithEndpointData,
- ISettings,
- NewAccessMethodSetting,
- ObfuscationSettings,
- RelaySettings,
- TunnelState,
- VoucherResponse,
-} from './daemon-rpc-types';
-import { IGuiSettingsState } from './gui-settings-state';
-import { LogLevel } from './logging-types';
-
-interface ILogEntry {
- level: LogLevel;
- message: string;
-}
-import { MapData } from '../renderer/lib/3dmap';
-import { invoke, invokeSync, notifyRenderer, send } from './ipc-helpers';
-import {
- IChangelog,
- ICurrentAppVersionInfo,
- IHistoryObject,
- IWindowShapeParameters,
-} from './ipc-types';
-
-export interface ITranslations {
- locale: string;
- messages?: GetTextTranslations;
- relayLocations?: GetTextTranslations;
-}
-
-export type LaunchApplicationResult = { success: true } | { error: string };
-
-export enum MacOsScrollbarVisibility {
- always,
- whenScrolling,
- automatic,
-}
-
-export interface IAppStateSnapshot {
- isConnected: boolean;
- autoStart: boolean;
- accountData?: IAccountData;
- accountHistory?: AccountNumber;
- tunnelState: TunnelState;
- settings: ISettings;
- isPerformingPostUpgrade: boolean;
- daemonAllowed?: boolean;
- deviceState?: DeviceState;
- relayList?: IRelayListWithEndpointData;
- currentVersion: ICurrentAppVersionInfo;
- upgradeVersion: IAppVersionInfo;
- guiSettings: IGuiSettingsState;
- translations: ITranslations;
- splitTunnelingApplications?: ISplitTunnelingApplication[];
- macOsScrollbarVisibility?: MacOsScrollbarVisibility;
- changelog: IChangelog;
- forceShowChanges: boolean;
- navigationHistory?: IHistoryObject;
- currentApiAccessMethod?: AccessMethodSetting;
- isMacOs13OrNewer: boolean;
-}
-
-// The different types of requests are:
-// * send<ArgumentType>(), which is used for one-way communication from the renderer process to the
-// main process. The main channel will have a property named 'handle<PropertyName>' and the
-// renderer will have a property named the same as the one specified.
-// * invoke<ArgumentType, ReturnType>(), which is used for two-way communication from the renderer
-// process to the main process. The naming is the same as `send<A>()`.
-// * invokeSync<ArgumentType, ReturnType>(), same as `invoke<A, R>()` but synchronous.
-// * notifyRenderer<ArgumentType>(), which is used for one-way communication from the main process
-// to the renderer process. The renderer ipc channel will have a property named
-// `listen<PropertyName>` and the main channel will have a property named `notify<PropertyName>`.
-//
-// Example:
-// const ipc = {
-// groupOfCalls: {
-// first: send<boolean>(),
-// second: request<boolean, number>(),
-// third: requestSync<boolean, number>(),
-// fourth: notifyRenderer<boolean>(),
-// },
-// };
-//
-// createIpcMain(ipc)
-// => {
-// groupOfCalls: {
-// handleFirst: (fn: (arg: boolean) => void) => void,
-// handleSecond: (fn: (arg: boolean) => Promise<number>) => void,
-// handleThird: (fn: (arg: boolean) => number) => void,
-// notifyFourth: (arg: boolean) => void,
-// },
-//
-// createIpcRenderer(ipc)
-// => {
-// groupOfCalls: {
-// first: (arg: boolean) => void,
-// second: (arg: boolean) => Promise<number>,
-// third: (arg: boolean) => number,
-// listenFourth: (fn: (arg: boolean) => void) => void,
-// },
-// }
-export const ipcSchema = {
- state: {
- get: invokeSync<void, IAppStateSnapshot>(),
- },
- map: {
- getData: invoke<void, MapData>(),
- },
- window: {
- shape: notifyRenderer<IWindowShapeParameters>(),
- focus: notifyRenderer<boolean>(),
- macOsScrollbarVisibility: notifyRenderer<MacOsScrollbarVisibility>(),
- scaleFactorChange: notifyRenderer<void>(),
- },
- navigation: {
- reset: notifyRenderer<void>(),
- setHistory: send<IHistoryObject>(),
- },
- daemon: {
- isPerformingPostUpgrade: notifyRenderer<boolean>(),
- daemonAllowed: notifyRenderer<boolean>(),
- connected: notifyRenderer<void>(),
- disconnected: notifyRenderer<void>(),
- },
- relays: {
- '': notifyRenderer<IRelayListWithEndpointData>(),
- },
- customLists: {
- createCustomList: invoke<string, void | CustomListError>(),
- deleteCustomList: invoke<string, void>(),
- updateCustomList: invoke<ICustomList, void | CustomListError>(),
- },
- currentVersion: {
- '': notifyRenderer<ICurrentAppVersionInfo>(),
- displayedChangelog: send<void>(),
- },
- upgradeVersion: {
- '': notifyRenderer<IAppVersionInfo>(),
- },
- app: {
- quit: send<void>(),
- openUrl: invoke<string, void>(),
- showOpenDialog: invoke<Electron.OpenDialogOptions, Electron.OpenDialogReturnValue>(),
- showLaunchDaemonSettings: invoke<void, void>(),
- showFullDiskAccessSettings: invoke<void, void>(),
- getPathBaseName: invoke<string, string>(),
- },
- tunnel: {
- '': notifyRenderer<TunnelState>(),
- connect: invoke<void, void>(),
- disconnect: invoke<void, void>(),
- reconnect: invoke<void, void>(),
- },
- settings: {
- '': notifyRenderer<ISettings>(),
- importFile: invoke<string, void>(),
- importText: invoke<string, void>(),
- apiAccessMethodSettingChange: notifyRenderer<AccessMethodSetting>(),
- setAllowLan: invoke<boolean, void>(),
- setShowBetaReleases: invoke<boolean, void>(),
- setEnableIpv6: invoke<boolean, void>(),
- setBlockWhenDisconnected: invoke<boolean, void>(),
- setBridgeState: invoke<BridgeState, void>(),
- setOpenVpnMssfix: invoke<number | undefined, void>(),
- setWireguardMtu: invoke<number | undefined, void>(),
- setWireguardQuantumResistant: invoke<boolean | undefined, void>(),
- setRelaySettings: invoke<RelaySettings, void>(),
- updateBridgeSettings: invoke<BridgeSettings, void>(),
- setDnsOptions: invoke<IDnsOptions, void>(),
- setObfuscationSettings: invoke<ObfuscationSettings, void>(),
- addApiAccessMethod: invoke<NewAccessMethodSetting, string>(),
- updateApiAccessMethod: invoke<AccessMethodSetting, void>(),
- removeApiAccessMethod: invoke<string, void>(),
- setApiAccessMethod: invoke<string, void>(),
- testApiAccessMethodById: invoke<string, boolean>(),
- testCustomApiAccessMethod: invoke<CustomProxy, boolean>(),
- clearAllRelayOverrides: invoke<void, void>(),
- setEnableDaita: invoke<boolean, void>(),
- setDaitaDirectOnly: invoke<boolean, void>(),
- },
- guiSettings: {
- '': notifyRenderer<IGuiSettingsState>(),
- setEnableSystemNotifications: send<boolean>(),
- setAutoConnect: send<boolean>(),
- setStartMinimized: send<boolean>(),
- setMonochromaticIcon: send<boolean>(),
- setPreferredLocale: invoke<string, ITranslations>(),
- setUnpinnedWindow: send<boolean>(),
- setAnimateMap: send<boolean>(),
- },
- account: {
- '': notifyRenderer<IAccountData | undefined>(),
- device: notifyRenderer<DeviceEvent>(),
- devices: notifyRenderer<Array<IDevice>>(),
- create: invoke<void, string>(),
- login: invoke<AccountNumber, AccountDataError | undefined>(),
- logout: invoke<void, void>(),
- getWwwAuthToken: invoke<void, string>(),
- submitVoucher: invoke<string, VoucherResponse>(),
- updateData: send<void>(),
- listDevices: invoke<AccountNumber, Array<IDevice>>(),
- removeDevice: invoke<IDeviceRemoval, void>(),
- },
- accountHistory: {
- '': notifyRenderer<AccountNumber | undefined>(),
- clear: invoke<void, void>(),
- },
- autoStart: {
- '': notifyRenderer<boolean>(),
- set: invoke<boolean, void>(),
- },
- problemReport: {
- collectLogs: invoke<string | undefined, string>(),
- sendReport: invoke<{ email: string; message: string; savedReportId: string }, void>(),
- viewLog: invoke<string, string>(),
- },
- logging: {
- log: send<ILogEntry>(),
- },
- linuxSplitTunneling: {
- getApplications: invoke<void, ILinuxSplitTunnelingApplication[]>(),
- launchApplication: invoke<ILinuxSplitTunnelingApplication | string, LaunchApplicationResult>(),
- },
- macOsSplitTunneling: {
- needFullDiskPermissions: invoke<void, boolean>(),
- },
- splitTunneling: {
- '': notifyRenderer<ISplitTunnelingApplication[]>(),
- setState: invoke<boolean, void>(),
- getApplications: invoke<
- boolean,
- { fromCache: boolean; applications: ISplitTunnelingApplication[] }
- >(),
- addApplication: invoke<ISplitTunnelingApplication | string, void>(),
- removeApplication: invoke<ISplitTunnelingApplication, void>(),
- forgetManuallyAddedApplication: invoke<ISplitTunnelingApplication, void>(),
- },
-};
diff --git a/gui/src/shared/ipc-types.ts b/gui/src/shared/ipc-types.ts
deleted file mode 100644
index c186c9ac83..0000000000
--- a/gui/src/shared/ipc-types.ts
+++ /dev/null
@@ -1,30 +0,0 @@
-import { Action, Location } from 'history';
-
-import { ITransitionSpecification } from '../renderer/lib/history';
-
-export interface ICurrentAppVersionInfo {
- gui: string;
- daemon?: string;
- isConsistent: boolean;
- isBeta: boolean;
-}
-
-export interface IWindowShapeParameters {
- arrowPosition?: number;
-}
-
-export type IChangelog = Array<string>;
-
-export interface LocationState {
- scrollPosition: [number, number];
- expandedSections: Record<string, boolean>;
- transition: ITransitionSpecification;
-}
-
-export interface IHistoryObject {
- entries: Location<LocationState>[];
- index: number;
- lastAction: Action;
-}
-
-export type ScrollPositions = Record<string, [number, number]>;
diff --git a/gui/src/shared/localization-contexts.ts b/gui/src/shared/localization-contexts.ts
deleted file mode 100644
index f30212025c..0000000000
--- a/gui/src/shared/localization-contexts.ts
+++ /dev/null
@@ -1,40 +0,0 @@
-export type LocalizationContexts =
- | 'changelog'
- | 'accessibility'
- | 'login-view'
- | 'device-management'
- | 'auth-failure'
- | 'launch-view'
- | 'error-boundary-view'
- | 'connect-container'
- | 'connect-view'
- | 'tunnel-control'
- | 'connection-info'
- | 'notifications'
- | 'in-app-notifications'
- | 'account-expiry'
- | 'select-location-view'
- | 'select-location-nav'
- | 'custom-bridge'
- | 'filter-view'
- | 'filter-nav'
- | 'settings-view'
- | 'navigation-bar'
- | 'account-view'
- | 'redeem-voucher-view'
- | 'redeem-voucher-alert'
- | 'user-interface-settings-view'
- | 'vpn-settings-view'
- | 'wireguard-settings-view'
- | 'wireguard-settings-nav'
- | 'openvpn-settings-view'
- | 'openvpn-settings-nav'
- | 'split-tunneling-view'
- | 'split-tunneling-nav'
- | 'api-access-methods-view'
- | 'settings-import'
- | 'support-view'
- | 'select-language-nav'
- | 'tray-icon-context-menu'
- | 'tray-icon-tooltip'
- | 'troubleshoot';
diff --git a/gui/src/shared/logging-types.ts b/gui/src/shared/logging-types.ts
deleted file mode 100644
index 8b4ff9e306..0000000000
--- a/gui/src/shared/logging-types.ts
+++ /dev/null
@@ -1,17 +0,0 @@
-export enum LogLevel {
- error,
- warning,
- info,
- verbose,
- debug,
-}
-
-export interface ILogOutput {
- level: LogLevel;
- write(level: LogLevel, message: string): void | Promise<void>;
- dispose?(): void;
-}
-
-export interface ILogInput {
- on(handler: (level: LogLevel, message: string) => void): void;
-}
diff --git a/gui/src/shared/logging.ts b/gui/src/shared/logging.ts
deleted file mode 100644
index c685289bf8..0000000000
--- a/gui/src/shared/logging.ts
+++ /dev/null
@@ -1,108 +0,0 @@
-import { ILogInput, ILogOutput, LogLevel } from './logging-types';
-
-export class Logger {
- private outputs: ILogOutput[] = [];
-
- public addOutput(output: ILogOutput) {
- this.outputs.push(output);
- }
-
- public addInput(input: ILogInput) {
- input.on((level: LogLevel, message: string) => this.outputMessage(level, message));
- }
-
- public log(level: LogLevel, ...data: unknown[]) {
- const time = this.getDateString();
- const stringifiedData = data.map(this.stringifyData).join(' ');
- const message = `[${time}][${LogLevel[level]}] ${stringifiedData}`;
-
- this.outputMessage(level, message);
- }
-
- public error = (...data: unknown[]) => this.log(LogLevel.error, ...data);
- public warn = (...data: unknown[]) => this.log(LogLevel.warning, ...data);
- public info = (...data: unknown[]) => this.log(LogLevel.info, ...data);
- public verbose = (...data: unknown[]) => this.log(LogLevel.verbose, ...data);
- public debug = (...data: unknown[]) => this.log(LogLevel.debug, ...data);
-
- public disposeDisposableOutputs() {
- // Keep the outputs that aren't disposable to continue to forward log messages to them.
- this.outputs = this.outputs.filter((output) => {
- output.dispose?.();
- return output.dispose === undefined;
- });
- }
-
- private getDateString(): string {
- const date = new Date();
- const year = date.getFullYear();
- const month = Number(date.getMonth() + 1)
- .toString()
- .padStart(2, '0');
- const day = Number(date.getDate()).toString().padStart(2, '0');
- const hour = Number(date.getHours()).toString().padStart(2, '0');
- const minute = Number(date.getMinutes()).toString().padStart(2, '0');
- const second = Number(date.getSeconds()).toString().padStart(2, '0');
- const millisecond = Number(date.getMilliseconds()).toString().padStart(3, '0');
- return `${year}-${month}-${day} ${hour}:${minute}:${second}.${millisecond}`;
- }
-
- private stringifyData(data: unknown): string {
- return typeof data === 'string' ? data : JSON.stringify(data);
- }
-
- private outputMessage(level: LogLevel, message: string) {
- this.outputs
- .filter((output) => level <= output.level)
- .forEach(async (output) => {
- try {
- await output.write(level, message);
- } catch (e) {
- const error = e as Error;
- console.error(
- `${output.constructor.name}.write: ${error.message}. Original message: ${message}`,
- );
- }
- });
- }
-}
-
-export class ConsoleOutput implements ILogOutput {
- private disabled = false;
-
- constructor(public level: LogLevel) {}
-
- public write(level: LogLevel, message: string) {
- if (this.disabled) {
- return;
- }
-
- try {
- switch (level) {
- case LogLevel.error:
- console.error(message);
- break;
- case LogLevel.warning:
- console.warn(message);
- break;
- case LogLevel.info:
- console.info(message);
- break;
- case LogLevel.verbose:
- console.log(message);
- break;
- case LogLevel.debug:
- console.log(message);
- break;
- }
- } catch (error) {
- this.disabled = true;
-
- const message = error instanceof Object && 'message' in error ? error.message : '';
- logger.error('Disabling console output due to:', message, error);
- }
- }
-}
-
-const logger = new Logger();
-export default logger;
diff --git a/gui/src/shared/notifications/account-expired.ts b/gui/src/shared/notifications/account-expired.ts
deleted file mode 100644
index a7af4f2c8b..0000000000
--- a/gui/src/shared/notifications/account-expired.ts
+++ /dev/null
@@ -1,42 +0,0 @@
-import { links } from '../../config.json';
-import { hasExpired } from '../account-expiry';
-import { TunnelState } from '../daemon-rpc-types';
-import { messages } from '../gettext';
-import {
- SystemNotification,
- SystemNotificationCategory,
- SystemNotificationProvider,
- SystemNotificationSeverityType,
-} from './notification';
-
-interface AccountExpiredNotificaitonContext {
- accountExpiry: string;
- tunnelState: TunnelState;
-}
-
-export class AccountExpiredNotificationProvider implements SystemNotificationProvider {
- public constructor(private context: AccountExpiredNotificaitonContext) {}
-
- public mayDisplay() {
- // Only show when disconnected since the error state handles this if the connection is closed
- // due to account expiry.
- return (
- this.context.tunnelState.state === 'disconnected' && hasExpired(this.context.accountExpiry)
- );
- }
-
- public getSystemNotification(): SystemNotification {
- return {
- message: messages.pgettext('notifications', 'Account is out of time'),
- category: SystemNotificationCategory.expiry,
- severity: SystemNotificationSeverityType.high,
- presentOnce: { value: true, name: this.constructor.name },
- action: {
- type: 'open-url',
- url: links.purchase,
- withAuth: true,
- text: messages.pgettext('notifications', 'Buy more'),
- },
- };
- }
-}
diff --git a/gui/src/shared/notifications/block-when-disconnected.ts b/gui/src/shared/notifications/block-when-disconnected.ts
deleted file mode 100644
index 2f3df718b6..0000000000
--- a/gui/src/shared/notifications/block-when-disconnected.ts
+++ /dev/null
@@ -1,66 +0,0 @@
-import { sprintf } from 'sprintf-js';
-
-import { strings } from '../../config.json';
-import { messages } from '../../shared/gettext';
-import { TunnelState } from '../daemon-rpc-types';
-import {
- InAppNotification,
- InAppNotificationProvider,
- SystemNotification,
- SystemNotificationCategory,
- SystemNotificationProvider,
- SystemNotificationSeverityType,
-} from './notification';
-
-interface BlockWhenDisconnectedNotificationContext {
- tunnelState: TunnelState;
- blockWhenDisconnected: boolean;
- hasExcludedApps: boolean;
-}
-
-export class BlockWhenDisconnectedNotificationProvider
- implements InAppNotificationProvider, SystemNotificationProvider
-{
- public constructor(private context: BlockWhenDisconnectedNotificationContext) {}
-
- public mayDisplay() {
- return (
- (this.context.tunnelState.state === 'disconnecting' ||
- this.context.tunnelState.state === 'disconnected') &&
- this.context.blockWhenDisconnected
- );
- }
-
- public getSystemNotification(): SystemNotification {
- const message = messages.pgettext('notifications', 'Lockdown mode active, connection blocked');
-
- return {
- message,
- severity: SystemNotificationSeverityType.info,
- category: SystemNotificationCategory.tunnelState,
- };
- }
-
- public getInAppNotification(): InAppNotification {
- const lockdownModeSettingName = messages.pgettext('vpn-settings-view', 'Lockdown mode');
- let subtitle = sprintf(
- messages.pgettext('in-app-notifications', '"%(lockdownModeSettingName)s" is enabled.'),
- { lockdownModeSettingName },
- );
- if (this.context.hasExcludedApps) {
- subtitle = `${subtitle} ${sprintf(
- messages.pgettext(
- 'notifications',
- 'The apps excluded with %(splitTunneling)s might not work properly right now.',
- ),
- { splitTunneling: strings.splitTunneling.toLowerCase() },
- )}`;
- }
-
- return {
- indicator: 'warning',
- title: messages.pgettext('in-app-notifications', 'BLOCKING INTERNET'),
- subtitle,
- };
- }
-}
diff --git a/gui/src/shared/notifications/close-to-account-expiry.ts b/gui/src/shared/notifications/close-to-account-expiry.ts
deleted file mode 100644
index 4fde6ab395..0000000000
--- a/gui/src/shared/notifications/close-to-account-expiry.ts
+++ /dev/null
@@ -1,72 +0,0 @@
-import { sprintf } from 'sprintf-js';
-
-import { links } from '../../config.json';
-import { messages } from '../../shared/gettext';
-import { closeToExpiry, formatRemainingTime } from '../account-expiry';
-import {
- InAppNotification,
- InAppNotificationProvider,
- SystemNotification,
- SystemNotificationCategory,
- SystemNotificationProvider,
- SystemNotificationSeverityType,
-} from './notification';
-
-interface CloseToAccountExpiryNotificationContext {
- accountExpiry: string;
- locale: string;
-}
-
-export class CloseToAccountExpiryNotificationProvider
- implements InAppNotificationProvider, SystemNotificationProvider
-{
- public constructor(private context: CloseToAccountExpiryNotificationContext) {}
-
- public mayDisplay = () => closeToExpiry(this.context.accountExpiry);
-
- public getSystemNotification(): SystemNotification {
- const message = sprintf(
- // TRANSLATORS: The system notification displayed to the user when the account credit is close to expiry.
- // TRANSLATORS: Available placeholder:
- // TRANSLATORS: %(duration)s - remaining time, e.g. "2 days"
- messages.pgettext(
- 'notifications',
- 'Account credit expires in %(duration)s. Buy more credit.',
- ),
- {
- duration: formatRemainingTime(this.context.accountExpiry),
- },
- );
-
- return {
- message,
- category: SystemNotificationCategory.expiry,
- severity: SystemNotificationSeverityType.medium,
- action: {
- type: 'open-url',
- url: links.purchase,
- withAuth: true,
- text: messages.pgettext('notifications', 'Buy more'),
- },
- };
- }
-
- public getInAppNotification(): InAppNotification {
- const subtitle = sprintf(
- messages.pgettext('in-app-notifications', '%(duration)s. Buy more credit.'),
- {
- duration: formatRemainingTime(this.context.accountExpiry, {
- capitalize: true,
- suffix: true,
- }),
- },
- );
-
- return {
- indicator: 'warning',
- title: messages.pgettext('in-app-notifications', 'ACCOUNT CREDIT EXPIRES SOON'),
- subtitle,
- action: { type: 'open-url', url: links.purchase, withAuth: true },
- };
- }
-}
diff --git a/gui/src/shared/notifications/connected.ts b/gui/src/shared/notifications/connected.ts
deleted file mode 100644
index c66339fe9e..0000000000
--- a/gui/src/shared/notifications/connected.ts
+++ /dev/null
@@ -1,42 +0,0 @@
-import { sprintf } from 'sprintf-js';
-
-import { messages } from '../../shared/gettext';
-import { TunnelState } from '../daemon-rpc-types';
-import {
- SystemNotification,
- SystemNotificationCategory,
- SystemNotificationProvider,
- SystemNotificationSeverityType,
-} from './notification';
-
-export class ConnectedNotificationProvider implements SystemNotificationProvider {
- public constructor(private context: TunnelState) {}
-
- public mayDisplay = () => this.context.state === 'connected';
-
- public getSystemNotification(): SystemNotification | undefined {
- if (this.context.state === 'connected') {
- let message = messages.pgettext('notifications', 'Connected');
- const location = this.context.details.location?.hostname;
- if (location) {
- message = sprintf(
- // TRANSLATORS: The message showed when a server has been connected to.
- // TRANSLATORS: Available placeholder:
- // TRANSLATORS: %(location) - name of the server location we're connected to (e.g. "se-got-003")
- messages.pgettext('notifications', 'Connected to %(location)s'),
- {
- location,
- },
- );
- }
-
- return {
- message,
- severity: SystemNotificationSeverityType.info,
- category: SystemNotificationCategory.tunnelState,
- };
- } else {
- return undefined;
- }
- }
-}
diff --git a/gui/src/shared/notifications/connecting.ts b/gui/src/shared/notifications/connecting.ts
deleted file mode 100644
index 444dd42beb..0000000000
--- a/gui/src/shared/notifications/connecting.ts
+++ /dev/null
@@ -1,60 +0,0 @@
-import { sprintf } from 'sprintf-js';
-
-import { messages } from '../../shared/gettext';
-import { TunnelState } from '../daemon-rpc-types';
-import {
- InAppNotification,
- InAppNotificationProvider,
- SystemNotification,
- SystemNotificationCategory,
- SystemNotificationProvider,
- SystemNotificationSeverityType,
-} from './notification';
-
-interface ConnectingNotificationContext {
- tunnelState: TunnelState;
- reconnecting?: boolean;
-}
-
-export class ConnectingNotificationProvider
- implements SystemNotificationProvider, InAppNotificationProvider
-{
- public constructor(private context: ConnectingNotificationContext) {}
-
- public mayDisplay() {
- return this.context.tunnelState.state === 'connecting' && !this.context.reconnecting;
- }
-
- public getSystemNotification(): SystemNotification | undefined {
- if (this.context.tunnelState.state === 'connecting') {
- let message = messages.pgettext('notifications', 'Connecting');
- const location = this.context.tunnelState.details?.location?.hostname;
- if (location) {
- message = sprintf(
- // TRANSLATORS: The message showed when a server is being connected to.
- // TRANSLATORS: Available placeholder:
- // TRANSLATORS: %(location) - name of the server location we're connecting to (e.g. "se-got-003")
- messages.pgettext('notifications', 'Connecting to %(location)s'),
- {
- location,
- },
- );
- }
-
- return {
- message,
- severity: SystemNotificationSeverityType.info,
- category: SystemNotificationCategory.tunnelState,
- throttle: true,
- };
- } else {
- return undefined;
- }
- }
-
- public getInAppNotification(): InAppNotification {
- return {
- title: messages.pgettext('in-app-notifications', 'BLOCKING INTERNET'),
- };
- }
-}
diff --git a/gui/src/shared/notifications/daemon-disconnected.ts b/gui/src/shared/notifications/daemon-disconnected.ts
deleted file mode 100644
index 50a62266d0..0000000000
--- a/gui/src/shared/notifications/daemon-disconnected.ts
+++ /dev/null
@@ -1,22 +0,0 @@
-import { messages } from '../../shared/gettext';
-import {
- SystemNotification,
- SystemNotificationCategory,
- SystemNotificationProvider,
- SystemNotificationSeverityType,
-} from './notification';
-
-export class DaemonDisconnectedNotificationProvider implements SystemNotificationProvider {
- public mayDisplay = () => true;
-
- public getSystemNotification(): SystemNotification {
- return {
- message: messages.pgettext(
- 'notifications',
- 'Connection might be unsecured. App lost contact with system service, please troubleshoot.',
- ),
- severity: SystemNotificationSeverityType.high,
- category: SystemNotificationCategory.tunnelState,
- };
- }
-}
diff --git a/gui/src/shared/notifications/disconnected.ts b/gui/src/shared/notifications/disconnected.ts
deleted file mode 100644
index 874cb11b3e..0000000000
--- a/gui/src/shared/notifications/disconnected.ts
+++ /dev/null
@@ -1,28 +0,0 @@
-import { messages } from '../../shared/gettext';
-import { TunnelState } from '../daemon-rpc-types';
-import {
- SystemNotification,
- SystemNotificationCategory,
- SystemNotificationProvider,
- SystemNotificationSeverityType,
-} from './notification';
-
-interface DisconnectedNotificationContext {
- tunnelState: TunnelState;
- blockWhenDisconnected: boolean;
-}
-
-export class DisconnectedNotificationProvider implements SystemNotificationProvider {
- public constructor(private context: DisconnectedNotificationContext) {}
-
- public mayDisplay = () =>
- this.context.tunnelState.state === 'disconnected' && !this.context.blockWhenDisconnected;
-
- public getSystemNotification(): SystemNotification | undefined {
- return {
- message: messages.pgettext('notifications', 'Disconnected and unsecure'),
- severity: SystemNotificationSeverityType.info,
- category: SystemNotificationCategory.tunnelState,
- };
- }
-}
diff --git a/gui/src/shared/notifications/error.ts b/gui/src/shared/notifications/error.ts
deleted file mode 100644
index af82748d7b..0000000000
--- a/gui/src/shared/notifications/error.ts
+++ /dev/null
@@ -1,338 +0,0 @@
-import { sprintf } from 'sprintf-js';
-
-import { strings } from '../../config.json';
-import {
- AuthFailedError,
- ErrorStateCause,
- ErrorStateDetails,
- TunnelParameterError,
- TunnelState,
-} from '../daemon-rpc-types';
-import { messages } from '../gettext';
-import {
- InAppNotification,
- InAppNotificationAction,
- InAppNotificationProvider,
- SystemNotification,
- SystemNotificationCategory,
- SystemNotificationProvider,
- SystemNotificationSeverityType,
-} from './notification';
-
-interface ErrorNotificationContext {
- tunnelState: TunnelState;
- hasExcludedApps: boolean;
- showFullDiskAccessSettings?: () => void;
-}
-
-export class ErrorNotificationProvider
- implements SystemNotificationProvider, InAppNotificationProvider
-{
- public constructor(private context: ErrorNotificationContext) {}
-
- public mayDisplay = () => this.context.tunnelState.state === 'error';
-
- public getSystemNotification(): SystemNotification | undefined {
- if (this.context.tunnelState.state === 'error') {
- let message = this.getMessage(this.context.tunnelState.details);
- if (!this.context.tunnelState.details.blockingError && this.context.hasExcludedApps) {
- message = `${message} ${sprintf(
- messages.pgettext(
- 'notifications',
- 'The apps excluded with %(splitTunneling)s might not work properly right now.',
- ),
- { splitTunneling: strings.splitTunneling.toLowerCase() },
- )}`;
- }
-
- return {
- message,
- severity:
- this.context.tunnelState.details.blockingError === undefined
- ? SystemNotificationSeverityType.low
- : SystemNotificationSeverityType.high,
- category: SystemNotificationCategory.tunnelState,
- };
- } else {
- return undefined;
- }
- }
-
- public getInAppNotification(): InAppNotification | undefined {
- if (this.context.tunnelState.state === 'error') {
- let subtitle = this.getMessage(this.context.tunnelState.details);
- if (!this.context.tunnelState.details.blockingError && this.context.hasExcludedApps) {
- subtitle = `${subtitle} ${sprintf(
- messages.pgettext(
- 'notifications',
- 'The apps excluded with %(splitTunneling)s might not work properly right now.',
- ),
- { splitTunneling: strings.splitTunneling.toLowerCase() },
- )}`;
- }
-
- return {
- indicator:
- this.context.tunnelState.details.cause === ErrorStateCause.isOffline
- ? 'warning'
- : 'error',
- title: this.context.tunnelState.details.blockingError
- ? messages.pgettext('in-app-notifications', 'NETWORK TRAFFIC MIGHT BE LEAKING')
- : messages.pgettext('in-app-notifications', 'BLOCKING INTERNET'),
- subtitle,
- action: this.getActions(this.context.tunnelState.details) ?? undefined,
- };
- } else {
- return undefined;
- }
- }
-
- private getMessage(errorState: ErrorStateDetails): string {
- if (errorState.blockingError) {
- if (errorState.cause === ErrorStateCause.setFirewallPolicyError) {
- switch (process.platform ?? window.env.platform) {
- case 'win32':
- return messages.pgettext(
- 'notifications',
- 'Unable to block all network traffic. Try temporarily disabling any third-party antivirus or security software or send a problem report.',
- );
- case 'linux':
- return messages.pgettext(
- 'notifications',
- 'Unable to block all network traffic. Try updating your kernel or send a problem report.',
- );
- }
- }
-
- return messages.pgettext(
- 'notifications',
- 'Unable to block all network traffic. Please troubleshoot or send a problem report.',
- );
- } else {
- switch (errorState.cause) {
- case ErrorStateCause.authFailed:
- switch (errorState.authFailedError) {
- case AuthFailedError.invalidAccount:
- return messages.pgettext(
- 'auth-failure',
- 'You are logged in with an invalid account number. Please log out and try another one.',
- );
-
- case AuthFailedError.expiredAccount:
- return messages.pgettext('auth-failure', 'Blocking internet: account is out of time');
-
- case AuthFailedError.tooManyConnections:
- return messages.pgettext(
- 'auth-failure',
- 'Too many simultaneous connections on this account. Disconnect another device or try connecting again shortly.',
- );
-
- case AuthFailedError.unknown:
- default:
- return messages.pgettext(
- 'auth-failure',
- 'Unable to authenticate account. Please send a problem report.',
- );
- }
- case ErrorStateCause.ipv6Unavailable:
- return messages.pgettext(
- 'notifications',
- 'Could not configure IPv6. Disable it in the app or enable it on your device.',
- );
- case ErrorStateCause.setFirewallPolicyError:
- switch (process.platform ?? window.env.platform) {
- case 'win32':
- return messages.pgettext(
- 'notifications',
- 'Unable to apply firewall rules. Try temporarily disabling any third-party antivirus or security software.',
- );
- case 'linux':
- return messages.pgettext(
- 'notifications',
- 'Unable to apply firewall rules. Try updating your kernel.',
- );
- default:
- return messages.pgettext('notifications', 'Unable to apply firewall rules.');
- }
- case ErrorStateCause.setDnsError:
- return messages.pgettext(
- 'notifications',
- 'Unable to set system DNS server. Please send a problem report.',
- );
- case ErrorStateCause.startTunnelError:
- return messages.pgettext(
- 'notifications',
- 'Unable to start tunnel connection. Please send a problem report.',
- );
- case ErrorStateCause.createTunnelDeviceError:
- if (errorState.osError === 4319) {
- return messages.pgettext(
- 'notifications',
- 'Unable to start tunnel connection. This could be because of conflicts with VMware, please troubleshoot.',
- );
- }
-
- return messages.pgettext(
- 'notifications',
- 'Unable to start tunnel connection. Please send a problem report.',
- );
- case ErrorStateCause.tunnelParameterError:
- return this.getTunnelParameterMessage(errorState.parameterError);
- case ErrorStateCause.isOffline:
- return messages.pgettext(
- 'notifications',
- 'Your device is offline. The tunnel will automatically connect once your device is back online.',
- );
- case ErrorStateCause.needFullDiskPermissions:
- return messages.pgettext('notifications', 'Failed to enable split tunneling.');
- case ErrorStateCause.splitTunnelError:
- switch (process.platform ?? window.env.platform) {
- case 'darwin':
- return messages.pgettext(
- 'notifications',
- 'Failed to enable split tunneling. Please try reconnecting or disable split tunneling.',
- );
- default:
- return messages.pgettext(
- 'notifications',
- 'Unable to communicate with Mullvad kernel driver. Try reconnecting or send a problem report.',
- );
- }
- }
- }
- }
-
- private getTunnelParameterMessage(error: TunnelParameterError): string {
- switch (error) {
- /// TODO: once bridge constraints can be set, add a more descriptive error message
- case TunnelParameterError.noMatchingBridgeRelay:
- case TunnelParameterError.noMatchingRelay:
- return messages.pgettext(
- 'notifications',
- 'No servers match your settings, try changing server or other settings.',
- );
- case TunnelParameterError.noWireguardKey:
- return sprintf(
- // TRANSLATORS: Available placeholders:
- // TRANSLATORS: %(wireguard)s - will be replaced with "WireGuard"
- messages.pgettext(
- 'notifications',
- 'Valid %(wireguard)s key is missing. Manage keys under Advanced settings.',
- ),
- { wireguard: strings.wireguard },
- );
- case TunnelParameterError.customTunnelHostResolutionError:
- return messages.pgettext(
- 'notifications',
- 'Unable to resolve host of custom tunnel. Try changing your settings.',
- );
- }
- }
-
- private getActions(errorState: ErrorStateDetails): InAppNotificationAction | void {
- const platform = process.platform ?? window.env.platform;
-
- if (errorState.cause === ErrorStateCause.setFirewallPolicyError && platform === 'linux') {
- return {
- type: 'troubleshoot-dialog',
- troubleshoot: {
- details: messages.pgettext('troubleshoot', 'This might be caused by an outdated kernel.'),
- steps: [
- messages.pgettext('troubleshoot', 'Update your kernel.'),
- messages.pgettext('troubleshoot', 'Make sure you have NF tables support.'),
- ],
- },
- };
- } else if (errorState.cause === ErrorStateCause.setDnsError) {
- const troubleshootSteps = [];
- if (platform === 'darwin') {
- troubleshootSteps.push(
- messages.pgettext(
- 'troubleshoot',
- 'Try to turn Wi-Fi Calling off in the FaceTime app settings and restart the Mac.',
- ),
- messages.pgettext(
- 'troubleshoot',
- 'Uninstall or disable other DNS, networking and ads/website blocking apps.',
- ),
- );
- } else if (platform === 'win32') {
- troubleshootSteps.push(
- messages.pgettext(
- 'troubleshoot',
- 'Uninstall or disable other DNS, networking and ads/website blocking apps.',
- ),
- );
- }
-
- return {
- type: 'troubleshoot-dialog',
- troubleshoot: {
- details: messages.pgettext(
- 'troubleshoot',
- 'This error can happen when something other than Mullvad is actively updating the DNS.',
- ),
- steps: troubleshootSteps,
- },
- };
- } else if (errorState.cause === ErrorStateCause.needFullDiskPermissions) {
- let troubleshootButtons = undefined;
- if (this.context.showFullDiskAccessSettings) {
- troubleshootButtons = [
- {
- label: messages.pgettext('troubleshoot', 'Open system settings'),
- action: () => this.context.showFullDiskAccessSettings?.(),
- },
- ];
- }
-
- return {
- type: 'troubleshoot-dialog',
- troubleshoot: {
- details: messages.pgettext(
- 'troubleshoot',
- 'Failed to enable split tunneling. This is because the app is missing system permissions. What you can do:',
- ),
- steps: [
- messages.pgettext(
- 'troubleshoot',
- 'Enable “Full Disk Access” for “Mullvad VPN” in the macOS system settings.',
- ),
- ],
- buttons: troubleshootButtons,
- },
- };
- } else if (platform === 'win32' && errorState.cause === ErrorStateCause.splitTunnelError) {
- return {
- type: 'troubleshoot-dialog',
- troubleshoot: {
- details: messages.pgettext(
- 'troubleshoot',
- 'Unable to communicate with Mullvad kernel driver.',
- ),
- steps: [
- messages.pgettext('troubleshoot', 'Try reconnecting.'),
- messages.pgettext('troubleshoot', 'Try restarting your device.'),
- ],
- },
- };
- } else if (
- errorState.cause === ErrorStateCause.createTunnelDeviceError &&
- errorState.osError === 4319
- ) {
- return {
- type: 'troubleshoot-dialog',
- troubleshoot: {
- details: messages.pgettext(
- 'troubleshoot',
- 'Unable to start tunnel connection because of a failure when creating the tunnel device. This is often caused by conflicts with the VMware Bridge Protocol.',
- ),
- steps: [
- messages.pgettext('troubleshoot', 'Try to reinstall VMware.'),
- messages.pgettext('troubleshoot', 'Try to uninstall VMware.'),
- ],
- },
- };
- }
- }
-}
diff --git a/gui/src/shared/notifications/inconsistent-version.ts b/gui/src/shared/notifications/inconsistent-version.ts
deleted file mode 100644
index e4f7a8ddc1..0000000000
--- a/gui/src/shared/notifications/inconsistent-version.ts
+++ /dev/null
@@ -1,39 +0,0 @@
-import { messages } from '../../shared/gettext';
-import {
- InAppNotification,
- InAppNotificationProvider,
- SystemNotification,
- SystemNotificationCategory,
- SystemNotificationProvider,
- SystemNotificationSeverityType,
-} from './notification';
-
-interface InconsistentVersionNotificationContext {
- consistent: boolean;
-}
-
-export class InconsistentVersionNotificationProvider
- implements SystemNotificationProvider, InAppNotificationProvider
-{
- public constructor(private context: InconsistentVersionNotificationContext) {}
-
- public mayDisplay = () => !this.context.consistent;
-
- public getSystemNotification(): SystemNotification {
- return {
- message: messages.pgettext('notifications', 'App is out of sync. Please quit and restart.'),
- category: SystemNotificationCategory.inconsistentVersion,
- severity: SystemNotificationSeverityType.high,
- presentOnce: { value: true, name: this.constructor.name },
- suppressInDevelopment: true,
- };
- }
-
- public getInAppNotification(): InAppNotification {
- return {
- indicator: 'error',
- title: messages.pgettext('in-app-notifications', 'APP IS OUT OF SYNC'),
- subtitle: messages.pgettext('in-app-notifications', 'Please quit and restart the app.'),
- };
- }
-}
diff --git a/gui/src/shared/notifications/new-device.ts b/gui/src/shared/notifications/new-device.ts
deleted file mode 100644
index 7d0fe9f299..0000000000
--- a/gui/src/shared/notifications/new-device.ts
+++ /dev/null
@@ -1,32 +0,0 @@
-import { sprintf } from 'sprintf-js';
-
-import { messages } from '../../shared/gettext';
-import { capitalizeEveryWord } from '../string-helpers';
-import { InAppNotification, InAppNotificationProvider } from './notification';
-
-interface NewDeviceNotificationContext {
- shouldDisplay: boolean;
- deviceName: string;
- close: () => void;
-}
-
-export class NewDeviceNotificationProvider implements InAppNotificationProvider {
- public constructor(private context: NewDeviceNotificationContext) {}
-
- public mayDisplay = () => this.context.shouldDisplay;
-
- public getInAppNotification(): InAppNotification {
- return {
- indicator: 'success',
- title: messages.pgettext('in-app-notifications', 'NEW DEVICE CREATED'),
- subtitle: sprintf(
- messages.pgettext(
- 'in-app-notifications',
- 'Welcome, this device is now called <b>%(deviceName)s</b>. For more details see the info button in Account.',
- ),
- { deviceName: capitalizeEveryWord(this.context.deviceName) },
- ),
- action: { type: 'close', close: this.context.close },
- };
- }
-}
diff --git a/gui/src/shared/notifications/notification.ts b/gui/src/shared/notifications/notification.ts
deleted file mode 100644
index 87166aab4d..0000000000
--- a/gui/src/shared/notifications/notification.ts
+++ /dev/null
@@ -1,86 +0,0 @@
-export type NotificationAction = {
- type: 'open-url';
- url: string;
- text?: string;
- withAuth?: boolean;
-};
-
-export interface InAppNotificationTroubleshootInfo {
- details: string;
- steps: string[];
- buttons?: Array<InAppNotificationTroubleshootButton>;
-}
-
-export interface InAppNotificationTroubleshootButton {
- label: string;
- action: () => void;
-}
-
-export type InAppNotificationAction =
- | NotificationAction
- | {
- type: 'troubleshoot-dialog';
- troubleshoot: InAppNotificationTroubleshootInfo;
- }
- | {
- type: 'close';
- close: () => void;
- };
-
-export type InAppNotificationIndicatorType = 'success' | 'warning' | 'error';
-
-export enum SystemNotificationSeverityType {
- info = 0,
- low,
- medium,
- high,
-}
-
-export enum SystemNotificationCategory {
- tunnelState,
- expiry,
- newVersion,
- inconsistentVersion,
-}
-
-interface NotificationProvider {
- mayDisplay(): boolean;
-}
-
-export interface SystemNotification {
- message: string;
- severity: SystemNotificationSeverityType;
- category: SystemNotificationCategory;
- throttle?: boolean;
- presentOnce?: { value: boolean; name: string };
- suppressInDevelopment?: boolean;
- action?: NotificationAction;
-}
-
-export interface InAppNotification {
- indicator?: InAppNotificationIndicatorType;
- title: string;
- subtitle?: string;
- action?: InAppNotificationAction;
-}
-
-export interface SystemNotificationProvider extends NotificationProvider {
- getSystemNotification(): SystemNotification | undefined;
-}
-
-export interface InAppNotificationProvider extends NotificationProvider {
- getInAppNotification(): InAppNotification | undefined;
-}
-
-export * from './account-expired';
-export * from './close-to-account-expiry';
-export * from './block-when-disconnected';
-export * from './connected';
-export * from './connecting';
-export * from './disconnected';
-export * from './daemon-disconnected';
-export * from './error';
-export * from './inconsistent-version';
-export * from './reconnecting';
-export * from './unsupported-version';
-export * from './update-available';
diff --git a/gui/src/shared/notifications/reconnecting.ts b/gui/src/shared/notifications/reconnecting.ts
deleted file mode 100644
index 4362c0edb6..0000000000
--- a/gui/src/shared/notifications/reconnecting.ts
+++ /dev/null
@@ -1,35 +0,0 @@
-import { messages } from '../../shared/gettext';
-import { TunnelState } from '../daemon-rpc-types';
-import {
- InAppNotification,
- InAppNotificationProvider,
- SystemNotification,
- SystemNotificationCategory,
- SystemNotificationProvider,
- SystemNotificationSeverityType,
-} from './notification';
-
-export class ReconnectingNotificationProvider
- implements SystemNotificationProvider, InAppNotificationProvider
-{
- public constructor(private context: TunnelState) {}
-
- public mayDisplay() {
- return this.context.state === 'disconnecting' && this.context.details === 'reconnect';
- }
-
- public getSystemNotification(): SystemNotification | undefined {
- return {
- message: messages.pgettext('notifications', 'Reconnecting'),
- severity: SystemNotificationSeverityType.info,
- category: SystemNotificationCategory.tunnelState,
- throttle: true,
- };
- }
-
- public getInAppNotification(): InAppNotification {
- return {
- title: messages.pgettext('in-app-notifications', 'BLOCKING INTERNET'),
- };
- }
-}
diff --git a/gui/src/shared/notifications/unsupported-version.ts b/gui/src/shared/notifications/unsupported-version.ts
deleted file mode 100644
index 15c622703c..0000000000
--- a/gui/src/shared/notifications/unsupported-version.ts
+++ /dev/null
@@ -1,62 +0,0 @@
-import { messages } from '../../shared/gettext';
-import { getDownloadUrl } from '../version';
-import {
- InAppNotification,
- InAppNotificationProvider,
- SystemNotification,
- SystemNotificationCategory,
- SystemNotificationProvider,
- SystemNotificationSeverityType,
-} from './notification';
-
-interface UnsupportedVersionNotificationContext {
- supported: boolean;
- consistent: boolean;
- suggestedUpgrade?: string;
- suggestedIsBeta?: boolean;
-}
-
-export class UnsupportedVersionNotificationProvider
- implements SystemNotificationProvider, InAppNotificationProvider
-{
- public constructor(private context: UnsupportedVersionNotificationContext) {}
-
- public mayDisplay() {
- return this.context.consistent && !this.context.supported;
- }
-
- public getSystemNotification(): SystemNotification {
- return {
- message: this.getMessage(),
- category: SystemNotificationCategory.newVersion,
- severity: SystemNotificationSeverityType.high,
- action: {
- type: 'open-url',
- url: getDownloadUrl(this.context.suggestedIsBeta ?? false),
- text: messages.pgettext('notifications', 'Upgrade'),
- },
- presentOnce: { value: true, name: this.constructor.name },
- suppressInDevelopment: true,
- };
- }
-
- public getInAppNotification(): InAppNotification {
- return {
- indicator: 'error',
- title: messages.pgettext('in-app-notifications', 'UNSUPPORTED VERSION'),
- subtitle: this.getMessage(),
- action: {
- type: 'open-url',
- url: 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.
- return messages.pgettext(
- 'notifications',
- 'Your privacy might be at risk with this unsupported app version. Please update now.',
- );
- }
-}
diff --git a/gui/src/shared/notifications/update-available.ts b/gui/src/shared/notifications/update-available.ts
deleted file mode 100644
index 732e7bb9a8..0000000000
--- a/gui/src/shared/notifications/update-available.ts
+++ /dev/null
@@ -1,96 +0,0 @@
-import { sprintf } from 'sprintf-js';
-
-import { messages } from '../../shared/gettext';
-import { getDownloadUrl } from '../version';
-import {
- InAppNotification,
- InAppNotificationProvider,
- SystemNotification,
- SystemNotificationCategory,
- SystemNotificationProvider,
- SystemNotificationSeverityType,
-} from './notification';
-
-interface UpdateAvailableNotificationContext {
- suggestedUpgrade?: string;
- suggestedIsBeta?: boolean;
-}
-
-export class UpdateAvailableNotificationProvider
- implements InAppNotificationProvider, 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),
- },
- };
- }
-
- public getSystemNotification(): SystemNotification {
- return {
- message: this.systemMessage(),
- category: SystemNotificationCategory.newVersion,
- severity: SystemNotificationSeverityType.medium,
- action: {
- type: 'open-url',
- url: getDownloadUrl(this.context.suggestedIsBeta ?? false),
- text: messages.pgettext('notifications', 'Upgrade'),
- },
- 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(
- // TRANSLATORS: The system notification that notifies the user when a beta update is
- // TRANSLATORS: available.
- // TRANSLATORS: Available placeholders:
- // TRANSLATORS: %(version)s - The version number of the new beta version.
- messages.pgettext(
- 'notifications',
- 'Beta update available. Try out the newest beta version (%(version)s).',
- ),
- { version: this.context.suggestedUpgrade },
- );
- } else {
- return messages.pgettext(
- 'notifications',
- 'Update available. Install the latest app version to stay up to date',
- );
- }
- }
-}
diff --git a/gui/src/shared/scheduler.ts b/gui/src/shared/scheduler.ts
deleted file mode 100644
index 2716097194..0000000000
--- a/gui/src/shared/scheduler.ts
+++ /dev/null
@@ -1,37 +0,0 @@
-import { useEffect, useMemo } from 'react';
-
-export class Scheduler {
- private timer?: NodeJS.Timeout;
- private running = false;
-
- public schedule(action: () => void, delay = 0) {
- this.cancel();
-
- this.running = true;
- this.timer = global.setTimeout(() => {
- this.running = false;
- action();
- }, delay);
- }
-
- public cancel() {
- if (this.timer) {
- clearTimeout(this.timer);
- this.running = false;
- }
- }
-
- public get isRunning() {
- return this.running;
- }
-}
-
-export function useScheduler() {
- const closeScheduler = useMemo(() => new Scheduler(), []);
-
- useEffect(() => {
- return () => closeScheduler.cancel();
- }, [closeScheduler]);
-
- return closeScheduler;
-}
diff --git a/gui/src/shared/string-helpers.ts b/gui/src/shared/string-helpers.ts
deleted file mode 100644
index 983a8e8796..0000000000
--- a/gui/src/shared/string-helpers.ts
+++ /dev/null
@@ -1,11 +0,0 @@
-export function capitalize(inputString: string): string {
- return inputString.charAt(0).toUpperCase() + inputString.slice(1);
-}
-
-export function capitalizeEveryWord(inputString: string): string {
- return inputString.split(' ').map(capitalize).join(' ');
-}
-
-export function removeNonNumericCharacters(value: string) {
- return value.replace(/[^0-9]/g, '');
-}
diff --git a/gui/src/shared/utils.ts b/gui/src/shared/utils.ts
deleted file mode 100644
index 042c56385a..0000000000
--- a/gui/src/shared/utils.ts
+++ /dev/null
@@ -1,5 +0,0 @@
-export type NonEmptyArray<T> = [T, ...T[]];
-
-export function hasValue<T>(value: T): value is NonNullable<T> {
- return value !== undefined && value !== null;
-}
diff --git a/gui/src/shared/version.ts b/gui/src/shared/version.ts
deleted file mode 100644
index dc87afaae0..0000000000
--- a/gui/src/shared/version.ts
+++ /dev/null
@@ -1,22 +0,0 @@
-import { links } from '../config.json';
-
-export function getDownloadUrl(suggestedIsBeta: boolean): string {
- let url = links.download;
- switch (process.platform ?? window.env.platform) {
- case 'win32':
- url += 'windows/';
- break;
- case 'linux':
- url += 'linux/';
- break;
- case 'darwin':
- url += 'macos/';
- break;
- }
-
- if (suggestedIsBeta) {
- url += 'beta/';
- }
-
- return url;
-}