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 (
);
}
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 }>();
// 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(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 (
{title}
{title}
{subtitle}
{id !== undefined && method === undefined ? (
Failed to open method
) : (
)}
{messages.gettext('Cancel')}
{id === undefined ? messages.gettext('Add') : messages.gettext('Save')}
);
}
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(type);
const isOpen = props.testing || props.testResult !== undefined;
const typeValue = isOpen ? type : prevType.current;
useEffect(() => {
if (isOpen) {
prevType.current = type;
}
}, [type]);
return (
);
}
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 = (
{messages.gettext('Save')}
);
const cancelButton = (
{messages.gettext('Cancel')}
);
const disabledCancelButton = (
{messages.gettext('Cancel')}
);
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>>(
() => [
{ 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(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 (
<>
{type === 'shadowsocks' && (
)}
{type === 'socks5-remote' && (
)}
{type === 'socks5-local' && (
)}
>
);
}
interface EditMethodProps {
method?: T;
onUpdate: (method: AccessMethod) => void;
}
function EditShadowsocks(props: EditMethodProps) {
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 (
);
}
function EditSocks5Remote(props: EditMethodProps) {
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 (
{authentication && (
<>
>
)}
);
}
function EditSocks5Local(props: EditMethodProps) {
const [remoteIp, setRemoteIp] = useState(props.method?.remoteIp ?? '');
const [remotePort, setRemotePort] = useState(props.method?.remotePort);
const [remoteTransportProtocol, setRemoteTransportProtocol] = useState(
props.method?.remoteTransportProtocol ?? 'tcp',
);
const [localPort, setLocalPort] = useState(props.method?.localPort);
const remoteTransportProtocols = useMemo>>(
() => [
{ 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 (
<>
defaultValue={remoteTransportProtocol}
onUpdate={setRemoteTransportProtocol}
items={remoteTransportProtocols}
/>
>
);
}
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;
}