Analysis pulled every export, import and symbol across the wire and then kept the handful that name code -- 87k rows for a kernel, each carrying a type and an address as text. Ask for what it uses instead: the offset, name and source of each function the module knows about, chosen where the rows already are.
1549 lines
59 KiB
Swift
1549 lines
59 KiB
Swift
import Foundation
|
|
import Frida
|
|
import Observation
|
|
|
|
@Observable
|
|
@MainActor
|
|
public final class ProcessNode: Identifiable {
|
|
public enum Phase: String, Sendable {
|
|
case attaching
|
|
case attached
|
|
case detached
|
|
}
|
|
|
|
public let id = UUID()
|
|
public let sessionID: UUID
|
|
|
|
public let deviceID: String
|
|
public let deviceName: String
|
|
public let pid: UInt
|
|
public let processName: String
|
|
public let processIcons: [Icon]
|
|
|
|
public internal(set) var phase: Phase
|
|
public internal(set) var lastSeenAt: Date
|
|
|
|
public private(set) var modules: [ProcessModule] = []
|
|
public private(set) var threads: [ProcessThread] = []
|
|
public private(set) var mainModule: ProcessModule?
|
|
public private(set) var processInfo: ProcessInfo?
|
|
|
|
public let device: Device
|
|
private let process: ProcessDetails
|
|
private let session: Session
|
|
private let script: Script
|
|
|
|
public enum AttachmentState: String, Sendable {
|
|
case attached
|
|
case detached
|
|
}
|
|
|
|
public struct InstrumentRef: Sendable {
|
|
public let id: UUID
|
|
public let kind: InstrumentKind
|
|
public let sourceIdentifier: String
|
|
public var configJSON: Data
|
|
public var state: InstrumentState
|
|
public var attachment: AttachmentState
|
|
public var status: InstrumentStatus?
|
|
public var componentStatuses: [UUID: InstrumentStatus]
|
|
|
|
public init(
|
|
id: UUID,
|
|
kind: InstrumentKind,
|
|
sourceIdentifier: String,
|
|
configJSON: Data,
|
|
state: InstrumentState = .enabled,
|
|
attachment: AttachmentState = .detached,
|
|
status: InstrumentStatus? = nil,
|
|
componentStatuses: [UUID: InstrumentStatus] = [:]
|
|
) {
|
|
self.id = id
|
|
self.kind = kind
|
|
self.sourceIdentifier = sourceIdentifier
|
|
self.configJSON = configJSON
|
|
self.state = state
|
|
self.attachment = attachment
|
|
self.status = status
|
|
self.componentStatuses = componentStatuses
|
|
}
|
|
}
|
|
|
|
public private(set) var instruments: [InstrumentRef] = []
|
|
|
|
public var loadedPackageNames = Set<String>()
|
|
|
|
private let _events = AsyncEventSource<RuntimeEvent>()
|
|
private let _replResults = AsyncEventSource<REPLResult>()
|
|
private let _traceUpdates = AsyncEventSource<ITrace>()
|
|
private let _moduleDeltas = AsyncEventSource<ModuleDelta>()
|
|
private let _threadDeltas = AsyncEventSource<ThreadDelta>()
|
|
private let _detachEvents = AsyncEventSource<SessionDetachReason>()
|
|
private let _widgetUpdates = AsyncEventSource<WidgetUpdate>()
|
|
|
|
public var events: AsyncStream<RuntimeEvent> { _events.makeStream() }
|
|
public var replResults: AsyncStream<REPLResult> { _replResults.makeStream() }
|
|
public var traceUpdates: AsyncStream<ITrace> { _traceUpdates.makeStream() }
|
|
public var moduleDeltas: AsyncStream<ModuleDelta> { _moduleDeltas.makeStream() }
|
|
public var threadDeltas: AsyncStream<ThreadDelta> { _threadDeltas.makeStream() }
|
|
public var detachEvents: AsyncStream<SessionDetachReason> { _detachEvents.makeStream() }
|
|
public var widgetUpdates: AsyncStream<WidgetUpdate> { _widgetUpdates.makeStream() }
|
|
|
|
private var scriptEventsStarted = false
|
|
private var scriptEventsStartWaiters: [CheckedContinuation<Void, Never>] = []
|
|
private var moduleSnapshotState: ModuleSnapshotState = .pending
|
|
private var moduleSnapshotWaiters: [CheckedContinuation<Void, Swift.Error>] = []
|
|
|
|
private enum ModuleSnapshotState: Equatable {
|
|
case pending
|
|
case ready
|
|
case detached
|
|
}
|
|
|
|
private var systemDrainTasks: [String: Task<Void, Never>] = [:]
|
|
private var pendingTraces: [String: PendingTrace] = [:]
|
|
private var pendingEmits: [String: Task<Void, Never>] = [:]
|
|
private static let runningTraceEmitInterval: UInt64 = 250_000_000
|
|
private static let systemDrainInterval: UInt64 = 50_000_000
|
|
|
|
private let drainService: SystemDrainService?
|
|
private let blobStore: BlobStore?
|
|
|
|
public var onResolveBlockNames: (@MainActor ([UInt64]) async -> [String])?
|
|
|
|
struct PendingTrace {
|
|
let id: UUID
|
|
let origin: ITrace.Origin
|
|
let displayName: String
|
|
let startedAt: Date
|
|
var hookTarget: String?
|
|
var prologueBytes: String?
|
|
var accumulated: Data
|
|
var lost: Int
|
|
}
|
|
|
|
public init(
|
|
sessionID: UUID,
|
|
device: Device,
|
|
process: ProcessDetails,
|
|
session: Session,
|
|
script: Script,
|
|
instruments: [InstrumentRef] = [],
|
|
drainService: SystemDrainService? = nil,
|
|
blobStore: BlobStore? = nil
|
|
) {
|
|
self.sessionID = sessionID
|
|
self.device = device
|
|
self.process = process
|
|
self.session = session
|
|
self.script = script
|
|
self.deviceID = device.id
|
|
self.deviceName = device.name
|
|
self.pid = process.pid
|
|
self.processName = process.name
|
|
self.processIcons = process.icons
|
|
self.phase = .attached
|
|
self.lastSeenAt = Date()
|
|
self.instruments = instruments
|
|
self.drainService = drainService
|
|
self.blobStore = blobStore
|
|
|
|
startObservingSessionState()
|
|
startObservingScriptMessages()
|
|
}
|
|
|
|
public func stop() {
|
|
Task { @MainActor in
|
|
for instrument in instruments where instrument.attachment == .attached {
|
|
_ = try? await script.exports.disposeInstrument(["instanceId": instrument.id.uuidString])
|
|
}
|
|
await tearDownITrace()
|
|
try? await session.detach()
|
|
}
|
|
}
|
|
|
|
public func kill() async throws {
|
|
try await device.kill(pid: pid)
|
|
}
|
|
|
|
public func resume() async throws {
|
|
try await device.resume(pid: pid)
|
|
}
|
|
|
|
public func loadPackages(_ bundles: [Any]) async throws {
|
|
try await script.exports.loadPackages(JSValue(bundles))
|
|
}
|
|
|
|
private func startObservingSessionState() {
|
|
Task { @MainActor [weak self] in
|
|
guard let self else { return }
|
|
|
|
for await event in session.events {
|
|
switch event {
|
|
case .detached(let reason, _):
|
|
await self.finalizePendingTracesOnCrash()
|
|
self.failInitialModulesSnapshotWaitersIfNeeded()
|
|
self._detachEvents.yield(reason)
|
|
self._events.finish()
|
|
self._replResults.finish()
|
|
self._traceUpdates.finish()
|
|
self._moduleDeltas.finish()
|
|
self._threadDeltas.finish()
|
|
self._detachEvents.finish()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func startObservingScriptMessages() {
|
|
Task { @MainActor [weak self] in
|
|
guard let self else { return }
|
|
|
|
self.scriptEventsStarted = true
|
|
let waiters = self.scriptEventsStartWaiters
|
|
self.scriptEventsStartWaiters.removeAll(keepingCapacity: false)
|
|
for w in waiters { w.resume() }
|
|
|
|
for await event in script.events {
|
|
switch event {
|
|
case .message(let message, let data):
|
|
if !self.tryHandleMessage(message, data: data) {
|
|
self._events.yield(RuntimeEvent(
|
|
source: .repl,
|
|
payload: .raw(message: message, data: data)
|
|
))
|
|
}
|
|
|
|
case .destroyed:
|
|
break
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
public func waitForScriptEventsSubscription() async {
|
|
if scriptEventsStarted { return }
|
|
|
|
await withCheckedContinuation { cont in
|
|
if scriptEventsStarted {
|
|
cont.resume()
|
|
} else {
|
|
scriptEventsStartWaiters.append(cont)
|
|
}
|
|
}
|
|
}
|
|
|
|
public func tryHandleMessage(_ message: Any, data: [UInt8]?) -> Bool {
|
|
guard let envelope = message as? [String: Any],
|
|
let envelopeType = envelope["type"] as? String
|
|
else {
|
|
return false
|
|
}
|
|
|
|
switch envelopeType {
|
|
|
|
case "send":
|
|
guard let inner = envelope["payload"],
|
|
let dict = inner as? [String: Any],
|
|
let type = dict["type"] as? String
|
|
else {
|
|
return false
|
|
}
|
|
|
|
switch type {
|
|
|
|
case "modules-changed":
|
|
let addedDicts = (dict["added"] as? [[String: Any]]) ?? []
|
|
let removedDicts = (dict["removed"] as? [[String: Any]]) ?? []
|
|
|
|
let added = addedDicts.compactMap(ProcessModule.fromJSON)
|
|
let removed = removedDicts.compactMap(ProcessModule.fromJSON)
|
|
|
|
if !removed.isEmpty {
|
|
let removedBases = Set(removed.map { $0.base })
|
|
modules.removeAll { removedBases.contains($0.base) }
|
|
}
|
|
|
|
modules.append(contentsOf: added)
|
|
markInitialModulesReadyIfNeeded()
|
|
|
|
_moduleDeltas.yield(ModuleDelta(added: added, removed: removed))
|
|
|
|
return true
|
|
|
|
case "threads-changed":
|
|
let addedDicts = (dict["added"] as? [[String: Any]]) ?? []
|
|
let removedIDs = (dict["removed"] as? [Int]) ?? []
|
|
let renamedDicts = (dict["renamed"] as? [[String: Any]]) ?? []
|
|
|
|
let addedThreads = addedDicts.compactMap(ProcessThread.fromJSON)
|
|
|
|
let removedSet = Set(removedIDs.map { UInt($0) })
|
|
if !removedSet.isEmpty {
|
|
threads.removeAll { removedSet.contains($0.id) }
|
|
}
|
|
|
|
threads.append(contentsOf: addedThreads)
|
|
|
|
var renames: [ThreadDelta.Rename] = []
|
|
for entry in renamedDicts {
|
|
guard let rawID = entry["id"] as? Int else { continue }
|
|
let tid = UInt(rawID)
|
|
let newName = entry["name"] as? String
|
|
if let i = threads.firstIndex(where: { $0.id == tid }) {
|
|
threads[i].name = newName
|
|
}
|
|
renames.append(ThreadDelta.Rename(id: tid, name: newName))
|
|
}
|
|
|
|
_threadDeltas.yield(ThreadDelta(
|
|
added: addedThreads,
|
|
removed: Array(removedSet),
|
|
renamed: renames
|
|
))
|
|
|
|
return true
|
|
|
|
case "console":
|
|
guard let levelString = dict["level"] as? String,
|
|
let level = ConsoleLevel(rawValue: levelString),
|
|
let encodedArgs = dict["args"] as? [Any]
|
|
else {
|
|
return false
|
|
}
|
|
|
|
var values: [JSInspectValue] = []
|
|
for encoded in encodedArgs {
|
|
guard let value = try? JSInspectValue.decodePacked(tree: encoded, blobBytes: data) else {
|
|
return false
|
|
}
|
|
values.append(value)
|
|
}
|
|
|
|
_events.yield(RuntimeEvent(
|
|
source: .console,
|
|
payload: .consoleMessage(ConsoleMessage(level: level, values: values)),
|
|
data: data.map { Array($0) }
|
|
))
|
|
|
|
return true
|
|
|
|
case "itrace:start":
|
|
guard let token = dict["token"] as? Int,
|
|
let sessionId = dict["sessionId"] as? String,
|
|
let bufferLocation = dict["bufferLocation"] as? String,
|
|
let originDict = dict["origin"] as? [String: Any],
|
|
let origin = parseTraceOrigin(originDict)
|
|
else { return false }
|
|
|
|
let hookTarget = dict["hookTarget"] as? String
|
|
let prologueBytes = dict["prologueBytes"] as? String
|
|
Task { @MainActor in
|
|
await self.handleITraceStart(
|
|
token: token,
|
|
sessionId: sessionId,
|
|
origin: origin,
|
|
bufferLocation: bufferLocation,
|
|
hookTarget: hookTarget,
|
|
prologueBytes: prologueBytes)
|
|
}
|
|
return true
|
|
|
|
case "itrace:stop":
|
|
guard let sessionId = dict["sessionId"] as? String else { return false }
|
|
let lost = dict["lost"] as? Int ?? 0
|
|
Task { @MainActor in
|
|
await self.handleITraceStop(sessionId: sessionId, lost: lost, data: data)
|
|
}
|
|
return true
|
|
|
|
case "itrace:chunk":
|
|
guard let sessionId = dict["sessionId"] as? String,
|
|
let chunkData = data
|
|
else { return false }
|
|
let lost = dict["lost"] as? Int ?? 0
|
|
handleITraceChunk(sessionId: sessionId, data: Array(chunkData), lost: lost)
|
|
return true
|
|
|
|
case "instrument-event":
|
|
guard let instanceId = dict["instance_id"] as? String,
|
|
let instrumentID = UUID(uuidString: instanceId),
|
|
let instrument = instruments.first(where: { $0.id == instrumentID }),
|
|
let encodedPayload = dict["payload"]
|
|
else {
|
|
return false
|
|
}
|
|
|
|
guard let payload = try? JSInspectValue.decodePacked(tree: encodedPayload, blobBytes: data) else {
|
|
return false
|
|
}
|
|
|
|
_events.yield(RuntimeEvent(
|
|
source: .instrument(id: instrument.id, name: instrument.sourceIdentifier),
|
|
payload: .jsValue(payload),
|
|
data: data.map { Array($0) }
|
|
))
|
|
|
|
return true
|
|
|
|
case "widget-counter-set":
|
|
guard let update = decodeCounterSetUpdate(dict) else { return false }
|
|
_widgetUpdates.yield(update)
|
|
return true
|
|
|
|
case "widget-histogram-set":
|
|
guard let update = decodeHistogramSetUpdate(dict) else { return false }
|
|
_widgetUpdates.yield(update)
|
|
return true
|
|
|
|
case "widget-histogram-increment":
|
|
guard let update = decodeHistogramIncrementUpdate(dict) else { return false }
|
|
_widgetUpdates.yield(update)
|
|
return true
|
|
|
|
case "widget-graph-point":
|
|
guard let update = decodeGraphPointUpdate(dict) else { return false }
|
|
_widgetUpdates.yield(update)
|
|
return true
|
|
|
|
case "widget-list-upsert":
|
|
guard let update = decodeListUpsertUpdate(dict) else { return false }
|
|
_widgetUpdates.yield(update)
|
|
return true
|
|
|
|
case "widget-list-remove":
|
|
guard let update = decodeListRemoveUpdate(dict) else { return false }
|
|
_widgetUpdates.yield(update)
|
|
return true
|
|
|
|
case "widget-table-upsert":
|
|
guard let update = decodeTableUpsertUpdate(dict) else { return false }
|
|
_widgetUpdates.yield(update)
|
|
return true
|
|
|
|
case "widget-table-remove":
|
|
guard let update = decodeTableRemoveUpdate(dict) else { return false }
|
|
_widgetUpdates.yield(update)
|
|
return true
|
|
|
|
case "widget-hex-set":
|
|
guard let update = decodeHexSetUpdate(dict, data: data) else { return false }
|
|
_widgetUpdates.yield(update)
|
|
return true
|
|
|
|
case "widget-console-append":
|
|
guard let update = decodeConsoleAppendUpdate(dict, data: data) else { return false }
|
|
_widgetUpdates.yield(update)
|
|
return true
|
|
|
|
case "widget-console-reply-done":
|
|
guard let update = decodeConsoleReplyDoneUpdate(dict) else { return false }
|
|
_widgetUpdates.yield(update)
|
|
return true
|
|
|
|
case "widget-clear":
|
|
guard let update = decodeClearUpdate(dict) else { return false }
|
|
_widgetUpdates.yield(update)
|
|
return true
|
|
|
|
default:
|
|
return false
|
|
}
|
|
|
|
case "error":
|
|
guard let text = envelope["description"] as? String else {
|
|
return false
|
|
}
|
|
let fileName = envelope["fileName"] as? String
|
|
let lineNumber = envelope["lineNumber"] as? Int
|
|
let columnNumber = envelope["columnNumber"] as? Int
|
|
let stack = envelope["stack"] as? String
|
|
|
|
_events.yield(RuntimeEvent(
|
|
source: .script,
|
|
payload: .jsError(JSError(
|
|
text: text,
|
|
fileName: fileName,
|
|
lineNumber: lineNumber,
|
|
columnNumber: columnNumber,
|
|
stack: stack
|
|
))
|
|
))
|
|
return true
|
|
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
private func decodeCounterSetUpdate(_ dict: [String: Any]) -> WidgetUpdate? {
|
|
guard let context = decodeWidgetContext(dict),
|
|
let counterObj = dict["counter"] as? [String: Any],
|
|
let value = decodeNumber(counterObj["value"])
|
|
else { return nil }
|
|
return WidgetUpdate(
|
|
instanceID: context.instanceID,
|
|
widget: context.widget,
|
|
kind: .counterSet(WidgetCounterValue(
|
|
value: value,
|
|
unit: counterObj["unit"] as? String,
|
|
delta: decodeNumber(counterObj["delta"])
|
|
))
|
|
)
|
|
}
|
|
|
|
private func decodeHistogramSetUpdate(_ dict: [String: Any]) -> WidgetUpdate? {
|
|
guard let context = decodeWidgetContext(dict),
|
|
let bucketArr = dict["buckets"] as? [[String: Any]]
|
|
else { return nil }
|
|
let buckets = bucketArr.compactMap { obj -> WidgetHistogramBucket? in
|
|
guard let label = obj["label"] as? String,
|
|
let count = decodeNumber(obj["count"])
|
|
else { return nil }
|
|
return WidgetHistogramBucket(label: label, count: count)
|
|
}
|
|
return WidgetUpdate(instanceID: context.instanceID, widget: context.widget, kind: .histogramSet(buckets))
|
|
}
|
|
|
|
private func decodeHistogramIncrementUpdate(_ dict: [String: Any]) -> WidgetUpdate? {
|
|
guard let context = decodeWidgetContext(dict),
|
|
let label = dict["label"] as? String,
|
|
let by = decodeNumber(dict["by"])
|
|
else { return nil }
|
|
return WidgetUpdate(
|
|
instanceID: context.instanceID,
|
|
widget: context.widget,
|
|
kind: .histogramIncrement(label: label, by: by)
|
|
)
|
|
}
|
|
|
|
private func decodeGraphPointUpdate(_ dict: [String: Any]) -> WidgetUpdate? {
|
|
guard let context = decodeWidgetContext(dict),
|
|
let pointObj = dict["point"] as? [String: Any],
|
|
let series = pointObj["series"] as? String,
|
|
let x = decodeNumber(pointObj["x"]),
|
|
let y = decodeNumber(pointObj["y"])
|
|
else { return nil }
|
|
let point = WidgetGraphPoint(series: series, x: x, y: y)
|
|
return WidgetUpdate(instanceID: context.instanceID, widget: context.widget, kind: .graphPoint(point))
|
|
}
|
|
|
|
private func decodeListUpsertUpdate(_ dict: [String: Any]) -> WidgetUpdate? {
|
|
guard let context = decodeWidgetContext(dict),
|
|
let itemObj = dict["item"] as? [String: Any],
|
|
let id = itemObj["id"] as? String,
|
|
let title = itemObj["title"] as? String
|
|
else { return nil }
|
|
let item = WidgetListItem(
|
|
id: id,
|
|
title: title,
|
|
subtitle: itemObj["subtitle"] as? String,
|
|
accessory: itemObj["accessory"] as? String
|
|
)
|
|
return WidgetUpdate(instanceID: context.instanceID, widget: context.widget, kind: .listUpsert(item))
|
|
}
|
|
|
|
private func decodeListRemoveUpdate(_ dict: [String: Any]) -> WidgetUpdate? {
|
|
guard let context = decodeWidgetContext(dict),
|
|
let itemID = dict["item"] as? String
|
|
else { return nil }
|
|
return WidgetUpdate(instanceID: context.instanceID, widget: context.widget, kind: .listRemove(itemID: itemID))
|
|
}
|
|
|
|
private func decodeTableUpsertUpdate(_ dict: [String: Any]) -> WidgetUpdate? {
|
|
guard let context = decodeWidgetContext(dict),
|
|
let rowObj = dict["row"] as? [String: Any],
|
|
let id = rowObj["id"] as? String,
|
|
let cells = rowObj["cells"] as? [String: String]
|
|
else { return nil }
|
|
return WidgetUpdate(
|
|
instanceID: context.instanceID,
|
|
widget: context.widget,
|
|
kind: .tableUpsert(WidgetTableRow(id: id, cells: cells))
|
|
)
|
|
}
|
|
|
|
private func decodeTableRemoveUpdate(_ dict: [String: Any]) -> WidgetUpdate? {
|
|
guard let context = decodeWidgetContext(dict),
|
|
let rowID = dict["row"] as? String
|
|
else { return nil }
|
|
return WidgetUpdate(instanceID: context.instanceID, widget: context.widget, kind: .tableRemove(rowID: rowID))
|
|
}
|
|
|
|
private func decodeHexSetUpdate(_ dict: [String: Any], data: [UInt8]?) -> WidgetUpdate? {
|
|
guard let context = decodeWidgetContext(dict) else { return nil }
|
|
let bytes: Data
|
|
if let data {
|
|
bytes = Data(data)
|
|
} else if let b64 = (dict["hex"] as? [String: Any])?["bytes"] as? String,
|
|
let decoded = Data(base64Encoded: b64)
|
|
{
|
|
bytes = decoded
|
|
} else {
|
|
return nil
|
|
}
|
|
let baseAddress: UInt64 = ((dict["hex"] as? [String: Any])?["base_address"] as? NSNumber)?.uint64Value ?? 0
|
|
return WidgetUpdate(
|
|
instanceID: context.instanceID,
|
|
widget: context.widget,
|
|
kind: .hexSet(WidgetHexState(bytes: bytes, baseAddress: baseAddress))
|
|
)
|
|
}
|
|
|
|
private func decodeConsoleAppendUpdate(_ dict: [String: Any], data: [UInt8]?) -> WidgetUpdate? {
|
|
guard let context = decodeWidgetContext(dict),
|
|
let entryObj = dict["entry"] as? [String: Any],
|
|
let id = entryObj["id"] as? String,
|
|
let kindStr = entryObj["kind"] as? String,
|
|
let kind = WidgetConsoleEntry.Kind(rawValue: kindStr)
|
|
else { return nil }
|
|
let text = (entryObj["text"] as? String) ?? ""
|
|
let image = decodeConsoleEntryImage(entryObj["image"], data: data)
|
|
let value = image == nil ? decodeConsoleEntryValue(entryObj["value"], data: data) : nil
|
|
let replyTo = entryObj["reply_to"] as? String
|
|
let entry = WidgetConsoleEntry(id: id, kind: kind, text: text, value: value, image: image, replyTo: replyTo)
|
|
return WidgetUpdate(instanceID: context.instanceID, widget: context.widget, kind: .consoleAppend(entry))
|
|
}
|
|
|
|
private func decodeConsoleEntryValue(_ raw: Any?, data: [UInt8]?) -> JSInspectValue? {
|
|
guard let raw else { return nil }
|
|
return try? JSInspectValue.decodePacked(tree: raw, blobBytes: data)
|
|
}
|
|
|
|
private func decodeConsoleEntryImage(_ raw: Any?, data: [UInt8]?) -> ConsoleImage? {
|
|
guard let obj = raw as? [String: Any],
|
|
let mediaType = obj["media_type"] as? String,
|
|
let width = obj["width"] as? Int,
|
|
let height = obj["height"] as? Int,
|
|
let bytes = data
|
|
else { return nil }
|
|
return ConsoleImage(mediaType: mediaType, data: Data(bytes), width: width, height: height)
|
|
}
|
|
|
|
private func decodeConsoleReplyDoneUpdate(_ dict: [String: Any]) -> WidgetUpdate? {
|
|
guard let context = decodeWidgetContext(dict),
|
|
let replyTo = dict["reply_to"] as? String
|
|
else { return nil }
|
|
return WidgetUpdate(
|
|
instanceID: context.instanceID,
|
|
widget: context.widget,
|
|
kind: .consoleReplyDone(inputEntryID: replyTo)
|
|
)
|
|
}
|
|
|
|
private func decodeClearUpdate(_ dict: [String: Any]) -> WidgetUpdate? {
|
|
guard let context = decodeWidgetContext(dict) else { return nil }
|
|
return WidgetUpdate(instanceID: context.instanceID, widget: context.widget, kind: .clear)
|
|
}
|
|
|
|
private func decodeWidgetContext(_ dict: [String: Any]) -> (instanceID: UUID, widget: String)? {
|
|
guard let instanceId = dict["instance_id"] as? String,
|
|
let instanceID = UUID(uuidString: instanceId),
|
|
let widget = dict["widget"] as? String
|
|
else { return nil }
|
|
return (instanceID, widget)
|
|
}
|
|
|
|
private func decodeNumber(_ raw: Any?) -> Double? {
|
|
if let d = raw as? Double { return d }
|
|
if let i = raw as? Int { return Double(i) }
|
|
if let n = raw as? NSNumber { return n.doubleValue }
|
|
return nil
|
|
}
|
|
|
|
private func markInitialModulesReadyIfNeeded() {
|
|
guard moduleSnapshotState == .pending else { return }
|
|
moduleSnapshotState = .ready
|
|
|
|
let waiters = moduleSnapshotWaiters
|
|
moduleSnapshotWaiters.removeAll(keepingCapacity: false)
|
|
for w in waiters { w.resume() }
|
|
}
|
|
|
|
private func failInitialModulesSnapshotWaitersIfNeeded() {
|
|
guard moduleSnapshotState == .pending else { return }
|
|
moduleSnapshotState = .detached
|
|
|
|
let waiters = moduleSnapshotWaiters
|
|
moduleSnapshotWaiters.removeAll(keepingCapacity: false)
|
|
for w in waiters {
|
|
w.resume(throwing: LumaCoreError.invalidOperation("Session detached"))
|
|
}
|
|
}
|
|
|
|
public func waitForInitialModulesSnapshot() async throws {
|
|
switch moduleSnapshotState {
|
|
case .ready:
|
|
return
|
|
case .detached:
|
|
throw LumaCoreError.invalidOperation("Session detached")
|
|
case .pending:
|
|
break
|
|
}
|
|
|
|
try await withCheckedThrowingContinuation { cont in
|
|
switch moduleSnapshotState {
|
|
case .ready:
|
|
cont.resume()
|
|
case .detached:
|
|
cont.resume(throwing: LumaCoreError.invalidOperation("Session detached"))
|
|
case .pending:
|
|
moduleSnapshotWaiters.append(cont)
|
|
}
|
|
}
|
|
}
|
|
|
|
public var evaluateRadare2: ((String) async -> REPLResult.Value)!
|
|
|
|
@discardableResult
|
|
public func evalInREPL(_ code: String, language: REPLLanguage = .javascript, cellID: UUID = UUID()) async -> REPLResult? {
|
|
let trimmed = code.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !trimmed.isEmpty else { return nil }
|
|
|
|
switch language {
|
|
case .javascript:
|
|
return await evalJavaScript(trimmed, cellID: cellID)
|
|
case .r2:
|
|
return await evalRadare2(trimmed, cellID: cellID)
|
|
}
|
|
}
|
|
|
|
private func evalJavaScript(_ code: String, cellID: UUID) async -> REPLResult? {
|
|
let (jsCode, pipeline) = splitCodeAndPipeline(code)
|
|
|
|
do {
|
|
let anyResult = try await script.exports.evaluate(jsCode, ["raw": pipeline != nil])
|
|
|
|
if let pipeline {
|
|
return try await handlePipelineResult(anyResult, cellID: cellID, originalCode: code, pipeline: pipeline)
|
|
}
|
|
|
|
guard let jsValue = try? JSInspectValue.decodePacked(from: anyResult) else {
|
|
return nil
|
|
}
|
|
|
|
return emitREPLResult(id: cellID, code: code, language: .javascript, value: .js(jsValue))
|
|
} catch {
|
|
return emitREPLResult(id: cellID, code: code, language: .javascript, value: .text("Error: \(error)"))
|
|
}
|
|
}
|
|
|
|
private func evalRadare2(_ command: String, cellID: UUID) async -> REPLResult? {
|
|
let value = await evaluateRadare2(command)
|
|
return emitREPLResult(id: cellID, code: command, language: .r2, value: value)
|
|
}
|
|
|
|
private func emitREPLResult(id: UUID, code: String, language: REPLLanguage, value: REPLResult.Value) -> REPLResult {
|
|
let result = REPLResult(id: id, code: code, language: language, value: value)
|
|
_replResults.yield(result)
|
|
return result
|
|
}
|
|
|
|
private func splitCodeAndPipeline(_ code: String) -> (jsCode: String, pipeline: String?) {
|
|
let trimmed = code.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
|
if let range = trimmed.range(of: "|>") {
|
|
let jsPart = trimmed[..<range.lowerBound]
|
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
let pipePart = trimmed[range.upperBound...]
|
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
|
if !jsPart.isEmpty, !pipePart.isEmpty {
|
|
return (jsPart, pipePart)
|
|
}
|
|
}
|
|
|
|
return (trimmed, nil)
|
|
}
|
|
|
|
private func handlePipelineResult(
|
|
_ anyResult: Any?,
|
|
cellID: UUID,
|
|
originalCode: String,
|
|
pipeline: String
|
|
) async throws -> REPLResult {
|
|
if let dict = anyResult as? JSONObject,
|
|
let kind = dict["kind"] as? String,
|
|
kind == "error"
|
|
{
|
|
let text = (dict["text"] as? String) ?? "Unknown error"
|
|
return emitREPLResult(id: cellID, code: originalCode, language: .javascript, value: .text(text))
|
|
}
|
|
|
|
if let pair = anyResult as? [Any], pair.count == 2, let bytes = pair[1] as? [UInt8] {
|
|
let outputString = try await runPipelineToString(pipeline, input: Data(bytes))
|
|
return emitREPLResult(id: cellID, code: originalCode, language: .javascript, value: .text(outputString))
|
|
}
|
|
|
|
if let bytes = anyResult as? [UInt8] {
|
|
let outputString = try await runPipelineToString(pipeline, input: Data(bytes))
|
|
return emitREPLResult(id: cellID, code: originalCode, language: .javascript, value: .text(outputString))
|
|
}
|
|
|
|
if let value = anyResult,
|
|
JSONSerialization.isValidJSONObject(value),
|
|
let inputData = try? JSONSerialization.data(withJSONObject: value)
|
|
{
|
|
let outputString = try await runPipelineToString(pipeline, input: inputData)
|
|
return emitREPLResult(id: cellID, code: originalCode, language: .javascript, value: .text(outputString))
|
|
}
|
|
|
|
let s = anyResult.map { String(describing: $0) } ?? "null"
|
|
return emitREPLResult(id: cellID, code: originalCode, language: .javascript, value: .text(s))
|
|
}
|
|
|
|
private func runPipelineToString(_ command: String, input: Data) async throws -> String {
|
|
let outputData = try await runPipeline(command, input: input)
|
|
return String(data: outputData, encoding: .utf8) ?? "(\(outputData.count) bytes from pipeline)"
|
|
}
|
|
|
|
private func runPipeline(_ command: String, input: Data) async throws -> Data {
|
|
#if os(macOS) || os(Linux)
|
|
try await withCheckedThrowingContinuation { continuation in
|
|
DispatchQueue.global(qos: .userInitiated).async {
|
|
do {
|
|
let process = Process()
|
|
process.executableURL = URL(fileURLWithPath: "/bin/sh")
|
|
process.arguments = ["-lc", command]
|
|
|
|
let stdinPipe = Pipe()
|
|
let stdoutPipe = Pipe()
|
|
let stderrPipe = Pipe()
|
|
|
|
process.standardInput = stdinPipe
|
|
process.standardOutput = stdoutPipe
|
|
process.standardError = stderrPipe
|
|
|
|
try process.run()
|
|
|
|
stdinPipe.fileHandleForWriting.write(input)
|
|
stdinPipe.fileHandleForWriting.closeFile()
|
|
|
|
process.waitUntilExit()
|
|
|
|
let out = stdoutPipe.fileHandleForReading.readDataToEndOfFile()
|
|
let err = stderrPipe.fileHandleForReading.readDataToEndOfFile()
|
|
|
|
if process.terminationStatus != 0 {
|
|
let stderrText = String(data: err, encoding: .utf8) ?? ""
|
|
let error = NSError(
|
|
domain: "REPLPipeline",
|
|
code: Int(process.terminationStatus),
|
|
userInfo: [
|
|
NSLocalizedDescriptionKey:
|
|
"Pipeline \"\(command)\" failed with status \(process.terminationStatus)",
|
|
"stderr": stderrText,
|
|
]
|
|
)
|
|
continuation.resume(throwing: error)
|
|
return
|
|
}
|
|
|
|
continuation.resume(returning: out)
|
|
} catch {
|
|
continuation.resume(throwing: error)
|
|
}
|
|
}
|
|
}
|
|
#else
|
|
throw LumaCoreError.notSupported("Running shell pipelines is only supported on macOS and Linux")
|
|
#endif
|
|
}
|
|
|
|
public var completeRadare2: ((String, Int) async -> [REPLCompletion])!
|
|
|
|
public func completeInREPL(code: String, cursor: Int, language: REPLLanguage = .javascript) async -> [REPLCompletion] {
|
|
let head = String(code.prefix(cursor)).trimmingCharacters(in: .whitespaces)
|
|
if head.hasPrefix(":") {
|
|
return modeCommandCompletions(typed: head.dropFirst().lowercased())
|
|
}
|
|
switch language {
|
|
case .javascript:
|
|
return await completeJavaScript(code: code, cursor: cursor)
|
|
case .r2:
|
|
return await completeRadare2(code, cursor)
|
|
}
|
|
}
|
|
|
|
private func modeCommandCompletions(typed: String) -> [REPLCompletion] {
|
|
[
|
|
("js", "Evaluate JavaScript in the target process"),
|
|
("r2", "Disassemble and analyze with radare2"),
|
|
]
|
|
.filter { $0.0.hasPrefix(typed) }
|
|
.map { REPLCompletion(insertText: $0.0, displayText: ":\($0.0)", detailText: $0.1) }
|
|
}
|
|
|
|
private func annotatedCompletion(_ name: String, base: String?) -> REPLCompletion {
|
|
let detail = base.flatMap { FridaTypeIndex.detail(for: "\($0).\(name)") } ?? FridaTypeIndex.detail(for: name)
|
|
guard let detail else { return REPLCompletion(name) }
|
|
return REPLCompletion(insertText: name, displayText: name, detailText: detail)
|
|
}
|
|
|
|
private func completeJavaScript(code: String, cursor: Int) async -> [REPLCompletion] {
|
|
do {
|
|
let anyResult = try await script.exports.complete(code, cursor)
|
|
|
|
if let items = anyResult as? [[String: Any]] {
|
|
let base = Self.completionBase(code: code, cursor: cursor)
|
|
let members = items.compactMap { item -> (name: String, callable: Bool)? in
|
|
guard let name = item["name"] as? String else { return nil }
|
|
return (name, (item["callable"] as? Bool) ?? false)
|
|
}
|
|
return members.sorted(by: Self.memberPrecedes).map { annotatedCompletion($0.name, base: base) }
|
|
}
|
|
} catch {
|
|
yieldEngineEvent(subsystem: "repl", level: .warning, text: "Failed to fetch REPL completions: \(error)")
|
|
}
|
|
|
|
return []
|
|
}
|
|
|
|
private static func completionBase(code: String, cursor: Int) -> String? {
|
|
let end = code.index(code.startIndex, offsetBy: min(cursor, code.count))
|
|
let prefix = code[..<end]
|
|
var start = prefix.endIndex
|
|
while start > prefix.startIndex {
|
|
let prev = prefix.index(before: start)
|
|
let ch = prefix[prev]
|
|
guard ch.isLetter || ch.isNumber || ch == "_" || ch == "$" || ch == "." else { break }
|
|
start = prev
|
|
}
|
|
let token = prefix[start...]
|
|
guard let lastDot = token.lastIndex(of: ".") else { return nil }
|
|
let base = token[..<lastDot]
|
|
let segment = base.lastIndex(of: ".").map { base[base.index(after: $0)...] } ?? base[...]
|
|
return segment.isEmpty ? nil : String(segment)
|
|
}
|
|
|
|
private static func memberPrecedes(_ a: (name: String, callable: Bool), _ b: (name: String, callable: Bool)) -> Bool {
|
|
if a.callable != b.callable { return !a.callable }
|
|
let aUnderscored = a.name.hasPrefix("_")
|
|
let bUnderscored = b.name.hasPrefix("_")
|
|
if aUnderscored != bUnderscored { return bUnderscored }
|
|
return a.name.lowercased() < b.name.lowercased()
|
|
}
|
|
|
|
public func readRemoteMemory(at address: UInt64, count: Int) async throws -> [UInt8] {
|
|
let addr = String(format: "0x%llx", address)
|
|
let any = try await script.exports.readMemory(addr, count)
|
|
guard let bytes = any as? [UInt8] else {
|
|
throw LumaCoreError.protocolViolation("Invalid reply")
|
|
}
|
|
return bytes
|
|
}
|
|
|
|
public func writeRemoteMemory(at address: UInt64, bytes: [UInt8]) async throws {
|
|
let addr = String(format: "0x%llx", address)
|
|
_ = try await script.exports.writeMemory(addr, bytes)
|
|
}
|
|
|
|
public func anchor(for address: UInt64) -> AddressAnchor {
|
|
if let m = modules.first(where: { address >= $0.base && address < ($0.base + $0.size) }) {
|
|
return .moduleOffset(name: m.name, offset: address - m.base)
|
|
}
|
|
return .absolute(address)
|
|
}
|
|
|
|
public func resolve(_ anchor: AddressAnchor) async throws -> UInt64 {
|
|
try await waitForInitialModulesSnapshot()
|
|
|
|
switch anchor {
|
|
case .absolute(let a):
|
|
return a
|
|
|
|
case .moduleOffset(let name, let offset):
|
|
guard let m = modules.first(where: { $0.name == name || $0.path == name }) else {
|
|
throw LumaCoreError.invalidArgument("Module '\(name)' not loaded in the current process")
|
|
}
|
|
return m.base &+ offset
|
|
|
|
case .moduleExport(let name, _):
|
|
guard modules.first(where: { $0.name == name || $0.path == name }) != nil else {
|
|
throw LumaCoreError.invalidArgument("Module '\(name)' not loaded in the current process")
|
|
}
|
|
return try await lookupAnchor(anchor)
|
|
|
|
case .objcMethod, .swiftFunc, .debugSymbol:
|
|
return try await lookupAnchor(anchor)
|
|
|
|
case .javaMethod:
|
|
throw LumaCoreError.invalidOperation("\(anchor.displayString) is a Java method and has no native address")
|
|
}
|
|
}
|
|
|
|
public func resolveSyncIfReady(_ anchor: AddressAnchor) throws -> UInt64 {
|
|
guard moduleSnapshotState == .ready else {
|
|
if moduleSnapshotState == .detached {
|
|
throw LumaCoreError.invalidOperation("Session detached")
|
|
}
|
|
throw LumaCoreError.invalidOperation("Initial modules snapshot not ready")
|
|
}
|
|
|
|
switch anchor {
|
|
case .absolute(let a):
|
|
return a
|
|
|
|
case .moduleOffset(let name, let offset):
|
|
guard let m = modules.first(where: { $0.name == name }) else {
|
|
throw LumaCoreError.invalidArgument("Module '\(name)' not loaded")
|
|
}
|
|
return m.base &+ offset
|
|
|
|
case .moduleExport, .objcMethod, .swiftFunc, .debugSymbol:
|
|
throw LumaCoreError.invalidOperation("\(anchor.displayString) requires async resolution")
|
|
|
|
case .javaMethod:
|
|
throw LumaCoreError.invalidOperation("\(anchor.displayString) is a Java method and has no native address")
|
|
}
|
|
}
|
|
|
|
private func lookupAnchor(_ anchor: AddressAnchor) async throws -> UInt64 {
|
|
let raw = try await script.exports.lookupAnchorAddress(anchor.toJSON())
|
|
guard let rawString = raw as? String else {
|
|
throw LumaCoreError.invalidArgument("Could not resolve \(anchor.displayString)")
|
|
}
|
|
return try parseAgentHexAddress(rawString)
|
|
}
|
|
|
|
public func symbolicate(addresses: [UInt64]) async throws -> [SymbolicateResult?] {
|
|
let raw = try await script.exports.symbolicate(addresses.map { String(format: "0x%llx", $0) })
|
|
|
|
guard let arr = raw as? [Any], arr.count == addresses.count else {
|
|
throw LumaCoreError.protocolViolation("Invalid reply")
|
|
}
|
|
|
|
return try arr.map(decodeSymbolicateEntry)
|
|
}
|
|
|
|
private func decodeSymbolicateEntry(_ entry: Any) throws -> SymbolicateResult? {
|
|
if entry is NSNull { return nil }
|
|
|
|
guard let dict = entry as? [String: Any],
|
|
let module = dict["module"] as? String,
|
|
let name = dict["name"] as? String
|
|
else {
|
|
throw LumaCoreError.protocolViolation("Invalid reply")
|
|
}
|
|
|
|
let offset = (dict["offset"] as? NSNumber).map { UInt64(truncating: $0) }
|
|
let source = decodeSymbolSource(from: dict)
|
|
|
|
return SymbolicateResult(module: module, name: name, offset: offset, source: source)
|
|
}
|
|
|
|
private func decodeSymbolSource(from dict: [String: Any]) -> SymbolicateResult.SourceLocation? {
|
|
guard let file = dict["file"] as? String,
|
|
let line = dict["line"] as? Int
|
|
else { return nil }
|
|
let column = dict["column"] as? Int
|
|
return SymbolicateResult.SourceLocation(file: file, line: line, column: column)
|
|
}
|
|
|
|
public func fetchThreadSnapshot(id: UInt) async throws -> ThreadSnapshot? {
|
|
let raw = try await script.exports.getThreadSnapshot(Int(id))
|
|
if raw is NSNull { return nil }
|
|
guard let dict = raw as? [String: Any] else {
|
|
throw LumaCoreError.protocolViolation("getThreadSnapshot: unexpected response shape")
|
|
}
|
|
return ThreadSnapshot.fromJSON(dict)
|
|
}
|
|
|
|
public func enumerateModuleFunctions(name: String) async throws -> [ModuleFunction] {
|
|
let raw = try await script.exports.enumerateModuleFunctions(name)
|
|
guard let rows = raw as? [[String: Any]] else {
|
|
throw LumaCoreError.protocolViolation("enumerateModuleFunctions: unexpected response shape")
|
|
}
|
|
return rows.compactMap(ModuleFunction.fromJSON)
|
|
}
|
|
|
|
public func enumerateModuleSymbols(name: String) async throws -> ModuleSymbolBundle {
|
|
let raw = try await script.exports.enumerateModuleSymbols(name)
|
|
guard let dict = raw as? [String: Any] else {
|
|
throw LumaCoreError.protocolViolation("enumerateModuleSymbols: unexpected response shape")
|
|
}
|
|
return ModuleSymbolBundle.fromJSON(dict)
|
|
}
|
|
|
|
public func queryModuleSymbols(
|
|
name: String,
|
|
category: ModuleSymbolCategory,
|
|
query: String,
|
|
offset: Int = 0,
|
|
limit: Int = ModuleSymbolPage.pageSize
|
|
) async throws -> ModuleSymbolPage {
|
|
let raw = try await script.exports.queryModuleSymbols(JSValue([
|
|
"module": name,
|
|
"category": category.rawValue,
|
|
"query": query,
|
|
"offset": offset,
|
|
"limit": limit,
|
|
]))
|
|
guard let dict = raw as? [String: Any] else {
|
|
throw LumaCoreError.protocolViolation("queryModuleSymbols: unexpected response shape")
|
|
}
|
|
return ModuleSymbolPage.fromJSON(dict, category: category)
|
|
}
|
|
|
|
public func enumerateModuleRanges(name: String) async throws -> [ModuleRange] {
|
|
let raw = try await script.exports.enumerateModuleRanges(name)
|
|
guard let arr = raw as? [[String: Any]] else {
|
|
throw LumaCoreError.protocolViolation("enumerateModuleRanges: unexpected response shape")
|
|
}
|
|
return arr.compactMap { entry in
|
|
guard let offsetStr = entry["offset"] as? String,
|
|
let offset = UInt64(offsetStr.dropFirst(2), radix: 16),
|
|
let size = entry["size"] as? Int,
|
|
let protection = entry["protection"] as? String
|
|
else { return nil }
|
|
return ModuleRange(offset: offset, size: UInt64(size), protection: protection)
|
|
}
|
|
}
|
|
|
|
public func getModuleIdentity(name: String) async throws -> String? {
|
|
let raw = try await script.exports.getModuleIdentity(name)
|
|
return raw as? String
|
|
}
|
|
|
|
public func findRangeByAddress(_ address: UInt64) async throws -> ProcessRange? {
|
|
let raw = try await script.exports.findRangeByAddress("0x" + String(address, radix: 16))
|
|
guard let entry = raw as? [String: Any] else { return nil }
|
|
guard let baseStr = entry["base"] as? String,
|
|
let size = entry["size"] as? Int,
|
|
let protection = entry["protection"] as? String
|
|
else { return nil }
|
|
let trimmed = baseStr.hasPrefix("0x") ? String(baseStr.dropFirst(2)) : baseStr
|
|
guard let base = UInt64(trimmed, radix: 16) else { return nil }
|
|
return ProcessRange(base: base, size: UInt64(size), protection: protection, filePath: entry["filePath"] as? String)
|
|
}
|
|
|
|
public struct ModuleRange: Sendable, Hashable, Codable {
|
|
public let offset: UInt64
|
|
public let size: UInt64
|
|
public let protection: String
|
|
}
|
|
|
|
public struct ProcessRange: Sendable, Hashable {
|
|
public let base: UInt64
|
|
public let size: UInt64
|
|
public let protection: String
|
|
public let filePath: String?
|
|
}
|
|
|
|
public func resolveTargets(scope: String, query: String) async throws -> [[String: Any]] {
|
|
let raw = try await script.exports.resolveTargets([
|
|
"scope": scope,
|
|
"query": query,
|
|
])
|
|
guard let arr = raw as? [[String: Any]] else {
|
|
throw LumaCoreError.protocolViolation("resolveTargets: unexpected response shape")
|
|
}
|
|
return arr
|
|
}
|
|
|
|
public func fetchProcessInfo() async -> ProcessInfo? {
|
|
guard let anyInfo = try? await script.exports.getProcessInfo(),
|
|
JSONSerialization.isValidJSONObject(anyInfo),
|
|
let data = try? JSONSerialization.data(withJSONObject: anyInfo),
|
|
let info = try? JSONDecoder().decode(ProcessInfo.self, from: data)
|
|
else {
|
|
return nil
|
|
}
|
|
processInfo = info
|
|
mainModule = ProcessModule(
|
|
name: info.mainModule.name,
|
|
path: info.mainModule.path,
|
|
base: info.mainModule.parsedBase,
|
|
size: UInt64(info.mainModule.size)
|
|
)
|
|
return info
|
|
}
|
|
|
|
public struct ProcessInfo: Codable, Sendable {
|
|
public let platform: String
|
|
public let arch: String
|
|
public let pointerSize: Int
|
|
public let mainModule: MainModule
|
|
public let identity: String
|
|
|
|
public struct MainModule: Codable, Sendable {
|
|
public let name: String
|
|
public let path: String
|
|
public let base: String
|
|
public let size: Int
|
|
|
|
var parsedBase: UInt64 {
|
|
let trimmed = base.hasPrefix("0x") ? String(base.dropFirst(2)) : base
|
|
return UInt64(trimmed, radix: 16) ?? 0
|
|
}
|
|
}
|
|
}
|
|
|
|
public func addInstrument(_ ref: InstrumentRef) {
|
|
instruments.append(ref)
|
|
}
|
|
|
|
public func removeInstrument(id: UUID) {
|
|
instruments.removeAll { $0.id == id }
|
|
}
|
|
|
|
public func markInstrumentAttached(id: UUID) {
|
|
if let i = instruments.firstIndex(where: { $0.id == id }) {
|
|
instruments[i].attachment = .attached
|
|
instruments[i].status = nil
|
|
}
|
|
}
|
|
|
|
public func markInstrumentDetached(id: UUID) {
|
|
if let i = instruments.firstIndex(where: { $0.id == id }) {
|
|
instruments[i].attachment = .detached
|
|
}
|
|
}
|
|
|
|
public func setInstrumentStatus(id: UUID, _ status: InstrumentStatus) {
|
|
if let i = instruments.firstIndex(where: { $0.id == id }) {
|
|
instruments[i].attachment = .detached
|
|
instruments[i].status = status
|
|
}
|
|
}
|
|
|
|
public func clearInstrumentStatus(id: UUID) {
|
|
if let i = instruments.firstIndex(where: { $0.id == id }) {
|
|
instruments[i].status = nil
|
|
}
|
|
}
|
|
|
|
public func replaceComponentStatuses(instrumentID: UUID, _ statuses: [UUID: InstrumentStatus]) {
|
|
if let i = instruments.firstIndex(where: { $0.id == instrumentID }) {
|
|
instruments[i].componentStatuses = statuses
|
|
}
|
|
}
|
|
|
|
public func updateInstrumentConfig(id: UUID, configJSON: Data) {
|
|
if let i = instruments.firstIndex(where: { $0.id == id }) {
|
|
instruments[i].configJSON = configJSON
|
|
}
|
|
}
|
|
|
|
public func loadInstrumentOnAgent(
|
|
instanceID: UUID,
|
|
moduleName: String,
|
|
source: String,
|
|
config: Any,
|
|
restored: [String: Any] = [:]
|
|
) async throws {
|
|
try await script.exports.loadInstrument(JSValue([
|
|
"instanceId": instanceID.uuidString,
|
|
"moduleName": moduleName,
|
|
"source": source,
|
|
"config": config,
|
|
"restored": restored,
|
|
]))
|
|
}
|
|
|
|
public func disposeInstrumentOnAgent(instanceID: UUID) async throws {
|
|
_ = try await script.exports.disposeInstrument(["instanceId": instanceID.uuidString])
|
|
}
|
|
|
|
public func pushInstrumentConfig(instanceID: UUID, config: Any) async throws {
|
|
try await script.exports.updateInstrumentConfig(JSValue([
|
|
"instanceId": instanceID.uuidString,
|
|
"config": config,
|
|
]))
|
|
}
|
|
|
|
public func invokeWidgetAction(instanceID: UUID, widget: String, action: String, item: String?) async throws {
|
|
var payload: [String: Any] = [
|
|
"instanceId": instanceID.uuidString,
|
|
"widget": widget,
|
|
"action": action,
|
|
]
|
|
if let item { payload["item"] = item }
|
|
_ = try await script.exports.invokeWidgetAction(JSValue(payload))
|
|
}
|
|
|
|
public func submitConsoleInput(instanceID: UUID, widget: String, entryID: String, text: String) async throws {
|
|
try await script.exports.submitConsoleInput(JSValue([
|
|
"instanceId": instanceID.uuidString,
|
|
"widget": widget,
|
|
"entryId": entryID,
|
|
"text": text,
|
|
]))
|
|
}
|
|
|
|
func handleITraceStart(
|
|
token: Int,
|
|
sessionId: String,
|
|
origin: ITrace.Origin,
|
|
bufferLocation: String,
|
|
hookTarget: String?,
|
|
prologueBytes: String?
|
|
) async {
|
|
let pending = PendingTrace(
|
|
id: UUID(uuidString: sessionId) ?? UUID(),
|
|
origin: origin,
|
|
displayName: traceDisplayName(for: origin),
|
|
startedAt: Date(),
|
|
hookTarget: hookTarget,
|
|
prologueBytes: prologueBytes,
|
|
accumulated: Data(),
|
|
lost: 0
|
|
)
|
|
pendingTraces[sessionId] = pending
|
|
emitTraceUpdate(sessionId: sessionId)
|
|
|
|
let drain = await negotiateDrain(sessionId: sessionId, bufferLocation: bufferLocation)
|
|
script.post(["type": "itrace:ack:\(token)", "drain": drain])
|
|
}
|
|
|
|
// The agent is blocked awaiting this ack before it arms Stalker, so the
|
|
// out-of-process remap (which may crash the target) is established here,
|
|
// while the buffer is still intact. task_for_pid capability is fixed per
|
|
// target, so a single denial disqualifies system draining for good.
|
|
private func negotiateDrain(sessionId: String, bufferLocation: String) async -> String {
|
|
guard let drainService,
|
|
await drainService.openBuffer(sessionId: sessionId, location: bufferLocation)
|
|
else {
|
|
return "in-agent"
|
|
}
|
|
|
|
startSystemDrainTimer(for: sessionId)
|
|
return "system"
|
|
}
|
|
|
|
func handleITraceChunk(sessionId: String, data: [UInt8], lost: Int) {
|
|
pendingTraces[sessionId]?.accumulated.append(contentsOf: data)
|
|
pendingTraces[sessionId]?.lost = lost
|
|
scheduleRunningTraceEmit(sessionId: sessionId)
|
|
}
|
|
|
|
func handleITraceStop(sessionId: String, lost: Int, data: [UInt8]?) async {
|
|
if let task = systemDrainTasks.removeValue(forKey: sessionId), let drainService {
|
|
task.cancel()
|
|
do {
|
|
let sysLost = await drainService.lost(sessionId: sessionId)
|
|
if let finalChunk = try await drainService.close(sessionId: sessionId), !finalChunk.isEmpty {
|
|
pendingTraces[sessionId]?.accumulated.append(contentsOf: finalChunk)
|
|
}
|
|
let mergedLost = max(pendingTraces[sessionId]?.lost ?? 0, sysLost)
|
|
pendingTraces[sessionId]?.lost = mergedLost
|
|
} catch {
|
|
}
|
|
}
|
|
|
|
if let data, !data.isEmpty {
|
|
pendingTraces[sessionId]?.accumulated.append(contentsOf: data)
|
|
}
|
|
|
|
let currentLost = pendingTraces[sessionId]?.lost ?? 0
|
|
pendingTraces[sessionId]?.lost = max(currentLost, lost)
|
|
|
|
await finalizeTrace(sessionId: sessionId)
|
|
}
|
|
|
|
private func scheduleRunningTraceEmit(sessionId: String) {
|
|
guard pendingEmits[sessionId] == nil else { return }
|
|
pendingEmits[sessionId] = Task { @MainActor [weak self] in
|
|
try? await Task.sleep(nanoseconds: Self.runningTraceEmitInterval)
|
|
guard let self, !Task.isCancelled else { return }
|
|
self.pendingEmits.removeValue(forKey: sessionId)
|
|
self.emitTraceUpdate(sessionId: sessionId)
|
|
}
|
|
}
|
|
|
|
private func emitTraceUpdate(sessionId: String) {
|
|
cancelPendingEmit(sessionId: sessionId)
|
|
guard let trace = makeRunningTrace(sessionId: sessionId) else { return }
|
|
_traceUpdates.yield(trace)
|
|
}
|
|
|
|
private func cancelPendingEmit(sessionId: String) {
|
|
pendingEmits.removeValue(forKey: sessionId)?.cancel()
|
|
}
|
|
|
|
private func makeRunningTrace(sessionId: String) -> ITrace? {
|
|
guard let pending = pendingTraces[sessionId] else { return nil }
|
|
let (traceData, metadataJSON, _) = decodeAccumulated(pending: pending)
|
|
return ITrace(
|
|
id: pending.id,
|
|
sessionID: sessionID,
|
|
origin: pending.origin,
|
|
displayName: pending.displayName,
|
|
startedAt: pending.startedAt,
|
|
stoppedAt: nil,
|
|
metadataJSON: metadataJSON,
|
|
dataSize: traceData.count,
|
|
lost: pending.lost
|
|
)
|
|
}
|
|
|
|
public func livePendingTraceData(traceID: UUID) -> Data? {
|
|
for pending in pendingTraces.values where pending.id == traceID {
|
|
return decodeAccumulated(pending: pending).traceData
|
|
}
|
|
return nil
|
|
}
|
|
|
|
private func decodeAccumulated(pending: PendingTrace) -> (traceData: Data, metadataJSON: Data, panics: [String]) {
|
|
return ITraceDecoder.parseRawBuffer(
|
|
pending.accumulated,
|
|
hookTarget: pending.hookTarget,
|
|
prologueBytes: pending.prologueBytes
|
|
)
|
|
}
|
|
|
|
private func startSystemDrainTimer(for sessionId: String) {
|
|
systemDrainTasks[sessionId] = Task { @MainActor [weak self] in
|
|
while !Task.isCancelled {
|
|
try? await Task.sleep(nanoseconds: Self.systemDrainInterval)
|
|
|
|
guard let self, let drainService = self.drainService else { break }
|
|
if self.pendingTraces[sessionId] == nil { break }
|
|
|
|
do {
|
|
if let chunk = try await drainService.drain(sessionId: sessionId), !chunk.isEmpty {
|
|
self.pendingTraces[sessionId]?.accumulated.append(contentsOf: chunk)
|
|
self.scheduleRunningTraceEmit(sessionId: sessionId)
|
|
}
|
|
} catch {
|
|
break
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func finalizeTrace(sessionId: String) async {
|
|
cancelPendingEmit(sessionId: sessionId)
|
|
systemDrainTasks.removeValue(forKey: sessionId)?.cancel()
|
|
guard let pending = pendingTraces.removeValue(forKey: sessionId) else { return }
|
|
|
|
var (traceData, metadataJSON, panics) = decodeAccumulated(pending: pending)
|
|
for panic in panics {
|
|
yieldEngineEvent(subsystem: "itrace", level: .error, text: "Panic: \(panic)")
|
|
}
|
|
if case .functionCall = pending.origin {
|
|
ITraceDecoder.cleanupAfterCapture(traceData: &traceData, metadataJSON: &metadataJSON)
|
|
}
|
|
|
|
await annotateBlocksWithSymbols(metadataJSON: &metadataJSON)
|
|
|
|
try? blobStore?.write(traceData, id: pending.id, kind: .trace)
|
|
|
|
let trace = ITrace(
|
|
id: pending.id,
|
|
sessionID: sessionID,
|
|
origin: pending.origin,
|
|
displayName: pending.displayName,
|
|
startedAt: pending.startedAt,
|
|
stoppedAt: Date(),
|
|
metadataJSON: metadataJSON,
|
|
dataSize: traceData.count,
|
|
lost: pending.lost
|
|
)
|
|
_traceUpdates.yield(trace)
|
|
}
|
|
|
|
private func annotateBlocksWithSymbols(metadataJSON: inout Data) async {
|
|
guard let onResolveBlockNames else { return }
|
|
guard var metadata = try? JSONDecoder().decode(ITraceMetadata.self, from: metadataJSON) else { return }
|
|
|
|
let addresses = metadata.blocks.compactMap { ITraceDecoder.parseHexAddress($0.address) }
|
|
guard !addresses.isEmpty else { return }
|
|
|
|
let names = await onResolveBlockNames(addresses)
|
|
for (i, name) in names.enumerated() where i < metadata.blocks.count {
|
|
metadata.blocks[i].name = name
|
|
}
|
|
|
|
if let data = try? JSONEncoder().encode(metadata) {
|
|
metadataJSON = data
|
|
}
|
|
}
|
|
|
|
private func parseTraceOrigin(_ dict: [String: Any]) -> ITrace.Origin? {
|
|
guard let kind = dict["kind"] as? String else { return nil }
|
|
switch kind {
|
|
case "functionCall":
|
|
guard let hookIdString = dict["hookId"] as? String,
|
|
let hookID = UUID(uuidString: hookIdString),
|
|
let callIndex = dict["callIndex"] as? Int
|
|
else { return nil }
|
|
return .functionCall(hookID: hookID, callIndex: callIndex)
|
|
case "thread":
|
|
guard let threadId = dict["threadId"] as? Int else { return nil }
|
|
let threadName = dict["threadName"] as? String
|
|
return .thread(threadID: UInt(threadId), threadName: threadName)
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
private func traceDisplayName(for origin: ITrace.Origin) -> String {
|
|
switch origin {
|
|
case .functionCall(let hookID, let callIndex):
|
|
let hookName = instruments.lazy
|
|
.compactMap { ref -> String? in
|
|
guard let config = try? TracerConfig.decode(from: ref.configJSON) else { return nil }
|
|
return config.hooks.first(where: { $0.id == hookID })?.displayName
|
|
}
|
|
.first ?? hookID.uuidString
|
|
return "\(hookName) call #\(callIndex)"
|
|
case .thread(let threadID, let threadName):
|
|
let label = threadName ?? "tid \(threadID)"
|
|
return "Thread trace: \(label)"
|
|
}
|
|
}
|
|
|
|
private func finalizePendingTracesOnCrash() async {
|
|
// Allow any in-flight `itrace:start` IPC messages from the agent to
|
|
// be processed before we finalize, so we don't drop traces whose
|
|
// start arrived in the same event-loop tick as the detach.
|
|
try? await Task.sleep(nanoseconds: 250_000_000)
|
|
let keys = Array(pendingTraces.keys)
|
|
for key in keys {
|
|
if let task = systemDrainTasks.removeValue(forKey: key), let drainService {
|
|
task.cancel()
|
|
do {
|
|
if let finalChunk = try await drainService.close(sessionId: key), !finalChunk.isEmpty {
|
|
pendingTraces[key]?.accumulated.append(contentsOf: finalChunk)
|
|
}
|
|
} catch {
|
|
}
|
|
}
|
|
await finalizeTrace(sessionId: key)
|
|
}
|
|
}
|
|
|
|
public func tearDownITrace() async {
|
|
let drainedSessions = Array(systemDrainTasks.keys)
|
|
for task in systemDrainTasks.values { task.cancel() }
|
|
systemDrainTasks.removeAll()
|
|
for task in pendingEmits.values { task.cancel() }
|
|
pendingEmits.removeAll()
|
|
pendingTraces.removeAll()
|
|
|
|
if let drainService {
|
|
for sessionId in drainedSessions {
|
|
_ = try? await drainService.close(sessionId: sessionId)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func yieldEngineEvent(subsystem: String, level: ConsoleLevel, text: String) {
|
|
_events.yield(RuntimeEvent(
|
|
source: .engine(subsystem: subsystem),
|
|
payload: .consoleMessage(ConsoleMessage(level: level, values: [.string(text)]))
|
|
))
|
|
}
|
|
}
|
|
|
|
extension CollaborationSession.Session.Phase {
|
|
public var toProcessSessionPhase: ProcessSession.Phase {
|
|
switch self {
|
|
case .attaching: return .attaching
|
|
case .attached: return .attached
|
|
case .detached: return .idle
|
|
}
|
|
}
|
|
}
|