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 /gui/src/main | |
| parent | b6db0f3487d5096e08b077c3d806c96b50aecf35 (diff) | |
| parent | 58556c0da8a4f3abc441d18c9dd0c026b1986dfb (diff) | |
| download | mullvadvpn-2b04fed8d6a486d97af47f1add45b0eeb1071db8.tar.xz mullvadvpn-2b04fed8d6a486d97af47f1add45b0eeb1071db8.zip | |
Merge branch 'add-macos-split-tunneling-gui-des-786'
Diffstat (limited to 'gui/src/main')
| -rw-r--r-- | gui/src/main/index.ts | 74 | ||||
| -rw-r--r-- | gui/src/main/linux-split-tunneling.ts | 1 | ||||
| -rw-r--r-- | gui/src/main/macos-split-tunneling.ts | 333 | ||||
| -rw-r--r-- | gui/src/main/platform-version.ts | 5 | ||||
| -rw-r--r-- | gui/src/main/windows-split-tunneling.ts | 976 |
5 files changed, 890 insertions, 499 deletions
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; + } } |
