@flareapp/electron 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +173 -0
- package/dist/main.cjs +486 -0
- package/dist/main.d.cts +77 -0
- package/dist/main.d.mts +77 -0
- package/dist/main.mjs +406 -0
- package/dist/preload.cjs +21 -0
- package/dist/preload.d.cts +8 -0
- package/dist/preload.d.mts +8 -0
- package/dist/preload.mjs +20 -0
- package/dist/renderer.cjs +84 -0
- package/dist/renderer.d.cts +30 -0
- package/dist/renderer.d.mts +30 -0
- package/dist/renderer.mjs +81 -0
- package/package.json +80 -0
package/README.md
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
# @flareapp/electron
|
|
2
|
+
|
|
3
|
+
> ⚠️ **Experimental (`0.1.0`).** This package is new and its API may change in a minor release. Feedback and bug reports are very welcome at https://github.com/spatie/flare-client-js/issues.
|
|
4
|
+
|
|
5
|
+
Electron SDK for [Flare](https://flareapp.io) error tracking. It captures JavaScript errors in **both** Electron processes and routes every report through the main process, so your API key lives in exactly one place.
|
|
6
|
+
|
|
7
|
+
## What it captures
|
|
8
|
+
|
|
9
|
+
- **Main process:** uncaught exceptions and unhandled promise rejections.
|
|
10
|
+
- **Renderer process:** `window.onerror` and `unhandledrejection`, plus anything you report manually.
|
|
11
|
+
- **Process crashes:** `render-process-gone` and `child-process-gone` (renderer/GPU/utility), reported as structured errors with the crash `reason` and `exitCode`.
|
|
12
|
+
|
|
13
|
+
It does **not** capture native crashes (C++/Crashpad minidumps). Only JavaScript-level errors are sent to Flare.
|
|
14
|
+
|
|
15
|
+
## Install
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
npm install @flareapp/electron
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
`electron` is a peer dependency; this package expects your app to provide it.
|
|
22
|
+
|
|
23
|
+
## Setup
|
|
24
|
+
|
|
25
|
+
Flare needs wiring in all three Electron contexts. The API key, `stage`, `version`, and sourcemap settings are configured **once, in the main process** — the renderer needs none of them.
|
|
26
|
+
|
|
27
|
+
### 1. Main process
|
|
28
|
+
|
|
29
|
+
```ts
|
|
30
|
+
// main.ts
|
|
31
|
+
import { app } from 'electron';
|
|
32
|
+
import { flare } from '@flareapp/electron/main';
|
|
33
|
+
|
|
34
|
+
flare.light('your-flare-api-key');
|
|
35
|
+
|
|
36
|
+
// Optional: these are set ONCE here and applied to renderer reports too.
|
|
37
|
+
flare.configure({ stage: 'production', version: app.getVersion() });
|
|
38
|
+
|
|
39
|
+
// Optional: fatal-handler behavior and IPC trust policy.
|
|
40
|
+
flare.configureElectron({
|
|
41
|
+
uncaughtExceptionMode: 'report-and-exit', // 'report' | 'report-and-exit' | 'off'
|
|
42
|
+
unhandledRejectionMode: 'report-and-exit',
|
|
43
|
+
captureRenderProcessGone: true,
|
|
44
|
+
});
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
In `report-and-exit` mode, after a fatal error Flare reports it, flushes pending reports (up to `shutdownTimeoutMs`), then calls `app.exit(1)`.
|
|
48
|
+
|
|
49
|
+
### 2. Preload script
|
|
50
|
+
|
|
51
|
+
Because `contextIsolation` is on (the Electron default and the recommended setting), the renderer cannot reach `ipcRenderer` directly. The preload helper bridges reports over `contextBridge`. This step is **required** — without it, renderer reports are dropped.
|
|
52
|
+
|
|
53
|
+
```ts
|
|
54
|
+
// preload.ts
|
|
55
|
+
import { exposeFlare } from '@flareapp/electron/preload';
|
|
56
|
+
|
|
57
|
+
exposeFlare();
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Make sure your `BrowserWindow` points at this preload script:
|
|
61
|
+
|
|
62
|
+
```ts
|
|
63
|
+
new BrowserWindow({
|
|
64
|
+
webPreferences: {
|
|
65
|
+
preload: path.join(__dirname, 'preload.js'),
|
|
66
|
+
contextIsolation: true,
|
|
67
|
+
},
|
|
68
|
+
});
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
### 3. Renderer
|
|
72
|
+
|
|
73
|
+
Import the renderer entry once, as early as possible, to install the global error listeners:
|
|
74
|
+
|
|
75
|
+
```ts
|
|
76
|
+
// renderer entry, e.g. main.tsx / index.ts
|
|
77
|
+
import '@flareapp/electron/renderer';
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
For manual reporting, use the exported instance:
|
|
81
|
+
|
|
82
|
+
```ts
|
|
83
|
+
import { flare } from '@flareapp/electron/renderer';
|
|
84
|
+
|
|
85
|
+
try {
|
|
86
|
+
doRiskyThing();
|
|
87
|
+
} catch (error) {
|
|
88
|
+
flare.report(error as Error);
|
|
89
|
+
}
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
The renderer builds the full report (stack trace + source snippets + browser context) in its own context, then forwards it to the main process. No API key lives in the renderer.
|
|
93
|
+
|
|
94
|
+
## How reports flow
|
|
95
|
+
|
|
96
|
+
```
|
|
97
|
+
renderer error
|
|
98
|
+
→ RendererFlare builds Report (stack + snippets + browser context)
|
|
99
|
+
→ renderer beforeSubmit → serialize → size-check
|
|
100
|
+
→ window.__flare.report(jsonString) [contextBridge]
|
|
101
|
+
→ ipcRenderer.invoke('flare:report') [IPC]
|
|
102
|
+
→ main: trust sender → size-check → parse → validate
|
|
103
|
+
→ overlay stage/version/sourcemap + app metadata + user
|
|
104
|
+
→ main beforeSubmit → sent to Flare
|
|
105
|
+
|
|
106
|
+
main-process error
|
|
107
|
+
→ process handlers → sent to Flare (app.exit on report-and-exit)
|
|
108
|
+
|
|
109
|
+
renderer / GPU crash
|
|
110
|
+
→ render-process-gone / child-process-gone → reported → sent to Flare
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
The API key is held only in the main process because that is the single egress point: every report, wherever it originates, is sent from main.
|
|
114
|
+
|
|
115
|
+
## Filtering reports (`beforeSubmit`)
|
|
116
|
+
|
|
117
|
+
`beforeSubmit` runs in **two stages**: once in the renderer (scrub close to the source) and once in main (the final gate before sending). Returning `null`/`false` from either drops the report.
|
|
118
|
+
|
|
119
|
+
```ts
|
|
120
|
+
// main
|
|
121
|
+
flare.configure({
|
|
122
|
+
beforeSubmit: (report) => {
|
|
123
|
+
// final scrub before sending
|
|
124
|
+
return report;
|
|
125
|
+
},
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
// renderer
|
|
129
|
+
import { flare } from '@flareapp/electron/renderer';
|
|
130
|
+
flare.configure({
|
|
131
|
+
beforeSubmit: (report) => {
|
|
132
|
+
delete report.attributes['context.custom'];
|
|
133
|
+
return report;
|
|
134
|
+
},
|
|
135
|
+
});
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
## Sender trust
|
|
139
|
+
|
|
140
|
+
The main process only accepts reports from frames it trusts. By **default** it accepts:
|
|
141
|
+
|
|
142
|
+
- `file:` URLs (packaged builds), and
|
|
143
|
+
- `http(s)` on `localhost` / `127.0.0.1` (dev servers).
|
|
144
|
+
|
|
145
|
+
It rejects everything else, including remote origins and custom protocols. If your app serves its renderer over a custom protocol or loads trusted remote content, opt in:
|
|
146
|
+
|
|
147
|
+
```ts
|
|
148
|
+
// Add a custom protocol scheme:
|
|
149
|
+
flare.configureElectron({ trustedProtocols: ['app'] });
|
|
150
|
+
|
|
151
|
+
// Or take full control:
|
|
152
|
+
flare.configureElectron({
|
|
153
|
+
trustSender: (frame) => new URL(frame.url).origin === 'https://app.example.com',
|
|
154
|
+
});
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
## Attaching the current user
|
|
158
|
+
|
|
159
|
+
```ts
|
|
160
|
+
import { flare } from '@flareapp/electron/main';
|
|
161
|
+
|
|
162
|
+
flare.setUser({ id: 123, email: 'user@example.com', username: 'jane' });
|
|
163
|
+
flare.setUser(null); // clear on logout
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
The user is attached to main-process reports and to forwarded renderer reports.
|
|
167
|
+
|
|
168
|
+
## Not captured
|
|
169
|
+
|
|
170
|
+
- Native crashes / Crashpad minidumps.
|
|
171
|
+
- Errors that occur before `flare.light('your-key')` runs in the main process. The fatal process handlers are attached by `light()`, and no report is sent without a key, so call `light()` as early as possible in your main entry. Errors before that point (in any process) are not sent.
|
|
172
|
+
|
|
173
|
+
This is an experimental release — see the note at the top.
|
package/dist/main.cjs
ADDED
|
@@ -0,0 +1,486 @@
|
|
|
1
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });
|
|
2
|
+
//#region \0rolldown/runtime.js
|
|
3
|
+
var __create = Object.create;
|
|
4
|
+
var __defProp = Object.defineProperty;
|
|
5
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
6
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
7
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
8
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
9
|
+
var __copyProps = (to, from, except, desc) => {
|
|
10
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
11
|
+
for (var keys = __getOwnPropNames(from), i = 0, n = keys.length, key; i < n; i++) {
|
|
12
|
+
key = keys[i];
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except) {
|
|
14
|
+
__defProp(to, key, {
|
|
15
|
+
get: ((k) => from[k]).bind(null, key),
|
|
16
|
+
enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
return to;
|
|
22
|
+
};
|
|
23
|
+
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", {
|
|
24
|
+
value: mod,
|
|
25
|
+
enumerable: true
|
|
26
|
+
}) : target, mod));
|
|
27
|
+
|
|
28
|
+
//#endregion
|
|
29
|
+
let electron = require("electron");
|
|
30
|
+
let _flareapp_core = require("@flareapp/core");
|
|
31
|
+
let node_os = require("node:os");
|
|
32
|
+
node_os = __toESM(node_os);
|
|
33
|
+
let node_fs_promises = require("node:fs/promises");
|
|
34
|
+
let node_url = require("node:url");
|
|
35
|
+
|
|
36
|
+
//#region src/env.ts
|
|
37
|
+
const CLIENT_VERSION = typeof process !== "undefined" && true ? "0.1.0" : "?";
|
|
38
|
+
|
|
39
|
+
//#endregion
|
|
40
|
+
//#region src/types.ts
|
|
41
|
+
const DEFAULT_ELECTRON_OPTIONS = {
|
|
42
|
+
uncaughtExceptionMode: "report-and-exit",
|
|
43
|
+
unhandledRejectionMode: "report-and-exit",
|
|
44
|
+
shutdownTimeoutMs: 2e3,
|
|
45
|
+
captureRenderProcessGone: true,
|
|
46
|
+
trustedProtocols: [],
|
|
47
|
+
trustSender: null,
|
|
48
|
+
maxReportBytes: 1e6
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
//#endregion
|
|
52
|
+
//#region src/main/collectElectron.ts
|
|
53
|
+
/**
|
|
54
|
+
* App + runtime attributes that are safe to overlay onto ANY report regardless of origin.
|
|
55
|
+
* Reused by both the collector (main errors) and the IPC receiver (forwarded renderer reports).
|
|
56
|
+
*
|
|
57
|
+
* Intentionally does NOT include per-process fields like `flare.entry_point.type` or `process.type`
|
|
58
|
+
* so that forwarded renderer reports keep their own `flare.entry_point.type: 'web'` intact.
|
|
59
|
+
*/
|
|
60
|
+
function collectElectronAppAttributes(app) {
|
|
61
|
+
const versions = process.versions;
|
|
62
|
+
const attrs = {
|
|
63
|
+
"service.name": app.getName(),
|
|
64
|
+
"app.version": app.getVersion(),
|
|
65
|
+
"app.packaged": app.isPackaged,
|
|
66
|
+
"process.runtime.name": "electron",
|
|
67
|
+
"process.runtime.version": versions.electron ?? "",
|
|
68
|
+
"process.versions.electron": versions.electron ?? "",
|
|
69
|
+
"process.versions.chrome": versions.chrome ?? "",
|
|
70
|
+
"process.versions.node": versions.node ?? process.version,
|
|
71
|
+
"host.arch": process.arch,
|
|
72
|
+
"os.type": node_os.default.type()
|
|
73
|
+
};
|
|
74
|
+
if (app.isReady()) try {
|
|
75
|
+
attrs["app.locale"] = app.getLocale();
|
|
76
|
+
} catch {}
|
|
77
|
+
return attrs;
|
|
78
|
+
}
|
|
79
|
+
/** Project a user into OTel enduser.* / client.address keys. */
|
|
80
|
+
function projectUser(user) {
|
|
81
|
+
const attrs = {};
|
|
82
|
+
if (!user) return attrs;
|
|
83
|
+
if (user.id !== void 0) attrs["enduser.id"] = String(user.id);
|
|
84
|
+
if (user.email !== void 0) attrs["enduser.email"] = user.email;
|
|
85
|
+
if (user.username !== void 0) attrs["enduser.username"] = user.username;
|
|
86
|
+
if (user.ipAddress !== void 0) attrs["client.address"] = user.ipAddress;
|
|
87
|
+
return attrs;
|
|
88
|
+
}
|
|
89
|
+
/** Build the ContextCollector core calls on every main-process report. */
|
|
90
|
+
function makeElectronContextCollector(app, getUser) {
|
|
91
|
+
return (_config) => ({
|
|
92
|
+
"flare.entry_point.type": "server",
|
|
93
|
+
"process.type": process.type ?? "browser",
|
|
94
|
+
...collectElectronAppAttributes(app),
|
|
95
|
+
...projectUser(getUser())
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
//#endregion
|
|
100
|
+
//#region src/main/ElectronDiskFileReader.ts
|
|
101
|
+
/** Reads source snippets for main-process stack frames from disk. Mirrors @flareapp/node's DiskFileReader. */
|
|
102
|
+
var ElectronDiskFileReader = class {
|
|
103
|
+
async read(url) {
|
|
104
|
+
if (!isLocalFileUrl(url)) return null;
|
|
105
|
+
try {
|
|
106
|
+
return await (0, node_fs_promises.readFile)(/^file:\/\//i.test(url) ? (0, node_url.fileURLToPath)(url) : url, "utf-8");
|
|
107
|
+
} catch {
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
function isLocalFileUrl(url) {
|
|
113
|
+
return /^file:\/\//i.test(url) || url.startsWith("/") || /^[a-z]:[\\/]/i.test(url) || url.startsWith("\\\\");
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
//#endregion
|
|
117
|
+
//#region src/main/ElectronFlushScheduler.ts
|
|
118
|
+
/** Flushes pending logs/reports when the Electron app is quitting. App is injectable for tests. */
|
|
119
|
+
var ElectronFlushScheduler = class {
|
|
120
|
+
listener = null;
|
|
121
|
+
constructor(app) {
|
|
122
|
+
this.app = app;
|
|
123
|
+
}
|
|
124
|
+
register(flush) {
|
|
125
|
+
if (this.listener) return;
|
|
126
|
+
this.listener = () => {
|
|
127
|
+
flush();
|
|
128
|
+
};
|
|
129
|
+
this.app.on("before-quit", this.listener);
|
|
130
|
+
}
|
|
131
|
+
/** Detach the before-quit listener so a disposed ElectronFlare leaves no flush handler on the shared app. */
|
|
132
|
+
dispose() {
|
|
133
|
+
if (this.listener) {
|
|
134
|
+
this.app.off("before-quit", this.listener);
|
|
135
|
+
this.listener = null;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
//#endregion
|
|
141
|
+
//#region src/constants.ts
|
|
142
|
+
/** IPC channel renderer reports travel on. Shared by preload, renderer, and main. */
|
|
143
|
+
const FLARE_IPC_CHANNEL = "flare:report";
|
|
144
|
+
/** Global key the preload bridge exposes in the renderer's main world. */
|
|
145
|
+
const FLARE_BRIDGE_KEY = "__flare";
|
|
146
|
+
|
|
147
|
+
//#endregion
|
|
148
|
+
//#region src/main/ipcReceiver.ts
|
|
149
|
+
/** Module-level ownership token for the single flare:report channel. */
|
|
150
|
+
let currentOwner = null;
|
|
151
|
+
/** Default sender-trust check: accept file: and localhost/127.0.0.1 only, plus configured custom protocols. */
|
|
152
|
+
function defaultTrustPolicy(frame, opts) {
|
|
153
|
+
let parsed;
|
|
154
|
+
try {
|
|
155
|
+
parsed = new URL(frame.url);
|
|
156
|
+
} catch {
|
|
157
|
+
return false;
|
|
158
|
+
}
|
|
159
|
+
const scheme = parsed.protocol.replace(/:$/, "");
|
|
160
|
+
if (scheme === "file") return parsed.hostname === "" || parsed.hostname === "localhost";
|
|
161
|
+
const isLoopback = parsed.hostname === "localhost" || parsed.hostname === "127.0.0.1" || parsed.hostname === "[::1]";
|
|
162
|
+
if ((scheme === "http" || scheme === "https") && isLoopback) return true;
|
|
163
|
+
if (opts.trustedProtocols.includes(scheme)) return true;
|
|
164
|
+
return false;
|
|
165
|
+
}
|
|
166
|
+
function isTrusted(frame, opts) {
|
|
167
|
+
if (!frame || typeof frame.url !== "string") return false;
|
|
168
|
+
if (opts.trustSender) return opts.trustSender(frame);
|
|
169
|
+
return defaultTrustPolicy(frame, opts);
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* Minimal top-level structural guard for a parsed report. This is intentionally NOT an exhaustive
|
|
173
|
+
* validator of every StackFrame / SpanEvent: the renderer builds the report with our own Flare code,
|
|
174
|
+
* and a compromised renderer could forge any valid-looking shape anyway. The real security boundary
|
|
175
|
+
* is the sender-trust check plus the byte-size cap; this guard only rejects obviously-wrong payloads.
|
|
176
|
+
*/
|
|
177
|
+
function isReportShape(value) {
|
|
178
|
+
if (typeof value !== "object" || value === null) return false;
|
|
179
|
+
const r = value;
|
|
180
|
+
return typeof r.seenAtUnixNano === "number" && Array.isArray(r.stacktrace) && Array.isArray(r.events) && typeof r.attributes === "object" && r.attributes !== null && !Array.isArray(r.attributes);
|
|
181
|
+
}
|
|
182
|
+
function registerIpcReceiver(ipcMain, owner, deps) {
|
|
183
|
+
if (currentOwner === owner) return;
|
|
184
|
+
if (currentOwner !== null) ipcMain.removeHandler(FLARE_IPC_CHANNEL);
|
|
185
|
+
currentOwner = owner;
|
|
186
|
+
ipcMain.handle(FLARE_IPC_CHANNEL, (async (event, payload) => {
|
|
187
|
+
const opts = deps.getOptions();
|
|
188
|
+
if (!isTrusted(event.senderFrame, opts)) return;
|
|
189
|
+
if (typeof payload !== "string") return;
|
|
190
|
+
if (Buffer.byteLength(payload, "utf8") > opts.maxReportBytes) return;
|
|
191
|
+
let parsed;
|
|
192
|
+
try {
|
|
193
|
+
parsed = JSON.parse(payload);
|
|
194
|
+
} catch {
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
if (!isReportShape(parsed)) return;
|
|
198
|
+
await deps.onReport(parsed);
|
|
199
|
+
}));
|
|
200
|
+
}
|
|
201
|
+
function disposeIpcReceiver(ipcMain, owner) {
|
|
202
|
+
if (currentOwner !== owner) return;
|
|
203
|
+
ipcMain.removeHandler(FLARE_IPC_CHANNEL);
|
|
204
|
+
currentOwner = null;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
//#endregion
|
|
208
|
+
//#region src/main/processHandlers.ts
|
|
209
|
+
/**
|
|
210
|
+
* Fatal callbacks for Electron main. Mirrors @flareapp/node's buildFatalCallbacks but exits via
|
|
211
|
+
* the injected `exit` (defaulting to app.exit at the call site), which is immediate and skips
|
|
212
|
+
* before-quit/will-quit, correct after an uncaught exception.
|
|
213
|
+
*/
|
|
214
|
+
function buildFatalCallbacks(flare, getOpts, exit) {
|
|
215
|
+
return {
|
|
216
|
+
async onUncaught(err, origin) {
|
|
217
|
+
const opts = getOpts();
|
|
218
|
+
if (opts.uncaughtExceptionMode === "report-and-exit") process.exitCode = 1;
|
|
219
|
+
const error = err instanceof Error ? err : new Error(String(err));
|
|
220
|
+
try {
|
|
221
|
+
await flare.report(error, { "process.uncaught_exception.origin": origin });
|
|
222
|
+
} catch {}
|
|
223
|
+
if (opts.uncaughtExceptionMode === "report-and-exit") {
|
|
224
|
+
await flare.flush(opts.shutdownTimeoutMs);
|
|
225
|
+
exit(1);
|
|
226
|
+
}
|
|
227
|
+
},
|
|
228
|
+
async onRejection(reason) {
|
|
229
|
+
const opts = getOpts();
|
|
230
|
+
if (opts.unhandledRejectionMode === "report-and-exit") process.exitCode = 1;
|
|
231
|
+
const error = reason instanceof Error ? reason : new Error(String(reason));
|
|
232
|
+
try {
|
|
233
|
+
await flare.report(error);
|
|
234
|
+
} catch {}
|
|
235
|
+
if (opts.unhandledRejectionMode === "report-and-exit") {
|
|
236
|
+
await flare.flush(opts.shutdownTimeoutMs);
|
|
237
|
+
exit(1);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
/**
|
|
243
|
+
* Owns the lifecycle of the two process-level error listeners for the Electron main process.
|
|
244
|
+
* Mirrors node's ProcessHandlerManager. Attach/detach the two process listeners, reconciling
|
|
245
|
+
* against the desired modes.
|
|
246
|
+
*/
|
|
247
|
+
var ProcessHandlerManager = class {
|
|
248
|
+
uncaughtHandler = null;
|
|
249
|
+
rejectionHandler = null;
|
|
250
|
+
constructor(cbs) {
|
|
251
|
+
this.cbs = cbs;
|
|
252
|
+
}
|
|
253
|
+
reconcile(opts) {
|
|
254
|
+
this.reconcileOne("uncaughtException", opts.uncaughtExceptionMode, () => this.uncaughtHandler, (h) => {
|
|
255
|
+
this.uncaughtHandler = h;
|
|
256
|
+
}, (err, origin) => this.cbs.onUncaught(err, origin));
|
|
257
|
+
this.reconcileOne("unhandledRejection", opts.unhandledRejectionMode, () => this.rejectionHandler, (h) => {
|
|
258
|
+
this.rejectionHandler = h;
|
|
259
|
+
}, (reason) => this.cbs.onRejection(reason));
|
|
260
|
+
}
|
|
261
|
+
detach() {
|
|
262
|
+
if (this.uncaughtHandler) {
|
|
263
|
+
process.off("uncaughtException", this.uncaughtHandler);
|
|
264
|
+
this.uncaughtHandler = null;
|
|
265
|
+
}
|
|
266
|
+
if (this.rejectionHandler) {
|
|
267
|
+
process.off("unhandledRejection", this.rejectionHandler);
|
|
268
|
+
this.rejectionHandler = null;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
reconcileOne(event, mode, get, set, impl) {
|
|
272
|
+
const current = get();
|
|
273
|
+
const wants = mode !== "off";
|
|
274
|
+
if (wants && !current) {
|
|
275
|
+
set(impl);
|
|
276
|
+
process.on(event, impl);
|
|
277
|
+
} else if (!wants && current) {
|
|
278
|
+
process.off(event, current);
|
|
279
|
+
set(null);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
};
|
|
283
|
+
|
|
284
|
+
//#endregion
|
|
285
|
+
//#region src/main/ElectronFlare.ts
|
|
286
|
+
const SDK_NAME = "@flareapp/electron";
|
|
287
|
+
/** Write `value` under `key` when non-empty, otherwise remove any renderer-supplied value. */
|
|
288
|
+
function overlayOrDelete(attributes, key, value) {
|
|
289
|
+
if (value) attributes[key] = value;
|
|
290
|
+
else delete attributes[key];
|
|
291
|
+
}
|
|
292
|
+
var ElectronFlare = class extends _flareapp_core.Flare {
|
|
293
|
+
app;
|
|
294
|
+
ipcMain;
|
|
295
|
+
options = { ...DEFAULT_ELECTRON_OPTIONS };
|
|
296
|
+
user = null;
|
|
297
|
+
isLit = false;
|
|
298
|
+
handlerManager;
|
|
299
|
+
renderGoneHandler = null;
|
|
300
|
+
childGoneHandler = null;
|
|
301
|
+
forwardedInFlight = /* @__PURE__ */ new Set();
|
|
302
|
+
flushScheduler;
|
|
303
|
+
mainStage = "";
|
|
304
|
+
mainVersion = "";
|
|
305
|
+
mainSourcemapVersionId = "";
|
|
306
|
+
constructor(deps) {
|
|
307
|
+
const app = deps.app;
|
|
308
|
+
const collector = makeElectronContextCollector(app, () => this.user);
|
|
309
|
+
const flushScheduler = new ElectronFlushScheduler(app);
|
|
310
|
+
super(new _flareapp_core.Api(), collector, new ElectronDiskFileReader(), new _flareapp_core.GlobalScopeProvider(), flushScheduler);
|
|
311
|
+
this.app = app;
|
|
312
|
+
this.ipcMain = deps.ipcMain;
|
|
313
|
+
this.flushScheduler = flushScheduler;
|
|
314
|
+
this.setSdkInfo({
|
|
315
|
+
name: SDK_NAME,
|
|
316
|
+
version: CLIENT_VERSION
|
|
317
|
+
});
|
|
318
|
+
this.handlerManager = new ProcessHandlerManager(buildFatalCallbacks(this, () => this.options, (code) => this.app.exit(code)));
|
|
319
|
+
registerIpcReceiver(this.ipcMain, this, {
|
|
320
|
+
getOptions: () => this.options,
|
|
321
|
+
onReport: (report) => this.receiveRendererReport(report)
|
|
322
|
+
});
|
|
323
|
+
this.reconcileCrashListeners();
|
|
324
|
+
}
|
|
325
|
+
configure(config) {
|
|
326
|
+
if (config.stage !== void 0) this.mainStage = config.stage;
|
|
327
|
+
if (config.version !== void 0) this.mainVersion = config.version;
|
|
328
|
+
if (config.sourcemapVersionId !== void 0) this.mainSourcemapVersionId = config.sourcemapVersionId;
|
|
329
|
+
return super.configure(config);
|
|
330
|
+
}
|
|
331
|
+
light(key, debug) {
|
|
332
|
+
super.light(key, debug);
|
|
333
|
+
this.isLit = true;
|
|
334
|
+
this.handlerManager.reconcile(this.options);
|
|
335
|
+
return this;
|
|
336
|
+
}
|
|
337
|
+
configureElectron(partial) {
|
|
338
|
+
if (partial.uncaughtExceptionMode !== void 0) this.options.uncaughtExceptionMode = partial.uncaughtExceptionMode;
|
|
339
|
+
if (partial.unhandledRejectionMode !== void 0) this.options.unhandledRejectionMode = partial.unhandledRejectionMode;
|
|
340
|
+
if (partial.shutdownTimeoutMs !== void 0) this.options.shutdownTimeoutMs = partial.shutdownTimeoutMs;
|
|
341
|
+
if (partial.captureRenderProcessGone !== void 0) this.options.captureRenderProcessGone = partial.captureRenderProcessGone;
|
|
342
|
+
if (partial.trustedProtocols !== void 0) this.options.trustedProtocols = Array.isArray(partial.trustedProtocols) ? partial.trustedProtocols : [];
|
|
343
|
+
if (partial.trustSender !== void 0) this.options.trustSender = partial.trustSender;
|
|
344
|
+
if (partial.maxReportBytes !== void 0 && Number.isFinite(partial.maxReportBytes)) this.options.maxReportBytes = partial.maxReportBytes;
|
|
345
|
+
if (this.isLit) this.handlerManager.reconcile(this.options);
|
|
346
|
+
this.reconcileCrashListeners();
|
|
347
|
+
return this;
|
|
348
|
+
}
|
|
349
|
+
setUser(user) {
|
|
350
|
+
this.user = user;
|
|
351
|
+
}
|
|
352
|
+
dispose() {
|
|
353
|
+
this.handlerManager.detach();
|
|
354
|
+
this.detachCrashListeners();
|
|
355
|
+
disposeIpcReceiver(this.ipcMain, this);
|
|
356
|
+
this.flushScheduler.dispose();
|
|
357
|
+
}
|
|
358
|
+
/** Attach or detach the process-gone listeners to match options.captureRenderProcessGone. Idempotent. */
|
|
359
|
+
reconcileCrashListeners() {
|
|
360
|
+
const want = this.options.captureRenderProcessGone;
|
|
361
|
+
const attached = this.renderGoneHandler !== null;
|
|
362
|
+
if (want && !attached) {
|
|
363
|
+
this.renderGoneHandler = (_event, webContents, details) => {
|
|
364
|
+
return this.reportProcessGone("renderer", details, webContents?.id);
|
|
365
|
+
};
|
|
366
|
+
this.childGoneHandler = (_event, details) => {
|
|
367
|
+
return this.reportProcessGone("child", details);
|
|
368
|
+
};
|
|
369
|
+
this.app.on("render-process-gone", this.renderGoneHandler);
|
|
370
|
+
this.app.on("child-process-gone", this.childGoneHandler);
|
|
371
|
+
} else if (!want && attached) this.detachCrashListeners();
|
|
372
|
+
}
|
|
373
|
+
detachCrashListeners() {
|
|
374
|
+
if (this.renderGoneHandler) {
|
|
375
|
+
this.app.off("render-process-gone", this.renderGoneHandler);
|
|
376
|
+
this.renderGoneHandler = null;
|
|
377
|
+
}
|
|
378
|
+
if (this.childGoneHandler) {
|
|
379
|
+
this.app.off("child-process-gone", this.childGoneHandler);
|
|
380
|
+
this.childGoneHandler = null;
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
reportProcessGone(kind, details, webContentsId) {
|
|
384
|
+
const reason = details.reason ?? "unknown";
|
|
385
|
+
const label = kind === "renderer" ? "Renderer process gone" : "Child process gone";
|
|
386
|
+
const error = /* @__PURE__ */ new Error(`${label}: ${reason}`);
|
|
387
|
+
const attrs = {
|
|
388
|
+
"electron.process_gone.kind": kind,
|
|
389
|
+
"electron.process_gone.reason": reason
|
|
390
|
+
};
|
|
391
|
+
if (details.exitCode !== void 0) attrs["electron.process_gone.exit_code"] = details.exitCode;
|
|
392
|
+
if (details.type !== void 0) attrs["electron.process_gone.type"] = details.type;
|
|
393
|
+
if (details.serviceName !== void 0) attrs["electron.process_gone.service_name"] = details.serviceName;
|
|
394
|
+
if (webContentsId !== void 0) attrs["electron.process_gone.web_contents_id"] = webContentsId;
|
|
395
|
+
return this.report(error, attrs);
|
|
396
|
+
}
|
|
397
|
+
/**
|
|
398
|
+
* Wait for both core's tracked reports AND forwarded renderer reports (which bypass core's
|
|
399
|
+
* private track()), bounded by timeoutMs. The timeout is cleared when all reports settle
|
|
400
|
+
* first, so the event loop is not kept alive unnecessarily.
|
|
401
|
+
*/
|
|
402
|
+
flush(timeoutMs = 2e3) {
|
|
403
|
+
const settled = Promise.allSettled([super.flush(timeoutMs), ...this.forwardedInFlight]);
|
|
404
|
+
return new Promise((resolve) => {
|
|
405
|
+
const timer = setTimeout(resolve, timeoutMs);
|
|
406
|
+
settled.then(() => {
|
|
407
|
+
clearTimeout(timer);
|
|
408
|
+
resolve();
|
|
409
|
+
});
|
|
410
|
+
});
|
|
411
|
+
}
|
|
412
|
+
/** Overlay main-authoritative config + Electron metadata + user onto a forwarded report, then send. */
|
|
413
|
+
receiveRendererReport(report) {
|
|
414
|
+
Object.assign(report.attributes, collectElectronAppAttributes(this.app), projectUser(this.user));
|
|
415
|
+
overlayOrDelete(report.attributes, "service.stage", this.mainStage);
|
|
416
|
+
overlayOrDelete(report.attributes, "service.version", this.mainVersion);
|
|
417
|
+
if (this.mainSourcemapVersionId) report.sourcemapVersionId = this.mainSourcemapVersionId;
|
|
418
|
+
else delete report.sourcemapVersionId;
|
|
419
|
+
const sent = this.sendReport(report).finally(() => {
|
|
420
|
+
this.forwardedInFlight.delete(sent);
|
|
421
|
+
});
|
|
422
|
+
this.forwardedInFlight.add(sent);
|
|
423
|
+
return sent;
|
|
424
|
+
}
|
|
425
|
+
};
|
|
426
|
+
|
|
427
|
+
//#endregion
|
|
428
|
+
//#region src/main.ts
|
|
429
|
+
const flare = new ElectronFlare({
|
|
430
|
+
app: electron.app,
|
|
431
|
+
ipcMain: electron.ipcMain
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
//#endregion
|
|
435
|
+
Object.defineProperty(exports, 'DEFAULT_URL_DENYLIST', {
|
|
436
|
+
enumerable: true,
|
|
437
|
+
get: function () {
|
|
438
|
+
return _flareapp_core.DEFAULT_URL_DENYLIST;
|
|
439
|
+
}
|
|
440
|
+
});
|
|
441
|
+
exports.ElectronFlare = ElectronFlare;
|
|
442
|
+
exports.FLARE_BRIDGE_KEY = FLARE_BRIDGE_KEY;
|
|
443
|
+
exports.FLARE_IPC_CHANNEL = FLARE_IPC_CHANNEL;
|
|
444
|
+
Object.defineProperty(exports, 'GlobalScopeProvider', {
|
|
445
|
+
enumerable: true,
|
|
446
|
+
get: function () {
|
|
447
|
+
return _flareapp_core.GlobalScopeProvider;
|
|
448
|
+
}
|
|
449
|
+
});
|
|
450
|
+
Object.defineProperty(exports, 'Logger', {
|
|
451
|
+
enumerable: true,
|
|
452
|
+
get: function () {
|
|
453
|
+
return _flareapp_core.Logger;
|
|
454
|
+
}
|
|
455
|
+
});
|
|
456
|
+
Object.defineProperty(exports, 'NullFileReader', {
|
|
457
|
+
enumerable: true,
|
|
458
|
+
get: function () {
|
|
459
|
+
return _flareapp_core.NullFileReader;
|
|
460
|
+
}
|
|
461
|
+
});
|
|
462
|
+
Object.defineProperty(exports, 'Scope', {
|
|
463
|
+
enumerable: true,
|
|
464
|
+
get: function () {
|
|
465
|
+
return _flareapp_core.Scope;
|
|
466
|
+
}
|
|
467
|
+
});
|
|
468
|
+
Object.defineProperty(exports, 'convertToError', {
|
|
469
|
+
enumerable: true,
|
|
470
|
+
get: function () {
|
|
471
|
+
return _flareapp_core.convertToError;
|
|
472
|
+
}
|
|
473
|
+
});
|
|
474
|
+
exports.flare = flare;
|
|
475
|
+
Object.defineProperty(exports, 'redactUrlQuery', {
|
|
476
|
+
enumerable: true,
|
|
477
|
+
get: function () {
|
|
478
|
+
return _flareapp_core.redactUrlQuery;
|
|
479
|
+
}
|
|
480
|
+
});
|
|
481
|
+
Object.defineProperty(exports, 'resolveDenylist', {
|
|
482
|
+
enumerable: true,
|
|
483
|
+
get: function () {
|
|
484
|
+
return _flareapp_core.resolveDenylist;
|
|
485
|
+
}
|
|
486
|
+
});
|