@dimina-kit/devtools 0.3.2-dev.20260524104239 → 0.3.2-dev.20260525152421

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.
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Main-process disk reader/writer backing `difile://_store/*` and
3
+ * `difile://<usr-rel>` URLs.
4
+ *
5
+ * Spec: `packages/devtools/docs/file-system.md` §6 P1-1 / P1-3.
6
+ *
7
+ * - `readDiskFile(realPath, opts?)`: read a (possibly ranged) slice of a
8
+ * file inside the USER_DATA_PATH sandbox. Returns `bytes` plus the
9
+ * content metadata callers need to assemble an HTTP response without a
10
+ * second `fs.stat` round-trip (`mime`, `etag`, `totalSize`).
11
+ * - `writeDiskFile(realPath, bytes)`: write bytes into the sandbox,
12
+ * creating parent directories on demand. Accepts both `Buffer` and
13
+ * `ArrayBuffer`.
14
+ * - `readDiskDir(realPath)`: list immediate children, no '.' / '..'.
15
+ * - `statDiskFile(realPath)`: minimal stat surface (size/mtime/mode/flags).
16
+ *
17
+ * The caller is responsible for canonicalizing `realPath` through
18
+ * `resolveVPath` *before* invoking these helpers — disk.ts assumes the
19
+ * argument is already known to be inside the sandbox base.
20
+ *
21
+ * MIME resolution prefers magic-byte sniffing (first ~12 bytes) over file
22
+ * extension, falling back to `application/octet-stream` for both unknown
23
+ * extensions and unknown magic.
24
+ *
25
+ * ETag shape is `W/"<mtime-ms>-<size>"` — weak validator, both axes folded
26
+ * into one token so byte-equal rewrites at the same timestamp still match.
27
+ */
28
+ export interface DiskReadResult {
29
+ bytes: Buffer;
30
+ mime: string;
31
+ /** Weak ETag of the form `W/"<mtime>-<size>"`. */
32
+ etag: string;
33
+ /** Full file size in bytes, regardless of whether a Range slice was returned. */
34
+ totalSize: number;
35
+ }
36
+ export interface DiskReadOptions {
37
+ /** Inclusive byte range `[start, end]`. Omit to read the whole file. */
38
+ range?: {
39
+ start: number;
40
+ end: number;
41
+ };
42
+ }
43
+ export interface DiskStat {
44
+ size: number;
45
+ /** Unix milliseconds. */
46
+ mtime: number;
47
+ mode: number;
48
+ isFile: boolean;
49
+ isDirectory: boolean;
50
+ }
51
+ export declare function readDiskFile(realPath: string, opts?: DiskReadOptions): Promise<DiskReadResult>;
52
+ export declare function writeDiskFile(realPath: string, bytes: Buffer | ArrayBuffer): Promise<void>;
53
+ export declare function readDiskDir(realPath: string): Promise<string[]>;
54
+ export declare function statDiskFile(realPath: string): Promise<DiskStat>;
55
+ //# sourceMappingURL=disk.d.ts.map
@@ -0,0 +1,215 @@
1
+ /**
2
+ * Main-process disk reader/writer backing `difile://_store/*` and
3
+ * `difile://<usr-rel>` URLs.
4
+ *
5
+ * Spec: `packages/devtools/docs/file-system.md` §6 P1-1 / P1-3.
6
+ *
7
+ * - `readDiskFile(realPath, opts?)`: read a (possibly ranged) slice of a
8
+ * file inside the USER_DATA_PATH sandbox. Returns `bytes` plus the
9
+ * content metadata callers need to assemble an HTTP response without a
10
+ * second `fs.stat` round-trip (`mime`, `etag`, `totalSize`).
11
+ * - `writeDiskFile(realPath, bytes)`: write bytes into the sandbox,
12
+ * creating parent directories on demand. Accepts both `Buffer` and
13
+ * `ArrayBuffer`.
14
+ * - `readDiskDir(realPath)`: list immediate children, no '.' / '..'.
15
+ * - `statDiskFile(realPath)`: minimal stat surface (size/mtime/mode/flags).
16
+ *
17
+ * The caller is responsible for canonicalizing `realPath` through
18
+ * `resolveVPath` *before* invoking these helpers — disk.ts assumes the
19
+ * argument is already known to be inside the sandbox base.
20
+ *
21
+ * MIME resolution prefers magic-byte sniffing (first ~12 bytes) over file
22
+ * extension, falling back to `application/octet-stream` for both unknown
23
+ * extensions and unknown magic.
24
+ *
25
+ * ETag shape is `W/"<mtime-ms>-<size>"` — weak validator, both axes folded
26
+ * into one token so byte-equal rewrites at the same timestamp still match.
27
+ */
28
+ import fs from 'node:fs/promises';
29
+ import path from 'node:path';
30
+ // -- MIME detection ---------------------------------------------------------
31
+ /**
32
+ * Extension-based MIME fallback. Kept deliberately small — anything more
33
+ * exotic should land in the magic-byte sniffer above. Lowercase keys only.
34
+ */
35
+ const EXT_MIME = {
36
+ '.txt': 'text/plain',
37
+ '.log': 'text/plain',
38
+ '.md': 'text/markdown',
39
+ '.html': 'text/html',
40
+ '.htm': 'text/html',
41
+ '.css': 'text/css',
42
+ '.csv': 'text/csv',
43
+ '.json': 'application/json',
44
+ '.xml': 'application/xml',
45
+ '.js': 'application/javascript',
46
+ '.mjs': 'application/javascript',
47
+ '.svg': 'image/svg+xml',
48
+ '.png': 'image/png',
49
+ '.jpg': 'image/jpeg',
50
+ '.jpeg': 'image/jpeg',
51
+ '.gif': 'image/gif',
52
+ '.webp': 'image/webp',
53
+ '.bmp': 'image/bmp',
54
+ '.ico': 'image/x-icon',
55
+ '.mp4': 'video/mp4',
56
+ '.m4v': 'video/mp4',
57
+ '.webm': 'video/webm',
58
+ '.mov': 'video/quicktime',
59
+ '.mp3': 'audio/mpeg',
60
+ '.wav': 'audio/wav',
61
+ '.ogg': 'audio/ogg',
62
+ '.pdf': 'application/pdf',
63
+ '.zip': 'application/zip',
64
+ };
65
+ function sniffMime(head) {
66
+ if (head.length >= 8
67
+ && head[0] === 0x89 && head[1] === 0x50 && head[2] === 0x4e && head[3] === 0x47
68
+ && head[4] === 0x0d && head[5] === 0x0a && head[6] === 0x1a && head[7] === 0x0a) {
69
+ return 'image/png';
70
+ }
71
+ if (head.length >= 3 && head[0] === 0xff && head[1] === 0xd8 && head[2] === 0xff) {
72
+ return 'image/jpeg';
73
+ }
74
+ if (head.length >= 6
75
+ && head[0] === 0x47 && head[1] === 0x49 && head[2] === 0x46
76
+ && head[3] === 0x38 && (head[4] === 0x37 || head[4] === 0x39) && head[5] === 0x61) {
77
+ return 'image/gif';
78
+ }
79
+ if (head.length >= 12
80
+ && head[0] === 0x52 && head[1] === 0x49 && head[2] === 0x46 && head[3] === 0x46
81
+ && head[8] === 0x57 && head[9] === 0x45 && head[10] === 0x42 && head[11] === 0x50) {
82
+ return 'image/webp';
83
+ }
84
+ if (head.length >= 12
85
+ && head[4] === 0x66 && head[5] === 0x74 && head[6] === 0x79 && head[7] === 0x70) {
86
+ // MP4 ftyp box: brand bytes vary, treat all as video/mp4 for now.
87
+ return 'video/mp4';
88
+ }
89
+ if (head.length >= 4
90
+ && head[0] === 0x25 && head[1] === 0x50 && head[2] === 0x44 && head[3] === 0x46) {
91
+ return 'application/pdf';
92
+ }
93
+ if (head.length >= 2 && head[0] === 0x42 && head[1] === 0x4d) {
94
+ return 'image/bmp';
95
+ }
96
+ if (head.length >= 4
97
+ && head[0] === 0x50 && head[1] === 0x4b && head[2] === 0x03 && head[3] === 0x04) {
98
+ return 'application/zip';
99
+ }
100
+ if (head.length >= 3 && head[0] === 0x49 && head[1] === 0x44 && head[2] === 0x33) {
101
+ // ID3-tagged MP3.
102
+ return 'audio/mpeg';
103
+ }
104
+ if (head.length >= 4 && head[0] === 0x4f && head[1] === 0x67 && head[2] === 0x67 && head[3] === 0x53) {
105
+ return 'audio/ogg';
106
+ }
107
+ return null;
108
+ }
109
+ function extMime(realPath) {
110
+ const ext = path.extname(realPath).toLowerCase();
111
+ if (!ext)
112
+ return null;
113
+ return EXT_MIME[ext] ?? null;
114
+ }
115
+ function detectMime(realPath, head) {
116
+ const sniffed = sniffMime(head);
117
+ if (sniffed)
118
+ return sniffed;
119
+ const byExt = extMime(realPath);
120
+ if (byExt)
121
+ return byExt;
122
+ return 'application/octet-stream';
123
+ }
124
+ // -- ETag -------------------------------------------------------------------
125
+ function etagOf(mtimeMs, size) {
126
+ return `W/"${Math.floor(mtimeMs)}-${size}"`;
127
+ }
128
+ // -- read -------------------------------------------------------------------
129
+ export async function readDiskFile(realPath, opts) {
130
+ const handle = await fs.open(realPath, 'r');
131
+ try {
132
+ const st = await handle.stat();
133
+ const totalSize = st.size;
134
+ const mtimeMs = st.mtimeMs;
135
+ const range = opts?.range;
136
+ let sliceStart = 0;
137
+ let sliceLen = totalSize;
138
+ if (range) {
139
+ const { start, end } = range;
140
+ if (!Number.isFinite(start) || !Number.isFinite(end)) {
141
+ throw new RangeError(`invalid range: ${start}-${end}`);
142
+ }
143
+ if (start < 0)
144
+ throw new RangeError(`range start out of bounds: ${start}`);
145
+ if (start > end)
146
+ throw new RangeError(`range start > end: ${start} > ${end}`);
147
+ if (start >= totalSize) {
148
+ throw new RangeError(`range start beyond file size: ${start} >= ${totalSize}`);
149
+ }
150
+ const clampedEnd = Math.min(end, totalSize - 1);
151
+ sliceStart = start;
152
+ sliceLen = clampedEnd - start + 1;
153
+ }
154
+ // Read at most the first 12 bytes for magic sniffing, regardless of
155
+ // whether the caller asked for a range. We need the head bytes from
156
+ // position 0 to label Content-Type correctly even on a tail Range
157
+ // request. For full reads the head is part of the body so we re-use
158
+ // it without a second read.
159
+ const head = Buffer.alloc(Math.min(12, totalSize));
160
+ if (head.length > 0) {
161
+ await handle.read(head, 0, head.length, 0);
162
+ }
163
+ let bytes;
164
+ if (!range) {
165
+ bytes = Buffer.alloc(totalSize);
166
+ if (totalSize > 0) {
167
+ if (totalSize <= head.length) {
168
+ head.copy(bytes, 0, 0, totalSize);
169
+ }
170
+ else {
171
+ head.copy(bytes, 0, 0, head.length);
172
+ await handle.read(bytes, head.length, totalSize - head.length, head.length);
173
+ }
174
+ }
175
+ }
176
+ else {
177
+ bytes = Buffer.alloc(sliceLen);
178
+ if (sliceLen > 0) {
179
+ await handle.read(bytes, 0, sliceLen, sliceStart);
180
+ }
181
+ }
182
+ return {
183
+ bytes,
184
+ mime: detectMime(realPath, head),
185
+ etag: etagOf(mtimeMs, totalSize),
186
+ totalSize,
187
+ };
188
+ }
189
+ finally {
190
+ await handle.close();
191
+ }
192
+ }
193
+ // -- write ------------------------------------------------------------------
194
+ export async function writeDiskFile(realPath, bytes) {
195
+ const buf = Buffer.isBuffer(bytes) ? bytes : Buffer.from(new Uint8Array(bytes));
196
+ await fs.mkdir(path.dirname(realPath), { recursive: true });
197
+ await fs.writeFile(realPath, buf);
198
+ }
199
+ // -- dir --------------------------------------------------------------------
200
+ export async function readDiskDir(realPath) {
201
+ const entries = await fs.readdir(realPath);
202
+ return entries;
203
+ }
204
+ // -- stat -------------------------------------------------------------------
205
+ export async function statDiskFile(realPath) {
206
+ const st = await fs.stat(realPath);
207
+ return {
208
+ size: st.size,
209
+ mtime: Math.floor(st.mtimeMs),
210
+ mode: st.mode,
211
+ isFile: st.isFile(),
212
+ isDirectory: st.isDirectory(),
213
+ };
214
+ }
215
+ //# sourceMappingURL=disk.js.map
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Main-process `simulator:fs:*` IPC channel handlers.
3
+ *
4
+ * Spec: `packages/devtools/docs/file-system.md` §4.4, §6 P1-7.
5
+ *
6
+ * Each handler is the pure function that backs one IPC channel:
7
+ *
8
+ * simulator:fs:read → handleFsRead
9
+ * simulator:fs:write → handleFsWrite
10
+ * simulator:fs:stat → handleFsStat
11
+ * simulator:fs:readdir → handleFsReaddir
12
+ * simulator:fs:unlink → handleFsUnlink
13
+ * simulator:fs:mkdir → handleFsMkdir
14
+ *
15
+ * Every handler MUST defensively re-assert that `realPath` lies inside the
16
+ * USER_DATA_PATH sandbox base (`DIMINA_HOME`-aware). The IPC boundary cannot
17
+ * trust the caller — a hostile preload or fuzzed payload could send a path
18
+ * that points outside the base. The renderer-side `resolveVPath` is one
19
+ * layer; this is the second.
20
+ *
21
+ * MIME / ETag / Range semantics mirror `disk.ts`; see `disk.test.ts` for
22
+ * the full contract on those axes.
23
+ */
24
+ import { type DiskStat } from './disk.js';
25
+ export interface FsReadRequest {
26
+ realPath: string;
27
+ range?: {
28
+ start: number;
29
+ end: number;
30
+ };
31
+ }
32
+ export interface FsReadResult {
33
+ bytes: Buffer | ArrayBuffer;
34
+ mime: string;
35
+ /** Weak ETag of the form `W/"<mtime>-<size>"`. */
36
+ etag: string;
37
+ /** Full file size in bytes, regardless of whether a Range slice was returned. */
38
+ totalSize: number;
39
+ }
40
+ export interface FsWriteRequest {
41
+ realPath: string;
42
+ bytes: Buffer | ArrayBuffer;
43
+ }
44
+ export interface FsStatRequest {
45
+ realPath: string;
46
+ }
47
+ export interface FsReaddirRequest {
48
+ realPath: string;
49
+ }
50
+ export interface FsUnlinkRequest {
51
+ realPath: string;
52
+ }
53
+ export interface FsMkdirRequest {
54
+ realPath: string;
55
+ recursive?: boolean;
56
+ }
57
+ export declare function handleFsRead(req: FsReadRequest): Promise<FsReadResult>;
58
+ export declare function handleFsWrite(req: FsWriteRequest): Promise<{
59
+ ok: true;
60
+ }>;
61
+ export declare function handleFsStat(req: FsStatRequest): Promise<DiskStat>;
62
+ export declare function handleFsReaddir(req: FsReaddirRequest): Promise<string[]>;
63
+ export declare function handleFsUnlink(req: FsUnlinkRequest): Promise<{
64
+ ok: true;
65
+ }>;
66
+ export declare function handleFsMkdir(req: FsMkdirRequest): Promise<{
67
+ ok: true;
68
+ }>;
69
+ //# sourceMappingURL=fs-channels.d.ts.map
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Main-process `simulator:fs:*` IPC channel handlers.
3
+ *
4
+ * Spec: `packages/devtools/docs/file-system.md` §4.4, §6 P1-7.
5
+ *
6
+ * Each handler is the pure function that backs one IPC channel:
7
+ *
8
+ * simulator:fs:read → handleFsRead
9
+ * simulator:fs:write → handleFsWrite
10
+ * simulator:fs:stat → handleFsStat
11
+ * simulator:fs:readdir → handleFsReaddir
12
+ * simulator:fs:unlink → handleFsUnlink
13
+ * simulator:fs:mkdir → handleFsMkdir
14
+ *
15
+ * Every handler MUST defensively re-assert that `realPath` lies inside the
16
+ * USER_DATA_PATH sandbox base (`DIMINA_HOME`-aware). The IPC boundary cannot
17
+ * trust the caller — a hostile preload or fuzzed payload could send a path
18
+ * that points outside the base. The renderer-side `resolveVPath` is one
19
+ * layer; this is the second.
20
+ *
21
+ * MIME / ETag / Range semantics mirror `disk.ts`; see `disk.test.ts` for
22
+ * the full contract on those axes.
23
+ */
24
+ import fs from 'node:fs/promises';
25
+ import path from 'node:path';
26
+ import { sandboxBase } from '../../../simulator/vpath.js';
27
+ import { readDiskDir, readDiskFile, statDiskFile, writeDiskFile, } from './disk.js';
28
+ /**
29
+ * Defense-in-depth: a hostile preload could synthesize a `realPath` that
30
+ * resolveVPath would never produce. Re-canonicalize and re-anchor under the
31
+ * sandbox base every time, throwing if the result escapes.
32
+ *
33
+ * Two-layer check:
34
+ * 1. Lexical: `path.normalize` then `startsWith(base + sep)`.
35
+ * 2. Filesystem: `fs.realpath` to follow symlinks and re-assert containment.
36
+ *
37
+ * The symlink check is best-effort — if the path does not yet exist (e.g. a
38
+ * write/mkdir target), `fs.realpath` throws ENOENT and we fall back to
39
+ * checking the deepest existing ancestor instead. A symlinked ancestor that
40
+ * points outside the sandbox still gets caught that way.
41
+ */
42
+ async function enforceSandbox(realPath) {
43
+ if (typeof realPath !== 'string' || realPath.length === 0) {
44
+ throw new Error('sandbox: realPath must be a non-empty string');
45
+ }
46
+ const base = sandboxBase();
47
+ const baseReal = await fs.realpath(base).catch(() => base);
48
+ const normalized = path.normalize(realPath);
49
+ if (normalized !== base && !normalized.startsWith(base + path.sep)) {
50
+ throw new Error('sandbox: realPath escapes the user-data base');
51
+ }
52
+ // Walk up until we find an existing path, realpath it, and assert the
53
+ // resolved ancestor stays under the sandbox base. This catches symlinks
54
+ // anywhere along the chain — both the leaf (read/stat) and the parent
55
+ // (write/mkdir creating a new file under a symlinked dir).
56
+ let probe = normalized;
57
+ while (probe !== path.parse(probe).root) {
58
+ try {
59
+ const resolved = await fs.realpath(probe);
60
+ if (resolved !== baseReal && !resolved.startsWith(baseReal + path.sep)) {
61
+ throw new Error('sandbox: realPath escapes the user-data base via symlink');
62
+ }
63
+ break;
64
+ }
65
+ catch (err) {
66
+ if (err.code === 'ENOENT') {
67
+ probe = path.dirname(probe);
68
+ continue;
69
+ }
70
+ throw err;
71
+ }
72
+ }
73
+ return normalized;
74
+ }
75
+ export async function handleFsRead(req) {
76
+ const safe = await enforceSandbox(req.realPath);
77
+ const result = await readDiskFile(safe, req.range ? { range: req.range } : undefined);
78
+ return {
79
+ bytes: result.bytes,
80
+ mime: result.mime,
81
+ etag: result.etag,
82
+ totalSize: result.totalSize,
83
+ };
84
+ }
85
+ export async function handleFsWrite(req) {
86
+ const safe = await enforceSandbox(req.realPath);
87
+ await writeDiskFile(safe, req.bytes);
88
+ return { ok: true };
89
+ }
90
+ export async function handleFsStat(req) {
91
+ const safe = await enforceSandbox(req.realPath);
92
+ return statDiskFile(safe);
93
+ }
94
+ export async function handleFsReaddir(req) {
95
+ const safe = await enforceSandbox(req.realPath);
96
+ return readDiskDir(safe);
97
+ }
98
+ export async function handleFsUnlink(req) {
99
+ const safe = await enforceSandbox(req.realPath);
100
+ await fs.unlink(safe);
101
+ return { ok: true };
102
+ }
103
+ export async function handleFsMkdir(req) {
104
+ const safe = await enforceSandbox(req.realPath);
105
+ await fs.mkdir(safe, { recursive: !!req.recursive });
106
+ return { ok: true };
107
+ }
108
+ //# sourceMappingURL=fs-channels.js.map
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Wires the `difile://_tmp/*` protocol handler on the simulator session
3
+ * and bridges renderer-side `setTempFileSink` callbacks to the main-process
4
+ * byte store. Returned disposable clears the store, unregisters the protocol
5
+ * handler, and tears down its private IPC channels.
6
+ *
7
+ * SENDER POLICY: The default workbench sender-policy intentionally rejects
8
+ * the simulator `<webview>` (see `utils/sender-policy.ts`). The simulator
9
+ * is the only legitimate writer for these channels, so this module installs
10
+ * its own narrow policy that accepts a sender only when its WebContents
11
+ * belongs to the simulator `Session` we were handed. This bypasses the
12
+ * default policy without widening trust to any other source.
13
+ *
14
+ * WRITE-vs-RENDER RACE: `createTempFilePath` is synchronous on the renderer,
15
+ * but the bytes travel here through `blob.arrayBuffer().then(ipcRenderer.send)`
16
+ * — async. The simulator can race ahead: in `chooseMedia` the renderer
17
+ * assigns `img.src = tempFilePath` (via `readImageMetadata`) immediately
18
+ * after `createTempFilePath` returns, which fires a `difile://` GET before
19
+ * our IPC write arrives. The protocol handler therefore parks a request on
20
+ * a per-url waiter for up to PENDING_TIMEOUT_MS; the IPC `write` handler
21
+ * drains the waiter on arrival.
22
+ *
23
+ * QUOTA: A single in-memory store backs all simulator sessions for the
24
+ * lifetime of the WorkbenchApp instance. We cap it at MAX_STORE_ENTRIES
25
+ * (FIFO eviction). Project switches do NOT clear the store on their own —
26
+ * stale entries from a previous project simply age out as new ones land.
27
+ * This is the same eviction shape WeChat uses for tmp files (LRU under a
28
+ * size cap), simplified for a dev-tools session scope.
29
+ */
30
+ import type { Session } from 'electron';
31
+ import { type Disposable } from '../../utils/disposable.js';
32
+ export declare function setupSimulatorTempFiles(simSession: Session): Disposable;
33
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1,160 @@
1
+ /**
2
+ * Wires the `difile://_tmp/*` protocol handler on the simulator session
3
+ * and bridges renderer-side `setTempFileSink` callbacks to the main-process
4
+ * byte store. Returned disposable clears the store, unregisters the protocol
5
+ * handler, and tears down its private IPC channels.
6
+ *
7
+ * SENDER POLICY: The default workbench sender-policy intentionally rejects
8
+ * the simulator `<webview>` (see `utils/sender-policy.ts`). The simulator
9
+ * is the only legitimate writer for these channels, so this module installs
10
+ * its own narrow policy that accepts a sender only when its WebContents
11
+ * belongs to the simulator `Session` we were handed. This bypasses the
12
+ * default policy without widening trust to any other source.
13
+ *
14
+ * WRITE-vs-RENDER RACE: `createTempFilePath` is synchronous on the renderer,
15
+ * but the bytes travel here through `blob.arrayBuffer().then(ipcRenderer.send)`
16
+ * — async. The simulator can race ahead: in `chooseMedia` the renderer
17
+ * assigns `img.src = tempFilePath` (via `readImageMetadata`) immediately
18
+ * after `createTempFilePath` returns, which fires a `difile://` GET before
19
+ * our IPC write arrives. The protocol handler therefore parks a request on
20
+ * a per-url waiter for up to PENDING_TIMEOUT_MS; the IPC `write` handler
21
+ * drains the waiter on arrival.
22
+ *
23
+ * QUOTA: A single in-memory store backs all simulator sessions for the
24
+ * lifetime of the WorkbenchApp instance. We cap it at MAX_STORE_ENTRIES
25
+ * (FIFO eviction). Project switches do NOT clear the store on their own —
26
+ * stale entries from a previous project simply age out as new ones land.
27
+ * This is the same eviction shape WeChat uses for tmp files (LRU under a
28
+ * size cap), simplified for a dev-tools session scope.
29
+ */
30
+ import { IpcRegistry } from '../../utils/ipc-registry.js';
31
+ import { toDisposable } from '../../utils/disposable.js';
32
+ import { registerTempFile, revokeTempFile, revokeAllTempFiles } from './store.js';
33
+ import { handleDifileRequest } from './request-handler.js';
34
+ import { handleFsMkdir, handleFsRead, handleFsReaddir, handleFsStat, handleFsUnlink, handleFsWrite, } from './fs-channels.js';
35
+ /** Upper bound for in-memory entries; oldest insertion is evicted (FIFO). */
36
+ const MAX_STORE_ENTRIES = 200;
37
+ /** Max time the protocol handler waits for an in-flight `write` IPC. */
38
+ const PENDING_TIMEOUT_MS = 500;
39
+ function enforceStoreCap(store) {
40
+ while (store.size > MAX_STORE_ENTRIES) {
41
+ const next = store.keys().next();
42
+ if (next.done)
43
+ break;
44
+ store.delete(next.value);
45
+ }
46
+ }
47
+ export function setupSimulatorTempFiles(simSession) {
48
+ const store = new Map();
49
+ const pendingWaiters = new Map();
50
+ let disposed = false;
51
+ function drainWaiters(path) {
52
+ const list = pendingWaiters.get(path);
53
+ if (!list)
54
+ return;
55
+ pendingWaiters.delete(path);
56
+ for (const fn of list)
57
+ fn();
58
+ }
59
+ function drainAllWaiters() {
60
+ const lists = Array.from(pendingWaiters.values());
61
+ pendingWaiters.clear();
62
+ for (const list of lists)
63
+ for (const fn of list)
64
+ fn();
65
+ }
66
+ const simulatorOnlyPolicy = sender => !sender.isDestroyed() && sender.session === simSession;
67
+ const registry = new IpcRegistry(simulatorOnlyPolicy);
68
+ registry.on('simulator:temp-file:write', (_event, payload) => {
69
+ if (disposed)
70
+ return;
71
+ const { path, mime, bytes } = payload;
72
+ registerTempFile(store, path, mime, bytes);
73
+ enforceStoreCap(store);
74
+ drainWaiters(path);
75
+ });
76
+ registry.on('simulator:temp-file:revoke', (_event, payload) => {
77
+ if (disposed)
78
+ return;
79
+ const { path } = payload;
80
+ revokeTempFile(store, path);
81
+ });
82
+ registry.on('simulator:temp-file:revoke-all', () => {
83
+ if (disposed)
84
+ return;
85
+ revokeAllTempFiles(store);
86
+ });
87
+ // Idempotent: a stale handler from a prior setup (e.g. fast app
88
+ // re-init in tests) is replaced rather than throwing.
89
+ try {
90
+ simSession.protocol.unhandle('difile');
91
+ }
92
+ catch {
93
+ // Not previously registered — fine.
94
+ }
95
+ simSession.protocol.handle('difile', async (req) => {
96
+ const url = req.url;
97
+ // Forward any HTTP headers Electron parsed (Range / If-None-Match)
98
+ // to the pure dispatcher so it can do its own conditional / range
99
+ // shaping.
100
+ const headers = {};
101
+ try {
102
+ req.headers.forEach((v, k) => { headers[k] = v; });
103
+ }
104
+ catch {
105
+ // Older Electron headers shape may not be iterable — best effort.
106
+ }
107
+ const ctx = { tempStore: store };
108
+ let res = await handleDifileRequest(ctx, { url, headers });
109
+ // Race waiter: the renderer-side `createTempFilePath` is synchronous
110
+ // but its `bytes` IPC arrives later. If we miss a `_tmp/*` lookup,
111
+ // park briefly and retry once.
112
+ if (res.status === 404
113
+ && !disposed
114
+ && url.startsWith('difile://_tmp/')) {
115
+ await new Promise((resolve) => {
116
+ let timer = null;
117
+ const notify = () => {
118
+ if (timer)
119
+ clearTimeout(timer);
120
+ resolve();
121
+ };
122
+ const list = pendingWaiters.get(url) ?? new Set();
123
+ list.add(notify);
124
+ pendingWaiters.set(url, list);
125
+ timer = setTimeout(() => {
126
+ const cur = pendingWaiters.get(url);
127
+ if (cur) {
128
+ cur.delete(notify);
129
+ if (cur.size === 0)
130
+ pendingWaiters.delete(url);
131
+ }
132
+ resolve();
133
+ }, PENDING_TIMEOUT_MS);
134
+ });
135
+ res = await handleDifileRequest(ctx, { url, headers });
136
+ }
137
+ return res;
138
+ });
139
+ // Phase 1 (P1-7): renderer FSM → main fs operations bridge. The same
140
+ // simulator-only sender policy applies — registry instance is shared.
141
+ registry.handle('simulator:fs:read', (_event, payload) => handleFsRead(payload));
142
+ registry.handle('simulator:fs:write', (_event, payload) => handleFsWrite(payload));
143
+ registry.handle('simulator:fs:stat', (_event, payload) => handleFsStat(payload));
144
+ registry.handle('simulator:fs:readdir', (_event, payload) => handleFsReaddir(payload));
145
+ registry.handle('simulator:fs:unlink', (_event, payload) => handleFsUnlink(payload));
146
+ registry.handle('simulator:fs:mkdir', (_event, payload) => handleFsMkdir(payload));
147
+ return toDisposable(async () => {
148
+ disposed = true;
149
+ drainAllWaiters();
150
+ store.clear();
151
+ try {
152
+ simSession.protocol.unhandle('difile');
153
+ }
154
+ catch {
155
+ // May already have been unhandled by app shutdown.
156
+ }
157
+ await registry.dispose();
158
+ });
159
+ }
160
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Pure dispatcher for `difile://` URL requests.
3
+ *
4
+ * Spec: `packages/devtools/docs/file-system.md` §4.3.
5
+ *
6
+ * The shipping implementation in `index.ts` registers a thin
7
+ * `simSession.protocol.handle('difile')` wrapper that delegates here; the race
8
+ * waiter on `_tmp/*` lives in `index.ts` because it owns the IPC lifecycle.
9
+ * For unit tests we assume the bytes are already in the store.
10
+ *
11
+ * Response shape:
12
+ * - 200: full body, with Content-Type, Cache-Control (immutable), ETag
13
+ * - 206: range slice, plus Content-Range
14
+ * - 304: empty body when `If-None-Match` matches the on-disk ETag. Per RFC
15
+ * 9110 §13.1.2 If-None-Match wins over Range.
16
+ * - 404: anything `resolveVPath` rejects, plus disk-side ENOENT and any
17
+ * other I/O error. The protocol handler in `index.ts` translates this
18
+ * into the renderer's network failure surface; we deliberately do not
19
+ * leak errno strings.
20
+ */
21
+ import type { TempFileStore } from './resolver.js';
22
+ export interface HandleDifileContext {
23
+ tempStore: TempFileStore;
24
+ }
25
+ export interface HandleDifileRequest {
26
+ url: string;
27
+ headers?: Record<string, string>;
28
+ }
29
+ export declare function handleDifileRequest(ctx: HandleDifileContext, req: HandleDifileRequest): Promise<Response>;
30
+ //# sourceMappingURL=request-handler.d.ts.map