mobile-debug-mcp 0.10.0 → 0.12.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.
Files changed (67) hide show
  1. package/README.md +20 -5
  2. package/dist/android/diagnostics.js +24 -0
  3. package/dist/android/interact.js +1 -145
  4. package/dist/android/manage.js +162 -0
  5. package/dist/android/observe.js +133 -88
  6. package/dist/android/run.js +187 -0
  7. package/dist/android/utils.js +137 -147
  8. package/dist/ios/interact.js +4 -175
  9. package/dist/ios/manage.js +169 -0
  10. package/dist/ios/observe.js +129 -13
  11. package/dist/ios/run.js +200 -0
  12. package/dist/ios/utils.js +138 -124
  13. package/dist/server.js +45 -17
  14. package/dist/tools/interact.js +21 -71
  15. package/dist/tools/manage.js +180 -0
  16. package/dist/tools/observe.js +23 -69
  17. package/dist/tools/run.js +180 -0
  18. package/dist/utils/diagnostics.js +25 -0
  19. package/docs/CHANGELOG.md +14 -0
  20. package/eslint.config.js +22 -1
  21. package/package.json +8 -5
  22. package/scripts/check-idb.js +83 -0
  23. package/scripts/check-idb.ts +73 -0
  24. package/scripts/idb-helper.ts +76 -0
  25. package/scripts/install-idb.js +88 -0
  26. package/scripts/install-idb.ts +90 -0
  27. package/scripts/run-ios-smoke.ts +34 -0
  28. package/scripts/run-ios-ui-tree-tap.ts +33 -0
  29. package/src/android/diagnostics.ts +23 -0
  30. package/src/android/interact.ts +2 -155
  31. package/src/android/manage.ts +157 -0
  32. package/src/android/observe.ts +129 -97
  33. package/src/android/utils.ts +147 -149
  34. package/src/ios/interact.ts +5 -181
  35. package/src/ios/manage.ts +164 -0
  36. package/src/ios/observe.ts +130 -14
  37. package/src/ios/utils.ts +127 -128
  38. package/src/server.ts +47 -17
  39. package/src/tools/interact.ts +23 -62
  40. package/src/tools/manage.ts +171 -0
  41. package/src/tools/observe.ts +24 -74
  42. package/src/types.ts +9 -0
  43. package/src/utils/diagnostics.ts +36 -0
  44. package/test/device/README.md +49 -0
  45. package/test/device/index.ts +27 -0
  46. package/test/device/manage/run-build-install-ios.ts +82 -0
  47. package/test/{integration → device/manage}/run-install-android.ts +4 -4
  48. package/test/{integration → device/manage}/run-install-ios.ts +4 -4
  49. package/test/{integration → device/observe}/logstream-real.ts +5 -4
  50. package/test/{integration → device/utils}/test-dist.ts +2 -2
  51. package/test/unit/index.ts +10 -6
  52. package/test/unit/manage/build.test.ts +83 -0
  53. package/test/unit/manage/build_and_install.test.ts +134 -0
  54. package/test/unit/manage/diagnostics.test.ts +85 -0
  55. package/test/unit/{install.test.ts → manage/install.test.ts} +27 -18
  56. package/test/unit/{logparse.test.ts → observe/logparse.test.ts} +1 -1
  57. package/test/unit/{logstream.test.ts → observe/logstream.test.ts} +9 -10
  58. package/test/unit/{wait_for_element_mock.ts → observe/wait_for_element_mock.ts} +3 -3
  59. package/test/unit/{detect-java.test.ts → utils/detect-java.test.ts} +5 -5
  60. package/tsconfig.json +2 -1
  61. package/test/integration/index.ts +0 -8
  62. package/test/integration/test-dist.mjs +0 -41
  63. /package/test/{integration → device/interact}/run-real-test.ts +0 -0
  64. /package/test/{integration → device/interact}/smoke-test.ts +0 -0
  65. /package/test/{integration → device/manage}/install.integration.ts +0 -0
  66. /package/test/{integration → device/observe}/test-ui-tree.ts +0 -0
  67. /package/test/{integration → device/observe}/wait_for_element_real.ts +0 -0
@@ -1,8 +1,6 @@
1
- import { promises as fs } from "fs";
2
1
  import { spawn } from "child_process";
