create-zenbu-app 0.0.30 → 0.0.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/dist/desktop-CtHHGkP3.mjs +847 -0
- package/dist/desktop.d.mts +185 -0
- package/dist/desktop.mjs +2 -0
- package/dist/index.d.mts +1 -0
- package/dist/index.mjs +1 -770
- package/package.json +11 -1
|
@@ -0,0 +1,847 @@
|
|
|
1
|
+
import { createRequire } from "node:module";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import fs from "node:fs";
|
|
5
|
+
import { spawnSync } from "node:child_process";
|
|
6
|
+
import fsp from "node:fs/promises";
|
|
7
|
+
import https from "node:https";
|
|
8
|
+
import crypto from "node:crypto";
|
|
9
|
+
//#region src/desktop/electron-cache.ts
|
|
10
|
+
/**
|
|
11
|
+
* Layout under `~/.zenbu/electron/`:
|
|
12
|
+
*
|
|
13
|
+
* versions/<major>/<exact-version>/Electron.app
|
|
14
|
+
* versions/<major>/.current -> "<exact-version>" (text marker)
|
|
15
|
+
* .locks/<major>.lock -> flock target during download
|
|
16
|
+
* .downloads/<exact>.zip -> intermediate, deleted on success
|
|
17
|
+
*/
|
|
18
|
+
const ROOT = path.join(os.homedir(), ".zenbu", "electron");
|
|
19
|
+
async function ensureElectronApp(opts) {
|
|
20
|
+
const { log } = opts;
|
|
21
|
+
const exact = await resolveExactVersion(opts.versionRange, log);
|
|
22
|
+
const major = parseInt(exact.split(".")[0], 10);
|
|
23
|
+
if (!Number.isFinite(major)) throw new Error(`failed to parse major from electron version "${exact}"`);
|
|
24
|
+
const versionDir = path.join(ROOT, "versions", String(major), exact);
|
|
25
|
+
const electronAppPath = path.join(versionDir, "Electron.app");
|
|
26
|
+
if (fs.existsSync(path.join(electronAppPath, "Contents", "MacOS"))) {
|
|
27
|
+
log.info(`cache hit: ${electronAppPath}`);
|
|
28
|
+
return {
|
|
29
|
+
electronAppPath,
|
|
30
|
+
version: exact,
|
|
31
|
+
major
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
await fsp.mkdir(path.join(ROOT, ".locks"), { recursive: true });
|
|
35
|
+
await fsp.mkdir(path.join(ROOT, ".downloads"), { recursive: true });
|
|
36
|
+
await withLock(path.join(ROOT, ".locks", `${major}-${exact}.lock`), async () => {
|
|
37
|
+
if (fs.existsSync(path.join(electronAppPath, "Contents", "MacOS"))) {
|
|
38
|
+
log.info(`cache hit (post-lock): ${electronAppPath}`);
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
await downloadAndExtract({
|
|
42
|
+
version: exact,
|
|
43
|
+
versionDir,
|
|
44
|
+
log,
|
|
45
|
+
downloadBase: opts.downloadBase
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
if (!fs.existsSync(path.join(electronAppPath, "Contents", "MacOS"))) throw new Error(`electron cache install failed: ${electronAppPath} missing after extract`);
|
|
49
|
+
return {
|
|
50
|
+
electronAppPath,
|
|
51
|
+
version: exact,
|
|
52
|
+
major
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
async function downloadAndExtract(opts) {
|
|
56
|
+
const arch = process.arch === "arm64" ? "arm64" : "x64";
|
|
57
|
+
const filename = `electron-v${opts.version}-darwin-${arch}.zip`;
|
|
58
|
+
const url = (opts.downloadBase ?? "https://github.com/electron/electron/releases/download") + `/v${opts.version}/${filename}`;
|
|
59
|
+
const zipPath = path.join(ROOT, ".downloads", filename);
|
|
60
|
+
opts.log.info(`downloading ${url}`);
|
|
61
|
+
await downloadFile(url, zipPath, opts.log);
|
|
62
|
+
await fsp.mkdir(opts.versionDir, { recursive: true });
|
|
63
|
+
opts.log.info(`extracting -> ${opts.versionDir}`);
|
|
64
|
+
const r = spawnSync("ditto", [
|
|
65
|
+
"-x",
|
|
66
|
+
"-k",
|
|
67
|
+
zipPath,
|
|
68
|
+
opts.versionDir
|
|
69
|
+
], {
|
|
70
|
+
stdio: "pipe",
|
|
71
|
+
encoding: "utf8"
|
|
72
|
+
});
|
|
73
|
+
if (r.status !== 0) throw new Error(`ditto failed (${r.status}): ${r.stderr?.slice(0, 1e3) ?? ""}`);
|
|
74
|
+
await fsp.unlink(zipPath).catch(() => {});
|
|
75
|
+
}
|
|
76
|
+
async function downloadFile(url, dest, log, redirectsLeft = 5) {
|
|
77
|
+
const tmp = `${dest}.part`;
|
|
78
|
+
if (await new Promise((resolve, reject) => {
|
|
79
|
+
https.get(url, (res) => {
|
|
80
|
+
const code = res.statusCode ?? 0;
|
|
81
|
+
if (code >= 300 && code < 400 && res.headers.location) {
|
|
82
|
+
res.resume();
|
|
83
|
+
if (redirectsLeft <= 0) {
|
|
84
|
+
reject(/* @__PURE__ */ new Error(`download ${url}: too many redirects`));
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
downloadFile(res.headers.location, dest, log, redirectsLeft - 1).then(() => resolve(false), reject);
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
if (code !== 200) {
|
|
91
|
+
res.resume();
|
|
92
|
+
reject(/* @__PURE__ */ new Error(`download ${url} -> HTTP ${code}`));
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
const total = parseInt(res.headers["content-length"] ?? "0", 10);
|
|
96
|
+
let received = 0;
|
|
97
|
+
let lastPct = -1;
|
|
98
|
+
const file = fs.createWriteStream(tmp);
|
|
99
|
+
res.on("data", (chunk) => {
|
|
100
|
+
received += chunk.length;
|
|
101
|
+
if (total > 0) {
|
|
102
|
+
const pct = Math.floor(received / total * 100);
|
|
103
|
+
if (pct !== lastPct && pct % 10 === 0) {
|
|
104
|
+
log.info(` ${pct}% (${received}/${total})`);
|
|
105
|
+
lastPct = pct;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
res.pipe(file);
|
|
110
|
+
file.on("finish", () => {
|
|
111
|
+
file.close((err) => err ? reject(err) : resolve(true));
|
|
112
|
+
});
|
|
113
|
+
file.on("error", reject);
|
|
114
|
+
}).on("error", reject);
|
|
115
|
+
})) await fsp.rename(tmp, dest);
|
|
116
|
+
}
|
|
117
|
+
async function withLock(lockFile, fn) {
|
|
118
|
+
const deadline = Date.now() + 5 * 6e4;
|
|
119
|
+
while (Date.now() < deadline) try {
|
|
120
|
+
const fd = fs.openSync(lockFile, "wx");
|
|
121
|
+
try {
|
|
122
|
+
return await fn();
|
|
123
|
+
} finally {
|
|
124
|
+
fs.closeSync(fd);
|
|
125
|
+
try {
|
|
126
|
+
fs.unlinkSync(lockFile);
|
|
127
|
+
} catch {}
|
|
128
|
+
}
|
|
129
|
+
} catch (err) {
|
|
130
|
+
if (err.code !== "EEXIST") throw err;
|
|
131
|
+
try {
|
|
132
|
+
const stat = fs.statSync(lockFile);
|
|
133
|
+
if (Date.now() - stat.mtimeMs > 10 * 6e4) {
|
|
134
|
+
fs.unlinkSync(lockFile);
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
} catch {}
|
|
138
|
+
await sleep(500);
|
|
139
|
+
}
|
|
140
|
+
throw new Error(`timed out waiting for ${lockFile}`);
|
|
141
|
+
}
|
|
142
|
+
function sleep(ms) {
|
|
143
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
144
|
+
}
|
|
145
|
+
let registryCache = null;
|
|
146
|
+
async function fetchRegistry() {
|
|
147
|
+
if (registryCache) return registryCache;
|
|
148
|
+
registryCache = new Promise((resolve, reject) => {
|
|
149
|
+
https.get("https://registry.npmjs.org/electron", { headers: { accept: "application/vnd.npm.install-v1+json" } }, (res) => {
|
|
150
|
+
if (res.statusCode !== 200) {
|
|
151
|
+
reject(/* @__PURE__ */ new Error(`registry HTTP ${res.statusCode}`));
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
let body = "";
|
|
155
|
+
res.setEncoding("utf8");
|
|
156
|
+
res.on("data", (c) => body += c);
|
|
157
|
+
res.on("end", () => {
|
|
158
|
+
try {
|
|
159
|
+
resolve(JSON.parse(body));
|
|
160
|
+
} catch (err) {
|
|
161
|
+
reject(err);
|
|
162
|
+
}
|
|
163
|
+
});
|
|
164
|
+
res.on("error", reject);
|
|
165
|
+
}).on("error", reject);
|
|
166
|
+
});
|
|
167
|
+
return registryCache;
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* Accepts an exact version, a caret range, or a bare major. Returns the
|
|
171
|
+
* latest published electron version that satisfies it. Pure HTTP — no npm
|
|
172
|
+
* CLI invocation.
|
|
173
|
+
*/
|
|
174
|
+
async function resolveExactVersion(range, log) {
|
|
175
|
+
const trimmed = range.trim();
|
|
176
|
+
if (/^\d+\.\d+\.\d+(?:-[\w.]+)?$/.test(trimmed)) return trimmed;
|
|
177
|
+
log.info(`resolving electron ${trimmed} from registry`);
|
|
178
|
+
const doc = await fetchRegistry();
|
|
179
|
+
const all = Object.keys(doc.versions ?? {});
|
|
180
|
+
if (/^\d+$/.test(trimmed)) return pickLatestStableMajor(all, parseInt(trimmed, 10), trimmed);
|
|
181
|
+
const caret = trimmed.match(/^\^(\d+)(?:\.(\d+))?(?:\.(\d+))?$/);
|
|
182
|
+
if (caret) return pickLatestStableMajor(all, parseInt(caret[1], 10), trimmed);
|
|
183
|
+
const tilde = trimmed.match(/^~(\d+)\.(\d+)(?:\.(\d+))?$/);
|
|
184
|
+
if (tilde) {
|
|
185
|
+
const major = parseInt(tilde[1], 10);
|
|
186
|
+
const minor = parseInt(tilde[2], 10);
|
|
187
|
+
return pickHighest(all.filter((v) => {
|
|
188
|
+
const m = v.match(/^(\d+)\.(\d+)\.(\d+)(?:-|$)/);
|
|
189
|
+
if (!m) return false;
|
|
190
|
+
return parseInt(m[1], 10) === major && parseInt(m[2], 10) === minor;
|
|
191
|
+
}), trimmed);
|
|
192
|
+
}
|
|
193
|
+
if (trimmed === "latest") {
|
|
194
|
+
const latest = doc["dist-tags"]?.latest;
|
|
195
|
+
if (!latest) throw new Error("registry has no `latest` dist-tag");
|
|
196
|
+
return latest;
|
|
197
|
+
}
|
|
198
|
+
throw new Error(`unsupported electron version spec: "${trimmed}". Use an exact version, "^MAJOR", "~MAJOR.MINOR", a bare major, or "latest".`);
|
|
199
|
+
}
|
|
200
|
+
function pickLatestStableMajor(all, major, raw) {
|
|
201
|
+
return pickHighest(all.filter((v) => {
|
|
202
|
+
const m = v.match(/^(\d+)\.\d+\.\d+$/);
|
|
203
|
+
return m !== null && parseInt(m[1], 10) === major;
|
|
204
|
+
}), raw);
|
|
205
|
+
}
|
|
206
|
+
function pickHighest(versions, raw) {
|
|
207
|
+
if (versions.length === 0) throw new Error(`no electron version matched "${raw}"`);
|
|
208
|
+
const sorted = versions.sort(compareSemver);
|
|
209
|
+
return sorted[sorted.length - 1];
|
|
210
|
+
}
|
|
211
|
+
function compareSemver(a, b) {
|
|
212
|
+
const pa = a.split(".").map((n) => parseInt(n, 10));
|
|
213
|
+
const pb = b.split(".").map((n) => parseInt(n, 10));
|
|
214
|
+
for (let i = 0; i < 3; i++) {
|
|
215
|
+
const da = pa[i] ?? 0;
|
|
216
|
+
const db = pb[i] ?? 0;
|
|
217
|
+
if (da !== db) return da - db;
|
|
218
|
+
}
|
|
219
|
+
return 0;
|
|
220
|
+
}
|
|
221
|
+
//#endregion
|
|
222
|
+
//#region src/desktop/icon.ts
|
|
223
|
+
const ICONSET_SIZES = [
|
|
224
|
+
{
|
|
225
|
+
name: "icon_16x16.png",
|
|
226
|
+
px: 16
|
|
227
|
+
},
|
|
228
|
+
{
|
|
229
|
+
name: "icon_16x16@2x.png",
|
|
230
|
+
px: 32
|
|
231
|
+
},
|
|
232
|
+
{
|
|
233
|
+
name: "icon_32x32.png",
|
|
234
|
+
px: 32
|
|
235
|
+
},
|
|
236
|
+
{
|
|
237
|
+
name: "icon_32x32@2x.png",
|
|
238
|
+
px: 64
|
|
239
|
+
},
|
|
240
|
+
{
|
|
241
|
+
name: "icon_128x128.png",
|
|
242
|
+
px: 128
|
|
243
|
+
},
|
|
244
|
+
{
|
|
245
|
+
name: "icon_128x128@2x.png",
|
|
246
|
+
px: 256
|
|
247
|
+
},
|
|
248
|
+
{
|
|
249
|
+
name: "icon_256x256.png",
|
|
250
|
+
px: 256
|
|
251
|
+
},
|
|
252
|
+
{
|
|
253
|
+
name: "icon_256x256@2x.png",
|
|
254
|
+
px: 512
|
|
255
|
+
},
|
|
256
|
+
{
|
|
257
|
+
name: "icon_512x512.png",
|
|
258
|
+
px: 512
|
|
259
|
+
},
|
|
260
|
+
{
|
|
261
|
+
name: "icon_512x512@2x.png",
|
|
262
|
+
px: 1024
|
|
263
|
+
}
|
|
264
|
+
];
|
|
265
|
+
async function prepareIcon(opts) {
|
|
266
|
+
const { source, dest, log } = opts;
|
|
267
|
+
if (!fs.existsSync(source)) throw new Error(`icon source does not exist: ${source}`);
|
|
268
|
+
await fsp.mkdir(path.dirname(dest), { recursive: true });
|
|
269
|
+
const ext = path.extname(source).toLowerCase();
|
|
270
|
+
if (ext === ".icns") {
|
|
271
|
+
log.info(`copying icns ${source} -> ${dest}`);
|
|
272
|
+
await fsp.copyFile(source, dest);
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
if (ext !== ".png") throw new Error(`unsupported icon format "${ext}". Use .png (square, ideally 1024x1024) or .icns.`);
|
|
276
|
+
log.info(`converting png -> icns (${source})`);
|
|
277
|
+
const tmp = await fsp.mkdtemp(path.join(os.tmpdir(), "czda-iconset-"));
|
|
278
|
+
const iconset = path.join(tmp, "icon.iconset");
|
|
279
|
+
await fsp.mkdir(iconset, { recursive: true });
|
|
280
|
+
for (const { name, px } of ICONSET_SIZES) {
|
|
281
|
+
const out = path.join(iconset, name);
|
|
282
|
+
const r = spawnSync("sips", [
|
|
283
|
+
"-z",
|
|
284
|
+
String(px),
|
|
285
|
+
String(px),
|
|
286
|
+
source,
|
|
287
|
+
"--out",
|
|
288
|
+
out
|
|
289
|
+
], {
|
|
290
|
+
stdio: "pipe",
|
|
291
|
+
encoding: "utf8"
|
|
292
|
+
});
|
|
293
|
+
if (r.status !== 0) throw new Error(`sips failed for ${name} (${r.status}): ${r.stderr?.slice(0, 500) ?? ""}`);
|
|
294
|
+
}
|
|
295
|
+
const r = spawnSync("iconutil", [
|
|
296
|
+
"-c",
|
|
297
|
+
"icns",
|
|
298
|
+
iconset,
|
|
299
|
+
"-o",
|
|
300
|
+
dest
|
|
301
|
+
], {
|
|
302
|
+
stdio: "pipe",
|
|
303
|
+
encoding: "utf8"
|
|
304
|
+
});
|
|
305
|
+
if (r.status !== 0) throw new Error(`iconutil failed (${r.status}): ${r.stderr?.slice(0, 500) ?? ""}`);
|
|
306
|
+
await fsp.rm(tmp, {
|
|
307
|
+
recursive: true,
|
|
308
|
+
force: true
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
//#endregion
|
|
312
|
+
//#region src/desktop/bundle.ts
|
|
313
|
+
const ENTITLEMENTS = `<?xml version="1.0" encoding="UTF-8"?>
|
|
314
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
315
|
+
<plist version="1.0">
|
|
316
|
+
<dict>
|
|
317
|
+
<key>com.apple.security.cs.allow-jit</key>
|
|
318
|
+
<true/>
|
|
319
|
+
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
|
|
320
|
+
<true/>
|
|
321
|
+
<key>com.apple.security.cs.disable-library-validation</key>
|
|
322
|
+
<true/>
|
|
323
|
+
</dict>
|
|
324
|
+
</plist>
|
|
325
|
+
`;
|
|
326
|
+
async function synthesizeBundle(opts) {
|
|
327
|
+
const { cachedElectronApp, destApp, displayName, slug, version, bundleId = `dev.zenbu.${slug}`, iconSource, packageManager, appsDir, launcherSource, log, dryRun = false, force = false } = opts;
|
|
328
|
+
if (!fs.existsSync(cachedElectronApp)) throw new Error(`cached Electron.app missing: ${cachedElectronApp}. Did the cache step fail?`);
|
|
329
|
+
if (fs.existsSync(destApp)) {
|
|
330
|
+
if (!force) throw new Error(`${destApp} already exists. Re-run with --force to overwrite.`);
|
|
331
|
+
if (dryRun) log.info(`[dry-run] would rm -rf ${destApp}`);
|
|
332
|
+
else {
|
|
333
|
+
log.info(`removing existing ${destApp}`);
|
|
334
|
+
await fsp.rm(destApp, {
|
|
335
|
+
recursive: true,
|
|
336
|
+
force: true
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
await ensureParentDir(destApp, dryRun, log);
|
|
341
|
+
await log.withStep(`clone-copy ${cachedElectronApp} -> ${destApp}`, async () => {
|
|
342
|
+
if (dryRun) return;
|
|
343
|
+
cloneCopy(cachedElectronApp, destApp, log);
|
|
344
|
+
});
|
|
345
|
+
const macosDir = path.join(destApp, "Contents", "MacOS");
|
|
346
|
+
const oldExe = path.join(macosDir, "Electron");
|
|
347
|
+
const newExe = path.join(macosDir, displayName);
|
|
348
|
+
await log.withStep(`rename executable -> ${displayName}`, async () => {
|
|
349
|
+
if (dryRun) return;
|
|
350
|
+
if (fs.existsSync(oldExe)) await fsp.rename(oldExe, newExe);
|
|
351
|
+
else if (!fs.existsSync(newExe)) throw new Error(`expected ${oldExe} or ${newExe} to exist after clone-copy`);
|
|
352
|
+
});
|
|
353
|
+
const infoPlist = path.join(destApp, "Contents", "Info.plist");
|
|
354
|
+
await log.withStep(`patch Info.plist`, async () => {
|
|
355
|
+
if (dryRun) return;
|
|
356
|
+
const ops = [
|
|
357
|
+
{
|
|
358
|
+
key: "CFBundleName",
|
|
359
|
+
type: "string",
|
|
360
|
+
value: displayName
|
|
361
|
+
},
|
|
362
|
+
{
|
|
363
|
+
key: "CFBundleDisplayName",
|
|
364
|
+
type: "string",
|
|
365
|
+
value: displayName
|
|
366
|
+
},
|
|
367
|
+
{
|
|
368
|
+
key: "CFBundleExecutable",
|
|
369
|
+
type: "string",
|
|
370
|
+
value: displayName
|
|
371
|
+
},
|
|
372
|
+
{
|
|
373
|
+
key: "CFBundleIdentifier",
|
|
374
|
+
type: "string",
|
|
375
|
+
value: bundleId
|
|
376
|
+
},
|
|
377
|
+
{
|
|
378
|
+
key: "CFBundleIconFile",
|
|
379
|
+
type: "string",
|
|
380
|
+
value: "icon.icns"
|
|
381
|
+
},
|
|
382
|
+
{
|
|
383
|
+
key: "CFBundleVersion",
|
|
384
|
+
type: "string",
|
|
385
|
+
value: version
|
|
386
|
+
},
|
|
387
|
+
{
|
|
388
|
+
key: "CFBundleShortVersionString",
|
|
389
|
+
type: "string",
|
|
390
|
+
value: version
|
|
391
|
+
},
|
|
392
|
+
{
|
|
393
|
+
key: "NSHighResolutionCapable",
|
|
394
|
+
type: "bool",
|
|
395
|
+
value: true
|
|
396
|
+
},
|
|
397
|
+
{
|
|
398
|
+
key: "LSMinimumSystemVersion",
|
|
399
|
+
type: "string",
|
|
400
|
+
value: "12.0"
|
|
401
|
+
},
|
|
402
|
+
{
|
|
403
|
+
key: "NSSupportsAutomaticGraphicsSwitching",
|
|
404
|
+
type: "bool",
|
|
405
|
+
value: true
|
|
406
|
+
},
|
|
407
|
+
{
|
|
408
|
+
key: "NSRequiresAquaSystemAppearance",
|
|
409
|
+
type: "bool",
|
|
410
|
+
value: false
|
|
411
|
+
},
|
|
412
|
+
{
|
|
413
|
+
key: "NSPrincipalClass",
|
|
414
|
+
type: "string",
|
|
415
|
+
value: "AtomApplication"
|
|
416
|
+
}
|
|
417
|
+
];
|
|
418
|
+
for (const op of ops) plutilReplace(infoPlist, op, log);
|
|
419
|
+
plutilRemove(infoPlist, ":ElectronAsarIntegrity", log);
|
|
420
|
+
});
|
|
421
|
+
const resourcesDir = path.join(destApp, "Contents", "Resources");
|
|
422
|
+
const iconDest = path.join(resourcesDir, "icon.icns");
|
|
423
|
+
await log.withStep(`install icon -> ${iconDest}`, async () => {
|
|
424
|
+
if (dryRun) return;
|
|
425
|
+
if (iconSource) await prepareIcon({
|
|
426
|
+
source: iconSource,
|
|
427
|
+
dest: iconDest,
|
|
428
|
+
log
|
|
429
|
+
});
|
|
430
|
+
else {
|
|
431
|
+
const fallback = path.join(resourcesDir, "electron.icns");
|
|
432
|
+
if (fs.existsSync(fallback)) await fsp.copyFile(fallback, iconDest);
|
|
433
|
+
else log.info(`no icon source and no electron.icns fallback; skipping`);
|
|
434
|
+
}
|
|
435
|
+
});
|
|
436
|
+
const bundleAppDir = path.join(resourcesDir, "app");
|
|
437
|
+
await log.withStep(`write Resources/app/{package.json,launcher.mjs,app-config.json,host.json}`, async () => {
|
|
438
|
+
if (dryRun) return;
|
|
439
|
+
await fsp.mkdir(bundleAppDir, { recursive: true });
|
|
440
|
+
const bundlePkg = {
|
|
441
|
+
name: slug,
|
|
442
|
+
version,
|
|
443
|
+
main: "launcher.mjs",
|
|
444
|
+
type: "module"
|
|
445
|
+
};
|
|
446
|
+
await fsp.writeFile(path.join(bundleAppDir, "package.json"), JSON.stringify(bundlePkg, null, 2) + "\n");
|
|
447
|
+
if (!fs.existsSync(launcherSource)) throw new Error(`launcher source missing: ${launcherSource}`);
|
|
448
|
+
await fsp.copyFile(launcherSource, path.join(bundleAppDir, "launcher.mjs"));
|
|
449
|
+
const appConfig = {
|
|
450
|
+
name: slug,
|
|
451
|
+
packageManager,
|
|
452
|
+
version,
|
|
453
|
+
local: true
|
|
454
|
+
};
|
|
455
|
+
await fsp.writeFile(path.join(bundleAppDir, "app-config.json"), JSON.stringify(appConfig, null, 2) + "\n");
|
|
456
|
+
await fsp.writeFile(path.join(bundleAppDir, "host.json"), JSON.stringify({ version }, null, 2) + "\n");
|
|
457
|
+
log.info(`appsDir baked into name = ${slug} (resolves to ${appsDir})`);
|
|
458
|
+
});
|
|
459
|
+
await adhocCodesign({
|
|
460
|
+
destApp,
|
|
461
|
+
log,
|
|
462
|
+
dryRun
|
|
463
|
+
});
|
|
464
|
+
await stripQuarantine({
|
|
465
|
+
destApp,
|
|
466
|
+
log,
|
|
467
|
+
dryRun
|
|
468
|
+
});
|
|
469
|
+
await log.withStep(`verify (codesign + plutil)`, async () => {
|
|
470
|
+
if (dryRun) return;
|
|
471
|
+
const cs = spawnSync("codesign", [
|
|
472
|
+
"--verify",
|
|
473
|
+
"--deep",
|
|
474
|
+
"--strict",
|
|
475
|
+
destApp
|
|
476
|
+
], {
|
|
477
|
+
stdio: "pipe",
|
|
478
|
+
encoding: "utf8"
|
|
479
|
+
});
|
|
480
|
+
if (cs.status !== 0) throw new Error(`codesign verify failed (${cs.status}): ${cs.stderr?.slice(0, 1e3) ?? ""}`);
|
|
481
|
+
const pl = spawnSync("plutil", ["-lint", infoPlist], {
|
|
482
|
+
stdio: "pipe",
|
|
483
|
+
encoding: "utf8"
|
|
484
|
+
});
|
|
485
|
+
if (pl.status !== 0) throw new Error(`plutil -lint failed (${pl.status}): ${pl.stderr?.slice(0, 500) ?? ""} ${pl.stdout?.slice(0, 500) ?? ""}`);
|
|
486
|
+
});
|
|
487
|
+
}
|
|
488
|
+
/**
|
|
489
|
+
* Ad-hoc deep-sign a bundle with the same entitlements used at synthesize
|
|
490
|
+
* time. Reusable so post-synthesize mutations (e.g. icon swap) can re-sign
|
|
491
|
+
* without rebuilding the whole bundle.
|
|
492
|
+
*/
|
|
493
|
+
async function adhocCodesign(opts) {
|
|
494
|
+
const { destApp, log, dryRun = false } = opts;
|
|
495
|
+
const entitlementsPath = path.join(os.tmpdir(), `czda-entitlements-${process.pid}-${Date.now()}.plist`);
|
|
496
|
+
await log.withStep(`ad-hoc codesign --deep`, async () => {
|
|
497
|
+
if (dryRun) return;
|
|
498
|
+
await fsp.writeFile(entitlementsPath, ENTITLEMENTS);
|
|
499
|
+
try {
|
|
500
|
+
const r = spawnSync("codesign", [
|
|
501
|
+
"--force",
|
|
502
|
+
"--deep",
|
|
503
|
+
"--sign",
|
|
504
|
+
"-",
|
|
505
|
+
"--entitlements",
|
|
506
|
+
entitlementsPath,
|
|
507
|
+
destApp
|
|
508
|
+
], {
|
|
509
|
+
stdio: "pipe",
|
|
510
|
+
encoding: "utf8"
|
|
511
|
+
});
|
|
512
|
+
if (r.status !== 0) throw new Error(`codesign failed (${r.status}): ${r.stderr?.slice(0, 1e3) ?? ""}`);
|
|
513
|
+
log.info(r.stderr?.trim() || "codesign ok");
|
|
514
|
+
} finally {
|
|
515
|
+
await fsp.unlink(entitlementsPath).catch(() => {});
|
|
516
|
+
}
|
|
517
|
+
});
|
|
518
|
+
}
|
|
519
|
+
async function stripQuarantine(opts) {
|
|
520
|
+
const { destApp, log, dryRun = false } = opts;
|
|
521
|
+
await log.withStep(`xattr -dr com.apple.quarantine`, async () => {
|
|
522
|
+
if (dryRun) return;
|
|
523
|
+
spawnSync("xattr", [
|
|
524
|
+
"-dr",
|
|
525
|
+
"com.apple.quarantine",
|
|
526
|
+
destApp
|
|
527
|
+
], { stdio: "ignore" });
|
|
528
|
+
});
|
|
529
|
+
}
|
|
530
|
+
function cloneCopy(src, dest, log) {
|
|
531
|
+
const r = spawnSync("cp", [
|
|
532
|
+
"-c",
|
|
533
|
+
"-R",
|
|
534
|
+
src,
|
|
535
|
+
dest
|
|
536
|
+
], {
|
|
537
|
+
stdio: "pipe",
|
|
538
|
+
encoding: "utf8"
|
|
539
|
+
});
|
|
540
|
+
if (r.status === 0) return;
|
|
541
|
+
log.info(`cp -c failed, falling back to cp -R: ${r.stderr?.trim() ?? ""}`);
|
|
542
|
+
const r2 = spawnSync("cp", [
|
|
543
|
+
"-R",
|
|
544
|
+
src,
|
|
545
|
+
dest
|
|
546
|
+
], {
|
|
547
|
+
stdio: "pipe",
|
|
548
|
+
encoding: "utf8"
|
|
549
|
+
});
|
|
550
|
+
if (r2.status !== 0) throw new Error(`cp failed (${r2.status}): ${r2.stderr?.slice(0, 1e3) ?? ""}`);
|
|
551
|
+
}
|
|
552
|
+
async function ensureParentDir(destApp, dryRun, log) {
|
|
553
|
+
const parent = path.dirname(destApp);
|
|
554
|
+
if (fs.existsSync(parent)) return;
|
|
555
|
+
if (dryRun) {
|
|
556
|
+
log.info(`[dry-run] would mkdir -p ${parent}`);
|
|
557
|
+
return;
|
|
558
|
+
}
|
|
559
|
+
await fsp.mkdir(parent, { recursive: true });
|
|
560
|
+
}
|
|
561
|
+
function plutilReplace(infoPlist, op, log) {
|
|
562
|
+
const value = String(op.value);
|
|
563
|
+
if (spawnSync("plutil", [
|
|
564
|
+
"-replace",
|
|
565
|
+
op.key,
|
|
566
|
+
`-${op.type}`,
|
|
567
|
+
value,
|
|
568
|
+
infoPlist
|
|
569
|
+
], {
|
|
570
|
+
stdio: "pipe",
|
|
571
|
+
encoding: "utf8"
|
|
572
|
+
}).status === 0) return;
|
|
573
|
+
const insert = spawnSync("plutil", [
|
|
574
|
+
"-insert",
|
|
575
|
+
op.key,
|
|
576
|
+
`-${op.type}`,
|
|
577
|
+
value,
|
|
578
|
+
infoPlist
|
|
579
|
+
], {
|
|
580
|
+
stdio: "pipe",
|
|
581
|
+
encoding: "utf8"
|
|
582
|
+
});
|
|
583
|
+
if (insert.status !== 0) throw new Error(`plutil -insert ${op.key} failed (${insert.status}): ${insert.stderr?.slice(0, 500) ?? ""}`);
|
|
584
|
+
log.info(`plutil inserted ${op.key}=${value}`);
|
|
585
|
+
}
|
|
586
|
+
function plutilRemove(infoPlist, keyPath, log) {
|
|
587
|
+
const r = spawnSync("plutil", [
|
|
588
|
+
"-remove",
|
|
589
|
+
keyPath,
|
|
590
|
+
infoPlist
|
|
591
|
+
], {
|
|
592
|
+
stdio: "pipe",
|
|
593
|
+
encoding: "utf8"
|
|
594
|
+
});
|
|
595
|
+
if (r.status === 0) {
|
|
596
|
+
log.info(`plutil removed ${keyPath}`);
|
|
597
|
+
return;
|
|
598
|
+
}
|
|
599
|
+
log.info(`plutil -remove ${keyPath}: ${r.stderr?.trim() ?? "(no key)"}`);
|
|
600
|
+
}
|
|
601
|
+
/**
|
|
602
|
+
* Resolve the path to `@zenbujs/core/dist/launcher.mjs` from any cwd. We
|
|
603
|
+
* use `package.json` as the resolution anchor because that subpath is
|
|
604
|
+
* exported (see packages/core/package.json `exports`).
|
|
605
|
+
*/
|
|
606
|
+
function resolveLauncherSource(fromDir) {
|
|
607
|
+
const req = createRequire(path.join(fromDir, "noop.js"));
|
|
608
|
+
let pkgPath;
|
|
609
|
+
try {
|
|
610
|
+
pkgPath = req.resolve("@zenbujs/core/package.json");
|
|
611
|
+
} catch (err) {
|
|
612
|
+
throw new Error(`failed to resolve @zenbujs/core from ${fromDir}: ${err.message}. Make sure create-zenbu-app's dependency on @zenbujs/core is installed.`);
|
|
613
|
+
}
|
|
614
|
+
const launcher = path.join(path.dirname(pkgPath), "dist", "launcher.mjs");
|
|
615
|
+
if (!fs.existsSync(launcher)) throw new Error(`@zenbujs/core launcher not found at ${launcher}. The package may be a stub or unbuilt.`);
|
|
616
|
+
return launcher;
|
|
617
|
+
}
|
|
618
|
+
//#endregion
|
|
619
|
+
//#region src/desktop/deps-sig.ts
|
|
620
|
+
function lockfileFor(type) {
|
|
621
|
+
switch (type) {
|
|
622
|
+
case "pnpm": return "pnpm-lock.yaml";
|
|
623
|
+
case "npm": return "package-lock.json";
|
|
624
|
+
case "yarn": return "yarn.lock";
|
|
625
|
+
case "bun": return "bun.lock";
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
/**
|
|
629
|
+
* Mirror of `packages/core/src/shared/pm-install.ts#depsSignature`, with
|
|
630
|
+
* `electronVersion` accepted explicitly because we run from Node (not
|
|
631
|
+
* Electron) and `process.versions.electron` is undefined here.
|
|
632
|
+
*
|
|
633
|
+
* Must stay byte-for-byte equivalent to the launcher's recipe so the
|
|
634
|
+
* pre-seeded `<appsDir>/.zenbu/deps-sig` matches what `ensureDepsInstalled`
|
|
635
|
+
* recomputes on first launch and the install gate skips.
|
|
636
|
+
*/
|
|
637
|
+
async function depsSignature(opts) {
|
|
638
|
+
const hash = crypto.createHash("sha256");
|
|
639
|
+
await fileHash(hash, path.join(opts.appsDir, "package.json"));
|
|
640
|
+
await fileHash(hash, path.join(opts.appsDir, lockfileFor(opts.pm.type)));
|
|
641
|
+
hash.update(`${opts.pm.type}@${opts.pm.version}`);
|
|
642
|
+
hash.update("\0");
|
|
643
|
+
hash.update(opts.electronVersion);
|
|
644
|
+
hash.update("\0");
|
|
645
|
+
hash.update(opts.platform ?? process.platform);
|
|
646
|
+
hash.update("\0");
|
|
647
|
+
hash.update(opts.arch ?? process.arch);
|
|
648
|
+
return hash.digest("hex");
|
|
649
|
+
}
|
|
650
|
+
async function fileHash(hash, filePath) {
|
|
651
|
+
hash.update(filePath);
|
|
652
|
+
hash.update("\0");
|
|
653
|
+
try {
|
|
654
|
+
hash.update(await fsp.readFile(filePath));
|
|
655
|
+
} catch {}
|
|
656
|
+
hash.update("\0");
|
|
657
|
+
}
|
|
658
|
+
async function writeDepsSig(appsDir, sig) {
|
|
659
|
+
const sigPath = path.join(appsDir, ".zenbu", "deps-sig");
|
|
660
|
+
await fsp.mkdir(path.dirname(sigPath), { recursive: true });
|
|
661
|
+
await fsp.writeFile(sigPath, sig);
|
|
662
|
+
}
|
|
663
|
+
//#endregion
|
|
664
|
+
//#region src/desktop/log.ts
|
|
665
|
+
const LOG_DIR = path.join(os.homedir(), ".zenbu", "logs", "create-zenbu-app");
|
|
666
|
+
function ts() {
|
|
667
|
+
return (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
|
|
668
|
+
}
|
|
669
|
+
function createLogger(opts) {
|
|
670
|
+
fs.mkdirSync(LOG_DIR, { recursive: true });
|
|
671
|
+
const file = path.join(LOG_DIR, `${ts()}-${opts.slug}.log`);
|
|
672
|
+
const stream = fs.createWriteStream(file, { flags: "a" });
|
|
673
|
+
stream.write(`=== create-zenbu-app --desktop ${(/* @__PURE__ */ new Date()).toISOString()} pid=${process.pid} slug=${opts.slug} ===\n`);
|
|
674
|
+
const writeLine = (prefix, line) => {
|
|
675
|
+
const text = `[${(/* @__PURE__ */ new Date()).toISOString()}] ${prefix}${line}\n`;
|
|
676
|
+
try {
|
|
677
|
+
stream.write(text);
|
|
678
|
+
} catch {}
|
|
679
|
+
if (opts.verbose) process.stdout.write(text);
|
|
680
|
+
};
|
|
681
|
+
return {
|
|
682
|
+
file,
|
|
683
|
+
step(label) {
|
|
684
|
+
writeLine("[STEP] ", label);
|
|
685
|
+
},
|
|
686
|
+
info(line) {
|
|
687
|
+
writeLine("", line);
|
|
688
|
+
},
|
|
689
|
+
error(line) {
|
|
690
|
+
const text = `[${(/* @__PURE__ */ new Date()).toISOString()}] [ERR] ${line}\n`;
|
|
691
|
+
try {
|
|
692
|
+
stream.write(text);
|
|
693
|
+
} catch {}
|
|
694
|
+
process.stderr.write(text);
|
|
695
|
+
},
|
|
696
|
+
async withStep(label, fn) {
|
|
697
|
+
this.step(label);
|
|
698
|
+
const start = Date.now();
|
|
699
|
+
try {
|
|
700
|
+
const out = await fn();
|
|
701
|
+
writeLine("[STEP-OK] ", `${label} (${Date.now() - start}ms)`);
|
|
702
|
+
return out;
|
|
703
|
+
} catch (err) {
|
|
704
|
+
const e = err;
|
|
705
|
+
writeLine("[STEP-FAIL] ", `${label} (${Date.now() - start}ms): ${e.stack ?? e.message ?? String(err)}`);
|
|
706
|
+
throw err;
|
|
707
|
+
}
|
|
708
|
+
},
|
|
709
|
+
tail(n = 30) {
|
|
710
|
+
try {
|
|
711
|
+
const all = fs.readFileSync(file, "utf8").split("\n");
|
|
712
|
+
return all.slice(Math.max(0, all.length - n - 1)).join("\n");
|
|
713
|
+
} catch {
|
|
714
|
+
return "";
|
|
715
|
+
}
|
|
716
|
+
},
|
|
717
|
+
close() {
|
|
718
|
+
try {
|
|
719
|
+
stream.end();
|
|
720
|
+
} catch {}
|
|
721
|
+
}
|
|
722
|
+
};
|
|
723
|
+
}
|
|
724
|
+
//#endregion
|
|
725
|
+
//#region src/desktop/update-icon.ts
|
|
726
|
+
/**
|
|
727
|
+
* Swap an existing bundle's `Contents/Resources/icon.icns`, then re-codesign
|
|
728
|
+
* (ad-hoc, deep) so the bundle stays valid. Optionally nudges Launch
|
|
729
|
+
* Services to re-read the icon.
|
|
730
|
+
*/
|
|
731
|
+
async function updateBundleIcon(opts) {
|
|
732
|
+
const { destApp, iconSource, log, refreshIconServices = true } = opts;
|
|
733
|
+
if (!fs.existsSync(destApp)) throw new Error(`bundle does not exist: ${destApp}`);
|
|
734
|
+
if (!destApp.endsWith(".app")) throw new Error(`expected an .app bundle, got: ${destApp}`);
|
|
735
|
+
if (!fs.existsSync(iconSource)) throw new Error(`icon source does not exist: ${iconSource}`);
|
|
736
|
+
const iconDest = path.join(destApp, "Contents", "Resources", "icon.icns");
|
|
737
|
+
await log.withStep(`replace icon -> ${iconDest}`, async () => {
|
|
738
|
+
await prepareIcon({
|
|
739
|
+
source: iconSource,
|
|
740
|
+
dest: iconDest,
|
|
741
|
+
log
|
|
742
|
+
});
|
|
743
|
+
});
|
|
744
|
+
await adhocCodesign({
|
|
745
|
+
destApp,
|
|
746
|
+
log
|
|
747
|
+
});
|
|
748
|
+
await stripQuarantine({
|
|
749
|
+
destApp,
|
|
750
|
+
log
|
|
751
|
+
});
|
|
752
|
+
if (refreshIconServices) await log.withStep(`refresh Launch Services / Dock`, async () => {
|
|
753
|
+
const now = /* @__PURE__ */ new Date();
|
|
754
|
+
try {
|
|
755
|
+
await fsp.utimes(destApp, now, now);
|
|
756
|
+
} catch (err) {
|
|
757
|
+
log.info(`utimes failed: ${err.message}`);
|
|
758
|
+
}
|
|
759
|
+
const lsregister = "/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister";
|
|
760
|
+
if (fs.existsSync(lsregister)) {
|
|
761
|
+
const r = spawnSync(lsregister, ["-f", destApp], {
|
|
762
|
+
stdio: "pipe",
|
|
763
|
+
encoding: "utf8"
|
|
764
|
+
});
|
|
765
|
+
if (r.status !== 0) log.info(`lsregister exit=${r.status} ${r.stderr?.trim() ?? ""}`);
|
|
766
|
+
}
|
|
767
|
+
const iconSvcCache = path.join(os.homedir(), "Library", "Caches", "com.apple.iconservices.store");
|
|
768
|
+
try {
|
|
769
|
+
if (fs.existsSync(iconSvcCache)) await fsp.rm(iconSvcCache, { force: true });
|
|
770
|
+
} catch {}
|
|
771
|
+
spawnSync("killall", ["Dock"], { stdio: "ignore" });
|
|
772
|
+
spawnSync("killall", ["Finder"], { stdio: "ignore" });
|
|
773
|
+
});
|
|
774
|
+
}
|
|
775
|
+
//#endregion
|
|
776
|
+
//#region src/desktop/index.ts
|
|
777
|
+
/**
|
|
778
|
+
* Default destination when `destApp` not provided. We prefer `/Applications`
|
|
779
|
+
* but fall back to `~/Applications` when /Applications isn't writable
|
|
780
|
+
* (e.g. without admin rights or on locked-down setups).
|
|
781
|
+
*/
|
|
782
|
+
function defaultDestApp(displayName) {
|
|
783
|
+
const sysApps = "/Applications";
|
|
784
|
+
try {
|
|
785
|
+
fs.accessSync(sysApps, fs.constants.W_OK);
|
|
786
|
+
return path.join(sysApps, `${displayName}.app`);
|
|
787
|
+
} catch {
|
|
788
|
+
const userApps = path.join(os.homedir(), "Applications");
|
|
789
|
+
return path.join(userApps, `${displayName}.app`);
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
async function buildDesktopApp(opts) {
|
|
793
|
+
const { displayName, slug, version, electronVersionRange, iconSource, appsDir, packageManager, resolveFrom, log, force = false, dryRun = false, skipDepsSig = false } = opts;
|
|
794
|
+
const destApp = opts.destApp ?? defaultDestApp(displayName);
|
|
795
|
+
const versionRange = electronVersionRange ?? await readElectronRangeFromAppsDir(appsDir) ?? "latest";
|
|
796
|
+
log.info(`displayName=${displayName} slug=${slug} version=${version}`);
|
|
797
|
+
log.info(`appsDir=${appsDir}`);
|
|
798
|
+
log.info(`destApp=${destApp}`);
|
|
799
|
+
log.info(`electronVersionRange=${versionRange}`);
|
|
800
|
+
const cache = await log.withStep(`ensure electron cache (${versionRange})`, () => ensureElectronApp({
|
|
801
|
+
versionRange,
|
|
802
|
+
log
|
|
803
|
+
}));
|
|
804
|
+
const launcherSource = resolveLauncherSource(resolveFrom);
|
|
805
|
+
log.info(`launcher source: ${launcherSource}`);
|
|
806
|
+
await synthesizeBundle({
|
|
807
|
+
cachedElectronApp: cache.electronAppPath,
|
|
808
|
+
destApp,
|
|
809
|
+
displayName,
|
|
810
|
+
slug,
|
|
811
|
+
version,
|
|
812
|
+
iconSource,
|
|
813
|
+
packageManager,
|
|
814
|
+
appsDir,
|
|
815
|
+
launcherSource,
|
|
816
|
+
log,
|
|
817
|
+
dryRun,
|
|
818
|
+
force
|
|
819
|
+
});
|
|
820
|
+
if (!skipDepsSig && !dryRun) await log.withStep(`write <appsDir>/.zenbu/deps-sig`, async () => {
|
|
821
|
+
const sig = await depsSignature({
|
|
822
|
+
appsDir,
|
|
823
|
+
pm: packageManager,
|
|
824
|
+
electronVersion: cache.version
|
|
825
|
+
});
|
|
826
|
+
await writeDepsSig(appsDir, sig);
|
|
827
|
+
log.info(`deps-sig=${sig.slice(0, 16)}…`);
|
|
828
|
+
});
|
|
829
|
+
else if (skipDepsSig) log.info(`skipping deps-sig (--no-install or skipDepsSig=true)`);
|
|
830
|
+
return {
|
|
831
|
+
destApp,
|
|
832
|
+
electronVersion: cache.version,
|
|
833
|
+
appsDir,
|
|
834
|
+
launchCommand: `open -a "${destApp}"`
|
|
835
|
+
};
|
|
836
|
+
}
|
|
837
|
+
async function readElectronRangeFromAppsDir(appsDir) {
|
|
838
|
+
try {
|
|
839
|
+
const raw = await fsp.readFile(path.join(appsDir, "package.json"), "utf8");
|
|
840
|
+
const pkg = JSON.parse(raw);
|
|
841
|
+
return pkg.devDependencies?.electron ?? pkg.dependencies?.electron ?? null;
|
|
842
|
+
} catch {
|
|
843
|
+
return null;
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
//#endregion
|
|
847
|
+
export { writeDepsSig as a, stripQuarantine as c, ensureElectronApp as d, depsSignature as i, synthesizeBundle as l, updateBundleIcon as n, adhocCodesign as o, createLogger as r, resolveLauncherSource as s, buildDesktopApp as t, prepareIcon as u };
|