@swipt/cli 0.1.0-nightly.44 → 0.1.0-nightly.46

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/connection.mjs CHANGED
@@ -155,7 +155,7 @@ const requiresToken = (found) =>
155
155
  `${found.name} requires a token: swipt device add ${withToken(found.url)}`;
156
156
 
157
157
  export async function attach(context) {
158
- const given = context.options.device ?? process.env.SWIPT_DEVICE;
158
+ const given = context.options.device;
159
159
  const stored = load();
160
160
  let device;
161
161
  if (given && /[:/]/.test(given)) {
@@ -197,7 +197,7 @@ export async function attach(context) {
197
197
  }
198
198
  if (names.length > 1) {
199
199
  throw new Error(
200
- `there is more than one device: ${names.join(", ")}; choose one with -d or SWIPT_DEVICE`,
200
+ `there is more than one device: ${names.join(", ")}; choose one with -d`,
201
201
  );
202
202
  }
203
203
  device = parseURL(stored[names[0]]);
package/coredevice.mjs ADDED
@@ -0,0 +1,74 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import fs from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+
6
+ import { label, sameName } from "./connection.mjs";
7
+
8
+ export function devicectl(args, { parse = false } = {}) {
9
+ const output = parse
10
+ ? path.join(os.tmpdir(), `devicectl-${process.pid}.json`)
11
+ : undefined;
12
+ const result = spawnSync(
13
+ "xcrun",
14
+ ["devicectl", ...args, ...(parse ? ["--json-output", output] : [])],
15
+ { encoding: "utf8" },
16
+ );
17
+ if (result.error) {
18
+ throw result.error;
19
+ }
20
+ if (result.status !== 0) {
21
+ const text = `${result.stdout}${result.stderr}`;
22
+ if (text.includes("4016")) {
23
+ throw new Error(
24
+ "CoreDevice cannot reach the device while its loopback VPN is on; turn off Swipt's VPN on the device, then run this again",
25
+ );
26
+ }
27
+ throw new Error(text.trim() || `devicectl exited with ${result.status}`);
28
+ }
29
+ if (!parse) {
30
+ return undefined;
31
+ }
32
+ const parsed = JSON.parse(fs.readFileSync(output, "utf8"));
33
+ fs.rmSync(output);
34
+ return parsed;
35
+ }
36
+
37
+ export function coreDevice(given, { required = false } = {}) {
38
+ const devices = devicectl(["list", "devices"], { parse: true })
39
+ .result.devices.filter(
40
+ (device) =>
41
+ device.hardwareProperties.platform === "iOS" &&
42
+ device.hardwareProperties.reality === "physical",
43
+ )
44
+ .map((device) => ({
45
+ name: device.deviceProperties.name,
46
+ udid: device.hardwareProperties.udid,
47
+ }));
48
+ const matches = given
49
+ ? devices.filter(
50
+ (device) =>
51
+ sameName(device.udid, given) ||
52
+ sameName(label(device.name), label(given)),
53
+ )
54
+ : required
55
+ ? []
56
+ : devices;
57
+ if (matches.length !== 1) {
58
+ const reason = !given
59
+ ? "choose a device with -d NAME|UDID"
60
+ : matches.length === 0
61
+ ? `no iOS device is named ${given}`
62
+ : `more than one iOS device is named ${given}; choose one by UDID`;
63
+ throw new Error(
64
+ devices.length === 0
65
+ ? `${reason}; no iOS device is paired with this Mac`
66
+ : `${reason}; paired iOS devices:\n${devices.map((device) => ` ${device.name} ${device.udid}`).join("\n")}`,
67
+ );
68
+ }
69
+ return matches[0];
70
+ }
71
+
72
+ export const apps = (udid) =>
73
+ devicectl(["device", "info", "apps", "--device", udid], { parse: true })
74
+ .result.apps;
package/device.mjs CHANGED
@@ -84,6 +84,12 @@ export const logs = (context) =>
84
84
  }
85
85
  });
86
86
 
