summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--gui/assets/images/icon-filter.svg4
-rw-r--r--gui/assets/images/icon-remove.svg23
-rw-r--r--gui/locales/messages.pot43
-rw-r--r--gui/src/config.json2
-rw-r--r--gui/src/main/daemon-rpc.ts16
-rw-r--r--gui/src/main/gui-settings.ts2
-rw-r--r--gui/src/main/index.ts105
-rw-r--r--gui/src/main/notification-controller.ts3
-rw-r--r--gui/src/main/windows-split-tunneling.ts43
-rw-r--r--gui/src/renderer/app.tsx37
-rw-r--r--gui/src/renderer/components/AdvancedSettings.tsx10
-rw-r--r--gui/src/renderer/components/AdvancedSettingsStyles.tsx8
-rw-r--r--gui/src/renderer/components/BetaLabel.tsx26
-rw-r--r--gui/src/renderer/components/CustomScrollbars.tsx4
-rw-r--r--gui/src/renderer/components/LinuxSplitTunnelingSettings.tsx318
-rw-r--r--gui/src/renderer/components/NotificationArea.tsx12
-rw-r--r--gui/src/renderer/components/SplitTunnelingSettings.tsx597
-rw-r--r--gui/src/renderer/components/SplitTunnelingSettingsStyles.tsx170
-rw-r--r--gui/src/renderer/containers/AdvancedSettingsPage.tsx2
-rw-r--r--gui/src/renderer/lib/utilityHooks.ts21
-rw-r--r--gui/src/renderer/redux/settings/actions.ts33
-rw-r--r--gui/src/renderer/redux/settings/reducers.ts17
-rw-r--r--gui/src/renderer/routes.tsx6
-rw-r--r--gui/src/shared/daemon-rpc-types.ts13
-rw-r--r--gui/src/shared/ipc-schema.ts18
-rw-r--r--gui/src/shared/notifications/block-when-disconnected.ts11
-rw-r--r--gui/src/shared/notifications/error.ts58
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.',
+ );
}
}
}