@reticlehq/electron 2.6.0 → 2.8.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.
Files changed (3) hide show
  1. package/README.md +4 -0
  2. package/main.cjs +59 -18
  3. package/package.json +2 -2
package/README.md CHANGED
@@ -6,6 +6,10 @@ Everything else Reticle does inside an Electron app is the ordinary browser SDK
6
6
 
7
7
  Both are **dev-only**. Gate each `require` behind your own dev check so neither ships.
8
8
 
9
+ ```bash
10
+ npm i -D @reticlehq/electron
11
+ ```
12
+
9
13
  ## IPC observation — one line at the top of your preload
10
14
 
11
15
  ```js
package/main.cjs CHANGED
@@ -17,7 +17,7 @@
17
17
  * Dev-only, like the rest of Reticle. Gate the require behind your dev check so it never ships.
18
18
  */
19
19
  const { ipcMain } = require('electron');
20
- const { writeFile, readdir, stat, unlink } = require('node:fs/promises');
20
+ const { writeFile, readdir, stat, unlink, mkdtemp } = require('node:fs/promises');
21
21
  const { tmpdir } = require('node:os');
22
22
  const { join } = require('node:path');
23
23
  // Generated from @reticlehq/core's TypeScript source, so the main process, the preload and the
@@ -26,6 +26,7 @@ const {
26
26
  RETICLE_CAPTURE_CHANNEL,
27
27
  RETICLE_CAPTURE_FILE_PREFIX,
28
28
  RETICLE_FULL_PAGE_UNSUPPORTED,
29
+ RETICLE_NOT_COMPOSITED,
29
30
  } = require('@reticlehq/core/desktop-contract');
30
31
 
31
32
  let captureSeq = 0;
@@ -57,8 +58,9 @@ function usableContents(sender) {
57
58
  *
58
59
  * The daemon unlinks a capture after reading it, but only if it ever reads: a session that died, a
59
60
  * 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.
61
+ * forever. Sweeping our own prefix on each capture bounds that to one file at a time, and the daemon
62
+ * removes this empty private directory in its existing shutdown path. Best-effort — a failed sweep
63
+ * must never fail a capture.
62
64
  *
63
65
  * Only files older than STALE_CAPTURE_MS are swept. Deleting every sibling unconditionally raced
64
66
  * with CONCURRENT captures: with two screenshots in flight, the second one's sweep unlinked the
@@ -67,14 +69,37 @@ function usableContents(sender) {
67
69
  */
68
70
  const STALE_CAPTURE_MS = 60_000;
69
71
 
72
+ /**
73
+ * This process's own capture directory, created 0700 — made once, on the first capture.
74
+ *
75
+ * A screenshot of your app window can hold customer records, a token on screen, an authenticated
76
+ * session. Written straight into the SHARED temp dir under a guessable name — a constant prefix, a
77
+ * readable pid and a counter from 0 — it is readable by any other local user for as long as it
78
+ * sits there, and a symlink pre-placed at that name would have `writeFile` follow it and write
79
+ * through with this app's privileges.
80
+ *
81
+ * `mkdtemp` closes both at once, and it is the DIRECTORY that does it rather than a less guessable
82
+ * filename: the OS creates it 0700 with a random suffix, so another user cannot enter it to read a
83
+ * capture, and cannot pre-create anything inside a directory that did not exist until this process
84
+ * made it. Guessing is not the property that matters; the shared parent was.
85
+ *
86
+ * Memoised as the PROMISE, not the resolved path, so two captures racing on the first screenshot
87
+ * await one `mkdtemp` instead of creating two directories and leaking one.
88
+ */
89
+ let capturesDirPromise;
90
+
91
+ function capturesDir() {
92
+ capturesDirPromise ??= mkdtemp(join(tmpdir(), RETICLE_CAPTURE_FILE_PREFIX));
93
+ return capturesDirPromise;
94
+ }
95
+
70
96
  async function sweepOldCaptures(dir, keepFile) {
71
- const mine = `${RETICLE_CAPTURE_FILE_PREFIX}${String(process.pid)}-`;
72
97
  const cutoff = Date.now() - STALE_CAPTURE_MS;
73
98
  try {
74
99
  const entries = await readdir(dir);
75
100
  await Promise.all(
76
101
  entries
77
- .filter((name) => name.startsWith(mine) && join(dir, name) !== keepFile)
102
+ .filter((name) => join(dir, name) !== keepFile)
78
103
  .map(async (name) => {
79
104
  const file = join(dir, name);
80
105
  const info = await stat(file).catch(() => undefined);
@@ -83,7 +108,7 @@ async function sweepOldCaptures(dir, keepFile) {
83
108
  }),
84
109
  );
85
110
  } catch {
86
- /* the temp dir is unreadable; a capture is still worth attempting */
111
+ /* the capture directory is unreadable; a capture is still worth attempting */
87
112
  }
88
113
  }
89
114
 
@@ -104,27 +129,43 @@ function installReticleCapture(win) {
104
129
  // Saying so beats handing back a viewport the caller thinks covers the whole scroll height.
105
130
  if (options && options.fullPage === true) throw new Error(RETICLE_FULL_PAGE_UNSUPPORTED);
106
131
  const contents = usableContents(event.sender);
107
- if (contents === null) return null;
132
+ // Named, not null. Every no-image answer used to be the same bare null, so a dead window, an
133
+ // uncomposited one and a thrown error were one silence — the tool reported `saved:false` with
134
+ // no bytes and no reason, which is unreadable in CI and useless to a user.
135
+ if (contents === null) throw new Error('no usable webContents to capture');
108
136
  try {
109
137
  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;
138
+ // An empty image means the window had nothing to compose yet, which is a fact about TIMING
139
+ // rather than a failure of the capture path — under a headless compositor a window that has
140
+ // not painted returns empty rather than erroring. Still never a 0-byte PNG, which a diff
141
+ // would trust as a real blank baseline; it is now reported by name instead.
142
+ if (image.isEmpty()) throw new Error(RETICLE_NOT_COMPOSITED);
113
143
  // Write to a temp FILE and return its path, rather than base64 over the bridge. The SDK's
114
144
  // transport sanitizer caps every string at 64KB, so a real screenshot came back silently
115
145
  // truncated — an invalid PNG that still reported `saved: true`. The daemon and the app are
116
146
  // always on the same machine here (the bridge is loopback), so a path is the honest channel:
117
147
  // no size cap, no chunking, and nothing large on the event wire.
118
148
  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);
149
+ // Inside this process's own 0700 directory. A failure to create it throws to the catch
150
+ // below and answers no-image; it deliberately does NOT fall back to the shared temp dir,
151
+ // because that fallback is the very exposure this directory exists to remove.
152
+ const dir = await capturesDir();
153
+ const file = join(dir, `${RETICLE_CAPTURE_FILE_PREFIX}${String(captureSeq)}.png`);
154
+ // `wx` is O_CREAT|O_EXCL: it REFUSES an existing path instead of writing through it, and
155
+ // O_EXCL does not follow a final symlink. The 0700 directory already stops an attacker
156
+ // getting a symlink here, so this is the second lock on the same door — and the one that
157
+ // still holds if a later change moves this write back out into the shared temp dir. The
158
+ // sequence only ever climbs within a fresh per-process directory, so it never collides
159
+ // with a capture of our own.
160
+ await writeFile(file, image.toPNG(), { flag: 'wx' });
161
+ await sweepOldCaptures(dir, file);
125
162
  return file;
126
- } catch {
127
- return null;
163
+ } catch (error) {
164
+ // Rethrow rather than swallow. The renderer maps the reasons it recognises onto the closed
165
+ // VisualReason vocabulary and passes anything else through as its own message, so a real
166
+ // failure (EACCES on the capture file, a window closing mid-write) now reaches the caller
167
+ // saying what it was. Swallowing it here is what made all of these indistinguishable.
168
+ throw error instanceof Error ? error : new Error(String(error));
128
169
  }
129
170
  });
130
171
  registered = true;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reticlehq/electron",
3
- "version": "2.6.0",
3
+ "version": "2.8.0",
4
4
  "description": "Reticle adapter for Electron: makes main-process IPC observable and the window screenshottable, from the two places the renderer cannot reach.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -8,7 +8,7 @@
8
8
  "./main": "./main.cjs"
9
9
  },
10
10
  "dependencies": {
11
- "@reticlehq/core": "2.6.0"
11
+ "@reticlehq/core": "2.8.0"
12
12
  },
13
13
  "peerDependencies": {
14
14
  "electron": ">=22"