@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.
Files changed (132) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +135 -2
  3. package/dist/launch/electron.d.ts +14 -0
  4. package/dist/launch/electron.d.ts.map +1 -0
  5. package/dist/launch/electron.js +40 -0
  6. package/dist/launch/electron.js.map +1 -0
  7. package/dist/launch/launch.d.ts +3 -0
  8. package/dist/launch/launch.d.ts.map +1 -0
  9. package/dist/launch/launch.js +69 -0
  10. package/dist/launch/launch.js.map +1 -0
  11. package/dist/main/addresses.d.ts +7 -0
  12. package/dist/main/addresses.d.ts.map +1 -0
  13. package/dist/main/addresses.js +155 -0
  14. package/dist/main/addresses.js.map +1 -0
  15. package/dist/main/air.d.ts +71 -0
  16. package/dist/main/air.d.ts.map +1 -0
  17. package/dist/main/air.js +216 -0
  18. package/dist/main/air.js.map +1 -0
  19. package/dist/main/connections.d.ts +38 -0
  20. package/dist/main/connections.d.ts.map +1 -0
  21. package/dist/main/connections.js +103 -0
  22. package/dist/main/connections.js.map +1 -0
  23. package/dist/main/decompress.d.ts +51 -0
  24. package/dist/main/decompress.d.ts.map +1 -0
  25. package/dist/main/decompress.js +184 -0
  26. package/dist/main/decompress.js.map +1 -0
  27. package/dist/main/files.d.ts +9 -0
  28. package/dist/main/files.d.ts.map +1 -0
  29. package/dist/main/files.js +120 -0
  30. package/dist/main/files.js.map +1 -0
  31. package/dist/main/main.d.ts +2 -0
  32. package/dist/main/main.d.ts.map +1 -0
  33. package/dist/main/main.js +1009 -0
  34. package/dist/main/main.js.map +1 -0
  35. package/dist/main/network.d.ts +182 -0
  36. package/dist/main/network.d.ts.map +1 -0
  37. package/dist/main/network.js +909 -0
  38. package/dist/main/network.js.map +1 -0
  39. package/dist/main/policy.d.ts +103 -0
  40. package/dist/main/policy.d.ts.map +1 -0
  41. package/dist/main/policy.js +481 -0
  42. package/dist/main/policy.js.map +1 -0
  43. package/dist/main/protocol.d.ts +14 -0
  44. package/dist/main/protocol.d.ts.map +1 -0
  45. package/dist/main/protocol.js +216 -0
  46. package/dist/main/protocol.js.map +1 -0
  47. package/dist/main/sandbox.d.ts +67 -0
  48. package/dist/main/sandbox.d.ts.map +1 -0
  49. package/dist/main/sandbox.js +182 -0
  50. package/dist/main/sandbox.js.map +1 -0
  51. package/dist/main/settings.d.ts +37 -0
  52. package/dist/main/settings.d.ts.map +1 -0
  53. package/dist/main/settings.js +88 -0
  54. package/dist/main/settings.js.map +1 -0
  55. package/dist/main/sockets.d.ts +10 -0
  56. package/dist/main/sockets.d.ts.map +1 -0
  57. package/dist/main/sockets.js +71 -0
  58. package/dist/main/sockets.js.map +1 -0
  59. package/dist/preload/preload.cjs +91 -0
  60. package/dist/preload/preload.cjs.map +1 -0
  61. package/dist/preload/preload.d.cts +2 -0
  62. package/dist/preload/preload.d.cts.map +1 -0
  63. package/dist/preload/prompt.cjs +12 -0
  64. package/dist/preload/prompt.cjs.map +1 -0
  65. package/dist/preload/prompt.d.cts +2 -0
  66. package/dist/preload/prompt.d.cts.map +1 -0
  67. package/dist/renderer/air.d.ts +14 -0
  68. package/dist/renderer/air.d.ts.map +1 -0
  69. package/dist/renderer/air.js +83 -0
  70. package/dist/renderer/air.js.map +1 -0
  71. package/dist/renderer/files.d.ts +4 -0
  72. package/dist/renderer/files.d.ts.map +1 -0
  73. package/dist/renderer/files.js +58 -0
  74. package/dist/renderer/files.js.map +1 -0
  75. package/dist/renderer/index.d.ts +7 -0
  76. package/dist/renderer/index.d.ts.map +1 -0
  77. package/dist/renderer/index.js +126 -0
  78. package/dist/renderer/index.js.map +1 -0
  79. package/dist/renderer/network.d.ts +6 -0
  80. package/dist/renderer/network.d.ts.map +1 -0
  81. package/dist/renderer/network.js +39 -0
  82. package/dist/renderer/network.js.map +1 -0
  83. package/dist/renderer/open-url.d.ts +10 -0
  84. package/dist/renderer/open-url.d.ts.map +1 -0
  85. package/dist/renderer/open-url.js +38 -0
  86. package/dist/renderer/open-url.js.map +1 -0
  87. package/dist/renderer/sockets.d.ts +4 -0
  88. package/dist/renderer/sockets.d.ts.map +1 -0
  89. package/dist/renderer/sockets.js +36 -0
  90. package/dist/renderer/sockets.js.map +1 -0
  91. package/dist/renderer/stage3d.d.ts +3 -0
  92. package/dist/renderer/stage3d.d.ts.map +1 -0
  93. package/dist/renderer/stage3d.js +26 -0
  94. package/dist/renderer/stage3d.js.map +1 -0
  95. package/dist/shared/api.d.ts +205 -0
  96. package/dist/shared/api.d.ts.map +1 -0
  97. package/dist/shared/api.js +5 -0
  98. package/dist/shared/api.js.map +1 -0
  99. package/package.json +46 -3
  100. package/src/launch/electron.ts +44 -0
  101. package/src/launch/launch.ts +72 -0
  102. package/src/launch/tsconfig.json +14 -0
  103. package/src/main/addresses.ts +184 -0
  104. package/src/main/air.ts +275 -0
  105. package/src/main/connections.ts +125 -0
  106. package/src/main/decompress.ts +219 -0
  107. package/src/main/files.ts +147 -0
  108. package/src/main/main.ts +1180 -0
  109. package/src/main/network.ts +1205 -0
  110. package/src/main/policy.ts +630 -0
  111. package/src/main/protocol.ts +237 -0
  112. package/src/main/sandbox.ts +213 -0
  113. package/src/main/settings.ts +117 -0
  114. package/src/main/sockets.ts +81 -0
  115. package/src/main/tsconfig.json +15 -0
  116. package/src/preload/preload.cts +110 -0
  117. package/src/preload/prompt.cts +11 -0
  118. package/src/preload/tsconfig.json +12 -0
  119. package/src/renderer/air.ts +100 -0
  120. package/src/renderer/files.ts +65 -0
  121. package/src/renderer/index.ts +145 -0
  122. package/src/renderer/network.ts +56 -0
  123. package/src/renderer/open-url.ts +50 -0
  124. package/src/renderer/sockets.ts +42 -0
  125. package/src/renderer/stage3d.ts +27 -0
  126. package/src/renderer/tsconfig.json +17 -0
  127. package/src/shared/api.ts +204 -0
  128. package/src/shared/tsconfig.json +11 -0
  129. package/static/icon.png +0 -0
  130. package/static/index.html +22 -0
  131. package/static/open-url.html +21 -0
  132. package/static/style.css +96 -0
