@swf2es/desktop 0.1.20 → 0.1.21
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/dist/main/air-bridge.d.ts +7 -0
- package/dist/main/air-bridge.d.ts.map +1 -0
- package/dist/main/air-bridge.js +166 -0
- package/dist/main/air-bridge.js.map +1 -0
- package/dist/main/dialogs.d.ts +18 -0
- package/dist/main/dialogs.d.ts.map +1 -0
- package/dist/main/dialogs.js +128 -0
- package/dist/main/dialogs.js.map +1 -0
- package/dist/main/main.js +23 -788
- package/dist/main/main.js.map +1 -1
- package/dist/main/menu.d.ts +2 -0
- package/dist/main/menu.d.ts.map +1 -0
- package/dist/main/menu.js +80 -0
- package/dist/main/menu.js.map +1 -0
- package/dist/main/network-rules.d.ts +35 -0
- package/dist/main/network-rules.d.ts.map +1 -0
- package/dist/main/network-rules.js +167 -0
- package/dist/main/network-rules.js.map +1 -0
- package/dist/main/network-types.d.ts +131 -0
- package/dist/main/network-types.d.ts.map +1 -0
- package/dist/main/network-types.js +21 -0
- package/dist/main/network-types.js.map +1 -0
- package/dist/main/network.d.ts +1 -113
- package/dist/main/network.d.ts.map +1 -1
- package/dist/main/network.js +8 -181
- package/dist/main/network.js.map +1 -1
- package/dist/main/open.d.ts +55 -0
- package/dist/main/open.d.ts.map +1 -0
- package/dist/main/open.js +256 -0
- package/dist/main/open.js.map +1 -0
- package/dist/main/permissions.d.ts +17 -0
- package/dist/main/permissions.d.ts.map +1 -0
- package/dist/main/permissions.js +113 -0
- package/dist/main/permissions.js.map +1 -0
- package/dist/main/state.d.ts +17 -0
- package/dist/main/state.d.ts.map +1 -0
- package/dist/main/state.js +30 -0
- package/dist/main/state.js.map +1 -0
- package/dist/main/switches.d.ts +25 -0
- package/dist/main/switches.d.ts.map +1 -0
- package/dist/main/switches.js +69 -0
- package/dist/main/switches.js.map +1 -0
- package/package.json +7 -7
- package/src/main/air-bridge.ts +185 -0
- package/src/main/dialogs.ts +147 -0
- package/src/main/main.ts +51 -937
- package/src/main/menu.ts +84 -0
- package/src/main/network-rules.ts +199 -0
- package/src/main/network-types.ts +150 -0
- package/src/main/network.ts +30 -337
- package/src/main/open.ts +302 -0
- package/src/main/permissions.ts +135 -0
- package/src/main/state.ts +44 -0
- package/src/main/switches.ts +87 -0
package/src/main/network.ts
CHANGED
|
@@ -26,15 +26,42 @@
|
|
|
26
26
|
// is asked for that address, not the name, so a proxy's DNS that answers
|
|
27
27
|
// otherwise than this machine's reaches nothing the judgement did not.
|
|
28
28
|
//
|
|
29
|
+
// Its types and limits are network-types.ts, and the rules that need no
|
|
30
|
+
// instance network-rules.ts.
|
|
31
|
+
//
|
|
29
32
|
// Without Electron, so that node tests it (tests/desktop/network.test.ts).
|
|
30
33
|
import { lookup } from "node:dns/promises";
|
|
31
34
|
import { type ClientRequest, request as httpRequest, type IncomingMessage } from "node:http";
|
|
32
35
|
import { request as httpsRequest } from "node:https";
|
|
33
|
-
import { connect, isIP,
|
|
36
|
+
import { connect, isIP, type Socket } from "node:net";
|
|
34
37
|
import type { Duplex } from "node:stream";
|
|
35
38
|
import * as tls from "node:tls";
|
|
36
|
-
import { type AddressClass, addressClass,
|
|
39
|
+
import { type AddressClass, addressClass, RANK } from "./addresses.ts";
|
|
37
40
|
import { DECODED, Decompressor } from "./decompress.ts";
|
|
41
|
+
import {
|
|
42
|
+
abortable,
|
|
43
|
+
bare,
|
|
44
|
+
cached,
|
|
45
|
+
checkPort,
|
|
46
|
+
defaultCa,
|
|
47
|
+
describe,
|
|
48
|
+
proxyFault,
|
|
49
|
+
redirectOf,
|
|
50
|
+
refererFor,
|
|
51
|
+
} from "./network-rules.ts";
|
|
52
|
+
import {
|
|
53
|
+
type Budget,
|
|
54
|
+
LIMITS,
|
|
55
|
+
type Limits,
|
|
56
|
+
type Movie,
|
|
57
|
+
type NetRequest,
|
|
58
|
+
type NetResponse,
|
|
59
|
+
type NetworkOptions,
|
|
60
|
+
type Origin,
|
|
61
|
+
type Pinned,
|
|
62
|
+
Refused,
|
|
63
|
+
type Session,
|
|
64
|
+
} from "./network-types.ts";
|
|
38
65
|
import {
|
|
39
66
|
allowsAccess,
|
|
40
67
|
allowsHeaders,
|
|
@@ -49,203 +76,13 @@ import {
|
|
|
49
76
|
urlMetaPolicy,
|
|
50
77
|
usablePolicies,
|
|
51
78
|
} from "./policy.ts";
|
|
52
|
-
import { connectThrough, type ProxyConfig, ProxyError } from "./proxy.ts";
|
|
53
|
-
|
|
54
|
-
export type Purpose = "movie" | "content" | "data" | "send";
|
|
55
|
-
|
|
56
|
-
export interface NetRequest {
|
|
57
|
-
url: string;
|
|
58
|
-
method: string;
|
|
59
|
-
headers: [string, string][];
|
|
60
|
-
body: Uint8Array | null;
|
|
61
|
-
purpose: Purpose;
|
|
62
|
-
/**
|
|
63
|
-
* Whether the body of a response whose status is 400 or more is read
|
|
64
|
-
* too, which an AIR application's URLLoader and URLStream complete
|
|
65
|
-
* with; only an application is given it.
|
|
66
|
-
*/
|
|
67
|
-
errorBody?: boolean;
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
export interface NetResponse {
|
|
71
|
-
bytes: Uint8Array | null;
|
|
72
|
-
status: number;
|
|
73
|
-
headers: [string, string][];
|
|
74
|
-
/** Where redirects took it, where that differs from the URL asked for. */
|
|
75
|
-
url?: string;
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
/** A request refused, with why, for the terminal; the SWF sees a failed load. */
|
|
79
|
-
export class Refused extends Error {}
|
|
80
|
-
|
|
81
|
-
export interface Limits {
|
|
82
|
-
/** The longest URL. */
|
|
83
|
-
url: number;
|
|
84
|
-
/** The most headers a SWF sends, and their names' and values' length all told. */
|
|
85
|
-
headers: number;
|
|
86
|
-
headerBytes: number;
|
|
87
|
-
/** The largest body a SWF sends. */
|
|
88
|
-
body: number;
|
|
89
|
-
/** The largest response read: a SWF, a sound, a level's data. */
|
|
90
|
-
response: number;
|
|
91
|
-
/** The largest policy file, URL or socket. */
|
|
92
|
-
policy: number;
|
|
93
|
-
redirects: number;
|
|
94
|
-
/** From the request to its response's headers, and from one chunk of its body to the next. */
|
|
95
|
-
timeout: number;
|
|
96
|
-
/**
|
|
97
|
-
* How long a request may take all told, however its bytes trickle in, so
|
|
98
|
-
* that a server sending a byte a second holds no turn for long; a policy
|
|
99
|
-
* file's whole fetch takes `policyTimeout` at most, as a movie keeps it.
|
|
100
|
-
*/
|
|
101
|
-
deadline: number;
|
|
102
|
-
/** How long a policy file may take, and port 843's socket policy, which most servers lack. */
|
|
103
|
-
policyTimeout: number;
|
|
104
|
-
masterSocketTimeout: number;
|
|
105
|
-
/**
|
|
106
|
-
* The bytes a movie's responses may hold at once, all told: past it a
|
|
107
|
-
* response is refused, so many large ones together cannot take the
|
|
108
|
-
* main process's memory, as each alone may come near `response`.
|
|
109
|
-
*/
|
|
110
|
-
buffered: number;
|
|
111
|
-
/** Requests under way at once; the rest wait their turn, up to `queue`. */
|
|
112
|
-
concurrent: number;
|
|
113
|
-
queue: number;
|
|
114
|
-
/** Policy files a movie may name with Security.loadPolicyFile. */
|
|
115
|
-
policyFiles: number;
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
const LIMITS: Limits = {
|
|
119
|
-
url: 64 * 1024,
|
|
120
|
-
headers: 32,
|
|
121
|
-
headerBytes: 8 * 1024,
|
|
122
|
-
body: 32 * 1024 * 1024,
|
|
123
|
-
response: 128 * 1024 * 1024,
|
|
124
|
-
policy: 1024 * 1024,
|
|
125
|
-
redirects: 10,
|
|
126
|
-
timeout: 30_000,
|
|
127
|
-
deadline: 300_000,
|
|
128
|
-
policyTimeout: 10_000,
|
|
129
|
-
masterSocketTimeout: 3_000,
|
|
130
|
-
buffered: 256 * 1024 * 1024,
|
|
131
|
-
concurrent: 16,
|
|
132
|
-
queue: 1024,
|
|
133
|
-
policyFiles: 64,
|
|
134
|
-
};
|
|
135
|
-
|
|
136
|
-
/**
|
|
137
|
-
* The movie whose requests are judged: a remote SWF by its URL, a local
|
|
138
|
-
* one with networking, or an AIR application, whose sandbox reaches the
|
|
139
|
-
* network unjudged, as AIR's application sandbox does: any address, no
|
|
140
|
-
* policy file.
|
|
141
|
-
*/
|
|
142
|
-
export type Movie = { kind: "remote"; url: string } | { kind: "local" } | { kind: "application" };
|
|
143
|
-
|
|
144
|
-
export interface NetworkOptions {
|
|
145
|
-
/** A host name's addresses, in order of preference: the system's resolver by default. */
|
|
146
|
-
resolve?: (host: string) => Promise<string[]>;
|
|
147
|
-
/** Where an address is: addresses.ts's by default; null for one never reached. */
|
|
148
|
-
classify?: (address: string) => AddressClass | null;
|
|
149
|
-
/** The certificate authorities HTTPS trusts, in place of Node's and the system's. */
|
|
150
|
-
ca?: string | string[];
|
|
151
|
-
/**
|
|
152
|
-
* Hosts whose HTTPS certificate is not verified: a server with one it
|
|
153
|
-
* signed itself, as a developer's own has (--ignore-certificate-errors).
|
|
154
|
-
* None by default.
|
|
155
|
-
*/
|
|
156
|
-
unverified?: (host: string) => boolean;
|
|
157
|
-
limits?: Partial<Limits>;
|
|
158
|
-
/** Where a socket server keeps its master policy: 843. */
|
|
159
|
-
socketPolicyPort?: number;
|
|
160
|
-
/** What every connection goes through (--proxy); none by default. */
|
|
161
|
-
proxy?: ProxyConfig | null;
|
|
162
|
-
}
|
|
79
|
+
import { connectThrough, type ProxyConfig, type ProxyError } from "./proxy.ts";
|
|
163
80
|
|
|
164
81
|
/** What Flash Player 32 reported, sent as it sent it. */
|
|
165
82
|
const FLASH_VERSION = "32,0,0,465";
|
|
166
83
|
const USER_AGENT = "Mozilla/5.0 (compatible; swf2es) Shockwave Flash";
|
|
167
|
-
|
|
168
|
-
/** A remote movie once its SWF has come, where from. */
|
|
169
|
-
interface Origin {
|
|
170
|
-
/** Its URL after redirects: its loaderInfo.url, and its Referer. */
|
|
171
|
-
url: URL;
|
|
172
|
-
requester: Requester;
|
|
173
|
-
rank: number;
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
interface Pinned {
|
|
177
|
-
address: string;
|
|
178
|
-
rank: number;
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
/** What one movie's requests share, and lose as the next opens. */
|
|
182
|
-
/** What one request's responses hold of its movie's `buffered`, given back as it is answered. */
|
|
183
|
-
interface Budget {
|
|
184
|
-
session: Session;
|
|
185
|
-
taken: number;
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
interface Session {
|
|
189
|
-
movie: Movie;
|
|
190
|
-
origin: Origin | null;
|
|
191
|
-
abort: AbortController;
|
|
192
|
-
/** URL and socket policies, by where they came from and the address they came from. */
|
|
193
|
-
policies: Map<string, Promise<UrlPolicy | null>>;
|
|
194
|
-
socketPolicies: Map<string, Promise<SocketPolicy | null>>;
|
|
195
|
-
/** What Security.loadPolicyFile named: http(s) URLs, and xmlsocket "host:port"s. */
|
|
196
|
-
urlPolicyFiles: URL[];
|
|
197
|
-
socketPolicyFiles: { host: string; port: number }[];
|
|
198
|
-
/** The bytes its responses hold now, of `buffered`. */
|
|
199
|
-
buffered: number;
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
const REDIRECTS = new Set([301, 302, 303, 307, 308]);
|
|
203
84
|
const TOKEN = /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/;
|
|
204
85
|
|
|
205
|
-
/** The system's trusted roots beside Node's, where this Node can tell them. */
|
|
206
|
-
function defaultCa(): string[] | undefined {
|
|
207
|
-
const get = (tls as { getCACertificates?: (type: string) => string[] }).getCACertificates;
|
|
208
|
-
if (typeof get !== "function") {
|
|
209
|
-
return undefined;
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
try {
|
|
213
|
-
return [...new Set([...get("default"), ...get("system")])];
|
|
214
|
-
} catch {
|
|
215
|
-
return undefined;
|
|
216
|
-
}
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
/**
|
|
220
|
-
* The hosts --ignore-certificate-errors names, of the app's arguments up to
|
|
221
|
-
* an AIR application's own after "--": null without the switch; with it
|
|
222
|
-
* alone, every host; with `=a.test,b.test`, those, whatever their case.
|
|
223
|
-
* A host is a name or an address, with no port: every port of it.
|
|
224
|
-
*/
|
|
225
|
-
export function unverifiedHosts(argv: string[]): { all: boolean; hosts: string[] } | null {
|
|
226
|
-
const end = argv.indexOf("--");
|
|
227
|
-
const own = end < 0 ? argv : argv.slice(0, end);
|
|
228
|
-
let named = false;
|
|
229
|
-
let all = false;
|
|
230
|
-
const hosts: string[] = [];
|
|
231
|
-
for (const arg of own) {
|
|
232
|
-
if (arg === "--ignore-certificate-errors") {
|
|
233
|
-
named = true;
|
|
234
|
-
all = true;
|
|
235
|
-
} else if (arg.startsWith("--ignore-certificate-errors=")) {
|
|
236
|
-
// The switch with nothing named ignores nothing, rather than everything by a slip.
|
|
237
|
-
named = true;
|
|
238
|
-
for (const host of arg.slice(arg.indexOf("=") + 1).split(",")) {
|
|
239
|
-
if (host.trim() !== "") {
|
|
240
|
-
hosts.push(host.trim().toLowerCase());
|
|
241
|
-
}
|
|
242
|
-
}
|
|
243
|
-
}
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
return named ? { all, hosts } : null;
|
|
247
|
-
}
|
|
248
|
-
|
|
249
86
|
export class Network {
|
|
250
87
|
private readonly limits: Limits;
|
|
251
88
|
private readonly resolveHost: (host: string) => Promise<string[]>;
|
|
@@ -1181,147 +1018,3 @@ export class Network {
|
|
|
1181
1018
|
});
|
|
1182
1019
|
}
|
|
1183
1020
|
}
|
|
1184
|
-
|
|
1185
|
-
/**
|
|
1186
|
-
* Whether `url` is potentially trustworthy, as the Secure Contexts
|
|
1187
|
-
* specification has it for a Referer: https, or this machine by its
|
|
1188
|
-
* loopback address or a name under localhost.
|
|
1189
|
-
*/
|
|
1190
|
-
function trustworthy(url: URL): boolean {
|
|
1191
|
-
if (url.protocol === "https:") {
|
|
1192
|
-
return true;
|
|
1193
|
-
}
|
|
1194
|
-
|
|
1195
|
-
// A name with its root's dot is the same name; of IPv6 addresses the
|
|
1196
|
-
// specification names ::1 alone, not an IPv4 loopback address inside one.
|
|
1197
|
-
const host = bare(url.hostname).toLowerCase().replace(/\.$/, "");
|
|
1198
|
-
return (
|
|
1199
|
-
host === "localhost" ||
|
|
1200
|
-
host.endsWith(".localhost") ||
|
|
1201
|
-
host === "::1" ||
|
|
1202
|
-
(isIPv4(host) && addressClass(host) === "loopback")
|
|
1203
|
-
);
|
|
1204
|
-
}
|
|
1205
|
-
|
|
1206
|
-
/** The longest Referer sent whole, as the Referrer Policy specification has it. */
|
|
1207
|
-
const MAX_REFERER = 4096;
|
|
1208
|
-
|
|
1209
|
-
/**
|
|
1210
|
-
* The Referer a request from the SWF at `swf` to `target` carries, as
|
|
1211
|
-
* browsers' strict-origin-when-cross-origin has it: the SWF's URL to its
|
|
1212
|
-
* own origin, its origin alone to another, and nothing from https to what
|
|
1213
|
-
* is not potentially trustworthy; a URL longer than 4096 characters goes
|
|
1214
|
-
* as its origin.
|
|
1215
|
-
*/
|
|
1216
|
-
export function refererFor(swf: URL | null, target: URL): string | null {
|
|
1217
|
-
if (!swf || (swf.protocol === "https:" && !trustworthy(target))) {
|
|
1218
|
-
return null;
|
|
1219
|
-
}
|
|
1220
|
-
|
|
1221
|
-
const origin = `${swf.origin}/`;
|
|
1222
|
-
if (swf.origin !== target.origin || swf.href.length > MAX_REFERER) {
|
|
1223
|
-
return origin.length > MAX_REFERER ? null : origin;
|
|
1224
|
-
}
|
|
1225
|
-
|
|
1226
|
-
return swf.href;
|
|
1227
|
-
}
|
|
1228
|
-
|
|
1229
|
-
/** `promise`, or a Refused once `signal` aborts: a request stops waiting on a shared policy. */
|
|
1230
|
-
function abortable<T>(promise: Promise<T>, signal: AbortSignal): Promise<T> {
|
|
1231
|
-
if (signal.aborted) {
|
|
1232
|
-
return Promise.reject(new Refused("aborted"));
|
|
1233
|
-
}
|
|
1234
|
-
|
|
1235
|
-
return new Promise((resolve, reject) => {
|
|
1236
|
-
const stop = () => reject(new Refused("aborted"));
|
|
1237
|
-
signal.addEventListener("abort", stop, { once: true });
|
|
1238
|
-
promise.then(
|
|
1239
|
-
(value) => {
|
|
1240
|
-
signal.removeEventListener("abort", stop);
|
|
1241
|
-
resolve(value);
|
|
1242
|
-
},
|
|
1243
|
-
(error) => {
|
|
1244
|
-
signal.removeEventListener("abort", stop);
|
|
1245
|
-
reject(error);
|
|
1246
|
-
},
|
|
1247
|
-
);
|
|
1248
|
-
});
|
|
1249
|
-
}
|
|
1250
|
-
|
|
1251
|
-
/** A host name without an IPv6 address's brackets. */
|
|
1252
|
-
function bare(host: string): string {
|
|
1253
|
-
return host.replace(/^\[(.*)\]$/, "$1");
|
|
1254
|
-
}
|
|
1255
|
-
|
|
1256
|
-
function checkPort(url: URL): void {
|
|
1257
|
-
const port = Number(url.port || (url.protocol === "https:" ? 443 : 80));
|
|
1258
|
-
if (badPort(port)) {
|
|
1259
|
-
throw new Refused(`port ${port} is not for HTTP`);
|
|
1260
|
-
}
|
|
1261
|
-
}
|
|
1262
|
-
|
|
1263
|
-
/** Where a redirect goes, if `response` is one, to http or https; one elsewhere is refused. */
|
|
1264
|
-
function redirectOf(url: URL, response: NetResponse): URL | null {
|
|
1265
|
-
if (!REDIRECTS.has(response.status)) {
|
|
1266
|
-
return null;
|
|
1267
|
-
}
|
|
1268
|
-
|
|
1269
|
-
const location = response.headers.find(([n]) => n.toLowerCase() === "location")?.[1];
|
|
1270
|
-
if (location === undefined) {
|
|
1271
|
-
return null;
|
|
1272
|
-
}
|
|
1273
|
-
|
|
1274
|
-
let next: URL;
|
|
1275
|
-
try {
|
|
1276
|
-
next = new URL(location, url);
|
|
1277
|
-
} catch {
|
|
1278
|
-
throw new Refused("a redirect to no URL");
|
|
1279
|
-
}
|
|
1280
|
-
|
|
1281
|
-
if (next.protocol !== "http:" && next.protocol !== "https:") {
|
|
1282
|
-
throw new Refused(`a redirect to ${next.protocol}`);
|
|
1283
|
-
}
|
|
1284
|
-
|
|
1285
|
-
if (next.username || next.password) {
|
|
1286
|
-
throw new Refused("a redirect to a URL with credentials");
|
|
1287
|
-
}
|
|
1288
|
-
|
|
1289
|
-
next.hash = "";
|
|
1290
|
-
return next;
|
|
1291
|
-
}
|
|
1292
|
-
|
|
1293
|
-
/** `key`'s value in `cache`, made once with `make`; a cache grown past its bound starts again. */
|
|
1294
|
-
function cached<T>(
|
|
1295
|
-
cache: Map<string, Promise<T>>,
|
|
1296
|
-
key: string,
|
|
1297
|
-
make: () => Promise<T>,
|
|
1298
|
-
): Promise<T> {
|
|
1299
|
-
let value = cache.get(key);
|
|
1300
|
-
if (!value) {
|
|
1301
|
-
if (cache.size >= 512) {
|
|
1302
|
-
cache.clear();
|
|
1303
|
-
}
|
|
1304
|
-
|
|
1305
|
-
value = make();
|
|
1306
|
-
cache.set(key, value);
|
|
1307
|
-
// A failure, a proxy's, is not kept: the next request asks again.
|
|
1308
|
-
const made = value;
|
|
1309
|
-
made.catch(() => {
|
|
1310
|
-
if (cache.get(key) === made) {
|
|
1311
|
-
cache.delete(key);
|
|
1312
|
-
}
|
|
1313
|
-
});
|
|
1314
|
-
}
|
|
1315
|
-
|
|
1316
|
-
return value;
|
|
1317
|
-
}
|
|
1318
|
-
|
|
1319
|
-
/** Whether `error` is a refusal for a proxy that failed, not a server it did not reach. */
|
|
1320
|
-
function proxyFault(error: unknown): boolean {
|
|
1321
|
-
const cause = (error as Error | null)?.cause;
|
|
1322
|
-
return cause instanceof ProxyError && cause.fault;
|
|
1323
|
-
}
|
|
1324
|
-
|
|
1325
|
-
function describe(requester: Requester): string {
|
|
1326
|
-
return requester.host === null ? 'a local SWF (domain="*")' : requester.host;
|
|
1327
|
-
}
|
package/src/main/open.ts
ADDED
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
// What plays, and opening it: a SWF by its path or its URL, or an AIR
|
|
2
|
+
// application by its descriptor, from the command line, the menu, a
|
|
3
|
+
// dialog or a drop; and closing it. Only this module changes what plays;
|
|
4
|
+
// the others read it.
|
|
5
|
+
import { statSync } from "node:fs";
|
|
6
|
+
import { open as openFile, realpath } from "node:fs/promises";
|
|
7
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
8
|
+
import { fileAttributes } from "@swf2es/format";
|
|
9
|
+
import { app, dialog, safeStorage } from "electron";
|
|
10
|
+
import type { OpenedMovie } from "../shared/api.js";
|
|
11
|
+
import { type AirCommand, airLaunch, EncryptedItems } from "./air.ts";
|
|
12
|
+
import { refuse } from "./dialogs.ts";
|
|
13
|
+
import { buildMenu } from "./menu.ts";
|
|
14
|
+
import type { Movie } from "./network-types.ts";
|
|
15
|
+
import { listening, sandbox, settings, TITLE, window } from "./state.ts";
|
|
16
|
+
import { network } from "./switches.ts";
|
|
17
|
+
|
|
18
|
+
/** What the network judges the movie playing as; null for none, or one without networking. */
|
|
19
|
+
export let networkMovie: Movie | null = null;
|
|
20
|
+
export let current: { path: string; movie: OpenedMovie } | null = null;
|
|
21
|
+
/** The servers the SWF playing was allowed, or denied, a socket to until it closes: "host:port". */
|
|
22
|
+
export let allowedOnce = new Set<string>();
|
|
23
|
+
export let deniedOnce = new Set<string>();
|
|
24
|
+
|
|
25
|
+
/** The longest URL a SWF is opened by. */
|
|
26
|
+
const MAX_URL = 8192;
|
|
27
|
+
/** The AIR application playing, as its command line asked for it; null for none. */
|
|
28
|
+
export let air: AirCommand | null = null;
|
|
29
|
+
/** The EncryptedLocalStore of the AIR application playing, if one is. */
|
|
30
|
+
export let encryptedItems: EncryptedItems | null = null;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* `text` as the URL of a remote SWF: http or https, with a host and no
|
|
34
|
+
* credentials, its fragment dropped; null if it is not one.
|
|
35
|
+
*/
|
|
36
|
+
export function remoteUrl(text: string): URL | null {
|
|
37
|
+
if (text.length > MAX_URL) {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
let url: URL;
|
|
42
|
+
try {
|
|
43
|
+
url = new URL(text.trim());
|
|
44
|
+
} catch {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if ((url.protocol !== "http:" && url.protocol !== "https:") || !url.hostname) {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (url.username || url.password) {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
url.hash = "";
|
|
57
|
+
return url;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The SWF named on a command line, from `cwd`: the last argument after the
|
|
62
|
+
* app's own that is an http or https URL or names a .swf file there is.
|
|
63
|
+
* Chromium's and Electron's switches may take values of their own
|
|
64
|
+
* (`--switch value`), so the first argument that is not a switch could be one.
|
|
65
|
+
*/
|
|
66
|
+
export function movieFromArguments(argv: string[], cwd = process.cwd()): string | null {
|
|
67
|
+
const named = argv
|
|
68
|
+
.slice(process.defaultApp ? 2 : 1)
|
|
69
|
+
.filter((a) => !a.startsWith("-"))
|
|
70
|
+
.map((a) => remoteUrl(a)?.href ?? (/\.swf$/i.test(a) ? resolve(cwd, a) : null))
|
|
71
|
+
.filter((a) => a !== null);
|
|
72
|
+
return (
|
|
73
|
+
named.reverse().find((a) => remoteUrl(a) || statSync(a, { throwIfNoEntry: false })?.isFile()) ??
|
|
74
|
+
null
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* How much of a SWF the main process reads to choose its sandbox: far more than FileAttributes
|
|
80
|
+
* takes compressed.
|
|
81
|
+
*/
|
|
82
|
+
const START = 64 * 1024;
|
|
83
|
+
const USE_NETWORK = 0x01;
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* The first `length` bytes of the file at `path`, or as many as it has, at least `least`; null
|
|
87
|
+
* otherwise.
|
|
88
|
+
*/
|
|
89
|
+
export async function readStart(
|
|
90
|
+
path: string,
|
|
91
|
+
length: number,
|
|
92
|
+
least = 8,
|
|
93
|
+
): Promise<Uint8Array | null> {
|
|
94
|
+
try {
|
|
95
|
+
const file = await openFile(path);
|
|
96
|
+
try {
|
|
97
|
+
const bytes = new Uint8Array(length);
|
|
98
|
+
const { bytesRead } = await file.read(bytes, 0, length, 0);
|
|
99
|
+
return bytesRead >= Math.min(least, length) ? bytes.subarray(0, bytesRead) : null;
|
|
100
|
+
} finally {
|
|
101
|
+
await file.close();
|
|
102
|
+
}
|
|
103
|
+
} catch {
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Open what the recent list, the command line or a reload names: a URL, a SWF's path or a descriptor's. */
|
|
109
|
+
export function openEntry(entry: string): Promise<void> {
|
|
110
|
+
if (remoteUrl(entry)) {
|
|
111
|
+
return openUrl(entry);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// A descriptor reopened from the recent list or a reload, with the arguments it was run with.
|
|
115
|
+
if (/\.xml$/i.test(entry)) {
|
|
116
|
+
return openAir(
|
|
117
|
+
air?.descriptor === entry ? air : { descriptor: entry, root: null, arguments: [] },
|
|
118
|
+
process.cwd(),
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return open(entry);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Run the AIR application `command` names, as adl does: its content SWF in
|
|
127
|
+
* the window, at the descriptor's size, in AIR's application sandbox, with
|
|
128
|
+
* its launch (shared/api.ts AirLaunch), from `cwd`.
|
|
129
|
+
*/
|
|
130
|
+
export async function openAir(command: AirCommand, cwd: string): Promise<void> {
|
|
131
|
+
const name = basename(command.descriptor);
|
|
132
|
+
let opened: ReturnType<typeof airLaunch>;
|
|
133
|
+
try {
|
|
134
|
+
opened = airLaunch(command, cwd, app.getPath("userData"), {
|
|
135
|
+
documents: app.getPath("documents"),
|
|
136
|
+
desktop: app.getPath("desktop"),
|
|
137
|
+
cache: app.getPath("temp"),
|
|
138
|
+
});
|
|
139
|
+
} catch (error) {
|
|
140
|
+
refuse(`${name} could not be opened.`, String(error));
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const head = await readStart(opened.content, START);
|
|
145
|
+
const signature = head ? String.fromCharCode(...head.subarray(0, 3)) : "";
|
|
146
|
+
if (!head || (signature !== "FWS" && signature !== "CWS" && signature !== "ZWS")) {
|
|
147
|
+
refuse(`${name}'s content is not a SWF.`, opened.content);
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const content = await realpath(opened.content);
|
|
152
|
+
sandbox.playApplication(content);
|
|
153
|
+
air = command;
|
|
154
|
+
// Beside its storage directory, by its id, as AIR keeps it; encrypted by the system's keychain where it has one.
|
|
155
|
+
encryptedItems = new EncryptedItems(
|
|
156
|
+
join(dirname(opened.launch.directories.applicationStorage), "EncryptedLocalStore"),
|
|
157
|
+
safeStorage.isEncryptionAvailable()
|
|
158
|
+
? {
|
|
159
|
+
encrypt: (text) => new Uint8Array(safeStorage.encryptString(text)),
|
|
160
|
+
decrypt: (data) => safeStorage.decryptString(Buffer.from(data)),
|
|
161
|
+
}
|
|
162
|
+
: null,
|
|
163
|
+
);
|
|
164
|
+
if (window && opened.width && opened.height) {
|
|
165
|
+
window.setSize(opened.width, opened.height);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Played from app:/, as AIR plays it: its relative URLs, and loaderInfo.url, are the application's.
|
|
169
|
+
const url = `app:/${opened.relative
|
|
170
|
+
.split("/")
|
|
171
|
+
.filter((s) => s !== "")
|
|
172
|
+
.map(encodeURIComponent)
|
|
173
|
+
.join("/")}`;
|
|
174
|
+
show(
|
|
175
|
+
command.descriptor,
|
|
176
|
+
{ url, name: opened.title, sandbox: "application", air: opened.launch },
|
|
177
|
+
{ kind: "application" },
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Play `movie` in the window, in place of what plays: what the user opened, as `entry`. */
|
|
182
|
+
function show(entry: string, movie: OpenedMovie, onNetwork: Movie | null): void {
|
|
183
|
+
current = { path: entry, movie };
|
|
184
|
+
playOnNetwork(onNetwork);
|
|
185
|
+
allowedOnce = new Set();
|
|
186
|
+
deniedOnce = new Set();
|
|
187
|
+
settings.opened(entry);
|
|
188
|
+
buildMenu();
|
|
189
|
+
if (window) {
|
|
190
|
+
// An AIR application's window is its own: its title alone, as adl shows it.
|
|
191
|
+
window.setTitle(movie.air ? movie.name : `${movie.name} — ${TITLE}`);
|
|
192
|
+
if (listening) {
|
|
193
|
+
window.webContents.send("desktop:open", movie);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Play the SWF at `text`, an http or https URL, in Flash's remote sandbox:
|
|
200
|
+
* no local file, and the network as its origin's (network.ts). The page
|
|
201
|
+
* fetches it through the main process like anything it loads.
|
|
202
|
+
*/
|
|
203
|
+
export async function openUrl(text: string): Promise<void> {
|
|
204
|
+
const url = remoteUrl(text);
|
|
205
|
+
if (!url) {
|
|
206
|
+
refuse("Only an http or https URL opens.", text.slice(0, 200));
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const last = url.pathname.split("/").pop() ?? "";
|
|
211
|
+
let name: string;
|
|
212
|
+
try {
|
|
213
|
+
name = decodeURIComponent(last) || url.host;
|
|
214
|
+
} catch {
|
|
215
|
+
name = last;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
air = null;
|
|
219
|
+
sandbox.playRemote(url.href);
|
|
220
|
+
show(url.href, { url: url.href, name, sandbox: "remote" }, { kind: "remote", url: url.href });
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Open the file the user chose in the dialog, dropped on the window or
|
|
225
|
+
* opened with the app: an AIR application's descriptor (`.xml`) runs the
|
|
226
|
+
* application, anything else is played as a SWF.
|
|
227
|
+
*/
|
|
228
|
+
export async function openChosen(path: string): Promise<void> {
|
|
229
|
+
if (!/\.xml$/i.test(path)) {
|
|
230
|
+
return open(path);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// An application has an installed program's reach, which a file chosen in
|
|
234
|
+
// passing should not get unasked: asked once, until it leaves the recent list.
|
|
235
|
+
if (window && !settings.settings.recent.includes(path)) {
|
|
236
|
+
const { response } = await dialog.showMessageBox(window, {
|
|
237
|
+
type: "question",
|
|
238
|
+
message: `Run ${basename(path)} as an AIR application?`,
|
|
239
|
+
detail:
|
|
240
|
+
"An AIR application reads and writes your files and reaches the network unasked, as an installed program does. Run only one you trust.",
|
|
241
|
+
buttons: ["Run", "Cancel"],
|
|
242
|
+
defaultId: 1,
|
|
243
|
+
cancelId: 1,
|
|
244
|
+
});
|
|
245
|
+
if (response !== 0) {
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// Run from its own directory, with no arguments, as one started by its icon is.
|
|
251
|
+
return openAir({ descriptor: path, root: null, arguments: [] }, dirname(path));
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** Play the SWF at `path` in the window, in place of what plays, in its sandbox. */
|
|
255
|
+
async function open(path: string): Promise<void> {
|
|
256
|
+
const name = basename(path);
|
|
257
|
+
// Its start alone: the main process never decompresses a SWF whole, whose
|
|
258
|
+
// header may name 4 GB. FileAttributes, the first tag, lies in it.
|
|
259
|
+
const head = await readStart(path, START);
|
|
260
|
+
const signature = head ? String.fromCharCode(...head.subarray(0, 3)) : "";
|
|
261
|
+
if (!head || (signature !== "FWS" && signature !== "CWS" && signature !== "ZWS")) {
|
|
262
|
+
refuse(`${name} is not a SWF.`, path);
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const useNetwork = (fileAttributes(head) & USE_NETWORK) !== 0;
|
|
267
|
+
|
|
268
|
+
let url: string;
|
|
269
|
+
try {
|
|
270
|
+
air = null;
|
|
271
|
+
url = sandbox.play(path, useNetwork);
|
|
272
|
+
} catch (error) {
|
|
273
|
+
// Gone, or unreadable, since it was read.
|
|
274
|
+
refuse(`${name} could not be opened.`, String(error));
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
app.addRecentDocument(path);
|
|
279
|
+
show(
|
|
280
|
+
path,
|
|
281
|
+
{ url, name, sandbox: useNetwork ? "localWithNetwork" : "localWithFile" },
|
|
282
|
+
useNetwork ? { kind: "local" } : null,
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/** Judge requests for `movie` from now on: the last one's are aborted. */
|
|
287
|
+
function playOnNetwork(movie: Movie | null): void {
|
|
288
|
+
networkMovie = movie;
|
|
289
|
+
network.play(movie);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
export function closeMovie(): void {
|
|
293
|
+
current = null;
|
|
294
|
+
air = null;
|
|
295
|
+
sandbox.stop();
|
|
296
|
+
playOnNetwork(null);
|
|
297
|
+
buildMenu();
|
|
298
|
+
window?.setTitle(TITLE);
|
|
299
|
+
if (listening) {
|
|
300
|
+
window?.webContents.send("desktop:close");
|
|
301
|
+
}
|
|
302
|
+
}
|