mirror of
https://github.com/StikDebug/StikDebug.git
synced 2026-10-16 12:30:29 +00:00
322 lines
11 KiB
Swift
322 lines
11 KiB
Swift
//
|
|
// MainTabView.swift
|
|
// StikJIT
|
|
//
|
|
// Created by Stephen on 3/27/25.
|
|
//
|
|
|
|
import SwiftUI
|
|
import Foundation
|
|
|
|
extension Notification.Name {
|
|
static let switchToTab = Notification.Name("MainTabSwitchNotification")
|
|
}
|
|
|
|
private enum ExternalLocationAction: Identifiable {
|
|
case simulate(URL, Double, Double)
|
|
case clear
|
|
|
|
var id: String {
|
|
switch self {
|
|
case .simulate(let url, _, _):
|
|
return "simulate-\(url.absoluteString)"
|
|
case .clear:
|
|
return "clear-location"
|
|
}
|
|
}
|
|
|
|
var title: String {
|
|
switch self {
|
|
case .simulate:
|
|
return "Simulate Location?"
|
|
case .clear:
|
|
return "Clear Location?"
|
|
}
|
|
}
|
|
|
|
var message: String {
|
|
switch self {
|
|
case .simulate(_, let latitude, let longitude):
|
|
return String(format: "An external link wants to set the simulated location to %.6f, %.6f.", latitude, longitude)
|
|
case .clear:
|
|
return "An external link wants to clear the simulated location."
|
|
}
|
|
}
|
|
|
|
var confirmationTitle: String {
|
|
switch self {
|
|
case .simulate:
|
|
return "Set Location"
|
|
case .clear:
|
|
return "Clear Location"
|
|
}
|
|
}
|
|
}
|
|
|
|
struct MainTabView: View {
|
|
@AppStorage("primaryTabSelection") private var selection: String = AppFeature.home.id
|
|
@State private var switchObserver: Any?
|
|
@State private var detachedFeature: AppFeature?
|
|
@State private var didSetInitialHome = false
|
|
@State private var pendingLocationAction: ExternalLocationAction?
|
|
|
|
var body: some View {
|
|
ZStack {
|
|
Color.clear.ignoresSafeArea()
|
|
|
|
TabView(selection: $selection) {
|
|
ForEach(AppFeature.mainTabs) { feature in
|
|
feature.destination
|
|
.tabItem { Label(feature.title, systemImage: feature.systemImage) }
|
|
.tag(feature.id)
|
|
}
|
|
}
|
|
.onAppear {
|
|
ensureSelectionIsValid()
|
|
if !didSetInitialHome {
|
|
selection = AppFeature.home.id
|
|
didSetInitialHome = true
|
|
}
|
|
guard switchObserver == nil else { return }
|
|
switchObserver = NotificationCenter.default.addObserver(forName: .switchToTab, object: nil, queue: .main) { note in
|
|
guard let id = note.object as? String else { return }
|
|
openFeature(id: id)
|
|
}
|
|
}
|
|
.onDisappear {
|
|
if let observer = switchObserver {
|
|
NotificationCenter.default.removeObserver(observer)
|
|
switchObserver = nil
|
|
}
|
|
}
|
|
.onOpenURL { url in
|
|
handleURL(url)
|
|
}
|
|
.confirmationDialog(
|
|
pendingLocationAction?.title ?? "External Location Request",
|
|
isPresented: Binding(
|
|
get: { pendingLocationAction != nil },
|
|
set: { isPresented in
|
|
if !isPresented {
|
|
pendingLocationAction = nil
|
|
}
|
|
}
|
|
),
|
|
titleVisibility: .visible,
|
|
presenting: pendingLocationAction
|
|
) { action in
|
|
Button(action.confirmationTitle, role: .destructive) {
|
|
performLocationAction(action)
|
|
pendingLocationAction = nil
|
|
}
|
|
Button("Cancel", role: .cancel) {
|
|
pendingLocationAction = nil
|
|
}
|
|
} message: { action in
|
|
Text(action.message)
|
|
}
|
|
.sheet(item: $detachedFeature) { feature in
|
|
NavigationStack {
|
|
feature.destination
|
|
.toolbar {
|
|
ToolbarItem(placement: .cancellationAction) {
|
|
Button("Close") {
|
|
detachedFeature = nil
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func ensureSelectionIsValid() {
|
|
let ids = AppFeature.mainTabs.map { $0.id }
|
|
if ids.contains(selection) {
|
|
return
|
|
}
|
|
selection = AppFeature.home.id
|
|
}
|
|
|
|
private func handleURL(_ url: URL) {
|
|
guard let host = url.host()?.lowercased() else { return }
|
|
|
|
switch host {
|
|
case "simulate-location", "set-location":
|
|
confirmSimulatedLocation(from: url)
|
|
case "location", "location-simulation":
|
|
if coordinate(from: url) == nil {
|
|
openFeature(id: AppFeature.location.id)
|
|
} else {
|
|
confirmSimulatedLocation(from: url)
|
|
}
|
|
case "clear-location", "stop-location":
|
|
pendingLocationAction = .clear
|
|
default:
|
|
break
|
|
}
|
|
}
|
|
|
|
private func openFeature(id: String) {
|
|
guard let feature = AppFeature(rawValue: id) else {
|
|
return
|
|
}
|
|
|
|
if AppFeature.mainTabs.contains(feature) {
|
|
selection = feature.id
|
|
} else {
|
|
detachedFeature = feature
|
|
}
|
|
}
|
|
|
|
private func confirmSimulatedLocation(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
|
|
}
|
|
|
|
pendingLocationAction = .simulate(url, coordinate.latitude, coordinate.longitude)
|
|
}
|
|
|
|
private func performLocationAction(_ action: ExternalLocationAction) {
|
|
switch action {
|
|
case .simulate(let url, _, _):
|
|
simulateLocation(from: url)
|
|
case .clear:
|
|
clearSimulatedLocation()
|
|
}
|
|
}
|
|
|
|
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])
|
|
}
|
|
}
|
|
}
|
|
|
|
struct MainTabView_Previews: PreviewProvider {
|
|
static var previews: some View {
|
|
MainTabView()
|
|
}
|
|
}
|