Skip to content
Open
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions i18n/en.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
{
"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",
Expand Down
41 changes: 36 additions & 5 deletions src/app/views/MattermostWebContentsView.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,16 +30,16 @@ import {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 {getInstalledBrowsers, openLinkInBrowser} 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,
Expand Down Expand Up @@ -94,6 +94,8 @@ export class MattermostWebContentsView extends EventEmitter {
this.webContentsView.webContents.on('did-navigate-in-page', () => this.handlePageTitleUpdated(this.webContentsView.webContents.getTitle()));
this.webContentsView.webContents.on('page-title-updated', (_, newTitle) => this.handlePageTitleUpdated(newTitle));

this.loadBrowserList();

if (!DeveloperMode.get('disableContextMenu')) {
this.contextMenu = new ContextMenu(this.generateContextMenu(), this.webContentsView.webContents);
}
Expand Down Expand Up @@ -493,7 +495,11 @@ export class MattermostWebContentsView extends EventEmitter {
return {
append: (_, parameters) => {
const parsedURL = parseURL(parameters.linkURL);
if (parsedURL && isInternalURL(parsedURL, server.url)) {
if (!parsedURL) {
return [];
}

if (isInternalURL(parsedURL, server.url)) {
return [
{
type: 'separator' as const,
Expand All @@ -514,8 +520,33 @@ export class MattermostWebContentsView extends EventEmitter {
},
];
}
return [];

return this.generateOpenInBrowserMenuItems(parsedURL.toString());
},
};
};

private generateOpenInBrowserMenuItems = (url: string): Electron.MenuItemConstructorOptions[] => {
const browsers = this.cachedBrowsers;
if (!browsers || browsers.length === 0) {
return [];
}

return [
{type: 'separator'},
{
label: localizeMessage('app.menus.contextMenu.openLinkInBrowser', 'Open Link in Browser'),
submenu: browsers.map((browser) => ({
label: browser.name,
click: () => openLinkInBrowser(url, browser),
})),
},
];
};

private cachedBrowsers: Awaited<ReturnType<typeof getInstalledBrowsers>> | null = null;

private loadBrowserList = async () => {
this.cachedBrowsers = await getInstalledBrowsers();
};
}
306 changes: 306 additions & 0 deletions src/main/browserManager.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,306 @@
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
'use strict';

const mockExec = jest.fn();
const mockExecFile = jest.fn();
jest.mock('child_process', () => ({
exec: mockExec,
execFile: mockExecFile,
}));

const mockExistsSync = jest.fn(() => true);
jest.mock('fs', () => ({
...jest.requireActual('fs'),
existsSync: (...args) => mockExistsSync(...args),
readFileSync: jest.fn(),
}));

jest.mock('electron', () => ({
shell: {
openExternal: jest.fn(),
},
}));

jest.mock('common/log', () => ({
Logger: jest.fn().mockImplementation(() => ({
debug: jest.fn(),
error: jest.fn(),
})),
}));

function mockExecResolveByPattern(mapping) {
mockExec.mockImplementation((cmd, opts, cb) => {
if (typeof opts === 'function') {
cb = opts;

Check failure on line 35 in src/main/browserManager.test.js

View workflow job for this annotation

GitHub Actions / build-mac-no-dmg

Assignment to function parameter 'cb'

Check failure on line 35 in src/main/browserManager.test.js

View workflow job for this annotation

GitHub Actions / build-linux

Assignment to function parameter 'cb'

Check failure on line 35 in src/main/browserManager.test.js

View workflow job for this annotation

GitHub Actions / build-win-no-installer

Assignment to function parameter 'cb'
}
if (typeof cb !== 'function') {
return;
}
for (const [pattern, stdout] of Object.entries(mapping)) {
if (cmd.includes(pattern)) {
cb(null, {stdout, stderr: ''});
return;
}
}
cb(null, {stdout: '', stderr: ''});
});
}

function mockExecReject(error) {
mockExec.mockImplementation((cmd, opts, cb) => {
if (typeof opts === 'function') {
cb = opts;

Check failure on line 53 in src/main/browserManager.test.js

View workflow job for this annotation

GitHub Actions / build-mac-no-dmg

Assignment to function parameter 'cb'

Check failure on line 53 in src/main/browserManager.test.js

View workflow job for this annotation

GitHub Actions / build-linux

Assignment to function parameter 'cb'

Check failure on line 53 in src/main/browserManager.test.js

View workflow job for this annotation

GitHub Actions / build-win-no-installer

Assignment to function parameter 'cb'
}
if (typeof cb === 'function') {
cb(error);
}
});
}

function mockExecFileResolve() {
mockExecFile.mockImplementation((file, args, opts, cb) => {
if (typeof opts === 'function') {
cb = opts;

Check failure on line 64 in src/main/browserManager.test.js

View workflow job for this annotation

GitHub Actions / build-mac-no-dmg

Assignment to function parameter 'cb'

Check failure on line 64 in src/main/browserManager.test.js

View workflow job for this annotation

GitHub Actions / build-linux

Assignment to function parameter 'cb'

Check failure on line 64 in src/main/browserManager.test.js

View workflow job for this annotation

GitHub Actions / build-win-no-installer

Assignment to function parameter 'cb'
}
if (typeof cb === 'function') {
cb(null, {stdout: '', stderr: ''});
}
});
}

function mockExecFileReject(error) {
mockExecFile.mockImplementation((file, args, opts, cb) => {
if (typeof opts === 'function') {
cb = opts;

Check failure on line 75 in src/main/browserManager.test.js

View workflow job for this annotation

GitHub Actions / build-mac-no-dmg

Assignment to function parameter 'cb'

Check failure on line 75 in src/main/browserManager.test.js

View workflow job for this annotation

GitHub Actions / build-linux

Assignment to function parameter 'cb'

Check failure on line 75 in src/main/browserManager.test.js

View workflow job for this annotation

GitHub Actions / build-win-no-installer

Assignment to function parameter 'cb'
}
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);

const browserManager = require('./browserManager');
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 () => {
mockExecResolveByPattern({
'com.google.Chrome': '/Applications/Google Chrome.app',
'com.apple.Safari': '/Applications/Safari.app',
});

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 () => {
mockExecReject(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 () => {
mockExecResolveByPattern({
'Google Chrome': ' (Default) REG_SZ "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe"',
'FIREFOX.EXE': ' (Default) REG_SZ "C:\\Program Files\\Mozilla Firefox\\firefox.exe"',
});

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 () => {
mockExecResolveByPattern({
'Google Chrome': ' (Default) REG_SZ "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe"',
'FIREFOX.EXE': ' (Default) REG_SZ "C:\\Program Files\\Mozilla Firefox\\firefox.exe"',
});
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 () => {
mockExecResolveByPattern({
'HKLM': '',

Check failure on line 173 in src/main/browserManager.test.js

View workflow job for this annotation

GitHub Actions / build-mac-no-dmg

Unnecessarily quoted property 'HKLM' found

Check failure on line 173 in src/main/browserManager.test.js

View workflow job for this annotation

GitHub Actions / build-linux

Unnecessarily quoted property 'HKLM' found

Check failure on line 173 in src/main/browserManager.test.js

View workflow job for this annotation

GitHub Actions / build-win-no-installer

Unnecessarily quoted property 'HKLM' found
'HKCU\\SOFTWARE\\Clients\\StartMenuInternet\\Google Chrome': ' (Default) REG_SZ "C:\\Users\\user\\AppData\\Local\\Google\\Chrome\\Application\\chrome.exe"',
});

const browsers = await getInstalledBrowsers();
expect(browsers.length).toBe(1);
expect(browsers[0].name).toBe('Google Chrome');
});

it('should handle registry errors gracefully', async () => {
mockExecReject(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 () => {
mockExec.mockImplementation((cmd, opts, cb) => {
if (typeof opts === 'function') {
cb = opts;

Check failure on line 198 in src/main/browserManager.test.js

View workflow job for this annotation

GitHub Actions / build-mac-no-dmg

Assignment to function parameter 'cb'

Check failure on line 198 in src/main/browserManager.test.js

View workflow job for this annotation

GitHub Actions / build-linux

Assignment to function parameter 'cb'

Check failure on line 198 in src/main/browserManager.test.js

View workflow job for this annotation

GitHub Actions / build-win-no-installer

Assignment to function parameter 'cb'
}
if (typeof cb !== 'function') {
return;
}
if (cmd === 'which firefox') {
cb(null, {stdout: '/usr/bin/firefox', stderr: ''});
} else if (cmd === 'which google-chrome') {
cb(null, {stdout: '/usr/bin/google-chrome', stderr: ''});
} else if (cmd.startsWith('grep')) {
cb(null, {stdout: '', stderr: ''});
} else {
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 () => {
mockExec.mockImplementation((cmd, opts, cb) => {
if (typeof opts === 'function') {
cb = opts;

Check failure on line 226 in src/main/browserManager.test.js

View workflow job for this annotation

GitHub Actions / build-mac-no-dmg

Assignment to function parameter 'cb'

Check failure on line 226 in src/main/browserManager.test.js

View workflow job for this annotation

GitHub Actions / build-linux

Assignment to function parameter 'cb'

Check failure on line 226 in src/main/browserManager.test.js

View workflow job for this annotation

GitHub Actions / build-win-no-installer

Assignment to function parameter 'cb'
}
if (typeof cb !== 'function') {
return;
}
if (cmd.startsWith('grep')) {
cb(null, {stdout: '', stderr: ''});
} else {
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'});
mockExecResolveByPattern({
'com.apple.Safari': '/Applications/Safari.app',
});

await getInstalledBrowsers();
mockExec.mockClear();
const browsers = await getInstalledBrowsers();
expect(mockExec).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');
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');
});
});
});
Loading
Loading