@swipt/cli 0.1.0-nightly.47 → 0.1.0-nightly.48

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/sideload.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { execFileSync, spawn, spawnSync } from "node:child_process";
1
+ import { execFile, execFileSync, spawn } from "node:child_process";
2
2
  import { X509Certificate, createHash } from "node:crypto";
3
3
  import fs from "node:fs";
4
4
  import { createRequire } from "node:module";
@@ -7,12 +7,23 @@ import path from "node:path";
7
7
  import readline from "node:readline";
8
8
  import { Readable } from "node:stream";
9
9
  import { pipeline } from "node:stream/promises";
10
+ import { promisify } from "node:util";
11
+
12
+ import password from "@inquirer/password";
13
+ import select from "@inquirer/select";
10
14
 
11
15
  import { UsageError, open } from "./client.mjs";
12
- import { apps, coreDevice, devicectl } from "./coredevice.mjs";
13
- import { printScriptError } from "./output.mjs";
16
+ import {
17
+ apps,
18
+ coreDevice,
19
+ devicectl,
20
+ provisioningProfiles,
21
+ } from "./coredevice.mjs";
22
+ import { choose, interactive, printScriptError, step } from "./output.mjs";
14
23
  import { LogLevel } from "./proto/common_pb.js";
15
24
 
25
+ const exec = promisify(execFile);
26
+
16
27
  const bundleId = "io.nekohasekai.swipt";
17
28
  const repository = "swipt-automation/swipt";
18
29
  const teamPattern = /^[A-Z0-9]{10}$/;
@@ -21,6 +32,10 @@ const sideloadedPattern = new RegExp(
21
32
  );
22
33
 
23
34
  const accountsHint = "Xcode › Settings › Accounts";
35
+ const xcodeProfiles = "Library/Developer/Xcode/UserData/Provisioning Profiles";
36
+
37
+ const expiresSoon = ({ creation, expiration }) =>
38
+ expiration - Date.now() < (expiration - creation) / 2;
24
39
 
25
40
  const passcodeTypes = ["pin", "numeric", "alphanumeric"];
26
41
 
@@ -71,22 +86,33 @@ async function github(url, accept, signal) {
71
86
  }
72
87
  if (!response.ok) {
73
88
  let message = `GitHub answered ${response.status} for ${url}`;
74
- if ([401, 403, 404, 429].includes(response.status)) {
89
+ if ([401, 403, 429].includes(response.status)) {
75
90
  message += `; ${process.env.GITHUB_TOKEN ? "GITHUB_TOKEN" : "set GITHUB_TOKEN to a token that"} must read the repository and stay under the rate limit`;
76
91
  }
77
- throw new Error(message);
92
+ const error = new Error(message);
93
+ error.status = response.status;
94
+ throw error;
78
95
  }
79
96
  return response;
80
97
  }
81
98
 
