@akanjs/devkit 3.1.1-rc.7 → 3.1.1-rc.9
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/cloud/cloudApi.ts +6 -3
- package/cloud/cloudHttpError.test.ts +8 -0
- package/cloud/cloudHttpError.ts +11 -1
- package/cloud/cloudUpdates.ts +14 -4
- package/mobile/desktopRelease.test.ts +131 -19
- package/mobile/desktopRelease.ts +81 -44
- package/mobile/nativeApi.test.ts +4 -1
- package/mobile/nativeApi.ts +3 -3
- package/mobile/nativeApp.test.ts +2 -2
- package/mobile/nativeApp.ts +18 -11
- package/mobile/nativeConfig.test.ts +16 -8
- package/mobile/nativeConfig.ts +1 -1
- package/mobile/updateUploader.test.ts +34 -0
- package/mobile/updateUploader.ts +9 -6
- package/package.json +2 -2
package/cloud/cloudApi.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { isRecord } from "akanjs/common";
|
|
1
|
+
import { isRecord, Logger } from "akanjs/common";
|
|
2
2
|
import type { Workspace } from "../commandDecorators";
|
|
3
3
|
import { CloudHttpError } from "./cloudHttpError";
|
|
4
4
|
import type { AccessToken, AccessTokenDto, HostConfig } from "./constants";
|
|
@@ -123,8 +123,11 @@ export class CloudApi {
|
|
|
123
123
|
if (error.status === 401)
|
|
124
124
|
return new Error("The server did not accept your sign-in: run `akan login`, then try again.", { cause: error });
|
|
125
125
|
if (error.status === 403) return new Error(`The project refused: ${who}.`, { cause: error });
|
|
126
|
-
if (error.status === 400)
|
|
127
|
-
|
|
126
|
+
if (error.status === 400) {
|
|
127
|
+
const { data } = error;
|
|
128
|
+
const why = `${error.error ? `: ${error.error}` : ""}${data ? ` ${Logger.inlineData(data)}` : ""}`;
|
|
129
|
+
return new Error(`The project refused the request${why}.`, { cause: error });
|
|
130
|
+
}
|
|
128
131
|
//? A proxy, or the gateway with no replica ready, answers 503 too; only the server's own names a domain Err key.
|
|
129
132
|
const { errKey } = error;
|
|
130
133
|
if (error.status === 503 && unavailable && errKey && errKey !== "base.error.serverUnavailable")
|
|
@@ -20,6 +20,14 @@ describe("CloudHttpError", () => {
|
|
|
20
20
|
expect(plain.errKey).toBeNull();
|
|
21
21
|
});
|
|
22
22
|
|
|
23
|
+
test("reads the data a server Err carried, and none from a body without it", () => {
|
|
24
|
+
const body = JSON.stringify({ error: "release.error.desktopMalformed", data: { reason: "a directory entry" } });
|
|
25
|
+
|
|
26
|
+
expect(new CloudHttpError(400, "Bad Request", "/x", body).data).toEqual({ reason: "a directory entry" });
|
|
27
|
+
expect(new CloudHttpError(400, "Bad Request", "/x", '{"error":"Bad Request","data":"x"}').data).toBeNull();
|
|
28
|
+
expect(new CloudHttpError(502, "Bad Gateway", "/x", "<html>502</html>").data).toBeNull();
|
|
29
|
+
});
|
|
30
|
+
|
|
23
31
|
test("takes Retry-After as seconds or as a date, and nothing else", () => {
|
|
24
32
|
expect(CloudHttpError.retryAfterMs("7")).toBe(7000);
|
|
25
33
|
expect(CloudHttpError.retryAfterMs(new Date(Date.now() + 60_000).toUTCString())).toBeGreaterThan(55_000);
|
package/cloud/cloudHttpError.ts
CHANGED
|
@@ -26,9 +26,19 @@ export class CloudHttpError extends Error {
|
|
|
26
26
|
|
|
27
27
|
/** The `error` of a JSON refusal: an Err key from the server itself, or whatever a proxy put there. */
|
|
28
28
|
get error(): string | null {
|
|
29
|
+
const error = this.#payload?.error;
|
|
30
|
+
return typeof error === "string" ? error : null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
get data(): Record<string, unknown> | null {
|
|
34
|
+
const data = this.#payload?.data;
|
|
35
|
+
return isRecord(data) ? data : null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
get #payload(): Record<string, unknown> | null {
|
|
29
39
|
try {
|
|
30
40
|
const parsed: unknown = JSON.parse(this.body);
|
|
31
|
-
return isRecord(parsed)
|
|
41
|
+
return isRecord(parsed) ? parsed : null;
|
|
32
42
|
} catch {
|
|
33
43
|
return null;
|
|
34
44
|
}
|
package/cloud/cloudUpdates.ts
CHANGED
|
@@ -9,6 +9,7 @@ import { NativeApp } from "../mobile/nativeApp";
|
|
|
9
9
|
import { NativePublicEnv } from "../mobile/nativePublicEnv";
|
|
10
10
|
import { type NativeUpdatePack, UpdatePackFolder } from "../mobile/updatePackFolder";
|
|
11
11
|
import { type AppBinaryDistribution, CloudApi } from "./cloudApi";
|
|
12
|
+
import { CloudHttpError } from "./cloudHttpError";
|
|
12
13
|
import { UpdatePackUpload } from "./updatePackUpload";
|
|
13
14
|
|
|
14
15
|
//* A phone app's over-the-air updates through an Akan Cloud project, which keeps the signing key: a pack uploaded for
|
|
@@ -61,7 +62,7 @@ export class CloudUpdates {
|
|
|
61
62
|
const release = await new UpdatePackUpload(this.cloudApi, this.devProjectId)
|
|
62
63
|
.send(archive, { appName: pack.app, target: pack.target, platform: pack.platform, channel: target, notes })
|
|
63
64
|
.catch((error: unknown) => {
|
|
64
|
-
throw
|
|
65
|
+
throw CloudUpdates.#refusal(error, "a pack is uploaded by a project member signed in with `akan login`");
|
|
65
66
|
});
|
|
66
67
|
const label = `${pack.app} ${pack.target} ${pack.platform} on channel ${target}: release ${release.id}`;
|
|
67
68
|
if (release.status === "rejected")
|
|
@@ -95,14 +96,15 @@ export class CloudUpdates {
|
|
|
95
96
|
const record = await new UpdatePackUpload(this.cloudApi, this.devProjectId)
|
|
96
97
|
.send(archive, { appName, target, platform, kind: "app", arch, channel, notes })
|
|
97
98
|
.catch((error: unknown) => {
|
|
98
|
-
throw
|
|
99
|
+
throw CloudUpdates.#refusal(error, "a release is uploaded by a project member signed in with `akan login`");
|
|
99
100
|
});
|
|
100
101
|
const label = `${appName} ${target} ${platform}-${arch} on channel ${channel}: release ${record.id}`;
|
|
101
102
|
const problems = (record.problems ?? []).map((problem) => ` - ${problem}`).join("\n");
|
|
102
103
|
if (record.status === "rejected") throw new ExitCodeError(`The project rejected ${label}:\n${problems}`, 2);
|
|
103
104
|
const manifestUrl =
|
|
104
|
-
typeof record.manifestUrl === "string" ? record.manifestUrl : `${url}/${platform}-${arch}/${channel}.json`;
|
|
105
|
-
const
|
|
105
|
+
typeof record.manifestUrl === "string" ? record.manifestUrl : `${url}/${platform}-${arch}/${channel}/app.json`;
|
|
106
|
+
const files = 1 + release.manifestFiles.length + release.payloads.length;
|
|
107
|
+
const what = `${label} (${release.manifest.bundle}, ${files} files)`;
|
|
106
108
|
Logger.rawLog(
|
|
107
109
|
record.status === "published"
|
|
108
110
|
? `Published ${what}. Installed apps read ${manifestUrl}.`
|
|
@@ -113,6 +115,14 @@ export class CloudUpdates {
|
|
|
113
115
|
});
|
|
114
116
|
}
|
|
115
117
|
|
|
118
|
+
//? A 400 naming a release Err key is the project's verdict on the release, as a rejected one is: a retry changes nothing.
|
|
119
|
+
static #refusal(error: unknown, who: string) {
|
|
120
|
+
const refusal = CloudApi.refusal(error, who);
|
|
121
|
+
const verdict =
|
|
122
|
+
error instanceof CloudHttpError && error.status === 400 && !!error.errKey?.startsWith("release.error.");
|
|
123
|
+
return verdict && refusal instanceof Error ? new ExitCodeError(refusal.message, 2) : refusal;
|
|
124
|
+
}
|
|
125
|
+
|
|
116
126
|
//? The store build first, then a pack of the same commit: a server that registers the binary checks the pack runs in it.
|
|
117
127
|
async registerBinary(
|
|
118
128
|
app: App,
|
|
@@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test";
|
|
|
2
2
|
import { createHash, generateKeyPairSync, sign } from "node:crypto";
|
|
3
3
|
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
4
4
|
import path from "node:path";
|
|
5
|
+
import * as nativeApi from "akanjs/native/api";
|
|
5
6
|
import { AkanAppConfig } from "../akanConfig";
|
|
6
7
|
import { tempDirs } from "../testHelpers";
|
|
7
8
|
import type { DesktopArch } from "./desktopBin";
|
|
@@ -13,12 +14,18 @@ const sha256 = (bytes: string | Uint8Array) => createHash("sha256").update(bytes
|
|
|
13
14
|
|
|
14
15
|
const keyOf = () => {
|
|
15
16
|
const { publicKey, privateKey } = generateKeyPairSync("ed25519");
|
|
17
|
+
const raw = publicKey.export({ format: "der", type: "spki" }).subarray(12).toString("base64");
|
|
18
|
+
const signature = (bytes: string) => sign(null, Buffer.from(bytes), privateKey).toString("base64");
|
|
16
19
|
return {
|
|
17
|
-
publicKey:
|
|
18
|
-
|
|
20
|
+
publicKey: raw,
|
|
21
|
+
keyId: nativeApi.keyIdOf(raw),
|
|
22
|
+
sign: (bytes: string) => `${signature(bytes)}\n`,
|
|
23
|
+
seal: (bytes: string) =>
|
|
24
|
+
`${JSON.stringify({ manifest: Buffer.from(bytes).toString("base64"), keyId: nativeApi.keyIdOf(raw), signature: signature(bytes) }, null, 2)}\n`,
|
|
19
25
|
};
|
|
20
26
|
};
|
|
21
27
|
const key = keyOf();
|
|
28
|
+
const spare = keyOf();
|
|
22
29
|
const appId = "com.akancad.app";
|
|
23
30
|
|
|
24
31
|
interface Published {
|
|
@@ -35,12 +42,18 @@ const publish = async ({
|
|
|
35
42
|
channel = "main",
|
|
36
43
|
app = platform === "macos" ? "Akancad.app" : "akancad",
|
|
37
44
|
gz = "the release's gzip tar",
|
|
45
|
+
target = "default",
|
|
46
|
+
signer = key,
|
|
47
|
+
pair = "same",
|
|
38
48
|
}: {
|
|
39
49
|
platform?: DesktopPlatform;
|
|
40
50
|
arch?: DesktopArch;
|
|
41
51
|
channel?: string;
|
|
42
52
|
app?: string;
|
|
43
53
|
gz?: string;
|
|
54
|
+
target?: string;
|
|
55
|
+
signer?: ReturnType<typeof keyOf>;
|
|
56
|
+
pair?: "same" | "earlier" | "none";
|
|
44
57
|
} = {}) => {
|
|
45
58
|
const updatesDir = await makeDir();
|
|
46
59
|
const dir = path.join(updatesDir, `${platform}-${arch}`);
|
|
@@ -68,9 +81,17 @@ const publish = async ({
|
|
|
68
81
|
patches: [{ from, url: `app/${delta}`, sha256: sha256("a delta"), size: 7 }],
|
|
69
82
|
server: true,
|
|
70
83
|
};
|
|
71
|
-
|
|
72
|
-
await writeFile(
|
|
73
|
-
|
|
84
|
+
await mkdir(path.join(dir, channel), { recursive: true });
|
|
85
|
+
await writeFile(
|
|
86
|
+
path.join(dir, channel, "app.json"),
|
|
87
|
+
signer.seal(`${JSON.stringify({ ...manifest, schema: 2, target }, null, 2)}\n`),
|
|
88
|
+
);
|
|
89
|
+
if (pair !== "none") {
|
|
90
|
+
const earlier = { ...manifest, sequence: manifest.sequence - 100, bundle: "1.1.0-1761699900" };
|
|
91
|
+
const bytes = `${JSON.stringify(pair === "same" ? manifest : earlier, null, 2)}\n`;
|
|
92
|
+
await writeFile(path.join(dir, `${channel}.json`), bytes);
|
|
93
|
+
await writeFile(path.join(dir, `${channel}.json.sig`), key.sign(bytes));
|
|
94
|
+
}
|
|
74
95
|
return { updatesDir, dir, tarSha, delta, build } satisfies Published;
|
|
75
96
|
};
|
|
76
97
|
|
|
@@ -80,14 +101,57 @@ const read = (
|
|
|
80
101
|
platform = "macos",
|
|
81
102
|
arch = "arm64",
|
|
82
103
|
channel = "main",
|
|
83
|
-
|
|
104
|
+
publicKeys = [key.publicKey, spare.publicKey],
|
|
105
|
+
revokedKeys = [],
|
|
106
|
+
target = "default",
|
|
84
107
|
id = appId,
|
|
85
|
-
}: {
|
|
86
|
-
|
|
108
|
+
}: {
|
|
109
|
+
platform?: DesktopPlatform;
|
|
110
|
+
arch?: DesktopArch;
|
|
111
|
+
channel?: string;
|
|
112
|
+
publicKeys?: string[];
|
|
113
|
+
revokedKeys?: string[];
|
|
114
|
+
target?: string;
|
|
115
|
+
id?: string;
|
|
116
|
+
} = {},
|
|
117
|
+
) =>
|
|
118
|
+
DesktopRelease.read(updatesDir, {
|
|
119
|
+
platform,
|
|
120
|
+
arch,
|
|
121
|
+
channel,
|
|
122
|
+
appId: id,
|
|
123
|
+
target,
|
|
124
|
+
publicKeys,
|
|
125
|
+
revokedKeys,
|
|
126
|
+
api: nativeApi,
|
|
127
|
+
});
|
|
87
128
|
|
|
88
129
|
const entriesOf = (archive: string) =>
|
|
89
130
|
Bun.spawnSync(["tar", "-tf", archive]).stdout.toString().split(/\r?\n/).filter(Boolean);
|
|
90
131
|
|
|
132
|
+
//? Every header, a pax one too: a file's name is the `path` of the pax header before it, else its own name field.
|
|
133
|
+
const headersOf = (bytes: Uint8Array) => {
|
|
134
|
+
const headers: { type: string; name: string; prefix: string; paxKeys: string[] }[] = [];
|
|
135
|
+
let paxPath: string | null = null;
|
|
136
|
+
for (let at = 0; at + 512 <= bytes.length; ) {
|
|
137
|
+
const block = Buffer.from(bytes.subarray(at, at + 512));
|
|
138
|
+
if (block.every((byte) => byte === 0)) break;
|
|
139
|
+
const field = (from: number, width: number) => block.toString("latin1", from, from + width).replace(/\0.*$/s, "");
|
|
140
|
+
const type = field(156, 1);
|
|
141
|
+
const size = Number.parseInt(field(124, 12), 8);
|
|
142
|
+
const records = Buffer.from(bytes.subarray(at + 512, at + 512 + size))
|
|
143
|
+
.toString("utf8")
|
|
144
|
+
.split("\n")
|
|
145
|
+
.filter(Boolean);
|
|
146
|
+
at += 512 + Math.ceil(size / 512) * 512;
|
|
147
|
+
const pax = type === "x" ? records.map((record) => record.slice(record.indexOf(" ") + 1)) : [];
|
|
148
|
+
const paxKeys = pax.map((record) => record.slice(0, record.indexOf("=")));
|
|
149
|
+
headers.push({ type, name: (type === "x" ? null : paxPath) ?? field(0, 100), prefix: field(345, 155), paxKeys });
|
|
150
|
+
paxPath = type === "x" ? (pax.find((record) => record.startsWith("path="))?.slice(5) ?? null) : null;
|
|
151
|
+
}
|
|
152
|
+
return headers;
|
|
153
|
+
};
|
|
154
|
+
|
|
91
155
|
const extract = async (archive: string) => {
|
|
92
156
|
const out = await makeDir();
|
|
93
157
|
expect(Bun.spawnSync(["tar", "-xf", archive, "-C", out]).exitCode).toBe(0);
|
|
@@ -99,7 +163,8 @@ describe("DesktopRelease", () => {
|
|
|
99
163
|
const published = await publish();
|
|
100
164
|
const { dir, tarSha, delta } = published;
|
|
101
165
|
await publish({ channel: "develop" }).then(async (other) => {
|
|
102
|
-
|
|
166
|
+
await mkdir(path.join(dir, "develop"), { recursive: true });
|
|
167
|
+
for (const name of ["develop.json", "develop.json.sig", "develop/app.json"])
|
|
103
168
|
await writeFile(path.join(dir, name), await readFile(path.join(other.dir, name)));
|
|
104
169
|
});
|
|
105
170
|
await writeFile(path.join(dir, "app", `${sha256("an earlier tar")}.tar.gz`), "an earlier release");
|
|
@@ -110,6 +175,7 @@ describe("DesktopRelease", () => {
|
|
|
110
175
|
|
|
111
176
|
expect(entries).toEqual([
|
|
112
177
|
"./release.json",
|
|
178
|
+
"./main/app.json",
|
|
113
179
|
"./main.json",
|
|
114
180
|
"./main.json.sig",
|
|
115
181
|
`./app/${tarSha}.tar.gz`,
|
|
@@ -117,18 +183,36 @@ describe("DesktopRelease", () => {
|
|
|
117
183
|
]);
|
|
118
184
|
});
|
|
119
185
|
|
|
120
|
-
test("the
|
|
186
|
+
test("keeps the one tar shape Akan Cloud accepts, a pax name of a long patch included", async () => {
|
|
187
|
+
const published = await publish();
|
|
188
|
+
const release = await read(published);
|
|
189
|
+
|
|
190
|
+
const headers = await release.withArchive(release.record(null), async (archive) =>
|
|
191
|
+
headersOf(await readFile(archive)),
|
|
192
|
+
);
|
|
193
|
+
|
|
194
|
+
expect(new Set(headers.map(({ type }) => type))).toEqual(new Set(["0", "x"]));
|
|
195
|
+
expect(headers.filter(({ prefix }) => prefix)).toEqual([]);
|
|
196
|
+
expect(headers.filter(({ type }) => type === "x").map(({ paxKeys }) => paxKeys)).toEqual([["path"]]);
|
|
197
|
+
const files = headers.filter(({ type }) => type === "0").map(({ name }) => name);
|
|
198
|
+
expect(files.slice(0, 4)).toEqual(["./release.json", "./main/app.json", "./main.json", "./main.json.sig"]);
|
|
199
|
+
expect(files.slice(4).every((name) => name.startsWith("./app/"))).toBe(true);
|
|
200
|
+
expect(files.slice(4)).toEqual([`./app/${published.tarSha}.tar.gz`, `./app/${published.delta}`]);
|
|
201
|
+
expect(Buffer.byteLength(`./app/${published.delta}`)).toBeGreaterThan(100);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
test("the envelope and the v1 pair go in byte for byte, beside a release.json of the CLI and the build time", async () => {
|
|
121
205
|
const published = await publish();
|
|
122
206
|
const release = await read(published);
|
|
123
207
|
const record = release.record({ teamId: "ABCDE12345" });
|
|
124
208
|
|
|
125
209
|
const out = await release.withArchive(record, extract);
|
|
126
210
|
|
|
127
|
-
for (const name of ["main.json", "main.json.sig"])
|
|
211
|
+
for (const name of ["main/app.json", "main.json", "main.json.sig"])
|
|
128
212
|
expect(sha256(await readFile(path.join(out, name)))).toBe(sha256(await readFile(path.join(published.dir, name))));
|
|
129
213
|
expect(await readFile(path.join(out, "release.json"), "utf8")).toBe(`${JSON.stringify(record, null, 2)}\n`);
|
|
130
214
|
expect(record).toEqual({
|
|
131
|
-
schema:
|
|
215
|
+
schema: 2,
|
|
132
216
|
cli: AkanAppConfig.akanVersion,
|
|
133
217
|
platform: "macos",
|
|
134
218
|
arch: "arm64",
|
|
@@ -151,22 +235,50 @@ describe("DesktopRelease", () => {
|
|
|
151
235
|
expect(await Bun.file(kept).exists()).toBe(false);
|
|
152
236
|
});
|
|
153
237
|
|
|
154
|
-
test("refuses
|
|
238
|
+
test("refuses an envelope no key of the ring signs, or a revoked one, before reading anything it names", async () => {
|
|
155
239
|
const published = await publish();
|
|
240
|
+
const envelope = path.join(published.dir, "main", "app.json");
|
|
241
|
+
const untrusted = `${envelope}: the update envelope is signed by key ${key.keyId}, which is not trusted; publish the release again.`;
|
|
242
|
+
|
|
243
|
+
await expect(read(published, { publicKeys: [keyOf().publicKey] })).rejects.toThrow(untrusted);
|
|
244
|
+
await expect(read(published, { revokedKeys: [key.keyId] })).rejects.toThrow(untrusted);
|
|
245
|
+
});
|
|
156
246
|
|
|
157
|
-
|
|
158
|
-
|
|
247
|
+
test("takes a release a later key signed, and leaves out a v1 pair that is not that release", async () => {
|
|
248
|
+
const entriesFor = async (published: Published) => {
|
|
249
|
+
const release = await read(published);
|
|
250
|
+
return await release.withArchive(release.record(null), async (archive) => entriesOf(archive).slice(0, 4));
|
|
251
|
+
};
|
|
252
|
+
const tail = (published: Published) => `./app/${published.tarSha}.tar.gz`;
|
|
253
|
+
|
|
254
|
+
const bySpare = await publish({ signer: spare, pair: "earlier" });
|
|
255
|
+
expect(await entriesFor(bySpare)).toEqual([
|
|
256
|
+
"./release.json",
|
|
257
|
+
"./main/app.json",
|
|
258
|
+
tail(bySpare),
|
|
259
|
+
`./app/${bySpare.delta}`,
|
|
260
|
+
]);
|
|
261
|
+
const unpaired = await publish({ pair: "none" });
|
|
262
|
+
expect((await entriesFor(unpaired)).slice(0, 3)).toEqual(["./release.json", "./main/app.json", tail(unpaired)]);
|
|
263
|
+
const forged = await publish();
|
|
264
|
+
await writeFile(
|
|
265
|
+
path.join(forged.dir, "main.json.sig"),
|
|
266
|
+
spare.sign(await readFile(path.join(forged.dir, "main.json"), "utf8")),
|
|
159
267
|
);
|
|
268
|
+
expect((await entriesFor(forged)).slice(0, 3)).toEqual(["./release.json", "./main/app.json", tail(forged)]);
|
|
160
269
|
});
|
|
161
270
|
|
|
162
271
|
test("refuses a release of another app, and names a channel or CPU it holds none of", async () => {
|
|
163
272
|
const published = await publish();
|
|
164
273
|
|
|
165
274
|
await expect(read(published, { id: "com.other" })).rejects.toThrow(
|
|
166
|
-
"is not a release of com.other for macos-arm64 on channel main.",
|
|
275
|
+
"is not a release of com.other target default for macos-arm64 on channel main.",
|
|
276
|
+
);
|
|
277
|
+
await expect(read(published, { target: "pilot" })).rejects.toThrow(
|
|
278
|
+
`is not a release of ${appId} target pilot for macos-arm64 on channel main.`,
|
|
167
279
|
);
|
|
168
280
|
await expect(read(published, { channel: "develop" })).rejects.toThrow(
|
|
169
|
-
`${path.join(published.dir, "develop.json")} is missing: run \`akan publish-update\` first.`,
|
|
281
|
+
`${path.join(published.dir, "develop", "app.json")} is missing: run \`akan publish-update\` first.`,
|
|
170
282
|
);
|
|
171
283
|
await expect(read(published, { arch: "x64" })).rejects.toThrow("macos-x64");
|
|
172
284
|
});
|
|
@@ -178,9 +290,9 @@ describe("DesktopRelease", () => {
|
|
|
178
290
|
const send = () => release.withArchive(release.record(null), async () => "sent");
|
|
179
291
|
|
|
180
292
|
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.`);
|
|
293
|
+
await expect(send()).rejects.toThrow(`${archive} is not the file main/app.json names: publish the release again.`);
|
|
182
294
|
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).`);
|
|
295
|
+
await expect(send()).rejects.toThrow(`${archive} is not the file main/app.json names (its sha256 differs).`);
|
|
184
296
|
});
|
|
185
297
|
});
|
|
186
298
|
|
package/mobile/desktopRelease.ts
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { createPublicKey, verify } from "node:crypto";
|
|
2
1
|
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
|
3
2
|
import os from "node:os";
|
|
4
3
|
import path from "node:path";
|
|
@@ -7,14 +6,18 @@ import type { UpdateManifest } from "akanjs/native/api";
|
|
|
7
6
|
import { AkanAppConfig } from "../akanConfig";
|
|
8
7
|
import type { DesktopArch } from "./desktopBin";
|
|
9
8
|
import type { DesktopPlatform } from "./mobileTarget";
|
|
9
|
+
import type { NativeBuildApiModule } from "./nativeApi";
|
|
10
10
|
import { TarWriter } from "./tarWriter";
|
|
11
11
|
import { UpdateUploader } from "./updateUploader";
|
|
12
12
|
|
|
13
13
|
export type DesktopSigner = { teamId: string } | { subject: string } | null;
|
|
14
14
|
|
|
15
|
-
/**
|
|
15
|
+
/**
|
|
16
|
+
* `release.json`, the one file of a desktop release archive `publish-update` did not write. Schema 2: the archive holds
|
|
17
|
+
* the release's envelope, and its v1 pair only when that is the same release.
|
|
18
|
+
*/
|
|
16
19
|
export interface DesktopReleaseRecord {
|
|
17
|
-
schema:
|
|
20
|
+
schema: 2;
|
|
18
21
|
cli: string;
|
|
19
22
|
platform: DesktopPlatform;
|
|
20
23
|
arch: DesktopArch;
|
|
@@ -37,10 +40,15 @@ interface DesktopReleasePayload {
|
|
|
37
40
|
sha256: string;
|
|
38
41
|
}
|
|
39
42
|
|
|
40
|
-
|
|
41
|
-
|
|
43
|
+
interface DesktopReleaseManifestFile {
|
|
44
|
+
name: string;
|
|
45
|
+
bytes: Uint8Array<ArrayBuffer>;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
//* One desktop channel's release as `publish-update` left it in `<updates>/<os>-<arch>/`: the signed envelope, the v1
|
|
49
|
+
//* pair of the same release, and the files the manifest names, packed as they are, so the same folder uploaded anywhere
|
|
50
|
+
//* else stays valid.
|
|
42
51
|
export class DesktopRelease {
|
|
43
|
-
static readonly #ed25519Spki = Buffer.from("302a300506032b6570032100", "hex");
|
|
44
52
|
//? Constrained Language Mode (WDAC, AppLocker) refuses .NET type literals and method calls, so the script makes none.
|
|
45
53
|
//? Windows PowerShell writes its console in the OEM code page, which mangles a subject outside it; a file keeps UTF-8.
|
|
46
54
|
static readonly #authenticode = `$ErrorActionPreference = 'Stop'
|
|
@@ -56,8 +64,7 @@ Set-Content -LiteralPath $env:AKAN_SIGNER_OUT -Value $subject -Encoding UTF8`;
|
|
|
56
64
|
readonly platform: DesktopPlatform,
|
|
57
65
|
readonly arch: DesktopArch,
|
|
58
66
|
readonly channel: string,
|
|
59
|
-
readonly
|
|
60
|
-
readonly signatureBytes: Uint8Array<ArrayBuffer>,
|
|
67
|
+
readonly manifestFiles: DesktopReleaseManifestFile[],
|
|
61
68
|
) {}
|
|
62
69
|
|
|
63
70
|
static async read(
|
|
@@ -67,39 +74,48 @@ Set-Content -LiteralPath $env:AKAN_SIGNER_OUT -Value $subject -Encoding UTF8`;
|
|
|
67
74
|
arch,
|
|
68
75
|
channel,
|
|
69
76
|
appId,
|
|
70
|
-
|
|
71
|
-
|
|
77
|
+
target,
|
|
78
|
+
publicKeys,
|
|
79
|
+
revokedKeys = [],
|
|
80
|
+
api,
|
|
81
|
+
}: {
|
|
82
|
+
platform: DesktopPlatform;
|
|
83
|
+
arch: DesktopArch;
|
|
84
|
+
channel: string;
|
|
85
|
+
appId: string;
|
|
86
|
+
target?: string;
|
|
87
|
+
publicKeys: readonly string[];
|
|
88
|
+
revokedKeys?: readonly string[];
|
|
89
|
+
api: Pick<NativeBuildApiModule, "openEnvelope" | "verifyManifest">;
|
|
90
|
+
},
|
|
72
91
|
) {
|
|
73
92
|
const folder = `${platform}-${arch}`;
|
|
74
93
|
const dir = path.join(updatesDir, folder);
|
|
75
|
-
const
|
|
76
|
-
const
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
if (
|
|
81
|
-
|
|
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);
|
|
94
|
+
const envelopeName = `${channel}/app.json`;
|
|
95
|
+
const envelopePath = path.join(dir, channel, "app.json");
|
|
96
|
+
const envelope = await readFile(envelopePath).catch(() => null);
|
|
97
|
+
if (!envelope) throw new Error(`${envelopePath} is missing: run \`akan publish-update\` first.`);
|
|
98
|
+
const opened = DesktopRelease.#open(api, envelope, publicKeys, revokedKeys);
|
|
99
|
+
if (typeof opened === "string") throw new Error(`${envelopePath}: ${opened}; publish the release again.`);
|
|
100
|
+
const manifest = DesktopRelease.#parse(opened);
|
|
89
101
|
if (
|
|
90
|
-
manifest?.schema !==
|
|
102
|
+
manifest?.schema !== 2 ||
|
|
91
103
|
manifest.kind !== "app" ||
|
|
92
104
|
manifest.app !== appId ||
|
|
93
105
|
manifest.platform !== platform ||
|
|
94
106
|
manifest.arch !== arch ||
|
|
95
107
|
manifest.channel !== channel ||
|
|
108
|
+
manifest.target !== target ||
|
|
96
109
|
typeof manifest.bundle !== "string" ||
|
|
97
110
|
typeof manifest.sequence !== "number" ||
|
|
98
111
|
typeof manifest.build !== "number" ||
|
|
99
112
|
!manifest.archive
|
|
100
113
|
)
|
|
101
|
-
throw new Error(
|
|
114
|
+
throw new Error(
|
|
115
|
+
`${envelopePath} is not a release of ${appId}${target ? ` target ${target}` : ""} for ${folder} on channel ${channel}.`,
|
|
116
|
+
);
|
|
102
117
|
const { archive, bundle, sequence, build } = manifest;
|
|
118
|
+
const legacy = await DesktopRelease.#legacyPair(api, dir, channel, publicKeys[0], manifest);
|
|
103
119
|
const keys = await UpdateUploader.releaseFiles(updatesDir, { dir, channel, bundle });
|
|
104
120
|
const expected = new Map(
|
|
105
121
|
[{ url: archive.url, size: archive.size, sha256: archive.gzSha256 }, ...(manifest.patches ?? [])].map(
|
|
@@ -118,19 +134,41 @@ Set-Content -LiteralPath $env:AKAN_SIGNER_OUT -Value $subject -Encoding UTF8`;
|
|
|
118
134
|
platform,
|
|
119
135
|
arch,
|
|
120
136
|
channel,
|
|
121
|
-
|
|
122
|
-
|
|
137
|
+
[{ name: envelopeName, bytes: envelope }, ...legacy],
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
//? An app built before protocol v2 reads the pair, signed with the first key only; a pair of an earlier release is
|
|
142
|
+
//? left out, since it would publish that release again to those apps.
|
|
143
|
+
static async #legacyPair(
|
|
144
|
+
api: Pick<NativeBuildApiModule, "verifyManifest">,
|
|
145
|
+
dir: string,
|
|
146
|
+
channel: string,
|
|
147
|
+
publicKey: string | undefined,
|
|
148
|
+
manifest: Partial<UpdateManifest>,
|
|
149
|
+
): Promise<DesktopReleaseManifestFile[]> {
|
|
150
|
+
const [bytes, signature] = await Promise.all(
|
|
151
|
+
[`${channel}.json`, `${channel}.json.sig`].map((name) => readFile(path.join(dir, name)).catch(() => null)),
|
|
123
152
|
);
|
|
153
|
+
if (!bytes || !signature || !publicKey) return [];
|
|
154
|
+
const v1 = DesktopRelease.#parse(bytes);
|
|
155
|
+
if (v1?.schema !== 1 || v1.bundle !== manifest.bundle || v1.sequence !== manifest.sequence) return [];
|
|
156
|
+
if (!api.verifyManifest(bytes, new TextDecoder().decode(signature).trim(), publicKey)) return [];
|
|
157
|
+
return [
|
|
158
|
+
{ name: `${channel}.json`, bytes },
|
|
159
|
+
{ name: `${channel}.json.sig`, bytes: signature },
|
|
160
|
+
];
|
|
124
161
|
}
|
|
125
162
|
|
|
126
163
|
record(signer: DesktopSigner): DesktopReleaseRecord {
|
|
127
164
|
const { platform, arch, channel } = this;
|
|
128
165
|
const builtAt = new Date(this.manifest.build * 1000).toISOString();
|
|
129
|
-
return { schema:
|
|
166
|
+
return { schema: 2, cli: AkanAppConfig.akanVersion, platform, arch, channel, signer, builtAt };
|
|
130
167
|
}
|
|
131
168
|
|
|
132
|
-
//*
|
|
133
|
-
//*
|
|
169
|
+
//* Akan Cloud refuses every push in any other shape: uncompressed, regular files under `./` names, no directory
|
|
170
|
+
//* entry, no ustar prefix, no GNU L/K, a pax 'x' holding `path` alone, and release.json, the envelope and the v1 pair
|
|
171
|
+
//* before any `app/` entry. What the manifest names is checked against it as it is copied.
|
|
134
172
|
async withArchive<T>(record: DesktopReleaseRecord, use: (archive: string) => Promise<T>): Promise<T> {
|
|
135
173
|
const tmp = await mkdtemp(path.join(os.tmpdir(), "akan-release-"));
|
|
136
174
|
try {
|
|
@@ -138,11 +176,10 @@ Set-Content -LiteralPath $env:AKAN_SIGNER_OUT -Value $subject -Encoding UTF8`;
|
|
|
138
176
|
const tar = await TarWriter.create(archive, { mtime: this.manifest.sequence });
|
|
139
177
|
try {
|
|
140
178
|
await tar.add("./release.json", new Blob([`${JSON.stringify(record, null, 2)}\n`]));
|
|
141
|
-
await tar.add(`./${
|
|
142
|
-
await tar.add(`./${this.channel}.json.sig`, new Blob([this.signatureBytes]));
|
|
179
|
+
for (const { name, bytes } of this.manifestFiles) await tar.add(`./${name}`, new Blob([bytes]));
|
|
143
180
|
for (const { name, size, sha256 } of this.payloads) {
|
|
144
181
|
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`;
|
|
182
|
+
const what = `${path.join(this.dir, name)} is not the file ${this.channel}/app.json names`;
|
|
146
183
|
if (!(await file.exists()) || file.size !== size) throw new Error(`${what}: publish the release again.`);
|
|
147
184
|
if ((await tar.add(`./${name}`, file)) !== sha256) throw new Error(`${what} (its sha256 differs).`);
|
|
148
185
|
}
|
|
@@ -169,7 +206,7 @@ Set-Content -LiteralPath $env:AKAN_SIGNER_OUT -Value $subject -Encoding UTF8`;
|
|
|
169
206
|
if (this.platform === "linux") return { signer: null, unread: null };
|
|
170
207
|
const app = this.manifest.files?.[0]?.path;
|
|
171
208
|
if (!app || !/^[^/\\]+$/.test(app) || app === "." || app === "..")
|
|
172
|
-
return { signer: null, unread: `${this.channel}.json names no app folder` };
|
|
209
|
+
return { signer: null, unread: `${this.channel}/app.json names no app folder` };
|
|
173
210
|
if (this.platform === "macos" && host !== "darwin")
|
|
174
211
|
return { signer: null, unread: "codesign reads a macOS app's signer on macOS only" };
|
|
175
212
|
if (this.platform === "windows" && host !== "win32")
|
|
@@ -227,16 +264,16 @@ Set-Content -LiteralPath $env:AKAN_SIGNER_OUT -Value $subject -Encoding UTF8`;
|
|
|
227
264
|
return { signer: subject ? { subject } : null, unread: null };
|
|
228
265
|
}
|
|
229
266
|
|
|
230
|
-
static #
|
|
267
|
+
static #open(
|
|
268
|
+
api: Pick<NativeBuildApiModule, "openEnvelope">,
|
|
269
|
+
envelope: Uint8Array,
|
|
270
|
+
publicKeys: readonly string[],
|
|
271
|
+
revokedKeys: readonly string[],
|
|
272
|
+
): Uint8Array | string {
|
|
231
273
|
try {
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
type: "spki",
|
|
236
|
-
});
|
|
237
|
-
return verify(null, manifest, key, Buffer.from(new TextDecoder().decode(signature).trim(), "base64"));
|
|
238
|
-
} catch {
|
|
239
|
-
return false;
|
|
274
|
+
return api.openEnvelope(new TextDecoder().decode(envelope), publicKeys, revokedKeys).manifest;
|
|
275
|
+
} catch (error) {
|
|
276
|
+
return toError(error).message;
|
|
240
277
|
}
|
|
241
278
|
}
|
|
242
279
|
|
package/mobile/nativeApi.test.ts
CHANGED
|
@@ -42,6 +42,9 @@ describe("NativeApi", () => {
|
|
|
42
42
|
});
|
|
43
43
|
|
|
44
44
|
test("refuses an API older than the one the build calls into, and another major, before anything builds", async () => {
|
|
45
|
+
await expect(NativeApi.load(await installVendoredApi("0.12.0"))).rejects.toThrow(
|
|
46
|
+
"install matching versions of akanjs and @akanjs/cli",
|
|
47
|
+
);
|
|
45
48
|
await expect(NativeApi.load(await installVendoredApi("0.10.0"))).rejects.toThrow(
|
|
46
49
|
"install matching versions of akanjs and @akanjs/cli",
|
|
47
50
|
);
|
|
@@ -51,6 +54,6 @@ describe("NativeApi", () => {
|
|
|
51
54
|
expect((await NativeApi.load(await installVendoredApi(NativeApi.minimumVersion))).API_VERSION).toBe(
|
|
52
55
|
NativeApi.minimumVersion,
|
|
53
56
|
);
|
|
54
|
-
expect((await NativeApi.load(await installVendoredApi("0.
|
|
57
|
+
expect((await NativeApi.load(await installVendoredApi("0.13.4"))).API_VERSION).toBe("0.13.4");
|
|
55
58
|
});
|
|
56
59
|
});
|
package/mobile/nativeApi.ts
CHANGED
|
@@ -6,9 +6,9 @@ export type NativeBuildApiModule = typeof NativeBuildApi;
|
|
|
6
6
|
export class NativeApi {
|
|
7
7
|
//* The build API versions itself; a major this devkit was not written against is refused before anything builds.
|
|
8
8
|
static readonly supportedMajor = 0;
|
|
9
|
-
//?
|
|
10
|
-
//? `requestedDistribution` in 0.11.0; akanjs 3.0.x vendors 0.10.0.
|
|
11
|
-
static readonly minimumVersion = "0.
|
|
9
|
+
//? A desktop release's envelope and `updates.target` arrived in 0.13.0, and an older API refuses the key; an update
|
|
10
|
+
//? release's `revoke` and `resetSequence` in 0.12.0; `requestedDistribution` in 0.11.0; akanjs 3.0.x vendors 0.10.0.
|
|
11
|
+
static readonly minimumVersion = "0.13.0";
|
|
12
12
|
|
|
13
13
|
/** The API of the akanjs the app installed: the workspace package inside this repo, akanjs's vendored copy elsewhere. */
|
|
14
14
|
static entryOf(appDir: string): string {
|
package/mobile/nativeApp.test.ts
CHANGED
|
@@ -341,8 +341,8 @@ describe("NativeApp", () => {
|
|
|
341
341
|
(await new NativeApp(fakeApp(), target({ basePath: undefined, updates }), env).config({ platform: "android" }))
|
|
342
342
|
.config.updates;
|
|
343
343
|
|
|
344
|
-
expect(await built("main")).toEqual({ ...updates, channel: "main" });
|
|
345
|
-
expect(await built("debug")).toEqual({ ...updates, channel: "debug" });
|
|
344
|
+
expect(await built("main")).toEqual({ ...updates, channel: "main", target: "default" });
|
|
345
|
+
expect(await built("debug")).toEqual({ ...updates, channel: "debug", target: "default" });
|
|
346
346
|
});
|
|
347
347
|
|
|
348
348
|
test("a desktop app is this computer's platform", () => {
|
package/mobile/nativeApp.ts
CHANGED
|
@@ -27,7 +27,7 @@ type TaskOptions = Parameters<NativeBuildApiModule["build"]>[0];
|
|
|
27
27
|
type DeviceSelector = NonNullable<Parameters<NativeBuildApiModule["run"]>[0]["device"]>;
|
|
28
28
|
type ReleaseOptions = Parameters<NativeBuildApiModule["release"]>[0];
|
|
29
29
|
type AndroidSigning = Extract<ReleaseOptions, { platform: "android" }>["signing"];
|
|
30
|
-
type
|
|
30
|
+
type ReleaseRevocation = Pick<Parameters<NativeBuildApiModule["checkPublishUpdate"]>[0], "revoke" | "resetSequence">;
|
|
31
31
|
|
|
32
32
|
export interface NativeRunOptions {
|
|
33
33
|
device?: DeviceSelector;
|
|
@@ -315,17 +315,24 @@ export class NativeApp {
|
|
|
315
315
|
//? The channel falls back as publishUpdate's does, so a push with the flags of the publish finds what it wrote.
|
|
316
316
|
async publishedRelease(platform: DesktopPlatform, { arch, channel }: { arch: DesktopArch; channel?: string | null }) {
|
|
317
317
|
this.#assertUpdates();
|
|
318
|
-
const { config } = await
|
|
319
|
-
|
|
320
|
-
|
|
318
|
+
const [api, { config }] = await Promise.all([
|
|
319
|
+
NativeApi.load(this.app.cwdPath),
|
|
320
|
+
this.config({ platform, stageBin: false }),
|
|
321
|
+
]);
|
|
322
|
+
const { updates } = config;
|
|
323
|
+
const publicKeys = updates?.publicKeys ?? (updates?.publicKey ? [updates.publicKey] : []);
|
|
324
|
+
if (!updates || !publicKeys.length) throw new Error(`Native target '${this.target.name}' has no updates key.`);
|
|
321
325
|
const release = await DesktopRelease.read(this.updatesDir, {
|
|
322
326
|
platform,
|
|
323
327
|
arch,
|
|
324
|
-
channel: channel ??
|
|
328
|
+
channel: channel ?? updates.channel ?? "production",
|
|
325
329
|
appId: config.app.id,
|
|
326
|
-
|
|
330
|
+
...(updates.target ? { target: updates.target } : {}),
|
|
331
|
+
publicKeys,
|
|
332
|
+
revokedKeys: updates.revokedKeys ?? [],
|
|
333
|
+
api,
|
|
327
334
|
});
|
|
328
|
-
return { release, url:
|
|
335
|
+
return { release, url: updates.url, exeName: config.app.fileName ?? "" };
|
|
329
336
|
}
|
|
330
337
|
|
|
331
338
|
/** A signed release installed apps take through the updates plugin, from a release build of this bundle. */
|
|
@@ -345,7 +352,7 @@ export class NativeApp {
|
|
|
345
352
|
arch?: DesktopArch;
|
|
346
353
|
sign?: boolean;
|
|
347
354
|
notarize?: boolean;
|
|
348
|
-
} &
|
|
355
|
+
} & ReleaseRevocation = {},
|
|
349
356
|
) {
|
|
350
357
|
NativeApp.#assertServerPlatform(platform, server);
|
|
351
358
|
this.#assertUpdates();
|
|
@@ -399,8 +406,8 @@ export class NativeApp {
|
|
|
399
406
|
|
|
400
407
|
/**
|
|
401
408
|
* What publishing refuses once the release is built: no updates settings, an invalid config, no signing key or
|
|
402
|
-
* another app's, a desktop release whose server presence is not its channel's previous release's, and a
|
|
403
|
-
*
|
|
409
|
+
* another app's, a desktop release whose server presence is not its channel's previous release's, and a release whose
|
|
410
|
+
* revocations no app would take.
|
|
404
411
|
*/
|
|
405
412
|
async assertPublishable(
|
|
406
413
|
platform: NativePlatform,
|
|
@@ -410,7 +417,7 @@ export class NativeApp {
|
|
|
410
417
|
arch,
|
|
411
418
|
revoke,
|
|
412
419
|
resetSequence,
|
|
413
|
-
}: { channel?: string; server?: boolean; arch?: DesktopArch } &
|
|
420
|
+
}: { channel?: string; server?: boolean; arch?: DesktopArch } & ReleaseRevocation = {},
|
|
414
421
|
) {
|
|
415
422
|
this.#assertUpdates();
|
|
416
423
|
const [api, { config }] = await Promise.all([
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { generateKeyPairSync } from "node:crypto";
|
|
2
3
|
import { mkdir, writeFile } from "node:fs/promises";
|
|
3
4
|
import path from "node:path";
|
|
4
5
|
import type { AkanNativeTarget } from "../akanConfig";
|
|
@@ -42,7 +43,9 @@ const adminTarget: AkanNativeTarget = {
|
|
|
42
43
|
},
|
|
43
44
|
};
|
|
44
45
|
|
|
45
|
-
const
|
|
46
|
+
const ed25519Key = () =>
|
|
47
|
+
generateKeyPairSync("ed25519").publicKey.export({ format: "der", type: "spki" }).subarray(12).toString("base64");
|
|
48
|
+
const updatesKey = ed25519Key();
|
|
46
49
|
|
|
47
50
|
describe("NativeConfig.build", () => {
|
|
48
51
|
test("hands the dmg window over with its pictures resolved against the app folder", () => {
|
|
@@ -224,19 +227,22 @@ describe("NativeConfig.build", () => {
|
|
|
224
227
|
url,
|
|
225
228
|
publicKey: updatesKey,
|
|
226
229
|
channel: "develop",
|
|
230
|
+
target: "admin",
|
|
227
231
|
});
|
|
228
232
|
expect(build({ url, publicKey: updatesKey, channel: "beta", readyTimeout: 15_000 }, "main").updates).toEqual({
|
|
229
233
|
url,
|
|
230
234
|
publicKey: updatesKey,
|
|
231
235
|
channel: "beta",
|
|
232
236
|
readyTimeout: 15_000,
|
|
237
|
+
target: "admin",
|
|
233
238
|
});
|
|
234
|
-
expect(build({ url, publicKey: updatesKey }).updates).toEqual({ url, publicKey: updatesKey });
|
|
235
|
-
const spare =
|
|
239
|
+
expect(build({ url, publicKey: updatesKey }).updates).toEqual({ url, publicKey: updatesKey, target: "admin" });
|
|
240
|
+
const spare = ed25519Key();
|
|
236
241
|
expect(build({ url, publicKeys: [updatesKey, spare] }, "main").updates).toEqual({
|
|
237
242
|
url,
|
|
238
243
|
publicKeys: [updatesKey, spare],
|
|
239
244
|
channel: "main",
|
|
245
|
+
target: "admin",
|
|
240
246
|
});
|
|
241
247
|
const report = { url: "https://cloud.example.com/api/ota/report/abcdefghijklmnop" };
|
|
242
248
|
expect(build({ url, publicKey: updatesKey, report }, "main").updates).toEqual({
|
|
@@ -244,6 +250,7 @@ describe("NativeConfig.build", () => {
|
|
|
244
250
|
publicKey: updatesKey,
|
|
245
251
|
channel: "main",
|
|
246
252
|
report,
|
|
253
|
+
target: "admin",
|
|
247
254
|
});
|
|
248
255
|
expect(build({ url, publicKey: updatesKey }, "main").plugins).toContain("updates");
|
|
249
256
|
expect(build(undefined, "main").plugins).not.toContain("updates");
|
|
@@ -457,7 +464,7 @@ describe("NativeConfig.build", () => {
|
|
|
457
464
|
});
|
|
458
465
|
|
|
459
466
|
expect(config.plugins).toEqual([...NativeConfig.basePlugins, "updates"]);
|
|
460
|
-
expect(config.updates).toEqual(updates);
|
|
467
|
+
expect(config.updates).toEqual({ ...updates, target: minimalTarget.name });
|
|
461
468
|
});
|
|
462
469
|
|
|
463
470
|
test("an updates channel left unnamed is the backend env the binary is built for", () => {
|
|
@@ -479,10 +486,11 @@ describe("NativeConfig.build", () => {
|
|
|
479
486
|
...(env ? { env } : {}),
|
|
480
487
|
}).config.updates;
|
|
481
488
|
|
|
482
|
-
|
|
483
|
-
expect(build("
|
|
484
|
-
expect(build("
|
|
485
|
-
expect(build()).toEqual(updates);
|
|
489
|
+
const target = minimalTarget.name;
|
|
490
|
+
expect(build("debug")).toEqual({ ...updates, channel: "debug", target });
|
|
491
|
+
expect(build("main")).toEqual({ ...updates, channel: "main", target });
|
|
492
|
+
expect(build("debug", "pilot")).toEqual({ ...updates, channel: "pilot", target });
|
|
493
|
+
expect(build()).toEqual({ ...updates, target });
|
|
486
494
|
});
|
|
487
495
|
|
|
488
496
|
test("keeps a file name the runtime accepts", () => {
|
package/mobile/nativeConfig.ts
CHANGED
|
@@ -267,7 +267,7 @@ export class NativeConfig {
|
|
|
267
267
|
static #updates(target: AkanNativeTarget, env?: NativeEnv): NonNullable<AkanNativeConfig["updates"]> | undefined {
|
|
268
268
|
const { url, publicKey, publicKeys, channel = env, ...rest } = target.updates ?? {};
|
|
269
269
|
const keys = publicKeys ? { publicKeys: [...publicKeys] } : publicKey ? { publicKey } : null;
|
|
270
|
-
return url && keys ? { ...rest, url, ...keys, ...(channel ? { channel } : {}) } : undefined;
|
|
270
|
+
return url && keys ? { ...rest, url, ...keys, ...(channel ? { channel } : {}), target: target.name } : undefined;
|
|
271
271
|
}
|
|
272
272
|
|
|
273
273
|
/** Letters, digits, `.`, `_` and `-` only; the folder name of an akan app already is one almost always. */
|
|
@@ -83,6 +83,40 @@ describe("UpdateUploader", () => {
|
|
|
83
83
|
);
|
|
84
84
|
});
|
|
85
85
|
|
|
86
|
+
test("uploads a desktop release's envelope, with the v1 pair only when the pair is that release", async () => {
|
|
87
|
+
const manifest = { kind: "app", bundle: "1.3.0-3000", archive: { url: `app/${sha("c")}.tar.gz` }, patches: [] };
|
|
88
|
+
await inUpdatesDir(
|
|
89
|
+
{
|
|
90
|
+
[`macos-arm64/app/${sha("c")}.tar.gz`]: "tar",
|
|
91
|
+
"macos-arm64/main/app.json": envelope(manifest),
|
|
92
|
+
"macos-arm64/main.json": JSON.stringify({ ...manifest, bundle: "1.2.0-2000" }),
|
|
93
|
+
"macos-arm64/main.json.sig": "sig",
|
|
94
|
+
"macos-arm64/beta/app.json": envelope({ ...manifest, bundle: "1.4.0-4000" }),
|
|
95
|
+
"macos-arm64/beta.json": JSON.stringify({ ...manifest, bundle: "1.4.0-4000" }),
|
|
96
|
+
"macos-arm64/beta.json.sig": "sig",
|
|
97
|
+
},
|
|
98
|
+
async (updatesDir) => {
|
|
99
|
+
const dir = path.join(updatesDir, "macos-arm64");
|
|
100
|
+
const upload = async (channel: string, bundle: string) => {
|
|
101
|
+
const { bucket, writes } = fakeBucket();
|
|
102
|
+
await new UpdateUploader(bucket, "").upload(updatesDir, { dir, channel, bundle });
|
|
103
|
+
return writes.map(({ key }) => key);
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
expect(await upload("main", "1.3.0-3000")).toEqual([
|
|
107
|
+
`macos-arm64/app/${sha("c")}.tar.gz`,
|
|
108
|
+
"macos-arm64/main/app.json",
|
|
109
|
+
]);
|
|
110
|
+
expect(await upload("beta", "1.4.0-4000")).toEqual([
|
|
111
|
+
`macos-arm64/app/${sha("c")}.tar.gz`,
|
|
112
|
+
"macos-arm64/beta.json.sig",
|
|
113
|
+
"macos-arm64/beta.json",
|
|
114
|
+
"macos-arm64/beta/app.json",
|
|
115
|
+
]);
|
|
116
|
+
},
|
|
117
|
+
);
|
|
118
|
+
});
|
|
119
|
+
|
|
86
120
|
test("uploads a phone release's own files and envelope, never pack records or another manifest beside them", async () => {
|
|
87
121
|
await inUpdatesDir(
|
|
88
122
|
{
|
package/mobile/updateUploader.ts
CHANGED
|
@@ -51,12 +51,8 @@ export class UpdateUploader {
|
|
|
51
51
|
//* APIs, which a later publish from another machine may have replaced.
|
|
52
52
|
static async releaseFiles(updatesDir: string, { dir, channel, bundle }: PublishedUpdate): Promise<string[]> {
|
|
53
53
|
const keyOf = (file: unknown) => UpdateUploader.#keyOf(updatesDir, dir, file);
|
|
54
|
-
const
|
|
55
|
-
|
|
56
|
-
const archive = desktop.archive?.url;
|
|
57
|
-
const patches = UpdateUploader.#field(desktop.patches, "url");
|
|
58
|
-
return [archive, ...patches, `${channel}.json.sig`, `${channel}.json`].map(keyOf);
|
|
59
|
-
}
|
|
54
|
+
const v1 = await UpdateUploader.#read<UpdateManifest>(path.join(dir, `${channel}.json`));
|
|
55
|
+
const pair = v1?.bundle === bundle ? [`${channel}.json.sig`, `${channel}.json`] : [];
|
|
60
56
|
const names = await readdir(path.join(dir, channel)).catch(() => [] as string[]);
|
|
61
57
|
for (const name of names.filter((name) => name.endsWith(".json"))) {
|
|
62
58
|
const envelope = await UpdateUploader.#read<UpdateEnvelope>(path.join(dir, channel, name));
|
|
@@ -65,6 +61,8 @@ export class UpdateUploader {
|
|
|
65
61
|
? UpdateUploader.#parse<UpdateManifest>(Buffer.from(envelope.manifest, "base64").toString("utf8"))
|
|
66
62
|
: null;
|
|
67
63
|
if (manifest?.bundle !== bundle) continue;
|
|
64
|
+
if (manifest.kind === "app")
|
|
65
|
+
return [...UpdateUploader.#appFiles(manifest), ...pair, `${channel}/${name}`].map(keyOf);
|
|
68
66
|
const hashes = [...new Set(UpdateUploader.#field(manifest.files, "sha256"))];
|
|
69
67
|
if (!hashes.every((hash): hash is string => typeof hash === "string" && /^[0-9a-f]{64}$/.test(hash)))
|
|
70
68
|
throw new Error(
|
|
@@ -72,9 +70,14 @@ export class UpdateUploader {
|
|
|
72
70
|
);
|
|
73
71
|
return [...hashes.map((hash) => `${UpdatePackFolder.filesDir}/${hash}`), `${channel}/${name}`].map(keyOf);
|
|
74
72
|
}
|
|
73
|
+
if (v1 && pair.length) return [...UpdateUploader.#appFiles(v1), ...pair].map(keyOf);
|
|
75
74
|
throw new Error(`${dir} holds no ${channel} manifest of release ${bundle}; nothing was uploaded.`);
|
|
76
75
|
}
|
|
77
76
|
|
|
77
|
+
static #appFiles(manifest: Partial<UpdateManifest>): unknown[] {
|
|
78
|
+
return [manifest.archive?.url, ...UpdateUploader.#field(manifest.patches, "url")];
|
|
79
|
+
}
|
|
80
|
+
|
|
78
81
|
static #keyOf(updatesDir: string, dir: string, file: unknown) {
|
|
79
82
|
const resolved = typeof file === "string" ? path.resolve(dir, file) : "";
|
|
80
83
|
const key = path.relative(updatesDir, resolved);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@akanjs/devkit",
|
|
3
|
-
"version": "3.1.1-rc.
|
|
3
|
+
"version": "3.1.1-rc.9",
|
|
4
4
|
"sourceType": "module",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"publishConfig": {
|
|
@@ -48,7 +48,7 @@
|
|
|
48
48
|
"@earendil-works/pi-tui": "1.3.0",
|
|
49
49
|
"@inquirer/prompts": "^8.7.3",
|
|
50
50
|
"@tailwindcss/node": "^4.3.3",
|
|
51
|
-
"akanjs": "3.1.1-rc.
|
|
51
|
+
"akanjs": "3.1.1-rc.9",
|
|
52
52
|
"chalk": "^6.0.1",
|
|
53
53
|
"commander": "^15.0.0",
|
|
54
54
|
"dayjs": "^1.11.23",
|