Files
luma/Luma/PhoneSessionView.swift
Ole André Vadla Ravnås dd71943d94 Allow naming address insights
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.
2026-05-19 16:01:25 +02:00

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