Files
luma/Luma/AddressInsightDetailView.swift

832 lines
30 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import LumaCore
import SwiftUI
struct AddressInsightDetailView: View {
let session: LumaCore.ProcessSession
let insightID: UUID
let engine: Engine
@Binding var selection: SidebarItemID?
private var insight: LumaCore.AddressInsight? {
engine.insightsBySession[session.id]?.first { $0.id == insightID }
}
@State private var refreshDebounce: Task<Void, Never>?
@State private var refreshTask: Task<Void, Never>?
@State private var showRefreshSpinner = false
@State private var spinnerTask: Task<Void, Never>?
@State private var memoryData: Data = Data()
@State private var disasmLines: [DisassemblyLine] = []
@State private var errorText: AttributedString?
@State private var isLoadingMore = false
@State private var disasmScope: DisassemblyScope = .span
@Environment(\.colorScheme) private var colorScheme
private var node: LumaCore.ProcessNode? {
engine.node(forSessionID: session.id)
}
var body: some View {
Group {
if let err = errorText {
Text(err)
.font(.system(.body, design: .monospaced))
.textSelection(.enabled)
} else if let kind = insight?.kind {
switch kind {
case .memory:
PatternDecodeView(
data: memoryData,
baseAddress: insight?.lastResolvedAddress,
sessionID: session.id,
engine: engine,
placements: Binding(
get: { insight?.placements ?? [] },
set: { placements in
if let insight {
engine.setPlacements(placements, forInsight: insight)
}
}
),
sizing: .fill,
onTruncated: readMoreMemory
)
.padding(.vertical, 2)
case .disassembly:
DisassemblyView(
lines: disasmLines,
sessionID: session.id,
engine: engine,
selection: $selection,
onNeedMore: { loadMoreDisasm() }
)
}
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.padding(8)
.overlay(alignment: .center) {
if showRefreshSpinner {
ProgressView()
.controlSize(.small)
.padding(8)
.background(.regularMaterial, in: Capsule())
.padding(8)
.transition(.opacity)
}
}
.animation(.default, value: showRefreshSpinner)
.onAppear { refresh() }
.onChange(of: colorScheme) { _, _ in refresh() }
.onChange(of: node != nil) { _, attached in
if attached { refresh() }
}
.onChange(of: insight?.lastResolvedAddress) { _, _ in refresh() }
.onChange(of: insight.flatMap { engine.insightRefreshGenerations[$0.id] }) { _, _ in refresh() }
}
private func refresh() {
refreshDebounce?.cancel()
refreshDebounce = Task { @MainActor in
try? await Task.sleep(nanoseconds: 5_000_000)
if Task.isCancelled {
return
}
doRefresh()
}
}
private func doRefresh() {
guard let snapshot = insight else { return }
refreshTask?.cancel()
spinnerTask?.cancel()
showRefreshSpinner = false
errorText = nil
isLoadingMore = false
spinnerTask = Task { @MainActor in
try? await Task.sleep(nanoseconds: 200_000_000)
if Task.isCancelled { return }
if !(refreshTask?.isCancelled ?? true) {
showRefreshSpinner = true
}
}
let kind = snapshot.kind
let byteCount = snapshot.byteCount
let anchor = snapshot.anchor
let hint = snapshot.lastResolvedAddress
let reader = engine.memoryReader(forSessionID: session.id)
refreshTask = Task { @MainActor in
defer {
spinnerTask?.cancel()
showRefreshSpinner = false
}
guard let resolved = await engine.resolve(sessionID: session.id, anchor: anchor, hint: hint) else {
if Task.isCancelled { return }
errorText = AttributedString("Unable to resolve address while detached.")
return
}
if Task.isCancelled { return }
engine.recordInsightResolution(snapshot, resolved: resolved)
switch kind {
case .memory:
do {
let bytes = try await reader.read(at: resolved, count: byteCount)
if Task.isCancelled { return }
disasmLines = []
memoryData = Data(bytes)
} catch {
if Task.isCancelled { return }
errorText = AttributedString(error.localizedDescription)
}
case .disassembly:
do {
_ = try await reader.read(at: resolved, count: 1)
} catch {
if Task.isCancelled { return }
disasmLines = []
memoryData = Data()
errorText = AttributedString(error.localizedDescription)
return
}
let page = await fetchDisasmPage(start: resolved, count: 64)
if Task.isCancelled { return }
disasmLines = page.lines
disasmScope = page.scope
memoryData = Data()
}
}
}
private func readMoreMemory() {
guard let insight, insight.kind == .memory, let resolved = insight.lastResolvedAddress,
memoryData.count < Engine.largestPatternRead
else { return }
let byteCount = min(max(memoryData.count, insight.byteCount) * 2, Engine.largestPatternRead)
let reader = engine.memoryReader(forSessionID: session.id)
Task { @MainActor in
guard let bytes = try? await reader.read(at: resolved, count: byteCount) else { return }
memoryData = Data(bytes)
engine.setByteCount(byteCount, forInsight: insight)
}
}
private func loadMoreDisasm() {
guard !isLoadingMore else { return }
guard insight?.kind == .disassembly else { return }
guard disasmScope == .span else { return }
guard let last = disasmLines.last else { return }
isLoadingMore = true
Task { @MainActor in
defer { isLoadingMore = false }
let decoded = await fetchDisasmPage(start: last.address, count: 64).lines
guard !Task.isCancelled else { return }
guard !decoded.isEmpty else { return }
var page = decoded
page.removeFirst()
guard !page.isEmpty else { return }
disasmLines.append(contentsOf: page)
}
}
private func fetchDisasmPage(
start: UInt64,
count: Int = 64
) async -> DisassemblyPage {
guard let disassembler = engine.disassembler(forSessionID: session.id) else {
return DisassemblyPage(lines: [], scope: .span)
}
return await disassembler.disassemblePage(
DisassemblyRequest(address: start, count: count, appearance: colorScheme == .dark ? .dark : .light)
)
}
}
struct DisassemblyView: View {
let lines: [DisassemblyLine]
let sessionID: UUID
let engine: Engine
@Binding var selection: SidebarItemID?
let onNeedMore: () -> Void
@State private var selectedAddr: UInt64?
@FocusState private var isFocused: Bool
@State private var hoveredAddr: UInt64?
@State private var pulsingAddr: UInt64?
@State private var pulsePhase: Bool = false
@State private var pulseTask: Task<Void, Never>?
@State private var requestedJumpTarget: UInt64?
@State private var openNotePopoverAddress: UInt64?
let rowHeight: CGFloat = 20
var body: some View {
ScrollViewReader { scrollProxy in
ScrollView {
LazyVStack(alignment: .leading, spacing: 0) {
ForEach(lines) { line in
DisasmRow(
line: line,
sessionID: sessionID,
engine: engine,
selection: $selection,
rowHeight: rowHeight,
isSelected: selectedAddr == line.address,
hoveredAddr: $hoveredAddr,
isPulsing: pulsingAddr == line.address,
pulsePhase: pulsePhase,
openNotePopoverAddress: $openNotePopoverAddress,
onSelect: {
selectedAddr = line.address
isFocused = true
},
onJump: { target in
try handleJump(target, scrollProxy: scrollProxy)
},
requestedJumpTarget: requestedJumpTarget,
clearRequestedJump: {
requestedJumpTarget = nil
}
)
.id(line.address)
.onAppear {
if line.id == lines.last?.id {
onNeedMore()
}
}
.errorPopoverHost()
}
}
.frame(maxWidth: 800, alignment: .leading)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.leading, 54)
.padding(.trailing, 12)
.overlay(alignment: .topLeading) {
DisasmFlowOverlay(
lines: lines,
rowHeight: rowHeight,
)
}
}
.focusable(true)
.focused($isFocused)
.focusEffectDisabled(true)
.textSelection(.disabled)
.contentShape(Rectangle())
.onTapGesture {
isFocused = true
}
.onKeyPress(.upArrow) {
if openNotePopoverAddress != nil { return .ignored }
moveSelection(-1, scrollProxy: scrollProxy)
return .handled
}
.onKeyPress(.downArrow) {
if openNotePopoverAddress != nil { return .ignored }
moveSelection(1, scrollProxy: scrollProxy)
return .handled
}
.onKeyPress("k") {
if openNotePopoverAddress != nil { return .ignored }
moveSelection(-1, scrollProxy: scrollProxy)
return .handled
}
.onKeyPress("j") {
if openNotePopoverAddress != nil { return .ignored }
moveSelection(1, scrollProxy: scrollProxy)
return .handled
}
.onKeyPress(.return) {
if openNotePopoverAddress != nil { return .ignored }
jumpSelection(scrollProxy: scrollProxy)
return .handled
}
.onKeyPress(.pageUp) {
if openNotePopoverAddress != nil { return .ignored }
moveSelection(-20, scrollProxy: scrollProxy)
return .handled
}
.onKeyPress(.pageDown) {
if openNotePopoverAddress != nil { return .ignored }
moveSelection(20, scrollProxy: scrollProxy)
return .handled
}
.onKeyPress(.home) {
if openNotePopoverAddress != nil { return .ignored }
selectEdge(first: true, scrollProxy: scrollProxy)
return .handled
}
.onKeyPress(.end) {
if openNotePopoverAddress != nil { return .ignored }
selectEdge(first: false, scrollProxy: scrollProxy)
return .handled
}
.onAppear { selectFirstIfNeeded() }
.onChange(of: lines.isEmpty) { _, _ in selectFirstIfNeeded() }
}
}
private func handleJump(_ target: UInt64, scrollProxy: ScrollViewProxy) throws {
if lines.contains(where: { $0.address == target }) {
selectedAddr = target
isFocused = true
withAnimation(.snappy) {
scrollProxy.scrollTo(target, anchor: .center)
}
pulseTask?.cancel()
pulsingAddr = target
pulsePhase = false
pulseTask = Task { @MainActor in
let myTarget = target
defer {
if pulsingAddr == myTarget {
pulsingAddr = nil
pulsePhase = false
}
}
let beats = 3
let half: Double = 0.18
for _ in 0..<beats {
guard pulsingAddr == myTarget else { return }
withAnimation(.easeInOut(duration: half)) { pulsePhase = true }
try? await Task.sleep(nanoseconds: UInt64(half * 1_000_000_000))
guard pulsingAddr == myTarget else { return }
withAnimation(.easeInOut(duration: half)) { pulsePhase = false }
try? await Task.sleep(nanoseconds: UInt64(half * 1_000_000_000))
}
}
} else {
let insight = try engine.getOrCreateInsight(
sessionID: sessionID,
pointer: target,
kind: .disassembly
)
selection = .insight(sessionID, insight.id)
}
}
private func moveSelection(_ delta: Int, scrollProxy: ScrollViewProxy) {
guard !lines.isEmpty else { return }
let indexByAddr = Dictionary(
lines.enumerated().map { ($0.element.address, $0.offset) },
uniquingKeysWith: { first, _ in first }
)
let currentIndex: Int
if let sel = selectedAddr, let i = indexByAddr[sel] {
currentIndex = i
} else {
currentIndex = 0
}
let next = max(0, min(lines.count - 1, currentIndex + delta))
let addr = lines[next].address
selectedAddr = addr
withAnimation(.snappy) {
scrollProxy.scrollTo(addr, anchor: .center)
}
if next >= lines.count - 1 {
onNeedMore()
}
}
private func selectEdge(first: Bool, scrollProxy: ScrollViewProxy) {
guard let addr = first ? lines.first?.address : lines.last?.address else { return }
selectedAddr = addr
withAnimation(.snappy) {
scrollProxy.scrollTo(addr, anchor: .center)
}
if !first {
onNeedMore()
}
}
private func selectFirstIfNeeded() {
if selectedAddr == nil, let first = lines.first?.address {
selectedAddr = first
}
}
private func jumpSelection(scrollProxy: ScrollViewProxy) {
guard let sel = selectedAddr else { return }
guard let line = lines.first(where: { $0.address == sel }) else { return }
guard let target = line.branchTarget ?? line.callTarget else { return }
requestedJumpTarget = target
}
}
private struct DisasmRow: View {
let line: DisassemblyLine
let sessionID: UUID
let engine: Engine
@Binding var selection: SidebarItemID?
let rowHeight: CGFloat
let isSelected: Bool
@Binding var hoveredAddr: UInt64?
let isPulsing: Bool
let pulsePhase: Bool
@Binding var openNotePopoverAddress: UInt64?
let onSelect: () -> Void
let onJump: (UInt64) throws -> Void
let requestedJumpTarget: UInt64?
let clearRequestedJump: () -> Void
@Environment(\.errorPresenter) private var errorPresenter
var body: some View {
let annotation = engine.addressAnnotations[sessionID]?[line.address]
let decorations = annotation?.decorations ?? []
let noteCount = annotation?.noteCount ?? 0
HStack(alignment: .firstTextBaseline, spacing: 10) {
HStack(spacing: 3) {
ForEach(decorations.prefix(3)) { deco in
Circle()
.frame(width: 5, height: 5)
.foregroundStyle(.secondary)
.opacity(0.45)
.help(deco.help ?? "")
}
if noteCount > 0 {
Image(systemName: "bubble.left.fill")
.font(.system(size: 8))
.foregroundStyle(.tint)
.frame(width: 14, height: 14)
.contentShape(Rectangle())
.onTapGesture {
openNotePopoverAddress = line.address
}
.help("\(noteCount) thread\(noteCount == 1 ? "" : "s")")
}
}
.frame(width: 24, alignment: .trailing)
Text(line.addressText.attributed)
.lineLimit(1)
.truncationMode(.tail)
.font(.system(.footnote, design: .monospaced))
.foregroundStyle(.secondary)
.frame(width: 110, alignment: .leading)
.contentShape(Rectangle())
.pointerActions(
engine: engine,
sessionID: sessionID,
value: String(format: "0x%llx", line.address),
address: line.address,
context: AddressContext(kind: .code),
selection: $selection
) {
Divider()
Button {
openNotePopoverAddress = line.address
} label: {
Label("Notes & AI…", systemImage: "bubble.left.and.text.bubble.right")
}
Button {
Task { @MainActor in
await goToFunctionStart()
}
} label: {
Label("Go to Function Start", systemImage: "arrow.up.left.and.arrow.down.right")
}
}
.popover(
isPresented: Binding(
get: { openNotePopoverAddress == line.address },
set: { presented in
if !presented, openNotePopoverAddress == line.address {
openNotePopoverAddress = nil
}
}
),
arrowEdge: .trailing
) {
AddressNotePopover(
engine: engine,
sessionID: sessionID,
address: line.address,
isPresented: Binding(
get: { openNotePopoverAddress == line.address },
set: { presented in
if !presented { openNotePopoverAddress = nil }
}
)
)
}
Text(line.bytesText.attributed)
.lineLimit(1)
.truncationMode(.tail)
.font(.system(.footnote, design: .monospaced))
.foregroundStyle(.tertiary)
.frame(width: 88, alignment: .leading)
HStack(spacing: 6) {
DisassemblyAsmText(
asm: line.asmText,
engine: engine,
sessionID: sessionID,
selection: $selection
)
if let target = line.branchTarget ?? line.callTarget {
Group {
if !containsPrintedTarget(line.asmText, target: target) {
Button {
jump(target)
} label: {
Text(String(format: "@0x%llx", target))
.font(.system(.footnote, design: .monospaced))
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(.quaternary, in: Capsule())
}
.buttonStyle(.plain)
} else {
Button {
jump(target)
} label: {
Image(systemName: "arrow.turn.down.right")
.font(.footnote)
.foregroundStyle(.secondary)
}
.buttonStyle(.plain)
.opacity(0.6)
.help("Jump to 0x\(String(target, radix: 16))")
}
}
}
}
.frame(minWidth: 240, maxWidth: .infinity, alignment: .leading)
Text(line.commentText?.attributed ?? AttributedString(""))
.lineLimit(1)
.truncationMode(.tail)
.font(.system(.footnote, design: .monospaced))
.foregroundStyle(.secondary)
.textSelection(.enabled)
.frame(width: 320, alignment: .leading)
}
.frame(height: rowHeight - 4, alignment: .center)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.contentShape(Rectangle())
.background {
if isPulsing {
RoundedRectangle(cornerRadius: 8)
.fill(Color.accentColor.opacity(pulsePhase ? 0.28 : 0.06))
} else if hoveredAddr == line.address {
RoundedRectangle(cornerRadius: 8)
.fill(.quaternary)
} else if isSelected {
RoundedRectangle(cornerRadius: 8)
.fill(Color.accentColor.opacity(0.14))
}
}
.onTapGesture {
onSelect()
}
.onHover { isHovering in
hoveredAddr = isHovering ? line.address : nil
}
.onChange(of: requestedJumpTarget) { _, newValue in
guard isSelected else { return }
guard let target = newValue else { return }
clearRequestedJump()
jump(target)
}
}
func jump(_ target: UInt64) {
do {
try onJump(target)
} catch {
errorPresenter.present("Can’t jump here", error.localizedDescription)
}
}
private func containsPrintedTarget(_ asm: StyledText, target: UInt64) -> Bool {
let s = asm.plainText.lowercased()
let hex = String(format: "0x%llx", target).lowercased()
return s.contains(hex)
}
private func goToFunctionStart() async {
guard let dis = engine.disassembler(forSessionID: sessionID) else { return }
guard let target = await dis.findFunctionStart(containing: line.address) else {
errorPresenter.present(
"No enclosing function",
"Couldn’t locate a function containing \(String(format: "0x%llx", line.address)). The binary may not have been analyzed at this address."
)
return
}
do {
let insight = try engine.getOrCreateInsight(sessionID: sessionID, pointer: target, kind: .disassembly)
selection = .insight(sessionID, insight.id)
} catch {
errorPresenter.present("Can’t open function", error.localizedDescription)
}
}
}
private struct DisassemblyAsmText: View {
let asm: StyledText
let engine: Engine
let sessionID: UUID
@Binding var selection: SidebarItemID?
var body: some View {
HStack(spacing: 0) {
ForEach(Array(asm.addressSegments().enumerated()), id: \.offset) { _, segment in
if let address = segment.address {
Text(segment.text.attributed)
.pointerActions(
engine: engine,
sessionID: sessionID,
value: String(format: "0x%llx", address),
address: address,
copyTitle: "Copy Address",
selection: $selection
)
} else {
Text(segment.text.attributed)
}
}
}
.lineLimit(1)
.truncationMode(.tail)
.font(.system(.footnote, design: .monospaced))
.foregroundStyle(.primary)
.textSelection(.enabled)
}
}
private struct DisasmFlowOverlay: View {
let lines: [DisassemblyLine]
let rowHeight: CGFloat
var body: some View {
GeometryReader { proxy in
Canvas { context, size in
let indexByAddr: [UInt64: Int] = Dictionary(
lines.enumerated().map { ($0.element.address, $0.offset) },
uniquingKeysWith: { first, _ in first }
)
func centerY(forRow row: Int) -> CGFloat {
CGFloat(row) * rowHeight + rowHeight * 0.5
}
struct Edge {
let src: UInt64
let dst: UInt64
let sRow: Int
let dRow: Int
let lo: Int
let hi: Int
}
var edges: [Edge] = []
edges.reserveCapacity(lines.count)
for line in lines {
guard let dst = line.branchTarget, let s = indexByAddr[line.address], let d = indexByAddr[dst] else { continue }
edges.append(Edge(src: line.address, dst: dst, sRow: s, dRow: d, lo: min(s, d), hi: max(s, d)))
}
edges.sort { a, b in
if a.lo != b.lo { return a.lo < b.lo }
return (a.hi - a.lo) < (b.hi - b.lo)
}
var laneEnds: [Int] = []
var laneForEdge: [Int] = Array(repeating: 0, count: edges.count)
for i in edges.indices {
let e = edges[i]
var lane = 0
while lane < laneEnds.count {
if e.lo > laneEnds[lane] {
laneEnds[lane] = e.hi
break
}
lane += 1
}
if lane == laneEnds.count {
laneEnds.append(e.hi)
}
laneForEdge[i] = lane
}
var colorForEdge: [Int] = Array(repeating: -1, count: edges.count)
func overlaps(_ a: Edge, _ b: Edge) -> Bool {
!(a.hi < b.lo || b.hi < a.lo)
}
for i in edges.indices {
var usedColors = Set<Int>()
for j in edges.indices {
guard j != i else { continue }
guard colorForEdge[j] >= 0 else { continue }
if overlaps(edges[i], edges[j]) && abs(laneForEdge[i] - laneForEdge[j]) <= 1 {
usedColors.insert(colorForEdge[j])
}
}
for c in FlowPalette.light.indices {
if !usedColors.contains(c) {
colorForEdge[i] = c
break
}
}
if colorForEdge[i] == -1 {
colorForEdge[i] = i % FlowPalette.light.count
}
}
let laneSpacing: CGFloat = 6
let baseX: CGFloat = 12
let elbowX = { (lane: Int) in baseX + CGFloat(lane) * laneSpacing }
let entryX: CGFloat = 48
for i in edges.indices {
let e = edges[i]
let y1 = centerY(forRow: e.sRow)
let y2 = centerY(forRow: e.dRow)
let lane = laneForEdge[i]
let x = elbowX(lane)
var path = Path()
path.move(to: CGPoint(x: entryX, y: y1))
path.addLine(to: CGPoint(x: x, y: y1))
path.addLine(to: CGPoint(x: x, y: y2))
path.addLine(to: CGPoint(x: entryX, y: y2))
let color = FlowPalette.light[colorForEdge[i]]
context.stroke(path, with: .color(color.opacity(0.9)), lineWidth: 1.25)
let tip = CGPoint(x: entryX, y: y2)
let arrowSize: CGFloat = 6
let left = CGPoint(x: tip.x - arrowSize, y: tip.y - arrowSize * 0.65)
let right = CGPoint(x: tip.x - arrowSize, y: tip.y + arrowSize * 0.65)
var head = Path()
head.move(to: tip)
head.addLine(to: left)
head.addLine(to: right)
head.closeSubpath()
context.fill(head, with: .color(color))
}
}
.allowsHitTesting(false)
}
}
}
private enum FlowPalette {
static let light: [Color] = [
.red, .orange, .yellow, .green, .mint, .teal,
.cyan, .blue, .indigo, .purple, .pink, .brown,
]
}