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

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,81 @@ 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
+ const installHint = '; install it with "swipt sideload install"';
482
+
483
+ async function sideloadedDevices(devices, hint = "") {
484
+ const sideloaded = await step(
485
+ "looking for Swipt on the devices",
486
+ () =>
487
+ Promise.all(
488
+ devices.map((device) =>
489
+ apps(device.udid).then(
490
+ (listed) =>
491
+ listed.some((app) =>
492
+ sideloadedPattern.test(app.bundleIdentifier),
493
+ ),
494
+ () => true,
495
+ ),
496
+ ),
497
+ ),
498
+ { transient: true },
499
+ );
500
+ const found = devices.filter((_, index) => sideloaded[index]);
501
+ if (found.length === 0) {
502
+ throw new Error(`Swipt is not sideloaded on any online iOS device${hint}`);
503
+ }
504
+ return found;
505
+ }
506
+
507
+ async function locate(context, device, fresh = false) {
508
+ const { options } = context;
509
+ const listed = await step(
510
+ `reading apps on ${device.name}`,
511
+ () => apps(device.udid),
512
+ { transient: true },
513
+ );
514
+ const found = listed
360
515
  .map((app) => ({
361
516
  ...app,
362
517
  team: sideloadedPattern.exec(app.bundleIdentifier)?.[1],
@@ -374,98 +529,252 @@ function locate(options, device, fresh = false) {
374
529
  };
375
530
  }
376
531
  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 })))}`,
532
+ const known = xcodeTeams();
533
+ const teams = found.map((app) => ({
534
+ id: app.team,
535
+ name: known.find((team) => team.id === app.team)?.name,
536
+ }));
537
+ const team = await choose(
538
+ context,
539
+ "Choose a team",
540
+ teams.map(teamChoice),
541
+ new Error(
542
+ `Swipt is sideloaded on ${device.name} by more than one team; choose one with --team ID:\n${teamList(teams)}`,
543
+ ),
379
544
  );
545
+ return { team, current: found.find((app) => app.team === team) };
380
546
  }
381
547
  if (found.length === 0 && fresh) {
382
548
  const teams = xcodeTeams();
383
- if (teams.length !== 1) {
549
+ if (teams.length === 0) {
384
550
  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)}`,
551
+ xcodeSignedIn()
552
+ ? `Xcode has no development certificate; add one at ${accountsHint} › Manage Certificates, or choose a team with --team ID`
553
+ : `Xcode has no account; sign in at ${accountsHint}, or choose a team with --team ID`,
388
554
  );
389
555
  }
390
- return { team: teams[0].id, current: undefined };
556
+ const team =
557
+ teams.length === 1
558
+ ? teams[0].id
559
+ : await choose(
560
+ context,
561
+ "Choose a team",
562
+ teams.map(teamChoice),
563
+ new Error(`choose a team with --team ID:\n${teamList(teams)}`),
564
+ );
565
+ return { team, current: undefined };
391
566
  }
392
567
  return { team: found[0]?.team, current: found[0] };
393
568
  }
394
569
 
395
570
  const buildText = (app) => `Swipt ${app.version} (${app.bundleVersion})`;
396
571
 
