@swipt/cli 0.1.0-nightly.44 → 0.1.0-nightly.45
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/coredevice.mjs +68 -0
- package/device.mjs +6 -0
- package/package.json +8 -3
- package/proto/system_pb.js +1 -1
- package/sideload/trust.js +235 -0
- package/sideload.mjs +649 -0
- package/swipt.mjs +76 -0
package/coredevice.mjs
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
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) {
|
|
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
|
+
: devices;
|
|
55
|
+
if (matches.length !== 1) {
|
|
56
|
+
const found = matches.length === 0 ? "no" : "more than one";
|
|
57
|
+
throw new Error(
|
|
58
|
+
given
|
|
59
|
+
? `${found} iOS device is named ${given}`
|
|
60
|
+
: `${found} iOS device is paired; choose one with -d NAME|UDID`,
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
return matches[0];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export const apps = (udid) =>
|
|
67
|
+
devicectl(["device", "info", "apps", "--device", udid], { parse: true })
|
|
68
|
+
.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.
|
|
3
|
+
"version": "0.1.0-nightly.45",
|
|
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.
|
|
35
|
+
"@swipt/api": "0.1.0-nightly.45"
|
|
36
|
+
},
|
|
37
|
+
"optionalDependencies": {
|
|
38
|
+
"@swipt/server-darwin-arm64": "0.1.0-nightly.45",
|
|
39
|
+
"@swipt/server-darwin-x64": "0.1.0-nightly.45"
|
|
35
40
|
},
|
|
36
41
|
"devDependencies": {
|
|
37
42
|
"@bufbuild/protoc-gen-es": "^2.16.0"
|
package/proto/system_pb.js
CHANGED
|
@@ -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("
|
|
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,649 @@
|
|
|
1
|
+
import { execFileSync, spawn, spawnSync } from "node:child_process";
|
|
2
|
+
import { 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 ?? process.env.SWIPT_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 || process.env.GH_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 || process.env.GH_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 locate(options, device) {
|
|
321
|
+
const found = apps(device.udid)
|
|
322
|
+
.map((app) => ({
|
|
323
|
+
...app,
|
|
324
|
+
team: sideloadedPattern.exec(app.bundleIdentifier)?.[1],
|
|
325
|
+
}))
|
|
326
|
+
.filter((app) => app.team !== undefined);
|
|
327
|
+
if (options.team !== undefined) {
|
|
328
|
+
if (!teamPattern.test(options.team)) {
|
|
329
|
+
throw new UsageError(
|
|
330
|
+
`invalid argument ${JSON.stringify(options.team)} for --team: it is the 10-character team ID shown in ${accountsHint}`,
|
|
331
|
+
);
|
|
332
|
+
}
|
|
333
|
+
return {
|
|
334
|
+
team: options.team,
|
|
335
|
+
current: found.find((app) => app.team === options.team),
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
if (found.length > 1) {
|
|
339
|
+
throw new Error(
|
|
340
|
+
`Swipt is sideloaded on ${device.name} by more than one team (${found.map((app) => app.team).join(", ")}); choose one with --team`,
|
|
341
|
+
);
|
|
342
|
+
}
|
|
343
|
+
return { team: found[0]?.team, current: found[0] };
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
const buildText = (app) => `Swipt ${app.version} (${app.bundleVersion})`;
|
|
347
|
+
|
|
348
|
+
async function deploy(context, onlyNewer) {
|
|
349
|
+
const passcode = passcodeOf(context.options);
|
|
350
|
+
const device = coreDevice(context.options.device ?? process.env.SWIPT_DEVICE);
|
|
351
|
+
const { team, current } = locate(context.options, device);
|
|
352
|
+
if (team === undefined) {
|
|
353
|
+
throw new Error(
|
|
354
|
+
onlyNewer
|
|
355
|
+
? `Swipt is not sideloaded on ${device.name}; install it with "swipt sideload install"`
|
|
356
|
+
: `Swipt is not sideloaded on ${device.name} yet; choose the team with --team ID, as shown in ${accountsHint}`,
|
|
357
|
+
);
|
|
358
|
+
}
|
|
359
|
+
const release = await findRelease(context.options.nightly, context.signal);
|
|
360
|
+
if (current !== undefined) {
|
|
361
|
+
const installed = Number(current.bundleVersion);
|
|
362
|
+
if (installed === release.build) {
|
|
363
|
+
console.log(
|
|
364
|
+
`${buildText(current)} on ${device.name} is already ${release.tag}`,
|
|
365
|
+
);
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
368
|
+
if (onlyNewer && installed > release.build) {
|
|
369
|
+
console.log(
|
|
370
|
+
`${buildText(current)} on ${device.name} is newer than ${release.tag} (${release.build})`,
|
|
371
|
+
);
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
const id = `${bundleId}.${team}`;
|
|
376
|
+
const work = fs.mkdtempSync(path.join(os.tmpdir(), "swipt-sideload-"));
|
|
377
|
+
try {
|
|
378
|
+
console.error(
|
|
379
|
+
`downloading Swipt ${release.version} (${release.build}) from ${release.tag}`,
|
|
380
|
+
);
|
|
381
|
+
const ipa = path.join(work, "Swipt.ipa");
|
|
382
|
+
await download(release.ipa, ipa, context.signal);
|
|
383
|
+
const app = unpack(ipa, work);
|
|
384
|
+
console.error(`signing ${id} with team ${team} through Xcode`);
|
|
385
|
+
resign(app, id, provision(work, id, team, device));
|
|
386
|
+
console.error(`installing on ${device.name}`);
|
|
387
|
+
devicectl(["device", "install", "app", "--device", device.udid, app]);
|
|
388
|
+
if (current === undefined) {
|
|
389
|
+
console.error(`pairing with ${device.name}`);
|
|
390
|
+
pair(device, id, work);
|
|
391
|
+
if (passcode !== undefined) {
|
|
392
|
+
setUpAutoUnlock(device, id, work, passcode);
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
} finally {
|
|
396
|
+
fs.rmSync(work, { recursive: true, force: true });
|
|
397
|
+
}
|
|
398
|
+
const result = locate({ team }, device).current;
|
|
399
|
+
if (result === undefined || Number(result.bundleVersion) !== release.build) {
|
|
400
|
+
throw new Error(
|
|
401
|
+
`${device.name} ${result === undefined ? `does not list ${id}` : `has ${buildText(result)}`} after the install`,
|
|
402
|
+
);
|
|
403
|
+
}
|
|
404
|
+
console.log(
|
|
405
|
+
`${buildText(result)} is ${current === undefined ? "installed" : "updated"} on ${device.name}`,
|
|
406
|
+
);
|
|
407
|
+
try {
|
|
408
|
+
devicectl([
|
|
409
|
+
"device",
|
|
410
|
+
"process",
|
|
411
|
+
"launch",
|
|
412
|
+
"--terminate-existing",
|
|
413
|
+
"--device",
|
|
414
|
+
device.udid,
|
|
415
|
+
id,
|
|
416
|
+
]);
|
|
417
|
+
} catch (error) {
|
|
418
|
+
if (/trusted by the user/.test(error.message)) {
|
|
419
|
+
console.log(
|
|
420
|
+
`it opens once the team's developer app is trusted in Settings › General › VPN & Device Management on ${device.name}`,
|
|
421
|
+
);
|
|
422
|
+
} else if (/BSErrorCodeDescription = Locked/.test(error.message)) {
|
|
423
|
+
console.log(`it opens once ${device.name} is unlocked`);
|
|
424
|
+
} else {
|
|
425
|
+
throw error;
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
export const install = (context) => deploy(context, false);
|
|
431
|
+
|
|
432
|
+
export const update = (context) => deploy(context, true);
|
|
433
|
+
|
|
434
|
+
const serverHint =
|
|
435
|
+
"swipt-server is missing; install @swipt/cli with its optional dependencies, or name it with SWIPT_SERVER_PATH";
|
|
436
|
+
|
|
437
|
+
function serverPath() {
|
|
438
|
+
if (process.env.SWIPT_SERVER_PATH) {
|
|
439
|
+
return process.env.SWIPT_SERVER_PATH;
|
|
440
|
+
}
|
|
441
|
+
let packaged;
|
|
442
|
+
try {
|
|
443
|
+
packaged = path.join(
|
|
444
|
+
path.dirname(
|
|
445
|
+
createRequire(import.meta.url).resolve(
|
|
446
|
+
`@swipt/server-${process.platform}-${process.arch}/package.json`,
|
|
447
|
+
),
|
|
448
|
+
),
|
|
449
|
+
"swipt-server",
|
|
450
|
+
);
|
|
451
|
+
} catch {
|
|
452
|
+
return "swipt-server";
|
|
453
|
+
}
|
|
454
|
+
return fs.existsSync(packaged) ? packaged : "swipt-server";
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
const schema = [
|
|
458
|
+
"CREATE TABLE grdb_migrations (identifier TEXT NOT NULL PRIMARY KEY)",
|
|
459
|
+
'CREATE TABLE "preferences" ("name" TEXT PRIMARY KEY NOT NULL, "data" BLOB NOT NULL)',
|
|
460
|
+
'CREATE TABLE "swiplets" ("id" TEXT PRIMARY KEY NOT NULL, "position" INTEGER NOT NULL, "etag" TEXT NOT NULL)',
|
|
461
|
+
'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)',
|
|
462
|
+
'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"))',
|
|
463
|
+
"INSERT INTO grdb_migrations VALUES ('0.1.0')",
|
|
464
|
+
];
|
|
465
|
+
|
|
466
|
+
function setUpAutoUnlock(device, id, work, passcode) {
|
|
467
|
+
const database = path.join(work, "Swipt.sqlite");
|
|
468
|
+
const preferences = {
|
|
469
|
+
serverAutoUnlocks: true,
|
|
470
|
+
serverAutoUnlockPasscodeType: passcodeTypes.indexOf(passcode.type),
|
|
471
|
+
serverAutoUnlockPasscode: passcode.passcode,
|
|
472
|
+
autoUnlockSetUp: true,
|
|
473
|
+
};
|
|
474
|
+
const quote = (text) => `'${text.replaceAll("'", "''")}'`;
|
|
475
|
+
execFileSync("sqlite3", [database], {
|
|
476
|
+
input: [
|
|
477
|
+
...schema,
|
|
478
|
+
...Object.entries(preferences).map(
|
|
479
|
+
([name, value]) =>
|
|
480
|
+
`INSERT INTO preferences VALUES (${quote(name)}, CAST(${quote(JSON.stringify(value))} AS BLOB))`,
|
|
481
|
+
),
|
|
482
|
+
]
|
|
483
|
+
.map((statement) => `${statement};\n`)
|
|
484
|
+
.join(""),
|
|
485
|
+
});
|
|
486
|
+
devicectl([
|
|
487
|
+
"device",
|
|
488
|
+
"copy",
|
|
489
|
+
"to",
|
|
490
|
+
"--device",
|
|
491
|
+
device.udid,
|
|
492
|
+
"--source",
|
|
493
|
+
database,
|
|
494
|
+
"--destination",
|
|
495
|
+
"Library/Application Support/Swipt.sqlite",
|
|
496
|
+
"--domain-type",
|
|
497
|
+
"appDataContainer",
|
|
498
|
+
"--domain-identifier",
|
|
499
|
+
id,
|
|
500
|
+
]);
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
function pair(device, id, work) {
|
|
504
|
+
const record = path.join(work, "PairingRecord.plist");
|
|
505
|
+
const result = spawnSync(
|
|
506
|
+
serverPath(),
|
|
507
|
+
["pair", "-d", device.udid, "-o", record],
|
|
508
|
+
{ encoding: "utf8", stdio: ["ignore", "ignore", "pipe"] },
|
|
509
|
+
);
|
|
510
|
+
if (result.error?.code === "ENOENT") {
|
|
511
|
+
console.error(`${serverHint}; Swipt pairs in Settings instead`);
|
|
512
|
+
return;
|
|
513
|
+
}
|
|
514
|
+
if (result.error) {
|
|
515
|
+
throw result.error;
|
|
516
|
+
}
|
|
517
|
+
if (result.status !== 0) {
|
|
518
|
+
console.error(
|
|
519
|
+
`pairing failed, so Swipt pairs in Settings instead: ${result.stderr.trim()}`,
|
|
520
|
+
);
|
|
521
|
+
return;
|
|
522
|
+
}
|
|
523
|
+
devicectl([
|
|
524
|
+
"device",
|
|
525
|
+
"copy",
|
|
526
|
+
"to",
|
|
527
|
+
"--device",
|
|
528
|
+
device.udid,
|
|
529
|
+
"--source",
|
|
530
|
+
record,
|
|
531
|
+
"--destination",
|
|
532
|
+
"Library/Application Support/PairingRecord.plist",
|
|
533
|
+
"--domain-type",
|
|
534
|
+
"appDataContainer",
|
|
535
|
+
"--domain-identifier",
|
|
536
|
+
id,
|
|
537
|
+
]);
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
function serve(udid, passcode, signal) {
|
|
541
|
+
const server = spawn(
|
|
542
|
+
serverPath(),
|
|
543
|
+
["-d", udid, ...(passcode ? ["--passcode-type", passcode.type] : [])],
|
|
544
|
+
{
|
|
545
|
+
env: {
|
|
546
|
+
...process.env,
|
|
547
|
+
...(passcode ? { SWIPT_PASSCODE: passcode.passcode } : {}),
|
|
548
|
+
},
|
|
549
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
550
|
+
signal,
|
|
551
|
+
},
|
|
552
|
+
);
|
|
553
|
+
let logs = "";
|
|
554
|
+
server.stderr.on("data", (chunk) => {
|
|
555
|
+
logs += chunk;
|
|
556
|
+
});
|
|
557
|
+
const exited = new Promise((resolve) => server.once("close", resolve));
|
|
558
|
+
const url = new Promise((resolve, reject) => {
|
|
559
|
+
server.once("error", (error) =>
|
|
560
|
+
reject(error.code === "ENOENT" ? new Error(serverHint) : error),
|
|
561
|
+
);
|
|
562
|
+
exited.then((code) =>
|
|
563
|
+
reject(
|
|
564
|
+
new Error(
|
|
565
|
+
`swipt-server exited with ${code ?? "a signal"}${logs.trim() === "" ? "" : `: ${logs.trim()}`}`,
|
|
566
|
+
),
|
|
567
|
+
),
|
|
568
|
+
);
|
|
569
|
+
readline.createInterface({ input: server.stdout }).on("line", (line) => {
|
|
570
|
+
const found = /^Swipt API: (\S+)/.exec(line);
|
|
571
|
+
if (found) {
|
|
572
|
+
const parsed = new URL(found[1]);
|
|
573
|
+
parsed.hostname = "127.0.0.1";
|
|
574
|
+
resolve(parsed.origin);
|
|
575
|
+
}
|
|
576
|
+
});
|
|
577
|
+
});
|
|
578
|
+
return {
|
|
579
|
+
url,
|
|
580
|
+
stop() {
|
|
581
|
+
server.kill("SIGTERM");
|
|
582
|
+
return exited;
|
|
583
|
+
},
|
|
584
|
+
};
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
export async function start(context) {
|
|
588
|
+
const passcode = passcodeOf(context.options);
|
|
589
|
+
const device = coreDevice(context.options.device ?? process.env.SWIPT_DEVICE);
|
|
590
|
+
const { team, current } = locate(context.options, device);
|
|
591
|
+
if (current === undefined) {
|
|
592
|
+
throw new Error(
|
|
593
|
+
`Swipt is not sideloaded on ${device.name}${team === undefined ? "" : ` by team ${team}`}`,
|
|
594
|
+
);
|
|
595
|
+
}
|
|
596
|
+
const server = serve(device.udid, passcode, context.signal);
|
|
597
|
+
try {
|
|
598
|
+
const client = open({ url: await server.url, token: "" });
|
|
599
|
+
let failure;
|
|
600
|
+
for await (const response of client.repl.execute(
|
|
601
|
+
{
|
|
602
|
+
code: fs.readFileSync(
|
|
603
|
+
new URL("sideload/trust.js", import.meta.url),
|
|
604
|
+
"utf8",
|
|
605
|
+
),
|
|
606
|
+
args: [current.bundleIdentifier],
|
|
607
|
+
program: true,
|
|
608
|
+
},
|
|
609
|
+
{ signal: context.signal },
|
|
610
|
+
)) {
|
|
611
|
+
switch (response.part.case) {
|
|
612
|
+
case "console": {
|
|
613
|
+
const output = response.part.value;
|
|
614
|
+
(output.level === LogLevel.ERROR || output.level === LogLevel.WARN
|
|
615
|
+
? console.error
|
|
616
|
+
: console.log)(output.text);
|
|
617
|
+
break;
|
|
618
|
+
}
|
|
619
|
+
case "error":
|
|
620
|
+
failure = response.part.value;
|
|
621
|
+
break;
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
if (failure) {
|
|
625
|
+
throw printScriptError(failure);
|
|
626
|
+
}
|
|
627
|
+
} finally {
|
|
628
|
+
await server.stop();
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
export function uninstall(context) {
|
|
633
|
+
const device = coreDevice(context.options.device ?? process.env.SWIPT_DEVICE);
|
|
634
|
+
const { team, current } = locate(context.options, device);
|
|
635
|
+
if (current === undefined) {
|
|
636
|
+
throw new Error(
|
|
637
|
+
`Swipt is not sideloaded on ${device.name}${team === undefined ? "" : ` by team ${team}`}`,
|
|
638
|
+
);
|
|
639
|
+
}
|
|
640
|
+
devicectl([
|
|
641
|
+
"device",
|
|
642
|
+
"uninstall",
|
|
643
|
+
"app",
|
|
644
|
+
"--device",
|
|
645
|
+
device.udid,
|
|
646
|
+
current.bundleIdentifier,
|
|
647
|
+
]);
|
|
648
|
+
console.log(`removed ${buildText(current)} from ${device.name}`);
|
|
649
|
+
}
|
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" },
|
|
@@ -21,6 +22,40 @@ const deviceOption = {
|
|
|
21
22
|
},
|
|
22
23
|
};
|
|
23
24
|
|
|
25
|
+
const sideloadOptions = {
|
|
26
|
+
device: {
|
|
27
|
+
type: "string",
|
|
28
|
+
short: "d",
|
|
29
|
+
value: "NAME|UDID",
|
|
30
|
+
help: "the paired iOS device (SWIPT_DEVICE)",
|
|
31
|
+
},
|
|
32
|
+
team: {
|
|
33
|
+
type: "string",
|
|
34
|
+
value: "ID",
|
|
35
|
+
help: "the team ID (default: the team of the installed Swipt)",
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const passcodeOptions = {
|
|
40
|
+
passcode: {
|
|
41
|
+
type: "string",
|
|
42
|
+
value: "CODE",
|
|
43
|
+
help: "the device passcode, for unlocking it (SWIPT_PASSCODE)",
|
|
44
|
+
},
|
|
45
|
+
"passcode-type": {
|
|
46
|
+
type: "string",
|
|
47
|
+
value: "TYPE",
|
|
48
|
+
help: "pin for 4 or 6 digits, numeric or alphanumeric for a custom code (default pin)",
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const nightlyOption = {
|
|
53
|
+
nightly: {
|
|
54
|
+
type: "boolean",
|
|
55
|
+
help: "the nightly build",
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
|
|
24
59
|
const durationUnits = {
|
|
25
60
|
ns: 1e-6,
|
|
26
61
|
us: 1e-3,
|
|
@@ -195,6 +230,13 @@ const commands = [
|
|
|
195
230
|
args: [1, 1],
|
|
196
231
|
run: connection.remove,
|
|
197
232
|
},
|
|
233
|
+
{
|
|
234
|
+
name: "stop",
|
|
235
|
+
short: "Stop the Swipt server",
|
|
236
|
+
args: [0, 0],
|
|
237
|
+
options: deviceOption,
|
|
238
|
+
run: device.stop,
|
|
239
|
+
},
|
|
198
240
|
{
|
|
199
241
|
name: "logs",
|
|
200
242
|
short: "Print the logs",
|
|
@@ -347,6 +389,40 @@ const commands = [
|
|
|
347
389
|
dataCommands("", device.replData),
|
|
348
390
|
],
|
|
349
391
|
},
|
|
392
|
+
{
|
|
393
|
+
name: "sideload",
|
|
394
|
+
short: "Install the Swipt app on a paired device",
|
|
395
|
+
options: sideloadOptions,
|
|
396
|
+
commands: [
|
|
397
|
+
{
|
|
398
|
+
name: "install",
|
|
399
|
+
short: "Install the latest release",
|
|
400
|
+
args: [0, 0],
|
|
401
|
+
options: { ...passcodeOptions, ...nightlyOption },
|
|
402
|
+
run: sideload.install,
|
|
403
|
+
},
|
|
404
|
+
{
|
|
405
|
+
name: "update",
|
|
406
|
+
short: "Install the latest release if it is newer",
|
|
407
|
+
args: [0, 0],
|
|
408
|
+
options: { ...passcodeOptions, ...nightlyOption },
|
|
409
|
+
run: sideload.update,
|
|
410
|
+
},
|
|
411
|
+
{
|
|
412
|
+
name: "start",
|
|
413
|
+
short: "Open the app, trusting its developer if needed",
|
|
414
|
+
args: [0, 0],
|
|
415
|
+
options: passcodeOptions,
|
|
416
|
+
run: sideload.start,
|
|
417
|
+
},
|
|
418
|
+
{
|
|
419
|
+
name: "uninstall",
|
|
420
|
+
short: "Remove the app",
|
|
421
|
+
args: [0, 0],
|
|
422
|
+
run: sideload.uninstall,
|
|
423
|
+
},
|
|
424
|
+
],
|
|
425
|
+
},
|
|
350
426
|
{
|
|
351
427
|
name: "version",
|
|
352
428
|
short: "Print the version",
|