@reticlehq/electron 2.5.0 → 2.7.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/main.cjs +43 -12
- package/package.json +2 -2
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
|
|
@@ -57,8 +57,9 @@ function usableContents(sender) {
|
|
|
57
57
|
*
|
|
58
58
|
* The daemon unlinks a capture after reading it, but only if it ever reads: a session that died, a
|
|
59
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,
|
|
61
|
-
*
|
|
60
|
+
* forever. Sweeping our own prefix on each capture bounds that to one file at a time, and the daemon
|
|
61
|
+
* removes this empty private directory in its existing shutdown path. Best-effort — a failed sweep
|
|
62
|
+
* must never fail a capture.
|
|
62
63
|
*
|
|
63
64
|
* Only files older than STALE_CAPTURE_MS are swept. Deleting every sibling unconditionally raced
|
|
64
65
|
* with CONCURRENT captures: with two screenshots in flight, the second one's sweep unlinked the
|
|
@@ -67,14 +68,37 @@ function usableContents(sender) {
|
|
|
67
68
|
*/
|
|
68
69
|
const STALE_CAPTURE_MS = 60_000;
|
|
69
70
|
|
|
71
|
+
/**
|
|
72
|
+
* This process's own capture directory, created 0700 — made once, on the first capture.
|
|
73
|
+
*
|
|
74
|
+
* A screenshot of your app window can hold customer records, a token on screen, an authenticated
|
|
75
|
+
* session. Written straight into the SHARED temp dir under a guessable name — a constant prefix, a
|
|
76
|
+
* readable pid and a counter from 0 — it is readable by any other local user for as long as it
|
|
77
|
+
* sits there, and a symlink pre-placed at that name would have `writeFile` follow it and write
|
|
78
|
+
* through with this app's privileges.
|
|
79
|
+
*
|
|
80
|
+
* `mkdtemp` closes both at once, and it is the DIRECTORY that does it rather than a less guessable
|
|
81
|
+
* filename: the OS creates it 0700 with a random suffix, so another user cannot enter it to read a
|
|
82
|
+
* capture, and cannot pre-create anything inside a directory that did not exist until this process
|
|
83
|
+
* made it. Guessing is not the property that matters; the shared parent was.
|
|
84
|
+
*
|
|
85
|
+
* Memoised as the PROMISE, not the resolved path, so two captures racing on the first screenshot
|
|
86
|
+
* await one `mkdtemp` instead of creating two directories and leaking one.
|
|
87
|
+
*/
|
|
88
|
+
let capturesDirPromise;
|
|
89
|
+
|
|
90
|
+
function capturesDir() {
|
|
91
|
+
capturesDirPromise ??= mkdtemp(join(tmpdir(), RETICLE_CAPTURE_FILE_PREFIX));
|
|
92
|
+
return capturesDirPromise;
|
|
93
|
+
}
|
|
94
|
+
|
|
70
95
|
async function sweepOldCaptures(dir, keepFile) {
|
|
71
|
-
const mine = `${RETICLE_CAPTURE_FILE_PREFIX}${String(process.pid)}-`;
|
|
72
96
|
const cutoff = Date.now() - STALE_CAPTURE_MS;
|
|
73
97
|
try {
|
|
74
98
|
const entries = await readdir(dir);
|
|
75
99
|
await Promise.all(
|
|
76
100
|
entries
|
|
77
|
-
.filter((name) =>
|
|
101
|
+
.filter((name) => join(dir, name) !== keepFile)
|
|
78
102
|
.map(async (name) => {
|
|
79
103
|
const file = join(dir, name);
|
|
80
104
|
const info = await stat(file).catch(() => undefined);
|
|
@@ -83,7 +107,7 @@ async function sweepOldCaptures(dir, keepFile) {
|
|
|
83
107
|
}),
|
|
84
108
|
);
|
|
85
109
|
} catch {
|
|
86
|
-
/* the
|
|
110
|
+
/* the capture directory is unreadable; a capture is still worth attempting */
|
|
87
111
|
}
|
|
88
112
|
}
|
|
89
113
|
|
|
@@ -116,12 +140,19 @@ function installReticleCapture(win) {
|
|
|
116
140
|
// always on the same machine here (the bridge is loopback), so a path is the honest channel:
|
|
117
141
|
// no size cap, no chunking, and nothing large on the event wire.
|
|
118
142
|
captureSeq += 1;
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
);
|
|
123
|
-
|
|
124
|
-
|
|
143
|
+
// Inside this process's own 0700 directory. A failure to create it throws to the catch
|
|
144
|
+
// below and answers no-image; it deliberately does NOT fall back to the shared temp dir,
|
|
145
|
+
// because that fallback is the very exposure this directory exists to remove.
|
|
146
|
+
const dir = await capturesDir();
|
|
147
|
+
const file = join(dir, `${RETICLE_CAPTURE_FILE_PREFIX}${String(captureSeq)}.png`);
|
|
148
|
+
// `wx` is O_CREAT|O_EXCL: it REFUSES an existing path instead of writing through it, and
|
|
149
|
+
// O_EXCL does not follow a final symlink. The 0700 directory already stops an attacker
|
|
150
|
+
// getting a symlink here, so this is the second lock on the same door — and the one that
|
|
151
|
+
// still holds if a later change moves this write back out into the shared temp dir. The
|
|
152
|
+
// sequence only ever climbs within a fresh per-process directory, so it never collides
|
|
153
|
+
// with a capture of our own.
|
|
154
|
+
await writeFile(file, image.toPNG(), { flag: 'wx' });
|
|
155
|
+
await sweepOldCaptures(dir, file);
|
|
125
156
|
return file;
|
|
126
157
|
} catch {
|
|
127
158
|
return null;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@reticlehq/electron",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.7.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.
|
|
11
|
+
"@reticlehq/core": "2.7.0"
|
|
12
12
|
},
|
|
13
13
|
"peerDependencies": {
|
|
14
14
|
"electron": ">=22"
|