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 {