diff --git a/i18n/en.json b/i18n/en.json index e79695b0a89..b0a726a4c23 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -2,6 +2,7 @@ "app.menus.contextMenu.copyEmailAddress": "Copy Email Address", "app.menus.contextMenu.openInNewTab": "Open in new tab", "app.menus.contextMenu.openInNewWindow": "Open in new window", + "app.menus.contextMenu.openLinkInBrowser": "Open Link in Browser", "app.navigationManager.invalidLinkDescription": "The link you clicked appears to be malformed and cannot be opened. Please check the URL for errors before trying again.", "app.navigationManager.invalidLinkTitle": "Invalid Link", "app.navigationManager.viewLimitReached": "View limit reached", diff --git a/src/app/views/MattermostWebContentsView.ts b/src/app/views/MattermostWebContentsView.ts index 0393cdd5d60..a39d4cbab4f 100644 --- a/src/app/views/MattermostWebContentsView.ts +++ b/src/app/views/MattermostWebContentsView.ts @@ -26,20 +26,20 @@ import { import type {Logger} from 'common/log'; import ServerManager from 'common/servers/serverManager'; import {RELOAD_INTERVAL, MAX_SERVER_RETRIES, SECOND, MAX_LOADING_SCREEN_SECONDS} from 'common/utils/constants'; -import {isInternalURL, parseURL} from 'common/utils/url'; +import {isHttpLink, isInternalURL, parseURL} from 'common/utils/url'; import {type MattermostView} from 'common/views/MattermostView'; import ViewManager from 'common/views/viewManager'; import {updateServerInfos} from 'main/app/utils'; +import ExternalBrowserManager from 'main/browserManager'; +import ContextMenu from 'main/contextMenu'; import DeveloperMode from 'main/developerMode'; import {localizeMessage} from 'main/i18nManager'; import performanceMonitor from 'main/performanceMonitor'; import {getServerAPI} from 'main/server/serverAPI'; +import {getWindowBoundaries, getLocalPreload, composeUserAgent} from 'main/utils'; import WebContentsEventManager from './webContentEvents'; -import ContextMenu from '../../main/contextMenu'; -import {getWindowBoundaries, getLocalPreload, composeUserAgent} from '../../main/utils'; - enum Status { LOADING, READY, @@ -497,7 +497,11 @@ export class MattermostWebContentsView extends EventEmitter { return { append: (_, parameters) => { const parsedURL = parseURL(parameters.linkURL); - if (parsedURL && isInternalURL(parsedURL, server.url)) { + if (!parsedURL) { + return []; + } + + if (this.isURLForConfiguredServer(parsedURL)) { return [ { type: 'separator' as const, @@ -518,8 +522,36 @@ export class MattermostWebContentsView extends EventEmitter { }, ]; } - return []; + + return this.generateOpenInBrowserMenuItems(parsedURL.toString()); }, }; }; + + private generateOpenInBrowserMenuItems = (url: string): Electron.MenuItemConstructorOptions[] => { + const browsers = ExternalBrowserManager.getCachedBrowsers(); + if (browsers.length === 0) { + return []; + } + + // Only allow http/https URLs to be opened in external browsers + if (!isHttpLink(url)) { + return []; + } + + return [ + {type: 'separator'}, + { + label: localizeMessage('app.menus.contextMenu.openLinkInBrowser', 'Open Link in Browser'), + submenu: browsers.map((browser) => ({ + label: browser.name, + click: () => ExternalBrowserManager.openLinkInBrowser(url, browser), + })), + }, + ]; + }; + + private isURLForConfiguredServer = (url: URL): boolean => { + return ServerManager.getAllServers().some((s) => isInternalURL(url, s.url)); + }; } diff --git a/src/main/app/initialize.ts b/src/main/app/initialize.ts index ab6632efb18..ee7d608a90e 100644 --- a/src/main/app/initialize.ts +++ b/src/main/app/initialize.ts @@ -49,6 +49,7 @@ import {ipcValidate} from 'common/Validator'; import ViewManager from 'common/views/viewManager'; import AppVersionManager from 'main/AppVersionManager'; import AutoLauncher from 'main/AutoLauncher'; +import ExternalBrowserManager from 'main/browserManager'; import {configPath, updatePaths} from 'main/constants'; import CriticalErrorHandler from 'main/CriticalErrorHandler'; import DeveloperMode from 'main/developerMode'; @@ -327,6 +328,7 @@ async function initializeAfterAppReady() { handleUpdateTheme(); } + ExternalBrowserManager.init(); MainWindow.show(); const updateServerInfo = (serverId: string) => { diff --git a/src/main/browserManager.test.js b/src/main/browserManager.test.js new file mode 100644 index 00000000000..6aca4808638 --- /dev/null +++ b/src/main/browserManager.test.js @@ -0,0 +1,375 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. +'use strict'; + +const mockExecFile = jest.fn(); +const mockEnumerateValues = jest.fn(); +jest.mock('child_process', () => ({ + execFile: mockExecFile, +})); + +jest.mock('registry-js', () => ({ + HKEY: { + HKEY_LOCAL_MACHINE: 'HKEY_LOCAL_MACHINE', + HKEY_CURRENT_USER: 'HKEY_CURRENT_USER', + }, + enumerateValues: (...args) => mockEnumerateValues(...args), +})); + +const mockExistsSync = jest.fn(() => true); +jest.mock('fs', () => ({ + ...jest.requireActual('fs'), + existsSync: (...args) => mockExistsSync(...args), +})); + +jest.mock('electron', () => ({ + shell: { + openExternal: jest.fn(), + }, +})); + +jest.mock('common/log', () => ({ + Logger: jest.fn().mockImplementation(() => ({ + debug: jest.fn(), + error: jest.fn(), + })), +})); + +function resolveExecCallback(opts, callback) { + if (typeof opts === 'function') { + return opts; + } + return callback; +} + +function mockExecFileResolve() { + mockExecFile.mockImplementation((file, args, opts, callback) => { + const cb = resolveExecCallback(opts, callback); + if (typeof cb === 'function') { + cb(null, {stdout: '', stderr: ''}); + } + }); +} + +function mockExecFileReject(error) { + mockExecFile.mockImplementation((file, args, opts, callback) => { + const cb = resolveExecCallback(opts, callback); + if (typeof cb === 'function') { + cb(error); + } + }); +} + +describe('main/browserManager', () => { + let getInstalledBrowsers; + let openLinkInBrowser; + let clearBrowserCache; + const originalPlatform = process.platform; + + beforeEach(() => { + jest.clearAllMocks(); + jest.resetModules(); + mockExistsSync.mockReturnValue(true); + mockEnumerateValues.mockReturnValue([]); + + const browserManager = require('./browserManager').default; + getInstalledBrowsers = browserManager.getInstalledBrowsers; + openLinkInBrowser = browserManager.openLinkInBrowser; + clearBrowserCache = browserManager.clearBrowserCache; + clearBrowserCache(); + }); + + afterEach(() => { + Object.defineProperty(process, 'platform', {value: originalPlatform}); + }); + + describe('getInstalledBrowsers - macOS', () => { + beforeEach(() => { + Object.defineProperty(process, 'platform', {value: 'darwin'}); + }); + + it('should detect installed browsers via mdfind', async () => { + mockExecFile.mockImplementation((file, args, opts, callback) => { + const cb = resolveExecCallback(opts, callback); + if (typeof cb !== 'function') { + return; + } + + const query = args[0]; + if (file === 'mdfind' && query.includes('com.google.Chrome')) { + cb(null, {stdout: '/Applications/Google Chrome.app\n', stderr: ''}); + return; + } + if (file === 'mdfind' && query.includes('com.apple.Safari')) { + cb(null, {stdout: '/Applications/Safari.app\n', stderr: ''}); + return; + } + + cb(new Error('not found')); + }); + + const browsers = await getInstalledBrowsers(); + expect(browsers.length).toBe(2); + expect(browsers[0].name).toBe('Safari'); + expect(browsers[0].executable).toBe('open'); + expect(browsers[0].args).toEqual(['-b', 'com.apple.Safari']); + expect(browsers[1].name).toBe('Google Chrome'); + expect(browsers[1].executable).toBe('open'); + expect(browsers[1].args).toEqual(['-b', 'com.google.Chrome']); + }); + + it('should handle mdfind errors gracefully', async () => { + mockExecFileReject(new Error('mdfind failed')); + + const browsers = await getInstalledBrowsers(); + expect(browsers).toEqual([]); + }); + }); + + describe('getInstalledBrowsers - Windows', () => { + beforeEach(() => { + Object.defineProperty(process, 'platform', {value: 'win32'}); + }); + + it('should detect installed browsers via registry', async () => { + mockEnumerateValues.mockImplementation((hive, key) => { + if (hive === 'HKEY_LOCAL_MACHINE' && key.endsWith('Google Chrome\\shell\\open\\command')) { + return [{name: '', data: '"C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe"'}]; + } + if (hive === 'HKEY_LOCAL_MACHINE' && key.endsWith('FIREFOX.EXE\\shell\\open\\command')) { + return [{name: '', data: '"C:\\Program Files\\Mozilla Firefox\\firefox.exe"'}]; + } + return []; + }); + + const browsers = await getInstalledBrowsers(); + expect(browsers.length).toBe(2); + expect(browsers[0].name).toBe('Google Chrome'); + expect(browsers[0].executable).toBe('C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe'); + expect(browsers[0].args).toEqual([]); + expect(browsers[1].name).toBe('Firefox'); + expect(browsers[1].executable).toBe('C:\\Program Files\\Mozilla Firefox\\firefox.exe'); + }); + + it('should skip browsers whose executable does not exist', async () => { + mockEnumerateValues.mockImplementation((hive, key) => { + if (hive === 'HKEY_LOCAL_MACHINE' && key.endsWith('Google Chrome\\shell\\open\\command')) { + return [{name: '', data: '"C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe"'}]; + } + if (hive === 'HKEY_LOCAL_MACHINE' && key.endsWith('FIREFOX.EXE\\shell\\open\\command')) { + return [{name: '', data: '"C:\\Program Files\\Mozilla Firefox\\firefox.exe"'}]; + } + return []; + }); + mockExistsSync.mockImplementation((p) => { + if (p === 'C:\\Program Files\\Mozilla Firefox\\firefox.exe') { + return false; + } + return true; + }); + + const browsers = await getInstalledBrowsers(); + expect(browsers.length).toBe(1); + expect(browsers[0].name).toBe('Google Chrome'); + }); + + it('should check both HKLM and HKCU registries', async () => { + mockEnumerateValues.mockImplementation((hive, key) => { + if (hive === 'HKEY_CURRENT_USER' && key.endsWith('Google Chrome\\shell\\open\\command')) { + return [{name: '', data: '"C:\\Users\\user\\AppData\\Local\\Google\\Chrome\\Application\\chrome.exe"'}]; + } + return []; + }); + + const browsers = await getInstalledBrowsers(); + expect(browsers.length).toBe(1); + expect(browsers[0].name).toBe('Google Chrome'); + }); + + it('should parse registry command with flags, quoted args, and %1 placeholder', async () => { + mockEnumerateValues.mockImplementation((hive, key) => { + if (hive === 'HKEY_LOCAL_MACHINE' && key.endsWith('Google Chrome\\shell\\open\\command')) { + return [{name: '', data: '"C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe" --profile "My Profile" %1'}]; + } + return []; + }); + + const browsers = await getInstalledBrowsers(); + expect(browsers.length).toBe(1); + expect(browsers[0].executable).toBe('C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe'); + expect(browsers[0].args).toEqual(['--profile', 'My Profile']); + }); + + it('should filter out quoted placeholders like "%1"', async () => { + mockEnumerateValues.mockImplementation((hive, key) => { + if (hive === 'HKEY_LOCAL_MACHINE' && key.endsWith('Google Chrome\\shell\\open\\command')) { + return [{name: '', data: '"C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe" --single-argument "%1"'}]; + } + return []; + }); + + const browsers = await getInstalledBrowsers(); + expect(browsers.length).toBe(1); + expect(browsers[0].executable).toBe('C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe'); + expect(browsers[0].args).toEqual(['--single-argument']); + }); + + it('should parse unquoted registry command path', async () => { + mockEnumerateValues.mockImplementation((hive, key) => { + if (hive === 'HKEY_LOCAL_MACHINE' && key.endsWith('Google Chrome\\shell\\open\\command')) { + return [{name: '', data: 'C:\\Chrome\\chrome.exe'}]; + } + return []; + }); + + const browsers = await getInstalledBrowsers(); + expect(browsers.length).toBe(1); + expect(browsers[0].executable).toBe('C:\\Chrome\\chrome.exe'); + expect(browsers[0].args).toEqual([]); + }); + + it('should handle registry errors gracefully', async () => { + mockEnumerateValues.mockImplementation(() => { + throw new Error('registry not found'); + }); + + const browsers = await getInstalledBrowsers(); + expect(browsers).toEqual([]); + }); + }); + + describe('getInstalledBrowsers - Linux', () => { + beforeEach(() => { + Object.defineProperty(process, 'platform', {value: 'linux'}); + }); + + it('should detect installed browsers via which', async () => { + mockExecFile.mockImplementation((file, args, opts, callback) => { + const cb = resolveExecCallback(opts, callback); + if (typeof cb !== 'function') { + return; + } + + if (file !== 'which') { + cb(new Error('unexpected command')); + return; + } + + if (args[0] === 'firefox') { + cb(null, {stdout: '/usr/bin/firefox\n', stderr: ''}); + return; + } + if (args[0] === 'google-chrome') { + cb(null, {stdout: '/usr/bin/google-chrome\n', stderr: ''}); + return; + } + + cb(new Error('not found')); + }); + + const browsers = await getInstalledBrowsers(); + expect(browsers.length).toBe(2); + expect(browsers[0].name).toBe('Firefox'); + expect(browsers[0].executable).toBe('/usr/bin/firefox'); + expect(browsers[0].args).toEqual([]); + expect(browsers[1].name).toBe('Google Chrome'); + expect(browsers[1].executable).toBe('/usr/bin/google-chrome'); + }); + + it('should handle which errors gracefully', async () => { + mockExecFile.mockImplementation((file, args, opts, callback) => { + const cb = resolveExecCallback(opts, callback); + if (typeof cb === 'function') { + cb(new Error('not found')); + } + }); + + const browsers = await getInstalledBrowsers(); + expect(browsers).toEqual([]); + }); + }); + + describe('getInstalledBrowsers - unsupported platform', () => { + it('should return empty array', async () => { + Object.defineProperty(process, 'platform', {value: 'freebsd'}); + const browsers = await getInstalledBrowsers(); + expect(browsers).toEqual([]); + }); + }); + + describe('caching', () => { + it('should cache browser results across calls', async () => { + Object.defineProperty(process, 'platform', {value: 'darwin'}); + mockExecFile.mockImplementation((file, args, opts, callback) => { + const cb = resolveExecCallback(opts, callback); + if (typeof cb !== 'function') { + return; + } + + if (file === 'mdfind' && args[0].includes('com.apple.Safari')) { + cb(null, {stdout: '/Applications/Safari.app\n', stderr: ''}); + return; + } + + cb(new Error('not found')); + }); + + await getInstalledBrowsers(); + mockExecFile.mockClear(); + const browsers = await getInstalledBrowsers(); + expect(mockExecFile).not.toHaveBeenCalled(); + expect(browsers.length).toBe(1); + }); + }); + + describe('openLinkInBrowser', () => { + it('should open link using execFile with browser executable and args', async () => { + mockExecFileResolve(); + + const browser = {name: 'Google Chrome', executable: 'open', args: ['-b', 'com.google.Chrome']}; + await openLinkInBrowser('https://example.com', browser); + + expect(mockExecFile).toHaveBeenCalledWith( + 'open', + ['-b', 'com.google.Chrome', 'https://example.com'], + {timeout: 10000}, + expect.any(Function), + ); + }); + + it('should pass URL as argument without shell interpolation', async () => { + mockExecFileResolve(); + + const browser = {name: 'Firefox', executable: '/usr/bin/firefox', args: []}; + const maliciousUrl = 'https://example.com/$(whoami)'; + await openLinkInBrowser(maliciousUrl, browser); + + expect(mockExecFile).toHaveBeenCalledWith( + '/usr/bin/firefox', + ['https://example.com/$(whoami)'], + {timeout: 10000}, + expect.any(Function), + ); + }); + + it('should fall back to shell.openExternal on failure', async () => { + const {shell} = require('electron'); + shell.openExternal.mockResolvedValue(); + mockExecFileReject(new Error('App not found')); + + const browser = {name: 'Chrome', executable: 'open', args: ['-b', 'com.google.Chrome']}; + await openLinkInBrowser('https://example.com', browser); + + expect(shell.openExternal).toHaveBeenCalledWith('https://example.com'); + }); + + it('should catch shell.openExternal rejection gracefully', async () => { + const {shell} = require('electron'); + shell.openExternal.mockRejectedValue(new Error('openExternal failed')); + mockExecFileReject(new Error('App not found')); + + const browser = {name: 'Chrome', executable: 'open', args: ['-b', 'com.google.Chrome']}; + await expect(openLinkInBrowser('https://example.com', browser)).resolves.toBeUndefined(); + }); + }); +}); diff --git a/src/main/browserManager.ts b/src/main/browserManager.ts new file mode 100644 index 00000000000..33011a344f9 --- /dev/null +++ b/src/main/browserManager.ts @@ -0,0 +1,271 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {execFile as execFileOriginal} from 'child_process'; +import fs from 'fs'; +import {promisify} from 'util'; + +import {shell} from 'electron'; +import type {RegistryValue} from 'registry-js'; +import {HKEY, enumerateValues} from 'registry-js'; + +import {Logger} from 'common/log'; + +const execFile = promisify(execFileOriginal); +const log = new Logger('ExternalBrowserManager'); + +export interface BrowserInfo { + name: string; + executable: string; + args: string[]; +} + +// macOS: known browsers with their bundle identifiers (safe — hardcoded values only) +const KNOWN_MACOS_BROWSERS: Array<{name: string; bundleId: string}> = [ + {name: 'Safari', bundleId: 'com.apple.Safari'}, + {name: 'Google Chrome', bundleId: 'com.google.Chrome'}, + {name: 'Firefox', bundleId: 'org.mozilla.firefox'}, + {name: 'Microsoft Edge', bundleId: 'com.microsoft.edgemac'}, + {name: 'Brave Browser', bundleId: 'com.brave.Browser'}, + {name: 'Arc', bundleId: 'company.thebrowser.Browser'}, + {name: 'Opera', bundleId: 'com.operasoftware.Opera'}, + {name: 'Vivaldi', bundleId: 'com.vivaldi.Vivaldi'}, + {name: 'Chromium', bundleId: 'org.chromium.Chromium'}, + {name: 'Orion', bundleId: 'com.kagi.kagimacOS'}, + {name: 'Zen Browser', bundleId: 'app.zen.browser'}, +]; + +// Windows: known browsers with their registry keys under HKLM\SOFTWARE\Clients\StartMenuInternet +const KNOWN_WINDOWS_BROWSERS: Array<{name: string; registryKey: string}> = [ + {name: 'Google Chrome', registryKey: 'Google Chrome'}, + {name: 'Firefox', registryKey: 'FIREFOX.EXE'}, + {name: 'Microsoft Edge', registryKey: 'Microsoft Edge'}, + {name: 'Brave Browser', registryKey: 'Brave'}, + {name: 'Opera', registryKey: 'OperaStable'}, + {name: 'Vivaldi', registryKey: 'Vivaldi'}, + {name: 'Arc', registryKey: 'Arc'}, +]; + +const WINDOWS_REGISTRY_PATH = 'SOFTWARE\\Clients\\StartMenuInternet'; +const WINDOWS_REGISTRY_ROOTS = [ + HKEY.HKEY_LOCAL_MACHINE, + HKEY.HKEY_CURRENT_USER, +]; + +// Linux: known browsers with their executable names +const KNOWN_LINUX_BROWSERS: Array<{name: string; commands: string[]}> = [ + {name: 'Firefox', commands: ['firefox', 'firefox-esr']}, + {name: 'Google Chrome', commands: ['google-chrome', 'google-chrome-stable']}, + {name: 'Chromium', commands: ['chromium', 'chromium-browser']}, + {name: 'Microsoft Edge', commands: ['microsoft-edge', 'microsoft-edge-stable']}, + {name: 'Brave Browser', commands: ['brave-browser', 'brave-browser-stable']}, + {name: 'Vivaldi', commands: ['vivaldi', 'vivaldi-stable']}, + {name: 'Opera', commands: ['opera']}, + {name: 'Zen Browser', commands: ['zen-browser']}, +]; + +const stripWrappingQuotes = (token: string) => token.replace(/^["']|["']$/g, ''); + +const tokenizeCommand = (value: string): string[] => { + return (value.match(/"[^"]*"|'[^']*'|\S+/g) || []).map(stripWrappingQuotes); +}; + +/** + * Parse a Windows registry command string (e.g. from shell\open\command). + * The value may be a quoted path with flags and a %1 placeholder, e.g.: + * "C:\Program Files\Browser\browser.exe" --flag %1 + * C:\Browser\browser.exe + */ +function parseRegistryCommand(raw: string): {executable: string; args: string[]} | null { + const tokens = tokenizeCommand(raw.trim()); + if (tokens.length === 0) { + return null; + } + + const [executable, ...rawArgs] = tokens; + const args = rawArgs.filter((token) => token && !(/^%(\d|\*)$/).test(token)); + return {executable, args}; +} + +/** + * Detects installed external browsers for the current platform and opens links in a selected browser. + */ +export class ExternalBrowserManager { + private cachedBrowsers: BrowserInfo[] | null = null; + private loadingBrowsers?: Promise; + + init = (): void => { + this.getInstalledBrowsers(); + }; + + getCachedBrowsers = (): BrowserInfo[] => { + return this.cachedBrowsers || []; + }; + + clearBrowserCache = (): void => { + this.cachedBrowsers = null; + this.loadingBrowsers = undefined; + }; + + getInstalledBrowsers = async (): Promise => { + if (this.cachedBrowsers) { + return this.cachedBrowsers; + } + + if (!this.loadingBrowsers) { + this.loadingBrowsers = this.loadInstalledBrowsers().finally(() => { + this.loadingBrowsers = undefined; + }); + } + + return this.loadingBrowsers; + }; + + openLinkInBrowser = async (url: string, browser: BrowserInfo): Promise => { + log.debug('Opening link in external browser', {browser: browser.name}); + try { + await execFile(browser.executable, [...browser.args, url], {timeout: 10000}); + } catch (execError) { + log.error('execFile failed to open link, falling back to default browser', {browser: browser.name, error: execError}); + try { + await shell.openExternal(url); + } catch (fallbackError) { + log.error('Fallback shell.openExternal also failed', {browser: browser.name, error: fallbackError}); + } + } + }; + + private loadInstalledBrowsers = async (): Promise => { + let browsers: BrowserInfo[] = []; + + try { + switch (process.platform) { + case 'darwin': + browsers = await this.getMacOSBrowsers(); + break; + case 'win32': + browsers = await this.getWindowsBrowsers(); + break; + case 'linux': + browsers = await this.getLinuxBrowsers(); + break; + default: + browsers = []; + } + } catch (error) { + log.error('Failed to detect installed browsers', {error}); + } + + this.cachedBrowsers = browsers; + log.debug('Detected installed browsers', {browsers: browsers.map((browser) => browser.name)}); + return browsers; + }; + + private getMacOSBrowsers = async (): Promise => { + const results = await Promise.all( + KNOWN_MACOS_BROWSERS.map(async (browser) => { + try { + const {stdout} = await execFile( + 'mdfind', + [`kMDItemCFBundleIdentifier == "${browser.bundleId}"`], + {timeout: 5000}, + ); + + if (stdout.split('\n').some((line) => line.trim().length > 0)) { + return { + name: browser.name, + executable: 'open', + args: ['-b', browser.bundleId], + }; + } + } catch { + // browser not found + } + + return null; + }), + ); + + return results.filter((browser): browser is BrowserInfo => browser !== null); + }; + + private getWindowsBrowsers = async (): Promise => { + const seenNames = new Set(); + const found: BrowserInfo[] = []; + + for (const hive of WINDOWS_REGISTRY_ROOTS) { + for (const browser of KNOWN_WINDOWS_BROWSERS) { + if (seenNames.has(browser.name)) { + continue; + } + + const command = this.getWindowsBrowserCommand(hive, browser.registryKey); + if (!command) { + continue; + } + + const parsed = parseRegistryCommand(command); + if (!parsed || !fs.existsSync(parsed.executable)) { + continue; + } + + found.push({ + name: browser.name, + executable: parsed.executable, + args: parsed.args, + }); + seenNames.add(browser.name); + } + } + + return found; + }; + + private getWindowsBrowserCommand = (hive: HKEY, registryKey: string): string | undefined => { + try { + const values = enumerateValues(hive, `${WINDOWS_REGISTRY_PATH}\\${registryKey}\\shell\\open\\command`); + const defaultValue = this.findDefaultRegistryValue(values); + return typeof defaultValue?.data === 'string' ? defaultValue.data : undefined; + } catch (error) { + log.debug('Failed to read browser command from registry', {hive, registryKey, error}); + return undefined; + } + }; + + private findDefaultRegistryValue = (values: readonly RegistryValue[]): RegistryValue | undefined => { + return values.find((value) => value.name === '') || + values.find((value) => value.name === '(Default)') || + values.find((value) => typeof value.data === 'string'); + }; + + private getLinuxBrowsers = async (): Promise => { + const results: Array = await Promise.all( + KNOWN_LINUX_BROWSERS.map(async (browser) => { + for (const command of browser.commands) { + try { + // eslint-disable-next-line no-await-in-loop + const {stdout} = await execFile('which', [command], {timeout: 3000}); + const executable = stdout.split('\n').map((line) => line.trim()).find(Boolean); + + if (executable && fs.existsSync(executable)) { + return { + name: browser.name, + executable, + args: [] as string[], + }; + } + } catch { + // command not found, try next + } + } + + return null; + }), + ); + + return results.filter((browser): browser is BrowserInfo => browser !== null); + }; +} + +const externalBrowserManager = new ExternalBrowserManager(); +export default externalBrowserManager;