summaryrefslogtreecommitdiffhomepage
path: root/gui/src/renderer/components
diff options
context:
space:
mode:
authorOskar Nyberg <oskar@mullvad.net>2024-04-08 07:57:05 +0200
committerOskar Nyberg <oskar@mullvad.net>2024-04-11 17:21:23 +0200
commit41a01db5963e0f499c0dc23419af8ed6bd8bf772 (patch)
tree4824fd3a7818cdb9cbfb1241b29334ca0336ce7b /gui/src/renderer/components
parent9e7b4bf6638c1b57d5291305a838367865ce8f76 (diff)
downloadmullvadvpn-41a01db5963e0f499c0dc23419af8ed6bd8bf772.tar.xz
mullvadvpn-41a01db5963e0f499c0dc23419af8ed6bd8bf772.zip
Refactor custom proxy form out of Api access method component
Diffstat (limited to 'gui/src/renderer/components')
-rw-r--r--gui/src/renderer/components/EditApiAccessMethod.tsx453
-rw-r--r--gui/src/renderer/components/ProxyForm.tsx532
2 files changed, 555 insertions, 430 deletions
diff --git a/gui/src/renderer/components/EditApiAccessMethod.tsx b/gui/src/renderer/components/EditApiAccessMethod.tsx
index b5b3bd6d48..59a5f2ff37 100644
--- a/gui/src/renderer/components/EditApiAccessMethod.tsx
+++ b/gui/src/renderer/components/EditApiAccessMethod.tsx
@@ -1,38 +1,27 @@
-import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import { useCallback, useEffect, useRef } from 'react';
import { useParams } from 'react-router';
import { sprintf } from 'sprintf-js';
import {
- AccessMethod,
- AccessMethodSetting,
CustomProxy,
+ NamedCustomProxy,
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 { SettingsForm } from './cell/SettingsForm';
import { BackAction } from './KeyboardNavigation';
import { Layout, SettingsContainer } from './Layout';
import { ModalAlert, ModalAlertType } from './Modal';
import { NavigationBar, NavigationContainer, NavigationItems, TitleBarItem } from './NavigationBar';
+import { NamedProxyForm } from './ProxyForm';
import SettingsHeader, { HeaderSubTitle, HeaderTitle } from './SettingsHeader';
import { StyledContent, StyledNavigationScrollbars, StyledSettingsContent } from './SettingsStyles';
-import { SmallButton, SmallButtonGroup } from './SmallButton';
+import { SmallButton } from './SmallButton';
export function EditApiAccessMethod() {
return (
@@ -45,7 +34,7 @@ export function EditApiAccessMethod() {
function AccessMethodForm() {
const history = useHistory();
const { addApiAccessMethod, updateApiAccessMethod } = useAppContext();
- const methods = useSelector((state) => state.settings.apiAccessMethods);
+ const methods = useSelector((state) => state.settings.apiAccessMethods.custom);
const [testing, testResult, testApiAccessMethod, resetTestResult] = useApiAccessMethodTest(
false,
@@ -55,19 +44,9 @@ function AccessMethodForm() {
// 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 }>();
- // Ugly way of iterating over all access methods, but it works.
- const method = [methods.direct, methods.mullvadBridges, ...methods.custom].find(
- (method) => method.id === id,
- );
-
- const updatedMethod = useRef<NewAccessMethodSetting | undefined>(method);
- const updateMethod = useCallback(
- (method: NewAccessMethodSetting) => (updatedMethod.current = method),
- [],
- );
+ const method = methods.find((method) => method.id === id);
- // Contains form submittability to know whether or not to enable the Add/Save button.
- const formSubmittable = useSettingsFormSubmittable();
+ const updatedMethod = useRef<NewAccessMethodSetting<CustomProxy> | undefined>(method);
const save = useCallback(() => {
if (updatedMethod.current !== undefined) {
@@ -81,15 +60,20 @@ function AccessMethodForm() {
}
}, [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 onSave = useCallback(
+ async (newMethod: NamedCustomProxy) => {
+ const enabled = id === undefined ? true : method?.enabled ?? true;
+ updatedMethod.current = { ...newMethod, enabled };
+ 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);
@@ -116,15 +100,8 @@ function AccessMethodForm() {
{id !== undefined && method === undefined ? (
<span>Failed to open method</span>
) : (
- <AccessMethodFormImpl method={method} updateMethod={updateMethod} />
+ <NamedProxyForm proxy={method} onSave={onSave} onCancel={history.pop} />
)}
-
- <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
@@ -257,387 +234,3 @@ function getTestingDialogButtons(type: ModalAlertType, save: () => void, cancel:
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 method = useRef<AccessMethod | undefined>(props.method);
-
- // When the form makes up a valid method the parent is updated.
- const onUpdate = useCallback(() => {
- if (method.current !== undefined && name.current !== '') {
- props.updateMethod({ ...method.current, name: name.current, enabled: true });
- }
- }, []);
-
- const updateName = useCallback(
- (value: string) => {
- name.current = value;
- onUpdate();
- },
- [onUpdate],
- );
-
- const updateMethod = useCallback(
- (value: AccessMethod) => {
- method.current = value;
- onUpdate();
- },
- [onUpdate],
- );
-
- 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
- data-testid="ciphers"
- 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/ProxyForm.tsx b/gui/src/renderer/components/ProxyForm.tsx
new file mode 100644
index 0000000000..5fbcf34d0f
--- /dev/null
+++ b/gui/src/renderer/components/ProxyForm.tsx
@@ -0,0 +1,532 @@
+import { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
+import React from 'react';
+
+import {
+ CustomProxy,
+ NamedCustomProxy,
+ RelayProtocol,
+ ShadowsocksCustomProxy,
+ Socks5LocalCustomProxy,
+ Socks5RemoteCustomProxy,
+} from '../../shared/daemon-rpc-types';
+import { messages } from '../../shared/gettext';
+import { IpAddress } from '../lib/ip';
+import * as Cell from './cell';
+import { SettingsForm, useSettingsFormSubmittable } from './cell/SettingsForm';
+import { SettingsGroup } from './cell/SettingsGroup';
+import { SettingsRadioGroup } from './cell/SettingsRadioGroup';
+import { SettingsRow } from './cell/SettingsRow';
+import { SettingsSelect, SettingsSelectItem } from './cell/SettingsSelect';
+import { SettingsNumberInput, SettingsTextInput } from './cell/SettingsTextInput';
+import {
+ SmallButton,
+ SmallButtonColor,
+ SmallButtonGroup,
+ SmallButtonGroupStart,
+} from './SmallButton';
+
+interface ProxyFormContext {
+ proxy?: CustomProxy;
+ setProxy: (proxy: CustomProxy) => void;
+ onSave: () => void;
+ onCancel: () => void;
+ onDelete?: () => void;
+}
+
+const proxyFormContext = React.createContext<ProxyFormContext>({
+ get proxy(): CustomProxy {
+ throw new Error('Missing ProxyFromContext provider');
+ },
+ setProxy(): void {
+ throw new Error('Missing ProxyFromContext provider');
+ },
+ onSave(): void {
+ throw new Error('Missing ProxyFromContext provider');
+ },
+ onCancel(): void {
+ throw new Error('Missing ProxyFromContext provider');
+ },
+ onDelete(): void {
+ throw new Error('Missing ProxyFromContext provider');
+ },
+});
+
+interface ProxyFormContextProviderProps {
+ proxy?: CustomProxy;
+ onSave: (proxy: CustomProxy) => void;
+ onCancel: () => void;
+ onDelete?: () => void;
+}
+
+function ProxyFormContextProvider(props: React.PropsWithChildren<ProxyFormContextProviderProps>) {
+ const [proxy, setProxy] = useState<CustomProxy | undefined>(props.proxy);
+
+ const onSave = useCallback(() => {
+ if (proxy !== undefined) {
+ props.onSave(proxy);
+ }
+ }, [proxy, props.onSave]);
+
+ const value = useMemo(
+ () => ({ proxy, setProxy, onSave, onCancel: props.onCancel, onDelete: props.onDelete }),
+ [proxy, onSave, props.onCancel, props.onDelete],
+ );
+
+ return <proxyFormContext.Provider value={value}>{props.children}</proxyFormContext.Provider>;
+}
+
+export function ProxyForm(props: ProxyFormContextProviderProps) {
+ return (
+ <ProxyFormContextProvider {...props}>
+ <SettingsForm>
+ <ProxyFormInner />
+ <ProxyFormButtons />
+ </SettingsForm>
+ </ProxyFormContextProvider>
+ );
+}
+
+interface NamedProxyFormContext {
+ name?: string;
+ setName: (name: string) => void;
+}
+
+const namedProxyFormContext = React.createContext<NamedProxyFormContext>({
+ get name(): string {
+ throw new Error('Missing NamedProxyFromContext provider');
+ },
+ setName(): void {
+ throw new Error('Missing NamedProxyFromContext provider');
+ },
+});
+
+interface NamedProxyFormContainerProps
+ extends Omit<ProxyFormContextProviderProps, 'proxy' | 'onSave'> {
+ proxy?: NamedCustomProxy;
+ onSave: (proxy: NamedCustomProxy) => void;
+}
+
+export function NamedProxyForm(props: NamedProxyFormContainerProps) {
+ const { onSave, ...otherProps } = props;
+
+ const [name, setName] = useState<string>(props.proxy?.name ?? '');
+
+ const save = useCallback(
+ (proxy: CustomProxy) => {
+ if (name !== '') {
+ onSave({ ...proxy, name });
+ }
+ },
+ [name, onSave],
+ );
+
+ const nameContextValue = useMemo(() => ({ name, setName }), [name]);
+
+ return (
+ <namedProxyFormContext.Provider value={nameContextValue}>
+ <ProxyFormContextProvider {...otherProps} onSave={save}>
+ <SettingsForm>
+ <ProxyFormNameField />
+ <ProxyFormInner />
+ <ProxyFormButtons />
+ </SettingsForm>
+ </ProxyFormContextProvider>
+ </namedProxyFormContext.Provider>
+ );
+}
+
+function ProxyFormNameField() {
+ const { name, setName } = useContext(namedProxyFormContext);
+
+ return (
+ <SettingsRow label={messages.gettext('Name')}>
+ <SettingsTextInput
+ defaultValue={name}
+ placeholder={messages.pgettext('api-access-methods-view', 'Enter name')}
+ onUpdate={setName}
+ />
+ </SettingsRow>
+ );
+}
+
+export function ProxyFormButtons() {
+ const { proxy, onSave, onCancel, onDelete } = useContext(proxyFormContext);
+
+ // Contains form submittability to know whether or not to enable the Add/Save button.
+ const formSubmittable = useSettingsFormSubmittable();
+
+ return (
+ <SmallButtonGroup>
+ {onDelete !== undefined && (
+ <SmallButtonGroupStart>
+ <SmallButton color={SmallButtonColor.red} onClick={onDelete}>
+ {messages.gettext('Delete')}
+ </SmallButton>
+ </SmallButtonGroupStart>
+ )}
+ <SmallButton onClick={onCancel}>{messages.gettext('Cancel')}</SmallButton>
+ <SmallButton onClick={onSave} disabled={!formSubmittable}>
+ {proxy === undefined ? messages.gettext('Add') : messages.gettext('Save')}
+ </SmallButton>
+ </SmallButtonGroup>
+ );
+}
+
+function ProxyFormInner() {
+ const { proxy, setProxy } = useContext(proxyFormContext);
+
+ // Available custom proxies
+ const types = useMemo<Array<SettingsSelectItem<CustomProxy['type']>>>(
+ () => [
+ { value: 'shadowsocks', label: 'Shadowsocks' },
+ {
+ value: 'socks5-remote',
+ label: messages.pgettext('api-access-methods-view', 'SOCKS5 remote'),
+ },
+ {
+ value: 'socks5-local',
+ label: messages.pgettext('api-access-methods-view', 'SOCKS5 local'),
+ },
+ ],
+ [],
+ );
+ const [type, setType] = useState(proxy?.type ?? 'shadowsocks');
+ const proxyRef = useRef<CustomProxy | undefined>(proxy);
+
+ const updateProxy = useCallback(
+ (value: CustomProxy) => {
+ proxyRef.current = value;
+
+ // When the form makes up a valid proxy the parent is updated.
+ if (proxyRef.current !== undefined) {
+ setProxy(proxyRef.current);
+ }
+ },
+ [setProxy],
+ );
+
+ return (
+ <>
+ <SettingsRow label={messages.gettext('Type')}>
+ <SettingsSelect defaultValue={type} onUpdate={setType} items={types} />
+ </SettingsRow>
+
+ {type === 'shadowsocks' && (
+ <EditShadowsocks
+ onUpdate={updateProxy}
+ proxy={proxy?.type === 'shadowsocks' ? proxy : undefined}
+ />
+ )}
+ {type === 'socks5-remote' && (
+ <EditSocks5Remote
+ onUpdate={updateProxy}
+ proxy={proxy?.type === 'socks5-remote' ? proxy : undefined}
+ />
+ )}
+ {type === 'socks5-local' && (
+ <EditSocks5Local
+ onUpdate={updateProxy}
+ proxy={proxy?.type === 'socks5-local' ? proxy : undefined}
+ />
+ )}
+ </>
+ );
+}
+
+interface EditProxyProps<T> {
+ proxy?: T;
+ onUpdate: (proxy: CustomProxy) => void;
+}
+
+function EditShadowsocks(props: EditProxyProps<ShadowsocksCustomProxy>) {
+ const [ip, setIp] = useState(props.proxy?.ip ?? '');
+ const [port, setPort] = useState(props.proxy?.port);
+ const [password, setPassword] = useState(props.proxy?.password ?? '');
+ const [cipher, setCipher] = useState(props.proxy?.cipher);
+
+ const ciphers = useMemo(
+ () =>
+ [
+ { value: 'aes-128-cfb', label: 'aes-128-cfb' },
+ { value: 'aes-128-cfb1', label: 'aes-128-cfb1' },
+ { value: 'aes-128-cfb8', label: 'aes-128-cfb8' },
+ { value: 'aes-128-cfb128', label: 'aes-128-cfb128' },
+ { value: 'aes-256-cfb', label: 'aes-256-cfb' },
+ { value: 'aes-256-cfb1', label: 'aes-256-cfb1' },
+ { value: 'aes-256-cfb8', label: 'aes-256-cfb8' },
+ { value: 'aes-256-cfb128', label: 'aes-256-cfb128' },
+ { value: 'rc4', label: 'rc4' },
+ { value: 'rc4-md5', label: 'rc4-md5' },
+ { value: 'chacha20', label: 'chacha20' },
+ { value: 'salsa20', label: 'salsa20' },
+ { value: 'chacha20-ietf', label: 'chacha20-ietf' },
+ { value: 'aes-128-gcm', label: 'aes-128-gcm' },
+ { value: 'aes-256-gcm', label: 'aes-256-gcm' },
+ { value: 'chacha20-ietf-poly1305', label: 'chacha20-ietf-poly1305' },
+ { value: 'xchacha20-ietf-poly1305', label: 'xchacha20-ietf-poly1305' },
+ { value: 'aes-128-pmac-siv', label: 'aes-128-pmac-siv' },
+ { value: 'aes-256-pmac-siv', label: 'aes-256-pmac-siv' },
+ ].sort((a, b) => a.label.localeCompare(b.label)),
+ [],
+ );
+
+ // Report back to form component with the proxy 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
+ data-testid="ciphers"
+ direction="up"
+ defaultValue={cipher}
+ onUpdate={setCipher}
+ items={ciphers}
+ />
+ </SettingsRow>
+ </SettingsGroup>
+ );
+}
+
+function EditSocks5Remote(props: EditProxyProps<Socks5RemoteCustomProxy>) {
+ const [ip, setIp] = useState(props.proxy?.ip ?? '');
+ const [port, setPort] = useState(props.proxy?.port);
+ const [authentication, setAuthentication] = useState(props.proxy?.authentication !== undefined);
+ const [username, setUsername] = useState(props.proxy?.authentication?.username ?? '');
+ const [password, setPassword] = useState(props.proxy?.authentication?.password ?? '');
+
+ // Report back to form component with the proxy 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: EditProxyProps<Socks5LocalCustomProxy>) {
+ const [remoteIp, setRemoteIp] = useState(props.proxy?.remoteIp ?? '');
+ const [remotePort, setRemotePort] = useState(props.proxy?.remotePort);
+ const [remoteTransportProtocol, setRemoteTransportProtocol] = useState<RelayProtocol>(
+ props.proxy?.remoteTransportProtocol ?? 'tcp',
+ );
+ const [localPort, setLocalPort] = useState(props.proxy?.localPort);
+
+ const remoteTransportProtocols = useMemo<Array<SettingsSelectItem<RelayProtocol>>>(
+ () => [
+ { value: 'tcp', label: 'TCP' },
+ { value: 'udp', label: 'UDP' },
+ ],
+ [],
+ );
+
+ 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;
+}