Files
luma/Luma/PhoneMainView.swift
Ole André Vadla Ravnås 0e933206fb Boot virtual machines from the target picker
Add a machine abstraction to LumaCore, with QEMU as its first
backend: templates mirroring frida-core's test guests, a QMP client,
and a display that maps QEMU's surface over D-Bus rather than
copying frames.

Leave readiness to the user: drive the guest, mark it ready, and come
back to that snapshot later. Keep the machine in the project, so
booting it again resumes where it was marked. Hand the stub to
addBareboneDevice, so it joins the picker beside the other devices.

Show the display while booting, and afterwards in a Machines panel,
which the side panel now models as an enum to share its slot.
2026-08-20 14:52:52 +02:00

439 lines
15 KiB
Swift

#if canImport(UIKit)
import Frida
import LumaCore
import SwiftUI
import UIKit
struct PhoneMainView: View {
private let projectURL: URL
private let documentActions: PhoneDocumentActions
@State private var engineResult: Result<Engine, any Swift.Error>
@State private var picker = TargetPicker()
init(projectURL: URL, documentActions: PhoneDocumentActions = .noop) {
self.projectURL = projectURL
self.documentActions = documentActions
let result: Result<Engine, any Swift.Error>
do {
let engine = try EngineRegistry.shared.engine(
for: projectURL,
dataDirectory: LumaAppPaths.shared.dataDirectory,
gitHubAuth: sharedGitHubAuth()
)
engine.imageProcessor = HostImageProcessor()
result = .success(engine)
} catch {
result = .failure(error)
}
self._engineResult = State(initialValue: result)
}
var body: some View {
switch engineResult {
case .success(let engine):
PhoneContentView(
engine: engine,
picker: picker,
projectURL: projectURL,
documentActions: documentActions
)
case .failure(let error):
VStack(spacing: 12) {
Text("Failed to open project")
.font(.title3)
Text(error.localizedDescription)
.font(.callout)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal, 24)
}
}
}
}
private struct PhoneContentView: View {
let engine: Engine
let picker: TargetPicker
let projectURL: URL
let documentActions: PhoneDocumentActions
@State private var path: [PhoneRoute] = []
@State private var activeDrawer: DrawerKind?
@State private var eventsBaseline: Int = 0
@State private var collabChatBaseline: Int = 0
var body: some View {
DrawerHost(
active: $activeDrawer,
engine: engine,
onEventsOpened: { eventsBaseline = engine.eventLog.totalReceived },
onCollabOpened: { collabChatBaseline = engine.collaboration.chatMessages.count }
) {
NavigationStack(path: $path) {
PhoneSessionsListView(
engine: engine,
path: $path,
activeDrawer: $activeDrawer,
eventsIndicator: eventsIndicator,
collabIndicator: collabIndicator,
documentActions: documentActions
)
.navigationDestination(for: PhoneRoute.self) { route in
switch route {
case .session(let id):
PhoneSessionView(
sessionID: id,
engine: engine,
path: $path,
activeDrawer: $activeDrawer,
eventsIndicator: eventsIndicator,
collabIndicator: collabIndicator
)
case .instrument(let sessionID, let instrumentID):
if engine.instrumentsBySession[sessionID]?
.contains(where: { $0.id == instrumentID }) == true
{
SessionContent(sessionID: sessionID, engine: engine) {
InstrumentDetailView(
instanceID: instrumentID,
sessionID: sessionID,
engine: engine,
selection: $path.asSidebarSelection()
)
}
.navigationBarTitleDisplayMode(.inline)
.toolbar { drawerTriggers }
}
case .insight(let sessionID, let insightID):
if let session = engine.sessions.first(where: { $0.id == sessionID }),
let insight = (engine.insightsBySession[sessionID] ?? []).first(where: { $0.id == insightID })
{
SessionContent(sessionID: sessionID, engine: engine) {
AddressInsightDetailView(
session: session,
insightID: insightID,
engine: engine,
selection: $path.asSidebarSelection()
)
}
.navigationTitle(engine.displayTitle(for: insight))
.navigationBarTitleDisplayMode(.inline)
.toolbar { drawerTriggers }
}
case .trace(let sessionID, let traceID):
if let session = engine.sessions.first(where: { $0.id == sessionID }),
let trace = engine.tracesBySession[sessionID]?
.first(where: { $0.id == traceID })
{
SessionContent(sessionID: sessionID, engine: engine) {
ITraceDetailView(
trace: trace,
session: session,
engine: engine,
selection: $path.asSidebarSelection()
)
}
.navigationTitle(trace.displayName)
.navigationBarTitleDisplayMode(.inline)
.toolbar { drawerTriggers }
}
case .customInstrument(let defID, let filePath):
CustomInstrumentEditorView(
defID: defID,
path: filePath,
engine: engine,
selection: $path.asSidebarSelection()
)
.navigationTitle(engine.customInstruments.def(withId: defID)?.name ?? "Custom Instrument")
.navigationBarTitleDisplayMode(.inline)
.toolbar { drawerTriggers }
}
}
}
}
.environment(picker)
.task {
await EngineRegistry.shared.startIfNeeded(for: projectURL)
engine.attachInstrumentUIs()
if engine.collaboration.isCollaborative {
engine.setSidePanel(.collaboration)
}
eventsBaseline = engine.eventLog.totalReceived
collabChatBaseline = engine.collaboration.chatMessages.count
}
.onAppear {
LumaAppState.shared.lastDocumentPath = projectURL.path
}
.onDisappear {
let url = projectURL
Task { @MainActor in
await EngineRegistry.shared.release(workingProjectURL: url)
}
}
.onOpenURL { url in
LumaAppDelegate.handle(url: url)
}
}
@ToolbarContentBuilder
private var drawerTriggers: some ToolbarContent {
ToolbarItemGroup(placement: .topBarTrailing) {
DrawerTriggerButton(
kind: .events,
active: $activeDrawer,
indicator: eventsIndicator
)
DrawerTriggerButton(
kind: .collab,
active: $activeDrawer,
indicator: collabIndicator
)
}
}
private var eventsIndicator: Bool {
engine.eventLog.totalReceived > eventsBaseline && activeDrawer != .events
}
private var collabIndicator: Bool {
engine.collaboration.chatMessages.count > collabChatBaseline && activeDrawer != .collab
}
}
enum PhoneRoute: Hashable {
case session(UUID)
case instrument(UUID, UUID)
case insight(UUID, UUID)
case trace(UUID, UUID)
case customInstrument(UUID, String?)
}
extension Binding where Value == [PhoneRoute] {
func asSidebarSelection() -> Binding<SidebarItemID?> {
Binding<SidebarItemID?>(
get: { nil },
set: { newValue in
guard let newValue else { return }
switch newValue {
case .insight(let sid, let iid):
self.wrappedValue.append(.insight(sid, iid))
case .instrument(let sid, let iid),
.instrumentComponent(let sid, let iid, _):
self.wrappedValue.append(.instrument(sid, iid))
case .itrace(let sid, let tid):
self.wrappedValue.append(.trace(sid, tid))
case .customInstrumentDef(let defID):
self.wrappedValue.append(.customInstrument(defID, nil))
case .customInstrumentFile(let defID, let path):
self.wrappedValue.append(.customInstrument(defID, path))
case .session, .repl, .module, .thread, .notebook, .missions, .mission, .package, .pharo:
break
}
}
)
}
}
enum DrawerKind: Hashable {
case events
case collab
}
private struct DrawerHost<Content: View>: View {
@Binding var active: DrawerKind?
let engine: Engine
let onEventsOpened: () -> Void
let onCollabOpened: () -> Void
@ViewBuilder let content: () -> Content
@State private var expanded = false
@State private var dragOffset: CGFloat = 0
@State private var keyboard = KeyboardObserver()
var body: some View {
GeometryReader { geo in
let width = geo.size.width
let defaultWidth = min(width * 0.85, width - 40)
let drawerWidth = expanded ? width : defaultWidth
let drawerHeight = max(0, geo.size.height - keyboard.height)
ZStack(alignment: .topTrailing) {
content()
if active != nil {
Color.black.opacity(0.35)
.ignoresSafeArea()
.transition(.opacity)
.onTapGesture { close() }
}
if let kind = active {
drawerBody(for: kind)
.frame(width: drawerWidth)
.frame(height: drawerHeight, alignment: .top)
.background(Color.platformWindowBackground)
.shadow(color: .black.opacity(0.3), radius: 12, x: -4, y: 0)
.offset(x: dragOffset)
.transition(.move(edge: .trailing))
.gesture(
DragGesture()
.onChanged { value in
dragOffset = max(0, value.translation.width)
}
.onEnded { value in
if value.translation.width > drawerWidth * 0.3 {
close()
} else {
withAnimation(.spring(response: 0.25)) {
dragOffset = 0
}
}
}
)
}
}
.animation(.spring(response: 0.3, dampingFraction: 0.85), value: active)
.onChange(of: active) { oldValue, newValue in
switch oldValue {
case .events:
onEventsOpened()
case .collab:
onCollabOpened()
case nil:
break
}
switch newValue {
case .events:
onEventsOpened()
case .collab:
onCollabOpened()
case nil:
expanded = false
dragOffset = 0
}
}
}
.ignoresSafeArea(.keyboard)
}
@ViewBuilder
private func drawerBody(for kind: DrawerKind) -> some View {
VStack(spacing: 0) {
DrawerHeader(
title: kind.title,
expanded: $expanded,
onClose: close
)
Divider()
switch kind {
case .events:
EventStreamView(
engine: engine,
selection: .constant(nil),
onCollapseRequested: close
)
case .collab:
CollaborationPanel(engine: engine)
}
}
}
private func close() {
withAnimation(.spring(response: 0.3, dampingFraction: 0.85)) {
active = nil
dragOffset = 0
expanded = false
}
}
}
private struct DrawerHeader: View {
let title: String
@Binding var expanded: Bool
let onClose: () -> Void
var body: some View {
HStack(spacing: 12) {
Button {
expanded.toggle()
} label: {
Image(systemName: expanded
? "arrow.down.right.and.arrow.up.left"
: "arrow.up.left.and.arrow.down.right")
}
.buttonStyle(.plain)
.font(.body)
Text(title)
.font(.headline)
Spacer()
Button(action: onClose) {
Image(systemName: "xmark")
}
.buttonStyle(.plain)
.font(.body)
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
}
}
private extension DrawerKind {
var title: String {
switch self {
case .events: return "Events"
case .collab: return "Collaboration"
}
}
}
struct DrawerTriggerButton: View {
let kind: DrawerKind
@Binding var active: DrawerKind?
let indicator: Bool
var body: some View {
Button {
if active == kind {
active = nil
} else {
active = kind
}
} label: {
ZStack(alignment: .topTrailing) {
Image(systemName: kind.icon)
.symbolVariant(active == kind ? .fill : .none)
if indicator {
Circle()
.fill(Color.accentColor)
.frame(width: 7, height: 7)
.offset(x: 3, y: -2)
.transition(.scale.combined(with: .opacity))
}
}
}
.animation(.spring(response: 0.3), value: indicator)
}
}
private extension DrawerKind {
var icon: String {
switch self {
case .events: return "waveform"
case .collab: return "person.2.wave.2"
}
}
}
#endif