mobile-debug-mcp 0.35.0 → 0.36.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/.cursor-plugin/marketplace.json +1 -1
- package/.github/workflows/ci.yml +1 -1
- package/README.md +86 -24
- package/agent-plugin/.antigravity-plugin/README.md +48 -0
- package/agent-plugin/.antigravity-plugin/plugin.json +18 -0
- package/agent-plugin/.claude-plugin/plugin.json +2 -2
- package/agent-plugin/.codex-plugin/plugin.json +1 -1
- package/agent-plugin/.cursor-plugin/plugin.json +2 -2
- package/agent-plugin/.mcp.json +1 -1
- package/agent-plugin/README.md +37 -8
- package/agent-plugin/cursor/.cursor-plugin/plugin.json +23 -0
- package/agent-plugin/cursor/LICENSE +201 -0
- package/agent-plugin/cursor/README.md +9 -0
- package/agent-plugin/cursor/skills/mobile-debug-mcp/SKILL.md +253 -0
- package/agent-plugin/mcp.json +1 -1
- package/agent-plugin/mcp_config.json +13 -0
- package/agent-plugin/plugin.json +1 -1
- package/agent-plugin/skills/mobile-debug-mcp/SKILL.md +36 -16
- package/dist/debugger/guard.js +4 -4
- package/dist/debugger/ios-worker.js +62 -0
- package/dist/debugger/ios.js +57 -0
- package/dist/debugger/sessions.js +33 -16
- package/dist/debugger/worker.js +6 -4
- package/dist/interact/index.js +12 -2
- package/dist/manage/ios.js +2 -0
- package/dist/observe/ios-trace/artifact.js +63 -0
- package/dist/observe/ios-trace/capabilities.js +86 -0
- package/dist/observe/ios-trace/native.js +93 -0
- package/dist/observe/ios-trace/notification.js +35 -0
- package/dist/observe/ios-trace/process.js +98 -0
- package/dist/observe/ios-trace/sessions.js +244 -0
- package/dist/observe/readiness-service.js +1 -1
- package/dist/observe/readiness.js +4 -4
- package/dist/observe/system-trace-error.js +7 -0
- package/dist/observe/system-trace-router.js +81 -0
- package/dist/observe/system-trace.js +4 -8
- package/dist/server/handlers/debugger-handlers.js +27 -8
- package/dist/server/handlers/diagnostic.js +12 -4
- package/dist/server/handlers/tool-handlers.js +19 -9
- package/dist/server/schemas/debugger-schema.js +10 -7
- package/dist/server/schemas/system-trace-schema.js +14 -9
- package/dist/server-core.js +1 -1
- package/dist/server.js +4 -4
- package/dist/utils/ios/utils.js +3 -0
- package/docs/CHANGELOG.md +23 -1
- package/docs/agent-plugin-installation-verification.md +39 -0
- package/docs/code-structure.md +3 -1
- package/docs/specs/031-ios-system-traces/intent.md +38 -0
- package/docs/specs/031-ios-system-traces/investigation.md +92 -0
- package/docs/specs/031-ios-system-traces/late-run-failure-evidence.json +32 -0
- package/docs/specs/031-ios-system-traces/plan.md +138 -0
- package/docs/specs/031-ios-system-traces/pr102-monitor-evidence.json +22 -0
- package/docs/specs/031-ios-system-traces/simulator-evidence.json +47 -0
- package/docs/specs/031-ios-system-traces/spec.md +135 -0
- package/docs/specs/032-ios-runtime-debugger/evidence.json +176 -0
- package/docs/specs/032-ios-runtime-debugger/intent.md +38 -0
- package/docs/specs/032-ios-runtime-debugger/plan.md +95 -0
- package/docs/specs/032-ios-runtime-debugger/report.md +205 -0
- package/docs/specs/032-ios-runtime-debugger/spec.md +153 -0
- package/docs/tools/TOOLS.md +2 -2
- package/docs/tools/debugger.md +45 -5
- package/docs/tools/system-traces.md +81 -14
- package/package.json +2 -1
- package/scripts/agent-plugin.mjs +55 -5
- package/src/debugger/android.ts +1 -1
- package/src/debugger/guard.ts +5 -5
- package/src/debugger/ios-worker.ts +49 -0
- package/src/debugger/ios.ts +45 -0
- package/src/debugger/lldb/breakpoints.py +51 -0
- package/src/debugger/lldb/inspection.py +109 -0
- package/src/debugger/lldb/worker.py +203 -0
- package/src/debugger/sessions.ts +24 -12
- package/src/debugger/worker.ts +7 -4
- package/src/interact/index.ts +13 -2
- package/src/manage/ios.ts +2 -0
- package/src/observe/ios-trace/artifact.ts +55 -0
- package/src/observe/ios-trace/capabilities.ts +74 -0
- package/src/observe/ios-trace/native.ts +72 -0
- package/src/observe/ios-trace/notification.ts +30 -0
- package/src/observe/ios-trace/process.ts +82 -0
- package/src/observe/ios-trace/sessions.ts +204 -0
- package/src/observe/readiness-service.ts +1 -1
- package/src/observe/readiness.ts +4 -4
- package/src/observe/system-trace-error.ts +3 -0
- package/src/observe/system-trace-router.ts +61 -0
- package/src/observe/system-trace.ts +4 -4
- package/src/server/handlers/debugger-handlers.ts +28 -9
- package/src/server/handlers/diagnostic.ts +11 -4
- package/src/server/handlers/tool-handlers.ts +20 -9
- package/src/server/schemas/debugger-schema.ts +10 -7
- package/src/server/schemas/system-trace-schema.ts +15 -9
- package/src/server-core.ts +1 -1
- package/src/server.ts +3 -3
- package/src/utils/ios/utils.ts +3 -0
- package/test/device/automated/debugger/runtime.ios.test.ts +221 -0
- package/test/device/automated/observe/ios-system-trace.smoke.ts +119 -0
- package/test/fixtures/contracts/tool-definitions.json +192 -32
- package/test/fixtures/ios-debugger-app/Main.swift +95 -0
- package/test/fixtures/ios-debugger-app/README.md +27 -0
- package/test/fixtures/ios-debugger-app/build.ts +21 -0
- package/test/helpers/ios-trace-shutdown.ts +29 -0
- package/test/unit/debugger/handle-guards.test.ts +88 -0
- package/test/unit/debugger/ios-preparation.test.ts +57 -0
- package/test/unit/debugger/ios.test.ts +104 -0
- package/test/unit/debugger/lldb-worker.py +166 -0
- package/test/unit/debugger/lldb-worker.test.ts +10 -0
- package/test/unit/observe/ios-trace-native.test.ts +90 -0
- package/test/unit/observe/ios-trace-sessions.test.ts +131 -0
- package/test/unit/observe/readiness.test.ts +1 -1
- package/test/unit/observe/system-trace-router.test.ts +49 -0
- package/test/unit/quality/agent-plugin-export.test.ts +38 -0
- package/test/unit/server/system-trace.test.ts +12 -3
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process'
|
|
2
|
+
import { promisify } from 'node:util'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
import { realpath } from 'node:fs/promises'
|
|
5
|
+
import { getXcrunCmd } from '../utils/ios/utils.js'
|
|
6
|
+
import type { DebugNative, DebugTarget } from './android.js'
|
|
7
|
+
import { DebuggerError, debuggerRemaining, debuggerSignal, nativeError } from './worker.js'
|
|
8
|
+
const exec = promisify(execFile)
|
|
9
|
+
export async function iosExec(file: string, args: string[], env?: NodeJS.ProcessEnv) {
|
|
10
|
+
try { return (await exec(file, args, { timeout: debuggerRemaining(5000), signal: debuggerSignal(), maxBuffer: 1024 * 1024, ...(env ? { env } : {}) })).stdout.trim() }
|
|
11
|
+
catch (e) { throw nativeError(e, 'DEVICE_UNAVAILABLE') }
|
|
12
|
+
}
|
|
13
|
+
export class IOSDebugNative implements DebugNative {
|
|
14
|
+
constructor(private run = iosExec) {}
|
|
15
|
+
private sim(args: string[]) { return this.run(getXcrunCmd(), ['simctl', ...args]) }
|
|
16
|
+
async candidates(device: string, app: string): Promise<DebugTarget[]> {
|
|
17
|
+
if (!/^[A-Fa-f0-9]{8}(?:-[A-Fa-f0-9]{4}){3}-[A-Fa-f0-9]{12}$/.test(device) || !/^[A-Za-z0-9][A-Za-z0-9.-]+$/.test(app)) throw new DebuggerError('INVALID_ARGUMENT')
|
|
18
|
+
const inventory = JSON.parse(await this.sim(['list', 'devices', 'booted', '--json'])) as { devices: Record<string, { udid: string; state: string }[]> }
|
|
19
|
+
if (!Object.values(inventory.devices).flat().some(d => d.udid === device && d.state === 'Booted')) throw new DebuggerError('DEBUGGER_UNSUPPORTED')
|
|
20
|
+
const services = await this.sim(['spawn', device, 'launchctl', 'list'])
|
|
21
|
+
const pids = this.processes(services, app)
|
|
22
|
+
if (!pids.length) return []
|
|
23
|
+
const container = await this.sim(['get_app_container', device, app, 'app'])
|
|
24
|
+
const executable = await this.run('/usr/bin/plutil', ['-extract', 'CFBundleExecutable', 'raw', '-o', '-', path.join(container, 'Info.plist')])
|
|
25
|
+
if (path.basename(executable) !== executable || !executable) throw new DebuggerError('PROCESS_NOT_FOUND')
|
|
26
|
+
const executablePath = await realpath(path.join(container, executable))
|
|
27
|
+
return Promise.all(pids.map(pid => this.identity(device, { app_id: app, pid, process_name: executable, executable_path: executablePath, start_identity: '', uid: null })))
|
|
28
|
+
}
|
|
29
|
+
private processes(services: string, app: string) {
|
|
30
|
+
return services.split('\n').flatMap(line => {
|
|
31
|
+
const [pid, , label] = line.trim().split(/\s+/)
|
|
32
|
+
return /^\d+$/.test(pid) && label?.startsWith(`UIKitApplication:${app}[`) ? [Number(pid)] : []
|
|
33
|
+
})
|
|
34
|
+
}
|
|
35
|
+
async identity(device: string, target: DebugTarget): Promise<DebugTarget> {
|
|
36
|
+
const services = await this.sim(['spawn', device, 'launchctl', 'list'])
|
|
37
|
+
if (!this.processes(services, target.app_id).includes(target.pid)) throw new DebuggerError('PROCESS_CHANGED')
|
|
38
|
+
const command = await this.run('/bin/ps', ['-ww', '-p', String(target.pid), '-o', 'comm='])
|
|
39
|
+
const started = await this.run('/bin/ps', ['-p', String(target.pid), '-o', 'lstart='])
|
|
40
|
+
if (command !== target.executable_path || !started || (target.start_identity && started !== target.start_identity)) throw new DebuggerError('PROCESS_CHANGED')
|
|
41
|
+
return { ...target, start_identity: started }
|
|
42
|
+
}
|
|
43
|
+
async forward(): Promise<number> { throw new DebuggerError('DEBUGGER_UNSUPPORTED') }
|
|
44
|
+
async removeForward() { throw new DebuggerError('DEBUGGER_UNSUPPORTED') }
|
|
45
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Owned source breakpoints with exact-line checking, including late image loads."""
|
|
2
|
+
import os
|
|
3
|
+
import uuid
|
|
4
|
+
import lldb
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class Breakpoints:
|
|
8
|
+
def __init__(self, target):
|
|
9
|
+
self.target = target
|
|
10
|
+
self.owned = {}
|
|
11
|
+
|
|
12
|
+
def describe(self, key):
|
|
13
|
+
bp, source, line = self.owned[key]
|
|
14
|
+
locations = []
|
|
15
|
+
rejected = False
|
|
16
|
+
for loc in bp:
|
|
17
|
+
entry = loc.GetAddress().GetLineEntry()
|
|
18
|
+
actual = str(entry.GetFileSpec())
|
|
19
|
+
exact = entry.IsValid() and os.path.normpath(actual) == source and entry.GetLine() == line
|
|
20
|
+
loc.SetEnabled(exact)
|
|
21
|
+
if exact:
|
|
22
|
+
locations.append({"source_file": actual, "line": line})
|
|
23
|
+
else:
|
|
24
|
+
rejected = True
|
|
25
|
+
known_source = self.target.FindCompileUnits(lldb.SBFileSpec(source)).GetSize() > 0
|
|
26
|
+
status = "resolved" if locations else "rejected" if rejected or known_source else "pending"
|
|
27
|
+
return {"breakpoint_id": key, "status": status, "locations": locations}
|
|
28
|
+
|
|
29
|
+
def set(self, args):
|
|
30
|
+
source = os.path.normpath(args["sourceFile"])
|
|
31
|
+
bp = self.target.BreakpointCreateByLocation(lldb.SBFileSpec(source), args["line"], 0, 0, lldb.SBFileSpecList(), False)
|
|
32
|
+
key = str(uuid.uuid4())
|
|
33
|
+
self.owned[key] = (bp, source, args["line"])
|
|
34
|
+
result = self.describe(key)
|
|
35
|
+
if result["status"] == "rejected":
|
|
36
|
+
self.target.BreakpointDelete(bp.GetID())
|
|
37
|
+
del self.owned[key]
|
|
38
|
+
return result
|
|
39
|
+
|
|
40
|
+
def refresh(self):
|
|
41
|
+
return [self.describe(key) for key in list(self.owned)]
|
|
42
|
+
|
|
43
|
+
def remove(self, key):
|
|
44
|
+
if key in self.owned:
|
|
45
|
+
bp, _, _ = self.owned.pop(key)
|
|
46
|
+
self.target.BreakpointDelete(bp.GetID())
|
|
47
|
+
return {"removed": True}
|
|
48
|
+
|
|
49
|
+
def clear(self):
|
|
50
|
+
for key in list(self.owned):
|
|
51
|
+
self.remove(key)
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""Bounded, static inspection. Never evaluate expressions or object descriptions."""
|
|
2
|
+
import uuid
|
|
3
|
+
import re
|
|
4
|
+
import lldb
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def location(frame):
|
|
8
|
+
entry = frame.GetLineEntry()
|
|
9
|
+
file = entry.GetFileSpec()
|
|
10
|
+
source = str(file)[:512] if file.IsValid() else None
|
|
11
|
+
return {"source_file": source, "line": entry.GetLine() if entry.IsValid() else None,
|
|
12
|
+
"method_name": (frame.GetFunctionName() or "")[:512], "class_name": None}
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Inspection:
|
|
16
|
+
def __init__(self):
|
|
17
|
+
self.invalidate()
|
|
18
|
+
|
|
19
|
+
def invalidate(self):
|
|
20
|
+
self.stop_id = None
|
|
21
|
+
self.threads = {}
|
|
22
|
+
self.frames = {}
|
|
23
|
+
|
|
24
|
+
def stopped(self, process):
|
|
25
|
+
self.invalidate()
|
|
26
|
+
self.stop_id = str(uuid.uuid4())
|
|
27
|
+
selected = None
|
|
28
|
+
chosen = process.GetSelectedThread()
|
|
29
|
+
threads = [chosen] if chosen.IsValid() else []
|
|
30
|
+
for index in range(min(process.GetNumThreads(), 100)):
|
|
31
|
+
thread = process.GetThreadAtIndex(index)
|
|
32
|
+
if not chosen.IsValid() or thread.GetThreadID() != chosen.GetThreadID():
|
|
33
|
+
threads.append(thread)
|
|
34
|
+
for thread in threads[:100]:
|
|
35
|
+
key = str(uuid.uuid4())
|
|
36
|
+
self.threads[key] = thread
|
|
37
|
+
if selected is None and thread.GetStopReason() not in (lldb.eStopReasonNone, lldb.eStopReasonInvalid):
|
|
38
|
+
selected = key
|
|
39
|
+
if selected is None or process.GetState() != lldb.eStateStopped:
|
|
40
|
+
self.invalidate()
|
|
41
|
+
return None
|
|
42
|
+
thread = self.threads.get(selected)
|
|
43
|
+
return {"event": "stopped", "stop_id": self.stop_id, "thread_id": selected,
|
|
44
|
+
"reason": stop_reason(thread),
|
|
45
|
+
"location": location(thread.GetFrameAtIndex(0)) if thread else None,
|
|
46
|
+
"threads": [{"thread_id": key, "name": (t.GetName() or "")[:256]} for key, t in self.threads.items()],
|
|
47
|
+
"threads_truncated": process.GetNumThreads() > 100}
|
|
48
|
+
|
|
49
|
+
def thread(self, args):
|
|
50
|
+
if args.get("stopId") != self.stop_id or args.get("threadId") not in self.threads:
|
|
51
|
+
raise ValueError("DEBUGGER_STALE_REFERENCE")
|
|
52
|
+
return self.threads[args["threadId"]]
|
|
53
|
+
|
|
54
|
+
def stack(self, args):
|
|
55
|
+
thread = self.thread(args)
|
|
56
|
+
offset, limit = args.get("offset", 0), args.get("limit", 20)
|
|
57
|
+
count = min(thread.GetNumFrames(), 1000)
|
|
58
|
+
frames = []
|
|
59
|
+
for index in range(offset, min(count, offset + limit)):
|
|
60
|
+
frame = thread.GetFrameAtIndex(index)
|
|
61
|
+
key = self.stop_id + ":" + args["threadId"] + ":" + str(index)
|
|
62
|
+
if key not in self.frames and len(self.frames) >= 1000:
|
|
63
|
+
break
|
|
64
|
+
self.frames[key] = frame
|
|
65
|
+
frames.append({"frame_id": key, "index": index, **location(frame)})
|
|
66
|
+
return {"frames": frames, "truncated": count > offset + len(frames) or thread.GetNumFrames() > 1000}
|
|
67
|
+
|
|
68
|
+
def locals(self, args):
|
|
69
|
+
if args.get("stopId") != self.stop_id or args.get("frameId") not in self.frames:
|
|
70
|
+
raise ValueError("DEBUGGER_STALE_REFERENCE")
|
|
71
|
+
frame = self.frames[args["frameId"]]
|
|
72
|
+
values = frame.GetVariables(True, True, False, True, lldb.eNoDynamicValues)
|
|
73
|
+
result = []
|
|
74
|
+
for index in range(min(values.GetSize(), 100)):
|
|
75
|
+
value = values.GetValueAtIndex(index).GetNonSyntheticValue()
|
|
76
|
+
name, kind = (value.GetName() or "")[:256], (value.GetTypeName() or "")[:256]
|
|
77
|
+
if not value.GetError().Success():
|
|
78
|
+
result.append({"name": name, "type": kind, "availability": "unavailable"})
|
|
79
|
+
continue
|
|
80
|
+
primitive = value.GetType().GetTypeFlags() & (lldb.eTypeIsScalar | lldb.eTypeIsInteger | lldb.eTypeIsFloat)
|
|
81
|
+
text = value.GetValue() if primitive else None
|
|
82
|
+
# Swift value wrappers are not C scalars. Decode only standard primitive storage.
|
|
83
|
+
if kind in ("Swift.Int", "Swift.Int32", "Swift.Int64", "Swift.UInt", "Swift.Bool", "Swift.Double", "Swift.Float"):
|
|
84
|
+
storage = value.GetChildMemberWithName("_value").GetNonSyntheticValue()
|
|
85
|
+
if storage.IsValid() and storage.GetError().Success():
|
|
86
|
+
text = storage.GetValue()
|
|
87
|
+
if kind == "Swift.Bool" and text in ("0", "1"):
|
|
88
|
+
text = "true" if text == "1" else "false"
|
|
89
|
+
result.append({"name": name, "type": kind, "value": text[:256] if text else None,
|
|
90
|
+
"kind": "primitive" if text is not None else "object",
|
|
91
|
+
"truncated": bool(text and len(text) > 256),
|
|
92
|
+
"object_id": self.object_identity(value, kind) if text is None else None})
|
|
93
|
+
return {"availability": "available" if result else "unknown", "locals": result,
|
|
94
|
+
"reason": None if result else "EMPTY_OR_UNAVAILABLE_VARIABLE_METADATA",
|
|
95
|
+
"truncated": values.GetSize() > 100}
|
|
96
|
+
|
|
97
|
+
def object_identity(self, value, kind):
|
|
98
|
+
pointer = value.GetValue()
|
|
99
|
+
if pointer and re.fullmatch(r"0x[0-9a-fA-F]+", pointer):
|
|
100
|
+
return str(uuid.uuid5(uuid.UUID(self.stop_id), kind + ":" + pointer))
|
|
101
|
+
return None
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def stop_reason(thread):
|
|
105
|
+
if thread is None:
|
|
106
|
+
return "unknown"
|
|
107
|
+
return {lldb.eStopReasonBreakpoint: "breakpoint", lldb.eStopReasonPlanComplete: "step",
|
|
108
|
+
lldb.eStopReasonSignal: "signal", lldb.eStopReasonException: "exception",
|
|
109
|
+
lldb.eStopReasonWatchpoint: "watchpoint", lldb.eStopReasonExec: "exec"}.get(thread.GetStopReason(), "unknown")
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
"""Private JSON worker for simulator LLDB. Stdout is exclusively bounded IPC."""
|
|
2
|
+
import json
|
|
3
|
+
import os
|
|
4
|
+
import queue
|
|
5
|
+
import sys
|
|
6
|
+
import threading
|
|
7
|
+
import time
|
|
8
|
+
import lldb
|
|
9
|
+
from inspection import Inspection
|
|
10
|
+
from breakpoints import Breakpoints
|
|
11
|
+
|
|
12
|
+
requests = queue.Queue(maxsize=64)
|
|
13
|
+
output_lock = threading.Lock()
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def send(value):
|
|
17
|
+
data = json.dumps(value, separators=(",", ":"))
|
|
18
|
+
if len(data.encode()) > 1024 * 1024:
|
|
19
|
+
raise ValueError("DEBUGGER_WORKER_FAILED")
|
|
20
|
+
with output_lock:
|
|
21
|
+
sys.stdout.write(data + "\n")
|
|
22
|
+
sys.stdout.flush()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def read_input():
|
|
26
|
+
try:
|
|
27
|
+
while True:
|
|
28
|
+
line = sys.stdin.buffer.readline(1024 * 1024 + 1)
|
|
29
|
+
if not line:
|
|
30
|
+
break
|
|
31
|
+
if len(line) > 1024 * 1024:
|
|
32
|
+
break
|
|
33
|
+
requests.put(json.loads(line), timeout=1)
|
|
34
|
+
except Exception:
|
|
35
|
+
pass
|
|
36
|
+
finally:
|
|
37
|
+
# EOF must remain observable even when the queue was filled by a dead parent.
|
|
38
|
+
eof.set()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
eof = threading.Event()
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class Worker:
|
|
45
|
+
def __init__(self):
|
|
46
|
+
self.debugger = lldb.SBDebugger.Create(False)
|
|
47
|
+
self.debugger.SetAsync(True)
|
|
48
|
+
self.debugger.SetUseColor(False)
|
|
49
|
+
lldb.SBDebugger.SetInternalVariable("target.load-script-from-symbol-file", "false", self.debugger.GetInstanceName())
|
|
50
|
+
self.output = open(os.devnull, "w")
|
|
51
|
+
self.debugger.SetOutputFileHandle(self.output, False)
|
|
52
|
+
self.debugger.SetErrorFileHandle(self.output, False)
|
|
53
|
+
self.process = None
|
|
54
|
+
self.attached = False
|
|
55
|
+
self.breakpoints = None
|
|
56
|
+
self.inspection = Inspection()
|
|
57
|
+
self.created = self.activity = time.monotonic()
|
|
58
|
+
self.paused_at = None
|
|
59
|
+
self.last_stop = 0
|
|
60
|
+
self.terminal = False
|
|
61
|
+
|
|
62
|
+
def attach(self, args):
|
|
63
|
+
self.debugger.SetAsync(False)
|
|
64
|
+
target = self.debugger.CreateTarget(args["executablePath"])
|
|
65
|
+
if not target.IsValid():
|
|
66
|
+
raise ValueError("DEBUGGER_WORKER_FAILED")
|
|
67
|
+
self.breakpoints = Breakpoints(target)
|
|
68
|
+
error = lldb.SBError()
|
|
69
|
+
self.process = target.AttachToProcessWithID(self.debugger.GetListener(), args["pid"], error)
|
|
70
|
+
self.debugger.SetAsync(True)
|
|
71
|
+
self.attached = self.process.IsValid()
|
|
72
|
+
if not error.Success() or not self.process.IsValid():
|
|
73
|
+
raise ValueError("DEBUGGER_WORKER_FAILED")
|
|
74
|
+
actual = self.process.GetProcessInfo().GetExecutableFile()
|
|
75
|
+
if not actual.IsValid() or os.path.realpath(str(actual)) != os.path.realpath(args["executablePath"]):
|
|
76
|
+
self.process.Detach()
|
|
77
|
+
raise ValueError("DEBUGGER_WORKER_FAILED")
|
|
78
|
+
self.last_stop = self.process.GetStopID()
|
|
79
|
+
if not self.process.Continue().Success():
|
|
80
|
+
raise ValueError("DEBUGGER_WORKER_FAILED")
|
|
81
|
+
return {"attached": True}
|
|
82
|
+
|
|
83
|
+
def detach(self):
|
|
84
|
+
self.inspection.invalidate()
|
|
85
|
+
if self.breakpoints:
|
|
86
|
+
self.breakpoints.clear()
|
|
87
|
+
if self.attached:
|
|
88
|
+
if not self.process.IsValid():
|
|
89
|
+
raise ValueError("DEBUGGER_WORKER_FAILED")
|
|
90
|
+
if self.process.GetState() not in (lldb.eStateExited, lldb.eStateDetached):
|
|
91
|
+
error = self.process.Detach()
|
|
92
|
+
if not error.Success():
|
|
93
|
+
raise ValueError("DEBUGGER_WORKER_FAILED")
|
|
94
|
+
self.attached = False
|
|
95
|
+
self.terminal = True
|
|
96
|
+
return {"detached": True}
|
|
97
|
+
|
|
98
|
+
def poll(self):
|
|
99
|
+
# Removing process events updates LLDB's public state/thread snapshot.
|
|
100
|
+
# Polling GetState alone can leave the initial attach stop cached.
|
|
101
|
+
for _ in range(32):
|
|
102
|
+
event = lldb.SBEvent()
|
|
103
|
+
if not self.debugger.GetListener().GetNextEvent(event):
|
|
104
|
+
break
|
|
105
|
+
if lldb.SBProcess.EventIsProcessEvent(event):
|
|
106
|
+
lldb.SBProcess.GetStateFromEvent(event)
|
|
107
|
+
if not self.process or self.terminal:
|
|
108
|
+
return
|
|
109
|
+
state = self.process.GetState()
|
|
110
|
+
if state in (lldb.eStateExited, lldb.eStateDetached):
|
|
111
|
+
self.inspection.invalidate()
|
|
112
|
+
self.terminal = True
|
|
113
|
+
send({"event": "terminated", "reason": "process_exit" if state == lldb.eStateExited else "detached"})
|
|
114
|
+
elif state == lldb.eStateStopped and self.process.GetStopID() != self.last_stop:
|
|
115
|
+
self.breakpoints.refresh()
|
|
116
|
+
event = self.inspection.stopped(self.process)
|
|
117
|
+
if event:
|
|
118
|
+
self.last_stop = self.process.GetStopID()
|
|
119
|
+
self.paused_at = time.monotonic()
|
|
120
|
+
send(event)
|
|
121
|
+
|
|
122
|
+
def command(self, args):
|
|
123
|
+
command = args["command"]
|
|
124
|
+
if command == "attach":
|
|
125
|
+
return self.attach(args)
|
|
126
|
+
if command == "detach":
|
|
127
|
+
return self.detach()
|
|
128
|
+
if not self.process or self.terminal:
|
|
129
|
+
raise ValueError("DEBUGGER_DISCONNECTED")
|
|
130
|
+
if command in ("heartbeat", "state"):
|
|
131
|
+
return {"breakpoints": self.breakpoints.refresh()}
|
|
132
|
+
if command == "set":
|
|
133
|
+
return self.breakpoints.set(args)
|
|
134
|
+
if command == "remove":
|
|
135
|
+
return self.breakpoints.remove(args["breakpointId"])
|
|
136
|
+
if command in ("stack", "locals", "step", "resume"):
|
|
137
|
+
if args.get("stopId") != self.inspection.stop_id:
|
|
138
|
+
raise ValueError("DEBUGGER_STALE_REFERENCE")
|
|
139
|
+
if self.process.GetState() != lldb.eStateStopped:
|
|
140
|
+
raise ValueError("DEBUGGER_NOT_PAUSED")
|
|
141
|
+
if command == "stack":
|
|
142
|
+
return self.inspection.stack(args)
|
|
143
|
+
if command == "locals":
|
|
144
|
+
return self.inspection.locals(args)
|
|
145
|
+
if command in ("step", "resume"):
|
|
146
|
+
thread = self.inspection.thread(args) if command == "step" else None
|
|
147
|
+
self.inspection.invalidate()
|
|
148
|
+
self.paused_at = None
|
|
149
|
+
if command == "resume":
|
|
150
|
+
if not self.process.Continue().Success():
|
|
151
|
+
raise ValueError("DEBUGGER_WORKER_FAILED")
|
|
152
|
+
else:
|
|
153
|
+
error = lldb.SBError()
|
|
154
|
+
if args["kind"] == "into":
|
|
155
|
+
thread.StepInto(None, lldb.LLDB_INVALID_LINE_NUMBER, error, lldb.eOnlyDuringStepping)
|
|
156
|
+
elif args["kind"] == "over":
|
|
157
|
+
thread.StepOver(lldb.eOnlyDuringStepping, error)
|
|
158
|
+
else:
|
|
159
|
+
thread.StepOut(error)
|
|
160
|
+
if not error.Success():
|
|
161
|
+
raise ValueError("DEBUGGER_WORKER_FAILED")
|
|
162
|
+
return {"resumed": True}
|
|
163
|
+
raise ValueError("INVALID_ARGUMENT")
|
|
164
|
+
|
|
165
|
+
def expired(self):
|
|
166
|
+
now = time.monotonic()
|
|
167
|
+
return ("pause_limit" if self.paused_at and now - self.paused_at >= 60 else
|
|
168
|
+
"idle_limit" if now - self.activity >= 300 else
|
|
169
|
+
"session_limit" if now - self.created >= 1800 else None)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def main():
|
|
173
|
+
worker = Worker()
|
|
174
|
+
threading.Thread(target=read_input, daemon=True).start()
|
|
175
|
+
try:
|
|
176
|
+
while not eof.is_set():
|
|
177
|
+
worker.poll()
|
|
178
|
+
reason = worker.expired()
|
|
179
|
+
if reason:
|
|
180
|
+
worker.detach()
|
|
181
|
+
send({"event": "terminated", "reason": reason})
|
|
182
|
+
break
|
|
183
|
+
try:
|
|
184
|
+
args = requests.get(timeout=0.02)
|
|
185
|
+
except queue.Empty:
|
|
186
|
+
continue
|
|
187
|
+
worker.activity = time.monotonic()
|
|
188
|
+
try:
|
|
189
|
+
result = worker.command(args)
|
|
190
|
+
send({"id": args["id"], "result": result})
|
|
191
|
+
except Exception as error:
|
|
192
|
+
allowed = {"INVALID_ARGUMENT", "DEBUGGER_NOT_PAUSED", "DEBUGGER_STALE_REFERENCE", "DEBUGGER_DISCONNECTED"}
|
|
193
|
+
code = str(error) if isinstance(error, ValueError) and str(error) in allowed else "DEBUGGER_WORKER_FAILED"
|
|
194
|
+
send({"id": args["id"], "error": {"code": code}})
|
|
195
|
+
finally:
|
|
196
|
+
try:
|
|
197
|
+
worker.detach()
|
|
198
|
+
finally:
|
|
199
|
+
lldb.SBDebugger.Destroy(worker.debugger)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
if __name__ == "__main__":
|
|
203
|
+
main()
|
package/src/debugger/sessions.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { atStage, classify, diagnostic, type DebuggerDiagnostic, type DiagnosticStage } from './diagnostics.js';
|
|
2
2
|
import type { WorkerReply } from './protocol.js';
|
|
3
3
|
import { registerDebuggerPauseProvider } from './guard.js';
|
|
4
|
+
import { IOSDebugNative } from './ios.js';
|
|
5
|
+
import { createIOSWorker, prepareIOSWorker, iosCapabilities, iosLimitations } from './ios-worker.js';
|
|
4
6
|
import { randomUUID } from 'node:crypto';
|
|
5
7
|
import { AndroidDebugNative, type DebugNative, type DebugTarget } from './android.js';
|
|
6
8
|
import { createWorker, prepareWorker, DebuggerError, withDebuggerBudget, debuggerRemaining, type DebugWorker, type WorkerEvent } from './worker.js';
|
|
@@ -56,14 +58,16 @@ interface ResultPayload {
|
|
|
56
58
|
}
|
|
57
59
|
export const debuggerCapabilities = { attach: true, breakpoints: true, stacks: true, locals: true, step: true, resume: true,
|
|
58
60
|
evaluation: false, mutation: false, coroutines: false, native: false };
|
|
59
|
-
const
|
|
61
|
+
export const androidLimitations = ['JAVA_KOTLIN_BYTECODE_ONLY', 'DEBUG_METADATA_REQUIRED', 'SHALLOW_LOCALS', 'ALL_THREADS_SUSPEND', 'DEVICE_WIDE_UI_GUARD'];
|
|
60
62
|
export function debuggerExpiry(created: number, activity: number, pausedAt: number | undefined, now: number) {
|
|
61
63
|
return pausedAt !== undefined && now - pausedAt >= 60000 ? 'pause_limit' : now - activity >= 300000 ? 'idle_limit' : now - created >= 1800000 ? 'session_limit' : null;
|
|
62
64
|
}
|
|
63
65
|
export class DebuggerSessions {
|
|
64
66
|
private sessions = new Map<string, Session>();
|
|
65
67
|
private closing = false;
|
|
66
|
-
constructor(private native: DebugNative = new AndroidDebugNative(), private workerFactory = createWorker, private prerequisite = prepareWorker) { }
|
|
68
|
+
constructor(private native: DebugNative = new AndroidDebugNative(), private workerFactory: (event: (event: WorkerEvent) => void) => Promise<DebugWorker> = createWorker, private prerequisite: () => Promise<unknown> = prepareWorker, private options: { platform?: 'android' | 'ios'; capabilities?: Record<string, boolean>; limitations?: string[] } = {}) { }
|
|
69
|
+
private get platform() { return this.options.platform ?? 'android'; }
|
|
70
|
+
private get workerResource() { return this.platform === 'ios' ? 'lldb_worker' : 'jdi_worker'; }
|
|
67
71
|
private prune() {
|
|
68
72
|
const terminal = [...this.sessions.values()].filter(s => s.terminalAt !== undefined && s.cleanup.status === 'complete');
|
|
69
73
|
for (const s of terminal)
|
|
@@ -80,9 +84,10 @@ export class DebuggerSessions {
|
|
|
80
84
|
const success = payload.success ?? (!['failed', 'disconnected'].includes(s.state) && s.cleanup.status !== 'failed');
|
|
81
85
|
const code = s.cleanup.status === 'failed' ? 'DEBUGGER_CLEANUP_FAILED' : s.state === 'disconnected' ? 'DEBUGGER_DISCONNECTED' : s.reason?.match(/^[A-Z_]+$/) ? s.reason : 'DEBUGGER_WORKER_FAILED';
|
|
82
86
|
return structuredClone({ success, ...(!success ? { error: { code, message: code, retryable: false, diagnostic: s.cleanup.status === 'failed' ? diagnostic('cleanup', 'operation_failed') : s.diagnostic ?? diagnostic('unknown', 'unknown') } } : {}), debug_session_id: s.id,
|
|
83
|
-
device: { platform:
|
|
84
|
-
capabilities: debuggerCapabilities, limitations, cleanup: s.cleanup, reason: s.reason, stop: s.stop, ...payload });
|
|
87
|
+
device: { platform: this.platform, id: s.device }, target: s.target, state: s.state, captured_at_ms: Date.now(),
|
|
88
|
+
capabilities: this.options.capabilities ?? debuggerCapabilities, limitations: this.options.limitations ?? androidLimitations, cleanup: s.cleanup, reason: s.reason, stop: s.stop, ...payload });
|
|
85
89
|
}
|
|
90
|
+
owns(id: string) { return this.sessions.has(id); }
|
|
86
91
|
summary(id: string) { return this.result(this.get(id)); }
|
|
87
92
|
hasActiveSessions() { return [...this.sessions.values()].some(s => s.cleanup.status !== 'complete'); }
|
|
88
93
|
paused(device: string) { const s = [...this.sessions.values()].find(s => s.device === device && s.state === 'paused'); return s ? this.result(s) : null; }
|
|
@@ -112,13 +117,13 @@ export class DebuggerSessions {
|
|
|
112
117
|
s.events.shift();
|
|
113
118
|
}
|
|
114
119
|
async capabilities(device: string, app: string, pid?: number) {
|
|
115
|
-
const prerequisites: Record<string, boolean> = { jdk: false, device: false, process: false };
|
|
120
|
+
const prerequisites: Record<string, boolean> = { [this.platform === 'ios' ? 'lldb' : 'jdk']: false, device: false, process: false };
|
|
116
121
|
let candidates: DebugTarget[] = [];
|
|
117
122
|
const reasons: string[] = [];
|
|
118
123
|
const diagnostics: DebuggerDiagnostic[] = [];
|
|
119
124
|
try {
|
|
120
125
|
await atStage('prerequisite', 'DEBUGGER_UNSUPPORTED', () => this.prerequisite());
|
|
121
|
-
prerequisites.jdk = true;
|
|
126
|
+
prerequisites[this.platform === 'ios' ? 'lldb' : 'jdk'] = true;
|
|
122
127
|
}
|
|
123
128
|
catch (e) {
|
|
124
129
|
if (e instanceof DebuggerError && ['DEBUGGER_TIMEOUT', 'DEBUGGER_CANCELLED'].includes(e.code))
|
|
@@ -139,9 +144,9 @@ export class DebuggerSessions {
|
|
|
139
144
|
reasons.push(e instanceof DebuggerError ? e.code : 'DEVICE_UNAVAILABLE');
|
|
140
145
|
diagnostics.push(classify(e, 'target_resolution', 'DEVICE_UNAVAILABLE').diagnostic ?? diagnostic('target_resolution', 'unknown'));
|
|
141
146
|
}
|
|
142
|
-
return { success: true, debug_session_id: null, device: { platform:
|
|
147
|
+
return { success: true, debug_session_id: null, device: { platform: this.platform, id: device }, target: null, state: null,
|
|
143
148
|
captured_at_ms: Date.now(), supported: Object.values(prerequisites).every(Boolean), prerequisites, candidates,
|
|
144
|
-
...(diagnostics.length ? { diagnostics } : {}), capabilities: debuggerCapabilities, reasons, limitations, cleanup: { status: 'complete', remaining_resources: [] } };
|
|
149
|
+
...(diagnostics.length ? { diagnostics } : {}), capabilities: this.options.capabilities ?? debuggerCapabilities, reasons, limitations: this.options.limitations ?? androidLimitations, cleanup: { status: 'complete', remaining_resources: [] } };
|
|
145
150
|
}
|
|
146
151
|
async attach(device: string, app: string, pid?: number, timeout = 10000) {
|
|
147
152
|
this.prune();
|
|
@@ -167,16 +172,19 @@ export class DebuggerSessions {
|
|
|
167
172
|
if (candidates.length > 1)
|
|
168
173
|
throw new DebuggerError('PROCESS_AMBIGUOUS');
|
|
169
174
|
s.target = candidates[0];
|
|
175
|
+
if (this.platform === 'android') {
|
|
170
176
|
stage = 'forwarding';
|
|
171
177
|
s.port = await atStage('forwarding', 'DEBUGGER_ATTACH_FAILED', () => this.native.forward(device, s.target!.pid));
|
|
172
178
|
remaining();
|
|
173
179
|
s.cleanup.remaining_resources.push(`forward:tcp:${s.port}`);
|
|
180
|
+
}
|
|
174
181
|
stage = 'worker_start';
|
|
175
182
|
s.worker = await atStage('worker_start', 'DEBUGGER_ATTACH_FAILED', () => this.workerFactory(event => this.event(s, event)));
|
|
176
183
|
remaining();
|
|
177
|
-
s.cleanup.remaining_resources.push(
|
|
184
|
+
s.cleanup.remaining_resources.push(this.workerResource);
|
|
185
|
+
if (this.platform === 'ios') await atStage('target_resolution', 'DEBUGGER_ATTACH_FAILED', () => this.native.identity(device, s.target!));
|
|
178
186
|
stage = 'attach';
|
|
179
|
-
await atStage('attach', 'DEBUGGER_ATTACH_FAILED', () => s.worker!.call('attach', { port: s.port, timeoutMs: remaining() }, remaining()));
|
|
187
|
+
await atStage('attach', 'DEBUGGER_ATTACH_FAILED', () => s.worker!.call('attach', { port: s.port, ...(this.platform === 'ios' ? { pid: s.target!.pid, executablePath: s.target!.executable_path } : {}), timeoutMs: remaining() }, remaining()));
|
|
180
188
|
stage = 'target_resolution';
|
|
181
189
|
await atStage('target_resolution', 'DEBUGGER_ATTACH_FAILED', () => this.native.identity(device, s.target!));
|
|
182
190
|
remaining();
|
|
@@ -227,7 +235,7 @@ export class DebuggerSessions {
|
|
|
227
235
|
s.worker = undefined;
|
|
228
236
|
}
|
|
229
237
|
catch {
|
|
230
|
-
remaining.push(
|
|
238
|
+
remaining.push(this.workerResource);
|
|
231
239
|
}
|
|
232
240
|
}
|
|
233
241
|
if (s.port !== undefined && s.target) {
|
|
@@ -275,6 +283,7 @@ export class DebuggerSessions {
|
|
|
275
283
|
private assertCommandTarget(s: Session, command: string, args: CommandArgs) {
|
|
276
284
|
if (!s.worker || ['detaching', 'detached', 'disconnected', 'failed'].includes(s.state))
|
|
277
285
|
throw new DebuggerError('DEBUGGER_DISCONNECTED', s.diagnostic ?? diagnostic('command', 'unavailable'));
|
|
286
|
+
if (command === 'set' && (this.platform === 'ios' ? typeof args.sourceFile !== 'string' || args.className !== undefined : typeof args.className !== 'string' || args.sourceFile !== undefined)) throw new DebuggerError('INVALID_ARGUMENT');
|
|
278
287
|
if (['stack', 'locals', 'step', 'resume'].includes(command)) {
|
|
279
288
|
if (args.stopId !== s.stop?.stop_id)
|
|
280
289
|
throw new DebuggerError('DEBUGGER_STALE_REFERENCE');
|
|
@@ -291,6 +300,7 @@ export class DebuggerSessions {
|
|
|
291
300
|
if (command === 'state' && (s.cleanup.status === 'complete' || ['detaching', 'detached', 'disconnected', 'failed'].includes(s.state)))
|
|
292
301
|
return this.result(s);
|
|
293
302
|
this.assertCommandTarget(s, command, args);
|
|
303
|
+
const previous = { state: s.state, stop: s.stop, pausedAt: s.pausedAt };
|
|
294
304
|
if (command === 'step' || command === 'resume') {
|
|
295
305
|
s.state = 'running';
|
|
296
306
|
s.stop = null;
|
|
@@ -301,6 +311,7 @@ export class DebuggerSessions {
|
|
|
301
311
|
return this.result(s, command === 'state' ? { breakpoints: data.breakpoints } : { result: data });
|
|
302
312
|
}
|
|
303
313
|
catch (e) {
|
|
314
|
+
if (['step', 'resume'].includes(command) && s.state === 'running' && s.stop === null && e instanceof DebuggerError && ['INVALID_ARGUMENT', 'DEBUGGER_STALE_REFERENCE'].includes(e.code)) Object.assign(s, previous);
|
|
304
315
|
return this.handleCommandError(s, command, classify(e, 'command', 'DEBUGGER_WORKER_FAILED'));
|
|
305
316
|
}
|
|
306
317
|
}
|
|
@@ -324,4 +335,5 @@ export class DebuggerSessions {
|
|
|
324
335
|
async shutdown() { this.closing = true; await Promise.allSettled([...this.sessions.values()].filter(s => s.cleanup.status !== 'complete').map(s => this.serial(s, () => this.cleanup(s, true)))); return [...this.sessions.values()].filter(s => s.cleanup.status !== 'complete').map(s => this.result(s)); }
|
|
325
336
|
}
|
|
326
337
|
export const debuggerSessions = new DebuggerSessions();
|
|
327
|
-
|
|
338
|
+
export const iosDebuggerSessions = new DebuggerSessions(new IOSDebugNative(), createIOSWorker, prepareIOSWorker, { platform: 'ios', capabilities: iosCapabilities, limitations: iosLimitations });
|
|
339
|
+
registerDebuggerPauseProvider((device, platform = 'android') => (platform === 'ios' ? iosDebuggerSessions : debuggerSessions).paused(device));
|
package/src/debugger/worker.ts
CHANGED
|
@@ -58,7 +58,7 @@ export interface DebugWorker {
|
|
|
58
58
|
close(): Promise<void>
|
|
59
59
|
}
|
|
60
60
|
type WorkerFailureReason = 'spawn_failed' | 'process_exited' | 'write_failed' | 'invalid_message' | 'message_limit'
|
|
61
|
-
export class
|
|
61
|
+
export class DebuggerProcess implements DebugWorker {
|
|
62
62
|
private child: ChildProcessWithoutNullStreams
|
|
63
63
|
private pending = new Map<number, { resolve(value: WorkerReply): void; reject(error: Error): void; timer: ReturnType<typeof setTimeout>; command: string }>()
|
|
64
64
|
private sequence = 0
|
|
@@ -66,8 +66,8 @@ export class JdiWorker implements DebugWorker {
|
|
|
66
66
|
private failure?: DebuggerError
|
|
67
67
|
private replied = false
|
|
68
68
|
private exited: Promise<void>
|
|
69
|
-
constructor(java: string, directory: string, private onEvent: (event: WorkerEvent) => void) {
|
|
70
|
-
this.child = spawn(java, ['-Xmx128m', '--add-modules', 'jdk.jdi', '-cp', directory, 'DebuggerWorker'], { stdio: ['pipe', 'pipe', 'pipe'] })
|
|
69
|
+
constructor(java: string, directory: string, private onEvent: (event: WorkerEvent) => void, launch?: { args: string[]; env: NodeJS.ProcessEnv }) {
|
|
70
|
+
this.child = spawn(java, launch?.args ?? ['-Xmx128m', '--add-modules', 'jdk.jdi', '-cp', directory, 'DebuggerWorker'], { stdio: ['pipe', 'pipe', 'pipe'], ...(launch ? { env: launch.env } : {}) })
|
|
71
71
|
let buffer = ''
|
|
72
72
|
this.exited = new Promise(resolve => { this.child.once('exit', () => { this.fail('process_exited'); resolve() }); this.child.once('error', () => { this.fail('spawn_failed'); resolve() }) })
|
|
73
73
|
this.child.stdin.on('error', () => this.fail('write_failed'))
|
|
@@ -131,5 +131,8 @@ export class JdiWorker implements DebugWorker {
|
|
|
131
131
|
}
|
|
132
132
|
export async function createWorker(onEvent: (event: WorkerEvent) => void) {
|
|
133
133
|
const { java, directory } = await prepareWorker()
|
|
134
|
-
return new
|
|
134
|
+
return new DebuggerProcess(java, directory, onEvent)
|
|
135
135
|
}
|
|
136
|
+
|
|
137
|
+
// Compatibility export for existing JDI transport callers/tests.
|
|
138
|
+
export { DebuggerProcess as JdiWorker }
|
package/src/interact/index.ts
CHANGED
|
@@ -157,11 +157,22 @@ export class ToolsInteract {
|
|
|
157
157
|
return await interact.tap(x, y, resolved.id, timeoutMs)
|
|
158
158
|
}
|
|
159
159
|
|
|
160
|
+
private static _getResolvedElement(elementId: string, platform?: 'android' | 'ios', deviceId?: string) {
|
|
161
|
+
const resolved = ToolsInteract._resolvedUiElements.get(elementId)
|
|
162
|
+
if (!resolved || (platform && resolved.platform !== platform) || (deviceId && resolved.deviceId !== deviceId)) return null
|
|
163
|
+
return resolved
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
static getResolvedElementTarget(elementId: string, platform?: 'android' | 'ios', deviceId?: string) {
|
|
167
|
+
const resolved = ToolsInteract._getResolvedElement(elementId, platform, deviceId)
|
|
168
|
+
return resolved ? { platform: resolved.platform, deviceId: resolved.deviceId } : null
|
|
169
|
+
}
|
|
170
|
+
|
|
160
171
|
static async tapElementHandler({ elementId, verificationMode = 'full', timeoutMs, platform, deviceId, freshTree }: { elementId: string, verificationMode?: 'none' | 'light' | 'full', timeoutMs?: number, platform?: 'android' | 'ios', deviceId?: string, freshTree?: UiTree }): Promise<TapElementResponse> {
|
|
161
172
|
const actionType = 'tap_element'
|
|
162
173
|
const selector = { elementId }
|
|
163
|
-
const resolved = ToolsInteract.
|
|
164
|
-
if (!resolved
|
|
174
|
+
const resolved = ToolsInteract._getResolvedElement(elementId, platform, deviceId)
|
|
175
|
+
if (!resolved) {
|
|
165
176
|
return ToolsInteract._actionFailure(actionType, selector, null, 'STALE_REFERENCE', true, null)
|
|
166
177
|
}
|
|
167
178
|
|
package/src/manage/ios.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { checkDebuggerGuard } from '../debugger/guard.js'
|
|
1
2
|
import { buildIOS } from './ios/build.js'
|
|
2
3
|
import type { IOSBuildOptions } from './ios/build-types.js'
|
|
3
4
|
export type { IOSBuildOptions } from './ios/build-types.js'
|
|
@@ -160,6 +161,7 @@ export class iOSManage {
|
|
|
160
161
|
|
|
161
162
|
async function installWithIdb(toInstall: string, deviceId: string) {
|
|
162
163
|
await new Promise<void>((resolve, reject) => {
|
|
164
|
+
checkDebuggerGuard(true)
|
|
163
165
|
const proc = spawn(getIdbCmd(), ['install', toInstall, '--udid', deviceId]);
|
|
164
166
|
let stderr = '';
|
|
165
167
|
proc.stderr.on('data', d => stderr += d.toString());
|