@@ -0,0 +1,44 @@
1
+ // Where Electron and the built app are, for launch.ts and the tests: from the
2
+ // checkout's src/launch or the package's dist/launch, the app two folders up.
3
+ import { existsSync, readFileSync } from "node:fs";
4
+ import { createRequire } from "node:module";
5
+ import { dirname, join } from "node:path";
6
+ import { fileURLToPath } from "node:url";
7
+
8
+ /** The app's folder: package.json, dist/ and static/. */
9
+ export const appRoot = fileURLToPath(new URL("../../", import.meta.url));
10
+
11
+ /**
12
+ * The Electron binary, or null where `pnpm install` skipped its download.
13
+ * Read from the package's path.txt and dist/, not by requiring it: its
14
+ * index.js downloads the binary when it is missing, which would make a
15
+ * check for it the download it is meant to avoid.
16
+ */
17
+ export function electronBinary(): string | null {
18
+ try {
19
+ const root = electronPackage();
20
+ const name = readFileSync(join(root, "path.txt"), "utf8");
21
+ const path = join(root, "dist", name);
22
+ return existsSync(path) ? path : null;
23
+ } catch {
24
+ return null;
25
+ }
26
+ }
27
+
28
+ /** The electron package's folder; throws where it is not installed. */
29
+ export function electronPackage(): string {
30
+ return dirname(createRequire(import.meta.url).resolve("electron/package.json"));
31
+ }
32
+
33
+ /** Why the app cannot start yet, or null. */
34
+ export function missing(): string | null {
35
+ if (!electronBinary()) {
36
+ return "Electron is not downloaded: run `pnpm --filter @swf2es/desktop fetch-electron` once.";
37
+ }
38
+
39
+ if (!existsSync(`${appRoot}dist/main/main.js`)) {
40
+ return "The app is not built: run `pnpm build`.";
41
+ }
42
+
43
+ return null;
44
+ }
@@ -0,0 +1,72 @@
1
+ #!/usr/bin/env node
2
+ // Starts the desktop app in Electron, with a SWF if one is named: the
3
+ // swf2es-desktop command of the package npm installs, and a checkout's
4
+ // `start`.
5
+ //
6
+ // npx @swf2es/desktop [--trace] [file.swf | https://host/movie.swf]
7
+ // npx @swf2es/desktop [--trace] app.xml [root-dir] [-- arguments...]
8
+ // pnpm --filter @swf2es/desktop start ... the same, in a checkout
9
+ //
10
+ // A relative path is the caller's: from the directory the command was run
11
+ // in, which pnpm and npm name in INIT_CWD where they change it.
12
+ import { execFileSync, spawn, spawnSync } from "node:child_process";
13
+ import { existsSync, readFileSync } from "node:fs";
14
+ import { join, resolve } from "node:path";
15
+ import { appRoot, electronBinary, electronPackage, missing } from "./electron.ts";
16
+
17
+ // Electron's binary comes by its package's install script, which a package manager may not
18
+ // have run: pnpm runs none it was not told to, and a checkout's install skips this one. Then
19
+ // it is fetched now, once, as that script fetches it.
20
+ if (!electronBinary()) {
21
+ let install = "";
22
+ try {
23
+ install = join(electronPackage(), "install.js");
24
+ } catch {
25
+ // Not installed at all: `missing` says so.
26
+ }
27
+
28
+ if (install && existsSync(install)) {
29
+ console.error("swf2es: downloading Electron, once...");
30
+ spawnSync(process.execPath, [install], { stdio: "inherit" });
31
+ }
32
+ }
33
+
34
+ const why = missing();
35
+ if (why) {
36
+ console.error(why);
37
+ process.exit(1);
38
+ }
39
+
40
+ const cwd = process.env.INIT_CWD ?? process.cwd();
41
+ // Switches and URLs as they are; a path from where the command was run. What follows "--" is
42
+ // an AIR application's own arguments, as they are.
43
+ const given = process.argv.slice(2);
44
+ const end = given.indexOf("--");
45
+ const own = end < 0 ? given : given.slice(0, end);
46
+ const args = [
47
+ ...own.map((a) => (a.startsWith("-") || /^https?:\/\//i.test(a) ? a : resolve(cwd, a))),
48
+ ...(end < 0 ? [] : given.slice(end)),
49
+ ];
50
+ // What the context menu's About item names: a checkout's commit, else the package's version.
51
+ let build = "";
52
+ try {
53
+ build = execFileSync("git", ["rev-parse", "--short=12", "HEAD"], {
54
+ cwd: appRoot,
55
+ encoding: "utf8",
56
+ stdio: ["ignore", "pipe", "ignore"],
57
+ }).trim();
58
+ } catch {
59
+ // Not a checkout, or no git.
60
+ }
61
+
62
+ // An installed package may sit inside some other project's checkout: its commit is not the app's.
63
+ if (!build || appRoot.includes(`${join("node_modules", "@swf2es")}`)) {
64
+ build = JSON.parse(readFileSync(join(appRoot, "package.json"), "utf8")).version;
65
+ }
66
+
67
+ const env = { ...process.env, SWF2ES_DESKTOP_BUILD: build };
68
+ const child = spawn(electronBinary() as string, [appRoot, ...args], { stdio: "inherit", env });
69
+ child.on("exit", (code, signal) => process.exit(signal ? 1 : (code ?? 0)));
70
+ for (const signal of ["SIGINT", "SIGTERM"] as const) {
71
+ process.on(signal, () => child.kill(signal));
72
+ }
@@ -0,0 +1,14 @@
1
+ {
2
+ "extends": "../../../../tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "rootDir": ".",
5
+ "outDir": "../../dist/launch",
6
+ "tsBuildInfoFile": "../../dist/launch/.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
+ }
@@ -0,0 +1,184 @@
1
+ // Where an IP address is, for the network the desktop app reaches for a
2
+ // SWF: on the internet, on a private network (a LAN, a link, a cloud's
3
+ // metadata service at 169.254.169.254), or on this machine. A SWF reaches
4
+ // a place more private than the one it came from only as that place's own
5
+ // policy file allows, as Chromium's Private Network Access asks a server
6
+ // there before a public page may. Some addresses no request may reach.
7
+ // Pure, so that node tests it (tests/desktop/network.test.ts).
8
+ import { isIPv4, isIPv6 } from "node:net";
9
+
10
+ export type AddressClass = "public" | "private" | "loopback";
11
+
12
+ /** How private a class is: a request to a higher rank than its SWF's needs the target's word. */
13
+ export const RANK: Record<AddressClass, number> = { public: 0, private: 1, loopback: 2 };
14
+
15
+ /** An IPv4 address as a number, or null. */
16
+ function ipv4(text: string): number | null {
17
+ if (!isIPv4(text)) {
18
+ return null;
19
+ }
20
+
21
+ return text.split(".").reduce((n, part) => n * 256 + Number(part), 0);
22
+ }
23
+
24
+ const inV4 = (n: number, base: string, bits: number): boolean => {
25
+ const mask = bits === 0 ? 0 : (0xffffffff << (32 - bits)) >>> 0;
26
+ return (n & mask) >>> 0 === ((ipv4(base) as number) & mask) >>> 0;
27
+ };
28
+
29
+ function classOfV4(n: number): AddressClass | null {
30
+ // This network, multicast, and the reserved block with broadcast: never a server.
31
+ if (inV4(n, "0.0.0.0", 8) || inV4(n, "224.0.0.0", 4) || inV4(n, "240.0.0.0", 4)) {
32
+ return null;
33
+ }
34
+
35
+ if (inV4(n, "127.0.0.0", 8)) {
36
+ return "loopback";
37
+ }
38
+
39
+ const special: [string, number][] = [
40
+ ["10.0.0.0", 8],
41
+ // Carrier-grade NAT.
42
+ ["100.64.0.0", 10],
43
+ // Link-local, where clouds keep their metadata service.
44
+ ["169.254.0.0", 16],
45
+ ["172.16.0.0", 12],
46
+ ["192.0.0.0", 24],
47
+ // The documentation and benchmarking blocks, never on the internet.
48
+ ["192.0.2.0", 24],
49
+ ["192.168.0.0", 16],
50
+ ["198.18.0.0", 15],
51
+ ["198.51.100.0", 24],
52
+ ["203.0.113.0", 24],
53
+ ];
54
+ return special.some(([base, bits]) => inV4(n, base, bits)) ? "private" : "public";
55
+ }
56
+
57
+ /** An IPv6 address's 16 bytes, its zone dropped; null if it is not one. */
58
+ function ipv6(text: string): Uint8Array | null {
59
+ const address = text.replace(/%.*$/, "");
60
+ if (!isIPv6(address)) {
61
+ return null;
62
+ }
63
+
64
+ let head = address;
65
+ let tail = "";
66
+ const bytes = new Uint8Array(16);
67
+ // A dotted IPv4 tail fills the last four bytes.
68
+ const dotted = /^(.*:)(\d+\.\d+\.\d+\.\d+)$/.exec(address);
69
+ let v4: number | null = null;
70
+ if (dotted) {
71
+ v4 = ipv4(dotted[2]);
72
+ head = `${dotted[1]}0:0`;
73
+ }
74
+
75
+ const halves = head.split("::");
76
+ [head, tail] = [halves[0], halves[1] ?? ""];
77
+ const groups = (part: string) => (part === "" ? [] : part.split(":"));
78
+ const front = groups(head);
79
+ const back = groups(tail);
80
+ const all = [...front, ...Array(8 - front.length - back.length).fill("0"), ...back];
81
+ all.forEach((group, i) => {
82
+ const value = Number.parseInt(group, 16);
83
+ bytes[i * 2] = value >> 8;
84
+ bytes[i * 2 + 1] = value & 0xff;
85
+ });
86
+ if (v4 !== null) {
87
+ bytes.set([v4 >>> 24, (v4 >>> 16) & 0xff, (v4 >>> 8) & 0xff, v4 & 0xff], 12);
88
+ }
89
+
90
+ return bytes;
91
+ }
92
+
93
+ const v4At = (bytes: Uint8Array, at: number): number =>
94
+ ((bytes[at] << 24) | (bytes[at + 1] << 16) | (bytes[at + 2] << 8) | bytes[at + 3]) >>> 0;
95
+
96
+ function startsWith(bytes: Uint8Array, prefix: number[], bits: number): boolean {
97
+ for (let bit = 0; bit < bits; bit++) {
98
+ const byte = bit >> 3;
99
+ const mask = 0x80 >> (bit & 7);
100
+ if ((bytes[byte] & mask) !== ((prefix[byte] ?? 0) & mask)) {
101
+ return false;
102
+ }
103
+ }
104
+
105
+ return true;
106
+ }
107
+
108
+ function classOfV6(bytes: Uint8Array): AddressClass | null {
109
+ const zero = (from: number, to: number) => bytes.subarray(from, to).every((b) => b === 0);
110
+ if (zero(0, 16)) {
111
+ return null;
112
+ }
113
+
114
+ if (zero(0, 15) && bytes[15] === 1) {
115
+ return "loopback";
116
+ }
117
+
118
+ // An IPv4 address within, mapped, translated (RFC 2765's ::ffff:0:a.b.c.d),
119
+ // compatible, by NAT64 or by 6to4: that address's class.
120
+ if (zero(0, 10) && bytes[10] === 0xff && bytes[11] === 0xff) {
121
+ return classOfV4(v4At(bytes, 12));
122
+ }
123
+
124
+ if (zero(0, 8) && bytes[8] === 0xff && bytes[9] === 0xff && zero(10, 12)) {
125
+ return classOfV4(v4At(bytes, 12));
126
+ }
127
+
128
+ if (zero(0, 12)) {
129
+ return classOfV4(v4At(bytes, 12));
130
+ }
131
+
132
+ if (startsWith(bytes, [0, 0x64, 0xff, 0x9b], 96)) {
133
+ return classOfV4(v4At(bytes, 12));
134
+ }
135
+
136
+ if (startsWith(bytes, [0x20, 0x02], 16)) {
137
+ return classOfV4(v4At(bytes, 2));
138
+ }
139
+
140
+ // Multicast, and the discard block.
141
+ if (bytes[0] === 0xff || startsWith(bytes, [0x01, 0x00], 64)) {
142
+ return null;
143
+ }
144
+
145
+ const special: [number[], number][] = [
146
+ // Unique local, link-local and the old site-local.
147
+ [[0xfc], 7],
148
+ [[0xfe, 0x80], 10],
149
+ [[0xfe, 0xc0], 10],
150
+ // NAT64 for local use, Teredo, whose IPv4 address is hidden, and documentation.
151
+ [[0, 0x64, 0xff, 0x9b, 0, 1], 48],
152
+ [[0x20, 0x01, 0, 0], 32],
153
+ [[0x20, 0x01, 0x0d, 0xb8], 32],
154
+ ];
155
+ return special.some(([prefix, bits]) => startsWith(bytes, prefix, bits)) ? "private" : "public";
156
+ }
157
+
158
+ /** Where `address` is; null for one no request may reach, or for what is no IP address. */
159
+ export function addressClass(address: string): AddressClass | null {
160
+ const v4 = ipv4(address);
161
+ if (v4 !== null) {
162
+ return classOfV4(v4);
163
+ }
164
+
165
+ const v6 = ipv6(address.replace(/^\[(.*)\]$/, "$1"));
166
+ return v6 ? classOfV6(v6) : null;
167
+ }
168
+
169
+ /**
170
+ * The ports the Fetch standard blocks, as browsers do, so that a request
171
+ * cannot speak HTTP to a mail, shell or name server; Flash in a browser
172
+ * went through the browser's network and met the same list.
173
+ */
174
+ const BAD_PORTS = new Set([
175
+ 1, 7, 9, 11, 13, 15, 17, 19, 20, 21, 22, 23, 25, 37, 42, 43, 53, 69, 77, 79, 87, 95, 101, 102,
176
+ 103, 104, 109, 110, 111, 113, 115, 117, 119, 123, 135, 137, 139, 143, 161, 179, 389, 427, 465,
177
+ 512, 513, 514, 515, 526, 530, 531, 532, 540, 548, 554, 556, 563, 587, 601, 636, 989, 990, 993,
178
+ 995, 1719, 1720, 1723, 2049, 3659, 4045, 4190, 5060, 5061, 6000, 6566, 6665, 6666, 6667, 6668,
179
+ 6669, 6679, 6697, 10080,
180
+ ]);
181
+
182
+ export function badPort(port: number): boolean {
183
+ return BAD_PORTS.has(port);
184
+ }
@@ -0,0 +1,275 @@
1
+ // An AIR application opened by its descriptor, as adl runs one
2
+ // (`adl app.xml [root-dir] [-- arguments...]`): its launch, which the page
3
+ // plays it with, and the file system its File reads and writes, while it
4
+ // plays.
5
+ // Apart from Electron, so that node tests it (tests/desktop/air.test.ts).
6
+ import {
7
+ closeSync,
8
+ cpSync,
9
+ ftruncateSync,
10
+ lstatSync,
11
+ mkdirSync,
12
+ openSync,
13
+ readdirSync,
14
+ readFileSync,
15
+ renameSync,
16
+ rmSync,
17
+ statSync,
18
+ writeFileSync,
19
+ writeSync,
20
+ } from "node:fs";
21
+ import { homedir } from "node:os";
22
+ import { basename, dirname, join, resolve, sep } from "node:path";
23
+ import { readAirDescriptor } from "@swf2es/format";
24
+ import type { AirFileStat, AirLaunch } from "../shared/api.js";
25
+
26
+ /** What the command line asks adl's way: the descriptor, the application's directory, the arguments. */
27
+ export interface AirCommand {
28
+ descriptor: string;
29
+ root: string | null;
30
+ arguments: string[];
31
+ }
32
+
33
+ /**
34
+ * The AIR application `argv` names, from `cwd`: a descriptor (`.xml`), the
35
+ * directory its content is under if one follows, and what follows `--`.
36
+ */
37
+ export function airCommand(argv: string[], cwd: string): AirCommand | null {
38
+ const end = argv.indexOf("--");
39
+ const own = (end < 0 ? argv : argv.slice(0, end)).filter((a) => !a.startsWith("-"));
40
+ const at = own.findIndex((a) => /\.xml$/i.test(a));
41
+ if (at < 0) {
42
+ return null;
43
+ }
44
+
45
+ const next = own[at + 1];
46
+ const root = next && statSync(resolve(cwd, next), { throwIfNoEntry: false })?.isDirectory();
47
+ return {
48
+ descriptor: resolve(cwd, own[at]),
49
+ root: root ? resolve(cwd, next) : null,
50
+ arguments: end < 0 ? [] : argv.slice(end + 1),
51
+ };
52
+ }
53
+
54
+ /** What opening `command` plays: the content SWF's path, its launch, and its window's title and size. */
55
+ export function airLaunch(
56
+ command: AirCommand,
57
+ currentDirectory: string,
58
+ userData: string,
59
+ directories: { documents: string; desktop: string; cache: string },
60
+ ): {
61
+ content: string;
62
+ /** The content's path under the application's directory, "/"-separated, as its app:/ URL has it. */
63
+ relative: string;
64
+ launch: AirLaunch;
65
+ title: string;
66
+ width: number | null;
67
+ height: number | null;
68
+ } {
69
+ const xml = readFileSync(command.descriptor, "utf8");
70
+ const descriptor = readAirDescriptor(xml);
71
+ const application = command.root ?? dirname(command.descriptor);
72
+ // Each application's own, as AIR keeps one under the user's data by its id.
73
+ const applicationStorage = join(
74
+ userData,
75
+ "air",
76
+ descriptor.id.replace(/[^\w.-]/g, "_"),
77
+ "Local Store",
78
+ );
79
+ mkdirSync(applicationStorage, { recursive: true });
80
+ return {
81
+ content: resolve(application, ...descriptor.content.split("/")),
82
+ relative: descriptor.content,
83
+ launch: {
84
+ descriptor: xml,
85
+ id: descriptor.id,
86
+ arguments: command.arguments,
87
+ currentDirectory,
88
+ separator: sep,
89
+ directories: { application, applicationStorage, user: homedir(), ...directories },
90
+ },
91
+ title: descriptor.title ?? basename(descriptor.content),
92
+ width: descriptor.width,
93
+ height: descriptor.height,
94
+ };
95
+ }
96
+
97
+ /** What is at `path`, as File reads it; null where nothing is. */
98
+ export function statOf(path: string): AirFileStat | null {
99
+ try {
100
+ const s = statSync(path);
101
+ const link = lstatSync(path).isSymbolicLink();
102
+ return {
103
+ directory: s.isDirectory(),
104
+ size: s.isDirectory() ? 0 : s.size,
105
+ modified: s.mtimeMs,
106
+ created: s.birthtimeMs || s.ctimeMs,
107
+ // A dot file is hidden where Windows' attribute is not at hand, as on macOS and Linux.
108
+ hidden: basename(path).startsWith("."),
109
+ symbolicLink: link,
110
+ };
111
+ } catch {
112
+ return null;
113
+ }
114
+ }
115
+
116
+ /** The names in the directory at `path`; null where it is none. */
117
+ export function listOf(path: string): string[] | null {
118
+ try {
119
+ return readdirSync(path);
120
+ } catch {
121
+ return null;
122
+ }
123
+ }
124
+
125
+ /** The bytes of the file at `path`, as FileStream's open reads them at once; null where none can be read. */
126
+ export function readOf(path: string): Uint8Array | null {
127
+ try {
128
+ return new Uint8Array(readFileSync(path));
129
+ } catch {
130
+ return null;
131
+ }
132
+ }
133
+
134
+ /** How the main process encrypts what it keeps: Electron's safeStorage where the system offers it. */
135
+ export interface Cipher {
136
+ encrypt(text: string): Uint8Array;
137
+ decrypt(data: Uint8Array): string;
138
+ }
139
+
140
+ /**
141
+ * An AIR application's EncryptedLocalStore items, as AIR keeps them for
142
+ * it apart from its files: in one file at `path`, beside its storage
143
+ * directory, of the items' bytes by name, encrypted whole by `cipher`
144
+ * where one is given, as Electron's safeStorage encrypts by the system's
145
+ * keychain. Its first byte says which: "E" encrypted, "P" plain. Written
146
+ * whole as each item changes, through a file beside it renamed over it.
147
+ */
148
+ export class EncryptedItems {
149
+ private readonly path: string;
150
+ private readonly cipher: Cipher | null;
151
+ private readonly items: Map<string, Uint8Array>;
152
+
153
+ constructor(path: string, cipher: Cipher | null) {
154
+ this.path = path;
155
+ this.cipher = cipher;
156
+ this.items = this.load();
157
+ }
158
+
159
+ get(name: string): Uint8Array | null {
160
+ return this.items.get(name) ?? null;
161
+ }
162
+
163
+ set(name: string, bytes: Uint8Array): void {
164
+ this.items.set(name, new Uint8Array(bytes));
165
+ this.save();
166
+ }
167
+
168
+ remove(name: string): void {
169
+ if (this.items.delete(name)) {
170
+ this.save();
171
+ }
172
+ }
173
+
174
+ reset(): void {
175
+ this.items.clear();
176
+ this.save();
177
+ }
178
+
179
+ /** What the file holds; nothing where there is none, or it cannot be read, as by another keychain. */
180
+ private load(): Map<string, Uint8Array> {
181
+ const items = new Map<string, Uint8Array>();
182
+ let text: string;
183
+ try {
184
+ const data = readFileSync(this.path);
185
+ if (data[0] === 0x45 && this.cipher) {
186
+ text = this.cipher.decrypt(new Uint8Array(data.subarray(1)));
187
+ } else if (data[0] === 0x50) {
188
+ text = data.subarray(1).toString("utf8");
189
+ } else {
190
+ return items;
191
+ }
192
+
193
+ const saved = JSON.parse(text) as Record<string, string>;
194
+ for (const [name, base64] of Object.entries(saved)) {
195
+ items.set(name, new Uint8Array(Buffer.from(base64, "base64")));
196
+ }
197
+ } catch {
198
+ // None yet, or unreadable: an empty store, as AIR's after a reset.
199
+ }
200
+
201
+ return items;
202
+ }
203
+
204
+ private save(): void {
205
+ const saved: Record<string, string> = {};
206
+ for (const [name, bytes] of this.items) {
207
+ saved[name] = Buffer.from(bytes).toString("base64");
208
+ }
209
+
210
+ const text = JSON.stringify(saved);
211
+ const data = this.cipher
212
+ ? Buffer.concat([Buffer.from("E"), this.cipher.encrypt(text)])
213
+ : Buffer.concat([Buffer.from("P"), Buffer.from(text, "utf8")]);
214
+ mkdirSync(dirname(this.path), { recursive: true });
215
+ writeFileSync(`${this.path}.new`, data);
216
+ renameSync(`${this.path}.new`, this.path);
217
+ }
218
+ }
219
+
220
+ /**
221
+ * Writes `bytes` into the file at `path` from `offset`, which is then
222
+ * `length` long, made with its directories where it is not: FileStream's
223
+ * writes. False where it cannot be written.
224
+ */
225
+ export function writeOf(path: string, bytes: Uint8Array, offset: number, length: number): boolean {
226
+ return attempt(() => {
227
+ mkdirSync(dirname(path), { recursive: true });
228
+ // "a+" would only append; "r+" needs the file there, which "a" makes without emptying it.
229
+ closeSync(openSync(path, "a"));
230
+ const fd = openSync(path, "r+");
231
+ try {
232
+ writeSync(fd, bytes, 0, bytes.length, offset);
233
+ ftruncateSync(fd, length);
234
+ } finally {
235
+ closeSync(fd);
236
+ }
237
+ });
238
+ }
239
+
240
+ /** Makes the directory at `path` and those it is in; false where it cannot. */
241
+ export function createDirectoryOf(path: string): boolean {
242
+ return attempt(() => mkdirSync(path, { recursive: true }));
243
+ }
244
+
245
+ /** Removes the file or directory at `path`, with all in it; false where it cannot. */
246
+ export function removeOf(path: string): boolean {
247
+ return attempt(() => rmSync(path, { recursive: true }));
248
+ }
249
+
250
+ /** Moves what is at `from` to `to`, its directories made; false where it cannot. */
251
+ export function moveOf(from: string, to: string): boolean {
252
+ return attempt(() => {
253
+ mkdirSync(dirname(to), { recursive: true });
254
+ try {
255
+ renameSync(from, to);
256
+ } catch (e) {
257
+ // Onto another disk, which a rename cannot reach.
258
+ if ((e as NodeJS.ErrnoException).code !== "EXDEV") {
259
+ throw e;
260
+ }
261
+
262
+ cpSync(from, to, { recursive: true });
263
+ rmSync(from, { recursive: true });
264
+ }
265
+ });
266
+ }
267
+
268
+ function attempt(change: () => void): boolean {
269
+ try {
270
+ change();
271
+ return true;
272
+ } catch {
273
+ return false;
274
+ }
275
+ }