Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Automatically inject preload scripts #359

Closed
wants to merge 19 commits into from
Closed
Show file tree
Hide file tree
Changes from 16 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
1 change: 0 additions & 1 deletion .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,6 @@ jobs:
os: [ubuntu-latest, macos-latest, windows-latest]
electron:
[
'1.8.8',
'2.0.18',
'3.1.13',
'4.2.12',
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,4 @@ stats.json
# Eslint
.eslintcache
.vagrant
src/preload/bundled.ts
1 change: 1 addition & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
src/preload/bundled.ts
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
[`@sentry/browser`](https://github.com/getsentry/sentry-javascript/tree/master/packages/browser))
- Captures **native crashes** (Minidump crash reports) from renderers and the main process
- Collects **breadcrumbs and context** information along with events across renderers and the main process
- Support `electron` version >= `1.8.x`
- Support `electron` version >= `2`

## Usage

Expand Down
1 change: 0 additions & 1 deletion example/sentry.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ init({
debug: true,
// useCrashpadMinidumpUploader: true,
// useSentryMinidumpUploader: false,
appName: 'Sentry Test',
});

addBreadcrumb({ message: 'test' });
Expand Down
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
"access": "public"
},
"scripts": {
"prebuild": "node scripts/build-preloads.js",
"build": "run-p build:es6 build:esm",
"build:es6": "tsc -p tsconfig.build.json",
"build:esm": "tsc -p tsconfig.esm.json",
Expand All @@ -25,6 +26,7 @@
"clean": "rimraf dist coverage esm",
"preexample": "run-s clean build",
"example": "electron example",
"prelint": "yarn prebuild",
"lint": "run-s lint:prettier lint:eslint",
"lint:prettier": "prettier --check \"{src,test}/**/*.ts\"",
"lint:eslint": "eslint . --cache --format stylish",
Expand Down Expand Up @@ -61,10 +63,10 @@
"chai-as-promised": "^7.1.1",
"chai-datetime": "^1.7.0",
"cross-env": "^5.2.0",
"eslint": "7.26.0",
"electron": "12.0.7",
"electron-download": "^4.1.1",
"electron-mocha": "^6.0.4",
"eslint": "7.26.0",
"extract-zip": "^1.6.7",
"koa": "^2.13.1",
"koa-bodyparser": "^4.3.0",
Expand Down
28 changes: 28 additions & 0 deletions scripts/build-preloads.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
const { readFileSync, writeFileSync } = require('fs');
const { join } = require('path');
const { transpile } = require('typescript');

const files = ['hook-ipc', 'start-native'];

function readString(path) {
return readFileSync(join(__dirname, path), { encoding: 'utf8' });
}

const ipcModule = readString('../src/ipc.ts');

function transpileFile(path) {
// Since we're not doing proper bundling we need to replace the ipc import with the code 😋
let file = readString(path).replace("import { IPC } from '../ipc';", ipcModule);

return transpile(file, { removeComments: true });
}

const template = readString('../src/preload/bundled.template.ts');

// Replace all the keys in the template with transpiled code
const code = files.reduce(
(tpl, file) => tpl.replace(`{{${file}}}`, transpileFile(`../src/preload/${file}.ts`)),
template,
);

writeFileSync(join(__dirname, '../src/preload/bundled.ts'), code);
83 changes: 9 additions & 74 deletions src/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,6 @@ import { BrowserOptions, ReportDialogOptions } from '@sentry/browser';
import { BaseBackend } from '@sentry/core';
import { NodeOptions } from '@sentry/node';
import { Client, Event, Options, Scope } from '@sentry/types';
import { SentryError } from '@sentry/utils';
import { App } from 'electron';

export const IPC = {
/** IPC to ping the main process when initializing in the renderer. */
PING: 'sentry-electron.ping',
/** IPC to send a captured event (exception or message) to Sentry. */
EVENT: 'sentry-electron.event',
/** IPC to capture a scope globally. */
SCOPE: 'sentry-electron.scope',
};

/**
* Configuration options for {@link ElectronOptions}.
Expand All @@ -31,15 +20,8 @@ export const IPC = {
*/
export interface ElectronOptions extends Options, BrowserOptions, NodeOptions {
/**
* The name of the application. Primarily used for crash directory naming. If this property is not supplied,
* it will be retrieved using the Electron `app.getName/name` API. If you disable the Electron `remote` module in
* the renderer, this property is required.
*/
appName?: string;

/**
* Enables crash reporting for JavaScript errors in this process. Defaults to
* `true`.
* Enables crash reporting for JavaScript errors in this process.
* Defaults to `true`.
*/
enableJavaScript?: boolean;

Expand Down Expand Up @@ -69,6 +51,13 @@ export interface ElectronOptions extends Options, BrowserOptions, NodeOptions {
*/
enableUnresponsive?: boolean;

/**
* Callback to fetch the sessions to inject preload scripts
*
* Defaults to injecting only in `session.defaultSession`
*/
preloadSessions?(): Electron.Session[];

/**
* Callback to allow custom naming of renderer processes
* If the callback is not set, or it returns `undefined`, the default naming
Expand All @@ -94,59 +83,5 @@ export interface ElectronClient extends Client<ElectronOptions> {
showReportDialog(options: ReportDialogOptions): void;
}

/** Name retrieval references for both Electron <v5 and v7< */
declare interface CrossApp extends App {
/**
* A `String` property that indicates the current application's name, which is the
* name in the application's `package.json` file.
*
* Usually the `name` field of `package.json` is a short lowercase name, according
* to the npm modules spec. You should usually also specify a `productName` field,
* which is your application's full capitalized name, and which will be preferred
* over `name` by Electron.
*/
name: string;

/**
* Usually the name field of package.json is a short lowercased name, according to
* the npm modules spec. You should usually also specify a productName field, which
* is your application's full capitalized name, and which will be preferred over
* name by Electron.
*/
getName(): string;
}

/** Get the name of an electron app for <v5 and v7< */
export function getNameFallback(): string {
if (typeof require === 'undefined') {
throw new SentryError(
'Could not require("electron") to get appName. Please ensure you pass `appName` to Sentry options',
);
}

const electron = require('electron');

// if we're in the main process
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
if (electron && electron.app) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
const appMain = electron.app as CrossApp;
return appMain.name || appMain.getName();
}

// We're in the renderer process but the remote module is not available
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
if (!electron || !electron.remote) {
throw new SentryError(
'The Electron `remote` module was not available to get appName. Please ensure you pass `appName` to Sentry options',
);
}

// Remote is available so get the app name
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
const a = electron.remote.app as CrossApp;
return a.name || a.getName();
}

/** Common interface for Electron backends. */
export { BaseBackend as CommonBackend };
13 changes: 11 additions & 2 deletions src/electron-version.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { parseSemver } from '@sentry/utils';
import { app, crashReporter } from 'electron';

/**
* Parsed Electron version
Expand All @@ -21,6 +22,10 @@ export function supportsRenderProcessGone(): boolean {
* Electron < 9 requires `crashReporter.start()` in the renderer
*/
export function requiresNativeHandlerRenderer(): boolean {
if (process.platform == 'darwin') {
return false;
}

const { major } = version();
return major < 9;
}
Expand All @@ -37,7 +42,11 @@ export function supportsCrashpadOnWindows(): boolean {
* Electron >= 9 supports `app.getPath('crashDumps')` rather than
* `crashReporter.getCrashesDirectory()`
*/
export function supportsGetPathCrashDumps(): boolean {
export function getCrashedDirectory(): string {
const { major } = version();
return major >= 9;

return major >= 9
? app.getPath('crashDumps')
: // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
(crashReporter as any).getCrashesDirectory();
}
File renamed without changes.
18 changes: 18 additions & 0 deletions src/ipc.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
export const IPC = {
/** IPC to send a captured event (exception or message) to Sentry. */
EVENT: 'sentry-electron.event',
/** IPC to capture a scope globally. */
SCOPE: 'sentry-electron.scope',
};

/**
* We store the IPC interface on window so it's the same for both regular and isolated contexts
*/
declare global {
interface Window {
__SENTRY_IPC__?: {
sendScope: (scope: string) => void;
sendEvent: (event: string) => void;
};
}
}
51 changes: 31 additions & 20 deletions src/main/backend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,13 @@ import {
import { NodeBackend } from '@sentry/node';
import { Event, EventHint, ScopeContext, Severity, Transport, TransportOptions } from '@sentry/types';
import { Dsn, forget, logger, SentryError } from '@sentry/utils';
import { app, crashReporter, ipcMain } from 'electron';
import { app, crashReporter, ipcMain, Session, session } from 'electron';
import { join } from 'path';

import { CommonBackend, ElectronOptions, getNameFallback, IPC } from '../common';
import { supportsGetPathCrashDumps, supportsRenderProcessGone } from '../electron-version';
import { CommonBackend, ElectronOptions } from '../common';
import { getCrashedDirectory, requiresNativeHandlerRenderer, supportsRenderProcessGone } from '../electron-version';
import { IPC } from '../ipc';
import { dropPreloadAndGetPath } from '../preload/bundled';
import { addEventDefaults } from './context';
import { captureMinidump } from './index';
import { normalizeEvent, normalizeUrl } from './normalize';
Expand Down Expand Up @@ -97,11 +99,15 @@ export class MainBackend extends BaseBackend<ElectronOptions> implements CommonB
this._setupScopeListener();

if (this._isNativeEnabled()) {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
forget(this._installNativeHandler());
}

this._installIPC();

app.once('ready', () => {
const sessions = options.preloadSessions?.() || [session.defaultSession];
this._addPreloadsToSessions(sessions);
});
}

/**
Expand Down Expand Up @@ -161,6 +167,24 @@ export class MainBackend extends BaseBackend<ElectronOptions> implements CommonB
return new NetTransport(transportOptions);
}

/**
* Adds required preload scripts to the passed sessions
*/
private _addPreloadsToSessions(sessions: Session[]): void {
const preloads = [dropPreloadAndGetPath('hook-ipc')];

// Some older versions of Electron require the native crash reporter starting in the renderer process
if (requiresNativeHandlerRenderer()) {
preloads.unshift(dropPreloadAndGetPath('start-native'));
}

for (const sesh of sessions) {
// Fetch any existing preloads so we don't overwrite them
const existing = sesh.getPreloads();
sesh.setPreloads([...preloads, ...existing]);
}
}

/**
* Adds a scope listener to persist changes to disk.
*/
Expand Down Expand Up @@ -214,7 +238,7 @@ export class MainBackend extends BaseBackend<ElectronOptions> implements CommonB
// Apply the scope to the event
await scope.applyToEvent(event);
// Add all the extra context
event = await addEventDefaults(this._options.appName, event);
event = await addEventDefaults(event);
return normalizeEvent(event);
}

Expand Down Expand Up @@ -293,24 +317,15 @@ export class MainBackend extends BaseBackend<ElectronOptions> implements CommonB
crashReporter.start({
companyName: '',
ignoreSystemCrashHandler: true,
productName: this._options.appName || getNameFallback(),
productName: app.name || app.getName(),
submitURL: MinidumpUploader.minidumpUrlFromDsn(dsn),
uploadToServer: this._options.useCrashpadMinidumpUploader || false,
compress: true,
globalExtra,
});

if (this._options.useSentryMinidumpUploader !== false) {
// The crashReporter has a method to retrieve the directory
// it uses to store minidumps in. The structure in this directory depends
// on the crash library being used (Crashpad or Breakpad).
const crashesDirectory = supportsGetPathCrashDumps()
? app.getPath('crashDumps')
: // unsafe member access required because of older versions of Electron
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
(crashReporter as any).getCrashesDirectory();

this._uploader = new MinidumpUploader(dsn, crashesDirectory, getCachePath(), this.getTransport());
this._uploader = new MinidumpUploader(dsn, getCrashedDirectory(), getCachePath(), this.getTransport());

// Flush already cached minidumps from the queue.
forget(this._uploader.flushQueue());
Expand Down Expand Up @@ -368,10 +383,6 @@ export class MainBackend extends BaseBackend<ElectronOptions> implements CommonB

/** Installs IPC handlers to receive events and metadata from renderers. */
private _installIPC(): void {
ipcMain.on(IPC.PING, (event: Electron.IpcMainEvent) => {
event.sender.send(IPC.PING);
});

ipcMain.on(IPC.EVENT, (ipc: Electron.IpcMainEvent, jsonEvent: string) => {
let event: Event;
try {
Expand Down
2 changes: 1 addition & 1 deletion src/main/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ export class MainClient extends BaseClient<MainBackend, ElectronOptions> impleme
return super._prepareEvent(event, scope, hint).then((filledEvent: Event | null) =>
new SyncPromise<Event>(async resolve => {
if (filledEvent) {
resolve(normalizeEvent(await addEventDefaults(this._options.appName, filledEvent)));
resolve(normalizeEvent(await addEventDefaults(filledEvent)));
} else {
resolve(filledEvent);
}
Expand Down
11 changes: 5 additions & 6 deletions src/main/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,7 @@ import { app } from 'electron';
import { platform, release } from 'os';
import { join } from 'path';

import { getNameFallback } from '../common';
import { readDirAsync, readFileAsync } from './fs';
import { readDirAsync, readFileAsync } from '../fs';

/** SDK version used in every event. */
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
Expand Down Expand Up @@ -227,8 +226,8 @@ async function getOsContext(): Promise<OsContext> {
* runtimes, limited device information, operating system context and defaults
* for the release and environment.
*/
async function getEventDefaults(appName: string | undefined): Promise<Event> {
const name = appName || getNameFallback();
async function getEventDefaults(): Promise<Event> {
const name = app.name || app.getName();

return {
contexts: {
Expand Down Expand Up @@ -268,12 +267,12 @@ async function getEventDefaults(appName: string | undefined): Promise<Event> {
}

/** Merges the given event payload with SDK defaults. */
export async function addEventDefaults(appName: string | undefined, event: Event): Promise<Event> {
export async function addEventDefaults(event: Event): Promise<Event> {
// The event defaults are cached as long as the app is running. We create the
// promise here synchronously to avoid multiple events computing them at the
// same time.
if (!defaultsPromise) {
defaultsPromise = getEventDefaults(appName);
defaultsPromise = getEventDefaults();
}

const { contexts = {} } = event;
Expand Down