@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,237 @@
|
|
|
1
|
+
// swf2es://, the one scheme the window loads anything from. swf2es://app/
|
|
2
|
+
// is the page, its modules and the libraries; swf2es://file/ is the local
|
|
3
|
+
// files the SWF playing may read, by its sandbox (sandbox.ts). They are
|
|
4
|
+
// two origins, so a SWF's files never share the page's, and the file host
|
|
5
|
+
// answers the page's fetches with CORS headers naming it.
|
|
6
|
+
import { createHash } from "node:crypto";
|
|
7
|
+
import { readFileSync } from "node:fs";
|
|
8
|
+
import { readFile, stat } from "node:fs/promises";
|
|
9
|
+
import { createRequire } from "node:module";
|
|
10
|
+
import { dirname, extname, join, normalize, sep } from "node:path";
|
|
11
|
+
import { fileURLToPath } from "node:url";
|
|
12
|
+
import { protocol, type Session } from "electron";
|
|
13
|
+
import type { LibraryState } from "../shared/api.js";
|
|
14
|
+
import { FILE_ORIGIN, type Sandbox } from "./sandbox.ts";
|
|
15
|
+
|
|
16
|
+
const SCHEME = "swf2es";
|
|
17
|
+
export const APP_ORIGIN = `${SCHEME}://app`;
|
|
18
|
+
|
|
19
|
+
const appRoot = fileURLToPath(new URL("../../", import.meta.url));
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Before the app is ready: a standard, secure scheme, which fetch, CORS and storage treat as https.
|
|
23
|
+
*/
|
|
24
|
+
export function registerScheme(): void {
|
|
25
|
+
protocol.registerSchemesAsPrivileged([
|
|
26
|
+
{
|
|
27
|
+
scheme: SCHEME,
|
|
28
|
+
privileges: {
|
|
29
|
+
standard: true,
|
|
30
|
+
secure: true,
|
|
31
|
+
supportFetchAPI: true,
|
|
32
|
+
corsEnabled: true,
|
|
33
|
+
codeCache: true,
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
]);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The directory a package's `specifier` resolves into, from `from`, and the package's root. */
|
|
40
|
+
function packageRoot(specifier: string, from: string): string {
|
|
41
|
+
const name = specifier.startsWith("@")
|
|
42
|
+
? specifier.split("/").slice(0, 2).join("/")
|
|
43
|
+
: specifier.split("/")[0];
|
|
44
|
+
let dir = dirname(createRequire(from).resolve(specifier));
|
|
45
|
+
for (;;) {
|
|
46
|
+
try {
|
|
47
|
+
if (JSON.parse(readFileSync(join(dir, "package.json"), "utf8")).name === name) {
|
|
48
|
+
return dir;
|
|
49
|
+
}
|
|
50
|
+
} catch {
|
|
51
|
+
// No package.json here; look further up.
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const parent = dirname(dir);
|
|
55
|
+
if (parent === dir) {
|
|
56
|
+
throw new Error(`no package root for ${specifier}`);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
dir = parent;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* What /modules/<name>/ serves: the packages' builds and their libraries, as static/index.html's
|
|
65
|
+
* import map names them.
|
|
66
|
+
*/
|
|
67
|
+
function moduleMounts(): Map<string, string> {
|
|
68
|
+
const here = join(appRoot, "package.json");
|
|
69
|
+
const dist = (specifier: string, from = here) => join(packageRoot(specifier, from), "dist");
|
|
70
|
+
const player = join(dist("@swf2es/player"), "index.js");
|
|
71
|
+
const format = join(dist("@swf2es/format"), "index.js");
|
|
72
|
+
return new Map([
|
|
73
|
+
["native", dist("@swf2es/native/compress", format)],
|
|
74
|
+
["format", dist("@swf2es/format")],
|
|
75
|
+
["context3d", dist("@swf2es/context3d/webgl")],
|
|
76
|
+
["codegen", dist("@swf2es/codegen")],
|
|
77
|
+
["runtime", dist("@swf2es/runtime", player)],
|
|
78
|
+
["render", dist("@swf2es/render", player)],
|
|
79
|
+
["player", dist("@swf2es/player")],
|
|
80
|
+
["player-hosts", dist("@swf2es/player-hosts/indexeddb")],
|
|
81
|
+
["web", dist("@swf2es/web")],
|
|
82
|
+
]);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const TYPES: Record<string, string> = {
|
|
86
|
+
".html": "text/html; charset=utf-8",
|
|
87
|
+
".css": "text/css; charset=utf-8",
|
|
88
|
+
".js": "text/javascript; charset=utf-8",
|
|
89
|
+
".mjs": "text/javascript; charset=utf-8",
|
|
90
|
+
".json": "application/json",
|
|
91
|
+
".map": "application/json",
|
|
92
|
+
".wasm": "application/wasm",
|
|
93
|
+
".swf": "application/x-shockwave-flash",
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
const typeOf = (path: string) => TYPES[extname(path).toLowerCase()] ?? "application/octet-stream";
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* The page's Content-Security-Policy. 'unsafe-eval' is the player's: it
|
|
100
|
+
* evaluates the modules it compiles with `new Function`; codegen is
|
|
101
|
+
* WebAssembly; the import map is allowed by its hash and no other inline
|
|
102
|
+
* script runs. The page reaches its own files and the SWF's, never the
|
|
103
|
+
* network: a SWF's http and https loads go through the main process
|
|
104
|
+
* (network.ts). Nothing is framed, embedded or posted to the page.
|
|
105
|
+
*/
|
|
106
|
+
function contentSecurityPolicy(page: string): string {
|
|
107
|
+
const map = /<script type="importmap">([\s\S]*?)<\/script>/.exec(page)?.[1];
|
|
108
|
+
const hash = map === undefined ? "" : createHash("sha256").update(map).digest("base64");
|
|
109
|
+
return [
|
|
110
|
+
"default-src 'none'",
|
|
111
|
+
// A page without the player's import map, the URL dialog, runs only its own scripts.
|
|
112
|
+
map === undefined
|
|
113
|
+
? "script-src 'self'"
|
|
114
|
+
: `script-src 'self' 'unsafe-eval' 'wasm-unsafe-eval' 'sha256-${hash}'`,
|
|
115
|
+
// The element's shadow root has <style>s of its own.
|
|
116
|
+
"style-src 'self' 'unsafe-inline'",
|
|
117
|
+
`img-src 'self' ${FILE_ORIGIN} data: blob:`,
|
|
118
|
+
`media-src 'self' ${FILE_ORIGIN} data: blob:`,
|
|
119
|
+
`font-src 'self' data:`,
|
|
120
|
+
`connect-src 'self' ${FILE_ORIGIN} data: blob:`,
|
|
121
|
+
"base-uri 'none'",
|
|
122
|
+
"form-action https:",
|
|
123
|
+
"frame-ancestors 'none'",
|
|
124
|
+
].join("; ");
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const notFound = () => new Response(null, { status: 404 });
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* What makes the app's pages cross-origin isolated, as a shareable
|
|
131
|
+
* ByteArray's shared memory needs (docs/workers.md), on the app's own
|
|
132
|
+
* responses, its workers' scripts among them. The page loads only the
|
|
133
|
+
* app's files and the SWF's, which the file host serves with CORS, and a
|
|
134
|
+
* SWF's network loads go through the main process, so it costs nothing.
|
|
135
|
+
*/
|
|
136
|
+
const ISOLATION = {
|
|
137
|
+
"cross-origin-opener-policy": "same-origin",
|
|
138
|
+
"cross-origin-embedder-policy": "credentialless",
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Serve swf2es:// in `session`: the app, its modules, the libraries `libraries` finds, and what
|
|
143
|
+
* `sandbox` lets the SWF read.
|
|
144
|
+
*/
|
|
145
|
+
export function serve(session: Session, sandbox: Sandbox, libraries: () => LibraryState): void {
|
|
146
|
+
const mounts = moduleMounts();
|
|
147
|
+
const staticDir = join(appRoot, "static");
|
|
148
|
+
const rendererDir = join(appRoot, "dist", "renderer");
|
|
149
|
+
|
|
150
|
+
/** `path` under `dir`, or null if it would leave it. */
|
|
151
|
+
const under = (dir: string, path: string): string | null => {
|
|
152
|
+
const file = normalize(join(dir, path));
|
|
153
|
+
return file.startsWith(dir + sep) ? file : null;
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
const app = async (path: string): Promise<Response> => {
|
|
157
|
+
// Each page with its policy: the player's and the URL dialog.
|
|
158
|
+
const pageName = path === "/" ? "index.html" : /^\/([a-z-]+\.html)$/.exec(path)?.[1];
|
|
159
|
+
if (pageName) {
|
|
160
|
+
let page: string;
|
|
161
|
+
try {
|
|
162
|
+
page = await readFile(join(staticDir, pageName), "utf8");
|
|
163
|
+
} catch {
|
|
164
|
+
return notFound();
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return new Response(page, {
|
|
168
|
+
headers: {
|
|
169
|
+
"content-type": TYPES[".html"],
|
|
170
|
+
"content-security-policy": contentSecurityPolicy(page),
|
|
171
|
+
...ISOLATION,
|
|
172
|
+
},
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const [, top, ...rest] = path.split("/");
|
|
177
|
+
let file: string | null = null;
|
|
178
|
+
switch (top) {
|
|
179
|
+
case "renderer":
|
|
180
|
+
file = under(rendererDir, rest.join("/"));
|
|
181
|
+
break;
|
|
182
|
+
case "modules": {
|
|
183
|
+
const dir = mounts.get(rest[0]);
|
|
184
|
+
file = dir ? under(dir, rest.slice(1).join("/")) : null;
|
|
185
|
+
break;
|
|
186
|
+
}
|
|
187
|
+
case "libraries": {
|
|
188
|
+
file = rest.join("/") === "playerglobal.abc" ? libraries().playerglobal : null;
|
|
189
|
+
break;
|
|
190
|
+
}
|
|
191
|
+
default:
|
|
192
|
+
file = under(staticDir, path.slice(1));
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
return file ? fileResponse(file, ISOLATION) : notFound();
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
session.protocol.handle(SCHEME, async (request) => {
|
|
199
|
+
const url = new URL(request.url);
|
|
200
|
+
if (request.method !== "GET" && request.method !== "HEAD") {
|
|
201
|
+
return new Response(null, { status: 405 });
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
let path: string;
|
|
205
|
+
try {
|
|
206
|
+
path = decodeURIComponent(url.pathname);
|
|
207
|
+
} catch {
|
|
208
|
+
// Malformed %-escapes name no file.
|
|
209
|
+
return new Response(null, { status: 400 });
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if (url.host === "app") {
|
|
213
|
+
return app(path);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
if (url.host === "file") {
|
|
217
|
+
const file = await sandbox.resolve(path);
|
|
218
|
+
return file ? fileResponse(file, { "access-control-allow-origin": APP_ORIGIN }) : notFound();
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
return notFound();
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
async function fileResponse(file: string, headers: Record<string, string>): Promise<Response> {
|
|
226
|
+
try {
|
|
227
|
+
if (!(await stat(file)).isFile()) {
|
|
228
|
+
return notFound();
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
return new Response(await readFile(file), {
|
|
232
|
+
headers: { "content-type": typeOf(file), ...headers },
|
|
233
|
+
});
|
|
234
|
+
} catch {
|
|
235
|
+
return notFound();
|
|
236
|
+
}
|
|
237
|
+
}
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
// The sandbox of the SWF playing, as Flash Player chose one for a local
|
|
2
|
+
// SWF by FileAttributes' UseNetwork bit: local-with-filesystem reads the
|
|
3
|
+
// files in the SWF's own directory and below, and reaches no network;
|
|
4
|
+
// local-with-networking reaches the network, and no local file but
|
|
5
|
+
// itself. A SWF opened by its URL is remote: no local file at all, and
|
|
6
|
+
// the network as its origin's (network.ts judges it). An AIR application
|
|
7
|
+
// opened by its descriptor plays in AIR's application sandbox: any local
|
|
8
|
+
// file, through File and its URLs, and the network as an application's,
|
|
9
|
+
// unjudged. Only the SWF playing now has a grant: opening another, or
|
|
10
|
+
// closing it, revokes it.
|
|
11
|
+
// What the SWF loads plays in its sandbox too.
|
|
12
|
+
// Apart from Electron, so that node tests it (tests/desktop/sandbox.test.ts).
|
|
13
|
+
import { createHash } from "node:crypto";
|
|
14
|
+
import { realpathSync } from "node:fs";
|
|
15
|
+
import { realpath } from "node:fs/promises";
|
|
16
|
+
import { homedir, tmpdir } from "node:os";
|
|
17
|
+
import { basename, dirname, isAbsolute, join, parse, relative, sep } from "node:path";
|
|
18
|
+
|
|
19
|
+
export const FILE_ORIGIN = "swf2es://file";
|
|
20
|
+
|
|
21
|
+
export type SandboxType = "localWithFile" | "localWithNetwork" | "remote" | "application";
|
|
22
|
+
|
|
23
|
+
interface Grant {
|
|
24
|
+
/** The SWF's real path, links followed; a remote SWF's URL. */
|
|
25
|
+
swf: string;
|
|
26
|
+
/** The directory it may read under, or null for none: its own file alone. */
|
|
27
|
+
root: string | null;
|
|
28
|
+
/** The URL's first segment, standing for the SWF's directory; null for a remote SWF. */
|
|
29
|
+
token: string | null;
|
|
30
|
+
type: SandboxType;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The OS's realpath, links followed, as both the grant and a request use it; null for no such file.
|
|
35
|
+
*/
|
|
36
|
+
async function real(path: string): Promise<string | null> {
|
|
37
|
+
try {
|
|
38
|
+
return await realpath(path);
|
|
39
|
+
} catch {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Whether `path` lies under `root`, not `root` itself. */
|
|
45
|
+
function inside(root: string, path: string): boolean {
|
|
46
|
+
const rest = relative(root, path);
|
|
47
|
+
return rest !== "" && rest !== ".." && !rest.startsWith(`..${sep}`) && !isAbsolute(rest);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export class Sandbox {
|
|
51
|
+
private grant: Grant | null = null;
|
|
52
|
+
/**
|
|
53
|
+
* The grant of a SWF opened but not yet asked for. It takes effect as the
|
|
54
|
+
* page fetches the SWF, which it does once it has let the last SWF go,
|
|
55
|
+
* so that one never runs, for a frame, in the next one's sandbox.
|
|
56
|
+
*/
|
|
57
|
+
private pending: Grant | null = null;
|
|
58
|
+
/** Directories never granted whole. */
|
|
59
|
+
private readonly shared: Set<string>;
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* `home` is the user's directory, never granted whole, nor the one that
|
|
63
|
+
* holds every user's (/home, /Users, C:\\Users), nor a temporary one
|
|
64
|
+
* every program shares: a SWF in any of them reads only itself.
|
|
65
|
+
*/
|
|
66
|
+
constructor(home = homedir()) {
|
|
67
|
+
const resolve = (path: string) => {
|
|
68
|
+
try {
|
|
69
|
+
return realpathSync.native(path);
|
|
70
|
+
} catch {
|
|
71
|
+
// No such directory: compared as given.
|
|
72
|
+
return path;
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
// Each as given and as it really is: a SWF's directory is compared real,
|
|
76
|
+
// and /tmp is /private/tmp on macOS, a home may be a link.
|
|
77
|
+
const shared = [home, dirname(home), tmpdir(), "/tmp", "/var/tmp"];
|
|
78
|
+
this.shared = new Set([...shared, ...shared.map(resolve), dirname(resolve(home))]);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** The sandbox of the SWF playing; null when none plays. */
|
|
82
|
+
get type(): SandboxType | null {
|
|
83
|
+
return this.grant?.type ?? null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** The real path of the SWF playing, or opened to play next, or its URL; null when none. */
|
|
87
|
+
get swf(): string | null {
|
|
88
|
+
return (this.pending ?? this.grant)?.swf ?? null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Grant `swf` its sandbox, in place of the last SWF's, and give the URL
|
|
93
|
+
* the page plays it from. The URL names the SWF's directory by a token,
|
|
94
|
+
* not its path, so neither loaderInfo.url nor anything the SWF sends
|
|
95
|
+
* tells where on the disk it is, or whose disk; the token, a hash of the
|
|
96
|
+
* path, stays the same from run to run for the SWF's SharedObjects.
|
|
97
|
+
* The shared directories (the constructor's) and a file system's root
|
|
98
|
+
* are never granted whole: a SWF there reads only itself.
|
|
99
|
+
*/
|
|
100
|
+
play(swf: string, network: boolean): string {
|
|
101
|
+
const path = realpathSync.native(swf);
|
|
102
|
+
const directory = dirname(path);
|
|
103
|
+
const whole = this.shared.has(directory) || directory === parse(directory).root;
|
|
104
|
+
const token = createHash("sha256").update(directory).digest("hex").slice(0, 16);
|
|
105
|
+
this.grant = null;
|
|
106
|
+
this.pending = {
|
|
107
|
+
swf: path,
|
|
108
|
+
root: network || whole ? null : directory,
|
|
109
|
+
token,
|
|
110
|
+
type: network ? "localWithNetwork" : "localWithFile",
|
|
111
|
+
};
|
|
112
|
+
return `${FILE_ORIGIN}/${token}/${encodeURIComponent(basename(path))}`;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Grant the remote SWF at `url` its sandbox, in place of the last SWF's:
|
|
117
|
+
* no local file, and the network as network.ts judges it. It takes
|
|
118
|
+
* effect as the page asks for that URL (startRemote).
|
|
119
|
+
*/
|
|
120
|
+
playRemote(url: string): void {
|
|
121
|
+
this.grant = null;
|
|
122
|
+
this.pending = { swf: url, root: null, token: null, type: "remote" };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* The page asks for the remote SWF at `url`: whether that is the one
|
|
127
|
+
* opened, whose grant then takes effect, or the one playing.
|
|
128
|
+
*/
|
|
129
|
+
startRemote(url: string): boolean {
|
|
130
|
+
const pending = this.pending;
|
|
131
|
+
if (pending?.type === "remote" && pending.swf === url) {
|
|
132
|
+
this.grant = pending;
|
|
133
|
+
this.pending = null;
|
|
134
|
+
return true;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return this.grant?.type === "remote" && this.grant.swf === url && this.pending === null;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Grant the AIR application whose content SWF is at `content` its
|
|
142
|
+
* sandbox, in place of the last SWF's. It takes effect as the page reads
|
|
143
|
+
* that file (startApplication).
|
|
144
|
+
*/
|
|
145
|
+
playApplication(content: string): void {
|
|
146
|
+
this.grant = null;
|
|
147
|
+
this.pending = { swf: content, root: null, token: null, type: "application" };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* The page reads the local file at `path`: whether an AIR application
|
|
152
|
+
* plays that may, its grant taking effect as the page reads its content SWF.
|
|
153
|
+
*/
|
|
154
|
+
startApplication(path: string): boolean {
|
|
155
|
+
const pending = this.pending;
|
|
156
|
+
if (pending?.type === "application" && pending.swf === path) {
|
|
157
|
+
this.grant = pending;
|
|
158
|
+
this.pending = null;
|
|
159
|
+
return true;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return this.application;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Whether an AIR application plays, whose File reads the file system. */
|
|
166
|
+
get application(): boolean {
|
|
167
|
+
return this.grant?.type === "application" && this.pending === null;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Nothing plays: every grant revoked. */
|
|
171
|
+
stop(): void {
|
|
172
|
+
this.grant = null;
|
|
173
|
+
this.pending = null;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Whether the SWF playing may reach the network: in local-with-networking, remote, or an AIR application. */
|
|
177
|
+
networkAllowed(): boolean {
|
|
178
|
+
const type = this.grant?.type;
|
|
179
|
+
return type === "localWithNetwork" || type === "remote" || type === "application";
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* The local file a swf2es://file URL names, if the SWF playing may read it; its path, decoded, is
|
|
184
|
+
* `pathname`.
|
|
185
|
+
*/
|
|
186
|
+
async resolve(pathname: string): Promise<string | null> {
|
|
187
|
+
const [empty, token, ...rest] = pathname.split("/");
|
|
188
|
+
const pending = this.pending;
|
|
189
|
+
if (pending?.token && empty === "" && token === pending.token && rest.length > 0) {
|
|
190
|
+
const path = await real(join(dirname(pending.swf), ...rest));
|
|
191
|
+
if (path === pending.swf && this.pending === pending) {
|
|
192
|
+
this.grant = pending;
|
|
193
|
+
this.pending = null;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const grant = this.grant;
|
|
198
|
+
if (!grant?.token || empty !== "" || token !== grant.token || rest.length === 0) {
|
|
199
|
+
return null;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const path = await real(join(dirname(grant.swf), ...rest));
|
|
203
|
+
if (path === null || this.grant !== grant) {
|
|
204
|
+
return null;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
if (path === grant.swf) {
|
|
208
|
+
return path;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
return grant.root !== null && inside(grant.root, path) ? path : null;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// What the app remembers between runs, in settings.json under its user
|
|
2
|
+
// data: a playerglobal.abc the user chose, and the SWFs opened last.
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
4
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { dirname, join } from "node:path";
|
|
6
|
+
import type { LibraryName, LibraryState } from "../shared/api.js";
|
|
7
|
+
|
|
8
|
+
export interface Settings {
|
|
9
|
+
stage3D: boolean;
|
|
10
|
+
playerglobal?: string;
|
|
11
|
+
/** Most recent first. */
|
|
12
|
+
recent: string[];
|
|
13
|
+
/**
|
|
14
|
+
* The servers, "host:port", each SWF, by its real path, is always allowed to connect a socket to.
|
|
15
|
+
*/
|
|
16
|
+
sockets: Record<string, string[]>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const RECENT = 10;
|
|
20
|
+
|
|
21
|
+
const strings = (value: unknown): string[] =>
|
|
22
|
+
Array.isArray(value) ? value.filter((v): v is string => typeof v === "string") : [];
|
|
23
|
+
|
|
24
|
+
export class SettingsFile {
|
|
25
|
+
private readonly path: string;
|
|
26
|
+
private current: Settings;
|
|
27
|
+
|
|
28
|
+
constructor(directory: string) {
|
|
29
|
+
this.path = join(directory, "settings.json");
|
|
30
|
+
this.current = this.read();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
get settings(): Readonly<Settings> {
|
|
34
|
+
return this.current;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The playerglobal.abc the user chose, where it still is; builtin.abc ships with the player. */
|
|
38
|
+
libraries(): LibraryState {
|
|
39
|
+
return { playerglobal: this.found(this.current.playerglobal) };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Whether it was saved; it holds for this run either way. */
|
|
43
|
+
setLibrary(name: LibraryName, path: string): boolean {
|
|
44
|
+
return this.update({ [name]: path });
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
setStage3D(enabled: boolean): boolean {
|
|
48
|
+
return this.update({ stage3D: enabled });
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** `path` opened: first in the recent list. */
|
|
52
|
+
opened(path: string): void {
|
|
53
|
+
this.update({
|
|
54
|
+
recent: [path, ...this.current.recent.filter((p) => p !== path)].slice(0, RECENT),
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Whether `swf` is always allowed a socket to `endpoint`, "host:port". */
|
|
59
|
+
socketAllowed(swf: string, endpoint: string): boolean {
|
|
60
|
+
return Object.hasOwn(this.current.sockets, swf) && this.current.sockets[swf].includes(endpoint);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Allow `swf` a socket to `endpoint` from now on: whether that was saved. */
|
|
64
|
+
allowSocket(swf: string, endpoint: string): boolean {
|
|
65
|
+
const allowed = Object.hasOwn(this.current.sockets, swf) ? this.current.sockets[swf] : [];
|
|
66
|
+
return this.update({ sockets: { ...this.current.sockets, [swf]: [...allowed, endpoint] } });
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
clearRecent(): void {
|
|
70
|
+
this.update({ recent: [] });
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
private found(path: string | undefined): string | null {
|
|
74
|
+
return path && existsSync(path) ? path : null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
private read(): Settings {
|
|
78
|
+
try {
|
|
79
|
+
const read = JSON.parse(readFileSync(this.path, "utf8"));
|
|
80
|
+
const text = (value: unknown) => (typeof value === "string" ? value : undefined);
|
|
81
|
+
return {
|
|
82
|
+
stage3D: read.stage3D === true,
|
|
83
|
+
playerglobal: text(read.playerglobal),
|
|
84
|
+
recent: strings(read.recent),
|
|
85
|
+
sockets: Object.fromEntries(
|
|
86
|
+
Object.entries(
|
|
87
|
+
typeof read.sockets === "object" && read.sockets !== null ? read.sockets : {},
|
|
88
|
+
).map(([swf, endpoints]) => [swf, strings(endpoints)]),
|
|
89
|
+
),
|
|
90
|
+
};
|
|
91
|
+
} catch {
|
|
92
|
+
// None yet, or unreadable: start again rather than refuse to start.
|
|
93
|
+
return { stage3D: false, recent: [], sockets: {} };
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Apply `change`, and write the settings: whether they were written. A
|
|
99
|
+
* user data directory that cannot be written keeps them for this run.
|
|
100
|
+
*/
|
|
101
|
+
private update(change: Partial<Settings>): boolean {
|
|
102
|
+
this.current = { ...this.current, ...change };
|
|
103
|
+
// Written whole and renamed into place, so a crash never leaves half a
|
|
104
|
+
// file, under a name no other write shares.
|
|
105
|
+
const temporary = `${this.path}.${process.pid}.${randomUUID()}.tmp`;
|
|
106
|
+
try {
|
|
107
|
+
mkdirSync(dirname(this.path), { recursive: true });
|
|
108
|
+
writeFileSync(temporary, `${JSON.stringify(this.current, null, 2)}\n`);
|
|
109
|
+
renameSync(temporary, this.path);
|
|
110
|
+
return true;
|
|
111
|
+
} catch (error) {
|
|
112
|
+
console.error(`swf2es: settings not saved to ${this.path}: ${error}`);
|
|
113
|
+
rmSync(temporary, { force: true });
|
|
114
|
+
return false;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// flash.net.Socket's TCP connections, made here for the page, which has
|
|
2
|
+
// no network of its own beyond fetch, and carried over IPC. Each page's
|
|
3
|
+
// connections (connections.ts) close as it navigates, reloads or goes.
|
|
4
|
+
import { type IpcMainEvent, ipcMain, type WebContents } from "electron";
|
|
5
|
+
import { Connections, validEndpoint } from "./connections.ts";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Carry socket messages from pages that `trusted` accepts, each connection
|
|
9
|
+
* made only as `permit` allows it, to the address it gives where it gives
|
|
10
|
+
* one; null refuses it.
|
|
11
|
+
*/
|
|
12
|
+
export function bridgeSockets(
|
|
13
|
+
trusted: (event: IpcMainEvent) => boolean,
|
|
14
|
+
permit: (host: string, port: number) => Promise<{ address?: string } | null>,
|
|
15
|
+
): void {
|
|
16
|
+
const pages = new Map<WebContents, Connections>();
|
|
17
|
+
|
|
18
|
+
const connectionsOf = (contents: WebContents): Connections => {
|
|
19
|
+
const known = pages.get(contents);
|
|
20
|
+
if (known) {
|
|
21
|
+
return known;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const connections = new Connections((id, event) => {
|
|
25
|
+
if (!contents.isDestroyed()) {
|
|
26
|
+
contents.send("socket:event", id, event);
|
|
27
|
+
}
|
|
28
|
+
});
|
|
29
|
+
pages.set(contents, connections);
|
|
30
|
+
contents.on("did-start-navigation", (details) => {
|
|
31
|
+
if (details.isMainFrame && !details.isSameDocument) {
|
|
32
|
+
connections.closeAll();
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
contents.once("destroyed", () => {
|
|
36
|
+
connections.closeAll();
|
|
37
|
+
pages.delete(contents);
|
|
38
|
+
});
|
|
39
|
+
return connections;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
ipcMain.on("socket:connect", (event, id: unknown, host: unknown, port: unknown) => {
|
|
43
|
+
if (!trusted(event) || typeof id !== "number") {
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const connections = connectionsOf(event.sender);
|
|
48
|
+
if (!validEndpoint(host, port)) {
|
|
49
|
+
connections.refuse(id);
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const generation = connections.generation;
|
|
54
|
+
void permit(host, port as number)
|
|
55
|
+
.catch(() => null)
|
|
56
|
+
.then((allowed) => {
|
|
57
|
+
// The page that asked may have gone while the user was asked.
|
|
58
|
+
if (connections.generation !== generation) {
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (allowed) {
|
|
63
|
+
connections.connect(id, host, port, allowed.address);
|
|
64
|
+
} else {
|
|
65
|
+
connections.refuse(id);
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
ipcMain.on("socket:send", (event, id: unknown, bytes: unknown) => {
|
|
71
|
+
if (trusted(event) && typeof id === "number" && bytes instanceof Uint8Array) {
|
|
72
|
+
pages.get(event.sender)?.send(id, bytes);
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
ipcMain.on("socket:close", (event, id: unknown) => {
|
|
77
|
+
if (trusted(event) && typeof id === "number") {
|
|
78
|
+
pages.get(event.sender)?.close(id);
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"extends": "../../../../tsconfig.base.json",
|
|
3
|
+
"compilerOptions": {
|
|
4
|
+
"rootDir": ".",
|
|
5
|
+
"outDir": "../../dist/main",
|
|
6
|
+
"tsBuildInfoFile": "../../dist/main/.tsbuildinfo",
|
|
7
|
+
"types": ["node"],
|
|
8
|
+
// Its modules import each other by their .ts names, so that node runs
|
|
9
|
+
// them as they are in the tests, and the build writes .js.
|
|
10
|
+
"rewriteRelativeImportExtensions": true,
|
|
11
|
+
"lib": ["ES2022"]
|
|
12
|
+
},
|
|
13
|
+
"include": ["*.ts"],
|
|
14
|
+
"references": [{ "path": "../shared" }, { "path": "../../../../packages/format" }]
|
|
15
|
+
}
|