summaryrefslogtreecommitdiffhomepage
path: root/gui/src
diff options
context:
space:
mode:
authorOskar Nyberg <oskar@mullvad.net>2024-01-29 09:46:37 +0100
committerOskar Nyberg <oskar@mullvad.net>2024-01-29 09:46:37 +0100
commit3ad7afed8319d07639df483873cf94159c7cdadb (patch)
treefc9a971c1176b5a33c30afa31856b8f35cf8dcc6 /gui/src
parentfd4b9f08b3cf1501db6982e3c7dc6b8f1346bc28 (diff)
parent4776f2890fe3ca76c78fe646956e3eeae74efce6 (diff)
downloadmullvadvpn-3ad7afed8319d07639df483873cf94159c7cdadb.tar.xz
mullvadvpn-3ad7afed8319d07639df483873cf94159c7cdadb.zip
Merge branch 'api-access-methods'
Diffstat (limited to 'gui/src')
-rw-r--r--gui/src/config.json3
-rw-r--r--gui/src/main/daemon-rpc.ts237
-rw-r--r--gui/src/main/default-settings.ts1
-rw-r--r--gui/src/main/index.ts21
-rw-r--r--gui/src/main/settings.ts21
-rw-r--r--gui/src/renderer/app.tsx27
-rw-r--r--gui/src/renderer/components/ApiAccessMethods.tsx326
-rw-r--r--gui/src/renderer/components/AppRouter.tsx4
-rw-r--r--gui/src/renderer/components/AriaGroup.tsx7
-rw-r--r--gui/src/renderer/components/ContextMenu.tsx211
-rw-r--r--gui/src/renderer/components/CustomScrollbars.tsx5
-rw-r--r--gui/src/renderer/components/EditApiAccessMethod.tsx618
-rw-r--r--gui/src/renderer/components/InfoButton.tsx7
-rw-r--r--gui/src/renderer/components/Modal.tsx62
-rw-r--r--gui/src/renderer/components/Settings.tsx20
-rw-r--r--gui/src/renderer/components/SmallButton.tsx88
-rw-r--r--gui/src/renderer/components/Switch.tsx20
-rw-r--r--gui/src/renderer/components/cell/Label.tsx30
-rw-r--r--gui/src/renderer/components/cell/SettingsForm.tsx69
-rw-r--r--gui/src/renderer/components/cell/SettingsGroup.tsx99
-rw-r--r--gui/src/renderer/components/cell/SettingsRadioGroup.tsx119
-rw-r--r--gui/src/renderer/components/cell/SettingsRow.tsx136
-rw-r--r--gui/src/renderer/components/cell/SettingsSelect.tsx245
-rw-r--r--gui/src/renderer/components/cell/SettingsTextInput.tsx124
-rw-r--r--gui/src/renderer/components/common-styles.ts5
-rw-r--r--gui/src/renderer/components/select-location/custom-list-helpers.ts5
-rw-r--r--gui/src/renderer/lib/api-access-methods.ts81
-rw-r--r--gui/src/renderer/lib/routes.ts2
-rw-r--r--gui/src/renderer/redux/settings/actions.ts32
-rw-r--r--gui/src/renderer/redux/settings/reducers.ts18
-rw-r--r--gui/src/shared/daemon-rpc-types.ts57
-rw-r--r--gui/src/shared/ipc-schema.ts11
-rw-r--r--gui/src/shared/localization-contexts.ts1
-rw-r--r--gui/src/shared/utils.ts3
34 files changed, 2680 insertions, 35 deletions
diff --git a/gui/src/config.json b/gui/src/config.json
index 783be371bc..d716149c3e 100644
--- a/gui/src/config.json
+++ b/gui/src/config.json
@@ -7,6 +7,7 @@
"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)",
@@ -18,12 +19,14 @@
"white": "rgb(255, 255, 255)",
"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)",
diff --git a/gui/src/main/daemon-rpc.ts b/gui/src/main/daemon-rpc.ts
index d5d29ad709..f93258a589 100644
--- a/gui/src/main/daemon-rpc.ts
+++ b/gui/src/main/daemon-rpc.ts
@@ -8,10 +8,13 @@ import {
import { promisify } from 'util';
import {
+ AccessMethod,
+ AccessMethodSetting,
AccountDataError,
AccountDataResponse,
AccountToken,
AfterDisconnect,
+ ApiAccessMethodSettings,
AuthFailedError,
BridgeSettings,
BridgeState,
@@ -20,6 +23,7 @@ import {
Constraint,
CustomListError,
CustomLists,
+ CustomProxy,
DaemonEvent,
DeviceEvent,
DeviceState,
@@ -49,6 +53,7 @@ import {
IWireguardEndpointData,
LoggedInDeviceState,
LoggedOutDeviceState,
+ NewAccessMethodSetting,
ObfuscationSettings,
ObfuscationType,
Ownership,
@@ -58,6 +63,7 @@ import {
RelayLocationGeographical,
RelayProtocol,
RelaySettings,
+ SocksAuth,
TunnelParameterError,
TunnelProtocol,
TunnelState,
@@ -642,6 +648,55 @@ export class DaemonRpc {
}
}
+ 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();
+ }
+
private subscriptionId(): number {
const current = this.nextSubscriptionId;
this.nextSubscriptionId += 1;
@@ -1102,6 +1157,7 @@ function convertFromSettings(settings: grpcTypes.Settings): ISettings | undefine
const splitTunnel = settingsObject.splitTunnel ?? { enableExclusions: false, appsList: [] };
const obfuscationSettings = convertFromObfuscationSettings(settingsObject.obfuscationSettings);
const customLists = convertFromCustomListSettings(settings.getCustomLists());
+ const apiAccessMethods = convertFromApiAccessMethodSettings(settings.getApiAccessMethods());
return {
...settings.toObject(),
bridgeState,
@@ -1111,6 +1167,7 @@ function convertFromSettings(settings: grpcTypes.Settings): ISettings | undefine
splitTunnel,
obfuscationSettings,
customLists,
+ apiAccessMethods,
};
}
@@ -1411,6 +1468,11 @@ function convertFromDaemonEvent(data: grpcTypes.DaemonEvent): DaemonEvent {
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)
@@ -1734,6 +1796,181 @@ function convertToCustomList(customList: ICustomList): grpcTypes.CustomList {
return grpcCustomList;
}
+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);
+}
+
+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;
+ }
+ default:
+ accessMethod.setCustom(convertToCustomProxy(method));
+ }
+
+ newMethod.setAccessMethod(accessMethod);
+ return newMethod;
+}
+
+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 {
+ return (
+ accessMethods
+ ?.getAccessMethodSettingsList()
+ .filter((setting) => setting.hasId() && setting.hasAccessMethod())
+ .map(convertFromApiAccessMethodSetting) ?? []
+ );
+}
+
+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.CUSTOM: {
+ const proxy = method.getCustom()!;
+ 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');
+ }
+ // This break is required to prevent eslint from complainting about fallthrough, even though
+ // all cases are covered above.
+ break;
+ }
+ case grpcTypes.AccessMethod.AccessMethodCase.ACCESS_METHOD_NOT_SET:
+ throw new Error('Access method not set, which should always be set');
+ }
+}
+
+function convertFromSocksAuth(auth: grpcTypes.SocksAuth): SocksAuth {
+ return {
+ username: auth.getUsername(),
+ password: auth.getPassword(),
+ };
+}
+
function ensureExists<T>(value: T | undefined, errorMessage: string): T {
if (value) {
return value;
diff --git a/gui/src/main/default-settings.ts b/gui/src/main/default-settings.ts
index fea88e4c27..4510a71896 100644
--- a/gui/src/main/default-settings.ts
+++ b/gui/src/main/default-settings.ts
@@ -71,5 +71,6 @@ export function getDefaultSettings(): ISettings {
},
},
customLists: [],
+ apiAccessMethods: [],
};
}
diff --git a/gui/src/main/index.ts b/gui/src/main/index.ts
index 6c3a6373fb..3aa2cc3568 100644
--- a/gui/src/main/index.ts
+++ b/gui/src/main/index.ts
@@ -8,6 +8,7 @@ import config from '../config.json';
import { hasExpired } from '../shared/account-expiry';
import { IWindowsApplication } from '../shared/application-types';
import {
+ AccessMethodSetting,
DaemonEvent,
DeviceEvent,
IRelayListWithEndpointData,
@@ -117,6 +118,8 @@ class ApplicationMain
private relayList?: IRelayListWithEndpointData;
+ private currentApiAccessMethod?: AccessMethodSetting;
+
public constructor() {
this.daemonRpc = new DaemonRpc(
new ConnectionObserver(this.onDaemonConnected, this.onDaemonDisconnected),
@@ -550,6 +553,19 @@ class ApplicationMain
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();
}
@@ -658,6 +674,10 @@ class ApplicationMain
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) => {
@@ -726,6 +746,7 @@ class ApplicationMain
changelog: this.changelog ?? [],
forceShowChanges: CommandLineOptions.showChanges.match,
navigationHistory: this.navigationHistory,
+ currentApiAccessMethod: this.currentApiAccessMethod,
}));
IpcMainEventChannel.tunnel.handleConnect(this.connectTunnel);
diff --git a/gui/src/main/settings.ts b/gui/src/main/settings.ts
index fc2450581f..71a973daeb 100644
--- a/gui/src/main/settings.ts
+++ b/gui/src/main/settings.ts
@@ -71,6 +71,24 @@ export default class Settings implements Readonly<ISettings> {
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.guiSettings.handleSetEnableSystemNotifications((flag: boolean) => {
this.guiSettings.enableSystemNotifications = flag;
@@ -135,6 +153,9 @@ export default class Settings implements Readonly<ISettings> {
public get customLists() {
return this.settingsValue.customLists;
}
+ public get apiAccessMethods() {
+ return this.settingsValue.apiAccessMethods;
+ }
public get gui() {
return this.guiSettings;
diff --git a/gui/src/renderer/app.tsx b/gui/src/renderer/app.tsx
index 3806c12413..01d76474d4 100644
--- a/gui/src/renderer/app.tsx
+++ b/gui/src/renderer/app.tsx
@@ -6,9 +6,11 @@ import { StyleSheetManager } from 'styled-components';
import { hasExpired } from '../shared/account-expiry';
import { ILinuxSplitTunnelingApplication, IWindowsApplication } from '../shared/application-types';
import {
+ AccessMethodSetting,
AccountToken,
BridgeSettings,
BridgeState,
+ CustomProxy,
DeviceEvent,
DeviceState,
IAccountData,
@@ -21,6 +23,7 @@ import {
IRelayListWithEndpointData,
ISettings,
liftConstraint,
+ NewAccessMethodSetting,
ObfuscationSettings,
RelaySettings,
TunnelState,
@@ -155,6 +158,10 @@ export default class AppRenderer {
this.updateBlockedState(this.tunnelState, newSettings.blockWhenDisconnected);
});
+ IpcRendererEventChannel.settings.listenApiAccessMethodSettingChange((setting) => {
+ this.setCurrentApiAccessMethod(setting);
+ });
+
IpcRendererEventChannel.relays.listen((relayListPair: IRelayListWithEndpointData) => {
this.setRelayListPair(relayListPair);
});
@@ -231,6 +238,7 @@ export default class AppRenderer {
this.setGuiSettings(initialState.guiSettings);
this.storeAutoStart(initialState.autoStart);
this.setChangelog(initialState.changelog, initialState.forceShowChanges);
+ this.setCurrentApiAccessMethod(initialState.currentApiAccessMethod);
if (initialState.macOsScrollbarVisibility !== undefined) {
this.reduxActions.userInterface.setMacOsScrollbarVisibility(
@@ -343,6 +351,18 @@ export default class AppRenderer {
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 login = async (accountToken: AccountToken) => {
const actions = this.reduxActions;
@@ -782,6 +802,7 @@ export default class AppRenderer {
reduxSettings.updateSplitTunnelingState(newSettings.splitTunnel.enableExclusions);
reduxSettings.updateObfuscationSettings(newSettings.obfuscationSettings);
reduxSettings.updateCustomLists(newSettings.customLists);
+ reduxSettings.updateApiAccessMethods(newSettings.apiAccessMethods);
this.setReduxRelaySettings(newSettings.relaySettings);
this.setBridgeSettings(newSettings.bridgeSettings);
@@ -963,6 +984,12 @@ export default class AppRenderer {
}
}
+ private setCurrentApiAccessMethod(method?: AccessMethodSetting) {
+ if (method) {
+ this.reduxActions.settings.updateCurrentApiAccessMethod(method);
+ }
+ }
+
private getLocationFromConstraints(): Partial<ILocation> {
const state = this.reduxStore.getState();
const coordinates = {
diff --git a/gui/src/renderer/components/ApiAccessMethods.tsx b/gui/src/renderer/components/ApiAccessMethods.tsx
new file mode 100644
index 0000000000..c8a6f98b19
--- /dev/null
+++ b/gui/src/renderer/components/ApiAccessMethods.tsx
@@ -0,0 +1,326 @@
+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/utilityHooks';
+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, 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)({
+ marginRight: '8px',
+});
+
+const StyledTitleInfoButton = styled(InfoButton)({
+ marginLeft: '12px',
+});
+
+const StyledMethodInfoButton = styled(InfoButton)({
+ marginRight: '11px',
+});
+
+const StyledSpinner = styled(ImageView)({
+ height: '10px',
+ width: '10px',
+ marginRight: '6px',
+});
+
+const StyledNameLabel = styled(Cell.Label)({
+ 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>
+ </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>
+ <HeaderSubTitle>
+ {messages.pgettext(
+ 'api-access-methods-view',
+ 'Manage and add custom methods to access the Mullvad API.',
+ )}
+ </HeaderSubTitle>
+ </SettingsHeader>
+
+ <StyledSettingsContent>
+ <Cell.Group>
+ {methods.map((method) => (
+ <ApiAccessMethod
+ key={method.id}
+ method={method}
+ inUse={method.id === currentMethod?.id}
+ />
+ ))}
+ </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;
+}
+
+function ApiAccessMethod(props: ApiAccessMethodProps) {
+ const {
+ setApiAccessMethod: setApiAccessMethodImpl,
+ updateApiAccessMethod,
+ removeApiAccessMethod,
+ } = useAppContext();
+ const history = 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();
+ }, [props.method.id]);
+
+ // 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],
+ );
+
+ const setApiAccessMethod = useCallback(async () => {
+ const reachable = await testApiAccessMethod(props.method.id);
+ if (reachable) {
+ await setApiAccessMethodImpl(props.method.id);
+ }
+ }, [testApiAccessMethod, props.method.id]);
+
+ const menuItems = useMemo<Array<ContextMenuItem>>(
+ () => [
+ {
+ type: 'item' as const,
+ label: 'Use',
+ disabled: props.inUse,
+ onClick: setApiAccessMethod,
+ },
+ { type: 'item' as const, label: 'Test', onClick: () => testApiAccessMethod(props.method.id) },
+ // Edit and Delete shouldn't be available for direct and bridges.
+ ...(props.method.type === 'direct' || props.method.type === 'bridges'
+ ? []
+ : [
+ { type: 'separator' as const },
+ {
+ type: 'item' as const,
+ label: 'Edit',
+ onClick: () =>
+ history.push(
+ generateRoutePath(RoutePath.editApiAccessMethods, { id: props.method.id }),
+ ),
+ },
+ {
+ type: 'item' as const,
+ label: 'Delete',
+ onClick: showRemoveConfirmation,
+ },
+ ]),
+ ],
+ [props.method.id, props.inUse, setApiAccessMethod, testApiAccessMethod, history.push],
+ );
+
+ return (
+ <Cell.Row>
+ <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.',
+ ),
+ ]}
+ />
+ )}
+ <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/AppRouter.tsx b/gui/src/renderer/components/AppRouter.tsx
index aa474e30d5..6c45083290 100644
--- a/gui/src/renderer/components/AppRouter.tsx
+++ b/gui/src/renderer/components/AppRouter.tsx
@@ -7,9 +7,11 @@ 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 Connect from './Connect';
import Debug from './Debug';
import { DeviceRevokedView } from './DeviceRevokedView';
+import { EditApiAccessMethod } from './EditApiAccessMethod';
import {
SetupFinished,
TimeAdded,
@@ -80,6 +82,8 @@ export default function AppRouter() {
<Route exact path={RoutePath.wireguardSettings} component={WireguardSettings} />
<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.editApiAccessMethods} component={EditApiAccessMethod} />
<Route exact path={RoutePath.support} component={Support} />
<Route exact path={RoutePath.problemReport} component={ProblemReport} />
<Route exact path={RoutePath.debug} component={Debug} />
diff --git a/gui/src/renderer/components/AriaGroup.tsx b/gui/src/renderer/components/AriaGroup.tsx
index 9f7755cb6a..8adf3243af 100644
--- a/gui/src/renderer/components/AriaGroup.tsx
+++ b/gui/src/renderer/components/AriaGroup.tsx
@@ -16,6 +16,7 @@ const AriaControlContext = React.createContext<IAriaControlContext>({
});
interface IAriaGroupProps {
+ describedId?: string;
children: React.ReactNode;
}
@@ -49,11 +50,11 @@ export function AriaDescriptionGroup(props: IAriaGroupProps) {
const contextValue = useMemo(
() => ({
- describedId: `${id}-described`,
+ describedId: props.describedId ?? `${id}-described`,
descriptionId: hasDescription ? `${id}-description` : undefined,
setHasDescription,
}),
- [hasDescription],
+ [hasDescription, props.describedId],
);
return (
@@ -94,7 +95,7 @@ export function AriaInputGroup(props: IAriaGroupProps) {
);
return (
- <AriaDescriptionGroup>
+ <AriaDescriptionGroup describedId={contextValue.inputId}>
<AriaInputContext.Provider value={contextValue}>{props.children}</AriaInputContext.Provider>
</AriaDescriptionGroup>
);
diff --git a/gui/src/renderer/components/ContextMenu.tsx b/gui/src/renderer/components/ContextMenu.tsx
new file mode 100644
index 0000000000..f80b456d46
--- /dev/null
+++ b/gui/src/renderer/components/ContextMenu.tsx
@@ -0,0 +1,211 @@
+import React, { useCallback, useContext, useEffect, useMemo } from 'react';
+import styled from 'styled-components';
+
+import { colors } from '../../config.json';
+import { useBoolean, useStyledRef } from '../lib/utilityHooks';
+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',
+});
+
+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.current]);
+
+ const contextValue = useMemo(
+ () => ({
+ getTriggerBounds,
+ toggleVisibility,
+ visible,
+ hide,
+ }),
+ [getTriggerBounds, visible],
+ );
+
+ const clickOutsideListener = useCallback(
+ (event: MouseEvent) => {
+ if (
+ visible &&
+ event.target !== null &&
+ ref.current?.contains(event.target as HTMLElement) === false
+ ) {
+ hide();
+ }
+ },
+ [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>
+ );
+}
+
+export function ContextMenuTrigger(props: React.PropsWithChildren) {
+ const { toggleVisibility } = useContext(menuContext);
+
+ return <div onClick={toggleVisibility}>{props.children}</div>;
+}
+
+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 onClick = useCallback(() => {
+ if (!props.item.disabled) {
+ props.closeMenu();
+ props.item.onClick();
+ }
+ }, [props.closeMenu, props.item.disabled, props.item.onClick]);
+
+ return (
+ <StyledMenuItem onClick={onClick} disabled={props.item.disabled}>
+ {props.item.label}
+ </StyledMenuItem>
+ );
+}
diff --git a/gui/src/renderer/components/CustomScrollbars.tsx b/gui/src/renderer/components/CustomScrollbars.tsx
index 65db8c87fe..45e7ad521d 100644
--- a/gui/src/renderer/components/CustomScrollbars.tsx
+++ b/gui/src/renderer/components/CustomScrollbars.tsx
@@ -542,8 +542,9 @@ class CustomScrollbars extends React.Component<IProps, IState> {
const thumbHeight = this.computeThumbHeight(scrollable);
thumb.style.setProperty('height', thumbHeight + 'px');
- // hide thumb when there is nothing to scroll
- const canScroll = thumbHeight < scrollable.offsetHeight;
+ // 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 });
diff --git a/gui/src/renderer/components/EditApiAccessMethod.tsx b/gui/src/renderer/components/EditApiAccessMethod.tsx
new file mode 100644
index 0000000000..578c8fe31a
--- /dev/null
+++ b/gui/src/renderer/components/EditApiAccessMethod.tsx
@@ -0,0 +1,618 @@
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import { useParams } from 'react-router';
+import { sprintf } from 'sprintf-js';
+
+import {
+ AccessMethod,
+ AccessMethodSetting,
+ CustomProxy,
+ NewAccessMethodSetting,
+ RelayProtocol,
+ ShadowsocksAccessMethod,
+ Socks5LocalAccessMethod,
+ Socks5RemoteAccessMethod,
+} 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 { IpAddress } from '../lib/ip';
+import { useSelector } from '../redux/store';
+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 { BackAction } from './KeyboardNavigation';
+import { Layout, SettingsContainer } from './Layout';
+import { ModalAlert, ModalAlertType } from './Modal';
+import { NavigationBar, NavigationContainer, NavigationItems, TitleBarItem } from './NavigationBar';
+import SettingsHeader, { HeaderSubTitle, HeaderTitle } from './SettingsHeader';
+import { StyledContent, StyledNavigationScrollbars, StyledSettingsContent } from './SettingsStyles';
+import { SmallButton, SmallButtonGroup } from './SmallButton';
+
+export function EditApiAccessMethod() {
+ return (
+ <SettingsForm>
+ <AccessMethodForm></AccessMethodForm>
+ </SettingsForm>
+ );
+}
+
+function AccessMethodForm() {
+ const history = useHistory();
+ const { addApiAccessMethod, updateApiAccessMethod } = useAppContext();
+ const methods = useSelector((state) => state.settings.apiAccessMethods);
+
+ 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 = useRef<NewAccessMethodSetting | undefined>(method);
+ const updateMethod = useCallback(
+ (method: NewAccessMethodSetting) => (updatedMethod.current = method),
+ [],
+ );
+
+ // Contains form submittability to know whether or not to enable the Add/Save button.
+ const formSubmittable = useSettingsFormSubmittable();
+
+ const save = useCallback(() => {
+ if (updatedMethod.current !== undefined) {
+ resetTestResult();
+ if (id === undefined) {
+ void addApiAccessMethod(updatedMethod.current);
+ } else {
+ void updateApiAccessMethod({ ...updatedMethod.current, id });
+ }
+ history.pop();
+ }
+ }, [updatedMethod.current, id]);
+
+ const onSave = useCallback(async () => {
+ if (
+ updatedMethod.current !== undefined &&
+ (await testApiAccessMethod(updatedMethod.current as CustomProxy))
+ ) {
+ // Hide the save dialog after 1.5 seconds.
+ saveScheduler.schedule(save, 1500);
+ }
+ }, [updatedMethod, save, history.pop]);
+
+ const title = getTitle(id !== undefined);
+ const subtitle = getSubtitle(id !== undefined);
+
+ return (
+ <BackAction action={history.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>
+ ) : (
+ <AccessMethodFormImpl method={method} updateMethod={updateMethod} />
+ )}
+
+ <SmallButtonGroup>
+ <SmallButton onClick={history.pop}>{messages.gettext('Cancel')}</SmallButton>
+ <SmallButton onClick={onSave} disabled={!formSubmittable}>
+ {id === undefined ? messages.gettext('Add') : messages.gettext('Save')}
+ </SmallButton>
+ </SmallButtonGroup>
+ </StyledSettingsContent>
+
+ <TestingDialog
+ name={updatedMethod.current?.name ?? ''}
+ newMethod={id === undefined}
+ testing={testing}
+ testResult={testResult}
+ cancel={resetTestResult}
+ save={save}
+ />
+ </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) {
+ const type = props.testing
+ ? ModalAlertType.loading
+ : props.testResult
+ ? ModalAlertType.success
+ : ModalAlertType.failure;
+ const prevType = useRef<ModalAlertType>(type);
+
+ const isOpen = props.testing || props.testResult !== undefined;
+ const typeValue = isOpen ? type : prevType.current;
+
+ useEffect(() => {
+ if (isOpen) {
+ prevType.current = type;
+ }
+ }, [type]);
+
+ return (
+ <ModalAlert
+ isOpen={isOpen}
+ type={typeValue}
+ gridButtons={getTestingDialogButtons(typeValue, props.save, props.cancel)}
+ close={props.cancel}
+ title={getTestingDialogTitle(typeValue, props.newMethod)}
+ message={getTestingDialogSubTitle(typeValue, 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 },
+ )
+ : messages.pgettext(
+ 'api-access-methods-view',
+ 'Clicking “Save” changes the in use method.',
+ );
+ 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];
+ }
+}
+
+interface EditApiAccessMethodImplProps {
+ method?: AccessMethodSetting;
+ updateMethod: (method: NewAccessMethodSetting) => void;
+}
+
+function AccessMethodFormImpl(props: EditApiAccessMethodImplProps) {
+ // Available method types.
+ const types = useMemo<Array<SettingsSelectItem<AccessMethod['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(props.method?.type ?? 'shadowsocks');
+
+ // State for the name input.
+ const name = useRef(props.method?.name ?? '');
+ const updateName = useCallback((value: string) => (name.current = value), []);
+
+ // When the form makes up a valid method the parent is updated.
+ const updateMethod = useCallback((value: AccessMethod) => {
+ if (name.current !== '') {
+ props.updateMethod({ ...value, name: name.current, enabled: true });
+ }
+ }, []);
+
+ return (
+ <>
+ <SettingsRow label={messages.gettext('Name')}>
+ <SettingsTextInput
+ defaultValue={name.current}
+ placeholder={messages.pgettext('api-access-methods-view', 'Enter name')}
+ onUpdate={updateName}
+ />
+ </SettingsRow>
+
+ <SettingsRow label={messages.gettext('Type')}>
+ <SettingsSelect defaultValue={type} onUpdate={setType} items={types} />
+ </SettingsRow>
+
+ {type === 'shadowsocks' && (
+ <EditShadowsocks
+ onUpdate={updateMethod}
+ method={props.method?.type === 'shadowsocks' ? props.method : undefined}
+ />
+ )}
+ {type === 'socks5-remote' && (
+ <EditSocks5Remote
+ onUpdate={updateMethod}
+ method={props.method?.type === 'socks5-remote' ? props.method : undefined}
+ />
+ )}
+ {type === 'socks5-local' && (
+ <EditSocks5Local
+ onUpdate={updateMethod}
+ method={props.method?.type === 'socks5-local' ? props.method : undefined}
+ />
+ )}
+ </>
+ );
+}
+
+interface EditMethodProps<T> {
+ method?: T;
+ onUpdate: (method: AccessMethod) => void;
+}
+
+function EditShadowsocks(props: EditMethodProps<ShadowsocksAccessMethod>) {
+ const [ip, setIp] = useState(props.method?.ip ?? '');
+ const [port, setPort] = useState(props.method?.port);
+ const [password, setPassword] = useState(props.method?.password ?? '');
+ const [cipher, setCipher] = useState(props.method?.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)),
+ [],
+ );
+
+ // Report back to form component with the method values when all required values are set.
+ useEffect(() => {
+ if (ip !== '' && port !== undefined && cipher !== undefined) {
+ props.onUpdate({
+ type: 'shadowsocks',
+ 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 direction="up" defaultValue={cipher} onUpdate={setCipher} items={ciphers} />
+ </SettingsRow>
+ </SettingsGroup>
+ );
+}
+
+function EditSocks5Remote(props: EditMethodProps<Socks5RemoteAccessMethod>) {
+ const [ip, setIp] = useState(props.method?.ip ?? '');
+ const [port, setPort] = useState(props.method?.port);
+ const [authentication, setAuthentication] = useState(props.method?.authentication !== undefined);
+ const [username, setUsername] = useState(props.method?.authentication?.username ?? '');
+ const [password, setPassword] = useState(props.method?.authentication?.password ?? '');
+
+ // Report back to form component with the method values when all required values are set.
+ useEffect(() => {
+ if (
+ ip !== '' &&
+ port !== undefined &&
+ (!authentication || (username !== '' && password !== ''))
+ ) {
+ props.onUpdate({
+ type: 'socks5-remote',
+ ip,
+ port,
+ authentication: authentication ? { username, password } : undefined,
+ });
+ }
+ }, [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: EditMethodProps<Socks5LocalAccessMethod>) {
+ const [remoteIp, setRemoteIp] = useState(props.method?.remoteIp ?? '');
+ const [remotePort, setRemotePort] = useState(props.method?.remotePort);
+ const [remoteTransportProtocol, setRemoteTransportProtocol] = useState<RelayProtocol>(
+ props.method?.remoteTransportProtocol ?? 'tcp',
+ );
+ const [localPort, setLocalPort] = useState(props.method?.localPort);
+
+ const remoteTransportProtocols = useMemo<Array<SettingsSelectItem<RelayProtocol>>>(
+ () => [
+ { value: 'tcp', label: 'TCP' },
+ { value: 'udp', label: 'UDP' },
+ ],
+ [],
+ );
+
+ useEffect(() => {
+ if (remoteIp !== '' && remotePort !== undefined && localPort !== undefined) {
+ props.onUpdate({
+ type: 'socks5-local',
+ remoteIp,
+ remotePort,
+ remoteTransportProtocol,
+ localPort,
+ });
+ }
+ }, [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/InfoButton.tsx b/gui/src/renderer/components/InfoButton.tsx
index 302b043ef4..8807356d79 100644
--- a/gui/src/renderer/components/InfoButton.tsx
+++ b/gui/src/renderer/components/InfoButton.tsx
@@ -33,12 +33,13 @@ export function InfoIcon(props: IInfoIconProps) {
}
interface IInfoButtonProps extends React.HTMLAttributes<HTMLButtonElement> {
- message?: string;
+ message?: string | Array<string>;
children?: React.ReactNode;
+ size?: number;
}
export default function InfoButton(props: IInfoButtonProps) {
- const { message, children, ...otherProps } = props;
+ const { message, children, size, ...otherProps } = props;
const [isOpen, show, hide] = useBoolean(false);
return (
@@ -47,7 +48,7 @@ export default function InfoButton(props: IInfoButtonProps) {
onClick={show}
aria-label={messages.pgettext('accessibility', 'More information')}
{...otherProps}>
- <InfoIcon />
+ <InfoIcon size={size} />
</StyledInfoButton>
<ModalAlert
isOpen={isOpen}
diff --git a/gui/src/renderer/components/Modal.tsx b/gui/src/renderer/components/Modal.tsx
index e87e2113f2..2fd676a4ae 100644
--- a/gui/src/renderer/components/Modal.tsx
+++ b/gui/src/renderer/components/Modal.tsx
@@ -6,10 +6,11 @@ import { colors } from '../../config.json';
import log from '../../shared/logging';
import { useWillExit } from '../lib/will-exit';
import * as AppButton from './AppButton';
-import { measurements, tinyText } from './common-styles';
+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';
@@ -101,6 +102,10 @@ export enum ModalAlertType {
info = 1,
caution,
warning,
+
+ loading,
+ success,
+ failure,
}
const ModalAlertContainer = styled.div({
@@ -147,6 +152,10 @@ const ModalAlertButtonGroupContainer = styled.div({
marginTop: measurements.buttonVerticalMargin,
});
+const StyledSmallButtonGrid = styled(SmallButtonGrid)({
+ marginRight: '16px',
+});
+
const ModalAlertButtonContainer = styled.div({
display: 'flex',
flexDirection: 'column',
@@ -156,8 +165,10 @@ const ModalAlertButtonContainer = styled.div({
interface IModalAlertProps {
type?: ModalAlertType;
iconColor?: string;
- message?: string;
- buttons: React.ReactNode[];
+ title?: string;
+ message?: string | Array<string>;
+ buttons?: React.ReactNode[];
+ gridButtons?: React.ReactNode[];
children?: React.ReactNode;
close?: () => void;
}
@@ -252,6 +263,9 @@ class ModalAlertImpl extends React.Component<IModalAlertImplProps, IModalAlertSt
}
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}>
@@ -268,16 +282,23 @@ class ModalAlertImpl extends React.Component<IModalAlertImplProps, IModalAlertSt
{this.props.type && (
<ModalAlertIcon>{this.renderTypeIcon(this.props.type)}</ModalAlertIcon>
)}
- {this.props.message && <ModalMessage>{this.props.message}</ModalMessage>}
+ {this.props.title && <ModalTitle>{this.props.title}</ModalTitle>}
+ {messages &&
+ messages.map((message) => <ModalMessage key={message}>{message}</ModalMessage>)}
{this.props.children}
</StyledCustomScrollbars>
<ModalAlertButtonGroupContainer>
- <AppButton.ButtonGroup>
- {this.props.buttons.map((button, index) => (
- <ModalAlertButtonContainer key={index}>{button}</ModalAlertButtonContainer>
- ))}
- </AppButton.ButtonGroup>
+ {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>
@@ -292,7 +313,7 @@ class ModalAlertImpl extends React.Component<IModalAlertImplProps, IModalAlertSt
private renderTypeIcon(type: ModalAlertType) {
let source = '';
- let color = '';
+ let color = undefined;
switch (type) {
case ModalAlertType.info:
source = 'icon-info';
@@ -306,7 +327,18 @@ class ModalAlertImpl extends React.Component<IModalAlertImplProps, IModalAlertSt
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} />
);
@@ -319,7 +351,17 @@ class ModalAlertImpl extends React.Component<IModalAlertImplProps, IModalAlertSt
};
}
+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',
+ },
});
diff --git a/gui/src/renderer/components/Settings.tsx b/gui/src/renderer/components/Settings.tsx
index 779814d3f4..53dbb386ff 100644
--- a/gui/src/renderer/components/Settings.tsx
+++ b/gui/src/renderer/components/Settings.tsx
@@ -72,6 +72,10 @@ export default function Support() {
)}
<Cell.Group>
+ <ApiAccessMethodsButton />
+ </Cell.Group>
+
+ <Cell.Group>
<SupportButton />
<AppVersionButton />
</Cell.Group>
@@ -136,6 +140,22 @@ function SplitTunnelingButton() {
);
}
+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 methods')
+ }
+ </Cell.Label>
+ </Cell.CellNavigationButton>
+ );
+}
+
function AppVersionButton() {
const appVersion = useSelector((state) => state.version.current);
const consistentVersion = useSelector((state) => state.version.consistent);
diff --git a/gui/src/renderer/components/SmallButton.tsx b/gui/src/renderer/components/SmallButton.tsx
new file mode 100644
index 0000000000..181a557fd2
--- /dev/null
+++ b/gui/src/renderer/components/SmallButton.tsx
@@ -0,0 +1,88 @@
+import React from 'react';
+import styled from 'styled-components';
+
+import { colors } from '../../config.json';
+import { smallText } from './common-styles';
+
+export enum SmallButtonColor {
+ blue,
+ red,
+}
+
+function getButtonColors(color?: SmallButtonColor, disabled?: boolean) {
+ switch (color) {
+ case SmallButtonColor.red:
+ return {
+ background: disabled ? colors.red60 : colors.red,
+ backgroundHover: disabled ? colors.red60 : colors.red80,
+ };
+ default:
+ return {
+ background: disabled ? colors.blue50 : colors.blue,
+ backgroundHover: disabled ? colors.blue50 : colors.blue60,
+ };
+ }
+}
+
+const StyledSmallButton = styled.button<{ $color?: SmallButtonColor; disabled?: boolean }>(
+ smallText,
+ (props) => {
+ const buttonColors = getButtonColors(props.$color, props.disabled);
+ return {
+ height: '32px',
+ padding: '5px 16px',
+ border: 'none',
+ background: buttonColors.background,
+ color: props.disabled ? colors.white50 : colors.white,
+ borderRadius: '4px',
+ marginLeft: '12px',
+
+ [`${StyledSmallButtonGrid} &&`]: {
+ marginLeft: 0,
+ },
+
+ '&&:hover': {
+ background: buttonColors.backgroundHover,
+ },
+ };
+ },
+);
+
+interface SmallButtonProps
+ extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, 'onClick' | 'color'> {
+ onClick: () => void;
+ children: string;
+ color?: SmallButtonColor;
+}
+
+export function SmallButton(props: SmallButtonProps) {
+ const { color, ...otherProps } = props;
+ return <StyledSmallButton $color={props.color} {...otherProps} />;
+}
+
+export const SmallButtonGroup = styled.div<{ $noMarginTop?: boolean }>((props) => ({
+ display: 'flex',
+ justifyContent: 'end',
+ margin: '0 23px',
+ marginTop: props.$noMarginTop ? 0 : '30px',
+}));
+
+const StyledSmallButtonGrid = styled.div<{ $columns: number }>((props) => ({
+ display: 'grid',
+ gridTemplateColumns: `repeat(${props.$columns}, 1fr)`,
+ gridColumnGap: '10px',
+}));
+
+interface SmallButtonGridProps {
+ className?: string;
+}
+
+export function SmallButtonGrid(props: React.PropsWithChildren<SmallButtonGridProps>) {
+ return (
+ <StyledSmallButtonGrid
+ $columns={React.Children.count(props.children)}
+ className={props.className}>
+ {props.children}
+ </StyledSmallButtonGrid>
+ );
+}
diff --git a/gui/src/renderer/components/Switch.tsx b/gui/src/renderer/components/Switch.tsx
index e5aaec4bb0..595bc422a2 100644
--- a/gui/src/renderer/components/Switch.tsx
+++ b/gui/src/renderer/components/Switch.tsx
@@ -16,13 +16,13 @@ interface IProps {
const SwitchContainer = styled.div<{ disabled: boolean }>((props) => ({
position: 'relative',
- width: '48px',
- height: '30px',
+ width: '34px',
+ height: '22px',
borderColor: props.disabled ? colors.white20 : colors.white80,
borderWidth: '2px',
borderStyle: 'solid',
- borderRadius: '16px',
- padding: '2px',
+ borderRadius: '11px',
+ padding: '1px',
}));
const Knob = styled.div<{ $isOn: boolean; disabled: boolean }>((props) => {
@@ -33,14 +33,14 @@ const Knob = styled.div<{ $isOn: boolean; disabled: boolean }>((props) => {
return {
position: 'absolute',
- height: '22px',
- borderRadius: '11px',
+ height: '16px',
+ borderRadius: '8px',
transition: 'all 200ms linear',
- width: '22px',
+ width: '16px',
backgroundColor,
- // When enabled the button should be placed all the way to the right (100%) minus padding (2px)
- // minus it's own width (22px).
- left: props.$isOn ? 'calc(100% - 2px - 22px)' : '2px',
+ // 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',
};
});
diff --git a/gui/src/renderer/components/cell/Label.tsx b/gui/src/renderer/components/cell/Label.tsx
index 780b593a1b..729b7dc31d 100644
--- a/gui/src/renderer/components/cell/Label.tsx
+++ b/gui/src/renderer/components/cell/Label.tsx
@@ -12,6 +12,13 @@ const StyledLabel = styled.div<{ disabled: boolean }>(buttonText, (props) => ({
flex: 1,
color: props.disabled ? colors.white40 : colors.white,
textAlign: 'left',
+
+ [`${LabelContainer} &&`]: {
+ marginTop: '5px',
+ marginBottom: 0,
+ height: '20px',
+ lineHeight: '20px',
+ },
}));
const StyledSubText = styled.span<{ disabled: boolean }>(tinyText, (props) => ({
@@ -31,13 +38,29 @@ const StyledTintedIcon = styled(ImageView).attrs((props: IImageViewProps) => ({
tintHoverColor: props.tintHoverColor ?? props.tintColor ?? colors.white60,
}))((props: IImageViewProps) => ({
'&&:hover': {
- backgroundColor: props.tintColor,
+ 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} />;
@@ -74,3 +97,8 @@ export function Icon(props: IImageViewProps) {
</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/SettingsForm.tsx b/gui/src/renderer/components/cell/SettingsForm.tsx
new file mode 100644
index 0000000000..1f2be529c1
--- /dev/null
+++ b/gui/src/renderer/components/cell/SettingsForm.tsx
@@ -0,0 +1,69 @@
+import React, { useCallback, useContext, useEffect, useId, useMemo, useState } from 'react';
+
+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?.reportInputSubmittable],
+ );
+
+ // Remove from required fields if unmounted.
+ useEffect(() => () => context?.removeInput(key), []);
+
+ 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
new file mode 100644
index 0000000000..f430d96a32
--- /dev/null
+++ b/gui/src/renderer/components/cell/SettingsGroup.tsx
@@ -0,0 +1,99 @@
+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), [unsetError]);
+
+ useEffect(() => () => 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
new file mode 100644
index 0000000000..46f3ccded7
--- /dev/null
+++ b/gui/src/renderer/components/cell/SettingsRadioGroup.tsx
@@ -0,0 +1,119 @@
+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 [value, setValue] = useState<T>(props.defaultValue ?? props.items[0]?.value ?? '');
+ const key = useId();
+
+ const onSelect = useCallback((value: T) => {
+ setValue(value);
+ props.onUpdate(value);
+ }, []);
+
+ 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 onChange = useCallback(
+ (event: React.ChangeEvent<HTMLInputElement>) => {
+ props.onSelect(event.target.value as T);
+ },
+ [props.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
new file mode 100644
index 0000000000..1211098a70
--- /dev/null
+++ b/gui/src/renderer/components/cell/SettingsRow.tsx
@@ -0,0 +1,136 @@
+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?.();
+ }
+ },
+ [reportError, unsetError],
+ );
+
+ const contextValue = useMemo(() => ({ invalid, setInvalid: setInvalidImpl }), [invalid]);
+
+ 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
new file mode 100644
index 0000000000..dd5f9b3b7e
--- /dev/null
+++ b/gui/src/renderer/components/cell/SettingsSelect.tsx
@@ -0,0 +1,245 @@
+import { useCallback, useEffect, useRef, useState } from 'react';
+import styled from 'styled-components';
+
+import { colors } from '../../../config.json';
+import { useScheduler } from '../../../shared/scheduler';
+import { useBoolean } from '../../lib/utilityHooks';
+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';
+}
+
+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();
+ }, []);
+
+ // 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],
+ );
+
+ // Update the parent when the value changes.
+ useEffect(() => {
+ props.onUpdate(value);
+ }, [value]);
+
+ return (
+ <AriaInput>
+ <StyledSelect onBlur={closeDropdown} onKeyDown={onKeyDown} role="listbox">
+ <StyledSelectedContainer 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 onClick = useCallback(() => {
+ props.onSelect(props.item.value);
+ }, [props.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
new file mode 100644
index 0000000000..6d4c22dcbb
--- /dev/null
+++ b/gui/src/renderer/components/cell/SettingsTextInput.tsx
@@ -0,0 +1,124 @@
+import { useCallback, useEffect } from 'react';
+import styled from 'styled-components';
+
+import { colors } from '../../../config.json';
+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],
+ );
+
+ const validateNumber = useCallback(
+ (value: string) => {
+ const parsedValue = parse(value);
+ return (parsedValue === undefined || validate?.(parsedValue)) ?? true;
+ },
+ [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.HTMLAttributes<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);
+ }
+ },
+ [onUpdate, propsOnChange, validate, optionalInForm],
+ );
+
+ // Report submittability to form context on load.
+ useEffect(() => {
+ const value = props.value ?? props.defaultValue ?? '';
+ reportSubmittable(
+ (value !== '' || optionalInForm === true) && validate?.(`${value}`) !== false,
+ );
+ }, []);
+
+ return (
+ <AriaInput>
+ <StyledInput {...otherProps} onChange={onChange} />
+ </AriaInput>
+ );
+}
diff --git a/gui/src/renderer/components/common-styles.ts b/gui/src/renderer/components/common-styles.ts
index b1a4af250b..dbfb720e44 100644
--- a/gui/src/renderer/components/common-styles.ts
+++ b/gui/src/renderer/components/common-styles.ts
@@ -25,6 +25,11 @@ export const smallText = {
color: colors.white80,
};
+export const smallNormalText = {
+ ...smallText,
+ fontWeight: 'normal',
+};
+
export const normalText = {
...openSans,
fontSize: '15px',
diff --git a/gui/src/renderer/components/select-location/custom-list-helpers.ts b/gui/src/renderer/components/select-location/custom-list-helpers.ts
index 40d77ba33d..799deb8ed3 100644
--- a/gui/src/renderer/components/select-location/custom-list-helpers.ts
+++ b/gui/src/renderer/components/select-location/custom-list-helpers.ts
@@ -1,6 +1,7 @@
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, useSelectedLocation } from './RelayListContext';
@@ -169,7 +170,3 @@ function updateRelay(relay: RelaySpecification, customList: string): RelaySpecif
visible: true,
};
}
-
-function hasValue<T>(value: T): value is NonNullable<T> {
- return value !== undefined && value !== null;
-}
diff --git a/gui/src/renderer/lib/api-access-methods.ts b/gui/src/renderer/lib/api-access-methods.ts
new file mode 100644
index 0000000000..fd78d1484a
--- /dev/null
+++ b/gui/src/renderer/lib/api-access-methods.ts
@@ -0,0 +1,81 @@
+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 './utilityHooks';
+
+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;
+ }, []);
+
+ const resetTestResult = useCallback(() => {
+ lastTestPromise.current = undefined;
+ unsetTesting();
+ setTestResult(undefined);
+ }, []);
+
+ return [testing, testResult, testApiAccessMethod, resetTestResult];
+}
diff --git a/gui/src/renderer/lib/routes.ts b/gui/src/renderer/lib/routes.ts
index df8193b6d8..a7cf89d9ce 100644
--- a/gui/src/renderer/lib/routes.ts
+++ b/gui/src/renderer/lib/routes.ts
@@ -17,6 +17,8 @@ export enum RoutePath {
wireguardSettings = '/settings/advanced/wireguard',
openVpnSettings = '/settings/advanced/openvpn',
splitTunneling = '/settings/split-tunneling',
+ apiAccessMethods = '/settings/api-access-methods',
+ editApiAccessMethods = '/settings/api-access-methods/edit/:id?',
support = '/settings/support',
problemReport = '/settings/support/problem-report',
debug = '/settings/debug',
diff --git a/gui/src/renderer/redux/settings/actions.ts b/gui/src/renderer/redux/settings/actions.ts
index 585aad5732..969f8b0a41 100644
--- a/gui/src/renderer/redux/settings/actions.ts
+++ b/gui/src/renderer/redux/settings/actions.ts
@@ -1,5 +1,7 @@
import { IWindowsApplication } from '../../../shared/application-types';
import {
+ AccessMethodSetting,
+ ApiAccessMethodSettings,
BridgeState,
CustomLists,
IDnsOptions,
@@ -104,6 +106,16 @@ export interface ISetCustomLists {
customLists: CustomLists;
}
+export interface ISetApiAccessMethods {
+ type: 'SET_API_ACCESS_METHODS';
+ accessMethods: ApiAccessMethodSettings;
+}
+
+export interface ISetCurrentApiAccessMethod {
+ type: 'SET_CURRENT_API_ACCESS_METHOD';
+ accessMethod: AccessMethodSetting;
+}
+
export type SettingsAction =
| IUpdateGuiSettingsAction
| IUpdateRelayAction
@@ -123,7 +135,9 @@ export type SettingsAction =
| IUpdateSplitTunnelingStateAction
| ISetSplitTunnelingApplicationsAction
| ISetObfuscationSettings
- | ISetCustomLists;
+ | ISetCustomLists
+ | ISetApiAccessMethods
+ | ISetCurrentApiAccessMethod;
function updateGuiSettings(guiSettings: IGuiSettingsState): IUpdateGuiSettingsAction {
return {
@@ -270,6 +284,20 @@ function updateCustomLists(customLists: CustomLists): ISetCustomLists {
};
}
+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,
+ };
+}
+
export default {
updateGuiSettings,
updateRelay,
@@ -290,4 +318,6 @@ export default {
setSplitTunnelingApplications,
updateObfuscationSettings,
updateCustomLists,
+ updateApiAccessMethods,
+ updateCurrentApiAccessMethod,
};
diff --git a/gui/src/renderer/redux/settings/reducers.ts b/gui/src/renderer/redux/settings/reducers.ts
index 3d03093e0a..39e4b3de56 100644
--- a/gui/src/renderer/redux/settings/reducers.ts
+++ b/gui/src/renderer/redux/settings/reducers.ts
@@ -1,5 +1,7 @@
import { IWindowsApplication } from '../../../shared/application-types';
import {
+ AccessMethodSetting,
+ ApiAccessMethodSettings,
BridgeState,
BridgeType,
CustomLists,
@@ -107,6 +109,8 @@ export interface ISettingsReduxState {
splitTunnelingApplications: IWindowsApplication[];
obfuscationSettings: ObfuscationSettings;
customLists: CustomLists;
+ apiAccessMethods: ApiAccessMethodSettings;
+ currentApiAccessMethod?: AccessMethodSetting;
}
const initialState: ISettingsReduxState = {
@@ -173,6 +177,8 @@ const initialState: ISettingsReduxState = {
},
},
customLists: [],
+ apiAccessMethods: [],
+ currentApiAccessMethod: undefined,
};
export default function (
@@ -303,6 +309,18 @@ export default function (
customLists: action.customLists,
};
+ case 'SET_API_ACCESS_METHODS':
+ return {
+ ...state,
+ apiAccessMethods: action.accessMethods,
+ };
+
+ case 'SET_CURRENT_API_ACCESS_METHOD':
+ return {
+ ...state,
+ currentApiAccessMethod: action.accessMethod,
+ };
+
default:
return state;
}
diff --git a/gui/src/shared/daemon-rpc-types.ts b/gui/src/shared/daemon-rpc-types.ts
index 48a4110e13..b409a6e835 100644
--- a/gui/src/shared/daemon-rpc-types.ts
+++ b/gui/src/shared/daemon-rpc-types.ts
@@ -173,7 +173,8 @@ export type DaemonEvent =
| { relayList: IRelayListWithEndpointData }
| { appVersionInfo: IAppVersionInfo }
| { device: DeviceEvent }
- | { deviceRemoval: Array<IDevice> };
+ | { deviceRemoval: Array<IDevice> }
+ | { accessMethodSetting: AccessMethodSetting };
export interface ITunnelStateRelayInfo {
endpoint: ITunnelEndpoint;
@@ -427,6 +428,7 @@ export interface ISettings {
splitTunnel: SplitTunnelSettings;
obfuscationSettings: ObfuscationSettings;
customLists: CustomLists;
+ apiAccessMethods: ApiAccessMethodSettings;
}
export type BridgeState = 'auto' | 'on' | 'off';
@@ -474,6 +476,59 @@ export type VoucherResponse =
| { type: 'success'; newExpiry: string; secondsAdded: number }
| { type: 'invalid' | 'already_used' | 'error' };
+export interface SocksAuth {
+ username: string;
+ password: string;
+}
+
+export type Socks5LocalAccessMethod = {
+ type: 'socks5-local';
+ remoteIp: string;
+ remotePort: number;
+ remoteTransportProtocol: RelayProtocol;
+ localPort: number;
+};
+
+export type Socks5RemoteAccessMethod = {
+ type: 'socks5-remote';
+ ip: string;
+ port: number;
+ authentication?: SocksAuth;
+};
+
+export type ShadowsocksAccessMethod = {
+ type: 'shadowsocks';
+ ip: string;
+ port: number;
+ password: string;
+ cipher: string;
+};
+
+export type CustomProxy =
+ | Socks5LocalAccessMethod
+ | Socks5RemoteAccessMethod
+ | ShadowsocksAccessMethod;
+
+export type AccessMethod =
+ | {
+ type: 'direct';
+ }
+ | {
+ type: 'bridges';
+ }
+ | CustomProxy;
+
+export type NewAccessMethodSetting = AccessMethod & {
+ name: string;
+ enabled: boolean;
+};
+
+export type AccessMethodSetting = NewAccessMethodSetting & {
+ id: string;
+};
+
+export type ApiAccessMethodSettings = Array<AccessMethodSetting>;
+
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 ecf34e93fb..3f348b1d86 100644
--- a/gui/src/shared/ipc-schema.ts
+++ b/gui/src/shared/ipc-schema.ts
@@ -2,11 +2,13 @@ import { GetTextTranslations } from 'gettext-parser';
import { ILinuxSplitTunnelingApplication, IWindowsApplication } from './application-types';
import {
+ AccessMethodSetting,
AccountDataError,
AccountToken,
BridgeSettings,
BridgeState,
CustomListError,
+ CustomProxy,
DeviceEvent,
DeviceState,
IAccountData,
@@ -17,6 +19,7 @@ import {
IDnsOptions,
IRelayListWithEndpointData,
ISettings,
+ NewAccessMethodSetting,
ObfuscationSettings,
RelaySettings,
TunnelState,
@@ -71,6 +74,7 @@ export interface IAppStateSnapshot {
changelog: IChangelog;
forceShowChanges: boolean;
navigationHistory?: IHistoryObject;
+ currentApiAccessMethod?: AccessMethodSetting;
}
// The different types of requests are:
@@ -160,6 +164,7 @@ export const ipcSchema = {
},
settings: {
'': notifyRenderer<ISettings>(),
+ apiAccessMethodSettingChange: notifyRenderer<AccessMethodSetting>(),
setAllowLan: invoke<boolean, void>(),
setShowBetaReleases: invoke<boolean, void>(),
setEnableIpv6: invoke<boolean, void>(),
@@ -172,6 +177,12 @@ export const ipcSchema = {
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>(),
},
guiSettings: {
'': notifyRenderer<IGuiSettingsState>(),
diff --git a/gui/src/shared/localization-contexts.ts b/gui/src/shared/localization-contexts.ts
index d342a4210e..081d5a622f 100644
--- a/gui/src/shared/localization-contexts.ts
+++ b/gui/src/shared/localization-contexts.ts
@@ -30,6 +30,7 @@ export type LocalizationContexts =
| 'openvpn-settings-nav'
| 'split-tunneling-view'
| 'split-tunneling-nav'
+ | 'api-access-methods-view'
| 'support-view'
| 'select-language-nav'
| 'tray-icon-context-menu'
diff --git a/gui/src/shared/utils.ts b/gui/src/shared/utils.ts
new file mode 100644
index 0000000000..24984e4412
--- /dev/null
+++ b/gui/src/shared/utils.ts
@@ -0,0 +1,3 @@
+export function hasValue<T>(value: T): value is NonNullable<T> {
+ return value !== undefined && value !== null;
+}