diff options
| author | Oskar Nyberg <oskar@mullvad.net> | 2024-05-28 15:41:34 +0200 |
|---|---|---|
| committer | Oskar Nyberg <oskar@mullvad.net> | 2024-05-28 15:41:34 +0200 |
| commit | 2b04fed8d6a486d97af47f1add45b0eeb1071db8 (patch) | |
| tree | 0d53951c8d6939526d41173269cba9d57e58b611 | |
| parent | b6db0f3487d5096e08b077c3d806c96b50aecf35 (diff) | |
| parent | 58556c0da8a4f3abc441d18c9dd0c026b1986dfb (diff) | |
| download | mullvadvpn-2b04fed8d6a486d97af47f1add45b0eeb1071db8.tar.xz mullvadvpn-2b04fed8d6a486d97af47f1add45b0eeb1071db8.zip | |
Merge branch 'add-macos-split-tunneling-gui-des-786'
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) => { |
