akanjs 3.0.0-beta.30 → 3.0.0-beta.32
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/client/translator.ts +26 -0
- package/index.ts +29 -4
- package/package.json +1 -1
- package/server/rscWorker.tsx +12 -1
- package/server/rscWorkerHost.ts +20 -1
- package/server/webRouter.ts +4 -1
- package/types/client/translator.d.ts +1 -0
- package/types/index.d.ts +28 -4
- package/types/server/rscWorkerHost.d.ts +1 -0
- package/types/vendor/@akanjs/native/packages/cli/src/api.d.ts +20 -4
- package/types/vendor/@akanjs/native/packages/cli/src/config.d.ts +7 -0
- package/types/vendor/@akanjs/native/packages/cli/src/lib/addons.d.ts +19 -0
- package/types/vendor/@akanjs/native/packages/cli/src/lib/macossigning.d.ts +95 -0
- package/types/vendor/@akanjs/native/packages/cli/src/lib/prepare.d.ts +21 -1
- package/types/vendor/@akanjs/native/packages/cli/src/lib/signing.d.ts +2 -2
- package/types/vendor/@akanjs/native/packages/cli/src/lib/toolchains.d.ts +9 -0
- package/types/vendor/@akanjs/native/packages/cli/src/lib/windowssigning.d.ts +30 -0
- package/types/vendor/@akanjs/native/packages/cli/src/platforms/desktop.d.ts +8 -2
- package/types/vendor/@akanjs/native/packages/cli/src/platforms/linux-appimage.d.ts +11 -0
- package/types/vendor/@akanjs/native/packages/cli/src/platforms/windows-installer.d.ts +2 -0
- package/ui/System/Client.tsx +9 -16
- package/ui/System/SSR.tsx +1 -1
- package/vendor/@akanjs/native/packages/cli/src/api.ts +53 -7
- package/vendor/@akanjs/native/packages/cli/src/commands/build.ts +38 -4
- package/vendor/@akanjs/native/packages/cli/src/config.ts +7 -0
- package/vendor/@akanjs/native/packages/cli/src/lib/addons.ts +217 -0
- package/vendor/@akanjs/native/packages/cli/src/lib/configkeys.ts +1 -0
- package/vendor/@akanjs/native/packages/cli/src/lib/macossigning.ts +357 -0
- package/vendor/@akanjs/native/packages/cli/src/lib/prepare.ts +34 -1
- package/vendor/@akanjs/native/packages/cli/src/lib/project.ts +1 -0
- package/vendor/@akanjs/native/packages/cli/src/lib/signing.ts +4 -3
- package/vendor/@akanjs/native/packages/cli/src/lib/toolchains.ts +24 -0
- package/vendor/@akanjs/native/packages/cli/src/lib/windowssigning.ts +155 -0
- package/vendor/@akanjs/native/packages/cli/src/platforms/desktop.ts +30 -13
- package/vendor/@akanjs/native/packages/cli/src/platforms/linux-appimage.ts +84 -0
- package/vendor/@akanjs/native/packages/cli/src/platforms/linux.ts +4 -1
- package/vendor/@akanjs/native/packages/cli/src/platforms/macos.ts +115 -43
- package/vendor/@akanjs/native/packages/cli/src/platforms/windows-installer.ts +29 -2
- package/vendor/@akanjs/native/packages/cli/src/platforms/windows.ts +12 -1
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
|
|
2
|
+
|
|
3
|
+
import { randomBytes } from "node:crypto";
|
|
4
|
+
import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import { basename, join } from "node:path";
|
|
7
|
+
import { exec, execOrThrow } from "./exec.ts";
|
|
8
|
+
import { CliError, dim, log } from "./log.ts";
|
|
9
|
+
import { type PlistValue, toPlist } from "./plist.ts";
|
|
10
|
+
import { SigningError } from "./prepare.ts";
|
|
11
|
+
import { type SigningIdentity, validIdentities } from "./signing.ts";
|
|
12
|
+
|
|
13
|
+
export interface MacosSigning {
|
|
14
|
+
/** A certificate name ("Developer ID Application: Name (TEAMID)") or its SHA-1, in a keychain already. */
|
|
15
|
+
identity?: string;
|
|
16
|
+
/** A .p12 holding the identity: imported into a keychain of its own for the build, then removed. */
|
|
17
|
+
certificate?: { path: string; password: string };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** App Store Connect API key (key, keyId, issuer), or a profile `xcrun notarytool store-credentials` saved. */
|
|
21
|
+
export interface MacosNotarization {
|
|
22
|
+
/** The AuthKey_<id>.p8 file. */
|
|
23
|
+
key?: string;
|
|
24
|
+
keyId?: string;
|
|
25
|
+
/** The issuer id; left out for an individual API key. */
|
|
26
|
+
issuer?: string;
|
|
27
|
+
profile?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface MacosBuild {
|
|
31
|
+
signing?: MacosSigning;
|
|
32
|
+
/** Release builds signed with a Developer ID only. */
|
|
33
|
+
notarize?: MacosNotarization;
|
|
34
|
+
/** A .dmg beside the app, with an Applications link; signed and notarized as the app is. */
|
|
35
|
+
dmg?: boolean;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export type MacosSignature = "adhoc" | "identity" | "development" | "distribution";
|
|
39
|
+
|
|
40
|
+
export interface ResolvedMacosIdentity {
|
|
41
|
+
/** The certificate's SHA-1, or "-" for ad hoc. */
|
|
42
|
+
sign: string;
|
|
43
|
+
name: string;
|
|
44
|
+
kind: MacosSignature;
|
|
45
|
+
teamId?: string;
|
|
46
|
+
/** Set when the identity lives in a keychain made for this build. */
|
|
47
|
+
keychain?: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Entitlements the hardened runtime needs for the app's own code: Bun's JavaScriptCore writes JIT code, and the
|
|
52
|
+
* TCC resources the Info.plist asks for are refused to a hardened app that does not claim them.
|
|
53
|
+
*/
|
|
54
|
+
export const RUNTIME_ENTITLEMENTS: Record<string, PlistValue> = {
|
|
55
|
+
"com.apple.security.cs.allow-jit": true,
|
|
56
|
+
"com.apple.security.cs.allow-unsigned-executable-memory": true,
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const USAGE_ENTITLEMENTS: Record<string, string> = {
|
|
60
|
+
NSCameraUsageDescription: "com.apple.security.device.camera",
|
|
61
|
+
NSMicrophoneUsageDescription: "com.apple.security.device.audio-input",
|
|
62
|
+
NSLocationUsageDescription: "com.apple.security.personal-information.location",
|
|
63
|
+
NSLocationWhenInUseUsageDescription: "com.apple.security.personal-information.location",
|
|
64
|
+
NSContactsUsageDescription: "com.apple.security.personal-information.addressbook",
|
|
65
|
+
NSCalendarsUsageDescription: "com.apple.security.personal-information.calendars",
|
|
66
|
+
NSPhotoLibraryUsageDescription: "com.apple.security.personal-information.photos-library",
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
/** The main executable's entitlements: the runtime's, those of the usage texts in `infoPlist`, then the app's own. */
|
|
70
|
+
export function appEntitlements(
|
|
71
|
+
infoPlist: Record<string, PlistValue>,
|
|
72
|
+
extra: Record<string, PlistValue> = {},
|
|
73
|
+
): Record<string, PlistValue> {
|
|
74
|
+
const out: Record<string, PlistValue> = { ...RUNTIME_ENTITLEMENTS };
|
|
75
|
+
for (const key of Object.keys(infoPlist)) {
|
|
76
|
+
const entitlement = USAGE_ENTITLEMENTS[key];
|
|
77
|
+
if (entitlement) out[entitlement] = true;
|
|
78
|
+
}
|
|
79
|
+
return { ...out, ...extra };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* The kind of a certificate by its name. A Developer ID's parentheses hold the team id; an Apple Development
|
|
84
|
+
* certificate's hold the member's own id, so its team is not read from the name.
|
|
85
|
+
*/
|
|
86
|
+
export function identityKind(name: string): { kind: MacosSignature; teamId?: string } {
|
|
87
|
+
if (name.startsWith("Developer ID Application:")) {
|
|
88
|
+
const teamId = /\(([A-Z0-9]{10})\)\s*$/.exec(name)?.[1];
|
|
89
|
+
return { kind: "distribution", ...(teamId ? { teamId } : {}) };
|
|
90
|
+
}
|
|
91
|
+
if (name.startsWith("Apple Development:") || name.startsWith("Mac Developer:")) return { kind: "development" };
|
|
92
|
+
return { kind: "identity" };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** The identity `wanted` names (a name or a SHA-1) among `identities`. */
|
|
96
|
+
export function pickMacosIdentity(identities: SigningIdentity[], wanted: string): SigningIdentity {
|
|
97
|
+
const upper = wanted.toUpperCase();
|
|
98
|
+
const found = identities.filter((i) => i.hash === upper || i.name === wanted);
|
|
99
|
+
const [only, ...more] = found;
|
|
100
|
+
if (only && more.length === 0) return only;
|
|
101
|
+
if (more.length)
|
|
102
|
+
throw new SigningError(`several valid identities are called ${JSON.stringify(wanted)}; give its SHA-1 instead`);
|
|
103
|
+
throw new SigningError(
|
|
104
|
+
`no valid code-signing identity ${JSON.stringify(wanted)} in the keychains`,
|
|
105
|
+
identities.map((i) => i.name),
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Runs `fn` with the identity `signing` names, or ad hoc without one. A certificate file goes into a keychain made for
|
|
111
|
+
* this build (on the user's search list meanwhile, so codesign finds its chain), deleted afterwards.
|
|
112
|
+
*/
|
|
113
|
+
export async function withMacosIdentity<T>(
|
|
114
|
+
signing: MacosSigning | undefined,
|
|
115
|
+
fn: (identity: ResolvedMacosIdentity) => Promise<T>,
|
|
116
|
+
fallback: () => Promise<ResolvedMacosIdentity>,
|
|
117
|
+
): Promise<T> {
|
|
118
|
+
if (!signing?.certificate) {
|
|
119
|
+
if (!signing?.identity) return fn(await fallback());
|
|
120
|
+
const picked = pickMacosIdentity(await validIdentities(), signing.identity);
|
|
121
|
+
return fn({ sign: picked.hash, name: picked.name, ...identityKind(picked.name) });
|
|
122
|
+
}
|
|
123
|
+
const dir = mkdtempSync(join(tmpdir(), "akan-native-macos-signing-"));
|
|
124
|
+
const keychain = join(dir, "signing.keychain-db");
|
|
125
|
+
const password = randomBytes(24).toString("hex");
|
|
126
|
+
const before = await searchList();
|
|
127
|
+
try {
|
|
128
|
+
await security(["create-keychain", "-p", password, keychain]);
|
|
129
|
+
await security(["set-keychain-settings", "-lut", "21600", keychain]);
|
|
130
|
+
await security(["unlock-keychain", "-p", password, keychain]);
|
|
131
|
+
|
|
132
|
+
await security(
|
|
133
|
+
[
|
|
134
|
+
"import",
|
|
135
|
+
signing.certificate.path,
|
|
136
|
+
"-k",
|
|
137
|
+
keychain,
|
|
138
|
+
"-P",
|
|
139
|
+
signing.certificate.password,
|
|
140
|
+
"-T",
|
|
141
|
+
"/usr/bin/codesign",
|
|
142
|
+
],
|
|
143
|
+
`the certificate ${signing.certificate.path} could not be imported (is the password right?)`,
|
|
144
|
+
);
|
|
145
|
+
|
|
146
|
+
await security(["set-key-partition-list", "-S", "apple-tool:,apple:,codesign:", "-s", "-k", password, keychain]);
|
|
147
|
+
await security(["list-keychains", "-d", "user", "-s", keychain, ...before]);
|
|
148
|
+
const identities = await validIdentities(keychain);
|
|
149
|
+
const picked = signing.identity
|
|
150
|
+
? pickMacosIdentity(identities, signing.identity)
|
|
151
|
+
: (identities.find((i) => identityKind(i.name).kind === "distribution") ?? identities[0]);
|
|
152
|
+
if (!picked) throw new SigningError(`${signing.certificate.path} holds no valid code-signing identity`);
|
|
153
|
+
return await fn({ sign: picked.hash, name: picked.name, keychain, ...identityKind(picked.name) });
|
|
154
|
+
} finally {
|
|
155
|
+
await exec(["/usr/bin/security", "list-keychains", "-d", "user", "-s", ...before], { echo: false });
|
|
156
|
+
await exec(["/usr/bin/security", "delete-keychain", keychain], { echo: false });
|
|
157
|
+
rmSync(dir, { recursive: true, force: true });
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async function searchList(): Promise<string[]> {
|
|
162
|
+
const out = (await exec(["/usr/bin/security", "list-keychains", "-d", "user"], { echo: false })).stdout;
|
|
163
|
+
return [...out.matchAll(/"([^"]+)"/g)].flatMap((m) => (m[1] ? [m[1]] : []));
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** A `security` call whose failure never repeats its arguments: some carry a password. */
|
|
167
|
+
async function security(args: string[], failure?: string): Promise<void> {
|
|
168
|
+
const result = await exec(["/usr/bin/security", ...args], { echo: false });
|
|
169
|
+
if (result.code !== 0)
|
|
170
|
+
throw new SigningError(failure ?? `security ${args[0]} failed: ${(result.stderr || result.stdout).trim()}`);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export interface CodesignOptions {
|
|
174
|
+
/** The hardened runtime and a secure timestamp, what notarization requires of every Mach-O file. */
|
|
175
|
+
hardened: boolean;
|
|
176
|
+
/** A secure timestamp without the runtime (a disk image). Default: `hardened`. */
|
|
177
|
+
timestamp?: boolean;
|
|
178
|
+
entitlements?: string;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** codesign arguments for one file. Apple's timestamp server only stamps certificates Apple issued. */
|
|
182
|
+
export function codesignArgs(identity: ResolvedMacosIdentity, file: string, options: CodesignOptions): string[] {
|
|
183
|
+
const timestamp = identity.sign !== "-" && (options.timestamp ?? options.hardened);
|
|
184
|
+
return [
|
|
185
|
+
"codesign",
|
|
186
|
+
"--force",
|
|
187
|
+
"--sign",
|
|
188
|
+
identity.sign,
|
|
189
|
+
...(identity.keychain ? ["--keychain", identity.keychain] : []),
|
|
190
|
+
...(timestamp ? ["--timestamp"] : []),
|
|
191
|
+
...(options.hardened && identity.sign !== "-" ? ["--options", "runtime"] : []),
|
|
192
|
+
...(options.entitlements ? ["--entitlements", options.entitlements] : []),
|
|
193
|
+
file,
|
|
194
|
+
];
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export async function codesign(identity: ResolvedMacosIdentity, file: string, options: CodesignOptions): Promise<void> {
|
|
198
|
+
const result = await exec(codesignArgs(identity, file, options), { echo: false });
|
|
199
|
+
if (result.code !== 0)
|
|
200
|
+
throw new SigningError(
|
|
201
|
+
`codesign with "${identity.name}" failed for ${basename(file)}: ${(result.stderr || result.stdout).trim()}`,
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export function writeEntitlements(dir: string, entitlements: Record<string, PlistValue>): string {
|
|
206
|
+
mkdirSync(dir, { recursive: true });
|
|
207
|
+
const file = join(dir, "entitlements.plist");
|
|
208
|
+
writeFileSync(file, toPlist(entitlements));
|
|
209
|
+
return file;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export function notarytoolAuth(notarization: MacosNotarization): string[] {
|
|
213
|
+
if (notarization.profile) return ["--keychain-profile", notarization.profile];
|
|
214
|
+
if (!notarization.key || !notarization.keyId)
|
|
215
|
+
throw new SigningError(
|
|
216
|
+
"notarization needs an App Store Connect API key (key, keyId and, for a team key, issuer) or a keychain profile",
|
|
217
|
+
);
|
|
218
|
+
return [
|
|
219
|
+
"--key",
|
|
220
|
+
notarization.key,
|
|
221
|
+
"--key-id",
|
|
222
|
+
notarization.keyId,
|
|
223
|
+
...(notarization.issuer ? ["--issuer", notarization.issuer] : []),
|
|
224
|
+
];
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
interface NotarySubmission {
|
|
228
|
+
id?: string;
|
|
229
|
+
status?: string;
|
|
230
|
+
message?: string;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** Submits `file` (a .zip, .dmg or .pkg), waits for Apple's verdict and refuses anything but Accepted. */
|
|
234
|
+
export async function notarize(file: string, notarization: MacosNotarization): Promise<string> {
|
|
235
|
+
const auth = notarytoolAuth(notarization);
|
|
236
|
+
log.step(`notarize: ${basename(file)}`);
|
|
237
|
+
const result = await exec(["xcrun", "notarytool", "submit", file, ...auth, "--wait", "--output-format", "json"], {
|
|
238
|
+
echo: false,
|
|
239
|
+
});
|
|
240
|
+
const submission = parseSubmission(result.stdout);
|
|
241
|
+
if (submission.status === "Accepted" && submission.id) {
|
|
242
|
+
log.info(dim(`notarized: ${submission.id}`));
|
|
243
|
+
return submission.id;
|
|
244
|
+
}
|
|
245
|
+
const issues = submission.id ? await notaryIssues(submission.id, auth) : [];
|
|
246
|
+
throw new SigningError(
|
|
247
|
+
`notarization of ${basename(file)} ended ${submission.status ?? "without a verdict"}${submission.message ? `: ${submission.message}` : ""}${result.code !== 0 && !submission.status ? `\n${(result.stderr || result.stdout).trim()}` : ""}`,
|
|
248
|
+
issues,
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
export function parseSubmission(stdout: string): NotarySubmission {
|
|
253
|
+
try {
|
|
254
|
+
return JSON.parse(stdout) as NotarySubmission;
|
|
255
|
+
} catch {
|
|
256
|
+
return {};
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
async function notaryIssues(id: string, auth: string[]): Promise<string[]> {
|
|
261
|
+
const result = await exec(["xcrun", "notarytool", "log", id, ...auth], { echo: false });
|
|
262
|
+
return notaryLogIssues(result.stdout);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** One line per issue of a notarytool log: the file and Apple's message. */
|
|
266
|
+
export function notaryLogIssues(stdout: string): string[] {
|
|
267
|
+
try {
|
|
268
|
+
const parsed = JSON.parse(stdout) as { issues?: { path?: string; message?: string }[] | null };
|
|
269
|
+
return (parsed.issues ?? []).map((i) => `${i.path ?? "?"}: ${i.message ?? ""}`.trim());
|
|
270
|
+
} catch {
|
|
271
|
+
return [];
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
export async function staple(file: string): Promise<void> {
|
|
276
|
+
await execOrThrow(["xcrun", "stapler", "staple", file], { echo: false });
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** The .app as notarytool takes it: a zip that keeps the bundle's symlinks and signature. */
|
|
280
|
+
export async function zipForNotary(appPath: string, zip: string): Promise<void> {
|
|
281
|
+
rmSync(zip, { force: true });
|
|
282
|
+
await execOrThrow(["ditto", "-c", "-k", "--sequesterRsrc", "--keepParent", appPath, zip], { echo: false });
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** A compressed disk image of the app beside a link to /Applications, the window people drag it into. */
|
|
286
|
+
export async function buildDmg(appPath: string, dmgPath: string, volumeName: string, workDir: string): Promise<void> {
|
|
287
|
+
const staging = join(workDir, "dmg");
|
|
288
|
+
rmSync(staging, { recursive: true, force: true });
|
|
289
|
+
mkdirSync(staging, { recursive: true });
|
|
290
|
+
await execOrThrow(["ditto", appPath, join(staging, basename(appPath))], { echo: false });
|
|
291
|
+
symlinkSync("/Applications", join(staging, "Applications"));
|
|
292
|
+
rmSync(dmgPath, { force: true });
|
|
293
|
+
await execOrThrow(
|
|
294
|
+
[
|
|
295
|
+
"hdiutil",
|
|
296
|
+
"create",
|
|
297
|
+
"-volname",
|
|
298
|
+
volumeName,
|
|
299
|
+
"-srcfolder",
|
|
300
|
+
staging,
|
|
301
|
+
"-ov",
|
|
302
|
+
"-fs",
|
|
303
|
+
"HFS+",
|
|
304
|
+
"-format",
|
|
305
|
+
"UDZO",
|
|
306
|
+
dmgPath,
|
|
307
|
+
],
|
|
308
|
+
{ echo: false },
|
|
309
|
+
);
|
|
310
|
+
rmSync(staging, { recursive: true, force: true });
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** What Gatekeeper says of a notarized app or disk image, as a downloaded copy would be judged. */
|
|
314
|
+
export async function assessGatekeeper(file: string, type: "execute" | "open"): Promise<void> {
|
|
315
|
+
const args =
|
|
316
|
+
type === "execute"
|
|
317
|
+
? ["spctl", "--assess", "--type", "execute", "-vv", file]
|
|
318
|
+
: ["spctl", "--assess", "--type", "open", "--context", "context:primary-signature", "-vv", file];
|
|
319
|
+
const result = await exec(args, { echo: false });
|
|
320
|
+
if (result.code !== 0)
|
|
321
|
+
throw new CliError(`Gatekeeper refuses ${basename(file)}: ${(result.stderr || result.stdout).trim()}`);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Settings from the environment for the command line (the API takes them as options only):
|
|
326
|
+
* AKAN_NATIVE_MACOS_IDENTITY, AKAN_NATIVE_MACOS_CERTIFICATE + _PASSWORD, and AKAN_NATIVE_MACOS_NOTARY_KEY +
|
|
327
|
+
* _KEY_ID + _ISSUER or AKAN_NATIVE_MACOS_NOTARY_PROFILE.
|
|
328
|
+
*/
|
|
329
|
+
export function macosDistributionFromEnv(
|
|
330
|
+
env: Record<string, string | undefined> = process.env,
|
|
331
|
+
): Pick<MacosBuild, "signing" | "notarize"> {
|
|
332
|
+
const identity = env.AKAN_NATIVE_MACOS_IDENTITY?.trim();
|
|
333
|
+
const certificate = env.AKAN_NATIVE_MACOS_CERTIFICATE?.trim();
|
|
334
|
+
if (certificate && env.AKAN_NATIVE_MACOS_CERTIFICATE_PASSWORD === undefined)
|
|
335
|
+
throw new SigningError("AKAN_NATIVE_MACOS_CERTIFICATE is set without AKAN_NATIVE_MACOS_CERTIFICATE_PASSWORD");
|
|
336
|
+
const signing: MacosSigning | undefined =
|
|
337
|
+
identity || certificate
|
|
338
|
+
? {
|
|
339
|
+
...(identity ? { identity } : {}),
|
|
340
|
+
...(certificate
|
|
341
|
+
? { certificate: { path: certificate, password: env.AKAN_NATIVE_MACOS_CERTIFICATE_PASSWORD ?? "" } }
|
|
342
|
+
: {}),
|
|
343
|
+
}
|
|
344
|
+
: undefined;
|
|
345
|
+
const profile = env.AKAN_NATIVE_MACOS_NOTARY_PROFILE?.trim();
|
|
346
|
+
const key = env.AKAN_NATIVE_MACOS_NOTARY_KEY?.trim();
|
|
347
|
+
const keyId = env.AKAN_NATIVE_MACOS_NOTARY_KEY_ID?.trim();
|
|
348
|
+
const issuer = env.AKAN_NATIVE_MACOS_NOTARY_ISSUER?.trim();
|
|
349
|
+
if (key && !keyId)
|
|
350
|
+
throw new SigningError("AKAN_NATIVE_MACOS_NOTARY_KEY is set without AKAN_NATIVE_MACOS_NOTARY_KEY_ID");
|
|
351
|
+
const notarize: MacosNotarization | undefined = profile
|
|
352
|
+
? { profile }
|
|
353
|
+
: key && keyId
|
|
354
|
+
? { key, keyId, ...(issuer ? { issuer } : {}) }
|
|
355
|
+
: undefined;
|
|
356
|
+
return { ...(signing ? { signing } : {}), ...(notarize ? { notarize } : {}) };
|
|
357
|
+
}
|
|
@@ -9,7 +9,10 @@ import { ENV_TYPES_FILE, loadEnv, writeEnvTypes } from "./env.ts";
|
|
|
9
9
|
import { exec } from "./exec.ts";
|
|
10
10
|
import { findExternalScripts, injectEarlyErrors, injectInitScript } from "./html.ts";
|
|
11
11
|
import { CliError, dim, log } from "./log.ts";
|
|
12
|
+
import type { MacosBuild } from "./macossigning.ts";
|
|
12
13
|
import { buildNumberProblem, ConfigError, dependencyProblems, type Project } from "./project.ts";
|
|
14
|
+
import { type DesktopArch, hostArch } from "./updates.ts";
|
|
15
|
+
import type { WindowsSigning } from "./windowssigning.ts";
|
|
13
16
|
|
|
14
17
|
/**
|
|
15
18
|
* debug: inspectable webview, dev signing, runtime env overrides, the dev server (akan-native run, dev,
|
|
@@ -28,6 +31,8 @@ const DEV_SERVER_PAGE = `<!doctype html>
|
|
|
28
31
|
|
|
29
32
|
export interface WindowsBuild {
|
|
30
33
|
installer?: boolean;
|
|
34
|
+
/** Authenticode for every PE file, the setup program and its uninstaller (lib/windowssigning.ts). */
|
|
35
|
+
signing?: WindowsSigning;
|
|
31
36
|
}
|
|
32
37
|
|
|
33
38
|
export interface BuildOptions {
|
|
@@ -63,6 +68,16 @@ export interface BuildOptions {
|
|
|
63
68
|
ios?: IosBuild;
|
|
64
69
|
/** Windows: an NSIS setup program next to the app folder (platforms/windows-installer.ts). */
|
|
65
70
|
windows?: WindowsBuild;
|
|
71
|
+
/** macOS: Developer ID signing, notarization and a dmg (lib/macossigning.ts). */
|
|
72
|
+
macos?: MacosBuild;
|
|
73
|
+
/** Linux: an AppImage beside the app folder (platforms/linux-appimage.ts). */
|
|
74
|
+
linux?: LinuxBuild;
|
|
75
|
+
/** Desktop builds: the CPU the app runs on, of the same OS; a macOS app is arm64 only. Default this computer's. */
|
|
76
|
+
arch?: DesktopArch;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export interface LinuxBuild {
|
|
80
|
+
appImage?: boolean;
|
|
66
81
|
}
|
|
67
82
|
|
|
68
83
|
/** Which identity and profile an iOS device build signs with; unset: found in the keychain and Xcode's profiles. */
|
|
@@ -145,10 +160,14 @@ export interface BuildContext {
|
|
|
145
160
|
android?: AndroidRelease;
|
|
146
161
|
ios?: IosBuild;
|
|
147
162
|
windows?: WindowsBuild;
|
|
163
|
+
macos?: MacosBuild;
|
|
164
|
+
linux?: LinuxBuild;
|
|
165
|
+
/** Desktop builds: see BuildOptions.arch, resolved. */
|
|
166
|
+
arch?: DesktopArch;
|
|
148
167
|
/** Artifacts besides the one the builder returns (an .aab next to the .apk, a Windows setup program). */
|
|
149
168
|
artifacts: { kind: "aab" | "ipa" | "installer"; path: string }[];
|
|
150
169
|
/** What the builder signed with, when it signs (Android: the app's release key or the debug key). */
|
|
151
|
-
signedAs?: "debug" | "development" | "distribution";
|
|
170
|
+
signedAs?: "adhoc" | "debug" | "development" | "distribution";
|
|
152
171
|
/** Called through the API (BuildOptions.api). */
|
|
153
172
|
api: boolean;
|
|
154
173
|
/** Android builds with pinned libraries: the license notices file written next to the app (BuildResult.licenses). */
|
|
@@ -179,6 +198,7 @@ export async function prepare(project: Project, platform: Platform, options: Bui
|
|
|
179
198
|
if (buildProblem) throw new ConfigError("the build number", [buildProblem]);
|
|
180
199
|
const needs = dependencyProblems(project.plugins, platform);
|
|
181
200
|
if (needs.length) throw new CliError(`plugin dependencies on ${platform}:\n - ${needs.join("\n - ")}`);
|
|
201
|
+
const arch = desktopArch(platform, options.arch);
|
|
182
202
|
|
|
183
203
|
if (config.web.build && !options.skipWebBuild) {
|
|
184
204
|
log.step(`web build: ${config.web.build}`);
|
|
@@ -279,10 +299,23 @@ export async function prepare(project: Project, platform: Platform, options: Bui
|
|
|
279
299
|
...(options.android ? { android: options.android } : {}),
|
|
280
300
|
...(options.ios ? { ios: options.ios } : {}),
|
|
281
301
|
...(options.windows ? { windows: options.windows } : {}),
|
|
302
|
+
...(options.macos ? { macos: options.macos } : {}),
|
|
303
|
+
...(options.linux ? { linux: options.linux } : {}),
|
|
304
|
+
...(arch ? { arch } : {}),
|
|
282
305
|
artifacts: [],
|
|
283
306
|
};
|
|
284
307
|
}
|
|
285
308
|
|
|
309
|
+
/** The arch a build makes: the one asked for, checked against the platform, else this computer's for a desktop. */
|
|
310
|
+
export function desktopArch(platform: Platform, arch: DesktopArch | undefined): DesktopArch | undefined {
|
|
311
|
+
const desktop = platform === "macos" || platform === "windows" || platform === "linux";
|
|
312
|
+
if (arch !== undefined && !desktop) throw new CliError(`a CPU is chosen for a desktop build, not for ${platform}`);
|
|
313
|
+
if (arch !== undefined && arch !== "arm64" && arch !== "x64")
|
|
314
|
+
throw new CliError(`the arch is arm64 or x64 (got ${JSON.stringify(arch)})`);
|
|
315
|
+
if (platform === "macos" && arch === "x64") throw new CliError("a macOS app is built for Apple silicon (arm64) only");
|
|
316
|
+
return arch ?? (desktop ? hostArch() : undefined);
|
|
317
|
+
}
|
|
318
|
+
|
|
286
319
|
/** https, or http to this machine (a local test server, `akan-native update serve`). */
|
|
287
320
|
export function secureUpdateUrl(url: string): boolean {
|
|
288
321
|
try {
|
|
@@ -685,6 +685,7 @@ function checkNative(raw: AkanNativeConfig, appDir: string, problems: string[]):
|
|
|
685
685
|
};
|
|
686
686
|
dict("native.ios.infoPlist", native.ios?.infoPlist);
|
|
687
687
|
dict("native.ios.entitlements", native.ios?.entitlements);
|
|
688
|
+
dict("native.macos.entitlements", native.macos?.entitlements);
|
|
688
689
|
for (const key of ["manifest", "application", "activity"] as const) {
|
|
689
690
|
const xml = native.android?.[key];
|
|
690
691
|
if (
|
|
@@ -18,9 +18,10 @@ const LOGIN_KEYCHAIN = join(userInfo().homedir, "Library", "Keychains", "login.k
|
|
|
18
18
|
export const identityName = () => `akan-native dev: ${userInfo().username}`;
|
|
19
19
|
const recordPath = (env = process.env) => join(akanNativeHome(env), "signing.json");
|
|
20
20
|
|
|
21
|
-
/** Valid (trusted) code-signing identities in the keychains. */
|
|
22
|
-
export async function validIdentities(): Promise<SigningIdentity[]> {
|
|
23
|
-
const
|
|
21
|
+
/** Valid (trusted) code-signing identities in the keychains, or in `keychain` only. */
|
|
22
|
+
export async function validIdentities(keychain?: string): Promise<SigningIdentity[]> {
|
|
23
|
+
const args = ["/usr/bin/security", "find-identity", "-v", "-p", "codesigning", ...(keychain ? [keychain] : [])];
|
|
24
|
+
const out = (await exec(args, { echo: false })).stdout;
|
|
24
25
|
return [...out.matchAll(/\d+\) ([0-9A-F]{40}) "([^"]+)"/g)].map((m) => ({ hash: m[1]!, name: m[2]! }));
|
|
25
26
|
}
|
|
26
27
|
|
|
@@ -80,6 +80,30 @@ export const TOOLCHAIN = {
|
|
|
80
80
|
root: "bundletool.jar",
|
|
81
81
|
} satisfies Download,
|
|
82
82
|
},
|
|
83
|
+
/**
|
|
84
|
+
* The AppImage type 2 runtime (CLI-9), the ELF a Linux AppImage starts with: it mounts the squashfs appended to it
|
|
85
|
+
* and runs its AppRun. A dated release, since `continuous` moves; the digests are the ones GitHub publishes.
|
|
86
|
+
*/
|
|
87
|
+
appimageRuntime: {
|
|
88
|
+
version: "20251108",
|
|
89
|
+
license: "MIT",
|
|
90
|
+
downloads: {
|
|
91
|
+
x64: {
|
|
92
|
+
url: "https://github.com/AppImage/type2-runtime/releases/download/20251108/runtime-x86_64",
|
|
93
|
+
sha256: "2fca8b443c92510f1483a883f60061ad09b46b978b2631c807cd873a47ec260d",
|
|
94
|
+
size: 944_632,
|
|
95
|
+
format: "file",
|
|
96
|
+
root: "runtime",
|
|
97
|
+
},
|
|
98
|
+
arm64: {
|
|
99
|
+
url: "https://github.com/AppImage/type2-runtime/releases/download/20251108/runtime-aarch64",
|
|
100
|
+
sha256: "00cbdfcf917cc6c0ff6d3347d59e0ca1f7f45a6df1a428a0d6d8a78664d87444",
|
|
101
|
+
size: 936_456,
|
|
102
|
+
format: "file",
|
|
103
|
+
root: "runtime",
|
|
104
|
+
},
|
|
105
|
+
} as Record<string, Download>,
|
|
106
|
+
},
|
|
83
107
|
android: {
|
|
84
108
|
buildTools: "37.0.0",
|
|
85
109
|
/** Older build-tools still work (with a warning); below this they do not. */
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
|
|
2
|
+
|
|
3
|
+
import { closeSync, existsSync, openSync, readdirSync, readSync, statSync } from "node:fs";
|
|
4
|
+
import { basename, join } from "node:path";
|
|
5
|
+
import { exec } from "./exec.ts";
|
|
6
|
+
import { ToolchainError } from "./log.ts";
|
|
7
|
+
import { SigningError } from "./prepare.ts";
|
|
8
|
+
|
|
9
|
+
export interface WindowsSigning {
|
|
10
|
+
/** A .pfx holding the code-signing certificate and its key. */
|
|
11
|
+
certificate?: { path: string; password: string };
|
|
12
|
+
/** A certificate in the user's or the machine's store, by its SHA-1 thumbprint (a token or an HSM keeps its key there). */
|
|
13
|
+
thumbprint?: string;
|
|
14
|
+
/** Any other signer, run once per file with `{file}` replaced, e.g. signtool with Azure Trusted Signing's dlib. */
|
|
15
|
+
command?: string[];
|
|
16
|
+
/** RFC 3161 timestamp server. Default http://timestamp.digicert.com. */
|
|
17
|
+
timestampUrl?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export const DEFAULT_TIMESTAMP_URL = "http://timestamp.digicert.com";
|
|
21
|
+
|
|
22
|
+
/** signtool: AKAN_NATIVE_SIGNTOOL, PATH, then the newest Windows 10/11 SDK's for this CPU. */
|
|
23
|
+
export function findSigntool(env: Record<string, string | undefined> = process.env): string | null {
|
|
24
|
+
if (env.AKAN_NATIVE_SIGNTOOL) return env.AKAN_NATIVE_SIGNTOOL;
|
|
25
|
+
const onPath = Bun.which("signtool");
|
|
26
|
+
if (onPath) return onPath;
|
|
27
|
+
const kits = env["ProgramFiles(x86)"] ? join(env["ProgramFiles(x86)"], "Windows Kits", "10", "bin") : null;
|
|
28
|
+
if (!kits || !existsSync(kits)) return null;
|
|
29
|
+
const arch = process.arch === "arm64" ? "arm64" : "x64";
|
|
30
|
+
const versions = readdirSync(kits)
|
|
31
|
+
.filter((name) => /^10\.\d+\.\d+\.\d+$/.test(name))
|
|
32
|
+
.sort((a, b) => compareVersions(b, a));
|
|
33
|
+
for (const version of versions) {
|
|
34
|
+
const candidate = join(kits, version, arch, "signtool.exe");
|
|
35
|
+
if (existsSync(candidate)) return candidate;
|
|
36
|
+
}
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function compareVersions(a: string, b: string): number {
|
|
41
|
+
const [x, y] = [a.split(".").map(Number), b.split(".").map(Number)];
|
|
42
|
+
for (let i = 0; i < Math.max(x.length, y.length); i++)
|
|
43
|
+
if ((x[i] ?? 0) !== (y[i] ?? 0)) return (x[i] ?? 0) - (y[i] ?? 0);
|
|
44
|
+
return 0;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** signtool's arguments after the executable, for `files` in one call. */
|
|
48
|
+
export function signtoolArgs(signing: WindowsSigning, files: string[], description: string): string[] {
|
|
49
|
+
const key = signing.certificate
|
|
50
|
+
? ["/f", signing.certificate.path, "/p", signing.certificate.password]
|
|
51
|
+
: signing.thumbprint
|
|
52
|
+
? ["/sha1", signing.thumbprint]
|
|
53
|
+
: null;
|
|
54
|
+
if (!key) throw new SigningError("Windows signing needs a certificate (.pfx), a thumbprint or a command");
|
|
55
|
+
return [
|
|
56
|
+
"sign",
|
|
57
|
+
"/fd",
|
|
58
|
+
"sha256",
|
|
59
|
+
"/tr",
|
|
60
|
+
signing.timestampUrl ?? DEFAULT_TIMESTAMP_URL,
|
|
61
|
+
"/td",
|
|
62
|
+
"sha256",
|
|
63
|
+
"/d",
|
|
64
|
+
description,
|
|
65
|
+
...key,
|
|
66
|
+
...files,
|
|
67
|
+
];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** The command a `command` signer runs for one file. */
|
|
71
|
+
export function signerCommand(command: string[], file: string): string[] {
|
|
72
|
+
if (!command.some((part) => part.includes("{file}"))) return [...command, file];
|
|
73
|
+
return command.map((part) => part.replaceAll("{file}", file));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Signs `files` (Authenticode, SHA-256, timestamped), then verifies each against the system's trusted roots. */
|
|
77
|
+
export async function signWindowsFiles(signing: WindowsSigning, files: string[], description: string): Promise<void> {
|
|
78
|
+
if (!files.length) return;
|
|
79
|
+
if (signing.command) {
|
|
80
|
+
for (const file of files) {
|
|
81
|
+
const result = await exec(signerCommand(signing.command, file), { echo: false });
|
|
82
|
+
if (result.code !== 0)
|
|
83
|
+
throw new SigningError(`the signing command failed for ${basename(file)}: ${tail(result)}`);
|
|
84
|
+
}
|
|
85
|
+
} else {
|
|
86
|
+
const signtool = findSigntool();
|
|
87
|
+
if (!signtool)
|
|
88
|
+
throw new ToolchainError(
|
|
89
|
+
"signtool signs Windows programs: install the Windows SDK's Signing Tools (winget install Microsoft.WindowsSDK.10.0.26100) or set AKAN_NATIVE_SIGNTOOL",
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
const result = await exec([signtool, ...signtoolArgs(signing, files, description)], { echo: false });
|
|
93
|
+
if (result.code !== 0) throw new SigningError(`signtool could not sign ${files.length} files: ${tail(result)}`);
|
|
94
|
+
const verified = await exec([signtool, "verify", "/pa", "/q", ...files], { echo: false });
|
|
95
|
+
if (verified.code !== 0) throw new SigningError(`signtool verify refuses the signed files: ${tail(verified)}`);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const tail = (result: { stdout: string; stderr: string }) =>
|
|
100
|
+
(result.stderr || result.stdout).trim().split("\n").slice(-10).join("\n");
|
|
101
|
+
|
|
102
|
+
/** Every PE file under `dir` (programs, DLLs, native addons), whatever its name. */
|
|
103
|
+
export function peFiles(dir: string): string[] {
|
|
104
|
+
if (!existsSync(dir)) return [];
|
|
105
|
+
return (readdirSync(dir, { recursive: true }) as string[])
|
|
106
|
+
.map((file) => join(dir, file))
|
|
107
|
+
.filter((file) => statSync(file).isFile() && isPe(file));
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function isPe(file: string): boolean {
|
|
111
|
+
const head = Buffer.alloc(2);
|
|
112
|
+
const fd = openSync(file, "r");
|
|
113
|
+
try {
|
|
114
|
+
return readSync(fd, head, 0, 2, 0) === 2 && head.readUInt16LE(0) === 0x5a4d;
|
|
115
|
+
} finally {
|
|
116
|
+
closeSync(fd);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Settings from the environment (the API takes them as options only): AKAN_NATIVE_WINDOWS_CERTIFICATE + _PASSWORD,
|
|
122
|
+
* AKAN_NATIVE_WINDOWS_THUMBPRINT, or AKAN_NATIVE_WINDOWS_SIGN_COMMAND (a JSON array of arguments, `{file}` replaced),
|
|
123
|
+
* and AKAN_NATIVE_WINDOWS_TIMESTAMP_URL.
|
|
124
|
+
*/
|
|
125
|
+
export function windowsSigningFromEnv(
|
|
126
|
+
env: Record<string, string | undefined> = process.env,
|
|
127
|
+
): WindowsSigning | undefined {
|
|
128
|
+
const certificate = env.AKAN_NATIVE_WINDOWS_CERTIFICATE?.trim();
|
|
129
|
+
const thumbprint = env.AKAN_NATIVE_WINDOWS_THUMBPRINT?.replace(/\s/g, "");
|
|
130
|
+
const rawCommand = env.AKAN_NATIVE_WINDOWS_SIGN_COMMAND?.trim();
|
|
131
|
+
const timestampUrl = env.AKAN_NATIVE_WINDOWS_TIMESTAMP_URL?.trim();
|
|
132
|
+
if (certificate && env.AKAN_NATIVE_WINDOWS_CERTIFICATE_PASSWORD === undefined)
|
|
133
|
+
throw new SigningError("AKAN_NATIVE_WINDOWS_CERTIFICATE is set without AKAN_NATIVE_WINDOWS_CERTIFICATE_PASSWORD");
|
|
134
|
+
let command: string[] | undefined;
|
|
135
|
+
if (rawCommand) {
|
|
136
|
+
try {
|
|
137
|
+
command = JSON.parse(rawCommand) as string[];
|
|
138
|
+
} catch {
|
|
139
|
+
command = undefined;
|
|
140
|
+
}
|
|
141
|
+
if (!Array.isArray(command) || !command.length || command.some((part) => typeof part !== "string"))
|
|
142
|
+
throw new SigningError(
|
|
143
|
+
'AKAN_NATIVE_WINDOWS_SIGN_COMMAND must be a JSON array of strings, e.g. ["signer", "{file}"]',
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
if (!certificate && !thumbprint && !command) return undefined;
|
|
147
|
+
return {
|
|
148
|
+
...(certificate
|
|
149
|
+
? { certificate: { path: certificate, password: env.AKAN_NATIVE_WINDOWS_CERTIFICATE_PASSWORD ?? "" } }
|
|
150
|
+
: {}),
|
|
151
|
+
...(thumbprint ? { thumbprint } : {}),
|
|
152
|
+
...(command ? { command } : {}),
|
|
153
|
+
...(timestampUrl ? { timestampUrl } : {}),
|
|
154
|
+
};
|
|
155
|
+
}
|