mirror of
https://github.com/StikDebug/StikDebug.git
synced 2026-10-15 20:10:30 +00:00
3.1.3 WIP
This commit is contained in:
parent
78551b8592
commit
08830f1125
6 changed files with 701 additions and 89 deletions
|
|
@ -11,6 +11,7 @@
|
|||
<string>com.stik.StikJIT.enableJIT</string>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>stikdebug</string>
|
||||
<string>stikjit</string>
|
||||
</array>
|
||||
</dict>
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue