Skip to content

Commit

Permalink
feat: support for custom user data (profile) directory for Firefox
Browse files Browse the repository at this point in the history
When using a custom Firefox profile for Puppeteer the modified
preferences as present in prefs.js need to be reset once the
profile is no longer needed by Puppeteer. If not done this could
cause side-effects when the profile is used next time outside
of Puppeteer.
  • Loading branch information
whimboo committed Nov 8, 2021
1 parent 1de62a0 commit a83bf6d
Show file tree
Hide file tree
Showing 4 changed files with 195 additions and 78 deletions.
64 changes: 49 additions & 15 deletions src/node/BrowserRunner.ts
Expand Up @@ -16,21 +16,28 @@

import { debug } from '../common/Debug.js';

import removeFolder from 'rimraf';
import * as childProcess from 'child_process';
import * as fs from 'fs';
import * as path from 'path';
import * as readline from 'readline';
import removeFolder from 'rimraf';
import { promisify } from 'util';

import { assert } from '../common/assert.js';
import { helper, debugError } from '../common/helper.js';
import { LaunchOptions } from './LaunchOptions.js';
import { Connection } from '../common/Connection.js';
import { NodeWebSocketTransport as WebSocketTransport } from '../node/NodeWebSocketTransport.js';
import { PipeTransport } from './PipeTransport.js';
import { Product } from '../common/Product.js';
import * as readline from 'readline';
import { TimeoutError } from '../common/Errors.js';
import { promisify } from 'util';

const removeFolderAsync = promisify(removeFolder);
const renameAsync = promisify(fs.rename);
const unlinkAsync = promisify(fs.unlink);

const debugLauncher = debug('puppeteer:launcher');

