summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorOskar Nyberg <oskar@mullvad.net>2024-02-15 16:04:54 +0100
committerOskar Nyberg <oskar@mullvad.net>2024-02-15 16:04:54 +0100
commit110e037e84e233aac0796e1d18502640bc241ca3 (patch)
tree4fe313d6321541cea6e467470292b50aa0f17618
parentd42287a49451c6ab42efb0edc5e66a1375e28306 (diff)
parent3edd6599b191c5c241abb86d8938164aac4c150d (diff)
downloadmullvadvpn-110e037e84e233aac0796e1d18502640bc241ca3.tar.xz
mullvadvpn-110e037e84e233aac0796e1d18502640bc241ca3.zip
Merge branch 'add-server-ip-override-ui-des-422'
-rw-r--r--CHANGELOG.md1
-rw-r--r--gui/assets/images/icon-checkmark.svg3
-rw-r--r--gui/assets/images/icon-cross.svg3
-rw-r--r--gui/locales/messages.pot78
-rw-r--r--gui/src/main/daemon-rpc.ts17
-rw-r--r--gui/src/main/default-settings.ts1
-rw-r--r--gui/src/main/index.ts3
-rw-r--r--gui/src/main/settings.ts16
-rw-r--r--gui/src/main/user-interface.ts1
-rw-r--r--gui/src/renderer/app.tsx5
-rw-r--r--gui/src/renderer/components/ApiAccessMethods.tsx48
-rw-r--r--gui/src/renderer/components/AppRouter.tsx4
-rw-r--r--gui/src/renderer/components/InfoButton.tsx16
-rw-r--r--gui/src/renderer/components/NavigationBar.tsx26
-rw-r--r--gui/src/renderer/components/SettingsImport.tsx304
-rw-r--r--gui/src/renderer/components/SettingsTextImport.tsx82
-rw-r--r--gui/src/renderer/components/VpnSettings.tsx15
-rw-r--r--gui/src/renderer/components/select-location/SelectLocation.tsx8
-rw-r--r--gui/src/renderer/components/select-location/SelectLocationStyles.tsx9
-rw-r--r--gui/src/renderer/lib/history.tsx13
-rw-r--r--gui/src/renderer/lib/routes.ts2
-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.ts17
-rw-r--r--gui/src/renderer/redux/settings/reducers.ts9
-rw-r--r--gui/src/renderer/redux/store.ts7
-rw-r--r--gui/src/shared/daemon-rpc-types.ts7
-rw-r--r--gui/src/shared/ipc-schema.ts4
-rw-r--r--gui/src/shared/localization-contexts.ts1
-rw-r--r--gui/test/e2e/installed/state-dependent/settings-import.spec.ts116
30 files changed, 845 insertions, 52 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md
index a5bfd14a62..7d1c96d57f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -32,6 +32,7 @@ Line wrap the file at 100 chars. Th
circumvent censorship by proxying API traffic.
- Add confirmation dialog when deleting a custom list.
- Add support for custom SOCKS5 OpenVPN bridges running locally.
+- Add ability to import server IP overrides in GUI.
#### Android
- Add support for all screen orientations.
diff --git a/gui/assets/images/icon-checkmark.svg b/gui/assets/images/icon-checkmark.svg
new file mode 100644
index 0000000000..67773298f8
--- /dev/null
+++ b/gui/assets/images/icon-checkmark.svg
@@ -0,0 +1,3 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 22 22">
+ <path d="M2.683 6.69a1.581 1.581 0 0 0-2.222 0 1.549 1.549 0 0 0 0 2.2l6.286 6.233a1.581 1.581 0 0 0 2.222 0L21.54 2.66a1.549 1.549 0 0 0 0-2.2 1.581 1.581 0 0 0-2.222 0L7.857 11.821z" transform="translate(0 3.208)" style="fill:#44ad4d"/>
+</svg>
diff --git a/gui/assets/images/icon-cross.svg b/gui/assets/images/icon-cross.svg
new file mode 100644
index 0000000000..0ac8215f62
--- /dev/null
+++ b/gui/assets/images/icon-cross.svg
@@ -0,0 +1,3 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
+ <path d="m12.7 10 6.75-6.747a1.907 1.907 0 0 0-2.7-2.7L10 7.3 3.25.556a1.907 1.907 0 0 0-2.7 2.7L7.3 10 .552 16.747a1.907 1.907 0 0 0 2.7 2.7L10 12.7l6.75 6.747a1.907 1.907 0 0 0 2.7-2.7L12.7 10z" transform="translate(2 2)" style="fill:#e34039"/>
+</svg>
diff --git a/gui/locales/messages.pot b/gui/locales/messages.pot
index ea04a3c6a8..7839a893ee 100644
--- a/gui/locales/messages.pot
+++ b/gui/locales/messages.pot
@@ -145,6 +145,9 @@ msgstr ""
msgid "Got it!"
msgstr ""
+msgid "Import"
+msgstr ""
+
msgid "IPv4"
msgstr ""
@@ -1324,6 +1327,77 @@ msgctxt "select-location-view"
msgid "While connected, your traffic will be routed through two secure locations, the entry point and the exit point (needs to be two different VPN servers)."
msgstr ""
+msgctxt "settings-import"
+msgid "Clear all overrides"
+msgstr ""
+
+msgctxt "settings-import"
+msgid "Clear all overrides?"
+msgstr ""
+
+msgctxt "settings-import"
+msgid "Clearing the imported overrides changes the server IPs, in the Select location view, back to default."
+msgstr ""
+
+msgctxt "settings-import"
+msgid "If you are having issues connecting to VPN servers, please contact support."
+msgstr ""
+
+msgctxt "settings-import"
+msgid "Import file"
+msgstr ""
+
+msgctxt "settings-import"
+msgid "Import files or text with new IP addresses for the servers in the Select location view."
+msgstr ""
+
+msgctxt "settings-import"
+msgid "Import of file %(fileName)s was successful, overrides are now active."
+msgstr ""
+
+msgctxt "settings-import"
+msgid "Import of file %(fileName)s was unsuccessful, please try again."
+msgstr ""
+
+msgctxt "settings-import"
+msgid "Import of text was successful, overrides are now active."
+msgstr ""
+
+msgctxt "settings-import"
+msgid "Import of text was unsuccessful, please try again."
+msgstr ""
+
+msgctxt "settings-import"
+msgid "IMPORT SUCCESSFUL"
+msgstr ""
+
+#. Title label in navigation bar
+msgctxt "settings-import"
+msgid "Import via text"
+msgstr ""
+
+msgctxt "settings-import"
+msgid "NO OVERRIDES IMPORTED"
+msgstr ""
+
+msgctxt "settings-import"
+msgid "On some networks, where various types of censorship are being used, our server IP addresses are sometimes blocked."
+msgstr ""
+
+msgctxt "settings-import"
+msgid "OVERRIDES ACTIVE"
+msgstr ""
+
+#. Title label in navigation bar. This is for a feature that lets
+#. users import server IP settings.
+msgctxt "settings-import"
+msgid "Server IP override"
+msgstr ""
+
+msgctxt "settings-import"
+msgid "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."
+msgstr ""
+
#. Navigation button to the 'API access methods' view
msgctxt "settings-view"
msgid "API access"
@@ -1768,6 +1842,10 @@ msgctxt "vpn-settings-view"
msgid "Malware"
msgstr ""
+msgctxt "vpn-settings-view"
+msgid "Server IP override"
+msgstr ""
+
#. Label for settings that enables block of social media.
msgctxt "vpn-settings-view"
msgid "Social media"
diff --git a/gui/src/main/daemon-rpc.ts b/gui/src/main/daemon-rpc.ts
index 6290aa7a92..703ad027cc 100644
--- a/gui/src/main/daemon-rpc.ts
+++ b/gui/src/main/daemon-rpc.ts
@@ -697,6 +697,14 @@ export class DaemonRpc {
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;
@@ -711,11 +719,14 @@ export class DaemonRpc {
return Date.now() + CHANNEL_STATE_TIMEOUT;
}
- private callEmpty<R>(fn: CallFunctionArgument<Empty, R>): Promise<R> {
+ private callEmpty<R = Empty>(fn: CallFunctionArgument<Empty, R>): Promise<R> {
return this.call<Empty, R>(fn, new Empty());
}
- private callString<R>(fn: CallFunctionArgument<StringValue, R>, value?: string): Promise<R> {
+ private callString<R = Empty>(
+ fn: CallFunctionArgument<StringValue, R>,
+ value?: string,
+ ): Promise<R> {
const googleString = new StringValue();
if (value !== undefined) {
@@ -1164,6 +1175,7 @@ function convertFromSettings(settings: grpcTypes.Settings): ISettings | undefine
const obfuscationSettings = convertFromObfuscationSettings(settingsObject.obfuscationSettings);
const customLists = convertFromCustomListSettings(settings.getCustomLists());
const apiAccessMethods = convertFromApiAccessMethodSettings(settings.getApiAccessMethods()!);
+ const relayOverrides = settingsObject.relayOverridesList;
return {
...settings.toObject(),
bridgeState,
@@ -1174,6 +1186,7 @@ function convertFromSettings(settings: grpcTypes.Settings): ISettings | undefine
obfuscationSettings,
customLists,
apiAccessMethods,
+ relayOverrides,
};
}
diff --git a/gui/src/main/default-settings.ts b/gui/src/main/default-settings.ts
index 7f7656a56a..e942a535b6 100644
--- a/gui/src/main/default-settings.ts
+++ b/gui/src/main/default-settings.ts
@@ -77,6 +77,7 @@ export function getDefaultSettings(): ISettings {
},
customLists: [],
apiAccessMethods: getDefaultApiAccessMethods(),
+ relayOverrides: [],
};
}
diff --git a/gui/src/main/index.ts b/gui/src/main/index.ts
index 635b0ad79e..92d51dba69 100644
--- a/gui/src/main/index.ts
+++ b/gui/src/main/index.ts
@@ -822,6 +822,9 @@ class ApplicationMain
await shell.openExternal(url);
}
});
+ IpcMainEventChannel.app.handleGetPathBaseName((filePath) =>
+ Promise.resolve(path.basename(filePath)),
+ );
IpcMainEventChannel.navigation.handleSetHistory((history) => {
this.navigationHistory = history;
diff --git a/gui/src/main/settings.ts b/gui/src/main/settings.ts
index 7c304f3e71..16996eebd9 100644
--- a/gui/src/main/settings.ts
+++ b/gui/src/main/settings.ts
@@ -1,3 +1,5 @@
+import fs from 'fs/promises';
+
import BridgeSettingsBuilder from '../shared/bridge-settings-builder';
import { ISettings } from '../shared/daemon-rpc-types';
import { ICurrentAppVersionInfo } from '../shared/ipc-types';
@@ -90,6 +92,17 @@ export default class Settings implements Readonly<ISettings> {
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.guiSettings.handleSetEnableSystemNotifications((flag: boolean) => {
this.guiSettings.enableSystemNotifications = flag;
});
@@ -160,6 +173,9 @@ export default class Settings implements Readonly<ISettings> {
public get apiAccessMethods() {
return this.settingsValue.apiAccessMethods;
}
+ public get relayOverrides() {
+ return this.settingsValue.relayOverrides;
+ }
public get gui() {
return this.guiSettings;
diff --git a/gui/src/main/user-interface.ts b/gui/src/main/user-interface.ts
index de902e2d7c..ac308adc01 100644
--- a/gui/src/main/user-interface.ts
+++ b/gui/src/main/user-interface.ts
@@ -69,6 +69,7 @@ export default class UserInterface implements WindowControllerDelegate {
...options,
});
this.browsingFiles = false;
+ this.showWindow();
return response;
});
diff --git a/gui/src/renderer/app.tsx b/gui/src/renderer/app.tsx
index 1a735e1ec5..5e2c574d79 100644
--- a/gui/src/renderer/app.tsx
+++ b/gui/src/renderer/app.tsx
@@ -345,6 +345,7 @@ export default class AppRenderer {
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) =>
@@ -365,6 +366,9 @@ export default class AppRenderer {
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);
@@ -812,6 +816,7 @@ export default class AppRenderer {
reduxSettings.updateObfuscationSettings(newSettings.obfuscationSettings);
reduxSettings.updateCustomLists(newSettings.customLists);
reduxSettings.updateApiAccessMethods(newSettings.apiAccessMethods);
+ reduxSettings.updateRelayOverrides(newSettings.relayOverrides);
this.setReduxRelaySettings(newSettings.relaySettings);
this.setBridgeSettings(newSettings.bridgeSettings);
diff --git a/gui/src/renderer/components/ApiAccessMethods.tsx b/gui/src/renderer/components/ApiAccessMethods.tsx
index b5cffddc94..ccec1ce823 100644
--- a/gui/src/renderer/components/ApiAccessMethods.tsx
+++ b/gui/src/renderer/components/ApiAccessMethods.tsx
@@ -24,7 +24,13 @@ import InfoButton from './InfoButton';
import { BackAction } from './KeyboardNavigation';
import { Layout, SettingsContainer } from './Layout';
import { ModalAlert, ModalAlertType } from './Modal';
-import { NavigationBar, NavigationContainer, NavigationItems, TitleBarItem } from './NavigationBar';
+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';
@@ -33,10 +39,6 @@ const StyledContextMenuButton = styled(Cell.Icon)({
marginRight: '8px',
});
-const StyledTitleInfoButton = styled(InfoButton)({
- marginLeft: '12px',
-});
-
const StyledMethodInfoButton = styled(InfoButton)({
marginRight: '11px',
});
@@ -90,31 +92,29 @@ export default function ApiAccessMethods() {
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')}
- <StyledTitleInfoButton
- 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.',
- ),
- ]}
- />
- </HeaderTitle>
+ <HeaderTitle>{messages.pgettext('navigation-bar', 'API access')}</HeaderTitle>
<HeaderSubTitle>
{messages.pgettext(
'api-access-methods-view',
diff --git a/gui/src/renderer/components/AppRouter.tsx b/gui/src/renderer/components/AppRouter.tsx
index 6c45083290..9e4a13adc8 100644
--- a/gui/src/renderer/components/AppRouter.tsx
+++ b/gui/src/renderer/components/AppRouter.tsx
@@ -26,6 +26,8 @@ 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 SplitTunnelingSettings from './SplitTunnelingSettings';
import Support from './Support';
import TooManyDevices from './TooManyDevices';
@@ -83,6 +85,8 @@ export default function AppRouter() {
<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} />
diff --git a/gui/src/renderer/components/InfoButton.tsx b/gui/src/renderer/components/InfoButton.tsx
index 8807356d79..e17e7b87b8 100644
--- a/gui/src/renderer/components/InfoButton.tsx
+++ b/gui/src/renderer/components/InfoButton.tsx
@@ -18,6 +18,8 @@ const StyledInfoButton = styled.button({
interface IInfoIconProps {
className?: string;
size?: number;
+ tintColor?: string;
+ tintHoverColor?: string;
}
export function InfoIcon(props: IInfoIconProps) {
@@ -25,21 +27,24 @@ export function InfoIcon(props: IInfoIconProps) {
<ImageView
source="icon-info"
width={props.size ?? 18}
- tintColor={colors.white}
- tintHoverColor={colors.white80}
+ tintColor={props.tintColor ?? colors.white}
+ tintHoverColor={props.tintHoverColor ?? colors.white80}
className={props.className}
/>
);
}
-interface IInfoButtonProps extends React.HTMLAttributes<HTMLButtonElement> {
+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, ...otherProps } = props;
+ const { message, children, size, tintColor, tintHoverColor, ...otherProps } = props;
const [isOpen, show, hide] = useBoolean(false);
return (
@@ -48,10 +53,11 @@ export default function InfoButton(props: IInfoButtonProps) {
onClick={show}
aria-label={messages.pgettext('accessibility', 'More information')}
{...otherProps}>
- <InfoIcon size={size} />
+ <InfoIcon size={size} tintColor={tintColor} tintHoverColor={tintHoverColor} />
</StyledInfoButton>
<ModalAlert
isOpen={isOpen}
+ title={props.title}
message={props.message}
type={ModalAlertType.info}
buttons={[
diff --git a/gui/src/renderer/components/NavigationBar.tsx b/gui/src/renderer/components/NavigationBar.tsx
index d375fc34d6..ee316de783 100644
--- a/gui/src/renderer/components/NavigationBar.tsx
+++ b/gui/src/renderer/components/NavigationBar.tsx
@@ -1,11 +1,13 @@
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 { useHistory } from '../lib/history';
+import { transitions, useHistory } from '../lib/history';
import { useCombinedRefs } from '../lib/utilityHooks';
import CustomScrollbars, { CustomScrollbarsRef, IScrollEvent } from './CustomScrollbars';
+import InfoButton from './InfoButton';
import { BackActionContext } from './KeyboardNavigation';
import {
StyledBackBarItemButton,
@@ -185,7 +187,9 @@ export const TitleBarItem = React.memo(function TitleBarItemT(props: ITitleBarIt
export function BackBarItem() {
const history = useHistory();
- const backIcon = useMemo(() => history.length > 2, []);
+ // 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, []);
const { parentBackAction } = useContext(BackActionContext);
const iconSource = backIcon ? 'icon-back' : 'icon-close-down';
const ariaLabel = backIcon ? messages.gettext('Back') : messages.gettext('Close');
@@ -196,3 +200,21 @@ export function BackBarItem() {
</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/SettingsImport.tsx b/gui/src/renderer/components/SettingsImport.tsx
new file mode 100644
index 0000000000..3f3cc5dd1f
--- /dev/null
+++ b/gui/src/renderer/components/SettingsImport.tsx
@@ -0,0 +1,304 @@
+import { useCallback, 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 { useAsyncEffect, useBoolean } from '../lib/utilityHooks';
+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);
+ }
+ }, []);
+
+ const confirmClear = useCallback(() => {
+ hideClearDialog();
+ void clearAllRelayOverrides();
+ setImportStatus(undefined);
+ }, []);
+
+ 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 });
+ }
+ }, []);
+
+ useAsyncEffect(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();
+ }
+ }
+ }, []);
+
+ 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/SettingsTextImport.tsx b/gui/src/renderer/components/SettingsTextImport.tsx
new file mode 100644
index 0000000000..7d51e7ac93
--- /dev/null
+++ b/gui/src/renderer/components/SettingsTextImport.tsx
@@ -0,0 +1,82 @@
+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, useStyledRef } from '../lib/utilityHooks';
+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 history = 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 = useCallback((element?: HTMLTextAreaElement) => {
+ if (element) {
+ element.value = initialValue;
+ }
+ }, []);
+
+ const combinedTextAreaRef = useCombinedRefs(textareaRef, onTextareaLoad);
+
+ const save = useCallback(() => {
+ if (textareaRef.current?.value) {
+ saveSettingsImportForm(textareaRef.current.value, true);
+ }
+ history.pop();
+ }, [history]);
+
+ const back = useCallback(() => {
+ if (textareaRef.current) {
+ saveSettingsImportForm(textareaRef.current.value, false);
+ }
+ history.pop();
+ }, [history]);
+
+ 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/VpnSettings.tsx b/gui/src/renderer/components/VpnSettings.tsx
index 06f69240b6..395d1ba183 100644
--- a/gui/src/renderer/components/VpnSettings.tsx
+++ b/gui/src/renderer/components/VpnSettings.tsx
@@ -123,6 +123,10 @@ export default function VpnSettings() {
<Cell.Group>
<CustomDnsSettings />
</Cell.Group>
+
+ <Cell.Group>
+ <IpOverrideButton />
+ </Cell.Group>
</StyledContent>
</NavigationScrollbars>
</NavigationContainer>
@@ -779,3 +783,14 @@ function OpenVpnSettingsButton() {
</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/select-location/SelectLocation.tsx b/gui/src/renderer/components/select-location/SelectLocation.tsx
index e09823586f..6bf0fcff6f 100644
--- a/gui/src/renderer/components/select-location/SelectLocation.tsx
+++ b/gui/src/renderer/components/select-location/SelectLocation.tsx
@@ -18,6 +18,7 @@ import { BackAction } from '../KeyboardNavigation';
import { Layout, SettingsContainer } from '../Layout';
import {
NavigationBar,
+ NavigationBarButton,
NavigationContainer,
NavigationItems,
NavigationScrollbars,
@@ -44,7 +45,6 @@ import {
StyledClearFilterButton,
StyledContent,
StyledFilter,
- StyledFilterIconButton,
StyledFilterRow,
StyledHeaderSubTitle,
StyledNavigationBarAttachment,
@@ -137,9 +137,7 @@ export default function SelectLocation() {
}
</TitleBarItem>
- <StyledFilterIconButton
- onClick={onViewFilter}
- aria-label={messages.gettext('Filter')}>
+ <NavigationBarButton onClick={onViewFilter} aria-label={messages.gettext('Filter')}>
<ImageView
source="icon-filter-round"
tintColor={colors.white40}
@@ -147,7 +145,7 @@ export default function SelectLocation() {
height={24}
width={24}
/>
- </StyledFilterIconButton>
+ </NavigationBarButton>
</NavigationItems>
</NavigationBar>
diff --git a/gui/src/renderer/components/select-location/SelectLocationStyles.tsx b/gui/src/renderer/components/select-location/SelectLocationStyles.tsx
index a031287e5e..fd401c8e8c 100644
--- a/gui/src/renderer/components/select-location/SelectLocationStyles.tsx
+++ b/gui/src/renderer/components/select-location/SelectLocationStyles.tsx
@@ -22,15 +22,6 @@ export const StyledNavigationBarAttachment = styled.div({
padding: '0 16px 14px',
});
-export const StyledFilterIconButton = styled.button({
- justifySelf: 'end',
- borderWidth: 0,
- padding: 0,
- margin: 0,
- cursor: 'default',
- backgroundColor: 'transparent',
-});
-
export const StyledFilterRow = styled.div({
...tinyText,
color: colors.white,
diff --git a/gui/src/renderer/lib/history.tsx b/gui/src/renderer/lib/history.tsx
index af851a01d4..741c298da6 100644
--- a/gui/src/renderer/lib/history.tsx
+++ b/gui/src/renderer/lib/history.tsx
@@ -139,6 +139,13 @@ export default class History {
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
@@ -183,13 +190,13 @@ export default class History {
private popImpl(n = 1): ITransitionSpecification | undefined {
if (this.canGo(-n)) {
+ const transition = this.getPopTransition(n);
+
this.lastAction = 'POP';
this.index -= n;
-
- const transition = this.entries[this.index + 1].state.transition;
this.entries = this.entries.slice(0, this.index + 1);
- return oppositeTransition(transition);
+ return transition;
} else {
return undefined;
}
diff --git a/gui/src/renderer/lib/routes.ts b/gui/src/renderer/lib/routes.ts
index a7cf89d9ce..499157976a 100644
--- a/gui/src/renderer/lib/routes.ts
+++ b/gui/src/renderer/lib/routes.ts
@@ -18,6 +18,8 @@ export enum RoutePath {
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',
diff --git a/gui/src/renderer/redux/settings-import/actions.ts b/gui/src/renderer/redux/settings-import/actions.ts
new file mode 100644
index 0000000000..f31af1c1c6
--- /dev/null
+++ b/gui/src/renderer/redux/settings-import/actions.ts
@@ -0,0 +1,40 @@
+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
new file mode 100644
index 0000000000..76908bc67a
--- /dev/null
+++ b/gui/src/renderer/redux/settings-import/reducers.ts
@@ -0,0 +1,41 @@
+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
index 969f8b0a41..aa4e460e6f 100644
--- a/gui/src/renderer/redux/settings/actions.ts
+++ b/gui/src/renderer/redux/settings/actions.ts
@@ -7,6 +7,7 @@ import {
IDnsOptions,
IWireguardEndpointData,
ObfuscationSettings,
+ RelayOverride,
} from '../../../shared/daemon-rpc-types';
import { IGuiSettingsState } from '../../../shared/gui-settings-state';
import { BridgeSettingsRedux, IRelayLocationCountryRedux, RelaySettingsRedux } from './reducers';
@@ -116,6 +117,11 @@ export interface ISetCurrentApiAccessMethod {
accessMethod: AccessMethodSetting;
}
+export interface ISetRelayOverrides {
+ type: 'SET_RELAY_OVERRIDES';
+ relayOverrides: Array<RelayOverride>;
+}
+
export type SettingsAction =
| IUpdateGuiSettingsAction
| IUpdateRelayAction
@@ -137,7 +143,8 @@ export type SettingsAction =
| ISetObfuscationSettings
| ISetCustomLists
| ISetApiAccessMethods
- | ISetCurrentApiAccessMethod;
+ | ISetCurrentApiAccessMethod
+ | ISetRelayOverrides;
function updateGuiSettings(guiSettings: IGuiSettingsState): IUpdateGuiSettingsAction {
return {
@@ -298,6 +305,13 @@ function updateCurrentApiAccessMethod(setting: AccessMethodSetting): ISetCurrent
};
}
+function updateRelayOverrides(relayOverrides: Array<RelayOverride>): ISetRelayOverrides {
+ return {
+ type: 'SET_RELAY_OVERRIDES',
+ relayOverrides,
+ };
+}
+
export default {
updateGuiSettings,
updateRelay,
@@ -320,4 +334,5 @@ export default {
updateCustomLists,
updateApiAccessMethods,
updateCurrentApiAccessMethod,
+ updateRelayOverrides,
};
diff --git a/gui/src/renderer/redux/settings/reducers.ts b/gui/src/renderer/redux/settings/reducers.ts
index bb971f896a..07502ab280 100644
--- a/gui/src/renderer/redux/settings/reducers.ts
+++ b/gui/src/renderer/redux/settings/reducers.ts
@@ -16,6 +16,7 @@ import {
ProxySettings,
RelayEndpointType,
RelayLocation,
+ RelayOverride,
RelayProtocol,
TunnelProtocol,
} from '../../../shared/daemon-rpc-types';
@@ -112,6 +113,7 @@ export interface ISettingsReduxState {
customLists: CustomLists;
apiAccessMethods: ApiAccessMethodSettings;
currentApiAccessMethod?: AccessMethodSetting;
+ relayOverrides: Array<RelayOverride>;
}
const initialState: ISettingsReduxState = {
@@ -181,6 +183,7 @@ const initialState: ISettingsReduxState = {
customLists: [],
apiAccessMethods: getDefaultApiAccessMethods(),
currentApiAccessMethod: undefined,
+ relayOverrides: [],
};
export default function (
@@ -323,6 +326,12 @@ export default function (
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
index 9634774038..1617acce3d 100644
--- a/gui/src/renderer/redux/store.ts
+++ b/gui/src/renderer/redux/store.ts
@@ -9,6 +9,8 @@ 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';
@@ -23,6 +25,7 @@ export interface IReduxState {
support: ISupportReduxState;
version: IVersionReduxState;
userInterface: IUserInterfaceReduxState;
+ settingsImport: SettingsImportReduxState;
}
export type ReduxAction =
@@ -31,7 +34,8 @@ export type ReduxAction =
| SettingsAction
| SupportAction
| VersionAction
- | UserInterfaceAction;
+ | UserInterfaceAction
+ | SettingsImportAction;
export type ReduxStore = ReturnType<typeof configureStore>;
export type ReduxDispatch = Dispatch<ReduxAction>;
@@ -43,6 +47,7 @@ export default function configureStore() {
support: supportReducer,
version: versionReducer,
userInterface: userInterfaceReducer,
+ settingsImport: settingsImportReducer,
};
const rootReducer = combineReducers(reducers);
diff --git a/gui/src/shared/daemon-rpc-types.ts b/gui/src/shared/daemon-rpc-types.ts
index f048549b7a..e24c124f4c 100644
--- a/gui/src/shared/daemon-rpc-types.ts
+++ b/gui/src/shared/daemon-rpc-types.ts
@@ -435,6 +435,7 @@ export interface ISettings {
obfuscationSettings: ObfuscationSettings;
customLists: CustomLists;
apiAccessMethods: ApiAccessMethodSettings;
+ relayOverrides: Array<RelayOverride>;
}
export type BridgeState = 'auto' | 'on' | 'off';
@@ -539,6 +540,12 @@ export type ApiAccessMethodSettings = {
custom: Array<AccessMethodSetting>;
};
+export interface RelayOverride {
+ hostname: string;
+ ipv4AddrIn?: string;
+ ipv6AddrIn?: string;
+}
+
export function parseSocketAddress(socketAddrStr: string): ISocketAddress {
const re = new RegExp(/(.+):(\d+)$/);
const matches = socketAddrStr.match(re);
diff --git a/gui/src/shared/ipc-schema.ts b/gui/src/shared/ipc-schema.ts
index 5257bb7b78..b94fe0a701 100644
--- a/gui/src/shared/ipc-schema.ts
+++ b/gui/src/shared/ipc-schema.ts
@@ -159,6 +159,7 @@ export const ipcSchema = {
openUrl: invoke<string, void>(),
showOpenDialog: invoke<Electron.OpenDialogOptions, Electron.OpenDialogReturnValue>(),
showLaunchDaemonSettings: invoke<void, void>(),
+ getPathBaseName: invoke<string, string>(),
},
tunnel: {
'': notifyRenderer<TunnelState>(),
@@ -168,6 +169,8 @@ export const ipcSchema = {
},
settings: {
'': notifyRenderer<ISettings>(),
+ importFile: invoke<string, void>(),
+ importText: invoke<string, void>(),
apiAccessMethodSettingChange: notifyRenderer<AccessMethodSetting>(),
setAllowLan: invoke<boolean, void>(),
setShowBetaReleases: invoke<boolean, void>(),
@@ -187,6 +190,7 @@ export const ipcSchema = {
setApiAccessMethod: invoke<string, void>(),
testApiAccessMethodById: invoke<string, boolean>(),
testCustomApiAccessMethod: invoke<CustomProxy, boolean>(),
+ clearAllRelayOverrides: invoke<void, void>(),
},
guiSettings: {
'': notifyRenderer<IGuiSettingsState>(),
diff --git a/gui/src/shared/localization-contexts.ts b/gui/src/shared/localization-contexts.ts
index 081d5a622f..71a663def0 100644
--- a/gui/src/shared/localization-contexts.ts
+++ b/gui/src/shared/localization-contexts.ts
@@ -31,6 +31,7 @@ export type LocalizationContexts =
| 'split-tunneling-view'
| 'split-tunneling-nav'
| 'api-access-methods-view'
+ | 'settings-import'
| 'support-view'
| 'select-language-nav'
| 'tray-icon-context-menu'
diff --git a/gui/test/e2e/installed/state-dependent/settings-import.spec.ts b/gui/test/e2e/installed/state-dependent/settings-import.spec.ts
new file mode 100644
index 0000000000..6b37b36244
--- /dev/null
+++ b/gui/test/e2e/installed/state-dependent/settings-import.spec.ts
@@ -0,0 +1,116 @@
+import { expect, test } from '@playwright/test';
+import { Page } from 'playwright';
+
+import { startInstalledApp } from '../installed-utils';
+import { TestUtils } from '../../utils';
+import { RoutePath } from '../../../../src/renderer/lib/routes';
+
+const INVALID_JSON = 'invalid json';
+const VALID_JSON = `
+{
+ "relay_overrides": [
+ {
+ "hostname": "se-got-wg-001",
+ "ipv4_addr_in": "127.0.0.1"
+ }
+ ]
+}
+`;
+
+// This test expects the daemon to be logged in.
+
+let page: Page;
+let util: TestUtils;
+
+test.beforeAll(async () => {
+ ({ page, util } = await startInstalledApp());
+});
+
+test.afterAll(async () => {
+ await page.close();
+});
+
+async function navigateToSettingsImport() {
+ await util.waitForNavigation(async () => await page.click('button[aria-label="Settings"]'));
+ await util.waitForNavigation(async () => await page.getByText('VPN settings').click());
+
+ expect(
+ await util.waitForNavigation(async () => await page.getByText('Server IP override').click())
+ ).toEqual(RoutePath.settingsImport);
+
+ const title = page.locator('h1')
+ await expect(title).toHaveText('Server IP override');
+}
+
+test('App should display no overrides', async () => {
+ await navigateToSettingsImport();
+ await expect(page.getByTestId('status-title')).toHaveText('NO OVERRIDES IMPORTED');
+ await expect(page.getByText('Clear all overrides')).toBeDisabled();
+});
+
+test('App should fail to import text', async () => {
+ expect(
+ await util.waitForNavigation(async () => await page.getByText('Import via text').click())
+ ).toEqual(RoutePath.settingsTextImport);
+
+ await page.locator('textarea').fill(INVALID_JSON);
+ expect(
+ await util.waitForNavigation(async () => await page.click('button[aria-label="Save"]'))
+ ).toEqual(RoutePath.settingsImport);
+
+ await expect(page.getByTestId('status-title')).toHaveText('NO OVERRIDES IMPORTED');
+ await expect(page.getByTestId('status-subtitle')).toBeVisible();
+ await expect(page.getByText('Clear all overrides')).toBeDisabled();
+ await expect(page.getByTestId('status-subtitle')).not.toBeEmpty();
+});
+
+test('App should succeed to import text', async () => {
+ expect(
+ await util.waitForNavigation(async () => await page.getByText('Import via text').click())
+ ).toEqual(RoutePath.settingsTextImport);
+
+ const textarea = page.locator('textarea');
+ await expect(textarea).toHaveValue(INVALID_JSON);
+ await textarea.fill(VALID_JSON);
+ expect(
+ await util.waitForNavigation(async () => await page.click('button[aria-label="Save"]'))
+ ).toEqual(RoutePath.settingsImport);
+
+ await expect(page.getByTestId('status-title')).toHaveText('IMPORT SUCCESSFUL');
+ await expect(page.getByTestId('status-subtitle')).toBeVisible();
+ await expect(page.getByText('Clear all overrides')).toBeEnabled();
+ await expect(page.getByTestId('status-subtitle')).not.toBeEmpty();
+
+ await expect(page.getByTestId('status-title')).toHaveText('OVERRIDES ACTIVE');
+
+ expect(
+ await util.waitForNavigation(async () => await page.getByText('Import via text').click())
+ ).toEqual(RoutePath.settingsTextImport);
+
+ await expect(textarea).toHaveValue('');
+
+ expect(
+ await util.waitForNavigation(async () => await page.click('button[aria-label="Close"]'))
+ ).toEqual(RoutePath.settingsImport);
+});
+
+test('App should show active overrides', async () => {
+ expect(
+ await util.waitForNavigation(async () => await page.click('button[aria-label="Back"]'))
+ ).toEqual(RoutePath.vpnSettings);
+ expect(
+ await util.waitForNavigation(async () => await page.getByText('Server IP override').click())
+ ).toEqual(RoutePath.settingsImport);
+
+ await expect(page.getByTestId('status-title')).toHaveText('OVERRIDES ACTIVE');
+ await expect(page.getByText('Clear all overrides')).toBeEnabled();
+});
+
+test('App should clear overrides', async () => {
+ await page.getByText('Clear all overrides').click();
+ await expect(page.getByText('Clear all overrides?')).toBeVisible();
+
+ await page.getByText(/^Clear$/).click();
+ await expect(page.getByTestId('status-title')).toHaveText('NO OVERRIDES IMPORTED');
+ await expect(page.getByText(/Clear all overrides$/)).toBeDisabled();
+});