mirror of
https://github.com/StikDebug/StikDebug.git
synced 2026-10-16 04:20:30 +00:00
535 lines
21 KiB
Swift
535 lines
21 KiB
Swift
//
|
|
// DeviceLibraryView.swift
|
|
// StikJIT
|
|
//
|
|
// Created by Stephen.
|
|
//
|
|
|
|
import SwiftUI
|
|
import UniformTypeIdentifiers
|
|
|
|
private struct DeviceAlert: Identifiable {
|
|
let id = UUID()
|
|
let title: String
|
|
let message: String
|
|
let isError: Bool
|
|
}
|
|
|
|
private enum DeviceEditorMode: Identifiable {
|
|
case add
|
|
case edit(DeviceProfileEntry)
|
|
|
|
var id: String {
|
|
switch self {
|
|
case .add: return "add"
|
|
case .edit(let device): return device.id.uuidString
|
|
}
|
|
}
|
|
}
|
|
|
|
struct DeviceLibraryView: View {
|
|
@StateObject private var store = DeviceLibraryStore.shared
|
|
@State private var editorMode: DeviceEditorMode?
|
|
@State private var alert: DeviceAlert?
|
|
@State private var isActivatingDevice = false
|
|
|
|
@AppStorage("customAccentColor") private var customAccentColorHex: String = ""
|
|
@AppStorage("appTheme") private var appThemeRaw: String = AppTheme.system.rawValue
|
|
@Environment(\.themeExpansionManager) private var themeExpansion
|
|
|
|
private var backgroundStyle: BackgroundStyle { themeExpansion?.backgroundStyle(for: appThemeRaw) ?? AppTheme.system.backgroundStyle }
|
|
private var preferredScheme: ColorScheme? { themeExpansion?.preferredColorScheme(for: appThemeRaw) }
|
|
private var accentColor: Color { themeExpansion?.resolvedAccentColor(from: customAccentColorHex) ?? .blue }
|
|
|
|
private var savedDevices: [DeviceProfileEntry] {
|
|
store.devices.sorted { lhs, rhs in
|
|
lhs.name.localizedCaseInsensitiveCompare(rhs.name) == .orderedAscending
|
|
}
|
|
}
|
|
|
|
private var displayedDevices: [DeviceProfileEntry] {
|
|
[store.defaultLocalDevice] + savedDevices
|
|
}
|
|
|
|
private var activeSubtitle: String {
|
|
store.activeDevice != nil
|
|
? "Currently using an external device."
|
|
: "No external device selected."
|
|
}
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
ZStack {
|
|
ThemedBackground(style: backgroundStyle)
|
|
.ignoresSafeArea()
|
|
|
|
ScrollView {
|
|
VStack(spacing: 20) {
|
|
fullWidthCard {
|
|
VStack(alignment: .leading, spacing: 16) {
|
|
headerRow
|
|
activeSummaryCard
|
|
|
|
ForEach(displayedDevices) { device in
|
|
let isDefault = store.isDefaultDevice(device)
|
|
DeviceRow(device: device,
|
|
isActive: isDefault ? !store.isUsingExternalDevice : store.activeDeviceID == device.id,
|
|
isDefault: isDefault,
|
|
accentColor: accentColor,
|
|
onActivate: { activate(device: device) },
|
|
onEdit: isDefault ? nil : { editorMode = .edit(device) },
|
|
onDelete: isDefault ? nil : { delete(device: device) })
|
|
if device.id != displayedDevices.last?.id {
|
|
Divider()
|
|
}
|
|
}
|
|
|
|
footerText
|
|
}
|
|
}
|
|
}
|
|
.padding(.horizontal, 20)
|
|
.padding(.vertical, 30)
|
|
}
|
|
}
|
|
.navigationTitle("Devices")
|
|
.toolbar {
|
|
ToolbarItem(placement: .topBarTrailing) {
|
|
Button {
|
|
editorMode = .add
|
|
} label: {
|
|
Label("Add Device", systemImage: "plus")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.tint(accentColor)
|
|
.preferredColorScheme(preferredScheme)
|
|
.disabled(isActivatingDevice)
|
|
.sheet(item: $editorMode) { mode in
|
|
DeviceEditorSheet(mode: mode) { input in
|
|
try handleSave(mode: mode, input: input)
|
|
if case .edit(let originalDevice) = mode,
|
|
store.activeDeviceID == originalDevice.id,
|
|
input.pairingData != nil {
|
|
if let refreshedDevice = store.devices.first(where: { $0.id == originalDevice.id }) {
|
|
do {
|
|
try store.activate(device: refreshedDevice)
|
|
startHeartbeatInBackground()
|
|
alert = DeviceAlert(title: "Pairing Updated",
|
|
message: "\(refreshedDevice.name)'s pairing file was refreshed.",
|
|
isError: false)
|
|
} catch {
|
|
alert = DeviceAlert(title: "Activation Failed",
|
|
message: error.localizedDescription,
|
|
isError: true)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.alert(item: $alert) { alert in
|
|
Alert(
|
|
title: Text(alert.title),
|
|
message: Text(alert.message),
|
|
dismissButton: .default(Text("OK"))
|
|
)
|
|
}
|
|
}
|
|
|
|
private func handleSave(mode: DeviceEditorMode, input: DeviceEditorInput) throws {
|
|
switch mode {
|
|
case .add:
|
|
try store.addDevice(name: input.name,
|
|
ipAddress: input.ipAddress,
|
|
pairingData: input.pairingData,
|
|
originalFilename: input.pairingFilename,
|
|
isTXM: input.isTXM)
|
|
alert = DeviceAlert(title: "Device Saved", message: "\(input.name) was added to your library.", isError: false)
|
|
case .edit(let device):
|
|
try store.update(device: device,
|
|
name: input.name,
|
|
ipAddress: input.ipAddress,
|
|
pairingData: input.pairingData,
|
|
originalFilename: input.pairingFilename ?? device.pairingFilename,
|
|
isTXM: input.isTXM)
|
|
alert = DeviceAlert(title: "Device Updated", message: "\(input.name) has been updated.", isError: false)
|
|
}
|
|
}
|
|
|
|
private func activate(device: DeviceProfileEntry) {
|
|
guard !isActivatingDevice else { return }
|
|
isActivatingDevice = true
|
|
defer { isActivatingDevice = false }
|
|
let activatingDefault = store.isDefaultDevice(device)
|
|
do {
|
|
try store.activate(device: device)
|
|
startHeartbeatInBackground()
|
|
let title = activatingDefault ? "Local Device Active" : "Device Activated"
|
|
let message = activatingDefault
|
|
? "Switched back to debugging on this device."
|
|
: "\(device.name) is now active. The heartbeat will refresh automatically."
|
|
alert = DeviceAlert(title: title, message: message, isError: false)
|
|
} catch {
|
|
alert = DeviceAlert(title: "Activation Failed",
|
|
message: error.localizedDescription,
|
|
isError: true)
|
|
}
|
|
}
|
|
|
|
private func delete(device: DeviceProfileEntry) {
|
|
do {
|
|
try store.remove(device: device)
|
|
} catch {
|
|
alert = DeviceAlert(title: "Delete Failed", message: error.localizedDescription, isError: true)
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func fullWidthCard<Content: View>(@ViewBuilder _ content: () -> Content) -> some View {
|
|
appGlassCard {
|
|
content()
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
}
|
|
|
|
private var headerRow: some View {
|
|
VStack(alignment: .leading, spacing: 10) {
|
|
HStack(alignment: .firstTextBaseline) {
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
|
|
Text("Device Library")
|
|
.font(.system(.title, design: .rounded).weight(.bold))
|
|
}
|
|
Spacer()
|
|
}
|
|
|
|
Text("Manage saved targets and switch between local or remote hardware.")
|
|
.font(.subheadline)
|
|
.foregroundColor(.secondary)
|
|
}
|
|
}
|
|
|
|
private var activeSummaryCard: some View {
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
Label {
|
|
Text(activeSubtitle)
|
|
.font(.subheadline)
|
|
.foregroundColor(.secondary)
|
|
} icon: {
|
|
Image(systemName: store.isUsingExternalDevice ? "antenna.radiowaves.left.and.right" : "iphone")
|
|
.foregroundColor(accentColor)
|
|
}
|
|
|
|
if let active = store.activeDevice {
|
|
let relative = relativeDateFormatter.localizedString(for: active.lastUpdated, relativeTo: Date())
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Text(active.name)
|
|
.font(.headline)
|
|
Text("IP: \(active.ipAddress)")
|
|
.font(.footnote)
|
|
.foregroundColor(.secondary)
|
|
Text("Synced \(relative).")
|
|
.font(.caption)
|
|
.foregroundColor(.secondary)
|
|
}
|
|
Button {
|
|
activate(device: store.defaultLocalDevice)
|
|
} label: {
|
|
Label("Switch to This Device", systemImage: "arrow.uturn.backward")
|
|
.font(.footnote.weight(.semibold))
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.buttonStyle(.borderedProminent)
|
|
.tint(accentColor)
|
|
} else {
|
|
Text("Using the on-device pairing file. Saved devices appear below for quick switching.")
|
|
.font(.footnote)
|
|
.foregroundColor(.secondary)
|
|
}
|
|
}
|
|
.padding(16)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.background(
|
|
RoundedRectangle(cornerRadius: 16, style: .continuous)
|
|
.fill(Color.primary.opacity(0.05))
|
|
)
|
|
}
|
|
|
|
private var footerText: some View {
|
|
Text("Saved devices keep their own pairing files so you can connect without copying them manually.")
|
|
.font(.footnote)
|
|
.foregroundColor(.secondary)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
}
|
|
|
|
private var relativeDateFormatter: RelativeDateTimeFormatter {
|
|
let formatter = RelativeDateTimeFormatter()
|
|
formatter.unitsStyle = .full
|
|
return formatter
|
|
}
|
|
}
|
|
|
|
private struct DeviceRow: View {
|
|
let device: DeviceProfileEntry
|
|
let isActive: Bool
|
|
let isDefault: Bool
|
|
let accentColor: Color
|
|
let onActivate: () -> Void
|
|
let onEdit: (() -> Void)?
|
|
let onDelete: (() -> Void)?
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 10) {
|
|
HStack(alignment: .top) {
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
HStack {
|
|
Text(device.name)
|
|
.font(.headline)
|
|
if isActive {
|
|
Label("Active", systemImage: "checkmark.circle.fill")
|
|
.font(.caption.weight(.semibold))
|
|
.padding(.horizontal, 8)
|
|
.padding(.vertical, 2)
|
|
.background(accentColor.opacity(0.15), in: Capsule())
|
|
.foregroundColor(accentColor)
|
|
}
|
|
}
|
|
if isDefault {
|
|
Text("Loopback IP: \(device.ipAddress)")
|
|
.font(.subheadline)
|
|
.foregroundColor(.secondary)
|
|
Text("Uses the pairing file already stored on this device.")
|
|
.font(.footnote)
|
|
.foregroundColor(.secondary)
|
|
} else {
|
|
Text("IP: \(device.ipAddress)")
|
|
.font(.subheadline)
|
|
.foregroundColor(.secondary)
|
|
Text("Pairing: \(device.pairingFilename)")
|
|
.font(.footnote)
|
|
.foregroundColor(.secondary)
|
|
if device.isTXM {
|
|
Label("TXM Capable", systemImage: "shield.checkerboard")
|
|
.font(.caption2.weight(.semibold))
|
|
.padding(.horizontal, 8)
|
|
.padding(.vertical, 2)
|
|
.background(Color.green.opacity(0.15), in: Capsule())
|
|
.foregroundColor(.green)
|
|
} else {
|
|
Label("Non-TXM", systemImage: "xmark.shield")
|
|
.font(.caption2.weight(.semibold))
|
|
.padding(.horizontal, 8)
|
|
.padding(.vertical, 2)
|
|
.background(Color.secondary.opacity(0.15), in: Capsule())
|
|
.foregroundColor(.secondary)
|
|
}
|
|
}
|
|
}
|
|
Spacer()
|
|
if onEdit != nil || onDelete != nil {
|
|
Menu {
|
|
Button("Use Device", action: onActivate)
|
|
if let onEdit {
|
|
Button("Edit Details", action: onEdit)
|
|
}
|
|
if let onDelete {
|
|
Button(role: .destructive, action: onDelete) {
|
|
Text("Delete Device")
|
|
}
|
|
}
|
|
} label: {
|
|
Image(systemName: "ellipsis.circle")
|
|
.font(.title3)
|
|
.foregroundColor(.secondary)
|
|
}
|
|
}
|
|
}
|
|
|
|
Button(action: onActivate) {
|
|
let title = isActive ? "Active" : (isDefault ? "Use Loopback" : "Use This Device")
|
|
HStack {
|
|
Image(systemName: isDefault ? "iphone" : "bolt.fill")
|
|
Text(title).fontWeight(.semibold)
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
.padding(.vertical, 10)
|
|
.background(isActive ? Color.gray.opacity(0.2) : accentColor.opacity(0.15), in: RoundedRectangle(cornerRadius: 12, style: .continuous))
|
|
.foregroundColor(isActive ? .secondary : accentColor)
|
|
}
|
|
.disabled(isActive)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Editor Sheet
|
|
|
|
private struct DeviceEditorInput {
|
|
var name: String
|
|
var ipAddress: String
|
|
var pairingData: Data?
|
|
var pairingFilename: String?
|
|
var isTXM: Bool
|
|
}
|
|
|
|
private struct DeviceEditorSheet: View {
|
|
let mode: DeviceEditorMode
|
|
let onSave: (DeviceEditorInput) throws -> Void
|
|
|
|
@Environment(\.dismiss) private var dismiss
|
|
@AppStorage("customAccentColor") private var customAccentColorHex: String = ""
|
|
@AppStorage("appTheme") private var appThemeRaw: String = AppTheme.system.rawValue
|
|
@Environment(\.themeExpansionManager) private var themeExpansion
|
|
|
|
@State private var name: String
|
|
@State private var ipAddress: String
|
|
@State private var pairingData: Data?
|
|
@State private var selectedFilename: String?
|
|
@State private var errorMessage: String?
|
|
@State private var showImporter = false
|
|
@State private var isTXMDevice: Bool
|
|
|
|
private var accentColor: Color { themeExpansion?.resolvedAccentColor(from: customAccentColorHex) ?? .blue }
|
|
private var requiresPairing: Bool {
|
|
if case .add = mode { return true }
|
|
return false
|
|
}
|
|
private var existingFilename: String? {
|
|
if case .edit(let device) = mode {
|
|
return device.pairingFilename
|
|
}
|
|
return nil
|
|
}
|
|
private var title: String {
|
|
switch mode {
|
|
case .add: return "New Device"
|
|
case .edit: return "Edit Device"
|
|
}
|
|
}
|
|
private var canSave: Bool {
|
|
!name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty &&
|
|
!ipAddress.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty &&
|
|
(!requiresPairing || pairingData != nil)
|
|
}
|
|
|
|
init(mode: DeviceEditorMode, onSave: @escaping (DeviceEditorInput) throws -> Void) {
|
|
self.mode = mode
|
|
self.onSave = onSave
|
|
switch mode {
|
|
case .add:
|
|
_name = State(initialValue: "")
|
|
_ipAddress = State(initialValue: "10.7.0.1")
|
|
_isTXMDevice = State(initialValue: false)
|
|
case .edit(let device):
|
|
_name = State(initialValue: device.name)
|
|
_ipAddress = State(initialValue: device.ipAddress)
|
|
_isTXMDevice = State(initialValue: device.isTXM)
|
|
}
|
|
_selectedFilename = State(initialValue: nil)
|
|
_pairingData = State(initialValue: nil)
|
|
}
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
Form {
|
|
Section(header: Text("Details")) {
|
|
TextField("Display Name", text: $name)
|
|
.textInputAutocapitalization(.words)
|
|
TextField("Device IP", text: $ipAddress)
|
|
.keyboardType(.numbersAndPunctuation)
|
|
Toggle(isOn: $isTXMDevice) {
|
|
Text("TXM Capable")
|
|
}
|
|
.tint(accentColor)
|
|
Text("Enable if this device includes the Trusted Execution Monitor (TXM).")
|
|
.font(.caption)
|
|
.foregroundColor(.secondary)
|
|
}
|
|
|
|
Section(header: Text("Pairing File")) {
|
|
Button {
|
|
showImporter = true
|
|
} label: {
|
|
Label("Select Pairing File", systemImage: "doc.badge")
|
|
}
|
|
.buttonStyle(.borderedProminent)
|
|
.tint(accentColor)
|
|
|
|
if let filename = selectedFilename {
|
|
Text("Selected: \(filename)")
|
|
.font(.footnote)
|
|
.foregroundColor(.secondary)
|
|
} else if let existing = existingFilename {
|
|
Text("Current: \(existing)")
|
|
.font(.footnote)
|
|
.foregroundColor(.secondary)
|
|
} else {
|
|
Text("No file selected")
|
|
.font(.footnote)
|
|
.foregroundColor(.secondary)
|
|
}
|
|
}
|
|
|
|
if let errorMessage {
|
|
Section {
|
|
Text(errorMessage)
|
|
.foregroundColor(.red)
|
|
}
|
|
}
|
|
}
|
|
.navigationTitle(title)
|
|
.toolbar {
|
|
ToolbarItem(placement: .cancellationAction) {
|
|
Button("Cancel") { dismiss() }
|
|
}
|
|
ToolbarItem(placement: .confirmationAction) {
|
|
Button("Save", action: save)
|
|
.disabled(!canSave)
|
|
}
|
|
}
|
|
.fileImporter(
|
|
isPresented: $showImporter,
|
|
allowedContentTypes: [
|
|
UTType(filenameExtension: "mobiledevicepairing", conformingTo: .data)!,
|
|
.propertyList
|
|
],
|
|
allowsMultipleSelection: false
|
|
) { result in
|
|
switch result {
|
|
case .success(let urls):
|
|
guard let url = urls.first else { return }
|
|
let accessing = url.startAccessingSecurityScopedResource()
|
|
defer {
|
|
if accessing { url.stopAccessingSecurityScopedResource() }
|
|
}
|
|
do {
|
|
pairingData = try Data(contentsOf: url)
|
|
selectedFilename = url.lastPathComponent
|
|
errorMessage = nil
|
|
} catch {
|
|
errorMessage = "Failed to read file: \(error.localizedDescription)"
|
|
}
|
|
case .failure(let error):
|
|
errorMessage = error.localizedDescription
|
|
}
|
|
}
|
|
}
|
|
.tint(accentColor)
|
|
}
|
|
|
|
private func save() {
|
|
guard canSave else { return }
|
|
do {
|
|
try onSave(DeviceEditorInput(
|
|
name: name,
|
|
ipAddress: ipAddress,
|
|
pairingData: pairingData,
|
|
pairingFilename: selectedFilename ?? existingFilename,
|
|
isTXM: isTXMDevice
|
|
))
|
|
dismiss()
|
|
} catch {
|
|
errorMessage = error.localizedDescription
|
|
}
|
|
}
|
|
}
|