Files
luma/Luma/SessionDetachedBanner.swift

375 lines
12 KiB
Swift

import Frida
import LumaCore
import SwiftUI
#if canImport(AppKit)
import AppKit
#endif
struct SessionContent<Content: View>: View {
let sessionID: UUID?
let engine: Engine
@ViewBuilder let content: () -> Content
var body: some View {
VStack(spacing: 0) {
switch bannerState {
case .armed(let session):
SessionArmedBanner(session: session, engine: engine)
case .detached(let session):
SessionDetachedBanner(session: session, engine: engine)
case .idle(let session):
SessionIdleBanner(session: session, engine: engine)
case .none:
EmptyView()
}
content()
}
}
private enum BannerState {
case armed(LumaCore.ProcessSession)
case detached(LumaCore.ProcessSession)
case idle(LumaCore.ProcessSession)
case none
}
private var bannerState: BannerState {
guard let sessionID,
let session = engine.sessions.first(where: { $0.id == sessionID })
else { return .none }
let isAttached = engine.node(forSessionID: session.id) != nil
if !isAttached, case .armed = session.armingState {
return .armed(session)
}
let hasError = session.lastError != nil
if isAttached && !hasError {
return .none
}
if !hasError, engine.isHostedRemotelyLive(session.id) {
return .none
}
if hasError {
return .detached(session)
}
if session.phase == .attaching {
return .detached(session)
}
if session.lastAttachedAt != nil {
return .detached(session)
}
if case .attach = session.kind {
return .detached(session)
}
return .idle(session)
}
}
struct SessionIdleBanner: View {
let session: LumaCore.ProcessSession
let engine: Engine
@State private var isShowingArmPrompt = false
@State private var armPatternDraft = ""
var body: some View {
LumaBanner(style: .warning) {
HStack {
HStack(alignment: .center, spacing: 8) {
Image(systemName: "bolt.slash")
.font(.headline)
Text(session.processName)
.font(.headline)
Divider()
.frame(height: 16)
Text("Idle — not waiting for a launch.")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
Button {
presentArmPrompt()
} label: {
Label("Arm…", systemImage: "scope")
}
.buttonStyle(.bordered)
.font(.caption)
}
}
.alert("Arm for Next Launch", isPresented: $isShowingArmPrompt) {
TextField("Identifier regex", text: $armPatternDraft)
.disableAutocorrection(true)
Button("Arm") { commitArm() }
.disabled(armPatternDraft.trimmingCharacters(in: .whitespaces).isEmpty)
Button("Cancel", role: .cancel) {}
} message: {
Text("Match the next spawn whose identifier matches this regex on \(session.deviceName).")
}
}
private func presentArmPrompt() {
armPatternDraft = engine.defaultArmPattern(for: session)
isShowingArmPrompt = true
}
private func commitArm() {
let pattern = armPatternDraft.trimmingCharacters(in: .whitespacesAndNewlines)
guard !pattern.isEmpty else { return }
let sessionID = session.id
Task { @MainActor in
await engine.armSession(id: sessionID, matchPattern: pattern)
}
}
}
struct SessionArmedBanner: View {
let session: LumaCore.ProcessSession
let engine: Engine
var body: some View {
LumaBanner(style: bannerStyle) {
HStack {
HStack(alignment: .center, spacing: 8) {
Image(systemName: "scope")
.font(.headline)
Text(session.processName)
.font(.headline)
Divider()
.frame(height: 16)
Text(statusText)
.font(.caption)
.foregroundStyle(.secondary)
.accessibilityIdentifier("session.armedStatus")
}
Spacer()
if !isActive {
Button {
resume()
} label: {
Label("Resume", systemImage: "play.circle")
}
.buttonStyle(.borderedProminent)
.font(.caption)
}
Button {
disarm()
} label: {
Label("Disarm", systemImage: "scope")
}
.buttonStyle(.bordered)
.font(.caption)
}
}
}
private var isActive: Bool {
engine.isGatingActive(forDeviceID: session.deviceID)
}
private var hasError: Bool {
session.lastError != nil
}
private var bannerStyle: LumaBannerStyle {
if hasError { return .error }
return isActive ? .info : .warning
}
private var statusText: String {
if let lastError = session.lastError {
return "Armed but inactive — \(lastError)"
}
if !isActive {
return "Armed but inactive — spawn gating is paused. Resume to enable it."
}
let pattern = session.armingState.matchPattern ?? ""
return pattern.isEmpty
? "Waiting for the next matching launch."
: "Waiting for the next launch matching \(pattern)."
}
private func resume() {
let sessionID = session.id
Task { @MainActor in
await engine.resumeGating(forSessionID: sessionID)
}
}
private func disarm() {
let sessionID = session.id
Task { @MainActor in
await engine.disarmSession(id: sessionID)
}
}
}
struct SessionDetachedBanner: View {
let session: LumaCore.ProcessSession
let engine: Engine
@Environment(TargetPicker.self) private var picker
@State private var isShowingErrorDetail = false
var body: some View {
LumaBanner(style: bannerStyle) {
HStack {
HStack(alignment: .center, spacing: 8) {
Image(systemName: "bolt.slash")
.font(.headline)
Text(session.processName)
.font(.headline)
.lineLimit(1)
.truncationMode(.tail)
.fixedSize(horizontal: false, vertical: true)
if session.phase == .attaching || errorText != nil || detachReasonText != nil {
Divider()
.frame(height: 16)
}
if session.phase == .attaching {
HStack(spacing: 8) {
ProgressView()
.controlSize(.small)
Text(session.kind.inProgressLabel)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
.truncationMode(.tail)
}
} else if let errorText = errorText {
let errorMessage = "Last \(session.kind.verbDisplayName) attempt failed: " + errorText
Text(errorMessage)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
.truncationMode(.tail)
.help(errorMessage)
.accessibilityIdentifier("session.errorText")
Button {
isShowingErrorDetail = true
} label: {
Image(systemName: "info.circle")
.font(.caption)
}
.buttonStyle(.plain)
.foregroundStyle(.secondary)
.help("Show the full error")
.popover(isPresented: $isShowingErrorDetail, arrowEdge: .bottom) {
errorDetail(errorMessage)
}
} else if let reasonText = detachReasonText {
Text(reasonText)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
.truncationMode(.tail)
}
}
Spacer()
Button {
reestablish()
} label: {
Label(actionLabel, systemImage: "arrow.clockwise")
}
.disabled(session.phase == .attaching || !canAct)
.buttonStyle(.bordered)
.font(.caption)
}
}
}
private var localUserOwnsHost: Bool {
engine.localUserHosts(session)
}
private var canAct: Bool {
engine.canTakeHosting(session)
}
private var actionLabel: String {
if localUserOwnsHost {
return "\(session.kind.reestablishLabel)\u{2026}"
}
return "Run on My Device\u{2026}"
}
private var bannerStyle: LumaBannerStyle {
switch session.detachReason {
case .applicationRequested:
return .warning
default:
return .error
}
}
private var errorText: String? {
session.lastError
}
private var detachReasonText: String? {
switch session.detachReason {
case .applicationRequested:
return "Not currently attached."
case .processReplaced:
return "Detached because the process was replaced."
case .processTerminated:
return "Detached because the process terminated."
case .connectionTerminated:
return "Detached because the connection was terminated."
case .deviceLost:
return "Detached because the device connection was lost."
}
}
private func reestablish() {
Task { @MainActor in
let result: Engine.ReestablishResult
if localUserOwnsHost {
result = await engine.reestablishSession(id: session.id)
} else {
result = await engine.reHost(sessionID: session.id)
}
if case .needsUserInput(let reason, let session) = result {
picker.context = .reestablish(session: session, reason: reason)
}
}
}
private func errorDetail(_ message: String) -> some View {
VStack(alignment: .leading, spacing: 12) {
Text(message)
.font(.callout)
.textSelection(.enabled)
.fixedSize(horizontal: false, vertical: true)
HStack {
Spacer()
Button("Copy") {
#if canImport(AppKit)
NSPasteboard.general.clearContents()
NSPasteboard.general.setString(message, forType: .string)
#endif
}
}
}
.padding()
.frame(width: 420)
}
}