kg-ios-usb-app-info 0.1.0
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/bin/kg-ios-usb-app-info.mjs +260 -0
- package/lib/kg-ios-usb-debug.mjs +565 -0
- package/package.json +22 -0
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
findDebugServers,
|
|
5
|
+
getUiTree,
|
|
6
|
+
debugScan
|
|
7
|
+
} from '../lib/kg-ios-usb-debug.mjs';
|
|
8
|
+
|
|
9
|
+
const VERSION = '0.1.0';
|
|
10
|
+
|
|
11
|
+
const args = process.argv.slice(2);
|
|
12
|
+
const command = args[0];
|
|
13
|
+
|
|
14
|
+
function getArgValue(name) {
|
|
15
|
+
const index = args.indexOf(name);
|
|
16
|
+
if (index < 0) return undefined;
|
|
17
|
+
|
|
18
|
+
const value = args[index + 1];
|
|
19
|
+
if (!value || value.startsWith('--')) {
|
|
20
|
+
return undefined;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
return value;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function hasArg(name) {
|
|
27
|
+
return args.includes(name);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function parseInteger(value, fallback) {
|
|
31
|
+
if (!value) return fallback;
|
|
32
|
+
|
|
33
|
+
const number = Number(value);
|
|
34
|
+
if (!Number.isInteger(number)) {
|
|
35
|
+
return fallback;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return number;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function parsePorts(value) {
|
|
42
|
+
if (!value) return undefined;
|
|
43
|
+
|
|
44
|
+
const ports = [];
|
|
45
|
+
|
|
46
|
+
for (const part of value.split(',')) {
|
|
47
|
+
const item = part.trim();
|
|
48
|
+
if (!item) continue;
|
|
49
|
+
|
|
50
|
+
if (item.includes('-')) {
|
|
51
|
+
const [startText, endText] = item.split('-');
|
|
52
|
+
const start = Number(startText);
|
|
53
|
+
const end = Number(endText);
|
|
54
|
+
|
|
55
|
+
if (
|
|
56
|
+
Number.isInteger(start) &&
|
|
57
|
+
Number.isInteger(end) &&
|
|
58
|
+
start > 0 &&
|
|
59
|
+
end >= start
|
|
60
|
+
) {
|
|
61
|
+
for (let port = start; port <= end; port++) {
|
|
62
|
+
ports.push(port);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const port = Number(item);
|
|
70
|
+
if (Number.isInteger(port) && port > 0) {
|
|
71
|
+
ports.push(port);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return ports.length ? [...new Set(ports)] : undefined;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function printJson(value, pretty = true) {
|
|
79
|
+
console.log(JSON.stringify(value, null, pretty ? 2 : 0));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function printHelp() {
|
|
83
|
+
console.log(`
|
|
84
|
+
kg-ios-usb-app-info
|
|
85
|
+
|
|
86
|
+
Usage:
|
|
87
|
+
kg-ios-usb-app-info list
|
|
88
|
+
kg-ios-usb-app-info tree
|
|
89
|
+
kg-ios-usb-app-info tree --bundle-id com.xxx.app
|
|
90
|
+
kg-ios-usb-app-info tree --raw
|
|
91
|
+
kg-ios-usb-app-info scan
|
|
92
|
+
kg-ios-usb-app-info help
|
|
93
|
+
|
|
94
|
+
Commands:
|
|
95
|
+
list List iOS Debug UI Servers over USB
|
|
96
|
+
tree Read current iOS app UI hierarchy over USB
|
|
97
|
+
scan Diagnose USB devices and debug ports
|
|
98
|
+
help Show help
|
|
99
|
+
version Show version
|
|
100
|
+
|
|
101
|
+
Options:
|
|
102
|
+
--bundle-id <id> Specify app bundleId when multiple apps are found
|
|
103
|
+
--ports <ports> Specify ports, e.g. 18080 or 18080,18081 or 18080-18100
|
|
104
|
+
--server-name <name> Specify debug server name, default KGDebugUIServer
|
|
105
|
+
--concurrency <n> Specify scan concurrency, default from lib
|
|
106
|
+
--compact Output compact JSON
|
|
107
|
+
--raw For tree command, output only raw UI tree JSON
|
|
108
|
+
|
|
109
|
+
Environment:
|
|
110
|
+
KG_IOS_USB_PORTS Default ports, e.g. 18080,18081 or 18080-18100
|
|
111
|
+
KG_IOS_USB_SERVER_NAME Default server name
|
|
112
|
+
KG_IOS_USB_DEBUG_TOKEN Debug token sent as X-Debug-Token
|
|
113
|
+
KG_IOS_USB_CONNECT_TIMEOUT_MS USB connect timeout
|
|
114
|
+
KG_IOS_USB_READ_TIMEOUT_MS HTTP read timeout
|
|
115
|
+
KG_IOS_USB_TREE_TIMEOUT_MS UI tree read timeout
|
|
116
|
+
|
|
117
|
+
Examples:
|
|
118
|
+
kg-ios-usb-app-info list
|
|
119
|
+
kg-ios-usb-app-info tree
|
|
120
|
+
kg-ios-usb-app-info tree --bundle-id com.kugou.tingshu
|
|
121
|
+
kg-ios-usb-app-info tree --ports 18080 --raw
|
|
122
|
+
kg-ios-usb-app-info scan --ports 18080-18100
|
|
123
|
+
`);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function printVersion() {
|
|
127
|
+
console.log(`kg-ios-usb-app-info ${VERSION}`);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function buildOptions() {
|
|
131
|
+
const bundleId = getArgValue('--bundle-id');
|
|
132
|
+
const ports = parsePorts(getArgValue('--ports'));
|
|
133
|
+
const serverName = getArgValue('--server-name');
|
|
134
|
+
const concurrency = parseInteger(getArgValue('--concurrency'), undefined);
|
|
135
|
+
|
|
136
|
+
const options = {};
|
|
137
|
+
|
|
138
|
+
if (bundleId) {
|
|
139
|
+
options.bundleId = bundleId;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (ports) {
|
|
143
|
+
options.ports = ports;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (serverName) {
|
|
147
|
+
options.serverName = serverName;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (concurrency) {
|
|
151
|
+
options.concurrency = concurrency;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return options;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async function handleList(cliOptions) {
|
|
158
|
+
const servers = await findDebugServers(cliOptions.queryOptions);
|
|
159
|
+
|
|
160
|
+
printJson({
|
|
161
|
+
ok: true,
|
|
162
|
+
count: servers.length,
|
|
163
|
+
servers
|
|
164
|
+
}, cliOptions.pretty);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async function handleTree(cliOptions) {
|
|
168
|
+
const result = await getUiTree(cliOptions.queryOptions);
|
|
169
|
+
|
|
170
|
+
if (cliOptions.raw) {
|
|
171
|
+
printJson(result.tree, cliOptions.pretty);
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
printJson({
|
|
176
|
+
ok: true,
|
|
177
|
+
target: result.target,
|
|
178
|
+
tree: result.tree
|
|
179
|
+
}, cliOptions.pretty);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async function handleScan(cliOptions) {
|
|
183
|
+
const result = await debugScan(cliOptions.queryOptions);
|
|
184
|
+
|
|
185
|
+
printJson({
|
|
186
|
+
ok: true,
|
|
187
|
+
...result
|
|
188
|
+
}, cliOptions.pretty);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
async function main() {
|
|
192
|
+
const pretty = !hasArg('--compact');
|
|
193
|
+
const raw = hasArg('--raw');
|
|
194
|
+
|
|
195
|
+
const cliOptions = {
|
|
196
|
+
pretty,
|
|
197
|
+
raw,
|
|
198
|
+
queryOptions: buildOptions()
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
if (
|
|
202
|
+
!command ||
|
|
203
|
+
command === 'help' ||
|
|
204
|
+
command === '--help' ||
|
|
205
|
+
command === '-h'
|
|
206
|
+
) {
|
|
207
|
+
printHelp();
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if (
|
|
212
|
+
command === 'version' ||
|
|
213
|
+
command === '--version' ||
|
|
214
|
+
command === '-v'
|
|
215
|
+
) {
|
|
216
|
+
printVersion();
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
if (command === 'list') {
|
|
221
|
+
await handleList(cliOptions);
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
if (command === 'tree') {
|
|
226
|
+
await handleTree(cliOptions);
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
if (command === 'scan') {
|
|
231
|
+
await handleScan(cliOptions);
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
throw new Error(`Unknown command: ${command}`);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function flushStdout() {
|
|
239
|
+
return new Promise(resolve => {
|
|
240
|
+
process.stdout.write('', resolve);
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
main()
|
|
245
|
+
.then(async () => {
|
|
246
|
+
await flushStdout();
|
|
247
|
+
process.exit(0);
|
|
248
|
+
})
|
|
249
|
+
.catch(async error => {
|
|
250
|
+
printJson({
|
|
251
|
+
ok: false,
|
|
252
|
+
error: error?.message || String(error)
|
|
253
|
+
}, true);
|
|
254
|
+
|
|
255
|
+
await flushStdout();
|
|
256
|
+
process.exit(1);
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
|
|
@@ -0,0 +1,565 @@
|
|
|
1
|
+
import { UsbmuxClient } from 'usbmux-client';
|
|
2
|
+
|
|
3
|
+
const DEFAULT_SERVER_NAME = process.env.KG_IOS_USB_SERVER_NAME || 'KGDebugUIServer';
|
|
4
|
+
|
|
5
|
+
// 默认扫描 18080~18100
|
|
6
|
+
// 也可以通过环境变量指定:
|
|
7
|
+
// KG_IOS_USB_PORTS=18080
|
|
8
|
+
// KG_IOS_USB_PORTS=18080,18081,18090-18100
|
|
9
|
+
const DEFAULT_PORTS = parsePorts(process.env.KG_IOS_USB_PORTS) ||
|
|
10
|
+
Array.from({ length: 21 }, (_, i) => 18080 + i);
|
|
11
|
+
|
|
12
|
+
const DEFAULT_CONNECT_TIMEOUT_MS = Number(process.env.KG_IOS_USB_CONNECT_TIMEOUT_MS || 1200);
|
|
13
|
+
const DEFAULT_READ_TIMEOUT_MS = Number(process.env.KG_IOS_USB_READ_TIMEOUT_MS || 2000);
|
|
14
|
+
const TREE_READ_TIMEOUT_MS = Number(process.env.KG_IOS_USB_TREE_TIMEOUT_MS || 15000);
|
|
15
|
+
const MAX_RESPONSE_BYTES = Number(process.env.KG_IOS_USB_MAX_RESPONSE_BYTES || 50 * 1024 * 1024);
|
|
16
|
+
|
|
17
|
+
function parsePorts(value) {
|
|
18
|
+
if (!value) return null;
|
|
19
|
+
|
|
20
|
+
const ports = [];
|
|
21
|
+
|
|
22
|
+
for (const part of value.split(',')) {
|
|
23
|
+
const item = part.trim();
|
|
24
|
+
if (!item) continue;
|
|
25
|
+
|
|
26
|
+
if (item.includes('-')) {
|
|
27
|
+
const [startText, endText] = item.split('-');
|
|
28
|
+
const start = Number(startText);
|
|
29
|
+
const end = Number(endText);
|
|
30
|
+
|
|
31
|
+
if (
|
|
32
|
+
Number.isInteger(start) &&
|
|
33
|
+
Number.isInteger(end) &&
|
|
34
|
+
start > 0 &&
|
|
35
|
+
end >= start
|
|
36
|
+
) {
|
|
37
|
+
for (let port = start; port <= end; port++) {
|
|
38
|
+
ports.push(port);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const port = Number(item);
|
|
46
|
+
if (Number.isInteger(port) && port > 0) {
|
|
47
|
+
ports.push(port);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return ports.length ? [...new Set(ports)] : null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function normalizeDevices(devices) {
|
|
55
|
+
if (Array.isArray(devices)) {
|
|
56
|
+
return devices.map((raw, index) => {
|
|
57
|
+
const id = raw.DeviceID ?? raw.deviceId ?? raw.id ?? index;
|
|
58
|
+
return {
|
|
59
|
+
id,
|
|
60
|
+
raw
|
|
61
|
+
};
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return Object.entries(devices || {}).map(([id, raw]) => ({
|
|
66
|
+
id,
|
|
67
|
+
raw
|
|
68
|
+
}));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function createTimeoutError(label, timeoutMs) {
|
|
72
|
+
return new Error(`${label} timeout after ${timeoutMs}ms`);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function withTimeout(promise, timeoutMs, label) {
|
|
76
|
+
let timer;
|
|
77
|
+
|
|
78
|
+
const timeoutPromise = new Promise((_, reject) => {
|
|
79
|
+
timer = setTimeout(() => {
|
|
80
|
+
reject(createTimeoutError(label, timeoutMs));
|
|
81
|
+
}, timeoutMs);
|
|
82
|
+
|
|
83
|
+
if (typeof timer.unref === 'function') {
|
|
84
|
+
timer.unref();
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
return Promise.race([promise, timeoutPromise]).finally(() => {
|
|
89
|
+
clearTimeout(timer);
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function safeDestroySocket(socket) {
|
|
94
|
+
if (!socket) return;
|
|
95
|
+
|
|
96
|
+
try {
|
|
97
|
+
socket.destroy();
|
|
98
|
+
} catch {
|
|
99
|
+
// ignore
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function parseHeaderLines(headerText) {
|
|
104
|
+
const lines = headerText.split('\r\n');
|
|
105
|
+
const statusLine = lines.shift() || '';
|
|
106
|
+
|
|
107
|
+
const headers = {};
|
|
108
|
+
for (const line of lines) {
|
|
109
|
+
const index = line.indexOf(':');
|
|
110
|
+
if (index < 0) continue;
|
|
111
|
+
|
|
112
|
+
const key = line.slice(0, index).trim().toLowerCase();
|
|
113
|
+
const value = line.slice(index + 1).trim();
|
|
114
|
+
|
|
115
|
+
if (!key) continue;
|
|
116
|
+
headers[key] = value;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const statusMatch = statusLine.match(/^HTTP\/\d(?:\.\d)?\s+(\d+)/i);
|
|
120
|
+
const statusCode = statusMatch ? Number(statusMatch[1]) : 0;
|
|
121
|
+
|
|
122
|
+
return {
|
|
123
|
+
statusLine,
|
|
124
|
+
statusCode,
|
|
125
|
+
headers
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function decodeChunkedBody(buffer) {
|
|
130
|
+
let offset = 0;
|
|
131
|
+
const chunks = [];
|
|
132
|
+
|
|
133
|
+
while (offset < buffer.length) {
|
|
134
|
+
const lineEnd = buffer.indexOf(Buffer.from('\r\n'), offset);
|
|
135
|
+
if (lineEnd < 0) break;
|
|
136
|
+
|
|
137
|
+
const sizeLine = buffer.subarray(offset, lineEnd).toString('utf8').trim();
|
|
138
|
+
const semicolonIndex = sizeLine.indexOf(';');
|
|
139
|
+
const sizeText = semicolonIndex >= 0 ? sizeLine.slice(0, semicolonIndex) : sizeLine;
|
|
140
|
+
const size = Number.parseInt(sizeText, 16);
|
|
141
|
+
|
|
142
|
+
if (!Number.isFinite(size)) break;
|
|
143
|
+
|
|
144
|
+
offset = lineEnd + 2;
|
|
145
|
+
|
|
146
|
+
if (size === 0) {
|
|
147
|
+
break;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const chunkEnd = offset + size;
|
|
151
|
+
if (chunkEnd > buffer.length) break;
|
|
152
|
+
|
|
153
|
+
chunks.push(buffer.subarray(offset, chunkEnd));
|
|
154
|
+
offset = chunkEnd + 2;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return Buffer.concat(chunks);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function parseHttpResponse(buffer) {
|
|
161
|
+
const headerEnd = buffer.indexOf(Buffer.from('\r\n\r\n'));
|
|
162
|
+
|
|
163
|
+
if (headerEnd < 0) {
|
|
164
|
+
return {
|
|
165
|
+
statusCode: 0,
|
|
166
|
+
statusLine: '',
|
|
167
|
+
headers: {},
|
|
168
|
+
rawHeaders: '',
|
|
169
|
+
bodyBuffer: buffer,
|
|
170
|
+
body: buffer.toString('utf8')
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const rawHeaders = buffer.subarray(0, headerEnd).toString('utf8');
|
|
175
|
+
const parsed = parseHeaderLines(rawHeaders);
|
|
176
|
+
|
|
177
|
+
let bodyBuffer = buffer.subarray(headerEnd + 4);
|
|
178
|
+
|
|
179
|
+
const transferEncoding = parsed.headers['transfer-encoding'] || '';
|
|
180
|
+
if (transferEncoding.toLowerCase().includes('chunked')) {
|
|
181
|
+
bodyBuffer = decodeChunkedBody(bodyBuffer);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return {
|
|
185
|
+
statusCode: parsed.statusCode,
|
|
186
|
+
statusLine: parsed.statusLine,
|
|
187
|
+
headers: parsed.headers,
|
|
188
|
+
rawHeaders,
|
|
189
|
+
bodyBuffer,
|
|
190
|
+
body: bodyBuffer.toString('utf8')
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function hasCompleteHttpResponse(buffer) {
|
|
195
|
+
const headerEnd = buffer.indexOf(Buffer.from('\r\n\r\n'));
|
|
196
|
+
if (headerEnd < 0) return false;
|
|
197
|
+
|
|
198
|
+
const headerText = buffer.subarray(0, headerEnd).toString('utf8');
|
|
199
|
+
const { headers } = parseHeaderLines(headerText);
|
|
200
|
+
|
|
201
|
+
const contentLengthText = headers['content-length'];
|
|
202
|
+
if (contentLengthText) {
|
|
203
|
+
const contentLength = Number(contentLengthText);
|
|
204
|
+
if (Number.isFinite(contentLength)) {
|
|
205
|
+
const totalLength = headerEnd + 4 + contentLength;
|
|
206
|
+
return buffer.length >= totalLength;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const transferEncoding = headers['transfer-encoding'] || '';
|
|
211
|
+
if (transferEncoding.toLowerCase().includes('chunked')) {
|
|
212
|
+
return buffer.includes(Buffer.from('\r\n0\r\n\r\n'), headerEnd + 4);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
return false;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function readHttpResponse(socket, timeoutMs = DEFAULT_READ_TIMEOUT_MS) {
|
|
219
|
+
return new Promise((resolve, reject) => {
|
|
220
|
+
let buffer = Buffer.alloc(0);
|
|
221
|
+
let finished = false;
|
|
222
|
+
let timer = null;
|
|
223
|
+
|
|
224
|
+
function resetTimer() {
|
|
225
|
+
clearTimeout(timer);
|
|
226
|
+
|
|
227
|
+
timer = setTimeout(() => {
|
|
228
|
+
if (buffer.length > 0) {
|
|
229
|
+
finish(null, buffer);
|
|
230
|
+
} else {
|
|
231
|
+
finish(createTimeoutError('HTTP read', timeoutMs));
|
|
232
|
+
}
|
|
233
|
+
}, timeoutMs);
|
|
234
|
+
|
|
235
|
+
if (typeof timer.unref === 'function') {
|
|
236
|
+
timer.unref();
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function cleanup() {
|
|
241
|
+
clearTimeout(timer);
|
|
242
|
+
socket.off('data', onData);
|
|
243
|
+
socket.off('end', onEnd);
|
|
244
|
+
socket.off('close', onClose);
|
|
245
|
+
socket.off('error', onError);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function finish(error, data) {
|
|
249
|
+
if (finished) return;
|
|
250
|
+
finished = true;
|
|
251
|
+
|
|
252
|
+
cleanup();
|
|
253
|
+
|
|
254
|
+
if (error) {
|
|
255
|
+
reject(error);
|
|
256
|
+
} else {
|
|
257
|
+
resolve(data || buffer);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function onData(chunk) {
|
|
262
|
+
buffer = Buffer.concat([buffer, chunk]);
|
|
263
|
+
|
|
264
|
+
if (buffer.length > MAX_RESPONSE_BYTES) {
|
|
265
|
+
finish(new Error(`HTTP response too large: ${buffer.length} bytes`));
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
if (hasCompleteHttpResponse(buffer)) {
|
|
270
|
+
finish(null, buffer);
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
resetTimer();
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function onEnd() {
|
|
278
|
+
finish(null, buffer);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function onClose() {
|
|
282
|
+
if (buffer.length > 0) {
|
|
283
|
+
finish(null, buffer);
|
|
284
|
+
} else {
|
|
285
|
+
finish(new Error('socket closed without response'));
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function onError(error) {
|
|
290
|
+
finish(error);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
socket.on('data', onData);
|
|
294
|
+
socket.on('end', onEnd);
|
|
295
|
+
socket.on('close', onClose);
|
|
296
|
+
socket.on('error', onError);
|
|
297
|
+
|
|
298
|
+
resetTimer();
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
async function requestDeviceHttp(client, deviceId, port, path, options = {}) {
|
|
303
|
+
const {
|
|
304
|
+
connectTimeoutMs = DEFAULT_CONNECT_TIMEOUT_MS,
|
|
305
|
+
readTimeoutMs = DEFAULT_READ_TIMEOUT_MS,
|
|
306
|
+
method = 'GET'
|
|
307
|
+
} = options;
|
|
308
|
+
|
|
309
|
+
let socket;
|
|
310
|
+
|
|
311
|
+
try {
|
|
312
|
+
socket = await withTimeout(
|
|
313
|
+
client.createDeviceTunnel(deviceId, port),
|
|
314
|
+
connectTimeoutMs,
|
|
315
|
+
`connect device=${deviceId} port=${port}`
|
|
316
|
+
);
|
|
317
|
+
|
|
318
|
+
const headers = [
|
|
319
|
+
`${method} ${path} HTTP/1.1`,
|
|
320
|
+
'Host: 127.0.0.1',
|
|
321
|
+
'Accept: application/json',
|
|
322
|
+
'User-Agent: kg-ios-usb-app-info/0.1.0',
|
|
323
|
+
'Connection: close'
|
|
324
|
+
];
|
|
325
|
+
|
|
326
|
+
const token = process.env.KG_IOS_USB_DEBUG_TOKEN;
|
|
327
|
+
if (token) {
|
|
328
|
+
headers.push(`X-Debug-Token: ${token}`);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
headers.push('', '');
|
|
332
|
+
|
|
333
|
+
socket.write(headers.join('\r\n'));
|
|
334
|
+
|
|
335
|
+
const responseBuffer = await readHttpResponse(socket, readTimeoutMs);
|
|
336
|
+
return parseHttpResponse(responseBuffer);
|
|
337
|
+
} finally {
|
|
338
|
+
safeDestroySocket(socket);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
async function requestDeviceJson(client, deviceId, port, path, options = {}) {
|
|
343
|
+
const response = await requestDeviceHttp(client, deviceId, port, path, options);
|
|
344
|
+
|
|
345
|
+
if (response.statusCode && (response.statusCode < 200 || response.statusCode >= 300)) {
|
|
346
|
+
throw new Error(
|
|
347
|
+
`HTTP ${response.statusCode} from device=${deviceId} port=${port} path=${path}: ` +
|
|
348
|
+
response.body.slice(0, 300)
|
|
349
|
+
);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
try {
|
|
353
|
+
return JSON.parse(response.body);
|
|
354
|
+
} catch (error) {
|
|
355
|
+
throw new Error(
|
|
356
|
+
`Invalid JSON from device=${deviceId} port=${port} path=${path}: ` +
|
|
357
|
+
`${error.message}; body=${response.body.slice(0, 300)}`
|
|
358
|
+
);
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
async function getDevices() {
|
|
363
|
+
const client = new UsbmuxClient();
|
|
364
|
+
|
|
365
|
+
const rawDevices = await withTimeout(
|
|
366
|
+
client.getDevices(),
|
|
367
|
+
3000,
|
|
368
|
+
'get USB devices'
|
|
369
|
+
);
|
|
370
|
+
|
|
371
|
+
return {
|
|
372
|
+
client,
|
|
373
|
+
devices: normalizeDevices(rawDevices),
|
|
374
|
+
rawDevices
|
|
375
|
+
};
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
async function mapWithConcurrency(items, concurrency, mapper) {
|
|
379
|
+
const results = new Array(items.length);
|
|
380
|
+
let nextIndex = 0;
|
|
381
|
+
|
|
382
|
+
async function worker() {
|
|
383
|
+
while (nextIndex < items.length) {
|
|
384
|
+
const currentIndex = nextIndex;
|
|
385
|
+
nextIndex += 1;
|
|
386
|
+
|
|
387
|
+
results[currentIndex] = await mapper(items[currentIndex], currentIndex);
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
const workerCount = Math.min(concurrency, items.length);
|
|
392
|
+
await Promise.all(Array.from({ length: workerCount }, worker));
|
|
393
|
+
|
|
394
|
+
return results;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function buildCandidates(devices, ports) {
|
|
398
|
+
const candidates = [];
|
|
399
|
+
|
|
400
|
+
for (const device of devices) {
|
|
401
|
+
for (const port of ports) {
|
|
402
|
+
candidates.push({
|
|
403
|
+
device,
|
|
404
|
+
port
|
|
405
|
+
});
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
return candidates;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
export async function findDebugServers(options = {}) {
|
|
413
|
+
const {
|
|
414
|
+
bundleId,
|
|
415
|
+
ports = DEFAULT_PORTS,
|
|
416
|
+
serverName = DEFAULT_SERVER_NAME,
|
|
417
|
+
concurrency = 6
|
|
418
|
+
} = options;
|
|
419
|
+
|
|
420
|
+
const { client, devices } = await getDevices();
|
|
421
|
+
const candidates = buildCandidates(devices, ports);
|
|
422
|
+
|
|
423
|
+
const results = await mapWithConcurrency(
|
|
424
|
+
candidates,
|
|
425
|
+
concurrency,
|
|
426
|
+
async ({ device, port }) => {
|
|
427
|
+
try {
|
|
428
|
+
const json = await requestDeviceJson(
|
|
429
|
+
client,
|
|
430
|
+
device.id,
|
|
431
|
+
port,
|
|
432
|
+
'/debug/ping',
|
|
433
|
+
{
|
|
434
|
+
connectTimeoutMs: DEFAULT_CONNECT_TIMEOUT_MS,
|
|
435
|
+
readTimeoutMs: DEFAULT_READ_TIMEOUT_MS
|
|
436
|
+
}
|
|
437
|
+
);
|
|
438
|
+
|
|
439
|
+
if (json?.server !== serverName) {
|
|
440
|
+
return null;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
if (bundleId && json.bundleId !== bundleId) {
|
|
444
|
+
return null;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
return {
|
|
448
|
+
deviceId: device.id,
|
|
449
|
+
port,
|
|
450
|
+
bundleId: json.bundleId || '',
|
|
451
|
+
appName: json.appName || '',
|
|
452
|
+
server: json.server || '',
|
|
453
|
+
ping: json,
|
|
454
|
+
device: device.raw
|
|
455
|
+
};
|
|
456
|
+
} catch {
|
|
457
|
+
return null;
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
);
|
|
461
|
+
|
|
462
|
+
return results.filter(Boolean);
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
async function getSingleDebugServer(options = {}) {
|
|
466
|
+
const { bundleId } = options;
|
|
467
|
+
const servers = await findDebugServers(options);
|
|
468
|
+
|
|
469
|
+
if (servers.length === 0) {
|
|
470
|
+
throw new Error(
|
|
471
|
+
bundleId
|
|
472
|
+
? `没有找到 bundleId=${bundleId} 的 iOS Debug UI Server`
|
|
473
|
+
: '没有找到 iOS Debug UI Server。请确认 USB 已连接、iPhone 已信任电脑、App 在前台、GCDWebServer 已启动,并且 /debug/ping 可访问。'
|
|
474
|
+
);
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
if (servers.length > 1 && !bundleId) {
|
|
478
|
+
throw new Error(
|
|
479
|
+
'找到多个 Debug UI Server,请使用 --bundle-id 指定:\n' +
|
|
480
|
+
servers
|
|
481
|
+
.map(server => {
|
|
482
|
+
return `${server.bundleId} app=${server.appName} device=${server.deviceId} port=${server.port}`;
|
|
483
|
+
})
|
|
484
|
+
.join('\n')
|
|
485
|
+
);
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
return servers[0];
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
export async function getUiTree(options = {}) {
|
|
492
|
+
const target = await getSingleDebugServer(options);
|
|
493
|
+
const client = new UsbmuxClient();
|
|
494
|
+
|
|
495
|
+
const tree = await requestDeviceJson(
|
|
496
|
+
client,
|
|
497
|
+
target.deviceId,
|
|
498
|
+
target.port,
|
|
499
|
+
'/debug/ui/tree',
|
|
500
|
+
{
|
|
501
|
+
connectTimeoutMs: DEFAULT_CONNECT_TIMEOUT_MS,
|
|
502
|
+
readTimeoutMs: TREE_READ_TIMEOUT_MS
|
|
503
|
+
}
|
|
504
|
+
);
|
|
505
|
+
|
|
506
|
+
return {
|
|
507
|
+
target,
|
|
508
|
+
tree
|
|
509
|
+
};
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
export async function debugScan(options = {}) {
|
|
513
|
+
const {
|
|
514
|
+
ports = DEFAULT_PORTS,
|
|
515
|
+
concurrency = 6
|
|
516
|
+
} = options;
|
|
517
|
+
|
|
518
|
+
const { client, devices, rawDevices } = await getDevices();
|
|
519
|
+
const candidates = buildCandidates(devices, ports);
|
|
520
|
+
|
|
521
|
+
const results = await mapWithConcurrency(
|
|
522
|
+
candidates,
|
|
523
|
+
concurrency,
|
|
524
|
+
async ({ device, port }) => {
|
|
525
|
+
try {
|
|
526
|
+
const response = await requestDeviceHttp(
|
|
527
|
+
client,
|
|
528
|
+
device.id,
|
|
529
|
+
port,
|
|
530
|
+
'/debug/ping',
|
|
531
|
+
{
|
|
532
|
+
connectTimeoutMs: DEFAULT_CONNECT_TIMEOUT_MS,
|
|
533
|
+
readTimeoutMs: DEFAULT_READ_TIMEOUT_MS
|
|
534
|
+
}
|
|
535
|
+
);
|
|
536
|
+
|
|
537
|
+
return {
|
|
538
|
+
deviceId: device.id,
|
|
539
|
+
port,
|
|
540
|
+
ok: true,
|
|
541
|
+
statusCode: response.statusCode,
|
|
542
|
+
headers: response.headers,
|
|
543
|
+
body: response.body
|
|
544
|
+
};
|
|
545
|
+
} catch (error) {
|
|
546
|
+
return {
|
|
547
|
+
deviceId: device.id,
|
|
548
|
+
port,
|
|
549
|
+
ok: false,
|
|
550
|
+
error: error?.message || String(error)
|
|
551
|
+
};
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
);
|
|
555
|
+
|
|
556
|
+
return {
|
|
557
|
+
rawDevices,
|
|
558
|
+
devices,
|
|
559
|
+
ports,
|
|
560
|
+
results
|
|
561
|
+
};
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
|
|
565
|
+
|
package/package.json
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "kg-ios-usb-app-info",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Read iOS app UI hierarchy and app info through USB for AI tools",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"kg-ios-usb-app-info": "bin/kg-ios-usb-app-info.mjs"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"bin",
|
|
11
|
+
"lib"
|
|
12
|
+
],
|
|
13
|
+
"dependencies": {
|
|
14
|
+
"usbmux-client": "^1.0.0"
|
|
15
|
+
},
|
|
16
|
+
"engines": {
|
|
17
|
+
"node": ">=18"
|
|
18
|
+
},
|
|
19
|
+
"license": "MIT"
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
|