@akanjs/devkit 3.1.1-rc.5 → 3.1.1-rc.7
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/akanConfig/akanConfig.test.ts +13 -2
- package/akanConfig/akanConfig.ts +5 -5
- package/akanConfig/productionLockfile.test.ts +80 -0
- package/akanConfig/productionLockfile.ts +32 -0
- package/akanContext.ts +25 -0
- package/applicationBuildRunner.test.ts +41 -0
- package/applicationBuildRunner.ts +23 -3
- package/cloud/cloudApi.ts +4 -1
- package/cloud/cloudUpdates.ts +37 -1
- package/cloud/updatePackUpload.test.ts +38 -2
- package/cloud/updatePackUpload.ts +5 -1
- package/commandDecorators/command.ts +3 -14
- package/commandDecorators/commandDecorators.test.ts +1 -1
- package/commandDecorators/workspaceVersionCheck.test.ts +70 -0
- package/commandDecorators/workspaceVersionCheck.ts +50 -0
- package/mobile/desktopRelease.test.ts +361 -0
- package/mobile/desktopRelease.ts +270 -0
- package/mobile/desktopServerStage.ts +4 -2
- package/mobile/index.ts +2 -0
- package/mobile/nativeApp.ts +17 -0
- package/mobile/tarWriter.test.ts +89 -0
- package/mobile/tarWriter.ts +93 -0
- package/package.json +2 -2
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { createHash, generateKeyPairSync, sign } from "node:crypto";
|
|
3
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { AkanAppConfig } from "../akanConfig";
|
|
6
|
+
import { tempDirs } from "../testHelpers";
|
|
7
|
+
import type { DesktopArch } from "./desktopBin";
|
|
8
|
+
import { DesktopRelease, type SignatureTool } from "./desktopRelease";
|
|
9
|
+
import type { DesktopPlatform } from "./mobileTarget";
|
|
10
|
+
|
|
11
|
+
const makeDir = tempDirs("akan-desktop-release-");
|
|
12
|
+
const sha256 = (bytes: string | Uint8Array) => createHash("sha256").update(bytes).digest("hex");
|
|
13
|
+
|
|
14
|
+
const keyOf = () => {
|
|
15
|
+
const { publicKey, privateKey } = generateKeyPairSync("ed25519");
|
|
16
|
+
return {
|
|
17
|
+
publicKey: publicKey.export({ format: "der", type: "spki" }).subarray(12).toString("base64"),
|
|
18
|
+
sign: (bytes: string) => `${sign(null, Buffer.from(bytes), privateKey).toString("base64")}\n`,
|
|
19
|
+
};
|
|
20
|
+
};
|
|
21
|
+
const key = keyOf();
|
|
22
|
+
const appId = "com.akancad.app";
|
|
23
|
+
|
|
24
|
+
interface Published {
|
|
25
|
+
updatesDir: string;
|
|
26
|
+
dir: string;
|
|
27
|
+
tarSha: string;
|
|
28
|
+
delta: string;
|
|
29
|
+
build: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const publish = async ({
|
|
33
|
+
platform = "macos",
|
|
34
|
+
arch = "arm64",
|
|
35
|
+
channel = "main",
|
|
36
|
+
app = platform === "macos" ? "Akancad.app" : "akancad",
|
|
37
|
+
gz = "the release's gzip tar",
|
|
38
|
+
}: {
|
|
39
|
+
platform?: DesktopPlatform;
|
|
40
|
+
arch?: DesktopArch;
|
|
41
|
+
channel?: string;
|
|
42
|
+
app?: string;
|
|
43
|
+
gz?: string;
|
|
44
|
+
} = {}) => {
|
|
45
|
+
const updatesDir = await makeDir();
|
|
46
|
+
const dir = path.join(updatesDir, `${platform}-${arch}`);
|
|
47
|
+
await mkdir(path.join(dir, "app"), { recursive: true });
|
|
48
|
+
const tarSha = sha256(`${channel} tar`);
|
|
49
|
+
const from = sha256("the previous tar");
|
|
50
|
+
const delta = `${from}-${tarSha}.delta.gz`;
|
|
51
|
+
await writeFile(path.join(dir, "app", `${tarSha}.tar.gz`), gz);
|
|
52
|
+
await writeFile(path.join(dir, "app", delta), "a delta");
|
|
53
|
+
const build = 1_761_699_000;
|
|
54
|
+
const manifest = {
|
|
55
|
+
schema: 1,
|
|
56
|
+
kind: "app",
|
|
57
|
+
app: appId,
|
|
58
|
+
platform,
|
|
59
|
+
channel,
|
|
60
|
+
sequence: 1_761_700_000,
|
|
61
|
+
bundle: "1.2.0-1761700000",
|
|
62
|
+
runtimeVersion: "0.12.0",
|
|
63
|
+
version: "1.2.0",
|
|
64
|
+
build,
|
|
65
|
+
arch,
|
|
66
|
+
files: [{ path: app, sha256: tarSha, size: 2048 }],
|
|
67
|
+
archive: { sha256: tarSha, url: `app/${tarSha}.tar.gz`, size: gz.length, gzSha256: sha256(gz) },
|
|
68
|
+
patches: [{ from, url: `app/${delta}`, sha256: sha256("a delta"), size: 7 }],
|
|
69
|
+
server: true,
|
|
70
|
+
};
|
|
71
|
+
const bytes = `${JSON.stringify(manifest, null, 2)}\n`;
|
|
72
|
+
await writeFile(path.join(dir, `${channel}.json`), bytes);
|
|
73
|
+
await writeFile(path.join(dir, `${channel}.json.sig`), key.sign(bytes));
|
|
74
|
+
return { updatesDir, dir, tarSha, delta, build } satisfies Published;
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const read = (
|
|
78
|
+
{ updatesDir }: Published,
|
|
79
|
+
{
|
|
80
|
+
platform = "macos",
|
|
81
|
+
arch = "arm64",
|
|
82
|
+
channel = "main",
|
|
83
|
+
publicKey = key.publicKey,
|
|
84
|
+
id = appId,
|
|
85
|
+
}: { platform?: DesktopPlatform; arch?: DesktopArch; channel?: string; publicKey?: string; id?: string } = {},
|
|
86
|
+
) => DesktopRelease.read(updatesDir, { platform, arch, channel, appId: id, publicKey });
|
|
87
|
+
|
|
88
|
+
const entriesOf = (archive: string) =>
|
|
89
|
+
Bun.spawnSync(["tar", "-tf", archive]).stdout.toString().split(/\r?\n/).filter(Boolean);
|
|
90
|
+
|
|
91
|
+
const extract = async (archive: string) => {
|
|
92
|
+
const out = await makeDir();
|
|
93
|
+
expect(Bun.spawnSync(["tar", "-xf", archive, "-C", out]).exitCode).toBe(0);
|
|
94
|
+
return out;
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
describe("DesktopRelease", () => {
|
|
98
|
+
test("packs only what the channel's manifest names: no other channel, earlier archive or folder record", async () => {
|
|
99
|
+
const published = await publish();
|
|
100
|
+
const { dir, tarSha, delta } = published;
|
|
101
|
+
await publish({ channel: "develop" }).then(async (other) => {
|
|
102
|
+
for (const name of ["develop.json", "develop.json.sig"])
|
|
103
|
+
await writeFile(path.join(dir, name), await readFile(path.join(other.dir, name)));
|
|
104
|
+
});
|
|
105
|
+
await writeFile(path.join(dir, "app", `${sha256("an earlier tar")}.tar.gz`), "an earlier release");
|
|
106
|
+
await writeFile(path.join(dir, "mirror.txt"), "not part of the release");
|
|
107
|
+
const release = await read(published);
|
|
108
|
+
|
|
109
|
+
const entries = await release.withArchive(release.record(null), async (archive) => entriesOf(archive));
|
|
110
|
+
|
|
111
|
+
expect(entries).toEqual([
|
|
112
|
+
"./release.json",
|
|
113
|
+
"./main.json",
|
|
114
|
+
"./main.json.sig",
|
|
115
|
+
`./app/${tarSha}.tar.gz`,
|
|
116
|
+
`./app/${delta}`,
|
|
117
|
+
]);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test("the manifest and its signature go in byte for byte, beside a release.json of the CLI and the build time", async () => {
|
|
121
|
+
const published = await publish();
|
|
122
|
+
const release = await read(published);
|
|
123
|
+
const record = release.record({ teamId: "ABCDE12345" });
|
|
124
|
+
|
|
125
|
+
const out = await release.withArchive(record, extract);
|
|
126
|
+
|
|
127
|
+
for (const name of ["main.json", "main.json.sig"])
|
|
128
|
+
expect(sha256(await readFile(path.join(out, name)))).toBe(sha256(await readFile(path.join(published.dir, name))));
|
|
129
|
+
expect(await readFile(path.join(out, "release.json"), "utf8")).toBe(`${JSON.stringify(record, null, 2)}\n`);
|
|
130
|
+
expect(record).toEqual({
|
|
131
|
+
schema: 1,
|
|
132
|
+
cli: AkanAppConfig.akanVersion,
|
|
133
|
+
platform: "macos",
|
|
134
|
+
arch: "arm64",
|
|
135
|
+
channel: "main",
|
|
136
|
+
signer: { teamId: "ABCDE12345" },
|
|
137
|
+
builtAt: new Date(published.build * 1000).toISOString(),
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test("leaves no archive behind, also when the upload fails", async () => {
|
|
142
|
+
const release = await read(await publish());
|
|
143
|
+
let kept = "";
|
|
144
|
+
|
|
145
|
+
await expect(
|
|
146
|
+
release.withArchive(release.record(null), async (archive) => {
|
|
147
|
+
kept = archive;
|
|
148
|
+
throw new Error("upload failed");
|
|
149
|
+
}),
|
|
150
|
+
).rejects.toThrow("upload failed");
|
|
151
|
+
expect(await Bun.file(kept).exists()).toBe(false);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test("refuses a manifest the target's first key did not sign, before reading anything it names", async () => {
|
|
155
|
+
const published = await publish();
|
|
156
|
+
|
|
157
|
+
await expect(read(published, { publicKey: keyOf().publicKey })).rejects.toThrow(
|
|
158
|
+
"main.json.sig does not verify with updates.publicKeys[0], the one key installed apps trust",
|
|
159
|
+
);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test("refuses a release of another app, and names a channel or CPU it holds none of", async () => {
|
|
163
|
+
const published = await publish();
|
|
164
|
+
|
|
165
|
+
await expect(read(published, { id: "com.other" })).rejects.toThrow(
|
|
166
|
+
"is not a release of com.other for macos-arm64 on channel main.",
|
|
167
|
+
);
|
|
168
|
+
await expect(read(published, { channel: "develop" })).rejects.toThrow(
|
|
169
|
+
`${path.join(published.dir, "develop.json")} is missing: run \`akan publish-update\` first.`,
|
|
170
|
+
);
|
|
171
|
+
await expect(read(published, { arch: "x64" })).rejects.toThrow("macos-x64");
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test("refuses an archive that is not the file the manifest names, by its length or its digest", async () => {
|
|
175
|
+
const published = await publish();
|
|
176
|
+
const archive = path.join(published.dir, "app", `${published.tarSha}.tar.gz`);
|
|
177
|
+
const release = await read(published);
|
|
178
|
+
const send = () => release.withArchive(release.record(null), async () => "sent");
|
|
179
|
+
|
|
180
|
+
await writeFile(archive, "the release's gzip tar!");
|
|
181
|
+
await expect(send()).rejects.toThrow(`${archive} is not the file main.json names: publish the release again.`);
|
|
182
|
+
await writeFile(archive, "the release's gzip taR");
|
|
183
|
+
await expect(send()).rejects.toThrow(`${archive} is not the file main.json names (its sha256 differs).`);
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
const recordingTool = (
|
|
188
|
+
answer: (cmd: string[], env: Record<string, string>) => Promise<{ code: number; output: string }>,
|
|
189
|
+
) => {
|
|
190
|
+
const calls: { cmd: string[]; env: Record<string, string> }[] = [];
|
|
191
|
+
const run: SignatureTool = async (cmd, { env = {} } = {}) => {
|
|
192
|
+
calls.push({ cmd, env });
|
|
193
|
+
return await answer(cmd, env);
|
|
194
|
+
};
|
|
195
|
+
return { calls, run };
|
|
196
|
+
};
|
|
197
|
+
const bom = String.fromCharCode(0xfeff);
|
|
198
|
+
const extractedTo = (cmd: string[]) => cmd[cmd.indexOf("-C") + 1] ?? "";
|
|
199
|
+
|
|
200
|
+
describe("DesktopRelease signer", () => {
|
|
201
|
+
const macTool = (codesign: { code: number; output: string }, { plist = true } = {}) =>
|
|
202
|
+
recordingTool(async (cmd) => {
|
|
203
|
+
if (cmd[0] === "/usr/bin/tar") {
|
|
204
|
+
if (plist) await Bun.write(path.join(extractedTo(cmd), "Akancad.app/Contents/Info.plist"), "<plist/>");
|
|
205
|
+
return { code: 1, output: "tar: _CodeSignature/*: Not found in archive" };
|
|
206
|
+
}
|
|
207
|
+
return codesign;
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
test("macOS: the TeamIdentifier codesign shows for the app in the archive", async () => {
|
|
211
|
+
const release = await read(await publish());
|
|
212
|
+
const { calls, run } = macTool({
|
|
213
|
+
code: 0,
|
|
214
|
+
output: "Executable=…\nTeamIdentifier=ABCDE12345\nSealed Resources=none\n",
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
expect(await release.signer({ exeName: "akancad", host: "darwin", run })).toEqual({
|
|
218
|
+
signer: { teamId: "ABCDE12345" },
|
|
219
|
+
unread: null,
|
|
220
|
+
});
|
|
221
|
+
const [extraction, display] = calls;
|
|
222
|
+
expect(extraction?.cmd.slice(0, 3)).toEqual([
|
|
223
|
+
"/usr/bin/tar",
|
|
224
|
+
"-xzf",
|
|
225
|
+
path.join(release.dir, release.manifest.archive.url),
|
|
226
|
+
]);
|
|
227
|
+
expect(extraction?.cmd.filter((_, idx, cmd) => cmd[idx - 1] === "--include")).toEqual([
|
|
228
|
+
"Akancad.app/Contents/Info.plist",
|
|
229
|
+
"Akancad.app/Contents/MacOS/*",
|
|
230
|
+
"Akancad.app/Contents/_CodeSignature/*",
|
|
231
|
+
]);
|
|
232
|
+
expect(display?.cmd).toEqual([
|
|
233
|
+
"/usr/bin/codesign",
|
|
234
|
+
"--display",
|
|
235
|
+
"--verbose=2",
|
|
236
|
+
path.join(extractedTo(extraction?.cmd ?? []), "Akancad.app"),
|
|
237
|
+
]);
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
test("macOS: an app signed ad hoc or not at all has no signer, and nothing to warn about", async () => {
|
|
241
|
+
const release = await read(await publish());
|
|
242
|
+
const adHoc = macTool({ code: 0, output: "Signature=adhoc\nTeamIdentifier=not set\n" });
|
|
243
|
+
const unsigned = macTool({ code: 1, output: "/tmp/x/Akancad.app: code object is not signed at all\n" });
|
|
244
|
+
|
|
245
|
+
for (const { run } of [adHoc, unsigned])
|
|
246
|
+
expect(await release.signer({ exeName: "akancad", host: "darwin", run })).toEqual({ signer: null, unread: null });
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
test("macOS: says why when codesign or the archive cannot tell", async () => {
|
|
250
|
+
const release = await read(await publish());
|
|
251
|
+
|
|
252
|
+
expect(
|
|
253
|
+
await release.signer({ exeName: "akancad", host: "darwin", run: macTool({ code: 1, output: "boom\n" }).run }),
|
|
254
|
+
).toEqual({ signer: null, unread: "codesign could not read Akancad.app: boom" });
|
|
255
|
+
expect(
|
|
256
|
+
await release.signer({
|
|
257
|
+
exeName: "akancad",
|
|
258
|
+
host: "darwin",
|
|
259
|
+
run: macTool({ code: 0, output: "" }, { plist: false }).run,
|
|
260
|
+
}),
|
|
261
|
+
).toEqual({ signer: null, unread: "the archive holds no Akancad.app/Contents/Info.plist" });
|
|
262
|
+
const { calls, run } = macTool({ code: 0, output: "TeamIdentifier=ABCDE12345\n" });
|
|
263
|
+
expect(await release.signer({ exeName: "akancad", host: "linux", run })).toEqual({
|
|
264
|
+
signer: null,
|
|
265
|
+
unread: "codesign reads a macOS app's signer on macOS only",
|
|
266
|
+
});
|
|
267
|
+
expect(calls).toEqual([]);
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
const windowsTool = (powershell: (out: string) => Promise<number>) =>
|
|
271
|
+
recordingTool(async (cmd, env) => {
|
|
272
|
+
if (cmd[0] === "tar.exe") {
|
|
273
|
+
await Bun.write(path.join(extractedTo(cmd), cmd.at(-1) ?? ""), "MZ");
|
|
274
|
+
return { code: 0, output: "" };
|
|
275
|
+
}
|
|
276
|
+
return { code: await powershell(env.AKAN_SIGNER_OUT), output: "" };
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
test("Windows: the Authenticode subject of the app's executable, read as UTF-8", async () => {
|
|
280
|
+
const release = await read(await publish({ platform: "windows", arch: "x64" }), {
|
|
281
|
+
platform: "windows",
|
|
282
|
+
arch: "x64",
|
|
283
|
+
});
|
|
284
|
+
const subject = "CN=아칸 주식회사, O=아칸 주식회사, C=KR";
|
|
285
|
+
const { calls, run } = windowsTool(async (out) => {
|
|
286
|
+
await Bun.write(out, `${bom}${subject}\r\n`);
|
|
287
|
+
return 0;
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
expect(await release.signer({ exeName: "akancad", host: "win32", run })).toEqual({
|
|
291
|
+
signer: { subject },
|
|
292
|
+
unread: null,
|
|
293
|
+
});
|
|
294
|
+
const [extraction, check] = calls;
|
|
295
|
+
expect(extraction?.cmd.at(-1)).toBe("akancad/akancad.exe");
|
|
296
|
+
expect(check?.env.AKAN_RELEASE_EXE).toBe(path.join(extractedTo(extraction?.cmd ?? []), "akancad/akancad.exe"));
|
|
297
|
+
expect(check?.cmd.slice(1, 4)).toEqual(["-NoProfile", "-NonInteractive", "-EncodedCommand"]);
|
|
298
|
+
expect(Buffer.from(check?.cmd[4] ?? "", "base64").toString("utf16le")).toContain(
|
|
299
|
+
"Get-AuthenticodeSignature -LiteralPath $env:AKAN_RELEASE_EXE",
|
|
300
|
+
);
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
test("Windows: an executable without a valid signature has no signer; a PowerShell that fails says so", async () => {
|
|
304
|
+
const release = await read(await publish({ platform: "windows", arch: "x64" }), {
|
|
305
|
+
platform: "windows",
|
|
306
|
+
arch: "x64",
|
|
307
|
+
});
|
|
308
|
+
const unsigned = windowsTool(async (out) => {
|
|
309
|
+
await Bun.write(out, `${bom}\r\n`);
|
|
310
|
+
return 0;
|
|
311
|
+
});
|
|
312
|
+
const blocked = windowsTool(async () => 1);
|
|
313
|
+
|
|
314
|
+
expect(await release.signer({ exeName: "akancad", host: "win32", run: unsigned.run })).toEqual({
|
|
315
|
+
signer: null,
|
|
316
|
+
unread: null,
|
|
317
|
+
});
|
|
318
|
+
expect(await release.signer({ exeName: "akancad", host: "win32", run: blocked.run })).toEqual({
|
|
319
|
+
signer: null,
|
|
320
|
+
unread: "PowerShell could not read the Authenticode signature of akancad/akancad.exe",
|
|
321
|
+
});
|
|
322
|
+
expect(await release.signer({ exeName: "akancad", host: "darwin", run: unsigned.run })).toEqual({
|
|
323
|
+
signer: null,
|
|
324
|
+
unread: "PowerShell reads a Windows app's Authenticode signer on Windows only",
|
|
325
|
+
});
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
test("Linux: no signer, and nothing run", async () => {
|
|
329
|
+
const release = await read(await publish({ platform: "linux", arch: "x64" }), { platform: "linux", arch: "x64" });
|
|
330
|
+
const { calls, run } = recordingTool(async () => ({ code: 0, output: "" }));
|
|
331
|
+
|
|
332
|
+
expect(await release.signer({ exeName: "akancad", host: "linux", run })).toEqual({ signer: null, unread: null });
|
|
333
|
+
expect(calls).toEqual([]);
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
test.skipIf(process.platform !== "darwin")(
|
|
337
|
+
"macOS: reads an ad hoc signature from the archive itself with the system tar and codesign",
|
|
338
|
+
async () => {
|
|
339
|
+
const build = await makeDir();
|
|
340
|
+
const contents = path.join(build, "Akancad.app", "Contents");
|
|
341
|
+
await mkdir(path.join(contents, "MacOS"), { recursive: true });
|
|
342
|
+
await mkdir(path.join(contents, "Resources"), { recursive: true });
|
|
343
|
+
await writeFile(path.join(contents, "Resources", "server.json"), "{}");
|
|
344
|
+
await writeFile(
|
|
345
|
+
path.join(contents, "Info.plist"),
|
|
346
|
+
'<?xml version="1.0" encoding="UTF-8"?>\n<plist version="1.0"><dict><key>CFBundleExecutable</key><string>akancad</string><key>CFBundleIdentifier</key><string>com.akancad.app</string></dict></plist>\n',
|
|
347
|
+
);
|
|
348
|
+
await writeFile(path.join(contents, "MacOS", "akancad"), await readFile("/usr/bin/true"));
|
|
349
|
+
const signed = Bun.spawnSync(["/usr/bin/codesign", "--force", "--sign", "-", path.join(build, "Akancad.app")]);
|
|
350
|
+
expect(signed.exitCode).toBe(0);
|
|
351
|
+
const gz = path.join(build, "release.tar.gz");
|
|
352
|
+
const tarred = Bun.spawnSync(["/usr/bin/tar", "--no-mac-metadata", "-czf", gz, "-C", build, "Akancad.app"]);
|
|
353
|
+
expect(tarred.exitCode).toBe(0);
|
|
354
|
+
const published = await publish();
|
|
355
|
+
await writeFile(path.join(published.dir, "app", `${published.tarSha}.tar.gz`), await readFile(gz));
|
|
356
|
+
const release = await read(published);
|
|
357
|
+
|
|
358
|
+
expect(await release.signer({ exeName: "akancad" })).toEqual({ signer: null, unread: null });
|
|
359
|
+
},
|
|
360
|
+
);
|
|
361
|
+
});
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
import { createPublicKey, verify } from "node:crypto";
|
|
2
|
+
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { isRecord, toError } from "akanjs/common";
|
|
6
|
+
import type { UpdateManifest } from "akanjs/native/api";
|
|
7
|
+
import { AkanAppConfig } from "../akanConfig";
|
|
8
|
+
import type { DesktopArch } from "./desktopBin";
|
|
9
|
+
import type { DesktopPlatform } from "./mobileTarget";
|
|
10
|
+
import { TarWriter } from "./tarWriter";
|
|
11
|
+
import { UpdateUploader } from "./updateUploader";
|
|
12
|
+
|
|
13
|
+
export type DesktopSigner = { teamId: string } | { subject: string } | null;
|
|
14
|
+
|
|
15
|
+
/** `release.json`, the one file of a desktop release archive `publish-update` did not write. */
|
|
16
|
+
export interface DesktopReleaseRecord {
|
|
17
|
+
schema: 1;
|
|
18
|
+
cli: string;
|
|
19
|
+
platform: DesktopPlatform;
|
|
20
|
+
arch: DesktopArch;
|
|
21
|
+
channel: string;
|
|
22
|
+
signer: DesktopSigner;
|
|
23
|
+
builtAt: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export type SignatureTool = (
|
|
27
|
+
cmd: string[],
|
|
28
|
+
options?: { env?: Record<string, string> },
|
|
29
|
+
) => Promise<{ code: number; output: string }>;
|
|
30
|
+
|
|
31
|
+
type DesktopManifest = Partial<UpdateManifest> &
|
|
32
|
+
Required<Pick<UpdateManifest, "archive" | "build" | "bundle" | "sequence">>;
|
|
33
|
+
|
|
34
|
+
interface DesktopReleasePayload {
|
|
35
|
+
name: string;
|
|
36
|
+
size: number;
|
|
37
|
+
sha256: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
//* One desktop channel's release as `publish-update` left it in `<updates>/<os>-<arch>/`: the signed manifest and the
|
|
41
|
+
//* files it names, packed as they are, so the same folder uploaded anywhere else stays valid.
|
|
42
|
+
export class DesktopRelease {
|
|
43
|
+
static readonly #ed25519Spki = Buffer.from("302a300506032b6570032100", "hex");
|
|
44
|
+
//? Constrained Language Mode (WDAC, AppLocker) refuses .NET type literals and method calls, so the script makes none.
|
|
45
|
+
//? Windows PowerShell writes its console in the OEM code page, which mangles a subject outside it; a file keeps UTF-8.
|
|
46
|
+
static readonly #authenticode = `$ErrorActionPreference = 'Stop'
|
|
47
|
+
$ProgressPreference = 'SilentlyContinue'
|
|
48
|
+
$signature = Get-AuthenticodeSignature -LiteralPath $env:AKAN_RELEASE_EXE
|
|
49
|
+
$subject = if ("$($signature.Status)" -ceq 'Valid') { "$($signature.SignerCertificate.Subject)" } else { '' }
|
|
50
|
+
Set-Content -LiteralPath $env:AKAN_SIGNER_OUT -Value $subject -Encoding UTF8`;
|
|
51
|
+
|
|
52
|
+
private constructor(
|
|
53
|
+
readonly dir: string,
|
|
54
|
+
readonly manifest: DesktopManifest,
|
|
55
|
+
readonly payloads: DesktopReleasePayload[],
|
|
56
|
+
readonly platform: DesktopPlatform,
|
|
57
|
+
readonly arch: DesktopArch,
|
|
58
|
+
readonly channel: string,
|
|
59
|
+
readonly manifestBytes: Uint8Array<ArrayBuffer>,
|
|
60
|
+
readonly signatureBytes: Uint8Array<ArrayBuffer>,
|
|
61
|
+
) {}
|
|
62
|
+
|
|
63
|
+
static async read(
|
|
64
|
+
updatesDir: string,
|
|
65
|
+
{
|
|
66
|
+
platform,
|
|
67
|
+
arch,
|
|
68
|
+
channel,
|
|
69
|
+
appId,
|
|
70
|
+
publicKey,
|
|
71
|
+
}: { platform: DesktopPlatform; arch: DesktopArch; channel: string; appId: string; publicKey: string },
|
|
72
|
+
) {
|
|
73
|
+
const folder = `${platform}-${arch}`;
|
|
74
|
+
const dir = path.join(updatesDir, folder);
|
|
75
|
+
const manifestPath = path.join(dir, `${channel}.json`);
|
|
76
|
+
const [manifestBytes, signatureBytes] = await Promise.all([
|
|
77
|
+
readFile(manifestPath).catch(() => null),
|
|
78
|
+
readFile(`${manifestPath}.sig`).catch(() => null),
|
|
79
|
+
]);
|
|
80
|
+
if (!manifestBytes || !signatureBytes)
|
|
81
|
+
throw new Error(
|
|
82
|
+
`${manifestBytes ? `${manifestPath}.sig` : manifestPath} is missing: run \`akan publish-update\` first.`,
|
|
83
|
+
);
|
|
84
|
+
if (!DesktopRelease.#verifies(manifestBytes, signatureBytes, publicKey))
|
|
85
|
+
throw new Error(
|
|
86
|
+
`${manifestPath}.sig does not verify with updates.publicKeys[0], the one key installed apps trust: publish the release again.`,
|
|
87
|
+
);
|
|
88
|
+
const manifest = DesktopRelease.#parse(manifestBytes);
|
|
89
|
+
if (
|
|
90
|
+
manifest?.schema !== 1 ||
|
|
91
|
+
manifest.kind !== "app" ||
|
|
92
|
+
manifest.app !== appId ||
|
|
93
|
+
manifest.platform !== platform ||
|
|
94
|
+
manifest.arch !== arch ||
|
|
95
|
+
manifest.channel !== channel ||
|
|
96
|
+
typeof manifest.bundle !== "string" ||
|
|
97
|
+
typeof manifest.sequence !== "number" ||
|
|
98
|
+
typeof manifest.build !== "number" ||
|
|
99
|
+
!manifest.archive
|
|
100
|
+
)
|
|
101
|
+
throw new Error(`${manifestPath} is not a release of ${appId} for ${folder} on channel ${channel}.`);
|
|
102
|
+
const { archive, bundle, sequence, build } = manifest;
|
|
103
|
+
const keys = await UpdateUploader.releaseFiles(updatesDir, { dir, channel, bundle });
|
|
104
|
+
const expected = new Map(
|
|
105
|
+
[{ url: archive.url, size: archive.size, sha256: archive.gzSha256 }, ...(manifest.patches ?? [])].map(
|
|
106
|
+
({ url, size, sha256 }) => [path.posix.normalize(url), { size, sha256 }],
|
|
107
|
+
),
|
|
108
|
+
);
|
|
109
|
+
const payloads = keys.flatMap((key) => {
|
|
110
|
+
const name = key.slice(folder.length + 1);
|
|
111
|
+
const digest = expected.get(name);
|
|
112
|
+
return digest ? [{ name, ...digest }] : [];
|
|
113
|
+
});
|
|
114
|
+
return new DesktopRelease(
|
|
115
|
+
dir,
|
|
116
|
+
{ ...manifest, archive, bundle, sequence, build },
|
|
117
|
+
payloads,
|
|
118
|
+
platform,
|
|
119
|
+
arch,
|
|
120
|
+
channel,
|
|
121
|
+
manifestBytes,
|
|
122
|
+
signatureBytes,
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
record(signer: DesktopSigner): DesktopReleaseRecord {
|
|
127
|
+
const { platform, arch, channel } = this;
|
|
128
|
+
const builtAt = new Date(this.manifest.build * 1000).toISOString();
|
|
129
|
+
return { schema: 1, cli: AkanAppConfig.akanVersion, platform, arch, channel, signer, builtAt };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
//* Uncompressed, regular files only under `./` names, no directory entries; what the manifest names is checked
|
|
133
|
+
//* against it while it is copied, so the archive holds the bytes the manifest signs for.
|
|
134
|
+
async withArchive<T>(record: DesktopReleaseRecord, use: (archive: string) => Promise<T>): Promise<T> {
|
|
135
|
+
const tmp = await mkdtemp(path.join(os.tmpdir(), "akan-release-"));
|
|
136
|
+
try {
|
|
137
|
+
const archive = path.join(tmp, "release.tar");
|
|
138
|
+
const tar = await TarWriter.create(archive, { mtime: this.manifest.sequence });
|
|
139
|
+
try {
|
|
140
|
+
await tar.add("./release.json", new Blob([`${JSON.stringify(record, null, 2)}\n`]));
|
|
141
|
+
await tar.add(`./${this.channel}.json`, new Blob([this.manifestBytes]));
|
|
142
|
+
await tar.add(`./${this.channel}.json.sig`, new Blob([this.signatureBytes]));
|
|
143
|
+
for (const { name, size, sha256 } of this.payloads) {
|
|
144
|
+
const file = Bun.file(path.join(this.dir, name));
|
|
145
|
+
const what = `${path.join(this.dir, name)} is not the file ${this.channel}.json names`;
|
|
146
|
+
if (!(await file.exists()) || file.size !== size) throw new Error(`${what}: publish the release again.`);
|
|
147
|
+
if ((await tar.add(`./${name}`, file)) !== sha256) throw new Error(`${what} (its sha256 differs).`);
|
|
148
|
+
}
|
|
149
|
+
await tar.close();
|
|
150
|
+
} catch (error) {
|
|
151
|
+
await tar.discard();
|
|
152
|
+
throw error;
|
|
153
|
+
}
|
|
154
|
+
return await use(archive);
|
|
155
|
+
} finally {
|
|
156
|
+
await rm(tmp, { recursive: true, force: true });
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async signer({
|
|
161
|
+
exeName,
|
|
162
|
+
host = process.platform,
|
|
163
|
+
run = DesktopRelease.#run,
|
|
164
|
+
}: {
|
|
165
|
+
exeName: string;
|
|
166
|
+
host?: NodeJS.Platform;
|
|
167
|
+
run?: SignatureTool;
|
|
168
|
+
}): Promise<{ signer: DesktopSigner; unread: string | null }> {
|
|
169
|
+
if (this.platform === "linux") return { signer: null, unread: null };
|
|
170
|
+
const app = this.manifest.files?.[0]?.path;
|
|
171
|
+
if (!app || !/^[^/\\]+$/.test(app) || app === "." || app === "..")
|
|
172
|
+
return { signer: null, unread: `${this.channel}.json names no app folder` };
|
|
173
|
+
if (this.platform === "macos" && host !== "darwin")
|
|
174
|
+
return { signer: null, unread: "codesign reads a macOS app's signer on macOS only" };
|
|
175
|
+
if (this.platform === "windows" && host !== "win32")
|
|
176
|
+
return { signer: null, unread: "PowerShell reads a Windows app's Authenticode signer on Windows only" };
|
|
177
|
+
const tmp = await mkdtemp(path.join(os.tmpdir(), "akan-signer-"));
|
|
178
|
+
try {
|
|
179
|
+
const archive = path.join(this.dir, this.manifest.archive.url);
|
|
180
|
+
return this.platform === "macos"
|
|
181
|
+
? await DesktopRelease.#teamOf(run, archive, tmp, app)
|
|
182
|
+
: await DesktopRelease.#subjectOf(run, archive, tmp, path.posix.join(app, `${exeName}.exe`));
|
|
183
|
+
} finally {
|
|
184
|
+
await rm(tmp, { recursive: true, force: true });
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
//? The signature lives in the executable and _CodeSignature; Info.plist names the executable. An app signed without
|
|
189
|
+
//? resources has no _CodeSignature, and bsdtar then exits 1 for the unmatched --include though it wrote the rest.
|
|
190
|
+
static async #teamOf(run: SignatureTool, archive: string, tmp: string, app: string) {
|
|
191
|
+
const contents = `${app}/Contents`;
|
|
192
|
+
await run([
|
|
193
|
+
"/usr/bin/tar",
|
|
194
|
+
"-xzf",
|
|
195
|
+
archive,
|
|
196
|
+
"-C",
|
|
197
|
+
tmp,
|
|
198
|
+
...["Info.plist", "MacOS/*", "_CodeSignature/*"].flatMap((entry) => ["--include", `${contents}/${entry}`]),
|
|
199
|
+
]);
|
|
200
|
+
if (!(await Bun.file(path.join(tmp, contents, "Info.plist")).exists()))
|
|
201
|
+
return { signer: null, unread: `the archive holds no ${contents}/Info.plist` };
|
|
202
|
+
const { output } = await run(["/usr/bin/codesign", "--display", "--verbose=2", path.join(tmp, app)]);
|
|
203
|
+
const teamId = /^TeamIdentifier=([A-Z0-9]{10})$/m.exec(output)?.[1];
|
|
204
|
+
if (teamId) return { signer: { teamId }, unread: null };
|
|
205
|
+
if (/^TeamIdentifier=not set$/m.test(output) || /code object is not signed at all/.test(output))
|
|
206
|
+
return { signer: null, unread: null };
|
|
207
|
+
return { signer: null, unread: `codesign could not read ${app}: ${output.trim().split("\n")[0] ?? ""}` };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
static async #subjectOf(run: SignatureTool, archive: string, tmp: string, exe: string) {
|
|
211
|
+
await run(["tar.exe", "-xzf", archive, "-C", tmp, exe]);
|
|
212
|
+
const extracted = path.join(tmp, exe);
|
|
213
|
+
if (!(await Bun.file(extracted).exists())) return { signer: null, unread: `the archive holds no ${exe}` };
|
|
214
|
+
const out = path.join(tmp, "signer.txt");
|
|
215
|
+
const { code } = await run(
|
|
216
|
+
[
|
|
217
|
+
path.join(process.env.SystemRoot ?? "C:\\Windows", "System32", "WindowsPowerShell", "v1.0", "powershell.exe"),
|
|
218
|
+
"-NoProfile",
|
|
219
|
+
"-NonInteractive",
|
|
220
|
+
"-EncodedCommand",
|
|
221
|
+
Buffer.from(DesktopRelease.#authenticode, "utf16le").toString("base64"),
|
|
222
|
+
],
|
|
223
|
+
{ env: { AKAN_RELEASE_EXE: extracted, AKAN_SIGNER_OUT: out } },
|
|
224
|
+
);
|
|
225
|
+
if (code !== 0) return { signer: null, unread: `PowerShell could not read the Authenticode signature of ${exe}` };
|
|
226
|
+
const subject = (await readFile(out, "utf8").catch(() => "")).replace(/^\uFEFF/, "").trim();
|
|
227
|
+
return { signer: subject ? { subject } : null, unread: null };
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
static #verifies(manifest: Uint8Array, signature: Uint8Array, publicKey: string) {
|
|
231
|
+
try {
|
|
232
|
+
const key = createPublicKey({
|
|
233
|
+
key: Buffer.concat([DesktopRelease.#ed25519Spki, Buffer.from(publicKey, "base64")]),
|
|
234
|
+
format: "der",
|
|
235
|
+
type: "spki",
|
|
236
|
+
});
|
|
237
|
+
return verify(null, manifest, key, Buffer.from(new TextDecoder().decode(signature).trim(), "base64"));
|
|
238
|
+
} catch {
|
|
239
|
+
return false;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
static #parse(bytes: Uint8Array): Partial<UpdateManifest> | null {
|
|
244
|
+
try {
|
|
245
|
+
const value: unknown = JSON.parse(new TextDecoder().decode(bytes));
|
|
246
|
+
return isRecord(value) ? (value as Partial<UpdateManifest>) : null;
|
|
247
|
+
} catch {
|
|
248
|
+
return null;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
static async #run(cmd: string[], { env }: { env?: Record<string, string> } = {}) {
|
|
253
|
+
try {
|
|
254
|
+
const proc = Bun.spawn(cmd, {
|
|
255
|
+
env: { ...process.env, ...env },
|
|
256
|
+
stdin: "ignore",
|
|
257
|
+
stdout: "pipe",
|
|
258
|
+
stderr: "pipe",
|
|
259
|
+
});
|
|
260
|
+
const [code, stdout, stderr] = await Promise.all([
|
|
261
|
+
proc.exited,
|
|
262
|
+
new Response(proc.stdout).text(),
|
|
263
|
+
new Response(proc.stderr).text(),
|
|
264
|
+
]);
|
|
265
|
+
return { code, output: `${stdout}${stderr}` };
|
|
266
|
+
} catch (error) {
|
|
267
|
+
return { code: -1, output: toError(error).message };
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
}
|
|
@@ -24,11 +24,13 @@ export interface DesktopServerRun extends ServerPolicy {
|
|
|
24
24
|
//* `bun install --production` does. It is staged outside dist: installing there would put host packages into the
|
|
25
25
|
//* image's `COPY . .`.
|
|
26
26
|
export class DesktopServerStage {
|
|
27
|
-
//? An API-only server starts neither the RSC worker nor the akan console and serves no CSR, public/ or SSR
|
|
28
|
-
//? and `install` wrote its own package.json. The rest of dist is the server's: its chunks,
|
|
27
|
+
//? An API-only server starts neither the RSC worker nor the akan console and serves no CSR, public/ or SSR
|
|
28
|
+
//? artifact, and `install` wrote its own package.json and bun.lock. The rest of dist is the server's: its chunks,
|
|
29
|
+
//? a `.node`, a `.wasm`.
|
|
29
30
|
static readonly uncarried = new Set([
|
|
30
31
|
"Dockerfile",
|
|
31
32
|
"package.json",
|
|
33
|
+
"bun.lock",
|
|
32
34
|
"rscWorker.js",
|
|
33
35
|
"console.js",
|
|
34
36
|
"console-runtime.js",
|
package/mobile/index.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
export * from "./desktopBin";
|
|
2
|
+
export * from "./desktopRelease";
|
|
2
3
|
export * from "./desktopServerStage";
|
|
3
4
|
export * from "./mobileTarget";
|
|
4
5
|
export * from "./nativeApi";
|
|
@@ -8,6 +9,7 @@ export * from "./nativePluginFolders";
|
|
|
8
9
|
export * from "./nativePublicEnv";
|
|
9
10
|
export * from "./nativeWebDir";
|
|
10
11
|
export * from "./placeholderAppId";
|
|
12
|
+
export * from "./tarWriter";
|
|
11
13
|
export * from "./updatePackFolder";
|
|
12
14
|
export * from "./updateUploader";
|
|
13
15
|
export * from "./usageDescriptions";
|