@swf2es/desktop 0.1.15 → 0.1.16
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 +44 -11
- package/dist/launch/launch.js +4 -1
- package/dist/launch/launch.js.map +1 -1
- package/dist/main/main.js +66 -8
- 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/sockets.d.ts +4 -2
- package/dist/main/sockets.d.ts.map +1 -1
- package/dist/main/sockets.js +15 -2
- package/dist/main/sockets.js.map +1 -1
- package/dist/main/websocket-connections.d.ts +62 -0
- package/dist/main/websocket-connections.d.ts.map +1 -0
- package/dist/main/websocket-connections.js +198 -0
- package/dist/main/websocket-connections.js.map +1 -0
- package/dist/main/websockets.d.ts +11 -0
- package/dist/main/websockets.d.ts.map +1 -0
- package/dist/main/websockets.js +93 -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/main.ts +88 -9
- package/src/main/netlog.ts +108 -0
- package/src/main/sockets.ts +17 -1
- package/src/main/websocket-connections.ts +264 -0
- package/src/main/websockets.ts +115 -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,264 @@
|
|
|
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 type { WebSocketEvent } 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
|
+
/** The longest URL, and the most and longest subprotocols, a page may ask for. */
|
|
11
|
+
const MAX_URL = 8192;
|
|
12
|
+
const MAX_PROTOCOLS = 32;
|
|
13
|
+
const MAX_PROTOCOL = 256;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The bytes a connection may hold unsent, as a socket's (connections.ts)
|
|
17
|
+
* but for whole messages, which are larger than a socket's writes: a peer
|
|
18
|
+
* that stops reading would otherwise have the main process buffer all an
|
|
19
|
+
* application sends. Past this the connection fails.
|
|
20
|
+
*/
|
|
21
|
+
export const MAX_BUFFERED = 16 * 1024 * 1024;
|
|
22
|
+
|
|
23
|
+
const OPEN = 1;
|
|
24
|
+
|
|
25
|
+
/** What is used of the WHATWG WebSocket. */
|
|
26
|
+
interface Client {
|
|
27
|
+
binaryType: string;
|
|
28
|
+
readonly protocol: string;
|
|
29
|
+
readonly readyState: number;
|
|
30
|
+
readonly bufferedAmount: number;
|
|
31
|
+
onopen: (() => void) | null;
|
|
32
|
+
onmessage: ((event: { data: unknown }) => void) | null;
|
|
33
|
+
onclose: ((event: { code: number }) => void) | null;
|
|
34
|
+
onerror: (() => void) | null;
|
|
35
|
+
send(data: string | Uint8Array): void;
|
|
36
|
+
close(code?: number): void;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
type ClientConstructor = new (
|
|
40
|
+
url: string,
|
|
41
|
+
// Node's takes its options in the protocols' place: undici's dispatcher among them.
|
|
42
|
+
protocols: string[] | { protocols: string[]; dispatcher: unknown },
|
|
43
|
+
) => Client;
|
|
44
|
+
|
|
45
|
+
/** Opens a client; throws as the WebSocket constructor does. */
|
|
46
|
+
export type Opener = (url: string, protocols: string[]) => unknown;
|
|
47
|
+
|
|
48
|
+
const globalClient = (): ClientConstructor | undefined =>
|
|
49
|
+
(globalThis as { WebSocket?: ClientConstructor }).WebSocket;
|
|
50
|
+
|
|
51
|
+
/** Whether this Node has the WHATWG WebSocket (22 and later, Electron's among them). */
|
|
52
|
+
export function hasWebSocket(): boolean {
|
|
53
|
+
return typeof globalClient() === "function";
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* What sends a client's requests with no certificate verified, for
|
|
58
|
+
* --ignore-certificate-errors; null where this Node gives no way to make
|
|
59
|
+
* one. The WHATWG WebSocket has no option for it: Node's is undici's,
|
|
60
|
+
* which takes a dispatcher, and Node exports no class of one, so it is
|
|
61
|
+
* made from the class of the dispatcher undici keeps for everything else.
|
|
62
|
+
*/
|
|
63
|
+
export function unverifiedDispatcher(): unknown {
|
|
64
|
+
// Naming the global loads undici, which then sets its dispatcher.
|
|
65
|
+
if (!hasWebSocket()) {
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const shared = (globalThis as Record<symbol, unknown>)[Symbol.for("undici.globalDispatcher.1")];
|
|
70
|
+
const Agent = (shared as { constructor?: unknown } | undefined)?.constructor;
|
|
71
|
+
if (typeof Agent !== "function" || Agent.name !== "Agent") {
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
try {
|
|
76
|
+
const agent = new (Agent as new (options: object) => { dispatch?: unknown })({
|
|
77
|
+
connect: { rejectUnauthorized: false },
|
|
78
|
+
});
|
|
79
|
+
return typeof agent.dispatch === "function" ? agent : null;
|
|
80
|
+
} catch {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* An opener that leaves a wss: server's certificate unverified where
|
|
87
|
+
* `unverified` says so of its host, through `dispatcher`
|
|
88
|
+
* (unverifiedDispatcher's); any other connection is the global's as it is.
|
|
89
|
+
*/
|
|
90
|
+
export function webSocketOpener(
|
|
91
|
+
unverified: (host: string) => boolean,
|
|
92
|
+
dispatcher: unknown,
|
|
93
|
+
): Opener {
|
|
94
|
+
return (url, protocols) => {
|
|
95
|
+
const Client = globalClient() as ClientConstructor;
|
|
96
|
+
const parsed = new URL(url);
|
|
97
|
+
const host = parsed.hostname.replace(/^\[(.*)\]$/, "$1").toLowerCase();
|
|
98
|
+
return dispatcher && parsed.protocol === "wss:" && unverified(host)
|
|
99
|
+
? new Client(url, { protocols, dispatcher })
|
|
100
|
+
: new Client(url, protocols);
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Whether the page asked for a ws: or wss: URL with a host, of a length a server takes. */
|
|
105
|
+
export function validWebSocketUrl(url: unknown): url is string {
|
|
106
|
+
if (typeof url !== "string" || url.length > MAX_URL) {
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
try {
|
|
111
|
+
const parsed = new URL(url);
|
|
112
|
+
return (parsed.protocol === "ws:" || parsed.protocol === "wss:") && parsed.hostname !== "";
|
|
113
|
+
} catch {
|
|
114
|
+
return false;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function validProtocols(protocols: unknown): protocols is string[] {
|
|
119
|
+
return (
|
|
120
|
+
Array.isArray(protocols) &&
|
|
121
|
+
protocols.length <= MAX_PROTOCOLS &&
|
|
122
|
+
protocols.every((p) => typeof p === "string" && p.length <= MAX_PROTOCOL)
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** The close codes the WHATWG WebSocket sends; any other throws. */
|
|
127
|
+
const sendable = (code: unknown): code is number =>
|
|
128
|
+
code === 1000 || (typeof code === "number" && code >= 3000 && code <= 4999);
|
|
129
|
+
|
|
130
|
+
export class WebSocketConnections {
|
|
131
|
+
private readonly clients = new Map<number, Client>();
|
|
132
|
+
private readonly tell: (id: number, event: WebSocketEvent) => void;
|
|
133
|
+
private readonly open: Opener;
|
|
134
|
+
|
|
135
|
+
/** `tell` carries each connection's events to its page; `open` makes a client, the global's by default. */
|
|
136
|
+
constructor(
|
|
137
|
+
tell: (id: number, event: WebSocketEvent) => void,
|
|
138
|
+
open: Opener = (url, protocols) => new (globalClient() as ClientConstructor)(url, protocols),
|
|
139
|
+
) {
|
|
140
|
+
this.tell = tell;
|
|
141
|
+
this.open = open;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
get size(): number {
|
|
145
|
+
return this.clients.size;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Counts closeAll's: a connection decided on before one belongs to a page that is gone. */
|
|
149
|
+
generation = 0;
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Connect `id` to `url`, offering `protocols`, each checked, as the page
|
|
153
|
+
* asks; a refusal, a URL or protocols the client throws for among them,
|
|
154
|
+
* is an error, then a close.
|
|
155
|
+
*/
|
|
156
|
+
connect(id: number, url: unknown, protocols: unknown): void {
|
|
157
|
+
if (
|
|
158
|
+
!validWebSocketUrl(url) ||
|
|
159
|
+
!validProtocols(protocols) ||
|
|
160
|
+
this.clients.has(id) ||
|
|
161
|
+
this.clients.size >= MAX_OPEN ||
|
|
162
|
+
!hasWebSocket()
|
|
163
|
+
) {
|
|
164
|
+
this.refuse(id);
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
let client: Client;
|
|
169
|
+
try {
|
|
170
|
+
client = this.open(url, protocols) as Client;
|
|
171
|
+
} catch {
|
|
172
|
+
this.refuse(id);
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
client.binaryType = "arraybuffer";
|
|
177
|
+
this.clients.set(id, client);
|
|
178
|
+
client.onopen = () => this.tell(id, { type: "open", protocol: client.protocol });
|
|
179
|
+
client.onmessage = ({ data }) =>
|
|
180
|
+
this.tell(id, {
|
|
181
|
+
type: "message",
|
|
182
|
+
data: typeof data === "string" ? data : new Uint8Array(data as ArrayBuffer),
|
|
183
|
+
});
|
|
184
|
+
client.onerror = () => this.tell(id, { type: "error" });
|
|
185
|
+
client.onclose = ({ code }) => {
|
|
186
|
+
// Only the client under this id: a page that reused it has another by now.
|
|
187
|
+
if (this.clients.get(id) === client) {
|
|
188
|
+
this.clients.delete(id);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
this.tell(id, { type: "close", code });
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Send a text message for a string, a binary one for bytes, once the
|
|
197
|
+
* connection is open, unless the peer has left more than MAX_BUFFERED
|
|
198
|
+
* unread: then the connection fails.
|
|
199
|
+
*/
|
|
200
|
+
send(id: number, data: string | Uint8Array): void {
|
|
201
|
+
const client = this.clients.get(id);
|
|
202
|
+
if (!client || client.readyState !== OPEN) {
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const size = typeof data === "string" ? Buffer.byteLength(data) : data.length;
|
|
207
|
+
if (client.bufferedAmount + size > MAX_BUFFERED) {
|
|
208
|
+
// The client has no way to drop a connection: its close waits behind
|
|
209
|
+
// what is buffered, so the page is told now and the client left to it.
|
|
210
|
+
this.silence(client);
|
|
211
|
+
this.clients.delete(id);
|
|
212
|
+
this.tell(id, { type: "error" });
|
|
213
|
+
this.tell(id, { type: "close", code: 1006 });
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
client.send(data);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** Refuse `id`, as a server that took no connection: an error, then a close with no frame. */
|
|
221
|
+
refuse(id: number): void {
|
|
222
|
+
this.tell(id, { type: "error" });
|
|
223
|
+
this.tell(id, { type: "close", code: 1006 });
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** A close frame with `code` where the client sends it, 1000 or 3000 to 4999, else with none. */
|
|
227
|
+
close(id: number, code?: unknown): void {
|
|
228
|
+
const client = this.clients.get(id);
|
|
229
|
+
if (!client) {
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
try {
|
|
234
|
+
client.close(sendable(code) ? code : undefined);
|
|
235
|
+
} catch {
|
|
236
|
+
// Already closing.
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Close every connection without a word: the page that held them is
|
|
242
|
+
* gone, and the next numbers its own from 1.
|
|
243
|
+
*/
|
|
244
|
+
closeAll(): void {
|
|
245
|
+
for (const client of this.clients.values()) {
|
|
246
|
+
this.silence(client);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
this.clients.clear();
|
|
250
|
+
this.generation++;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
private silence(client: Client): void {
|
|
254
|
+
client.onopen = null;
|
|
255
|
+
client.onmessage = null;
|
|
256
|
+
client.onerror = null;
|
|
257
|
+
client.onclose = null;
|
|
258
|
+
try {
|
|
259
|
+
client.close();
|
|
260
|
+
} catch {
|
|
261
|
+
// Already closing.
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
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).
|
|
20
|
+
*/
|
|
21
|
+
export function bridgeWebSockets(
|
|
22
|
+
trusted: (event: IpcMainEvent) => boolean,
|
|
23
|
+
permit: (url: string) => boolean,
|
|
24
|
+
open: Opener | undefined,
|
|
25
|
+
log: ((line: string) => void) | null = null,
|
|
26
|
+
data = false,
|
|
27
|
+
): void {
|
|
28
|
+
const pages = new Map<WebContents, WebSocketConnections>();
|
|
29
|
+
|
|
30
|
+
const connectionsOf = (contents: WebContents): WebSocketConnections => {
|
|
31
|
+
const known = pages.get(contents);
|
|
32
|
+
if (known) {
|
|
33
|
+
return known;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const connections = new WebSocketConnections((id, event) => {
|
|
37
|
+
if (log) {
|
|
38
|
+
switch (event.type) {
|
|
39
|
+
case "open":
|
|
40
|
+
log(webSocketLine(id, event.protocol === "" ? "open" : `open ${event.protocol}`));
|
|
41
|
+
break;
|
|
42
|
+
case "message":
|
|
43
|
+
if (data) {
|
|
44
|
+
log(webSocketDataLine(id, false, event.data));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
break;
|
|
48
|
+
case "close":
|
|
49
|
+
log(webSocketLine(id, `close ${event.code}`));
|
|
50
|
+
break;
|
|
51
|
+
case "error":
|
|
52
|
+
log(webSocketLine(id, "error"));
|
|
53
|
+
break;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (!contents.isDestroyed()) {
|
|
58
|
+
contents.send("websocket:event", id, event);
|
|
59
|
+
}
|
|
60
|
+
}, open);
|
|
61
|
+
pages.set(contents, connections);
|
|
62
|
+
contents.on("did-start-navigation", (details) => {
|
|
63
|
+
if (details.isMainFrame && !details.isSameDocument) {
|
|
64
|
+
connections.closeAll();
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
contents.once("destroyed", () => {
|
|
68
|
+
connections.closeAll();
|
|
69
|
+
pages.delete(contents);
|
|
70
|
+
});
|
|
71
|
+
return connections;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
ipcMain.on("websocket:connect", (event, id: unknown, url: unknown, protocols: unknown) => {
|
|
75
|
+
if (!trusted(event) || typeof id !== "number") {
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const connections = connectionsOf(event.sender);
|
|
80
|
+
if (!validWebSocketUrl(url) || !validProtocols(protocols)) {
|
|
81
|
+
connections.refuse(id);
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// As the client will read it: what is said of it has nothing a terminal would act on.
|
|
86
|
+
const href = new URL(url).href;
|
|
87
|
+
const allowed = permit(href);
|
|
88
|
+
log?.(webSocketLine(id, `${allowed ? "connecting to" : "refused"} ${href}`));
|
|
89
|
+
if (allowed) {
|
|
90
|
+
connections.connect(id, href, protocols);
|
|
91
|
+
} else {
|
|
92
|
+
connections.refuse(id);
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
ipcMain.on("websocket:send", (event, id: unknown, message: unknown) => {
|
|
97
|
+
if (
|
|
98
|
+
trusted(event) &&
|
|
99
|
+
typeof id === "number" &&
|
|
100
|
+
(typeof message === "string" || message instanceof Uint8Array)
|
|
101
|
+
) {
|
|
102
|
+
if (log && data) {
|
|
103
|
+
log(webSocketDataLine(id, true, message));
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
pages.get(event.sender)?.send(id, message);
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
ipcMain.on("websocket:close", (event, id: unknown, code: unknown) => {
|
|
111
|
+
if (trusted(event) && typeof id === "number") {
|
|
112
|
+
pages.get(event.sender)?.close(id, code);
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
}
|
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
|
+
}
|
package/src/shared/api.ts
CHANGED
|
@@ -50,6 +50,14 @@ export interface AirWindowState {
|
|
|
50
50
|
visible: boolean;
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
+
/** One of the system's displays as flash.display.Screen reads it: all of it, and what a window may fill. */
|
|
54
|
+
export interface AirScreenState {
|
|
55
|
+
bounds: { x: number; y: number; width: number; height: number };
|
|
56
|
+
visibleBounds: { x: number; y: number; width: number; height: number };
|
|
57
|
+
colorDepth: number;
|
|
58
|
+
main: boolean;
|
|
59
|
+
}
|
|
60
|
+
|
|
53
61
|
/**
|
|
54
62
|
* An AIR application's own reach, only while one the user opened plays:
|
|
55
63
|
* the file system, read and written synchronously as File does, and the
|
|
@@ -72,6 +80,8 @@ export interface DesktopAir {
|
|
|
72
80
|
remove(path: string): boolean;
|
|
73
81
|
move(from: string, to: string): boolean;
|
|
74
82
|
window(): AirWindowState;
|
|
83
|
+
/** The displays as they are now; none where no application plays. */
|
|
84
|
+
screens(): AirScreenState[];
|
|
75
85
|
setBounds(bounds: { x: number; y: number; width: number; height: number }): void;
|
|
76
86
|
setTitle(title: string): void;
|
|
77
87
|
minimize(): void;
|
|
@@ -134,6 +144,23 @@ export interface DesktopSockets {
|
|
|
134
144
|
onEvent(listener: (id: number, event: SocketEvent) => void): void;
|
|
135
145
|
}
|
|
136
146
|
|
|
147
|
+
/** What befalls a WebSocket, as player's WebSocketEvents has it. */
|
|
148
|
+
export type WebSocketEvent =
|
|
149
|
+
| { type: "open"; protocol: string }
|
|
150
|
+
| { type: "message"; data: string | Uint8Array }
|
|
151
|
+
| { type: "close"; code: number }
|
|
152
|
+
| { type: "error" };
|
|
153
|
+
|
|
154
|
+
/** air.net.WebSocket's connections, opened by the shell, for an AIR application alone. */
|
|
155
|
+
export interface DesktopWebSockets {
|
|
156
|
+
/** A connection's id; its events follow, an "error" then a "close" if the shell refuses it. */
|
|
157
|
+
connect(url: string, protocols: string[]): number;
|
|
158
|
+
/** A text message for a string, a binary one for bytes. */
|
|
159
|
+
send(id: number, data: string | Uint8Array): void;
|
|
160
|
+
close(id: number, code?: number): void;
|
|
161
|
+
onEvent(listener: (id: number, event: WebSocketEvent) => void): void;
|
|
162
|
+
}
|
|
163
|
+
|
|
137
164
|
/** A request of a SWF's, as player's FetchRequest has it. */
|
|
138
165
|
export interface NetworkRequest {
|
|
139
166
|
url: string;
|
|
@@ -182,8 +209,20 @@ export interface DesktopFiles {
|
|
|
182
209
|
release(id: number): void;
|
|
183
210
|
}
|
|
184
211
|
|
|
212
|
+
/**
|
|
213
|
+
* The window's full screen, which a SWF's Stage.displayState asks for
|
|
214
|
+
* while one the user opened plays, with no gesture, as AIR's and the
|
|
215
|
+
* projector's did.
|
|
216
|
+
*/
|
|
217
|
+
export interface DesktopFullScreen {
|
|
218
|
+
set(fullScreen: boolean): void;
|
|
219
|
+
/** The window has entered or left full screen, a SWF's doing or the user's (F11). */
|
|
220
|
+
onChange(listener: (fullScreen: boolean) => void): void;
|
|
221
|
+
}
|
|
222
|
+
|
|
185
223
|
export interface DesktopApi {
|
|
186
224
|
files: DesktopFiles;
|
|
225
|
+
fullScreen: DesktopFullScreen;
|
|
187
226
|
/** The page has loaded and listens: what it is to show. */
|
|
188
227
|
start(): Promise<StartState>;
|
|
189
228
|
/** The shell opened a SWF: play it in place of what plays. */
|
|
@@ -199,6 +238,7 @@ export interface DesktopApi {
|
|
|
199
238
|
/** Ask the user for a library; the page reloads once the shell has it. */
|
|
200
239
|
chooseLibrary(name: LibraryName): void;
|
|
201
240
|
sockets: DesktopSockets;
|
|
241
|
+
webSockets: DesktopWebSockets;
|
|
202
242
|
network: DesktopNetwork;
|
|
203
243
|
air: DesktopAir;
|
|
204
244
|
}
|