StikDebug/StikJIT/Utilities/DeviceLibraryStore.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

265 lines
9.5 KiB
Swift

//
// DeviceLibraryStore.swift
// StikJIT
//
// Created by Stephen.
//
import Foundation
struct DeviceProfileEntry: Identifiable, Codable, Equatable {
var id: UUID
var name: String
var ipAddress: String
var pairingRelativePath: String
var pairingFilename: String
var dateAdded: Date
var lastUpdated: Date
}
enum DeviceLibraryError: LocalizedError {
case missingPairingData
case deviceNotFound
case pairingFileUnavailable
case fileOperationFailed(String)
var errorDescription: String? {
switch self {
case .missingPairingData:
return "Select a pairing file before saving."
case .deviceNotFound:
return "The selected device could not be found."
case .pairingFileUnavailable:
return "The pairing file for this device is missing. Re-import it and try again."
case .fileOperationFailed(let reason):
return reason
}
}
}
final class DeviceLibraryStore: ObservableObject {
static let shared = DeviceLibraryStore()
@Published private(set) var devices: [DeviceProfileEntry] = []
@Published private(set) var activeDeviceID: UUID?
var activeDevice: DeviceProfileEntry? {
guard let activeDeviceID else { return nil }
return devices.first(where: { $0.id == activeDeviceID })
}
var isUsingExternalDevice: Bool {
activeDeviceID != nil
}
var defaultLocalDevice: DeviceProfileEntry {
DeviceProfileEntry(
id: localLoopbackID,
name: "This Device",
ipAddress: "10.7.0.2",
pairingRelativePath: "",
pairingFilename: "pairingFile.plist",
dateAdded: Date.distantPast,
lastUpdated: Date.distantPast
)
}
private let fileManager = FileManager.default
private let storageURL: URL
private let pairingsDirectory: URL
private let baseDirectory: URL
private let activeDeviceKey = "DeviceLibraryActiveDeviceID"
private let localLoopbackID = UUID(uuidString: "00000000-0000-0000-0000-000000000001") ?? UUID()
private init() {
baseDirectory = URL.documentsDirectory.appendingPathComponent("DeviceLibrary", isDirectory: true)
pairingsDirectory = baseDirectory.appendingPathComponent("Pairings", isDirectory: true)
storageURL = baseDirectory.appendingPathComponent("devices.json")
createDirectoriesIfNeeded()
loadFromDisk()
if let rawValue = UserDefaults.standard.string(forKey: activeDeviceKey),
let uuid = UUID(uuidString: rawValue),
devices.contains(where: { $0.id == uuid }) {
activeDeviceID = uuid
} else {
UserDefaults.standard.removeObject(forKey: activeDeviceKey)
UserDefaults.standard.set(false, forKey: UserDefaults.Keys.usingExternalDevice)
activeDeviceID = nil
}
updateExternalDeviceFlag()
}
// MARK: - Public API
func refresh() {
loadFromDisk()
}
func addDevice(name: String, ipAddress: String, pairingData: Data?, originalFilename: String?) throws {
guard let pairingData else {
throw DeviceLibraryError.missingPairingData
}
let id = UUID()
let now = Date()
let relativePath = try persistPairingData(pairingData, for: id)
let entry = DeviceProfileEntry(
id: id,
name: name.trimmingCharacters(in: .whitespacesAndNewlines),
ipAddress: ipAddress.trimmingCharacters(in: .whitespacesAndNewlines),
pairingRelativePath: relativePath,
pairingFilename: originalFilename ?? "pairingFile.plist",
dateAdded: now,
lastUpdated: now
)
devices.append(entry)
persistDevices()
}
func update(device: DeviceProfileEntry,
name: String,
ipAddress: String,
pairingData: Data?,
originalFilename: String?) throws {
guard !isDefaultDevice(device) else { return }
guard let index = devices.firstIndex(where: { $0.id == device.id }) else {
throw DeviceLibraryError.deviceNotFound
}
devices[index].name = name.trimmingCharacters(in: .whitespacesAndNewlines)
devices[index].ipAddress = ipAddress.trimmingCharacters(in: .whitespacesAndNewlines)
devices[index].lastUpdated = Date()
if activeDeviceID == device.id {
UserDefaults.standard.set(devices[index].ipAddress, forKey: "TunnelDeviceIP")
}
if let pairingData {
let relativePath = try persistPairingData(pairingData, for: device.id)
devices[index].pairingRelativePath = relativePath
if let originalFilename {
devices[index].pairingFilename = originalFilename
}
}
persistDevices()
}
func remove(device: DeviceProfileEntry) throws {
guard !isDefaultDevice(device) else { return }
guard let index = devices.firstIndex(where: { $0.id == device.id }) else {
throw DeviceLibraryError.deviceNotFound
}
let relativePath = devices[index].pairingRelativePath
let storedURL = pairingsDirectory.appendingPathComponent(relativePath)
if fileManager.fileExists(atPath: storedURL.path) {
try? fileManager.removeItem(at: storedURL)
}
devices.remove(at: index)
if activeDeviceID == device.id {
clearActiveDevice()
}
persistDevices()
}
func activate(device: DeviceProfileEntry) throws {
if isDefaultDevice(device) {
clearActiveDevice()
return
}
let storedURL = pairingsDirectory.appendingPathComponent(device.pairingRelativePath)
guard fileManager.fileExists(atPath: storedURL.path) else {
throw DeviceLibraryError.pairingFileUnavailable
}
let destinationURL = URL.documentsDirectory.appendingPathComponent("pairingFile.plist")
if fileManager.fileExists(atPath: destinationURL.path) {
try? fileManager.removeItem(at: destinationURL)
}
try fileManager.copyItem(at: storedURL, to: destinationURL)
try fileManager.setAttributes([.posixPermissions: 0o600], ofItemAtPath: destinationURL.path)
UserDefaults.standard.set(device.ipAddress, forKey: "TunnelDeviceIP")
activeDeviceID = device.id
UserDefaults.standard.set(device.id.uuidString, forKey: activeDeviceKey)
UserDefaults.standard.set(true, forKey: UserDefaults.Keys.usingExternalDevice)
persistDevices()
updateExternalDeviceFlag()
}
func clearActiveDevice() {
activeDeviceID = nil
UserDefaults.standard.removeObject(forKey: activeDeviceKey)
UserDefaults.standard.set(false, forKey: UserDefaults.Keys.usingExternalDevice)
UserDefaults.standard.removeObject(forKey: "TunnelDeviceIP")
updateExternalDeviceFlag()
}
// MARK: - Persistence
private func createDirectoriesIfNeeded() {
do {
if !fileManager.fileExists(atPath: baseDirectory.path) {
try fileManager.createDirectory(at: baseDirectory, withIntermediateDirectories: true)
}
if !fileManager.fileExists(atPath: pairingsDirectory.path) {
try fileManager.createDirectory(at: pairingsDirectory, withIntermediateDirectories: true)
}
} catch {
print("Failed to create DeviceLibrary directories: \(error)")
}
}
private func persistPairingData(_ data: Data, for id: UUID) throws -> String {
createDirectoriesIfNeeded()
let filename = "\(id.uuidString).mobiledevicepairing"
let destination = pairingsDirectory.appendingPathComponent(filename)
if fileManager.fileExists(atPath: destination.path) {
try fileManager.removeItem(at: destination)
}
do {
try data.write(to: destination, options: .atomic)
try fileManager.setAttributes([.posixPermissions: 0o600], ofItemAtPath: destination.path)
} catch {
throw DeviceLibraryError.fileOperationFailed("Unable to store pairing file. \(error.localizedDescription)")
}
return filename
}
private func loadFromDisk() {
guard fileManager.fileExists(atPath: storageURL.path) else {
devices = []
return
}
do {
let data = try Data(contentsOf: storageURL)
let decoded = try JSONDecoder().decode([DeviceProfileEntry].self, from: data)
devices = decoded
} catch {
print("Failed to load device library: \(error)")
devices = []
}
if let activeDeviceID, !devices.contains(where: { $0.id == activeDeviceID }) {
clearActiveDevice()
}
updateExternalDeviceFlag()
}
private func persistDevices() {
do {
createDirectoriesIfNeeded()
let data = try JSONEncoder().encode(devices)
try data.write(to: storageURL, options: .atomic)
} catch {
print("Failed to save device library: \(error)")
}
}
private func updateExternalDeviceFlag() {
UserDefaults.standard.set(isUsingExternalDevice, forKey: UserDefaults.Keys.usingExternalDevice)
}
func isDefaultDevice(_ device: DeviceProfileEntry) -> Bool {
device.id == localLoopbackID
}
}