mirror of
https://github.com/StikDebug/StikDebug.git
synced 2026-10-16 04:20:30 +00:00
(WIP) Rewrite parts of the app in Swift and fix iOS 26.4 issues (#358)
* Rewrite parts of the app in Swift and fix iOS 26.4 issues (WIP) * Add more PID stuff * Improve location simulation * whoops
This commit is contained in:
parent
789502aeeb
commit
5db5f49117
43 changed files with 3559 additions and 2582 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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<CChar>? = nil;
|
||||
let lockdownHandle = await MainActor.run { self.lockdownHandle }
|
||||
var cXml: UnsafeMutablePointer<CChar>?
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
805
StikJIT/Utilities/IdeviceFFIBridge.swift
Normal file
805
StikJIT/Utilities/IdeviceFFIBridge.swift
Normal file
|
|
@ -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<CChar>?) -> String? {
|
||||
guard let cString else { return nil }
|
||||
return String(validatingUTF8: cString)
|
||||
}
|
||||
|
||||
static func consumeFFIError(
|
||||
_ ffiError: UnsafeMutablePointer<IdeviceFfiError>?,
|
||||
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<T>(
|
||||
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<OpaquePointer?>) -> UnsafeMutablePointer<IdeviceFfiError>?
|
||||
) 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<T>(
|
||||
fallback: String,
|
||||
missingClientMessage: String,
|
||||
domain: String = "StikDebug",
|
||||
connect: (UnsafeMutablePointer<OpaquePointer?>) -> UnsafeMutablePointer<IdeviceFfiError>?,
|
||||
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..<count {
|
||||
plist_free(apps[index])
|
||||
}
|
||||
idevice_data_free(
|
||||
rawApps.assumingMemoryBound(to: UInt8.self),
|
||||
UInt(count * MemoryLayout<plist_t?>.stride)
|
||||
)
|
||||
}
|
||||
|
||||
var dictionaries: [[String: Any]] = []
|
||||
dictionaries.reserveCapacity(count)
|
||||
|
||||
for index in 0..<count {
|
||||
var binaryPlist: UnsafeMutablePointer<CChar>?
|
||||
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<plist_t?>?
|
||||
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..<deviceCount {
|
||||
plist_free(devices[index])
|
||||
}
|
||||
idevice_data_free(
|
||||
UnsafeMutableRawPointer(devices).assumingMemoryBound(to: UInt8.self),
|
||||
UInt(deviceCount * MemoryLayout<plist_t?>.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<UnsafeMutablePointer<UInt8>?>?
|
||||
var profileLengths: UnsafeMutablePointer<Int>?
|
||||
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..<profileCount {
|
||||
guard let bytes = profilePointers[index] else { continue }
|
||||
result.append(Data(bytes: bytes, count: profileLengths[index]))
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func removeProfile(withUUID uuid: String) throws {
|
||||
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
|
||||
if let ffiError = misagent_remove(misagentClient, uuid) {
|
||||
throw IdeviceBridge.consumeFFIError(
|
||||
ffiError,
|
||||
fallback: "Failed to remove provisioning profile",
|
||||
domain: "profiles"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func addProfile(_ profile: Data) throws {
|
||||
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
|
||||
let ffiError = profile.withUnsafeBytes { rawBuffer in
|
||||
misagent_install(
|
||||
misagentClient,
|
||||
rawBuffer.bindMemory(to: UInt8.self).baseAddress,
|
||||
profile.count
|
||||
)
|
||||
}
|
||||
|
||||
if let ffiError {
|
||||
throw IdeviceBridge.consumeFFIError(
|
||||
ffiError,
|
||||
fallback: "Failed to add provisioning profile",
|
||||
domain: "profiles"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func fetchProcessList() throws -> [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<ProcessTokenC>?
|
||||
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..<Int(count) {
|
||||
let process = processes[index]
|
||||
var dictionary: [String: Any] = ["pid": NSNumber(value: process.pid)]
|
||||
if let executableURL = IdeviceBridge.string(from: process.executable_url) {
|
||||
dictionary["path"] = executableURL
|
||||
}
|
||||
result.append(dictionary as NSDictionary)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func sendSignal(_ signal: Int32, toProcessWithPID pid: Int32) throws {
|
||||
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 response: UnsafeMutablePointer<SignalResponseC>?
|
||||
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<CChar>? {
|
||||
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<CChar>?
|
||||
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("<?xml".utf8)
|
||||
let plistEnd = Data("</plist>".utf8)
|
||||
let binaryMagic = Data("bplist00".utf8)
|
||||
|
||||
if let startRange = cmsData.range(of: xmlStart),
|
||||
let endRange = cmsData.range(of: plistEnd, options: [], in: startRange.lowerBound..<cmsData.endIndex) {
|
||||
return cmsData[startRange.lowerBound..<endRange.upperBound]
|
||||
}
|
||||
|
||||
if let binaryRange = cmsData.range(of: binaryMagic) {
|
||||
return cmsData[binaryRange.lowerBound..<cmsData.endIndex]
|
||||
}
|
||||
|
||||
throw IdeviceBridge.makeError(
|
||||
domain: NSCocoaErrorDomain,
|
||||
code: NSFileReadUnknownError,
|
||||
message: "Unable to extract plist from CMS payload"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private enum LocationSimulationStatus {
|
||||
static let ok: Int32 = 0
|
||||
static let invalidIP: Int32 = 1
|
||||
static let pairingRead: Int32 = 2
|
||||
static let providerCreate: Int32 = 3
|
||||
static let remoteServer: Int32 = 9
|
||||
static let locationSimulation: Int32 = 10
|
||||
static let locationSet: Int32 = 11
|
||||
static let locationClear: Int32 = 12
|
||||
}
|
||||
|
||||
private enum LocationSimulationState {
|
||||
static var adapter: OpaquePointer?
|
||||
static var handshake: OpaquePointer?
|
||||
static var remoteServer: OpaquePointer?
|
||||
static var locationSimulation: OpaquePointer?
|
||||
|
||||
static func cleanup() {
|
||||
if let locationSimulation {
|
||||
location_simulation_free(locationSimulation)
|
||||
self.locationSimulation = nil
|
||||
}
|
||||
if let remoteServer {
|
||||
remote_server_free(remoteServer)
|
||||
self.remoteServer = nil
|
||||
}
|
||||
if let handshake {
|
||||
rsd_handshake_free(handshake)
|
||||
self.handshake = nil
|
||||
}
|
||||
if let adapter {
|
||||
adapter_free(adapter)
|
||||
self.adapter = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func simulate_location(_ deviceIP: String, _ latitude: Double, _ longitude: Double, _ pairingFile: String) -> Int32 {
|
||||
if let locationSimulation = LocationSimulationState.locationSimulation {
|
||||
if let ffiError = location_simulation_set(locationSimulation, latitude, longitude) {
|
||||
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<sockaddr_in>.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
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
771
StikJIT/Utilities/JITEnableContext.swift
Normal file
771
StikJIT/Utilities/JITEnableContext.swift
Normal file
|
|
@ -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<CChar>?, fallback: String) -> String {
|
||||
guard let cString, let string = String(validatingUTF8: cString) else {
|
||||
return fallback
|
||||
}
|
||||
return string
|
||||
}
|
||||
|
||||
private func error(from ffiError: UnsafeMutablePointer<IdeviceFfiError>?, 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<sockaddr_in>.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<T>(
|
||||
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<IdeviceFfiError>?, 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<IdeviceFfiError>?, 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<T>(
|
||||
_ 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<T>(
|
||||
_ 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<T>(
|
||||
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<CChar>?
|
||||
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) ?? "<nil>"
|
||||
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) ?? "<nil>"
|
||||
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<CChar>?
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
//
|
||||
// ProcessInspectorBridge.h
|
||||
// StikJIT
|
||||
//
|
||||
|
||||
#import "../idevice/JITEnableContext.h"
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
FOUNDATION_EXPORT NSArray<NSDictionary*> * _Nullable FetchDeviceProcessList(NSError **error);
|
||||
FOUNDATION_EXPORT BOOL KillDeviceProcess(int pid, NSError **error);
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
|
|
@ -1,34 +0,0 @@
|
|||
//
|
||||
// ProcessInspectorBridge.m
|
||||
// StikJIT
|
||||
//
|
||||
|
||||
#import "ProcessInspectorBridge.h"
|
||||
|
||||
NSArray<NSDictionary*> * _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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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<Unmanaged<CFError>?>?
|
||||
) -> 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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue