3.1.3 WIP

This commit is contained in:
Stephen 2026-05-18 09:25:24 -04:00
parent 78551b8592
commit 08830f1125
6 changed files with 701 additions and 89 deletions

View file

@ -11,6 +11,7 @@
<string>com.stik.StikJIT.enableJIT</string>
<key>CFBundleURLSchemes</key>
<array>
<string>stikdebug</string>
<string>stikjit</string>
</array>
</dict>

View file

@ -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) {

View file

@ -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 {

View file

@ -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..<text.endIndex, in: text)
return regex.matches(in: text, range: range).compactMap { match in
guard let matchRange = Range(match.range, in: text) else { return nil }
return Double(text[matchRange])
}
}
private var displayTabs: [TabDescriptor] {
var tabs = ["home", "tools"].compactMap { id in
@ -95,11 +237,7 @@ struct MainTabView: View {
}
switchObserver = NotificationCenter.default.addObserver(forName: .switchToTab, object: nil, queue: .main) { note in
guard let id = note.object as? String else { return }
if selectedTabDescriptors.contains(where: { $0.id == id }) {
selection = id
} else if let descriptor = availableTabs.first(where: { $0.id == id }) {
detachedTab = descriptor
}
openTab(id: id)
}
}
.onDisappear {
@ -111,6 +249,9 @@ struct MainTabView: View {
.onChange(of: enabledTabIdentifiers) { _, _ in
ensureSelectionIsValid()
}
.onOpenURL { url in
handleURL(url)
}
.sheet(item: $detachedTab) { descriptor in
NavigationStack {
descriptor.builder()

View file

@ -8,6 +8,7 @@
import SwiftUI
import MapKit
import UIKit
import UniformTypeIdentifiers
private struct CoordinateSnapshot: Equatable {
let latitude: Double
@ -43,6 +44,7 @@ private enum RouteSimulationDefaults {
static let pathSamplingDistance: CLLocationDistance = 10
static let playbackTickInterval: TimeInterval = 0.5
static let minimumSpeedMetersPerSecond: CLLocationSpeed = 1.0
static let importedRouteFallbackSpeedMetersPerSecond: CLLocationSpeed = 13.4
}
private struct RoutePlaybackSample {
@ -131,6 +133,13 @@ private func midpointCoordinate(
interpolateCoordinate(from: start, to: end, fraction: 0.5)
}
private func distanceAlong(_ coordinates: [CLLocationCoordinate2D]) -> 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..<text.endIndex, in: text)
return regex.matches(in: text, range: range).compactMap { match in
guard let matchRange = Range(match.range, in: text) else { return nil }
return Double(text[matchRange])
}
}
}
// MARK: - Bookmark Model
struct LocationBookmark: Identifiable, Codable {
@ -387,11 +724,6 @@ final class LocationSearchCompleter: NSObject, ObservableObject, MKLocalSearchCo
}
struct LocationSimulationView: View {
// Serial queue: the location simulation helpers share process-wide state, so
// serialising all calls avoids handle lifetime races.
private static let locationQueue = DispatchQueue(label: "com.stik.location-sim",
qos: .userInitiated)
@State private var coordinate: CLLocationCoordinate2D?
@State private var position: MapCameraPosition = .userLocation(fallback: .automatic)
@ -403,12 +735,14 @@ struct LocationSimulationView: View {
@State private var isBusy = false
@State private var isLoadingRoute = false
@State private var isPrefetchingRouteSpeeds = false
@State private var isImportingCoordinates = false
@State private var showAlert = false
@State private var alertTitle = ""
@State private var alertMessage = ""
@State private var searchText = ""
@StateObject private var searchCompleter = LocationSearchCompleter()
@State private var showCoordinateImporter = false
@State private var showRouteSearch = false
@State private var routeStartSelection: RouteSearchSelection?
@State private var routeEndSelection: RouteSearchSelection?
@ -602,6 +936,11 @@ struct LocationSimulationView: View {
Spacer()
VStack(spacing: 12) {
if isImportingCoordinates {
ProgressView("Importing coordinates…")
.font(.footnote)
}
if hasRouteContext {
routeControls
} else {
@ -628,14 +967,26 @@ struct LocationSimulationView: View {
Image(systemName: "point.topleft.down.curvedto.point.bottomright.up")
}
.disabled(isBusy || isRouteRunning)
Button {
showCoordinateImporter = true
} label: {
Image(systemName: "square.and.arrow.down")
}
.disabled(isBusy || isRouteRunning || isImportingCoordinates)
.accessibilityLabel("Import Coordinates")
}
ToolbarItem(placement: .topBarTrailing) {
TextField("Search location...", text: $searchText)
.padding(.leading, 6)
.autocorrectionDisabled()
.submitLabel(.go)
.onChange(of: searchText) { _, newValue in
searchCompleter.update(query: newValue)
}
.onSubmit {
applyCoordinatesFromSearchText()
}
}
}
.alert(alertTitle, isPresented: $showAlert) {
@ -669,6 +1020,13 @@ struct LocationSimulationView: View {
refreshRoute()
}
}
.fileImporter(
isPresented: $showCoordinateImporter,
allowedContentTypes: CoordinateImportParser.supportedContentTypes,
allowsMultipleSelection: false
) { result in
importCoordinates(result)
}
.onAppear {
loadBookmarks()
}
@ -727,6 +1085,124 @@ struct LocationSimulationView: View {
}
}
private func applyCoordinatesFromSearchText() {
let importedCoordinates = CoordinateImportParser.parseInline(searchText)
guard !importedCoordinates.isEmpty else { return }
searchText = ""
searchCompleter.results = []
applyImportedCoordinates(importedCoordinates, sourceName: "Imported")
}
private func importCoordinates(_ result: Result<[URL], Error>) {
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))
}
}

View file

@ -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 {