572
+ async function signingPeriod(device, id, team) {
573
+ const names = [id, "*"].map((it) => `iOS Team Provisioning Profile: ${it}`);
574
+ const profiles = await step(
575
+ `reading the signing on ${device.name}`,
576
+ () => provisioningProfiles(device.udid),
577
+ { transient: true },
578
+ );
579
+ return profiles
580
+ .filter(
581
+ (profile) =>
582
+ profile.teamIdentifier === team && names.includes(profile.name),
583
+ )
584
+ .map((profile) => ({
585
+ creation: Date.parse(profile.creationDate),
586
+ expiration: Date.parse(profile.expirationDate),
587
+ }))
588
+ .reduce((latest, it) => (it.expiration > latest.expiration ? it : latest), {
589
+ creation: 0,
590
+ expiration: 0,
591
+ });
592
+ }
593
+
594
+ const dateText = (time) => new Date(time).toLocaleString();
595
+
596
+ async function askPasscode(context) {
597
+ const type = await select(
598
+ {
599
+ message:
600
+ "Set up auto-unlock? Swipt can then unlock this device by itself",
601
+ choices: [
602
+ { name: "4 or 6 digit passcode", short: "4 or 6 digits", value: "pin" },
603
+ { name: "Custom numeric passcode", short: "numeric", value: "numeric" },
604
+ {
605
+ name: "Custom alphanumeric passcode",
606
+ short: "alphanumeric",
607
+ value: "alphanumeric",
608
+ },
609
+ { name: "No Thanks", value: null },
610
+ ],
611
+ },
612
+ { output: process.stderr, signal: context.signal },
613
+ );
614
+ if (type === null) {
615
+ return undefined;
616
+ }
617
+ const options = (passcode) => ({ passcode, "passcode-type": type });
618
+ return passcodeOf(
619
+ options(
620
+ await password(
621
+ {
622
+ message: "Device passcode",
623
+ validate(passcode) {
624
+ try {
625
+ passcodeOf(options(passcode));
626
+ return passcode !== "" || "enter the passcode";
627
+ } catch (error) {
628
+ return error.message;
629
+ }
630
+ },
631
+ },
632
+ { output: process.stderr, signal: context.signal },
633
+ ),
634
+ ),
635
+ );
636
+ }
637
+
397
638
  async function deploy(context, onlyNewer) {
398
- const device = coreDevice(context.options.device, { required: true });
399
- const { team, current } = locate(context.options, device, !onlyNewer);
639
+ const device = await coreDevice(
640
+ context,
641
+ onlyNewer
642
+ ? { filter: (devices) => sideloadedDevices(devices, installHint) }
643
+ : undefined,
644
+ );
645
+ const { team, current } = await locate(context, device, !onlyNewer);
400
646
  if (current === undefined && onlyNewer) {
401
647
  throw new Error(
402
- `Swipt is not sideloaded on ${device.name}${team === undefined ? "" : ` by team ${team}`}; install it with "swipt sideload install"`,
648
+ `Swipt is not sideloaded on ${device.name}${team === undefined ? "" : ` by team ${team}`}${installHint}`,
403
649
  );
404
650
  }
405
651
  const passcode =
406
- current === undefined ? passcodeOf(context.options) : undefined;
407
- const release = await findRelease(context.options.nightly, context.signal);
408
- if (current !== undefined) {
652
+ current === undefined &&
653
+ interactive(context) &&
654
+ context.options.passcode === undefined &&
655
+ context.options["passcode-type"] === undefined &&
656
+ !context.options["no-setup"]
657
+ ? await askPasscode(context)
658
+ : passcodeOf(context.options);
659
+ const release = await step(
660
+ "checking the latest release",
661
+ () => findRelease(context.options.nightly, context.signal),
662
+ { transient: true },
663
+ );
664
+ const id = `${bundleId}.${team}`;
665
+ if (onlyNewer) {
409
666
  const installed = Number(current.bundleVersion);
410
- if (installed === release.build) {
667
+ const signing = await signingPeriod(device, id, team);
668
+ if (installed >= release.build && !expiresSoon(signing)) {
411
669
  console.log(
412
- `${buildText(current)} on ${device.name} is already ${release.tag}`,
670
+ `${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
671
  );
672
+ if (
673
+ !context.options["no-setup"] &&
674
+ !context.options["no-setup-pair"] &&
675
+ (await pair(device, id))
676
+ ) {
677
+ await openConfigured(context, device, id, passcode);
678
+ }
414
679
  return;
415
680
  }
416
- if (onlyNewer && installed > release.build) {
417
- console.log(
418
- `${buildText(current)} on ${device.name} is newer than ${release.tag} (${release.build})`,
681
+ if (installed > release.build) {
682
+ throw new Error(
683
+ `${buildText(current)} on ${device.name} needs new signing, but ${release.tag} (${release.build}) is older; use --nightly, or downgrade with "swipt sideload install"`,
419
684
  );
420
- return;
421
685
  }
422
686
  }
423
- const id = `${bundleId}.${team}`;
424
687
  const work = fs.mkdtempSync(path.join(os.tmpdir(), "swipt-sideload-"));
425
688
  try {
426
- console.error(
427
- `downloading Swipt ${release.version} (${release.build}) from ${release.tag}`,
689
+ const signing = await step(
690
+ `signing ${id} with team ${team} through Xcode`,
691
+ (update) => provision(work, id, team, device, update),
428
692
  );
429
693
  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
- }
694
+ await step(
695
+ `downloading Swipt ${release.version} (${release.build}) from ${release.tag}`,
696
+ (update) => download(release.ipa, ipa, context.signal, update),
697
+ );
698
+ const app = await step("re-signing Swipt", async (update) => {
699
+ const unpacked = await unpack(ipa, work);
700
+ await resign(unpacked, id, signing, update);
701
+ return unpacked;
702
+ });
703
+ await step(`installing on ${device.name}`, () =>
704
+ devicectl(["device", "install", "app", "--device", device.udid, app]),
705
+ );
706
+ if (current === undefined && passcode !== undefined) {
707
+ await step("setting up auto-unlock", () =>
708
+ setUpAutoUnlock(device, id, work, passcode),
709
+ );
442
710
  }
443
711
  } finally {
444
712
  fs.rmSync(work, { recursive: true, force: true });
445
713
  }
446
- const result = locate({ team }, device).current;
714
+ const result = (await locate({ options: { team } }, device)).current;
447
715
  if (result === undefined || Number(result.bundleVersion) !== release.build) {
448
716
  throw new Error(
449
717
  `${device.name} ${result === undefined ? `does not list ${id}` : `has ${buildText(result)}`} after the install`,
450
718
  );
451
719
  }
720
+ const action =
721
+ current === undefined
722
+ ? "installed"
723
+ : Number(current.bundleVersion) === release.build
724
+ ? "re-signed"
725
+ : "updated";
452
726
  console.log(
453
- `${buildText(result)} is ${current === undefined ? "installed" : "updated"} on ${device.name}`,
727
+ `${buildText(result)} is ${action} on ${device.name}; its signing expires ${dateText((await signingPeriod(device, id, team)).expiration)}`,
454
728
  );
729
+ if (context.options["no-setup"]) {
730
+ await launch(device, id);
731
+ return;
732
+ }
733
+ if (!context.options["no-setup-pair"]) {
734
+ await pair(device, id);
735
+ }
736
+ await openConfigured(context, device, id, passcode);
737
+ }
738
+
739
+ const launchCommand = (device, id, args = []) => [
740
+ "device",
741
+ "process",
742
+ "launch",
743
+ "--terminate-existing",
744
+ "--device",
745
+ device.udid,
746
+ id,
747
+ ...(args.length > 0 ? ["--", ...args] : []),
748
+ ];
749
+
750
+ const publishArgument = "--publish-local-network";
751
+ const setupArgument = "--setup";
752
+
753
+ async function openConfigured(context, device, id, passcode) {
455
754
  try {
456
- devicectl([
457
- "device",
458
- "process",
459
- "launch",
460
- "--terminate-existing",
461
- "--device",
462
- device.udid,
755
+ await trustAndOpen(context, device, id, passcode);
756
+ } catch (error) {
757
+ if (error.message !== serverHint) {
758
+ throw error;
759
+ }
760
+ console.error(`${serverHint}; its developer is not trusted automatically`);
761
+ await launch(
762
+ device,
463
763
  id,
464
- ]);
764
+ context.options["no-setup-api"] ? [] : [publishArgument],
765
+ );
766
+ }
767
+ }
768
+
769
+ async function launch(device, id, args) {
770
+ try {
771
+ await step(`opening Swipt on ${device.name}`, () =>
772
+ devicectl(launchCommand(device, id, args)),
773
+ );
465
774
  } catch (error) {
466
775
  if (/trusted by the user/.test(error.message)) {
467
776
  console.log(
468
- `it opens once the team's developer app is trusted in Settings › General › VPN & Device Management on ${device.name}`,
777
+ `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
778
  );
470
779
  } else if (/BSErrorCodeDescription = Locked/.test(error.message)) {
471
780
  console.log(`it opens once ${device.name} is unlocked`);
@@ -480,9 +789,14 @@ export const install = (context) => deploy(context, false);
480
789
  export const update = (context) => deploy(context, true);
481
790
 
482
791
  const serverHint =
483
- "swipt-server is missing; install @swipt/cli with its optional dependencies";
792
+ 'swipt-server is missing; install it with "npm install -g @swipt/cli --include=optional"';
484
793
 
485
- function serverPath() {
794
+ async function serverPath() {
795
+ const root = path.dirname(import.meta.dirname);
796
+ if (fs.existsSync(path.join(root, "cmd/swipt-server"))) {
797
+ await exec("make", ["server"], { cwd: root });
798
+ return path.join(root, "build/swipt-server");
799
+ }
486
800
  let packaged;
487
801
  try {
488
802
  packaged = path.join(
@@ -508,7 +822,7 @@ const schema = [
508
822
  "INSERT INTO grdb_migrations VALUES ('0.1.0')",
509
823
  ];
510
824
 
511
- function setUpAutoUnlock(device, id, work, passcode) {
825
+ async function setUpAutoUnlock(device, id, work, passcode) {
512
826
  const database = path.join(work, "Swipt.sqlite");
513
827
  const preferences = {
514
828
  serverAutoUnlocks: true,
@@ -528,7 +842,7 @@ function setUpAutoUnlock(device, id, work, passcode) {
528
842
  .map((statement) => `${statement};\n`)
529
843
  .join(""),
530
844
  });
531
- devicectl([
845
+ await devicectl([
532
846
  "device",
533
847
  "copy",
534
848
  "to",
@@ -545,46 +859,94 @@ function setUpAutoUnlock(device, id, work, passcode) {
545
859
  ]);
546
860
  }
547
861
 
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;
862
+ const pairingDirectory = "Library/Application Support";
863
+ const pairingFile = "PairingRecord.plist";
864
+
865
+ async function hasPairing(device, id) {
866
+ try {
867
+ const { result } = await devicectl(
868
+ [
869
+ "device",
870
+ "info",
871
+ "files",
872
+ "--device",
873
+ device.udid,
874
+ "--domain-type",
875
+ "appDataContainer",
876
+ "--domain-identifier",
877
+ id,
878
+ "--subdirectory",
879
+ pairingDirectory,
880
+ ],
881
+ { parse: true },
882
+ );
883
+ return result.files.some((file) => file.name === pairingFile);
884
+ } catch {
885
+ return false;
558
886
  }
559
- if (result.error) {
560
- throw result.error;
887
+ }
888
+
889
+ async function pair(device, id) {
890
+ const paired = await step(
891
+ `checking the pairing of ${device.name}`,
892
+ () => hasPairing(device, id),
893
+ { transient: true },
894
+ );
895
+ if (paired) {
896
+ return false;
561
897
  }
562
- if (result.status !== 0) {
563
- console.error(
564
- `pairing failed, so Swipt pairs in Settings instead: ${result.stderr.trim()}`,
565
- );
566
- return;
898
+ const work = fs.mkdtempSync(path.join(os.tmpdir(), "swipt-pairing-"));
899
+ let warning;
900
+ try {
901
+ await step(`pairing with ${device.name}`, async () => {
902
+ const record = path.join(work, pairingFile);
903
+ try {
904
+ await exec(await serverPath(), [
905
+ "pair",
906
+ "-d",
907
+ device.udid,
908
+ "-o",
909
+ record,
910
+ ]);
911
+ } catch (error) {
912
+ if (typeof error.code === "number") {
913
+ warning = `pairing failed, so Swipt pairs in Settings instead: ${error.stderr.trim()}`;
914
+ } else if (error.code === "ENOENT") {
915
+ warning = `${serverHint}; Swipt pairs in Settings instead`;
916
+ }
917
+ throw error;
918
+ }
919
+ await devicectl([
920
+ "device",
921
+ "copy",
922
+ "to",
923
+ "--device",
924
+ device.udid,
925
+ "--source",
926
+ record,
927
+ "--destination",
928
+ `${pairingDirectory}/${pairingFile}`,
929
+ "--domain-type",
930
+ "appDataContainer",
931
+ "--domain-identifier",
932
+ id,
933
+ ]);
934
+ });
935
+ } catch (error) {
936
+ if (warning === undefined) {
937
+ throw error;
938
+ }
939
+ console.error(warning);
940
+ return false;
941
+ } finally {
942
+ fs.rmSync(work, { recursive: true, force: true });
567
943
  }
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
- ]);
944
+ return true;
583
945
  }
584
946
 
585
- function serve(udid, passcode, signal) {
947
+ async function serve(udid, passcode, signal) {
586
948
  const server = spawn(
587
- serverPath(),
949
+ await serverPath(),
588
950
  [
589
951
  "-d",
590
952
  udid,
@@ -631,66 +993,118 @@ function serve(udid, passcode, signal) {
631
993
  };
632
994
  }
633
995
 
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);
996
+ async function trustAndOpen(context, device, id, passcode) {
997
+ const lines = [];
998
+ let failure;
644
999
  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;
1000
+ await step(`opening Swipt on ${device.name}`, async (update) => {
1001
+ update("starting swipt-server");
1002
+ const waiting = passcode === undefined && process.stderr.isTTY;
1003
+ let server;
1004
+ let url;
1005
+ while (url === undefined) {
1006
+ server = await serve(device.udid, passcode, context.signal);
1007
+ try {
1008
+ url = await server.url;
1009
+ } catch (error) {
1010
+ if (!/is connected to this Mac/.test(error.message)) {
1011
+ throw error;
1012
+ }
1013
+ if (!waiting) {
1014
+ throw new Error(
1015
+ `${device.name} is not reachable; unlock it or pass --passcode, then run this again`,
1016
+ );
1017
+ }
1018
+ update("unlock the device to continue");
665
1019
  }
666
- case "error":
667
- failure = response.part.value;
668
- break;
669
1020
  }
670
- }
671
- if (failure) {
672
- throw printScriptError(failure);
1021
+ try {
1022
+ const client = open({ url, token: "" });
1023
+ const run = async (args) => {
1024
+ for await (const response of client.repl.execute(
1025
+ {
1026
+ code: fs.readFileSync(
1027
+ new URL("sideload/trust.js", import.meta.url),
1028
+ "utf8",
1029
+ ),
1030
+ args,
1031
+ program: true,
1032
+ },
1033
+ { signal: context.signal },
1034
+ )) {
1035
+ switch (response.part.case) {
1036
+ case "console": {
1037
+ const output = response.part.value;
1038
+ if (output.level === LogLevel.DEBUG) {
1039
+ update(output.text);
1040
+ } else {
1041
+ lines.push(output);
1042
+ }
1043
+ break;
1044
+ }
1045
+ case "error":
1046
+ failure = response.part.value;
1047
+ break;
1048
+ }
1049
+ }
1050
+ if (failure) {
1051
+ throw new Error(failure.message);
1052
+ }
1053
+ };
1054
+ const runArguments = (mode) => [id, mode, waiting ? "wait" : ""];
1055
+ await run(runArguments("trust"));
1056
+ update("opening Swipt");
1057
+ await devicectl(
1058
+ launchCommand(device, id, [
1059
+ setupArgument,
1060
+ ...(context.options["no-setup-api"] ? [] : [publishArgument]),
1061
+ ]),
1062
+ );
1063
+ await run(runArguments("prompts"));
1064
+ } finally {
1065
+ await server.stop();
1066
+ }
1067
+ });
1068
+ } catch (error) {
1069
+ if (failure === undefined) {
1070
+ throw error;
673
1071
  }
674
1072
  } finally {
675
- await server.stop();
1073
+ for (const output of lines) {
1074
+ (output.level === LogLevel.ERROR || output.level === LogLevel.WARN
1075
+ ? console.error
1076
+ : console.log)(output.text);
1077
+ }
1078
+ }
1079
+ if (failure) {
1080
+ if (/\bdevice is locked\b/.test(failure.message)) {
1081
+ throw new Error(
1082
+ passcode === undefined
1083
+ ? `${device.name} is locked; unlock it or pass --passcode, then run this again`
1084
+ : `${device.name} did not unlock; check --passcode and --passcode-type, then run this again`,
1085
+ );
1086
+ }
1087
+ throw printScriptError(failure);
676
1088
  }
677
1089
  }
678
1090
 
679
- export function uninstall(context) {
680
- const device = coreDevice(context.options.device, { required: true });
681
- const { team, current } = locate(context.options, device);
1091
+ export async function uninstall(context) {
1092
+ const device = await coreDevice(context, { filter: sideloadedDevices });
1093
+ const { team, current } = await locate(context, device);
682
1094
  if (current === undefined) {
683
1095
  throw new Error(
684
1096
  `Swipt is not sideloaded on ${device.name}${team === undefined ? "" : ` by team ${team}`}`,
685
1097
  );
686
1098
  }
687
- devicectl([
688
- "device",
689
- "uninstall",
690
- "app",
691
- "--device",
692
- device.udid,
693
- current.bundleIdentifier,
694
- ]);
1099
+ await step(`removing Swipt from ${device.name}`, () =>
1100
+ devicectl([
1101
+ "device",
1102
+ "uninstall",
1103
+ "app",
1104
+ "--device",
1105
+ device.udid,
1106
+ current.bundleIdentifier,
1107
+ ]),
1108
+ );
695
1109
  console.log(`removed ${buildText(current)} from ${device.name}`);
696
1110
  }