diff --git a/StikDebug.xcodeproj/project.pbxproj b/StikDebug.xcodeproj/project.pbxproj index 1a400a0e..575eb3fa 100644 --- a/StikDebug.xcodeproj/project.pbxproj +++ b/StikDebug.xcodeproj/project.pbxproj @@ -448,14 +448,15 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = XXK735HX49; + DEVELOPMENT_TEAM = ""; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; - FRAMEWORK_SEARCH_PATHS = ( + FRAMEWORK_SEARCH_PATHS = "$(inherited)"; + GENERATE_INFOPLIST_FILE = YES; + HEADER_SEARCH_PATHS = ( "$(inherited)", - "$(PROJECT_DIR)/StikJIT/Sources", + "$(PROJECT_DIR)/StikJIT/idevice", ); - GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = StikJIT/Info.plist; INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO; INFOPLIST_KEY_LSSupportsOpeningDocumentsInPlace = YES; @@ -490,7 +491,7 @@ SUPPORTS_MACCATALYST = NO; SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; SWIFT_EMIT_LOC_STRINGS = YES; - SWIFT_OBJC_BRIDGING_HEADER = "StikJIT/StikJIT-Bridging-Header.h"; + SWIFT_INCLUDE_PATHS = "$(inherited) $(PROJECT_DIR)/StikJIT/idevice"; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; @@ -509,14 +510,15 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = XXK735HX49; + DEVELOPMENT_TEAM = ""; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; - FRAMEWORK_SEARCH_PATHS = ( + FRAMEWORK_SEARCH_PATHS = "$(inherited)"; + GENERATE_INFOPLIST_FILE = YES; + HEADER_SEARCH_PATHS = ( "$(inherited)", - "$(PROJECT_DIR)/StikJIT/Sources", + "$(PROJECT_DIR)/StikJIT/idevice", ); - GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = StikJIT/Info.plist; INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO; INFOPLIST_KEY_LSSupportsOpeningDocumentsInPlace = YES; @@ -551,7 +553,7 @@ SUPPORTS_MACCATALYST = NO; SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; SWIFT_EMIT_LOC_STRINGS = YES; - SWIFT_OBJC_BRIDGING_HEADER = "StikJIT/StikJIT-Bridging-Header.h"; + SWIFT_INCLUDE_PATHS = "$(inherited) $(PROJECT_DIR)/StikJIT/idevice"; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; XROS_DEPLOYMENT_TARGET = 2.2; diff --git a/StikJIT/JSSupport/IDeviceJSBridgeDebugProxy.m b/StikJIT/JSSupport/IDeviceJSBridgeDebugProxy.m deleted file mode 100644 index c8a155ea..00000000 --- a/StikJIT/JSSupport/IDeviceJSBridgeDebugProxy.m +++ /dev/null @@ -1,126 +0,0 @@ -// -// IDeviceJSBridgeDebugProxy.m -// StikJIT -// -// Created by s s on 2025/4/25. -// -@import Foundation; -@import JavaScriptCore; -#import "JSSupport.h" -#import "../idevice/JITEnableContext.h" -#import "../idevice/idevice.h" -#include "../idevice/jit.h" - -NSString* handleJSContextSendDebugCommand(JSContext* context, NSString* commandStr, DebugProxyHandle* debugProxy) { - DebugserverCommandHandle* command = 0; - - command = debugserver_command_new([commandStr UTF8String], NULL, 0); - - char* attach_response = 0; - IdeviceFfiError* err = debug_proxy_send_command(debugProxy, command, &attach_response); - debugserver_command_free(command); - if (err) { - context.exception = [JSValue valueWithObject:[NSString stringWithFormat:@"error code %d, msg %s", err->code, err->message] inContext:context]; - idevice_error_free(err); - return nil; - } - NSString* commandResponse = nil; - if(attach_response) { - commandResponse = @(attach_response); - } - idevice_string_free(attach_response); - return commandResponse; -} - -// 0 <= val <= 15 -char u8toHexChar(uint8_t val) { - if(val < 10) { - return val + '0'; - } else { - return val + 87; - } -} - -void calcAndWriteCheckSum(char* commandStart) { - uint8_t sum = 0; - char* cur = commandStart; - for(; *cur != '#'; ++cur) { - sum += *cur; - } - cur[1] = u8toHexChar((sum & 0xf0) >> 4); - cur[2] = u8toHexChar(sum & 0xf); -} - -// support up to 9 digit -void writeAddress(char* writeStart, uint64_t addr) { - writeStart[0] = u8toHexChar((addr & 0xf00000000) >> 32); - writeStart[1] = u8toHexChar((addr & 0xf0000000) >> 28); - writeStart[2] = u8toHexChar((addr & 0xf000000) >> 24); - writeStart[3] = u8toHexChar((addr & 0xf00000) >> 20); - writeStart[4] = u8toHexChar((addr & 0xf0000) >> 16); - writeStart[5] = u8toHexChar((addr & 0xf000) >> 12); - writeStart[6] = u8toHexChar((addr & 0xf00) >> 8); - writeStart[7] = u8toHexChar((addr & 0xf0) >> 4); - writeStart[8] = u8toHexChar((addr & 0xf)); -} - -// you need to free generated buffer -char* getBulkMemWriteCommand(uint64_t startAddr, uint64_t JITPagesSize, uint32_t* commandCountOut, uint32_t* bufferLengthOut) { - // $M10c128000,1:69#12 - uint32_t commandCount = (uint32_t)(JITPagesSize >> 14); - uint32_t commandBufferSize = commandCount * 19; - *commandCountOut = commandCount; - *bufferLengthOut = commandBufferSize; - char* buffer = malloc(commandBufferSize + 1); - char* bufferEnd = buffer + commandBufferSize; - buffer[commandBufferSize] = 0; - - uint64_t curAddr = startAddr; - for(char* curBufferPtr = buffer; curBufferPtr < bufferEnd; curBufferPtr += 19) { - curBufferPtr[0] = '$'; - curBufferPtr[1] = 'M'; - curBufferPtr[11] = ','; - curBufferPtr[12] = '1'; - curBufferPtr[13] = ':'; - curBufferPtr[14] = '6'; - curBufferPtr[15] = '9'; - curBufferPtr[16] = '#'; - writeAddress(curBufferPtr + 2, curAddr); - calcAndWriteCheckSum(curBufferPtr + 1); - curAddr += 16384; - } - return buffer; -} - -NSString* handleJITPageWrite(JSContext* context, uint64_t startAddr, uint64_t JITPagesSize, DebugProxyHandle* debugProxy) { - uint32_t bufferLength = 0; - uint32_t commandCount = 0; - char* commandBuffer = getBulkMemWriteCommand(startAddr, JITPagesSize, &commandCount, &bufferLength); - // we send 1024 commands at a time - for(int curCommand = 0; curCommand < commandCount; curCommand += 1024) { - uint32_t commandsToSend = (commandCount - curCommand > 1024) ? 1024 : (commandCount - curCommand); - IdeviceFfiError* err = debug_proxy_send_raw(debugProxy, (const uint8_t *)commandBuffer + curCommand * 19, commandsToSend * 19); - if(err) { - context.exception = [JSValue valueWithObject:[NSString stringWithFormat:@"error code %d, msg %s", err->code, err->message] inContext:context]; - free(commandBuffer); - idevice_error_free(err); - return nil; - } - - for(int i = 0; i < commandsToSend; ++i) { - char* response = 0; - IdeviceFfiError* err = debug_proxy_read_response(debugProxy, &response); - if(response) { - idevice_string_free(response); - } - if(err) { - context.exception = [JSValue valueWithObject:[NSString stringWithFormat:@"error code %d, msg %s", err->code, err->message] inContext:context]; - free(commandBuffer); - idevice_error_free(err); - return nil; - } - } - } - free(commandBuffer); - return @"OK"; -} diff --git a/StikJIT/JSSupport/JSDebugSupport.swift b/StikJIT/JSSupport/JSDebugSupport.swift new file mode 100644 index 00000000..8a3b1669 --- /dev/null +++ b/StikJIT/JSSupport/JSDebugSupport.swift @@ -0,0 +1,161 @@ +// +// JSDebugSupport.swift +// StikDebug +// +// Created by Stephen on 2026/3/30. +// + +import Foundation +import JavaScriptCore +import idevice + +private func jsException(_ message: String, in context: JSContext?) { + guard let context else { return } + context.exception = JSValue(object: message, in: context) +} + +private func describeIdeviceError(_ ffiError: UnsafeMutablePointer) -> String { + if let message = ffiError.pointee.message { + return "error code \(ffiError.pointee.code), msg \(String(cString: message))" + } + return "error code \(ffiError.pointee.code)" +} + +func handleJSContextSendDebugCommand(_ context: JSContext?, _ commandStr: String, _ debugProxy: OpaquePointer?) -> String? { + guard let debugProxy else { + jsException("debug proxy is unavailable", in: context) + return nil + } + + guard let command = debugserver_command_new(commandStr, nil, 0) else { + jsException("failed to allocate debugserver command", in: context) + return nil + } + + var response: UnsafeMutablePointer? + let ffiError = debug_proxy_send_command(debugProxy, command, &response) + debugserver_command_free(command) + + if let ffiError { + jsException(describeIdeviceError(ffiError), in: context) + idevice_error_free(ffiError) + if let response { + idevice_string_free(response) + } + return nil + } + + defer { + if let response { + idevice_string_free(response) + } + } + + guard let response else { return nil } + return String(cString: response) +} + +private func hexCharacter(for value: UInt8) -> UInt8 { + if value < 10 { + return value + Character("0").asciiValue! + } + return value + 87 +} + +private func fillAddress(into buffer: inout [UInt8], at index: Int, address: UInt64) { + let masks: [UInt64] = [ + 0xf00000000, + 0x0f0000000, + 0x00f000000, + 0x000f00000, + 0x0000f0000, + 0x00000f000, + 0x000000f00, + 0x0000000f0, + 0x00000000f, + ] + + for (offset, mask) in masks.enumerated() { + let shift = UInt64((masks.count - 1 - offset) * 4) + let nibble = UInt8((address & mask) >> shift) + buffer[index + offset] = hexCharacter(for: nibble) + } +} + +private func writeChecksum(into buffer: inout [UInt8], at startIndex: Int) { + var checksum: UInt8 = 0 + var index = startIndex + while buffer[index] != Character("#").asciiValue! { + checksum &+= buffer[index] + index += 1 + } + + buffer[index + 1] = hexCharacter(for: (checksum & 0xf0) >> 4) + buffer[index + 2] = hexCharacter(for: checksum & 0x0f) +} + +private func makeBulkWriteCommands(startAddress: UInt64, pageSize: UInt64) -> [UInt8] { + let commandCount = Int(pageSize >> 14) + var buffer = [UInt8](repeating: 0, count: commandCount * 19) + + var currentAddress = startAddress + for commandIndex in 0.. String? { + guard let debugProxy else { + jsException("debug proxy is unavailable", in: context) + return nil + } + + let commandBuffer = makeBulkWriteCommands(startAddress: startAddr, pageSize: jitPagesSize) + let commandCount = Int(jitPagesSize >> 14) + let commandsPerBatch = 128 + + for batchStart in stride(from: 0, to: commandCount, by: commandsPerBatch) { + let commandsToSend = min(commandsPerBatch, commandCount - batchStart) + let byteOffset = batchStart * 19 + let byteCount = commandsToSend * 19 + + let ffiError = commandBuffer.withUnsafeBytes { rawBuffer -> UnsafeMutablePointer? in + let baseAddress = rawBuffer.bindMemory(to: UInt8.self).baseAddress! + return debug_proxy_send_raw(debugProxy, baseAddress.advanced(by: byteOffset), UInt(byteCount)) + } + + if let ffiError { + jsException(describeIdeviceError(ffiError), in: context) + idevice_error_free(ffiError) + return nil + } + + for _ in 0..? + let ffiError = debug_proxy_read_response(debugProxy, &response) + if let response { + idevice_string_free(response) + } + if let ffiError { + jsException(describeIdeviceError(ffiError), in: context) + idevice_error_free(ffiError) + return nil + } + } + } + + return "OK" +} diff --git a/StikJIT/JSSupport/JSSupport.h b/StikJIT/JSSupport/JSSupport.h deleted file mode 100644 index 13a4cc40..00000000 --- a/StikJIT/JSSupport/JSSupport.h +++ /dev/null @@ -1,12 +0,0 @@ -// -// JSSupport.h -// StikJIT -// -// Created by s s on 2025/4/24. -// -@import WebKit; -@import JavaScriptCore; -#include "../idevice/jit.h" - -NSString* handleJSContextSendDebugCommand(JSContext* context, NSString* commandStr, DebugProxyHandle* debugProxy); -NSString* handleJITPageWrite(JSContext* context, uint64_t startAddr, uint64_t JITPagesSize, DebugProxyHandle* debugProxy); diff --git a/StikJIT/JSSupport/RunJSView.swift b/StikJIT/JSSupport/RunJSView.swift index f9b37d25..6548d22a 100644 --- a/StikJIT/JSSupport/RunJSView.swift +++ b/StikJIT/JSSupport/RunJSView.swift @@ -7,11 +7,12 @@ import SwiftUI import JavaScriptCore +import idevice typealias RemoteServerHandle = OpaquePointer typealias ScreenshotClientHandle = OpaquePointer -class RunJSViewModel: ObservableObject { +final class RunJSViewModel: ObservableObject, @unchecked Sendable { var context: JSContext? @Published var logs: [String] = [] @Published var scriptName: String = "Script" @@ -19,9 +20,9 @@ class RunJSViewModel: ObservableObject { var pid: Int var debugProxy: OpaquePointer? var remoteServer: OpaquePointer? - var semaphore: dispatch_semaphore_t? + var semaphore: DispatchSemaphore? - init(pid: Int, debugProxy: OpaquePointer?, remoteServer: OpaquePointer?, semaphore: dispatch_semaphore_t?) { + init(pid: Int, debugProxy: OpaquePointer?, remoteServer: OpaquePointer?, semaphore: DispatchSemaphore?) { self.pid = pid self.debugProxy = debugProxy self.remoteServer = remoteServer @@ -166,7 +167,7 @@ class RunJSViewModel: ObservableObject { private func sanitizedScreenshotName(from preferredName: String?) -> String { let defaultName = "screenshot-\(Int(Date().timeIntervalSince1970))" - guard var candidate = preferredName?.trimmingCharacters(in: .whitespacesAndNewlines), + guard let candidate = preferredName?.trimmingCharacters(in: .whitespacesAndNewlines), !candidate.isEmpty else { return "\(defaultName).png" } @@ -215,7 +216,7 @@ struct RunJSView: View { } } .navigationTitle("Running \(model.scriptName)") - .onChange(of: model.logs.count) { newCount in + .onChange(of: model.logs.count) { _, newCount in guard newCount > 0 else { return } withAnimation { proxy.scrollTo(newCount - 1, anchor: .bottom) diff --git a/StikJIT/JSSupport/ScriptListView.swift b/StikJIT/JSSupport/ScriptListView.swift index cb1adbb8..95b08c84 100644 --- a/StikJIT/JSSupport/ScriptListView.swift +++ b/StikJIT/JSSupport/ScriptListView.swift @@ -14,7 +14,7 @@ struct ScriptListView: View { @State private var showNewFileAlert = false @State private var newFileName = "" @State private var showImporter = false - @AppStorage("DefaultScriptName") private var defaultScriptName = "attachDetach.js" + @AppStorage(UserDefaults.Keys.defaultScriptName) private var defaultScriptName = UserDefaults.Keys.defaultScriptNameValue @State private var isBusy = false @State private var alertVisible = false @@ -200,43 +200,14 @@ struct ScriptListView: View { // MARK: - File Ops - private func scriptsDirectory() -> URL { - let dir = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] - .appendingPathComponent("scripts") - var isDir: ObjCBool = false - let exists = FileManager.default.fileExists(atPath: dir.path, isDirectory: &isDir) - do { - if exists && !isDir.boolValue { - try FileManager.default.removeItem(at: dir) - } - if !exists || !isDir.boolValue { - try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) - } - try ensureDefaultScripts(in: dir) - } catch { - presentError(title: "Unable to Create Scripts Folder", message: error.localizedDescription) - } - return dir + private func scriptsDirectory() throws -> URL { + let directory = try ScriptStore.prepareDirectory() + try ensureEditorScripts(in: directory) + return directory } - private func ensureDefaultScripts(in directory: URL) throws { + private func ensureEditorScripts(in directory: URL) throws { let fm = FileManager.default - let bundledScripts: [(resource: String, filename: String)] = [ - ("attachDetach", "attachDetach.js"), - ("maciOS", "maciOS.js"), - ("Amethyst-MeloNX", "Amethyst-MeloNX.js"), - ("Geode", "Geode.js"), - ("manic", "manic.js"), - ("UTM-Dolphin", "UTM-Dolphin.js") - ] - for entry in bundledScripts { - if let bundleURL = Bundle.main.url(forResource: entry.resource, withExtension: "js") { - let destination = directory.appendingPathComponent(entry.filename) - if !fm.fileExists(atPath: destination.path) { - try fm.copyItem(at: bundleURL, to: destination) - } - } - } let screenshotURL = directory.appendingPathComponent("screenshot-demo.js") if !fm.fileExists(atPath: screenshotURL.path) { try screenshotDemoScript.write(to: screenshotURL, atomically: true, encoding: .utf8) @@ -248,10 +219,15 @@ struct ScriptListView: View { } private func loadScripts() { - let dir = scriptsDirectory() - scripts = (try? FileManager.default.contentsOfDirectory(at: dir, includingPropertiesForKeys: nil))? - .filter { $0.pathExtension.lowercased() == "js" } - .sorted { $0.lastPathComponent.localizedCaseInsensitiveCompare($1.lastPathComponent) == .orderedAscending } ?? [] + do { + let directory = try scriptsDirectory() + scripts = try FileManager.default.contentsOfDirectory(at: directory, includingPropertiesForKeys: nil) + .filter { $0.pathExtension.lowercased() == "js" } + .sorted { $0.lastPathComponent.localizedCaseInsensitiveCompare($1.lastPathComponent) == .orderedAscending } + } catch { + scripts = [] + presentError(title: "Unable to Load Scripts", message: error.localizedDescription) + } } private func saveDefaultScript(_ url: URL) { @@ -263,12 +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" } - let newURL = scriptsDirectory().appendingPathComponent(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 - } do { + let newURL = try scriptsDirectory().appendingPathComponent(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 + } try "".write(to: newURL, atomically: true, encoding: .utf8) newFileName = "" loadScripts() @@ -282,7 +258,7 @@ struct ScriptListView: View { do { try FileManager.default.removeItem(at: url) if url.lastPathComponent == defaultScriptName { - UserDefaults.standard.removeObject(forKey: "DefaultScriptName") + UserDefaults.standard.removeObject(forKey: UserDefaults.Keys.defaultScriptName) } loadScripts() } catch { @@ -295,7 +271,8 @@ struct ScriptListView: View { DispatchQueue.global(qos: .userInitiated).async { defer { DispatchQueue.main.async { self.isBusy = false } } do { - let dest = self.scriptsDirectory().appendingPathComponent(fileURL.lastPathComponent) + let directory = try self.scriptsDirectory() + let dest = directory.appendingPathComponent(fileURL.lastPathComponent) if FileManager.default.fileExists(atPath: dest.path) { try FileManager.default.removeItem(at: dest) } diff --git a/StikJIT/StikJIT-Bridging-Header.h b/StikJIT/StikJIT-Bridging-Header.h deleted file mode 100644 index 9e96e7f8..00000000 --- a/StikJIT/StikJIT-Bridging-Header.h +++ /dev/null @@ -1,11 +0,0 @@ -// -// Use this file to import your target's public headers that you would like to expose to Swift. -// - -#include "idevice/JITEnableContext.h" -#import "Utilities/ProcessInspectorBridge.h" -#include "idevice/idevice.h" -#include "JSSupport/JSSupport.h" -#include "idevice/ideviceinfo.h" -#include "idevice/location_simulation.h" -#include "idevice/profiles.h" diff --git a/StikJIT/StikJITApp.swift b/StikJIT/StikJITApp.swift index 61465ec5..c3c880a6 100644 --- a/StikJIT/StikJITApp.swift +++ b/StikJIT/StikJITApp.swift @@ -7,7 +7,7 @@ import SwiftUI import Network -import UniformTypeIdentifiers +import idevice // Register default settings before the app starts private func registerAdvancedOptionsDefault() { @@ -134,7 +134,8 @@ struct HeartbeatApp: App { if UserDefaults.standard.bool(forKey: "keepAliveAudio") { BackgroundAudioManager.shared.start() } - if let fixMethod = class_getInstanceMethod(UIDocumentPickerViewController.self, #selector(UIDocumentPickerViewController.fix_init(forOpeningContentTypes:asCopy:))), + let fixSelector = NSSelectorFromString("fix_initForOpeningContentTypes:asCopy:") + if let fixMethod = class_getInstanceMethod(UIDocumentPickerViewController.self, fixSelector), let origMethod = class_getInstanceMethod(UIDocumentPickerViewController.self, #selector(UIDocumentPickerViewController.init(forOpeningContentTypes:asCopy:))) { method_exchangeImplementations(origMethod, fixMethod) } @@ -177,7 +178,7 @@ struct HeartbeatApp: App { } } } - .onChange(of: scenePhase) { newPhase in + .onChange(of: scenePhase) { _, newPhase in handleScenePhaseChange(newPhase) } } @@ -269,7 +270,7 @@ class MountingProgress: ObservableObject { } func isPairing() -> Bool { - let pairingpath = URL.documentsDirectory.appendingPathComponent("pairingFile.plist").path + let pairingpath = PairingFileStore.prepareURL().path var pairingFile: RpPairingFileHandle? let err = rp_pairing_file_read(pairingpath, &pairingFile) if err != nil { return false } @@ -279,7 +280,7 @@ func isPairing() -> Bool { func startTunnelInBackground(showErrorUI: Bool = true) { assert(Thread.isMainThread, "startTunnelInBackground must be called on the main thread") - let pairingFileURL = URL.documentsDirectory.appendingPathComponent("pairingFile.plist") + let pairingFileURL = PairingFileStore.prepareURL() guard FileManager.default.fileExists(atPath: pairingFileURL.path) else { tunnelStartPending = false @@ -321,7 +322,7 @@ func startTunnelInBackground(showErrorUI: Bool = true) { DispatchQueue.main.async { if code == -9 { do { - try FileManager.default.removeItem(at: URL.documentsDirectory.appendingPathComponent("pairingFile.plist")) + try PairingFileStore.remove() LogManager.shared.addInfoLog("Removed invalid pairing file") } catch { LogManager.shared.addErrorLog("Failed to remove invalid pairing file: \(error.localizedDescription)") diff --git a/StikJIT/Utilities/BackgroundAudioManager.swift b/StikJIT/Utilities/BackgroundAudioManager.swift index 3a712672..20c99a60 100644 --- a/StikJIT/Utilities/BackgroundAudioManager.swift +++ b/StikJIT/Utilities/BackgroundAudioManager.swift @@ -11,6 +11,8 @@ final class BackgroundAudioManager { private var engine = AVAudioEngine() private var player = AVAudioPlayerNode() private var isRunning = false + private var persistentEnabled = false + private var activityCount = 0 private var healthCheckTimer: Timer? private init() { @@ -29,22 +31,54 @@ final class BackgroundAudioManager { } func start() { - isRunning = true - startEngine() - startHealthCheck() + persistentEnabled = true + refreshRunningState() } func stop() { - isRunning = false - healthCheckTimer?.invalidate() - healthCheckTimer = nil - player.stop() - engine.stop() - try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation) + persistentEnabled = false + refreshRunningState() + } + + func requestStart() { + activityCount += 1 + refreshRunningState() + } + + func requestStop() { + activityCount = max(activityCount - 1, 0) + refreshRunningState() + } + + private func refreshRunningState() { + let shouldRun = persistentEnabled || (activityCount > 0 && UserDefaults.standard.bool(forKey: "keepAliveAudio")) + guard shouldRun != isRunning else { + if shouldRun { + recoverIfNeeded() + } + return + } + + isRunning = shouldRun + if shouldRun { + startEngine() + startHealthCheck() + } else { + healthCheckTimer?.invalidate() + healthCheckTimer = nil + player.stop() + engine.stop() + try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation) + } } private func startEngine() { do { + engine.stop() + player.stop() + engine = AVAudioEngine() + player = AVAudioPlayerNode() + let session = AVAudioSession.sharedInstance() try session.setCategory(.playback, options: .mixWithOthers) try session.setActive(true) diff --git a/StikJIT/Utilities/DeviceInfoManager.swift b/StikJIT/Utilities/DeviceInfoManager.swift index b0db7f68..51201706 100644 --- a/StikJIT/Utilities/DeviceInfoManager.swift +++ b/StikJIT/Utilities/DeviceInfoManager.swift @@ -8,6 +8,7 @@ import SwiftUI import UIKit import UniformTypeIdentifiers +import idevice // MARK: - Device Info Manager @@ -59,9 +60,10 @@ final class DeviceInfoManager: ObservableObject { private func loadInfo() { busy = true Task.detached { - var cXml : UnsafeMutablePointer? = nil; + let lockdownHandle = await MainActor.run { self.lockdownHandle } + var cXml: UnsafeMutablePointer? do { - cXml = try await JITEnableContext.shared.ideviceInfoGetXML(withLockdownClient: self.lockdownHandle?.raw) + cXml = try JITEnableContext.shared.ideviceInfoGetXML(withLockdownClient: lockdownHandle?.raw) } catch { await MainActor.run { self.error = ("Fetch Error", "Failed to fetch device info \(error)") @@ -71,7 +73,7 @@ final class DeviceInfoManager: ObservableObject { } guard let cXml else { return } - defer { free(UnsafeMutableRawPointer(mutating: cXml)) } + defer { plist_mem_free(cXml) } guard let xml = String(validatingUTF8: cXml) else { await MainActor.run { self.error = ("Parse Error", "Invalid XML data") @@ -139,8 +141,7 @@ struct DeviceInfoView: View { @State private var showShareSheet = false @State private var justCopied = false - private let docs = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] - private var pairingURL: URL { docs.appendingPathComponent("pairingFile.plist") } + private var pairingURL: URL { PairingFileStore.prepareURL() } private var isPaired: Bool { FileManager.default.fileExists(atPath: pairingURL.path) } @State private var searchText = "" @@ -274,7 +275,7 @@ struct DeviceInfoView: View { } } } - .fileImporter(isPresented: $importer, allowedContentTypes: [.propertyList]) { result in + .fileImporter(isPresented: $importer, allowedContentTypes: PairingFileStore.supportedContentTypes) { result in if case .success(let url) = result { importPairing(from: url) } } .fileExporter( @@ -288,7 +289,7 @@ struct DeviceInfoView: View { } .onAppear { if isPaired { mgr.initAndLoad() } } .onDisappear { mgr.cleanup() } - .onChange(of: mgr.error?.message) { _ in + .onChange(of: mgr.error?.message) { _, _ in if let err = mgr.error { fail(err.title, err.message) mgr.error = nil @@ -352,14 +353,8 @@ struct DeviceInfoView: View { // MARK: - Pairing Import private func importPairing(from src: URL) { - guard src.startAccessingSecurityScopedResource() else { return } - defer { src.stopAccessingSecurityScopedResource() } do { - if FileManager.default.fileExists(atPath: pairingURL.path) { - try FileManager.default.removeItem(at: pairingURL) - } - try FileManager.default.copyItem(at: src, to: pairingURL) - try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: pairingURL.path) + try PairingFileStore.importFromPicker(src) notify("Pairing File Added", "Your device is ready. Tap Reload to fetch info.") mgr.initAndLoad() } catch { diff --git a/StikJIT/Utilities/Extensions.swift b/StikJIT/Utilities/Extensions.swift index 1e2067b8..bae3ed4d 100644 --- a/StikJIT/Utilities/Extensions.swift +++ b/StikJIT/Utilities/Extensions.swift @@ -4,7 +4,222 @@ // // Created by s s on 2025/7/9. // +import Foundation import UniformTypeIdentifiers +import UIKit + +enum PairingFileStore { + static let fileName = "rp_pairing_file.plist" + private static let legacyFileName = "pairingFile.plist" + static let supportedContentTypes: [UTType] = [ + UTType(filenameExtension: "mobiledevicepairing", conformingTo: .data)!, + UTType(filenameExtension: "mobiledevicepair", conformingTo: .data)!, + .propertyList + ] + + static var url: URL { + URL.documentsDirectory.appendingPathComponent(fileName) + } + + private static var legacyURL: URL { + 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 { + 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) + } + } + + return destination + } + + static func replace(with sourceURL: URL, fileManager: FileManager = .default) throws { + let destination = prepareURL(fileManager: fileManager) + if fileManager.fileExists(atPath: destination.path) { + try fileManager.removeItem(at: destination) + } + if fileManager.fileExists(atPath: legacyURL.path) { + try? fileManager.removeItem(at: legacyURL) + } + try fileManager.copyItem(at: sourceURL, to: destination) + try? fileManager.setAttributes([.posixPermissions: 0o600], ofItemAtPath: destination.path) + } + + static func importFromPicker(_ sourceURL: URL, fileManager: FileManager = .default) throws { + let accessing = sourceURL.startAccessingSecurityScopedResource() + defer { + if accessing { + sourceURL.stopAccessingSecurityScopedResource() + } + } + + guard fileManager.fileExists(atPath: sourceURL.path) else { + throw CocoaError(.fileNoSuchFile) + } + + try replace(with: sourceURL, fileManager: fileManager) + } + + static func remove(fileManager: FileManager = .default) throws { + let destination = prepareURL(fileManager: fileManager) + if fileManager.fileExists(atPath: destination.path) { + try fileManager.removeItem(at: destination) + } + if fileManager.fileExists(atPath: legacyURL.path) { + try? fileManager.removeItem(at: legacyURL) + } + } +} + +struct ScriptResource { + let resourceName: String + let fileName: String +} + +enum ScriptStore { + static let directoryName = "scripts" + static let assignmentKey = UserDefaults.Keys.bundleScriptMap + static let favoriteAppNamesSuiteName = "group.com.stik.sj" + static let favoriteAppNamesKey = "favoriteAppNames" + static let defaultScriptName = UserDefaults.Keys.defaultScriptNameValue + static let bundledResources: [ScriptResource] = [ + ScriptResource(resourceName: "attachDetach", fileName: "attachDetach.js"), + ScriptResource(resourceName: "maciOS", fileName: "maciOS.js"), + ScriptResource(resourceName: "universal", fileName: "universal.js"), + ScriptResource(resourceName: "Geode", fileName: "Geode.js"), + ScriptResource(resourceName: "manic", fileName: "manic.js"), + ScriptResource(resourceName: "UTM-Dolphin", fileName: "UTM-Dolphin.js") + ] + + static var directoryURL: URL { + URL.documentsDirectory.appendingPathComponent(directoryName) + } + + @discardableResult + static func prepareDirectory(fileManager: FileManager = .default) throws -> URL { + let directory = directoryURL + var isDirectory: ObjCBool = false + let exists = fileManager.fileExists(atPath: directory.path, isDirectory: &isDirectory) + if exists && !isDirectory.boolValue { + try fileManager.removeItem(at: directory) + } + if !exists || !isDirectory.boolValue { + try fileManager.createDirectory(at: directory, withIntermediateDirectories: true) + } + try ensureBundledScripts(in: directory, fileManager: fileManager) + return directory + } + + static func scriptURL(named scriptName: String, fileManager: FileManager = .default) throws -> URL { + let directory = try prepareDirectory(fileManager: fileManager) + return directory.appendingPathComponent(scriptName) + } + + static func assignedScriptName(for bundleID: String, defaults: UserDefaults = .standard) -> String? { + assignedScriptMap(defaults: defaults)[bundleID] + } + + static func updateAssignedScriptName(_ scriptName: String?, for bundleID: String, defaults: UserDefaults = .standard) { + var mapping = assignedScriptMap(defaults: defaults) + if let scriptName { + mapping[bundleID] = scriptName + } else { + mapping.removeValue(forKey: bundleID) + } + defaults.set(mapping, forKey: assignmentKey) + } + + static func preferredScript(for bundleID: String, fileManager: FileManager = .default) -> (data: Data, name: String)? { + assignedScript(for: bundleID, fileManager: fileManager) ?? autoScript(for: bundleID, fileManager: fileManager) + } + + static func favoriteAppName(for bundleID: String, defaults: UserDefaults? = UserDefaults(suiteName: favoriteAppNamesSuiteName)) -> String? { + let names = defaults?.dictionary(forKey: favoriteAppNamesKey) as? [String: String] + return names?[bundleID] + } + + static func autoScriptResource(for appName: String) -> ScriptResource? { + switch appName { + case "maciOS": + return ScriptResource(resourceName: "maciOS", fileName: "maciOS.js") + case "Amethyst", "MeloNX", "XeniOS", "MeloCafe": + return ScriptResource(resourceName: "universal", fileName: "universal.js") + case "Geode": + return ScriptResource(resourceName: "Geode", fileName: "Geode.js") + case "Manic EMU": + return ScriptResource(resourceName: "manic", fileName: "manic.js") + case "UTM", "DolphiniOS", "Flycast": + return ScriptResource(resourceName: "UTM-Dolphin", fileName: "UTM-Dolphin.js") + default: + return nil + } + } + + private static func ensureBundledScripts(in directory: URL, fileManager: FileManager) throws { + for resource in bundledResources { + guard let bundleURL = Bundle.main.url(forResource: resource.resourceName, withExtension: "js") else { + continue + } + let destination = directory.appendingPathComponent(resource.fileName) + if !fileManager.fileExists(atPath: destination.path) { + try fileManager.copyItem(at: bundleURL, to: destination) + } + } + } + + private static func assignedScript(for bundleID: String, fileManager: FileManager) -> (data: Data, name: String)? { + guard let scriptName = assignedScriptName(for: bundleID) else { + return nil + } + guard let scriptURL = try? scriptURL(named: scriptName, fileManager: fileManager), + let data = try? Data(contentsOf: scriptURL) else { + return nil + } + return (data, scriptName) + } + + private static func autoScript(for bundleID: String, fileManager: FileManager) -> (data: Data, name: String)? { + guard ProcessInfo.processInfo.hasTXM else { + return nil + } + guard #available(iOS 26, *) else { + return nil + } + + let appName = (try? JITEnableContext.shared.getAppList()[bundleID]) ?? favoriteAppName(for: bundleID) + guard let appName, + let resource = autoScriptResource(for: appName) else { + return nil + } + + if let scriptURL = try? scriptURL(named: resource.fileName, fileManager: fileManager), + let data = try? Data(contentsOf: scriptURL) { + return (data, resource.fileName) + } + + guard let bundleURL = Bundle.main.url(forResource: resource.resourceName, withExtension: "js"), + let data = try? Data(contentsOf: bundleURL) else { + return nil + } + return (data, resource.fileName) + } + + private static func assignedScriptMap(defaults: UserDefaults) -> [String: String] { + defaults.dictionary(forKey: assignmentKey) as? [String: String] ?? [:] + } +} extension FileManager { func filePath(atPath path: String, withLength length: Int) -> String? { @@ -21,11 +236,15 @@ extension UIDocumentPickerViewController { extension Notification.Name { static let pairingFileImported = Notification.Name("PairingFileImported") + static let intentJSScriptReady = Notification.Name("intentJSScriptReady") } extension UserDefaults { enum Keys { /// Forces the app to treat the current device as TXM-capable so scripts always run. static let txmOverride = "overrideTXMForScripts" + static let bundleScriptMap = "BundleScriptMap" + static let defaultScriptName = "DefaultScriptName" + static let defaultScriptNameValue = "attachDetach.js" } } diff --git a/StikJIT/Utilities/IdeviceFFIBridge.swift b/StikJIT/Utilities/IdeviceFFIBridge.swift new file mode 100644 index 00000000..42f6a001 --- /dev/null +++ b/StikJIT/Utilities/IdeviceFFIBridge.swift @@ -0,0 +1,805 @@ +// +// IdeviceFFIBridge.swift +// StikDebug +// +// Created by Stephen on 2026/3/30. +// + +import Foundation +import UIKit +import idevice + +private enum IdeviceBridge { + static let processQueue = DispatchQueue(label: "com.stikdebug.processInspector", qos: .userInitiated) + + static func makeError( + domain: String = "StikDebug", + code: Int = -1, + message: String + ) -> NSError { + NSError( + domain: domain, + code: code, + userInfo: [NSLocalizedDescriptionKey: message] + ) + } + + static func string(from cString: UnsafePointer?) -> String? { + guard let cString else { return nil } + return String(validatingUTF8: cString) + } + + static func consumeFFIError( + _ ffiError: UnsafeMutablePointer?, + fallback: String, + domain: String = "StikDebug" + ) -> NSError { + guard let ffiError else { + return makeError(domain: domain, message: fallback) + } + + let code = Int(ffiError.pointee.code) + let message = string(from: ffiError.pointee.message) ?? fallback + idevice_error_free(ffiError) + return makeError(domain: domain, code: code, message: message) + } + + static func withTunnelHandles( + for context: JITEnableContext, + _ body: (OpaquePointer, OpaquePointer) throws -> T + ) throws -> T { + let handles = try activeTunnelHandles(for: context) + return try body(handles.adapter, handles.handshake) + } + + static func connectClient( + fallback: String, + missingClientMessage: String, + domain: String = "StikDebug", + connect: (UnsafeMutablePointer) -> UnsafeMutablePointer? + ) throws -> OpaquePointer { + var client: OpaquePointer? + if let ffiError = connect(&client) { + throw consumeFFIError(ffiError, fallback: fallback, domain: domain) + } + + guard let client else { + throw makeError(domain: domain, message: missingClientMessage) + } + + return client + } + + static func withConnectedClient( + fallback: String, + missingClientMessage: String, + domain: String = "StikDebug", + connect: (UnsafeMutablePointer) -> UnsafeMutablePointer?, + cleanup: (OpaquePointer) -> Void, + _ body: (OpaquePointer) throws -> T + ) throws -> T { + let client = try connectClient( + fallback: fallback, + missingClientMessage: missingClientMessage, + domain: domain, + connect: connect + ) + defer { cleanup(client) } + return try body(client) + } + + static func plistDictionaries(adapter: OpaquePointer, handshake: OpaquePointer) throws -> [[String: Any]] { + try withConnectedClient( + fallback: "Failed to connect to installation proxy", + missingClientMessage: "Installation proxy client was not created", + connect: { installation_proxy_connect_rsd(adapter, handshake, $0) }, + cleanup: { installation_proxy_client_free($0) } + ) { client in + var rawApps: UnsafeMutableRawPointer? + var count = 0 + if let ffiError = installation_proxy_get_apps(client, nil, nil, 0, &rawApps, &count) { + throw consumeFFIError(ffiError, fallback: "Failed to fetch installed apps") + } + + guard let rawApps, count > 0 else { return [] } + + let apps = rawApps.assumingMemoryBound(to: plist_t?.self) + defer { + for index in 0...stride) + ) + } + + var dictionaries: [[String: Any]] = [] + dictionaries.reserveCapacity(count) + + for index in 0..? + var binaryLength: UInt32 = 0 + let app = apps[index] + + guard plist_to_bin(app, &binaryPlist, &binaryLength) == PLIST_ERR_SUCCESS, + let binaryPlist, + binaryLength > 0 else { + continue + } + + let data = Data(bytes: binaryPlist, count: Int(binaryLength)) + plist_mem_free(binaryPlist) + + guard let plist = try? PropertyListSerialization.propertyList(from: data, format: nil), + let dictionary = plist as? [String: Any] else { + continue + } + + dictionaries.append(dictionary) + } + + return dictionaries + } + } + + static func appName(from dictionary: [String: Any]) -> String { + if let displayName = dictionary["CFBundleDisplayName"] as? String, !displayName.isEmpty { + return displayName + } + if let name = dictionary["CFBundleName"] as? String, !name.isEmpty { + return name + } + return "Unknown" + } + + static func hasGetTaskAllow(_ dictionary: [String: Any]) -> Bool { + guard let entitlements = dictionary["Entitlements"] as? [String: Any] else { + return false + } + + if let flag = entitlements["get-task-allow"] as? Bool { + return flag + } + + if let flag = entitlements["get-task-allow"] as? NSNumber { + return flag.boolValue + } + + return false + } + + static func isHiddenSystemApp(_ dictionary: [String: Any]) -> Bool { + guard let applicationType = dictionary["ApplicationType"] as? String, + applicationType == "System" || applicationType == "HiddenSystemApp" else { + return false + } + + if let isHidden = dictionary["IsHidden"] as? Bool, isHidden { + return true + } + + if let isHidden = dictionary["IsHidden"] as? NSNumber, isHidden.boolValue { + return true + } + + guard let tags = dictionary["SBAppTags"] as? [String] else { + return false + } + + return tags.contains("hidden") || tags.contains("hidden-system-app") + } + + static func appDictionary( + adapter: OpaquePointer, + handshake: OpaquePointer, + requireGetTaskAllow: Bool, + filter: (([String: Any]) -> Bool)? = nil + ) throws -> [String: String] { + let dictionaries = try plistDictionaries(adapter: adapter, handshake: handshake) + var result: [String: String] = [:] + result.reserveCapacity(dictionaries.count) + + for dictionary in dictionaries { + if requireGetTaskAllow && !hasGetTaskAllow(dictionary) { + continue + } + + if let filter, !filter(dictionary) { + continue + } + + guard let bundleID = dictionary["CFBundleIdentifier"] as? String, + !bundleID.isEmpty else { + continue + } + + result[bundleID] = appName(from: dictionary) + } + + return result + } + + static func activeTunnelHandles(for context: JITEnableContext) throws -> (adapter: OpaquePointer, handshake: OpaquePointer) { + try context.ensureTunnel() + + guard let adapterHandle = context.adapterHandle, + let handshakeHandle = context.handshakeHandle else { + throw makeError(message: "Tunnel is not connected") + } + + return (adapterHandle, handshakeHandle) + } +} + +extension JITEnableContext { + func getMountedDeviceCount() throws -> Int { + try IdeviceBridge.withTunnelHandles(for: self) { adapter, handshake in + try IdeviceBridge.withConnectedClient( + fallback: "Failed to connect to image mounter", + missingClientMessage: "Image mounter client was not created", + connect: { image_mounter_connect_rsd(adapter, handshake, $0) }, + cleanup: { image_mounter_free($0) } + ) { client in + var devices: UnsafeMutablePointer? + var deviceCount = 0 + if let ffiError = image_mounter_copy_devices(client, &devices, &deviceCount) { + throw IdeviceBridge.consumeFFIError(ffiError, fallback: "Failed to fetch mounted devices") + } + + if let devices { + for index in 0...stride) + ) + } + + return deviceCount + } + } + } + + func mountPersonalDDI(withImagePath imagePath: String, trustcachePath: String, manifestPath: String) throws { + guard FileManager.default.fileExists(atPath: imagePath), + FileManager.default.fileExists(atPath: trustcachePath), + FileManager.default.fileExists(atPath: manifestPath) else { + throw IdeviceBridge.makeError(code: 1, message: "Failed to read one or more files") + } + + try IdeviceBridge.withTunnelHandles(for: self) { adapter, handshake in + try IdeviceBridge.withConnectedClient( + fallback: "Failed to connect to lockdownd", + missingClientMessage: "Lockdownd client was not created", + connect: { lockdownd_connect_rsd(adapter, handshake, $0) }, + cleanup: { lockdownd_client_free($0) } + ) { lockdownClient in + var uniqueChipIDPlist: plist_t? + if let ffiError = lockdownd_get_value(lockdownClient, "UniqueChipID", nil, &uniqueChipIDPlist) { + throw IdeviceBridge.consumeFFIError(ffiError, fallback: "Failed to query UniqueChipID") + } + + if let uniqueChipIDPlist { + plist_free(uniqueChipIDPlist) + } + } + + try IdeviceBridge.withConnectedClient( + fallback: "Failed to connect to image mounter", + missingClientMessage: "Image mounter client was not created", + connect: { image_mounter_connect_rsd(adapter, handshake, $0) }, + cleanup: { image_mounter_free($0) } + ) { _ in + throw IdeviceBridge.makeError( + code: 10, + message: "mount_personalized not yet available over RSD tunnels" + ) + } + } + } + + func fetchAllProfiles() throws -> [Data] { + try IdeviceBridge.withTunnelHandles(for: self) { adapter, handshake in + try IdeviceBridge.withConnectedClient( + fallback: "Failed to connect to misagent", + missingClientMessage: "Misagent client was not created", + domain: "profiles", + connect: { misagent_connect_rsd(adapter, handshake, $0) }, + cleanup: { misagent_client_free($0) } + ) { misagentClient in + var profilePointers: UnsafeMutablePointer?>? + var profileLengths: UnsafeMutablePointer? + var profileCount = 0 + + if let ffiError = misagent_copy_all(misagentClient, &profilePointers, &profileLengths, &profileCount) { + throw IdeviceBridge.consumeFFIError( + ffiError, + fallback: "Failed to fetch provisioning profiles", + domain: "profiles" + ) + } + + defer { + if let profilePointers, let profileLengths { + misagent_free_profiles(profilePointers, profileLengths, profileCount) + } + } + + guard let profilePointers, let profileLengths else { return [] } + + var result: [Data] = [] + result.reserveCapacity(profileCount) + + for index in 0.. [NSDictionary] { + try IdeviceBridge.processQueue.sync { + try IdeviceBridge.withTunnelHandles(for: self) { adapter, handshake in + try IdeviceBridge.withConnectedClient( + fallback: "Unable to open AppService", + missingClientMessage: "AppService client was not created", + connect: { app_service_connect_rsd(adapter, handshake, $0) }, + cleanup: { app_service_free($0) } + ) { appService in + var processes: UnsafeMutablePointer? + var count = UInt(0) + if let ffiError = app_service_list_processes(appService, &processes, &count) { + throw IdeviceBridge.consumeFFIError(ffiError, fallback: "Failed to list processes") + } + + defer { + if let processes { + app_service_free_process_list(processes, count) + } + } + + guard let processes else { return [] } + + var result: [NSDictionary] = [] + result.reserveCapacity(Int(count)) + + for index in 0..? + let ffiError = app_service_send_signal(appService, UInt32(pid), UInt32(signal), &response) + defer { + if let response { + app_service_free_signal_response(response) + } + } + + if let ffiError { + throw IdeviceBridge.consumeFFIError(ffiError, fallback: "Failed to send signal \(signal) to process") + } + } + } + } + + func killProcess(withPID pid: Int32) throws { + try sendSignal(Int32(SIGKILL), toProcessWithPID: pid) + } + + func getAppList() throws -> [String: String] { + try IdeviceBridge.withTunnelHandles(for: self) { adapter, handshake in + try IdeviceBridge.appDictionary( + adapter: adapter, + handshake: handshake, + requireGetTaskAllow: true + ) + } + } + + func getAllApps() throws -> [String: String] { + try IdeviceBridge.withTunnelHandles(for: self) { adapter, handshake in + try IdeviceBridge.appDictionary( + adapter: adapter, + handshake: handshake, + requireGetTaskAllow: false + ) + } + } + + func getHiddenSystemApps() throws -> [String: String] { + try IdeviceBridge.withTunnelHandles(for: self) { adapter, handshake in + try IdeviceBridge.appDictionary( + adapter: adapter, + handshake: handshake, + requireGetTaskAllow: false, + filter: IdeviceBridge.isHiddenSystemApp + ) + } + } + + func getSideloadedApps() throws -> [NSDictionary] { + try IdeviceBridge.withTunnelHandles(for: self) { adapter, handshake in + try IdeviceBridge.plistDictionaries(adapter: adapter, handshake: handshake) + .filter { $0["ProfileValidated"] != nil } + .map { $0 as NSDictionary } + } + } + + func getAppIcon(withBundleId bundleId: String) throws -> UIImage { + try IdeviceBridge.withTunnelHandles(for: self) { adapter, handshake in + try IdeviceBridge.withConnectedClient( + fallback: "Failed to connect to SpringBoard Services", + missingClientMessage: "SpringBoard Services client was not created", + connect: { springboard_services_connect_rsd(adapter, handshake, $0) }, + cleanup: { springboard_services_free($0) } + ) { client in + var rawIconData: UnsafeMutableRawPointer? + var rawIconLength = 0 + if let ffiError = springboard_services_get_icon(client, bundleId, &rawIconData, &rawIconLength) { + throw IdeviceBridge.consumeFFIError(ffiError, fallback: "Failed to get app icon") + } + + guard let rawIconData, rawIconLength > 0 else { + throw IdeviceBridge.makeError(message: "App icon data was empty") + } + + defer { free(rawIconData) } + + let data = Data(bytes: rawIconData, count: rawIconLength) + guard let image = UIImage(data: data) else { + throw IdeviceBridge.makeError(message: "Failed to decode app icon image") + } + + return image + } + } + } + + func ideviceInfoInit() throws -> OpaquePointer { + try IdeviceBridge.withTunnelHandles(for: self) { adapter, handshake in + try IdeviceBridge.connectClient( + fallback: "Failed to connect to lockdownd", + missingClientMessage: "Lockdownd client was not created", + domain: "profiles", + connect: { lockdownd_connect_rsd(adapter, handshake, $0) } + ) + } + } + + func ideviceInfoGetXML(withLockdownClient lockdownClient: OpaquePointer?) throws -> UnsafeMutablePointer? { + guard let lockdownClient else { return nil } + + var plistObject: plist_t? + if let ffiError = lockdownd_get_value(lockdownClient, nil, nil, &plistObject) { + throw IdeviceBridge.consumeFFIError(ffiError, fallback: "Failed to fetch device info") + } + + guard let plistObject else { + return nil + } + + defer { plist_free(plistObject) } + + var xml: UnsafeMutablePointer? + var xmlLength: UInt32 = 0 + guard plist_to_xml(plistObject, &xml, &xmlLength) == PLIST_ERR_SUCCESS, + let xml, + xmlLength > 0 else { + throw IdeviceBridge.makeError(message: "Failed to serialize device info plist") + } + + return xml + } +} + +func FetchDeviceProcessList(_ error: NSErrorPointer) -> [NSDictionary]? { + do { + return try JITEnableContext.shared.fetchProcessList() + } catch let nsError as NSError { + error?.pointee = nsError + return nil + } +} + +func KillDeviceProcess(_ pid: Int32, _ error: NSErrorPointer) -> Bool { + do { + try JITEnableContext.shared.killProcess(withPID: pid) + return true + } catch let nsError as NSError { + error?.pointee = nsError + return false + } +} + +struct ProcessInfoEntry: Identifiable { + let pid: Int + private let rawPath: String + let bundleID: String? + let name: String? + + init?(dictionary: NSDictionary) { + guard let pidNumber = dictionary["pid"] as? NSNumber else { return nil } + pid = pidNumber.intValue + rawPath = dictionary["path"] as? String ?? "Unknown" + bundleID = dictionary["bundleID"] as? String + name = dictionary["name"] as? String + } + + static func currentEntries(_ error: NSErrorPointer = nil) -> [ProcessInfoEntry] { + let entries = FetchDeviceProcessList(error) ?? [] + return entries.compactMap(Self.init(dictionary:)) + } + + var id: Int { pid } + + var executablePath: String { + rawPath.replacingOccurrences(of: "file://", with: "") + } + + var displayName: String { + if let name, !name.isEmpty { + return name + } + if let bundleID, !bundleID.isEmpty { + return bundleID + } + if let component = executablePath.split(separator: "/").last { + return String(component) + } + return "Process \(pid)" + } + + var stableIdentifier: String { + if let bundleID, !bundleID.isEmpty { + return bundleID + } + return displayName + } +} + +@objcMembers +final class CMSDecoderHelper: NSObject { + static func decodeCMSData(_ cmsData: Data) throws -> Data { + guard !cmsData.isEmpty else { + throw IdeviceBridge.makeError( + domain: NSCocoaErrorDomain, + code: NSURLErrorBadURL, + message: "Invalid or empty CMS payload" + ) + } + + let xmlStart = Data("".utf8) + let binaryMagic = Data("bplist00".utf8) + + if let startRange = cmsData.range(of: xmlStart), + let endRange = cmsData.range(of: plistEnd, options: [], in: startRange.lowerBound.. Int32 { + if let locationSimulation = LocationSimulationState.locationSimulation { + if let ffiError = location_simulation_set(locationSimulation, latitude, longitude) { + idevice_error_free(ffiError) + LocationSimulationState.cleanup() + } else { + return LocationSimulationStatus.ok + } + } + + var address = sockaddr_in() + address.sin_family = sa_family_t(AF_INET) + address.sin_port = in_port_t(49152).bigEndian + + let inetResult = deviceIP.withCString { inet_pton(AF_INET, $0, &address.sin_addr) } + guard inetResult == 1 else { + return LocationSimulationStatus.invalidIP + } + + var pairingHandle: OpaquePointer? + let pairingError = pairingFile.withCString { rp_pairing_file_read($0, &pairingHandle) } + if let pairingError { + idevice_error_free(pairingError) + return LocationSimulationStatus.pairingRead + } + + guard let pairingHandle else { + return LocationSimulationStatus.pairingRead + } + + defer { rp_pairing_file_free(pairingHandle) } + + let providerError = withUnsafePointer(to: &address) { pointer in + pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { + tunnel_create_rppairing( + $0, + socklen_t(MemoryLayout.stride), + "StikDebugLocation", + pairingHandle, + nil, + nil, + &LocationSimulationState.adapter, + &LocationSimulationState.handshake + ) + } + } + + if let providerError { + idevice_error_free(providerError) + LocationSimulationState.cleanup() + return LocationSimulationStatus.providerCreate + } + + let remoteServerError = remote_server_connect_rsd( + LocationSimulationState.adapter, + LocationSimulationState.handshake, + &LocationSimulationState.remoteServer + ) + if let remoteServerError { + idevice_error_free(remoteServerError) + LocationSimulationState.cleanup() + return LocationSimulationStatus.remoteServer + } + + let locationSimulationError = location_simulation_new( + LocationSimulationState.remoteServer, + &LocationSimulationState.locationSimulation + ) + if let locationSimulationError { + idevice_error_free(locationSimulationError) + LocationSimulationState.cleanup() + return LocationSimulationStatus.locationSimulation + } + + LocationSimulationState.remoteServer = nil + + let locationSetError = location_simulation_set( + LocationSimulationState.locationSimulation, + latitude, + longitude + ) + if let locationSetError { + idevice_error_free(locationSetError) + LocationSimulationState.cleanup() + return LocationSimulationStatus.locationSet + } + + return LocationSimulationStatus.ok +} + +func clear_simulated_location() -> Int32 { + guard let locationSimulation = LocationSimulationState.locationSimulation else { + return LocationSimulationStatus.locationClear + } + + let ffiError = location_simulation_clear(locationSimulation) + LocationSimulationState.cleanup() + + if let ffiError { + idevice_error_free(ffiError) + return LocationSimulationStatus.locationClear + } + + return LocationSimulationStatus.ok +} diff --git a/StikJIT/Utilities/Intents.swift b/StikJIT/Utilities/Intents.swift index 40a0af13..3c6ee7ec 100644 --- a/StikJIT/Utilities/Intents.swift +++ b/StikJIT/Utilities/Intents.swift @@ -73,18 +73,14 @@ struct RunningProcessEntity: AppEntity { /// Resolve the current PID for this process by re-fetching the process list. func resolveCurrentPID() -> Int? { var err: NSError? - let entries = FetchDeviceProcessList(&err) ?? [] + let entries = ProcessInfoEntry.currentEntries(&err) for item in entries { - guard let dict = item as? NSDictionary, - let pidNum = dict["pid"] as? NSNumber else { continue } - let name = dict["name"] as? String ?? "" - let bID = dict["bundleID"] as? String ?? "" // Match by bundle ID first (most stable), then by name - if let myBundle = bundleID, !myBundle.isEmpty, bID == myBundle { - return pidNum.intValue + if let myBundle = bundleID, !myBundle.isEmpty, item.bundleID == myBundle { + return item.pid } - if name == displayName { - return pidNum.intValue + if item.displayName == displayName { + return item.pid } } return nil @@ -118,32 +114,16 @@ struct RunningProcessQuery: EntityStringQuery { private func fetchProcessEntities() throws -> [RunningProcessEntity] { var err: NSError? - let entries = FetchDeviceProcessList(&err) ?? [] + let entries = ProcessInfoEntry.currentEntries(&err) if let err { throw err } - return entries.compactMap { item -> RunningProcessEntity? in - guard let dict = item as? NSDictionary, - let pidNumber = dict["pid"] as? NSNumber else { return nil } - let pid = pidNumber.intValue - let name = dict["name"] as? String - let bundleID = dict["bundleID"] as? String - let path = dict["path"] as? String ?? "" - - let displayName: String - if let name, !name.isEmpty { - displayName = name - } else if let bundleID, !bundleID.isEmpty { - displayName = bundleID - } else if let last = path.replacingOccurrences(of: "file://", with: "").split(separator: "/").last { - displayName = String(last) - } else { - displayName = "Process \(pid)" - } - - // Use bundleID as stable ID if available, otherwise fall back to name - let stableID = (bundleID != nil && !bundleID!.isEmpty) ? bundleID! : displayName - - return RunningProcessEntity(id: stableID, pid: pid, displayName: displayName, bundleID: bundleID) + return entries.map { entry in + RunningProcessEntity( + id: entry.stableIdentifier, + pid: entry.pid, + displayName: entry.displayName, + bundleID: entry.bundleID + ) } .sorted { $0.displayName.localizedCaseInsensitiveCompare($1.displayName) == .orderedAscending } } @@ -176,7 +156,7 @@ struct EnableJITIntent: AppIntent, ForegroundContinuableIntent { var scriptData: Data? = nil var scriptName: String? = nil - if let preferred = IntentScriptResolver.preferredScript(for: bundleID) { + if let preferred = ScriptStore.preferredScript(for: bundleID) { scriptData = preferred.data scriptName = preferred.name } @@ -198,9 +178,10 @@ struct EnableJITIntent: AppIntent, ForegroundContinuableIntent { userInfo: ["model": model, "scriptData": sd, "scriptName": name] ) } - DispatchQueue.global(qos: .background).async { - do { try model.runScript(data: sd, name: name) } - catch { LogManager.shared.addErrorLog("Script error: \(error.localizedDescription)") } + do { try model.runScript(data: sd, name: name) } + catch { + semaphore.signal() + LogManager.shared.addErrorLog("Script error: \(error.localizedDescription)") } } } @@ -322,75 +303,3 @@ func ensureTunnel() async { } try? await Task.sleep(nanoseconds: 1_000_000_000) } - -// MARK: - Script Resolution (mirrors HomeView logic) - -enum IntentScriptResolver { - static func preferredScript(for bundleID: String) -> (data: Data, name: String)? { - if let assigned = assignedScript(for: bundleID) { - return assigned - } - return autoScript(for: bundleID) - } - - private static func assignedScript(for bundleID: String) -> (data: Data, name: String)? { - guard let mapping = UserDefaults.standard.dictionary(forKey: "BundleScriptMap") as? [String: String], - let scriptName = mapping[bundleID] else { return nil } - let scriptsDir = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] - .appendingPathComponent("scripts") - let scriptURL = scriptsDir.appendingPathComponent(scriptName) - guard FileManager.default.fileExists(atPath: scriptURL.path), - let data = try? Data(contentsOf: scriptURL) else { return nil } - return (data, scriptName) - } - - private static func autoScript(for bundleID: String) -> (data: Data, name: String)? { - guard ProcessInfo.processInfo.hasTXM else { return nil } - guard #available(iOS 26, *) else { return nil } - let appName = (try? JITEnableContext.shared.getAppList()[bundleID]) ?? storedFavoriteName(for: bundleID) - guard let appName, - let resource = autoScriptResource(for: appName) else { - return nil - } - let scriptsDir = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] - .appendingPathComponent("scripts") - let documentsURL = scriptsDir.appendingPathComponent(resource.fileName) - if let data = try? Data(contentsOf: documentsURL) { - return (data, resource.fileName) - } - guard let bundleURL = Bundle.main.url(forResource: resource.resource, withExtension: "js"), - let data = try? Data(contentsOf: bundleURL) else { - return nil - } - return (data, resource.fileName) - } - - private static func storedFavoriteName(for bundleID: String) -> String? { - let defaults = UserDefaults(suiteName: "group.com.stik.sj") - let names = defaults?.dictionary(forKey: "favoriteAppNames") as? [String: String] - return names?[bundleID] - } - - private static func autoScriptResource(for appName: String) -> (resource: String, fileName: String)? { - switch appName { - case "maciOS": - return ("maciOS", "maciOS.js") - case "Amethyst", "MeloNX": - return ("Amethyst-MeloNX", "Amethyst-MeloNX.js") - case "Geode": - return ("Geode", "Geode.js") - case "Manic EMU": - return ("manic", "manic.js") - case "UTM", "DolphiniOS", "Flycast": - return ("UTM-Dolphin", "UTM-Dolphin.js") - default: - return nil - } - } -} - -// MARK: - Notification for JS Script UI - -extension Notification.Name { - static let intentJSScriptReady = Notification.Name("intentJSScriptReady") -} diff --git a/StikJIT/Utilities/JITEnableContext.swift b/StikJIT/Utilities/JITEnableContext.swift new file mode 100644 index 00000000..04308c06 --- /dev/null +++ b/StikJIT/Utilities/JITEnableContext.swift @@ -0,0 +1,771 @@ +// +// JITEnableContext.swift +// StikDebug +// +// Created by Stephen on 2026/3/30. +// + +import Foundation +import idevice +import Darwin + +typealias LogFunc = (String?) -> Void +typealias DebugAppCallback = (_ pid: Int32, _ debugProxy: OpaquePointer?, _ remoteServer: OpaquePointer?, _ semaphore: DispatchSemaphore) -> Void +typealias SyslogLineHandler = (String) -> Void +typealias SyslogErrorHandler = (NSError?) -> Void + +final class JITEnableContext { + static let shared = JITEnableContext() + + private struct TunnelHandles { + var adapter: OpaquePointer? + var handshake: OpaquePointer? + + mutating func free() { + if let handshake { + rsd_handshake_free(handshake) + self.handshake = nil + } + if let adapter { + adapter_free(adapter) + self.adapter = nil + } + } + } + + private var adapter: OpaquePointer? + private var handshake: OpaquePointer? + + private let tunnelLock = NSLock() + private var tunnelConnecting = false + private var tunnelSemaphore: DispatchSemaphore? + private var lastTunnelError: NSError? + + private let syslogQueue = DispatchQueue(label: "com.stik.syslogrelay.queue") + private var syslogStreaming = false + private var syslogClient: OpaquePointer? + private var syslogLineHandler: SyslogLineHandler? + private var syslogErrorHandler: SyslogErrorHandler? + + var adapterHandle: OpaquePointer? { adapter } + var handshakeHandle: OpaquePointer? { handshake } + + private init() { + let logURL = FileManager.default + .urls(for: .documentDirectory, in: .userDomainMask)[0] + .appendingPathComponent("idevice_log.txt") + + var path = Array(logURL.path.utf8CString) + path.withUnsafeMutableBufferPointer { buffer in + _ = idevice_init_logger(Info, Debug, buffer.baseAddress) + } + } + + deinit { + stopSyslogRelay() + if let handshake { + rsd_handshake_free(handshake) + } + if let adapter { + adapter_free(adapter) + } + } + + private func makeError(_ message: String, code: Int = -1) -> NSError { + NSError( + domain: "StikJIT", + code: code, + userInfo: [NSLocalizedDescriptionKey: message] + ) + } + + private func nsString(from cString: UnsafePointer?, fallback: String) -> String { + guard let cString, let string = String(validatingUTF8: cString) else { + return fallback + } + return string + } + + private func error(from ffiError: UnsafeMutablePointer?, fallback: String) -> NSError { + guard let ffiError else { + return makeError(fallback) + } + let message = nsString(from: ffiError.pointee.message, fallback: fallback) + let error = makeError(message, code: Int(ffiError.pointee.code)) + idevice_error_free(ffiError) + return error + } + + private func routeLog(_ message: String) { + if message.localizedCaseInsensitiveContains("error") { + LogManager.shared.addErrorLog(message) + } else if message.localizedCaseInsensitiveContains("warning") { + LogManager.shared.addWarningLog(message) + } else if message.localizedCaseInsensitiveContains("debug") { + LogManager.shared.addDebugLog(message) + } else { + LogManager.shared.addInfoLog(message) + } + } + + private func emitLog(_ message: String, logger: LogFunc?) { + routeLog(message) + logger?(message) + } + + private func getPairingFile() throws -> OpaquePointer { + let pairingFileURL = PairingFileStore.prepareURL() + + guard FileManager.default.fileExists(atPath: pairingFileURL.path) else { + throw makeError("Pairing file not found!", code: -17) + } + + var pairingFile: OpaquePointer? + let ffiError = pairingFileURL.path.withCString { path in + rp_pairing_file_read(path, &pairingFile) + } + + if let ffiError { + throw error(from: ffiError, fallback: "Failed to read pairing file!") + } + + guard let pairingFile else { + throw makeError("Failed to read pairing file!", code: -17) + } + + return pairingFile + } + + private func createTunnel(hostname: String) throws -> TunnelHandles { + let pairingFile = try getPairingFile() + defer { rp_pairing_file_free(pairingFile) } + + var addr = sockaddr_in() + addr.sin_family = sa_family_t(AF_INET) + addr.sin_port = in_port_t(49152).bigEndian + + let deviceIP = DeviceConnectionContext.targetIPAddress + let parseResult = deviceIP.withCString { inet_pton(AF_INET, $0, &addr.sin_addr) } + guard parseResult == 1 else { + throw makeError("Failed to parse target IP address.", code: -18) + } + + var tunnel = TunnelHandles() + let ffiError = hostname.withCString { hostname in + withUnsafePointer(to: &addr) { pointer in + pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { + tunnel_create_rppairing( + $0, + socklen_t(MemoryLayout.stride), + hostname, + pairingFile, + nil, + nil, + &tunnel.adapter, + &tunnel.handshake + ) + } + } + } + + if let ffiError { + throw error(from: ffiError, fallback: "Failed to create tunnel") + } + + guard tunnel.adapter != nil, tunnel.handshake != nil else { + var incompleteTunnel = tunnel + incompleteTunnel.free() + throw makeError("Tunnel was created without valid handles") + } + + return tunnel + } + + func startTunnel() throws { + tunnelLock.lock() + if tunnelConnecting { + let waitSemaphore = tunnelSemaphore + tunnelLock.unlock() + + if let waitSemaphore { + waitSemaphore.wait() + waitSemaphore.signal() + } + + if let lastTunnelError { + throw lastTunnelError + } + return + } + + tunnelConnecting = true + let completionSemaphore = DispatchSemaphore(value: 0) + tunnelSemaphore = completionSemaphore + tunnelLock.unlock() + + var newAdapter: OpaquePointer? + var newHandshake: OpaquePointer? + var finalError: NSError? + + defer { + tunnelLock.lock() + tunnelConnecting = false + tunnelSemaphore = nil + lastTunnelError = finalError + tunnelLock.unlock() + completionSemaphore.signal() + } + + do { + let newTunnel = try createTunnel(hostname: "StikDebug") + newAdapter = newTunnel.adapter + newHandshake = newTunnel.handshake + } catch let tunnelError as NSError { + finalError = tunnelError + throw tunnelError + } + + if let handshake { + rsd_handshake_free(handshake) + } + if let adapter { + adapter_free(adapter) + } + + adapter = newAdapter + handshake = newHandshake + } + + func ensureTunnel() throws { + if adapter == nil || handshake == nil { + try startTunnel() + } + } + + private func withFreshDebugTunnel( + hostname: String, + _ body: (OpaquePointer, OpaquePointer) throws -> T + ) throws -> T { + var tunnel = try createTunnel(hostname: hostname) + defer { tunnel.free() } + + guard let adapter = tunnel.adapter, let handshake = tunnel.handshake else { + throw makeError("Tunnel is not connected") + } + + return try body(adapter, handshake) + } + + private struct DebugSession { + var remoteServer: OpaquePointer? + var debugProxy: OpaquePointer? + + mutating func free() { + if let debugProxy { + debug_proxy_free(debugProxy) + self.debugProxy = nil + } + if let remoteServer { + remote_server_free(remoteServer) + self.remoteServer = nil + } + } + } + + private final class DebugHeartbeatKeepAlive { + private static let defaultInterval: UInt64 = 2 + private static let maxInterval: UInt64 = 3 + + private let queue = DispatchQueue(label: "com.stikdebug.debug-heartbeat", qos: .utility) + private let stateLock = NSLock() + private let startupSemaphore = DispatchSemaphore(value: 0) + private let stoppedSemaphore = DispatchSemaphore(value: 0) + private let logger: LogFunc? + private let makeClient: () throws -> (client: OpaquePointer, tunnel: TunnelHandles) + private let errorBuilder: (UnsafeMutablePointer?, String) -> NSError + private var startupError: NSError? + private var client: OpaquePointer? + private var tunnel: TunnelHandles? + private var stopRequested = false + + init( + logger: LogFunc?, + makeClient: @escaping () throws -> (client: OpaquePointer, tunnel: TunnelHandles), + errorBuilder: @escaping (UnsafeMutablePointer?, String) -> NSError + ) { + self.logger = logger + self.makeClient = makeClient + self.errorBuilder = errorBuilder + } + + func start() throws { + startupError = nil + queue.async { [weak self] in + self?.run() + } + + startupSemaphore.wait() + if let startupError { + throw startupError + } + } + + func stop() { + stateLock.lock() + stopRequested = true + stateLock.unlock() + _ = stoppedSemaphore.wait(timeout: .now() + .seconds(Int(Self.maxInterval + 1))) + } + + private func shouldStop() -> Bool { + stateLock.lock() + defer { stateLock.unlock() } + return stopRequested + } + + private func log(_ message: String) { + logger?(message) + } + + private func run() { + do { + let resources = try makeClient() + client = resources.client + tunnel = resources.tunnel + startupError = nil + } catch let error as NSError { + startupError = error + startupSemaphore.signal() + stoppedSemaphore.signal() + return + } + + defer { + if let client { + heartbeat_client_free(client) + self.client = nil + } + if var tunnel = tunnel { + tunnel.free() + self.tunnel = nil + } + stoppedSemaphore.signal() + } + + startupSemaphore.signal() + + var interval = Self.defaultInterval + + while !shouldStop(), let client { + var suggestedInterval: UInt64 = 0 + let ffiError = heartbeat_get_marco(client, interval, &suggestedInterval) + + if shouldStop() { + break + } + + if let ffiError { + let heartbeatError = errorBuilder(ffiError, "Debug heartbeat failed") + let description = heartbeatError.localizedDescription + + if description.contains("HeartbeatTimeout") { + interval = Self.defaultInterval + continue + } + + if description.contains("HeartbeatSleepyTime") { + log("Debug heartbeat stopped: device entered SleepyTime") + break + } + + log("Debug heartbeat warning: \(description)") + interval = Self.defaultInterval + continue + } + + interval = min(max(suggestedInterval, 1), Self.maxInterval) + + if let ffiError = heartbeat_send_polo(client) { + let heartbeatError = errorBuilder(ffiError, "Failed to reply to heartbeat") + log("Debug heartbeat warning: \(heartbeatError.localizedDescription)") + interval = Self.defaultInterval + } + } + } + } + + private func connectDebugSession(adapter: OpaquePointer, handshake: OpaquePointer) throws -> DebugSession { + var session = DebugSession() + + if let ffiError = remote_server_connect_rsd(adapter, handshake, &session.remoteServer) { + throw error(from: ffiError, fallback: "Failed to connect remote server") + } + + if let ffiError = debug_proxy_connect_rsd(adapter, handshake, &session.debugProxy) { + session.free() + throw error(from: ffiError, fallback: "Failed to connect debug proxy") + } + + return session + } + + private func withConnectedDebugSession( + _ body: (OpaquePointer, OpaquePointer) throws -> T + ) throws -> T { + try withFreshDebugTunnel(hostname: "StikDebugDebug") { adapter, handshake in + var session = try connectDebugSession(adapter: adapter, handshake: handshake) + defer { session.free() } + + guard let remoteServer = session.remoteServer, + let debugProxy = session.debugProxy else { + throw makeError("Debug session was not created") + } + + return try body(remoteServer, debugProxy) + } + } + + private func withConnectedRemoteServer( + _ body: (OpaquePointer) throws -> T + ) throws -> T { + try ensureTunnel() + guard let adapter, let handshake else { + throw makeError("Tunnel is not connected") + } + + var remoteServer: OpaquePointer? + if let ffiError = remote_server_connect_rsd(adapter, handshake, &remoteServer) { + throw error(from: ffiError, fallback: "Failed to connect remote server") + } + + guard let remoteServer else { + throw makeError("Remote server handle was not created") + } + + defer { remote_server_free(remoteServer) } + return try body(remoteServer) + } + + private func withProcessControl( + remoteServer: OpaquePointer, + _ body: (OpaquePointer) throws -> T + ) throws -> T { + var processControl: OpaquePointer? + if let ffiError = process_control_new(remoteServer, &processControl) { + throw error(from: ffiError, fallback: "Failed to open process control") + } + + guard let processControl else { + throw makeError("Process control handle was not created") + } + + defer { process_control_free(processControl) } + return try body(processControl) + } + + private func connectHeartbeatKeepAlive(logger: LogFunc?) throws -> DebugHeartbeatKeepAlive { + return DebugHeartbeatKeepAlive( + logger: logger, + makeClient: { [weak self] in + guard let self else { + throw NSError( + domain: "StikJIT", + code: -1, + userInfo: [NSLocalizedDescriptionKey: "Debug heartbeat context is unavailable"] + ) + } + + var tunnel = try self.createTunnel(hostname: "StikDebugHeartbeat") + guard let adapter = tunnel.adapter, let handshake = tunnel.handshake else { + tunnel.free() + throw self.makeError("Tunnel is not connected") + } + + var heartbeatClient: OpaquePointer? + if let ffiError = heartbeat_connect_rsd(adapter, handshake, &heartbeatClient) { + tunnel.free() + throw self.error(from: ffiError, fallback: "Failed to connect debug heartbeat") + } + + guard let heartbeatClient else { + tunnel.free() + throw self.makeError("Heartbeat client was not created") + } + + return (client: heartbeatClient, tunnel: tunnel) + }, + errorBuilder: { [weak self] ffiError, fallback in + self?.error(from: ffiError, fallback: fallback) ?? NSError( + domain: "StikJIT", + code: -1, + userInfo: [NSLocalizedDescriptionKey: fallback] + ) + } + ) + } + + private func sendDebugCommand(_ command: String, debugProxy: OpaquePointer) throws -> String? { + guard let commandHandle = debugserver_command_new(command, nil, 0) else { + throw makeError("Failed to create debugserver command: \(command)") + } + + var response: UnsafeMutablePointer? + let ffiError = debug_proxy_send_command(debugProxy, commandHandle, &response) + debugserver_command_free(commandHandle) + + if let ffiError { + if let response { + idevice_string_free(response) + } + throw error(from: ffiError, fallback: "Debugserver command failed: \(command)") + } + + defer { + if let response { + idevice_string_free(response) + } + } + + guard let response else { return nil } + return String(cString: response) + } + + private func runDebugServerCommand( + pid: Int32, + debugProxy: OpaquePointer, + remoteServer: OpaquePointer, + logger: LogFunc?, + callback: DebugAppCallback? + ) { + debug_proxy_send_ack(debugProxy) + debug_proxy_send_ack(debugProxy) + + do { + let response = try sendDebugCommand("QStartNoAckMode", debugProxy: debugProxy) ?? "" + emitLog("QStartNoAckMode result = \(response)", logger: logger) + } catch { + emitLog(error.localizedDescription, logger: logger) + } + + debug_proxy_set_ack_mode(debugProxy, 0) + + if let callback { + let keepAlive: DebugHeartbeatKeepAlive? + do { + let heartbeat = try connectHeartbeatKeepAlive(logger: logger) + try heartbeat.start() + keepAlive = heartbeat + emitLog("Debug heartbeat keepalive started", logger: logger) + } catch { + keepAlive = nil + emitLog("Warning: failed to start debug heartbeat keepalive: \(error.localizedDescription)", logger: logger) + } + defer { + keepAlive?.stop() + if keepAlive != nil { + emitLog("Debug heartbeat keepalive stopped", logger: logger) + } + } + + let semaphore = DispatchSemaphore(value: 0) + callback(pid, debugProxy, remoteServer, semaphore) + semaphore.wait() + + var breakByte: UInt8 = 0x03 + if let ffiError = debug_proxy_send_raw(debugProxy, &breakByte, 1) { + emitLog(error(from: ffiError, fallback: "Failed to interrupt target").localizedDescription, logger: logger) + } + usleep(500) + } else { + let attachCommand = "vAttach;\(String(UInt32(pid), radix: 16))" + do { + let response = try sendDebugCommand(attachCommand, debugProxy: debugProxy) ?? "" + emitLog("Attach response: \(response)", logger: logger) + } catch { + emitLog(error.localizedDescription, logger: logger) + } + } + + do { + let response = try sendDebugCommand("D", debugProxy: debugProxy) + if let response { + emitLog("Detach response: \(response)", logger: logger) + } + } catch { + emitLog(error.localizedDescription, logger: logger) + } + } + + func debugApp(withBundleID bundleID: String, logger: LogFunc?, jsCallback: DebugAppCallback?) -> Bool { + runDebugSession(logger: logger, callback: jsCallback) { remoteServer in + try withProcessControl(remoteServer: remoteServer) { processControl in + var pid: UInt64 = 0 + let ffiError = bundleID.withCString { bundleID in + process_control_launch_app(processControl, bundleID, nil, 0, nil, 0, true, false, &pid) + } + + if let ffiError { + throw error(from: ffiError, fallback: "Failed to launch app") + } + + return Int32(pid) + } + } + } + + func debugApp(withPID pid: Int32, logger: LogFunc?, jsCallback: DebugAppCallback?) -> Bool { + runDebugSession(logger: logger, callback: jsCallback) { _ in pid } + } + + func launchAppWithoutDebug(_ bundleID: String, logger: LogFunc?) -> Bool { + do { + let pid = try withConnectedRemoteServer { remoteServer in + try withProcessControl(remoteServer: remoteServer) { processControl in + var pid: UInt64 = 0 + let ffiError = bundleID.withCString { bundleID in + process_control_launch_app(processControl, bundleID, nil, 0, nil, 0, false, true, &pid) + } + + if let ffiError { + throw error(from: ffiError, fallback: "Failed to launch app") + } + + return pid + } + } + + emitLog("Launched app (PID \(pid))", logger: logger) + return true + } catch { + emitLog(error.localizedDescription, logger: logger) + return false + } + } + + func startSyslogRelay(handler: @escaping SyslogLineHandler, onError: @escaping SyslogErrorHandler) { + do { + try ensureTunnel() + } catch let nsError as NSError { + onError(nsError) + return + } catch { + onError(makeError(error.localizedDescription)) + return + } + + guard !syslogStreaming else { return } + + syslogStreaming = true + syslogLineHandler = handler + syslogErrorHandler = onError + + syslogQueue.async { [weak self] in + guard let self else { return } + guard let adapter = self.adapter, let handshake = self.handshake else { + self.handleSyslogFailure(self.makeError("Tunnel is not connected")) + return + } + + var client: OpaquePointer? + if let ffiError = syslog_relay_connect_rsd(adapter, handshake, &client) { + self.handleSyslogFailure(self.error(from: ffiError, fallback: "Failed to connect to syslog relay")) + return + } + + self.syslogClient = client + + while self.syslogStreaming, let client = self.syslogClient { + var message: UnsafeMutablePointer? + let ffiError = syslog_relay_next(client, &message) + if let ffiError { + if let message { + idevice_string_free(message) + } + self.handleSyslogFailure(self.error(from: ffiError, fallback: "Syslog relay read failed")) + break + } + + guard let message else { + continue + } + + let line = String(cString: message) + idevice_string_free(message) + + if let handler = self.syslogLineHandler { + DispatchQueue.main.async { + handler(line) + } + } + } + + if let client = self.syslogClient { + syslog_relay_client_free(client) + } + + self.syslogClient = nil + self.syslogStreaming = false + self.syslogLineHandler = nil + self.syslogErrorHandler = nil + } + } + + func stopSyslogRelay() { + guard syslogStreaming else { return } + + syslogStreaming = false + syslogLineHandler = nil + syslogErrorHandler = nil + + syslogQueue.async { [weak self] in + guard let self else { return } + if let syslogClient = self.syslogClient { + syslog_relay_client_free(syslogClient) + self.syslogClient = nil + } + } + } + + private func runDebugSession( + logger: LogFunc?, + callback: DebugAppCallback?, + pidProvider: (OpaquePointer) throws -> Int32 + ) -> Bool { + do { + try withConnectedDebugSession { remoteServer, debugProxy in + let pid = try pidProvider(remoteServer) + runDebugServerCommand( + pid: pid, + debugProxy: debugProxy, + remoteServer: remoteServer, + logger: logger, + callback: callback + ) + } + + emitLog("Debug session completed", logger: logger) + return true + } catch { + emitLog(error.localizedDescription, logger: logger) + return false + } + } + + private func handleSyslogFailure(_ error: NSError) { + syslogStreaming = false + if let syslogClient { + syslog_relay_client_free(syslogClient) + self.syslogClient = nil + } + + let errorHandler = syslogErrorHandler + syslogLineHandler = nil + syslogErrorHandler = nil + + if let errorHandler { + DispatchQueue.main.async { + errorHandler(error) + } + } + } +} diff --git a/StikJIT/Utilities/ProcessInspectorBridge.h b/StikJIT/Utilities/ProcessInspectorBridge.h deleted file mode 100644 index d90c89af..00000000 --- a/StikJIT/Utilities/ProcessInspectorBridge.h +++ /dev/null @@ -1,13 +0,0 @@ -// -// ProcessInspectorBridge.h -// StikJIT -// - -#import "../idevice/JITEnableContext.h" - -NS_ASSUME_NONNULL_BEGIN - -FOUNDATION_EXPORT NSArray * _Nullable FetchDeviceProcessList(NSError **error); -FOUNDATION_EXPORT BOOL KillDeviceProcess(int pid, NSError **error); - -NS_ASSUME_NONNULL_END diff --git a/StikJIT/Utilities/ProcessInspectorBridge.m b/StikJIT/Utilities/ProcessInspectorBridge.m deleted file mode 100644 index e130603b..00000000 --- a/StikJIT/Utilities/ProcessInspectorBridge.m +++ /dev/null @@ -1,34 +0,0 @@ -// -// ProcessInspectorBridge.m -// StikJIT -// - -#import "ProcessInspectorBridge.h" - -NSArray * _Nullable FetchDeviceProcessList(NSError **error) { - @try { - return [[JITEnableContext shared] fetchProcessListWithError:error]; - } @catch (NSException *exception) { - if (error) { - NSString *message = [NSString stringWithFormat:@"Process fetch failed: %@", exception.reason ?: @"Unknown error"]; - *error = [NSError errorWithDomain:@"ProcessInspectorBridge" - code:-500 - userInfo:@{NSLocalizedDescriptionKey: message}]; - } - return nil; - } -} - -BOOL KillDeviceProcess(int pid, NSError **error) { - @try { - return [[JITEnableContext shared] killProcessWithPID:pid error:error]; - } @catch (NSException *exception) { - if (error) { - NSString *message = [NSString stringWithFormat:@"Process kill failed: %@", exception.reason ?: @"Unknown error"]; - *error = [NSError errorWithDomain:@"ProcessInspectorBridge" - code:-501 - userInfo:@{NSLocalizedDescriptionKey: message}]; - } - return NO; - } -} diff --git a/StikJIT/Utilities/Security.swift b/StikJIT/Utilities/Security.swift index f49eb2a4..d2c9a968 100644 --- a/StikJIT/Utilities/Security.swift +++ b/StikJIT/Utilities/Security.swift @@ -4,6 +4,7 @@ // from MeloNX // Created by s s on 2025/4/6. // +import Foundation import Security typealias SecTaskRef = OpaquePointer @@ -11,8 +12,8 @@ typealias SecTaskRef = OpaquePointer @_silgen_name("SecTaskCopyValueForEntitlement") func SecTaskCopyValueForEntitlement( _ task: SecTaskRef, - _ entitlement: NSString, - _ error: NSErrorPointer + _ entitlement: CFString, + _ error: UnsafeMutablePointer?>? ) -> CFTypeRef? @_silgen_name("SecTaskCreateFromSelf") @@ -22,6 +23,6 @@ func SecTaskCreateFromSelf( func checkAppEntitlement(_ ent: String) -> Bool { guard let task = SecTaskCreateFromSelf(nil) else { return false } - guard let value = SecTaskCopyValueForEntitlement(task, ent as NSString, nil) else { return false } + guard let value = SecTaskCopyValueForEntitlement(task, ent as CFString, nil) else { return false } return value.boolValue != nil && value.boolValue } diff --git a/StikJIT/Utilities/SystemLogStream.swift b/StikJIT/Utilities/SystemLogStream.swift index 5e961cf2..e98fb3d0 100644 --- a/StikJIT/Utilities/SystemLogStream.swift +++ b/StikJIT/Utilities/SystemLogStream.swift @@ -55,10 +55,13 @@ final class SystemLogStream: ObservableObject { startBatchTimer() JITEnableContext.shared.startSyslogRelay(handler: { [weak self] line in - guard let line else { return } - self?.handleLine(line) + Task { @MainActor [weak self] in + self?.handleLine(line) + } }, onError: { [weak self] error in - self?.handleError(error as NSError?) + Task { @MainActor [weak self] in + self?.handleError(error) + } }) } @@ -136,9 +139,11 @@ final class SystemLogStream: ObservableObject { private func startBatchTimer() { batchTimer?.invalidate() batchTimer = Timer.scheduledTimer(withTimeInterval: Self.batchInterval, repeats: true) { [weak self] _ in - guard let self else { return } - guard !self.isPaused, self.updateInterval == 0, !self.pendingEntries.isEmpty else { return } - self.flushAllPending() + Task { @MainActor [weak self] in + guard let self else { return } + guard !self.isPaused, self.updateInterval == 0, !self.pendingEntries.isEmpty else { return } + self.flushAllPending() + } } if let batchTimer { RunLoop.main.add(batchTimer, forMode: .common) @@ -178,12 +183,14 @@ final class SystemLogStream: ObservableObject { } flushTimer = Timer.scheduledTimer(withTimeInterval: updateInterval, repeats: false) { [weak self] _ in - guard let self else { return } - self.flushTimer = nil - guard !self.isPaused else { return } - self.flushAllPending() - if !self.pendingEntries.isEmpty { - self.scheduleFlushIfNeeded() + Task { @MainActor [weak self] in + guard let self else { return } + self.flushTimer = nil + guard !self.isPaused else { return } + self.flushAllPending() + if !self.pendingEntries.isEmpty { + self.scheduleFlushIfNeeded() + } } } @@ -196,10 +203,12 @@ final class SystemLogStream: ObservableObject { guard !isStreaming else { return } guard retryTimer == nil else { return } let timer = Timer(timeInterval: 2.0, repeats: false) { [weak self] _ in - guard let self else { return } - self.retryTimer = nil - if !self.isStreaming && !self.isPaused { - self.start() + Task { @MainActor [weak self] in + guard let self else { return } + self.retryTimer = nil + if !self.isStreaming && !self.isPaused { + self.start() + } } } retryTimer = timer diff --git a/StikJIT/Views/ConsoleLogsView.swift b/StikJIT/Views/ConsoleLogsView.swift index 258f8b95..ac592e83 100644 --- a/StikJIT/Views/ConsoleLogsView.swift +++ b/StikJIT/Views/ConsoleLogsView.swift @@ -17,7 +17,6 @@ struct ConsoleLogsView: View { @State private var showingCustomAlert = false @State private var alertMessage = "" @State private var alertTitle = "" - @State private var isError = false @State private var logCheckTimer: Timer? = nil @@ -111,12 +110,9 @@ struct ConsoleLogsView: View { .onDisappear { systemLogStream.stop() } - .onChange(of: systemLogStream.lastError) { newError in + .onChange(of: systemLogStream.lastError) { _, newError in if let error = newError { - alertTitle = "Syslog Error" - alertMessage = error - isError = true - showingCustomAlert = true + presentAlert(title: "Syslog Error", message: error) systemLogStream.lastError = nil } } @@ -177,7 +173,7 @@ struct ConsoleLogsView: View { .onPreferenceChange(ScrollOffsetPreferenceKey.self) { offset in jitIsAtBottom = offset > -20 } - .onChange(of: logManager.logs.count) { _ in + .onChange(of: logManager.logs.count) { _, _ in guard jitIsAtBottom, let lastLog = logManager.logs.last else { return } withAnimation { proxy.scrollTo(lastLog.id, anchor: .bottom) @@ -246,7 +242,7 @@ struct ConsoleLogsView: View { .onPreferenceChange(ScrollOffsetPreferenceKey.self) { offset in syslogIsAtBottom = offset > -20 } - .onChange(of: systemLogStream.entries.count) { _ in + .onChange(of: systemLogStream.entries.count) { _, _ in guard syslogIsAtBottom, syslogSearchText.isEmpty, let lastLog = systemLogStream.entries.last else { return } withAnimation { @@ -276,10 +272,7 @@ struct ConsoleLogsView: View { "[\(formatTime(date: $0.timestamp))] [\($0.type.rawValue)] \($0.message)" }.joined(separator: "\n") UIPasteboard.general.string = logsContent - alertTitle = "Logs Copied" - alertMessage = "Logs have been copied to clipboard." - isError = false - showingCustomAlert = true + presentAlert(title: "Logs Copied", message: "Logs have been copied to clipboard.") } @ViewBuilder @@ -294,10 +287,7 @@ struct ConsoleLogsView: View { } } else { Button("Export Logs", systemImage: "square.and.arrow.up") { - alertTitle = "Export Failed" - alertMessage = "No idevice logs found" - isError = true - showingCustomAlert = true + presentAlert(title: "Export Failed", message: "No idevice logs found") } } } @@ -332,7 +322,7 @@ struct ConsoleLogsView: View { } private func createSyslogAttributedString(_ entry: SystemLogStream.Entry) -> NSAttributedString { - let type = logType(for: entry.raw) + let type = Self.logType(for: entry.raw) let fullString = NSMutableAttributedString() let timestampString = "[\(DateFormatter.consoleLogsFormatter.string(from: entry.timestamp))]" @@ -358,9 +348,7 @@ struct ConsoleLogsView: View { return fullString } private func formatTime(date: Date) -> String { - let formatter = DateFormatter() - formatter.dateFormat = "HH:mm:ss" - return formatter.string(from: date) + DateFormatter.consoleLogsFormatter.string(from: date) } private func colorForLogType(_ type: LogManager.LogEntry.LogType) -> Color { @@ -376,7 +364,7 @@ struct ConsoleLogsView: View { } } - private func logType(for line: String) -> LogManager.LogEntry.LogType { + nonisolated private static func logType(for line: String) -> LogManager.LogEntry.LogType { let lowercase = line.lowercased() if lowercase.contains("error") { return .error @@ -391,7 +379,7 @@ struct ConsoleLogsView: View { private var syslogErrorCount: Int { systemLogStream.entries.reduce(0) { count, entry in - count + (logType(for: entry.raw) == .error ? 1 : 0) + count + (Self.logType(for: entry.raw) == .error ? 1 : 0) } } @@ -428,26 +416,7 @@ struct ConsoleLogsView: View { let skipPrefixes = ["=== DEVICE INFORMATION ===", "Version:", "Name:", "Model:", "=== LOG ENTRIES ==="] - var parsed: [LogManager.LogEntry] = [] - parsed.reserveCapacity(recentLines.count) - - for line in recentLines { - if line.isEmpty { continue } - if skipPrefixes.contains(where: { line.contains($0) }) { continue } - - let type: LogManager.LogEntry.LogType - if line.contains("ERROR") || line.contains("Error") { - type = .error - } else if line.contains("WARNING") || line.contains("Warning") { - type = .warning - } else if line.contains("DEBUG") { - type = .debug - } else { - type = .info - } - parsed.append(LogManager.LogEntry(timestamp: Date(), type: type, message: line)) - } - + let parsed = Self.parseAppLogEntries(from: recentLines, skipPrefixes: skipPrefixes) return (parsed, lines.count) } catch { return nil @@ -501,25 +470,7 @@ struct ConsoleLogsView: View { guard lines.count > previousCount else { return ([], lines.count) } let newLines = lines[previousCount..(from lines: S, skipPrefixes: [String] = []) -> [LogManager.LogEntry] where S.Element == String { + var parsed: [LogManager.LogEntry] = [] + parsed.reserveCapacity(lines.underestimatedCount) + + for line in lines { + if line.isEmpty { continue } + if skipPrefixes.contains(where: { line.contains($0) }) { continue } + parsed.append(LogManager.LogEntry(timestamp: Date(), type: Self.logType(for: line), message: line)) + } + return parsed + } + } diff --git a/StikJIT/Views/HomeView.swift b/StikJIT/Views/HomeView.swift index f05b1d61..d21ec515 100644 --- a/StikJIT/Views/HomeView.swift +++ b/StikJIT/Views/HomeView.swift @@ -6,7 +6,6 @@ // import SwiftUI -import UniformTypeIdentifiers import UIKit struct JITEnableConfiguration { @@ -16,6 +15,63 @@ struct JITEnableConfiguration { var scriptName : String? = nil } +private final class DebugKeepAliveLease { + private let stateLock = NSLock() + private var isActive = false + private var backgroundTaskID: UIBackgroundTaskIdentifier = .invalid + + init() { + activate() + } + + func invalidate() { + stateLock.lock() + guard isActive else { + stateLock.unlock() + return + } + isActive = false + stateLock.unlock() + + runOnMain { + BackgroundAudioManager.shared.requestStop() + BackgroundLocationManager.shared.requestStop() + + if self.backgroundTaskID != .invalid { + UIApplication.shared.endBackgroundTask(self.backgroundTaskID) + self.backgroundTaskID = .invalid + } + } + } + + private func activate() { + stateLock.lock() + guard !isActive else { + stateLock.unlock() + return + } + isActive = true + stateLock.unlock() + + runOnMain { + BackgroundAudioManager.shared.requestStart() + BackgroundLocationManager.shared.requestStart() + self.backgroundTaskID = UIApplication.shared.beginBackgroundTask(withName: "StikDebugDebugSession") { [weak self] in + LogManager.shared.addWarningLog("Debug session background task expired") + self?.invalidate() + } + } + } + + private func runOnMain(_ work: @escaping () -> Void) { + if Thread.isMainThread { + work() + } else { + DispatchQueue.main.sync(execute: work) + } + } +} + struct HomeView: View { @AppStorage("autoQuitAfterEnablingJIT") private var doAutoQuitAfterEnablingJIT = false @@ -27,7 +83,7 @@ struct HomeView: View { @State var scriptViewShow = false @State private var isShowingConsole = false - @AppStorage("DefaultScriptName") var selectedScript = "attachDetach.js" + @AppStorage(UserDefaults.Keys.defaultScriptName) var selectedScript = UserDefaults.Keys.defaultScriptNameValue @State var jsModel: RunJSViewModel? @ObservedObject private var mounting = MountingProgress.shared @@ -83,7 +139,7 @@ struct HomeView: View { config.scriptName = scriptName } if config.scriptData == nil, let bundleID = config.bundleID, - let scriptInfo = preferredScript(for: bundleID) { + let scriptInfo = ScriptStore.preferredScript(for: bundleID) { config.scriptData = scriptInfo.data config.scriptName = scriptInfo.name } @@ -121,33 +177,25 @@ struct HomeView: View { break } } - .fileImporter(isPresented: $isShowingPairingFilePicker, allowedContentTypes: [UTType(filenameExtension: "mobiledevicepairing", conformingTo: .data)!, UTType(filenameExtension: "mobiledevicepair", conformingTo: .data)!, .propertyList]) { result in + .fileImporter(isPresented: $isShowingPairingFilePicker, allowedContentTypes: PairingFileStore.supportedContentTypes) { result in switch result { case .success(let url): let fileManager = FileManager.default - let accessing = url.startAccessingSecurityScopedResource() - if fileManager.fileExists(atPath: url.path) { - do { - let dest = URL.documentsDirectory.appendingPathComponent("pairingFile.plist") - if fileManager.fileExists(atPath: dest.path) { - try fileManager.removeItem(at: dest) - } - try fileManager.copyItem(at: url, to: dest) - pubTunnelConnected = false - startTunnelInBackground() - NotificationCenter.default.post(name: .pairingFileImported, object: nil) - // Dismiss any existing connection error alert - if let scene = UIApplication.shared.connectedScenes.first as? UIWindowScene, - let root = scene.windows.first?.rootViewController { - var top = root - while let presented = top.presentedViewController { top = presented } - if top is UIAlertController { top.dismiss(animated: true) } - } - } catch { - print("Error copying pairing file: \(error)") + do { + try PairingFileStore.importFromPicker(url, fileManager: fileManager) + pubTunnelConnected = false + startTunnelInBackground() + NotificationCenter.default.post(name: .pairingFileImported, object: nil) + // Dismiss any existing connection error alert + if let scene = UIApplication.shared.connectedScenes.first as? UIWindowScene, + let root = scene.windows.first?.rootViewController { + var top = root + while let presented = top.presentedViewController { top = presented } + if top is UIAlertController { top.dismiss(animated: true) } } + } catch { + print("Error copying pairing file: \(error)") } - if accessing { url.stopAccessingSecurityScopedResource() } case .failure(let error): print("Failed to import pairing file: \(error)") } @@ -179,70 +227,6 @@ struct HomeView: View { } } } - - - private func autoScript(for bundleID: String) -> (data: Data, name: String)? { - guard ProcessInfo.processInfo.hasTXM else { return nil } - guard #available(iOS 26, *) else { return nil } - let appName = (try? JITEnableContext.shared.getAppList()[bundleID]) ?? storedFavoriteName(for: bundleID) - guard let appName, - let resource = autoScriptResource(for: appName) else { - return nil - } - let scriptsDir = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] - .appendingPathComponent("scripts") - let documentsURL = scriptsDir.appendingPathComponent(resource.fileName) - if let data = try? Data(contentsOf: documentsURL) { - return (data, resource.fileName) - } - guard let bundleURL = Bundle.main.url(forResource: resource.resource, withExtension: "js"), - let data = try? Data(contentsOf: bundleURL) else { - return nil - } - return (data, resource.fileName) - } - - private func assignedScript(for bundleID: String) -> (data: Data, name: String)? { - guard let mapping = UserDefaults.standard.dictionary(forKey: "BundleScriptMap") as? [String: String], - let scriptName = mapping[bundleID] else { return nil } - let scriptsDir = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] - .appendingPathComponent("scripts") - let scriptURL = scriptsDir.appendingPathComponent(scriptName) - guard FileManager.default.fileExists(atPath: scriptURL.path), - let data = try? Data(contentsOf: scriptURL) else { return nil } - return (data, scriptName) - } - - private func preferredScript(for bundleID: String) -> (data: Data, name: String)? { - if let assigned = assignedScript(for: bundleID) { - return assigned - } - return autoScript(for: bundleID) - } - - private func storedFavoriteName(for bundleID: String) -> String? { - let defaults = UserDefaults(suiteName: "group.com.stik.sj") - let names = defaults?.dictionary(forKey: "favoriteAppNames") as? [String: String] - return names?[bundleID] - } - - private func autoScriptResource(for appName: String) -> (resource: String, fileName: String)? { - switch appName { - case "maciOS": - return ("maciOS", "maciOS.js") - case "Amethyst", "MeloNX", "XeniOS", "MeloCafe": - return ("universal", "universal.js") - case "Geode": - return ("Geode", "Geode.js") - case "Manic EMU": - return ("manic", "manic.js") - case "UTM", "DolphiniOS", "Flycast": - return ("UTM-Dolphin", "UTM-Dolphin.js") - default: - return nil - } - } - private func getJsCallback(_ script: Data, name: String? = nil) -> DebugAppCallback { return { pid, debugProxyHandle, remoteServerHandle, semaphore in let model = RunJSViewModel(pid: Int(pid), @@ -255,9 +239,13 @@ struct HomeView: View { scriptViewShow = true } - DispatchQueue.global(qos: .background).async { - do { try model.runScript(data: script, name: name) } - catch { showAlert(title: "Error Occurred While Executing Script.".localized, message: error.localizedDescription, showOk: true) } + do { + try model.runScript(data: script, name: name) + } catch { + semaphore.signal() + DispatchQueue.main.async { + showAlert(title: "Error Occurred While Executing Script.".localized, message: error.localizedDescription, showOk: true) + } } } } @@ -265,7 +253,6 @@ struct HomeView: View { private func startJITInBackground(bundleID: String? = nil, pid: Int? = nil, scriptData: Data? = nil, scriptName: String? = nil, triggeredByURLScheme: Bool = false) { isProcessing = true LogManager.shared.addInfoLog("Starting Debug for \(bundleID ?? String(pid ?? 0))") - BackgroundLocationManager.shared.requestStart() if triggeredByURLScheme { pubTunnelConnected = false @@ -273,6 +260,8 @@ struct HomeView: View { } DispatchQueue.global(qos: .background).async { + let keepAliveLease = DebugKeepAliveLease() + defer { keepAliveLease.invalidate() } if triggeredByURLScheme { sleep(1) @@ -280,7 +269,6 @@ struct HomeView: View { let finishProcessing = { DispatchQueue.main.async { isProcessing = false - BackgroundLocationManager.shared.requestStop() } } @@ -288,7 +276,7 @@ struct HomeView: View { var scriptName = scriptName if scriptData == nil, let bundleID, - let preferred = preferredScript(for: bundleID) { + let preferred = ScriptStore.preferredScript(for: bundleID) { scriptName = preferred.name scriptData = preferred.data } diff --git a/StikJIT/Views/InstalledAppsListView.swift b/StikJIT/Views/InstalledAppsListView.swift index adc15a12..9b03b31a 100644 --- a/StikJIT/Views/InstalledAppsListView.swift +++ b/StikJIT/Views/InstalledAppsListView.swift @@ -15,7 +15,7 @@ import Combine struct InstalledAppsListView: View { @StateObject private var viewModel = InstalledAppsViewModel() - private let sharedDefaults = UserDefaults(suiteName: "group.com.stik.sj") ?? .standard + private let sharedDefaults = UserDefaults(suiteName: ScriptStore.favoriteAppNamesSuiteName) ?? .standard @AppStorage("recentApps") private var recentApps: [String] = [] @AppStorage("favoriteApps") private var favoriteApps: [String] = [] { @@ -414,7 +414,7 @@ private enum AppListTab: Int, CaseIterable, Identifiable { let prevF = (sharedDefaults.array(forKey: "favoriteApps") as? [String]) ?? [] let prevPinned = (sharedDefaults.array(forKey: "pinnedSystemApps") as? [String]) ?? [] let prevPinnedNames = (sharedDefaults.dictionary(forKey: "pinnedSystemAppNames") as? [String: String]) ?? [:] - let prevFavNames = (sharedDefaults.dictionary(forKey: "favoriteAppNames") as? [String: String]) ?? [:] + let prevFavNames = (sharedDefaults.dictionary(forKey: ScriptStore.favoriteAppNamesKey) as? [String: String]) ?? [:] if prevR != recentApps { sharedDefaults.set(recentApps, forKey: "recentApps") @@ -441,7 +441,7 @@ private enum AppListTab: Int, CaseIterable, Identifiable { return (id, name) }) if prevFavNames != computedFavNames { - sharedDefaults.set(computedFavNames, forKey: "favoriteAppNames") + sharedDefaults.set(computedFavNames, forKey: ScriptStore.favoriteAppNamesKey) touched = true } @@ -693,16 +693,14 @@ struct AppButton: View { } private func assignScript(_ url: URL?) { - var mapping = UserDefaults.standard.dictionary(forKey: "BundleScriptMap") as? [String: String] ?? [:] if let url { let filename = url.lastPathComponent - mapping[bundleID] = filename + ScriptStore.updateAssignedScriptName(filename, for: bundleID) assignedScriptName = filename } else { - mapping.removeValue(forKey: bundleID) + ScriptStore.updateAssignedScriptName(nil, for: bundleID) assignedScriptName = nil } - UserDefaults.standard.set(mapping, forKey: "BundleScriptMap") Haptics.light() } @@ -711,8 +709,7 @@ struct AppButton: View { } private static func currentAssignment(for bundleID: String) -> String? { - let mapping = UserDefaults.standard.dictionary(forKey: "BundleScriptMap") as? [String: String] - return mapping?[bundleID] + ScriptStore.assignedScriptName(for: bundleID) } private func persistIfChanged() { diff --git a/StikJIT/Views/MainTabView.swift b/StikJIT/Views/MainTabView.swift index 77da5214..84d017c1 100644 --- a/StikJIT/Views/MainTabView.swift +++ b/StikJIT/Views/MainTabView.swift @@ -26,7 +26,7 @@ struct MainTabView: View { @State private var didSetInitialHome = false private var configurableTabs: [TabDescriptor] { - var tabs: [TabDescriptor] = [ + let tabs: [TabDescriptor] = [ TabDescriptor(id: "home", title: "Apps", systemImage: "square.grid.2x2") { AnyView(HomeView()) }, TabDescriptor(id: "scripts", title: "Scripts", systemImage: "scroll") { AnyView(ScriptListView()) }, TabDescriptor(id: "tools", title: "Tools", systemImage: "wrench.and.screwdriver") { AnyView(ToolsView()) }, @@ -108,7 +108,7 @@ struct MainTabView: View { switchObserver = nil } } - .onChange(of: enabledTabIdentifiers) { _ in + .onChange(of: enabledTabIdentifiers) { _, _ in ensureSelectionIsValid() } .sheet(item: $detachedTab) { descriptor in diff --git a/StikJIT/Views/MapSelectionView.swift b/StikJIT/Views/MapSelectionView.swift index f5eb595f..b787e583 100644 --- a/StikJIT/Views/MapSelectionView.swift +++ b/StikJIT/Views/MapSelectionView.swift @@ -9,12 +9,338 @@ import SwiftUI import MapKit import UIKit -extension CLLocationCoordinate2D: Equatable { - public static func == (lhs: CLLocationCoordinate2D, rhs: CLLocationCoordinate2D) -> Bool { - lhs.latitude == rhs.latitude && lhs.longitude == rhs.longitude +private struct CoordinateSnapshot: Equatable { + let latitude: Double + let longitude: Double + + init(_ coordinate: CLLocationCoordinate2D) { + latitude = coordinate.latitude + longitude = coordinate.longitude + } + + var coordinate: CLLocationCoordinate2D { + CLLocationCoordinate2D(latitude: latitude, longitude: longitude) } } +private struct RouteSearchSelection { + let title: String + let coordinate: CLLocationCoordinate2D +} + +private enum RouteSearchField { + case start + case end +} + +private struct RouteSimulationPlan { + let displayCoordinates: [CLLocationCoordinate2D] + let distance: CLLocationDistance + let expectedTravelTime: TimeInterval +} + +private enum RouteSimulationDefaults { + static let pathSamplingDistance: CLLocationDistance = 10 + static let playbackTickInterval: TimeInterval = 0.5 + static let minimumSpeedMetersPerSecond: CLLocationSpeed = 1.0 +} + +private struct RoutePlaybackSample { + let coordinate: CLLocationCoordinate2D + let delayFromPrevious: TimeInterval +} + +private struct OpenStreetMapWay { + let geometry: [CLLocationCoordinate2D] + let speedLimitMetersPerSecond: CLLocationSpeed +} + +private enum OpenStreetMapSpeedLimitService { + static let endpoint = URL(string: "https://overpass-api.de/api/interpreter")! + static let copyrightURL = URL(string: "https://www.openstreetmap.org/copyright")! + static let boundingBoxPaddingDegrees = 0.0015 + static let nearestWayThreshold: CLLocationDistance = 40 +} + +private struct OverpassResponse: Decodable { + let elements: [Element] + + struct Element: Decodable { + let tags: [String: String]? + let geometry: [Coordinate]? + } + + struct Coordinate: Decodable { + let lat: Double + let lon: Double + } +} + +private extension MKPolyline { + var coordinateArray: [CLLocationCoordinate2D] { + var coordinates = [CLLocationCoordinate2D]( + repeating: CLLocationCoordinate2D(latitude: 0, longitude: 0), + count: pointCount + ) + getCoordinates(&coordinates, range: NSRange(location: 0, length: pointCount)) + return coordinates + } +} + +private func interpolateCoordinate( + from start: CLLocationCoordinate2D, + to end: CLLocationCoordinate2D, + fraction: Double +) -> CLLocationCoordinate2D { + CLLocationCoordinate2D( + latitude: start.latitude + ((end.latitude - start.latitude) * fraction), + longitude: start.longitude + ((end.longitude - start.longitude) * fraction) + ) +} + +private func sampledRouteCoordinates( + from coordinates: [CLLocationCoordinate2D], + targetDistance: CLLocationDistance +) -> [CLLocationCoordinate2D] { + guard coordinates.count > 1 else { return coordinates } + + var sampled = [coordinates[0]] + for (start, end) in zip(coordinates, coordinates.dropFirst()) { + let distance = CLLocation(latitude: start.latitude, longitude: start.longitude) + .distance(from: CLLocation(latitude: end.latitude, longitude: end.longitude)) + let segmentCount = max(1, Int(ceil(distance / targetDistance))) + for index in 1...segmentCount { + let point = interpolateCoordinate( + from: start, + to: end, + fraction: Double(index) / Double(segmentCount) + ) + if sampled.last.map(CoordinateSnapshot.init) != CoordinateSnapshot(point) { + sampled.append(point) + } + } + } + + return sampled +} + +private func midpointCoordinate( + from start: CLLocationCoordinate2D, + to end: CLLocationCoordinate2D +) -> CLLocationCoordinate2D { + interpolateCoordinate(from: start, to: end, fraction: 0.5) +} + +private func distanceFromPoint( + _ point: MKMapPoint, + toSegmentFrom start: MKMapPoint, + to end: MKMapPoint +) -> CLLocationDistance { + let dx = end.x - start.x + let dy = end.y - start.y + + guard dx != 0 || dy != 0 else { + return point.distance(to: start) + } + + let projection = max(0, min(1, ((point.x - start.x) * dx + (point.y - start.y) * dy) / ((dx * dx) + (dy * dy)))) + let projectedPoint = MKMapPoint( + x: start.x + (dx * projection), + y: start.y + (dy * projection) + ) + return point.distance(to: projectedPoint) +} + +private func parseSpeedLimitMetersPerSecond(from rawValue: String) -> CLLocationSpeed? { + let normalized = rawValue + .lowercased() + .split(separator: ";") + .first? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + + guard !normalized.isEmpty else { return nil } + guard normalized != "none", + normalized != "signals", + normalized != "implicit", + normalized != "walk" else { + return nil + } + + let scanner = Scanner(string: normalized) + guard let numericValue = scanner.scanDouble() else { return nil } + + if normalized.contains("mph") { + return numericValue * 0.44704 + } + if normalized.contains("knot") { + return numericValue * 0.514444 + } + + return numericValue / 3.6 +} + +private func speedLimitMetersPerSecond(from tags: [String: String]) -> CLLocationSpeed? { + if let maxspeed = tags["maxspeed"], + let parsed = parseSpeedLimitMetersPerSecond(from: maxspeed) { + return parsed + } + + let directionalValues = [ + tags["maxspeed:forward"], + tags["maxspeed:backward"] + ] + .compactMap { $0 } + .compactMap(parseSpeedLimitMetersPerSecond(from:)) + + guard !directionalValues.isEmpty else { return nil } + return directionalValues.min() +} + +private func overpassQuery(for coordinates: [CLLocationCoordinate2D]) -> String? { + guard let first = coordinates.first else { return nil } + + var minLatitude = first.latitude + var maxLatitude = first.latitude + var minLongitude = first.longitude + var maxLongitude = first.longitude + + for coordinate in coordinates.dropFirst() { + minLatitude = min(minLatitude, coordinate.latitude) + maxLatitude = max(maxLatitude, coordinate.latitude) + minLongitude = min(minLongitude, coordinate.longitude) + maxLongitude = max(maxLongitude, coordinate.longitude) + } + + let padding = OpenStreetMapSpeedLimitService.boundingBoxPaddingDegrees + let south = minLatitude - padding + let west = minLongitude - padding + let north = maxLatitude + padding + let east = maxLongitude + padding + + let bbox = String(format: "%.6f,%.6f,%.6f,%.6f", south, west, north, east) + + return """ + [out:json][timeout:20]; + ( + way(\(bbox))[highway][maxspeed]; + way(\(bbox))[highway]["maxspeed:forward"]; + way(\(bbox))[highway]["maxspeed:backward"]; + ); + out tags geom; + """ +} + +private func fetchOpenStreetMapWays(for coordinates: [CLLocationCoordinate2D]) async throws -> [OpenStreetMapWay] { + guard let query = overpassQuery(for: coordinates) else { return [] } + + var components = URLComponents(url: OpenStreetMapSpeedLimitService.endpoint, resolvingAgainstBaseURL: false) + components?.queryItems = [URLQueryItem(name: "data", value: query)] + guard let url = components?.url else { return [] } + + let (data, response) = try await URLSession.shared.data(from: url) + + if let httpResponse = response as? HTTPURLResponse, + !(200...299).contains(httpResponse.statusCode) { + throw NSError( + domain: "OpenStreetMapSpeedLimits", + code: httpResponse.statusCode, + userInfo: [NSLocalizedDescriptionKey: "Overpass returned HTTP \(httpResponse.statusCode)."] + ) + } + + let decoded = try JSONDecoder().decode(OverpassResponse.self, from: data) + return decoded.elements.compactMap { element in + guard let tags = element.tags, + let speedLimit = speedLimitMetersPerSecond(from: tags), + let geometry = element.geometry?.map({ CLLocationCoordinate2D(latitude: $0.lat, longitude: $0.lon) }), + geometry.count > 1 else { + return nil + } + + return OpenStreetMapWay( + geometry: geometry, + speedLimitMetersPerSecond: speedLimit + ) + } +} + +private func nearestSpeedLimit( + forSegmentFrom start: CLLocationCoordinate2D, + to end: CLLocationCoordinate2D, + using ways: [OpenStreetMapWay] +) -> CLLocationSpeed? { + let midpoint = MKMapPoint(midpointCoordinate(from: start, to: end)) + var bestMatch: (speed: CLLocationSpeed, distance: CLLocationDistance)? + + for way in ways { + for (wayStart, wayEnd) in zip(way.geometry, way.geometry.dropFirst()) { + let candidateDistance = distanceFromPoint( + midpoint, + toSegmentFrom: MKMapPoint(wayStart), + to: MKMapPoint(wayEnd) + ) + + if bestMatch == nil || candidateDistance < bestMatch!.distance { + bestMatch = (way.speedLimitMetersPerSecond, candidateDistance) + } + } + } + + guard let bestMatch, + bestMatch.distance <= OpenStreetMapSpeedLimitService.nearestWayThreshold else { + return nil + } + + return bestMatch.speed +} + +private func buildPlaybackSamples( + from displayCoordinates: [CLLocationCoordinate2D], + speedWays: [OpenStreetMapWay], + fallbackSpeedMetersPerSecond: CLLocationSpeed +) -> [RoutePlaybackSample] { + guard let firstCoordinate = displayCoordinates.first else { return [] } + + var samples = [RoutePlaybackSample(coordinate: firstCoordinate, delayFromPrevious: 0)] + + for (start, end) in zip(displayCoordinates, displayCoordinates.dropFirst()) { + let segmentDistance = CLLocation(latitude: start.latitude, longitude: start.longitude) + .distance(from: CLLocation(latitude: end.latitude, longitude: end.longitude)) + guard segmentDistance > 0 else { continue } + + let speedLimit = nearestSpeedLimit(forSegmentFrom: start, to: end, using: speedWays) ?? fallbackSpeedMetersPerSecond + let clampedSpeed = max(speedLimit, RouteSimulationDefaults.minimumSpeedMetersPerSecond) + let segmentTravelTime = segmentDistance / clampedSpeed + let segmentStepCount = max(1, Int(ceil(segmentTravelTime / RouteSimulationDefaults.playbackTickInterval))) + let stepDelay = segmentTravelTime / Double(segmentStepCount) + + for index in 1...segmentStepCount { + let coordinate = interpolateCoordinate( + from: start, + to: end, + fraction: Double(index) / Double(segmentStepCount) + ) + if samples.last.map({ CoordinateSnapshot($0.coordinate) }) != CoordinateSnapshot(coordinate) { + samples.append(RoutePlaybackSample(coordinate: coordinate, delayFromPrevious: stepDelay)) + } + } + } + + return samples +} + +private func prefetchRoutePlaybackSamples( + displayCoordinates: [CLLocationCoordinate2D], + fallbackSpeedMetersPerSecond: CLLocationSpeed +) async -> [RoutePlaybackSample] { + let speedWays = (try? await fetchOpenStreetMapWays(for: displayCoordinates)) ?? [] + return buildPlaybackSamples( + from: displayCoordinates, + speedWays: speedWays, + fallbackSpeedMetersPerSecond: fallbackSpeedMetersPerSecond + ) +} + // MARK: - Bookmark Model struct LocationBookmark: Identifiable, Codable { @@ -42,6 +368,11 @@ final class LocationSearchCompleter: NSObject, ObservableObject, MKLocalSearchCo } func update(query: String) { + guard !query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + results = [] + completer.queryFragment = "" + return + } completer.queryFragment = query } @@ -56,8 +387,8 @@ final class LocationSearchCompleter: NSObject, ObservableObject, MKLocalSearchCo } struct LocationSimulationView: View { - // Serial queue: simulate_location and clear_simulated_location share C global - // state — serialising all calls eliminates the use-after-free race. + // 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) @@ -66,13 +397,26 @@ struct LocationSimulationView: View { @State private var backgroundTaskID: UIBackgroundTaskIdentifier = .invalid @State private var resendTimer: Timer? + @State private var routeLoadTask: Task? + @State private var routeSpeedPrefetchTask: Task? + @State private var routePlaybackTask: Task? @State private var isBusy = false + @State private var isLoadingRoute = false + @State private var isPrefetchingRouteSpeeds = 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 showRouteSearch = false + @State private var routeStartSelection: RouteSearchSelection? + @State private var routeEndSelection: RouteSearchSelection? + @State private var routePlan: RouteSimulationPlan? + @State private var routePlaybackSamples: [RoutePlaybackSample] = [] + @State private var routePlaybackCoordinate: CLLocationCoordinate2D? + @State private var simulatedCoordinate: CLLocationCoordinate2D? + @State private var routeRequestID = UUID() // Bookmarks @State private var bookmarks: [LocationBookmark] = [] @@ -81,7 +425,7 @@ struct LocationSimulationView: View { @State private var newBookmarkName = "" private var pairingFilePath: String { - URL.documentsDirectory.appendingPathComponent("pairingFile.plist").path() + PairingFileStore.prepareURL().path() } private var pairingExists: Bool { @@ -93,11 +437,136 @@ struct LocationSimulationView: View { return stored.isEmpty ? "10.7.0.1" : stored } + private var routePolyline: MKPolyline? { + guard let routePlan, routePlan.displayCoordinates.count > 1 else { return nil } + return routePlan.displayCoordinates.withUnsafeBufferPointer { buffer in + guard let baseAddress = buffer.baseAddress else { return nil } + return MKPolyline(coordinates: baseAddress, count: buffer.count) + } + } + + private var routeStartCoordinate: CLLocationCoordinate2D? { + routeStartSelection?.coordinate + } + + private var routeEndCoordinate: CLLocationCoordinate2D? { + routeEndSelection?.coordinate + } + + private var hasActiveSimulation: Bool { + simulatedCoordinate != nil || routePlaybackTask != nil + } + + private var isRouteRunning: Bool { + routePlaybackTask != nil + } + + private var hasRouteContext: Bool { + routeStartSelection != nil || + routeEndSelection != nil || + routePlan != nil || + isLoadingRoute || + isPrefetchingRouteSpeeds || + routePlaybackCoordinate != nil + } + + private var routeSummaryText: String? { + guard let routePlan else { return nil } + let distanceText = Measurement( + value: routePlan.distance / 1000, + unit: UnitLength.kilometers + ).formatted(.measurement(width: .abbreviated, usage: .road)) + let formatter = DateComponentsFormatter() + formatter.allowedUnits = [.hour, .minute] + formatter.unitsStyle = .abbreviated + formatter.zeroFormattingBehavior = .dropAll + let durationText = formatter.string(from: routePlan.expectedTravelTime) + if let durationText, !durationText.isEmpty { + return "\(distanceText) • ETA \(durationText)" + } + return distanceText + } + + private var routeStatusText: String { + if isLoadingRoute { + return "Calculating route…" + } + if isPrefetchingRouteSpeeds { + return "Prefetching road speeds…" + } + if routePlan != nil { + return "Route ready." + } + if routeStartSelection != nil || routeEndSelection != nil { + return "Pick both route endpoints to build the drive." + } + return "Plan a route from the toolbar." + } + + private var routeAttributionLink: some View { + Link( + "Speed limit data © OpenStreetMap contributors (ODbL)", + destination: OpenStreetMapSpeedLimitService.copyrightURL + ) + .font(.caption2) + .foregroundStyle(.secondary) + } + + private var searchResultsListBase: some View { + List(searchCompleter.results.prefix(5), id: \.self) { result in + Button { + selectSearchResult(result) + } label: { + VStack(alignment: .leading, spacing: 2) { + Text(result.title) + .font(.subheadline) + if !result.subtitle.isEmpty { + Text(result.subtitle) + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + } + .listStyle(.plain) + .frame(maxHeight: 350) + .scrollDisabled(true) + .padding(.horizontal, 16) + .padding(.top, 8) + } + + @ViewBuilder + private var searchResultsList: some View { + if #available(iOS 26, *) { + searchResultsListBase + .glassEffect(in: .rect(cornerRadius: 12)) + } else { + searchResultsListBase + } + } + var body: some View { ZStack(alignment: .bottom) { MapReader { proxy in Map(position: $position) { - if let coordinate { + if hasRouteContext { + if let routePolyline { + MapPolyline(routePolyline) + .stroke(.blue.opacity(0.8), lineWidth: 5) + } + if let routeStartCoordinate { + Marker("Start", coordinate: routeStartCoordinate) + .tint(.green) + } + if let routeEndCoordinate { + Marker("End", coordinate: routeEndCoordinate) + .tint(.red) + } + if let routePlaybackCoordinate { + Marker("Current", coordinate: routePlaybackCoordinate) + .tint(.blue) + } + } else if let coordinate { Marker("Pin", coordinate: coordinate) .tint(.red) } @@ -105,113 +574,60 @@ struct LocationSimulationView: View { .mapStyle(.standard(elevation: .realistic)) .onTapGesture { point in if let loc = proxy.convert(point, from: .local) { - coordinate = loc + applySelection(loc) } } .mapControls { MapCompass() } } - .ignoresSafeArea() - .onChange(of: coordinate) { _, new in - if let new { - position = .region(MKCoordinateRegion(center: new, latitudinalMeters: 1000, longitudinalMeters: 1000)) + .ignoresSafeArea() + .onChange(of: coordinate.map(CoordinateSnapshot.init)) { _, new in + if let new { + position = .region( + MKCoordinateRegion( + center: new.coordinate, + latitudinalMeters: 1000, + longitudinalMeters: 1000 + ) + ) + } } - } VStack(spacing: 0) { if !searchCompleter.results.isEmpty { - if #available(iOS 26, *) { - List(searchCompleter.results.prefix(5), id: \.self) { result in - Button { - selectSearchResult(result) - } label: { - VStack(alignment: .leading, spacing: 2) { - Text(result.title) - .font(.subheadline) - if !result.subtitle.isEmpty { - Text(result.subtitle) - .font(.caption) - .foregroundStyle(.secondary) - } - } - } - } - .listStyle(.plain) - .frame(maxHeight: 350) - .scrollDisabled(true) - .glassEffect(in: .rect(cornerRadius: 12)) - .padding(.horizontal, 16) - .padding(.top, 8) - } else { - List(searchCompleter.results.prefix(5), id: \.self) { result in - Button { - selectSearchResult(result) - } label: { - VStack(alignment: .leading, spacing: 2) { - Text(result.title) - .font(.subheadline) - if !result.subtitle.isEmpty { - Text(result.subtitle) - .font(.caption) - .foregroundStyle(.secondary) - } - } - } - } - .listStyle(.plain) - .frame(maxHeight: 350) - .scrollDisabled(true) - .padding(.horizontal, 16) - .padding(.top, 8) - } + searchResultsList } Spacer() - // Bottom controls VStack(spacing: 12) { - if let coord = coordinate { - Text(String(format: "%.6f, %.6f", coord.latitude, coord.longitude)) - .font(.footnote.monospaced()) - .foregroundStyle(.secondary) - - HStack(spacing: 12) { - Button("Stop", action: clear) - .buttonStyle(.bordered) - .tint(.red) - .disabled(!pairingExists || isBusy) - - Button("Simulate Location", action: simulate) - .buttonStyle(.borderedProminent) - .disabled(!pairingExists || isBusy) - - Button { - showSaveBookmark = true - } label: { - Image(systemName: "bookmark") - } - .buttonStyle(.bordered) - .tint(.blue) - } + if hasRouteContext { + routeControls } else { - Text("Tap map to drop pin") - .font(.subheadline) - .foregroundStyle(.secondary) + pinControls } } .padding(.bottom, 24) .padding(.horizontal, 16) + .padding(.horizontal, 16) } } .navigationBarTitleDisplayMode(.inline) .toolbar { - ToolbarItem(placement: .topBarLeading) { + ToolbarItemGroup(placement: .topBarLeading) { Button { showBookmarks = true } label: { Image(systemName: "bookmark.fill") } + + Button { + showRouteSearch = true + } label: { + Image(systemName: "point.topleft.down.curvedto.point.bottomright.up") + } + .disabled(isBusy || isRouteRunning) } ToolbarItem(placement: .topBarTrailing) { TextField("Search location...", text: $searchText) @@ -236,17 +652,32 @@ struct LocationSimulationView: View { } .sheet(isPresented: $showBookmarks) { BookmarksView(bookmarks: $bookmarks) { bookmark in - coordinate = bookmark.coordinate + applySelection(bookmark.coordinate) showBookmarks = false } onDelete: { offsets in bookmarks.remove(atOffsets: offsets) saveBookmarks() } } + .sheet(isPresented: $showRouteSearch) { + RouteSearchSheet( + initialStart: routeStartSelection, + initialEnd: routeEndSelection + ) { startSelection, endSelection in + routeStartSelection = startSelection + routeEndSelection = endSelection + refreshRoute() + } + } .onAppear { loadBookmarks() } .onDisappear { + routeLoadTask?.cancel() + routeLoadTask = nil + routeSpeedPrefetchTask?.cancel() + routeSpeedPrefetchTask = nil + cancelRoutePlayback(resetMarker: true) stopResendLoop() if backgroundTaskID != .invalid { BackgroundLocationManager.shared.requestStop() @@ -291,29 +722,141 @@ struct LocationSimulationView: View { let request = MKLocalSearch.Request(completion: result) MKLocalSearch(request: request).start { response, _ in if let item = response?.mapItems.first { - coordinate = item.placemark.coordinate + applySelection(item.placemark.coordinate) + } + } + } + + @ViewBuilder + private var pinControls: some View { + if let coord = coordinate { + Text(String(format: "%.6f, %.6f", coord.latitude, coord.longitude)) + .font(.footnote.monospaced()) + .foregroundStyle(.secondary) + + HStack(spacing: 12) { + Button("Stop", action: clear) + .buttonStyle(.bordered) + .tint(.red) + .disabled(!pairingExists || isBusy || !hasActiveSimulation) + + Button("Simulate Location", action: simulate) + .buttonStyle(.borderedProminent) + .disabled(!pairingExists || isBusy || isLoadingRoute) + + Button { + showSaveBookmark = true + } label: { + Image(systemName: "bookmark") + } + .buttonStyle(.bordered) + .tint(.blue) + .disabled(isRouteRunning) + } + } else { + Text("Tap map to drop pin") + .font(.subheadline) + .foregroundStyle(.secondary) + } + } + + private var routeControls: some View { + VStack(spacing: 10) { + Text(routeStatusText) + .font(.footnote) + .foregroundStyle(.secondary) + + if isLoadingRoute || isPrefetchingRouteSpeeds { + ProgressView() + .controlSize(.small) + } else if let routeSummaryText { + Text(routeSummaryText) + .font(.footnote.monospaced()) + .foregroundStyle(.secondary) + } + + routeAttributionLink + + HStack(spacing: 12) { + Button("Stop", action: clear) + .buttonStyle(.bordered) + .tint(.red) + .disabled(!pairingExists || isBusy || !hasActiveSimulation) + + Button("Play Route", action: simulateRoute) + .buttonStyle(.borderedProminent) + .disabled( + !pairingExists || + isBusy || + isLoadingRoute || + isPrefetchingRouteSpeeds || + routePlan == nil || + routePlaybackSamples.isEmpty + ) + + Button("Reset", action: resetRouteSelection) + .buttonStyle(.bordered) + .disabled(isBusy || isRouteRunning) } } } private func simulate() { guard pairingExists, let coord = coordinate, !isBusy else { return } + runLocationCommand( + errorTitle: "Simulation Failed", + errorMessage: { code in + "Could not simulate location (error \(code)). Make sure the device is connected and the DDI is mounted." + }, + operation: { locationUpdateCode(for: coord) } + ) { + routePlaybackCoordinate = nil + beginBackgroundTask() + startResendLoop(with: coord) + BackgroundLocationManager.shared.requestStart() + } + } + + private func simulateRoute() { + guard pairingExists, + routePlan != nil, + let firstCoordinate = routePlaybackSamples.first?.coordinate, + !isBusy else { + return + } + stopResendLoop() + cancelRoutePlayback(resetMarker: false) + runLocationCommand( + errorTitle: "Route Simulation Failed", + errorMessage: { code in + "Could not start route simulation (error \(code)). Make sure the device is connected and the DDI is mounted." + }, + operation: { locationUpdateCode(for: firstCoordinate) } + ) { + beginBackgroundTask() + BackgroundLocationManager.shared.requestStart() + simulatedCoordinate = nil + routePlaybackCoordinate = firstCoordinate + startRoutePlayback() + } + } + + private func runLocationCommand( + errorTitle: String, + errorMessage: @escaping (Int32) -> String, + operation: @escaping () -> Int32, + onSuccess: @escaping () -> Void + ) { isBusy = true - let ip = deviceIP - let path = pairingFilePath - let lat = coord.latitude - let lon = coord.longitude Self.locationQueue.async { - let code = simulate_location(ip, lat, lon, path) + let code = operation() DispatchQueue.main.async { isBusy = false if code == 0 { - beginBackgroundTask() - startResendLoop() - BackgroundLocationManager.shared.requestStart() + onSuccess() } else { - alertTitle = "Simulation Failed" - alertMessage = "Could not simulate location (error \(code)). Make sure the device is connected and the DDI is mounted." + alertTitle = errorTitle + alertMessage = errorMessage(code) showAlert = true } } @@ -322,22 +865,19 @@ struct LocationSimulationView: View { private func clear() { guard pairingExists, !isBusy else { return } - isBusy = true + routeLoadTask?.cancel() + routeLoadTask = nil + routeSpeedPrefetchTask?.cancel() + routeSpeedPrefetchTask = nil + cancelRoutePlayback(resetMarker: true) stopResendLoop() - Self.locationQueue.async { - let code = clear_simulated_location() - DispatchQueue.main.async { - isBusy = false - if code == 0 { - coordinate = nil - endBackgroundTask() - BackgroundLocationManager.shared.requestStop() - } else { - alertTitle = "Clear Failed" - alertMessage = "Could not clear simulated location (error \(code))." - showAlert = true - } - } + runLocationCommand( + errorTitle: "Clear Failed", + errorMessage: { code in "Could not clear simulated location (error \(code))." }, + operation: clear_simulated_location + ) { + endBackgroundTask() + BackgroundLocationManager.shared.requestStop() } } @@ -352,16 +892,13 @@ struct LocationSimulationView: View { backgroundTaskID = .invalid } - private func startResendLoop() { + private func startResendLoop(with coordinate: CLLocationCoordinate2D) { + simulatedCoordinate = coordinate resendTimer?.invalidate() resendTimer = Timer.scheduledTimer(withTimeInterval: 4, repeats: true) { _ in - guard let coord = coordinate else { return } - let ip = deviceIP - let path = pairingFilePath - let lat = coord.latitude - let lon = coord.longitude + guard let simulatedCoordinate else { return } Self.locationQueue.async { - _ = simulate_location(ip, lat, lon, path) + _ = locationUpdateCode(for: simulatedCoordinate) } } } @@ -369,6 +906,425 @@ struct LocationSimulationView: View { private func stopResendLoop() { resendTimer?.invalidate() resendTimer = nil + simulatedCoordinate = nil + } + + private func cancelRoutePlayback(resetMarker: Bool) { + routePlaybackTask?.cancel() + routePlaybackTask = nil + if resetMarker { + routePlaybackCoordinate = nil + } + } + + private func applySelection(_ coordinate: CLLocationCoordinate2D) { + guard !isRouteRunning else { return } + if hasRouteContext { + resetRouteSelection() + } + self.coordinate = coordinate + } + + private func resetRouteSelection() { + routeLoadTask?.cancel() + routeLoadTask = nil + routeSpeedPrefetchTask?.cancel() + routeSpeedPrefetchTask = nil + routeRequestID = UUID() + routePlan = nil + routeStartSelection = nil + routeEndSelection = nil + routePlaybackSamples = [] + routePlaybackCoordinate = nil + isLoadingRoute = false + isPrefetchingRouteSpeeds = false + } + + private func refreshRoute() { + routeLoadTask?.cancel() + routeSpeedPrefetchTask?.cancel() + routePlan = nil + routePlaybackSamples = [] + + guard let routeStart = routeStartSelection?.coordinate, + let routeEnd = routeEndSelection?.coordinate else { + isLoadingRoute = false + isPrefetchingRouteSpeeds = false + return + } + + let requestID = UUID() + routeRequestID = requestID + isLoadingRoute = true + isPrefetchingRouteSpeeds = false + + let request = MKDirections.Request() + request.source = MKMapItem(placemark: MKPlacemark(coordinate: routeStart)) + request.destination = MKMapItem(placemark: MKPlacemark(coordinate: routeEnd)) + request.requestsAlternateRoutes = false + request.transportType = .automobile + + routeLoadTask = Task { + do { + let response = try await MKDirections(request: request).calculate() + guard !Task.isCancelled else { return } + guard let route = response.routes.first else { + throw NSError( + domain: "RouteSimulation", + code: -1, + userInfo: [NSLocalizedDescriptionKey: "No drivable route was returned."] + ) + } + + let displayCoordinates = sampledRouteCoordinates( + from: route.polyline.coordinateArray, + targetDistance: RouteSimulationDefaults.pathSamplingDistance + ) + let routePlan = RouteSimulationPlan( + displayCoordinates: displayCoordinates, + distance: route.distance, + expectedTravelTime: route.expectedTravelTime + ) + + await MainActor.run { + guard routeRequestID == requestID else { return } + self.routePlan = routePlan + isLoadingRoute = false + isPrefetchingRouteSpeeds = true + if let routePolyline { + position = .rect(routePolyline.boundingMapRect) + } + } + + let fallbackSpeed = route.expectedTravelTime > 0 + ? route.distance / route.expectedTravelTime + : 13.4 + + await MainActor.run { + guard routeRequestID == requestID else { return } + routeSpeedPrefetchTask?.cancel() + 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 + } + } + } + } catch is CancellationError { + await MainActor.run { + guard routeRequestID == requestID else { return } + isLoadingRoute = false + isPrefetchingRouteSpeeds = false + } + } catch { + await MainActor.run { + guard routeRequestID == requestID else { return } + isLoadingRoute = false + isPrefetchingRouteSpeeds = false + alertTitle = "Route Failed" + alertMessage = error.localizedDescription + showAlert = true + } + } + } + } + + private func startRoutePlayback() { + routePlaybackTask = Task { + var lastSuccessfulCoordinate = routePlaybackSamples.first?.coordinate + + for sample in routePlaybackSamples.dropFirst() { + try? await Task.sleep(for: .seconds(sample.delayFromPrevious)) + guard !Task.isCancelled else { return } + + let code = await sendLocationUpdate(for: sample.coordinate) + guard code == 0 else { + await MainActor.run { + routePlaybackTask = nil + routePlaybackCoordinate = lastSuccessfulCoordinate + if let lastSuccessfulCoordinate { + startResendLoop(with: lastSuccessfulCoordinate) + } + alertTitle = "Route Simulation Failed" + alertMessage = "Could not continue route simulation (error \(code))." + showAlert = true + } + return + } + + lastSuccessfulCoordinate = sample.coordinate + await MainActor.run { + routePlaybackCoordinate = sample.coordinate + } + } + + await MainActor.run { + routePlaybackTask = nil + if let lastSuccessfulCoordinate { + routePlaybackCoordinate = lastSuccessfulCoordinate + startResendLoop(with: lastSuccessfulCoordinate) + } + } + } + } + + private func sendLocationUpdate(for coordinate: CLLocationCoordinate2D) async -> Int32 { + await withCheckedContinuation { continuation in + Self.locationQueue.async { + continuation.resume(returning: locationUpdateCode(for: coordinate)) + } + } + } + + private func locationUpdateCode(for coordinate: CLLocationCoordinate2D) -> Int32 { + simulate_location(deviceIP, coordinate.latitude, coordinate.longitude, pairingFilePath) + } +} + +private struct RouteSearchSheet: View { + @Environment(\.dismiss) private var dismiss + + let initialStart: RouteSearchSelection? + let initialEnd: RouteSearchSelection? + let onApply: (RouteSearchSelection, RouteSearchSelection) -> Void + + @StateObject private var startCompleter = LocationSearchCompleter() + @StateObject private var endCompleter = LocationSearchCompleter() + @State private var startQuery: String + @State private var endQuery: String + @State private var startSelection: RouteSearchSelection? + @State private var endSelection: RouteSearchSelection? + @State private var isResolvingSelection = false + @State private var errorMessage: String? + @FocusState private var focusedField: RouteSearchField? + + init( + initialStart: RouteSearchSelection?, + initialEnd: RouteSearchSelection?, + onApply: @escaping (RouteSearchSelection, RouteSearchSelection) -> Void + ) { + self.initialStart = initialStart + self.initialEnd = initialEnd + self.onApply = onApply + _startQuery = State(initialValue: initialStart?.title ?? "") + _endQuery = State(initialValue: initialEnd?.title ?? "") + _startSelection = State(initialValue: initialStart) + _endSelection = State(initialValue: initialEnd) + } + + private var activeResults: [MKLocalSearchCompletion] { + switch focusedField { + case .start: + return startCompleter.results + case .end: + return endCompleter.results + case .none: + return [] + } + } + + private var canApply: Bool { + startSelection != nil && endSelection != nil && !isResolvingSelection + } + + var body: some View { + NavigationStack { + VStack(alignment: .leading, spacing: 16) { + routeField( + title: "Start", + icon: "circle.fill", + tint: .green, + text: $startQuery, + selection: startSelection, + field: .start + ) + + routeField( + title: "End", + icon: "flag.checkered.circle.fill", + tint: .red, + text: $endQuery, + selection: endSelection, + field: .end + ) + + if let errorMessage { + Text(errorMessage) + .font(.footnote) + .foregroundStyle(.red) + } + + if isResolvingSelection { + ProgressView("Resolving location…") + .font(.footnote) + } else if !activeResults.isEmpty { + ScrollView { + LazyVStack(spacing: 0) { + ForEach(Array(activeResults.enumerated()), id: \.offset) { index, result in + Button { + resolve(result) + } label: { + VStack(alignment: .leading, spacing: 2) { + Text(result.title) + .font(.subheadline) + .foregroundStyle(.primary) + .frame(maxWidth: .infinity, alignment: .leading) + if !result.subtitle.isEmpty { + Text(result.subtitle) + .font(.caption) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + .padding(.vertical, 10) + .padding(.horizontal, 12) + } + .buttonStyle(.plain) + + if index < activeResults.count - 1 { + Divider() + } + } + } + } + .frame(maxHeight: 260) + } else { + Text("Search for a start and destination to build the route.") + .font(.footnote) + .foregroundStyle(.secondary) + } + + Spacer(minLength: 0) + } + .padding(16) + .navigationTitle("Simulate Route") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { + dismiss() + } + } + + ToolbarItem(placement: .confirmationAction) { + Button("Use Route") { + guard let startSelection, let endSelection else { return } + onApply(startSelection, endSelection) + dismiss() + } + .disabled(!canApply) + } + } + } + .presentationDetents([.medium, .large]) + .onAppear { + if startSelection == nil { + focusedField = .start + } else if endSelection == nil { + focusedField = .end + } + } + } + + private func routeField( + title: String, + icon: String, + tint: Color, + text: Binding, + selection: RouteSearchSelection?, + field: RouteSearchField + ) -> some View { + VStack(alignment: .leading, spacing: 8) { + Text(title) + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + + HStack(spacing: 10) { + Image(systemName: icon) + .foregroundStyle(tint) + + TextField(title, text: text) + .textInputAutocapitalization(.words) + .autocorrectionDisabled() + .focused($focusedField, equals: field) + .submitLabel(field == .start ? .next : .done) + .onChange(of: text.wrappedValue) { _, newValue in + errorMessage = nil + update(query: newValue, for: field) + } + .onSubmit { + if field == .start { + focusedField = .end + } else { + focusedField = nil + } + } + } + .padding(.horizontal, 2) + .padding(.vertical, 4) + + if let selection { + Text(String(format: "%.5f, %.5f", selection.coordinate.latitude, selection.coordinate.longitude)) + .font(.caption.monospaced()) + .foregroundStyle(.secondary) + } + } + } + + private func update(query: String, for field: RouteSearchField) { + switch field { + case .start: + if query != startSelection?.title { + startSelection = nil + } + startCompleter.update(query: query) + case .end: + if query != endSelection?.title { + endSelection = nil + } + endCompleter.update(query: query) + } + } + + private func resolve(_ completion: MKLocalSearchCompletion) { + let field = focusedField ?? .start + let request = MKLocalSearch.Request(completion: completion) + isResolvingSelection = true + errorMessage = nil + + MKLocalSearch(request: request).start { response, error in + DispatchQueue.main.async { + isResolvingSelection = false + + guard let item = response?.mapItems.first else { + errorMessage = error?.localizedDescription ?? "Could not resolve that location." + return + } + + let name = item.name?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let title = name.isEmpty ? completion.title : name + let selection = RouteSearchSelection(title: title, coordinate: item.placemark.coordinate) + + switch field { + case .start: + startSelection = selection + startQuery = title + startCompleter.results = [] + focusedField = .end + case .end: + endSelection = selection + endQuery = title + endCompleter.results = [] + focusedField = nil + } + } + } } } diff --git a/StikJIT/Views/ProcessInspectorView.swift b/StikJIT/Views/ProcessInspectorView.swift index c9316b14..fded0645 100644 --- a/StikJIT/Views/ProcessInspectorView.swift +++ b/StikJIT/Views/ProcessInspectorView.swift @@ -32,10 +32,10 @@ struct ProcessInspectorView: View { .onDisappear { viewModel.stopAutoRefresh() } - .alert(viewModel.killAlertTitle, isPresented: $viewModel.showKillAlert) { + .alert(viewModel.actionAlertTitle, isPresented: $viewModel.showActionAlert) { Button("OK", role: .cancel) { } } message: { - Text(viewModel.killAlertMessage) + Text(viewModel.actionAlertMessage) } .alert(viewModel.errorAlertTitle, isPresented: $viewModel.showErrorAlert) { Button("Try Again") { viewModel.refresh() } @@ -63,8 +63,11 @@ struct ProcessInspectorView: View { ForEach(viewModel.filteredProcesses) { process in ProcessRow( process: process, - isKilling: viewModel.killingPID == process.pid, + activeControl: viewModel.activeControl(for: process), + isBusy: viewModel.isRunningControlAction, isConfirming: killCandidate?.pid == process.pid, + onResumeTap: { viewModel.control(.resume, process: $0) }, + onPauseTap: { viewModel.control(.pause, process: $0) }, onKillTap: { handleKillTap(for: $0) } ) } @@ -82,7 +85,7 @@ private extension ProcessInspectorView { killConfirmTask?.cancel() killConfirmTask = nil killCandidate = nil - viewModel.kill(process: process) + viewModel.control(.kill, process: process) } else { killCandidate = process killConfirmTask?.cancel() @@ -100,10 +103,129 @@ private extension ProcessInspectorView { // MARK: - Row +enum ProcessControlAction: String { + case resume + case pause + case kill + + var signal: Int32 { + switch self { + case .resume: + return Int32(SIGCONT) + case .pause: + return Int32(SIGSTOP) + case .kill: + return Int32(SIGKILL) + } + } + + var buttonLabel: String { + switch self { + case .resume: + return "Resume" + case .pause: + return "Pause" + case .kill: + return "Kill" + } + } + + var systemImage: String { + switch self { + case .resume: + return "play.circle" + case .pause: + return "pause.circle" + case .kill: + return "xmark.circle" + } + } + + var tint: Color { + switch self { + case .resume: + return .green + case .pause: + return .orange + case .kill: + return .red + } + } + + var progressTitle: String { + switch self { + case .resume: + return "Resuming Process" + case .pause: + return "Pausing Process" + case .kill: + return "Terminating Process" + } + } + + var timeoutTitle: String { + switch self { + case .resume: + return "Resume Timed Out" + case .pause: + return "Pause Timed Out" + case .kill: + return "Kill Timed Out" + } + } + + var failureTitle: String { + switch self { + case .resume: + return "Resume Failed" + case .pause: + return "Pause Failed" + case .kill: + return "Kill Failed" + } + } + + var successTitle: String { + switch self { + case .resume: + return "Process Resumed" + case .pause: + return "Process Paused" + case .kill: + return "Process Terminated" + } + } + + func successMessage(for pid: Int) -> String { + switch self { + case .resume: + return "Sent SIGCONT (19) to PID \(pid)." + case .pause: + return "Sent SIGSTOP (17) to PID \(pid)." + case .kill: + return "PID \(pid) was terminated." + } + } + + func timeoutMessage(for pid: Int) -> String { + switch self { + case .resume: + return "Could not confirm resume for PID \(pid). Try again." + case .pause: + return "Could not confirm pause for PID \(pid). Try again." + case .kill: + return "Could not confirm termination for PID \(pid). Try again." + } + } +} + private struct ProcessRow: View { let process: ProcessInfoEntry - let isKilling: Bool + let activeControl: ProcessControlAction? + let isBusy: Bool let isConfirming: Bool + let onResumeTap: (ProcessInfoEntry) -> Void + let onPauseTap: (ProcessInfoEntry) -> Void let onKillTap: (ProcessInfoEntry) -> Void var body: some View { @@ -128,27 +250,54 @@ private struct ProcessRow: View { .textSelection(.enabled) HStack { Spacer() - if isKilling { + if activeControl != nil { ProgressView() .progressViewStyle(.circular) .tint(.accentColor) } else { - Button { - onKillTap(process) - } label: { - if isConfirming { - Label("Confirm", systemImage: "checkmark.circle.fill") - .labelStyle(.iconOnly) + HStack(spacing: 8) { + Button { + onResumeTap(process) + } label: { + Image(systemName: ProcessControlAction.resume.systemImage) .font(.title3) - } else { - Image(systemName: "xmark.circle") + } + .buttonStyle(.bordered) + .controlSize(.small) + .tint(ProcessControlAction.resume.tint) + .labelStyle(.iconOnly) + .disabled(isBusy) + + Button { + onPauseTap(process) + } label: { + Image(systemName: ProcessControlAction.pause.systemImage) .font(.title3) } + .buttonStyle(.bordered) + .controlSize(.small) + .tint(ProcessControlAction.pause.tint) + .labelStyle(.iconOnly) + .disabled(isBusy) + + Button { + onKillTap(process) + } label: { + if isConfirming { + Label("Confirm", systemImage: "checkmark.circle.fill") + .labelStyle(.iconOnly) + .font(.title3) + } else { + Image(systemName: ProcessControlAction.kill.systemImage) + .font(.title3) + } + } + .buttonStyle(.bordered) + .controlSize(.small) + .tint(isConfirming ? .green : ProcessControlAction.kill.tint) + .labelStyle(.iconOnly) + .disabled(isBusy) } - .buttonStyle(.bordered) - .controlSize(.small) - .tint(isConfirming ? .green : .red) - .labelStyle(.iconOnly) } } } @@ -166,13 +315,13 @@ final class ProcessInspectorViewModel: ObservableObject { @Published var showErrorAlert = false @Published var errorAlertTitle = "" @Published var errorAlertMessage = "" - @Published private(set) var killingPID: Int? - @Published var showKillAlert = false - @Published var killAlertTitle = "" - @Published var killAlertMessage = "" + @Published private(set) var activeControlState: (pid: Int, action: ProcessControlAction)? + @Published var showActionAlert = false + @Published var actionAlertTitle = "" + @Published var actionAlertMessage = "" private var refreshTask: Task? - private var killTimeoutTask: Task? + private var controlTimeoutTask: Task? @Published private(set) var lastUpdated: Date? var filteredProcesses: [ProcessInfoEntry] { guard !searchText.isEmpty else { return processes } @@ -207,27 +356,25 @@ final class ProcessInspectorViewModel: ObservableObject { func stopAutoRefresh() { refreshTask?.cancel() refreshTask = nil - killTimeoutTask?.cancel() - killTimeoutTask = nil + controlTimeoutTask?.cancel() + controlTimeoutTask = nil } func refresh() { guard !isRefreshing else { return } isRefreshing = true Task.detached(priority: .utility) { [weak self] in + guard let self else { return } var err: NSError? - let entries = FetchDeviceProcessList(&err) ?? [] + let parsedEntries = ProcessInfoEntry.currentEntries(&err) + let errorMessage = err?.localizedDescription await MainActor.run { - guard let self else { return } - if let err { + if let errorMessage { self.errorAlertTitle = "Failed to Load Processes" - self.errorAlertMessage = err.localizedDescription + self.errorAlertMessage = errorMessage self.showErrorAlert = true } else { - self.processes = entries.compactMap { item -> ProcessInfoEntry? in - guard let dict = item as? NSDictionary else { return nil } - return ProcessInfoEntry(dictionary: dict) - } + self.processes = parsedEntries self.lastUpdated = Date() } self.isRefreshing = false @@ -235,84 +382,67 @@ final class ProcessInspectorViewModel: ObservableObject { } } - func kill(process: ProcessInfoEntry) { - guard killingPID == nil else { - killAlertTitle = "Busy" - killAlertMessage = "Already terminating PID \(killingPID!)." - showKillAlert = true + var isRunningControlAction: Bool { + activeControlState != nil + } + + func activeControl(for process: ProcessInfoEntry) -> ProcessControlAction? { + guard activeControlState?.pid == process.pid else { return nil } + return activeControlState?.action + } + + func control(_ action: ProcessControlAction, process: ProcessInfoEntry) { + guard activeControlState == nil else { + actionAlertTitle = "Busy" + if let activeControlState { + actionAlertMessage = "\(activeControlState.action.progressTitle) for PID \(activeControlState.pid)." + } else { + actionAlertMessage = "Another process action is already running." + } + showActionAlert = true return } let targetPID = process.pid - killingPID = targetPID - killTimeoutTask?.cancel() - killTimeoutTask = Task { [weak self] in + activeControlState = (targetPID, action) + controlTimeoutTask?.cancel() + controlTimeoutTask = Task { [weak self] in + guard let self else { return } try? await Task.sleep(for: .seconds(8)) - await MainActor.run { - guard let self else { return } - if self.killingPID == targetPID { - self.killingPID = nil - self.killAlertTitle = "Kill Timed Out" - self.killAlertMessage = "Could not confirm termination for PID \(targetPID). Try again." - self.showKillAlert = true - } + if self.activeControlState?.pid == targetPID && self.activeControlState?.action == action { + self.activeControlState = nil + self.actionAlertTitle = action.timeoutTitle + self.actionAlertMessage = action.timeoutMessage(for: targetPID) + self.showActionAlert = true } } Task.detached(priority: .userInitiated) { [weak self] in + guard let self else { return } var err: NSError? - let success = KillDeviceProcess(Int32(targetPID), &err) + let success: Bool + do { + try JITEnableContext.shared.sendSignal(action.signal, toProcessWithPID: Int32(targetPID)) + success = true + } catch let nsError as NSError { + err = nsError + success = false + } + let errorMessage = err?.localizedDescription ?? "Unknown error" await MainActor.run { - guard let self else { return } - self.killTimeoutTask?.cancel() - self.killTimeoutTask = nil - guard self.killingPID == targetPID else { return } - self.killingPID = nil + self.controlTimeoutTask?.cancel() + self.controlTimeoutTask = nil + guard self.activeControlState?.pid == targetPID && self.activeControlState?.action == action else { return } + self.activeControlState = nil if success { - self.killAlertTitle = "Process Terminated" - self.killAlertMessage = "PID \(targetPID) was terminated." - self.showKillAlert = true + self.actionAlertTitle = action.successTitle + self.actionAlertMessage = action.successMessage(for: targetPID) + self.showActionAlert = true self.refresh() } else { - self.killAlertTitle = "Kill Failed" - self.killAlertMessage = err?.localizedDescription ?? "Unknown error" - self.showKillAlert = true + self.actionAlertTitle = action.failureTitle + self.actionAlertMessage = errorMessage + self.showActionAlert = true } } } } - -} - -struct ProcessInfoEntry: Identifiable { - let pid: Int - private let rawPath: String - let bundleID: String? - let name: String? - - init?(dictionary: NSDictionary) { - guard let pidNumber = dictionary["pid"] as? NSNumber else { return nil } - pid = pidNumber.intValue - rawPath = dictionary["path"] as? String ?? "Unknown" - bundleID = dictionary["bundleID"] as? String - name = dictionary["name"] as? String - } - - var id: Int { pid } - - var executablePath: String { - rawPath.replacingOccurrences(of: "file://", with: "") - } - - var displayName: String { - if let name = name, !name.isEmpty { - return name - } - if let bundle = bundleID, !bundle.isEmpty { - return bundle - } - let cleaned = executablePath - if let component = cleaned.split(separator: "/").last { - return String(component) - } - return "Process \(pid)" - } } diff --git a/StikJIT/Views/SettingsView.swift b/StikJIT/Views/SettingsView.swift index de8c443b..83f0cb37 100644 --- a/StikJIT/Views/SettingsView.swift +++ b/StikJIT/Views/SettingsView.swift @@ -4,7 +4,6 @@ // Created by Stephen on 3/27/25. import SwiftUI -import UniformTypeIdentifiers import UIKit struct SettingsView: View { @@ -187,7 +186,7 @@ struct SettingsView: View { } .fileImporter( isPresented: $isShowingPairingFilePicker, - allowedContentTypes: [UTType(filenameExtension: "mobiledevicepairing", conformingTo: .data)!, UTType(filenameExtension: "mobiledevicepair", conformingTo: .data)!, .propertyList], + allowedContentTypes: PairingFileStore.supportedContentTypes, allowsMultipleSelection: false ) { result in switch result { @@ -195,43 +194,32 @@ struct SettingsView: View { guard let url = urls.first else { return } let fileManager = FileManager.default - let accessing = url.startAccessingSecurityScopedResource() - - if fileManager.fileExists(atPath: url.path) { - do { - if fileManager.fileExists(atPath: URL.documentsDirectory.appendingPathComponent("pairingFile.plist").path) { - try fileManager.removeItem(at: URL.documentsDirectory.appendingPathComponent("pairingFile.plist")) - } + do { + try PairingFileStore.importFromPicker(url, fileManager: fileManager) + DispatchQueue.main.async { + isImportingFile = true + importProgress = 0.0 + pairingStatusMessage = nil + showPairingFileMessage = false + } - try fileManager.copyItem(at: url, to: URL.documentsDirectory.appendingPathComponent("pairingFile.plist")) + let progressTimer = Timer.scheduledTimer(withTimeInterval: 0.05, repeats: true) { timer in 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 - } + if importProgress < 1.0 { + importProgress += 0.05 + } else { + timer.invalidate() + isImportingFile = false } } + } - RunLoop.current.add(progressTimer, forMode: .common) - DispatchQueue.main.async { - startTunnelInBackground() - } - - } catch { } - } - - if accessing { - url.stopAccessingSecurityScopedResource() + RunLoop.current.add(progressTimer, forMode: .common) + DispatchQueue.main.async { + startTunnelInBackground() + } + } catch { + break } case .failure: break diff --git a/StikJIT/idevice/JITEnableContext.h b/StikJIT/idevice/JITEnableContext.h deleted file mode 100644 index 7d71dd1c..00000000 --- a/StikJIT/idevice/JITEnableContext.h +++ /dev/null @@ -1,82 +0,0 @@ -// -// JITEnableContext.h -// StikJIT -// -// Created by s s on 2025/3/28. -// -@import Foundation; -@import UIKit; -#include "idevice.h" -#include "jit.h" -#include "mount.h" - -typedef void (^LogFuncC)(const char* message, ...); -typedef void (^LogFunc)(NSString *message); -typedef void (^SyslogLineHandler)(NSString *line); -typedef void (^SyslogErrorHandler)(NSError *error); - -@interface JITEnableContext : NSObject { - // tunnel - @protected AdapterHandle *adapter; - @protected RsdHandshakeHandle *handshake; - - // process - @protected dispatch_queue_t processInspectorQueue; - - // syslog - @protected dispatch_queue_t syslogQueue; - @protected BOOL syslogStreaming; - @protected SyslogRelayClientHandle *syslogClient; - @protected SyslogLineHandler syslogLineHandler; - @protected SyslogErrorHandler syslogErrorHandler; - - // ideviceInfo - @protected LockdowndClientHandle * g_client; -} -@property (class, readonly)JITEnableContext* shared; -- (RpPairingFileHandle*)getPairingFileWithError:(NSError**)error; -- (BOOL)ensureTunnelWithError:(NSError**)err; -- (BOOL)startTunnel:(NSError**)err; - -@end - -@interface JITEnableContext(JIT) -- (BOOL)debugAppWithBundleID:(NSString*)bundleID logger:(LogFunc)logger jsCallback:(DebugAppCallback)jsCallback; -- (BOOL)debugAppWithPID:(int)pid logger:(LogFunc)logger jsCallback:(DebugAppCallback)jsCallback; -- (BOOL)launchAppWithoutDebug:(NSString*)bundleID logger:(LogFunc)logger; -@end - -@interface JITEnableContext(DDI) -- (NSUInteger)getMountedDeviceCount:(NSError**)error __attribute__((swift_error(zero_result))); -- (NSInteger)mountPersonalDDIWithImagePath:(NSString*)imagePath trustcachePath:(NSString*)trustcachePath manifestPath:(NSString*)manifestPath error:(NSError**)error __attribute__((swift_error(nonzero_result))); -@end - -@interface JITEnableContext(Profile) -- (NSArray*)fetchAllProfiles:(NSError **)error; -- (BOOL)removeProfileWithUUID:(NSString*)uuid error:(NSError **)error; -- (BOOL)addProfile:(NSData*)profile error:(NSError **)error; -@end - -@interface JITEnableContext(Process) -- (NSArray*)fetchProcessListWithError:(NSError**)error; -- (BOOL)killProcessWithPID:(int)pid error:(NSError **)error; -@end - -@interface JITEnableContext(App) -- (UIImage*)getAppIconWithBundleId:(NSString*)bundleId error:(NSError**)error; -- (NSDictionary*)getAppListWithError:(NSError**)error; -- (NSDictionary*)getAllAppsWithError:(NSError**)error; -- (NSDictionary*)getHiddenSystemAppsWithError:(NSError**)error; -- (NSArray*)getSideloadedAppsWithError:(NSError**)error; -@end - -@interface JITEnableContext(Syslog) -- (void)startSyslogRelayWithHandler:(SyslogLineHandler)lineHandler - onError:(SyslogErrorHandler)errorHandler NS_SWIFT_NAME(startSyslogRelay(handler:onError:)); -- (void)stopSyslogRelay; -@end - -@interface JITEnableContext(DeviceInfo) -- (LockdowndClientHandle*)ideviceInfoInit:(NSError**)error; -- (char*)ideviceInfoGetXMLWithLockdownClient:(LockdowndClientHandle*)lockdownClient error:(NSError**)error; -@end diff --git a/StikJIT/idevice/JITEnableContext.m b/StikJIT/idevice/JITEnableContext.m deleted file mode 100644 index 4b3af85e..00000000 --- a/StikJIT/idevice/JITEnableContext.m +++ /dev/null @@ -1,201 +0,0 @@ -// -// JITEnableContext.m -// StikJIT -// -// Created by s s on 2025/3/28. -// -#include "idevice.h" -#include -#include -#include - -#include "jit.h" -#include "applist.h" -#include "profiles.h" - -#include "JITEnableContext.h" -#import "StikDebug-Swift.h" -#include -#import - -static JITEnableContext* sharedJITContext = nil; - -@implementation JITEnableContext { - NSError* lastTunnelError; - os_unfair_lock tunnelLock; - BOOL tunnelConnecting; - dispatch_semaphore_t tunnelSemaphore; -} - -+ (instancetype)shared { - static dispatch_once_t onceToken; - dispatch_once(&onceToken, ^{ - sharedJITContext = [[JITEnableContext alloc] init]; - }); - return sharedJITContext; -} - -- (instancetype)init { - NSFileManager* fm = [NSFileManager defaultManager]; - NSURL* docPathUrl = [fm URLsForDirectory:NSDocumentDirectory inDomains:NSUserDomainMask].firstObject; - NSURL* logURL = [docPathUrl URLByAppendingPathComponent:@"idevice_log.txt"]; - idevice_init_logger(Info, Debug, (char*)logURL.path.UTF8String); - syslogQueue = dispatch_queue_create("com.stik.syslogrelay.queue", DISPATCH_QUEUE_SERIAL); - syslogStreaming = NO; - syslogClient = NULL; - dispatch_queue_attr_t qosAttr = dispatch_queue_attr_make_with_qos_class(DISPATCH_QUEUE_SERIAL, QOS_CLASS_USER_INITIATED, 0); - processInspectorQueue = dispatch_queue_create("com.stikdebug.processInspector", qosAttr); - - tunnelLock = OS_UNFAIR_LOCK_INIT; - tunnelConnecting = NO; - tunnelSemaphore = NULL; - lastTunnelError = nil; - - return self; -} - -- (NSError*)errorWithStr:(NSString*)str code:(int)code { - return [NSError errorWithDomain:@"StikJIT" - code:code - userInfo:@{ NSLocalizedDescriptionKey: str }]; -} - -- (LogFuncC)createCLogger:(LogFunc)logger { - return ^(const char* format, ...) { - va_list args; - va_start(args, format); - NSString* fmt = [NSString stringWithCString:format encoding:NSASCIIStringEncoding]; - NSString* message = [[NSString alloc] initWithFormat:fmt arguments:args]; - - if ([message containsString:@"ERROR"] || [message containsString:@"Error"]) { - [[LogManagerBridge shared] addErrorLog:message]; - } else if ([message containsString:@"WARNING"] || [message containsString:@"Warning"]) { - [[LogManagerBridge shared] addWarningLog:message]; - } else if ([message containsString:@"DEBUG"]) { - [[LogManagerBridge shared] addDebugLog:message]; - } else { - [[LogManagerBridge shared] addInfoLog:message]; - } - - if (logger) { - logger(message); - } - va_end(args); - }; -} - -- (RpPairingFileHandle*)getPairingFileWithError:(NSError**)error { - NSFileManager* fm = [NSFileManager defaultManager]; - NSURL* docPathUrl = [fm URLsForDirectory:NSDocumentDirectory inDomains:NSUserDomainMask].firstObject; - NSURL* pairingFileURL = [docPathUrl URLByAppendingPathComponent:@"pairingFile.plist"]; - - if (![fm fileExistsAtPath:pairingFileURL.path]) { - *error = [self errorWithStr:@"Pairing file not found!" code:-17]; - return nil; - } - - RpPairingFileHandle* pairingFile = NULL; - IdeviceFfiError* err = rp_pairing_file_read(pairingFileURL.fileSystemRepresentation, &pairingFile); - if (err) { - *error = [self errorWithStr:@"Failed to read pairing file!" code:err->code]; - idevice_error_free(err); - return nil; - } - return pairingFile; -} - -- (BOOL)startTunnel:(NSError**)err { - os_unfair_lock_lock(&tunnelLock); - - // If tunnel is already being created, wait for it to complete - if (tunnelConnecting) { - dispatch_semaphore_t waitSemaphore = tunnelSemaphore; - os_unfair_lock_unlock(&tunnelLock); - - if (waitSemaphore) { - dispatch_semaphore_wait(waitSemaphore, DISPATCH_TIME_FOREVER); - dispatch_semaphore_signal(waitSemaphore); - } - *err = lastTunnelError; - return *err == nil; - } - - // Mark tunnel as connecting - tunnelConnecting = YES; - tunnelSemaphore = dispatch_semaphore_create(0); - dispatch_semaphore_t completionSemaphore = tunnelSemaphore; - os_unfair_lock_unlock(&tunnelLock); - - RpPairingFileHandle* pairingFile = [self getPairingFileWithError:err]; - if (*err) { - os_unfair_lock_lock(&tunnelLock); - tunnelConnecting = NO; - tunnelSemaphore = NULL; - os_unfair_lock_unlock(&tunnelLock); - dispatch_semaphore_signal(completionSemaphore); - return NO; - } - - struct sockaddr_in addr; - memset(&addr, 0, sizeof(addr)); - addr.sin_family = AF_INET; - addr.sin_port = htons(49152); - - NSString* deviceIP = [[NSUserDefaults standardUserDefaults] stringForKey:@"customTargetIP"]; - inet_pton(AF_INET, (deviceIP && deviceIP.length > 0) ? [deviceIP UTF8String] : "10.7.0.1", &addr.sin_addr); - - AdapterHandle *newAdapter = NULL; - RsdHandshakeHandle *newHandshake = NULL; - IdeviceFfiError *ffiErr = tunnel_create_rppairing( - (const idevice_sockaddr *)&addr, - sizeof(addr), - "StikDebug", - pairingFile, - NULL, - NULL, - &newAdapter, - &newHandshake - ); - rp_pairing_file_free(pairingFile); - - if (ffiErr) { - *err = [self errorWithStr:[NSString stringWithUTF8String:ffiErr->message ?: "Failed to create tunnel"] - code:ffiErr->code]; - lastTunnelError = *err; - idevice_error_free(ffiErr); - } else { - // Clean up old tunnel if any - if (handshake) { rsd_handshake_free(handshake); } - if (adapter) { adapter_free(adapter); } - adapter = newAdapter; - handshake = newHandshake; - lastTunnelError = nil; - } - - os_unfair_lock_lock(&tunnelLock); - tunnelConnecting = NO; - tunnelSemaphore = NULL; - os_unfair_lock_unlock(&tunnelLock); - dispatch_semaphore_signal(completionSemaphore); - - return *err == nil; -} - -- (BOOL)ensureTunnelWithError:(NSError**)err { - if (!adapter || !handshake) { - return [self startTunnel:err]; - } - return YES; -} - -- (void)dealloc { - [self stopSyslogRelay]; - if (handshake) { - rsd_handshake_free(handshake); - } - if (adapter) { - adapter_free(adapter); - } -} - -@end diff --git a/StikJIT/idevice/JITEnableContextInternal.h b/StikJIT/idevice/JITEnableContextInternal.h deleted file mode 100644 index 1b6c8515..00000000 --- a/StikJIT/idevice/JITEnableContextInternal.h +++ /dev/null @@ -1,17 +0,0 @@ -// -// JITEnableContextInternal.h -// StikDebug -// -// Created by s s on 2025/12/12. -// -#include "idevice.h" -#import "JITEnableContext.h" -@import Foundation; - - -@interface JITEnableContext(Internal) - -- (LogFuncC)createCLogger:(LogFunc)logger; -- (NSError*)errorWithStr:(NSString*)str code:(int)code; - -@end diff --git a/StikJIT/idevice/applist.h b/StikJIT/idevice/applist.h deleted file mode 100644 index 1731cd8d..00000000 --- a/StikJIT/idevice/applist.h +++ /dev/null @@ -1,18 +0,0 @@ -// -// applist.h -// StikJIT -// -// Created by Stephen on 3/27/25. -// - -#ifndef APPLIST_H -#define APPLIST_H -@import Foundation; -@import UIKit; - -NSDictionary* list_installed_apps(AdapterHandle* adapter, RsdHandshakeHandle* handshake, NSString** error); -NSDictionary* list_all_apps(AdapterHandle* adapter, RsdHandshakeHandle* handshake, NSString** error); -NSDictionary* list_hidden_system_apps(AdapterHandle* adapter, RsdHandshakeHandle* handshake, NSString** error); -UIImage* getAppIcon(AdapterHandle* adapter, RsdHandshakeHandle* handshake, NSString* bundleID, NSString** error); - -#endif /* APPLIST_H */ diff --git a/StikJIT/idevice/applist.m b/StikJIT/idevice/applist.m deleted file mode 100644 index f39d17f0..00000000 --- a/StikJIT/idevice/applist.m +++ /dev/null @@ -1,363 +0,0 @@ -// -// applist.c -// StikJIT -// -// Created by Stephen on 3/27/25. -// - -#import "idevice.h" -#include -#include -#include -#import "applist.h" -#import "JITEnableContext.h" -#import "JITEnableContextInternal.h" - -NSError* makeError(int code, NSString* msg); -static NSString *extractAppName(plist_t app) -{ - plist_t displayNameNode = plist_dict_get_item(app, "CFBundleDisplayName"); - if (displayNameNode) { - char *displayNameC = NULL; - plist_get_string_val(displayNameNode, &displayNameC); - if (displayNameC && displayNameC[0] != '\0') { - NSString *displayName = [NSString stringWithUTF8String:displayNameC]; - plist_mem_free(displayNameC); - return displayName; - } - plist_mem_free(displayNameC); - } - - plist_t nameNode = plist_dict_get_item(app, "CFBundleName"); - if (nameNode) { - char *nameC = NULL; - plist_get_string_val(nameNode, &nameC); - if (nameC && nameC[0] != '\0') { - NSString *name = [NSString stringWithUTF8String:nameC]; - plist_mem_free(nameC); - return name; - } - plist_mem_free(nameC); - } - - return @"Unknown"; -} - -static BOOL nodeContainsHiddenTag(plist_t tagsNode) -{ - if (!tagsNode || plist_get_node_type(tagsNode) != PLIST_ARRAY) { - return NO; - } - - uint32_t tagsCount = plist_array_get_size(tagsNode); - for (uint32_t i = 0; i < tagsCount; i++) { - plist_t tagNode = plist_array_get_item(tagsNode, i); - if (!tagNode || plist_get_node_type(tagNode) != PLIST_STRING) { - continue; - } - char *tagC = NULL; - plist_get_string_val(tagNode, &tagC); - if (!tagC) { - continue; - } - BOOL isHidden = (strcmp(tagC, "hidden") == 0 || strcmp(tagC, "hidden-system-app") == 0); - free(tagC); - if (isHidden) { - return YES; - } - } - return NO; -} - -static BOOL isHiddenSystemApp(plist_t app) -{ - plist_t typeNode = plist_dict_get_item(app, "ApplicationType"); - BOOL isSystemType = NO; - if (typeNode && plist_get_node_type(typeNode) == PLIST_STRING) { - char *typeC = NULL; - plist_get_string_val(typeNode, &typeC); - if (typeC) { - if (strcmp(typeC, "System") == 0 || strcmp(typeC, "HiddenSystemApp") == 0) { - isSystemType = YES; - } - free(typeC); - } - } - - if (!isSystemType) { - return NO; - } - - plist_t hiddenNode = plist_dict_get_item(app, "IsHidden"); - if (hiddenNode && plist_get_node_type(hiddenNode) == PLIST_BOOLEAN) { - uint8_t hidden = 0; - plist_get_bool_val(hiddenNode, &hidden); - if (hidden) { - return YES; - } - } - - plist_t tagsNode = plist_dict_get_item(app, "SBAppTags"); - if (nodeContainsHiddenTag(tagsNode)) { - return YES; - } - - return NO; -} - -static NSDictionary *buildAppDictionary(void *apps, - size_t count, - BOOL requireGetTaskAllow, - BOOL (^filter)(plist_t app)) -{ - NSMutableDictionary *result = [NSMutableDictionary dictionaryWithCapacity:count]; - - for (size_t i = 0; i < count; i++) { - plist_t app = ((plist_t *)apps)[i]; - plist_t ent = plist_dict_get_item(app, "Entitlements"); - - if (requireGetTaskAllow) { - if (!ent) continue; - plist_t tnode = plist_dict_get_item(ent, "get-task-allow"); - if (!tnode) continue; - - uint8_t isAllowed = 0; - plist_get_bool_val(tnode, &isAllowed); - if (!isAllowed) continue; - } - - if (filter && !filter(app)) { - continue; - } - - plist_t bidNode = plist_dict_get_item(app, "CFBundleIdentifier"); - if (!bidNode) continue; - - char *bidC = NULL; - plist_get_string_val(bidNode, &bidC); - if (!bidC || bidC[0] == '\0') { - plist_mem_free(bidC); - continue; - } - - NSString *bundleID = [NSString stringWithUTF8String:bidC]; - plist_mem_free(bidC); - - result[bundleID] = extractAppName(app); - } - - return result; -} - -static NSArray* getSideloadedApps(AdapterHandle *adapter, - RsdHandshakeHandle *handshake, - NSString **error) { - InstallationProxyClientHandle *client = NULL; - IdeviceFfiError* err = installation_proxy_connect_rsd(adapter, handshake, &client); - if (err) { - *error = [NSString stringWithFormat:@"Failed to connect to installation proxy: %s", err->message]; - idevice_error_free(err); - return nil; - } - - plist_t *apps = NULL; - size_t count = 0; - err = installation_proxy_get_apps(client, NULL, NULL, 0, (void*)&apps, &count); - if (err) { - *error = [NSString stringWithFormat:@"Failed to get apps: %s", err->message]; - idevice_error_free(err); - installation_proxy_client_free(client); - return nil; - } - - NSMutableArray* result = [NSMutableArray new]; - - for (size_t i = 0; i < count; i++) { - plist_t app = ((plist_t *)apps)[i]; - - plist_t profileValidatedNode = 0; - if(!(profileValidatedNode = plist_dict_get_item(app, "ProfileValidated"))) { - continue; - } - - char* bin = 0; - uint32_t size = 0; - plist_to_bin(app, &bin, &size); - if(!bin || size == 0) { - continue; - } - - NSData* d = [NSData dataWithBytes:bin length:size]; - NSError* err; - NSDictionary* dict = [NSPropertyListSerialization propertyListWithData:d options:0 format:nil error:&err]; - plist_mem_free(bin); - - if(err) { - continue; - } - - [result addObject:dict]; - - } - - installation_proxy_client_free(client); - for(int i = 0; i < count; ++i) { - plist_free(apps[i]); - } - idevice_data_free((uint8_t *)apps, sizeof(plist_t)*count); - - return result; -} - -static NSDictionary *performAppQuery(AdapterHandle *adapter, - RsdHandshakeHandle *handshake, - BOOL requireGetTaskAllow, - NSString **error, - BOOL (^filter)(plist_t app)) -{ - InstallationProxyClientHandle *client = NULL; - IdeviceFfiError* err = installation_proxy_connect_rsd(adapter, handshake, &client); - if (err) { - *error = [NSString stringWithFormat:@"Failed to connect to installation proxy: %s", err->message]; - idevice_error_free(err); - return nil; - } - - plist_t *apps = NULL; - size_t count = 0; - err = installation_proxy_get_apps(client, NULL, NULL, 0, (void*)&apps, &count); - if (err) { - *error = [NSString stringWithFormat:@"Failed to get apps: %s", err->message]; - idevice_error_free(err); - installation_proxy_client_free(client); - return nil; - } - - NSDictionary *result = buildAppDictionary(apps, count, requireGetTaskAllow, filter); - installation_proxy_client_free(client); - for(int i = 0; i < count; ++i) { - plist_free(apps[i]); - } - idevice_data_free((uint8_t *)apps, sizeof(plist_t)*count); - return result; -} - -NSDictionary* list_installed_apps(AdapterHandle* adapter, RsdHandshakeHandle* handshake, NSString** error) { - return performAppQuery(adapter, handshake, YES, error, nil); -} - -NSDictionary* list_all_apps(AdapterHandle* adapter, RsdHandshakeHandle* handshake, NSString** error) { - return performAppQuery(adapter, handshake, NO, error, nil); -} - -NSDictionary* list_hidden_system_apps(AdapterHandle* adapter, RsdHandshakeHandle* handshake, NSString** error) { - return performAppQuery(adapter, handshake, NO, error, ^BOOL(plist_t app) { - return isHiddenSystemApp(app); - }); -} - -UIImage* getAppIcon(AdapterHandle* adapter, RsdHandshakeHandle* handshake, NSString* bundleID, NSString** error) { - SpringBoardServicesClientHandle *client = NULL; - IdeviceFfiError *err = springboard_services_connect_rsd(adapter, handshake, &client); - if (err) { - *error = [NSString stringWithUTF8String:err->message ?: "Failed to connect to SpringBoard Services"]; - idevice_error_free(err); - return nil; - } - - void *pngData = NULL; - size_t dataLen = 0; - err = springboard_services_get_icon(client, [bundleID UTF8String], &pngData, &dataLen); - if (err) { - *error = [NSString stringWithUTF8String:err->message ?: "Failed to get app icon"]; - idevice_error_free(err); - springboard_services_free(client); - return nil; - } - - NSData *data = [NSData dataWithBytes:pngData length:dataLen]; - free(pngData); - UIImage *icon = [UIImage imageWithData:data]; - - springboard_services_free(client); - return icon; -} - -@implementation JITEnableContext(App) - -- (NSDictionary*)getAppListWithError:(NSError**)error { - [self ensureTunnelWithError:error]; - if(*error) { - return nil; - } - - NSString* errorStr = nil; - NSDictionary* apps = list_installed_apps(adapter, handshake, &errorStr); - if (errorStr) { - *error = [self errorWithStr:errorStr code:-17]; - return nil; - } - return apps; -} - -- (NSDictionary*)getAllAppsWithError:(NSError**)error { - [self ensureTunnelWithError:error]; - if(*error) { - return nil; - } - - NSString* errorStr = nil; - NSDictionary* apps = list_all_apps(adapter, handshake, &errorStr); - if (errorStr) { - *error = [self errorWithStr:errorStr code:-17]; - return nil; - } - return apps; -} - -- (NSDictionary*)getHiddenSystemAppsWithError:(NSError**)error { - [self ensureTunnelWithError:error]; - if(*error) { - return nil; - } - - NSString* errorStr = nil; - NSDictionary* apps = list_hidden_system_apps(adapter, handshake, &errorStr); - if (errorStr) { - *error = [self errorWithStr:errorStr code:-17]; - return nil; - } - return apps; -} - -- (NSArray*)getSideloadedAppsWithError:(NSError**)error { - [self ensureTunnelWithError:error]; - if(*error) { - return nil; - } - - NSString* errorStr = nil; - NSArray* apps = getSideloadedApps(adapter, handshake, &errorStr); - if (errorStr) { - *error = [self errorWithStr:errorStr code:-17]; - return nil; - } - return apps; -} - -- (UIImage*)getAppIconWithBundleId:(NSString*)bundleId error:(NSError**)error { - [self ensureTunnelWithError:error]; - if(*error) { - return nil; - } - - NSString* errorStr = nil; - UIImage* icon = getAppIcon(adapter, handshake, bundleId, &errorStr); - if (errorStr) { - *error = [self errorWithStr:errorStr code:-17]; - return nil; - } - return icon; -} - -@end diff --git a/StikJIT/idevice/ideviceinfo.h b/StikJIT/idevice/ideviceinfo.h deleted file mode 100644 index 78ef64b3..00000000 --- a/StikJIT/idevice/ideviceinfo.h +++ /dev/null @@ -1,23 +0,0 @@ -// -// ideviceinfo.h -// StikDebug -// -// Created by Stephen on 8/2/25. -// - -#ifndef IDEVICEINFO_H -#define IDEVICEINFO_H - -#include - -#ifdef __cplusplus -extern "C" { -#endif - - - -#ifdef __cplusplus -} -#endif - -#endif // IDEVICEINFO_H diff --git a/StikJIT/idevice/ideviceinfo.m b/StikJIT/idevice/ideviceinfo.m deleted file mode 100644 index 6921bde7..00000000 --- a/StikJIT/idevice/ideviceinfo.m +++ /dev/null @@ -1,66 +0,0 @@ -// -// ideviceinfo.c -// StikDebug -// -// Created by Stephen on 8/2/25. -// - -#include -#include -#include "ideviceinfo.h" -#include "idevice.h" -#import "JITEnableContext.h" -#import "JITEnableContextInternal.h" -@import Foundation; - -NSError* makeError(int code, NSString* msg); -LockdowndClientHandle* ideviceinfo_c_init(AdapterHandle* adapter, RsdHandshakeHandle* handshake, NSError** error) { - LockdowndClientHandle *g_client = NULL; - IdeviceFfiError *err = lockdownd_connect_rsd(adapter, handshake, &g_client); - if (err) { - *error = makeError(err->code, @(err->message)); - idevice_error_free(err); - return NULL; - } - - // No session start needed - RSD tunnel handles authentication - return g_client; -} - -char *ideviceinfo_c_get_xml(LockdowndClientHandle* g_client, NSError** error) { - if (!g_client) { - return NULL; - } - - void *plist_obj = NULL; - struct IdeviceFfiError *err = lockdownd_get_value(g_client, NULL, NULL, &plist_obj); - if (err) { - *error = makeError(err->code, @(err->message)); - idevice_error_free(err); - return NULL; - } - - char *xml = NULL; - uint32_t xml_len = 0; - if (plist_to_xml(plist_obj, &xml, &xml_len) != 0 || !xml) { - plist_free(plist_obj); - return NULL; - } - plist_free(plist_obj); - return xml; -} - -@implementation JITEnableContext(DeviceInfo) - -- (LockdowndClientHandle*)ideviceInfoInit:(NSError**)error { - [self ensureTunnelWithError:error]; - if (*error) { return nil; } - return ideviceinfo_c_init(adapter, handshake, error); -} - -- (char*)ideviceInfoGetXMLWithLockdownClient:(LockdowndClientHandle*)lockdownClient error:(NSError**)error { - [self ensureTunnelWithError:error]; - if (*error) { return NULL; } - return ideviceinfo_c_get_xml(lockdownClient, error); -} -@end diff --git a/StikJIT/idevice/jit.h b/StikJIT/idevice/jit.h deleted file mode 100644 index e7a3480f..00000000 --- a/StikJIT/idevice/jit.h +++ /dev/null @@ -1,23 +0,0 @@ -// -// jit.h -// StikJIT -// -// Created by Stephen on 3/27/25. -// - -// jit.h -#ifndef JIT_H -#define JIT_H -#include "idevice.h" -#include - -typedef void (^LogFuncC)(const char* message, ...); -typedef void (^DebugAppCallback)(int pid, - struct DebugProxyHandle* debug_proxy, - struct RemoteServerHandle* remote_server, - dispatch_semaphore_t semaphore); -int debug_app(AdapterHandle* adapter, RsdHandshakeHandle* handshake, const char *bundle_id, LogFuncC logger, DebugAppCallback callback); -int debug_app_pid(AdapterHandle* adapter, RsdHandshakeHandle* handshake, int pid, LogFuncC logger, DebugAppCallback callback); -int launch_app_via_proxy(AdapterHandle* adapter, RsdHandshakeHandle* handshake, const char *bundle_id, LogFuncC logger); - -#endif /* JIT_H */ diff --git a/StikJIT/idevice/jit.m b/StikJIT/idevice/jit.m deleted file mode 100644 index e86ef338..00000000 --- a/StikJIT/idevice/jit.m +++ /dev/null @@ -1,219 +0,0 @@ -// -// jit.c -// StikJIT -// -// Created by Stephen on 3/27/25. -// - -// Jackson Coxson - -#include -#include -#include -#include -#include -#include -#include -#include - -#include "jit.h" -#import "JITEnableContext.h" -#import "JITEnableContextInternal.h" - -// MARK: - Shared debug session - -typedef struct { - RemoteServerHandle *remote_server; - DebugProxyHandle *debug_proxy; -} DebugSession; - -static void debug_session_free(DebugSession *s) { - if (s->debug_proxy) { debug_proxy_free(s->debug_proxy); s->debug_proxy = NULL; } - if (s->remote_server) { remote_server_free(s->remote_server); s->remote_server = NULL; } -} - -// Connects to the device using the existing adapter+handshake and sets up the debug proxy. -// Returns 0 on success; cleans up any partial state and returns 1 on failure. -static int connect_debug_session(AdapterHandle *adapter, RsdHandshakeHandle *handshake, DebugSession *out) { - memset(out, 0, sizeof(*out)); - IdeviceFfiError *err = NULL; - - err = remote_server_connect_rsd(adapter, handshake, &out->remote_server); - if (err) { idevice_error_free(err); return 1; } - - err = debug_proxy_connect_rsd(adapter, handshake, &out->debug_proxy); - if (err) { idevice_error_free(err); debug_session_free(out); return 1; } - - return 0; -} - -// MARK: - Debug server commands - -void runDebugServerCommand(int pid, - DebugProxyHandle* debug_proxy, - RemoteServerHandle* remote_server, - LogFuncC logger, - DebugAppCallback callback) { - // Enable QStartNoAckMode - char *disableResponse = NULL; - debug_proxy_send_ack(debug_proxy); - debug_proxy_send_ack(debug_proxy); - DebugserverCommandHandle *disableAckCommand = debugserver_command_new("QStartNoAckMode", NULL, 0); - IdeviceFfiError* err = debug_proxy_send_command(debug_proxy, disableAckCommand, &disableResponse); - debugserver_command_free(disableAckCommand); - logger("QStartNoAckMode result = %s, err = %d", disableResponse, err); - idevice_string_free(disableResponse); - debug_proxy_set_ack_mode(debug_proxy, false); - - if (callback) { - dispatch_semaphore_t semaphore = dispatch_semaphore_create(0); - callback(pid, debug_proxy, remote_server, semaphore); - dispatch_semaphore_wait(semaphore, DISPATCH_TIME_FOREVER); - err = debug_proxy_send_raw(debug_proxy, "\x03", 1); - usleep(500); - } else { - char attach_command[64]; - snprintf(attach_command, sizeof(attach_command), "vAttach;%" PRIx64, pid); - - DebugserverCommandHandle *attach_cmd = debugserver_command_new(attach_command, NULL, 0); - if (attach_cmd == NULL) { - logger("Failed to create attach command"); - return; - } - - char *attach_response = NULL; - err = debug_proxy_send_command(debug_proxy, attach_cmd, &attach_response); - debugserver_command_free(attach_cmd); - if (err) { - logger("Failed to attach to process: %d", err->code); - idevice_error_free(err); - } else if (attach_response != NULL) { - logger("Attach response: %s", attach_response); - idevice_string_free(attach_response); - } - } - - // Send detach command - DebugserverCommandHandle *detach_cmd = debugserver_command_new("D", NULL, 0); - if (detach_cmd == NULL) { - logger("Failed to create detach command"); - } else { - char *detach_response = NULL; - err = debug_proxy_send_command(debug_proxy, detach_cmd, &detach_response); - debugserver_command_free(detach_cmd); - if (err) { - logger("Failed to detach from process: %d", err->code); - idevice_error_free(err); - } else if (detach_response != NULL) { - logger("Detach response: %s", detach_response); - idevice_string_free(detach_response); - } - } -} - -// MARK: - Public entry points - -int debug_app(AdapterHandle* adapter, RsdHandshakeHandle* handshake, const char *bundle_id, LogFuncC logger, DebugAppCallback callback) { - DebugSession session; - if (connect_debug_session(adapter, handshake, &session) != 0) return 1; - - ProcessControlHandle *process_control = NULL; - IdeviceFfiError *err = process_control_new(session.remote_server, &process_control); - if (err) { - idevice_error_free(err); - debug_session_free(&session); - return 1; - } - - uint64_t pid = 0; - err = process_control_launch_app(process_control, bundle_id, NULL, 0, NULL, 0, true, false, &pid); - if (err) { - idevice_error_free(err); - process_control_free(process_control); - debug_session_free(&session); - return 1; - } - - runDebugServerCommand((int)pid, session.debug_proxy, session.remote_server, logger, callback); - - process_control_free(process_control); - debug_session_free(&session); - logger("Debug session completed"); - return 0; -} - -int debug_app_pid(AdapterHandle* adapter, RsdHandshakeHandle* handshake, int pid, LogFuncC logger, DebugAppCallback callback) { - DebugSession session; - if (connect_debug_session(adapter, handshake, &session) != 0) return 1; - - runDebugServerCommand(pid, session.debug_proxy, session.remote_server, logger, callback); - - debug_session_free(&session); - logger("Debug session completed"); - return 0; -} - -int launch_app_via_proxy(AdapterHandle* adapter, RsdHandshakeHandle* handshake, const char *bundle_id, LogFuncC logger) { - IdeviceFfiError* err = NULL; - - RemoteServerHandle *remote_server = NULL; - ProcessControlHandle *process_control = NULL; - uint64_t pid = 0; - int result = 1; - - err = remote_server_connect_rsd(adapter, handshake, &remote_server); - if (err) { idevice_error_free(err); goto cleanup; } - - err = process_control_new(remote_server, &process_control); - if (err) { idevice_error_free(err); goto cleanup; } - - err = process_control_launch_app(process_control, bundle_id, NULL, 0, NULL, 0, false, true, &pid); - if (err) { - idevice_error_free(err); - if (logger) logger("Failed to launch app: %s", bundle_id); - goto cleanup; - } - - if (logger) logger("Launched app (PID %llu)", pid); - result = 0; - -cleanup: - if (process_control) process_control_free(process_control); - if (remote_server) remote_server_free(remote_server); - return result; -} - - -@implementation JITEnableContext(JIT) - -- (BOOL)debugAppWithBundleID:(NSString*)bundleID logger:(LogFunc)logger jsCallback:(DebugAppCallback)jsCallback { - NSError* err = nil; - [self ensureTunnelWithError:&err]; - if (err) { - logger(err.localizedDescription); - return NO; - } - return debug_app(adapter, handshake, [bundleID UTF8String], [self createCLogger:logger], jsCallback) == 0; -} - -- (BOOL)debugAppWithPID:(int)pid logger:(LogFunc)logger jsCallback:(DebugAppCallback)jsCallback { - NSError* err = nil; - [self ensureTunnelWithError:&err]; - if (err) { - logger(err.localizedDescription); - return NO; - } - return debug_app_pid(adapter, handshake, pid, [self createCLogger:logger], jsCallback) == 0; -} - -- (BOOL)launchAppWithoutDebug:(NSString*)bundleID logger:(LogFunc)logger { - NSError* err = nil; - [self ensureTunnelWithError:&err]; - if (err) { - logger(err.localizedDescription); - return NO; - } - return launch_app_via_proxy(adapter, handshake, [bundleID UTF8String], [self createCLogger:logger]) == 0; -} - -@end diff --git a/StikJIT/idevice/location_simulation.c b/StikJIT/idevice/location_simulation.c deleted file mode 100644 index 405533b3..00000000 --- a/StikJIT/idevice/location_simulation.c +++ /dev/null @@ -1,111 +0,0 @@ -// -// location_simulation.c -// StikDebug -// -// Created by Stephen on 8/3/25. -// - -#include "location_simulation.h" -#include "idevice.h" -#include -#include -#include -#include -#include - -static AdapterHandle *g_adapter = NULL; -static RsdHandshakeHandle *g_handshake = NULL; -static RemoteServerHandle *g_remote_server = NULL; -static LocationSimulationHandle *g_location_sim = NULL; - -static void cleanup_on_error(void) { - if (g_location_sim) { location_simulation_free(g_location_sim); g_location_sim = NULL; } - if (g_remote_server) { remote_server_free(g_remote_server); g_remote_server = NULL; } - if (g_handshake) { rsd_handshake_free(g_handshake); g_handshake = NULL; } - if (g_adapter) { adapter_free(g_adapter); g_adapter = NULL; } -} - -int simulate_location(const char *device_ip, - double latitude, - double longitude, - const char *pairing_file) -{ - IdeviceFfiError *err = NULL; - - if (g_location_sim) { - if ((err = location_simulation_set(g_location_sim, latitude, longitude))) { - idevice_error_free(err); - cleanup_on_error(); - } else { - return IPA_OK; - } - } - - struct sockaddr_in addr = { .sin_family = AF_INET, - .sin_port = htons(49152) }; - if (inet_pton(AF_INET, device_ip, &addr.sin_addr) != 1) { - return IPA_ERR_INVALID_IP; - } - - RpPairingFileHandle *rp_pairing = NULL; - if ((err = rp_pairing_file_read(pairing_file, &rp_pairing))) { - idevice_error_free(err); - return IPA_ERR_PAIRING_READ; - } - - if ((err = tunnel_create_rppairing( - (const idevice_sockaddr *)&addr, - sizeof(addr), - "StikDebugLocation", - rp_pairing, - NULL, - NULL, - &g_adapter, - &g_handshake))) - { - idevice_error_free(err); - rp_pairing_file_free(rp_pairing); - cleanup_on_error(); - return IPA_ERR_PROVIDER_CREATE; - } - rp_pairing_file_free(rp_pairing); - - if ((err = remote_server_connect_rsd(g_adapter, - g_handshake, - &g_remote_server))) - { - idevice_error_free(err); - cleanup_on_error(); - return IPA_ERR_REMOTE_SERVER; - } - - if ((err = location_simulation_new(g_remote_server, - &g_location_sim))) { - idevice_error_free(err); - cleanup_on_error(); - return IPA_ERR_LOCATION_SIM; - } - // location_simulation_new takes ownership of g_remote_server. - g_remote_server = NULL; - - if ((err = location_simulation_set(g_location_sim, - latitude, - longitude))) { - idevice_error_free(err); - cleanup_on_error(); - return IPA_ERR_LOCATION_SET; - } - - return IPA_OK; -} - -int clear_simulated_location(void) -{ - IdeviceFfiError *err = NULL; - if (!g_location_sim) return IPA_ERR_LOCATION_CLEAR; - - err = location_simulation_clear(g_location_sim); - cleanup_on_error(); - - return err ? IPA_ERR_LOCATION_CLEAR : IPA_OK; -} diff --git a/StikJIT/idevice/location_simulation.h b/StikJIT/idevice/location_simulation.h deleted file mode 100644 index f1ff8d3e..00000000 --- a/StikJIT/idevice/location_simulation.h +++ /dev/null @@ -1,55 +0,0 @@ -// -// location_simulation.h -// StikDebug -// -// Created by Stephen on 8/3/25. -// - -#ifndef LOCATION_SIMULATION_H -#define LOCATION_SIMULATION_H - -#include - -#ifdef __cplusplus -extern "C" { -#endif - -// Success / error codes -#define IPA_OK 0 -#define IPA_ERR_INVALID_IP 1 -#define IPA_ERR_PAIRING_READ 2 -#define IPA_ERR_PROVIDER_CREATE 3 -#define IPA_ERR_CORE_DEVICE 4 -#define IPA_ERR_RSD_PORT 5 -#define IPA_ERR_ADAPTER_CREATE 6 -#define IPA_ERR_STREAM 7 -#define IPA_ERR_HANDSHAKE 8 -#define IPA_ERR_REMOTE_SERVER 9 -#define IPA_ERR_LOCATION_SIM 10 -#define IPA_ERR_LOCATION_SET 11 -#define IPA_ERR_LOCATION_CLEAR 12 - -/** - * Simulate the device’s location. - * @param device_ip IP address of the device. - * @param latitude Latitude to simulate. - * @param longitude Longitude to simulate. - * @param pairing_file Path to the pairing file. - * @return IPA_OK (0) on success, or one of the IPA_ERR_* codes. - */ -int simulate_location(const char *device_ip, - double latitude, - double longitude, - const char *pairing_file); - -/** - * Clear any simulated location. - * @return IPA_OK (0) on success, or IPA_ERR_LOCATION_CLEAR. - */ -int clear_simulated_location(void); - -#ifdef __cplusplus -} -#endif - -#endif /* LOCATION_SIMULATION_H */ diff --git a/StikJIT/idevice/module.modulemap b/StikJIT/idevice/module.modulemap new file mode 100644 index 00000000..ce711ac9 --- /dev/null +++ b/StikJIT/idevice/module.modulemap @@ -0,0 +1,4 @@ +module idevice [system] { + header "idevice.h" + export * +} diff --git a/StikJIT/idevice/mount.h b/StikJIT/idevice/mount.h deleted file mode 100644 index f56c1f22..00000000 --- a/StikJIT/idevice/mount.h +++ /dev/null @@ -1,14 +0,0 @@ -// -// mount.h -// StikDebug -// -// Created by s s on 2025/12/6. -// - -#ifndef MOUNT_H -#define MOUNT_H -#include "idevice.h" -#include -size_t getMountedDeviceCount(AdapterHandle* adapter, RsdHandshakeHandle* handshake, NSError** error); -int mountPersonalDDI(AdapterHandle* adapter, RsdHandshakeHandle* handshake, NSString* imagePath, NSString* trustcachePath, NSString* manifestPath, NSError** error); -#endif diff --git a/StikJIT/idevice/mount.m b/StikJIT/idevice/mount.m deleted file mode 100644 index 1897b36b..00000000 --- a/StikJIT/idevice/mount.m +++ /dev/null @@ -1,101 +0,0 @@ -// -// mount1.m -// StikDebug -// -// Created by s s on 2025/12/6. -// -#include "mount.h" -#import "JITEnableContext.h" -#import "JITEnableContextInternal.h" -@import Foundation; - -NSError* makeError(int code, NSString* msg); - -size_t getMountedDeviceCount(AdapterHandle* adapter, RsdHandshakeHandle* handshake, NSError** error) { - ImageMounterHandle *client = NULL; - IdeviceFfiError *err = image_mounter_connect_rsd(adapter, handshake, &client); - if (err) { - *error = makeError(err->code, @(err->message)); - idevice_error_free(err); - return 0; - } - - plist_t *devices = NULL; - size_t deviceLength = 0; - err = image_mounter_copy_devices(client, &devices, &deviceLength); - image_mounter_free(client); - if (err) { - *error = makeError(err->code, @(err->message)); - idevice_error_free(err); - return 0; - } - - for (int i = 0; i < (int)deviceLength; i++) { - plist_free(devices[i]); - } - idevice_data_free((uint8_t *)devices, deviceLength * sizeof(plist_t)); - return deviceLength; -} - -int mountPersonalDDI(AdapterHandle* adapter, RsdHandshakeHandle* handshake, NSString* imagePath, NSString* trustcachePath, NSString* manifestPath, NSError** error) { - NSData *image = [NSData dataWithContentsOfFile:imagePath]; - NSData *trustcache = [NSData dataWithContentsOfFile:trustcachePath]; - NSData *buildManifest = [NSData dataWithContentsOfFile:manifestPath]; - if (!image || !trustcache || !buildManifest) { - *error = makeError(1, @"Failed to read one or more files"); - return 1; - } - - // Get UniqueChipID via lockdownd over RSD (no session start needed - tunnel handles auth) - LockdowndClientHandle *lockdownClient = NULL; - IdeviceFfiError *err = lockdownd_connect_rsd(adapter, handshake, &lockdownClient); - if (err) { - *error = makeError(6, @(err->message)); - idevice_error_free(err); - return 6; - } - - plist_t uniqueChipIDPlist = NULL; - err = lockdownd_get_value(lockdownClient, "UniqueChipID", NULL, &uniqueChipIDPlist); - lockdownd_client_free(lockdownClient); - if (err) { - *error = makeError(8, @(err->message)); - idevice_error_free(err); - return 8; - } - - uint64_t uniqueChipID = 0; - plist_get_uint_val(uniqueChipIDPlist, &uniqueChipID); - plist_free(uniqueChipIDPlist); - - ImageMounterHandle *mounterClient = NULL; - err = image_mounter_connect_rsd(adapter, handshake, &mounterClient); - if (err) { - *error = makeError(9, @(err->message)); - idevice_error_free(err); - return 9; - } - - // TODO: image_mounter_mount_personalized still requires an IdeviceProviderHandle. - // The FFI needs an RSD variant (e.g. image_mounter_mount_personalized_rsd) that - // takes an AdapterHandle instead. - *error = makeError(10, @"mount_personalized not yet available over RSD tunnels"); - image_mounter_free(mounterClient); - return 10; -} - -@implementation JITEnableContext(DDI) - -- (NSUInteger)getMountedDeviceCount:(NSError**)error { - [self ensureTunnelWithError:error]; - if (*error) { return 0; } - return getMountedDeviceCount(adapter, handshake, error); -} - -- (NSInteger)mountPersonalDDIWithImagePath:(NSString*)imagePath trustcachePath:(NSString*)trustcachePath manifestPath:(NSString*)manifestPath error:(NSError**)error { - [self ensureTunnelWithError:error]; - if (*error) { return 0; } - return mountPersonalDDI(adapter, handshake, imagePath, trustcachePath, manifestPath, error); -} - -@end diff --git a/StikJIT/idevice/process.m b/StikJIT/idevice/process.m deleted file mode 100644 index 62393901..00000000 --- a/StikJIT/idevice/process.m +++ /dev/null @@ -1,130 +0,0 @@ -// -// process.m -// StikDebug -// -// Created by s s on 2025/12/12. -// - -#import "JITEnableContext.h" -#import "JITEnableContextInternal.h" -@import Foundation; - -// MARK: - Shared AppService session - -typedef struct { - AppServiceHandle *appService; -} AppServiceSession; - -static void app_service_session_free(AppServiceSession *s) { - if (s->appService) { app_service_free(s->appService); s->appService = NULL; } -} - -// Connects to the device via the existing adapter+handshake → AppService. -// Returns 0 on success; cleans up any partial state and returns 1 on failure. -static int connect_app_service(AdapterHandle *adapter, - RsdHandshakeHandle *handshake, - AppServiceSession *out, - JITEnableContext *ctx, - NSError **outError) -{ - memset(out, 0, sizeof(*out)); - IdeviceFfiError *ffiError = NULL; - - ffiError = app_service_connect_rsd(adapter, handshake, &out->appService); - if (ffiError) { - *outError = [ctx errorWithStr:[NSString stringWithUTF8String:ffiError->message ?: "Unable to open AppService"] - code:ffiError->code]; - idevice_error_free(ffiError); - return 1; - } - - return 0; -} - -// MARK: - JITEnableContext(Process) - -@implementation JITEnableContext(Process) - -- (NSArray*)fetchProcessesViaAppServiceWithError:(NSError **)error { - [self ensureTunnelWithError:error]; - if (*error) { return nil; } - - AppServiceSession session; - if (connect_app_service(adapter, handshake, &session, self, error) != 0) { return nil; } - - ProcessTokenC *processes = NULL; - uintptr_t count = 0; - IdeviceFfiError *ffiError = app_service_list_processes(session.appService, &processes, &count); - - NSMutableArray *result = nil; - if (ffiError) { - if (error) { - *error = [self errorWithStr:[NSString stringWithUTF8String:ffiError->message ?: "Failed to list processes"] - code:ffiError->code]; - } - idevice_error_free(ffiError); - } else { - result = [NSMutableArray arrayWithCapacity:count]; - for (uintptr_t idx = 0; idx < count; idx++) { - ProcessTokenC proc = processes[idx]; - NSMutableDictionary *entry = [NSMutableDictionary dictionary]; - entry[@"pid"] = @(proc.pid); - if (proc.executable_url) { - entry[@"path"] = [NSString stringWithUTF8String:proc.executable_url]; - } - [result addObject:entry]; - } - if (processes && count > 0) { - app_service_free_process_list(processes, count); - } - } - - app_service_session_free(&session); - return result; -} - -- (NSArray*)_fetchProcessListLocked:(NSError**)error { - [self ensureTunnelWithError:error]; - if (*error) { return nil; } - return [self fetchProcessesViaAppServiceWithError:error]; -} - -- (NSArray*)fetchProcessListWithError:(NSError**)error { - __block NSArray *result = nil; - __block NSError *localError = nil; - dispatch_sync(processInspectorQueue, ^{ - result = [self _fetchProcessListLocked:&localError]; - }); - if (error && localError) { - *error = localError; - } - return result; -} - -- (BOOL)killProcessWithPID:(int)pid error:(NSError **)error { - [self ensureTunnelWithError:error]; - if (*error) { return NO; } - - AppServiceSession session; - if (connect_app_service(adapter, handshake, &session, self, error) != 0) { return NO; } - - SignalResponseC *signalResponse = NULL; - IdeviceFfiError *ffiError = app_service_send_signal(session.appService, (uint32_t)pid, SIGKILL, &signalResponse); - - BOOL success = NO; - if (ffiError) { - if (error) { - *error = [self errorWithStr:[NSString stringWithUTF8String:ffiError->message ?: "Failed to kill process"] - code:ffiError->code]; - } - idevice_error_free(ffiError); - } else { - success = YES; - } - - if (signalResponse) { app_service_free_signal_response(signalResponse); } - app_service_session_free(&session); - return success; -} - -@end diff --git a/StikJIT/idevice/profiles.h b/StikJIT/idevice/profiles.h deleted file mode 100644 index fa65d22a..00000000 --- a/StikJIT/idevice/profiles.h +++ /dev/null @@ -1,22 +0,0 @@ -// -// profiles.h -// StikDebug -// -// Created by s s on 2025/11/29. -// - -#ifndef PROFILES_H -#define PROFILES_H -#include "idevice.h" -#include -NSArray* _Nullable fetchAppProfiles(AdapterHandle* _Nonnull adapter, RsdHandshakeHandle* _Nonnull handshake, NSError* _Nullable * _Nullable error); -bool removeProfile(AdapterHandle* _Nonnull adapter, RsdHandshakeHandle* _Nonnull handshake, NSString* _Nonnull uuid, NSError* _Nullable * _Nullable error); -bool addProfile(AdapterHandle* _Nonnull adapter, RsdHandshakeHandle* _Nonnull handshake, NSData* _Nonnull profile, NSError* _Nullable * _Nullable error); - -@interface CMSDecoderHelper : NSObject -// Decode CMS/PKCS7 data and return decoded payload and any embedded certs -+ (NSData*)decodeCMSData:(NSData *)cmsData -// outCerts:(NSArray * _Nullable * _Nullable)outCerts - error:(NSError * _Nullable * _Nullable)error; -@end -#endif diff --git a/StikJIT/idevice/profiles.m b/StikJIT/idevice/profiles.m deleted file mode 100644 index a88abfb9..00000000 --- a/StikJIT/idevice/profiles.m +++ /dev/null @@ -1,170 +0,0 @@ -// -// profiles.m -// StikDebug -// -// Created by s s on 2025/11/29. -// -#include "profiles.h" -#import "JITEnableContext.h" -#import "JITEnableContextInternal.h" -@import Foundation; - -NSError* makeError(int code, NSString* msg) { - return [NSError errorWithDomain:@"profiles" code:code userInfo:@{NSLocalizedDescriptionKey: msg}]; -} - - -NSArray* fetchAppProfiles(AdapterHandle* adapter, RsdHandshakeHandle* handshake, NSError** error) { - MisagentClientHandle *misagentHandle = NULL; - IdeviceFfiError *err = misagent_connect_rsd(adapter, handshake, &misagentHandle); - if (err) { - *error = makeError(err->code, @(err->message)); - idevice_error_free(err); - return nil; - } - - uint8_t **profileArr = NULL; - size_t profileCount = 0; - size_t *profileLengthArr = NULL; - err = misagent_copy_all(misagentHandle, &profileArr, &profileLengthArr, &profileCount); - - if (err) { - *error = makeError((err)->code, @((err)->message)); - misagent_client_free(misagentHandle); - idevice_error_free(err); - return nil; - } - - NSMutableArray* ans = [NSMutableArray array]; - for(int i = 0; i < profileCount; ++i) { - size_t len = profileLengthArr[i]; - uint8_t* profile = profileArr[i]; - NSData* profileData = [NSData dataWithBytes:profile length:len]; - - [ans addObject:profileData]; - } - - misagent_free_profiles(profileArr, profileLengthArr, profileCount); - misagent_client_free(misagentHandle); - - return ans; -} - -bool removeProfile(AdapterHandle* adapter, RsdHandshakeHandle* handshake, NSString* uuid, NSError** error) { - MisagentClientHandle *misagentHandle = NULL; - IdeviceFfiError * err = misagent_connect_rsd(adapter, handshake, &misagentHandle); - if (err) { - *error = makeError(err->code, @(err->message)); - idevice_error_free(err); - return false; - } - - err = misagent_remove(misagentHandle, [uuid UTF8String]); - if (err) { - *error = makeError((err)->code, @((err)->message)); - misagent_client_free(misagentHandle); - idevice_error_free(err); - return false; - } - - misagent_client_free(misagentHandle); - return true; -} - -bool addProfile(AdapterHandle* adapter, RsdHandshakeHandle* handshake, NSData* profile, NSError** error) { - MisagentClientHandle *misagentHandle = NULL; - IdeviceFfiError * err = misagent_connect_rsd(adapter, handshake, &misagentHandle); - if (err) { - *error = makeError(err->code, @(err->message)); - idevice_error_free(err); - return false; - } - - err = misagent_install(misagentHandle, [profile bytes], [profile length]); - if (err) { - *error = makeError((err)->code, @((err)->message)); - misagent_client_free(misagentHandle); - idevice_error_free(err); - return false; - } - - misagent_client_free(misagentHandle); - return true; -} - -@implementation CMSDecoderHelper - -+ (NSData*)decodeCMSData:(NSData *)cmsData -// outCerts:(NSArray * _Nullable * _Nullable)outCerts - error:(NSError * _Nullable * _Nullable)error -{ - if (!cmsData || cmsData.length == 0) { - if (error) { - *error = [NSError errorWithDomain:NSCocoaErrorDomain - code:NSURLErrorBadURL - userInfo:@{NSLocalizedDescriptionKey: @"Invalid or empty CMS payload"}]; - } - return nil; - } - - NSData *xmlStart = [@"" dataUsingEncoding:NSASCIIStringEncoding]; - NSData *binaryMagic = [@"bplist00" dataUsingEncoding:NSASCIIStringEncoding]; - - NSRange startRange = [cmsData rangeOfData:xmlStart options:0 range:NSMakeRange(0, cmsData.length)]; - if (startRange.location != NSNotFound) { - NSRange endSearchRange = NSMakeRange(startRange.location, cmsData.length - startRange.location); - NSRange endRange = [cmsData rangeOfData:plistEnd options:0 range:endSearchRange]; - if (endRange.location != NSNotFound) { - NSUInteger plistEndIndex = NSMaxRange(endRange); - if (plistEndIndex > startRange.location && plistEndIndex <= cmsData.length) { - return [cmsData subdataWithRange:NSMakeRange(startRange.location, plistEndIndex - startRange.location)]; - } - } - } - - NSRange binaryRange = [cmsData rangeOfData:binaryMagic options:0 range:NSMakeRange(0, cmsData.length)]; - if (binaryRange.location != NSNotFound) { - return [cmsData subdataWithRange:NSMakeRange(binaryRange.location, cmsData.length - binaryRange.location)]; - } - - if (error) { - *error = [NSError errorWithDomain:NSCocoaErrorDomain - code:NSFileReadUnknownError - userInfo:@{NSLocalizedDescriptionKey: @"Unable to extract plist from CMS payload"}]; - } - return nil; -} - -@end - -@implementation JITEnableContext(Profile) - -- (NSArray*)fetchAllProfiles:(NSError **)error { - [self ensureTunnelWithError:error]; - if(*error) { - return nil; - } - - return fetchAppProfiles(adapter, handshake, error); -} - -- (BOOL)removeProfileWithUUID:(NSString*)uuid error:(NSError **)error { - [self ensureTunnelWithError:error]; - if(*error) { - return NO; - } - - return removeProfile(adapter, handshake, uuid, error); -} - -- (BOOL)addProfile:(NSData*)profile error:(NSError **)error { - [self ensureTunnelWithError:error]; - if(*error) { - return NO; - } - return addProfile(adapter, handshake, profile, error); -} - - -@end diff --git a/StikJIT/idevice/syslog.m b/StikJIT/idevice/syslog.m deleted file mode 100644 index b4b0dca3..00000000 --- a/StikJIT/idevice/syslog.m +++ /dev/null @@ -1,125 +0,0 @@ -// -// syslog.m -// StikDebug -// -// Created by s s on 2025/12/12. -// - -#import "JITEnableContext.h" -#import "JITEnableContextInternal.h" - -@implementation JITEnableContext(Syslog) - -- (void)startSyslogRelayWithHandler:(SyslogLineHandler)lineHandler - onError:(SyslogErrorHandler)errorHandler -{ - NSError* error = nil; - [self ensureTunnelWithError:&error]; - if(error) { - errorHandler(error); - return; - } - if (!lineHandler || syslogStreaming) { - return; - } - - syslogStreaming = YES; - syslogLineHandler = [lineHandler copy]; - syslogErrorHandler = [errorHandler copy]; - - __weak typeof(self) weakSelf = self; - dispatch_async(syslogQueue, ^{ - __strong typeof(self) strongSelf = weakSelf; - if (!strongSelf) { return; } - - SyslogRelayClientHandle *client = NULL; - IdeviceFfiError *err = syslog_relay_connect_rsd(strongSelf->adapter, strongSelf->handshake, &client); - if (err != NULL) { - NSString *message = err->message ? [NSString stringWithCString:err->message encoding:NSASCIIStringEncoding] : @"Failed to connect to syslog relay"; - NSError *nsError = [strongSelf errorWithStr:message code:err->code]; - idevice_error_free(err); - [strongSelf handleSyslogFailure:nsError]; - return; - } - - strongSelf->syslogClient = client; - - while (strongSelf && strongSelf->syslogStreaming) { - char *message = NULL; - IdeviceFfiError *nextErr = syslog_relay_next(client, &message); - if (nextErr != NULL) { - NSString *errMsg = nextErr->message ? [NSString stringWithCString:nextErr->message encoding:NSASCIIStringEncoding] : @"Syslog relay read failed"; - NSError *nsError = [strongSelf errorWithStr:errMsg code:nextErr->code]; - idevice_error_free(nextErr); - if (message) { idevice_string_free(message); } - [strongSelf handleSyslogFailure:nsError]; - client = NULL; - break; - } - - if (!message) { - continue; - } - - NSString *line = [NSString stringWithCString:message encoding:NSUTF8StringEncoding]; - idevice_string_free(message); - if (!line || !strongSelf->syslogLineHandler) { - continue; - } - - SyslogLineHandler handlerCopy = strongSelf->syslogLineHandler; - if (handlerCopy) { - dispatch_async(dispatch_get_main_queue(), ^{ - handlerCopy(line); - }); - } - } - - if (client) { - syslog_relay_client_free(client); - } - - strongSelf->syslogClient = NULL; - strongSelf->syslogStreaming = NO; - strongSelf->syslogLineHandler = nil; - strongSelf->syslogErrorHandler = nil; - }); -} - -- (void)stopSyslogRelay { - if (!syslogStreaming) { - return; - } - - syslogStreaming = NO; - syslogLineHandler = nil; - syslogErrorHandler = nil; - - dispatch_async(syslogQueue, ^{ - if (self->syslogClient) { - syslog_relay_client_free(self->syslogClient); - self->syslogClient = NULL; - } - }); -} - -- (void)handleSyslogFailure:(NSError *)error { - syslogStreaming = NO; - if (syslogClient) { - syslog_relay_client_free(syslogClient); - syslogClient = NULL; - } - SyslogErrorHandler errorCopy = syslogErrorHandler; - syslogLineHandler = nil; - syslogErrorHandler = nil; - - if (errorCopy) { - dispatch_async(dispatch_get_main_queue(), ^{ - errorCopy(error); - }); - } -} - - - -@end