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 = ["\\", ".", "*", "+", "?", "(", ")", "[", "]", "{", "}", "|"] 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(@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, 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 } } }