3
- import { execCommand, getIOSDeviceMetadata, validateBundleId, IDB } from "./utils.js";
2
+ import { getIOSDeviceMetadata, getIdbCmd, isIDBInstalled } from "./utils.js";
4
3
  import { iOSObserve } from "./observe.js";
5
- import path from "path";
6
4
  export class iOSInteract {
7
5
  observe = new iOSObserve();
8
6
  async waitForElement(text, timeout, deviceId = "booted") {
@@ -33,12 +31,8 @@ export class iOSInteract {
33
31
  }
34
32
  async tap(x, y, deviceId = "booted") {
35
33
  const device = await getIOSDeviceMetadata(deviceId);
36
- // Check for idb
37
- const child = spawn(IDB, ['--version']);
38
- const idbExists = await new Promise((resolve) => {
39
- child.on('error', () => resolve(false));
40
- child.on('close', (code) => resolve(code === 0));
41
- });
34
+ // Use shared helper to detect idb
35
+ const idbExists = await isIDBInstalled();
42
36
  if (!idbExists) {
43
37
  return {
44
38
  device,
@@ -55,7 +49,7 @@ export class iOSInteract {
55
49
  args.push('--udid', targetUdid);
56
50
  }
57
51
  await new Promise((resolve, reject) => {
58
- const proc = spawn(IDB, args);
52
+ const proc = spawn(getIdbCmd(), args);
59
53
  let stderr = '';
60
54
  proc.stderr.on('data', d => stderr += d.toString());
61
55
  proc.on('close', code => {
@@ -72,169 +66,4 @@ export class iOSInteract {
72
66
  return { device, success: false, x, y, error: e instanceof Error ? e.message : String(e) };
73
67
  }
74
68
  }
75
- async installApp(appPath, deviceId = "booted") {
76
- const device = await getIOSDeviceMetadata(deviceId);
77
- // Helper to find .app bundles under a directory
78
- async function findAppBundle(dir) {
79
- const entries = await fs.readdir(dir, { withFileTypes: true }).catch(() => []);
80
- for (const e of entries) {
81
- const full = path.join(dir, e.name);
82
- if (e.isDirectory()) {
83
- if (full.endsWith('.app'))
84
- return full;
85
- const found = await findAppBundle(full);
86
- if (found)
87
- return found;
88
- }
89
- }
90
- return undefined;
91
- }
92
- try {
93
- let toInstall = appPath;
94
- const stat = await fs.stat(appPath).catch(() => null);
95
- if (stat && stat.isDirectory()) {
96
- // If directory already contains a .app, use it
97
- const found = await findAppBundle(appPath);
98
- if (found) {
99
- toInstall = found;
100
- }
101
- else {
102
- // Attempt to locate an Xcode project and build for simulator
103
- const files = await fs.readdir(appPath).catch(() => []);
104
- // Prefer workspace when present (CocoaPods / multi-project setups)
105
- const workspace = files.find(f => f.endsWith('.xcworkspace'));
106
- const proj = files.find(f => f.endsWith('.xcodeproj'));
107
- if (!workspace && !proj)
108
- throw new Error('No .app bundle, .xcworkspace or .xcodeproj found in directory');
109
- let buildArgs;
110
- let scheme;
111
- if (workspace) {
112
- const workspacePath = path.join(appPath, workspace);
113
- scheme = workspace.replace(/\.xcworkspace$/, '');
114
- buildArgs = ['-workspace', workspacePath, '-scheme', scheme, '-configuration', 'Debug', '-sdk', 'iphonesimulator', 'build', '-quiet'];
115
- }
116
- else {
117
- const projectPath = path.join(appPath, proj);
118
- scheme = proj.replace(/\.xcodeproj$/, '');
119
- buildArgs = ['-project', projectPath, '-scheme', scheme, '-configuration', 'Debug', '-sdk', 'iphonesimulator', 'build', '-quiet'];
120
- }
121
- await new Promise((resolve, reject) => {
122
- const proc = spawn('xcodebuild', buildArgs, { cwd: appPath });
123
- let stderr = '';
124
- proc.stderr?.on('data', d => stderr += d.toString());
125
- proc.on('close', code => {
126
- if (code === 0)
127
- resolve();
128
- else
129
- reject(new Error(stderr || `xcodebuild failed with code ${code}`));
130
- });
131
- proc.on('error', err => reject(err));
132
- });
133
- const built = await findAppBundle(appPath);
134
- if (!built)
135
- throw new Error('Could not locate built .app after xcodebuild');
136
- toInstall = built;
137
- }
138
- }
139
- // Try simulator install first
140
- try {
141
- const res = await execCommand(['simctl', 'install', deviceId, toInstall], deviceId);
142
- return { device, installed: true, output: res.output };
143
- }
144
- catch (e) {
145
- // If simctl fails and idb is available, try idb install for physical devices
146
- try {
147
- const child = spawn(IDB, ['--version']);
148
- const idbExists = await new Promise((resolve) => {
149
- child.on('error', () => resolve(false));
150
- child.on('close', (code) => resolve(code === 0));
151
- });
152
- if (idbExists) {
153
- // Use idb to install (works for physical devices and simulators)
154
- await new Promise((resolve, reject) => {
155
- const proc = spawn(IDB, ['install', toInstall, '--udid', device.id]);
156
- let stderr = '';
157
- proc.stderr.on('data', d => stderr += d.toString());
158
- proc.on('close', code => {
159
- if (code === 0)
160
- resolve();
161
- else
162
- reject(new Error(stderr || `idb install failed with code ${code}`));
163
- });
164
- proc.on('error', err => reject(err));
165
- });
166
- return { device, installed: true };
167
- }
168
- }
169
- catch {
170
- // fallthrough
171
- }
172
- return { device, installed: false, error: e instanceof Error ? e.message : String(e) };
173
- }
174
- }
175
- catch (e) {
176
- return { device, installed: false, error: e instanceof Error ? e.message : String(e) };
177
- }
178
- }
179
- async startApp(bundleId, deviceId = "booted") {
180
- validateBundleId(bundleId);
181
- const result = await execCommand(['simctl', 'launch', deviceId, bundleId], deviceId);
182
- const device = await getIOSDeviceMetadata(deviceId);
183
- // Simulate launch time and appStarted for demonstration
184
- return {
185
- device,
186
- appStarted: !!result.output,
187
- launchTimeMs: 1000,
188
- };
189
- }
190
- async terminateApp(bundleId, deviceId = "booted") {
191
- validateBundleId(bundleId);
192
- await execCommand(['simctl', 'terminate', deviceId, bundleId], deviceId);
193
- const device = await getIOSDeviceMetadata(deviceId);
194
- return {
195
- device,
196
- appTerminated: true
197
- };
198
- }
199
- async restartApp(bundleId, deviceId = "booted") {
200
- // terminateApp already validates bundleId
201
- await this.terminateApp(bundleId, deviceId);
202
- const startResult = await this.startApp(bundleId, deviceId);
203
- return {
204
- device: startResult.device,
205
- appRestarted: startResult.appStarted,
206
- launchTimeMs: startResult.launchTimeMs
207
- };
208
- }
209
- async resetAppData(bundleId, deviceId = "booted") {
210
- validateBundleId(bundleId);
211
- await this.terminateApp(bundleId, deviceId);
212
- const device = await getIOSDeviceMetadata(deviceId);
213
- // Get data container path
214
- const containerResult = await execCommand(['simctl', 'get_app_container', deviceId, bundleId, 'data'], deviceId);
215
- const dataPath = containerResult.output.trim();
216
- if (!dataPath) {
217
- throw new Error(`Could not find data container for ${bundleId}`);
218
- }
219
- // Clear contents of Library and Documents
220
- try {
221
- const libraryPath = `${dataPath}/Library`;
222
- const documentsPath = `${dataPath}/Documents`;
223
- const tmpPath = `${dataPath}/tmp`;
224
- await fs.rm(libraryPath, { recursive: true, force: true }).catch(() => { });
225
- await fs.rm(documentsPath, { recursive: true, force: true }).catch(() => { });
226
- await fs.rm(tmpPath, { recursive: true, force: true }).catch(() => { });
227
- // Re-create empty directories as they are expected by apps
228
- await fs.mkdir(libraryPath, { recursive: true }).catch(() => { });
229
- await fs.mkdir(documentsPath, { recursive: true }).catch(() => { });
230
- await fs.mkdir(tmpPath, { recursive: true }).catch(() => { });
231
- return {
232
- device,
233
- dataCleared: true
234
- };
235
- }
236
- catch (e) {
237
- throw new Error(`Failed to clear data for ${bundleId}: ${e instanceof Error ? e.message : String(e)}`);
238
- }
239
- }
240
69
  }
@@ -0,0 +1,169 @@
1
+ import { promises as fs } from "fs";
2
+ import { spawn } from "child_process";
3
+ import { execCommand, execCommandWithDiagnostics, getIOSDeviceMetadata, validateBundleId, getIdbCmd, findAppBundle } from "./utils.js";
4
+ import path from "path";
5
+ export class iOSManage {
6
+ async build(projectPath, _variant) {
7
+ void _variant;
8
+ try {
9
+ const files = await fs.readdir(projectPath).catch(() => []);
10
+ const workspace = files.find(f => f.endsWith('.xcworkspace'));
11
+ const proj = files.find(f => f.endsWith('.xcodeproj'));
12
+ if (!workspace && !proj)
13
+ return { error: 'No Xcode project or workspace found' };
14
+ let buildArgs;
15
+ if (workspace) {
16
+ const workspacePath = path.join(projectPath, workspace);
17
+ const scheme = workspace.replace(/\.xcworkspace$/, '');
18
+ buildArgs = ['-workspace', workspacePath, '-scheme', scheme, '-configuration', 'Debug', '-sdk', 'iphonesimulator', 'build'];
19
+ }
20
+ else {
21
+ const projectPathFull = path.join(projectPath, proj);
22
+ const scheme = proj.replace(/\.xcodeproj$/, '');
23
+ buildArgs = ['-project', projectPathFull, '-scheme', scheme, '-configuration', 'Debug', '-sdk', 'iphonesimulator', 'build'];
24
+ }
25
+ await new Promise((resolve, reject) => {
26
+ const xcodeCmd = process.env.XCODEBUILD_PATH || 'xcodebuild';
27
+ const proc = spawn(xcodeCmd, buildArgs, { cwd: projectPath });
28
+ let stderr = '';
29
+ proc.stderr?.on('data', d => stderr += d.toString());
30
+ proc.on('close', code => {
31
+ if (code === 0)
32
+ resolve();
33
+ else
34
+ reject(new Error(stderr || `xcodebuild failed with code ${code}`));
35
+ });
36
+ proc.on('error', err => reject(err));
37
+ });
38
+ const built = await findAppBundle(projectPath);
39
+ if (!built)
40
+ return { error: 'Could not find .app after build' };
41
+ return { artifactPath: built };
42
+ }
43
+ catch (e) {
44
+ return { error: e instanceof Error ? e.message : String(e) };
45
+ }
46
+ }
47
+ async installApp(appPath, deviceId = "booted") {
48
+ const device = await getIOSDeviceMetadata(deviceId);
49
+ try {
50
+ let toInstall = appPath;
51
+ const stat = await fs.stat(appPath).catch(() => null);
52
+ if (stat && stat.isDirectory()) {
53
+ if (appPath.endsWith('.app')) {
54
+ toInstall = appPath;
55
+ }
56
+ else {
57
+ const found = await findAppBundle(appPath);
58
+ if (found) {
59
+ toInstall = found;
60
+ }
61
+ else {
62
+ // Reuse the existing build() implementation to avoid duplicating the xcodebuild logic
63
+ const buildRes = await this.build(appPath);
64
+ if (buildRes.error)
65
+ throw new Error(buildRes.error);
66
+ toInstall = buildRes.artifactPath;
67
+ }
68
+ }
69
+ }
70
+ try {
71
+ const res = await execCommand(['simctl', 'install', deviceId, toInstall], deviceId);
72
+ return { device, installed: true, output: res.output };
73
+ }
74
+ catch (e) {
75
+ // Gather diagnostics for simctl failure
76
+ const diag = execCommandWithDiagnostics(['simctl', 'install', deviceId, toInstall], deviceId);
77
+ try {
78
+ const child = spawn(getIdbCmd(), ['--version']);
79
+ const idbExists = await new Promise((resolve) => {
80
+ child.on('error', () => resolve(false));
81
+ child.on('close', (code) => resolve(code === 0));
82
+ });
83
+ if (idbExists) {
84
+ // attempt idb install via spawn but include diagnostics
85
+ await new Promise((resolve, reject) => {
86
+ const proc = spawn(getIdbCmd(), ['install', toInstall, '--udid', device.id]);
87
+ let stderr = '';
88
+ proc.stderr.on('data', d => stderr += d.toString());
89
+ proc.on('close', code => {
90
+ if (code === 0)
91
+ resolve();
92
+ else
93
+ reject(new Error(stderr || `idb install failed with code ${code}`));
94
+ });
95
+ proc.on('error', err => reject(err));
96
+ });
97
+ return { device, installed: true };
98
+ }
99
+ }
100
+ catch { }
101
+ return { device, installed: false, error: e instanceof Error ? e.message : String(e), diagnostics: diag };
102
+ }
103
+ }
104
+ catch (e) {
105
+ return { device, installed: false, error: e instanceof Error ? e.message : String(e) };
106
+ }
107
+ }
108
+ async startApp(bundleId, deviceId = "booted") {
109
+ validateBundleId(bundleId);
110
+ try {
111
+ const result = await execCommand(['simctl', 'launch', deviceId, bundleId], deviceId);
112
+ const device = await getIOSDeviceMetadata(deviceId);
113
+ return { device, appStarted: !!result.output, launchTimeMs: 1000 };
114
+ }
115
+ catch (e) {
116
+ const diag = execCommandWithDiagnostics(['simctl', 'launch', deviceId, bundleId], deviceId);
117
+ const device = await getIOSDeviceMetadata(deviceId);
118
+ return { device, appStarted: false, launchTimeMs: 0, error: e instanceof Error ? e.message : String(e), diagnostics: diag };
119
+ }
120
+ }
121
+ async terminateApp(bundleId, deviceId = "booted") {
122
+ validateBundleId(bundleId);
123
+ try {
124
+ await execCommand(['simctl', 'terminate', deviceId, bundleId], deviceId);
125
+ const device = await getIOSDeviceMetadata(deviceId);
126
+ return { device, appTerminated: true };
127
+ }
128
+ catch (e) {
129
+ const diag = execCommandWithDiagnostics(['simctl', 'terminate', deviceId, bundleId], deviceId);
130
+ const device = await getIOSDeviceMetadata(deviceId);
131
+ return { device, appTerminated: false, error: e instanceof Error ? e.message : String(e), diagnostics: diag };
132
+ }
133
+ }
134
+ async restartApp(bundleId, deviceId = "booted") {
135
+ await this.terminateApp(bundleId, deviceId);
136
+ const startResult = await this.startApp(bundleId, deviceId);
137
+ return { device: startResult.device, appRestarted: startResult.appStarted, launchTimeMs: startResult.launchTimeMs };
138
+ }
139
+ async resetAppData(bundleId, deviceId = "booted") {
140
+ validateBundleId(bundleId);
141
+ await this.terminateApp(bundleId, deviceId);
142
+ const device = await getIOSDeviceMetadata(deviceId);
143
+ try {
144
+ const containerResult = await execCommand(['simctl', 'get_app_container', deviceId, bundleId, 'data'], deviceId);
145
+ const dataPath = containerResult.output.trim();
146
+ if (!dataPath)
147
+ throw new Error(`Could not find data container for ${bundleId}`);
148
+ try {
149
+ const libraryPath = `${dataPath}/Library`;
150
+ const documentsPath = `${dataPath}/Documents`;
151
+ const tmpPath = `${dataPath}/tmp`;
152
+ await fs.rm(libraryPath, { recursive: true, force: true }).catch(() => { });
153
+ await fs.rm(documentsPath, { recursive: true, force: true }).catch(() => { });
154
+ await fs.rm(tmpPath, { recursive: true, force: true }).catch(() => { });
155
+ await fs.mkdir(libraryPath, { recursive: true }).catch(() => { });
156
+ await fs.mkdir(documentsPath, { recursive: true }).catch(() => { });
157
+ await fs.mkdir(tmpPath, { recursive: true }).catch(() => { });
158
+ return { device, dataCleared: true };
159
+ }
160
+ catch (e) {
161
+ throw new Error(`Failed to clear data for ${bundleId}: ${e instanceof Error ? e.message : String(e)}`);
162
+ }
163
+ }
164
+ catch (e) {
165
+ const diag = execCommandWithDiagnostics(['simctl', 'get_app_container', deviceId, bundleId, 'data'], deviceId);
166
+ return { device, dataCleared: false, error: e instanceof Error ? e.message : String(e), diagnostics: diag };
167
+ }
168
+ }
169
+ }
@@ -1,11 +1,25 @@
1
1
  import { spawn } from "child_process";
2
2
  import { promises as fs } from "fs";
3
- import { execCommand, getIOSDeviceMetadata, validateBundleId, IDB } from "./utils.js";
3
+ import { execCommand, getIOSDeviceMetadata, validateBundleId, getIdbCmd, getXcrunCmd, isIDBInstalled } from "./utils.js";
4
+ import { createWriteStream, promises as fsPromises } from 'fs';
5
+ import path from 'path';
6
+ import { parseLogLine } from '../android/utils.js';
4
7
  // --- Helper Functions Specific to Observe ---
5
8
  const delay = (ms) => new Promise(resolve => setTimeout(resolve, ms));
6
9
  function parseIDBFrame(frame) {
7
10
  if (!frame)
8
11
  return [0, 0, 0, 0];
12
+ // Handle string frames like "{{0, 0}, {402, 874}}"
13
+ if (typeof frame === 'string') {
14
+ const nums = frame.match(/-?\d+(?:\.\d+)?/g);
15
+ if (!nums || nums.length < 4)
16
+ return [0, 0, 0, 0];
17
+ const x = Number(nums[0]);
18
+ const y = Number(nums[1]);
19
+ const w = Number(nums[2]);
20
+ const h = Number(nums[3]);
21
+ return [Math.round(x), Math.round(y), Math.round(x + w), Math.round(y + h)];
22
+ }
9
23
  const x = Number(frame.x || 0);
10
24
  const y = Number(frame.y || 0);
11
25
  const w = Number(frame.width || frame.w || 0);
@@ -69,14 +83,14 @@ function traverseIDBNode(node, elements, parentIndex = -1, depth = 0) {
69
83
  }
70
84
  return currentIndex;
71
85
  }
72
- // Check if IDB is installed
73
- async function isIDBInstalled() {
74
- return new Promise((resolve) => {
75
- // Check if 'idb' is in path by trying to run it
76
- const child = spawn(IDB, ['--version']);
77
- child.on('error', () => resolve(false));
78
- child.on('close', (code) => resolve(code === 0));
79
- });
86
+ // iOS live log stream support (moved from ios/utils to observe)
87
+ const iosActiveLogStreams = new Map();
88
+ // Test helpers
89
+ export function _setIOSActiveLogStream(sessionId, file) {
90
+ iosActiveLogStreams.set(sessionId, { proc: {}, file });
91
+ }
92
+ export function _clearIOSActiveLogStream(sessionId) {
93
+ iosActiveLogStreams.delete(sessionId);
80
94
  }
81
95
  export class iOSObserve {
82
96
  async getDeviceMetadata(deviceId = "booted") {
@@ -149,12 +163,12 @@ export class iOSObserve {
149
163
  try {
150
164
  // Stabilization delay
151
165
  await delay(300 + (attempts * 100));
152
- const args = ['ui', 'describe', '--json'];
166
+ const args = ['ui', 'describe-all', '--json'];
153
167
  if (targetUdid) {
154
168
  args.push('--udid', targetUdid);
155
169
  }
156
170
  const output = await new Promise((resolve, reject) => {
157
- const child = spawn(IDB, args);
171
+ const child = spawn(getIdbCmd(), args);
158
172
  let stdout = '';
159
173
  let stderr = '';
160
174
  child.stdout.on('data', (data) => stdout += data.toString());
@@ -189,8 +203,15 @@ export class iOSObserve {
189
203
  }
190
204
  try {
191
205
  const elements = [];
192
- const root = jsonContent;
193
- traverseIDBNode(root, elements);
206
+ // idb describe-all returns either a root object or an array of root nodes
207
+ if (Array.isArray(jsonContent)) {
208
+ for (const node of jsonContent) {
209
+ traverseIDBNode(node, elements);
210
+ }
211
+ }
212
+ else {
213
+ traverseIDBNode(jsonContent, elements);
214
+ }
194
215
  // Infer resolution from root element if possible (usually the Window/Application frame)
195
216
  let width = 0;
196
217
  let height = 0;
@@ -216,4 +237,99 @@ export class iOSObserve {
216
237
  };
217
238
  }
218
239
  }
240
+ // --- Log stream methods ---
241
+ async startLogStream(bundleId, deviceId = 'booted', sessionId = 'default') {
242
+ try {
243
+ const predicate = `process == "${bundleId}" or subsystem contains "${bundleId}"`;
244
+ if (iosActiveLogStreams.has(sessionId)) {
245
+ try {
246
+ iosActiveLogStreams.get(sessionId).proc.kill();
247
+ }
248
+ catch { }
249
+ iosActiveLogStreams.delete(sessionId);
250
+ }
251
+ const args = ['simctl', 'spawn', deviceId, 'log', 'stream', '--style', 'syslog', '--predicate', predicate];
252
+ const proc = spawn(getXcrunCmd(), args);
253
+ // Prepare output file
254
+ const tmpDir = process.env.TMPDIR || '/tmp';
255
+ const file = path.join(tmpDir, `mobile-debug-ios-log-${sessionId}.ndjson`);
256
+ const stream = createWriteStream(file, { flags: 'a' });
257
+ proc.stdout.on('data', (chunk) => {
258
+ const text = chunk.toString();
259
+ const lines = text.split(/\r?\n/).filter(Boolean);
260
+ for (const l of lines) {
261
+ const entry = parseLogLine(l);
262
+ stream.write(JSON.stringify(entry) + '\n');
263
+ }
264
+ });
265
+ proc.stderr.on('data', (chunk) => {
266
+ const text = chunk.toString();
267
+ const lines = text.split(/\r?\n/).filter(Boolean);
268
+ for (const l of lines) {
269
+ const entry = { timestamp: '', level: 'E', tag: 'xcrun', message: l };
270
+ stream.write(JSON.stringify(entry) + '\n');
271
+ }
272
+ });
273
+ proc.on('close', () => {
274
+ stream.end();
275
+ iosActiveLogStreams.delete(sessionId);
276
+ });
277
+ iosActiveLogStreams.set(sessionId, { proc, file });
278
+ return { success: true, stream_started: true };
279
+ }
280
+ catch {
281
+ return { success: false, error: 'log_stream_start_failed' };
282
+ }
283
+ }
284
+ async stopLogStream(sessionId = 'default') {
285
+ const entry = iosActiveLogStreams.get(sessionId);
286
+ if (!entry)
287
+ return { success: true };
288
+ try {
289
+ entry.proc.kill();
290
+ }
291
+ catch { }
292
+ iosActiveLogStreams.delete(sessionId);
293
+ return { success: true };
294
+ }
295
+ async readLogStream(sessionId = 'default', limit = 100, since) {
296
+ const entry = iosActiveLogStreams.get(sessionId);
297
+ if (!entry)
298
+ return { entries: [] };
299
+ try {
300
+ const data = await fsPromises.readFile(entry.file, 'utf8').catch(() => '');
301
+ if (!data)
302
+ return { entries: [], crash_summary: { crash_detected: false } };
303
+ const lines = data.split(/\r?\n/).filter(Boolean);
304
+ const parsed = lines.map(l => {
305
+ try {
306
+ return JSON.parse(l);
307
+ }
308
+ catch {
309
+ return { message: l, _iso: null, crash: false };
310
+ }
311
+ });
312
+ let filtered = parsed;
313
+ if (since) {
314
+ let sinceMs = null;
315
+ if (/^\d+$/.test(since))
316
+ sinceMs = Number(since);
317
+ else {
318
+ const sDate = new Date(since);
319
+ if (!isNaN(sDate.getTime()))
320
+ sinceMs = sDate.getTime();
321
+ }
322
+ if (sinceMs !== null) {
323
+ filtered = parsed.filter(p => p._iso && (new Date(p._iso).getTime() >= sinceMs));
324
+ }
325
+ }
326
+ const entries = filtered.slice(-Math.max(0, limit));
327
+ const crashEntry = entries.find(e => e.crash);
328
+ const crash_summary = crashEntry ? { crash_detected: true, exception: crashEntry.exception, sample: crashEntry.message } : { crash_detected: false };
329
+ return { entries, crash_summary };
330
+ }
331
+ catch {
332
+ return { entries: [], crash_summary: { crash_detected: false } };
333
+ }
334
+ }
219
335
  }