diff options
| author | Oskar Nyberg <oskar@mullvad.net> | 2020-11-17 11:09:35 +0100 |
|---|---|---|
| committer | Oskar Nyberg <oskar@mullvad.net> | 2020-11-17 11:09:35 +0100 |
| commit | 6d95d36bb37e73e65f7b4950993dc3e42d447a5c (patch) | |
| tree | ee2d4b9f0de9cd08615c396f7157baa603e9194e | |
| parent | 765f777dd4399b334fe6641e5d427d379826501a (diff) | |
| parent | 6332e991b2bfaf334df03d93d5bd20df06ae699f (diff) | |
| download | mullvadvpn-6d95d36bb37e73e65f7b4950993dc3e42d447a5c.tar.xz mullvadvpn-6d95d36bb37e73e65f7b4950993dc3e42d447a5c.zip | |
Merge branch 'custom-dns-ui'
| -rw-r--r-- | CHANGELOG.md | 8 | ||||
| -rw-r--r-- | gui/assets/images/icon-add.svg | 8 | ||||
| -rw-r--r-- | gui/package-lock.json | 14 | ||||
| -rw-r--r-- | gui/package.json | 2 | ||||
| -rw-r--r-- | gui/src/main/daemon-rpc.ts | 13 | ||||
| -rw-r--r-- | gui/src/main/index.ts | 8 | ||||
| -rw-r--r-- | gui/src/renderer/app.tsx | 6 | ||||
| -rw-r--r-- | gui/src/renderer/components/AdvancedSettings.tsx | 209 | ||||
| -rw-r--r-- | gui/src/renderer/components/AdvancedSettingsStyles.tsx | 28 | ||||
| -rw-r--r-- | gui/src/renderer/components/cell/Input.tsx | 143 | ||||
| -rw-r--r-- | gui/src/renderer/components/cell/List.tsx | 123 | ||||
| -rw-r--r-- | gui/src/renderer/containers/AdvancedSettingsPage.tsx | 13 | ||||
| -rw-r--r-- | gui/src/renderer/redux/settings/actions.ts | 23 | ||||
| -rw-r--r-- | gui/src/renderer/redux/settings/reducers.ts | 14 | ||||
| -rw-r--r-- | gui/src/shared/daemon-rpc-types.ts | 6 | ||||
| -rw-r--r-- | gui/src/shared/ipc-event-channel.ts | 6 |
16 files changed, 607 insertions, 17 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md index f82436fc16..9bce5f7634 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,19 +28,13 @@ Line wrap the file at 100 chars. Th - Add `--wait` flag to `connect`, `disconnect` and `reconnect` CLI subcommands to make the CLI wait for the target state to be reached before exiting. - Navigate back to the main view when escape is pressed. - -#### Windows -- Add support for custom DNS resolvers (CLI only). +- Add support for custom DNS resolvers. #### Linux - Optionally use NetworkManager to create WireGuard devices. -- Add support for custom DNS resolvers (CLI only). - Disable NetworkManager's connectivity check before applying firewall rules to avoid triggerring NetworkManager's [bug](https://gitlab.freedesktop.org/NetworkManager/NetworkManager/-/issues/312#note_453724) -#### macOS -- Add support for custom DNS resolvers (CLI only). - ### Changed - Use the API to fetch API IP addresses instead of DNS. - Remove WireGuard keys during uninstallation after the firewall is unlocked. diff --git a/gui/assets/images/icon-add.svg b/gui/assets/images/icon-add.svg new file mode 100644 index 0000000000..a5a4aadf19 --- /dev/null +++ b/gui/assets/images/icon-add.svg @@ -0,0 +1,8 @@ +<svg width="24px" height="24px" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"> + <title>icon-close</title> + <desc>Mullvad VPN app</desc> + <defs></defs> + <g id="icon" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd" fill-opacity="0.6" style="transform-origin: center; transform: rotate(45deg);"> + <path d="M12,24 C5.37312,24 -3.2900871e-16,18.62688 -7.34788079e-16,12 C-1.14056745e-15,5.37312 5.37312,0 12,0 C18.62688,0 24,5.37312 24,12 C24,18.62688 18.62688,24 12,24 Z M13.5,12 L17.2947612,8.20523878 C17.6857559,7.81424414 17.6838785,7.18387854 17.293923,6.79392296 L17.206077,6.70607704 C16.8181114,6.31811142 16.1842538,6.31574616 15.7947612,6.70523878 L12,10.5 L8.20523878,6.70523878 C7.81574616,6.31574616 7.18188858,6.31811142 6.79392296,6.70607704 L6.70607704,6.79392296 C6.31612146,7.18387854 6.31424414,7.81424414 6.70523878,8.20523878 L10.5,12 L6.70523878,15.7947612 C6.31424414,16.1857559 6.31612146,16.8161215 6.70607704,17.206077 L6.79392296,17.293923 C7.18188858,17.6818886 7.81574616,17.6842538 8.20523878,17.2947612 L12,13.5 L15.7947612,17.2947612 C16.1842538,17.6842538 16.8181114,17.6818886 17.206077,17.293923 L17.293923,17.206077 C17.6838785,16.8161215 17.6857559,16.1857559 17.2947612,15.7947612 L13.5,12 L13.5,12 Z" id="path" fill="#FFFFFF"></path> + </g> +</svg> diff --git a/gui/package-lock.json b/gui/package-lock.json index 2a90b829e7..1792ac8c92 100644 --- a/gui/package-lock.json +++ b/gui/package-lock.json @@ -618,6 +618,15 @@ "hoist-non-react-statics": "^3.3.0" } }, + "@types/ip": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@types/ip/-/ip-1.1.0.tgz", + "integrity": "sha512-dwNe8gOoF70VdL6WJBwVHtQmAX4RMd62M+mAB9HQFjG1/qiCLM/meRy95Pd14FYBbEDwCq7jgJs89cHpLBu4HQ==", + "dev": true, + "requires": { + "@types/node": "*" + } + }, "@types/json-schema": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.6.tgz", @@ -7271,6 +7280,11 @@ "integrity": "sha1-EEqOSqym09jNFXqO+L+rLXo//bY=", "dev": true }, + "ip": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/ip/-/ip-1.1.5.tgz", + "integrity": "sha1-vd7XARQpCCjAoDnnLvJfWq7ENUo=" + }, "is-absolute": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-absolute/-/is-absolute-1.0.0.tgz", diff --git a/gui/package.json b/gui/package.json index 394c912de8..7fd93c3a3a 100644 --- a/gui/package.json +++ b/gui/package.json @@ -18,6 +18,7 @@ "electron-log": "^4.1.1", "gettext-parser": "^4.0.3", "google-protobuf": "^4.0.0-rc.2", + "ip": "^1.1.5", "linux-app-list": "^1.0.1", "mkdirp": "^1.0.3", "moment": "^2.24.0", @@ -46,6 +47,7 @@ "@types/gettext-parser": "^4.0.0", "@types/google-protobuf": "^3.7.2", "@types/history": "^4.7.8", + "@types/ip": "^1.1.0", "@types/mkdirp": "^1.0.0", "@types/mocha": "^5.2.6", "@types/node": "^10.12.3", diff --git a/gui/src/main/daemon-rpc.ts b/gui/src/main/daemon-rpc.ts index 130651c200..c7f4ce8608 100644 --- a/gui/src/main/daemon-rpc.ts +++ b/gui/src/main/daemon-rpc.ts @@ -48,6 +48,7 @@ import { ProxySettings, VoucherResponse, TunnelProtocol, + IDnsOptions, } from '../shared/daemon-rpc-types'; import * as managementInterface from './management_interface/management_interface_grpc_pb'; @@ -452,6 +453,14 @@ export class DaemonRpc { }; } + public async setDnsOptions(dns: IDnsOptions): Promise<void> { + const dnsOptions = new grpcTypes.DnsOptions(); + dnsOptions.setCustom(dns.custom); + dnsOptions.setAddressesList(dns.addresses); + + await this.call<grpcTypes.DnsOptions, Empty>(this.client.setDnsOptions, dnsOptions); + } + public async verifyWireguardKey(): Promise<boolean> { const response = await this.callEmpty<BoolValue>(this.client.verifyWireguardKey); return response.getValue(); @@ -1029,6 +1038,10 @@ function convertFromTunnelOptions(tunnelOptions: grpcTypes.TunnelOptions.AsObjec generic: { enableIpv6: tunnelOptions.generic!.enableIpv6, }, + dns: { + custom: tunnelOptions.dnsOptions?.custom ?? false, + addresses: tunnelOptions.dnsOptions?.addressesList ?? [], + }, }; } diff --git a/gui/src/main/index.ts b/gui/src/main/index.ts index 514fa4b4e8..d780e8e128 100644 --- a/gui/src/main/index.ts +++ b/gui/src/main/index.ts @@ -15,6 +15,7 @@ import { DaemonEvent, IAccountData, IAppVersionInfo, + IDnsOptions, ILocation, IRelayList, ISettings, @@ -137,6 +138,10 @@ class ApplicationMain { wireguard: { mtu: undefined, }, + dns: { + custom: false, + addresses: [], + }, }, }; private guiSettings = new GuiSettings(); @@ -984,6 +989,9 @@ class ApplicationMain { IpcMainEventChannel.settings.handleUpdateBridgeSettings((bridgeSettings: BridgeSettings) => { return this.daemonRpc.setBridgeSettings(bridgeSettings); }); + IpcMainEventChannel.settings.handleDnsOptions((dns: IDnsOptions) => { + return this.daemonRpc.setDnsOptions(dns); + }); IpcMainEventChannel.autoStart.handleSet((autoStart: boolean) => { return this.setAutoStart(autoStart); }); diff --git a/gui/src/renderer/app.tsx b/gui/src/renderer/app.tsx index d7d5b8bf29..17e117f739 100644 --- a/gui/src/renderer/app.tsx +++ b/gui/src/renderer/app.tsx @@ -32,6 +32,7 @@ import { BridgeState, IAccountData, IAppVersionInfo, + IDnsOptions, ILocation, IRelayList, ISettings, @@ -303,6 +304,10 @@ export default class AppRenderer { return IpcRendererEventChannel.settings.updateBridgeSettings(bridgeSettings); } + public setDnsOptions(dns: IDnsOptions) { + return IpcRendererEventChannel.settings.setDnsOptions(dns); + } + public removeAccountFromHistory(accountToken: AccountToken): Promise<void> { return IpcRendererEventChannel.accountHistory.removeItem(accountToken); } @@ -611,6 +616,7 @@ export default class AppRenderer { reduxSettings.updateOpenVpnMssfix(newSettings.tunnelOptions.openvpn.mssfix); reduxSettings.updateWireguardMtu(newSettings.tunnelOptions.wireguard.mtu); reduxSettings.updateBridgeState(newSettings.bridgeState); + reduxSettings.updateDnsOptions(newSettings.tunnelOptions.dns); this.setRelaySettings(newSettings.relaySettings); this.setBridgeSettings(newSettings.bridgeSettings); diff --git a/gui/src/renderer/components/AdvancedSettings.tsx b/gui/src/renderer/components/AdvancedSettings.tsx index 239b5206cd..6633e35746 100644 --- a/gui/src/renderer/components/AdvancedSettings.tsx +++ b/gui/src/renderer/components/AdvancedSettings.tsx @@ -1,10 +1,18 @@ +import ip from 'ip'; import * as React from 'react'; import { sprintf } from 'sprintf-js'; -import { BridgeState, RelayProtocol, TunnelProtocol } from '../../shared/daemon-rpc-types'; +import { colors } from '../../config.json'; +import { + BridgeState, + IDnsOptions, + RelayProtocol, + TunnelProtocol, +} from '../../shared/daemon-rpc-types'; import { messages } from '../../shared/gettext'; +import consumePromise from '../../shared/promise'; import { WgKeyState } from '../redux/settings/reducers'; import { - StyledBottomCellGroup, + StyledButtonCellGroup, StyledContainer, StyledInputFrame, StyledNavigationScrollbars, @@ -13,10 +21,15 @@ import { StyledSelectorContainer, StyledTunnelProtocolSelector, StyledTunnelProtocolContainer, + StyledCustomDnsSwitchContainer, + StyledCustomDnsFotter, + StyledAddCustomDnsLabel, + StyledAddCustomDnsButton, } from './AdvancedSettingsStyles'; import * as AppButton from './AppButton'; import { AriaDescription, AriaInput, AriaInputGroup, AriaLabel } from './AriaGroup'; import * as Cell from './cell'; +import CellList, { ICellListItem } from './cell/List'; import { Layout } from './Layout'; import { ModalAlert, ModalAlertType, ModalContainer, ModalMessage } from './Modal'; import { @@ -28,6 +41,7 @@ import { } from './NavigationBar'; import Selector, { ISelectorItem } from './cell/Selector'; import SettingsHeader, { HeaderTitle } from './SettingsHeader'; +import Accordion from './Accordion'; const MIN_MSSFIX_VALUE = 1000; const MAX_MSSFIX_VALUE = 1450; @@ -59,6 +73,7 @@ interface IProps { mssfix?: number; wireguardMtu?: number; bridgeState: BridgeState; + dns: IDnsOptions; setBridgeState: (value: BridgeState) => void; setEnableIpv6: (value: boolean) => void; setBlockWhenDisconnected: (value: boolean) => void; @@ -67,6 +82,7 @@ interface IProps { setWireguardMtu: (value: number | undefined) => void; setOpenVpnRelayProtocolAndPort: (protocol?: RelayProtocol, port?: number) => void; setWireguardRelayPort: (port?: number) => void; + setDnsOptions: (dns: IDnsOptions) => Promise<void>; onViewWireguardKeys: () => void; onViewLinuxSplitTunneling: () => void; onClose: () => void; @@ -74,13 +90,23 @@ interface IProps { interface IState { showConfirmBlockWhenDisconnectedAlert: boolean; + showAddCustomDns: boolean; + invalidDnsIp: boolean; + publicDnsIpToConfirm?: string; } export default class AdvancedSettings extends React.Component<IProps, IState> { public state = { showConfirmBlockWhenDisconnectedAlert: false, + showAddCustomDns: false, + invalidDnsIp: false, + publicDnsIpToConfirm: undefined, }; + private customDnsSwitchRef = React.createRef<HTMLDivElement>(); + private customDnsAddButtonRef = React.createRef<HTMLButtonElement>(); + private customDnsInputContainerRef = React.createRef<HTMLDivElement>(); + private portItems: { [key in RelayProtocol]: Array<ISelectorItem<OptionalPort>> }; private protocolItems: Array<ISelectorItem<OptionalRelayProtocol>>; private bridgeStateItems: Array<ISelectorItem<BridgeState>>; @@ -395,7 +421,7 @@ export default class AdvancedSettings extends React.Component<IProps, IState> { </Cell.Footer> </AriaInputGroup> - <StyledBottomCellGroup> + <StyledButtonCellGroup> <Cell.CellButton onClick={this.props.onViewWireguardKeys}> <Cell.Label> {messages.pgettext('advanced-settings-view', 'WireGuard key')} @@ -411,7 +437,67 @@ export default class AdvancedSettings extends React.Component<IProps, IState> { <Cell.Icon height={12} width={7} source="icon-chevron" /> </Cell.CellButton> )} - </StyledBottomCellGroup> + </StyledButtonCellGroup> + + <StyledCustomDnsSwitchContainer> + <AriaInputGroup> + <AriaLabel> + <Cell.InputLabel> + {messages.pgettext('advanced-settings-view', 'Use custom DNS server')} + </Cell.InputLabel> + </AriaLabel> + <AriaInput> + <Cell.Switch + ref={this.customDnsSwitchRef} + isOn={this.props.dns.custom} + onChange={this.setCustomDnsEnabled} + /> + </AriaInput> + </AriaInputGroup> + </StyledCustomDnsSwitchContainer> + <Accordion expanded={this.props.dns.custom}> + <CellList items={this.customDnsItems()} onRemove={this.removeDnsAddress} /> + + {this.state.showAddCustomDns && ( + <div ref={this.customDnsInputContainerRef}> + <Cell.RowInput + onSubmit={this.addDnsAddress} + onChange={this.addDnsInputChange} + invalid={this.state.invalidDnsIp} + paddingLeft={32} + onBlur={this.customDnsInputBlur} + autofocus + /> + </div> + )} + + <StyledAddCustomDnsButton + ref={this.customDnsAddButtonRef} + onClick={this.showAddCustomDnsRow} + disabled={this.state.showAddCustomDns} + tabIndex={-1}> + <StyledAddCustomDnsLabel tabIndex={-1}> + {messages.pgettext('advanced-settings-view', 'Add a server')} + </StyledAddCustomDnsLabel> + <Cell.Icon + source="icon-add" + width={22} + height={22} + tintColor={colors.white60} + tintHoverColor={colors.white80} + tabIndex={-1} + /> + </StyledAddCustomDnsButton> + </Accordion> + + <StyledCustomDnsFotter> + <Cell.FooterText> + {messages.pgettext( + 'advanced-settings-view', + 'Enable to add at least one DNS server.', + )} + </Cell.FooterText> + </StyledCustomDnsFotter> </StyledNavigationScrollbars> </NavigationContainer> </StyledContainer> @@ -419,10 +505,105 @@ export default class AdvancedSettings extends React.Component<IProps, IState> { {this.state.showConfirmBlockWhenDisconnectedAlert && this.renderConfirmBlockWhenDisconnectedAlert()} + {this.state.publicDnsIpToConfirm && this.renderCustomDnsConfirmationDialog()} </ModalContainer> ); } + private setCustomDnsEnabled = async (enabled: boolean) => { + await this.props.setDnsOptions({ + custom: enabled, + addresses: this.props.dns.addresses, + }); + + if (enabled && this.props.dns.addresses.length === 0) { + this.showAddCustomDnsRow(); + } + + if (!enabled) { + this.setState({ showAddCustomDns: false }); + } + }; + + private customDnsItems(): ICellListItem<string>[] { + return this.props.dns.addresses.map((address) => ({ + label: address, + value: address, + })); + } + + private showAddCustomDnsRow = () => { + this.setState({ showAddCustomDns: true }); + }; + + // The input field should be hidden when it loses focus unless something on the same row or the + // add-button is the new focused element. + private customDnsInputBlur = (event?: React.FocusEvent<HTMLTextAreaElement>) => { + const relatedTarget = event?.relatedTarget as Node | undefined; + if ( + relatedTarget && + (this.customDnsSwitchRef.current?.contains(relatedTarget) || + this.customDnsAddButtonRef.current?.contains(relatedTarget) || + this.customDnsInputContainerRef.current?.contains(relatedTarget)) + ) { + event?.target.focus(); + } else { + this.hideAddCustomDnsRow(false); + } + }; + + private hideAddCustomDnsRow(justAdded: boolean) { + if (!this.state.publicDnsIpToConfirm) { + this.setState({ showAddCustomDns: false }); + if (!justAdded && this.props.dns.addresses.length === 0) { + consumePromise(this.setCustomDnsEnabled(false)); + } + } + } + + private addDnsInputChange = (_value: string) => { + this.setState({ invalidDnsIp: false }); + }; + + private hideCustomDnsConfirmationDialog = () => { + this.setState({ publicDnsIpToConfirm: undefined }); + }; + + private confirmPublicDnsAddress = () => { + consumePromise(this.addDnsAddress(this.state.publicDnsIpToConfirm!, true)); + this.hideCustomDnsConfirmationDialog(); + }; + + private addDnsAddress = async (address: string, confirmed?: boolean) => { + if (ip.isV4Format(address) || ip.isV6Format(address)) { + if (ip.isPublic(address) && !confirmed) { + this.setState({ publicDnsIpToConfirm: address }); + } else { + try { + await this.props.setDnsOptions({ + custom: this.props.dns.custom, + addresses: [...this.props.dns.addresses, address], + }); + this.hideAddCustomDnsRow(true); + } catch (_e) { + this.setState({ invalidDnsIp: true }); + } + } + } else { + this.setState({ invalidDnsIp: true }); + } + }; + + private removeDnsAddress = (address: string) => { + const addresses = this.props.dns.addresses.filter((item) => item !== address); + consumePromise( + this.props.setDnsOptions({ + custom: addresses.length > 0 && this.props.dns.custom, + addresses, + }), + ); + }; + private tunnelProtocolItems = ( hasWireguardKey: boolean, ): Array<ISelectorItem<OptionalTunnelProtocol>> => { @@ -448,6 +629,26 @@ export default class AdvancedSettings extends React.Component<IProps, IState> { ]; }; + private renderCustomDnsConfirmationDialog = () => { + return ( + <ModalAlert + type={ModalAlertType.info} + buttons={[ + <AppButton.RedButton key="confirm" onClick={this.confirmPublicDnsAddress}> + {messages.pgettext('advanced-settings-view', 'Add anyway')} + </AppButton.RedButton>, + <AppButton.BlueButton key="back" onClick={this.hideCustomDnsConfirmationDialog}> + {messages.gettext('Back')} + </AppButton.BlueButton>, + ]} + close={this.hideCustomDnsConfirmationDialog} + message={messages.pgettext( + 'advanced-settings-view', + 'The DNS server you are trying to add might not work because it is public. Currently we only support local DNS servers.', + )}></ModalAlert> + ); + }; + private renderConfirmBlockWhenDisconnectedAlert = () => { return ( <ModalAlert diff --git a/gui/src/renderer/components/AdvancedSettingsStyles.tsx b/gui/src/renderer/components/AdvancedSettingsStyles.tsx index 0bccc7c158..d26972ed1c 100644 --- a/gui/src/renderer/components/AdvancedSettingsStyles.tsx +++ b/gui/src/renderer/components/AdvancedSettingsStyles.tsx @@ -29,7 +29,7 @@ export const StyledNavigationScrollbars = styled(NavigationScrollbars)({ flex: 1, }); -export const StyledBottomCellGroup = styled.div({ +export const StyledButtonCellGroup = styled.div({ display: 'flex', flexDirection: 'column', flex: 1, @@ -44,3 +44,29 @@ export const StyledNoWireguardKeyError = styled(Cell.FooterText)({ fontWeight: 800, color: colors.red, }); + +export const StyledCustomDnsSwitchContainer = styled(Cell.Container)({ + marginBottom: '1px', +}); + +export const StyledCustomDnsFotter = styled(Cell.Footer)({ + marginBottom: '2px', +}); + +export const StyledAddCustomDnsButton = styled(Cell.CellButton)({ + backgroundColor: colors.blue40, +}); + +export const StyledAddCustomDnsLabel = styled(Cell.Label)( + {}, + (props: { paddingLeft?: number }) => ({ + fontFamily: 'Open Sans', + fontWeight: 'normal', + fontSize: '16px', + paddingLeft: (props.paddingLeft ?? 32) + 'px', + whiteSpace: 'pre-wrap', + overflowWrap: 'break-word', + width: '171px', + marginRight: '25px', + }), +); diff --git a/gui/src/renderer/components/cell/Input.tsx b/gui/src/renderer/components/cell/Input.tsx index bfa47b6687..0ab5b4c493 100644 --- a/gui/src/renderer/components/cell/Input.tsx +++ b/gui/src/renderer/components/cell/Input.tsx @@ -1,9 +1,10 @@ -import React, { useCallback, useContext, useState } from 'react'; +import React, { useCallback, useContext, useEffect, useRef, useState } from 'react'; import styled from 'styled-components'; import { colors } from '../../../config.json'; import { mediumText } from '../common-styles'; -import { CellDisabledContext } from './Container'; +import { CellDisabledContext, Container } from './Container'; import StandaloneSwitch from '../Switch'; +import ImageView from '../ImageView'; export const Switch = React.forwardRef(function SwitchT( props: StandaloneSwitch['props'], @@ -183,3 +184,141 @@ export function AutoSizingTextInput({ onChangeValue, ...otherProps }: IInputProp </StyledAutoSizingTextInputContainer> ); } + +const StyledCellInputRowContainer = styled(Container)({ + backgroundColor: 'white', + marginBottom: '1px', +}); + +const StyledSubmitButton = styled.button({ + border: 'none', + backgroundColor: 'transparent', + padding: '14px 0', +}); + +const StyledInputWrapper = styled.div({}, (props: { marginLeft: number }) => ({ + position: 'relative', + flex: 1, + width: '171px', + marginLeft: props.marginLeft + 'px', + marginRight: '25px', + lineHeight: '24px', + minHeight: '24px', + fontFamily: 'Open Sans', + fontWeight: 'normal', + fontSize: '16px', + padding: '14px 0', + maxWidth: '100%', +})); + +const StyledTextArea = styled.textarea({}, (props: { invalid?: boolean }) => ({ + position: 'absolute', + top: 0, + left: 0, + width: '100%', + height: '100%', + backgroundColor: 'transparent', + border: 'none', + flex: 1, + lineHeight: '24px', + fontFamily: 'Open Sans', + fontWeight: 'normal', + fontSize: '16px', + resize: 'none', + padding: '14px 0', + color: props.invalid ? colors.red : 'auto', +})); + +const StyledInputFiller = styled.div({ + whiteSpace: 'pre-wrap', + overflowWrap: 'break-word', + minHeight: '24px', + color: 'transparent', +}); + +interface IRowInputProps { + onChange?: (value: string) => void; + onSubmit: (value: string) => void; + onFocus?: (event: React.FocusEvent<HTMLTextAreaElement>) => void; + onBlur?: (event?: React.FocusEvent<HTMLTextAreaElement>) => void; + paddingLeft?: number; + invalid?: boolean; + autofocus?: boolean; +} + +export function RowInput(props: IRowInputProps) { + const [value, setValue] = useState(''); + const textAreaRef = useRef() as React.RefObject<HTMLTextAreaElement>; + + const submit = useCallback(() => props.onSubmit(value), [props.onSubmit, value]); + const onChange = useCallback( + (event: React.ChangeEvent<HTMLTextAreaElement>) => { + const value = event.target.value; + setValue(value); + props.onChange?.(value); + }, + [props.onChange], + ); + const onKeyDown = useCallback( + (event: React.KeyboardEvent<HTMLTextAreaElement>) => { + if (event.key === 'Enter') { + event.preventDefault(); + submit(); + } + }, + [submit], + ); + + const globalKeyListener = useCallback( + (event: KeyboardEvent) => { + if (event.key === 'Escape') { + event.stopPropagation(); + props.onBlur?.(); + } + }, + [props.onBlur], + ); + + useEffect(() => { + if (props.autofocus) { + textAreaRef.current?.focus(); + } + }, []); + + useEffect(() => { + if (props.invalid) { + textAreaRef.current?.focus(); + } + }, [props.invalid]); + + useEffect(() => { + document.addEventListener('keydown', globalKeyListener, true); + return () => document.removeEventListener('keydown', globalKeyListener, true); + }, []); + + return ( + <StyledCellInputRowContainer> + <StyledInputWrapper marginLeft={props.paddingLeft ?? 0}> + <StyledInputFiller>{value}</StyledInputFiller> + <StyledTextArea + ref={textAreaRef} + onChange={onChange} + onKeyDown={onKeyDown} + rows={1} + value={value} + invalid={props.invalid} + onFocus={props.onFocus} + onBlur={props.onBlur} + /> + </StyledInputWrapper> + <StyledSubmitButton onClick={submit}> + <ImageView + source="icon-tick" + height={22} + tintColor={colors.green} + tintHoverColor={colors.green90} + /> + </StyledSubmitButton> + </StyledCellInputRowContainer> + ); +} diff --git a/gui/src/renderer/components/cell/List.tsx b/gui/src/renderer/components/cell/List.tsx new file mode 100644 index 0000000000..cd99a052cb --- /dev/null +++ b/gui/src/renderer/components/cell/List.tsx @@ -0,0 +1,123 @@ +import React, { useCallback } from 'react'; +import styled from 'styled-components'; +import { colors } from '../../../config.json'; +import { messages } from '../../../shared/gettext'; +import { AriaDescribed, AriaDescription, AriaDescriptionGroup } from '../AriaGroup'; +import ImageView from '../ImageView'; +import * as Cell from '.'; + +export interface ICellListItem<T> { + label: string; + value: T; +} + +interface ICellListProps<T> { + title?: string; + items: Array<ICellListItem<T>>; + onSelect?: (value: T) => void; + onRemove?: (value: T) => void; + className?: string; + paddingLeft?: number; +} + +export default function CellList<T>(props: ICellListProps<T>) { + const paddingLeft = props.paddingLeft ?? 32; + + return ( + <Cell.Section role="listbox" className={props.className}> + {props.title && <Cell.SectionTitle as="label">{props.title}</Cell.SectionTitle>} + {props.items.map((item, i) => { + return ( + <CellListItem + key={`${i}-${item.value}`} + value={item.value} + onSelect={props.onSelect} + onRemove={props.onRemove} + paddingLeft={paddingLeft}> + {item.label} + </CellListItem> + ); + })} + </Cell.Section> + ); +} + +const StyledContainer = styled(Cell.Container)({ + display: 'flex', + marginBottom: '1px', + backgroundColor: colors.blue40, +}); + +const StyledButton = styled.button({ + display: 'flex', + alignItems: 'center', + flex: 1, + border: 'none', + background: 'transparent', + padding: 0, + margin: 0, +}); + +const StyledLabel = styled(Cell.Label)({}, (props: { paddingLeft: number }) => ({ + fontFamily: 'Open Sans', + fontWeight: 'normal', + fontSize: '16px', + paddingLeft: props.paddingLeft + 'px', + whiteSpace: 'pre-wrap', + overflowWrap: 'break-word', + width: '171px', + marginRight: '25px', +})); + +const StyledRemoveButton = styled.button({ + background: 'transparent', + border: 'none', + padding: 0, +}); + +const StyledRemoveIcon = styled(ImageView)({ + [StyledRemoveButton + ':hover &']: { + backgroundColor: colors.white80, + }, +}); + +interface ICellListItemProps<T> { + value: T; + onSelect?: (application: T) => void; + onRemove?: (application: T) => void; + paddingLeft: number; + children: string; +} + +function CellListItem<T>(props: ICellListItemProps<T>) { + const onSelect = useCallback(() => props.onSelect?.(props.value), [props.onSelect, props.value]); + const onRemove = useCallback(() => props.onRemove?.(props.value), [props.onRemove, props.value]); + + return ( + <AriaDescriptionGroup> + <StyledContainer> + <StyledButton + onClick={props.onSelect ? onSelect : undefined} + as={props.onSelect ? 'button' : 'span'}> + <AriaDescription> + <StyledLabel paddingLeft={props.paddingLeft}>{props.children}</StyledLabel> + </AriaDescription> + </StyledButton> + {props.onRemove && ( + <AriaDescribed> + <StyledRemoveButton + onClick={onRemove} + aria-label={messages.pgettext('accessibility', 'Remove item')}> + <StyledRemoveIcon + source="icon-close" + width={22} + height={22} + tintColor={colors.white60} + /> + </StyledRemoveButton> + </AriaDescribed> + )} + </StyledContainer> + </AriaDescriptionGroup> + ); +} diff --git a/gui/src/renderer/containers/AdvancedSettingsPage.tsx b/gui/src/renderer/containers/AdvancedSettingsPage.tsx index 6dec67cf4d..68d6899acb 100644 --- a/gui/src/renderer/containers/AdvancedSettingsPage.tsx +++ b/gui/src/renderer/containers/AdvancedSettingsPage.tsx @@ -1,7 +1,12 @@ import log from 'electron-log'; import { connect } from 'react-redux'; import { RouteComponentProps, withRouter } from 'react-router'; -import { BridgeState, RelayProtocol, TunnelProtocol } from '../../shared/daemon-rpc-types'; +import { + BridgeState, + IDnsOptions, + RelayProtocol, + TunnelProtocol, +} from '../../shared/daemon-rpc-types'; import RelaySettingsBuilder from '../../shared/relay-settings-builder'; import AdvancedSettings from '../components/AdvancedSettings'; @@ -19,6 +24,7 @@ const mapStateToProps = (state: IReduxState) => { mssfix: state.settings.openVpn.mssfix, wireguardMtu: state.settings.wireguard.mtu, bridgeState: state.settings.bridgeState, + dns: state.settings.dns, ...protocolAndPort, }; }; @@ -152,6 +158,11 @@ const mapDispatchToProps = (_dispatch: ReduxDispatch, props: RouteComponentProps log.error('Failed to update mtu value', e.message); } }, + + setDnsOptions: (dns: IDnsOptions) => { + return props.app.setDnsOptions(dns); + }, + onViewWireguardKeys: () => props.history.push('/settings/advanced/wireguard-keys'), onViewLinuxSplitTunneling: () => props.history.push('/settings/advanced/linux-split-tunneling'), }; diff --git a/gui/src/renderer/redux/settings/actions.ts b/gui/src/renderer/redux/settings/actions.ts index d1f564bccd..6428badde8 100644 --- a/gui/src/renderer/redux/settings/actions.ts +++ b/gui/src/renderer/redux/settings/actions.ts @@ -1,4 +1,9 @@ -import { BridgeState, IWireguardPublicKey, KeygenEvent } from '../../../shared/daemon-rpc-types'; +import { + BridgeState, + IDnsOptions, + IWireguardPublicKey, + KeygenEvent, +} from '../../../shared/daemon-rpc-types'; import { IGuiSettingsState } from '../../../shared/gui-settings-state'; import { BridgeSettingsRedux, IRelayLocationRedux, IWgKey, RelaySettingsRedux } from './reducers'; @@ -97,6 +102,11 @@ export interface IWireguardKeyVerifiedAction { verified?: boolean; } +export interface IUpdateDnsOptionsAction { + type: 'UPDATE_DNS_OPTIONS'; + dns: IDnsOptions; +} + export type SettingsAction = | IUpdateGuiSettingsAction | IUpdateRelayAction @@ -116,7 +126,8 @@ export type SettingsAction = | IWireguardGenerateKey | IWireguardReplaceKey | IWireguardKeygenEvent - | IWireguardKeyVerifiedAction; + | IWireguardKeyVerifiedAction + | IUpdateDnsOptionsAction; function updateGuiSettings(guiSettings: IGuiSettingsState): IUpdateGuiSettingsAction { return { @@ -261,6 +272,13 @@ function completeWireguardKeyVerification(verified?: boolean): IWireguardKeyVeri }; } +function updateDnsOptions(dns: IDnsOptions): IUpdateDnsOptionsAction { + return { + type: 'UPDATE_DNS_OPTIONS', + dns, + }; +} + export default { updateGuiSettings, updateRelay, @@ -281,4 +299,5 @@ export default { replaceWireguardKey, verifyWireguardKey, completeWireguardKeyVerification, + updateDnsOptions, }; diff --git a/gui/src/renderer/redux/settings/reducers.ts b/gui/src/renderer/redux/settings/reducers.ts index 6f24e871b6..980eea6cd5 100644 --- a/gui/src/renderer/redux/settings/reducers.ts +++ b/gui/src/renderer/redux/settings/reducers.ts @@ -133,6 +133,10 @@ export interface ISettingsReduxState { wireguard: { mtu?: number; }; + dns: { + custom: boolean; + addresses: string[]; + }; wireguardKeyState: WgKeyState; } @@ -173,6 +177,10 @@ const initialState: ISettingsReduxState = { wireguardKeyState: { type: 'key-not-set', }, + dns: { + custom: false, + addresses: [], + }, }; export default function ( @@ -300,6 +308,12 @@ export default function ( }, }; + case 'UPDATE_DNS_OPTIONS': + return { + ...state, + dns: action.dns, + }; + default: return state; } diff --git a/gui/src/shared/daemon-rpc-types.ts b/gui/src/shared/daemon-rpc-types.ts index ecc04eddb6..bac978f7d6 100644 --- a/gui/src/shared/daemon-rpc-types.ts +++ b/gui/src/shared/daemon-rpc-types.ts @@ -262,6 +262,12 @@ export interface ITunnelOptions { generic: { enableIpv6: boolean; }; + dns: IDnsOptions; +} + +export interface IDnsOptions { + custom: boolean; + addresses: string[]; } export type ProxySettings = ILocalProxySettings | IRemoteProxySettings | IShadowsocksProxySettings; diff --git a/gui/src/shared/ipc-event-channel.ts b/gui/src/shared/ipc-event-channel.ts index 5cd1d22098..a7410dd22c 100644 --- a/gui/src/shared/ipc-event-channel.ts +++ b/gui/src/shared/ipc-event-channel.ts @@ -13,6 +13,7 @@ import { BridgeState, IAccountData, IAppVersionInfo, + IDnsOptions, ILocation, IRelayList, ISettings, @@ -78,6 +79,7 @@ interface ISettingsMethods extends IReceiver<ISettings> { setWireguardMtu(mtu?: number): Promise<void>; updateRelaySettings(update: RelaySettingsUpdate): Promise<void>; updateBridgeSettings(bridgeSettings: BridgeSettings): Promise<void>; + setDnsOptions(dns: IDnsOptions): Promise<void>; } interface ISettingsHandlers extends ISender<ISettings> { @@ -90,6 +92,7 @@ interface ISettingsHandlers extends ISender<ISettings> { handleWireguardMtu(fn: (mtu?: number) => Promise<void>): void; handleUpdateRelaySettings(fn: (update: RelaySettingsUpdate) => Promise<void>): void; handleUpdateBridgeSettings(fn: (bridgeSettings: BridgeSettings) => Promise<void>): void; + handleDnsOptions(fn: (dns: IDnsOptions) => Promise<void>): void; } interface IGuiSettingsMethods extends IReceiver<IGuiSettingsState> { @@ -188,6 +191,7 @@ const SET_OPENVPN_MSSFIX = 'set-openvpn-mssfix'; const SET_WIREGUARD_MTU = 'set-wireguard-mtu'; const UPDATE_RELAY_SETTINGS = 'update-relay-settings'; const UPDATE_BRIDGE_SETTINGS = 'update-bridge-location'; +const SET_DNS_OPTIONS = 'set-dns-options'; const LOCATION_CHANGED = 'location-changed'; const RELAYS_CHANGED = 'relays-changed'; @@ -275,6 +279,7 @@ export class IpcRendererEventChannel { setWireguardMtu: requestSender(SET_WIREGUARD_MTU), updateRelaySettings: requestSender(UPDATE_RELAY_SETTINGS), updateBridgeSettings: requestSender(UPDATE_BRIDGE_SETTINGS), + setDnsOptions: requestSender(SET_DNS_OPTIONS), }; public static location: IReceiver<ILocation> = { @@ -385,6 +390,7 @@ export class IpcMainEventChannel { handleWireguardMtu: requestHandler(SET_WIREGUARD_MTU), handleUpdateRelaySettings: requestHandler(UPDATE_RELAY_SETTINGS), handleUpdateBridgeSettings: requestHandler(UPDATE_BRIDGE_SETTINGS), + handleDnsOptions: requestHandler(SET_DNS_OPTIONS), }; public static relays: ISender<IRelayListPair> = { |
