@swf2es/desktop 0.0.0-stage → 0.1.15
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 +135 -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 +1009 -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 +1180 -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,1009 @@
|
|
|
1
|
+
// The desktop app's main process: one window on swf2es://app/, the menu,
|
|
2
|
+
// the files the user opens, the libraries they point at, and the page's
|
|
3
|
+
// few requests (shared/api.ts), each checked to come from that page.
|
|
4
|
+
//
|
|
5
|
+
// electron apps/desktop [--trace] [file.swf | http(s)://host/movie.swf]
|
|
6
|
+
// electron apps/desktop [--trace] app.xml [root-dir] [-- arguments...]
|
|
7
|
+
//
|
|
8
|
+
// --ignore-certificate-errors[=host,...] leaves HTTPS certificates unchecked.
|
|
9
|
+
//
|
|
10
|
+
// The second runs an AIR application by its descriptor, as adl does.
|
|
11
|
+
import { statSync } from "node:fs";
|
|
12
|
+
import { open as openFile, readFile, realpath } from "node:fs/promises";
|
|
13
|
+
import { basename, dirname, isAbsolute, join, resolve } from "node:path";
|
|
14
|
+
import { fileURLToPath } from "node:url";
|
|
15
|
+
import { fileAttributes } from "@swf2es/format";
|
|
16
|
+
import { app, BrowserWindow, dialog, ipcMain, Menu, safeStorage, session, shell, } from "electron";
|
|
17
|
+
import { airCommand, airLaunch, createDirectoryOf, EncryptedItems, listOf, moveOf, readOf, removeOf, statOf, writeOf, } from "./air.js";
|
|
18
|
+
import { bridgeFiles } from "./files.js";
|
|
19
|
+
import { Network, Refused, unverifiedHosts } from "./network.js";
|
|
20
|
+
import { APP_ORIGIN, registerScheme, serve } from "./protocol.js";
|
|
21
|
+
import { Sandbox } from "./sandbox.js";
|
|
22
|
+
import { SettingsFile } from "./settings.js";
|
|
23
|
+
import { bridgeSockets } from "./sockets.js";
|
|
24
|
+
const preload = fileURLToPath(new URL("../preload/preload.cjs", import.meta.url));
|
|
25
|
+
const promptPreload = fileURLToPath(new URL("../preload/prompt.cjs", import.meta.url));
|
|
26
|
+
// The window's and the taskbar's icon, the project's, where the platform takes one from the window.
|
|
27
|
+
const icon = fileURLToPath(new URL("../../static/icon.png", import.meta.url));
|
|
28
|
+
const PROMPT_URL = `${APP_ORIGIN}/open-url.html`;
|
|
29
|
+
const TITLE = "swf2es";
|
|
30
|
+
// The tests keep their settings apart from the user's.
|
|
31
|
+
const userData = process.env.SWF2ES_DESKTOP_USER_DATA;
|
|
32
|
+
if (userData) {
|
|
33
|
+
app.setPath("userData", userData);
|
|
34
|
+
}
|
|
35
|
+
// launch.ts's commit; a test or another launcher gives none.
|
|
36
|
+
const build = /^[\w.+-]{1,40}$/.test(process.env.SWF2ES_DESKTOP_BUILD ?? "")
|
|
37
|
+
? process.env.SWF2ES_DESKTOP_BUILD
|
|
38
|
+
: null;
|
|
39
|
+
registerScheme();
|
|
40
|
+
app.enableSandbox();
|
|
41
|
+
/** --trace: what the page logs, a SWF's trace() among it, goes to the terminal. */
|
|
42
|
+
const trace = process.argv.includes("--trace");
|
|
43
|
+
const sandbox = new Sandbox();
|
|
44
|
+
/** The SWF's http and https requests, judged as Flash's sandboxes had them. */
|
|
45
|
+
/**
|
|
46
|
+
* --ignore-certificate-errors[=host,...]: HTTPS servers whose certificates go
|
|
47
|
+
* unchecked, for one's own with a certificate it signed itself. For this
|
|
48
|
+
* run alone, never kept, and said aloud: anyone on the way to such a host
|
|
49
|
+
* can read and change what the SWF loads from it.
|
|
50
|
+
*/
|
|
51
|
+
const unverified = unverifiedHosts(process.argv.slice(1));
|
|
52
|
+
if (unverified) {
|
|
53
|
+
process.stderr.write(unverified.all
|
|
54
|
+
? "swf2es: --ignore-certificate-errors: no HTTPS server's certificate is verified\n"
|
|
55
|
+
: `swf2es: --ignore-certificate-errors: certificates are not verified for ${unverified.hosts.join(", ") || "no host"}\n`);
|
|
56
|
+
}
|
|
57
|
+
const network = new Network({
|
|
58
|
+
unverified: (host) => !!unverified && (unverified.all || unverified.hosts.includes(host.toLowerCase())),
|
|
59
|
+
});
|
|
60
|
+
/** What the page's requests under way abort with, by the preload's number. */
|
|
61
|
+
const requests = new Map();
|
|
62
|
+
let settings;
|
|
63
|
+
/** What the network judges the movie playing as; null for none, or one without networking. */
|
|
64
|
+
let networkMovie = null;
|
|
65
|
+
let window = null;
|
|
66
|
+
/** The dialog that asks for a URL, while it is open. */
|
|
67
|
+
let prompt = null;
|
|
68
|
+
let current = null;
|
|
69
|
+
/** The servers the SWF playing was allowed, or denied, a socket to until it closes: "host:port". */
|
|
70
|
+
let allowedOnce = new Set();
|
|
71
|
+
let deniedOnce = new Set();
|
|
72
|
+
/** The prompts on screen, by "host:port": a second connection there waits on the first's answer. */
|
|
73
|
+
const asking = new Map();
|
|
74
|
+
/**
|
|
75
|
+
* The page has asked to start and listens for what opens: until then, start() hands it the movie.
|
|
76
|
+
*/
|
|
77
|
+
let listening = false;
|
|
78
|
+
/** The longest URL a SWF is opened by. */
|
|
79
|
+
const MAX_URL = 8192;
|
|
80
|
+
/** The AIR application playing, as its command line asked for it; null for none. */
|
|
81
|
+
let air = null;
|
|
82
|
+
/** The EncryptedLocalStore of the AIR application playing, if one is. */
|
|
83
|
+
let encryptedItems = null;
|
|
84
|
+
/**
|
|
85
|
+
* `text` as the URL of a remote SWF: http or https, with a host and no
|
|
86
|
+
* credentials, its fragment dropped; null if it is not one.
|
|
87
|
+
*/
|
|
88
|
+
function remoteUrl(text) {
|
|
89
|
+
if (text.length > MAX_URL) {
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
let url;
|
|
93
|
+
try {
|
|
94
|
+
url = new URL(text.trim());
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
if ((url.protocol !== "http:" && url.protocol !== "https:") || !url.hostname) {
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
if (url.username || url.password) {
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
url.hash = "";
|
|
106
|
+
return url;
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* The SWF named on a command line, from `cwd`: the last argument after the
|
|
110
|
+
* app's own that is an http or https URL or names a .swf file there is.
|
|
111
|
+
* Chromium's and Electron's switches may take values of their own
|
|
112
|
+
* (`--switch value`), so the first argument that is not a switch could be one.
|
|
113
|
+
*/
|
|
114
|
+
function movieFromArguments(argv, cwd = process.cwd()) {
|
|
115
|
+
const named = argv
|
|
116
|
+
.slice(process.defaultApp ? 2 : 1)
|
|
117
|
+
.filter((a) => !a.startsWith("-"))
|
|
118
|
+
.map((a) => remoteUrl(a)?.href ?? (/\.swf$/i.test(a) ? resolve(cwd, a) : null))
|
|
119
|
+
.filter((a) => a !== null);
|
|
120
|
+
return (named.reverse().find((a) => remoteUrl(a) || statSync(a, { throwIfNoEntry: false })?.isFile()) ??
|
|
121
|
+
null);
|
|
122
|
+
}
|
|
123
|
+
/** Whether a message comes from the window's own page, on swf2es://app/. */
|
|
124
|
+
function trusted(event) {
|
|
125
|
+
const frame = event.senderFrame;
|
|
126
|
+
return (window !== null &&
|
|
127
|
+
event.sender === window.webContents &&
|
|
128
|
+
frame !== null &&
|
|
129
|
+
frame.parent === null &&
|
|
130
|
+
// Node's URL gives a scheme it does not know no origin: compare the text.
|
|
131
|
+
frame.url.startsWith(`${APP_ORIGIN}/`));
|
|
132
|
+
}
|
|
133
|
+
/** How long after a click or a key a SWF may open a page: Chromium's transient activation. */
|
|
134
|
+
const GESTURE_MS = 5000;
|
|
135
|
+
/** The least time between two pages opened, whatever the gestures. */
|
|
136
|
+
const OPEN_INTERVAL_MS = 1000;
|
|
137
|
+
const GESTURES = new Set(["mouseDown", "rawKeyDown", "keyDown", "touchEnd", "gestureTap"]);
|
|
138
|
+
/** When the user last clicked or pressed a key in the window, and when a page last opened. */
|
|
139
|
+
let gestureAt = Number.NEGATIVE_INFINITY;
|
|
140
|
+
let openedAt = Number.NEGATIVE_INFINITY;
|
|
141
|
+
/**
|
|
142
|
+
* An http(s) URL in the system's browser, never in the app, only for a SWF
|
|
143
|
+
* with the network (local-with-networking or remote), and only as a click
|
|
144
|
+
* or a key asked: one page a gesture, a second apart at least, as a
|
|
145
|
+
* browser's popup blocker allows, so a SWF cannot launch the browser in a
|
|
146
|
+
* loop. Anything else goes nowhere.
|
|
147
|
+
*/
|
|
148
|
+
function openExternal(url) {
|
|
149
|
+
let protocol;
|
|
150
|
+
try {
|
|
151
|
+
protocol = new URL(url).protocol;
|
|
152
|
+
}
|
|
153
|
+
catch {
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
const now = performance.now();
|
|
157
|
+
if (protocol !== "https:" && protocol !== "http:") {
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
// Flash refused a local-with-filesystem SWF the network, navigateToURL
|
|
161
|
+
// among it: a page opened with what it read in its URL would send it away.
|
|
162
|
+
if (!sandbox.networkAllowed()) {
|
|
163
|
+
process.stderr.write(`swf2es: not opening ${url}: the SWF has no network\n`);
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
if (now - gestureAt > GESTURE_MS || now - openedAt < OPEN_INTERVAL_MS) {
|
|
167
|
+
process.stderr.write(`swf2es: not opening ${url}: no click or key asked for it\n`);
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
gestureAt = Number.NEGATIVE_INFINITY;
|
|
171
|
+
openedAt = now;
|
|
172
|
+
void shell.openExternal(url);
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* How much of a SWF the main process reads to choose its sandbox: far more than FileAttributes
|
|
176
|
+
* takes compressed.
|
|
177
|
+
*/
|
|
178
|
+
const START = 64 * 1024;
|
|
179
|
+
const USE_NETWORK = 0x01;
|
|
180
|
+
/**
|
|
181
|
+
* The first `length` bytes of the file at `path`, or as many as it has, at least `least`; null
|
|
182
|
+
* otherwise.
|
|
183
|
+
*/
|
|
184
|
+
async function readStart(path, length, least = 8) {
|
|
185
|
+
try {
|
|
186
|
+
const file = await openFile(path);
|
|
187
|
+
try {
|
|
188
|
+
const bytes = new Uint8Array(length);
|
|
189
|
+
const { bytesRead } = await file.read(bytes, 0, length, 0);
|
|
190
|
+
return bytesRead >= Math.min(least, length) ? bytes.subarray(0, bytesRead) : null;
|
|
191
|
+
}
|
|
192
|
+
finally {
|
|
193
|
+
await file.close();
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
catch {
|
|
197
|
+
return null;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
function refuse(message, detail) {
|
|
201
|
+
if (window) {
|
|
202
|
+
void dialog.showMessageBox(window, { type: "error", message, detail });
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
/** Open what the recent list, the command line or a reload names: a URL, a SWF's path or a descriptor's. */
|
|
206
|
+
function openEntry(entry) {
|
|
207
|
+
if (remoteUrl(entry)) {
|
|
208
|
+
return openUrl(entry);
|
|
209
|
+
}
|
|
210
|
+
// A descriptor reopened from the recent list or a reload, with the arguments it was run with.
|
|
211
|
+
if (/\.xml$/i.test(entry)) {
|
|
212
|
+
return openAir(air?.descriptor === entry ? air : { descriptor: entry, root: null, arguments: [] }, process.cwd());
|
|
213
|
+
}
|
|
214
|
+
return open(entry);
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* Run the AIR application `command` names, as adl does: its content SWF in
|
|
218
|
+
* the window, at the descriptor's size, in AIR's application sandbox, with
|
|
219
|
+
* its launch (shared/api.ts AirLaunch), from `cwd`.
|
|
220
|
+
*/
|
|
221
|
+
async function openAir(command, cwd) {
|
|
222
|
+
const name = basename(command.descriptor);
|
|
223
|
+
let opened;
|
|
224
|
+
try {
|
|
225
|
+
opened = airLaunch(command, cwd, app.getPath("userData"), {
|
|
226
|
+
documents: app.getPath("documents"),
|
|
227
|
+
desktop: app.getPath("desktop"),
|
|
228
|
+
cache: app.getPath("temp"),
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
catch (error) {
|
|
232
|
+
refuse(`${name} could not be opened.`, String(error));
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
const head = await readStart(opened.content, START);
|
|
236
|
+
const signature = head ? String.fromCharCode(...head.subarray(0, 3)) : "";
|
|
237
|
+
if (!head || (signature !== "FWS" && signature !== "CWS" && signature !== "ZWS")) {
|
|
238
|
+
refuse(`${name}'s content is not a SWF.`, opened.content);
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
const content = await realpath(opened.content);
|
|
242
|
+
sandbox.playApplication(content);
|
|
243
|
+
air = command;
|
|
244
|
+
// Beside its storage directory, by its id, as AIR keeps it; encrypted by the system's keychain where it has one.
|
|
245
|
+
encryptedItems = new EncryptedItems(join(dirname(opened.launch.directories.applicationStorage), "EncryptedLocalStore"), safeStorage.isEncryptionAvailable()
|
|
246
|
+
? {
|
|
247
|
+
encrypt: (text) => new Uint8Array(safeStorage.encryptString(text)),
|
|
248
|
+
decrypt: (data) => safeStorage.decryptString(Buffer.from(data)),
|
|
249
|
+
}
|
|
250
|
+
: null);
|
|
251
|
+
if (window && opened.width && opened.height) {
|
|
252
|
+
window.setSize(opened.width, opened.height);
|
|
253
|
+
}
|
|
254
|
+
// Played from app:/, as AIR plays it: its relative URLs, and loaderInfo.url, are the application's.
|
|
255
|
+
const url = `app:/${opened.relative
|
|
256
|
+
.split("/")
|
|
257
|
+
.filter((s) => s !== "")
|
|
258
|
+
.map(encodeURIComponent)
|
|
259
|
+
.join("/")}`;
|
|
260
|
+
show(command.descriptor, { url, name: opened.title, sandbox: "application", air: opened.launch }, { kind: "application" });
|
|
261
|
+
}
|
|
262
|
+
/** Play `movie` in the window, in place of what plays: what the user opened, as `entry`. */
|
|
263
|
+
function show(entry, movie, onNetwork) {
|
|
264
|
+
current = { path: entry, movie };
|
|
265
|
+
playOnNetwork(onNetwork);
|
|
266
|
+
allowedOnce = new Set();
|
|
267
|
+
deniedOnce = new Set();
|
|
268
|
+
settings.opened(entry);
|
|
269
|
+
buildMenu();
|
|
270
|
+
if (window) {
|
|
271
|
+
// An AIR application's window is its own: its title alone, as adl shows it.
|
|
272
|
+
window.setTitle(movie.air ? movie.name : `${movie.name} — ${TITLE}`);
|
|
273
|
+
if (listening) {
|
|
274
|
+
window.webContents.send("desktop:open", movie);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
/**
|
|
279
|
+
* Play the SWF at `text`, an http or https URL, in Flash's remote sandbox:
|
|
280
|
+
* no local file, and the network as its origin's (network.ts). The page
|
|
281
|
+
* fetches it through the main process like anything it loads.
|
|
282
|
+
*/
|
|
283
|
+
async function openUrl(text) {
|
|
284
|
+
const url = remoteUrl(text);
|
|
285
|
+
if (!url) {
|
|
286
|
+
refuse("Only an http or https URL opens.", text.slice(0, 200));
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
const last = url.pathname.split("/").pop() ?? "";
|
|
290
|
+
let name;
|
|
291
|
+
try {
|
|
292
|
+
name = decodeURIComponent(last) || url.host;
|
|
293
|
+
}
|
|
294
|
+
catch {
|
|
295
|
+
name = last;
|
|
296
|
+
}
|
|
297
|
+
air = null;
|
|
298
|
+
sandbox.playRemote(url.href);
|
|
299
|
+
show(url.href, { url: url.href, name, sandbox: "remote" }, { kind: "remote", url: url.href });
|
|
300
|
+
}
|
|
301
|
+
/**
|
|
302
|
+
* Open the file the user chose in the dialog, dropped on the window or
|
|
303
|
+
* opened with the app: an AIR application's descriptor (`.xml`) runs the
|
|
304
|
+
* application, anything else is played as a SWF.
|
|
305
|
+
*/
|
|
306
|
+
async function openChosen(path) {
|
|
307
|
+
if (!/\.xml$/i.test(path)) {
|
|
308
|
+
return open(path);
|
|
309
|
+
}
|
|
310
|
+
// An application has an installed program's reach, which a file chosen in
|
|
311
|
+
// passing should not get unasked: asked once, until it leaves the recent list.
|
|
312
|
+
if (window && !settings.settings.recent.includes(path)) {
|
|
313
|
+
const { response } = await dialog.showMessageBox(window, {
|
|
314
|
+
type: "question",
|
|
315
|
+
message: `Run ${basename(path)} as an AIR application?`,
|
|
316
|
+
detail: "An AIR application reads and writes your files and reaches the network unasked, as an installed program does. Run only one you trust.",
|
|
317
|
+
buttons: ["Run", "Cancel"],
|
|
318
|
+
defaultId: 1,
|
|
319
|
+
cancelId: 1,
|
|
320
|
+
});
|
|
321
|
+
if (response !== 0) {
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
// Run from its own directory, with no arguments, as one started by its icon is.
|
|
326
|
+
return openAir({ descriptor: path, root: null, arguments: [] }, dirname(path));
|
|
327
|
+
}
|
|
328
|
+
/** Play the SWF at `path` in the window, in place of what plays, in its sandbox. */
|
|
329
|
+
async function open(path) {
|
|
330
|
+
const name = basename(path);
|
|
331
|
+
// Its start alone: the main process never decompresses a SWF whole, whose
|
|
332
|
+
// header may name 4 GB. FileAttributes, the first tag, lies in it.
|
|
333
|
+
const head = await readStart(path, START);
|
|
334
|
+
const signature = head ? String.fromCharCode(...head.subarray(0, 3)) : "";
|
|
335
|
+
if (!head || (signature !== "FWS" && signature !== "CWS" && signature !== "ZWS")) {
|
|
336
|
+
refuse(`${name} is not a SWF.`, path);
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
const useNetwork = (fileAttributes(head) & USE_NETWORK) !== 0;
|
|
340
|
+
let url;
|
|
341
|
+
try {
|
|
342
|
+
air = null;
|
|
343
|
+
url = sandbox.play(path, useNetwork);
|
|
344
|
+
}
|
|
345
|
+
catch (error) {
|
|
346
|
+
// Gone, or unreadable, since it was read.
|
|
347
|
+
refuse(`${name} could not be opened.`, String(error));
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
app.addRecentDocument(path);
|
|
351
|
+
show(path, { url, name, sandbox: useNetwork ? "localWithNetwork" : "localWithFile" }, useNetwork ? { kind: "local" } : null);
|
|
352
|
+
}
|
|
353
|
+
/** Judge requests for `movie` from now on: the last one's are aborted. */
|
|
354
|
+
function playOnNetwork(movie) {
|
|
355
|
+
networkMovie = movie;
|
|
356
|
+
network.play(movie);
|
|
357
|
+
}
|
|
358
|
+
function closeMovie() {
|
|
359
|
+
current = null;
|
|
360
|
+
air = null;
|
|
361
|
+
sandbox.stop();
|
|
362
|
+
playOnNetwork(null);
|
|
363
|
+
buildMenu();
|
|
364
|
+
window?.setTitle(TITLE);
|
|
365
|
+
if (listening) {
|
|
366
|
+
window?.webContents.send("desktop:close");
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
/**
|
|
370
|
+
* Whether the SWF playing may connect a socket to `host`:`port`, and where
|
|
371
|
+
* to: only with the network, and only as the user allows, once or always
|
|
372
|
+
* for that SWF, when it first asks for that server; and for a remote SWF,
|
|
373
|
+
* as that server's socket policy allows, at the address it came from.
|
|
374
|
+
* Denied where no one can be asked.
|
|
375
|
+
*/
|
|
376
|
+
async function permitSocket(host, port) {
|
|
377
|
+
// AIR's application sandbox connects where it likes, unasked, with no socket policy.
|
|
378
|
+
if (sandbox.application) {
|
|
379
|
+
return {};
|
|
380
|
+
}
|
|
381
|
+
const playing = current;
|
|
382
|
+
const swf = sandbox.swf;
|
|
383
|
+
const endpoint = `${host}:${port}`;
|
|
384
|
+
if (!playing || !swf || !sandbox.networkAllowed()) {
|
|
385
|
+
process.stderr.write(`swf2es: no socket to ${endpoint}: the SWF has no network\n`);
|
|
386
|
+
return null;
|
|
387
|
+
}
|
|
388
|
+
if (!(await allowedByUser(playing, swf, endpoint))) {
|
|
389
|
+
return null;
|
|
390
|
+
}
|
|
391
|
+
if (networkMovie?.kind !== "remote") {
|
|
392
|
+
return {};
|
|
393
|
+
}
|
|
394
|
+
try {
|
|
395
|
+
return { address: await network.socketAddress(host, port) };
|
|
396
|
+
}
|
|
397
|
+
catch (error) {
|
|
398
|
+
process.stderr.write(`swf2es: no socket to ${endpoint}: ${error.message}\n`);
|
|
399
|
+
return null;
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
/** Whether the user allows the SWF playing a socket to `endpoint`, asked once a server. */
|
|
403
|
+
async function allowedByUser(playing, swf, endpoint) {
|
|
404
|
+
if (settings.socketAllowed(swf, endpoint) || allowedOnce.has(endpoint)) {
|
|
405
|
+
return true;
|
|
406
|
+
}
|
|
407
|
+
if (deniedOnce.has(endpoint)) {
|
|
408
|
+
return false;
|
|
409
|
+
}
|
|
410
|
+
let answer = asking.get(endpoint);
|
|
411
|
+
if (!answer) {
|
|
412
|
+
answer = askForSocket(playing.movie.name, swf, endpoint).finally(() => asking.delete(endpoint));
|
|
413
|
+
asking.set(endpoint, answer);
|
|
414
|
+
}
|
|
415
|
+
return (await answer) && current === playing;
|
|
416
|
+
}
|
|
417
|
+
async function askForSocket(name, swf, endpoint) {
|
|
418
|
+
if (!window) {
|
|
419
|
+
return false;
|
|
420
|
+
}
|
|
421
|
+
const playing = current;
|
|
422
|
+
let response;
|
|
423
|
+
try {
|
|
424
|
+
({ response } = await dialog.showMessageBox(window, {
|
|
425
|
+
type: "question",
|
|
426
|
+
message: `${name} asks to connect to ${endpoint}.`,
|
|
427
|
+
detail: "A socket lets the SWF send that server whatever it has. " +
|
|
428
|
+
"Allow it only for a server you trust.",
|
|
429
|
+
buttons: ["Allow Once", "Always Allow for This SWF", "Deny"],
|
|
430
|
+
defaultId: 2,
|
|
431
|
+
cancelId: 2,
|
|
432
|
+
noLink: true,
|
|
433
|
+
}));
|
|
434
|
+
}
|
|
435
|
+
catch {
|
|
436
|
+
return false;
|
|
437
|
+
}
|
|
438
|
+
if (current !== playing) {
|
|
439
|
+
return false;
|
|
440
|
+
}
|
|
441
|
+
switch (response) {
|
|
442
|
+
case 0:
|
|
443
|
+
allowedOnce.add(endpoint);
|
|
444
|
+
return true;
|
|
445
|
+
case 1:
|
|
446
|
+
if (!settings.allowSocket(swf, endpoint)) {
|
|
447
|
+
allowedOnce.add(endpoint);
|
|
448
|
+
}
|
|
449
|
+
return true;
|
|
450
|
+
default:
|
|
451
|
+
deniedOnce.add(endpoint);
|
|
452
|
+
return false;
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
async function showOpenDialog() {
|
|
456
|
+
if (!window) {
|
|
457
|
+
return;
|
|
458
|
+
}
|
|
459
|
+
const result = await dialog.showOpenDialog(window, {
|
|
460
|
+
title: "Open a SWF or an AIR application",
|
|
461
|
+
properties: ["openFile"],
|
|
462
|
+
filters: [
|
|
463
|
+
{ name: "SWF and AIR application descriptors", extensions: ["swf", "xml"] },
|
|
464
|
+
{ name: "All files", extensions: ["*"] },
|
|
465
|
+
],
|
|
466
|
+
});
|
|
467
|
+
if (!result.canceled && result.filePaths[0]) {
|
|
468
|
+
await openChosen(result.filePaths[0]);
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
/** Ask for a library, check that it is ABC, keep its path, and start the page again with it. */
|
|
472
|
+
async function chooseLibrary(name) {
|
|
473
|
+
if (!window) {
|
|
474
|
+
return;
|
|
475
|
+
}
|
|
476
|
+
const result = await dialog.showOpenDialog(window, {
|
|
477
|
+
title: `Choose ${name}.abc`,
|
|
478
|
+
properties: ["openFile"],
|
|
479
|
+
filters: [
|
|
480
|
+
{ name: "ActionScript bytecode", extensions: ["abc"] },
|
|
481
|
+
{ name: "All files", extensions: ["*"] },
|
|
482
|
+
],
|
|
483
|
+
});
|
|
484
|
+
const path = result.filePaths[0];
|
|
485
|
+
if (result.canceled || !path) {
|
|
486
|
+
return;
|
|
487
|
+
}
|
|
488
|
+
// An ABC starts with its minor version, then its major one, 46.
|
|
489
|
+
const head = await readStart(path, 4);
|
|
490
|
+
if (head?.[2] !== 46 || head[3] !== 0) {
|
|
491
|
+
refuse(`${basename(path)} is not an ABC file.`, `swf2es needs ${name}.abc itself.`);
|
|
492
|
+
return;
|
|
493
|
+
}
|
|
494
|
+
if (!settings.setLibrary(name, path)) {
|
|
495
|
+
refuse("The settings could not be saved.", `swf2es uses ${path} until it quits.`);
|
|
496
|
+
}
|
|
497
|
+
// The page fetched the libraries once; a fresh page fetches the new one.
|
|
498
|
+
window.webContents.reload();
|
|
499
|
+
}
|
|
500
|
+
function buildMenu() {
|
|
501
|
+
const recent = settings.settings.recent;
|
|
502
|
+
const template = [
|
|
503
|
+
...(process.platform === "darwin" ? [{ role: "appMenu" }] : []),
|
|
504
|
+
{
|
|
505
|
+
label: "&File",
|
|
506
|
+
submenu: [
|
|
507
|
+
{ label: "&Open…", accelerator: "CmdOrCtrl+O", click: () => void showOpenDialog() },
|
|
508
|
+
{ label: "Open &URL…", accelerator: "CmdOrCtrl+L", click: showUrlDialog },
|
|
509
|
+
{
|
|
510
|
+
label: "Open &Recent",
|
|
511
|
+
submenu: [
|
|
512
|
+
...recent.map((entry) => ({ label: entry, click: () => void openEntry(entry) })),
|
|
513
|
+
...(recent.length > 0 ? [{ type: "separator" }] : []),
|
|
514
|
+
{
|
|
515
|
+
label: "Clear Recent",
|
|
516
|
+
enabled: recent.length > 0,
|
|
517
|
+
click: () => {
|
|
518
|
+
settings.clearRecent();
|
|
519
|
+
app.clearRecentDocuments();
|
|
520
|
+
buildMenu();
|
|
521
|
+
},
|
|
522
|
+
},
|
|
523
|
+
],
|
|
524
|
+
},
|
|
525
|
+
{
|
|
526
|
+
id: "close-movie",
|
|
527
|
+
label: "&Close Movie",
|
|
528
|
+
accelerator: "CmdOrCtrl+W",
|
|
529
|
+
enabled: !!current,
|
|
530
|
+
click: closeMovie,
|
|
531
|
+
},
|
|
532
|
+
{ type: "separator" },
|
|
533
|
+
{ label: "Choose playerglobal.abc…", click: () => void chooseLibrary("playerglobal") },
|
|
534
|
+
{ type: "separator" },
|
|
535
|
+
{ role: "quit" },
|
|
536
|
+
],
|
|
537
|
+
},
|
|
538
|
+
{
|
|
539
|
+
label: "&View",
|
|
540
|
+
submenu: [
|
|
541
|
+
{
|
|
542
|
+
id: "reload-movie",
|
|
543
|
+
label: "&Reload Movie",
|
|
544
|
+
accelerator: "CmdOrCtrl+R",
|
|
545
|
+
enabled: !!current,
|
|
546
|
+
click: () => current && void openEntry(current.path),
|
|
547
|
+
},
|
|
548
|
+
{ role: "togglefullscreen", accelerator: "F11" },
|
|
549
|
+
{ type: "separator" },
|
|
550
|
+
{
|
|
551
|
+
id: "stage3d",
|
|
552
|
+
label: "Stage3D (Experimental)",
|
|
553
|
+
type: "checkbox",
|
|
554
|
+
checked: settings.settings.stage3D,
|
|
555
|
+
click: (item) => {
|
|
556
|
+
if (!settings.setStage3D(item.checked)) {
|
|
557
|
+
refuse("The settings could not be saved.", "The Stage3D choice holds until you quit.");
|
|
558
|
+
}
|
|
559
|
+
window?.webContents.reload();
|
|
560
|
+
},
|
|
561
|
+
},
|
|
562
|
+
{ type: "separator" },
|
|
563
|
+
{ role: "toggleDevTools" },
|
|
564
|
+
],
|
|
565
|
+
},
|
|
566
|
+
];
|
|
567
|
+
Menu.setApplicationMenu(Menu.buildFromTemplate(template));
|
|
568
|
+
}
|
|
569
|
+
/**
|
|
570
|
+
* The window's page may go nowhere but swf2es://app/, and open nothing but the system's browser.
|
|
571
|
+
*/
|
|
572
|
+
function guard(contents) {
|
|
573
|
+
contents.on("will-navigate", (event) => {
|
|
574
|
+
event.preventDefault();
|
|
575
|
+
openExternal(event.url);
|
|
576
|
+
});
|
|
577
|
+
contents.on("input-event", (_event, input) => {
|
|
578
|
+
if (GESTURES.has(input.type)) {
|
|
579
|
+
gestureAt = performance.now();
|
|
580
|
+
}
|
|
581
|
+
});
|
|
582
|
+
contents.setWindowOpenHandler(({ url }) => {
|
|
583
|
+
openExternal(url);
|
|
584
|
+
return { action: "deny" };
|
|
585
|
+
});
|
|
586
|
+
contents.on("did-start-navigation", (details) => {
|
|
587
|
+
if (details.isMainFrame && !details.isSameDocument) {
|
|
588
|
+
listening = false;
|
|
589
|
+
// The page's requests go with it; the next page asks for the movie again.
|
|
590
|
+
network.play(networkMovie);
|
|
591
|
+
requests.clear();
|
|
592
|
+
}
|
|
593
|
+
});
|
|
594
|
+
if (trace) {
|
|
595
|
+
contents.on("console-message", ({ level, message }) => {
|
|
596
|
+
// A SWF's trace() is console.log's; warnings and errors are the page's own.
|
|
597
|
+
(level === "info" ? process.stdout : process.stderr).write(`${message}\n`);
|
|
598
|
+
});
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
/**
|
|
602
|
+
* The dialog that asks for a SWF's URL: a window of its own, apart from the
|
|
603
|
+
* page, so that only the user, and never a SWF, opens one by its URL.
|
|
604
|
+
*/
|
|
605
|
+
function showUrlDialog() {
|
|
606
|
+
if (!window) {
|
|
607
|
+
return;
|
|
608
|
+
}
|
|
609
|
+
if (prompt) {
|
|
610
|
+
prompt.focus();
|
|
611
|
+
return;
|
|
612
|
+
}
|
|
613
|
+
const dialogWindow = new BrowserWindow({
|
|
614
|
+
parent: window,
|
|
615
|
+
modal: true,
|
|
616
|
+
width: 560,
|
|
617
|
+
height: 180,
|
|
618
|
+
resizable: false,
|
|
619
|
+
minimizable: false,
|
|
620
|
+
maximizable: false,
|
|
621
|
+
fullscreenable: false,
|
|
622
|
+
title: "Open URL",
|
|
623
|
+
show: false,
|
|
624
|
+
webPreferences: {
|
|
625
|
+
preload: promptPreload,
|
|
626
|
+
contextIsolation: true,
|
|
627
|
+
sandbox: true,
|
|
628
|
+
nodeIntegration: false,
|
|
629
|
+
webSecurity: true,
|
|
630
|
+
spellcheck: false,
|
|
631
|
+
},
|
|
632
|
+
});
|
|
633
|
+
prompt = dialogWindow;
|
|
634
|
+
dialogWindow.setMenu(null);
|
|
635
|
+
dialogWindow.on("page-title-updated", (event) => event.preventDefault());
|
|
636
|
+
dialogWindow.once("ready-to-show", () => dialogWindow.show());
|
|
637
|
+
dialogWindow.on("closed", () => {
|
|
638
|
+
if (prompt === dialogWindow) {
|
|
639
|
+
prompt = null;
|
|
640
|
+
}
|
|
641
|
+
});
|
|
642
|
+
const contents = dialogWindow.webContents;
|
|
643
|
+
contents.on("will-navigate", (event) => event.preventDefault());
|
|
644
|
+
contents.setWindowOpenHandler(() => ({ action: "deny" }));
|
|
645
|
+
void dialogWindow.loadURL(PROMPT_URL);
|
|
646
|
+
}
|
|
647
|
+
/** Whether a message comes from the URL dialog's own page. */
|
|
648
|
+
function fromPrompt(event) {
|
|
649
|
+
const frame = event.senderFrame;
|
|
650
|
+
return (prompt !== null &&
|
|
651
|
+
event.sender === prompt.webContents &&
|
|
652
|
+
frame !== null &&
|
|
653
|
+
frame.parent === null &&
|
|
654
|
+
frame.url === PROMPT_URL);
|
|
655
|
+
}
|
|
656
|
+
function createWindow() {
|
|
657
|
+
window = new BrowserWindow({
|
|
658
|
+
width: 1024,
|
|
659
|
+
height: 768,
|
|
660
|
+
title: TITLE,
|
|
661
|
+
icon,
|
|
662
|
+
webPreferences: {
|
|
663
|
+
preload,
|
|
664
|
+
contextIsolation: true,
|
|
665
|
+
sandbox: true,
|
|
666
|
+
nodeIntegration: false,
|
|
667
|
+
webSecurity: true,
|
|
668
|
+
spellcheck: false,
|
|
669
|
+
},
|
|
670
|
+
});
|
|
671
|
+
// The title is the movie's, set here, not the page's <title>.
|
|
672
|
+
window.on("page-title-updated", (event) => event.preventDefault());
|
|
673
|
+
window.on("closed", () => {
|
|
674
|
+
window = null;
|
|
675
|
+
});
|
|
676
|
+
guard(window.webContents);
|
|
677
|
+
void window.loadURL(`${APP_ORIGIN}/`);
|
|
678
|
+
}
|
|
679
|
+
function listen() {
|
|
680
|
+
bridgeFiles(trusted, () => window, () => {
|
|
681
|
+
if (performance.now() - gestureAt > GESTURE_MS) {
|
|
682
|
+
return false;
|
|
683
|
+
}
|
|
684
|
+
gestureAt = Number.NEGATIVE_INFINITY;
|
|
685
|
+
return true;
|
|
686
|
+
}, { ipcMain, dialog });
|
|
687
|
+
ipcMain.handle("desktop:start", (event) => {
|
|
688
|
+
if (!trusted(event)) {
|
|
689
|
+
throw new Error("not the app's page");
|
|
690
|
+
}
|
|
691
|
+
listening = true;
|
|
692
|
+
return {
|
|
693
|
+
stage3D: settings.settings.stage3D,
|
|
694
|
+
libraries: settings.libraries(),
|
|
695
|
+
movie: current?.movie ?? null,
|
|
696
|
+
build,
|
|
697
|
+
};
|
|
698
|
+
});
|
|
699
|
+
ipcMain.on("desktop:open-dialog", (event) => {
|
|
700
|
+
if (trusted(event)) {
|
|
701
|
+
void showOpenDialog();
|
|
702
|
+
}
|
|
703
|
+
});
|
|
704
|
+
ipcMain.on("desktop:open-url-dialog", (event) => {
|
|
705
|
+
if (trusted(event)) {
|
|
706
|
+
showUrlDialog();
|
|
707
|
+
}
|
|
708
|
+
});
|
|
709
|
+
ipcMain.handle("prompt:initial", (event) => {
|
|
710
|
+
if (!fromPrompt(event)) {
|
|
711
|
+
throw new Error("not the URL dialog");
|
|
712
|
+
}
|
|
713
|
+
// What plays, if it came by a URL, for the user to change.
|
|
714
|
+
return current && remoteUrl(current.path) ? current.path : "";
|
|
715
|
+
});
|
|
716
|
+
ipcMain.on("prompt:answer", (event, text) => {
|
|
717
|
+
if (!fromPrompt(event)) {
|
|
718
|
+
return;
|
|
719
|
+
}
|
|
720
|
+
prompt?.close();
|
|
721
|
+
if (typeof text === "string" && text.trim() !== "") {
|
|
722
|
+
void openUrl(text);
|
|
723
|
+
}
|
|
724
|
+
});
|
|
725
|
+
ipcMain.on("desktop:open-path", (event, path) => {
|
|
726
|
+
if (trusted(event) && typeof path === "string" && isAbsolute(path)) {
|
|
727
|
+
void openChosen(path);
|
|
728
|
+
}
|
|
729
|
+
});
|
|
730
|
+
ipcMain.on("desktop:choose-library", (event, name) => {
|
|
731
|
+
if (trusted(event) && name === "playerglobal") {
|
|
732
|
+
void chooseLibrary(name);
|
|
733
|
+
}
|
|
734
|
+
});
|
|
735
|
+
bridgeSockets(trusted, permitSocket);
|
|
736
|
+
ipcMain.handle("net:fetch", (event, id, request) => {
|
|
737
|
+
if (!trusted(event) || typeof id !== "number") {
|
|
738
|
+
throw new Error("not the app's page");
|
|
739
|
+
}
|
|
740
|
+
// The response goes as a message of its own, copied as it is sent, so
|
|
741
|
+
// that its bytes count against the movie's budget until then; the answer
|
|
742
|
+
// says whether it went. A refusal is an answer, not an error, which
|
|
743
|
+
// Electron would print as one.
|
|
744
|
+
const contents = event.sender;
|
|
745
|
+
const deliver = (response) => {
|
|
746
|
+
if (!contents.isDestroyed()) {
|
|
747
|
+
contents.send("net:response", id, response);
|
|
748
|
+
}
|
|
749
|
+
};
|
|
750
|
+
return fetchForPage(id, request, deliver).then(() => ({ delivered: true }), () => ({ delivered: false }));
|
|
751
|
+
});
|
|
752
|
+
ipcMain.on("net:abort", (event, id) => {
|
|
753
|
+
if (trusted(event) && typeof id === "number") {
|
|
754
|
+
requests.get(id)?.abort();
|
|
755
|
+
}
|
|
756
|
+
});
|
|
757
|
+
ipcMain.on("net:policy-file", (event, url) => {
|
|
758
|
+
if (trusted(event) && sandbox.networkAllowed()) {
|
|
759
|
+
network.addPolicyFile(url);
|
|
760
|
+
}
|
|
761
|
+
});
|
|
762
|
+
listenForAir();
|
|
763
|
+
}
|
|
764
|
+
/** The window as an AIR application's NativeWindow reads it. */
|
|
765
|
+
function windowState(w) {
|
|
766
|
+
const bounds = w.getBounds();
|
|
767
|
+
const content = w.getContentBounds();
|
|
768
|
+
return {
|
|
769
|
+
bounds,
|
|
770
|
+
contentOffset: { x: content.x - bounds.x, y: content.y - bounds.y },
|
|
771
|
+
title: w.getTitle(),
|
|
772
|
+
displayState: w.isMinimized() ? "minimized" : w.isMaximized() ? "maximized" : "normal",
|
|
773
|
+
active: w.isFocused(),
|
|
774
|
+
visible: w.isVisible(),
|
|
775
|
+
};
|
|
776
|
+
}
|
|
777
|
+
/**
|
|
778
|
+
* An AIR application's reach, shared/api.ts DesktopAir: the file system
|
|
779
|
+
* and the window, only while one the user opened plays and only for its
|
|
780
|
+
* page. The file system is read synchronously where File reads it so.
|
|
781
|
+
*/
|
|
782
|
+
function listenForAir() {
|
|
783
|
+
const playing = (event) => trusted(event) && sandbox.application;
|
|
784
|
+
ipcMain.on("air:stat", (event, path) => {
|
|
785
|
+
event.returnValue = playing(event) && typeof path === "string" ? statOf(path) : null;
|
|
786
|
+
});
|
|
787
|
+
ipcMain.on("air:list", (event, path) => {
|
|
788
|
+
event.returnValue = playing(event) && typeof path === "string" ? listOf(path) : null;
|
|
789
|
+
});
|
|
790
|
+
// FileStream's open, which reads a file at once, as stat does: only once the application plays.
|
|
791
|
+
ipcMain.on("air:read-sync", (event, path) => {
|
|
792
|
+
event.returnValue = playing(event) && typeof path === "string" ? readOf(path) : null;
|
|
793
|
+
});
|
|
794
|
+
// EncryptedLocalStore, at once as its methods are: only the application playing's.
|
|
795
|
+
const items = (event) => (playing(event) ? encryptedItems : null);
|
|
796
|
+
ipcMain.on("air:item-get", (event, name) => {
|
|
797
|
+
event.returnValue = typeof name === "string" ? (items(event)?.get(name) ?? null) : null;
|
|
798
|
+
});
|
|
799
|
+
ipcMain.on("air:item-set", (event, name, bytes) => {
|
|
800
|
+
if (typeof name === "string" && bytes instanceof Uint8Array) {
|
|
801
|
+
items(event)?.set(name, bytes);
|
|
802
|
+
}
|
|
803
|
+
event.returnValue = null;
|
|
804
|
+
});
|
|
805
|
+
ipcMain.on("air:item-remove", (event, name) => {
|
|
806
|
+
if (typeof name === "string") {
|
|
807
|
+
items(event)?.remove(name);
|
|
808
|
+
}
|
|
809
|
+
event.returnValue = null;
|
|
810
|
+
});
|
|
811
|
+
ipcMain.on("air:items-reset", (event) => {
|
|
812
|
+
items(event)?.reset();
|
|
813
|
+
event.returnValue = null;
|
|
814
|
+
});
|
|
815
|
+
// FileStream's writes and File's changes, at once as adl's: only once the application plays.
|
|
816
|
+
ipcMain.on("air:write-sync", (event, path, bytes, offset, length) => {
|
|
817
|
+
event.returnValue =
|
|
818
|
+
playing(event) &&
|
|
819
|
+
typeof path === "string" &&
|
|
820
|
+
bytes instanceof Uint8Array &&
|
|
821
|
+
Number.isSafeInteger(offset) &&
|
|
822
|
+
Number.isSafeInteger(length) &&
|
|
823
|
+
offset >= 0 &&
|
|
824
|
+
length >= 0 &&
|
|
825
|
+
writeOf(path, bytes, offset, length);
|
|
826
|
+
});
|
|
827
|
+
ipcMain.on("air:create-directory", (event, path) => {
|
|
828
|
+
event.returnValue = playing(event) && typeof path === "string" && createDirectoryOf(path);
|
|
829
|
+
});
|
|
830
|
+
ipcMain.on("air:remove", (event, path) => {
|
|
831
|
+
event.returnValue = playing(event) && typeof path === "string" && removeOf(path);
|
|
832
|
+
});
|
|
833
|
+
ipcMain.on("air:move", (event, from, to) => {
|
|
834
|
+
event.returnValue =
|
|
835
|
+
playing(event) && typeof from === "string" && typeof to === "string" && moveOf(from, to);
|
|
836
|
+
});
|
|
837
|
+
// A read is what starts the application's grant: the page reads its content SWF first.
|
|
838
|
+
ipcMain.handle("air:read", async (event, path) => {
|
|
839
|
+
if (!trusted(event) || typeof path !== "string" || !air) {
|
|
840
|
+
return null;
|
|
841
|
+
}
|
|
842
|
+
try {
|
|
843
|
+
const real = await realpath(path);
|
|
844
|
+
return sandbox.startApplication(real) ? new Uint8Array(await readFile(real)) : null;
|
|
845
|
+
}
|
|
846
|
+
catch {
|
|
847
|
+
return null;
|
|
848
|
+
}
|
|
849
|
+
});
|
|
850
|
+
ipcMain.on("air:window", (event) => {
|
|
851
|
+
event.returnValue = playing(event) && window ? windowState(window) : null;
|
|
852
|
+
});
|
|
853
|
+
ipcMain.on("air:set-bounds", (event, bounds) => {
|
|
854
|
+
const b = bounds;
|
|
855
|
+
const n = (v) => (typeof v === "number" && Number.isFinite(v) ? Math.round(v) : null);
|
|
856
|
+
const [x, y, width, height] = [n(b?.x), n(b?.y), n(b?.width), n(b?.height)];
|
|
857
|
+
if (playing(event) &&
|
|
858
|
+
window &&
|
|
859
|
+
x !== null &&
|
|
860
|
+
y !== null &&
|
|
861
|
+
width &&
|
|
862
|
+
height &&
|
|
863
|
+
width > 0 &&
|
|
864
|
+
height > 0) {
|
|
865
|
+
window.setBounds({ x, y, width, height });
|
|
866
|
+
}
|
|
867
|
+
});
|
|
868
|
+
ipcMain.on("air:set-title", (event, title) => {
|
|
869
|
+
if (playing(event) && window && typeof title === "string") {
|
|
870
|
+
window.setTitle(title.slice(0, 1024));
|
|
871
|
+
}
|
|
872
|
+
});
|
|
873
|
+
ipcMain.on("air:window-do", (event, what) => {
|
|
874
|
+
if (!playing(event) || !window) {
|
|
875
|
+
return;
|
|
876
|
+
}
|
|
877
|
+
switch (what) {
|
|
878
|
+
case "minimize":
|
|
879
|
+
window.minimize();
|
|
880
|
+
break;
|
|
881
|
+
case "maximize":
|
|
882
|
+
window.maximize();
|
|
883
|
+
break;
|
|
884
|
+
case "restore":
|
|
885
|
+
window.restore();
|
|
886
|
+
break;
|
|
887
|
+
case "activate":
|
|
888
|
+
window.focus();
|
|
889
|
+
break;
|
|
890
|
+
case "show":
|
|
891
|
+
window.show();
|
|
892
|
+
break;
|
|
893
|
+
case "hide":
|
|
894
|
+
window.hide();
|
|
895
|
+
break;
|
|
896
|
+
case "close":
|
|
897
|
+
window.close();
|
|
898
|
+
break;
|
|
899
|
+
}
|
|
900
|
+
});
|
|
901
|
+
ipcMain.on("air:exit", (event, code) => {
|
|
902
|
+
if (playing(event)) {
|
|
903
|
+
app.exit(typeof code === "number" ? code | 0 : 0);
|
|
904
|
+
}
|
|
905
|
+
});
|
|
906
|
+
}
|
|
907
|
+
/**
|
|
908
|
+
* A request of the page's: the SWF's own, as the movie opened by its URL,
|
|
909
|
+
* or what the SWF playing loads, only with the network, as network.ts
|
|
910
|
+
* judges it. A refusal is said on stderr, and the page sees a failed
|
|
911
|
+
* load.
|
|
912
|
+
*/
|
|
913
|
+
async function fetchForPage(id, request, deliver) {
|
|
914
|
+
const asked = request;
|
|
915
|
+
const url = typeof asked?.url === "string" ? asked.url : "";
|
|
916
|
+
const shown = url.slice(0, 2048);
|
|
917
|
+
const movie = asked?.purpose === "movie" && sandbox.startRemote(url);
|
|
918
|
+
if (!movie && !sandbox.networkAllowed()) {
|
|
919
|
+
process.stderr.write(`swf2es: not loading ${shown}: the SWF has no network\n`);
|
|
920
|
+
throw new Refused("the SWF has no network");
|
|
921
|
+
}
|
|
922
|
+
const controller = new AbortController();
|
|
923
|
+
requests.set(id, controller);
|
|
924
|
+
try {
|
|
925
|
+
return await network.fetch(request, controller.signal, deliver);
|
|
926
|
+
}
|
|
927
|
+
catch (error) {
|
|
928
|
+
// A refusal says why; anything else, which should not happen, is said too.
|
|
929
|
+
if (!controller.signal.aborted) {
|
|
930
|
+
const why = error instanceof Refused ? error.message : `failed: ${error}`;
|
|
931
|
+
process.stderr.write(`swf2es: not loading ${shown}: ${why}\n`);
|
|
932
|
+
}
|
|
933
|
+
throw error;
|
|
934
|
+
}
|
|
935
|
+
finally {
|
|
936
|
+
if (requests.get(id) === controller) {
|
|
937
|
+
requests.delete(id);
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
// One app to a user data directory: a second start hands its SWF to the first and quits.
|
|
942
|
+
const first = app.requestSingleInstanceLock();
|
|
943
|
+
if (!first) {
|
|
944
|
+
app.quit();
|
|
945
|
+
}
|
|
946
|
+
app.on("second-instance", (_event, argv, workingDirectory) => {
|
|
947
|
+
if (window) {
|
|
948
|
+
if (window.isMinimized()) {
|
|
949
|
+
window.restore();
|
|
950
|
+
}
|
|
951
|
+
window.focus();
|
|
952
|
+
}
|
|
953
|
+
const application = airCommand(argv.slice(1), workingDirectory);
|
|
954
|
+
if (application && settings) {
|
|
955
|
+
void openAir(application, workingDirectory);
|
|
956
|
+
return;
|
|
957
|
+
}
|
|
958
|
+
const entry = movieFromArguments(argv, workingDirectory);
|
|
959
|
+
if (entry && settings) {
|
|
960
|
+
void openEntry(entry);
|
|
961
|
+
}
|
|
962
|
+
});
|
|
963
|
+
let pending = movieFromArguments(process.argv);
|
|
964
|
+
// Whether `pending` came from the system's "open with", not the command line: asked as a drop is.
|
|
965
|
+
let pendingChosen = false;
|
|
966
|
+
// An AIR application named adl's way, which the command line's SWF gives way to.
|
|
967
|
+
const pendingAir = airCommand(process.argv.slice(process.defaultApp ? 2 : 1), process.cwd());
|
|
968
|
+
// macOS hands a file opened from the Finder as an event, perhaps before the app is ready.
|
|
969
|
+
app.on("open-file", (event, path) => {
|
|
970
|
+
event.preventDefault();
|
|
971
|
+
if (settings) {
|
|
972
|
+
void openChosen(path);
|
|
973
|
+
}
|
|
974
|
+
else {
|
|
975
|
+
pending = path;
|
|
976
|
+
pendingChosen = true;
|
|
977
|
+
}
|
|
978
|
+
});
|
|
979
|
+
app.on("web-contents-created", (_event, contents) => {
|
|
980
|
+
contents.on("will-attach-webview", (event) => event.preventDefault());
|
|
981
|
+
});
|
|
982
|
+
app.on("window-all-closed", () => app.quit());
|
|
983
|
+
// Not awaited at the top level: Electron makes the app ready only once the main module has run.
|
|
984
|
+
void app.whenReady().then(() => {
|
|
985
|
+
if (!first) {
|
|
986
|
+
return;
|
|
987
|
+
}
|
|
988
|
+
settings = new SettingsFile(app.getPath("userData"));
|
|
989
|
+
serve(session.defaultSession, sandbox, () => settings.libraries());
|
|
990
|
+
// The page never goes to the network itself: a SWF's requests go through
|
|
991
|
+
// the main process (network.ts), which sends them without this session.
|
|
992
|
+
session.defaultSession.webRequest.onBeforeRequest({ urls: ["http://*/*", "https://*/*", "ws://*/*", "wss://*/*"] }, (details, answer) => {
|
|
993
|
+
process.stderr.write(`swf2es: not loading ${details.url}: the page has no network\n`);
|
|
994
|
+
answer({ cancel: true });
|
|
995
|
+
});
|
|
996
|
+
// Only what the player needs that the page cannot simply have: full screen.
|
|
997
|
+
session.defaultSession.setPermissionRequestHandler((contents, permission, answer) => answer(permission === "fullscreen" && contents === window?.webContents));
|
|
998
|
+
session.defaultSession.setPermissionCheckHandler((contents, permission) => permission === "fullscreen" && contents === window?.webContents);
|
|
999
|
+
listen();
|
|
1000
|
+
createWindow();
|
|
1001
|
+
buildMenu();
|
|
1002
|
+
if (pendingAir) {
|
|
1003
|
+
void openAir(pendingAir, process.cwd());
|
|
1004
|
+
}
|
|
1005
|
+
else if (pending) {
|
|
1006
|
+
void (pendingChosen ? openChosen(pending) : openEntry(pending));
|
|
1007
|
+
}
|
|
1008
|
+
});
|
|
1009
|
+
//# sourceMappingURL=main.js.map
|