const PROCESS_ERROR_EXPLANATION = `Puppeteer was unable to kill the process which ran the browser binary.
This means that, on future Puppeteer launches, Puppeteer might not be able to launch the browser.
Please check your open processes and ensure that the browser processes that Puppeteer launched have been killed.
Expand All @@ -40,7 +47,8 @@ export class BrowserRunner {
private _product: Product;
private _executablePath: string;
private _processArguments: string[];
private _tempDirectory?: string;
private _userDataDir: string;
private _isTempUserDataDir?: boolean;

proc = null;
connection = null;
Expand All @@ -53,12 +61,14 @@ export class BrowserRunner {
product: Product,
executablePath: string,
processArguments: string[],
tempDirectory?: string
userDataDir: string,
isTempUserDataDir?: boolean
) {
this._product = product;
this._executablePath = executablePath;
this._processArguments = processArguments;
this._tempDirectory = tempDirectory;
this._userDataDir = userDataDir;
this._isTempUserDataDir = isTempUserDataDir;
}

start(options: LaunchOptions): void {
Expand Down Expand Up @@ -95,17 +105,39 @@ export class BrowserRunner {
}
this._closed = false;
this._processClosing = new Promise((fulfill, reject) => {
this.proc.once('exit', () => {
this.proc.once('exit', async () => {
this._closed = true;
// Cleanup as processes exit.
if (this._tempDirectory) {
removeFolderAsync(this._tempDirectory)
.then(() => fulfill())
.catch((error) => {
if (this._isTempUserDataDir) {
try {
await removeFolderAsync(this._userDataDir);
fulfill();
} catch (error) {
console.error(error);
reject(error);
}
} else {
if (this._product === 'firefox') {
try {
// When an existing user profile has been used remove the user
// preferences file and restore possibly backuped preferences.
await unlinkAsync(path.join(this._userDataDir, 'user.js'));

const prefsBackupPath = path.join(
this._userDataDir,
'prefs.js.puppeteer'
);
if (fs.existsSync(prefsBackupPath)) {
const prefsPath = path.join(this._userDataDir, 'prefs.js');
await unlinkAsync(prefsPath);
await renameAsync(prefsBackupPath, prefsPath);
}
} catch (error) {
console.error(error);
reject(error);
});
} else {
}
}

fulfill();
}
});
Expand All @@ -132,7 +164,7 @@ export class BrowserRunner {

close(): Promise<void> {
if (this._closed) return Promise.resolve();
if (this._tempDirectory && this._product !== 'firefox') {
if (this._isTempUserDataDir && this._product !== 'firefox') {
this.kill();
} else if (this.connection) {
// Attempt to close the browser gracefully
Expand All @@ -150,7 +182,9 @@ export class BrowserRunner {
kill(): void {
// Attempt to remove temporary profile directory to avoid littering.
try {
removeFolder.sync(this._tempDirectory);
if (this._isTempUserDataDir) {
removeFolder.sync(this._userDataDir);
}
} catch (error) {}

// If the process failed to launch (for example if the browser executable path
Expand Down
106 changes: 75 additions & 31 deletions src/node/Launcher.ts
Expand Up @@ -23,6 +23,7 @@ import { Browser } from '../common/Browser.js';
import { BrowserRunner } from './BrowserRunner.js';
import { promisify } from 'util';

const copyFileAsync = promisify(fs.copyFile);
const mkdtempAsync = promisify(fs.mkdtemp);
const writeFileAsync = promisify(fs.writeFile);

Expand Down Expand Up @@ -85,7 +86,6 @@ class ChromeLauncher implements ProductLauncher {
debuggingPort = null,
} = options;

const profilePath = path.join(tmpDir(), 'puppeteer_dev_chrome_profile-');
const chromeArguments = [];
if (!ignoreDefaultArgs) chromeArguments.push(...this.defaultArgs(options));
else if (Array.isArray(ignoreDefaultArgs))
Expand All @@ -96,8 +96,6 @@ class ChromeLauncher implements ProductLauncher {
);
else chromeArguments.push(...args);

let temporaryUserDataDir = null;

if (
!chromeArguments.some((argument) =>
argument.startsWith('--remote-debugging-')
Expand All @@ -113,9 +111,28 @@ class ChromeLauncher implements ProductLauncher {
chromeArguments.push(`--remote-debugging-port=${debuggingPort || 0}`);
}
}
if (!chromeArguments.some((arg) => arg.startsWith('--user-data-dir'))) {
temporaryUserDataDir = await mkdtempAsync(profilePath);
chromeArguments.push(`--user-data-dir=${temporaryUserDataDir}`);

let userDataDir;
let isTempUserDataDir = true;

// Check for the user data dir argument, which will always be set even
// with a custom directory specified via the userDataDir option.
const userDataDirIndex = chromeArguments.findIndex((arg) => {
return arg.startsWith('--user-data-dir');
});

if (userDataDirIndex !== -1) {
userDataDir = chromeArguments[userDataDirIndex].split('=')[1];
if (!fs.existsSync(userDataDir)) {
throw new Error(`Chrome user data dir not found at '${userDataDir}'`);
}

isTempUserDataDir = false;
} else {
userDataDir = await mkdtempAsync(
path.join(tmpDir(), 'puppeteer_dev_chrome_profile-')
);
chromeArguments.push(`--user-data-dir=${userDataDir}`);
}

let chromeExecutable = executablePath;
Expand Down Expand Up @@ -145,7 +162,8 @@ class ChromeLauncher implements ProductLauncher {
this.product,
chromeExecutable,
chromeArguments,
temporaryUserDataDir
userDataDir,
isTempUserDataDir
);
runner.start({
handleSIGHUP,
Expand Down Expand Up @@ -303,15 +321,30 @@ class FirefoxLauncher implements ProductLauncher {
firefoxArguments.push(`--remote-debugging-port=${debuggingPort || 0}`);
}

let temporaryUserDataDir = null;
let userDataDir = null;
let isTempUserDataDir = true;

if (
!firefoxArguments.includes('-profile') &&
!firefoxArguments.includes('--profile')
) {
temporaryUserDataDir = await this._createProfile(extraPrefsFirefox);
// Check for the profile argument, which will always be set even
// with a custom directory specified via the userDataDir option.
const profileArgIndex = firefoxArguments.findIndex((arg) => {
return ['-profile', '--profile'].includes(arg);
});

if (profileArgIndex !== -1) {
userDataDir = firefoxArguments[profileArgIndex + 1];
if (!fs.existsSync(userDataDir)) {
throw new Error(`Firefox profile not found at '${userDataDir}'`);
}

// When using a custom Firefox profile it needs to be populated
// with required preferences.
isTempUserDataDir = false;
const prefs = this.defaultPreferences(extraPrefsFirefox);
this.writePreferences(prefs, userDataDir);
} else {
userDataDir = await this._createProfile(extraPrefsFirefox);
firefoxArguments.push('--profile');
firefoxArguments.push(temporaryUserDataDir);
firefoxArguments.push(userDataDir);
}

await this._updateRevision();
Expand All @@ -326,7 +359,8 @@ class FirefoxLauncher implements ProductLauncher {
this.product,
firefoxExecutable,
firefoxArguments,
temporaryUserDataDir
userDataDir,
isTempUserDataDir
);
runner.start({
handleSIGHUP,
Expand Down Expand Up @@ -406,9 +440,9 @@ class FirefoxLauncher implements ProductLauncher {
return firefoxArguments;
}

defaultPreferences(extraPrefs: {
defaultPreferences(extraPrefs: { [x: string]: unknown }): {
[x: string]: unknown;
}): { [x: string]: unknown } {
} {
const server = 'dummy.test';

const defaultPrefs = {
Expand Down Expand Up @@ -616,33 +650,43 @@ class FirefoxLauncher implements ProductLauncher {
return Object.assign(defaultPrefs, extraPrefs);
}

/**
* Populates the user.js file with custom preferences as needed to allow
* Firefox's CDP support to properly function. These preferences will be
* automatically copied over to prefs.js during startup of Firefox. To be
* able to restore the original values of preferences a backup of prefs.js
* will be created.
*
* @param prefs List of preferences to add.
* @param profilePath Firefox profile to write the preferences to.
*/
async writePreferences(
prefs: { [x: string]: unknown },
profilePath: string
): Promise<void> {
const prefsJS = [];
const userJS = [];
const lines = Object.entries(prefs).map(([key, value]) => {
return `user_pref(${JSON.stringify(key)}, ${JSON.stringify(value)});`;
});

for (const [key, value] of Object.entries(prefs))
userJS.push(
`user_pref(${JSON.stringify(key)}, ${JSON.stringify(value)});`
);
await writeFileAsync(path.join(profilePath, 'user.js'), userJS.join('\n'));
await writeFileAsync(
path.join(profilePath, 'prefs.js'),
prefsJS.join('\n')
);
await writeFileAsync(path.join(profilePath, 'user.js'), lines.join('\n'));

// Create a backup of the preferences file if it already exitsts.
const prefsPath = path.join(profilePath, 'prefs.js');
if (fs.existsSync(prefsPath)) {
const prefsBackupPath = path.join(profilePath, 'prefs.js.puppeteer');
await copyFileAsync(prefsPath, prefsBackupPath);
}
}

async _createProfile(extraPrefs: { [x: string]: unknown }): Promise<string> {
const profilePath = await mkdtempAsync(
const temporaryProfilePath = await mkdtempAsync(
path.join(tmpDir(), 'puppeteer_dev_firefox_profile-')
);

const prefs = this.defaultPreferences(extraPrefs);
await this.writePreferences(prefs, profilePath);
await this.writePreferences(prefs, temporaryProfilePath);

return profilePath;
return temporaryProfilePath;
}
}

Expand Down

0 comments on commit a83bf6d

Please sign in to comment.