From 08830f1125ef6ed0fafb0346d57e9b1a3840b093 Mon Sep 17 00:00:00 2001 From: Stephen <158498287+StephenDev0@users.noreply.github.com> Date: Mon, 18 May 2026 09:25:24 -0400 Subject: [PATCH] 3.1.3 WIP --- StikJIT/Info.plist | 1 + StikJIT/Utilities/IdeviceFFIBridge.swift | 4 + StikJIT/Views/HomeView.swift | 27 +- StikJIT/Views/MainTabView.swift | 151 ++++++- StikJIT/Views/MapSelectionView.swift | 492 ++++++++++++++++++++++- StikJIT/Views/SettingsView.swift | 115 +++--- 6 files changed, 701 insertions(+), 89 deletions(-) diff --git a/StikJIT/Info.plist b/StikJIT/Info.plist index 471d344..ca4c017 100644 --- a/StikJIT/Info.plist +++ b/StikJIT/Info.plist @@ -11,6 +11,7 @@ com.stik.StikJIT.enableJIT CFBundleURLSchemes + stikdebug stikjit diff --git a/StikJIT/Utilities/IdeviceFFIBridge.swift b/StikJIT/Utilities/IdeviceFFIBridge.swift index 7b31e7b..6871c79 100644 --- a/StikJIT/Utilities/IdeviceFFIBridge.swift +++ b/StikJIT/Utilities/IdeviceFFIBridge.swift @@ -750,6 +750,10 @@ private enum LocationSimulationState { } } +enum LocationSimulationCommandQueue { + static let shared = DispatchQueue(label: "com.stik.location-sim", qos: .userInitiated) +} + func simulate_location(_ deviceIP: String, _ latitude: Double, _ longitude: Double, _ pairingFile: String) -> Int32 { if let locationSimulation = LocationSimulationState.locationSimulation { if let ffiError = location_simulation_set(locationSimulation, latitude, longitude) { diff --git a/StikJIT/Views/HomeView.swift b/StikJIT/Views/HomeView.swift index 0250f1a..f056e03 100644 --- a/StikJIT/Views/HomeView.swift +++ b/StikJIT/Views/HomeView.swift @@ -133,24 +133,24 @@ struct HomeView: View { } } .onOpenURL { url in - guard let host = url.host() else { return } + guard let host = url.host()?.lowercased() else { return } let components = URLComponents(url: url, resolvingAgainstBaseURL: false) switch host { case "enable-jit": var config = JITEnableConfiguration() - if let pidStr = components?.queryItems?.first(where: { $0.name == "pid" })?.value, let pid = Int(pidStr) { + if let pidStr = queryValue(["pid"], in: components), let pid = Int(pidStr) { config.pid = pid } - if let bundleId = components?.queryItems?.first(where: { $0.name == "bundle-id" })?.value { + if let bundleId = queryValue(["bundle-id", "bundleID", "bundle_id", "bundleId"], in: components) { config.bundleID = bundleId } - if let scriptBase64URL = components?.queryItems?.first(where: { $0.name == "script-data" })?.value?.removingPercentEncoding { + if let scriptBase64URL = queryValue(["script-data", "scriptData", "script_data"], in: components)?.removingPercentEncoding { let base64 = base64URLToBase64(scriptBase64URL) if let scriptData = Data(base64Encoded: base64) { config.scriptData = scriptData } } - if let scriptName = components?.queryItems?.first(where: { $0.name == "script-name" })?.value { + if let scriptName = queryValue(["script-name", "scriptName", "script_name"], in: components) { config.scriptName = scriptName } if config.scriptData == nil, let bundleID = config.bundleID, @@ -164,7 +164,7 @@ struct HomeView: View { pendingJITEnableConfiguration = config } case "kill-process": - if let pidStr = components?.queryItems?.first(where: { $0.name == "pid" })?.value, let pid = Int(pidStr) { + if let pidStr = queryValue(["pid"], in: components), let pid = Int(pidStr) { pubTunnelConnected = false startTunnelInBackground(showErrorUI: false) DispatchQueue.global(qos: .userInitiated).async { @@ -182,7 +182,7 @@ struct HomeView: View { } } case "launch-app": - if let bundleId = components?.queryItems?.first(where: { $0.name == "bundle-id" })?.value { + if let bundleId = queryValue(["bundle-id", "bundleID", "bundle_id", "bundleId"], in: components) { HapticFeedbackHelper.trigger() DispatchQueue.global(qos: .userInitiated).async { let _ = JITEnableContext.shared.launchAppWithoutDebug(bundleId, logger: nil) @@ -243,6 +243,19 @@ struct HomeView: View { } } + private func queryValue(_ names: [String], in components: URLComponents?) -> String? { + guard let queryItems = components?.queryItems else { return nil } + for name in names { + if let rawValue = queryItems.first(where: { $0.name.caseInsensitiveCompare(name) == .orderedSame })?.value { + let value = rawValue.trimmingCharacters(in: .whitespacesAndNewlines) + if !value.isEmpty { + return value + } + } + } + return nil + } + private func debugFeedbackView(_ feedback: DebugFeedback) -> some View { HStack(spacing: 10) { if feedback.isWorking { diff --git a/StikJIT/Views/MainTabView.swift b/StikJIT/Views/MainTabView.swift index 84d017c..7b1a751 100644 --- a/StikJIT/Views/MainTabView.swift +++ b/StikJIT/Views/MainTabView.swift @@ -6,6 +6,7 @@ // import SwiftUI +import Foundation private struct TabDescriptor: Identifiable { let id: String @@ -60,6 +61,147 @@ struct MainTabView: View { } selection = ids.first ?? settingsTab.id } + + private func handleURL(_ url: URL) { + guard let host = url.host()?.lowercased() else { return } + + switch host { + case "simulate-location", "set-location": + simulateLocation(from: url) + case "location", "location-simulation": + if coordinate(from: url) == nil { + openTab(id: "location") + } else { + simulateLocation(from: url) + } + case "clear-location", "stop-location": + clearSimulatedLocation() + default: + break + } + } + + private func openTab(id: String) { + if displayTabs.contains(where: { $0.id == id }) { + selection = id + } else if let descriptor = availableTabs.first(where: { $0.id == id }) { + detachedTab = descriptor + } + } + + private func simulateLocation(from url: URL) { + guard let coordinate = coordinate(from: url) else { + showAlert( + title: "Invalid Location URL", + message: "Use stikdebug://simulate-location?lat=37.3349&lon=-122.0090", + showOk: true + ) + return + } + + guard coordinateIsValid(latitude: coordinate.latitude, longitude: coordinate.longitude) else { + showAlert( + title: "Invalid Coordinates", + message: "Latitude must be between -90 and 90. Longitude must be between -180 and 180.", + showOk: true + ) + return + } + + let pairingFile = PairingFileStore.prepareURL() + guard FileManager.default.fileExists(atPath: pairingFile.path) else { + showAlert( + title: "Pairing File Required", + message: "Import a pairing file before simulating location from a URL.", + showOk: true + ) + return + } + + LocationSimulationCommandQueue.shared.async { + let code = simulate_location( + DeviceConnectionContext.targetIPAddress, + coordinate.latitude, + coordinate.longitude, + pairingFile.path + ) + + DispatchQueue.main.async { + if code == 0 { + BackgroundLocationManager.shared.requestStart() + LogManager.shared.addInfoLog( + String(format: "Simulated location from URL: %.6f, %.6f", coordinate.latitude, coordinate.longitude) + ) + } else { + showAlert( + title: "Location Simulation Failed", + message: "Could not simulate location from URL (error \(code)). Make sure the device is connected and the DDI is mounted.", + showOk: true + ) + } + } + } + } + + private func clearSimulatedLocation() { + LocationSimulationCommandQueue.shared.async { + let code = clear_simulated_location() + DispatchQueue.main.async { + if code == 0 { + BackgroundLocationManager.shared.requestStop() + LogManager.shared.addInfoLog("Cleared simulated location from URL") + } else { + showAlert( + title: "Clear Location Failed", + message: "Could not clear simulated location from URL (error \(code)).", + showOk: true + ) + } + } + } + } + + private func coordinate(from url: URL) -> (latitude: Double, longitude: Double)? { + let components = URLComponents(url: url, resolvingAgainstBaseURL: false) + let queryItems = components?.queryItems ?? [] + + func queryValue(_ names: [String]) -> String? { + for name in names { + if let value = queryItems.first(where: { $0.name.caseInsensitiveCompare(name) == .orderedSame })?.value { + return value + } + } + return nil + } + + if let latitudeText = queryValue(["lat", "latitude"]), + let longitudeText = queryValue(["lon", "lng", "long", "longitude"]), + let latitude = Double(latitudeText.trimmingCharacters(in: .whitespacesAndNewlines)), + let longitude = Double(longitudeText.trimmingCharacters(in: .whitespacesAndNewlines)) { + return (latitude, longitude) + } + + let coordinateText = queryValue(["coordinate", "coordinates", "coords", "q", "ll"]) + ?? components?.path + ?? "" + let values = numbers(in: coordinateText) + guard values.count >= 2 else { return nil } + return (values[0], values[1]) + } + + private func coordinateIsValid(latitude: Double, longitude: Double) -> Bool { + (-90.0...90.0).contains(latitude) && (-180.0...180.0).contains(longitude) + } + + private func numbers(in text: String) -> [Double] { + let pattern = #"[-+]?(?:\d+(?:\.\d*)?|\.\d+)"# + guard let regex = try? NSRegularExpression(pattern: pattern) else { return [] } + let range = NSRange(text.startIndex.. CLLocationDistance { + zip(coordinates, coordinates.dropFirst()).reduce(0) { total, pair in + total + CLLocation(latitude: pair.0.latitude, longitude: pair.0.longitude) + .distance(from: CLLocation(latitude: pair.1.latitude, longitude: pair.1.longitude)) + } +} + private func distanceFromPoint( _ point: MKMapPoint, toSegmentFrom start: MKMapPoint, @@ -341,6 +350,334 @@ private func prefetchRoutePlaybackSamples( ) } +private enum CoordinateImportError: LocalizedError { + case emptyFile + case noCoordinates + + var errorDescription: String? { + switch self { + case .emptyFile: + return "The selected file is empty." + case .noCoordinates: + return "No valid coordinates were found. Use GPX, GeoJSON, JSON, CSV, or plain text with latitude and longitude values." + } + } +} + +private enum CoordinateImportParser { + static let supportedContentTypes: [UTType] = [ + .plainText, + .commaSeparatedText, + .json, + .xml, + UTType(filenameExtension: "gpx", conformingTo: .xml) ?? .xml, + UTType(filenameExtension: "kml", conformingTo: .xml) ?? .xml, + UTType(filenameExtension: "geojson", conformingTo: .json) ?? .json + ] + + private enum CoordinateOrder { + case latitudeLongitude + case longitudeLatitude + } + + static func parse(url: URL) throws -> [CLLocationCoordinate2D] { + let accessing = url.startAccessingSecurityScopedResource() + defer { + if accessing { + url.stopAccessingSecurityScopedResource() + } + } + + let data = try Data(contentsOf: url) + guard !data.isEmpty else { throw CoordinateImportError.emptyFile } + + let fileExtension = url.pathExtension.lowercased() + if fileExtension == "json" || fileExtension == "geojson" { + if let coordinates = try? parseJSONCoordinates(from: data), + !coordinates.isEmpty { + return coordinates + } + } + + if fileExtension == "gpx" || fileExtension == "kml" || fileExtension == "xml" { + let coordinates = parseXMLCoordinates(from: data) + if !coordinates.isEmpty { + return coordinates + } + } + + if let text = decodedText(from: data) { + let coordinates = parseInline(text) + if !coordinates.isEmpty { + return coordinates + } + } + + if let coordinates = try? parseJSONCoordinates(from: data), + !coordinates.isEmpty { + return coordinates + } + + let coordinates = parseXMLCoordinates(from: data) + if !coordinates.isEmpty { + return coordinates + } + + throw CoordinateImportError.noCoordinates + } + + static func parseInline(_ text: String) -> [CLLocationCoordinate2D] { + sanitized(parseTextCoordinates(from: text)) + } + + private static func decodedText(from data: Data) -> String? { + String(data: data, encoding: .utf8) + ?? String(data: data, encoding: .utf16) + ?? String(data: data, encoding: .ascii) + } + + private static func sanitized(_ coordinates: [CLLocationCoordinate2D]) -> [CLLocationCoordinate2D] { + var result: [CLLocationCoordinate2D] = [] + for coordinate in coordinates where CLLocationCoordinate2DIsValid(coordinate) { + if result.last.map(CoordinateSnapshot.init) == CoordinateSnapshot(coordinate) { + continue + } + result.append(coordinate) + } + return result + } + + private static func coordinate( + first: Double, + second: Double, + order: CoordinateOrder + ) -> CLLocationCoordinate2D? { + let preferred: CLLocationCoordinate2D + let fallback: CLLocationCoordinate2D + + switch order { + case .latitudeLongitude: + preferred = CLLocationCoordinate2D(latitude: first, longitude: second) + fallback = CLLocationCoordinate2D(latitude: second, longitude: first) + case .longitudeLatitude: + preferred = CLLocationCoordinate2D(latitude: second, longitude: first) + fallback = CLLocationCoordinate2D(latitude: first, longitude: second) + } + + if CLLocationCoordinate2DIsValid(preferred) { + return preferred + } + if CLLocationCoordinate2DIsValid(fallback) { + return fallback + } + return nil + } + + private static func parseJSONCoordinates(from data: Data) throws -> [CLLocationCoordinate2D] { + let object = try JSONSerialization.jsonObject(with: data) + return sanitized(coordinates(fromJSONObject: object, order: .latitudeLongitude)) + } + + private static func coordinates( + fromJSONObject object: Any, + order: CoordinateOrder + ) -> [CLLocationCoordinate2D] { + if let dictionary = object as? [String: Any] { + if let latitude = numberValue(forAnyKey: ["latitude", "lat"], in: dictionary), + let longitude = numberValue(forAnyKey: ["longitude", "lon", "lng"], in: dictionary), + let coordinate = coordinate(first: latitude, second: longitude, order: .latitudeLongitude) { + return [coordinate] + } + + if let geometry = dictionary["geometry"] { + return coordinates(fromJSONObject: geometry, order: order) + } + + if let type = dictionary["type"] as? String { + let loweredType = type.lowercased() + if loweredType == "featurecollection", + let features = dictionary["features"] as? [Any] { + return features.flatMap { coordinates(fromJSONObject: $0, order: .longitudeLatitude) } + } + if loweredType == "geometrycollection", + let geometries = dictionary["geometries"] as? [Any] { + return geometries.flatMap { coordinates(fromJSONObject: $0, order: .longitudeLatitude) } + } + if let coordinateObject = dictionary["coordinates"] { + return coordinates(fromJSONObject: coordinateObject, order: .longitudeLatitude) + } + } + + return dictionary.values.flatMap { coordinates(fromJSONObject: $0, order: order) } + } + + if let array = object as? [Any] { + if array.count >= 2, + let first = numericValue(array[0]), + let second = numericValue(array[1]), + let coordinate = coordinate(first: first, second: second, order: order) { + return [coordinate] + } + + return array.flatMap { coordinates(fromJSONObject: $0, order: order) } + } + + return [] + } + + private static func numericValue(_ value: Any) -> Double? { + if let number = value as? NSNumber { + return number.doubleValue + } + if let string = value as? String { + return Double(string.trimmingCharacters(in: .whitespacesAndNewlines)) + } + return nil + } + + private static func numberValue(forAnyKey keys: [String], in dictionary: [String: Any]) -> Double? { + let keyedValues = Dictionary(uniqueKeysWithValues: dictionary.map { ($0.key.lowercased(), $0.value) }) + for key in keys { + if let value = keyedValues[key], + let number = numericValue(value) { + return number + } + } + return nil + } + + private static func parseXMLCoordinates(from data: Data) -> [CLLocationCoordinate2D] { + let collector = XMLCoordinateCollector() + let parser = XMLParser(data: data) + parser.delegate = collector + guard parser.parse() else { return [] } + return sanitized(collector.coordinates) + } + + private final class XMLCoordinateCollector: NSObject, XMLParserDelegate { + var coordinates: [CLLocationCoordinate2D] = [] + private var isCollectingKMLCoordinates = false + private var kmlCoordinateBuffer = "" + + func parser( + _ parser: XMLParser, + didStartElement elementName: String, + namespaceURI: String?, + qualifiedName qName: String?, + attributes attributeDict: [String: String] = [:] + ) { + let name = elementName.lowercased() + if ["wpt", "trkpt", "rtept"].contains(name), + let latitude = Double(attributeDict["lat"] ?? ""), + let longitude = Double(attributeDict["lon"] ?? ""), + let coordinate = CoordinateImportParser.coordinate( + first: latitude, + second: longitude, + order: .latitudeLongitude + ) { + coordinates.append(coordinate) + } else if name == "coordinates" { + isCollectingKMLCoordinates = true + kmlCoordinateBuffer = "" + } + } + + func parser(_ parser: XMLParser, foundCharacters string: String) { + if isCollectingKMLCoordinates { + kmlCoordinateBuffer += string + } + } + + func parser( + _ parser: XMLParser, + didEndElement elementName: String, + namespaceURI: String?, + qualifiedName qName: String? + ) { + guard elementName.lowercased() == "coordinates" else { return } + coordinates.append(contentsOf: CoordinateImportParser.parseKMLCoordinateText(kmlCoordinateBuffer)) + isCollectingKMLCoordinates = false + kmlCoordinateBuffer = "" + } + } + + private static func parseKMLCoordinateText(_ text: String) -> [CLLocationCoordinate2D] { + text + .split(whereSeparator: { $0.isWhitespace }) + .compactMap { token -> CLLocationCoordinate2D? in + let values = token + .split(separator: ",") + .compactMap { Double($0.trimmingCharacters(in: .whitespacesAndNewlines)) } + guard values.count >= 2 else { return nil } + return coordinate(first: values[0], second: values[1], order: .longitudeLatitude) + } + } + + private static func parseTextCoordinates(from text: String) -> [CLLocationCoordinate2D] { + var coordinates: [CLLocationCoordinate2D] = [] + var headerIndices: (latitude: Int, longitude: Int)? + + for line in text.components(separatedBy: .newlines) { + let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { continue } + + let fields = splitFields(trimmed) + if headerIndices == nil, + let detectedHeader = detectHeader(in: fields) { + headerIndices = detectedHeader + continue + } + + if let headerIndices, + fields.indices.contains(headerIndices.latitude), + fields.indices.contains(headerIndices.longitude), + let latitude = numbers(in: fields[headerIndices.latitude]).first, + let longitude = numbers(in: fields[headerIndices.longitude]).first, + let coordinate = coordinate(first: latitude, second: longitude, order: .latitudeLongitude) { + coordinates.append(coordinate) + continue + } + + let values = numbers(in: trimmed) + if values.count >= 2, + let coordinate = coordinate(first: values[0], second: values[1], order: .latitudeLongitude) { + coordinates.append(coordinate) + } + } + + return coordinates + } + + private static func splitFields(_ line: String) -> [String] { + line + .split { character in + character == "," || + character == ";" || + character == "\t" + } + .map { String($0).trimmingCharacters(in: .whitespacesAndNewlines) } + } + + private static func detectHeader(in fields: [String]) -> (latitude: Int, longitude: Int)? { + let lowered = fields.map { $0.lowercased() } + guard let latitude = lowered.firstIndex(where: { $0 == "lat" || $0 == "latitude" }), + let longitude = lowered.firstIndex(where: { $0 == "lon" || $0 == "lng" || $0 == "long" || $0 == "longitude" }) else { + return nil + } + return (latitude, longitude) + } + + private static func numbers(in text: String) -> [Double] { + let pattern = #"[-+]?(?:\d+(?:\.\d*)?|\.\d+)"# + guard let regex = try? NSRegularExpression(pattern: pattern) else { return [] } + let range = NSRange(text.startIndex..) { + switch result { + case .success(let urls): + guard let url = urls.first else { return } + let sourceName = url.deletingPathExtension().lastPathComponent + isImportingCoordinates = true + + Task { + do { + let coordinates = try await Task.detached(priority: .userInitiated) { + try CoordinateImportParser.parse(url: url) + }.value + + await MainActor.run { + isImportingCoordinates = false + applyImportedCoordinates( + coordinates, + sourceName: sourceName.isEmpty ? "Imported" : sourceName + ) + } + } catch { + await MainActor.run { + isImportingCoordinates = false + showImportError(error) + } + } + } + case .failure(let error): + showImportError(error) + } + } + + private func applyImportedCoordinates( + _ importedCoordinates: [CLLocationCoordinate2D], + sourceName: String + ) { + guard !isRouteRunning else { return } + + let coordinates = importedCoordinates.filter(CLLocationCoordinate2DIsValid) + guard let firstCoordinate = coordinates.first else { + showImportError(CoordinateImportError.noCoordinates) + return + } + + if coordinates.count == 1 { + applySelection(firstCoordinate) + return + } + + routeLoadTask?.cancel() + routeLoadTask = nil + routeSpeedPrefetchTask?.cancel() + routeSpeedPrefetchTask = nil + routeRequestID = UUID() + routePlan = nil + routePlaybackSamples = [] + routePlaybackCoordinate = nil + isLoadingRoute = false + isPrefetchingRouteSpeeds = false + coordinate = nil + + let displayCoordinates = sampledRouteCoordinates( + from: coordinates, + targetDistance: RouteSimulationDefaults.pathSamplingDistance + ) + + guard displayCoordinates.count > 1, + let lastCoordinate = displayCoordinates.last else { + applySelection(firstCoordinate) + return + } + + let distance = distanceAlong(displayCoordinates) + let fallbackSpeed = RouteSimulationDefaults.importedRouteFallbackSpeedMetersPerSecond + routeStartSelection = RouteSearchSelection(title: "\(sourceName) Start", coordinate: firstCoordinate) + routeEndSelection = RouteSearchSelection(title: "\(sourceName) End", coordinate: lastCoordinate) + routePlan = RouteSimulationPlan( + displayCoordinates: displayCoordinates, + distance: distance, + expectedTravelTime: distance / fallbackSpeed + ) + + if let routePolyline { + position = .rect(routePolyline.boundingMapRect) + } + + let requestID = UUID() + routeRequestID = requestID + isPrefetchingRouteSpeeds = true + routeSpeedPrefetchTask = Task.detached(priority: .utility) { + let playbackSamples = await prefetchRoutePlaybackSamples( + displayCoordinates: displayCoordinates, + fallbackSpeedMetersPerSecond: fallbackSpeed + ) + guard !Task.isCancelled else { return } + await MainActor.run { + guard routeRequestID == requestID else { return } + routePlaybackSamples = playbackSamples + isPrefetchingRouteSpeeds = false + } + } + } + + private func showImportError(_ error: Error) { + alertTitle = "Import Failed" + alertMessage = error.localizedDescription + showAlert = true + } + @ViewBuilder private var pinControls: some View { if let coord = coordinate { @@ -848,7 +1324,7 @@ struct LocationSimulationView: View { onSuccess: @escaping () -> Void ) { isBusy = true - Self.locationQueue.async { + LocationSimulationCommandQueue.shared.async { let code = operation() DispatchQueue.main.async { isBusy = false @@ -897,7 +1373,7 @@ struct LocationSimulationView: View { resendTimer?.invalidate() resendTimer = Timer.scheduledTimer(withTimeInterval: 4, repeats: true) { _ in guard let simulatedCoordinate else { return } - Self.locationQueue.async { + LocationSimulationCommandQueue.shared.async { _ = locationUpdateCode(for: simulatedCoordinate) } } @@ -1076,7 +1552,7 @@ struct LocationSimulationView: View { private func sendLocationUpdate(for coordinate: CLLocationCoordinate2D) async -> Int32 { await withCheckedContinuation { continuation in - Self.locationQueue.async { + LocationSimulationCommandQueue.shared.async { continuation.resume(returning: locationUpdateCode(for: coordinate)) } } diff --git a/StikJIT/Views/SettingsView.swift b/StikJIT/Views/SettingsView.swift index 83f0cb3..98fed01 100644 --- a/StikJIT/Views/SettingsView.swift +++ b/StikJIT/Views/SettingsView.swift @@ -20,10 +20,8 @@ struct SettingsView: View { @AppStorage("primaryTabSelection") private var tabSelection = TabConfiguration.defaultIDs.first ?? "home" @State private var isShowingPairingFilePicker = false - @State private var showPairingFileMessage = false @State private var isImportingFile = false - @State private var importProgress: Float = 0.0 - @State private var pairingStatusMessage: String? = nil + @State private var pairingImportMessage: (text: String, isError: Bool)? @State private var showDDIConfirmation = false @State private var isRedownloadingDDI = false @State private var ddiDownloadProgress: Double = 0.0 @@ -86,12 +84,28 @@ struct SettingsView: View { // 3) Pairing File Section("Pairing File") { - Button { isShowingPairingFilePicker = true } label: { + Button { + isShowingPairingFilePicker = true + } label: { Label("Import Pairing File", systemImage: "doc.badge.plus") } - if showPairingFileMessage && !isImportingFile { - Label("Imported successfully", systemImage: "checkmark.circle.fill") - .foregroundStyle(.green) + .disabled(isImportingFile) + + if isImportingFile { + HStack(spacing: 10) { + ProgressView() + .controlSize(.small) + Text("Importing pairing file…") + .font(.caption) + .foregroundStyle(.secondary) + } + } else if let pairingImportMessage { + Label( + pairingImportMessage.text, + systemImage: pairingImportMessage.isError ? "exclamationmark.triangle.fill" : "checkmark.circle.fill" + ) + .font(.caption) + .foregroundStyle(pairingImportMessage.isError ? .red : .green) } } @@ -194,35 +208,24 @@ struct SettingsView: View { guard let url = urls.first else { return } let fileManager = FileManager.default + isImportingFile = true + pairingImportMessage = nil + do { try PairingFileStore.importFromPicker(url, fileManager: fileManager) - DispatchQueue.main.async { - isImportingFile = true - importProgress = 0.0 - pairingStatusMessage = nil - showPairingFileMessage = false - } - - let progressTimer = Timer.scheduledTimer(withTimeInterval: 0.05, repeats: true) { timer in - DispatchQueue.main.async { - if importProgress < 1.0 { - importProgress += 0.05 - } else { - timer.invalidate() - isImportingFile = false - } - } - } - - RunLoop.current.add(progressTimer, forMode: .common) - DispatchQueue.main.async { - startTunnelInBackground() - } + isImportingFile = false + pairingImportMessage = ("Imported successfully", false) + startTunnelInBackground() + schedulePairingStatusDismiss() } catch { - break + isImportingFile = false + pairingImportMessage = ("Import failed: \(error.localizedDescription)", true) + schedulePairingStatusDismiss() } - case .failure: - break + case .failure(let error): + isImportingFile = false + pairingImportMessage = ("Import failed: \(error.localizedDescription)", true) + schedulePairingStatusDismiss() } } .confirmationDialog("Redownload DDI Files?", isPresented: $showDDIConfirmation, titleVisibility: .visible) { @@ -233,43 +236,6 @@ struct SettingsView: View { } message: { Text("Existing DDI files will be removed before downloading fresh copies.") } - .overlay { if isImportingFile { importBusyOverlay } } - } - - @ViewBuilder - private var importBusyOverlay: some View { - Color.black.opacity(0.35).ignoresSafeArea() - VStack(spacing: 12) { - ProgressView("Processing pairing file…") - VStack(spacing: 8) { - GeometryReader { geometry in - ZStack(alignment: .leading) { - RoundedRectangle(cornerRadius: 6) - .fill(Color(UIColor.tertiarySystemFill)) - .frame(height: 8) - RoundedRectangle(cornerRadius: 6) - .fill(Color.green) - .frame(width: geometry.size.width * CGFloat(importProgress), height: 8) - .animation(.linear(duration: 0.3), value: importProgress) - } - } - .frame(height: 8) - Text("\(Int(importProgress * 100))%") - .font(.caption) - .foregroundStyle(.secondary) - } - .padding(.top, 6) - } - .padding(16) - .background( - RoundedRectangle(cornerRadius: 16, style: .continuous) - .fill(.ultraThinMaterial) - .overlay( - RoundedRectangle(cornerRadius: 16, style: .continuous) - .strokeBorder(Color.white.opacity(0.15), lineWidth: 1) - ) - ) - .shadow(color: .black.opacity(0.15), radius: 12, x: 0, y: 4) } private var versionFooter: String { @@ -322,6 +288,17 @@ struct SettingsView: View { } scheduleDDIStatusDismiss() } + + private func schedulePairingStatusDismiss() { + Task { + try? await Task.sleep(nanoseconds: 4_000_000_000) + await MainActor.run { + if !isImportingFile { + pairingImportMessage = nil + } + } + } + } private func scheduleDDIStatusDismiss() { Task {