mirror of
https://github.com/StikDebug/StikDebug.git
synced 2026-10-16 04:20: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.
265 lines
9.5 KiB
Swift
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
|
|
}
|
|
}
|