mirror of
https://github.com/StikDebug/StikDebug.git
synced 2026-10-15 12:02:14 +00:00
Push missing 3.1.3 commits and hopefully add better error clarity
This commit is contained in:
parent
8a7b39dbe2
commit
4b20cd3d1e
9 changed files with 459 additions and 96 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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? {
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue