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; }