// // EnableJIT.swift // AltPackage // // Created by Riley Testut on 8/29/23. // Copyright © 2023 Riley Testut. All rights reserved. // import Foundation import OSLog import RegexBuilder import ArgumentParser struct EnableJIT: PythonCommand { static let configuration = CommandConfiguration(commandName: "enable", abstract: "Enable JIT for a specific app on your device.") @Argument(help: "The name or PID of the app to enable JIT for.", transform: AppProcess.init) var process: AppProcess @Option(help: "Your iOS device's UDID.") var udid: String @Option(name: .shortAndLong, help: "Number of seconds to wait when connecting to an iOS device before operation is cancelled.") var timeout: TimeInterval = 90.0 // PythonCommand var pythonPath: String? mutating func run() async throws { // Use local variables to fix "escaping autoclosure captures mutating self parameter" compiler error. let process = self.process let udid = self.udid do { do { Logger.main.info("Enabling JIT for \(process, privacy: .private(mask: .hash)) on device \(udid, privacy: .private(mask: .hash))...") try await self.prepare() let rsdTunnel = try await self.startRSDTunnel() defer { rsdTunnel.process.terminate() } print("Connected to device \(self.udid)!", rsdTunnel) let port = try await self.startDebugServer(rsdTunnel: rsdTunnel) print("Started debugserver on port \(port).") print("Attaching debugger...") let lldb = try await self.attachDebugger(ipAddress: rsdTunnel.ipAddress, port: port) defer { lldb.terminate() } print("Attached debugger to \(process).") try await self.detachDebugger(lldb) print("Detached debugger from \(process).") print("✅ Successfully enabled JIT for \(process) on device \(udid)!") } catch let error as ProcessError { if let output = error.output { print(output) } throw error } } catch { print("❌ Unable to enable JIT for \(process) on device \(udid).") print(error.localizedDescription) Logger.main.error("Failed to enable JIT for \(process, privacy: .private(mask: .hash)) on device \(udid, privacy: .private(mask: .hash)). \(error, privacy: .public)") throw ExitCode.failure } } } private extension EnableJIT { func startRSDTunnel() async throws -> RemoteServiceDiscoveryTunnel { do { Logger.main.info("Starting RSD tunnel with timeout: \(self.timeout)") let process = try Process.launch(.python3, arguments: ["-u", "-m", "pymobiledevice3", "remote", "start-quic-tunnel", "--udid", self.udid], environment: self.processEnvironment) do { let rsdTunnel = try await withTimeout(seconds: self.timeout) { let regex = Regex { "--rsd" OneOrMore(.whitespace) Capture { OneOrMore(.anyGraphemeCluster) } OneOrMore(.whitespace) TryCapture { OneOrMore(.digit) } transform: { match in Int(match) } } for try await line in process.outputLines { if let match = line.firstMatch(of: regex) { let rsdTunnel = RemoteServiceDiscoveryTunnel(ipAddress: String(match.1), port: match.2, process: process) return rsdTunnel } } throw ProcessError.unexpectedOutput(executableURL: .python3, output: process.output) } // MUST close standardOutput in order to stream output later. process.stopOutput() return rsdTunnel } catch is TimedOutError { process.terminate() let error = ProcessError.timedOut(executableURL: .python3, output: process.output) throw error } catch { process.terminate() throw error } } catch let error as NSError { let localizedFailure = NSLocalizedString("Could not connect to device \(self.udid).", comment: "") throw error.withLocalizedFailure(localizedFailure) } } func startDebugServer(rsdTunnel: RemoteServiceDiscoveryTunnel) async throws -> Int { do { Logger.main.info("Starting debugserver with timeout: \(self.timeout)") return try await withTimeout(seconds: self.timeout) { let arguments = ["-u", "-m", "pymobiledevice3", "developer", "debugserver", "start-server"] + rsdTunnel.commandArguments let output = try await Process.launchAndWait(.python3, arguments: arguments, environment: self.processEnvironment) let port = Reference(Int.self) let regex = Regex { "connect://" OneOrMore(.anyGraphemeCluster, .eager) ":" TryCapture(as: port) { OneOrMore(.digit) } transform: { match in Int(match) } } if let match = output.firstMatch(of: regex) { return match[port] } throw ProcessError.unexpectedOutput(executableURL: .python3, output: output) } } catch let error as NSError { let localizedFailure = NSLocalizedString("Could not start debugserver on device \(self.udid).", comment: "") throw error.withLocalizedFailure(localizedFailure) } } func attachDebugger(ipAddress: String, port: Int) async throws -> Process { do { Logger.main.info("Attaching debugger...") let processID: Int switch self.process { case .pid(let pid): processID = pid case .name(let name): guard let pid = try await self.getPID(for: name) else { throw JITError.processNotRunning(self.process) } processID = pid } let process = try Process.launch(.lldb, environment: self.processEnvironment) do { try await withThrowingTaskGroup(of: Void.self) { taskGroup in // // Throw error if program terminates. // taskGroup.addTask { // try await withCheckedThrowingContinuation { continuation in // process.terminationHandler = { process in // Task { // // Should NEVER be called unless an error occurs. // continuation.resume(throwing: ProcessError.terminated(executableURL: .lldb, exitCode: process.terminationStatus, output: process.output)) // } // } // } // } taskGroup.addTask { do { try await self.sendDebuggerCommand("platform select remote-ios", to: process, timeout: 5) { ChoiceOf { "SDK Roots:" "unable to locate SDK" } } let ipAddress = "[\(ipAddress)]" let connectCommand = "process connect connect://\(ipAddress):\(port)" try await self.sendDebuggerCommand(connectCommand, to: process, timeout: 10) try await self.sendDebuggerCommand("settings set target.memory-module-load-level minimal", to: process, timeout: 5) let attachCommand = "attach -p \(processID)" let failureMessage = "attach failed" let output = try await self.sendDebuggerCommand(attachCommand, to: process, timeout: 120) { ChoiceOf { failureMessage Regex { "Process " OneOrMore(.digit) " stopped" } } } if output.contains(failureMessage) { throw ProcessError.failed(executableURL: .lldb, exitCode: -1, output: process.output) } } catch is TimedOutError { let error = ProcessError.timedOut(executableURL: .lldb, output: process.output) throw error } } // Wait until first child task returns _ = try await taskGroup.next()! // Cancel remaining tasks taskGroup.cancelAll() } return process } catch { process.terminate() throw error } } catch let error as NSError { let localizedFailure = String(format: NSLocalizedString("Could not attach debugger to %@.", comment: ""), self.process.description) throw error.withLocalizedFailure(localizedFailure) } } func detachDebugger(_ process: Process) async throws { do { Logger.main.info("Detaching debugger...") try await withThrowingTaskGroup(of: Void.self) { taskGroup in // // Throw error if program terminates. // taskGroup.addTask { // try await withCheckedThrowingContinuation { continuation in // process.terminationHandler = { process in // if process.terminationStatus == 0 // { // continuation.resume() // } // else // { // continuation.resume(throwing: ProcessError.terminated(executableURL: .lldb, exitCode: process.terminationStatus, output: process.output)) // } // } // } // } taskGroup.addTask { do { try await self.sendDebuggerCommand("c", to: process, timeout: 10) { "Process " OneOrMore(.digit) " resuming" } try await self.sendDebuggerCommand("detach", to: process, timeout: 10) { "Process " OneOrMore(.digit) " detached" } } catch is TimedOutError { let error = ProcessError.timedOut(executableURL: .lldb, output: process.output) throw error } } // Wait until first child task returns _ = try await taskGroup.next()! // Cancel remaining tasks taskGroup.cancelAll() } } catch let error as NSError { let localizedFailure = NSLocalizedString("Could not detach debugger from \(self.process).", comment: "") throw error.withLocalizedFailure(localizedFailure) } } } private extension EnableJIT { func getPID(for name: String) async throws -> Int? { Logger.main.info("Retrieving PID for \(name, privacy: .private(mask: .hash))...") let arguments = ["-m", "pymobiledevice3", "processes", "pgrep", name, "--udid", self.udid] let output = try await Process.launchAndWait(.python3, arguments: arguments, environment: self.processEnvironment) let regex = Regex { "INFO" OneOrMore(.whitespace) TryCapture { OneOrMore(.digit) } transform: { match in Int(match) } OneOrMore(.whitespace) name } if let match = output.firstMatch(of: regex) { Logger.main.info("\(name, privacy: .private(mask: .hash)) PID is \(match.1)") return match.1 } return nil } @discardableResult func sendDebuggerCommand(_ command: String, to process: Process, timeout: TimeInterval, @RegexComponentBuilder regex: @escaping () -> (some RegexComponent)? = { Optional>.none }) async throws -> String { guard let inputPipe = process.standardInput as? Pipe else { preconditionFailure("`process` must have a Pipe as its standardInput") } defer { inputPipe.fileHandleForWriting.writeabilityHandler = nil } let initialOutput = process.output let data = (command + "\n").data(using: .utf8)! // Will always succeed. Logger.main.info("Sending lldb command: \(command, privacy: .public)") let output = try await withTimeout(seconds: timeout) { // Wait until process is ready to receive input. try await withCheckedThrowingContinuation { continuation in inputPipe.fileHandleForWriting.writeabilityHandler = { fileHandle in inputPipe.fileHandleForWriting.writeabilityHandler = nil let result = Result { try fileHandle.write(contentsOf: data) } continuation.resume(with: result) } } // Wait until we receive at least one line of output. for try await _ in process.outputLines { break } // Keep waiting until output doesn't change. // If regex is provided, we keep waiting until a match is found. var previousOutput = process.output while true { try await Task.sleep(for: .seconds(0.2)) let output = process.output if output == previousOutput { guard let regex = regex() else { // No regex, so break as soon as output stops changing. break } if output.contains(regex) { // Found a match, so exit while loop. break } else { // Output hasn't changed, but regex does not match (yet). continue } } previousOutput = output } return previousOutput } // Subtract initialOutput from output to get just this command's output. let commandOutput = output.replacingOccurrences(of: initialOutput, with: "") return commandOutput } }