mirror of
https://github.com/StikDebug/StikDebug.git
synced 2026-10-15 20:10:30 +00:00
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.
503 lines
20 KiB
Swift
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
|
|
}
|
|
}
|
|
}
|