diff options
27 files changed, 1216 insertions, 386 deletions
diff --git a/gui/assets/images/icon-filter.svg b/gui/assets/images/icon-filter.svg new file mode 100644 index 0000000000..da766cb06c --- /dev/null +++ b/gui/assets/images/icon-filter.svg @@ -0,0 +1,4 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"> + <path fill="none" d="M0 0h24v24H0z"/> + <path d="M9.222 16.667h3.556v-1.778H9.222zM3 6v1.778h16V6zm2.667 6.222h10.666v-1.778H5.667z" transform="translate(1 .667)"/> +</svg> diff --git a/gui/assets/images/icon-remove.svg b/gui/assets/images/icon-remove.svg new file mode 100644 index 0000000000..e293425ee5 --- /dev/null +++ b/gui/assets/images/icon-remove.svg @@ -0,0 +1,23 @@ +<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="24" height="24" viewBox="0 0 24 24"> + <defs> + <path id="lypxbt7e6a" d="M19.5 12c0 .552-.448 1-1 1h-13c-.552 0-1-.448-1-1s.448-1 1-1h13c.552 0 1 .448 1 1zM12 24C5.373 24 0 18.627 0 12S5.373 0 12 0s12 5.373 12 12-5.373 12-12 12z"/> + </defs> + <g fill="none" fill-rule="evenodd"> + <g> + <g> + <g> + <g> + <g transform="translate(-280 -279) translate(0 212) translate(0 53) translate(237 14) translate(43)"> + <mask id="wzem7yftrb" fill="#fff"> + <use xlink:href="#lypxbt7e6a"/> + </mask> + <g fill="#FFF" fill-opacity=".4" mask="url(#wzem7yftrb)"> + <path d="M0 0H24V24H0z"/> + </g> + </g> + </g> + </g> + </g> + </g> + </g> +</svg> diff --git a/gui/locales/messages.pot b/gui/locales/messages.pot index 15b7277c37..7c7f85d6be 100644 --- a/gui/locales/messages.pot +++ b/gui/locales/messages.pot @@ -63,6 +63,9 @@ msgstr[1] "" msgid "Back" msgstr "" +msgid "BETA" +msgstr "" + msgid "BLOCKED CONNECTION" msgstr "" @@ -694,6 +697,10 @@ msgid "Reconnecting" msgstr "" msgctxt "notifications" +msgid "The apps excluded with split tunneling might not work properly right now." +msgstr "" + +msgctxt "notifications" msgid "Unable to apply firewall rules." msgstr "" @@ -718,6 +725,10 @@ msgid "Unable to block all network traffic. Try updating your kernel or contact msgstr "" msgctxt "notifications" +msgid "Unable to communicate with Mullvad kernel driver. Try reconnecting or contact support." +msgstr "" + +msgctxt "notifications" msgid "Unable to resolve host of custom tunnel. Try changing your settings." msgstr "" @@ -981,7 +992,15 @@ msgid "%(applicationName)s is problematic and can’t be excluded from the VPN t msgstr "" msgctxt "split-tunneling-view" -msgid "Browse" +msgid "Add" +msgstr "" + +msgctxt "split-tunneling-view" +msgid "All apps" +msgstr "" + +msgctxt "split-tunneling-view" +msgid "Choose the apps you want to exclude from the VPN tunnel." msgstr "" msgctxt "split-tunneling-view" @@ -989,6 +1008,18 @@ msgid "Click on an app to launch it. Its traffic will bypass the VPN tunnel unti msgstr "" msgctxt "split-tunneling-view" +msgid "Excluded apps" +msgstr "" + +msgctxt "split-tunneling-view" +msgid "Filter..." +msgstr "" + +msgctxt "split-tunneling-view" +msgid "Find another app" +msgstr "" + +msgctxt "split-tunneling-view" msgid "If it’s already running, close %(applicationName)s before launching it from here. Otherwise it might not be excluded from the VPN tunnel." msgstr "" @@ -997,7 +1028,7 @@ msgid "Launch" msgstr "" msgctxt "split-tunneling-view" -msgid "Launch application" +msgid "No result for %(searchTerm)s." msgstr "" #. This error message is shown if the user tries to launch a Linux desktop @@ -1017,6 +1048,14 @@ msgctxt "split-tunneling-view" msgid "Split tunneling" msgstr "" +msgctxt "split-tunneling-view" +msgid "Split tunneling has been disabled from the CLI and will automatically be enabled when adding or removing applications from the lists below." +msgstr "" + +msgctxt "split-tunneling-view" +msgid "Try a different search." +msgstr "" + #. Error message showed in a dialog when an application failes to launch. msgctxt "split-tunneling-view" msgid "Unable to launch selection. %(detailedErrorMessage)s" diff --git a/gui/src/config.json b/gui/src/config.json index 112391c360..c573e0214b 100644 --- a/gui/src/config.json +++ b/gui/src/config.json @@ -16,11 +16,13 @@ "red": "rgb(227, 64, 57)", "darkYellow": "rgb(142, 78, 19)", "yellow": "rgb(255, 213, 36)", + "black": "rgb(0, 0, 0)", "white": "rgb(255, 255, 255)", "white80": "rgba(255, 255, 255, 0.8)", "white60": "rgba(255, 255, 255, 0.6)", "white40": "rgba(255, 255, 255, 0.4)", "white20": "rgba(255, 255, 255, 0.2)", + "white10": "rgba(255, 255, 255, 0.1)", "blue20": "rgba(41, 77, 115, 0.2)", "blue40": "rgba(41, 77, 115, 0.4)", "blue60": "rgba(41, 77, 115, 0.6)", diff --git a/gui/src/main/daemon-rpc.ts b/gui/src/main/daemon-rpc.ts index c740393e2a..10159ad49e 100644 --- a/gui/src/main/daemon-rpc.ts +++ b/gui/src/main/daemon-rpc.ts @@ -476,6 +476,18 @@ export class DaemonRpc { return response.toObject(); } + public async addSplitTunnelingApplication(path: string): Promise<void> { + await this.callString(this.client.addSplitTunnelApp, path); + } + + public async removeSplitTunnelingApplication(path: string): Promise<void> { + await this.callString(this.client.removeSplitTunnelApp, path); + } + + public async setSplitTunnelingState(enabled: boolean): Promise<void> { + await this.callBool(this.client.setSplitTunnelState, enabled); + } + private subscriptionId(): number { const current = this.nextSubscriptionId; this.nextSubscriptionId += 1; @@ -802,7 +814,7 @@ function convertFromTunnelStateErrorCause( return { reason: 'tunnel_parameter_error', details: parameterErrorMap[state.parameterError] }; } case grpcTypes.ErrorState.Cause.SPLIT_TUNNEL_ERROR: - return { reason: 'start_tunnel_error' }; + return { reason: 'split_tunnel_error' }; case grpcTypes.ErrorState.Cause.VPN_PERMISSION_DENIED: // VPN_PERMISSION_DENIED is only ever created on Android throw invalidErrorStateCause; @@ -868,12 +880,14 @@ function convertFromSettings(settings: grpcTypes.Settings): ISettings | undefine const relaySettings = convertFromRelaySettings(settings.getRelaySettings())!; const bridgeSettings = convertFromBridgeSettings(settingsObject.bridgeSettings!); const tunnelOptions = convertFromTunnelOptions(settingsObject.tunnelOptions!); + const splitTunnel = settingsObject.splitTunnel ?? { enableExclusions: false, appsList: [] }; return { ...settings.toObject(), bridgeState, relaySettings, bridgeSettings, tunnelOptions, + splitTunnel, }; } diff --git a/gui/src/main/gui-settings.ts b/gui/src/main/gui-settings.ts index 7708facc84..57dc754cd8 100644 --- a/gui/src/main/gui-settings.ts +++ b/gui/src/main/gui-settings.ts @@ -81,7 +81,7 @@ export default class GuiSettings { return this.stateValue.unpinnedWindow; } - public addBrowsedForSplitTunnelingapplications(newApp: string) { + public addBrowsedForSplitTunnelingApplications(newApp: string) { this.changeStateAndNotify({ ...this.stateValue, browsedForSplitTunnelingApplications: [...this.browsedForSplitTunnelingApplications, newApp], diff --git a/gui/src/main/index.ts b/gui/src/main/index.ts index 1785794818..1c981adf11 100644 --- a/gui/src/main/index.ts +++ b/gui/src/main/index.ts @@ -16,6 +16,7 @@ import { sprintf } from 'sprintf-js'; import * as uuid from 'uuid'; import config from '../config.json'; import { hasExpired } from '../shared/account-expiry'; +import { IApplication } from '../shared/application-types'; import BridgeSettingsBuilder from '../shared/bridge-settings-builder'; import { AccountToken, @@ -75,8 +76,9 @@ import TrayIconController, { TrayIconType } from './tray-icon-controller'; import WindowController from './window-controller'; import { ITranslations } from '../shared/ipc-schema'; -// Only import when running app on Linux. +// Only import split tunneling library on correct OS. const linuxSplitTunneling = process.platform === 'linux' && require('./linux-split-tunneling'); +const windowsSplitTunneling = process.platform === 'win32' && require('./windows-split-tunneling'); const DAEMON_RPC_PATH = process.platform === 'win32' ? 'unix:////./pipe/Mullvad VPN' : 'unix:///var/run/mullvad-vpn'; @@ -108,6 +110,10 @@ class ApplicationMain { private tray?: Tray; private trayIconController?: TrayIconController; + // True while file pickers are displayed which is used to decide if the Browser window should be + // hidden when losing focus. + private browsingFiles = false; + private daemonRpc = new DaemonRpc(DAEMON_RPC_PATH); private daemonEventListener?: SubscriptionListener<DaemonEvent>; private reconnectBackoff = new ReconnectionBackoff(); @@ -123,6 +129,10 @@ class ApplicationMain { autoConnect: false, blockWhenDisconnected: false, showBetaReleases: false, + splitTunnel: { + enableExclusions: false, + appsList: [], + }, relaySettings: { normal: { location: 'any', @@ -218,6 +228,8 @@ class ApplicationMain { private rendererLog?: Logger; private translations: ITranslations = { locale: this.locale }; + private windowsSplitTunnelingApplications?: IApplication[]; + public run() { // Remove window animations to combat window flickering when opening window. Can be removed when // this issue has been resolved: https://github.com/electron/electron/issues/12130 @@ -722,6 +734,7 @@ class ApplicationMain { this.notificationController.notifyTunnelState( newState, this.settings.blockWhenDisconnected, + this.settings.splitTunnel.enableExclusions && this.settings.splitTunnel.appsList.length > 0, this.accountData?.expiry, ); @@ -754,6 +767,10 @@ class ApplicationMain { if (this.windowController) { IpcMainEventChannel.settings.notify(this.windowController.webContents, newSettings); + + if (windowsSplitTunneling) { + consumePromise(this.updateSplitTunnelingApplications(newSettings.splitTunnel.appsList)); + } } // since settings can have the relay constraints changed, the relay @@ -761,6 +778,20 @@ class ApplicationMain { this.setRelays(this.relays, newSettings.relaySettings, newSettings.bridgeState); } + private async updateSplitTunnelingApplications(appList: string[]): Promise<void> { + const { applications } = await windowsSplitTunneling.getApplications({ + applicationPaths: appList, + }); + this.windowsSplitTunnelingApplications = applications; + + if (this.windowController) { + IpcMainEventChannel.windowsSplitTunneling.notify( + this.windowController.webContents, + applications, + ); + } + } + private setLocation(newLocation: ILocation) { this.location = newLocation; @@ -1056,6 +1087,7 @@ class ApplicationMain { translations: this.translations, platform: process.platform, runningInDevelopment: process.env.NODE_ENV === 'development', + windowsSplitTunnelingApplications: this.windowsSplitTunnelingApplications, })); IpcMainEventChannel.settings.handleSetAllowLan((allowLan: boolean) => @@ -1155,18 +1187,63 @@ class ApplicationMain { }); IpcMainEventChannel.wireguardKeys.handleVerifyKey(() => this.daemonRpc.verifyWireguardKey()); - IpcMainEventChannel.splitTunneling.handleGetApplications(() => { + IpcMainEventChannel.linuxSplitTunneling.handleGetApplications(() => { if (linuxSplitTunneling) { return linuxSplitTunneling.getApplications(this.locale); } else { - throw Error('linuxSplitTunneling called without being imported'); + throw Error('linuxSplitTunneling.getApplications function called without being imported'); } }); - IpcMainEventChannel.splitTunneling.handleLaunchApplication((application) => { + IpcMainEventChannel.windowsSplitTunneling.handleGetApplications((updateCaches: boolean) => { + if (windowsSplitTunneling) { + return windowsSplitTunneling.getApplications({ + updateCaches, + }); + } else { + throw Error('windowsSplitTunneling.getApplications function called without being imported'); + } + }); + IpcMainEventChannel.linuxSplitTunneling.handleLaunchApplication((application) => { if (linuxSplitTunneling) { return linuxSplitTunneling.launchApplication(application); } else { - throw Error('linuxSplitTunneling called without being imported'); + throw Error('linuxSplitTunneling.launchApplication function called without being imported'); + } + }); + + IpcMainEventChannel.windowsSplitTunneling.handleSetState((enabled) => { + if (windowsSplitTunneling) { + return this.daemonRpc.setSplitTunnelingState(enabled); + } else { + throw Error('windowsSplitTunneling.setState function called without being imported'); + } + }); + IpcMainEventChannel.windowsSplitTunneling.handleAddApplication(async (application) => { + if (windowsSplitTunneling) { + // If the applications is a string (path) it's an application picked with the file picker + // that we want to add to the list of additional applications. + if (typeof application === 'string') { + this.guiSettings.addBrowsedForSplitTunnelingApplications(application); + const applicationPath = windowsSplitTunneling.addApplicationPathToCache(application); + await this.daemonRpc.addSplitTunnelingApplication(applicationPath); + } else { + await this.daemonRpc.addSplitTunnelingApplication(application.absolutepath); + } + } else { + throw Error( + 'windowsSplitTunneling.handleAddApplication function called without being imported', + ); + } + }); + IpcMainEventChannel.windowsSplitTunneling.handleRemoveApplication((application) => { + if (windowsSplitTunneling) { + return this.daemonRpc.removeSplitTunnelingApplication( + typeof application === 'string' ? application : application.absolutepath, + ); + } else { + throw Error( + 'windowsSplitTunneling.handleRemoveApplication function called without being imported', + ); } }); @@ -1228,7 +1305,21 @@ class ApplicationMain { await shell.openExternal(url); } }); - IpcMainEventChannel.app.handleShowOpenDialog((options) => dialog.showOpenDialog(options)); + IpcMainEventChannel.app.handleShowOpenDialog(async (options) => { + this.browsingFiles = true; + const response = await dialog.showOpenDialog({ + defaultPath: app.getPath('home'), + ...options, + }); + this.browsingFiles = false; + return response; + }); + + if (windowsSplitTunneling) { + this.guiSettings.browsedForSplitTunnelingApplications.forEach( + windowsSplitTunneling.addApplicationPathToCache, + ); + } } private async createNewAccount(): Promise<string> { @@ -1806,7 +1897,7 @@ class ApplicationMain { cursorPos.y >= trayBounds.y && cursorPos.x <= trayBounds.x + trayBounds.width && cursorPos.y <= trayBounds.y + trayBounds.height; - if (!isCursorInside) { + if (!isCursorInside && !this.browsingFiles) { windowController.hide(); } }); diff --git a/gui/src/main/notification-controller.ts b/gui/src/main/notification-controller.ts index 80eb14364a..ca0e12888f 100644 --- a/gui/src/main/notification-controller.ts +++ b/gui/src/main/notification-controller.ts @@ -51,6 +51,7 @@ export default class NotificationController { public notifyTunnelState( tunnelState: TunnelState, blockWhenDisconnected: boolean, + hasExcludedApps: boolean, accountExpiry?: string, ) { const notificationProviders: SystemNotificationProvider[] = [ @@ -58,7 +59,7 @@ export default class NotificationController { new ConnectedNotificationProvider(tunnelState), new ReconnectingNotificationProvider(tunnelState), new DisconnectedNotificationProvider({ tunnelState, blockWhenDisconnected }), - new ErrorNotificationProvider({ tunnelState, accountExpiry }), + new ErrorNotificationProvider({ tunnelState, accountExpiry, hasExcludedApps }), ]; const notificationProvider = notificationProviders.find((notification) => diff --git a/gui/src/main/windows-split-tunneling.ts b/gui/src/main/windows-split-tunneling.ts index c07f9f2f7c..88a32f9850 100644 --- a/gui/src/main/windows-split-tunneling.ts +++ b/gui/src/main/windows-split-tunneling.ts @@ -36,7 +36,13 @@ const APPLICATION_PATHS = [ // Some applications might be falsely filtered from the application list. This allow-list specifies // apps that are falsely filtered but should be included. -const APPLICATION_ALLOW_LIST = ['firefox.exe', 'chrome.exe']; +const APPLICATION_ALLOW_LIST = [ + 'firefox.exe', + 'chrome.exe', + 'msedge.exe', + 'brave.exe', + 'iexplore.exe', +]; // Cache of all previously scanned shortcuts. const shortcutCache: Record<string, ShortcutDetails> = {}; @@ -68,7 +74,10 @@ export async function getApplications(options: { .filter( (application) => options.applicationPaths === undefined || - options.applicationPaths.includes(application.absolutepath), + options.applicationPaths.find( + (applicationPath) => + applicationPath.toLowerCase() === application.absolutepath.toLowerCase(), + ) !== undefined, ) .sort((a, b) => a.name.localeCompare(b.name)); @@ -101,11 +110,11 @@ async function updateShortcutCache(): Promise<void> { const shortcuts: ShortcutDetails[] = []; for (const shortcut of resolvedLinks) { if ( - APPLICATION_ALLOW_LIST.includes(path.basename(shortcut.target)) || + APPLICATION_ALLOW_LIST.includes(path.basename(shortcut.target.toLowerCase())) || (await importsDll(shortcut.target, 'WS2_32.dll')) ) { shortcuts.push(shortcut); - shortcutCache[shortcut.target] = shortcut; + shortcutCache[shortcut.target.toLowerCase()] = shortcut; } } } @@ -115,11 +124,12 @@ async function updateApplicationCache(): Promise<void> { await Promise.all( shortcuts.map(async (shortcut) => { - if (applicationCache[shortcut.target] === undefined) { - applicationCache[shortcut.target] = await convertToSplitTunnelingApplication(shortcut); + const lowercaseTarget = shortcut.target.toLowerCase(); + if (applicationCache[lowercaseTarget] === undefined) { + applicationCache[lowercaseTarget] = await convertToSplitTunnelingApplication(shortcut); } - return applicationCache[shortcut.target]; + return applicationCache[lowercaseTarget]; }), ); } @@ -127,8 +137,10 @@ async function updateApplicationCache(): Promise<void> { // Add excluded apps that are missing from the shortcut cache to it function addApplicationToAdditionalShortcuts(applicationPath: string): void { if ( - shortcutCache[applicationPath] === undefined && - !additionalShortcuts.some((shortcut) => shortcut.target === applicationPath) + shortcutCache[applicationPath.toLowerCase()] === undefined && + !additionalShortcuts.some( + (shortcut) => shortcut.target.toLowerCase() === applicationPath.toLowerCase(), + ) ) { additionalShortcuts.push({ target: applicationPath, @@ -178,16 +190,17 @@ function resolveLinks(linkPaths: string[]): ShortcutDetails[] { // Removes all duplicate shortcuts. function removeDuplicates(shortcuts: ShortcutDetails[]): ShortcutDetails[] { const unique = shortcuts.reduce((shortcuts, shortcut) => { - if (shortcuts[shortcut.target]) { + const lowercaseTarget = shortcut.target.toLowerCase(); + if (shortcuts[lowercaseTarget]) { if ( - shortcuts[shortcut.target].args && - shortcuts[shortcut.target].args !== '' && + shortcuts[lowercaseTarget].args && + shortcuts[lowercaseTarget].args !== '' && (!shortcut.args || shortcut.args === '') ) { - shortcuts[shortcut.target] = shortcut; + shortcuts[lowercaseTarget] = shortcut; } } else { - shortcuts[shortcut.target] = shortcut; + shortcuts[lowercaseTarget] = shortcut; } return shortcuts; }, {} as Record<string, ShortcutDetails>); @@ -206,7 +219,7 @@ async function convertToSplitTunnelingApplication( } async function retrieveIcon(exe: string) { - const icon = await app.getFileIcon(exe); + const icon = await app.getFileIcon(exe, { size: 'large' }); return icon.toDataURL(); } diff --git a/gui/src/renderer/app.tsx b/gui/src/renderer/app.tsx index 36e49c7c77..677286c092 100644 --- a/gui/src/renderer/app.tsx +++ b/gui/src/renderer/app.tsx @@ -16,9 +16,9 @@ import userInterfaceActions from './redux/userinterface/actions'; import versionActions from './redux/version/actions'; import { ICurrentAppVersionInfo } from '../shared/ipc-types'; -import { ILinuxSplitTunnelingApplication } from '../shared/application-types'; -import { messages, relayLocations } from '../shared/gettext'; +import { IApplication, ILinuxSplitTunnelingApplication } from '../shared/application-types'; import { IGuiSettingsState, SYSTEM_PREFERRED_LOCALE_KEY } from '../shared/gui-settings-state'; +import { messages, relayLocations } from '../shared/gettext'; import log, { ConsoleOutput } from '../shared/logging'; import { IRelayListPair, LaunchApplicationResult } from '../shared/ipc-schema'; import consumePromise from '../shared/promise'; @@ -197,6 +197,10 @@ export default class AppRenderer { this.reduxActions.settings.setWireguardKeygenEvent(event); }); + IpcRendererEventChannel.windowsSplitTunneling.listen((applications: IApplication[]) => { + this.reduxActions.settings.setSplitTunnelingApplications(applications); + }); + IpcRendererEventChannel.windowFocus.listen((focus: boolean) => { this.reduxActions.userInterface.setWindowFocused(focus); }); @@ -245,6 +249,12 @@ export default class AppRenderer { } this.checkContentHeight(); + + if (initialState.windowsSplitTunnelingApplications) { + this.reduxActions.settings.setSplitTunnelingApplications( + initialState.windowsSplitTunnelingApplications, + ); + } } public renderView() { @@ -448,14 +458,30 @@ export default class AppRenderer { actions.settings.setWireguardKeygenEvent(keygenEvent); } - public getSplitTunnelingApplications() { - return IpcRendererEventChannel.splitTunneling.getApplications(); + public getLinuxSplitTunnelingApplications() { + return IpcRendererEventChannel.linuxSplitTunneling.getApplications(); + } + + public getWindowsSplitTunnelingApplications(updateCache = false) { + return IpcRendererEventChannel.windowsSplitTunneling.getApplications(updateCache); } public launchExcludedApplication( application: ILinuxSplitTunnelingApplication | string, ): Promise<LaunchApplicationResult> { - return IpcRendererEventChannel.splitTunneling.launchApplication(application); + return IpcRendererEventChannel.linuxSplitTunneling.launchApplication(application); + } + + public setSplitTunnelingState(enabled: boolean): Promise<void> { + return IpcRendererEventChannel.windowsSplitTunneling.setState(enabled); + } + + public addSplitTunnelingApplication(application: IApplication | string): Promise<void> { + return IpcRendererEventChannel.windowsSplitTunneling.addApplication(application); + } + + public removeSplitTunnelingApplication(application: IApplication | string) { + consumePromise(IpcRendererEventChannel.windowsSplitTunneling.removeApplication(application)); } public collectProblemReport(toRedact?: string): Promise<string> { @@ -716,6 +742,7 @@ export default class AppRenderer { reduxSettings.updateWireguardMtu(newSettings.tunnelOptions.wireguard.mtu); reduxSettings.updateBridgeState(newSettings.bridgeState); reduxSettings.updateDnsOptions(newSettings.tunnelOptions.dns); + reduxSettings.updateSplitTunnelingState(newSettings.splitTunnel.enableExclusions); 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 ef59798a46..2614379781 100644 --- a/gui/src/renderer/components/AdvancedSettings.tsx +++ b/gui/src/renderer/components/AdvancedSettings.tsx @@ -25,6 +25,7 @@ import { StyledCustomDnsFotter, StyledAddCustomDnsLabel, StyledAddCustomDnsButton, + StyledBetaLabel, } from './AdvancedSettingsStyles'; import * as AppButton from './AppButton'; import { AriaDescription, AriaInput, AriaInputGroup, AriaLabel } from './AriaGroup'; @@ -85,7 +86,7 @@ interface IProps { setWireguardRelayPort: (port?: number) => void; setDnsOptions: (dns: IDnsOptions) => Promise<void>; onViewWireguardKeys: () => void; - onViewLinuxSplitTunneling: () => void; + onViewSplitTunneling: () => void; onClose: () => void; } @@ -438,10 +439,13 @@ export default class AdvancedSettings extends React.Component<IProps, IState> { </Cell.Label> <Cell.Icon height={12} width={7} source="icon-chevron" /> </Cell.CellButton> + </StyledButtonCellGroup> - {window.platform === 'linux' && ( - <Cell.CellButton onClick={this.props.onViewLinuxSplitTunneling}> + <StyledButtonCellGroup> + {(window.platform === 'linux' || window.platform === 'win32') && ( + <Cell.CellButton onClick={this.props.onViewSplitTunneling}> <Cell.Label> + {window.platform === 'win32' && <StyledBetaLabel />} {messages.pgettext('advanced-settings-view', 'Split tunneling')} </Cell.Label> <Cell.Icon height={12} width={7} source="icon-chevron" /> diff --git a/gui/src/renderer/components/AdvancedSettingsStyles.tsx b/gui/src/renderer/components/AdvancedSettingsStyles.tsx index b618db0844..56c8b99a9c 100644 --- a/gui/src/renderer/components/AdvancedSettingsStyles.tsx +++ b/gui/src/renderer/components/AdvancedSettingsStyles.tsx @@ -1,5 +1,6 @@ import styled from 'styled-components'; import { colors } from '../../config.json'; +import BetaLabel from './BetaLabel'; import * as Cell from './cell'; import { Container } from './Layout'; import { NavigationScrollbars } from './NavigationBar'; @@ -33,7 +34,7 @@ export const StyledButtonCellGroup = styled.div({ display: 'flex', flexDirection: 'column', flex: 1, - marginBottom: '22px', + marginBottom: '20px', }); export const StyledNoWireguardKeyErrorContainer = styled(Cell.Footer)({ @@ -70,3 +71,8 @@ export const StyledAddCustomDnsLabel = styled(Cell.Label)( marginRight: '25px', }), ); + +export const StyledBetaLabel = styled(BetaLabel)({ + marginRight: '8px', + verticalAlign: 'bottom', +}); diff --git a/gui/src/renderer/components/BetaLabel.tsx b/gui/src/renderer/components/BetaLabel.tsx new file mode 100644 index 0000000000..eded2eea55 --- /dev/null +++ b/gui/src/renderer/components/BetaLabel.tsx @@ -0,0 +1,26 @@ +import React from 'react'; +import styled from 'styled-components'; +import { colors } from '../../config.json'; +import { messages } from '../../shared/gettext'; + +const StyledBetaLabel = styled.span({ + display: 'inline-block', + fontFamily: 'Open Sans', + color: colors.blue, + fontSize: '13px', + fontWeight: 800, + lineHeight: '20px', + padding: '2px 0', + background: colors.yellow, + borderRadius: '5px', + width: '50px', + textAlign: 'center', +}); + +interface IBetaLabelProps { + className?: string; +} + +export default function BetaLabel(props: IBetaLabelProps) { + return <StyledBetaLabel {...props}>{messages.gettext('BETA')}</StyledBetaLabel>; +} diff --git a/gui/src/renderer/components/CustomScrollbars.tsx b/gui/src/renderer/components/CustomScrollbars.tsx index 87c5b82dce..2b5119839c 100644 --- a/gui/src/renderer/components/CustomScrollbars.tsx +++ b/gui/src/renderer/components/CustomScrollbars.tsx @@ -72,10 +72,10 @@ export default class CustomScrollbars extends React.Component<IProps, IState> { this.updateScrollbarsHelper({ size: true }); }); - public scrollToTop() { + public scrollToTop(smooth = false) { const scrollable = this.scrollableRef.current; if (scrollable) { - scrollable.scrollTop = 0; + scrollable.scrollTo({ top: 0, behavior: smooth ? 'smooth' : 'auto' }); } } diff --git a/gui/src/renderer/components/LinuxSplitTunnelingSettings.tsx b/gui/src/renderer/components/LinuxSplitTunnelingSettings.tsx deleted file mode 100644 index fea2fe23db..0000000000 --- a/gui/src/renderer/components/LinuxSplitTunnelingSettings.tsx +++ /dev/null @@ -1,318 +0,0 @@ -import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'; -import { sprintf } from 'sprintf-js'; -import styled from 'styled-components'; -import { colors } from '../../config.json'; -import { messages } from '../../shared/gettext'; -import { ILinuxSplitTunnelingApplication } from '../../shared/application-types'; -import consumePromise from '../../shared/promise'; -import { useAppContext } from '../context'; -import * as AppButton from './AppButton'; -import * as Cell from './cell'; -import ImageView from './ImageView'; -import { Container, Layout } from './Layout'; -import { ModalContainer, ModalAlert, ModalAlertType } from './Modal'; -import { - BackBarItem, - NavigationBar, - NavigationContainer, - NavigationItems, - NavigationScrollbars, - TitleBarItem, -} from './NavigationBar'; -import SettingsHeader, { HeaderSubTitle, HeaderTitle } from './SettingsHeader'; -import { useHistory } from '../lib/history'; - -const StyledPageCover = styled.div({}, (props: { show: boolean }) => ({ - position: 'absolute', - zIndex: 2, - top: 0, - left: 0, - right: 0, - bottom: 0, - backgroundColor: '#000000', - opacity: 0.6, - display: props.show ? 'block' : 'none', -})); - -const StyledContainer = styled(Container)({ - backgroundColor: colors.darkBlue, -}); - -const StyledNavigationScrollbars = styled(NavigationScrollbars)({ - flex: 1, -}); - -const StyledContent = styled.div({ - display: 'flex', - flexDirection: 'column', - flex: 1, -}); - -const StyledCellButton = styled(Cell.CellButton)((props: { lookDisabled: boolean }) => ({ - ':not(:disabled):hover': { - backgroundColor: props.lookDisabled ? colors.blue : undefined, - }, -})); - -const disabledApplication = (props: { lookDisabled: boolean }) => ({ - opacity: props.lookDisabled ? 0.6 : undefined, -}); - -const StyledIcon = styled(Cell.UntintedIcon)(disabledApplication, { - marginRight: '12px', -}); - -const StyledCellLabel = styled(Cell.Label)(disabledApplication, { - fontFamily: 'Open Sans', - fontWeight: 'normal', - fontSize: '16px', -}); - -const StyledIconPlaceholder = styled.div({ - width: '35px', - marginRight: '12px', -}); - -const StyledApplicationListContent = styled.div({ - display: 'flex', - flexDirection: 'column', -}); - -const StyledApplicationListAnimation = styled.div({}, (props: { height?: number }) => ({ - overflow: 'hidden', - height: props.height ? `${props.height}px` : 'auto', - transition: 'height 500ms ease-in-out', - marginBottom: '20px', -})); - -const StyledSpinnerRow = styled.div({ - display: 'flex', - justifyContent: 'center', - padding: '8px 0', - background: colors.blue40, -}); - -const StyledBrowseButton = styled(AppButton.BlueButton)({ - margin: '0 22px 22px', -}); - -export default function LinuxSplitTunnelingSettings() { - const { - getSplitTunnelingApplications, - launchExcludedApplication, - showOpenDialog, - } = useAppContext(); - const history = useHistory(); - - const [applications, setApplications] = useState<ILinuxSplitTunnelingApplication[]>(); - const [applicationListHeight, setApplicationListHeight] = useState<number>(); - const [browsing, setBrowsing] = useState(false); - const [browseError, setBrowseError] = useState<string>(); - - const applicationListRef = useRef() as React.RefObject<HTMLDivElement>; - - const launchApplication = useCallback( - async (application: ILinuxSplitTunnelingApplication | string) => { - const result = await launchExcludedApplication(application); - if ('error' in result) { - setBrowseError(result.error); - } - }, - [], - ); - - const launchWithFilePicker = useCallback(async () => { - setBrowsing(true); - const file = await showOpenDialog({ - properties: ['openFile'], - buttonLabel: messages.pgettext('split-tunneling-view', 'Launch application'), - }); - setBrowsing(false); - - if (file.filePaths[0]) { - await launchApplication(file.filePaths[0]); - } - }, []); - - const hideBrowseFailureDialog = useCallback(() => setBrowseError(undefined), []); - - useEffect(() => { - consumePromise(getSplitTunnelingApplications().then(setApplications)); - }, []); - - useLayoutEffect(() => { - const height = applicationListRef.current?.getBoundingClientRect().height; - setApplicationListHeight(height); - }, [applications]); - - return ( - <> - <StyledPageCover show={browsing} /> - <ModalContainer> - <Layout> - <StyledContainer> - <NavigationContainer> - <NavigationBar> - <NavigationItems> - <BackBarItem action={history.pop}> - { - // TRANSLATORS: Back button in navigation bar - messages.pgettext('navigation-bar', 'Advanced') - } - </BackBarItem> - <TitleBarItem> - { - // TRANSLATORS: Title label in navigation bar - messages.pgettext('split-tunneling-nav', 'Split tunneling') - } - </TitleBarItem> - </NavigationItems> - </NavigationBar> - - <StyledNavigationScrollbars> - <StyledContent> - <SettingsHeader> - <HeaderTitle> - {messages.pgettext('split-tunneling-view', 'Split tunneling')} - </HeaderTitle> - <HeaderSubTitle> - {messages.pgettext( - 'split-tunneling-view', - 'Click on an app to launch it. Its traffic will bypass the VPN tunnel until you close it.', - )} - </HeaderSubTitle> - </SettingsHeader> - - <StyledApplicationListAnimation height={applicationListHeight}> - <StyledApplicationListContent ref={applicationListRef}> - {applications === undefined ? ( - <StyledSpinnerRow> - <ImageView source="icon-spinner" height={60} width={60} /> - </StyledSpinnerRow> - ) : ( - applications.map((application) => ( - <ApplicationRow - key={application.absolutepath} - application={application} - launchApplication={launchApplication} - /> - )) - )} - </StyledApplicationListContent> - </StyledApplicationListAnimation> - - <StyledBrowseButton onClick={launchWithFilePicker}> - {messages.pgettext('split-tunneling-view', 'Browse')} - </StyledBrowseButton> - </StyledContent> - </StyledNavigationScrollbars> - </NavigationContainer> - </StyledContainer> - </Layout> - {browseError && ( - <ModalAlert - type={ModalAlertType.warning} - iconColor={colors.red} - message={sprintf( - // TRANSLATORS: Error message showed in a dialog when an application failes to launch. - messages.pgettext( - 'split-tunneling-view', - 'Unable to launch selection. %(detailedErrorMessage)s', - ), - { detailedErrorMessage: browseError }, - )} - buttons={[ - <AppButton.BlueButton key="close" onClick={hideBrowseFailureDialog}> - {messages.gettext('Close')} - </AppButton.BlueButton>, - ]} - close={hideBrowseFailureDialog} - /> - )} - </ModalContainer> - </> - ); -} - -interface IApplicationRowProps { - application: ILinuxSplitTunnelingApplication; - launchApplication: (application: ILinuxSplitTunnelingApplication) => void; -} - -function ApplicationRow(props: IApplicationRowProps) { - const [showWarning, setShowWarning] = useState(false); - - const launch = useCallback(() => { - setShowWarning(false); - props.launchApplication(props.application); - }, [props.launchApplication, props.application]); - - const showWarningDialog = useCallback(() => setShowWarning(true), []); - const hideWarningDialog = useCallback(() => setShowWarning(false), []); - - const disabled = props.application.warning === 'launches-elsewhere'; - const warningColor = disabled ? colors.red : colors.yellow; - const warningMessage = disabled - ? sprintf( - messages.pgettext( - 'split-tunneling-view', - '%(applicationName)s is problematic and can’t be excluded from the VPN tunnel.', - ), - { - applicationName: props.application.name, - }, - ) - : sprintf( - messages.pgettext( - 'split-tunneling-view', - 'If it’s already running, close %(applicationName)s before launching it from here. Otherwise it might not be excluded from the VPN tunnel.', - ), - { - applicationName: props.application.name, - }, - ); - const warningDialogButtons = disabled - ? [ - <AppButton.BlueButton key="cancel" onClick={hideWarningDialog}> - {messages.gettext('Back')} - </AppButton.BlueButton>, - ] - : [ - <AppButton.BlueButton key="launch" onClick={launch}> - {messages.pgettext('split-tunneling-view', 'Launch')} - </AppButton.BlueButton>, - <AppButton.BlueButton key="cancel" onClick={hideWarningDialog}> - {messages.gettext('Cancel')} - </AppButton.BlueButton>, - ]; - - return ( - <> - <StyledCellButton - onClick={props.application.warning ? showWarningDialog : launch} - lookDisabled={disabled}> - {props.application.icon ? ( - <StyledIcon - source={props.application.icon} - width={35} - height={35} - lookDisabled={disabled} - /> - ) : ( - <StyledIconPlaceholder /> - )} - <StyledCellLabel lookDisabled={disabled}>{props.application.name}</StyledCellLabel> - {props.application.warning && <Cell.Icon source="icon-alert" tintColor={warningColor} />} - </StyledCellButton> - {showWarning && ( - <ModalAlert - type={ModalAlertType.warning} - iconColor={warningColor} - message={warningMessage} - buttons={warningDialogButtons} - close={hideWarningDialog} - /> - )} - </> - ); -} diff --git a/gui/src/renderer/components/NotificationArea.tsx b/gui/src/renderer/components/NotificationArea.tsx index 2c2de14b35..40a553ac18 100644 --- a/gui/src/renderer/components/NotificationArea.tsx +++ b/gui/src/renderer/components/NotificationArea.tsx @@ -44,12 +44,20 @@ export default function NotificationArea(props: IProps) { : undefined, ); const wireGuardKey = useSelector((state: IReduxState) => state.settings.wireguardKeyState); + const hasExcludedApps = useSelector( + (state: IReduxState) => + state.settings.splitTunneling && state.settings.splitTunnelingApplications.length > 0, + ); const notificationProviders: InAppNotificationProvider[] = [ new ConnectingNotificationProvider({ tunnelState }), new ReconnectingNotificationProvider(tunnelState), - new BlockWhenDisconnectedNotificationProvider({ tunnelState, blockWhenDisconnected }), - new ErrorNotificationProvider({ tunnelState, accountExpiry }), + new BlockWhenDisconnectedNotificationProvider({ + tunnelState, + blockWhenDisconnected, + hasExcludedApps, + }), + new ErrorNotificationProvider({ tunnelState, accountExpiry, hasExcludedApps }), new NoValidKeyNotificationProvider({ tunnelProtocol, wireGuardKey }), new InconsistentVersionNotificationProvider({ consistent: version.consistent }), new UnsupportedVersionNotificationProvider(version), diff --git a/gui/src/renderer/components/SplitTunnelingSettings.tsx b/gui/src/renderer/components/SplitTunnelingSettings.tsx new file mode 100644 index 0000000000..d6f2bbbdcf --- /dev/null +++ b/gui/src/renderer/components/SplitTunnelingSettings.tsx @@ -0,0 +1,597 @@ +import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; +import { useSelector } from 'react-redux'; +import { sprintf } from 'sprintf-js'; +import { colors } from '../../config.json'; +import { messages } from '../../shared/gettext'; +import { IApplication, ILinuxSplitTunnelingApplication } from '../../shared/application-types'; +import consumePromise from '../../shared/promise'; +import { useAppContext } from '../context'; +import { useHistory } from '../lib/history'; +import { useAsyncEffect } from '../lib/utilityHooks'; +import { IReduxState } from '../redux/store'; +import Accordion from './Accordion'; +import * as AppButton from './AppButton'; +import * as Cell from './cell'; +import CustomScrollbars from './CustomScrollbars'; +import ImageView from './ImageView'; +import { Layout } from './Layout'; +import { ModalContainer, ModalAlert, ModalAlertType } from './Modal'; +import { + BackBarItem, + NavigationBar, + NavigationContainer, + NavigationItems, + TitleBarItem, +} from './NavigationBar'; +import SettingsHeader, { HeaderSubTitle, HeaderTitle } from './SettingsHeader'; +import { + StyledPageCover, + StyledContainer, + StyledNavigationScrollbars, + StyledContent, + StyledCellButton, + StyledIcon, + StyledCellLabel, + StyledIconPlaceholder, + StyledApplicationListContent, + StyledApplicationListAnimation, + StyledSpinnerRow, + StyledBrowseButton, + StyledSearchInput, + StyledClearButton, + StyledSearchIcon, + StyledClearIcon, + StyledNoResultText, + StyledSearchContainer, + StyledNoResult, + StyledNoResultSearchTerm, + StyledDisabledWarning, + StyledBetaLabel, +} from './SplitTunnelingSettingsStyles'; + +export default function SplitTunneling() { + const { pop } = useHistory(); + const [browsing, setBrowsing] = useState(false); + const scrollbarsRef = useRef() as React.RefObject<CustomScrollbars>; + + const scrollToTop = useCallback(() => scrollbarsRef.current?.scrollToTop(true), [scrollbarsRef]); + + return ( + <> + <StyledPageCover show={browsing} /> + <ModalContainer> + <Layout> + <StyledContainer> + <NavigationContainer> + <NavigationBar> + <NavigationItems> + <BackBarItem action={pop}> + { + // TRANSLATORS: Back button in navigation bar + messages.pgettext('navigation-bar', 'Advanced') + } + </BackBarItem> + <TitleBarItem> + { + // TRANSLATORS: Title label in navigation bar + messages.pgettext('split-tunneling-nav', 'Split tunneling') + } + </TitleBarItem> + </NavigationItems> + </NavigationBar> + + <StyledNavigationScrollbars ref={scrollbarsRef}> + <StyledContent> + <PlatformSpecificSplitTunnelingSettings + setBrowsing={setBrowsing} + scrollToTop={scrollToTop} + /> + </StyledContent> + </StyledNavigationScrollbars> + </NavigationContainer> + </StyledContainer> + </Layout> + </ModalContainer> + </> + ); +} + +interface IPlatformSplitTunnelingSettingsProps { + setBrowsing: (value: boolean) => void; + scrollToTop: () => void; +} + +function PlatformSpecificSplitTunnelingSettings(props: IPlatformSplitTunnelingSettingsProps) { + switch (window.platform) { + case 'linux': + return <LinuxSplitTunnelingSettings {...props} />; + case 'win32': + return <WindowsSplitTunnelingSettings {...props} />; + default: + throw new Error(`Split tunneling not implemented on ${window.platform}`); + } +} + +function useFilePicker( + buttonLabel: string, + setOpen: (value: boolean) => void, + select: (path: string) => void, + filter?: { name: string; extensions: string[] }, +) { + const { showOpenDialog } = useAppContext(); + + return useCallback(async () => { + setOpen(true); + const file = await showOpenDialog({ + properties: ['openFile'], + buttonLabel, + filters: filter ? [filter] : undefined, + }); + setOpen(false); + + if (file.filePaths[0]) { + select(file.filePaths[0]); + } + }, [buttonLabel, setOpen, select]); +} + +function LinuxSplitTunnelingSettings(props: IPlatformSplitTunnelingSettingsProps) { + const { getLinuxSplitTunnelingApplications, launchExcludedApplication } = useAppContext(); + + const [searchTerm, setSearchTerm] = useState(''); + const [applications, setApplications] = useState<ILinuxSplitTunnelingApplication[]>(); + const [browseError, setBrowseError] = useState<string>(); + + useEffect(() => consumePromise(getLinuxSplitTunnelingApplications().then(setApplications)), []); + + const launchApplication = useCallback( + async (application: ILinuxSplitTunnelingApplication | string) => { + const result = await launchExcludedApplication(application); + if ('error' in result) { + setBrowseError(result.error); + } + }, + [launchExcludedApplication], + ); + + const launchWithFilePicker = useFilePicker( + messages.pgettext('split-tunneling-view', 'Launch'), + props.setBrowsing, + launchApplication, + ); + + const filteredApplications = useMemo( + () => applications?.filter((application) => includesSearchTerm(application, searchTerm)), + [applications, searchTerm], + ); + + const hideBrowseFailureDialog = useCallback(() => setBrowseError(undefined), []); + + return ( + <> + <SettingsHeader> + <HeaderTitle>{messages.pgettext('split-tunneling-view', 'Split tunneling')}</HeaderTitle> + <HeaderSubTitle> + {messages.pgettext( + 'split-tunneling-view', + 'Click on an app to launch it. Its traffic will bypass the VPN tunnel until you close it.', + )} + </HeaderSubTitle> + </SettingsHeader> + + <SearchBar searchTerm={searchTerm} onSearch={setSearchTerm} /> + <ApplicationList + applications={filteredApplications} + onSelect={launchApplication} + rowComponent={LinuxApplicationRow} + /> + + <StyledBrowseButton onClick={launchWithFilePicker}> + {messages.pgettext('split-tunneling-view', 'Find another app')} + </StyledBrowseButton> + + {browseError && ( + <ModalAlert + type={ModalAlertType.warning} + iconColor={colors.red} + message={sprintf( + // TRANSLATORS: Error message showed in a dialog when an application failes to launch. + messages.pgettext( + 'split-tunneling-view', + 'Unable to launch selection. %(detailedErrorMessage)s', + ), + { detailedErrorMessage: browseError }, + )} + buttons={[ + <AppButton.BlueButton key="close" onClick={hideBrowseFailureDialog}> + {messages.gettext('Close')} + </AppButton.BlueButton>, + ]} + close={hideBrowseFailureDialog} + /> + )} + </> + ); +} + +interface ILinuxApplicationRowProps { + application: ILinuxSplitTunnelingApplication; + onSelect?: (application: ILinuxSplitTunnelingApplication) => void; +} + +function LinuxApplicationRow(props: ILinuxApplicationRowProps) { + const [showWarning, setShowWarning] = useState(false); + + const launch = useCallback(() => { + setShowWarning(false); + props.onSelect?.(props.application); + }, [props.onSelect, props.application]); + + const showWarningDialog = useCallback(() => setShowWarning(true), []); + const hideWarningDialog = useCallback(() => setShowWarning(false), []); + + const disabled = props.application.warning === 'launches-elsewhere'; + const warningColor = disabled ? colors.red : colors.yellow; + const warningMessage = disabled + ? sprintf( + messages.pgettext( + 'split-tunneling-view', + '%(applicationName)s is problematic and can’t be excluded from the VPN tunnel.', + ), + { + applicationName: props.application.name, + }, + ) + : sprintf( + messages.pgettext( + 'split-tunneling-view', + 'If it’s already running, close %(applicationName)s before launching it from here. Otherwise it might not be excluded from the VPN tunnel.', + ), + { + applicationName: props.application.name, + }, + ); + const warningDialogButtons = disabled + ? [ + <AppButton.BlueButton key="cancel" onClick={hideWarningDialog}> + {messages.gettext('Back')} + </AppButton.BlueButton>, + ] + : [ + <AppButton.BlueButton key="launch" onClick={launch}> + {messages.pgettext('split-tunneling-view', 'Launch')} + </AppButton.BlueButton>, + <AppButton.BlueButton key="cancel" onClick={hideWarningDialog}> + {messages.gettext('Cancel')} + </AppButton.BlueButton>, + ]; + + return ( + <> + <StyledCellButton + onClick={props.application.warning ? showWarningDialog : launch} + lookDisabled={disabled}> + {props.application.icon ? ( + <StyledIcon + source={props.application.icon} + width={35} + height={35} + lookDisabled={disabled} + /> + ) : ( + <StyledIconPlaceholder /> + )} + <StyledCellLabel lookDisabled={disabled}>{props.application.name}</StyledCellLabel> + {props.application.warning && <Cell.Icon source="icon-alert" tintColor={warningColor} />} + </StyledCellButton> + {showWarning && ( + <ModalAlert + type={ModalAlertType.warning} + iconColor={warningColor} + message={warningMessage} + buttons={warningDialogButtons} + close={hideWarningDialog} + /> + )} + </> + ); +} + +export function WindowsSplitTunnelingSettings(props: IPlatformSplitTunnelingSettingsProps) { + const { + addSplitTunnelingApplication, + removeSplitTunnelingApplication, + getWindowsSplitTunnelingApplications, + setSplitTunnelingState, + } = useAppContext(); + const splitTunnelingEnabled = useSelector((state: IReduxState) => state.settings.splitTunneling); + const splitTunnelingApplications = useSelector( + (state: IReduxState) => state.settings.splitTunnelingApplications, + ); + + const [searchTerm, setSearchTerm] = useState(''); + const [applications, setApplications] = useState<IApplication[]>(); + useAsyncEffect(async () => { + const { fromCache, applications } = await getWindowsSplitTunnelingApplications(); + setApplications(applications); + + if (fromCache) { + const { applications } = await getWindowsSplitTunnelingApplications(true); + setApplications(applications); + } + }, []); + + const filteredSplitApplications = useMemo( + () => + splitTunnelingApplications.filter((application) => + includesSearchTerm(application, searchTerm), + ), + [splitTunnelingApplications, searchTerm], + ); + + const filteredNonSplitApplications = useMemo(() => { + return applications?.filter( + (application) => + includesSearchTerm(application, searchTerm) && + !splitTunnelingApplications.some( + (splitTunnelingApplication) => + application.absolutepath === splitTunnelingApplication.absolutepath, + ), + ); + }, [applications, splitTunnelingApplications, searchTerm]); + + const addApplication = useCallback( + async (application: IApplication | string) => { + if (!splitTunnelingEnabled) { + await setSplitTunnelingState(true); + } + await addSplitTunnelingApplication(application); + }, + [addSplitTunnelingApplication, splitTunnelingEnabled, setSplitTunnelingState], + ); + + const addApplicationAndUpdate = useCallback( + async (application: IApplication | string) => { + await addApplication(application); + const { applications } = await getWindowsSplitTunnelingApplications(); + setApplications(applications); + }, + [addApplication, getWindowsSplitTunnelingApplications], + ); + + const removeApplication = useCallback( + async (application: IApplication) => { + if (!splitTunnelingEnabled) { + await setSplitTunnelingState(true); + } + removeSplitTunnelingApplication(application); + }, + [removeSplitTunnelingApplication, splitTunnelingEnabled], + ); + + const filePickerCallback = useFilePicker( + messages.pgettext('split-tunneling-view', 'Add'), + props.setBrowsing, + addApplicationAndUpdate, + { name: 'Executables', extensions: ['exe', 'lnk'] }, + ); + + const addWithFilePicker = useCallback(async () => { + props.scrollToTop(); + await filePickerCallback(); + }, [filePickerCallback, props.scrollToTop]); + + const showSplitSection = filteredSplitApplications.length > 0; + const showNonSplitSection = + !filteredNonSplitApplications || filteredNonSplitApplications.length > 0; + + const noResultTextParts = messages + .pgettext('split-tunneling-view', 'No result for %(searchTerm)s.') + .split('%(searchTerm)s', 2); + const noResult = ( + <> + <span>{noResultTextParts[0]}</span> + <StyledNoResultSearchTerm>{searchTerm}</StyledNoResultSearchTerm> + <span>{noResultTextParts[1]}</span> + </> + ); + + return ( + <> + <SettingsHeader> + <HeaderTitle> + {messages.pgettext('split-tunneling-view', 'Split tunneling')} + <StyledBetaLabel /> + </HeaderTitle> + <HeaderSubTitle> + {messages.pgettext( + 'split-tunneling-view', + 'Choose the apps you want to exclude from the VPN tunnel.', + )} + </HeaderSubTitle> + </SettingsHeader> + + {!splitTunnelingEnabled && filteredSplitApplications?.length > 0 && ( + <StyledDisabledWarning> + {messages.pgettext( + 'split-tunneling-view', + 'Split tunneling has been disabled from the CLI and will automatically be enabled when adding or removing applications from the lists below.', + )} + </StyledDisabledWarning> + )} + + <SearchBar searchTerm={searchTerm} onSearch={setSearchTerm} /> + + {(showSplitSection || showNonSplitSection) && ( + <> + <Accordion expanded={showSplitSection}> + <Cell.Section> + <Cell.SectionTitle> + {messages.pgettext('split-tunneling-view', 'Excluded apps')} + </Cell.SectionTitle> + <ApplicationList + applications={filteredSplitApplications} + onRemove={removeApplication} + rowComponent={ApplicationRow} + /> + </Cell.Section> + </Accordion> + + <Accordion expanded={showNonSplitSection}> + <Cell.Section> + <Cell.SectionTitle> + {messages.pgettext('split-tunneling-view', 'All apps')} + </Cell.SectionTitle> + <ApplicationList + applications={filteredNonSplitApplications} + onSelect={addApplication} + rowComponent={ApplicationRow} + /> + </Cell.Section> + </Accordion> + </> + )} + + {searchTerm !== '' && !showSplitSection && !showNonSplitSection && ( + <StyledNoResult> + <StyledNoResultText>{noResult}</StyledNoResultText> + <StyledNoResultText> + {messages.pgettext('split-tunneling-view', 'Try a different search.')} + </StyledNoResultText> + </StyledNoResult> + )} + + <StyledBrowseButton onClick={addWithFilePicker}> + {messages.pgettext('split-tunneling-view', 'Find another app')} + </StyledBrowseButton> + </> + ); +} + +interface IApplicationListProps<T extends IApplication> { + applications: T[] | undefined; + onSelect?: (application: T) => void; + onRemove?: (application: T) => void; + rowComponent: React.ComponentType<IApplicationRowProps<T>>; +} + +function ApplicationList<T extends IApplication>(props: IApplicationListProps<T>) { + const [applicationListHeight, setApplicationListHeight] = useState<number>(); + const applicationListRef = useRef() as React.RefObject<HTMLDivElement>; + + useLayoutEffect(() => { + const height = applicationListRef.current?.getBoundingClientRect().height; + setApplicationListHeight(height); + }, [applicationListRef, props.applications]); + + return ( + <StyledApplicationListAnimation height={applicationListHeight}> + <StyledApplicationListContent ref={applicationListRef}> + {props.applications === undefined ? ( + <StyledSpinnerRow> + <ImageView source="icon-spinner" height={60} width={60} /> + </StyledSpinnerRow> + ) : ( + props.applications.map((application) => ( + <props.rowComponent + key={application.absolutepath} + application={application} + onSelect={props.onSelect} + onRemove={props.onRemove} + /> + )) + )} + </StyledApplicationListContent> + </StyledApplicationListAnimation> + ); +} + +interface IApplicationRowProps<T extends IApplication> { + application: T; + onSelect?: (application: T) => void; + onRemove?: (application: T) => void; +} + +function ApplicationRow<T extends IApplication>(props: IApplicationRowProps<T>) { + const onSelect = useCallback(() => { + props.onSelect?.(props.application); + }, [props.onSelect, props.application]); + + const onRemove = useCallback(() => { + props.onRemove?.(props.application); + }, [props.onRemove, props.application]); + + return ( + <Cell.CellButton> + {props.application.icon ? ( + <StyledIcon source={props.application.icon} width={35} height={35} /> + ) : ( + <StyledIconPlaceholder /> + )} + <StyledCellLabel>{props.application.name}</StyledCellLabel> + {props.onSelect && ( + <ImageView + source="icon-add" + width={24} + height={24} + onClick={onSelect} + tintColor={colors.white60} + tintHoverColor={colors.white80} + /> + )} + {props.onRemove && ( + <ImageView + source="icon-remove" + width={24} + height={24} + onClick={onRemove} + tintColor={colors.white60} + tintHoverColor={colors.white80} + /> + )} + </Cell.CellButton> + ); +} + +interface ISearchBarProps { + searchTerm: string; + onSearch: (searchTerm: string) => void; +} + +function SearchBar(props: ISearchBarProps) { + const inputRef = useRef() as React.RefObject<HTMLInputElement>; + + const onInput = useCallback( + (event: React.FormEvent) => { + const element = event.target as HTMLInputElement; + props.onSearch(element.value); + }, + [props.onSearch], + ); + + const onClear = useCallback(() => { + props.onSearch(''); + inputRef.current?.blur(); + }, [props.onSearch]); + + return ( + <StyledSearchContainer> + <StyledSearchInput + ref={inputRef} + value={props.searchTerm} + onInput={onInput} + placeholder={messages.pgettext('split-tunneling-view', 'Filter...')} + /> + <StyledSearchIcon source="icon-filter" width={24} tintColor={colors.white60} /> + {props.searchTerm.length > 0 && ( + <StyledClearButton onClick={onClear}> + <StyledClearIcon source="icon-close-sml" width={16} tintColor={colors.white40} /> + </StyledClearButton> + )} + </StyledSearchContainer> + ); +} + +function includesSearchTerm(application: IApplication, searchTerm: string) { + return application.name.toLowerCase().includes(searchTerm.toLowerCase()); +} diff --git a/gui/src/renderer/components/SplitTunnelingSettingsStyles.tsx b/gui/src/renderer/components/SplitTunnelingSettingsStyles.tsx new file mode 100644 index 0000000000..45079090f9 --- /dev/null +++ b/gui/src/renderer/components/SplitTunnelingSettingsStyles.tsx @@ -0,0 +1,170 @@ +import styled from 'styled-components'; +import { colors } from '../../config.json'; +import * as AppButton from './AppButton'; +import BetaLabel from './BetaLabel'; +import * as Cell from './cell'; +import { mediumText, smallText } from './common-styles'; +import ImageView from './ImageView'; +import { Container } from './Layout'; +import { NavigationScrollbars } from './NavigationBar'; + +export const StyledPageCover = styled.div({}, (props: { show: boolean }) => ({ + position: 'absolute', + zIndex: 2, + top: 0, + left: 0, + right: 0, + bottom: 0, + backgroundColor: colors.black, + opacity: 0.5, + display: props.show ? 'block' : 'none', +})); + +export const StyledContainer = styled(Container)({ + backgroundColor: colors.darkBlue, +}); + +export const StyledNavigationScrollbars = styled(NavigationScrollbars)({ + flex: 1, +}); + +export const StyledContent = styled.div({ + display: 'flex', + flexDirection: 'column', + flex: 1, +}); + +export const StyledCellButton = styled(Cell.CellButton)((props: { lookDisabled?: boolean }) => ({ + ':not(:disabled):hover': { + backgroundColor: props.lookDisabled ? colors.blue : undefined, + }, +})); + +const disabledApplication = (props: { lookDisabled?: boolean }) => ({ + opacity: props.lookDisabled ? 0.6 : undefined, +}); + +export const StyledIcon = styled(Cell.UntintedIcon)(disabledApplication, { + marginRight: '12px', +}); + +export const StyledCellLabel = styled(Cell.Label)(disabledApplication, { + fontFamily: 'Open Sans', + fontWeight: 'normal', + fontSize: '16px', +}); + +export const StyledIconPlaceholder = styled.div({ + width: '35px', + marginRight: '12px', +}); + +export const StyledApplicationListContent = styled.div({ + display: 'flex', + flexDirection: 'column', +}); + +export const StyledApplicationListAnimation = styled.div({}, (props: { height?: number }) => ({ + overflow: 'hidden', + height: props.height ? `${props.height}px` : 'auto', + transition: 'height 500ms ease-in-out', + marginBottom: '20px', +})); + +export const StyledSpinnerRow = styled.div({ + display: 'flex', + justifyContent: 'center', + padding: '8px 0', + background: colors.blue40, +}); + +export const StyledBrowseButton = styled(AppButton.BlueButton)({ + margin: '0 22px 22px', +}); + +export const StyledCellContainer = styled(Cell.Container)({ + marginBottom: '20px', +}); + +export const StyledSearchContainer = styled.div({ + position: 'relative', + marginBottom: '18px', +}); + +export const StyledSearchInput = styled.input.attrs({ type: 'text' })({ + ...mediumText, + width: 'calc(100% - 22px * 2)', + border: 'none', + borderRadius: '4px', + padding: '9px 38px', + margin: '0 22px', + color: colors.white60, + backgroundColor: colors.white10, + '::placeholder': { + color: colors.white60, + }, + ':focus': { + color: colors.blue, + backgroundColor: colors.white, + '::placeholder': { + color: colors.blue40, + }, + }, +}); + +export const StyledClearButton = styled.button({ + position: 'absolute', + top: '50%', + transform: 'translateY(-50%)', + right: '28px', + border: 'none', + background: 'none', + padding: 0, +}); + +export const StyledSearchIcon = styled(ImageView)({ + position: 'absolute', + top: '50%', + transform: 'translateY(-50%)', + left: '28px', + [`${StyledSearchInput}:focus ~ &`]: { + backgroundColor: colors.blue, + }, +}); + +export const StyledClearIcon = styled(ImageView)({ + ':hover': { + backgroundColor: colors.white60, + }, + [`${StyledSearchInput}:focus ~ ${StyledClearButton} &`]: { + backgroundColor: colors.blue40, + ':hover': { + backgroundColor: colors.blue, + }, + }, +}); + +export const StyledNoResult = styled(Cell.Footer)({ + display: 'flex', + flexDirection: 'column', + paddingTop: 0, + marginTop: 0, +}); + +export const StyledNoResultText = styled(Cell.FooterText)({ + textAlign: 'center', +}); + +export const StyledNoResultSearchTerm = styled.span({ + fontWeight: 'bold', +}); + +export const StyledDisabledWarning = styled.span(smallText, { + margin: '0 22px 18px', + color: colors.red, +}); + +export const StyledBetaLabel = styled(BetaLabel)({ + marginLeft: '8px', + verticalAlign: 'middle', +}); diff --git a/gui/src/renderer/containers/AdvancedSettingsPage.tsx b/gui/src/renderer/containers/AdvancedSettingsPage.tsx index a9e603718b..36a1a77963 100644 --- a/gui/src/renderer/containers/AdvancedSettingsPage.tsx +++ b/gui/src/renderer/containers/AdvancedSettingsPage.tsx @@ -164,7 +164,7 @@ const mapDispatchToProps = (_dispatch: ReduxDispatch, props: IHistoryProps & IAp }, onViewWireguardKeys: () => props.history.push('/settings/advanced/wireguard-keys'), - onViewLinuxSplitTunneling: () => props.history.push('/settings/advanced/linux-split-tunneling'), + onViewSplitTunneling: () => props.history.push('/settings/advanced/split-tunneling'), }; }; diff --git a/gui/src/renderer/lib/utilityHooks.ts b/gui/src/renderer/lib/utilityHooks.ts index ee3f191593..9e8cea0bad 100644 --- a/gui/src/renderer/lib/utilityHooks.ts +++ b/gui/src/renderer/lib/utilityHooks.ts @@ -1,4 +1,5 @@ import React, { useCallback, useEffect, useRef } from 'react'; +import consumePromise from '../../shared/promise'; export function useMounted() { const mountedRef = useRef(false); @@ -25,3 +26,23 @@ export function assignToRef<T>(element: T | null, ref?: React.Ref<T>) { (ref as React.MutableRefObject<T>).current = element; } } + +export function useAsyncEffect( + effect: () => Promise<void | (() => void | Promise<void>)>, + dependencies: unknown[], +): void { + const isMounted = useMounted(); + + useEffect(() => { + const promise = effect(); + return () => { + consumePromise( + promise.then((destructor) => { + if (isMounted() && destructor) { + return destructor(); + } + }), + ); + }; + }, dependencies); +} diff --git a/gui/src/renderer/redux/settings/actions.ts b/gui/src/renderer/redux/settings/actions.ts index 6428badde8..1b1e48265c 100644 --- a/gui/src/renderer/redux/settings/actions.ts +++ b/gui/src/renderer/redux/settings/actions.ts @@ -5,6 +5,7 @@ import { KeygenEvent, } from '../../../shared/daemon-rpc-types'; import { IGuiSettingsState } from '../../../shared/gui-settings-state'; +import { IApplication } from '../../../shared/application-types'; import { BridgeSettingsRedux, IRelayLocationRedux, IWgKey, RelaySettingsRedux } from './reducers'; export interface IUpdateGuiSettingsAction { @@ -107,6 +108,16 @@ export interface IUpdateDnsOptionsAction { dns: IDnsOptions; } +export interface IUpdateSplitTunnelingStateAction { + type: 'UPDATE_SPLIT_TUNNELING_STATE'; + enabled: boolean; +} + +export interface ISetSplitTunnelingApplicationsAction { + type: 'SET_SPLIT_TUNNELING_APPLICATIONS'; + applications: IApplication[]; +} + export type SettingsAction = | IUpdateGuiSettingsAction | IUpdateRelayAction @@ -127,7 +138,9 @@ export type SettingsAction = | IWireguardReplaceKey | IWireguardKeygenEvent | IWireguardKeyVerifiedAction - | IUpdateDnsOptionsAction; + | IUpdateDnsOptionsAction + | IUpdateSplitTunnelingStateAction + | ISetSplitTunnelingApplicationsAction; function updateGuiSettings(guiSettings: IGuiSettingsState): IUpdateGuiSettingsAction { return { @@ -279,6 +292,22 @@ function updateDnsOptions(dns: IDnsOptions): IUpdateDnsOptionsAction { }; } +function updateSplitTunnelingState(enabled: boolean): IUpdateSplitTunnelingStateAction { + return { + type: 'UPDATE_SPLIT_TUNNELING_STATE', + enabled, + }; +} + +function setSplitTunnelingApplications( + applications: IApplication[], +): ISetSplitTunnelingApplicationsAction { + return { + type: 'SET_SPLIT_TUNNELING_APPLICATIONS', + applications, + }; +} + export default { updateGuiSettings, updateRelay, @@ -300,4 +329,6 @@ export default { verifyWireguardKey, completeWireguardKeyVerification, updateDnsOptions, + updateSplitTunnelingState, + setSplitTunnelingApplications, }; diff --git a/gui/src/renderer/redux/settings/reducers.ts b/gui/src/renderer/redux/settings/reducers.ts index be4287c90f..1b07e805fa 100644 --- a/gui/src/renderer/redux/settings/reducers.ts +++ b/gui/src/renderer/redux/settings/reducers.ts @@ -1,3 +1,4 @@ +import { IApplication } from '../../../shared/application-types'; import { BridgeState, KeygenEvent, @@ -136,6 +137,8 @@ export interface ISettingsReduxState { }; dns: IDnsOptions; wireguardKeyState: WgKeyState; + splitTunneling: boolean; + splitTunnelingApplications: IApplication[]; } const initialState: ISettingsReduxState = { @@ -187,6 +190,8 @@ const initialState: ISettingsReduxState = { addresses: [], }, }, + splitTunneling: false, + splitTunnelingApplications: [], }; export default function ( @@ -320,6 +325,18 @@ export default function ( dns: action.dns, }; + case 'UPDATE_SPLIT_TUNNELING_STATE': + return { + ...state, + splitTunneling: action.enabled, + }; + + case 'SET_SPLIT_TUNNELING_APPLICATIONS': + return { + ...state, + splitTunnelingApplications: action.applications, + }; + default: return state; } diff --git a/gui/src/renderer/routes.tsx b/gui/src/renderer/routes.tsx index 68605821d4..1f12e7386e 100644 --- a/gui/src/renderer/routes.tsx +++ b/gui/src/renderer/routes.tsx @@ -5,7 +5,7 @@ import Launch from './components/Launch'; import KeyboardNavigation from './components/KeyboardNavigation'; import MainView from './components/MainView'; import Focus, { IFocusHandle } from './components/Focus'; -import LinuxSplitTunnelingSettings from './components/LinuxSplitTunnelingSettings'; +import SplitTunnelingSettings from './components/SplitTunnelingSettings'; import TransitionContainer, { TransitionView } from './components/TransitionContainer'; import AccountPage from './containers/AccountPage'; import AdvancedSettingsPage from './containers/AdvancedSettingsPage'; @@ -96,8 +96,8 @@ class AppRoutes extends React.Component<IHistoryProps, IAppRoutesState> { /> <Route exact={true} - path="/settings/advanced/linux-split-tunneling" - component={LinuxSplitTunnelingSettings} + path="/settings/advanced/split-tunneling" + component={SplitTunnelingSettings} /> <Route exact={true} path="/settings/support" component={SupportPage} /> <Route exact={true} path="/select-location" component={SelectLocationPage} /> diff --git a/gui/src/shared/daemon-rpc-types.ts b/gui/src/shared/daemon-rpc-types.ts index b0319369cf..4c806b11b8 100644 --- a/gui/src/shared/daemon-rpc-types.ts +++ b/gui/src/shared/daemon-rpc-types.ts @@ -34,7 +34,12 @@ export type TunnelParameterError = export type ErrorStateCause = | { - reason: 'ipv6_unavailable' | 'set_dns_error' | 'start_tunnel_error' | 'is_offline'; + reason: + | 'ipv6_unavailable' + | 'set_dns_error' + | 'start_tunnel_error' + | 'is_offline' + | 'split_tunnel_error'; } | { reason: 'set_firewall_policy_error'; details: FirewallPolicyError } | { reason: 'tunnel_parameter_error'; details: TunnelParameterError } @@ -311,6 +316,7 @@ export interface ISettings { tunnelOptions: ITunnelOptions; bridgeSettings: BridgeSettings; bridgeState: BridgeState; + splitTunnel: SplitTunnelSettings; } export type KeygenEvent = INewWireguardKey | KeygenFailure; @@ -327,6 +333,11 @@ export interface IWireguardPublicKey { export type BridgeState = 'auto' | 'on' | 'off'; +export type SplitTunnelSettings = { + enableExclusions: boolean; + appsList: string[]; +}; + export interface IBridgeConstraints { location: Constraint<RelayLocation>; } diff --git a/gui/src/shared/ipc-schema.ts b/gui/src/shared/ipc-schema.ts index 01c5c53a95..5789bcc149 100644 --- a/gui/src/shared/ipc-schema.ts +++ b/gui/src/shared/ipc-schema.ts @@ -1,5 +1,5 @@ import { GetTextTranslations } from 'gettext-parser'; -import { ILinuxSplitTunnelingApplication } from './application-types'; +import { IApplication, ILinuxSplitTunnelingApplication } from './application-types'; import { AccountToken, BridgeSettings, @@ -56,6 +56,7 @@ export interface IAppStateSnapshot { translations: ITranslations; platform: NodeJS.Platform; runningInDevelopment: boolean; + windowsSplitTunnelingApplications?: IApplication[]; } // The different types of requests are: @@ -178,10 +179,6 @@ export const ipcSchema = { generateKey: invoke<void, KeygenEvent>(), verifyKey: invoke<void, boolean>(), }, - splitTunneling: { - getApplications: invoke<void, ILinuxSplitTunnelingApplication[]>(), - launchApplication: invoke<ILinuxSplitTunnelingApplication | string, LaunchApplicationResult>(), - }, problemReport: { collectLogs: invoke<string | undefined, string>(), sendReport: invoke<{ email: string; message: string; savedReportId: string }, void>(), @@ -190,4 +187,15 @@ export const ipcSchema = { logging: { log: send<ILogEntry>(), }, + linuxSplitTunneling: { + getApplications: invoke<void, ILinuxSplitTunnelingApplication[]>(), + launchApplication: invoke<ILinuxSplitTunnelingApplication | string, LaunchApplicationResult>(), + }, + windowsSplitTunneling: { + '': notifyRenderer<IApplication[]>(), + setState: invoke<boolean, void>(), + getApplications: invoke<boolean, { fromCache: boolean; applications: IApplication[] }>(), + addApplication: invoke<IApplication | string, void>(), + removeApplication: invoke<IApplication | string, void>(), + }, }; diff --git a/gui/src/shared/notifications/block-when-disconnected.ts b/gui/src/shared/notifications/block-when-disconnected.ts index fb19655993..6dcc3cc749 100644 --- a/gui/src/shared/notifications/block-when-disconnected.ts +++ b/gui/src/shared/notifications/block-when-disconnected.ts @@ -5,6 +5,7 @@ import { InAppNotification, InAppNotificationProvider } from './notification'; interface BlockWhenDisconnectedNotificationContext { tunnelState: TunnelState; blockWhenDisconnected: boolean; + hasExcludedApps: boolean; } export class BlockWhenDisconnectedNotificationProvider implements InAppNotificationProvider { @@ -19,10 +20,18 @@ export class BlockWhenDisconnectedNotificationProvider implements InAppNotificat } public getInAppNotification(): InAppNotification { + let subtitle = messages.pgettext('in-app-notifications', '"Always require VPN" is enabled.'); + if (this.context.hasExcludedApps) { + subtitle = `${subtitle} ${messages.pgettext( + 'notifications', + 'The apps excluded with split tunneling might not work properly right now.', + )}`; + } + return { indicator: 'warning', title: messages.pgettext('in-app-notifications', 'BLOCKING INTERNET'), - subtitle: messages.pgettext('in-app-notifications', '"Always require VPN" is enabled.'), + subtitle, }; } } diff --git a/gui/src/shared/notifications/error.ts b/gui/src/shared/notifications/error.ts index c5f15482dc..97e5e2ce93 100644 --- a/gui/src/shared/notifications/error.ts +++ b/gui/src/shared/notifications/error.ts @@ -11,6 +11,7 @@ import { interface ErrorNotificationContext { tunnelState: TunnelState; accountExpiry?: string; + hasExcludedApps: boolean; } export class ErrorNotificationProvider @@ -20,25 +21,45 @@ export class ErrorNotificationProvider public mayDisplay = () => this.context.tunnelState.state === 'error'; public getSystemNotification() { - return this.context.tunnelState.state === 'error' - ? { - message: getMessage(this.context.tunnelState.details, this.context.accountExpiry), - critical: !!this.context.tunnelState.details.blockFailure, - } - : undefined; + if (this.context.tunnelState.state === 'error') { + let message = getMessage(this.context.tunnelState.details, this.context.accountExpiry); + if (!this.context.tunnelState.details.blockFailure && this.context.hasExcludedApps) { + message = `${message} ${messages.pgettext( + 'notifications', + 'The apps excluded with split tunneling might not work properly right now.', + )}`; + } + + return { + message, + critical: !!this.context.tunnelState.details.blockFailure, + }; + } else { + return undefined; + } } public getInAppNotification(): InAppNotification | undefined { - return this.context.tunnelState.state === 'error' - ? { - indicator: - this.context.tunnelState.details.cause.reason === 'is_offline' ? 'warning' : 'error', - title: !this.context.tunnelState.details.blockFailure - ? messages.pgettext('in-app-notifications', 'BLOCKING INTERNET') - : messages.pgettext('in-app-notifications', 'NETWORK TRAFFIC MIGHT BE LEAKING'), - subtitle: getMessage(this.context.tunnelState.details, this.context.accountExpiry), - } - : undefined; + if (this.context.tunnelState.state === 'error') { + let subtitle = getMessage(this.context.tunnelState.details, this.context.accountExpiry); + if (!this.context.tunnelState.details.blockFailure && this.context.hasExcludedApps) { + subtitle = `${subtitle} ${messages.pgettext( + 'notifications', + 'The apps excluded with split tunneling might not work properly right now.', + )}`; + } + + return { + indicator: + this.context.tunnelState.details.cause.reason === 'is_offline' ? 'warning' : 'error', + title: !this.context.tunnelState.details.blockFailure + ? messages.pgettext('in-app-notifications', 'BLOCKING INTERNET') + : messages.pgettext('in-app-notifications', 'NETWORK TRAFFIC MIGHT BE LEAKING'), + subtitle, + }; + } else { + return undefined; + } } } @@ -117,6 +138,11 @@ function getMessage(errorDetails: IErrorState, accountExpiry?: string): string { 'notifications', "Your device is offline. Try connecting when it's back online.", ); + case 'split_tunnel_error': + return messages.pgettext( + 'notifications', + 'Unable to communicate with Mullvad kernel driver. Try reconnecting or contact support.', + ); } } } |
