@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.
- package/dist/main/app/app.js +13 -2
- package/dist/main/app/bootstrap.d.ts +9 -0
- package/dist/main/app/bootstrap.js +28 -1
- package/dist/main/index.bundle.js +570 -20
- package/dist/main/services/simulator-temp-files/disk.d.ts +55 -0
- package/dist/main/services/simulator-temp-files/disk.js +215 -0
- package/dist/main/services/simulator-temp-files/fs-channels.d.ts +69 -0
- package/dist/main/services/simulator-temp-files/fs-channels.js +108 -0
- package/dist/main/services/simulator-temp-files/index.d.ts +33 -0
- package/dist/main/services/simulator-temp-files/index.js +160 -0
- package/dist/main/services/simulator-temp-files/request-handler.d.ts +30 -0
- package/dist/main/services/simulator-temp-files/request-handler.js +178 -0
- package/dist/main/services/simulator-temp-files/resolver.d.ts +22 -0
- package/dist/main/services/simulator-temp-files/resolver.js +22 -0
- package/dist/main/services/simulator-temp-files/store.d.ts +14 -0
- package/dist/main/services/simulator-temp-files/store.js +24 -0
- package/dist/preload/runtime/temp-files.d.ts +8 -0
- package/dist/preload/runtime/temp-files.js +34 -0
- package/dist/preload/windows/simulator.js +38 -6
- package/dist/simulator/assets/simulator-CVOFTpSj.js +9 -0
- package/dist/simulator/simulator.html +1 -1
- package/package.json +4 -4
- package/dist/simulator/assets/simulator-DDRBJ6Wa.js +0 -9
|
@@ -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
|