From 4b20cd3d1efd8ef400b238b86a405067b9c4a8a0 Mon Sep 17 00:00:00 2001 From: Stephen <158498287+StephenDev0@users.noreply.github.com> Date: Wed, 20 May 2026 09:02:20 -0400 Subject: [PATCH] Push missing 3.1.3 commits and hopefully add better error clarity --- StikJIT/JSSupport/ScriptListView.swift | 19 +- StikJIT/StikJITApp.swift | 97 +++++++- .../Utilities/DeviceConnectionContext.swift | 8 +- StikJIT/Utilities/Extensions.swift | 77 ++++-- StikJIT/Views/HomeView.swift | 228 +++++++++++++----- StikJIT/Views/InstalledAppsListView.swift | 14 +- StikJIT/Views/MainTabView.swift | 102 +++++++- StikJIT/Views/MapSelectionView.swift | 3 +- StikJIT/Views/SettingsView.swift | 7 +- 9 files changed, 459 insertions(+), 96 deletions(-) diff --git a/StikJIT/JSSupport/ScriptListView.swift b/StikJIT/JSSupport/ScriptListView.swift index 95b08c8..0f0d8ae 100644 --- a/StikJIT/JSSupport/ScriptListView.swift +++ b/StikJIT/JSSupport/ScriptListView.swift @@ -239,8 +239,12 @@ struct ScriptListView: View { guard !newFileName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return } var filename = newFileName.trimmingCharacters(in: .whitespacesAndNewlines) if !filename.hasSuffix(".js") { filename += ".js" } + guard let filename = ScriptStore.normalizedScriptFileName(filename) else { + presentError(title: "Failed to Create New Script", message: "Use a simple .js filename without folders.") + return + } do { - let newURL = try scriptsDirectory().appendingPathComponent(filename) + let newURL = try ScriptStore.scriptURL(named: filename) guard !FileManager.default.fileExists(atPath: newURL.path) else { presentError(title: "Failed to Create New Script", message: "A script with the same name already exists.") return @@ -270,16 +274,25 @@ struct ScriptListView: View { isBusy = true DispatchQueue.global(qos: .userInitiated).async { defer { DispatchQueue.main.async { self.isBusy = false } } + let accessing = fileURL.startAccessingSecurityScopedResource() + defer { + if accessing { + fileURL.stopAccessingSecurityScopedResource() + } + } do { + guard let fileName = ScriptStore.normalizedScriptFileName(fileURL.lastPathComponent) else { + throw CocoaError(.fileReadInvalidFileName) + } let directory = try self.scriptsDirectory() - let dest = directory.appendingPathComponent(fileURL.lastPathComponent) + let dest = directory.appendingPathComponent(fileName) if FileManager.default.fileExists(atPath: dest.path) { try FileManager.default.removeItem(at: dest) } try FileManager.default.copyItem(at: fileURL, to: dest) DispatchQueue.main.async { self.loadScripts() - self.presentSuccess(title: "Imported", message: fileURL.lastPathComponent) + self.presentSuccess(title: "Imported", message: fileName) } } catch { DispatchQueue.main.async { diff --git a/StikJIT/StikJITApp.swift b/StikJIT/StikJITApp.swift index c3c880a..2de744a 100644 --- a/StikJIT/StikJITApp.swift +++ b/StikJIT/StikJITApp.swift @@ -317,7 +317,7 @@ func startTunnelInBackground(showErrorUI: Bool = true) { } catch { let err2 = error as NSError let code = err2.code - LogManager.shared.addErrorLog("\(error.localizedDescription) (Code: \(code))") + LogManager.shared.addErrorLog(tunnelConnectionLogMessage(for: err2)) guard showErrorUI else { return } DispatchQueue.main.async { if code == -9 { @@ -340,7 +340,7 @@ func startTunnelInBackground(showErrorUI: Bool = true) { } else { showAlert( title: "Connection Error", - message: "\(error.localizedDescription)\n\nMake sure Wi‑Fi and LocalDevVPN are connected and that the device is reachable.", + message: tunnelConnectionAlertMessage(for: err2), showOk: false, showTryAgain: true ) { shouldTryAgain in @@ -358,6 +358,82 @@ func startTunnelInBackground(showErrorUI: Bool = true) { } +private func tunnelConnectionLogMessage(for error: NSError) -> String { + let target = "\(DeviceConnectionContext.targetIPAddress):49152" + return "Tunnel connection failed for \(target): \(error.localizedDescription) (Domain: \(error.domain), Code: \(error.code), Raw: \(String(describing: error)))" +} + +private func tunnelConnectionAlertMessage(for error: NSError) -> String { + let targetIP = DeviceConnectionContext.targetIPAddress + let rawMessage = error.localizedDescription + let lowercasedMessage = rawMessage.lowercased() + + let likelyCause: String + let recoverySteps: [String] + + if error.code == 48 || lowercasedMessage.contains("address already in use") || lowercasedMessage.contains("port already in use") { + likelyCause = "A port needed for the tunnel is already in use." + recoverySteps = [ + "Close other JIT, debugging, proxy, or VPN apps that may be using the tunnel.", + "Disconnect and reconnect LocalDevVPN.", + "Restart StikDebug, then try again.", + "If it keeps happening, reboot the device to clear the stuck port." + ] + } else if error.code == 54 || lowercasedMessage.contains("connection reset") { + likelyCause = "The device or VPN closed the tunnel connection before setup finished." + recoverySteps = [ + "Open LocalDevVPN and confirm the VPN is connected.", + "Make sure LocalDevVPN is using the default \(DeviceConnectionContext.defaultTargetIPAddress) address.", + "Reconnect Wi-Fi and LocalDevVPN, then try again.", + "If this keeps happening, select a fresh pairing file." + ] + } else if error.code == -18 || lowercasedMessage.contains("parse target ip") { + likelyCause = "The configured target IP address is not valid." + recoverySteps = [ + "Open Settings and check the target IP address.", + "Use the default \(DeviceConnectionContext.defaultTargetIPAddress)." + ] + } else if lowercasedMessage.contains("timed out") || lowercasedMessage.contains("timeout") { + likelyCause = "The app could not reach the device before the connection timed out." + recoverySteps = [ + "Confirm Wi-Fi and LocalDevVPN are both connected.", + "Wake and unlock the target device.", + "Confirm LocalDevVPN is exposing the device at \(targetIP)." + ] + } else if lowercasedMessage.contains("network is unreachable") || lowercasedMessage.contains("no route") { + likelyCause = "The VPN route to the device is not available." + recoverySteps = [ + "Disconnect and reconnect LocalDevVPN.", + "Confirm iOS shows the VPN indicator.", + "Try switching Wi-Fi off and on." + ] + } else { + likelyCause = "The tunnel could not be created." + recoverySteps = [ + "Confirm Wi-Fi and LocalDevVPN are connected.", + "Wake and unlock the target device.", + "Reconnect LocalDevVPN, then try again." + ] + } + + let steps = recoverySteps.enumerated() + .map { "\($0.offset + 1). \($0.element)" } + .joined(separator: "\n") + + return """ + \(likelyCause) + + Target: \(targetIP):49152 + Expected LocalDevVPN IP: \(DeviceConnectionContext.defaultTargetIPAddress) + + Try this: + \(steps) + + Technical details: + Code \(error.code): \(rawMessage) + """ +} + func checkDeviceConnection(callback: @escaping (Bool, String?) -> Void) { let targetIP = DeviceConnectionContext.targetIPAddress let host = NWEndpoint.Host(targetIP) @@ -463,20 +539,33 @@ private let ddiDownloadItems: [DDIDownloadItem] = [ enum DDIDownloadError: LocalizedError { case invalidURL(String) + case invalidResponse + case badStatus(Int) var errorDescription: String? { switch self { case .invalidURL(let string): return "Invalid download URL: \(string)" + case .invalidResponse: + return "The DDI server returned an invalid response." + case .badStatus(let statusCode): + return "The DDI server returned HTTP \(statusCode)." } } } func downloadFile(from urlString: String, to destinationURL: URL) async throws { - guard let url = URL(string: urlString) else { + guard let url = URL(string: urlString), + url.scheme?.lowercased() == "https" else { throw DDIDownloadError.invalidURL(urlString) } - let (tempLocalUrl, _) = try await URLSession.shared.download(from: url) + let (tempLocalUrl, response) = try await URLSession.shared.download(from: url) + guard let httpResponse = response as? HTTPURLResponse else { + throw DDIDownloadError.invalidResponse + } + guard (200..<300).contains(httpResponse.statusCode) else { + throw DDIDownloadError.badStatus(httpResponse.statusCode) + } let fileManager = FileManager.default try fileManager.createDirectory(at: destinationURL.deletingLastPathComponent(), withIntermediateDirectories: true) diff --git a/StikJIT/Utilities/DeviceConnectionContext.swift b/StikJIT/Utilities/DeviceConnectionContext.swift index 6439e80..e765160 100644 --- a/StikJIT/Utilities/DeviceConnectionContext.swift +++ b/StikJIT/Utilities/DeviceConnectionContext.swift @@ -8,11 +8,9 @@ import Foundation enum DeviceConnectionContext { + static let defaultTargetIPAddress = "10.7.0.1" + static var targetIPAddress: String { - let stored = UserDefaults.standard.string(forKey: "customTargetIP") - if let stored, !stored.isEmpty { - return stored - } - return "10.7.0.1" + defaultTargetIPAddress } } diff --git a/StikJIT/Utilities/Extensions.swift b/StikJIT/Utilities/Extensions.swift index fb081f6..762d5b5 100644 --- a/StikJIT/Utilities/Extensions.swift +++ b/StikJIT/Utilities/Extensions.swift @@ -26,28 +26,43 @@ enum PairingFileStore { ] static var url: URL { - URL.documentsDirectory.appendingPathComponent(fileName) + directoryURL.appendingPathComponent(fileName) } - private static var legacyURL: URL { - URL.documentsDirectory.appendingPathComponent(legacyFileName) + private static var directoryURL: URL { + FileManager.default + .urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] + .appendingPathComponent("Pairing", isDirectory: true) + } + + private static var legacyURLs: [URL] { + [ + URL.documentsDirectory.appendingPathComponent(fileName), + URL.documentsDirectory.appendingPathComponent(legacyFileName) + ] } @discardableResult static func prepareURL(fileManager: FileManager = .default) -> URL { let destination = url - guard !fileManager.fileExists(atPath: destination.path), - fileManager.fileExists(atPath: legacyURL.path) else { + try? fileManager.createDirectory(at: directoryURL, withIntermediateDirectories: true) + + guard !fileManager.fileExists(atPath: destination.path) else { + removeLegacyCopies(fileManager: fileManager) return destination } - do { - try fileManager.moveItem(at: legacyURL, to: destination) - } catch { - if let data = try? Data(contentsOf: legacyURL) { - try? data.write(to: destination, options: .atomic) - try? fileManager.removeItem(at: legacyURL) + for legacyURL in legacyURLs where fileManager.fileExists(atPath: legacyURL.path) { + do { + try fileManager.moveItem(at: legacyURL, to: destination) + } catch { + if let data = try? Data(contentsOf: legacyURL) { + try? data.write(to: destination, options: .atomic) + try? fileManager.removeItem(at: legacyURL) + } } + try? fileManager.setAttributes([.posixPermissions: 0o600], ofItemAtPath: destination.path) + break } return destination @@ -58,9 +73,7 @@ enum PairingFileStore { if fileManager.fileExists(atPath: destination.path) { try fileManager.removeItem(at: destination) } - if fileManager.fileExists(atPath: legacyURL.path) { - try? fileManager.removeItem(at: legacyURL) - } + removeLegacyCopies(fileManager: fileManager) try fileManager.copyItem(at: sourceURL, to: destination) try? fileManager.setAttributes([.posixPermissions: 0o600], ofItemAtPath: destination.path) } @@ -85,7 +98,11 @@ enum PairingFileStore { if fileManager.fileExists(atPath: destination.path) { try fileManager.removeItem(at: destination) } - if fileManager.fileExists(atPath: legacyURL.path) { + removeLegacyCopies(fileManager: fileManager) + } + + private static func removeLegacyCopies(fileManager: FileManager) { + for legacyURL in legacyURLs where fileManager.fileExists(atPath: legacyURL.path) { try? fileManager.removeItem(at: legacyURL) } } @@ -131,6 +148,9 @@ enum ScriptStore { } static func scriptURL(named scriptName: String, fileManager: FileManager = .default) throws -> URL { + guard let scriptName = normalizedScriptFileName(scriptName) else { + throw CocoaError(.fileReadInvalidFileName) + } let directory = try prepareDirectory(fileManager: fileManager) return directory.appendingPathComponent(scriptName) } @@ -141,8 +161,8 @@ enum ScriptStore { static func updateAssignedScriptName(_ scriptName: String?, for bundleID: String, defaults: UserDefaults = .standard) { var mapping = assignedScriptMap(defaults: defaults) - if let scriptName { - mapping[bundleID] = scriptName + if let scriptName, let normalizedName = normalizedScriptFileName(scriptName) { + mapping[bundleID] = normalizedName } else { mapping.removeValue(forKey: bundleID) } @@ -208,7 +228,28 @@ enum ScriptStore { } private static func assignedScriptMap(defaults: UserDefaults) -> [String: String] { - defaults.dictionary(forKey: assignmentKey) as? [String: String] ?? [:] + let rawMap = defaults.dictionary(forKey: assignmentKey) as? [String: String] ?? [:] + return rawMap.compactMapValues(normalizedScriptFileName) + } + + static func normalizedScriptFileName(_ candidate: String) -> String? { + let trimmed = candidate.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty, + !trimmed.contains("/"), + !trimmed.contains("\\"), + !trimmed.contains("\0") else { + return nil + } + + let fileName = URL(fileURLWithPath: trimmed).lastPathComponent + guard fileName == trimmed, + fileName != ".", + fileName != "..", + URL(fileURLWithPath: fileName).pathExtension.lowercased() == "js" else { + return nil + } + + return fileName } } diff --git a/StikJIT/Views/HomeView.swift b/StikJIT/Views/HomeView.swift index eb5e775..d099e04 100644 --- a/StikJIT/Views/HomeView.swift +++ b/StikJIT/Views/HomeView.swift @@ -15,6 +15,78 @@ struct JITEnableConfiguration { var scriptName : String? = nil } +private enum ExternalURLAction: Identifiable { + case enableJIT(JITEnableConfiguration) + case killProcess(Int) + case launchApp(String) + + var id: String { + switch self { + case .enableJIT(let config): + return "enable-\(config.bundleID ?? "")-\(config.pid ?? 0)-\(config.scriptName ?? "")" + case .killProcess(let pid): + return "kill-\(pid)" + case .launchApp(let bundleID): + return "launch-\(bundleID)" + } + } + + var title: String { + switch self { + case .enableJIT: + return "Enable JIT?" + case .killProcess: + return "Kill Process?" + case .launchApp: + return "Launch App?" + } + } + + var message: String { + switch self { + case .enableJIT(let config): + let scriptText = config.scriptData == nil ? "" : " and run a script" + return "An external link wants to enable JIT\(scriptText) for \(targetDescription(for: config))." + case .killProcess(let pid): + return "An external link wants to kill process \(pid)." + case .launchApp(let bundleID): + return "An external link wants to launch \(bundleID)." + } + } + + var confirmationTitle: String { + switch self { + case .enableJIT(let config): + return config.scriptData == nil ? "Enable JIT" : "Enable and Run Script" + case .killProcess: + return "Kill Process" + case .launchApp: + return "Launch App" + } + } + + var role: ButtonRole? { + switch self { + case .enableJIT(let config): + return config.scriptData == nil ? nil : .destructive + case .killProcess: + return .destructive + case .launchApp: + return nil + } + } + + private func targetDescription(for config: JITEnableConfiguration) -> String { + if let bundleID = config.bundleID { + return bundleID + } + if let pid = config.pid { + return "process \(pid)" + } + return "the requested app" + } +} + private final class DebugKeepAliveLease { private let stateLock = NSLock() private var isActive = false @@ -81,6 +153,7 @@ struct HomeView: View { @State private var pendingJITEnableConfiguration : JITEnableConfiguration? = nil @State private var isShowingPairingFilePicker = false @State private var debugFeedback: DebugFeedback? + @State private var pendingExternalURLAction: ExternalURLAction? @State var scriptViewShow = false @State private var isShowingConsole = false @@ -133,64 +206,30 @@ struct HomeView: View { } } .onOpenURL { url in - 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 = queryValue(["pid"], in: components), let pid = Int(pidStr) { - config.pid = pid - } - if let bundleId = queryValue(["bundle-id", "bundleID", "bundle_id", "bundleId"], in: components) { - config.bundleID = bundleId - } - 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 + handleExternalURL(url) + } + .confirmationDialog( + pendingExternalURLAction?.title ?? "External Request", + isPresented: Binding( + get: { pendingExternalURLAction != nil }, + set: { isPresented in + if !isPresented { + pendingExternalURLAction = nil } } - if let scriptName = queryValue(["script-name", "scriptName", "script_name"], in: components) { - config.scriptName = scriptName - } - if config.scriptData == nil, let bundleID = config.bundleID, - let scriptInfo = ScriptStore.preferredScript(for: bundleID) { - config.scriptData = scriptInfo.data - config.scriptName = scriptInfo.name - } - if viewDidAppeared { - startJITInBackground(bundleID: config.bundleID, pid: config.pid, scriptData: config.scriptData, scriptName: config.scriptName, triggeredByURLScheme: true) - } else { - pendingJITEnableConfiguration = config - } - case "kill-process": - if let pidStr = queryValue(["pid"], in: components), let pid = Int(pidStr) { - pubTunnelConnected = false - startTunnelInBackground(showErrorUI: false) - DispatchQueue.global(qos: .userInitiated).async { - sleep(1) - do { - try JITEnableContext.shared.killProcess(withPID: Int32(pid)) - DispatchQueue.main.async { - LogManager.shared.addInfoLog("Killed process \(pid) via URL scheme") - } - } catch { - DispatchQueue.main.async { - LogManager.shared.addErrorLog("Failed to kill process \(pid): \(error.localizedDescription)") - } - } - } - } - case "launch-app": - 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) - } - } - default: - break + ), + titleVisibility: .visible, + presenting: pendingExternalURLAction + ) { action in + Button(action.confirmationTitle, role: action.role) { + performExternalURLAction(action) + pendingExternalURLAction = nil } + Button("Cancel", role: .cancel) { + pendingExternalURLAction = nil + } + } message: { action in + Text(action.message) } .fileImporter(isPresented: $isShowingPairingFilePicker, allowedContentTypes: PairingFileStore.supportedContentTypes) { result in switch result { @@ -256,6 +295,85 @@ struct HomeView: View { return nil } + private func handleExternalURL(_ url: URL) { + 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 = queryValue(["pid"], in: components), let pid = Int(pidStr) { + config.pid = pid + } + if let bundleId = queryValue(["bundle-id", "bundleID", "bundle_id", "bundleId"], in: components) { + config.bundleID = bundleId + } + 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 = queryValue(["script-name", "scriptName", "script_name"], in: components) { + config.scriptName = scriptName + } + if config.scriptData == nil, let bundleID = config.bundleID, + let scriptInfo = ScriptStore.preferredScript(for: bundleID) { + config.scriptData = scriptInfo.data + config.scriptName = scriptInfo.name + } + pendingExternalURLAction = .enableJIT(config) + case "kill-process": + if let pidStr = queryValue(["pid"], in: components), let pid = Int(pidStr) { + pendingExternalURLAction = .killProcess(pid) + } + case "launch-app": + if let bundleId = queryValue(["bundle-id", "bundleID", "bundle_id", "bundleId"], in: components) { + pendingExternalURLAction = .launchApp(bundleId) + } + default: + break + } + } + + private func performExternalURLAction(_ action: ExternalURLAction) { + switch action { + case .enableJIT(let config): + if viewDidAppeared { + startJITInBackground( + bundleID: config.bundleID, + pid: config.pid, + scriptData: config.scriptData, + scriptName: config.scriptName, + triggeredByURLScheme: true + ) + } else { + pendingJITEnableConfiguration = config + } + case .killProcess(let pid): + pubTunnelConnected = false + startTunnelInBackground(showErrorUI: false) + DispatchQueue.global(qos: .userInitiated).async { + sleep(1) + do { + try JITEnableContext.shared.killProcess(withPID: Int32(pid)) + DispatchQueue.main.async { + LogManager.shared.addInfoLog("Killed process \(pid) via URL scheme") + } + } catch { + DispatchQueue.main.async { + LogManager.shared.addErrorLog("Failed to kill process \(pid): \(error.localizedDescription)") + } + } + } + case .launchApp(let bundleID): + HapticFeedbackHelper.trigger() + DispatchQueue.global(qos: .userInitiated).async { + let _ = JITEnableContext.shared.launchAppWithoutDebug(bundleID, logger: nil) + } + } + } + private func debugFeedbackView(_ feedback: DebugFeedback) -> some View { HStack(spacing: 10) { if feedback.isWorking { diff --git a/StikJIT/Views/InstalledAppsListView.swift b/StikJIT/Views/InstalledAppsListView.swift index 6065014..30f7301 100644 --- a/StikJIT/Views/InstalledAppsListView.swift +++ b/StikJIT/Views/InstalledAppsListView.swift @@ -1060,13 +1060,25 @@ enum AppIconRepository { guard let container = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: appGroupIdentifier) else { return nil } + guard let fileName = cacheFileName(for: bundleID) else { + return nil + } let directory = container.appendingPathComponent("icons", isDirectory: true) do { try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) } catch { return nil } - return directory.appendingPathComponent("\(bundleID).png") + return directory.appendingPathComponent(fileName) + } + + private static func cacheFileName(for bundleID: String) -> String? { + let allowed = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: ".-_")) + let sanitized = String(bundleID.unicodeScalars.map { scalar in + allowed.contains(scalar) ? Character(scalar) : "_" + }) + guard !sanitized.isEmpty else { return nil } + return "\(sanitized).png" } private static func memoryCost(for image: UIImage) -> Int { diff --git a/StikJIT/Views/MainTabView.swift b/StikJIT/Views/MainTabView.swift index 81f85e9..8eb04e2 100644 --- a/StikJIT/Views/MainTabView.swift +++ b/StikJIT/Views/MainTabView.swift @@ -12,11 +12,53 @@ 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 { @@ -50,6 +92,29 @@ struct MainTabView: View { .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 @@ -78,15 +143,15 @@ struct MainTabView: View { switch host { case "simulate-location", "set-location": - simulateLocation(from: url) + confirmSimulatedLocation(from: url) case "location", "location-simulation": if coordinate(from: url) == nil { openFeature(id: AppFeature.location.id) } else { - simulateLocation(from: url) + confirmSimulatedLocation(from: url) } case "clear-location", "stop-location": - clearSimulatedLocation() + pendingLocationAction = .clear default: break } @@ -104,6 +169,37 @@ struct MainTabView: View { } } + 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( diff --git a/StikJIT/Views/MapSelectionView.swift b/StikJIT/Views/MapSelectionView.swift index d77a3f4..69b8f6f 100644 --- a/StikJIT/Views/MapSelectionView.swift +++ b/StikJIT/Views/MapSelectionView.swift @@ -776,8 +776,7 @@ struct LocationSimulationView: View { } private var deviceIP: String { - let stored = UserDefaults.standard.string(forKey: "customTargetIP") ?? "" - return stored.isEmpty ? "10.7.0.1" : stored + DeviceConnectionContext.targetIPAddress } private var routeStartCoordinate: CLLocationCoordinate2D? { diff --git a/StikJIT/Views/SettingsView.swift b/StikJIT/Views/SettingsView.swift index 41f31c8..ae82443 100644 --- a/StikJIT/Views/SettingsView.swift +++ b/StikJIT/Views/SettingsView.swift @@ -17,7 +17,6 @@ struct SettingsView: View { @AppStorage(UserDefaults.Keys.txmOverride) private var overrideTXMDetection = false @AppStorage("keepAliveAudio") private var keepAliveAudio = true @AppStorage("keepAliveLocation") private var keepAliveLocation = true - @AppStorage("customTargetIP") private var customTargetIP = "" @State private var isShowingPairingFilePicker = false @State private var isImportingFile = false @@ -126,10 +125,8 @@ struct SettingsView: View { HStack { Text("Target Device IP") Spacer() - TextField("10.7.0.1", text: $customTargetIP) - .multilineTextAlignment(.trailing) - .keyboardType(.numbersAndPunctuation) - .submitLabel(.done) + Text(DeviceConnectionContext.defaultTargetIPAddress) + .foregroundStyle(.secondary) } Button { openAppFolder() } label: { Label("App Folder", systemImage: "folder")