Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
quarto-dev
GitHub Repository: quarto-dev/quarto-cli
Path: blob/main/package/src/macos/installer.ts
6450 views
1
/*
2
* installer.ts
3
*
4
* Copyright (C) 2020-2022 Posit Software, PBC
5
*
6
*/
7
8
// TODO: Could also consider moving the keychain work out of the github actions and into typescript
9
// TODO: Confirm whether we should truly be signing the other, non deno, files
10
// TODO: Configuration could be initialized with working dir and scripts dir so sub tasks can just use that directory (and have it cleaned up automatically)
11
// TODO: Bundle and package Identifier - same or different?
12
13
import { dirname, join } from "../../../src/deno_ral/path.ts";
14
import { ensureDirSync, existsSync } from "../../../src/deno_ral/fs.ts";
15
import { error, info, warning } from "../../../src/deno_ral/log.ts";
16
17
import { Configuration } from "../common/config.ts";
18
import { runCmd } from "../util/cmd.ts";
19
import { getEnv } from "../util/utils.ts";
20
import { makeTarball } from "../util/tar.ts";
21
22
// Packaging specific configuration
23
// (Some things are global others may be platform specific)
24
export interface PackageInfo {
25
name: string;
26
identifier: string;
27
packageArgs: () => string[];
28
}
29
30
export async function makeInstallerMac(config: Configuration) {
31
// Core package
32
const corePackageName = `quarto-core.pkg`;
33
const corePackagePath = join(
34
config.directoryInfo.out,
35
corePackageName,
36
);
37
const packageIdentifier = "org.rstudio.quarto";
38
39
// Product package
40
const packageName = `quarto-${config.version}-macos.pkg`;
41
const packagePath = join(
42
config.directoryInfo.out,
43
packageName,
44
);
45
46
const distXml = join(
47
config.directoryInfo.pkg,
48
"scripts",
49
"macos",
50
"distribution.xml",
51
);
52
53
info(`Packaging into ${packagePath}`);
54
55
// Clean any existing package
56
if (existsSync(packagePath)) {
57
Deno.removeSync(packagePath);
58
}
59
60
// Make the output dir
61
ensureDirSync(dirname(packagePath));
62
63
// The application cert developer Id
64
const applicationDevId = getEnv("QUARTO_APPLE_APP_DEV_ID", "");
65
const signBinaries = applicationDevId.length > 0;
66
67
// Sign the deno executable
68
if (signBinaries) {
69
info("Signing binaries");
70
const entitlements = join(
71
config.directoryInfo.pkg,
72
"scripts",
73
"macos",
74
"entitlements.plist",
75
);
76
77
// Sign these non-binary files and don't include
78
// the entitlements declaration
79
const signWithoutEntitlements: string[] = [
80
join(config.directoryInfo.pkgWorking.bin, "quarto.js"),
81
join(config.directoryInfo.pkgWorking.bin, "quarto"),
82
];
83
84
85
// Sign these executable / binary files
86
// and include our entitlements declaration
87
const signWithEntitlements: string[] = [];
88
["aarch64", "x86_64"].forEach((arch) => {
89
signWithEntitlements.push(join(
90
config.directoryInfo.pkgWorking.bin,
91
"tools",
92
arch,
93
"deno",
94
));
95
96
signWithEntitlements.push(join(
97
config.directoryInfo.pkgWorking.bin,
98
"tools",
99
arch,
100
"dart-sass",
101
"src",
102
"dart",
103
));
104
signWithoutEntitlements.push(join(config.directoryInfo.pkgWorking.bin, "tools", arch, "dart-sass", "sass"));
105
106
signWithEntitlements.push(join(config.directoryInfo.pkgWorking.bin, "tools", arch, "esbuild"));
107
signWithEntitlements.push(join(config.directoryInfo.pkgWorking.bin, "tools", arch, "pandoc"));
108
signWithEntitlements.push(join(config.directoryInfo.pkgWorking.bin, "tools", arch, "typst"));
109
110
const typstGatherPath = join(
111
config.directoryInfo.pkgWorking.bin,
112
"tools",
113
arch,
114
"typst-gather",
115
);
116
if (existsSync(typstGatherPath)) {
117
signWithEntitlements.push(typstGatherPath);
118
}
119
120
const denoDomPath = join(
121
config.directoryInfo.pkgWorking.bin,
122
"tools",
123
"x86_64",
124
"deno_dom",
125
"libplugin.dylib",
126
);
127
if (existsSync(denoDomPath)) {
128
signWithEntitlements.push(denoDomPath);
129
}
130
131
const denoDomaarch64Path = join(
132
config.directoryInfo.pkgWorking.bin,
133
"tools",
134
"aarch64",
135
"deno_dom",
136
"libplugin.dylib",
137
);
138
if (existsSync(denoDomaarch64Path)) {
139
signWithEntitlements.push(denoDomaarch64Path);
140
}
141
});
142
143
144
145
for (const fileToSign of signWithEntitlements) {
146
info(fileToSign);
147
await signCode(applicationDevId, fileToSign, entitlements);
148
}
149
for (const fileToSign of signWithoutEntitlements) {
150
info(fileToSign);
151
await signCode(applicationDevId, fileToSign);
152
}
153
154
info("Done signing Done signing binaries");
155
} else {
156
warning("Missing Application Developer Id, not signing");
157
}
158
159
// Now that runtimes have been signed, create a zip
160
makeTarball(
161
config.directoryInfo.pkgWorking.root,
162
join(config.directoryInfo.out, `quarto-${config.version}-macos.tar.gz`),
163
true,
164
);
165
166
// Installer signature configuration
167
const installerDevId = getEnv("QUARTO_APPLE_INST_DEV_ID", "");
168
const signInstaller = installerDevId.length > 0;
169
const performSign = async (file: string) => {
170
if (signInstaller) {
171
info("Signing file");
172
info(packagePath);
173
174
const targetPath = join(dirname(file), "signing.out");
175
await signPackage(
176
installerDevId,
177
file,
178
targetPath,
179
);
180
181
info("Signing file");
182
info(file);
183
184
info("Cleaning unsigned file");
185
Deno.removeSync(file);
186
Deno.renameSync(targetPath, file);
187
}
188
};
189
190
// Run pkg build
191
const scriptDir = join(config.directoryInfo.pkg, "scripts", "macos", "pkg");
192
const packageArgs = [
193
"--scripts",
194
scriptDir,
195
"--install-location",
196
"/Library/Quarto",
197
];
198
await runCmd(
199
"pkgbuild",
200
[
201
"--root",
202
config.directoryInfo.pkgWorking.root,
203
"--identifier",
204
packageIdentifier,
205
"--version",
206
config.version,
207
...packageArgs,
208
"--ownership",
209
"recommended",
210
"--install-location",
211
"/Applications/quarto",
212
corePackagePath,
213
],
214
);
215
// Maybe sign the package
216
await performSign(corePackagePath);
217
218
// Use productbuild to create an improved install experience
219
const distXmlContents = Deno.readTextFileSync(distXml);
220
const localDistXml = join(dirname(packagePath), "distribution.xml");
221
const replacedContents = distXmlContents.replace("$PATH$", corePackagePath);
222
info(`Local dist file: ${localDistXml}`);
223
Deno.writeTextFileSync(
224
localDistXml,
225
replacedContents,
226
);
227
228
const oldWd = Deno.cwd();
229
Deno.chdir(config.directoryInfo.out);
230
await runCmd(
231
"productbuild",
232
[
233
"--package-path",
234
corePackagePath,
235
"--distribution",
236
localDistXml,
237
packagePath,
238
],
239
);
240
Deno.chdir(oldWd);
241
242
// Remove core file
243
Deno.removeSync(corePackagePath);
244
Deno.removeSync(localDistXml);
245
246
//sign product build output
247
await performSign(packagePath);
248
249
// The application cert developer Id
250
if (signInstaller) {
251
// Submit package for notary
252
const username = getEnv("QUARTO_APPLE_CONNECT_UN", "");
253
const password = getEnv("QUARTO_APPLE_CONNECT_PW", "");
254
const teamId = getEnv("QUARTO_APPLE_CONNECT_TEAMID", "");
255
if (username.length > 0 && password.length > 0) {
256
const requestId = await notarizeAndWait(
257
packagePath,
258
username,
259
password,
260
teamId
261
);
262
263
264
// Staple the notary to the package
265
await stapleNotary(packagePath);
266
267
} else {
268
warning("Missing Connect credentials, not notarizing");
269
}
270
} else {
271
warning("Missing Installer Developer Id, not signing");
272
}
273
}
274
275
// https://deno.com/blog/v1.23#remove-unstable-denosleepsync-api
276
function sleepSync(timeout: number) {
277
const sab = new SharedArrayBuffer(1024);
278
const int32 = new Int32Array(sab);
279
Atomics.wait(int32, 0, 0, timeout);
280
}
281
282
async function signPackage(
283
developerId: string,
284
input: string,
285
output: string,
286
) {
287
await runCmd(
288
"productsign",
289
["--sign", developerId, "--timestamp", input, output],
290
);
291
}
292
293
async function signCode(
294
developerId: string,
295
input: string,
296
entitlements?: string,
297
) {
298
const args = [
299
"-s",
300
developerId,
301
"--timestamp",
302
"--options=runtime",
303
"--force",
304
"--deep",
305
"--verbose=4",
306
];
307
if (entitlements) {
308
args.push("--entitlements");
309
args.push(entitlements);
310
}
311
312
const result = await runCmd(
313
"codesign",
314
[...args, input],
315
);
316
317
info(result.stdout);
318
if (!result.status.success) {
319
error(result.stderr);
320
}
321
322
return result;
323
}
324
325
async function notarizeAndWait(
326
input: string,
327
username: string,
328
password: string,
329
teamId: string
330
) {
331
const result = await runCmd(
332
"xcrun",
333
[
334
"notarytool",
335
"submit",
336
"--apple-id",
337
username,
338
"--password",
339
password,
340
"--team-id",
341
teamId,
342
input,
343
"--wait"
344
],
345
);
346
347
if (result.status.success) {
348
const match = result.stdout.match(/id: (.*)/);
349
if (match) {
350
const id = match[1];
351
return id;
352
} else {
353
throw new Error("Notarization Failed to return an Id:\n" + result.stdout);
354
}
355
} else {
356
throw new Error("Notarization Failed\n" + result.stderr);
357
}
358
}
359
360
async function waitForNotaryStatus(
361
requestId: string,
362
username: string,
363
password: string,
364
) {
365
const starttime = Date.now();
366
367
// 20 minutes
368
const msToWait = 1200000;
369
370
const pollIntervalSeconds = 15;
371
372
let errorCount = 0;
373
let notaryResult = undefined;
374
while (notaryResult == undefined) {
375
const result = await runCmd(
376
"xcrun",
377
[
378
"altool",
379
"--notarization-info",
380
requestId,
381
"--username",
382
username,
383
"--password",
384
password,
385
],
386
);
387
388
const match = result.stdout.match(/Status: (.*)\n/);
389
if (match) {
390
const status = match[1];
391
if (status === "in progress") {
392
// Successful status means reset error counter
393
errorCount = 0;
394
395
// Sleep for 15 seconds between checks
396
await new Promise((resolve) =>
397
setTimeout(resolve, pollIntervalSeconds * 1000)
398
);
399
} else if (status === "success") {
400
notaryResult = "Success";
401
} else {
402
if (errorCount > 5) {
403
error(result.stderr);
404
throw new Error("Failed to Notarize - " + status);
405
}
406
407
//increment error counter
408
errorCount = errorCount + 1;
409
}
410
}
411
if (Date.now() - starttime > msToWait) {
412
throw new Error(
413
`Failed to Notarize - timed out after ${
414
msToWait / 1000
415
} seconds when awaiting notarization`,
416
);
417
}
418
}
419
return notaryResult;
420
}
421
422
async function stapleNotary(input: string) {
423
await runCmd(
424
"xcrun",
425
["stapler", "staple", input],
426
);
427
}
428
429