@reticlehq/electron 2.3.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/LICENSE ADDED
@@ -0,0 +1,33 @@
1
+ Reticle — License overview
2
+ Copyright (c) 2026 Reticle Labs
3
+
4
+ Reticle uses a per-package license model so the SDK is safe to embed in your own
5
+ application while the server and enterprise features remain protected. The
6
+ license that applies depends on the package. Each package contains its own
7
+ LICENSE file, which is authoritative.
8
+
9
+ 1. Apache License, Version 2.0 — the wire contract + the embeddable SDK
10
+ Packages: @reticlehq/core, @reticlehq/browser, @reticlehq/react,
11
+ @reticlehq/babel-plugin, @reticlehq/next, @reticlehq/vite-plugin,
12
+ @reticlehq/eslint-plugin
13
+ @reticlehq/core is the bottom-of-graph wire contract (constants + zod schemas,
14
+ zod its only dependency); every embeddable SDK package above depends on it, so
15
+ it is Apache-2.0 too — that is what makes the SDK genuinely safe to embed. These
16
+ run inside / compile into your application. Use them anywhere, including in apps
17
+ you ship to your own customers. See the LICENSE and NOTICE files in each package.
18
+ (@reticlehq/protocol is a deprecated, unpublished Apache-2.0 alias of core.)
19
+
20
+ 2. Functional Source License, Version 1.1, Apache 2.0 future (FSL-1.1-ALv2)
21
+ Packages: @reticlehq/server, @reticlehq/test
22
+ Free for any Permitted Purpose (internal use, development, evaluation,
23
+ non-commercial research/education, professional services). The one
24
+ restriction is a Competing Use — offering Reticle itself as a competing
25
+ product or service. Each version converts to Apache-2.0 two years after
26
+ its release. See the LICENSE file in each package.
27
+
28
+ 3. The Reticle Enterprise License — enterprise features
29
+ Path: packages/server/src/ee/ (source-available)
30
+ Free for development and evaluation; production use requires a valid Reticle
31
+ Enterprise subscription license key. See packages/server/src/ee/LICENSE.
32
+
33
+ For OEM / embedding / commercial licensing questions: hey@reticle.sh
package/NOTICE ADDED
@@ -0,0 +1,5 @@
1
+ Reticle
2
+ Copyright 2026 Reticle Labs
3
+
4
+ This product includes software developed at Reticle Labs.
5
+ Licensed under the Apache License, Version 2.0.
package/README.md ADDED
@@ -0,0 +1,49 @@
1
+ # @reticlehq/electron
2
+
3
+ Reticle's Electron adapter — the two places a renderer cannot reach.
4
+
5
+ Everything else Reticle does inside an Electron app is the ordinary browser SDK (`@reticlehq/browser`). This package exists for the two things the renderer physically cannot do: observe IPC (a `contextBridge` object is deeply frozen and non-configurable) and screenshot the window (the renderer has no access to its own pixels).
6
+
7
+ Both are **dev-only**. Gate each `require` behind your own dev check so neither ships.
8
+
9
+ ## IPC observation — one line at the top of your preload
10
+
11
+ ```js
12
+ require('@reticlehq/electron/preload');
13
+ ```
14
+
15
+ It must come **first**, before your own `require('electron')` and before `contextBridge.exposeInMainWorld`. It wraps `ipcRenderer.invoke` while that function is still ordinary and writable, so every channel your preload goes on to expose is covered.
16
+
17
+ Each call then reaches the agent as an ordinary network record — `ipc://<channel>`, with `initiator: "ipc"`, the duration, and the error the main process actually threw:
18
+
19
+ ```
20
+ reticle_network { ok: false } → ipc://todos:archive ok:false "archive is not implemented"
21
+ ```
22
+
23
+ Without it, a desktop app's whole backend is invisible: `reticle_network` reports nothing, which reads as "this app makes no backend calls" rather than "you are blind to all of them". The SDK declares that as a `coverage: partial` blind spot rather than letting the silence pass, and `reticle doctor` names the missing line.
24
+
25
+ > A **sandboxed** preload cannot resolve `node_modules`. Either bundle the preload (electron-vite and Electron Forge do this by default, and the `require` is inlined at build time, so sandboxing stays on) or set `sandbox: false` for an unbundled dev preload.
26
+
27
+ ## Screenshots — one line in the main process
28
+
29
+ ```js
30
+ const { installReticleCapture } = require('@reticlehq/electron/main');
31
+
32
+ const win = new BrowserWindow({ ... });
33
+ installReticleCapture(win);
34
+ ```
35
+
36
+ `reticle_screenshot` and `reticle_visual_diff` now work. It uses `webContents.capturePage()`, which reads the window's own backing store — correct while the window is behind your editor, correct while backgrounded, and needing no screen-recording permission. Capturing a screen region was deliberately not used: it photographs whatever is on top, which would bank a picture of your editor as a visual baseline.
37
+
38
+ Safe to call for several windows; the handler registers once and answers for whichever window asked.
39
+
40
+ `{ fullPage: true }` is **refused**, not silently downgraded — `capturePage()` composites the viewport, and handing back a viewport image for a full-page request would bank a baseline that says nothing about the content below the fold.
41
+
42
+ ## Requirements
43
+
44
+ - Electron >= 22 (peer dependency)
45
+ - A renderer running `@reticlehq/browser` — see [Desktop apps: Electron & Tauri][docs].
46
+
47
+ [docs]: https://github.com/reticlehq/reticle/blob/main/docs/desktop-apps.md
48
+
49
+ Apache-2.0
package/main.cjs ADDED
@@ -0,0 +1,134 @@
1
+ 'use strict';
2
+ /**
3
+ * Reticle's Electron MAIN-process helper — the screenshot half of desktop support.
4
+ *
5
+ * const { installReticleCapture } = require('@reticlehq/electron/main');
6
+ * const win = new BrowserWindow({ ... });
7
+ * installReticleCapture(win);
8
+ *
9
+ * ...and `reticle_screenshot` / `reticle_visual_diff` work on your app.
10
+ *
11
+ * Why the main process: `webContents.capturePage()` reads the window's own BACKING STORE. That makes
12
+ * it correct while the window is behind your editor, correct while it is backgrounded, and free of
13
+ * any screen-recording permission. Capturing a screen region instead was tried and rejected — it
14
+ * photographs whatever is on top, which would quietly save a picture of your editor as a visual
15
+ * baseline. Renderer-side capture is not an option either: the renderer has no access to the pixels.
16
+ *
17
+ * Dev-only, like the rest of Reticle. Gate the require behind your dev check so it never ships.
18
+ */
19
+ const { ipcMain } = require('electron');
20
+ const { writeFile, readdir, stat, unlink } = require('node:fs/promises');
21
+ const { tmpdir } = require('node:os');
22
+ const { join } = require('node:path');
23
+ // Generated from @reticlehq/core's TypeScript source, so the main process, the preload and the
24
+ // daemon agree by construction rather than by three people copying the same string.
25
+ const {
26
+ RETICLE_CAPTURE_CHANNEL,
27
+ RETICLE_CAPTURE_FILE_PREFIX,
28
+ RETICLE_FULL_PAGE_UNSUPPORTED,
29
+ } = require('@reticlehq/core/desktop-contract');
30
+
31
+ let captureSeq = 0;
32
+ /** One ipcMain handler serves every window — the channel is global, so registration is once-only. */
33
+ let registered = false;
34
+ /** Windows this app registered, so a capture survives its requester being destroyed. */
35
+ const windows = new Set();
36
+
37
+ /**
38
+ * The webContents to photograph: the requester if it is alive, else the ONE remaining window.
39
+ *
40
+ * The fallback exists for a window that closed mid-command — answering "no image" there reports a
41
+ * capture failure with no cause. But it only applies when exactly one window is left, because with
42
+ * two the choice is a guess: picking "any" window silently returns a picture of a DIFFERENT window
43
+ * and reports it as a successful capture of the one that was asked for, which is the same
44
+ * photographed-the-wrong-thing failure that ruled out capturing a screen region in the first place.
45
+ * Ambiguous means no image, and no image is a result the caller can act on.
46
+ */
47
+ function usableContents(sender) {
48
+ if (sender !== null && sender !== undefined && !sender.isDestroyed()) return sender;
49
+ const alive = [...windows]
50
+ .map((win) => win?.webContents)
51
+ .filter((contents) => contents !== undefined && contents !== null && !contents.isDestroyed());
52
+ return alive.length === 1 ? alive[0] : null;
53
+ }
54
+
55
+ /**
56
+ * Delete this process's earlier captures before writing a new one.
57
+ *
58
+ * The daemon unlinks a capture after reading it, but only if it ever reads: a session that died, a
59
+ * command that timed out, or a path the daemon rejected all leave a ~300KB PNG in the temp directory
60
+ * forever. Sweeping our own prefix on each capture bounds that to one file at a time, with no timer
61
+ * to leak and no cleanup handler to forget. Best-effort — a failed sweep must never fail a capture.
62
+ *
63
+ * Only files older than STALE_CAPTURE_MS are swept. Deleting every sibling unconditionally raced
64
+ * with CONCURRENT captures: with two screenshots in flight, the second one's sweep unlinked the
65
+ * first one's PNG before the daemon had read it, and the daemon then answered `no-visual-provider`
66
+ * — which reads as "this app installed no capture helper" when the helper is installed and working.
67
+ */
68
+ const STALE_CAPTURE_MS = 60_000;
69
+
70
+ async function sweepOldCaptures(dir, keepFile) {
71
+ const mine = `${RETICLE_CAPTURE_FILE_PREFIX}${String(process.pid)}-`;
72
+ const cutoff = Date.now() - STALE_CAPTURE_MS;
73
+ try {
74
+ const entries = await readdir(dir);
75
+ await Promise.all(
76
+ entries
77
+ .filter((name) => name.startsWith(mine) && join(dir, name) !== keepFile)
78
+ .map(async (name) => {
79
+ const file = join(dir, name);
80
+ const info = await stat(file).catch(() => undefined);
81
+ if (info !== undefined && info.mtimeMs < cutoff)
82
+ await unlink(file).catch(() => undefined);
83
+ }),
84
+ );
85
+ } catch {
86
+ /* the temp dir is unreadable; a capture is still worth attempting */
87
+ }
88
+ }
89
+
90
+ /**
91
+ * Let Reticle screenshot this window. Safe to call for several windows; the handler is registered
92
+ * once and answers for whichever window asked, so a multi-window app needs no extra wiring.
93
+ */
94
+ function installReticleCapture(win) {
95
+ if (win === null || win === undefined) return;
96
+ // Remembered so a capture can still resolve if the requesting webContents has gone (a window
97
+ // closing mid-command), rather than returning null and reporting an unexplained capture failure.
98
+ windows.add(win);
99
+ win.on?.('closed', () => windows.delete(win));
100
+
101
+ if (!registered) {
102
+ ipcMain.handle(RETICLE_CAPTURE_CHANNEL, async (event, options) => {
103
+ // `capturePage()` reads the composited window, so there is no full-document render to ask for.
104
+ // Saying so beats handing back a viewport the caller thinks covers the whole scroll height.
105
+ if (options && options.fullPage === true) throw new Error(RETICLE_FULL_PAGE_UNSUPPORTED);
106
+ const contents = usableContents(event.sender);
107
+ if (contents === null) return null;
108
+ try {
109
+ const image = await contents.capturePage();
110
+ // An empty image means the window had nothing to compose yet; report it as no-image rather
111
+ // than handing back a 0-byte PNG that a diff would treat as a real, blank baseline.
112
+ if (image.isEmpty()) return null;
113
+ // Write to a temp FILE and return its path, rather than base64 over the bridge. The SDK's
114
+ // transport sanitizer caps every string at 64KB, so a real screenshot came back silently
115
+ // truncated — an invalid PNG that still reported `saved: true`. The daemon and the app are
116
+ // always on the same machine here (the bridge is loopback), so a path is the honest channel:
117
+ // no size cap, no chunking, and nothing large on the event wire.
118
+ captureSeq += 1;
119
+ const file = join(
120
+ tmpdir(),
121
+ `${RETICLE_CAPTURE_FILE_PREFIX}${String(process.pid)}-${String(captureSeq)}.png`,
122
+ );
123
+ await writeFile(file, image.toPNG());
124
+ await sweepOldCaptures(tmpdir(), file);
125
+ return file;
126
+ } catch {
127
+ return null;
128
+ }
129
+ });
130
+ registered = true;
131
+ }
132
+ }
133
+
134
+ module.exports = { installReticleCapture };
package/package.json ADDED
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "@reticlehq/electron",
3
+ "version": "2.3.0",
4
+ "description": "Reticle adapter for Electron: makes main-process IPC observable and the window screenshottable, from the two places the renderer cannot reach.",
5
+ "type": "module",
6
+ "exports": {
7
+ "./preload": "./preload.cjs",
8
+ "./main": "./main.cjs"
9
+ },
10
+ "dependencies": {
11
+ "@reticlehq/core": "2.3.0"
12
+ },
13
+ "peerDependencies": {
14
+ "electron": ">=22"
15
+ },
16
+ "license": "Apache-2.0",
17
+ "author": "Reticle contributors",
18
+ "repository": {
19
+ "type": "git",
20
+ "url": "git+https://github.com/reticlehq/reticle.git",
21
+ "directory": "packages/electron"
22
+ },
23
+ "homepage": "https://github.com/reticlehq/reticle#readme",
24
+ "bugs": "https://github.com/reticlehq/reticle/issues",
25
+ "files": [
26
+ "preload.cjs",
27
+ "main.cjs",
28
+ "README.md",
29
+ "NOTICE"
30
+ ],
31
+ "keywords": [
32
+ "electron",
33
+ "reticle",
34
+ "ai-agents",
35
+ "testing",
36
+ "instrumentation"
37
+ ]
38
+ }
package/preload.cjs ADDED
@@ -0,0 +1,238 @@
1
+ 'use strict';
2
+ /**
3
+ * Reticle's Electron preload shim. Add ONE line at the top of your preload, before you expose
4
+ * anything:
5
+ *
6
+ * require('@reticlehq/electron/preload');
7
+ *
8
+ * ...and every `ipcRenderer.invoke`, `sendSync` and `send` your app makes becomes a Reticle-visible
9
+ * request (`ipc://<channel>`), so net assertions and settle-waiting cover the main-process hop.
10
+ * A one-way `send` is recorded as DISPATCHED with no outcome, because that is all the renderer knows.
11
+ *
12
+ * Why a preload shim and not renderer-side patching, like every other Reticle observer:
13
+ * `contextBridge.exposeInMainWorld` hands the renderer a DEEPLY FROZEN object, installed on `window`
14
+ * as non-writable AND non-configurable. Neither assignment nor defineProperty can replace a method on
15
+ * it — verified, not assumed. The renderer physically cannot instrument an app's IPC surface. The
16
+ * preload is the last point where the functions are still ordinary and writable.
17
+ *
18
+ * This file is hand-written CommonJS on purpose: a sandboxed Electron preload is CJS-only and cannot
19
+ * load the ESM SDK build.
20
+ *
21
+ * Dev-only, like the rest of Reticle. Gate the require behind your dev check so it never ships.
22
+ */
23
+ const { contextBridge, ipcRenderer } = require('electron');
24
+ // The ONE definition of these strings, generated from @reticlehq/core's TypeScript source so a CJS
25
+ // preload and the ESM renderer cannot drift apart. Previously hand-copied into six files.
26
+ const {
27
+ RETICLE_IPC_GLOBAL,
28
+ RETICLE_CAPTURE_CHANNEL,
29
+ RETICLE_FULL_PAGE_UNSUPPORTED,
30
+ } = require('@reticlehq/core/desktop-contract');
31
+
32
+ /**
33
+ * Renderer-side subscribers, keyed by a token.
34
+ *
35
+ * A Map rather than one slot, and a token rather than the callback itself, for two reasons. A single
36
+ * slot meant a second `connect()` in the same renderer silently STOLE the first one's subscription —
37
+ * and "unsubscribing" was really "overwrite with a no-op", so an SDK teardown left the app in a
38
+ * different state than it found it. Tokens rather than function identity because a callback crosses
39
+ * `contextBridge` as a proxy, so the reference the preload holds is not the one the renderer passes
40
+ * back and `delete(callback)` would never match.
41
+ *
42
+ * Empty until a renderer subscribes, so records before `connect()` are dropped rather than queued —
43
+ * the SDK only wants activity from the moment it is watching.
44
+ */
45
+ const sinks = new Map();
46
+ let sinkToken = 0;
47
+ let seq = 0;
48
+
49
+ function report(record) {
50
+ for (const sink of sinks.values()) {
51
+ try {
52
+ sink(record);
53
+ } catch {
54
+ /* a renderer sink is best-effort; it must never break the app's IPC call */
55
+ }
56
+ }
57
+ }
58
+
59
+ /**
60
+ * Longest payload reported per IPC call, matching the HTTP body cap.
61
+ *
62
+ * A desktop app's ENTIRE API surface is IPC. The web side has recorded request/response bodies for a
63
+ * long time — which is what lets Reticle catch a batch whose 200 hides per-item failures, or a refund
64
+ * posting a major-unit number into a minor-unit field. On Electron none of that could ever fire,
65
+ * because this shim reported only channel, ok and duration and dropped the payloads it was already
66
+ * holding. Every payload-based check was silently web-only on the platform this release is about.
67
+ */
68
+ const IPC_BODY_MAX = 8192;
69
+
70
+ /**
71
+ * Serialize an IPC payload, or report why it could not be.
72
+ *
73
+ * Size is computed even when the body is not forwarded, because "a write returned ok and its payload
74
+ * went unread" is a caveat the verdict layer needs and it cannot infer it from an absent field.
75
+ */
76
+ function payload(value) {
77
+ if (value === undefined) return undefined;
78
+ try {
79
+ const text = JSON.stringify(value);
80
+ if (typeof text !== 'string') return undefined;
81
+ return text.length > IPC_BODY_MAX
82
+ ? { text: text.slice(0, IPC_BODY_MAX), size: text.length, truncated: true }
83
+ : { text, size: text.length };
84
+ } catch {
85
+ // Circular, or carrying something structured-cloneable but not JSON-able. Not an error — the
86
+ // call is still reported, just without a readable payload.
87
+ return undefined;
88
+ }
89
+ }
90
+
91
+ /** Wrap one invoke-style function so each call reports a start and an end. */
92
+ function observe(original, channelFromArg, name) {
93
+ return function reticleObservedIpc(...args) {
94
+ const id = `i${String(++seq)}`;
95
+ const channel = channelFromArg && typeof args[0] === 'string' ? args[0] : name;
96
+ const startedAt = Date.now();
97
+ report({ phase: 'start', id, channel });
98
+ // The arguments after the channel name ARE the request payload.
99
+ const request = payload(channelFromArg ? args.slice(1) : args);
100
+ const finish = (ok, error, value) => {
101
+ const response = payload(value);
102
+ report({
103
+ phase: 'end',
104
+ id,
105
+ channel,
106
+ ok,
107
+ durationMs: Date.now() - startedAt,
108
+ ...(error === undefined ? {} : { error }),
109
+ ...(request === undefined ? {} : { requestBody: request.text, requestSize: request.size }),
110
+ ...(response === undefined
111
+ ? {}
112
+ : {
113
+ responseBody: response.text,
114
+ responseSize: response.size,
115
+ ...(response.truncated === true ? { responseBodyTruncated: true } : {}),
116
+ }),
117
+ });
118
+ };
119
+ let result;
120
+ try {
121
+ result = original.apply(this, args);
122
+ } catch (err) {
123
+ finish(false, String(err && err.message ? err.message : err));
124
+ throw err;
125
+ }
126
+ if (result === null || typeof result !== 'object' || typeof result.then !== 'function') {
127
+ finish(true, undefined, result);
128
+ return result;
129
+ }
130
+ return result.then(
131
+ (value) => {
132
+ finish(true, undefined, value);
133
+ return value;
134
+ },
135
+ (err) => {
136
+ finish(false, String(err && err.message ? err.message : err));
137
+ throw err;
138
+ },
139
+ );
140
+ };
141
+ }
142
+
143
+ /**
144
+ * Wrap a ONE-WAY send. It has no verdict to wait for, so it is recorded as DISPATCHED and never as
145
+ * succeeded: `ok` and `status` are omitted entirely rather than defaulted to true/200.
146
+ *
147
+ * That distinction is the whole point. `ipcRenderer.send` hands the message to the main process and
148
+ * returns; whether the handler ran, threw, or does not exist is information the renderer never gets.
149
+ * Recording it as a 200 would manufacture a verdict nobody produced — so `reticle_network { ok }`
150
+ * matches it in NEITHER direction, and the record carries `oneWay: true` so a reader can see why
151
+ * there is no outcome rather than assume one.
152
+ *
153
+ * Only a throw at the call site (a closed port, a serialization failure) is a real failure the
154
+ * renderer can observe, and that one IS reported as `ok: false`.
155
+ */
156
+ function observeOneWay(original, name) {
157
+ return function reticleObservedOneWayIpc(...args) {
158
+ const id = `i${String(++seq)}`;
159
+ const channel = typeof args[0] === 'string' ? args[0] : name;
160
+ try {
161
+ const result = original.apply(this, args);
162
+ report({ phase: 'end', id, channel, oneWay: true, durationMs: 0 });
163
+ return result;
164
+ } catch (err) {
165
+ report({
166
+ phase: 'end',
167
+ id,
168
+ channel,
169
+ oneWay: true,
170
+ ok: false,
171
+ durationMs: 0,
172
+ error: String(err && err.message ? err.message : err),
173
+ });
174
+ throw err;
175
+ }
176
+ };
177
+ }
178
+
179
+ // Patch the three entry points an app's IPC actually leaves through, before its own
180
+ // `exposeInMainWorld` captures the references.
181
+ //
182
+ // `invoke` alone is NOT the whole surface — that claim used to be in this comment and it was wrong.
183
+ // A `send` + `on('reply')` pair is a mainstream Electron pattern, and an app built that way had
184
+ // EVERY backend call invisible while `coverage` still read full. `sendSync` genuinely returns its
185
+ // result, so it is observed like an invoke; `send` cannot be, so it is recorded as one-way.
186
+ //
187
+ // `postMessage` (MessagePort transfer) is deliberately not wrapped: the ports outlive the call, so a
188
+ // single record could not describe it honestly. An app using it should say so in its own signals.
189
+ const originalInvoke = ipcRenderer.invoke.bind(ipcRenderer);
190
+ ipcRenderer.invoke = observe(originalInvoke, true, 'invoke');
191
+ if (typeof ipcRenderer.sendSync === 'function') {
192
+ ipcRenderer.sendSync = observe(ipcRenderer.sendSync.bind(ipcRenderer), true, 'sendSync');
193
+ }
194
+ if (typeof ipcRenderer.send === 'function') {
195
+ ipcRenderer.send = observeOneWay(ipcRenderer.send.bind(ipcRenderer), 'send');
196
+ }
197
+
198
+ contextBridge.exposeInMainWorld(RETICLE_IPC_GLOBAL, {
199
+ /** Start receiving records. Returns a token to hand back to `unsubscribe`. */
200
+ subscribe(callback) {
201
+ if (typeof callback !== 'function') return -1;
202
+ sinkToken += 1;
203
+ sinks.set(sinkToken, callback);
204
+ return sinkToken;
205
+ },
206
+ /**
207
+ * Stop receiving records. A real removal, so an SDK teardown leaves the app as it found it.
208
+ *
209
+ * The `ipcRenderer.invoke` patch itself is deliberately NOT reverted: it is a pass-through with no
210
+ * observable effect once no sinks remain, and un-patching it mid-flight would strand any call that
211
+ * had already started inside the wrapper.
212
+ */
213
+ unsubscribe(token) {
214
+ sinks.delete(token);
215
+ },
216
+ /**
217
+ * Screenshot this window. Resolves to a base64 PNG, or null when the app did not install the
218
+ * main-process helper (`@reticlehq/electron/main`) — there is no handler to answer, and a
219
+ * missing screenshot must read as missing, never as a blank image.
220
+ *
221
+ * Uses the ORIGINAL invoke: this is Reticle's own plumbing, and recording it as an app IPC call
222
+ * would put `ipc://__reticle:capture` in the agent's own network evidence.
223
+ */
224
+ capture(fullPage) {
225
+ return originalInvoke(RETICLE_CAPTURE_CHANNEL, { fullPage: fullPage === true }).catch(
226
+ (error) => {
227
+ // A missing handler must stay `null` ("this app installed no capture helper"). An explicit
228
+ // refusal is different information and has to reach the tool, or `fullPage` would silently
229
+ // come back as a viewport image the caller believes is the whole page.
230
+ const message = String((error && error.message) || error);
231
+ if (message.includes(RETICLE_FULL_PAGE_UNSUPPORTED)) {
232
+ throw new Error(RETICLE_FULL_PAGE_UNSUPPORTED);
233
+ }
234
+ return null;
235
+ },
236
+ );
237
+ },
238
+ });