@swf2es/desktop 0.1.15 → 0.1.17
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/README.md +85 -12
- package/dist/launch/launch.js +4 -1
- package/dist/launch/launch.js.map +1 -1
- package/dist/main/air.d.ts +3 -3
- package/dist/main/air.d.ts.map +1 -1
- package/dist/main/air.js +17 -8
- package/dist/main/air.js.map +1 -1
- package/dist/main/case-paths.d.ts +12 -0
- package/dist/main/case-paths.d.ts.map +1 -0
- package/dist/main/case-paths.js +84 -0
- package/dist/main/case-paths.js.map +1 -0
- package/dist/main/connections.d.ts +20 -1
- package/dist/main/connections.d.ts.map +1 -1
- package/dist/main/connections.js +71 -8
- package/dist/main/connections.js.map +1 -1
- package/dist/main/main.js +133 -9
- package/dist/main/main.js.map +1 -1
- package/dist/main/netlog.d.ts +21 -0
- package/dist/main/netlog.d.ts.map +1 -0
- package/dist/main/netlog.js +90 -0
- package/dist/main/netlog.js.map +1 -0
- package/dist/main/network.d.ts +10 -0
- package/dist/main/network.d.ts.map +1 -1
- package/dist/main/network.js +111 -35
- package/dist/main/network.js.map +1 -1
- package/dist/main/proxy.d.ts +48 -0
- package/dist/main/proxy.d.ts.map +1 -0
- package/dist/main/proxy.js +402 -0
- package/dist/main/proxy.js.map +1 -0
- package/dist/main/sandbox.d.ts.map +1 -1
- package/dist/main/sandbox.js +2 -1
- package/dist/main/sandbox.js.map +1 -1
- package/dist/main/settings.d.ts +2 -0
- package/dist/main/settings.d.ts.map +1 -1
- package/dist/main/settings.js +4 -0
- package/dist/main/settings.js.map +1 -1
- package/dist/main/sockets.d.ts +6 -2
- package/dist/main/sockets.d.ts.map +1 -1
- package/dist/main/sockets.js +18 -3
- package/dist/main/sockets.js.map +1 -1
- package/dist/main/websocket-connections.d.ts +73 -0
- package/dist/main/websocket-connections.d.ts.map +1 -0
- package/dist/main/websocket-connections.js +266 -0
- package/dist/main/websocket-connections.js.map +1 -0
- package/dist/main/websockets.d.ts +12 -0
- package/dist/main/websockets.d.ts.map +1 -0
- package/dist/main/websockets.js +94 -0
- package/dist/main/websockets.js.map +1 -0
- package/dist/preload/preload.cjs +20 -0
- package/dist/preload/preload.cjs.map +1 -1
- package/dist/renderer/air.d.ts.map +1 -1
- package/dist/renderer/air.js +1 -0
- package/dist/renderer/air.js.map +1 -1
- package/dist/renderer/full-screen.d.ts +4 -0
- package/dist/renderer/full-screen.d.ts.map +1 -0
- package/dist/renderer/full-screen.js +32 -0
- package/dist/renderer/full-screen.js.map +1 -0
- package/dist/renderer/index.d.ts.map +1 -1
- package/dist/renderer/index.js +4 -0
- package/dist/renderer/index.js.map +1 -1
- package/dist/renderer/websockets.d.ts +4 -0
- package/dist/renderer/websockets.d.ts.map +1 -0
- package/dist/renderer/websockets.js +36 -0
- package/dist/renderer/websockets.js.map +1 -0
- package/dist/shared/api.d.ts +53 -0
- package/dist/shared/api.d.ts.map +1 -1
- package/package.json +7 -7
- package/src/launch/launch.ts +5 -2
- package/src/main/air.ts +18 -8
- package/src/main/case-paths.ts +97 -0
- package/src/main/connections.ts +90 -8
- package/src/main/main.ts +174 -10
- package/src/main/netlog.ts +108 -0
- package/src/main/network.ts +140 -37
- package/src/main/proxy.ts +497 -0
- package/src/main/sandbox.ts +2 -1
- package/src/main/settings.ts +5 -0
- package/src/main/sockets.ts +22 -3
- package/src/main/websocket-connections.ts +344 -0
- package/src/main/websockets.ts +117 -0
- package/src/preload/preload.cts +23 -0
- package/src/renderer/air.ts +1 -0
- package/src/renderer/full-screen.ts +44 -0
- package/src/renderer/index.ts +4 -0
- package/src/renderer/websockets.ts +43 -0
- package/src/shared/api.ts +40 -0
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
// One page's air.net.WebSocket connections, made with the WHATWG WebSocket
|
|
2
|
+
// of Electron's Node: what the WebSocket bridge (websockets.ts) holds for
|
|
3
|
+
// each page, apart from Electron so that node runs it in the tests
|
|
4
|
+
// (tests/desktop/websocket-connections.test.ts).
|
|
5
|
+
import { isIP, type Socket } from "node:net";
|
|
6
|
+
import * as tls from "node:tls";
|
|
7
|
+
import type { WebSocketEvent } from "../shared/api.js";
|
|
8
|
+
import { connectThrough, type ProxyConfig, type ProxyError } from "./proxy.ts";
|
|
9
|
+
|
|
10
|
+
/** As many as a page may hold open at once; past it a connection is refused. */
|
|
11
|
+
const MAX_OPEN = 64;
|
|
12
|
+
|
|
13
|
+
/** The longest URL, and the most and longest subprotocols, a page may ask for. */
|
|
14
|
+
const MAX_URL = 8192;
|
|
15
|
+
const MAX_PROTOCOLS = 32;
|
|
16
|
+
const MAX_PROTOCOL = 256;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The bytes a connection may hold unsent, as a socket's (connections.ts)
|
|
20
|
+
* but for whole messages, which are larger than a socket's writes: a peer
|
|
21
|
+
* that stops reading would otherwise have the main process buffer all an
|
|
22
|
+
* application sends. Past this the connection fails.
|
|
23
|
+
*/
|
|
24
|
+
export const MAX_BUFFERED = 16 * 1024 * 1024;
|
|
25
|
+
|
|
26
|
+
const OPEN = 1;
|
|
27
|
+
|
|
28
|
+
/** What is used of the WHATWG WebSocket. */
|
|
29
|
+
interface Client {
|
|
30
|
+
binaryType: string;
|
|
31
|
+
readonly protocol: string;
|
|
32
|
+
readonly readyState: number;
|
|
33
|
+
readonly bufferedAmount: number;
|
|
34
|
+
onopen: (() => void) | null;
|
|
35
|
+
onmessage: ((event: { data: unknown }) => void) | null;
|
|
36
|
+
onclose: ((event: { code: number }) => void) | null;
|
|
37
|
+
onerror: (() => void) | null;
|
|
38
|
+
send(data: string | Uint8Array): void;
|
|
39
|
+
close(code?: number): void;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
type ClientConstructor = new (
|
|
43
|
+
url: string,
|
|
44
|
+
// Node's takes its options in the protocols' place: undici's dispatcher among them.
|
|
45
|
+
protocols: string[] | { protocols: string[]; dispatcher: unknown },
|
|
46
|
+
) => Client;
|
|
47
|
+
|
|
48
|
+
/** Opens a client; throws as the WebSocket constructor does. */
|
|
49
|
+
export type Opener = (url: string, protocols: string[]) => unknown;
|
|
50
|
+
|
|
51
|
+
const globalClient = (): ClientConstructor | undefined =>
|
|
52
|
+
(globalThis as { WebSocket?: ClientConstructor }).WebSocket;
|
|
53
|
+
|
|
54
|
+
/** Whether this Node has the WHATWG WebSocket (22 and later, Electron's among them). */
|
|
55
|
+
export function hasWebSocket(): boolean {
|
|
56
|
+
return typeof globalClient() === "function";
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* What sends a client's requests with no certificate verified, for
|
|
61
|
+
* --ignore-certificate-errors; null where this Node gives no way to make
|
|
62
|
+
* one. The WHATWG WebSocket has no option for it: Node's is undici's,
|
|
63
|
+
* which takes a dispatcher, and Node exports no class of one, so it is
|
|
64
|
+
* made from the class of the dispatcher undici keeps for everything else.
|
|
65
|
+
*/
|
|
66
|
+
export function unverifiedDispatcher(): unknown {
|
|
67
|
+
const Agent = agentClass();
|
|
68
|
+
if (!Agent) {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
try {
|
|
73
|
+
const agent = new Agent({
|
|
74
|
+
connect: { rejectUnauthorized: false },
|
|
75
|
+
});
|
|
76
|
+
return typeof agent.dispatch === "function" ? agent : null;
|
|
77
|
+
} catch {
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** undici's Agent, the class of the dispatcher it keeps for everything else; null where none. */
|
|
83
|
+
function agentClass(): (new (options: object) => { dispatch?: unknown }) | null {
|
|
84
|
+
// Naming the global loads undici, which then sets its dispatcher.
|
|
85
|
+
if (!hasWebSocket()) {
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const shared = (globalThis as Record<symbol, unknown>)[Symbol.for("undici.globalDispatcher.1")];
|
|
90
|
+
const Agent = (shared as { constructor?: unknown } | undefined)?.constructor;
|
|
91
|
+
return typeof Agent === "function" && Agent.name === "Agent"
|
|
92
|
+
? (Agent as new (
|
|
93
|
+
options: object,
|
|
94
|
+
) => { dispatch?: unknown })
|
|
95
|
+
: null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* What sends every client's requests through `proxy` (--proxy): a tunnel to
|
|
100
|
+
* the URL's host, by name for the proxy to resolve, and TLS over it for
|
|
101
|
+
* wss:, its certificate unverified where `unverified` says so of its host.
|
|
102
|
+
* `warn` is told why a proxy did not make one. Null where this Node gives
|
|
103
|
+
* no way to make one, as unverifiedDispatcher.
|
|
104
|
+
*/
|
|
105
|
+
export function proxiedDispatcher(
|
|
106
|
+
proxy: ProxyConfig,
|
|
107
|
+
unverified: (host: string) => boolean,
|
|
108
|
+
warn: (message: string) => void = () => {},
|
|
109
|
+
): unknown {
|
|
110
|
+
const Agent = agentClass();
|
|
111
|
+
if (!Agent) {
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// undici's connector: its options name the target, and its callback takes the socket made.
|
|
116
|
+
const connect = (
|
|
117
|
+
options: { hostname: string; protocol: string; port: string | number; servername?: string },
|
|
118
|
+
done: (error: Error | null, socket: Socket | null) => void,
|
|
119
|
+
) => {
|
|
120
|
+
const host = options.hostname.replace(/^\[(.*)\]$/, "$1").toLowerCase();
|
|
121
|
+
const https = options.protocol === "https:";
|
|
122
|
+
const port = Number(options.port) || (https ? 443 : 80);
|
|
123
|
+
connectThrough(proxy, host, port).then(
|
|
124
|
+
(tunnel) => {
|
|
125
|
+
if (!https) {
|
|
126
|
+
done(null, tunnel);
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const secure = tls.connect({
|
|
131
|
+
socket: tunnel,
|
|
132
|
+
servername: isIP(host) ? undefined : (options.servername ?? host),
|
|
133
|
+
ALPNProtocols: ["http/1.1"],
|
|
134
|
+
...(unverified(host) ? { rejectUnauthorized: false } : {}),
|
|
135
|
+
});
|
|
136
|
+
const failed = (error: Error) => done(error, null);
|
|
137
|
+
secure.once("error", failed);
|
|
138
|
+
secure.once("secureConnect", () => {
|
|
139
|
+
secure.removeListener("error", failed);
|
|
140
|
+
done(null, secure);
|
|
141
|
+
});
|
|
142
|
+
},
|
|
143
|
+
(error: ProxyError) => {
|
|
144
|
+
warn(`no WebSocket to ${host}:${port}: ${error.message}`);
|
|
145
|
+
done(error, null);
|
|
146
|
+
},
|
|
147
|
+
);
|
|
148
|
+
};
|
|
149
|
+
try {
|
|
150
|
+
const agent = new Agent({ connect });
|
|
151
|
+
return typeof agent.dispatch === "function" ? agent : null;
|
|
152
|
+
} catch {
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** An opener whose every connection is `dispatcher`'s: proxiedDispatcher's. */
|
|
158
|
+
export function dispatchedOpener(dispatcher: unknown): Opener {
|
|
159
|
+
return (url, protocols) => {
|
|
160
|
+
const Client = globalClient() as ClientConstructor;
|
|
161
|
+
return new Client(url, { protocols, dispatcher });
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* An opener that leaves a wss: server's certificate unverified where
|
|
167
|
+
* `unverified` says so of its host, through `dispatcher`
|
|
168
|
+
* (unverifiedDispatcher's); any other connection is the global's as it is.
|
|
169
|
+
*/
|
|
170
|
+
export function webSocketOpener(
|
|
171
|
+
unverified: (host: string) => boolean,
|
|
172
|
+
dispatcher: unknown,
|
|
173
|
+
): Opener {
|
|
174
|
+
return (url, protocols) => {
|
|
175
|
+
const Client = globalClient() as ClientConstructor;
|
|
176
|
+
const parsed = new URL(url);
|
|
177
|
+
const host = parsed.hostname.replace(/^\[(.*)\]$/, "$1").toLowerCase();
|
|
178
|
+
return dispatcher && parsed.protocol === "wss:" && unverified(host)
|
|
179
|
+
? new Client(url, { protocols, dispatcher })
|
|
180
|
+
: new Client(url, protocols);
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Whether the page asked for a ws: or wss: URL with a host, of a length a server takes. */
|
|
185
|
+
export function validWebSocketUrl(url: unknown): url is string {
|
|
186
|
+
if (typeof url !== "string" || url.length > MAX_URL) {
|
|
187
|
+
return false;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
try {
|
|
191
|
+
const parsed = new URL(url);
|
|
192
|
+
return (parsed.protocol === "ws:" || parsed.protocol === "wss:") && parsed.hostname !== "";
|
|
193
|
+
} catch {
|
|
194
|
+
return false;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export function validProtocols(protocols: unknown): protocols is string[] {
|
|
199
|
+
return (
|
|
200
|
+
Array.isArray(protocols) &&
|
|
201
|
+
protocols.length <= MAX_PROTOCOLS &&
|
|
202
|
+
protocols.every((p) => typeof p === "string" && p.length <= MAX_PROTOCOL)
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** The close codes the WHATWG WebSocket sends; any other throws. */
|
|
207
|
+
const sendable = (code: unknown): code is number =>
|
|
208
|
+
code === 1000 || (typeof code === "number" && code >= 3000 && code <= 4999);
|
|
209
|
+
|
|
210
|
+
export class WebSocketConnections {
|
|
211
|
+
private readonly clients = new Map<number, Client>();
|
|
212
|
+
private readonly tell: (id: number, event: WebSocketEvent) => void;
|
|
213
|
+
private readonly open: Opener;
|
|
214
|
+
|
|
215
|
+
/** `tell` carries each connection's events to its page; `open` makes a client, the global's by default. */
|
|
216
|
+
constructor(
|
|
217
|
+
tell: (id: number, event: WebSocketEvent) => void,
|
|
218
|
+
open: Opener = (url, protocols) => new (globalClient() as ClientConstructor)(url, protocols),
|
|
219
|
+
) {
|
|
220
|
+
this.tell = tell;
|
|
221
|
+
this.open = open;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
get size(): number {
|
|
225
|
+
return this.clients.size;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** Counts closeAll's: a connection decided on before one belongs to a page that is gone. */
|
|
229
|
+
generation = 0;
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Connect `id` to `url`, offering `protocols`, each checked, as the page
|
|
233
|
+
* asks; a refusal, a URL or protocols the client throws for among them,
|
|
234
|
+
* is an error, then a close.
|
|
235
|
+
*/
|
|
236
|
+
connect(id: number, url: unknown, protocols: unknown): void {
|
|
237
|
+
if (
|
|
238
|
+
!validWebSocketUrl(url) ||
|
|
239
|
+
!validProtocols(protocols) ||
|
|
240
|
+
this.clients.has(id) ||
|
|
241
|
+
this.clients.size >= MAX_OPEN ||
|
|
242
|
+
!hasWebSocket()
|
|
243
|
+
) {
|
|
244
|
+
this.refuse(id);
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
let client: Client;
|
|
249
|
+
try {
|
|
250
|
+
client = this.open(url, protocols) as Client;
|
|
251
|
+
} catch {
|
|
252
|
+
this.refuse(id);
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
client.binaryType = "arraybuffer";
|
|
257
|
+
this.clients.set(id, client);
|
|
258
|
+
client.onopen = () => this.tell(id, { type: "open", protocol: client.protocol });
|
|
259
|
+
client.onmessage = ({ data }) =>
|
|
260
|
+
this.tell(id, {
|
|
261
|
+
type: "message",
|
|
262
|
+
data: typeof data === "string" ? data : new Uint8Array(data as ArrayBuffer),
|
|
263
|
+
});
|
|
264
|
+
client.onerror = () => this.tell(id, { type: "error" });
|
|
265
|
+
client.onclose = ({ code }) => {
|
|
266
|
+
// Only the client under this id: a page that reused it has another by now.
|
|
267
|
+
if (this.clients.get(id) === client) {
|
|
268
|
+
this.clients.delete(id);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
this.tell(id, { type: "close", code });
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Send a text message for a string, a binary one for bytes, once the
|
|
277
|
+
* connection is open, unless the peer has left more than MAX_BUFFERED
|
|
278
|
+
* unread: then the connection fails.
|
|
279
|
+
*/
|
|
280
|
+
send(id: number, data: string | Uint8Array): void {
|
|
281
|
+
const client = this.clients.get(id);
|
|
282
|
+
if (!client || client.readyState !== OPEN) {
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const size = typeof data === "string" ? Buffer.byteLength(data) : data.length;
|
|
287
|
+
if (client.bufferedAmount + size > MAX_BUFFERED) {
|
|
288
|
+
// The client has no way to drop a connection: its close waits behind
|
|
289
|
+
// what is buffered, so the page is told now and the client left to it.
|
|
290
|
+
this.silence(client);
|
|
291
|
+
this.clients.delete(id);
|
|
292
|
+
this.tell(id, { type: "error" });
|
|
293
|
+
this.tell(id, { type: "close", code: 1006 });
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
client.send(data);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/** Refuse `id`, as a server that took no connection: an error, then a close with no frame. */
|
|
301
|
+
refuse(id: number): void {
|
|
302
|
+
this.tell(id, { type: "error" });
|
|
303
|
+
this.tell(id, { type: "close", code: 1006 });
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/** A close frame with `code` where the client sends it, 1000 or 3000 to 4999, else with none. */
|
|
307
|
+
close(id: number, code?: unknown): void {
|
|
308
|
+
const client = this.clients.get(id);
|
|
309
|
+
if (!client) {
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
try {
|
|
314
|
+
client.close(sendable(code) ? code : undefined);
|
|
315
|
+
} catch {
|
|
316
|
+
// Already closing.
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Close every connection without a word: the page that held them is
|
|
322
|
+
* gone, and the next numbers its own from 1.
|
|
323
|
+
*/
|
|
324
|
+
closeAll(): void {
|
|
325
|
+
for (const client of this.clients.values()) {
|
|
326
|
+
this.silence(client);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
this.clients.clear();
|
|
330
|
+
this.generation++;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
private silence(client: Client): void {
|
|
334
|
+
client.onopen = null;
|
|
335
|
+
client.onmessage = null;
|
|
336
|
+
client.onerror = null;
|
|
337
|
+
client.onclose = null;
|
|
338
|
+
try {
|
|
339
|
+
client.close();
|
|
340
|
+
} catch {
|
|
341
|
+
// Already closing.
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// air.net.WebSocket's connections, made here for the page, which has no
|
|
2
|
+
// network of its own, and carried over IPC as flash.net.Socket's are
|
|
3
|
+
// (sockets.ts). Each page's connections (websocket-connections.ts) close
|
|
4
|
+
// as it navigates, reloads or goes.
|
|
5
|
+
import { type IpcMainEvent, ipcMain, type WebContents } from "electron";
|
|
6
|
+
import { webSocketDataLine, webSocketLine } from "./netlog.ts";
|
|
7
|
+
import {
|
|
8
|
+
type Opener,
|
|
9
|
+
validProtocols,
|
|
10
|
+
validWebSocketUrl,
|
|
11
|
+
WebSocketConnections,
|
|
12
|
+
} from "./websocket-connections.ts";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Carry WebSocket messages from pages that `trusted` accepts, each
|
|
16
|
+
* connection made only where `permit` allows its URL, with `open` if
|
|
17
|
+
* given. `log`, where given, is told each connection asked for, opened,
|
|
18
|
+
* refused, failed and closed (--log-network), and with `data` what each
|
|
19
|
+
* sends and receives too (--log-sockets), the connections' lines ending
|
|
20
|
+
* in `via`.
|
|
21
|
+
*/
|
|
22
|
+
export function bridgeWebSockets(
|
|
23
|
+
trusted: (event: IpcMainEvent) => boolean,
|
|
24
|
+
permit: (url: string) => boolean,
|
|
25
|
+
open: Opener | undefined,
|
|
26
|
+
log: ((line: string) => void) | null = null,
|
|
27
|
+
data = false,
|
|
28
|
+
via = "",
|
|
29
|
+
): void {
|
|
30
|
+
const pages = new Map<WebContents, WebSocketConnections>();
|
|
31
|
+
|
|
32
|
+
const connectionsOf = (contents: WebContents): WebSocketConnections => {
|
|
33
|
+
const known = pages.get(contents);
|
|
34
|
+
if (known) {
|
|
35
|
+
return known;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const connections = new WebSocketConnections((id, event) => {
|
|
39
|
+
if (log) {
|
|
40
|
+
switch (event.type) {
|
|
41
|
+
case "open":
|
|
42
|
+
log(webSocketLine(id, event.protocol === "" ? "open" : `open ${event.protocol}`));
|
|
43
|
+
break;
|
|
44
|
+
case "message":
|
|
45
|
+
if (data) {
|
|
46
|
+
log(webSocketDataLine(id, false, event.data));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
break;
|
|
50
|
+
case "close":
|
|
51
|
+
log(webSocketLine(id, `close ${event.code}`));
|
|
52
|
+
break;
|
|
53
|
+
case "error":
|
|
54
|
+
log(webSocketLine(id, "error"));
|
|
55
|
+
break;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (!contents.isDestroyed()) {
|
|
60
|
+
contents.send("websocket:event", id, event);
|
|
61
|
+
}
|
|
62
|
+
}, open);
|
|
63
|
+
pages.set(contents, connections);
|
|
64
|
+
contents.on("did-start-navigation", (details) => {
|
|
65
|
+
if (details.isMainFrame && !details.isSameDocument) {
|
|
66
|
+
connections.closeAll();
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
contents.once("destroyed", () => {
|
|
70
|
+
connections.closeAll();
|
|
71
|
+
pages.delete(contents);
|
|
72
|
+
});
|
|
73
|
+
return connections;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
ipcMain.on("websocket:connect", (event, id: unknown, url: unknown, protocols: unknown) => {
|
|
77
|
+
if (!trusted(event) || typeof id !== "number") {
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const connections = connectionsOf(event.sender);
|
|
82
|
+
if (!validWebSocketUrl(url) || !validProtocols(protocols)) {
|
|
83
|
+
connections.refuse(id);
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// As the client will read it: what is said of it has nothing a terminal would act on.
|
|
88
|
+
const href = new URL(url).href;
|
|
89
|
+
const allowed = permit(href);
|
|
90
|
+
log?.(webSocketLine(id, `${allowed ? `connecting to ${href}${via}` : `refused ${href}`}`));
|
|
91
|
+
if (allowed) {
|
|
92
|
+
connections.connect(id, href, protocols);
|
|
93
|
+
} else {
|
|
94
|
+
connections.refuse(id);
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
ipcMain.on("websocket:send", (event, id: unknown, message: unknown) => {
|
|
99
|
+
if (
|
|
100
|
+
trusted(event) &&
|
|
101
|
+
typeof id === "number" &&
|
|
102
|
+
(typeof message === "string" || message instanceof Uint8Array)
|
|
103
|
+
) {
|
|
104
|
+
if (log && data) {
|
|
105
|
+
log(webSocketDataLine(id, true, message));
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
pages.get(event.sender)?.send(id, message);
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
ipcMain.on("websocket:close", (event, id: unknown, code: unknown) => {
|
|
113
|
+
if (trusted(event) && typeof id === "number") {
|
|
114
|
+
pages.get(event.sender)?.close(id, code);
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
}
|
package/src/preload/preload.cts
CHANGED
|
@@ -7,11 +7,13 @@ import type {
|
|
|
7
7
|
NetworkResponse,
|
|
8
8
|
OpenedMovie,
|
|
9
9
|
SocketEvent,
|
|
10
|
+
WebSocketEvent,
|
|
10
11
|
} from "../shared/api.js";
|
|
11
12
|
|
|
12
13
|
const { contextBridge, ipcRenderer, webUtils } = require("electron") as typeof import("electron");
|
|
13
14
|
|
|
14
15
|
let nextSocket = 1;
|
|
16
|
+
let nextWebSocket = 1;
|
|
15
17
|
let nextRequest = 1;
|
|
16
18
|
/** The requests waiting for their response, by number. */
|
|
17
19
|
const responses = new Map<number, (response: NetworkResponse) => void>();
|
|
@@ -27,6 +29,12 @@ const api: DesktopApi = {
|
|
|
27
29
|
write: (id, bytes) => ipcRenderer.invoke("files:write", id, bytes),
|
|
28
30
|
release: (id) => ipcRenderer.send("files:release", id),
|
|
29
31
|
},
|
|
32
|
+
fullScreen: {
|
|
33
|
+
set: (fullScreen) => ipcRenderer.send("desktop:full-screen", fullScreen === true),
|
|
34
|
+
onChange: (listener) => {
|
|
35
|
+
ipcRenderer.on("desktop:full-screen", (_event, fullScreen: boolean) => listener(fullScreen));
|
|
36
|
+
},
|
|
37
|
+
},
|
|
30
38
|
start: () => ipcRenderer.invoke("desktop:start"),
|
|
31
39
|
onOpen: (listener) => {
|
|
32
40
|
ipcRenderer.on("desktop:open", (_event, movie: OpenedMovie) => listener(movie));
|
|
@@ -53,6 +61,20 @@ const api: DesktopApi = {
|
|
|
53
61
|
);
|
|
54
62
|
},
|
|
55
63
|
},
|
|
64
|
+
webSockets: {
|
|
65
|
+
connect: (url, protocols) => {
|
|
66
|
+
const id = nextWebSocket++;
|
|
67
|
+
ipcRenderer.send("websocket:connect", id, String(url), Array.from(protocols, String));
|
|
68
|
+
return id;
|
|
69
|
+
},
|
|
70
|
+
send: (id, data) => ipcRenderer.send("websocket:send", id, data),
|
|
71
|
+
close: (id, code) => ipcRenderer.send("websocket:close", id, code),
|
|
72
|
+
onEvent: (listener) => {
|
|
73
|
+
ipcRenderer.on("websocket:event", (_event, id: number, event: WebSocketEvent) =>
|
|
74
|
+
listener(id, event),
|
|
75
|
+
);
|
|
76
|
+
},
|
|
77
|
+
},
|
|
56
78
|
network: {
|
|
57
79
|
fetch: (request, cancel) => {
|
|
58
80
|
const id = nextRequest++;
|
|
@@ -95,6 +117,7 @@ const api: DesktopApi = {
|
|
|
95
117
|
remove: (path) => ipcRenderer.sendSync("air:remove", String(path)),
|
|
96
118
|
move: (from, to) => ipcRenderer.sendSync("air:move", String(from), String(to)),
|
|
97
119
|
window: () => ipcRenderer.sendSync("air:window"),
|
|
120
|
+
screens: () => ipcRenderer.sendSync("air:screens"),
|
|
98
121
|
setBounds: (bounds) => ipcRenderer.send("air:set-bounds", bounds),
|
|
99
122
|
setTitle: (title) => ipcRenderer.send("air:set-title", String(title)),
|
|
100
123
|
minimize: () => ipcRenderer.send("air:window-do", "minimize"),
|
package/src/renderer/air.ts
CHANGED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// Stage.displayState's full screen in the desktop app: the window's, through
|
|
2
|
+
// the shell, since the player's element already fills it. A SWF asks with
|
|
3
|
+
// no gesture, as an AIR application and Flash's projector could, which the
|
|
4
|
+
// Fullscreen API on the element would refuse. Esc leaves, as in Flash; the
|
|
5
|
+
// menu's F11 enters and leaves too, and the SWF hears of either.
|
|
6
|
+
import type { WindowFullScreen } from "@swf2es/web";
|
|
7
|
+
import type { DesktopFullScreen } from "../shared/api.js";
|
|
8
|
+
|
|
9
|
+
export function windowFullScreen(shell: DesktopFullScreen): WindowFullScreen {
|
|
10
|
+
const listeners = new Set<(fullScreen: boolean) => void>();
|
|
11
|
+
let full = false;
|
|
12
|
+
shell.onChange((fullScreen) => {
|
|
13
|
+
full = fullScreen;
|
|
14
|
+
for (const listener of listeners) {
|
|
15
|
+
listener(fullScreen);
|
|
16
|
+
}
|
|
17
|
+
});
|
|
18
|
+
// In the capture phase: before the SWF's own key handlers, whatever has the focus.
|
|
19
|
+
document.addEventListener(
|
|
20
|
+
"keydown",
|
|
21
|
+
(event) => {
|
|
22
|
+
if (event.key === "Escape" && full) {
|
|
23
|
+
full = false;
|
|
24
|
+
shell.set(false);
|
|
25
|
+
// No window manager tells of this one sooner than the SWF should hear.
|
|
26
|
+
for (const listener of listeners) {
|
|
27
|
+
listener(false);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
true,
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
return {
|
|
35
|
+
set: (fullScreen) => {
|
|
36
|
+
full = fullScreen;
|
|
37
|
+
shell.set(fullScreen);
|
|
38
|
+
},
|
|
39
|
+
onChange: (listener) => {
|
|
40
|
+
listeners.add(listener);
|
|
41
|
+
return () => listeners.delete(listener);
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
}
|
package/src/renderer/index.ts
CHANGED
|
@@ -5,8 +5,10 @@ import { configure, type Swf2esPlayerElement } from "@swf2es/web";
|
|
|
5
5
|
import type { AirLaunch, DesktopApi, LibraryState, OpenedMovie } from "../shared/api.js";
|
|
6
6
|
import { airHosts, airPath } from "./air.js";
|
|
7
7
|
import { shellFiles } from "./files.js";
|
|
8
|
+
import { windowFullScreen } from "./full-screen.js";
|
|
8
9
|
import { shellFetch } from "./network.js";
|
|
9
10
|
import { shellSocketHost } from "./sockets.js";
|
|
11
|
+
import { shellWebSocketHost } from "./websockets.js";
|
|
10
12
|
|
|
11
13
|
declare global {
|
|
12
14
|
interface Window {
|
|
@@ -22,6 +24,7 @@ configure({
|
|
|
22
24
|
cache: true,
|
|
23
25
|
fileHost: shellFiles(desktop.files),
|
|
24
26
|
sockets: shellSocketHost(desktop.sockets),
|
|
27
|
+
webSockets: shellWebSocketHost(desktop.webSockets),
|
|
25
28
|
fetch: shellFetch(desktop.network, () => {
|
|
26
29
|
const launch = airLaunch;
|
|
27
30
|
return launch
|
|
@@ -32,6 +35,7 @@ configure({
|
|
|
32
35
|
: null;
|
|
33
36
|
}),
|
|
34
37
|
loadPolicyFile: (url) => desktop.network.loadPolicyFile(url),
|
|
38
|
+
fullScreen: windowFullScreen(desktop.fullScreen),
|
|
35
39
|
});
|
|
36
40
|
|
|
37
41
|
const byId = <T extends HTMLElement>(id: string) => document.getElementById(id) as T;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// air.net.WebSocket over the shell's connections: what the player asks of
|
|
2
|
+
// a WebSocketHost, carried to the main process and back. The page's own
|
|
3
|
+
// WebSocket reaches nothing: the page has no network.
|
|
4
|
+
import type { WebSocketEvents, WebSocketHost } from "@swf2es/player";
|
|
5
|
+
import type { DesktopWebSockets } from "../shared/api.js";
|
|
6
|
+
|
|
7
|
+
export function shellWebSocketHost(webSockets: DesktopWebSockets): WebSocketHost {
|
|
8
|
+
// Until its close, which the shell sends after a failure too.
|
|
9
|
+
const open = new Map<number, WebSocketEvents>();
|
|
10
|
+
webSockets.onEvent((id, event) => {
|
|
11
|
+
const events = open.get(id);
|
|
12
|
+
if (!events) {
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
switch (event.type) {
|
|
17
|
+
case "open":
|
|
18
|
+
events.open(event.protocol);
|
|
19
|
+
break;
|
|
20
|
+
case "message":
|
|
21
|
+
events.message(event.data);
|
|
22
|
+
break;
|
|
23
|
+
case "error":
|
|
24
|
+
events.error();
|
|
25
|
+
break;
|
|
26
|
+
case "close":
|
|
27
|
+
open.delete(id);
|
|
28
|
+
events.close(event.code);
|
|
29
|
+
break;
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
return {
|
|
34
|
+
connect(url, protocols, events) {
|
|
35
|
+
const id = webSockets.connect(url, protocols);
|
|
36
|
+
open.set(id, events);
|
|
37
|
+
return {
|
|
38
|
+
send: (data) => webSockets.send(id, data),
|
|
39
|
+
close: (code) => webSockets.close(id, code),
|
|
40
|
+
};
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
}
|