@swf2es/desktop 0.0.0-stage → 0.1.14
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 +202 -0
- package/README.md +132 -2
- package/dist/launch/electron.d.ts +14 -0
- package/dist/launch/electron.d.ts.map +1 -0
- package/dist/launch/electron.js +40 -0
- package/dist/launch/electron.js.map +1 -0
- package/dist/launch/launch.d.ts +3 -0
- package/dist/launch/launch.d.ts.map +1 -0
- package/dist/launch/launch.js +69 -0
- package/dist/launch/launch.js.map +1 -0
- package/dist/main/addresses.d.ts +7 -0
- package/dist/main/addresses.d.ts.map +1 -0
- package/dist/main/addresses.js +155 -0
- package/dist/main/addresses.js.map +1 -0
- package/dist/main/air.d.ts +71 -0
- package/dist/main/air.d.ts.map +1 -0
- package/dist/main/air.js +216 -0
- package/dist/main/air.js.map +1 -0
- package/dist/main/connections.d.ts +38 -0
- package/dist/main/connections.d.ts.map +1 -0
- package/dist/main/connections.js +103 -0
- package/dist/main/connections.js.map +1 -0
- package/dist/main/decompress.d.ts +51 -0
- package/dist/main/decompress.d.ts.map +1 -0
- package/dist/main/decompress.js +184 -0
- package/dist/main/decompress.js.map +1 -0
- package/dist/main/files.d.ts +9 -0
- package/dist/main/files.d.ts.map +1 -0
- package/dist/main/files.js +120 -0
- package/dist/main/files.js.map +1 -0
- package/dist/main/main.d.ts +2 -0
- package/dist/main/main.d.ts.map +1 -0
- package/dist/main/main.js +979 -0
- package/dist/main/main.js.map +1 -0
- package/dist/main/network.d.ts +182 -0
- package/dist/main/network.d.ts.map +1 -0
- package/dist/main/network.js +909 -0
- package/dist/main/network.js.map +1 -0
- package/dist/main/policy.d.ts +103 -0
- package/dist/main/policy.d.ts.map +1 -0
- package/dist/main/policy.js +481 -0
- package/dist/main/policy.js.map +1 -0
- package/dist/main/protocol.d.ts +14 -0
- package/dist/main/protocol.d.ts.map +1 -0
- package/dist/main/protocol.js +216 -0
- package/dist/main/protocol.js.map +1 -0
- package/dist/main/sandbox.d.ts +67 -0
- package/dist/main/sandbox.d.ts.map +1 -0
- package/dist/main/sandbox.js +182 -0
- package/dist/main/sandbox.js.map +1 -0
- package/dist/main/settings.d.ts +37 -0
- package/dist/main/settings.d.ts.map +1 -0
- package/dist/main/settings.js +88 -0
- package/dist/main/settings.js.map +1 -0
- package/dist/main/sockets.d.ts +10 -0
- package/dist/main/sockets.d.ts.map +1 -0
- package/dist/main/sockets.js +71 -0
- package/dist/main/sockets.js.map +1 -0
- package/dist/preload/preload.cjs +91 -0
- package/dist/preload/preload.cjs.map +1 -0
- package/dist/preload/preload.d.cts +2 -0
- package/dist/preload/preload.d.cts.map +1 -0
- package/dist/preload/prompt.cjs +12 -0
- package/dist/preload/prompt.cjs.map +1 -0
- package/dist/preload/prompt.d.cts +2 -0
- package/dist/preload/prompt.d.cts.map +1 -0
- package/dist/renderer/air.d.ts +14 -0
- package/dist/renderer/air.d.ts.map +1 -0
- package/dist/renderer/air.js +83 -0
- package/dist/renderer/air.js.map +1 -0
- package/dist/renderer/files.d.ts +4 -0
- package/dist/renderer/files.d.ts.map +1 -0
- package/dist/renderer/files.js +58 -0
- package/dist/renderer/files.js.map +1 -0
- package/dist/renderer/index.d.ts +7 -0
- package/dist/renderer/index.d.ts.map +1 -0
- package/dist/renderer/index.js +126 -0
- package/dist/renderer/index.js.map +1 -0
- package/dist/renderer/network.d.ts +6 -0
- package/dist/renderer/network.d.ts.map +1 -0
- package/dist/renderer/network.js +39 -0
- package/dist/renderer/network.js.map +1 -0
- package/dist/renderer/open-url.d.ts +10 -0
- package/dist/renderer/open-url.d.ts.map +1 -0
- package/dist/renderer/open-url.js +38 -0
- package/dist/renderer/open-url.js.map +1 -0
- package/dist/renderer/sockets.d.ts +4 -0
- package/dist/renderer/sockets.d.ts.map +1 -0
- package/dist/renderer/sockets.js +36 -0
- package/dist/renderer/sockets.js.map +1 -0
- package/dist/renderer/stage3d.d.ts +3 -0
- package/dist/renderer/stage3d.d.ts.map +1 -0
- package/dist/renderer/stage3d.js +26 -0
- package/dist/renderer/stage3d.js.map +1 -0
- package/dist/shared/api.d.ts +205 -0
- package/dist/shared/api.d.ts.map +1 -0
- package/dist/shared/api.js +5 -0
- package/dist/shared/api.js.map +1 -0
- package/package.json +46 -3
- package/src/launch/electron.ts +44 -0
- package/src/launch/launch.ts +72 -0
- package/src/launch/tsconfig.json +14 -0
- package/src/main/addresses.ts +184 -0
- package/src/main/air.ts +275 -0
- package/src/main/connections.ts +125 -0
- package/src/main/decompress.ts +219 -0
- package/src/main/files.ts +147 -0
- package/src/main/main.ts +1146 -0
- package/src/main/network.ts +1205 -0
- package/src/main/policy.ts +630 -0
- package/src/main/protocol.ts +237 -0
- package/src/main/sandbox.ts +213 -0
- package/src/main/settings.ts +117 -0
- package/src/main/sockets.ts +81 -0
- package/src/main/tsconfig.json +15 -0
- package/src/preload/preload.cts +110 -0
- package/src/preload/prompt.cts +11 -0
- package/src/preload/tsconfig.json +12 -0
- package/src/renderer/air.ts +100 -0
- package/src/renderer/files.ts +65 -0
- package/src/renderer/index.ts +145 -0
- package/src/renderer/network.ts +56 -0
- package/src/renderer/open-url.ts +50 -0
- package/src/renderer/sockets.ts +42 -0
- package/src/renderer/stage3d.ts +27 -0
- package/src/renderer/tsconfig.json +17 -0
- package/src/shared/api.ts +204 -0
- package/src/shared/tsconfig.json +11 -0
- package/static/icon.png +0 -0
- package/static/index.html +22 -0
- package/static/open-url.html +21 -0
- package/static/style.css +96 -0
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// One page's flash.net.Socket connections, made with node:net: what the
|
|
2
|
+
// socket bridge (sockets.ts) holds for each page, apart from Electron so
|
|
3
|
+
// that node runs it in the tests (tests/desktop/connections.test.ts).
|
|
4
|
+
import { connect, type Socket } from "node:net";
|
|
5
|
+
import type { SocketEvent } from "../shared/api.js";
|
|
6
|
+
|
|
7
|
+
/** As many as a page may hold open at once; past it a connection is refused. */
|
|
8
|
+
const MAX_OPEN = 64;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The bytes a connection may hold unsent. A peer that stops reading would
|
|
12
|
+
* otherwise have the main process buffer all a SWF writes: 100 writes of
|
|
13
|
+
* 8 MB took it from 238 to 1038 MB. Past this the connection fails, as
|
|
14
|
+
* Flash's did when a write could not go.
|
|
15
|
+
*/
|
|
16
|
+
export const MAX_BUFFERED = 4 * 1024 * 1024;
|
|
17
|
+
|
|
18
|
+
const SOCKET_ERROR = "Error #2031: Socket Error.";
|
|
19
|
+
|
|
20
|
+
/** Whether the page asked for a host name of DNS's length and a TCP port. */
|
|
21
|
+
export function validEndpoint(host: unknown, port: unknown): host is string {
|
|
22
|
+
return (
|
|
23
|
+
typeof host === "string" &&
|
|
24
|
+
host.length > 0 &&
|
|
25
|
+
host.length <= 255 &&
|
|
26
|
+
typeof port === "number" &&
|
|
27
|
+
Number.isInteger(port) &&
|
|
28
|
+
port > 0 &&
|
|
29
|
+
port < 65536
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export class Connections {
|
|
34
|
+
private readonly sockets = new Map<number, Socket>();
|
|
35
|
+
private readonly tell: (id: number, event: SocketEvent) => void;
|
|
36
|
+
|
|
37
|
+
/** `tell` carries each connection's events to its page. */
|
|
38
|
+
constructor(tell: (id: number, event: SocketEvent) => void) {
|
|
39
|
+
this.tell = tell;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
get size(): number {
|
|
43
|
+
return this.sockets.size;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Counts closeAll's: a connection decided on before one belongs to a page that is gone. */
|
|
47
|
+
generation = 0;
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Connect `id` to `host`:`port`, each checked, as the page asks, at `address` where the host's
|
|
51
|
+
* was pinned (a remote SWF's socket policy came from there); a refusal is an error, then a close.
|
|
52
|
+
*/
|
|
53
|
+
connect(id: number, host: unknown, port: unknown, address?: string): void {
|
|
54
|
+
if (!validEndpoint(host, port) || this.sockets.has(id) || this.sockets.size >= MAX_OPEN) {
|
|
55
|
+
this.refuse(id);
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const socket = connect({ host: address ?? host, port: port as number });
|
|
60
|
+
this.sockets.set(id, socket);
|
|
61
|
+
socket.on("connect", () =>
|
|
62
|
+
this.tell(id, {
|
|
63
|
+
type: "open",
|
|
64
|
+
localAddress: socket.localAddress ?? "",
|
|
65
|
+
localPort: socket.localPort ?? 0,
|
|
66
|
+
remoteAddress: socket.remoteAddress ?? "",
|
|
67
|
+
remotePort: socket.remotePort ?? 0,
|
|
68
|
+
}),
|
|
69
|
+
);
|
|
70
|
+
socket.on("data", (bytes) => this.tell(id, { type: "data", bytes: new Uint8Array(bytes) }));
|
|
71
|
+
socket.on("error", () => this.tell(id, { type: "error", message: SOCKET_ERROR }));
|
|
72
|
+
socket.on("close", () => {
|
|
73
|
+
// Only the socket under this id: a page that reused it has another by now.
|
|
74
|
+
if (this.sockets.get(id) === socket) {
|
|
75
|
+
this.sockets.delete(id);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
this.tell(id, { type: "close" });
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Write `bytes`, unless the peer has left more than MAX_BUFFERED unread: then the connection
|
|
84
|
+
* fails.
|
|
85
|
+
*/
|
|
86
|
+
send(id: number, bytes: Uint8Array): void {
|
|
87
|
+
const socket = this.sockets.get(id);
|
|
88
|
+
if (!socket || socket.destroyed) {
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (socket.writableLength + bytes.length > MAX_BUFFERED) {
|
|
93
|
+
this.tell(id, { type: "error", message: SOCKET_ERROR });
|
|
94
|
+
socket.destroy();
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
socket.write(bytes);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Refuse `id`, as a server that took no connection: an error, then a close. */
|
|
102
|
+
refuse(id: number): void {
|
|
103
|
+
this.tell(id, { type: "error", message: SOCKET_ERROR });
|
|
104
|
+
this.tell(id, { type: "close" });
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
close(id: number): void {
|
|
108
|
+
this.sockets.get(id)?.destroy();
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Close every connection without a word: the page that held them is gone, and the next numbers
|
|
113
|
+
* its own from 1.
|
|
114
|
+
*/
|
|
115
|
+
closeAll(): void {
|
|
116
|
+
for (const socket of this.sockets.values()) {
|
|
117
|
+
socket.removeAllListeners();
|
|
118
|
+
socket.on("error", () => {});
|
|
119
|
+
socket.destroy();
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
this.sockets.clear();
|
|
123
|
+
this.generation++;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
// A response body a server sent compressed, unasked, decompressed as it
|
|
2
|
+
// streams in, as Chromium decompresses it (network.ts). Without Electron,
|
|
3
|
+
// so that node tests it (tests/desktop/network.test.ts).
|
|
4
|
+
import type { Transform } from "node:stream";
|
|
5
|
+
import { createBrotliDecompress, createInflate, createInflateRaw } from "node:zlib";
|
|
6
|
+
|
|
7
|
+
/** What a server may send compressed, unasked, that is decompressed; any other goes as it came. */
|
|
8
|
+
export const DECODED = new Set(["gzip", "x-gzip", "br", "deflate"]);
|
|
9
|
+
|
|
10
|
+
/** The longest gzip header read, its name and comment included. */
|
|
11
|
+
export const GZIP_HEADER_MAX = 64 * 1024;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* A gzip header read as its bytes come: kept in an array that doubles as
|
|
15
|
+
* it fills, and parsed on from where the last chunk left off, so that a
|
|
16
|
+
* header sent a byte at a time costs no more than one sent whole. One
|
|
17
|
+
* whose end lies past GZIP_HEADER_MAX is refused, however it is chunked.
|
|
18
|
+
*/
|
|
19
|
+
export class GzipHeader {
|
|
20
|
+
private bytes = new Uint8Array(64);
|
|
21
|
+
private length = 0;
|
|
22
|
+
/** Where parsing goes on from, and in which part: fixed bytes, extra, name, comment, CRC. */
|
|
23
|
+
private at = 0;
|
|
24
|
+
private part = 0;
|
|
25
|
+
|
|
26
|
+
/** What came so far. */
|
|
27
|
+
get buffered(): Uint8Array {
|
|
28
|
+
return this.bytes.subarray(0, this.length);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Add `chunk`: the header's length once whole, 0 while more is needed, -1 if it is none. */
|
|
32
|
+
add(chunk: Uint8Array): number {
|
|
33
|
+
if (this.length + chunk.length > this.bytes.length) {
|
|
34
|
+
let capacity = this.bytes.length * 2;
|
|
35
|
+
while (capacity < this.length + chunk.length) {
|
|
36
|
+
capacity *= 2;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const grown = new Uint8Array(capacity);
|
|
40
|
+
grown.set(this.buffered);
|
|
41
|
+
this.bytes = grown;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
this.bytes.set(chunk, this.length);
|
|
45
|
+
this.length += chunk.length;
|
|
46
|
+
const end = this.parse();
|
|
47
|
+
return end > GZIP_HEADER_MAX || (end === 0 && this.at > GZIP_HEADER_MAX) ? -1 : end;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
private parse(): number {
|
|
51
|
+
const bytes = this.buffered;
|
|
52
|
+
const flags = bytes[3];
|
|
53
|
+
for (;;) {
|
|
54
|
+
switch (this.part) {
|
|
55
|
+
case 0: {
|
|
56
|
+
const magic = [0x1f, 0x8b, 8];
|
|
57
|
+
for (let i = 0; i < Math.min(3, bytes.length); i++) {
|
|
58
|
+
if (bytes[i] !== magic[i]) {
|
|
59
|
+
return -1;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (bytes.length < 10) {
|
|
64
|
+
return 0;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
this.at = 10;
|
|
68
|
+
this.part = 1;
|
|
69
|
+
break;
|
|
70
|
+
}
|
|
71
|
+
case 1:
|
|
72
|
+
if (flags & 4) {
|
|
73
|
+
if (bytes.length < this.at + 2) {
|
|
74
|
+
return 0;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
this.at += 2 + (bytes[this.at] | (bytes[this.at + 1] << 8));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
this.part = 2;
|
|
81
|
+
break;
|
|
82
|
+
case 2:
|
|
83
|
+
case 3: {
|
|
84
|
+
// FNAME, then FCOMMENT: each up to its NUL, sought only in what is new.
|
|
85
|
+
if (flags & (this.part === 2 ? 8 : 16)) {
|
|
86
|
+
if (this.at > bytes.length) {
|
|
87
|
+
return 0;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const nul = bytes.indexOf(0, this.at);
|
|
91
|
+
if (nul < 0) {
|
|
92
|
+
this.at = bytes.length;
|
|
93
|
+
return 0;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
this.at = nul + 1;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
this.part++;
|
|
100
|
+
break;
|
|
101
|
+
}
|
|
102
|
+
default:
|
|
103
|
+
return bytes.length >= this.at + (flags & 2 ? 2 : 0) ? this.at + (flags & 2 ? 2 : 0) : 0;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Decompresses one body of gzip, deflate or br, choosing its inflater
|
|
111
|
+
* once its first bytes are in: gzip's header is read here and its deflate
|
|
112
|
+
* stream inflated raw, so that what follows the stream (its footer, and
|
|
113
|
+
* whatever a server put after it) is ignored, as Chromium ignores it;
|
|
114
|
+
* deflate is zlib's stream or a raw one, as its first two bytes tell.
|
|
115
|
+
*/
|
|
116
|
+
export class Decompressor {
|
|
117
|
+
/** The first bytes, while they do not yet tell deflate's kind (no more than two). */
|
|
118
|
+
private head = new Uint8Array(0);
|
|
119
|
+
private gzip: GzipHeader | null = null;
|
|
120
|
+
private inflater: Transform | null = null;
|
|
121
|
+
/** Called once the inflater wants more after a write said to wait. */
|
|
122
|
+
onDrain: () => void = () => {};
|
|
123
|
+
|
|
124
|
+
private readonly coding: string;
|
|
125
|
+
private readonly data: (chunk: Buffer) => void;
|
|
126
|
+
private readonly done: () => void;
|
|
127
|
+
private readonly failed: () => void;
|
|
128
|
+
|
|
129
|
+
constructor(coding: string, data: (chunk: Buffer) => void, done: () => void, failed: () => void) {
|
|
130
|
+
this.coding = coding;
|
|
131
|
+
this.data = data;
|
|
132
|
+
this.done = done;
|
|
133
|
+
this.failed = failed;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Whether more may be written now. */
|
|
137
|
+
write(chunk: Buffer): boolean {
|
|
138
|
+
if (this.inflater) {
|
|
139
|
+
return this.inflater.write(chunk);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (this.coding !== "br" && this.coding !== "deflate") {
|
|
143
|
+
this.gzip ??= new GzipHeader();
|
|
144
|
+
const end = this.gzip.add(chunk);
|
|
145
|
+
if (end === 0) {
|
|
146
|
+
return true;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (end < 0) {
|
|
150
|
+
this.failed();
|
|
151
|
+
return false;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return this.start(createInflateRaw(), this.gzip.buffered.subarray(end));
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const head = new Uint8Array(this.head.length + chunk.length);
|
|
158
|
+
head.set(this.head);
|
|
159
|
+
head.set(chunk, this.head.length);
|
|
160
|
+
this.head = head;
|
|
161
|
+
return this.choose(false);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
end(): void {
|
|
165
|
+
if (!this.inflater && this.gzip) {
|
|
166
|
+
// A gzip header that never ended.
|
|
167
|
+
this.failed();
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (!this.inflater && !this.choose(true)) {
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (this.inflater) {
|
|
176
|
+
this.inflater.end();
|
|
177
|
+
} else {
|
|
178
|
+
// Nothing came: nothing to undo.
|
|
179
|
+
this.done();
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
destroy(): void {
|
|
184
|
+
this.inflater?.destroy();
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Make the inflater once the bytes so far tell which (`last`: no more come); false: failed. */
|
|
188
|
+
private choose(last: boolean): boolean {
|
|
189
|
+
const head = this.head;
|
|
190
|
+
if (head.length === 0) {
|
|
191
|
+
return true;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (this.coding === "br") {
|
|
195
|
+
return this.start(createBrotliDecompress(), head);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Deflate: zlib's stream or a raw one, as its first two bytes tell.
|
|
199
|
+
if (head.length < 2 && !last) {
|
|
200
|
+
return true;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const zlib =
|
|
204
|
+
head.length >= 2 && (head[0] & 0x0f) === 8 && ((head[0] << 8) | head[1]) % 31 === 0;
|
|
205
|
+
return this.start(zlib ? createInflate() : createInflateRaw(), head);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** Inflate with `inflater` from `first` on. */
|
|
209
|
+
private start(inflater: Transform, first: Uint8Array): boolean {
|
|
210
|
+
this.inflater = inflater;
|
|
211
|
+
this.head = new Uint8Array(0);
|
|
212
|
+
this.gzip = null;
|
|
213
|
+
inflater.on("data", this.data);
|
|
214
|
+
inflater.on("end", this.done);
|
|
215
|
+
inflater.on("error", this.failed);
|
|
216
|
+
inflater.on("drain", () => this.onDrain());
|
|
217
|
+
return first.length === 0 || inflater.write(first);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
// A dialog grants an opaque capability, never a renderer-supplied path.
|
|
2
|
+
import { readFile, stat, writeFile } from "node:fs/promises";
|
|
3
|
+
import { basename } from "node:path";
|
|
4
|
+
import type {
|
|
5
|
+
BrowserWindow,
|
|
6
|
+
Dialog,
|
|
7
|
+
IpcMain,
|
|
8
|
+
IpcMainEvent,
|
|
9
|
+
IpcMainInvokeEvent,
|
|
10
|
+
WebContents,
|
|
11
|
+
} from "electron";
|
|
12
|
+
|
|
13
|
+
type Event = IpcMainEvent | IpcMainInvokeEvent;
|
|
14
|
+
type FileBridgeHost = {
|
|
15
|
+
ipcMain: Pick<IpcMain, "handle" | "on">;
|
|
16
|
+
dialog: Pick<Dialog, "showOpenDialog" | "showSaveDialog">;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export function bridgeFiles(
|
|
20
|
+
trusted: (event: Event) => boolean,
|
|
21
|
+
window: () => BrowserWindow | null,
|
|
22
|
+
gesture: () => boolean,
|
|
23
|
+
{ ipcMain, dialog }: FileBridgeHost,
|
|
24
|
+
): void {
|
|
25
|
+
const grants = new Map<number, { path: string; write: boolean }>();
|
|
26
|
+
let next = 1;
|
|
27
|
+
let dialogOpen = false;
|
|
28
|
+
let generation = 0;
|
|
29
|
+
const revoke = () => {
|
|
30
|
+
generation++;
|
|
31
|
+
grants.clear();
|
|
32
|
+
};
|
|
33
|
+
const watched = new WeakSet<WebContents>();
|
|
34
|
+
const watch = (owner: BrowserWindow) => {
|
|
35
|
+
const contents = owner.webContents;
|
|
36
|
+
if (watched.has(contents)) {
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
watched.add(contents);
|
|
41
|
+
contents.on("did-start-navigation", (details) => {
|
|
42
|
+
if (details.isMainFrame && !details.isSameDocument) {
|
|
43
|
+
revoke();
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
contents.once("destroyed", revoke);
|
|
47
|
+
};
|
|
48
|
+
ipcMain.on("files:release", (event, id: unknown) => {
|
|
49
|
+
if (trusted(event) && typeof id === "number") {
|
|
50
|
+
grants.delete(id);
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
const check = (event: Event, id: unknown, write: boolean): string => {
|
|
54
|
+
const grant = typeof id === "number" ? grants.get(id) : undefined;
|
|
55
|
+
if (!trusted(event) || !grant || grant.write !== write) {
|
|
56
|
+
throw new Error("invalid file capability");
|
|
57
|
+
}
|
|
58
|
+
return grant.path;
|
|
59
|
+
};
|
|
60
|
+
ipcMain.handle("files:read", (event, id: unknown) => readFile(check(event, id, false)));
|
|
61
|
+
ipcMain.handle("files:write", async (event, id: unknown, bytes: unknown) => {
|
|
62
|
+
const path = check(event, id, true);
|
|
63
|
+
if (!(bytes instanceof Uint8Array)) {
|
|
64
|
+
throw new Error("invalid file bytes");
|
|
65
|
+
}
|
|
66
|
+
grants.delete(id as number);
|
|
67
|
+
await writeFile(path, bytes);
|
|
68
|
+
});
|
|
69
|
+
ipcMain.handle("files:browse", async (event, filters: unknown, multiple: unknown) => {
|
|
70
|
+
const owner = window();
|
|
71
|
+
if (!trusted(event) || !owner || dialogOpen || !gesture()) {
|
|
72
|
+
return [];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
watch(owner);
|
|
76
|
+
const opened = generation;
|
|
77
|
+
const current = () => trusted(event) && owner === window() && generation === opened;
|
|
78
|
+
dialogOpen = true;
|
|
79
|
+
try {
|
|
80
|
+
const result = await dialog.showOpenDialog(owner, {
|
|
81
|
+
properties: multiple === true ? ["openFile", "multiSelections"] : ["openFile"],
|
|
82
|
+
filters: Array.isArray(filters)
|
|
83
|
+
? filters.slice(0, 100).flatMap((filter) => {
|
|
84
|
+
if (
|
|
85
|
+
typeof filter?.description !== "string" ||
|
|
86
|
+
typeof filter?.extension !== "string"
|
|
87
|
+
) {
|
|
88
|
+
return [];
|
|
89
|
+
}
|
|
90
|
+
const extensions = filter.extension
|
|
91
|
+
.split(";")
|
|
92
|
+
.map((ext: string) => ext.trim().replace(/^\*\./, ""))
|
|
93
|
+
.filter((ext: string) => /^[\w-]+$/.test(ext));
|
|
94
|
+
return extensions.length ? [{ name: filter.description, extensions }] : [];
|
|
95
|
+
})
|
|
96
|
+
: [],
|
|
97
|
+
});
|
|
98
|
+
if (result.canceled || !current()) {
|
|
99
|
+
return [];
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const selected = await Promise.all(
|
|
103
|
+
result.filePaths.map(async (path) => ({ path, info: await stat(path) })),
|
|
104
|
+
);
|
|
105
|
+
if (!current()) {
|
|
106
|
+
return [];
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return selected.map(({ path, info }) => {
|
|
110
|
+
const id = next++;
|
|
111
|
+
grants.set(id, { path, write: false });
|
|
112
|
+
return {
|
|
113
|
+
id,
|
|
114
|
+
name: basename(path),
|
|
115
|
+
size: info.size,
|
|
116
|
+
modificationDate: info.mtimeMs,
|
|
117
|
+
creationDate: info.birthtimeMs,
|
|
118
|
+
};
|
|
119
|
+
});
|
|
120
|
+
} finally {
|
|
121
|
+
dialogOpen = false;
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
ipcMain.handle("files:save", async (event, name: unknown) => {
|
|
125
|
+
const owner = window();
|
|
126
|
+
if (!trusted(event) || !owner || typeof name !== "string" || dialogOpen || !gesture()) {
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
watch(owner);
|
|
131
|
+
const opened = generation;
|
|
132
|
+
const current = () => trusted(event) && owner === window() && generation === opened;
|
|
133
|
+
dialogOpen = true;
|
|
134
|
+
try {
|
|
135
|
+
const result = await dialog.showSaveDialog(owner, { defaultPath: basename(name) });
|
|
136
|
+
if (result.canceled || !result.filePath || !current()) {
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const id = next++;
|
|
141
|
+
grants.set(id, { path: result.filePath, write: true });
|
|
142
|
+
return { id, name: basename(result.filePath) };
|
|
143
|
+
} finally {
|
|
144
|
+
dialogOpen = false;
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
}
|