diff options
| author | Oskar Nyberg <oskar@mullvad.net> | 2024-01-29 09:46:37 +0100 |
|---|---|---|
| committer | Oskar Nyberg <oskar@mullvad.net> | 2024-01-29 09:46:37 +0100 |
| commit | 3ad7afed8319d07639df483873cf94159c7cdadb (patch) | |
| tree | fc9a971c1176b5a33c30afa31856b8f35cf8dcc6 /gui/src | |
| parent | fd4b9f08b3cf1501db6982e3c7dc6b8f1346bc28 (diff) | |
| parent | 4776f2890fe3ca76c78fe646956e3eeae74efce6 (diff) | |
| download | mullvadvpn-3ad7afed8319d07639df483873cf94159c7cdadb.tar.xz mullvadvpn-3ad7afed8319d07639df483873cf94159c7cdadb.zip | |
Merge branch 'api-access-methods'
Diffstat (limited to 'gui/src')
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; +} |
