1808 lines
65 KiB
Swift
1808 lines
65 KiB
Swift
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
|
||
}
|
||
}
|
||
}
|