87
+ export const stop = (context) =>
88
+ session(context, async (client) => {
89
+ await client.system.shutdown({}, { signal: context.signal });
90
+ console.log("stopped");
91
+ });
92
+
87
93
  function readInput(value) {
88
94
  if (value === undefined || value === "") {
89
95
  return undefined;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@swipt/cli",
3
- "version": "0.1.0-nightly.44",
3
+ "version": "0.1.0-nightly.46",
4
4
  "license": "GPL-3.0-or-later",
5
5
  "repository": {
6
6
  "type": "git",
@@ -13,7 +13,8 @@
13
13
  },
14
14
  "files": [
15
15
  "*.mjs",
16
- "proto"
16
+ "proto",
17
+ "sideload"
17
18
  ],
18
19
  "publishConfig": {
19
20
  "access": "public"
@@ -31,7 +32,11 @@
31
32
  "react-native-worklets": "0.13.0"
32
33
  },
33
34
  "peerDependencies": {
34
- "@swipt/api": "0.1.0-nightly.44"
35
+ "@swipt/api": "0.1.0-nightly.46"
36
+ },
37
+ "optionalDependencies": {
38
+ "@swipt/server-darwin-arm64": "0.1.0-nightly.46",
39
+ "@swipt/server-darwin-x64": "0.1.0-nightly.46"
35
40
  },
36
41
  "devDependencies": {
37
42
  "@bufbuild/protoc-gen-es": "^2.16.0"
@@ -10,7 +10,7 @@ import { file_common } from "./common_pb.js";
10
10
  * Describes the file system.proto.
11
11
  */
12
12
  export const file_system = /*@__PURE__*/
13
- fileDesc("CgxzeXN0ZW0ucHJvdG8SBXN3aXB0IqgBCg9HZXRJbmZvUmVzcG9uc2USFgoOc2VydmVyX3ZlcnNpb24YASABKAkSEwoLZGV2aWNlX25hbWUYAiABKAkSDAoEdWRpZBgDIAEoCRIyChFkZXZpY2VfY29ubmVjdGlvbhgEIAEoCzIXLnN3aXB0LkRldmljZUNvbm5lY3Rpb24SEwoLYXBwX3ZlcnNpb24YBSABKAkSEQoJYXBwX2J1aWxkGAYgASgJMkoKDVN5c3RlbVNlcnZpY2USOQoHR2V0SW5mbxIWLmdvb2dsZS5wcm90b2J1Zi5FbXB0eRoWLnN3aXB0LkdldEluZm9SZXNwb25zZUIyWjBnaXRodWIuY29tL3N3aXB0LWF1dG9tYXRpb24vc3dpcHQvaW50ZXJuYWwvcHJvdG9iBnByb3RvMw", [file_google_protobuf_empty, file_common]);
13
+ fileDesc("CgxzeXN0ZW0ucHJvdG8SBXN3aXB0IqgBCg9HZXRJbmZvUmVzcG9uc2USFgoOc2VydmVyX3ZlcnNpb24YASABKAkSEwoLZGV2aWNlX25hbWUYAiABKAkSDAoEdWRpZBgDIAEoCRIyChFkZXZpY2VfY29ubmVjdGlvbhgEIAEoCzIXLnN3aXB0LkRldmljZUNvbm5lY3Rpb24SEwoLYXBwX3ZlcnNpb24YBSABKAkSEQoJYXBwX2J1aWxkGAYgASgJMoYBCg1TeXN0ZW1TZXJ2aWNlEjkKB0dldEluZm8SFi5nb29nbGUucHJvdG9idWYuRW1wdHkaFi5zd2lwdC5HZXRJbmZvUmVzcG9uc2USOgoIU2h1dGRvd24SFi5nb29nbGUucHJvdG9idWYuRW1wdHkaFi5nb29nbGUucHJvdG9idWYuRW1wdHlCMlowZ2l0aHViLmNvbS9zd2lwdC1hdXRvbWF0aW9uL3N3aXB0L2ludGVybmFsL3Byb3RvYgZwcm90bzM", [file_google_protobuf_empty, file_common]);
14
14
 
15
15
  /**
16
16
  * Describes the message swipt.GetInfoResponse.
@@ -0,0 +1,235 @@
1
+ const [id] = args;
2
+ const settings = "com.apple.Preferences";
3
+ const developerPattern =
4
+ /(?:Apple Development|iPhone Developer): .+?\([A-Z0-9]{10}\)/;
5
+ const tapTimeoutMs = 3_000;
6
+
7
+ const sheet = async () => {
8
+ const screen = await accessibility.snapshot({ scope: "screen" });
9
+ return screen.find({ role: "sheet" });
10
+ };
11
+
12
+ const alertPresentMs = 600;
13
+
14
+ async function dismiss(alert, buttons, index) {
15
+ await sleep(alertPresentMs);
16
+ for (let attempt = 0; attempt < 5; attempt++) {
17
+ await input.tap(buttons[index].point);
18
+ await sleep(alertPresentMs);
19
+ const shown = await sheet();
20
+ if (shown === null || shown.label !== alert.label) {
21
+ return;
22
+ }
23
+ }
24
+ throw new Error(`the alert stays: ${alert.label}`);
25
+ }
26
+
27
+ async function allowPrompts(name, waitMs) {
28
+ const until = Date.now() + waitMs;
29
+ do {
30
+ const alert = await sheet();
31
+ const buttons = alert?.findAll({ role: "button" }) ?? [];
32
+ if (buttons.length === 2 && alert.label?.includes(name)) {
33
+ await dismiss(alert, buttons, 1);
34
+ return;
35
+ }
36
+ await sleep(300);
37
+ } while (Date.now() < until);
38
+ }
39
+
40
+ async function launch(name) {
41
+ try {
42
+ await device.launchApplication(id, { restart: true });
43
+ await allowPrompts(name, 3_000);
44
+ return null;
45
+ } catch (error) {
46
+ for (let attempt = 0; attempt < 10; attempt++) {
47
+ const alert = await sheet();
48
+ const buttons = alert?.findAll({ role: "button" }) ?? [];
49
+ if (buttons.length === 1) {
50
+ const developer = alert
51
+ .findAll({ role: "text" })
52
+ .map((text) => developerPattern.exec(text.label ?? "")?.[0])
53
+ .find((found) => found !== undefined);
54
+ await dismiss(alert, buttons, 0);
55
+ if (developer === undefined) {
56
+ throw new Error(`the alert names no developer: ${alert.label}`);
57
+ }
58
+ return developer;
59
+ }
60
+ await sleep(300);
61
+ }
62
+ throw error;
63
+ }
64
+ }
65
+
66
+ async function findScrolling(selector, bounds) {
67
+ for (let attempt = 0; attempt < 12; attempt++) {
68
+ const found = await accessibility.find(
69
+ { app: settings, ...selector },
70
+ { timeoutMs: 1_500, poll: true },
71
+ );
72
+ if (found !== null && !found.partial && !found.offScreen) {
73
+ return found;
74
+ }
75
+ const distance = found === null ? 0.5 : 0.25;
76
+ await input.swipe(
77
+ [bounds.width * 0.6, bounds.height * (0.3 + distance)],
78
+ [bounds.width * 0.6, bounds.height * 0.3],
79
+ { durationMs: 300 },
80
+ );
81
+ await sleep(500);
82
+ }
83
+ throw new Error(`Settings did not show ${JSON.stringify(selector)}`);
84
+ }
85
+
86
+ async function openRow(selector, bounds, loaded) {
87
+ for (let attempt = 0; attempt < 3; attempt++) {
88
+ try {
89
+ await accessibility.tap(await findScrolling(selector, bounds), {
90
+ timeoutMs: tapTimeoutMs,
91
+ });
92
+ } catch (error) {
93
+ if (error.name !== "TimeoutError") {
94
+ throw error;
95
+ }
96
+ await input.pressHome();
97
+ await sleep(800);
98
+ await device.launchApplication(settings);
99
+ await sleep(1_000);
100
+ continue;
101
+ }
102
+ for (let wait = 0; wait < 10; wait++) {
103
+ await sleep(300);
104
+ if (loaded(await accessibility.snapshot({ scope: "frontApp" }))) {
105
+ return;
106
+ }
107
+ }
108
+ }
109
+ throw new Error(`Settings did not open ${JSON.stringify(selector)}`);
110
+ }
111
+
112
+ async function back() {
113
+ const button = await accessibility.find(
114
+ { app: settings, identifier: "BackButton" },
115
+ { timeoutMs: 3_000, poll: true },
116
+ );
117
+ if (button === null) {
118
+ throw new Error("Settings shows no back button");
119
+ }
120
+ await accessibility.tap(button, { timeoutMs: tapTimeoutMs });
121
+ await sleep(600);
122
+ }
123
+
124
+ async function trust(developer, bounds) {
125
+ await device.launchApplication(settings, { restart: true });
126
+ await openRow(
127
+ { identifier: "com.apple.settings.general" },
128
+ bounds,
129
+ (opened) =>
130
+ opened.findAll({ identifier: /^com\.apple\.settings\.general\./ })
131
+ .length > 0,
132
+ );
133
+ await openRow(
134
+ { identifier: "com.apple.settings.general.vpnAndDeviceManagement" },
135
+ bounds,
136
+ (opened) =>
137
+ opened.findAll({ role: "button", identifier: "VPN" }).length > 0,
138
+ );
139
+ const candidates = (list) => {
140
+ const elsewhere = new Set(
141
+ list
142
+ .findAll({
143
+ role: "button",
144
+ within: {
145
+ identifier: "com.apple.settings.sidebar.collectionView",
146
+ },
147
+ })
148
+ .concat(
149
+ list.findAll({
150
+ role: "button",
151
+ within: { role: /^(navigationBar|searchField)$/ },
152
+ }),
153
+ )
154
+ .map((row) => row.identifier),
155
+ );
156
+ return list
157
+ .findAll({ role: "button" })
158
+ .filter(
159
+ (row) =>
160
+ row.identifier !== undefined &&
161
+ row.identifier !== "BackButton" &&
162
+ row.identifier !== "VPN" &&
163
+ row.identifier !== row.label &&
164
+ !elsewhere.has(row.identifier),
165
+ )
166
+ .map((row) => row.identifier);
167
+ };
168
+ let rows = [];
169
+ for (let attempt = 0; attempt < 15 && rows.length === 0; attempt++) {
170
+ await sleep(400);
171
+ rows = candidates(await accessibility.snapshot({ scope: "frontApp" }));
172
+ }
173
+ for (const row of rows) {
174
+ const button = await accessibility.find(
175
+ { app: settings, role: "button", identifier: row },
176
+ { timeoutMs: 3_000, poll: true },
177
+ );
178
+ if (button === null) {
179
+ continue;
180
+ }
181
+ await accessibility.tap(button, { timeoutMs: tapTimeoutMs });
182
+ let control = null;
183
+ for (let attempt = 0; attempt < 10 && control === null; attempt++) {
184
+ await sleep(300);
185
+ const opened = await accessibility.snapshot({ scope: "frontApp" });
186
+ if (
187
+ opened.findAll((it) => (it.label ?? "").includes(developer)).length > 0
188
+ ) {
189
+ control =
190
+ opened
191
+ .findAll({ role: "text" })
192
+ .find((it) => it.label !== row && it.label?.includes(row)) ?? null;
193
+ break;
194
+ }
195
+ }
196
+ if (control === null) {
197
+ await back();
198
+ continue;
199
+ }
200
+ await input.tap(control.point);
201
+ for (let attempt = 0; attempt < 10; attempt++) {
202
+ const alert = await sheet();
203
+ const buttons = alert?.findAll({ role: "button" }) ?? [];
204
+ if (buttons.length === 2) {
205
+ await dismiss(alert, buttons, 0);
206
+ return;
207
+ }
208
+ await sleep(300);
209
+ }
210
+ throw new Error("Settings did not ask to allow the developer");
211
+ }
212
+ throw new Error(`Settings lists no app from ${developer}`);
213
+ }
214
+
215
+ async function main() {
216
+ await device.unlock().catch(() => {});
217
+ const name =
218
+ (await device.installedApplications()).find((app) => app.bundleId === id)
219
+ ?.name ?? "Swipt";
220
+ await allowPrompts(name, 0);
221
+ const developer = await launch(name);
222
+ if (developer === null) {
223
+ console.log(`${name} is running`);
224
+ return;
225
+ }
226
+ const { screen: bounds } = await device.info();
227
+ await trust(developer, bounds);
228
+ await sleep(1_000);
229
+ if ((await launch(name)) !== null) {
230
+ throw new Error(`${name} does not open after trusting ${developer}`);
231
+ }
232
+ console.log(`${name} is running; ${developer} is trusted`);
233
+ }
234
+
235
+ await main();
package/sideload.mjs ADDED
@@ -0,0 +1,696 @@
1
+ import { execFileSync, spawn, spawnSync } from "node:child_process";
2
+ import { X509Certificate, createHash } from "node:crypto";
3
+ import fs from "node:fs";
4
+ import { createRequire } from "node:module";
5
+ import os from "node:os";
6
+ import path from "node:path";
7
+ import readline from "node:readline";
8
+ import { Readable } from "node:stream";
9
+ import { pipeline } from "node:stream/promises";
10
+
11
+ import { UsageError, open } from "./client.mjs";
12
+ import { apps, coreDevice, devicectl } from "./coredevice.mjs";
13
+ import { printScriptError } from "./output.mjs";
14
+ import { LogLevel } from "./proto/common_pb.js";
15
+
16
+ const bundleId = "io.nekohasekai.swipt";
17
+ const repository = "swipt-automation/swipt";
18
+ const teamPattern = /^[A-Z0-9]{10}$/;
19
+ const sideloadedPattern = new RegExp(
20
+ `^${bundleId.replaceAll(".", "\\.")}\\.([A-Z0-9]{10})$`,
21
+ );
22
+
23
+ const accountsHint = "Xcode › Settings › Accounts";
24
+
25
+ const passcodeTypes = ["pin", "numeric", "alphanumeric"];
26
+
27
+ function passcodeOf(options) {
28
+ const type = options["passcode-type"] ?? "pin";
29
+ if (!passcodeTypes.includes(type)) {
30
+ throw new UsageError(
31
+ `invalid argument ${JSON.stringify(type)} for --passcode-type: it is ${passcodeTypes.join(", ")}`,
32
+ );
33
+ }
34
+ const passcode = options.passcode;
35
+ if (!passcode) {
36
+ return undefined;
37
+ }
38
+ const digits = /^\d+$/.test(passcode);
39
+ if (type === "pin" && !(digits && [4, 6].includes(passcode.length))) {
40
+ throw new UsageError(
41
+ "a pin passcode is 4 or 6 digits; a custom one needs --passcode-type numeric or alphanumeric",
42
+ );
43
+ }
44
+ if (type === "numeric" && !digits) {
45
+ throw new UsageError("a numeric passcode is digits only");
46
+ }
47
+ if (!/^[\x20-\x7e]{1,64}$/.test(passcode)) {
48
+ throw new UsageError("a passcode is up to 64 printable ASCII characters");
49
+ }
50
+ return { passcode, type };
51
+ }
52
+
53
+ function headers(accept) {
54
+ const token = process.env.GITHUB_TOKEN;
55
+ return {
56
+ Accept: accept,
57
+ "X-GitHub-Api-Version": "2022-11-28",
58
+ "User-Agent": "swipt-cli",
59
+ ...(token ? { Authorization: `Bearer ${token}` } : {}),
60
+ };
61
+ }
62
+
63
+ async function github(url, accept, signal) {
64
+ let response = await fetch(url, {
65
+ headers: headers(accept),
66
+ signal,
67
+ redirect: "manual",
68
+ });
69
+ if (response.status >= 300 && response.status < 400) {
70
+ response = await fetch(response.headers.get("location"), { signal });
71
+ }
72
+ if (!response.ok) {
73
+ let message = `GitHub answered ${response.status} for ${url}`;
74
+ if ([401, 403, 404, 429].includes(response.status)) {
75
+ 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
+ }
77
+ throw new Error(message);
78
+ }
79
+ return response;
80
+ }
81
+
82
+ 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();
90
+ const asset = (name) => {
91
+ const found = release.assets.find((it) => it.name === name);
92
+ if (!found) {
93
+ throw new Error(`release ${release.tag_name} has no ${name}`);
94
+ }
95
+ return found.url;
96
+ };
97
+ const metadata = await (
98
+ await github(asset("metadata.json"), "application/octet-stream", signal)
99
+ ).json();
100
+ return {
101
+ tag: release.tag_name,
102
+ version: metadata.version,
103
+ build: Number(metadata.build),
104
+ ipa: asset("Swipt.ipa"),
105
+ };
106
+ }
107
+
108
+ async function download(url, file, signal) {
109
+ const response = await github(url, "application/octet-stream", signal);
110
+ await pipeline(Readable.fromWeb(response.body), fs.createWriteStream(file), {
111
+ signal,
112
+ });
113
+ }
114
+
115
+ function unpack(ipa, directory) {
116
+ execFileSync("unzip", ["-q", ipa, "-d", directory]);
117
+ const payload = path.join(directory, "Payload");
118
+ const app = fs.readdirSync(payload).find((name) => name.endsWith(".app"));
119
+ if (app === undefined) {
120
+ throw new Error("the IPA has no app in Payload");
121
+ }
122
+ return path.join(payload, app);
123
+ }
124
+
125
+ const pbxproj = (id, team) => `// !$*UTF8*$!
126
+ {
127
+ archiveVersion = 1;
128
+ classes = {
129
+ };
130
+ objectVersion = 56;
131
+ objects = {
132
+ A1 = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.c; path = main.c; sourceTree = "<group>"; };
133
+ A2 = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Stub.app; sourceTree = BUILT_PRODUCTS_DIR; };
134
+ A3 = {isa = PBXBuildFile; fileRef = A1; };
135
+ B1 = {isa = PBXGroup; children = (B2, B3); sourceTree = "<group>"; };
136
+ B2 = {isa = PBXGroup; children = (A1); path = Stub; sourceTree = "<group>"; };
137
+ B3 = {isa = PBXGroup; children = (A2); name = Products; sourceTree = "<group>"; };
138
+ C1 = {isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = (A3); runOnlyForDeploymentPostprocessing = 0; };
139
+ D1 = {
140
+ isa = PBXNativeTarget;
141
+ buildConfigurationList = E1;
142
+ buildPhases = (C1);
143
+ buildRules = ();
144
+ dependencies = ();
145
+ name = Stub;
146
+ productName = Stub;
147
+ productReference = A2;
148
+ productType = "com.apple.product-type.application";
149
+ };
150
+ E1 = {isa = XCConfigurationList; buildConfigurations = (F1); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; };
151
+ E2 = {isa = XCConfigurationList; buildConfigurations = (F2); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; };
152
+ F1 = {
153
+ isa = XCBuildConfiguration;
154
+ buildSettings = {
155
+ CODE_SIGN_STYLE = Automatic;
156
+ DEVELOPMENT_TEAM = "${team}";
157
+ GENERATE_INFOPLIST_FILE = YES;
158
+ PRODUCT_BUNDLE_IDENTIFIER = "${id}";
159
+ PRODUCT_NAME = Stub;
160
+ SDKROOT = iphoneos;
161
+ TARGETED_DEVICE_FAMILY = "1,2";
162
+ };
163
+ name = Release;
164
+ };
165
+ F2 = {isa = XCBuildConfiguration; buildSettings = {}; name = Release; };
166
+ G1 = {
167
+ isa = PBXProject;
168
+ buildConfigurationList = E2;
169
+ compatibilityVersion = "Xcode 14.0";
170
+ mainGroup = B1;
171
+ productRefGroup = B3;
172
+ projectDirPath = "";
173
+ projectRoot = "";
174
+ targets = (D1);
175
+ };
176
+ };
177
+ rootObject = G1;
178
+ }
179
+ `;
180
+
181
+ function explain(output, team) {
182
+ const errors = [
183
+ ...new Set(
184
+ output
185
+ .split("\n")
186
+ .filter((line) => /^(xcodebuild: )?error: /.test(line))
187
+ .map((line) => line.replace(/^(xcodebuild: )?error: /, "")),
188
+ ),
189
+ ].join("; ");
190
+ if (/No Accounts?\b/.test(output)) {
191
+ return `Xcode has no account for team ${team}; sign in with its Apple Account at ${accountsHint}`;
192
+ }
193
+ if (/signing certificate|certificateType/i.test(output)) {
194
+ return `Xcode could not make a development certificate for team ${team}; add one at ${accountsHint} › Manage Certificates (${errors})`;
195
+ }
196
+ if (/requires Xcode|xcode-select/.test(output)) {
197
+ return "xcodebuild needs Xcode; install it and select it with xcode-select";
198
+ }
199
+ return errors || output.trim();
200
+ }
201
+
202
+ function provision(work, id, team, device) {
203
+ const stub = path.join(work, "Stub");
204
+ const products = path.join(stub, "Products");
205
+ fs.mkdirSync(path.join(stub, "Stub.xcodeproj"), { recursive: true });
206
+ fs.mkdirSync(path.join(stub, "Stub"));
207
+ fs.writeFileSync(
208
+ path.join(stub, "Stub/main.c"),
209
+ "int main(void) { return 0; }\n",
210
+ );
211
+ fs.writeFileSync(
212
+ path.join(stub, "Stub.xcodeproj/project.pbxproj"),
213
+ pbxproj(id, team),
214
+ );
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));
241
+ }
242
+ const app = path.join(products, "Stub.app");
243
+ const certificates = path.join(work, "certificate");
244
+ const stdio = ["ignore", "pipe", "ignore"];
245
+ execFileSync(
246
+ "codesign",
247
+ ["-d", `--extract-certificates=${certificates}`, app],
248
+ { stdio },
249
+ );
250
+ const entitlements = path.join(work, "entitlements.plist");
251
+ fs.writeFileSync(
252
+ entitlements,
253
+ execFileSync("codesign", ["-d", "--entitlements", "-", "--xml", app], {
254
+ stdio,
255
+ }),
256
+ );
257
+ return {
258
+ identity: createHash("sha1")
259
+ .update(fs.readFileSync(`${certificates}0`))
260
+ .digest("hex")
261
+ .toUpperCase(),
262
+ entitlements,
263
+ profile: path.join(app, "embedded.mobileprovision"),
264
+ };
265
+ }
266
+
267
+ function resign(app, id, signing) {
268
+ const info = path.join(app, "Info.plist");
269
+ execFileSync("plutil", [
270
+ "-replace",
271
+ "CFBundleIdentifier",
272
+ "-string",
273
+ id,
274
+ info,
275
+ ]);
276
+ let permitted;
277
+ try {
278
+ permitted = JSON.parse(
279
+ execFileSync("plutil", [
280
+ "-extract",
281
+ "BGTaskSchedulerPermittedIdentifiers",
282
+ "json",
283
+ "-o",
284
+ "-",
285
+ info,
286
+ ]),
287
+ );
288
+ } catch {
289
+ permitted = undefined;
290
+ }
291
+ if (permitted !== undefined) {
292
+ execFileSync("plutil", [
293
+ "-replace",
294
+ "BGTaskSchedulerPermittedIdentifiers",
295
+ "-json",
296
+ JSON.stringify(
297
+ permitted.map((it) =>
298
+ it.startsWith(`${bundleId}.`) ? id + it.slice(bundleId.length) : it,
299
+ ),
300
+ ),
301
+ info,
302
+ ]);
303
+ }
304
+ fs.copyFileSync(signing.profile, path.join(app, "embedded.mobileprovision"));
305
+ const codesign = ["--force", "--sign", signing.identity, "--timestamp=none"];
306
+ const frameworks = path.join(app, "Frameworks");
307
+ if (fs.existsSync(frameworks)) {
308
+ for (const name of fs.readdirSync(frameworks)) {
309
+ execFileSync("codesign", [...codesign, path.join(frameworks, name)]);
310
+ }
311
+ }
312
+ execFileSync("codesign", [
313
+ ...codesign,
314
+ "--entitlements",
315
+ signing.entitlements,
316
+ app,
317
+ ]);
318
+ }
319
+
320
+ function xcodeTeams() {
321
+ const valid = new Set(
322
+ [
323
+ ...execFileSync(
324
+ "security",
325
+ ["find-identity", "-v", "-p", "codesigning"],
326
+ {
327
+ encoding: "utf8",
328
+ },
329
+ ).matchAll(/\) ([0-9A-F]{40}) "Apple Development: /g),
330
+ ].map((match) => match[1]),
331
+ );
332
+ const certificates = execFileSync(
333
+ "security",
334
+ ["find-certificate", "-a", "-Z", "-p", "-c", "Apple Development"],
335
+ { encoding: "utf8" },
336
+ ).matchAll(
337
+ /SHA-1 hash: ([0-9A-F]{40})[\s\S]*?(-----BEGIN CERTIFICATE-----[\s\S]*?-----END CERTIFICATE-----)/g,
338
+ );
339
+ const teams = new Map();
340
+ for (const [, hash, pem] of certificates) {
341
+ if (!valid.has(hash)) {
342
+ continue;
343
+ }
344
+ const subject = new X509Certificate(pem).subject;
345
+ const id = /^OU=(.+)$/m.exec(subject)?.[1];
346
+ if (id !== undefined && teamPattern.test(id)) {
347
+ teams.set(id, /^O=(.+)$/m.exec(subject)?.[1].replaceAll("\\", ""));
348
+ }
349
+ }
350
+ return [...teams].map(([id, name]) => ({ id, name }));
351
+ }
352
+
353
+ const teamList = (teams) =>
354
+ teams
355
+ .map((team) => ` ${team.id}${team.name ? ` ${team.name}` : ""}`)
356
+ .join("\n");
357
+
358
+ function locate(options, device, fresh = false) {
359
+ const found = apps(device.udid)
360
+ .map((app) => ({
361
+ ...app,
362
+ team: sideloadedPattern.exec(app.bundleIdentifier)?.[1],
363
+ }))
364
+ .filter((app) => app.team !== undefined);
365
+ if (options.team !== undefined) {
366
+ if (!teamPattern.test(options.team)) {
367
+ throw new UsageError(
368
+ `invalid argument ${JSON.stringify(options.team)} for --team: it is the 10-character team ID shown in ${accountsHint}`,
369
+ );
370
+ }
371
+ return {
372
+ team: options.team,
373
+ current: found.find((app) => app.team === options.team),
374
+ };
375
+ }
376
+ 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 })))}`,
379
+ );
380
+ }
381
+ if (found.length === 0 && fresh) {
382
+ const teams = xcodeTeams();
383
+ if (teams.length !== 1) {
384
+ 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)}`,
388
+ );
389
+ }
390
+ return { team: teams[0].id, current: undefined };
391
+ }
392
+ return { team: found[0]?.team, current: found[0] };
393
+ }
394
+
395
+ const buildText = (app) => `Swipt ${app.version} (${app.bundleVersion})`;
396
+
397
+ async function deploy(context, onlyNewer) {
398
+ const device = coreDevice(context.options.device, { required: true });
399
+ const { team, current } = locate(context.options, device, !onlyNewer);
400
+ if (current === undefined && onlyNewer) {
401
+ throw new Error(
402
+ `Swipt is not sideloaded on ${device.name}${team === undefined ? "" : ` by team ${team}`}; install it with "swipt sideload install"`,
403
+ );
404
+ }
405
+ const passcode =
406
+ current === undefined ? passcodeOf(context.options) : undefined;
407
+ const release = await findRelease(context.options.nightly, context.signal);
408
+ if (current !== undefined) {
409
+ const installed = Number(current.bundleVersion);
410
+ if (installed === release.build) {
411
+ console.log(
412
+ `${buildText(current)} on ${device.name} is already ${release.tag}`,
413
+ );
414
+ return;
415
+ }
416
+ if (onlyNewer && installed > release.build) {
417
+ console.log(
418
+ `${buildText(current)} on ${device.name} is newer than ${release.tag} (${release.build})`,
419
+ );
420
+ return;
421
+ }
422
+ }
423
+ const id = `${bundleId}.${team}`;
424
+ const work = fs.mkdtempSync(path.join(os.tmpdir(), "swipt-sideload-"));
425
+ try {
426
+ console.error(
427
+ `downloading Swipt ${release.version} (${release.build}) from ${release.tag}`,
428
+ );
429
+ 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
+ }
442
+ }
443
+ } finally {
444
+ fs.rmSync(work, { recursive: true, force: true });
445
+ }
446
+ const result = locate({ team }, device).current;
447
+ if (result === undefined || Number(result.bundleVersion) !== release.build) {
448
+ throw new Error(
449
+ `${device.name} ${result === undefined ? `does not list ${id}` : `has ${buildText(result)}`} after the install`,
450
+ );
451
+ }
452
+ console.log(
453
+ `${buildText(result)} is ${current === undefined ? "installed" : "updated"} on ${device.name}`,
454
+ );
455
+ try {
456
+ devicectl([
457
+ "device",
458
+ "process",
459
+ "launch",
460
+ "--terminate-existing",
461
+ "--device",
462
+ device.udid,
463
+ id,
464
+ ]);
465
+ } catch (error) {
466
+ if (/trusted by the user/.test(error.message)) {
467
+ console.log(
468
+ `it opens once the team's developer app is trusted in Settings › General › VPN & Device Management on ${device.name}`,
469
+ );
470
+ } else if (/BSErrorCodeDescription = Locked/.test(error.message)) {
471
+ console.log(`it opens once ${device.name} is unlocked`);
472
+ } else {
473
+ throw error;
474
+ }
475
+ }
476
+ }
477
+
478
+ export const install = (context) => deploy(context, false);
479
+
480
+ export const update = (context) => deploy(context, true);
481
+
482
+ const serverHint =
483
+ "swipt-server is missing; install @swipt/cli with its optional dependencies";
484
+
485
+ function serverPath() {
486
+ let packaged;
487
+ try {
488
+ packaged = path.join(
489
+ path.dirname(
490
+ createRequire(import.meta.url).resolve(
491
+ `@swipt/server-${process.platform}-${process.arch}/package.json`,
492
+ ),
493
+ ),
494
+ "swipt-server",
495
+ );
496
+ } catch {
497
+ return "swipt-server";
498
+ }
499
+ return fs.existsSync(packaged) ? packaged : "swipt-server";
500
+ }
501
+
502
+ const schema = [
503
+ "CREATE TABLE grdb_migrations (identifier TEXT NOT NULL PRIMARY KEY)",
504
+ 'CREATE TABLE "preferences" ("name" TEXT PRIMARY KEY NOT NULL, "data" BLOB NOT NULL)',
505
+ 'CREATE TABLE "swiplets" ("id" TEXT PRIMARY KEY NOT NULL, "position" INTEGER NOT NULL, "etag" TEXT NOT NULL)',
506
+ 'CREATE TABLE "repositories" ("url" TEXT PRIMARY KEY NOT NULL, "position" INTEGER NOT NULL, "name" TEXT NOT NULL, "localizedNames" TEXT, "iconColor" TEXT, "iconSymbol" TEXT, "image" BLOB, "etag" TEXT NOT NULL)',
507
+ 'CREATE TABLE "repositorySwiplets" ("repository" TEXT NOT NULL REFERENCES "repositories"("url") ON DELETE CASCADE, "position" INTEGER NOT NULL, "id" TEXT NOT NULL, "name" TEXT NOT NULL, "localizedNames" TEXT NOT NULL, "description" TEXT NOT NULL, "version" TEXT NOT NULL, "versionCode" INTEGER NOT NULL, "iconColor" TEXT, "iconSymbol" TEXT, "url" TEXT NOT NULL, "sha256" TEXT NOT NULL, PRIMARY KEY ("repository", "id"))',
508
+ "INSERT INTO grdb_migrations VALUES ('0.1.0')",
509
+ ];
510
+
511
+ function setUpAutoUnlock(device, id, work, passcode) {
512
+ const database = path.join(work, "Swipt.sqlite");
513
+ const preferences = {
514
+ serverAutoUnlocks: true,
515
+ serverAutoUnlockPasscodeType: passcodeTypes.indexOf(passcode.type),
516
+ serverAutoUnlockPasscode: passcode.passcode,
517
+ autoUnlockSetUp: true,
518
+ };
519
+ const quote = (text) => `'${text.replaceAll("'", "''")}'`;
520
+ execFileSync("sqlite3", [database], {
521
+ input: [
522
+ ...schema,
523
+ ...Object.entries(preferences).map(
524
+ ([name, value]) =>
525
+ `INSERT INTO preferences VALUES (${quote(name)}, CAST(${quote(JSON.stringify(value))} AS BLOB))`,
526
+ ),
527
+ ]
528
+ .map((statement) => `${statement};\n`)
529
+ .join(""),
530
+ });
531
+ devicectl([
532
+ "device",
533
+ "copy",
534
+ "to",
535
+ "--device",
536
+ device.udid,
537
+ "--source",
538
+ database,
539
+ "--destination",
540
+ "Library/Application Support/Swipt.sqlite",
541
+ "--domain-type",
542
+ "appDataContainer",
543
+ "--domain-identifier",
544
+ id,
545
+ ]);
546
+ }
547
+
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;
558
+ }
559
+ if (result.error) {
560
+ throw result.error;
561
+ }
562
+ if (result.status !== 0) {
563
+ console.error(
564
+ `pairing failed, so Swipt pairs in Settings instead: ${result.stderr.trim()}`,
565
+ );
566
+ return;
567
+ }
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
+ ]);
583
+ }
584
+
585
+ function serve(udid, passcode, signal) {
586
+ const server = spawn(
587
+ serverPath(),
588
+ [
589
+ "-d",
590
+ udid,
591
+ ...(passcode
592
+ ? ["--passcode", passcode.passcode, "--passcode-type", passcode.type]
593
+ : []),
594
+ ],
595
+ {
596
+ stdio: ["ignore", "pipe", "pipe"],
597
+ signal,
598
+ },
599
+ );
600
+ let logs = "";
601
+ server.stderr.on("data", (chunk) => {
602
+ logs += chunk;
603
+ });
604
+ const exited = new Promise((resolve) => server.once("close", resolve));
605
+ const url = new Promise((resolve, reject) => {
606
+ server.once("error", (error) =>
607
+ reject(error.code === "ENOENT" ? new Error(serverHint) : error),
608
+ );
609
+ exited.then((code) =>
610
+ reject(
611
+ new Error(
612
+ `swipt-server exited with ${code ?? "a signal"}${logs.trim() === "" ? "" : `: ${logs.trim()}`}`,
613
+ ),
614
+ ),
615
+ );
616
+ readline.createInterface({ input: server.stdout }).on("line", (line) => {
617
+ const found = /^Swipt API: (\S+)/.exec(line);
618
+ if (found) {
619
+ const parsed = new URL(found[1]);
620
+ parsed.hostname = "127.0.0.1";
621
+ resolve(parsed.origin);
622
+ }
623
+ });
624
+ });
625
+ return {
626
+ url,
627
+ stop() {
628
+ server.kill("SIGTERM");
629
+ return exited;
630
+ },
631
+ };
632
+ }
633
+
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);
644
+ 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;
665
+ }
666
+ case "error":
667
+ failure = response.part.value;
668
+ break;
669
+ }
670
+ }
671
+ if (failure) {
672
+ throw printScriptError(failure);
673
+ }
674
+ } finally {
675
+ await server.stop();
676
+ }
677
+ }
678
+
679
+ export function uninstall(context) {
680
+ const device = coreDevice(context.options.device, { required: true });
681
+ const { team, current } = locate(context.options, device);
682
+ if (current === undefined) {
683
+ throw new Error(
684
+ `Swipt is not sideloaded on ${device.name}${team === undefined ? "" : ` by team ${team}`}`,
685
+ );
686
+ }
687
+ devicectl([
688
+ "device",
689
+ "uninstall",
690
+ "app",
691
+ "--device",
692
+ device.udid,
693
+ current.bundleIdentifier,
694
+ ]);
695
+ console.log(`removed ${buildText(current)} from ${device.name}`);
696
+ }
package/swipt.mjs CHANGED
@@ -7,6 +7,7 @@ import * as connection from "./connection.mjs";
7
7
  import * as device from "./device.mjs";
8
8
  import { FalseResult } from "./output.mjs";
9
9
  import { buildProject, createCommand, sourceCommand } from "./project.mjs";
10
+ import * as sideload from "./sideload.mjs";
10
11
 
11
12
  const globalOptions = {
12
13
  help: { type: "boolean", short: "h", help: "help" },
@@ -17,10 +18,37 @@ const deviceOption = {
17
18
  type: "string",
18
19
  short: "d",
19
20
  value: "NAME|URL",
20
- help: "the device to use (SWIPT_DEVICE)",
21
+ help: "the device to use",
21
22
  },
22
23
  };
23
24
 
25
+ const sideloadOptions = {
26
+ device: {
27
+ type: "string",
28
+ short: "d",
29
+ value: "NAME|UDID",
30
+ help: "device paired with this Mac",
31
+ },
32
+ team: {
33
+ type: "string",
34
+ value: "ID",
35
+ help: "team ID, required if more than one",
36
+ },
37
+ };
38
+
39
+ const passcodeOptions = (help) => ({
40
+ passcode: { type: "string", value: "CODE", help },
41
+ "passcode-type": {
42
+ type: "string",
43
+ value: "TYPE",
44
+ help: "pin (4 or 6 digits, default), numeric or alphanumeric",
45
+ },
46
+ });
47
+
48
+ const nightlyOption = {
49
+ nightly: { type: "boolean", help: "use nightly build" },
50
+ };
51
+
24
52
  const durationUnits = {
25
53
  ns: 1e-6,
26
54
  us: 1e-3,
@@ -195,6 +223,13 @@ const commands = [
195
223
  args: [1, 1],
196
224
  run: connection.remove,
197
225
  },
226
+ {
227
+ name: "stop",
228
+ short: "Stop the Swipt server",
229
+ args: [0, 0],
230
+ options: deviceOption,
231
+ run: device.stop,
232
+ },
198
233
  {
199
234
  name: "logs",
200
235
  short: "Print the logs",
@@ -347,6 +382,43 @@ const commands = [
347
382
  dataCommands("", device.replData),
348
383
  ],
349
384
  },
385
+ {
386
+ name: "sideload",
387
+ short: "Sideload Swipt, signed by Xcode",
388
+ options: sideloadOptions,
389
+ commands: [
390
+ {
391
+ name: "install",
392
+ short: "Install latest release",
393
+ args: [0, 0],
394
+ options: {
395
+ ...passcodeOptions("set up auto-unlock with this passcode"),
396
+ ...nightlyOption,
397
+ },
398
+ run: sideload.install,
399
+ },
400
+ {
401
+ name: "update",
402
+ short: "Update to latest release",
403
+ args: [0, 0],
404
+ options: nightlyOption,
405
+ run: sideload.update,
406
+ },
407
+ {
408
+ name: "start",
409
+ short: "Trust developer if needed and open Swipt",
410
+ args: [0, 0],
411
+ options: passcodeOptions("unlock device with this passcode"),
412
+ run: sideload.start,
413
+ },
414
+ {
415
+ name: "uninstall",
416
+ short: "Remove Swipt",
417
+ args: [0, 0],
418
+ run: sideload.uninstall,
419
+ },
420
+ ],
421
+ },
350
422
  {
351
423
  name: "version",
352
424
  short: "Print the version",