StikDebug/StikJIT/Views/DeviceLibraryView.swift
Stephen B 06b64192c4 Add device library and external device support (Semi WIP)
Introduces DeviceLibraryStore and DeviceConnectionContext to manage external device profiles and connections. Updates app logic to support switching between local loopback VPN and direct external device connections, including UI for device management and improved connection handling throughout the app.
2025-12-06 15:20:23 -05:00

503 lines
20 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(requireVPNConnection: false)
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)
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)
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(requireVPNConnection: false)
let title = activatingDefault ? "Loopback Active" : "Device Activated"
let message = activatingDefault
? "Switched back to debugging this device over the loopback VPN."
: "\(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)
}
}
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?
}
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
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")
case .edit(let device):
_name = State(initialValue: device.name)
_ipAddress = State(initialValue: device.ipAddress)
}
_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)
}
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
))
dismiss()
} catch {
errorMessage = error.localizedDescription
}
}
}