Files
luma/Luma/TargetPickerView.swift

1808 lines
65 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 Frida
import SwiftUI
import LumaCore
import UniformTypeIdentifiers
struct TargetPickerView: View {
enum Mode: String {
case spawn
case armForLaunch
case attach
}
enum SpawnSubmode: String {
case application
case program
}
@StateObject private var store: DeviceListModel
private let engine: Engine
private let deviceManager: DeviceManager
#if canImport(UIKit)
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
private var isCompactWidth: Bool { horizontalSizeClass == .compact }
#else
private var isCompactWidth: Bool { false }
#endif
let reason: String?
let onSpawn: (Device, SpawnConfig) -> Void
let onAttach: (Device, ProcessDetails) -> Void
let onArm: (Device, SpawnConfig, String) -> Void
@Environment(\.dismiss) private var dismiss
@State private var pickerState: TargetPickerState?
@State private var selectedDeviceID: Device.ID?
@State private var pendingDeviceSelection: Device.ID?
@State private var armRegex: String = ""
@State private var armDisplayName: String = ""
@State private var armAutoResume: Bool = true
@State private var isShowingArmSuggestions: Bool = false
@State private var armSuggestionsSearchText: String = ""
@State private var remoteConfigs: [LumaCore.RemoteDeviceConfig] = []
@State private var mode: Mode = .attach
@State private var spawnSubmode: SpawnSubmode = .application
@State private var loadingApplications = false
@State private var applications: [ApplicationDetails] = []
@State private var applicationError: String?
@State private var applicationSearchText: String = ""
@State private var selectedApplicationIdentifier: String?
@State private var appArgumentsText: String = ""
@State private var appEnvEntries: [EnvEntry] = []
@State private var appWorkingDirectory: String = ""
@State private var appStdio: Stdio = .pipe
@State private var appAutoResume: Bool = true
@State private var appArgumentsExpanded: Bool = false
@State private var appEnvExpanded: Bool = false
@State private var appWorkingDirExpanded: Bool = false
@State private var appExecutionExpanded: Bool = false
@State private var appOptionsExpanded: Bool = false
@State private var programPath: String = ""
@State private var isShowingProgramBrowser: Bool = false
@State private var programArgumentsText: String = ""
@State private var programEnvEntries: [EnvEntry] = []
@State private var programWorkingDirectory: String = ""
@State private var programStdio: Stdio = .pipe
@State private var programAutoResume: Bool = true
@State private var processes: [ProcessDetails] = []
@State private var loadingProcesses = false
@State private var processError: String?
@State private var processSearchText: String = ""
@FocusState private var focusedField: FocusedField?
@State private var selectedProcessPID: UInt?
@State private var showingAddRemoteSheet = false
@State private var showingBootMachineSheet = false
@State private var remoteAddress = ""
@State private var remoteCertificate = ""
@State private var remoteOrigin = ""
@State private var remoteToken = ""
@State private var remoteKeepalive = ""
@State private var showingAdvancedRemoteOptions = false
@State private var addRemoteError: String?
enum FocusedField: Hashable {
case processSearch
case processList
}
init(
engine: Engine,
deviceManager: DeviceManager,
reason: String? = nil,
onSpawn: @escaping (Device, SpawnConfig) -> Void,
onAttach: @escaping (Device, ProcessDetails) -> Void,
onArm: @escaping (Device, SpawnConfig, String) -> Void
) {
self.engine = engine
self.deviceManager = deviceManager
self.reason = reason
self.onSpawn = onSpawn
self.onAttach = onAttach
self.onArm = onArm
self._store = StateObject(wrappedValue: DeviceListModel(manager: deviceManager))
}
private var selectedDevice: Device? {
guard let id = selectedDeviceID else { return nil }
return store.devices.first { $0.id == id }
}
private var canSpawn: Bool {
guard selectedDevice != nil else { return false }
switch spawnSubmode {
case .application:
return selectedApplicationIdentifier != nil
case .program:
return !programPath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}
}
private var canArm: Bool {
guard selectedDevice != nil else { return false }
return !armRegex.trimmingCharacters(in: .whitespaces).isEmpty
&& !armDisplayName.trimmingCharacters(in: .whitespaces).isEmpty
}
@ToolbarContentBuilder
private var sharedToolbar: some ToolbarContent {
ToolbarItem(placement: .cancellationAction) {
Button {
dismiss()
} label: {
Text("Cancel")
}
}
ToolbarItem(placement: .confirmationAction) {
switch mode {
case .spawn:
Button {
triggerSpawn()
} label: {
Label("Spawn & Attach", systemImage: "play.circle")
}
.disabled(!canSpawn)
case .armForLaunch:
Button {
triggerArm()
} label: {
Label("Arm for Launch", systemImage: "scope")
}
.disabled(!canArm)
case .attach:
EmptyView()
}
}
}
var body: some View {
VStack(spacing: 0) {
if let reason {
LumaBanner(style: .info) {
Label {
Text(reason)
.font(.callout)
} icon: {
Image(systemName: "info.circle")
}
Spacer()
}
}
NavigationSplitView {
deviceListPane()
.navigationTitle("New Session")
.navigationSplitViewColumnWidth(min: 200, ideal: 250)
.toolbar {
if isCompactWidth {
sharedToolbar
}
}
} detail: {
detailPane
}
.frame(minWidth: isCompactWidth ? 0 : 904, minHeight: isCompactWidth ? 0 : 560)
#if canImport(AppKit)
.sheet(isPresented: $showingBootMachineSheet) {
BootVirtualMachineSheet(engine: engine) { device in
pendingDeviceSelection = device.id
selectedDeviceID = device.id
}
}
#endif
.sheet(isPresented: $showingAddRemoteSheet) {
addRemoteSheet()
}
.task(id: pendingDeviceSelection) {
guard pendingDeviceSelection != nil else { return }
try? await Task.sleep(for: .seconds(6))
guard !Task.isCancelled else { return }
pendingDeviceSelection = nil
}
.task { await engine.virtualMachines.prewarm() }
.task {
if pickerState == nil {
pickerState = TargetPickerState()
}
if let lastID = pickerState?.lastSelectedDeviceID,
store.devices.contains(where: { $0.id == lastID })
{
selectedDeviceID = lastID
} else if !isCompactWidth {
if let local = store.devices.first(where: { $0.type == .local }) {
selectedDeviceID = local.id
} else {
selectedDeviceID = store.devices.first?.id
}
}
if let state = pickerState {
if let rawMode = state.lastModeRaw,
let savedMode = Mode(rawValue: rawMode)
{
mode = savedMode
} else {
mode = .attach
}
if let rawSub = state.lastSpawnSubmodeRaw,
let savedSub = SpawnSubmode(rawValue: rawSub)
{
spawnSubmode = savedSub
}
if let appID = state.lastSpawnApplicationID {
selectedApplicationIdentifier = appID
}
if let progPath = state.lastSpawnProgramPath {
programPath = progPath
}
}
}
.onChange(of: store.devices) { _, newDevices in
if let pending = pendingDeviceSelection {
if newDevices.contains(where: { $0.id == pending }) {
selectedDeviceID = pending
pendingDeviceSelection = nil
}
return
}
if let current = selectedDeviceID,
newDevices.contains(where: { $0.id == current })
{
return
}
if let lastID = pickerState?.lastSelectedDeviceID,
newDevices.contains(where: { $0.id == lastID })
{
selectedDeviceID = lastID
return
}
if !isCompactWidth {
if let local = newDevices.first(where: { $0.type == .local }) {
selectedDeviceID = local.id
} else {
selectedDeviceID = newDevices.first?.id
}
}
}
.onChange(of: selectedDeviceID) { _, newID in
if newID != pendingDeviceSelection {
pendingDeviceSelection = nil
}
pickerState?.lastSelectedDeviceID = newID
}
.onChange(of: mode) { _, newValue in
pickerState?.lastModeRaw = newValue.rawValue
}
.onChange(of: spawnSubmode) { _, newValue in
pickerState?.lastSpawnSubmodeRaw = newValue.rawValue
}
.onChange(of: selectedApplicationIdentifier) { _, newValue in
pickerState?.lastSpawnApplicationID = newValue
}
.onChange(of: programPath) { _, newValue in
pickerState?.lastSpawnProgramPath = newValue
}
}
}
@ViewBuilder
/// Typed on its own: inline in the split view's detail closure, this is one
/// expression the type-checker gives up on.
private var detailPane: some View {
VStack(alignment: .leading, spacing: 0) {
modeSelector()
Divider()
if let device = selectedDevice {
switch mode {
case .spawn:
spawnDetailPane(for: device)
case .armForLaunch:
armDetailPane(for: device)
case .attach:
processDetailPane(for: device)
}
} else if store.discoveryState == .discovering {
detailLoadingPlaceholder
} else {
ZStack {
Color.clear
ContentUnavailableView(
"Select a Device",
systemImage: "ipad.and.iphone",
description: Text("Choose a device on the left to start a new session.")
)
}
}
}
.navigationTitle("New Session")
.toolbar { sharedToolbar }
}
@ViewBuilder
private var detailLoadingPlaceholder: some View {
if mode == .spawn && spawnSubmode == .application {
skeletonRows(placeholderApplicationNames)
} else {
VStack(spacing: 0) {
skeletonSearchField
Divider()
skeletonRows(placeholderProcessNames)
}
}
}
private func modeSelector() -> some View {
HStack {
Picker("Mode", selection: $mode) {
Text("Spawn").tag(Mode.spawn)
Text("Wait for Launch").tag(Mode.armForLaunch)
Text("Attach").tag(Mode.attach)
}
.pickerStyle(.segmented)
.labelsHidden()
.fixedSize()
Spacer()
Text(modeHint)
.font(.footnote)
.foregroundStyle(.secondary)
}
.padding(.horizontal)
.padding(.top, 8)
.padding(.bottom, 4)
}
@ViewBuilder
private func spawnDetailPane(for device: Device) -> some View {
VStack(alignment: .leading, spacing: 0) {
spawnHeader()
Divider()
switch spawnSubmode {
case .application:
applicationSpawnPane(for: device)
case .program:
programSpawnPane(for: device)
}
}
.task(id: device.id) {
await loadApplications(for: device)
}
}
@ViewBuilder
private func spawnHeader() -> some View {
HStack(spacing: 12) {
if spawnSubmode == .application && !applications.isEmpty {
HStack(spacing: 6) {
Image(systemName: "magnifyingglass")
.foregroundStyle(.secondary)
TextField("Filter by name or identifier", text: $applicationSearchText)
.textFieldStyle(.roundedBorder)
.disableAutocorrection(true)
#if canImport(UIKit)
.textInputAutocapitalization(.never)
#endif
}
} else {
Spacer()
}
Picker("Launch", selection: $spawnSubmode) {
Text("Application").tag(SpawnSubmode.application)
Text("Program").tag(SpawnSubmode.program)
}
.pickerStyle(.segmented)
.frame(maxWidth: 260)
}
.padding(.horizontal)
.padding(.top, 6)
.padding(.bottom, 6)
}
@ViewBuilder
private func applicationSpawnPane(for device: Device) -> some View {
if loadingApplications {
enumeratingList(placeholderApplicationNames, withSearchField: false, for: device)
} else if let applicationError {
ZStack {
Color.clear
VStack(spacing: 8) {
Image(systemName: "exclamationmark.triangle.fill")
.font(.largeTitle)
.foregroundStyle(.orange)
.opacity(0.8)
Text("Failed to Enumerate Applications")
.font(.headline)
Text(applicationError)
.font(.footnote)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal, 24)
}
.padding()
}
} else if applications.isEmpty {
ZStack {
Color.clear
VStack(spacing: 8) {
Image(systemName: "apps.iphone")
.font(.largeTitle)
.foregroundStyle(.secondary)
.opacity(0.6)
Text("No Applications")
.font(.headline)
Text(
"This device did not report any launchable applications. On some platforms application spawning is not supported; use Program instead."
)
.font(.footnote)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal, 24)
}
.padding()
}
} else {
VStack(spacing: 0) {
VStack(alignment: .leading, spacing: 8) {
List(filteredApplications, id: \.identifier) { app in
Button {
selectedApplicationIdentifier = app.identifier
} label: {
HStack(spacing: 8) {
if let firstIcon = app.icons.last {
firstIcon.swiftUIImage
.resizable()
.interpolation(.high)
.aspectRatio(contentMode: .fit)
.frame(width: 24, height: 24)
.cornerRadius(4)
} else {
IconPlaceholderView(
seed: app.identifier,
displayName: app.name,
cornerRadius: 4
)
.frame(width: 24, height: 24)
}
VStack(alignment: .leading, spacing: 2) {
Text(app.name)
Text(app.identifier)
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
if app.pid != 0 {
Text("Running (PID \(app.pid))")
.font(.caption2)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(Color.secondary.opacity(0.15))
.clipShape(RoundedRectangle(cornerRadius: 4, style: .continuous))
}
}
.padding(.vertical, 2)
}
.buttonStyle(.plain)
.listRowBackground(
selectedApplicationIdentifier == app.identifier
? Color.accentColor.opacity(0.12)
: Color.clear
)
}
}
Divider()
Button {
withAnimation(.easeInOut(duration: 0.15)) {
appOptionsExpanded.toggle()
}
} label: {
HStack(spacing: 6) {
Image(systemName: "chevron.right")
.rotationEffect(.degrees(appOptionsExpanded ? 90 : 0))
.foregroundStyle(.secondary)
Text("Options")
.font(.subheadline.weight(.medium))
Spacer()
}
.contentShape(Rectangle())
.padding(.horizontal, 16)
.padding(.vertical, 8)
}
.buttonStyle(.plain)
if appOptionsExpanded {
Divider()
Form {
Section(isExpanded: $appArgumentsExpanded) {
TextField("Arguments (optional)", text: $appArgumentsText, axis: .vertical)
.lineLimit(1...3)
Text("Arguments can be passed to apps too, but are not supported on all targets.")
.font(.footnote)
.foregroundStyle(.secondary)
} header: {
Text("Launch Arguments")
}
Section(isExpanded: $appEnvExpanded) {
EnvEditor(entries: $appEnvEntries)
Text("Environment variables are added on top of the default environment.")
.font(.footnote)
.foregroundStyle(.secondary)
} header: {
Text("Environment")
}
Section(isExpanded: $appWorkingDirExpanded) {
TextField("Working directory (optional)", text: $appWorkingDirectory)
#if canImport(UIKit)
.textInputAutocapitalization(.never)
#endif
.disableAutocorrection(true)
Text("Use an absolute path on the target device, e.g. /var/mobile.")
.font(.footnote)
.foregroundStyle(.secondary)
} header: {
Text("Working Directory")
}
Section(isExpanded: $appExecutionExpanded) {
StdioPicker(selection: $appStdio)
VStack(alignment: .leading, spacing: 4) {
Toggle(isOn: $appAutoResume) {
Text("Automatically resume after instruments load")
}
Text("When turned off, the process will remain paused after spawn until you resume it from Luma.")
.font(.footnote)
.foregroundStyle(.secondary)
}
} header: {
Text("Execution")
}
}
.formStyle(.grouped)
}
}
}
}
@ViewBuilder
private func programSpawnPane(for device: Device) -> some View {
VStack(spacing: 0) {
Form {
Section("Program") {
HStack {
TextField("Absolute program path", text: $programPath)
#if canImport(UIKit)
.textInputAutocapitalization(.never)
#endif
.disableAutocorrection(true)
#if os(macOS)
if device.type == .local {
Button("Browse…") { isShowingProgramBrowser = true }
}
#endif
}
Text("Provide an absolute path on the target device, e.g. /usr/bin/foo.")
.font(.footnote)
.foregroundStyle(.secondary)
}
#if os(macOS)
.fileImporter(
isPresented: $isShowingProgramBrowser,
allowedContentTypes: [.executable, .unixExecutable, .item]
) { result in
if case .success(let url) = result {
programPath = url.path
}
}
#endif
Section("Arguments") {
TextField("Arguments (optional)", text: $programArgumentsText, axis: .vertical)
.lineLimit(1...3)
Text("Space-separated arguments. Shell-style quoting may be supported in a future version.")
.font(.footnote)
.foregroundStyle(.secondary)
}
Section("Environment") {
EnvEditor(entries: $programEnvEntries)
Text("Environment variables are added on top of the default environment.")
.font(.footnote)
.foregroundStyle(.secondary)
}
Section("Working Directory") {
TextField("Working directory (optional)", text: $programWorkingDirectory)
#if canImport(UIKit)
.textInputAutocapitalization(.never)
#endif
.disableAutocorrection(true)
}
Section("Execution") {
StdioPicker(selection: $programStdio)
VStack(alignment: .leading, spacing: 4) {
Toggle(isOn: $programAutoResume) {
Text("Automatically resume after instruments load")
}
Text("When turned off, the process will remain paused after spawn until you resume it from Luma.")
.font(.footnote)
.foregroundStyle(.secondary)
}
}
}
.formStyle(.grouped)
}
.task(id: device.id) {
await loadProcesses(for: device)
}
}
private var modeHint: String {
switch mode {
case .spawn: return "Spawn a new app or program under Luma."
case .armForLaunch: return "Capture the next launch matching your rule."
case .attach: return "Attach to an already-running process on this device."
}
}
@ViewBuilder
private func armDetailPane(for device: Device) -> some View {
Form {
Section {
HStack(spacing: 8) {
TextField("Identifier regex", text: $armRegex)
.disableAutocorrection(true)
#if canImport(UIKit)
.textInputAutocapitalization(.never)
#endif
Button {
isShowingArmSuggestions = true
} label: {
Image(systemName: "rectangle.and.text.magnifyingglass")
}
.buttonStyle(.borderless)
.help("Browse running applications on \(device.name)")
.popover(isPresented: $isShowingArmSuggestions, arrowEdge: .top) {
armSuggestionsPopover(for: device)
}
}
TextField("Display name", text: $armDisplayName)
Text("Match the regex against each new spawn's identifier on \(device.name). Display name is shown in the sidebar.")
.font(.footnote)
.foregroundStyle(.secondary)
} header: {
Text("Capture Rule")
}
Section {
VStack(alignment: .leading, spacing: 4) {
Toggle("Automatically resume on capture", isOn: $armAutoResume)
Text("When off, the captured process is held paused so you can attach instruments before it runs.")
.font(.footnote)
.foregroundStyle(.secondary)
}
} header: {
Text("On Capture")
}
}
.formStyle(.grouped)
}
@ViewBuilder
private func armSuggestionsPopover(for device: Device) -> some View {
VStack(spacing: 0) {
HStack(spacing: 8) {
Image(systemName: "magnifyingglass").foregroundStyle(.secondary)
TextField("Filter by name or identifier", text: $armSuggestionsSearchText)
.textFieldStyle(.roundedBorder)
.disableAutocorrection(true)
#if canImport(UIKit)
.textInputAutocapitalization(.never)
#endif
}
.padding(12)
Divider()
armSuggestionsBody
}
.frame(width: 360, height: 360)
.task(id: device.id) {
await loadApplications(for: device)
}
}
@ViewBuilder
private var armSuggestionsBody: some View {
if loadingApplications {
ProgressView("Enumerating applications…")
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else if armPopoverApplications.isEmpty {
VStack(spacing: 6) {
Image(systemName: "apps.iphone")
.font(.title3)
.foregroundStyle(.secondary)
Text(applications.isEmpty
? "No applications enumerated."
: "No applications match the filter."
)
.font(.footnote)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else {
ScrollView {
LazyVStack(alignment: .leading, spacing: 0) {
ForEach(armPopoverApplications, id: \.identifier) { app in
Button {
applyArmSuggestion(app)
isShowingArmSuggestions = false
} label: {
armSuggestionRow(app)
.padding(.horizontal, 12)
.padding(.vertical, 6)
}
.buttonStyle(.plain)
Divider()
}
}
}
}
}
private var armPopoverApplications: [ApplicationDetails] {
let trimmed = armSuggestionsSearchText.trimmingCharacters(in: .whitespaces)
guard !trimmed.isEmpty else { return applications }
return applications.filter {
$0.name.localizedCaseInsensitiveContains(trimmed)
|| $0.identifier.localizedCaseInsensitiveContains(trimmed)
}
}
@ViewBuilder
private func armSuggestionRow(_ app: ApplicationDetails) -> some View {
HStack(spacing: 8) {
if let firstIcon = app.icons.last {
firstIcon.swiftUIImage
.resizable()
.interpolation(.high)
.aspectRatio(contentMode: .fit)
.frame(width: 20, height: 20)
.cornerRadius(4)
} else {
IconPlaceholderView(
seed: app.identifier,
displayName: app.name,
cornerRadius: 4
)
.frame(width: 20, height: 20)
}
VStack(alignment: .leading, spacing: 2) {
Text(app.name)
Text(app.identifier)
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
}
.contentShape(Rectangle())
}
private func applyArmSuggestion(_ app: ApplicationDetails) {
armRegex = "^" + NSRegularExpression.escapedPattern(for: app.identifier) + "$"
armDisplayName = app.name
}
@ViewBuilder
private func processDetailPane(for device: Device) -> some View {
VStack(alignment: .leading, spacing: 0) {
if loadingProcesses {
enumeratingList(placeholderProcessNames, withSearchField: true, for: device)
} else if let processError {
ZStack {
Color.clear
VStack(spacing: 8) {
Image(systemName: "exclamationmark.triangle")
.font(.system(size: 28))
.foregroundStyle(.yellow)
.opacity(0.8)
Text("Failed to Enumerate Processes")
.font(.headline)
Text(processError)
.font(.footnote)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal, 24)
}
.padding()
}
} else if processes.isEmpty {
ZStack {
Color.clear
VStack(spacing: 8) {
Image(systemName: "square.stack.3d.up")
.font(.largeTitle)
.foregroundStyle(.secondary)
.opacity(0.6)
Text("No Processes")
.font(.headline)
Text("No processes were returned by this device.")
.font(.footnote)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal, 24)
}
.padding()
}
} else {
VStack(spacing: 0) {
HStack(spacing: 8) {
Image(systemName: "magnifyingglass")
.foregroundStyle(.secondary)
TextField("Filter by process name", text: $processSearchText)
.textFieldStyle(.roundedBorder)
.disableAutocorrection(true)
.accessibilityIdentifier("targetPicker.processSearch")
#if canImport(UIKit)
.textInputAutocapitalization(.never)
#endif
.focused($focusedField, equals: .processSearch)
.onKeyPress(.downArrow) {
if selectedProcessPID == nil,
let first = filteredProcesses.first
{
selectedProcessPID = first.pid
}
focusedField = .processList
return .handled
}
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
Divider()
ScrollViewReader { proxy in
List(filteredProcesses, id: \.pid, selection: $selectedProcessPID) { proc in
HStack(spacing: 8) {
if let firstIcon = proc.icons.last {
firstIcon.swiftUIImage
.resizable()
.interpolation(.high)
.aspectRatio(contentMode: .fit)
.frame(width: 24, height: 24)
.cornerRadius(4)
} else if proc.pid == 0 {
Image(systemName: "cpu")
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 20, height: 20)
.foregroundStyle(.secondary)
.frame(width: 24, height: 24)
} else {
IconPlaceholderView(
seed: proc.name,
displayName: proc.name,
cornerRadius: 4
)
.frame(width: 24, height: 24)
}
VStack(alignment: .leading, spacing: 2) {
Text(proc.name)
.fontWeight(proc.pid == 0 ? .semibold : .regular)
Text(processSubtitle(for: proc))
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
.truncationMode(.middle)
.help(processSubtitle(for: proc))
}
Spacer()
if let id = pickerState?.lastSelectedProcessName,
id == proc.name
{
Image(systemName: "checkmark")
.foregroundStyle(.secondary)
}
}
.padding(.vertical, 2)
.contentShape(Rectangle())
.tag(proc.pid)
.accessibilityIdentifier("targetPicker.process.\(proc.name)")
.onTapGesture {
attach(to: device, process: proc)
}
}
.focused($focusedField, equals: .processList)
.onKeyPress(.return) {
guard
focusedField == .processList,
let pid = selectedProcessPID,
let proc = filteredProcesses.first(where: { $0.pid == pid })
else {
return .ignored
}
attach(to: device, process: proc)
return .handled
}
.onAppear {
if let targetName = pickerState?.lastSelectedProcessName,
let target = processes.first(where: { $0.name == targetName })
{
selectedProcessPID = target.pid
proxy.scrollTo(target.pid, anchor: .center)
}
}
}
}
}
}
.task(id: device.id) {
await loadProcesses(for: device)
}
}
private func processSubtitle(for proc: ProcessDetails) -> String {
guard proc.pid != 0 else { return "The kernel" }
guard let argv = proc.parameters["argv"] as? [String], !argv.isEmpty else {
return "PID \(proc.pid)"
}
return "PID \(proc.pid) · \(argv.joined(separator: " "))"
}
private var filteredProcesses: [ProcessDetails] {
if processSearchText.isEmpty {
return processes
} else {
return processes.filter {
$0.name.localizedCaseInsensitiveContains(processSearchText)
|| (($0.parameters["argv"] as? [String])?
.contains { $0.localizedCaseInsensitiveContains(processSearchText) } ?? false)
}
}
}
private var filteredApplications: [ApplicationDetails] {
if applicationSearchText.isEmpty {
return applications
} else {
return applications.filter {
$0.name.localizedCaseInsensitiveContains(applicationSearchText)
|| $0.identifier.localizedCaseInsensitiveContains(applicationSearchText)
}
}
}
private func triggerSpawn() {
guard let device = selectedDevice, let config = currentSpawnConfig() else { return }
onSpawn(device, config)
dismiss()
}
private func triggerArm() {
guard let device = selectedDevice else { return }
let pattern = armRegex.trimmingCharacters(in: .whitespacesAndNewlines)
guard !pattern.isEmpty else { return }
let displayName = resolvedArmDisplayName(forPattern: pattern)
let config = SpawnConfig(
target: .application(
identifier: armTargetIdentifier(forPattern: pattern), name: displayName, iconPNGData: nil),
arguments: [],
environment: [:],
workingDirectory: nil,
stdio: .pipe,
autoResume: armAutoResume
)
onArm(device, config, pattern)
dismiss()
}
private func resolvedArmDisplayName(forPattern pattern: String) -> String {
let trimmed = armDisplayName.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmed.isEmpty { return trimmed }
return literalFromAnchoredPattern(pattern) ?? pattern
}
private func armTargetIdentifier(forPattern pattern: String) -> String {
literalFromAnchoredPattern(pattern) ?? pattern
}
private func literalFromAnchoredPattern(_ pattern: String) -> String? {
var trimmed = pattern
if trimmed.hasPrefix("^") { trimmed.removeFirst() }
if trimmed.hasSuffix("$") { trimmed.removeLast() }
let metacharacters: Set<Character> = ["\\", ".", "*", "+", "?", "(", ")", "[", "]", "{", "}", "|"]
return trimmed.contains(where: { metacharacters.contains($0) }) ? nil : trimmed
}
private func currentSpawnConfig() -> SpawnConfig? {
switch spawnSubmode {
case .application:
guard let identifier = selectedApplicationIdentifier,
let app = applications.first(where: { $0.identifier == identifier })
else { return nil }
return SpawnConfig(
target: .application(
identifier: app.identifier, name: app.name, iconPNGData: app.icons.last?.pngData),
arguments: parseArguments(from: appArgumentsText),
environment: buildEnvironment(from: appEnvEntries),
workingDirectory: appWorkingDirectory.nilIfBlank,
stdio: appStdio,
autoResume: appAutoResume
)
case .program:
return SpawnConfig(
target: .program(path: programPath),
arguments: parseArguments(from: programArgumentsText),
environment: buildEnvironment(from: programEnvEntries),
workingDirectory: programWorkingDirectory.nilIfBlank,
stdio: programStdio,
autoResume: programAutoResume
)
}
}
private func attach(to device: Device, process: ProcessDetails) {
pickerState?.lastSelectedProcessName = process.name
onAttach(device, process)
dismiss()
}
private func parseArguments(from text: String) -> [String] {
text.split(whereSeparator: \.isWhitespace).map(String.init)
}
private func buildEnvironment(from entries: [EnvEntry]) -> [String: String] {
var result: [String: String] = [:]
for entry in entries {
let key = entry.key.trimmingCharacters(in: .whitespaces)
guard !key.isEmpty else { continue }
result[key] = entry.value
}
return result
}
private func loadProcesses(for device: Device) async {
loadingProcesses = true
processes = []
processError = nil
defer { loadingProcesses = false }
do {
let procs = try await device.enumerateProcesses(scope: .full)
processes = procs.sortedWithKernelFirst()
processError = nil
} catch {
processes = []
processError = error.localizedDescription
}
}
private func loadApplications(for device: Device) async {
loadingApplications = true
applications = []
applicationError = nil
defer { loadingApplications = false }
do {
let apps = try await device.enumerateApplications(scope: .full)
applications = apps.sorted {
$0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending
}
applicationError = nil
} catch {
applicationError = error.localizedDescription
applications = []
}
}
@ViewBuilder
private func deviceListPane() -> some View {
VStack(spacing: 0) {
deviceListHeaderView
switch store.discoveryState {
case .discovering:
discoveringBody
case .ready:
if store.devices.isEmpty {
emptyDevicesBody
} else {
deviceList
}
}
}
.modifier(CompactGroupedBackground(isCompactWidth: isCompactWidth))
}
private var discoveringBody: some View {
skeletonList {
ForEach(placeholderDeviceNames, id: \.self) { name in
skeletonRow(name: name)
}
}
}
private var emptyDevicesBody: some View {
ZStack {
Color.clear
ContentUnavailableView(
"No Devices",
systemImage: "ipad.and.iphone",
description: Text("Connect a device or add a remote target to get started.")
)
}
}
private var deviceList: some View {
VStack(spacing: 0) {
List(selection: $selectedDeviceID) {
ForEach(store.devices, id: \.id) { device in
HStack(spacing: 8) {
if let icon = device.icon {
icon.swiftUIImage
.resizable()
.interpolation(.high)
.aspectRatio(contentMode: .fit)
.frame(width: 24, height: 24)
.cornerRadius(4)
} else {
defaultDeviceIcon()
}
VStack(alignment: .leading, spacing: 2) {
Text(device.name)
Text(device.id)
.font(.caption2)
.foregroundStyle(.secondary)
.lineLimit(1)
}
}
.padding(.vertical, 2)
.contextMenu {
if let config = remoteConfigs.first(where: { $0.runtimeDeviceID == device.id }) {
Button(role: .destructive) {
removeRemote(config: config)
} label: {
Label("Remove Remote Device", systemImage: "trash")
}
}
}
}
}
.modifier(CompactGroupedList(isCompactWidth: isCompactWidth))
}
}
private var placeholderDeviceNames: [String] {
["Local System", "Remote Device", "Attached Phone"]
}
private var placeholderProcessNames: [String] {
["SpringBoard", "com.apple.WebKit.Networking", "Notification Center", "backboardd", "launchd"]
}
private var placeholderApplicationNames: [String] {
["Messages", "Maps", "Photos", "Settings"]
}
private func enumeratingList(_ names: [String], withSearchField: Bool, for device: Device) -> some View {
VStack(spacing: 0) {
if withSearchField {
skeletonSearchField
Divider()
}
skeletonRows(names)
}
.overlay(alignment: .bottom) {
enumeratingStatus(for: device)
}
}
@ViewBuilder
private func enumeratingStatus(for device: Device) -> some View {
Group {
if let stage = engine.connectionActivity.stage(for: device.id) {
ConnectionStageView(stage: stage)
} else {
HStack(spacing: 8) {
ProgressView()
.controlSize(.small)
Text("Querying \(device.name)…")
.font(.footnote)
.foregroundStyle(.secondary)
}
}
}
.padding(12)
.frame(maxWidth: .infinity)
.background(.bar)
}
private func skeletonList<Rows: View>(@ViewBuilder rows: () -> Rows) -> some View {
List {
rows()
}
.redacted(reason: .placeholder)
.disabled(true)
.modifier(CompactGroupedList(isCompactWidth: isCompactWidth))
}
private func skeletonRows(_ names: [String]) -> some View {
skeletonList {
ForEach(names, id: \.self) { name in
skeletonRow(name: name)
}
}
}
private func skeletonRow(name: String) -> some View {
HStack(spacing: 8) {
defaultDeviceIcon()
VStack(alignment: .leading, spacing: 2) {
Text(name)
Text(String(repeating: "0", count: name.count + 6))
.font(.caption)
.foregroundStyle(.secondary)
}
}
.padding(.vertical, 2)
}
private var skeletonSearchField: some View {
HStack(spacing: 8) {
Image(systemName: "magnifyingglass")
.foregroundStyle(.secondary)
RoundedRectangle(cornerRadius: 6)
.fill(.quaternary)
.frame(height: 22)
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
}
private var deviceListHeaderView: some View {
HStack(alignment: .firstTextBaseline, spacing: 6) {
deviceListHeaderLeading
if store.discoveryState == .discovering {
ProgressView()
.controlSize(.mini)
}
Spacer()
Menu {
Button("Add Remote Device…") {
showingAddRemoteSheet = true
}
#if canImport(AppKit)
Button("Boot Virtual Machine…") {
showingBootMachineSheet = true
}
.disabled(bootableTemplate == nil)
.help(virtualMachineHelp)
#endif
} label: {
Image(systemName: "plus.circle.fill")
.foregroundStyle(.tint)
.imageScale(.large)
}
.menuStyle(.borderlessButton)
.menuIndicator(.hidden)
.frame(width: 24)
.accessibilityLabel("Add Device…")
}
.padding(.horizontal, deviceListHeaderHorizontalPadding)
.padding(.top, isCompactWidth ? 0 : 12)
.padding(.bottom, isCompactWidth ? 16 : 8)
}
@ViewBuilder
private var deviceListHeaderLeading: some View {
if isCompactWidth {
Text(discoveryHelpText)
.font(.caption)
.foregroundStyle(.secondary)
} else {
Text("Devices")
.font(.footnote.weight(.semibold))
.foregroundStyle(.secondary)
.textCase(.uppercase)
}
}
private var bootableTemplate: VirtualMachineTemplate? {
engine.virtualMachines.templates.first { engine.virtualMachines.availability(for: $0).isAvailable }
}
private var virtualMachineHelp: String {
bootableTemplate != nil
? "Boot a guest and instrument its kernel"
: engine.virtualMachines.templates.compactMap { engine.virtualMachines.availability(for: $0).reason }.first
?? "No virtual machine backend is available"
}
private var discoveryHelpText: String {
switch store.discoveryState {
case .discovering:
return "Searching for locally connected and remote targets…"
case .ready:
return "Pick a device to attach to a running process or spawn a new one."
}
}
private var deviceListHeaderHorizontalPadding: CGFloat {
#if canImport(UIKit)
return isCompactWidth ? 20 : 16
#else
return 12
#endif
}
@ViewBuilder
private func addRemoteSheet() -> some View {
#if canImport(UIKit)
addRemoteSheetIOS
#else
addRemoteSheetMac
#endif
}
#if canImport(UIKit)
private var addRemoteSheetIOS: some View {
NavigationStack {
Form {
Section {
TextField("hostname:port", text: $remoteAddress)
.textInputAutocapitalization(.never)
.disableAutocorrection(true)
} header: {
Text("Address")
} footer: {
Text("Enter the address of a frida-server or portal.")
}
Section("TLS Certificate") {
TextField("PEM-encoded (optional)", text: $remoteCertificate, axis: .vertical)
.lineLimit(3, reservesSpace: true)
.textInputAutocapitalization(.never)
.disableAutocorrection(true)
}
Section {
DisclosureGroup("Advanced Options", isExpanded: $showingAdvancedRemoteOptions) {
TextField("Origin", text: $remoteOrigin)
.textInputAutocapitalization(.never)
.disableAutocorrection(true)
TextField("Bearer / auth token", text: $remoteToken)
.textInputAutocapitalization(.never)
.disableAutocorrection(true)
TextField("Keepalive seconds", text: $remoteKeepalive)
.keyboardType(.numberPad)
}
}
if let addRemoteError {
Section {
Text(addRemoteError)
.foregroundStyle(.red)
}
}
}
.navigationTitle("Add Remote Device")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") { cancelAddRemote() }
}
ToolbarItem(placement: .confirmationAction) {
Button("Add") {
Task { await addRemote() }
}
.disabled(remoteAddress.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
}
}
}
.presentationDetents([.medium, .large])
.presentationDragIndicator(.visible)
}
#endif
#if !canImport(UIKit)
private var addRemoteSheetMac: some View {
ScrollView {
VStack(alignment: .leading, spacing: 16) {
Text("Add Remote Device")
.font(.headline)
Text("Enter the address of a frida-server or portal.")
.font(.footnote)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
labeledTextField(
"Address",
placeholder: "hostname:port",
text: $remoteAddress
)
labeledTextField(
"TLS certificate (optional)",
placeholder: "PEM-encoded certificate",
text: $remoteCertificate,
multiline: true
)
DisclosureGroup(
isExpanded: $showingAdvancedRemoteOptions,
content: {
VStack(spacing: 8) {
labeledTextField(
"Origin (optional)",
placeholder: "Origin",
text: $remoteOrigin
)
labeledTextField(
"Token (optional)",
placeholder: "Bearer / auth token",
text: $remoteToken
)
labeledTextField(
"Keepalive Interval (optional)",
placeholder: "Seconds",
text: $remoteKeepalive
)
}
.padding(.top, 8)
},
label: {
Text("Advanced Options")
.font(.subheadline)
.foregroundStyle(.secondary)
}
)
if let addRemoteError {
Text(addRemoteError)
.font(.footnote)
.foregroundStyle(.red)
}
HStack {
Spacer()
Button("Cancel") {
cancelAddRemote()
}
Button("Add") {
Task {
await addRemote()
}
}
.keyboardShortcut(.defaultAction)
}
}
.padding()
.frame(maxWidth: .infinity, alignment: .topLeading)
}
.frame(minWidth: 420)
}
#endif
@ViewBuilder
private func labeledTextField(
_ title: String,
placeholder: String,
text: Binding<String>,
multiline: Bool = false
) -> some View {
VStack(alignment: .leading, spacing: 4) {
Text(title)
.font(.subheadline)
Group {
if multiline {
TextField(placeholder, text: text, axis: .vertical)
.textFieldStyle(.roundedBorder)
.lineLimit(3, reservesSpace: true)
} else {
TextField(placeholder, text: text)
.textFieldStyle(.roundedBorder)
}
}
#if canImport(UIKit)
.disableAutocorrection(true)
.textInputAutocapitalization(.never)
#endif
}
}
private func cancelAddRemote() {
showingAddRemoteSheet = false
clearRemoteForm()
}
private func clearRemoteForm() {
remoteAddress = ""
remoteCertificate = ""
remoteOrigin = ""
remoteToken = ""
remoteKeepalive = ""
showingAdvancedRemoteOptions = false
addRemoteError = nil
}
private func addRemote() async {
let address = remoteAddress.trimmingCharacters(in: .whitespacesAndNewlines)
let certificate = remoteCertificate.isEmpty ? nil : remoteCertificate
let origin = remoteOrigin.isEmpty ? nil : remoteOrigin
let token = remoteToken.isEmpty ? nil : remoteToken
let keepalive: Int? = {
let trimmed = remoteKeepalive.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.isEmpty { return nil }
return Int(trimmed)
}()
guard !address.isEmpty else {
addRemoteError = "Address is required."
return
}
addRemoteError = nil
do {
_ = try await deviceManager.addRemoteDevice(
address: address,
certificate: certificate,
origin: origin,
token: token,
keepaliveInterval: keepalive
)
let config = LumaCore.RemoteDeviceConfig(
address: address,
certificate: certificate,
origin: origin,
token: token,
keepaliveInterval: keepalive
)
remoteConfigs.append(config)
showingAddRemoteSheet = false
clearRemoteForm()
} catch {
addRemoteError = error.localizedDescription
}
}
private func removeRemote(config: LumaCore.RemoteDeviceConfig) {
remoteConfigs.removeAll { $0.id == config.id }
Task {
try? await deviceManager.removeRemoteDevice(address: config.address)
}
}
@ViewBuilder
private func defaultDeviceIcon(size: CGFloat = 24) -> some View {
Image(systemName: "ipad.and.iphone")
.resizable()
.scaledToFit()
.frame(width: size, height: size)
.foregroundStyle(.secondary)
.opacity(0.6)
.cornerRadius(4)
}
struct EnvEntry: Identifiable, Hashable {
let id: UUID
var key: String
var value: String
init(id: UUID = UUID(), key: String, value: String) {
self.id = id
self.key = key
self.value = value
}
}
struct EnvEditor: View {
@Binding var entries: [EnvEntry]
var body: some View {
VStack(spacing: 4) {
ForEach($entries) { $entry in
HStack(spacing: 8) {
TextField("KEY", text: $entry.key)
#if canImport(UIKit)
.textInputAutocapitalization(.never)
#endif
.disableAutocorrection(true)
.frame(minWidth: 80)
Text("=")
.foregroundStyle(.secondary)
TextField("value", text: $entry.value)
#if canImport(UIKit)
.textInputAutocapitalization(.never)
#endif
.disableAutocorrection(true)
Button {
entries.removeAll { $0.id == entry.id }
} label: {
Image(systemName: "minus.circle")
.symbolRenderingMode(.hierarchical)
}
.buttonStyle(.borderless)
.help("Remove this variable")
}
}
Button {
entries.append(EnvEntry(key: "", value: ""))
} label: {
Label("Add Variable", systemImage: "plus.circle")
}
.buttonStyle(.borderless)
.padding(.top, 4)
}
}
}
struct StdioPicker: View {
@Binding var selection: Stdio
var body: some View {
VStack(alignment: .leading, spacing: 6) {
Picker("Standard I/O", selection: $selection) {
Text("Inherit").tag(Stdio.inherit)
Text("Pipe to Luma").tag(Stdio.pipe)
}
.pickerStyle(.segmented)
Text(footerText)
.font(.footnote)
.foregroundStyle(.secondary)
}
}
private var footerText: String {
switch selection {
case .inherit:
return "Use the target’s default stdin/stdout/stderr behavior."
case .pipe:
return "Capture output via device events and allow sending input from Luma."
@unknown default:
return ""
}
}
}
}
extension String {
fileprivate var nilIfBlank: String? {
let trimmed = trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? nil : trimmed
}
}
private struct CompactGroupedBackground: ViewModifier {
let isCompactWidth: Bool
func body(content: Content) -> some View {
#if canImport(UIKit)
if isCompactWidth {
content.background(
Color(UIColor.systemGroupedBackground)
.ignoresSafeArea()
)
} else {
content
}
#else
content
#endif
}
}
private struct CompactGroupedList: ViewModifier {
let isCompactWidth: Bool
func body(content: Content) -> some View {
#if canImport(UIKit)
if isCompactWidth {
content.scrollContentBackground(.hidden)
} else {
content
}
#else
content
#endif
}
}
private struct ConnectionStageView: View {
let stage: ConnectionStage
@State private var displayedFraction: Double = 0
var body: some View {
VStack(spacing: 6) {
Text("\(stage.status)…")
.font(.footnote)
.foregroundStyle(.secondary)
ProgressView(value: min(displayedFraction, 1.0))
.frame(maxWidth: 240)
}
.onAppear {
displayedFraction = stage.fraction
creep(toward: stage)
}
.onChange(of: stage) { _, newStage in
creep(toward: newStage)
}
}
private func creep(toward stage: ConnectionStage) {
withAnimation(.easeOut(duration: 0.3)) {
displayedFraction = stage.fraction
}
let restingFraction = stage.fraction + (1.0 - stage.fraction) * 0.5
withAnimation(.easeInOut(duration: max(stage.expectedDuration, 0.3)).delay(0.3)) {
displayedFraction = restingFraction
}
}
}