Replace the auto-derived title with userTitle (nil = auto) and a parentInsightID link. Display name is userTitle, else <parent.title>+0x<delta>, else sub_<offset> if at a function start, else module+offset. Sidebar context menu Rename… and an LLM rename_insight tool both go through Engine.renameInsight, which broadcasts via the existing updateInsight op and pushes the new name into r2 as a flag (plus afn at function starts). getOrCreateInsight kicks off a background pass that links a new insight to its enclosing function-start insight, and adopts siblings inside its region when the new insight is itself the function start. The same adoption runs when a fresh ModuleAnalysis newly reveals a function start under an existing insight.
579 lines
19 KiB
Swift
579 lines
19 KiB
Swift
#if canImport(UIKit)
|
|
|
|
import Frida
|
|
import LumaCore
|
|
import SwiftUI
|
|
|
|
struct PhoneSessionView: View {
|
|
let sessionID: UUID
|
|
let engine: Engine
|
|
@Binding var path: [PhoneRoute]
|
|
@Binding var activeDrawer: DrawerKind?
|
|
let eventsIndicator: Bool
|
|
let collabIndicator: Bool
|
|
|
|
@Environment(TargetPicker.self) private var picker
|
|
|
|
@State private var segment: Segment = .repl
|
|
@State private var isShowingAddInstrument = false
|
|
@State private var isShowingSwitcher = false
|
|
@State private var isShowingNotebook = false
|
|
@State private var isShowingCodeShare = false
|
|
@State private var pendingCodeShareAfterAddInstrumentDismiss = false
|
|
@State private var pendingKill = false
|
|
@State private var pendingDelete = false
|
|
@State private var isShowingHostingBlockedAlert = false
|
|
|
|
enum Segment: String, CaseIterable, Identifiable {
|
|
case repl = "REPL"
|
|
case instruments = "Instruments"
|
|
case insights = "Insights"
|
|
case traces = "Traces"
|
|
|
|
var id: Self { self }
|
|
|
|
var icon: String {
|
|
switch self {
|
|
case .repl: return "terminal"
|
|
case .instruments: return "waveform.path.ecg"
|
|
case .insights: return "doc.text.magnifyingglass"
|
|
case .traces: return "waveform.path"
|
|
}
|
|
}
|
|
}
|
|
|
|
private var session: LumaCore.ProcessSession? {
|
|
engine.sessions.first { $0.id == sessionID }
|
|
}
|
|
|
|
private var node: LumaCore.ProcessNode? {
|
|
engine.node(forSessionID: sessionID)
|
|
}
|
|
|
|
var body: some View {
|
|
VStack(spacing: 0) {
|
|
header
|
|
Divider()
|
|
|
|
Picker("Segment", selection: $segment) {
|
|
ForEach(Segment.allCases) { s in
|
|
Text(s.rawValue).tag(s)
|
|
}
|
|
}
|
|
.pickerStyle(.segmented)
|
|
.padding(.horizontal, 12)
|
|
.padding(.top, 8)
|
|
.padding(.bottom, 4)
|
|
|
|
if let session, session.phase == .awaitingInitialResume, let node {
|
|
resumeBanner(session: session, node: node)
|
|
}
|
|
|
|
SessionContent(sessionID: sessionID, engine: engine) {
|
|
segmentBody
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
}
|
|
}
|
|
.toolbar(.hidden, for: .navigationBar)
|
|
.sheet(isPresented: $isShowingNotebook) {
|
|
PhoneNotebookSheet(engine: engine)
|
|
}
|
|
.sheet(
|
|
isPresented: $isShowingAddInstrument,
|
|
onDismiss: {
|
|
if pendingCodeShareAfterAddInstrumentDismiss {
|
|
pendingCodeShareAfterAddInstrumentDismiss = false
|
|
isShowingCodeShare = true
|
|
}
|
|
}
|
|
) {
|
|
if let session {
|
|
AddInstrumentSheet(
|
|
session: session,
|
|
engine: engine,
|
|
selection: $path.asSidebarSelection(),
|
|
onInstrumentAdded: { _ in
|
|
isShowingAddInstrument = false
|
|
},
|
|
onBrowseCodeShare: {
|
|
pendingCodeShareAfterAddInstrumentDismiss = true
|
|
}
|
|
)
|
|
}
|
|
}
|
|
.sheet(isPresented: $isShowingCodeShare) {
|
|
if let session {
|
|
NavigationStack {
|
|
CodeShareBrowserView(
|
|
session: session,
|
|
engine: engine,
|
|
onInstrumentAdded: { _ in
|
|
isShowingCodeShare = false
|
|
}
|
|
)
|
|
}
|
|
}
|
|
}
|
|
.confirmationDialog(
|
|
"Kill Process?",
|
|
isPresented: $pendingKill,
|
|
titleVisibility: .visible
|
|
) {
|
|
Button("Kill Process", role: .destructive) { killProcess() }
|
|
Button("Cancel", role: .cancel) {}
|
|
} message: {
|
|
if let session {
|
|
Text("This will force-terminate \"\(session.processName)\".")
|
|
}
|
|
}
|
|
.confirmationDialog(
|
|
"Delete Session?",
|
|
isPresented: $pendingDelete,
|
|
titleVisibility: .visible
|
|
) {
|
|
Button("Delete Session", role: .destructive) { deleteSession() }
|
|
Button("Cancel", role: .cancel) {}
|
|
} message: {
|
|
Text("This will remove the session and its history.")
|
|
}
|
|
.alert(
|
|
"Only lab owners can host sessions",
|
|
isPresented: $isShowingHostingBlockedAlert
|
|
) {
|
|
Button("OK", role: .cancel) {}
|
|
} message: {
|
|
Text("You're a member of this lab. Ask an owner to promote you before starting a session.")
|
|
}
|
|
}
|
|
|
|
@Environment(\.dismiss) private var dismiss
|
|
|
|
private var header: some View {
|
|
HStack(spacing: 4) {
|
|
Button {
|
|
dismiss()
|
|
} label: {
|
|
Image(systemName: "chevron.left")
|
|
.font(.body.weight(.semibold))
|
|
.frame(width: 36, height: 36)
|
|
}
|
|
.accessibilityLabel("Back")
|
|
|
|
Button {
|
|
isShowingSwitcher = true
|
|
} label: {
|
|
HStack(spacing: 4) {
|
|
Text(session?.processName ?? "Session")
|
|
.font(.headline)
|
|
.lineLimit(1)
|
|
Image(systemName: "chevron.down")
|
|
.font(.caption2)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
.buttonStyle(.plain)
|
|
.popover(isPresented: $isShowingSwitcher, arrowEdge: .top) {
|
|
PhoneSessionSwitcher(
|
|
engine: engine,
|
|
current: sessionID,
|
|
onPick: { id in
|
|
isShowingSwitcher = false
|
|
if id != sessionID {
|
|
path.removeLast()
|
|
path.append(.session(id))
|
|
}
|
|
},
|
|
onNew: {
|
|
isShowingSwitcher = false
|
|
if engine.canHostNewSessions {
|
|
picker.context = .newSession
|
|
} else {
|
|
isShowingHostingBlockedAlert = true
|
|
}
|
|
}
|
|
)
|
|
.presentationCompactAdaptation(.popover)
|
|
}
|
|
|
|
Button {
|
|
isShowingNotebook = true
|
|
} label: {
|
|
Image(systemName: "book.pages")
|
|
.font(.body)
|
|
.frame(width: 36, height: 36)
|
|
}
|
|
.accessibilityLabel("Notebook")
|
|
|
|
Spacer()
|
|
|
|
DrawerTriggerButton(
|
|
kind: .events,
|
|
active: $activeDrawer,
|
|
indicator: eventsIndicator
|
|
)
|
|
.font(.body)
|
|
.frame(width: 36, height: 36)
|
|
|
|
DrawerTriggerButton(
|
|
kind: .collab,
|
|
active: $activeDrawer,
|
|
indicator: collabIndicator
|
|
)
|
|
.font(.body)
|
|
.frame(width: 36, height: 36)
|
|
|
|
Menu {
|
|
if segment == .instruments {
|
|
Button {
|
|
isShowingAddInstrument = true
|
|
} label: {
|
|
Label("Add Instrument…", systemImage: "plus")
|
|
}
|
|
Divider()
|
|
}
|
|
|
|
if node != nil {
|
|
Button {
|
|
detach()
|
|
} label: {
|
|
Label("Detach Session", systemImage: "bolt.slash")
|
|
}
|
|
Button(role: .destructive) {
|
|
pendingKill = true
|
|
} label: {
|
|
Label("Kill Process", systemImage: "xmark.circle")
|
|
}
|
|
}
|
|
|
|
Button(role: .destructive) {
|
|
pendingDelete = true
|
|
} label: {
|
|
Label("Delete Session", systemImage: "trash")
|
|
}
|
|
} label: {
|
|
Image(systemName: "ellipsis")
|
|
.font(.body)
|
|
.frame(width: 36, height: 36)
|
|
}
|
|
}
|
|
.padding(.horizontal, 8)
|
|
.padding(.vertical, 4)
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var segmentBody: some View {
|
|
switch segment {
|
|
case .repl:
|
|
REPLView(
|
|
sessionID: sessionID,
|
|
engine: engine,
|
|
selection: $path.asSidebarSelection()
|
|
)
|
|
|
|
case .instruments:
|
|
instrumentsList
|
|
|
|
case .insights:
|
|
insightsList
|
|
|
|
case .traces:
|
|
tracesList
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var instrumentsList: some View {
|
|
let instruments = engine.instrumentsBySession[sessionID] ?? []
|
|
if instruments.isEmpty {
|
|
segmentEmptyState(
|
|
icon: "waveform.path.ecg",
|
|
title: "No instruments yet",
|
|
hint: nil,
|
|
actionTitle: "Add Instrument…",
|
|
action: { isShowingAddInstrument = true }
|
|
)
|
|
} else {
|
|
List {
|
|
ForEach(instruments) { instance in
|
|
Button {
|
|
path.append(.instrument(sessionID, instance.id))
|
|
} label: {
|
|
InstrumentRow(instance: instance, engine: engine)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.swipeActions(edge: .trailing) {
|
|
Button(role: .destructive) {
|
|
Task { await engine.removeInstrument(instance) }
|
|
} label: {
|
|
Label("Delete", systemImage: "trash")
|
|
}
|
|
Button {
|
|
let newState: LumaCore.InstrumentState = instance.state == .enabled ? .disabled : .enabled
|
|
Task {
|
|
await engine.setInstrumentState(instance, state: newState)
|
|
}
|
|
} label: {
|
|
Label(
|
|
instance.state == .enabled ? "Disable" : "Enable",
|
|
systemImage: instance.state == .enabled ? "pause.circle" : "play.circle"
|
|
)
|
|
}
|
|
.tint(instance.state == .enabled ? .orange : .green)
|
|
}
|
|
}
|
|
}
|
|
.listStyle(.plain)
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var insightsList: some View {
|
|
let insights = (engine.insightsBySession[sessionID] ?? [])
|
|
.sorted { $0.createdAt < $1.createdAt }
|
|
if insights.isEmpty {
|
|
segmentEmptyState(
|
|
icon: "doc.text.magnifyingglass",
|
|
title: "No insights yet",
|
|
hint: "Long-press a NativePointer value and choose Open Memory or Open Disassembly to create one.",
|
|
actionTitle: nil,
|
|
action: nil
|
|
)
|
|
} else {
|
|
List {
|
|
ForEach(insights) { insight in
|
|
Button {
|
|
path.append(.insight(sessionID, insight.id))
|
|
} label: {
|
|
InsightRow(insight: insight, engine: engine)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.swipeActions(edge: .trailing) {
|
|
Button(role: .destructive) {
|
|
try? engine.store.deleteInsight(id: insight.id)
|
|
} label: {
|
|
Label("Delete", systemImage: "trash")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.listStyle(.plain)
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var tracesList: some View {
|
|
let traces = (engine.tracesBySession[sessionID] ?? [])
|
|
.sorted { $0.startedAt < $1.startedAt }
|
|
if traces.isEmpty {
|
|
segmentEmptyState(
|
|
icon: "waveform.path",
|
|
title: "No traces yet",
|
|
hint: "Run an ITrace session to record one.",
|
|
actionTitle: nil,
|
|
action: nil
|
|
)
|
|
} else {
|
|
List {
|
|
ForEach(traces) { trace in
|
|
Button {
|
|
path.append(.trace(sessionID, trace.id))
|
|
} label: {
|
|
TraceRow(trace: trace)
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
}
|
|
.listStyle(.plain)
|
|
}
|
|
}
|
|
|
|
private func segmentEmptyState(
|
|
icon: String,
|
|
title: String,
|
|
hint: String?,
|
|
actionTitle: String?,
|
|
action: (() -> Void)?
|
|
) -> some View {
|
|
VStack(spacing: 12) {
|
|
Image(systemName: icon)
|
|
.font(.system(size: 44))
|
|
.foregroundStyle(.secondary)
|
|
Text(title).font(.headline)
|
|
if let hint {
|
|
Text(hint)
|
|
.font(.subheadline)
|
|
.foregroundStyle(.secondary)
|
|
.multilineTextAlignment(.center)
|
|
}
|
|
if let actionTitle, let action {
|
|
Button(actionTitle, action: action)
|
|
.buttonStyle(.borderedProminent)
|
|
.padding(.top, 8)
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
.padding()
|
|
}
|
|
|
|
private func resumeBanner(session: LumaCore.ProcessSession, node: LumaCore.ProcessNode) -> some View {
|
|
HStack(spacing: 10) {
|
|
Image(systemName: "pause.circle")
|
|
Text("Spawned \(session.processName). Ready to resume.")
|
|
.font(.subheadline)
|
|
Spacer()
|
|
Button {
|
|
Task { @MainActor in
|
|
await engine.resumeSpawnedProcess(node: node)
|
|
}
|
|
} label: {
|
|
Label("Resume", systemImage: "play.fill")
|
|
}
|
|
.buttonStyle(.borderedProminent)
|
|
.controlSize(.small)
|
|
}
|
|
.padding(.horizontal, 12)
|
|
.padding(.vertical, 8)
|
|
.background(Color.accentColor.opacity(0.12))
|
|
}
|
|
|
|
private func detach() {
|
|
guard let node else { return }
|
|
engine.removeNode(node)
|
|
}
|
|
|
|
private func killProcess() {
|
|
guard let node, let session else { return }
|
|
Task { @MainActor in
|
|
do { try await node.kill() } catch {
|
|
engine.updateSession(id: session.id) { $0.lastError = error.localizedDescription }
|
|
}
|
|
}
|
|
}
|
|
|
|
private func deleteSession() {
|
|
if let node { engine.removeNode(node) }
|
|
try? engine.store.deleteSession(id: sessionID)
|
|
if !path.isEmpty { path.removeLast() }
|
|
}
|
|
}
|
|
|
|
private struct InstrumentRow: View {
|
|
let instance: LumaCore.InstrumentInstance
|
|
let engine: Engine
|
|
|
|
private var descriptor: InstrumentDescriptor {
|
|
engine.descriptor(for: instance)
|
|
}
|
|
|
|
var body: some View {
|
|
HStack(spacing: 12) {
|
|
InstrumentIconView(icon: descriptor.icon, pointSize: 20)
|
|
.frame(width: 28, height: 28)
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text(descriptor.displayName)
|
|
.font(.body)
|
|
if instance.state == .disabled {
|
|
Text("Disabled")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
Spacer()
|
|
Image(systemName: "chevron.right")
|
|
.font(.caption)
|
|
.foregroundStyle(.tertiary)
|
|
}
|
|
.padding(.vertical, 4)
|
|
.opacity(instance.state == .enabled ? 1 : 0.55)
|
|
.contentShape(Rectangle())
|
|
}
|
|
}
|
|
|
|
private struct InsightRow: View {
|
|
let insight: LumaCore.AddressInsight
|
|
let engine: Engine
|
|
|
|
var body: some View {
|
|
HStack(spacing: 12) {
|
|
Image(systemName: insight.kind == .memory ? "doc.text.magnifyingglass" : "hammer")
|
|
.frame(width: 28, height: 28)
|
|
.foregroundStyle(.tint)
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text(engine.displayTitle(for: insight)).font(.body)
|
|
Text(insight.anchor.displayString)
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
.lineLimit(1)
|
|
}
|
|
Spacer()
|
|
Image(systemName: "chevron.right")
|
|
.font(.caption)
|
|
.foregroundStyle(.tertiary)
|
|
}
|
|
.padding(.vertical, 4)
|
|
.contentShape(Rectangle())
|
|
}
|
|
}
|
|
|
|
private struct TraceRow: View {
|
|
let trace: LumaCore.ITrace
|
|
|
|
var body: some View {
|
|
HStack(spacing: 12) {
|
|
Image(systemName: trace.isRunning ? "record.circle" : "waveform.path")
|
|
.frame(width: 28, height: 28)
|
|
.foregroundStyle(trace.isRunning ? .red : .primary)
|
|
Text(trace.displayName).font(.body)
|
|
Spacer()
|
|
Image(systemName: "chevron.right")
|
|
.font(.caption)
|
|
.foregroundStyle(.tertiary)
|
|
}
|
|
.padding(.vertical, 4)
|
|
.contentShape(Rectangle())
|
|
}
|
|
}
|
|
|
|
struct PhoneSessionSwitcher: View {
|
|
let engine: Engine
|
|
let current: UUID
|
|
let onPick: (UUID) -> Void
|
|
let onNew: () -> Void
|
|
|
|
var body: some View {
|
|
VStack(spacing: 0) {
|
|
ForEach(engine.sessions) { session in
|
|
Button {
|
|
onPick(session.id)
|
|
} label: {
|
|
HStack(spacing: 8) {
|
|
PhoneSessionRow(session: session, engine: engine)
|
|
if session.id == current {
|
|
Image(systemName: "checkmark")
|
|
.foregroundStyle(.tint)
|
|
}
|
|
}
|
|
}
|
|
.buttonStyle(.plain)
|
|
.padding(.horizontal, 12)
|
|
.padding(.vertical, 6)
|
|
Divider()
|
|
}
|
|
Button(action: onNew) {
|
|
HStack {
|
|
Image(systemName: "plus.circle")
|
|
Text("New Session…")
|
|
Spacer()
|
|
}
|
|
.padding(.horizontal, 12)
|
|
.padding(.vertical, 10)
|
|
.foregroundStyle(.tint)
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
.frame(minWidth: 260, maxWidth: 340)
|
|
}
|
|
}
|
|
|
|
#endif
|