82
99
  async function findRelease(nightly, signal) {
83
- const release = await (
84
- await github(
85
- `https://api.github.com/repos/${repository}/releases/${nightly ? "tags/nightly" : "latest"}`,
86
- "application/vnd.github+json",
87
- signal,
88
- )
89
- ).json();
100
+ const read = async (name) =>
101
+ (
102
+ await github(
103
+ `https://api.github.com/repos/${repository}/releases/${name}`,
104
+ "application/vnd.github+json",
105
+ signal,
106
+ )
107
+ ).json();
108
+ const release = nightly
109
+ ? await read("tags/nightly")
110
+ : await read("latest").catch((error) => {
111
+ if (error.status !== 404) {
112
+ throw error;
113
+ }
114
+ return read("tags/nightly");
115
+ });
90
116
  const asset = (name) => {
91
117
  const found = release.assets.find((it) => it.name === name);
92
118
  if (!found) {
@@ -105,15 +131,31 @@ async function findRelease(nightly, signal) {
105
131
  };
106
132
  }
107
133
 
108
- async function download(url, file, signal) {
134
+ async function download(url, file, signal, update) {
109
135
  const response = await github(url, "application/octet-stream", signal);
110
- await pipeline(Readable.fromWeb(response.body), fs.createWriteStream(file), {
111
- signal,
112
- });
136
+ const total = Number(response.headers.get("content-length"));
137
+ const megabytes = (bytes) => (bytes / 1_000_000).toFixed(1);
138
+ let received = 0;
139
+ await pipeline(
140
+ Readable.fromWeb(response.body),
141
+ async function* (source) {
142
+ for await (const chunk of source) {
143
+ received += chunk.length;
144
+ update(
145
+ total > 0
146
+ ? `${megabytes(received)} / ${megabytes(total)} MB ${Math.floor((received / total) * 100)}%`
147
+ : `${megabytes(received)} MB`,
148
+ );
149
+ yield chunk;
150
+ }
151
+ },
152
+ fs.createWriteStream(file),
153
+ { signal },
154
+ );
113
155
  }
114
156
 
115
- function unpack(ipa, directory) {
116
- execFileSync("unzip", ["-q", ipa, "-d", directory]);
157
+ async function unpack(ipa, directory) {
158
+ await exec("unzip", ["-q", ipa, "-d", directory]);
117
159
  const payload = path.join(directory, "Payload");
118
160
  const app = fs.readdirSync(payload).find((name) => name.endsWith(".app"));
119
161
  if (app === undefined) {
@@ -155,6 +197,7 @@ const pbxproj = (id, team) => `// !$*UTF8*$!
155
197
  CODE_SIGN_STYLE = Automatic;
156
198
  DEVELOPMENT_TEAM = "${team}";
157
199
  GENERATE_INFOPLIST_FILE = YES;
200
+ IPHONEOS_DEPLOYMENT_TARGET = 16.4;
158
201
  PRODUCT_BUNDLE_IDENTIFIER = "${id}";
159
202
  PRODUCT_NAME = Stub;
160
203
  SDKROOT = iphoneos;
@@ -199,9 +242,20 @@ function explain(output, team) {
199
242
  return errors || output.trim();
200
243
  }
201
244
 
202
- function provision(work, id, team, device) {
245
+ function profileField(file, key) {
246
+ return execFileSync("plutil", ["-extract", key, "raw", "-o", "-", "-"], {
247
+ input: execFileSync("security", ["cms", "-D", "-i", file], {
248
+ stdio: ["ignore", "pipe", "ignore"],
249
+ }),
250
+ encoding: "utf8",
251
+ }).trim();
252
+ }
253
+
254
+ async function provision(work, id, team, device, update) {
203
255
  const stub = path.join(work, "Stub");
204
256
  const products = path.join(stub, "Products");
257
+ const app = path.join(products, "Stub.app");
258
+ const profile = path.join(app, "embedded.mobileprovision");
205
259
  fs.mkdirSync(path.join(stub, "Stub.xcodeproj"), { recursive: true });
206
260
  fs.mkdirSync(path.join(stub, "Stub"));
207
261
  fs.writeFileSync(
@@ -212,34 +266,65 @@ function provision(work, id, team, device) {
212
266
  path.join(stub, "Stub.xcodeproj/project.pbxproj"),
213
267
  pbxproj(id, team),
214
268
  );
215
- const result = spawnSync(
216
- "xcodebuild",
217
- [
218
- "build",
219
- "-project",
220
- path.join(stub, "Stub.xcodeproj"),
221
- "-target",
222
- "Stub",
223
- "-configuration",
224
- "Release",
225
- "-destination",
226
- `id=${device.udid}`,
227
- `CONFIGURATION_BUILD_DIR=${products}`,
228
- `OBJROOT=${path.join(stub, "Objects")}`,
229
- "-allowProvisioningUpdates",
230
- "-allowProvisioningDeviceRegistration",
231
- ],
232
- { encoding: "utf8" },
233
- );
234
- if (result.error) {
235
- throw result.error.code === "ENOENT"
236
- ? new Error("xcodebuild is missing; install Xcode")
237
- : result.error;
238
- }
239
- if (result.status !== 0) {
240
- throw new Error(explain(`${result.stdout}${result.stderr}`, team));
269
+ const build = async () => {
270
+ const running = exec(
271
+ "xcodebuild",
272
+ [
273
+ "build",
274
+ "-project",
275
+ path.join(stub, "Stub.xcodeproj"),
276
+ "-scheme",
277
+ "Stub",
278
+ "-configuration",
279
+ "Release",
280
+ "-destination",
281
+ `id=${device.udid}`,
282
+ `CONFIGURATION_BUILD_DIR=${products}`,
283
+ `OBJROOT=${path.join(stub, "Objects")}`,
284
+ "-allowProvisioningUpdates",
285
+ "-allowProvisioningDeviceRegistration",
286
+ ],
287
+ { maxBuffer: Infinity },
288
+ );
289
+ running.child.stdout.on("data", (chunk) => {
290
+ const line = String(chunk)
291
+ .split("\n")
292
+ .findLast((it) => /^[A-Z]\w* /.test(it));
293
+ if (line) {
294
+ update(line.split(" ")[0]);
295
+ }
296
+ });
297
+ try {
298
+ await running;
299
+ } catch (error) {
300
+ if (error.code === "ENOENT") {
301
+ throw new Error("xcodebuild is missing; install Xcode");
302
+ }
303
+ if (typeof error.code !== "number") {
304
+ throw error;
305
+ }
306
+ throw new Error(explain(`${error.stdout}${error.stderr}`, team));
307
+ }
308
+ };
309
+ await build();
310
+ if (
311
+ expiresSoon({
312
+ creation: Date.parse(profileField(profile, "CreationDate")),
313
+ expiration: Date.parse(profileField(profile, "ExpirationDate")),
314
+ })
315
+ ) {
316
+ // Xcode keeps a valid cached profile and creates a new one once it is removed.
317
+ fs.rmSync(
318
+ path.join(
319
+ os.homedir(),
320
+ xcodeProfiles,
321
+ `${profileField(profile, "UUID")}.mobileprovision`,
322
+ ),
323
+ { force: true },
324
+ );
325
+ update("renewing the profile");
326
+ await build();
241
327
  }
242
- const app = path.join(products, "Stub.app");
243
328
  const certificates = path.join(work, "certificate");
244
329
  const stdio = ["ignore", "pipe", "ignore"];
245
330
  execFileSync(
@@ -260,11 +345,11 @@ function provision(work, id, team, device) {
260
345
  .digest("hex")
261
346
  .toUpperCase(),
262
347
  entitlements,
263
- profile: path.join(app, "embedded.mobileprovision"),
348
+ profile,
264
349
  };
265
350
  }
266
351
 
267
- function resign(app, id, signing) {
352
+ async function resign(app, id, signing, update) {
268
353
  const info = path.join(app, "Info.plist");
269
354
  execFileSync("plutil", [
270
355
  "-replace",
@@ -306,10 +391,12 @@ function resign(app, id, signing) {
306
391
  const frameworks = path.join(app, "Frameworks");
307
392
  if (fs.existsSync(frameworks)) {
308
393
  for (const name of fs.readdirSync(frameworks)) {
309
- execFileSync("codesign", [...codesign, path.join(frameworks, name)]);
394
+ update(name);
395
+ await exec("codesign", [...codesign, path.join(frameworks, name)]);
310
396
  }
311
397
  }
312
- execFileSync("codesign", [
398
+ update(path.basename(app));
399
+ await exec("codesign", [
313
400
  ...codesign,
314
401
  "--entitlements",
315
402
  signing.entitlements,
@@ -350,13 +437,55 @@ function xcodeTeams() {
350
437
  return [...teams].map(([id, name]) => ({ id, name }));
351
438
  }
352
439
 
440
+ function xcodeSignedIn() {
441
+ try {
442
+ const lists = JSON.parse(
443
+ execFileSync(
444
+ "plutil",
445
+ [
446
+ "-extract",
447
+ "DVTDeveloperAccountManagerAppleIDLists",
448
+ "json",
449
+ "-o",
450
+ "-",
451
+ "-",
452
+ ],
453
+ {
454
+ input: execFileSync("defaults", [
455
+ "export",
456
+ "com.apple.dt.Xcode",
457
+ "-",
458
+ ]),
459
+ stdio: ["pipe", "pipe", "ignore"],
460
+ encoding: "utf8",
461
+ },
462
+ ),
463
+ );
464
+ return Object.values(lists).some((accounts) => accounts.length > 0);
465
+ } catch {
466
+ return false;
467
+ }
468
+ }
469
+
353
470
  const teamList = (teams) =>
354
471
  teams
355
472
  .map((team) => ` ${team.id}${team.name ? ` ${team.name}` : ""}`)
356
473
  .join("\n");
357
474
 
358
- function locate(options, device, fresh = false) {
359
- const found = apps(device.udid)
475
+ const teamChoice = ({ id, name }) => ({
476
+ name: name ? `${name} ${id}` : id,
477
+ short: name ?? id,
478
+ value: id,
479
+ });
480
+
481
+ async function locate(context, device, fresh = false) {
482
+ const { options } = context;
483
+ const listed = await step(
484
+ `reading apps on ${device.name}`,
485
+ () => apps(device.udid),
486
+ { transient: true },
487
+ );
488
+ const found = listed
360
489
  .map((app) => ({
361
490
  ...app,
362
491
  team: sideloadedPattern.exec(app.bundleIdentifier)?.[1],
@@ -374,98 +503,247 @@ function locate(options, device, fresh = false) {
374
503
  };
375
504
  }
376
505
  if (found.length > 1) {
377
- throw new Error(
378
- `Swipt is sideloaded on ${device.name} by more than one team; choose one with --team ID:\n${teamList(found.map((app) => ({ id: app.team })))}`,
506
+ const known = xcodeTeams();
507
+ const teams = found.map((app) => ({
508
+ id: app.team,
509
+ name: known.find((team) => team.id === app.team)?.name,
510
+ }));
511
+ const team = await choose(
512
+ context,
513
+ "Choose a team",
514
+ teams.map(teamChoice),
515
+ new Error(
516
+ `Swipt is sideloaded on ${device.name} by more than one team; choose one with --team ID:\n${teamList(teams)}`,
517
+ ),
379
518
  );
519
+ return { team, current: found.find((app) => app.team === team) };
380
520
  }
381
521
  if (found.length === 0 && fresh) {
382
522
  const teams = xcodeTeams();
383
- if (teams.length !== 1) {
523
+ if (teams.length === 0) {
384
524
  throw new Error(
385
- teams.length === 0
386
- ? `Xcode has no development certificate; sign in at ${accountsHint}, or choose a team with --team ID`
387
- : `choose a team with --team ID:\n${teamList(teams)}`,
525
+ xcodeSignedIn()
526
+ ? `Xcode has no development certificate; add one at ${accountsHint} › Manage Certificates, or choose a team with --team ID`
527
+ : `Xcode has no account; sign in at ${accountsHint}, or choose a team with --team ID`,
388
528
  );
389
529
  }
390
- return { team: teams[0].id, current: undefined };
530
+ const team =
531
+ teams.length === 1
532
+ ? teams[0].id
533
+ : await choose(
534
+ context,
535
+ "Choose a team",
536
+ teams.map(teamChoice),
537
+ new Error(`choose a team with --team ID:\n${teamList(teams)}`),
538
+ );
539
+ return { team, current: undefined };
391
540
  }
392
541
  return { team: found[0]?.team, current: found[0] };
393
542
  }
394
543
 
395
544
  const buildText = (app) => `Swipt ${app.version} (${app.bundleVersion})`;
396
545
 
546
+ async function signingPeriod(device, id, team) {
547
+ const names = [id, "*"].map((it) => `iOS Team Provisioning Profile: ${it}`);
548
+ const profiles = await step(
549
+ `reading the signing on ${device.name}`,
550
+ () => provisioningProfiles(device.udid),
551
+ { transient: true },
552
+ );
553
+ return profiles
554
+ .filter(
555
+ (profile) =>
556
+ profile.teamIdentifier === team && names.includes(profile.name),
557
+ )
558
+ .map((profile) => ({
559
+ creation: Date.parse(profile.creationDate),
560
+ expiration: Date.parse(profile.expirationDate),
561
+ }))
562
+ .reduce((latest, it) => (it.expiration > latest.expiration ? it : latest), {
563
+ creation: 0,
564
+ expiration: 0,
565
+ });
566
+ }
567
+
568
+ const dateText = (time) => new Date(time).toLocaleString();
569
+
570
+ async function askPasscode(context) {
571
+ const type = await select(
572
+ {
573
+ message:
574
+ "Set up auto-unlock? Swipt can then unlock this device by itself",
575
+ choices: [
576
+ { name: "4 or 6 digit passcode", short: "4 or 6 digits", value: "pin" },
577
+ { name: "Custom numeric passcode", short: "numeric", value: "numeric" },
578
+ {
579
+ name: "Custom alphanumeric passcode",
580
+ short: "alphanumeric",
581
+ value: "alphanumeric",
582
+ },
583
+ { name: "No Thanks", value: null },
584
+ ],
585
+ },
586
+ { output: process.stderr, signal: context.signal },
587
+ );
588
+ if (type === null) {
589
+ return undefined;
590
+ }
591
+ const options = (passcode) => ({ passcode, "passcode-type": type });
592
+ return passcodeOf(
593
+ options(
594
+ await password(
595
+ {
596
+ message: "Device passcode",
597
+ validate(passcode) {
598
+ try {
599
+ passcodeOf(options(passcode));
600
+ return passcode !== "" || "enter the passcode";
601
+ } catch (error) {
602
+ return error.message;
603
+ }
604
+ },
605
+ },
606
+ { output: process.stderr, signal: context.signal },
607
+ ),
608
+ ),
609
+ );
610
+ }
611
+
397
612
  async function deploy(context, onlyNewer) {
398
- const device = coreDevice(context.options.device, { required: true });
399
- const { team, current } = locate(context.options, device, !onlyNewer);
613
+ const device = await coreDevice(context);
614
+ const { team, current } = await locate(context, device, !onlyNewer);
400
615
  if (current === undefined && onlyNewer) {
401
616
  throw new Error(
402
617
  `Swipt is not sideloaded on ${device.name}${team === undefined ? "" : ` by team ${team}`}; install it with "swipt sideload install"`,
403
618
  );
404
619
  }
405
620
  const passcode =
406
- current === undefined ? passcodeOf(context.options) : undefined;
407
- const release = await findRelease(context.options.nightly, context.signal);
408
- if (current !== undefined) {
621
+ current === undefined &&
622
+ interactive(context) &&
623
+ context.options.passcode === undefined &&
624
+ context.options["passcode-type"] === undefined &&
625
+ !context.options["no-setup"]
626
+ ? await askPasscode(context)
627
+ : passcodeOf(context.options);
628
+ const release = await step(
629
+ "checking the latest release",
630
+ () => findRelease(context.options.nightly, context.signal),
631
+ { transient: true },
632
+ );
633
+ const id = `${bundleId}.${team}`;
634
+ if (onlyNewer) {
409
635
  const installed = Number(current.bundleVersion);
410
- if (installed === release.build) {
636
+ const signing = await signingPeriod(device, id, team);
637
+ if (installed >= release.build && !expiresSoon(signing)) {
411
638
  console.log(
412
- `${buildText(current)} on ${device.name} is already ${release.tag}`,
639
+ `${buildText(current)} on ${device.name} is ${installed === release.build ? `already ${release.tag}` : `newer than ${release.tag} (${release.build})`}; its signing expires ${dateText(signing.expiration)}`,
413
640
  );
641
+ if (
642
+ !context.options["no-setup"] &&
643
+ !context.options["no-setup-pair"] &&
644
+ (await pair(device, id))
645
+ ) {
646
+ await openConfigured(context, device, id, passcode);
647
+ }
414
648
  return;
415
649
  }
416
- if (onlyNewer && installed > release.build) {
417
- console.log(
418
- `${buildText(current)} on ${device.name} is newer than ${release.tag} (${release.build})`,
650
+ if (installed > release.build) {
651
+ throw new Error(
652
+ `${buildText(current)} on ${device.name} needs new signing, but ${release.tag} (${release.build}) is older; use --nightly, or downgrade with "swipt sideload install"`,
419
653
  );
420
- return;
421
654
  }
422
655
  }
423
- const id = `${bundleId}.${team}`;
424
656
  const work = fs.mkdtempSync(path.join(os.tmpdir(), "swipt-sideload-"));
425
657
  try {
426
- console.error(
427
- `downloading Swipt ${release.version} (${release.build}) from ${release.tag}`,
658
+ const signing = await step(
659
+ `signing ${id} with team ${team} through Xcode`,
660
+ (update) => provision(work, id, team, device, update),
428
661
  );
429
662
  const ipa = path.join(work, "Swipt.ipa");
430
- await download(release.ipa, ipa, context.signal);
431
- const app = unpack(ipa, work);
432
- console.error(`signing ${id} with team ${team} through Xcode`);
433
- resign(app, id, provision(work, id, team, device));
434
- console.error(`installing on ${device.name}`);
435
- devicectl(["device", "install", "app", "--device", device.udid, app]);
436
- if (current === undefined) {
437
- console.error(`pairing with ${device.name}`);
438
- pair(device, id, work);
439
- if (passcode !== undefined) {
440
- setUpAutoUnlock(device, id, work, passcode);
441
- }
663
+ await step(
664
+ `downloading Swipt ${release.version} (${release.build}) from ${release.tag}`,
665
+ (update) => download(release.ipa, ipa, context.signal, update),
666
+ );
667
+ const app = await step("re-signing Swipt", async (update) => {
668
+ const unpacked = await unpack(ipa, work);
669
+ await resign(unpacked, id, signing, update);
670
+ return unpacked;
671
+ });
672
+ await step(`installing on ${device.name}`, () =>
673
+ devicectl(["device", "install", "app", "--device", device.udid, app]),
674
+ );
675
+ if (current === undefined && passcode !== undefined) {
676
+ await step("setting up auto-unlock", () =>
677
+ setUpAutoUnlock(device, id, work, passcode),
678
+ );
442
679
  }
443
680
  } finally {
444
681
  fs.rmSync(work, { recursive: true, force: true });
445
682
  }
446
- const result = locate({ team }, device).current;
683
+ const result = (await locate({ options: { team } }, device)).current;
447
684
  if (result === undefined || Number(result.bundleVersion) !== release.build) {
448
685
  throw new Error(
449
686
  `${device.name} ${result === undefined ? `does not list ${id}` : `has ${buildText(result)}`} after the install`,
450
687
  );
451
688
  }
689
+ const action =
690
+ current === undefined
691
+ ? "installed"
692
+ : Number(current.bundleVersion) === release.build
693
+ ? "re-signed"
694
+ : "updated";
452
695
  console.log(
453
- `${buildText(result)} is ${current === undefined ? "installed" : "updated"} on ${device.name}`,
696
+ `${buildText(result)} is ${action} on ${device.name}; its signing expires ${dateText((await signingPeriod(device, id, team)).expiration)}`,
454
697
  );
698
+ if (context.options["no-setup"]) {
699
+ await launch(device, id);
700
+ return;
701
+ }
702
+ if (!context.options["no-setup-pair"]) {
703
+ await pair(device, id);
704
+ }
705
+ await openConfigured(context, device, id, passcode);
706
+ }
707
+
708
+ const launchCommand = (device, id, args = []) => [
709
+ "device",
710
+ "process",
711
+ "launch",
712
+ "--terminate-existing",
713
+ "--device",
714
+ device.udid,
715
+ id,
716
+ ...(args.length > 0 ? ["--", ...args] : []),
717
+ ];
718
+
719
+ const publishArgument = "--publish-local-network";
720
+ const setupArgument = "--setup";
721
+
722
+ async function openConfigured(context, device, id, passcode) {
455
723
  try {
456
- devicectl([
457
- "device",
458
- "process",
459
- "launch",
460
- "--terminate-existing",
461
- "--device",
462
- device.udid,
724
+ await trustAndOpen(context, device, id, passcode);
725
+ } catch (error) {
726
+ if (error.message !== serverHint) {
727
+ throw error;
728
+ }
729
+ console.error(`${serverHint}; its developer is not trusted automatically`);
730
+ await launch(
731
+ device,
463
732
  id,
464
- ]);
733
+ context.options["no-setup-api"] ? [] : [publishArgument],
734
+ );
735
+ }
736
+ }
737
+
738
+ async function launch(device, id, args) {
739
+ try {
740
+ await step(`opening Swipt on ${device.name}`, () =>
741
+ devicectl(launchCommand(device, id, args)),
742
+ );
465
743
  } catch (error) {
466
744
  if (/trusted by the user/.test(error.message)) {
467
745
  console.log(
468
- `it opens once the team's developer app is trusted in Settings › General › VPN & Device Management on ${device.name}`,
746
+ `its developer is not trusted yet; run "swipt sideload install -d ${device.udid} --team ${id.slice(bundleId.length + 1)}" to trust it and open Swipt automatically, or trust it in Settings › General › VPN & Device Management on ${device.name}`,
469
747
  );
470
748
  } else if (/BSErrorCodeDescription = Locked/.test(error.message)) {
471
749
  console.log(`it opens once ${device.name} is unlocked`);
@@ -480,9 +758,14 @@ export const install = (context) => deploy(context, false);
480
758
  export const update = (context) => deploy(context, true);
481
759
 
482
760
  const serverHint =
483
- "swipt-server is missing; install @swipt/cli with its optional dependencies";
761
+ 'swipt-server is missing; install it with "npm install -g @swipt/cli --include=optional"';
484
762
 
485
- function serverPath() {
763
+ async function serverPath() {
764
+ const root = path.dirname(import.meta.dirname);
765
+ if (fs.existsSync(path.join(root, "cmd/swipt-server"))) {
766
+ await exec("make", ["server"], { cwd: root });
767
+ return path.join(root, "build/swipt-server");
768
+ }
486
769
  let packaged;
487
770
  try {
488
771
  packaged = path.join(
@@ -508,7 +791,7 @@ const schema = [
508
791
  "INSERT INTO grdb_migrations VALUES ('0.1.0')",
509
792
  ];
510
793
 
511
- function setUpAutoUnlock(device, id, work, passcode) {
794
+ async function setUpAutoUnlock(device, id, work, passcode) {
512
795
  const database = path.join(work, "Swipt.sqlite");
513
796
  const preferences = {
514
797
  serverAutoUnlocks: true,
@@ -528,7 +811,7 @@ function setUpAutoUnlock(device, id, work, passcode) {
528
811
  .map((statement) => `${statement};\n`)
529
812
  .join(""),
530
813
  });
531
- devicectl([
814
+ await devicectl([
532
815
  "device",
533
816
  "copy",
534
817
  "to",
@@ -545,46 +828,94 @@ function setUpAutoUnlock(device, id, work, passcode) {
545
828
  ]);
546
829
  }
547
830
 
548
- function pair(device, id, work) {
549
- const record = path.join(work, "PairingRecord.plist");
550
- const result = spawnSync(
551
- serverPath(),
552
- ["pair", "-d", device.udid, "-o", record],
553
- { encoding: "utf8", stdio: ["ignore", "ignore", "pipe"] },
554
- );
555
- if (result.error?.code === "ENOENT") {
556
- console.error(`${serverHint}; Swipt pairs in Settings instead`);
557
- return;
831
+ const pairingDirectory = "Library/Application Support";
832
+ const pairingFile = "PairingRecord.plist";
833
+
834
+ async function hasPairing(device, id) {
835
+ try {
836
+ const { result } = await devicectl(
837
+ [
838
+ "device",
839
+ "info",
840
+ "files",
841
+ "--device",
842
+ device.udid,
843
+ "--domain-type",
844
+ "appDataContainer",
845
+ "--domain-identifier",
846
+ id,
847
+ "--subdirectory",
848
+ pairingDirectory,
849
+ ],
850
+ { parse: true },
851
+ );
852
+ return result.files.some((file) => file.name === pairingFile);
853
+ } catch {
854
+ return false;
558
855
  }
559
- if (result.error) {
560
- throw result.error;
856
+ }
857
+
858
+ async function pair(device, id) {
859
+ const paired = await step(
860
+ `checking the pairing of ${device.name}`,
861
+ () => hasPairing(device, id),
862
+ { transient: true },
863
+ );
864
+ if (paired) {
865
+ return false;
561
866
  }
562
- if (result.status !== 0) {
563
- console.error(
564
- `pairing failed, so Swipt pairs in Settings instead: ${result.stderr.trim()}`,
565
- );
566
- return;
867
+ const work = fs.mkdtempSync(path.join(os.tmpdir(), "swipt-pairing-"));
868
+ let warning;
869
+ try {
870
+ await step(`pairing with ${device.name}`, async () => {
871
+ const record = path.join(work, pairingFile);
872
+ try {
873
+ await exec(await serverPath(), [
874
+ "pair",
875
+ "-d",
876
+ device.udid,
877
+ "-o",
878
+ record,
879
+ ]);
880
+ } catch (error) {
881
+ if (typeof error.code === "number") {
882
+ warning = `pairing failed, so Swipt pairs in Settings instead: ${error.stderr.trim()}`;
883
+ } else if (error.code === "ENOENT") {
884
+ warning = `${serverHint}; Swipt pairs in Settings instead`;
885
+ }
886
+ throw error;
887
+ }
888
+ await devicectl([
889
+ "device",
890
+ "copy",
891
+ "to",
892
+ "--device",
893
+ device.udid,
894
+ "--source",
895
+ record,
896
+ "--destination",
897
+ `${pairingDirectory}/${pairingFile}`,
898
+ "--domain-type",
899
+ "appDataContainer",
900
+ "--domain-identifier",
901
+ id,
902
+ ]);
903
+ });
904
+ } catch (error) {
905
+ if (warning === undefined) {
906
+ throw error;
907
+ }
908
+ console.error(warning);
909
+ return false;
910
+ } finally {
911
+ fs.rmSync(work, { recursive: true, force: true });
567
912
  }
568
- devicectl([
569
- "device",
570
- "copy",
571
- "to",
572
- "--device",
573
- device.udid,
574
- "--source",
575
- record,
576
- "--destination",
577
- "Library/Application Support/PairingRecord.plist",
578
- "--domain-type",
579
- "appDataContainer",
580
- "--domain-identifier",
581
- id,
582
- ]);
913
+ return true;
583
914
  }
584
915
 
585
- function serve(udid, passcode, signal) {
916
+ async function serve(udid, passcode, signal) {
586
917
  const server = spawn(
587
- serverPath(),
918
+ await serverPath(),
588
919
  [
589
920
  "-d",
590
921
  udid,
@@ -631,66 +962,118 @@ function serve(udid, passcode, signal) {
631
962
  };
632
963
  }
633
964
 
634
- export async function start(context) {
635
- const passcode = passcodeOf(context.options);
636
- const device = coreDevice(context.options.device, { required: true });
637
- const { team, current } = locate(context.options, device);
638
- if (current === undefined) {
639
- throw new Error(
640
- `Swipt is not sideloaded on ${device.name}${team === undefined ? "" : ` by team ${team}`}`,
641
- );
642
- }
643
- const server = serve(device.udid, passcode, context.signal);
965
+ async function trustAndOpen(context, device, id, passcode) {
966
+ const lines = [];
967
+ let failure;
644
968
  try {
645
- const client = open({ url: await server.url, token: "" });
646
- let failure;
647
- for await (const response of client.repl.execute(
648
- {
649
- code: fs.readFileSync(
650
- new URL("sideload/trust.js", import.meta.url),
651
- "utf8",
652
- ),
653
- args: [current.bundleIdentifier],
654
- program: true,
655
- },
656
- { signal: context.signal },
657
- )) {
658
- switch (response.part.case) {
659
- case "console": {
660
- const output = response.part.value;
661
- (output.level === LogLevel.ERROR || output.level === LogLevel.WARN
662
- ? console.error
663
- : console.log)(output.text);
664
- break;
969
+ await step(`opening Swipt on ${device.name}`, async (update) => {
970
+ update("starting swipt-server");
971
+ const waiting = passcode === undefined && process.stderr.isTTY;
972
+ let server;
973
+ let url;
974
+ while (url === undefined) {
975
+ server = await serve(device.udid, passcode, context.signal);
976
+ try {
977
+ url = await server.url;
978
+ } catch (error) {
979
+ if (!/is connected to this Mac/.test(error.message)) {
980
+ throw error;
981
+ }
982
+ if (!waiting) {
983
+ throw new Error(
984
+ `${device.name} is not reachable; unlock it or pass --passcode, then run this again`,
985
+ );
986
+ }
987
+ update("unlock the device to continue");
665
988
  }
666
- case "error":
667
- failure = response.part.value;
668
- break;
669
989
  }
670
- }
671
- if (failure) {
672
- throw printScriptError(failure);
990
+ try {
991
+ const client = open({ url, token: "" });
992
+ const run = async (args) => {
993
+ for await (const response of client.repl.execute(
994
+ {
995
+ code: fs.readFileSync(
996
+ new URL("sideload/trust.js", import.meta.url),
997
+ "utf8",
998
+ ),
999
+ args,
1000
+ program: true,
1001
+ },
1002
+ { signal: context.signal },
1003
+ )) {
1004
+ switch (response.part.case) {
1005
+ case "console": {
1006
+ const output = response.part.value;
1007
+ if (output.level === LogLevel.DEBUG) {
1008
+ update(output.text);
1009
+ } else {
1010
+ lines.push(output);
1011
+ }
1012
+ break;
1013
+ }
1014
+ case "error":
1015
+ failure = response.part.value;
1016
+ break;
1017
+ }
1018
+ }
1019
+ if (failure) {
1020
+ throw new Error(failure.message);
1021
+ }
1022
+ };
1023
+ const runArguments = (mode) => [id, mode, waiting ? "wait" : ""];
1024
+ await run(runArguments("trust"));
1025
+ update("opening Swipt");
1026
+ await devicectl(
1027
+ launchCommand(device, id, [
1028
+ setupArgument,
1029
+ ...(context.options["no-setup-api"] ? [] : [publishArgument]),
1030
+ ]),
1031
+ );
1032
+ await run(runArguments("prompts"));
1033
+ } finally {
1034
+ await server.stop();
1035
+ }
1036
+ });
1037
+ } catch (error) {
1038
+ if (failure === undefined) {
1039
+ throw error;
673
1040
  }
674
1041
  } finally {
675
- await server.stop();
1042
+ for (const output of lines) {
1043
+ (output.level === LogLevel.ERROR || output.level === LogLevel.WARN
1044
+ ? console.error
1045
+ : console.log)(output.text);
1046
+ }
1047
+ }
1048
+ if (failure) {
1049
+ if (/\bdevice is locked\b/.test(failure.message)) {
1050
+ throw new Error(
1051
+ passcode === undefined
1052
+ ? `${device.name} is locked; unlock it or pass --passcode, then run this again`
1053
+ : `${device.name} did not unlock; check --passcode and --passcode-type, then run this again`,
1054
+ );
1055
+ }
1056
+ throw printScriptError(failure);
676
1057
  }
677
1058
  }
678
1059
 
679
- export function uninstall(context) {
680
- const device = coreDevice(context.options.device, { required: true });
681
- const { team, current } = locate(context.options, device);
1060
+ export async function uninstall(context) {
1061
+ const device = await coreDevice(context);
1062
+ const { team, current } = await locate(context, device);
682
1063
  if (current === undefined) {
683
1064
  throw new Error(
684
1065
  `Swipt is not sideloaded on ${device.name}${team === undefined ? "" : ` by team ${team}`}`,
685
1066
  );
686
1067
  }
687
- devicectl([
688
- "device",
689
- "uninstall",
690
- "app",
691
- "--device",
692
- device.udid,
693
- current.bundleIdentifier,
694
- ]);
1068
+ await step(`removing Swipt from ${device.name}`, () =>
1069
+ devicectl([
1070
+ "device",
1071
+ "uninstall",
1072
+ "app",
1073
+ "--device",
1074
+ device.udid,
1075
+ current.bundleIdentifier,
1076
+ ]),
1077
+ );
695
1078
  console.log(`removed ${buildText(current)} from ${device.name}`);
696
1079
  }