summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorOskar Nyberg <oskar@mullvad.net>2024-05-28 15:41:34 +0200
committerOskar Nyberg <oskar@mullvad.net>2024-05-28 15:41:34 +0200
commit2b04fed8d6a486d97af47f1add45b0eeb1071db8 (patch)
tree0d53951c8d6939526d41173269cba9d57e58b611
parentb6db0f3487d5096e08b077c3d806c96b50aecf35 (diff)
parent58556c0da8a4f3abc441d18c9dd0c026b1986dfb (diff)
downloadmullvadvpn-2b04fed8d6a486d97af47f1add45b0eeb1071db8.tar.xz
mullvadvpn-2b04fed8d6a486d97af47f1add45b0eeb1071db8.zip
Merge branch 'add-macos-split-tunneling-gui-des-786'
-rw-r--r--gui/.eslintrc.js4
-rw-r--r--gui/package-lock.json92
-rw-r--r--gui/package.json1
-rw-r--r--gui/src/main/index.ts74
-rw-r--r--gui/src/main/linux-split-tunneling.ts1
-rw-r--r--gui/src/main/macos-split-tunneling.ts333
-rw-r--r--gui/src/main/platform-version.ts5
-rw-r--r--gui/src/main/windows-split-tunneling.ts976
-rw-r--r--gui/src/renderer/app.tsx30
-rw-r--r--gui/src/renderer/components/Settings.tsx4
-rw-r--r--gui/src/renderer/components/SplitTunnelingSettings.tsx80
-rw-r--r--gui/src/renderer/components/Switch.tsx2
-rw-r--r--gui/src/renderer/components/TransitionContainer.tsx1
-rw-r--r--gui/src/renderer/components/cell/Selector.tsx2
-rw-r--r--gui/src/renderer/components/cell/SettingsSelect.tsx1
-rw-r--r--gui/src/renderer/redux/settings/actions.ts6
-rw-r--r--gui/src/renderer/redux/settings/reducers.ts4
-rw-r--r--gui/src/renderer/redux/userinterface/actions.ts16
-rw-r--r--gui/src/renderer/redux/userinterface/reducers.ts8
-rw-r--r--gui/src/shared/application-types.ts36
-rw-r--r--gui/src/shared/ipc-schema.ts20
-rw-r--r--gui/test/e2e/installed/state-dependent/macos-split-tunneling.spec.ts162
-rw-r--r--gui/test/e2e/setup/main.ts3
23 files changed, 1282 insertions, 579 deletions
diff --git a/gui/.eslintrc.js b/gui/.eslintrc.js
index 4cf1921910..2817c63581 100644
--- a/gui/.eslintrc.js
+++ b/gui/.eslintrc.js
@@ -30,6 +30,10 @@ const namingConvention = [
{
selector: 'typeProperty',
format: ['camelCase'],
+ filter: {
+ regex: "^(data-testid|aria-labelledby|aria-describedby)$",
+ match: false,
+ },
},
{
selector: 'typeLike',
diff --git a/gui/package-lock.json b/gui/package-lock.json
index 11ef7f51df..0734b69c4d 100644
--- a/gui/package-lock.json
+++ b/gui/package-lock.json
@@ -21,6 +21,7 @@
"react-redux": "^7.2.9",
"react-router": "^5.3.4",
"redux": "^4.2.0",
+ "simple-plist": "^1.3.1",
"sprintf-js": "^1.1.2",
"styled-components": "^6.1.0"
},
@@ -1781,7 +1782,6 @@
"version": "0.8.10",
"resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.10.tgz",
"integrity": "sha512-2WALfTl4xo2SkGCYRt6rDTFfk9R1czmBvUQy12gK2KuRKIpWEhcbbzy8EZXtz/jkRqHX8bFEc6FC1HjX4TUWYw==",
- "dev": true,
"engines": {
"node": ">=10.0.0"
}
@@ -2670,6 +2670,14 @@
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="
},
+ "node_modules/big-integer": {
+ "version": "1.6.52",
+ "resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.52.tgz",
+ "integrity": "sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==",
+ "engines": {
+ "node": ">=0.6"
+ }
+ },
"node_modules/binary-extensions": {
"version": "1.13.1",
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-1.13.1.tgz",
@@ -2727,6 +2735,25 @@
"dev": true,
"optional": true
},
+ "node_modules/bplist-creator": {
+ "version": "0.1.0",
+ "resolved": "https://registry.npmjs.org/bplist-creator/-/bplist-creator-0.1.0.tgz",
+ "integrity": "sha512-sXaHZicyEEmY86WyueLTQesbeoH/mquvarJaQNbjuOQO+7gbFcDEWqKmcWA4cOTLzFlfgvkiVxolk1k5bBIpmg==",
+ "dependencies": {
+ "stream-buffers": "2.2.x"
+ }
+ },
+ "node_modules/bplist-parser": {
+ "version": "0.3.1",
+ "resolved": "https://registry.npmjs.org/bplist-parser/-/bplist-parser-0.3.1.tgz",
+ "integrity": "sha512-PyJxiNtA5T2PlLIeBot4lbp7rj4OadzjnMZD/G5zuBNt8ei/yCU7+wW0h2bag9vr8c+/WuRWmSxbqAl9hL1rBA==",
+ "dependencies": {
+ "big-integer": "1.6.x"
+ },
+ "engines": {
+ "node": ">= 5.10.0"
+ }
+ },
"node_modules/brace-expansion": {
"version": "1.1.11",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
@@ -9853,7 +9880,6 @@
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/plist/-/plist-3.1.0.tgz",
"integrity": "sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ==",
- "dev": true,
"dependencies": {
"@xmldom/xmldom": "^0.8.8",
"base64-js": "^1.5.1",
@@ -10983,6 +11009,16 @@
"integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==",
"dev": true
},
+ "node_modules/simple-plist": {
+ "version": "1.3.1",
+ "resolved": "https://registry.npmjs.org/simple-plist/-/simple-plist-1.3.1.tgz",
+ "integrity": "sha512-iMSw5i0XseMnrhtIzRb7XpQEXepa9xhWxGUojHBL43SIpQuDQkh3Wpy67ZbDzZVr6EKxvwVChnVpdl8hEVLDiw==",
+ "dependencies": {
+ "bplist-creator": "0.1.0",
+ "bplist-parser": "0.3.1",
+ "plist": "^3.0.5"
+ }
+ },
"node_modules/simple-update-notifier": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz",
@@ -11383,6 +11419,14 @@
"node": ">= 6"
}
},
+ "node_modules/stream-buffers": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/stream-buffers/-/stream-buffers-2.2.0.tgz",
+ "integrity": "sha512-uyQK/mx5QjHun80FLJTfaWE7JtwfRMKBLkMne6udYOmvH0CawotVa7TfgYHzAnpphn4+TweIx1QKMnRIbipmUg==",
+ "engines": {
+ "node": ">= 0.10.0"
+ }
+ },
"node_modules/stream-combiner": {
"version": "0.0.4",
"resolved": "https://registry.npmjs.org/stream-combiner/-/stream-combiner-0.0.4.tgz",
@@ -12947,7 +12991,6 @@
"version": "15.1.1",
"resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz",
"integrity": "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==",
- "dev": true,
"engines": {
"node": ">=8.0"
}
@@ -14562,8 +14605,7 @@
"@xmldom/xmldom": {
"version": "0.8.10",
"resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.10.tgz",
- "integrity": "sha512-2WALfTl4xo2SkGCYRt6rDTFfk9R1czmBvUQy12gK2KuRKIpWEhcbbzy8EZXtz/jkRqHX8bFEc6FC1HjX4TUWYw==",
- "dev": true
+ "integrity": "sha512-2WALfTl4xo2SkGCYRt6rDTFfk9R1czmBvUQy12gK2KuRKIpWEhcbbzy8EZXtz/jkRqHX8bFEc6FC1HjX4TUWYw=="
},
"7zip-bin": {
"version": "5.2.0",
@@ -15271,6 +15313,11 @@
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="
},
+ "big-integer": {
+ "version": "1.6.52",
+ "resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.52.tgz",
+ "integrity": "sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg=="
+ },
"binary-extensions": {
"version": "1.13.1",
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-1.13.1.tgz",
@@ -15325,6 +15372,22 @@
"dev": true,
"optional": true
},
+ "bplist-creator": {
+ "version": "0.1.0",
+ "resolved": "https://registry.npmjs.org/bplist-creator/-/bplist-creator-0.1.0.tgz",
+ "integrity": "sha512-sXaHZicyEEmY86WyueLTQesbeoH/mquvarJaQNbjuOQO+7gbFcDEWqKmcWA4cOTLzFlfgvkiVxolk1k5bBIpmg==",
+ "requires": {
+ "stream-buffers": "2.2.x"
+ }
+ },
+ "bplist-parser": {
+ "version": "0.3.1",
+ "resolved": "https://registry.npmjs.org/bplist-parser/-/bplist-parser-0.3.1.tgz",
+ "integrity": "sha512-PyJxiNtA5T2PlLIeBot4lbp7rj4OadzjnMZD/G5zuBNt8ei/yCU7+wW0h2bag9vr8c+/WuRWmSxbqAl9hL1rBA==",
+ "requires": {
+ "big-integer": "1.6.x"
+ }
+ },
"brace-expansion": {
"version": "1.1.11",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
@@ -20971,7 +21034,6 @@
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/plist/-/plist-3.1.0.tgz",
"integrity": "sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ==",
- "dev": true,
"requires": {
"@xmldom/xmldom": "^0.8.8",
"base64-js": "^1.5.1",
@@ -21869,6 +21931,16 @@
"integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==",
"dev": true
},
+ "simple-plist": {
+ "version": "1.3.1",
+ "resolved": "https://registry.npmjs.org/simple-plist/-/simple-plist-1.3.1.tgz",
+ "integrity": "sha512-iMSw5i0XseMnrhtIzRb7XpQEXepa9xhWxGUojHBL43SIpQuDQkh3Wpy67ZbDzZVr6EKxvwVChnVpdl8hEVLDiw==",
+ "requires": {
+ "bplist-creator": "0.1.0",
+ "bplist-parser": "0.3.1",
+ "plist": "^3.0.5"
+ }
+ },
"simple-update-notifier": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz",
@@ -22200,6 +22272,11 @@
}
}
},
+ "stream-buffers": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/stream-buffers/-/stream-buffers-2.2.0.tgz",
+ "integrity": "sha512-uyQK/mx5QjHun80FLJTfaWE7JtwfRMKBLkMne6udYOmvH0CawotVa7TfgYHzAnpphn4+TweIx1QKMnRIbipmUg=="
+ },
"stream-combiner": {
"version": "0.0.4",
"resolved": "https://registry.npmjs.org/stream-combiner/-/stream-combiner-0.0.4.tgz",
@@ -23441,8 +23518,7 @@
"xmlbuilder": {
"version": "15.1.1",
"resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz",
- "integrity": "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==",
- "dev": true
+ "integrity": "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg=="
},
"xtend": {
"version": "4.0.2",
diff --git a/gui/package.json b/gui/package.json
index e3f03cee61..d1d6480783 100644
--- a/gui/package.json
+++ b/gui/package.json
@@ -23,6 +23,7 @@
"react-redux": "^7.2.9",
"react-router": "^5.3.4",
"redux": "^4.2.0",
+ "simple-plist": "^1.3.1",
"sprintf-js": "^1.1.2",
"styled-components": "^6.1.0"
},
diff --git a/gui/src/main/index.ts b/gui/src/main/index.ts
index 4cdb9f5727..fe733fa38b 100644
--- a/gui/src/main/index.ts
+++ b/gui/src/main/index.ts
@@ -6,7 +6,10 @@ import util from 'util';
import config from '../config.json';
import { hasExpired } from '../shared/account-expiry';
-import { IWindowsApplication } from '../shared/application-types';
+import {
+ ISplitTunnelingApplication,
+ ISplitTunnelingAppListRetriever,
+} from '../shared/application-types';
import {
AccessMethodSetting,
DaemonEvent,
@@ -52,6 +55,7 @@ import NotificationController, {
NotificationControllerDelegate,
NotificationSender,
} from './notification-controller';
+import { isMacOs13OrNewer } from './platform-version';
import * as problemReport from './problem-report';
import { resolveBin } from './proc';
import ReconnectionBackoff from './reconnection-backoff';
@@ -67,7 +71,8 @@ const execAsync = util.promisify(exec);
// 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');
+// This is used on Windows and macOS and will be undefined on Linux.
+const splitTunneling: ISplitTunnelingAppListRetriever | undefined = importSplitTunneling();
const ALLOWED_PERMISSIONS = ['clipboard-sanitized-write'];
@@ -110,7 +115,7 @@ class ApplicationMain
private rendererLog?: Logger;
private translations: ITranslations = { locale: this.locale };
- private windowsSplitTunnelingApplications?: IWindowsApplication[];
+ private splitTunnelingApplications?: ISplitTunnelingApplication[];
private macOsScrollbarVisibility?: MacOsScrollbarVisibility;
@@ -723,9 +728,7 @@ class ApplicationMain
IpcMainEventChannel.settings.notify?.(newSettings);
- if (windowsSplitTunneling) {
- void this.updateSplitTunnelingApplications(newSettings.splitTunnel.appsList);
- }
+ void this.updateSplitTunnelingApplications(newSettings.splitTunnel.appsList);
}
private setRelayList(relayList: IRelayListWithEndpointData) {
@@ -734,12 +737,12 @@ class ApplicationMain
}
private async updateSplitTunnelingApplications(appList: string[]): Promise<void> {
- const { applications } = await windowsSplitTunneling.getApplications({
- applicationPaths: appList,
- });
- this.windowsSplitTunnelingApplications = applications;
+ if (splitTunneling) {
+ const { applications } = await splitTunneling.getMetadataForApplications(appList);
+ this.splitTunnelingApplications = applications;
- IpcMainEventChannel.windowsSplitTunneling.notify?.(applications);
+ IpcMainEventChannel.splitTunneling.notify?.(applications);
+ }
}
private registerIpcListeners() {
@@ -758,12 +761,13 @@ class ApplicationMain
upgradeVersion: this.version.upgradeVersion,
guiSettings: this.settings.gui.state,
translations: this.translations,
- windowsSplitTunnelingApplications: this.windowsSplitTunnelingApplications,
+ splitTunnelingApplications: this.splitTunnelingApplications,
macOsScrollbarVisibility: this.macOsScrollbarVisibility,
changelog: this.changelog ?? [],
forceShowChanges: CommandLineOptions.showChanges.match,
navigationHistory: this.navigationHistory,
currentApiAccessMethod: this.currentApiAccessMethod,
+ isMacOs13OrNewer: isMacOs13OrNewer(),
}));
IpcMainEventChannel.map.handleGetData(async () => ({
@@ -789,38 +793,38 @@ class ApplicationMain
IpcMainEventChannel.linuxSplitTunneling.handleGetApplications(() => {
return linuxSplitTunneling.getApplications(this.locale);
});
- IpcMainEventChannel.windowsSplitTunneling.handleGetApplications((updateCaches: boolean) => {
- return windowsSplitTunneling.getApplications({ updateCaches });
+ IpcMainEventChannel.splitTunneling.handleGetApplications((updateCaches: boolean) => {
+ return splitTunneling!.getApplications(updateCaches);
});
IpcMainEventChannel.linuxSplitTunneling.handleLaunchApplication((application) => {
return linuxSplitTunneling.launchApplication(application);
});
- IpcMainEventChannel.windowsSplitTunneling.handleSetState((enabled) => {
+ IpcMainEventChannel.splitTunneling.handleSetState((enabled) => {
return this.daemonRpc.setSplitTunnelingState(enabled);
});
- IpcMainEventChannel.windowsSplitTunneling.handleAddApplication(async (application) => {
+ IpcMainEventChannel.splitTunneling.handleAddApplication(async (application) => {
// 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.settings.gui.addBrowsedForSplitTunnelingApplications(application);
- const applicationPath = await windowsSplitTunneling.addApplicationPathToCache(application);
- await this.daemonRpc.addSplitTunnelingApplication(applicationPath);
+ const executablePath = await splitTunneling!.resolveExecutablePath(application);
+ await splitTunneling!.addApplicationPathToCache(application);
+ await this.daemonRpc.addSplitTunnelingApplication(executablePath);
} else {
await this.daemonRpc.addSplitTunnelingApplication(application.absolutepath);
}
});
- IpcMainEventChannel.windowsSplitTunneling.handleRemoveApplication((application) => {
+ IpcMainEventChannel.splitTunneling.handleRemoveApplication((application) => {
return this.daemonRpc.removeSplitTunnelingApplication(
typeof application === 'string' ? application : application.absolutepath,
);
});
- IpcMainEventChannel.windowsSplitTunneling.handleForgetManuallyAddedApplication(
- (application) => {
- this.settings.gui.deleteBrowsedForSplitTunnelingApplications(application.absolutepath);
- return windowsSplitTunneling.removeApplicationFromCache(application);
- },
- );
+ IpcMainEventChannel.splitTunneling.handleForgetManuallyAddedApplication((application) => {
+ this.settings.gui.deleteBrowsedForSplitTunnelingApplications(application.absolutepath);
+ splitTunneling!.removeApplicationFromCache(application);
+ return Promise.resolve();
+ });
IpcMainEventChannel.app.handleQuit(() => this.disconnectAndQuit());
IpcMainEventChannel.app.handleOpenUrl(async (url) => {
@@ -851,10 +855,10 @@ class ApplicationMain
this.settings.registerIpcListeners();
this.account.registerIpcListeners();
- if (windowsSplitTunneling) {
- this.settings.gui.browsedForSplitTunnelingApplications.forEach(
- windowsSplitTunneling.addApplicationPathToCache,
- );
+ if (splitTunneling) {
+ this.settings.gui.browsedForSplitTunnelingApplications.forEach((application) => {
+ void splitTunneling.addApplicationPathToCache(application);
+ });
}
}
@@ -1104,6 +1108,18 @@ class ApplicationMain
/* eslint-enable @typescript-eslint/member-ordering */
}
+function importSplitTunneling() {
+ if (process.platform === 'win32') {
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
+ const { WindowsSplitTunnelingAppListRetriever } = require('./windows-split-tunneling');
+ return new WindowsSplitTunnelingAppListRetriever();
+ } else if (process.platform === 'darwin') {
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
+ const { MacOsSplitTunnelingAppListRetriever } = require('./macos-split-tunneling');
+ return new MacOsSplitTunnelingAppListRetriever();
+ }
+}
+
if (CommandLineOptions.help.match) {
console.log('Mullvad VPN');
console.log('Graphical interface for managing the Mullvad VPN daemon');
diff --git a/gui/src/main/linux-split-tunneling.ts b/gui/src/main/linux-split-tunneling.ts
index 9a93054590..3ae1a15390 100644
--- a/gui/src/main/linux-split-tunneling.ts
+++ b/gui/src/main/linux-split-tunneling.ts
@@ -121,7 +121,6 @@ export async function getApplications(locale: string): Promise<ILinuxSplitTunnel
const applications = desktopEntries
.filter(shouldShowApplication)
.map(addApplicationWarnings)
- .sort((a, b) => a.name.localeCompare(b.name))
.map(replaceIconNameWithDataUrl);
return Promise.all(applications);
diff --git a/gui/src/main/macos-split-tunneling.ts b/gui/src/main/macos-split-tunneling.ts
new file mode 100644
index 0000000000..f66ac4838a
--- /dev/null
+++ b/gui/src/main/macos-split-tunneling.ts
@@ -0,0 +1,333 @@
+import { NativeImage, nativeImage } from 'electron';
+import fs from 'fs/promises';
+import { userInfo } from 'os';
+import path from 'path';
+import plist from 'simple-plist';
+import { promisify } from 'util';
+
+import {
+ ISplitTunnelingApplication,
+ ISplitTunnelingAppListRetriever,
+} from '../shared/application-types';
+import log from '../shared/logging';
+
+const readPlist = promisify(plist.readFile);
+
+type Plist = Record<string, unknown>;
+
+export class MacOsSplitTunnelingAppListRetriever implements ISplitTunnelingAppListRetriever {
+ /**
+ * Cache of all previously scanned applications.
+ */
+ private applicationCache = new ApplicationCache();
+ /**
+ * List of apps that have been added manually by the user.
+ */
+ private additionalApplications = new AdditionalApplications();
+
+ public async getApplications(
+ updateCaches = false,
+ ): Promise<{
+ fromCache: boolean;
+ applications: ISplitTunnelingApplication[];
+ }> {
+ const fromCache = !updateCaches && !this.applicationCache.isEmpty();
+
+ // Update cache if requested or if cache is empty.
+ if (!fromCache) {
+ const applicationBundlePaths = await this.findApplicationBundlePaths();
+ const executablePaths = this.additionalApplications.values();
+ await Promise.all([
+ // `getApplication updates the cache so no need to use the result.`
+ ...applicationBundlePaths.map((applicationBundlePath) =>
+ this.getApplication(applicationBundlePath, false),
+ ),
+ ...executablePaths.map((executablePath) => this.getApplication(executablePath, true)),
+ ]);
+ }
+
+ // Return applications from cache.
+ return { fromCache, applications: this.applicationCache.values() };
+ }
+
+ public async getMetadataForApplications(
+ applicationPaths: string[],
+ ): Promise<{ fromCache: boolean; applications: ISplitTunnelingApplication[] }> {
+ await Promise.all(
+ applicationPaths
+ .filter((applicationPath) => !this.applicationCache.includes(applicationPath))
+ .map((applicationPath) => this.addApplicationPathToCache(applicationPath)),
+ );
+
+ const applications = await this.getApplications();
+
+ applications.applications = applications.applications.filter((application) =>
+ applicationPaths.some(
+ (applicationPath) =>
+ applicationPath.toLowerCase() === application.absolutepath.toLowerCase(),
+ ),
+ );
+
+ return applications;
+ }
+
+ public removeApplicationFromCache(application: ISplitTunnelingApplication): void {
+ this.applicationCache.remove(application);
+ this.additionalApplications.remove(application);
+ }
+
+ public async addApplicationPathToCache(applicationPath: string): Promise<void> {
+ const application = await this.getApplication(applicationPath, true);
+ if (application?.deletable) {
+ this.additionalApplications.add(application);
+ }
+ }
+
+ public async resolveExecutablePath(applicationPath: string): Promise<string> {
+ if (path.extname(applicationPath) === '.app') {
+ const macOsApplication = await this.createMacOsApplication(applicationPath, false);
+ return macOsApplication?.absolutepath ?? applicationPath;
+ } else {
+ return applicationPath;
+ }
+ }
+
+ /**
+ * Creates an `ISplitTunnelingApplication` and adds it to the cache.
+ */
+ private async getApplication(
+ applicationPath: string,
+ deletable: boolean,
+ ): Promise<ISplitTunnelingApplication | undefined> {
+ const application = await this.createApplication(applicationPath, deletable);
+
+ if (application !== undefined) {
+ this.applicationCache.add(application);
+ }
+
+ return application;
+ }
+
+ private async findApplicationBundlePaths() {
+ const readdirPromises = this.getAppDirectories().map((directory) =>
+ this.readDirectory(directory),
+ );
+ const applicationBundlePaths = (await Promise.all(readdirPromises)).flat();
+ return applicationBundlePaths.filter((filePath) => {
+ const parsedFilePath = path.parse(filePath);
+ return (
+ parsedFilePath.ext === '.app' &&
+ !parsedFilePath.name.startsWith('.') &&
+ parsedFilePath.name !== 'Mullvad VPN'
+ );
+ });
+ }
+
+ /**
+ * Returns contents of directory with results as absolute paths.
+ */
+ private async readDirectory(applicationDir: string) {
+ const basenames = await fs.readdir(applicationDir);
+ return basenames.map((basename) => path.join(applicationDir, basename));
+ }
+
+ private async readApplicationBundlePlist(applicationBundlePath: string): Promise<Plist> {
+ const plistPath = path.join(applicationBundlePath, 'Contents', 'Info.plist');
+ return (await readPlist(plistPath)) ?? {};
+ }
+
+ /**
+ * Creates an `ISplitTunnelingApplication` for any type of application.
+ */
+ private async createApplication(
+ applicationPath: string,
+ deletable: boolean,
+ ): Promise<ISplitTunnelingApplication | undefined> {
+ if (path.extname(applicationPath) === '.app') {
+ return this.createMacOsApplication(applicationPath, deletable);
+ }
+
+ const applicationDirectory = this.getApplicationDirectoryForExecutable(applicationPath);
+ if (applicationDirectory) {
+ const additionalApplication = await this.createMacOsApplication(
+ applicationDirectory,
+ deletable,
+ );
+ if (additionalApplication?.absolutepath === applicationPath) {
+ return additionalApplication;
+ }
+ }
+
+ return this.createExecutableApplication(applicationPath, deletable);
+ }
+
+ /**
+ * Creates an `ISplitTunnelingApplication` for the provided executable.
+ */
+ private async createExecutableApplication(
+ executablePath: string,
+ deletable: boolean,
+ ): Promise<ISplitTunnelingApplication> {
+ return {
+ absolutepath: executablePath,
+ name: path.basename(executablePath),
+ icon: (await this.getApplicationIcon(executablePath)).toDataURL(),
+ deletable,
+ };
+ }
+
+ /**
+ * Creates an `ISplitTunnelingApplication` for the provided application bundle.
+ */
+ private async createMacOsApplication(
+ applicationBundlePath: string,
+ deletable: boolean,
+ ): Promise<ISplitTunnelingApplication | undefined> {
+ const appInfo = await this.readApplicationBundlePlist(applicationBundlePath);
+
+ if (!('CFBundleExecutable' in appInfo) || typeof appInfo.CFBundleExecutable !== 'string') {
+ return undefined;
+ }
+
+ const name = this.getApplicationName(appInfo);
+ if (!name) {
+ return undefined;
+ }
+
+ const icon = await this.getApplicationIcon(applicationBundlePath);
+ const executablePath = path.join(
+ applicationBundlePath,
+ 'Contents',
+ 'MacOS',
+ appInfo.CFBundleExecutable,
+ );
+
+ return {
+ absolutepath: executablePath,
+ name,
+ icon: icon.toDataURL(),
+ deletable,
+ };
+ }
+
+ private getApplicationName(appInfo: Plist): string | void {
+ if ('CFBundleDisplayName' in appInfo && typeof appInfo.CFBundleDisplayName === 'string') {
+ return appInfo.CFBundleDisplayName;
+ }
+
+ if ('CFBundleName' in appInfo && typeof appInfo.CFBundleName === 'string') {
+ return appInfo.CFBundleName;
+ }
+ }
+
+ private async getApplicationIcon(applicationPath: string): Promise<NativeImage> {
+ const applicationDirectory =
+ this.getApplicationDirectoryForExecutable(applicationPath) ?? applicationPath;
+
+ try {
+ // 70x70 is the size at which the icon will be rendered in the split tunneling view accounting
+ // for HiDPI displays.
+ return await nativeImage.createThumbnailFromPath(applicationDirectory, {
+ height: 70,
+ width: 70,
+ });
+ } catch {
+ log.info('Failed to fetch icon for split tunneling application:', applicationPath);
+ return nativeImage.createEmpty();
+ }
+ }
+
+ /**
+ * Returns path to the application bundle if the provided path is or is part of an application
+ * bundle.
+ */
+ private getApplicationDirectoryForExecutable(currentPath: string): string | undefined {
+ const parsedPath = path.parse(currentPath);
+ if (parsedPath.ext === '.app') {
+ return currentPath;
+ } else if (parsedPath.dir === '/') {
+ return undefined;
+ } else {
+ return this.getApplicationDirectoryForExecutable(parsedPath.dir);
+ }
+ }
+
+ /**
+ * Returns the directories to be scanned for application bundles.
+ */
+ private getAppDirectories() {
+ return [
+ '/Applications',
+ '/Applications/Utilities',
+ '/System/Applications',
+ path.join('/', 'Users', userInfo().username, 'Applications'),
+ ];
+ }
+}
+
+/**
+ * Cache of all previously scanned applications.
+ */
+class ApplicationCache {
+ private cache: Record<string, ISplitTunnelingApplication> = {};
+
+ public add(application: ISplitTunnelingApplication) {
+ const cacheKey = application.absolutepath.toLowerCase();
+ this.cache[cacheKey] = this.merge(application, this.cache[cacheKey]);
+ }
+
+ public remove(application: ISplitTunnelingApplication) {
+ delete this.cache[application.absolutepath.toLowerCase()];
+ }
+
+ public values(): Array<ISplitTunnelingApplication> {
+ return Object.values(this.cache);
+ }
+
+ public isEmpty(): boolean {
+ return Object.keys(this.cache).length === 0;
+ }
+
+ public includes(application: ISplitTunnelingApplication | string) {
+ const cacheKey = typeof application === 'string' ? application : application.absolutepath;
+ return this.cache[cacheKey.toLowerCase()] !== undefined;
+ }
+
+ /**
+ * Merges two applications by using the values from the new one but respects the `deletable`
+ * property on the old one.
+ */
+ private merge(
+ newApplication: ISplitTunnelingApplication,
+ oldApplication?: ISplitTunnelingApplication,
+ ): ISplitTunnelingApplication {
+ if (oldApplication === undefined) {
+ return newApplication;
+ }
+
+ newApplication.deletable =
+ newApplication.deletable === true && oldApplication.deletable === true;
+ return newApplication;
+ }
+}
+
+/**
+ * List of apps that have been added manually by the user.
+ */
+class AdditionalApplications {
+ private executablePaths: Record<string, string> = {};
+
+ public add(application: ISplitTunnelingApplication | string) {
+ const executablePath = typeof application === 'string' ? application : application.absolutepath;
+ this.executablePaths[executablePath.toLowerCase()] = executablePath;
+ }
+
+ public remove(application: ISplitTunnelingApplication | string) {
+ const executablePath = typeof application === 'string' ? application : application.absolutepath;
+ delete this.executablePaths[executablePath.toLowerCase()];
+ }
+
+ public values(): Array<string> {
+ return Object.values(this.executablePaths);
+ }
+}
diff --git a/gui/src/main/platform-version.ts b/gui/src/main/platform-version.ts
index 51f73fe75b..027434d8d9 100644
--- a/gui/src/main/platform-version.ts
+++ b/gui/src/main/platform-version.ts
@@ -5,6 +5,11 @@ export function isMacOs11OrNewer() {
return process.platform === 'darwin' && major >= 20;
}
+export function isMacOs13OrNewer() {
+ const [major] = parseVersion();
+ return process.platform === 'darwin' && major >= 22;
+}
+
// Windows 11 has the internal version 10.0.22000+.
export function isWindows11OrNewer() {
const [major, minor, patch] = parseVersion();
diff --git a/gui/src/main/windows-split-tunneling.ts b/gui/src/main/windows-split-tunneling.ts
index 844b74b77b..c74623a1f7 100644
--- a/gui/src/main/windows-split-tunneling.ts
+++ b/gui/src/main/windows-split-tunneling.ts
@@ -2,7 +2,10 @@ import { app, shell } from 'electron';
import fs from 'fs';
import path from 'path';
-import { IWindowsApplication } from '../shared/application-types';
+import {
+ ISplitTunnelingApplication,
+ ISplitTunnelingAppListRetriever,
+} from '../shared/application-types';
import log from '../shared/logging';
import {
ArrayValue,
@@ -57,581 +60,616 @@ const APPLICATION_ALLOW_LIST = [
'iexplore.exe',
];
-// Cache of all previously scanned shortcuts.
-const shortcutCache: Record<string, ShortcutDetails> = {};
-// Cache of all previously scanned applications.
-const applicationCache: Record<string, IWindowsApplication> = {};
-// List of shortcuts that have been added manually by the user.
-let additionalShortcuts: ShortcutDetails[] = [];
+export class WindowsSplitTunnelingAppListRetriever implements ISplitTunnelingAppListRetriever {
+ // Cache of all previously scanned shortcuts.
+ private shortcutCache: Record<string, ShortcutDetails> = {};
+ // Cache of all previously scanned applications.
+ private applicationCache: Record<string, ISplitTunnelingApplication> = {};
+ // List of shortcuts that have been added manually by the user.
+ private additionalShortcuts: ShortcutDetails[] = [];
-// Finds applications by searching through the startmenu for shortcuts with and exe-file as target.
-// If applicationPaths has a value, the returned applications are only the ones corresponding to
-// those paths.
-export async function getApplications(options: {
- applicationPaths?: string[];
- updateCaches?: boolean;
-}): Promise<{ fromCache: boolean; applications: IWindowsApplication[] }> {
- const cacheIsEmpty = Object.keys(shortcutCache).length === 0;
+ // Finds applications by searching through the startmenu for shortcuts with and exe-file as
+ // target.
+ public async getApplications(
+ updateCaches = false,
+ ): Promise<{ fromCache: boolean; applications: ISplitTunnelingApplication[] }> {
+ const cacheIsEmpty = Object.keys(this.shortcutCache).length === 0;
- if (options.updateCaches || cacheIsEmpty) {
- await updateShortcutCache();
- }
+ const fromCache = !updateCaches && !cacheIsEmpty;
+ if (!fromCache) {
+ await this.updateShortcutCache();
+ }
- // Add excluded apps that are missing from the shortcut cache to it
- if (options.applicationPaths) {
- await Promise.all(options.applicationPaths.map(addApplicationToAdditionalShortcuts));
+ await this.updateApplicationCache();
+
+ return {
+ fromCache,
+ applications: Object.values(this.applicationCache),
+ };
}
- await updateApplicationCache();
- // If applicationPaths is supplied the returnvalue should only contain the applications
- // corresponding to those paths.
- const applications = Object.values(applicationCache)
- .filter(
+ public async getMetadataForApplications(
+ applicationPaths: string[],
+ ): Promise<{ fromCache: boolean; applications: ISplitTunnelingApplication[] }> {
+ // Add excluded apps that are missing from the shortcut cache to it
+ await Promise.all(
+ applicationPaths.map((applicationPath) =>
+ this.addApplicationToAdditionalShortcuts(applicationPath),
+ ),
+ );
+
+ const applications = await this.getApplications();
+ // If applicationPaths is supplied the returnvalue should only contain the applications
+ // corresponding to those paths.
+ applications.applications = applications.applications.filter(
(application) =>
- options.applicationPaths === undefined ||
- options.applicationPaths.find(
+ applicationPaths.find(
(applicationPath) =>
applicationPath.toLowerCase() === application.absolutepath.toLowerCase(),
) !== undefined,
- )
- .sort((a, b) => a.name.localeCompare(b.name));
-
- return {
- fromCache: !options.updateCaches && !cacheIsEmpty,
- applications,
- };
-}
+ );
-// Adds either a shortcut or an executable to the additionalShortcuts list
-export async function addApplicationPathToCache(applicationPath: string): Promise<string> {
- const parsedPath = path.parse(applicationPath);
- if (parsedPath.ext === '.lnk') {
- const shortcutDetiails = shell.readShortcutLink(path.resolve(applicationPath));
- additionalShortcuts.push({
- ...shortcutDetiails,
- name: path.parse(applicationPath).name,
- deletable: true,
- });
- return shortcutDetiails.target;
- } else {
- await addApplicationToAdditionalShortcuts(applicationPath);
- return applicationPath;
+ return applications;
}
-}
-export function removeApplicationFromCache(application: IWindowsApplication): void {
- additionalShortcuts = additionalShortcuts.filter(
- (shortcut) => shortcut.target !== application.absolutepath,
- );
- delete applicationCache[application.absolutepath.toLowerCase()];
-}
+ public resolveExecutablePath(providedPath: string): Promise<string> {
+ if (path.extname(providedPath) === '.lnk') {
+ return Promise.resolve(shell.readShortcutLink(path.resolve(providedPath)).target);
+ }
-// Reads the start-menu directories and adds all shortcuts, targeting applications using networking,
-// to the shortcuts cache. Whether or not an application use networking is determined by checking for
-// "WS2_32.dll" in it's imports.
-async function updateShortcutCache(): Promise<void> {
- const links = await Promise.all(APPLICATION_PATHS.map(findAllLinks));
- const resolvedLinks = removeDuplicates(resolveLinks(links.flat()));
+ return Promise.resolve(providedPath);
+ }
- const shortcuts: ShortcutDetails[] = [];
- for (const shortcut of resolvedLinks) {
- if (
- APPLICATION_ALLOW_LIST.includes(path.basename(shortcut.target.toLowerCase())) ||
- (await importsDll(shortcut.target, 'WS2_32.dll'))
- ) {
- shortcuts.push(shortcut);
- shortcutCache[shortcut.target.toLowerCase()] = shortcut;
+ // Adds either a shortcut or an executable to the additionalShortcuts list
+ public async addApplicationPathToCache(applicationPath: string): Promise<void> {
+ const parsedPath = path.parse(applicationPath);
+ if (parsedPath.ext === '.lnk') {
+ const shortcutDetiails = shell.readShortcutLink(path.resolve(applicationPath));
+ this.additionalShortcuts.push({
+ ...shortcutDetiails,
+ name: path.parse(applicationPath).name,
+ deletable: true,
+ });
+ } else {
+ await this.addApplicationToAdditionalShortcuts(applicationPath);
}
}
-}
-async function updateApplicationCache(): Promise<void> {
- const shortcuts = Object.values(shortcutCache).concat(additionalShortcuts);
+ public removeApplicationFromCache(application: ISplitTunnelingApplication): void {
+ this.additionalShortcuts = this.additionalShortcuts.filter(
+ (shortcut) => shortcut.target !== application.absolutepath,
+ );
+ delete this.applicationCache[application.absolutepath.toLowerCase()];
+ }
- await Promise.all(
- shortcuts.map(async (shortcut) => {
- const lowercaseTarget = shortcut.target.toLowerCase();
- if (applicationCache[lowercaseTarget] === undefined) {
- applicationCache[lowercaseTarget] = await convertToSplitTunnelingApplication(shortcut);
+ // Reads the start-menu directories and adds all shortcuts, targeting applications using networking,
+ // to the shortcuts cache. Whether or not an application use networking is determined by checking for
+ // "WS2_32.dll" in it's imports.
+ private async updateShortcutCache(): Promise<void> {
+ const links = await Promise.all(
+ APPLICATION_PATHS.map((applicationPath) => this.findAllLinks(applicationPath)),
+ );
+ const resolvedLinks = this.removeDuplicates(this.resolveLinks(links.flat()));
+
+ const shortcuts: ShortcutDetails[] = [];
+ for (const shortcut of resolvedLinks) {
+ if (
+ APPLICATION_ALLOW_LIST.includes(path.basename(shortcut.target.toLowerCase())) ||
+ (await this.importsDll(shortcut.target, 'WS2_32.dll'))
+ ) {
+ shortcuts.push(shortcut);
+ this.shortcutCache[shortcut.target.toLowerCase()] = shortcut;
}
+ }
+ }
- return applicationCache[lowercaseTarget];
- }),
- );
-}
+ private async updateApplicationCache(): Promise<void> {
+ const shortcuts = Object.values(this.shortcutCache).concat(this.additionalShortcuts);
-// Add excluded apps that are missing from the shortcut cache to it
-async function addApplicationToAdditionalShortcuts(applicationPath: string): Promise<void> {
- if (
- shortcutCache[applicationPath.toLowerCase()] === undefined &&
- !additionalShortcuts.some(
- (shortcut) => shortcut.target.toLowerCase() === applicationPath.toLowerCase(),
- )
- ) {
- additionalShortcuts.push({
- target: applicationPath,
- name: (await getProgramName(applicationPath)) ?? path.parse(applicationPath).name,
- deletable: true,
- });
+ await Promise.all(
+ shortcuts.map(async (shortcut) => {
+ const lowercaseTarget = shortcut.target.toLowerCase();
+ if (this.applicationCache[lowercaseTarget] === undefined) {
+ this.applicationCache[lowercaseTarget] = await this.convertToSplitTunnelingApplication(
+ shortcut,
+ );
+ }
+
+ return this.applicationCache[lowercaseTarget];
+ }),
+ );
}
-}
-// Fins all links in a directory.
-async function findAllLinks(path: string): Promise<string[]> {
- if (path.endsWith('.lnk')) {
- return [path];
- } else {
- const stat = await fs.promises.stat(path);
- if (stat.isDirectory()) {
- const contents = await fs.promises.readdir(path);
- const result = await Promise.all(contents.map((item) => findAllLinks(`${path}/${item}`)));
- return result.flat();
- } else {
- return [];
+ // Add excluded apps that are missing from the shortcut cache to it
+ private async addApplicationToAdditionalShortcuts(applicationPath: string): Promise<void> {
+ if (
+ this.shortcutCache[applicationPath.toLowerCase()] === undefined &&
+ !this.additionalShortcuts.some(
+ (shortcut) => shortcut.target.toLowerCase() === applicationPath.toLowerCase(),
+ )
+ ) {
+ this.additionalShortcuts.push({
+ target: applicationPath,
+ name: (await this.getProgramName(applicationPath)) ?? path.parse(applicationPath).name,
+ deletable: true,
+ });
}
}
-}
-function resolveLinks(linkPaths: string[]): ShortcutDetails[] {
- return linkPaths
- .map((link) => {
- try {
- return {
- ...shell.readShortcutLink(path.resolve(link)),
- name: path.parse(link).name,
- };
- } catch {
- return null;
+ // Fins all links in a directory.
+ private async findAllLinks(path: string): Promise<string[]> {
+ if (path.endsWith('.lnk')) {
+ return [path];
+ } else {
+ const stat = await fs.promises.stat(path);
+ if (stat.isDirectory()) {
+ const contents = await fs.promises.readdir(path);
+ const result = await Promise.all(
+ contents.map((item) => this.findAllLinks(`${path}/${item}`)),
+ );
+ return result.flat();
+ } else {
+ return [];
}
- })
- .filter(
- (shortcut): shortcut is ShortcutDetails =>
- shortcut !== null &&
- !shortcut.target.endsWith('Mullvad VPN.exe') &&
- shortcut.target.endsWith('.exe') &&
- !shortcut.target.toLowerCase().includes('install') && // Covers "uninstall" as well.
- !shortcut.name.toLowerCase().includes('install'),
- );
-}
+ }
+ }
-async function getProgramName(exePath: string): Promise<string | undefined> {
- try {
- return await getProductName(exePath);
- } catch {
- return undefined;
+ private resolveLinks(linkPaths: string[]): ShortcutDetails[] {
+ return linkPaths
+ .map((link) => {
+ try {
+ return {
+ ...shell.readShortcutLink(path.resolve(link)),
+ name: path.parse(link).name,
+ };
+ } catch {
+ return null;
+ }
+ })
+ .filter(
+ (shortcut): shortcut is ShortcutDetails =>
+ shortcut !== null &&
+ !shortcut.target.endsWith('Mullvad VPN.exe') &&
+ shortcut.target.endsWith('.exe') &&
+ !shortcut.target.toLowerCase().includes('install') && // Covers "uninstall" as well.
+ !shortcut.name.toLowerCase().includes('install'),
+ );
}
-}
-// Removes all duplicate shortcuts.
-function removeDuplicates(shortcuts: ShortcutDetails[]): ShortcutDetails[] {
- const unique = shortcuts.reduce((shortcuts, shortcut) => {
- const lowercaseTarget = shortcut.target.toLowerCase();
- if (shortcuts[lowercaseTarget]) {
- if (
- shortcuts[lowercaseTarget].args &&
- shortcuts[lowercaseTarget].args !== '' &&
- (!shortcut.args || shortcut.args === '')
- ) {
- shortcuts[lowercaseTarget] = shortcut;
- }
- } else {
- shortcuts[lowercaseTarget] = shortcut;
+ private async getProgramName(exePath: string): Promise<string | undefined> {
+ try {
+ return await this.getProductName(exePath);
+ } catch {
+ return undefined;
}
- return shortcuts;
- }, {} as Record<string, ShortcutDetails>);
+ }
- return Object.values(unique);
-}
+ // Removes all duplicate shortcuts.
+ private removeDuplicates(shortcuts: ShortcutDetails[]): ShortcutDetails[] {
+ const unique = shortcuts.reduce((shortcuts, shortcut) => {
+ const lowercaseTarget = shortcut.target.toLowerCase();
+ if (shortcuts[lowercaseTarget]) {
+ if (
+ shortcuts[lowercaseTarget].args &&
+ shortcuts[lowercaseTarget].args !== '' &&
+ (!shortcut.args || shortcut.args === '')
+ ) {
+ shortcuts[lowercaseTarget] = shortcut;
+ }
+ } else {
+ shortcuts[lowercaseTarget] = shortcut;
+ }
+ return shortcuts;
+ }, {} as Record<string, ShortcutDetails>);
-async function convertToSplitTunnelingApplication(
- shortcut: ShortcutDetails,
-): Promise<IWindowsApplication> {
- return {
- absolutepath: shortcut.target,
- name: shortcut.name,
- icon: await retrieveIcon(shortcut.target),
- deletable: shortcut.deletable,
- };
-}
+ return Object.values(unique);
+ }
-async function retrieveIcon(exe: string) {
- const icon = await app.getFileIcon(exe, { size: 'large' });
- return icon.toDataURL();
-}
+ private async convertToSplitTunnelingApplication(
+ shortcut: ShortcutDetails,
+ ): Promise<ISplitTunnelingApplication> {
+ return {
+ absolutepath: shortcut.target,
+ name: shortcut.name,
+ icon: await this.retrieveIcon(shortcut.target),
+ deletable: shortcut.deletable,
+ };
+ }
-// Checks if the application at the supplied path imports a specific dll.
-async function importsDll(path: string, dllName: string): Promise<boolean> {
- let fileHandle: fs.promises.FileHandle;
- try {
- fileHandle = await fs.promises.open(path, fs.constants.O_RDONLY);
- } catch (e) {
- return false;
+ private async retrieveIcon(exe: string) {
+ const icon = await app.getFileIcon(exe, { size: 'large' });
+ return icon.toDataURL();
}
- const imports = await getExeImports(fileHandle, path);
- await fileHandle.close();
- return imports.map((name) => name.toLowerCase()).includes(dllName.toLowerCase());
-}
+ // Checks if the application at the supplied path imports a specific dll.
+ private async importsDll(path: string, dllName: string): Promise<boolean> {
+ let fileHandle: fs.promises.FileHandle;
+ try {
+ fileHandle = await fs.promises.open(path, fs.constants.O_RDONLY);
+ } catch (e) {
+ return false;
+ }
-async function getExeImports(fileHandle: fs.promises.FileHandle, path: string): Promise<string[]> {
- try {
- const tableOffsetResult = await getTableOffset(fileHandle, IMAGE_DIRECTORY_ENTRY_IMPORT);
- if (tableOffsetResult) {
- const { offset: importTableOffset, rvaToOffset } = tableOffsetResult;
- const moduleNames = await getImportModuleNames(fileHandle, importTableOffset, rvaToOffset);
- return moduleNames;
- } else {
+ const imports = await this.getExeImports(fileHandle, path);
+ await fileHandle.close();
+ return imports.map((name) => name.toLowerCase()).includes(dllName.toLowerCase());
+ }
+
+ private async getExeImports(fileHandle: fs.promises.FileHandle, path: string): Promise<string[]> {
+ try {
+ const tableOffsetResult = await this.getTableOffset(fileHandle, IMAGE_DIRECTORY_ENTRY_IMPORT);
+ if (tableOffsetResult) {
+ const { offset: importTableOffset, rvaToOffset } = tableOffsetResult;
+ const moduleNames = await this.getImportModuleNames(
+ fileHandle,
+ importTableOffset,
+ rvaToOffset,
+ );
+ return moduleNames;
+ } else {
+ return [];
+ }
+ } catch (e) {
+ log.error(`Failed to read .exe import table for ${path}.`, e);
return [];
}
- } catch (e) {
- log.error(`Failed to read .exe import table for ${path}.`, e);
- return [];
}
-}
-async function readString(
- fileHandle: fs.promises.FileHandle,
- offset: number,
- encoding: 'ascii' | 'ucs2',
-): Promise<{ value: string; endOffset: number }> {
- const characterSize = getCharacterSize(encoding);
- const buffer = Buffer.alloc(characterSize);
- await fileHandle.read(buffer, 0, characterSize, offset);
+ private async readString(
+ fileHandle: fs.promises.FileHandle,
+ offset: number,
+ encoding: 'ascii' | 'ucs2',
+ ): Promise<{ value: string; endOffset: number }> {
+ const characterSize = this.getCharacterSize(encoding);
+ const buffer = Buffer.alloc(characterSize);
+ await fileHandle.read(buffer, 0, characterSize, offset);
- const nextOffset = offset + characterSize;
- if (buffer.every((value) => value === 0)) {
- return { value: '', endOffset: nextOffset };
- } else {
- const { value: nextValue, endOffset } = await readString(fileHandle, nextOffset, encoding);
- const value = buffer.toString(encoding) + nextValue;
- return { value, endOffset };
+ const nextOffset = offset + characterSize;
+ if (buffer.every((value) => value === 0)) {
+ return { value: '', endOffset: nextOffset };
+ } else {
+ const { value: nextValue, endOffset } = await this.readString(
+ fileHandle,
+ nextOffset,
+ encoding,
+ );
+ const value = buffer.toString(encoding) + nextValue;
+ return { value, endOffset };
+ }
}
-}
-function getCharacterSize(encoding: 'ascii' | 'ucs2'): number {
- switch (encoding) {
- case 'ascii':
- return 1;
- case 'ucs2':
- return 2;
+ private getCharacterSize(encoding: 'ascii' | 'ucs2'): number {
+ switch (encoding) {
+ case 'ascii':
+ return 1;
+ case 'ucs2':
+ return 2;
+ }
}
-}
-// Finds and returns the NT header.
-async function getNtHeader(
- fileHandle: fs.promises.FileHandle,
-): Promise<StructValue<ImageNtHeadersUnion>> {
- // Check whether or not the file follows the PE format.
- const dosHeader = await Value.fromFile(fileHandle, 0, DOS_HEADER);
- const eMagic = dosHeader.get<PrimitiveValue>('e_magic').value();
- if (eMagic !== 0x5a4d) {
- throw new Error('Not a PE file');
- }
+ // Finds and returns the NT header.
+ private async getNtHeader(
+ fileHandle: fs.promises.FileHandle,
+ ): Promise<StructValue<ImageNtHeadersUnion>> {
+ // Check whether or not the file follows the PE format.
+ const dosHeader = await Value.fromFile(fileHandle, 0, DOS_HEADER);
+ const eMagic = dosHeader.get<PrimitiveValue>('e_magic').value();
+ if (eMagic !== 0x5a4d) {
+ throw new Error('Not a PE file');
+ }
- const ntHeaderOffset = dosHeader.get<PrimitiveValue>('e_lfanew').value();
+ const ntHeaderOffset = dosHeader.get<PrimitiveValue>('e_lfanew').value();
- // Check if this is a 32- or 64-bit exe-file and return the correct datatype.
- const ntHeader32 = await Value.fromFile(fileHandle, ntHeaderOffset, IMAGE_NT_HEADERS);
- const signature = ntHeader32.get<PrimitiveValue>('Signature').buffer.toString('ascii');
- if (signature !== 'PE\0\0') {
- throw new Error('Not a PE file');
- }
+ // Check if this is a 32- or 64-bit exe-file and return the correct datatype.
+ const ntHeader32 = await Value.fromFile(fileHandle, ntHeaderOffset, IMAGE_NT_HEADERS);
+ const signature = ntHeader32.get<PrimitiveValue>('Signature').buffer.toString('ascii');
+ if (signature !== 'PE\0\0') {
+ throw new Error('Not a PE file');
+ }
- const magic = ntHeader32
- .get<StructValue<typeof IMAGE_OPTIONAL_HEADER32>>('OptionalHeader')
- .get<PrimitiveValue>('Magic')
- .value();
+ const magic = ntHeader32
+ .get<StructValue<typeof IMAGE_OPTIONAL_HEADER32>>('OptionalHeader')
+ .get<PrimitiveValue>('Magic')
+ .value();
- // magic is 0x20b for 64-bit executables.
- return magic === 0x20b
- ? Value.fromFile(fileHandle, ntHeaderOffset, IMAGE_NT_HEADERS64)
- : ntHeader32;
-}
+ // magic is 0x20b for 64-bit executables.
+ return magic === 0x20b
+ ? Value.fromFile(fileHandle, ntHeaderOffset, IMAGE_NT_HEADERS64)
+ : ntHeader32;
+ }
-// Reads the import table and returns a list of the imported DLLs.
-async function getImportModuleNames(
- fileHandle: fs.promises.FileHandle,
- importTableOffset: number,
- rvaToOffset: RvaToOffset,
-): Promise<string[]> {
- const moduleNames: string[] = [];
- const entrySize = Value.sizeOf(IMAGE_IMPORT_MODULE_DIRECTORY);
+ // Reads the import table and returns a list of the imported DLLs.
+ private async getImportModuleNames(
+ fileHandle: fs.promises.FileHandle,
+ importTableOffset: number,
+ rvaToOffset: RvaToOffset,
+ ): Promise<string[]> {
+ const moduleNames: string[] = [];
+ const entrySize = Value.sizeOf(IMAGE_IMPORT_MODULE_DIRECTORY);
- // eslint-disable-next-line no-constant-condition
- for (let i = 0; true; i++) {
- const importEntry = await Value.fromFile(
- fileHandle,
- importTableOffset + i * entrySize,
- IMAGE_IMPORT_MODULE_DIRECTORY,
- );
- const nameRva = importEntry.get('ModuleName').value();
+ // eslint-disable-next-line no-constant-condition
+ for (let i = 0; true; i++) {
+ const importEntry = await Value.fromFile(
+ fileHandle,
+ importTableOffset + i * entrySize,
+ IMAGE_IMPORT_MODULE_DIRECTORY,
+ );
+ const nameRva = importEntry.get('ModuleName').value();
- if (nameRva !== 0x0) {
- const offset = await rvaToOffset(nameRva);
+ if (nameRva !== 0x0) {
+ const offset = await rvaToOffset(nameRva);
- const { value: name } = await readString(fileHandle, offset, 'ascii');
- moduleNames.push(name);
- } else {
- return moduleNames;
+ const { value: name } = await this.readString(fileHandle, offset, 'ascii');
+ moduleNames.push(name);
+ } else {
+ return moduleNames;
+ }
}
}
-}
-
-async function getProductName(path: string): Promise<string | undefined> {
- let fileHandle: fs.promises.FileHandle;
- try {
- fileHandle = await fs.promises.open(path, fs.constants.O_RDONLY);
- } catch {
- return undefined;
- }
- try {
- const getTableOffsetResult = await getTableOffset(fileHandle, IMAGE_DIRECTORY_ENTRY_RESOURCE);
+ private async getProductName(path: string): Promise<string | undefined> {
+ let fileHandle: fs.promises.FileHandle;
+ try {
+ fileHandle = await fs.promises.open(path, fs.constants.O_RDONLY);
+ } catch {
+ return undefined;
+ }
- if (getTableOffsetResult) {
- const { offset: resourceTableOffset, rvaToOffset } = getTableOffsetResult;
- const leafOffsets = await getResourceTreeLeafOffsets(
+ try {
+ const getTableOffsetResult = await this.getTableOffset(
fileHandle,
- resourceTableOffset,
- resourceTableOffset,
- rvaToOffset,
- [[16], [1], [0, 1033]],
+ IMAGE_DIRECTORY_ENTRY_RESOURCE,
);
- const productName = await leafOffsets.reduce(async (alreadyFoundValue, leafOffset) => {
- const value = await alreadyFoundValue;
- if (value) {
- return value;
- } else {
- const strings = await getVsVersionInfoStrings(fileHandle, leafOffset);
- return strings.get('FileDescription') ?? strings.get('ProductName');
- }
- }, Promise.resolve() as Promise<string | undefined>);
+ if (getTableOffsetResult) {
+ const { offset: resourceTableOffset, rvaToOffset } = getTableOffsetResult;
+ const leafOffsets = await this.getResourceTreeLeafOffsets(
+ fileHandle,
+ resourceTableOffset,
+ resourceTableOffset,
+ rvaToOffset,
+ [[16], [1], [0, 1033]],
+ );
- return productName;
- } else {
+ const productName = await leafOffsets.reduce(async (alreadyFoundValue, leafOffset) => {
+ const value = await alreadyFoundValue;
+ if (value) {
+ return value;
+ } else {
+ const strings = await this.getVsVersionInfoStrings(fileHandle, leafOffset);
+ return strings.get('FileDescription') ?? strings.get('ProductName');
+ }
+ }, Promise.resolve() as Promise<string | undefined>);
+
+ return productName;
+ } else {
+ return undefined;
+ }
+ } catch {
return undefined;
+ } finally {
+ await fileHandle.close();
}
- } catch {
- return undefined;
- } finally {
- await fileHandle.close();
}
-}
-async function getTableOffset(
- fileHandle: fs.promises.FileHandle,
- tableIndex: number,
-): Promise<{ offset: number; rvaToOffset: RvaToOffset } | undefined> {
- const ntHeader = await getNtHeader(fileHandle);
- const fileHeader = ntHeader.get<StructValue<typeof IMAGE_FILE_HEADER>>('FileHeader');
- const optionalHeader = ntHeader.get<StructValue<ImageOptionalHeaderUnion>>('OptionalHeader');
+ private async getTableOffset(
+ fileHandle: fs.promises.FileHandle,
+ tableIndex: number,
+ ): Promise<{ offset: number; rvaToOffset: RvaToOffset } | undefined> {
+ const ntHeader = await this.getNtHeader(fileHandle);
+ const fileHeader = ntHeader.get<StructValue<typeof IMAGE_FILE_HEADER>>('FileHeader');
+ const optionalHeader = ntHeader.get<StructValue<ImageOptionalHeaderUnion>>('OptionalHeader');
+
+ const tableRva = optionalHeader
+ .get<ArrayValue<typeof IMAGE_DATA_DIRECTORY>>('DataDirectory')
+ .nth(tableIndex)
+ .get('VirtualAddress')
+ .value();
+
+ if (tableRva === 0x0) {
+ return undefined;
+ }
- const tableRva = optionalHeader
- .get<ArrayValue<typeof IMAGE_DATA_DIRECTORY>>('DataDirectory')
- .nth(tableIndex)
- .get('VirtualAddress')
- .value();
+ const numberOfSections = fileHeader.get<PrimitiveValue>('NumberOfSections').value();
+ const ntHeaderEndOffset =
+ ntHeader.offset +
+ ntHeader.get<PrimitiveValue<typeof DWORD>>('Signature').size +
+ fileHeader.size +
+ fileHeader.get<PrimitiveValue>('SizeOfOptionalHeader').value();
- if (tableRva === 0x0) {
- return undefined;
+ const rvaToOffset = (rva: number) =>
+ rvaToOffsetImpl(fileHandle, rva, numberOfSections, ntHeaderEndOffset);
+
+ const tableOffset = await rvaToOffset(tableRva);
+
+ return { offset: tableOffset, rvaToOffset };
}
- const numberOfSections = fileHeader.get<PrimitiveValue>('NumberOfSections').value();
- const ntHeaderEndOffset =
- ntHeader.offset +
- ntHeader.get<PrimitiveValue<typeof DWORD>>('Signature').size +
- fileHeader.size +
- fileHeader.get<PrimitiveValue>('SizeOfOptionalHeader').value();
+ // Searches the resource tree for the supplied paths and returns the leaves at the end of those
+ // paths.
+ private async getResourceTreeLeafOffsets(
+ fileHandle: fs.promises.FileHandle,
+ sectionOffset: number,
+ tableOffset: number,
+ rvaToOffset: (rva: number) => Promise<number>,
+ [ids, ...path]: number[][],
+ ): Promise<number[]> {
+ const table = await Value.fromFile(fileHandle, tableOffset, IMAGE_RESOURCE_DIRECTORY);
- const rvaToOffset = (rva: number) =>
- rvaToOffsetImpl(fileHandle, rva, numberOfSections, ntHeaderEndOffset);
+ const numberOfNameEntries = table.get('NumberOfNameEntries').value();
+ const numberOfIdEntries = table.get('NumberOfIdEntries').value();
- const tableOffset = await rvaToOffset(tableRva);
+ const leaves: number[] = [];
- return { offset: tableOffset, rvaToOffset };
-}
+ for (let i = numberOfNameEntries; i < numberOfNameEntries + numberOfIdEntries; i++) {
+ const offset =
+ tableOffset +
+ Value.sizeOf(IMAGE_RESOURCE_DIRECTORY) +
+ i * Value.sizeOf(IMAGE_RESOURCE_DIRECTORY_ID_ENTRY);
+ const entry = await Value.fromFile(fileHandle, offset, IMAGE_RESOURCE_DIRECTORY_ID_ENTRY);
+
+ const id = entry.get('Id').value();
+ if (!ids.includes(id)) {
+ continue;
+ }
-// Searches the resource tree for the supplied paths and returns the leaves at the end of those
-// paths.
-async function getResourceTreeLeafOffsets(
- fileHandle: fs.promises.FileHandle,
- sectionOffset: number,
- tableOffset: number,
- rvaToOffset: (rva: number) => Promise<number>,
- [ids, ...path]: number[][],
-): Promise<number[]> {
- const table = await Value.fromFile(fileHandle, tableOffset, IMAGE_RESOURCE_DIRECTORY);
+ let offsetToData = entry.get('OffsetToData').value();
+ // If the first bit is 1 then the offset points to another node, otherwise it point to a leaf.
+ const isLeaf = (offsetToData & 0x80000000) === 0;
- const numberOfNameEntries = table.get('NumberOfNameEntries').value();
- const numberOfIdEntries = table.get('NumberOfIdEntries').value();
+ if (isLeaf && path.length === 0) {
+ const leafDataOffset = await this.getResourceTreeLeafValueOffset(
+ fileHandle,
+ sectionOffset + offsetToData,
+ rvaToOffset,
+ );
- const leaves: number[] = [];
+ leaves.push(leafDataOffset);
+ } else if (!isLeaf) {
+ offsetToData &= 0x7fffffff;
- for (let i = numberOfNameEntries; i < numberOfNameEntries + numberOfIdEntries; i++) {
- const offset =
- tableOffset +
- Value.sizeOf(IMAGE_RESOURCE_DIRECTORY) +
- i * Value.sizeOf(IMAGE_RESOURCE_DIRECTORY_ID_ENTRY);
- const entry = await Value.fromFile(fileHandle, offset, IMAGE_RESOURCE_DIRECTORY_ID_ENTRY);
+ const subTreeLeaves = await this.getResourceTreeLeafOffsets(
+ fileHandle,
+ sectionOffset,
+ sectionOffset + offsetToData,
+ rvaToOffset,
+ path,
+ );
- const id = entry.get('Id').value();
- if (!ids.includes(id)) {
- continue;
+ leaves.push(...subTreeLeaves);
+ } else {
+ continue;
+ }
}
- let offsetToData = entry.get('OffsetToData').value();
- // If the first bit is 1 then the offset points to another node, otherwise it point to a leaf.
- const isLeaf = (offsetToData & 0x80000000) === 0;
+ return leaves;
+ }
- if (isLeaf && path.length === 0) {
- const leafDataOffset = await getResourceTreeLeafValueOffset(
+ // Finds the Strings structures within the VS_VERSIONINFO structure and returns the contents.
+ private async getVsVersionInfoStrings(
+ fileHandle: fs.promises.FileHandle,
+ offset: number,
+ ): Promise<Map<string, string>> {
+ try {
+ const stringFileInfoOffset = await this.getVsVersionInfoChildrenOffset(fileHandle, offset);
+
+ const stringTableOffset = await this.getChildrenOffset(
fileHandle,
- sectionOffset + offsetToData,
- rvaToOffset,
+ stringFileInfoOffset,
+ STRING_FILE_INFO,
+ (szKey) => szKey === 'StringFileInfo',
);
+ const stringTable = await Value.fromFile(fileHandle, stringTableOffset, STRING_TABLE);
+ const stringTableLength = stringTable.get<PrimitiveValue>('wLength').value();
- leaves.push(leafDataOffset);
- } else if (!isLeaf) {
- offsetToData &= 0x7fffffff;
+ const stringsOffset = await this.getChildrenOffset(
+ fileHandle,
+ stringTableOffset,
+ STRING_TABLE,
+ (szKey) => szKey.substring(4).toLowerCase() === '04b0',
+ );
- const subTreeLeaves = await getResourceTreeLeafOffsets(
+ const strings = await this.parseStrings(
fileHandle,
- sectionOffset,
- sectionOffset + offsetToData,
- rvaToOffset,
- path,
+ stringsOffset,
+ stringTableOffset + stringTableLength,
);
- leaves.push(...subTreeLeaves);
- } else {
- continue;
+ return strings;
+ } catch {
+ return new Map();
}
}
- return leaves;
-}
+ // Loops through the list of strings and returns a map with the contents.
+ private async parseStrings(
+ fileHandle: fs.promises.FileHandle,
+ stringsOffset: number,
+ stringTableEnd: number,
+ ): Promise<Map<string, string>> {
+ const strings = new Map<string, string>();
+
+ let currentStringOffset = stringsOffset;
+ while (currentStringOffset < stringTableEnd) {
+ const stringValue = await Value.fromFile(
+ fileHandle,
+ currentStringOffset,
+ STRING_TABLE_STRING,
+ );
+ const structSize = stringValue.get('wLength').value();
+ const valueSize = (stringValue.get('wValueLength').value() - 1) * 2;
-// Finds the Strings structures within the VS_VERSIONINFO structure and returns the contents.
-async function getVsVersionInfoStrings(
- fileHandle: fs.promises.FileHandle,
- offset: number,
-): Promise<Map<string, string>> {
- try {
- const stringFileInfoOffset = await getVsVersionInfoChildrenOffset(fileHandle, offset);
+ const szKeyOffset = currentStringOffset + stringValue.size;
+ const { value: szKey, endOffset } = await this.readString(fileHandle, szKeyOffset, 'ucs2');
- const stringTableOffset = await getChildrenOffset(
- fileHandle,
- stringFileInfoOffset,
- STRING_FILE_INFO,
- (szKey) => szKey === 'StringFileInfo',
- );
- const stringTable = await Value.fromFile(fileHandle, stringTableOffset, STRING_TABLE);
- const stringTableLength = stringTable.get<PrimitiveValue>('wLength').value();
+ const valueOffset = this.alignDword(endOffset);
+ // Some programs specify the value size in bytes instead of words resulting in reading double
+ // the length. To make sure we don't read beyond the end offset we calculate the max size to
+ // read. The last value is the null termination character.
+ const calculatedValueMaxSize = structSize - (valueOffset - currentStringOffset) - 2;
+ const valueReadSize = Math.min(valueSize, calculatedValueMaxSize);
- const stringsOffset = await getChildrenOffset(
- fileHandle,
- stringTableOffset,
- STRING_TABLE,
- (szKey) => szKey.substr(4).toLowerCase() === '04b0',
- );
+ const { buffer } = await fileHandle.read(
+ Buffer.alloc(valueReadSize),
+ 0,
+ valueReadSize,
+ valueOffset,
+ );
+ const value = buffer.toString('ucs2');
- const strings = await parseStrings(
- fileHandle,
- stringsOffset,
- stringTableOffset + stringTableLength,
- );
+ strings.set(szKey, value);
+ currentStringOffset += this.alignDword(stringValue.get<PrimitiveValue>('wLength').value());
+ }
return strings;
- } catch {
- return new Map();
}
-}
-
-// Loops through the list of strings and returns a map with the contents.
-async function parseStrings(
- fileHandle: fs.promises.FileHandle,
- stringsOffset: number,
- stringTableEnd: number,
-): Promise<Map<string, string>> {
- const strings = new Map<string, string>();
- let currentStringOffset = stringsOffset;
- while (currentStringOffset < stringTableEnd) {
- const stringValue = await Value.fromFile(fileHandle, currentStringOffset, STRING_TABLE_STRING);
- const structSize = stringValue.get('wLength').value();
- const valueSize = (stringValue.get('wValueLength').value() - 1) * 2;
+ private async getResourceTreeLeafValueOffset(
+ fileHandle: fs.promises.FileHandle,
+ offset: number,
+ rvaToOffset: (rva: number) => Promise<number>,
+ ): Promise<number> {
+ const leaf = await Value.fromFile(fileHandle, offset, IMAGE_RESOURCE_DIRECTORY_DATA_ENTRY);
+ const valueRva = leaf.get<PrimitiveValue>('DataRVA').value();
+ const valueOffset = await rvaToOffset(valueRva);
- const szKeyOffset = currentStringOffset + stringValue.size;
- const { value: szKey, endOffset } = await readString(fileHandle, szKeyOffset, 'ucs2');
-
- const valueOffset = alignDword(endOffset);
- // Some programs specify the value size in bytes instead of words resulting in reading double
- // the length. To make sure we don't read beyond the end offset we calculate the max size to
- // read. The last value is the null termination character.
- const calculatedValueMaxSize = structSize - (valueOffset - currentStringOffset) - 2;
- const valueReadSize = Math.min(valueSize, calculatedValueMaxSize);
+ return valueOffset;
+ }
- const { buffer } = await fileHandle.read(
- Buffer.alloc(valueReadSize),
- 0,
- valueReadSize,
- valueOffset,
+ // Finds the offset to the Children field in the VS_VERSIONINFO structure.
+ private async getVsVersionInfoChildrenOffset(fileHandle: fs.promises.FileHandle, offset: number) {
+ const valueValueOffset = await this.getChildrenOffset(
+ fileHandle,
+ offset,
+ VS_VERSIONINFO,
+ (szKey) => szKey === 'VS_VERSION_INFO',
);
- const value = buffer.toString('ucs2');
+ const versionInfo = await Value.fromFile(fileHandle, offset, VS_VERSIONINFO);
+ const versionInfoValueLength = versionInfo.get<PrimitiveValue>('wValueLength').value();
+ const valuePadding2Offset = valueValueOffset + versionInfoValueLength;
+ const valueChildrenOffset = this.alignDword(valuePadding2Offset);
- strings.set(szKey, value);
- currentStringOffset += alignDword(stringValue.get<PrimitiveValue>('wLength').value());
+ return valueChildrenOffset;
}
- return strings;
-}
-
-async function getResourceTreeLeafValueOffset(
- fileHandle: fs.promises.FileHandle,
- offset: number,
- rvaToOffset: (rva: number) => Promise<number>,
-): Promise<number> {
- const leaf = await Value.fromFile(fileHandle, offset, IMAGE_RESOURCE_DIRECTORY_DATA_ENTRY);
- const valueRva = leaf.get<PrimitiveValue>('DataRVA').value();
- const valueOffset = await rvaToOffset(valueRva);
-
- return valueOffset;
-}
-
-// Finds the offset to the Children field in the VS_VERSIONINFO structure.
-async function getVsVersionInfoChildrenOffset(fileHandle: fs.promises.FileHandle, offset: number) {
- const valueValueOffset = await getChildrenOffset(
- fileHandle,
- offset,
- VS_VERSIONINFO,
- (szKey) => szKey === 'VS_VERSION_INFO',
- );
- const versionInfo = await Value.fromFile(fileHandle, offset, VS_VERSIONINFO);
- const versionInfoValueLength = versionInfo.get<PrimitiveValue>('wValueLength').value();
- const valuePadding2Offset = valueValueOffset + versionInfoValueLength;
- const valueChildrenOffset = alignDword(valuePadding2Offset);
-
- return valueChildrenOffset;
-}
+ // Finds the offset to the Children field in any of the STRING_FILE_INFO, STRING_TABLE and
+ // STRING_TABLE_STRING structures.
+ private async getChildrenOffset(
+ fileHandle: fs.promises.FileHandle,
+ offset: number,
+ datatype: StructWrapper,
+ validateSzKey?: (szKey: string) => boolean,
+ ) {
+ const szKeyOffset = offset + Value.sizeOf(datatype);
+ const { value, endOffset } = await this.readString(fileHandle, szKeyOffset, 'ucs2');
+ if (validateSzKey && !validateSzKey(value)) {
+ throw new Error(`Invalid szKey "${value}"`);
+ }
-// Finds the offset to the Children field in any of the STRING_FILE_INFO, STRING_TABLE and
-// STRING_TABLE_STRING structures.
-async function getChildrenOffset(
- fileHandle: fs.promises.FileHandle,
- offset: number,
- datatype: StructWrapper,
- validateSzKey?: (szKey: string) => boolean,
-) {
- const szKeyOffset = offset + Value.sizeOf(datatype);
- const { value, endOffset } = await readString(fileHandle, szKeyOffset, 'ucs2');
- if (validateSzKey && !validateSzKey(value)) {
- throw new Error(`Invalid szKey "${value}"`);
+ return this.alignDword(endOffset);
}
- return alignDword(endOffset);
-}
-
-function alignDword(offset: number): number {
- return Math.ceil(offset / 4) * 4;
+ private alignDword(offset: number): number {
+ return Math.ceil(offset / 4) * 4;
+ }
}
diff --git a/gui/src/renderer/app.tsx b/gui/src/renderer/app.tsx
index 92c4cb5698..3aa73698dc 100644
--- a/gui/src/renderer/app.tsx
+++ b/gui/src/renderer/app.tsx
@@ -4,7 +4,10 @@ import { bindActionCreators } from 'redux';
import { StyleSheetManager } from 'styled-components';
import { closeToExpiry, hasExpired } from '../shared/account-expiry';
-import { ILinuxSplitTunnelingApplication, IWindowsApplication } from '../shared/application-types';
+import {
+ ILinuxSplitTunnelingApplication,
+ ISplitTunnelingApplication,
+} from '../shared/application-types';
import {
AccessMethodSetting,
AccountToken,
@@ -185,7 +188,7 @@ export default class AppRenderer {
this.storeAutoStart(autoStart);
});
- IpcRendererEventChannel.windowsSplitTunneling.listen((applications: IWindowsApplication[]) => {
+ IpcRendererEventChannel.splitTunneling.listen((applications: ISplitTunnelingApplication[]) => {
this.reduxActions.settings.setSplitTunnelingApplications(applications);
});
@@ -242,6 +245,7 @@ export default class AppRenderer {
this.storeAutoStart(initialState.autoStart);
this.setChangelog(initialState.changelog, initialState.forceShowChanges);
this.setCurrentApiAccessMethod(initialState.currentApiAccessMethod);
+ this.reduxActions.userInterface.setIsMacOs13OrNewer(initialState.isMacOs13OrNewer);
if (initialState.macOsScrollbarVisibility !== undefined) {
this.reduxActions.userInterface.setMacOsScrollbarVisibility(
@@ -258,9 +262,9 @@ export default class AppRenderer {
this.checkContentHeight(true);
});
- if (initialState.windowsSplitTunnelingApplications) {
+ if (initialState.splitTunnelingApplications) {
this.reduxActions.settings.setSplitTunnelingApplications(
- initialState.windowsSplitTunnelingApplications,
+ initialState.splitTunnelingApplications,
);
}
@@ -334,11 +338,11 @@ export default class AppRenderer {
public launchExcludedApplication = (application: ILinuxSplitTunnelingApplication | string) =>
IpcRendererEventChannel.linuxSplitTunneling.launchApplication(application);
public setSplitTunnelingState = (state: boolean) =>
- IpcRendererEventChannel.windowsSplitTunneling.setState(state);
- public addSplitTunnelingApplication = (application: string | IWindowsApplication) =>
- IpcRendererEventChannel.windowsSplitTunneling.addApplication(application);
- public forgetManuallyAddedSplitTunnelingApplication = (application: IWindowsApplication) =>
- IpcRendererEventChannel.windowsSplitTunneling.forgetManuallyAddedApplication(application);
+ IpcRendererEventChannel.splitTunneling.setState(state);
+ public addSplitTunnelingApplication = (application: string | ISplitTunnelingApplication) =>
+ IpcRendererEventChannel.splitTunneling.addApplication(application);
+ public forgetManuallyAddedSplitTunnelingApplication = (application: ISplitTunnelingApplication) =>
+ IpcRendererEventChannel.splitTunneling.forgetManuallyAddedApplication(application);
public setObfuscationSettings = (obfuscationSettings: ObfuscationSettings) =>
IpcRendererEventChannel.settings.setObfuscationSettings(obfuscationSettings);
public setDaitaSettings = (daitaSettings: IDaitaSettings) =>
@@ -513,12 +517,12 @@ export default class AppRenderer {
return IpcRendererEventChannel.autoStart.set(autoStart);
};
- public getWindowsSplitTunnelingApplications(updateCache = false) {
- return IpcRendererEventChannel.windowsSplitTunneling.getApplications(updateCache);
+ public getSplitTunnelingApplications(updateCache = false) {
+ return IpcRendererEventChannel.splitTunneling.getApplications(updateCache);
}
- public removeSplitTunnelingApplication(application: IWindowsApplication) {
- void IpcRendererEventChannel.windowsSplitTunneling.removeApplication(application);
+ public removeSplitTunnelingApplication(application: ISplitTunnelingApplication) {
+ void IpcRendererEventChannel.splitTunneling.removeApplication(application);
}
public async showLaunchDaemonSettings() {
diff --git a/gui/src/renderer/components/Settings.tsx b/gui/src/renderer/components/Settings.tsx
index 8f68a1c462..34fb3eb0c1 100644
--- a/gui/src/renderer/components/Settings.tsx
+++ b/gui/src/renderer/components/Settings.tsx
@@ -26,8 +26,10 @@ export default function Support() {
const loginState = useSelector((state) => state.account.status);
const connectedToDaemon = useSelector((state) => state.userInterface.connectedToDaemon);
+ const isMacOs13OrNewer = useSelector((state) => state.userInterface.isMacOs13OrNewer);
const showSubSettings = loginState.type === 'ok' && connectedToDaemon;
+ const showSplitTunneling = window.env.platform !== 'darwin' || isMacOs13OrNewer;
return (
<BackAction action={history.pop}>
@@ -59,7 +61,7 @@ export default function Support() {
<VpnSettingsButton />
</Cell.Group>
- {(window.env.platform === 'linux' || window.env.platform === 'win32') && (
+ {showSplitTunneling && (
<Cell.Group>
<SplitTunnelingButton />
</Cell.Group>
diff --git a/gui/src/renderer/components/SplitTunnelingSettings.tsx b/gui/src/renderer/components/SplitTunnelingSettings.tsx
index cb4de70deb..2cee823b3e 100644
--- a/gui/src/renderer/components/SplitTunnelingSettings.tsx
+++ b/gui/src/renderer/components/SplitTunnelingSettings.tsx
@@ -6,7 +6,7 @@ import { colors, strings } from '../../config.json';
import {
IApplication,
ILinuxSplitTunnelingApplication,
- IWindowsApplication,
+ ISplitTunnelingApplication,
} from '../../shared/application-types';
import { messages } from '../../shared/gettext';
import { useAppContext } from '../context';
@@ -91,10 +91,8 @@ function PlatformSpecificSplitTunnelingSettings(props: IPlatformSplitTunnelingSe
switch (window.env.platform) {
case 'linux':
return <LinuxSplitTunnelingSettings {...props} />;
- case 'win32':
- return <WindowsSplitTunnelingSettings {...props} />;
default:
- throw new Error(`Split tunneling not implemented on ${window.env.platform}`);
+ return <SplitTunnelingSettings {...props} />;
}
}
@@ -300,12 +298,12 @@ function LinuxApplicationRow(props: ILinuxApplicationRowProps) {
);
}
-export function WindowsSplitTunnelingSettings(props: IPlatformSplitTunnelingSettingsProps) {
+export function SplitTunnelingSettings(props: IPlatformSplitTunnelingSettingsProps) {
const {
addSplitTunnelingApplication,
removeSplitTunnelingApplication,
forgetManuallyAddedSplitTunnelingApplication,
- getWindowsSplitTunnelingApplications,
+ getSplitTunnelingApplications,
setSplitTunnelingState,
} = useAppContext();
const splitTunnelingEnabled = useSelector((state: IReduxState) => state.settings.splitTunneling);
@@ -314,13 +312,13 @@ export function WindowsSplitTunnelingSettings(props: IPlatformSplitTunnelingSett
);
const [searchTerm, setSearchTerm] = useState('');
- const [applications, setApplications] = useState<IWindowsApplication[]>();
+ const [applications, setApplications] = useState<ISplitTunnelingApplication[]>();
useAsyncEffect(async () => {
- const { fromCache, applications } = await getWindowsSplitTunnelingApplications();
+ const { fromCache, applications } = await getSplitTunnelingApplications();
setApplications(applications);
if (fromCache) {
- const { applications } = await getWindowsSplitTunnelingApplications(true);
+ const { applications } = await getSplitTunnelingApplications(true);
setApplications(applications);
}
}, []);
@@ -345,7 +343,7 @@ export function WindowsSplitTunnelingSettings(props: IPlatformSplitTunnelingSett
}, [applications, splitTunnelingApplications, searchTerm]);
const addApplication = useCallback(
- async (application: IWindowsApplication | string) => {
+ async (application: ISplitTunnelingApplication | string) => {
if (!splitTunnelingEnabled) {
await setSplitTunnelingState(true);
}
@@ -354,26 +352,26 @@ export function WindowsSplitTunnelingSettings(props: IPlatformSplitTunnelingSett
[addSplitTunnelingApplication, splitTunnelingEnabled, setSplitTunnelingState],
);
- const addApplicationAndUpdate = useCallback(
- async (application: IWindowsApplication | string) => {
+ const addBrowsedForApplication = useCallback(
+ async (application: string) => {
await addApplication(application);
- const { applications } = await getWindowsSplitTunnelingApplications();
+ const { applications } = await getSplitTunnelingApplications();
setApplications(applications);
},
- [addApplication, getWindowsSplitTunnelingApplications],
+ [addApplication, getSplitTunnelingApplications],
);
const forgetManuallyAddedApplicationAndUpdate = useCallback(
- async (application: IWindowsApplication) => {
+ async (application: ISplitTunnelingApplication) => {
await forgetManuallyAddedSplitTunnelingApplication(application);
- const { applications } = await getWindowsSplitTunnelingApplications();
+ const { applications } = await getSplitTunnelingApplications();
setApplications(applications);
},
- [forgetManuallyAddedSplitTunnelingApplication, getWindowsSplitTunnelingApplications],
+ [forgetManuallyAddedSplitTunnelingApplication, getSplitTunnelingApplications],
);
const removeApplication = useCallback(
- async (application: IWindowsApplication) => {
+ async (application: ISplitTunnelingApplication) => {
if (!splitTunnelingEnabled) {
await setSplitTunnelingState(true);
}
@@ -385,8 +383,8 @@ export function WindowsSplitTunnelingSettings(props: IPlatformSplitTunnelingSett
const filePickerCallback = useFilePicker(
messages.pgettext('split-tunneling-view', 'Add'),
props.setBrowsing,
- addApplicationAndUpdate,
- { name: 'Executables', extensions: ['exe', 'lnk'] },
+ addBrowsedForApplication,
+ getFilePickerOptionsForPlatform(),
);
const addWithFilePicker = useCallback(async () => {
@@ -395,21 +393,17 @@ export function WindowsSplitTunnelingSettings(props: IPlatformSplitTunnelingSett
}, [filePickerCallback, props.scrollToTop]);
const excludedRowRenderer = useCallback(
- (application: IWindowsApplication) => (
- <WindowsApplicationRow application={application} onRemove={removeApplication} />
+ (application: ISplitTunnelingApplication) => (
+ <ApplicationRow application={application} onRemove={removeApplication} />
),
[removeApplication],
);
const includedRowRenderer = useCallback(
- (application: IWindowsApplication) => {
+ (application: ISplitTunnelingApplication) => {
const onForget = application.deletable ? forgetManuallyAddedApplicationAndUpdate : undefined;
return (
- <WindowsApplicationRow
- application={application}
- onAdd={addApplication}
- onDelete={onForget}
- />
+ <ApplicationRow application={application} onAdd={addApplication} onDelete={onForget} />
);
},
[addApplication, forgetManuallyAddedApplicationAndUpdate],
@@ -452,6 +446,7 @@ export function WindowsSplitTunnelingSettings(props: IPlatformSplitTunnelingSett
<Accordion expanded={showSplitSection}>
<Cell.Section sectionTitle={excludedTitle}>
<ApplicationList
+ data-testid="split-applications"
applications={filteredSplitApplications}
rowRenderer={excludedRowRenderer}
/>
@@ -461,6 +456,7 @@ export function WindowsSplitTunnelingSettings(props: IPlatformSplitTunnelingSett
<Accordion expanded={showNonSplitSection}>
<Cell.Section sectionTitle={allTitle}>
<ApplicationList
+ data-testid="non-split-applications"
applications={filteredNonSplitApplications}
rowRenderer={includedRowRenderer}
/>
@@ -490,6 +486,7 @@ export function WindowsSplitTunnelingSettings(props: IPlatformSplitTunnelingSett
interface IApplicationListProps<T extends IApplication> {
applications: T[] | undefined;
rowRenderer: (application: T) => React.ReactElement;
+ 'data-testid'?: string;
}
function ApplicationList<T extends IApplication>(props: IApplicationListProps<T>) {
@@ -501,8 +498,11 @@ function ApplicationList<T extends IApplication>(props: IApplicationListProps<T>
);
} else {
return (
- <StyledListContainer>
- <List items={props.applications} getKey={applicationGetKey}>
+ <StyledListContainer data-testid={props['data-testid']}>
+ <List
+ data-testid={props['data-testid']}
+ items={props.applications.sort((a, b) => a.name.localeCompare(b.name))}
+ getKey={applicationGetKey}>
{props.rowRenderer}
</List>
</StyledListContainer>
@@ -514,14 +514,14 @@ function applicationGetKey<T extends IApplication>(application: T): string {
return application.absolutepath;
}
-interface IWindowsApplicationRowProps {
- application: IWindowsApplication;
- onAdd?: (application: IWindowsApplication) => void;
- onRemove?: (application: IWindowsApplication) => void;
- onDelete?: (application: IWindowsApplication) => void;
+interface IApplicationRowProps {
+ application: ISplitTunnelingApplication;
+ onAdd?: (application: ISplitTunnelingApplication) => void;
+ onRemove?: (application: ISplitTunnelingApplication) => void;
+ onDelete?: (application: ISplitTunnelingApplication) => void;
}
-function WindowsApplicationRow(props: IWindowsApplicationRowProps) {
+function ApplicationRow(props: IApplicationRowProps) {
const onAdd = useCallback(() => {
props.onAdd?.(props.application);
}, [props.onAdd, props.application]);
@@ -576,3 +576,11 @@ function WindowsApplicationRow(props: IWindowsApplicationRowProps) {
function includesSearchTerm(application: IApplication, searchTerm: string) {
return application.name.toLowerCase().includes(searchTerm.toLowerCase());
}
+
+function getFilePickerOptionsForPlatform():
+ | { name: string; extensions: Array<string> }
+ | undefined {
+ return window.env.platform === 'win32'
+ ? { name: 'Executables', extensions: ['exe', 'lnk'] }
+ : undefined;
+}
diff --git a/gui/src/renderer/components/Switch.tsx b/gui/src/renderer/components/Switch.tsx
index f87bf2a52b..595bc422a2 100644
--- a/gui/src/renderer/components/Switch.tsx
+++ b/gui/src/renderer/components/Switch.tsx
@@ -5,9 +5,7 @@ import { colors } from '../../config.json';
interface IProps {
id?: string;
- // eslint-disable-next-line @typescript-eslint/naming-convention
'aria-labelledby'?: string;
- // eslint-disable-next-line @typescript-eslint/naming-convention
'aria-describedby'?: string;
isOn: boolean;
onChange?: (isOn: boolean) => void;
diff --git a/gui/src/renderer/components/TransitionContainer.tsx b/gui/src/renderer/components/TransitionContainer.tsx
index 9773c64512..3e7007e3c8 100644
--- a/gui/src/renderer/components/TransitionContainer.tsx
+++ b/gui/src/renderer/components/TransitionContainer.tsx
@@ -48,7 +48,6 @@ interface StyledTransitionContentProps {
export const StyledTransitionContent = styled.div.attrs<
StyledTransitionContentProps,
- // eslint-disable-next-line @typescript-eslint/naming-convention
{ 'data-testid': string }
>({
'data-testid': 'transition-content',
diff --git a/gui/src/renderer/components/cell/Selector.tsx b/gui/src/renderer/components/cell/Selector.tsx
index 5e5d685a59..6bfeeb3887 100644
--- a/gui/src/renderer/components/cell/Selector.tsx
+++ b/gui/src/renderer/components/cell/Selector.tsx
@@ -16,7 +16,6 @@ export interface SelectorItem<T> {
label: string;
value: T;
disabled?: boolean;
- // eslint-disable-next-line @typescript-eslint/naming-convention
'data-testid'?: string;
}
@@ -136,7 +135,6 @@ interface SelectorCellProps<T> {
onSelect: (value: T) => void;
children: React.ReactNode | Array<React.ReactNode>;
forwardedRef?: React.Ref<HTMLButtonElement>;
- // eslint-disable-next-line @typescript-eslint/naming-convention
'data-testid'?: string;
}
diff --git a/gui/src/renderer/components/cell/SettingsSelect.tsx b/gui/src/renderer/components/cell/SettingsSelect.tsx
index 4286cda1e2..f0d918ded6 100644
--- a/gui/src/renderer/components/cell/SettingsSelect.tsx
+++ b/gui/src/renderer/components/cell/SettingsSelect.tsx
@@ -87,7 +87,6 @@ interface SettingsSelectProps<T extends string> {
items: Array<SettingsSelectItem<T>>;
onUpdate: (value: T) => void;
direction?: 'down' | 'up';
- // eslint-disable-next-line @typescript-eslint/naming-convention
'data-testid'?: string;
}
diff --git a/gui/src/renderer/redux/settings/actions.ts b/gui/src/renderer/redux/settings/actions.ts
index ba6e4ce5a8..d2a3fb1c4a 100644
--- a/gui/src/renderer/redux/settings/actions.ts
+++ b/gui/src/renderer/redux/settings/actions.ts
@@ -1,4 +1,4 @@
-import { IWindowsApplication } from '../../../shared/application-types';
+import { ISplitTunnelingApplication } from '../../../shared/application-types';
import {
AccessMethodSetting,
ApiAccessMethodSettings,
@@ -100,7 +100,7 @@ export interface IUpdateSplitTunnelingStateAction {
export interface ISetSplitTunnelingApplicationsAction {
type: 'SET_SPLIT_TUNNELING_APPLICATIONS';
- applications: IWindowsApplication[];
+ applications: ISplitTunnelingApplication[];
}
export interface ISetObfuscationSettings {
@@ -281,7 +281,7 @@ function updateSplitTunnelingState(enabled: boolean): IUpdateSplitTunnelingState
}
function setSplitTunnelingApplications(
- applications: IWindowsApplication[],
+ applications: ISplitTunnelingApplication[],
): ISetSplitTunnelingApplicationsAction {
return {
type: 'SET_SPLIT_TUNNELING_APPLICATIONS',
diff --git a/gui/src/renderer/redux/settings/reducers.ts b/gui/src/renderer/redux/settings/reducers.ts
index 18873eea20..eae413fe22 100644
--- a/gui/src/renderer/redux/settings/reducers.ts
+++ b/gui/src/renderer/redux/settings/reducers.ts
@@ -1,5 +1,5 @@
import { getDefaultApiAccessMethods } from '../../../main/default-settings';
-import { IWindowsApplication } from '../../../shared/application-types';
+import { ISplitTunnelingApplication } from '../../../shared/application-types';
import {
AccessMethodSetting,
ApiAccessMethodSettings,
@@ -115,7 +115,7 @@ export interface ISettingsReduxState {
};
dns: IDnsOptions;
splitTunneling: boolean;
- splitTunnelingApplications: IWindowsApplication[];
+ splitTunnelingApplications: ISplitTunnelingApplication[];
obfuscationSettings: ObfuscationSettings;
customLists: CustomLists;
apiAccessMethods: ApiAccessMethodSettings;
diff --git a/gui/src/renderer/redux/userinterface/actions.ts b/gui/src/renderer/redux/userinterface/actions.ts
index cc43990980..238835318e 100644
--- a/gui/src/renderer/redux/userinterface/actions.ts
+++ b/gui/src/renderer/redux/userinterface/actions.ts
@@ -56,6 +56,11 @@ export interface ISetSelectLocationView {
selectLocationView: LocationType;
}
+export interface ISetIsMacOs13OrNewer {
+ type: 'SET_IS_MACOS13_OR_NEWER';
+ isMacOs13OrNewer: boolean;
+}
+
export type UserInterfaceAction =
| IUpdateLocaleAction
| IUpdateWindowArrowPositionAction
@@ -67,7 +72,8 @@ export type UserInterfaceAction =
| ISetChangelog
| ISetForceShowChanges
| ISetIsPerformingPostUpgrade
- | ISetSelectLocationView;
+ | ISetSelectLocationView
+ | ISetIsMacOs13OrNewer;
function updateLocale(locale: string): IUpdateLocaleAction {
return {
@@ -147,6 +153,13 @@ function setSelectLocationView(selectLocationView: LocationType): ISetSelectLoca
};
}
+function setIsMacOs13OrNewer(isMacOs13OrNewer: boolean): ISetIsMacOs13OrNewer {
+ return {
+ type: 'SET_IS_MACOS13_OR_NEWER',
+ isMacOs13OrNewer,
+ };
+}
+
export default {
updateLocale,
updateWindowArrowPosition,
@@ -159,4 +172,5 @@ export default {
setForceShowChanges,
setIsPerformingPostUpgrade,
setSelectLocationView,
+ setIsMacOs13OrNewer,
};
diff --git a/gui/src/renderer/redux/userinterface/reducers.ts b/gui/src/renderer/redux/userinterface/reducers.ts
index 622b7814aa..89427e9b06 100644
--- a/gui/src/renderer/redux/userinterface/reducers.ts
+++ b/gui/src/renderer/redux/userinterface/reducers.ts
@@ -15,6 +15,7 @@ export interface IUserInterfaceReduxState {
forceShowChanges: boolean;
isPerformingPostUpgrade: boolean;
selectLocationView: LocationType;
+ isMacOs13OrNewer: boolean;
}
const initialState: IUserInterfaceReduxState = {
@@ -28,6 +29,7 @@ const initialState: IUserInterfaceReduxState = {
forceShowChanges: false,
isPerformingPostUpgrade: false,
selectLocationView: LocationType.exit,
+ isMacOs13OrNewer: true,
};
export default function (
@@ -80,6 +82,12 @@ export default function (
selectLocationView: action.selectLocationView,
};
+ case 'SET_IS_MACOS13_OR_NEWER':
+ return {
+ ...state,
+ isMacOs13OrNewer: action.isMacOs13OrNewer,
+ };
+
default:
return state;
}
diff --git a/gui/src/shared/application-types.ts b/gui/src/shared/application-types.ts
index 5f9fb80fd0..526d994d7b 100644
--- a/gui/src/shared/application-types.ts
+++ b/gui/src/shared/application-types.ts
@@ -6,7 +6,7 @@ export interface IApplication {
icon?: string;
}
-export interface IWindowsApplication extends IApplication {
+export interface ISplitTunnelingApplication extends IApplication {
deletable: boolean;
}
@@ -24,3 +24,37 @@ export interface ILinuxApplication extends IApplication {
export interface ILinuxSplitTunnelingApplication extends ILinuxApplication {
warning?: Warning;
}
+
+export interface ISplitTunnelingAppListRetriever {
+ /**
+ * Returns a list of all applications known to the app.
+ * @param updateCaches Specifies if the application list should be fetched again and merged into the existing cache.
+ */
+ getApplications(
+ updateCaches?: boolean,
+ ): Promise<{ fromCache: boolean; applications: ISplitTunnelingApplication[] }>;
+
+ /**
+ * Returns an object containing information about whether or not it was fetched from the cache,
+ * and a list of ISplitTunnelingApplication corresponding to the provided paths.
+ */
+ getMetadataForApplications(
+ applicationPaths: string[],
+ ): Promise<{ fromCache: boolean; applications: ISplitTunnelingApplication[] }>;
+
+ /**
+ * Resolves the actual executable path when an app is provided. On Windows this resolves links and
+ * on macOS this finds the executable when an application bundle is provided.
+ */
+ resolveExecutablePath(providedPath: string): Promise<string>;
+
+ /**
+ * Adds an application to the internal cache.
+ */
+ addApplicationPathToCache(applicationPath: string): Promise<void>;
+
+ /**
+ * Removes an application from the internal cache.
+ */
+ removeApplicationFromCache(application: ISplitTunnelingApplication): void;
+}
diff --git a/gui/src/shared/ipc-schema.ts b/gui/src/shared/ipc-schema.ts
index 561ec924a0..e33736bcf8 100644
--- a/gui/src/shared/ipc-schema.ts
+++ b/gui/src/shared/ipc-schema.ts
@@ -1,6 +1,6 @@
import { GetTextTranslations } from 'gettext-parser';
-import { ILinuxSplitTunnelingApplication, IWindowsApplication } from './application-types';
+import { ILinuxSplitTunnelingApplication, ISplitTunnelingApplication } from './application-types';
import {
AccessMethodSetting,
AccountDataError,
@@ -71,12 +71,13 @@ export interface IAppStateSnapshot {
upgradeVersion: IAppVersionInfo;
guiSettings: IGuiSettingsState;
translations: ITranslations;
- windowsSplitTunnelingApplications?: IWindowsApplication[];
+ splitTunnelingApplications?: ISplitTunnelingApplication[];
macOsScrollbarVisibility?: MacOsScrollbarVisibility;
changelog: IChangelog;
forceShowChanges: boolean;
navigationHistory?: IHistoryObject;
currentApiAccessMethod?: AccessMethodSetting;
+ isMacOs13OrNewer: boolean;
}
// The different types of requests are:
@@ -238,12 +239,15 @@ export const ipcSchema = {
getApplications: invoke<void, ILinuxSplitTunnelingApplication[]>(),
launchApplication: invoke<ILinuxSplitTunnelingApplication | string, LaunchApplicationResult>(),
},
- windowsSplitTunneling: {
- '': notifyRenderer<IWindowsApplication[]>(),
+ splitTunneling: {
+ '': notifyRenderer<ISplitTunnelingApplication[]>(),
setState: invoke<boolean, void>(),
- getApplications: invoke<boolean, { fromCache: boolean; applications: IWindowsApplication[] }>(),
- addApplication: invoke<IWindowsApplication | string, void>(),
- removeApplication: invoke<IWindowsApplication, void>(),
- forgetManuallyAddedApplication: invoke<IWindowsApplication, void>(),
+ getApplications: invoke<
+ boolean,
+ { fromCache: boolean; applications: ISplitTunnelingApplication[] }
+ >(),
+ addApplication: invoke<ISplitTunnelingApplication | string, void>(),
+ removeApplication: invoke<ISplitTunnelingApplication, void>(),
+ forgetManuallyAddedApplication: invoke<ISplitTunnelingApplication, void>(),
},
};
diff --git a/gui/test/e2e/installed/state-dependent/macos-split-tunneling.spec.ts b/gui/test/e2e/installed/state-dependent/macos-split-tunneling.spec.ts
new file mode 100644
index 0000000000..0f1680bf44
--- /dev/null
+++ b/gui/test/e2e/installed/state-dependent/macos-split-tunneling.spec.ts
@@ -0,0 +1,162 @@
+import { Locator, expect, test } from '@playwright/test';
+import { Page } from 'playwright';
+import { execSync } from 'child_process';
+
+import { startInstalledApp } from '../installed-utils';
+import { TestUtils } from '../../utils';
+import { RoutePath } from '../../../../src/renderer/lib/routes';
+
+// macOS only. This test expects the daemon to be logged in and for split tunneling to be off and
+// have no split applications.
+
+let page: Page;
+let util: TestUtils;
+
+test.beforeAll(async () => {
+ ({ page, util } = await startInstalledApp());
+});
+
+test.afterAll(async () => {
+ await page.close();
+});
+
+async function navigateToSplitTunneling() {
+ await util.waitForNavigation(async () => await page.click('button[aria-label="Settings"]'));
+
+ expect(
+ await util.waitForNavigation(async () => await page.getByText('Split tunneling').click())
+ ).toEqual(RoutePath.splitTunneling);
+
+ const title = page.locator('h1')
+ await expect(title).toHaveText('Split tunneling');
+}
+
+test('App should enable split tunneling', async () => {
+ await navigateToSplitTunneling();
+
+ const toggle = page.getByRole('checkbox');
+ await expect(toggle).not.toBeChecked();
+
+ const splitList = page.getByTestId('split-applications');
+ const nonSplitList = page.getByTestId('non-split-applications');
+
+ await expect(splitList).not.toBeVisible();
+ await expect(nonSplitList).not.toBeVisible();
+
+ const launchPadApp = page.getByText('launchpad');
+ await expect(launchPadApp).not.toBeVisible();
+
+ toggle.click();
+ await expect(toggle).toBeChecked();
+ await expect(splitList).not.toBeVisible();
+ await expect(nonSplitList).toBeVisible();
+ await expect(launchPadApp).toBeVisible();
+ expect(await numberOfApplicationsInList('split-applications')).toBe(0);
+ expect(getDaemonSplitTunnelingApplications()).toHaveLength(0);
+});
+
+test('App should split launchpad', async () => {
+ const splitList = page.getByTestId('split-applications');
+ const nonSplitList = page.getByTestId('non-split-applications');
+
+ const splitLaunchPadApp = splitList.getByText('launchpad');
+ const nonSplitLaunchPadApp = nonSplitList.getByText('launchpad');
+
+ await expect(splitLaunchPadApp).not.toBeVisible();
+ await expect(nonSplitLaunchPadApp).toBeVisible();
+
+ await toggleApplication(nonSplitLaunchPadApp);
+
+ await expect(splitLaunchPadApp).toBeVisible();
+ await expect(nonSplitLaunchPadApp).not.toBeVisible();
+ expect(await numberOfApplicationsInList('split-applications')).toBe(1);
+
+ const daemonSplitTunnelingApplications = getDaemonSplitTunnelingApplications();
+ expect(daemonSplitTunnelingApplications).toHaveLength(1);
+ expect(isSplitInDaemon('launchpad')).toBeTruthy();
+});
+
+test('App should split clock', async () => {
+ const splitList = page.getByTestId('split-applications');
+ const nonSplitList = page.getByTestId('non-split-applications');
+
+ const splitClockApp = splitList.getByText('clock');
+ const nonSplitClockApp = nonSplitList.getByText('clock');
+
+ await expect(splitClockApp).not.toBeVisible();
+ await expect(nonSplitClockApp).toBeVisible();
+
+ await toggleApplication(nonSplitClockApp);
+
+ await expect(splitClockApp).toBeVisible();
+ await expect(nonSplitClockApp).not.toBeVisible();
+ expect(await numberOfApplicationsInList('split-applications')).toBe(2);
+
+ const daemonSplitTunnelingApplications = getDaemonSplitTunnelingApplications();
+ expect(daemonSplitTunnelingApplications).toHaveLength(2);
+ expect(isSplitInDaemon('launchpad')).toBeTruthy();
+ expect(isSplitInDaemon('clock')).toBeTruthy();
+});
+
+test('App should unsplit launchpad', async () => {
+ const splitList = page.getByTestId('split-applications');
+ const nonSplitList = page.getByTestId('non-split-applications');
+
+ const splitLaunchPadApp = splitList.getByText('launchpad');
+ const nonSplitLaunchPadApp = nonSplitList.getByText('launchpad');
+
+ await expect(splitLaunchPadApp).toBeVisible();
+ await expect(nonSplitLaunchPadApp).not.toBeVisible();
+
+ await toggleApplication(splitLaunchPadApp);
+
+ await expect(splitLaunchPadApp).not.toBeVisible();
+ await expect(nonSplitLaunchPadApp).toBeVisible();
+ expect(await numberOfApplicationsInList('split-applications')).toBe(1);
+
+ const daemonSplitTunnelingApplications = getDaemonSplitTunnelingApplications();
+ expect(daemonSplitTunnelingApplications).toHaveLength(1);
+ expect(isSplitInDaemon('launchpad')).toBeFalsy();
+ expect(isSplitInDaemon('clock')).toBeTruthy();
+});
+
+test('App should disable split tunneling', async () => {
+ const toggle = page.getByRole('checkbox');
+ await expect(toggle).toBeChecked();
+
+ const splitList = page.getByTestId('split-applications');
+ const nonSplitList = page.getByTestId('non-split-applications');
+
+ await expect(splitList).toBeVisible();
+ await expect(nonSplitList).toBeVisible();
+
+ const launchPadApp = page.getByText('launchpad');
+ await expect(launchPadApp).toBeVisible();
+
+ toggle.click();
+ await expect(toggle).not.toBeChecked();
+});
+
+async function toggleApplication(applicationLocator: Locator) {
+ await applicationLocator.locator('~ div').click();
+}
+
+async function numberOfApplicationsInList(listTestid: string) {
+ const list = page.getByTestId(listTestid);
+ const listHidden = await list.isHidden();
+ if (listHidden) {
+ return 0;
+ }
+
+ return await list.locator('button').count();
+}
+
+function getDaemonSplitTunnelingApplications() {
+ const output = execSync('mullvad split-tunnel get').toString().trim().split('\n');
+ return output.slice(output.indexOf('Excluded applications:') + 1);
+}
+
+function isSplitInDaemon(app: string): boolean {
+ return !!getDaemonSplitTunnelingApplications()
+ .find((splitApp) => splitApp.toLowerCase().includes(app));
+}
diff --git a/gui/test/e2e/setup/main.ts b/gui/test/e2e/setup/main.ts
index 78da7f59b5..0dd8a4f1f0 100644
--- a/gui/test/e2e/setup/main.ts
+++ b/gui/test/e2e/setup/main.ts
@@ -168,12 +168,13 @@ class ApplicationMain {
upgradeVersion: this.upgradeVersion,
guiSettings: this.guiSettings,
translations: this.translations,
- windowsSplitTunnelingApplications: [],
+ splitTunnelingApplications: [],
macOsScrollbarVisibility: MacOsScrollbarVisibility.whenScrolling,
changelog: [],
forceShowChanges: false,
navigationHistory: undefined,
scrollPositions: {},
+ isMacOs13OrNewer: true,
}));
IpcMainEventChannel.guiSettings.handleSetPreferredLocale((locale) => {