// // ContentView.swift // StikJIT // // Created by Stephen on 3/26/25. // import SwiftUI import UIKit struct JITEnableConfiguration { var bundleID: String? = nil var pid : Int? = nil var scriptData: Data? = nil 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 @AppStorage("bundleID") private var bundleID: String = "" @State private var isProcessing = false @State private var viewDidAppeared = false @State private var pendingJITEnableConfiguration : JITEnableConfiguration? = nil @State private var isShowingPairingFilePicker = false @State private var debugFeedback: DebugFeedback? @State var scriptViewShow = false @State private var isShowingConsole = false @AppStorage(UserDefaults.Keys.defaultScriptName) var selectedScript = UserDefaults.Keys.defaultScriptNameValue @State var jsModel: RunJSViewModel? @ObservedObject private var mounting = MountingProgress.shared let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect() private struct DebugFeedback: Identifiable { let id = UUID() let message: String let isError: Bool let isWorking: Bool } var body: some View { InstalledAppsListView(onSelectApp: { selectedBundle, selectedName in bundleID = selectedBundle HapticFeedbackHelper.trigger() startJITInBackground(bundleID: selectedBundle, displayName: selectedName) }, showDoneButton: false, onImportPairingFile: { isShowingPairingFilePicker = true }) .overlay(alignment: .bottom) { if let debugFeedback { debugFeedbackView(debugFeedback) .padding(.bottom, 40) .transition(.move(edge: .bottom).combined(with: .opacity)) } } .onAppear { startTunnelInBackground() MountingProgress.shared.checkforMounted() viewDidAppeared = true if let config = pendingJITEnableConfiguration { startJITInBackground(bundleID: config.bundleID, pid: config.pid, scriptData: config.scriptData, scriptName: config.scriptName, triggeredByURLScheme: true) pendingJITEnableConfiguration = nil } } .onReceive(NotificationCenter.default.publisher(for: .intentJSScriptReady)) { notification in guard let model = notification.userInfo?["model"] as? RunJSViewModel else { return } jsModel = model if let name = notification.userInfo?["scriptName"] as? String { selectedScript = name } scriptViewShow = true } .onReceive(timer) { _ in if mounting.mountingThread == nil && !mounting.coolisMounted { MountingProgress.shared.checkforMounted() } } .onOpenURL { url in guard let host = url.host()?.lowercased() else { return } let components = URLComponents(url: url, resolvingAgainstBaseURL: false) switch host { case "enable-jit": var config = JITEnableConfiguration() if let pidStr = queryValue(["pid"], in: components), let pid = Int(pidStr) { config.pid = pid } if let bundleId = queryValue(["bundle-id", "bundleID", "bundle_id", "bundleId"], in: components) { config.bundleID = bundleId } if let scriptBase64URL = queryValue(["script-data", "scriptData", "script_data"], in: components)?.removingPercentEncoding { let base64 = base64URLToBase64(scriptBase64URL) if let scriptData = Data(base64Encoded: base64) { config.scriptData = scriptData } } if let scriptName = queryValue(["script-name", "scriptName", "script_name"], in: components) { config.scriptName = scriptName } if config.scriptData == nil, let bundleID = config.bundleID, let scriptInfo = ScriptStore.preferredScript(for: bundleID) { config.scriptData = scriptInfo.data config.scriptName = scriptInfo.name } if viewDidAppeared { startJITInBackground(bundleID: config.bundleID, pid: config.pid, scriptData: config.scriptData, scriptName: config.scriptName, triggeredByURLScheme: true) } else { pendingJITEnableConfiguration = config } case "kill-process": if let pidStr = queryValue(["pid"], in: components), let pid = Int(pidStr) { pubTunnelConnected = false startTunnelInBackground(showErrorUI: false) DispatchQueue.global(qos: .userInitiated).async { sleep(1) do { try JITEnableContext.shared.killProcess(withPID: Int32(pid)) DispatchQueue.main.async { LogManager.shared.addInfoLog("Killed process \(pid) via URL scheme") } } catch { DispatchQueue.main.async { LogManager.shared.addErrorLog("Failed to kill process \(pid): \(error.localizedDescription)") } } } } case "launch-app": if let bundleId = queryValue(["bundle-id", "bundleID", "bundle_id", "bundleId"], in: components) { HapticFeedbackHelper.trigger() DispatchQueue.global(qos: .userInitiated).async { let _ = JITEnableContext.shared.launchAppWithoutDebug(bundleId, logger: nil) } } default: break } } .fileImporter(isPresented: $isShowingPairingFilePicker, allowedContentTypes: PairingFileStore.supportedContentTypes) { result in switch result { case .success(let url): let fileManager = FileManager.default 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)") } case .failure(let error): print("Failed to import pairing file: \(error)") } } .sheet(isPresented: $isShowingConsole) { NavigationStack { ConsoleLogsView() .toolbar { ToolbarItem(placement: .cancellationAction) { Button("Close".localized) { isShowingConsole = false } } } } } .sheet(isPresented: $scriptViewShow) { NavigationStack { if let jsModel { RunJSView(model: jsModel) .toolbar { ToolbarItem(placement: .topBarTrailing) { Button("Done".localized) { scriptViewShow = false } } } .navigationTitle(selectedScript) .navigationBarTitleDisplayMode(.inline) } } } } private func queryValue(_ names: [String], in components: URLComponents?) -> String? { guard let queryItems = components?.queryItems else { return nil } for name in names { if let rawValue = queryItems.first(where: { $0.name.caseInsensitiveCompare(name) == .orderedSame })?.value { let value = rawValue.trimmingCharacters(in: .whitespacesAndNewlines) if !value.isEmpty { return value } } } return nil } private func debugFeedbackView(_ feedback: DebugFeedback) -> some View { HStack(spacing: 10) { if feedback.isWorking { ProgressView() .controlSize(.small) } Text(feedback.message) .font(.subheadline.weight(.semibold)) .multilineTextAlignment(.center) } .padding(.horizontal, 16) .padding(.vertical, 10) .background(Capsule().fill(.ultraThinMaterial)) .foregroundStyle(feedback.isError ? .red : .primary) .shadow(radius: 4) .padding(.horizontal, 20) .accessibilityElement(children: .ignore) .accessibilityLabel(feedback.message) } private func getJsCallback(_ script: Data, name: String? = nil) -> DebugAppCallback { return { pid, debugProxyHandle, remoteServerHandle, semaphore in let model = RunJSViewModel(pid: Int(pid), debugProxy: debugProxyHandle, remoteServer: remoteServerHandle, semaphore: semaphore) DispatchQueue.main.async { jsModel = model scriptViewShow = 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) } } } } private func startJITInBackground(bundleID: String? = nil, pid: Int? = nil, scriptData: Data? = nil, scriptName: String? = nil, triggeredByURLScheme: Bool = false, displayName: String? = nil) { isProcessing = true let targetName = displayName ?? bundleID ?? pid.map { String(format: "process %d".localized, $0) } ?? "app".localized let startingMessage = String(format: "Starting JIT for %@".localized, targetName) LogManager.shared.addInfoLog("Starting Debug for \(bundleID ?? String(pid ?? 0))") withAnimation { debugFeedback = DebugFeedback(message: startingMessage, isError: false, isWorking: true) } AccessibilityAnnouncer.announce(startingMessage) if triggeredByURLScheme { pubTunnelConnected = false startTunnelInBackground(showErrorUI: false) } DispatchQueue.global(qos: .background).async { let keepAliveLease = DebugKeepAliveLease() defer { keepAliveLease.invalidate() } if triggeredByURLScheme { sleep(1) } let finishProcessing: (Bool, String?) -> Void = { success, detail in DispatchQueue.main.async { isProcessing = false let message = success ? String(format: "JIT request completed for %@".localized, targetName) : String(format: "JIT failed for %@".localized, targetName) let feedback = DebugFeedback(message: message, isError: !success, isWorking: false) withAnimation { debugFeedback = feedback } AccessibilityAnnouncer.announce(message) DispatchQueue.main.asyncAfter(deadline: .now() + 3.0) { if debugFeedback?.id == feedback.id { withAnimation { debugFeedback = nil } } } if !success { let failureMessage = detail ?? "StikDebug could not launch or attach to the selected app. Check that the VPN is enabled, the pairing file is current, and the app is still installed.".localized showAlert(title: "Failed to Enable JIT".localized, message: failureMessage, showOk: true) } } } var scriptData = scriptData var scriptName = scriptName if scriptData == nil, let bundleID, let preferred = ScriptStore.preferredScript(for: bundleID) { scriptName = preferred.name scriptData = preferred.data } var callback: DebugAppCallback? = nil if ProcessInfo.processInfo.hasTXM, let sd = scriptData { callback = getJsCallback(sd, name: scriptName ?? bundleID ?? "Script") } var lastDebugMessage: String? let logger: LogFunc = { message in if let message { lastDebugMessage = message LogManager.shared.addInfoLog(message) } } var success: Bool if let pid { success = JITEnableContext.shared.debugApp(withPID: Int32(pid), logger: logger, jsCallback: callback) } else if let bundleID { success = JITEnableContext.shared.debugApp(withBundleID: bundleID, logger: logger, jsCallback: callback) } else { lastDebugMessage = "Either bundle ID or PID should be specified.".localized success = false } if success { DispatchQueue.main.async { LogManager.shared.addInfoLog("Debug process completed for \(bundleID ?? String(pid ?? 0))") if doAutoQuitAfterEnablingJIT { exit(0) } } } finishProcessing(success, success ? nil : lastDebugMessage) } } private func base64URLToBase64(_ base64url: String) -> String { var base64 = base64url.replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/") let pad = 4 - (base64.count % 4) if pad < 4 { base64 += String(repeating: "=", count: pad) } return base64 } } #Preview { HomeView() } public extension ProcessInfo { var hasTXM: Bool { if isTXMOverridden { return true } return ProcessInfo.hasTXMSupport( operatingSystemVersion: operatingSystemVersion, localTXMDetector: ProcessInfo.detectLocalTXM ) } var isTXMOverridden: Bool { UserDefaults.standard.bool(forKey: UserDefaults.Keys.txmOverride) } static func hasTXMSupport( operatingSystemVersion: OperatingSystemVersion, localTXMDetector: () -> Bool ) -> Bool { guard operatingSystemVersion.majorVersion >= 26 else { return false } return localTXMDetector() } private static func detectLocalTXM() -> Bool { if let boot = FileManager.default.filePath(atPath: "/System/Volumes/Preboot", withLength: 36), let file = FileManager.default.filePath(atPath: "\(boot)/boot", withLength: 96) { return access("\(file)/usr/standalone/firmware/FUD/Ap,TrustedExecutionMonitor.img4", F_OK) == 0 } else { return (FileManager.default.filePath(atPath: "/private/preboot", withLength: 96).map { access("\($0)/usr/standalone/firmware/FUD/Ap,TrustedExecutionMonitor.img4", F_OK) == 0 }) ?? false } } }